Compare commits

...
75 Commits
Author SHA1 Message Date
Craig Raw db60afd13b v1.6.4 2022-05-19 13:32:33 +02:00
Craig Raw f176a2a04f add freeze utxo hyperlink to dust attack warning 2022-05-19 12:44:15 +02:00
Craig Raw 82be3a52dc show signature status on transaction tab for loaded transactions when offline 2022-05-19 11:23:40 +02:00
Craig Raw 4b2b8f653a ensure minimum relay fee rate is always equal or greater than 1 sat/vb 2022-05-19 08:50:34 +02:00
Craig Raw 555e5ecfb8 recalculate txid before copying 2022-05-18 08:44:34 +02:00
Craig Raw c0ca74ce6a add dust attack warning to utxos tab where small value txes are received on used addresses 2022-05-18 08:38:47 +02:00
Craig Raw 674498052f update gh build action and java versions 2022-05-17 12:30:22 +02:00
Craig Raw 1399b73dc2 upgrade to nightjar 0.2.33 for whirlpool startup fix 2022-05-17 11:20:17 +02:00
Craig Raw c51f3d9e66 improve validation and focus handling in integer spinners 2022-05-17 10:52:43 +02:00
Craig Raw 766a8c267f scan seed qr to bip39 and watch only keystores 2022-05-17 09:18:09 +02:00
Craig Raw 948d663fbf sign psbt from a transient scanned seed (seedqr, compactseedqr, ur:crypto-seed, ur:crypto-bip39 supported) 2022-05-17 08:04:57 +02:00
Craig Raw 66be5c43a6 fix whirlpool introduction text 2022-05-16 08:32:09 +02:00
Craig Raw e0b00513b9 make usb hw enumerate period configurable 2022-05-12 14:50:31 +02:00
Craig Raw 218761c594 disable privacy optimisation button for payjoins, improve rbf behaviour for wallet sweep txes 2022-05-12 10:57:31 +02:00
Craig Raw 5e4d6d5a78 only show mix selected button for p2wpkh wallets 2022-05-11 15:27:02 +02:00
Craig Raw b06df383dd enable max button when rbf replacement tx has only one output 2022-05-11 15:18:14 +02:00
Craig Raw 361e92c600 fix test build error 2022-05-11 13:23:44 +02:00
Craig Raw 69d0a2f96e configure aarch64 specific dependencies 2022-05-09 15:01:35 +02:00
Craig Raw bf078b2ea0 add native libraries for aarch64 2022-05-06 17:34:23 +02:00
Craig Raw b5fa8f0ee0 add delete wallet functionality, overwriting wallet file data first 2022-05-06 10:37:15 +02:00
Craig Raw 7fb230e56b upgrade to javafx 18 2022-05-05 10:54:48 +02:00
Craig Raw a53ecc4fdc Merge branch 'master' of github.com:sparrowwallet/sparrow 2022-05-05 10:19:58 +02:00
Craig Raw 3955eaaa3c upgrade to gradle 7.4.2 2022-05-05 10:19:31 +02:00
Craig Raw d1d090a12b followup 2022-05-04 15:10:15 +02:00
Craig Raw cd1509749a upgrade to hwi-2.1.0 with usb taproot signing and jade support 2022-05-04 14:33:48 +02:00
Craig Raw dd5278f442 update mix to button when wallet label is changed 2022-05-03 12:24:31 +02:00
Craig Raw 631f5d48df add note on installing wix to build windows installer 2022-05-03 11:43:22 +02:00
Craig Raw c981cf32b9 add restart in testnet/mainnet menu command 2022-05-03 09:20:30 +02:00
Craig Raw 984cabfc03 make connection toggle pulsing clearer by increasing opacity range 2022-04-29 15:54:37 +02:00
Craig Raw 8d28f8f0a9 reverse sort order of date column in utxos tab 2022-04-29 15:38:10 +02:00
Craig Raw 472fccc788 enable pasting a string into send to many spreadsheet using pre-editing cell context menu 2022-04-29 12:18:17 +02:00
Craig Raw b1e715b272 update wallet name in db on load if wallet filename is changed 2022-04-28 14:57:04 +02:00
Craig Raw 6931cf7a45 add select all button to utxos tab 2022-04-28 13:10:52 +02:00
Craig Raw 1ccfc3c042 disable lock all wallets menu item when all wallets are locked 2022-04-28 12:28:58 +02:00
Craig Raw dd1976f173 rename menu item where necessary to indicate wallet accounts are refreshed individually 2022-04-28 11:58:49 +02:00
Craig Raw eceaf40430 update confirmation status for opened unconfirmed wallet txes when mined 2022-04-28 11:22:24 +02:00
Craig Raw e565786bbc pass desired account number to keystore import description 2022-04-27 10:53:11 +02:00
Craig Raw 37a8a0a7f9 improve table sorting of utxos with the same hash 2022-04-26 16:21:13 +02:00
Craig Raw 6aa3bb2ff3 disable clear button when no utxos are selected 2022-04-26 16:03:49 +02:00
Craig Raw 516ee26ba0 remove ds store files 2022-04-26 14:58:23 +02:00
Craig Raw 1224abcd1d truncate keystore labels on caravan import 2022-04-26 14:34:45 +02:00
Craig Raw 81eda96690 v1.6.3 2022-03-31 15:11:00 +02:00
Craig Raw af9eb3cc64 add pdf export of wallet output descriptor from qr display dialog 2022-03-31 14:14:20 +02:00
Craig Raw d9bba16eb6 ensure order of unencrypted wallet tabs is retained across restarts 2022-03-31 11:42:46 +02:00
Craig Raw 58cd50f674 update bwt for bitcoin core v23 compatibility, ensure rescan when changing wallet birthday in transactions table 2022-03-31 10:38:38 +02:00
Craig Raw b2b9dbeb8d remove earn.com as fee rates source 2022-03-31 09:21:31 +02:00
Craig Raw 0469141fee use paynym.is onion address when proxy is set 2022-03-31 09:20:57 +02:00
Craig Raw b16c7345a8 allow collaborative sends for linked paynyms, support searching for custom paynyms when initiating collaborative sends 2022-03-30 18:26:41 +02:00
Craig Raw b1940e9293 fix issues updating utxo chart when mixing 2022-03-29 17:38:39 +02:00
Craig Raw 1f51f632c4 change show delay duration for help tooltips to 500ms 2022-03-29 12:10:47 +02:00
Craig Raw ba199ff11b avoid npe loading new wallets with watchlast set 2022-03-28 09:40:31 +02:00
Craig Raw 79c0f7769a indicate when a server failure occurs loading a transaction from file 2022-03-28 09:31:18 +02:00
Craig Raw 761ec0659f show error message if partial signatures do not match wallet on finalizing psbt 2022-03-27 11:00:25 +02:00
Craig Raw 5e31cdb7ac update utxo fiat balances as rates change 2022-03-26 14:15:00 +02:00
Craig Raw 468384d82a omit frozen utxos from soroban collaboration wallets 2022-03-26 11:19:30 +02:00
Craig Raw 230a4c5585 move subtabs to the left with clearer color hierarchy, always show once a multiple account wallet has been opened 2022-03-22 09:16:28 +02:00
Craig Raw 9048d341c6 disable bip47 support for taproot script type 2022-03-21 09:39:16 +02:00
Craig Raw b0f60bb671 warn if saved certificate file could not be deleted 2022-03-19 12:28:12 +02:00
Craig Raw 9c87ecd4ec v1.6.2 2022-03-17 15:20:33 +02:00
Craig Raw 5324e5fcc2 add paynym contact from followers list 2022-03-17 14:47:45 +02:00
Craig Raw c02da607e7 allow any linked paynym contact to be renamed 2022-03-17 13:11:45 +02:00
Craig Raw 281fad5970 identify and color code signatures in transaction hex witness data 2022-03-17 12:12:57 +02:00
Craig Raw 95d8201bd9 set bip47 wallet label before creating addresses 2022-03-17 10:33:47 +02:00
Craig Raw 04cb27f85e label invalid notification transactions and avoid relink attempts 2022-03-17 09:45:51 +02:00
Craig Raw a765e07c10 support linking and sending to payment codes without paynym.is 2022-03-16 16:37:08 +02:00
Craig Raw ef5cca26ea allow mix counterparty to retry listening 2022-03-14 11:47:12 +02:00
Craig Raw d86517606b make db-updater daemon thread 2022-03-13 10:56:58 +02:00
Craig Raw 9dcf3b7eea final fix for send to paynym max button issue 2022-03-11 12:54:09 +02:00
Craig Raw 689f4abfde enable create button when sending max payment to a linked paynym 2022-03-08 10:22:39 +01:00
Craig Raw 5357b55ef4 v1.6.1 2022-03-04 14:46:33 +02:00
Craig Raw a10bdef484 add paynym addresses dialog 2022-03-04 14:44:22 +02:00
Craig Raw 58f20dab60 update optimization buttons after max button pressed 2022-03-04 11:53:12 +02:00
Craig Raw 1c7abc1b24 fix regression with expanded transaction diagram dark theme background 2022-03-04 11:17:58 +02:00
Craig Raw 02e0fd1357 roll back to preferring ipv4 over ipv6 addresses 2022-03-04 10:38:40 +02:00
Craig Raw 7b3ff2a6d3 fix various refresh history issues when updating node sets 2022-03-04 10:36:47 +02:00
130 changed files with 2191 additions and 366 deletions
+6 -6
View File
@@ -9,18 +9,18 @@ jobs:
matrix:
os: [windows-latest, ubuntu-latest]
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
with:
submodules: true
- name: Set up JDK 16.0.1
uses: actions/setup-java@v2
- name: Set up JDK 17.0.2
uses: actions/setup-java@v3
with:
distribution: 'adopt'
java-version: '16.0.1'
distribution: 'temurin'
java-version: '17.0.2'
- name: Show Build Versions
run: ./gradlew -v
- name: Cache Gradle packages
uses: actions/cache@v2
uses: actions/cache@v3
with:
path: ~/.gradle/caches
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }}
+2
View File
@@ -28,6 +28,8 @@ The Sparrow binaries can be built from source using
`./gradlew jpackage`
Note that to build the Windows installer, you will need to install [WiX](https://github.com/wixtoolset/wix3/releases).
When updating to the latest HEAD
`git pull --recurse-submodules`
+25 -20
View File
@@ -1,16 +1,22 @@
plugins {
id 'application'
id 'org.openjfx.javafxplugin' version '0.0.10'
id 'org.openjfx.javafxplugin' version '0.0.13'
id 'extra-java-module-info'
id 'org.beryx.jlink' version '2.24.0'
}
def sparrowVersion = '1.6.0'
def sparrowVersion = '1.6.4'
def os = org.gradle.internal.os.OperatingSystem.current()
def osName = os.getFamilyName()
if(os.macOsX) {
osName = "osx"
}
def targetName = ""
def osArch = "x64"
if(System.getProperty("os.arch") == "aarch64") {
osArch = "aarch64"
targetName = "-" + osArch
}
group "com.sparrowwallet"
version "${sparrowVersion}"
@@ -29,7 +35,7 @@ tasks.withType(AbstractArchiveTask) {
}
javafx {
version = "17"
version = "18"
modules = [ 'javafx.controls', 'javafx.fxml', 'javafx.swing', 'javafx.graphics' ]
}
@@ -67,13 +73,13 @@ dependencies {
}
implementation('com.sparrowwallet:hummingbird:1.6.4')
implementation('co.nstant.in:cbor:0.9')
implementation('com.nativelibs4java:bridj:0.7-20140918-3') {
implementation("com.nativelibs4java:bridj${targetName}:0.7-20140918-3") {
exclude group: 'com.google.android.tools', module: 'dx'
}
implementation('com.github.sarxos:webcam-capture:0.3.13-SNAPSHOT') {
implementation("com.github.sarxos:webcam-capture${targetName}:0.3.13-SNAPSHOT") {
exclude group: 'com.nativelibs4java', module: 'bridj'
}
implementation("com.sparrowwallet:netlayer-jpms-${osName}:0.6.8") {
implementation("com.sparrowwallet:netlayer-jpms-${osName}${targetName}:0.6.8") {
exclude group: 'org.jetbrains.kotlin'
}
implementation('org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.5.20')
@@ -92,11 +98,12 @@ dependencies {
implementation('org.slf4j:jul-to-slf4j:1.7.30') {
exclude group: 'org.slf4j'
}
implementation('com.sparrowwallet.nightjar:nightjar:0.2.32')
implementation('com.sparrowwallet.nightjar:nightjar:0.2.33')
implementation('io.reactivex.rxjava2:rxjava:2.2.15')
implementation('io.reactivex.rxjava2:rxjavafx:2.2.2')
implementation('org.apache.commons:commons-lang3:3.7')
implementation('net.sourceforge.streamsupport:streamsupport:1.7.0')
implementation('com.github.librepdf:openpdf:1.3.27')
testImplementation('junit:junit:4.12')
}
@@ -117,17 +124,17 @@ compileJava {
processResources {
doLast {
delete fileTree("$buildDir/resources/main/native").matching {
exclude "${osName}/**"
exclude "${osName}/${osArch}/**"
}
}
}
test {
jvmArgs '--add-opens=java.base/java.io=ALL-UNNAMED'
jvmArgs '--add-opens=java.base/java.io=com.google.gson'
}
run {
applicationDefaultJvmArgs = ["-XX:+HeapDumpOnOutOfMemoryError", "-Djava.net.preferIPv6Addresses=system",
applicationDefaultJvmArgs = ["-XX:+HeapDumpOnOutOfMemoryError",
"--add-opens=javafx.graphics/com.sun.javafx.css=org.controlsfx.controls",
"--add-opens=javafx.graphics/javafx.scene=org.controlsfx.controls",
"--add-opens=javafx.controls/com.sun.javafx.scene.control.behavior=org.controlsfx.controls",
@@ -173,8 +180,7 @@ jlink {
options = ['--strip-debug', '--compress', '2', '--no-header-files', '--no-man-pages', '--ignore-signing-information', '--exclude-files', '**.png', '--exclude-resources', 'glob:/com.sparrowwallet.merged.module/META-INF/*']
launcher {
name = 'sparrow'
jvmArgs = ["-Djava.net.preferIPv6Addresses=system",
"--add-opens=javafx.graphics/com.sun.javafx.css=org.controlsfx.controls",
jvmArgs = ["--add-opens=javafx.graphics/com.sun.javafx.css=org.controlsfx.controls",
"--add-opens=javafx.graphics/javafx.scene=org.controlsfx.controls",
"--add-opens=javafx.controls/com.sun.javafx.scene.control.behavior=org.controlsfx.controls",
"--add-opens=javafx.controls/com.sun.javafx.scene.control.inputmap=org.controlsfx.controls",
@@ -329,12 +335,12 @@ extraJavaModuleInfo {
requires('org.slf4j')
requires('com.fasterxml.jackson.databind')
}
module('bridj-0.7-20140918-3.jar', 'com.nativelibs4java.bridj', '0.7-20140918-3') {
module("bridj${targetName}-0.7-20140918-3.jar", 'com.nativelibs4java.bridj', '0.7-20140918-3') {
exports('org.bridj')
exports('org.bridj.cpp')
requires('java.logging')
}
module('webcam-capture-0.3.13-SNAPSHOT.jar', 'com.github.sarxos.webcam.capture', '0.3.13-SNAPSHOT') {
module("webcam-capture${targetName}-0.3.13-SNAPSHOT.jar", 'com.github.sarxos.webcam.capture', '0.3.13-SNAPSHOT') {
exports('com.github.sarxos.webcam')
exports('com.github.sarxos.webcam.ds.buildin')
exports('com.github.sarxos.webcam.ds.buildin.natives')
@@ -453,6 +459,8 @@ extraJavaModuleInfo {
module('kotlin-stdlib-jdk7-1.5.20.jar', 'org.jetbrains.kotlin.kotlin.stdlib.jdk7', '1.5.20')
module('kotlin-stdlib-1.5.20.jar', 'kotlin.stdlib', '1.5.20') {
exports('kotlin')
exports('kotlin.jvm')
exports('kotlin.collections')
}
module('hummingbird-1.6.3.jar', 'com.sparrowwallet.hummingbird', '1.6.3') {
exports('com.sparrowwallet.hummingbird')
@@ -462,7 +470,7 @@ extraJavaModuleInfo {
module('cbor-0.9.jar', 'co.nstant.in.cbor', '0.9') {
exports('co.nstant.in.cbor')
}
module('nightjar-0.2.32.jar', 'com.sparrowwallet.nightjar', '0.2.32') {
module('nightjar-0.2.33.jar', 'com.sparrowwallet.nightjar', '0.2.33') {
requires('com.google.common')
requires('net.sourceforge.streamsupport')
requires('org.slf4j')
@@ -550,7 +558,7 @@ extraJavaModuleInfo {
module('jcip-annotations-1.0.jar', 'net.jcip.annotations', '1.0') {
exports('net.jcip.annotations')
}
module("netlayer-jpms-${osName}-0.6.8.jar", 'netlayer.jpms', '0.6.8') {
module("netlayer-jpms-${osName}${targetName}-0.6.8.jar", 'netlayer.jpms', '0.6.8') {
exports('org.berndpruenster.netlayer.tor')
requires('com.github.ravn.jsocks')
requires('com.github.JesusMcCloud.jtorctl')
@@ -578,15 +586,12 @@ extraJavaModuleInfo {
module('logback-core-1.2.8.jar', 'logback.core', '1.2.8') {
requires('java.xml')
}
module('kotlin-stdlib-common-1.5.20.jar', 'org.jetbrains.kotlin.kotlin.stdlib.common', '1.5.20') {
exports('kotlin.jvm')
exports('kotlin.collections')
}
module('jcommander-1.81.jar', 'com.beust.jcommander', '1.81') {
exports('com.beust.jcommander')
}
module('junit-4.12.jar', 'junit', '4.12') {
exports('org.junit')
requires('org.hamcrest.core')
}
module('hamcrest-core-1.3.jar', 'org.hamcrest.core', '1.3')
}
@@ -67,6 +67,8 @@ abstract public class ExtraModuleInfoTransform implements TransformAction<ExtraM
outputs.file(originalJar);
} else if (automaticModules.containsKey(originalJarName)) {
addAutomaticModuleName(originalJar, getModuleJar(outputs, originalJar), automaticModules.get(originalJarName));
} else if(originalJarName.startsWith("kotlin-stdlib-common")) {
//ignore
} else {
throw new RuntimeException("Not a module and no mapping defined: " + originalJarName);
}
+1 -1
Submodule drongo updated: 0734757a17...d24243ce20
Binary file not shown.
+1 -1
View File
@@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored
+1 -1
View File
@@ -72,7 +72,7 @@ case "`uname`" in
Darwin* )
darwin=true
;;
MINGW* )
MSYS* | MINGW* )
msys=true
;;
NONSTOP* )
+1 -1
View File
@@ -21,7 +21,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.6.0</string>
<string>1.6.4</string>
<key>CFBundleSignature</key>
<string>????</string>
<!-- See https://developer.apple.com/app-store/categories/ for list of AppStore categories -->
@@ -1,5 +1,6 @@
package com.sparrowwallet.sparrow;
import com.beust.jcommander.JCommander;
import com.google.common.base.Charsets;
import com.google.common.eventbus.Subscribe;
import com.google.common.io.ByteSource;
@@ -90,6 +91,7 @@ public class AppController implements Initializable {
public static final String LOADING_TRANSACTIONS_MESSAGE = "Loading wallet, select Transactions tab to view...";
public static final String CONNECTION_FAILED_PREFIX = "Connection failed: ";
public static final String TRYING_ANOTHER_SERVER_MESSAGE = "trying another server...";
public static final String JPACKAGE_APP_PATH = "jpackage.app-path";
@FXML
private MenuItem saveTransaction;
@@ -109,6 +111,12 @@ public class AppController implements Initializable {
@FXML
private MenuItem exportWallet;
@FXML
private MenuItem deleteWallet;
@FXML
private MenuItem closeTab;
@FXML
private Menu fileMenu;
@@ -156,6 +164,9 @@ public class AppController implements Initializable {
@FXML
private MenuItem lockWallet;
@FXML
private MenuItem lockAllWallets;
@FXML
private MenuItem searchWallet;
@@ -178,6 +189,9 @@ public class AppController implements Initializable {
private CheckMenuItem preventSleep;
private static final BooleanProperty preventSleepProperty = new SimpleBooleanProperty();
@FXML
private MenuItem restart;
@FXML
private StackPane rootStack;
@@ -196,6 +210,8 @@ public class AppController implements Initializable {
private Tab previouslySelectedTab;
private boolean subTabsVisible;
private final Set<Wallet> loadingWallets = new LinkedHashSet<>();
private final Set<Wallet> emptyLoadingWallets = new LinkedHashSet<>();
@@ -284,11 +300,13 @@ public class AppController implements Initializable {
EventManager.get().post(new TransactionTabsClosedEvent(closedTransactionTabs));
}
closeTab.setDisable(tabs.getTabs().isEmpty());
if(tabs.getTabs().isEmpty()) {
Stage tabStage = (Stage)tabs.getScene().getWindow();
tabStage.setTitle("Sparrow");
saveTransaction.setVisible(true);
saveTransaction.setDisable(true);
exportWallet.setDisable(true);
}
}
});
@@ -329,6 +347,8 @@ public class AppController implements Initializable {
showLoadingLog.selectedProperty().bindBidirectional(showLoadingLogProperty);
preventSleepProperty.set(Config.get().isPreventSleep());
preventSleep.selectedProperty().bindBidirectional(preventSleepProperty);
restart.setText("Restart in " + (Network.get() == Network.MAINNET ? Network.TESTNET.toDisplayString() : Network.MAINNET.toDisplayString()));
restart.setVisible(System.getProperty(JPACKAGE_APP_PATH) != null);
saveTransaction.setDisable(true);
showTransaction.visibleProperty().bind(Bindings.and(saveTransaction.visibleProperty(), saveTransaction.disableProperty().not()));
@@ -337,6 +357,8 @@ public class AppController implements Initializable {
savePSBTBinary.disableProperty().bind(saveTransaction.visibleProperty());
showPSBT.visibleProperty().bind(saveTransaction.visibleProperty().not());
exportWallet.setDisable(true);
deleteWallet.disableProperty().bind(exportWallet.disableProperty());
closeTab.setDisable(true);
lockWallet.setDisable(true);
searchWallet.disableProperty().bind(exportWallet.disableProperty());
refreshWallet.disableProperty().bind(Bindings.or(exportWallet.disableProperty(), Bindings.or(serverToggle.disableProperty(), AppServices.onlineProperty().not())));
@@ -345,7 +367,6 @@ public class AppController implements Initializable {
showPayNym.setDisable(true);
findMixingPartner.setDisable(true);
AppServices.onlineProperty().addListener((observable, oldValue, newValue) -> {
showPayNym.setDisable(exportWallet.isDisable() || getSelectedWalletForm() == null || !getSelectedWalletForm().getWallet().hasPaymentCode() || !newValue);
findMixingPartner.setDisable(exportWallet.isDisable() || getSelectedWalletForm() == null || !SorobanServices.canWalletMix(getSelectedWalletForm().getWallet()) || !newValue);
});
@@ -781,6 +802,10 @@ public class AppController implements Initializable {
}
}
public void deleteWallet(ActionEvent event) {
deleteWallet(getSelectedWalletForm());
}
public void closeTab(ActionEvent event) {
tabs.getTabs().remove(tabs.getSelectionModel().getSelectedItem());
}
@@ -835,6 +860,31 @@ public class AppController implements Initializable {
AppServices.get().setPreventSleep(item.isSelected());
}
public void restart(ActionEvent event) {
if(System.getProperty(JPACKAGE_APP_PATH) == null) {
throw new IllegalStateException("Property " + JPACKAGE_APP_PATH + " is not present");
}
Args args = new Args();
ProcessHandle.current().info().arguments().ifPresent(argv -> {
JCommander jCommander = JCommander.newBuilder().addObject(args).acceptUnknownOptions(true).build();
jCommander.parse(argv);
});
args.network = (Network.get() == Network.MAINNET ? Network.TESTNET : Network.MAINNET);
try {
List<String> cmd = new ArrayList<>();
cmd.add(System.getProperty(JPACKAGE_APP_PATH));
cmd.addAll(args.toParams());
final ProcessBuilder builder = new ProcessBuilder(cmd);
builder.start();
quit(event);
} catch(Exception e) {
log.error("Error restarting application", e);
}
}
public void openFile(File file) {
if(isWalletFile(file)) {
openWalletFile(file, true);
@@ -892,6 +942,7 @@ public class AppController implements Initializable {
Storage storage = new Storage(file);
if(!storage.isEncrypted()) {
Storage.LoadWalletService loadWalletService = new Storage.LoadWalletService(storage);
loadWalletService.setExecutor(Storage.LoadWalletService.getSingleThreadedExecutor());
loadWalletService.setOnSucceeded(workerStateEvent -> {
WalletAndKey walletAndKey = loadWalletService.getValue();
openWallet(storage, walletAndKey, this, forceSameWindow);
@@ -1380,6 +1431,17 @@ public class AppController implements Initializable {
}
}
private boolean allWalletsLocked(Wallet lockingWallet) {
for(Tab tab : tabs.getTabs()) {
TabData tabData = (TabData)tab.getUserData();
if(tabData instanceof WalletTabData walletTabData && walletTabData.getWallet() != lockingWallet && !walletTabData.getWalletForm().isLocked()) {
return false;
}
}
return true;
}
public void searchWallet(ActionEvent event) {
Tab selectedTab = tabs.getSelectionModel().getSelectedItem();
if(selectedTab != null) {
@@ -1459,8 +1521,8 @@ public class AppController implements Initializable {
});
TabPane subTabs = new TabPane();
subTabs.setSide(Side.RIGHT);
setSubTabsVisible(subTabs, false);
subTabs.setSide(Side.LEFT);
setSubTabsVisible(subTabs, areSubTabsVisible());
subTabs.rotateGraphicProperty().set(true);
tab.setContent(subTabs);
@@ -1469,7 +1531,7 @@ public class AppController implements Initializable {
tab.setUserData(tabData);
tab.setContextMenu(getTabContextMenu(tab));
walletForm.lockedProperty().addListener((observable, oldValue, newValue) -> {
setSubTabsVisible(subTabs, !newValue && subTabs.getTabs().size() > 1);
setSubTabsVisible(subTabs, !newValue && areSubTabsVisible());
});
subTabs.getSelectionModel().selectedItemProperty().addListener((observable, old_val, selectedTab) -> {
@@ -1528,6 +1590,30 @@ public class AppController implements Initializable {
}
}
private void setSubTabsVisible(boolean visible) {
for(Tab tab : tabs.getTabs()) {
TabData tabData = (TabData) tab.getUserData();
if(tabData instanceof WalletTabData) {
setSubTabsVisible((TabPane)tab.getContent(), visible);
}
}
}
private boolean areSubTabsVisible() {
if(subTabsVisible) {
return true;
}
for(Wallet wallet : AppServices.get().getOpenWallets().keySet()) {
if(wallet.getChildWallets().stream().anyMatch(childWallet -> !childWallet.isNested())) {
subTabsVisible = true;
return true;
}
}
return false;
}
public WalletForm addWalletSubTab(TabPane subTabs, Storage storage, Wallet wallet) {
try {
Tab subTab = new Tab();
@@ -1646,6 +1732,10 @@ public class AppController implements Initializable {
}
}
private void addTransactionTab(Transaction transaction, TransactionView initialView, Integer initialIndex) {
addTransactionTab(null, null, transaction, null, null, initialView, initialIndex);
}
private void addTransactionTab(BlockTransaction blockTransaction, TransactionView initialView, Integer initialIndex) {
addTransactionTab(null, null, blockTransaction.getTransaction(), null, blockTransaction, initialView, initialIndex);
}
@@ -1779,10 +1869,65 @@ public class AppController implements Initializable {
tabs.getTabs().removeAll(tabs.getTabs());
});
contextMenu.getItems().addAll(close, closeOthers, closeAll);
MenuItem delete = new MenuItem("Delete...");
delete.setOnAction(event -> {
deleteWallet(getSelectedWalletForm());
});
contextMenu.getItems().addAll(close, closeOthers, closeAll, delete);
return contextMenu;
}
private void deleteWallet(WalletForm selectedWalletForm) {
Optional<ButtonType> optButtonType = AppServices.showWarningDialog("Delete Wallet?", "The wallet file and any backups will be deleted. Are you sure?", ButtonType.NO, ButtonType.YES);
if(optButtonType.isPresent() && optButtonType.get() == ButtonType.YES) {
Storage storage = selectedWalletForm.getStorage();
if(selectedWalletForm.getMasterWallet().isEncrypted()) {
WalletPasswordDialog dlg = new WalletPasswordDialog(selectedWalletForm.getWallet().getMasterName(), WalletPasswordDialog.PasswordRequirement.LOAD);
Optional<SecureString> password = dlg.showAndWait();
if(password.isPresent()) {
Storage.KeyDerivationService keyDerivationService = new Storage.KeyDerivationService(storage, password.get(), true);
keyDerivationService.setOnSucceeded(workerStateEvent -> {
EventManager.get().post(new StorageEvent(selectedWalletForm.getWalletId(), TimedEvent.Action.END, "Done"));
ECKey encryptionFullKey = keyDerivationService.getValue();
try {
tabs.getTabs().remove(tabs.getSelectionModel().getSelectedItem());
deleteStorage(storage);
} finally {
encryptionFullKey.clear();
password.get().clear();
}
});
keyDerivationService.setOnFailed(workerStateEvent -> {
EventManager.get().post(new StorageEvent(selectedWalletForm.getWalletId(), TimedEvent.Action.END, "Failed"));
if(keyDerivationService.getException() instanceof InvalidPasswordException) {
Optional<ButtonType> optResponse = showErrorDialog("Invalid Password", "The wallet password was invalid. Try again?", ButtonType.CANCEL, ButtonType.OK);
if(optResponse.isPresent() && optResponse.get().equals(ButtonType.OK)) {
Platform.runLater(() -> deleteWallet(getSelectedWalletForm()));
}
} else {
log.error("Error deriving wallet key", keyDerivationService.getException());
}
});
EventManager.get().post(new StorageEvent(selectedWalletForm.getWalletId(), TimedEvent.Action.START, "Decrypting wallet..."));
keyDerivationService.start();
}
} else {
tabs.getTabs().remove(tabs.getSelectionModel().getSelectedItem());
deleteStorage(storage);
}
}
}
private void deleteStorage(Storage storage) {
if(storage.isClosed()) {
Platform.runLater(storage::delete);
} else {
Platform.runLater(() -> deleteStorage(storage));
}
}
private ContextMenu getSubTabContextMenu(Wallet wallet, TabPane subTabs, Tab subTab) {
ContextMenu contextMenu = new ContextMenu();
MenuItem rename = new MenuItem("Rename Account");
@@ -1807,7 +1952,7 @@ public class AppController implements Initializable {
if(optButtonType.isPresent() && optButtonType.get() == ButtonType.OK) {
subTabs.getTabs().remove(subTab);
if(subTabs.getTabs().size() == 1) {
setSubTabsVisible(subTabs, false);
setSubTabsVisible(subTabs, areSubTabsVisible());
}
EventManager.get().post(new WalletDeletedEvent(wallet));
}
@@ -1846,7 +1991,7 @@ public class AppController implements Initializable {
private void serverToggleStartAnimation() {
Node thumbArea = serverToggle.lookup(".thumb-area");
if(thumbArea != null) {
Timeline timeline = AnimationUtil.getPulse(thumbArea, Duration.millis(600), 1.0, 0.4, 8);
Timeline timeline = AnimationUtil.getPulse(thumbArea, Duration.millis(600), 1.0, 0.25, 8);
timeline.play();
serverToggle.setUserData(new AnimationUtil.AnimatedNode(thumbArea, timeline));
}
@@ -1987,9 +2132,10 @@ public class AppController implements Initializable {
saveTransaction.setDisable(true);
lockWallet.setDisable(walletTabData.getWalletForm().lockedProperty().get());
exportWallet.setDisable(walletTabData.getWallet() == null || !walletTabData.getWallet().isValid() || walletTabData.getWalletForm().isLocked());
refreshWallet.setText(walletTabData.getWallet() == null || walletTabData.getWalletForm().getMasterWallet().getChildWallets().stream().allMatch(Wallet::isNested) ? "Refresh Wallet" : "Refresh Wallet Account");
showLoadingLog.setDisable(false);
showTxHex.setDisable(true);
showPayNym.setDisable(exportWallet.isDisable() || !walletTabData.getWallet().hasPaymentCode() || !AppServices.onlineProperty().get());
showPayNym.setDisable(exportWallet.isDisable() || !walletTabData.getWallet().hasPaymentCode());
findMixingPartner.setDisable(exportWallet.isDisable() || !SorobanServices.canWalletMix(walletTabData.getWallet()) || !AppServices.onlineProperty().get());
}
}
@@ -2015,10 +2161,22 @@ public class AppController implements Initializable {
if(selectedWalletForm != null) {
if(selectedWalletForm.getWalletId().equals(event.getWalletId())) {
exportWallet.setDisable(!event.getWallet().isValid() || selectedWalletForm.isLocked());
showPayNym.setDisable(exportWallet.isDisable() || !event.getWallet().hasPaymentCode() || !AppServices.onlineProperty().get());
showPayNym.setDisable(exportWallet.isDisable() || !event.getWallet().hasPaymentCode());
findMixingPartner.setDisable(exportWallet.isDisable() || !SorobanServices.canWalletMix(event.getWallet()) || !AppServices.onlineProperty().get());
}
}
for(Tab walletTab : tabs.getTabs()) {
TabData tabData = (TabData) walletTab.getUserData();
if(tabData instanceof WalletTabData walletTabData) {
if(walletTabData.getWalletForm().getWalletId().equals(event.getWalletId()) && event.getWallet().isMasterWallet()) {
TabPane subTabs = (TabPane)walletTab.getContent();
Tab masterTab = subTabs.getTabs().stream().filter(tab -> ((WalletTabData)tab.getUserData()).getWallet().isMasterWallet()).findFirst().orElse(subTabs.getTabs().get(0));
Label masterLabel = (Label)masterTab.getGraphic();
masterLabel.setText(event.getWallet().getLabel() != null ? event.getWallet().getLabel() : event.getWallet().getAutomaticName());
}
}
}
}
@Subscribe
@@ -2253,6 +2411,14 @@ public class AppController implements Initializable {
serverToggleStopAnimation();
}
@Subscribe
public void walletOpened(WalletOpenedEvent walletOpenedEvent) {
if(!subTabsVisible && walletOpenedEvent.getWallet().getChildWallets().stream().anyMatch(childWallet -> !childWallet.isNested())) {
subTabsVisible = true;
setSubTabsVisible(true);
}
}
@Subscribe
public void walletTabsClosed(WalletTabsClosedEvent event) {
event.getClosedWalletTabData().stream().map(WalletTabData::getWallet).forEach(loadingWallets::remove);
@@ -2426,7 +2592,11 @@ public class AppController implements Initializable {
@Subscribe
public void viewTransaction(ViewTransactionEvent event) {
if(tabs.getScene().getWindow().equals(event.getWindow())) {
addTransactionTab(event.getBlockTransaction(), event.getInitialView(), event.getInitialIndex());
if(event.getBlockTransaction() != null) {
addTransactionTab(event.getBlockTransaction(), event.getInitialView(), event.getInitialIndex());
} else {
addTransactionTab(event.getTransaction(), event.getInitialView(), event.getInitialIndex());
}
}
}
@@ -2510,6 +2680,7 @@ public class AppController implements Initializable {
if(selectedWalletForm != null && selectedWalletForm.getMasterWallet().equals(event.getWallet())) {
lockWallet.setDisable(true);
exportWallet.setDisable(true);
lockAllWallets.setDisable(allWalletsLocked(event.getWallet()));
}
}
@@ -2519,6 +2690,7 @@ public class AppController implements Initializable {
if(selectedWalletForm != null && selectedWalletForm.getMasterWallet().equals(event.getWallet())) {
lockWallet.setDisable(false);
exportWallet.setDisable(!event.getWallet().isValid());
lockAllWallets.setDisable(false);
}
}
}
@@ -70,7 +70,7 @@ public class AppServices {
private static final int SERVER_PING_PERIOD_SECS = 60;
private static final int PUBLIC_SERVER_RETRY_PERIOD_SECS = 3;
private static final int ENUMERATE_HW_PERIOD_SECS = 30;
public static final int ENUMERATE_HW_PERIOD_SECS = 30;
private static final int RATES_PERIOD_SECS = 5 * 60;
private static final int VERSION_CHECK_PERIOD_HOURS = 24;
private static final ExchangeSource DEFAULT_EXCHANGE_SOURCE = ExchangeSource.COINGECKO;
@@ -292,9 +292,12 @@ public class AppServices {
"\n\nThis may indicate a man-in-the-middle attack!" +
"\n\nDo you still want to proceed?", ButtonType.NO, ButtonType.YES);
if(optButton.isPresent() && optButton.get() == ButtonType.YES) {
crtFile.delete();
Platform.runLater(() -> restartService(connectionService));
return;
if(crtFile.delete()) {
Platform.runLater(() -> restartService(connectionService));
return;
} else {
AppServices.showErrorDialog("Could not delete certificate", "The certificate file at " + crtFile.getAbsolutePath() + " could not be deleted.\n\nPlease delete this file manually.");
}
}
}
}
@@ -355,7 +358,7 @@ public class AppServices {
private Hwi.ScheduledEnumerateService createDeviceEnumerateService() {
Hwi.ScheduledEnumerateService enumerateService = new Hwi.ScheduledEnumerateService(null);
enumerateService.setPeriod(Duration.seconds(ENUMERATE_HW_PERIOD_SECS));
enumerateService.setPeriod(Duration.seconds(Config.get().getEnumerateHwPeriod()));
enumerateService.setOnSucceeded(workerStateEvent -> {
List<Device> devices = enumerateService.getValue();
@@ -687,6 +690,10 @@ public class AppServices {
payjoinURIs.put(bitcoinURI.getAddress(), bitcoinURI);
}
public static void clearPayjoinURI(Address address) {
payjoinURIs.remove(address);
}
public static void clearTransactionHistoryCache(Wallet wallet) {
ElectrumServer.clearRetrievedScriptHashes(wallet);
@@ -710,11 +717,18 @@ public class AppServices {
}
public static Optional<ButtonType> showAlertDialog(String title, String content, Alert.AlertType alertType, ButtonType... buttons) {
return showAlertDialog(title, content, alertType, null, buttons);
}
public static Optional<ButtonType> showAlertDialog(String title, String content, Alert.AlertType alertType, Node graphic, ButtonType... buttons) {
Alert alert = new Alert(alertType, content, buttons);
setStageIcon(alert.getDialogPane().getScene().getWindow());
alert.getDialogPane().getScene().getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
alert.setTitle(title);
alert.setHeaderText(title);
if(graphic != null) {
alert.setGraphic(graphic);
}
Pattern linkPattern = Pattern.compile("\\[(http.+)]");
Matcher matcher = linkPattern.matcher(content);
@@ -913,7 +927,7 @@ public class AppServices {
System.setProperty(Network.BLOCK_HEIGHT_PROPERTY, Integer.toString(currentBlockHeight));
targetBlockFeeRates = event.getTargetBlockFeeRates();
addMempoolRateSizes(event.getMempoolRateSizes());
minimumRelayFeeRate = event.getMinimumRelayFeeRate();
minimumRelayFeeRate = Math.max(event.getMinimumRelayFeeRate(), Transaction.DEFAULT_MIN_RELAY_FEE);
latestBlockHeader = event.getBlockHeader();
Config.get().addRecentServer();
}
@@ -4,6 +4,9 @@ import com.beust.jcommander.Parameter;
import com.sparrowwallet.drongo.Network;
import org.slf4j.event.Level;
import java.util.ArrayList;
import java.util.List;
public class Args {
@Parameter(names = { "--dir", "-d" }, description = "Path to Sparrow home folder")
public String dir;
@@ -16,4 +19,23 @@ public class Args {
@Parameter(names = { "--help", "-h" }, description = "Show usage", help = true)
public boolean help;
public List<String> toParams() {
List<String> params = new ArrayList<>();
if(dir != null) {
params.add("-d");
params.add(dir);
}
if(network != null) {
params.add("-n");
params.add(network.toString());
}
if(level != null) {
params.add("-l");
params.add(level.toString());
}
return params;
}
}
@@ -30,7 +30,7 @@ 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.6.0";
public static final String APP_VERSION = "1.6.4";
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";
@@ -1,17 +1,23 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.wallet.Status;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.WalletUtxoStatusChangedEvent;
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;
import javafx.scene.control.Hyperlink;
import javafx.scene.control.Tooltip;
import javafx.scene.control.TreeTableCell;
import javafx.util.Duration;
import org.controlsfx.glyphfont.Glyph;
import java.util.Collections;
public class AddressCell extends TreeTableCell<Entry, UtxoEntry.AddressStatus> {
public AddressCell() {
super();
@@ -37,10 +43,12 @@ public class AddressCell extends TreeTableCell<Entry, UtxoEntry.AddressStatus> {
setContextMenu(new EntryCell.AddressContextMenu(address, utxoEntry.getOutputDescriptor(), new NodeEntry(utxoEntry.getWallet(), utxoEntry.getNode())));
Tooltip tooltip = new Tooltip();
tooltip.setShowDelay(Duration.millis(250));
tooltip.setText(getTooltipText(utxoEntry, addressStatus.isDuplicate()));
tooltip.setText(getTooltipText(utxoEntry, addressStatus.isDuplicate(), addressStatus.isDustAttack()));
setTooltip(tooltip);
if(addressStatus.isDuplicate()) {
if(addressStatus.isDustAttack()) {
setGraphic(getDustAttackHyperlink(utxoEntry));
} else if(addressStatus.isDuplicate()) {
setGraphic(getDuplicateGlyph());
} else {
setGraphic(null);
@@ -49,9 +57,9 @@ public class AddressCell extends TreeTableCell<Entry, UtxoEntry.AddressStatus> {
}
}
private String getTooltipText(UtxoEntry utxoEntry, boolean duplicate) {
private String getTooltipText(UtxoEntry utxoEntry, boolean duplicate, boolean dustAttack) {
return (utxoEntry.getNode().getWallet().isNested() ? utxoEntry.getNode().getWallet().getDisplayName() + " " : "" ) +
utxoEntry.getNode().toString() + (duplicate ? " (Duplicate address)" : "");
utxoEntry.getNode().toString() + (duplicate ? " (Duplicate address)" : (dustAttack ? " (Possible dust attack)" : ""));
}
public static Glyph getDuplicateGlyph() {
@@ -60,4 +68,22 @@ public class AddressCell extends TreeTableCell<Entry, UtxoEntry.AddressStatus> {
duplicateGlyph.setFontSize(12);
return duplicateGlyph;
}
public static Hyperlink getDustAttackHyperlink(UtxoEntry utxoEntry) {
Glyph dustAttackGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.EXCLAMATION_TRIANGLE);
dustAttackGlyph.getStyleClass().add("dust-attack-warning");
dustAttackGlyph.setFontSize(12);
Hyperlink hyperlink = new Hyperlink(utxoEntry.getHashIndex().getStatus() == Status.FROZEN ? "" : "Freeze?", dustAttackGlyph);
hyperlink.getStyleClass().add("freeze-dust-utxo");
hyperlink.setOnAction(event -> {
if(utxoEntry.getHashIndex().getStatus() != Status.FROZEN) {
hyperlink.setText("");
utxoEntry.getHashIndex().setStatus(Status.FROZEN);
EventManager.get().post(new WalletUtxoStatusChangedEvent(utxoEntry.getWallet(), Collections.singletonList(utxoEntry.getHashIndex())));
}
});
return hyperlink;
}
}
@@ -4,8 +4,8 @@ import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.WalletAddressesChangedEvent;
import com.sparrowwallet.sparrow.event.WalletDataChangedEvent;
import com.sparrowwallet.sparrow.event.WalletHistoryClearedEvent;
import com.sparrowwallet.sparrow.event.WalletHistoryStatusEvent;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.Storage;
@@ -91,7 +91,7 @@ public class CoinTreeTable extends TreeTableView<Entry> {
EventManager.get().post(new WalletDataChangedEvent(wallet));
//Trigger full wallet rescan
wallet.clearHistory();
EventManager.get().post(new WalletHistoryClearedEvent(wallet, pastWallet, storage.getWalletId(wallet)));
EventManager.get().post(new WalletAddressesChangedEvent(wallet, pastWallet, storage.getWalletId(wallet)));
}
});
if(wallet.getBirthDate() == null) {
@@ -0,0 +1,73 @@
package com.sparrowwallet.sparrow.control;
import com.lowagie.text.*;
import com.lowagie.text.Font;
import com.lowagie.text.Image;
import com.lowagie.text.pdf.PdfWriter;
import com.sparrowwallet.hummingbird.UR;
import com.sparrowwallet.hummingbird.UREncoder;
import com.sparrowwallet.sparrow.AppServices;
import javafx.embed.swing.SwingFXUtils;
import javafx.event.ActionEvent;
import javafx.scene.control.*;
import javafx.scene.control.Button;
import javafx.stage.FileChooser;
import javafx.stage.Stage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.awt.*;
import java.io.File;
import java.io.FileOutputStream;
public class DescriptorQRDisplayDialog extends QRDisplayDialog {
private static final Logger log = LoggerFactory.getLogger(DescriptorQRDisplayDialog.class);
public DescriptorQRDisplayDialog(String walletName, String outputDescriptor, UR ur) {
super(ur);
DialogPane dialogPane = getDialogPane();
final ButtonType pdfButtonType = new javafx.scene.control.ButtonType("Save PDF...", ButtonBar.ButtonData.HELP_2);
dialogPane.getButtonTypes().add(pdfButtonType);
Button pdfButton = (Button)dialogPane.lookupButton(pdfButtonType);
pdfButton.addEventFilter(ActionEvent.ACTION, event -> {
savePdf(walletName, outputDescriptor, ur);
event.consume();
});
}
private void savePdf(String walletName, String outputDescriptor, UR ur) {
Stage window = new Stage();
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle("Save PDF");
fileChooser.setInitialFileName(walletName + ".pdf");
AppServices.moveToActiveWindowScreen(window, 800, 450);
File file = fileChooser.showSaveDialog(window);
if(file != null) {
try(Document document = new Document()) {
document.setMargins(36, 36, 48, 36);
PdfWriter.getInstance(document, new FileOutputStream(file));
document.open();
Font titleFont = FontFactory.getFont(FontFactory.HELVETICA_BOLD, 16, Color.BLACK);
Chunk title = new Chunk("Output descriptor for " + walletName, titleFont);
document.add(title);
UREncoder urEncoder = new UREncoder(ur, 2000, 10, 0);
String fragment = urEncoder.nextPart();
if(urEncoder.isSinglePart()) {
Image image = Image.getInstance(SwingFXUtils.fromFXImage(getQrCode(fragment), null), Color.WHITE);
document.add(image);
}
Font descriptorFont = FontFactory.getFont(FontFactory.COURIER, 14, Color.BLACK);
Paragraph descriptor = new Paragraph(outputDescriptor, descriptorFont);
document.add(descriptor);
} catch(Exception e) {
log.error("Error creating descriptor PDF", e);
AppServices.showErrorDialog("Error creating PDF", e.getMessage());
}
}
}
}
@@ -3,10 +3,7 @@ package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.Utils;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.protocol.TransactionInput;
import com.sparrowwallet.drongo.protocol.TransactionOutput;
import com.sparrowwallet.drongo.protocol.*;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
@@ -123,15 +120,18 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
HBox actionBox = new HBox();
actionBox.getStyleClass().add("cell-actions");
Button receiveButton = new Button("");
receiveButton.setGraphic(getReceiveGlyph());
receiveButton.setOnAction(event -> {
EventManager.get().post(new ReceiveActionEvent(nodeEntry));
Platform.runLater(() -> EventManager.get().post(new ReceiveToEvent(nodeEntry)));
});
actionBox.getChildren().add(receiveButton);
if(canSignMessage(nodeEntry.getNode().getWallet())) {
if(!nodeEntry.getNode().getWallet().isBip47()) {
Button receiveButton = new Button("");
receiveButton.setGraphic(getReceiveGlyph());
receiveButton.setOnAction(event -> {
EventManager.get().post(new ReceiveActionEvent(nodeEntry));
Platform.runLater(() -> EventManager.get().post(new ReceiveToEvent(nodeEntry)));
});
actionBox.getChildren().add(receiveButton);
}
if(canSignMessage(nodeEntry.getNode())) {
Button signMessageButton = new Button("");
signMessageButton.setGraphic(getSignMessageGlyph());
signMessageButton.setOnAction(event -> {
@@ -151,7 +151,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
} else if(entry instanceof HashIndexEntry) {
HashIndexEntry hashIndexEntry = (HashIndexEntry)entry;
setText(hashIndexEntry.getDescription());
setContextMenu(new HashIndexEntryContextMenu(getTreeTableView(), hashIndexEntry));
setContextMenu(getTreeTableView().getStyleClass().contains("bip47") ? null : new HashIndexEntryContextMenu(getTreeTableView(), hashIndexEntry));
Tooltip tooltip = new Tooltip();
tooltip.setShowDelay(Duration.millis(250));
tooltip.setText(hashIndexEntry.getHashIndex().toString());
@@ -175,7 +175,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
actionBox.getChildren().add(spendUtxoButton);
}
setGraphic(actionBox);
setGraphic(getTreeTableView().getStyleClass().contains("bip47") ? null : actionBox);
}
}
}
@@ -206,14 +206,16 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
.map(e -> e.getBlockTransaction().getTransaction().getOutputs().get((int)e.getHashIndex().getIndex()))
.collect(Collectors.toList());
long changeTotal = ourOutputs.stream().mapToLong(TransactionOutput::getValue).sum();
long changeTotal = ourOutputs.stream().mapToLong(TransactionOutput::getValue).sum() - consolidationOutputs.stream().mapToLong(TransactionOutput::getValue).sum();
Transaction tx = blockTransaction.getTransaction();
double vSize = tx.getVirtualSize();
int inputSize = tx.getInputs().get(0).getLength() + (tx.getInputs().get(0).hasWitness() ? tx.getInputs().get(0).getWitness().getLength() / Transaction.WITNESS_SCALE_FACTOR : 0);
List<BlockTransactionHashIndex> walletUtxos = new ArrayList<>(transactionEntry.getWallet().getWalletUtxos().keySet());
//Remove any UTXOs created by the transaction that is to be replaced
walletUtxos.removeIf(utxo -> ourOutputs.stream().anyMatch(output -> output.getHash().equals(utxo.getHash()) && output.getIndex() == utxo.getIndex()));
Collections.shuffle(walletUtxos);
while((double)changeTotal / vSize < getMaxFeeRate() && !walletUtxos.isEmpty()) {
//If there is insufficent change output, include another random UTXO so the fee can be increased
//If there is insufficient change output, include another random UTXO so the fee can be increased
BlockTransactionHashIndex utxo = walletUtxos.remove(0);
utxos.add(utxo);
changeTotal += utxo.getValue();
@@ -223,6 +225,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
List<TransactionOutput> externalOutputs = new ArrayList<>(blockTransaction.getTransaction().getOutputs());
externalOutputs.removeAll(ourOutputs);
externalOutputs.addAll(consolidationOutputs);
final long rbfChange = changeTotal;
List<Payment> payments = externalOutputs.stream().map(txOutput -> {
try {
String label = transactionEntry.getLabel() == null ? "" : transactionEntry.getLabel();
@@ -239,20 +242,37 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
label += (label.isEmpty() ? "" : " ") + "(Replaced By Fee)";
}
return new Payment(txOutput.getScript().getToAddresses()[0], label, txOutput.getValue(), false);
if(txOutput.getScript().getToAddress() != null) {
//Disable change creation by enabling max payment when there is only one output and no additional UTXOs included
return new Payment(txOutput.getScript().getToAddress(), label, txOutput.getValue(), blockTransaction.getTransaction().getOutputs().size() == 1 && rbfChange == 0);
}
return null;
} catch(Exception e) {
log.error("Error creating RBF payment", e);
return null;
}
}).filter(Objects::nonNull).collect(Collectors.toList());
List<byte[]> opReturns = externalOutputs.stream().map(txOutput -> {
List<ScriptChunk> scriptChunks = txOutput.getScript().getChunks();
if(scriptChunks.size() != 2 || scriptChunks.get(0).getOpcode() != ScriptOpCodes.OP_RETURN) {
return null;
}
if(scriptChunks.get(1).getData() != null) {
return scriptChunks.get(1).getData();
}
return null;
}).filter(Objects::nonNull).collect(Collectors.toList());
if(payments.isEmpty()) {
AppServices.showErrorDialog("Replace By Fee Error", "Error creating RBF transaction, check log for details");
return;
}
EventManager.get().post(new SendActionEvent(transactionEntry.getWallet(), utxos));
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(transactionEntry.getWallet(), utxos, payments, blockTransaction.getFee(), true)));
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(transactionEntry.getWallet(), utxos, payments, opReturns.isEmpty() ? null : opReturns, blockTransaction.getFee(), true)));
}
private static Double getMaxFeeRate() {
@@ -284,12 +304,14 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
Payment payment = new Payment(freshNode.getAddress(), label, utxo.getValue(), true);
EventManager.get().post(new SendActionEvent(transactionEntry.getWallet(), List.of(utxo)));
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(transactionEntry.getWallet(), List.of(utxo), List.of(payment), blockTransaction.getFee(), false)));
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(transactionEntry.getWallet(), List.of(utxo), List.of(payment), null, blockTransaction.getFee(), false)));
}
private static boolean canSignMessage(Wallet wallet) {
private static boolean canSignMessage(WalletNode walletNode) {
Wallet wallet = walletNode.getWallet();
return wallet.getKeystores().size() == 1 && wallet.getScriptType() != ScriptType.P2TR &&
(wallet.getKeystores().get(0).hasPrivateKey() || wallet.getKeystores().get(0).getSource() == KeystoreSource.HW_USB);
(wallet.getKeystores().get(0).hasPrivateKey() || wallet.getKeystores().get(0).getSource() == KeystoreSource.HW_USB) &&
(!wallet.isBip47() || walletNode.getKeyPurpose() == KeyPurpose.RECEIVE);
}
private static boolean containsWalletOutputs(TransactionEntry transactionEntry) {
@@ -502,16 +524,18 @@ public class EntryCell extends TreeTableCell<Entry, Entry> {
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 || !nodeEntry.getWallet().isBip47()) {
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 && canSignMessage(nodeEntry.getNode().getWallet())) {
if(nodeEntry != null && canSignMessage(nodeEntry.getNode())) {
MenuItem signVerifyMessage = new MenuItem("Sign/Verify Message");
signVerifyMessage.setGraphic(getSignMessageGlyph());
signVerifyMessage.setOnAction(AE -> {
@@ -16,7 +16,7 @@ public class FileKeystoreImportPane extends FileImportPane {
private final KeyDerivation requiredDerivation;
public FileKeystoreImportPane(Wallet wallet, KeystoreFileImport importer, KeyDerivation requiredDerivation) {
super(importer, importer.getName(), "Keystore import", importer.getKeystoreImportDescription(), "image/" + importer.getWalletModel().getType() + ".png", importer.isKeystoreImportScannable(), importer.isFileFormatAvailable());
super(importer, importer.getName(), "Keystore import", importer.getKeystoreImportDescription(getAccount(wallet, requiredDerivation)), "image/" + importer.getWalletModel().getType() + ".png", importer.isKeystoreImportScannable(), importer.isFileFormatAvailable());
this.wallet = wallet;
this.importer = importer;
this.requiredDerivation = requiredDerivation;
@@ -34,4 +34,17 @@ public class FileKeystoreImportPane extends FileImportPane {
EventManager.get().post(new KeystoreImportEvent(keystore));
}
}
private static int getAccount(Wallet wallet, KeyDerivation requiredDerivation) {
if(wallet == null || requiredDerivation == null) {
return 0;
}
int account = wallet.getScriptType().getAccount(requiredDerivation.getDerivationPath());
if(account < 0) {
account = 0;
}
return account;
}
}
@@ -21,6 +21,7 @@ public class HelpLabel extends Label {
tooltip.textProperty().bind(helpTextProperty());
tooltip.graphicProperty().bind(helpGraphicProperty());
tooltip.setShowDuration(Duration.seconds(15));
tooltip.setShowDelay(Duration.millis(500));
getStyleClass().add("help-label");
Platform.runLater(() -> setTooltip(tooltip));
@@ -0,0 +1,83 @@
package com.sparrowwallet.sparrow.control;
import javafx.beans.NamedArg;
import javafx.scene.control.Spinner;
import javafx.scene.control.SpinnerValueFactory;
import javafx.util.converter.IntegerStringConverter;
public class IntegerSpinner extends Spinner<Integer> {
public IntegerSpinner() {
super();
setupEditor();
}
public IntegerSpinner(@NamedArg("min") int min,
@NamedArg("max") int max,
@NamedArg("initialValue") int initialValue) {
super(min, max, initialValue);
setupEditor();
}
public IntegerSpinner(@NamedArg("min") int min,
@NamedArg("max") int max,
@NamedArg("initialValue") int initialValue,
@NamedArg("amountToStepBy") int amountToStepBy) {
super(min, max, initialValue, amountToStepBy);
setupEditor();
}
private void setupEditor() {
getEditor().focusedProperty().addListener((observable, oldValue, newValue) -> {
if(newValue != null && !newValue) {
commitValue();
}
});
getEditor().textProperty().addListener((observable, oldValue, newValue) -> {
if(!newValue.matches("\\d*")) {
getEditor().setText(newValue.replaceAll("[^\\d]", ""));
}
});
}
public static class ValueFactory extends SpinnerValueFactory.IntegerSpinnerValueFactory {
public ValueFactory(@NamedArg("min") int min,
@NamedArg("max") int max) {
super(min, max);
setupConverter(min);
}
public ValueFactory(@NamedArg("min") int min,
@NamedArg("max") int max,
@NamedArg("initialValue") int initialValue) {
super(min, max, initialValue);
setupConverter(initialValue);
}
public ValueFactory(@NamedArg("min") int min,
@NamedArg("max") int max,
@NamedArg("initialValue") int initialValue,
@NamedArg("amountToStepBy") int amountToStepBy) {
super(min, max, initialValue, amountToStepBy);
setupConverter(initialValue);
}
private void setupConverter(Integer defaultValue) {
setConverter(new IntegerStringConverter() {
@Override
public Integer fromString(String value) {
if(value == null) {
return null;
}
value = value.trim();
if(value.length() < 1) {
return defaultValue;
}
return Integer.valueOf(value);
}
});
}
}
}
@@ -24,18 +24,6 @@ public class MnemonicKeystoreDisplayPane extends MnemonicKeystorePane {
showWordList(keystore.getSeed());
}
private void showWordList(DeterministicSeed seed) {
List<String> words = seed.getMnemonicCode();
setContent(getMnemonicWordsEntry(words.size()));
setExpanded(true);
for(int i = 0; i < wordsPane.getChildren().size(); i++) {
WordEntry wordEntry = (WordEntry)wordsPane.getChildren().get(i);
wordEntry.getEditor().setText(words.get(i));
wordEntry.getEditor().setEditable(false);
}
}
protected Node getMnemonicWordsEntry(int numWords) {
VBox vBox = new VBox();
vBox.setSpacing(10);
@@ -1,6 +1,8 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.wallet.Bip39MnemonicCode;
import com.sparrowwallet.drongo.wallet.DeterministicSeed;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import javafx.application.Platform;
import javafx.beans.property.IntegerProperty;
@@ -26,13 +28,16 @@ import org.controlsfx.validation.ValidationResult;
import org.controlsfx.validation.ValidationSupport;
import org.controlsfx.validation.Validator;
import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.*;
import static com.sparrowwallet.sparrow.AppServices.showErrorDialog;
public class MnemonicKeystorePane extends TitledDescriptionPane {
private static final Logger log = LoggerFactory.getLogger(MnemonicKeystorePane.class);
protected SplitMenuButton enterMnemonicButton;
protected TilePane wordsPane;
protected Label validLabel;
@@ -73,9 +78,44 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
});
enterMnemonicButton.getItems().add(item);
}
enterMnemonicButton.getItems().add(new SeparatorMenuItem());
MenuItem scanItem = new MenuItem("Scan QR...");
scanItem.setOnAction(event -> {
scanQR();
});
enterMnemonicButton.getItems().add(scanItem);
enterMnemonicButton.managedProperty().bind(enterMnemonicButton.visibleProperty());
}
protected void scanQR() {
QRScanDialog qrScanDialog = new QRScanDialog();
Optional<QRScanDialog.Result> optionalResult = qrScanDialog.showAndWait();
if(optionalResult.isPresent()) {
QRScanDialog.Result result = optionalResult.get();
if(result.seed != null) {
showWordList(result.seed);
Platform.runLater(() -> validLabel.requestFocus());
} else if(result.exception != null) {
log.error("Error scanning QR", result.exception);
showErrorDialog("Error scanning QR", result.exception.getMessage());
} else {
AppServices.showErrorDialog("Invalid QR Code", "Cannot parse QR code into a seed.");
}
}
}
protected void showWordList(DeterministicSeed seed) {
List<String> words = seed.getMnemonicCode();
setContent(getMnemonicWordsEntry(words.size()));
setExpanded(true);
for(int i = 0; i < wordsPane.getChildren().size(); i++) {
WordEntry wordEntry = (WordEntry)wordsPane.getChildren().get(i);
wordEntry.getEditor().setText(words.get(i));
wordEntry.getEditor().setEditable(false);
}
}
protected void enterMnemonic(int numWords) {
setDescription("Generate new or enter existing");
showHideLink.setVisible(false);
@@ -3,6 +3,7 @@ package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.bip47.PaymentCode;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.paynym.PayNymService;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.concurrent.Service;
@@ -39,7 +40,7 @@ public class PayNymAvatar extends StackPane {
String cacheId = getCacheId(paymentCode, getPrefWidth());
if(paymentCodeCache.containsKey(cacheId)) {
setImage(paymentCodeCache.get(cacheId));
} else {
} else if(AppServices.isConnected()) {
PayNymAvatarService payNymAvatarService = new PayNymAvatarService(paymentCode, getPrefWidth());
payNymAvatarService.setOnRunning(runningEvent -> {
getChildren().clear();
@@ -48,7 +49,7 @@ public class PayNymAvatar extends StackPane {
setImage(payNymAvatarService.getValue());
});
payNymAvatarService.setOnFailed(failedEvent -> {
log.error("Error", failedEvent.getSource().getException());
log.debug("Error loading PayNym avatar", failedEvent.getSource().getException());
});
payNymAvatarService.start();
}
@@ -117,8 +118,8 @@ public class PayNymAvatar extends StackPane {
}
synchronized(lock) {
String url = "https://paynym.is/" + paymentCodeStr + "/avatar";
Proxy proxy = AppServices.getProxy();
String url = PayNymService.getHostUrl(proxy != null) + "/" + paymentCodeStr + "/avatar";
try(InputStream is = (proxy == null ? new URL(url).openStream() : new URL(url).openConnection(proxy).getInputStream())) {
Image image = new Image(is, 150, 150, true, false);
@@ -1,5 +1,8 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.event.WalletLabelChangedEvent;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.paynym.PayNym;
import com.sparrowwallet.sparrow.paynym.PayNymController;
@@ -10,16 +13,20 @@ import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import org.controlsfx.glyphfont.Glyph;
import java.util.Optional;
public class PayNymCell extends ListCell<PayNym> {
private final PayNymController payNymController;
private final boolean contact;
public PayNymCell(PayNymController payNymController) {
public PayNymCell(PayNymController payNymController, boolean contact) {
super();
setAlignment(Pos.CENTER_LEFT);
setContentDisplay(ContentDisplay.LEFT);
getStyleClass().add("paynym-cell");
setPrefHeight(50);
this.payNymController = payNymController;
this.contact = contact;
}
@Override
@@ -47,7 +54,7 @@ public class PayNymCell extends ListCell<PayNym> {
labelBox.getChildren().add(label);
pane.setLeft(labelBox);
if(getListView().getUserData() == Boolean.TRUE) {
if(getListView().getUserData() == Boolean.TRUE || (!contact && payNymController != null && payNymController.isFollowing(payNym) == Boolean.FALSE)) {
HBox hBox = new HBox();
hBox.setAlignment(Pos.CENTER);
Button button = new Button("Add Contact");
@@ -57,7 +64,7 @@ public class PayNymCell extends ListCell<PayNym> {
button.setDisable(true);
payNymController.followPayNym(payNym.paymentCode());
});
} else if(payNymController != null) {
} else if(contact && payNymController != null) {
HBox hBox = new HBox();
hBox.setAlignment(Pos.CENTER);
pane.setRight(hBox);
@@ -83,6 +90,12 @@ public class PayNymCell extends ListCell<PayNym> {
setText(null);
setGraphic(pane);
if(contact && payNymController != null && payNymController.isLinked(payNym)) {
setContextMenu(new PayNymCellContextMenu(payNym));
} else {
setContextMenu(null);
}
}
}
@@ -91,4 +104,30 @@ public class PayNymCell extends ListCell<PayNym> {
failGlyph.setFontSize(12);
return failGlyph;
}
private class PayNymCellContextMenu extends ContextMenu {
public PayNymCellContextMenu(PayNym payNym) {
MenuItem rename = new MenuItem("Rename Contact...");
rename.setOnAction(event -> {
WalletLabelDialog walletLabelDialog = new WalletLabelDialog(payNym.nymName(), "Contact");
Optional<String> optLabel = walletLabelDialog.showAndWait();
if(optLabel.isPresent()) {
int index = getListView().getItems().indexOf(payNym);
for(Wallet childWallet : payNymController.getMasterWallet().getChildWallets()) {
if(childWallet.isBip47()
&& childWallet.getKeystores().get(0).getExternalPaymentCode().equals(payNym.paymentCode())
&& (childWallet.getLabel() == null || childWallet.getLabel().startsWith(payNym.nymName()))) {
childWallet.setLabel(optLabel.get() + " " + childWallet.getScriptType().getName());
EventManager.get().post(new WalletLabelChangedEvent(childWallet));
}
}
payNymController.updateFollowing();
getListView().getSelectionModel().select(index);
}
});
getItems().add(rename);
}
}
}
@@ -25,6 +25,7 @@ public class ProgressTimer extends ProgressIndicator {
}
public void start(EventHandler<ActionEvent> onFinished) {
getStyleClass().remove("warn");
timeline = new Timeline(
new KeyFrame(Duration.ZERO, new KeyValue(progressProperty(), 0)),
new KeyFrame(Duration.seconds(getSeconds() * 0.8), e -> getStyleClass().add("warn")),
@@ -137,7 +137,7 @@ public class QRDisplayDialog extends Dialog<UR> {
}
}
private Image getQrCode(String fragment) {
protected Image getQrCode(String fragment) {
try {
QRCodeWriter qrCodeWriter = new QRCodeWriter();
BitMatrix qrMatrix = qrCodeWriter.encode(fragment, BarcodeFormat.QR_CODE, QR_WIDTH, QR_HEIGHT);
@@ -10,15 +10,14 @@ import com.sparrowwallet.drongo.address.P2PKHAddress;
import com.sparrowwallet.drongo.address.P2SHAddress;
import com.sparrowwallet.drongo.address.P2WPKHAddress;
import com.sparrowwallet.drongo.crypto.*;
import com.sparrowwallet.drongo.policy.Policy;
import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.protocol.Base43;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.psbt.PSBTParseException;
import com.sparrowwallet.drongo.uri.BitcoinURI;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.DeterministicSeed;
import com.sparrowwallet.drongo.wallet.SeedQR;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.hummingbird.LegacyURDecoder;
import com.sparrowwallet.hummingbird.registry.*;
@@ -257,6 +256,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
BitcoinURI bitcoinURI;
Address address;
ExtendedKey extendedKey;
DeterministicSeed seed;
try {
extendedKey = ExtendedKey.fromDescriptor(qrtext);
result = new Result(extendedKey);
@@ -334,6 +334,22 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
//Ignore, not parseable as base43 decoded bytes
}
try {
seed = SeedQR.getSeed(qrtext);
result = new Result(seed);
return;
} catch(Exception e) {
//Ignore, not parseable as a SeedQR
}
try {
seed = SeedQR.getSeed(qrResult.getRawBytes());
result = new Result(seed);
return;
} catch(Exception e) {
//Ignore, not parseable as a CompactSeedQR
}
result = new Result(qrtext);
}
}
@@ -401,6 +417,14 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
CryptoAccount cryptoAccount = (CryptoAccount)ur.decodeFromRegistry();
List<Wallet> wallets = getWallets(cryptoAccount);
return new Result(wallets);
} else if(urRegistryType.equals(RegistryType.CRYPTO_SEED)) {
CryptoSeed cryptoSeed = (CryptoSeed)ur.decodeFromRegistry();
DeterministicSeed seed = getSeed(cryptoSeed);
return new Result(seed);
} else if(urRegistryType.equals(RegistryType.CRYPTO_BIP39)) {
CryptoBip39 cryptoBip39 = (CryptoBip39)ur.decodeFromRegistry();
DeterministicSeed seed = getSeed(cryptoBip39);
return new Result(seed);
} else {
log.error("Unsupported UR type " + urRegistryType);
return new Result(new URException("UR type " + urRegistryType + " is not supported"));
@@ -523,6 +547,14 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
return null;
}
private DeterministicSeed getSeed(CryptoSeed cryptoSeed) {
return new DeterministicSeed(cryptoSeed.getSeed(), null, cryptoSeed.getBirthdate() == null ? System.currentTimeMillis() : cryptoSeed.getBirthdate().getTime());
}
private DeterministicSeed getSeed(CryptoBip39 cryptoBip39) {
return new DeterministicSeed(cryptoBip39.getWords(), null, System.currentTimeMillis(), DeterministicSeed.Type.BIP39);
}
}
private class QRScanListener implements WebcamListener {
@@ -626,6 +658,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
public final ExtendedKey extendedKey;
public final OutputDescriptor outputDescriptor;
public final List<Wallet> wallets;
public final DeterministicSeed seed;
public final String payload;
public final Throwable exception;
@@ -636,6 +669,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.extendedKey = null;
this.outputDescriptor = null;
this.wallets = null;
this.seed = null;
this.payload = null;
this.exception = null;
}
@@ -647,6 +681,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.extendedKey = null;
this.outputDescriptor = null;
this.wallets = null;
this.seed = null;
this.payload = null;
this.exception = null;
}
@@ -658,6 +693,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.extendedKey = null;
this.outputDescriptor = null;
this.wallets = null;
this.seed = null;
this.payload = null;
this.exception = null;
}
@@ -669,6 +705,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.extendedKey = null;
this.outputDescriptor = null;
this.wallets = null;
this.seed = null;
this.payload = null;
this.exception = null;
}
@@ -680,6 +717,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.extendedKey = extendedKey;
this.outputDescriptor = null;
this.wallets = null;
this.seed = null;
this.payload = null;
this.exception = null;
}
@@ -691,6 +729,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.extendedKey = null;
this.outputDescriptor = outputDescriptor;
this.wallets = null;
this.seed = null;
this.payload = null;
this.exception = null;
}
@@ -702,6 +741,19 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.extendedKey = null;
this.outputDescriptor = null;
this.wallets = wallets;
this.seed = null;
this.payload = null;
this.exception = null;
}
public Result(DeterministicSeed seed) {
this.transaction = null;
this.psbt = null;
this.uri = null;
this.extendedKey = null;
this.outputDescriptor = null;
this.wallets = null;
this.seed = seed;
this.payload = null;
this.exception = null;
}
@@ -713,6 +765,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.extendedKey = null;
this.outputDescriptor = null;
this.wallets = null;
this.seed = null;
this.payload = payload;
this.exception = null;
}
@@ -724,6 +777,7 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
this.extendedKey = null;
this.outputDescriptor = null;
this.wallets = null;
this.seed = null;
this.payload = null;
this.exception = exception;
}
@@ -14,6 +14,7 @@ import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.input.Clipboard;
import javafx.scene.layout.StackPane;
import javafx.stage.FileChooser;
import javafx.util.StringConverter;
@@ -47,7 +48,19 @@ public class SendToManyDialog extends Dialog<List<Payment>> {
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 = new SpreadsheetView(grid) {
@Override
public void pasteClipboard() {
final Clipboard clipboard = Clipboard.getSystemClipboard();
if(clipboard.hasString()) {
final TablePosition<?,?> tp = getSelectionModel().getFocusedCell();
SpreadsheetCell cell = getGrid().getRows().get(tp.getRow()).get(tp.getColumn());
getGrid().setCellValue(cell.getRow(), cell.getColumn(), cell.getCellType().convertValue(clipboard.getString()));
} else {
super.pasteClipboard();
}
}
};
spreadsheetView.getColumns().get(0).setPrefWidth(400);
spreadsheetView.getColumns().get(1).setPrefWidth(150);
spreadsheetView.getColumns().get(2).setPrefWidth(247);
@@ -1,6 +1,7 @@
package com.sparrowwallet.sparrow.control;
import com.sparrowwallet.sparrow.AppServices;
import javafx.beans.property.*;
import javafx.concurrent.Worker;
import javafx.scene.control.DialogPane;
import javafx.scene.image.Image;
@@ -22,4 +23,117 @@ public class ServiceProgressDialog extends ProgressDialog {
Image image = new Image(imagePath);
dialogPane.setGraphic(new ImageView(image));
}
public static class ProxyWorker implements Worker<Boolean> {
private final ObjectProperty<State> state = new SimpleObjectProperty<>(this, "state", State.READY);
private final StringProperty message = new SimpleStringProperty(this, "message", "");
private final DoubleProperty progress = new SimpleDoubleProperty(this, "progress", -1);
public void start() {
state.set(State.SCHEDULED);
}
public void end() {
state.set(State.SUCCEEDED);
}
@Override
public State getState() {
return state.get();
}
@Override
public ReadOnlyObjectProperty<State> stateProperty() {
return state;
}
@Override
public Boolean getValue() {
return Boolean.TRUE;
}
@Override
public ReadOnlyObjectProperty<Boolean> valueProperty() {
return null;
}
@Override
public Throwable getException() {
return null;
}
@Override
public ReadOnlyObjectProperty<Throwable> exceptionProperty() {
return null;
}
@Override
public double getWorkDone() {
return 0;
}
@Override
public ReadOnlyDoubleProperty workDoneProperty() {
return null;
}
@Override
public double getTotalWork() {
return 0;
}
@Override
public ReadOnlyDoubleProperty totalWorkProperty() {
return null;
}
@Override
public double getProgress() {
return progress.get();
}
@Override
public ReadOnlyDoubleProperty progressProperty() {
return progress;
}
@Override
public boolean isRunning() {
return false;
}
@Override
public ReadOnlyBooleanProperty runningProperty() {
return null;
}
@Override
public String getMessage() {
return message.get();
}
public void setMessage(String strMessage) {
message.set(strMessage);
}
@Override
public ReadOnlyStringProperty messageProperty() {
return message;
}
@Override
public String getTitle() {
return null;
}
@Override
public ReadOnlyStringProperty titleProperty() {
return null;
}
@Override
public boolean cancel() {
return false;
}
}
}
@@ -82,13 +82,14 @@ public class TransactionDiagram extends GridPane {
scenePane.setBorder(new Border(new BorderStroke(Color.DARKGRAY, BorderStrokeStyle.SOLID, CornerRadii.EMPTY, BorderWidths.DEFAULT)));
}
VBox vBox = new VBox(20);
vBox.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
scenePane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
if(Config.get().getTheme() == Theme.DARK) {
vBox.getStylesheets().add(AppServices.class.getResource("darktheme.css").toExternalForm());
scenePane.getStylesheets().add(AppServices.class.getResource("darktheme.css").toExternalForm());
}
vBox.getStylesheets().add(AppServices.class.getResource("wallet/wallet.css").toExternalForm());
vBox.getStylesheets().add(AppServices.class.getResource("wallet/send.css").toExternalForm());
scenePane.getStylesheets().add(AppServices.class.getResource("wallet/wallet.css").toExternalForm());
scenePane.getStylesheets().add(AppServices.class.getResource("wallet/send.css").toExternalForm());
VBox vBox = new VBox(20);
vBox.setPadding(new Insets(20, 40, 20, 50));
expandedDiagram = new TransactionDiagram();
@@ -165,8 +165,9 @@ public class TransactionHexArea extends CodeArea {
for(int j = 0; j < witness.getPushes().size(); j++) {
byte[] push = witness.getPushes().get(j);
VarInt witnessLen = new VarInt(push.length);
boolean isSignature = isSignature(push);
cursor = addSegment(segments, cursor, witnessLen.getSizeInBytes() * 2, i, j, "witness-" + getIndexedStyleClass(i, selectedInputIndex, "length"));
cursor = addSegment(segments, cursor, (int) witnessLen.value * 2, i, j, "witness-" + getIndexedStyleClass(i, selectedInputIndex, "data"));
cursor = addSegment(segments, cursor, (int) witnessLen.value * 2, i, j, "witness-" + getIndexedStyleClass(i, selectedInputIndex, "data" + (isSignature ? "-signature" : "")));
}
}
}
@@ -204,6 +205,19 @@ public class TransactionHexArea extends CodeArea {
return "other";
}
private boolean isSignature(byte[] data) {
if(data.length >= 64) {
try {
TransactionSignature.decodeFromBitcoin(data, false);
return true;
} catch(Exception e) {
//ignore, not a signature
}
}
return false;
}
private String describeTransactionPart(Collection<String> styles) {
String style = "";
Integer index = null;
@@ -238,7 +252,7 @@ public class TransactionHexArea extends CodeArea {
case "output-pubkeyscript" -> "Output #" + index + " scriptPubKey";
case "witness-count" -> "Input #" + index + " witness count";
case "witness-length" -> "Input #" + index + " witness #" + witnessIndex + " length";
case "witness-data" -> "Input #" + index + " witness #" + witnessIndex + " data";
case "witness-data", "witness-data-signature" -> "Input #" + index + " witness #" + witnessIndex + " data";
case "locktime" -> "Locktime";
default -> "";
};
@@ -57,6 +57,7 @@ public class UtxosChart extends BarChart<String, Number> {
for(int i = 0; i < utxoDataList.size(); i++) {
XYChart.Data<String, Number> newData = utxoDataList.get(i);
newData.setXValue((i+1) + ". " + newData.getXValue());
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() instanceof Entry && !newData.getExtraValue().equals(existingData.getExtraValue()))) {
@@ -70,7 +71,7 @@ public class UtxosChart extends BarChart<String, Number> {
}
if(utxoSeries.getData().size() > utxoDataList.size()) {
utxoSeries.getData().remove(Math.max(0, utxoDataList.size() - 1), utxoSeries.getData().size());
utxoSeries.getData().remove(utxoDataList.size(), utxoSeries.getData().size());
}
if(selectedEntries != null) {
@@ -24,6 +24,7 @@ public class UtxosTreeTable extends CoinTreeTable {
});
dateCol.setCellFactory(p -> new DateCell());
dateCol.setSortable(true);
dateCol.setComparator((o1, o2) -> (int) (o2.getValue() - o1.getValue()));
getColumns().add(dateCol);
TreeTableColumn<Entry, Entry> outputCol = new TreeTableColumn<>("Output");
@@ -35,7 +36,12 @@ public class UtxosTreeTable extends CoinTreeTable {
outputCol.setComparator((o1, o2) -> {
UtxoEntry entry1 = (UtxoEntry)o1;
UtxoEntry entry2 = (UtxoEntry)o2;
return entry1.getDescription().compareTo(entry2.getDescription());
int hashCompare = entry1.getHashIndex().getHash().toString().compareTo(entry2.getHashIndex().getHash().toString());
if(hashCompare != 0) {
return hashCompare;
}
return (int)(entry1.getHashIndex().getIndex() - entry2.getHashIndex().getIndex());
});
getColumns().add(outputCol);
@@ -10,19 +10,26 @@ import javafx.scene.layout.VBox;
import org.controlsfx.control.textfield.CustomTextField;
import org.controlsfx.control.textfield.TextFields;
import org.controlsfx.glyphfont.Glyph;
import org.controlsfx.validation.ValidationResult;
import org.controlsfx.validation.ValidationSupport;
import org.controlsfx.validation.Validator;
import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
public class WalletLabelDialog extends Dialog<String> {
private static final int MAX_LABEL_LENGTH = 25;
private final CustomTextField label;
public WalletLabelDialog(String initialName) {
this(initialName, "Account");
}
public WalletLabelDialog(String initialName, String walletType) {
final DialogPane dialogPane = getDialogPane();
AppServices.setStageIcon(dialogPane.getScene().getWindow());
setTitle("Account Name");
dialogPane.setHeaderText("Enter a name for this account:");
setTitle(walletType + " Name");
dialogPane.setHeaderText("Enter a name for this " + walletType.toLowerCase() + ":");
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
dialogPane.getButtonTypes().addAll(ButtonType.CANCEL);
dialogPane.setPrefWidth(400);
@@ -48,17 +55,18 @@ public class WalletLabelDialog extends Dialog<String> {
Platform.runLater(() -> {
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
validationSupport.registerValidator(label, Validator.combine(
Validator.createEmptyValidator("Account name is required")
Validator.createEmptyValidator(walletType + " name is required"),
(Control c, String newValue) -> ValidationResult.fromErrorIf(c, "Label too long", newValue != null && newValue.length() > MAX_LABEL_LENGTH)
));
});
final ButtonType okButtonType = new javafx.scene.control.ButtonType("Rename Account", ButtonBar.ButtonData.OK_DONE);
final ButtonType okButtonType = new javafx.scene.control.ButtonType("Rename " + walletType, ButtonBar.ButtonData.OK_DONE);
dialogPane.getButtonTypes().addAll(okButtonType);
Button okButton = (Button)dialogPane.lookupButton(okButtonType);
BooleanBinding isInvalid = Bindings.createBooleanBinding(() -> label.getText().length() == 0, label.textProperty());
BooleanBinding isInvalid = Bindings.createBooleanBinding(() -> label.getText().length() == 0 || label.getText().length() > MAX_LABEL_LENGTH, label.textProperty());
okButton.disableProperty().bind(isInvalid);
label.setPromptText("Account Name");
label.setPromptText(walletType + " Name");
Platform.runLater(label::requestFocus);
setResultConverter(dialogButton -> dialogButton == okButtonType ? label.getText() : null);
}
@@ -1,6 +1,7 @@
package com.sparrowwallet.sparrow.event;
import com.samourai.whirlpool.client.whirlpool.beans.Pool;
import com.sparrowwallet.drongo.bip47.PaymentCode;
import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
import com.sparrowwallet.drongo.wallet.Payment;
import com.sparrowwallet.drongo.wallet.Wallet;
@@ -15,19 +16,21 @@ public class SpendUtxoEvent {
private final Long fee;
private final boolean includeSpentMempoolOutputs;
private final Pool pool;
private final PaymentCode paymentCode;
public SpendUtxoEvent(Wallet wallet, List<BlockTransactionHashIndex> utxos) {
this(wallet, utxos, null, null, false);
this(wallet, utxos, null, null, null, false);
}
public SpendUtxoEvent(Wallet wallet, List<BlockTransactionHashIndex> utxos, List<Payment> payments, Long fee, boolean includeSpentMempoolOutputs) {
public SpendUtxoEvent(Wallet wallet, List<BlockTransactionHashIndex> utxos, List<Payment> payments, List<byte[]> opReturns, Long fee, boolean includeSpentMempoolOutputs) {
this.wallet = wallet;
this.utxos = utxos;
this.payments = payments;
this.opReturns = null;
this.opReturns = opReturns;
this.fee = fee;
this.includeSpentMempoolOutputs = includeSpentMempoolOutputs;
this.pool = null;
this.paymentCode = null;
}
public SpendUtxoEvent(Wallet wallet, List<BlockTransactionHashIndex> utxos, List<Payment> payments, List<byte[]> opReturns, Long fee, Pool pool) {
@@ -38,6 +41,18 @@ public class SpendUtxoEvent {
this.fee = fee;
this.includeSpentMempoolOutputs = false;
this.pool = pool;
this.paymentCode = null;
}
public SpendUtxoEvent(Wallet wallet, List<Payment> payments, List<byte[]> opReturns, PaymentCode paymentCode) {
this.wallet = wallet;
this.utxos = null;
this.payments = payments;
this.opReturns = opReturns;
this.fee = null;
this.includeSpentMempoolOutputs = false;
this.pool = null;
this.paymentCode = paymentCode;
}
public Wallet getWallet() {
@@ -67,4 +82,8 @@ public class SpendUtxoEvent {
public Pool getPool() {
return pool;
}
public PaymentCode getPaymentCode() {
return paymentCode;
}
}
@@ -0,0 +1,13 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.protocol.Transaction;
public class TransactionFetchFailedEvent extends TransactionReferencesFailedEvent {
public TransactionFetchFailedEvent(Transaction transaction, Throwable exception) {
super(transaction, exception);
}
public TransactionFetchFailedEvent(Transaction transaction, Throwable exception, int pageStart, int pageEnd) {
super(transaction, exception, pageStart, pageEnd);
}
}
@@ -1,5 +1,6 @@
package com.sparrowwallet.sparrow.event;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.wallet.BlockTransaction;
import com.sparrowwallet.sparrow.transaction.TransactionView;
import com.sparrowwallet.sparrow.wallet.HashIndexEntry;
@@ -7,10 +8,19 @@ import javafx.stage.Window;
public class ViewTransactionEvent {
private final Window window;
private final Transaction transaction;
private final BlockTransaction blockTransaction;
private final TransactionView initialView;
private final Integer initialIndex;
public ViewTransactionEvent(Window window, Transaction transaction) {
this.window = window;
this.transaction = transaction;
this.blockTransaction = null;
this.initialView = TransactionView.HEADERS;
this.initialIndex = null;
}
public ViewTransactionEvent(Window window, BlockTransaction blockTransaction) {
this(window, blockTransaction, TransactionView.HEADERS, null);
}
@@ -21,6 +31,7 @@ public class ViewTransactionEvent {
public ViewTransactionEvent(Window window, BlockTransaction blockTransaction, TransactionView initialView, Integer initialIndex) {
this.window = window;
this.transaction = blockTransaction.getTransaction();
this.blockTransaction = blockTransaction;
this.initialView = initialView;
this.initialIndex = initialIndex;
@@ -30,6 +41,10 @@ public class ViewTransactionEvent {
return window;
}
public Transaction getTransaction() {
return transaction;
}
public BlockTransaction getBlockTransaction() {
return blockTransaction;
}
@@ -53,7 +53,7 @@ public class CaravanMultisig implements WalletImport, WalletExport {
ScriptType scriptType = ScriptType.valueOf(cf.addressType.replace('-', '_'));
for(ExtPublicKey extKey : cf.extendedPublicKeys) {
Keystore keystore = new Keystore(extKey.name);
Keystore keystore = new Keystore(extKey.name.length() > Keystore.MAX_LABEL_LENGTH ? extKey.name.substring(0, Keystore.MAX_LABEL_LENGTH) : extKey.name);
try {
keystore.setKeyDerivation(new KeyDerivation(extKey.xfp, extKey.bip32Path));
} catch(NumberFormatException e) {
@@ -70,6 +70,7 @@ public class CaravanMultisig implements WalletImport, WalletExport {
keystore.setWalletModel(walletModel);
keystore.setSource(KeystoreSource.HW_USB);
}
wallet.makeLabelsUnique(keystore);
wallet.getKeystores().add(keystore);
}
@@ -28,7 +28,7 @@ public class CoboVaultMultisig extends ColdcardMultisig {
}
@Override
public String getKeystoreImportDescription() {
public String getKeystoreImportDescription(int account) {
return "Import file or QR created by using the Multisig Wallet > ... > Show/Export XPUB feature on your Cobo Vault.";
}
@@ -24,7 +24,7 @@ public class CoboVaultSinglesig implements KeystoreFileImport, WalletImport {
}
@Override
public String getKeystoreImportDescription() {
public String getKeystoreImportDescription(int account) {
return "Import file or QR created by using the My Cobo Vault > ... > Export Wallet feature on your Cobo Vault.";
}
@@ -84,8 +84,8 @@ public class ColdcardMultisig implements WalletImport, KeystoreFileImport, Walle
}
@Override
public String getKeystoreImportDescription() {
return "Import file created by using the Settings > Multisig Wallets > Export XPUB > 0 feature on your Coldcard.";
public String getKeystoreImportDescription(int account) {
return "Import file created by using the Settings > Multisig Wallets > Export XPUB > " + account + " feature on your Coldcard.";
}
@Override
@@ -28,8 +28,8 @@ public class ColdcardSinglesig implements KeystoreFileImport, WalletImport {
}
@Override
public String getKeystoreImportDescription() {
return "Import file created by using the Advanced > MicroSD > Export Wallet > Generic JSON > 0 feature on your Coldcard. Note this requires firmware version 3.1.3 or later.";
public String getKeystoreImportDescription(int account) {
return "Import file created by using the Advanced > MicroSD > Export Wallet > Generic JSON > " + account + " feature on your Coldcard. Note this requires firmware version 3.1.3 or later.";
}
@Override
@@ -15,8 +15,10 @@ import java.lang.reflect.Type;
import java.util.*;
import java.util.stream.Collectors;
import static com.sparrowwallet.sparrow.AppServices.ENUMERATE_HW_PERIOD_SECS;
import static com.sparrowwallet.sparrow.net.PagedBatchRequestBuilder.DEFAULT_PAGE_SIZE;
import static com.sparrowwallet.sparrow.net.TcpTransport.DEFAULT_MAX_TIMEOUT;
import static com.sparrowwallet.sparrow.wallet.WalletUtxosEntry.DUST_ATTACK_THRESHOLD_SATS;
public class Config {
private static final Logger log = LoggerFactory.getLogger(Config.class);
@@ -44,7 +46,9 @@ public class Config {
private boolean preventSleep = false;
private List<File> recentWalletFiles;
private Integer keyDerivationPeriod;
private long dustAttackThreshold = DUST_ATTACK_THRESHOLD_SATS;
private File hwi;
private int enumerateHwPeriod = ENUMERATE_HW_PERIOD_SECS;
private Boolean hdCapture;
private String webcamDevice;
private ServerType serverType;
@@ -300,6 +304,10 @@ public class Config {
flush();
}
public long getDustAttackThreshold() {
return dustAttackThreshold;
}
public File getHwi() {
return hwi;
}
@@ -309,6 +317,10 @@ public class Config {
flush();
}
public int getEnumerateHwPeriod() {
return enumerateHwPeriod;
}
public Boolean getHdCapture() {
return hdCapture;
}
@@ -35,7 +35,7 @@ public class Electrum implements KeystoreFileImport, WalletImport, WalletExport
}
@Override
public String getKeystoreImportDescription() {
public String getKeystoreImportDescription(int account) {
return "Import a single keystore from an Electrum wallet (use File > Import > Electrum to import a multisig wallet).";
}
@@ -35,7 +35,7 @@ public class GordianSeedTool implements KeystoreFileImport {
}
@Override
public String getKeystoreImportDescription() {
public String getKeystoreImportDescription(int account) {
return "Select your seed and scan the QR code created by Authenticate > Derive Key > Other Key Derivations > " + Network.get().toDisplayString() + " > Master Key > Account Descriptor. Click the share icon at the bottom.";
}
@@ -33,7 +33,7 @@ public class Hwi {
private static final Logger log = LoggerFactory.getLogger(Hwi.class);
private static final String HWI_HOME_DIR = "hwi";
private static final String HWI_VERSION_PREFIX = "hwi-";
private static final String HWI_VERSION = "2.0.2";
private static final String HWI_VERSION = "2.1.0";
private static final String HWI_VERSION_DIR = HWI_VERSION_PREFIX + HWI_VERSION;
private static boolean isPromptActive = false;
@@ -294,7 +294,14 @@ 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/" + HWI_VERSION_DIR + "-mac-amd64-signed.zip");
String osArch = System.getProperty("os.arch");
InputStream inputStream;
if(osArch.equals("aarch64")) {
inputStream = Hwi.class.getResourceAsStream("/native/osx/aarch64/" + HWI_VERSION_DIR + "-mac-aarch64-signed.zip");
} else {
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");
}
@@ -336,8 +343,9 @@ public class Hwi {
InputStream inputStream;
Path tempExecPath;
if(platform == Platform.WINDOWS) {
Files.createDirectories(getHwiHomeDir().toPath());
inputStream = Hwi.class.getResourceAsStream("/native/windows/x64/hwi.exe");
tempExecPath = Files.createTempFile(HWI_VERSION_DIR, null);
tempExecPath = Files.createTempFile(getHwiHomeDir().toPath(), HWI_VERSION_DIR, null);
} else {
inputStream = Hwi.class.getResourceAsStream("/native/linux/x64/hwi");
tempExecPath = Files.createTempFile(HWI_VERSION_DIR, null, PosixFilePermissions.asFileAttribute(ownerExecutableWritable));
@@ -368,7 +376,7 @@ public class Hwi {
}
private File getHwiHomeDir() {
if(Platform.getCurrent() == Platform.OSX) {
if(Platform.getCurrent() == Platform.OSX || Platform.getCurrent() == Platform.WINDOWS) {
return new File(Storage.getSparrowDir(), HWI_HOME_DIR);
}
@@ -1,5 +1,8 @@
package com.sparrowwallet.sparrow.io;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.*;
import java.net.URI;
import java.net.URISyntaxException;
@@ -9,11 +12,14 @@ import java.nio.charset.StandardCharsets;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.SecureRandom;
import java.util.*;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
public class IOUtils {
private static final Logger log = LoggerFactory.getLogger(IOUtils.class);
public static FileType getFileType(File file) {
try {
String type = Files.probeContentType(file.toPath());
@@ -120,4 +126,28 @@ public class IOUtils {
return true;
}
public static boolean secureDelete(File file) {
if(file.exists()) {
long length = file.length();
SecureRandom random = new SecureRandom();
try(RandomAccessFile raf = new RandomAccessFile(file, "rws")) {
raf.seek(0);
raf.getFilePointer();
byte[] data = new byte[64];
int pos = 0;
while(pos < length) {
random.nextBytes(data);
raf.write(data);
pos += data.length;
}
} catch(IOException e) {
log.warn("Error overwriting file for deletion " + file.getName(), e);
}
return file.delete();
}
return false;
}
}
@@ -305,6 +305,11 @@ public class JsonPersistence implements Persistence {
com.google.common.io.Files.copy(walletFile, outputStream);
}
@Override
public boolean isClosed() {
return true;
}
@Override
public void close() {
//Nothing required
@@ -28,7 +28,7 @@ public class KeystoneMultisig extends ColdcardMultisig {
}
@Override
public String getKeystoreImportDescription() {
public String getKeystoreImportDescription(int account) {
return "Import file or QR created by using the Multisig Wallet > ... > Show/Export XPUB feature on your Keystone.";
}
@@ -27,7 +27,7 @@ public class KeystoneSinglesig implements KeystoreFileImport, WalletImport {
}
@Override
public String getKeystoreImportDescription() {
public String getKeystoreImportDescription(int account) {
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.";
}
@@ -7,6 +7,10 @@ import java.io.InputStream;
public interface KeystoreFileImport extends KeystoreImport, FileImport {
Keystore getKeystore(ScriptType scriptType, InputStream inputStream, String password) throws ImportException;
String getKeystoreImportDescription(int account);
default String getKeystoreImportDescription() {
return getKeystoreImportDescription(0);
}
boolean isKeystoreImportScannable();
default boolean isFileFormatAvailable() {
return true;
@@ -1,8 +1,5 @@
package com.sparrowwallet.sparrow.io;
import com.sparrowwallet.drongo.policy.PolicyType;
import com.sparrowwallet.drongo.wallet.WalletModel;
public interface KeystoreImport extends Import {
String getKeystoreImportDescription();
}
@@ -27,7 +27,7 @@ public class PassportMultisig extends ColdcardMultisig {
}
@Override
public String getKeystoreImportDescription() {
public String getKeystoreImportDescription(int account) {
return "Import file or QR created from New Account > Sparrow > Multisig > QR Code/microSD on your Passport. For existing accounts, use Manage Account > Export by QR/microSD.";
}
@@ -13,7 +13,7 @@ public class PassportSinglesig extends ColdcardSinglesig {
}
@Override
public String getKeystoreImportDescription() {
public String getKeystoreImportDescription(int account) {
return "Import file or QR created from New Account > Sparrow > Standard > QR Code/microSD on your Passport. For existing accounts, use Manage Account > Export by QR/microSD.";
}
@@ -25,5 +25,6 @@ public interface Persistence {
String getWalletId(Storage storage, Wallet wallet);
String getWalletName(File walletFile, Wallet wallet);
void copyWallet(File walletFile, OutputStream outputStream) throws IOException;
boolean isClosed();
void close();
}
@@ -9,7 +9,7 @@ public class SeedSigner extends SpecterDIY {
}
@Override
public String getKeystoreImportDescription() {
public String getKeystoreImportDescription(int account) {
return "Import QR created on your SeedSigner by selecting xPub from Seed in the Seed Tools menu once you have entered your seed.";
}
@@ -45,7 +45,7 @@ public class SpecterDIY implements KeystoreFileImport, WalletExport {
}
@Override
public String getKeystoreImportDescription() {
public String getKeystoreImportDescription(int account) {
return "Import file or QR created by using the Master Public Keys feature on your Specter DIY device. Note the default is P2WPKH for Single Signature, and P2WSH for Multi Signature.";
}
@@ -8,6 +8,7 @@ import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.MainApp;
import javafx.concurrent.Service;
import javafx.concurrent.Task;
import org.apache.commons.lang3.concurrent.BasicThreadFactory;
import org.controlsfx.tools.Platform;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -22,6 +23,8 @@ import java.security.cert.CertificateEncodingException;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.*;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -123,6 +126,10 @@ public class Storage {
return persistence.isPersisted(this, wallet);
}
public boolean isClosed() {
return persistence.isClosed();
}
public void close() {
ClosePersistenceService closePersistenceService = new ClosePersistenceService();
closePersistenceService.start();
@@ -160,6 +167,11 @@ public class Storage {
persistence.copyWallet(walletFile, outputStream);
}
public void delete() {
deleteBackups();
IOUtils.secureDelete(walletFile);
}
public void deleteBackups() {
deleteBackups(null);
}
@@ -178,7 +190,7 @@ public class Storage {
private void deleteBackups(String prefix) {
File[] backups = getBackups(prefix);
for(File backup : backups) {
backup.delete();
IOUtils.secureDelete(backup);
}
}
@@ -509,6 +521,8 @@ public class Storage {
private final Storage storage;
private final SecureString password;
private static Executor singleThreadedExecutor;
public LoadWalletService(Storage storage) {
this.storage = storage;
this.password = null;
@@ -536,6 +550,15 @@ public class Storage {
}
};
}
public static Executor getSingleThreadedExecutor() {
if(singleThreadedExecutor == null) {
BasicThreadFactory factory = new BasicThreadFactory.Builder().namingPattern("LoadWalletService-single").daemon(true).priority(Thread.MIN_PRIORITY).build();
singleThreadedExecutor = Executors.newSingleThreadScheduledExecutor(factory);
}
return singleThreadedExecutor;
}
}
public static class KeyDerivationService extends Service<ECKey> {
@@ -84,7 +84,7 @@ public class DbPersistence implements Persistence {
Jdbi jdbi = getJdbi(storage, getFilePassword(encryptionKey));
masterWallet = jdbi.withHandle(handle -> {
WalletDao walletDao = handle.attach(WalletDao.class);
return walletDao.getMainWallet(MASTER_SCHEMA);
return walletDao.getMainWallet(MASTER_SCHEMA, getWalletName(storage.getWalletFile(), null));
});
Map<WalletAndKey, Storage> childWallets = loadChildWallets(storage, masterWallet, encryptionKey);
@@ -109,7 +109,7 @@ public class DbPersistence implements Persistence {
Jdbi childJdbi = getJdbi(storage, getFilePassword(encryptionKey));
Wallet wallet = childJdbi.withHandle(handle -> {
WalletDao walletDao = handle.attach(WalletDao.class);
Wallet childWallet = walletDao.getMainWallet(schema);
Wallet childWallet = walletDao.getMainWallet(schema, null);
childWallet.setName(schema.substring(WALLET_SCHEMA_PREFIX.length()));
childWallet.setMasterWallet(masterWallet);
return childWallet;
@@ -166,7 +166,7 @@ public class DbPersistence implements Persistence {
private synchronized void createUpdateExecutor(Wallet masterWallet) {
if(updateExecutor == null) {
BasicThreadFactory factory = new BasicThreadFactory.Builder().namingPattern(masterWallet.getFullName() + "-dbupdater").daemon(false).priority(Thread.NORM_PRIORITY).build();
BasicThreadFactory factory = new BasicThreadFactory.Builder().namingPattern(masterWallet.getFullName() + "-dbupdater").daemon(true).priority(Thread.NORM_PRIORITY).build();
updateExecutor = Executors.newSingleThreadExecutor(factory);
}
}
@@ -568,6 +568,11 @@ public class DbPersistence implements Persistence {
com.google.common.io.Files.copy(walletFile, outputStream);
}
@Override
public boolean isClosed() {
return dataSource.isClosed();
}
@Override
public void close() {
EventManager.get().unregister(this);
@@ -55,6 +55,9 @@ public interface WalletDao {
@GetGeneratedKeys("id")
long insert(String name, String label, int network, int policyType, int scriptType, Integer storedBlockHeight, Integer gapLimit, Integer watchLast, Date birthDate, long defaultPolicy);
@SqlUpdate("update wallet set name = :name where id = :id")
void updateName(@Bind("id") long id, @Bind("name") String name);
@SqlUpdate("update wallet set label = :label where id = :id")
void updateLabel(@Bind("id") long id, @Bind("label") String label);
@@ -70,12 +73,17 @@ public interface WalletDao {
@SqlUpdate("set schema ?")
int setSchema(String schema);
default Wallet getMainWallet(String schema) {
default Wallet getMainWallet(String schema, String walletName) {
try {
setSchema(schema);
Wallet mainWallet = loadMainWallet();
if(mainWallet != null) {
loadWallet(mainWallet);
if(walletName != null && !walletName.equals(mainWallet.getName())) {
mainWallet.setName(walletName);
updateName(mainWallet.getId(), walletName);
}
}
return mainWallet;
@@ -41,7 +41,10 @@ public class Bwt {
if(!initialized) {
try {
org.controlsfx.tools.Platform platform = org.controlsfx.tools.Platform.getCurrent();
if(platform == org.controlsfx.tools.Platform.OSX) {
String osArch = System.getProperty("os.arch");
if(platform == org.controlsfx.tools.Platform.OSX && osArch.equals("aarch64")) {
NativeUtils.loadLibraryFromJar("/native/osx/aarch64/libbwt_jni.dylib");
} else if(platform == org.controlsfx.tools.Platform.OSX) {
NativeUtils.loadLibraryFromJar("/native/osx/x64/libbwt_jni.dylib");
} else if(platform == org.controlsfx.tools.Platform.WINDOWS) {
NativeUtils.loadLibraryFromJar("/native/windows/x64/bwt_jni.dll");
@@ -348,7 +348,7 @@ public class ElectrumServer {
private Set<WalletNode> getAddressNodes(Wallet wallet, WalletNode purposeNode) {
Integer watchLast = wallet.getWatchLast();
if(watchLast == null || watchLast < wallet.getGapLimit() || wallet.getStoredBlockHeight() == 0 || wallet.getTransactions().isEmpty()) {
if(watchLast == null || watchLast < wallet.getGapLimit() || wallet.getStoredBlockHeight() == null || wallet.getStoredBlockHeight() == 0 || wallet.getTransactions().isEmpty()) {
return purposeNode.getChildren();
}
@@ -1303,6 +1303,11 @@ public class ElectrumServer {
return false;
}
Set<WalletNode> nodes = (filterToNodes == null ? null : filterToNodes.stream().filter(node -> node.getWallet().equals(wallet)).collect(Collectors.toSet()));
if(filterToNodes != null && nodes.isEmpty()) {
return false;
}
boolean initial = (walletSynchronizeLocks.putIfAbsent(wallet, new Object()) == null);
synchronized(walletSynchronizeLocks.get(wallet)) {
if(initial) {
@@ -1311,7 +1316,6 @@ public class ElectrumServer {
if(isConnected()) {
ElectrumServer electrumServer = new ElectrumServer();
Set<WalletNode> nodes = (filterToNodes == null ? null : filterToNodes.stream().filter(node -> node.getWallet().equals(wallet)).collect(Collectors.toSet()));
Map<String, String> previousScriptHashes = getCalculatedScriptHashes(wallet);
Map<WalletNode, Set<BlockTransactionHash>> nodeTransactionMap = (nodes == null ? electrumServer.getHistory(wallet) : electrumServer.getHistory(wallet, nodes));
@@ -1713,12 +1717,8 @@ public class ElectrumServer {
PayNym payNym = Config.get().isUsePayNym() ? getPayNym(paymentCode) : null;
List<ScriptType> scriptTypes = payNym == null || wallet.getScriptType() != ScriptType.P2PKH ? PayNym.getSegwitScriptTypes() : payNym.getScriptTypes();
for(ScriptType childScriptType : scriptTypes) {
Wallet addedWallet = wallet.addChildWallet(paymentCode, childScriptType, output, blkTx);
if(payNym != null) {
addedWallet.setLabel(payNym.nymName() + " " + childScriptType.getName());
} else {
addedWallet.setLabel(paymentCode.toAbbreviatedString() + " " + childScriptType.getName());
}
String label = (payNym == null ? paymentCode.toAbbreviatedString() : payNym.nymName()) + " " + childScriptType.getName();
Wallet addedWallet = wallet.addChildWallet(paymentCode, childScriptType, output, blkTx, label);
//Check this is a valid payment code, will throw IllegalArgumentException if not
try {
WalletNode receiveNode = new WalletNode(addedWallet, KeyPurpose.RECEIVE, 0);
@@ -3,9 +3,11 @@ package com.sparrowwallet.sparrow.paynym;
import com.sparrowwallet.drongo.bip47.InvalidPaymentCodeException;
import com.sparrowwallet.drongo.bip47.PaymentCode;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.wallet.Wallet;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Collections;
import java.util.List;
public class PayNym {
@@ -18,6 +20,8 @@ public class PayNym {
private final List<PayNym> following;
private final List<PayNym> followers;
private boolean collaborativeSend;
public PayNym(PaymentCode paymentCode, String nymId, String nymName, boolean segwit, List<PayNym> following, List<PayNym> followers) {
this.paymentCode = paymentCode;
this.nymId = nymId;
@@ -51,6 +55,14 @@ public class PayNym {
return followers;
}
public boolean isCollaborativeSend() {
return collaborativeSend;
}
public void setCollaborativeSend(boolean collaborativeSend) {
this.collaborativeSend = collaborativeSend;
}
public List<ScriptType> getScriptTypes() {
return segwit ? getSegwitScriptTypes() : getV1ScriptTypes();
}
@@ -74,4 +86,31 @@ public class PayNym {
return new PayNym(paymentCode, nymId, nymName, segwit, following, followers);
}
public static PayNym fromWallet(Wallet bip47Wallet) {
if(!bip47Wallet.isBip47()) {
throw new IllegalArgumentException("Not a BIP47 wallet");
}
PaymentCode externalPaymentCode = bip47Wallet.getKeystores().get(0).getExternalPaymentCode();
String nymName = externalPaymentCode.toAbbreviatedString();
String walletNymName = getNymName(bip47Wallet);
if(walletNymName != null) {
nymName = walletNymName;
}
boolean segwit = bip47Wallet.getScriptType() != ScriptType.P2PKH;
return new PayNym(externalPaymentCode, null, nymName, segwit, Collections.emptyList(), Collections.emptyList());
}
public static String getNymName(Wallet bip47Wallet) {
if(bip47Wallet.getLabel() != null) {
String suffix = " " + bip47Wallet.getScriptType().getName();
if(bip47Wallet.getLabel().endsWith(suffix)) {
return bip47Wallet.getLabel().substring(0, bip47Wallet.getLabel().length() - suffix.length());
}
}
return null;
}
}
@@ -0,0 +1,85 @@
package com.sparrowwallet.sparrow.paynym;
import com.google.common.eventbus.Subscribe;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.wallet.WalletNode;
import com.sparrowwallet.sparrow.control.AddressTreeTable;
import com.sparrowwallet.sparrow.event.WalletEntryLabelsChangedEvent;
import com.sparrowwallet.sparrow.event.WalletHistoryChangedEvent;
import com.sparrowwallet.sparrow.wallet.Entry;
import com.sparrowwallet.sparrow.wallet.WalletForm;
import javafx.collections.FXCollections;
import javafx.fxml.FXML;
import javafx.scene.control.ComboBox;
import javafx.util.StringConverter;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
public class PayNymAddressesController {
@FXML
private ComboBox<WalletForm> payNymWalletForms;
@FXML
private AddressTreeTable receiveTable;
@FXML
private AddressTreeTable sendTable;
public void initializeView(WalletForm walletForm) {
payNymWalletForms.setItems(FXCollections.observableList(walletForm.getNestedWalletForms().stream().filter(nested -> nested.getWallet().isBip47()).collect(Collectors.toList())));
payNymWalletForms.setConverter(new StringConverter<>() {
@Override
public String toString(WalletForm nested) {
return nested == null ? "" : nested.getWallet().getDisplayName();
}
@Override
public WalletForm fromString(String string) {
return null;
}
});
Optional<WalletForm> optInitial = walletForm.getNestedWalletForms().stream().filter(nested -> nested.getWallet().isBip47() && nested.getWallet().getScriptType() == ScriptType.P2WPKH).findFirst();
if(optInitial.isPresent()) {
optInitial.get().getAccountEntries().clear();
receiveTable.initialize(optInitial.get().getNodeEntry(KeyPurpose.RECEIVE));
sendTable.initialize(optInitial.get().getNodeEntry(KeyPurpose.SEND));
payNymWalletForms.getSelectionModel().select(optInitial.get());
}
payNymWalletForms.getSelectionModel().selectedItemProperty().addListener((observable, oldValue, selected) -> {
selected.getAccountEntries().clear();
receiveTable.updateAll(selected.getNodeEntry(KeyPurpose.RECEIVE));
sendTable.updateAll(selected.getNodeEntry(KeyPurpose.SEND));
});
}
@Subscribe
public void walletHistoryChanged(WalletHistoryChangedEvent event) {
if(event.getWallet().equals(payNymWalletForms.getValue().getWallet())) {
List<WalletNode> receiveNodes = event.getReceiveNodes();
if(!receiveNodes.isEmpty()) {
receiveTable.updateHistory(receiveNodes);
}
List<WalletNode> sendNodes = event.getChangeNodes();
if(!sendNodes.isEmpty()) {
sendTable.updateHistory(sendNodes);
}
}
}
@Subscribe
public void walletEntryLabelChanged(WalletEntryLabelsChangedEvent event) {
if(event.getWallet().equals(payNymWalletForms.getValue().getWallet())) {
for(Entry entry : event.getEntries()) {
receiveTable.updateLabel(entry);
sendTable.updateLabel(entry);
}
}
}
}
@@ -0,0 +1,45 @@
package com.sparrowwallet.sparrow.paynym;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.wallet.WalletForm;
import javafx.fxml.FXMLLoader;
import javafx.scene.control.ButtonBar;
import javafx.scene.control.ButtonType;
import javafx.scene.control.Dialog;
import javafx.scene.control.DialogPane;
import java.io.IOException;
public class PayNymAddressesDialog extends Dialog<Boolean> {
public PayNymAddressesDialog(WalletForm walletForm) {
final DialogPane dialogPane = getDialogPane();
AppServices.setStageIcon(dialogPane.getScene().getWindow());
AppServices.onEscapePressed(dialogPane.getScene(), this::close);
try {
FXMLLoader payNymLoader = new FXMLLoader(AppServices.class.getResource("paynym/paynymaddresses.fxml"));
dialogPane.setContent(payNymLoader.load());
PayNymAddressesController controller = payNymLoader.getController();
controller.initializeView(walletForm);
EventManager.get().register(controller);
final ButtonType doneButtonType = new javafx.scene.control.ButtonType("Done", ButtonBar.ButtonData.OK_DONE);
dialogPane.getButtonTypes().add(doneButtonType);
setOnCloseRequest(event -> {
EventManager.get().unregister(controller);
});
setResultConverter(dialogButton -> dialogButton == doneButtonType ? Boolean.TRUE : Boolean.FALSE);
dialogPane.setPrefWidth(800);
dialogPane.setPrefHeight(600);
setResizable(true);
} catch(IOException e) {
throw new RuntimeException(e);
}
}
}
@@ -18,10 +18,7 @@ import com.sparrowwallet.sparrow.net.ElectrumServer;
import com.sparrowwallet.sparrow.wallet.Entry;
import com.sparrowwallet.sparrow.wallet.TransactionEntry;
import javafx.application.Platform;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.beans.property.*;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.event.ActionEvent;
@@ -34,17 +31,19 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.*;
import java.util.function.Function;
import java.util.function.UnaryOperator;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import static com.sparrowwallet.sparrow.AppServices.showErrorDialog;
import static com.sparrowwallet.sparrow.wallet.PaymentController.MINIMUM_P2PKH_OUTPUT_SATS;
public class PayNymController {
private static final Logger log = LoggerFactory.getLogger(PayNymController.class);
public static final Pattern PAYNYM_REGEX = Pattern.compile("\\+[a-z]+[0-9][0-9a-fA-F][0-9a-fA-F]");
private static final long MINIMUM_P2PKH_OUTPUT_SATS = 546L;
public static final String INVALID_PAYMENT_CODE_LABEL = "Invalid Payment Code";
private String walletId;
private boolean selectLinkedOnly;
@@ -65,6 +64,9 @@ public class PayNymController {
@FXML
private CopyableTextField searchPayNyms;
@FXML
private Button searchPayNymsScan;
@FXML
private ProgressIndicator findPayNym;
@@ -83,6 +85,8 @@ public class PayNymController {
private final Map<Sha256Hash, PayNym> notificationTransactions = new HashMap<>();
private final BooleanProperty closeProperty = new SimpleBooleanProperty(false);
public void initializeView(String walletId, boolean selectLinkedOnly) {
this.walletId = walletId;
this.selectLinkedOnly = selectLinkedOnly;
@@ -90,6 +94,7 @@ public class PayNymController {
payNymName.managedProperty().bind(payNymName.visibleProperty());
payNymRetrieve.managedProperty().bind(payNymRetrieve.visibleProperty());
payNymRetrieve.visibleProperty().bind(payNymName.visibleProperty().not());
payNymRetrieve.setDisable(!AppServices.isConnected());
retrievePayNymProgress.managedProperty().bind(retrievePayNymProgress.visibleProperty());
retrievePayNymProgress.maxHeightProperty().bind(payNymName.heightProperty());
@@ -132,6 +137,8 @@ public class PayNymController {
return change;
};
searchPayNymsScan.disableProperty().bind(searchPayNyms.disableProperty());
searchPayNyms.setDisable(true);
searchPayNyms.setTextFormatter(new TextFormatter<>(paymentCodeFilter));
searchPayNyms.addEventFilter(KeyEvent.ANY, event -> {
if(event.getCode() == KeyCode.ENTER) {
@@ -144,7 +151,7 @@ public class PayNymController {
findPayNym.setVisible(false);
followingList.setCellFactory(param -> {
return new PayNymCell(this);
return new PayNymCell(this, true);
});
followingList.getSelectionModel().selectedItemProperty().addListener((observable, oldValue, payNym) -> {
@@ -152,16 +159,17 @@ public class PayNymController {
});
followersList.setCellFactory(param -> {
return new PayNymCell(null);
return new PayNymCell(this, false);
});
followersList.setSelectionModel(new NoSelectionModel<>());
followersList.setFocusTraversable(false);
if(Config.get().isUsePayNym() && masterWallet.hasPaymentCode()) {
if(Config.get().isUsePayNym() && AppServices.isConnected() && masterWallet.hasPaymentCode()) {
refresh();
} else {
payNymName.setVisible(false);
updateFollowing();
}
}
@@ -174,12 +182,13 @@ public class PayNymController {
AppServices.getPayNymService().getPayNym(getMasterWallet().getPaymentCode().toString()).subscribe(payNym -> {
retrievePayNymProgress.setVisible(false);
walletPayNym = payNym;
searchPayNyms.setDisable(false);
payNymName.setText(payNym.nymName());
paymentCode.setPaymentCode(payNym.paymentCode());
payNymAvatar.setPaymentCode(payNym.paymentCode());
followingList.setUserData(null);
followingList.setPlaceholder(new Label("No contacts"));
followingList.setItems(FXCollections.observableList(payNym.following()));
updateFollowing();
followersList.setPlaceholder(new Label("No followers"));
followersList.setItems(FXCollections.observableList(payNym.followers()));
Platform.runLater(() -> addWalletIfNotificationTransactionPresent(payNym.following()));
@@ -187,6 +196,7 @@ public class PayNymController {
retrievePayNymProgress.setVisible(false);
if(error.getMessage().endsWith("404")) {
payNymName.setVisible(false);
updateFollowing();
} else {
log.error("Error retrieving PayNym", error);
Optional<ButtonType> optResponse = showErrorDialog("Error retrieving PayNym", "Could not retrieve PayNym. Try again?", ButtonType.CANCEL, ButtonType.OK);
@@ -194,6 +204,7 @@ public class PayNymController {
refresh();
} else {
payNymName.setVisible(false);
updateFollowing();
}
}
});
@@ -202,7 +213,7 @@ public class PayNymController {
private void resetFollowing() {
if(followingList.getUserData() != null) {
followingList.setUserData(null);
followingList.setItems(FXCollections.observableList(walletPayNym.following()));
updateFollowing();
}
}
@@ -271,35 +282,74 @@ public class PayNymController {
public void followPayNym(PaymentCode contact) {
PayNymService payNymService = AppServices.getPayNymService();
Wallet masterWallet = getMasterWallet();
retrievePayNymProgress.setVisible(true);
payNymService.getAuthToken(masterWallet, new HashMap<>()).subscribe(authToken -> {
String signature = payNymService.getSignature(masterWallet, authToken);
payNymService.followPaymentCode(contact, authToken, signature).subscribe(followMap -> {
refresh();
}, error -> {
retrievePayNymProgress.setVisible(false);
log.error("Could not follow payment code", error);
Optional<ButtonType> optResponse = showErrorDialog("Error retrieving PayNym", "Could not follow payment code. Try again?", ButtonType.CANCEL, ButtonType.OK);
if(optResponse.isPresent() && optResponse.get().equals(ButtonType.OK)) {
followPayNym(contact);
} else {
followingList.refresh();
followersList.refresh();
}
});
}, error -> {
retrievePayNymProgress.setVisible(false);
log.error("Could not follow payment code", error);
Optional<ButtonType> optResponse = showErrorDialog("Error retrieving PayNym", "Could not follow payment code. Try again?", ButtonType.CANCEL, ButtonType.OK);
if(optResponse.isPresent() && optResponse.get().equals(ButtonType.OK)) {
followPayNym(contact);
} else {
followingList.refresh();
followersList.refresh();
}
});
}
public Boolean isFollowing(PayNym payNym) {
if(followingList.getItems() != null) {
return followingList.getItems().stream().anyMatch(following -> payNym.paymentCode().equals(following.paymentCode()));
}
return null;
}
public boolean isLinked(PayNym payNym) {
PaymentCode externalPaymentCode = payNym.paymentCode();
return getMasterWallet().getChildWallet(externalPaymentCode, payNym.segwit() ? ScriptType.P2WPKH : ScriptType.P2PKH) != null;
}
public void updateFollowing() {
List<PayNym> followingPayNyms = new ArrayList<>();
if(walletPayNym != null) {
followingPayNyms.addAll(walletPayNym.following());
}
Map<PaymentCode, PayNym> payNymMap = followingPayNyms.stream().collect(Collectors.toMap(PayNym::paymentCode, Function.identity(), (u, v) -> u, LinkedHashMap::new));
followingList.setItems(FXCollections.observableList(getExistingWalletPayNyms(payNymMap)));
}
private List<PayNym> getExistingWalletPayNyms(Map<PaymentCode, PayNym> payNymMap) {
List<Wallet> childWallets = new ArrayList<>(getMasterWallet().getChildWallets());
childWallets.sort(Comparator.comparingInt(o -> -o.getScriptType().ordinal()));
for(Wallet childWallet : childWallets) {
if(childWallet.isBip47()) {
PaymentCode externalPaymentCode = childWallet.getKeystores().get(0).getExternalPaymentCode();
String walletNymName = PayNym.getNymName(childWallet);
if(payNymMap.get(externalPaymentCode) == null || (walletNymName != null && !walletNymName.equals(payNymMap.get(externalPaymentCode).nymName()))) {
payNymMap.put(externalPaymentCode, PayNym.fromWallet(childWallet));
}
}
}
return new ArrayList<>(payNymMap.values());
}
private void addWalletIfNotificationTransactionPresent(List<PayNym> following) {
Map<BlockTransaction, PayNym> unlinkedPayNyms = new HashMap<>();
Map<BlockTransaction, WalletNode> unlinkedNotifications = new HashMap<>();
@@ -307,7 +357,7 @@ public class PayNymController {
if(!isLinked(payNym)) {
PaymentCode externalPaymentCode = payNym.paymentCode();
Map<BlockTransaction, WalletNode> unlinkedNotification = getMasterWallet().getNotificationTransaction(externalPaymentCode);
if(!unlinkedNotification.isEmpty()) {
if(!unlinkedNotification.isEmpty() && !INVALID_PAYMENT_CODE_LABEL.equals(unlinkedNotification.keySet().iterator().next().getLabel())) {
unlinkedNotifications.putAll(unlinkedNotification);
unlinkedPayNyms.put(unlinkedNotification.keySet().iterator().next(), payNym);
}
@@ -360,6 +410,9 @@ public class PayNymController {
byte[] opReturnData = PaymentCode.getOpReturnData(blockTransaction.getTransaction());
if(Arrays.equals(opReturnData, blindedPaymentCode)) {
addedWallets.addAll(addChildWallets(payNym, externalPaymentCode));
} else {
blockTransaction.setLabel(INVALID_PAYMENT_CODE_LABEL);
EventManager.get().post(new WalletEntryLabelsChangedEvent(input0Node.getWallet(), new TransactionEntry(input0Node.getWallet(), blockTransaction, Collections.emptyMap(), Collections.emptyMap())));
}
} catch(Exception e) {
log.error("Error adding linked contact from notification transaction", e);
@@ -380,8 +433,8 @@ public class PayNymController {
Storage storage = AppServices.get().getOpenWallets().get(masterWallet);
List<ScriptType> scriptTypes = masterWallet.getScriptType() != ScriptType.P2PKH ? PayNym.getSegwitScriptTypes() : payNym.getScriptTypes();
for(ScriptType childScriptType : scriptTypes) {
Wallet addedWallet = masterWallet.addChildWallet(externalPaymentCode, childScriptType);
addedWallet.setLabel(payNym.nymName() + " " + childScriptType.getName());
String label = payNym.nymName() + " " + childScriptType.getName();
Wallet addedWallet = masterWallet.addChildWallet(externalPaymentCode, childScriptType, label);
if(!storage.isPersisted(addedWallet)) {
try {
storage.saveWallet(addedWallet);
@@ -397,11 +450,20 @@ public class PayNymController {
}
public void linkPayNym(PayNym payNym) {
ButtonType previewType = new ButtonType("Preview", ButtonBar.ButtonData.LEFT);
ButtonType sendType = new ButtonType("Send", ButtonBar.ButtonData.YES);
Optional<ButtonType> optButtonType = AppServices.showAlertDialog("Link PayNym?",
"Linking to this contact will allow you to send to it non-collaboratively through unique private addresses you can generate independently.\n\n" +
"It will cost " + MINIMUM_P2PKH_OUTPUT_SATS + " sats to create the link, plus the mining fee. Send transaction?", Alert.AlertType.CONFIRMATION, ButtonType.NO, ButtonType.YES);
if(optButtonType.isPresent() && optButtonType.get() == ButtonType.YES) {
"Linking to this contact will allow you to send to it directly (non-collaboratively) through unique private addresses you can generate independently.\n\n" +
"It will cost " + MINIMUM_P2PKH_OUTPUT_SATS + " sats to create the link through a notification transaction, plus the mining fee. Send transaction?", Alert.AlertType.CONFIRMATION, previewType, ButtonType.CANCEL, sendType);
if(optButtonType.isPresent() && optButtonType.get() == sendType) {
broadcastNotificationTransaction(payNym);
} else if(optButtonType.isPresent() && optButtonType.get() == previewType) {
PaymentCode paymentCode = payNym.paymentCode();
Payment payment = new Payment(paymentCode.getNotificationAddress(), "Link " + payNym.nymName(), MINIMUM_P2PKH_OUTPUT_SATS, false);
Wallet wallet = AppServices.get().getWallet(walletId);
EventManager.get().post(new SendActionEvent(wallet, new ArrayList<>(wallet.getWalletUtxos().keySet())));
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(wallet, List.of(payment), List.of(new byte[80]), paymentCode)));
closeProperty.set(true);
} else {
followingList.refresh();
}
@@ -544,7 +606,7 @@ public class PayNymController {
return wallet.createWalletTransaction(utxoSelectors, utxoFilters, payments, opReturns, Collections.emptySet(), feeRate, minimumFeeRate, null, AppServices.getCurrentBlockHeight(), groupByAddress, includeMempoolOutputs, false);
}
private Wallet getMasterWallet() {
public Wallet getMasterWallet() {
Wallet wallet = AppServices.get().getWallet(walletId);
return wallet.isMasterWallet() ? wallet : wallet.getMasterWallet();
}
@@ -561,6 +623,10 @@ public class PayNymController {
return payNymProperty;
}
public BooleanProperty closeProperty() {
return closeProperty;
}
@Subscribe
public void walletHistoryChanged(WalletHistoryChangedEvent event) {
List<Entry> changedLabelEntries = new ArrayList<>();
@@ -9,16 +9,16 @@ import java.io.IOException;
public class PayNymDialog extends Dialog<PayNym> {
public PayNymDialog(String walletId) {
this(walletId, false, false);
this(walletId, Operation.SHOW, false);
}
public PayNymDialog(String walletId, boolean selectPayNym, boolean selectLinkedOnly) {
public PayNymDialog(String walletId, Operation operation, boolean selectLinkedOnly) {
final DialogPane dialogPane = getDialogPane();
AppServices.setStageIcon(dialogPane.getScene().getWindow());
AppServices.onEscapePressed(dialogPane.getScene(), this::close);
try {
FXMLLoader payNymLoader = new FXMLLoader(AppServices.class.getResource("soroban/paynym.fxml"));
FXMLLoader payNymLoader = new FXMLLoader(AppServices.class.getResource("paynym/paynym.fxml"));
dialogPane.setContent(payNymLoader.load());
PayNymController payNymController = payNymLoader.getController();
payNymController.initializeView(walletId, selectLinkedOnly);
@@ -30,13 +30,36 @@ public class PayNymDialog extends Dialog<PayNym> {
AppServices.moveToActiveWindowScreen(this);
dialogPane.getStylesheets().add(AppServices.class.getResource("app.css").toExternalForm());
dialogPane.getStylesheets().add(AppServices.class.getResource("soroban/paynym.css").toExternalForm());
dialogPane.getStylesheets().add(AppServices.class.getResource("paynym/paynym.css").toExternalForm());
final ButtonType sendDirectlyButtonType = new javafx.scene.control.ButtonType("Send Directly", ButtonBar.ButtonData.APPLY);
final ButtonType sendCollaborativelyButtonType = new javafx.scene.control.ButtonType("Send Collaboratively", ButtonBar.ButtonData.OK_DONE);
final ButtonType selectButtonType = new javafx.scene.control.ButtonType("Select Contact", ButtonBar.ButtonData.APPLY);
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
final ButtonType doneButtonType = new javafx.scene.control.ButtonType("Done", ButtonBar.ButtonData.OK_DONE);
if(selectPayNym) {
if(operation == Operation.SEND) {
if(selectLinkedOnly) {
dialogPane.getButtonTypes().addAll(sendDirectlyButtonType, cancelButtonType);
} else {
dialogPane.getButtonTypes().addAll(sendDirectlyButtonType, sendCollaborativelyButtonType, cancelButtonType);
Button sendCollaborativelyButton = (Button)dialogPane.lookupButton(sendCollaborativelyButtonType);
sendCollaborativelyButton.setDisable(true);
sendCollaborativelyButton.setDefaultButton(false);
payNymController.payNymProperty().addListener((observable, oldValue, payNym) -> {
sendCollaborativelyButton.setDisable(payNym == null);
sendCollaborativelyButton.setDefaultButton(payNym != null && !payNymController.isLinked(payNym));
});
}
Button sendDirectlyButton = (Button)dialogPane.lookupButton(sendDirectlyButtonType);
sendDirectlyButton.setDisable(true);
sendDirectlyButton.setDefaultButton(true);
payNymController.payNymProperty().addListener((observable, oldValue, payNym) -> {
sendDirectlyButton.setDisable(payNym == null || !payNymController.isLinked(payNym));
sendDirectlyButton.setDefaultButton(!sendDirectlyButton.isDisable());
});
} else if(operation == Operation.SELECT) {
dialogPane.getButtonTypes().addAll(selectButtonType, cancelButtonType);
Button selectButton = (Button)dialogPane.lookupButton(selectButtonType);
selectButton.setDisable(true);
@@ -48,13 +71,35 @@ public class PayNymDialog extends Dialog<PayNym> {
dialogPane.getButtonTypes().add(doneButtonType);
}
payNymController.closeProperty().addListener((observable, oldValue, newValue) -> {
if(newValue) {
close();
}
});
setOnCloseRequest(event -> {
EventManager.get().unregister(payNymController);
});
setResultConverter(dialogButton -> dialogButton == selectButtonType ? payNymController.getPayNym() : null);
setResultConverter(dialogButton -> {
if(dialogButton == sendCollaborativelyButtonType) {
PayNym payNym = payNymController.getPayNym();
payNym.setCollaborativeSend(true);
return payNym;
} else if(dialogButton == sendDirectlyButtonType || dialogButton == selectButtonType) {
PayNym payNym = payNymController.getPayNym();
payNym.setCollaborativeSend(false);
return payNym;
}
return null;
});
} catch(IOException e) {
throw new RuntimeException(e);
}
}
public enum Operation {
SHOW, SELECT, SEND;
}
}
@@ -52,7 +52,7 @@ public class PayNymService {
body.put("code", paymentCode.toString());
IHttpClient httpClient = httpClientService.getHttpClient(HttpUsage.COORDINATOR_REST);
return httpClient.postJson("https://paynym.is/api/v1/create", Map.class, headers, body)
return httpClient.postJson(getHostUrl() + "/api/v1/create", Map.class, headers, body)
.subscribeOn(Schedulers.io())
.observeOn(JavaFxScheduler.platform())
.map(Optional::get);
@@ -70,7 +70,7 @@ public class PayNymService {
body.put("code", paymentCode.toString());
IHttpClient httpClient = httpClientService.getHttpClient(HttpUsage.COORDINATOR_REST);
return httpClient.postJson("https://paynym.is/api/v1/token", Map.class, headers, body)
return httpClient.postJson(getHostUrl() + "/api/v1/token", Map.class, headers, body)
.subscribeOn(Schedulers.io())
.observeOn(JavaFxScheduler.platform())
.map(Optional::get);
@@ -115,7 +115,7 @@ public class PayNymService {
body.put("signature", signature);
IHttpClient httpClient = httpClientService.getHttpClient(HttpUsage.COORDINATOR_REST);
return httpClient.postJson("https://paynym.is/api/v1/claim", Map.class, headers, body)
return httpClient.postJson(getHostUrl() + "/api/v1/claim", Map.class, headers, body)
.subscribeOn(Schedulers.io())
.observeOn(JavaFxScheduler.platform())
.map(Optional::get);
@@ -140,7 +140,7 @@ public class PayNymService {
body.put("signature", signature);
IHttpClient httpClient = httpClientService.getHttpClient(HttpUsage.COORDINATOR_REST);
return httpClient.postJson("https://paynym.is/api/v1/nym/add", Map.class, headers, body)
return httpClient.postJson(getHostUrl() + "/api/v1/nym/add", Map.class, headers, body)
.subscribeOn(Schedulers.io())
.observeOn(JavaFxScheduler.platform())
.map(Optional::get);
@@ -160,7 +160,7 @@ public class PayNymService {
body.put("target", paymentCode.toString());
IHttpClient httpClient = httpClientService.getHttpClient(HttpUsage.COORDINATOR_REST);
return httpClient.postJson("https://paynym.is/api/v1/follow", Map.class, headers, body)
return httpClient.postJson(getHostUrl() + "/api/v1/follow", Map.class, headers, body)
.subscribeOn(Schedulers.io())
.observeOn(JavaFxScheduler.platform())
.map(Optional::get);
@@ -174,7 +174,7 @@ public class PayNymService {
body.put("nym", nymIdentifier);
IHttpClient httpClient = httpClientService.getHttpClient(HttpUsage.COORDINATOR_REST);
return httpClient.postJson("https://paynym.is/api/v1/nym", Map.class, headers, body)
return httpClient.postJson(getHostUrl() + "/api/v1/nym", Map.class, headers, body)
.subscribeOn(Schedulers.io())
.observeOn(JavaFxScheduler.platform())
.map(Optional::get);
@@ -235,6 +235,14 @@ public class PayNymService {
httpClientService.setTorProxy(torProxy);
}
private String getHostUrl() {
return getHostUrl(getTorProxy() != null);
}
public static String getHostUrl(boolean tor) {
return tor ? "http://paynym7bwekdtb2hzgkpl6y2waqcrs2dii7lwincvxme7mdpcpxzfsad.onion" : "https://paynym.is";
}
public void shutdown() {
httpClientService.shutdown();
}
@@ -587,11 +587,12 @@ public class ServerPreferencesController extends PreferencesDetailController {
Optional<ButtonType> optButton = AppServices.showErrorDialog("SSL Handshake Failed", "The certificate provided by the server at " + tlsServerException.getServer().getHost() + " appears to have changed." +
"\n\nThis may indicate a man-in-the-middle attack!" +
"\n\nDo you still want to proceed?", ButtonType.NO, ButtonType.YES);
if(optButton.isPresent()) {
if(optButton.get() == ButtonType.YES) {
savedCrtFile.delete();
if(optButton.isPresent() && optButton.get() == ButtonType.YES) {
if(savedCrtFile.delete()) {
Platform.runLater(this::startElectrumConnection);
return;
} else {
AppServices.showErrorDialog("Could not delete certificate", "The certificate file at " + savedCrtFile.getAbsolutePath() + " could not be deleted.\n\nPlease delete this file manually.");
}
}
}
@@ -9,6 +9,7 @@ import com.samourai.wallet.cahoots.CahootsType;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.psbt.PSBTParseException;
import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
import com.sparrowwallet.drongo.wallet.Status;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletNode;
import com.sparrowwallet.sparrow.AppServices;
@@ -90,7 +91,7 @@ public class CounterpartyController extends SorobanController {
private PayNymAvatar mixPartnerAvatar;
@FXML
private Label meetingFail;
private Hyperlink meetingFail;
@FXML
private VBox mixDetails;
@@ -187,6 +188,15 @@ public class CounterpartyController extends SorobanController {
mixingPartner.managedProperty().bind(mixingPartner.visibleProperty());
meetingFail.managedProperty().bind(meetingFail.visibleProperty());
meetingFail.visibleProperty().bind(mixingPartner.visibleProperty().not());
meetingFail.setOnAction(event -> {
step2Desc.setText("Ask your mix partner to initiate the Soroban communication.");
mixingPartner.setVisible(true);
startCounterpartyMeetingReceive();
step2Timer.start(e -> {
step2Desc.setText("Mix declined due to timeout.");
meetingReceived.set(Boolean.FALSE);
});
});
mixDetails.managedProperty().bind(mixDetails.visibleProperty());
mixDetails.setVisible(false);
@@ -287,7 +297,9 @@ public class CounterpartyController extends SorobanController {
Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId);
Map<BlockTransactionHashIndex, WalletNode> walletUtxos = wallet.getWalletUtxos();
for(Map.Entry<BlockTransactionHashIndex, WalletNode> entry : walletUtxos.entrySet()) {
counterpartyCahootsWallet.addUtxo(entry.getValue(), wallet.getWalletTransaction(entry.getKey().getHash()), (int)entry.getKey().getIndex());
if(entry.getKey().getStatus() != Status.FROZEN) {
counterpartyCahootsWallet.addUtxo(entry.getValue(), wallet.getWalletTransaction(entry.getKey().getHash()), (int)entry.getKey().getIndex());
}
}
try {
@@ -39,10 +39,13 @@ import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.beans.value.ChangeListener;
import javafx.collections.FXCollections;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.scene.control.*;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.VBox;
import javafx.util.Duration;
import javafx.util.StringConverter;
@@ -148,6 +151,22 @@ public class InitiatorController extends SorobanController {
private boolean closed;
private final ChangeListener<String> counterpartyListener = (observable, oldValue, newValue) -> {
if(newValue != null) {
if(newValue.startsWith("P") && newValue.contains("...") && newValue.length() == 20 && counterpartyPaymentCode.get() != null) {
//Assumed valid payment code
} else if(Config.get().isUsePayNym() && PAYNYM_REGEX.matcher(newValue).matches()) {
if(!newValue.equals(counterpartyPayNymName.get())) {
searchPayNyms(newValue);
}
} else if(!newValue.equals(counterpartyPayNymName.get())) {
counterpartyPayNymName.set(null);
counterpartyPaymentCode.set(null);
payNymAvatar.clearPaymentCode();
}
}
};
public void initializeView(String walletId, Wallet wallet, WalletTransaction walletTransaction) {
this.walletId = walletId;
this.wallet = wallet;
@@ -247,29 +266,11 @@ public class InitiatorController extends SorobanController {
return change;
};
counterparty.setTextFormatter(new TextFormatter<>(paymentCodeFilter));
counterparty.textProperty().addListener((observable, oldValue, newValue) -> {
if(newValue != null) {
if(newValue.startsWith("P") && newValue.contains("...") && newValue.length() == 20 && counterpartyPaymentCode.get() != null) {
//Assumed valid payment code
} else if(Config.get().isUsePayNym() && PAYNYM_REGEX.matcher(newValue).matches()) {
if(!newValue.equals(counterpartyPayNymName.get())) {
payNymLoading.setVisible(true);
AppServices.getPayNymService().getPayNym(newValue).subscribe(payNym -> {
payNymLoading.setVisible(false);
counterpartyPayNymName.set(payNym.nymName());
counterpartyPaymentCode.set(new PaymentCode(payNym.paymentCode().toString()));
payNymAvatar.setPaymentCode(payNym.paymentCode());
}, error -> {
payNymLoading.setVisible(false);
//ignore, probably doesn't exist but will try again on meeting request
});
}
} else {
counterpartyPayNymName.set(null);
counterpartyPaymentCode.set(null);
payNymAvatar.clearPaymentCode();
}
counterparty.textProperty().addListener(counterpartyListener);
counterparty.addEventFilter(KeyEvent.ANY, event -> {
if(counterparty.isEditable() && event.getCode() == KeyCode.ENTER) {
searchPayNyms(counterparty.getText());
event.consume();
}
});
@@ -308,6 +309,25 @@ public class InitiatorController extends SorobanController {
});
}
private void searchPayNyms(String identifier) {
payNymLoading.setVisible(true);
AppServices.getPayNymService().getPayNym(identifier).subscribe(payNym -> {
payNymLoading.setVisible(false);
counterpartyPayNymName.set(payNym.nymName());
counterpartyPaymentCode.set(new PaymentCode(payNym.paymentCode().toString()));
payNymAvatar.setPaymentCode(payNym.paymentCode());
counterparty.textProperty().removeListener(counterpartyListener);
int caret = counterparty.getCaretPosition();
counterparty.setText("");
counterparty.setText(payNym.nymName());
counterparty.positionCaret(caret);
counterparty.textProperty().addListener(counterpartyListener);
}, error -> {
payNymLoading.setVisible(false);
//ignore, probably doesn't exist but will try again on meeting request
});
}
private void setPayNymFollowers() {
Wallet masterWallet = wallet.isMasterWallet() ? wallet : wallet.getMasterWallet();
AppServices.getPayNymService().getPayNym(masterWallet.getPaymentCode().toString()).map(PayNym::following).subscribe(followerPayNyms -> {
@@ -433,7 +453,9 @@ public class InitiatorController extends SorobanController {
Payment payment = walletTransaction.getPayments().get(0);
Map<BlockTransactionHashIndex, WalletNode> firstSetUtxos = walletTransaction.isCoinControlUsed() ? walletTransaction.getSelectedUtxoSets().get(0) : wallet.getWalletUtxos();
for(Map.Entry<BlockTransactionHashIndex, WalletNode> entry : firstSetUtxos.entrySet()) {
initiatorCahootsWallet.addUtxo(entry.getValue(), wallet.getWalletTransaction(entry.getKey().getHash()), (int)entry.getKey().getIndex());
if(entry.getKey().getStatus() != Status.FROZEN) {
initiatorCahootsWallet.addUtxo(entry.getValue(), wallet.getWalletTransaction(entry.getKey().getHash()), (int)entry.getKey().getIndex());
}
}
SorobanCahootsService sorobanCahootsService = soroban.getSorobanCahootsService(initiatorCahootsWallet);
@@ -615,7 +637,7 @@ public class InitiatorController extends SorobanController {
}
public void findPayNym(ActionEvent event) {
PayNymDialog payNymDialog = new PayNymDialog(walletId, true, false);
PayNymDialog payNymDialog = new PayNymDialog(walletId, PayNymDialog.Operation.SELECT, false);
Optional<PayNym> optPayNym = payNymDialog.showAndWait();
optPayNym.ifPresent(payNym -> {
counterpartyPayNymName.set(payNym.nymName());
@@ -14,6 +14,7 @@ import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.control.*;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5Brands;
import com.sparrowwallet.sparrow.io.Device;
import com.sparrowwallet.sparrow.net.ElectrumServer;
@@ -59,6 +60,9 @@ import java.time.*;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import static com.sparrowwallet.sparrow.AppServices.showErrorDialog;
public class HeadersController extends TransactionFormController implements Initializable, DynamicUpdate {
private static final Logger log = LoggerFactory.getLogger(HeadersController.class);
@@ -78,7 +82,7 @@ public class HeadersController extends TransactionFormController implements Init
private TransactionDiagram transactionDiagram;
@FXML
private Spinner<Integer> version;
private IntegerSpinner version;
@FXML
private CopyableLabel segwit;
@@ -108,10 +112,10 @@ public class HeadersController extends TransactionFormController implements Init
private Field locktimeDateField;
@FXML
private Spinner<Integer> locktimeNone;
private IntegerSpinner locktimeNone;
@FXML
private Spinner<Integer> locktimeBlock;
private IntegerSpinner locktimeBlock;
@FXML
private Hyperlink locktimeCurrentHeight;
@@ -239,8 +243,12 @@ public class HeadersController extends TransactionFormController implements Init
updateTxId();
version.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(1, 2, (int)tx.getVersion()));
version.setValueFactory(new IntegerSpinner.ValueFactory(1, 2, (int)tx.getVersion()));
version.valueProperty().addListener((obs, oldValue, newValue) -> {
if(newValue == null || newValue < 1 || newValue > 2) {
return;
}
tx.setVersion(newValue);
if(oldValue != null) {
EventManager.get().post(new TransactionChangedEvent(tx));
@@ -301,9 +309,9 @@ public class HeadersController extends TransactionFormController implements Init
futureDateWarning.managedProperty().bind(futureDateWarning.visibleProperty());
futureDateWarning.setVisible(false);
locktimeNone.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(0, (int)Transaction.MAX_BLOCK_LOCKTIME-1, 0));
locktimeNone.setValueFactory(new IntegerSpinner.ValueFactory(0, (int)Transaction.MAX_BLOCK_LOCKTIME-1, 0));
if(tx.getLocktime() < Transaction.MAX_BLOCK_LOCKTIME) {
locktimeBlock.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(0, (int)Transaction.MAX_BLOCK_LOCKTIME-1, (int)tx.getLocktime()));
locktimeBlock.setValueFactory(new IntegerSpinner.ValueFactory(0, (int)Transaction.MAX_BLOCK_LOCKTIME-1, (int)tx.getLocktime()));
if(tx.getLocktime() == 0) {
locktimeToggleGroup.selectToggle(locktimeNoneType);
} else {
@@ -312,13 +320,17 @@ public class HeadersController extends TransactionFormController implements Init
LocalDateTime date = Instant.now().atZone(ZoneId.systemDefault()).toLocalDateTime();
locktimeDate.setDateTimeValue(date);
} else {
locktimeBlock.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(0, (int)Transaction.MAX_BLOCK_LOCKTIME-1));
locktimeBlock.setValueFactory(new IntegerSpinner.ValueFactory(0, (int)Transaction.MAX_BLOCK_LOCKTIME-1));
LocalDateTime date = Instant.ofEpochSecond(tx.getLocktime()).atZone(ZoneId.systemDefault()).toLocalDateTime();
locktimeDate.setDateTimeValue(date);
locktimeToggleGroup.selectToggle(locktimeDateType);
}
locktimeBlock.valueProperty().addListener((obs, oldValue, newValue) -> {
if(newValue == null || newValue < 0 || newValue >= Transaction.MAX_BLOCK_LOCKTIME) {
return;
}
tx.setLocktime(newValue);
locktimeCurrentHeight.setVisible(headersForm.isEditable() && AppServices.getCurrentBlockHeight() != null && newValue < AppServices.getCurrentBlockHeight());
futureBlockWarning.setVisible(AppServices.getCurrentBlockHeight() != null && newValue > AppServices.getCurrentBlockHeight());
@@ -793,7 +805,7 @@ public class HeadersController extends TransactionFormController implements Init
public void copyId(ActionEvent event) {
ClipboardContent content = new ClipboardContent();
content.putString(headersForm.getTransaction().getTxId().toString());
content.putString(headersForm.getTransaction().calculateTxId(false).toString());
Clipboard.getSystemClipboard().setContent(content);
}
@@ -828,7 +840,49 @@ public class HeadersController extends TransactionFormController implements Init
ToggleButton toggleButton = (ToggleButton)event.getSource();
toggleButton.setSelected(false);
EventManager.get().post(new RequestQRScanEvent(toggleButton.getScene().getWindow()));
QRScanDialog qrScanDialog = new QRScanDialog();
Optional<QRScanDialog.Result> optionalResult = qrScanDialog.showAndWait();
if(optionalResult.isPresent()) {
QRScanDialog.Result result = optionalResult.get();
if(result.transaction != null) {
EventManager.get().post(new ViewTransactionEvent(toggleButton.getScene().getWindow(), result.transaction));
} else if(result.psbt != null) {
EventManager.get().post(new ViewPSBTEvent(toggleButton.getScene().getWindow(), null, null, result.psbt));
} else if(result.seed != null) {
signFromSeed(result.seed);
} else if(result.exception != null) {
log.error("Error scanning QR", result.exception);
showErrorDialog("Error scanning QR", result.exception.getMessage());
} else {
AppServices.showErrorDialog("Invalid QR Code", "Cannot parse QR code into a transaction or seed.");
}
}
}
private void signFromSeed(DeterministicSeed seed) {
try {
String masterFingerprint = Keystore.fromSeed(seed, ScriptType.P2PKH.getDefaultDerivation()).getKeyDerivation().getMasterFingerprint();
Wallet walletCopy = headersForm.getSigningWallet().copy();
OptionalInt optIndex = IntStream.range(0, walletCopy.getKeystores().size())
.filter(i -> walletCopy.getKeystores().get(i).getKeyDerivation().getMasterFingerprint().equals(masterFingerprint)).findFirst();
if(optIndex.isPresent()) {
walletCopy.getKeystores().forEach(keystore -> {
keystore.setSeed(null);
keystore.setMasterPrivateExtendedKey(null);
});
Keystore original = walletCopy.getKeystores().get(optIndex.getAsInt());
Keystore replacement = Keystore.fromSeed(seed, original.getKeyDerivation().getDerivation());
walletCopy.getKeystores().set(optIndex.getAsInt(), replacement);
signUnencryptedKeystores(walletCopy);
} else {
AppServices.showErrorDialog("Invalid seed", "The QR code contains a seed that does not match any of the keystores in the signing wallet.");
}
} catch(MnemonicException e) {
log.error("Invalid seed", e);
AppServices.showErrorDialog("Invalid seed", e.getMessage());
} finally {
seed.clear();
}
}
public void savePSBT(ActionEvent event) {
@@ -951,8 +1005,13 @@ public class HeadersController extends TransactionFormController implements Init
private void finalizePSBT() {
if(headersForm.getPsbt() != null && headersForm.getPsbt().isSigned() && !headersForm.getPsbt().isFinalized()) {
headersForm.getSigningWallet().finalise(headersForm.getPsbt());
EventManager.get().post(new PSBTFinalizedEvent(headersForm.getPsbt()));
try {
headersForm.getSigningWallet().finalise(headersForm.getPsbt());
EventManager.get().post(new PSBTFinalizedEvent(headersForm.getPsbt()));
} catch(IllegalArgumentException e) {
AppServices.showErrorDialog("Cannot finalize PSBT", e.getMessage());
throw e;
}
}
}
@@ -1159,16 +1218,35 @@ public class HeadersController extends TransactionFormController implements Init
}
}
Long feeAmt = calculateFee(event.getInputTransactions());
if(feeAmt != null) {
updateFee(feeAmt);
}
if(!event.getInputTransactions().isEmpty()) {
Long feeAmt = calculateFee(event.getInputTransactions());
if(feeAmt != null) {
updateFee(feeAmt);
}
Map<Sha256Hash, BlockTransaction> allFetchedInputTransactions = new HashMap<>(event.getInputTransactions());
if(headersForm.getInputTransactions() != null) {
allFetchedInputTransactions.putAll(headersForm.getInputTransactions());
Map<Sha256Hash, BlockTransaction> allFetchedInputTransactions = new HashMap<>(event.getInputTransactions());
if(headersForm.getInputTransactions() != null) {
allFetchedInputTransactions.putAll(headersForm.getInputTransactions());
}
transactionDiagram.update(getWalletTransaction(allFetchedInputTransactions));
}
transactionDiagram.update(getWalletTransaction(allFetchedInputTransactions));
}
}
@Subscribe
public void transactionFetchFailed(TransactionFetchFailedEvent event) {
if(event.getTransaction().getTxId().equals(headersForm.getTransaction().getTxId())
&& !blockchainForm.isVisible() && !signingWalletForm.isVisible() && !signaturesForm.isVisible()) {
blockchainForm.setVisible(true);
blockStatus.setText("Unknown transaction status, server failed to respond");
Glyph errorGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.EXCLAMATION_CIRCLE);
errorGlyph.setFontSize(12);
blockStatus.setGraphic(errorGlyph);
blockStatus.setContentDisplay(ContentDisplay.LEFT);
errorGlyph.getStyleClass().add("failure");
blockHeightField.setVisible(false);
blockTimestampField.setVisible(false);
blockHashField.setVisible(false);
}
}
@@ -1351,7 +1429,7 @@ public class HeadersController extends TransactionFormController implements Init
for(BlockTransactionHashIndex output : walletNode.getTransactionOutputs()) {
if(output.getHash().equals(txid) && output.getLabel() == null) { //If we send to ourselves, usually change
String label = outputIndexLabels.containsKey((int)output.getIndex()) ? outputIndexLabels.get((int)output.getIndex()) : headersForm.getName();
output.setLabel(label + (walletNode.getKeyPurpose() == KeyPurpose.CHANGE ? " (change)" : " (received)"));
output.setLabel(label + (walletNode.getKeyPurpose() == KeyPurpose.CHANGE ? (walletNode.getWallet().isBip47() ? " (sent)" : " (change)") : " (received)"));
changedLabelEntries.add(new HashIndexEntry(event.getWallet(), output, HashIndexEntry.Type.OUTPUT, walletNode.getKeyPurpose()));
}
if(output.getSpentBy() != null && output.getSpentBy().getHash().equals(txid) && output.getSpentBy().getLabel() == null) { //The norm - sending out
@@ -1364,6 +1442,12 @@ public class HeadersController extends TransactionFormController implements Init
if(!changedLabelEntries.isEmpty()) {
Platform.runLater(() -> EventManager.get().post(new WalletEntryLabelsChangedEvent(event.getWallet(), changedLabelEntries)));
}
} else if(headersForm.getBlockTransaction() != null && headersForm.getBlockTransaction().getHeight() <= 0) {
BlockTransaction walletTransaction = event.getWallet().getWalletTransaction(headersForm.getBlockTransaction().getHash());
if(walletTransaction != null && walletTransaction.getHeight() > 0) {
headersForm.setBlockTransaction(walletTransaction);
updateBlockchainForm(headersForm.getBlockTransaction(), AppServices.getCurrentBlockHeight());
}
}
}
@@ -104,7 +104,7 @@ public class InputController extends TransactionFormController implements Initia
private CopyableLabel locktimeAbsolute;
@FXML
private Spinner<Integer> locktimeRelativeBlocks;
private IntegerSpinner locktimeRelativeBlocks;
@FXML
private RelativeTimelockSpinner locktimeRelativeSeconds;
@@ -406,7 +406,7 @@ public class InputController extends TransactionFormController implements Initia
}
});
locktimeRelativeBlocks.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(0, (int)TransactionInput.RELATIVE_TIMELOCK_VALUE_MASK, 0));
locktimeRelativeBlocks.setValueFactory(new IntegerSpinner.ValueFactory(0, (int)TransactionInput.RELATIVE_TIMELOCK_VALUE_MASK, 0));
locktimeRelativeBlocks.managedProperty().bind(locktimeRelativeBlocks.visibleProperty());
locktimeRelativeSeconds.managedProperty().bind(locktimeRelativeSeconds.visibleProperty());
locktimeRelativeCombo.getSelectionModel().selectedItemProperty().addListener((ov, old_toggle, new_toggle) -> {
@@ -433,6 +433,10 @@ public class InputController extends TransactionFormController implements Initia
}
locktimeRelativeBlocks.valueProperty().addListener((obs, oldValue, newValue) -> {
if(newValue == null || newValue < 0 || newValue > TransactionInput.RELATIVE_TIMELOCK_VALUE_MASK) {
return;
}
setRelativeLocktime(txInput, transaction, oldValue != null);
});
locktimeRelativeSeconds.valueProperty().addListener((obs, oldValue, newValue) -> {
@@ -509,7 +513,7 @@ public class InputController extends TransactionFormController implements Initia
@Subscribe
public void blockTransactionFetched(BlockTransactionFetchedEvent event) {
if(event.getTxId().equals(inputForm.getTransaction().getTxId()) && inputForm.getIndex() >= event.getPageStart() && inputForm.getIndex() < event.getPageEnd()) {
if(event.getTxId().equals(inputForm.getTransaction().getTxId()) && !event.getInputTransactions().isEmpty() && inputForm.getIndex() >= event.getPageStart() && inputForm.getIndex() < event.getPageEnd()) {
updateOutpoint(event.getInputTransactions());
if(inputForm.getPsbt() == null) {
updateSpends(event.getInputTransactions());
@@ -164,7 +164,7 @@ public class InputsController extends TransactionFormController implements Initi
@Subscribe
public void blockTransactionFetched(BlockTransactionFetchedEvent event) {
if(event.getTxId().equals(inputsForm.getTransaction().getTxId()) && inputsForm.getPsbt() == null) {
if(event.getTxId().equals(inputsForm.getTransaction().getTxId()) && !event.getInputTransactions().isEmpty() && inputsForm.getPsbt() == null) {
updateBlockTransactionInputs(event.getInputTransactions());
}
}
@@ -6,6 +6,7 @@ import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.psbt.PSBTInput;
import com.sparrowwallet.drongo.psbt.PSBTOutput;
import com.sparrowwallet.drongo.wallet.BlockTransaction;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.TransactionTabData;
@@ -30,6 +31,7 @@ import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.net.URL;
import java.util.*;
import java.util.stream.Collectors;
public class TransactionController implements Initializable {
private static final Logger log = LoggerFactory.getLogger(TransactionController.class);
@@ -57,6 +59,8 @@ public class TransactionController implements Initializable {
private int selectedInputIndex = -1;
private int selectedOutputIndex = -1;
private boolean transactionsFetched;
@Override
public void initialize(URL location, ResourceBundle resources) {
EventManager.get().register(this);
@@ -68,6 +72,16 @@ public class TransactionController implements Initializable {
transactionMasterDetail.setShowDetailNode(Config.get().isShowTransactionHex());
txhex.setTransaction(getTransaction());
highlightTxHex();
fetchTransactions();
transactionMasterDetail.sceneProperty().addListener((observable, oldScene, newScene) -> {
if(oldScene == null && newScene != null) {
transactionMasterDetail.setDividerPosition(AppServices.isReducedWindowHeight(transactionMasterDetail) ? 0.9 : 0.82);
}
});
}
private void fetchTransactions() {
fetchThisAndInputBlockTransactions(0, Math.min(getTransaction().getInputs().size(), PageForm.PAGE_SIZE));
fetchOutputBlockTransactions(0, Math.min(getTransaction().getOutputs().size(), PageForm.PAGE_SIZE));
@@ -76,12 +90,6 @@ public class TransactionController implements Initializable {
} else if(TransactionView.OUTPUT.equals(initialView) && initialIndex >= PageForm.PAGE_SIZE) {
fetchOutputBlockTransactions(initialIndex, initialIndex + 1);
}
transactionMasterDetail.sceneProperty().addListener((observable, oldScene, newScene) -> {
if(oldScene == null && newScene != null) {
transactionMasterDetail.setDividerPosition(AppServices.isReducedWindowHeight(transactionMasterDetail) ? 0.9 : 0.82);
}
});
}
private void initializeTxTree() {
@@ -353,6 +361,7 @@ public class TransactionController implements Initializable {
ElectrumServer.TransactionReferenceService transactionReferenceService = new ElectrumServer.TransactionReferenceService(references);
transactionReferenceService.setOnSucceeded(successEvent -> {
transactionsFetched = true;
Map<Sha256Hash, BlockTransaction> transactionMap = transactionReferenceService.getValue();
BlockTransaction thisBlockTx = null;
Map<Sha256Hash, BlockTransaction> inputTransactions = new HashMap<>();
@@ -379,10 +388,36 @@ public class TransactionController implements Initializable {
});
transactionReferenceService.setOnFailed(failedEvent -> {
log.error("Error fetching transaction or input references", failedEvent.getSource().getException());
EventManager.get().post(new TransactionReferencesFailedEvent(getTransaction(), failedEvent.getSource().getException(), indexStart, maxIndex));
if(references.contains(getTransaction().getTxId())) {
EventManager.get().post(new TransactionFetchFailedEvent(getTransaction(), failedEvent.getSource().getException(), indexStart, maxIndex));
} else {
EventManager.get().post(new TransactionReferencesFailedEvent(getTransaction(), failedEvent.getSource().getException(), indexStart, maxIndex));
}
});
EventManager.get().post(new TransactionReferencesStartedEvent(getTransaction(), indexStart, maxIndex));
transactionReferenceService.start();
} else if(!AppServices.isConnected()) {
BlockTransaction blockTx = null;
Set<Sha256Hash> inputReferences = getTransaction().getInputs().stream().map(input -> input.getOutpoint().getHash()).collect(Collectors.toSet());
Map<Sha256Hash, BlockTransaction> inputTransactions = new HashMap<>();
for(Wallet wallet : AppServices.get().getOpenWallets().keySet()) {
Map<Sha256Hash, BlockTransaction> walletTransactions = wallet.getWalletTransactions();
if(blockTx == null && walletTransactions.get(getTransaction().getTxId()) != null) {
blockTx = walletTransactions.get(getTransaction().getTxId());
}
for(Sha256Hash inputReference : inputReferences) {
if(inputTransactions.get(inputReference) == null && walletTransactions.get(inputReference) != null) {
inputTransactions.put(inputReference, walletTransactions.get(inputReference));
}
}
}
if(inputTransactions.size() == inputReferences.size()) {
EventManager.get().post(new BlockTransactionFetchedEvent(getTransaction(), blockTx, inputTransactions, 0, getTransaction().getInputs().size()));
} else {
EventManager.get().post(new BlockTransactionFetchedEvent(getTransaction(), blockTx, Collections.emptyMap(), 0, getTransaction().getInputs().size()));
}
}
}
@@ -428,7 +463,7 @@ public class TransactionController implements Initializable {
@Subscribe
public void viewTransaction(ViewTransactionEvent event) {
if(txdata.getTransaction().getTxId().equals(event.getBlockTransaction().getTransaction().getTxId())) {
if(txdata.getTransaction().getTxId().equals(event.getTransaction().getTxId())) {
TreeItem<TransactionForm> existingItem = getTreeItem(event.getInitialView(), event.getInitialIndex());
if(existingItem != null && !(existingItem.getValue() instanceof PageForm)) {
setTreeSelection(event.getInitialView(), event.getInitialIndex());
@@ -496,7 +531,7 @@ public class TransactionController implements Initializable {
@Subscribe
public void blockTransactionFetched(BlockTransactionFetchedEvent event) {
if(event.getTxId().equals(getTransaction().getTxId())) {
if(event.getTxId().equals(getTransaction().getTxId()) && !event.getInputTransactions().isEmpty()) {
if(event.getBlockTransaction() != null && (!Sha256Hash.ZERO_HASH.equals(event.getBlockTransaction().getBlockHash()) || txdata.getBlockTransaction() == null)) {
txdata.setBlockTransaction(event.getBlockTransaction());
}
@@ -543,4 +578,18 @@ public class TransactionController implements Initializable {
}
}
}
@Subscribe
public void newConnection(ConnectionEvent event) {
if(!transactionsFetched) {
fetchTransactions();
}
}
@Subscribe
public void openWallets(OpenWalletsEvent event) {
if(!transactionsFetched) {
fetchTransactions();
}
}
}
@@ -9,9 +9,11 @@ import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.control.AddressTreeTable;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.paynym.PayNymAddressesDialog;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.fxml.Initializable;
import javafx.scene.control.Button;
import javafx.stage.FileChooser;
import javafx.stage.Stage;
import org.slf4j.Logger;
@@ -34,6 +36,9 @@ public class AddressesController extends WalletFormController implements Initial
@FXML
private AddressTreeTable changeTable;
@FXML
private Button showPayNymAddresses;
@Override
public void initialize(URL location, ResourceBundle resources) {
EventManager.get().register(this);
@@ -43,6 +48,9 @@ public class AddressesController extends WalletFormController implements Initial
public void initializeView() {
receiveTable.initialize(getWalletForm().getNodeEntry(KeyPurpose.RECEIVE));
changeTable.initialize(getWalletForm().getNodeEntry(KeyPurpose.CHANGE));
showPayNymAddresses.managedProperty().bind(showPayNymAddresses.visibleProperty());
showPayNymAddresses.setVisible(getWalletForm().getWallet().getChildWallets().stream().anyMatch(Wallet::isBip47));
}
@Subscribe
@@ -110,6 +118,13 @@ public class AddressesController extends WalletFormController implements Initial
}
}
@Subscribe
public void childWalletsAdded(ChildWalletsAddedEvent event) {
if(event.getWallet().equals(getWalletForm().getWallet())) {
showPayNymAddresses.setVisible(getWalletForm().getWallet().getChildWallets().stream().anyMatch(Wallet::isBip47));
}
}
public void exportReceiveAddresses(ActionEvent event) {
exportAddresses(KeyPurpose.RECEIVE);
}
@@ -151,4 +166,9 @@ public class AddressesController extends WalletFormController implements Initial
}
}
}
public void showPayNymAddresses(ActionEvent event) {
PayNymAddressesDialog payNymAddressesDialog = new PayNymAddressesDialog(getWalletForm());
payNymAddressesDialog.showAndWait();
}
}
@@ -3,6 +3,7 @@ package com.sparrowwallet.sparrow.wallet;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.control.DateStringConverter;
import com.sparrowwallet.sparrow.control.IntegerSpinner;
import com.sparrowwallet.sparrow.event.SettingsChangedEvent;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
@@ -10,8 +11,6 @@ import javafx.fxml.FXML;
import javafx.fxml.Initializable;
import javafx.scene.control.ComboBox;
import javafx.scene.control.DatePicker;
import javafx.scene.control.Spinner;
import javafx.scene.control.SpinnerValueFactory;
import javafx.util.StringConverter;
import java.net.URL;
@@ -28,7 +27,7 @@ public class AdvancedController implements Initializable {
private DatePicker birthDate;
@FXML
private Spinner<Integer> gapLimit;
private IntegerSpinner gapLimit;
@FXML
private ComboBox<Integer> watchLast;
@@ -50,8 +49,12 @@ public class AdvancedController implements Initializable {
}
});
gapLimit.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(Wallet.DEFAULT_LOOKAHEAD, 9999, wallet.getGapLimit()));
gapLimit.setValueFactory(new IntegerSpinner.ValueFactory(Wallet.DEFAULT_LOOKAHEAD, 9999, wallet.getGapLimit()));
gapLimit.valueProperty().addListener((observable, oldValue, newValue) -> {
if(newValue == null || newValue < Wallet.DEFAULT_LOOKAHEAD || newValue > 9999) {
return;
}
wallet.setGapLimit(newValue);
if(!watchLast.getItems().equals(getWatchListItems(wallet))) {
Integer value = watchLast.getValue();
@@ -2,10 +2,7 @@ package com.sparrowwallet.sparrow.wallet;
import com.google.common.eventbus.Subscribe;
import com.sparrowwallet.drongo.*;
import com.sparrowwallet.drongo.wallet.Keystore;
import com.sparrowwallet.drongo.wallet.KeystoreSource;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletModel;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.control.*;
@@ -413,6 +410,18 @@ public class KeystoreController extends WalletFormController implements Initiali
}
AppServices.showErrorDialog("Missing Script Type", "QR Code did not contain any information for the " + getWalletForm().getWallet().getScriptType().getDescription() + " script type.");
} else if(result.seed != null) {
try {
Keystore keystore = Keystore.fromSeed(result.seed, getWalletForm().getWallet().getScriptType().getDefaultDerivation());
fingerprint.setText(keystore.getKeyDerivation().getMasterFingerprint());
derivation.setText(keystore.getKeyDerivation().getDerivationPath());
xpub.setText(keystore.getExtendedPublicKey().toString());
} catch(MnemonicException e) {
log.error("Error parsing seed", e);
AppServices.showErrorDialog("Error parsing seed", e.getMessage());
} finally {
result.seed.clear();
}
} else if(result.exception != null) {
log.error("Error scanning QR", result.exception);
AppServices.showErrorDialog("Error scanning QR", result.exception.getMessage());
@@ -7,6 +7,7 @@ import com.sparrowwallet.drongo.wallet.StandardAccount;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.control.IntegerSpinner;
import com.sparrowwallet.sparrow.event.MixToConfigChangedEvent;
import com.sparrowwallet.sparrow.whirlpool.Whirlpool;
import javafx.collections.FXCollections;
@@ -31,7 +32,7 @@ public class MixToController implements Initializable {
private ComboBox<Wallet> mixToWallets;
@FXML
private Spinner<Integer> minMixes;
private IntegerSpinner minMixes;
@FXML
private ComboBox<IndexRange> indexRange;
@@ -94,8 +95,12 @@ public class MixToController implements Initializable {
});
int initialMinMixes = mixConfig.getMinMixes() == null ? Whirlpool.DEFAULT_MIXTO_MIN_MIXES : mixConfig.getMinMixes();
minMixes.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(1, 10000, initialMinMixes));
minMixes.setValueFactory(new IntegerSpinner.ValueFactory(1, 10000, initialMinMixes));
minMixes.valueProperty().addListener((observable, oldValue, newValue) -> {
if(newValue == null || newValue < 1 || newValue > 10000) {
return;
}
mixConfig.setMinMixes(newValue);
EventManager.get().post(new MixToConfigChangedEvent(wallet));
});
@@ -18,10 +18,8 @@ import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.CurrencyRate;
import com.sparrowwallet.sparrow.EventManager;
import com.sparrowwallet.sparrow.control.*;
import com.sparrowwallet.sparrow.event.BitcoinUnitChangedEvent;
import com.sparrowwallet.sparrow.event.ExchangeRatesUpdatedEvent;
import com.sparrowwallet.sparrow.event.FiatCurrencySelectedEvent;
import com.sparrowwallet.sparrow.event.OpenWalletsEvent;
import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.net.ExchangeSource;
import com.sparrowwallet.sparrow.paynym.PayNym;
@@ -41,7 +39,7 @@ import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.fxml.Initializable;
import javafx.scene.control.*;
import javafx.util.StringConverter;
import org.controlsfx.glyphfont.Glyph;
import org.controlsfx.validation.ValidationResult;
import org.controlsfx.validation.ValidationSupport;
import org.controlsfx.validation.Validator;
@@ -59,6 +57,8 @@ import static com.sparrowwallet.sparrow.AppServices.showErrorDialog;
public class PaymentController extends WalletFormController implements Initializable {
private static final Logger log = LoggerFactory.getLogger(PaymentController.class);
public static final long MINIMUM_P2PKH_OUTPUT_SATS = 546L;
private SendController sendController;
private ValidationSupport validationSupport;
@@ -115,7 +115,7 @@ public class PaymentController extends WalletFormController implements Initializ
Long recipientValueSats = getRecipientValueSats();
if(recipientValueSats != null) {
setFiatAmount(AppServices.getFiatCurrencyExchangeRate(), recipientValueSats);
dustAmountProperty.set(recipientValueSats <= getRecipientDustThreshold());
dustAmountProperty.set(recipientValueSats < getRecipientDustThreshold());
emptyAmountProperty.set(false);
} else {
fiatAmount.setText("");
@@ -134,7 +134,7 @@ public class PaymentController extends WalletFormController implements Initializ
private static final Wallet payNymWallet = new Wallet() {
@Override
public String getFullDisplayName() {
return "PayNym...";
return "PayNym or Payment code...";
}
};
@@ -150,30 +150,14 @@ public class PaymentController extends WalletFormController implements Initializ
@Override
public void initializeView() {
openWallets.setConverter(new StringConverter<>() {
@Override
public String toString(Wallet wallet) {
return wallet == null ? "" : wallet.getFullDisplayName() + (wallet == sendController.getWalletForm().getWallet() ? " (Consolidation)" : "");
}
@Override
public Wallet fromString(String string) {
return null;
}
});
updateOpenWallets();
openWallets.prefWidthProperty().bind(address.widthProperty());
openWallets.valueProperty().addListener((observable, oldValue, newValue) -> {
if(newValue == payNymWallet) {
boolean selectLinkedOnly = sendController.getPaymentTabs().getTabs().size() > 1 || !SorobanServices.canWalletMix(sendController.getWalletForm().getWallet());
PayNymDialog payNymDialog = new PayNymDialog(sendController.getWalletForm().getWalletId(), true, selectLinkedOnly);
PayNymDialog payNymDialog = new PayNymDialog(sendController.getWalletForm().getWalletId(), PayNymDialog.Operation.SEND, selectLinkedOnly);
Optional<PayNym> optPayNym = payNymDialog.showAndWait();
if(optPayNym.isPresent()) {
PayNym payNym = optPayNym.get();
payNymProperty.set(payNym);
address.setText(payNym.nymName());
label.requestFocus();
}
optPayNym.ifPresent(this::setPayNym);
} else if(newValue != null) {
WalletNode freshNode = newValue.getFreshNode(KeyPurpose.RECEIVE);
Address freshAddress = freshNode.getAddress();
@@ -181,6 +165,19 @@ public class PaymentController extends WalletFormController implements Initializ
label.requestFocus();
}
});
openWallets.setCellFactory(c -> new ListCell<>() {
@Override
protected void updateItem(Wallet wallet, boolean empty) {
super.updateItem(wallet, empty);
if(empty || wallet == null) {
setText(null);
setGraphic(null);
} else {
setText(wallet.getFullDisplayName() + (wallet == sendController.getWalletForm().getWallet() ? " (Consolidation)" : ""));
setGraphic(wallet == payNymWallet ? getPayNymGlyph() : null);
}
}
});
payNymProperty.addListener((observable, oldValue, payNym) -> {
updateMixOnlyStatus(payNym);
@@ -188,6 +185,8 @@ public class PaymentController extends WalletFormController implements Initializ
});
address.textProperty().addListener((observable, oldValue, newValue) -> {
address.leftProperty().set(null);
if(payNymProperty.get() != null && !newValue.equals(payNymProperty.get().nymName())) {
payNymProperty.set(null);
}
@@ -200,6 +199,32 @@ public class PaymentController extends WalletFormController implements Initializ
//ignore, not a URI
}
if(sendController.getWalletForm().getWallet().hasPaymentCode()) {
try {
PaymentCode paymentCode = new PaymentCode(newValue);
Wallet recipientBip47Wallet = sendController.getWalletForm().getWallet().getChildWallet(paymentCode, sendController.getWalletForm().getWallet().getScriptType());
if(recipientBip47Wallet == null && sendController.getWalletForm().getWallet().getScriptType() != ScriptType.P2PKH) {
recipientBip47Wallet = sendController.getWalletForm().getWallet().getChildWallet(paymentCode, ScriptType.P2PKH);
}
if(recipientBip47Wallet != null) {
PayNym payNym = PayNym.fromWallet(recipientBip47Wallet);
Platform.runLater(() -> setPayNym(payNym));
} else if(!paymentCode.equals(sendController.getWalletForm().getWallet().getPaymentCode())) {
ButtonType previewType = new ButtonType("Preview Transaction", ButtonBar.ButtonData.YES);
Optional<ButtonType> optButton = AppServices.showAlertDialog("Send notification transaction?", "This payment code is not yet linked with a notification transaction. Send a notification transaction?", Alert.AlertType.CONFIRMATION, ButtonType.CANCEL, previewType);
if(optButton.isPresent() && optButton.get() == previewType) {
Payment payment = new Payment(paymentCode.getNotificationAddress(), "Link " + paymentCode.toAbbreviatedString(), MINIMUM_P2PKH_OUTPUT_SATS, false);
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(sendController.getWalletForm().getWallet(), List.of(payment), List.of(new byte[80]), paymentCode)));
} else {
Platform.runLater(() -> address.setText(""));
}
}
} catch(Exception e) {
//ignore, not a payment code
}
}
revalidateAmount();
maxButton.setDisable(!isMaxButtonEnabled());
sendController.updateTransaction();
@@ -253,6 +278,17 @@ public class PaymentController extends WalletFormController implements Initializ
addValidation(validationSupport);
}
public void setPayNym(PayNym payNym) {
PayNym existingPayNym = payNymProperty.get();
payNymProperty.set(payNym);
address.setText(payNym.nymName());
address.leftProperty().set(getPayNymGlyph());
label.requestFocus();
if(existingPayNym != null && payNym.nymName().equals(existingPayNym.nymName()) && payNym.isCollaborativeSend() != existingPayNym.isCollaborativeSend()) {
sendController.updateTransaction();
}
}
public void updateMixOnlyStatus() {
updateMixOnlyStatus(payNymProperty.get());
}
@@ -276,7 +312,9 @@ public class PaymentController extends WalletFormController implements Initializ
}
private void updateOpenWallets(Collection<Wallet> wallets) {
List<Wallet> openWalletList = wallets.stream().filter(wallet -> wallet.isValid() && !wallet.isWhirlpoolChildWallet() && !wallet.isBip47()).collect(Collectors.toList());
List<Wallet> openWalletList = wallets.stream().filter(wallet -> wallet.isValid()
&& (wallet == sendController.getWalletForm().getWallet() || !wallet.isWhirlpoolChildWallet())
&& !wallet.isBip47()).collect(Collectors.toList());
if(sendController.getWalletForm().getWallet().hasPaymentCode()) {
openWalletList.add(payNymWallet);
@@ -296,7 +334,7 @@ public class PaymentController extends WalletFormController implements Initializ
));
validationSupport.registerValidator(amount, Validator.combine(
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Insufficient Inputs", getRecipientValueSats() != null && sendController.isInsufficientInputs()),
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Insufficient Value", getRecipientValueSats() != null && getRecipientValueSats() <= getRecipientDustThreshold())
(Control c, String newValue) -> ValidationResult.fromErrorIf( c, "Insufficient Value", getRecipientValueSats() != null && getRecipientValueSats() < getRecipientDustThreshold())
));
}
@@ -318,25 +356,28 @@ public class PaymentController extends WalletFormController implements Initializ
}
private Address getRecipientAddress() throws InvalidAddressException {
if(payNymProperty.get() == null) {
PayNym payNym = payNymProperty.get();
if(payNym == null) {
return Address.fromString(address.getText());
}
try {
Wallet recipientBip47Wallet = getWalletForPayNym(payNymProperty.get());
if(recipientBip47Wallet != null) {
WalletNode sendNode = recipientBip47Wallet.getFreshNode(KeyPurpose.SEND);
ECKey pubKey = sendNode.getPubKey();
Address address = recipientBip47Wallet.getScriptType().getAddress(pubKey);
if(sendController.getPaymentTabs().getTabs().size() > 1 || (getRecipientValueSats() != null && getRecipientValueSats() > getRecipientDustThreshold(address))) {
return address;
if(!payNym.isCollaborativeSend()) {
try {
Wallet recipientBip47Wallet = getWalletForPayNym(payNym);
if(recipientBip47Wallet != null) {
WalletNode sendNode = recipientBip47Wallet.getFreshNode(KeyPurpose.SEND);
ECKey pubKey = sendNode.getPubKey();
Address address = recipientBip47Wallet.getScriptType().getAddress(pubKey);
if(sendController.getPaymentTabs().getTabs().size() > 1 || (getRecipientValueSats() != null && getRecipientValueSats() > getRecipientDustThreshold(address)) || maxButton.isSelected()) {
return address;
}
}
} catch(InvalidPaymentCodeException e) {
log.error("Error creating payment code from PayNym", e);
}
} catch(InvalidPaymentCodeException e) {
log.error("Error creating payment code from PayNym", e);
}
return new PayNymAddress(payNymProperty.get());
return new PayNymAddress(payNym);
}
private Wallet getWalletForPayNym(PayNym payNym) throws InvalidPaymentCodeException {
@@ -394,7 +435,7 @@ public class PaymentController extends WalletFormController implements Initializ
public void revalidateAmount() {
revalidate(amount, amountListener);
Long recipientValueSats = getRecipientValueSats();
dustAmountProperty.set(recipientValueSats != null && recipientValueSats <= getRecipientDustThreshold());
dustAmountProperty.set(recipientValueSats != null && recipientValueSats < getRecipientDustThreshold());
emptyAmountProperty.set(recipientValueSats == null);
}
@@ -426,7 +467,7 @@ public class PaymentController extends WalletFormController implements Initializ
Address recipientAddress = getRecipientAddress();
Long value = sendAll ? Long.valueOf(getRecipientDustThreshold() + 1) : getRecipientValueSats();
if(!label.getText().isEmpty() && value != null && value > getRecipientDustThreshold()) {
if(!label.getText().isEmpty() && value != null && value >= getRecipientDustThreshold()) {
Payment payment = new Payment(recipientAddress, label.getText(), value, sendAll);
if(address.getUserData() != null) {
payment.setType((Payment.Type)address.getUserData());
@@ -458,6 +499,12 @@ public class PaymentController extends WalletFormController implements Initializ
}
public void clear() {
try {
AppServices.clearPayjoinURI(getRecipientAddress());
} catch(InvalidAddressException e) {
//ignore
}
address.setText("");
label.setText("");
@@ -509,6 +556,8 @@ public class PaymentController extends WalletFormController implements Initializ
QRScanDialog.Result result = optionalResult.get();
if(result.uri != null) {
updateFromURI(result.uri);
} else if(result.payload != null) {
address.setText(result.payload);
} else if(result.exception != null) {
log.error("Error scanning QR", result.exception);
showErrorDialog("Error scanning QR", result.exception.getMessage());
@@ -556,6 +605,14 @@ public class PaymentController extends WalletFormController implements Initializ
amountUnit.setDisable(disable);
scanQrButton.setDisable(disable);
addPaymentButton.setDisable(disable);
maxButton.setDisable(disable);
}
public static Glyph getPayNymGlyph() {
Glyph payNymGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.ROBOT);
payNymGlyph.getStyleClass().add("paynym-icon");
payNymGlyph.setFontSize(12);
return payNymGlyph;
}
@Subscribe
@@ -4,10 +4,16 @@ import com.google.common.eventbus.Subscribe;
import com.samourai.whirlpool.client.whirlpool.beans.Pool;
import com.sparrowwallet.drongo.BitcoinUnit;
import com.sparrowwallet.drongo.KeyPurpose;
import com.sparrowwallet.drongo.SecureString;
import com.sparrowwallet.drongo.address.Address;
import com.sparrowwallet.drongo.address.InvalidAddressException;
import com.sparrowwallet.drongo.bip47.PaymentCode;
import com.sparrowwallet.drongo.bip47.SecretPoint;
import com.sparrowwallet.drongo.crypto.ECKey;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.protocol.Sha256Hash;
import com.sparrowwallet.drongo.protocol.Transaction;
import com.sparrowwallet.drongo.protocol.TransactionOutPoint;
import com.sparrowwallet.drongo.psbt.PSBT;
import com.sparrowwallet.drongo.wallet.*;
import com.sparrowwallet.sparrow.AppServices;
@@ -19,6 +25,7 @@ import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.Storage;
import com.sparrowwallet.sparrow.net.*;
import com.sparrowwallet.sparrow.paynym.PayNym;
import com.sparrowwallet.sparrow.soroban.InitiatorDialog;
import com.sparrowwallet.sparrow.paynym.PayNymAddress;
import com.sparrowwallet.sparrow.soroban.SorobanServices;
@@ -26,6 +33,7 @@ import com.sparrowwallet.sparrow.whirlpool.Whirlpool;
import javafx.animation.KeyFrame;
import javafx.animation.Timeline;
import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.beans.property.*;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
@@ -143,6 +151,9 @@ public class SendController extends WalletFormController implements Initializabl
@FXML
private Button premixButton;
@FXML
private Button notificationButton;
private StackPane tabHeader;
private final BooleanProperty userFeeSet = new SimpleBooleanProperty(false);
@@ -153,6 +164,8 @@ public class SendController extends WalletFormController implements Initializabl
private final ObjectProperty<Pool> whirlpoolProperty = new SimpleObjectProperty<>(null);
private final ObjectProperty<PaymentCode> paymentCodeProperty = new SimpleObjectProperty<>(null);
private final ObjectProperty<WalletTransaction> walletTransactionProperty = new SimpleObjectProperty<>(null);
private final BooleanProperty insufficientInputsProperty = new SimpleBooleanProperty(false);
@@ -218,8 +231,9 @@ public class SendController extends WalletFormController implements Initializabl
}
};
private final ChangeListener<Boolean> premixButtonOnlineListener = (observable, oldValue, newValue) -> {
private final ChangeListener<Boolean> broadcastButtonsOnlineListener = (observable, oldValue, newValue) -> {
premixButton.setDisable(!newValue);
notificationButton.setDisable(walletTransactionProperty.get() == null || isInsufficientFeeRate() || !newValue);
};
private ValidationSupport validationSupport;
@@ -386,6 +400,7 @@ public class SendController extends WalletFormController implements Initializabl
if(userFeeSet.get()) {
setTargetBlocks(getTargetBlocks(feeRate));
setFeeRangeRate(feeRate);
revalidate(fee, feeListener);
} else {
setFeeValueSats(walletTransaction.getFee());
}
@@ -397,6 +412,7 @@ public class SendController extends WalletFormController implements Initializabl
transactionDiagram.update(walletTransaction);
updatePrivacyAnalysis(walletTransaction);
createButton.setDisable(walletTransaction == null || isInsufficientFeeRate() || isPayNymMixOnlyPayment(walletTransaction.getPayments()));
notificationButton.setDisable(walletTransaction == null || isInsufficientFeeRate() || !AppServices.isConnected());
});
transactionDiagram.sceneProperty().addListener((observable, oldScene, newScene) -> {
@@ -430,9 +446,11 @@ public class SendController extends WalletFormController implements Initializabl
createButton.managedProperty().bind(createButton.visibleProperty());
premixButton.managedProperty().bind(premixButton.visibleProperty());
createButton.visibleProperty().bind(premixButton.visibleProperty().not());
notificationButton.managedProperty().bind(notificationButton.visibleProperty());
createButton.visibleProperty().bind(Bindings.and(premixButton.visibleProperty().not(), notificationButton.visibleProperty().not()));
premixButton.setVisible(false);
AppServices.onlineProperty().addListener(new WeakChangeListener<>(premixButtonOnlineListener));
notificationButton.setVisible(false);
AppServices.onlineProperty().addListener(new WeakChangeListener<>(broadcastButtonsOnlineListener));
}
private void initializeTabHeader(int count) {
@@ -582,6 +600,7 @@ public class SendController extends WalletFormController implements Initializabl
if(currentWalletTransactionService.isRunning()) {
transactionDiagram.update("Selecting UTXOs...");
createButton.setDisable(true);
notificationButton.setDisable(true);
}
});
final Timeline timeline = new Timeline(delay);
@@ -967,7 +986,8 @@ public class SendController extends WalletFormController implements Initializabl
private boolean isMixPossible(List<Payment> payments) {
return (utxoSelectorProperty.get() == null || SorobanServices.canWalletMix(walletForm.getWallet()))
&& payments.size() == 1
&& (payments.get(0).getAddress().getScriptType() == getWalletForm().getWallet().getFreshNode(KeyPurpose.RECEIVE).getAddress().getScriptType());
&& (payments.get(0).getAddress().getScriptType() == getWalletForm().getWallet().getFreshNode(KeyPurpose.RECEIVE).getAddress().getScriptType())
&& AppServices.getPayjoinURI(payments.get(0).getAddress()) == null;
}
private void updateOptimizationButtons(List<Payment> payments) {
@@ -1047,13 +1067,17 @@ public class SendController extends WalletFormController implements Initializabl
validationSupport.setErrorDecorationEnabled(false);
setInputFieldsDisabled(false);
setInputFieldsDisabled(false, false);
efficiencyToggle.setDisable(false);
privacyToggle.setDisable(false);
premixButton.setVisible(false);
notificationButton.setVisible(false);
createButton.setDefaultButton(true);
whirlpoolProperty.set(null);
paymentCodeProperty.set(null);
}
public UtxoSelector getUtxoSelector() {
@@ -1181,18 +1205,182 @@ public class SendController extends WalletFormController implements Initializabl
tx0BroadcastService.start();
}
private void setInputFieldsDisabled(boolean disable) {
public void broadcastNotification(ActionEvent event) {
Wallet wallet = getWalletForm().getWallet();
Storage storage = AppServices.get().getOpenWallets().get(wallet);
if(wallet.isEncrypted()) {
WalletPasswordDialog dlg = new WalletPasswordDialog(wallet.getMasterName(), WalletPasswordDialog.PasswordRequirement.LOAD);
Optional<SecureString> password = dlg.showAndWait();
if(password.isPresent()) {
Storage.DecryptWalletService decryptWalletService = new Storage.DecryptWalletService(wallet.copy(), password.get());
decryptWalletService.setOnSucceeded(workerStateEvent -> {
EventManager.get().post(new StorageEvent(storage.getWalletId(wallet), TimedEvent.Action.END, "Done"));
Wallet decryptedWallet = decryptWalletService.getValue();
broadcastNotification(decryptedWallet);
decryptedWallet.clearPrivate();
});
decryptWalletService.setOnFailed(workerStateEvent -> {
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.getWalletId(wallet), TimedEvent.Action.START, "Decrypting wallet..."));
decryptWalletService.start();
}
} else {
broadcastNotification(wallet);
}
}
public void broadcastNotification(Wallet decryptedWallet) {
try {
PaymentCode paymentCode = decryptedWallet.getPaymentCode();
PaymentCode externalPaymentCode = paymentCodeProperty.get();
WalletTransaction walletTransaction = walletTransactionProperty.get();
WalletNode input0Node = walletTransaction.getSelectedUtxos().entrySet().iterator().next().getValue();
Keystore keystore = input0Node.getWallet().isNested() ? decryptedWallet.getChildWallet(input0Node.getWallet().getName()).getKeystores().get(0) : decryptedWallet.getKeystores().get(0);
ECKey input0Key = keystore.getKey(input0Node);
TransactionOutPoint input0Outpoint = walletTransaction.getTransaction().getInputs().iterator().next().getOutpoint();
SecretPoint secretPoint = new SecretPoint(input0Key.getPrivKeyBytes(), externalPaymentCode.getNotificationKey().getPubKey());
byte[] blindingMask = PaymentCode.getMask(secretPoint.ECDHSecretAsBytes(), input0Outpoint.bitcoinSerialize());
byte[] blindedPaymentCode = PaymentCode.blind(paymentCode.getPayload(), blindingMask);
List<UtxoSelector> utxoSelectors = List.of(new PresetUtxoSelector(walletTransaction.getSelectedUtxos().keySet(), true));
Long userFee = userFeeSet.get() ? getFeeValueSats() : null;
double feeRate = getUserFeeRate();
Integer currentBlockHeight = AppServices.getCurrentBlockHeight();
boolean groupByAddress = Config.get().isGroupByAddress();
boolean includeMempoolOutputs = Config.get().isIncludeMempoolOutputs();
boolean includeSpentMempoolOutputs = includeSpentMempoolOutputsProperty.get();
WalletTransaction finalWalletTx = decryptedWallet.createWalletTransaction(utxoSelectors, getUtxoFilters(), walletTransaction.getPayments(), List.of(blindedPaymentCode), excludedChangeNodes, feeRate, getMinimumFeeRate(), userFee, currentBlockHeight, groupByAddress, includeMempoolOutputs, includeSpentMempoolOutputs);
PSBT psbt = finalWalletTx.createPSBT();
decryptedWallet.sign(psbt);
decryptedWallet.finalise(psbt);
Transaction transaction = psbt.extractTransaction();
ServiceProgressDialog.ProxyWorker proxyWorker = new ServiceProgressDialog.ProxyWorker();
ElectrumServer.BroadcastTransactionService broadcastTransactionService = new ElectrumServer.BroadcastTransactionService(transaction);
broadcastTransactionService.setOnSucceeded(successEvent -> {
ElectrumServer.TransactionMempoolService transactionMempoolService = new ElectrumServer.TransactionMempoolService(walletTransaction.getWallet(), transaction.getTxId(), new HashSet<>(walletTransaction.getSelectedUtxos().values()));
transactionMempoolService.setDelay(Duration.seconds(2));
transactionMempoolService.setPeriod(Duration.seconds(5));
transactionMempoolService.setRestartOnFailure(false);
transactionMempoolService.setOnSucceeded(mempoolWorkerStateEvent -> {
Set<String> scriptHashes = transactionMempoolService.getValue();
if(!scriptHashes.isEmpty()) {
transactionMempoolService.cancel();
clear(null);
if(Config.get().isUsePayNym()) {
proxyWorker.setMessage("Finding PayNym...");
AppServices.getPayNymService().getPayNym(externalPaymentCode.toString()).subscribe(payNym -> {
proxyWorker.end();
addChildWallets(walletTransaction.getWallet(), externalPaymentCode, transaction, payNym);
}, error -> {
proxyWorker.end();
addChildWallets(walletTransaction.getWallet(), externalPaymentCode, transaction, null);
});
} else {
proxyWorker.end();
addChildWallets(walletTransaction.getWallet(), externalPaymentCode, transaction, null);
}
}
if(transactionMempoolService.getIterationCount() > 5 && transactionMempoolService.isRunning()) {
transactionMempoolService.cancel();
proxyWorker.end();
log.error("Timeout searching for broadcasted notification transaction");
AppServices.showErrorDialog("Timeout searching for broadcasted transaction", "The transaction was broadcast but the server did not register it in the mempool. It is safe to try broadcasting again.");
}
});
transactionMempoolService.setOnFailed(mempoolWorkerStateEvent -> {
transactionMempoolService.cancel();
proxyWorker.end();
log.error("Error searching for broadcasted notification transaction", mempoolWorkerStateEvent.getSource().getException());
AppServices.showErrorDialog("Timeout searching for broadcasted transaction", "The transaction was broadcast but the server did not register it in the mempool. It is safe to try broadcasting again.");
});
proxyWorker.setMessage("Receiving notification transaction...");
transactionMempoolService.start();
});
broadcastTransactionService.setOnFailed(failedEvent -> {
proxyWorker.end();
log.error("Error broadcasting notification transaction", failedEvent.getSource().getException());
AppServices.showErrorDialog("Error broadcasting notification transaction", failedEvent.getSource().getException().getMessage());
});
ServiceProgressDialog progressDialog = new ServiceProgressDialog("Broadcast", "Broadcast Notification Transaction", "/image/paynym.png", proxyWorker);
AppServices.moveToActiveWindowScreen(progressDialog);
proxyWorker.setMessage("Broadcasting notification transaction...");
proxyWorker.start();
broadcastTransactionService.start();
} catch(Exception e) {
log.error("Error creating notification transaction", e);
AppServices.showErrorDialog("Error creating notification transaction", e.getMessage());
}
}
private void addChildWallets(Wallet wallet, PaymentCode externalPaymentCode, Transaction transaction, PayNym payNym) {
List<Wallet> addedWallets = addChildWallets(externalPaymentCode, payNym);
Wallet masterWallet = getWalletForm().getMasterWallet();
Storage storage = AppServices.get().getOpenWallets().get(masterWallet);
EventManager.get().post(new ChildWalletsAddedEvent(storage, masterWallet, addedWallets));
BlockTransaction blockTransaction = wallet.getWalletTransaction(transaction.getTxId());
if(blockTransaction != null && blockTransaction.getLabel() == null) {
blockTransaction.setLabel("Link " + (payNym == null ? externalPaymentCode.toAbbreviatedString() : payNym.nymName()));
TransactionEntry transactionEntry = new TransactionEntry(wallet, blockTransaction, Collections.emptyMap(), Collections.emptyMap());
EventManager.get().post(new WalletEntryLabelsChangedEvent(wallet, List.of(transactionEntry)));
}
if(paymentTabs.getTabs().size() > 0 && !addedWallets.isEmpty()) {
Wallet addedWallet = addedWallets.stream().filter(w -> w.getScriptType() == ScriptType.P2WPKH).findFirst().orElse(addedWallets.iterator().next());
PaymentController controller = (PaymentController)paymentTabs.getTabs().get(0).getUserData();
controller.setPayNym(payNym == null ? PayNym.fromWallet(addedWallet) : payNym);
}
Glyph successGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.CHECK_CIRCLE);
successGlyph.getStyleClass().add("success");
successGlyph.setFontSize(50);
AppServices.showAlertDialog("Notification Successful", "The notification transaction was successfully sent for payment code " +
externalPaymentCode.toAbbreviatedString() + (payNym == null ? "" : " (" + payNym.nymName() + ")") +
".\n\nYou can send to it by entering the payment code, or selecting `PayNym or Payment code` in the Pay to dropdown.", Alert.AlertType.INFORMATION, successGlyph, ButtonType.OK);
}
public List<Wallet> addChildWallets(PaymentCode externalPaymentCode, PayNym payNym) {
List<Wallet> addedWallets = new ArrayList<>();
Wallet masterWallet = getWalletForm().getMasterWallet();
Storage storage = AppServices.get().getOpenWallets().get(masterWallet);
List<ScriptType> scriptTypes = PayNym.getSegwitScriptTypes();
for(ScriptType childScriptType : scriptTypes) {
String label = (payNym == null ? externalPaymentCode.toAbbreviatedString() : payNym.nymName()) + " " + childScriptType.getName();
Wallet addedWallet = masterWallet.addChildWallet(externalPaymentCode, childScriptType, label);
if(!storage.isPersisted(addedWallet)) {
try {
storage.saveWallet(addedWallet);
} catch(Exception e) {
log.error("Error saving wallet", e);
AppServices.showErrorDialog("Error saving wallet " + addedWallet.getName(), e.getMessage());
}
}
addedWallets.add(addedWallet);
}
return addedWallets;
}
private void setInputFieldsDisabled(boolean disablePayments, boolean disableFeeSelection) {
for(int i = 0; i < paymentTabs.getTabs().size(); i++) {
Tab tab = paymentTabs.getTabs().get(i);
tab.setClosable(!disable);
tab.setClosable(!disablePayments);
PaymentController controller = (PaymentController)tab.getUserData();
controller.setInputFieldsDisabled(disable);
feeRange.setDisable(disable);
targetBlocks.setDisable(disable);
fee.setDisable(disable);
feeAmountUnit.setDisable(disable);
controller.setInputFieldsDisabled(disablePayments);
}
feeRange.setDisable(disableFeeSelection);
targetBlocks.setDisable(disableFeeSelection);
fee.setDisable(disableFeeSelection);
feeAmountUnit.setDisable(disableFeeSelection);
transactionDiagram.requestFocus();
}
@Subscribe
@@ -1262,11 +1450,15 @@ public class SendController extends WalletFormController implements Initializabl
@Subscribe
public void spendUtxos(SpendUtxoEvent event) {
if(!event.getUtxos().isEmpty() && event.getWallet().equals(getWalletForm().getWallet())) {
if((event.getUtxos() == null || !event.getUtxos().isEmpty()) && event.getWallet().equals(getWalletForm().getWallet())) {
if(whirlpoolProperty.get() != null || paymentCodeProperty.get() != null) {
clear(null);
}
if(event.getPayments() != null) {
clear(null);
setPayments(event.getPayments());
} else if(paymentTabs.getTabs().size() == 1) {
} else if(paymentTabs.getTabs().size() == 1 && event.getUtxos() != null) {
Payment payment = new Payment(null, null, event.getUtxos().stream().mapToLong(BlockTransactionHashIndex::getValue).sum(), true);
setPayments(List.of(payment));
}
@@ -1282,16 +1474,25 @@ public class SendController extends WalletFormController implements Initializabl
includeSpentMempoolOutputsProperty.set(event.isIncludeSpentMempoolOutputs());
List<BlockTransactionHashIndex> utxos = event.getUtxos();
utxoSelectorProperty.set(new PresetUtxoSelector(utxos));
if(event.getUtxos() != null) {
List<BlockTransactionHashIndex> utxos = event.getUtxos();
utxoSelectorProperty.set(new PresetUtxoSelector(utxos));
}
utxoFilterProperty.set(null);
whirlpoolProperty.set(event.getPool());
paymentCodeProperty.set(event.getPaymentCode());
updateTransaction(event.getPayments() == null || event.getPayments().stream().anyMatch(Payment::isSendMax));
boolean isWhirlpoolPremix = (event.getPool() != null);
setInputFieldsDisabled(isWhirlpoolPremix);
premixButton.setVisible(isWhirlpoolPremix);
premixButton.setDefaultButton(isWhirlpoolPremix);
boolean isNotificationTransaction = (event.getPaymentCode() != null);
notificationButton.setVisible(isNotificationTransaction);
notificationButton.setDefaultButton(isNotificationTransaction);
setInputFieldsDisabled(isWhirlpoolPremix || isNotificationTransaction, isWhirlpoolPremix);
}
}
@@ -1434,6 +1635,7 @@ public class SendController extends WalletFormController implements Initializabl
boolean roundPaymentAmounts = userPayments.stream().anyMatch(payment -> payment.getAmount() % 100 == 0);
boolean mixedAddressTypes = userPayments.stream().anyMatch(payment -> payment.getAddress().getScriptType() != getWalletForm().getWallet().getFreshNode(KeyPurpose.RECEIVE).getAddress().getScriptType());
boolean addressReuse = userPayments.stream().anyMatch(payment -> walletAddresses.get(payment.getAddress()) != null && !walletAddresses.get(payment.getAddress()).getTransactionOutputs().isEmpty());
boolean payjoinPresent = userPayments.stream().anyMatch(payment -> AppServices.getPayjoinURI(payment.getAddress()) != null);
if(optimizationStrategy == OptimizationStrategy.PRIVACY) {
if(payNymPresent) {
@@ -1445,6 +1647,8 @@ public class SendController extends WalletFormController implements Initializabl
addLabel("Cannot coinjoin due to mixed address types", getInfoGlyph());
} else if(userPayments.size() > 1) {
addLabel("Cannot coinjoin due to multiple payments", getInfoGlyph());
} else if(payjoinPresent) {
addLabel("Cannot coinjoin due to payjoin", getInfoGlyph());
} else {
if(utxoSelectorProperty().get() != null) {
addLabel("Cannot fake coinjoin due to coin control", getInfoGlyph());
@@ -324,6 +324,7 @@ public class SettingsController extends WalletFormController implements Initiali
return;
}
OutputDescriptor outputDescriptor = OutputDescriptor.getOutputDescriptor(walletForm.getWallet(), KeyPurpose.DEFAULT_PURPOSES, null);
List<ScriptExpression> scriptExpressions = getScriptExpressions(walletForm.getWallet().getScriptType());
CryptoOutput cryptoOutput;
@@ -341,7 +342,7 @@ public class SettingsController extends WalletFormController implements Initiali
}
UR cryptoOutputUR = cryptoOutput.toUR();
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(cryptoOutputUR);
QRDisplayDialog qrDisplayDialog = new DescriptorQRDisplayDialog(walletForm.getWallet().getFullDisplayName(), outputDescriptor.toString(true), cryptoOutputUR);
qrDisplayDialog.showAndWait();
}
@@ -69,7 +69,13 @@ public class UtxoEntry extends HashIndexEntry {
private ObjectProperty<AddressStatus> addressStatusProperty;
public final void setDuplicateAddress(boolean value) {
addressStatusProperty().set(new AddressStatus(value));
AddressStatus addressStatus = addressStatusProperty().get();
addressStatusProperty().set(new AddressStatus(value, addressStatus.dustAttack));
}
public final void setDustAttack(boolean value) {
AddressStatus addressStatus = addressStatusProperty().get();
addressStatusProperty().set(new AddressStatus(addressStatus.duplicate, value));
}
public final boolean isDuplicateAddress() {
@@ -78,7 +84,7 @@ public class UtxoEntry extends HashIndexEntry {
public final ObjectProperty<AddressStatus> addressStatusProperty() {
if(addressStatusProperty == null) {
addressStatusProperty = new SimpleObjectProperty<>(UtxoEntry.this, "addressStatus", new AddressStatus(false));
addressStatusProperty = new SimpleObjectProperty<>(UtxoEntry.this, "addressStatus", new AddressStatus(false, false));
}
return addressStatusProperty;
@@ -86,9 +92,11 @@ public class UtxoEntry extends HashIndexEntry {
public class AddressStatus {
private final boolean duplicate;
private final boolean dustAttack;
public AddressStatus(boolean duplicate) {
public AddressStatus(boolean duplicate, boolean dustAttack) {
this.duplicate = duplicate;
this.dustAttack = dustAttack;
}
public UtxoEntry getUtxoEntry() {
@@ -102,6 +110,10 @@ public class UtxoEntry extends HashIndexEntry {
public boolean isDuplicate() {
return duplicate;
}
public boolean isDustAttack() {
return dustAttack;
}
}
/**
@@ -18,6 +18,7 @@ import com.sparrowwallet.sparrow.event.*;
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.io.Storage;
import com.sparrowwallet.sparrow.net.ExchangeSource;
import com.sparrowwallet.sparrow.whirlpool.Whirlpool;
import com.sparrowwallet.sparrow.whirlpool.WhirlpoolDialog;
import com.sparrowwallet.sparrow.whirlpool.WhirlpoolServices;
@@ -82,6 +83,12 @@ public class UtxosController extends WalletFormController implements Initializab
@FXML
private Button mixTo;
@FXML
private Button selectAll;
@FXML
private Button clear;
@FXML
private Button sendSelected;
@@ -171,6 +178,7 @@ public class UtxosController extends WalletFormController implements Initializab
}
}
clear.setDisable(true);
sendSelected.setDisable(true);
sendSelected.setTooltip(new Tooltip("Send selected UTXOs. Use " + (org.controlsfx.tools.Platform.getCurrent() == org.controlsfx.tools.Platform.OSX ? "Cmd" : "Ctrl") + "+click to select multiple." ));
mixSelected.managedProperty().bind(mixSelected.visibleProperty());
@@ -186,9 +194,10 @@ public class UtxosController extends WalletFormController implements Initializab
}
private void updateFields(WalletUtxosEntry walletUtxosEntry) {
balance.setValue(walletUtxosEntry.getChildren().stream().mapToLong(Entry::getValue).sum());
mempoolBalance.setValue(walletUtxosEntry.getChildren().stream().filter(entry -> ((UtxoEntry)entry).getHashIndex().getHeight() <= 0).mapToLong(Entry::getValue).sum());
balance.setValue(walletUtxosEntry.getBalance());
mempoolBalance.setValue(walletUtxosEntry.getMempoolBalance());
utxoCount.setText(walletUtxosEntry.getChildren() != null ? Integer.toString(walletUtxosEntry.getChildren().size()) : "0");
selectAll.setDisable(walletUtxosEntry.getChildren() == null || walletUtxosEntry.getChildren().size() == 0);
}
private boolean canWalletMix() {
@@ -198,6 +207,8 @@ public class UtxosController extends WalletFormController implements Initializab
private void updateButtons(BitcoinUnit unit) {
List<Entry> selectedEntries = getSelectedEntries();
selectAll.setDisable(utxosTable.getRoot().getChildren().size() == utxosTable.getSelectionModel().getSelectedCells().size());
clear.setDisable(selectedEntries.isEmpty());
sendSelected.setDisable(selectedEntries.isEmpty());
mixSelected.setDisable(selectedEntries.isEmpty() || !AppServices.isConnected());
@@ -368,6 +379,10 @@ public class UtxosController extends WalletFormController implements Initializab
.collect(Collectors.toList());
}
public void selectAll(ActionEvent event) {
utxosTable.getSelectionModel().selectAll();
}
public void clear(ActionEvent event) {
utxosTable.getSelectionModel().clearSelection();
}
@@ -586,6 +601,11 @@ public class UtxosController extends WalletFormController implements Initializab
Platform.runLater(this::updateMixToButton);
}
@Subscribe
public void walletLabelChanged(WalletLabelChangedEvent event) {
Platform.runLater(this::updateMixToButton);
}
@Subscribe
public void whirlpoolMix(WhirlpoolMixEvent event) {
if(event.getWallet().equals(walletForm.getWallet())) {
@@ -612,4 +632,20 @@ public class UtxosController extends WalletFormController implements Initializab
selectEntry(utxosTable, utxosTable.getRoot(), event.getEntry());
}
}
@Subscribe
public void fiatCurrencySelected(FiatCurrencySelectedEvent event) {
if(event.getExchangeSource() == ExchangeSource.NONE) {
fiatBalance.setCurrency(null);
fiatBalance.setBtcRate(0.0);
fiatMempoolBalance.setCurrency(null);
fiatMempoolBalance.setBtcRate(0.0);
}
}
@Subscribe
public void exchangeRatesUpdated(ExchangeRatesUpdatedEvent event) {
setFiatBalance(fiatBalance, event.getCurrencyRate(), getWalletForm().getWalletUtxosEntry().getBalance());
setFiatBalance(fiatMempoolBalance, event.getCurrencyRate(), getWalletForm().getWalletUtxosEntry().getMempoolBalance());
}
}
@@ -213,7 +213,7 @@ public class WalletForm {
private void updateWallets(Integer blockHeight, Wallet previousWallet) {
List<WalletNode> nestedHistoryChangedNodes = new ArrayList<>();
for(Wallet childWallet : wallet.getChildWallets()) {
for(Wallet childWallet : new ArrayList<>(wallet.getChildWallets())) {
if(childWallet.isNested()) {
Wallet previousChildWallet = previousWallet.getChildWallet(childWallet.getName());
if(previousChildWallet != null) {
@@ -259,12 +259,12 @@ public class WalletForm {
}
private List<WalletNode> getHistoryChangedNodes(Set<WalletNode> previousNodes, Set<WalletNode> currentNodes) {
Map<WalletNode, WalletNode> previousNodeMap = new HashMap<>(previousNodes.size());
previousNodes.forEach(walletNode -> previousNodeMap.put(walletNode, walletNode));
Map<String, WalletNode> previousNodeMap = new HashMap<>(previousNodes.size());
previousNodes.forEach(walletNode -> previousNodeMap.put(walletNode.getDerivationPath(), walletNode));
List<WalletNode> changedNodes = new ArrayList<>();
for(WalletNode currentNode : currentNodes) {
WalletNode previousNode = previousNodeMap.get(currentNode);
WalletNode previousNode = previousNodeMap.get(currentNode.getDerivationPath());
if(previousNode != null) {
if(!currentNode.getTransactionOutputs().equals(previousNode.getTransactionOutputs())) {
changedNodes.add(currentNode);
@@ -286,7 +286,7 @@ public class WalletForm {
return null;
}
Set<WalletNode> allNodes = new LinkedHashSet<>();
Set<WalletNode> allNodes = new LinkedHashSet<>(walletNodes);
for(WalletNode walletNode : walletNodes) {
for(Set<WalletNode> nodes : walletTransactionNodes) {
if(nodes.contains(walletNode)) {
@@ -377,6 +377,10 @@ public class WalletForm {
this.lockedProperty.set(locked);
}
public List<NodeEntry> getAccountEntries() {
return accountEntries;
}
@Subscribe
public void walletDataChanged(WalletDataChangedEvent event) {
if(event.getWallet().equals(wallet)) {
@@ -471,7 +475,7 @@ public class WalletForm {
}
if((receivedRef.getLabel() == null || receivedRef.getLabel().isEmpty()) && wallet.getStandardAccountType() != StandardAccount.WHIRLPOOL_PREMIX) {
receivedRef.setLabel(changedNode.getLabel() + (changedNode.getKeyPurpose() == KeyPurpose.CHANGE ? " (change)" : " (received)"));
receivedRef.setLabel(changedNode.getLabel() + (changedNode.getKeyPurpose() == KeyPurpose.CHANGE ? (changedNode.getWallet().isBip47() ? " (sent)" : " (change)") : " (received)"));
changedLabelEntries.add(new HashIndexEntry(event.getWallet(), receivedRef, HashIndexEntry.Type.OUTPUT, changedNode.getKeyPurpose()));
}
}
@@ -496,7 +500,7 @@ public class WalletForm {
for(BlockTransactionHashIndex receivedRef : childNode.getTransactionOutputs()) {
if(receivedRef.getHash().equals(transactionEntry.getBlockTransaction().getHash())) {
if((receivedRef.getLabel() == null || receivedRef.getLabel().isEmpty()) && wallet.getStandardAccountType() != StandardAccount.WHIRLPOOL_PREMIX) {
receivedRef.setLabel(entry.getLabel() + (keyPurpose == KeyPurpose.CHANGE ? " (change)" : " (received)"));
receivedRef.setLabel(entry.getLabel() + (keyPurpose == KeyPurpose.CHANGE ? (event.getWallet().isBip47() ? " (sent)" : " (change)") : " (received)"));
labelChangedEntries.put(new HashIndexEntry(event.getWallet(), receivedRef, HashIndexEntry.Type.OUTPUT, keyPurpose), entry);
}
if((childNode.getLabel() == null || childNode.getLabel().isEmpty())) {
@@ -2,16 +2,21 @@ package com.sparrowwallet.sparrow.wallet;
import com.samourai.whirlpool.client.wallet.beans.MixProgress;
import com.sparrowwallet.drongo.wallet.Wallet;
import com.sparrowwallet.drongo.wallet.WalletNode;
import com.sparrowwallet.sparrow.AppServices;
import com.sparrowwallet.sparrow.io.Config;
import com.sparrowwallet.sparrow.whirlpool.Whirlpool;
import java.util.*;
import java.util.stream.Collectors;
public class WalletUtxosEntry extends Entry {
public static final int DUST_ATTACK_THRESHOLD_SATS = 1000;
public WalletUtxosEntry(Wallet wallet) {
super(wallet, wallet.getName(), wallet.getWalletUtxos().entrySet().stream().map(entry -> new UtxoEntry(entry.getValue().getWallet(), entry.getKey(), HashIndexEntry.Type.OUTPUT, entry.getValue())).collect(Collectors.toList()));
calculateDuplicates();
calculateDust();
updateMixProgress();
}
@@ -48,6 +53,18 @@ public class WalletUtxosEntry extends Entry {
}
}
protected void calculateDust() {
long dustAttackThreshold = Config.get().getDustAttackThreshold();
Set<WalletNode> duplicateNodes = getWallet().getWalletTxos().values().stream()
.collect(Collectors.groupingBy(e -> e, Collectors.counting()))
.entrySet().stream().filter(e -> e.getValue() > 1).map(Map.Entry::getKey).collect(Collectors.toSet());
for(Entry entry : getChildren()) {
UtxoEntry utxoEntry = (UtxoEntry) entry;
utxoEntry.setDustAttack(utxoEntry.getValue() <= dustAttackThreshold && duplicateNodes.contains(utxoEntry.getNode()) && !utxoEntry.getWallet().allInputsFromWallet(utxoEntry.getHashIndex().getHash()));
}
}
public void updateMixProgress() {
Whirlpool whirlpool = AppServices.getWhirlpoolServices().getWhirlpool(getWallet());
if(whirlpool != null) {
@@ -74,6 +91,15 @@ public class WalletUtxosEntry extends Entry {
getChildren().removeAll(entriesRemoved);
calculateDuplicates();
calculateDust();
updateMixProgress();
}
public long getBalance() {
return getChildren().stream().mapToLong(Entry::getValue).sum();
}
public long getMempoolBalance() {
return getChildren().stream().filter(entry -> ((UtxoEntry)entry).getHashIndex().getHeight() <= 0).mapToLong(Entry::getValue).sum();
}
}
@@ -4,6 +4,7 @@ import com.google.common.eventbus.Subscribe;
import com.google.common.net.HostAndPort;
import com.samourai.whirlpool.client.wallet.WhirlpoolEventService;
import com.sparrowwallet.drongo.Network;
import com.sparrowwallet.drongo.protocol.ScriptType;
import com.sparrowwallet.drongo.wallet.DeterministicSeed;
import com.sparrowwallet.drongo.wallet.MixConfig;
import com.sparrowwallet.drongo.wallet.StandardAccount;
@@ -161,6 +162,7 @@ public class WhirlpoolServices {
public static boolean canWalletMix(Wallet wallet) {
return Whirlpool.WHIRLPOOL_NETWORKS.contains(Network.get())
&& wallet.getScriptType() != ScriptType.P2TR //Taproot not yet supported
&& wallet.getKeystores().size() == 1
&& wallet.getKeystores().get(0).hasSeed()
&& wallet.getKeystores().get(0).getSeed().getType() == DeterministicSeed.Type.BIP39
+1
View File
@@ -44,4 +44,5 @@ open module com.sparrowwallet.sparrow {
requires org.apache.commons.lang3;
requires net.sourceforge.streamsupport;
requires co.nstant.in.cbor;
requires com.github.librepdf.openpdf;
}
@@ -34,12 +34,19 @@
.master-only > .tab-header-area {
visibility: hidden;
-fx-pref-height: 0;
}
.wallet-subtabs > .tab-header-area .tab {
-fx-pref-height: 50;
-fx-pref-width: 90;
-fx-alignment: CENTER;
-fx-background-color: derive(#3da0e3, 32%);
-fx-background-insets: 0 1 0 1,0,0;
}
.wallet-subtabs > .tab-header-area .tab:selected {
-fx-background-color: #3da0e3;
}
.wallet-subtabs > .tab-header-area .tab-label {
@@ -49,6 +56,10 @@
-fx-translate-x: -6;
}
.wallet-subtabs > .tab-header-area .tab-label .label {
-fx-text-fill: #fff;
}
.status-bar .status-label {
-fx-alignment: center-left;
}
@@ -48,7 +48,8 @@
<SeparatorMenuItem styleClass="osxHide" />
<MenuItem styleClass="osxHide" mnemonicParsing="false" text="Preferences..." accelerator="Shortcut+P" onAction="#openPreferences"/>
<SeparatorMenuItem />
<MenuItem mnemonicParsing="false" text="Close Tab" accelerator="Shortcut+W" onAction="#closeTab"/>
<MenuItem fx:id="deleteWallet" mnemonicParsing="false" text="Delete Wallet..." onAction="#deleteWallet"/>
<MenuItem fx:id="closeTab" mnemonicParsing="false" text="Close Tab" accelerator="Shortcut+W" onAction="#closeTab"/>
<MenuItem styleClass="osxHide" mnemonicParsing="false" text="Quit" accelerator="Shortcut+Q" onAction="#quit"/>
</items>
</Menu>
@@ -102,7 +103,7 @@
<SeparatorMenuItem />
<MenuItem fx:id="minimizeToTray" mnemonicParsing="false" text="Minimize to System Tray" accelerator="Shortcut+Y" onAction="#minimizeToTray"/>
<MenuItem fx:id="lockWallet" mnemonicParsing="false" text="Lock Wallet" accelerator="Shortcut+L" onAction="#lockWallet"/>
<MenuItem mnemonicParsing="false" text="Lock All Wallets" accelerator="Shortcut+Shift+L" onAction="#lockWallets"/>
<MenuItem fx:id="lockAllWallets" mnemonicParsing="false" text="Lock All Wallets" accelerator="Shortcut+Shift+L" onAction="#lockWallets"/>
<SeparatorMenuItem />
<MenuItem fx:id="searchWallet" mnemonicParsing="false" text="Search Wallet" accelerator="Shortcut+Shift+S" onAction="#searchWallet"/>
<MenuItem fx:id="refreshWallet" mnemonicParsing="false" text="Refresh Wallet" accelerator="Shortcut+R" onAction="#refreshWallet"/>
@@ -118,6 +119,7 @@
<SeparatorMenuItem />
<MenuItem styleClass="osxHide,windowsHide" mnemonicParsing="false" text="Install Udev Rules" onAction="#installUdevRules"/>
<CheckMenuItem fx:id="preventSleep" mnemonicParsing="false" text="Prevent Computer Sleep" onAction="#preventSleep"/>
<MenuItem fx:id="restart" mnemonicParsing="false" text="Restart" onAction="#restart" />
</Menu>
<Menu fx:id="helpMenu" mnemonicParsing="false" text="Help">
<MenuItem mnemonicParsing="false" text="Show Introduction" onAction="#showIntroduction"/>
@@ -173,6 +173,10 @@
-fx-text-fill: #e06c75;
}
.root .failure.hyperlink:visited {
-fx-text-fill: #e06c75;
}
.root .titled-description-pane > .title {
-fx-background-color: derive(-fx-base, 10%);
-fx-padding: 0;
@@ -114,6 +114,10 @@
-fx-underline: false;
}
.failure.hyperlink:visited {
-fx-text-fill: rgb(202, 18, 67);
}
.hyperlink:hover:visited {
-fx-underline: true;
}
@@ -64,7 +64,7 @@
<Label text="Find Contact:" styleClass="field-label" />
<HBox spacing="10">
<CopyableTextField fx:id="searchPayNyms" promptText="PayNym or Payment code" styleClass="field-control"/>
<Button onAction="#scanQR">
<Button fx:id="searchPayNymsScan" onAction="#scanQR">
<graphic>
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="12" icon="CAMERA" />
</graphic>
@@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import java.lang.*?>
<?import java.util.*?>
<?import javafx.scene.*?>
<?import javafx.scene.control.*?>
<?import javafx.scene.layout.*?>
<?import javafx.geometry.Insets?>
<?import com.sparrowwallet.sparrow.control.AddressTreeTable?>
<?import org.controlsfx.glyphfont.Glyph?>
<?import tornadofx.control.Fieldset?>
<?import tornadofx.control.Form?>
<?import tornadofx.control.Field?>
<?import javafx.scene.image.ImageView?>
<?import javafx.scene.image.Image?>
<StackPane stylesheets="@../wallet/addresses.css, @../wallet/wallet.css, @paynym.css, @../general.css" styleClass="paynym-pane" xmlns="http://javafx.com/javafx/10.0.2-internal" xmlns:fx="http://javafx.com/fxml/1" fx:controller="com.sparrowwallet.sparrow.paynym.PayNymAddressesController">
<VBox>
<HBox styleClass="title-area">
<HBox alignment="CENTER_LEFT">
<Label text="PayNym Addresses" styleClass="title-label" />
</HBox>
<Region HBox.hgrow="ALWAYS"/>
<ImageView AnchorPane.rightAnchor="0">
<Image url="/image/paynym.png" requestedWidth="50" requestedHeight="50" smooth="false" />
</ImageView>
</HBox>
<BorderPane>
<padding>
<Insets left="25" right="25" bottom="25" />
</padding>
<center>
<VBox spacing="15">
<Form GridPane.columnIndex="0" GridPane.rowIndex="0">
<Fieldset inputGrow="SOMETIMES" text="" styleClass="header">
<Field text="PayNym:">
<ComboBox fx:id="payNymWalletForms" />
</Field>
</Fieldset>
</Form>
<BorderPane GridPane.columnIndex="0" GridPane.rowIndex="1">
<top>
<HBox alignment="CENTER_LEFT">
<Label styleClass="addresses-treetable-label" text="Receive Addresses"/>
</HBox>
</top>
<center>
<AddressTreeTable fx:id="receiveTable" maxHeight="160" styleClass="bip47" />
</center>
</BorderPane>
<BorderPane GridPane.columnIndex="0" GridPane.rowIndex="2">
<top>
<HBox alignment="CENTER_LEFT">
<Label styleClass="addresses-treetable-label" text="Send Addresses"/>
</HBox>
</top>
<center>
<AddressTreeTable fx:id="sendTable" maxHeight="160" styleClass="bip47" />
</center>
</BorderPane>
</VBox>
</center>
</BorderPane>
</VBox>
</StackPane>
@@ -48,7 +48,6 @@
<FXCollections fx:factory="observableArrayList">
<FeeRatesSource fx:constant="ELECTRUM_SERVER" />
<FeeRatesSource fx:constant="MEMPOOL_SPACE" />
<FeeRatesSource fx:constant="BITCOINFEES_EARN_COM" />
<FeeRatesSource fx:constant="MINIMUM" />
</FXCollections>
</items>
@@ -107,11 +107,11 @@
<HBox styleClass="field-box">
<Label text="Mix partner:" styleClass="field-label" />
<Label fx:id="mixingPartner" text="Waiting for mix partner..." styleClass="field-control" />
<Label fx:id="meetingFail" text="Failed to find mix partner." styleClass="failure" graphicTextGap="5">
<Hyperlink fx:id="meetingFail" text="Failed to find mix partner. Try again?" styleClass="failure" graphicTextGap="5">
<graphic>
<Glyph fontFamily="Font Awesome 5 Free Solid" fontSize="12" icon="EXCLAMATION_CIRCLE" styleClass="failure" />
</graphic>
</Label>
</Hyperlink>
</HBox>
<VBox fx:id="mixDetails" spacing="10">
<HBox styleClass="field-box">
@@ -17,6 +17,7 @@
.witness-count { -fx-fill: color-0 }
.witness-length { -fx-fill: color-6 }
.witness-data { -fx-fill: color-6 }
.witness-data-signature { -fx-fill: color-3 }
.locktime { -fx-fill: color-7 }
@@ -28,6 +28,7 @@
<?import javafx.scene.control.TabPane?>
<?import javafx.scene.control.Tab?>
<?import com.sparrowwallet.sparrow.control.TransactionDiagram?>
<?import com.sparrowwallet.sparrow.control.IntegerSpinner?>
<GridPane hgap="10.0" vgap="10.0" styleClass="tx-pane" xmlns="http://javafx.com/javafx/10.0.2-internal" xmlns:fx="http://javafx.com/fxml/1" fx:controller="com.sparrowwallet.sparrow.transaction.HeadersController" stylesheets="@headers.css, @transaction.css, @../general.css">
<padding>
@@ -71,7 +72,7 @@
<Form GridPane.columnIndex="0" GridPane.rowIndex="0">
<Fieldset text="Headers" inputGrow="SOMETIMES">
<Field text="Version:">
<Spinner fx:id="version" prefWidth="60" editable="true" />
<IntegerSpinner fx:id="version" prefWidth="60" editable="true" />
</Field>
<Field text="Type:">
<CopyableLabel fx:id="segwit" />
@@ -94,10 +95,10 @@
</SegmentedButton>
</Field>
<Field fx:id="locktimeNoneField" text="Block:">
<Spinner fx:id="locktimeNone" disable="true" prefWidth="120"/>
<IntegerSpinner fx:id="locktimeNone" disable="true" prefWidth="120"/>
</Field>
<Field fx:id="locktimeBlockField" text="Block:">
<Spinner fx:id="locktimeBlock" editable="true" max="499999999" prefWidth="120"/>
<IntegerSpinner fx:id="locktimeBlock" editable="true" max="499999999" prefWidth="120"/>
<Hyperlink fx:id="locktimeCurrentHeight" text="Set current height" onAction="#setLocktimeToCurrentHeight" />
<Label fx:id="futureBlockWarning">
<graphic>
@@ -21,6 +21,7 @@
.witness-count { -fx-fill: color-0 }
.witness-length { -fx-fill: color-7 }
.witness-data { -fx-fill: color-6 }
.witness-data-signature { -fx-fill: color-3 }
.locktime { -fx-fill: color-grey }

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