Compare commits

...
151 Commits
Author SHA1 Message Date
Craig Raw 146e6cbdc4 improve context menu item text fill on dark theme for default split menu buttons 2026-02-09 13:56:36 +02:00
Craig Raw e4fa0c9356 bump to v2.4.0 2026-02-09 12:42:16 +02:00
Craig Raw 30719c5a7f final address chunk contrast tweaking 2026-02-09 12:04:32 +02:00
Craig Raw a120d08ea1 remove extension-less file associations which are no longer supported by jpackage 2026-02-06 17:22:13 +02:00
Craig Raw b51bd90261 add workaround for jpackage issue where mime-info is not generated on linux 2026-02-06 16:28:33 +02:00
Craig Raw a765105d34 reduce contrast on address chunks 2026-02-06 13:34:06 +02:00
Craig Raw 2cd7d0bdf3 respect configured bitcoin unit on transaction diagram and label 2026-02-06 12:07:02 +02:00
Craig Raw c6c24028d9 followup 2026-02-06 11:18:18 +02:00
Craig Raw efb81555bd add view menu item to toggle address chunk display formatting 2026-02-05 15:38:41 +02:00
Craig Raw b11afdf2bb format display of addresses in tables only when the table is selected 2026-02-05 12:48:23 +02:00
Craig Raw 779a55c494 update readme to reflect java 25 requirement 2026-02-05 12:34:51 +02:00
Craig Raw b06d858794 followup 2026-02-05 12:01:53 +02:00
Craig Raw 306c025cbf clear tool cache, revert to setup-java action for reproducibility 2026-02-05 11:59:09 +02:00
Craig Raw b145bddf07 combine sdkman and setup-java actions 2026-02-05 11:22:24 +02:00
Craig Raw edee72d97d followup 2 2026-02-05 11:05:27 +02:00
Craig Raw b87650a740 followup 2026-02-05 10:58:42 +02:00
Craig Raw 0c9bbc2550 define sdkmanrc and reference it in github action 2026-02-05 10:55:08 +02:00
Craig Raw 088f8e302c upgrade kmp-tor to fix file deletion issue on windows 2026-02-04 16:59:42 +02:00
Craig Raw 4309216ad7 fix file association exception on windows 2026-02-04 14:31:05 +02:00
Craig Raw dffaf806cd remove warnings on gradle test task 2026-02-04 12:57:19 +02:00
Craig Raw 448067db54 upgrade to java 25.0.2 and javafx 25.0.2 2026-02-04 12:18:25 +02:00
Craig Raw 6b498c28c2 format display of addresses in 4 character chunks 2026-02-03 15:11:29 +02:00
Craig Raw 4724dc7700 further minor qr display dialog updates 2026-01-28 15:12:18 +02:00
Craig Raw 883558fd9c updates for java 25 compatibility 2026-01-28 13:47:33 +02:00
Craig Raw 9d576bc45d avoid scanning for legacy addresses on bitbox02 with wallet discovery 2026-01-23 09:34:19 +02:00
Craig Raw 49d807f31b fix trezor change detection on signing 2026-01-22 15:42:25 +02:00
Craig Raw 0c2ee6690f add connected device wallet discovery with configurable number of accounts to scan 2026-01-22 13:09:39 +02:00
Liz LightningandGitHub 85c81adc09 add user agent to coingecko historical rates call 2026-01-21 11:36:46 +02:00
Craig Raw 04de83706e improve qr encoding ui, save previous selection and add raw encoding for tx hex 2026-01-20 14:43:47 +02:00
Craig Raw ab99f1d392 fix handling of non-standard key derivations when writing output descriptors 2026-01-15 10:32:40 +02:00
Craig Raw 53857389b7 use precise fee rates estimate from mempool.space 2026-01-15 09:48:22 +02:00
Craig Raw 98576d40c6 convert from tilepane to gridpane to resolve mnemonic words layout issue 2026-01-14 16:16:54 +02:00
Craig Raw 96f7d4bdb3 add additional fee rate limit check for high fee transactions 2026-01-13 16:30:57 +02:00
Michele BalistreriandGitHub 0c67962730 add support for keycard via smart card interface 2026-01-13 15:11:54 +02:00
Craig Raw 0515d8056b add user agent to coingecko exchange source request 2026-01-13 14:49:25 +02:00
Craig Raw cf15760d34 avoid extraneous error logging on card enumeration 2026-01-13 14:07:52 +02:00
Craig Raw f28f15df66 remove unnecessary lark .gitmodules 2026-01-13 11:18:17 +02:00
Craig Raw 34900d2909 add any missing key path information to psbts once signing wallet is chosen 2026-01-03 14:21:44 +02:00
Craig Raw 430f2ee320 add warning when entering outdated slip132 values into a watch-only wallet of a different script type 2025-12-23 09:03:12 +02:00
Craig Raw 74160ba35f minor trezor v2 fixes 2025-12-20 10:41:35 +02:00
Craig Raw 59d85cdd05 improve thp pairing flow, and add passphrase session support 2025-12-18 13:12:54 +02:00
Craig Raw 0406421918 add trezor safe 7 noise config implementation 2025-12-17 14:21:38 +02:00
Craig Raw 4ddd09fe22 update to macos-15-intel runner 2025-12-11 11:59:52 +02:00
Craig Raw a49edb7c45 add ledger nano gen5 support 2025-12-11 11:08:51 +02:00
Craig Raw 5275cab436 update drongo for bip93 package export 2025-12-09 12:05:55 +02:00
Ian McKenzieandGitHub 4dcd463d92 add codex32 importer to software keystore import workflow 2025-12-09 12:04:35 +02:00
Craig Raw f900f6dcb2 minor ui changes to master private key import 2025-12-04 10:24:45 +02:00
Craig Raw b0ab6c9d42 ensure plugdev is added as a system group 2025-12-03 08:25:23 +02:00
Craig Raw b8a703add2 improve calculation of taproot tweaked keys 2025-12-01 11:38:25 +02:00
Craig Raw 4185a8dced update drongo with bech32 refactor 2025-11-27 07:38:49 +02:00
Craig Raw a608197038 followup to display address for watch-only wallets 2025-11-26 07:07:12 +02:00
Craig Raw b4630043eb update block logo fill for dark theme 2025-11-25 10:38:48 +02:00
Liz LightningandGitHub 6452bf8184 add block.xyz augur fee estimator 2025-11-25 10:32:33 +02:00
Craig Raw 71cb5852b3 always show display address for watch-only wallets 2025-11-25 09:51:13 +02:00
Craig Raw 8efee4eaae pass default derivation to usb device and card import panes 2025-11-25 09:43:43 +02:00
Craig Raw ac044c6f71 update drongo for bip32 tests 2025-11-24 11:44:27 +02:00
Craig Raw d2d45e5432 improve psbt/tx matching and ensure incoming psbt signatures are always verified 2025-11-24 11:18:29 +02:00
Craig Raw c16997eacc handle errors if silent payments psbt validation fails 2025-11-19 16:06:55 +02:00
Craig Raw 21543de007 minor updates to handle psbtv2 as the default internal representation 2025-11-19 11:57:58 +02:00
Craig Raw 6f772cb193 update to support bip375 2025-11-18 10:37:02 +02:00
Craig Raw a4634f6a3d update drongo for keycard wallet models 2025-11-12 16:58:35 +02:00
Michele BalistreriandGitHub a794dafdf9 add keycard shell importers for singlesig and multisig 2025-11-12 16:57:38 +02:00
Craig Raw 9fb19ebe8b add bech32m tests 2025-11-12 11:12:51 +02:00
Craig Raw 46a39225bd update to configurable app name in codesign action for macos 2025-11-11 16:37:23 +02:00
Craig Raw a0f7e2e65d use sparrowwallet action for macos codesigning 2025-11-10 12:50:22 +02:00
Craig Raw 39cb946e08 add frigate to detected electrum server capabilities list 2025-11-10 10:10:23 +02:00
Craig Raw 0894407b0b delete home folder on exit if empty 2025-11-07 08:10:13 +02:00
Craig Raw c76acb8d82 bump to v2.3.2 2025-11-06 13:00:07 +02:00
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
Craig Raw e2fa3df08d restore pre gradle 9 archive task behaviour for file permissions 2025-10-03 10:09:57 +02:00
Craig Raw 6d6ede9abe bump to v2.3.0 2025-10-03 08:08:25 +02:00
Craig Raw cca9ab1056 improve implementation of adding dns payment information from psbt 2025-10-02 14:58:02 +02:00
Craig Raw 9e33861110 revert to javafx 23 due to jpackage launcher link bug 2025-10-01 12:10:39 +02:00
Craig Raw c3d3fd1fda revert to java 22 and javafx 24 due to bug in jpackage launcher linking (jdk-8345810) 2025-10-01 11:52:45 +02:00
Craig Raw ca8553ecb8 revert continuity camera device change as unsupported on macos 13 2025-09-30 12:33:33 +02:00
Craig Raw d23ee8c086 upgrade openpnp-capture to iterate over continuity camera devices on mac 2025-09-30 12:24:22 +02:00
Craig Raw e776a17ad4 upgrade jdbi to remove older caffeine dependency 2025-09-30 09:37:45 +02:00
Craig Raw 480ce1e476 fix deprecation warning 2025-09-29 17:39:48 +02:00
Craig Raw 656cd90b08 upgrade guava and commons-lang3 2025-09-29 14:34:07 +02:00
Craig Raw 8df0777959 upgrade to java 25 and javafx 25 2025-09-29 13:32:56 +02:00
Craig Raw 84566b92e6 remove unnecessary zbar native libraries 2025-09-29 12:39:49 +02:00
Craig Raw 7802510e58 support dns hrns in send to many dialog 2025-09-29 11:53:52 +02:00
Craig Raw efb1eb1051 add initial sending to silent payments support 2025-09-29 08:37:07 +02:00
Craig Raw 6240667478 improve error dialog on payjoin receiver error 2025-09-02 09:49:39 +02:00
Craig Raw 2c27112dad update drongo 2025-08-16 13:03:03 +02:00
Craig Raw 6d53e1ed1d fix bluewallet spelling 2025-08-12 08:09:01 +02:00
Craig Raw e8c5660897 allow transaction diagram input and output labels to expand into available width 2025-08-11 14:03:51 +02:00
Craig Raw bef6c750bd upgrade to gradle 8.14.3 2025-08-07 11:28:27 +02:00
Craig Raw 4ec3603789 fix non bip32 child derivation test 2025-08-07 08:55:19 +02:00
Craig Raw 90c9f9733f display a warning if an output descriptor provided in the wallet settings will be modified for use 2025-08-05 09:28:52 +02:00
Craig Raw 64efcf67d3 display zero byte length witness elements as empty instead of op_0 2025-08-04 13:38:41 +02:00
Craig Raw 385d173948 handle npe connecting to bitcoin core with wallet functionality disabled 2025-08-01 07:44:34 +02:00
Craig Raw d81b868049 add any dns payment instructions from loaded psbts if not already cached 2025-07-31 11:47:43 +02:00
Craig Raw 2ff7a15d1e add padding to writes when connected over tls 2025-07-31 10:34:56 +02:00
Craig Raw f48fa7e23c support zero in pin keypad for onekey classic pin entry 2025-07-29 14:45:30 +02:00
Craig Raw 4632850e1e use improved dnssec validation and handle offline state when resolving bip 353 hrns 2025-07-29 12:52:10 +02:00
Craig Raw 5f62523710 support sending to and displaying bip353 human readable names and include dnssec proof in associated psbts 2025-07-24 14:36:11 +02:00
Craig Raw 9dcf210762 support creating transactions with the minimum relay fee rate configured by the user or set by the connected server 2025-07-17 09:15:59 +02:00
Craig Raw c7e9a0a161 restore coingecko historical rate support by limiting to the last 365 days 2025-07-15 09:06:52 +02:00
Craig Raw fa10714844 followup 2025-07-10 08:40:31 +02:00
Craig Raw 80105aee62 save webcam device unique id instead of name to config 2025-07-10 08:15:00 +02:00
Craig Raw 3c5fa58a16 suppress warnings for jzbar ffm usage 2025-07-09 12:46:57 +02:00
doblon8andGitHub 2a2be2617c replace jni-based zbar wrapper with ffm-based jzbar
* Replace ZBar JNI library implementation with jzbar

* Move ZBar.java to com.sparrowwallet.sparrow.control package

* Move ZBar.java to the com.sparrowwallet.sparrow.io package

* Switch to jzbar from Maven Central and update module/package imports accordingly

