Compare commits

..
92 Commits
Author SHA1 Message Date
Craig Raw c8d997fbf0 v1.4.2 2021-06-23 12:51:38 +02:00
Craig Raw 911ed3a718 improve background text 2021-06-23 12:30:58 +02:00
Craig Raw dbfed31432 add format toggle to message signing dialog (electrum or trezor) 2021-06-22 16:00:15 +02:00
Craig Raw 6f3d4e224e add context menu item to copy transaction hex 2021-06-22 11:47:56 +02:00
Craig Raw 4d6609990c fix error messages on subclassed importers/exporters 2021-06-22 10:13:37 +02:00
Craig Raw 5482196cc7 dark theme improvements 2021-06-22 08:36:16 +02:00
Craig Raw 09f6c9ef81 ensure cleanup of migrated wallets when importing 2021-06-21 11:50:58 +02:00
Craig Raw 9b8f97c041 fix import of sparrow wallet with seed, alphabetically sort import and export choices 2021-06-21 11:25:47 +02:00
Craig Raw c68c713a4b add orig files for keystone and seedsigner 2021-06-18 13:31:08 +02:00
Craig Raw 02e144f802 set keystore label, model and source type when importing a wallet from specter desktop 2021-06-18 11:21:53 +02:00
Craig Raw a9ab4d6c78 Handle importing a wallet from a crypto-account QR using the File > Import Wallet dialog 2021-06-17 14:34:11 +02:00
Craig Raw 5df4e5761c add seedsigner keystore import 2021-06-16 14:46:55 +02:00
Craig Raw 6d8b8579ba followup 2021-06-16 09:37:35 +02:00
Craig Raw eaa5190502 various db persistence improvements 2021-06-16 09:35:26 +02:00
Craig Raw 445db6a4d6 reduce file reads on db files to avoid locking exception on windows 2021-06-15 17:58:25 +02:00
Craig Raw 7f178b5f67 jpms related changes for v1.4.2-beta 2021-06-15 16:50:05 +02:00
Craig Raw 1208baf00e use mempool.space onion address for fee rates if tor proxy enabled 2021-06-15 09:31:12 +02:00
Craig Raw 655a473cd5 update send selected button when freezing or unfreezing utxos 2021-06-14 16:58:05 +02:00
Craig Raw e6c536930b highlight default button and allow actioning from keyboard when only one usb device is listed 2021-06-14 16:55:01 +02:00
Craig Raw f1510de360 update encrypted seeds and private keys when wallet password changes 2021-06-14 14:54:40 +02:00
Craig Raw cfac2768ae use varbinary rather than fixed binary column types where length is variable 2021-06-14 11:52:12 +02:00
Craig Raw ab41f2e80e upgrade to h2 HEAD 2021-06-11 15:45:16 +02:00
Craig Raw 9ebabecfbe use json persistence for sparrow wallet export, add sparrow wallet import to wallets dir 2021-06-11 11:29:54 +02:00
Craig Raw 8914acff68 minor followup 2021-06-10 17:02:32 +02:00
Craig Raw 4a0ecba716 add keystone hww import and export 2021-06-10 16:37:41 +02:00
Craig Raw e99b1d4171 upgrade to hwi 2.0.2 and relocate hwi to sparrow home folder on osx to avoid partial installation deletions in tmpdir 2021-06-10 14:06:14 +02:00
Craig Raw a59d5d3086 introduce database persistence with automatic migration of existing wallets 2021-06-10 12:08:35 +02:00
Craig Raw 600a77da3a allow psbts without utxo data to be loaded if utxos are provided in an existing psbt 2021-05-28 11:09:23 +02:00
craigrawandGitHub bc83f6fa22 Merge pull request #134 from haakonn/mnemonic-entry
Make mnemonic entry more efficient
2021-05-27 16:18:39 +02:00
Haakon Nilsen 4cbde7e7aa When entering mnemonic words, don't close dropdown when a prefix is encountered, and move focus to the next field upon completion 2021-05-27 10:56:47 +02:00
craigrawandGitHub 3ae63408e6 Merge pull request #133 from haakonn/save-tx-menuitems
Fixes around transaction saving menu items
2021-05-27 09:58:11 +02:00
Haakon Nilsen e740c6d162 Disable transaction saving menu items after a transaction tab is closed and no other tabs are open 2021-05-27 09:15:36 +02:00
Haakon Nilsen f7f5852476 Disable the "Save transaction" menu item when starting app without any open tabs 2021-05-26 18:57:07 +02:00
Haakon Nilsen 447e2ab264 Disable the keyboard shortcut for saving PSBT binary when its parent menu is disabled 2021-05-26 18:54:20 +02:00
craigrawandGitHub 8a77f22158 Merge pull request #132 from haakonn/close-on-escape
Close "About" and "Introduction" when Escape key is pressed
2021-05-26 08:39:16 +02:00
Haakon Nilsen c096327be4 Close "About" and "Introduction" when Escape key is pressed 2021-05-25 22:13:51 +02:00
Craig Raw 911153e1aa fix receive to address context menu actions 2021-05-25 08:21:17 +02:00
Craig Raw a60eadf8fc add export to specter diy 2021-05-24 14:20:08 +02:00
Craig Raw 9ebbf2557f support electrs batching and improve batching read timeout handling 2021-05-24 13:39:06 +02:00
Craig Raw 42b279d22a add specter desktop reimport usb wallets warning 2021-05-24 12:29:57 +02:00
Craig Raw 1a452db4cf add tooltip to send utxos button with directions on how to select multiple utxos 2021-05-24 12:27:30 +02:00
Craig Raw c1cf5be616 add mempool.bisq.services as a broadcaster, broadcast tx twice if possible on mainnet, handle different network broadcaster network capabilities 2021-05-21 11:48:13 +02:00
Craig Raw dd146210ba set send amount to total utxo value when sending selected utxos before an address or label is added 2021-05-21 09:53:00 +02:00
Craig Raw 94088f795c handle invalid values from mempool.get_fee_histogram 2021-05-21 08:44:55 +02:00
Craig Raw c5b09189df explicitly place decorationpane in the scene graph to avoid app resizing issues 2021-05-20 17:27:33 +02:00
Craig Raw 197c44bb07 update null and empty labels, clear script hash cache on increasing gap limit 2021-05-20 14:49:16 +02:00
Craig Raw c202a941b9 add mempool.emzy.de as another broadcaster 2021-05-20 14:48:01 +02:00
Craig Raw 383594b03c v1.4.1 2021-05-20 12:32:15 +02:00
Craig Raw 42bfe572ef calculate and cache script hashes statuses on wallet load to avoid unnecessary initial history fetching 2021-05-20 09:52:02 +02:00
Craig Raw 1677c47500 request payjoin transactions (over tor if available) in background thread 2021-05-19 12:24:46 +02:00
Craig Raw 53a447c72d fix cpu usage on spreadsheetview 2021-05-19 09:51:55 +02:00
Craig Raw f23a891ece add send to many dialog 2021-05-19 08:41:15 +02:00
Craig Raw 0e42c657b3 broadcast transactions over tor to a broadcasting service where tor proxy available 2021-05-18 09:37:59 +02:00
Craig Raw b17c15f702 request new change address in the transaction diagram 2021-05-17 13:21:56 +02:00
Craig Raw c9cdf6e77d handle partial rbf, fix cursor reset on payment label 2021-05-17 11:58:12 +02:00
Craig Raw 4f6ead842d fix txtree selection bug when preselecting a txi/txo on opening the editor for the first time 2021-05-17 10:53:26 +02:00
Craig Raw adcddfa84d various minor utxo screen improvements, including hiding the utxo chart 2021-05-17 10:09:19 +02:00
Craig Raw d67c5c5218 update related address, tx, txi and txo labels when null, set tx, txi, txo labels to address label on receive, add paste label to context menu 2021-05-14 18:03:46 +02:00
Craig Raw fb72010bdf refactor storage to handle different persistence strategies 2021-05-14 12:55:12 +02:00
Craig Raw 8bc8bdb2f2 dont backup wallets outside of wallets folder 2021-05-12 14:52:19 +02:00
Craig Raw 8f4cf9f2a0 add unique4j classes to avoid module dependency issue 2021-05-12 14:28:56 +02:00
Craig Raw ed323a8388 ensure aopp window is always on top 2021-05-12 09:19:49 +02:00
Craig Raw d559b437b3 bring window to front one external file load 2021-05-12 09:02:39 +02:00
Craig Raw 6155306acc always try obtain application lock on startup 2021-05-12 08:46:24 +02:00
Craig Raw 6f35d86890 bring opened file window to foreground 2021-05-11 12:58:11 +02:00
Craig Raw cbf32a36c6 follow up #2 2021-05-11 12:40:20 +02:00
Craig Raw 9789e0f198 follow up 2021-05-11 12:33:33 +02:00
Craig Raw 574209c837 ensure file and uri opening on original app instance 2021-05-11 12:31:36 +02:00
Craig Raw 74c83fc5e1 fade animation on server toggle while connecting 2021-05-11 08:52:45 +02:00
Craig Raw a4ddc11bab Merge branch 'master' of github.com:sparrowwallet/sparrow 2021-05-10 14:34:57 +02:00
Craig Raw 3bf480234f fix aopp alert dialog on windows 2021-05-10 14:25:16 +02:00
Craig Raw 29811ca5de handle uri and psbt opening on linux 2021-05-10 14:22:24 +02:00
Craig Raw b7522420cf cleanup windows install 2021-05-10 13:41:46 +02:00
Craig Raw f7d1e22e4e use custom wix installation file 2021-05-10 13:39:40 +02:00
Craig Raw e0c38031b1 add aopp registry keys on windows install 2021-05-10 12:17:52 +02:00
Craig Raw e046512e86 open files or uris from commandline arguments 2021-05-10 11:44:06 +02:00
Craig Raw 7cba0de268 check if opening uris is supported 2021-05-10 08:42:50 +02:00
Craig Raw 425e476f20 add aopp proof of address ownership support 2021-05-07 16:24:28 +02:00
Craig Raw e5dd33d5a1 register and add bitcoin: uri handler, register aopp: handler 2021-05-06 14:21:56 +02:00
Craig Raw 07012615ff 2nd fix to alert height 2021-05-05 14:59:53 +02:00
Craig Raw 23f3ae51db hide alert on clicking link 2021-05-05 14:43:33 +02:00
Craig Raw 10514b8039 shorten mitm message to avoid alert height ellipsis 2021-05-05 14:35:43 +02:00
Craig Raw 19637fd706 handle missing webcam dependency on windows showing error alert with link to resolve 2021-05-05 14:31:28 +02:00
Craig Raw dd56e2b42e handle windows with owner only file and directory creation 2021-05-05 12:05:48 +02:00
Craig Raw e0b6530275 only save tmp backup wallet if connected 2021-05-05 11:34:45 +02:00
Craig Raw a0d3f3b745 ensure config, wallet dirs and wallets are owner read/write only 2021-05-05 11:25:25 +02:00
Craig Raw 438c13fe2d add wallet transactions loading log viewer, and improve loading messages 2021-05-04 15:17:57 +02:00
Craig Raw c067300ee8 fix empty addresses table bug after requesting many new addresses 2021-05-04 10:42:18 +02:00
Craig Raw 020ca2f312 always use 8 fraction digits when displaying btc amounts, use a fixed width font for numbers on osx 2021-05-04 10:23:35 +02:00
Craig Raw 5ede1dd97d ensure wallet export file streams are always closed 2021-04-30 14:03:58 +02:00
Craig Raw 45f6ae214a add icons to wallet context menu items where they appear as action buttons 2021-04-30 13:19:53 +02:00
Craig Raw 45a9093055 ensure import file stream is closed 2021-04-29 20:58:04 +02:00
161 changed files with 6948 additions and 1121 deletions
+18 -5
View File
@@ -5,7 +5,7 @@ plugins {
id 'org.beryx.jlink' version '2.22.0'
}
def sparrowVersion = '1.4.0'
def sparrowVersion = '1.4.2'
def os = org.gradle.internal.os.OperatingSystem.current()
def osName = os.getFamilyName()
if(os.macOsX) {
@@ -44,6 +44,13 @@ dependencies {
}
implementation('com.google.guava:guava:28.2-jre')
implementation('com.google.code.gson:gson:2.8.6')
implementation('com.h2database:h2:1.4.201-SNAPSHOT')
implementation('com.zaxxer:HikariCP:4.0.3')
implementation('org.jdbi:jdbi3-core:3.20.0') {
exclude group: 'org.slf4j'
}
implementation('org.jdbi:jdbi3-sqlobject:3.20.0')
implementation('org.flywaydb:flyway-core:7.10.5-SNAPSHOT')
implementation('org.fxmisc.richtext:richtextfx:0.10.4')
implementation('no.tornado:tornadofx-controls:1.0.4')
implementation('com.google.zxing:javase:3.4.0')
@@ -60,7 +67,7 @@ dependencies {
}
implementation("com.sparrowwallet:netlayer-jpms-${osName}:0.6.8")
implementation('de.codecentric.centerdevice:centerdevice-nsmenufx:2.1.7')
implementation('org.controlsfx:controlsfx:11.0.2' ) {
implementation('org.controlsfx:controlsfx:11.1.0' ) {
exclude group: 'org.openjfx', module: 'javafx-base'
exclude group: 'org.openjfx', module: 'javafx-graphics'
exclude group: 'org.openjfx', module: 'javafx-controls'
@@ -105,6 +112,7 @@ run {
"--add-opens=javafx.base/com.sun.javafx.event=org.controlsfx.controls",
"--add-opens=javafx.controls/javafx.scene.control.cell=com.sparrowwallet.sparrow",
"--add-opens=org.controlsfx.controls/impl.org.controlsfx.skin=com.sparrowwallet.sparrow",
"--add-opens=org.controlsfx.controls/impl.org.controlsfx.skin=javafx.fxml",
"--add-opens=javafx.graphics/com.sun.javafx.tk=centerdevice.nsmenufx",
"--add-opens=javafx.graphics/com.sun.javafx.tk.quantum=centerdevice.nsmenufx",
"--add-opens=javafx.graphics/com.sun.glass.ui=centerdevice.nsmenufx",
@@ -130,6 +138,8 @@ jlink {
requires 'com.fasterxml.jackson.databind'
requires 'jdk.crypto.cryptoki'
requires 'java.management'
uses 'org.flywaydb.core.extensibility.FlywayExtension'
uses 'org.flywaydb.core.internal.database.DatabaseType'
}
options = ['--strip-debug', '--compress', '2', '--no-header-files', '--no-man-pages', '--ignore-signing-information', '--exclude-files', '**.png']
@@ -143,6 +153,7 @@ jlink {
"--add-opens=javafx.base/com.sun.javafx.event=org.controlsfx.controls",
"--add-opens=javafx.controls/javafx.scene.control.cell=com.sparrowwallet.sparrow",
"--add-opens=org.controlsfx.controls/impl.org.controlsfx.skin=com.sparrowwallet.sparrow",
"--add-opens=org.controlsfx.controls/impl.org.controlsfx.skin=javafx.fxml",
"--add-opens=javafx.graphics/com.sun.javafx.tk=com.sparrowwallet.merged.module",
"--add-opens=javafx.graphics/com.sun.javafx.tk.quantum=com.sparrowwallet.merged.module",
"--add-opens=javafx.graphics/com.sun.glass.ui=com.sparrowwallet.merged.module",
@@ -151,7 +162,9 @@ jlink {
"--add-opens=javafx.graphics/com.sun.glass.ui=com.sparrowwallet.sparrow",
"--add-opens=javafx.graphics/com.sun.javafx.application=com.sparrowwallet.sparrow",
"--add-opens=java.base/java.net=com.sparrowwallet.sparrow",
"--add-reads=com.sparrowwallet.merged.module=java.desktop"]
"--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"]
if(os.macOsX) {
jvmArgs += "--add-opens=javafx.graphics/com.sun.glass.ui.mac=com.sparrowwallet.merged.module"
@@ -164,9 +177,9 @@ jlink {
appVersion = "${sparrowVersion}"
skipInstaller = os.macOsX
imageOptions = []
installerOptions = ['--file-associations', 'src/main/deploy/associations.properties', '--license-file', 'LICENSE']
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/aopp.properties', '--license-file', 'LICENSE']
if(os.windows) {
installerOptions += ['--win-per-user-install', '--win-dir-chooser', '--win-menu', '--win-shortcut']
installerOptions += ['--win-per-user-install', '--win-dir-chooser', '--win-menu', '--win-shortcut', '--resource-dir', 'src/main/deploy/package/windows/']
imageOptions += ['--icon', 'src/main/deploy/package/windows/sparrow.ico']
installerType = "exe"
}
+1 -1
Submodule drongo updated: db9617ee10...c021300797
+2
View File
@@ -0,0 +1,2 @@
mime-type=x-scheme-handler/aopp
description=Verify Address Ownership URI
-3
View File
@@ -1,3 +0,0 @@
extension=psbt
mime-type=application/octet-stream
description=Partially Signed Bitcoin Transaction
+2
View File
@@ -0,0 +1,2 @@
mime-type=x-scheme-handler/bitcoin
description=Bitcoin Scheme URI
@@ -0,0 +1,9 @@
[Desktop Entry]
Name=Sparrow
Comment=Sparrow
Exec=/opt/sparrow/bin/Sparrow %U
Icon=/opt/sparrow/lib/Sparrow.png
Terminal=false
Type=Application
Categories=Unknown
MimeType=application/psbt;application/bitcoin-transaction;x-scheme-handler/bitcoin;x-scheme-handler/aopp
+84 -1
View File
@@ -21,7 +21,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.4.0</string>
<string>1.4.2</string>
<key>CFBundleSignature</key>
<string>????</string>
<!-- See https://developer.apple.com/app-store/categories/ for list of AppStore categories -->
@@ -35,5 +35,88 @@
<string>true</string>
<key>NSCameraUsageDescription</key>
<string>Sparrow requires access to the camera in order to scan QR codes</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.sparrowwallet.sparrow.bitcoin</string>
<key>CFBundleURLSchemes</key>
<array>
<string>bitcoin</string>
</array>
</dict>
<dict>
<key>CFBundleURLName</key>
<string>com.sparrowwallet.sparrow.aopp</string>
<key>CFBundleURLSchemes</key>
<array>
<string>aopp</string>
</array>
</dict>
</array>
<key>UTImportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeIdentifier</key>
<string>org.bitcoin.psbt</string>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>psbt</string>
</array>
</dict>
<key>UTTypeDescription</key>
<string>Partially Signed Bitcoin Transaction</string>
<key>UTTypeIconFile</key>
<string>sparrow.icns</string>
</dict>
<dict>
<key>UTTypeIdentifier</key>
<string>org.bitcoin.txn</string>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>txn</string>
</array>
</dict>
<key>UTTypeDescription</key>
<string>Bitcoin Transaction</string>
<key>UTTypeIconFile</key>
<string>sparrow.icns</string>
</dict>
</array>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleTypeIconFile</key>
<string>sparrow.icns</string>
<key>CFBundleTypeExtensions</key>
<array>
<string>psbt</string>
</array>
<key>CFBundleTypeName</key>
<string>Partially Signed Bitcoin Transaction</string>
<key>LSHandlerRank</key>
<string>Default</string>
</dict>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleTypeIconFile</key>
<string>sparrow.icns</string>
<key>CFBundleTypeExtensions</key>
<array>
<string>txn</string>
</array>
<key>CFBundleTypeName</key>
<string>Bitcoin Transaction</string>
<key>LSHandlerRank</key>
<string>Default</string>
</dict>
</array>
</dict>
</plist>
+163
View File
@@ -0,0 +1,163 @@
<?xml version="1.0" encoding="utf-8"?>
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi"
xmlns:util="http://schemas.microsoft.com/wix/UtilExtension">
<?ifdef JpIsSystemWide ?>
<?define JpInstallScope="perMachine"?>
<?else?>
<?define JpInstallScope="perUser"?>
<?endif?>
<?define JpProductLanguage=1033 ?>
<?define JpInstallerVersion=200 ?>
<?define JpCompressedMsi=yes ?>
<?include $(var.JpConfigDir)/overrides.wxi ?>
<Product
Id="$(var.JpProductCode)"
Name="$(var.JpAppName)"
Language="$(var.JpProductLanguage)"
Version="$(var.JpAppVersion)"
Manufacturer="$(var.JpAppVendor)"
UpgradeCode="$(var.JpProductUpgradeCode)">
<Package
Description="$(var.JpAppDescription)"
Manufacturer="$(var.JpAppVendor)"
InstallerVersion="$(var.JpInstallerVersion)"
Compressed="$(var.JpCompressedMsi)"
InstallScope="$(var.JpInstallScope)" Platform="x64"
/>
<Media Id="1" Cabinet="Data.cab" EmbedCab="yes" />
<?ifdef JpAllowDowngrades ?>
<?ifdef JpAllowUpgrades ?>
<MajorUpgrade
AllowDowngrades="yes"
Disallow="no"
/>
<?endif?>
<?endif?>
<?ifdef JpAllowDowngrades ?>
<?ifndef JpAllowUpgrades ?>
<MajorUpgrade
AllowDowngrades="yes"
Disallow="yes"
DisallowUpgradeErrorMessage="!(loc.DisallowUpgradeErrorMessage)"
/>
<?endif?>
<?endif?>
<?ifndef JpAllowDowngrades ?>
<?ifdef JpAllowUpgrades ?>
<MajorUpgrade
AllowDowngrades="no"
Disallow="no"
DowngradeErrorMessage="!(loc.DowngradeErrorMessage)"
/>
<?endif?>
<?endif?>
<?ifndef JpAllowDowngrades ?>
<?ifndef JpAllowUpgrades ?>
<MajorUpgrade
AllowDowngrades="no"
Disallow="yes"
DowngradeErrorMessage="!(loc.DowngradeErrorMessage)"
DisallowUpgradeErrorMessage="!(loc.DisallowUpgradeErrorMessage)"
/>
<?endif?>
<?endif?>
<!-- Standard required root -->
<Directory Id="TARGETDIR" Name="SourceDir"/>
<DirectoryRef Id="TARGETDIR">
<Component Id="RegistryEntries" Guid="{206C911C-56EF-44B8-9257-5FD214427965}">
<RegistryKey Root="HKCR" Key="aopp" Action="createAndRemoveOnUninstall">
<RegistryValue Type="string" Name="URL Protocol" Value=""/>
<RegistryValue Type="string" Value="URL:Address Ownership Proof Protocol"/>
<RegistryKey Key="DefaultIcon">
<RegistryValue Type="string" Value="$(var.JpAppName).exe" />
</RegistryKey>
<RegistryKey Key="shell\open\command">
<RegistryValue Type="string" Value="&quot;[INSTALLDIR]$(var.JpAppName).exe&quot; &quot;%1&quot;" />
</RegistryKey>
</RegistryKey>
<RegistryKey Root="HKCR" Key="bitcoin" Action="createAndRemoveOnUninstall">
<RegistryValue Type="string" Name="URL Protocol" Value=""/>
<RegistryValue Type="string" Value="URL:Bitcoin Payment URL"/>
<RegistryKey Key="DefaultIcon">
<RegistryValue Type="string" Value="$(var.JpAppName).exe" />
</RegistryKey>
<RegistryKey Key="shell\open\command">
<RegistryValue Type="string" Value="&quot;[INSTALLDIR]$(var.JpAppName).exe&quot; &quot;%1&quot;" />
</RegistryKey>
</RegistryKey>
</Component>
</DirectoryRef>
<Feature Id="DefaultFeature" Title="!(loc.MainFeatureTitle)" Level="1">
<ComponentGroupRef Id="Shortcuts"/>
<ComponentGroupRef Id="Files"/>
<ComponentGroupRef Id="FileAssociations"/>
<ComponentRef Id="RegistryEntries"/>
</Feature>
<?ifdef JpInstallDirChooser ?>
<Binary Id="JpCaDll" SourceFile="wixhelper.dll"/>
<CustomAction Id="JpCheckInstallDir" BinaryKey="JpCaDll" DllEntry="CheckInstallDir" />
<?endif?>
<UI>
<?ifdef JpInstallDirChooser ?>
<Dialog Id="JpInvalidInstallDir" Width="300" Height="85" Title="[ProductName] Setup" NoMinimize="yes">
<Control Id="JpInvalidInstallDirYes" Type="PushButton" X="100" Y="55" Width="50" Height="15" Default="no" Cancel="no" Text="Yes">
<Publish Event="NewDialog" Value="VerifyReadyDlg">1</Publish>
</Control>
<Control Id="JpInvalidInstallDirNo" Type="PushButton" X="150" Y="55" Width="50" Height="15" Default="yes" Cancel="yes" Text="No">
<Publish Event="NewDialog" Value="InstallDirDlg">1</Publish>
</Control>
<Control Id="Text" Type="Text" X="25" Y="15" Width="250" Height="30" TabSkip="no">
<Text>!(loc.message.install.dir.exist)</Text>
</Control>
</Dialog>
<!--
Run WixUI_InstallDir dialog in the default install directory.
-->
<Property Id="WIXUI_INSTALLDIR" Value="INSTALLDIR"/>
<UIRef Id="WixUI_InstallDir" />
<Publish Dialog="InstallDirDlg" Control="Next" Event="DoAction" Value="JpCheckInstallDir" Order="3">1</Publish>
<Publish Dialog="InstallDirDlg" Control="Next" Event="NewDialog" Value="JpInvalidInstallDir" Order="5">INSTALLDIR_VALID="0"</Publish>
<Publish Dialog="InstallDirDlg" Control="Next" Event="NewDialog" Value="VerifyReadyDlg" Order="5">INSTALLDIR_VALID="1"</Publish>
<?ifndef JpLicenseRtf ?>
<!--
No license file provided.
Override the dialog sequence in built-in dialog set "WixUI_InstallDir"
to exclude license dialog.
-->
<Publish Dialog="WelcomeDlg" Control="Next" Event="NewDialog" Value="InstallDirDlg" Order="2">1</Publish>
<Publish Dialog="InstallDirDlg" Control="Back" Event="NewDialog" Value="WelcomeDlg" Order="2">1</Publish>
<?endif?>
<?else?>
<?ifdef JpLicenseRtf ?>
<UIRef Id="WixUI_Minimal" />
<?endif?>
<?endif?>
</UI>
<?ifdef JpLicenseRtf ?>
<WixVariable Id="WixUILicenseRtf" Value="$(var.JpLicenseRtf)"/>
<?endif?>
</Product>
</Wix>
+3
View File
@@ -0,0 +1,3 @@
mime-type=application/psbt
extension=psbt
description=Partially Signed Bitcoin Transaction
+3
View File
@@ -0,0 +1,3 @@
mime-type=application/bitcoin-transaction
extension=txn
description=Bitcoin Transaction
@@ -12,7 +12,7 @@ public class AboutController {
private Label title;
public void initializeView() {
title.setText(MainApp.APP_NAME + " " + MainApp.APP_VERSION);
title.setText(MainApp.APP_NAME + " " + MainApp.APP_VERSION + MainApp.APP_VERSION_SUFFIX);
}
public void setStage(Stage stage) {
@@ -18,6 +18,7 @@ import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.psbt.PSBTInput;
import com.sparrowwallet.drongo.psbt.PSBTParseException;
import com.sparrowwallet.drongo.psbt.PSBTSignatureException;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.control.*;
import com.sparrowwallet.sparrow.event.*;
@@ -87,6 +88,9 @@ public class AppController implements Initializable {
@FXML
private Menu savePSBT;
@FXML
private MenuItem savePSBTBinary;
@FXML
private MenuItem exportWallet;
@@ -120,6 +124,12 @@ public class AppController implements Initializable {
@FXML
private CheckMenuItem useHdCameraResolution;
@FXML
private CheckMenuItem showLoadingLog;
@FXML
private CheckMenuItem showUtxosChart;
@FXML
private CheckMenuItem showTxHex;
@@ -129,6 +139,9 @@ public class AppController implements Initializable {
@FXML
private MenuItem refreshWallet;
@FXML
private MenuItem sendToMany;
@FXML
private StackPane rootStack;
@@ -167,11 +180,7 @@ public class AppController implements Initializable {
boolean success = false;
if(db.hasFiles()) {
for(File file : db.getFiles()) {
if(isWalletFile(file)) {
openWalletFile(file, true);
} else {
openTransactionFile(file);
}
openFile(file);
}
success = true;
}
@@ -227,6 +236,8 @@ public class AppController implements Initializable {
if(tabs.getTabs().isEmpty()) {
Stage tabStage = (Stage)tabs.getScene().getWindow();
tabStage.setTitle("Sparrow");
saveTransaction.setVisible(true);
saveTransaction.setDisable(true);
}
}
});
@@ -259,9 +270,14 @@ public class AppController implements Initializable {
hideEmptyUsedAddresses.setSelected(Config.get().isHideEmptyUsedAddresses());
useHdCameraResolution.setSelected(Config.get().isHdCapture());
showTxHex.setSelected(Config.get().isShowTransactionHex());
showLoadingLog.setSelected(Config.get().isShowLoadingLog());
showUtxosChart.setSelected(Config.get().isShowUtxosChart());
saveTransaction.setDisable(true);
savePSBT.visibleProperty().bind(saveTransaction.visibleProperty().not());
savePSBTBinary.disableProperty().bind(saveTransaction.visibleProperty());
exportWallet.setDisable(true);
refreshWallet.disableProperty().bind(Bindings.or(exportWallet.disableProperty(), Bindings.or(serverToggle.disableProperty(), AppServices.onlineProperty().not())));
sendToMany.disableProperty().bind(exportWallet.disableProperty());
setServerType(Config.get().getServerType());
serverToggle.setSelected(isConnected());
@@ -311,7 +327,7 @@ public class AppController implements Initializable {
}
}
public void showDocumentation(ActionEvent event) throws IOException {
public void showDocumentation(ActionEvent event) {
AppServices.get().getApplication().getHostServices().showDocument("https://sparrowwallet.com/docs");
}
@@ -324,7 +340,7 @@ public class AppController implements Initializable {
}
}
public void submitBugReport(ActionEvent event) throws IOException {
public void submitBugReport(ActionEvent event) {
AppServices.get().getApplication().getHostServices().showDocument("https://sparrowwallet.com/submitbugreport");
}
@@ -343,7 +359,9 @@ public class AppController implements Initializable {
stage.setTitle("About " + MainApp.APP_NAME);
stage.initStyle(org.controlsfx.tools.Platform.getCurrent() == org.controlsfx.tools.Platform.OSX ? StageStyle.UNDECORATED : StageStyle.DECORATED);
stage.setResizable(false);
stage.setScene(new Scene(root));
Scene scene = new Scene(root);
AppServices.onEscapePressed(scene, stage::close);
stage.setScene(scene);
controller.setStage(stage);
controller.initializeView();
setStageIcon(stage);
@@ -432,7 +450,7 @@ public class AppController implements Initializable {
showErrorDialog("Invalid PSBT", e.getMessage());
} catch(TransactionParseException e) {
showErrorDialog("Invalid transaction", e.getMessage());
} catch(ParseException e) {
} catch(Exception e) {
showErrorDialog("Invalid file", e.getMessage());
}
}
@@ -664,6 +682,18 @@ public class AppController implements Initializable {
Config.get().setHdCapture(item.isSelected());
}
public void showLoadingLog(ActionEvent event) {
CheckMenuItem item = (CheckMenuItem)event.getSource();
Config.get().setShowLoadingLog(item.isSelected());
EventManager.get().post(new LoadingLogChangedEvent(item.isSelected()));
}
public void showUtxosChart(ActionEvent event) {
CheckMenuItem item = (CheckMenuItem)event.getSource();
Config.get().setShowUtxosChart(item.isSelected());
EventManager.get().post(new UtxosChartChangedEvent(item.isSelected()));
}
public void showTxHex(ActionEvent event) {
CheckMenuItem item = (CheckMenuItem)event.getSource();
Config.get().setShowTransactionHex(item.isSelected());
@@ -677,9 +707,12 @@ public class AppController implements Initializable {
EventManager.get().post(new BitcoinUnitChangedEvent(unit));
}
private boolean isWalletFile(File file) {
FileType fileType = IOUtils.getFileType(file);
return FileType.JSON.equals(fileType) || FileType.BINARY.equals(fileType);
public void openFile(File file) {
if(isWalletFile(file)) {
openWalletFile(file, true);
} else {
openTransactionFile(file);
}
}
private void setServerToggleTooltip(Integer currentBlockHeight) {
@@ -729,17 +762,11 @@ public class AppController implements Initializable {
public void openWalletFile(File file, boolean forceSameWindow) {
try {
Storage storage = new Storage(file);
FileType fileType = IOUtils.getFileType(file);
if(FileType.JSON.equals(fileType)) {
Storage.WalletBackupAndKey walletBackupAndKey = storage.loadWallet();
checkWalletNetwork(walletBackupAndKey.wallet);
restorePublicKeysFromSeed(walletBackupAndKey.wallet, null);
if(!walletBackupAndKey.wallet.isValid()) {
throw new IllegalStateException("Wallet file is not valid.");
}
addWalletTabOrWindow(storage, walletBackupAndKey.wallet, walletBackupAndKey.backupWallet, forceSameWindow);
} else if(FileType.BINARY.equals(fileType)) {
WalletPasswordDialog dlg = new WalletPasswordDialog(file.getName(), WalletPasswordDialog.PasswordRequirement.LOAD);
if(!storage.isEncrypted()) {
WalletBackupAndKey walletBackupAndKey = storage.loadUnencryptedWallet();
openWallet(storage, walletBackupAndKey, this, forceSameWindow);
} else {
WalletPasswordDialog dlg = new WalletPasswordDialog(storage.getWalletName(null), WalletPasswordDialog.PasswordRequirement.LOAD);
Optional<SecureString> optionalPassword = dlg.showAndWait();
if(optionalPassword.isEmpty()) {
return;
@@ -748,20 +775,12 @@ public class AppController implements Initializable {
SecureString password = optionalPassword.get();
Storage.LoadWalletService loadWalletService = new Storage.LoadWalletService(storage, password);
loadWalletService.setOnSucceeded(workerStateEvent -> {
EventManager.get().post(new StorageEvent(storage.getWalletFile(), TimedEvent.Action.END, "Done"));
Storage.WalletBackupAndKey walletBackupAndKey = loadWalletService.getValue();
try {
checkWalletNetwork(walletBackupAndKey.wallet);
restorePublicKeysFromSeed(walletBackupAndKey.wallet, walletBackupAndKey.key);
addWalletTabOrWindow(storage, walletBackupAndKey.wallet, walletBackupAndKey.backupWallet, forceSameWindow);
} catch(Exception e) {
showErrorDialog("Error Opening Wallet", e.getMessage());
} finally {
walletBackupAndKey.key.clear();
}
EventManager.get().post(new StorageEvent(storage.getWalletId(null), TimedEvent.Action.END, "Done"));
WalletBackupAndKey walletBackupAndKey = loadWalletService.getValue();
openWallet(storage, walletBackupAndKey, this, forceSameWindow);
});
loadWalletService.setOnFailed(workerStateEvent -> {
EventManager.get().post(new StorageEvent(storage.getWalletFile(), TimedEvent.Action.END, "Failed"));
EventManager.get().post(new StorageEvent(storage.getWalletId(null), TimedEvent.Action.END, "Failed"));
Throwable exception = loadWalletService.getException();
if(exception instanceof InvalidPasswordException) {
Optional<ButtonType> optResponse = showErrorDialog("Invalid Password", "The wallet password was invalid. Try again?", ButtonType.CANCEL, ButtonType.OK);
@@ -776,11 +795,11 @@ public class AppController implements Initializable {
password.clear();
}
});
EventManager.get().post(new StorageEvent(storage.getWalletFile(), TimedEvent.Action.START, "Decrypting wallet..."));
EventManager.get().post(new StorageEvent(storage.getWalletId(null), TimedEvent.Action.START, "Decrypting wallet..."));
loadWalletService.start();
} else {
throw new IOException("Unsupported file type");
}
} catch(StorageException e) {
showErrorDialog("Error Opening Wallet", e.getMessage());
} catch(Exception e) {
if(!attemptImportWallet(file, null)) {
log.error("Error opening wallet", e);
@@ -789,6 +808,26 @@ public class AppController implements Initializable {
}
}
private void openWallet(Storage storage, WalletBackupAndKey walletBackupAndKey, AppController appController, boolean forceSameWindow) {
try {
checkWalletNetwork(walletBackupAndKey.getWallet());
restorePublicKeysFromSeed(walletBackupAndKey.getWallet(), walletBackupAndKey.getKey());
if(!walletBackupAndKey.getWallet().isValid()) {
throw new IllegalStateException("Wallet file is not valid.");
}
AppController walletAppController = appController.addWalletTabOrWindow(storage, walletBackupAndKey.getWallet(), walletBackupAndKey.getBackupWallet(), forceSameWindow);
for(Map.Entry<Storage, WalletBackupAndKey> entry : walletBackupAndKey.getChildWallets().entrySet()) {
openWallet(entry.getKey(), entry.getValue(), walletAppController, true);
}
Platform.runLater(() -> selectTab(walletBackupAndKey.getWallet()));
} catch(Exception e) {
log.error("Error opening wallet", e);
showErrorDialog("Error Opening Wallet", e.getMessage());
} finally {
walletBackupAndKey.clear();
}
}
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.");
@@ -857,7 +896,8 @@ public class AppController implements Initializable {
new Electrum(),
new SpecterDesktop(),
new CoboVaultSinglesig(), new CoboVaultMultisig(),
new PassportSinglesig());
new PassportSinglesig(),
new KeystoneSinglesig(), new KeystoneMultisig());
for(WalletImport importer : walletImporters) {
try(FileInputStream inputStream = new FileInputStream(file)) {
if(importer.isEncrypted(file) && password == null) {
@@ -921,17 +961,28 @@ public class AppController implements Initializable {
walletFile.delete();
}
if(wallet.isEncrypted()) {
throw new IllegalArgumentException("Imported wallet must be unencrypted");
}
Storage storage = new Storage(Storage.getWalletFile(wallet.getName()));
WalletPasswordDialog dlg = new WalletPasswordDialog(wallet.getName(), WalletPasswordDialog.PasswordRequirement.UPDATE_NEW);
Optional<SecureString> password = dlg.showAndWait();
if(password.isPresent()) {
if(password.get().length() == 0) {
storage.setEncryptionPubKey(Storage.NO_PASSWORD_KEY);
addWalletTabOrWindow(storage, wallet, null, false);
try {
storage.setEncryptionPubKey(Storage.NO_PASSWORD_KEY);
storage.saveWallet(wallet);
checkWalletNetwork(wallet);
restorePublicKeysFromSeed(wallet, null);
addWalletTabOrWindow(storage, wallet, null, false);
} catch(IOException | StorageException | MnemonicException e) {
log.error("Error saving imported wallet", e);
}
} else {
Storage.KeyDerivationService keyDerivationService = new Storage.KeyDerivationService(storage, password.get());
keyDerivationService.setOnSucceeded(workerStateEvent -> {
EventManager.get().post(new StorageEvent(Storage.getWalletFile(wallet.getName()), TimedEvent.Action.END, "Done"));
EventManager.get().post(new StorageEvent(Storage.getWalletFile(wallet.getName()).getAbsolutePath(), TimedEvent.Action.END, "Done"));
ECKey encryptionFullKey = keyDerivationService.getValue();
Key key = null;
@@ -940,7 +991,12 @@ public class AppController implements Initializable {
key = new Key(encryptionFullKey.getPrivKeyBytes(), storage.getKeyDeriver().getSalt(), EncryptionType.Deriver.ARGON2);
wallet.encrypt(key);
storage.setEncryptionPubKey(encryptionPubKey);
storage.saveWallet(wallet);
checkWalletNetwork(wallet);
restorePublicKeysFromSeed(wallet, key);
addWalletTabOrWindow(storage, wallet, null, false);
} catch(IOException | StorageException | MnemonicException e) {
log.error("Error saving imported wallet", e);
} finally {
encryptionFullKey.clear();
if(key != null) {
@@ -949,10 +1005,10 @@ public class AppController implements Initializable {
}
});
keyDerivationService.setOnFailed(workerStateEvent -> {
EventManager.get().post(new StorageEvent(Storage.getWalletFile(wallet.getName()), TimedEvent.Action.END, "Failed"));
EventManager.get().post(new StorageEvent(Storage.getWalletFile(wallet.getName()).getAbsolutePath(), TimedEvent.Action.END, "Failed"));
showErrorDialog("Error encrypting wallet", keyDerivationService.getException().getMessage());
});
EventManager.get().post(new StorageEvent(Storage.getWalletFile(wallet.getName()), TimedEvent.Action.START, "Encrypting wallet..."));
EventManager.get().post(new StorageEvent(Storage.getWalletFile(wallet.getName()).getAbsolutePath(), TimedEvent.Action.START, "Encrypting wallet..."));
keyDerivationService.start();
}
}
@@ -997,6 +1053,28 @@ public class AppController implements Initializable {
messageSignDialog.showAndWait();
}
public void sendToMany(ActionEvent event) {
Tab selectedTab = tabs.getSelectionModel().getSelectedItem();
TabData tabData = (TabData)selectedTab.getUserData();
if(tabData.getType() == TabData.TabType.WALLET) {
WalletTabData walletTabData = (WalletTabData) tabData;
Wallet wallet = walletTabData.getWallet();
BitcoinUnit bitcoinUnit = Config.get().getBitcoinUnit();
if(bitcoinUnit == BitcoinUnit.AUTO) {
bitcoinUnit = wallet.getAutoUnit();
}
SendToManyDialog sendToManyDialog = new SendToManyDialog(bitcoinUnit);
Optional<List<Payment>> optPayments = sendToManyDialog.showAndWait();
optPayments.ifPresent(payments -> {
if(!payments.isEmpty()) {
EventManager.get().post(new SendActionEvent(wallet, new ArrayList<>(wallet.getWalletUtxos().keySet())));
Platform.runLater(() -> EventManager.get().post(new SendPaymentsEvent(wallet, payments)));
}
});
}
}
public void minimizeToTray(ActionEvent event) {
AppServices.get().minimizeStage((Stage)tabs.getScene().getWindow());
}
@@ -1011,18 +1089,18 @@ public class AppController implements Initializable {
walletTabData.getStorage().backupTempWallet();
wallet.clearHistory();
AppServices.clearTransactionHistoryCache(wallet);
EventManager.get().post(new WalletAddressesChangedEvent(wallet, pastWallet, walletTabData.getStorage().getWalletFile()));
EventManager.get().post(new WalletHistoryClearedEvent(wallet, pastWallet, walletTabData.getWalletForm().getWalletId()));
}
}
public void addWalletTabOrWindow(Storage storage, Wallet wallet, Wallet backupWallet, boolean forceSameWindow) {
Window existingWalletWindow = AppServices.get().getWindowForWallet(storage);
public AppController addWalletTabOrWindow(Storage storage, Wallet wallet, Wallet backupWallet, boolean forceSameWindow) {
Window existingWalletWindow = AppServices.get().getWindowForWallet(storage.getWalletId(wallet));
if(existingWalletWindow instanceof Stage) {
Stage existingWalletStage = (Stage)existingWalletWindow;
existingWalletStage.toFront();
EventManager.get().post(new ViewWalletEvent(existingWalletWindow, wallet, storage));
return;
return this;
}
if(!forceSameWindow && Config.get().isOpenWalletsInNewWindows() && !getOpenWallets().isEmpty()) {
@@ -1031,17 +1109,16 @@ public class AppController implements Initializable {
stage.toFront();
stage.setX(AppServices.get().getWalletWindowMaxX() + 30);
appController.addWalletTab(storage, wallet, backupWallet);
return appController;
} else {
addWalletTab(storage, wallet, backupWallet);
return this;
}
}
public void addWalletTab(Storage storage, Wallet wallet, Wallet backupWallet) {
try {
String name = storage.getWalletFile().getName();
if(name.endsWith(".json")) {
name = name.substring(0, name.lastIndexOf('.'));
}
String name = storage.getWalletName(wallet);
if(!name.equals(wallet.getName())) {
wallet.setName(name);
}
@@ -1106,7 +1183,8 @@ public class AppController implements Initializable {
private void addTransactionTab(String name, File file, byte[] bytes) throws PSBTParseException, ParseException, TransactionParseException {
if(PSBT.isPSBT(bytes)) {
PSBT psbt = new PSBT(bytes);
//Don't verify signatures here - provided PSBT may omit UTXO data that can be found when combining with an existing PSBT
PSBT psbt = new PSBT(bytes, false);
addTransactionTab(name, file, psbt);
} else if(Transaction.isTransaction(bytes)) {
try {
@@ -1182,6 +1260,16 @@ public class AppController implements Initializable {
}
}
if(psbt != null) {
try {
//Any PSBTs that have reached this point could not be combined with an existing PSBT. Verify signatures before continuing
psbt.verifySignatures();
} catch(PSBTSignatureException e) {
AppServices.showErrorDialog("Invalid PSBT", e.getMessage());
return;
}
}
try {
String tabName = name;
@@ -1277,6 +1365,28 @@ public class AppController implements Initializable {
EventManager.get().post(new ThemeChangedEvent(selectedTheme));
}
private void serverToggleStartAnimation() {
Node thumbArea = serverToggle.lookup(".thumb-area");
if(thumbArea != null) {
FadeTransition fadeTransition = new FadeTransition(Duration.millis(1000), thumbArea);
fadeTransition.setFromValue(1.0);
fadeTransition.setToValue(0.4);
fadeTransition.setAutoReverse(true);
fadeTransition.setCycleCount(Animation.INDEFINITE);
fadeTransition.play();
serverToggle.setUserData(fadeTransition);
}
}
private void serverToggleStopAnimation() {
if(serverToggle.getUserData() != null) {
FadeTransition fadeTransition = (FadeTransition)serverToggle.getUserData();
fadeTransition.stop();
fadeTransition.getNode().setOpacity(1.0);
serverToggle.setUserData(null);
}
}
private void tabLabelStartAnimation(Wallet wallet) {
tabs.getTabs().stream().filter(tab -> tab.getUserData() instanceof WalletTabData && ((WalletTabData)tab.getUserData()).getWallet() == wallet).forEach(this::tabLabelStartAnimation);
}
@@ -1364,6 +1474,8 @@ public class AppController implements Initializable {
saveTransaction.setVisible(false);
}
exportWallet.setDisable(true);
showLoadingLog.setDisable(true);
showUtxosChart.setDisable(true);
showTxHex.setDisable(false);
} else if(event instanceof WalletTabSelectedEvent) {
WalletTabSelectedEvent walletTabEvent = (WalletTabSelectedEvent)event;
@@ -1371,6 +1483,8 @@ public class AppController implements Initializable {
saveTransaction.setVisible(true);
saveTransaction.setDisable(true);
exportWallet.setDisable(walletTabData.getWallet() == null || !walletTabData.getWallet().isValid());
showLoadingLog.setDisable(false);
showUtxosChart.setDisable(false);
showTxHex.setDisable(true);
}
}
@@ -1396,7 +1510,7 @@ public class AppController implements Initializable {
TabData tabData = (TabData)tab.getUserData();
if(tabData instanceof WalletTabData) {
WalletTabData walletTabData = (WalletTabData)tabData;
if(walletTabData.getWalletForm().getWalletFile().equals(event.getWalletFile())) {
if(walletTabData.getWalletForm().getWalletId().equals(event.getWalletId())) {
exportWallet.setDisable(!event.getWallet().isValid());
}
}
@@ -1544,12 +1658,14 @@ public class AppController implements Initializable {
if(!statusBar.getText().contains(TRYING_ANOTHER_SERVER_MESSAGE)) {
statusUpdated(new StatusEvent(event.getStatus(), 120));
}
serverToggleStartAnimation();
}
@Subscribe
public void connectionFailed(ConnectionFailedEvent event) {
String status = CONNECTION_FAILED_PREFIX + event.getMessage();
statusUpdated(new StatusEvent(status));
serverToggleStopAnimation();
}
@Subscribe
@@ -1557,6 +1673,7 @@ public class AppController implements Initializable {
String status = "Connected to " + Config.get().getServerAddress() + " at height " + event.getBlockHeight();
statusUpdated(new StatusEvent(status));
setServerToggleTooltip(event.getBlockHeight());
serverToggleStopAnimation();
}
@Subscribe
@@ -1571,6 +1688,7 @@ public class AppController implements Initializable {
for(Wallet wallet : getOpenWallets().keySet()) {
tabLabelStopAnimation(wallet);
}
serverToggleStopAnimation();
}
@Subscribe
@@ -1707,6 +1825,17 @@ public class AppController implements Initializable {
@Subscribe
public void viewWallet(ViewWalletEvent event) {
if(tabs.getScene().getWindow().equals(event.getWindow())) {
for(Tab tab : tabs.getTabs()) {
TabData tabData = (TabData) tab.getUserData();
if(tabData.getType() == TabData.TabType.WALLET) {
WalletTabData walletTabData = (WalletTabData) tabData;
if(event.getStorage().getWalletId(event.getWallet()).equals(walletTabData.getWalletForm().getWalletId())) {
tabs.getSelectionModel().select(tab);
return;
}
}
}
for(Tab tab : tabs.getTabs()) {
TabData tabData = (TabData) tab.getUserData();
if(tabData.getType() == TabData.TabType.WALLET) {
@@ -1758,14 +1887,22 @@ public class AppController implements Initializable {
@Subscribe
public void requestWalletOpen(RequestWalletOpenEvent event) {
if(tabs.getScene().getWindow().equals(event.getWindow())) {
openWallet(true);
if(event.getFile() != null) {
openWalletFile(event.getFile(), true);
} else {
openWallet(true);
}
}
}
@Subscribe
public void requestTransactionOpen(RequestTransactionOpenEvent event) {
if(tabs.getScene().getWindow().equals(event.getWindow())) {
openTransactionFromFile(null);
if(event.getFile() != null) {
openTransactionFile(event.getFile());
} else {
openTransactionFromFile(null);
}
}
}
@@ -1775,4 +1912,14 @@ public class AppController implements Initializable {
openTransactionFromQR(null);
}
}
}
@Subscribe
public void sendAction(SendActionEvent event) {
selectTab(event.getWallet());
}
@Subscribe
public void recieveAction(ReceiveActionEvent event) {
selectTab(event.getWallet());
}
}
@@ -4,17 +4,16 @@ import com.google.common.eventbus.Subscribe;
import com.google.common.net.HostAndPort;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.uri.BitcoinURI;
import com.sparrowwallet.drongo.wallet.KeystoreSource;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.control.TextUtils;
import com.sparrowwallet.sparrow.control.TrayManager;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.Device;
import com.sparrowwallet.sparrow.io.Hwi;
import com.sparrowwallet.sparrow.io.Storage;
import com.sparrowwallet.sparrow.io.*;
import com.sparrowwallet.sparrow.net.*;
import javafx.application.Platform;
import javafx.beans.property.BooleanProperty;
@@ -27,28 +26,35 @@ import javafx.fxml.FXMLLoader;
import javafx.scene.Node;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.scene.control.Alert;
import javafx.scene.control.ButtonType;
import javafx.scene.control.*;
import javafx.scene.control.Dialog;
import javafx.scene.control.DialogPane;
import javafx.scene.control.Label;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.input.KeyCode;
import javafx.scene.text.Font;
import javafx.stage.Screen;
import javafx.stage.Stage;
import javafx.stage.Window;
import javafx.util.Duration;
import org.berndpruenster.netlayer.tor.Tor;
import org.controlsfx.control.HyperlinkLabel;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.awt.*;
import java.awt.desktop.OpenFilesHandler;
import java.awt.desktop.OpenURIHandler;
import java.io.File;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.Proxy;
import java.net.*;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
public class AppServices {
@@ -61,6 +67,7 @@ public class AppServices {
private static final int VERSION_CHECK_PERIOD_HOURS = 24;
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";
private static AppServices INSTANCE;
@@ -94,6 +101,10 @@ public class AppServices {
private static List<Device> devices;
private static final List<File> argFiles = new ArrayList<>();
private static final List<URI> argUris = new ArrayList<>();
private static final Map<Address, BitcoinURI> payjoinURIs = new HashMap<>();
private final ChangeListener<Boolean> onlineServicesListener = new ChangeListener<>() {
@@ -113,6 +124,14 @@ public class AppServices {
}
};
private static final OpenURIHandler openURIHandler = event -> {
openURI(event.getURI());
};
private static final OpenFilesHandler openFilesHandler = event -> {
openFiles(event.getFiles(), null);
};
public AppServices(MainApp application) {
this.application = application;
EventManager.get().register(this);
@@ -134,6 +153,8 @@ public class AppServices {
restartServices();
}
}
addURIHandlers();
}
private void restartServices() {
@@ -219,7 +240,7 @@ public class AppServices {
connectionService.setRestartOnFailure(false);
if(tlsServerException.getCause().getMessage().contains("PKIX path building failed")) {
File crtFile = Config.get().getElectrumServerCert();
if(crtFile != null) {
if(crtFile != null && Config.get().getServerType() == ServerType.ELECTRUM_SERVER) {
AppServices.showErrorDialog("SSL Handshake Failed", "The configured server certificate at " + crtFile.getAbsolutePath() + " did not match the certificate provided by the server at " + tlsServerException.getServer().getHost() + "." +
"\n\nThis may indicate a man-in-the-middle attack!" +
"\n\nChange the configured server certificate if you would like to proceed.");
@@ -352,18 +373,36 @@ public class AppServices {
return Tor.getDefault() != null;
}
public static boolean isUsingProxy() {
return isTorRunning() || Config.get().isUseProxy();
}
public static Proxy getProxy() {
return getProxy(TOR_DEFAULT_PROXY_CIRCUIT_ID);
}
public static Proxy getProxy(String proxyCircuitId) {
Config config = Config.get();
Proxy proxy = null;
if(config.isUseProxy()) {
HostAndPort proxy = HostAndPort.fromString(config.getProxyServer());
InetSocketAddress proxyAddress = new InetSocketAddress(proxy.getHost(), proxy.getPortOrDefault(ProxyTcpOverTlsTransport.DEFAULT_PROXY_PORT));
return new Proxy(Proxy.Type.SOCKS, proxyAddress);
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);
} else if(AppServices.isTorRunning()) {
InetSocketAddress proxyAddress = new InetSocketAddress("localhost", TorService.PROXY_PORT);
return new Proxy(Proxy.Type.SOCKS, proxyAddress);
proxy = new Proxy(Proxy.Type.SOCKS, proxyAddress);
}
return null;
//Setting new proxy authentication credentials will force a new Tor circuit to be created
if(proxy != null) {
Authenticator.setDefault(new Authenticator() {
public PasswordAuthentication getPasswordAuthentication() {
return (new PasswordAuthentication("user", proxyCircuitId.toCharArray()));
}
});
}
return proxy;
}
static void initialize(MainApp application) {
@@ -415,6 +454,14 @@ public class AppServices {
stage.hide();
}
public static void onEscapePressed(Scene scene, Runnable runnable) {
scene.setOnKeyPressed(event -> {
if(event.getCode() == KeyCode.ESCAPE) {
runnable.run();
}
});
}
public Map<Wallet, Storage> getOpenWallets() {
Map<Wallet, Storage> openWallets = new LinkedHashMap<>();
for(List<WalletTabData> walletTabDataList : walletWindows.values()) {
@@ -426,8 +473,8 @@ public class AppServices {
return openWallets;
}
public Window getWindowForWallet(Storage storage) {
Optional<Window> optWindow = walletWindows.entrySet().stream().filter(entry -> entry.getValue().stream().anyMatch(walletTabData -> walletTabData.getStorage().getWalletFile().equals(storage.getWalletFile()))).map(Map.Entry::getKey).findFirst();
public Window getWindowForWallet(String walletId) {
Optional<Window> optWindow = walletWindows.entrySet().stream().filter(entry -> entry.getValue().stream().anyMatch(walletTabData -> walletTabData.getWalletForm().getWalletId().equals(walletId))).map(Map.Entry::getKey).findFirst();
return optWindow.orElse(null);
}
@@ -500,6 +547,10 @@ public class AppServices {
ElectrumServer.clearRetrievedScriptHashes(wallet);
}
public static boolean isWalletFile(File file) {
return Storage.isWalletFile(file);
}
public static Optional<ButtonType> showWarningDialog(String title, String content, ButtonType... buttons) {
return showAlertDialog(title, content, Alert.AlertType.WARNING, buttons);
}
@@ -514,6 +565,29 @@ public class AppServices {
alert.getDialogPane().getScene().getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
alert.setTitle(title);
alert.setHeaderText(title);
Pattern linkPattern = Pattern.compile("\\[(http.+)]");
Matcher matcher = linkPattern.matcher(content);
if(matcher.find()) {
String link = matcher.group(1);
HyperlinkLabel hyperlinkLabel = new HyperlinkLabel(content);
hyperlinkLabel.setMaxWidth(Double.MAX_VALUE);
hyperlinkLabel.setMaxHeight(Double.MAX_VALUE);
hyperlinkLabel.getStyleClass().add("content");
Label label = new Label();
hyperlinkLabel.setPrefWidth(Math.max(360, TextUtils.computeTextWidth(label.getFont(), link, 0.0D) + 50));
hyperlinkLabel.setOnAction(event -> {
alert.close();
get().getApplication().getHostServices().showDocument(link);
});
alert.getDialogPane().setContent(hyperlinkLabel);
}
String[] lines = content.split("\r\n|\r|\n");
if(lines.length > 3) {
alert.getDialogPane().setPrefHeight(200 + lines.length * 20);
}
moveToActiveWindowScreen(alert);
return alert.showAndWait();
}
@@ -545,10 +619,6 @@ public class AppServices {
}
}
public void moveToWalletWindowScreen(Storage storage, Dialog<?> dialog) {
moveToWindowScreen(getWindowForWallet(storage), dialog);
}
public static void moveToWindowScreen(Window currentWindow, Dialog<?> dialog) {
Window newWindow = dialog.getDialogPane().getScene().getWindow();
DialogPane dialogPane = dialog.getDialogPane();
@@ -567,6 +637,135 @@ public class AppServices {
}
}
static void parseFileUriArguments(List<String> fileUriArguments) {
for(String fileUri : fileUriArguments) {
try {
File file = new File(fileUri.replace("~", System.getProperty("user.home")));
if(file.exists()) {
argFiles.add(file);
continue;
}
URI uri = new URI(fileUri);
argUris.add(uri);
} catch(URISyntaxException e) {
log.warn("Could not parse " + fileUri + " as a valid file or URI");
} catch(Exception e) {
//ignore
}
}
}
public static void openFileUriArguments(Window window) {
openFiles(argFiles, window);
argFiles.clear();
for(URI argUri : argUris) {
openURI(argUri);
}
argUris.clear();
}
private static void openFiles(List<File> files, Window window) {
final List<File> openFiles = new ArrayList<>(files);
Platform.runLater(() -> {
Window openWindow = window;
if(openWindow == null) {
openWindow = getActiveWindow();
}
if(openWindow instanceof Stage) {
((Stage)openWindow).setAlwaysOnTop(true);
((Stage)openWindow).setAlwaysOnTop(false);
}
for(File file : openFiles) {
if(isWalletFile(file)) {
EventManager.get().post(new RequestWalletOpenEvent(openWindow, file));
} else {
EventManager.get().post(new RequestTransactionOpenEvent(openWindow, file));
}
}
});
}
private static void openURI(URI uri) {
Platform.runLater(() -> {
if("bitcoin".equals(uri.getScheme())) {
openBitcoinUri(uri);
} else if("aopp".equals(uri.getScheme())) {
openAddressOwnershipProof(uri);
}
});
}
public static void addURIHandlers() {
try {
if(Desktop.isDesktopSupported()) {
if(Desktop.getDesktop().isSupported(Desktop.Action.APP_OPEN_FILE)) {
Desktop.getDesktop().setOpenFileHandler(openFilesHandler);
}
if(Desktop.getDesktop().isSupported(Desktop.Action.APP_OPEN_URI)) {
Desktop.getDesktop().setOpenURIHandler(openURIHandler);
}
}
} catch(Exception e) {
log.error("Could not add URI handler", e);
}
}
private static void openBitcoinUri(URI uri) {
try {
BitcoinURI bitcoinURI = new BitcoinURI(uri.toString());
Wallet wallet = selectWallet(null, "pay from");
if(wallet != null) {
final Wallet sendingWallet = wallet;
EventManager.get().post(new SendActionEvent(sendingWallet, new ArrayList<>(sendingWallet.getWalletUtxos().keySet())));
Platform.runLater(() -> EventManager.get().post(new SendPaymentsEvent(sendingWallet, List.of(bitcoinURI.toPayment()))));
}
} catch(Exception e) {
showErrorDialog("Not a valid bitcoin URI", e.getMessage());
}
}
public static void openAddressOwnershipProof(URI uri) {
try {
Aopp aopp = new Aopp(uri);
Wallet wallet = selectWallet(aopp.getScriptType(), "send proof of address");
if(wallet != null) {
EventManager.get().post(new ReceiveActionEvent(wallet));
Platform.runLater(() -> EventManager.get().post(new ReceiveProofEvent(wallet, aopp)));
}
} catch(Exception e) {
showErrorDialog("Not a valid AOPP URI", e.getMessage());
}
}
private static Wallet selectWallet(ScriptType scriptType, String actionDescription) {
Wallet wallet = null;
List<Wallet> wallets = get().getOpenWallets().keySet().stream().filter(w -> scriptType == null || w.getScriptType() == scriptType).collect(Collectors.toList());
if(wallets.isEmpty()) {
showErrorDialog("No wallet available", "Open a" + (scriptType == null ? "" : " " + scriptType.getDescription()) + " wallet to " + actionDescription + ".");
} else if(wallets.size() == 1) {
wallet = wallets.iterator().next();
} else {
ChoiceDialog<Wallet> walletChoiceDialog = new ChoiceDialog<>(wallets.iterator().next(), wallets);
walletChoiceDialog.setTitle("Choose Wallet");
walletChoiceDialog.setHeaderText("Choose a wallet to " + actionDescription);
Image image = new Image("/image/sparrow-small.png");
walletChoiceDialog.getDialogPane().setGraphic(new ImageView(image));
setStageIcon(walletChoiceDialog.getDialogPane().getScene().getWindow());
moveToActiveWindowScreen(walletChoiceDialog);
Optional<Wallet> optWallet = walletChoiceDialog.showAndWait();
if(optWallet.isPresent()) {
wallet = optWallet.get();
}
}
return wallet;
}
public static Font getMonospaceFont() {
return Font.font("Roboto Mono", 13);
}
@@ -637,7 +836,10 @@ public class AppServices {
@Subscribe
public void openWallets(OpenWalletsEvent event) {
if(event.getWalletTabDataList().isEmpty()) {
walletWindows.remove(event.getWindow());
List<WalletTabData> closedTabData = walletWindows.remove(event.getWindow());
if(closedTabData != null && !closedTabData.isEmpty()) {
EventManager.get().post(new WalletTabsClosedEvent(closedTabData));
}
} else {
walletWindows.put(event.getWindow(), event.getWalletTabDataList());
}
@@ -646,7 +848,7 @@ public class AppServices {
Platform.runLater(() -> {
if(!Window.getWindows().isEmpty()) {
List<File> walletFiles = allWallets.stream().map(walletTabData -> walletTabData.getStorage().getWalletFile()).collect(Collectors.toList());
List<File> walletFiles = allWallets.stream().filter(walletTabData -> walletTabData.getWallet().getMasterWallet() == null).map(walletTabData -> walletTabData.getStorage().getWalletFile()).collect(Collectors.toList());
Config.get().setRecentWalletFiles(Config.get().isLoadRecentWallets() ? walletFiles : Collections.emptyList());
}
});
@@ -663,10 +865,6 @@ public class AppServices {
deviceEnumerateService = createDeviceEnumerateService();
}
if(deviceEnumerateService.isRunning()) {
deviceEnumerateService.cancel();
}
if(deviceEnumerateService.getState() == Worker.State.CANCELLED) {
deviceEnumerateService.reset();
}
@@ -7,14 +7,14 @@ import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5Brands;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.FileType;
import com.sparrowwallet.sparrow.io.IOUtils;
import com.sparrowwallet.sparrow.io.Storage;
import com.sparrowwallet.sparrow.net.Bwt;
import com.sparrowwallet.sparrow.net.PublicElectrumServer;
import com.sparrowwallet.sparrow.net.ServerType;
import com.sparrowwallet.sparrow.preferences.PreferenceGroup;
import com.sparrowwallet.sparrow.preferences.PreferencesDialog;
import com.sparrowwallet.sparrow.instance.InstanceException;
import com.sparrowwallet.sparrow.instance.InstanceList;
import javafx.application.Application;
import javafx.scene.text.Font;
import javafx.stage.Stage;
@@ -29,13 +29,17 @@ import java.util.*;
import java.util.stream.Collectors;
public class MainApp extends Application {
public static final String APP_ID = "com.sparrowwallet.sparrow";
public static final String APP_NAME = "Sparrow";
public static final String APP_VERSION = "1.4.0";
public static final String APP_VERSION = "1.4.2";
public static final String APP_VERSION_SUFFIX = "";
public static final String APP_HOME_PROPERTY = "sparrow.home";
public static final String NETWORK_ENV_PROPERTY = "SPARROW_NETWORK";
private Stage mainStage;
private static SparrowInstance sparrowInstance;
@Override
public void init() throws Exception {
Thread.setDefaultUncaughtExceptionHandler((t, e) -> LoggerFactory.getLogger(MainApp.class).error("Exception in thread \"" + t.getName() + "\"", e));
@@ -94,9 +98,8 @@ public class MainApp extends Application {
List<File> recentWalletFiles = Config.get().getRecentWalletFiles();
if(recentWalletFiles != null) {
//Re-sort to preserve wallet order as far as possible. Unencrypted wallets will still be opened first.
List<File> encryptedWalletFiles = recentWalletFiles.stream().filter(file -> FileType.BINARY.equals(IOUtils.getFileType(file))).collect(Collectors.toList());
Collections.reverse(encryptedWalletFiles);
//Preserve wallet order as far as possible. Unencrypted wallets will still be opened first.
List<File> encryptedWalletFiles = recentWalletFiles.stream().filter(Storage::isEncrypted).collect(Collectors.toList());
List<File> sortedWalletFiles = new ArrayList<>(recentWalletFiles);
sortedWalletFiles.removeAll(encryptedWalletFiles);
sortedWalletFiles.addAll(encryptedWalletFiles);
@@ -108,6 +111,8 @@ public class MainApp extends Application {
}
}
AppServices.openFileUriArguments(stage);
AppServices.get().start();
}
@@ -115,6 +120,9 @@ public class MainApp extends Application {
public void stop() throws Exception {
AppServices.get().stop();
mainStage.close();
if(sparrowInstance != null) {
sparrowInstance.freeLock();
}
}
public static void main(String[] argv) {
@@ -162,6 +170,19 @@ public class MainApp extends Application {
getLogger().info("Using " + Network.get() + " configuration");
}
List<String> fileUriArguments = jCommander.getUnknownOptions();
try {
sparrowInstance = new SparrowInstance(fileUriArguments);
sparrowInstance.acquireLock(); //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);
}
if(!fileUriArguments.isEmpty()) {
AppServices.parseFileUriArguments(fileUriArguments);
}
SLF4JBridgeHandler.removeHandlersForRootLogger();
SLF4JBridgeHandler.install();
com.sun.javafx.application.LauncherImpl.launchApplication(MainApp.class, MainAppPreloader.class, argv);
@@ -170,4 +191,31 @@ public class MainApp extends Application {
private static Logger getLogger() {
return LoggerFactory.getLogger(MainApp.class);
}
private static class SparrowInstance extends InstanceList {
private final List<String> fileUriArguments;
public SparrowInstance(List<String> fileUriArguments) {
super(MainApp.APP_ID + "." + Network.get(), !fileUriArguments.isEmpty());
this.fileUriArguments = fileUriArguments;
}
@Override
protected void receiveMessageList(List<String> messageList) {
if(messageList != null && !messageList.isEmpty()) {
AppServices.parseFileUriArguments(messageList);
AppServices.openFileUriArguments(null);
}
}
@Override
protected List<String> sendMessageList() {
return fileUriArguments;
}
@Override
protected void beforeExit() {
getLogger().info("Opening files/URIs in already running instance, exiting...");
}
}
}
@@ -12,6 +12,7 @@ public class WelcomeDialog extends Dialog<Mode> {
public WelcomeDialog() {
final DialogPane dialogPane = getDialogPane();
AppServices.setStageIcon(dialogPane.getScene().getWindow());
AppServices.onEscapePressed(dialogPane.getScene(), this::close);
try {
FXMLLoader welcomeLoader = new FXMLLoader(AppServices.class.getResource("welcome.fxml"));
@@ -3,6 +3,7 @@ package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.wallet.Entry;
import com.sparrowwallet.sparrow.wallet.NodeEntry;
import com.sparrowwallet.sparrow.wallet.UtxoEntry;
import javafx.geometry.Pos;
import javafx.scene.control.ContentDisplay;
@@ -32,7 +33,7 @@ public class AddressCell extends TreeTableCell<Entry, Entry> {
UtxoEntry utxoEntry = (UtxoEntry)entry;
Address address = utxoEntry.getAddress();
setText(address.toString());
setContextMenu(new EntryCell.AddressContextMenu(address, utxoEntry.getOutputDescriptor(), null));
setContextMenu(new EntryCell.AddressContextMenu(address, utxoEntry.getOutputDescriptor(), new NodeEntry(utxoEntry.getWallet(), utxoEntry.getNode())));
Tooltip tooltip = new Tooltip();
tooltip.setText(getTooltipText(utxoEntry));
setTooltip(tooltip);
@@ -0,0 +1,37 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.address.InvalidAddressException;
import javafx.util.StringConverter;
public class AddressStringConverter extends StringConverter<Address> {
@Override
public Address fromString(String value) {
// If the specified value is null or zero-length, return null
if(value == null) {
return null;
}
value = value.trim();
if (value.length() < 1) {
return null;
}
try {
return Address.fromString(value);
} catch(InvalidAddressException e) {
throw new IllegalArgumentException(e);
}
}
@Override
public String toString(Address value) {
// If the specified value is null, return a zero-length String
if(value == null) {
return "";
}
return value.toString();
}
}
@@ -9,6 +9,7 @@ import com.sparrowwallet.sparrow.wallet.Entry;
import com.sparrowwallet.sparrow.wallet.NodeEntry;
import javafx.application.Platform;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.collections.ListChangeListener;
import javafx.scene.control.*;
import javafx.scene.input.MouseButton;
@@ -82,6 +83,10 @@ public class AddressTreeTable extends CoinTreeTable {
}
}
});
rootEntry.getChildren().addListener((ListChangeListener<Entry>) c -> {
this.refresh();
});
}
public void updateAll(NodeEntry rootEntry) {
@@ -99,22 +104,21 @@ public class AddressTreeTable extends CoinTreeTable {
}
public void updateHistory(List<WalletNode> updatedNodes) {
//We only ever add or replace child nodes - never remove in order to keep a full sequence
//We only ever add child nodes - never remove in order to keep a full sequence
NodeEntry rootEntry = (NodeEntry)getRoot().getValue();
for(WalletNode updatedNode : updatedNodes) {
NodeEntry nodeEntry = new NodeEntry(rootEntry.getWallet(), updatedNode);
Optional<Entry> optEntry = rootEntry.getChildren().stream().filter(childEntry -> ((NodeEntry)childEntry).getNode().equals(updatedNode)).findFirst();
if(optEntry.isPresent()) {
int index = rootEntry.getChildren().indexOf(optEntry.get());
rootEntry.getChildren().set(index, nodeEntry);
NodeEntry existingEntry = (NodeEntry)optEntry.get();
existingEntry.refreshChildren();
} else {
NodeEntry nodeEntry = new NodeEntry(rootEntry.getWallet(), updatedNode);
rootEntry.getChildren().add(nodeEntry);
}
}
sort();
refresh();
}
public void updateLabel(Entry entry) {
@@ -10,16 +10,24 @@ import javafx.scene.control.ContentDisplay;
import javafx.scene.control.Tooltip;
import javafx.scene.control.TreeTableCell;
import javafx.scene.layout.Region;
import org.controlsfx.tools.Platform;
import java.text.DecimalFormat;
import java.text.DecimalFormatSymbols;
import java.util.Locale;
class CoinCell extends TreeTableCell<Entry, Number> {
public static final DecimalFormat TABLE_BTC_FORMAT = new DecimalFormat("0.00000000", DecimalFormatSymbols.getInstance(Locale.ENGLISH));
private final Tooltip tooltip;
public CoinCell() {
super();
tooltip = new Tooltip();
getStyleClass().add("coin-cell");
if(Platform.getCurrent() == Platform.OSX) {
getStyleClass().add("number-field");
}
}
@Override
@@ -38,7 +46,8 @@ class CoinCell extends TreeTableCell<Entry, Number> {
BitcoinUnit unit = coinTreeTable.getBitcoinUnit();
String satsValue = String.format(Locale.ENGLISH, "%,d", amount.longValue());
final String btcValue = CoinLabel.getBTCFormat().format(amount.doubleValue() / Transaction.SATOSHIS_PER_BITCOIN);
DecimalFormat decimalFormat = (amount.longValue() == 0L ? CoinLabel.getBTCFormat() : TABLE_BTC_FORMAT);
final String btcValue = decimalFormat.format(amount.doubleValue() / Transaction.SATOSHIS_PER_BITCOIN);
if(unit.equals(BitcoinUnit.BTC)) {
tooltip.setText(satsValue + " " + BitcoinUnit.SATOSHIS.getLabel());
@@ -5,8 +5,8 @@ import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.WalletDataChangedEvent;
import com.sparrowwallet.sparrow.event.WalletHistoryClearedEvent;
import com.sparrowwallet.sparrow.event.WalletHistoryStatusEvent;
import com.sparrowwallet.sparrow.event.WalletAddressesChangedEvent;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.Storage;
import com.sparrowwallet.sparrow.net.ServerType;
@@ -92,7 +92,7 @@ public class CoinTreeTable extends TreeTableView<Entry> {
EventManager.get().post(new WalletDataChangedEvent(wallet));
//Trigger full wallet rescan
wallet.clearHistory();
EventManager.get().post(new WalletAddressesChangedEvent(wallet, pastWallet, storage.getWalletFile()));
EventManager.get().post(new WalletHistoryClearedEvent(wallet, pastWallet, storage.getWalletId(wallet)));
}
});
if(wallet.getBirthDate() == null) {
@@ -34,7 +34,7 @@ public class DateCell extends TreeTableCell<Entry, Entry> {
if(entry instanceof UtxoEntry) {
UtxoEntry utxoEntry = (UtxoEntry)entry;
if(utxoEntry.getHashIndex().getHeight() <= 0) {
setText("Unconfirmed " + (utxoEntry.isSpendable() ? "(Spendable)" : "(Not yet spendable)"));
setText("Unconfirmed " + (utxoEntry.getHashIndex().getHeight() < 0 ? "Parent " : "") + (utxoEntry.isSpendable() ? "(Spendable)" : "(Not yet spendable)"));
} else {
String date = DATE_FORMAT.format(utxoEntry.getHashIndex().getDate());
setText(date);
@@ -25,8 +25,8 @@ public class DeviceAddressDialog extends DeviceDialog<String> {
}
@Override
protected DevicePane getDevicePane(Device device) {
return new DevicePane(wallet, outputDescriptor, device);
protected DevicePane getDevicePane(Device device, boolean defaultDevice) {
return new DevicePane(wallet, outputDescriptor, device, defaultDevice);
}
@Subscribe
@@ -22,6 +22,7 @@ import java.util.Objects;
public abstract class DeviceDialog<R> extends Dialog<R> {
private final List<String> operationFingerprints;
private final Accordion deviceAccordion;
private final Button scanButton;
private final VBox scanBox;
private final Label scanLabel;
@@ -57,18 +58,19 @@ public abstract class DeviceDialog<R> extends Dialog<R> {
Glyph usb = new Glyph(FontAwesome5Brands.FONT_NAME, FontAwesome5Brands.Glyph.USB);
usb.setFontSize(50);
scanLabel = new Label("Connect Hardware Wallet");
Button button = new Button("Scan...");
button.setPrefSize(120, 60);
button.setOnAction(event -> {
scanButton = new Button("Scan...");
scanButton.setPrefSize(120, 60);
scanButton.setOnAction(event -> {
scan();
});
scanBox.getChildren().addAll(usb, scanLabel, button);
scanBox.getChildren().addAll(usb, scanLabel, scanButton);
scanBox.managedProperty().bind(scanBox.visibleProperty());
stackPane.getChildren().addAll(anchorPane, scanBox);
List<Device> devices = AppServices.getDevices();
if(devices == null || devices.isEmpty()) {
scanButton.setDefaultButton(true);
scanBox.setVisible(true);
} else {
Platform.runLater(() -> setDevices(devices));
@@ -97,15 +99,21 @@ public abstract class DeviceDialog<R> extends Dialog<R> {
private void scan() {
Hwi.EnumerateService enumerateService = new Hwi.EnumerateService(null);
enumerateService.setOnSucceeded(workerStateEvent -> {
scanButton.setText("Scan...");
List<Device> devices = enumerateService.getValue();
setDevices(devices);
Platform.runLater(() -> EventManager.get().post(new UsbDeviceEvent(devices)));
});
enumerateService.setOnFailed(workerStateEvent -> {
scanButton.setText("Scan...");
deviceAccordion.getPanes().clear();
scanButton.setDefaultButton(true);
scanBox.setVisible(true);
scanLabel.setText(workerStateEvent.getSource().getException().getMessage());
});
enumerateService.setOnRunning(workerStateEvent -> {
scanButton.setText("Scanning...");
});
enumerateService.start();
}
@@ -122,16 +130,17 @@ public abstract class DeviceDialog<R> extends Dialog<R> {
deviceAccordion.getPanes().clear();
if(dialogDevices.isEmpty()) {
scanButton.setDefaultButton(true);
scanBox.setVisible(true);
scanLabel.setText("No matching devices found");
} else {
scanBox.setVisible(false);
for(Device device : dialogDevices) {
DevicePane devicePane = getDevicePane(device);
DevicePane devicePane = getDevicePane(device, dialogDevices.size() == 1);
deviceAccordion.getPanes().add(devicePane);
}
}
}
protected abstract DevicePane getDevicePane(Device device);
protected abstract DevicePane getDevicePane(Device device, boolean defaultDevice);
}
@@ -59,7 +59,9 @@ public class DevicePane extends TitledDescriptionPane {
private final SimpleStringProperty passphrase = new SimpleStringProperty("");
public DevicePane(Wallet wallet, Device device) {
private boolean defaultDevice;
public DevicePane(Wallet wallet, Device device, boolean defaultDevice) {
super(device.getModel().toDisplayString(), "", "", "image/" + device.getType() + ".png");
this.deviceOperation = DeviceOperation.IMPORT;
this.wallet = wallet;
@@ -68,6 +70,7 @@ public class DevicePane extends TitledDescriptionPane {
this.keyDerivation = null;
this.message = null;
this.device = device;
this.defaultDevice = defaultDevice;
setDefaultStatus();
showHideLink.setVisible(false);
@@ -80,7 +83,7 @@ public class DevicePane extends TitledDescriptionPane {
buttonBox.getChildren().addAll(setPassphraseButton, importButton);
}
public DevicePane(PSBT psbt, Device device) {
public DevicePane(PSBT psbt, Device device, boolean defaultDevice) {
super(device.getModel().toDisplayString(), "", "", "image/" + device.getType() + ".png");
this.deviceOperation = DeviceOperation.SIGN;
this.wallet = null;
@@ -89,6 +92,7 @@ public class DevicePane extends TitledDescriptionPane {
this.keyDerivation = null;
this.message = null;
this.device = device;
this.defaultDevice = defaultDevice;
setDefaultStatus();
showHideLink.setVisible(false);
@@ -101,7 +105,7 @@ public class DevicePane extends TitledDescriptionPane {
buttonBox.getChildren().addAll(setPassphraseButton, signButton);
}
public DevicePane(Wallet wallet, OutputDescriptor outputDescriptor, Device device) {
public DevicePane(Wallet wallet, OutputDescriptor outputDescriptor, Device device, boolean defaultDevice) {
super(device.getModel().toDisplayString(), "", "", "image/" + device.getType() + ".png");
this.deviceOperation = DeviceOperation.DISPLAY_ADDRESS;
this.wallet = wallet;
@@ -110,6 +114,7 @@ public class DevicePane extends TitledDescriptionPane {
this.keyDerivation = null;
this.message = null;
this.device = device;
this.defaultDevice = defaultDevice;
setDefaultStatus();
showHideLink.setVisible(false);
@@ -122,7 +127,7 @@ public class DevicePane extends TitledDescriptionPane {
buttonBox.getChildren().addAll(setPassphraseButton, displayAddressButton);
}
public DevicePane(Wallet wallet, String message, KeyDerivation keyDerivation, Device device) {
public DevicePane(Wallet wallet, String message, KeyDerivation keyDerivation, Device device, boolean defaultDevice) {
super(device.getModel().toDisplayString(), "", "", "image/" + device.getType() + ".png");
this.deviceOperation = DeviceOperation.SIGN_MESSAGE;
this.wallet = wallet;
@@ -131,6 +136,7 @@ public class DevicePane extends TitledDescriptionPane {
this.keyDerivation = keyDerivation;
this.message = message;
this.device = device;
this.defaultDevice = defaultDevice;
setDefaultStatus();
showHideLink.setVisible(false);
@@ -145,6 +151,7 @@ public class DevicePane extends TitledDescriptionPane {
private void initialise(Device device) {
if(device.isNeedsPinSent()) {
unlockButton.setDefaultButton(defaultDevice);
unlockButton.setVisible(true);
} else if(device.isNeedsPassphraseSent()) {
setPassphraseButton.setVisible(true);
@@ -229,7 +236,6 @@ public class DevicePane extends TitledDescriptionPane {
private void createSignButton() {
signButton = new Button("Sign");
signButton.setDefaultButton(true);
signButton.setAlignment(Pos.CENTER_RIGHT);
signButton.setMinWidth(44);
signButton.setOnAction(event -> {
@@ -242,7 +248,6 @@ public class DevicePane extends TitledDescriptionPane {
private void createDisplayAddressButton() {
displayAddressButton = new Button("Display Address");
displayAddressButton.setDefaultButton(true);
displayAddressButton.setAlignment(Pos.CENTER_RIGHT);
displayAddressButton.setOnAction(event -> {
displayAddressButton.setDisable(true);
@@ -259,7 +264,6 @@ public class DevicePane extends TitledDescriptionPane {
private void createSignMessageButton() {
signMessageButton = new Button("Sign Message");
signMessageButton.setDefaultButton(true);
signMessageButton.setAlignment(Pos.CENTER_RIGHT);
signMessageButton.setOnAction(event -> {
signMessageButton.setDisable(true);
@@ -284,7 +288,9 @@ public class DevicePane extends TitledDescriptionPane {
vBox.setMaxHeight(120);
vBox.setSpacing(42);
pinField = (CustomPasswordField)TextFields.createClearablePasswordField();
Platform.runLater(() -> pinField.requestFocus());
enterPinButton = new Button("Enter PIN");
enterPinButton.setDefaultButton(true);
enterPinButton.setOnAction(event -> {
enterPinButton.setDisable(true);
sendPin(pinField.getText());
@@ -324,9 +330,15 @@ public class DevicePane extends TitledDescriptionPane {
CustomPasswordField passphraseField = (CustomPasswordField)TextFields.createClearablePasswordField();
passphrase.bind(passphraseField.textProperty());
HBox.setHgrow(passphraseField, Priority.ALWAYS);
passphraseField.setOnAction(event -> {
setExpanded(false);
setDescription("Confirm passphrase on device...");
sendPassphrase(passphrase.get());
});
SplitMenuButton sendPassphraseButton = new SplitMenuButton();
sendPassphraseButton.setText("Send Passphrase");
sendPassphraseButton.getStyleClass().add("default-button");
sendPassphraseButton.setOnAction(event -> {
setExpanded(false);
setDescription("Confirm passphrase on device...");
@@ -604,17 +616,23 @@ public class DevicePane extends TitledDescriptionPane {
private void showOperationButton() {
if(deviceOperation.equals(DeviceOperation.IMPORT)) {
if(defaultDevice) {
importButton.getStyleClass().add("default-button");
}
importButton.setVisible(true);
showHideLink.setText("Show derivation...");
showHideLink.setVisible(true);
setContent(getDerivationEntry(wallet.getScriptType() == null ? ScriptType.P2WPKH.getDefaultDerivation() : wallet.getScriptType().getDefaultDerivation()));
} else if(deviceOperation.equals(DeviceOperation.SIGN)) {
signButton.setDefaultButton(defaultDevice);
signButton.setVisible(true);
showHideLink.setVisible(false);
} else if(deviceOperation.equals(DeviceOperation.DISPLAY_ADDRESS)) {
displayAddressButton.setDefaultButton(defaultDevice);
displayAddressButton.setVisible(true);
showHideLink.setVisible(false);
} else if(deviceOperation.equals(DeviceOperation.SIGN_MESSAGE)) {
signMessageButton.setDefaultButton(defaultDevice);
signMessageButton.setVisible(true);
showHideLink.setVisible(false);
}
@@ -627,11 +645,11 @@ public class DevicePane extends TitledDescriptionPane {
HBox.setHgrow(derivationField, Priority.ALWAYS);
ValidationSupport validationSupport = new ValidationSupport();
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
validationSupport.registerValidator(derivationField, Validator.combine(
Validator.createEmptyValidator("Derivation is required"),
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Invalid derivation", !KeyDerivation.isValid(newValue))
));
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
Button importDerivationButton = new Button("Import");
importDerivationButton.setOnAction(event -> {
@@ -22,8 +22,8 @@ public class DeviceSignDialog extends DeviceDialog<PSBT> {
}
@Override
protected DevicePane getDevicePane(Device device) {
return new DevicePane(psbt, device);
protected DevicePane getDevicePane(Device device, boolean defaultDevice) {
return new DevicePane(psbt, device, defaultDevice);
}
@Subscribe
@@ -26,8 +26,8 @@ public class DeviceSignMessageDialog extends DeviceDialog<String> {
}
@Override
protected DevicePane getDevicePane(Device device) {
return new DevicePane(wallet, message, keyDerivation, device);
protected DevicePane getDevicePane(Device device, boolean defaultDevice) {
return new DevicePane(wallet, message, keyDerivation, device, defaultDevice);
}
@Subscribe
@@ -4,6 +4,7 @@ import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.protocol.TransactionInput;
import com.sparrowwallet.drongo.protocol.TransactionOutput;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.AppServices;
@@ -72,9 +73,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
HBox actionBox = new HBox();
Button viewTransactionButton = new Button("");
Glyph searchGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.SEARCH);
searchGlyph.setFontSize(12);
viewTransactionButton.setGraphic(searchGlyph);
viewTransactionButton.setGraphic(getViewTransactionGlyph());
viewTransactionButton.setOnAction(event -> {
EventManager.get().post(new ViewTransactionEvent(this.getScene().getWindow(), transactionEntry.getBlockTransaction()));
});
@@ -83,9 +82,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
BlockTransaction blockTransaction = transactionEntry.getBlockTransaction();
if(blockTransaction.getHeight() <= 0 && blockTransaction.getTransaction().isReplaceByFee() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) {
Button increaseFeeButton = new Button("");
Glyph increaseFeeGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.HAND_HOLDING_MEDICAL);
increaseFeeGlyph.setFontSize(12);
increaseFeeButton.setGraphic(increaseFeeGlyph);
increaseFeeButton.setGraphic(getIncreaseFeeRBFGlyph());
increaseFeeButton.setOnAction(event -> {
increaseFee(transactionEntry);
});
@@ -94,9 +91,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
if(blockTransaction.getHeight() <= 0 && containsWalletOutputs(transactionEntry)) {
Button cpfpButton = new Button("");
Glyph cpfpGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.SIGN_OUT_ALT);
cpfpGlyph.setFontSize(12);
cpfpButton.setGraphic(cpfpGlyph);
cpfpButton.setGraphic(getIncreaseFeeCPFPGlyph());
cpfpButton.setOnAction(event -> {
createCpfp(transactionEntry);
});
@@ -108,7 +103,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
NodeEntry nodeEntry = (NodeEntry)entry;
Address address = nodeEntry.getAddress();
setText(address.toString());
setContextMenu(new AddressContextMenu(address, nodeEntry.getOutputDescriptor(), null));
setContextMenu(new AddressContextMenu(address, nodeEntry.getOutputDescriptor(), nodeEntry));
Tooltip tooltip = new Tooltip();
tooltip.setText(nodeEntry.getNode().getDerivationPath().replace("m", ".."));
setTooltip(tooltip);
@@ -116,9 +111,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
HBox actionBox = new HBox();
Button receiveButton = new Button("");
Glyph receiveGlyph = new Glyph("FontAwesome", FontAwesome.Glyph.ARROW_DOWN);
receiveGlyph.setFontSize(12);
receiveButton.setGraphic(receiveGlyph);
receiveButton.setGraphic(getReceiveGlyph());
receiveButton.setOnAction(event -> {
EventManager.get().post(new ReceiveActionEvent(nodeEntry));
Platform.runLater(() -> EventManager.get().post(new ReceiveToEvent(nodeEntry)));
@@ -128,9 +121,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
if(nodeEntry.getWallet().getKeystores().size() == 1 &&
(nodeEntry.getWallet().getKeystores().get(0).hasPrivateKey() || nodeEntry.getWallet().getKeystores().get(0).getSource() == KeystoreSource.HW_USB)) {
Button signMessageButton = new Button("");
Glyph signMessageGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.PEN_FANCY);
signMessageGlyph.setFontSize(12);
signMessageButton.setGraphic(signMessageGlyph);
signMessageButton.setGraphic(getSignMessageGlyph());
signMessageButton.setOnAction(event -> {
MessageSignDialog messageSignDialog = new MessageSignDialog(nodeEntry.getWallet(), nodeEntry.getNode());
messageSignDialog.showAndWait();
@@ -150,9 +141,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
HBox actionBox = new HBox();
Button viewTransactionButton = new Button("");
Glyph searchGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.SEARCH);
searchGlyph.setFontSize(12);
viewTransactionButton.setGraphic(searchGlyph);
viewTransactionButton.setGraphic(getViewTransactionGlyph());
viewTransactionButton.setOnAction(event -> {
EventManager.get().post(new ViewTransactionEvent(this.getScene().getWindow(), hashIndexEntry.getBlockTransaction(), hashIndexEntry));
});
@@ -160,9 +149,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
if(hashIndexEntry.getType().equals(HashIndexEntry.Type.OUTPUT) && hashIndexEntry.isSpendable() && !hashIndexEntry.getHashIndex().isSpent()) {
Button spendUtxoButton = new Button("");
Glyph sendGlyph = new Glyph("FontAwesome", FontAwesome.Glyph.SEND);
sendGlyph.setFontSize(12);
spendUtxoButton.setGraphic(sendGlyph);
spendUtxoButton.setGraphic(getSendGlyph());
spendUtxoButton.setOnAction(event -> {
sendSelectedUtxos(getTreeTableView(), hashIndexEntry);
});
@@ -182,6 +169,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
.map(e -> (HashIndexEntry)e)
.filter(e -> e.getType().equals(HashIndexEntry.Type.INPUT) && e.isSpendable())
.map(e -> blockTransaction.getTransaction().getInputs().get((int)e.getHashIndex().getIndex()))
.filter(TransactionInput::isReplaceByFeeEnabled)
.map(txInput -> walletTxos.keySet().stream().filter(txo -> txo.getHash().equals(txInput.getOutpoint().getHash()) && txo.getIndex() == txInput.getOutpoint().getIndex()).findFirst().get())
.collect(Collectors.toList());
@@ -249,7 +237,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
}
private static Double getMaxFeeRate() {
if(AppServices.getTargetBlockFeeRates().isEmpty()) {
if(AppServices.getTargetBlockFeeRates() == null || AppServices.getTargetBlockFeeRates().isEmpty()) {
return 100.0;
}
@@ -314,21 +302,74 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
EventManager.get().post(new WalletUtxoStatusChangedEvent(hashIndexEntry.getWallet(), hashIndexEntry.getHashIndex()));
}
private static Glyph getViewTransactionGlyph() {
Glyph searchGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.SEARCH);
searchGlyph.setFontSize(12);
return searchGlyph;
}
private static Glyph getIncreaseFeeRBFGlyph() {
Glyph increaseFeeGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.HAND_HOLDING_MEDICAL);
increaseFeeGlyph.setFontSize(12);
return increaseFeeGlyph;
}
private static Glyph getIncreaseFeeCPFPGlyph() {
Glyph cpfpGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.SIGN_OUT_ALT);
cpfpGlyph.setFontSize(12);
return cpfpGlyph;
}
private static Glyph getReceiveGlyph() {
Glyph receiveGlyph = new Glyph("FontAwesome", FontAwesome.Glyph.ARROW_DOWN);
receiveGlyph.setFontSize(12);
return receiveGlyph;
}
private static Glyph getSignMessageGlyph() {
Glyph signMessageGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.PEN_FANCY);
signMessageGlyph.setFontSize(12);
return signMessageGlyph;
}
private static Glyph getSendGlyph() {
Glyph sendGlyph = new Glyph("FontAwesome", FontAwesome.Glyph.SEND);
sendGlyph.setFontSize(12);
return sendGlyph;
}
private static Glyph getCopyGlyph() {
Glyph copyGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.COPY);
copyGlyph.setFontSize(12);
return copyGlyph;
}
private static Glyph getFreezeGlyph() {
Glyph copyGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.SNOWFLAKE);
copyGlyph.setFontSize(12);
return copyGlyph;
}
private static Glyph getUnfreezeGlyph() {
Glyph copyGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.SUN);
copyGlyph.setFontSize(12);
return copyGlyph;
}
private static class UnconfirmedTransactionContextMenu extends ContextMenu {
public UnconfirmedTransactionContextMenu(TransactionEntry transactionEntry) {
BlockTransaction blockTransaction = transactionEntry.getBlockTransaction();
MenuItem copyTxid = new MenuItem("Copy Transaction ID");
copyTxid.setOnAction(AE -> {
MenuItem viewTransaction = new MenuItem("View Transaction");
viewTransaction.setGraphic(getViewTransactionGlyph());
viewTransaction.setOnAction(AE -> {
hide();
ClipboardContent content = new ClipboardContent();
content.putString(blockTransaction.getHashAsString());
Clipboard.getSystemClipboard().setContent(content);
EventManager.get().post(new ViewTransactionEvent(this.getOwnerWindow(), blockTransaction));
});
getItems().add(copyTxid);
getItems().add(viewTransaction);
if(blockTransaction.getTransaction().isReplaceByFee() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) {
MenuItem increaseFee = new MenuItem("Increase Fee (RBF)");
increaseFee.setGraphic(getIncreaseFeeRBFGlyph());
increaseFee.setOnAction(AE -> {
hide();
increaseFee(transactionEntry);
@@ -339,6 +380,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
if(containsWalletOutputs(transactionEntry)) {
MenuItem createCpfp = new MenuItem("Increase Effective Fee (CPFP)");
createCpfp.setGraphic(getIncreaseFeeCPFPGlyph());
createCpfp.setOnAction(AE -> {
hide();
createCpfp(transactionEntry);
@@ -346,11 +388,28 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
getItems().add(createCpfp);
}
MenuItem copyTxid = new MenuItem("Copy Transaction ID");
copyTxid.setOnAction(AE -> {
hide();
ClipboardContent content = new ClipboardContent();
content.putString(blockTransaction.getHashAsString());
Clipboard.getSystemClipboard().setContent(content);
});
getItems().add(copyTxid);
}
}
private static class TransactionContextMenu extends ContextMenu {
public TransactionContextMenu(String date, BlockTransaction blockTransaction) {
MenuItem viewTransaction = new MenuItem("View Transaction");
viewTransaction.setGraphic(getViewTransactionGlyph());
viewTransaction.setOnAction(AE -> {
hide();
EventManager.get().post(new ViewTransactionEvent(this.getOwnerWindow(), blockTransaction));
});
MenuItem copyDate = new MenuItem("Copy Date");
copyDate.setOnAction(AE -> {
hide();
@@ -375,12 +434,32 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
Clipboard.getSystemClipboard().setContent(content);
});
getItems().addAll(copyDate, copyTxid, copyHeight);
getItems().addAll(viewTransaction, copyDate, copyTxid, copyHeight);
}
}
public static class AddressContextMenu extends ContextMenu {
public AddressContextMenu(Address address, String outputDescriptor, NodeEntry nodeEntry) {
MenuItem receiveToAddress = new MenuItem("Receive To");
receiveToAddress.setGraphic(getReceiveGlyph());
receiveToAddress.setOnAction(event -> {
hide();
EventManager.get().post(new ReceiveActionEvent(nodeEntry));
Platform.runLater(() -> EventManager.get().post(new ReceiveToEvent(nodeEntry)));
});
getItems().add(receiveToAddress);
if(nodeEntry != null) {
MenuItem signVerifyMessage = new MenuItem("Sign/Verify Message");
signVerifyMessage.setGraphic(getSignMessageGlyph());
signVerifyMessage.setOnAction(AE -> {
hide();
MessageSignDialog messageSignDialog = new MessageSignDialog(nodeEntry.getWallet(), nodeEntry.getNode());
messageSignDialog.showAndWait();
});
getItems().add(signVerifyMessage);
}
MenuItem copyAddress = new MenuItem("Copy Address");
copyAddress.setOnAction(AE -> {
hide();
@@ -406,34 +485,22 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
});
getItems().addAll(copyAddress, copyHex, copyOutputDescriptor);
if(nodeEntry != null) {
MenuItem signVerifyMessage = new MenuItem("Sign/Verify Message");
signVerifyMessage.setOnAction(AE -> {
hide();
MessageSignDialog messageSignDialog = new MessageSignDialog(nodeEntry.getWallet(), nodeEntry.getNode());
messageSignDialog.showAndWait();
});
getItems().add(signVerifyMessage);
}
}
}
private static class HashIndexEntryContextMenu extends ContextMenu {
public HashIndexEntryContextMenu(TreeTableView<Entry> treeTableView, HashIndexEntry hashIndexEntry) {
String label = "Copy " + (hashIndexEntry.getType().equals(HashIndexEntry.Type.OUTPUT) ? "Transaction Output" : "Transaction Input");
MenuItem copyHashIndex = new MenuItem(label);
copyHashIndex.setOnAction(AE -> {
MenuItem viewTransaction = new MenuItem("View Transaction");
viewTransaction.setGraphic(getViewTransactionGlyph());
viewTransaction.setOnAction(AE -> {
hide();
ClipboardContent content = new ClipboardContent();
content.putString(hashIndexEntry.getHashIndex().toString());
Clipboard.getSystemClipboard().setContent(content);
EventManager.get().post(new ViewTransactionEvent(this.getOwnerWindow(), hashIndexEntry.getBlockTransaction()));
});
getItems().add(copyHashIndex);
getItems().add(viewTransaction);
if(hashIndexEntry.getType().equals(HashIndexEntry.Type.OUTPUT) && hashIndexEntry.isSpendable() && !hashIndexEntry.getHashIndex().isSpent()) {
MenuItem sendSelected = new MenuItem("Send Selected");
sendSelected.setGraphic(getSendGlyph());
sendSelected.setOnAction(AE -> {
hide();
sendSelectedUtxos(treeTableView, hashIndexEntry);
@@ -444,6 +511,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
if(hashIndexEntry.getType().equals(HashIndexEntry.Type.OUTPUT) && !hashIndexEntry.getHashIndex().isSpent()) {
if(hashIndexEntry.getHashIndex().getStatus() == null || hashIndexEntry.getHashIndex().getStatus() != Status.FROZEN) {
MenuItem freezeUtxo = new MenuItem("Freeze UTXO");
freezeUtxo.setGraphic(getFreezeGlyph());
freezeUtxo.setOnAction(AE -> {
hide();
freezeUtxo(hashIndexEntry);
@@ -451,6 +519,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
getItems().add(freezeUtxo);
} else {
MenuItem unfreezeUtxo = new MenuItem("Unfreeze UTXO");
unfreezeUtxo.setGraphic(getUnfreezeGlyph());
unfreezeUtxo.setOnAction(AE -> {
hide();
unfreezeUtxo(hashIndexEntry);
@@ -458,6 +527,16 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
getItems().add(unfreezeUtxo);
}
}
String label = "Copy " + (hashIndexEntry.getType().equals(HashIndexEntry.Type.OUTPUT) ? "Transaction Output" : "Transaction Input");
MenuItem copyHashIndex = new MenuItem(label);
copyHashIndex.setOnAction(AE -> {
hide();
ClipboardContent content = new ClipboardContent();
content.putString(hashIndexEntry.getHashIndex().toString());
Clipboard.getSystemClipboard().setContent(content);
});
getItems().add(copyHashIndex);
}
}
@@ -4,6 +4,7 @@ import com.google.gson.JsonParseException;
import com.sparrowwallet.drongo.crypto.InvalidPasswordException;
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.sparrow.AppServices;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
@@ -113,8 +114,9 @@ public abstract class FileImportPane extends TitledDescriptionPane {
importButton.setDisable(true);
setExpanded(true);
} else {
InputStream inputStream = new BufferedInputStream(new FileInputStream(file));
importFile(file.getName(), inputStream, password);
try(InputStream inputStream = new BufferedInputStream(new FileInputStream(file))) {
importFile(file.getName(), inputStream, password);
};
}
} catch (Exception e) {
log.error("Error importing file", e);
@@ -171,11 +173,15 @@ public abstract class FileImportPane extends TitledDescriptionPane {
if(wallets != null) {
for(Wallet wallet : wallets) {
if(scriptType.equals(wallet.getScriptType()) && !wallet.getKeystores().isEmpty()) {
return wallet.getKeystores().get(0);
Keystore keystore = wallet.getKeystores().get(0);
keystore.setLabel(importer.getName().replace(" Multisig", ""));
keystore.setSource(KeystoreSource.HW_AIRGAPPED);
keystore.setWalletModel(importer.getWalletModel());
return keystore;
}
}
throw new ImportException("Script type " + scriptType + " is not supported");
throw new ImportException("Script type " + scriptType.getDescription() + " is not supported in this QR. Check you are displaying the correct QR code.");
}
return null;
@@ -9,10 +9,7 @@ import com.sparrowwallet.sparrow.event.StorageEvent;
import com.sparrowwallet.sparrow.event.TimedEvent;
import com.sparrowwallet.sparrow.event.WalletExportEvent;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.CoboVaultMultisig;
import com.sparrowwallet.sparrow.io.PassportMultisig;
import com.sparrowwallet.sparrow.io.Storage;
import com.sparrowwallet.sparrow.io.WalletExport;
import com.sparrowwallet.sparrow.io.*;
import javafx.geometry.Pos;
import javafx.scene.control.Button;
import javafx.scene.control.Control;
@@ -83,8 +80,8 @@ public class FileWalletExportPane extends TitledDescriptionPane {
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("Export " + exporter.getWalletModel().toDisplayString() + " File");
String extension = exporter.getExportFileExtension(wallet);
fileChooser.setInitialFileName(wallet.getName() + "-" +
exporter.getWalletModel().toDisplayString().toLowerCase().replace(" ", "") +
fileChooser.setInitialFileName(wallet.getName() +
(exporter instanceof Sparrow ? "" : "-" + exporter.getWalletModel().toDisplayString().toLowerCase().replace(" ", "")) +
(extension == null || extension.isEmpty() ? "" : "." + extension));
AppServices.moveToActiveWindowScreen(window, 800, 450);
@@ -100,10 +97,10 @@ public class FileWalletExportPane extends TitledDescriptionPane {
WalletPasswordDialog dlg = new WalletPasswordDialog(wallet.getName(), WalletPasswordDialog.PasswordRequirement.LOAD);
Optional<SecureString> password = dlg.showAndWait();
if(password.isPresent()) {
final File walletFile = AppServices.get().getOpenWallets().get(wallet).getWalletFile();
final String walletId = AppServices.get().getOpenWallets().get(wallet).getWalletId(wallet);
Storage.DecryptWalletService decryptWalletService = new Storage.DecryptWalletService(copy, password.get());
decryptWalletService.setOnSucceeded(workerStateEvent -> {
EventManager.get().post(new StorageEvent(walletFile, TimedEvent.Action.END, "Done"));
EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.END, "Done"));
Wallet decryptedWallet = decryptWalletService.getValue();
try {
@@ -113,10 +110,10 @@ public class FileWalletExportPane extends TitledDescriptionPane {
}
});
decryptWalletService.setOnFailed(workerStateEvent -> {
EventManager.get().post(new StorageEvent(walletFile, TimedEvent.Action.END, "Failed"));
EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.END, "Failed"));
setError("Export Error", decryptWalletService.getException().getMessage());
});
EventManager.get().post(new StorageEvent(walletFile, TimedEvent.Action.START, "Decrypting wallet..."));
EventManager.get().post(new StorageEvent(walletId, TimedEvent.Action.START, "Decrypting wallet..."));
decryptWalletService.start();
}
} else {
@@ -127,16 +124,17 @@ public class FileWalletExportPane extends TitledDescriptionPane {
private void exportWallet(File file, Wallet exportWallet) {
try {
if(file != null) {
OutputStream outputStream = new FileOutputStream(file);
exporter.exportWallet(exportWallet, outputStream);
EventManager.get().post(new WalletExportEvent(exportWallet));
try(OutputStream outputStream = new FileOutputStream(file)) {
exporter.exportWallet(exportWallet, outputStream);
EventManager.get().post(new WalletExportEvent(exportWallet));
}
} else {
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
exporter.exportWallet(exportWallet, outputStream);
QRDisplayDialog qrDisplayDialog;
if(exporter instanceof CoboVaultMultisig) {
qrDisplayDialog = new QRDisplayDialog(RegistryType.BYTES.toString(), outputStream.toByteArray(), true);
} else if(exporter instanceof PassportMultisig) {
} else if(exporter instanceof PassportMultisig || exporter instanceof KeystoneMultisig) {
qrDisplayDialog = new QRDisplayDialog(RegistryType.BYTES.toString(), outputStream.toByteArray(), false);
} else {
qrDisplayDialog = new QRDisplayDialog(outputStream.toString(StandardCharsets.UTF_8));
@@ -1,6 +1,7 @@
package com.sparrowwallet.sparrow.control;
import com.google.common.io.ByteStreams;
import com.google.common.io.Files;
import com.google.gson.JsonParseException;
import com.sparrowwallet.drongo.policy.Policy;
import com.sparrowwallet.drongo.policy.PolicyType;
@@ -27,6 +28,8 @@ import org.slf4j.LoggerFactory;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.List;
import java.util.stream.Collectors;
public class FileWalletKeystoreImportPane extends FileImportPane {
private static final Logger log = LoggerFactory.getLogger(FileWalletKeystoreImportPane.class);
@@ -42,35 +45,62 @@ public class FileWalletKeystoreImportPane extends FileImportPane {
protected void importFile(String fileName, InputStream inputStream, String password) throws ImportException {
this.fileName = fileName;
try {
fileBytes = ByteStreams.toByteArray(inputStream);
} catch(IOException e) {
throw new ImportException("Could not read file", e);
List<ScriptType> scriptTypes = ScriptType.getAddressableScriptTypes(PolicyType.SINGLE);
if(wallets != null && !wallets.isEmpty()) {
if(wallets.size() == 1 && scriptTypes.contains(wallets.get(0).getScriptType())) {
Wallet wallet = wallets.get(0);
wallet.setPolicyType(PolicyType.SINGLE);
wallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE, wallet.getScriptType(), wallet.getKeystores(), null));
wallet.setName(importer.getName());
EventManager.get().post(new WalletImportEvent(wallets.get(0)));
} else {
scriptTypes.retainAll(wallets.stream().map(Wallet::getScriptType).collect(Collectors.toList()));
if(scriptTypes.isEmpty()) {
throw new ImportException("No singlesig script types present in QR code");
}
}
} else {
try {
fileBytes = ByteStreams.toByteArray(inputStream);
} catch(IOException e) {
throw new ImportException("Could not read file", e);
}
}
setContent(getScriptTypeEntry());
setContent(getScriptTypeEntry(scriptTypes));
setExpanded(true);
importButton.setDisable(true);
}
private void importWallet(ScriptType scriptType) throws ImportException {
ByteArrayInputStream bais = new ByteArrayInputStream(fileBytes);
Keystore keystore = importer.getKeystore(scriptType, bais, "");
if(wallets != null && !wallets.isEmpty()) {
Wallet wallet = wallets.stream().filter(wallet1 -> wallet1.getScriptType() == scriptType).findFirst().orElseThrow(ImportException::new);
wallet.setName(importer.getName());
wallet.setPolicyType(PolicyType.SINGLE);
wallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE, wallet.getScriptType(), wallet.getKeystores(), null));
EventManager.get().post(new WalletImportEvent(wallet));
} else {
ByteArrayInputStream bais = new ByteArrayInputStream(fileBytes);
Keystore keystore = importer.getKeystore(scriptType, bais, "");
Wallet wallet = new Wallet();
wallet.setName(fileName);
wallet.setPolicyType(PolicyType.SINGLE);
wallet.setScriptType(scriptType);
wallet.getKeystores().add(keystore);
wallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE, scriptType, wallet.getKeystores(), null));
Wallet wallet = new Wallet();
wallet.setName(Files.getNameWithoutExtension(fileName));
wallet.setPolicyType(PolicyType.SINGLE);
wallet.setScriptType(scriptType);
wallet.getKeystores().add(keystore);
wallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE, scriptType, wallet.getKeystores(), null));
EventManager.get().post(new WalletImportEvent(wallet));
EventManager.get().post(new WalletImportEvent(wallet));
}
}
private Node getScriptTypeEntry() {
private Node getScriptTypeEntry(List<ScriptType> scriptTypes) {
Label label = new Label("Script Type:");
ComboBox<ScriptType> scriptTypeComboBox = new ComboBox<>(FXCollections.observableArrayList(ScriptType.getAddressableScriptTypes(PolicyType.SINGLE)));
scriptTypeComboBox.setValue(ScriptType.P2WPKH);
ComboBox<ScriptType> scriptTypeComboBox = new ComboBox<>(FXCollections.observableArrayList(scriptTypes));
if(scriptTypes.contains(ScriptType.P2WPKH)) {
scriptTypeComboBox.setValue(ScriptType.P2WPKH);
}
HelpLabel helpLabel = new HelpLabel();
helpLabel.setHelpText("P2WPKH is a Native Segwit type and is usually the best choice for new wallets.\nP2SH-P2WPKH is a Wrapped Segwit type and is a reasonable choice for the widest compatibility.\nP2PKH is a Legacy type and should be avoided for new wallets.\nFor existing wallets, be sure to choose the type that matches the wallet you are importing.");
@@ -6,6 +6,7 @@ import javafx.scene.control.*;
import javafx.scene.control.cell.TextFieldTreeTableCell;
import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent;
import javafx.scene.input.DataFormat;
import javafx.util.converter.DefaultStringConverter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -28,10 +29,11 @@ class LabelCell extends TextFieldTreeTableCell<Entry, String> {
setText(null);
setGraphic(null);
} else {
EntryCell.applyRowStyles(this, getTreeTableView().getTreeItem(getIndex()).getValue());
Entry entry = getTreeTableView().getTreeItem(getIndex()).getValue();
EntryCell.applyRowStyles(this, entry);
setText(label);
setContextMenu(new LabelContextMenu(label));
setContextMenu(new LabelContextMenu(entry, label));
}
}
@@ -80,7 +82,7 @@ class LabelCell extends TextFieldTreeTableCell<Entry, String> {
}
private static class LabelContextMenu extends ContextMenu {
public LabelContextMenu(String label) {
public LabelContextMenu(Entry entry, String label) {
MenuItem copyLabel = new MenuItem("Copy Label");
copyLabel.setOnAction(AE -> {
hide();
@@ -88,8 +90,20 @@ class LabelCell extends TextFieldTreeTableCell<Entry, String> {
content.putString(label);
Clipboard.getSystemClipboard().setContent(content);
});
getItems().add(copyLabel);
Object content = Clipboard.getSystemClipboard().getContent(DataFormat.PLAIN_TEXT);
if(content instanceof String) {
MenuItem pasteLabel = new MenuItem("Paste Label");
pasteLabel.setOnAction(AE -> {
hide();
Object currentContent = Clipboard.getSystemClipboard().getContent(DataFormat.PLAIN_TEXT);
if(currentContent instanceof String) {
entry.labelProperty().set((String)currentContent);
}
});
getItems().add(pasteLabel);
}
}
}
}
@@ -24,6 +24,7 @@ import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.VBox;
import org.controlsfx.control.SegmentedButton;
import org.controlsfx.validation.ValidationResult;
import org.controlsfx.validation.ValidationSupport;
import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
@@ -34,6 +35,7 @@ import tornadofx.control.Fieldset;
import tornadofx.control.Form;
import java.security.SignatureException;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
@@ -43,8 +45,12 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
private final TextField address;
private final TextArea message;
private final TextArea signature;
private final ToggleGroup formatGroup;
private final ToggleButton formatTrezor;
private final ToggleButton formatElectrum;
private final Wallet wallet;
private WalletNode walletNode;
private boolean electrumSignatureFormat;
/**
* Verification only constructor
@@ -69,6 +75,19 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
* @param walletNode Wallet node to derive address from
*/
public MessageSignDialog(Wallet wallet, WalletNode walletNode) {
this(wallet, walletNode, null, null);
}
/**
* Sign and verify with preset address, and supplied title, message and dialog buttons
*
* @param wallet Wallet to sign with
* @param walletNode Wallet node to derive address from
* @param title Header text of dialog
* @param msg Message to sign (all fields will be made uneditable)
* @param buttons The dialog buttons to display. If one contains the text "sign" it will trigger the signing process
*/
public MessageSignDialog(Wallet wallet, WalletNode walletNode, String title, String msg, ButtonType... buttons) {
if(wallet != null) {
if(wallet.getKeystores().size() != 1) {
throw new IllegalArgumentException("Cannot sign messages using a wallet with multiple keystores - a single key is required");
@@ -84,7 +103,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
final DialogPane dialogPane = getDialogPane();
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
AppServices.setStageIcon(dialogPane.getScene().getWindow());
dialogPane.setHeaderText(wallet == null ? "Verify Message" : "Sign/Verify Message");
dialogPane.setHeaderText(title == null ? (wallet == null ? "Verify Message" : "Sign/Verify Message") : title);
Image image = new Image("image/seed.png", 50, 50, false, false);
if (!image.isError()) {
@@ -129,51 +148,90 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
signature.setWrapText(true);
signatureField.getInputs().add(signature);
fieldset.getChildren().addAll(addressField, messageField, signatureField);
Field formatField = new Field();
formatField.setText("Format:");
formatGroup = new ToggleGroup();
formatElectrum = new ToggleButton("Standard (Electrum)");
formatTrezor = new ToggleButton("BIP137 (Trezor)");
SegmentedButton formatButtons = new SegmentedButton(formatElectrum, formatTrezor);
formatButtons.setToggleGroup(formatGroup);
formatField.getInputs().add(formatButtons);
formatGroup.selectedToggleProperty().addListener((observable, oldValue, newValue) -> {
electrumSignatureFormat = (newValue == formatElectrum);
});
formatButtons.setDisable(wallet != null && walletNode != null && wallet.getScriptType() == ScriptType.P2PKH);
fieldset.getChildren().addAll(addressField, messageField, signatureField, formatField);
form.getChildren().add(fieldset);
dialogPane.setContent(form);
if(msg != null) {
message.setText(msg);
address.setEditable(false);
message.setEditable(false);
signature.setEditable(false);
formatButtons.setDisable(true);
}
ButtonType signButtonType = new javafx.scene.control.ButtonType("Sign", ButtonBar.ButtonData.BACK_PREVIOUS);
ButtonType verifyButtonType = new javafx.scene.control.ButtonType("Verify", ButtonBar.ButtonData.NEXT_FORWARD);
ButtonType doneButtonType = new javafx.scene.control.ButtonType("Done", ButtonBar.ButtonData.OK_DONE);
dialogPane.getButtonTypes().addAll(signButtonType, verifyButtonType, doneButtonType);
Button signButton = (Button)dialogPane.lookupButton(signButtonType);
signButton.setDisable(wallet == null);
signButton.setOnAction(event -> {
signMessage();
});
if(buttons.length > 0) {
dialogPane.getButtonTypes().addAll(buttons);
Button verifyButton = (Button)dialogPane.lookupButton(verifyButtonType);
verifyButton.setDefaultButton(false);
verifyButton.setOnAction(event -> {
verifyMessage();
});
boolean validAddress = isValidAddress();
signButton.setDisable(!validAddress || (wallet == null));
verifyButton.setDisable(!validAddress);
ValidationSupport validationSupport = new ValidationSupport();
Platform.runLater(() -> {
validationSupport.registerValidator(address, (Control c, String newValue) -> ValidationResult.fromErrorIf(c, "Invalid address", !isValidAddress()));
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
});
address.textProperty().addListener((observable, oldValue, newValue) -> {
boolean valid = isValidAddress();
signButton.setDisable(!valid || (wallet == null));
verifyButton.setDisable(!valid);
if(valid && wallet != null) {
try {
Address address = getAddress();
setWalletNodeFromAddress(wallet, address);
} catch(InvalidAddressException e) {
//can't happen
}
ButtonType customSignButtonType = Arrays.stream(buttons).filter(buttonType -> buttonType.getText().toLowerCase().contains("sign")).findFirst().orElse(null);
if(customSignButtonType != null) {
Button customSignButton = (Button)dialogPane.lookupButton(customSignButtonType);
customSignButton.setDefaultButton(true);
customSignButton.setOnAction(event -> {
customSignButton.setDisable(true);
signMessage();
setResult(ButtonBar.ButtonData.OK_DONE);
});
}
});
} else {
dialogPane.getButtonTypes().addAll(signButtonType, verifyButtonType, doneButtonType);
Button signButton = (Button) dialogPane.lookupButton(signButtonType);
signButton.setDisable(wallet == null);
signButton.setOnAction(event -> {
signMessage();
});
Button verifyButton = (Button) dialogPane.lookupButton(verifyButtonType);
verifyButton.setDefaultButton(false);
verifyButton.setOnAction(event -> {
verifyMessage();
});
boolean validAddress = isValidAddress();
signButton.setDisable(!validAddress || (wallet == null));
verifyButton.setDisable(!validAddress);
ValidationSupport validationSupport = new ValidationSupport();
Platform.runLater(() -> {
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
validationSupport.registerValidator(address, (Control c, String newValue) -> ValidationResult.fromErrorIf(c, "Invalid address", !isValidAddress()));
});
address.textProperty().addListener((observable, oldValue, newValue) -> {
boolean valid = isValidAddress();
signButton.setDisable(!valid || (wallet == null));
verifyButton.setDisable(!valid);
if(valid && wallet != null) {
try {
Address address = getAddress();
setWalletNodeFromAddress(wallet, address);
} catch(InvalidAddressException e) {
//can't happen
}
}
});
}
EventManager.get().register(this);
setOnCloseRequest(event -> {
@@ -186,13 +244,37 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
});
AppServices.moveToActiveWindowScreen(this);
setResultConverter(dialogButton -> dialogButton == signButtonType || dialogButton == verifyButtonType ? ButtonBar.ButtonData.APPLY : ButtonBar.ButtonData.OK_DONE);
setResultConverter(dialogButton -> dialogButton == signButtonType || dialogButton == verifyButtonType ? ButtonBar.ButtonData.APPLY : dialogButton.getButtonData());
Platform.runLater(() -> {
if(address.getText().isEmpty()) {
address.requestFocus();
} else if(message.getText().isEmpty()) {
message.requestFocus();
}
formatGroup.selectToggle(formatElectrum);
});
}
private Address getAddress()throws InvalidAddressException {
return Address.fromString(address.getText());
}
public String getSignature() {
return signature.getText();
}
/**
* Use the Electrum signing format, which uses the non-segwit compressed signing parameters for both segwit types (p2sh-p2wpkh and p2wpkh)
*
* @param electrumSignatureFormat
*/
public void setElectrumSignatureFormat(boolean electrumSignatureFormat) {
formatGroup.selectToggle(electrumSignatureFormat ? formatElectrum : formatTrezor);
this.electrumSignatureFormat = electrumSignatureFormat;
}
private boolean isValidAddress() {
try {
getAddress();
@@ -228,7 +310,8 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
//Note we can expect a single keystore due to the check above
Keystore keystore = decryptedWallet.getKeystores().get(0);
ECKey privKey = keystore.getKey(walletNode);
String signatureText = privKey.signMessage(message.getText().trim(), decryptedWallet.getScriptType(), null);
ScriptType scriptType = electrumSignatureFormat ? ScriptType.P2PKH : decryptedWallet.getScriptType();
String signatureText = privKey.signMessage(message.getText().trim(), scriptType, null);
signature.clear();
signature.appendText(signatureText);
privKey.clear();
@@ -316,16 +399,16 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
if(password.isPresent()) {
Storage.DecryptWalletService decryptWalletService = new Storage.DecryptWalletService(wallet.copy(), password.get());
decryptWalletService.setOnSucceeded(workerStateEvent -> {
EventManager.get().post(new StorageEvent(storage.getWalletFile(), TimedEvent.Action.END, "Done"));
EventManager.get().post(new StorageEvent(storage.getWalletId(wallet), TimedEvent.Action.END, "Done"));
Wallet decryptedWallet = decryptWalletService.getValue();
signUnencryptedKeystore(decryptedWallet);
decryptedWallet.clearPrivate();
});
decryptWalletService.setOnFailed(workerStateEvent -> {
EventManager.get().post(new StorageEvent(storage.getWalletFile(), TimedEvent.Action.END, "Failed"));
EventManager.get().post(new StorageEvent(storage.getWalletId(wallet), TimedEvent.Action.END, "Failed"));
AppServices.showErrorDialog("Incorrect Password", decryptWalletService.getException().getMessage());
});
EventManager.get().post(new StorageEvent(storage.getWalletFile(), TimedEvent.Action.START, "Decrypting wallet..."));
EventManager.get().post(new StorageEvent(storage.getWalletId(wallet), TimedEvent.Action.START, "Decrypting wallet..."));
decryptWalletService.start();
}
}
@@ -36,7 +36,6 @@ import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
@@ -159,15 +158,20 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
ObservableList<String> wordEntryList = FXCollections.observableArrayList(words);
wordEntriesProperty = new SimpleListProperty<>(wordEntryList);
List<WordEntry> wordEntries = new ArrayList<>(numWords);
for(int i = 0; i < numWords; i++) {
WordEntry wordEntry = new WordEntry(i, wordEntryList);
wordsPane.getChildren().add(wordEntry);
wordEntries.add(new WordEntry(i, wordEntryList));
}
for(int i = 0; i < numWords - 1; i++) {
wordEntries.get(i).setNext(wordEntries.get(i + 1).getEditor());
}
wordsPane.getChildren().addAll(wordEntries);
vBox.getChildren().add(wordsPane);
if(!displayWordsOnly) {
PassphraseEntry passphraseEntry = new PassphraseEntry();
wordEntries.get(wordEntries.size() - 1).setNext(passphraseEntry.getEditor());
passphraseEntry.setPadding(new Insets(0, 26, 10, 10));
vBox.getChildren().add(passphraseEntry);
@@ -397,6 +401,7 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
private static class WordEntry extends HBox {
private static List<String> wordList;
private final TextField wordField;
private Node next;
public WordEntry(int wordNumber, ObservableList<String> wordEntryList) {
super();
@@ -425,13 +430,18 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
wordList = Bip39MnemonicCode.INSTANCE.getWordList();
AutoCompletionBinding<String> autoCompletionBinding = TextFields.bindAutoCompletion(wordField, new WordlistSuggestionProvider(wordList));
autoCompletionBinding.setDelay(50);
autoCompletionBinding.setOnAutoCompleted(event -> {
if (next != null) {
next.requestFocus();
}
});
ValidationSupport validationSupport = new ValidationSupport();
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
validationSupport.registerValidator(wordField, Validator.combine(
Validator.createEmptyValidator("Word is required"),
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Invalid word", !wordList.contains(newValue))
));
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
wordField.textProperty().addListener((observable, oldValue, newValue) -> {
wordEntryList.set(wordNumber, newValue);
@@ -444,6 +454,10 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
return wordField;
}
public void setNext(Node node) {
next = node;
}
public static boolean isValid(String word) {
return wordList.contains(word);
}
@@ -461,10 +475,6 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
List<String> suggestions = new ArrayList<>();
if(!request.getUserText().isEmpty()) {
for(String word : wordList) {
if(word.equals(request.getUserText())) {
return Collections.emptyList();
}
if(word.startsWith(request.getUserText())) {
suggestions.add(word);
}
@@ -476,13 +486,15 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
}
private class PassphraseEntry extends HBox {
private final CustomTextField passphraseField;
public PassphraseEntry() {
super();
setAlignment(Pos.CENTER_LEFT);
setSpacing(10);
Label passphraseLabel = new Label("Passphrase:");
CustomTextField passphraseField = (CustomTextField) TextFields.createClearableTextField();
passphraseField = (CustomTextField) TextFields.createClearableTextField();
passphraseProperty.bind(passphraseField.textProperty());
passphraseField.setPromptText("Leave blank for none");
@@ -492,6 +504,10 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
getChildren().addAll(passphraseLabel, passphraseField, helpLabel);
}
public TextField getEditor() {
return passphraseField;
}
}
private Node getDerivationEntry(List<ChildNumber> derivation) {
@@ -501,11 +517,11 @@ public class MnemonicKeystoreImportPane extends TitledDescriptionPane {
HBox.setHgrow(derivationField, Priority.ALWAYS);
ValidationSupport validationSupport = new ValidationSupport();
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
validationSupport.registerValidator(derivationField, Validator.combine(
Validator.createEmptyValidator("Derivation is required"),
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Invalid derivation", !KeyDerivation.isValid(newValue))
));
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
Button importDerivationButton = new Button("Import Custom Derivation Keystore");
importDerivationButton.setDisable(true);
@@ -113,7 +113,19 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
webcamService.resultProperty().addListener(new QRResultListener());
webcamService.setOnFailed(failedEvent -> {
Platform.runLater(() -> setResult(new Result(failedEvent.getSource().getException())));
Throwable exception = failedEvent.getSource().getException();
Throwable nested = exception;
while(nested.getCause() != null) {
nested = nested.getCause();
}
if(org.controlsfx.tools.Platform.getCurrent() == org.controlsfx.tools.Platform.WINDOWS &&
nested.getMessage().startsWith("Library 'OpenIMAJGrabber' was not loaded successfully from file")) {
exception = new WebcamDependencyException("Your system is missing a dependency required for the webcam. Follow the link below for more details.\n\n[https://sparrowwallet.com/docs/faq.html#your-system-is-missing-a-dependency-for-the-webcam]", exception);
}
final Throwable result = exception;
Platform.runLater(() -> setResult(new Result(result)));
});
webcamService.start();
webcamResolutionProperty.addListener((observable, oldValue, newResolution) -> {
@@ -168,7 +180,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
}
} else {
decoder.receivePart(qrtext);
Platform.runLater(() -> percentComplete.setValue(decoder.getEstimatedPercentComplete()));
Platform.runLater(() -> percentComplete.setValue(decoder.getProcessedPartsCount() > 0 ? decoder.getEstimatedPercentComplete() : 0));
if(decoder.getResult() != null) {
URDecoder.Result urResult = decoder.getResult();
@@ -197,7 +209,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
if(parts.stream().filter(Objects::nonNull).count() == n) {
String complete = String.join("", parts);
try {
PSBT psbt = PSBT.fromString(complete);
PSBT psbt = PSBT.fromString(complete, false);
result = new Result(psbt);
return;
} catch(PSBTParseException e) {
@@ -249,7 +261,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
}
try {
psbt = PSBT.fromString(qrtext);
psbt = PSBT.fromString(qrtext, false);
result = new Result(psbt);
return;
} catch(PSBTParseException e) {
@@ -261,7 +273,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
}
try {
psbt = new PSBT(qrResult.getRawBytes());
psbt = new PSBT(qrResult.getRawBytes(), false);
result = new Result(psbt);
return;
} catch(Exception e) {
@@ -286,7 +298,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
//Try Base43 used by Electrum
try {
psbt = new PSBT(Base43.decode(qrtext));
psbt = new PSBT(Base43.decode(qrtext), false);
result = new Result(psbt);
return;
} catch(Exception e) {
@@ -312,7 +324,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
if(urRegistryType.equals(RegistryType.BYTES)) {
byte[] urBytes = (byte[])ur.decodeFromRegistry();
try {
PSBT psbt = new PSBT(urBytes);
PSBT psbt = new PSBT(urBytes, false);
return new Result(psbt);
} catch(PSBTParseException e) {
if(PSBT.isPSBT(urBytes)) {
@@ -342,7 +354,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
} else if(urRegistryType.equals(RegistryType.CRYPTO_PSBT)) {
CryptoPSBT cryptoPSBT = (CryptoPSBT)ur.decodeFromRegistry();
try {
PSBT psbt = new PSBT(cryptoPSBT.getPsbt());
PSBT psbt = new PSBT(cryptoPSBT.getPsbt(), false);
return new Result(psbt);
} catch(Exception e) {
log.error("Error parsing PSBT from UR type " + urRegistryType, e);
@@ -457,7 +469,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
ExtendedKey extendedKey = outputDescriptor.getSingletonExtendedPublicKey();
wallet.setScriptType(outputDescriptor.getScriptType());
Keystore keystore = new Keystore();
keystore.setKeyDerivation(new KeyDerivation(masterFingerprint, outputDescriptor.getKeyDerivation(extendedKey).getDerivationPath()));
keystore.setKeyDerivation(new KeyDerivation(masterFingerprint, KeyDerivation.writePath(outputDescriptor.getKeyDerivation(extendedKey).getDerivation())));
keystore.setExtendedPublicKey(extendedKey);
wallet.getKeystores().add(keystore);
wallets.add(wallet);
@@ -715,6 +727,24 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
}
}
public static class WebcamDependencyException extends ScanException {
public WebcamDependencyException() {
super();
}
public WebcamDependencyException(String message) {
super(message);
}
public WebcamDependencyException(Throwable cause) {
super(cause);
}
public WebcamDependencyException(String message, Throwable cause) {
super(message, cause);
}
}
public static class ScanDelayCalculator implements WebcamUpdater.DelayCalculator {
public long calculateDelay(long snapshotDuration, double deviceFps) {
return Math.max(SCAN_PERIOD_MILLIS - snapshotDuration, 0L);
@@ -0,0 +1,302 @@
package com.sparrowwallet.sparrow.control;
import com.csvreader.CsvReader;
import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.address.InvalidAddressException;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.wallet.Payment;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.StackPane;
import javafx.stage.FileChooser;
import javafx.util.StringConverter;
import org.controlsfx.control.spreadsheet.*;
import org.controlsfx.glyphfont.Glyph;
import org.controlsfx.tools.Platform;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
public class SendToManyDialog extends Dialog<List<Payment>> {
private final BitcoinUnit bitcoinUnit;
private final SpreadsheetView spreadsheetView;
public static final AddressCellType ADDRESS = new AddressCellType();
public SendToManyDialog(BitcoinUnit bitcoinUnit) {
this.bitcoinUnit = bitcoinUnit;
final DialogPane dialogPane = new SendToManyDialogPane();
setDialogPane(dialogPane);
setTitle("Send to Many");
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
dialogPane.setHeaderText("Send to many recipients by specifying addresses and amounts.\nOnly the first row's label is necessary.");
Image image = new Image("/image/sparrow-small.png");
dialogPane.setGraphic(new ImageView(image));
List<Payment> initialPayments = IntStream.range(0, 100).mapToObj(i -> new Payment(null, null, -1, false)).collect(Collectors.toList());
Grid grid = getGrid(initialPayments);
spreadsheetView = new SpreadsheetView(grid);
spreadsheetView.getColumns().get(0).setPrefWidth(400);
spreadsheetView.getColumns().get(1).setPrefWidth(150);
spreadsheetView.getColumns().get(2).setPrefWidth(247);
StackPane stackPane = new StackPane();
stackPane.getChildren().add(spreadsheetView);
dialogPane.setContent(stackPane);
dialogPane.getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
final ButtonType loadCsvButtonType = new javafx.scene.control.ButtonType("Load CSV", ButtonBar.ButtonData.LEFT);
dialogPane.getButtonTypes().add(loadCsvButtonType);
setResultConverter((dialogButton) -> {
ButtonBar.ButtonData data = dialogButton == null ? null : dialogButton.getButtonData();
return data == ButtonBar.ButtonData.OK_DONE ? getPayments() : null;
});
dialogPane.setPrefWidth(850);
dialogPane.setPrefHeight(500);
AppServices.setStageIcon(dialogPane.getScene().getWindow());
AppServices.moveToActiveWindowScreen(this);
}
private Grid getGrid(List<Payment> payments) {
int rowCount = payments.size();
int columnCount = 3;
GridBase grid = new GridBase(rowCount, columnCount);
ObservableList<ObservableList<SpreadsheetCell>> rows = FXCollections.observableArrayList();
for(int row = 0; row < grid.getRowCount(); ++row) {
final ObservableList<SpreadsheetCell> list = FXCollections.observableArrayList();
SpreadsheetCell addressCell = ADDRESS.createCell(row, 0, 1, 1, payments.get(row).getAddress());
addressCell.getStyleClass().add("fixed-width");
list.add(addressCell);
double amount = (double)payments.get(row).getAmount();
if(bitcoinUnit == BitcoinUnit.BTC) {
amount = amount / Transaction.SATOSHIS_PER_BITCOIN;
}
SpreadsheetCell amountCell = SpreadsheetCellType.DOUBLE.createCell(row, 1, 1, 1, amount < 0 ? null : amount);
amountCell.setFormat(bitcoinUnit == BitcoinUnit.BTC ? "0.00000000" : "###,###");
amountCell.getStyleClass().add("number-value");
if(Platform.getCurrent() == Platform.OSX) {
amountCell.getStyleClass().add("number-field");
}
list.add(amountCell);
list.add(SpreadsheetCellType.STRING.createCell(row, 2, 1, 1, payments.get(row).getLabel()));
rows.add(list);
}
grid.setRows(rows);
grid.getColumnHeaders().setAll("Address", "Amount (" + bitcoinUnit.getLabel() + ")", "Label");
return grid;
}
private List<Payment> getPayments() {
List<Payment> payments = new ArrayList<>();
Grid grid = spreadsheetView.getGrid();
String firstLabel = null;
for(int row = 0; row < grid.getRowCount(); row++) {
ObservableList<SpreadsheetCell> rowCells = spreadsheetView.getItems().get(row);
Address address = (Address)rowCells.get(0).getItem();
Double value = (Double)rowCells.get(1).getItem();
String label = (String)rowCells.get(2).getItem();
if(firstLabel == null) {
firstLabel = label;
}
if(label == null || label.isEmpty()) {
label = firstLabel;
}
if(address != null && value != null) {
if(bitcoinUnit == BitcoinUnit.BTC) {
value = value * Transaction.SATOSHIS_PER_BITCOIN;
}
payments.add(new Payment(address, label, value.longValue(), false));
}
}
return payments;
}
private class SendToManyDialogPane extends DialogPane {
@Override
protected Node createButton(ButtonType buttonType) {
Node button;
if(buttonType.getButtonData() == ButtonBar.ButtonData.LEFT) {
Button loadButton = new Button(buttonType.getText());
loadButton.setGraphicTextGap(5);
loadButton.setGraphic(getGlyph(FontAwesome5.Glyph.ARROW_UP));
final ButtonBar.ButtonData buttonData = buttonType.getButtonData();
ButtonBar.setButtonData(loadButton, buttonData);
loadButton.setOnAction(event -> {
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("Open CSV");
fileChooser.getExtensionFilters().addAll(
new FileChooser.ExtensionFilter("All Files", org.controlsfx.tools.Platform.getCurrent().equals(org.controlsfx.tools.Platform.UNIX) ? "*" : "*.*"),
new FileChooser.ExtensionFilter("CSV", "*.csv")
);
AppServices.moveToActiveWindowScreen(this.getScene().getWindow(), 800, 450);
File file = fileChooser.showOpenDialog(this.getScene().getWindow());
if(file != null) {
try {
List<Payment> csvPayments = new ArrayList<>();
try(Reader reader = new FileReader(file, StandardCharsets.UTF_8)) {
CsvReader csvReader = new CsvReader(reader);
while(csvReader.readRecord()) {
if(csvReader.getColumnCount() < 2) {
continue;
}
try {
long amount;
if(bitcoinUnit == BitcoinUnit.BTC) {
double doubleAmount = Double.parseDouble(csvReader.get(1).replace(",", ""));
amount = (long)(doubleAmount * Transaction.SATOSHIS_PER_BITCOIN);
} else {
amount = Long.parseLong(csvReader.get(1).replace(",", ""));
}
Address address = Address.fromString(csvReader.get(0));
String label = csvReader.get(2);
csvPayments.add(new Payment(address, label, amount, false));
} catch(NumberFormatException e) {
//ignore and continue - probably a header line
} catch(InvalidAddressException e) {
AppServices.showErrorDialog("Invalid Address", e.getMessage());
}
}
if(csvPayments.isEmpty()) {
AppServices.showErrorDialog("No recipients found", "No valid recipients were found. Use a CSV file with three columns, and ensure amounts are in " + bitcoinUnit.getLabel() + ".");
return;
}
spreadsheetView.setGrid(getGrid(csvPayments));
}
} catch(IOException e) {
AppServices.showErrorDialog("Cannot load CSV", e.getMessage());
}
}
});
button = loadButton;
} else {
button = super.createButton(buttonType);
}
return button;
}
private Glyph getGlyph(FontAwesome5.Glyph glyphName) {
Glyph glyph = new Glyph(FontAwesome5.FONT_NAME, glyphName);
glyph.setFontSize(11);
return glyph;
}
}
public static class AddressCellType extends SpreadsheetCellType<Address> {
public AddressCellType() {
this(new StringConverterWithFormat<>(new AddressStringConverter()) {
@Override
public String toString(Address item) {
return toStringFormat(item, ""); //$NON-NLS-1$
}
@Override
public Address fromString(String str) {
if(str == null || str.isEmpty()) { //$NON-NLS-1$
return null;
} else {
return myConverter.fromString(str);
}
}
@Override
public String toStringFormat(Address item, String format) {
try {
if(item == null) {
return ""; //$NON-NLS-1$
} else {
return item.toString();
}
} catch (Exception ex) {
return myConverter.toString(item);
}
}
});
}
public AddressCellType(StringConverter<Address> converter) {
super(converter);
}
@Override
public String toString() {
return "address";
}
public SpreadsheetCell createCell(final int row, final int column, final int rowSpan, final int columnSpan,
final Address value) {
SpreadsheetCell cell = new SpreadsheetCellBase(row, column, rowSpan, columnSpan, this);
cell.setItem(value);
return cell;
}
@Override
public SpreadsheetCellEditor createEditor(SpreadsheetView view) {
return new SpreadsheetCellEditor.StringEditor(view);
}
@Override
public boolean match(Object value, Object... options) {
if(value instanceof Address)
return true;
else {
try {
converter.fromString(value == null ? null : value.toString());
return true;
} catch (Exception e) {
return false;
}
}
}
@Override
public Address convertValue(Object value) {
if(value instanceof Address)
return (Address)value;
else {
try {
return converter.fromString(value == null ? null : value.toString());
} catch (Exception e) {
return null;
}
}
}
@Override
public String toString(Address item) {
return converter.toString(item);
}
@Override
public String toString(Address item, String format) {
return ((StringConverterWithFormat<Address>)converter).toStringFormat(item, format);
}
};
}
@@ -12,10 +12,14 @@ import javafx.scene.image.ImageView;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import org.controlsfx.glyphfont.Glyph;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Optional;
public class TextAreaDialog extends Dialog<String> {
private static final Logger log = LoggerFactory.getLogger(TextAreaDialog.class);
private final TextArea textArea;
private final String defaultValue;
@@ -99,6 +103,7 @@ public class TextAreaDialog extends Dialog<String> {
} else if(result.outputDescriptor != null) {
textArea.setText(result.outputDescriptor.toString(true));
} else if(result.exception != null) {
log.error("Error scanning QR", result.exception);
AppServices.showErrorDialog("Error scanning QR", result.exception.getMessage());
}
}
@@ -16,6 +16,7 @@ import java.util.Arrays;
import java.util.OptionalDouble;
public class TitledDescriptionPane extends TitledPane {
private Label mainLabel;
private Label descriptionLabel;
protected Hyperlink showHideLink;
protected HBox buttonBox;
@@ -50,7 +51,7 @@ public class TitledDescriptionPane extends TitledPane {
VBox labelsBox = new VBox();
labelsBox.setSpacing(5);
labelsBox.setAlignment(Pos.CENTER_LEFT);
Label mainLabel = new Label();
mainLabel = new Label();
mainLabel.setText(title);
mainLabel.getStyleClass().add("main-label");
labelsBox.getChildren().add(mainLabel);
@@ -99,6 +100,10 @@ public class TitledDescriptionPane extends TitledPane {
return null;
}
public String getTitle() {
return mainLabel.getText();
}
protected void setDescription(String text) {
descriptionLabel.getStyleClass().remove("description-error");
descriptionLabel.getStyleClass().add("description-label");
@@ -1,5 +1,6 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.uri.BitcoinURI;
@@ -10,6 +11,7 @@ import com.sparrowwallet.drongo.wallet.WalletTransaction;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.ExcludeUtxoEvent;
import com.sparrowwallet.sparrow.event.ReplaceChangeAddressEvent;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
@@ -200,6 +202,7 @@ public class TransactionDiagram extends GridPane {
private Pane getInputsLabels(Map<BlockTransactionHashIndex, WalletNode> displayedUtxos) {
VBox inputsBox = new VBox();
inputsBox.setMaxWidth(150);
inputsBox.setPrefWidth(150);
inputsBox.setPadding(new Insets(0, 10, 0, 10));
inputsBox.minHeightProperty().bind(minHeightProperty());
inputsBox.setAlignment(Pos.CENTER_RIGHT);
@@ -388,14 +391,33 @@ public class TransactionDiagram extends GridPane {
}
if(walletTx.getChangeNode() != null) {
WalletNode defaultChangeNode = walletTx.getWallet().getFreshNode(KeyPurpose.CHANGE);
boolean overGapLimit = (walletTx.getChangeNode().getIndex() - defaultChangeNode.getIndex()) > walletTx.getWallet().getGapLimit();
HBox actionBox = new HBox();
String changeDesc = walletTx.getChangeAddress().toString().substring(0, 8) + "...";
Label changeLabel = new Label(changeDesc, getChangeGlyph());
Label changeLabel = new Label(changeDesc, overGapLimit ? getChangeWarningGlyph() : getChangeGlyph());
changeLabel.getStyleClass().addAll("output-label", "change-label");
Tooltip changeTooltip = new Tooltip("Change of " + getSatsValue(walletTx.getChangeAmount()) + " sats to " + walletTx.getChangeNode().getDerivationPath().replace("m", "..") + "\n" + walletTx.getChangeAddress().toString());
Tooltip changeTooltip = new Tooltip("Change of " + getSatsValue(walletTx.getChangeAmount()) + " sats to " + walletTx.getChangeNode().getDerivationPath().replace("m", "..") + "\n" + walletTx.getChangeAddress().toString() + (overGapLimit ? "\nAddress is beyond the gap limit!" : ""));
changeTooltip.getStyleClass().add("change-label");
changeTooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY));
changeLabel.setTooltip(changeTooltip);
outputsBox.getChildren().add(changeLabel);
Button nextChangeAddressButton = new Button("");
nextChangeAddressButton.setGraphic(getChangeReplaceGlyph());
nextChangeAddressButton.setOnAction(event -> {
EventManager.get().post(new ReplaceChangeAddressEvent(walletTx));
});
Tooltip replaceChangeTooltip = new Tooltip("Use next change address");
nextChangeAddressButton.setTooltip(replaceChangeTooltip);
Label replaceChangeLabel = new Label("", nextChangeAddressButton);
replaceChangeLabel.getStyleClass().add("replace-change-label");
replaceChangeLabel.setVisible(false);
actionBox.setOnMouseEntered(event -> replaceChangeLabel.setVisible(true));
actionBox.setOnMouseExited(event -> replaceChangeLabel.setVisible(false));
actionBox.getChildren().addAll(changeLabel, replaceChangeLabel);
outputsBox.getChildren().add(actionBox);
outputsBox.getChildren().add(createSpacer());
}
@@ -484,6 +506,20 @@ public class TransactionDiagram extends GridPane {
return changeGlyph;
}
public static Glyph getChangeWarningGlyph() {
Glyph changeWarningGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.EXCLAMATION_TRIANGLE);
changeWarningGlyph.getStyleClass().add("change-warning-icon");
changeWarningGlyph.setFontSize(12);
return changeWarningGlyph;
}
public static Glyph getChangeReplaceGlyph() {
Glyph changeReplaceGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.ARROW_DOWN);
changeReplaceGlyph.getStyleClass().add("change-replace-icon");
changeReplaceGlyph.setFontSize(12);
return changeReplaceGlyph;
}
private Glyph getFeeGlyph() {
Glyph feeGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.HAND_HOLDING);
feeGlyph.getStyleClass().add("fee-icon");
@@ -4,7 +4,11 @@ import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.protocol.*;
import javafx.application.Platform;
import javafx.geometry.Point2D;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.Label;
import javafx.scene.control.MenuItem;
import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent;
import javafx.stage.Popup;
import org.fxmisc.richtext.CodeArea;
import org.fxmisc.richtext.event.MouseOverTextEvent;
@@ -33,7 +37,8 @@ public class TransactionHexArea extends CodeArea {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
transaction.bitcoinSerializeToStream(baos);
String hex = Utils.bytesToHex(baos.toByteArray());
String fullHex = Utils.bytesToHex(baos.toByteArray());
String hex = fullHex;
if(hex.length() > TRUNCATE_AT) {
hex = hex.substring(0, TRUNCATE_AT);
hex += "[truncated]";
@@ -42,6 +47,7 @@ public class TransactionHexArea extends CodeArea {
clear();
appendText(hex);
previousSegmentList = new ArrayList<>();
setContextMenu(new TransactionHexContextMenu(fullHex));
} catch (IOException e) {
throw new IllegalStateException("Can't happen");
}
@@ -266,4 +272,18 @@ public class TransactionHexArea extends CodeArea {
return Objects.hash(start, length, style);
}
}
private static class TransactionHexContextMenu extends ContextMenu {
public TransactionHexContextMenu(String hex) {
MenuItem copy = new MenuItem("Copy All");
copy.setOnAction(AE -> {
hide();
ClipboardContent content = new ClipboardContent();
content.putString(hex);
Clipboard.getSystemClipboard().setContent(content);
});
getItems().add(copy);
}
}
}
@@ -45,12 +45,12 @@ public class TransactionIdDialog extends Dialog<Sha256Hash> {
ValidationSupport validationSupport = new ValidationSupport();
Platform.runLater(() -> {
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
validationSupport.registerValidator(txid, Validator.combine(
Validator.createEmptyValidator("Transaction id is required"),
(Control c, String newValue) -> ValidationResult.fromErrorIf(c, "Transaction ID length incorrect", newValue.length() != 64),
(Control c, String newValue) -> ValidationResult.fromErrorIf(c, "Transaction ID must be hexadecimal", !Utils.isHex(newValue))
));
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
});
final ButtonType okButtonType = new javafx.scene.control.ButtonType("Open Transaction", ButtonBar.ButtonData.OK_DONE);
@@ -44,16 +44,18 @@ public class UtxosChart extends BarChart<String, Number> {
totalUtxos = utxoDataList.size();
if(utxoDataList.size() > MAX_BARS) {
Long otherTotal = utxoDataList.subList(MAX_BARS - 1, utxoDataList.size()).stream().mapToLong(data -> data.getYValue().longValue()).sum();
List<Data<String, Number>> otherDataList = utxoDataList.subList(MAX_BARS - 1, utxoDataList.size());
List<Entry> otherEntries = otherDataList.stream().map(data -> (Entry)data.getExtraValue()).collect(Collectors.toList());
Long otherTotal = otherDataList.stream().mapToLong(data -> data.getYValue().longValue()).sum();
utxoDataList = utxoDataList.subList(0, MAX_BARS - 1);
utxoDataList.add(new XYChart.Data<>(OTHER_CATEGORY, otherTotal));
utxoDataList.add(new XYChart.Data<>(OTHER_CATEGORY, otherTotal, otherEntries));
}
for(int i = 0; i < utxoDataList.size(); i++) {
XYChart.Data<String, Number> newData = utxoDataList.get(i);
if(i < utxoSeries.getData().size()) {
XYChart.Data<String, Number> existingData = utxoSeries.getData().get(i);
if(!newData.getXValue().equals(existingData.getXValue()) || !newData.getYValue().equals(existingData.getYValue()) || (newData.getExtraValue() != null && !newData.getExtraValue().equals(existingData.getExtraValue()))) {
if(!newData.getXValue().equals(existingData.getXValue()) || !newData.getYValue().equals(existingData.getYValue()) || (newData.getExtraValue() instanceof Entry && !newData.getExtraValue().equals(existingData.getExtraValue()))) {
utxoSeries.getData().set(i, newData);
}
} else {
@@ -87,7 +89,8 @@ public class UtxosChart extends BarChart<String, Number> {
for(int i = 0; i < utxoSeries.getData().size(); i++) {
XYChart.Data<String, Number> data = utxoSeries.getData().get(i);
if((data.getExtraValue() != null && entries.contains((Entry)data.getExtraValue())) || (data.getExtraValue() == null && entries.size() == totalUtxos)) {
if((data.getExtraValue() instanceof Entry && entries.contains((Entry)data.getExtraValue())) ||
(data.getExtraValue() instanceof List && entries.containsAll((List)data.getExtraValue()))) {
Node bar = lookup(".data" + i);
if(bar != null) {
bar.getStyleClass().add("selected");
@@ -52,10 +52,10 @@ public class WalletBirthDateDialog extends Dialog<Date> {
ValidationSupport validationSupport = new ValidationSupport();
Platform.runLater( () -> {
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
validationSupport.registerValidator(birthDatePicker, Validator.combine(
(Control c, LocalDate newValue) -> ValidationResult.fromErrorIf( c, "Birth date not specified", newValue == null)
));
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
});
final ButtonType okButtonType = new javafx.scene.control.ButtonType("Rescan Wallet", ButtonBar.ButtonData.OK_DONE);
@@ -11,6 +11,7 @@ import javafx.scene.control.*;
import javafx.scene.layout.AnchorPane;
import javafx.scene.layout.StackPane;
import java.util.Comparator;
import java.util.List;
public class WalletExportDialog extends Dialog<Wallet> {
@@ -43,7 +44,7 @@ public class WalletExportDialog extends Dialog<Wallet> {
if(wallet.getPolicyType() == PolicyType.SINGLE) {
exporters = List.of(new Electrum(), new SpecterDesktop(), new Sparrow());
} else if(wallet.getPolicyType() == PolicyType.MULTI) {
exporters = List.of(new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new PassportMultisig(), new SpecterDesktop(), new BlueWalletMultisig(), new Sparrow());
exporters = List.of(new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new KeystoneMultisig(), new PassportMultisig(), new SpecterDesktop(), new BlueWalletMultisig(), new SpecterDIY(), new Sparrow());
} else {
throw new UnsupportedOperationException("Cannot export wallet with policy type " + wallet.getPolicyType());
}
@@ -53,6 +54,8 @@ public class WalletExportDialog extends Dialog<Wallet> {
FileWalletExportPane exportPane = new FileWalletExportPane(wallet, exporter);
exportAccordion.getPanes().add(exportPane);
}
exportAccordion.getPanes().sort(Comparator.comparing(o -> ((TitledDescriptionPane) o).getTitle()));
scrollPane.setContent(exportAccordion);
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
@@ -16,6 +16,7 @@ import javafx.scene.layout.AnchorPane;
import javafx.scene.layout.StackPane;
import org.controlsfx.glyphfont.Glyph;
import java.util.Comparator;
import java.util.List;
public class WalletImportDialog extends Dialog<Wallet> {
@@ -47,17 +48,19 @@ public class WalletImportDialog extends Dialog<Wallet> {
AnchorPane.setRightAnchor(scrollPane, 0.0);
importAccordion = new Accordion();
List<KeystoreFileImport> keystoreImporters = List.of(new ColdcardSinglesig(), new CoboVaultSinglesig(), new PassportSinglesig());
List<KeystoreFileImport> keystoreImporters = List.of(new ColdcardSinglesig(), new CoboVaultSinglesig(), new KeystoneSinglesig(), new PassportSinglesig(), new SeedSigner(), new SpecterDIY());
for(KeystoreFileImport importer : keystoreImporters) {
FileWalletKeystoreImportPane importPane = new FileWalletKeystoreImportPane(importer);
importAccordion.getPanes().add(importPane);
}
List<WalletImport> walletImporters = List.of(new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new SpecterDesktop(), new BlueWalletMultisig());
List<WalletImport> walletImporters = List.of(new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new KeystoneMultisig(), new SpecterDesktop(), new BlueWalletMultisig(), new Sparrow());
for(WalletImport importer : walletImporters) {
FileWalletImportPane importPane = new FileWalletImportPane(importer);
importAccordion.getPanes().add(importPane);
}
importAccordion.getPanes().sort(Comparator.comparing(o -> ((TitledDescriptionPane) o).getTitle()));
scrollPane.setContent(importAccordion);
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
@@ -92,7 +95,7 @@ public class WalletImportDialog extends Dialog<Wallet> {
List<Device> devices = enumerateService.getValue();
importAccordion.getPanes().removeIf(titledPane -> titledPane instanceof DevicePane);
for(Device device : devices) {
DevicePane devicePane = new DevicePane(new Wallet(), device);
DevicePane devicePane = new DevicePane(new Wallet(), device, devices.size() == 1);
importAccordion.getPanes().add(0, devicePane);
}
Platform.runLater(() -> EventManager.get().post(new UsbDeviceEvent(devices)));
@@ -93,6 +93,7 @@ public class WalletNameDialog extends Dialog<WalletNameDialog.NameAndBirthDate>
ValidationSupport validationSupport = new ValidationSupport();
Platform.runLater( () -> {
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
validationSupport.registerValidator(name, Validator.combine(
Validator.createEmptyValidator("Wallet name is required"),
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Wallet name is not unique", Storage.walletExists(newValue))
@@ -100,7 +101,6 @@ public class WalletNameDialog extends Dialog<WalletNameDialog.NameAndBirthDate>
validationSupport.registerValidator(existingPicker, Validator.combine(
(Control c, LocalDate newValue) -> ValidationResult.fromErrorIf( c, "Birth date not specified", existingCheck.isSelected() && newValue == null)
));
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
});
final ButtonType okButtonType = new javafx.scene.control.ButtonType("Create Wallet", ButtonBar.ButtonData.OK_DONE);
@@ -77,8 +77,8 @@ public class WalletPasswordDialog extends Dialog<SecureString> {
ValidationSupport validationSupport = new ValidationSupport();
Platform.runLater( () -> {
validationSupport.registerValidator(passwordConfirm, (Control c, String newValue) -> ValidationResult.fromErrorIf(c, "Password confirmation does not match", !passwordConfirm.getText().equals(password.getText())));
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
validationSupport.registerValidator(passwordConfirm, (Control c, String newValue) -> ValidationResult.fromErrorIf(c, "Password confirmation does not match", !passwordConfirm.getText().equals(password.getText())));
});
okButtonType = new javafx.scene.control.ButtonType(requirement.okButtonText, ButtonBar.ButtonData.OK_DONE);
@@ -135,11 +135,11 @@ public class XprvKeystoreImportPane extends TitledDescriptionPane {
}
ValidationSupport validationSupport = new ValidationSupport();
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
validationSupport.registerValidator(xprvField, Validator.combine(
Validator.createEmptyValidator("xprv is required"),
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Invalid private key", !ExtendedKey.isValid(newValue) || ExtendedKey.fromDescriptor(newValue).getKey().isPubKeyOnly())
));
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
Button importXprvButton = new Button("Import");
importXprvButton.setMinWidth(80);
@@ -176,11 +176,11 @@ public class XprvKeystoreImportPane extends TitledDescriptionPane {
HBox.setHgrow(derivationField, Priority.ALWAYS);
ValidationSupport validationSupport = new ValidationSupport();
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
validationSupport.registerValidator(derivationField, Validator.combine(
Validator.createEmptyValidator("Derivation is required"),
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Invalid derivation", !KeyDerivation.isValid(newValue))
));
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
Button importDerivationButton = new Button("Import Custom Derivation Keystore");
importDerivationButton.setDisable(true);
@@ -0,0 +1,19 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.Wallet;
import java.util.List;
public class KeystoreEncryptionChangedEvent extends WalletSettingsChangedEvent {
private List<Keystore> changedKeystores;
public KeystoreEncryptionChangedEvent(Wallet wallet, Wallet pastWallet, String walletId, List<Keystore> changedKeystores) {
super(wallet, pastWallet, walletId);
this.changedKeystores = changedKeystores;
}
public List<Keystore> getChangedKeystores() {
return changedKeystores;
}
}
@@ -0,0 +1,22 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.Wallet;
import java.util.List;
/**
* This event is trigger when one or more keystores on a wallet are updated, and the wallet is saved
*/
public class KeystoreLabelsChangedEvent extends WalletSettingsChangedEvent {
private final List<Keystore> changedKeystores;
public KeystoreLabelsChangedEvent(Wallet wallet, Wallet pastWallet, String walletId, List<Keystore> changedKeystores) {
super(wallet, pastWallet, walletId);
this.changedKeystores = changedKeystores;
}
public List<Keystore> getChangedKeystores() {
return changedKeystores;
}
}
@@ -0,0 +1,13 @@
package com.sparrowwallet.sparrow.event;
public class LoadingLogChangedEvent {
private final boolean visible;
public LoadingLogChangedEvent(boolean visible) {
this.visible = visible;
}
public boolean isVisible() {
return visible;
}
}
@@ -1,15 +1,20 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.wallet.NodeEntry;
public class ReceiveActionEvent {
private final NodeEntry receiveEntry;
private final Wallet wallet;
public ReceiveActionEvent(NodeEntry receiveEntry) {
this.receiveEntry = receiveEntry;
this.wallet = receiveEntry.getWallet();
}
public NodeEntry getReceiveEntry() {
return receiveEntry;
public ReceiveActionEvent(Wallet wallet) {
this.wallet = wallet;
}
public Wallet getWallet() {
return wallet;
}
}
@@ -0,0 +1,22 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.net.Aopp;
public class ReceiveProofEvent {
private final Wallet wallet;
private final Aopp aopp;
public ReceiveProofEvent(Wallet wallet, Aopp aopp) {
this.wallet = wallet;
this.aopp = aopp;
}
public Wallet getWallet() {
return wallet;
}
public Aopp getAopp() {
return aopp;
}
}
@@ -0,0 +1,15 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.WalletTransaction;
public class ReplaceChangeAddressEvent {
private final WalletTransaction walletTransaction;
public ReplaceChangeAddressEvent(WalletTransaction walletTx) {
this.walletTransaction = walletTx;
}
public WalletTransaction getWalletTransaction() {
return walletTransaction;
}
}
@@ -2,17 +2,30 @@ package com.sparrowwallet.sparrow.event;
import javafx.stage.Window;
import java.io.File;
/**
* Event class used to request the transaction open file dialog
*/
public class RequestTransactionOpenEvent {
private final Window window;
private final File file;
public RequestTransactionOpenEvent(Window window) {
this.window = window;
this.file = null;
}
public RequestTransactionOpenEvent(Window window, File file) {
this.window = window;
this.file = file;
}
public Window getWindow() {
return window;
}
public File getFile() {
return file;
}
}
@@ -2,17 +2,30 @@ package com.sparrowwallet.sparrow.event;
import javafx.stage.Window;
import java.io.File;
/**
* Event class used to request the wallet open dialog
*/
public class RequestWalletOpenEvent {
private final Window window;
private final File file;
public RequestWalletOpenEvent(Window window) {
this.window = window;
this.file = null;
}
public RequestWalletOpenEvent(Window window, File file) {
this.window = window;
this.file = file;
}
public Window getWindow() {
return window;
}
public File getFile() {
return file;
}
}
@@ -0,0 +1,24 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Payment;
import com.sparrowwallet.drongo.wallet.Wallet;
import java.util.List;
public class SendPaymentsEvent {
private final Wallet wallet;
private final List<Payment> payments;
public SendPaymentsEvent(Wallet wallet, List<Payment> payments) {
this.wallet = wallet;
this.payments = payments;
}
public Wallet getWallet() {
return wallet;
}
public List<Payment> getPayments() {
return payments;
}
}
@@ -8,9 +8,9 @@ import java.util.Map;
public class StorageEvent extends TimedEvent {
private static boolean firstRunDone = false;
private static final Map<File, Long> eventTime = new HashMap<>();
private static final Map<String, Long> eventTime = new HashMap<>();
public StorageEvent(File file, Action action, String status) {
public StorageEvent(String walletId, Action action, String status) {
super(action, status);
Integer keyDerivationPeriod = Config.get().getKeyDerivationPeriod();
@@ -19,10 +19,10 @@ public class StorageEvent extends TimedEvent {
}
if(action == Action.START) {
eventTime.put(file, System.currentTimeMillis());
eventTime.put(walletId, System.currentTimeMillis());
timeMills = keyDerivationPeriod;
} else if(action == Action.END) {
long start = eventTime.get(file);
long start = eventTime.get(walletId);
if(firstRunDone) {
keyDerivationPeriod = (int)(System.currentTimeMillis() - start);
Config.get().setKeyDerivationPeriod(keyDerivationPeriod);
@@ -0,0 +1,13 @@
package com.sparrowwallet.sparrow.event;
public class UtxosChartChangedEvent {
private final boolean visible;
public UtxosChartChangedEvent(boolean visible) {
this.visible = visible;
}
public boolean isVisible() {
return visible;
}
}
@@ -11,8 +11,8 @@ import java.io.File;
* This is because any failure in saving the wallet must be immediately reported to the user.
* Note that all wallet detail controllers that share a WalletForm, and that class posts WalletNodesChangedEvent once it has cleared it's entry caches.
*/
public class WalletAddressesChangedEvent extends WalletSettingsChangedEvent {
public WalletAddressesChangedEvent(Wallet wallet, Wallet pastWallet, File walletFile) {
super(wallet, pastWallet, walletFile);
public class WalletAddressesChangedEvent extends WalletHistoryClearedEvent {
public WalletAddressesChangedEvent(Wallet wallet, Wallet pastWallet, String walletId) {
super(wallet, pastWallet, walletId);
}
}
@@ -1,21 +0,0 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.wallet.Entry;
/**
* This event is fired when a wallet entry (transaction, txi or txo) label is changed.
* Extends WalletDataChangedEvent so triggers a background save.
*/
public class WalletEntryLabelChangedEvent extends WalletDataChangedEvent {
private final Entry entry;
public WalletEntryLabelChangedEvent(Wallet wallet, Entry entry) {
super(wallet);
this.entry = entry;
}
public Entry getEntry() {
return entry;
}
}
@@ -0,0 +1,27 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.wallet.Entry;
import java.util.List;
/**
* This event is fired when a wallet entry (transaction, txi or txo) label is changed.
*/
public class WalletEntryLabelsChangedEvent extends WalletChangedEvent {
private final List<Entry> entries;
public WalletEntryLabelsChangedEvent(Wallet wallet, Entry entry) {
super(wallet);
this.entries = List.of(entry);
}
public WalletEntryLabelsChangedEvent(Wallet wallet, List<Entry> entries) {
super(wallet);
this.entries = entries;
}
public List<Entry> getEntries() {
return entries;
}
}
@@ -23,8 +23,8 @@ public class WalletHistoryChangedEvent extends WalletChangedEvent {
this.historyChangedNodes = historyChangedNodes;
}
public File getWalletFile() {
return storage.getWalletFile();
public String getWalletId() {
return storage.getWalletId(getWallet());
}
public List<WalletNode> getHistoryChangedNodes() {
@@ -0,0 +1,9 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Wallet;
public class WalletHistoryClearedEvent extends WalletSettingsChangedEvent {
public WalletHistoryClearedEvent(Wallet wallet, Wallet pastWallet, String walletId) {
super(wallet, pastWallet, walletId);
}
}
@@ -6,7 +6,7 @@ public class WalletHistoryFailedEvent extends WalletHistoryStatusEvent {
private final Throwable exception;
public WalletHistoryFailedEvent(Wallet wallet, Throwable exception) {
super(wallet, exception.getCause() == null ? exception.getMessage() : exception.getCause().getMessage());
super(wallet, exception.getMessage());
this.exception = exception;
}
@@ -0,0 +1,9 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.Wallet;
public class WalletPasswordChangedEvent extends WalletSettingsChangedEvent {
public WalletPasswordChangedEvent(Wallet wallet, Wallet pastWallet, String walletId) {
super(wallet, pastWallet, walletId);
}
}
@@ -5,25 +5,24 @@ import com.sparrowwallet.drongo.wallet.Wallet;
import java.io.File;
/**
* This event is posted when a wallet's settings are changed in a way that does not update the wallet addresses or history.
* For example, changing the keystore source or label will trigger this event, which updates the wallet but avoids a full wallet refresh.
* It is impossible for the validity of a wallet to change on this event - listen for WalletAddressesChangedEvent for this
* This is the base class for events posted when a wallet's settings are changed
* Do not listen for this event directly - listen for a subclass, for example KeystoreLabelsChangedEvent or WalletAddressesChangedEvent
*/
public class WalletSettingsChangedEvent extends WalletChangedEvent {
private final Wallet pastWallet;
private final File walletFile;
private final String walletId;
public WalletSettingsChangedEvent(Wallet wallet, Wallet pastWallet, File walletFile) {
public WalletSettingsChangedEvent(Wallet wallet, Wallet pastWallet, String walletId) {
super(wallet);
this.pastWallet = pastWallet;
this.walletFile = walletFile;
this.walletId = walletId;
}
public Wallet getPastWallet() {
return pastWallet;
}
public File getWalletFile() {
return walletFile;
public String getWalletId() {
return walletId;
}
}
@@ -3,7 +3,7 @@ package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
import com.sparrowwallet.drongo.wallet.Wallet;
public class WalletUtxoStatusChangedEvent extends WalletDataChangedEvent {
public class WalletUtxoStatusChangedEvent extends WalletChangedEvent {
private final BlockTransactionHashIndex utxo;
public WalletUtxoStatusChangedEvent(Wallet wallet, BlockTransactionHashIndex utxo) {
@@ -19,11 +19,13 @@ public class FontAwesome5 extends GlyphFont {
ANGLE_DOUBLE_RIGHT('\uf101'),
ARROW_DOWN('\uf063'),
ARROW_UP('\uf062'),
BAN('\uf05e'),
BTC('\uf15a'),
CAMERA('\uf030'),
CHECK_CIRCLE('\uf058'),
CIRCLE('\uf111'),
COINS('\uf51e'),
COPY('\uf0c5'),
EXCHANGE_ALT('\uf362'),
EXCLAMATION_CIRCLE('\uf06a'),
EXCLAMATION_TRIANGLE('\uf071'),
@@ -50,6 +52,8 @@ public class FontAwesome5 extends GlyphFont {
SEARCH('\uf002'),
SIGN_OUT_ALT('\uf2f5'),
SQUARE('\uf0c8'),
SNOWFLAKE('\uf2dc'),
SUN('\uf185'),
TIMES_CIRCLE('\uf057'),
TOGGLE_OFF('\uf204'),
TOGGLE_ON('\uf205'),
@@ -0,0 +1,535 @@
/**
* 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 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.SocketException;
import java.net.UnknownHostException;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
/**
* 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;
/**
* 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) {
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() {}
}
@@ -0,0 +1,69 @@
/**
* 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;
/**
* The <code>InstanceException</code> class is a wrapper for all exceptions thrown from Instance.
*
* @author Pratanu Mandal
* @since 1.1
*
*/
public class InstanceException extends Exception {
private static final long serialVersionUID = 268060627071973613L;
/**
* Constructs a new exception with null as its detail message.
*/
public InstanceException() {
super();
}
/**
* Constructs a new exception with the specified detail message.<br>
* The cause is not initialized, and may subsequently be initialized by a call to {@link #initCause}.
*
* @param message the detail message. The detail message is saved for later retrieval by the {@link #getMessage()} method.
*/
public InstanceException(String message) {
super(message);
}
/**
* Constructs a new exception with the specified detail message and cause.<br><br>
* Note that the detail message associated with cause is not automatically incorporated in this exception's detail message.
*
* @param message the detail message (which is saved for later retrieval by the {@link #getMessage()} method).
* @param cause the cause (which is saved for later retrieval by the {@link #getCause()} method). (A null value is permitted, and indicates that the cause is nonexistent or unknown.)
*/
public InstanceException(String message, Throwable cause) {
super(message, cause);
}
/**
* Constructs a new exception with the specified cause and a detail message of (cause==null ? null : cause.toString()) (which typically contains the class and detail message of cause).<br>
* This constructor is useful for exceptions that are little more than wrappers for other throwables (for example, {@link java.security.PrivilegedActionException}).
*
* @param cause the cause (which is saved for later retrieval by the {@link #getCause()} method). (A null value is permitted, and indicates that the cause is nonexistent or unknown.)
*/
public InstanceException(Throwable cause) {
super(cause);
}
}
@@ -0,0 +1,172 @@
package com.sparrowwallet.sparrow.instance;
import java.util.ArrayList;
import java.util.List;
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);
}
/**
* 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);
}
/**
* Internal method used in first instance to receive and parse messages from subsequent instances.<br>
* The use of this method directly in <code>InstanceList</code> is discouraged. Use <code>receiveMessageList()</code> instead.<br><br>
*
* This method is not synchronized.
*
* @param message message received by first instance from subsequent instances
*/
@Override
protected final void receiveMessage(String message) {
if (message == null) {
receiveMessageList(null);
}
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++) {
JsonElement element = jsonArgs.get(i);
stringArgs.add(element.getAsString());
}
// return the parsed string list
receiveMessageList(stringArgs);
}
}
/**
* Internal method used in subsequent instances to parse and send message to first instance.<br>
* The use of this method directly in <code>InstanceList</code> is discouraged. Use <code>sendMessageList()</code> instead.<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
*/
@Override
protected final String sendMessage() {
// convert arguments to JSON array string
JsonArray jsonArgs = new JsonArray();
List<String> stringArgs = sendMessageList();
if (stringArgs == null) return null;
for (String arg : stringArgs) {
jsonArgs.add(arg);
}
// return the JSON array string
return jsonArgs.toString();
}
/**
* Method used in first instance to receive list of messages from subsequent instances.<br><br>
*
* This method is not synchronized.
*
* @param messageList list of messages received by first instance from subsequent instances
*/
protected abstract void receiveMessageList(List<String> messageList);
/**
* Method used in subsequent instances to send list of messages 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 list of messages sent from subsequent instances
*/
protected abstract List<String> sendMessageList();
}
@@ -57,8 +57,8 @@ public class CoboVaultSinglesig implements KeystoreFileImport, WalletImport {
return keystore;
} catch (Exception e) {
log.error("Error getting Cobo Vault keystore", e);
throw new ImportException("Error getting Cobo Vault keystore", e);
log.error("Error getting " + getName() + " keystore", e);
throw new ImportException("Error getting " + getName() + " keystore", e);
}
}
@@ -81,7 +81,7 @@ public class CoboVaultSinglesig implements KeystoreFileImport, WalletImport {
try {
wallet.checkWallet();
} catch(InvalidWalletException e) {
throw new ImportException("Imported Cobo Vault wallet was invalid: " + e.getMessage());
throw new ImportException("Imported " + getName() + " wallet was invalid: " + e.getMessage());
}
return wallet;
@@ -36,7 +36,7 @@ public class ColdcardMultisig implements WalletImport, KeystoreFileImport, Walle
@Override
public Keystore getKeystore(ScriptType scriptType, InputStream inputStream, String password) throws ImportException {
InputStreamReader reader = new InputStreamReader(inputStream, StandardCharsets.UTF_8);
ColdcardKeystore cck = Storage.getGson().fromJson(reader, ColdcardKeystore.class);
ColdcardKeystore cck = JsonPersistence.getGson().fromJson(reader, ColdcardKeystore.class);
Keystore keystore = new Keystore("Coldcard");
keystore.setSource(KeystoreSource.HW_AIRGAPPED);
@@ -145,13 +145,13 @@ public class ColdcardMultisig implements WalletImport, KeystoreFileImport, Walle
wallet.setScriptType(scriptType);
if(!wallet.isValid()) {
throw new IllegalStateException("This file does not describe a valid wallet. Please use the Settings > Multisig Wallets > Export XPUB feature on your Coldcard.");
throw new IllegalStateException("This file does not describe a valid wallet. " + getKeystoreImportDescription());
}
return wallet;
} catch(Exception e) {
log.error("Error importing Coldcard multisig wallet", e);
throw new ImportException("Error importing Coldcard multisig wallet", e);
log.error("Error importing " + getName() + " wallet", e);
throw new ImportException("Error importing " + getName() + " wallet", e);
}
}
@@ -180,7 +180,7 @@ public class ColdcardMultisig implements WalletImport, KeystoreFileImport, Walle
}
try {
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(outputStream));
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8));
writer.append("# " + getName() + " setup file (created by Sparrow)\n");
writer.append("#\n");
writer.append("Name: ").append(wallet.getName()).append("\n");
@@ -202,10 +202,9 @@ public class ColdcardMultisig implements WalletImport, KeystoreFileImport, Walle
}
writer.flush();
writer.close();
} catch(Exception e) {
log.error("Error exporting Coldcard multisig wallet", e);
throw new ExportException("Error exporting Coldcard multisig wallet", e);
log.error("Error exporting " + getName() + " wallet", e);
throw new ExportException("Error exporting " + getName() + " wallet", e);
}
}
@@ -86,8 +86,8 @@ public class ColdcardSinglesig implements KeystoreFileImport, WalletImport {
}
}
} catch (Exception e) {
log.error("Error getting Coldcard keystore", e);
throw new ImportException("Error getting Coldcard keystore", e);
log.error("Error getting " + getName() + " keystore", e);
throw new ImportException("Error getting " + getName() + " keystore", e);
}
throw new ImportException("Correct derivation not found for script type: " + scriptType);
@@ -11,6 +11,8 @@ import org.slf4j.LoggerFactory;
import java.io.*;
import java.lang.reflect.Type;
import java.nio.file.Files;
import java.nio.file.attribute.PosixFilePermissions;
import java.util.Arrays;
import java.util.Currency;
import java.util.List;
@@ -38,6 +40,8 @@ public class Config {
private boolean openWalletsInNewWindows = false;
private boolean hideEmptyUsedAddresses = false;
private boolean showTransactionHex = true;
private boolean showLoadingLog = false;
private boolean showUtxosChart = true;
private List<File> recentWalletFiles;
private Integer keyDerivationPeriod;
private File hwi;
@@ -66,10 +70,6 @@ public class Config {
private static File getConfigFile() {
File sparrowDir = Storage.getSparrowDir();
if(!sparrowDir.exists()) {
sparrowDir.mkdirs();
}
return new File(sparrowDir, CONFIG_FILENAME);
}
@@ -249,6 +249,24 @@ public class Config {
flush();
}
public boolean isShowLoadingLog() {
return showLoadingLog;
}
public void setShowLoadingLog(boolean showLoadingLog) {
this.showLoadingLog = showLoadingLog;
flush();
}
public boolean isShowUtxosChart() {
return showUtxosChart;
}
public void setShowUtxosChart(boolean showUtxosChart) {
this.showUtxosChart = showUtxosChart;
flush();
}
public List<File> getRecentWalletFiles() {
return recentWalletFiles;
}
@@ -435,6 +453,10 @@ public class Config {
Gson gson = getGson();
try {
File configFile = getConfigFile();
if(!configFile.exists()) {
Storage.createOwnerOnlyFile(configFile);
}
Writer writer = new FileWriter(configFile);
gson.toJson(this, writer);
writer.flush();
@@ -367,7 +367,6 @@ public class Electrum implements KeystoreFileImport, WalletImport, WalletExport
String json = gson.toJson(eJson);
outputStream.write(json.getBytes(StandardCharsets.UTF_8));
outputStream.flush();
outputStream.close();
} catch (Exception e) {
log.error("Error exporting Electrum Wallet", e);
throw new ExportException("Error exporting Electrum Wallet", e);
@@ -30,7 +30,10 @@ import java.util.zip.ZipInputStream;
public class Hwi {
private static final Logger log = LoggerFactory.getLogger(Hwi.class);
private static final String VERSION_PREFIX = "hwi-2.0.1";
private static final String HWI_HOME_DIR = "hwi";
private static final String HWI_VERSION_PREFIX = "hwi-";
private static final String HWI_VERSION = "2.0.2";
private static final String HWI_VERSION_DIR = HWI_VERSION_PREFIX + HWI_VERSION;
private static boolean isPromptActive = false;
@@ -51,7 +54,7 @@ public class Hwi {
}
return Arrays.stream(devices).filter(device -> device != null && device.getModel() != null).collect(Collectors.toList());
} catch(IOException e) {
log.error("Error executing " + VERSION_PREFIX, e);
log.error("Error executing " + HWI_VERSION_DIR, e);
throw new ImportException(e);
} finally {
isPromptActive = false;
@@ -222,11 +225,12 @@ public class Hwi {
private synchronized File getHwiExecutable(Command command) {
File hwiExecutable = Config.get().getHwi();
if(hwiExecutable != null && hwiExecutable.exists()) {
File homeDir = getHwiHomeDir();
String tmpDir = System.getProperty("java.io.tmpdir");
String hwiPath = hwiExecutable.getAbsolutePath();
if(command.isTestFirst() && hwiPath.startsWith(tmpDir) && (!hwiPath.contains(VERSION_PREFIX) || !testHwi(hwiExecutable))) {
if(command.isTestFirst() && (hwiPath.contains(tmpDir) || hwiPath.startsWith(homeDir.getAbsolutePath())) && (!hwiPath.contains(HWI_VERSION_DIR) || !testHwi(hwiExecutable))) {
if(Platform.getCurrent() == Platform.OSX) {
deleteDirectory(hwiExecutable.getParentFile());
IOUtils.deleteDirectory(hwiExecutable.getParentFile());
} else {
hwiExecutable.delete();
}
@@ -243,55 +247,65 @@ public class Hwi {
//The check will still happen on first invocation, but will not thereafter
//See https://github.com/bitcoin-core/HWI/issues/327 for details
if(platform == Platform.OSX) {
InputStream inputStream = Hwi.class.getResourceAsStream("/native/osx/x64/" + VERSION_PREFIX + "-mac-amd64-signed.zip");
Path tempHwiDirPath = Files.createTempDirectory(VERSION_PREFIX, PosixFilePermissions.asFileAttribute(ownerExecutableWritable));
File tempHwiDir = tempHwiDirPath.toFile();
//tempHwiDir.deleteOnExit();
log.debug("Using temp HWI path: " + tempHwiDir.getAbsolutePath());
File tempExec = null;
ZipInputStream zis = new ZipInputStream(inputStream);
ZipEntry zipEntry = zis.getNextEntry();
while(zipEntry != null) {
if(zipEntry.isDirectory()) {
newDirectory(tempHwiDir, zipEntry, ownerExecutableWritable);
} else {
File newFile = newFile(tempHwiDir, zipEntry, ownerExecutableWritable);
//newFile.deleteOnExit();
FileOutputStream fos = new FileOutputStream(newFile);
ByteStreams.copy(zis, new FileOutputStream(newFile));
fos.flush();
fos.close();
if(zipEntry.getName().equals("hwi")) {
tempExec = newFile;
}
}
zipEntry = zis.getNextEntry();
InputStream inputStream = Hwi.class.getResourceAsStream("/native/osx/x64/" + HWI_VERSION_DIR + "-mac-amd64-signed.zip");
if(inputStream == null) {
throw new IllegalStateException("Cannot load " + HWI_VERSION_DIR + " from classpath");
}
zis.closeEntry();
zis.close();
hwiExecutable = tempExec;
File hwiHomeDir = getHwiHomeDir();
File hwiVersionDir = new File(hwiHomeDir, HWI_VERSION_DIR);
if(!hwiVersionDir.exists()) {
Files.createDirectories(hwiVersionDir.toPath(), PosixFilePermissions.asFileAttribute(ownerExecutableWritable));
}
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);
} else {
File newFile = newFile(hwiVersionDir, zipEntry, ownerExecutableWritable);
try(FileOutputStream fos = new FileOutputStream(newFile)) {
ByteStreams.copy(zis, new FileOutputStream(newFile));
fos.flush();
};
if(zipEntry.getName().equals("hwi")) {
hwiExec = newFile;
}
}
zipEntry = zis.getNextEntry();
}
zis.closeEntry();
}
hwiExecutable = hwiExec;
} else {
InputStream inputStream;
Path tempExecPath;
if(platform == Platform.WINDOWS) {
inputStream = Hwi.class.getResourceAsStream("/native/windows/x64/hwi.exe");
tempExecPath = Files.createTempFile(VERSION_PREFIX, null);
tempExecPath = Files.createTempFile(HWI_VERSION_DIR, null);
} else {
inputStream = Hwi.class.getResourceAsStream("/native/linux/x64/hwi");
tempExecPath = Files.createTempFile(VERSION_PREFIX, null, PosixFilePermissions.asFileAttribute(ownerExecutableWritable));
tempExecPath = Files.createTempFile(HWI_VERSION_DIR, null, PosixFilePermissions.asFileAttribute(ownerExecutableWritable));
}
if(inputStream == null) {
throw new IllegalStateException("Cannot load " + HWI_VERSION_DIR + " from classpath");
}
File tempExec = tempExecPath.toFile();
tempExec.deleteOnExit();
OutputStream tempExecStream = new BufferedOutputStream(new FileOutputStream(tempExec));
ByteStreams.copy(inputStream, tempExecStream);
inputStream.close();
tempExecStream.flush();
tempExecStream.close();
try(OutputStream tempExecStream = new BufferedOutputStream(new FileOutputStream(tempExec))) {
ByteStreams.copy(inputStream, tempExecStream);
inputStream.close();
tempExecStream.flush();
};
hwiExecutable = tempExec;
}
@@ -305,6 +319,14 @@ public class Hwi {
return hwiExecutable;
}
private File getHwiHomeDir() {
if(Platform.getCurrent() == Platform.OSX) {
return new File(Storage.getSparrowDir(), HWI_HOME_DIR);
}
return new File(System.getProperty("java.io.tmpdir"));
}
private boolean testHwi(File hwiExecutable) {
try {
List<String> command = List.of(hwiExecutable.getAbsolutePath(), "--version");
@@ -317,17 +339,6 @@ public class Hwi {
}
}
private boolean deleteDirectory(File directoryToBeDeleted) {
File[] allContents = directoryToBeDeleted.listFiles();
if (allContents != null) {
for (File file : allContents) {
deleteDirectory(file);
}
}
return directoryToBeDeleted.delete();
}
public static File newDirectory(File destinationDir, ZipEntry zipEntry, Set<PosixFilePermission> setFilePermissions) throws IOException {
String destDirPath = destinationDir.getCanonicalPath();
@@ -1,8 +1,17 @@
package com.sparrowwallet.sparrow.io;
import java.io.*;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
public class IOUtils {
public static FileType getFileType(File file) {
@@ -16,10 +25,12 @@ public class IOUtils {
if(file.exists()) {
try(BufferedReader br = new BufferedReader(new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8))) {
String line = br.readLine();
if(line.startsWith("01000000") || line.startsWith("cHNid")) {
return FileType.TEXT;
} else if(line.startsWith("{")) {
return FileType.JSON;
if(line != null) {
if(line.startsWith("01000000") || line.startsWith("cHNid")) {
return FileType.TEXT;
} else if(line.startsWith("{")) {
return FileType.JSON;
}
}
}
}
@@ -36,4 +47,75 @@ public class IOUtils {
return FileType.UNKNOWN;
}
/**
* List directory contents for a resource folder. Not recursive.
* This is basically a brute-force implementation.
* Works for regular files, JARs and Java modules.
*
* @param clazz Any java class that lives in the same place as the resources you want.
* @param path Should end with "/", but not start with one.
* @return Just the name of each member item, not the full paths.
* @throws URISyntaxException
* @throws IOException
*/
public static String[] getResourceListing(Class clazz, String path) throws URISyntaxException, IOException {
URL dirURL = clazz.getClassLoader().getResource(path);
if(dirURL != null && dirURL.getProtocol().equals("file")) {
/* A file path: easy enough */
return new File(dirURL.toURI()).list();
}
if(dirURL == null) {
/*
* In case of a jar file, we can't actually find a directory.
* Have to assume the same jar as clazz.
*/
String me = clazz.getName().replace(".", "/")+".class";
dirURL = clazz.getClassLoader().getResource(me);
}
if(dirURL.getProtocol().equals("jar")) {
/* A JAR path */
String jarPath = dirURL.getPath().substring(5, dirURL.getPath().indexOf("!")); //strip out only the JAR file
JarFile jar = new JarFile(URLDecoder.decode(jarPath, "UTF-8"));
Enumeration<JarEntry> entries = jar.entries(); //gives ALL entries in jar
Set<String> result = new HashSet<String>(); //avoid duplicates in case it is a subdirectory
while(entries.hasMoreElements()) {
String name = entries.nextElement().getName();
if(name.startsWith(path)) { //filter according to the path
String entry = name.substring(path.length());
int checkSubdir = entry.indexOf("/");
if (checkSubdir >= 0) {
// if it is a subdirectory, we just return the directory name
entry = entry.substring(0, checkSubdir);
}
result.add(entry);
}
}
return result.toArray(new String[result.size()]);
}
if(dirURL.getProtocol().equals("jrt")) {
java.nio.file.FileSystem jrtFs = FileSystems.newFileSystem(URI.create("jrt:/"), Collections.emptyMap());
Path resourcePath = jrtFs.getPath("modules/com.sparrowwallet.sparrow", path);
return Files.list(resourcePath).map(filePath -> filePath.getFileName().toString()).toArray(String[]::new);
}
throw new UnsupportedOperationException("Cannot list files for URL " + dirURL);
}
public static boolean deleteDirectory(File directory) {
try {
Files.walk(directory.toPath())
.sorted(Comparator.reverseOrder())
.map(Path::toFile)
.forEach(File::delete);
} catch(IOException e) {
return false;
}
return true;
}
}
@@ -0,0 +1,484 @@
package com.sparrowwallet.sparrow.io;
import com.google.gson.*;
import com.sparrowwallet.drongo.ExtendedKey;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.crypto.Argon2KeyDeriver;
import com.sparrowwallet.drongo.crypto.AsymmetricKeyDeriver;
import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletNode;
import java.io.*;
import java.lang.reflect.Type;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.*;
import java.util.stream.Collectors;
import java.util.zip.DeflaterOutputStream;
import java.util.zip.InflaterInputStream;
import static com.sparrowwallet.drongo.crypto.Argon2KeyDeriver.SPRW1_PARAMETERS;
public class JsonPersistence implements Persistence {
public static final String HEADER_MAGIC_1 = "SPRW1";
public static final int BINARY_HEADER_LENGTH = 28;
private final Gson gson;
private AsymmetricKeyDeriver keyDeriver;
public JsonPersistence() {
this.gson = getGson();
}
@Override
public WalletBackupAndKey loadWallet(Storage storage) throws IOException, StorageException {
Wallet wallet;
try(Reader reader = new FileReader(storage.getWalletFile())) {
wallet = gson.fromJson(reader, Wallet.class);
}
Map<Storage, WalletBackupAndKey> childWallets = loadChildWallets(storage, wallet, null);
wallet.setChildWallets(childWallets.values().stream().map(WalletBackupAndKey::getWallet).collect(Collectors.toList()));
File backupFile = storage.getTempBackup();
Wallet backupWallet = backupFile == null ? null : loadWallet(backupFile, null);
return new WalletBackupAndKey(wallet, backupWallet, null, null, childWallets);
}
@Override
public WalletBackupAndKey loadWallet(Storage storage, CharSequence password) throws IOException, StorageException {
return loadWallet(storage, password, null);
}
@Override
public WalletBackupAndKey loadWallet(Storage storage, CharSequence password, ECKey alreadyDerivedKey) throws IOException, StorageException {
Wallet wallet;
ECKey encryptionKey;
try(InputStream fileStream = new FileInputStream(storage.getWalletFile())) {
encryptionKey = getEncryptionKey(password, fileStream, alreadyDerivedKey);
Reader reader = new InputStreamReader(new InflaterInputStream(new ECIESInputStream(fileStream, encryptionKey, getEncryptionMagic())), StandardCharsets.UTF_8);
wallet = gson.fromJson(reader, Wallet.class);
}
Map<Storage, WalletBackupAndKey> childWallets = loadChildWallets(storage, wallet, encryptionKey);
wallet.setChildWallets(childWallets.values().stream().map(WalletBackupAndKey::getWallet).collect(Collectors.toList()));
File backupFile = storage.getTempBackup();
Wallet backupWallet = backupFile == null ? null : loadWallet(backupFile, encryptionKey);
return new WalletBackupAndKey(wallet, backupWallet, encryptionKey, keyDeriver, childWallets);
}
private Map<Storage, WalletBackupAndKey> loadChildWallets(Storage storage, Wallet masterWallet, ECKey encryptionKey) throws IOException, StorageException {
File[] walletFiles = getChildWalletFiles(storage.getWalletFile(), masterWallet);
Map<Storage, WalletBackupAndKey> childWallets = new LinkedHashMap<>();
for(File childFile : walletFiles) {
Wallet childWallet = loadWallet(childFile, encryptionKey);
Storage childStorage = new Storage(childFile);
childStorage.setEncryptionPubKey(encryptionKey == null ? Storage.NO_PASSWORD_KEY : ECKey.fromPublicOnly(encryptionKey));
childStorage.setKeyDeriver(getKeyDeriver());
childWallet.setMasterWallet(masterWallet);
childWallets.put(childStorage, new WalletBackupAndKey(childWallet, null, encryptionKey, keyDeriver, Collections.emptyMap()));
}
return childWallets;
}
private Wallet loadWallet(File walletFile, ECKey encryptionKey) throws IOException, StorageException {
if(encryptionKey != null) {
try(InputStream fileStream = new FileInputStream(walletFile)) {
encryptionKey = getEncryptionKey(null, fileStream, encryptionKey);
Reader reader = new InputStreamReader(new InflaterInputStream(new ECIESInputStream(fileStream, encryptionKey, getEncryptionMagic())), StandardCharsets.UTF_8);
return gson.fromJson(reader, Wallet.class);
}
} else {
try(Reader reader = new FileReader(walletFile)) {
return gson.fromJson(reader, Wallet.class);
}
}
}
private File[] getChildWalletFiles(File walletFile, Wallet masterWallet) {
File childDir = new File(walletFile.getParentFile(), masterWallet.getName() + "-child");
if(!childDir.exists()) {
return new File[0];
}
File[] childFiles = childDir.listFiles(pathname -> {
FileType fileType = IOUtils.getFileType(pathname);
return pathname.getName().startsWith(masterWallet.getName()) && (fileType == FileType.BINARY || fileType == FileType.JSON);
});
return childFiles == null ? new File[0] : childFiles;
}
@Override
public File storeWallet(Storage storage, Wallet wallet) throws IOException {
File walletFile = storage.getWalletFile();
if(!walletFile.getName().endsWith(".json")) {
File jsonFile = new File(walletFile.getParentFile(), walletFile.getName() + ".json");
if(walletFile.exists()) {
if(!walletFile.renameTo(jsonFile)) {
throw new IOException("Could not rename " + walletFile.getName() + " to " + jsonFile.getName());
}
}
walletFile = jsonFile;
}
if(!walletFile.exists()) {
Storage.createOwnerOnlyFile(walletFile);
}
try(Writer writer = new FileWriter(walletFile)) {
gson.toJson(wallet, writer);
}
return walletFile;
}
@Override
public File storeWallet(Storage storage, Wallet wallet, ECKey encryptionPubKey) throws IOException {
File walletFile = storage.getWalletFile();
if(walletFile.getName().endsWith(".json")) {
File noJsonFile = new File(walletFile.getParentFile(), walletFile.getName().substring(0, walletFile.getName().lastIndexOf('.')));
if(walletFile.exists()) {
if(!walletFile.renameTo(noJsonFile)) {
throw new IOException("Could not rename " + walletFile.getName() + " to " + noJsonFile.getName());
}
}
walletFile = noJsonFile;
}
if(!walletFile.exists()) {
Storage.createOwnerOnlyFile(walletFile);
}
try(OutputStream outputStream = new FileOutputStream(walletFile)) {
writeBinaryHeader(outputStream);
OutputStreamWriter writer = new OutputStreamWriter(new DeflaterOutputStream(new ECIESOutputStream(outputStream, encryptionPubKey, getEncryptionMagic())), StandardCharsets.UTF_8);
gson.toJson(wallet, writer);
//Close the writer explicitly as the try-resources block will not do so
writer.close();
}
return walletFile;
}
@Override
public void updateWallet(Storage storage, Wallet wallet) throws IOException {
storeWallet(storage, wallet);
}
@Override
public void updateWallet(Storage storage, Wallet wallet, ECKey encryptionPubKey) throws IOException {
storeWallet(storage, wallet, encryptionPubKey);
}
private void writeBinaryHeader(OutputStream outputStream) throws IOException {
ByteBuffer buf = ByteBuffer.allocate(21);
buf.put(HEADER_MAGIC_1.getBytes(StandardCharsets.UTF_8));
buf.put(keyDeriver.getSalt());
byte[] encoded = Base64.getEncoder().encode(buf.array());
if(encoded.length != BINARY_HEADER_LENGTH) {
throw new IllegalStateException("Header length not " + BINARY_HEADER_LENGTH + " bytes");
}
outputStream.write(encoded);
}
private static byte[] getEncryptionMagic() {
return "BIE1".getBytes(StandardCharsets.UTF_8);
}
@Override
public ECKey getEncryptionKey(CharSequence password) throws IOException, StorageException {
return getEncryptionKey(password, null, null);
}
private ECKey getEncryptionKey(CharSequence password, InputStream inputStream, ECKey alreadyDerivedKey) throws IOException, StorageException {
if(password != null && password.equals("")) {
return Storage.NO_PASSWORD_KEY;
}
AsymmetricKeyDeriver keyDeriver = getKeyDeriver(inputStream);
return alreadyDerivedKey == null ? keyDeriver.deriveECKey(password) : alreadyDerivedKey;
}
@Override
public AsymmetricKeyDeriver getKeyDeriver() {
return keyDeriver;
}
@Override
public void setKeyDeriver(AsymmetricKeyDeriver keyDeriver) {
this.keyDeriver = keyDeriver;
}
private AsymmetricKeyDeriver getKeyDeriver(InputStream inputStream) throws IOException, StorageException {
if(keyDeriver == null) {
keyDeriver = getWalletKeyDeriver(inputStream);
} else if(inputStream != null) {
inputStream.skip(BINARY_HEADER_LENGTH);
}
return keyDeriver;
}
private AsymmetricKeyDeriver getWalletKeyDeriver(InputStream inputStream) throws IOException, StorageException {
byte[] salt = new byte[SPRW1_PARAMETERS.saltLength];
if(inputStream != null) {
byte[] header = new byte[BINARY_HEADER_LENGTH];
int read = inputStream.read(header);
if(read != BINARY_HEADER_LENGTH) {
throw new StorageException("Not a Sparrow wallet - invalid header");
}
try {
byte[] decodedHeader = Base64.getDecoder().decode(header);
byte[] magic = Arrays.copyOfRange(decodedHeader, 0, HEADER_MAGIC_1.length());
if(!HEADER_MAGIC_1.equals(new String(magic, StandardCharsets.UTF_8))) {
throw new StorageException("Not a Sparrow wallet - invalid magic");
}
salt = Arrays.copyOfRange(decodedHeader, HEADER_MAGIC_1.length(), decodedHeader.length);
} catch(IllegalArgumentException e) {
throw new StorageException("Not a Sparrow wallet - invalid header");
}
} else {
SecureRandom secureRandom = new SecureRandom();
secureRandom.nextBytes(salt);
}
return new Argon2KeyDeriver(salt);
}
@Override
public PersistenceType getType() {
return PersistenceType.JSON;
}
@Override
public boolean isEncrypted(File walletFile) throws IOException {
FileType fileType = IOUtils.getFileType(walletFile);
if(FileType.JSON.equals(fileType)) {
return false;
} else if(FileType.BINARY.equals(fileType)) {
try(FileInputStream fileInputStream = new FileInputStream(walletFile)) {
getWalletKeyDeriver(fileInputStream);
return true;
} catch(StorageException e) {
return false;
}
}
throw new IOException("Unsupported file type");
}
@Override
public String getWalletId(Storage storage, Wallet wallet) {
return storage.getWalletFile().getParentFile().getAbsolutePath() + File.separator + getWalletName(storage.getWalletFile(), null) + ":" + (wallet == null || wallet.isMasterWallet() ? "master" : wallet.getName());
}
@Override
public String getWalletName(File walletFile, Wallet wallet) {
String name = walletFile.getName();
if(name.endsWith("." + getType().getExtension())) {
name = name.substring(0, name.lastIndexOf('.'));
}
return name;
}
@Override
public void copyWallet(File walletFile, OutputStream outputStream) throws IOException {
com.google.common.io.Files.copy(walletFile, outputStream);
}
@Override
public void close() {
//Nothing required
}
public static Gson getGson() {
return getGson(true);
}
private static Gson getGson(boolean includeWalletSerializers) {
GsonBuilder gsonBuilder = new GsonBuilder();
gsonBuilder.registerTypeAdapter(ExtendedKey.class, new ExtendedPublicKeySerializer());
gsonBuilder.registerTypeAdapter(ExtendedKey.class, new ExtendedPublicKeyDeserializer());
gsonBuilder.registerTypeAdapter(byte[].class, new ByteArraySerializer());
gsonBuilder.registerTypeAdapter(byte[].class, new ByteArrayDeserializer());
gsonBuilder.registerTypeAdapter(Sha256Hash.class, new Sha256HashSerializer());
gsonBuilder.registerTypeAdapter(Sha256Hash.class, new Sha256HashDeserializer());
gsonBuilder.registerTypeAdapter(Date.class, new DateSerializer());
gsonBuilder.registerTypeAdapter(Date.class, new DateDeserializer());
gsonBuilder.registerTypeAdapter(Transaction.class, new TransactionSerializer());
gsonBuilder.registerTypeAdapter(Transaction.class, new TransactionDeserializer());
if(includeWalletSerializers) {
gsonBuilder.registerTypeAdapter(Keystore.class, new KeystoreSerializer());
gsonBuilder.registerTypeAdapter(WalletNode.class, new NodeSerializer());
gsonBuilder.registerTypeAdapter(WalletNode.class, new NodeDeserializer());
}
gsonBuilder.addSerializationExclusionStrategy(new ExclusionStrategy() {
@Override
public boolean shouldSkipField(FieldAttributes field) {
return field.getName().equals("id") || field.getName().equals("masterWallet") || field.getName().equals("childWallets");
}
@Override
public boolean shouldSkipClass(Class<?> clazz) {
return false;
}
});
return gsonBuilder.setPrettyPrinting().disableHtmlEscaping().create();
}
private static class ExtendedPublicKeySerializer implements JsonSerializer<ExtendedKey> {
@Override
public JsonElement serialize(ExtendedKey src, Type typeOfSrc, JsonSerializationContext context) {
return new JsonPrimitive(src.toString());
}
}
private static class ExtendedPublicKeyDeserializer implements JsonDeserializer<ExtendedKey> {
@Override
public ExtendedKey deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
return ExtendedKey.fromDescriptor(json.getAsJsonPrimitive().getAsString());
}
}
private static class ByteArraySerializer implements JsonSerializer<byte[]> {
@Override
public JsonElement serialize(byte[] src, Type typeOfSrc, JsonSerializationContext context) {
return new JsonPrimitive(Utils.bytesToHex(src));
}
}
private static class ByteArrayDeserializer implements JsonDeserializer<byte[]> {
@Override
public byte[] deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
return Utils.hexToBytes(json.getAsJsonPrimitive().getAsString());
}
}
private static class Sha256HashSerializer implements JsonSerializer<Sha256Hash> {
@Override
public JsonElement serialize(Sha256Hash src, Type typeOfSrc, JsonSerializationContext context) {
return new JsonPrimitive(src.toString());
}
}
private static class Sha256HashDeserializer implements JsonDeserializer<Sha256Hash> {
@Override
public Sha256Hash deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
return Sha256Hash.wrap(json.getAsJsonPrimitive().getAsString());
}
}
private static class DateSerializer implements JsonSerializer<Date> {
@Override
public JsonElement serialize(Date src, Type typeOfSrc, JsonSerializationContext context) {
return new JsonPrimitive(src.getTime());
}
}
private static class DateDeserializer implements JsonDeserializer<Date> {
@Override
public Date deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
return new Date(json.getAsJsonPrimitive().getAsLong());
}
}
private static class TransactionSerializer implements JsonSerializer<Transaction> {
@Override
public JsonElement serialize(Transaction src, Type typeOfSrc, JsonSerializationContext context) {
try {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
src.bitcoinSerializeToStream(baos);
return new JsonPrimitive(Utils.bytesToHex(baos.toByteArray()));
} catch (IOException e) {
throw new IllegalStateException(e);
}
}
}
private static class TransactionDeserializer implements JsonDeserializer<Transaction> {
@Override
public Transaction deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
byte[] rawTx = Utils.hexToBytes(json.getAsJsonPrimitive().getAsString());
return new Transaction(rawTx);
}
}
private static class KeystoreSerializer implements JsonSerializer<Keystore> {
@Override
public JsonElement serialize(Keystore keystore, Type typeOfSrc, JsonSerializationContext context) {
JsonObject jsonObject = (JsonObject)getGson(false).toJsonTree(keystore);
if(keystore.hasPrivateKey()) {
jsonObject.remove("extendedPublicKey");
jsonObject.getAsJsonObject("keyDerivation").remove("masterFingerprint");
}
return jsonObject;
}
}
private static class NodeSerializer implements JsonSerializer<WalletNode> {
@Override
public JsonElement serialize(WalletNode node, Type typeOfSrc, JsonSerializationContext context) {
JsonObject jsonObject = (JsonObject)getGson(false).toJsonTree(node);
JsonArray children = jsonObject.getAsJsonArray("children");
Iterator<JsonElement> iter = children.iterator();
while(iter.hasNext()) {
JsonObject childObject = (JsonObject)iter.next();
removeEmptyCollection(childObject, "children");
removeEmptyCollection(childObject, "transactionOutputs");
if(childObject.get("label") == null && childObject.get("children") == null && childObject.get("transactionOutputs") == null) {
iter.remove();
}
}
return jsonObject;
}
private void removeEmptyCollection(JsonObject jsonObject, String memberName) {
if(jsonObject.get(memberName) != null && jsonObject.getAsJsonArray(memberName).size() == 0) {
jsonObject.remove(memberName);
}
}
}
private static class NodeDeserializer implements JsonDeserializer<WalletNode> {
@Override
public WalletNode deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
WalletNode node = getGson(false).fromJson(json, typeOfT);
node.parseDerivation();
for(WalletNode childNode : node.getChildren()) {
childNode.parseDerivation();
if(childNode.getChildren() == null) {
childNode.setChildren(new TreeSet<>());
}
if(childNode.getTransactionOutputs() == null) {
childNode.setTransactionOutputs(new TreeSet<>());
}
}
return node;
}
}
}
@@ -0,0 +1,70 @@
package com.sparrowwallet.sparrow.io;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletModel;
import java.io.InputStream;
public class KeystoneMultisig extends ColdcardMultisig {
@Override
public String getName() {
return "Keystone Multisig";
}
@Override
public WalletModel getWalletModel() {
return WalletModel.KEYSTONE;
}
@Override
public Keystore getKeystore(ScriptType scriptType, InputStream inputStream, String password) throws ImportException {
Keystore keystore = super.getKeystore(scriptType, inputStream, password);
keystore.setLabel("Keystone");
keystore.setWalletModel(getWalletModel());
return keystore;
}
@Override
public String getKeystoreImportDescription() {
return "Import file or QR created by using the Multisig Wallet > ... > Show/Export XPUB feature on your Keystone.";
}
@Override
public Wallet importWallet(InputStream inputStream, String password) throws ImportException {
Wallet wallet = super.importWallet(inputStream, password);
for(Keystore keystore : wallet.getKeystores()) {
keystore.setLabel(keystore.getLabel().replace("Coldcard", "Keystone"));
keystore.setWalletModel(WalletModel.KEYSTONE);
}
return wallet;
}
@Override
public String getWalletImportDescription() {
return "Import file or QR created by using the Multisig Wallet > ... > Create Multisig Wallet feature on your Keystone.";
}
@Override
public String getWalletExportDescription() {
return "Export file or QR that can be read by your Keystone using the Multisig Wallet > ... > Import Multisig Wallet feature.";
}
@Override
public boolean isWalletImportScannable() {
return true;
}
@Override
public boolean isKeystoreImportScannable() {
return true;
}
@Override
public boolean isWalletExportScannable() {
return true;
}
}
@@ -0,0 +1,112 @@
package com.sparrowwallet.sparrow.io;
import com.google.gson.Gson;
import com.sparrowwallet.drongo.ExtendedKey;
import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.OutputDescriptor;
import com.sparrowwallet.drongo.policy.Policy;
import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.wallet.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.BufferedReader;
import java.io.File;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.stream.Collectors;
public class KeystoneSinglesig implements KeystoreFileImport, WalletImport {
private static final Logger log = LoggerFactory.getLogger(KeystoneSinglesig.class);
@Override
public String getName() {
return "Keystone";
}
@Override
public String getKeystoreImportDescription() {
return "Import file or QR created by using the My Keystone > ... > Export Wallet feature on your Keystone. Make sure to set the Watch-only Wallet to Sparrow in the Settings first.";
}
@Override
public WalletModel getWalletModel() {
return WalletModel.KEYSTONE;
}
@Override
public Keystore getKeystore(ScriptType scriptType, InputStream inputStream, String password) throws ImportException {
try {
String outputDescriptor = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8)).lines().collect(Collectors.joining("\n"));
OutputDescriptor descriptor = OutputDescriptor.getOutputDescriptor(outputDescriptor);
if(descriptor.isMultisig()) {
throw new IllegalArgumentException("Output descriptor describes a multisig wallet");
}
if(descriptor.getScriptType() != scriptType) {
throw new IllegalArgumentException("Output descriptor describes a " + descriptor.getScriptType().getDescription() + " wallet");
}
ExtendedKey xpub = descriptor.getSingletonExtendedPublicKey();
KeyDerivation keyDerivation = descriptor.getKeyDerivation(xpub);
Keystore keystore = new Keystore();
keystore.setLabel(getName());
keystore.setSource(KeystoreSource.HW_AIRGAPPED);
keystore.setWalletModel(WalletModel.KEYSTONE);
keystore.setKeyDerivation(keyDerivation);
keystore.setExtendedPublicKey(xpub);
return keystore;
} catch (IllegalArgumentException e) {
log.error("Error getting " + getName() + " keystore - not an output descriptor");
throw new ImportException("Error getting " + getName() + " keystore", e);
} catch (Exception e) {
log.error("Error getting " + getName() + " keystore", e);
throw new ImportException("Error getting " + getName() + " keystore", e);
}
}
@Override
public String getWalletImportDescription() {
return getKeystoreImportDescription();
}
@Override
public Wallet importWallet(InputStream inputStream, String password) throws ImportException {
//Use default of P2WPKH
Keystore keystore = getKeystore(ScriptType.P2WPKH, inputStream, "");
Wallet wallet = new Wallet();
wallet.setPolicyType(PolicyType.SINGLE);
wallet.setScriptType(ScriptType.P2WPKH);
wallet.getKeystores().add(keystore);
wallet.setDefaultPolicy(Policy.getPolicy(PolicyType.SINGLE, ScriptType.P2WPKH, wallet.getKeystores(), null));
try {
wallet.checkWallet();
} catch(InvalidWalletException e) {
throw new ImportException("Imported " + getName() + " wallet was invalid: " + e.getMessage());
}
return wallet;
}
@Override
public boolean isEncrypted(File file) {
return false;
}
@Override
public boolean isWalletImportScannable() {
return true;
}
@Override
public boolean isKeystoreImportScannable() {
return true;
}
}
@@ -0,0 +1,28 @@
package com.sparrowwallet.sparrow.io;
import com.sparrowwallet.drongo.crypto.AsymmetricKeyDeriver;
import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.wallet.Wallet;
import java.io.File;
import java.io.IOException;
import java.io.OutputStream;
public interface Persistence {
WalletBackupAndKey loadWallet(Storage storage) throws IOException, StorageException;
WalletBackupAndKey loadWallet(Storage storage, CharSequence password) throws IOException, StorageException;
WalletBackupAndKey loadWallet(Storage storage, CharSequence password, ECKey alreadyDerivedKey) throws IOException, StorageException;
File storeWallet(Storage storage, Wallet wallet) throws IOException, StorageException;
File storeWallet(Storage storage, Wallet wallet, ECKey encryptionPubKey) throws IOException, StorageException;
void updateWallet(Storage storage, Wallet wallet) throws IOException, StorageException;
void updateWallet(Storage storage, Wallet wallet, ECKey encryptionPubKey) throws IOException, StorageException;
ECKey getEncryptionKey(CharSequence password) throws IOException, StorageException;
AsymmetricKeyDeriver getKeyDeriver();
void setKeyDeriver(AsymmetricKeyDeriver keyDeriver);
PersistenceType getType();
boolean isEncrypted(File walletFile) throws IOException;
String getWalletId(Storage storage, Wallet wallet);
String getWalletName(File walletFile, Wallet wallet);
void copyWallet(File walletFile, OutputStream outputStream) throws IOException;
void close();
}
@@ -0,0 +1,42 @@
package com.sparrowwallet.sparrow.io;
import com.sparrowwallet.sparrow.io.db.DbPersistence;
public enum PersistenceType {
JSON("json") {
@Override
public String getExtension() {
return getName();
}
@Override
public Persistence getInstance() {
return new JsonPersistence();
}
},
DB("db") {
@Override
public String getExtension() {
return "mv.db";
}
@Override
public Persistence getInstance() {
return new DbPersistence();
}
};
private final String name;
private PersistenceType(String name) {
this.name = name;
}
public String getName() {
return name;
}
public abstract String getExtension();
public abstract Persistence getInstance();
}
@@ -0,0 +1,20 @@
package com.sparrowwallet.sparrow.io;
import com.sparrowwallet.drongo.wallet.WalletModel;
public class SeedSigner extends SpecterDIY {
@Override
public String getName() {
return "SeedSigner";
}
@Override
public String getKeystoreImportDescription() {
return "Import QR created on your SeedSigner by selecting Generate XPUB in the Signing Tools menu. Note that SeedSigner currently only supports P2WSH Multisig wallets.";
}
@Override
public WalletModel getWalletModel() {
return WalletModel.SEEDSIGNER;
}
}
@@ -1,15 +1,19 @@
package com.sparrowwallet.sparrow.io;
import com.google.common.io.Files;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletModel;
import com.sparrowwallet.sparrow.AppServices;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
public class Sparrow implements WalletExport {
public class Sparrow implements WalletImport, WalletExport {
private static final Logger log = LoggerFactory.getLogger(Sparrow.class);
@Override
@@ -26,9 +30,14 @@ public class Sparrow implements WalletExport {
public void exportWallet(Wallet wallet, OutputStream outputStream) throws ExportException {
try {
Storage storage = AppServices.get().getOpenWallets().get(wallet);
Files.copy(storage.getWalletFile().toPath(), outputStream);
File tempFile = File.createTempFile(wallet.getName(), null);
Storage tempStorage = new Storage(PersistenceType.JSON, tempFile);
tempStorage.setKeyDeriver(storage.getKeyDeriver());
tempStorage.setEncryptionPubKey(storage.getEncryptionPubKey());
tempStorage.saveWallet(wallet);
Files.copy(tempStorage.getWalletFile(), outputStream);
outputStream.flush();
outputStream.close();
tempStorage.getWalletFile().delete();
} catch(Exception e) {
log.error("Error exporting Sparrow wallet file", e);
throw new ExportException("Error exporting Sparrow wallet file", e);
@@ -37,14 +46,16 @@ public class Sparrow implements WalletExport {
@Override
public String getWalletExportDescription() {
return "Exports a copy of your Sparrow wallet file, which can be loaded in another Sparrow instance running on any supported platform.";
return "Exports your Sparrow wallet file, which can be imported into another Sparrow instance running on any supported platform. If the wallet is encrypted, the same password is used to encrypt the exported file.";
}
@Override
public String getExportFileExtension(Wallet wallet) {
Storage storage = AppServices.get().getOpenWallets().get(wallet);
if(storage != null && (storage.getEncryptionPubKey() == null || Storage.NO_PASSWORD_KEY.equals(storage.getEncryptionPubKey()))) {
return "json";
try {
Storage storage = AppServices.get().getOpenWallets().get(wallet);
return storage.isEncrypted() ? "" : PersistenceType.JSON.getExtension();
} catch(IOException e) {
//ignore
}
return "";
@@ -59,4 +70,58 @@ public class Sparrow implements WalletExport {
public boolean walletExportRequiresDecryption() {
return false;
}
@Override
public boolean isEncrypted(File file) {
return Storage.isEncrypted(file);
}
@Override
public String getWalletImportDescription() {
return "Imports an exported Sparrow wallet file into Sparrow's wallets folder.";
}
@Override
public Wallet importWallet(InputStream inputStream, String password) throws ImportException {
Storage storage = null;
Wallet wallet = null;
File tempFile = null;
try {
tempFile = File.createTempFile("sparrow", null);
java.nio.file.Files.copy(inputStream, tempFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
storage = new Storage(PersistenceType.JSON, tempFile);
if(!isEncrypted(tempFile)) {
wallet = storage.loadUnencryptedWallet().getWallet();
} else {
WalletBackupAndKey walletBackupAndKey = storage.loadEncryptedWallet(password);
wallet = walletBackupAndKey.getWallet();
wallet.decrypt(walletBackupAndKey.getKey());
}
return wallet;
} catch(IOException | StorageException e) {
log.error("Error importing Sparrow wallet", e);
throw new ImportException("Error importing Sparrow wallet", e);
} finally {
if(storage != null) {
storage.close();
}
if(tempFile != null) {
if(wallet != null) {
File migratedWalletFile = Storage.getExistingWallet(tempFile.getParentFile(), wallet.getName());
if(migratedWalletFile != null) {
migratedWalletFile.delete();
}
}
tempFile.delete();
}
}
}
@Override
public boolean isWalletImportScannable() {
return false;
}
}
@@ -10,13 +10,10 @@ import com.sparrowwallet.drongo.wallet.WalletModel;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.*;
import java.nio.charset.StandardCharsets;
public class SpecterDIY implements KeystoreFileImport {
public class SpecterDIY implements KeystoreFileImport, WalletExport {
private static final Logger log = LoggerFactory.getLogger(SpecterDIY.class);
@Override
@@ -38,8 +35,8 @@ public class SpecterDIY implements KeystoreFileImport {
return keystore;
} catch(IOException e) {
log.error("Error getting Specter DIY keystore", e);
throw new ImportException("Error getting Specter DIY keystore", e);
log.error("Error getting " + getName() + " keystore", e);
throw new ImportException("Error getting " + getName() + " keystore", e);
}
}
@@ -67,4 +64,36 @@ public class SpecterDIY implements KeystoreFileImport {
public WalletModel getWalletModel() {
return WalletModel.SPECTER_DIY;
}
@Override
public void exportWallet(Wallet wallet, OutputStream outputStream) throws ExportException {
try {
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8));
writer.append("addwallet ").append(wallet.getName()).append("&").append(OutputDescriptor.getOutputDescriptor(wallet).toString().replace('\'', 'h')).append("\n");
writer.flush();
} catch(Exception e) {
log.error("Error exporting " + getName() + " wallet", e);
throw new ExportException("Error exporting " + getName() + " wallet", e);
}
}
@Override
public String getWalletExportDescription() {
return "Export file or QR that can be read by your Specter DIY using the Wallets > Add Wallet feature.";
}
@Override
public String getExportFileExtension(Wallet wallet) {
return "txt";
}
@Override
public boolean isWalletExportScannable() {
return true;
}
@Override
public boolean walletExportRequiresDecryption() {
return false;
}
}
@@ -3,10 +3,7 @@ package com.sparrowwallet.sparrow.io;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.sparrowwallet.drongo.OutputDescriptor;
import com.sparrowwallet.drongo.wallet.BlockTransactionHash;
import com.sparrowwallet.drongo.wallet.InvalidWalletException;
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;
@@ -15,6 +12,7 @@ import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.List;
public class SpecterDesktop implements WalletImport, WalletExport {
private static final Logger log = LoggerFactory.getLogger(SpecterDesktop.class);
@@ -31,10 +29,9 @@ public class SpecterDesktop implements WalletImport, WalletExport {
String json = gson.toJson(specterWallet);
outputStream.write(json.getBytes(StandardCharsets.UTF_8));
outputStream.flush();
outputStream.close();
} catch(Exception e) {
log.error("Error exporting Specter Desktop wallet", e);
throw new ExportException("Error exporting Specter Desktop wallet", e);
log.error("Error exporting " + getName() + " wallet", e);
throw new ExportException("Error exporting " + getName() + " wallet", e);
}
}
@@ -50,7 +47,7 @@ public class SpecterDesktop implements WalletImport, WalletExport {
@Override
public String getWalletImportDescription() {
return "Import a Specter wallet created by using the Wallets > Settings > Export to Wallet Software in Specter Desktop";
return "Import a Specter wallet created by using the Wallets > Settings > Export to Wallet Software in Specter Desktop. Note Connected (USB) Hardware Wallets may need to be reimported separately as the export file may not contain this information.";
}
@Override
@@ -64,20 +61,43 @@ public class SpecterDesktop implements WalletImport, WalletExport {
Wallet wallet = outputDescriptor.toWallet();
wallet.setName(specterWallet.label);
if(specterWallet.devices != null && specterWallet.devices.size() == wallet.getKeystores().size()) {
boolean uniqueLabels = specterWallet.devices.stream().map(d -> d.label).distinct().count() == specterWallet.devices.size();
for(int i = 0; i < specterWallet.devices.size(); i++) {
SpecterWalletDevice device = specterWallet.devices.get(i);
Keystore keystore = wallet.getKeystores().get(i);
keystore.setLabel(device.label + (uniqueLabels ? "" : " " + i));
WalletModel walletModel = device.getWalletModel();
if(walletModel != null) {
keystore.setWalletModel(walletModel);
if(walletModel == WalletModel.TREZOR_1 || walletModel == WalletModel.TREZOR_T || walletModel == WalletModel.KEEPKEY ||
walletModel == WalletModel.LEDGER_NANO_S || walletModel == WalletModel.LEDGER_NANO_X ||
walletModel == WalletModel.BITBOX_02 || walletModel == WalletModel.COLDCARD) {
keystore.setSource(KeystoreSource.HW_USB);
} else if(walletModel == WalletModel.BITCOIN_CORE) {
keystore.setSource(KeystoreSource.SW_WATCH);
} else {
keystore.setSource(KeystoreSource.HW_AIRGAPPED);
}
}
}
}
try {
wallet.checkWallet();
} catch(InvalidWalletException e) {
throw new ImportException("Imported Specter wallet was invalid: " + e.getMessage());
throw new ImportException("Imported " + getName() + " wallet was invalid: " + e.getMessage());
}
return wallet;
}
} catch(Exception e) {
log.error("Error importing Specter Desktop wallet", e);
throw new ImportException("Error importing Specter Desktop wallet", e);
log.error("Error importing " + getName() + " wallet", e);
throw new ImportException("Error importing " + getName() + " wallet", e);
}
throw new ImportException("File was not a valid Specter Desktop wallet");
throw new ImportException("File was not a valid " + getName() + " wallet");
}
@Override
@@ -114,5 +134,32 @@ public class SpecterDesktop implements WalletImport, WalletExport {
public String label;
public Integer blockheight;
public String descriptor;
public List<SpecterWalletDevice> devices;
}
public static class SpecterWalletDevice {
public String type;
public String label;
public WalletModel getWalletModel() {
if(type != null) {
String model = type;
if(model.equals("cobo")) {
model = "cobovault";
}
WalletModel walletModel = WalletModel.fromType(model);
if(walletModel == WalletModel.SPECTER_DESKTOP) {
walletModel = WalletModel.SPECTER_DIY;
}
if(walletModel == WalletModel.TREZOR_1) {
walletModel = WalletModel.TREZOR_T;
}
return walletModel;
}
return null;
}
}
}
@@ -1,17 +1,10 @@
package com.sparrowwallet.sparrow.io;
import com.google.common.io.Files;
import com.google.gson.*;
import com.sparrowwallet.drongo.ExtendedKey;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.SecureString;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.crypto.*;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletNode;
import com.sparrowwallet.sparrow.MainApp;
import javafx.concurrent.Service;
import javafx.concurrent.Task;
@@ -20,10 +13,9 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.*;
import java.lang.reflect.Type;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.nio.file.Files;
import java.nio.file.attribute.PosixFilePermission;
import java.nio.file.attribute.PosixFilePermissions;
import java.security.cert.Certificate;
import java.security.cert.CertificateEncodingException;
import java.text.DateFormat;
@@ -31,9 +23,6 @@ import java.text.SimpleDateFormat;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.zip.*;
import static com.sparrowwallet.drongo.crypto.Argon2KeyDeriver.SPRW1_PARAMETERS;
public class Storage {
private static final Logger log = LoggerFactory.getLogger(Storage.class);
@@ -47,224 +36,161 @@ public class Storage {
public static final String WALLETS_DIR = "wallets";
public static final String WALLETS_BACKUP_DIR = "backup";
public static final String CERTS_DIR = "certs";
public static final String HEADER_MAGIC_1 = "SPRW1";
private static final int BINARY_HEADER_LENGTH = 28;
public static final String TEMP_BACKUP_EXTENSION = "tmp";
public static final String TEMP_BACKUP_PREFIX = "tmp";
public static final List<String> RESERVED_WALLET_NAMES = List.of("temp");
private Persistence persistence;
private File walletFile;
private final Gson gson;
private AsymmetricKeyDeriver keyDeriver;
private ECKey encryptionPubKey;
public Storage(File walletFile) {
this(!walletFile.exists() || walletFile.getName().endsWith("." + PersistenceType.DB.getExtension()) ? PersistenceType.DB : PersistenceType.JSON, walletFile);
}
public Storage(PersistenceType persistenceType, File walletFile) {
this.persistence = persistenceType.getInstance();
this.walletFile = walletFile;
}
public Storage(Persistence persistence, File walletFile) {
this.persistence = persistence;
this.walletFile = walletFile;
this.gson = getGson();
}
public File getWalletFile() {
return walletFile;
}
public static Gson getGson() {
return getGson(true);
public boolean isEncrypted() throws IOException {
return persistence.isEncrypted(walletFile);
}
private static Gson getGson(boolean includeWalletSerializers) {
GsonBuilder gsonBuilder = new GsonBuilder();
gsonBuilder.registerTypeAdapter(ExtendedKey.class, new ExtendedPublicKeySerializer());
gsonBuilder.registerTypeAdapter(ExtendedKey.class, new ExtendedPublicKeyDeserializer());
gsonBuilder.registerTypeAdapter(byte[].class, new ByteArraySerializer());
gsonBuilder.registerTypeAdapter(byte[].class, new ByteArrayDeserializer());
gsonBuilder.registerTypeAdapter(Sha256Hash.class, new Sha256HashSerializer());
gsonBuilder.registerTypeAdapter(Sha256Hash.class, new Sha256HashDeserializer());
gsonBuilder.registerTypeAdapter(Date.class, new DateSerializer());
gsonBuilder.registerTypeAdapter(Date.class, new DateDeserializer());
gsonBuilder.registerTypeAdapter(Transaction.class, new TransactionSerializer());
gsonBuilder.registerTypeAdapter(Transaction.class, new TransactionDeserializer());
if(includeWalletSerializers) {
gsonBuilder.registerTypeAdapter(Keystore.class, new KeystoreSerializer());
gsonBuilder.registerTypeAdapter(WalletNode.class, new NodeSerializer());
gsonBuilder.registerTypeAdapter(WalletNode.class, new NodeDeserializer());
}
return gsonBuilder.setPrettyPrinting().disableHtmlEscaping().create();
public String getWalletId(Wallet wallet) {
return persistence.getWalletId(this, wallet);
}
public WalletBackupAndKey loadWallet() throws IOException {
Wallet wallet = loadWallet(walletFile);
public String getWalletName(Wallet wallet) {
return persistence.getWalletName(walletFile, wallet);
}
Wallet backupWallet = null;
File[] backups = getBackups("json." + TEMP_BACKUP_EXTENSION);
if(backups.length > 0) {
try {
backupWallet = loadWallet(backups[0]);
} catch(Exception e) {
log.error("Error loading backup wallet " + TEMP_BACKUP_EXTENSION, e);
}
public String getWalletFileExtension() {
if(walletFile.getName().endsWith("." + getType().getExtension())) {
return getType().getExtension();
}
return "";
}
public WalletBackupAndKey loadUnencryptedWallet() throws IOException, StorageException {
WalletBackupAndKey masterWalletAndKey = persistence.loadWallet(this);
encryptionPubKey = NO_PASSWORD_KEY;
return new WalletBackupAndKey(wallet, backupWallet, null);
return migrateToDb(masterWalletAndKey);
}
public Wallet loadWallet(File jsonFile) throws IOException {
Reader reader = new FileReader(jsonFile);
Wallet wallet = gson.fromJson(reader, Wallet.class);
reader.close();
return wallet;
public WalletBackupAndKey loadEncryptedWallet(CharSequence password) throws IOException, StorageException {
WalletBackupAndKey masterWalletAndKey = persistence.loadWallet(this, password);
encryptionPubKey = ECKey.fromPublicOnly(masterWalletAndKey.getEncryptionKey());
return migrateToDb(masterWalletAndKey);
}
public WalletBackupAndKey loadWallet(CharSequence password) throws IOException, StorageException {
WalletAndKey walletAndKey = loadWallet(walletFile, password);
WalletAndKey backupAndKey = new WalletAndKey(null, null);
File[] backups = getBackups(TEMP_BACKUP_EXTENSION, "json." + TEMP_BACKUP_EXTENSION);
if(backups.length > 0) {
try {
backupAndKey = loadWallet(backups[0], password);
} catch(Exception e) {
log.error("Error loading backup wallet " + TEMP_BACKUP_EXTENSION, e);
}
public void saveWallet(Wallet wallet) throws IOException, StorageException {
File parent = walletFile.getParentFile();
if(!parent.exists() && !Storage.createOwnerOnlyDirectory(parent)) {
throw new IOException("Could not create folder " + parent);
}
return new WalletBackupAndKey(walletAndKey.wallet, backupAndKey.wallet, walletAndKey.key);
}
public WalletAndKey loadWallet(File encryptedFile, CharSequence password) throws IOException, StorageException {
InputStream fileStream = new FileInputStream(encryptedFile);
ECKey encryptionKey = getEncryptionKey(password, fileStream);
InputStream inputStream = new InflaterInputStream(new ECIESInputStream(fileStream, encryptionKey, getEncryptionMagic()));
Reader reader = new InputStreamReader(inputStream, StandardCharsets.UTF_8);
Wallet wallet = gson.fromJson(reader, Wallet.class);
reader.close();
Key key = new Key(encryptionKey.getPrivKeyBytes(), keyDeriver.getSalt(), EncryptionType.Deriver.ARGON2);
encryptionPubKey = ECKey.fromPublicOnly(encryptionKey);
return new WalletAndKey(wallet, key);
}
public void storeWallet(Wallet wallet) throws IOException {
if(encryptionPubKey != null && !NO_PASSWORD_KEY.equals(encryptionPubKey)) {
storeWallet(encryptionPubKey, wallet);
walletFile = persistence.storeWallet(this, wallet, encryptionPubKey);
return;
}
File parent = walletFile.getParentFile();
if(!parent.exists() && !parent.mkdirs()) {
throw new IOException("Could not create folder " + parent);
}
if(!walletFile.getName().endsWith(".json")) {
File jsonFile = new File(parent, walletFile.getName() + ".json");
if(walletFile.exists()) {
if(!walletFile.renameTo(jsonFile)) {
throw new IOException("Could not rename " + walletFile.getName() + " to " + jsonFile.getName());
}
}
walletFile = jsonFile;
}
Writer writer = new FileWriter(walletFile);
gson.toJson(wallet, writer);
writer.close();
walletFile = persistence.storeWallet(this, wallet);
}
private void storeWallet(ECKey encryptionPubKey, Wallet wallet) throws IOException {
File parent = walletFile.getParentFile();
if(!parent.exists() && !parent.mkdirs()) {
throw new IOException("Could not create folder " + parent);
public void updateWallet(Wallet wallet) throws IOException, StorageException {
if(encryptionPubKey != null && !NO_PASSWORD_KEY.equals(encryptionPubKey)) {
persistence.updateWallet(this, wallet, encryptionPubKey);
} else {
persistence.updateWallet(this, wallet);
}
if(walletFile.getName().endsWith(".json")) {
File noJsonFile = new File(parent, walletFile.getName().substring(0, walletFile.getName().lastIndexOf('.')));
if(walletFile.exists()) {
if(!walletFile.renameTo(noJsonFile)) {
throw new IOException("Could not rename " + walletFile.getName() + " to " + noJsonFile.getName());
}
}
walletFile = noJsonFile;
}
OutputStream outputStream = new FileOutputStream(walletFile);
writeBinaryHeader(outputStream);
OutputStreamWriter writer = new OutputStreamWriter(new DeflaterOutputStream(new ECIESOutputStream(outputStream, encryptionPubKey, getEncryptionMagic())), StandardCharsets.UTF_8);
gson.toJson(wallet, writer);
writer.close();
}
private void writeBinaryHeader(OutputStream outputStream) throws IOException {
ByteBuffer buf = ByteBuffer.allocate(21);
buf.put(HEADER_MAGIC_1.getBytes(StandardCharsets.UTF_8));
buf.put(keyDeriver.getSalt());
byte[] encoded = Base64.getEncoder().encode(buf.array());
if(encoded.length != BINARY_HEADER_LENGTH) {
throw new IllegalStateException("Header length not " + BINARY_HEADER_LENGTH + " bytes");
}
outputStream.write(encoded);
public void close() {
ClosePersistenceService closePersistenceService = new ClosePersistenceService();
closePersistenceService.start();
}
public void backupWallet() throws IOException {
backupWallet(null);
if(walletFile.toPath().startsWith(getWalletsDir().toPath())) {
backupWallet(null);
}
}
public void backupTempWallet() {
try {
backupWallet(TEMP_BACKUP_EXTENSION);
backupWallet(TEMP_BACKUP_PREFIX);
} catch(IOException e) {
log.error("Error creating ." + TEMP_BACKUP_EXTENSION + " backup wallet", e);
log.error("Error creating " + TEMP_BACKUP_PREFIX + " backup wallet", e);
}
}
public void backupWallet(String extension) throws IOException {
private void backupWallet(String prefix) throws IOException {
File backupDir = getWalletsBackupDir();
Date backupDate = new Date();
String backupName = walletFile.getName();
String walletName = persistence.getWalletName(walletFile, null);
String dateSuffix = "-" + BACKUP_DATE_FORMAT.format(backupDate);
int lastDot = backupName.lastIndexOf('.');
if(lastDot > 0) {
backupName = backupName.substring(0, lastDot) + dateSuffix + backupName.substring(lastDot);
} else {
backupName += dateSuffix;
}
String backupName = walletName + dateSuffix + walletFile.getName().substring(walletName.length());
if(extension != null) {
backupName += "." + extension;
if(prefix != null) {
backupName = prefix + "_" + backupName;
}
File backupFile = new File(backupDir, backupName);
Files.copy(walletFile, backupFile);
if(!backupFile.exists()) {
createOwnerOnlyFile(backupFile);
}
try(FileOutputStream outputStream = new FileOutputStream(backupFile)) {
copyWallet(outputStream);
}
}
public void copyWallet(OutputStream outputStream) throws IOException {
persistence.copyWallet(walletFile, outputStream);
}
public void deleteBackups() {
deleteBackups(null);
}
public void deleteBackups(String extension) {
File[] backups = getBackups(extension);
public void deleteTempBackups() {
deleteBackups(Storage.TEMP_BACKUP_PREFIX);
}
private void deleteBackups(String prefix) {
File[] backups = getBackups(prefix);
for(File backup : backups) {
backup.delete();
}
}
private File[] getBackups(String extension) {
return getBackups(extension, null);
public File getTempBackup() {
File[] backups = getBackups(TEMP_BACKUP_PREFIX);
return backups.length == 0 ? null : backups[0];
}
private File[] getBackups(String extension, String notExtension) {
File[] getBackups(String prefix) {
File backupDir = getWalletsBackupDir();
String walletName = persistence.getWalletName(walletFile, null);
String extension = walletFile.getName().substring(walletName.length());
File[] backups = backupDir.listFiles((dir, name) -> {
return name.startsWith(Files.getNameWithoutExtension(walletFile.getName()) + "-") &&
return name.startsWith((prefix == null ? "" : prefix + "_") + walletName + "-") &&
getBackupDate(name) != null &&
(extension == null || name.endsWith("." + extension)) &&
(notExtension == null || !name.endsWith("." + notExtension));
(extension.isEmpty() || name.endsWith(extension));
});
backups = backups == null ? new File[0] : backups;
Arrays.sort(backups, Comparator.comparing(o -> getBackupDate(((File)o).getName())).reversed());
return backups;
@@ -279,6 +205,49 @@ public class Storage {
return null;
}
private WalletBackupAndKey migrateToDb(WalletBackupAndKey masterWalletAndKey) throws IOException, StorageException {
if(getType() == PersistenceType.JSON) {
log.info("Migrating " + masterWalletAndKey.getWallet().getName() + " from JSON to DB persistence");
masterWalletAndKey = migrateType(PersistenceType.DB, masterWalletAndKey.getWallet(), masterWalletAndKey.getEncryptionKey());
}
return masterWalletAndKey;
}
private WalletBackupAndKey migrateType(PersistenceType type, Wallet wallet, ECKey encryptionKey) throws IOException, StorageException {
File existingFile = walletFile;
try {
AsymmetricKeyDeriver keyDeriver = persistence.getKeyDeriver();
persistence = type.getInstance();
persistence.setKeyDeriver(keyDeriver);
walletFile = new File(walletFile.getParentFile(), wallet.getName() + "." + type.getExtension());
if(walletFile.exists()) {
walletFile.delete();
}
saveWallet(wallet);
if(type == PersistenceType.DB) {
for(Wallet childWallet : wallet.getChildWallets()) {
saveWallet(childWallet);
}
}
if(NO_PASSWORD_KEY.equals(encryptionPubKey)) {
return persistence.loadWallet(this);
}
return persistence.loadWallet(this, null, encryptionKey);
} catch(Exception e) {
existingFile = null;
throw e;
} finally {
if(existingFile != null) {
existingFile.delete();
}
}
}
public ECKey getEncryptionPubKey() {
return encryptionPubKey;
}
@@ -288,77 +257,52 @@ public class Storage {
}
public ECKey getEncryptionKey(CharSequence password) throws IOException, StorageException {
return getEncryptionKey(password, null);
}
private ECKey getEncryptionKey(CharSequence password, InputStream inputStream) throws IOException, StorageException {
if(password.equals("")) {
return NO_PASSWORD_KEY;
}
return getKeyDeriver(inputStream).deriveECKey(password);
return persistence.getEncryptionKey(password);
}
public AsymmetricKeyDeriver getKeyDeriver() {
return keyDeriver;
return persistence.getKeyDeriver();
}
void setKeyDeriver(AsymmetricKeyDeriver keyDeriver) {
this.keyDeriver = keyDeriver;
persistence.setKeyDeriver(keyDeriver);
}
private AsymmetricKeyDeriver getKeyDeriver(InputStream inputStream) throws IOException, StorageException {
if(keyDeriver == null) {
byte[] salt = new byte[SPRW1_PARAMETERS.saltLength];
if(inputStream != null) {
byte[] header = new byte[BINARY_HEADER_LENGTH];
int read = inputStream.read(header);
if(read != BINARY_HEADER_LENGTH) {
throw new StorageException("Not a Sparrow wallet - invalid header");
}
try {
byte[] decodedHeader = Base64.getDecoder().decode(header);
byte[] magic = Arrays.copyOfRange(decodedHeader, 0, HEADER_MAGIC_1.length());
if(!HEADER_MAGIC_1.equals(new String(magic, StandardCharsets.UTF_8))) {
throw new StorageException("Not a Sparrow wallet - invalid magic");
}
salt = Arrays.copyOfRange(decodedHeader, HEADER_MAGIC_1.length(), decodedHeader.length);
} catch(IllegalArgumentException e) {
throw new StorageException("Not a Sparrow wallet - invalid header");
}
} else {
SecureRandom secureRandom = new SecureRandom();
secureRandom.nextBytes(salt);
}
keyDeriver = new Argon2KeyDeriver(salt);
} else if(inputStream != null) {
inputStream.skip(BINARY_HEADER_LENGTH);
}
return keyDeriver;
}
private static byte[] getEncryptionMagic() {
return "BIE1".getBytes(StandardCharsets.UTF_8);
public PersistenceType getType() {
return persistence.getType();
}
public static boolean walletExists(String walletName) {
File encrypted = new File(getWalletsDir(), walletName.trim());
File unencrypted = new File(getWalletsDir(), walletName.trim() + ".json");
if(encrypted.exists()) {
return true;
}
return (encrypted.exists() || unencrypted.exists());
for(PersistenceType persistenceType : PersistenceType.values()) {
File unencrypted = new File(getWalletsDir(), walletName.trim() + "." + persistenceType.getExtension());
if(unencrypted.exists()) {
return true;
}
}
return RESERVED_WALLET_NAMES.contains(walletName);
}
public static File getExistingWallet(String walletName) {
File encrypted = new File(getWalletsDir(), walletName);
File unencrypted = new File(getWalletsDir(), walletName + ".json");
return getExistingWallet(getWalletsDir(), walletName);
}
public static File getExistingWallet(File walletsDir, String walletName) {
File encrypted = new File(walletsDir, walletName.trim());
if(encrypted.exists()) {
return encrypted;
} else if(unencrypted.exists()) {
return unencrypted;
}
for(PersistenceType persistenceType : PersistenceType.values()) {
File unencrypted = new File(walletsDir, walletName.trim() + "." + persistenceType.getExtension());
if(unencrypted.exists()) {
return unencrypted;
}
}
return null;
@@ -369,10 +313,42 @@ public class Storage {
return new File(getWalletsDir(), walletName);
}
public static boolean isWalletFile(File walletFile) {
for(PersistenceType type : PersistenceType.values()) {
if(walletFile.getName().endsWith("." + type.getExtension())) {
return true;
}
try {
if(type == PersistenceType.JSON && type.getInstance().isEncrypted(walletFile)) {
return true;
}
} catch(IOException e) {
//ignore
}
}
return false;
}
public static boolean isEncrypted(File walletFile) {
try {
for(PersistenceType type : PersistenceType.values()) {
if(walletFile.getName().endsWith("." + type.getExtension())) {
return type.getInstance().isEncrypted(walletFile);
}
}
} catch(IOException e) {
//ignore
}
return FileType.BINARY.equals(IOUtils.getFileType(walletFile));
}
public static File getWalletsBackupDir() {
File walletsBackupDir = new File(getWalletsDir(), WALLETS_BACKUP_DIR);
if(!walletsBackupDir.exists()) {
walletsBackupDir.mkdirs();
createOwnerOnlyDirectory(walletsBackupDir);
}
return walletsBackupDir;
@@ -381,7 +357,7 @@ public class Storage {
public static File getWalletsDir() {
File walletsDir = new File(getSparrowDir(), WALLETS_DIR);
if(!walletsDir.exists()) {
walletsDir.mkdirs();
createOwnerOnlyDirectory(walletsDir);
}
return walletsDir;
@@ -390,7 +366,7 @@ public class Storage {
public static File getCertificateFile(String host) {
File certsDir = getCertsDir();
File[] certs = certsDir.listFiles((dir, name) -> name.equals(host));
if(certs.length > 0) {
if(certs != null && certs.length > 0) {
return certs[0];
}
@@ -412,18 +388,25 @@ public class Storage {
static File getCertsDir() {
File certsDir = new File(getSparrowDir(), CERTS_DIR);
if(!certsDir.exists()) {
certsDir.mkdirs();
createOwnerOnlyDirectory(certsDir);
}
return certsDir;
}
static File getSparrowDir() {
File sparrowDir;
if(Network.get() != Network.MAINNET) {
return new File(getSparrowHome(), Network.get().getName());
sparrowDir = new File(getSparrowHome(), Network.get().getName());
} else {
sparrowDir = getSparrowHome();
}
return getSparrowHome();
if(!sparrowDir.exists()) {
createOwnerOnlyDirectory(sparrowDir);
}
return sparrowDir;
}
public static File getSparrowHome() {
@@ -446,160 +429,55 @@ public class Storage {
return new File(System.getProperty("user.home"));
}
private static class ExtendedPublicKeySerializer implements JsonSerializer<ExtendedKey> {
@Override
public JsonElement serialize(ExtendedKey src, Type typeOfSrc, JsonSerializationContext context) {
return new JsonPrimitive(src.toString());
}
}
private static class ExtendedPublicKeyDeserializer implements JsonDeserializer<ExtendedKey> {
@Override
public ExtendedKey deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
return ExtendedKey.fromDescriptor(json.getAsJsonPrimitive().getAsString());
}
}
private static class ByteArraySerializer implements JsonSerializer<byte[]> {
@Override
public JsonElement serialize(byte[] src, Type typeOfSrc, JsonSerializationContext context) {
return new JsonPrimitive(Utils.bytesToHex(src));
}
}
private static class ByteArrayDeserializer implements JsonDeserializer<byte[]> {
@Override
public byte[] deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
return Utils.hexToBytes(json.getAsJsonPrimitive().getAsString());
}
}
private static class Sha256HashSerializer implements JsonSerializer<Sha256Hash> {
@Override
public JsonElement serialize(Sha256Hash src, Type typeOfSrc, JsonSerializationContext context) {
return new JsonPrimitive(src.toString());
}
}
private static class Sha256HashDeserializer implements JsonDeserializer<Sha256Hash> {
@Override
public Sha256Hash deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
return Sha256Hash.wrap(json.getAsJsonPrimitive().getAsString());
}
}
private static class DateSerializer implements JsonSerializer<Date> {
@Override
public JsonElement serialize(Date src, Type typeOfSrc, JsonSerializationContext context) {
return new JsonPrimitive(src.getTime());
}
}
private static class DateDeserializer implements JsonDeserializer<Date> {
@Override
public Date deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
return new Date(json.getAsJsonPrimitive().getAsLong());
}
}
private static class TransactionSerializer implements JsonSerializer<Transaction> {
@Override
public JsonElement serialize(Transaction src, Type typeOfSrc, JsonSerializationContext context) {
try {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
src.bitcoinSerializeToStream(baos);
return new JsonPrimitive(Utils.bytesToHex(baos.toByteArray()));
} catch (IOException e) {
throw new IllegalStateException(e);
}
}
}
private static class TransactionDeserializer implements JsonDeserializer<Transaction> {
@Override
public Transaction deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
byte[] rawTx = Utils.hexToBytes(json.getAsJsonPrimitive().getAsString());
return new Transaction(rawTx);
}
}
private static class KeystoreSerializer implements JsonSerializer<Keystore> {
@Override
public JsonElement serialize(Keystore keystore, Type typeOfSrc, JsonSerializationContext context) {
JsonObject jsonObject = (JsonObject)getGson(false).toJsonTree(keystore);
if(keystore.hasPrivateKey()) {
jsonObject.remove("extendedPublicKey");
jsonObject.getAsJsonObject("keyDerivation").remove("masterFingerprint");
public static boolean createOwnerOnlyDirectory(File directory) {
try {
if(Platform.getCurrent() == Platform.WINDOWS) {
Files.createDirectories(directory.toPath());
return true;
}
return jsonObject;
Files.createDirectories(directory.toPath(), PosixFilePermissions.asFileAttribute(getDirectoryOwnerOnlyPosixFilePermissions()));
return true;
} catch(UnsupportedOperationException e) {
return directory.mkdirs();
} catch(IOException e) {
log.error("Could not create directory " + directory.getAbsolutePath(), e);
}
return false;
}
private static class NodeSerializer implements JsonSerializer<WalletNode> {
@Override
public JsonElement serialize(WalletNode node, Type typeOfSrc, JsonSerializationContext context) {
JsonObject jsonObject = (JsonObject)getGson(false).toJsonTree(node);
JsonArray children = jsonObject.getAsJsonArray("children");
Iterator<JsonElement> iter = children.iterator();
while(iter.hasNext()) {
JsonObject childObject = (JsonObject)iter.next();
removeEmptyCollection(childObject, "children");
removeEmptyCollection(childObject, "transactionOutputs");
if(childObject.get("label") == null && childObject.get("children") == null && childObject.get("transactionOutputs") == null) {
iter.remove();
}
public static boolean createOwnerOnlyFile(File file) {
try {
if(Platform.getCurrent() == Platform.WINDOWS) {
Files.createFile(file.toPath());
return true;
}
return jsonObject;
Files.createFile(file.toPath(), PosixFilePermissions.asFileAttribute(getFileOwnerOnlyPosixFilePermissions()));
return true;
} catch(UnsupportedOperationException e) {
return false;
} catch(IOException e) {
log.error("Could not create file " + file.getAbsolutePath(), e);
}
private void removeEmptyCollection(JsonObject jsonObject, String memberName) {
if(jsonObject.get(memberName) != null && jsonObject.getAsJsonArray(memberName).size() == 0) {
jsonObject.remove(memberName);
}
}
return false;
}
private static class NodeDeserializer implements JsonDeserializer<WalletNode> {
@Override
public WalletNode deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
WalletNode node = getGson(false).fromJson(json, typeOfT);
node.parseDerivation();
private static Set<PosixFilePermission> getDirectoryOwnerOnlyPosixFilePermissions() {
Set<PosixFilePermission> ownerOnly = getFileOwnerOnlyPosixFilePermissions();
ownerOnly.add(PosixFilePermission.OWNER_EXECUTE);
for(WalletNode childNode : node.getChildren()) {
childNode.parseDerivation();
if(childNode.getChildren() == null) {
childNode.setChildren(new TreeSet<>());
}
if(childNode.getTransactionOutputs() == null) {
childNode.setTransactionOutputs(new TreeSet<>());
}
}
return node;
}
return ownerOnly;
}
public static class WalletAndKey {
public final Wallet wallet;
public final Key key;
private static Set<PosixFilePermission> getFileOwnerOnlyPosixFilePermissions() {
Set<PosixFilePermission> ownerOnly = EnumSet.noneOf(PosixFilePermission.class);
ownerOnly.add(PosixFilePermission.OWNER_READ);
ownerOnly.add(PosixFilePermission.OWNER_WRITE);
public WalletAndKey(Wallet wallet, Key key) {
this.wallet = wallet;
this.key = key;
}
}
public static class WalletBackupAndKey extends WalletAndKey {
public final Wallet backupWallet;
public WalletBackupAndKey(Wallet wallet, Wallet backupWallet, Key key) {
super(wallet, key);
this.backupWallet = backupWallet;
}
return ownerOnly;
}
public static class LoadWalletService extends Service<WalletBackupAndKey> {
@@ -615,7 +493,7 @@ public class Storage {
protected Task<WalletBackupAndKey> createTask() {
return new Task<>() {
protected WalletBackupAndKey call() throws IOException, StorageException {
WalletBackupAndKey walletBackupAndKey = storage.loadWallet(password);
WalletBackupAndKey walletBackupAndKey = storage.loadEncryptedWallet(password);
password.clear();
return walletBackupAndKey;
}
@@ -669,4 +547,16 @@ public class Storage {
};
}
}
public class ClosePersistenceService extends Service<Void> {
@Override
protected Task<Void> createTask() {
return new Task<>() {
protected Void call() {
persistence.close();
return null;
}
};
}
}
}
@@ -0,0 +1,51 @@
package com.sparrowwallet.sparrow.io;
import com.sparrowwallet.drongo.crypto.*;
import com.sparrowwallet.drongo.wallet.Wallet;
import java.util.Map;
public class WalletBackupAndKey {
private final Wallet wallet;
private final Wallet backupWallet;
private final ECKey encryptionKey;
private final Key key;
private final Map<Storage, WalletBackupAndKey> childWallets;
public WalletBackupAndKey(Wallet wallet, Wallet backupWallet, ECKey encryptionKey, AsymmetricKeyDeriver keyDeriver, Map<Storage, WalletBackupAndKey> childWallets) {
this.wallet = wallet;
this.backupWallet = backupWallet;
this.encryptionKey = encryptionKey;
this.key = encryptionKey == null ? null : new Key(encryptionKey.getPrivKeyBytes(), keyDeriver.getSalt(), EncryptionType.Deriver.ARGON2);
this.childWallets = childWallets;
}
public Wallet getWallet() {
return wallet;
}
public Wallet getBackupWallet() {
return backupWallet;
}
public ECKey getEncryptionKey() {
return encryptionKey;
}
public Key getKey() {
return key;
}
public Map<Storage, WalletBackupAndKey> getChildWallets() {
return childWallets;
}
public void clear() {
if(encryptionKey != null) {
encryptionKey.clear();
}
if(key != null) {
key.clear();
}
}
}
@@ -0,0 +1,60 @@
package com.sparrowwallet.sparrow.io.db;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.wallet.BlockTransaction;
import com.sparrowwallet.drongo.wallet.Wallet;
import org.jdbi.v3.sqlobject.config.RegisterRowMapper;
import org.jdbi.v3.sqlobject.customizer.Bind;
import org.jdbi.v3.sqlobject.statement.GetGeneratedKeys;
import org.jdbi.v3.sqlobject.statement.SqlQuery;
import org.jdbi.v3.sqlobject.statement.SqlUpdate;
import java.util.Date;
import java.util.Map;
public interface BlockTransactionDao {
@SqlQuery("select id, txid, hash, height, date, fee, label, transaction, blockHash from blockTransaction where wallet = ? order by id")
@RegisterRowMapper(BlockTransactionMapper.class)
Map<Sha256Hash, BlockTransaction> getForWalletId(Long id);
@SqlQuery("select id, txid, hash, height, date, fee, label, transaction, blockHash from blockTransaction where txid = ?")
@RegisterRowMapper(BlockTransactionMapper.class)
Map<Sha256Hash, BlockTransaction> getForTxId(byte[] id);
@SqlUpdate("insert into blockTransaction (txid, hash, height, date, fee, label, transaction, blockHash, wallet) values (?, ?, ?, ?, ?, ?, ?, ?, ?)")
@GetGeneratedKeys("id")
long insertBlockTransaction(byte[] txid, byte[] hash, int height, Date date, Long fee, String label, byte[] transaction, byte[] blockHash, long wallet);
@SqlUpdate("update blockTransaction set txid = ?, hash = ?, height = ?, date = ?, fee = ?, label = ?, transaction = ?, blockHash = ?, wallet = ? where id = ?")
void updateBlockTransaction(byte[] txid, byte[] hash, int height, Date date, Long fee, String label, byte[] transaction, byte[] blockHash, long wallet, long id);
@SqlUpdate("update blockTransaction set label = :label where id = :id")
void updateLabel(@Bind("id") long id, @Bind("label") String label);
@SqlUpdate("delete from blockTransaction where wallet = ?")
void clear(long wallet);
default void addBlockTransactions(Wallet wallet) {
for(Map.Entry<Sha256Hash, BlockTransaction> blkTxEntry : wallet.getTransactions().entrySet()) {
blkTxEntry.getValue().setId(null);
addOrUpdate(wallet, blkTxEntry.getKey(), blkTxEntry.getValue());
}
}
default void addOrUpdate(Wallet wallet, Sha256Hash txid, BlockTransaction blkTx) {
Map<Sha256Hash, BlockTransaction> existing = getForTxId(txid.getBytes());
if(existing.isEmpty() && blkTx.getId() == null) {
long id = insertBlockTransaction(txid.getBytes(), blkTx.getHash().getBytes(), blkTx.getHeight(), blkTx.getDate(), blkTx.getFee(), blkTx.getLabel(),
blkTx.getTransaction() == null ? null : blkTx.getTransaction().bitcoinSerialize(),
blkTx.getBlockHash() == null ? null : blkTx.getBlockHash().getBytes(), wallet.getId());
blkTx.setId(id);
} else {
Long existingId = existing.get(txid) != null ? existing.get(txid).getId() : blkTx.getId();
updateBlockTransaction(txid.getBytes(), blkTx.getHash().getBytes(), blkTx.getHeight(), blkTx.getDate(), blkTx.getFee(), blkTx.getLabel(),
blkTx.getTransaction() == null ? null : blkTx.getTransaction().bitcoinSerialize(),
blkTx.getBlockHash() == null ? null : blkTx.getBlockHash().getBytes(), wallet.getId(), existingId);
blkTx.setId(existingId);
}
}
}
@@ -0,0 +1,26 @@
package com.sparrowwallet.sparrow.io.db;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
import com.sparrowwallet.drongo.wallet.Status;
import org.jdbi.v3.core.mapper.RowMapper;
import org.jdbi.v3.core.statement.StatementContext;
import java.sql.ResultSet;
import java.sql.SQLException;
public class BlockTransactionHashIndexMapper implements RowMapper<BlockTransactionHashIndex> {
@Override
public BlockTransactionHashIndex map(ResultSet rs, StatementContext ctx) throws SQLException {
BlockTransactionHashIndex blockTransactionHashIndex = new BlockTransactionHashIndex(Sha256Hash.wrap(rs.getBytes("blockTransactionHashIndex.hash")),
rs.getInt("blockTransactionHashIndex.height"), rs.getTimestamp("blockTransactionHashIndex.date"), rs.getLong("blockTransactionHashIndex.fee"),
rs.getLong("blockTransactionHashIndex.index"), rs.getLong("blockTransactionHashIndex.outputValue"), null, rs.getString("blockTransactionHashIndex.label"));
blockTransactionHashIndex.setId(rs.getLong("blockTransactionHashIndex.id"));
int statusIndex = rs.getInt("blockTransactionHashIndex.status");
if(!rs.wasNull()) {
blockTransactionHashIndex.setStatus(Status.values()[statusIndex]);
}
return blockTransactionHashIndex;
}
}
@@ -0,0 +1,51 @@
package com.sparrowwallet.sparrow.io.db;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.wallet.BlockTransaction;
import org.jdbi.v3.core.mapper.RowMapper;
import org.jdbi.v3.core.statement.StatementContext;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Map;
public class BlockTransactionMapper implements RowMapper<Map.Entry<Sha256Hash, BlockTransaction>> {
@Override
public Map.Entry<Sha256Hash, BlockTransaction> map(ResultSet rs, StatementContext ctx) throws SQLException {
Sha256Hash txid = Sha256Hash.wrap(rs.getBytes("txid"));
byte[] txBytes = rs.getBytes("transaction");
Transaction transaction = null;
if(txBytes != null) {
transaction = new Transaction(txBytes);
}
Long fee = rs.getLong("fee");
if(rs.wasNull()) {
fee = null;
}
BlockTransaction blockTransaction = new BlockTransaction(Sha256Hash.wrap(rs.getBytes("hash")), rs.getInt("height"), rs.getTimestamp("date"),
fee, transaction, rs.getBytes("blockHash") == null ? null : Sha256Hash.wrap(rs.getBytes("blockHash")), rs.getString("label"));
blockTransaction.setId(rs.getLong("id"));
return new Map.Entry<>() {
@Override
public Sha256Hash getKey() {
return txid;
}
@Override
public BlockTransaction getValue() {
return blockTransaction;
}
@Override
public BlockTransaction setValue(BlockTransaction value) {
return null;
}
};
}
}
@@ -0,0 +1,653 @@
package com.sparrowwallet.sparrow.io.db;
import com.google.common.eventbus.Subscribe;
import com.google.common.io.Files;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.crypto.Argon2KeyDeriver;
import com.sparrowwallet.drongo.crypto.AsymmetricKeyDeriver;
import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.crypto.InvalidPasswordException;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.io.*;
import com.sparrowwallet.sparrow.wallet.*;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import com.zaxxer.hikari.pool.HikariPool;
import org.flywaydb.core.Flyway;
import org.flywaydb.core.api.FlywayException;
import org.flywaydb.core.api.exception.FlywayValidateException;
import org.h2.tools.ChangeFileEncryption;
import org.jdbi.v3.core.Jdbi;
import org.jdbi.v3.core.h2.H2DatabasePlugin;
import org.jdbi.v3.sqlobject.SqlObjectPlugin;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.*;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.StandardCopyOption;
import java.security.SecureRandom;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class DbPersistence implements Persistence {
private static final Logger log = LoggerFactory.getLogger(DbPersistence.class);
static final String DEFAULT_SCHEMA = "PUBLIC";
private static final String WALLET_SCHEMA_PREFIX = "wallet_";
private static final String MASTER_SCHEMA = WALLET_SCHEMA_PREFIX + "master";
private static final byte[] H2_ENCRYPT_HEADER = "H2encrypt\n".getBytes(StandardCharsets.UTF_8);
private static final int H2_ENCRYPT_SALT_LENGTH_BYTES = 8;
private static final int SALT_LENGTH_BYTES = 16;
public static final byte[] HEADER_MAGIC_1 = "SPRW1\n".getBytes(StandardCharsets.UTF_8);
private static final String H2_USER = "sa";
private static final String H2_PASSWORD = "";
public static final String MIGRATION_RESOURCES_DIR = "com/sparrowwallet/sparrow/sql/";
private HikariDataSource dataSource;
private AsymmetricKeyDeriver keyDeriver;
private Wallet masterWallet;
private final Map<Wallet, DirtyPersistables> dirtyPersistablesMap = new HashMap<>();
public DbPersistence() {
EventManager.get().register(this);
}
@Override
public WalletBackupAndKey loadWallet(Storage storage) throws IOException, StorageException {
return loadWallet(storage, null, null);
}
@Override
public WalletBackupAndKey loadWallet(Storage storage, CharSequence password) throws IOException, StorageException {
return loadWallet(storage, password, null);
}
@Override
public WalletBackupAndKey loadWallet(Storage storage, CharSequence password, ECKey alreadyDerivedKey) throws IOException, StorageException {
ECKey encryptionKey = getEncryptionKey(password, storage.getWalletFile(), alreadyDerivedKey);
migrate(storage, MASTER_SCHEMA, encryptionKey);
Jdbi jdbi = getJdbi(storage, getFilePassword(encryptionKey));
masterWallet = jdbi.withHandle(handle -> {
WalletDao walletDao = handle.attach(WalletDao.class);
return walletDao.getMainWallet(MASTER_SCHEMA);
});
File backupFile = storage.getTempBackup();
Wallet backupWallet = null;
if(backupFile != null) {
Persistence backupPersistence = PersistenceType.DB.getInstance();
backupPersistence.setKeyDeriver(keyDeriver);
backupWallet = backupPersistence.loadWallet(new Storage(backupPersistence, backupFile), password, encryptionKey).getWallet();
}
Map<Storage, WalletBackupAndKey> childWallets = loadChildWallets(storage, masterWallet, backupWallet, encryptionKey);
masterWallet.setChildWallets(childWallets.values().stream().map(WalletBackupAndKey::getWallet).collect(Collectors.toList()));
return new WalletBackupAndKey(masterWallet, backupWallet, encryptionKey, keyDeriver, childWallets);
}
private Map<Storage, WalletBackupAndKey> loadChildWallets(Storage storage, Wallet masterWallet, Wallet backupWallet, ECKey encryptionKey) throws StorageException {
Jdbi jdbi = getJdbi(storage, getFilePassword(encryptionKey));
List<String> schemas = jdbi.withHandle(handle -> {
return handle.createQuery("show schemas").mapTo(String.class).list();
});
List<String> childSchemas = schemas.stream().filter(schema -> schema.startsWith(WALLET_SCHEMA_PREFIX) && !schema.equals(MASTER_SCHEMA)).collect(Collectors.toList());
Map<Storage, WalletBackupAndKey> childWallets = new LinkedHashMap<>();
for(String schema : childSchemas) {
migrate(storage, schema, encryptionKey);
Jdbi childJdbi = getJdbi(storage, getFilePassword(encryptionKey));
Wallet wallet = childJdbi.withHandle(handle -> {
WalletDao walletDao = handle.attach(WalletDao.class);
Wallet childWallet = walletDao.getMainWallet(schema);
childWallet.setName(schema.substring(WALLET_SCHEMA_PREFIX.length()));
childWallet.setMasterWallet(masterWallet);
return childWallet;
});
Wallet backupChildWallet = backupWallet == null ? null : backupWallet.getChildWallets().stream().filter(child -> wallet.getName().equals(child.getName())).findFirst().orElse(null);
childWallets.put(storage, new WalletBackupAndKey(wallet, backupChildWallet, encryptionKey, keyDeriver, Collections.emptyMap()));
}
return childWallets;
}
@Override
public File storeWallet(Storage storage, Wallet wallet) throws IOException, StorageException {
File walletFile = storage.getWalletFile();
walletFile = renameToDbFile(walletFile);
updatePassword(storage, null);
cleanAndAddWallet(storage, wallet, null);
return walletFile;
}
@Override
public File storeWallet(Storage storage, Wallet wallet, ECKey encryptionPubKey) throws IOException, StorageException {
File walletFile = storage.getWalletFile();
walletFile = renameToDbFile(walletFile);
boolean existing = walletFile.exists();
updatePassword(storage, encryptionPubKey);
cleanAndAddWallet(storage, wallet, getFilePassword(encryptionPubKey));
if(!existing) {
writeBinaryHeader(walletFile);
}
return walletFile;
}
@Override
public void updateWallet(Storage storage, Wallet wallet) throws StorageException {
updateWallet(storage, wallet, null);
}
@Override
public void updateWallet(Storage storage, Wallet wallet, ECKey encryptionPubKey) throws StorageException {
updatePassword(storage, encryptionPubKey);
update(storage, wallet, getFilePassword(encryptionPubKey));
}
private File renameToDbFile(File walletFile) throws IOException {
if(!walletFile.getName().endsWith("." + getType().getExtension())) {
File dbFile = new File(walletFile.getParentFile(), walletFile.getName() + "." + getType().getExtension());
if(walletFile.exists()) {
if(!walletFile.renameTo(dbFile)) {
throw new IOException("Could not rename " + walletFile.getName() + " to " + dbFile.getName());
}
}
return dbFile;
}
return walletFile;
}
private void update(Storage storage, Wallet wallet, String password) throws StorageException {
DirtyPersistables dirtyPersistables = dirtyPersistablesMap.get(wallet);
if(dirtyPersistables == null) {
return;
}
log.debug("Updating " + wallet.getName() + " on " + Thread.currentThread().getName());
log.debug(dirtyPersistables.toString());
Jdbi jdbi = getJdbi(storage, password);
jdbi.useHandle(handle -> {
WalletDao walletDao = handle.attach(WalletDao.class);
try {
walletDao.setSchema(getSchema(wallet));
if(dirtyPersistables.clearHistory) {
WalletNodeDao walletNodeDao = handle.attach(WalletNodeDao.class);
BlockTransactionDao blockTransactionDao = handle.attach(BlockTransactionDao.class);
walletNodeDao.clearHistory(wallet);
blockTransactionDao.clear(wallet.getId());
}
if(!dirtyPersistables.historyNodes.isEmpty()) {
WalletNodeDao walletNodeDao = handle.attach(WalletNodeDao.class);
BlockTransactionDao blockTransactionDao = handle.attach(BlockTransactionDao.class);
Set<Sha256Hash> referencedTxIds = new HashSet<>();
for(WalletNode addressNode : dirtyPersistables.historyNodes) {
if(addressNode.getId() == null) {
WalletNode purposeNode = wallet.getNode(addressNode.getKeyPurpose());
if(purposeNode.getId() == null) {
long purposeNodeId = walletNodeDao.insertWalletNode(purposeNode.getDerivationPath(), purposeNode.getLabel(), wallet.getId(), null);
purposeNode.setId(purposeNodeId);
}
long nodeId = walletNodeDao.insertWalletNode(addressNode.getDerivationPath(), addressNode.getLabel(), wallet.getId(), purposeNode.getId());
addressNode.setId(nodeId);
}
Set<BlockTransactionHashIndex> txos = addressNode.getTransactionOutputs().stream().flatMap(txo -> txo.isSpent() ? Stream.of(txo, txo.getSpentBy()) : Stream.of(txo)).collect(Collectors.toSet());
List<Long> existingIds = txos.stream().map(Persistable::getId).filter(Objects::nonNull).collect(Collectors.toList());
referencedTxIds.addAll(txos.stream().map(BlockTransactionHash::getHash).collect(Collectors.toSet()));
walletNodeDao.deleteNodeTxosNotInList(addressNode, existingIds.isEmpty() ? List.of(-1L) : existingIds);
for(BlockTransactionHashIndex txo : addressNode.getTransactionOutputs()) {
walletNodeDao.addOrUpdate(addressNode, txo);
}
}
for(Sha256Hash txid : referencedTxIds) {
BlockTransaction blkTx = wallet.getTransactions().get(txid);
blockTransactionDao.addOrUpdate(wallet, txid, blkTx);
}
}
if(dirtyPersistables.blockHeight != null) {
walletDao.updateStoredBlockHeight(wallet.getId(), dirtyPersistables.blockHeight);
}
if(!dirtyPersistables.labelEntries.isEmpty()) {
BlockTransactionDao blockTransactionDao = handle.attach(BlockTransactionDao.class);
WalletNodeDao walletNodeDao = handle.attach(WalletNodeDao.class);
for(Entry entry : dirtyPersistables.labelEntries) {
if(entry instanceof TransactionEntry && ((TransactionEntry)entry).getBlockTransaction().getId() != null) {
blockTransactionDao.updateLabel(((TransactionEntry)entry).getBlockTransaction().getId(), entry.getLabel());
} else if(entry instanceof NodeEntry && ((NodeEntry)entry).getNode().getId() != null) {
walletNodeDao.updateNodeLabel(((NodeEntry)entry).getNode().getId(), entry.getLabel());
} else if(entry instanceof HashIndexEntry && ((HashIndexEntry)entry).getHashIndex().getId() != null) {
walletNodeDao.updateTxoLabel(((HashIndexEntry)entry).getHashIndex().getId(), entry.getLabel());
}
}
}
if(!dirtyPersistables.utxoStatuses.isEmpty()) {
WalletNodeDao walletNodeDao = handle.attach(WalletNodeDao.class);
for(BlockTransactionHashIndex utxo : dirtyPersistables.utxoStatuses) {
walletNodeDao.updateTxoStatus(utxo.getId(), utxo.getStatus() == null ? null : utxo.getStatus().ordinal());
}
}
if(!dirtyPersistables.labelKeystores.isEmpty()) {
KeystoreDao keystoreDao = handle.attach(KeystoreDao.class);
for(Keystore keystore : dirtyPersistables.labelKeystores) {
keystoreDao.updateLabel(keystore.getLabel(), keystore.getId());
}
}
if(!dirtyPersistables.encryptionKeystores.isEmpty()) {
KeystoreDao keystoreDao = handle.attach(KeystoreDao.class);
for(Keystore keystore : dirtyPersistables.encryptionKeystores) {
keystoreDao.updateKeystoreEncryption(keystore);
}
}
dirtyPersistablesMap.remove(wallet);
} finally {
walletDao.setSchema(DEFAULT_SCHEMA);
}
});
}
private void cleanAndAddWallet(Storage storage, Wallet wallet, String password) throws StorageException {
String schema = getSchema(wallet);
cleanAndMigrate(storage, schema, password);
Jdbi jdbi = getJdbi(storage, password);
jdbi.useHandle(handle -> {
WalletDao walletDao = handle.attach(WalletDao.class);
walletDao.addWallet(schema, wallet);
});
if(wallet.isMasterWallet()) {
masterWallet = wallet;
}
}
private void migrate(Storage storage, String schema, ECKey encryptionKey) throws StorageException {
File migrationDir = getMigrationDir();
try {
Flyway flyway = getFlyway(storage, schema, getFilePassword(encryptionKey), migrationDir);
flyway.migrate();
} catch(FlywayValidateException e) {
log.error("Failed to open wallet file. Validation error during schema migration.", e);
throw new StorageException("Failed to open wallet file. Validation error during schema migration.", e);
} catch(FlywayException e) {
log.error("Failed to open wallet file. ", e);
throw new StorageException("Failed to open wallet file.\n" + e.getMessage(), e);
} finally {
IOUtils.deleteDirectory(migrationDir);
}
}
private void cleanAndMigrate(Storage storage, String schema, String password) throws StorageException {
File migrationDir = getMigrationDir();
try {
Flyway flyway = getFlyway(storage, schema, password, migrationDir);
flyway.clean();
flyway.migrate();
} catch(FlywayException e) {
log.error("Failed to save wallet file.", e);
throw new StorageException("Failed to save wallet file.\n" + e.getMessage(), e);
} finally {
IOUtils.deleteDirectory(migrationDir);
}
}
private String getSchema(Wallet wallet) {
return wallet.isMasterWallet() ? MASTER_SCHEMA : WALLET_SCHEMA_PREFIX + wallet.getName();
}
private String getFilePassword(ECKey encryptionKey) {
if(encryptionKey == null) {
return null;
}
return Utils.bytesToHex(encryptionKey.getPubKey());
}
private void writeBinaryHeader(File walletFile) throws IOException {
if(dataSource != null && !dataSource.isClosed()) {
dataSource.close();
}
ByteBuffer header = ByteBuffer.allocate(HEADER_MAGIC_1.length + SALT_LENGTH_BYTES);
header.put(HEADER_MAGIC_1);
header.put(keyDeriver.getSalt());
header.flip();
try(FileChannel fileChannel = new RandomAccessFile(walletFile, "rwd").getChannel()) {
fileChannel.position(H2_ENCRYPT_HEADER.length + H2_ENCRYPT_SALT_LENGTH_BYTES);
fileChannel.write(header);
}
}
private void updatePassword(Storage storage, ECKey encryptionPubKey) {
String newPassword = getFilePassword(encryptionPubKey);
String currentPassword = getDatasourcePassword();
//The password only needs to be changed if the datasource is not null - if we have not loaded the wallet from a datasource, it is a new wallet and the database is still to be created
if(dataSource != null && !Objects.equals(currentPassword, newPassword)) {
if(!dataSource.isClosed()) {
dataSource.close();
}
try {
File walletFile = storage.getWalletFile();
ChangeFileEncryption.execute(walletFile.getParent(), getWalletName(walletFile, null), "AES",
currentPassword == null ? null : currentPassword.toCharArray(),
newPassword == null ? null : newPassword.toCharArray(), true);
if(newPassword != null) {
writeBinaryHeader(walletFile);
}
//This sets the new password on the datasource for the next updatePassword check
getDataSource(storage, newPassword);
} catch(Exception e) {
log.error("Error changing database password", e);
}
}
}
private String getDatasourcePassword() {
if(dataSource != null) {
String dsPassword = dataSource.getPassword();
if(dsPassword.isEmpty()) {
return null;
}
return dsPassword.substring(0, dsPassword.length() - (" " + H2_PASSWORD).length());
}
return null;
}
@Override
public ECKey getEncryptionKey(CharSequence password) throws IOException {
return getEncryptionKey(password, null, null);
}
private ECKey getEncryptionKey(CharSequence password, File walletFile, ECKey alreadyDerivedKey) throws IOException {
if(alreadyDerivedKey != null) {
return alreadyDerivedKey;
} else if(password == null) {
return null;
} else if(password.equals("")) {
return Storage.NO_PASSWORD_KEY;
}
AsymmetricKeyDeriver keyDeriver = getKeyDeriver(walletFile);
return keyDeriver.deriveECKey(password);
}
@Override
public AsymmetricKeyDeriver getKeyDeriver() {
return keyDeriver;
}
@Override
public void setKeyDeriver(AsymmetricKeyDeriver keyDeriver) {
this.keyDeriver = keyDeriver;
}
private AsymmetricKeyDeriver getKeyDeriver(File walletFile) throws IOException {
if(keyDeriver == null) {
keyDeriver = getWalletKeyDeriver(walletFile);
}
return keyDeriver;
}
private AsymmetricKeyDeriver getWalletKeyDeriver(File walletFile) throws IOException {
if(keyDeriver == null) {
byte[] salt = new byte[SALT_LENGTH_BYTES];
if(walletFile != null && walletFile.exists()) {
try(InputStream inputStream = new FileInputStream(walletFile)) {
inputStream.skip(H2_ENCRYPT_HEADER.length + H2_ENCRYPT_SALT_LENGTH_BYTES + HEADER_MAGIC_1.length);
inputStream.read(salt, 0, salt.length);
}
} else {
SecureRandom secureRandom = new SecureRandom();
secureRandom.nextBytes(salt);
}
return new Argon2KeyDeriver(salt);
}
return keyDeriver;
}
@Override
public boolean isEncrypted(File walletFile) throws IOException {
if(dataSource != null) {
return getDatasourcePassword() != null;
}
byte[] header = new byte[H2_ENCRYPT_HEADER.length];
try(InputStream inputStream = new FileInputStream(walletFile)) {
inputStream.read(header, 0, H2_ENCRYPT_HEADER.length);
return Arrays.equals(H2_ENCRYPT_HEADER, header);
}
}
@Override
public String getWalletId(Storage storage, Wallet wallet) {
return storage.getWalletFile().getParentFile().getAbsolutePath() + File.separator + getWalletName(storage.getWalletFile(), null) + ":" + (wallet == null || wallet.isMasterWallet() ? "master" : wallet.getName());
}
@Override
public String getWalletName(File walletFile, Wallet wallet) {
if(wallet != null && wallet.getMasterWallet() != null) {
return wallet.getName();
}
String name = walletFile.getName();
if(name.endsWith("." + getType().getExtension())) {
name = name.substring(0, name.length() - getType().getExtension().length() - 1);
}
return name;
}
@Override
public PersistenceType getType() {
return PersistenceType.DB;
}
@Override
public void copyWallet(File walletFile, OutputStream outputStream) throws IOException {
if(dataSource != null && !dataSource.isClosed()) {
dataSource.close();
}
com.google.common.io.Files.copy(walletFile, outputStream);
}
@Override
public void close() {
EventManager.get().unregister(this);
if(dataSource != null && !dataSource.isClosed()) {
dataSource.close();
}
}
private Jdbi getJdbi(Storage storage, String password) throws StorageException {
Jdbi jdbi = Jdbi.create(getDataSource(storage, password));
jdbi.installPlugin(new H2DatabasePlugin());
jdbi.installPlugin(new SqlObjectPlugin());
return jdbi;
}
private Flyway getFlyway(Storage storage, String schema, String password, File resourcesDir) throws StorageException {
return Flyway.configure().dataSource(getDataSource(storage, password)).locations("filesystem:" + resourcesDir.getAbsolutePath()).schemas(schema).failOnMissingLocations(true).load();
}
//Flyway does not support JPMS yet, so the migration files are extracted to a temp dir in order to avoid classloader encapsulation issues
private File getMigrationDir() {
File migrationDir = Files.createTempDir();
try {
String[] files = IOUtils.getResourceListing(DbPersistence.class, MIGRATION_RESOURCES_DIR);
for(String name : files) {
File targetFile = new File(migrationDir, name);
try(InputStream inputStream = DbPersistence.class.getResourceAsStream("/" + MIGRATION_RESOURCES_DIR + name)) {
if(inputStream != null) {
java.nio.file.Files.copy(inputStream, targetFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
} else {
log.error("Could not load resource at /" + MIGRATION_RESOURCES_DIR + name);
}
}
}
} catch(Exception e) {
log.error("Could not extract migration resources", e);
}
return migrationDir;
}
private HikariDataSource getDataSource(Storage storage, String password) throws StorageException {
if(dataSource == null || dataSource.isClosed()) {
dataSource = createDataSource(storage.getWalletFile(), password);
}
return dataSource;
}
private HikariDataSource createDataSource(File walletFile, String password) throws StorageException {
try {
Class.forName("org.h2.Driver");
HikariConfig config = new HikariConfig();
config.setJdbcUrl(getUrl(walletFile, password));
config.setUsername(H2_USER);
config.setPassword(password == null ? H2_PASSWORD : password + " " + H2_PASSWORD);
return new HikariDataSource(config);
} catch(ClassNotFoundException e) {
log.error("Cannot find H2 driver", e);
throw new StorageException("Cannot find H2 driver", e);
} catch(HikariPool.PoolInitializationException e) {
if(e.getMessage() != null && e.getMessage().contains("Database may be already in use")) {
log.error("Wallet file may already be in use. Make sure the application is not running elsewhere.", e);
throw new StorageException("Wallet file may already be in use. Make sure the application is not running elsewhere.", e);
} else if(e.getMessage() != null && (e.getMessage().contains("Wrong user name or password") || e.getMessage().contains("Encryption error in file"))) {
throw new InvalidPasswordException("Incorrect password for wallet file " + walletFile.getAbsolutePath(), e);
} else {
log.error("Failed to open database file", e);
throw new StorageException("Failed to open database file.\n" + e.getMessage(), e);
}
}
}
private String getUrl(File walletFile, String password) {
return "jdbc:h2:" + walletFile.getAbsolutePath().replace("." + getType().getExtension(), "") + ";INIT=SET TRACE_LEVEL_FILE=4;TRACE_LEVEL_FILE=4;DATABASE_TO_UPPER=false" + (password == null ? "" : ";CIPHER=AES");
}
private boolean persistsFor(Wallet wallet) {
if(masterWallet != null) {
if(wallet == masterWallet) {
return true;
}
return masterWallet.getChildWallets().contains(wallet);
}
return false;
}
@Subscribe
public void walletHistoryCleared(WalletHistoryClearedEvent event) {
if(persistsFor(event.getWallet())) {
dirtyPersistablesMap.computeIfAbsent(event.getWallet(), key -> new DirtyPersistables()).clearHistory = true;
}
}
@Subscribe
public void walletHistoryChanged(WalletHistoryChangedEvent event) {
if(persistsFor(event.getWallet())) {
dirtyPersistablesMap.computeIfAbsent(event.getWallet(), key -> new DirtyPersistables()).historyNodes.addAll(event.getHistoryChangedNodes());
}
}
@Subscribe
public void walletBlockHeightChanged(WalletBlockHeightChangedEvent event) {
if(persistsFor(event.getWallet())) {
dirtyPersistablesMap.computeIfAbsent(event.getWallet(), key -> new DirtyPersistables()).blockHeight = event.getBlockHeight();
}
}
@Subscribe
public void walletEntryLabelsChanged(WalletEntryLabelsChangedEvent event) {
if(persistsFor(event.getWallet())) {
dirtyPersistablesMap.computeIfAbsent(event.getWallet(), key -> new DirtyPersistables()).labelEntries.addAll(event.getEntries());
}
}
@Subscribe
public void walletUtxoStatusChanged(WalletUtxoStatusChangedEvent event) {
if(persistsFor(event.getWallet())) {
dirtyPersistablesMap.computeIfAbsent(event.getWallet(), key -> new DirtyPersistables()).utxoStatuses.add(event.getUtxo());
}
}
@Subscribe
public void keystoreLabelsChanged(KeystoreLabelsChangedEvent event) {
if(persistsFor(event.getWallet())) {
dirtyPersistablesMap.computeIfAbsent(event.getWallet(), key -> new DirtyPersistables()).labelKeystores.addAll(event.getChangedKeystores());
}
}
@Subscribe
public void keystoreEncryptionChanged(KeystoreEncryptionChangedEvent event) {
if(persistsFor(event.getWallet())) {
dirtyPersistablesMap.computeIfAbsent(event.getWallet(), key -> new DirtyPersistables()).encryptionKeystores.addAll(event.getChangedKeystores());
}
}
private static class DirtyPersistables {
public boolean clearHistory;
public final List<WalletNode> historyNodes = new ArrayList<>();
public Integer blockHeight = null;
public final List<Entry> labelEntries = new ArrayList<>();
public final List<BlockTransactionHashIndex> utxoStatuses = new ArrayList<>();
public final List<Keystore> labelKeystores = new ArrayList<>();
public final List<Keystore> encryptionKeystores = new ArrayList<>();
public String toString() {
return "Dirty Persistables" +
"\nClear history:" + clearHistory +
"\nNodes:" + historyNodes +
"\nBlockHeight:" + blockHeight +
"\nTx labels:" + labelEntries.stream().filter(entry -> entry instanceof TransactionEntry).map(entry -> ((TransactionEntry)entry).getBlockTransaction().getHash().toString()).collect(Collectors.toList()) +
"\nAddress labels:" + labelEntries.stream().filter(entry -> entry instanceof NodeEntry).map(entry -> ((NodeEntry)entry).getNode().toString() + " " + entry.getLabel()).collect(Collectors.toList()) +
"\nUTXO labels:" + labelEntries.stream().filter(entry -> entry instanceof HashIndexEntry).map(entry -> ((HashIndexEntry)entry).getHashIndex().toString()).collect(Collectors.toList()) +
"\nUTXO statuses:" + utxoStatuses +
"\nKeystore labels:" + labelKeystores.stream().map(Keystore::getLabel).collect(Collectors.toList()) +
"\nKeystore encryptions:" + encryptionKeystores.stream().map(Keystore::getLabel).collect(Collectors.toList());
}
}
}
@@ -0,0 +1,102 @@
package com.sparrowwallet.sparrow.io.db;
import com.sparrowwallet.drongo.crypto.EncryptedData;
import com.sparrowwallet.drongo.wallet.DeterministicSeed;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.MasterPrivateExtendedKey;
import com.sparrowwallet.drongo.wallet.Wallet;
import org.jdbi.v3.sqlobject.config.RegisterRowMapper;
import org.jdbi.v3.sqlobject.statement.GetGeneratedKeys;
import org.jdbi.v3.sqlobject.statement.SqlQuery;
import org.jdbi.v3.sqlobject.statement.SqlUpdate;
import java.util.List;
public interface KeystoreDao {
@SqlQuery("select keystore.id, keystore.label, keystore.source, keystore.walletModel, keystore.masterFingerprint, keystore.derivationPath, keystore.extendedPublicKey, " +
"masterPrivateExtendedKey.id, masterPrivateExtendedKey.privateKey, masterPrivateExtendedKey.chainCode, masterPrivateExtendedKey.initialisationVector, masterPrivateExtendedKey.encryptedBytes, masterPrivateExtendedKey.keySalt, masterPrivateExtendedKey.deriver, masterPrivateExtendedKey.crypter, " +
"seed.id, seed.type, seed.mnemonicString, seed.initialisationVector, seed.encryptedBytes, seed.keySalt, seed.deriver, seed.crypter, seed.needsPassphrase, seed.creationTimeSeconds " +
"from keystore left join masterPrivateExtendedKey on keystore.masterPrivateExtendedKey = masterPrivateExtendedKey.id left join seed on keystore.seed = seed.id where keystore.wallet = ? order by keystore.index asc")
@RegisterRowMapper(KeystoreMapper.class)
List<Keystore> getForWalletId(Long id);
@SqlUpdate("insert into keystore (label, source, walletModel, masterFingerprint, derivationPath, extendedPublicKey, masterPrivateExtendedKey, seed, wallet, index) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
@GetGeneratedKeys("id")
long insert(String label, int source, int walletModel, String masterFingerprint, String derivationPath, String extendedPublicKey, Long masterPrivateExtendedKey, Long seed, long wallet, int index);
@SqlUpdate("insert into masterPrivateExtendedKey (privateKey, chainCode, initialisationVector, encryptedBytes, keySalt, deriver, crypter, creationTimeSeconds) values (?, ?, ?, ?, ?, ?, ?, ?)")
@GetGeneratedKeys("id")
long insertMasterPrivateExtendedKey(byte[] privateKey, byte[] chainCode, byte[] initialisationVector, byte[] encryptedBytes, byte[] keySalt, Integer deriver, Integer crypter, long creationTimeSeconds);
@SqlUpdate("update masterPrivateExtendedKey set privateKey = ?, chainCode = ?, initialisationVector = ?, encryptedBytes = ?, keySalt = ?, deriver = ?, crypter = ?, creationTimeSeconds = ? where id = ?")
void updateMasterPrivateExtendedKey(byte[] privateKey, byte[] chainCode, byte[] initialisationVector, byte[] encryptedBytes, byte[] keySalt, Integer deriver, Integer crypter, long creationTimeSeconds, long id);
@SqlUpdate("insert into seed (type, mnemonicString, initialisationVector, encryptedBytes, keySalt, deriver, crypter, needsPassphrase, creationTimeSeconds) values (?, ?, ?, ?, ?, ?, ?, ?, ?)")
@GetGeneratedKeys("id")
long insertSeed(int type, String mnemonicString, byte[] initialisationVector, byte[] encryptedBytes, byte[] keySalt, Integer deriver, Integer crypter, boolean needsPassphrase, long creationTimeSeconds);
@SqlUpdate("update seed set type = ?, mnemonicString = ?, initialisationVector = ?, encryptedBytes = ?, keySalt = ?, deriver = ?, crypter = ?, needsPassphrase = ?, creationTimeSeconds = ? where id = ?")
void updateSeed(int type, String mnemonicString, byte[] initialisationVector, byte[] encryptedBytes, byte[] keySalt, Integer deriver, Integer crypter, boolean needsPassphrase, long creationTimeSeconds, long id);
@SqlUpdate("update keystore set label = ? where id = ?")
void updateLabel(String label, long id);
default void addKeystores(Wallet wallet) {
for(int i = 0; i < wallet.getKeystores().size(); i++) {
Keystore keystore = wallet.getKeystores().get(i);
if(keystore.hasMasterPrivateExtendedKey()) {
MasterPrivateExtendedKey mpek = keystore.getMasterPrivateExtendedKey();
if(mpek.isEncrypted()) {
EncryptedData data = mpek.getEncryptedData();
long id = insertMasterPrivateExtendedKey(null, null, data.getInitialisationVector(), data.getEncryptedBytes(), data.getKeySalt(), data.getEncryptionType().getDeriver().ordinal(), data.getEncryptionType().getCrypter().ordinal(), mpek.getCreationTimeSeconds());
mpek.setId(id);
} else {
long id = insertMasterPrivateExtendedKey(mpek.getPrivateKey().getPrivKeyBytes(), mpek.getPrivateKey().getChainCode(), null, null, null, null, null, mpek.getCreationTimeSeconds());
mpek.setId(id);
}
}
if(keystore.hasSeed()) {
DeterministicSeed seed = keystore.getSeed();
if(seed.isEncrypted()) {
EncryptedData data = seed.getEncryptedData();
long id = insertSeed(seed.getType().ordinal(), null, data.getInitialisationVector(), data.getEncryptedBytes(), data.getKeySalt(), data.getEncryptionType().getDeriver().ordinal(), data.getEncryptionType().getCrypter().ordinal(), seed.needsPassphrase(), seed.getCreationTimeSeconds());
seed.setId(id);
} else {
long id = insertSeed(seed.getType().ordinal(), seed.getMnemonicString().asString(), null, null, null, null, null, seed.needsPassphrase(), seed.getCreationTimeSeconds());
seed.setId(id);
}
}
long id = insert(keystore.getLabel(), keystore.getSource().ordinal(), keystore.getWalletModel().ordinal(),
keystore.hasPrivateKey() ? null : keystore.getKeyDerivation().getMasterFingerprint(),
keystore.getKeyDerivation().getDerivationPath(),
keystore.hasPrivateKey() ? null : keystore.getExtendedPublicKey().toString(),
keystore.getMasterPrivateExtendedKey() == null ? null : keystore.getMasterPrivateExtendedKey().getId(),
keystore.getSeed() == null ? null : keystore.getSeed().getId(), wallet.getId(), i);
keystore.setId(id);
}
}
default void updateKeystoreEncryption(Keystore keystore) {
if(keystore.hasMasterPrivateExtendedKey()) {
MasterPrivateExtendedKey mpek = keystore.getMasterPrivateExtendedKey();
if(mpek.isEncrypted()) {
EncryptedData data = mpek.getEncryptedData();
updateMasterPrivateExtendedKey(null, null, data.getInitialisationVector(), data.getEncryptedBytes(), data.getKeySalt(), data.getEncryptionType().getDeriver().ordinal(), data.getEncryptionType().getCrypter().ordinal(), mpek.getCreationTimeSeconds(), mpek.getId());
} else {
updateMasterPrivateExtendedKey(mpek.getPrivateKey().getPrivKeyBytes(), mpek.getPrivateKey().getChainCode(), null, null, null, null, null, mpek.getCreationTimeSeconds(), mpek.getId());
}
}
if(keystore.hasSeed()) {
DeterministicSeed seed = keystore.getSeed();
if(seed.isEncrypted()) {
EncryptedData data = seed.getEncryptedData();
updateSeed(seed.getType().ordinal(), null, data.getInitialisationVector(), data.getEncryptedBytes(), data.getKeySalt(), data.getEncryptionType().getDeriver().ordinal(), data.getEncryptionType().getCrypter().ordinal(), seed.needsPassphrase(), seed.getCreationTimeSeconds(), seed.getId());
} else {
updateSeed(seed.getType().ordinal(), seed.getMnemonicString().asString(), null, null, null, null, null, seed.needsPassphrase(), seed.getCreationTimeSeconds(), seed.getId());
}
}
}
}
@@ -0,0 +1,58 @@
package com.sparrowwallet.sparrow.io.db;
import com.sparrowwallet.drongo.ExtendedKey;
import com.sparrowwallet.drongo.KeyDerivation;
import com.sparrowwallet.drongo.crypto.EncryptedData;
import com.sparrowwallet.drongo.crypto.EncryptionType;
import com.sparrowwallet.drongo.wallet.*;
import org.jdbi.v3.core.mapper.RowMapper;
import org.jdbi.v3.core.statement.StatementContext;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Arrays;
import java.util.List;
public class KeystoreMapper implements RowMapper<Keystore> {
@Override
public Keystore map(ResultSet rs, StatementContext ctx) throws SQLException {
Keystore keystore = new Keystore(rs.getString("keystore.label"));
keystore.setId(rs.getLong("keystore.id"));
keystore.setSource(KeystoreSource.values()[rs.getInt("keystore.source")]);
keystore.setWalletModel(WalletModel.values()[rs.getInt("keystore.walletModel")]);
keystore.setKeyDerivation(new KeyDerivation(rs.getString("keystore.masterFingerprint"), rs.getString("keystore.derivationPath")));
keystore.setExtendedPublicKey(rs.getString("keystore.extendedPublicKey") == null ? null : ExtendedKey.fromDescriptor(rs.getString("keystore.extendedPublicKey")));
if(rs.getBytes("masterPrivateExtendedKey.privateKey") != null) {
MasterPrivateExtendedKey masterPrivateExtendedKey = new MasterPrivateExtendedKey(rs.getBytes("masterPrivateExtendedKey.privateKey"), rs.getBytes("masterPrivateExtendedKey.chainCode"));
masterPrivateExtendedKey.setId(rs.getLong("masterPrivateExtendedKey.id"));
keystore.setMasterPrivateExtendedKey(masterPrivateExtendedKey);
} else if(rs.getBytes("masterPrivateExtendedKey.encryptedBytes") != null) {
EncryptedData encryptedData = new EncryptedData(rs.getBytes("masterPrivateExtendedKey.initialisationVector"),
rs.getBytes("masterPrivateExtendedKey.encryptedBytes"), rs.getBytes("masterPrivateExtendedKey.keySalt"),
EncryptionType.Deriver.values()[rs.getInt("masterPrivateExtendedKey.deriver")],
EncryptionType.Crypter.values()[rs.getInt("masterPrivateExtendedKey.crypter")]);
MasterPrivateExtendedKey masterPrivateExtendedKey = new MasterPrivateExtendedKey(encryptedData);
masterPrivateExtendedKey.setId(rs.getLong("masterPrivateExtendedKey.id"));
keystore.setMasterPrivateExtendedKey(masterPrivateExtendedKey);
}
if(rs.getString("seed.mnemonicString") != null) {
List<String> mnemonicCode = Arrays.asList(rs.getString("seed.mnemonicString").split(" "));
DeterministicSeed seed = new DeterministicSeed(mnemonicCode, rs.getBoolean("seed.needsPassphrase"), rs.getLong("seed.creationTimeSeconds"), DeterministicSeed.Type.values()[rs.getInt("seed.type")]);
seed.setId(rs.getLong("seed.id"));
keystore.setSeed(seed);
} else if(rs.getBytes("seed.encryptedBytes") != null) {
EncryptedData encryptedData = new EncryptedData(rs.getBytes("seed.initialisationVector"),
rs.getBytes("seed.encryptedBytes"), rs.getBytes("seed.keySalt"),
EncryptionType.Deriver.values()[rs.getInt("seed.deriver")],
EncryptionType.Crypter.values()[rs.getInt("seed.crypter")]);
DeterministicSeed seed = new DeterministicSeed(encryptedData, rs.getBoolean("seed.needsPassphrase"), rs.getLong("seed.creationTimeSeconds"), DeterministicSeed.Type.values()[rs.getInt("seed.type")]);
seed.setId(rs.getLong("seed.id"));
keystore.setSeed(seed);
}
return keystore;
}
}
@@ -0,0 +1,22 @@
package com.sparrowwallet.sparrow.io.db;
import com.sparrowwallet.drongo.policy.Policy;
import org.jdbi.v3.sqlobject.config.RegisterRowMapper;
import org.jdbi.v3.sqlobject.statement.GetGeneratedKeys;
import org.jdbi.v3.sqlobject.statement.SqlQuery;
import org.jdbi.v3.sqlobject.statement.SqlUpdate;
public interface PolicyDao {
@SqlQuery("select * from policy where id = ?")
@RegisterRowMapper(PolicyMapper.class)
Policy getPolicy(long id);
@SqlUpdate("insert into policy (name, script) values (?, ?)")
@GetGeneratedKeys("id")
long insert(String name, String script);
default void addPolicy(Policy policy) {
long id = insert(policy.getName(), policy.getMiniscript().getScript());
policy.setId(id);
}
}
@@ -0,0 +1,18 @@
package com.sparrowwallet.sparrow.io.db;
import com.sparrowwallet.drongo.policy.Miniscript;
import com.sparrowwallet.drongo.policy.Policy;
import org.jdbi.v3.core.mapper.RowMapper;
import org.jdbi.v3.core.statement.StatementContext;
import java.sql.ResultSet;
import java.sql.SQLException;
public class PolicyMapper implements RowMapper<Policy> {
@Override
public Policy map(ResultSet rs, StatementContext ctx) throws SQLException {
Policy policy = new Policy(rs.getString("name"), new Miniscript(rs.getString("script")));
policy.setId(rs.getLong("id"));
return policy;
}
}
@@ -0,0 +1,106 @@
package com.sparrowwallet.sparrow.io.db;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.wallet.BlockTransaction;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletNode;
import org.jdbi.v3.sqlobject.CreateSqlObject;
import org.jdbi.v3.sqlobject.config.RegisterRowMapper;
import org.jdbi.v3.sqlobject.customizer.Bind;
import org.jdbi.v3.sqlobject.statement.GetGeneratedKeys;
import org.jdbi.v3.sqlobject.statement.SqlQuery;
import org.jdbi.v3.sqlobject.statement.SqlUpdate;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public interface WalletDao {
@CreateSqlObject
PolicyDao createPolicyDao();
@CreateSqlObject
KeystoreDao createKeystoreDao();
@CreateSqlObject
WalletNodeDao createWalletNodeDao();
@CreateSqlObject
BlockTransactionDao createBlockTransactionDao();
@SqlQuery("select wallet.id, wallet.name, wallet.network, wallet.policyType, wallet.scriptType, wallet.storedBlockHeight, wallet.gapLimit, wallet.birthDate, policy.id, policy.name, policy.script from wallet left join policy on wallet.defaultPolicy = policy.id")
@RegisterRowMapper(WalletMapper.class)
List<Wallet> loadAllWallets();
@SqlQuery("select wallet.id, wallet.name, wallet.network, wallet.policyType, wallet.scriptType, wallet.storedBlockHeight, wallet.gapLimit, wallet.birthDate, policy.id, policy.name, policy.script from wallet left join policy on wallet.defaultPolicy = policy.id where wallet.id = 1")
@RegisterRowMapper(WalletMapper.class)
Wallet loadMainWallet();
@SqlQuery("select wallet.id, wallet.name, wallet.network, wallet.policyType, wallet.scriptType, wallet.storedBlockHeight, wallet.gapLimit, wallet.birthDate, policy.id, policy.name, policy.script from wallet left join policy on wallet.defaultPolicy = policy.id where wallet.id != 1")
@RegisterRowMapper(WalletMapper.class)
List<Wallet> loadChildWallets();
@SqlUpdate("insert into wallet (name, network, policyType, scriptType, storedBlockHeight, gapLimit, birthDate, defaultPolicy) values (?, ?, ?, ?, ?, ?, ?, ?)")
@GetGeneratedKeys("id")
long insert(String name, int network, int policyType, int scriptType, Integer storedBlockHeight, Integer gapLimit, Date birthDate, long defaultPolicy);
@SqlUpdate("update wallet set storedBlockHeight = :blockHeight where id = :id")
void updateStoredBlockHeight(@Bind("id") long id, @Bind("blockHeight") Integer blockHeight);
@SqlUpdate("set schema ?")
int setSchema(String schema);
default Wallet getMainWallet(String schema) {
try {
setSchema(schema);
Wallet mainWallet = loadMainWallet();
if(mainWallet != null) {
loadWallet(mainWallet);
}
return mainWallet;
} finally {
setSchema(DbPersistence.DEFAULT_SCHEMA);
}
}
default List<Wallet> getChildWallets(String schema) {
try {
List<Wallet> childWallets = loadChildWallets();
for(Wallet childWallet : childWallets) {
loadWallet(childWallet);
}
return childWallets;
} finally {
setSchema(DbPersistence.DEFAULT_SCHEMA);
}
}
default void loadWallet(Wallet wallet) {
wallet.getKeystores().addAll(createKeystoreDao().getForWalletId(wallet.getId()));
List<WalletNode> walletNodes = createWalletNodeDao().getForWalletId(wallet.getId());
wallet.getPurposeNodes().addAll(walletNodes.stream().filter(walletNode -> walletNode.getDerivation().size() == 1).collect(Collectors.toList()));
Map<Sha256Hash, BlockTransaction> blockTransactions = createBlockTransactionDao().getForWalletId(wallet.getId()); //.stream().collect(Collectors.toMap(BlockTransaction::getHash, Function.identity(), (existing, replacement) -> existing, LinkedHashMap::new));
wallet.updateTransactions(blockTransactions);
}
default void addWallet(String schema, Wallet wallet) {
try {
setSchema(schema);
createPolicyDao().addPolicy(wallet.getDefaultPolicy());
long id = insert(wallet.getName(), wallet.getNetwork().ordinal(), wallet.getPolicyType().ordinal(), wallet.getScriptType().ordinal(), wallet.getStoredBlockHeight(), wallet.gapLimit(), wallet.getBirthDate(), wallet.getDefaultPolicy().getId());
wallet.setId(id);
createKeystoreDao().addKeystores(wallet);
createWalletNodeDao().addWalletNodes(wallet);
createBlockTransactionDao().addBlockTransactions(wallet);
} finally {
setSchema(DbPersistence.DEFAULT_SCHEMA);
}
}
}
@@ -0,0 +1,37 @@
package com.sparrowwallet.sparrow.io.db;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.policy.Miniscript;
import com.sparrowwallet.drongo.policy.Policy;
import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.wallet.Wallet;
import org.jdbi.v3.core.mapper.RowMapper;
import org.jdbi.v3.core.statement.StatementContext;
import java.sql.ResultSet;
import java.sql.SQLException;
public class WalletMapper implements RowMapper<Wallet> {
@Override
public Wallet map(ResultSet rs, StatementContext ctx) throws SQLException {
Wallet wallet = new Wallet(rs.getString("wallet.name"));
wallet.setId(rs.getLong("wallet.id"));
wallet.setNetwork(Network.values()[rs.getInt("wallet.network")]);
wallet.setPolicyType(PolicyType.values()[rs.getInt("wallet.policyType")]);
wallet.setScriptType(ScriptType.values()[rs.getInt("wallet.scriptType")]);
Policy policy = new Policy(rs.getString("policy.name"), new Miniscript(rs.getString("policy.script")));
policy.setId(rs.getLong("policy.id"));
wallet.setDefaultPolicy(policy);
int storedBlockHeight = rs.getInt("wallet.storedBlockHeight");
wallet.setStoredBlockHeight(rs.wasNull() ? null : storedBlockHeight);
int gapLimit = rs.getInt("wallet.gapLimit");
wallet.gapLimit(rs.wasNull() ? null : gapLimit);
wallet.setBirthDate(rs.getTimestamp("wallet.birthDate"));
return wallet;
}
}
@@ -0,0 +1,116 @@
package com.sparrowwallet.sparrow.io.db;
import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletNode;
import org.jdbi.v3.sqlobject.config.RegisterRowMapper;
import org.jdbi.v3.sqlobject.customizer.Bind;
import org.jdbi.v3.sqlobject.customizer.BindList;
import org.jdbi.v3.sqlobject.statement.GetGeneratedKeys;
import org.jdbi.v3.sqlobject.statement.SqlQuery;
import org.jdbi.v3.sqlobject.statement.SqlUpdate;
import org.jdbi.v3.sqlobject.statement.UseRowReducer;
import java.util.Date;
import java.util.List;
public interface WalletNodeDao {
@SqlQuery("select walletNode.id, walletNode.derivationPath, walletNode.label, walletNode.parent, " +
"blockTransactionHashIndex.id, blockTransactionHashIndex.hash, blockTransactionHashIndex.height, blockTransactionHashIndex.date, blockTransactionHashIndex.fee, blockTransactionHashIndex.label, " +
"blockTransactionHashIndex.index, blockTransactionHashIndex.outputValue, blockTransactionHashIndex.status, blockTransactionHashIndex.spentBy, blockTransactionHashIndex.node " +
"from walletNode left join blockTransactionHashIndex on walletNode.id = blockTransactionHashIndex.node where walletNode.wallet = ? order by walletNode.parent asc nulls first, blockTransactionHashIndex.spentBy asc nulls first")
@RegisterRowMapper(WalletNodeMapper.class)
@RegisterRowMapper(BlockTransactionHashIndexMapper.class)
@UseRowReducer(WalletNodeReducer.class)
List<WalletNode> getForWalletId(Long id);
@SqlUpdate("insert into walletNode (derivationPath, label, wallet, parent) values (?, ?, ?, ?)")
@GetGeneratedKeys("id")
long insertWalletNode(String derivationPath, String label, long wallet, Long parent);
@SqlUpdate("insert into blockTransactionHashIndex (hash, height, date, fee, label, index, outputValue, status, spentBy, node) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
@GetGeneratedKeys("id")
long insertBlockTransactionHashIndex(byte[] hash, int height, Date date, Long fee, String label, long index, long value, Integer status, Long spentBy, long node);
@SqlUpdate("update blockTransactionHashIndex set hash = ?, height = ?, date = ?, fee = ?, label = ?, index = ?, outputValue = ?, status = ?, spentBy = ?, node = ? where id = ?")
void updateBlockTransactionHashIndex(byte[] hash, int height, Date date, Long fee, String label, long index, long value, Integer status, Long spentBy, long node, long id);
@SqlUpdate("update walletNode set label = :label where id = :id")
void updateNodeLabel(@Bind("id") long id, @Bind("label") String label);
@SqlUpdate("update blockTransactionHashIndex set label = :label where id = :id")
void updateTxoLabel(@Bind("id") long id, @Bind("label") String label);
@SqlUpdate("update blockTransactionHashIndex set status = :status where id = :id")
void updateTxoStatus(@Bind("id") long id, @Bind("status") Integer status);
@SqlUpdate("delete from blockTransactionHashIndex where blockTransactionHashIndex.node in (select walletNode.id from walletNode where walletNode.wallet = ?)")
void clearHistory(long wallet);
@SqlUpdate("delete from blockTransactionHashIndex where blockTransactionHashIndex.node in (select walletNode.id from walletNode where walletNode.wallet = ?) and blockTransactionHashIndex.spentBy is not null")
void clearSpentHistory(long wallet);
@SqlUpdate("delete from blockTransactionHashIndex where node = :nodeId and id not in (<ids>)")
void deleteUnreferencedNodeTxos(@Bind("nodeId") Long nodeId, @BindList("ids") List<Long> ids);
@SqlUpdate("delete from blockTransactionHashIndex where node = :nodeId and id not in (<ids>) and spentBy is not null")
void deleteUnreferencedNodeSpentTxos(@Bind("nodeId") Long nodeId, @BindList("ids") List<Long> ids);
default void addWalletNodes(Wallet wallet) {
for(WalletNode purposeNode : wallet.getPurposeNodes()) {
long purposeNodeId = insertWalletNode(purposeNode.getDerivationPath(), purposeNode.getLabel(), wallet.getId(), null);
purposeNode.setId(purposeNodeId);
for(WalletNode addressNode : purposeNode.getChildren()) {
long addressNodeId = insertWalletNode(addressNode.getDerivationPath(), addressNode.getLabel(), wallet.getId(), purposeNodeId);
addressNode.setId(addressNodeId);
addTransactionOutputs(addressNode);
}
}
}
default void addTransactionOutputs(WalletNode addressNode) {
for(BlockTransactionHashIndex txo : addressNode.getTransactionOutputs()) {
txo.setId(null);
if(txo.isSpent()) {
txo.getSpentBy().setId(null);
}
addOrUpdate(addressNode, txo);
}
}
default void addOrUpdate(WalletNode addressNode, BlockTransactionHashIndex txo) {
Long spentById = null;
if(txo.isSpent()) {
BlockTransactionHashIndex spentBy = txo.getSpentBy();
if(spentBy.getId() == null) {
spentById = insertBlockTransactionHashIndex(spentBy.getHash().getBytes(), spentBy.getHeight(), spentBy.getDate(), spentBy.getFee(), spentBy.getLabel(), spentBy.getIndex(), spentBy.getValue(),
spentBy.getStatus() == null ? null : spentBy.getStatus().ordinal(), null, addressNode.getId());
spentBy.setId(spentById);
} else {
updateBlockTransactionHashIndex(spentBy.getHash().getBytes(), spentBy.getHeight(), spentBy.getDate(), spentBy.getFee(), spentBy.getLabel(), spentBy.getIndex(), spentBy.getValue(),
spentBy.getStatus() == null ? null : spentBy.getStatus().ordinal(), null, addressNode.getId(), spentBy.getId());
spentById = spentBy.getId();
}
}
if(txo.getId() == null) {
long txoId = insertBlockTransactionHashIndex(txo.getHash().getBytes(), txo.getHeight(), txo.getDate(), txo.getFee(), txo.getLabel(), txo.getIndex(), txo.getValue(),
txo.getStatus() == null ? null : txo.getStatus().ordinal(), spentById, addressNode.getId());
txo.setId(txoId);
} else {
updateBlockTransactionHashIndex(txo.getHash().getBytes(), txo.getHeight(), txo.getDate(), txo.getFee(), txo.getLabel(), txo.getIndex(), txo.getValue(),
txo.getStatus() == null ? null : txo.getStatus().ordinal(), spentById, addressNode.getId(), txo.getId());
}
}
default void deleteNodeTxosNotInList(WalletNode addressNode, List<Long> txoIds) {
deleteUnreferencedNodeSpentTxos(addressNode.getId(), txoIds);
deleteUnreferencedNodeTxos(addressNode.getId(), txoIds);
}
default void clearHistory(Wallet wallet) {
clearSpentHistory(wallet.getId());
clearHistory(wallet.getId());
}
}

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