Compare commits

...
166 Commits
Author SHA1 Message Date
Craig Raw 0d16c87b40 minor caravan name update 2024-05-13 11:36:04 +02:00
Craig Raw b59a65dcfe export electrum wallets with only usb capable hardware wallets as hardware keystore types 2024-05-10 09:54:33 +02:00
Craig Raw 87cc28e0a4 improve error message when importing invalid coldcard multisig config 2024-05-09 15:44:47 +02:00
Craig Raw 1187925543 fix wallet loading failure icon color in tab label when using dark theme 2024-05-09 14:42:13 +02:00
Craig Raw cd4edab4ae add testnet4 public server and tx broadcast from mempool.space 2024-05-09 13:00:11 +02:00
Craig Raw daf320f36b optionally show output descriptor qr export as bbqr, update coldcard import and export instructions 2024-05-09 12:20:54 +02:00
Craig Raw f6ff92865b avoid adding testnet symlink in windows as admin privileges are required 2024-05-08 16:15:31 +02:00
Craig Raw d420c71673 add testnet4 network support 2024-05-08 15:50:15 +02:00
Craig Raw 07101b3ca0 add additional fingerprint check when finding signing nodes from provided psbt input derivation paths 2024-05-06 09:51:34 +02:00
Craig Raw 00f7f3f5b3 update default derivation path for unknown unchained signer 2024-05-06 08:17:04 +02:00
Craig Raw 5d2c133401 fix single character multisig output descriptor threshold parsing issue 2024-05-03 12:16:25 +02:00
Craig Raw 7b0dfd66a7 fix premature decompression of bbqr zlib parts 2024-05-03 11:46:33 +02:00
Craig Raw 83719e7df2 fix signing regression on psbts with external inputs 2024-04-30 11:55:28 +02:00
Craig Raw f1b246f0b0 bump to v1.9.1 2024-04-26 09:07:37 +02:00
Craig Raw 599880ea5c improve samourai backup import error message 2024-04-25 15:21:13 +02:00
Craig Raw d625bab02e bump to v1.9.0 2024-04-25 15:13:10 +02:00
Craig Raw 1676676e06 remove whirlpool and soroban features and dependencies 2024-04-25 15:11:22 +02:00
Craig Raw f7e603118f bump to v1.8.7 2024-04-24 11:27:58 +02:00
Craig Raw f6fd889712 ignore scroll events with zero scroll movement 2024-04-24 09:00:36 +02:00
Craig Raw 21d91d3d10 add additional check against existing child wallet names when suggesting new accounts to add 2024-04-23 08:32:58 +02:00
Craig Raw f1cddc28e7 copy context menu changes on receive tab 2024-04-22 16:57:44 +02:00
Craig Raw 1887e1c7b0 allow editing of the output descriptor of a new account on a watch only wallet 2024-04-22 13:29:41 +02:00
Craig Raw 3e870f362d fall back to sparrow logo for faulty wallet icon loads 2024-04-22 12:18:28 +02:00
Craig Raw 665d70b845 fix freeze on account settings tab loading wallet type icon 2024-04-22 12:05:24 +02:00
Craig Raw c2cbe62a5a fix showing multiple password dialogs on bip47 paynym wallet linking 2024-04-22 10:02:02 +02:00
Craig Raw c6b6e74515 maintain strong reference to key derivation service until action completion 2024-04-19 10:49:42 +02:00
Craig Raw 8ddc494b53 parse output descriptors with missing fingerprints in key origin information 2024-04-19 09:59:48 +02:00
Craig Raw 33f439f49a minor text changes 2024-04-19 09:43:18 +02:00
Craig Raw d68ab40c94 add wallet import for samourai backup export 2024-04-18 16:04:06 +02:00
Craig Raw 31346e2afa add mix selected button to the postmix account in desktop and terminal 2024-04-18 13:22:50 +02:00
Craig Raw c407a41475 add fine adjustment control for fee rate slider using mouse scroll 2024-04-18 12:12:40 +02:00
Craig Raw 8baa8e2e96 support changing the frame rate of animated qrs with mouse scroll on the qr image 2024-04-18 11:45:10 +02:00
Craig Raw 72adbe44a7 cleanup 2024-04-18 10:06:35 +02:00
Craig Raw dd64c18c21 followup 2024-04-18 09:45:58 +02:00
Craig Raw 2354b061a9 add user write permissions to jvm legal files 2024-04-18 09:29:11 +02:00
Craig Raw a5050117a3 handle null proxy configuration when fetching proxy 2024-04-17 14:11:41 +02:00
Craig Raw f245b57022 add export search results as csv functionality 2024-04-17 14:01:55 +02:00
Craig Raw d3752a856b make search wallet dialog non-modal, close any non-modal dialogs on application closing 2024-04-17 12:33:13 +02:00
Craig Raw fe7dba6d83 support searching on multiple addresses, txids and utxos in a search phrase separated by spaces 2024-04-17 09:22:31 +02:00
Craig Raw 2d0a94d024 add copy context menu to date/address/output column in search wallet dialog 2024-04-16 14:34:27 +02:00
Craig Raw 41146310d6 allow label editing in the search wallet dialog 2024-04-16 13:26:58 +02:00
Craig Raw a167f6aedb add show all wallets summary 2024-04-16 12:31:56 +02:00
Craig Raw 0fed7c45ee change transactions and utxos csv export to use utc timezone for dates 2024-04-16 10:35:42 +02:00
Craig Raw 5a0df265bc unminimize existing app window when second instance is launched 2024-04-15 12:40:17 +02:00
Craig Raw 646b8b0e65 show discover button when adding accounts on a bitcoin core connection, but warn user account discovery is not generally supported if no accounts are found 2024-04-15 09:55:24 +02:00
Craig Raw c9b40b1973 fall back to coldcard singlesig import if multisig format import fails 2024-04-15 08:32:38 +02:00
Craig Raw 9ec5b6ce26 fix network enum error on startup in signet 2024-04-14 08:13:30 +02:00
Craig Raw 93893111c6 fix issue where gap limit was not increased when wallet could partially sign transaction 2024-04-12 09:12:33 +02:00
Craig Raw 3600d32ffd bump to v1.8.6 2024-04-11 13:48:12 +02:00
Craig Raw 1e0855c11d use hwi 3.0.0 built on ubuntu 20.04 amd64 2024-04-11 10:46:44 +02:00
Craig Raw 15cb028951 merge whirlpool 1.x client using decentralized soroban 2024-04-11 08:37:28 +02:00
Craig Raw e178168bec fix npe on soroban counterparty timeout 2024-04-11 08:09:08 +02:00
Craig Raw 5696e00cb5 fix stonewallx2 transaction address selection 2024-04-10 12:42:11 +02:00
craigrawandGitHub 594a873f20 Merge pull request #1369 from zeroleak/whirlpool-1.0.6
upgrade to whirlpool 1.0.6 to support simpler onion switching
2024-04-10 10:34:47 +02:00
zeroleak da30d4223a upgrade whirlpool-client 1.0.6 2024-04-10 09:52:41 +02:00
Craig Raw 2441b4d7c3 handle coldcard singlesig file imports containing p2sh-p2wsh 2024-04-10 07:44:24 +02:00
Craig Raw cc739a71e9 followup 2024-04-09 11:03:20 +02:00
Craig Raw 5f98eb9eb9 followup 2024-04-08 13:45:52 +02:00
Craig Raw 5aa25b98c3 supporting importing labels from electrum history csv using wallet labels import 2024-04-08 13:38:36 +02:00
Craig Raw 5058cd283d use hwi 3.0.0 built on ubuntu 20.04 aarch64 2024-04-08 11:36:27 +02:00
Craig Raw af6171692b upgrade to hwi 3.0.0 2024-04-08 11:19:34 +02:00
Craig Raw 3c631fa653 add button to display seedqr on seed display dialog after warning 2024-04-05 13:50:05 +02:00
Craig Raw 10a796098b keep any existing seeds with matching fingerprints when changing a wallets output descriptor, rederiving the xpub if necessary 2024-04-05 12:11:46 +02:00
Craig Raw 8ac642b09c set default derivation for mnemonic and xprv imports to current keystore derivation 2024-04-04 14:05:41 +02:00
Craig Raw 33d9f260c4 close qr display dialog for current fresh address when it updates 2024-04-04 12:50:03 +02:00
Craig Raw 86247c6440 dynamically retrieve currently selected pool when tx0 preview is fetched 2024-04-04 12:15:33 +02:00
Craig Raw c39425ed3b improve logging on tor cookie read errors 2024-04-04 10:41:32 +02:00
Craig Raw e5e94c3ea6 compute latest block on open 2024-04-03 12:40:51 +02:00
Craig Raw 9ba4458f48 fix tor proxy switching for whirlpool client and mixing bip47 utxos 2024-04-02 17:14:19 +02:00
craigrawandGitHub a9fd7c263f Merge pull request #1349 from zeroleak/whirlpool-1.0.5
whirlpool 1.0.5
2024-04-02 11:05:38 +02:00
zeroleak 6ef1313137 upgrade whirlpool-client 1.0.5 2024-04-01 16:32:56 +02:00
Craig Raw 8e66db0237 Merge branch 'master' into whirlpool-1.0.0 2024-03-29 10:12:11 +02:00
Craig Raw 6da98befe1 update drongo to latest 2024-03-29 10:10:51 +02:00
Craig Raw 6b4c301458 always bring first instance to foreground when second instance is closed 2024-03-29 09:36:11 +02:00
Craig Raw 86ff7b8cf9 optimize initial fee rates fetching by avoiding double server fee estimate and histogram calls where possible 2024-03-28 15:36:47 +02:00
Craig Raw a805d9e036 use cached fee rate estimates on initial server connection if available, and retrieve updates from fee rate source immediately afterwards 2024-03-28 14:14:32 +02:00
Craig Raw f0bfc44e72 avoid saving lock file link for default instance if environment variable is set 2024-03-28 12:01:48 +02:00
Craig Raw 0fad93524e delete existing instance lock file and recreate if client connection fails 2024-03-28 10:56:37 +02:00
Craig Raw c1fc8712d5 use default sparrow home location in user dir for instance lock file pointer 2024-03-28 09:12:44 +02:00
Craig Raw 5d674b7e91 followup to handle situations where creating symlinks fails 2024-03-27 13:08:01 +02:00
Craig Raw d1a353ae53 use unix sockets in sparrow home for instance checks and message passing, with system symlink to find existing instances for files and uris 2024-03-27 12:45:05 +02:00
Craig Raw 08ec158d19 support cookie authentication for tor control port 2024-03-26 13:26:32 +02:00
Craig Raw 2e8112cba0 add restart in different home folder to tools menu 2024-03-26 11:37:46 +02:00
Craig Raw c108741b6f upgrade to pgpainless 1.6.7 with basic modules support 2024-03-26 10:04:05 +02:00
Craig Raw 4450d625dd external destination now checks for custom postmix handler 2024-03-25 17:01:31 +02:00
Craig Raw 2e1ee0c5b2 ensure whirlpool wallets use nfkd encoding for passphrases 2024-03-25 16:57:36 +02:00
Craig Raw 1c3c2d8089 cache tx0info and clear after tx0 broadcast 2024-03-25 16:23:24 +02:00
craigrawandGitHub a4991064af Merge pull request #1333 from zeroleak/whirlpool-1.0.0-beta23
upgrade to whirlpool 1.0.1
2024-03-25 14:54:48 +02:00
Craig Raw 6ea6f4b5d2 add new wallet, open wallet and import wallet hyperlinks to background text shown when no tabs are open 2024-03-25 14:25:31 +02:00
Craig Raw 210d52c001 change unselected tabs to be lighter colored than selected tabs in dark theme 2024-03-25 13:05:16 +02:00
zeroleak fcbed8795f upgrade whirlpool-client 1.0.1 2024-03-20 15:24:42 +01:00
zeroleak 0d9e798bb7 upgrade whirlpool-client 1.0.0-beta23 2024-03-19 15:01:15 +01:00
Craig Raw 9d0c35bc75 handle import of ur crypto-hdkey without source fingerprint 2024-03-18 07:48:55 +02:00
Craig Raw f3c44e6f3e fix script display of uncompressed pubkeys 2024-03-16 08:08:18 +02:00
Craig Raw 14d0437424 indicate if disconnected on startup, and display status with instruction on how to connect for longer 2024-03-14 09:36:02 +02:00
Craig Raw d2934c94c5 disable manifest field in download verify dialog if signature signs release file directly 2024-03-14 09:05:14 +02:00
Craig Raw 2e847199f5 fix message signing by qr with no action on scan qr 2024-03-14 08:49:52 +02:00
Craig Raw c9d1650ed4 various updates and fixes 2024-03-13 15:58:43 +02:00
zeroleak da74089969 upgrade to whirlpool 1.0.0-beta10 2024-03-11 02:12:56 +01:00
Craig Raw d1ac5b076e avoid adding block explorer to transaction context menu when configured to none 2024-03-09 10:24:42 +02:00
Craig Raw e1564217ed bump to v1.8.5 2024-03-07 20:16:35 +02:00
Craig Raw 2ef66d504f show pgp fingerprint in pgp verification signed by field tooltip 2024-03-07 08:21:46 +02:00
Craig Raw f0bd07b4b7 fix tests with derivation paths matching other networks 2024-03-07 08:13:48 +02:00
Craig Raw e43b783664 update build plugins to remove gradle warnings 2024-03-07 07:59:53 +02:00
Craig Raw 2d5e24366c revert to custom javafx gradle plugin to avoid monocle classnotfound issue 2024-03-07 07:50:23 +02:00
Craig Raw 195854fb6f bump to v1.8.4 2024-03-06 16:26:01 +02:00
Craig Raw 9e4eed965c show file name in invalid file dialog 2024-03-06 14:17:15 +02:00
Craig Raw c034dbe89e better handling of multiple verification file drop 2024-03-06 14:11:37 +02:00
Craig Raw 4cb2e1ef9f show bbqr option for bip129 and file menu qr transaction display 2024-03-06 13:33:13 +02:00
Craig Raw 7258b049c9 followup 2024-03-06 12:40:57 +02:00
Craig Raw 5475a81e3a avoid disabling public key field when user provided key is used 2024-03-06 12:31:08 +02:00
Craig Raw f003b6d732 improve asc file type description 2024-03-06 08:40:52 +02:00
zeroleak 249a01c208 upgrade to whirlpool 1.0.0-beta4 2024-03-05 18:32:20 +01:00
Craig Raw c34a423f95 followup 2024-03-05 15:10:33 +02:00
Craig Raw 55e7689f7c open files and uris after initial wallet loading 2024-03-05 14:09:52 +02:00
Craig Raw 914afe9a8d perform gpg verification in separate thread 2024-03-05 12:52:37 +02:00
Craig Raw 81c7bc7ecb improvements to download verifier drag and drop 2024-03-05 12:23:11 +02:00
Craig Raw 3d18477560 add application/pgp-signature to handled mime types 2024-03-04 16:14:46 +02:00
Craig Raw 2c1f591c51 handle drop of manifest files as well 2024-03-04 15:30:25 +02:00
Craig Raw d109eaa654 handle signature and manifest file mixups, add file handler for .asc files 2024-03-04 15:07:56 +02:00
Craig Raw 803e43cb45 if validating derivations, disallow paths that match other networks 2024-03-01 11:24:21 +02:00
Craig Raw a45024ac70 followup 2024-03-01 10:06:30 +02:00
Craig Raw 0f05502af6 allow adding additional accounts (up to account 30) if accounts 0-9 have already been added 2024-03-01 09:51:23 +02:00
Craig Raw 6a496894e1 add import file to krux keystore and wallet import 2024-03-01 07:50:00 +02:00
Craig Raw d3b0eac51a improve qr reading by additionally scanning using boofcv 2024-02-29 15:32:29 +02:00
Craig Raw 2cc02e38e6 add restart in signet to tools menu, add mempool.space signet public server 2024-02-28 14:52:52 +02:00
Craig Raw ae29108656 cormorant: fix wallet loading with multiple ancestors in the mempool 2024-02-28 13:13:32 +02:00
Craig Raw 0ed8c6af7c improve script area display of inputs that spend a taproot script path 2024-02-28 10:29:14 +02:00
Craig Raw 6d7f02227a add seconds to date column for transactions and utxos csv export 2024-02-27 15:35:26 +02:00
Craig Raw 8f52039c7b followup 2024-02-27 15:27:26 +02:00
craigrawandGitHub f14e2fb020 Merge pull request #1281 from PrinceOfEgypt/master
add delay to rates service startup
2024-02-27 15:23:45 +02:00
PrinceOfEgypt cc9a557a2e Configure startup delay by platform 2024-02-26 09:22:17 -06:00
Craig Raw e50cc7126d make coldcard import and export functions scannable 2024-02-26 16:43:56 +02:00
PrinceOfEgyptandGitHub 6cdbba4bb3 Merge branch 'sparrowwallet:master' into master 2024-02-26 08:41:54 -06:00
Craig Raw 7f3885611a add support for bbqr encoding and decoding 2024-02-26 11:41:10 +02:00
PrinceOfEgypt 17ea75603f Create separate constant for RatesService delay 2024-02-25 12:14:11 -06:00
PrinceOfEgypt da1626070b Add 2 second delay to RatesService 2024-02-24 09:36:02 -06:00
Craig Raw 6f4d37d3ff confirm and close application before launching installer 2024-02-22 14:56:38 +02:00
Craig Raw 78b0c63f87 test for windows gpg home 2024-02-22 14:12:24 +02:00
Craig Raw 14d96bdb4e match gpg behaviour for loading user public keyrings 2024-02-22 13:50:58 +02:00
Craig Raw d73820464e add download verification dialog supporting pgp signatures and optional sha256 manifests 2024-02-22 13:35:06 +02:00
Craig Raw ff4ff90bb2 revert to java 18 due to windows code signing issue 2024-02-16 11:27:24 +02:00
Craig Raw d2940265fd fix drongo typo 2024-02-16 08:11:24 +02:00
Craig Raw 3f72a84afe increase whirlpool http client timeout 2024-02-16 07:58:46 +02:00
Craig Raw 521bbdd70e revert to build on ubuntu 20.04 for xz compression 2024-02-15 16:50:05 +02:00
Craig Raw 15c3150a4f update to build on ubuntu 22.0.4 2024-02-15 13:09:22 +02:00
Craig Raw 865c52e5d9 followup 2024-02-15 11:39:46 +02:00
Craig Raw ee195f2677 upgrade build to java 21 2024-02-15 10:32:29 +02:00
Craig Raw 1d50b4f296 upgrade junit tests from 4 to 5 2024-02-15 10:26:29 +02:00
Craig Raw 22310cd8c9 update guava, gson, junit, nightjar and slf4j dependencies 2024-02-15 09:24:21 +02:00
Craig Raw 78406fd024 include labels for hidden addresses in address csv export 2024-02-07 09:26:26 +02:00
Craig Raw 74a551ed01 add support for satschip nfc card 2024-02-07 09:19:55 +02:00
craigrawandGitHub 4de6bd5e83 Merge pull request #1256 from rex4539/typos
fix typos
2024-02-05 07:42:21 +02:00
Dimitris Apostolou 8d6230e834 Fix typos 2024-02-02 23:11:36 +02:00
Craig Raw 31042039d7 upgrade to hwi 2.4.0 2024-02-01 10:45:19 +02:00
Craig Raw 1ba501f5c8 add trezor safe 3 support (hwi update still required) 2024-01-30 09:37:00 +02:00
Craig Raw 55d5a97d99 avoid saving certificates for public servers 2024-01-30 08:03:38 +02:00
Craig Raw 37af663511 update gradle wrapper to 8.5 2024-01-29 14:35:58 +02:00
Craig Raw b757c76028 update drongo build with java 21 2024-01-29 12:21:29 +02:00
craigrawandGitHub 82e0b63ed0 Merge pull request #1244 from msgilligan/msgilligan-gradle-jlink-3.0.1
update jlink plugin to build with java 21
2024-01-29 12:20:21 +02:00
Sean Gilligan 7da3a55489 build.gradle: Update to JLink plugin 3.0.1
This adds support for building with JDK 21.
2024-01-24 10:27:25 -08:00
Craig Raw 7ebb92d90a crop y axis range in block target fee rates chart 2024-01-24 09:21:04 +02:00
Craig Raw 1d32b69345 add border to expanded transaction diagram on linux to handle some window managers 2024-01-22 09:52:32 +02:00
Craig Raw fbc49fd6f5 change cancel button text to close on qr display and scan dialogs 2024-01-22 09:15:55 +02:00
Craig Raw 30001051c7 avoid null labels when broadcasting a loaded transaction with no name 2024-01-22 09:12:00 +02:00
Craig Raw 1e74ae5f19 bump to v1.8.3 2024-01-18 13:01:59 +02:00
220 changed files with 5401 additions and 8831 deletions
+4 -4
View File
@@ -12,11 +12,11 @@ jobs:
matrix:
os: [windows-2022, ubuntu-20.04, macos-12]
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
with:
submodules: true
- name: Set up JDK 18.0.1
uses: actions/setup-java@v3
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '18.0.1'
@@ -31,7 +31,7 @@ jobs:
if: ${{ runner.os == 'Linux' }}
run: ./gradlew packageTarDistribution
- name: Upload Artifacts
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: Sparrow Build - ${{ runner.os }}
path: |
@@ -48,7 +48,7 @@ jobs:
run: for f in build/jpackage/sparrow*; do mv -v "$f" "${f/sparrow/sparrow-server}"; done;
- name: Upload Headless Artifact
if: ${{ runner.os == 'Linux' }}
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: Sparrow Build - ${{ runner.os }} Headless
path: |
+5 -3
View File
@@ -64,10 +64,12 @@ Usage: sparrow [options]
Possible Values: [ERROR, WARN, INFO, DEBUG, TRACE]
--network, -n
Network to use
Possible Values: [mainnet, testnet, regtest, signet]
Possible Values: [mainnet, testnet, regtest, signet, testnet4]
```
As a fallback, the network (mainnet, testnet, regtest or signet) can also be set using an environment variable `SPARROW_NETWORK`. For example:
Note that testnet currently refers to testnet3.
As a fallback, the network (mainnet, testnet, testnet4, regtest or signet) can also be set using an environment variable `SPARROW_NETWORK`. For example:
`export SPARROW_NETWORK=testnet`
@@ -83,7 +85,7 @@ When not explicitly configured using the command line argument above, Sparrow st
| Linux | ~/.sparrow |
| Windows | %APPDATA%/Sparrow |
Testnet, regtest and signet configurations (along with their wallets) are stored in subfolders to allow easy switching between networks.
Testnet3, testnet4, regtest and signet configurations (along with their wallets) are stored in subfolders to allow easy switching between networks.
## Reporting Issues
+60 -126
View File
@@ -2,12 +2,12 @@ import java.awt.GraphicsEnvironment
plugins {
id 'application'
id 'org-openjfx-javafxplugin'
id 'extra-java-module-info'
id 'org.beryx.jlink' version '2.26.0'
id 'org-openjfx-javafxplugin'
id 'org.beryx.jlink' version '3.0.1'
}
def sparrowVersion = '1.8.2'
def sparrowVersion = '1.9.1'
def os = org.gradle.internal.os.OperatingSystem.current()
def osName = os.getFamilyName()
if(os.macOsX) {
@@ -63,18 +63,19 @@ java {
dependencies {
//Any changes to the dependencies must be reflected in the module definitions below!
implementation(project(':drongo')) {
exclude group: 'org.hamcrest'
exclude group: 'junit'
}
implementation('com.google.guava:guava:31.1-jre')
implementation('com.google.code.gson:gson:2.8.6')
implementation(project(':drongo'))
implementation('com.google.guava:guava:33.0.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')
implementation('com.zaxxer:HikariCP:4.0.3') {
exclude group: 'org.slf4j'
}
implementation('org.jdbi:jdbi3-core:3.20.0') {
exclude group: 'org.slf4j'
}
implementation('org.jdbi:jdbi3-sqlobject:3.20.0')
implementation('org.jdbi:jdbi3-sqlobject:3.20.0') {
exclude group: 'org.slf4j'
}
implementation('org.flywaydb:flyway-core:7.10.7-SNAPSHOT')
implementation('org.fxmisc.richtext:richtextfx:0.10.4')
implementation('no.tornado:tornadofx-controls:1.0.4')
@@ -118,25 +119,26 @@ dependencies {
}
implementation('dev.bwt:bwt-jni:0.1.8')
implementation('net.sourceforge.javacsv:javacsv:2.0')
implementation('org.slf4j:jul-to-slf4j:1.7.30') {
implementation ('org.slf4j:slf4j-api:2.0.12')
implementation('org.slf4j:jul-to-slf4j:2.0.12') {
exclude group: 'org.slf4j'
}
implementation('com.sparrowwallet.nightjar:nightjar:0.2.39')
implementation('com.sparrowwallet.bokmakierie:bokmakierie:1.0')
implementation('org.eclipse.jetty:jetty-client:9.4.54.v20240208')
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-compress:1.25.0')
implementation('net.sourceforge.streamsupport:streamsupport:1.7.0')
implementation('com.github.librepdf:openpdf:1.3.30')
implementation('com.googlecode.lanterna:lanterna:3.1.1')
implementation('net.coobird:thumbnailator:0.4.18')
implementation('com.github.hervegirod:fxsvgimage:1.0b2')
implementation('com.sparrowwallet:toucan:0.9.0')
testImplementation('junit:junit:4.13.1')
}
application {
mainModule = 'com.sparrowwallet.sparrow'
mainClass = 'com.sparrowwallet.sparrow.SparrowWallet'
implementation('com.jcraft:jzlib:1.1.3')
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')
}
compileJava {
@@ -157,10 +159,14 @@ processResources {
}
test {
jvmArgs '--add-opens=java.base/java.io=com.google.gson'
useJUnitPlatform()
jvmArgs = ["--add-opens=java.base/java.io=ALL-UNNAMED", "--add-opens=java.base/java.io=com.google.gson"]
}
run {
application {
mainModule = 'com.sparrowwallet.sparrow'
mainClass = 'com.sparrowwallet.sparrow.SparrowWallet'
applicationDefaultJvmArgs = ["-XX:+HeapDumpOnOutOfMemoryError",
"--add-opens=javafx.graphics/com.sun.javafx.css=org.controlsfx.controls",
"--add-opens=javafx.graphics/javafx.scene=org.controlsfx.controls",
@@ -206,8 +212,6 @@ jlink {
uses 'org.flywaydb.core.extensibility.FlywayExtension'
uses 'org.flywaydb.core.internal.database.DatabaseType'
uses 'org.eclipse.jetty.http.HttpFieldPreEncoder'
uses 'org.eclipse.jetty.websocket.api.extensions.Extension'
uses 'org.eclipse.jetty.websocket.common.RemoteEndpointFactory'
}
options = ['--strip-native-commands', '--strip-java-debug-attributes', '--compress', '2', '--no-header-files', '--no-man-pages', '--ignore-signing-information', '--exclude-files', '**.png', '--exclude-resources', 'glob:/com.sparrowwallet.merged.module/META-INF/*']
@@ -236,11 +240,14 @@ jlink {
"--add-reads=com.sparrowwallet.merged.module=java.desktop",
"--add-reads=com.sparrowwallet.merged.module=java.sql",
"--add-reads=com.sparrowwallet.merged.module=com.sparrowwallet.sparrow",
"--add-reads=com.sparrowwallet.merged.module=logback.classic",
"--add-reads=com.sparrowwallet.merged.module=ch.qos.logback.classic",
"--add-reads=com.sparrowwallet.merged.module=org.slf4j",
"--add-reads=com.sparrowwallet.merged.module=com.fasterxml.jackson.databind",
"--add-reads=com.sparrowwallet.merged.module=com.fasterxml.jackson.annotation",
"--add-reads=com.sparrowwallet.merged.module=com.fasterxml.jackson.core",
"--add-reads=com.sparrowwallet.merged.module=co.nstant.in.cbor",
"--add-reads=com.sparrowwallet.merged.module=org.bouncycastle.pg",
"--add-reads=com.sparrowwallet.merged.module=org.bouncycastle.provider",
"--add-reads=kotlin.stdlib=kotlinx.coroutines.core"]
if(os.windows) {
@@ -260,7 +267,7 @@ jlink {
appVersion = "${sparrowVersion}"
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/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', '--file-associations', 'src/main/deploy/bitcoin.properties', '--file-associations', 'src/main/deploy/auth47.properties', '--file-associations', 'src/main/deploy/lightning.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']
@@ -283,6 +290,16 @@ jlink {
}
}
tasks.jlink.finalizedBy "addUserWritePermission"
task addUserWritePermission(type: Exec) {
if(os.windows) {
commandLine 'icacls', "$buildDir\\image\\legal", '/grant', 'Users:(OI)(CI)F', '/T'
} else {
commandLine 'chmod', '-R', 'u+w', "$buildDir/image/legal"
}
}
task removeGroupWritePermission(type: Exec) {
commandLine 'chmod', '-R', 'g-w', "$buildDir/jpackage/Sparrow"
}
@@ -405,41 +422,16 @@ extraJavaModuleInfo {
module('javacsv-2.0.jar', 'net.sourceforge.javacsv', '2.0') {
exports('com.csvreader')
}
module('jul-to-slf4j-1.7.30.jar', 'org.slf4j.jul.to.slf4j', '1.7.30') {
exports('org.slf4j.bridge')
requires('java.logging')
requires('org.slf4j')
}
module('jeromq-0.5.0.jar', 'jeromq', '0.5.0') {
exports('org.zeromq')
}
module('json-simple-1.1.1.jar', 'json.simple', '1.1.1') {
exports('org.json.simple')
}
module('logback-classic-1.2.8.jar', 'logback.classic', '1.2.8') {
exports('ch.qos.logback.classic')
requires('org.slf4j')
requires('logback.core')
requires('java.xml')
requires('java.logging')
}
module('failureaccess-1.0.1.jar', 'failureaccess', '1.0.1') {
exports('com.google.common.util.concurrent.internal')
exports('org.json.simple.parser')
}
module('listenablefuture-9999.0-empty-to-avoid-conflict-with-guava.jar', 'com.google.guava.listenablefuture', '9999.0-empty-to-avoid-conflict-with-guava')
module('guava-31.1-jre.jar', 'com.google.common', '31.1-jre') {
exports('com.google.common.eventbus')
exports('com.google.common.net')
exports('com.google.common.base')
exports('com.google.common.collect')
exports('com.google.common.io')
exports('com.google.common.primitives')
exports('com.google.common.math')
requires('failureaccess')
requires('java.logging')
}
module('jsr305-3.0.2.jar', 'com.google.code.findbugs.jsr305', '3.0.2')
module('j2objc-annotations-1.3.jar', 'com.google.j2objc.j2objc.annotations', '1.3')
module('j2objc-annotations-2.8.jar', 'com.google.j2objc.j2objc.annotations', '2.8')
module('jdbi3-core-3.20.0.jar', 'org.jdbi.v3.core', '3.20.0') {
exports('org.jdbi.v3.core')
exports('org.jdbi.v3.core.mapper')
@@ -450,6 +442,7 @@ extraJavaModuleInfo {
requires('io.leangen.geantyref')
requires('java.sql')
requires('org.slf4j')
requires('com.github.benmanes.caffeine')
}
module('geantyref-1.3.11.jar', 'io.leangen.geantyref', '1.3.11') {
exports('io.leangen.geantyref')
@@ -508,65 +501,21 @@ extraJavaModuleInfo {
exports('co.nstant.in.cbor.model')
exports('co.nstant.in.cbor.builder')
}
module('nightjar-0.2.39.jar', 'com.sparrowwallet.nightjar', '0.2.39') {
requires('com.google.common')
requires('net.sourceforge.streamsupport')
requires('org.slf4j')
requires('org.bouncycastle.provider')
requires('com.fasterxml.jackson.databind')
requires('com.fasterxml.jackson.annotation')
requires('com.fasterxml.jackson.core')
requires('logback.classic')
requires('org.json')
requires('io.reactivex.rxjava2')
exports('com.samourai.http.client')
exports('com.samourai.tor.client')
exports('com.samourai.wallet.api.backend')
exports('com.samourai.wallet.api.backend.beans')
exports('com.samourai.wallet.client.indexHandler')
exports('com.samourai.wallet.hd')
exports('com.samourai.wallet.util')
exports('com.samourai.wallet.bip47.rpc')
exports('com.samourai.wallet.bip47.rpc.java')
exports('com.samourai.wallet.cahoots')
exports('com.samourai.wallet.cahoots.psbt')
exports('com.samourai.wallet.cahoots.stonewallx2')
exports('com.samourai.soroban.cahoots')
exports('com.samourai.soroban.client')
exports('com.samourai.soroban.client.cahoots')
exports('com.samourai.soroban.client.meeting')
exports('com.samourai.soroban.client.rpc')
exports('com.samourai.wallet.send')
exports('com.samourai.whirlpool.client.event')
exports('com.samourai.whirlpool.client.wallet')
exports('com.samourai.whirlpool.client.wallet.beans')
exports('com.samourai.whirlpool.client.wallet.data.dataSource')
exports('com.samourai.whirlpool.client.wallet.data.dataPersister')
exports('com.samourai.whirlpool.client.whirlpool')
exports('com.samourai.whirlpool.client.whirlpool.beans')
exports('com.samourai.whirlpool.client.wallet.data.pool')
exports('com.samourai.whirlpool.client.wallet.data.utxo')
exports('com.samourai.whirlpool.client.wallet.data.utxoConfig')
exports('com.samourai.whirlpool.client.wallet.data.supplier')
exports('com.samourai.whirlpool.client.mix.handler')
exports('com.samourai.whirlpool.client.mix.listener')
exports('com.samourai.whirlpool.protocol.beans')
exports('com.samourai.whirlpool.protocol.rest')
exports('com.samourai.whirlpool.client.tx0')
exports('com.samourai.wallet.segwit.bech32')
exports('com.samourai.whirlpool.client.wallet.data.chain')
exports('com.samourai.whirlpool.client.wallet.data.wallet')
exports('com.samourai.whirlpool.client.wallet.data.minerFee')
exports('com.samourai.whirlpool.client.wallet.data.walletState')
exports('com.sparrowwallet.nightjar.http')
exports('com.sparrowwallet.nightjar.stomp')
exports('com.sparrowwallet.nightjar.tor')
module('commons-codec-1.10.jar', 'commons.codec', '1.10') {
exports('org.apache.commons.codec')
}
module('throwing-supplier-1.0.3.jar', 'zeroleak.throwingsupplier', '1.0.3') {
exports('com.zeroleak.throwingsupplier')
module('logback-core-1.2.13.jar', 'ch.qos.logback.core', '1.2.13') {
exports('ch.qos.logback.core')
}
module('okhttp-2.7.5.jar', 'com.squareup.okhttp', '2.7.5') {
exports('com.squareup.okhttp')
module('jackson-datatype-jsr310-2.13.2.jar', 'jackson-datatype-jsr310', '2.13.2') {
exports('com.fasterxml.jackson.datatype.jsr310')
}
module('json-20240205.jar', 'org.json', '20240205') {
exports('org.json')
}
module('scrypt-1.4.0.jar', 'scrypt', '1.4.0') {
exports('com.lambdaworks.codec')
exports('com.lambdaworks.crypto')
}
module('okio-1.6.0.jar', 'com.squareup.okio', '1.6.0') {
exports('okio')
@@ -574,22 +523,12 @@ extraJavaModuleInfo {
module('java-jwt-3.8.1.jar', 'com.auth0.jwt', '3.8.1') {
exports('com.auth0.jwt')
}
module('json-20180130.jar', 'org.json', '1.0') {
exports('org.json')
}
module('scrypt-1.4.0.jar', 'com.lambdaworks.scrypt', '1.4.0') {
exports('com.lambdaworks.codec')
exports('com.lambdaworks.crypto')
}
module('streamsupport-1.7.0.jar', 'net.sourceforge.streamsupport', '1.7.0') {
requires('jdk.unsupported')
exports('java8.util')
exports('java8.util.function')
exports('java8.util.stream')
}
module('protobuf-java-2.6.1.jar', 'com.google.protobuf', '2.6.1') {
exports('com.google.protobuf')
}
module('commons-text-1.2.jar', 'org.apache.commons.text', '1.2') {
exports('org.apache.commons.text')
}
@@ -719,15 +658,10 @@ extraJavaModuleInfo {
exports('io.matthewnelson.component.parcelize')
}
module('jnacl-1.0.0.jar', 'eu.neilalexander.jnacl', '1.0.0')
module('logback-core-1.2.8.jar', 'logback.core', '1.2.8') {
requires('java.xml')
}
module('jcommander-1.81.jar', 'com.beust.jcommander', '1.81') {
exports('com.beust.jcommander')
}
module('junit-4.12.jar', 'junit', '4.12') {
exports('org.junit')
requires('org.hamcrest.core')
module('jzlib-1.1.3.jar', 'com.jcraft.jzlib', '1.1.3') {
exports('com.jcraft.jzlib')
}
module('hamcrest-core-1.3.jar', 'org.hamcrest.core', '1.3')
}
+3 -3
View File
@@ -3,9 +3,9 @@ plugins {
}
dependencies {
implementation 'org.ow2.asm:asm:8.0.1'
implementation 'com.google.gradle:osdetector-gradle-plugin:1.7.0'
implementation 'org.javamodularity:moduleplugin:1.8.12'
implementation 'com.google.gradle:osdetector-gradle-plugin:1.7.3'
implementation 'org.javamodularity:moduleplugin:1.8.14'
implementation 'org.ow2.asm:asm:9.6'
}
repositories {
+2 -2
View File
@@ -42,7 +42,7 @@ Check if key fingerprint matches: `3B04D753C9050D9A5D343F39843C48A565F8F04B`:
```
gpg --import --import-options show-only adoptium.asc
```
If key doesn't match, do not procede.
If key doesn't match, do not proceed.
Add Adoptium PGP key to a the keyring shared folder:
```sh
@@ -82,7 +82,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="1.8.1"
GIT_TAG="1.9.0"
```
The project can then be initially cloned as follows:
+1 -1
Submodule drongo updated: 42f279e5e7...571db32574
Binary file not shown.
+2 -1
View File
@@ -1,6 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored
+17 -12
View File
@@ -83,10 +83,8 @@ done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# 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
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
@@ -133,10 +131,13 @@ location of your Java installation."
fi
else
JAVACMD=java
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
@@ -144,7 +145,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
@@ -152,7 +153,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
@@ -197,11 +198,15 @@ if "$cygwin" || "$msys" ; then
done
fi
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded.
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
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,
# 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" \
+3
View File
@@ -0,0 +1,3 @@
mime-type=application/pgp-signature
extension=asc
description=ASCII Armored File
@@ -6,5 +6,5 @@ Icon=/opt/sparrow/lib/Sparrow.png
Terminal=false
Type=Application
Categories=Finance;Network;
MimeType=application/psbt;application/bitcoin-transaction;x-scheme-handler/bitcoin;x-scheme-handler/auth47;x-scheme-handler/lightning
MimeType=application/psbt;application/bitcoin-transaction;application/pgp-signature;x-scheme-handler/bitcoin;x-scheme-handler/auth47;x-scheme-handler/lightning
StartupWMClass=Sparrow
+16 -1
View File
@@ -21,7 +21,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.8.2</string>
<string>1.9.1</string>
<key>CFBundleSignature</key>
<string>????</string>
<!-- See https://developer.apple.com/app-store/categories/ for list of AppStore categories -->
@@ -94,6 +94,21 @@
<key>UTTypeIconFile</key>
<string>sparrow.icns</string>
</dict>
<dict>
<key>UTTypeIdentifier</key>
<string>com.sparrowwallet.asc</string>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>asc</string>
</array>
</dict>
<key>UTTypeDescription</key>
<string>ASCII Armored File</string>
<key>UTTypeIconFile</key>
<string>sparrow.icns</string>
</dict>
</array>
<key>CFBundleDocumentTypes</key>
<array>
@@ -21,14 +21,13 @@ import com.sparrowwallet.sparrow.control.*;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.*;
import com.sparrowwallet.sparrow.io.bbqr.BBQR;
import com.sparrowwallet.sparrow.io.bbqr.BBQRType;
import com.sparrowwallet.sparrow.net.ElectrumServer;
import com.sparrowwallet.sparrow.net.ServerType;
import com.sparrowwallet.sparrow.preferences.PreferenceGroup;
import com.sparrowwallet.sparrow.preferences.PreferencesDialog;
import com.sparrowwallet.sparrow.soroban.CounterpartyDialog;
import com.sparrowwallet.sparrow.paynym.PayNymDialog;
import com.sparrowwallet.sparrow.soroban.Soroban;
import com.sparrowwallet.sparrow.soroban.SorobanServices;
import com.sparrowwallet.sparrow.transaction.TransactionController;
import com.sparrowwallet.sparrow.transaction.TransactionData;
import com.sparrowwallet.sparrow.transaction.TransactionView;
@@ -77,6 +76,7 @@ import java.util.*;
import java.util.stream.Collectors;
import static com.sparrowwallet.sparrow.AppServices.*;
import static com.sparrowwallet.sparrow.control.DownloadVerifierDialog.*;
public class AppController implements Initializable {
private static final Logger log = LoggerFactory.getLogger(AppController.class);
@@ -184,9 +184,6 @@ public class AppController implements Initializable {
@FXML
private MenuItem sweepPrivateKey;
@FXML
private MenuItem findMixingPartner;
@FXML
private MenuItem showPayNym;
@@ -198,7 +195,7 @@ public class AppController implements Initializable {
private static final BooleanProperty preventSleepProperty = new SimpleBooleanProperty();
@FXML
private MenuItem restart;
private Menu restart;
@FXML
private StackPane rootStack;
@@ -212,12 +209,18 @@ public class AppController implements Initializable {
@FXML
private UnlabeledToggleSwitch serverToggle;
private Storage.KeyDerivationService keyDerivationService;
private PauseTransition wait;
private Timeline statusTimeline;
private SearchWalletDialog searchWalletDialog;
private SendToManyDialog sendToManyDialog;
private DownloadVerifierDialog downloadVerifierDialog;
private Tab previouslySelectedTab;
private boolean subTabsVisible;
@@ -275,9 +278,18 @@ public class AppController implements Initializable {
void initializeView() {
setPlatformApplicationMenu();
rootStack.getScene().getWindow().setOnHiding(windowEvent -> {
if(searchWalletDialog != null && searchWalletDialog.isShowing()) {
searchWalletDialog.close();
}
if(sendToManyDialog != null && sendToManyDialog.isShowing()) {
sendToManyDialog.close();
}
});
rootStack.setOnDragOver(event -> {
if(event.getGestureSource() != rootStack && event.getDragboard().hasFiles()) {
event.acceptTransferModes(TransferMode.COPY_OR_MOVE);
event.acceptTransferModes(TransferMode.LINK);
}
event.consume();
});
@@ -286,9 +298,7 @@ public class AppController implements Initializable {
Dragboard db = event.getDragboard();
boolean success = false;
if(db.hasFiles()) {
for(File file : db.getFiles()) {
openFile(file);
}
openFiles(db.getFiles());
success = true;
}
event.setDropCompleted(success);
@@ -330,6 +340,8 @@ public class AppController implements Initializable {
EventManager.get().post(new OpenWalletsEvent(tabs.getScene().getWindow(), Collections.emptyList()));
});
tabs.setPickOnBounds(false);
registerShortcuts();
BitcoinUnit unit = Config.get().getBitcoinUnit();
@@ -375,7 +387,17 @@ public class AppController implements Initializable {
showLoadingLog.selectedProperty().bindBidirectional(showLoadingLogProperty);
preventSleepProperty.set(Config.get().isPreventSleep());
preventSleep.selectedProperty().bindBidirectional(preventSleepProperty);
restart.setText("Restart in " + (Network.get() == Network.MAINNET ? Network.TESTNET.toDisplayString() : Network.MAINNET.toDisplayString()));
MenuItem homeItem = new MenuItem("Home Folder...");
homeItem.setOnAction(this::restartInHome);
restart.getItems().add(homeItem);
List<Network> networks = new ArrayList<>(List.of(Network.MAINNET, Network.TESTNET, Network.TESTNET4, Network.SIGNET));
networks.remove(Network.get());
for(Network network : networks) {
MenuItem networkItem = new MenuItem(network.toDisplayString());
networkItem.setOnAction(event -> restart(event, network));
restart.getItems().add(networkItem);
}
restart.setVisible(System.getProperty(JPACKAGE_APP_PATH) != null);
saveTransaction.setDisable(true);
@@ -395,10 +417,6 @@ public class AppController implements Initializable {
sendToMany.disableProperty().bind(exportWallet.disableProperty());
sweepPrivateKey.disableProperty().bind(Bindings.or(serverToggle.disableProperty(), AppServices.onlineProperty().not()));
showPayNym.setDisable(true);
findMixingPartner.setDisable(true);
AppServices.onlineProperty().addListener((observable, oldValue, newValue) -> {
findMixingPartner.setDisable(exportWallet.isDisable() || getSelectedWalletForm() == null || !SorobanServices.canWalletMix(getSelectedWalletForm().getWallet()) || !newValue);
});
configureSwitchServer();
setServerType(Config.get().getServerType());
@@ -626,7 +644,7 @@ public class AppController implements Initializable {
} catch(TransactionParseException e) {
showErrorDialog("Invalid transaction", e.getMessage());
} catch(Exception e) {
showErrorDialog("Invalid file", e.getMessage());
showErrorDialog("Invalid file", "Cannot recognise the format of the " + file.getName() + " file.");
}
}
}
@@ -742,8 +760,10 @@ public class AppController implements Initializable {
Transaction transaction = transactionTabData.getTransaction();
try {
UR ur = UR.fromBytes(transaction.bitcoinSerialize());
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(ur);
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);
qrDisplayDialog.initOwner(rootStack.getScene().getWindow());
qrDisplayDialog.showAndWait();
} catch(Exception e) {
@@ -841,8 +861,10 @@ public class AppController implements Initializable {
if(tabData.getType() == TabData.TabType.TRANSACTION) {
TransactionTabData transactionTabData = (TransactionTabData)tabData;
CryptoPSBT cryptoPSBT = new CryptoPSBT(transactionTabData.getPsbt().serialize());
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(cryptoPSBT.toUR());
byte[] psbtBytes = transactionTabData.getPsbt().serialize();
CryptoPSBT cryptoPSBT = new CryptoPSBT(psbtBytes);
BBQR bbqr = new BBQR(BBQRType.PSBT, psbtBytes);
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(cryptoPSBT.toUR(), bbqr, false, true, false);
qrDisplayDialog.initOwner(rootStack.getScene().getWindow());
qrDisplayDialog.show();
}
@@ -957,19 +979,46 @@ public class AppController implements Initializable {
AppServices.get().setPreventSleep(item.isSelected());
}
public void restart(ActionEvent event) {
public void restartInHome(ActionEvent event) {
Args args = getRestartArgs();
File initialDir = null;
if(args.dir != null) {
initialDir = new File(args.dir);
}
Stage window = new Stage();
DirectoryChooser directoryChooser = new DirectoryChooser();
directoryChooser.setTitle("Choose Sparrow Home Folder");
directoryChooser.setInitialDirectory(initialDir == null || !initialDir.exists() ? Storage.getSparrowHome() : initialDir);
File newHome = directoryChooser.showDialog(window);
if(newHome != null) {
args.dir = newHome.getAbsolutePath();
restart(event, args);
}
}
public void restart(ActionEvent event, Network network) {
if(System.getProperty(JPACKAGE_APP_PATH) == null) {
throw new IllegalStateException("Property " + JPACKAGE_APP_PATH + " is not present");
}
Args args = getRestartArgs();
args.network = network;
restart(event, args);
}
private static Args getRestartArgs() {
Args args = new Args();
ProcessHandle.current().info().arguments().ifPresent(argv -> {
JCommander jCommander = JCommander.newBuilder().addObject(args).acceptUnknownOptions(true).build();
jCommander.parse(argv);
});
args.network = (Network.get() == Network.MAINNET ? Network.TESTNET : Network.MAINNET);
return args;
}
private void restart(ActionEvent event, Args args) {
try {
List<String> cmd = new ArrayList<>();
cmd.add(System.getProperty(JPACKAGE_APP_PATH));
@@ -982,11 +1031,19 @@ public class AppController implements Initializable {
}
}
public void openFile(File file) {
if(isWalletFile(file)) {
openWalletFile(file, true);
} else {
openTransactionFile(file);
public void openFiles(List<File> files) {
boolean verifyOpened = false;
for(File file : files) {
if(isWalletFile(file)) {
openWalletFile(file, true);
} else if(isVerifyDownloadFile(file)) {
if(!verifyOpened) {
verifyDownload(new ActionEvent(file, rootStack));
verifyOpened = true;
}
} else {
openTransactionFile(file);
}
}
}
@@ -1257,7 +1314,7 @@ public class AppController implements Initializable {
log.error("Error saving imported wallet", e);
}
} else {
Storage.KeyDerivationService keyDerivationService = new Storage.KeyDerivationService(storage, password.get());
keyDerivationService = new Storage.KeyDerivationService(storage, password.get());
keyDerivationService.setOnSucceeded(workerStateEvent -> {
EventManager.get().post(new StorageEvent(Storage.getWalletFile(wallet.getName()).getAbsolutePath(), TimedEvent.Action.END, "Done"));
ECKey encryptionFullKey = keyDerivationService.getValue();
@@ -1288,11 +1345,13 @@ public class AppController implements Initializable {
if(key != null) {
key.clear();
}
keyDerivationService = null;
}
});
keyDerivationService.setOnFailed(workerStateEvent -> {
EventManager.get().post(new StorageEvent(Storage.getWalletFile(wallet.getName()).getAbsolutePath(), TimedEvent.Action.END, "Failed"));
showErrorDialog("Error encrypting wallet", keyDerivationService.getException().getMessage());
keyDerivationService = null;
});
EventManager.get().post(new StorageEvent(Storage.getWalletFile(wallet.getName()).getAbsolutePath(), TimedEvent.Action.START, "Encrypting wallet..."));
keyDerivationService.start();
@@ -1365,7 +1424,6 @@ public class AppController implements Initializable {
}
sendToManyDialog = new SendToManyDialog(bitcoinUnit);
sendToManyDialog.initOwner(rootStack.getScene().getWindow());
sendToManyDialog.initModality(Modality.NONE);
Optional<List<Payment>> optPayments = sendToManyDialog.showAndWait();
sendToManyDialog = null;
@@ -1391,74 +1449,6 @@ public class AppController implements Initializable {
optTransaction.ifPresent(transaction -> addTransactionTab(null, null, transaction));
}
public void findMixingPartner(ActionEvent event) {
WalletForm selectedWalletForm = getSelectedWalletForm();
if(selectedWalletForm != null) {
Wallet wallet = selectedWalletForm.getWallet();
Soroban soroban = AppServices.getSorobanServices().getSoroban(selectedWalletForm.getWalletId());
if(soroban.getHdWallet() == null) {
if(wallet.isEncrypted()) {
Wallet copy = wallet.copy();
WalletPasswordDialog dlg = new WalletPasswordDialog(copy.getMasterName(), WalletPasswordDialog.PasswordRequirement.LOAD);
dlg.initOwner(rootStack.getScene().getWindow());
Optional<SecureString> password = dlg.showAndWait();
if(password.isPresent()) {
Storage storage = selectedWalletForm.getStorage();
Storage.KeyDerivationService keyDerivationService = new Storage.KeyDerivationService(storage, password.get(), true);
keyDerivationService.setOnSucceeded(workerStateEvent -> {
EventManager.get().post(new StorageEvent(selectedWalletForm.getWalletId(), TimedEvent.Action.END, "Done"));
ECKey encryptionFullKey = keyDerivationService.getValue();
Key key = new Key(encryptionFullKey.getPrivKeyBytes(), storage.getKeyDeriver().getSalt(), EncryptionType.Deriver.ARGON2);
copy.decrypt(key);
try {
soroban.setHDWallet(copy);
CounterpartyDialog counterpartyDialog = new CounterpartyDialog(selectedWalletForm.getWalletId(), selectedWalletForm.getWallet());
counterpartyDialog.initOwner(rootStack.getScene().getWindow());
if(Config.get().isSameAppMixing()) {
counterpartyDialog.initModality(Modality.NONE);
}
counterpartyDialog.showAndWait();
} finally {
key.clear();
encryptionFullKey.clear();
password.get().clear();
}
});
keyDerivationService.setOnFailed(workerStateEvent -> {
EventManager.get().post(new StorageEvent(selectedWalletForm.getWalletId(), TimedEvent.Action.END, "Failed"));
if(keyDerivationService.getException() instanceof InvalidPasswordException) {
Optional<ButtonType> optResponse = showErrorDialog("Invalid Password", "The wallet password was invalid. Try again?", ButtonType.CANCEL, ButtonType.OK);
if(optResponse.isPresent() && optResponse.get().equals(ButtonType.OK)) {
Platform.runLater(() -> findMixingPartner(null));
}
} else {
log.error("Error deriving wallet key", keyDerivationService.getException());
}
});
EventManager.get().post(new StorageEvent(selectedWalletForm.getWalletId(), TimedEvent.Action.START, "Decrypting wallet..."));
keyDerivationService.start();
}
} else {
soroban.setHDWallet(wallet);
CounterpartyDialog counterpartyDialog = new CounterpartyDialog(selectedWalletForm.getWalletId(), selectedWalletForm.getWallet());
counterpartyDialog.initOwner(rootStack.getScene().getWindow());
if(Config.get().isSameAppMixing()) {
counterpartyDialog.initModality(Modality.NONE);
}
counterpartyDialog.showAndWait();
}
} else {
CounterpartyDialog counterpartyDialog = new CounterpartyDialog(selectedWalletForm.getWalletId(), selectedWalletForm.getWallet());
counterpartyDialog.initOwner(rootStack.getScene().getWindow());
if(Config.get().isSameAppMixing()) {
counterpartyDialog.initModality(Modality.NONE);
}
counterpartyDialog.showAndWait();
}
}
}
public void showPayNym(ActionEvent event) {
WalletForm selectedWalletForm = getSelectedWalletForm();
if(selectedWalletForm != null) {
@@ -1468,6 +1458,23 @@ public class AppController implements Initializable {
}
}
public void verifyDownload(ActionEvent event) {
if(downloadVerifierDialog != null) {
Stage stage = (Stage)downloadVerifierDialog.getDialogPane().getScene().getWindow();
stage.setAlwaysOnTop(true);
stage.setAlwaysOnTop(false);
if(event.getSource() instanceof File file) {
downloadVerifierDialog.setSignatureFile(file);
}
return;
}
downloadVerifierDialog = new DownloadVerifierDialog(event.getSource() instanceof File file ? file : null);
downloadVerifierDialog.initOwner(rootStack.getScene().getWindow());
downloadVerifierDialog.showAndWait();
downloadVerifierDialog = null;
}
public void minimizeToTray(ActionEvent event) {
AppServices.get().minimizeStage((Stage)tabs.getScene().getWindow());
}
@@ -1534,13 +1541,46 @@ public class AppController implements Initializable {
}
private void searchWallets(List<WalletForm> walletForms) {
SearchWalletDialog searchWalletDialog = new SearchWalletDialog(walletForms);
searchWalletDialog.initOwner(rootStack.getScene().getWindow());
Optional<Entry> optEntry = searchWalletDialog.showAndWait();
if(optEntry.isPresent()) {
Entry entry = optEntry.get();
EventManager.get().post(new FunctionActionEvent(entry.getWalletFunction(), entry.getWallet()));
Platform.runLater(() -> EventManager.get().post(new SelectEntryEvent(entry)));
if(searchWalletDialog != null) {
if(!searchWalletDialog.getWalletForms().equals(walletForms)) {
searchWalletDialog.close();
} else {
Stage stage = (Stage)searchWalletDialog.getDialogPane().getScene().getWindow();
stage.setAlwaysOnTop(true);
stage.setAlwaysOnTop(false);
return;
}
}
Platform.runLater(() -> {
searchWalletDialog = new SearchWalletDialog(walletForms);
searchWalletDialog.initModality(Modality.NONE);
Optional<Entry> optEntry = searchWalletDialog.showAndWait();
if(optEntry.isPresent()) {
Entry entry = optEntry.get();
EventManager.get().post(new FunctionActionEvent(entry.getWalletFunction(), entry.getWallet()));
Platform.runLater(() -> EventManager.get().post(new SelectEntryEvent(entry)));
}
searchWalletDialog = null;
});
}
public void showAllWalletsSummary(ActionEvent event) {
List<List<WalletForm>> allWalletForms = new ArrayList<>();
for(Tab tab : tabs.getTabs()) {
if(tab.getUserData() instanceof WalletTabData) {
TabPane subTabs = (TabPane)tab.getContent();
allWalletForms.add(subTabs.getTabs().stream().map(subTab -> ((WalletTabData)subTab.getUserData()).getWalletForm())
.filter(walletForm -> walletForm.getWallet().isValid() && !walletForm.isLocked()).collect(Collectors.toList()));
}
}
if(allWalletForms.isEmpty() || allWalletForms.stream().allMatch(List::isEmpty)) {
showErrorDialog("No wallets", "There are no open and unlocked wallets to summarize.");
} else {
WalletSummaryDialog walletSummaryDialog = new WalletSummaryDialog(allWalletForms);
walletSummaryDialog.initOwner(rootStack.getScene().getWindow());
walletSummaryDialog.showAndWait();
}
}
@@ -1552,7 +1592,7 @@ public class AppController implements Initializable {
TabPane subTabs = (TabPane) selectedTab.getContent();
List<WalletForm> walletForms = subTabs.getTabs().stream().map(subTab -> ((WalletTabData)subTab.getUserData()).getWalletForm()).collect(Collectors.toList());
if(!walletForms.isEmpty()) {
WalletSummaryDialog walletSummaryDialog = new WalletSummaryDialog(walletForms);
WalletSummaryDialog walletSummaryDialog = new WalletSummaryDialog(List.of(walletForms));
walletSummaryDialog.initOwner(rootStack.getScene().getWindow());
walletSummaryDialog.showAndWait();
}
@@ -1608,14 +1648,6 @@ public class AppController implements Initializable {
tabLabel.setGraphicTextGap(5.0);
tab.setGraphic(tabLabel);
tab.setClosable(true);
tab.setOnCloseRequest(event -> {
if(AppServices.getWhirlpoolServices().getWhirlpoolForMixToWallet(((WalletTabData)tab.getUserData()).getWalletForm().getWalletId()) != null) {
Optional<ButtonType> optType = AppServices.showWarningDialog("Close mix to wallet?", "This wallet has been configured as the final destination for mixes, and needs to be open for this to occur.\n\nAre you sure you want to close?", ButtonType.NO, ButtonType.YES);
if(optType.isPresent() && optType.get() == ButtonType.NO) {
event.consume();
}
}
});
TabPane subTabs = new TabPane();
subTabs.setSide(Side.LEFT);
@@ -2109,7 +2141,7 @@ public class AppController implements Initializable {
dlg.initOwner(rootStack.getScene().getWindow());
Optional<SecureString> password = dlg.showAndWait();
if(password.isPresent()) {
Storage.KeyDerivationService keyDerivationService = new Storage.KeyDerivationService(storage, password.get(), true);
keyDerivationService = new Storage.KeyDerivationService(storage, password.get(), true);
keyDerivationService.setOnSucceeded(workerStateEvent -> {
EventManager.get().post(new StorageEvent(selectedWalletForm.getWalletId(), TimedEvent.Action.END, "Done"));
ECKey encryptionFullKey = keyDerivationService.getValue();
@@ -2119,7 +2151,7 @@ public class AppController implements Initializable {
deleteStorage(storage, true);
} finally {
encryptionFullKey.clear();
password.get().clear();
keyDerivationService = null;
}
});
keyDerivationService.setOnFailed(workerStateEvent -> {
@@ -2132,6 +2164,7 @@ public class AppController implements Initializable {
} else {
log.error("Error deriving wallet key", keyDerivationService.getException());
}
keyDerivationService = null;
});
EventManager.get().post(new StorageEvent(selectedWalletForm.getWalletId(), TimedEvent.Action.START, "Decrypting wallet..."));
keyDerivationService.start();
@@ -2434,7 +2467,6 @@ public class AppController implements Initializable {
showLoadingLog.setDisable(true);
showTxHex.setDisable(false);
showPayNym.setDisable(true);
findMixingPartner.setDisable(true);
} else if(event instanceof WalletTabSelectedEvent) {
WalletTabSelectedEvent walletTabEvent = (WalletTabSelectedEvent)event;
WalletTabData walletTabData = walletTabEvent.getWalletTabData();
@@ -2446,7 +2478,6 @@ public class AppController implements Initializable {
showLoadingLog.setDisable(false);
showTxHex.setDisable(true);
showPayNym.setDisable(exportWallet.isDisable() || !walletTabData.getWallet().hasPaymentCode());
findMixingPartner.setDisable(exportWallet.isDisable() || !SorobanServices.canWalletMix(walletTabData.getWallet()) || !AppServices.onlineProperty().get());
}
}
}
@@ -2472,7 +2503,6 @@ public class AppController implements Initializable {
if(selectedWalletForm.getWalletId().equals(event.getWalletId())) {
exportWallet.setDisable(!event.getWallet().isValid() || selectedWalletForm.isLocked());
showPayNym.setDisable(exportWallet.isDisable() || !event.getWallet().hasPaymentCode());
findMixingPartner.setDisable(exportWallet.isDisable() || !SorobanServices.canWalletMix(event.getWallet()) || !AppServices.onlineProperty().get());
}
}
@@ -2714,7 +2744,7 @@ public class AppController implements Initializable {
public void disconnection(DisconnectionEvent event) {
serverToggle.setDisable(false);
if(!AppServices.isConnecting() && !AppServices.isConnected() && !statusBar.getText().startsWith(CONNECTION_FAILED_PREFIX) && !statusBar.getText().contains(TRYING_ANOTHER_SERVER_MESSAGE)) {
statusUpdated(new StatusEvent("Disconnected"));
statusUpdated(new StatusEvent("Disconnected (click toggle on the right to connect)", 240));
}
if(statusTimeline == null || statusTimeline.getStatus() != Animation.Status.RUNNING) {
statusBar.setProgress(0);
@@ -3029,6 +3059,13 @@ public class AppController implements Initializable {
}
}
@Subscribe
public void requestVerifyDownloadOpen(RequestVerifyDownloadEvent event) {
if(tabs.getScene().getWindow().equals(event.getWindow())) {
verifyDownload(new ActionEvent(event.getFile(), rootStack));
}
}
@Subscribe
public void functionAction(FunctionActionEvent event) {
selectTab(event.getWallet());
@@ -24,8 +24,6 @@ import com.sparrowwallet.sparrow.control.TrayManager;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.io.*;
import com.sparrowwallet.sparrow.net.*;
import com.sparrowwallet.sparrow.soroban.SorobanServices;
import com.sparrowwallet.sparrow.whirlpool.WhirlpoolServices;
import javafx.application.Application;
import javafx.application.Platform;
import javafx.beans.property.BooleanProperty;
@@ -33,6 +31,7 @@ import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.concurrent.ScheduledService;
import javafx.concurrent.Service;
import javafx.concurrent.Task;
import javafx.concurrent.Worker;
import javafx.fxml.FXMLLoader;
@@ -68,6 +67,8 @@ import java.util.*;
import java.util.List;
import java.util.stream.Collectors;
import static com.sparrowwallet.sparrow.control.DownloadVerifierDialog.*;
public class AppServices {
private static final Logger log = LoggerFactory.getLogger(AppServices.class);
@@ -78,6 +79,8 @@ public class AppServices {
private static final int RATES_PERIOD_SECS = 5 * 60;
private static final int VERSION_CHECK_PERIOD_HOURS = 24;
private static final int CONNECTION_DELAY_SECS = 2;
private static final int RATES_DELAY_SECS_DEFAULT = 2;
private static final int RATES_DELAY_SECS_WINDOWS = 5;
private static final ExchangeSource DEFAULT_EXCHANGE_SOURCE = ExchangeSource.COINGECKO;
private static final Currency DEFAULT_FIAT_CURRENCY = Currency.getInstance("USD");
private static final String TOR_DEFAULT_PROXY_CIRCUIT_ID = "default";
@@ -90,11 +93,7 @@ public class AppServices {
private static AppServices INSTANCE;
private final WhirlpoolServices whirlpoolServices = new WhirlpoolServices();
private final SorobanServices sorobanServices = new SorobanServices();
private InteractionServices interactionServices;
private final InteractionServices interactionServices;
private static HttpClientService httpClientService;
@@ -112,6 +111,8 @@ public class AppServices {
private ElectrumServer.ConnectionService connectionService;
private ElectrumServer.FeeRatesService feeRatesService;
private Hwi.ScheduledEnumerateService deviceEnumerateService;
private VersionCheckService versionCheckService;
@@ -160,6 +161,11 @@ public class AppServices {
connectionService.cancel();
ratesService.cancel();
versionCheckService.cancel();
if(httpClientService != null) {
HttpClientService.ShutdownService shutdownService = new HttpClientService.ShutdownService(httpClientService);
shutdownService.start();
}
}
}
};
@@ -176,13 +182,12 @@ public class AppServices {
this.application = application;
this.interactionServices = interactionServices;
EventManager.get().register(this);
EventManager.get().register(whirlpoolServices);
EventManager.get().register(sorobanServices);
}
public void start() {
Config config = Config.get();
connectionService = createConnectionService();
feeRatesService = createFeeRatesService();
ratesService = createRatesService(config.getExchangeSource(), config.getFiatCurrency());
versionCheckService = createVersionCheckService();
torService = createTorService();
@@ -196,6 +201,8 @@ public class AppServices {
} else {
restartServices();
}
} else {
EventManager.get().post(new DisconnectionEvent());
}
addURIHandlers();
@@ -279,8 +286,15 @@ public class AppServices {
onlineProperty.setValue(true);
onlineProperty.addListener(onlineServicesListener);
if(connectionService.getValue() != null) {
EventManager.get().post(connectionService.getValue());
FeeRatesUpdatedEvent event = connectionService.getValue();
if(event != null) {
EventManager.get().post(event);
}
FeeRatesSource feeRatesSource = Config.get().getFeeRatesSource();
feeRatesSource = (feeRatesSource == null ? FeeRatesSource.MEMPOOL_SPACE : feeRatesSource);
if(event instanceof ConnectionEvent && Network.get().equals(Network.MAINNET) && feeRatesSource.isExternal()) {
EventManager.get().post(new FeeRatesSourceChangedEvent(feeRatesSource));
}
});
connectionService.setOnFailed(failEvent -> {
@@ -351,10 +365,21 @@ public class AppServices {
return connectionService;
}
private ElectrumServer.FeeRatesService createFeeRatesService() {
ElectrumServer.FeeRatesService feeRatesService = new ElectrumServer.FeeRatesService();
feeRatesService.setOnSucceeded(workerStateEvent -> {
EventManager.get().post(feeRatesService.getValue());
});
return feeRatesService;
}
private ExchangeSource.RatesService createRatesService(ExchangeSource exchangeSource, Currency currency) {
ExchangeSource.RatesService ratesService = new ExchangeSource.RatesService(
exchangeSource == null ? DEFAULT_EXCHANGE_SOURCE : exchangeSource,
currency == null ? DEFAULT_FIAT_CURRENCY : currency);
//Delay startup on first run, Windows requires a longer delay
ratesService.setDelay(org.controlsfx.tools.Platform.getCurrent() == org.controlsfx.tools.Platform.WINDOWS ? Duration.seconds(RATES_DELAY_SECS_WINDOWS) : Duration.seconds(RATES_DELAY_SECS_DEFAULT));
ratesService.setPeriod(Duration.seconds(RATES_PERIOD_SECS));
ratesService.setRestartOnFailure(true);
@@ -469,7 +494,7 @@ public class AppServices {
public static Proxy getProxy(String proxyCircuitId) {
Config config = Config.get();
Proxy proxy = null;
if(config.isUseProxy()) {
if(config.isUseProxy() && config.getProxyServer() != null) {
HostAndPort proxyHostAndPort = HostAndPort.fromString(config.getProxyServer());
InetSocketAddress proxyAddress = new InetSocketAddress(proxyHostAndPort.getHost(), proxyHostAndPort.getPortOrDefault(ProxyTcpOverTlsTransport.DEFAULT_PROXY_PORT));
proxy = new Proxy(Proxy.Type.SOCKS, proxyAddress);
@@ -501,24 +526,15 @@ public class AppServices {
return INSTANCE;
}
public static WhirlpoolServices getWhirlpoolServices() {
return get().whirlpoolServices;
}
public static SorobanServices getSorobanServices() {
return get().sorobanServices;
}
public static InteractionServices getInteractionServices() {
return get().interactionServices;
}
public static HttpClientService getHttpClientService() {
HostAndPort torProxy = getTorProxy();
if(httpClientService == null) {
HostAndPort torProxy = getTorProxy();
httpClientService = new HttpClientService(torProxy);
} else {
HostAndPort torProxy = getTorProxy();
if(!Objects.equals(httpClientService.getTorProxy(), torProxy)) {
httpClientService.setTorProxy(getTorProxy());
}
@@ -780,7 +796,7 @@ public class AppServices {
}
public static Window getActiveWindow() {
return Stage.getWindows().stream().filter(Window::isFocused).findFirst().orElse(get().walletWindows.keySet().iterator().hasNext() ? get().walletWindows.keySet().iterator().next() : null);
return Stage.getWindows().stream().filter(Window::isFocused).findFirst().orElse(get().walletWindows.keySet().iterator().hasNext() ? get().walletWindows.keySet().iterator().next() : (Stage.getWindows().iterator().hasNext() ? Stage.getWindows().iterator().next() : null));
}
public static void moveToActiveWindowScreen(Dialog<?> dialog) {
@@ -853,6 +869,25 @@ public class AppServices {
}
}
public static void openFileUriArgumentsAfterWalletLoading(Window window) {
if(!argFiles.isEmpty() || !argUris.isEmpty()) {
Service<Void> service = new Service<>() {
@Override
protected Task<Void> createTask() {
return new Task<>() {
@Override
protected Void call() {
Platform.runLater(() -> openFileUriArguments(window));
return null;
}
};
}
};
service.setExecutor(Storage.LoadWalletService.getSingleThreadedExecutor());
service.start();
}
}
public static void openFileUriArguments(Window window) {
openFiles(argFiles, window);
argFiles.clear();
@@ -872,6 +907,7 @@ public class AppServices {
}
if(openWindow instanceof Stage) {
((Stage)openWindow).setIconified(false);
((Stage)openWindow).setAlwaysOnTop(true);
((Stage)openWindow).setAlwaysOnTop(false);
}
@@ -879,6 +915,8 @@ public class AppServices {
for(File file : openFiles) {
if(isWalletFile(file)) {
EventManager.get().post(new RequestWalletOpenEvent(openWindow, file));
} else if(isVerifyDownloadFile(file)) {
EventManager.get().post(new RequestVerifyDownloadEvent(openWindow, file));
} else {
EventManager.get().post(new RequestTransactionOpenEvent(openWindow, file));
}
@@ -1041,6 +1079,37 @@ public class AppServices {
return wallet;
}
public static final List<Network> WHIRLPOOL_NETWORKS = List.of(Network.MAINNET, Network.TESTNET);
public static boolean isWhirlpoolCompatible(Wallet wallet) {
return WHIRLPOOL_NETWORKS.contains(Network.get())
&& wallet.getScriptType() != ScriptType.P2TR //Taproot not yet supported
&& wallet.getKeystores().size() == 1
&& wallet.getKeystores().get(0).hasSeed()
&& wallet.getKeystores().get(0).getSeed().getType() == DeterministicSeed.Type.BIP39
&& wallet.getStandardAccountType() != null
&& StandardAccount.isMixableAccount(wallet.getStandardAccountType());
}
public static boolean isWhirlpoolPostmixCompatible(Wallet wallet) {
return WHIRLPOOL_NETWORKS.contains(Network.get())
&& wallet.getScriptType() != ScriptType.P2TR //Taproot not yet supported
&& wallet.getKeystores().size() == 1;
}
public static List<Wallet> addWhirlpoolWallets(Wallet decryptedWallet, String walletId, Storage storage) {
List<Wallet> childWallets = new ArrayList<>();
for(StandardAccount whirlpoolAccount : StandardAccount.WHIRLPOOL_ACCOUNTS) {
if(decryptedWallet.getChildWallet(whirlpoolAccount) == null) {
Wallet childWallet = decryptedWallet.addChildWallet(whirlpoolAccount);
childWallets.add(childWallet);
EventManager.get().post(new ChildWalletsAddedEvent(storage, decryptedWallet, childWallet));
}
}
return childWallets;
}
public static Font getMonospaceFont() {
return Font.font("Roboto Mono", 13);
}
@@ -1075,17 +1144,18 @@ public class AppServices {
@Subscribe
public void mempoolRateSizes(MempoolRateSizesUpdatedEvent event) {
addMempoolRateSizes(event.getMempoolRateSizes());
if(event.getMempoolRateSizes() != null) {
addMempoolRateSizes(event.getMempoolRateSizes());
}
}
@Subscribe
public void feeRateSourceChanged(FeeRatesSourceChangedEvent event) {
ElectrumServer.FeeRatesService feeRatesService = new ElectrumServer.FeeRatesService();
feeRatesService.setOnSucceeded(workerStateEvent -> {
EventManager.get().post(feeRatesService.getValue());
});
//Perform once-off fee rates retrieval to immediately change displayed rates
feeRatesService.start();
if(feeRatesService != null && !feeRatesService.isRunning() && Config.get().getMode() != Mode.OFFLINE) {
feeRatesService = createFeeRatesService();
feeRatesService.start();
}
}
@Subscribe
@@ -12,7 +12,12 @@ import org.fxmisc.richtext.event.MouseOverTextEvent;
import org.fxmisc.richtext.model.TwoDimensional;
import java.time.Duration;
import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static com.sparrowwallet.drongo.protocol.ScriptType.*;
import static org.fxmisc.richtext.model.TwoDimensional.Bias.Backward;
public abstract class BaseController {
@@ -24,14 +29,11 @@ public abstract class BaseController {
scriptArea.setMouseOverTextDelay(Duration.ofMillis(150));
scriptArea.addEventHandler(MouseOverTextEvent.MOUSE_OVER_TEXT_BEGIN, e -> {
TwoDimensional.Position position = scriptArea.getParagraph(0).getStyleSpans().offsetToPosition(e.getCharacterIndex(), Backward);
if(position.getMajor() % 2 == 0 && scriptArea.getScript().getChunks().size() > position.getMajor() / 2) {
ScriptChunk hoverChunk = scriptArea.getScript().getChunks().get(position.getMajor()/2);
if(!hoverChunk.isOpCode()) {
Point2D pos = e.getScreenPosition();
popupMsg.setText(describeScriptChunk(hoverChunk));
popup.show(scriptArea, pos.getX(), pos.getY() + 10);
}
ScriptChunk hoverChunk = getScriptChunk(scriptArea, e.getCharacterIndex());
if(hoverChunk != null) {
Point2D pos = e.getScreenPosition();
popupMsg.setText(describeScriptChunk(hoverChunk));
popup.show(scriptArea, pos.getX(), pos.getY() + 10);
}
});
scriptArea.addEventHandler(MouseOverTextEvent.MOUSE_OVER_TEXT_END, e -> {
@@ -80,4 +82,26 @@ public abstract class BaseController {
return "Invalid";
}
public static ScriptChunk getScriptChunk(ScriptArea area, int characterIndex) {
TwoDimensional.Position position = area.getParagraph(0).getStyleSpans().offsetToPosition(characterIndex, Backward);
int ignoreCount = 0;
for(int i = 0; i < position.getMajor() && i < area.getParagraph(0).getStyleSpans().getSpanCount(); i++) {
Collection<String> styles = area.getParagraph(0).getStyleSpans().getStyleSpan(i).getStyle();
if(i < position.getMajor() && (styles.contains("") || styles.contains("script-nest"))) {
ignoreCount++;
}
}
boolean hashScripts = List.of(P2PKH, P2SH, P2WPKH, P2WSH).stream().anyMatch(type -> type.isScriptType(area.getScript()));
List<ScriptChunk> flatChunks = area.getScript().getChunks().stream().flatMap(chunk -> !hashScripts && chunk.isScript() ? chunk.getScript().getChunks().stream() : Stream.of(chunk)).collect(Collectors.toList());
int chunkIndex = position.getMajor() - ignoreCount;
if(chunkIndex < flatChunks.size()) {
ScriptChunk chunk = flatChunks.get(chunkIndex);
if(!chunk.isOpCode()) {
return chunk;
}
}
return null;
}
}
@@ -80,6 +80,7 @@ public class SparrowDesktop extends Application {
}
System.setProperty(Wallet.ALLOW_DERIVATIONS_MATCHING_OTHER_SCRIPT_TYPES_PROPERTY, Boolean.toString(!Config.get().isValidateDerivationPaths()));
System.setProperty(Wallet.ALLOW_DERIVATIONS_MATCHING_OTHER_NETWORKS_PROPERTY, Boolean.toString(!Config.get().isValidateDerivationPaths()));
if(Config.get().getAppHeight() != null && Config.get().getAppWidth() != null) {
mainStage.setWidth(Config.get().getAppWidth());
@@ -107,7 +108,7 @@ public class SparrowDesktop extends Application {
}
}
AppServices.openFileUriArguments(stage);
AppServices.openFileUriArgumentsAfterWalletLoading(stage);
AppServices.get().start();
}
@@ -16,9 +16,9 @@ import java.io.File;
import java.util.*;
public class SparrowWallet {
public static final String APP_ID = "com.sparrowwallet.sparrow";
public static final String APP_ID = "sparrow";
public static final String APP_NAME = "Sparrow";
public static final String APP_VERSION = "1.8.2";
public static final String APP_VERSION = "1.9.1";
public static final String APP_VERSION_SUFFIX = "";
public static final String APP_HOME_PROPERTY = "sparrow.home";
public static final String NETWORK_ENV_PROPERTY = "SPARROW_NETWORK";
@@ -66,6 +66,11 @@ public class SparrowWallet {
Network.set(Network.TESTNET);
}
File testnet4Flag = new File(Storage.getSparrowHome(), "network-" + Network.TESTNET4.getName());
if(testnet4Flag.exists()) {
Network.set(Network.TESTNET4);
}
File signetFlag = new File(Storage.getSparrowHome(), "network-" + Network.SIGNET.getName());
if(signetFlag.exists()) {
Network.set(Network.SIGNET);
@@ -79,7 +84,7 @@ public class SparrowWallet {
try {
instance = new Instance(fileUriArguments);
instance.acquireLock(); //If fileUriArguments is not empty, will exit app after sending fileUriArguments if lock cannot be acquired
instance.acquireLock(!fileUriArguments.isEmpty()); //If fileUriArguments is not empty, will exit app after sending fileUriArguments if lock cannot be acquired
} catch(InstanceException e) {
getLogger().error("Could not access application lock", e);
}
@@ -130,13 +135,13 @@ public class SparrowWallet {
private final List<String> fileUriArguments;
public Instance(List<String> fileUriArguments) {
super(SparrowWallet.APP_ID + "." + Network.get(), !fileUriArguments.isEmpty());
super(SparrowWallet.APP_ID, true);
this.fileUriArguments = fileUriArguments;
}
@Override
protected void receiveMessageList(List<String> messageList) {
if(messageList != null && !messageList.isEmpty()) {
if(messageList != null) {
AppServices.parseFileUriArguments(messageList);
AppServices.openFileUriArguments(null);
}
@@ -5,21 +5,19 @@ import com.sparrowwallet.drongo.wallet.StandardAccount;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.net.ServerType;
import com.sparrowwallet.sparrow.whirlpool.WhirlpoolServices;
import javafx.collections.FXCollections;
import javafx.scene.control.*;
import javafx.scene.layout.VBox;
import javafx.util.StringConverter;
import org.controlsfx.glyphfont.Glyph;
import java.util.ArrayList;
import java.util.List;
import java.util.*;
import static com.sparrowwallet.drongo.wallet.StandardAccount.*;
public class AddAccountDialog extends Dialog<List<StandardAccount>> {
private static final int MAX_SHOWN_ACCOUNTS = 8;
private final ComboBox<StandardAccount> standardAccountCombo;
private boolean discoverAccounts = false;
@@ -44,31 +42,32 @@ public class AddAccountDialog extends Dialog<List<StandardAccount>> {
standardAccountCombo = new ComboBox<>();
standardAccountCombo.setMaxWidth(Double.MAX_VALUE);
List<Integer> existingIndexes = new ArrayList<>();
Set<Integer> existingIndexes = new LinkedHashSet<>();
Wallet masterWallet = wallet.isMasterWallet() ? wallet : wallet.getMasterWallet();
existingIndexes.add(masterWallet.getAccountIndex());
for(Wallet childWallet : masterWallet.getChildWallets()) {
if(!childWallet.isNested()) {
existingIndexes.add(childWallet.getAccountIndex());
Optional<StandardAccount> optStdAcc = Arrays.stream(StandardAccount.values()).filter(stdacc -> stdacc.getName().equals(childWallet.getName())).findFirst();
optStdAcc.ifPresent(standardAccount -> existingIndexes.add(standardAccount.getAccountNumber()));
}
}
List<StandardAccount> availableAccounts = new ArrayList<>();
for(StandardAccount standardAccount : StandardAccount.values()) {
if(!existingIndexes.contains(standardAccount.getAccountNumber()) && !StandardAccount.isWhirlpoolAccount(standardAccount)) {
if(!existingIndexes.contains(standardAccount.getAccountNumber()) && !StandardAccount.isWhirlpoolAccount(standardAccount) && availableAccounts.size() <= MAX_SHOWN_ACCOUNTS) {
availableAccounts.add(standardAccount);
}
}
if(WhirlpoolServices.canWalletMix(masterWallet) && !masterWallet.isWhirlpoolMasterWallet()) {
if(AppServices.isWhirlpoolCompatible(masterWallet) && !masterWallet.isWhirlpoolMasterWallet()) {
availableAccounts.add(WHIRLPOOL_PREMIX);
} else if(WhirlpoolServices.canWatchPostmix(masterWallet) && !existingIndexes.contains(WHIRLPOOL_POSTMIX.getAccountNumber())) {
} else if(AppServices.isWhirlpoolPostmixCompatible(masterWallet) && !existingIndexes.contains(WHIRLPOOL_POSTMIX.getAccountNumber())) {
availableAccounts.add(WHIRLPOOL_POSTMIX);
}
final ButtonType discoverButtonType = new javafx.scene.control.ButtonType("Discover", ButtonBar.ButtonData.LEFT);
if(!availableAccounts.isEmpty() && Config.get().getServerType() != ServerType.BITCOIN_CORE &&
(masterWallet.getKeystores().stream().allMatch(ks -> ks.getSource() == KeystoreSource.SW_SEED)
if(!availableAccounts.isEmpty() && (masterWallet.getKeystores().stream().allMatch(ks -> ks.getSource() == KeystoreSource.SW_SEED)
|| (masterWallet.getKeystores().size() == 1 && masterWallet.getKeystores().stream().allMatch(ks -> ks.getSource() == KeystoreSource.HW_USB)))) {
dialogPane.getButtonTypes().add(discoverButtonType);
Button discoverButton = (Button)dialogPane.lookupButton(discoverButtonType);
@@ -10,6 +10,7 @@ import javafx.beans.value.ChangeListener;
import javafx.event.EventHandler;
import javafx.scene.Cursor;
import javafx.scene.Node;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.Tooltip;
import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent;
@@ -52,6 +53,7 @@ public class CopyableTextField extends CustomTextField {
selectedTextProperty().removeListener(selectionListener);
}
});
setContextMenu(new ContextMenu());
}
private void setupCopyButtonField(ObjectProperty<Node> rightProperty) {
@@ -3,13 +3,14 @@ package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.hummingbird.UR;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.PdfUtils;
import com.sparrowwallet.sparrow.io.bbqr.BBQR;
import javafx.event.ActionEvent;
import javafx.scene.control.*;
import javafx.scene.control.Button;
public class DescriptorQRDisplayDialog extends QRDisplayDialog {
public DescriptorQRDisplayDialog(String walletName, String outputDescriptor, UR ur) {
super(ur);
public DescriptorQRDisplayDialog(String walletName, String outputDescriptor, UR ur, BBQR bbqr, boolean selectBbqrButton) {
super(ur, bbqr, false, false, selectBbqrButton);
DialogPane dialogPane = getDialogPane();
final ButtonType pdfButtonType = new javafx.scene.control.ButtonType("Save PDF...", ButtonBar.ButtonData.HELP_2);
@@ -19,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);
PdfUtils.saveOutputDescriptor(walletName, outputDescriptor, ur, isUseBbqrEncoding() ? bbqr : null);
event.consume();
});
}
@@ -0,0 +1,750 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.pgp.PGPKeySource;
import com.sparrowwallet.drongo.pgp.PGPUtils;
import com.sparrowwallet.drongo.pgp.PGPVerificationException;
import com.sparrowwallet.drongo.pgp.PGPVerificationResult;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.glyphfont.GlyphUtils;
import com.sparrowwallet.sparrow.net.VersionCheckService;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.concurrent.Service;
import javafx.concurrent.Task;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.input.Dragboard;
import javafx.scene.input.TransferMode;
import javafx.scene.layout.*;
import javafx.stage.FileChooser;
import javafx.stage.Stage;
import org.controlsfx.tools.Platform;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import tornadofx.control.Field;
import tornadofx.control.Fieldset;
import tornadofx.control.Form;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import static com.sparrowwallet.sparrow.AppController.DRAG_OVER_CLASS;
public class DownloadVerifierDialog extends Dialog<ButtonBar.ButtonData> {
private static final Logger log = LoggerFactory.getLogger(DownloadVerifierDialog.class);
private static final DateFormat signatureDateFormat = new SimpleDateFormat("EEE MMM dd HH:mm:ss yyyy z");
private static final long MAX_VALID_MANIFEST_SIZE = 100 * 1024;
private static final String SHA256SUMS_MANIFEST_PREFIX = "sha256sums";
private static final List<String> SIGNATURE_EXTENSIONS = List.of("asc", "sig", "gpg");
private static final List<String> MANIFEST_EXTENSIONS = List.of("txt");
private static final List<String> PUBLIC_KEY_EXTENSIONS = List.of("asc");
private static final List<String> MACOS_RELEASE_EXTENSIONS = List.of("dmg");
private static final List<String> WINDOWS_RELEASE_EXTENSIONS = List.of("exe", "zip");
private static final List<String> LINUX_RELEASE_EXTENSIONS = List.of("deb", "rpm", "tar.gz");
private static final List<String> DISK_IMAGE_EXTENSIONS = List.of("img", "bin", "dfu");
private static final List<String> ARCHIVE_EXTENSIONS = List.of("zip", "tar.gz", "tar.bz2", "tar.xz", "rar", "7z");
private static final String SPARROW_RELEASE_PREFIX = "sparrow-";
private static final String SPARROW_SIGNATURE_SUFFIX = "-manifest.txt.asc";
private static final Pattern SPARROW_RELEASE_VERSION = Pattern.compile("[0-9]+(\\.[0-9]+)*");
private static final long MIN_VALID_SPARROW_RELEASE_SIZE = 10 * 1024 * 1024;
private final ObjectProperty<File> signature = new SimpleObjectProperty<>();
private final ObjectProperty<File> manifest = new SimpleObjectProperty<>();
private final ObjectProperty<File> publicKey = new SimpleObjectProperty<>();
private final ObjectProperty<File> release = new SimpleObjectProperty<>();
private final BooleanProperty manifestDisabled = new SimpleBooleanProperty();
private final BooleanProperty publicKeyDisabled = new SimpleBooleanProperty();
private final Label signedBy;
private final Label releaseHash;
private final Label releaseVerified;
private final Hyperlink releaseLink;
private static File lastFileParent;
public DownloadVerifierDialog(File initialSignatureFile) {
final DialogPane dialogPane = getDialogPane();
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
dialogPane.getStylesheets().add(AppServices.class.getResource("dialog.css").toExternalForm());
AppServices.setStageIcon(dialogPane.getScene().getWindow());
dialogPane.setHeader(new Header());
setupDrag(dialogPane);
VBox vBox = new VBox();
vBox.setSpacing(20);
vBox.setPadding(new Insets(20, 10, 10, 20));
Form form = new Form();
Fieldset filesFieldset = new Fieldset();
filesFieldset.setText("Files");
filesFieldset.setSpacing(10);
String version = VersionCheckService.getVersion() != null ? VersionCheckService.getVersion() : "x.x.x";
Field signatureField = setupField(signature, "Signature", SIGNATURE_EXTENSIONS, false, "sparrow-" + version + "-manifest.txt", null);
Field manifestField = setupField(manifest, "Manifest", MANIFEST_EXTENSIONS, false, "sparrow-" + version + "-manifest", manifestDisabled);
Field publicKeyField = setupField(publicKey, "Public Key", PUBLIC_KEY_EXTENSIONS, true, "pgp_keys", publicKeyDisabled);
Field releaseFileField = setupField(release, "Release File", getReleaseFileExtensions(), false, getReleaseFileExample(version), null);
filesFieldset.getChildren().addAll(signatureField, manifestField, publicKeyField, releaseFileField);
form.getChildren().add(filesFieldset);
Fieldset resultsFieldset = new Fieldset();
resultsFieldset.setText("Results");
resultsFieldset.setSpacing(10);
signedBy = new Label();
Field signedByField = setupResultField(signedBy, "Signed By");
releaseHash = new Label();
Field hashMatchedField = setupResultField(releaseHash, "Release Hash");
releaseVerified = new Label();
Field releaseVerifiedField = setupResultField(releaseVerified, "Verified");
releaseLink = new Hyperlink("");
releaseVerifiedField.getInputs().add(releaseLink);
releaseLink.setOnAction(event -> {
if(release.get() != null && release.get().exists()) {
if(release.get().getName().toLowerCase(Locale.ROOT).startsWith("sparrow")) {
Optional<ButtonType> optType = AppServices.showAlertDialog("Exit Sparrow?", "Sparrow must be closed before installation. Exit?", Alert.AlertType.CONFIRMATION, ButtonType.NO, ButtonType.YES);
if(optType.isPresent() && optType.get() == ButtonType.YES) {
javafx.application.Platform.exit();
AppServices.get().getApplication().getHostServices().showDocument("file://" + release.get().getAbsolutePath());
}
} else {
AppServices.get().getApplication().getHostServices().showDocument("file://" + release.get().getAbsolutePath());
}
}
});
resultsFieldset.getChildren().addAll(signedByField, hashMatchedField, releaseVerifiedField);
form.getChildren().add(resultsFieldset);
vBox.getChildren().addAll(form);
dialogPane.setContent(vBox);
ButtonType clearButtonType = new javafx.scene.control.ButtonType("Clear", ButtonBar.ButtonData.CANCEL_CLOSE);
ButtonType closeButtonType = new javafx.scene.control.ButtonType("Close", ButtonBar.ButtonData.OK_DONE);
dialogPane.getButtonTypes().addAll(clearButtonType, closeButtonType);
setOnCloseRequest(event -> {
if(ButtonBar.ButtonData.CANCEL_CLOSE.equals(getResult())) {
signature.set(null);
manifest.set(null);
publicKey.set(null);
release.set(null);
signedBy.setText("");
signedBy.setGraphic(null);
signedBy.setTooltip(null);
releaseHash.setText("");
releaseHash.setGraphic(null);
releaseVerified.setText("");
releaseVerified.setGraphic(null);
releaseLink.setText("");
event.consume();
}
});
setResultConverter(ButtonType::getButtonData);
AppServices.moveToActiveWindowScreen(this);
dialogPane.setPrefWidth(900);
setResizable(true);
signature.addListener((observable, oldValue, signatureFile) -> {
if(signatureFile != null) {
boolean verify = true;
File actualSignatureFile = findSignatureFile(signatureFile);
if(actualSignatureFile != null && !actualSignatureFile.equals(signature.get())) {
signature.set(actualSignatureFile);
verify = false;
} else if(PGPUtils.signatureContainsManifest(signatureFile)) {
manifest.set(signatureFile);
verify = false;
} else {
File manifestFile = findManifestFile(signatureFile);
if(manifestFile != null && !manifestFile.equals(manifest.get())) {
manifest.set(manifestFile);
verify = false;
}
}
if(verify) {
verify();
}
}
});
manifest.addListener((observable, oldValue, manifestFile) -> {
if(manifestFile != null) {
boolean verify = true;
try {
Map<File, String> manifestMap = getManifest(manifestFile);
File releaseFile = findReleaseFile(manifestFile, manifestMap);
if(releaseFile != null && !releaseFile.equals(release.get())) {
release.set(releaseFile);
verify = false;
}
} catch(IOException e) {
log.debug("Error reading manifest file", e);
verify = false;
} catch(InvalidManifestException e) {
release.set(manifestFile);
verify = false;
}
if(verify) {
verify();
}
}
});
publicKey.addListener((observable, oldValue, newValue) -> {
verify();
});
release.addListener((observable, oldValue, releaseFile) -> {
verify();
});
if(initialSignatureFile != null) {
javafx.application.Platform.runLater(() -> signature.set(initialSignatureFile));
}
}
private void setupDrag(DialogPane dialogPane) {
dialogPane.setOnDragOver(event -> {
if(event.getGestureSource() != dialogPane && event.getDragboard().hasFiles()) {
event.acceptTransferModes(TransferMode.LINK);
}
event.consume();
});
dialogPane.setOnDragDropped(event -> {
Dragboard db = event.getDragboard();
boolean success = false;
if(db.hasFiles()) {
for(File file : db.getFiles()) {
if(isVerifyDownloadFile(file)) {
signature.set(file);
break;
}
}
success = true;
}
event.setDropCompleted(success);
event.consume();
});
dialogPane.setOnDragEntered(event -> {
dialogPane.getStyleClass().add(DRAG_OVER_CLASS);
});
dialogPane.setOnDragExited(event -> {
dialogPane.getStyleClass().removeAll(DRAG_OVER_CLASS);
});
}
private void verify() {
manifestDisabled.set(false);
publicKeyDisabled.set(false);
if(signature.get() == null || manifest.get() == null) {
clearReleaseFields();
return;
}
PGPVerifyService pgpVerifyService = new PGPVerifyService(signature.get(), manifest.get(), publicKey.get());
pgpVerifyService.setOnRunning(event -> {
signedBy.setText("Verifying...");
signedBy.setGraphic(GlyphUtils.getBusyGlyph());
signedBy.setTooltip(null);
clearReleaseFields();
});
pgpVerifyService.setOnSucceeded(event -> {
PGPVerificationResult result = pgpVerifyService.getValue();
String message = result.userId() + " on " + signatureDateFormat.format(result.signatureTimestamp()) + (result.expired() ? " (key expired)" : "");
signedBy.setText(message);
signedBy.setGraphic(result.expired() ? GlyphUtils.getWarningGlyph() : GlyphUtils.getSuccessGlyph());
signedBy.setTooltip(new Tooltip(result.fingerprint()));
if(!result.expired() && result.keySource() != PGPKeySource.USER) {
publicKeyDisabled.set(true);
}
if(manifest.get().equals(release.get())) {
manifestDisabled.set(true);
releaseHash.setText("No hash required, signature signs release file directly");
releaseHash.setGraphic(GlyphUtils.getSuccessGlyph());
releaseHash.setTooltip(null);
releaseVerified.setText("Ready to install ");
releaseVerified.setGraphic(GlyphUtils.getSuccessGlyph());
releaseLink.setText(release.get().getName());
} else {
verifyManifest();
}
});
pgpVerifyService.setOnFailed(event -> {
Throwable e = event.getSource().getException();
signedBy.setText(getDisplayMessage(e));
signedBy.setGraphic(GlyphUtils.getFailureGlyph());
signedBy.setTooltip(null);
clearReleaseFields();
});
pgpVerifyService.start();
}
private void clearReleaseFields() {
releaseHash.setText("");
releaseHash.setGraphic(null);
releaseHash.setTooltip(null);
releaseVerified.setText("");
releaseVerified.setGraphic(null);
releaseLink.setText("");
}
private void verifyManifest() {
File releaseFile = release.get();
if(releaseFile != null && releaseFile.exists()) {
FileSha256Service hashService = new FileSha256Service(releaseFile);
hashService.setOnRunning(event -> {
releaseHash.setText("Calculating...");
releaseHash.setGraphic(GlyphUtils.getBusyGlyph());
releaseHash.setTooltip(null);
releaseVerified.setText("");
releaseVerified.setGraphic(null);
releaseLink.setText("");
});
hashService.setOnSucceeded(event -> {
String calculatedHash = hashService.getValue();
try {
Map<File, String> manifestMap = getManifest(manifest.get());
String manifestHash = getManifestHash(releaseFile.getName(), manifestMap);
if(calculatedHash.equalsIgnoreCase(manifestHash)) {
releaseHash.setText("Matched manifest hash");
releaseHash.setGraphic(GlyphUtils.getSuccessGlyph());
releaseHash.setTooltip(new Tooltip(calculatedHash));
releaseVerified.setText("Ready to install ");
releaseVerified.setGraphic(GlyphUtils.getSuccessGlyph());
releaseLink.setText(releaseFile.getName());
} else if(manifestHash == null) {
releaseHash.setText("Could not find manifest hash for " + releaseFile.getName());
releaseHash.setGraphic(GlyphUtils.getFailureGlyph());
releaseHash.setTooltip(new Tooltip("Manifest hashes provided for:\n" + manifestMap.keySet().stream().map(File::getName).collect(Collectors.joining("\n"))));
releaseVerified.setText("Cannot verify " + releaseFile.getName());
releaseVerified.setGraphic(GlyphUtils.getFailureGlyph());
releaseLink.setText("");
} else {
releaseHash.setText("Did not match manifest hash");
releaseHash.setGraphic(GlyphUtils.getFailureGlyph());
releaseHash.setTooltip(new Tooltip("Calculated Hash: " + calculatedHash + "\nManifest Hash: " + manifestHash));
releaseVerified.setText("Cannot verify " + releaseFile.getName());
releaseVerified.setGraphic(GlyphUtils.getFailureGlyph());
releaseLink.setText("");
}
} catch(IOException | InvalidManifestException e) {
releaseHash.setText("Could not read manifest");
releaseHash.setGraphic(GlyphUtils.getFailureGlyph());
releaseHash.setTooltip(new Tooltip(e.getMessage()));
releaseVerified.setText("Cannot verify " + releaseFile.getName());
releaseVerified.setGraphic(GlyphUtils.getFailureGlyph());
releaseLink.setText("");
}
});
hashService.setOnFailed(event -> {
releaseHash.setText("Could not calculate manifest");
releaseHash.setGraphic(GlyphUtils.getFailureGlyph());
releaseHash.setTooltip(new Tooltip(event.getSource().getException().getMessage()));
releaseVerified.setText("Cannot verify " + releaseFile.getName());
releaseVerified.setGraphic(GlyphUtils.getFailureGlyph());
releaseLink.setText("");
});
hashService.start();
} else {
releaseHash.setText("No release file");
releaseHash.setGraphic(GlyphUtils.getFailureGlyph());
releaseHash.setTooltip(null);
releaseVerified.setText("Not verified");
releaseVerified.setGraphic(GlyphUtils.getFailureGlyph());
releaseLink.setText("");
}
}
private Field setupField(ObjectProperty<File> fileProperty, String title, List<String> extensions, boolean optional, String example, BooleanProperty disabledProperty) {
Field field = new Field();
field.setText(title + ":");
FileField fileField = new FileField(fileProperty, title, extensions, optional, example, disabledProperty);
field.getInputs().add(fileField);
return field;
}
private Field setupResultField(Label label, String title) {
Field field = new Field();
field.setText(title + ":");
field.getInputs().add(label);
label.setGraphicTextGap(8);
return field;
}
public static Map<File, String> getManifest(File manifest) throws IOException, InvalidManifestException {
if(manifest.length() > MAX_VALID_MANIFEST_SIZE) {
throw new InvalidManifestException();
}
try(InputStream manifestStream = new FileInputStream(manifest)) {
return getManifest(manifestStream);
}
}
public static Map<File, String> getManifest(InputStream manifestStream) throws IOException {
Map<File, String> manifest = new HashMap<>();
BufferedReader reader = new BufferedReader(new InputStreamReader(manifestStream, StandardCharsets.UTF_8));
String line;
while((line = reader.readLine()) != null) {
String[] parts = line.split("\\s+");
if(parts.length > 1 && parts[0].length() == 64) {
String manifestHash = parts[0];
String manifestFileName = parts[1];
if(manifestFileName.startsWith("*") || manifestFileName.startsWith("U") || manifestFileName.startsWith("^")) {
manifestFileName = manifestFileName.substring(1);
}
manifest.put(new File(manifestFileName), manifestHash);
}
}
return manifest;
}
private String getManifestHash(String contentFileName, Map<File, String> manifest) {
for(Map.Entry<File, String> entry : manifest.entrySet()) {
if(contentFileName.equalsIgnoreCase(entry.getKey().getName())) {
return entry.getValue();
}
}
return null;
}
private File findSignatureFile(File providedFile) {
for(String extension : SIGNATURE_EXTENSIONS) {
File signatureFile = new File(providedFile.getParentFile(), providedFile.getName() + "." + extension);
if(signatureFile.exists()) {
return signatureFile;
}
}
if(providedFile.getName().toLowerCase(Locale.ROOT).startsWith(SPARROW_RELEASE_PREFIX)) {
Matcher matcher = SPARROW_RELEASE_VERSION.matcher(providedFile.getName());
if(matcher.find()) {
String version = matcher.group();
File signatureFile = new File(providedFile.getParentFile(), SPARROW_RELEASE_PREFIX + version + SPARROW_SIGNATURE_SUFFIX);
if(signatureFile.exists()) {
return signatureFile;
}
}
}
return null;
}
private File findManifestFile(File providedFile) {
String signatureName = providedFile.getName();
if(signatureName.length() > 4 && SIGNATURE_EXTENSIONS.stream().anyMatch(ext -> signatureName.toLowerCase(Locale.ROOT).endsWith("." + ext))) {
File manifestFile = new File(providedFile.getParent(), signatureName.substring(0, signatureName.length() - 4));
if(manifestFile.exists()) {
return manifestFile;
}
}
return null;
}
private File findReleaseFile(File manifestFile, Map<File, String> manifestMap) {
List<String> releaseExtensions = getReleaseFileExtensions();
List<List<String>> extensionLists = List.of(releaseExtensions, DISK_IMAGE_EXTENSIONS, ARCHIVE_EXTENSIONS, List.of(""));
for(List<String> extensions : extensionLists) {
for(File file : manifestMap.keySet()) {
if(extensions.stream().anyMatch(ext -> file.getName().toLowerCase(Locale.ROOT).endsWith(ext))) {
File releaseFile = new File(manifestFile.getParent(), file.getName());
if(releaseFile.exists()) {
return releaseFile;
}
}
}
}
return null;
}
private List<String> getReleaseFileExtensions() {
Platform platform = Platform.getCurrent();
switch(platform) {
case OSX -> {
return MACOS_RELEASE_EXTENSIONS;
}
case WINDOWS -> {
return WINDOWS_RELEASE_EXTENSIONS;
}
default -> {
return LINUX_RELEASE_EXTENSIONS;
}
}
}
private String getReleaseFileExample(String version) {
Platform platform = Platform.getCurrent();
String arch = System.getProperty("os.arch");
switch(platform) {
case OSX -> {
return "Sparrow-" + version + "-" + arch;
}
case WINDOWS -> {
return "Sparrow-" + version;
}
default -> {
return "sparrow_" + version + "-1_" + (arch.equals("aarch64") ? "arm64" : arch);
}
}
}
private String getDisplayMessage(Throwable e) {
String message = e.getMessage();
message = message.substring(0, 1).toUpperCase(Locale.ROOT) + message.substring(1);
if(message.endsWith(".")) {
message = message.substring(0, message.length() - 1);
}
if(message.equals("Invalid header encountered")) {
message += ", not a valid signature file";
}
if(message.startsWith("Malformed message")) {
message = "Not a valid signature file";
}
return message;
}
public static boolean isVerifyDownloadFile(File file) {
if(file != null) {
String name = file.getName().toLowerCase(Locale.ROOT);
if(name.length() > 4 && SIGNATURE_EXTENSIONS.stream().anyMatch(ext -> name.endsWith("." + ext))) {
return true;
}
if(MANIFEST_EXTENSIONS.stream().anyMatch(ext -> name.endsWith("." + ext)) || name.startsWith(SHA256SUMS_MANIFEST_PREFIX)) {
try {
Map<File, String> manifest = getManifest(file);
return !manifest.isEmpty();
} catch(Exception e) {
//ignore
}
}
if(name.startsWith(SPARROW_RELEASE_PREFIX) && file.length() >= MIN_VALID_SPARROW_RELEASE_SIZE) {
Matcher matcher = SPARROW_RELEASE_VERSION.matcher(name);
return matcher.find();
}
}
return false;
}
public void setSignatureFile(File signatureFile) {
signature.set(signatureFile);
}
private static class Header extends GridPane {
public Header() {
setMaxWidth(Double.MAX_VALUE);
getStyleClass().add("header-panel");
VBox vBox = new VBox();
vBox.setPadding(new Insets(10, 0, 0, 0));
Label headerLabel = new Label("Verify Download");
headerLabel.setWrapText(true);
headerLabel.setAlignment(Pos.CENTER_LEFT);
headerLabel.setMaxWidth(Double.MAX_VALUE);
headerLabel.setMaxHeight(Double.MAX_VALUE);
CopyableLabel descriptionLabel = new CopyableLabel("Download the release file, GPG signature and optional manifest of a project to verify the download integrity");
descriptionLabel.setAlignment(Pos.CENTER_LEFT);
vBox.getChildren().addAll(headerLabel, descriptionLabel);
add(vBox, 0, 0);
StackPane graphicContainer = new StackPane();
graphicContainer.getStyleClass().add("graphic-container");
Image image = new Image("image/sparrow-small.png", 50, 50, false, false);
if (!image.isError()) {
ImageView imageView = new ImageView();
imageView.setSmooth(false);
imageView.setImage(image);
graphicContainer.getChildren().add(imageView);
}
add(graphicContainer, 1, 0);
ColumnConstraints textColumn = new ColumnConstraints();
textColumn.setFillWidth(true);
textColumn.setHgrow(Priority.ALWAYS);
ColumnConstraints graphicColumn = new ColumnConstraints();
graphicColumn.setFillWidth(false);
graphicColumn.setHgrow(Priority.NEVER);
getColumnConstraints().setAll(textColumn , graphicColumn);
}
}
private static class FileField extends HBox {
private final ObjectProperty<File> fileProperty;
public FileField(ObjectProperty<File> fileProperty, String title, List<String> extensions, boolean optional, String example, BooleanProperty disabledProperty) {
super(10);
this.fileProperty = fileProperty;
TextField textField = new TextField();
textField.setEditable(false);
textField.setPromptText("e.g. " + example + formatExtensionsList(extensions) + (optional ? " (optional)" : ""));
textField.setOnMouseClicked(event -> browseForFile(title, extensions));
Button browseButton = new Button("Browse...");
browseButton.setOnAction(event -> browseForFile(title, extensions));
getChildren().addAll(textField, browseButton);
HBox.setHgrow(textField, Priority.ALWAYS);
fileProperty.addListener((observable, oldValue, file) -> {
textField.setText(file == null ? "" : file.getAbsolutePath());
if(file != null) {
lastFileParent = file.getParentFile();
}
});
if(disabledProperty != null) {
disabledProperty.addListener((observable, oldValue, disabled) -> {
textField.setDisable(disabled);
browseButton.setDisable(disabled);
});
}
}
private void browseForFile(String title, List<String> extensions) {
Stage window = new Stage();
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("Open File");
File userDir = new File(System.getProperty("user.home"));
File downloadsDir = new File(userDir, "Downloads");
fileChooser.setInitialDirectory(lastFileParent != null ? lastFileParent : (downloadsDir.exists() ? downloadsDir : userDir));
fileChooser.setSelectedExtensionFilter(new FileChooser.ExtensionFilter(title + " files", extensions));
AppServices.moveToActiveWindowScreen(window, 800, 450);
File file = fileChooser.showOpenDialog(window);
if(file != null) {
fileProperty.set(file);
}
}
public String formatExtensionsList(List<String> items) {
StringBuilder result = new StringBuilder();
for(int i = 0; i < items.size(); i++) {
result.append(".").append(items.get(i));
if (i < items.size() - 1) {
result.append(", ");
}
if (i == items.size() - 2) {
result.append("or ");
}
}
return result.toString();
}
}
private static class PGPVerifyService extends Service<PGPVerificationResult> {
private final File signature;
private final File manifest;
private final File publicKey;
public PGPVerifyService(File signature, File manifest, File publicKey) {
this.signature = signature;
this.manifest = manifest;
this.publicKey = publicKey;
}
@Override
protected Task<PGPVerificationResult> createTask() {
return new Task<>() {
protected PGPVerificationResult call() throws IOException, PGPVerificationException {
boolean detachedSignature = !manifest.equals(signature);
try(InputStream publicKeyStream = publicKey == null ? null : new FileInputStream(publicKey);
InputStream contentStream = new BufferedInputStream(new FileInputStream(manifest));
InputStream detachedSignatureStream = detachedSignature ? new FileInputStream(signature) : null) {
return PGPUtils.verify(publicKeyStream, contentStream, detachedSignatureStream);
}
}
};
}
}
private static class FileSha256Service extends Service<String> {
private final File file;
public FileSha256Service(File file) {
this.file = file;
}
@Override
protected Task<String> createTask() {
return new Task<>() {
protected String call() throws IOException {
try(InputStream inputStream = new BufferedInputStream(new FileInputStream(file))) {
return sha256(inputStream);
}
}
};
}
private String sha256(InputStream stream) throws IOException {
try {
final byte[] buffer = new byte[1024 * 1024];
final MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
int bytesRead = 0;
while((bytesRead = stream.read(buffer)) >= 0) {
if (bytesRead > 0) {
sha256.update(buffer, 0, bytesRead);
}
}
return Utils.bytesToHex(sha256.digest());
} catch(NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
}
}
private static class InvalidManifestException extends Exception { }
}
@@ -585,12 +585,14 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
getItems().add(createCpfp);
}
MenuItem openBlockExplorer = new MenuItem("Open in Block Explorer");
openBlockExplorer.setOnAction(AE -> {
hide();
AppServices.openBlockExplorer(blockTransaction.getHashAsString());
});
getItems().add(openBlockExplorer);
if(!Config.get().isBlockExplorerDisabled()) {
MenuItem openBlockExplorer = new MenuItem("Open in Block Explorer");
openBlockExplorer.setOnAction(AE -> {
hide();
AppServices.openBlockExplorer(blockTransaction.getHashAsString());
});
getItems().add(openBlockExplorer);
}
MenuItem copyTxid = new MenuItem("Copy Transaction ID");
copyTxid.setOnAction(AE -> {
@@ -604,7 +606,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
}
}
private static class TransactionContextMenu extends ContextMenu {
protected static class TransactionContextMenu extends ContextMenu {
public TransactionContextMenu(String date, BlockTransaction blockTransaction) {
MenuItem viewTransaction = new MenuItem("View Transaction");
viewTransaction.setGraphic(getViewTransactionGlyph());
@@ -612,12 +614,16 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
hide();
EventManager.get().post(new ViewTransactionEvent(this.getOwnerWindow(), blockTransaction));
});
getItems().add(viewTransaction);
MenuItem openBlockExplorer = new MenuItem("Open in Block Explorer");
openBlockExplorer.setOnAction(AE -> {
hide();
AppServices.openBlockExplorer(blockTransaction.getHashAsString());
});
if(!Config.get().isBlockExplorerDisabled()) {
MenuItem openBlockExplorer = new MenuItem("Open in Block Explorer");
openBlockExplorer.setOnAction(AE -> {
hide();
AppServices.openBlockExplorer(blockTransaction.getHashAsString());
});
getItems().add(openBlockExplorer);
}
MenuItem copyDate = new MenuItem("Copy Date");
copyDate.setOnAction(AE -> {
@@ -626,6 +632,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
content.putString(date);
Clipboard.getSystemClipboard().setContent(content);
});
getItems().add(copyDate);
MenuItem copyTxid = new MenuItem("Copy Transaction ID");
copyTxid.setOnAction(AE -> {
@@ -634,6 +641,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
content.putString(blockTransaction.getHashAsString());
Clipboard.getSystemClipboard().setContent(content);
});
getItems().add(copyTxid);
MenuItem copyHeight = new MenuItem("Copy Block Height");
copyHeight.setOnAction(AE -> {
@@ -642,8 +650,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
content.putString(blockTransaction.getHeight() > 0 ? Integer.toString(blockTransaction.getHeight()) : "Mempool");
Clipboard.getSystemClipboard().setContent(content);
});
getItems().addAll(viewTransaction, openBlockExplorer, copyDate, copyTxid, copyHeight);
getItems().add(copyHeight);
}
}
@@ -13,6 +13,8 @@ import java.util.stream.Collectors;
import static com.sparrowwallet.sparrow.AppServices.*;
public class FeeRangeSlider extends Slider {
private static final double FEE_RATE_SCROLL_INCREMENT = 0.01;
public FeeRangeSlider() {
super(0, FEE_RATES_RANGE.size() - 1, 0);
setMajorTickUnit(1);
@@ -45,6 +47,18 @@ public class FeeRangeSlider extends Slider {
updateMaxFeeRange(newValue.doubleValue());
}
});
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);
}
setFeeRate(newFeeRate);
}
});
}
public double getFeeRate() {
@@ -240,6 +240,8 @@ public abstract class FileImportPane extends TitledDescriptionPane {
contentBox.setPadding(new Insets(10, 30, 10, 30));
contentBox.setPrefHeight(60);
javafx.application.Platform.runLater(passwordField::requestFocus);
return contentBox;
}
}
@@ -4,12 +4,14 @@ import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.KeystoreSource;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.hummingbird.registry.RegistryType;
import com.sparrowwallet.hummingbird.UR;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.KeystoreExportEvent;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.*;
import com.sparrowwallet.sparrow.io.bbqr.BBQR;
import com.sparrowwallet.sparrow.io.bbqr.BBQRType;
import javafx.geometry.Pos;
import javafx.scene.control.Button;
import javafx.scene.control.ButtonType;
@@ -153,7 +155,9 @@ public class FileKeystoreExportPane extends TitledDescriptionPane {
} else {
QRDisplayDialog qrDisplayDialog;
if(exporter instanceof Bip129) {
qrDisplayDialog = new QRDisplayDialog(RegistryType.BYTES.toString(), baos.toByteArray(), false);
UR ur = UR.fromBytes(baos.toByteArray());
BBQR bbqr = new BBQR(BBQRType.UNICODE, baos.toByteArray());
qrDisplayDialog = new QRDisplayDialog(ur, bbqr, false, true, false);
} else {
qrDisplayDialog = new QRDisplayDialog(baos.toString(StandardCharsets.UTF_8));
}
@@ -29,7 +29,7 @@ public class FileKeystoreImportPane extends FileImportPane {
}
if(requiredDerivation != null && !requiredDerivation.getDerivation().equals(keystore.getKeyDerivation().getDerivation())) {
setError("Incorrect derivation", "This account requires a derivation of " + requiredDerivation.getDerivationPath() + ", but the imported keystore has a derivation of " + keystore.getKeyDerivation().getDerivationPath() + ".");
setError("Incorrect derivation", "This account requires a derivation of " + requiredDerivation.getDerivationPath() + ", but the imported keystore has a derivation of " + KeyDerivation.writePath(keystore.getKeyDerivation().getDerivation()) + ".");
} else {
EventManager.get().post(new KeystoreImportEvent(keystore));
}
@@ -4,6 +4,7 @@ import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.OutputDescriptor;
import com.sparrowwallet.drongo.SecureString;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.hummingbird.UR;
import com.sparrowwallet.hummingbird.registry.CryptoOutput;
import com.sparrowwallet.hummingbird.registry.RegistryType;
import com.sparrowwallet.sparrow.AppServices;
@@ -13,6 +14,8 @@ import com.sparrowwallet.sparrow.event.TimedEvent;
import com.sparrowwallet.sparrow.event.WalletExportEvent;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.*;
import com.sparrowwallet.sparrow.io.bbqr.BBQR;
import com.sparrowwallet.sparrow.io.bbqr.BBQRType;
import javafx.concurrent.Service;
import javafx.concurrent.Task;
import javafx.geometry.Pos;
@@ -163,12 +166,23 @@ public class FileWalletExportPane extends TitledDescriptionPane {
QRDisplayDialog qrDisplayDialog;
if(exporter instanceof CoboVaultMultisig) {
qrDisplayDialog = new QRDisplayDialog(RegistryType.BYTES.toString(), outputStream.toByteArray(), true);
} else if(exporter instanceof PassportMultisig || exporter instanceof KeystoneMultisig || exporter instanceof JadeMultisig || exporter instanceof Bip129) {
} else if(exporter instanceof PassportMultisig || exporter instanceof KeystoneMultisig || exporter instanceof JadeMultisig) {
qrDisplayDialog = new QRDisplayDialog(RegistryType.BYTES.toString(), outputStream.toByteArray(), false);
} else if(exporter instanceof Bip129) {
UR ur = UR.fromBytes(outputStream.toByteArray());
BBQR bbqr = new BBQR(BBQRType.UNICODE, outputStream.toByteArray());
qrDisplayDialog = new QRDisplayDialog(ur, bbqr, false, false, false);
} 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());
OutputDescriptor outputDescriptor = OutputDescriptor.getOutputDescriptor(exportWallet, KeyPurpose.DEFAULT_PURPOSES, null);
CryptoOutput cryptoOutput = getCryptoOutput(exportWallet);
qrDisplayDialog = new DescriptorQRDisplayDialog(exportWallet.getFullDisplayName(), outputDescriptor.toString(true), cryptoOutput.toUR());
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);
} 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);
} else {
qrDisplayDialog = new QRDisplayDialog(outputStream.toString(StandardCharsets.UTF_8));
}
@@ -12,6 +12,7 @@ import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.WalletImportEvent;
import com.sparrowwallet.sparrow.io.ImportException;
import com.sparrowwallet.sparrow.io.KeystoreFileImport;
import javafx.application.Platform;
import javafx.collections.FXCollections;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
@@ -38,6 +39,7 @@ public class FileWalletKeystoreImportPane extends FileImportPane {
private final KeystoreFileImport importer;
private String fileName;
private byte[] fileBytes;
private String password;
public FileWalletKeystoreImportPane(KeystoreFileImport importer) {
super(importer, importer.getName(), "Wallet import", importer.getKeystoreImportDescription(), "image/" + importer.getWalletModel().getType() + ".png", importer.isKeystoreImportScannable(), importer.isFileFormatAvailable());
@@ -46,6 +48,7 @@ public class FileWalletKeystoreImportPane extends FileImportPane {
protected void importFile(String fileName, InputStream inputStream, String password) throws ImportException {
this.fileName = fileName;
this.password = password;
List<ScriptType> scriptTypes = ScriptType.getAddressableScriptTypes(PolicyType.SINGLE);
if(wallets != null && !wallets.isEmpty()) {
@@ -83,7 +86,7 @@ public class FileWalletKeystoreImportPane extends FileImportPane {
EventManager.get().post(new WalletImportEvent(wallet));
} else {
ByteArrayInputStream bais = new ByteArrayInputStream(fileBytes);
Keystore keystore = importer.getKeystore(scriptType, bais, "");
Keystore keystore = importer.getKeystore(scriptType, bais, password);
Wallet wallet = new Wallet();
wallet.setName(Files.getNameWithoutExtension(fileName));
@@ -151,6 +154,8 @@ public class FileWalletKeystoreImportPane extends FileImportPane {
contentBox.setPadding(new Insets(10, 30, 10, 30));
contentBox.setPrefHeight(60);
Platform.runLater(scriptTypeComboBox::requestFocus);
return contentBox;
}
}
@@ -472,7 +472,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(qrText, true);
qrDisplayDialog.initOwner(getDialogPane().getScene().getWindow());
Optional<ButtonType> optButtonType = qrDisplayDialog.showAndWait();
if(optButtonType.isPresent() && optButtonType.get().getButtonData() == ButtonBar.ButtonData.NEXT_FORWARD) {
if(optButtonType.isPresent() && optButtonType.get().getButtonData() == ButtonBar.ButtonData.OK_DONE) {
scanQr();
}
}
@@ -1,27 +1,11 @@
package com.sparrowwallet.sparrow.control;
import com.samourai.whirlpool.client.mix.listener.MixFailReason;
import com.samourai.whirlpool.client.mix.listener.MixStep;
import com.samourai.whirlpool.client.wallet.beans.MixProgress;
import com.samourai.whirlpool.protocol.beans.Utxo;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.wallet.Entry;
import com.sparrowwallet.sparrow.wallet.UtxoEntry;
import com.sparrowwallet.sparrow.whirlpool.Whirlpool;
import com.sparrowwallet.sparrow.whirlpool.WhirlpoolException;
import javafx.animation.Timeline;
import javafx.geometry.Pos;
import javafx.scene.control.*;
import javafx.util.Duration;
import org.controlsfx.glyphfont.Glyph;
import org.controlsfx.tools.Platform;
import java.util.Locale;
public class MixStatusCell extends TreeTableCell<Entry, UtxoEntry.MixStatus> {
private static final int ERROR_DISPLAY_MILLIS = 5 * 60 * 1000;
public MixStatusCell() {
super();
setAlignment(Pos.CENTER_RIGHT);
@@ -41,174 +25,9 @@ public class MixStatusCell extends TreeTableCell<Entry, UtxoEntry.MixStatus> {
setGraphic(null);
} else {
setText(Integer.toString(mixStatus.getMixesDone()));
if(mixStatus.getNextMixUtxo() == null) {
setContextMenu(new MixStatusContextMenu(mixStatus.getUtxoEntry(), mixStatus.getMixProgress() != null && mixStatus.getMixProgress().getMixStep() != MixStep.FAIL));
} else {
setContextMenu(null);
}
if(mixStatus.getNextMixUtxo() != null) {
setMixSuccess(mixStatus.getNextMixUtxo());
} else if(mixStatus.getMixFailReason() != null) {
setMixFail(mixStatus.getMixFailReason(), mixStatus.getMixError(), mixStatus.getMixErrorTimestamp());
} else if(mixStatus.getMixProgress() != null) {
setMixProgress(mixStatus.getUtxoEntry(), mixStatus.getMixProgress());
} else {
setGraphic(null);
setTooltip(null);
}
}
}
private void setMixSuccess(Utxo nextMixUtxo) {
ProgressIndicator progressIndicator = getProgressIndicator();
progressIndicator.setProgress(-1);
setGraphic(progressIndicator);
Tooltip tt = new Tooltip();
tt.setText("Waiting for broadcast of " + nextMixUtxo.getHash().substring(0, 8) + "..." + ":" + nextMixUtxo.getIndex() );
setTooltip(tt);
}
private void setMixFail(MixFailReason mixFailReason, String mixError, Long mixErrorTimestamp) {
if(mixFailReason != MixFailReason.CANCEL) {
long elapsed = mixErrorTimestamp == null ? 0L : System.currentTimeMillis() - mixErrorTimestamp;
if(elapsed >= ERROR_DISPLAY_MILLIS) {
//Old error, don't set again.
return;
}
Glyph failGlyph = getFailGlyph();
setGraphic(failGlyph);
Tooltip tt = new Tooltip();
tt.setText(mixFailReason.getMessage() + (mixError == null ? "" : ": " + mixError) +
"\nMix failures are generally caused by peers disconnecting during a mix." +
"\nMake sure your internet connection is stable and the computer is configured to prevent sleeping." +
"\nTo prevent sleeping, use the " + getPlatformSleepConfig() + " or enable the function in the Tools menu.");
setTooltip(tt);
Duration fadeDuration = Duration.millis(ERROR_DISPLAY_MILLIS - elapsed);
double fadeFromValue = 1.0 - ((double)elapsed / ERROR_DISPLAY_MILLIS);
Timeline timeline = AnimationUtil.getSlowFadeOut(failGlyph, fadeDuration, fadeFromValue, 10);
timeline.setOnFinished(event -> {
setTooltip(null);
});
timeline.play();
} else {
setContextMenu(null);
setGraphic(null);
setTooltip(null);
}
}
private String getPlatformSleepConfig() {
Platform platform = Platform.getCurrent();
if(platform == Platform.OSX) {
return "OSX System Preferences";
} else if(platform == Platform.WINDOWS) {
return "Windows Control Panel";
}
return "system power settings";
}
private void setMixProgress(UtxoEntry utxoEntry, MixProgress mixProgress) {
if(mixProgress.getMixStep() != MixStep.FAIL) {
ProgressIndicator progressIndicator = getProgressIndicator();
progressIndicator.setProgress(mixProgress.getMixStep().getProgressPercent() == 100 ? -1 : mixProgress.getMixStep().getProgressPercent() / 100.0);
setGraphic(progressIndicator);
Tooltip tt = new Tooltip();
String status = mixProgress.getMixStep().getMessage().substring(0, 1).toUpperCase(Locale.ROOT) + mixProgress.getMixStep().getMessage().substring(1);
tt.setText(status);
setTooltip(tt);
if(mixProgress.getMixStep() == MixStep.REGISTERED_INPUT) {
tt.setOnShowing(event -> {
Whirlpool whirlpool = AppServices.getWhirlpoolServices().getWhirlpool(utxoEntry.getWallet());
Whirlpool.RegisteredInputsService registeredInputsService = new Whirlpool.RegisteredInputsService(whirlpool, mixProgress.getPoolId());
registeredInputsService.setOnSucceeded(eventStateHandler -> {
if(registeredInputsService.getValue() != null) {
tt.setText(status + " (1 of " + registeredInputsService.getValue() + ")");
}
});
registeredInputsService.start();
});
}
} else {
setGraphic(null);
setTooltip(null);
}
}
private ProgressIndicator getProgressIndicator() {
ProgressIndicator progressIndicator;
if(getGraphic() instanceof ProgressIndicator) {
progressIndicator = (ProgressIndicator)getGraphic();
} else {
progressIndicator = new ProgressBar();
}
return progressIndicator;
}
private static Glyph getMixGlyph() {
Glyph copyGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.RANDOM);
copyGlyph.setFontSize(12);
return copyGlyph;
}
private static Glyph getStopGlyph() {
Glyph copyGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.STOP_CIRCLE);
copyGlyph.setFontSize(12);
return copyGlyph;
}
public static Glyph getFailGlyph() {
Glyph failGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.EXCLAMATION_CIRCLE);
failGlyph.getStyleClass().add("fail-warning");
failGlyph.setFontSize(12);
return failGlyph;
}
private static class MixStatusContextMenu extends ContextMenu {
public MixStatusContextMenu(UtxoEntry utxoEntry, boolean isMixing) {
Whirlpool pool = AppServices.getWhirlpoolServices().getWhirlpool(utxoEntry.getWallet());
if(isMixing) {
MenuItem mixStop = new MenuItem("Stop Mixing");
if(pool != null) {
mixStop.disableProperty().bind(pool.mixingProperty().not());
}
mixStop.setGraphic(getStopGlyph());
mixStop.setOnAction(event -> {
hide();
Whirlpool whirlpool = AppServices.getWhirlpoolServices().getWhirlpool(utxoEntry.getWallet());
if(whirlpool != null) {
try {
whirlpool.mixStop(utxoEntry.getHashIndex());
} catch(WhirlpoolException e) {
AppServices.showErrorDialog("Error stopping mixing UTXO", e.getMessage());
}
}
});
getItems().add(mixStop);
} else {
MenuItem mixNow = new MenuItem("Mix Now");
if(pool != null) {
mixNow.disableProperty().bind(pool.mixingProperty().not());
}
mixNow.setGraphic(getMixGlyph());
mixNow.setOnAction(event -> {
hide();
Whirlpool whirlpool = AppServices.getWhirlpoolServices().getWhirlpool(utxoEntry.getWallet());
if(whirlpool != null) {
try {
whirlpool.mix(utxoEntry.getHashIndex());
} catch(WhirlpoolException e) {
AppServices.showErrorDialog("Error mixing UTXO", e.getMessage());
}
}
});
getItems().add(mixNow);
}
}
}
}
@@ -33,6 +33,7 @@ import java.util.Optional;
public class MnemonicKeystoreImportPane extends MnemonicKeystorePane {
protected final Wallet wallet;
private final KeystoreMnemonicImport importer;
private final KeyDerivation defaultDerivation;
private SplitMenuButton importButton;
@@ -43,10 +44,11 @@ public class MnemonicKeystoreImportPane extends MnemonicKeystorePane {
private Button confirmButton;
private List<String> generatedMnemonicCode;
public MnemonicKeystoreImportPane(Wallet wallet, KeystoreMnemonicImport importer) {
public MnemonicKeystoreImportPane(Wallet wallet, KeystoreMnemonicImport importer, KeyDerivation defaultDerivation) {
super(importer.getName(), "Create or enter seed", importer.getKeystoreImportDescription(), "image/" + importer.getWalletModel().getType() + ".png");
this.wallet = wallet;
this.importer = importer;
this.defaultDerivation = defaultDerivation;
createImportButton();
buttonBox.getChildren().add(importButton);
@@ -59,7 +61,7 @@ public class MnemonicKeystoreImportPane extends MnemonicKeystorePane {
importButton.getStyleClass().add("default-button");
importButton.setOnAction(event -> {
importButton.setDisable(true);
importKeystore(wallet.getScriptType().getDefaultDerivation(), false);
importKeystore(getDefaultDerivation(), false);
});
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;
@@ -77,6 +79,10 @@ public class MnemonicKeystoreImportPane extends MnemonicKeystorePane {
importButton.setVisible(false);
}
private List<ChildNumber> getDefaultDerivation() {
return defaultDerivation == null || defaultDerivation.getDerivation().isEmpty() ? wallet.getScriptType().getDefaultDerivation() : defaultDerivation.getDerivation();
}
protected void enterMnemonic(int numWords) {
generatedMnemonicCode = null;
super.enterMnemonic(numWords);
@@ -243,7 +249,7 @@ public class MnemonicKeystoreImportPane extends MnemonicKeystorePane {
setDescription("Ready to import");
showHideLink.setText("Show Derivation...");
showHideLink.setVisible(false);
setContent(getDerivationEntry(wallet.getScriptType().getDefaultDerivation()));
setContent(getDerivationEntry(getDefaultDerivation()));
}
}
@@ -79,10 +79,6 @@ public class PayNymAvatar extends StackPane {
this.paymentCodeProperty.set(paymentCode);
}
public void setPaymentCode(com.samourai.wallet.bip47.rpc.PaymentCode paymentCode) {
setPaymentCode(PaymentCode.fromString(paymentCode.toString()));
}
public void clearPaymentCode() {
this.paymentCodeProperty.set(null);
}
@@ -81,10 +81,7 @@ public class PayNymCell extends ListCell<PayNym> {
linkButton.setDisable(true);
payNymController.linkPayNym(payNym);
});
if(payNymController.isSelectLinkedOnly()) {
getStyleClass().add("unlinked");
}
getStyleClass().add("unlinked");
}
}
@@ -10,11 +10,6 @@ public class PaymentCodeTextField extends CopyableTextField {
setPaymentCodeString();
}
public void setPaymentCode(com.samourai.wallet.bip47.rpc.PaymentCode paymentCode) {
this.paymentCodeStr = paymentCode.toString();
setPaymentCodeString();
}
private void setPaymentCodeString() {
String abbrevPcode = paymentCodeStr.substring(0, 12) + "..." + paymentCodeStr.substring(paymentCodeStr.length() - 5);
setText(abbrevPcode);
@@ -1,22 +1,28 @@
package com.sparrowwallet.sparrow.control;
public enum QRDensity {
NORMAL("Normal", 400),
LOW("Low", 80);
NORMAL("Normal", 400, 2000),
LOW("Low", 80, 1000);
private final String name;
private final int maxFragmentLength;
private final int maxUrFragmentLength;
private final int maxBbqrFragmentLength;
QRDensity(String name, int maxFragmentLength) {
QRDensity(String name, int maxUrFragmentLength, int maxBbqrFragmentLength) {
this.name = name;
this.maxFragmentLength = maxFragmentLength;
this.maxUrFragmentLength = maxUrFragmentLength;
this.maxBbqrFragmentLength = maxBbqrFragmentLength;
}
public String getName() {
return name;
}
public int getMaxFragmentLength() {
return maxFragmentLength;
public int getMaxUrFragmentLength() {
return maxUrFragmentLength;
}
public int getMaxBbqrFragmentLength() {
return maxBbqrFragmentLength;
}
}
@@ -14,6 +14,9 @@ import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.ImportException;
import com.sparrowwallet.hummingbird.UR;
import com.sparrowwallet.hummingbird.UREncoder;
import com.sparrowwallet.sparrow.io.bbqr.BBQR;
import com.sparrowwallet.sparrow.io.bbqr.BBQREncoder;
import com.sparrowwallet.sparrow.io.bbqr.BBQREncoding;
import javafx.concurrent.ScheduledService;
import javafx.concurrent.Task;
import javafx.scene.Node;
@@ -39,15 +42,21 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
private static final int MIN_FRAGMENT_LENGTH = 10;
private static final int ANIMATION_PERIOD_MILLIS = 200;
private static final double ANIMATION_PERIOD_MILLIS = 200d;
private static final int DEFAULT_QR_SIZE = 580;
private static final int REDUCED_QR_SIZE = 520;
private static final BBQREncoding DEFAULT_BBQR_ENCODING = BBQREncoding.ZLIB;
private final int qrSize = getQRSize();
private final UR ur;
private UREncoder encoder;
private UREncoder urEncoder;
private final BBQR bbqr;
private BBQREncoder bbqrEncoder;
private boolean useBbqrEncoding;
private final ImageView qrImageView;
@@ -62,17 +71,26 @@ 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), addLegacyEncodingOption, false);
this(UR.fromBytes(type, data), null, addLegacyEncodingOption, false, false);
}
public QRDisplayDialog(UR ur) {
this(ur, false, false);
this(ur, null, false, false, false);
}
public QRDisplayDialog(UR ur, boolean addLegacyEncodingOption, boolean addScanButton) {
public QRDisplayDialog(UR ur, BBQR bbqr, boolean addLegacyEncodingOption, boolean addScanButton, boolean selectBbqrButton) {
this.ur = ur;
this.addLegacyEncodingOption = addLegacyEncodingOption;
this.encoder = new UREncoder(ur, Config.get().getQrDensity().getMaxFragmentLength(), MIN_FRAGMENT_LENGTH, 0);
this.bbqr = bbqr;
this.addLegacyEncodingOption = bbqr == null && addLegacyEncodingOption;
this.urEncoder = new UREncoder(ur, Config.get().getQrDensity().getMaxUrFragmentLength(), MIN_FRAGMENT_LENGTH, 0);
if(bbqr != null) {
this.bbqrEncoder = new BBQREncoder(bbqr.type(), DEFAULT_BBQR_ENCODING, bbqr.data(), Config.get().getQrDensity().getMaxBbqrFragmentLength(), 0);
if(selectBbqrButton) {
useBbqrEncoding = true;
}
}
final DialogPane dialogPane = new QRDisplayDialogPane();
setDialogPane(dialogPane);
@@ -82,19 +100,29 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
qrImageView = new ImageView();
stackPane.getChildren().add(qrImageView);
qrImageView.setOnScroll(scrollEvent -> {
if(animateQRService != null && animateQRService.isRunning() && scrollEvent.getDeltaY() != 0) {
Duration duration = animateQRService.getPeriod();
Duration newDuration = scrollEvent.getDeltaY() > 0 ? duration.multiply(1.1) : duration.multiply(0.9);
if(newDuration.lessThan(Duration.millis(ANIMATION_PERIOD_MILLIS*10)) && newDuration.greaterThan(Duration.millis(ANIMATION_PERIOD_MILLIS/2))) {
animateQRService.setPeriod(newDuration);
}
}
});
dialogPane.setContent(Borders.wrap(stackPane).lineBorder().buildAll());
nextPart();
if(encoder.isSinglePart()) {
if(isSinglePart()) {
qrImageView.setImage(getQrCode(currentPart));
} else {
createAnimateQRService();
}
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Close", ButtonBar.ButtonData.CANCEL_CLOSE);
dialogPane.getButtonTypes().add(cancelButtonType);
if(addLegacyEncodingOption) {
if(this.addLegacyEncodingOption) {
final ButtonType legacyEncodingButtonType = new javafx.scene.control.ButtonType("Use Legacy Encoding (Cobo Vault)", ButtonBar.ButtonData.LEFT);
dialogPane.getButtonTypes().add(legacyEncodingButtonType);
} else {
@@ -102,8 +130,13 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
dialogPane.getButtonTypes().add(densityButtonType);
}
if(bbqr != null) {
final ButtonType bbqrButtonType = new javafx.scene.control.ButtonType("Show BBQr", ButtonBar.ButtonData.BACK_PREVIOUS);
dialogPane.getButtonTypes().add(bbqrButtonType);
}
if(addScanButton) {
final ButtonType scanButtonType = new javafx.scene.control.ButtonType("Scan QR", ButtonBar.ButtonData.NEXT_FORWARD);
final ButtonType scanButtonType = new javafx.scene.control.ButtonType("Scan QR", ButtonBar.ButtonData.OK_DONE);
dialogPane.getButtonTypes().add(scanButtonType);
}
@@ -121,7 +154,9 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
public QRDisplayDialog(String data, boolean addScanButton) {
this.ur = null;
this.encoder = null;
this.bbqr = null;
this.urEncoder = null;
this.bbqrEncoder = null;
final DialogPane dialogPane = new QRDisplayDialogPane();
setDialogPane(dialogPane);
@@ -134,11 +169,11 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
dialogPane.setContent(Borders.wrap(stackPane).lineBorder().buildAll());
qrImageView.setImage(getQrCode(data));
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Close", ButtonBar.ButtonData.CANCEL_CLOSE);
dialogPane.getButtonTypes().addAll(cancelButtonType);
if(addScanButton) {
final ButtonType scanButtonType = new javafx.scene.control.ButtonType("Scan QR", ButtonBar.ButtonData.NEXT_FORWARD);
final ButtonType scanButtonType = new javafx.scene.control.ButtonType("Scan QR", ButtonBar.ButtonData.OK_DONE);
dialogPane.getButtonTypes().add(scanButtonType);
}
@@ -163,9 +198,22 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
});
}
private boolean isSinglePart() {
if(useBbqrEncoding) {
return bbqrEncoder.isSinglePart();
} else if(!useLegacyEncoding) {
return urEncoder.isSinglePart();
} else {
return legacyParts.length == 1;
}
}
private void nextPart() {
if(!useLegacyEncoding) {
String fragment = encoder.nextPart();
if(useBbqrEncoding) {
String fragment = bbqrEncoder.nextPart();
currentPart = fragment.toUpperCase(Locale.ROOT);
} else if(!useLegacyEncoding) {
String fragment = urEncoder.nextPart();
currentPart = fragment.toUpperCase(Locale.ROOT);
} else {
currentPart = legacyParts[legacyPartIndex];
@@ -201,37 +249,27 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
this.legacyParts = legacyEncoder.encode();
this.useLegacyEncoding = true;
if(legacyParts.length == 1) {
if(animateQRService != null) {
animateQRService.cancel();
}
nextPart();
qrImageView.setImage(getQrCode(currentPart));
} else if(animateQRService == null) {
createAnimateQRService();
} else if(!animateQRService.isRunning()) {
animateQRService.reset();
animateQRService.start();
}
restartAnimation();
} catch(UR.InvalidTypeException e) {
//Can't happen
}
} else {
this.useLegacyEncoding = false;
restartAnimation();
}
}
if(encoder.isSinglePart()) {
if(animateQRService != null) {
animateQRService.cancel();
}
public boolean isUseBbqrEncoding() {
return useBbqrEncoding;
}
qrImageView.setImage(getQrCode(currentPart));
} else if(animateQRService == null) {
createAnimateQRService();
} else if(!animateQRService.isRunning()) {
animateQRService.reset();
animateQRService.start();
}
private void setUseBbqrEncoding(boolean useBbqrEncoding) {
if(useBbqrEncoding) {
this.useBbqrEncoding = true;
restartAnimation();
} else {
this.useBbqrEncoding = false;
restartAnimation();
}
}
@@ -240,12 +278,28 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
animateQRService.cancel();
}
this.encoder = new UREncoder(ur, Config.get().getQrDensity().getMaxFragmentLength(), MIN_FRAGMENT_LENGTH, 0);
nextPart();
if(encoder.isSinglePart()) {
if(bbqr != null) {
this.bbqrEncoder = new BBQREncoder(bbqr.type(), DEFAULT_BBQR_ENCODING, bbqr.data(), Config.get().getQrDensity().getMaxBbqrFragmentLength(), 0);
}
this.urEncoder = new UREncoder(ur, Config.get().getQrDensity().getMaxUrFragmentLength(), MIN_FRAGMENT_LENGTH, 0);
restartAnimation();
}
private void restartAnimation() {
if(isSinglePart()) {
if(animateQRService != null) {
animateQRService.cancel();
}
nextPart();
qrImageView.setImage(getQrCode(currentPart));
} else {
} else if(animateQRService == null) {
createAnimateQRService();
} else if(!animateQRService.isRunning()) {
animateQRService.reset();
animateQRService.start();
}
}
@@ -290,7 +344,7 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
ButtonBar.setButtonData(density, buttonData);
density.setOnAction(event -> {
if(!initialDensityChange && !encoder.isSinglePart()) {
if(!initialDensityChange && !isSinglePart()) {
Optional<ButtonType> optButtonType = AppServices.showWarningDialog("Discard progress?", "Changing the QR code density means any progress on the receiving device must be discarded. Proceed?", ButtonType.NO, ButtonType.YES);
if(optButtonType.isPresent() && optButtonType.get() == ButtonType.YES) {
initialDensityChange = true;
@@ -306,12 +360,25 @@ public class QRDisplayDialog extends Dialog<ButtonType> {
return density;
}
} else if(buttonType.getButtonData() == ButtonBar.ButtonData.NEXT_FORWARD) {
} else if(buttonType.getButtonData() == ButtonBar.ButtonData.OK_DONE) {
Button scanButton = (Button)super.createButton(buttonType);
scanButton.setGraphicTextGap(5);
scanButton.setGraphic(getGlyph(FontAwesome5.Glyph.CAMERA));
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);
bbqr.selectedProperty().addListener((observable, oldValue, newValue) -> {
setUseBbqrEncoding(newValue);
});
return bbqr;
}
return super.createButton(buttonType);
@@ -30,6 +30,9 @@ import com.sparrowwallet.hummingbird.registry.pathcomponent.PathComponent;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.bbqr.BBQRDecoder;
import com.sparrowwallet.sparrow.io.bbqr.BBQRException;
import com.sparrowwallet.sparrow.wallet.KeystoreController;
import javafx.application.Platform;
import javafx.beans.property.DoubleProperty;
import javafx.beans.property.ObjectProperty;
@@ -63,8 +66,9 @@ import java.util.stream.IntStream;
public class QRScanDialog extends Dialog<QRScanDialog.Result> {
private static final Logger log = LoggerFactory.getLogger(QRScanDialog.class);
private final URDecoder decoder;
private final LegacyURDecoder legacyDecoder;
private final URDecoder urDecoder;
private final LegacyURDecoder legacyUrDecoder;
private final BBQRDecoder bbqrDecoder;
private final WebcamService webcamService;
private List<String> parts;
@@ -80,8 +84,9 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
private final ObjectProperty<WebcamDevice> webcamDeviceProperty = new SimpleObjectProperty<>();
public QRScanDialog() {
this.decoder = new URDecoder();
this.legacyDecoder = new LegacyURDecoder();
this.urDecoder = new URDecoder();
this.legacyUrDecoder = new LegacyURDecoder();
this.bbqrDecoder = new BBQRDecoder();
if(Config.get().isHdCapture()) {
webcamResolutionProperty.set(WebcamResolution.HD);
@@ -167,7 +172,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
Platform.runLater(() -> webcamResolutionProperty.set(null));
});
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Close", ButtonBar.ButtonData.CANCEL_CLOSE);
final ButtonType hdButtonType = new javafx.scene.control.ButtonType("Use HD Capture", ButtonBar.ButtonData.LEFT);
final ButtonType camButtonType = new javafx.scene.control.ButtonType("Default Camera", ButtonBar.ButtonData.HELP_2);
dialogPane.getButtonTypes().addAll(hdButtonType, camButtonType, cancelButtonType);
@@ -192,23 +197,23 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
if(qrtext.toLowerCase(Locale.ROOT).startsWith(UR.UR_PREFIX)) {
if(LegacyURDecoder.isLegacyURFragment(qrtext)) {
legacyDecoder.receivePart(qrtext);
Platform.runLater(() -> percentComplete.setValue(legacyDecoder.getPercentComplete()));
legacyUrDecoder.receivePart(qrtext);
Platform.runLater(() -> percentComplete.setValue(legacyUrDecoder.getPercentComplete()));
if(legacyDecoder.isComplete()) {
if(legacyUrDecoder.isComplete()) {
try {
UR ur = legacyDecoder.decode();
UR ur = legacyUrDecoder.decode();
result = extractResultFromUR(ur);
} catch(Exception e) {
result = new Result(new URException(e.getMessage()));
}
}
} else {
decoder.receivePart(qrtext);
Platform.runLater(() -> percentComplete.setValue(decoder.getProcessedPartsCount() > 0 ? decoder.getEstimatedPercentComplete() : 0));
urDecoder.receivePart(qrtext);
Platform.runLater(() -> percentComplete.setValue(urDecoder.getProcessedPartsCount() > 0 ? urDecoder.getEstimatedPercentComplete() : 0));
if(decoder.getResult() != null) {
URDecoder.Result urResult = decoder.getResult();
if(urDecoder.getResult() != null) {
URDecoder.Result urResult = urDecoder.getResult();
if(urResult.type == ResultType.SUCCESS) {
result = extractResultFromUR(urResult.ur);
Platform.runLater(() -> setResult(result));
@@ -217,6 +222,19 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
}
}
}
} else if(BBQRDecoder.isBBQRFragment(qrtext)) {
bbqrDecoder.receivePart(qrtext);
Platform.runLater(() -> percentComplete.setValue(bbqrDecoder.getPercentComplete()));
if(bbqrDecoder.getResult() != null) {
BBQRDecoder.Result bbqrResult = bbqrDecoder.getResult();
if(bbqrResult.getResultType() == BBQRDecoder.ResultType.SUCCESS) {
result = extractResultFromBBQR(bbqrResult);
Platform.runLater(() -> setResult(result));
} else {
result = new Result(new BBQRException(bbqrResult.getError()));
}
}
} else if(partMatcher.matches()) {
int m = Integer.parseInt(partMatcher.group(1));
int n = Integer.parseInt(partMatcher.group(2));
@@ -604,7 +622,8 @@ 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());
return new KeyDerivation(Utils.bytesToHex(cryptoKeypath.getSourceFingerprint()), KeyDerivation.writePath(path));
String fingerprint = cryptoKeypath.getSourceFingerprint() == null ? KeystoreController.DEFAULT_WATCH_ONLY_FINGERPRINT : Utils.bytesToHex(cryptoKeypath.getSourceFingerprint());
return new KeyDerivation(fingerprint, KeyDerivation.writePath(path));
}
return null;
@@ -651,6 +670,19 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
return wallets;
}
private Result extractResultFromBBQR(BBQRDecoder.Result result) {
if(result.getPsbt() != null) {
return new Result(result.getPsbt());
} else if(result.getTransaction() != null) {
return new Result(result.getTransaction());
} else if(result.toString() != null) {
return new Result(result.toString());
} else {
log.error("Unsupported BBQR type " + result.getBbqrType());
return new Result(new URException("BBQR type " + result.getBbqrType() + " is not supported"));
}
}
}
private class QRScanListener implements WebcamListener {
@@ -54,10 +54,12 @@ public class ScriptArea extends CodeArea {
ScriptChunk chunk = script.getChunks().get(i);
if(chunk.isOpCode()) {
append(chunk.toString(), "script-opcode");
} else if(chunk.isSignature()) {
append("<signature" + signatureCount++ + ">", "script-signature");
} else if(chunk.isPubKey()) {
append("<pubkey" + pubKeyCount++ + ">", "script-pubkey");
} else if(chunk.isSignature()) {
append("<signature" + signatureCount++ + ">", "script-signature");
} else if(chunk.isTaprootControlBlock()) {
append("<controlblock>", "script-controlblock");
} else if(chunk.isString()) {
append(chunk.toString(), "script-other");
} else if(chunk.isScript()) {
@@ -2,25 +2,22 @@ package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.protocol.Script;
import com.sparrowwallet.drongo.protocol.ScriptChunk;
import com.sparrowwallet.sparrow.BaseController;
import javafx.geometry.Point2D;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.MenuItem;
import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent;
import javafx.scene.input.ContextMenuEvent;
import org.fxmisc.richtext.CodeArea;
import org.fxmisc.richtext.model.TwoDimensional;
import java.util.OptionalInt;
import static org.fxmisc.richtext.model.TwoDimensional.Bias.Backward;
public class ScriptContextMenu extends ContextMenu {
private Script script;
private MenuItem copyvalue;
private ScriptChunk hoverChunk;
public ScriptContextMenu(CodeArea area, Script script)
public ScriptContextMenu(ScriptArea area, Script script)
{
this.script = script;
@@ -40,12 +37,9 @@ public class ScriptContextMenu extends ContextMenu {
Point2D point = area.screenToLocal(event.getScreenX(), event.getScreenY());
OptionalInt characterIndex = area.hit(point.getX(), point.getY()).getCharacterIndex();
if(characterIndex.isPresent()) {
TwoDimensional.Position position = area.getParagraph(0).getStyleSpans().offsetToPosition(characterIndex.getAsInt(), Backward);
if(position.getMajor() % 2 == 0) {
ScriptChunk chunk = script.getChunks().get(position.getMajor() / 2);
if(!chunk.isOpCode()) {
this.hoverChunk = chunk;
}
ScriptChunk chunk = BaseController.getScriptChunk(area, characterIndex.getAsInt());
if(chunk != null) {
this.hoverChunk = chunk;
}
}
copyvalue.setDisable(hoverChunk == null);
@@ -1,18 +1,27 @@
package com.sparrowwallet.sparrow.control;
import com.csvreader.CsvWriter;
import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.address.InvalidAddressException;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.glyphfont.GlyphUtils;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.wallet.*;
import javafx.application.Platform;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.collections.ListChangeListener;
import javafx.event.ActionEvent;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.VBox;
import javafx.stage.FileChooser;
import javafx.stage.Stage;
import org.controlsfx.control.textfield.TextFields;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -20,10 +29,11 @@ import tornadofx.control.Field;
import tornadofx.control.Fieldset;
import tornadofx.control.Form;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.*;
public class SearchWalletDialog extends Dialog<Entry> {
private static final Logger log = LoggerFactory.getLogger(SearchWalletDialog.class);
@@ -48,7 +58,7 @@ public class SearchWalletDialog extends Dialog<Entry> {
dialogPane.getStylesheets().add(AppServices.class.getResource("wallet/wallet.css").toExternalForm());
dialogPane.getStylesheets().add(AppServices.class.getResource("search.css").toExternalForm());
AppServices.setStageIcon(dialogPane.getScene().getWindow());
dialogPane.setHeaderText(showWallet ? "Search All Wallets" : "Search Wallet");
dialogPane.setHeaderText(showWallet ? "Search All Wallets" : "Search Wallet " + walletForms.get(0).getMasterWallet().getName());
Image image = new Image("image/sparrow-small.png", 50, 50, false, false);
if(!image.isError()) {
@@ -81,6 +91,7 @@ public class SearchWalletDialog extends Dialog<Entry> {
results.setUnitFormat(walletForms.iterator().next().getWallet());
results.setColumnResizePolicy(TreeTableView.CONSTRAINED_RESIZE_POLICY);
results.setPlaceholder(new Label("No results"));
results.setEditable(true);
if(showWallet) {
TreeTableColumn<Entry, String> walletColumn = new TreeTableColumn<>("Wallet");
@@ -117,7 +128,7 @@ public class SearchWalletDialog extends Dialog<Entry> {
labelCol.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, String> param) -> {
return param.getValue().getValue().labelProperty();
});
labelCol.setCellFactory(p -> new SearchLabelCell());
labelCol.setCellFactory(p -> new LabelCell());
results.getColumns().add(labelCol);
TreeTableColumn<Entry, Number> amountCol = new TreeTableColumn<>("Value");
@@ -130,12 +141,20 @@ public class SearchWalletDialog extends Dialog<Entry> {
vBox.getChildren().addAll(form, results);
dialogPane.setContent(vBox);
ButtonType exportButtonType = new ButtonType("Export CSV", ButtonBar.ButtonData.LEFT);
ButtonType showButtonType = new javafx.scene.control.ButtonType("Show", ButtonBar.ButtonData.APPLY);
ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
dialogPane.getButtonTypes().addAll(cancelButtonType, showButtonType);
dialogPane.getButtonTypes().addAll(exportButtonType, cancelButtonType, showButtonType);
Button showButton = (Button) dialogPane.lookupButton(showButtonType);
Button exportButton = (Button)dialogPane.lookupButton(exportButtonType);
exportButton.setGraphic(GlyphUtils.getDownArrowGlyph());
exportButton.addEventFilter(ActionEvent.ACTION, event -> {
event.consume();
exportResults(showWallet);
});
Button showButton = (Button)dialogPane.lookupButton(showButtonType);
showButton.setDefaultButton(true);
showButton.setDisable(true);
@@ -147,7 +166,7 @@ public class SearchWalletDialog extends Dialog<Entry> {
});
search.textProperty().addListener((observable, oldValue, newValue) -> {
searchWallets(newValue.toLowerCase(Locale.ROOT));
searchWallets(newValue);
});
setResizable(true);
@@ -157,42 +176,41 @@ public class SearchWalletDialog extends Dialog<Entry> {
Platform.runLater(search::requestFocus);
}
private void searchWallets(String searchText) {
List<Entry> matchingEntries = new ArrayList<>();
public List<WalletForm> getWalletForms() {
return walletForms;
}
if(!searchText.isEmpty()) {
Long searchValue = getSearchValue(searchText);
Address searchAddress = getSearchAddress(searchText);
private void searchWallets(String searchPhrase) {
Set<Entry> matchingEntries = new LinkedHashSet<>();
for(WalletForm walletForm : walletForms) {
WalletTransactionsEntry walletTransactionsEntry = walletForm.getWalletTransactionsEntry();
for(Entry entry : walletTransactionsEntry.getChildren()) {
if(entry instanceof TransactionEntry transactionEntry) {
if(transactionEntry.getBlockTransaction().getHash().toString().equals(searchText) ||
(transactionEntry.getLabel() != null && transactionEntry.getLabel().toLowerCase(Locale.ROOT).contains(searchText)) ||
(transactionEntry.getValue() != null && searchValue != null && Math.abs(transactionEntry.getValue()) == searchValue) ||
(searchAddress != null && transactionEntry.getBlockTransaction().getTransaction().getOutputs().stream().map(output -> output.getScript().getToAddress()).filter(Objects::nonNull).anyMatch(address -> address.equals(searchAddress)))) {
matchingEntries.add(entry);
}
}
}
if(!searchPhrase.isEmpty()) {
Set<String> searchWords = new LinkedHashSet<>(Arrays.stream(searchPhrase.split("\\s+"))
.filter(text -> isAddress(text) || isHash(text) || isHashIndex(text)).toList());
String freeText = removeOccurrences(searchPhrase, searchWords).trim();
if(!freeText.isEmpty()) {
searchWords.add(freeText);
}
for(KeyPurpose keyPurpose : KeyPurpose.DEFAULT_PURPOSES) {
NodeEntry purposeEntry = walletForm.getNodeEntry(keyPurpose);
for(Entry entry : purposeEntry.getChildren()) {
if(entry instanceof NodeEntry nodeEntry) {
if(nodeEntry.getAddress().toString().toLowerCase(Locale.ROOT).contains(searchText) ||
(nodeEntry.getLabel() != null && nodeEntry.getLabel().toLowerCase(Locale.ROOT).contains(searchText)) ||
(nodeEntry.getValue() != null && searchValue != null && Math.abs(nodeEntry.getValue()) == searchValue)) {
for(String searchText : searchWords) {
Long searchValue = getSearchValue(searchText);
Address searchAddress = getSearchAddress(searchText);
searchText = searchText.toLowerCase(Locale.ROOT);
for(WalletForm walletForm : walletForms) {
WalletTransactionsEntry walletTransactionsEntry = walletForm.getWalletTransactionsEntry();
for(Entry entry : walletTransactionsEntry.getChildren()) {
if(entry instanceof TransactionEntry transactionEntry) {
if(transactionEntry.getBlockTransaction().getHash().toString().equals(searchText) ||
(transactionEntry.getLabel() != null && transactionEntry.getLabel().toLowerCase(Locale.ROOT).contains(searchText)) ||
(transactionEntry.getValue() != null && searchValue != null && Math.abs(transactionEntry.getValue()) == searchValue) ||
(searchAddress != null && transactionEntry.getBlockTransaction().getTransaction().getOutputs().stream().map(output -> output.getScript().getToAddress()).filter(Objects::nonNull).anyMatch(address -> address.equals(searchAddress)))) {
matchingEntries.add(entry);
}
}
}
}
for(WalletForm nestedWalletForm : walletForm.getNestedWalletForms()) {
for(KeyPurpose keyPurpose : nestedWalletForm.getWallet().getWalletKeyPurposes()) {
NodeEntry purposeEntry = nestedWalletForm.getNodeEntry(keyPurpose);
for(KeyPurpose keyPurpose : KeyPurpose.DEFAULT_PURPOSES) {
NodeEntry purposeEntry = walletForm.getNodeEntry(keyPurpose);
for(Entry entry : purposeEntry.getChildren()) {
if(entry instanceof NodeEntry nodeEntry) {
if(nodeEntry.getAddress().toString().toLowerCase(Locale.ROOT).contains(searchText) ||
@@ -203,22 +221,38 @@ public class SearchWalletDialog extends Dialog<Entry> {
}
}
}
}
WalletUtxosEntry walletUtxosEntry = walletForm.getWalletUtxosEntry();
for(Entry entry : walletUtxosEntry.getChildren()) {
if(entry instanceof HashIndexEntry hashIndexEntry) {
if(hashIndexEntry.getBlockTransaction().getHash().toString().toLowerCase(Locale.ROOT).equals(searchText) ||
(hashIndexEntry.getLabel() != null && hashIndexEntry.getLabel().toLowerCase(Locale.ROOT).contains(searchText)) ||
(hashIndexEntry.getValue() != null && searchValue != null && Math.abs(hashIndexEntry.getValue()) == searchValue)) {
matchingEntries.add(entry);
for(WalletForm nestedWalletForm : walletForm.getNestedWalletForms()) {
for(KeyPurpose keyPurpose : nestedWalletForm.getWallet().getWalletKeyPurposes()) {
NodeEntry purposeEntry = nestedWalletForm.getNodeEntry(keyPurpose);
for(Entry entry : purposeEntry.getChildren()) {
if(entry instanceof NodeEntry nodeEntry) {
if(nodeEntry.getAddress().toString().toLowerCase(Locale.ROOT).contains(searchText) ||
(nodeEntry.getLabel() != null && nodeEntry.getLabel().toLowerCase(Locale.ROOT).contains(searchText)) ||
(nodeEntry.getValue() != null && searchValue != null && Math.abs(nodeEntry.getValue()) == searchValue)) {
matchingEntries.add(entry);
}
}
}
}
}
WalletUtxosEntry walletUtxosEntry = walletForm.getWalletUtxosEntry();
for(Entry entry : walletUtxosEntry.getChildren()) {
if(entry instanceof HashIndexEntry hashIndexEntry) {
if(hashIndexEntry.getBlockTransaction().getHash().toString().toLowerCase(Locale.ROOT).equals(searchText) ||
hashIndexEntry.getHashIndex().toString().toLowerCase(Locale.ROOT).equals(searchText) ||
(hashIndexEntry.getLabel() != null && hashIndexEntry.getLabel().toLowerCase(Locale.ROOT).contains(searchText)) ||
(hashIndexEntry.getValue() != null && searchValue != null && Math.abs(hashIndexEntry.getValue()) == searchValue)) {
matchingEntries.add(entry);
}
}
}
}
}
}
SearchWalletEntry rootEntry = new SearchWalletEntry(walletForms.iterator().next().getWallet(), matchingEntries);
SearchWalletEntry rootEntry = new SearchWalletEntry(walletForms.iterator().next().getWallet(), new ArrayList<>(matchingEntries));
RecursiveTreeItem<Entry> rootItem = new RecursiveTreeItem<>(rootEntry, Entry::getChildren);
results.setRoot(rootItem);
}
@@ -239,6 +273,95 @@ public class SearchWalletDialog extends Dialog<Entry> {
}
}
private boolean isAddress(String text) {
try {
Address.fromString(text);
return true;
} catch(InvalidAddressException e) {
return false;
}
}
private boolean isHash(String text) {
return text.length() == 64 && Utils.isHex(text);
}
private boolean isHashIndex(String text) {
String[] parts = text.split(":");
if(parts.length == 2 && isHash(parts[0])) {
try {
Integer.parseInt(parts[1]);
return true;
} catch(NumberFormatException e) {
//ignore
}
}
return false;
}
private String removeOccurrences(String inputString, Collection<String> stringsToRemove) {
for(String str : stringsToRemove) {
inputString = inputString.replaceAll("(?i)" + str, "");
}
return inputString;
}
public void exportResults(boolean showWallet) {
Stage window = new Stage();
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("Export search results to CSV");
fileChooser.setInitialFileName(getDialogPane().getHeaderText() + ".csv");
AppServices.moveToActiveWindowScreen(window, 800, 450);
File file = fileChooser.showSaveDialog(window);
if(file != null) {
try(FileOutputStream outputStream = new FileOutputStream(file)) {
CsvWriter writer = new CsvWriter(outputStream, ',', StandardCharsets.UTF_8);
List<String> headers = new ArrayList<>(List.of("Wallet", "Account", "Type", "Date", "Txid / Address / Output", "Label", "Value"));
if(!showWallet) {
headers.remove(0);
}
writer.writeRecord(headers.toArray(new String[0]));
for(TreeItem<Entry> item : results.getRoot().getChildren()) {
Entry entry = item.getValue();
if(showWallet) {
writer.write(entry.getWallet().getMasterName());
}
writer.write(entry.getWallet().getDisplayName());
writer.write(entry.getEntryType());
if(entry instanceof TransactionEntry transactionEntry) {
writer.write(transactionEntry.getBlockTransaction().getDate() == null ? "Unconfirmed" : EntryCell.DATE_FORMAT.format(transactionEntry.getBlockTransaction().getDate()));
writer.write(transactionEntry.getBlockTransaction().getHash().toString());
} else if(entry instanceof NodeEntry nodeEntry) {
writer.write("");
writer.write(nodeEntry.getAddress().toString());
} else if(entry instanceof HashIndexEntry hashIndexEntry) {
writer.write(hashIndexEntry.getBlockTransaction().getDate() == null ? "Unconfirmed" : EntryCell.DATE_FORMAT.format(hashIndexEntry.getBlockTransaction().getDate()));
writer.write(hashIndexEntry.getHashIndex().toString());
} else {
writer.write("");
writer.write("");
}
writer.write(entry.getLabel());
writer.write(getCoinValue(entry.getValue() == null ? 0 : entry.getValue()));
writer.endRecord();
}
writer.close();
} catch(IOException e) {
log.error("Error exporting search results as CSV", e);
AppServices.showErrorDialog("Error exporting search results as CSV", e.getMessage());
}
}
}
private String getCoinValue(Long value) {
UnitFormat format = Config.get().getUnitFormat() == null ? UnitFormat.DOT : Config.get().getUnitFormat();
return BitcoinUnit.BTC.equals(results.getBitcoinUnit()) ? format.tableFormatBtcValue(value) : String.format(Locale.ENGLISH, "%d", value);
}
private static class SearchWalletEntry extends Entry {
public SearchWalletEntry(Wallet wallet, List<Entry> entries) {
super(wallet, wallet.getName(), entries);
@@ -264,15 +387,19 @@ public class SearchWalletDialog extends Dialog<Entry> {
@Override
protected void updateItem(Entry entry, boolean empty) {
super.updateItem(entry, empty);
setContextMenu(null);
}
}
private static class SearchLabelCell extends LabelCell {
@Override
public void updateItem(String label, boolean empty) {
super.updateItem(label, empty);
setContextMenu(null);
ContextMenu copyMenu;
if(entry instanceof TransactionEntry transactionEntry) {
copyMenu = new TransactionContextMenu(getText(), transactionEntry.getBlockTransaction());
} else if(entry instanceof NodeEntry nodeEntry) {
copyMenu = new AddressContextMenu(nodeEntry.getAddress(), nodeEntry.getOutputDescriptor(), null, false, null);
} else if(entry instanceof UtxoEntry utxoEntry) {
copyMenu = new HashIndexEntryContextMenu(null, utxoEntry);
} else {
copyMenu = new ContextMenu();
}
copyMenu.getItems().removeIf(menuItem -> !menuItem.getText().startsWith("Copy"));
setContextMenu(copyMenu);
}
}
}
@@ -1,12 +1,16 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.SeedQR;
import com.sparrowwallet.sparrow.AppServices;
import javafx.application.Platform;
import javafx.event.ActionEvent;
import javafx.scene.control.*;
import javafx.scene.layout.AnchorPane;
import javafx.scene.layout.StackPane;
import java.util.Optional;
public class SeedDisplayDialog extends Dialog<Void> {
public SeedDisplayDialog(Keystore decryptedKeystore) {
final DialogPane dialogPane = getDialogPane();
@@ -39,8 +43,15 @@ public class SeedDisplayDialog extends Dialog<Void> {
stackPane.getChildren().addAll(anchorPane);
final ButtonType seedQRButtonType = new javafx.scene.control.ButtonType("Show SeedQR", ButtonBar.ButtonData.LEFT);
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
dialogPane.getButtonTypes().addAll(cancelButtonType);
dialogPane.getButtonTypes().addAll(seedQRButtonType, cancelButtonType);
Button seedQRButton = (Button)dialogPane.lookupButton(seedQRButtonType);
seedQRButton.addEventFilter(ActionEvent.ACTION, event -> {
event.consume();
showSeedQR(decryptedKeystore);
});
dialogPane.setPrefWidth(500);
dialogPane.setPrefHeight(150 + height);
@@ -48,4 +59,15 @@ public class SeedDisplayDialog extends Dialog<Void> {
Platform.runLater(() -> keystoreAccordion.setExpandedPane(keystorePane));
}
private void showSeedQR(Keystore decryptedKeystore) {
Optional<ButtonType> optButtonType = AppServices.showWarningDialog("Sensitive QR", "The following QR contains these seed words. " +
"Be careful before displaying or digitally recording it.\n\nAre you sure you want to continue?", ButtonType.YES, ButtonType.NO);
if(optButtonType.isPresent() && optButtonType.get() == ButtonType.YES) {
String seedQR = SeedQR.getSeedQR(decryptedKeystore.getSeed());
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(seedQR);
qrDisplayDialog.initOwner(getDialogPane().getScene().getWindow());
qrDisplayDialog.showAndWait();
}
}
}
@@ -0,0 +1,34 @@
package com.sparrowwallet.sparrow.control;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.MenuItem;
import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent;
import org.fxmisc.richtext.CodeArea;
public class SelectableCodeArea extends CodeArea {
public SelectableCodeArea() {
super();
ContextMenu contextMenu = new ContextMenu();
MenuItem copy = new MenuItem("Copy");
copy.setDisable(true);
copy.setOnAction(event -> {
ClipboardContent content = new ClipboardContent();
content.putString(getSelectedText());
Clipboard.getSystemClipboard().setContent(content);
});
MenuItem copyAll = new MenuItem("Copy All");
copyAll.setOnAction(event -> {
ClipboardContent content = new ClipboardContent();
content.putString(getText());
Clipboard.getSystemClipboard().setContent(content);
});
contextMenu.getItems().addAll(copy, copyAll);
setContextMenu(contextMenu);
selectedTextProperty().addListener((observable, oldValue, newValue) -> {
copy.setDisable(newValue.isEmpty());
});
}
}
@@ -12,11 +12,9 @@ import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.Theme;
import com.sparrowwallet.sparrow.event.ExcludeUtxoEvent;
import com.sparrowwallet.sparrow.event.ReplaceChangeAddressEvent;
import com.sparrowwallet.sparrow.event.SorobanInitiatedEvent;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.glyphfont.GlyphUtils;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.soroban.SorobanServices;
import com.sparrowwallet.sparrow.wallet.OptimizationStrategy;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
@@ -91,7 +89,7 @@ public class TransactionDiagram extends GridPane {
stage.setResizable(false);
StackPane scenePane = new StackPane();
if(Platform.getCurrent() == Platform.WINDOWS) {
if(Platform.getCurrent() == Platform.WINDOWS || Platform.getCurrent() == Platform.UNIX) {
scenePane.setBorder(new Border(new BorderStroke(Color.DARKGRAY, BorderStrokeStyle.SOLID, CornerRadii.EMPTY, BorderWidths.DEFAULT)));
}
@@ -246,19 +244,9 @@ public class TransactionDiagram extends GridPane {
}
private List<Map<BlockTransactionHashIndex, WalletNode>> getDisplayedUtxoSets() {
boolean addUserSet = getOptimizationStrategy() == OptimizationStrategy.PRIVACY && SorobanServices.canWalletMix(walletTx.getWallet())
&& walletTx.getPayments().size() == 1
&& (walletTx.getPayments().get(0).getAddress().getScriptType() == walletTx.getWallet().getFreshNode(KeyPurpose.RECEIVE).getAddress().getScriptType());
List<Map<BlockTransactionHashIndex, WalletNode>> displayedUtxoSets = new ArrayList<>();
for(Map<BlockTransactionHashIndex, WalletNode> selectedUtxoSet : walletTx.getSelectedUtxoSets()) {
displayedUtxoSets.add(getDisplayedUtxos(selectedUtxoSet, addUserSet ? 2 : walletTx.getSelectedUtxoSets().size()));
}
if(addUserSet && displayedUtxoSets.size() == 1) {
Map<BlockTransactionHashIndex, WalletNode> addUserUtxoSet = new HashMap<>();
addUserUtxoSet.put(new AddUserBlockTransactionHashIndex(!walletTx.isTwoPersonCoinjoin()), null);
displayedUtxoSets.add(addUserUtxoSet);
displayedUtxoSets.add(getDisplayedUtxos(selectedUtxoSet, walletTx.getSelectedUtxoSets().size()));
}
List<Map<BlockTransactionHashIndex, WalletNode>> paddedUtxoSets = new ArrayList<>();
@@ -339,11 +327,9 @@ public class TransactionDiagram extends GridPane {
double setHeight = (height / numSets) - 5;
for(int set = 0; set < numSets; set++) {
boolean externalUserSet = displayedUtxoSets.get(set).values().stream().anyMatch(Objects::nonNull);
boolean addUserSet = displayedUtxoSets.get(set).keySet().stream().anyMatch(ref -> ref instanceof AddUserBlockTransactionHashIndex);
if(externalUserSet || addUserSet) {
boolean replace = !isFinal() && set > 0 && SorobanServices.canWalletMix(walletTx.getWallet());
Glyph bracketGlyph = !replace && walletTx.isCoinControlUsed() ? getLockGlyph() : (addUserSet ? getUserAddGlyph() : getCoinsGlyph(replace));
String tooltipText = addUserSet ? "Click to add a mix partner" : (walletTx.getWallet().getFullDisplayName() + (replace ? "\nClick to replace with a mix partner" : ""));
if(externalUserSet) {
Glyph bracketGlyph = walletTx.isCoinControlUsed() ? getLockGlyph() : getCoinsGlyph();
String tooltipText = walletTx.getWallet().getFullDisplayName();
StackPane stackPane = getBracket(width, setHeight, bracketGlyph, tooltipText);
allBrackets.getChildren().add(stackPane);
} else {
@@ -474,14 +460,6 @@ public class TransactionDiagram extends GridPane {
tooltip.setText(joiner.toString());
} else if(input instanceof InvisibleBlockTransactionHashIndex) {
tooltip.setText("");
} else if(input instanceof AddUserBlockTransactionHashIndex) {
tooltip.setText("");
label.setGraphic(walletTx.isTwoPersonCoinjoin() ? getQuestionGlyph() : getWarningGlyph());
label.setOnMouseClicked(event -> {
EventManager.get().post(new SorobanInitiatedEvent(walletTx.getWallet()));
closeExpanded();
event.consume();
});
} else {
if(walletTx.getInputTransactions() != null && walletTx.getInputTransactions().get(input.getHash()) != null) {
BlockTransaction blockTransaction = walletTx.getInputTransactions().get(input.getHash());
@@ -570,7 +548,7 @@ public class TransactionDiagram extends GridPane {
CubicCurve curve = new CubicCurve();
curve.getStyleClass().add("input-line");
if(inputs.get(numUtxos-i) instanceof PayjoinBlockTransactionHashIndex || inputs.get(numUtxos-i) instanceof AddUserBlockTransactionHashIndex) {
if(inputs.get(numUtxos-i) instanceof PayjoinBlockTransactionHashIndex) {
curve.getStyleClass().add("input-dashed-line");
} else if(inputs.get(numUtxos-i) instanceof InvisibleBlockTransactionHashIndex) {
continue;
@@ -789,7 +767,7 @@ public class TransactionDiagram extends GridPane {
}
boolean highFee = (walletTx.getFeePercentage() > 0.1);
Label feeLabel = highFee ? new Label("High Fee", getWarningGlyph()) : new Label("Fee", getFeeGlyph());
Label feeLabel = highFee ? new Label("High Fee", getFeeWarningGlyph()) : new Label("Fee", getFeeGlyph());
feeLabel.getStyleClass().addAll("output-label", "fee-label");
String percentage = String.format("%.2f", walletTx.getFeePercentage() * 100.0);
Tooltip feeTooltip = new Tooltip(walletTx.getFee() < 0 ? "Unknown fee" : "Fee of " + getSatsValue(walletTx.getFee()) + " sats (" + percentage + "%)");
@@ -952,46 +930,10 @@ public class TransactionDiagram extends GridPane {
return null;
}
private Glyph getUserAddGlyph() {
Glyph userAddGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.USER_PLUS);
userAddGlyph.getStyleClass().add("useradd-icon");
userAddGlyph.setFontSize(12);
userAddGlyph.setOnMouseEntered(event -> {
userAddGlyph.setFontSize(18);
});
userAddGlyph.setOnMouseExited(event -> {
userAddGlyph.setFontSize(12);
});
userAddGlyph.setOnMouseClicked(event -> {
EventManager.get().post(new SorobanInitiatedEvent(walletTx.getWallet()));
closeExpanded();
event.consume();
});
return userAddGlyph;
}
private Glyph getCoinsGlyph(boolean allowReplacement) {
private Glyph getCoinsGlyph() {
Glyph coinsGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.COINS);
coinsGlyph.setFontSize(12);
if(allowReplacement) {
coinsGlyph.getStyleClass().add("coins-replace-icon");
coinsGlyph.setOnMouseEntered(event -> {
coinsGlyph.setIcon(FontAwesome5.Glyph.USER_PLUS);
coinsGlyph.setFontSize(18);
});
coinsGlyph.setOnMouseExited(event -> {
coinsGlyph.setIcon(FontAwesome5.Glyph.COINS);
coinsGlyph.setFontSize(12);
});
coinsGlyph.setOnMouseClicked(event -> {
EventManager.get().post(new SorobanInitiatedEvent(walletTx.getWallet()));
closeExpanded();
event.consume();
});
} else {
coinsGlyph.getStyleClass().add("coins-icon");
}
coinsGlyph.getStyleClass().add("coins-icon");
return coinsGlyph;
}
@@ -1094,20 +1036,6 @@ public class TransactionDiagram extends GridPane {
}
}
private static class AddUserBlockTransactionHashIndex extends BlockTransactionHashIndex {
private final boolean required;
public AddUserBlockTransactionHashIndex(boolean required) {
super(Sha256Hash.ZERO_HASH, 0, new Date(), 0L, 0, 0);
this.required = required;
}
@Override
public String getLabel() {
return "Add Mix Partner" + (required ? "" : "?");
}
}
public static class AdditionalPayment extends Payment {
private final List<Payment> additionalPayments;
@@ -27,7 +27,8 @@ public class UsbStatusButton extends MenuButton {
public void setDevices(List<Device> devices) {
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.KEEPKEY || device.getModel() == WalletModel.BITBOX_02)) {
if(!device.isNeedsPinSent() && (device.getModel() == WalletModel.TREZOR_1 || device.getModel() == WalletModel.TREZOR_T ||
device.getModel() == WalletModel.TREZOR_SAFE_3 || device.getModel() == WalletModel.KEEPKEY || device.getModel() == WalletModel.BITBOX_02)) {
deviceItem = new Menu(device.getModel().toDisplayString());
MenuItem toggleItem = new MenuItem("Toggle Passphrase" + (!device.getModel().externalPassphraseEntry() ? "" : (device.isNeedsPassphraseSent() ? " Off" : " On")));
toggleItem.setOnAction(event -> {
@@ -19,9 +19,10 @@ import javafx.scene.shape.Circle;
import org.controlsfx.glyphfont.Glyph;
import org.girod.javafx.svgimage.SVGImage;
import org.girod.javafx.svgimage.SVGLoader;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.*;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLConnection;
import java.net.URLStreamHandler;
@@ -137,7 +138,7 @@ public class WalletIcon extends StackPane {
}
private void addWalletIcon(String walletId) {
Image image = new Image(PROTOCOL + ":" + walletId + "?" + QUERY, WIDTH, HEIGHT, true, false);
Image image = new Image(PROTOCOL + ":" + walletId.replaceAll(" ", "%20").replaceAll("#", "%23") + "?" + QUERY, WIDTH, HEIGHT, true, false);
getChildren().clear();
Circle circle = new Circle(getPrefWidth() / 2,getPrefHeight() / 2,getPrefWidth() / 2);
circle.setFill(new ImagePattern(image));
@@ -176,6 +177,8 @@ public class WalletIcon extends StackPane {
}
public static class WalletIconStreamHandler extends URLStreamHandler {
private static final Logger log = LoggerFactory.getLogger(WalletIconStreamHandler.class);
@Override
protected URLConnection openConnection(URL url) throws IOException {
return new URLConnection(url) {
@@ -186,17 +189,19 @@ public class WalletIcon extends StackPane {
@Override
public InputStream getInputStream() throws IOException {
String walletId = url.getPath();
String walletId = url.getPath().replaceAll("%20", " ").replaceAll("%23", "#");
String query = url.getQuery();
Wallet wallet = AppServices.get().getWallet(walletId);
if(wallet == null) {
throw new IOException("Cannot find wallet for wallet id " + walletId);
log.warn("Cannot find wallet for wallet id " + walletId);
return getFallbackIconStream();
}
if(query.startsWith(QUERY)) {
if(wallet.getWalletConfig() == null || wallet.getWalletConfig().getIconData() == null) {
throw new IOException("No icon data for " + walletId);
log.warn("No icon data for " + walletId);
return getFallbackIconStream();
}
ByteArrayInputStream bais = new ByteArrayInputStream(wallet.getWalletConfig().getIconData());
@@ -207,7 +212,12 @@ public class WalletIcon extends StackPane {
}
}
throw new MalformedURLException("Cannot load url " + url);
log.warn("Cannot load url " + url);
return getFallbackIconStream();
}
private static InputStream getFallbackIconStream() {
return WalletIconStreamHandler.class.getResourceAsStream("/image/sparrow.png");
}
};
}
@@ -51,7 +51,8 @@ public class WalletImportDialog extends Dialog<Wallet> {
AnchorPane.setRightAnchor(scrollPane, 0.0);
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());
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());
for(KeystoreFileImport importer : keystoreImporters) {
if(!importer.isDeprecated() || Config.get().isShowDeprecatedImportExport()) {
FileWalletKeystoreImportPane importPane = new FileWalletKeystoreImportPane(importer);
@@ -59,7 +60,8 @@ public class WalletImportDialog extends Dialog<Wallet> {
}
}
List<WalletImport> walletImporters = new ArrayList<>(List.of(new Bip129(), new CaravanMultisig(), new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new KeystoneMultisig(), new Descriptor(), new SpecterDesktop(), new BlueWalletMultisig(), new Sparrow(), new JadeMultisig()));
List<WalletImport> walletImporters = new ArrayList<>(List.of(new Bip129(), new CaravanMultisig(), new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(),
new KeystoneMultisig(), new Descriptor(), new SpecterDesktop(), new BlueWalletMultisig(), new Sparrow(), new JadeMultisig()));
if(!selectedWalletForms.isEmpty()) {
walletImporters.add(new WalletLabels(selectedWalletForms));
}
@@ -18,17 +18,21 @@ import javafx.scene.image.ImageView;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
public class WalletSummaryDialog extends Dialog<Void> {
public WalletSummaryDialog(List<WalletForm> walletForms) {
if(walletForms.isEmpty()) {
public WalletSummaryDialog(List<List<WalletForm>> summaryWalletFormsList) {
List<List<WalletForm>> walletFormsList = new ArrayList<>(summaryWalletFormsList);
walletFormsList.removeIf(List::isEmpty);
if(walletFormsList.isEmpty()) {
throw new IllegalArgumentException("No wallets selected to summarize");
}
Wallet masterWallet = walletForms.get(0).getMasterWallet();
boolean allOpenWallets = walletFormsList.size() > 1;
List<Wallet> masterWallets = walletFormsList.stream().map(walletForms -> walletForms.get(0).getMasterWallet()).toList();
final DialogPane dialogPane = getDialogPane();
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
@@ -37,7 +41,7 @@ public class WalletSummaryDialog extends Dialog<Void> {
dialogPane.getStylesheets().add(AppServices.class.getResource("wallet/transactions.css").toExternalForm());
AppServices.setStageIcon(dialogPane.getScene().getWindow());
dialogPane.setHeaderText("Wallet Summary for " + masterWallet.getName());
dialogPane.setHeaderText("Wallet Summary for " + (allOpenWallets ? "All Open Wallets" : masterWallets.get(0).getName()));
Image image = new Image("image/sparrow-small.png", 50, 50, false, false);
if(!image.isError()) {
@@ -64,7 +68,7 @@ public class WalletSummaryDialog extends Dialog<Void> {
});
balanceColumn.setCellFactory(p -> new CoinCell());
table.getColumns().add(balanceColumn);
table.setUnitFormat(masterWallet);
table.setUnitFormat(masterWallets.get(0));
CurrencyRate currencyRate = AppServices.getFiatCurrencyExchangeRate();
if(currencyRate != null && currencyRate.isAvailable() && Config.get().getExchangeSource() != ExchangeSource.NONE) {
@@ -77,12 +81,20 @@ public class WalletSummaryDialog extends Dialog<Void> {
table.setCurrencyRate(currencyRate);
}
SummaryEntry rootEntry = new SummaryEntry(walletForms);
Entry rootEntry = allOpenWallets ? new AllSummaryEntry(walletFormsList) : new SummaryEntry(walletFormsList.get(0));
TreeItem<Entry> rootItem = new TreeItem<>(rootEntry);
for(Entry childEntry : rootEntry.getChildren()) {
TreeItem<Entry> childItem = new TreeItem<>(childEntry);
rootItem.getChildren().add(childItem);
childItem.getChildren().add(new TreeItem<>(new UnconfirmedEntry((WalletTransactionsEntry)childEntry)));
if(allOpenWallets) {
for(Entry walletEntry : childEntry.getChildren()) {
TreeItem<Entry> walletItem = new TreeItem<>(walletEntry);
childItem.getChildren().add(walletItem);
walletItem.getChildren().add(new TreeItem<>(new UnconfirmedEntry((WalletTransactionsEntry)walletEntry)));
}
} else {
childItem.getChildren().add(new TreeItem<>(new UnconfirmedEntry((WalletTransactionsEntry)childEntry)));
}
}
table.setShowRoot(true);
@@ -97,6 +109,14 @@ public class WalletSummaryDialog extends Dialog<Void> {
hBox.getChildren().add(vBox);
Wallet balanceWallet;
if(allOpenWallets) {
balanceWallet = new Wallet();
balanceWallet.getChildWallets().addAll(masterWallets.stream().flatMap(mws -> mws.getAllWallets().stream()).toList());
} else {
balanceWallet = masterWallets.get(0);
}
NumberAxis xAxis = new NumberAxis();
xAxis.setSide(Side.BOTTOM);
xAxis.setForceZeroInRange(false);
@@ -104,7 +124,7 @@ public class WalletSummaryDialog extends Dialog<Void> {
NumberAxis yAxis = new NumberAxis();
yAxis.setSide(Side.LEFT);
BalanceChart balanceChart = new BalanceChart(xAxis, yAxis);
balanceChart.initialize(new WalletTransactionsEntry(masterWallet, true));
balanceChart.initialize(new WalletTransactionsEntry(balanceWallet, true));
balanceChart.setAnimated(false);
balanceChart.setLegendVisible(false);
balanceChart.setVerticalGridLinesVisible(false);
@@ -120,6 +140,32 @@ public class WalletSummaryDialog extends Dialog<Void> {
AppServices.moveToActiveWindowScreen(this);
}
public static class AllSummaryEntry extends Entry {
private AllSummaryEntry(List<List<WalletForm>> walletFormsList) {
super(null, "All Wallets", walletFormsList.stream().map(SummaryEntry::new).collect(Collectors.toList()));
}
@Override
public Long getValue() {
long value = 0;
for(Entry entry : getChildren()) {
value += entry.getValue();
}
return value;
}
@Override
public String getEntryType() {
return null;
}
@Override
public Function getWalletFunction() {
return Function.TRANSACTIONS;
}
}
public static class SummaryEntry extends Entry {
private SummaryEntry(List<WalletForm> walletForms) {
super(walletForms.get(0).getWallet(), walletForms.get(0).getWallet().getName(), walletForms.stream().map(WalletForm::getWalletTransactionsEntry).collect(Collectors.toList()));
@@ -5,6 +5,7 @@ import com.google.zxing.*;
import com.google.zxing.client.j2se.BufferedImageLuminanceSource;
import com.google.zxing.common.HybridBinarizer;
import com.google.zxing.qrcode.QRCodeReader;
import com.sparrowwallet.bokmakierie.Bokmakierie;
import com.sparrowwallet.sparrow.io.Config;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
@@ -43,6 +44,7 @@ public class WebcamService extends ScheduledService<Image> {
private Webcam cam;
private long lastQrSampleTime;
private final Reader qrReader;
private final Bokmakierie bokmakierie;
static {
Webcam.setDriver(new WebcamScanDriver());
@@ -55,6 +57,7 @@ public class WebcamService extends ScheduledService<Image> {
this.delayCalculator = delayCalculator;
this.lastQrSampleTime = System.currentTimeMillis();
this.qrReader = new QRCodeReader();
this.bokmakierie = new Bokmakierie();
}
@Override
@@ -156,6 +159,15 @@ public class WebcamService extends ScheduledService<Image> {
LuminanceSource source = new BufferedImageLuminanceSource(bufferedImage);
BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(source));
try {
com.sparrowwallet.bokmakierie.Result result = bokmakierie.scan(bufferedImage);
if(result != null) {
return new Result(result.getMessage(), result.getRawBytes(), new ResultPoint[0], BarcodeFormat.QR_CODE);
}
} catch(Exception e) {
log.debug("Error scanning QR", e);
}
if(ZBar.isEnabled()) {
ZBar.Scan scan = ZBar.scan(bufferedImage);
if(scan != null) {
@@ -28,16 +28,18 @@ import java.util.List;
public class XprvKeystoreImportPane extends TitledDescriptionPane {
protected final Wallet wallet;
protected final KeystoreXprvImport importer;
protected final KeyDerivation defaultDerivation;
private Button enterXprvButton;
private SplitMenuButton importButton;
private ExtendedKey xprv;
public XprvKeystoreImportPane(Wallet wallet, KeystoreXprvImport importer) {
public XprvKeystoreImportPane(Wallet wallet, KeystoreXprvImport importer, KeyDerivation defaultDerivation) {
super(importer.getName(), "Extended key import", importer.getKeystoreImportDescription(), "image/" + importer.getWalletModel().getType() + ".png");
this.wallet = wallet;
this.importer = importer;
this.defaultDerivation = defaultDerivation;
createImportButton();
buttonBox.getChildren().add(importButton);
@@ -47,6 +49,7 @@ public class XprvKeystoreImportPane extends TitledDescriptionPane {
super("Master Private Key", "BIP32 key", "", "image/" + WalletModel.SEED.getType() + ".png");
this.wallet = null;
this.importer = null;
this.defaultDerivation = keystore.getKeyDerivation();
try {
this.xprv = keystore.getExtendedMasterPrivateKey();
@@ -79,7 +82,7 @@ public class XprvKeystoreImportPane extends TitledDescriptionPane {
importButton.getStyleClass().add("default-button");
importButton.setOnAction(event -> {
importButton.setDisable(true);
importKeystore(wallet.getScriptType().getDefaultDerivation());
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;
@@ -97,6 +100,10 @@ public class XprvKeystoreImportPane extends TitledDescriptionPane {
importButton.setVisible(false);
}
private List<ChildNumber> getDefaultDerivation() {
return defaultDerivation == null || defaultDerivation.getDerivation().isEmpty() ? wallet.getScriptType().getDefaultDerivation() : defaultDerivation.getDerivation();
}
private void enterXprv() {
setDescription("Enter master private key");
showHideLink.setVisible(false);
@@ -149,7 +156,7 @@ public class XprvKeystoreImportPane extends TitledDescriptionPane {
importButton.setVisible(true);
setDescription("Ready to import");
xprv = ExtendedKey.fromDescriptor(xprvField.getText());
setContent(getDerivationEntry(wallet.getScriptType().getDefaultDerivation()));
setContent(getDerivationEntry(getDefaultDerivation()));
});
xprvField.textProperty().addListener((observable, oldValue, newValue) -> {
@@ -0,0 +1,28 @@
package com.sparrowwallet.sparrow.event;
import javafx.stage.Window;
import java.io.File;
public class RequestVerifyDownloadEvent {
private final Window window;
private final File file;
public RequestVerifyDownloadEvent(Window window) {
this.window = window;
this.file = null;
}
public RequestVerifyDownloadEvent(Window window, File file) {
this.window = window;
this.file = file;
}
public Window getWindow() {
return window;
}
public File getFile() {
return file;
}
}
@@ -1,15 +0,0 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Wallet;
public class SorobanInitiatedEvent {
private Wallet wallet;
public SorobanInitiatedEvent(Wallet wallet) {
this.wallet = wallet;
}
public Wallet getWallet() {
return wallet;
}
}
@@ -1,6 +1,5 @@
package com.sparrowwallet.sparrow.event;
import com.samourai.whirlpool.client.whirlpool.beans.Pool;
import com.sparrowwallet.drongo.bip47.PaymentCode;
import com.sparrowwallet.drongo.wallet.BlockTransaction;
import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
@@ -17,7 +16,6 @@ public class SpendUtxoEvent {
private final Long fee;
private final boolean requireAllUtxos;
private final BlockTransaction replacedTransaction;
private final Pool pool;
private final PaymentCode paymentCode;
public SpendUtxoEvent(Wallet wallet, List<BlockTransactionHashIndex> utxos) {
@@ -32,19 +30,6 @@ public class SpendUtxoEvent {
this.fee = fee;
this.requireAllUtxos = requireAllUtxos;
this.replacedTransaction = replacedTransaction;
this.pool = null;
this.paymentCode = null;
}
public SpendUtxoEvent(Wallet wallet, List<BlockTransactionHashIndex> utxos, List<Payment> payments, List<byte[]> opReturns, Long fee, Pool pool) {
this.wallet = wallet;
this.utxos = utxos;
this.payments = payments;
this.opReturns = opReturns;
this.fee = fee;
this.requireAllUtxos = false;
this.replacedTransaction = null;
this.pool = pool;
this.paymentCode = null;
}
@@ -56,7 +41,6 @@ public class SpendUtxoEvent {
this.fee = null;
this.requireAllUtxos = false;
this.replacedTransaction = null;
this.pool = null;
this.paymentCode = paymentCode;
}
@@ -88,10 +72,6 @@ public class SpendUtxoEvent {
return replacedTransaction;
}
public Pool getPool() {
return pool;
}
public PaymentCode getPaymentCode() {
return paymentCode;
}
@@ -1,15 +0,0 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Wallet;
public class WhirlpoolIndexHighFrequencyEvent {
private final Wallet wallet;
public WhirlpoolIndexHighFrequencyEvent(Wallet wallet) {
this.wallet = wallet;
}
public Wallet getWallet() {
return wallet;
}
}
@@ -1,67 +0,0 @@
package com.sparrowwallet.sparrow.event;
import com.samourai.whirlpool.client.mix.listener.MixFailReason;
import com.samourai.whirlpool.client.wallet.beans.MixProgress;
import com.samourai.whirlpool.protocol.beans.Utxo;
import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
import com.sparrowwallet.drongo.wallet.Wallet;
public class WhirlpoolMixEvent {
private final Wallet wallet;
private final BlockTransactionHashIndex utxo;
private final MixProgress mixProgress;
private final Utxo nextUtxo;
private final MixFailReason mixFailReason;
private final String mixError;
public WhirlpoolMixEvent(Wallet wallet, BlockTransactionHashIndex utxo, MixProgress mixProgress) {
this.wallet = wallet;
this.utxo = utxo;
this.mixProgress = mixProgress;
this.nextUtxo = null;
this.mixFailReason = null;
this.mixError = null;
}
public WhirlpoolMixEvent(Wallet wallet, BlockTransactionHashIndex utxo, Utxo nextUtxo) {
this.wallet = wallet;
this.utxo = utxo;
this.mixProgress = null;
this.nextUtxo = nextUtxo;
this.mixFailReason = null;
this.mixError = null;
}
public WhirlpoolMixEvent(Wallet wallet, BlockTransactionHashIndex utxo, MixFailReason mixFailReason, String mixError) {
this.wallet = wallet;
this.utxo = utxo;
this.mixProgress = null;
this.nextUtxo = null;
this.mixFailReason = mixFailReason;
this.mixError = mixError;
}
public Wallet getWallet() {
return wallet;
}
public BlockTransactionHashIndex getUtxo() {
return utxo;
}
public MixProgress getMixProgress() {
return mixProgress;
}
public Utxo getNextUtxo() {
return nextUtxo;
}
public MixFailReason getMixFailReason() {
return mixFailReason;
}
public String getMixError() {
return mixError;
}
}
@@ -1,19 +0,0 @@
package com.sparrowwallet.sparrow.event;
import com.samourai.whirlpool.protocol.beans.Utxo;
import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletNode;
public class WhirlpoolMixSuccessEvent extends WhirlpoolMixEvent {
private final WalletNode walletNode;
public WhirlpoolMixSuccessEvent(Wallet wallet, BlockTransactionHashIndex utxo, Utxo nextUtxo, WalletNode walletNode) {
super(wallet, utxo, nextUtxo);
this.walletNode = walletNode;
}
public WalletNode getWalletNode() {
return walletNode;
}
}
@@ -44,6 +44,7 @@ public class FontAwesome5 extends GlyphFont {
HAND_HOLDING('\uf4bd'),
HAND_HOLDING_MEDICAL('\ue05c'),
HAND_HOLDING_WATER('\uf4c1'),
HOURGLASS_HALF('\uf252'),
HISTORY('\uf1da'),
INFO_CIRCLE('\uf05a'),
KEY('\uf084'),
@@ -141,7 +141,7 @@ public class GlyphUtils {
return feeGlyph;
}
public static Glyph getWarningGlyph() {
public static Glyph getFeeWarningGlyph() {
Glyph feeWarningGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.EXCLAMATION_CIRCLE);
feeWarningGlyph.getStyleClass().add("fee-warning-icon");
feeWarningGlyph.setFontSize(12);
@@ -175,4 +175,39 @@ public class GlyphUtils {
userGlyph.setFontSize(12);
return userGlyph;
}
public static Glyph getSuccessGlyph() {
Glyph successGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.CHECK_CIRCLE);
successGlyph.getStyleClass().add("success");
successGlyph.setFontSize(12);
return successGlyph;
}
public static Glyph getWarningGlyph() {
Glyph warningGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.EXCLAMATION_TRIANGLE);
warningGlyph.getStyleClass().add("warn-icon");
warningGlyph.setFontSize(12);
return warningGlyph;
}
public static Glyph getFailureGlyph() {
Glyph failureGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.TIMES_CIRCLE);
failureGlyph.getStyleClass().add("failure");
failureGlyph.setFontSize(12);
return failureGlyph;
}
public static Glyph getBusyGlyph() {
Glyph busyGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.HOURGLASS_HALF);
busyGlyph.getStyleClass().add("busy");
busyGlyph.setFontSize(12);
return busyGlyph;
}
public static Glyph getDownArrowGlyph() {
Glyph downGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.ARROW_DOWN);
downGlyph.getStyleClass().add("arrow-down");
downGlyph.setFontSize(12);
return downGlyph;
}
}
@@ -1,535 +1,270 @@
/**
* Copyright 2019 Pratanu Mandal
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package com.sparrowwallet.sparrow.instance;
import com.sparrowwallet.sparrow.io.Storage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.RandomAccessFile;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.ConnectException;
import java.net.SocketException;
import java.net.UnknownHostException;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
import java.net.StandardProtocolFamily;
import java.net.UnixDomainSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.util.Iterator;
import java.util.Set;
/**
* The <code>Instance</code> class is the primary logical entry point to the library.<br>
* It allows to create an application lock or free it and send and receive messages between first and subsequent instances.<br><br>
*
* <pre>
* // unique application ID
* String APP_ID = "tk.pratanumandal.unique4j-mlsdvo-20191511-#j.6";
*
* // create Instance instance
* Instance unique = new Instance(APP_ID) {
* &nbsp;&nbsp;&nbsp;&nbsp;&#64;Override
* &nbsp;&nbsp;&nbsp;&nbsp;protected void receiveMessage(String message) {
* &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;// print received message (timestamp)
* &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;System.out.println(message);
* &nbsp;&nbsp;&nbsp;&nbsp;}
* &nbsp;&nbsp;&nbsp;&nbsp;
* &nbsp;&nbsp;&nbsp;&nbsp;&#64;Override
* &nbsp;&nbsp;&nbsp;&nbsp;protected String sendMessage() {
* &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;// send timestamp as message
* &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;Timestamp ts = new Timestamp(new Date().getTime());
* &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;return "Another instance launch attempted: " + ts.toString();
* &nbsp;&nbsp;&nbsp;&nbsp;}
* };
*
* // try to obtain lock
* try {
* &nbsp;&nbsp;&nbsp;&nbsp;unique.acquireLock();
* } catch (InstanceException e) {
* &nbsp;&nbsp;&nbsp;&nbsp;e.printStackTrace();
* }
*
* ...
*
* // try to free the lock before exiting program
* try {
* &nbsp;&nbsp;&nbsp;&nbsp;unique.freeLock();
* } catch (InstanceException e) {
* &nbsp;&nbsp;&nbsp;&nbsp;e.printStackTrace();
* }
* </pre>
*
* @author Pratanu Mandal
* @since 1.3
*
*/
public abstract class Instance {
private static final Logger log = LoggerFactory.getLogger(Instance.class);
// starting position of port check
private static final int PORT_START = 7221;
// system temporary directory path
private static final String TEMP_DIR = System.getProperty("java.io.tmpdir");
/**
* Unique string representing the application ID.<br><br>
*
* The APP_ID must be as unique as possible.
* Avoid generic names like "my_app_id" or "hello_world".<br>
* A good strategy is to use the entire package name (group ID + artifact ID) along with some random characters.
*/
public final String APP_ID;
// auto exit from application or not
private final boolean AUTO_EXIT;
// lock server port
private int port;
// lock server socket
private ServerSocket server;
// lock file RAF object
private RandomAccessFile lockRAF;
// file lock for the lock file RAF object
private FileLock fileLock;
private static final String LINK_ENV_PROPERTY = "SPARROW_NO_LOCK_FILE_LINK";
/**
* Parameterized constructor.<br>
* This constructor configures to automatically exit the application for subsequent instances.<br><br>
*
* The APP_ID must be as unique as possible.
* Avoid generic names like "my_app_id" or "hello_world".<br>
* A good strategy is to use the entire package name (group ID + artifact ID) along with some random characters.
*
* @param APP_ID Unique string representing the application ID
*/
public Instance(final String APP_ID) {
this(APP_ID, true);
}
/**
* Parameterized constructor.<br>
* This constructor allows to explicitly specify the exit strategy for subsequent instances.<br><br>
*
* The APP_ID must be as unique as possible.
* Avoid generic names like "my_app_id" or "hello_world".<br>
* A good strategy is to use the entire package name (group ID + artifact ID) along with some random characters.
*
* @since 1.2
*
* @param APP_ID Unique string representing the application ID
* @param AUTO_EXIT If true, automatically exit the application for subsequent instances
*/
public Instance(final String APP_ID, final boolean AUTO_EXIT) {
this.APP_ID = APP_ID;
this.AUTO_EXIT = AUTO_EXIT;
}
/**
* Try to obtain lock. If not possible, send data to first instance.
*
* @deprecated Use <code>acquireLock()</code> instead.
* @throws InstanceException throws InstanceException if it is unable to start a server or connect to server
*/
@Deprecated
public void lock() throws InstanceException {
acquireLock();
}
/**
* Try to obtain lock. If not possible, send data to first instance.
*
* @since 1.2
*
* @return true if able to acquire lock, false otherwise
* @throws InstanceException throws InstanceException if it is unable to start a server or connect to server
*/
public boolean acquireLock() throws InstanceException {
// try to obtain port number from lock file
port = lockFile();
if (port == -1) {
// failed to fetch port number
// try to start server
startServer();
}
else {
// port number fetched from lock file
// try to start client
doClient();
}
return (server != null);
}
// start the server
private void startServer() throws InstanceException {
// try to create server
port = PORT_START;
while (true) {
try {
server = new ServerSocket(port, 50, InetAddress.getByName(null));
break;
} catch (IOException e) {
port++;
}
}
// try to lock file
lockFile(port);
// server created successfully; this is the first instance
// keep listening for data from other instances
Thread thread = new Thread() {
@Override
public void run() {
while (!server.isClosed()) {
try {
// establish connection
final Socket socket = server.accept();
// handle socket on a different thread to allow parallel connections
Thread thread = new Thread() {
@Override
public void run() {
try {
// open writer
OutputStream os = socket.getOutputStream();
DataOutputStream dos = new DataOutputStream(os);
// open reader
InputStream is = socket.getInputStream();
DataInputStream dis = new DataInputStream(is);
// read message length from client
int length = dis.readInt();
// read message string from client
String message = null;
if (length > -1) {
byte[] messageBytes = new byte[length];
int bytesRead = dis.read(messageBytes, 0, length);
message = new String(messageBytes, 0, bytesRead, "UTF-8");
}
// write response to client
if (APP_ID == null) {
dos.writeInt(-1);
}
else {
byte[] appId = APP_ID.getBytes("UTF-8");
dos.writeInt(appId.length);
dos.write(appId);
}
dos.flush();
// close writer and reader
dos.close();
dis.close();
// perform user action on message
receiveMessage(message);
// close socket
socket.close();
} catch (IOException e) {
handleException(new InstanceException(e));
}
}
};
// start socket thread
thread.start();
} catch (SocketException e) {
if (!server.isClosed()) {
handleException(new InstanceException(e));
}
} catch (IOException e) {
handleException(new InstanceException(e));
}
}
}
};
thread.start();
}
// do client tasks
private void doClient() throws InstanceException {
// get localhost address
InetAddress address = null;
try {
address = InetAddress.getByName(null);
} catch (UnknownHostException e) {
throw new InstanceException(e);
}
// try to establish connection to server
Socket socket = null;
try {
socket = new Socket(address, port);
} catch (IOException e) {
// connection failed try to start server
startServer();
}
// connection successful try to connect to server
if (socket != null) {
try {
// get message to be sent to first instance
String message = sendMessage();
// open writer
OutputStream os = socket.getOutputStream();
DataOutputStream dos = new DataOutputStream(os);
// open reader
InputStream is = socket.getInputStream();
DataInputStream dis = new DataInputStream(is);
// write message to server
if (message == null) {
dos.writeInt(-1);
}
else {
byte[] messageBytes = message.getBytes("UTF-8");
dos.writeInt(messageBytes.length);
dos.write(messageBytes);
}
dos.flush();
// read response length from server
int length = dis.readInt();
// read response string from server
String response = null;
if (length > -1) {
byte[] responseBytes = new byte[length];
int bytesRead = dis.read(responseBytes, 0, length);
response = new String(responseBytes, 0, bytesRead, "UTF-8");
}
// close writer and reader
dos.close();
dis.close();
if (response.equals(APP_ID)) {
// validation successful
if (AUTO_EXIT) {
// perform pre-exit tasks
beforeExit();
// exit this instance
System.exit(0);
}
}
else {
// validation failed, this is the first instance
startServer();
}
} catch (IOException e) {
throw new InstanceException(e);
} finally {
// close socket
try {
if (socket != null) socket.close();
} catch (IOException e) {
throw new InstanceException(e);
}
}
}
}
// try to get port from lock file
private int lockFile() throws InstanceException {
// lock file path
String filePath = TEMP_DIR + File.separator + APP_ID + ".lock";
File file = new File(filePath);
// try to get port from lock file
if (file.exists()) {
BufferedReader br = null;
try {
br = new BufferedReader(new InputStreamReader(new FileInputStream(file)));
return Integer.parseInt(br.readLine());
} catch (IOException e) {
throw new InstanceException(e);
} catch (NumberFormatException e) {
// do nothing
} finally {
try {
if (br != null) br.close();
} catch (IOException e) {
throw new InstanceException(e);
}
}
}
return -1;
}
// try to write port to lock file
private void lockFile(int port) throws InstanceException {
// lock file path
String filePath = TEMP_DIR + File.separator + APP_ID + ".lock";
File file = new File(filePath);
// try to write port to lock file
BufferedWriter bw = null;
try {
bw = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(file)));
bw.write(String.valueOf(port));
} catch (IOException e) {
throw new InstanceException(e);
} finally {
try {
if (bw != null) bw.close();
} catch (IOException e) {
throw new InstanceException(e);
}
}
// try to obtain file lock
try {
lockRAF = new RandomAccessFile(file, "rw");
FileChannel fc = lockRAF.getChannel();
fileLock = fc.tryLock(0, Long.MAX_VALUE, true);
if (fileLock == null) {
throw new InstanceException("Failed to obtain file lock");
}
} catch (FileNotFoundException e) {
throw new InstanceException(e);
} catch (IOException e) {
throw new InstanceException(e);
}
}
/**
* Free the lock if possible. This is only required to be called from the first instance.
*
* @deprecated Use <code>freeLock()</code> instead.
* @throws InstanceException throws InstanceException if it is unable to stop the server or release file lock
*/
@Deprecated
public void free() throws InstanceException {
freeLock();
}
/**
* Free the lock if possible. This is only required to be called from the first instance.
*
* @since 1.2
*
* @return true if able to release lock, false otherwise
* @throws InstanceException throws InstanceException if it is unable to stop the server or release file lock
*/
public boolean freeLock() throws InstanceException {
try {
// close server socket
if (server != null) {
server.close();
// lock file path
String filePath = TEMP_DIR + File.separator + APP_ID + ".lock";
File file = new File(filePath);
// try to release file lock
if (fileLock != null) {
fileLock.release();
}
// try to close lock file RAF object
if (lockRAF != null) {
lockRAF.close();
}
// try to delete lock file
if (file.exists()) {
file.delete();
}
return true;
}
return false;
} catch (IOException e) {
throw new InstanceException(e);
}
}
/**
* Method used in first instance to receive messages from subsequent instances.<br><br>
*
* This method is not synchronized.
*
* @param message message received by first instance from subsequent instances
*/
protected abstract void receiveMessage(String message);
/**
* Method used in subsequent instances to send message to first instance.<br><br>
*
* It is not recommended to perform blocking (long running) tasks here. Use <code>beforeExit()</code> method instead.<br>
* One exception to this rule is if you intend to perform some user interaction before sending the message.<br><br>
*
* This method is not synchronized.
*
* @return message sent from subsequent instances
*/
protected abstract String sendMessage();
/**
* Method to receive and handle exceptions occurring while first instance is listening for subsequent instances.<br><br>
*
* By default prints stack trace of all exceptions. Override this method to handle exceptions explicitly.<br><br>
*
* This method is not synchronized.
*
* @param exception exception occurring while first instance is listening for subsequent instances
*/
protected void handleException(Exception exception) {
public final String applicationId;
private final boolean autoExit;
private Selector selector;
private ServerSocketChannel serverChannel;
public Instance(final String applicationId) {
this(applicationId, true);
}
public Instance(final String applicationId, final boolean autoExit) {
this.applicationId = applicationId;
this.autoExit = autoExit;
}
/**
* Try to obtain lock. If not possible, send data to first instance.
*
* @throws InstanceException throws InstanceException if it is unable to start a server or connect to server
*/
public void acquireLock(boolean findExisting) throws InstanceException {
Path lockFile = getLockFile(findExisting);
if(!Files.exists(lockFile)) {
startServer(lockFile);
} else {
doClient(lockFile);
}
}
private void startServer(Path lockFile) throws InstanceException {
try {
selector = Selector.open();
UnixDomainSocketAddress socketAddress = UnixDomainSocketAddress.of(lockFile);
serverChannel = ServerSocketChannel.open(StandardProtocolFamily.UNIX);
serverChannel.bind(socketAddress);
serverChannel.configureBlocking(false);
serverChannel.register(selector, SelectionKey.OP_ACCEPT);
lockFile.toFile().deleteOnExit();
} catch(Exception e) {
throw new InstanceException("Could not open UNIX socket lock file for instance at " + lockFile.toAbsolutePath(), e);
}
Thread thread = new Thread(() -> {
while(true) {
try {
selector.select();
Set<SelectionKey> selectedKeys = selector.selectedKeys();
Iterator<SelectionKey> iter = selectedKeys.iterator();
while(iter.hasNext()) {
SelectionKey key = iter.next();
if(key.isAcceptable()) {
SocketChannel client = serverChannel.accept();
client.configureBlocking(false);
client.register(selector, SelectionKey.OP_READ);
}
if(key.isReadable()) {
try(SocketChannel clientChannel = (SocketChannel)key.channel()) {
String message = readMessage(clientChannel);
clientChannel.write(ByteBuffer.wrap(applicationId.getBytes(StandardCharsets.UTF_8)));
receiveMessage(message);
}
}
iter.remove();
}
} catch(SocketException e) {
if(serverChannel.isOpen()) {
handleException(new InstanceException(e));
}
} catch(Exception e) {
handleException(new InstanceException(e));
}
}
});
thread.setDaemon(true);
thread.setName("SparrowInstanceListener");
thread.start();
createSymlink(lockFile);
}
private void doClient(Path lockFile) throws InstanceException {
try(SocketChannel client = SocketChannel.open(UnixDomainSocketAddress.of(lockFile))) {
String message = sendMessage();
client.write(ByteBuffer.wrap(message.getBytes(StandardCharsets.UTF_8)));
client.shutdownOutput();
String response = readMessage(client);
if(response.equals(applicationId) && autoExit) {
beforeExit();
System.exit(0);
}
} catch(ConnectException e) {
try {
Files.deleteIfExists(lockFile);
startServer(lockFile);
} catch(Exception ex) {
throw new InstanceException("Could not delete lock file from previous instance", e);
}
} catch(Exception e) {
throw new InstanceException("Could not open client connection to existing instance", e);
}
}
private static String readMessage(SocketChannel clientChannel) throws IOException {
ByteBuffer buffer = ByteBuffer.allocate(1024);
StringBuilder messageBuilder = new StringBuilder();
while(clientChannel.read(buffer) != -1) {
buffer.flip();
messageBuilder.append(new String(buffer.array(), 0, buffer.limit()));
buffer.clear();
}
return messageBuilder.toString();
}
private Path getLockFile(boolean findExisting) {
if(findExisting) {
Path pointer = getUserLockFilePointer();
try {
if(pointer != null && Files.exists(pointer)) {
if(Files.isSymbolicLink(pointer)) {
return Files.readSymbolicLink(pointer);
} else {
Path lockFile = Path.of(Files.readString(pointer, StandardCharsets.UTF_8));
if(Files.exists(lockFile)) {
return lockFile;
}
}
}
} catch(IOException e) {
log.warn("Could not find lock file at " + pointer.toAbsolutePath());
} catch(Exception e) {
//ignore
}
}
return Storage.getSparrowDir().toPath().resolve(applicationId + ".lock");
}
private void createSymlink(Path lockFile) {
Path pointer = getUserLockFilePointer();
try {
if(pointer != null && !Files.exists(pointer, LinkOption.NOFOLLOW_LINKS)) {
Files.createSymbolicLink(pointer, lockFile);
pointer.toFile().deleteOnExit();
}
} catch(IOException e) {
log.debug("Could not create symlink " + pointer.toAbsolutePath() + " to lockFile at " + lockFile.toAbsolutePath() + ", writing as normal file", e);
try {
Files.writeString(pointer, lockFile.toAbsolutePath().toString(), StandardCharsets.UTF_8);
pointer.toFile().deleteOnExit();
} catch(IOException ex) {
log.warn("Could not create pointer " + pointer.toAbsolutePath() + " to lockFile at " + lockFile.toAbsolutePath(), ex);
}
} catch(Exception e) {
//ignore
}
}
private Path getUserLockFilePointer() {
if(Boolean.parseBoolean(System.getenv(LINK_ENV_PROPERTY))) {
return null;
}
try {
File sparrowHome = Storage.getSparrowHome(true);
if(!sparrowHome.exists()) {
Storage.createOwnerOnlyDirectory(sparrowHome);
}
return sparrowHome.toPath().resolve(applicationId + ".default");
} catch(Exception e) {
return null;
}
}
/**
* Free the lock if possible. This is only required to be called from the first instance.
*
* @throws InstanceException throws InstanceException if it is unable to stop the server or release file lock
*/
public void freeLock() throws InstanceException {
try {
if(serverChannel != null && serverChannel.isOpen()) {
serverChannel.close();
}
if(getUserLockFilePointer() != null) {
Files.deleteIfExists(getUserLockFilePointer());
}
Files.deleteIfExists(getLockFile(false));
} catch(Exception e) {
throw new InstanceException(e);
}
}
/**
* Method used in first instance to receive messages from subsequent instances.<br><br>
*
* This method is not synchronized.
*
* @param message message received by first instance from subsequent instances
*/
protected abstract void receiveMessage(String message);
/**
* Method used in subsequent instances to send message to first instance.<br><br>
*
* It is not recommended to perform blocking (long running) tasks here. Use <code>beforeExit()</code> method instead.<br>
* One exception to this rule is if you intend to perform some user interaction before sending the message.<br><br>
*
* This method is not synchronized.
*
* @return message sent from subsequent instances
*/
protected abstract String sendMessage();
/**
* Method to receive and handle exceptions occurring while first instance is listening for subsequent instances.<br><br>
*
* By default prints stack trace of all exceptions. Override this method to handle exceptions explicitly.<br><br>
*
* This method is not synchronized.
*
* @param exception exception occurring while first instance is listening for subsequent instances
*/
protected void handleException(Exception exception) {
log.error("Error listening for instances", exception);
}
/**
* This method is called before exiting from subsequent instances.<br><br>
*
* Override this method to perform blocking tasks before exiting from subsequent instances.<br>
* This method is not invoked if auto exit is turned off.<br><br>
*
* This method is not synchronized.
*
* @since 1.2
*/
protected void beforeExit() {}
}
/**
* This method is called before exiting from subsequent instances.<br><br>
*
* Override this method to perform blocking tasks before exiting from subsequent instances.<br>
* This method is not invoked if auto exit is turned off.<br><br>
*
* This method is not synchronized.
*/
protected void beforeExit() {}
}
@@ -7,88 +7,14 @@ import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonParser;
/**
* The <code>InstanceList</code> class is a logical entry point to the library which extends the functionality of the <code>Instance</code> class.<br>
* It allows to create an application lock or free it and send and receive messages between first and subsequent instances.<br><br>
*
* This class is intended for passing a list of strings instead of a single string from the subsequent instance to the first instance.<br><br>
*
* <pre>
* // unique application ID
* String APP_ID = "tk.pratanumandal.unique4j-mlsdvo-20191511-#j.6";
*
* // create Instance instance
* Instance unique = new InstanceList(APP_ID) {
* &nbsp;&nbsp;&nbsp;&nbsp;&#64;Override
* &nbsp;&nbsp;&nbsp;&nbsp;protected List&lt;String&gt; sendMessageList() {
* &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;List&lt;String&gt; messageList = new ArrayList&lt;String&gt;();
* &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
* &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;messageList.add("Message 1");
* &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;messageList.add("Message 2");
* &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;messageList.add("Message 3");
* &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;messageList.add("Message 4");
* &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
* &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;return messageList;
* &nbsp;&nbsp;&nbsp;&nbsp;}
*
* &nbsp;&nbsp;&nbsp;&nbsp;&#64;Override
* &nbsp;&nbsp;&nbsp;&nbsp;protected void receiveMessageList(List&lt;String&gt; messageList) {
* &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;for (String message : messageList) {
* &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;System.out.println(message);
* &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;}
* &nbsp;&nbsp;&nbsp;&nbsp;}
* };
*
* // try to obtain lock
* try {
* &nbsp;&nbsp;&nbsp;&nbsp;unique.acquireLock();
* } catch (InstanceException e) {
* &nbsp;&nbsp;&nbsp;&nbsp;e.printStackTrace();
* }
*
* ...
*
* // try to free the lock before exiting program
* try {
* &nbsp;&nbsp;&nbsp;&nbsp;unique.freeLock();
* } catch (InstanceException e) {
* &nbsp;&nbsp;&nbsp;&nbsp;e.printStackTrace();
* }
* </pre>
*
* @author Pratanu Mandal
* @since 1.3
*
*/
public abstract class InstanceList extends Instance {
/**
* Parameterized constructor.<br>
* This constructor configures to automatically exit the application for subsequent instances.<br><br>
*
* The APP_ID must be as unique as possible.
* Avoid generic names like "my_app_id" or "hello_world".<br>
* A good strategy is to use the entire package name (group ID + artifact ID) along with some random characters.
*
* @param APP_ID Unique string representing the application ID
*/
public InstanceList(String APP_ID) {
super(APP_ID);
public InstanceList(String applicationId) {
super(applicationId);
}
/**
* Parameterized constructor.<br>
* This constructor allows to explicitly specify the exit strategy for subsequent instances.<br><br>
*
* The APP_ID must be as unique as possible.
* Avoid generic names like "my_app_id" or "hello_world".<br>
* A good strategy is to use the entire package name (group ID + artifact ID) along with some random characters.
*
* @param APP_ID Unique string representing the application ID
* @param AUTO_EXIT If true, automatically exit the application for subsequent instances
*/
public InstanceList(String APP_ID, boolean AUTO_EXIT) {
super(APP_ID, AUTO_EXIT);
public InstanceList(String applicationId, boolean autoExit) {
super(applicationId, autoExit);
}
/**
@@ -101,16 +27,15 @@ public abstract class InstanceList extends Instance {
*/
@Override
protected final void receiveMessage(String message) {
if (message == null) {
if(message == null) {
receiveMessageList(null);
}
else {
} else {
// parse the JSON array string into an array of string arguments
JsonArray jsonArgs = JsonParser.parseString(message).getAsJsonArray();
List<String> stringArgs = new ArrayList<String>(jsonArgs.size());
for (int i = 0; i < jsonArgs.size(); i++) {
for(int i = 0; i < jsonArgs.size(); i++) {
JsonElement element = jsonArgs.get(i);
stringArgs.add(element.getAsString());
}
@@ -137,10 +62,11 @@ public abstract class InstanceList extends Instance {
JsonArray jsonArgs = new JsonArray();
List<String> stringArgs = sendMessageList();
if(stringArgs == null) {
return null;
}
if (stringArgs == null) return null;
for (String arg : stringArgs) {
for(String arg : stringArgs) {
jsonArgs.add(arg);
}
@@ -168,5 +94,4 @@ public abstract class InstanceList extends Instance {
* @return list of messages sent from subsequent instances
*/
protected abstract List<String> sendMessageList();
}
@@ -28,7 +28,7 @@ public class CaravanMultisig implements WalletImport, WalletExport {
@Override
public String getName() {
return "Unchained or Caravan Multisig";
return "Unchained Caravan Multisig";
}
@Override
@@ -56,7 +56,7 @@ public class CaravanMultisig implements WalletImport, WalletExport {
Keystore keystore = new Keystore(extKey.name.length() > Keystore.MAX_LABEL_LENGTH ? extKey.name.substring(0, Keystore.MAX_LABEL_LENGTH) : extKey.name);
if("Unknown".equals(extKey.bip32Path)) {
extKey.bip32Path = "m/45'/0/0/0";
extKey.bip32Path = "m/0/0/0/0";
}
try {
@@ -107,7 +107,7 @@ public class CaravanMultisig implements WalletImport, WalletExport {
CaravanFile cf = new CaravanFile();
cf.name = wallet.getFullName();
cf.addressType = wallet.getScriptType().toString().replace('-', '_');
cf.network = Network.get().getName();
cf.network = Network.getCanonical().getName();
cf.client = new Client();
Quorum quorum = new Quorum();
@@ -62,7 +62,7 @@ public abstract class CardApi {
}
public static CardApi getCardApi(WalletModel walletModel, String pin) throws CardException {
if(walletModel == WalletModel.TAPSIGNER || walletModel == WalletModel.SATSCARD) {
if(walletModel == WalletModel.TAPSIGNER || walletModel == WalletModel.SATSCHIP || walletModel == WalletModel.SATSCARD) {
return new CkCardApi(walletModel, pin);
}
@@ -7,10 +7,7 @@ import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.policy.Policy;
import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.KeystoreSource;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletModel;
import com.sparrowwallet.drongo.wallet.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -36,6 +33,31 @@ public class ColdcardMultisig implements WalletImport, KeystoreFileImport, Walle
@Override
public Keystore getKeystore(ScriptType scriptType, InputStream inputStream, String password) throws ImportException {
try {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
inputStream.transferTo(baos);
InputStream firstClone = new ByteArrayInputStream(baos.toByteArray());
InputStream secondClone = new ByteArrayInputStream(baos.toByteArray());
Keystore keystore;
try {
keystore = getKeystoreMultisig(scriptType, firstClone, password);
} catch(Exception e) {
keystore = getKeystoreSinglesig(scriptType, secondClone, password);
}
return keystore;
} catch(IOException e) {
throw new ImportException("Error importing keystore for " + scriptType, e);
}
}
private Keystore getKeystoreSinglesig(ScriptType scriptType, InputStream inputStream, String password) throws ImportException {
ColdcardSinglesig coldcardSinglesig = new ColdcardSinglesig();
return coldcardSinglesig.getKeystore(scriptType, inputStream, password);
}
public Keystore getKeystoreMultisig(ScriptType scriptType, InputStream inputStream, String password) throws ImportException {
InputStreamReader reader = new InputStreamReader(inputStream, StandardCharsets.UTF_8);
ColdcardKeystore cck = JsonPersistence.getGson().fromJson(reader, ColdcardKeystore.class);
@@ -86,7 +108,7 @@ public class ColdcardMultisig implements WalletImport, KeystoreFileImport, Walle
@Override
public String getKeystoreImportDescription(int account) {
return "Import file created by using the Settings > Multisig Wallets > Export XPUB > " + account + " feature on your Coldcard.";
return "Import file or QR created by using Advanced/Tools > Export Wallet > Sparrow Wallet" + (account > 0 ? " > 1 > " + account : "") + " on your Coldcard. For older firmware use Settings > Multisig Wallets > Export XPUB > " + account + ".";
}
@Override
@@ -149,8 +171,10 @@ public class ColdcardMultisig implements WalletImport, KeystoreFileImport, Walle
wallet.setDefaultPolicy(policy);
wallet.setScriptType(scriptType);
if(!wallet.isValid()) {
throw new IllegalStateException("This file does not describe a valid wallet. " + getKeystoreImportDescription());
try {
wallet.checkWallet();
} catch(InvalidWalletException e) {
throw new IllegalStateException("This file does not describe a valid wallet: " + e.getMessage());
}
return wallet;
@@ -161,7 +185,7 @@ public class ColdcardMultisig implements WalletImport, KeystoreFileImport, Walle
@Override
public String getWalletImportDescription() {
return "Import file created by using the Settings > Multisig Wallets > [Wallet Detail] > Coldcard Export feature on your Coldcard.";
return "Import file or QR created by using Settings > Multisig Wallets > [Wallet Detail] > Coldcard Export on your Coldcard.";
}
@Override
@@ -214,7 +238,7 @@ public class ColdcardMultisig implements WalletImport, KeystoreFileImport, Walle
@Override
public String getWalletExportDescription() {
return "Export file that can be read by your Coldcard using the Settings > Multisig Wallets > Import from File feature.";
return "Export file or QR that can be read by your Coldcard using Settings > Multisig Wallets > Import from File or QR.";
}
@Override
@@ -224,17 +248,17 @@ public class ColdcardMultisig implements WalletImport, KeystoreFileImport, Walle
@Override
public boolean isWalletImportScannable() {
return false;
return true;
}
@Override
public boolean isKeystoreImportScannable() {
return false;
return true;
}
@Override
public boolean isWalletExportScannable() {
return false;
return true;
}
@Override
@@ -30,7 +30,7 @@ public class ColdcardSinglesig implements KeystoreFileImport, WalletImport {
@Override
public String getKeystoreImportDescription(int account) {
return "Import file created by using the Advanced > MicroSD > Export Wallet > Generic JSON > " + account + " feature on your Coldcard. Note this requires firmware version 3.1.3 or later.";
return "Import file or QR created by using Advanced/Tools > Export Wallet > Sparrow Wallet" + (account > 0 ? " > 1 > " + account : "") + " on your Coldcard. For older firmware use Advanced > MicroSD > Export Wallet > Generic JSON > " + account + ".";
}
@Override
@@ -45,12 +45,12 @@ public class ColdcardSinglesig implements KeystoreFileImport, WalletImport {
@Override
public boolean isWalletImportScannable() {
return false;
return true;
}
@Override
public boolean isKeystoreImportScannable() {
return false;
return true;
}
@Override
@@ -72,7 +72,7 @@ public class ColdcardSinglesig implements KeystoreFileImport, WalletImport {
ColdcardKeystore ck = gson.fromJson(map.get(key), ColdcardKeystore.class);
if(ck.name != null) {
ScriptType ckScriptType = ScriptType.valueOf(ck.name.replace("p2wpkh-p2sh", "p2sh_p2wpkh").replace("p2sh-p2wpkh", "p2sh_p2wpkh").toUpperCase(Locale.ROOT));
ScriptType ckScriptType = ScriptType.valueOf(ck.name.replace("p2wpkh-p2sh", "p2sh_p2wpkh").replace("-", "_").toUpperCase(Locale.ROOT));
if(ckScriptType.equals(scriptType)) {
Keystore keystore = new Keystore();
keystore.setLabel(getName());
@@ -77,7 +77,6 @@ public class Config {
private int maxServerTimeout = DEFAULT_MAX_TIMEOUT;
private int maxPageSize = DEFAULT_PAGE_SIZE;
private boolean usePayNym;
private boolean sameAppMixing;
private boolean mempoolFullRbf;
private Double appWidth;
private Double appHeight;
@@ -662,15 +661,6 @@ public class Config {
flush();
}
public boolean isSameAppMixing() {
return sameAppMixing;
}
public void setSameAppMixing(boolean sameAppMixing) {
this.sameAppMixing = sameAppMixing;
flush();
}
public boolean isMempoolFullRbf() {
return mempoolFullRbf;
}
@@ -328,8 +328,12 @@ public class Electrum implements KeystoreFileImport, WalletImport, WalletExport
ek.derivation = keystore.getKeyDerivation().getDerivationPath();
ek.root_fingerprint = keystore.getKeyDerivation().getMasterFingerprint();
ek.xpub = keystore.getExtendedPublicKey().toString(xpubHeader);
ek.type = "hardware";
ek.hw_type = keystore.getWalletModel().getType();
if(keystore.getWalletModel().hasUsb()) {
ek.type = "hardware";
ek.hw_type = keystore.getWalletModel().getType();
} else {
ek.type = "bip32";
}
ew.use_encryption = false;
} else if(keystore.getSource() == KeystoreSource.SW_SEED) {
ek.type = "bip32";
@@ -36,7 +36,7 @@ public class GordianSeedTool implements KeystoreFileImport {
@Override
public String getKeystoreImportDescription(int account) {
return "Select your seed and scan the QR code created by Authenticate > Derive Key > Other Key Derivations > " + Network.get().toDisplayString() + " > Master Key > Account Descriptor. Click the share icon at the bottom.";
return "Select your seed and scan the QR code created by Authenticate > Derive Key > Other Key Derivations > " + Network.getCanonical().getCapitalizedName() + " > Master Key > Account Descriptor. Click the share icon at the bottom.";
}
@Override
@@ -13,6 +13,9 @@ import com.sparrowwallet.drongo.wallet.WalletModel;
import javafx.concurrent.ScheduledService;
import javafx.concurrent.Service;
import javafx.concurrent.Task;
import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
import org.apache.commons.compress.compressors.bzip2.BZip2CompressorInputStream;
import org.controlsfx.tools.Platform;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -24,19 +27,18 @@ import java.lang.reflect.Type;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.attribute.PosixFilePermission;
import java.nio.file.attribute.PosixFilePermissions;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
public class Hwi {
private static final Logger log = LoggerFactory.getLogger(Hwi.class);
private static final String HWI_HOME_DIR = "hwi";
private static final String HWI_VERSION_PREFIX = "hwi-";
private static final String HWI_VERSION = "2.3.1";
private static final String HWI_VERSION = "3.0.0";
private static final String HWI_VERSION_DIR = HWI_VERSION_PREFIX + HWI_VERSION;
private static boolean isPromptActive = false;
@@ -377,9 +379,9 @@ public class Hwi {
if(platform == Platform.OSX) {
InputStream inputStream;
if(osArch.equals("aarch64")) {
inputStream = Hwi.class.getResourceAsStream("/native/osx/aarch64/" + HWI_VERSION_DIR + "-mac-aarch64-signed.zip");
inputStream = Hwi.class.getResourceAsStream("/native/osx/aarch64/" + HWI_VERSION_DIR + "-mac-aarch64-signed.tar.bz2");
} else {
inputStream = Hwi.class.getResourceAsStream("/native/osx/x64/" + HWI_VERSION_DIR + "-mac-amd64-signed.zip");
inputStream = Hwi.class.getResourceAsStream("/native/osx/x64/" + HWI_VERSION_DIR + "-mac-amd64-signed.tar.bz2");
}
if(inputStream == null) {
@@ -396,26 +398,27 @@ public class Hwi {
log.debug("Using HWI path: " + hwiVersionDir.getAbsolutePath());
File hwiExec = null;
try(ZipInputStream zis = new ZipInputStream(inputStream)) {
ZipEntry zipEntry = zis.getNextEntry();
while(zipEntry != null) {
if(zipEntry.isDirectory()) {
newDirectory(hwiVersionDir, zipEntry, ownerExecutableWritable);
try(TarArchiveInputStream tarInput = new TarArchiveInputStream(new BZip2CompressorInputStream(inputStream))) {
TarArchiveEntry tarEntry = tarInput.getNextEntry();
while(tarEntry != null) {
if(tarEntry.isDirectory()) {
newDirectory(hwiVersionDir, tarEntry, ownerExecutableWritable);
} else if(tarEntry.isSymbolicLink()) {
newSymlink(hwiVersionDir, tarEntry, ownerExecutableWritable);
} else {
File newFile = newFile(hwiVersionDir, zipEntry, ownerExecutableWritable);
File newFile = newFile(hwiVersionDir, tarEntry, ownerExecutableWritable);
try(FileOutputStream fos = new FileOutputStream(newFile)) {
ByteStreams.copy(zis, new FileOutputStream(newFile));
ByteStreams.copy(tarInput, new FileOutputStream(newFile));
fos.flush();
};
}
if(zipEntry.getName().equals("hwi")) {
if(tarEntry.getName().equals("hwi")) {
hwiExec = newFile;
}
}
zipEntry = zis.getNextEntry();
tarEntry = tarInput.getNextEntry();
}
zis.closeEntry();
}
hwiExecutable = hwiExec;
@@ -478,29 +481,43 @@ public class Hwi {
}
}
public static File newDirectory(File destinationDir, ZipEntry zipEntry, Set<PosixFilePermission> setFilePermissions) throws IOException {
public static File newDirectory(File destinationDir, TarArchiveEntry tarEntry, Set<PosixFilePermission> setFilePermissions) throws IOException {
String destDirPath = destinationDir.getCanonicalPath();
Path path = Path.of(destDirPath, zipEntry.getName());
Path path = Path.of(destDirPath, tarEntry.getName());
File destDir = Files.createDirectory(path, PosixFilePermissions.asFileAttribute(setFilePermissions)).toFile();
String destSubDirPath = destDir.getCanonicalPath();
if(!destSubDirPath.startsWith(destDirPath + File.separator)) {
throw new IOException("Entry is outside of the target dir: " + zipEntry.getName());
throw new IOException("Entry is outside of the target dir: " + tarEntry.getName());
}
return destDir;
}
public static File newFile(File destinationDir, ZipEntry zipEntry, Set<PosixFilePermission> setFilePermissions) throws IOException {
public static File newFile(File destinationDir, TarArchiveEntry tarEntry, Set<PosixFilePermission> setFilePermissions) throws IOException {
String destDirPath = destinationDir.getCanonicalPath();
Path path = Path.of(destDirPath, zipEntry.getName());
Path path = Path.of(destDirPath, tarEntry.getName());
File destFile = Files.createFile(path, PosixFilePermissions.asFileAttribute(setFilePermissions)).toFile();
String destFilePath = destFile.getCanonicalPath();
if(!destFilePath.startsWith(destDirPath + File.separator)) {
throw new IOException("Entry is outside of the target dir: " + zipEntry.getName());
throw new IOException("Entry is outside of the target dir: " + tarEntry.getName());
}
return destFile;
}
public static File newSymlink(File destinationDir, TarArchiveEntry tarEntry, Set<PosixFilePermission> setFilePermissions) throws IOException {
String destDirPath = destinationDir.getCanonicalPath();
Path path = Path.of(destDirPath, tarEntry.getName());
File destFile = Files.createSymbolicLink(path, Paths.get(tarEntry.getLinkName())).toFile();
String destFilePath = destFile.getCanonicalPath();
if(!destFilePath.startsWith(destDirPath + File.separator)) {
throw new IOException("Entry is outside of the target dir: " + tarEntry.getName());
}
return destFile;
@@ -584,7 +601,7 @@ public class Hwi {
private void addChainType(List<String> elements, boolean commandPresent) {
if(Network.get() != Network.MAINNET) {
elements.add(elements.size() - (commandPresent ? 1 : 0), "--chain");
elements.add(elements.size() - (commandPresent ? 1 : 0), getChainName(Network.get()));
elements.add(elements.size() - (commandPresent ? 1 : 0), getChainName(Network.getCanonical()));
}
}
@@ -836,7 +853,7 @@ public class Hwi {
public WalletModel deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
String modelStr = json.getAsJsonPrimitive().getAsString();
try {
return WalletModel.valueOf(modelStr.toUpperCase(Locale.ROOT));
return WalletModel.valueOf(modelStr.toUpperCase(Locale.ROOT).replace(' ', '_'));
} catch(Exception e) {
for(WalletModel model : WalletModel.values()) {
if(modelStr.startsWith(model.getType())) {
@@ -17,9 +17,4 @@ public class Krux extends SpecterDIY {
public WalletModel getWalletModel() {
return WalletModel.KRUX;
}
@Override
public boolean isFileFormatAvailable() {
return false;
}
}
@@ -22,6 +22,9 @@ import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.hummingbird.UR;
import com.sparrowwallet.hummingbird.UREncoder;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.io.bbqr.BBQR;
import com.sparrowwallet.sparrow.io.bbqr.BBQREncoder;
import com.sparrowwallet.sparrow.io.bbqr.BBQREncoding;
import javafx.embed.swing.SwingFXUtils;
import javafx.stage.FileChooser;
import javafx.stage.Stage;
@@ -39,7 +42,7 @@ public class PdfUtils {
private static final int QR_WIDTH = 480;
private static final int QR_HEIGHT = 480;
public static void saveOutputDescriptor(String walletName, String outputDescriptor, UR ur) {
public static void saveOutputDescriptor(String walletName, String outputDescriptor, UR ur, BBQR bbqr) {
Stage window = new Stage();
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("Save PDF");
@@ -56,9 +59,22 @@ public class PdfUtils {
Chunk title = new Chunk("Output descriptor for " + walletName, titleFont);
document.add(title);
UREncoder urEncoder = new UREncoder(ur, 2000, 10, 0);
String fragment = urEncoder.nextPart();
if(urEncoder.isSinglePart()) {
String fragment = null;
if(bbqr != null) {
BBQREncoder bbqrEncoder = new BBQREncoder(bbqr.type(), BBQREncoding.ZLIB, bbqr.data(), 2000, 0);
if(bbqrEncoder.isSinglePart()) {
fragment = bbqrEncoder.nextPart();
}
}
if(fragment == null) {
UREncoder urEncoder = new UREncoder(ur, 2000, 10, 0);
if(urEncoder.isSinglePart()) {
fragment = urEncoder.nextPart();
}
}
if(fragment != null) {
Image image = Image.getInstance(SwingFXUtils.fromFXImage(getQrCode(fragment), null), Color.WHITE);
document.add(image);
}
@@ -0,0 +1,99 @@
package com.sparrowwallet.sparrow.io;
import com.google.common.io.CharStreams;
import com.google.gson.Gson;
import com.google.gson.JsonElement;
import com.google.gson.JsonParseException;
import com.google.gson.reflect.TypeToken;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.crypto.SamouraiUtil;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.wallet.*;
import java.io.File;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.lang.reflect.Type;
import java.nio.charset.StandardCharsets;
import java.util.Map;
@SuppressWarnings("deprecation")
public class Samourai implements KeystoreFileImport {
@Override
public String getKeystoreImportDescription(int account) {
return "Import the wallet backup file samourai.txt exported from the Samourai app. Note that see the full balance, several script types may need to imported in separate wallets.";
}
@Override
public Keystore getKeystore(ScriptType scriptType, InputStream inputStream, String password) throws ImportException {
try {
String input = CharStreams.toString(new InputStreamReader(inputStream, StandardCharsets.UTF_8));
Gson gson = new Gson();
Type stringStringMap = new TypeToken<Map<String, JsonElement>>() {
}.getType();
Map<String, JsonElement> map = gson.fromJson(input, stringStringMap);
String payload = input;
if(map.containsKey("payload")) {
payload = map.get("payload").getAsString();
}
int version = 1;
if(map.containsKey("version")) {
version = map.get("version").getAsInt();
}
String decrypted;
if(version == 1) {
decrypted = SamouraiUtil.decrypt(payload, password, SamouraiUtil.DefaultPBKDF2Iterations);
} else if(version == 2) {
decrypted = SamouraiUtil.decryptSHA256(payload, password);
} else {
throw new ImportException("Unsupported backup version: " + version);
}
SamouraiBackup backup = gson.fromJson(decrypted, SamouraiBackup.class);
DeterministicSeed seed = new DeterministicSeed(Utils.hexToBytes(backup.wallet.seed), password, 0);
Keystore keystore = Keystore.fromSeed(seed, scriptType.getDefaultDerivation());
keystore.setLabel(getWalletModel().toDisplayString());
return keystore;
} catch(JsonParseException e) {
throw new ImportException("Failed to decrypt the wallet backup file, check if the password is correct.");
} catch(ImportException e) {
throw e;
} catch(Exception e) {
throw new ImportException("Error importing backup", e);
}
}
@Override
public boolean isKeystoreImportScannable() {
return false;
}
@Override
public boolean isEncrypted(File file) {
return true;
}
@Override
public String getName() {
return "Samourai Backup";
}
@Override
public WalletModel getWalletModel() {
return WalletModel.SAMOURAI;
}
private static class SamouraiBackup {
public SamouraiWallet wallet;
}
private static class SamouraiWallet {
public boolean testnet;
public String seed;
public String fingerprint;
}
}
@@ -108,7 +108,7 @@ public class SpecterDesktop implements WalletImport, WalletExport {
WalletModel walletModel = device.getWalletModel();
if(walletModel != null) {
keystore.setWalletModel(walletModel);
if(walletModel == WalletModel.TREZOR_1 || walletModel == WalletModel.TREZOR_T || walletModel == WalletModel.KEEPKEY ||
if(walletModel == WalletModel.TREZOR_1 || walletModel == WalletModel.TREZOR_T || walletModel == WalletModel.TREZOR_SAFE_3 || walletModel == WalletModel.KEEPKEY ||
walletModel == WalletModel.LEDGER_NANO_S || walletModel == WalletModel.LEDGER_NANO_X || walletModel == WalletModel.LEDGER_NANO_S_PLUS ||
walletModel == WalletModel.BITBOX_02 || walletModel == WalletModel.COLDCARD) {
keystore.setSource(KeystoreSource.HW_USB);
@@ -8,8 +8,6 @@ import com.sparrowwallet.drongo.wallet.StandardAccount;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.SparrowWallet;
import com.sparrowwallet.sparrow.soroban.Soroban;
import com.sparrowwallet.sparrow.whirlpool.Whirlpool;
import javafx.concurrent.ScheduledService;
import javafx.concurrent.Service;
import javafx.concurrent.Task;
@@ -21,6 +19,7 @@ import org.slf4j.LoggerFactory;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermission;
import java.nio.file.attribute.PosixFilePermissions;
import java.security.cert.Certificate;
@@ -174,15 +173,6 @@ public class Storage {
}
}
if(wallet.isWhirlpoolMasterWallet()) {
String walletId = getWalletId(wallet);
Whirlpool whirlpool = AppServices.getWhirlpoolServices().getWhirlpool(walletId);
whirlpool.setScode(wallet.getMasterMixConfig().getScode());
whirlpool.setHDWallet(getWalletId(wallet), copy);
Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId);
soroban.setHDWallet(copy);
}
StandardAccount standardAccount = wallet.getStandardAccountType();
if(standardAccount != null && standardAccount.getMinimumGapLimit() != null && wallet.gapLimit() == null) {
wallet.setGapLimit(standardAccount.getMinimumGapLimit());
@@ -227,8 +217,8 @@ public class Storage {
}
private void checkWalletNetwork(Wallet wallet) {
if(wallet.getNetwork() != null && wallet.getNetwork() != Network.get()) {
throw new IllegalStateException("Provided " + wallet.getNetwork() + " wallet is invalid on a " + Network.get() + " network. Use a " + wallet.getNetwork() + " configuration to load this wallet.");
if(wallet.getNetwork() != null && wallet.getNetwork() != Network.getCanonical()) {
throw new IllegalStateException("Provided " + wallet.getNetwork() + " wallet is invalid on a " + Network.getCanonical() + " network. Use a " + wallet.getNetwork() + " configuration to load this wallet.");
}
}
@@ -535,10 +525,23 @@ public class Storage {
return certsDir;
}
static File getSparrowDir() {
public static File getSparrowDir() {
File sparrowDir;
if(Network.get() != Network.MAINNET) {
sparrowDir = new File(getSparrowHome(), Network.get().getName());
Network network = Network.get();
if(network != Network.MAINNET) {
sparrowDir = new File(getSparrowHome(), network.getHome());
if(!network.getName().equals(network.getHome()) && !sparrowDir.exists()) {
File networkNameDir = new File(getSparrowHome(), network.getName());
if(networkNameDir.exists() && networkNameDir.isDirectory() && !Files.isSymbolicLink(networkNameDir.toPath())) {
try {
if(networkNameDir.renameTo(sparrowDir) && !isWindows()) {
Files.createSymbolicLink(networkNameDir.toPath(), Path.of(sparrowDir.getName()));
}
} catch(Exception e) {
log.debug("Error creating symlink from " + networkNameDir.getAbsolutePath() + " to " + sparrowDir.getName(), e);
}
}
}
} else {
sparrowDir = getSparrowHome();
}
@@ -547,15 +550,36 @@ public class Storage {
createOwnerOnlyDirectory(sparrowDir);
}
if(!network.getName().equals(network.getHome()) && !isWindows()) {
try {
Path networkNamePath = getSparrowHome().toPath().resolve(network.getName());
if(Files.isSymbolicLink(networkNamePath)) {
Path symlinkTarget = getSparrowHome().toPath().resolve(Files.readSymbolicLink(networkNamePath));
if(!Files.isSameFile(sparrowDir.toPath(), symlinkTarget)) {
Files.delete(networkNamePath);
Files.createSymbolicLink(networkNamePath, Path.of(sparrowDir.getName()));
}
} else if(!Files.exists(networkNamePath)) {
Files.createSymbolicLink(networkNamePath, Path.of(sparrowDir.getName()));
}
} catch(Exception e) {
log.debug("Error updating symlink from " + network.getName() + " to " + sparrowDir.getName(), e);
}
}
return sparrowDir;
}
public static File getSparrowHome() {
if(System.getProperty(SparrowWallet.APP_HOME_PROPERTY) != null) {
return getSparrowHome(false);
}
public static File getSparrowHome(boolean useDefault) {
if(!useDefault && System.getProperty(SparrowWallet.APP_HOME_PROPERTY) != null) {
return new File(System.getProperty(SparrowWallet.APP_HOME_PROPERTY));
}
if(Platform.getCurrent() == Platform.WINDOWS) {
if(isWindows()) {
return new File(getHomeDir(), WINDOWS_SPARROW_DIR);
}
@@ -563,7 +587,7 @@ public class Storage {
}
static File getHomeDir() {
if(Platform.getCurrent() == Platform.WINDOWS) {
if(isWindows()) {
return new File(System.getenv("APPDATA"));
}
@@ -572,7 +596,7 @@ public class Storage {
public static boolean createOwnerOnlyDirectory(File directory) {
try {
if(Platform.getCurrent() == Platform.WINDOWS) {
if(isWindows()) {
Files.createDirectories(directory.toPath());
return true;
}
@@ -590,7 +614,7 @@ public class Storage {
public static boolean createOwnerOnlyFile(File file) {
try {
if(Platform.getCurrent() == Platform.WINDOWS) {
if(isWindows()) {
Files.createFile(file.toPath());
return true;
}
@@ -621,6 +645,10 @@ public class Storage {
return ownerOnly;
}
private static boolean isWindows() {
return Platform.getCurrent() == Platform.WINDOWS;
}
public static class LoadWalletService extends Service<WalletAndKey> {
private final Storage storage;
private final SecureString password;
@@ -1,8 +1,10 @@
package com.sparrowwallet.sparrow.io;
import com.csvreader.CsvReader;
import com.google.gson.Gson;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.OutputDescriptor;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
@@ -122,7 +124,7 @@ public class WalletLabels implements WalletImport, WalletExport {
@Override
public String getWalletImportDescription() {
return "Imports a file containing labels in the BIP329 standard format to the currently selected wallet.";
return "Imports a file containing labels in the BIP329 standard (or Electrum history CSV) format to the currently selected wallet.";
}
@Override
@@ -141,7 +143,17 @@ public class WalletLabels implements WalletImport, WalletExport {
try {
label = gson.fromJson(line, Label.class);
} catch(Exception e) {
continue;
//Try parse Electrum history CSV, or any CSV with txid,label entries
try {
CsvReader csvReader = new CsvReader(new StringReader(line));
if(csvReader.readRecord() && csvReader.getColumnCount() > 1 && csvReader.get(0).length() == 64 && Utils.isHex(csvReader.get(0))) {
label = new Label(Type.tx, csvReader.get(0), csvReader.get(1), null, null);
} else {
continue;
}
} catch(Exception ex) {
continue;
}
}
if(label == null || label.type == null || label.ref == null) {
@@ -10,7 +10,6 @@ import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletModel;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.UnitFormat;
import com.sparrowwallet.sparrow.control.EntryCell;
import com.sparrowwallet.sparrow.net.ExchangeSource;
import com.sparrowwallet.sparrow.wallet.Entry;
import com.sparrowwallet.sparrow.wallet.TransactionEntry;
@@ -21,14 +20,21 @@ import org.apache.commons.lang3.time.DateUtils;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.*;
import java.util.stream.Collectors;
public class WalletTransactions implements WalletExport {
private static final long ONE_DAY = 24*60*60*1000L;
public static final DateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
private final WalletForm walletForm;
static {
DATE_FORMAT.setTimeZone(TimeZone.getTimeZone("UTC"));
}
public WalletTransactions(WalletForm walletForm) {
this.walletForm = walletForm;
}
@@ -70,7 +76,7 @@ public class WalletTransactions implements WalletExport {
try {
CsvWriter writer = new CsvWriter(outputStream, ',', StandardCharsets.UTF_8);
writer.write("Date");
writer.write("Date (UTC)");
writer.write("Label");
writer.write("Value");
writer.write("Balance");
@@ -83,7 +89,7 @@ public class WalletTransactions implements WalletExport {
for(Entry entry : walletTransactionsEntry.getChildren()) {
TransactionEntry txEntry = (TransactionEntry)entry;
writer.write(txEntry.getBlockTransaction().getDate() == null ? "Unconfirmed" : EntryCell.DATE_FORMAT.format(txEntry.getBlockTransaction().getDate()));
writer.write(txEntry.getBlockTransaction().getDate() == null ? "Unconfirmed" : DATE_FORMAT.format(txEntry.getBlockTransaction().getDate()));
writer.write(txEntry.getLabel());
writer.write(getCoinValue(bitcoinUnit, txEntry.getValue()));
writer.write(getCoinValue(bitcoinUnit, txEntry.getBalance()));
@@ -0,0 +1,3 @@
package com.sparrowwallet.sparrow.io.bbqr;
public record BBQR(BBQRType type, byte[] data) {}
@@ -0,0 +1,181 @@
package com.sparrowwallet.sparrow.io.bbqr;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.psbt.PSBT;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.TreeMap;
public class BBQRDecoder {
private static final Logger log = LoggerFactory.getLogger(BBQRDecoder.class);
private final Map<Integer, byte[]> receivedParts = new TreeMap<>();
private int totalParts;
private BBQRType type;
private Result result;
public static boolean isBBQRFragment(String part) {
try {
BBQRHeader.fromString(part);
return true;
} catch(Exception e) {
return false;
}
}
public void receivePart(String part) {
try {
BBQRHeader header = BBQRHeader.fromString(part);
totalParts = header.seqTotal();
type = header.type();
byte[] partData = header.decode(part);
receivedParts.put(header.seqNumber(), partData);
if(receivedParts.size() == totalParts) {
byte[] data = concatParts();
data = header.inflate(data);
if(type == BBQRType.PSBT) {
result = new Result(new PSBT(data));
} else if(type == BBQRType.TXN) {
result = new Result(new Transaction(data));
} else if(type == BBQRType.JSON || type == BBQRType.UNICODE) {
result = new Result(type, new String(data, StandardCharsets.UTF_8));
} else {
result = new Result(type, data);
}
}
} catch(Exception e) {
log.error("Could not parse received QR of type " + type, e);
result = new Result(ResultType.FAILURE, e.getMessage());
}
}
private byte[] concatParts() {
int totalLength = 0;
for(byte[] part : receivedParts.values()) {
totalLength += part.length;
}
byte[] data = new byte[totalLength];
int index = 0;
for(byte[] part : receivedParts.values()) {
System.arraycopy(part, 0, data, index, part.length);
index += part.length;
}
return data;
}
public int getProcessedPartsCount() {
return receivedParts.size();
}
public double getPercentComplete() {
if(totalParts == 0) {
return 0d;
}
return (double)getProcessedPartsCount() / totalParts;
}
public Result getResult() {
return result;
}
public static class Result {
private final ResultType resultType;
private final BBQRType bbqrType;
private final PSBT psbt;
private final Transaction transaction;
private final String strData;
private final byte[] data;
private final String error;
public Result(ResultType resultType, String error) {
this.resultType = resultType;
this.bbqrType = null;
this.psbt = null;
this.transaction = null;
this.strData = null;
this.data = null;
this.error = error;
}
public Result(PSBT psbt) {
this.resultType = ResultType.SUCCESS;
this.bbqrType = BBQRType.PSBT;
this.psbt = psbt;
this.transaction = null;
this.strData = null;
this.data = null;
this.error = null;
}
public Result(Transaction transaction) {
this.resultType = ResultType.SUCCESS;
this.bbqrType = BBQRType.TXN;
this.psbt = null;
this.transaction = transaction;
this.strData = null;
this.data = null;
this.error = null;
}
public Result(BBQRType bbqrType, String strData) {
this.resultType = ResultType.SUCCESS;
this.bbqrType = bbqrType;
this.psbt = null;
this.transaction = null;
this.strData = strData;
this.data = null;
this.error = null;
}
public Result(BBQRType bbqrType, byte[] data) {
this.resultType = ResultType.SUCCESS;
this.bbqrType = bbqrType;
this.psbt = null;
this.transaction = null;
this.strData = null;
this.data = data;
this.error = null;
}
public ResultType getResultType() {
return resultType;
}
public BBQRType getBbqrType() {
return bbqrType;
}
public PSBT getPsbt() {
return psbt;
}
public Transaction getTransaction() {
return transaction;
}
public byte[] getData() {
return data;
}
public String toString() {
return strData;
}
public String getError() {
return error;
}
}
public enum ResultType {
SUCCESS, FAILURE;
}
}
@@ -0,0 +1,71 @@
package com.sparrowwallet.sparrow.io.bbqr;
import java.util.ArrayList;
import java.util.List;
public class BBQREncoder {
private final String[] parts;
private int partIndex;
public BBQREncoder(BBQRType bbqrType, BBQREncoding bbqrEncoding, byte[] data, int maxFragmentLength, int firstSeqNum) {
this.parts = encode(bbqrType, bbqrEncoding, data, maxFragmentLength).toArray(new String[0]);
this.partIndex = firstSeqNum;
}
public boolean isSinglePart() {
return parts.length == 1;
}
public String nextPart() {
String currentPart = parts[partIndex];
partIndex++;
if(partIndex > parts.length - 1) {
partIndex = 0;
}
return currentPart;
}
public int getNumParts() {
return parts.length;
}
private List<String> encode(BBQRType type, BBQREncoding desiredEncoding, byte[] data, int desiredChunkSize) {
String encoded;
BBQREncoding encoding = desiredEncoding;
try {
encoded = encoding.encode(encoding.deflate(data));
if(encoding == BBQREncoding.ZLIB) {
String uncompressed = BBQREncoding.BASE32.encode(data);
if(encoded.length() > uncompressed.length()) {
throw new BBQREncodingException("Compressed data was larger than uncompressed data");
}
}
} catch(BBQREncodingException e) {
encoding = BBQREncoding.BASE32;
encoded = BBQREncoding.BASE32.encode(data);
}
int inputLength = encoded.length();
int numChunks = (inputLength + desiredChunkSize - 1) / desiredChunkSize;
int chunkSize = numChunks == 1 ? desiredChunkSize : (int)Math.ceil((double)inputLength / numChunks);
int modulo = chunkSize % encoding.getPartModulo();
if(modulo > 0) {
chunkSize += (encoding.getPartModulo() - modulo);
}
List<String> chunks = new ArrayList<>();
int startIndex = 0;
for(int i = 0; i < numChunks; i ++) {
int endIndex = Math.min(startIndex + chunkSize, encoded.length());
BBQRHeader bbqrHeader = new BBQRHeader(encoding, type, numChunks, i);
String chunk = bbqrHeader + encoded.substring(startIndex, endIndex);
startIndex = endIndex;
chunks.add(chunk);
}
return chunks;
}
}
@@ -0,0 +1,122 @@
package com.sparrowwallet.sparrow.io.bbqr;
import com.google.common.io.BaseEncoding;
import com.jcraft.jzlib.*;
import com.sparrowwallet.drongo.Utils;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.util.Locale;
public enum BBQREncoding {
HEX("H") {
@Override
public String encode(byte[] data) throws BBQREncodingException {
return Utils.bytesToHex(data).toUpperCase(Locale.ROOT);
}
@Override
public byte[] decode(String part) throws BBQREncodingException {
return Utils.hexToBytes(part);
}
@Override
public int getPartModulo() {
return 2;
}
}, BASE32("2") {
@Override
public String encode(byte[] data) throws BBQREncodingException {
return BaseEncoding.base32().encode(data).replaceAll("=+$", "");
}
@Override
public byte[] decode(String part) throws BBQREncodingException {
return BaseEncoding.base32().decode(part);
}
@Override
public int getPartModulo() {
return 8;
}
}, ZLIB("Z") {
@Override
public String encode(byte[] data) throws BBQREncodingException {
return BASE32.encode(data);
}
@Override
public byte[] deflate(byte[] data) throws BBQREncodingException {
try {
Deflater deflater = new Deflater(JZlib.Z_BEST_COMPRESSION, 10, true);
ByteArrayOutputStream out = new ByteArrayOutputStream();
DeflaterOutputStream zOut = new DeflaterOutputStream(out, deflater);
zOut.write(data);
zOut.close();
return out.toByteArray();
} catch(Exception e) {
throw new BBQREncodingException("Error deflating with zlib", e);
}
}
@Override
public byte[] decode(String part) throws BBQREncodingException {
return BASE32.decode(part);
}
@Override
public byte[] inflate(byte[] data) throws BBQREncodingException {
try {
Inflater inflater = new Inflater(10, true);
ByteArrayInputStream in = new ByteArrayInputStream(data);
InflaterInputStream zIn = new InflaterInputStream(in, inflater);
byte[] decoded = zIn.readAllBytes();
zIn.close();
return decoded;
} catch(Exception e) {
throw new BBQREncodingException("Error inflating with zlib", e);
}
}
@Override
public int getPartModulo() {
return 8;
}
};
public static BBQREncoding fromString(String code) {
for(BBQREncoding encoding : values()) {
if(encoding.getCode().equals(code)) {
return encoding;
}
}
throw new IllegalArgumentException("Could not find encoding for code " + code);
}
private final String code;
BBQREncoding(String code) {
this.code = code;
}
public String getCode() {
return code;
}
public byte[] deflate(byte[] data) throws BBQREncodingException {
return data;
}
public byte[] inflate(byte[] data) throws BBQREncodingException {
return data;
}
public abstract String encode(byte[] data) throws BBQREncodingException;
public abstract byte[] decode(String part) throws BBQREncodingException;
public abstract int getPartModulo();
}
@@ -0,0 +1,11 @@
package com.sparrowwallet.sparrow.io.bbqr;
public class BBQREncodingException extends BBQRException {
public BBQREncodingException(String message) {
super(message);
}
public BBQREncodingException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,11 @@
package com.sparrowwallet.sparrow.io.bbqr;
public class BBQRException extends RuntimeException {
public BBQRException(String message) {
super(message);
}
public BBQRException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,44 @@
package com.sparrowwallet.sparrow.io.bbqr;
import java.math.BigInteger;
public record BBQRHeader(BBQREncoding encoding, BBQRType type, int seqTotal, int seqNumber) {
public static final String HEADER = "B$";
public String toString() {
return HEADER + encoding.getCode() + type.getCode() + encodeToBase36(seqTotal) + encodeToBase36(seqNumber);
}
public byte[] decode(String part) {
return encoding.decode(part.substring(8));
}
public byte[] inflate(byte[] data) {
return encoding.inflate(data);
}
public static BBQRHeader fromString(String part) {
if(part.length() < 8) {
throw new IllegalArgumentException("Part too short");
}
if(!HEADER.equals(part.substring(0, 2))) {
throw new IllegalArgumentException("Part does not start with " + HEADER);
}
BBQREncoding e = BBQREncoding.fromString(part.substring(2, 3));
BBQRType t = BBQRType.fromString(part.substring(3, 4));
return new BBQRHeader(e, t, decodeFromBase36(part.substring(4, 6)), decodeFromBase36(part.substring(6, 8)));
}
private String encodeToBase36(int number) {
String base36Encoded = new BigInteger(String.valueOf(number)).toString(36);
return String.format("%2s", base36Encoded).replace(' ', '0');
}
private static int decodeFromBase36(String base36) {
BigInteger bigInteger = new BigInteger(base36, 36);
return bigInteger.intValue();
}
}
@@ -0,0 +1,25 @@
package com.sparrowwallet.sparrow.io.bbqr;
public enum BBQRType {
PSBT("P"), TXN("T"), JSON("J"), CBOR("C"), UNICODE("U"), BINARY("B"), EXECUTABLE("X");
private final String code;
BBQRType(String code) {
this.code = code;
}
public String getCode() {
return code;
}
public static BBQRType fromString(String code) {
for(BBQRType type : values()) {
if(type.getCode().equals(code)) {
return type;
}
}
throw new IllegalArgumentException("Could not find type for code " + code);
}
}
@@ -15,6 +15,7 @@ public class CardStatus extends CardResponse {
String ver;
BigInteger birth;
boolean tapsigner;
boolean satschip;
List<BigInteger> path;
BigInteger num_backups;
List<BigInteger> slots;
@@ -24,7 +25,7 @@ public class CardStatus extends CardResponse {
boolean testnet;
public boolean isInitialized() {
return (getCardType() == WalletModel.TAPSIGNER && path != null) || (getCardType() == WalletModel.SATSCARD && addr != null);
return ((getCardType() == WalletModel.TAPSIGNER || getCardType() == WalletModel.SATSCHIP) && path != null) || (getCardType() == WalletModel.SATSCARD && addr != null);
}
public String getIdentifier() {
@@ -42,11 +43,11 @@ public class CardStatus extends CardResponse {
}
public boolean requiresBackup() {
return num_backups == null || num_backups.intValue() == 0;
return !satschip && (num_backups == null || num_backups.intValue() == 0);
}
public WalletModel getCardType() {
return tapsigner ? WalletModel.TAPSIGNER : WalletModel.SATSCARD;
return satschip ? WalletModel.SATSCHIP : (tapsigner ? WalletModel.TAPSIGNER : WalletModel.SATSCARD);
}
public int getCurrentSlot() {
@@ -72,6 +73,7 @@ public class CardStatus extends CardResponse {
", ver='" + ver + '\'' +
", birth=" + birth +
", tapsigner=" + tapsigner +
", satschip=" + satschip +
", path=" + path +
", num_backups=" + num_backups +
", slots=" + slots +
@@ -32,10 +32,6 @@ public class CkCardApi extends CardApi {
private final CardProtocol cardProtocol;
private String cvc;
public CkCardApi(String cvc) throws CardException {
this(WalletModel.TAPSIGNER, cvc);
}
public CkCardApi(WalletModel cardType, String cvc) throws CardException {
this.cardType = cardType;
this.cardProtocol = new CardProtocol();
@@ -137,6 +133,8 @@ public class CkCardApi extends CardApi {
public Service<Void> getInitializationService(byte[] entropy, StringProperty messageProperty) {
if(cardType == WalletModel.TAPSIGNER) {
return new CardImportPane.CardInitializationService(new Tapsigner(), cvc, entropy, messageProperty);
} else if(cardType == WalletModel.SATSCHIP) {
return new CardImportPane.CardInitializationService(new Satschip(), cvc, entropy, messageProperty);
}
return new CardInitializationService(entropy, messageProperty);
@@ -144,6 +142,10 @@ public class CkCardApi extends CardApi {
@Override
public Service<Keystore> getImportService(List<ChildNumber> derivation, StringProperty messageProperty) {
if(cardType == WalletModel.SATSCHIP) {
return new CardImportPane.CardImportService(new Satschip(), cvc, derivation, messageProperty);
}
return new CardImportPane.CardImportService(new Tapsigner(), cvc, derivation, messageProperty);
}
@@ -155,11 +157,11 @@ public class CkCardApi extends CardApi {
ExtendedKey derivedXpubkey = ExtendedKey.fromDescriptor(Base58.encodeChecked(derivedXpub.xpub));
Keystore keystore = new Keystore();
keystore.setLabel(WalletModel.TAPSIGNER.toDisplayString());
keystore.setLabel(cardType.toDisplayString());
keystore.setKeyDerivation(keyDerivation);
keystore.setSource(KeystoreSource.HW_AIRGAPPED);
keystore.setExtendedPublicKey(derivedXpubkey);
keystore.setWalletModel(WalletModel.TAPSIGNER);
keystore.setWalletModel(cardType);
return keystore;
}
@@ -404,7 +406,7 @@ public class CkCardApi extends CardApi {
@Override
protected PSBT call() throws Exception {
CardStatus cardStatus = getStatus();
if(cardStatus.getCardType() != WalletModel.TAPSIGNER) {
if(cardStatus.getCardType() != WalletModel.TAPSIGNER && cardStatus.getCardType() != WalletModel.SATSCHIP) {
throw new IllegalStateException("Please use a " + WalletModel.TAPSIGNER.toDisplayString() + " to sign transactions.");
}
@@ -465,7 +467,7 @@ public class CkCardApi extends CardApi {
@Override
protected String call() throws Exception {
CardStatus cardStatus = getStatus();
if(cardStatus.getCardType() != WalletModel.TAPSIGNER) {
if(cardStatus.getCardType() != WalletModel.TAPSIGNER && cardStatus.getCardType() != WalletModel.SATSCHIP) {
throw new IllegalStateException("Please use a " + WalletModel.TAPSIGNER.toDisplayString() + " to sign messages.");
}
@@ -0,0 +1,20 @@
package com.sparrowwallet.sparrow.io.ckcard;
import com.sparrowwallet.drongo.wallet.WalletModel;
public class Satschip extends Tapsigner {
@Override
public String getKeystoreImportDescription(int account) {
return "Import the keystore from your Satschip by placing it on the card reader.";
}
@Override
public String getName() {
return "Satschip";
}
@Override
public WalletModel getWalletModel() {
return WalletModel.SATSCHIP;
}
}
@@ -16,7 +16,7 @@ public class Tapsigner implements KeystoreCardImport {
public boolean isInitialized() throws CardException {
CkCardApi cardApi = null;
try {
cardApi = new CkCardApi(null);
cardApi = new CkCardApi(getWalletModel(), null);
return cardApi.isInitialized();
} finally {
if(cardApi != null) {
@@ -37,7 +37,7 @@ public class Tapsigner implements KeystoreCardImport {
CkCardApi cardApi = null;
try {
cardApi = new CkCardApi(pin);
cardApi = new CkCardApi(getWalletModel(), pin);
CardStatus cardStatus = cardApi.getStatus();
if(cardStatus.isInitialized()) {
throw new IllegalStateException("Card is already initialized.");
@@ -63,7 +63,7 @@ public class Tapsigner implements KeystoreCardImport {
CkCardApi cardApi = null;
try {
cardApi = new CkCardApi(pin);
cardApi = new CkCardApi(getWalletModel(), pin);
CardStatus cardStatus = cardApi.getStatus();
if(!cardStatus.isInitialized()) {
throw new IllegalStateException("Card is not initialized.");
@@ -256,7 +256,10 @@ public class DbPersistence implements Persistence {
}
for(Sha256Hash txid : referencedTxIds) {
BlockTransaction blkTx = wallet.getTransactions().get(txid);
blockTransactionDao.addOrUpdate(wallet, txid, blkTx);
//May be null for a nested wallet if still updating
if(blkTx != null) {
blockTransactionDao.addOrUpdate(wallet, txid, blkTx);
}
}
if(!dirtyPersistables.clearHistory) {
DetachedLabelDao detachedLabelDao = handle.attach(DetachedLabelDao.class);
@@ -192,7 +192,7 @@ public class SatoCardApi extends CardApi {
String fullpath = KeyDerivation.writePath(derivation);
cardProtocol.cardVerifyPIN(0, pin);
// 2FA is optionnal, currently not supported in sparrow as it requires to send 2FA to a mobile app through a server.
// 2FA is optional, currently not supported in sparrow as it requires to send 2FA to a mobile app through a server.
SatoCardStatus cardStatus = this.getStatus();
if(cardStatus.needs2FA()) {
throw new CardException("Satochip 2FA is not (yet) supported within Sparrow");
@@ -46,7 +46,7 @@ public class SatochipCommandSet {
}
/**
* Returns the application info as stored from the last sent SELECT command. Returns null if no succesful SELECT
* Returns the application info as stored from the last sent SELECT command. Returns null if no successful SELECT
* command has been sent using this command set.
*
* @return the application info object
@@ -5,6 +5,7 @@ import com.sparrowwallet.sparrow.control.CardImportPane;
import com.sparrowwallet.sparrow.control.FileKeystoreImportPane;
import com.sparrowwallet.sparrow.control.TitledDescriptionPane;
import com.sparrowwallet.sparrow.io.*;
import com.sparrowwallet.sparrow.io.ckcard.Satschip;
import com.sparrowwallet.sparrow.io.ckcard.Tapsigner;
import com.sparrowwallet.sparrow.io.satochip.Satochip;
import javafx.fxml.FXML;
@@ -39,7 +40,7 @@ public class HwAirgappedController extends KeystoreImportDetailController {
}
}
List<KeystoreCardImport> cardImporters = List.of(new Tapsigner(), new Satochip());
List<KeystoreCardImport> cardImporters = List.of(new Tapsigner(), new Satochip(), new Satschip());
for(KeystoreCardImport importer : cardImporters) {
if(!importer.isDeprecated() || Config.get().isShowDeprecatedImportExport()) {
CardImportPane importPane = new CardImportPane(getMasterController().getWallet(), importer, getMasterController().getRequiredDerivation());
@@ -31,6 +31,7 @@ public class KeystoreImportController implements Initializable {
@FXML
private StackPane importPane;
private KeyDerivation defaultDerivation;
private KeyDerivation requiredDerivation;
private WalletModel requiredModel;
@@ -110,6 +111,14 @@ public class KeystoreImportController implements Initializable {
}
}
public KeyDerivation getDefaultDerivation() {
return defaultDerivation;
}
public void setDefaultDerivation(KeyDerivation defaultDerivation) {
this.defaultDerivation = defaultDerivation;
}
public KeyDerivation getRequiredDerivation() {
return requiredDerivation;
}
@@ -29,7 +29,7 @@ public class KeystoreImportDialog extends Dialog<Keystore> {
this(wallet, initialSource, null, null, false);
}
public KeystoreImportDialog(Wallet wallet, KeystoreSource initialSource, KeyDerivation requiredDerivation, WalletModel requiredModel, boolean restrictSource) {
public KeystoreImportDialog(Wallet wallet, KeystoreSource initialSource, KeyDerivation currentDerivation, WalletModel currentModel, boolean restrictImport) {
EventManager.get().register(this);
setOnCloseRequest(event -> {
EventManager.get().unregister(this);
@@ -43,12 +43,15 @@ public class KeystoreImportDialog extends Dialog<Keystore> {
dialogPane.setContent(Borders.wrap(ksiLoader.load()).emptyBorder().buildAll());
keystoreImportController = ksiLoader.getController();
keystoreImportController.initializeView(wallet);
keystoreImportController.selectSource(initialSource, restrictSource);
keystoreImportController.setRequiredDerivation(requiredDerivation);
keystoreImportController.setRequiredModel(requiredModel);
keystoreImportController.selectSource(initialSource, restrictImport);
keystoreImportController.setDefaultDerivation(currentDerivation);
if(restrictImport) {
keystoreImportController.setRequiredDerivation(currentDerivation);
keystoreImportController.setRequiredModel(currentModel);
}
final ButtonType watchOnlyButtonType = new javafx.scene.control.ButtonType(Network.get().getXpubHeader().getDisplayName() + " / Watch Only Wallet", ButtonBar.ButtonData.LEFT);
if(!restrictSource) {
if(!restrictImport) {
dialogPane.getButtonTypes().add(watchOnlyButtonType);
}
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
@@ -27,9 +27,9 @@ public class SwController extends KeystoreImportDetailController {
if(importer instanceof KeystoreFileImport) {
importPane = new FileKeystoreImportPane(getMasterController().getWallet(), (KeystoreFileImport)importer, getMasterController().getRequiredDerivation());
} else if(importer instanceof KeystoreMnemonicImport) {
importPane = new MnemonicKeystoreImportPane(getMasterController().getWallet(), (KeystoreMnemonicImport)importer);
importPane = new MnemonicKeystoreImportPane(getMasterController().getWallet(), (KeystoreMnemonicImport)importer, getMasterController().getDefaultDerivation());
} else if(importer instanceof KeystoreXprvImport) {
importPane = new XprvKeystoreImportPane(getMasterController().getWallet(), (KeystoreXprvImport)importer);
importPane = new XprvKeystoreImportPane(getMasterController().getWallet(), (KeystoreXprvImport)importer, getMasterController().getDefaultDerivation());
} else {
throw new IllegalArgumentException("Could not create ImportPane for importer of type " + importer.getClass());
}
@@ -5,8 +5,8 @@ import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.nightjar.http.JavaHttpException;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.net.http.client.HttpResponseException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -46,7 +46,7 @@ public enum BroadcastSource {
@Override
public List<Network> getSupportedNetworks() {
return List.of(Network.MAINNET, Network.TESTNET, Network.SIGNET);
return List.of(Network.MAINNET, Network.TESTNET, Network.SIGNET, Network.TESTNET4);
}
protected URL getURL(HostAndPort proxy) throws MalformedURLException {
@@ -56,6 +56,8 @@ public enum BroadcastSource {
return new URL(getBaseUrl(proxy) + "/testnet/api/tx");
} else if(Network.get() == Network.SIGNET) {
return new URL(getBaseUrl(proxy) + "/signet/api/tx");
} else if(Network.get() == Network.TESTNET4) {
return new URL(getBaseUrl(proxy) + "/testnet4/api/tx");
} else {
throw new IllegalStateException("Cannot broadcast transaction to " + getName() + " on network " + Network.get());
}
@@ -77,6 +79,8 @@ public enum BroadcastSource {
return new URL(getBaseUrl(proxy) + "/api/tx");
} else if(Network.get() == Network.TESTNET) {
return new URL(getBaseUrl(proxy) + "/testnet/api/tx");
} else if(Network.get() == Network.SIGNET) {
return new URL(getBaseUrl(proxy) + "/signet/api/tx");
} else {
throw new IllegalStateException("Cannot broadcast transaction to " + getName() + " on network " + Network.get());
}
@@ -158,7 +162,7 @@ public enum BroadcastSource {
} catch(Exception e) {
throw new BroadcastException("Could not retrieve txid from broadcast, server returned: " + response);
}
} catch(JavaHttpException e) {
} catch(HttpResponseException e) {
throw new BroadcastException("Could not broadcast transaction, server returned " + e.getStatusCode() + ": " + e.getResponseBody());
} catch(Exception e) {
log.error("Could not post transaction via " + getName(), e);
@@ -119,7 +119,7 @@ public class Bwt {
*/
private void start(Collection<String> outputDescriptors, Collection<String> addresses, Integer rescanSince, Boolean forceRescan, Integer gapLimit, CallbackNotifier callback) {
BwtConfig bwtConfig = new BwtConfig();
bwtConfig.network = Network.get() == Network.MAINNET ? "bitcoin" : Network.get().getName();
bwtConfig.network = Network.get() == Network.MAINNET ? "bitcoin" : Network.getCanonical().getName();
if(!outputDescriptors.isEmpty()) {
bwtConfig.descriptors = outputDescriptors;
@@ -812,12 +812,18 @@ public class ElectrumServer {
return transactionMap;
}
public Map<Integer, Double> getFeeEstimates(List<Integer> targetBlocks) throws ServerException {
public Map<Integer, Double> getFeeEstimates(List<Integer> targetBlocks, boolean useCached) throws ServerException {
Map<Integer, Double> targetBlocksFeeRatesSats = getDefaultFeeEstimates(targetBlocks);
FeeRatesSource feeRatesSource = Config.get().getFeeRatesSource();
feeRatesSource = (feeRatesSource == null ? FeeRatesSource.MEMPOOL_SPACE : feeRatesSource);
if(Network.get().equals(Network.MAINNET)) {
if(!feeRatesSource.isExternal()) {
targetBlocksFeeRatesSats.putAll(feeRatesSource.getBlockTargetFeeRates(targetBlocksFeeRatesSats));
} else if(useCached) {
if(AppServices.getTargetBlockFeeRates() != null) {
targetBlocksFeeRatesSats.putAll(AppServices.getTargetBlockFeeRates());
}
} else if(Network.get().equals(Network.MAINNET)) {
targetBlocksFeeRatesSats.putAll(feeRatesSource.getBlockTargetFeeRates(targetBlocksFeeRatesSats));
}
@@ -1204,7 +1210,7 @@ public class ElectrumServer {
String banner = electrumServer.getServerBanner();
Map<Integer, Double> blockTargetFeeRates = electrumServer.getFeeEstimates(AppServices.TARGET_BLOCKS_RANGE);
Map<Integer, Double> blockTargetFeeRates = electrumServer.getFeeEstimates(AppServices.TARGET_BLOCKS_RANGE, true);
Set<MempoolRateSize> mempoolRateSizes = electrumServer.getMempoolRateSizes();
feeRatesRetrievedAt = System.currentTimeMillis();
@@ -1220,7 +1226,7 @@ public class ElectrumServer {
long elapsed = System.currentTimeMillis() - feeRatesRetrievedAt;
if(elapsed > FEE_RATES_PERIOD) {
Map<Integer, Double> blockTargetFeeRates = electrumServer.getFeeEstimates(AppServices.TARGET_BLOCKS_RANGE);
Map<Integer, Double> blockTargetFeeRates = electrumServer.getFeeEstimates(AppServices.TARGET_BLOCKS_RANGE, false);
Set<MempoolRateSize> mempoolRateSizes = electrumServer.getMempoolRateSizes();
feeRatesRetrievedAt = System.currentTimeMillis();
return new FeeRatesUpdatedEvent(blockTargetFeeRates, mempoolRateSizes);
@@ -1679,9 +1685,8 @@ public class ElectrumServer {
return new Task<>() {
protected FeeRatesUpdatedEvent call() throws ServerException {
ElectrumServer electrumServer = new ElectrumServer();
Map<Integer, Double> blockTargetFeeRates = electrumServer.getFeeEstimates(AppServices.TARGET_BLOCKS_RANGE);
Set<MempoolRateSize> mempoolRateSizes = electrumServer.getMempoolRateSizes();
return new FeeRatesUpdatedEvent(blockTargetFeeRates, mempoolRateSizes);
Map<Integer, Double> blockTargetFeeRates = electrumServer.getFeeEstimates(AppServices.TARGET_BLOCKS_RANGE, false);
return new FeeRatesUpdatedEvent(blockTargetFeeRates, null);
}
};
}
@@ -1702,7 +1707,7 @@ public class ElectrumServer {
for(int i = 0; i < wallets.size(); i++) {
Wallet wallet = wallets.get(i);
updateProgress(i, wallets.size() + StandardAccount.values().length);
updateProgress(i, wallets.size() + StandardAccount.DISCOVERY_ACCOUNTS.size());
Map<WalletNode, Set<BlockTransactionHash>> nodeTransactionMap = new TreeMap<>();
electrumServer.getReferences(wallet, wallet.getNode(KeyPurpose.RECEIVE).getChildren(), nodeTransactionMap, 0);
if(nodeTransactionMap.values().stream().anyMatch(blockTransactionHashes -> !blockTransactionHashes.isEmpty())) {
@@ -1721,7 +1726,7 @@ public class ElectrumServer {
foundAccounts.add(standardAccount);
}
}
updateProgress(i + j, wallets.size() + StandardAccount.values().length);
updateProgress(i + j, wallets.size() + StandardAccount.DISCOVERY_ACCOUNTS.size());
}
for(StandardAccount standardAccount : foundAccounts) {
@@ -1743,7 +1748,7 @@ public class ElectrumServer {
}
List<StandardAccount> accounts = new ArrayList<>();
for(StandardAccount account : StandardAccount.values()) {
for(StandardAccount account : StandardAccount.DISCOVERY_ACCOUNTS) {
if(account != StandardAccount.ACCOUNT_0 && (!StandardAccount.isWhirlpoolAccount(account) || wallet.getScriptType() == ScriptType.P2WPKH)) {
accounts.add(account);
}
@@ -1,8 +1,8 @@
package com.sparrowwallet.sparrow.net;
import com.sparrowwallet.nightjar.http.JavaHttpException;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.event.ExchangeRatesUpdatedEvent;
import com.sparrowwallet.sparrow.net.http.client.HttpResponseException;
import javafx.concurrent.ScheduledService;
import javafx.concurrent.Service;
import javafx.concurrent.Task;
@@ -107,7 +107,7 @@ public enum ExchangeSource {
if(log.isDebugEnabled()) {
log.warn("Error retrieving historical currency rates", e);
} else {
if(e instanceof JavaHttpException javaHttpException && javaHttpException.getStatusCode() == 404) {
if(e instanceof HttpResponseException httpException && httpException.getStatusCode() == 404) {
log.warn("Error retrieving historical currency rates (" + e.getMessage() + "). BTC-" + currency.getCurrencyCode() + " may not be supported by " + this);
} else {
log.warn("Error retrieving historical currency rates (" + e.getMessage() + ")");
@@ -9,27 +9,27 @@ import java.util.LinkedHashMap;
import java.util.Map;
public enum FeeRatesSource {
ELECTRUM_SERVER("Server") {
ELECTRUM_SERVER("Server", false) {
@Override
public Map<Integer, Double> getBlockTargetFeeRates(Map<Integer, Double> defaultblockTargetFeeRates) {
return Collections.emptyMap();
}
},
MEMPOOL_SPACE("mempool.space") {
MEMPOOL_SPACE("mempool.space", true) {
@Override
public Map<Integer, Double> getBlockTargetFeeRates(Map<Integer, Double> defaultblockTargetFeeRates) {
String url = AppServices.isUsingProxy() ? "http://mempoolhqx4isw62xs7abwphsq7ldayuidyx2v2oethdhhj6mlo2r6ad.onion/api/v1/fees/recommended" : "https://mempool.space/api/v1/fees/recommended";
return getThreeTierFeeRates(this, defaultblockTargetFeeRates, url);
}
},
BITCOINFEES_EARN_COM("bitcoinfees.earn.com") {
BITCOINFEES_EARN_COM("bitcoinfees.earn.com", true) {
@Override
public Map<Integer, Double> getBlockTargetFeeRates(Map<Integer, Double> defaultblockTargetFeeRates) {
String url = "https://bitcoinfees.earn.com/api/v1/fees/recommended";
return getThreeTierFeeRates(this, defaultblockTargetFeeRates, url);
}
},
MINIMUM("Minimum (1 sat/vB)") {
MINIMUM("Minimum (1 sat/vB)", false) {
@Override
public Map<Integer, Double> getBlockTargetFeeRates(Map<Integer, Double> defaultblockTargetFeeRates) {
Map<Integer, Double> blockTargetFeeRates = new LinkedHashMap<>();
@@ -40,7 +40,7 @@ public enum FeeRatesSource {
return blockTargetFeeRates;
}
},
OXT_ME("oxt.me") {
OXT_ME("oxt.me", true) {
@Override
public Map<Integer, Double> getBlockTargetFeeRates(Map<Integer, Double> defaultblockTargetFeeRates) {
String url = AppServices.isUsingProxy() ? "http://oxtwshnfyktikbflierkwcxxksbonl6v73l5so5zky7ur72w52tktkid.onion/stats/global/mempool" : "https://api.oxt.me/stats/global/mempool";
@@ -63,9 +63,11 @@ public enum FeeRatesSource {
public static final int BLOCKS_IN_TWO_HOURS = 12;
private final String name;
private final boolean external;
FeeRatesSource(String name) {
FeeRatesSource(String name, boolean external) {
this.name = name;
this.external = external;
}
public abstract Map<Integer, Double> getBlockTargetFeeRates(Map<Integer, Double> defaultblockTargetFeeRates);
@@ -74,6 +76,10 @@ public enum FeeRatesSource {
return name;
}
public boolean isExternal() {
return external;
}
private static Map<Integer, Double> getThreeTierFeeRates(FeeRatesSource feeRatesSource, Map<Integer, Double> defaultblockTargetFeeRates, String url) {
if(log.isInfoEnabled()) {
log.info("Requesting fee rates from " + url);
@@ -1,57 +1,50 @@
package com.sparrowwallet.sparrow.net;
import com.google.common.net.HostAndPort;
import com.samourai.http.client.HttpUsage;
import com.samourai.http.client.IHttpClient;
import com.sparrowwallet.nightjar.http.JavaHttpClientService;
import com.sparrowwallet.sparrow.net.http.client.AsyncUtil;
import com.sparrowwallet.sparrow.net.http.client.HttpUsage;
import com.sparrowwallet.sparrow.net.http.client.IHttpClient;
import com.sparrowwallet.sparrow.net.http.client.JettyHttpClientService;
import io.reactivex.Observable;
import java8.util.Optional;
import javafx.concurrent.Service;
import javafx.concurrent.Task;
import java.util.Map;
import java.util.Optional;
public class HttpClientService {
private final JavaHttpClientService httpClientService;
public class HttpClientService extends JettyHttpClientService {
private static final int REQUEST_TIMEOUT = 120000;
public HttpClientService(HostAndPort torProxy) {
this.httpClientService = new JavaHttpClientService(torProxy, 120000);
super(REQUEST_TIMEOUT, new HttpProxySupplier(torProxy));
}
public <T> T requestJson(String url, Class<T> responseType, Map<String, String> headers) throws Exception {
IHttpClient httpClient = httpClientService.getHttpClient(HttpUsage.COORDINATOR_REST);
return httpClient.getJson(url, responseType, headers);
return getHttpClient(HttpUsage.DEFAULT).getJson(url, responseType, headers);
}
public <T> Observable<Optional<T>> postJson(String url, Class<T> responseType, Map<String, String> headers, Object body) {
IHttpClient httpClient = httpClientService.getHttpClient(HttpUsage.COORDINATOR_REST);
return httpClient.postJson(url, responseType, headers, body);
return getHttpClient(HttpUsage.DEFAULT).postJson(url, responseType, headers, body).toObservable();
}
public String postString(String url, Map<String, String> headers, String contentType, String content) throws Exception {
IHttpClient httpClient = httpClientService.getHttpClient(HttpUsage.COORDINATOR_REST);
return httpClient.postString(url, headers, contentType, content);
}
public void changeIdentity() {
HostAndPort torProxy = getTorProxy();
if(torProxy != null) {
TorUtils.changeIdentity(torProxy);
}
IHttpClient httpClient = getHttpClient(HttpUsage.DEFAULT);
return AsyncUtil.getInstance().blockingGet(httpClient.postString(url, headers, contentType, content)).get();
}
public HostAndPort getTorProxy() {
return httpClientService.getTorProxy();
return getHttpProxySupplier().getTorProxy();
}
public void setTorProxy(HostAndPort torProxy) {
//Ensure all http clients are shutdown first
httpClientService.shutdown();
httpClientService.setTorProxy(torProxy);
stop();
getHttpProxySupplier()._setTorProxy(torProxy);
}
public void shutdown() {
httpClientService.shutdown();
@Override
public HttpProxySupplier getHttpProxySupplier() {
return (HttpProxySupplier)super.getHttpProxySupplier();
}
public static class ShutdownService extends Service<Boolean> {
@@ -65,7 +58,7 @@ public class HttpClientService {
protected Task<Boolean> createTask() {
return new Task<>() {
protected Boolean call() throws Exception {
httpClientService.shutdown();
httpClientService.stop();
return true;
}
};
@@ -0,0 +1,51 @@
package com.sparrowwallet.sparrow.net;
import com.google.common.net.HostAndPort;
import com.sparrowwallet.sparrow.net.http.client.HttpProxy;
import com.sparrowwallet.sparrow.net.http.client.HttpProxyProtocol;
import com.sparrowwallet.sparrow.net.http.client.HttpUsage;
import com.sparrowwallet.sparrow.net.http.client.IHttpProxySupplier;
import java.util.Optional;
public class HttpProxySupplier implements IHttpProxySupplier {
private HostAndPort torProxy;
private HttpProxy httpProxy;
public HttpProxySupplier(HostAndPort torProxy) {
this.torProxy = torProxy;
this.httpProxy = computeHttpProxy(torProxy);
}
private HttpProxy computeHttpProxy(HostAndPort hostAndPort) {
if (hostAndPort == null) {
return null;
}
return new HttpProxy(HttpProxyProtocol.SOCKS, hostAndPort.getHost(), hostAndPort.getPort());
}
public HostAndPort getTorProxy() {
return torProxy;
}
// shouldnt call directly but use httpClientService.setTorProxy()
public void _setTorProxy(HostAndPort hostAndPort) {
// set proxy
this.torProxy = hostAndPort;
this.httpProxy = computeHttpProxy(hostAndPort);
}
@Override
public Optional<HttpProxy> getHttpProxy(HttpUsage httpUsage) {
return Optional.ofNullable(httpProxy);
}
@Override
public void changeIdentity() {
HostAndPort torProxy = getTorProxy();
if(torProxy != null) {
TorUtils.changeIdentity(torProxy);
}
}
}

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