* Remove jzbar entry from extraJavaModuleInfo to avoid module patching error
2025-07-09 11:55:51 +02:00
Craig Raw 6c9a0d14cd compare on device unique id when choosing selected camera 2025-07-09 10:58:58 +02:00
Craig Raw f82fcb58bb fix issue of including parent path elements in deterministic key when deriving child xpub from an output descriptor containing more than two child path elements 2025-07-09 10:26:49 +02:00
Craig Raw 5ec3bff6a4 fix jade configuration for signet and regtest networks 2025-07-02 16:45:10 +02:00
Oleg KoretskyandGitHub 134dc826ba do not change coin label unit on right click 2025-07-02 16:32:10 +02:00
Craig Raw cd2a6623a4 fix restart menu options on linux standalone package 2025-07-01 16:07:45 +02:00
Craig Raw 49ab9e40e3 select first matching webcam by name 2025-06-28 16:10:57 +02:00
Craig Raw cec7eac9ac fix selection of nearest supported resolution where chosen resolution is not available 2025-06-24 11:26:13 +02:00
Craig Raw 33e043fd9a include child derivations in output descriptor for bip 129 wallet export 2025-06-24 10:52:52 +02:00
Craig Raw 3aae26b196 bump to v2.2.4 2025-06-10 09:01:37 +02:00
Craig Raw 73d4fd5049 prevent double free when closing capture library 2025-06-09 14:43:06 +02:00
Craig Raw a94380e882 minor specter diy ui tweaks 2025-06-07 11:23:06 +02:00
Craig Raw e4dd4950bf prevent selection of unsupported bip322 format when signing a message with a connected device 2025-06-06 13:07:36 +02:00
Craig Raw 26ce1b3469 derive to maximum bip32 account level where child path in output descriptor contains more than two elements 2025-06-06 11:45:46 +02:00
Craig Raw ebce34f3d1 minor tweaks 2025-06-05 14:23:02 +02:00
Craig Raw f28e00b97e suggest opening the send to many dialog when adding multiple payments on the send tab 2025-06-05 10:31:37 +02:00
Craig Raw 25770c2426 suggest connecting to broadcast a finalized transaction if offlineand a server is configured 2025-06-05 09:40:17 +02:00
Craig Raw 799cac7b1f handle bitkey descriptor export format 2025-06-05 08:28:21 +02:00
Craig Raw c265fd1969 fix cormorant server.version rpc issue 2025-06-04 17:18:31 +02:00
Craig Raw 890f0476b1 introduce delay before closing capture library 2025-06-04 15:23:48 +02:00
Craig Raw 4d93381124 improve electrum server script hash unsubscribe support 2025-06-04 14:52:33 +02:00
Craig Raw 364909cfa3 support nv12 capture pixel format on linux 2025-06-03 12:48:01 +02:00
Craig Raw 38f0068411 detect if electrum server supports scripthash unsubscribe capability 2025-06-03 12:38:03 +02:00
Craig Raw 8885e48ed9 request rgb3 pixel format on linux where returned format is unsupported 2025-06-02 16:28:44 +02:00
Craig Raw 31ce3ce68a further electrum server optimisations 2025-06-02 15:56:46 +02:00
Craig Raw b0d0514617 remove possibility of task queueing in webcam service 2025-06-02 11:36:06 +02:00
Craig Raw d7d23f9b58 always use the master wallet payment code when creating the notification transaction payload on the send tab 2025-06-02 09:41:46 +02:00
Craig Raw 3fdf093a26 use semaphore to ensure last webcam service task has completed before closing stream 2025-05-29 14:17:58 +02:00
Craig Raw 74c298fd93 iterate and remove faulty capture devices on opening qr scan dialog 2025-05-29 13:58:46 +02:00
Craig Raw 4298bfb053 bump to v2.2.3 2025-05-22 14:58:09 +02:00
Craig Raw 231eb13cee retrieve and show next block median fee rate in recent blocks view where available 2025-05-22 13:35:59 +02:00
Craig Raw 52470ee6d8 further electrum server optimization tweaks 2025-05-22 11:59:25 +02:00
Craig Raw 853949675e fix npe configuring recent blocks view on new installs 2025-05-22 08:44:39 +02:00
Craig Raw 098afebbe0 increase recent blocks estimated fee rate update frequency 2025-05-21 15:38:06 +02:00
Craig Raw 63c0a6d6e2 bump to v2.2.2 2025-05-21 13:29:16 +02:00
209 changed files with 7831 additions and 1912 deletions
+28 -14
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@v4
- uses: actions/checkout@v6
with:
submodules: true
- name: Set up JDK 22.0.2
uses: actions/setup-java@v4
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
+56 -60
View File
@@ -1,9 +1,9 @@
plugins {
id 'application'
id 'org-openjfx-javafxplugin'
id 'org.beryx.jlink' version '3.1.1'
id 'org.gradlex.extra-java-module-info' version '1.9'
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()
@@ -19,21 +19,20 @@ if(System.getProperty("os.arch") == "aarch64") {
}
def headless = "true".equals(System.getProperty("java.awt.headless"))
group 'com.sparrowwallet'
version '2.2.1'
group = 'com.sparrowwallet'
version = '2.4.0'
repositories {
mavenCentral()
maven { url 'https://code.sparrowwallet.com/api/packages/sparrowwallet/maven' }
maven { url = uri('https://code.sparrowwallet.com/api/packages/sparrowwallet/maven') }
}
tasks.withType(AbstractArchiveTask) {
preserveFileTimestamps = false
reproducibleFileOrder = true
tasks.withType(AbstractArchiveTask).configureEach {
useFileSystemPermissions()
}
javafx {
version = headless ? "18" : "23.0.2"
version = headless ? "18" : "25.0.2"
modules = [ 'javafx.controls', 'javafx.fxml', 'javafx.swing', 'javafx.graphics' ]
}
@@ -45,20 +44,20 @@ dependencies {
//Any changes to the dependencies must be reflected in the module definitions below!
implementation(project(':drongo'))
implementation(project(':lark'))
implementation('com.google.guava:guava:33.0.0-jre')
implementation('com.google.guava:guava:33.5.0-jre')
implementation('com.google.code.gson:gson:2.9.1')
implementation('com.h2database:h2:2.1.214')
implementation('com.zaxxer:HikariCP:4.0.3') {
exclude group: 'org.slf4j'
}
implementation('org.jdbi:jdbi3-core:3.20.0') {
implementation('org.jdbi:jdbi3-core:3.49.5') {
exclude group: 'org.slf4j'
}
implementation('org.jdbi:jdbi3-sqlobject:3.20.0') {
implementation('org.jdbi:jdbi3-sqlobject:3.49.5') {
exclude group: 'org.slf4j'
}
implementation('org.flywaydb:flyway-core:9.22.3')
implementation('org.fxmisc.richtext:richtextfx:0.10.4')
implementation('org.fxmisc.richtext:richtextfx:0.11.7')
implementation('no.tornado:tornadofx-controls:1.0.4')
implementation('com.google.zxing:javase:3.4.0') {
exclude group: 'com.beust', module: 'jcommander'
@@ -74,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-5')
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'
@@ -100,7 +101,7 @@ dependencies {
implementation('com.sparrowwallet:tern:1.0.6')
implementation('io.reactivex.rxjava2:rxjava:2.2.15')
implementation('io.reactivex.rxjava2:rxjavafx:2.2.2')
implementation('org.apache.commons:commons-lang3:3.7')
implementation('org.apache.commons:commons-lang3:3.19.0')
implementation('org.apache.commons:commons-compress:1.27.1')
implementation('net.sourceforge.streamsupport:streamsupport:1.7.0')
implementation('com.github.librepdf:openpdf:1.3.30')
@@ -109,6 +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.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 {
@@ -141,6 +143,12 @@ application {
mainClass = 'com.sparrowwallet.sparrow.SparrowWallet'
applicationDefaultJvmArgs = ["-XX:+HeapDumpOnOutOfMemoryError",
"--enable-native-access=com.sparrowwallet.drongo",
"--enable-native-access=com.sun.jna",
"--enable-native-access=javafx.graphics",
"--enable-native-access=com.fazecast.jSerialComm",
"--enable-native-access=org.usb4java",
"--enable-native-access=io.github.doblon8.jzbar",
"--add-opens=javafx.graphics/com.sun.javafx.css=org.controlsfx.controls",
"--add-opens=javafx.graphics/javafx.scene=org.controlsfx.controls",
"--add-opens=javafx.controls/com.sun.javafx.scene.control.behavior=org.controlsfx.controls",
@@ -150,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",
@@ -165,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"]
@@ -183,13 +185,21 @@ jlink {
requires 'jdk.crypto.cryptoki'
requires 'java.management'
requires 'io.leangen.geantyref'
requires 'static jdk.jfr'
uses 'org.eclipse.jetty.http.HttpFieldPreEncoder'
}
options = ['--strip-native-commands', '--strip-java-debug-attributes', '--compress', 'zip-6', '--no-header-files', '--no-man-pages', '--ignore-signing-information', '--exclude-files', '**.png', '--exclude-resources', 'glob:/com.sparrowwallet.merged.module/META-INF/*']
launcher {
name = 'sparrow'
jvmArgs = ["--add-opens=javafx.graphics/com.sun.javafx.css=org.controlsfx.controls",
jvmArgs = ["--enable-native-access=com.sparrowwallet.drongo",
"--enable-native-access=com.sun.jna",
"--enable-native-access=javafx.graphics",
"--enable-native-access=com.sparrowwallet.merged.module",
"--enable-native-access=com.fazecast.jSerialComm",
"--enable-native-access=org.usb4java",
"--enable-native-access=io.github.doblon8.jzbar",
"--add-opens=javafx.graphics/com.sun.javafx.css=org.controlsfx.controls",
"--add-opens=javafx.graphics/javafx.scene=org.controlsfx.controls",
"--add-opens=javafx.controls/com.sun.javafx.scene.control.behavior=org.controlsfx.controls",
"--add-opens=javafx.controls/com.sun.javafx.scene.control.inputmap=org.controlsfx.controls",
@@ -198,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",
@@ -221,6 +226,7 @@ jlink {
"--add-reads=com.sparrowwallet.merged.module=org.bouncycastle.pg",
"--add-reads=com.sparrowwallet.merged.module=org.bouncycastle.provider",
"--add-reads=com.sparrowwallet.merged.module=kotlin.stdlib",
"--add-reads=com.sparrowwallet.merged.module=org.reactfx.reactfx",
"--add-reads=kotlin.stdlib=kotlinx.coroutines.core",
"--add-reads=org.flywaydb.core=java.desktop"]
@@ -228,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"]
@@ -241,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']
@@ -260,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"
}
}
@@ -274,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"
}
@@ -308,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()) {
@@ -380,33 +397,12 @@ 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')
}
module('com.google.guava:listenablefuture|empty-to-avoid-conflict-with-guava', 'com.google.guava.listenablefuture')
module('com.google.code.findbugs:jsr305', 'com.google.code.findbugs.jsr305')
module('j2objc-annotations-2.8.jar', 'com.google.j2objc.j2objc.annotations', '2.8')
module('org.jdbi:jdbi3-core', 'org.jdbi.v3.core') {
exports('org.jdbi.v3.core')
exports('org.jdbi.v3.core.mapper')
exports('org.jdbi.v3.core.statement')
exports('org.jdbi.v3.core.result')
exports('org.jdbi.v3.core.h2')
exports('org.jdbi.v3.core.spi')
requires('io.leangen.geantyref')
requires('java.sql')
requires('org.slf4j')
requires('com.github.benmanes.caffeine')
}
module('io.leangen.geantyref:geantyref', 'io.leangen.geantyref') {
exports('io.leangen.geantyref')
}
module('org.fxmisc.richtext:richtextfx', 'org.fxmisc.richtext') {
exports('org.fxmisc.richtext')
exports('org.fxmisc.richtext.event')
@@ -416,10 +412,10 @@ extraJavaModuleInfo {
requires('javafx.graphics')
requires('org.fxmisc.flowless')
requires('org.reactfx.reactfx')
requires('org.fxmisc.undo.undofx')
requires('org.fxmisc.undo')
requires('org.fxmisc.wellbehaved')
}
module('org.fxmisc.undo:undofx', 'org.fxmisc.undo.undofx') {
module('org.fxmisc.undo:undofx', 'org.fxmisc.undo') {
requires('javafx.base')
requires('javafx.controls')
requires('javafx.graphics')
+1 -2
View File
@@ -4,13 +4,12 @@ plugins {
dependencies {
implementation 'com.google.gradle:osdetector-gradle-plugin:1.7.3'
implementation 'org.javamodularity:moduleplugin:1.8.14'
}
repositories {
mavenCentral()
maven {
url "https://plugins.gradle.org/m2/"
url = uri("https://plugins.gradle.org/m2/")
}
}
@@ -32,7 +32,6 @@ package org.openjfx.gradle;
import com.google.gradle.osdetector.OsDetectorPlugin;
import org.gradle.api.Plugin;
import org.gradle.api.Project;
import org.javamodularity.moduleplugin.ModuleSystemPlugin;
import org.openjfx.gradle.tasks.ExecTask;
public class JavaFXPlugin implements Plugin<Project> {
@@ -40,10 +39,9 @@ public class JavaFXPlugin implements Plugin<Project> {
@Override
public void apply(Project project) {
project.getPlugins().apply(OsDetectorPlugin.class);
project.getPlugins().apply(ModuleSystemPlugin.class);
project.getExtensions().create("javafx", JavaFXOptions.class, project);
project.getTasks().create("configJavafxRun", ExecTask.class, project);
project.getTasks().register("configJavafxRun", ExecTask.class, project);
}
}
@@ -33,27 +33,19 @@ import org.gradle.api.DefaultTask;
import org.gradle.api.GradleException;
import org.gradle.api.Project;
import org.gradle.api.file.FileCollection;
import org.gradle.api.logging.Logger;
import org.gradle.api.logging.Logging;
import org.gradle.api.plugins.ApplicationPlugin;
import org.gradle.api.tasks.JavaExec;
import org.gradle.api.tasks.TaskAction;
import org.javamodularity.moduleplugin.extensions.RunModuleOptions;
import org.openjfx.gradle.JavaFXModule;
import org.openjfx.gradle.JavaFXOptions;
import org.openjfx.gradle.JavaFXPlatform;
import javax.inject.Inject;
import java.io.File;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.TreeSet;
public class ExecTask extends DefaultTask {
private static final Logger LOGGER = Logging.getLogger(ExecTask.class);
private final Project project;
private JavaExec execTask;
@@ -78,37 +70,11 @@ public class ExecTask extends DefaultTask {
var definedJavaFXModuleNames = new TreeSet<>(javaFXOptions.getModules());
if (!definedJavaFXModuleNames.isEmpty()) {
RunModuleOptions moduleOptions = execTask.getExtensions().findByType(RunModuleOptions.class);
final FileCollection classpathWithoutJavaFXJars = execTask.getClasspath().filter(
jar -> Arrays.stream(JavaFXModule.values()).noneMatch(javaFXModule -> jar.getName().contains(javaFXModule.getArtifactName()))
);
final FileCollection javaFXPlatformJars = execTask.getClasspath().filter(jar -> isJavaFXJar(jar, javaFXOptions.getPlatform()));
if (moduleOptions != null) {
LOGGER.info("Modular JavaFX application found");
// Remove empty JavaFX jars from classpath
execTask.setClasspath(classpathWithoutJavaFXJars.plus(javaFXPlatformJars));
definedJavaFXModuleNames.forEach(javaFXModule -> moduleOptions.getAddModules().add(javaFXModule));
} else {
LOGGER.info("Non-modular JavaFX application found");
// Remove all JavaFX jars from classpath
execTask.setClasspath(classpathWithoutJavaFXJars);
var javaFXModuleJvmArgs = List.of("--module-path", javaFXPlatformJars.getAsPath());
var jvmArgs = new ArrayList<String>();
jvmArgs.add("--add-modules");
jvmArgs.add(String.join(",", definedJavaFXModuleNames));
List<String> execJvmArgs = execTask.getJvmArgs();
if (execJvmArgs != null) {
jvmArgs.addAll(execJvmArgs);
}
jvmArgs.addAll(javaFXModuleJvmArgs);
execTask.setJvmArgs(jvmArgs);
}
execTask.setClasspath(classpathWithoutJavaFXJars.plus(javaFXPlatformJars));
}
} else {
throw new GradleException("Run task not found. Please, make sure the Application plugin is applied");
+1 -1
View File
@@ -83,7 +83,7 @@ sudo apt install -y rpm fakeroot binutils
First, assign a temporary variable in your shell for the specific release you want to build. For the current one specify:
```shell
GIT_TAG="2.2.0"
GIT_TAG="2.3.1"
```
The project can then be initially cloned as follows:
+1 -1
Submodule drongo updated: abb598d3b0...0a500ea002
Binary file not shown.
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
Vendored
+7 -8
View File
@@ -1,7 +1,7 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -15,6 +15,8 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
@@ -55,7 +57,7 @@
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
@@ -84,7 +86,7 @@ done
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
@@ -112,7 +114,6 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
@@ -170,7 +171,6 @@ fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
@@ -203,15 +203,14 @@ fi
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
Vendored
+13 -12
View File
@@ -13,6 +13,8 @@
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@@ -43,11 +45,11 @@ set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
@@ -57,22 +59,21 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
+1 -1
Submodule lark updated: 5facb25ede...36cf0c85dc
-2
View File
@@ -1,2 +0,0 @@
mime-type=x-scheme-handler/auth47
description=Auth47 Authentication URI
-2
View File
@@ -1,2 +0,0 @@
mime-type=x-scheme-handler/bitcoin
description=Bitcoin Scheme URI
-2
View File
@@ -1,2 +0,0 @@
mime-type=x-scheme-handler/lightning
description=LNURL URI
+1 -1
View File
@@ -26,7 +26,7 @@ case "$1" in
xdg-mime install /opt/sparrowwallet/lib/sparrowwallet-Sparrow-MimeInfo.xml
install -D -m 644 /opt/sparrowwallet/lib/runtime/conf/udev/*.rules /etc/udev/rules.d
if ! getent group plugdev > /dev/null; then
groupadd plugdev
groupadd -r plugdev
fi
if ! groups "${SUDO_USER:-$(whoami)}" | grep -q plugdev; then
usermod -aG plugdev "${SUDO_USER:-$(whoami)}"
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<mime-info xmlns="http://www.freedesktop.org/standards/shared-mime-info">
<mime-type type="application/psbt">
<comment>Partially Signed Bitcoin Transaction</comment>
<glob pattern="*.psbt"/>
</mime-type>
<mime-type type="application/bitcoin-transaction">
<comment>Bitcoin Transaction</comment>
<glob pattern="*.txn"/>
</mime-type>
<mime-type type="application/pgp-signature">
<comment>ASCII Armored File</comment>
<glob pattern="*.asc"/>
</mime-type>
<mime-type type="x-scheme-handler/bitcoin">
<comment>Bitcoin Scheme URI</comment>
</mime-type>
<mime-type type="x-scheme-handler/auth47">
<comment>Auth47 Authentication URI</comment>
</mime-type>
<mime-type type="x-scheme-handler/lightning">
<comment>LNURL URI</comment>
</mime-type>
</mime-info>
@@ -82,7 +82,7 @@ xdg-desktop-menu install /opt/sparrowwallet/lib/sparrowwallet-Sparrow.desktop
xdg-mime install /opt/sparrowwallet/lib/sparrowwallet-Sparrow-MimeInfo.xml
install -D -m 644 /opt/sparrowwallet/lib/runtime/conf/udev/*.rules /etc/udev/rules.d
if ! getent group plugdev > /dev/null; then
groupadd plugdev
groupadd -r plugdev
fi
if ! groups "${SUDO_USER:-$(whoami)}" | grep -q plugdev; then
usermod -aG plugdev "${SUDO_USER:-$(whoami)}"
@@ -21,7 +21,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2.2.1</string>
<string>2.4.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<!-- See https://developer.apple.com/app-store/categories/ for list of AppStore categories -->
@@ -33,6 +33,8 @@
<string>Copyright (C) 2021</string>
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>NSCameraUseContinuityCameraDeviceType</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>Sparrow requires access to the camera in order to scan QR codes</string>
<key>NSLocalNetworkUsageDescription</key>

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 99 KiB

@@ -3,13 +3,14 @@ package com.sparrowwallet.sparrow;
import com.beust.jcommander.JCommander;
import com.google.common.eventbus.Subscribe;
import com.sparrowwallet.drongo.*;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.crypto.*;
import com.sparrowwallet.drongo.dns.DnsPayment;
import com.sparrowwallet.drongo.dns.DnsPaymentCache;
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.PSBTParseException;
import com.sparrowwallet.drongo.psbt.PSBTSignatureException;
import com.sparrowwallet.drongo.psbt.*;
import com.sparrowwallet.drongo.silentpayments.SilentPaymentAddress;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.hummingbird.UR;
import com.sparrowwallet.hummingbird.registry.CryptoPSBT;
@@ -30,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;
@@ -49,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;
@@ -69,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.*;
@@ -86,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;
@@ -134,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;
@@ -142,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;
@@ -366,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;
@@ -378,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());
@@ -575,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);
@@ -763,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) {
@@ -822,10 +842,10 @@ public class AppController implements Initializable {
try(FileOutputStream outputStream = new FileOutputStream(file)) {
if(asText) {
PrintWriter writer = new PrintWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8));
writer.print(transactionTabData.getPsbt().toBase64String(includeXpubs));
writer.print(transactionTabData.getPsbt().getForExport().toBase64String(includeXpubs));
writer.flush();
} else {
outputStream.write(transactionTabData.getPsbt().serialize(includeXpubs, true));
outputStream.write(transactionTabData.getPsbt().getForExport().serialize(includeXpubs, true));
}
} catch(IOException e) {
log.error("Error saving PSBT", e);
@@ -848,7 +868,7 @@ public class AppController implements Initializable {
TabData tabData = (TabData)selectedTab.getUserData();
if(tabData.getType() == TabData.TabType.TRANSACTION) {
TransactionTabData transactionTabData = (TransactionTabData)tabData;
String data = asBase64 ? transactionTabData.getPsbt().toBase64String() : transactionTabData.getPsbt().toString();
String data = asBase64 ? transactionTabData.getPsbt().getForExport().toBase64String() : transactionTabData.getPsbt().getForExport().toString();
ClipboardContent content = new ClipboardContent();
content.putString(data);
@@ -862,10 +882,10 @@ public class AppController implements Initializable {
if(tabData.getType() == TabData.TabType.TRANSACTION) {
TransactionTabData transactionTabData = (TransactionTabData)tabData;
byte[] psbtBytes = transactionTabData.getPsbt().serialize();
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();
}
@@ -937,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()) {
@@ -1034,6 +1070,10 @@ public class AppController implements Initializable {
cmd.add(System.getProperty(JPACKAGE_APP_PATH));
cmd.addAll(args.toParams());
final ProcessBuilder builder = new ProcessBuilder(cmd);
if(OsType.getCurrent() == OsType.UNIX) {
Map<String, String> env = builder.environment();
env.remove("LD_LIBRARY_PATH");
}
builder.start();
quit(event);
} catch(Exception e) {
@@ -1198,22 +1238,24 @@ public class AppController implements Initializable {
List<WalletForm> selectedWalletForms = getSelectedWalletForms();
WalletImportDialog dlg = new WalletImportDialog(selectedWalletForms);
dlg.initOwner(rootStack.getScene().getWindow());
Optional<Wallet> optionalWallet = dlg.showAndWait();
if(optionalWallet.isPresent()) {
Wallet wallet = optionalWallet.get();
Optional<List<Wallet>> optionalWallets = dlg.showAndWait();
if(optionalWallets.isPresent()) {
List<Wallet> wallets = optionalWallets.get();
List<WalletTabData> walletTabData = getOpenWalletTabData();
List<ExtendedKey> xpubs = wallet.getKeystores().stream().map(Keystore::getExtendedPublicKey).collect(Collectors.toList());
Optional<WalletForm> optNewWalletForm = walletTabData.stream()
.map(WalletTabData::getWalletForm)
.filter(wf -> wf.getSettingsWalletForm() != null && wf.getSettingsWalletForm().getWallet().getPolicyType() == PolicyType.MULTI &&
wf.getSettingsWalletForm().getWallet().getScriptType() == wallet.getScriptType() && !wf.getSettingsWalletForm().getWallet().isValid() &&
wf.getSettingsWalletForm().getWallet().getKeystores().stream().map(Keystore::getExtendedPublicKey).anyMatch(xpubs::contains)).findFirst();
if(optNewWalletForm.isPresent()) {
EventManager.get().post(new ExistingWalletImportedEvent(optNewWalletForm.get().getWalletId(), wallet));
selectTab(optNewWalletForm.get().getWallet());
} else if(selectedWalletForms.isEmpty() || wallet != selectedWalletForms.get(0).getWallet()) {
addImportedWallet(wallet);
for(Wallet wallet : wallets) {
List<WalletTabData> walletTabData = getOpenWalletTabData();
List<ExtendedKey> xpubs = wallet.getKeystores().stream().map(Keystore::getExtendedPublicKey).collect(Collectors.toList());
Optional<WalletForm> optNewWalletForm = walletTabData.stream()
.map(WalletTabData::getWalletForm)
.filter(wf -> wf.getSettingsWalletForm() != null && wf.getSettingsWalletForm().getWallet().getPolicyType() == PolicyType.MULTI &&
wf.getSettingsWalletForm().getWallet().getScriptType() == wallet.getScriptType() && !wf.getSettingsWalletForm().getWallet().isValid() &&
wf.getSettingsWalletForm().getWallet().getKeystores().stream().map(Keystore::getExtendedPublicKey).anyMatch(xpubs::contains)).findFirst();
if(optNewWalletForm.isPresent()) {
EventManager.get().post(new ExistingWalletImportedEvent(optNewWalletForm.get().getWalletId(), wallet));
selectTab(optNewWalletForm.get().getWallet());
} else if(selectedWalletForms.isEmpty() || wallet != selectedWalletForms.get(0).getWallet()) {
addImportedWallet(wallet);
}
}
}
}
@@ -1422,6 +1464,10 @@ public class AppController implements Initializable {
}
public void sendToMany(ActionEvent event) {
sendToMany(Collections.emptyList());
}
private void sendToMany(List<Payment> initialPayments) {
if(sendToManyDialog != null) {
Stage stage = (Stage)sendToManyDialog.getDialogPane().getScene().getWindow();
stage.setAlwaysOnTop(true);
@@ -1437,7 +1483,7 @@ public class AppController implements Initializable {
bitcoinUnit = wallet.getAutoUnit();
}
sendToManyDialog = new SendToManyDialog(bitcoinUnit);
sendToManyDialog = new SendToManyDialog(bitcoinUnit, initialPayments);
sendToManyDialog.initModality(Modality.NONE);
Optional<List<Payment>> optPayments = sendToManyDialog.showAndWait();
sendToManyDialog = null;
@@ -1908,6 +1954,39 @@ public class AppController implements Initializable {
}
}
//Add DNS payment information if not already cached
for(PSBTOutput psbtOutput : psbt.getPsbtOutputs()) {
if(psbtOutput.getDnssecProof() != null && !psbtOutput.getDnssecProof().isEmpty()) {
Address address = psbtOutput.getScript() != null ? psbtOutput.getScript().getToAddress() : null;
if(address != null && DnsPaymentCache.getDnsPayment(address) == null) {
try {
Optional<DnsPayment> optDnsPayment = psbtOutput.getDnsPayment();
if(optDnsPayment.isPresent() && address.equals(optDnsPayment.get().bitcoinURI().getAddress())) {
DnsPaymentCache.putDnsPayment(address, optDnsPayment.get());
}
} catch(Exception e) {
log.debug("Error resolving DNS payment", e);
}
}
SilentPaymentAddress silentPaymentAddress = psbtOutput.getSilentPaymentAddress();
if(address != null && silentPaymentAddress == null) {
silentPaymentAddress = AppServices.get().getOpenWallets().keySet().stream()
.map(wallet -> wallet.getSilentPaymentAddress(address)).filter(Objects::nonNull).findFirst().orElse(null);
}
if(silentPaymentAddress != null && DnsPaymentCache.getDnsPayment(silentPaymentAddress) == null) {
try {
Optional<DnsPayment> optDnsPayment = psbtOutput.getDnsPayment();
if(optDnsPayment.isPresent() && silentPaymentAddress.equals(optDnsPayment.get().bitcoinURI().getSilentPaymentAddress())) {
DnsPaymentCache.putDnsPayment(silentPaymentAddress, optDnsPayment.get());
}
} catch(Exception e) {
log.debug("Error resolving DNS payment", e);
}
}
}
}
Window psbtWalletWindow = AppServices.get().getWindowForPSBT(psbt);
if(psbtWalletWindow != null && !tabs.getScene().getWindow().equals(psbtWalletWindow)) {
EventManager.get().post(new ViewPSBTEvent(psbtWalletWindow, name, file, psbt));
@@ -1931,39 +2010,13 @@ public class AppController implements Initializable {
private void addTransactionTab(String name, File file, Transaction transaction, PSBT psbt, BlockTransaction blockTransaction, TransactionView initialView, Integer initialIndex) {
for(Tab tab : tabs.getTabs()) {
TabData tabData = (TabData)tab.getUserData();
if(tabData instanceof TransactionTabData) {
TransactionTabData transactionTabData = (TransactionTabData)tabData;
if(tabData instanceof TransactionTabData transactionTabData) {
if(isExistingTransaction(transactionTabData, transaction, psbt, getTabName(tab))) {
handleTransactionMerge(transactionTabData, psbt, name, tab);
return;
}
//If an exact match bytewise of an existing tab, return that tab
if(Arrays.equals(transactionTabData.getTransaction().bitcoinSerialize(), transaction.bitcoinSerialize())) {
if(transactionTabData.getPsbt() != null && psbt != null && !transactionTabData.getPsbt().isFinalized()) {
if(!psbt.isFinalized()) {
//As per BIP174, combine PSBTs with matching transactions so long as they are not yet finalized
transactionTabData.getPsbt().combine(psbt);
if(name != null && !name.isEmpty()) {
((Label)tab.getGraphic()).setText(name);
}
EventManager.get().post(new PSBTCombinedEvent(transactionTabData.getPsbt()));
} else {
//If the new PSBT is finalized, copy the finalized fields to the existing unfinalized PSBT
for(int i = 0; i < transactionTabData.getPsbt().getPsbtInputs().size(); i++) {
PSBTInput existingInput = transactionTabData.getPsbt().getPsbtInputs().get(i);
PSBTInput finalizedInput = psbt.getPsbtInputs().get(i);
existingInput.setFinalScriptSig(finalizedInput.getFinalScriptSig());
existingInput.setFinalScriptWitness(finalizedInput.getFinalScriptWitness());
existingInput.clearNonFinalFields();
}
if(name != null && !name.isEmpty()) {
((Label)tab.getGraphic()).setText(name);
}
EventManager.get().post(new PSBTFinalizedEvent(transactionTabData.getPsbt()));
}
}
tabs.getSelectionModel().select(tab);
if(transactionTabData.getPsbt() != null && transactionTabData.getPsbt().possibleUnverifiableSilentPaymentsTransaction(transaction) && !openUnverifiableTransaction(getTabName(tab))) {
return;
}
}
@@ -2030,6 +2083,69 @@ public class AppController implements Initializable {
}
}
private boolean isExistingTransaction(TransactionTabData transactionTabData, Transaction transaction, PSBT psbt, String tabName) {
PSBT currentPsbt = transactionTabData.getPsbt();
Transaction currentTransaction = transactionTabData.getTransaction();
if(currentPsbt != null && psbt != null && currentPsbt.matches(psbt)) {
return true;
} else if(currentTransaction.getTxId().equals(transaction.getTxId())) {
if(currentTransaction.getWTxId().equals(transaction.getWTxId())) {
return true;
} else if(currentPsbt == null) {
AppServices.showWarningDialog("Suspicious Transaction",
"This transaction has the same txid as the transaction in tab " + tabName + ", but contains different witnesses. It will be opened in a separate tab.");
}
}
return false;
}
private void handleTransactionMerge(TransactionTabData transactionTabData, PSBT psbt, String name, Tab tab) {
PSBT currentPsbt = transactionTabData.getPsbt();
if(currentPsbt != null && psbt != null && !currentPsbt.isFinalized()) {
if(!psbt.isFinalized()) {
//As per BIP174, combine PSBTs with matching transactions so long as they are not yet finalized
try {
currentPsbt.verifyCombinedSignatures(psbt);
currentPsbt.combine(psbt);
setTabName(tab, name);
EventManager.get().post(new PSBTCombinedEvent(currentPsbt));
} catch(PSBTSignatureException e) {
AppServices.showErrorDialog("Invalid PSBT", e.getMessage());
}
} else {
//If the new PSBT is finalized, copy the finalized fields to the existing unfinalized PSBT
currentPsbt.copyFinalizedFields(psbt);
setTabName(tab, name);
EventManager.get().post(new PSBTFinalizedEvent(currentPsbt));
}
}
tabs.getSelectionModel().select(tab);
}
private boolean openUnverifiableTransaction(String tabName) {
Optional<ButtonType> result = AppServices.showWarningDialog(
"Unverifiable Silent Payments Transaction",
"This transaction contains an unverifiable silent payments output.\n\n" +
"The tab " + tabName + " contains a similar transaction spending to a silent payments address, " +
"but this transaction does not contain enough information to determine if the recipient address is correct.\n\n" +
"Open the transaction in another tab?", ButtonType.YES, ButtonType.NO);
return result.isPresent() && result.get() == ButtonType.YES;
}
private String getTabName(Tab tab) {
return ((Label)tab.getGraphic()).getText();
}
private void setTabName(Tab tab, String name){
if(name != null && !name.isEmpty()) {
((Label)tab.getGraphic()).setText(name);
}
}
private ContextMenu getTabContextMenu(Tab tab) {
ContextMenu contextMenu = new ContextMenu();
@@ -2046,23 +2162,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);
@@ -2495,7 +2621,7 @@ public class AppController implements Initializable {
if(event instanceof TransactionTabSelectedEvent) {
TransactionTabSelectedEvent txTabEvent = (TransactionTabSelectedEvent)event;
TransactionTabData transactionTabData = txTabEvent.getTransactionTabData();
if(transactionTabData.getPsbt() == null || transactionTabData.getPsbt().getTransaction() != transactionTabData.getTransaction()) {
if(transactionTabData.getPsbt() == null || !transactionTabData.getPsbt().getTransaction().getTxId().equals(transactionTabData.getTransaction().getTxId())) {
saveTransaction.setVisible(true);
saveTransaction.setDisable(false);
} else {
@@ -3064,6 +3190,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()));
@@ -3107,6 +3238,11 @@ public class AppController implements Initializable {
}
}
@Subscribe
public void requestSendToMany(RequestSendToManyEvent event) {
sendToMany(event.getPayments());
}
@Subscribe
public void functionAction(FunctionActionEvent event) {
selectTab(event.getWallet());
@@ -91,8 +91,7 @@ public class AppServices {
private static final String TOR_DEFAULT_PROXY_CIRCUIT_ID = "default";
public static final List<Integer> TARGET_BLOCKS_RANGE = List.of(1, 2, 3, 4, 5, 10, 25, 50);
public static final List<Long> LONG_FEE_RATES_RANGE = List.of(1L, 2L, 4L, 8L, 16L, 32L, 64L, 128L, 256L, 512L, 1024L, 2048L, 4096L, 8192L);
public static final List<Long> FEE_RATES_RANGE = LONG_FEE_RATES_RANGE.subList(0, LONG_FEE_RATES_RANGE.size() - 3);
private static final List<Double> LONG_FEE_RATES_RANGE = List.of(1d, 2d, 4d, 8d, 16d, 32d, 64d, 128d, 256d, 512d, 1024d, 2048d, 4096d, 8192d);
public static final double FALLBACK_FEE_RATE = 20000d / 1000;
public static final double TESTNET_FALLBACK_FEE_RATE = 1000d / 1000;
@@ -136,10 +135,14 @@ public class AppServices {
private static Map<Integer, Double> targetBlockFeeRates;
private static Double nextBlockMedianFeeRate;
private static final TreeMap<Date, Set<MempoolRateSize>> mempoolHistogram = new TreeMap<>();
private static Double minimumRelayFeeRate;
private static Double serverMinimumRelayFeeRate;
private static CurrencyRate fiatCurrencyExchangeRate;
private static List<Device> devices;
@@ -209,6 +212,7 @@ public class AppServices {
preventSleepService = createPreventSleepService();
onlineProperty.addListener(onlineServicesListener);
minimumRelayFeeRate = getConfiguredMinimumRelayFeeRate(config);
if(config.getMode() == Mode.ONLINE) {
if(config.requiresInternalTor()) {
@@ -748,6 +752,30 @@ public class AppServices {
return Math.max(minRate, Transaction.DUST_RELAY_TX_FEE);
}
public static List<Double> getLongFeeRatesRange() {
if(minimumRelayFeeRate == null || minimumRelayFeeRate >= Transaction.DEFAULT_MIN_RELAY_FEE) {
return LONG_FEE_RATES_RANGE;
} else {
List<Double> longFeeRatesRange = new ArrayList<>();
longFeeRatesRange.add(minimumRelayFeeRate);
longFeeRatesRange.addAll(LONG_FEE_RATES_RANGE);
return longFeeRatesRange;
}
}
public static List<Double> getFeeRatesRange() {
if(minimumRelayFeeRate == null || minimumRelayFeeRate >= Transaction.DEFAULT_MIN_RELAY_FEE) {
return LONG_FEE_RATES_RANGE.subList(0, LONG_FEE_RATES_RANGE.size() - 3);
} else {
List<Double> longFeeRatesRange = getLongFeeRatesRange();
return longFeeRatesRange.subList(0, longFeeRatesRange.size() - 4);
}
}
public static Double getNextBlockMedianFeeRate() {
return nextBlockMedianFeeRate == null ? getDefaultFeeRate() : nextBlockMedianFeeRate;
}
public static double getFallbackFeeRate() {
return Network.get() == Network.MAINNET ? FALLBACK_FEE_RATE : TESTNET_FALLBACK_FEE_RATE;
}
@@ -782,10 +810,18 @@ public class AppServices {
});
}
public static Double getConfiguredMinimumRelayFeeRate(Config config) {
return config.getMinRelayFeeRate() >= 0d && config.getMinRelayFeeRate() < Transaction.DEFAULT_MIN_RELAY_FEE ? config.getMinRelayFeeRate() : null;
}
public static Double getMinimumRelayFeeRate() {
return minimumRelayFeeRate == null ? Transaction.DEFAULT_MIN_RELAY_FEE : minimumRelayFeeRate;
}
public static Double getServerMinimumRelayFeeRate() {
return serverMinimumRelayFeeRate;
}
public static CurrencyRate getFiatCurrencyExchangeRate() {
return fiatCurrencyExchangeRate;
}
@@ -799,8 +835,8 @@ public class AppServices {
}
public static void addPayjoinURI(BitcoinURI bitcoinURI) {
if(bitcoinURI.getPayjoinUrl() == null) {
throw new IllegalArgumentException("Not a payjoin URI");
if(bitcoinURI.getPayjoinUrl() == null || bitcoinURI.getAddress() == null) {
throw new IllegalArgumentException("Not a valid payjoin URI");
}
payjoinURIs.put(bitcoinURI.getAddress(), bitcoinURI);
}
@@ -851,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");
}
}
}
@@ -1197,7 +1238,7 @@ public class AppServices {
}
public static Font getMonospaceFont() {
return Font.font("Roboto Mono", 13);
return Font.font("Fragment Mono Regular", 13);
}
public static boolean isOnWayland() {
@@ -1213,7 +1254,10 @@ public class AppServices {
public void newConnection(ConnectionEvent event) {
currentBlockHeight = event.getBlockHeight();
System.setProperty(Network.BLOCK_HEIGHT_PROPERTY, Integer.toString(currentBlockHeight));
minimumRelayFeeRate = Math.max(event.getMinimumRelayFeeRate(), Transaction.DEFAULT_MIN_RELAY_FEE);
if(getConfiguredMinimumRelayFeeRate(Config.get()) == null) {
minimumRelayFeeRate = event.getMinimumRelayFeeRate() == null ? Transaction.DEFAULT_MIN_RELAY_FEE : event.getMinimumRelayFeeRate();
}
serverMinimumRelayFeeRate = event.getMinimumRelayFeeRate();
latestBlockHeader = event.getBlockHeader();
Config.get().addRecentServer();
@@ -1249,11 +1293,13 @@ public class AppServices {
if(AppServices.currentBlockHeight != null) {
blockSummaries.keySet().removeIf(height -> AppServices.currentBlockHeight - height > 5);
}
nextBlockMedianFeeRate = event.getNextBlockMedianFeeRate();
}
@Subscribe
public void feesUpdated(FeeRatesUpdatedEvent event) {
targetBlockFeeRates = event.getTargetBlockFeeRates();
nextBlockMedianFeeRate = event.getNextBlockMedianFeeRate();
}
@Subscribe
@@ -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());
@@ -113,8 +113,8 @@ public class SparrowDesktop extends Application {
private void initializeFonts() {
GlyphFontRegistry.register(new FontAwesome5());
GlyphFontRegistry.register(new FontAwesome5Brands());
Font.loadFont(AppServices.class.getResourceAsStream("/font/RobotoMono-Regular.ttf"), 13);
Font.loadFont(AppServices.class.getResourceAsStream("/font/RobotoMono-Italic.ttf"), 11);
Font.loadFont(AppServices.class.getResourceAsStream("/font/FragmentMono-Regular.ttf"), 13);
Font.loadFont(AppServices.class.getResourceAsStream("/font/FragmentMono-Italic.ttf"), 11);
if(OsType.getCurrent() == OsType.MACOS) {
Font.loadFont(AppServices.class.getResourceAsStream("/font/LiberationSans-Regular.ttf"), 13);
}
@@ -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.2.1";
public static final String APP_VERSION = "2.4.0";
public static final String APP_VERSION_SUFFIX = "";
public static final String APP_HOME_PROPERTY = "sparrow.home";
public static final String NETWORK_ENV_PROPERTY = "SPARROW_NETWORK";
@@ -22,6 +22,7 @@ public class AddressLabel extends IdLabel {
public AddressLabel(String text) {
super(text);
setSkin(new AddressTextFieldSkin(this));
addressProperty().addListener((observable, oldValue, newValue) -> {
setAddressAsText(newValue);
contextMenu.copyHex.setText("Copy " + newValue.getOutputScriptDataType());
@@ -0,0 +1,147 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.Network;
import javafx.beans.value.ChangeListener;
import javafx.geometry.Insets;
import javafx.scene.Node;
import javafx.scene.control.ContentDisplay;
import javafx.scene.control.Label;
import javafx.scene.control.skin.LabelSkin;
import javafx.scene.text.Font;
import javafx.scene.text.Text;
import javafx.scene.text.TextFlow;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class AddressLabelSkin extends LabelSkin {
public static final int CHUNK_SIZE = 4;
public static final Pattern CHUNK_PATTERN = Pattern.compile("(?<=\\G.{" + CHUNK_SIZE + "})");
private final TextFlow displayFlow;
private final ChangeListener<String> textListener;
private final ChangeListener<Font> fontListener;
public AddressLabelSkin(Label control) {
super(control);
displayFlow = new TextFlow();
displayFlow.setMouseTransparent(true);
getChildren().addFirst(displayFlow);
textListener = (_, _, newText) -> updateDisplay(newText);
fontListener = (_, _, _) -> updateDisplay(control.getText());
control.textProperty().addListener(textListener);
control.fontProperty().addListener(fontListener);
updateDisplay(control.getText());
control.setStyle("-fx-text-fill: transparent;");
}
@Override
public void dispose() {
getSkinnable().textProperty().removeListener(textListener);
getSkinnable().fontProperty().removeListener(fontListener);
super.dispose();
}
private void updateDisplay(String text) {
displayFlow.getChildren().clear();
if(text == null || text.isEmpty()) {
return;
}
List<AddressSpan> addresses = findAddresses(text);
int pos = 0;
for(AddressSpan span : addresses) {
if(span.start > pos) {
Text normalText = createText(text.substring(pos, span.start), false);
displayFlow.getChildren().add(normalText);
}
addChunkedAddress(text.substring(span.start, span.end));
pos = span.end;
}
if(pos < text.length()) {
Text normalText = createText(text.substring(pos), false);
displayFlow.getChildren().add(normalText);
}
}
private void addChunkedAddress(String address) {
String[] chunks = CHUNK_PATTERN.split(address);
for(int i = 0; i < chunks.length; i++) {
Text chunk = createText(chunks[i], i % 2 != 0);
displayFlow.getChildren().add(chunk);
}
}
private Text createText(String content, boolean alternate) {
Text text = new Text(content);
text.setFont(getSkinnable().getFont());
text.getStyleClass().add("address-chunk");
if(alternate) {
text.getStyleClass().add("alternate");
}
return text;
}
private List<AddressSpan> findAddresses(String text) {
List<AddressSpan> spans = new ArrayList<>();
Pattern wordBoundary = Pattern.compile("\\S+");
Matcher matcher = wordBoundary.matcher(text);
while(matcher.find()) {
String candidate = matcher.group();
if(isValidAddress(candidate)) {
spans.add(new AddressSpan(matcher.start(), matcher.end()));
}
}
return spans;
}
private boolean isValidAddress(String candidate) {
Network network = Network.get();
return network.hasP2PKHAddressPrefix(candidate) || network.hasP2SHAddressPrefix(candidate) ||
candidate.startsWith(network.getBech32AddressHRP()) || candidate.startsWith(network.getSilentPaymentsAddressHrp());
}
@Override
protected void updateChildren() {
super.updateChildren();
if(displayFlow != null && !getChildren().contains(displayFlow)) {
getChildren().addFirst(displayFlow);
}
}
@Override
protected void layoutChildren(double x, double y, double w, double h) {
super.layoutChildren(x, y, w, h);
// Position TextFlow to align with the label's text area
Label label = getSkinnable();
Insets padding = label.getPadding();
Node graphic = label.getGraphic();
double graphicOffset = 0;
if(graphic != null && label.getContentDisplay() == ContentDisplay.LEFT) {
graphicOffset = graphic.getLayoutBounds().getWidth() + label.getGraphicTextGap();
}
displayFlow.resizeRelocate(
x + padding.getLeft() + graphicOffset,
y + padding.getTop(),
w - padding.getLeft() - padding.getRight() - graphicOffset,
h - padding.getTop() - padding.getBottom()
);
}
private record AddressSpan(int start, int end) {}
}
@@ -0,0 +1,273 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.protocol.Base58;
import com.sparrowwallet.drongo.protocol.Bech32;
import impl.org.controlsfx.skin.CustomTextFieldSkin;
import javafx.beans.property.ObjectProperty;
import javafx.beans.value.ChangeListener;
import javafx.beans.property.SimpleObjectProperty;
import javafx.geometry.Insets;
import javafx.scene.Node;
import javafx.scene.control.TextField;
import javafx.scene.layout.Region;
import javafx.scene.shape.Path;
import javafx.scene.shape.Rectangle;
import javafx.scene.text.Font;
import javafx.scene.text.Text;
import javafx.scene.text.TextFlow;
import org.controlsfx.control.textfield.CustomTextField;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class AddressTextFieldSkin extends CustomTextFieldSkin {
private static final boolean[] BASE58_OK = buildOkTable(new String(Base58.ALPHABET));
private static final boolean[] BECH32_DATA_OK = buildOkTable(Bech32.CHARSET);
private final TextFlow displayFlow;
private final Rectangle clip;
private final ChangeListener<String> textListener;
private final ChangeListener<Font> fontListener;
public AddressTextFieldSkin(TextField control) {
super(control);
displayFlow = new TextFlow();
displayFlow.setMouseTransparent(true);
clip = new Rectangle();
displayFlow.setClip(clip);
getChildren().addFirst(displayFlow);
textListener = (_, _, newText) -> updateDisplay(newText);
fontListener = (_, _, _) -> updateDisplay(control.getText());
control.textProperty().addListener(textListener);
control.fontProperty().addListener(fontListener);
updateDisplay(control.getText());
control.setStyle("-fx-text-fill: transparent;");
// Unbind caret color since it's normally bound to textFill
unbindCaretColor(getChildren());
}
@Override
public void dispose() {
getSkinnable().textProperty().removeListener(textListener);
getSkinnable().fontProperty().removeListener(fontListener);
super.dispose();
}
private void unbindCaretColor(javafx.collections.ObservableList<Node> children) {
for(Node node : children) {
if(node instanceof Path path && path.getStroke() != null) {
path.fillProperty().unbind();
path.strokeProperty().unbind();
path.getStyleClass().add("address-field-caret");
} else if(node instanceof javafx.scene.Parent parent) {
unbindCaretColor(parent.getChildrenUnmodifiable());
}
}
}
@Override
public ObjectProperty<Node> leftProperty() {
if(getSkinnable() instanceof CustomTextField customTextField) {
return customTextField.leftProperty();
}
return new SimpleObjectProperty<>();
}
@Override
public ObjectProperty<Node> rightProperty() {
if(getSkinnable() instanceof CustomTextField customTextField) {
return customTextField.rightProperty();
}
return new SimpleObjectProperty<>();
}
private void updateDisplay(String text) {
displayFlow.getChildren().clear();
if(text == null || text.isEmpty()) {
return;
}
List<AddressSpan> addresses = findAddresses(text);
int pos = 0;
for(AddressSpan span : addresses) {
if(span.start > pos) {
Text normalText = createText(text.substring(pos, span.start), false);
displayFlow.getChildren().add(normalText);
}
addChunkedAddress(text.substring(span.start, span.end));
pos = span.end;
}
if(pos < text.length()) {
Text normalText = createText(text.substring(pos), false);
displayFlow.getChildren().add(normalText);
}
}
private void addChunkedAddress(String address) {
String[] chunks = AddressLabelSkin.CHUNK_PATTERN.split(address);
for(int i = 0; i < chunks.length; i++) {
Text chunk = createText(chunks[i], i % 2 != 0);
displayFlow.getChildren().add(chunk);
}
}
private Text createText(String content, boolean alternate) {
Text text = new Text(content);
text.setFont(getSkinnable().getFont());
text.getStyleClass().add("address-chunk");
if(alternate) {
text.getStyleClass().add("alternate");
}
return text;
}
private List<AddressSpan> findAddresses(String text) {
List<AddressSpan> spans = new ArrayList<>();
Pattern wordBoundary = Pattern.compile("\\S+");
Matcher matcher = wordBoundary.matcher(text);
while(matcher.find()) {
String candidate = matcher.group();
if(isValidAddress(candidate)) {
spans.add(new AddressSpan(matcher.start(), matcher.end()));
}
}
return spans;
}
private boolean isValidAddress(String candidate) {
if(candidate == null || candidate.isEmpty()) {
return false;
}
Network network = Network.get();
// Base58 (legacy) partial: must start with a legacy prefix and contain only base58 chars.
if(network.hasP2PKHAddressPrefix(candidate) || network.hasP2SHAddressPrefix(candidate)) {
return containsOnlyAscii(candidate, BASE58_OK);
}
String lower = candidate.toLowerCase(Locale.ROOT);
// Bech32 (segwit v0/v1) partial: starts with HRP, then optional '1', then bech32 data charset.
if(lower.startsWith(network.getBech32AddressHRP())) {
return isBech32LikePartial(lower);
}
// Silent payments partial (bech32-like): starts with its HRP, then optional '1', then bech32 data charset.
if(lower.startsWith(network.getSilentPaymentsAddressHrp())) {
return isBech32LikePartial(lower);
}
return false;
}
private static boolean isBech32LikePartial(String lower) {
int sep = lower.indexOf(Bech32.BECH32_SEPARATOR);
if(sep < 0) {
return containsOnlyHrpChars(lower);
}
String hrp = lower.substring(0, sep);
String dataPart = lower.substring(sep + 1);
if(hrp.isEmpty()) {
return false;
}
return containsOnlyHrpChars(hrp) && containsOnlyAscii(dataPart, BECH32_DATA_OK);
}
private static boolean containsOnlyHrpChars(String s) {
for(int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
boolean ok = (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9');
if(!ok) {
return false;
}
}
return true;
}
private static boolean[] buildOkTable(String allowed) {
boolean[] ok = new boolean[128];
for(int i = 0; i < allowed.length(); i++) {
char c = allowed.charAt(i);
if(c < ok.length) {
ok[c] = true;
} else {
throw new IllegalArgumentException("Non-ASCII allowed char: " + c);
}
}
return ok;
}
private static boolean containsOnlyAscii(String s, boolean[] ok) {
for(int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if(c >= ok.length || !ok[c]) {
return false;
}
}
return true;
}
@Override
protected void layoutChildren(double x, double y, double w, double h) {
super.layoutChildren(x, y, w, h);
Insets padding = getSkinnable().getPadding();
double leftWidth = 0;
double rightWidth = 0;
if(getSkinnable() instanceof CustomTextField customTextField) {
Node left = customTextField.getLeft();
Node right = customTextField.getRight();
if(left != null) {
leftWidth = left.getLayoutBounds().getWidth();
if(left instanceof Region leftRegion) {
leftWidth += leftRegion.getPadding().getLeft() + leftRegion.getPadding().getRight() + 1;
}
}
if(right != null) {
rightWidth = right.getLayoutBounds().getWidth();
if(right instanceof Region rightRegion) {
rightWidth += rightRegion.getPadding().getLeft() + rightRegion.getPadding().getRight();
}
}
}
double availableWidth = w - padding.getLeft() - padding.getRight() - leftWidth - rightWidth;
clip.setWidth(availableWidth);
clip.setHeight(h);
double topOffset = getSkinnable().getBaselineOffset() - displayFlow.getBaselineOffset();
displayFlow.resizeRelocate(
padding.getLeft() + leftWidth,
topOffset,
displayFlow.prefWidth(-1),
h - padding.getTop() - padding.getBottom()
);
}
private record AddressSpan(int start, int end) {}
}
@@ -0,0 +1,111 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.address.InvalidAddressException;
import javafx.beans.value.ChangeListener;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.text.Text;
import javafx.scene.text.TextFlow;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class AddressTooltipSkin implements Skin<Tooltip> {
private final Tooltip tooltip;
private final TextFlow textFlow;
private final ChangeListener<String> textListener;
public AddressTooltipSkin(Tooltip tooltip) {
this.tooltip = tooltip;
textFlow = new TextFlow();
textFlow.getStyleClass().addAll(tooltip.getStyleClass());
textListener = (_, _, newText) -> updateDisplay(newText);
tooltip.textProperty().addListener(textListener);
updateDisplay(tooltip.getText());
}
@Override
public Tooltip getSkinnable() {
return tooltip;
}
@Override
public Node getNode() {
return textFlow;
}
@Override
public void dispose() {
tooltip.textProperty().removeListener(textListener);
}
private void updateDisplay(String text) {
textFlow.getChildren().clear();
if(text == null || text.isEmpty()) {
return;
}
List<AddressSpan> addresses = findAddresses(text);
int pos = 0;
for(AddressSpan span : addresses) {
if(span.start > pos) {
textFlow.getChildren().add(createText(text.substring(pos, span.start), false));
}
addChunkedAddress(text.substring(span.start, span.end));
pos = span.end;
}
if(pos < text.length()) {
textFlow.getChildren().add(createText(text.substring(pos), false));
}
}
private void addChunkedAddress(String address) {
String[] chunks = AddressLabelSkin.CHUNK_PATTERN.split(address);
for(int i = 0; i < chunks.length; i++) {
textFlow.getChildren().add(createText(chunks[i], i % 2 != 0));
}
}
private Text createText(String content, boolean alternate) {
Text text = new Text(content);
text.getStyleClass().add("address-chunk");
if(alternate) {
text.getStyleClass().add("alternate");
}
return text;
}
private List<AddressSpan> findAddresses(String text) {
List<AddressSpan> spans = new ArrayList<>();
Pattern wordBoundary = Pattern.compile("\\S+");
Matcher matcher = wordBoundary.matcher(text);
while(matcher.find()) {
String candidate = matcher.group();
if(isValidAddress(candidate)) {
spans.add(new AddressSpan(matcher.start(), matcher.end()));
}
}
return spans;
}
private boolean isValidAddress(String candidate) {
try {
Address.fromString(candidate);
return true;
} catch(InvalidAddressException e) {
return false;
}
}
private record AddressSpan(int start, int end) {}
}
@@ -30,7 +30,11 @@ public class AddressTreeTable extends CoinTreeTable {
addressCol.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, Entry> param) -> {
return new ReadOnlyObjectWrapper<>(param.getValue().getValue());
});
addressCol.setCellFactory(p -> new EntryCell());
addressCol.setCellFactory(p -> {
EntryCell entryCell = new EntryCell();
entryCell.setSkin(new AddressTreeTableCellSkin<>(entryCell));
return entryCell;
});
addressCol.setSortable(false);
getColumns().add(addressCol);
@@ -0,0 +1,127 @@
package com.sparrowwallet.sparrow.control;
import javafx.beans.value.ChangeListener;
import javafx.geometry.Insets;
import javafx.scene.control.TreeTableCell;
import javafx.scene.control.skin.TreeTableCellSkin;
import javafx.scene.layout.Region;
import javafx.scene.text.Text;
import javafx.scene.text.TextFlow;
public class AddressTreeTableCellSkin<S, T> extends TreeTableCellSkin<S, T> {
private final TextFlow displayFlow;
private final ChangeListener<String> textListener;
private final Text ellipsisText;
private String currentDisplayedText;
public AddressTreeTableCellSkin(TreeTableCell<S, T> cell) {
super(cell);
displayFlow = new TextFlow();
displayFlow.setMouseTransparent(true);
displayFlow.setMinWidth(Region.USE_PREF_SIZE);
getChildren().add(displayFlow);
ellipsisText = new Text("...");
ellipsisText.fontProperty().bind(cell.fontProperty());
ellipsisText.getStyleClass().add("address-chunk");
textListener = (_, _, newText) -> updateDisplay(newText);
cell.textProperty().addListener(textListener);
updateDisplay(cell.getText());
cell.setStyle("-fx-text-fill: transparent;");
}
private void updateDisplay(String text) {
currentDisplayedText = text;
buildDisplay(text, false);
}
private void buildDisplay(String text, boolean truncated) {
displayFlow.getChildren().clear();
if(text == null || text.isEmpty()) {
return;
}
if(getSkinnable().getStyleClass().contains("address-cell")) {
String[] chunks = AddressLabelSkin.CHUNK_PATTERN.split(text);
for(int i = 0; i < chunks.length; i++) {
displayFlow.getChildren().add(createText(chunks[i], i % 2 != 0));
}
} else {
Text normalText = createText(text, false);
displayFlow.getChildren().add(normalText);
}
if(truncated) {
displayFlow.getChildren().add(ellipsisText);
}
}
private Text createText(String content, boolean alternate) {
Text text = new Text(content);
text.fontProperty().bind(getSkinnable().fontProperty());
text.getStyleClass().add("address-chunk");
if(alternate) {
text.getStyleClass().add("alternate");
}
return text;
}
@Override
protected void layoutChildren(double x, double y, double w, double h) {
super.layoutChildren(x, y, w, h);
TreeTableCell<S, T> cell = getSkinnable();
Insets padding = cell.getPadding();
double leftOffset = 0;
double topOffset = y + padding.getTop();
Text labeledText = (Text)getChildren().stream().filter(n -> n instanceof Text).findFirst().orElse(null);
if(labeledText != null) {
leftOffset = labeledText.getLayoutX();
topOffset = labeledText.getLayoutY() - labeledText.getBaselineOffset();
String fullText = cell.getText();
String displayedText = labeledText.getText();
if(fullText != null && displayedText != null && !fullText.equals(displayedText)) {
String ellipsis = cell.getEllipsisString();
if(displayedText.endsWith(ellipsis)) {
String truncatedText = displayedText.substring(0, displayedText.length() - ellipsis.length());
if(!truncatedText.equals(currentDisplayedText)) {
currentDisplayedText = truncatedText;
buildDisplay(truncatedText, true);
}
}
} else if(fullText != null && !fullText.equals(currentDisplayedText)) {
currentDisplayedText = fullText;
buildDisplay(fullText, false);
}
}
displayFlow.resizeRelocate(
leftOffset,
topOffset,
w - padding.getLeft() - padding.getRight(),
h - padding.getTop() - padding.getBottom()
);
}
@Override
protected void updateChildren() {
super.updateChildren();
if(displayFlow != null && !getChildren().contains(displayFlow)) {
getChildren().add(displayFlow);
}
}
@Override
public void dispose() {
getSkinnable().textProperty().removeListener(textListener);
super.dispose();
}
}
@@ -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);
}
}
@@ -52,7 +52,10 @@ public class BlockCube extends Group {
public BlockCube(Integer weight, Double medianFee, Integer height, Integer txCount, Long timestamp, boolean confirmed) {
getStyleClass().addAll("block-" + Network.getCanonical().getName(), "block-cube");
this.confirmedProperty.set(confirmed);
this.feeRatesSource.set(Config.get().getFeeRatesSource());
FeeRatesSource feeRatesSource = Config.get().getFeeRatesSource();
feeRatesSource = (feeRatesSource == null ? FeeRatesSource.MEMPOOL_SPACE : feeRatesSource);
this.feeRatesSource.set(feeRatesSource);
this.weightProperty.addListener((_, _, _) -> {
if(front != null) {
@@ -198,6 +201,8 @@ public class BlockCube extends Group {
} else {
feeRateIcon.getChildren().clear();
}
} else {
feeRateIcon.getChildren().clear();
}
}
}
@@ -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);
@@ -6,10 +6,16 @@ import javafx.beans.property.SimpleObjectProperty;
import javafx.scene.Cursor;
import javafx.scene.Node;
import javafx.scene.control.ComboBox;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.MenuItem;
import javafx.scene.control.SeparatorMenuItem;
import javafx.scene.input.Clipboard;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import org.controlsfx.control.textfield.CustomTextField;
import java.util.List;
public class ComboBoxTextField extends CustomTextField {
private final ObjectProperty<ComboBox<?>> comboProperty = new SimpleObjectProperty<>();
@@ -68,4 +74,53 @@ public class ComboBoxTextField extends CustomTextField {
public void setComboProperty(ComboBox<?> comboProperty) {
this.comboProperty.set(comboProperty);
}
public ContextMenu getCustomContextMenu(List<MenuItem> customItems) {
return new CustomContextMenu(customItems);
}
public class CustomContextMenu extends ContextMenu {
public CustomContextMenu(List<MenuItem> customItems) {
super();
setFont(null);
MenuItem undo = new MenuItem("Undo");
undo.setOnAction(_ -> undo());
MenuItem redo = new MenuItem("Redo");
redo.setOnAction(_ -> redo());
MenuItem cut = new MenuItem("Cut");
cut.setOnAction(_ -> cut());
MenuItem copy = new MenuItem("Copy");
copy.setOnAction(_ -> copy());
MenuItem paste = new MenuItem("Paste");
paste.setOnAction(_ -> paste());
MenuItem delete = new MenuItem("Delete");
delete.setOnAction(_ -> deleteText(getSelection()));
MenuItem selectAll = new MenuItem("Select All");
selectAll.setOnAction(_ -> selectAll());
getItems().addAll(undo, redo, new SeparatorMenuItem(), cut, copy, paste, delete, new SeparatorMenuItem(), selectAll);
getItems().addAll(customItems);
setOnShowing(_ -> {
boolean hasSelection = getSelection().getLength() > 0;
boolean hasText = getText() != null && !getText().isEmpty();
boolean clipboardHasContent = Clipboard.getSystemClipboard().hasString();
undo.setDisable(!isUndoable());
redo.setDisable(!isRedoable());
cut.setDisable(!isEditable() || !hasSelection);
copy.setDisable(!hasSelection);
paste.setDisable(!isEditable() || !clipboardHasContent);
delete.setDisable(!hasSelection);
selectAll.setDisable(!hasText);
});
}
}
}
@@ -0,0 +1,39 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.sparrow.AppServices;
import javafx.geometry.Insets;
import javafx.scene.control.Alert;
import javafx.scene.control.ButtonType;
import javafx.scene.control.CheckBox;
import javafx.scene.control.Label;
import javafx.scene.layout.VBox;
import static com.sparrowwallet.sparrow.AppServices.getActiveWindow;
import static com.sparrowwallet.sparrow.AppServices.setStageIcon;
public class ConfirmationAlert extends Alert {
private final CheckBox dontAskAgain;
public ConfirmationAlert(String title, String contentText, ButtonType... buttons) {
super(AlertType.CONFIRMATION, contentText, buttons);
initOwner(getActiveWindow());
setStageIcon(getDialogPane().getScene().getWindow());
getDialogPane().getScene().getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
setTitle(title);
setHeaderText(title);
VBox contentBox = new VBox(20);
contentBox.setPadding(new Insets(10, 20, 10, 20));
Label contentLabel = new Label(contentText);
contentLabel.setWrapText(true);
dontAskAgain = new CheckBox("Don't ask again");
contentBox.getChildren().addAll(contentLabel, dontAskAgain);
getDialogPane().setContent(contentBox);
}
public boolean isDontAskAgain() {
return dontAskAgain.isSelected();
}
}
@@ -10,8 +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.MouseEvent;
import javafx.scene.input.MouseButton;
public class CopyableCoinLabel extends CopyableLabel {
private final LongProperty valueProperty = new SimpleLongProperty(-1);
@@ -29,6 +28,10 @@ public class CopyableCoinLabel extends CopyableLabel {
valueProperty().addListener((observable, oldValue, newValue) -> setValueAsText((Long)newValue, Config.get().getUnitFormat(), Config.get().getBitcoinUnit()));
setOnMouseClicked(event -> {
if(!event.getButton().equals(MouseButton.PRIMARY)) {
return;
}
if(bitcoinUnit == null) {
bitcoinUnit = Config.get().getBitcoinUnit();
}
@@ -67,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();
@@ -453,20 +472,26 @@ public class DevicePane extends TitledDescriptionPane {
});
vBox.getChildren().addAll(pinField, enterPinButton);
TilePane tilePane = new TilePane();
tilePane.setPrefColumns(3);
tilePane.setHgap(10);
tilePane.setVgap(10);
tilePane.setMaxWidth(150);
tilePane.setMaxHeight(120);
GridPane gridPane = new GridPane();
gridPane.setHgap(10);
gridPane.setVgap(10);
gridPane.setMaxWidth(150);
gridPane.setMaxHeight(device.getModel().hasZeroInPin() ? 160 : 120);
int[] digits = new int[] {7, 8, 9, 4, 5, 6, 1, 2, 3};
int[] digits = device.getModel().hasZeroInPin() ? new int[] {7, 8, 9, 4, 5, 6, 1, 2, 3, 0} : new int[] {7, 8, 9, 4, 5, 6, 1, 2, 3};
for(int i = 0; i < digits.length; i++) {
Button pinButton = new Button();
Glyph circle = new Glyph(FontAwesome5.FONT_NAME, "CIRCLE");
pinButton.setGraphic(circle);
pinButton.setUserData(digits[i]);
tilePane.getChildren().add(pinButton);
GridPane.setRowIndex(pinButton, i / 3);
GridPane.setColumnIndex(pinButton, i % 3);
if((i / 3) == 3) {
GridPane.setHgrow(pinButton, Priority.ALWAYS);
GridPane.setColumnSpan(pinButton, 3);
pinButton.setMaxWidth(Double.MAX_VALUE);
}
gridPane.getChildren().add(pinButton);
pinButton.setOnAction(event -> {
pinField.setText(pinField.getText() + pinButton.getUserData());
});
@@ -474,7 +499,7 @@ public class DevicePane extends TitledDescriptionPane {
HBox contentBox = new HBox();
contentBox.setSpacing(50);
contentBox.getChildren().add(tilePane);
contentBox.getChildren().add(gridPane);
contentBox.getChildren().add(vBox);
contentBox.setPadding(new Insets(10, 0, 10, 0));
contentBox.setAlignment(Pos.TOP_CENTER);
@@ -494,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...");
@@ -858,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));
@@ -869,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));
@@ -883,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());
@@ -913,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());
}
@@ -927,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();
}
@@ -992,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);
@@ -1017,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);
@@ -1032,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();
@@ -5,6 +5,8 @@ import com.sparrowwallet.drongo.OsType;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.protocol.*;
import com.sparrowwallet.drongo.silentpayments.SilentPayment;
import com.sparrowwallet.drongo.silentpayments.SilentPaymentAddress;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
@@ -55,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;
@@ -66,8 +68,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
setText(null);
setGraphic(null);
} else {
if(entry instanceof TransactionEntry) {
TransactionEntry transactionEntry = (TransactionEntry)entry;
if(entry instanceof TransactionEntry transactionEntry) {
if(transactionEntry.getBlockTransaction().getHeight() == -1) {
setText("Unconfirmed Parent");
setContextMenu(new UnconfirmedTransactionContextMenu(transactionEntry));
@@ -101,7 +102,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
actionBox.getChildren().add(viewTransactionButton);
BlockTransaction blockTransaction = transactionEntry.getBlockTransaction();
if(blockTransaction.getHeight() <= 0 && canRBF(blockTransaction) &&
if(blockTransaction.getHeight() <= 0 && canRBF(blockTransaction, transactionEntry.getWallet()) &&
Config.get().isIncludeMempoolOutputs() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) {
Button increaseFeeButton = new Button("");
increaseFeeButton.setGraphic(getIncreaseFeeRBFGlyph());
@@ -121,16 +122,15 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
}
setGraphic(actionBox);
} else if(entry instanceof NodeEntry) {
NodeEntry nodeEntry = (NodeEntry)entry;
} 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");
@@ -163,8 +163,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
setContextMenu(null);
setGraphic(new HBox());
}
} else if(entry instanceof HashIndexEntry) {
HashIndexEntry hashIndexEntry = (HashIndexEntry)entry;
} else if(entry instanceof HashIndexEntry hashIndexEntry) {
setText(hashIndexEntry.getDescription());
setContextMenu(getTreeTableView().getStyleClass().contains("bip47") ? null : new HashIndexEntryContextMenu(getTreeTableView(), hashIndexEntry));
Tooltip tooltip = new Tooltip();
@@ -212,13 +211,14 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
private static void increaseFee(TransactionEntry transactionEntry, boolean cancelTransaction) {
BlockTransaction blockTransaction = transactionEntry.getBlockTransaction();
boolean silentPaymentTransaction = transactionEntry.getWallet().isSilentPaymentsTransaction(blockTransaction);
Map<BlockTransactionHashIndex, WalletNode> walletTxos = transactionEntry.getWallet().getWalletTxos();
List<BlockTransactionHashIndex> utxos = transactionEntry.getChildren().stream()
.filter(e -> e instanceof HashIndexEntry)
.map(e -> (HashIndexEntry)e)
.filter(e -> e.getType().equals(HashIndexEntry.Type.INPUT) && e.isSpendable())
.map(e -> blockTransaction.getTransaction().getInputs().get((int)e.getHashIndex().getIndex()))
.filter(i -> Config.get().isMempoolFullRbf() || i.isReplaceByFeeEnabled())
.filter(i -> Config.get().isMempoolFullRbf() || i.isReplaceByFeeEnabled() || silentPaymentTransaction)
.map(txInput -> walletTxos.keySet().stream().filter(txo -> txo.getHash().equals(txInput.getOutpoint().getHash()) && txo.getIndex() == txInput.getOutpoint().getIndex()).findFirst().get())
.collect(Collectors.toList());
@@ -243,6 +243,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
.collect(Collectors.toList());
boolean consolidationTransaction = consolidationOutputs.size() == blockTransaction.getTransaction().getOutputs().size() && consolidationOutputs.size() == 1;
boolean safeToAddInputsOrOutputs = transactionEntry.getWallet().isSafeToAddInputsOrOutputs(blockTransaction);
long changeTotal = ourOutputs.stream().mapToLong(TransactionOutput::getValue).sum() - consolidationOutputs.stream().mapToLong(TransactionOutput::getValue).sum();
Transaction tx = blockTransaction.getTransaction();
double vSize = tx.getVirtualSize();
@@ -257,7 +258,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
List<OutputGroup> outputGroups = transactionEntry.getWallet().getGroupedUtxos(txoFilters, feeRate, AppServices.getMinimumRelayFeeRate(), Config.get().isGroupByAddress())
.stream().filter(outputGroup -> outputGroup.getEffectiveValue() >= 0).collect(Collectors.toList());
Collections.shuffle(outputGroups);
while((double)changeTotal / vSize < getMaxFeeRate() && !outputGroups.isEmpty() && !cancelTransaction && !consolidationTransaction) {
while((double)changeTotal / vSize < getMaxFeeRate() && !outputGroups.isEmpty() && !cancelTransaction && !consolidationTransaction && safeToAddInputsOrOutputs) {
//If there is insufficient change output, include another random output group so the fee can be increased
OutputGroup outputGroup = outputGroups.remove(0);
for(BlockTransactionHashIndex utxo : outputGroup.getUtxos()) {
@@ -298,9 +299,13 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
label += " (Replaced By Fee)";
}
if(txOutput.getScript().getToAddress() != null) {
Address address = txOutput.getScript().getToAddress();
if(address != null) {
long value = txOutput.getValue();
//Disable change creation by enabling max payment when there is only one output and no additional UTXOs included
return new Payment(txOutput.getScript().getToAddress(), label, txOutput.getValue(), blockTransaction.getTransaction().getOutputs().size() == 1 && rbfChange == 0);
boolean sendMax = blockTransaction.getTransaction().getOutputs().size() == 1 && rbfChange == 0;
SilentPaymentAddress silentPaymentAddress = transactionEntry.getWallet().getSilentPaymentAddress(address);
return silentPaymentAddress == null ? new Payment(address, label, value, sendMax) : new SilentPayment(silentPaymentAddress, label, value, sendMax);
}
return null;
@@ -337,7 +342,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
}
EventManager.get().post(new SendActionEvent(transactionEntry.getWallet(), utxos));
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(transactionEntry.getWallet(), utxos, payments, opReturns.isEmpty() ? null : opReturns, rbfFee, true, blockTransaction)));
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(transactionEntry.getWallet(), utxos, payments, opReturns.isEmpty() ? null : opReturns, rbfFee, true, blockTransaction, safeToAddInputsOrOutputs)));
}
private static Double getMaxFeeRate() {
@@ -394,11 +399,11 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
Payment payment = new Payment(freshAddress, label, inputTotal, true);
EventManager.get().post(new SendActionEvent(transactionEntry.getWallet(), utxos));
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(transactionEntry.getWallet(), utxos, List.of(payment), null, blockTransaction.getFee(), true, null)));
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(transactionEntry.getWallet(), utxos, List.of(payment), null, blockTransaction.getFee(), true, null, true)));
}
private static boolean canRBF(BlockTransaction blockTransaction) {
return Config.get().isMempoolFullRbf() || blockTransaction.getTransaction().isReplaceByFee();
private static boolean canRBF(BlockTransaction blockTransaction, Wallet wallet) {
return Config.get().isMempoolFullRbf() || blockTransaction.getTransaction().isReplaceByFee() || wallet.isSilentPaymentsTransaction(blockTransaction);
}
private static boolean canSignMessage(WalletNode walletNode) {
@@ -476,7 +481,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
tooltip += "\nFee rate: " + String.format("%.2f", feeRate) + " sats/vB";
}
tooltip += "\nRBF: " + (canRBF(transactionEntry.getBlockTransaction()) ? "Enabled" : "Disabled");
tooltip += "\nRBF: " + (canRBF(transactionEntry.getBlockTransaction(), transactionEntry.getWallet()) ? "Enabled" : "Disabled");
}
return tooltip;
@@ -544,6 +549,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
private static class UnconfirmedTransactionContextMenu extends ContextMenu {
public UnconfirmedTransactionContextMenu(TransactionEntry transactionEntry) {
Wallet wallet = transactionEntry.getWallet();
BlockTransaction blockTransaction = transactionEntry.getBlockTransaction();
MenuItem viewTransaction = new MenuItem("View Transaction");
viewTransaction.setGraphic(getViewTransactionGlyph());
@@ -553,7 +559,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
});
getItems().add(viewTransaction);
if(canRBF(blockTransaction) && Config.get().isIncludeMempoolOutputs() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) {
if(canRBF(blockTransaction, wallet) && Config.get().isIncludeMempoolOutputs() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) {
MenuItem increaseFee = new MenuItem("Increase Fee (RBF)");
increaseFee.setGraphic(getIncreaseFeeRBFGlyph());
increaseFee.setOnAction(AE -> {
@@ -564,7 +570,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
getItems().add(increaseFee);
}
if(canRBF(blockTransaction) && Config.get().isIncludeMempoolOutputs() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) {
if(canRBF(blockTransaction, wallet) && Config.get().isIncludeMempoolOutputs() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) {
MenuItem cancelTx = new MenuItem("Cancel Transaction (RBF)");
cancelTx.setGraphic(getCancelTransactionRBFGlyph());
cancelTx.setOnAction(AE -> {
@@ -850,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")));
}
}
@@ -1,5 +1,6 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.net.FeeRatesSource;
import javafx.application.Platform;
@@ -7,6 +8,7 @@ import javafx.scene.Node;
import javafx.scene.control.Slider;
import javafx.util.StringConverter;
import java.text.DecimalFormat;
import java.util.*;
import java.util.stream.Collectors;
@@ -14,9 +16,11 @@ import static com.sparrowwallet.sparrow.AppServices.*;
public class FeeRangeSlider extends Slider {
private static final double FEE_RATE_SCROLL_INCREMENT = 0.01;
private static final DecimalFormat INTEGER_FEE_RATE_FORMAT = new DecimalFormat("0");
private static final DecimalFormat FRACTIONAL_FEE_RATE_FORMAT = new DecimalFormat("0.###");
public FeeRangeSlider() {
super(0, FEE_RATES_RANGE.size() - 1, 0);
super(0, AppServices.getFeeRatesRange().size() - 1, 0);
setMajorTickUnit(1);
setMinorTickCount(0);
setSnapToTicks(false);
@@ -27,11 +31,11 @@ public class FeeRangeSlider extends Slider {
setLabelFormatter(new StringConverter<>() {
@Override
public String toString(Double object) {
Long feeRate = LONG_FEE_RATES_RANGE.get(object.intValue());
Double feeRate = AppServices.getLongFeeRatesRange().get(object.intValue());
if(isLongFeeRange() && feeRate >= 1000) {
return feeRate / 1000 + "k";
return INTEGER_FEE_RATE_FORMAT.format(feeRate / 1000) + "k";
}
return Long.toString(feeRate);
return feeRate > 0d && feeRate < Transaction.DEFAULT_MIN_RELAY_FEE ? FRACTIONAL_FEE_RATE_FORMAT.format(feeRate) : INTEGER_FEE_RATE_FORMAT.format(feeRate);
}
@Override
@@ -51,10 +55,10 @@ public class FeeRangeSlider extends Slider {
setOnScroll(event -> {
if(event.getDeltaY() != 0) {
double newFeeRate = getFeeRate() + (event.getDeltaY() > 0 ? FEE_RATE_SCROLL_INCREMENT : -FEE_RATE_SCROLL_INCREMENT);
if(newFeeRate < LONG_FEE_RATES_RANGE.get(0)) {
newFeeRate = LONG_FEE_RATES_RANGE.get(0);
} else if(newFeeRate > LONG_FEE_RATES_RANGE.get(LONG_FEE_RATES_RANGE.size() - 1)) {
newFeeRate = LONG_FEE_RATES_RANGE.get(LONG_FEE_RATES_RANGE.size() - 1);
if(newFeeRate < AppServices.getLongFeeRatesRange().getFirst()) {
newFeeRate = AppServices.getLongFeeRatesRange().getFirst();
} else if(newFeeRate > AppServices.getLongFeeRatesRange().getLast()) {
newFeeRate = AppServices.getLongFeeRatesRange().getLast();
}
setFeeRate(newFeeRate);
}
@@ -62,27 +66,79 @@ public class FeeRangeSlider extends Slider {
}
public double getFeeRate() {
return Math.pow(2.0, getValue());
return getFeeRate(AppServices.getMinimumRelayFeeRate());
}
public double getFeeRate(Double minRelayFeeRate) {
if(minRelayFeeRate >= Transaction.DEFAULT_MIN_RELAY_FEE) {
return Math.pow(2.0, getValue());
}
if(getValue() < 1.0d) {
if(minRelayFeeRate == 0.0d) {
return getValue();
}
return Math.pow(minRelayFeeRate, 1.0d - getValue());
}
return Math.pow(2.0, getValue() - 1.0d);
}
public void setFeeRate(double feeRate) {
double value = Math.log(feeRate) / Math.log(2);
setFeeRate(feeRate, AppServices.getMinimumRelayFeeRate());
}
public void setFeeRate(double feeRate, Double minRelayFeeRate) {
double value = getValue(feeRate, minRelayFeeRate);
updateMaxFeeRange(value);
setValue(value);
}
private double getValue(double feeRate, Double minRelayFeeRate) {
double value;
if(minRelayFeeRate >= Transaction.DEFAULT_MIN_RELAY_FEE) {
value = Math.log(feeRate) / Math.log(2);
} else {
if(feeRate < Transaction.DEFAULT_MIN_RELAY_FEE) {
if(minRelayFeeRate == 0.0d) {
return feeRate;
}
value = 1.0d - (Math.log(feeRate) / Math.log(minRelayFeeRate));
} else {
value = (Math.log(feeRate) / Math.log(2.0)) + 1.0d;
}
}
return value;
}
public void updateFeeRange(Double minRelayFeeRate, Double previousMinRelayFeeRate) {
if(minRelayFeeRate != null && previousMinRelayFeeRate != null) {
setFeeRate(getFeeRate(previousMinRelayFeeRate), minRelayFeeRate);
}
setMinorTickCount(1);
setMinorTickCount(0);
}
private void updateMaxFeeRange(double value) {
if(value >= getMax() && !isLongFeeRange()) {
setMax(LONG_FEE_RATES_RANGE.size() - 1);
if(AppServices.getMinimumRelayFeeRate() < Transaction.DEFAULT_MIN_RELAY_FEE) {
setMin(1.0d);
}
setMax(AppServices.getLongFeeRatesRange().size() - 1);
updateTrackHighlight();
} else if(value == getMin() && isLongFeeRange()) {
setMax(FEE_RATES_RANGE.size() - 1);
if(AppServices.getMinimumRelayFeeRate() < Transaction.DEFAULT_MIN_RELAY_FEE) {
setMin(0.0d);
}
setMax(AppServices.getFeeRatesRange().size() - 1);
updateTrackHighlight();
}
}
private boolean isLongFeeRange() {
return getMax() > FEE_RATES_RANGE.size() - 1;
public boolean isLongFeeRange() {
return getMax() > AppServices.getFeeRatesRange().size() - 1;
}
public void updateTrackHighlight() {
@@ -137,9 +193,9 @@ public class FeeRangeSlider extends Slider {
}
private int getPercentageOfFeeRange(Double feeRate) {
double index = Math.log(feeRate) / Math.log(2);
double index = getValue(feeRate, AppServices.getMinimumRelayFeeRate());
if(isLongFeeRange()) {
index *= ((double)FEE_RATES_RANGE.size() / (LONG_FEE_RATES_RANGE.size())) * 0.99;
index *= ((double)AppServices.getFeeRatesRange().size() / (AppServices.getLongFeeRatesRange().size())) * 0.99;
}
return (int)Math.round(index * 10.0);
}
@@ -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) {
@@ -240,6 +241,9 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
setFormatFromScriptType(address.getScriptType());
if(wallet != null) {
setWalletNodeFromAddress(wallet, address);
if(walletNode != null) {
setFormatFromScriptType(getSigningScriptType(walletNode));
}
}
} catch(InvalidAddressException e) {
//can't happen
@@ -273,7 +277,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
}
if(wallet != null && walletNode != null) {
setFormatFromScriptType(wallet.getScriptType());
setFormatFromScriptType(getSigningScriptType(walletNode));
} else {
formatGroup.selectToggle(formatElectrum);
}
@@ -287,9 +291,13 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
}
private boolean canSign(Wallet wallet) {
return wallet.getKeystores().get(0).hasPrivateKey()
|| wallet.getKeystores().get(0).getSource() == KeystoreSource.HW_USB
|| wallet.getKeystores().get(0).getWalletModel().isCard();
return wallet.getKeystores().getFirst().hasPrivateKey()
|| wallet.getKeystores().getFirst().getSource() == KeystoreSource.HW_USB
|| wallet.getKeystores().getFirst().getWalletModel().isCard();
}
private boolean canSignBip322(Wallet wallet) {
return wallet.getKeystores().getFirst().hasPrivateKey();
}
private Address getAddress()throws InvalidAddressException {
@@ -313,6 +321,11 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
walletNode = wallet.getWalletAddresses().get(address);
}
private ScriptType getSigningScriptType(WalletNode walletNode) {
ScriptType scriptType = walletNode.getWallet().getScriptType();
return canSign(walletNode.getWallet()) && !canSignBip322(walletNode.getWallet()) ? ScriptType.P2PKH : scriptType;
}
private void setFormatFromScriptType(ScriptType scriptType) {
formatElectrum.setDisable(scriptType == ScriptType.P2TR);
formatTrezor.setDisable(scriptType == ScriptType.P2TR || scriptType == ScriptType.P2PKH);
@@ -345,7 +358,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
//Note we can expect a single keystore due to the check in the constructor
Wallet signingWallet = walletNode.getWallet();
if(signingWallet.getKeystores().get(0).hasPrivateKey()) {
if(signingWallet.getKeystores().getFirst().hasPrivateKey()) {
if(signingWallet.isEncrypted()) {
EventManager.get().post(new RequestOpenWalletsEvent());
} else {
@@ -358,7 +371,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
private void signUnencryptedKeystore(Wallet decryptedWallet) {
try {
Keystore keystore = decryptedWallet.getKeystores().get(0);
Keystore keystore = decryptedWallet.getKeystores().getFirst();
ECKey privKey = keystore.getKey(walletNode);
String signatureText;
if(isBip322()) {
@@ -378,8 +391,8 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
}
private void signDeviceKeystore(Wallet deviceWallet) {
List<String> fingerprints = List.of(deviceWallet.getKeystores().get(0).getKeyDerivation().getMasterFingerprint());
KeyDerivation fullDerivation = deviceWallet.getKeystores().get(0).getKeyDerivation().extend(walletNode.getDerivation());
List<String> fingerprints = List.of(deviceWallet.getKeystores().getFirst().getKeyDerivation().getMasterFingerprint());
KeyDerivation fullDerivation = deviceWallet.getKeystores().getFirst().getKeyDerivation().extend(walletNode.getDerivation());
DeviceSignMessageDialog deviceSignMessageDialog = new DeviceSignMessageDialog(fingerprints, deviceWallet, message.getText().trim(), fullDerivation);
deviceSignMessageDialog.initOwner(getDialogPane().getScene().getWindow());
Optional<String> optSignature = deviceSignMessageDialog.showAndWait();
@@ -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())));
@@ -398,14 +398,14 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
double feeRate = feeRange.getFeeRate();
long fee = (long)Math.ceil(noFeeTransaction.getVirtualSize() * feeRate);
if(feeRate == Transaction.DEFAULT_MIN_RELAY_FEE) {
if(feeRate == AppServices.getMinimumRelayFeeRate() && feeRate > 0d) {
fee++;
}
long dustThreshold = destAddress.getScriptType().getDustThreshold(sweepOutput, Transaction.DUST_RELAY_TX_FEE);
if(total - fee <= dustThreshold) {
feeRate = Transaction.DEFAULT_MIN_RELAY_FEE;
fee = (long)Math.ceil(noFeeTransaction.getVirtualSize() * feeRate) + 1;
feeRate = AppServices.getMinimumRelayFeeRate();
fee = (long)Math.ceil(noFeeTransaction.getVirtualSize() * feeRate) + (feeRate > 0d ? 1 : 0);
if(total - fee <= dustThreshold) {
AppServices.showErrorDialog("Insufficient funds", "The unspent outputs for this private key contain insufficient funds to spend (" + total + " sats).");
@@ -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;
}
}
@@ -122,19 +122,21 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
if(percentComplete.get() <= 0.0) {
Platform.runLater(() -> percentComplete.set(opening ? 0.0 : -1.0));
}
});
if(opening) {
webcamService.openedProperty().addListener((_, _, opened) -> {
if(opened) {
Platform.runLater(() -> {
try {
postOpenUpdate = true;
List<CaptureDevice> newDevices = new ArrayList<>(webcamService.getDevices());
List<CaptureDevice> newDevices = new ArrayList<>(webcamService.getAvailableDevices());
newDevices.removeAll(foundDevices);
foundDevices.addAll(newDevices);
foundDevices.removeIf(device -> !webcamService.getDevices().contains(device));
if(Config.get().getWebcamDevice() != null && webcamDeviceProperty.get() == null) {
if(webcamService.getDevice() != null) {
for(CaptureDevice device : foundDevices) {
if(device.getName().equals(Config.get().getWebcamDevice())) {
if(device.equals(webcamService.getDevice())) {
webcamDeviceProperty.set(device);
}
}
@@ -146,10 +148,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
postOpenUpdate = false;
}
});
}
});
webcamService.closedProperty().addListener((_, _, closed) -> {
if(closed && webcamResolutionProperty.get() != null) {
} else if(webcamResolutionProperty.get() != null) {
webcamService.setResolution(webcamResolutionProperty.get());
webcamService.setDevice(webcamDeviceProperty.get());
Platform.runLater(() -> {
@@ -190,6 +189,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
});
webcamDeviceProperty.addListener((_, _, newValue) -> {
Config.get().setWebcamDevice(newValue.getName());
Config.get().setWebcamDeviceId(newValue.getUniqueId());
if(!Objects.equals(webcamService.getDevice(), newValue)) {
webcamService.cancel();
}
@@ -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();
}
}
@@ -17,8 +17,10 @@ import javafx.util.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import static com.sparrowwallet.sparrow.AppServices.TARGET_BLOCKS_RANGE;
import static com.sparrowwallet.sparrow.control.BlockCube.CUBE_SIZE;
public class RecentBlocksView extends Pane {
@@ -48,7 +50,9 @@ public class RecentBlocksView extends Pane {
}
}));
updateFeeRatesSource(Config.get().getFeeRatesSource());
FeeRatesSource feeRatesSource = Config.get().getFeeRatesSource();
feeRatesSource = (feeRatesSource == null ? FeeRatesSource.MEMPOOL_SPACE : feeRatesSource);
updateFeeRatesSource(feeRatesSource);
Tooltip.install(this, tooltip);
}
@@ -104,7 +108,7 @@ public class RecentBlocksView extends Pane {
}
}
public void addNewBlock(List<BlockSummary> latestBlocks, Double currentFeeRate) {
private void addNewBlock(List<BlockSummary> latestBlocks, Double currentFeeRate) {
if(getCubes().isEmpty()) {
return;
}
@@ -136,6 +140,14 @@ public class RecentBlocksView extends Pane {
}
}
public void updateFeeRate(Map<Integer, Double> targetBlockFeeRates) {
int defaultTarget = TARGET_BLOCKS_RANGE.get((TARGET_BLOCKS_RANGE.size() / 2) - 1);
if(targetBlockFeeRates.get(defaultTarget) != null) {
Double defaultRate = targetBlockFeeRates.get(defaultTarget);
updateFeeRate(defaultRate);
}
}
public void updateFeeRate(Double currentFeeRate) {
if(!getCubes().isEmpty()) {
BlockCube firstCube = getCubes().getFirst();
@@ -2,6 +2,7 @@ package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.protocol.Script;
import com.sparrowwallet.drongo.protocol.ScriptChunk;
import com.sparrowwallet.drongo.protocol.ScriptOpCodes;
import javafx.geometry.Pos;
import org.controlsfx.control.decoration.Decorator;
import org.controlsfx.control.decoration.GraphicDecoration;
@@ -53,7 +54,11 @@ public class ScriptArea extends CodeArea {
for (int i = 0; i < script.getChunks().size(); i++) {
ScriptChunk chunk = script.getChunks().get(i);
if(chunk.isOpCode()) {
append(chunk.toString(), "script-opcode");
if(chunk.getOpcode() == ScriptOpCodes.OP_0 && witnessScript != null) {
append("<empty>", "script-other");
} else {
append(chunk.toString(), "script-opcode");
}
} else if(chunk.isPubKey()) {
append("<pubkey" + pubKeyCount++ + ">", "script-pubkey");
} else if(chunk.isSignature()) {
@@ -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,36 +5,49 @@ import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.drongo.OsType;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.address.InvalidAddressException;
import com.sparrowwallet.drongo.dns.DnsPayment;
import com.sparrowwallet.drongo.dns.DnsPaymentCache;
import com.sparrowwallet.drongo.dns.DnsPaymentResolver;
import com.sparrowwallet.drongo.dns.DnsPaymentValidationException;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.silentpayments.SilentPayment;
import com.sparrowwallet.drongo.silentpayments.SilentPaymentAddress;
import com.sparrowwallet.drongo.uri.BitcoinURIParseException;
import com.sparrowwallet.drongo.wallet.Payment;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.RequestConnectEvent;
import com.sparrowwallet.sparrow.glyphfont.GlyphUtils;
import com.sparrowwallet.sparrow.io.Config;
import javafx.application.Platform;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.concurrent.Service;
import javafx.concurrent.Task;
import javafx.event.ActionEvent;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.input.Clipboard;
import javafx.scene.layout.StackPane;
import javafx.stage.FileChooser;
import javafx.util.StringConverter;
import org.controlsfx.control.spreadsheet.*;
import org.controlsfx.glyphfont.Glyph;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.ExecutionException;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
public class SendToManyDialog extends Dialog<List<Payment>> {
private final BitcoinUnit bitcoinUnit;
private final SpreadsheetView spreadsheetView;
public static final AddressCellType ADDRESS = new AddressCellType();
public static final SendToAddressCellType SEND_TO_ADDRESS = new SendToAddressCellType();
public SendToManyDialog(BitcoinUnit bitcoinUnit) {
public SendToManyDialog(BitcoinUnit bitcoinUnit, List<Payment> payments) {
this.bitcoinUnit = bitcoinUnit;
final DialogPane dialogPane = new SendToManyDialogPane();
@@ -44,7 +57,8 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
dialogPane.setHeaderText("Send to many recipients by specifying addresses and amounts.\nOnly the first row's label is necessary.");
dialogPane.setGraphic(new DialogImage(DialogImage.Type.SPARROW));
List<Payment> initialPayments = IntStream.range(0, 100).mapToObj(i -> new Payment(null, null, -1, false)).collect(Collectors.toList());
List<Payment> initialPayments = IntStream.range(0, 100)
.mapToObj(i -> i < payments.size() ? payments.get(i) : new Payment(null, null, -1, false)).collect(Collectors.toList());
Grid grid = getGrid(initialPayments);
spreadsheetView = new SpreadsheetView(grid) {
@@ -69,14 +83,16 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
dialogPane.setContent(stackPane);
dialogPane.getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
Button okButton = (Button) dialogPane.lookupButton(ButtonType.OK);
okButton.addEventFilter(ActionEvent.ACTION, event -> {
getPayments();
event.consume();
});
final ButtonType loadCsvButtonType = new javafx.scene.control.ButtonType("Load CSV", ButtonBar.ButtonData.LEFT);
dialogPane.getButtonTypes().add(loadCsvButtonType);
setResultConverter((dialogButton) -> {
ButtonBar.ButtonData data = dialogButton == null ? null : dialogButton.getButtonData();
return data == ButtonBar.ButtonData.OK_DONE ? getPayments() : null;
});
setResultConverter((_) -> null);
dialogPane.setPrefWidth(850);
dialogPane.setPrefHeight(500);
@@ -86,18 +102,24 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
}
private Grid getGrid(List<Payment> payments) {
int rowCount = payments.size();
return createGrid(payments.stream().map(payment -> new SendToPayment(payment, SendToAddress.fromPayment(payment))).collect(Collectors.toList()));
}
private Grid createGrid(List<SendToPayment> sendToPayments) {
int rowCount = sendToPayments.size();
int columnCount = 3;
GridBase grid = new GridBase(rowCount, columnCount);
ObservableList<ObservableList<SpreadsheetCell>> rows = FXCollections.observableArrayList();
for(int row = 0; row < grid.getRowCount(); ++row) {
SendToPayment sendToPayment = sendToPayments.get(row);
final ObservableList<SpreadsheetCell> list = FXCollections.observableArrayList();
SpreadsheetCell addressCell = ADDRESS.createCell(row, 0, 1, 1, payments.get(row).getAddress());
SendToAddress sendToAddress = sendToPayment.sendToAddress();
SpreadsheetCell addressCell = SEND_TO_ADDRESS.createCell(row, 0, 1, 1, sendToAddress);
addressCell.getStyleClass().add("fixed-width");
list.add(addressCell);
double amount = (double)payments.get(row).getAmount();
double amount = (double)sendToPayment.payment().getAmount();
if(bitcoinUnit == BitcoinUnit.BTC) {
amount = amount / Transaction.SATOSHIS_PER_BITCOIN;
}
@@ -109,7 +131,7 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
}
list.add(amountCell);
list.add(SpreadsheetCellType.STRING.createCell(row, 2, 1, 1, payments.get(row).getLabel()));
list.add(SpreadsheetCellType.STRING.createCell(row, 2, 1, 1, sendToPayment.payment().getLabel()));
rows.add(list);
}
grid.setRows(rows);
@@ -118,32 +140,49 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
return grid;
}
private List<Payment> getPayments() {
List<Payment> payments = new ArrayList<>();
Grid grid = spreadsheetView.getGrid();
String firstLabel = null;
for(int row = 0; row < grid.getRowCount(); row++) {
private void getPayments() {
if(needsResolution() && Config.get().hasServer() && !AppServices.isConnected() && !AppServices.isConnecting()) {
if(Config.get().getConnectToResolve() == null || Config.get().getConnectToResolve() == Boolean.FALSE) {
Platform.runLater(() -> {
ConfirmationAlert confirmationAlert = new ConfirmationAlert("Connect to resolve?", "You are currently offline. Connect to resolve the addresses?", ButtonType.NO, ButtonType.YES);
Optional<ButtonType> optType = confirmationAlert.showAndWait();
if(confirmationAlert.isDontAskAgain() && optType.isPresent()) {
Config.get().setConnectToResolve(optType.get() == ButtonType.YES);
}
if(optType.isPresent() && optType.get() == ButtonType.YES) {
EventManager.get().post(new RequestConnectEvent());
}
});
} else {
Platform.runLater(() -> EventManager.get().post(new RequestConnectEvent()));
}
return;
}
CreatePaymentsService createPaymentsService = new CreatePaymentsService();
createPaymentsService.setOnSucceeded(_ -> {
List<Payment> payments = createPaymentsService.getValue();
if(payments != null) {
setResult(payments);
}
});
createPaymentsService.setOnFailed(event -> {
Throwable ex = event.getSource().getException();
AppServices.showErrorDialog("Error creating payments", ex.getMessage());
});
createPaymentsService.start();
}
private boolean needsResolution() {
for(int row = 0; row < spreadsheetView.getGrid().getRowCount(); row++) {
ObservableList<SpreadsheetCell> rowCells = spreadsheetView.getItems().get(row);
Address address = (Address)rowCells.get(0).getItem();
Double value = (Double)rowCells.get(1).getItem();
String label = (String)rowCells.get(2).getItem();
if(firstLabel == null) {
firstLabel = label;
}
if(label == null || label.isEmpty()) {
label = firstLabel;
}
if(address != null && value != null) {
if(bitcoinUnit == BitcoinUnit.BTC) {
value = value * Transaction.SATOSHIS_PER_BITCOIN;
}
payments.add(new Payment(address, label, value.longValue(), false));
SendToAddress sendToAddress = (SendToAddress)rowCells.getFirst().getItem();
if(sendToAddress.hrn != null && DnsPaymentCache.getDnsPayment(sendToAddress.hrn) == null) {
return true;
}
}
return payments;
return false;
}
private class SendToManyDialogPane extends DialogPane {
@@ -153,7 +192,7 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
if(buttonType.getButtonData() == ButtonBar.ButtonData.LEFT) {
Button loadButton = new Button(buttonType.getText());
loadButton.setGraphicTextGap(5);
loadButton.setGraphic(getGlyph(FontAwesome5.Glyph.ARROW_UP));
loadButton.setGraphic(GlyphUtils.getUpArrowGlyph());
final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
ButtonBar.setButtonData(loadButton, buttonData);
loadButton.setOnAction(event -> {
@@ -168,7 +207,7 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
File file = fileChooser.showOpenDialog(this.getScene().getWindow());
if(file != null) {
try {
List<Payment> csvPayments = new ArrayList<>();
List<SendToPayment> csvPayments = new ArrayList<>();
try(Reader reader = new FileReader(file, StandardCharsets.UTF_8)) {
CsvReader csvReader = new CsvReader(reader);
while(csvReader.readRecord()) {
@@ -184,9 +223,22 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
} else {
amount = Long.parseLong(csvReader.get(1).replace(",", ""));
}
Address address = Address.fromString(csvReader.get(0));
String label = csvReader.get(2);
csvPayments.add(new Payment(address, label, amount, false));
Optional<String> optDnsPaymentHrn = DnsPayment.getHrn(csvReader.get(0));
if(optDnsPaymentHrn.isPresent()) {
Payment payment = new Payment(null, label, amount, false);
csvPayments.add(new SendToPayment(payment, new SendToAddress(optDnsPaymentHrn.get())));
} else {
try {
SilentPaymentAddress silentPaymentAddress = SilentPaymentAddress.from(csvReader.get(0));
Payment payment = new SilentPayment(silentPaymentAddress, label, amount, false);
csvPayments.add(new SendToPayment(payment, SendToAddress.fromPayment(payment)));
} catch(Exception e) {
Address address = Address.fromString(csvReader.get(0));
Payment payment = new Payment(address, label, amount, false);
csvPayments.add(new SendToPayment(payment, SendToAddress.fromPayment(payment)));
}
}
} catch(NumberFormatException e) {
//ignore and continue - probably a header line
} catch(InvalidAddressException e) {
@@ -199,7 +251,7 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
return;
}
spreadsheetView.setGrid(getGrid(csvPayments));
spreadsheetView.setGrid(createGrid(csvPayments));
}
} catch(IOException e) {
AppServices.showErrorDialog("Cannot load CSV", e.getMessage());
@@ -214,24 +266,18 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
return button;
}
private Glyph getGlyph(FontAwesome5.Glyph glyphName) {
Glyph glyph = new Glyph(FontAwesome5.FONT_NAME, glyphName);
glyph.setFontSize(11);
return glyph;
}
}
public static class AddressCellType extends SpreadsheetCellType<Address> {
public AddressCellType() {
this(new StringConverterWithFormat<>(new AddressStringConverter()) {
public static class SendToAddressCellType extends SpreadsheetCellType<SendToAddress> {
public SendToAddressCellType() {
this(new StringConverterWithFormat<>(new SendToAddressStringConverter()) {
@Override
public String toString(Address item) {
public String toString(SendToAddress item) {
return toStringFormat(item, ""); //$NON-NLS-1$
}
@Override
public Address fromString(String str) {
public SendToAddress fromString(String str) {
if(str == null || str.isEmpty()) { //$NON-NLS-1$
return null;
} else {
@@ -240,7 +286,7 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
}
@Override
public String toStringFormat(Address item, String format) {
public String toStringFormat(SendToAddress item, String format) {
try {
if(item == null) {
return ""; //$NON-NLS-1$
@@ -254,7 +300,7 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
});
}
public AddressCellType(StringConverter<Address> converter) {
public SendToAddressCellType(StringConverter<SendToAddress> converter) {
super(converter);
}
@@ -264,7 +310,7 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
}
public SpreadsheetCell createCell(final int row, final int column, final int rowSpan, final int columnSpan,
final Address value) {
final SendToAddress value) {
SpreadsheetCell cell = new SpreadsheetCellBase(row, column, rowSpan, columnSpan, this);
cell.setItem(value);
return cell;
@@ -277,7 +323,7 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
@Override
public boolean match(Object value, Object... options) {
if(value instanceof Address)
if(value instanceof SendToAddress)
return true;
else {
try {
@@ -290,9 +336,9 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
}
@Override
public Address convertValue(Object value) {
if(value instanceof Address)
return (Address)value;
public SendToAddress convertValue(Object value) {
if(value instanceof SendToAddress)
return (SendToAddress)value;
else {
try {
return converter.fromString(value == null ? null : value.toString());
@@ -303,13 +349,155 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
}
@Override
public String toString(Address item) {
public String toString(SendToAddress item) {
return converter.toString(item);
}
@Override
public String toString(Address item, String format) {
return ((StringConverterWithFormat<Address>)converter).toStringFormat(item, format);
public String toString(SendToAddress item, String format) {
return ((StringConverterWithFormat<SendToAddress>)converter).toStringFormat(item, format);
}
};
public static class SendToAddress {
private final String hrn;
private final Address address;
private final SilentPaymentAddress silentPaymentAddress;
public SendToAddress(String hrn) {
this.hrn = hrn;
this.address = null;
this.silentPaymentAddress = null;
}
public SendToAddress(Address address) {
this.hrn = null;
this.address = address;
this.silentPaymentAddress = null;
}
public SendToAddress(SilentPaymentAddress silentPaymentAddress) {
this.hrn = null;
this.address = null;
this.silentPaymentAddress = silentPaymentAddress;
}
public String toString() {
return hrn == null ? silentPaymentAddress == null ? (address == null ? null : address.toString()) : silentPaymentAddress.toString() : hrn;
}
public static SendToAddress fromPayment(Payment payment) {
DnsPayment dnsPayment = DnsPaymentCache.getDnsPayment(payment);
if(dnsPayment != null) {
return new SendToAddress(dnsPayment.hrn());
}
return payment instanceof SilentPayment ? new SendToAddress(((SilentPayment)payment).getSilentPaymentAddress()) : new SendToAddress(payment.getAddress());
}
public Payment toPayment(String label, long value, boolean sendMax) throws DnsPaymentValidationException, IOException, ExecutionException, InterruptedException, BitcoinURIParseException {
if(hrn != null) {
DnsPayment dnsPayment = DnsPaymentCache.getDnsPayment(hrn);
if(dnsPayment == null) {
DnsPaymentResolver resolver = new DnsPaymentResolver(hrn);
Optional<DnsPayment> optDnsPayment = resolver.resolve();
if(optDnsPayment.isPresent()) {
dnsPayment = optDnsPayment.get();
if(dnsPayment.hasAddress()) {
DnsPaymentCache.putDnsPayment(dnsPayment.bitcoinURI().getAddress(), dnsPayment);
} else if(dnsPayment.hasSilentPaymentAddress()) {
DnsPaymentCache.putDnsPayment(dnsPayment.bitcoinURI().getSilentPaymentAddress(), dnsPayment);
}
return getPayment(optDnsPayment.get(), label, value, sendMax);
} else {
throw new IllegalArgumentException("Payment to " + hrn + " could not be resolved.");
}
} else {
return getPayment(dnsPayment, label, value, sendMax);
}
}
if(silentPaymentAddress != null) {
return new SilentPayment(silentPaymentAddress, label, value, sendMax);
} else {
return new Payment(address, label, value, sendMax);
}
}
private static Payment getPayment(DnsPayment dnsPayment, String label, long value, boolean sendMax) {
if(dnsPayment.hasAddress()) {
return new Payment(dnsPayment.bitcoinURI().getAddress(), label, value, sendMax);
} else if(dnsPayment.hasSilentPaymentAddress()) {
return new SilentPayment(dnsPayment.bitcoinURI().getSilentPaymentAddress(), label, value, sendMax);
} else {
throw new IllegalArgumentException("Payment to " + dnsPayment + " has no associated address.");
}
}
}
private static class SendToAddressStringConverter extends StringConverter<SendToAddress> {
private final AddressStringConverter addressStringConverter = new AddressStringConverter();
@Override
public SendToAddress fromString(String value) {
Optional<String> optDnsPaymentHrn = DnsPayment.getHrn(value);
if(optDnsPaymentHrn.isPresent()) {
return new SendToAddress(optDnsPaymentHrn.get());
}
try {
SilentPaymentAddress silentPaymentAddress = SilentPaymentAddress.from(value);
return new SendToAddress(silentPaymentAddress);
} catch(Exception e) {
Address address = addressStringConverter.fromString(value);
return address == null ? null : new SendToAddress(address);
}
}
@Override
public String toString(SendToAddress value) {
return value.toString();
}
}
private class CreatePaymentsService extends Service<List<Payment>> {
@Override
protected Task<List<Payment>> createTask() {
return new Task<>() {
@Override
protected List<Payment> call() throws Exception {
return getPayments();
}
};
}
private List<Payment> getPayments() throws DnsPaymentValidationException, IOException, ExecutionException, InterruptedException, BitcoinURIParseException {
List<Payment> payments = new ArrayList<>();
Grid grid = spreadsheetView.getGrid();
String firstLabel = null;
for(int row = 0; row < grid.getRowCount(); row++) {
ObservableList<SpreadsheetCell> rowCells = spreadsheetView.getItems().get(row);
SendToAddress sendToAddress = (SendToAddress)rowCells.get(0).getItem();
Double value = (Double)rowCells.get(1).getItem();
String label = (String)rowCells.get(2).getItem();
if(firstLabel == null) {
firstLabel = label;
}
if(label == null || label.isEmpty()) {
label = firstLabel;
}
if(sendToAddress != null && value != null) {
if(bitcoinUnit == BitcoinUnit.BTC) {
value = value * Transaction.SATOSHIS_PER_BITCOIN;
}
payments.add(sendToAddress.toPayment(label, value.longValue(), false));
}
}
return payments;
}
}
private record SendToPayment(Payment payment, SendToAddress sendToAddress) {}
}
@@ -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,11 +1,16 @@
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;
import com.sparrowwallet.drongo.bip47.PaymentCode;
import com.sparrowwallet.drongo.dns.DnsPayment;
import com.sparrowwallet.drongo.dns.DnsPaymentCache;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.protocol.TransactionOutput;
import com.sparrowwallet.drongo.silentpayments.SilentPayment;
import com.sparrowwallet.drongo.silentpayments.SilentPaymentAddress;
import com.sparrowwallet.drongo.uri.BitcoinURI;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.*;
@@ -22,6 +27,7 @@ import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.embed.swing.SwingFXUtils;
import javafx.event.EventHandler;
import javafx.geometry.HPos;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Group;
@@ -103,6 +109,7 @@ public class TransactionDiagram extends GridPane {
expandedDiagram.setId("transactionDiagram");
expandedDiagram.setExpanded(true);
expandedDiagram.setFinal(isFinal());
expandedDiagram.setMaxWidth(AppServices.getActiveWindow().getWidth() - 200);
updateDerivedDiagram(expandedDiagram);
HBox buttonBox = new HBox();
@@ -120,7 +127,7 @@ public class TransactionDiagram extends GridPane {
AppServices.setStageIcon(stage);
stage.setScene(scene);
stage.setOnShowing(e -> {
AppServices.moveToActiveWindowScreen(stage, 600, 460);
AppServices.moveToActiveWindowScreen(stage, expandedDiagram.getMaxWidth(), 460);
});
stage.setOnHidden(e -> {
expandedDiagram = null;
@@ -137,6 +144,39 @@ public class TransactionDiagram extends GridPane {
}
};
public TransactionDiagram() {
ColumnConstraints col1 = new ColumnConstraints();
col1.setPrefWidth(22);
col1.setHgrow(Priority.NEVER);
ColumnConstraints col2 = new ColumnConstraints();
col2.setHgrow(Priority.ALWAYS);
col2.setPercentWidth(25);
col2.setFillWidth(true);
ColumnConstraints col3 = new ColumnConstraints();
col3.setPrefWidth(140);
col3.setHgrow(Priority.NEVER);
ColumnConstraints col4 = new ColumnConstraints();
Label label = new Label();
col4.setMinWidth(TextUtils.computeTextWidth(label.getFont(), "Transaction", 0) + 20);
col4.setHgrow(Priority.NEVER);
col4.setHalignment(HPos.CENTER);
ColumnConstraints col5 = new ColumnConstraints();
col5.setPrefWidth(140);
col5.setHgrow(Priority.NEVER);
ColumnConstraints col6 = new ColumnConstraints();
col6.setHgrow(Priority.ALWAYS);
col6.setPercentWidth(25);
col6.setFillWidth(true);
getColumnConstraints().addAll(col1, col2, col3, col4, col5, col6);
setPadding(new Insets(0, 0, 0, 40));
}
public void update(WalletTransaction walletTx) {
setMinHeight(getDiagramHeight());
setMaxHeight(getDiagramHeight());
@@ -165,7 +205,7 @@ public class TransactionDiagram extends GridPane {
VBox messagePane = new VBox();
messagePane.setPrefHeight(getDiagramHeight());
messagePane.setPadding(new Insets(0, 10, 0, 280));
messagePane.setPadding(new Insets(0, 10, 0, 10));
messagePane.setAlignment(Pos.CENTER);
messagePane.getChildren().add(createSpacer());
@@ -225,7 +265,6 @@ public class TransactionDiagram extends GridPane {
GridPane.setConstraints(outputsPane, 5, 0);
getChildren().clear();
getChildren().addAll(inputsTypePane, inputsPane, inputsLinesPane, txPane, outputsLinesPane, outputsPane);
List<Payment> userPayments = getUserPayments();
if(!isFinal() && userPayments.size() > 1) {
@@ -234,6 +273,8 @@ public class TransactionDiagram extends GridPane {
getChildren().add(totalsPane);
}
getChildren().addAll(inputsTypePane, inputsPane, inputsLinesPane, txPane, outputsLinesPane, outputsPane);
if(contextMenu == null) {
contextMenu = new ContextMenu();
MenuItem menuItem = new MenuItem("Save as Image...");
@@ -407,8 +448,6 @@ public class TransactionDiagram extends GridPane {
private Pane getInputsLabels(List<Map<BlockTransactionHashIndex, WalletNode>> displayedUtxoSets) {
VBox inputsBox = new VBox();
inputsBox.setMaxWidth(isExpanded() ? 300 : 150);
inputsBox.setPrefWidth(isExpanded() ? 230 : 150);
inputsBox.setPadding(new Insets(0, 10, 0, 10));
inputsBox.minHeightProperty().bind(minHeightProperty());
inputsBox.setAlignment(Pos.BASELINE_RIGHT);
@@ -436,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) {
@@ -462,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));
}
@@ -475,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);
@@ -494,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());
@@ -533,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) {
@@ -640,7 +693,8 @@ public class TransactionDiagram extends GridPane {
double width = 140.0;
long sum = walletTx.getTotal();
List<Long> values = walletTx.getTransaction().getOutputs().stream().filter(txo -> txo.getScript().getToAddress() != null).map(TransactionOutput::getValue).collect(Collectors.toList());
List<Long> values = walletTx.getOutputs().stream().filter(output -> !(output instanceof WalletTransaction.NonAddressOutput))
.map(output -> output.getTransactionOutput().getValue()).collect(Collectors.toList());
values.add(walletTx.getFee());
int numOutputs = displayedPayments.size() + walletTx.getChangeMap().size() + 1;
for(int i = 1; i <= numOutputs; i++) {
@@ -676,8 +730,6 @@ public class TransactionDiagram extends GridPane {
private Pane getOutputsLabels(List<Payment> displayedPayments) {
VBox outputsBox = new VBox();
outputsBox.setMaxWidth(isExpanded() ? 350 : 150);
outputsBox.setPrefWidth(isExpanded() ? 230 : 150);
outputsBox.setPadding(new Insets(0, 20, 0, 10));
outputsBox.setAlignment(Pos.BASELINE_LEFT);
outputsBox.getChildren().add(createSpacer());
@@ -686,20 +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.getAddress().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 "
+ (payment instanceof AdditionalPayment ? (isExpanded() ? "\n" : "(click to expand)\n") + payment : (toWallet == null ? (payment.getLabel() == null ? (toNode != null ? toNode : (toBip47Wallet == null ? "external address" : toBip47Wallet.getDisplayName())) : payment.getLabel()) : toWallet.getFullDisplayName()) + "\n" + payment.getAddress().toString())
+ 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());
@@ -719,9 +777,13 @@ public class TransactionDiagram extends GridPane {
paymentBox.getChildren().addAll(region, amountLabel);
}
Wallet bip47Wallet = toWallet != null && toWallet.isBip47() ? toWallet : (toBip47Wallet != null && toBip47Wallet.isBip47() ? toBip47Wallet : null);
PaymentCode paymentCode = bip47Wallet == null ? null : bip47Wallet.getKeystores().getFirst().getExternalPaymentCode();
outputNodes.add(new OutputNode(paymentBox, payment.getAddress(), payment.getAmount(), paymentCode));
if(payment instanceof SilentPayment silentPayment) {
outputNodes.add(new OutputNode(paymentBox, silentPayment.isAddressComputed() ? silentPayment.getAddress() : null, payment.getAmount(), null, silentPayment.getSilentPaymentAddress()));
} else {
Wallet bip47Wallet = toWallet != null && toWallet.isBip47() ? toWallet : (toBip47Wallet != null && toBip47Wallet.isBip47() ? toBip47Wallet : null);
PaymentCode paymentCode = bip47Wallet == null ? null : bip47Wallet.getKeystores().getFirst().getExternalPaymentCode();
outputNodes.add(new OutputNode(paymentBox, payment.getAddress(), payment.getAmount(), paymentCode, null));
}
}
Set<Integer> seenIndexes = new HashSet<>();
@@ -736,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);
@@ -785,7 +849,7 @@ public class TransactionDiagram extends GridPane {
outputsBox.getChildren().add(outputNode.outputLabel);
outputsBox.getChildren().add(createSpacer());
ContextMenu contextMenu = new LabelContextMenu(outputNode.address, outputNode.amount, outputNode.paymentCode);
ContextMenu contextMenu = new LabelContextMenu(outputNode.address, outputNode.amount, outputNode.paymentCode, outputNode.silentPaymentAddress);
if(!outputNode.outputLabel.getChildren().isEmpty() && outputNode.outputLabel.getChildren().get(0) instanceof Label outputLabelControl) {
outputLabelControl.setContextMenu(contextMenu);
}
@@ -795,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);
@@ -960,8 +1024,11 @@ public class TransactionDiagram extends GridPane {
}
private int getOutputIndex(Address address, long amount, Collection<Integer> seenIndexes) {
List<TransactionOutput> addressOutputs = walletTx.getTransaction().getOutputs().stream().filter(txOutput -> txOutput.getScript().getToAddress() != null).collect(Collectors.toList());
TransactionOutput output = addressOutputs.stream().filter(txOutput -> address.equals(txOutput.getScript().getToAddress()) && txOutput.getValue() == amount && !seenIndexes.contains(txOutput.getIndex())).findFirst().orElseThrow();
List<TransactionOutput> addressOutputs = walletTx.getOutputs().stream().filter(output -> !(output instanceof WalletTransaction.NonAddressOutput))
.map(WalletTransaction.Output::getTransactionOutput).collect(Collectors.toList());
TransactionOutput output = addressOutputs.stream()
.filter(txOutput -> address.equals(txOutput.getScript().getToAddress()) && txOutput.getValue() == amount && !seenIndexes.contains(txOutput.getIndex()))
.findFirst().orElseThrow();
return addressOutputs.indexOf(output);
}
@@ -1111,7 +1178,7 @@ public class TransactionDiagram extends GridPane {
}
public String toString() {
return additionalPayments.stream().map(payment -> payment.getAddress().toString()).collect(Collectors.joining("\n"));
return additionalPayments.stream().map(Payment::toString).collect(Collectors.joining("\n"));
}
}
@@ -1120,25 +1187,27 @@ public class TransactionDiagram extends GridPane {
public Address address;
public long amount;
public PaymentCode paymentCode;
public SilentPaymentAddress silentPaymentAddress;
public OutputNode(Pane outputLabel, Address address, long amount) {
this(outputLabel, address, amount, null);
this(outputLabel, address, amount, null, null);
}
public OutputNode(Pane outputLabel, Address address, long amount, PaymentCode paymentCode) {
public OutputNode(Pane outputLabel, Address address, long amount, PaymentCode paymentCode, SilentPaymentAddress silentPaymentAddress) {
this.outputLabel = outputLabel;
this.address = address;
this.amount = amount;
this.paymentCode = paymentCode;
this.silentPaymentAddress = silentPaymentAddress;
}
}
private class LabelContextMenu extends ContextMenu {
public LabelContextMenu(Address address, long value) {
this(address, value, null);
this(address, value, null, null);
}
public LabelContextMenu(Address address, long value, PaymentCode paymentCode) {
public LabelContextMenu(Address address, long value, PaymentCode paymentCode, SilentPaymentAddress silentPaymentAddress) {
if(address != null) {
MenuItem copyAddress = new MenuItem("Copy Address");
copyAddress.setOnAction(event -> {
@@ -1186,6 +1255,17 @@ public class TransactionDiagram extends GridPane {
});
getItems().add(copyPaymentCode);
}
if(silentPaymentAddress != null) {
MenuItem copySilentPaymentAddress = new MenuItem("Copy Silent Payment Address");
copySilentPaymentAddress.setOnAction(AE -> {
hide();
ClipboardContent content = new ClipboardContent();
content.putString(silentPaymentAddress.toString());
Clipboard.getSystemClipboard().setContent(content);
});
getItems().add(copySilentPaymentAddress);
}
}
}
}
@@ -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.getAddress().toString();
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,7 +240,8 @@ public class TransactionDiagramLabel extends HBox {
icon.setGraphic(glyph);
CopyableLabel label = new CopyableLabel();
label.setFont(Font.font("Roboto Mono Italic", 13));
label.setSkin(new AddressTextFieldSkin(label));
label.setFont(Font.font("Fragment Mono Italic", 13));
label.setText(text);
HBox output = new HBox(5);
@@ -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)));
@@ -6,8 +6,9 @@ public enum WebcamPixelFormat {
//Only V4L2 formats defined in linux/videodev2.h are required here, declared in order of priority for supported formats
PIX_FMT_RGB24("RGB3", true),
PIX_FMT_YUYV("YUYV", true),
PIX_FMT_MJPG("MJPG", true),
PIX_FMT_NV12("NV12", false);
PIX_FMT_NV12("NV12", true),
PIX_FMT_YU12("YU12", true),
PIX_FMT_MJPG("MJPG", true);
private final String name;
private final boolean supported;
@@ -25,6 +26,14 @@ public enum WebcamPixelFormat {
return supported;
}
public int getFourCC() {
char a = name.charAt(0);
char b = name.charAt(1);
char c = name.charAt(2);
char d = name.charAt(3);
return ((int) a) | ((int) b << 8) | ((int) c << 16) | ((int) d << 24);
}
public String toString() {
return name;
}
@@ -7,6 +7,7 @@ import com.google.zxing.qrcode.QRCodeReader;
import com.sparrowwallet.bokmakierie.Bokmakierie;
import com.sparrowwallet.drongo.OsType;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.ZBar;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleBooleanProperty;
@@ -15,7 +16,6 @@ import javafx.concurrent.ScheduledService;
import javafx.concurrent.Task;
import javafx.embed.swing.SwingFXUtils;
import javafx.scene.image.Image;
import net.sourceforge.zbar.ZBar;
import org.openpnp.capture.*;
import org.openpnp.capture.library.OpenpnpCaptureLibrary;
import org.slf4j.Logger;
@@ -27,6 +27,9 @@ import java.awt.image.BufferedImage;
import java.awt.image.WritableRaster;
import java.util.*;
import java.util.List;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@@ -34,13 +37,18 @@ import java.util.stream.Stream;
public class WebcamService extends ScheduledService<Image> {
private static final Logger log = LoggerFactory.getLogger(WebcamService.class);
private final Semaphore taskSemaphore = new Semaphore(1);
private final AtomicBoolean cancelRequested = new AtomicBoolean(false);
private final AtomicBoolean captureClosed = new AtomicBoolean(false);
private List<CaptureDevice> devices;
private List<CaptureDevice> availableDevices;
private Set<WebcamResolution> resolutions;
private WebcamResolution resolution;
private CaptureDevice device;
private final BooleanProperty opening = new SimpleBooleanProperty(false);
private final BooleanProperty closed = new SimpleBooleanProperty(false);
private final BooleanProperty opened = new SimpleBooleanProperty(false);
private final ObjectProperty<Result> resultProperty = new SimpleObjectProperty<>(null);
@@ -105,26 +113,44 @@ public class WebcamService extends ScheduledService<Image> {
return new Task<>() {
@Override
protected Image call() throws Exception {
if(cancelRequested.get() || isCancelled() || captureClosed.get()) {
return null;
}
if(!taskSemaphore.tryAcquire()) {
log.warn("Skipped execution of webcam capture task, another task is running");
return null;
}
try {
if(stream == null) {
if(devices == null) {
devices = capture.getDevices();
availableDevices = new ArrayList<>(devices);
if(devices.isEmpty()) {
throw new UnsupportedOperationException("No cameras available");
}
}
CaptureDevice selectedDevice = devices.stream().filter(d -> !d.getFormats().isEmpty()).findFirst().orElse(devices.getFirst());
while(stream == null && !availableDevices.isEmpty()) {
CaptureDevice selectedDevice = availableDevices.stream().filter(d -> !d.getFormats().isEmpty()).findFirst().orElse(availableDevices.getFirst());
if(device != null) {
for(CaptureDevice webcam : devices) {
if(webcam.getName().equals(device.getName())) {
for(CaptureDevice webcam : availableDevices) {
if(webcam.equals(device)) {
selectedDevice = webcam;
break;
}
}
} else if(Config.get().getWebcamDevice() != null) {
for(CaptureDevice webcam : devices) {
for(CaptureDevice webcam : availableDevices) {
if(webcam.getUniqueId().equals(Config.get().getWebcamDeviceId())) {
selectedDevice = webcam;
break;
}
if(webcam.getName().equals(Config.get().getWebcamDevice())) {
selectedDevice = webcam;
break;
}
}
}
@@ -163,22 +189,35 @@ public class WebcamService extends ScheduledService<Image> {
}
}
//On Linux, formats not defined in WebcamPixelFormat are unsupported
if(OsType.getCurrent() == OsType.UNIX && WebcamPixelFormat.fromFourCC(format.getFormatInfo().fourcc) == null) {
log.warn("Unsupported camera pixel format " + WebcamPixelFormat.fourCCToString(format.getFormatInfo().fourcc));
}
if(log.isDebugEnabled()) {
log.debug("Opening capture stream on " + device + " with format " + format.getFormatInfo().width + "x" + format.getFormatInfo().height + " (" + WebcamPixelFormat.fourCCToString(format.getFormatInfo().fourcc) + ")");
log.debug("Opening capture stream on " + device + " with format " + format.getFormatInfo().width + "x" + format.getFormatInfo().height + " (" + WebcamPixelFormat.fourCCToString(format.getFormatInfo().fourcc) + ")");
}
opening.set(true);
stream = device.openStream(format);
opening.set(false);
closed.set(false);
try {
zoomLimits = stream.getPropertyLimits(CaptureProperty.Zoom);
} catch(Throwable e) {
log.debug("Error getting zoom limits on " + device + ", assuming no zoom function");
}
if(stream == null) {
availableDevices.remove(device);
}
}
if(stream == null) {
throw new UnsupportedOperationException("No usable cameras available, tried " + devices);
}
opened.set(true);
BufferedImage originalImage = stream.capture();
CroppedDimension cropped = getCroppedDimension(originalImage);
BufferedImage croppedImage = originalImage.getSubimage(cropped.x, cropped.y, cropped.length, cropped.length);
@@ -195,6 +234,7 @@ public class WebcamService extends ScheduledService<Image> {
return image;
} finally {
opening.set(false);
taskSemaphore.release();
}
}
};
@@ -204,21 +244,38 @@ public class WebcamService extends ScheduledService<Image> {
public void reset() {
stream = null;
zoomLimits = null;
cancelRequested.set(false);
super.reset();
}
@Override
public boolean cancel() {
if(stream != null) {
stream.close();
closed.set(true);
cancelRequested.set(true);
boolean cancelled = super.cancel();
try {
if(taskSemaphore.tryAcquire(1, TimeUnit.SECONDS)) {
taskSemaphore.release();
} else {
log.error("Timed out waiting for task semaphore to be available to cancel, cancelling anyway");
}
} catch(InterruptedException e) {
log.error("Interrupted while waiting for task semaphore to be available to cancel, cancelling anyway");
}
return super.cancel();
if(stream != null) {
stream.close();
opened.set(false);
}
return cancelled;
}
public void close() {
capture.close();
public synchronized void close() {
if(!captureClosed.get()) {
captureClosed.set(true);
capture.close();
}
}
public PropertyLimits getZoomLimits() {
@@ -262,9 +319,6 @@ public class WebcamService extends ScheduledService<Image> {
}
private Result readQR(BufferedImage bufferedImage) {
LuminanceSource source = new BufferedImageLuminanceSource(bufferedImage);
BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(source));
try {
com.sparrowwallet.bokmakierie.Result result = bokmakierie.scan(bufferedImage);
if(result != null) {
@@ -282,6 +336,8 @@ public class WebcamService extends ScheduledService<Image> {
}
try {
LuminanceSource source = new BufferedImageLuminanceSource(bufferedImage);
BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(source));
return qrReader.decode(bitmap, Map.of(DecodeHintType.TRY_HARDER, Boolean.TRUE));
} catch(ReaderException e) {
// fall thru, it means there is no QR code in image
@@ -336,6 +392,10 @@ public class WebcamService extends ScheduledService<Image> {
return devices;
}
public List<CaptureDevice> getAvailableDevices() {
return availableDevices;
}
public Set<WebcamResolution> getResolutions() {
return resolutions;
}
@@ -376,8 +436,12 @@ public class WebcamService extends ScheduledService<Image> {
return opening;
}
public BooleanProperty closedProperty() {
return closed;
public BooleanProperty openedProperty() {
return opened;
}
public boolean getCancelRequested() {
return cancelRequested.get();
}
public static <T extends Enum<T>> T getNearestEnum(T target) {
@@ -385,10 +449,27 @@ public class WebcamService extends ScheduledService<Image> {
}
public static <T extends Enum<T>> T getNearestEnum(T target, T[] values) {
int ordinal = target.ordinal();
return Stream.concat(ordinal > 0 ? Stream.of(values[ordinal - 1]) : Stream.empty(), ordinal < values.length - 1 ? Stream.of(values[ordinal + 1]) : Stream.empty())
.findFirst()
.orElse(null);
if(values == null || values.length == 0) {
return null;
}
int targetOrdinal = target.ordinal();
if(values.length == 1) {
return values[0];
}
for(int i = 0; i < values.length; i++) {
if(targetOrdinal < values[i].ordinal()) {
if(i == 0) {
return values[0];
}
int diffToPrev = Math.abs(targetOrdinal - values[i - 1].ordinal());
int diffToNext = Math.abs(targetOrdinal - values[i].ordinal());
return diffToPrev <= diffToNext ? values[i - 1] : values[i];
}
}
return values[values.length - 1];
}
private static class CroppedDimension {
@@ -61,7 +61,7 @@ public class WebcamView {
});
service.valueProperty().addListener((observable, oldValue, newValue) -> {
if(newValue != null) {
if(newValue != null && !service.getCancelRequested()) {
imageProperty.set(newValue);
}
});
@@ -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);
@@ -6,12 +6,18 @@ import java.util.Map;
public class BlockSummaryEvent {
private final Map<Integer, BlockSummary> blockSummaryMap;
private final Double nextBlockMedianFeeRate;
public BlockSummaryEvent(Map<Integer, BlockSummary> blockSummaryMap) {
public BlockSummaryEvent(Map<Integer, BlockSummary> blockSummaryMap, Double nextBlockMedianFeeRate) {
this.blockSummaryMap = blockSummaryMap;
this.nextBlockMedianFeeRate = nextBlockMedianFeeRate;
}
public Map<Integer, BlockSummary> getBlockSummaryMap() {
return blockSummaryMap;
}
public Double getNextBlockMedianFeeRate() {
return nextBlockMedianFeeRate;
}
}
@@ -1,6 +1,7 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.protocol.BlockHeader;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.net.MempoolRateSize;
import java.util.List;
@@ -13,6 +14,7 @@ public class ConnectionEvent extends FeeRatesUpdatedEvent {
private final int blockHeight;
private final BlockHeader blockHeader;
private final Double minimumRelayFeeRate;
private final Double previousMinimumRelayFeeRate;
public ConnectionEvent(List<String> serverVersion, String serverBanner, int blockHeight, BlockHeader blockHeader, Map<Integer, Double> targetBlockFeeRates, Set<MempoolRateSize> mempoolRateSizes, Double minimumRelayFeeRate) {
super(targetBlockFeeRates, mempoolRateSizes);
@@ -21,6 +23,7 @@ public class ConnectionEvent extends FeeRatesUpdatedEvent {
this.blockHeight = blockHeight;
this.blockHeader = blockHeader;
this.minimumRelayFeeRate = minimumRelayFeeRate;
this.previousMinimumRelayFeeRate = AppServices.getMinimumRelayFeeRate();
}
public List<String> getServerVersion() {
@@ -42,4 +45,8 @@ public class ConnectionEvent extends FeeRatesUpdatedEvent {
public Double getMinimumRelayFeeRate() {
return minimumRelayFeeRate;
}
public Double getPreviousMinimumRelayFeeRate() {
return previousMinimumRelayFeeRate;
}
}
@@ -7,13 +7,23 @@ import java.util.Set;
public class FeeRatesUpdatedEvent extends MempoolRateSizesUpdatedEvent {
private final Map<Integer, Double> targetBlockFeeRates;
private final Double nextBlockMedianFeeRate;
public FeeRatesUpdatedEvent(Map<Integer, Double> targetBlockFeeRates, Set<MempoolRateSize> mempoolRateSizes) {
this(targetBlockFeeRates, mempoolRateSizes, null);
}
public FeeRatesUpdatedEvent(Map<Integer, Double> targetBlockFeeRates, Set<MempoolRateSize> mempoolRateSizes, Double nextBlockMedianFeeRate) {
super(mempoolRateSizes);
this.targetBlockFeeRates = targetBlockFeeRates;
this.nextBlockMedianFeeRate = nextBlockMedianFeeRate;
}
public Map<Integer, Double> getTargetBlockFeeRates() {
return targetBlockFeeRates;
}
public Double getNextBlockMedianFeeRate() {
return nextBlockMedianFeeRate;
}
}
@@ -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;
@@ -0,0 +1,17 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Payment;
import java.util.List;
public class RequestSendToManyEvent {
private final List<Payment> payments;
public RequestSendToManyEvent(List<Payment> payments) {
this.payments = payments;
}
public List<Payment> getPayments() {
return payments;
}
}
@@ -17,12 +17,13 @@ public class SpendUtxoEvent {
private final boolean requireAllUtxos;
private final BlockTransaction replacedTransaction;
private final PaymentCode paymentCode;
private final boolean allowPaymentChanges;
public SpendUtxoEvent(Wallet wallet, List<BlockTransactionHashIndex> utxos) {
this(wallet, utxos, null, null, null, false, null);
this(wallet, utxos, null, null, null, false, null, true);
}
public SpendUtxoEvent(Wallet wallet, List<BlockTransactionHashIndex> utxos, List<Payment> payments, List<byte[]> opReturns, Long fee, boolean requireAllUtxos, BlockTransaction replacedTransaction) {
public SpendUtxoEvent(Wallet wallet, List<BlockTransactionHashIndex> utxos, List<Payment> payments, List<byte[]> opReturns, Long fee, boolean requireAllUtxos, BlockTransaction replacedTransaction, boolean allowPaymentChanges) {
this.wallet = wallet;
this.utxos = utxos;
this.payments = payments;
@@ -31,6 +32,7 @@ public class SpendUtxoEvent {
this.requireAllUtxos = requireAllUtxos;
this.replacedTransaction = replacedTransaction;
this.paymentCode = null;
this.allowPaymentChanges = allowPaymentChanges;
}
public SpendUtxoEvent(Wallet wallet, List<Payment> payments, List<byte[]> opReturns, PaymentCode paymentCode) {
@@ -42,6 +44,7 @@ public class SpendUtxoEvent {
this.requireAllUtxos = false;
this.replacedTransaction = null;
this.paymentCode = paymentCode;
this.allowPaymentChanges = false;
}
public Wallet getWallet() {
@@ -75,4 +78,8 @@ public class SpendUtxoEvent {
public PaymentCode getPaymentCode() {
return paymentCode;
}
public boolean allowPaymentChanges() {
return allowPaymentChanges;
}
}
@@ -0,0 +1,9 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.protocol.Transaction;
public class TransactionOutputsChangedEvent extends TransactionChangedEvent {
public TransactionOutputsChangedEvent(Transaction transaction) {
super(transaction);
}
}
@@ -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;
}
}
@@ -14,9 +14,16 @@ import java.util.List;
*/
public class WalletNodeHistoryChangedEvent {
private final String scriptHash;
private final String status;
public WalletNodeHistoryChangedEvent(String scriptHash) {
this.scriptHash = scriptHash;
this.status = null;
}
public WalletNodeHistoryChangedEvent(String scriptHash, String status) {
this.scriptHash = scriptHash;
this.status = status;
}
public WalletNode getWalletNode(Wallet wallet) {
@@ -70,4 +77,8 @@ public class WalletNodeHistoryChangedEvent {
public String getScriptHash() {
return scriptHash;
}
public String getStatus() {
return status;
}
}
@@ -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();
@@ -213,6 +214,13 @@ public class GlyphUtils {
return busyGlyph;
}
public static Glyph getUpArrowGlyph() {
Glyph upGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.ARROW_UP);
upGlyph.getStyleClass().add("arrow-up");
upGlyph.setFontSize(12);
return upGlyph;
}
public static Glyph getDownArrowGlyph() {
Glyph downGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.ARROW_DOWN);
downGlyph.getStyleClass().add("arrow-down");
@@ -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");
@@ -192,7 +192,7 @@ public class Bip129 implements KeystoreFileExport, KeystoreFileImport, WalletExp
public void exportWallet(Wallet wallet, OutputStream outputStream, String password) throws ExportException {
try {
String record = "BSMS 1.0\n" +
OutputDescriptor.getOutputDescriptor(wallet) +
OutputDescriptor.getOutputDescriptor(wallet, KeyPurpose.DEFAULT_PURPOSES, null) +
"\n/0/*,/1/*\n" +
wallet.getNode(KeyPurpose.RECEIVE).getChildren().iterator().next().getAddress();
outputStream.write(record.getBytes(StandardCharsets.UTF_8));
@@ -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);
}
}
}
@@ -9,7 +9,7 @@ import java.io.InputStream;
public class BlueWalletMultisig extends ColdcardMultisig {
@Override
public String getName() {
return "Blue Wallet Vault Multisig";
return "BlueWallet Vault Multisig";
}
@Override
@@ -21,7 +21,7 @@ public class BlueWalletMultisig extends ColdcardMultisig {
public Wallet importWallet(InputStream inputStream, String password) throws ImportException {
Wallet wallet = super.importWallet(inputStream, password);
for(Keystore keystore : wallet.getKeystores()) {
keystore.setLabel(keystore.getLabel().replace("Coldcard", "Blue Wallet"));
keystore.setLabel(keystore.getLabel().replace("Coldcard", "BlueWallet"));
keystore.setWalletModel(WalletModel.BLUE_WALLET);
}
@@ -30,12 +30,12 @@ public class BlueWalletMultisig extends ColdcardMultisig {
@Override
public String getWalletImportDescription() {
return "Import file or QR created by using the Wallet > Export Coordination Setup feature on your Blue Wallet Vault wallet.";
return "Import file or QR created by using the Wallet > Export Coordination Setup feature on your BlueWallet Vault wallet.";
}
@Override
public String getWalletExportDescription() {
return "Export file that can be read by Blue Wallet using the Add Wallet > Vault > Import wallet feature.";
return "Export file that can be read by BlueWallet using the Add Wallet > Vault > Import wallet feature.";
}
@Override
@@ -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());
}
@@ -2,10 +2,12 @@ package com.sparrowwallet.sparrow.io;
import com.google.gson.*;
import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.drongo.protocol.Transaction;
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;
@@ -45,22 +47,29 @@ 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;
private boolean showDeprecatedImportExport = false;
private boolean signBsmsExports = false;
private boolean preventSleep = false;
private Boolean connectToBroadcast;
private Boolean connectToResolve;
private Boolean suggestSendToMany;
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;
private String webcamDevice;
private String webcamDeviceId;
private ServerType serverType;
private Server publicElectrumServer;
private Server coreServer;
@@ -69,6 +78,7 @@ public class Config {
private File coreDataDir;
private String coreAuth;
private boolean useLegacyCoreWallet;
private boolean legacyServer;
private Server electrumServer;
private List<Server> recentElectrumServers;
private File electrumServerCert;
@@ -79,6 +89,7 @@ public class Config {
private int maxPageSize = DEFAULT_PAGE_SIZE;
private boolean usePayNym;
private boolean mempoolFullRbf;
private double minRelayFeeRate = Transaction.DEFAULT_MIN_RELAY_FEE;
private Double appWidth;
private Double appHeight;
@@ -287,6 +298,15 @@ public class Config {
flush();
}
public boolean isChunkAddresses() {
return chunkAddresses;
}
public void setChunkAddresses(boolean chunkAddresses) {
this.chunkAddresses = chunkAddresses;
flush();
}
public boolean isHideEmptyUsedAddresses() {
return hideEmptyUsedAddresses;
}
@@ -296,6 +316,15 @@ public class Config {
flush();
}
public boolean isHideAmounts() {
return hideAmounts;
}
public void setHideAmounts(boolean hideAmounts) {
this.hideAmounts = hideAmounts;
flush();
}
public boolean isShowTransactionHex() {
return showTransactionHex;
}
@@ -347,6 +376,34 @@ public class Config {
public void setPreventSleep(boolean preventSleep) {
this.preventSleep = preventSleep;
flush();
}
public Boolean getConnectToBroadcast() {
return connectToBroadcast;
}
public void setConnectToBroadcast(Boolean connectToBroadcast) {
this.connectToBroadcast = connectToBroadcast;
flush();
}
public Boolean getConnectToResolve() {
return connectToResolve;
}
public void setConnectToResolve(Boolean connectToResolve) {
this.connectToResolve = connectToResolve;
flush();
}
public Boolean getSuggestSendToMany() {
return suggestSendToMany;
}
public void setSuggestSendToMany(Boolean suggestSendToMany) {
this.suggestSendToMany = suggestSendToMany;
flush();
}
public List<File> getRecentWalletFiles() {
@@ -384,6 +441,15 @@ public class Config {
flush();
}
public QREncoding getQrEncoding() {
return qrEncoding;
}
public void setQrEncoding(QREncoding qrEncoding) {
this.qrEncoding = qrEncoding;
flush();
}
public WebcamResolution getWebcamResolution() {
return webcamResolution;
}
@@ -415,6 +481,15 @@ public class Config {
flush();
}
public String getWebcamDeviceId() {
return webcamDeviceId;
}
public void setWebcamDeviceId(String webcamDeviceId) {
this.webcamDeviceId = webcamDeviceId;
flush();
}
public ServerType getServerType() {
return serverType;
}
@@ -549,6 +624,15 @@ public class Config {
flush();
}
public boolean isLegacyServer() {
return legacyServer;
}
public void setLegacyServer(boolean legacyServer) {
this.legacyServer = legacyServer;
flush();
}
public Server getElectrumServer() {
return electrumServer;
}
@@ -667,6 +751,14 @@ public class Config {
flush();
}
public double getMinRelayFeeRate() {
return minRelayFeeRate;
}
public void setMinRelayFeeRate(double minRelayFeeRate) {
this.minRelayFeeRate = minRelayFeeRate;
}
public Double getAppWidth() {
return appWidth;
}
@@ -126,7 +126,7 @@ public class Descriptor implements WalletImport, WalletExport {
} else if(line.startsWith("#")) {
continue;
} else {
paragraph.append(line);
paragraph.append(line.replaceFirst("^.+:", "").trim());
}
}
@@ -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()));
}
}
@@ -37,7 +37,8 @@ public class ElectrumPersonalServer implements WalletExport {
try {
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8));
writer.write("# Electrum Personal Server configuration file fragments\n");
writer.write("# Copy the lines below into the relevant sections in your EPS config.ini file\n\n");
writer.write("# First close Sparrow and edit your config file in Sparrow home to set \"legacyServer\": true\n");
writer.write("# Then copy the lines below into the relevant sections in your EPS config.ini file\n\n");
writer.write("# Copy into [master-public-keys] section\n");
Wallet masterWallet = wallet.isMasterWallet() ? wallet : wallet.getMasterWallet();
writeWalletXpub(masterWallet, writer);
@@ -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) {}
}

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