mirror of
https://github.com/sparrowwallet/sparrow.git
synced 2026-07-30 19:46:16 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95cb8c4b2c | ||
|
|
2712555c72 | ||
|
|
72066395d6 | ||
|
|
7b3e5f37b9 | ||
|
|
870a468584 | ||
|
|
0340cba441 | ||
|
|
91f845cbbf | ||
|
|
d0f21eafd1 | ||
|
|
36c2181a7f | ||
|
|
218e751333 | ||
|
|
995d2c5e4e | ||
|
|
0d7ae74f0f | ||
|
|
e6eea67c4b | ||
|
|
ea3e0ca34a | ||
|
|
3fedd8eb43 | ||
|
|
30408af719 | ||
|
|
910a400b18 | ||
|
|
f4ac18c3e1 | ||
|
|
cb06e1aaf7 | ||
|
|
d881e47ec9 | ||
|
|
dbafefb940 | ||
|
|
ee6589991d | ||
|
|
24578dcf88 | ||
|
|
ddae1a12d8 | ||
|
|
1c9b6c3eef | ||
|
|
158ecd4ab1 | ||
|
|
b6bcdef712 | ||
|
|
6eefd3f182 | ||
|
|
9280504f70 | ||
|
|
a6a671f687 | ||
|
|
4e3e8b7cc4 | ||
|
|
2b8fc3900a | ||
|
|
cff731dec7 | ||
|
|
c9c0c35964 | ||
|
|
afed5c65f5 | ||
|
|
0724c38582 | ||
|
|
31539a27ac | ||
|
|
201900aa0e | ||
|
|
3dcbe34485 | ||
|
|
31842cc0f2 | ||
|
|
f85349bd36 | ||
|
|
a18c24e19f | ||
|
|
e23c1b3872 | ||
|
|
3c2ef43526 | ||
|
|
c81aae0c6a | ||
|
|
ff1a9e8a52 | ||
|
|
90bfa47046 | ||
|
|
6383b8b46f | ||
|
|
76dc294748 | ||
|
|
0bc1dd96ed | ||
|
|
73d2d3cbbc | ||
|
|
d5fdd6881c | ||
|
|
b1bc25ba04 | ||
|
|
2c1204c247 | ||
|
|
05a1fd8e8d | ||
|
|
97f21394a7 | ||
|
|
c57a445046 | ||
|
|
fb981f1548 | ||
|
|
407dde2703 | ||
|
|
f175139fd3 | ||
|
|
bebd7eebe5 | ||
|
|
e01f6b42b1 | ||
|
|
bd1c6c076e | ||
|
|
46b1bd2fd2 | ||
|
|
2d7c5dcec7 | ||
|
|
93bcf6cef9 | ||
|
|
4b6a03ef56 | ||
|
|
7ff4230e13 | ||
|
|
7d7967ec00 | ||
|
|
b0883f034b | ||
|
|
f88628c469 | ||
|
|
ac7a964edf | ||
|
|
7bb22419df | ||
|
|
c443bc78d3 | ||
|
|
a5519537c5 | ||
|
|
ef67d1f33b | ||
|
|
31bd64f821 | ||
|
|
4c408ac7b1 | ||
|
|
30a9c1208a | ||
|
|
96fd824a3e | ||
|
|
0a469a380b | ||
|
|
5e3f31de30 | ||
|
|
af9fb8694e | ||
|
|
ebfdfc0c9f | ||
|
|
c9d6bb350d | ||
|
|
f980516462 | ||
|
|
795892f7c9 | ||
|
|
9576581d89 | ||
|
|
296223130e | ||
|
|
87e2da0e01 | ||
|
|
9156ea1114 | ||
|
|
b8fc2fd59e | ||
|
|
ef9723ed44 | ||
|
|
5105b503ea | ||
|
|
9bcb34e7d1 | ||
|
|
185a17edce |
@@ -2,6 +2,9 @@ name: Package
|
||||
|
||||
on: workflow_dispatch
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
+3
-3
@@ -7,7 +7,7 @@ plugins {
|
||||
id 'org.beryx.jlink' version '2.26.0'
|
||||
}
|
||||
|
||||
def sparrowVersion = '1.7.7'
|
||||
def sparrowVersion = '1.8.0'
|
||||
def os = org.gradle.internal.os.OperatingSystem.current()
|
||||
def osName = os.getFamilyName()
|
||||
if(os.macOsX) {
|
||||
@@ -121,7 +121,7 @@ dependencies {
|
||||
implementation('org.slf4j:jul-to-slf4j:1.7.30') {
|
||||
exclude group: 'org.slf4j'
|
||||
}
|
||||
implementation('com.sparrowwallet.nightjar:nightjar:0.2.36')
|
||||
implementation('com.sparrowwallet.nightjar:nightjar:0.2.37')
|
||||
implementation('io.reactivex.rxjava2:rxjava:2.2.15')
|
||||
implementation('io.reactivex.rxjava2:rxjavafx:2.2.2')
|
||||
implementation('org.apache.commons:commons-lang3:3.7')
|
||||
@@ -508,7 +508,7 @@ extraJavaModuleInfo {
|
||||
exports('co.nstant.in.cbor.model')
|
||||
exports('co.nstant.in.cbor.builder')
|
||||
}
|
||||
module('nightjar-0.2.36.jar', 'com.sparrowwallet.nightjar', '0.2.36') {
|
||||
module('nightjar-0.2.37.jar', 'com.sparrowwallet.nightjar', '0.2.37') {
|
||||
requires('com.google.common')
|
||||
requires('net.sourceforge.streamsupport')
|
||||
requires('org.slf4j')
|
||||
|
||||
@@ -82,7 +82,7 @@ sudo apt install -y rpm fakeroot binutils
|
||||
First, assign a temporary variable in your shell for the specific release you want to build. For the current one specify:
|
||||
|
||||
```shell
|
||||
GIT_TAG="1.7.6"
|
||||
GIT_TAG="1.7.9"
|
||||
```
|
||||
|
||||
The project can then be initially cloned as follows:
|
||||
|
||||
+1
-1
Submodule drongo updated: a8df17ff5e...28551247c6
@@ -21,7 +21,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.7.7</string>
|
||||
<string>1.8.0</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<!-- See https://developer.apple.com/app-store/categories/ for list of AppStore categories -->
|
||||
|
||||
@@ -166,6 +166,9 @@ public class AppController implements Initializable {
|
||||
@FXML
|
||||
private MenuItem lockAllWallets;
|
||||
|
||||
@FXML
|
||||
private MenuItem showWalletSummary;
|
||||
|
||||
@FXML
|
||||
private MenuItem searchWallet;
|
||||
|
||||
@@ -210,6 +213,8 @@ public class AppController implements Initializable {
|
||||
|
||||
private Timeline statusTimeline;
|
||||
|
||||
private SendToManyDialog sendToManyDialog;
|
||||
|
||||
private Tab previouslySelectedTab;
|
||||
|
||||
private boolean subTabsVisible;
|
||||
@@ -218,6 +223,8 @@ public class AppController implements Initializable {
|
||||
|
||||
private final Set<Wallet> emptyLoadingWallets = new LinkedHashSet<>();
|
||||
|
||||
private final Map<File, File> renamedWallets = new HashMap<>();
|
||||
|
||||
private final ChangeListener<Boolean> serverToggleOnlineListener = (observable, oldValue, newValue) -> {
|
||||
Platform.runLater(() -> setServerToggleTooltip(getCurrentBlockHeight()));
|
||||
};
|
||||
@@ -320,6 +327,8 @@ public class AppController implements Initializable {
|
||||
EventManager.get().post(new OpenWalletsEvent(tabs.getScene().getWindow(), Collections.emptyList()));
|
||||
});
|
||||
|
||||
registerShortcuts();
|
||||
|
||||
BitcoinUnit unit = Config.get().getBitcoinUnit();
|
||||
if(unit == null) {
|
||||
unit = BitcoinUnit.AUTO;
|
||||
@@ -376,6 +385,7 @@ public class AppController implements Initializable {
|
||||
deleteWallet.disableProperty().bind(exportWallet.disableProperty());
|
||||
closeTab.setDisable(true);
|
||||
lockWallet.setDisable(true);
|
||||
showWalletSummary.disableProperty().bind(exportWallet.disableProperty());
|
||||
searchWallet.disableProperty().bind(exportWallet.disableProperty());
|
||||
refreshWallet.disableProperty().bind(Bindings.or(exportWallet.disableProperty(), Bindings.or(serverToggle.disableProperty(), AppServices.onlineProperty().not())));
|
||||
sendToMany.disableProperty().bind(exportWallet.disableProperty());
|
||||
@@ -400,6 +410,22 @@ public class AppController implements Initializable {
|
||||
setNetworkLabel();
|
||||
}
|
||||
|
||||
private void registerShortcuts() {
|
||||
org.controlsfx.tools.Platform platform = org.controlsfx.tools.Platform.getCurrent();
|
||||
if(platform == org.controlsfx.tools.Platform.OSX) {
|
||||
tabs.getScene().addEventFilter(KeyEvent.KEY_PRESSED, event -> {
|
||||
if(event.isShortcutDown() && event.isAltDown() && (event.getCode() == KeyCode.LEFT || event.getCode() == KeyCode.RIGHT)) {
|
||||
int currentIndex = tabs.getSelectionModel().getSelectedIndex();
|
||||
if(event.getCode() == KeyCode.LEFT && currentIndex > 0) {
|
||||
tabs.getSelectionModel().select(currentIndex - 1);
|
||||
} else if(event.getCode() == KeyCode.RIGHT && currentIndex < tabs.getTabs().size() - 1) {
|
||||
tabs.getSelectionModel().select(currentIndex + 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void setPlatformApplicationMenu() {
|
||||
org.controlsfx.tools.Platform platform = org.controlsfx.tools.Platform.getCurrent();
|
||||
if(platform == org.controlsfx.tools.Platform.OSX) {
|
||||
@@ -443,6 +469,7 @@ public class AppController implements Initializable {
|
||||
|
||||
public void showIntroduction(ActionEvent event) {
|
||||
WelcomeDialog welcomeDialog = new WelcomeDialog();
|
||||
welcomeDialog.initOwner(rootStack.getScene().getWindow());
|
||||
Optional<Mode> optionalMode = welcomeDialog.showAndWait();
|
||||
if(optionalMode.isPresent() && optionalMode.get().equals(Mode.ONLINE)) {
|
||||
openPreferences(PreferenceGroup.SERVER);
|
||||
@@ -467,7 +494,17 @@ public class AppController implements Initializable {
|
||||
}
|
||||
|
||||
public void submitBugReport(ActionEvent event) {
|
||||
AppServices.get().getApplication().getHostServices().showDocument("https://sparrowwallet.com/submitbugreport");
|
||||
ButtonType supportType = new ButtonType("Get Support", ButtonBar.ButtonData.LEFT);
|
||||
ButtonType bugType = new ButtonType("Submit Bug Report", ButtonBar.ButtonData.YES);
|
||||
Optional<ButtonType> optResponse = showWarningDialog("Submit Bug Report", "Please note that this facility is for bug reports and feature requests only. There is a community of Sparrow users who can assist with support requests.", supportType, bugType);
|
||||
|
||||
if(optResponse.isPresent()) {
|
||||
if(optResponse.get() == bugType) {
|
||||
AppServices.get().getApplication().getHostServices().showDocument("https://sparrowwallet.com/submitbugreport");
|
||||
} else {
|
||||
openSupport(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void showAbout(ActionEvent event) {
|
||||
@@ -520,7 +557,8 @@ public class AppController implements Initializable {
|
||||
}
|
||||
|
||||
public void showInstallUdevMessage() {
|
||||
TextAreaDialog dialog = new TextAreaDialog("sudo " + Config.get().getHwi().getAbsolutePath() + " installudevrules");
|
||||
TextAreaDialog dialog = new TextAreaDialog("sudo " + Config.get().getHwi().getAbsolutePath() + " installudevrules", false);
|
||||
dialog.initOwner(rootStack.getScene().getWindow());
|
||||
dialog.setTitle("Install Udev Rules");
|
||||
dialog.getDialogPane().setHeaderText("Installing udev rules ensures devices can connect over USB.\nThis command requires root privileges.\nOpen a shell and enter the following:");
|
||||
dialog.showAndWait();
|
||||
@@ -538,9 +576,11 @@ public class AppController implements Initializable {
|
||||
);
|
||||
|
||||
AppServices.moveToActiveWindowScreen(window, 800, 450);
|
||||
File file = fileChooser.showOpenDialog(window);
|
||||
if (file != null) {
|
||||
openTransactionFile(file);
|
||||
List<File> files = fileChooser.showOpenMultipleDialog(window);
|
||||
if(files != null) {
|
||||
for(File file : files) {
|
||||
openTransactionFile(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -589,6 +629,7 @@ public class AppController implements Initializable {
|
||||
|
||||
public void openTransactionFromText(ActionEvent event) {
|
||||
TextAreaDialog dialog = new TextAreaDialog();
|
||||
dialog.initOwner(rootStack.getScene().getWindow());
|
||||
dialog.setTitle("Open from text");
|
||||
dialog.getDialogPane().setHeaderText("Paste a transaction or PSBT:");
|
||||
Optional<String> text = dialog.showAndWait();
|
||||
@@ -607,6 +648,7 @@ public class AppController implements Initializable {
|
||||
|
||||
public void openTransactionFromId(ActionEvent event) {
|
||||
TransactionIdDialog dialog = new TransactionIdDialog();
|
||||
dialog.initOwner(rootStack.getScene().getWindow());
|
||||
Optional<Sha256Hash> optionalTxId = dialog.showAndWait();
|
||||
if(optionalTxId.isPresent()) {
|
||||
Sha256Hash txId = optionalTxId.get();
|
||||
@@ -634,6 +676,7 @@ public class AppController implements Initializable {
|
||||
|
||||
public void openTransactionFromQR(ActionEvent event) {
|
||||
QRScanDialog qrScanDialog = new QRScanDialog();
|
||||
qrScanDialog.initOwner(rootStack.getScene().getWindow());
|
||||
Optional<QRScanDialog.Result> optionalResult = qrScanDialog.showAndWait();
|
||||
if(optionalResult.isPresent()) {
|
||||
QRScanDialog.Result result = optionalResult.get();
|
||||
@@ -697,6 +740,7 @@ public class AppController implements Initializable {
|
||||
try {
|
||||
UR ur = UR.fromBytes(transaction.bitcoinSerialize());
|
||||
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(ur);
|
||||
qrDisplayDialog.initOwner(rootStack.getScene().getWindow());
|
||||
qrDisplayDialog.showAndWait();
|
||||
} catch(Exception e) {
|
||||
log.error("Error creating UR", e);
|
||||
@@ -795,6 +839,7 @@ public class AppController implements Initializable {
|
||||
|
||||
CryptoPSBT cryptoPSBT = new CryptoPSBT(transactionTabData.getPsbt().serialize());
|
||||
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(cryptoPSBT.toUR());
|
||||
qrDisplayDialog.initOwner(rootStack.getScene().getWindow());
|
||||
qrDisplayDialog.show();
|
||||
}
|
||||
}
|
||||
@@ -839,6 +884,10 @@ public class AppController implements Initializable {
|
||||
}
|
||||
}
|
||||
|
||||
public void renameWallet(ActionEvent event) {
|
||||
renameWallet(getSelectedWalletForm());
|
||||
}
|
||||
|
||||
public void deleteWallet(ActionEvent event) {
|
||||
deleteWallet(getSelectedWalletForm());
|
||||
}
|
||||
@@ -956,6 +1005,7 @@ public class AppController implements Initializable {
|
||||
|
||||
public void newWallet(ActionEvent event) {
|
||||
WalletNameDialog dlg = new WalletNameDialog();
|
||||
dlg.initOwner(rootStack.getScene().getWindow());
|
||||
Optional<WalletNameDialog.NameAndBirthDate> optNameAndBirthDate = dlg.showAndWait();
|
||||
if(optNameAndBirthDate.isPresent()) {
|
||||
WalletNameDialog.NameAndBirthDate nameAndBirthDate = optNameAndBirthDate.get();
|
||||
@@ -977,9 +1027,11 @@ public class AppController implements Initializable {
|
||||
fileChooser.setInitialDirectory(Storage.getWalletsDir());
|
||||
|
||||
AppServices.moveToActiveWindowScreen(window, 800, 450);
|
||||
File file = fileChooser.showOpenDialog(window);
|
||||
if(file != null) {
|
||||
openWalletFile(file, forceSameWindow);
|
||||
List<File> files = fileChooser.showOpenMultipleDialog(window);
|
||||
if(files != null) {
|
||||
for(File file : files) {
|
||||
openWalletFile(file, forceSameWindow);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1005,6 +1057,7 @@ public class AppController implements Initializable {
|
||||
loadWalletService.start();
|
||||
} else {
|
||||
WalletPasswordDialog dlg = new WalletPasswordDialog(storage.getWalletName(null), WalletPasswordDialog.PasswordRequirement.LOAD);
|
||||
dlg.initOwner(rootStack.getScene().getWindow());
|
||||
Optional<SecureString> optionalPassword = dlg.showAndWait();
|
||||
if(optionalPassword.isEmpty()) {
|
||||
return;
|
||||
@@ -1073,6 +1126,7 @@ public class AppController implements Initializable {
|
||||
public void importWallet(ActionEvent event) {
|
||||
List<WalletForm> selectedWalletForms = getSelectedWalletForms();
|
||||
WalletImportDialog dlg = new WalletImportDialog(selectedWalletForms);
|
||||
dlg.initOwner(rootStack.getScene().getWindow());
|
||||
Optional<Wallet> optionalWallet = dlg.showAndWait();
|
||||
if(optionalWallet.isPresent()) {
|
||||
Wallet wallet = optionalWallet.get();
|
||||
@@ -1110,6 +1164,7 @@ public class AppController implements Initializable {
|
||||
try(FileInputStream inputStream = new FileInputStream(file)) {
|
||||
if(importer.isEncrypted(file) && password == null) {
|
||||
WalletPasswordDialog dlg = new WalletPasswordDialog(file.getName(), WalletPasswordDialog.PasswordRequirement.LOAD);
|
||||
dlg.initOwner(rootStack.getScene().getWindow());
|
||||
Optional<SecureString> optionalPassword = dlg.showAndWait();
|
||||
if(optionalPassword.isPresent()) {
|
||||
password = optionalPassword.get();
|
||||
@@ -1132,6 +1187,7 @@ public class AppController implements Initializable {
|
||||
|
||||
private void addImportedWallet(Wallet wallet) {
|
||||
WalletNameDialog nameDlg = new WalletNameDialog(wallet.getName(), true, wallet.getBirthDate());
|
||||
nameDlg.initOwner(rootStack.getScene().getWindow());
|
||||
Optional<WalletNameDialog.NameAndBirthDate> optNameAndBirthDate = nameDlg.showAndWait();
|
||||
if(optNameAndBirthDate.isPresent()) {
|
||||
WalletNameDialog.NameAndBirthDate nameAndBirthDate = optNameAndBirthDate.get();
|
||||
@@ -1177,6 +1233,7 @@ public class AppController implements Initializable {
|
||||
|
||||
Storage storage = new Storage(Storage.getWalletFile(wallet.getName()));
|
||||
WalletPasswordDialog dlg = new WalletPasswordDialog(wallet.getName(), WalletPasswordDialog.PasswordRequirement.UPDATE_NEW);
|
||||
dlg.initOwner(rootStack.getScene().getWindow());
|
||||
Optional<SecureString> password = dlg.showAndWait();
|
||||
if(password.isPresent()) {
|
||||
if(password.get().length() == 0) {
|
||||
@@ -1243,6 +1300,7 @@ public class AppController implements Initializable {
|
||||
WalletForm selectedWalletForm = getSelectedWalletForm();
|
||||
if(selectedWalletForm != null) {
|
||||
WalletExportDialog dlg = new WalletExportDialog(selectedWalletForm.getWallet());
|
||||
dlg.initOwner(rootStack.getScene().getWindow());
|
||||
Optional<Wallet> wallet = dlg.showAndWait();
|
||||
if(wallet.isPresent()) {
|
||||
//Successful export
|
||||
@@ -1260,6 +1318,7 @@ public class AppController implements Initializable {
|
||||
|
||||
private void openPreferences(PreferenceGroup preferenceGroup) {
|
||||
PreferencesDialog preferencesDialog = new PreferencesDialog(preferenceGroup);
|
||||
preferencesDialog.initOwner(rootStack.getScene().getWindow());
|
||||
preferencesDialog.showAndWait();
|
||||
configureSwitchServer();
|
||||
serverToggle.setDisable(!Config.get().hasServer());
|
||||
@@ -1270,8 +1329,7 @@ public class AppController implements Initializable {
|
||||
WalletForm selectedWalletForm = getSelectedWalletForm();
|
||||
if(selectedWalletForm != null) {
|
||||
Wallet wallet = selectedWalletForm.getWallet();
|
||||
if(wallet.getKeystores().size() == 1 &&
|
||||
(wallet.getKeystores().get(0).hasPrivateKey() || wallet.getKeystores().get(0).getSource() == KeystoreSource.HW_USB)) {
|
||||
if(wallet.getKeystores().size() == 1) {
|
||||
//Can sign and verify
|
||||
messageSignDialog = new MessageSignDialog(wallet);
|
||||
}
|
||||
@@ -1282,10 +1340,18 @@ public class AppController implements Initializable {
|
||||
messageSignDialog = new MessageSignDialog();
|
||||
}
|
||||
|
||||
messageSignDialog.initOwner(rootStack.getScene().getWindow());
|
||||
messageSignDialog.showAndWait();
|
||||
}
|
||||
|
||||
public void sendToMany(ActionEvent event) {
|
||||
if(sendToManyDialog != null) {
|
||||
Stage stage = (Stage)sendToManyDialog.getDialogPane().getScene().getWindow();
|
||||
stage.setAlwaysOnTop(true);
|
||||
stage.setAlwaysOnTop(false);
|
||||
return;
|
||||
}
|
||||
|
||||
WalletForm selectedWalletForm = getSelectedWalletForm();
|
||||
if(selectedWalletForm != null) {
|
||||
Wallet wallet = selectedWalletForm.getWallet();
|
||||
@@ -1294,11 +1360,14 @@ public class AppController implements Initializable {
|
||||
bitcoinUnit = wallet.getAutoUnit();
|
||||
}
|
||||
|
||||
SendToManyDialog sendToManyDialog = new SendToManyDialog(bitcoinUnit);
|
||||
sendToManyDialog = new SendToManyDialog(bitcoinUnit);
|
||||
sendToManyDialog.initOwner(rootStack.getScene().getWindow());
|
||||
sendToManyDialog.initModality(Modality.NONE);
|
||||
Optional<List<Payment>> optPayments = sendToManyDialog.showAndWait();
|
||||
sendToManyDialog = null;
|
||||
optPayments.ifPresent(payments -> {
|
||||
if(!payments.isEmpty()) {
|
||||
EventManager.get().post(new SendActionEvent(wallet, new ArrayList<>(wallet.getWalletUtxos().keySet())));
|
||||
EventManager.get().post(new SendActionEvent(wallet, new ArrayList<>(wallet.getSpendableUtxos().keySet())));
|
||||
Platform.runLater(() -> EventManager.get().post(new SendPaymentsEvent(wallet, payments)));
|
||||
}
|
||||
});
|
||||
@@ -1313,6 +1382,7 @@ public class AppController implements Initializable {
|
||||
}
|
||||
|
||||
PrivateKeySweepDialog dialog = new PrivateKeySweepDialog(wallet);
|
||||
dialog.initOwner(rootStack.getScene().getWindow());
|
||||
Optional<Transaction> optTransaction = dialog.showAndWait();
|
||||
optTransaction.ifPresent(transaction -> addTransactionTab(null, null, transaction));
|
||||
}
|
||||
@@ -1326,6 +1396,7 @@ public class AppController implements Initializable {
|
||||
if(wallet.isEncrypted()) {
|
||||
Wallet copy = wallet.copy();
|
||||
WalletPasswordDialog dlg = new WalletPasswordDialog(copy.getMasterName(), WalletPasswordDialog.PasswordRequirement.LOAD);
|
||||
dlg.initOwner(rootStack.getScene().getWindow());
|
||||
Optional<SecureString> password = dlg.showAndWait();
|
||||
if(password.isPresent()) {
|
||||
Storage storage = selectedWalletForm.getStorage();
|
||||
@@ -1339,6 +1410,7 @@ public class AppController implements Initializable {
|
||||
try {
|
||||
soroban.setHDWallet(copy);
|
||||
CounterpartyDialog counterpartyDialog = new CounterpartyDialog(selectedWalletForm.getWalletId(), selectedWalletForm.getWallet());
|
||||
counterpartyDialog.initOwner(rootStack.getScene().getWindow());
|
||||
if(Config.get().isSameAppMixing()) {
|
||||
counterpartyDialog.initModality(Modality.NONE);
|
||||
}
|
||||
@@ -1366,6 +1438,7 @@ public class AppController implements Initializable {
|
||||
} else {
|
||||
soroban.setHDWallet(wallet);
|
||||
CounterpartyDialog counterpartyDialog = new CounterpartyDialog(selectedWalletForm.getWalletId(), selectedWalletForm.getWallet());
|
||||
counterpartyDialog.initOwner(rootStack.getScene().getWindow());
|
||||
if(Config.get().isSameAppMixing()) {
|
||||
counterpartyDialog.initModality(Modality.NONE);
|
||||
}
|
||||
@@ -1373,6 +1446,7 @@ public class AppController implements Initializable {
|
||||
}
|
||||
} else {
|
||||
CounterpartyDialog counterpartyDialog = new CounterpartyDialog(selectedWalletForm.getWalletId(), selectedWalletForm.getWallet());
|
||||
counterpartyDialog.initOwner(rootStack.getScene().getWindow());
|
||||
if(Config.get().isSameAppMixing()) {
|
||||
counterpartyDialog.initModality(Modality.NONE);
|
||||
}
|
||||
@@ -1385,6 +1459,7 @@ public class AppController implements Initializable {
|
||||
WalletForm selectedWalletForm = getSelectedWalletForm();
|
||||
if(selectedWalletForm != null) {
|
||||
PayNymDialog payNymDialog = new PayNymDialog(selectedWalletForm.getWalletId());
|
||||
payNymDialog.initOwner(rootStack.getScene().getWindow());
|
||||
payNymDialog.showAndWait();
|
||||
}
|
||||
}
|
||||
@@ -1430,13 +1505,52 @@ public class AppController implements Initializable {
|
||||
TabPane subTabs = (TabPane) selectedTab.getContent();
|
||||
List<WalletForm> walletForms = subTabs.getTabs().stream().map(subTab -> ((WalletTabData)subTab.getUserData()).getWalletForm()).collect(Collectors.toList());
|
||||
if(!walletForms.isEmpty()) {
|
||||
SearchWalletDialog searchWalletDialog = new SearchWalletDialog(walletForms);
|
||||
Optional<Entry> optEntry = searchWalletDialog.showAndWait();
|
||||
if(optEntry.isPresent()) {
|
||||
Entry entry = optEntry.get();
|
||||
EventManager.get().post(new FunctionActionEvent(entry.getWalletFunction(), entry.getWallet()));
|
||||
Platform.runLater(() -> EventManager.get().post(new SelectEntryEvent(entry)));
|
||||
}
|
||||
searchWallets(walletForms);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void searchAllWallets(ActionEvent event) {
|
||||
List<WalletForm> allWalletForms = new ArrayList<>();
|
||||
for(Tab tab : tabs.getTabs()) {
|
||||
TabData tabData = (TabData)tab.getUserData();
|
||||
if(tabData instanceof WalletTabData) {
|
||||
TabPane subTabs = (TabPane)tab.getContent();
|
||||
allWalletForms.addAll(subTabs.getTabs().stream().map(subTab -> ((WalletTabData)subTab.getUserData()).getWalletForm())
|
||||
.filter(walletForm -> walletForm.getWallet().isValid() && !walletForm.isLocked()).collect(Collectors.toList()));
|
||||
}
|
||||
}
|
||||
|
||||
if(allWalletForms.isEmpty()) {
|
||||
showErrorDialog("No wallets", "There are no open and unlocked wallets to search.");
|
||||
} else {
|
||||
searchWallets(allWalletForms);
|
||||
}
|
||||
}
|
||||
|
||||
private void searchWallets(List<WalletForm> walletForms) {
|
||||
SearchWalletDialog searchWalletDialog = new SearchWalletDialog(walletForms);
|
||||
searchWalletDialog.initOwner(rootStack.getScene().getWindow());
|
||||
Optional<Entry> optEntry = searchWalletDialog.showAndWait();
|
||||
if(optEntry.isPresent()) {
|
||||
Entry entry = optEntry.get();
|
||||
EventManager.get().post(new FunctionActionEvent(entry.getWalletFunction(), entry.getWallet()));
|
||||
Platform.runLater(() -> EventManager.get().post(new SelectEntryEvent(entry)));
|
||||
}
|
||||
}
|
||||
|
||||
public void showWalletSummary(ActionEvent event) {
|
||||
Tab selectedTab = tabs.getSelectionModel().getSelectedItem();
|
||||
if(selectedTab != null) {
|
||||
TabData tabData = (TabData) selectedTab.getUserData();
|
||||
if(tabData instanceof WalletTabData) {
|
||||
TabPane subTabs = (TabPane) selectedTab.getContent();
|
||||
List<WalletForm> walletForms = subTabs.getTabs().stream().map(subTab -> ((WalletTabData)subTab.getUserData()).getWalletForm()).collect(Collectors.toList());
|
||||
if(!walletForms.isEmpty()) {
|
||||
WalletSummaryDialog walletSummaryDialog = new WalletSummaryDialog(walletForms);
|
||||
walletSummaryDialog.initOwner(rootStack.getScene().getWindow());
|
||||
walletSummaryDialog.showAndWait();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1527,6 +1641,11 @@ public class AppController implements Initializable {
|
||||
|
||||
tabs.getTabs().add(tab);
|
||||
tabs.getSelectionModel().select(tab);
|
||||
|
||||
File oldWalletFile = renamedWallets.remove(storage.getWalletFile());
|
||||
if(oldWalletFile != null) {
|
||||
deleteStorage(new Storage(oldWalletFile), false);
|
||||
}
|
||||
} else {
|
||||
for(Tab walletTab : tabs.getTabs()) {
|
||||
TabData tabData = (TabData)walletTab.getUserData();
|
||||
@@ -1603,6 +1722,9 @@ public class AppController implements Initializable {
|
||||
subTabLabel.setGraphic(getSubTabGlyph(wallet));
|
||||
subTabLabel.setContentDisplay(ContentDisplay.TOP);
|
||||
subTabLabel.setAlignment(Pos.TOP_CENTER);
|
||||
if(isSubTabLabelTruncated(subTabLabel, label)) {
|
||||
subTabLabel.setTooltip(new Tooltip(label));
|
||||
}
|
||||
subTab.setGraphic(subTabLabel);
|
||||
FXMLLoader walletLoader = new FXMLLoader(getClass().getResource("wallet/wallet.fxml"));
|
||||
subTab.setContent(walletLoader.load());
|
||||
@@ -1856,6 +1978,7 @@ public class AppController implements Initializable {
|
||||
tabs.getTabs().add(index + 1, tab);
|
||||
tabs.getTabs().addListener(tabsChangeListener);
|
||||
tabs.getSelectionModel().select(tab);
|
||||
EventManager.get().post(new RequestOpenWalletsEvent()); //Rearrange recent files list
|
||||
});
|
||||
MenuItem moveLeft = new MenuItem("Move Left");
|
||||
moveLeft.setOnAction(event -> {
|
||||
@@ -1865,6 +1988,7 @@ public class AppController implements Initializable {
|
||||
tabs.getTabs().add(index - 1, tab);
|
||||
tabs.getTabs().addListener(tabsChangeListener);
|
||||
tabs.getSelectionModel().select(tab);
|
||||
EventManager.get().post(new RequestOpenWalletsEvent()); //Rearrange recent files list
|
||||
});
|
||||
contextMenu.getItems().addAll(moveRight, moveLeft);
|
||||
|
||||
@@ -1947,12 +2071,38 @@ public class AppController implements Initializable {
|
||||
}
|
||||
}
|
||||
|
||||
private void renameWallet(WalletForm selectedWalletForm) {
|
||||
WalletNameDialog walletNameDialog = new WalletNameDialog(selectedWalletForm.getMasterWallet().getName(), false, null, true);
|
||||
walletNameDialog.initOwner(rootStack.getScene().getWindow());
|
||||
Optional<WalletNameDialog.NameAndBirthDate> optName = walletNameDialog.showAndWait();
|
||||
if(optName.isPresent()) {
|
||||
File walletFile = Storage.getWalletFile(optName.get().getName() + "." + PersistenceType.DB.getExtension());
|
||||
if(walletFile.exists()) {
|
||||
showErrorDialog("Error renaming wallet", "Wallet file " + walletFile.getAbsolutePath() + " already exists.");
|
||||
return;
|
||||
}
|
||||
|
||||
Storage.CopyWalletService copyWalletService = new Storage.CopyWalletService(selectedWalletForm.getWallet(), walletFile);
|
||||
copyWalletService.setOnSucceeded(event -> {
|
||||
renamedWallets.put(walletFile, selectedWalletForm.getStorage().getWalletFile());
|
||||
tabs.getTabs().remove(tabs.getSelectionModel().getSelectedItem());
|
||||
openWalletFile(walletFile, true);
|
||||
});
|
||||
copyWalletService.setOnFailed(event -> {
|
||||
log.error("Error renaming wallet", event.getSource().getException());
|
||||
showErrorDialog("Error renaming wallet", event.getSource().getException().getMessage());
|
||||
});
|
||||
copyWalletService.start();
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteWallet(WalletForm selectedWalletForm) {
|
||||
Optional<ButtonType> optButtonType = AppServices.showWarningDialog("Delete " + selectedWalletForm.getWallet().getMasterName() + "?", "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);
|
||||
dlg.initOwner(rootStack.getScene().getWindow());
|
||||
Optional<SecureString> password = dlg.showAndWait();
|
||||
if(password.isPresent()) {
|
||||
Storage.KeyDerivationService keyDerivationService = new Storage.KeyDerivationService(storage, password.get(), true);
|
||||
@@ -1962,7 +2112,7 @@ public class AppController implements Initializable {
|
||||
|
||||
try {
|
||||
tabs.getTabs().remove(tabs.getSelectionModel().getSelectedItem());
|
||||
deleteStorage(storage);
|
||||
deleteStorage(storage, true);
|
||||
} finally {
|
||||
encryptionFullKey.clear();
|
||||
password.get().clear();
|
||||
@@ -1984,15 +2134,15 @@ public class AppController implements Initializable {
|
||||
}
|
||||
} else {
|
||||
tabs.getTabs().remove(tabs.getSelectionModel().getSelectedItem());
|
||||
deleteStorage(storage);
|
||||
deleteStorage(storage, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteStorage(Storage storage) {
|
||||
private void deleteStorage(Storage storage, boolean deleteBackups) {
|
||||
if(storage.isClosed()) {
|
||||
Platform.runLater(() -> {
|
||||
Storage.DeleteWalletService deleteWalletService = new Storage.DeleteWalletService(storage);
|
||||
Storage.DeleteWalletService deleteWalletService = new Storage.DeleteWalletService(storage, deleteBackups);
|
||||
deleteWalletService.setDelay(Duration.seconds(3));
|
||||
deleteWalletService.setPeriod(Duration.hours(1));
|
||||
deleteWalletService.setOnSucceeded(event -> {
|
||||
@@ -2008,7 +2158,7 @@ public class AppController implements Initializable {
|
||||
deleteWalletService.start();
|
||||
});
|
||||
} else {
|
||||
Platform.runLater(() -> deleteStorage(storage));
|
||||
Platform.runLater(() -> deleteStorage(storage, deleteBackups));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2020,10 +2170,16 @@ public class AppController implements Initializable {
|
||||
rename.setOnAction(event -> {
|
||||
Label subTabLabel = (Label)subTab.getGraphic();
|
||||
WalletLabelDialog walletLabelDialog = new WalletLabelDialog(subTabLabel.getText());
|
||||
walletLabelDialog.initOwner(rootStack.getScene().getWindow());
|
||||
Optional<String> optLabel = walletLabelDialog.showAndWait();
|
||||
if(optLabel.isPresent()) {
|
||||
String label = optLabel.get();
|
||||
subTabLabel.setText(label);
|
||||
if(isSubTabLabelTruncated(subTabLabel, label)) {
|
||||
subTabLabel.setTooltip(new Tooltip(label));
|
||||
} else {
|
||||
subTabLabel.setTooltip(null);
|
||||
}
|
||||
|
||||
Wallet renamedWallet = AppServices.get().getWallet(walletId);
|
||||
renamedWallet.setLabel(label);
|
||||
@@ -2048,9 +2204,18 @@ public class AppController implements Initializable {
|
||||
contextMenu.getItems().add(delete);
|
||||
}
|
||||
|
||||
contextMenu.setOnShowing(event -> {
|
||||
Wallet renameWallet = AppServices.get().getWallet(walletId);
|
||||
rename.setDisable(!renameWallet.isValid());
|
||||
});
|
||||
|
||||
return contextMenu;
|
||||
}
|
||||
|
||||
private boolean isSubTabLabelTruncated(Label subTabLabel, String label) {
|
||||
return TextUtils.computeTextWidth(subTabLabel.getFont(), label, 0.0D) > (90-6);
|
||||
}
|
||||
|
||||
private void configureSwitchServer() {
|
||||
switchServer.getItems().clear();
|
||||
|
||||
|
||||
@@ -61,8 +61,10 @@ import java.awt.event.KeyEvent;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.net.*;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.*;
|
||||
import java.util.List;
|
||||
@@ -563,8 +565,17 @@ public class AppServices {
|
||||
return windowIcon;
|
||||
}
|
||||
|
||||
public static boolean isReducedWindowHeight() {
|
||||
Window activeWindow = getActiveWindow();
|
||||
return (activeWindow != null && activeWindow.getHeight() < getReducedWindowHeight());
|
||||
}
|
||||
|
||||
public static boolean isReducedWindowHeight(Node node) {
|
||||
return (node.getScene() != null && node.getScene().getWindow().getHeight() < 768);
|
||||
return (node.getScene() != null && node.getScene().getWindow().getHeight() < getReducedWindowHeight());
|
||||
}
|
||||
|
||||
private static double getReducedWindowHeight() {
|
||||
return org.controlsfx.tools.Platform.getCurrent() != org.controlsfx.tools.Platform.OSX ? 802d : 768d; //Check for menu bar of ~34px
|
||||
}
|
||||
|
||||
public Application getApplication() {
|
||||
@@ -682,6 +693,12 @@ public class AppServices {
|
||||
|
||||
Date yesterday = Date.from(LocalDateTime.now().minusDays(1).atZone(ZoneId.systemDefault()).toInstant());
|
||||
mempoolHistogram.keySet().removeIf(date -> date.before(yesterday));
|
||||
|
||||
ZonedDateTime twoHoursAgo = LocalDateTime.now().minusHours(2).atZone(ZoneId.systemDefault());
|
||||
mempoolHistogram.keySet().removeIf(date -> {
|
||||
ZonedDateTime dateTime = date.toInstant().atZone(ZoneId.systemDefault());
|
||||
return dateTime.isBefore(twoHoursAgo) && (dateTime.getMinute() % 10 != 0);
|
||||
});
|
||||
}
|
||||
|
||||
public static Double getMinimumRelayFeeRate() {
|
||||
@@ -901,7 +918,7 @@ public class AppServices {
|
||||
|
||||
if(wallet != null) {
|
||||
final Wallet sendingWallet = wallet;
|
||||
EventManager.get().post(new SendActionEvent(sendingWallet, new ArrayList<>(sendingWallet.getWalletUtxos().keySet()), true));
|
||||
EventManager.get().post(new SendActionEvent(sendingWallet, new ArrayList<>(sendingWallet.getSpendableUtxos().keySet()), true));
|
||||
Platform.runLater(() -> EventManager.get().post(new SendPaymentsEvent(sendingWallet, List.of(bitcoinURI.toPayment()))));
|
||||
}
|
||||
} catch(Exception e) {
|
||||
@@ -941,6 +958,7 @@ public class AppServices {
|
||||
Storage storage = AppServices.get().getOpenWallets().get(wallet);
|
||||
Wallet copy = wallet.copy();
|
||||
WalletPasswordDialog dlg = new WalletPasswordDialog(copy.getMasterName(), WalletPasswordDialog.PasswordRequirement.LOAD);
|
||||
dlg.initOwner(getActiveWindow());
|
||||
Optional<SecureString> password = dlg.showAndWait();
|
||||
if(password.isPresent()) {
|
||||
Storage.KeyDerivationService keyDerivationService = new Storage.KeyDerivationService(storage, password.get(), true);
|
||||
@@ -1003,6 +1021,7 @@ public class AppServices {
|
||||
wallet = wallets.iterator().next();
|
||||
} else {
|
||||
ChoiceDialog<Wallet> walletChoiceDialog = new ChoiceDialog<>(wallets.iterator().next(), wallets);
|
||||
walletChoiceDialog.initOwner(getActiveWindow());
|
||||
walletChoiceDialog.setTitle("Choose Wallet");
|
||||
walletChoiceDialog.setHeaderText("Choose a wallet to " + actionDescription);
|
||||
Image image = new Image("/image/sparrow-small.png");
|
||||
|
||||
@@ -15,13 +15,13 @@ import java.util.Optional;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static com.sparrowwallet.sparrow.AppServices.moveToActiveWindowScreen;
|
||||
import static com.sparrowwallet.sparrow.AppServices.setStageIcon;
|
||||
import static com.sparrowwallet.sparrow.AppServices.*;
|
||||
|
||||
public class DefaultInteractionServices implements InteractionServices {
|
||||
@Override
|
||||
public Optional<ButtonType> showAlert(String title, String content, Alert.AlertType alertType, Node graphic, ButtonType... buttons) {
|
||||
Alert alert = new Alert(alertType, content, buttons);
|
||||
alert.initOwner(getActiveWindow());
|
||||
setStageIcon(alert.getDialogPane().getScene().getWindow());
|
||||
alert.getDialogPane().getScene().getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
|
||||
alert.setTitle(title);
|
||||
@@ -53,6 +53,8 @@ public class DefaultInteractionServices implements InteractionServices {
|
||||
alert.getDialogPane().setPrefHeight(200 + numLines * 20);
|
||||
}
|
||||
|
||||
alert.setResizable(true);
|
||||
|
||||
moveToActiveWindowScreen(alert);
|
||||
return alert.showAndWait();
|
||||
}
|
||||
@@ -60,6 +62,7 @@ public class DefaultInteractionServices implements InteractionServices {
|
||||
@Override
|
||||
public Optional<String> requestPassphrase(String walletName, Keystore keystore) {
|
||||
KeystorePassphraseDialog passphraseDialog = new KeystorePassphraseDialog(walletName, keystore);
|
||||
passphraseDialog.initOwner(getActiveWindow());
|
||||
return passphraseDialog.showAndWait();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ public class SparrowDesktop extends Application {
|
||||
GlyphFontRegistry.register(new FontAwesome5());
|
||||
GlyphFontRegistry.register(new FontAwesome5Brands());
|
||||
Font.loadFont(AppServices.class.getResourceAsStream("/font/RobotoMono-Regular.ttf"), 13);
|
||||
Font.loadFont(AppServices.class.getResourceAsStream("/font/RobotoMono-Italic.ttf"), 11);
|
||||
URL.setURLStreamHandlerFactory(protocol -> WalletIcon.PROTOCOL.equals(protocol) ? new WalletIcon.WalletIconStreamHandler() : null);
|
||||
|
||||
AppServices.initialize(this);
|
||||
|
||||
@@ -18,7 +18,7 @@ import java.util.*;
|
||||
public class SparrowWallet {
|
||||
public static final String APP_ID = "com.sparrowwallet.sparrow";
|
||||
public static final String APP_NAME = "Sparrow";
|
||||
public static final String APP_VERSION = "1.7.7";
|
||||
public static final String APP_VERSION = "1.8.0";
|
||||
public static final String APP_VERSION_SUFFIX = "";
|
||||
public static final String APP_HOME_PROPERTY = "sparrow.home";
|
||||
public static final String NETWORK_ENV_PROPERTY = "SPARROW_NETWORK";
|
||||
|
||||
@@ -17,6 +17,8 @@ import org.controlsfx.glyphfont.Glyph;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static com.sparrowwallet.drongo.wallet.StandardAccount.*;
|
||||
|
||||
public class AddAccountDialog extends Dialog<List<StandardAccount>> {
|
||||
private final ComboBox<StandardAccount> standardAccountCombo;
|
||||
private boolean discoverAccounts = false;
|
||||
@@ -53,13 +55,15 @@ public class AddAccountDialog extends Dialog<List<StandardAccount>> {
|
||||
|
||||
List<StandardAccount> availableAccounts = new ArrayList<>();
|
||||
for(StandardAccount standardAccount : StandardAccount.values()) {
|
||||
if(!existingIndexes.contains(standardAccount.getAccountNumber()) && !StandardAccount.WHIRLPOOL_ACCOUNTS.contains(standardAccount)) {
|
||||
if(!existingIndexes.contains(standardAccount.getAccountNumber()) && !WHIRLPOOL_ACCOUNTS.contains(standardAccount)) {
|
||||
availableAccounts.add(standardAccount);
|
||||
}
|
||||
}
|
||||
|
||||
if(WhirlpoolServices.canWalletMix(masterWallet) && !masterWallet.isWhirlpoolMasterWallet()) {
|
||||
availableAccounts.add(StandardAccount.WHIRLPOOL_PREMIX);
|
||||
availableAccounts.add(WHIRLPOOL_PREMIX);
|
||||
} else if(WhirlpoolServices.canWatchPostmix(masterWallet) && !existingIndexes.contains(WHIRLPOOL_POSTMIX.getAccountNumber())) {
|
||||
availableAccounts.add(WHIRLPOOL_POSTMIX);
|
||||
}
|
||||
|
||||
final ButtonType discoverButtonType = new javafx.scene.control.ButtonType("Discover", ButtonBar.ButtonData.LEFT);
|
||||
@@ -82,10 +86,14 @@ public class AddAccountDialog extends Dialog<List<StandardAccount>> {
|
||||
return "None Available";
|
||||
}
|
||||
|
||||
if(StandardAccount.WHIRLPOOL_ACCOUNTS.contains(account)) {
|
||||
if(account == WHIRLPOOL_PREMIX) {
|
||||
return "Whirlpool Accounts";
|
||||
}
|
||||
|
||||
if(account == WHIRLPOOL_POSTMIX) {
|
||||
return "Whirlpool Postmix (No mixing)";
|
||||
}
|
||||
|
||||
return account.getName();
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ public class AddressCell extends TreeTableCell<Entry, UtxoEntry.AddressStatus> {
|
||||
if(utxoEntry != null) {
|
||||
Address address = addressStatus.getAddress();
|
||||
setText(address.toString());
|
||||
setContextMenu(new EntryCell.AddressContextMenu(address, utxoEntry.getOutputDescriptor(), new NodeEntry(utxoEntry.getWallet(), utxoEntry.getNode()), false));
|
||||
setContextMenu(new EntryCell.AddressContextMenu(address, utxoEntry.getOutputDescriptor(), new NodeEntry(utxoEntry.getWallet(), utxoEntry.getNode()), false, getTreeTableView()));
|
||||
Tooltip tooltip = new Tooltip();
|
||||
tooltip.setShowDelay(Duration.millis(250));
|
||||
tooltip.setText(getTooltipText(utxoEntry, addressStatus.isDuplicate(), addressStatus.isDustAttack()));
|
||||
|
||||
@@ -4,8 +4,7 @@ import com.google.common.base.Throwables;
|
||||
import com.sparrowwallet.drongo.KeyDerivation;
|
||||
import com.sparrowwallet.drongo.crypto.ChildNumber;
|
||||
import com.sparrowwallet.drongo.protocol.Sha256Hash;
|
||||
import com.sparrowwallet.drongo.wallet.Keystore;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.drongo.wallet.*;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.event.KeystoreImportEvent;
|
||||
@@ -36,6 +35,7 @@ import org.slf4j.LoggerFactory;
|
||||
import javax.smartcardio.CardException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static com.sparrowwallet.sparrow.io.CardApi.isReaderAvailable;
|
||||
|
||||
@@ -74,21 +74,21 @@ public class CardImportPane extends TitledDescriptionPane {
|
||||
return;
|
||||
}
|
||||
|
||||
if(pin.get().length() < 6) {
|
||||
setDescription(pin.get().isEmpty() ? "Enter PIN code" : "PIN code too short");
|
||||
setContent(getPinAndDerivationEntry());
|
||||
showHideLink.setVisible(false);
|
||||
setExpanded(true);
|
||||
importButton.setDisable(false);
|
||||
return;
|
||||
}
|
||||
|
||||
StringProperty messageProperty = new SimpleStringProperty();
|
||||
messageProperty.addListener((observable, oldValue, newValue) -> {
|
||||
Platform.runLater(() -> setDescription(newValue));
|
||||
});
|
||||
|
||||
try {
|
||||
if(pin.get().length() < importer.getWalletModel().getMinPinLength()) {
|
||||
setDescription(pin.get().isEmpty() ? (!importer.getWalletModel().hasDefaultPin() && !importer.isInitialized() ? "Choose a PIN code" : "Enter PIN code") : "PIN code too short");
|
||||
setContent(getPinAndDerivationEntry());
|
||||
showHideLink.setVisible(false);
|
||||
setExpanded(true);
|
||||
importButton.setDisable(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if(!importer.isInitialized()) {
|
||||
setDescription("Card not initialized");
|
||||
setContent(getInitializationPanel(messageProperty));
|
||||
@@ -121,6 +121,75 @@ public class CardImportPane extends TitledDescriptionPane {
|
||||
}
|
||||
|
||||
private Node getInitializationPanel(StringProperty messageProperty) {
|
||||
if(importer.getWalletModel().requiresSeedInitialization()) {
|
||||
return getSeedInitializationPanel(messageProperty);
|
||||
}
|
||||
|
||||
return getEntropyInitializationPanel(messageProperty);
|
||||
}
|
||||
|
||||
private Node getSeedInitializationPanel(StringProperty messageProperty) {
|
||||
VBox confirmationBox = new VBox(5);
|
||||
CustomPasswordField confirmationPin = new ViewPasswordField();
|
||||
confirmationPin.setPromptText("Re-enter chosen PIN");
|
||||
confirmationBox.getChildren().add(confirmationPin);
|
||||
|
||||
Button initializeButton = new Button("Initialize");
|
||||
initializeButton.setDefaultButton(true);
|
||||
initializeButton.setOnAction(event -> {
|
||||
initializeButton.setDisable(true);
|
||||
if(!pin.get().equals(confirmationPin.getText())) {
|
||||
setError("PIN Error", "The confirmation PIN did not match");
|
||||
return;
|
||||
}
|
||||
int pinSize = pin.get().length();
|
||||
if(pinSize < importer.getWalletModel().getMinPinLength() || pinSize > importer.getWalletModel().getMaxPinLength()) {
|
||||
setError("PIN Error", "PIN length must be between " + importer.getWalletModel().getMinPinLength() + " and " + importer.getWalletModel().getMaxPinLength() + " characters");
|
||||
return;
|
||||
}
|
||||
|
||||
SeedEntryDialog seedEntryDialog = new SeedEntryDialog(importer.getWalletModel().toDisplayString() + " Seed Words", 12);
|
||||
seedEntryDialog.initOwner(this.getScene().getWindow());
|
||||
Optional<List<String>> optWords = seedEntryDialog.showAndWait();
|
||||
if(optWords.isPresent()) {
|
||||
try {
|
||||
List<String> mnemonicWords = optWords.get();
|
||||
Bip39MnemonicCode.INSTANCE.check(mnemonicWords);
|
||||
DeterministicSeed seed = new DeterministicSeed(mnemonicWords, "", System.currentTimeMillis(), DeterministicSeed.Type.BIP39);
|
||||
byte[] seedBytes = seed.getSeedBytes();
|
||||
|
||||
CardInitializationService cardInitializationService = new CardInitializationService(importer, pin.get(), seedBytes, messageProperty);
|
||||
cardInitializationService.setOnSucceeded(successEvent -> {
|
||||
AppServices.showSuccessDialog("Card Initialized", "The card was successfully initialized.\n\nYou can now import the keystore.");
|
||||
setDescription("Leave card on reader");
|
||||
setExpanded(false);
|
||||
importButton.setDisable(false);
|
||||
});
|
||||
cardInitializationService.setOnFailed(failEvent -> {
|
||||
log.error("Error initializing card", failEvent.getSource().getException());
|
||||
AppServices.showErrorDialog("Card Initialization Failed", "The card was not initialized.\n\n" + failEvent.getSource().getException().getMessage());
|
||||
initializeButton.setDisable(false);
|
||||
});
|
||||
cardInitializationService.start();
|
||||
} catch(MnemonicException e) {
|
||||
log.error("Invalid seed entered", e);
|
||||
AppServices.showErrorDialog("Invalid seed entered", "The seed was invalid.\n\n" + e.getMessage());
|
||||
initializeButton.setDisable(false);
|
||||
}
|
||||
} else {
|
||||
initializeButton.setDisable(false);
|
||||
}
|
||||
});
|
||||
|
||||
HBox contentBox = new HBox(20);
|
||||
contentBox.getChildren().addAll(confirmationBox, initializeButton);
|
||||
contentBox.setPadding(new Insets(10, 30, 10, 30));
|
||||
HBox.setHgrow(confirmationBox, Priority.ALWAYS);
|
||||
|
||||
return contentBox;
|
||||
}
|
||||
|
||||
private Node getEntropyInitializationPanel(StringProperty messageProperty) {
|
||||
VBox initTypeBox = new VBox(5);
|
||||
RadioButton automatic = new RadioButton("Automatic (Recommended)");
|
||||
RadioButton advanced = new RadioButton("Advanced");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.wallet.WalletModel;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.binding.Bindings;
|
||||
@@ -22,7 +23,7 @@ public class CardPinDialog extends Dialog<CardPinDialog.CardPinChange> {
|
||||
private final CheckBox backupFirst;
|
||||
private final ButtonType okButtonType;
|
||||
|
||||
public CardPinDialog(boolean backupOnly) {
|
||||
public CardPinDialog(WalletModel walletModel, boolean backupOnly) {
|
||||
this.existingPin = new ViewPasswordField();
|
||||
this.newPin = new ViewPasswordField();
|
||||
this.newPinConfirm = new ViewPasswordField();
|
||||
@@ -71,7 +72,11 @@ public class CardPinDialog extends Dialog<CardPinDialog.CardPinChange> {
|
||||
if(backupOnly) {
|
||||
fieldset.getChildren().addAll(currentField);
|
||||
} else {
|
||||
fieldset.getChildren().addAll(currentField, newField, confirmField, backupField);
|
||||
fieldset.getChildren().addAll(currentField, newField, confirmField);
|
||||
}
|
||||
|
||||
if(walletModel.supportsBackup()) {
|
||||
fieldset.getChildren().add(backupField);
|
||||
}
|
||||
|
||||
form.getChildren().add(fieldset);
|
||||
@@ -80,8 +85,8 @@ public class CardPinDialog extends Dialog<CardPinDialog.CardPinChange> {
|
||||
ValidationSupport validationSupport = new ValidationSupport();
|
||||
Platform.runLater( () -> {
|
||||
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
|
||||
validationSupport.registerValidator(existingPin, (Control c, String newValue) -> ValidationResult.fromErrorIf(c, "Incorrect PIN length", existingPin.getText().length() < 6 || existingPin.getText().length() > 32));
|
||||
validationSupport.registerValidator(newPin, (Control c, String newValue) -> ValidationResult.fromErrorIf(c, "Incorrect PIN length", newPin.getText().length() < 6 || newPin.getText().length() > 32));
|
||||
validationSupport.registerValidator(existingPin, (Control c, String newValue) -> ValidationResult.fromErrorIf(c, "Incorrect PIN length", existingPin.getText().length() < walletModel.getMinPinLength() || existingPin.getText().length() > walletModel.getMaxPinLength()));
|
||||
validationSupport.registerValidator(newPin, (Control c, String newValue) -> ValidationResult.fromErrorIf(c, "Incorrect PIN length", newPin.getText().length() < walletModel.getMinPinLength() || newPin.getText().length() > walletModel.getMaxPinLength()));
|
||||
validationSupport.registerValidator(newPinConfirm, (Control c, String newValue) -> ValidationResult.fromErrorIf(c, "PIN confirmation does not match", !newPinConfirm.getText().equals(newPin.getText())));
|
||||
});
|
||||
|
||||
@@ -89,8 +94,8 @@ public class CardPinDialog extends Dialog<CardPinDialog.CardPinChange> {
|
||||
dialogPane.getButtonTypes().addAll(okButtonType);
|
||||
Button okButton = (Button) dialogPane.lookupButton(okButtonType);
|
||||
okButton.setPrefWidth(130);
|
||||
BooleanBinding isInvalid = Bindings.createBooleanBinding(() -> existingPin.getText().length() < 6 || existingPin.getText().length() > 32
|
||||
|| newPin.getText().length() < 6 || newPin.getText().length() > 32
|
||||
BooleanBinding isInvalid = Bindings.createBooleanBinding(() -> existingPin.getText().length() < walletModel.getMinPinLength() || existingPin.getText().length() > walletModel.getMaxPinLength()
|
||||
|| newPin.getText().length() < walletModel.getMinPinLength() || newPin.getText().length() > walletModel.getMaxPinLength()
|
||||
|| !newPin.getText().equals(newPinConfirm.getText()),
|
||||
existingPin.textProperty(), newPin.textProperty(), newPinConfirm.textProperty());
|
||||
okButton.disableProperty().bind(isInvalid);
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.BitcoinUnit;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.sparrow.CurrencyRate;
|
||||
import com.sparrowwallet.sparrow.UnitFormat;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
@@ -17,6 +18,7 @@ import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.Hyperlink;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.TreeTableColumn;
|
||||
import javafx.scene.control.TreeTableView;
|
||||
import javafx.scene.layout.StackPane;
|
||||
|
||||
@@ -28,6 +30,7 @@ import java.util.Optional;
|
||||
public class CoinTreeTable extends TreeTableView<Entry> {
|
||||
private BitcoinUnit bitcoinUnit;
|
||||
private UnitFormat unitFormat;
|
||||
private CurrencyRate currencyRate;
|
||||
|
||||
public BitcoinUnit getBitcoinUnit() {
|
||||
return bitcoinUnit;
|
||||
@@ -64,6 +67,18 @@ public class CoinTreeTable extends TreeTableView<Entry> {
|
||||
}
|
||||
}
|
||||
|
||||
public CurrencyRate getCurrencyRate() {
|
||||
return currencyRate;
|
||||
}
|
||||
|
||||
public void setCurrencyRate(CurrencyRate currencyRate) {
|
||||
this.currencyRate = currencyRate;
|
||||
|
||||
if(!getChildren().isEmpty()) {
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
public void updateHistoryStatus(WalletHistoryStatusEvent event) {
|
||||
if(getRoot() != null) {
|
||||
Entry entry = getRoot().getValue();
|
||||
@@ -94,6 +109,7 @@ public class CoinTreeTable extends TreeTableView<Entry> {
|
||||
hyperlink.setTranslateY(30);
|
||||
hyperlink.setOnAction(event -> {
|
||||
WalletBirthDateDialog dlg = new WalletBirthDateDialog(wallet.getBirthDate(), false);
|
||||
dlg.initOwner(this.getScene().getWindow());
|
||||
Optional<Date> optDate = dlg.showAndWait();
|
||||
if(optDate.isPresent()) {
|
||||
Storage storage = AppServices.get().getOpenWallets().get(wallet);
|
||||
@@ -119,4 +135,12 @@ public class CoinTreeTable extends TreeTableView<Entry> {
|
||||
stackPane.setAlignment(Pos.CENTER);
|
||||
return stackPane;
|
||||
}
|
||||
|
||||
public void setSortColumn(int columnIndex, TreeTableColumn.SortType sortType) {
|
||||
if(columnIndex >= 0 && columnIndex < getColumns().size() && getSortOrder().isEmpty() && !getRoot().getChildren().isEmpty()) {
|
||||
TreeTableColumn<Entry, ?> column = getColumns().get(columnIndex);
|
||||
column.setSortType(sortType == null ? TreeTableColumn.SortType.DESCENDING : sortType);
|
||||
getSortOrder().add(column);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,7 +300,7 @@ public class DevicePane extends TitledDescriptionPane {
|
||||
|
||||
if(importButton instanceof SplitMenuButton importMenuButton) {
|
||||
if(wallet.getScriptType() == null) {
|
||||
ScriptType[] scriptTypes = new ScriptType[] {ScriptType.P2WPKH, ScriptType.P2SH_P2WPKH, ScriptType.P2PKH};
|
||||
ScriptType[] scriptTypes = new ScriptType[] {ScriptType.P2WPKH, ScriptType.P2SH_P2WPKH, ScriptType.P2PKH, ScriptType.P2TR};
|
||||
for(ScriptType scriptType : scriptTypes) {
|
||||
MenuItem item = new MenuItem(scriptType.getDescription());
|
||||
final List<ChildNumber> derivation = scriptType.getDefaultDerivation();
|
||||
@@ -673,8 +673,8 @@ public class DevicePane extends TitledDescriptionPane {
|
||||
try {
|
||||
CardApi cardApi = CardApi.getCardApi(device.getModel(), pin.get());
|
||||
if(!cardApi.isInitialized()) {
|
||||
if(pin.get().length() < 6) {
|
||||
setDescription(pin.get().isEmpty() ? "Enter PIN code" : "PIN code too short");
|
||||
if(pin.get().length() < device.getModel().getMinPinLength()) {
|
||||
setDescription(pin.get().isEmpty() ? (device.getModel().hasDefaultPin() ? "Enter PIN code" : "Choose a PIN code") : "PIN code too short");
|
||||
setContent(getCardPinEntry(importButton));
|
||||
showHideLink.setVisible(false);
|
||||
setExpanded(true);
|
||||
@@ -795,7 +795,7 @@ public class DevicePane extends TitledDescriptionPane {
|
||||
}
|
||||
|
||||
private void handleCardOperation(Service<?> service, ButtonBase operationButton, String operationDescription, boolean pinRequired, EventHandler<WorkerStateEvent> successHandler) {
|
||||
if(pinRequired && pin.get().length() < 6) {
|
||||
if(pinRequired && pin.get().length() < device.getModel().getMinPinLength()) {
|
||||
setDescription(pin.get().isEmpty() ? "Enter PIN code" : "PIN code too short");
|
||||
setContent(getCardPinEntry(operationButton));
|
||||
showHideLink.setVisible(false);
|
||||
@@ -940,7 +940,7 @@ public class DevicePane extends TitledDescriptionPane {
|
||||
try {
|
||||
CardApi cardApi = CardApi.getCardApi(device.getModel(), pin.get());
|
||||
if(!cardApi.isInitialized()) {
|
||||
if(pin.get().length() < 6) {
|
||||
if(pin.get().length() < device.getModel().getMinPinLength()) {
|
||||
setDescription(pin.get().isEmpty() ? "Enter PIN code" : "PIN code too short");
|
||||
setContent(getCardPinEntry(getAddressButton));
|
||||
showHideLink.setVisible(false);
|
||||
@@ -1047,6 +1047,75 @@ public class DevicePane extends TitledDescriptionPane {
|
||||
}
|
||||
|
||||
private Node getCardInitializationPanel(CardApi cardApi, ButtonBase operationButton, DeviceOperation deviceOperation) {
|
||||
if(device.getModel().requiresSeedInitialization()) {
|
||||
return getCardSeedInitializationPanel(cardApi, operationButton, deviceOperation);
|
||||
}
|
||||
|
||||
return getCardEntropyInitializationPanel(cardApi, operationButton, deviceOperation);
|
||||
}
|
||||
|
||||
private Node getCardSeedInitializationPanel(CardApi cardApi, ButtonBase operationButton, DeviceOperation deviceOperation) {
|
||||
VBox confirmationBox = new VBox(5);
|
||||
CustomPasswordField confirmationPin = new ViewPasswordField();
|
||||
confirmationPin.setPromptText("Re-enter chosen PIN");
|
||||
confirmationBox.getChildren().add(confirmationPin);
|
||||
|
||||
Button initializeButton = new Button("Initialize");
|
||||
initializeButton.setDefaultButton(true);
|
||||
initializeButton.setOnAction(event -> {
|
||||
initializeButton.setDisable(true);
|
||||
if(!pin.get().equals(confirmationPin.getText())) {
|
||||
setError("PIN Error", "The confirmation PIN did not match");
|
||||
return;
|
||||
}
|
||||
int pinSize = pin.get().length();
|
||||
if(pinSize < device.getModel().getMinPinLength() || pinSize > device.getModel().getMaxPinLength()) {
|
||||
setError("PIN Error", "PIN length must be between " + device.getModel().getMinPinLength() + " and " + device.getModel().getMaxPinLength() + " characters");
|
||||
return;
|
||||
}
|
||||
|
||||
SeedEntryDialog seedEntryDialog = new SeedEntryDialog(device.getModel().toDisplayString() + " Seed Words", 12);
|
||||
seedEntryDialog.initOwner(this.getScene().getWindow());
|
||||
Optional<List<String>> optWords = seedEntryDialog.showAndWait();
|
||||
if(optWords.isPresent()) {
|
||||
try {
|
||||
List<String> mnemonicWords = optWords.get();
|
||||
Bip39MnemonicCode.INSTANCE.check(mnemonicWords);
|
||||
DeterministicSeed seed = new DeterministicSeed(mnemonicWords, "", System.currentTimeMillis(), DeterministicSeed.Type.BIP39);
|
||||
byte[] seedBytes = seed.getSeedBytes();
|
||||
|
||||
Service<Void> cardInitializationService = cardApi.getInitializationService(seedBytes, messageProperty);
|
||||
cardInitializationService.setOnSucceeded(successEvent -> {
|
||||
AppServices.showSuccessDialog("Card Initialized", "The card was successfully initialized.\n\nYou can now import the keystore.");
|
||||
operationButton.setDisable(false);
|
||||
setDefaultStatus();
|
||||
setExpanded(false);
|
||||
});
|
||||
cardInitializationService.setOnFailed(failEvent -> {
|
||||
log.error("Error initializing card", failEvent.getSource().getException());
|
||||
AppServices.showErrorDialog("Card Initialization Failed", "The card was not initialized.\n\n" + failEvent.getSource().getException().getMessage());
|
||||
initializeButton.setDisable(false);
|
||||
});
|
||||
cardInitializationService.start();
|
||||
} catch(MnemonicException e) {
|
||||
log.error("Invalid seed entered", e);
|
||||
AppServices.showErrorDialog("Invalid seed entered", "The seed was invalid.\n\n" + e.getMessage());
|
||||
initializeButton.setDisable(false);
|
||||
}
|
||||
} else {
|
||||
initializeButton.setDisable(false);
|
||||
}
|
||||
});
|
||||
|
||||
HBox contentBox = new HBox(20);
|
||||
contentBox.getChildren().addAll(confirmationBox, initializeButton);
|
||||
contentBox.setPadding(new Insets(10, 30, 10, 30));
|
||||
HBox.setHgrow(confirmationBox, Priority.ALWAYS);
|
||||
|
||||
return contentBox;
|
||||
}
|
||||
|
||||
private Node getCardEntropyInitializationPanel(CardApi cardApi, ButtonBase operationButton, DeviceOperation deviceOperation) {
|
||||
VBox initTypeBox = new VBox(5);
|
||||
RadioButton automatic = new RadioButton("Automatic (Recommended)");
|
||||
RadioButton advanced = new RadioButton("Advanced");
|
||||
|
||||
@@ -36,7 +36,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
|
||||
private static final Logger log = LoggerFactory.getLogger(EntryCell.class);
|
||||
|
||||
public static final DateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm");
|
||||
private static final Pattern REPLACED_BY_FEE_SUFFIX = Pattern.compile("(.*)\\(Replaced By Fee( #)?(\\d+)?\\).*");
|
||||
public static final Pattern REPLACED_BY_FEE_SUFFIX = Pattern.compile("(.*?)( \\(Replaced By Fee( #)?(\\d+)?\\)).*?");
|
||||
|
||||
private static EntryCell lastCell;
|
||||
|
||||
@@ -100,7 +100,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
|
||||
actionBox.getChildren().add(viewTransactionButton);
|
||||
|
||||
BlockTransaction blockTransaction = transactionEntry.getBlockTransaction();
|
||||
if(blockTransaction.getHeight() <= 0 && blockTransaction.getTransaction().isReplaceByFee() &&
|
||||
if(blockTransaction.getHeight() <= 0 && canRBF(blockTransaction) &&
|
||||
Config.get().isIncludeMempoolOutputs() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) {
|
||||
Button increaseFeeButton = new Button("");
|
||||
increaseFeeButton.setGraphic(getIncreaseFeeRBFGlyph());
|
||||
@@ -124,7 +124,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
|
||||
NodeEntry nodeEntry = (NodeEntry)entry;
|
||||
Address address = nodeEntry.getAddress();
|
||||
setText(address.toString());
|
||||
setContextMenu(new AddressContextMenu(address, nodeEntry.getOutputDescriptor(), nodeEntry, true));
|
||||
setContextMenu(new AddressContextMenu(address, nodeEntry.getOutputDescriptor(), nodeEntry, true, getTreeTableView()));
|
||||
Tooltip tooltip = new Tooltip();
|
||||
tooltip.setShowDelay(Duration.millis(250));
|
||||
tooltip.setText(nodeEntry.getNode().toString());
|
||||
@@ -149,6 +149,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
|
||||
signMessageButton.setGraphic(getSignMessageGlyph());
|
||||
signMessageButton.setOnAction(event -> {
|
||||
MessageSignDialog messageSignDialog = new MessageSignDialog(nodeEntry.getWallet(), nodeEntry.getNode());
|
||||
messageSignDialog.initOwner(getTreeTableView().getScene().getWindow());
|
||||
messageSignDialog.showAndWait();
|
||||
});
|
||||
actionBox.getChildren().add(signMessageButton);
|
||||
@@ -216,7 +217,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
|
||||
.map(e -> (HashIndexEntry)e)
|
||||
.filter(e -> e.getType().equals(HashIndexEntry.Type.INPUT) && e.isSpendable())
|
||||
.map(e -> blockTransaction.getTransaction().getInputs().get((int)e.getHashIndex().getIndex()))
|
||||
.filter(TransactionInput::isReplaceByFeeEnabled)
|
||||
.filter(i -> Config.get().isMempoolFullRbf() || i.isReplaceByFeeEnabled())
|
||||
.map(txInput -> walletTxos.keySet().stream().filter(txo -> txo.getHash().equals(txInput.getOutpoint().getHash()) && txo.getIndex() == txInput.getOutpoint().getIndex()).findFirst().get())
|
||||
.collect(Collectors.toList());
|
||||
|
||||
@@ -230,31 +231,47 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
|
||||
.filter(e -> e instanceof HashIndexEntry)
|
||||
.map(e -> (HashIndexEntry)e)
|
||||
.filter(e -> e.getType().equals(HashIndexEntry.Type.OUTPUT))
|
||||
.map(e -> e.getBlockTransaction().getTransaction().getOutputs().get((int)e.getHashIndex().getIndex()))
|
||||
.map(e -> blockTransaction.getTransaction().getOutputs().get((int)e.getHashIndex().getIndex()))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
List<TransactionOutput> consolidationOutputs = transactionEntry.getChildren().stream()
|
||||
.filter(e -> e instanceof HashIndexEntry)
|
||||
.map(e -> (HashIndexEntry)e)
|
||||
.filter(e -> e.getType().equals(HashIndexEntry.Type.OUTPUT) && e.getKeyPurpose() == KeyPurpose.RECEIVE)
|
||||
.map(e -> e.getBlockTransaction().getTransaction().getOutputs().get((int)e.getHashIndex().getIndex()))
|
||||
.map(e -> blockTransaction.getTransaction().getOutputs().get((int)e.getHashIndex().getIndex()))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
boolean consolidationTransaction = consolidationOutputs.size() == blockTransaction.getTransaction().getOutputs().size() && consolidationOutputs.size() == 1;
|
||||
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 that are frozen or created by the transaction that is to be replaced
|
||||
walletUtxos.removeIf(utxo -> utxo.getStatus() == Status.FROZEN || ourOutputs.stream().anyMatch(output -> output.getHash().equals(utxo.getHash()) && output.getIndex() == utxo.getIndex()));
|
||||
Collections.shuffle(walletUtxos);
|
||||
while((double)changeTotal / vSize < getMaxFeeRate() && !walletUtxos.isEmpty() && !cancelTransaction) {
|
||||
//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();
|
||||
vSize += inputSize;
|
||||
if(changeTotal == 0) {
|
||||
//Add change output length to vSize if change was not present on the original transaction
|
||||
TransactionOutput changeOutput = new TransactionOutput(new Transaction(), 1L, transactionEntry.getWallet().getFreshNode(KeyPurpose.CHANGE).getOutputScript());
|
||||
vSize += changeOutput.getLength();
|
||||
}
|
||||
double inputSize = tx.getInputs().get(0).getLength() + (tx.getInputs().get(0).hasWitness() ? (double)tx.getInputs().get(0).getWitness().getLength() / Transaction.WITNESS_SCALE_FACTOR : 0);
|
||||
List<TxoFilter> txoFilters = List.of(new ExcludeTxoFilter(utxos), new SpentTxoFilter(blockTransaction.getHash()), new FrozenTxoFilter(), new CoinbaseTxoFilter(transactionEntry.getWallet()));
|
||||
double feeRate = blockTransaction.getFeeRate() == null ? AppServices.getMinimumRelayFeeRate() : blockTransaction.getFeeRate();
|
||||
List<OutputGroup> outputGroups = transactionEntry.getWallet().getGroupedUtxos(txoFilters, feeRate, AppServices.getMinimumRelayFeeRate(), Config.get().isGroupByAddress())
|
||||
.stream().filter(outputGroup -> outputGroup.getEffectiveValue() >= 0).collect(Collectors.toList());
|
||||
Collections.shuffle(outputGroups);
|
||||
while((double)changeTotal / vSize < getMaxFeeRate() && !outputGroups.isEmpty() && !cancelTransaction && !consolidationTransaction) {
|
||||
//If there is insufficient change output, include another random output group so the fee can be increased
|
||||
OutputGroup outputGroup = outputGroups.remove(0);
|
||||
for(BlockTransactionHashIndex utxo : outputGroup.getUtxos()) {
|
||||
utxos.add(utxo);
|
||||
changeTotal += utxo.getValue();
|
||||
vSize += inputSize;
|
||||
}
|
||||
}
|
||||
|
||||
Long fee = blockTransaction.getFee();
|
||||
if(fee != null) {
|
||||
//Replacement tx fees must be greater than the original tx fees by its minimum relay cost
|
||||
fee += (long)Math.ceil(vSize * AppServices.getMinimumRelayFeeRate());
|
||||
}
|
||||
Long rbfFee = fee;
|
||||
|
||||
List<TransactionOutput> externalOutputs = new ArrayList<>(blockTransaction.getTransaction().getOutputs());
|
||||
externalOutputs.removeAll(ourOutputs);
|
||||
@@ -263,17 +280,21 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
|
||||
List<Payment> payments = externalOutputs.stream().map(txOutput -> {
|
||||
try {
|
||||
String label = transactionEntry.getLabel() == null ? "" : transactionEntry.getLabel();
|
||||
Matcher matcher = REPLACED_BY_FEE_SUFFIX.matcher(label);
|
||||
label = REPLACED_BY_FEE_SUFFIX.matcher(label).replaceAll("$1");
|
||||
String[] paymentLabels = label.split(", ");
|
||||
if(externalOutputs.size() > 1 && externalOutputs.size() == paymentLabels.length) {
|
||||
label = paymentLabels[externalOutputs.indexOf(txOutput)];
|
||||
}
|
||||
Matcher matcher = REPLACED_BY_FEE_SUFFIX.matcher(transactionEntry.getLabel() == null ? "" : transactionEntry.getLabel());
|
||||
if(matcher.matches()) {
|
||||
String base = matcher.group(1);
|
||||
if(matcher.groupCount() > 2 && matcher.group(3) != null) {
|
||||
int count = Integer.parseInt(matcher.group(3)) + 1;
|
||||
label = base + "(Replaced By Fee #" + count + ")";
|
||||
if(matcher.groupCount() > 3 && matcher.group(4) != null) {
|
||||
int count = Integer.parseInt(matcher.group(4)) + 1;
|
||||
label += " (Replaced By Fee #" + count + ")";
|
||||
} else {
|
||||
label = base + "(Replaced By Fee #2)";
|
||||
label += " (Replaced By Fee #2)";
|
||||
}
|
||||
} else {
|
||||
label += (label.isEmpty() ? "" : " ") + "(Replaced By Fee)";
|
||||
label += " (Replaced By Fee)";
|
||||
}
|
||||
|
||||
if(txOutput.getScript().getToAddress() != null) {
|
||||
@@ -315,7 +336,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
|
||||
}
|
||||
|
||||
EventManager.get().post(new SendActionEvent(transactionEntry.getWallet(), utxos));
|
||||
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(transactionEntry.getWallet(), utxos, payments, opReturns.isEmpty() ? null : opReturns, blockTransaction.getFee(), true)));
|
||||
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(transactionEntry.getWallet(), utxos, payments, opReturns.isEmpty() ? null : opReturns, rbfFee, true, blockTransaction)));
|
||||
}
|
||||
|
||||
private static Double getMaxFeeRate() {
|
||||
@@ -339,22 +360,47 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
|
||||
throw new IllegalStateException("Cannot create CPFP without any wallet outputs to spend");
|
||||
}
|
||||
|
||||
BlockTransactionHashIndex utxo = ourOutputs.get(0);
|
||||
BlockTransactionHashIndex cpfpUtxo = ourOutputs.get(0);
|
||||
Address freshAddress = transactionEntry.getWallet().getFreshNode(KeyPurpose.RECEIVE).getAddress();
|
||||
TransactionOutput txOutput = new TransactionOutput(new Transaction(), cpfpUtxo.getValue(), freshAddress.getOutputScript());
|
||||
long dustThreshold = freshAddress.getScriptType().getDustThreshold(txOutput, Transaction.DUST_RELAY_TX_FEE);
|
||||
double inputSize = freshAddress.getScriptType().getInputVbytes();
|
||||
double vSize = inputSize + txOutput.getLength();
|
||||
|
||||
List<TxoFilter> txoFilters = List.of(new ExcludeTxoFilter(List.of(cpfpUtxo)), new SpentTxoFilter(), new FrozenTxoFilter(), new CoinbaseTxoFilter(transactionEntry.getWallet()));
|
||||
double feeRate = blockTransaction.getFeeRate() == null ? AppServices.getMinimumRelayFeeRate() : blockTransaction.getFeeRate();
|
||||
List<OutputGroup> outputGroups = transactionEntry.getWallet().getGroupedUtxos(txoFilters, feeRate, AppServices.getMinimumRelayFeeRate(), Config.get().isGroupByAddress())
|
||||
.stream().filter(outputGroup -> outputGroup.getEffectiveValue() >= 0).collect(Collectors.toList());
|
||||
Collections.shuffle(outputGroups);
|
||||
|
||||
List<BlockTransactionHashIndex> utxos = new ArrayList<>();
|
||||
utxos.add(cpfpUtxo);
|
||||
long inputTotal = cpfpUtxo.getValue();
|
||||
while((inputTotal - (long)(getMaxFeeRate() * vSize)) < dustThreshold && !outputGroups.isEmpty()) {
|
||||
//If there is insufficient input value, include another random output group so the fee can be increased
|
||||
OutputGroup outputGroup = outputGroups.remove(0);
|
||||
for(BlockTransactionHashIndex utxo : outputGroup.getUtxos()) {
|
||||
utxos.add(utxo);
|
||||
inputTotal += utxo.getValue();
|
||||
vSize += inputSize;
|
||||
}
|
||||
}
|
||||
|
||||
WalletNode freshNode = transactionEntry.getWallet().getFreshNode(KeyPurpose.RECEIVE);
|
||||
String label = transactionEntry.getLabel() == null ? "" : transactionEntry.getLabel();
|
||||
label += (label.isEmpty() ? "" : " ") + "(CPFP)";
|
||||
Payment payment = new Payment(freshNode.getAddress(), label, utxo.getValue(), true);
|
||||
Payment payment = new Payment(freshAddress, label, inputTotal, 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), null, blockTransaction.getFee(), false)));
|
||||
EventManager.get().post(new SendActionEvent(transactionEntry.getWallet(), utxos));
|
||||
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(transactionEntry.getWallet(), utxos, List.of(payment), null, blockTransaction.getFee(), true, null)));
|
||||
}
|
||||
|
||||
private static boolean canRBF(BlockTransaction blockTransaction) {
|
||||
return Config.get().isMempoolFullRbf() || blockTransaction.getTransaction().isReplaceByFee();
|
||||
}
|
||||
|
||||
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).getWalletModel().isCard()) &&
|
||||
(!wallet.isBip47() || walletNode.getKeyPurpose() == KeyPurpose.RECEIVE);
|
||||
return wallet.getKeystores().size() == 1 && (!wallet.isBip47() || walletNode.getKeyPurpose() == KeyPurpose.RECEIVE);
|
||||
}
|
||||
|
||||
private static boolean containsWalletOutputs(TransactionEntry transactionEntry) {
|
||||
@@ -427,7 +473,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
|
||||
tooltip += "\nFee rate: " + String.format("%.2f", feeRate) + " sats/vB";
|
||||
}
|
||||
|
||||
tooltip += "\nRBF: " + (transactionEntry.getBlockTransaction().getTransaction().isReplaceByFee() ? "Enabled" : "Disabled");
|
||||
tooltip += "\nRBF: " + (canRBF(transactionEntry.getBlockTransaction()) ? "Enabled" : "Disabled");
|
||||
}
|
||||
|
||||
return tooltip;
|
||||
@@ -504,7 +550,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
|
||||
});
|
||||
getItems().add(viewTransaction);
|
||||
|
||||
if(blockTransaction.getTransaction().isReplaceByFee() && Config.get().isIncludeMempoolOutputs() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) {
|
||||
if(canRBF(blockTransaction) && Config.get().isIncludeMempoolOutputs() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) {
|
||||
MenuItem increaseFee = new MenuItem("Increase Fee (RBF)");
|
||||
increaseFee.setGraphic(getIncreaseFeeRBFGlyph());
|
||||
increaseFee.setOnAction(AE -> {
|
||||
@@ -515,7 +561,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
|
||||
getItems().add(increaseFee);
|
||||
}
|
||||
|
||||
if(blockTransaction.getTransaction().isReplaceByFee() && Config.get().isIncludeMempoolOutputs() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) {
|
||||
if(canRBF(blockTransaction) && Config.get().isIncludeMempoolOutputs() && transactionEntry.getWallet().allInputsFromWallet(blockTransaction.getHash())) {
|
||||
MenuItem cancelTx = new MenuItem("Cancel Transaction (RBF)");
|
||||
cancelTx.setGraphic(getCancelTransactionRBFGlyph());
|
||||
cancelTx.setOnAction(AE -> {
|
||||
@@ -600,7 +646,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
|
||||
}
|
||||
|
||||
public static class AddressContextMenu extends ContextMenu {
|
||||
public AddressContextMenu(Address address, String outputDescriptor, NodeEntry nodeEntry, boolean addUtxoItems) {
|
||||
public AddressContextMenu(Address address, String outputDescriptor, NodeEntry nodeEntry, boolean addUtxoItems, TreeTableView<Entry> treetable) {
|
||||
if(nodeEntry == null || !nodeEntry.getWallet().isBip47()) {
|
||||
MenuItem receiveToAddress = new MenuItem("Receive To");
|
||||
receiveToAddress.setGraphic(getReceiveGlyph());
|
||||
@@ -618,6 +664,7 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
|
||||
signVerifyMessage.setOnAction(AE -> {
|
||||
hide();
|
||||
MessageSignDialog messageSignDialog = new MessageSignDialog(nodeEntry.getWallet(), nodeEntry.getNode());
|
||||
messageSignDialog.initOwner(treetable.getScene().getWindow());
|
||||
messageSignDialog.showAndWait();
|
||||
});
|
||||
getItems().add(signVerifyMessage);
|
||||
@@ -747,6 +794,8 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
|
||||
cell.getStyleClass().remove("transaction-row");
|
||||
cell.getStyleClass().remove("node-row");
|
||||
cell.getStyleClass().remove("utxo-row");
|
||||
cell.getStyleClass().remove("unconfirmed-row");
|
||||
cell.getStyleClass().remove("summary-row");
|
||||
cell.getStyleClass().remove("address-cell");
|
||||
cell.getStyleClass().remove("hashindex-row");
|
||||
cell.getStyleClass().remove("confirming");
|
||||
@@ -781,6 +830,10 @@ public class EntryCell extends TreeTableCell<Entry, Entry> implements Confirmati
|
||||
if(hashIndexEntry.isSpent()) {
|
||||
cell.getStyleClass().add("spent");
|
||||
}
|
||||
} else if(entry instanceof WalletSummaryDialog.UnconfirmedEntry) {
|
||||
cell.getStyleClass().add("unconfirmed-row");
|
||||
} else if(entry instanceof WalletSummaryDialog.SummaryEntry) {
|
||||
cell.getStyleClass().add("summary-row");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.net.FeeRatesSource;
|
||||
import javafx.application.Platform;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.Slider;
|
||||
import javafx.util.StringConverter;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static com.sparrowwallet.sparrow.AppServices.*;
|
||||
|
||||
public class FeeRangeSlider extends Slider {
|
||||
public FeeRangeSlider() {
|
||||
super(0, FEE_RATES_RANGE.size() - 1, 0);
|
||||
setMajorTickUnit(1);
|
||||
setMinorTickCount(0);
|
||||
setSnapToTicks(false);
|
||||
setShowTickLabels(true);
|
||||
setShowTickMarks(true);
|
||||
|
||||
setLabelFormatter(new StringConverter<Double>() {
|
||||
@Override
|
||||
public String toString(Double object) {
|
||||
return Long.toString(FEE_RATES_RANGE.get(object.intValue()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Double fromString(String string) {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
updateTrackHighlight();
|
||||
}
|
||||
|
||||
public double getFeeRate() {
|
||||
return Math.pow(2.0, getValue());
|
||||
}
|
||||
|
||||
public void setFeeRate(double feeRate) {
|
||||
setValue(Math.log(feeRate) / Math.log(2));
|
||||
}
|
||||
|
||||
public void updateTrackHighlight() {
|
||||
addFeeRangeTrackHighlight(0);
|
||||
}
|
||||
|
||||
private void addFeeRangeTrackHighlight(int count) {
|
||||
Platform.runLater(() -> {
|
||||
Node track = lookup(".track");
|
||||
if(track != null) {
|
||||
Map<Integer, Double> targetBlocksFeeRates = getTargetBlocksFeeRates();
|
||||
String highlight = "";
|
||||
if(targetBlocksFeeRates.get(Integer.MAX_VALUE) != null) {
|
||||
highlight += "#a0a1a766 " + getPercentageOfFeeRange(targetBlocksFeeRates.get(Integer.MAX_VALUE)) + "%, ";
|
||||
}
|
||||
highlight += "#41a9c966 " + getPercentageOfFeeRange(targetBlocksFeeRates, FeeRatesSource.BLOCKS_IN_TWO_HOURS - 1) + "%, ";
|
||||
highlight += "#fba71b66 " + getPercentageOfFeeRange(targetBlocksFeeRates, FeeRatesSource.BLOCKS_IN_HOUR - 1) + "%, ";
|
||||
highlight += "#c8416466 " + getPercentageOfFeeRange(targetBlocksFeeRates, FeeRatesSource.BLOCKS_IN_HALF_HOUR - 1) + "%";
|
||||
|
||||
track.setStyle("-fx-background-color: " +
|
||||
"-fx-shadow-highlight-color, " +
|
||||
"linear-gradient(to bottom, derive(-fx-text-box-border, -10%), -fx-text-box-border), " +
|
||||
"linear-gradient(to bottom, derive(-fx-control-inner-background, -9%), derive(-fx-control-inner-background, 0%), derive(-fx-control-inner-background, -5%), derive(-fx-control-inner-background, -12%)), " +
|
||||
"linear-gradient(to right, " + highlight + ")");
|
||||
} else if(count < 20) {
|
||||
addFeeRangeTrackHighlight(count+1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private Map<Integer, Double> getTargetBlocksFeeRates() {
|
||||
Map<Integer, Double> retrievedFeeRates = AppServices.getTargetBlockFeeRates();
|
||||
if(retrievedFeeRates == null) {
|
||||
retrievedFeeRates = TARGET_BLOCKS_RANGE.stream().collect(Collectors.toMap(java.util.function.Function.identity(), v -> FALLBACK_FEE_RATE,
|
||||
(u, v) -> { throw new IllegalStateException("Duplicate target blocks"); },
|
||||
LinkedHashMap::new));
|
||||
}
|
||||
|
||||
return retrievedFeeRates;
|
||||
}
|
||||
|
||||
private int getPercentageOfFeeRange(Map<Integer, Double> targetBlocksFeeRates, Integer minTargetBlocks) {
|
||||
List<Integer> rates = new ArrayList<>(targetBlocksFeeRates.keySet());
|
||||
Collections.reverse(rates);
|
||||
for(Integer targetBlocks : rates) {
|
||||
if(targetBlocks < minTargetBlocks) {
|
||||
return getPercentageOfFeeRange(targetBlocksFeeRates.get(targetBlocks));
|
||||
}
|
||||
}
|
||||
|
||||
return 100;
|
||||
}
|
||||
|
||||
private int getPercentageOfFeeRange(Double feeRate) {
|
||||
double index = Math.log(feeRate) / Math.log(2);
|
||||
return (int)Math.round(index * 10.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.protocol.Transaction;
|
||||
import com.sparrowwallet.sparrow.CurrencyRate;
|
||||
import com.sparrowwallet.sparrow.UnitFormat;
|
||||
import com.sparrowwallet.sparrow.wallet.Entry;
|
||||
import javafx.scene.control.ContextMenu;
|
||||
import javafx.scene.control.MenuItem;
|
||||
import javafx.scene.control.Tooltip;
|
||||
import javafx.scene.control.TreeTableCell;
|
||||
import javafx.scene.input.Clipboard;
|
||||
import javafx.scene.input.ClipboardContent;
|
||||
import org.controlsfx.tools.Platform;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Currency;
|
||||
|
||||
public class FiatCell extends TreeTableCell<Entry, Number> {
|
||||
private final Tooltip tooltip;
|
||||
private final FiatContextMenu contextMenu;
|
||||
|
||||
public FiatCell() {
|
||||
super();
|
||||
tooltip = new Tooltip();
|
||||
contextMenu = new FiatContextMenu();
|
||||
getStyleClass().add("coin-cell");
|
||||
if(Platform.getCurrent() == Platform.OSX) {
|
||||
getStyleClass().add("number-field");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void updateItem(Number amount, boolean empty) {
|
||||
super.updateItem(amount, empty);
|
||||
|
||||
if(empty || amount == null) {
|
||||
setText(null);
|
||||
setGraphic(null);
|
||||
setTooltip(null);
|
||||
setContextMenu(null);
|
||||
} else {
|
||||
Entry entry = getTreeTableView().getTreeItem(getIndex()).getValue();
|
||||
EntryCell.applyRowStyles(this, entry);
|
||||
|
||||
CoinTreeTable coinTreeTable = (CoinTreeTable) getTreeTableView();
|
||||
UnitFormat format = coinTreeTable.getUnitFormat();
|
||||
CurrencyRate currencyRate = coinTreeTable.getCurrencyRate();
|
||||
|
||||
if(currencyRate != null && currencyRate.isAvailable()) {
|
||||
Currency currency = currencyRate.getCurrency();
|
||||
double btcRate = currencyRate.getBtcRate();
|
||||
|
||||
BigDecimal satsBalance = BigDecimal.valueOf(amount.longValue());
|
||||
BigDecimal btcBalance = satsBalance.divide(BigDecimal.valueOf(Transaction.SATOSHIS_PER_BITCOIN));
|
||||
BigDecimal fiatBalance = btcBalance.multiply(BigDecimal.valueOf(btcRate));
|
||||
|
||||
String label = format.formatCurrencyValue(fiatBalance.doubleValue());
|
||||
tooltip.setText("1 BTC = " + currency.getSymbol() + " " + format.formatCurrencyValue(btcRate));
|
||||
|
||||
setText(label);
|
||||
setGraphic(null);
|
||||
setTooltip(tooltip);
|
||||
setContextMenu(contextMenu);
|
||||
} else {
|
||||
setText(null);
|
||||
setGraphic(null);
|
||||
setTooltip(null);
|
||||
setContextMenu(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class FiatContextMenu extends ContextMenu {
|
||||
public FiatContextMenu() {
|
||||
MenuItem copyValue = new MenuItem("Copy Value");
|
||||
copyValue.setOnAction(AE -> {
|
||||
hide();
|
||||
ClipboardContent content = new ClipboardContent();
|
||||
content.putString(getText());
|
||||
Clipboard.getSystemClipboard().setContent(content);
|
||||
});
|
||||
|
||||
MenuItem copyRate = new MenuItem("Copy Rate");
|
||||
copyRate.setOnAction(AE -> {
|
||||
hide();
|
||||
ClipboardContent content = new ClipboardContent();
|
||||
content.putString(getTooltip().getText());
|
||||
Clipboard.getSystemClipboard().setContent(content);
|
||||
});
|
||||
|
||||
getItems().addAll(copyValue, copyRate);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -150,6 +150,7 @@ public abstract class FileImportPane extends TitledDescriptionPane {
|
||||
|
||||
private void importQR() {
|
||||
QRScanDialog qrScanDialog = new QRScanDialog();
|
||||
qrScanDialog.initOwner(this.getScene().getWindow());
|
||||
Optional<QRScanDialog.Result> optionalResult = qrScanDialog.showAndWait();
|
||||
if(optionalResult.isPresent()) {
|
||||
QRScanDialog.Result result = optionalResult.get();
|
||||
|
||||
@@ -116,6 +116,7 @@ public class FileKeystoreExportPane extends TitledDescriptionPane {
|
||||
|
||||
if(keystore.getSource() == KeystoreSource.HW_USB || keystore.getWalletModel().isCard()) {
|
||||
TextAreaDialog dialog = new TextAreaDialog(message, false);
|
||||
dialog.initOwner(this.getScene().getWindow());
|
||||
dialog.setTitle("Sign " + exporter.getName() + " Export");
|
||||
dialog.getDialogPane().setHeaderText("The following text needs to be signed by the device.\nClick OK to continue.");
|
||||
dialog.showAndWait();
|
||||
@@ -126,6 +127,7 @@ public class FileKeystoreExportPane extends TitledDescriptionPane {
|
||||
List<String> operationFingerprints = List.of(keystore.getKeyDerivation().getMasterFingerprint());
|
||||
|
||||
DeviceSignMessageDialog deviceSignMessageDialog = new DeviceSignMessageDialog(operationFingerprints, wallet, message, keystore.getKeyDerivation());
|
||||
deviceSignMessageDialog.initOwner(this.getScene().getWindow());
|
||||
Optional<String> optSignature = deviceSignMessageDialog.showAndWait();
|
||||
if(optSignature.isPresent()) {
|
||||
exporter.addSignature(keystore, optSignature.get(), baos);
|
||||
@@ -155,6 +157,7 @@ public class FileKeystoreExportPane extends TitledDescriptionPane {
|
||||
} else {
|
||||
qrDisplayDialog = new QRDisplayDialog(baos.toString(StandardCharsets.UTF_8));
|
||||
}
|
||||
qrDisplayDialog.initOwner(buttonBox.getScene().getWindow());
|
||||
qrDisplayDialog.showAndWait();
|
||||
}
|
||||
} catch(Exception e) {
|
||||
|
||||
@@ -111,6 +111,7 @@ public class FileWalletExportPane extends TitledDescriptionPane {
|
||||
if(wallet.isEncrypted() && exporter.walletExportRequiresDecryption()) {
|
||||
Wallet copy = wallet.copy();
|
||||
WalletPasswordDialog dlg = new WalletPasswordDialog(wallet.getMasterName(), WalletPasswordDialog.PasswordRequirement.LOAD);
|
||||
dlg.initOwner(buttonBox.getScene().getWindow());
|
||||
Optional<SecureString> password = dlg.showAndWait();
|
||||
if(password.isPresent()) {
|
||||
final String walletId = AppServices.get().getOpenWallets().get(wallet).getWalletId(wallet);
|
||||
@@ -155,6 +156,7 @@ public class FileWalletExportPane extends TitledDescriptionPane {
|
||||
} else {
|
||||
qrDisplayDialog = new QRDisplayDialog(outputStream.toString(StandardCharsets.UTF_8));
|
||||
}
|
||||
qrDisplayDialog.initOwner(buttonBox.getScene().getWindow());
|
||||
qrDisplayDialog.showAndWait();
|
||||
}
|
||||
} catch(Exception e) {
|
||||
|
||||
@@ -12,7 +12,7 @@ public class FileWalletImportPane extends FileImportPane {
|
||||
private final WalletImport importer;
|
||||
|
||||
public FileWalletImportPane(WalletImport importer) {
|
||||
super(importer, importer.getName(), "Wallet import", importer.getWalletImportDescription(), "image/" + importer.getWalletModel().getType() + ".png", importer.isWalletImportScannable(), true);
|
||||
super(importer, importer.getName(), "Wallet import", importer.getWalletImportDescription(), "image/" + importer.getWalletModel().getType() + ".png", importer.isWalletImportScannable(), importer.isWalletImportFileFormatAvailable());
|
||||
this.importer = importer;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,16 +5,16 @@ import com.sparrowwallet.drongo.KeyDerivation;
|
||||
import com.sparrowwallet.drongo.SecureString;
|
||||
import com.sparrowwallet.drongo.address.Address;
|
||||
import com.sparrowwallet.drongo.address.InvalidAddressException;
|
||||
import com.sparrowwallet.drongo.crypto.Bip322;
|
||||
import com.sparrowwallet.drongo.crypto.ECKey;
|
||||
import com.sparrowwallet.drongo.policy.PolicyType;
|
||||
import com.sparrowwallet.drongo.protocol.ScriptType;
|
||||
import com.sparrowwallet.drongo.wallet.*;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.event.OpenWalletsEvent;
|
||||
import com.sparrowwallet.sparrow.event.RequestOpenWalletsEvent;
|
||||
import com.sparrowwallet.sparrow.event.StorageEvent;
|
||||
import com.sparrowwallet.sparrow.event.TimedEvent;
|
||||
import com.sparrowwallet.sparrow.event.*;
|
||||
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
|
||||
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5Brands;
|
||||
import com.sparrowwallet.sparrow.io.Storage;
|
||||
import javafx.application.Platform;
|
||||
import javafx.scene.control.*;
|
||||
@@ -22,6 +22,7 @@ import javafx.scene.image.Image;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.layout.VBox;
|
||||
import org.controlsfx.control.SegmentedButton;
|
||||
import org.controlsfx.glyphfont.Glyph;
|
||||
import org.controlsfx.validation.ValidationResult;
|
||||
import org.controlsfx.validation.ValidationSupport;
|
||||
import org.controlsfx.validation.decoration.StyleClassValidationDecoration;
|
||||
@@ -37,6 +38,8 @@ import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
|
||||
import static com.sparrowwallet.sparrow.AppServices.showErrorDialog;
|
||||
|
||||
public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
private static final Logger log = LoggerFactory.getLogger(MessageSignDialog.class);
|
||||
|
||||
@@ -46,9 +49,10 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
private final ToggleGroup formatGroup;
|
||||
private final ToggleButton formatTrezor;
|
||||
private final ToggleButton formatElectrum;
|
||||
private final ToggleButton formatBip322;
|
||||
private final Wallet wallet;
|
||||
private WalletNode walletNode;
|
||||
private boolean electrumSignatureFormat;
|
||||
private boolean canSign;
|
||||
private boolean closed;
|
||||
|
||||
/**
|
||||
@@ -89,10 +93,12 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
public MessageSignDialog(Wallet wallet, WalletNode walletNode, String title, String msg, ButtonType... buttons) {
|
||||
if(walletNode != null) {
|
||||
checkWalletSigning(walletNode.getWallet());
|
||||
this.canSign = canSign(walletNode.getWallet());
|
||||
}
|
||||
|
||||
if(wallet != null) {
|
||||
checkWalletSigning(wallet);
|
||||
this.canSign = canSign(wallet);
|
||||
}
|
||||
|
||||
this.wallet = wallet;
|
||||
@@ -125,7 +131,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
address = new TextField();
|
||||
address.getStyleClass().add("id");
|
||||
address.setEditable(walletNode == null);
|
||||
address.setTooltip(new Tooltip("Only Legacy (P2PKH), Nested Segwit (P2SH-P2WPKH) and Native Segwit (P2WPKH) singlesig addresses can sign"));
|
||||
address.setTooltip(new Tooltip("Only singlesig addresses can sign"));
|
||||
addressField.getInputs().add(address);
|
||||
|
||||
if(walletNode != null) {
|
||||
@@ -136,17 +142,18 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
messageField.setText("Message:");
|
||||
message = new TextArea();
|
||||
message.setWrapText(true);
|
||||
message.setPrefRowCount(10);
|
||||
message.setStyle("-fx-pref-height: 180px");
|
||||
message.setPrefRowCount(8);
|
||||
message.setStyle("-fx-pref-height: 160px");
|
||||
messageField.getInputs().add(message);
|
||||
|
||||
Field signatureField = new Field();
|
||||
signatureField.setText("Signature:");
|
||||
signature = new TextArea();
|
||||
signature.getStyleClass().add("id");
|
||||
signature.setPrefRowCount(2);
|
||||
signature.setStyle("-fx-pref-height: 60px");
|
||||
signature.setPrefRowCount(4);
|
||||
signature.setStyle("-fx-pref-height: 80px");
|
||||
signature.setWrapText(true);
|
||||
signature.setOnMouseClicked(event -> signature.selectAll());
|
||||
signatureField.getInputs().add(signature);
|
||||
|
||||
Field formatField = new Field();
|
||||
@@ -154,16 +161,11 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
formatGroup = new ToggleGroup();
|
||||
formatElectrum = new ToggleButton("Standard (Electrum)");
|
||||
formatTrezor = new ToggleButton("BIP137 (Trezor)");
|
||||
SegmentedButton formatButtons = new SegmentedButton(formatElectrum, formatTrezor);
|
||||
formatBip322 = new ToggleButton("BIP322 (Simple)");
|
||||
SegmentedButton formatButtons = new SegmentedButton(formatElectrum, formatTrezor, formatBip322);
|
||||
formatButtons.setToggleGroup(formatGroup);
|
||||
formatField.getInputs().add(formatButtons);
|
||||
|
||||
formatGroup.selectedToggleProperty().addListener((observable, oldValue, newValue) -> {
|
||||
electrumSignatureFormat = (newValue == formatElectrum);
|
||||
});
|
||||
|
||||
formatButtons.setDisable(wallet != null && walletNode != null && wallet.getScriptType() == ScriptType.P2PKH);
|
||||
|
||||
fieldset.getChildren().addAll(addressField, messageField, signatureField, formatField);
|
||||
form.getChildren().add(fieldset);
|
||||
dialogPane.setContent(form);
|
||||
@@ -176,6 +178,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
formatButtons.setDisable(true);
|
||||
}
|
||||
|
||||
ButtonType showQrButtonType = new javafx.scene.control.ButtonType("Sign by QR", ButtonBar.ButtonData.LEFT);
|
||||
ButtonType signButtonType = new javafx.scene.control.ButtonType("Sign", ButtonBar.ButtonData.BACK_PREVIOUS);
|
||||
ButtonType verifyButtonType = new javafx.scene.control.ButtonType("Verify", ButtonBar.ButtonData.NEXT_FORWARD);
|
||||
ButtonType doneButtonType = new javafx.scene.control.ButtonType("Done", ButtonBar.ButtonData.CANCEL_CLOSE);
|
||||
@@ -194,10 +197,20 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
});
|
||||
}
|
||||
} else {
|
||||
dialogPane.getButtonTypes().addAll(signButtonType, verifyButtonType, doneButtonType);
|
||||
dialogPane.getButtonTypes().addAll(showQrButtonType, signButtonType, verifyButtonType, doneButtonType);
|
||||
|
||||
Button showQrButton = (Button) dialogPane.lookupButton(showQrButtonType);
|
||||
showQrButton.setDisable(wallet == null);
|
||||
showQrButton.setGraphic(getGlyph(new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.QRCODE)));
|
||||
showQrButton.setGraphicTextGap(5);
|
||||
showQrButton.setOnAction(event -> {
|
||||
showQr();
|
||||
});
|
||||
|
||||
Button signButton = (Button) dialogPane.lookupButton(signButtonType);
|
||||
signButton.setDisable(wallet == null);
|
||||
signButton.setDisable(!canSign);
|
||||
signButton.setGraphic(getGlyph(getSignGlyph()));
|
||||
signButton.setGraphicTextGap(5);
|
||||
signButton.setOnAction(event -> {
|
||||
signMessage();
|
||||
});
|
||||
@@ -209,7 +222,8 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
});
|
||||
|
||||
boolean validAddress = isValidAddress();
|
||||
signButton.setDisable(!validAddress || (wallet == null));
|
||||
showQrButton.setDisable(!validAddress || (wallet == null));
|
||||
signButton.setDisable(!validAddress || !canSign);
|
||||
verifyButton.setDisable(!validAddress);
|
||||
|
||||
ValidationSupport validationSupport = new ValidationSupport();
|
||||
@@ -220,13 +234,17 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
|
||||
address.textProperty().addListener((observable, oldValue, newValue) -> {
|
||||
boolean valid = isValidAddress();
|
||||
signButton.setDisable(!valid || (wallet == null));
|
||||
showQrButton.setDisable(!valid || (wallet == null));
|
||||
signButton.setDisable(!valid || !canSign);
|
||||
verifyButton.setDisable(!valid);
|
||||
|
||||
if(valid && wallet != null) {
|
||||
if(valid) {
|
||||
try {
|
||||
Address address = getAddress();
|
||||
setWalletNodeFromAddress(wallet, address);
|
||||
setFormatFromScriptType(address.getScriptType());
|
||||
if(wallet != null) {
|
||||
setWalletNodeFromAddress(wallet, address);
|
||||
}
|
||||
} catch(InvalidAddressException e) {
|
||||
//can't happen
|
||||
}
|
||||
@@ -249,7 +267,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
|
||||
AppServices.onEscapePressed(dialogPane.getScene(), () -> setResult(ButtonBar.ButtonData.CANCEL_CLOSE));
|
||||
AppServices.moveToActiveWindowScreen(this);
|
||||
setResultConverter(dialogButton -> dialogButton == signButtonType || dialogButton == verifyButtonType ? ButtonBar.ButtonData.APPLY : dialogButton.getButtonData());
|
||||
setResultConverter(dialogButton -> dialogButton == showQrButtonType || dialogButton == signButtonType || dialogButton == verifyButtonType ? ButtonBar.ButtonData.APPLY : dialogButton.getButtonData());
|
||||
|
||||
Platform.runLater(() -> {
|
||||
if(address.getText().isEmpty()) {
|
||||
@@ -258,7 +276,11 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
message.requestFocus();
|
||||
}
|
||||
|
||||
formatGroup.selectToggle(formatElectrum);
|
||||
if(wallet != null && walletNode != null) {
|
||||
setFormatFromScriptType(wallet.getScriptType());
|
||||
} else {
|
||||
formatGroup.selectToggle(formatElectrum);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -266,9 +288,12 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
if(wallet.getKeystores().size() != 1) {
|
||||
throw new IllegalArgumentException("Cannot sign messages using a wallet with multiple keystores - a single key is required");
|
||||
}
|
||||
if(!wallet.getKeystores().get(0).hasPrivateKey() && wallet.getKeystores().get(0).getSource() != KeystoreSource.HW_USB && !wallet.getKeystores().get(0).getWalletModel().isCard()) {
|
||||
throw new IllegalArgumentException("Cannot sign messages using a wallet without private keys or a connected keystore");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean canSign(Wallet wallet) {
|
||||
return wallet.getKeystores().get(0).hasPrivateKey()
|
||||
|| wallet.getKeystores().get(0).getSource() == KeystoreSource.HW_USB
|
||||
|| wallet.getKeystores().get(0).getWalletModel().isCard();
|
||||
}
|
||||
|
||||
private Address getAddress()throws InvalidAddressException {
|
||||
@@ -279,20 +304,10 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
return signature.getText();
|
||||
}
|
||||
|
||||
/**
|
||||
* Use the Electrum signing format, which uses the non-segwit compressed signing parameters for both segwit types (p2sh-p2wpkh and p2wpkh)
|
||||
*
|
||||
* @param electrumSignatureFormat
|
||||
*/
|
||||
public void setElectrumSignatureFormat(boolean electrumSignatureFormat) {
|
||||
formatGroup.selectToggle(electrumSignatureFormat ? formatElectrum : formatTrezor);
|
||||
this.electrumSignatureFormat = electrumSignatureFormat;
|
||||
}
|
||||
|
||||
private boolean isValidAddress() {
|
||||
try {
|
||||
Address address = getAddress();
|
||||
return address.getScriptType() != ScriptType.P2TR && (address.getScriptType().isAllowed(PolicyType.SINGLE) || address.getScriptType() == ScriptType.P2SH);
|
||||
return address.getScriptType().isAllowed(PolicyType.SINGLE) || address.getScriptType() == ScriptType.P2SH;
|
||||
} catch (InvalidAddressException e) {
|
||||
return false;
|
||||
}
|
||||
@@ -302,12 +317,36 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
walletNode = wallet.getWalletAddresses().get(address);
|
||||
}
|
||||
|
||||
private void setFormatFromScriptType(ScriptType scriptType) {
|
||||
formatElectrum.setDisable(scriptType == ScriptType.P2TR);
|
||||
formatTrezor.setDisable(scriptType == ScriptType.P2TR || scriptType == ScriptType.P2PKH);
|
||||
formatBip322.setDisable(scriptType != ScriptType.P2WPKH && scriptType != ScriptType.P2TR);
|
||||
if(scriptType == ScriptType.P2TR) {
|
||||
formatGroup.selectToggle(formatBip322);
|
||||
} else if(formatGroup.getSelectedToggle() == null || scriptType == ScriptType.P2PKH || (scriptType != ScriptType.P2WPKH && formatBip322.isSelected())) {
|
||||
formatGroup.selectToggle(formatElectrum);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isBip322() {
|
||||
return formatBip322.isSelected();
|
||||
}
|
||||
|
||||
private boolean isElectrumSignatureFormat() {
|
||||
return formatElectrum.isSelected();
|
||||
}
|
||||
|
||||
private void signMessage() {
|
||||
if(walletNode == null) {
|
||||
AppServices.showErrorDialog("Address not in wallet", "The provided address is not present in the currently selected wallet.");
|
||||
return;
|
||||
}
|
||||
|
||||
if(!canSign) {
|
||||
AppServices.showErrorDialog("Wallet can't sign", "This wallet cannot sign a message.");
|
||||
return;
|
||||
}
|
||||
|
||||
//Note we can expect a single keystore due to the check in the constructor
|
||||
Wallet signingWallet = walletNode.getWallet();
|
||||
if(signingWallet.getKeystores().get(0).hasPrivateKey()) {
|
||||
@@ -325,8 +364,14 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
try {
|
||||
Keystore keystore = decryptedWallet.getKeystores().get(0);
|
||||
ECKey privKey = keystore.getKey(walletNode);
|
||||
ScriptType scriptType = electrumSignatureFormat ? ScriptType.P2PKH : decryptedWallet.getScriptType();
|
||||
String signatureText = privKey.signMessage(message.getText().trim(), scriptType);
|
||||
String signatureText;
|
||||
if(isBip322()) {
|
||||
ScriptType scriptType = decryptedWallet.getScriptType();
|
||||
signatureText = Bip322.signMessageBip322(scriptType, message.getText().trim(), privKey);
|
||||
} else {
|
||||
ScriptType scriptType = isElectrumSignatureFormat() ? ScriptType.P2PKH : decryptedWallet.getScriptType();
|
||||
signatureText = privKey.signMessage(message.getText().trim(), scriptType);
|
||||
}
|
||||
signature.clear();
|
||||
signature.appendText(signatureText);
|
||||
privKey.clear();
|
||||
@@ -340,6 +385,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
List<String> fingerprints = List.of(deviceWallet.getKeystores().get(0).getKeyDerivation().getMasterFingerprint());
|
||||
KeyDerivation fullDerivation = deviceWallet.getKeystores().get(0).getKeyDerivation().extend(walletNode.getDerivation());
|
||||
DeviceSignMessageDialog deviceSignMessageDialog = new DeviceSignMessageDialog(fingerprints, deviceWallet, message.getText().trim(), fullDerivation);
|
||||
deviceSignMessageDialog.initOwner(getDialogPane().getScene().getWindow());
|
||||
Optional<String> optSignature = deviceSignMessageDialog.showAndWait();
|
||||
if(optSignature.isPresent()) {
|
||||
signature.clear();
|
||||
@@ -353,16 +399,33 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
//http://www.secg.org/download/aid-780/sec1-v2.pdf section 4.1.6
|
||||
boolean verified = false;
|
||||
try {
|
||||
ECKey signedMessageKey = ECKey.signedMessageToKey(message.getText().trim(), signature.getText().trim(), false);
|
||||
ECKey signedMessageKey = ECKey.signedMessageToKey(message.getText().trim(), signature.getText().trim(), true);
|
||||
verified = verifyMessage(signedMessageKey);
|
||||
if(verified) {
|
||||
formatGroup.selectToggle(formatElectrum);
|
||||
}
|
||||
} catch(SignatureException e) {
|
||||
//ignore
|
||||
}
|
||||
|
||||
if(!verified) {
|
||||
try {
|
||||
ECKey electrumSignedMessageKey = ECKey.signedMessageToKey(message.getText(), signature.getText(), true);
|
||||
ECKey electrumSignedMessageKey = ECKey.signedMessageToKey(message.getText(), signature.getText(), false);
|
||||
verified = verifyMessage(electrumSignedMessageKey);
|
||||
if(verified) {
|
||||
formatGroup.selectToggle(formatTrezor);
|
||||
}
|
||||
} catch(SignatureException e) {
|
||||
//ignore
|
||||
}
|
||||
}
|
||||
|
||||
if(!verified && Bip322.isSupported(getAddress().getScriptType()) && !signature.getText().trim().isEmpty()) {
|
||||
try {
|
||||
verified = Bip322.verifyMessageBip322(getAddress().getScriptType(), getAddress(), message.getText().trim(), signature.getText().trim());
|
||||
if(verified) {
|
||||
formatGroup.selectToggle(formatBip322);
|
||||
}
|
||||
} catch(SignatureException e) {
|
||||
//ignore
|
||||
}
|
||||
@@ -395,6 +458,60 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
return providedAddress.equals(signedMessageAddress);
|
||||
}
|
||||
|
||||
private void showQr() {
|
||||
if(walletNode == null) {
|
||||
AppServices.showErrorDialog("Address not in wallet", "The provided address is not present in the currently selected wallet.");
|
||||
return;
|
||||
}
|
||||
|
||||
//Note we can expect a single keystore due to the check in the constructor
|
||||
KeyDerivation firstDerivation = walletNode.getWallet().getKeystores().get(0).getKeyDerivation();
|
||||
String derivationPath = KeyDerivation.writePath(firstDerivation.extend(walletNode.getDerivation()).getDerivation(), false);
|
||||
|
||||
String qrText = "signmessage " + derivationPath + " ascii:" + message.getText().trim();
|
||||
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(qrText, true);
|
||||
qrDisplayDialog.initOwner(getDialogPane().getScene().getWindow());
|
||||
Optional<ButtonType> optButtonType = qrDisplayDialog.showAndWait();
|
||||
if(optButtonType.isPresent() && optButtonType.get().getButtonData() == ButtonBar.ButtonData.NEXT_FORWARD) {
|
||||
scanQr();
|
||||
}
|
||||
}
|
||||
|
||||
private void scanQr() {
|
||||
QRScanDialog qrScanDialog = new QRScanDialog();
|
||||
qrScanDialog.initOwner(getDialogPane().getScene().getWindow());
|
||||
Optional<QRScanDialog.Result> optionalResult = qrScanDialog.showAndWait();
|
||||
if(optionalResult.isPresent()) {
|
||||
QRScanDialog.Result result = optionalResult.get();
|
||||
if(result.payload != null) {
|
||||
signature.clear();
|
||||
signature.appendText(result.payload);
|
||||
} 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 signature.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected Glyph getSignGlyph() {
|
||||
if(wallet != null) {
|
||||
if(wallet.containsSource(KeystoreSource.HW_USB)) {
|
||||
return new Glyph(FontAwesome5Brands.FONT_NAME, FontAwesome5Brands.Glyph.USB);
|
||||
} else if(wallet.getKeystores().get(0).getWalletModel().isCard()) {
|
||||
return new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.WIFI);
|
||||
}
|
||||
}
|
||||
|
||||
return new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.PEN_FANCY);
|
||||
}
|
||||
|
||||
private static Glyph getGlyph(Glyph glyph) {
|
||||
glyph.setFontSize(11);
|
||||
return glyph;
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void openWallets(OpenWalletsEvent event) {
|
||||
Storage storage = event.getStorage(wallet);
|
||||
@@ -404,6 +521,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
}
|
||||
|
||||
WalletPasswordDialog dlg = new WalletPasswordDialog(wallet.getMasterName(), WalletPasswordDialog.PasswordRequirement.LOAD);
|
||||
dlg.initOwner(getDialogPane().getScene().getWindow());
|
||||
Optional<SecureString> password = dlg.showAndWait();
|
||||
if(password.isPresent()) {
|
||||
Storage.DecryptWalletService decryptWalletService = new Storage.DecryptWalletService(walletNode.getWallet().copy(), password.get());
|
||||
@@ -415,7 +533,7 @@ public class MessageSignDialog extends Dialog<ButtonBar.ButtonData> {
|
||||
});
|
||||
decryptWalletService.setOnFailed(workerStateEvent -> {
|
||||
EventManager.get().post(new StorageEvent(storage.getWalletId(wallet), TimedEvent.Action.END, "Failed"));
|
||||
AppServices.showErrorDialog("Incorrect Password", decryptWalletService.getException().getMessage());
|
||||
AppServices.showErrorDialog("Incorrect Password", "The password was incorrect.");
|
||||
});
|
||||
EventManager.get().post(new StorageEvent(storage.getWalletId(wallet), TimedEvent.Action.START, "Decrypting wallet..."));
|
||||
decryptWalletService.start();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.Utils;
|
||||
import com.sparrowwallet.drongo.wallet.Bip39MnemonicCode;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
|
||||
@@ -169,20 +170,31 @@ public class MnemonicGridDialog extends Dialog<List<String>> {
|
||||
List<String> abbreviations = selectedCells.stream()
|
||||
.map(position -> (String)spreadsheetView.getGrid().getRows().get(position.getRow()).get(position.getColumn()).getItem()).collect(Collectors.toList());
|
||||
|
||||
boolean isInteger = abbreviations.stream().allMatch(Utils::isNumber);
|
||||
boolean isHex = abbreviations.stream().allMatch(Utils::isHex);
|
||||
|
||||
List<String> words = new ArrayList<>();
|
||||
for(String abbreviation : abbreviations) {
|
||||
for(String word : Bip39MnemonicCode.INSTANCE.getWordList()) {
|
||||
if((abbreviation.length() == 3 && word.equals(abbreviation)) || (abbreviation.length() >= 4 && word.startsWith(abbreviation))) {
|
||||
words.add(word);
|
||||
break;
|
||||
if(isInteger) {
|
||||
try {
|
||||
int index = Integer.parseInt(abbreviation);
|
||||
words.add(Bip39MnemonicCode.INSTANCE.getWordList().get(index - 1));
|
||||
} catch(NumberFormatException e) {
|
||||
//ignore
|
||||
}
|
||||
} else if(isHex) {
|
||||
try {
|
||||
int index = Integer.parseInt(abbreviation, 16);
|
||||
words.add(Bip39MnemonicCode.INSTANCE.getWordList().get(index - 1));
|
||||
} catch(NumberFormatException e) {
|
||||
//ignore
|
||||
}
|
||||
} else {
|
||||
for(String word : Bip39MnemonicCode.INSTANCE.getWordList()) {
|
||||
if((abbreviation.length() == 3 && word.equals(abbreviation)) || (abbreviation.length() >= 4 && word.startsWith(abbreviation))) {
|
||||
words.add(word);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
int index = Integer.parseInt(abbreviation);
|
||||
words.add(Bip39MnemonicCode.INSTANCE.getWordList().get(index - 1));
|
||||
} catch(NumberFormatException e) {
|
||||
//ignore
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,6 +281,7 @@ public class MnemonicGridDialog extends Dialog<List<String>> {
|
||||
ButtonBar.setButtonData(generateButton, buttonData);
|
||||
generateButton.setOnAction(event -> {
|
||||
SeedEntryDialog seedEntryDialog = new SeedEntryDialog("Border Wallets Entropy Grid Recovery Seed", 12);
|
||||
seedEntryDialog.initOwner(getDialogPane().getScene().getWindow());
|
||||
Optional<List<String>> optWords = seedEntryDialog.showAndWait();
|
||||
if(optWords.isPresent()) {
|
||||
List<String> mnemonicWords = optWords.get();
|
||||
|
||||
@@ -254,6 +254,7 @@ public class MnemonicKeystoreImportPane extends MnemonicKeystorePane {
|
||||
if(!dryrun) {
|
||||
if(passphraseProperty.get() != null && !passphraseProperty.get().isEmpty()) {
|
||||
KeystorePassphraseDialog keystorePassphraseDialog = new KeystorePassphraseDialog(null, keystore, true);
|
||||
keystorePassphraseDialog.initOwner(this.getScene().getWindow());
|
||||
Optional<String> optPassphrase = keystorePassphraseDialog.showAndWait();
|
||||
if(optPassphrase.isEmpty() || !optPassphrase.get().equals(passphraseProperty.get())) {
|
||||
throw new ImportException("Re-entered passphrase did not match");
|
||||
|
||||
@@ -97,6 +97,7 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
|
||||
|
||||
protected void showGrid() {
|
||||
MnemonicGridDialog mnemonicGridDialog = new MnemonicGridDialog();
|
||||
mnemonicGridDialog.initOwner(this.getScene().getWindow());
|
||||
Optional<List<String>> optWords = mnemonicGridDialog.showAndWait();
|
||||
if(optWords.isPresent()) {
|
||||
List<String> words = optWords.get();
|
||||
@@ -134,6 +135,7 @@ public class MnemonicKeystorePane extends TitledDescriptionPane {
|
||||
|
||||
protected void scanQR() {
|
||||
QRScanDialog qrScanDialog = new QRScanDialog();
|
||||
qrScanDialog.initOwner(this.getScene().getWindow());
|
||||
Optional<QRScanDialog.Result> optionalResult = qrScanDialog.showAndWait();
|
||||
if(optionalResult.isPresent()) {
|
||||
QRScanDialog.Result result = optionalResult.get();
|
||||
|
||||
@@ -109,6 +109,7 @@ public class MnemonicWalletKeystoreImportPane extends MnemonicKeystorePane {
|
||||
|
||||
List<List<ChildNumber>> derivations = ScriptType.getScriptTypesForPolicyType(PolicyType.SINGLE).stream().map(ScriptType::getDefaultDerivation).collect(Collectors.toList());
|
||||
derivations.add(List.of(new ChildNumber(0, true)));
|
||||
derivations.add(ScriptType.P2PKH.getDefaultDerivation(1)); //Bisq segwit misderivation
|
||||
|
||||
for(ScriptType scriptType : ScriptType.getScriptTypesForPolicyType(PolicyType.SINGLE)) {
|
||||
for(List<ChildNumber> derivation : derivations) {
|
||||
|
||||
@@ -110,6 +110,7 @@ public class PayNymCell extends ListCell<PayNym> {
|
||||
MenuItem rename = new MenuItem("Rename Contact...");
|
||||
rename.setOnAction(event -> {
|
||||
WalletLabelDialog walletLabelDialog = new WalletLabelDialog(payNym.nymName(), "Contact");
|
||||
walletLabelDialog.initOwner(PayNymCell.this.getScene().getWindow());
|
||||
Optional<String> optLabel = walletLabelDialog.showAndWait();
|
||||
if(optLabel.isPresent()) {
|
||||
int index = getListView().getItems().indexOf(payNym);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.google.common.base.Throwables;
|
||||
import com.google.common.eventbus.Subscribe;
|
||||
import com.google.common.io.Files;
|
||||
import com.sparrowwallet.drongo.KeyPurpose;
|
||||
import com.sparrowwallet.drongo.address.Address;
|
||||
@@ -14,6 +15,9 @@ import com.sparrowwallet.drongo.psbt.PSBT;
|
||||
import com.sparrowwallet.drongo.psbt.PSBTInput;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.UnitFormat;
|
||||
import com.sparrowwallet.sparrow.event.FeeRatesUpdatedEvent;
|
||||
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
|
||||
import com.sparrowwallet.sparrow.io.CardApi;
|
||||
import com.sparrowwallet.sparrow.io.Config;
|
||||
@@ -46,10 +50,7 @@ import tornadofx.control.Form;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static com.sparrowwallet.drongo.protocol.ScriptType.P2TR;
|
||||
@@ -62,6 +63,8 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
|
||||
private final CopyableLabel keyAddress;
|
||||
private final ComboBoxTextField toAddress;
|
||||
private final ComboBox<Wallet> toWallet;
|
||||
private final FeeRangeSlider feeRange;
|
||||
private final CopyableLabel feeRate;
|
||||
|
||||
public PrivateKeySweepDialog(Wallet wallet) {
|
||||
final DialogPane dialogPane = getDialogPane();
|
||||
@@ -146,7 +149,24 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
|
||||
stackPane.getChildren().addAll(toWallet, toAddress);
|
||||
toAddressField.getInputs().add(stackPane);
|
||||
|
||||
fieldset.getChildren().addAll(keyField, keyScriptTypeField, addressField, toAddressField);
|
||||
Field feeRangeField = new Field();
|
||||
feeRangeField.setText("Fee range:");
|
||||
feeRange = new FeeRangeSlider();
|
||||
feeRange.setMaxWidth(320);
|
||||
feeRangeField.getInputs().add(feeRange);
|
||||
|
||||
Field feeRateField = new Field();
|
||||
feeRateField.setText("Fee rate:");
|
||||
feeRate = new CopyableLabel();
|
||||
feeRateField.getInputs().add(feeRate);
|
||||
|
||||
feeRange.valueProperty().addListener((observable, oldValue, newValue) -> {
|
||||
updateFeeRate();
|
||||
});
|
||||
feeRange.setFeeRate(AppServices.getDefaultFeeRate());
|
||||
updateFeeRate();
|
||||
|
||||
fieldset.getChildren().addAll(keyField, keyScriptTypeField, addressField, toAddressField, feeRangeField, feeRateField);
|
||||
form.getChildren().add(fieldset);
|
||||
dialogPane.setContent(form);
|
||||
|
||||
@@ -201,6 +221,11 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
|
||||
setResultConverter(dialogButton -> null);
|
||||
dialogPane.setPrefWidth(680);
|
||||
|
||||
EventManager.get().register(this);
|
||||
setOnCloseRequest(event -> {
|
||||
EventManager.get().unregister(this);
|
||||
});
|
||||
|
||||
ValidationSupport validationSupport = new ValidationSupport();
|
||||
Platform.runLater(() -> {
|
||||
validationSupport.setValidationDecorator(new StyleClassValidationDecoration());
|
||||
@@ -224,6 +249,7 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
|
||||
|
||||
private void decryptKey() {
|
||||
PassphraseDialog passphraseDialog = new PassphraseDialog();
|
||||
passphraseDialog.initOwner(getDialogPane().getScene().getWindow());
|
||||
Optional<String> optPassphrase = passphraseDialog.showAndWait();
|
||||
if(optPassphrase.isPresent()) {
|
||||
try {
|
||||
@@ -265,6 +291,7 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
|
||||
|
||||
private void scanPrivateKey() {
|
||||
QRScanDialog qrScanDialog = new QRScanDialog();
|
||||
qrScanDialog.initOwner(getDialogPane().getScene().getWindow());
|
||||
Optional<QRScanDialog.Result> result = qrScanDialog.showAndWait();
|
||||
if(result.isPresent() && result.get().payload != null) {
|
||||
key.setText(result.get().payload);
|
||||
@@ -294,6 +321,7 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
|
||||
|
||||
private void unsealPrivateKey() {
|
||||
DeviceUnsealDialog deviceUnsealDialog = new DeviceUnsealDialog(Collections.emptyList());
|
||||
deviceUnsealDialog.initOwner(getDialogPane().getScene().getWindow());
|
||||
Optional<DeviceUnsealDialog.DevicePrivateKey> optPrivateKey = deviceUnsealDialog.showAndWait();
|
||||
if(optPrivateKey.isPresent()) {
|
||||
DeviceUnsealDialog.DevicePrivateKey devicePrivateKey = optPrivateKey.get();
|
||||
@@ -312,6 +340,7 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
|
||||
Date since = null;
|
||||
if(Config.get().getServerType() == ServerType.BITCOIN_CORE) {
|
||||
WalletBirthDateDialog addressScanDateDialog = new WalletBirthDateDialog(null, true);
|
||||
addressScanDateDialog.initOwner(getDialogPane().getScene().getWindow());
|
||||
Optional<Date> optSince = addressScanDateDialog.showAndWait();
|
||||
if(optSince.isPresent()) {
|
||||
since = optSince.get();
|
||||
@@ -330,6 +359,7 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
|
||||
|
||||
if(Config.get().getServerType() == ServerType.BITCOIN_CORE) {
|
||||
ServiceProgressDialog serviceProgressDialog = new ServiceProgressDialog("Address Scan", "Scanning address for transactions...", "/image/sparrow.png", addressUtxosService);
|
||||
serviceProgressDialog.initOwner(getDialogPane().getScene().getWindow());
|
||||
AppServices.moveToActiveWindowScreen(serviceProgressDialog);
|
||||
}
|
||||
|
||||
@@ -352,7 +382,7 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
|
||||
TransactionOutput sweepOutput = new TransactionOutput(noFeeTransaction, total, destAddress.getOutputScript());
|
||||
noFeeTransaction.addOutput(sweepOutput);
|
||||
|
||||
Double feeRate = AppServices.getDefaultFeeRate();
|
||||
double feeRate = feeRange.getFeeRate();
|
||||
long fee = (long)Math.ceil(noFeeTransaction.getVirtualSize() * feeRate);
|
||||
if(feeRate == Transaction.DEFAULT_MIN_RELAY_FEE) {
|
||||
fee++;
|
||||
@@ -420,6 +450,16 @@ public class PrivateKeySweepDialog extends Dialog<Transaction> {
|
||||
return glyph;
|
||||
}
|
||||
|
||||
private void updateFeeRate() {
|
||||
UnitFormat format = Config.get().getUnitFormat() == null ? UnitFormat.DOT : Config.get().getUnitFormat();
|
||||
feeRate.setText(format.getCurrencyFormat().format(feeRange.getFeeRate()) + " sats/vB");
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void feeRatesUpdated(FeeRatesUpdatedEvent event) {
|
||||
feeRange.updateTrackHighlight();
|
||||
}
|
||||
|
||||
public class PassphraseDialog extends Dialog<String> {
|
||||
private final CustomPasswordField passphrase;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
public enum QRDensity {
|
||||
NORMAL("Normal", 250),
|
||||
NORMAL("Normal", 400),
|
||||
LOW("Low", 80);
|
||||
|
||||
private final String name;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.google.zxing.BarcodeFormat;
|
||||
import com.google.zxing.EncodeHintType;
|
||||
import com.google.zxing.client.j2se.MatrixToImageConfig;
|
||||
import com.google.zxing.client.j2se.MatrixToImageWriter;
|
||||
import com.google.zxing.common.BitMatrix;
|
||||
@@ -29,18 +30,21 @@ import org.slf4j.LoggerFactory;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
public class QRDisplayDialog extends Dialog<UR> {
|
||||
public class QRDisplayDialog extends Dialog<ButtonType> {
|
||||
private static final Logger log = LoggerFactory.getLogger(QRDisplayDialog.class);
|
||||
|
||||
private static final int MIN_FRAGMENT_LENGTH = 10;
|
||||
|
||||
private static final int ANIMATION_PERIOD_MILLIS = 200;
|
||||
|
||||
private static final int QR_WIDTH = 480;
|
||||
private static final int QR_HEIGHT = 480;
|
||||
private static final int DEFAULT_QR_SIZE = 580;
|
||||
private static final int REDUCED_QR_SIZE = 520;
|
||||
|
||||
private final int qrSize = getQRSize();
|
||||
|
||||
private final UR ur;
|
||||
private UREncoder encoder;
|
||||
@@ -58,14 +62,14 @@ public class QRDisplayDialog extends Dialog<UR> {
|
||||
private static boolean initialDensityChange;
|
||||
|
||||
public QRDisplayDialog(String type, byte[] data, boolean addLegacyEncodingOption) throws UR.URException {
|
||||
this(UR.fromBytes(type, data), addLegacyEncodingOption);
|
||||
this(UR.fromBytes(type, data), addLegacyEncodingOption, false);
|
||||
}
|
||||
|
||||
public QRDisplayDialog(UR ur) {
|
||||
this(ur, false);
|
||||
this(ur, false, false);
|
||||
}
|
||||
|
||||
public QRDisplayDialog(UR ur, boolean addLegacyEncodingOption) {
|
||||
public QRDisplayDialog(UR ur, boolean addLegacyEncodingOption, boolean addScanButton) {
|
||||
this.ur = ur;
|
||||
this.addLegacyEncodingOption = addLegacyEncodingOption;
|
||||
this.encoder = new UREncoder(ur, Config.get().getQrDensity().getMaxFragmentLength(), MIN_FRAGMENT_LENGTH, 0);
|
||||
@@ -98,18 +102,28 @@ public class QRDisplayDialog extends Dialog<UR> {
|
||||
dialogPane.getButtonTypes().add(densityButtonType);
|
||||
}
|
||||
|
||||
dialogPane.setPrefWidth(40 + QR_WIDTH + 40);
|
||||
dialogPane.setPrefHeight(40 + QR_HEIGHT + 85);
|
||||
if(addScanButton) {
|
||||
final ButtonType scanButtonType = new javafx.scene.control.ButtonType("Scan QR", ButtonBar.ButtonData.NEXT_FORWARD);
|
||||
dialogPane.getButtonTypes().add(scanButtonType);
|
||||
}
|
||||
|
||||
dialogPane.setPrefWidth(40 + qrSize + 40);
|
||||
dialogPane.setPrefHeight(40 + qrSize + 85);
|
||||
AppServices.moveToActiveWindowScreen(this);
|
||||
|
||||
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? ur : null);
|
||||
setResultConverter(dialogButton -> dialogButton);
|
||||
}
|
||||
|
||||
public QRDisplayDialog(String data) {
|
||||
this(data, false);
|
||||
}
|
||||
|
||||
public QRDisplayDialog(String data, boolean addScanButton) {
|
||||
this.ur = null;
|
||||
this.encoder = null;
|
||||
|
||||
final DialogPane dialogPane = getDialogPane();
|
||||
final DialogPane dialogPane = new QRDisplayDialogPane();
|
||||
setDialogPane(dialogPane);
|
||||
AppServices.setStageIcon(dialogPane.getScene().getWindow());
|
||||
|
||||
StackPane stackPane = new StackPane();
|
||||
@@ -121,11 +135,21 @@ public class QRDisplayDialog extends Dialog<UR> {
|
||||
|
||||
final ButtonType cancelButtonType = new javafx.scene.control.ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
|
||||
dialogPane.getButtonTypes().addAll(cancelButtonType);
|
||||
dialogPane.setPrefWidth(40 + QR_WIDTH + 40);
|
||||
dialogPane.setPrefHeight(40 + QR_HEIGHT + 85);
|
||||
|
||||
if(addScanButton) {
|
||||
final ButtonType scanButtonType = new javafx.scene.control.ButtonType("Scan QR", ButtonBar.ButtonData.NEXT_FORWARD);
|
||||
dialogPane.getButtonTypes().add(scanButtonType);
|
||||
}
|
||||
|
||||
dialogPane.setPrefWidth(40 + qrSize + 40);
|
||||
dialogPane.setPrefHeight(40 + qrSize + 85);
|
||||
AppServices.moveToActiveWindowScreen(this);
|
||||
|
||||
setResultConverter(dialogButton -> dialogButton != cancelButtonType ? ur : null);
|
||||
setResultConverter(dialogButton -> dialogButton);
|
||||
}
|
||||
|
||||
private int getQRSize() {
|
||||
return AppServices.isReducedWindowHeight() ? REDUCED_QR_SIZE : DEFAULT_QR_SIZE;
|
||||
}
|
||||
|
||||
private void createAnimateQRService() {
|
||||
@@ -153,7 +177,7 @@ public class QRDisplayDialog extends Dialog<UR> {
|
||||
protected Image getQrCode(String fragment) {
|
||||
try {
|
||||
QRCodeWriter qrCodeWriter = new QRCodeWriter();
|
||||
BitMatrix qrMatrix = qrCodeWriter.encode(fragment, BarcodeFormat.QR_CODE, QR_WIDTH, QR_HEIGHT);
|
||||
BitMatrix qrMatrix = qrCodeWriter.encode(fragment, BarcodeFormat.QR_CODE, qrSize, qrSize, Map.of(EncodeHintType.MARGIN, "2"));
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
MatrixToImageWriter.writeToStream(qrMatrix, "PNG", baos, new MatrixToImageConfig());
|
||||
@@ -280,6 +304,12 @@ public class QRDisplayDialog extends Dialog<UR> {
|
||||
|
||||
return density;
|
||||
}
|
||||
} else if(buttonType.getButtonData() == ButtonBar.ButtonData.NEXT_FORWARD) {
|
||||
Button scanButton = (Button)super.createButton(buttonType);
|
||||
scanButton.setGraphicTextGap(5);
|
||||
scanButton.setGraphic(getGlyph(FontAwesome5.Glyph.CAMERA));
|
||||
|
||||
return scanButton;
|
||||
}
|
||||
|
||||
return super.createButton(buttonType);
|
||||
|
||||
@@ -502,18 +502,22 @@ public class QRScanDialog extends Dialog<QRScanDialog.Result> {
|
||||
if(cryptoOutput.getMultiKey() != null) {
|
||||
MultiKey multiKey = cryptoOutput.getMultiKey();
|
||||
Map<ExtendedKey, KeyDerivation> extendedPublicKeys = new LinkedHashMap<>();
|
||||
Map<ExtendedKey, String> extendedPublicKeyLabels = new LinkedHashMap<>();
|
||||
for(CryptoHDKey cryptoHDKey : multiKey.getHdKeys()) {
|
||||
ExtendedKey extendedKey = getExtendedKey(cryptoHDKey);
|
||||
KeyDerivation keyDerivation = getKeyDerivation(cryptoHDKey.getOrigin());
|
||||
extendedPublicKeys.put(extendedKey, keyDerivation);
|
||||
if(cryptoHDKey.getName() != null) {
|
||||
extendedPublicKeyLabels.put(extendedKey, cryptoHDKey.getName());
|
||||
}
|
||||
}
|
||||
return new OutputDescriptor(scriptType, multiKey.getThreshold(), extendedPublicKeys);
|
||||
return new OutputDescriptor(scriptType, multiKey.getThreshold(), extendedPublicKeys, new LinkedHashMap<>(), extendedPublicKeyLabels);
|
||||
} else if(cryptoOutput.getEcKey() != null) {
|
||||
throw new IllegalArgumentException("EC keys are currently unsupported");
|
||||
} else if(cryptoOutput.getHdKey() != null) {
|
||||
ExtendedKey extendedKey = getExtendedKey(cryptoOutput.getHdKey());
|
||||
KeyDerivation keyDerivation = getKeyDerivation(cryptoOutput.getHdKey().getOrigin());
|
||||
return new OutputDescriptor(scriptType, extendedKey, keyDerivation);
|
||||
return new OutputDescriptor(scriptType, extendedKey, keyDerivation, cryptoOutput.getHdKey().getName());
|
||||
}
|
||||
|
||||
throw new IllegalStateException("CryptoOutput did not contain sufficient information");
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.KeyPurpose;
|
||||
import com.sparrowwallet.drongo.address.Address;
|
||||
import com.sparrowwallet.drongo.address.InvalidAddressException;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.wallet.*;
|
||||
@@ -21,6 +23,7 @@ import tornadofx.control.Form;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
|
||||
public class SearchWalletDialog extends Dialog<Entry> {
|
||||
private static final Logger log = LoggerFactory.getLogger(SearchWalletDialog.class);
|
||||
@@ -36,13 +39,16 @@ public class SearchWalletDialog extends Dialog<Entry> {
|
||||
throw new IllegalArgumentException("No wallets selected to search");
|
||||
}
|
||||
|
||||
boolean showWallet = walletForms.stream().map(WalletForm::getMasterWallet).distinct().limit(2).count() > 1;
|
||||
boolean showAccount = walletForms.stream().anyMatch(walletForm -> !walletForm.getWallet().isMasterWallet() || !walletForm.getNestedWalletForms().isEmpty());
|
||||
|
||||
final DialogPane dialogPane = getDialogPane();
|
||||
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
|
||||
dialogPane.getStylesheets().add(AppServices.class.getResource("dialog.css").toExternalForm());
|
||||
dialogPane.getStylesheets().add(AppServices.class.getResource("wallet/wallet.css").toExternalForm());
|
||||
dialogPane.getStylesheets().add(AppServices.class.getResource("search.css").toExternalForm());
|
||||
AppServices.setStageIcon(dialogPane.getScene().getWindow());
|
||||
dialogPane.setHeaderText("Search Wallet");
|
||||
dialogPane.setHeaderText(showWallet ? "Search All Wallets" : "Search Wallet");
|
||||
|
||||
Image image = new Image("image/sparrow-small.png", 50, 50, false, false);
|
||||
if(!image.isError()) {
|
||||
@@ -69,11 +75,9 @@ public class SearchWalletDialog extends Dialog<Entry> {
|
||||
fieldset.getChildren().addAll(searchField);
|
||||
form.getChildren().add(fieldset);
|
||||
|
||||
boolean showWallet = walletForms.size() > 1 || walletForms.stream().anyMatch(walletForm -> !walletForm.getNestedWalletForms().isEmpty());
|
||||
|
||||
results = new CoinTreeTable();
|
||||
results.setShowRoot(false);
|
||||
results.setPrefWidth(showWallet ? 950 : 850);
|
||||
results.setPrefWidth(showWallet || showAccount ? 950 : 850);
|
||||
results.setUnitFormat(walletForms.iterator().next().getWallet());
|
||||
results.setColumnResizePolicy(TreeTableView.CONSTRAINED_RESIZE_POLICY);
|
||||
results.setPlaceholder(new Label("No results"));
|
||||
@@ -81,11 +85,19 @@ public class SearchWalletDialog extends Dialog<Entry> {
|
||||
if(showWallet) {
|
||||
TreeTableColumn<Entry, String> walletColumn = new TreeTableColumn<>("Wallet");
|
||||
walletColumn.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, String> param) -> {
|
||||
return new ReadOnlyObjectWrapper<>(param.getValue().getValue().getWallet().getDisplayName());
|
||||
return new ReadOnlyObjectWrapper<>(param.getValue().getValue().getWallet().getMasterName());
|
||||
});
|
||||
results.getColumns().add(walletColumn);
|
||||
}
|
||||
|
||||
if(showAccount) {
|
||||
TreeTableColumn<Entry, String> accountColumn = new TreeTableColumn<>("Account");
|
||||
accountColumn.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, String> param) -> {
|
||||
return new ReadOnlyObjectWrapper<>(param.getValue().getValue().getWallet().getDisplayName());
|
||||
});
|
||||
results.getColumns().add(accountColumn);
|
||||
}
|
||||
|
||||
TreeTableColumn<Entry, String> typeColumn = new TreeTableColumn<>("Type");
|
||||
typeColumn.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, String> param) -> {
|
||||
return new ReadOnlyObjectWrapper<>(param.getValue().getValue().getEntryType());
|
||||
@@ -135,25 +147,22 @@ public class SearchWalletDialog extends Dialog<Entry> {
|
||||
});
|
||||
|
||||
search.textProperty().addListener((observable, oldValue, newValue) -> {
|
||||
searchWallet(newValue.toLowerCase(Locale.ROOT));
|
||||
searchWallets(newValue.toLowerCase(Locale.ROOT));
|
||||
});
|
||||
|
||||
setResizable(true);
|
||||
|
||||
AppServices.moveToActiveWindowScreen(this);
|
||||
|
||||
Platform.runLater(search::requestFocus);
|
||||
}
|
||||
|
||||
private void searchWallet(String searchText) {
|
||||
private void searchWallets(String searchText) {
|
||||
List<Entry> matchingEntries = new ArrayList<>();
|
||||
|
||||
if(!searchText.isEmpty()) {
|
||||
Long searchValue = null;
|
||||
|
||||
try {
|
||||
searchValue = Math.abs(Long.parseLong(searchText));
|
||||
} catch(NumberFormatException e) {
|
||||
//ignore
|
||||
}
|
||||
Long searchValue = getSearchValue(searchText);
|
||||
Address searchAddress = getSearchAddress(searchText);
|
||||
|
||||
for(WalletForm walletForm : walletForms) {
|
||||
WalletTransactionsEntry walletTransactionsEntry = walletForm.getWalletTransactionsEntry();
|
||||
@@ -161,7 +170,8 @@ public class SearchWalletDialog extends Dialog<Entry> {
|
||||
if(entry instanceof TransactionEntry transactionEntry) {
|
||||
if(transactionEntry.getBlockTransaction().getHash().toString().equals(searchText) ||
|
||||
(transactionEntry.getLabel() != null && transactionEntry.getLabel().toLowerCase(Locale.ROOT).contains(searchText)) ||
|
||||
(transactionEntry.getValue() != null && searchValue != null && Math.abs(transactionEntry.getValue()) == searchValue)) {
|
||||
(transactionEntry.getValue() != null && searchValue != null && Math.abs(transactionEntry.getValue()) == searchValue) ||
|
||||
(searchAddress != null && transactionEntry.getBlockTransaction().getTransaction().getOutputs().stream().map(output -> output.getScript().getToAddress()).filter(Objects::nonNull).anyMatch(address -> address.equals(searchAddress)))) {
|
||||
matchingEntries.add(entry);
|
||||
}
|
||||
}
|
||||
@@ -213,6 +223,22 @@ public class SearchWalletDialog extends Dialog<Entry> {
|
||||
results.setRoot(rootItem);
|
||||
}
|
||||
|
||||
private Long getSearchValue(String searchText) {
|
||||
try {
|
||||
return Math.abs(Long.parseLong(searchText));
|
||||
} catch(NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Address getSearchAddress(String searchText) {
|
||||
try {
|
||||
return Address.fromString(searchText);
|
||||
} catch(InvalidAddressException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static class SearchWalletEntry extends Entry {
|
||||
public SearchWalletEntry(Wallet wallet, List<Entry> entries) {
|
||||
super(wallet, wallet.getName(), entries);
|
||||
|
||||
@@ -109,6 +109,7 @@ public class TextAreaDialog extends Dialog<String> {
|
||||
ButtonBar.setButtonData(scanButton, buttonData);
|
||||
scanButton.setOnAction(event -> {
|
||||
QRScanDialog qrScanDialog = new QRScanDialog();
|
||||
qrScanDialog.initOwner(getDialogPane().getScene().getWindow());
|
||||
Optional<QRScanDialog.Result> optionalResult = qrScanDialog.showAndWait();
|
||||
if(optionalResult.isPresent()) {
|
||||
QRScanDialog.Result result = optionalResult.get();
|
||||
|
||||
@@ -72,6 +72,7 @@ public class TransactionDiagram extends GridPane {
|
||||
|
||||
private WalletTransaction walletTx;
|
||||
private final BooleanProperty finalProperty = new SimpleBooleanProperty(false);
|
||||
private final ObjectProperty<TransactionDiagramLabel> labelProperty = new SimpleObjectProperty<>(null);
|
||||
private final ObjectProperty<OptimizationStrategy> optimizationStrategyProperty = new SimpleObjectProperty<>(OptimizationStrategy.EFFICIENCY);
|
||||
private boolean expanded;
|
||||
private TransactionDiagram expandedDiagram;
|
||||
@@ -154,6 +155,10 @@ public class TransactionDiagram extends GridPane {
|
||||
updateDerivedDiagram(expandedDiagram);
|
||||
}
|
||||
}
|
||||
|
||||
if(getLabel() != null) {
|
||||
getLabel().update(this);
|
||||
}
|
||||
}
|
||||
|
||||
public void update(String message) {
|
||||
@@ -534,7 +539,7 @@ public class TransactionDiagram extends GridPane {
|
||||
return input.getLabel() != null && !input.getLabel().isEmpty() ? input.getLabel() : input.getHashAsString().substring(0, 8) + "..:" + input.getIndex();
|
||||
}
|
||||
|
||||
private String getSatsValue(long amount) {
|
||||
String getSatsValue(long amount) {
|
||||
UnitFormat format = Config.get().getUnitFormat() == null ? UnitFormat.DOT : Config.get().getUnitFormat();
|
||||
return format.formatSatsValue(amount);
|
||||
}
|
||||
@@ -692,7 +697,8 @@ public class TransactionDiagram extends GridPane {
|
||||
Wallet toBip47Wallet = getBip47SendWallet(payment);
|
||||
Tooltip recipientTooltip = new Tooltip((toWallet == null ? (toNode != null ? "Consolidate " : "Pay ") : "Receive ")
|
||||
+ getSatsValue(payment.getAmount()) + " sats to "
|
||||
+ (payment instanceof AdditionalPayment ? (isExpanded() ? "\n" : "(click to expand)\n") + payment : (toWallet == null ? (payment.getLabel() == null ? (toNode != null ? toNode : (toBip47Wallet == null ? "external address" : toBip47Wallet.getDisplayName())) : payment.getLabel()) : toWallet.getFullDisplayName()) + "\n" + payment.getAddress().toString()));
|
||||
+ (payment instanceof AdditionalPayment ? (isExpanded() ? "\n" : "(click to expand)\n") + payment : (toWallet == null ? (payment.getLabel() == null ? (toNode != null ? toNode : (toBip47Wallet == null ? "external address" : toBip47Wallet.getDisplayName())) : payment.getLabel()) : toWallet.getFullDisplayName()) + "\n" + payment.getAddress().toString())
|
||||
+ (isDuplicateAddress(payment) ? " (Duplicate)" : ""));
|
||||
recipientTooltip.getStyleClass().add("recipient-label");
|
||||
recipientTooltip.setShowDelay(new Duration(TOOLTIP_SHOW_DELAY));
|
||||
recipientTooltip.setShowDuration(Duration.INDEFINITE);
|
||||
@@ -923,12 +929,12 @@ public class TransactionDiagram extends GridPane {
|
||||
}
|
||||
|
||||
if(payment.getType() == Payment.Type.WHIRLPOOL_FEE) {
|
||||
return "Whirlpool Fee";
|
||||
return "Whirlpool fee";
|
||||
} else if(walletTx.isPremixSend(payment)) {
|
||||
int premixIndex = getOutputIndex(payment.getAddress(), payment.getAmount()) - 2;
|
||||
return "Premix #" + premixIndex;
|
||||
} else if(walletTx.isBadbankSend(payment)) {
|
||||
return "Badbank Change";
|
||||
return "Badbank change";
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -938,7 +944,7 @@ public class TransactionDiagram extends GridPane {
|
||||
return walletTx.getTransaction().getOutputs().stream().filter(txOutput -> address.equals(txOutput.getScript().getToAddress()) && txOutput.getValue() == amount).mapToInt(TransactionOutput::getIndex).findFirst().orElseThrow();
|
||||
}
|
||||
|
||||
private Wallet getToWallet(Payment payment) {
|
||||
Wallet getToWallet(Payment payment) {
|
||||
for(Wallet openWallet : AppServices.get().getOpenWallets().keySet()) {
|
||||
if(openWallet != walletTx.getWallet() && openWallet.isValid()) {
|
||||
WalletNode addressNode = openWallet.getWalletAddresses().get(payment.getAddress());
|
||||
@@ -985,11 +991,17 @@ public class TransactionDiagram extends GridPane {
|
||||
return ((AdditionalPayment)payment).getOutputGlyph(this);
|
||||
} else if(getToWallet(payment) != null) {
|
||||
return getDepositGlyph();
|
||||
} else if(isDuplicateAddress(payment)) {
|
||||
return getPaymentWarningGlyph();
|
||||
}
|
||||
|
||||
return getPaymentGlyph();
|
||||
}
|
||||
|
||||
private boolean isDuplicateAddress(Payment payment) {
|
||||
return walletTx.getPayments().stream().filter(p -> payment != p).anyMatch(p -> payment.getAddress() != null && payment.getAddress().equals(p.getAddress()));
|
||||
}
|
||||
|
||||
public static Glyph getExcludeGlyph() {
|
||||
Glyph excludeGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.TIMES_CIRCLE);
|
||||
excludeGlyph.getStyleClass().add("exclude-utxo");
|
||||
@@ -1004,6 +1016,13 @@ public class TransactionDiagram extends GridPane {
|
||||
return paymentGlyph;
|
||||
}
|
||||
|
||||
public static Glyph getPaymentWarningGlyph() {
|
||||
Glyph paymentWarningGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.EXCLAMATION_TRIANGLE);
|
||||
paymentWarningGlyph.getStyleClass().add("payment-warning-icon");
|
||||
paymentWarningGlyph.setFontSize(12);
|
||||
return paymentWarningGlyph;
|
||||
}
|
||||
|
||||
public static Glyph getConsolidationGlyph() {
|
||||
Glyph consolidationGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.REPLY_ALL);
|
||||
consolidationGlyph.getStyleClass().add("consolidation-icon");
|
||||
@@ -1078,7 +1097,7 @@ public class TransactionDiagram extends GridPane {
|
||||
return changeReplaceGlyph;
|
||||
}
|
||||
|
||||
private Glyph getFeeGlyph() {
|
||||
public Glyph getFeeGlyph() {
|
||||
Glyph feeGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.HAND_HOLDING);
|
||||
feeGlyph.getStyleClass().add("fee-icon");
|
||||
feeGlyph.setFontSize(12);
|
||||
@@ -1162,6 +1181,10 @@ public class TransactionDiagram extends GridPane {
|
||||
}
|
||||
}
|
||||
|
||||
public WalletTransaction getWalletTransaction() {
|
||||
return walletTx;
|
||||
}
|
||||
|
||||
public boolean isFinal() {
|
||||
return finalProperty.get();
|
||||
}
|
||||
@@ -1174,6 +1197,18 @@ public class TransactionDiagram extends GridPane {
|
||||
this.finalProperty.set(isFinal);
|
||||
}
|
||||
|
||||
public TransactionDiagramLabel getLabel() {
|
||||
return labelProperty.get();
|
||||
}
|
||||
|
||||
public ObjectProperty<TransactionDiagramLabel> labelProperty() {
|
||||
return labelProperty;
|
||||
}
|
||||
|
||||
public void setLabelProperty(TransactionDiagramLabel label) {
|
||||
this.labelProperty.set(label);
|
||||
}
|
||||
|
||||
public OptimizationStrategy getOptimizationStrategy() {
|
||||
return optimizationStrategyProperty.get();
|
||||
}
|
||||
@@ -1301,7 +1336,7 @@ public class TransactionDiagram extends GridPane {
|
||||
}
|
||||
}
|
||||
|
||||
private static class LabelContextMenu extends ContextMenu {
|
||||
private class LabelContextMenu extends ContextMenu {
|
||||
public LabelContextMenu(Address address, long value) {
|
||||
if(address != null) {
|
||||
MenuItem copyAddress = new MenuItem("Copy Address");
|
||||
@@ -1317,6 +1352,7 @@ public class TransactionDiagram extends GridPane {
|
||||
showAddress.setOnAction(event -> {
|
||||
hide();
|
||||
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(address.toString());
|
||||
qrDisplayDialog.initOwner(TransactionDiagram.this.getScene().getWindow());
|
||||
qrDisplayDialog.showAndWait();
|
||||
});
|
||||
getItems().add(showAddress);
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.wallet.*;
|
||||
import com.sparrowwallet.sparrow.glyphfont.FontAwesome5;
|
||||
import javafx.beans.property.IntegerProperty;
|
||||
import javafx.beans.property.SimpleIntegerProperty;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.text.Font;
|
||||
import org.controlsfx.glyphfont.Glyph;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class TransactionDiagramLabel extends HBox {
|
||||
private final List<HBox> outputs = new ArrayList<>();
|
||||
private final Button left;
|
||||
private final Button right;
|
||||
private final IntegerProperty displayedIndex = new SimpleIntegerProperty(-1);
|
||||
|
||||
public TransactionDiagramLabel() {
|
||||
setSpacing(5);
|
||||
setAlignment(Pos.CENTER_RIGHT);
|
||||
|
||||
left = new Button("");
|
||||
left.setGraphic(getLeftGlyph());
|
||||
left.setOnAction(event -> {
|
||||
int index = displayedIndex.get();
|
||||
if(index > 0) {
|
||||
index--;
|
||||
}
|
||||
displayedIndex.set(index);
|
||||
});
|
||||
|
||||
right = new Button("");
|
||||
right.setGraphic(getRightGlyph());
|
||||
right.setOnAction(event -> {
|
||||
int index = displayedIndex.get();
|
||||
if(index < outputs.size() - 1) {
|
||||
index++;
|
||||
}
|
||||
displayedIndex.set(index);
|
||||
});
|
||||
|
||||
displayedIndex.addListener((observable, oldValue, newValue) -> {
|
||||
left.setDisable(newValue.intValue() <= 0);
|
||||
right.setDisable(newValue.intValue() < 0 || newValue.intValue() >= outputs.size() - 1);
|
||||
if(oldValue.intValue() >= 0 && oldValue.intValue() < outputs.size()) {
|
||||
outputs.get(oldValue.intValue()).setVisible(false);
|
||||
}
|
||||
if(newValue.intValue() >= 0 && newValue.intValue() < outputs.size()) {
|
||||
outputs.get(newValue.intValue()).setVisible(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void update(TransactionDiagram transactionDiagram) {
|
||||
getChildren().clear();
|
||||
outputs.clear();
|
||||
displayedIndex.set(-1);
|
||||
double maxWidth = getMaxWidth();
|
||||
|
||||
WalletTransaction walletTx = transactionDiagram.getWalletTransaction();
|
||||
List<OutputLabel> outputLabels = new ArrayList<>();
|
||||
|
||||
List<Payment> premixOutputs = walletTx.getPayments().stream().filter(walletTx::isPremixSend).collect(Collectors.toList());
|
||||
if(!premixOutputs.isEmpty()) {
|
||||
OutputLabel premixOutputLabel = getPremixOutputLabel(transactionDiagram, premixOutputs);
|
||||
if(premixOutputLabel != null) {
|
||||
outputLabels.add(premixOutputLabel);
|
||||
}
|
||||
|
||||
Optional<Payment> optWhirlpoolFee = walletTx.getPayments().stream().filter(payment -> payment.getType() == Payment.Type.WHIRLPOOL_FEE).findFirst();
|
||||
if(optWhirlpoolFee.isPresent()) {
|
||||
OutputLabel whirlpoolFeeOutputLabel = getWhirlpoolFeeOutputLabel(transactionDiagram, optWhirlpoolFee.get(), premixOutputs);
|
||||
outputLabels.add(whirlpoolFeeOutputLabel);
|
||||
}
|
||||
|
||||
List<Payment> badbankOutputs = walletTx.getPayments().stream().filter(walletTx::isBadbankSend).collect(Collectors.toList());
|
||||
List<OutputLabel> badbankOutputLabels = badbankOutputs.stream().map(payment -> getBadbankOutputLabel(transactionDiagram, payment)).collect(Collectors.toList());
|
||||
outputLabels.addAll(badbankOutputLabels);
|
||||
} else if(walletTx.getPayments().size() >= 5 && walletTx.getPayments().stream().mapToLong(Payment::getAmount).distinct().count() <= 1
|
||||
&& walletTx.getWallet().getStandardAccountType() == StandardAccount.WHIRLPOOL_PREMIX && walletTx.getPayments().stream().anyMatch(walletTx::isPostmixSend)) {
|
||||
OutputLabel mixOutputLabel = getMixOutputLabel(transactionDiagram, walletTx.getPayments());
|
||||
if(mixOutputLabel != null) {
|
||||
outputLabels.add(mixOutputLabel);
|
||||
}
|
||||
} else if(walletTx.getPayments().size() >= 5 && walletTx.getPayments().stream().mapToLong(Payment::getAmount).distinct().count() <= 1
|
||||
&& walletTx.getWallet().getStandardAccountType() == StandardAccount.WHIRLPOOL_POSTMIX && walletTx.getPayments().stream().anyMatch(walletTx::isConsolidationSend)) {
|
||||
OutputLabel remixOutputLabel = getRemixOutputLabel(transactionDiagram, walletTx.getPayments());
|
||||
if(remixOutputLabel != null) {
|
||||
outputLabels.add(remixOutputLabel);
|
||||
}
|
||||
} else {
|
||||
List<Payment> payments = walletTx.getPayments().stream().filter(payment -> payment.getType() == Payment.Type.DEFAULT && !walletTx.isConsolidationSend(payment)).collect(Collectors.toList());
|
||||
List<OutputLabel> paymentLabels = payments.stream().map(payment -> getOutputLabel(transactionDiagram, payment)).collect(Collectors.toList());
|
||||
if(walletTx.getSelectedUtxos().values().stream().allMatch(Objects::isNull)) {
|
||||
paymentLabels.sort(Comparator.comparingInt(paymentLabel -> (paymentLabel.text.startsWith("Receive") ? 0 : 1)));
|
||||
}
|
||||
outputLabels.addAll(paymentLabels);
|
||||
|
||||
List<Payment> consolidations = walletTx.getPayments().stream().filter(payment -> payment.getType() == Payment.Type.DEFAULT && walletTx.isConsolidationSend(payment)).collect(Collectors.toList());
|
||||
outputLabels.addAll(consolidations.stream().map(consolidation -> getOutputLabel(transactionDiagram, consolidation)).collect(Collectors.toList()));
|
||||
|
||||
List<Payment> mixes = walletTx.getPayments().stream().filter(payment -> payment.getType() == Payment.Type.MIX || payment.getType() == Payment.Type.FAKE_MIX).collect(Collectors.toList());
|
||||
outputLabels.addAll(mixes.stream().map(payment -> getOutputLabel(transactionDiagram, payment)).collect(Collectors.toList()));
|
||||
}
|
||||
|
||||
Map<WalletNode, Long> changeMap = walletTx.getChangeMap();
|
||||
outputLabels.addAll(changeMap.entrySet().stream().map(changeEntry -> getOutputLabel(transactionDiagram, changeEntry)).collect(Collectors.toList()));
|
||||
|
||||
OutputLabel feeOutputLabel = getFeeOutputLabel(transactionDiagram);
|
||||
if(feeOutputLabel != null) {
|
||||
outputLabels.add(feeOutputLabel);
|
||||
}
|
||||
|
||||
for(OutputLabel outputLabel : outputLabels) {
|
||||
maxWidth = Math.max(maxWidth, outputLabel.width);
|
||||
outputs.add(outputLabel.hBox);
|
||||
getChildren().add(outputLabel.hBox);
|
||||
}
|
||||
|
||||
HBox buttonBox = new HBox();
|
||||
buttonBox.setAlignment(Pos.CENTER_RIGHT);
|
||||
buttonBox.getChildren().addAll(left, right);
|
||||
getChildren().add(buttonBox);
|
||||
|
||||
setMaxWidth(maxWidth);
|
||||
setPrefWidth(maxWidth);
|
||||
|
||||
if(outputLabels.size() > 0) {
|
||||
displayedIndex.set(0);
|
||||
}
|
||||
}
|
||||
|
||||
private OutputLabel getPremixOutputLabel(TransactionDiagram transactionDiagram, List<Payment> premixOutputs) {
|
||||
if(premixOutputs.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Payment premixOutput = premixOutputs.get(0);
|
||||
long total = premixOutputs.stream().mapToLong(Payment::getAmount).sum();
|
||||
Glyph glyph = transactionDiagram.getOutputGlyph(premixOutput);
|
||||
String text;
|
||||
if(premixOutputs.size() == 1) {
|
||||
text = "Premix transaction with 1 output of " + transactionDiagram.getSatsValue(premixOutput.getAmount()) + " sats";
|
||||
} else {
|
||||
text = "Premix transaction with " + premixOutputs.size() + " outputs of " + transactionDiagram.getSatsValue(premixOutput.getAmount()) + " sats each ("
|
||||
+ transactionDiagram.getSatsValue(total) + " sats)";
|
||||
}
|
||||
|
||||
return getOutputLabel(glyph, text);
|
||||
}
|
||||
|
||||
private OutputLabel getBadbankOutputLabel(TransactionDiagram transactionDiagram, Payment payment) {
|
||||
Glyph glyph = transactionDiagram.getOutputGlyph(payment);
|
||||
String text = "Badbank change of " + transactionDiagram.getSatsValue(payment.getAmount()) + " sats to " + payment.getAddress().toString();
|
||||
|
||||
return getOutputLabel(glyph, text);
|
||||
}
|
||||
|
||||
private OutputLabel getWhirlpoolFeeOutputLabel(TransactionDiagram transactionDiagram, Payment whirlpoolFee, List<Payment> premixOutputs) {
|
||||
long total = premixOutputs.stream().mapToLong(Payment::getAmount).sum();
|
||||
double feePercentage = (double)whirlpoolFee.getAmount() / (total - whirlpoolFee.getAmount());
|
||||
Glyph glyph = transactionDiagram.getOutputGlyph(whirlpoolFee);
|
||||
String text = "Whirlpool fee of " + transactionDiagram.getSatsValue(whirlpoolFee.getAmount()) + " sats (" + String.format("%.2f", feePercentage * 100.0) + "% of total premix value)";
|
||||
|
||||
return getOutputLabel(glyph, text);
|
||||
}
|
||||
|
||||
private OutputLabel getMixOutputLabel(TransactionDiagram transactionDiagram, List<Payment> mixOutputs) {
|
||||
if(mixOutputs.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Payment remixOutput = mixOutputs.get(0);
|
||||
long total = mixOutputs.stream().mapToLong(Payment::getAmount).sum();
|
||||
Glyph glyph = TransactionDiagram.getPremixGlyph();
|
||||
String text = "Mix transaction with " + mixOutputs.size() + " outputs of " + transactionDiagram.getSatsValue(remixOutput.getAmount()) + " sats each ("
|
||||
+ transactionDiagram.getSatsValue(total) + " sats)";
|
||||
|
||||
return getOutputLabel(glyph, text);
|
||||
}
|
||||
|
||||
private OutputLabel getRemixOutputLabel(TransactionDiagram transactionDiagram, List<Payment> remixOutputs) {
|
||||
if(remixOutputs.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Payment remixOutput = remixOutputs.get(0);
|
||||
long total = remixOutputs.stream().mapToLong(Payment::getAmount).sum();
|
||||
Glyph glyph = TransactionDiagram.getPremixGlyph();
|
||||
String text = "Remix transaction with " + remixOutputs.size() + " outputs of " + transactionDiagram.getSatsValue(remixOutput.getAmount()) + " sats each ("
|
||||
+ transactionDiagram.getSatsValue(total) + " sats)";
|
||||
|
||||
return getOutputLabel(glyph, text);
|
||||
}
|
||||
|
||||
private OutputLabel getOutputLabel(TransactionDiagram transactionDiagram, Payment payment) {
|
||||
WalletTransaction walletTx = transactionDiagram.getWalletTransaction();
|
||||
Wallet toWallet = transactionDiagram.getToWallet(payment);
|
||||
WalletNode toNode = walletTx.getWallet() != null && !walletTx.getWallet().isBip47() ? walletTx.getAddressNodeMap().get(payment.getAddress()) : null;
|
||||
|
||||
Glyph glyph = transactionDiagram.getOutputGlyph(payment);
|
||||
String text = (toWallet == null ? (toNode != null ? "Consolidate " : "Pay ") : "Receive ") + transactionDiagram.getSatsValue(payment.getAmount()) + " sats to " + payment.getAddress().toString();
|
||||
|
||||
return getOutputLabel(glyph, text);
|
||||
}
|
||||
|
||||
private OutputLabel getOutputLabel(TransactionDiagram transactionDiagram, Map.Entry<WalletNode, Long> changeEntry) {
|
||||
WalletTransaction walletTx = transactionDiagram.getWalletTransaction();
|
||||
|
||||
Glyph glyph = TransactionDiagram.getChangeGlyph();
|
||||
String text = "Change of " + transactionDiagram.getSatsValue(changeEntry.getValue()) + " sats to " + walletTx.getChangeAddress(changeEntry.getKey()).toString();
|
||||
|
||||
return getOutputLabel(glyph, text);
|
||||
}
|
||||
|
||||
private OutputLabel getFeeOutputLabel(TransactionDiagram transactionDiagram) {
|
||||
WalletTransaction walletTx = transactionDiagram.getWalletTransaction();
|
||||
if(walletTx.getFee() < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Glyph glyph = transactionDiagram.getFeeGlyph();
|
||||
String text = "Fee of " + transactionDiagram.getSatsValue(walletTx.getFee()) + " sats (" + String.format("%.2f", walletTx.getFeePercentage() * 100.0) + "%)";
|
||||
|
||||
return getOutputLabel(glyph, text);
|
||||
}
|
||||
|
||||
private OutputLabel getOutputLabel(Glyph glyph, String text) {
|
||||
Label icon = new Label();
|
||||
icon.setMinWidth(15);
|
||||
glyph.setFontSize(12);
|
||||
icon.setGraphic(glyph);
|
||||
|
||||
CopyableLabel label = new CopyableLabel();
|
||||
label.setFont(Font.font("Roboto Mono Italic", 13));
|
||||
label.setText(text);
|
||||
|
||||
HBox output = new HBox(5);
|
||||
output.setAlignment(Pos.CENTER);
|
||||
output.managedProperty().bind(output.visibleProperty());
|
||||
output.setVisible(false);
|
||||
output.getChildren().addAll(icon, label);
|
||||
|
||||
double lineWidth = TextUtils.computeTextWidth(label.getFont(), label.getText(), 0.0D) + 2 + getSpacing() + icon.getMinWidth() + 60;
|
||||
return new OutputLabel(output, lineWidth, text);
|
||||
}
|
||||
|
||||
public static Glyph getLeftGlyph() {
|
||||
Glyph caretLeftGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.CARET_LEFT);
|
||||
caretLeftGlyph.getStyleClass().add("label-left-icon");
|
||||
caretLeftGlyph.setFontSize(15);
|
||||
return caretLeftGlyph;
|
||||
}
|
||||
|
||||
public static Glyph getRightGlyph() {
|
||||
Glyph caretRightGlyph = new Glyph(FontAwesome5.FONT_NAME, FontAwesome5.Glyph.CARET_RIGHT);
|
||||
caretRightGlyph.getStyleClass().add("label-right-icon");
|
||||
caretRightGlyph.setFontSize(15);
|
||||
return caretRightGlyph;
|
||||
}
|
||||
|
||||
private record OutputLabel(HBox hBox, double width, String text) {}
|
||||
}
|
||||
@@ -50,8 +50,7 @@ public class TransactionsTreeTable extends CoinTreeTable {
|
||||
setPlaceholder(getDefaultPlaceholder(rootEntry.getWallet()));
|
||||
setEditable(true);
|
||||
setColumnResizePolicy(TreeTableView.CONSTRAINED_RESIZE_POLICY);
|
||||
dateCol.setSortType(TreeTableColumn.SortType.DESCENDING);
|
||||
getSortOrder().add(dateCol);
|
||||
setSortColumn(0, TreeTableColumn.SortType.DESCENDING);
|
||||
}
|
||||
|
||||
public void updateAll(WalletTransactionsEntry rootEntry) {
|
||||
@@ -61,16 +60,13 @@ public class TransactionsTreeTable extends CoinTreeTable {
|
||||
setRoot(rootItem);
|
||||
rootItem.setExpanded(true);
|
||||
|
||||
if(getColumns().size() > 0 && getSortOrder().isEmpty()) {
|
||||
TreeTableColumn<Entry, ?> dateCol = getColumns().get(0);
|
||||
getSortOrder().add(dateCol);
|
||||
dateCol.setSortType(TreeTableColumn.SortType.DESCENDING);
|
||||
}
|
||||
setSortColumn(0, TreeTableColumn.SortType.DESCENDING);
|
||||
}
|
||||
|
||||
public void updateHistory() {
|
||||
//Transaction entries should have already been updated using WalletTransactionsEntry.updateHistory, so only a resort required
|
||||
sort();
|
||||
setSortColumn(0, TreeTableColumn.SortType.DESCENDING);
|
||||
}
|
||||
|
||||
public void updateLabel(Entry entry) {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import javafx.scene.control.TreeTableColumn;
|
||||
|
||||
public class TreeTableColumnConfig {
|
||||
private final int index;
|
||||
private final Integer width;
|
||||
private final TreeTableColumn.SortType sortType;
|
||||
|
||||
public TreeTableColumnConfig(int index, Integer width, TreeTableColumn.SortType sortType) {
|
||||
this.index = index;
|
||||
this.width = width;
|
||||
this.sortType = sortType;
|
||||
}
|
||||
|
||||
public TreeTableColumnConfig(int index, String width, String sortType) {
|
||||
this.index = index;
|
||||
this.width = width.isEmpty() ? null : Integer.valueOf(width, 10);
|
||||
this.sortType = sortType.isEmpty() ? null : TreeTableColumn.SortType.valueOf(sortType);
|
||||
}
|
||||
|
||||
public int getIndex() {
|
||||
return index;
|
||||
}
|
||||
|
||||
public Integer getWidth() {
|
||||
return width;
|
||||
}
|
||||
|
||||
public TreeTableColumn.SortType getSortType() {
|
||||
return sortType;
|
||||
}
|
||||
|
||||
public String toString() {
|
||||
return index + "-" + (width == null ? "" : width) + "-" + (sortType == null ? "" : sortType);
|
||||
}
|
||||
|
||||
public static TreeTableColumnConfig fromString(String columnConfig) {
|
||||
String[] parts = columnConfig.split("-", 3);
|
||||
if(parts.length == 3) {
|
||||
return new TreeTableColumnConfig(Integer.parseInt(parts[0]), parts[1], parts[2]);
|
||||
}
|
||||
|
||||
return new TreeTableColumnConfig(Integer.parseInt(parts[0]), (Integer)null, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.sparrow.wallet.Entry;
|
||||
import javafx.scene.control.TreeTableColumn;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.StringJoiner;
|
||||
|
||||
public class TreeTableConfig {
|
||||
private final List<TreeTableColumnConfig> columnConfigs;
|
||||
|
||||
public TreeTableConfig(CoinTreeTable treeTable) {
|
||||
columnConfigs = new ArrayList<>();
|
||||
TreeTableColumn<Entry, ?> sortColumn = treeTable.getSortOrder().isEmpty() ? null : treeTable.getSortOrder().get(0);
|
||||
for(int i = 0; i < treeTable.getColumns().size(); i++) {
|
||||
TreeTableColumn<Entry, ?> column = treeTable.getColumns().get(i);
|
||||
//TODO: Support column widths
|
||||
columnConfigs.add(new TreeTableColumnConfig(i, null, sortColumn == column ? column.getSortType() : null));
|
||||
}
|
||||
}
|
||||
|
||||
public TreeTableConfig(List<TreeTableColumnConfig> columnConfigs) {
|
||||
this.columnConfigs = columnConfigs;
|
||||
}
|
||||
|
||||
public String toString() {
|
||||
StringJoiner joiner = new StringJoiner("|");
|
||||
columnConfigs.stream().forEach(col -> joiner.add(col.toString()));
|
||||
return joiner.toString();
|
||||
}
|
||||
|
||||
public static TreeTableConfig fromString(String tableConfig) {
|
||||
List<TreeTableColumnConfig> columnConfigs = new ArrayList<>();
|
||||
String[] parts = tableConfig.split("\\|");
|
||||
for(String part : parts) {
|
||||
columnConfigs.add(TreeTableColumnConfig.fromString(part));
|
||||
}
|
||||
|
||||
return new TreeTableConfig(columnConfigs);
|
||||
}
|
||||
}
|
||||
@@ -83,8 +83,7 @@ public class UtxosTreeTable extends CoinTreeTable {
|
||||
setPlaceholder(getDefaultPlaceholder(rootEntry.getWallet()));
|
||||
setEditable(true);
|
||||
setColumnResizePolicy(TreeTableView.CONSTRAINED_RESIZE_POLICY);
|
||||
amountCol.setSortType(TreeTableColumn.SortType.DESCENDING);
|
||||
getSortOrder().add(amountCol);
|
||||
setSortColumn(getColumns().size() - 1, TreeTableColumn.SortType.DESCENDING);
|
||||
|
||||
getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
|
||||
}
|
||||
@@ -96,17 +95,14 @@ public class UtxosTreeTable extends CoinTreeTable {
|
||||
setRoot(rootItem);
|
||||
rootItem.setExpanded(true);
|
||||
|
||||
if(getColumns().size() > 0 && getSortOrder().isEmpty()) {
|
||||
TreeTableColumn<Entry, ?> amountCol = getColumns().get(getColumns().size() - 1);
|
||||
getSortOrder().add(amountCol);
|
||||
amountCol.setSortType(TreeTableColumn.SortType.DESCENDING);
|
||||
}
|
||||
setSortColumn(getColumns().size() - 1, TreeTableColumn.SortType.DESCENDING);
|
||||
}
|
||||
|
||||
public void updateHistory() {
|
||||
//Utxo entries should have already been updated, so only a resort required
|
||||
if(!getRoot().getChildren().isEmpty()) {
|
||||
sort();
|
||||
setSortColumn(getColumns().size() - 1, TreeTableColumn.SortType.DESCENDING);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ public class WalletImportDialog extends Dialog<Wallet> {
|
||||
}
|
||||
}
|
||||
|
||||
List<WalletImport> walletImporters = new ArrayList<>(List.of(new Bip129(), new CaravanMultisig(), new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new KeystoneMultisig(), new Descriptor(), new SpecterDesktop(), new BlueWalletMultisig(), new Sparrow()));
|
||||
List<WalletImport> walletImporters = new ArrayList<>(List.of(new Bip129(), new CaravanMultisig(), new ColdcardMultisig(), new CoboVaultMultisig(), new Electrum(), new KeystoneMultisig(), new Descriptor(), new SpecterDesktop(), new BlueWalletMultisig(), new Sparrow(), new JadeMultisig()));
|
||||
if(!selectedWalletForms.isEmpty()) {
|
||||
walletImporters.add(new WalletLabels(selectedWalletForms));
|
||||
}
|
||||
|
||||
@@ -42,9 +42,13 @@ public class WalletNameDialog extends Dialog<WalletNameDialog.NameAndBirthDate>
|
||||
}
|
||||
|
||||
public WalletNameDialog(String initialName, boolean hasExistingTransactions, Date startDate) {
|
||||
this(initialName, hasExistingTransactions, startDate, false);
|
||||
}
|
||||
|
||||
public WalletNameDialog(String initialName, boolean hasExistingTransactions, Date startDate, boolean rename) {
|
||||
final DialogPane dialogPane = getDialogPane();
|
||||
AppServices.setStageIcon(dialogPane.getScene().getWindow());
|
||||
boolean requestBirthDate = (Config.get().getServerType() == null || Config.get().getServerType() == ServerType.BITCOIN_CORE);
|
||||
boolean requestBirthDate = !rename && (Config.get().getServerType() == null || Config.get().getServerType() == ServerType.BITCOIN_CORE);
|
||||
|
||||
setTitle("Wallet Name");
|
||||
dialogPane.setHeaderText("Enter a name for this wallet:");
|
||||
@@ -121,16 +125,16 @@ public class WalletNameDialog extends Dialog<WalletNameDialog.NameAndBirthDate>
|
||||
));
|
||||
});
|
||||
|
||||
final ButtonType okButtonType = new javafx.scene.control.ButtonType("Create Wallet", ButtonBar.ButtonData.OK_DONE);
|
||||
final ButtonType okButtonType = new javafx.scene.control.ButtonType((rename ? "Rename" : "Create") + " Wallet", ButtonBar.ButtonData.OK_DONE);
|
||||
dialogPane.getButtonTypes().addAll(okButtonType);
|
||||
Button okButton = (Button) dialogPane.lookupButton(okButtonType);
|
||||
BooleanBinding isInvalid = Bindings.createBooleanBinding(() ->
|
||||
name.getText().length() == 0 || Storage.walletExists(name.getText()) || (existingCheck.isSelected() && existingPicker.getValue() == null), name.textProperty(), existingCheck.selectedProperty(), existingPicker.valueProperty());
|
||||
name.getText().trim().length() == 0 || Storage.walletExists(name.getText()) || (existingCheck.isSelected() && existingPicker.getValue() == null), name.textProperty(), existingCheck.selectedProperty(), existingPicker.valueProperty());
|
||||
okButton.disableProperty().bind(isInvalid);
|
||||
|
||||
name.setPromptText("Wallet Name");
|
||||
Platform.runLater(name::requestFocus);
|
||||
setResultConverter(dialogButton -> dialogButton == okButtonType ? new NameAndBirthDate(name.getText(), existingPicker.getValue()) : null);
|
||||
setResultConverter(dialogButton -> dialogButton == okButtonType ? new NameAndBirthDate(name.getText().trim(), existingPicker.getValue()) : null);
|
||||
}
|
||||
|
||||
public static class NameAndBirthDate {
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
package com.sparrowwallet.sparrow.control;
|
||||
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.CurrencyRate;
|
||||
import com.sparrowwallet.sparrow.io.Config;
|
||||
import com.sparrowwallet.sparrow.net.ExchangeSource;
|
||||
import com.sparrowwallet.sparrow.wallet.Entry;
|
||||
import com.sparrowwallet.sparrow.wallet.Function;
|
||||
import com.sparrowwallet.sparrow.wallet.WalletForm;
|
||||
import com.sparrowwallet.sparrow.wallet.WalletTransactionsEntry;
|
||||
import javafx.beans.property.ReadOnlyObjectWrapper;
|
||||
import javafx.geometry.Side;
|
||||
import javafx.scene.chart.NumberAxis;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.VBox;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class WalletSummaryDialog extends Dialog<Void> {
|
||||
public WalletSummaryDialog(List<WalletForm> walletForms) {
|
||||
if(walletForms.isEmpty()) {
|
||||
throw new IllegalArgumentException("No wallets selected to summarize");
|
||||
}
|
||||
|
||||
Wallet masterWallet = walletForms.get(0).getMasterWallet();
|
||||
|
||||
final DialogPane dialogPane = getDialogPane();
|
||||
dialogPane.getStylesheets().add(AppServices.class.getResource("general.css").toExternalForm());
|
||||
dialogPane.getStylesheets().add(AppServices.class.getResource("dialog.css").toExternalForm());
|
||||
dialogPane.getStylesheets().add(AppServices.class.getResource("wallet/wallet.css").toExternalForm());
|
||||
dialogPane.getStylesheets().add(AppServices.class.getResource("wallet/transactions.css").toExternalForm());
|
||||
|
||||
AppServices.setStageIcon(dialogPane.getScene().getWindow());
|
||||
dialogPane.setHeaderText("Wallet Summary for " + masterWallet.getName());
|
||||
|
||||
Image image = new Image("image/sparrow-small.png", 50, 50, false, false);
|
||||
if(!image.isError()) {
|
||||
ImageView imageView = new ImageView();
|
||||
imageView.setSmooth(false);
|
||||
imageView.setImage(image);
|
||||
dialogPane.setGraphic(imageView);
|
||||
}
|
||||
|
||||
HBox hBox = new HBox(40);
|
||||
|
||||
CoinTreeTable table = new CoinTreeTable();
|
||||
|
||||
TreeTableColumn<Entry, String> nameColumn = new TreeTableColumn<>("Wallet");
|
||||
nameColumn.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, String> param) -> {
|
||||
return new ReadOnlyObjectWrapper<>(param.getValue().getValue().getLabel());
|
||||
});
|
||||
nameColumn.setCellFactory(p -> new LabelCell());
|
||||
table.getColumns().add(nameColumn);
|
||||
|
||||
TreeTableColumn<Entry, Number> balanceColumn = new TreeTableColumn<>("Balance");
|
||||
balanceColumn.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, Number> param) -> {
|
||||
return new ReadOnlyObjectWrapper<>(param.getValue().getValue().getValue());
|
||||
});
|
||||
balanceColumn.setCellFactory(p -> new CoinCell());
|
||||
table.getColumns().add(balanceColumn);
|
||||
table.setUnitFormat(masterWallet);
|
||||
|
||||
CurrencyRate currencyRate = AppServices.getFiatCurrencyExchangeRate();
|
||||
if(currencyRate != null && currencyRate.isAvailable() && Config.get().getExchangeSource() != ExchangeSource.NONE) {
|
||||
TreeTableColumn<Entry, Number> fiatColumn = new TreeTableColumn<>(currencyRate.getCurrency().getSymbol());
|
||||
fiatColumn.setCellValueFactory((TreeTableColumn.CellDataFeatures<Entry, Number> param) -> {
|
||||
return new ReadOnlyObjectWrapper<>(param.getValue().getValue().getValue());
|
||||
});
|
||||
fiatColumn.setCellFactory(p -> new FiatCell());
|
||||
table.getColumns().add(fiatColumn);
|
||||
table.setCurrencyRate(currencyRate);
|
||||
}
|
||||
|
||||
SummaryEntry rootEntry = new SummaryEntry(walletForms);
|
||||
TreeItem<Entry> rootItem = new TreeItem<>(rootEntry);
|
||||
for(Entry childEntry : rootEntry.getChildren()) {
|
||||
TreeItem<Entry> childItem = new TreeItem<>(childEntry);
|
||||
rootItem.getChildren().add(childItem);
|
||||
childItem.getChildren().add(new TreeItem<>(new UnconfirmedEntry((WalletTransactionsEntry)childEntry)));
|
||||
}
|
||||
|
||||
table.setShowRoot(true);
|
||||
table.setRoot(rootItem);
|
||||
rootItem.setExpanded(true);
|
||||
|
||||
table.setColumnResizePolicy(TreeTableView.CONSTRAINED_RESIZE_POLICY);
|
||||
table.setPrefWidth(450);
|
||||
|
||||
VBox vBox = new VBox();
|
||||
vBox.getChildren().add(table);
|
||||
|
||||
hBox.getChildren().add(vBox);
|
||||
|
||||
NumberAxis xAxis = new NumberAxis();
|
||||
xAxis.setSide(Side.BOTTOM);
|
||||
xAxis.setForceZeroInRange(false);
|
||||
xAxis.setMinorTickVisible(false);
|
||||
NumberAxis yAxis = new NumberAxis();
|
||||
yAxis.setSide(Side.LEFT);
|
||||
BalanceChart balanceChart = new BalanceChart(xAxis, yAxis);
|
||||
balanceChart.initialize(new WalletTransactionsEntry(masterWallet, true));
|
||||
balanceChart.setAnimated(false);
|
||||
balanceChart.setLegendVisible(false);
|
||||
balanceChart.setVerticalGridLinesVisible(false);
|
||||
|
||||
hBox.getChildren().add(balanceChart);
|
||||
|
||||
getDialogPane().setContent(hBox);
|
||||
|
||||
ButtonType okButtonType = new javafx.scene.control.ButtonType("Done", ButtonBar.ButtonData.OK_DONE);
|
||||
dialogPane.getButtonTypes().addAll(okButtonType);
|
||||
|
||||
AppServices.moveToActiveWindowScreen(this);
|
||||
}
|
||||
|
||||
public static class SummaryEntry extends Entry {
|
||||
private SummaryEntry(List<WalletForm> walletForms) {
|
||||
super(walletForms.get(0).getWallet(), walletForms.get(0).getWallet().getName(), walletForms.stream().map(WalletForm::getWalletTransactionsEntry).collect(Collectors.toList()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Long getValue() {
|
||||
long value = 0;
|
||||
for(Entry entry : getChildren()) {
|
||||
value += entry.getValue();
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getEntryType() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Function getWalletFunction() {
|
||||
return Function.TRANSACTIONS;
|
||||
}
|
||||
}
|
||||
|
||||
public static class UnconfirmedEntry extends Entry {
|
||||
private final WalletTransactionsEntry walletTransactionsEntry;
|
||||
|
||||
private UnconfirmedEntry(WalletTransactionsEntry walletTransactionsEntry) {
|
||||
super(walletTransactionsEntry.getWallet(), "Unconfirmed", Collections.emptyList());
|
||||
this.walletTransactionsEntry = walletTransactionsEntry;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Long getValue() {
|
||||
return walletTransactionsEntry.getMempoolBalance();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getEntryType() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Function getWalletFunction() {
|
||||
return Function.TRANSACTIONS;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import javafx.concurrent.ScheduledService;
|
||||
import javafx.concurrent.Task;
|
||||
import javafx.embed.swing.SwingFXUtils;
|
||||
import javafx.scene.image.Image;
|
||||
import net.sourceforge.zbar.ZBar;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
@@ -155,6 +156,13 @@ public class WebcamService extends ScheduledService<Image> {
|
||||
LuminanceSource source = new BufferedImageLuminanceSource(bufferedImage);
|
||||
BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(source));
|
||||
|
||||
if(ZBar.isEnabled()) {
|
||||
ZBar.Scan scan = ZBar.scan(bufferedImage);
|
||||
if(scan != null) {
|
||||
return new Result(scan.stringData(), scan.rawData(), new ResultPoint[0], BarcodeFormat.QR_CODE);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return qrReader.decode(bitmap, Map.of(DecodeHintType.TRY_HARDER, Boolean.TRUE));
|
||||
} catch(ReaderException e) {
|
||||
|
||||
@@ -2,6 +2,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.BlockTransaction;
|
||||
import com.sparrowwallet.drongo.wallet.BlockTransactionHashIndex;
|
||||
import com.sparrowwallet.drongo.wallet.Payment;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
@@ -14,21 +15,23 @@ public class SpendUtxoEvent {
|
||||
private final List<Payment> payments;
|
||||
private final List<byte[]> opReturns;
|
||||
private final Long fee;
|
||||
private final boolean includeSpentMempoolOutputs;
|
||||
private final boolean requireAllUtxos;
|
||||
private final BlockTransaction replacedTransaction;
|
||||
private final Pool pool;
|
||||
private final PaymentCode paymentCode;
|
||||
|
||||
public SpendUtxoEvent(Wallet wallet, List<BlockTransactionHashIndex> utxos) {
|
||||
this(wallet, utxos, null, null, null, false);
|
||||
this(wallet, utxos, null, null, null, false, null);
|
||||
}
|
||||
|
||||
public SpendUtxoEvent(Wallet wallet, List<BlockTransactionHashIndex> utxos, List<Payment> payments, List<byte[]> opReturns, Long fee, boolean includeSpentMempoolOutputs) {
|
||||
public SpendUtxoEvent(Wallet wallet, List<BlockTransactionHashIndex> utxos, List<Payment> payments, List<byte[]> opReturns, Long fee, boolean requireAllUtxos, BlockTransaction replacedTransaction) {
|
||||
this.wallet = wallet;
|
||||
this.utxos = utxos;
|
||||
this.payments = payments;
|
||||
this.opReturns = opReturns;
|
||||
this.fee = fee;
|
||||
this.includeSpentMempoolOutputs = includeSpentMempoolOutputs;
|
||||
this.requireAllUtxos = requireAllUtxos;
|
||||
this.replacedTransaction = replacedTransaction;
|
||||
this.pool = null;
|
||||
this.paymentCode = null;
|
||||
}
|
||||
@@ -39,7 +42,8 @@ public class SpendUtxoEvent {
|
||||
this.payments = payments;
|
||||
this.opReturns = opReturns;
|
||||
this.fee = fee;
|
||||
this.includeSpentMempoolOutputs = false;
|
||||
this.requireAllUtxos = false;
|
||||
this.replacedTransaction = null;
|
||||
this.pool = pool;
|
||||
this.paymentCode = null;
|
||||
}
|
||||
@@ -50,7 +54,8 @@ public class SpendUtxoEvent {
|
||||
this.payments = payments;
|
||||
this.opReturns = opReturns;
|
||||
this.fee = null;
|
||||
this.includeSpentMempoolOutputs = false;
|
||||
this.requireAllUtxos = false;
|
||||
this.replacedTransaction = null;
|
||||
this.pool = null;
|
||||
this.paymentCode = paymentCode;
|
||||
}
|
||||
@@ -75,8 +80,12 @@ public class SpendUtxoEvent {
|
||||
return fee;
|
||||
}
|
||||
|
||||
public boolean isIncludeSpentMempoolOutputs() {
|
||||
return includeSpentMempoolOutputs;
|
||||
public boolean isRequireAllUtxos() {
|
||||
return requireAllUtxos;
|
||||
}
|
||||
|
||||
public BlockTransaction getReplacedTransaction() {
|
||||
return replacedTransaction;
|
||||
}
|
||||
|
||||
public Pool getPool() {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.sparrowwallet.sparrow.event;
|
||||
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
|
||||
public class WhirlpoolIndexHighFrequencyEvent {
|
||||
private final Wallet wallet;
|
||||
|
||||
public WhirlpoolIndexHighFrequencyEvent(Wallet wallet) {
|
||||
this.wallet = wallet;
|
||||
}
|
||||
|
||||
public Wallet getWallet() {
|
||||
return wallet;
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,8 @@ public class FontAwesome5 extends GlyphFont {
|
||||
BTC('\uf15a'),
|
||||
BULLSEYE('\uf140'),
|
||||
CAMERA('\uf030'),
|
||||
CARET_LEFT('\uf0d9'),
|
||||
CARET_RIGHT('\uf0da'),
|
||||
CHECK_CIRCLE('\uf058'),
|
||||
CIRCLE('\uf111'),
|
||||
COINS('\uf51e'),
|
||||
|
||||
@@ -28,7 +28,7 @@ public class CaravanMultisig implements WalletImport, WalletExport {
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "Caravan Multisig";
|
||||
return "Unchained or Caravan Multisig";
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.sparrowwallet.drongo.wallet.Keystore;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.drongo.wallet.WalletModel;
|
||||
import com.sparrowwallet.sparrow.io.ckcard.CkCardApi;
|
||||
import com.sparrowwallet.sparrow.io.satochip.SatoCardApi;
|
||||
import javafx.beans.property.StringProperty;
|
||||
import javafx.concurrent.Service;
|
||||
import org.controlsfx.tools.Platform;
|
||||
@@ -23,6 +24,7 @@ import javax.smartcardio.TerminalFactory;
|
||||
import java.io.File;
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
@@ -33,19 +35,30 @@ public abstract class CardApi {
|
||||
new File("/usr/lib/libpcsclite.so.1"),
|
||||
new File("/usr/local/lib/libpcsclite.so.1"),
|
||||
new File("/lib/x86_64-linux-gnu/libpcsclite.so.1"),
|
||||
new File("/lib/aarch64-linux-gnu/libpcsclite.so.1")};
|
||||
new File("/lib/aarch64-linux-gnu/libpcsclite.so.1"),
|
||||
new File("/usr/lib64/libpcsclite.so.1"),
|
||||
new File("/usr/lib/x86_64-linux-gnu/libpcsclite.so.1")};
|
||||
|
||||
private static boolean initialized;
|
||||
|
||||
public static List<WalletModel> getConnectedCards() throws CardException {
|
||||
List<WalletModel> cards = new ArrayList<>();
|
||||
|
||||
try {
|
||||
CkCardApi ckCardApi = new CkCardApi(null, null);
|
||||
return List.of(ckCardApi.getCardType());
|
||||
cards.add(ckCardApi.getCardType());
|
||||
} catch(Exception e) {
|
||||
//ignore
|
||||
}
|
||||
|
||||
return Collections.emptyList();
|
||||
try {
|
||||
SatoCardApi satoCardApi = new SatoCardApi(null, null);
|
||||
cards.add(satoCardApi.getCardType());
|
||||
} catch(Exception e) {
|
||||
//ignore
|
||||
}
|
||||
|
||||
return cards;
|
||||
}
|
||||
|
||||
public static CardApi getCardApi(WalletModel walletModel, String pin) throws CardException {
|
||||
@@ -53,6 +66,10 @@ public abstract class CardApi {
|
||||
return new CkCardApi(walletModel, pin);
|
||||
}
|
||||
|
||||
if(walletModel == WalletModel.SATOCHIP) {
|
||||
return new SatoCardApi(walletModel, pin);
|
||||
}
|
||||
|
||||
throw new IllegalArgumentException("Cannot create card API for " + walletModel.toDisplayString());
|
||||
}
|
||||
|
||||
|
||||
@@ -6,5 +6,5 @@ import javax.smartcardio.CardException;
|
||||
|
||||
public interface CardImport extends ImportExport {
|
||||
boolean isInitialized() throws CardException;
|
||||
void initialize(String pin, byte[] chainCode, StringProperty messageProperty) throws CardException;
|
||||
void initialize(String pin, byte[] entropy, StringProperty messageProperty) throws CardException;
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ public class Config {
|
||||
private int enumerateHwPeriod = ENUMERATE_HW_PERIOD_SECS;
|
||||
private QRDensity qrDensity;
|
||||
private Boolean hdCapture;
|
||||
private boolean useZbar = true;
|
||||
private String webcamDevice;
|
||||
private ServerType serverType;
|
||||
private Server publicElectrumServer;
|
||||
@@ -77,6 +78,7 @@ public class Config {
|
||||
private int maxPageSize = DEFAULT_PAGE_SIZE;
|
||||
private boolean usePayNym;
|
||||
private boolean sameAppMixing;
|
||||
private boolean mempoolFullRbf;
|
||||
private Double appWidth;
|
||||
private Double appHeight;
|
||||
|
||||
@@ -404,6 +406,10 @@ public class Config {
|
||||
flush();
|
||||
}
|
||||
|
||||
public boolean isUseZbar() {
|
||||
return useZbar;
|
||||
}
|
||||
|
||||
public String getWebcamDevice() {
|
||||
return webcamDevice;
|
||||
}
|
||||
@@ -665,6 +671,15 @@ public class Config {
|
||||
flush();
|
||||
}
|
||||
|
||||
public boolean isMempoolFullRbf() {
|
||||
return mempoolFullRbf;
|
||||
}
|
||||
|
||||
public void setMempoolFullRbf(boolean mempoolFullRbf) {
|
||||
this.mempoolFullRbf = mempoolFullRbf;
|
||||
flush();
|
||||
}
|
||||
|
||||
public Double getAppWidth() {
|
||||
return appWidth;
|
||||
}
|
||||
|
||||
@@ -36,7 +36,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.2.1";
|
||||
private static final String HWI_VERSION = "2.3.1";
|
||||
private static final String HWI_VERSION_DIR = HWI_VERSION_PREFIX + HWI_VERSION;
|
||||
|
||||
private static boolean isPromptActive = false;
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package com.sparrowwallet.sparrow.io;
|
||||
|
||||
import com.sparrowwallet.drongo.wallet.Keystore;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.drongo.wallet.WalletModel;
|
||||
|
||||
import java.io.InputStream;
|
||||
|
||||
public class JadeMultisig extends ColdcardMultisig {
|
||||
@Override
|
||||
public String getName() {
|
||||
@@ -38,4 +41,30 @@ public class JadeMultisig extends ColdcardMultisig {
|
||||
public boolean walletExportRequiresDecryption() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getWalletImportDescription() {
|
||||
return "Import the QR created using Options > Wallet > Registered Wallets on your Jade.";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isWalletImportScannable() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isWalletImportFileFormatAvailable() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Wallet importWallet(InputStream inputStream, String password) throws ImportException {
|
||||
Wallet wallet = super.importWallet(inputStream, password);
|
||||
for(Keystore keystore : wallet.getKeystores()) {
|
||||
keystore.setLabel(keystore.getLabel().replace("Coldcard", "Jade"));
|
||||
keystore.setWalletModel(WalletModel.JADE);
|
||||
}
|
||||
|
||||
return wallet;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.sparrowwallet.sparrow.io;
|
||||
|
||||
import com.sparrowwallet.drongo.wallet.WalletModel;
|
||||
|
||||
public class Krux extends SpecterDIY {
|
||||
@Override
|
||||
public String getName() {
|
||||
return "Krux";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getKeystoreImportDescription(int account) {
|
||||
return "Import file or QR created on your Krux by selecting Extended Public Key from the main menu once you have loaded your mnemonic.";
|
||||
}
|
||||
|
||||
@Override
|
||||
public WalletModel getWalletModel() {
|
||||
return WalletModel.KRUX;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isFileFormatAvailable() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.sparrowwallet.sparrow.io;
|
||||
|
||||
import com.google.zxing.BarcodeFormat;
|
||||
import com.google.zxing.EncodeHintType;
|
||||
import com.google.zxing.WriterException;
|
||||
import com.google.zxing.client.j2se.MatrixToImageConfig;
|
||||
import com.google.zxing.client.j2se.MatrixToImageWriter;
|
||||
@@ -104,7 +105,7 @@ public class PdfUtils {
|
||||
|
||||
private static javafx.scene.image.Image getQrCode(String fragment) throws IOException, WriterException {
|
||||
QRCodeWriter qrCodeWriter = new QRCodeWriter();
|
||||
BitMatrix qrMatrix = qrCodeWriter.encode(fragment.toUpperCase(Locale.ROOT), BarcodeFormat.QR_CODE, QR_WIDTH, QR_HEIGHT);
|
||||
BitMatrix qrMatrix = qrCodeWriter.encode(fragment.toUpperCase(Locale.ROOT), BarcodeFormat.QR_CODE, QR_WIDTH, QR_HEIGHT, Map.of(EncodeHintType.MARGIN, "2"));
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
MatrixToImageWriter.writeToStream(qrMatrix, "PNG", baos, new MatrixToImageConfig());
|
||||
|
||||
@@ -39,8 +39,10 @@ public class Sparrow implements WalletImport, WalletExport {
|
||||
Storage tempStorage = new Storage(persistence, tempFile);
|
||||
tempStorage.setKeyDeriver(storage.getKeyDeriver());
|
||||
tempStorage.setEncryptionPubKey(storage.getEncryptionPubKey());
|
||||
tempStorage.saveWallet(exportedWallet);
|
||||
for(Wallet childWallet : exportedWallet.getChildWallets()) {
|
||||
|
||||
Wallet copy = exportedWallet.copy();
|
||||
tempStorage.saveWallet(copy);
|
||||
for(Wallet childWallet : copy.getChildWallets()) {
|
||||
tempStorage.saveWallet(childWallet);
|
||||
}
|
||||
persistence.close();
|
||||
|
||||
@@ -8,7 +8,6 @@ import com.sparrowwallet.drongo.wallet.StandardAccount;
|
||||
import com.sparrowwallet.drongo.wallet.Wallet;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.SparrowWallet;
|
||||
import com.sparrowwallet.sparrow.control.WalletPasswordDialog;
|
||||
import com.sparrowwallet.sparrow.soroban.Soroban;
|
||||
import com.sparrowwallet.sparrow.whirlpool.Whirlpool;
|
||||
import javafx.concurrent.ScheduledService;
|
||||
@@ -265,8 +264,11 @@ public class Storage {
|
||||
persistence.copyWallet(walletFile, outputStream);
|
||||
}
|
||||
|
||||
public boolean delete() {
|
||||
deleteBackups();
|
||||
public boolean delete(boolean deleteBackups) {
|
||||
if(deleteBackups) {
|
||||
deleteBackups();
|
||||
}
|
||||
|
||||
return IOUtils.secureDelete(walletFile);
|
||||
}
|
||||
|
||||
@@ -735,18 +737,44 @@ public class Storage {
|
||||
}
|
||||
}
|
||||
|
||||
public static class CopyWalletService extends Service<Void> {
|
||||
private final Wallet wallet;
|
||||
private final File newWalletFile;
|
||||
|
||||
public CopyWalletService(Wallet wallet, File newWalletFile) {
|
||||
this.wallet = wallet;
|
||||
this.newWalletFile = newWalletFile;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Task<Void> createTask() {
|
||||
return new Task<>() {
|
||||
protected Void call() throws IOException, ExportException {
|
||||
Sparrow export = new Sparrow();
|
||||
try(BufferedOutputStream outputStream = new BufferedOutputStream(new FileOutputStream(newWalletFile))) {
|
||||
export.exportWallet(wallet, outputStream);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public static class DeleteWalletService extends ScheduledService<Boolean> {
|
||||
private final Storage storage;
|
||||
private final boolean deleteBackups;
|
||||
|
||||
public DeleteWalletService(Storage storage) {
|
||||
public DeleteWalletService(Storage storage, boolean deleteBackups) {
|
||||
this.storage = storage;
|
||||
this.deleteBackups = deleteBackups;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Task<Boolean> createTask() {
|
||||
return new Task<>() {
|
||||
protected Boolean call() {
|
||||
return storage.delete();
|
||||
return storage.delete(deleteBackups);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,4 +8,7 @@ public interface WalletImport extends FileImport {
|
||||
String getWalletImportDescription();
|
||||
Wallet importWallet(InputStream inputStream, String password) throws ImportException;
|
||||
boolean isWalletImportScannable();
|
||||
default boolean isWalletImportFileFormatAvailable() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ public class CardTransport {
|
||||
break;
|
||||
} catch(CardException e) {
|
||||
if(!iter.hasNext()) {
|
||||
log.error(e.getMessage());
|
||||
log.debug(e.getMessage());
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,7 @@ public class CardTransport {
|
||||
CardChannel cardChannel = connection.getBasicChannel();
|
||||
ResponseAPDU resp = cardChannel.transmit(new CommandAPDU(0, 0xA4, 4, 0, Utils.hexToBytes(APPID.toUpperCase())));
|
||||
if(resp.getSW() != SW_OKAY) {
|
||||
throw new CardException("Card initialization error, response was 0x" + Integer.toHexString(resp.getSW()) + ". Note that only the Tapsigner is currently supported.");
|
||||
throw new CardException("Card initialization error, response was 0x" + Integer.toHexString(resp.getSW()));
|
||||
}
|
||||
|
||||
return connection;
|
||||
|
||||
@@ -26,7 +26,7 @@ public class Tapsigner implements KeystoreCardImport {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void initialize(String pin, byte[] chainCode, StringProperty messageProperty) throws CardException {
|
||||
public void initialize(String pin, byte[] entropy, StringProperty messageProperty) throws CardException {
|
||||
if(pin.length() < 6) {
|
||||
throw new CardException("PIN too short.");
|
||||
}
|
||||
@@ -43,7 +43,7 @@ public class Tapsigner implements KeystoreCardImport {
|
||||
throw new IllegalStateException("Card is already initialized.");
|
||||
}
|
||||
cardApi.checkWait(cardStatus, new SimpleIntegerProperty(), messageProperty);
|
||||
cardApi.initialize(0, chainCode);
|
||||
cardApi.initialize(0, entropy);
|
||||
} finally {
|
||||
if(cardApi != null) {
|
||||
cardApi.disconnect();
|
||||
|
||||
@@ -285,7 +285,7 @@ public class DbPersistence implements Persistence {
|
||||
WalletNodeDao walletNodeDao = handle.attach(WalletNodeDao.class);
|
||||
for(Entry entry : dirtyPersistables.labelEntries) {
|
||||
if(entry instanceof TransactionEntry && ((TransactionEntry)entry).getBlockTransaction().getId() != null) {
|
||||
blockTransactionDao.updateLabel(((TransactionEntry)entry).getBlockTransaction().getId(), entry.getLabel());
|
||||
blockTransactionDao.updateLabel(((TransactionEntry)entry).getBlockTransaction().getId(), blockTransactionDao.truncate(entry.getLabel()));
|
||||
} else if(entry instanceof NodeEntry) {
|
||||
WalletNode addressNode = ((NodeEntry)entry).getNode();
|
||||
if(addressNode.getId() == null) {
|
||||
@@ -301,9 +301,9 @@ public class DbPersistence implements Persistence {
|
||||
walletNodeDao.updateNodeAddressData(addressNode.getId(), addressNode.getAddressData());
|
||||
}
|
||||
|
||||
walletNodeDao.updateNodeLabel(addressNode.getId(), entry.getLabel());
|
||||
walletNodeDao.updateNodeLabel(addressNode.getId(), walletNodeDao.truncate(entry.getLabel()));
|
||||
} else if(entry instanceof HashIndexEntry && ((HashIndexEntry)entry).getHashIndex().getId() != null) {
|
||||
walletNodeDao.updateTxoLabel(((HashIndexEntry)entry).getHashIndex().getId(), entry.getLabel());
|
||||
walletNodeDao.updateTxoLabel(((HashIndexEntry)entry).getHashIndex().getId(), walletNodeDao.truncate(entry.getLabel()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -583,7 +583,7 @@ public class DbPersistence implements Persistence {
|
||||
|
||||
@Override
|
||||
public boolean isClosed() {
|
||||
return dataSource.isClosed();
|
||||
return dataSource == null || dataSource.isClosed();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -21,6 +21,7 @@ public interface MixConfigDao {
|
||||
|
||||
default void addMixConfig(Wallet wallet) {
|
||||
if(wallet.getMixConfig() != null) {
|
||||
wallet.getMixConfig().setId(null);
|
||||
addOrUpdate(wallet, wallet.getMixConfig());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package com.sparrowwallet.sparrow.io.satochip;
|
||||
|
||||
import com.sparrowwallet.drongo.Utils;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
|
||||
/**
|
||||
* ISO7816-4 APDU.
|
||||
*/
|
||||
public class APDUCommand {
|
||||
protected int cla;
|
||||
protected int ins;
|
||||
protected int p1;
|
||||
protected int p2;
|
||||
protected byte[] data;
|
||||
protected boolean needsLE;
|
||||
|
||||
/**
|
||||
* Constructs an APDU with no response data length field. The data field cannot be null, but can be a zero-length array.
|
||||
*
|
||||
* @param cla class byte
|
||||
* @param ins instruction code
|
||||
* @param p1 P1 parameter
|
||||
* @param p2 P2 parameter
|
||||
* @param data the APDU data
|
||||
*/
|
||||
public APDUCommand(int cla, int ins, int p1, int p2, byte[] data) {
|
||||
this(cla, ins, p1, p2, data, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs an APDU with an optional data length field. The data field cannot be null, but can be a zero-length array.
|
||||
* The LE byte, if sent, is set to 0.
|
||||
*
|
||||
* @param cla class byte
|
||||
* @param ins instruction code
|
||||
* @param p1 P1 parameter
|
||||
* @param p2 P2 parameter
|
||||
* @param data the APDU data
|
||||
* @param needsLE whether the LE byte should be sent or not
|
||||
*/
|
||||
public APDUCommand(int cla, int ins, int p1, int p2, byte[] data, boolean needsLE) {
|
||||
this.cla = cla & 0xff;
|
||||
this.ins = ins & 0xff;
|
||||
this.p1 = p1 & 0xff;
|
||||
this.p2 = p2 & 0xff;
|
||||
this.data = data;
|
||||
this.needsLE = needsLE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes the APDU in order to send it to the card.
|
||||
*
|
||||
* @return the byte array representation of the APDU
|
||||
*/
|
||||
public byte[] serialize() {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
out.write(this.cla);
|
||||
out.write(this.ins);
|
||||
out.write(this.p1);
|
||||
out.write(this.p2);
|
||||
out.write(this.data.length);
|
||||
out.write(this.data, 0, this.data.length);
|
||||
|
||||
if(this.needsLE) {
|
||||
out.write(0); // Response length
|
||||
}
|
||||
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes the APDU to human readable hex string format
|
||||
*
|
||||
* @return the hex string representation of the APDU
|
||||
*/
|
||||
public String toHexString() {
|
||||
byte[] raw = this.serialize();
|
||||
if(raw == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return Utils.bytesToHex(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the CLA of the APDU
|
||||
*
|
||||
* @return the CLA of the APDU
|
||||
*/
|
||||
public int getCla() {
|
||||
return cla;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the INS of the APDU
|
||||
*
|
||||
* @return the INS of the APDU
|
||||
*/
|
||||
public int getIns() {
|
||||
return ins;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the P1 of the APDU
|
||||
*
|
||||
* @return the P1 of the APDU
|
||||
*/
|
||||
public int getP1() {
|
||||
return p1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the P2 of the APDU
|
||||
*
|
||||
* @return the P2 of the APDU
|
||||
*/
|
||||
public int getP2() {
|
||||
return p2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the data field of the APDU
|
||||
*
|
||||
* @return the data field of the APDU
|
||||
*/
|
||||
public byte[] getData() {
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether LE is sent or not.
|
||||
*
|
||||
* @return whether LE is sent or not
|
||||
*/
|
||||
public boolean getNeedsLE() {
|
||||
return this.needsLE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package com.sparrowwallet.sparrow.io.satochip;
|
||||
|
||||
import com.sparrowwallet.drongo.Utils;
|
||||
|
||||
/**
|
||||
* ISO7816-4 APDU response.
|
||||
*/
|
||||
public class APDUResponse {
|
||||
public static final int SW_OK = 0x9000;
|
||||
public static final int SW_SECURITY_CONDITION_NOT_SATISFIED = 0x6982;
|
||||
public static final int SW_AUTHENTICATION_METHOD_BLOCKED = 0x6983;
|
||||
public static final int SW_CARD_LOCKED = 0x6283;
|
||||
public static final int SW_REFERENCED_DATA_NOT_FOUND = 0x6A88;
|
||||
public static final int SW_CONDITIONS_OF_USE_NOT_SATISFIED = 0x6985; // applet may be already installed
|
||||
public static final int SW_WRONG_PIN_MASK = 0x63C0;
|
||||
public static final String HEXES = "0123456789ABCDEF";
|
||||
|
||||
private final byte[] apdu;
|
||||
private byte[] data;
|
||||
private int sw;
|
||||
private int sw1;
|
||||
private int sw2;
|
||||
|
||||
/**
|
||||
* Creates an APDU object by parsing the raw response from the card.
|
||||
*
|
||||
* @param apdu the raw response from the card.
|
||||
*/
|
||||
public APDUResponse(byte[] apdu) {
|
||||
if(apdu.length < 2) {
|
||||
throw new IllegalArgumentException("APDU response must be at least 2 bytes");
|
||||
}
|
||||
this.apdu = apdu;
|
||||
this.parse();
|
||||
}
|
||||
|
||||
public APDUResponse(byte[] data, byte sw1, byte sw2) {
|
||||
byte[] apdu = new byte[data.length + 2];
|
||||
System.arraycopy(data, 0, apdu, 0, data.length);
|
||||
apdu[data.length] = sw1;
|
||||
apdu[data.length + 1] = sw2;
|
||||
this.apdu = apdu;
|
||||
this.parse();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Parses the APDU response, separating the response data from SW.
|
||||
*/
|
||||
private void parse() {
|
||||
int length = this.apdu.length;
|
||||
|
||||
this.sw1 = this.apdu[length - 2] & 0xff;
|
||||
this.sw2 = this.apdu[length - 1] & 0xff;
|
||||
this.sw = (this.sw1 << 8) | this.sw2;
|
||||
|
||||
this.data = new byte[length - 2];
|
||||
System.arraycopy(this.apdu, 0, this.data, 0, length - 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the data field of this APDU.
|
||||
*
|
||||
* @return the data field of this APDU
|
||||
*/
|
||||
public byte[] getData() {
|
||||
return this.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Status Word.
|
||||
*
|
||||
* @return the status word
|
||||
*/
|
||||
public int getSw() {
|
||||
return this.sw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the SW1 byte
|
||||
*
|
||||
* @return SW1
|
||||
*/
|
||||
public int getSw1() {
|
||||
return this.sw1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the SW2 byte
|
||||
*
|
||||
* @return SW2
|
||||
*/
|
||||
public int getSw2() {
|
||||
return this.sw2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the raw unparsed response.
|
||||
*
|
||||
* @return raw APDU data
|
||||
*/
|
||||
public byte[] getBytes() {
|
||||
return this.apdu;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes the APDU to human readable hex string format
|
||||
*
|
||||
* @return the hex string representation of the APDU
|
||||
*/
|
||||
public String toHexString() {
|
||||
byte[] raw = this.apdu;
|
||||
if(raw == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return Utils.bytesToHex(raw);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package com.sparrowwallet.sparrow.io.satochip;
|
||||
|
||||
public final class Constants {
|
||||
|
||||
// Prevents instantiation of class
|
||||
private Constants() {
|
||||
}
|
||||
|
||||
/****************************************
|
||||
* Instruction codes *
|
||||
****************************************/
|
||||
public static final byte CLA = (byte) 0xB0;
|
||||
// Applet initialization
|
||||
public static final byte INS_SETUP = (byte) 0x2A;
|
||||
// Keys' use and management
|
||||
public static final byte INS_IMPORT_KEY = (byte) 0x32;
|
||||
public static final byte INS_RESET_KEY = (byte) 0x33;
|
||||
public static final byte INS_GET_PUBLIC_FROM_PRIVATE = (byte) 0x35;
|
||||
// External authentication
|
||||
public static final byte INS_CREATE_PIN = (byte) 0x40; //TODO: remove?
|
||||
public static final byte INS_VERIFY_PIN = (byte) 0x42;
|
||||
public static final byte INS_CHANGE_PIN = (byte) 0x44;
|
||||
public static final byte INS_UNBLOCK_PIN = (byte) 0x46;
|
||||
public static final byte INS_LOGOUT_ALL = (byte) 0x60;
|
||||
// Status information
|
||||
public static final byte INS_LIST_PINS = (byte) 0x48;
|
||||
public static final byte INS_GET_STATUS = (byte) 0x3C;
|
||||
public static final byte INS_CARD_LABEL = (byte) 0x3D;
|
||||
// HD wallet
|
||||
public static final byte INS_BIP32_IMPORT_SEED = (byte) 0x6C;
|
||||
public static final byte INS_BIP32_RESET_SEED = (byte) 0x77;
|
||||
public static final byte INS_BIP32_GET_AUTHENTIKEY = (byte) 0x73;
|
||||
public static final byte INS_BIP32_SET_AUTHENTIKEY_PUBKEY = (byte) 0x75;
|
||||
public static final byte INS_BIP32_GET_EXTENDED_KEY = (byte) 0x6D;
|
||||
public static final byte INS_BIP32_SET_EXTENDED_PUBKEY = (byte) 0x74;
|
||||
public static final byte INS_SIGN_MESSAGE = (byte) 0x6E;
|
||||
public static final byte INS_SIGN_SHORT_MESSAGE = (byte) 0x72;
|
||||
public static final byte INS_SIGN_TRANSACTION = (byte) 0x6F;
|
||||
public static final byte INS_PARSE_TRANSACTION = (byte) 0x71;
|
||||
public static final byte INS_CRYPT_TRANSACTION_2FA = (byte) 0x76;
|
||||
public static final byte INS_SET_2FA_KEY = (byte) 0x79;
|
||||
public static final byte INS_RESET_2FA_KEY = (byte) 0x78;
|
||||
public static final byte INS_SIGN_TRANSACTION_HASH = (byte) 0x7A;
|
||||
// secure channel
|
||||
public static final byte INS_INIT_SECURE_CHANNEL = (byte) 0x81;
|
||||
public static final byte INS_PROCESS_SECURE_CHANNEL = (byte) 0x82;
|
||||
// secure import from SeedKeeper
|
||||
public static final byte INS_IMPORT_ENCRYPTED_SECRET = (byte) 0xAC;
|
||||
public static final byte INS_IMPORT_TRUSTED_PUBKEY = (byte) 0xAA;
|
||||
public static final byte INS_EXPORT_TRUSTED_PUBKEY = (byte) 0xAB;
|
||||
public static final byte INS_EXPORT_AUTHENTIKEY = (byte) 0xAD;
|
||||
// Personalization PKI support
|
||||
public static final byte INS_IMPORT_PKI_CERTIFICATE = (byte) 0x92;
|
||||
public static final byte INS_EXPORT_PKI_CERTIFICATE = (byte) 0x93;
|
||||
public static final byte INS_SIGN_PKI_CSR = (byte) 0x94;
|
||||
public static final byte INS_EXPORT_PKI_PUBKEY = (byte) 0x98;
|
||||
public static final byte INS_LOCK_PKI = (byte) 0x99;
|
||||
public static final byte INS_CHALLENGE_RESPONSE_PKI = (byte) 0x9A;
|
||||
// reset to factory settings
|
||||
public static final byte INS_RESET_TO_FACTORY = (byte) 0xFF;
|
||||
|
||||
/****************************************
|
||||
* Error codes *
|
||||
****************************************/
|
||||
|
||||
/**
|
||||
* Entered PIN is not correct
|
||||
*/
|
||||
public static final short SW_PIN_FAILED = (short) 0x63C0;// includes number of tries remaining
|
||||
///** DEPRECATED - Entered PIN is not correct */
|
||||
//public static final short SW_AUTH_FAILED = (short) 0x9C02;
|
||||
/**
|
||||
* Required operation is not allowed in actual circumstances
|
||||
*/
|
||||
public static final short SW_OPERATION_NOT_ALLOWED = (short) 0x9C03;
|
||||
/**
|
||||
* Required setup is not not done
|
||||
*/
|
||||
public static final short SW_SETUP_NOT_DONE = (short) 0x9C04;
|
||||
/**
|
||||
* Required setup is already done
|
||||
*/
|
||||
public static final short SW_SETUP_ALREADY_DONE = (short) 0x9C07;
|
||||
/**
|
||||
* Required feature is not (yet) supported
|
||||
*/
|
||||
public static final short SW_UNSUPPORTED_FEATURE = (short) 0x9C05;
|
||||
/**
|
||||
* Required operation was not authorized because of a lack of privileges
|
||||
*/
|
||||
public static final short SW_UNAUTHORIZED = (short) 0x9C06;
|
||||
/**
|
||||
* Algorithm specified is not correct
|
||||
*/
|
||||
public static final short SW_INCORRECT_ALG = (short) 0x9C09;
|
||||
|
||||
/**
|
||||
* There have been memory problems on the card
|
||||
*/
|
||||
public static final short SW_NO_MEMORY_LEFT = (short) 0x9C01;
|
||||
///** DEPRECATED - Required object is missing */
|
||||
//public static final short SW_OBJECT_NOT_FOUND= (short) 0x9C07;
|
||||
|
||||
/**
|
||||
* Incorrect P1 parameter
|
||||
*/
|
||||
public static final short SW_INCORRECT_P1 = (short) 0x9C10;
|
||||
/**
|
||||
* Incorrect P2 parameter
|
||||
*/
|
||||
public static final short SW_INCORRECT_P2 = (short) 0x9C11;
|
||||
/**
|
||||
* Invalid input parameter to command
|
||||
*/
|
||||
public static final short SW_INVALID_PARAMETER = (short) 0x9C0F;
|
||||
|
||||
/**
|
||||
* Eckeys initialized
|
||||
*/
|
||||
public static final short SW_ECKEYS_INITIALIZED_KEY = (short) 0x9C1A;
|
||||
|
||||
/**
|
||||
* Verify operation detected an invalid signature
|
||||
*/
|
||||
public static final short SW_SIGNATURE_INVALID = (short) 0x9C0B;
|
||||
/**
|
||||
* Operation has been blocked for security reason
|
||||
*/
|
||||
public static final short SW_IDENTITY_BLOCKED = (short) 0x9C0C;
|
||||
/**
|
||||
* For debugging purposes
|
||||
*/
|
||||
public static final short SW_INTERNAL_ERROR = (short) 0x9CFF;
|
||||
/**
|
||||
* Very low probability error
|
||||
*/
|
||||
public static final short SW_BIP32_DERIVATION_ERROR = (short) 0x9C0E;
|
||||
/**
|
||||
* Incorrect initialization of method
|
||||
*/
|
||||
public static final short SW_INCORRECT_INITIALIZATION = (short) 0x9C13;
|
||||
/**
|
||||
* Bip32 seed is not initialized
|
||||
*/
|
||||
public static final short SW_BIP32_UNINITIALIZED_SEED = (short) 0x9C14;
|
||||
/**
|
||||
* Bip32 seed is already initialized (must be reset before change)
|
||||
*/
|
||||
public static final short SW_BIP32_INITIALIZED_SEED = (short) 0x9C17;
|
||||
//** DEPRECATED - Bip32 authentikey pubkey is not initialized*/
|
||||
//public static final short SW_BIP32_UNINITIALIZED_AUTHENTIKEY_PUBKEY= (short) 0x9C16;
|
||||
/**
|
||||
* Incorrect transaction hash
|
||||
*/
|
||||
public static final short SW_INCORRECT_TXHASH = (short) 0x9C15;
|
||||
|
||||
/**
|
||||
* 2FA already initialized
|
||||
*/
|
||||
public static final short SW_2FA_INITIALIZED_KEY = (short) 0x9C18;
|
||||
/**
|
||||
* 2FA uninitialized
|
||||
*/
|
||||
public static final short SW_2FA_UNINITIALIZED_KEY = (short) 0x9C19;
|
||||
|
||||
/**
|
||||
* HMAC errors
|
||||
*/
|
||||
public static final short SW_HMAC_UNSUPPORTED_KEYSIZE = (short) 0x9c1E;
|
||||
public static final short SW_HMAC_UNSUPPORTED_MSGSIZE = (short) 0x9c1F;
|
||||
|
||||
/**
|
||||
* Secure channel
|
||||
*/
|
||||
public static final short SW_SECURE_CHANNEL_REQUIRED = (short) 0x9C20;
|
||||
public static final short SW_SECURE_CHANNEL_UNINITIALIZED = (short) 0x9C21;
|
||||
public static final short SW_SECURE_CHANNEL_WRONG_IV = (short) 0x9C22;
|
||||
public static final short SW_SECURE_CHANNEL_WRONG_MAC = (short) 0x9C23;
|
||||
|
||||
/**
|
||||
* Secret data is too long for import
|
||||
**/
|
||||
public static final short SW_IMPORTED_DATA_TOO_LONG = (short) 0x9C32;
|
||||
/**
|
||||
* Wrong HMAC when importing Secret through Secure import
|
||||
**/
|
||||
public static final short SW_SECURE_IMPORT_WRONG_MAC = (short) 0x9C33;
|
||||
/**
|
||||
* Wrong Fingerprint when importing Secret through Secure import
|
||||
**/
|
||||
public static final short SW_SECURE_IMPORT_WRONG_FINGERPRINT = (short) 0x9C34;
|
||||
/**
|
||||
* No Trusted Pubkey when importing Secret through Secure import
|
||||
**/
|
||||
public static final short SW_SECURE_IMPORT_NO_TRUSTEDPUBKEY = (short) 0x9C35;
|
||||
|
||||
/**
|
||||
* PKI perso error
|
||||
*/
|
||||
public static final short SW_PKI_ALREADY_LOCKED = (short) 0x9C40;
|
||||
/**
|
||||
* CARD HAS BEEN RESET TO FACTORY
|
||||
*/
|
||||
public static final short SW_RESET_TO_FACTORY = (short) 0xFF00;
|
||||
/**
|
||||
* For instructions that have been deprecated
|
||||
*/
|
||||
public static final short SW_INS_DEPRECATED = (short) 0x9C26;
|
||||
/**
|
||||
* For debugging purposes 2
|
||||
*/
|
||||
public static final short SW_DEBUG_FLAG = (short) 0x9FFF;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.sparrowwallet.sparrow.io.satochip;
|
||||
|
||||
import java.util.StringTokenizer;
|
||||
|
||||
/**
|
||||
* Keypath object to be used with the SatochipCommandSet
|
||||
*/
|
||||
public class KeyPath {
|
||||
private final byte[] data;
|
||||
|
||||
/**
|
||||
* Parses a keypath into a byte array to be used with the SatochipCommandSet object.
|
||||
* <p>
|
||||
* A valid string is composed of a minimum of one and a maximum of 11 components separated by "/".
|
||||
* <p>
|
||||
* The first component should be "m", indicating the master key.
|
||||
* <p>
|
||||
* All other components are positive integers fitting in 31 bit, eventually suffixed by an apostrophe (') sign,
|
||||
* which indicates an hardened key.
|
||||
* <p>
|
||||
* An example of a valid path is "m/44'/0'/0'/0/0"
|
||||
*
|
||||
* @param keypath the keypath as a string
|
||||
*/
|
||||
public KeyPath(String keypath) {
|
||||
StringTokenizer tokenizer = new StringTokenizer(keypath, "/");
|
||||
|
||||
String sourceOrFirstElement = tokenizer.nextToken(); // m
|
||||
|
||||
int componentCount = tokenizer.countTokens();
|
||||
if(componentCount > 10) {
|
||||
throw new IllegalArgumentException("Too many components");
|
||||
}
|
||||
|
||||
data = new byte[4 * componentCount];
|
||||
|
||||
for(int i = 0; i < componentCount; i++) {
|
||||
long component = parseComponent(tokenizer.nextToken());
|
||||
writeComponent(component, i);
|
||||
}
|
||||
}
|
||||
|
||||
public KeyPath(byte[] data) {
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
private long parseComponent(String num) {
|
||||
long sign;
|
||||
|
||||
if(num.endsWith("'")) {
|
||||
sign = 0x80000000L;
|
||||
num = num.substring(0, (num.length() - 1));
|
||||
} else {
|
||||
sign = 0L;
|
||||
}
|
||||
|
||||
if(num.startsWith("+") || num.startsWith("-")) {
|
||||
throw new NumberFormatException("No sign allowed");
|
||||
}
|
||||
return (sign | Long.parseLong(num));
|
||||
}
|
||||
|
||||
private void writeComponent(long component, int i) {
|
||||
int off = (i * 4);
|
||||
data[off] = (byte) ((component >> 24) & 0xff);
|
||||
data[off + 1] = (byte) ((component >> 16) & 0xff);
|
||||
data[off + 2] = (byte) ((component >> 8) & 0xff);
|
||||
data[off + 3] = (byte) (component & 0xff);
|
||||
}
|
||||
|
||||
/**
|
||||
* The byte encoded key path.
|
||||
*
|
||||
* @return byte encoded key path
|
||||
*/
|
||||
public byte[] getData() {
|
||||
return data;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
StringBuffer sb = new StringBuffer();
|
||||
sb.append('m');
|
||||
|
||||
for(int i = 0; i < this.data.length; i += 4) {
|
||||
sb.append('/');
|
||||
appendComponent(sb, i);
|
||||
}
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private void appendComponent(StringBuffer sb, int i) {
|
||||
int num = ((this.data[i] & 0x7f) << 24) | ((this.data[i + 1] & 0xff) << 16) | ((this.data[i + 2] & 0xff) << 8) | (this.data[i + 3] & 0xff);
|
||||
sb.append(num);
|
||||
|
||||
if((this.data[i] & 0x80) == 0x80) {
|
||||
sb.append('\'');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
package com.sparrowwallet.sparrow.io.satochip;
|
||||
|
||||
import com.sparrowwallet.drongo.ExtendedKey;
|
||||
import com.sparrowwallet.drongo.KeyDerivation;
|
||||
import com.sparrowwallet.drongo.Utils;
|
||||
import com.sparrowwallet.drongo.Network;
|
||||
import com.sparrowwallet.drongo.address.Address;
|
||||
import com.sparrowwallet.drongo.crypto.ChildNumber;
|
||||
import com.sparrowwallet.drongo.crypto.ECKey;
|
||||
import com.sparrowwallet.drongo.crypto.ECDSASignature;
|
||||
import com.sparrowwallet.drongo.crypto.SchnorrSignature;
|
||||
import com.sparrowwallet.drongo.protocol.*;
|
||||
import com.sparrowwallet.drongo.psbt.PSBT;
|
||||
import com.sparrowwallet.drongo.psbt.PSBTInput;
|
||||
import com.sparrowwallet.drongo.psbt.PSBTInputSigner;
|
||||
import com.sparrowwallet.drongo.wallet.*;
|
||||
import com.sparrowwallet.sparrow.control.CardImportPane;
|
||||
import com.sparrowwallet.sparrow.io.CardApi;
|
||||
import javafx.beans.property.StringProperty;
|
||||
import javafx.concurrent.Service;
|
||||
import javafx.concurrent.Task;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import javax.smartcardio.*;
|
||||
import java.util.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
public class SatoCardApi extends CardApi {
|
||||
private static final Logger log = LoggerFactory.getLogger(SatoCardApi.class);
|
||||
|
||||
private final WalletModel cardType;
|
||||
private final SatochipCommandSet cardProtocol;
|
||||
private final String pin;
|
||||
private String basePath = null;
|
||||
|
||||
public SatoCardApi(WalletModel cardType, String pin) throws CardException {
|
||||
this.cardType = cardType;
|
||||
this.cardProtocol = new SatochipCommandSet();
|
||||
this.pin = pin;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isInitialized() throws CardException {
|
||||
SatoCardStatus cardStatus = this.getStatus();
|
||||
return cardStatus.isInitialized(); // setupDone && isSeeded
|
||||
}
|
||||
|
||||
//TODO
|
||||
@Override
|
||||
public void initialize(int slot, byte[] seedBytes) throws CardException {
|
||||
// TODO check device certificate
|
||||
SatoCardStatus cardStatus = this.getStatus();
|
||||
|
||||
APDUResponse rapdu;
|
||||
if(!cardStatus.isSetupDone()) {
|
||||
byte maxPinTries = 5;
|
||||
rapdu = this.cardProtocol.cardSetup(maxPinTries, pin.getBytes(StandardCharsets.UTF_8));
|
||||
// check ok
|
||||
}
|
||||
|
||||
if(!cardStatus.isSeeded()) {
|
||||
// check pin
|
||||
rapdu = this.cardProtocol.cardVerifyPIN(0, pin);
|
||||
// todo: check PIN response
|
||||
|
||||
rapdu = this.cardProtocol.cardBip32ImportSeed(seedBytes);
|
||||
// check ok
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public WalletModel getCardType() throws CardException {
|
||||
return WalletModel.SATOCHIP;
|
||||
}
|
||||
|
||||
//TODO
|
||||
@Override
|
||||
public int getCurrentSlot() throws CardException {
|
||||
throw new CardException("Satochip does not support slots");
|
||||
}
|
||||
|
||||
//TODO
|
||||
@Override
|
||||
public ScriptType getDefaultScriptType() {
|
||||
return ScriptType.P2WPKH;
|
||||
}
|
||||
|
||||
SatoCardStatus getStatus() throws CardException {
|
||||
return this.cardProtocol.getApplicationStatus();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Service<Void> getAuthDelayService() throws CardException {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean requiresBackup() throws CardException {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Service<String> getBackupService() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean changePin(String newPin) throws CardException {
|
||||
this.cardProtocol.cardChangePIN((byte) 0, this.pin, newPin);
|
||||
return true;
|
||||
}
|
||||
|
||||
void setDerivation(List<ChildNumber> derivation) throws CardException {
|
||||
this.basePath = KeyDerivation.writePath(derivation);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Service<Void> getInitializationService(byte[] seedBytes, StringProperty messageProperty) {
|
||||
return new CardInitializationService(seedBytes, messageProperty);
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public Service<Keystore> getImportService(List<ChildNumber> derivation, StringProperty messageProperty) {
|
||||
return new CardImportPane.CardImportService(new Satochip(), pin, derivation, messageProperty);
|
||||
}
|
||||
|
||||
/*
|
||||
* Satochip derives BIP32 keys based on the fullPath (from masterseed to leaf), not the partial path from a given xpub.
|
||||
* the basePath (from masterseed to xpub) is only provided in Satochip.java:getKeystore(String pin, List<ChildNumber> derivation, StringProperty messageProperty)
|
||||
* In SatoCardApi:getKeystore(), no derivation path (i.e. basePath from masterSeed to xpub or relative path) is given and no derivation is reliably available as a object field.
|
||||
* currently, we try to get the path from this.basePath if available (or use a default value) but it's not reliable enough
|
||||
*/
|
||||
@Override
|
||||
public Keystore getKeystore() throws CardException {
|
||||
this.cardProtocol.cardVerifyPIN(0, pin);
|
||||
String keyDerivationString = (this.basePath != null ? this.basePath : getDefaultScriptType().getDefaultDerivationPath());
|
||||
ExtendedKey.Header xtype = Network.get().getXpubHeader();
|
||||
String xpub = this.cardProtocol.cardBip32GetXpub(keyDerivationString, xtype);
|
||||
ExtendedKey extendedKey = ExtendedKey.fromDescriptor(xpub);
|
||||
String masterXpub = this.cardProtocol.cardBip32GetXpub("m", xtype);
|
||||
ExtendedKey masterExtendedKey = ExtendedKey.fromDescriptor(masterXpub);
|
||||
String masterFingerprint = Utils.bytesToHex(masterExtendedKey.getKey().getFingerprint());
|
||||
KeyDerivation keyDerivation = new KeyDerivation(masterFingerprint, keyDerivationString);
|
||||
|
||||
Keystore keystore = new Keystore();
|
||||
keystore.setLabel(WalletModel.SATOCHIP.toDisplayString());
|
||||
keystore.setKeyDerivation(keyDerivation);
|
||||
keystore.setSource(KeystoreSource.HW_USB);
|
||||
keystore.setExtendedPublicKey(extendedKey);
|
||||
keystore.setWalletModel(WalletModel.SATOCHIP);
|
||||
|
||||
return keystore;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Service<PSBT> getSignService(Wallet wallet, PSBT psbt, StringProperty messageProperty) {
|
||||
return new SignService(wallet, psbt, messageProperty);
|
||||
}
|
||||
|
||||
void sign(Wallet wallet, PSBT psbt) throws CardException {
|
||||
Map<PSBTInput, WalletNode> signingNodes = wallet.getSigningNodes(psbt);
|
||||
for(PSBTInput psbtInput : psbt.getPsbtInputs()) {
|
||||
if(!psbtInput.isSigned()) {
|
||||
WalletNode signingNode = signingNodes.get(psbtInput);
|
||||
String fullPath = null;
|
||||
List<Keystore> keystores = wallet.getKeystores();
|
||||
for(int i = 0; i < keystores.size(); i++) {
|
||||
Keystore keystore = keystores.get(i);
|
||||
WalletModel walletModel = keystore.getWalletModel();
|
||||
if(walletModel == WalletModel.SATOCHIP) {
|
||||
String basePath = keystore.getKeyDerivation().getDerivationPath();
|
||||
String extendedPath = signingNode.getDerivationPath().substring(1);
|
||||
fullPath = basePath + extendedPath;
|
||||
keystore.getPubKey(signingNode);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
psbtInput.sign(new CardPSBTInputSigner(signingNode, fullPath));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Service<String> getSignMessageService(String message, ScriptType scriptType, List<ChildNumber> derivation, StringProperty messageProperty) {
|
||||
return new SignMessageService(message, scriptType, derivation, messageProperty);
|
||||
}
|
||||
|
||||
String signMessage(String message, ScriptType scriptType, List<ChildNumber> derivation) throws CardException {
|
||||
String fullpath = KeyDerivation.writePath(derivation);
|
||||
cardProtocol.cardVerifyPIN(0, pin);
|
||||
|
||||
// 2FA is optionnal, currently not supported in sparrow as it requires to send 2FA to a mobile app through a server.
|
||||
SatoCardStatus cardStatus = this.getStatus();
|
||||
if(cardStatus.needs2FA()) {
|
||||
throw new CardException("Satochip 2FA is not (yet) supported within Sparrow");
|
||||
}
|
||||
|
||||
// derive the correct key in satochip
|
||||
APDUResponse rapdu = cardProtocol.cardBip32GetExtendedKey(fullpath);
|
||||
// recover pubkey
|
||||
SatochipParser parser = new SatochipParser();
|
||||
byte[][] extendeKeyBytes = parser.parseBip32GetExtendedKey(rapdu);
|
||||
ECKey pubkey = ECKey.fromPublicOnly(extendeKeyBytes[0]);
|
||||
|
||||
// sign msg
|
||||
return pubkey.signMessage(message, scriptType, hash -> {
|
||||
try {
|
||||
// do the signature with satochip
|
||||
byte keynbr = (byte) 0xFF;
|
||||
byte[] chalresponse = null;
|
||||
APDUResponse rapdu2 = cardProtocol.cardSignTransactionHash(keynbr, hash.getBytes(), chalresponse);
|
||||
byte[] sigBytes = rapdu2.getData();
|
||||
return ECDSASignature.decodeFromDER(sigBytes);
|
||||
} catch(Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Service<ECKey> getPrivateKeyService(Integer slot, StringProperty messageProperty) {
|
||||
throw new UnsupportedOperationException("Satochip does not support private key export");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Service<Address> getAddressService(StringProperty messageProperty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void disconnect() {
|
||||
cardProtocol.cardDisconnect();
|
||||
}
|
||||
|
||||
public class CardInitializationService extends Service<Void> {
|
||||
private final byte[] seedBytes;
|
||||
private final StringProperty messageProperty;
|
||||
|
||||
public CardInitializationService(byte[] seedBytes, StringProperty messageProperty) {
|
||||
this.seedBytes = seedBytes;
|
||||
this.messageProperty = messageProperty;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Task<Void> createTask() {
|
||||
return new Task<>() {
|
||||
@Override
|
||||
protected Void call() throws Exception {
|
||||
if(seedBytes == null) {
|
||||
throw new CardException("Failed to initialize Satochip - no seed provided");
|
||||
}
|
||||
|
||||
initialize(0, seedBytes);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public class SignService extends Service<PSBT> {
|
||||
private final Wallet wallet;
|
||||
private final PSBT psbt;
|
||||
private final StringProperty messageProperty;
|
||||
|
||||
public SignService(Wallet wallet, PSBT psbt, StringProperty messageProperty) {
|
||||
this.wallet = wallet;
|
||||
this.psbt = psbt;
|
||||
this.messageProperty = messageProperty;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Task<PSBT> createTask() {
|
||||
return new Task<>() {
|
||||
@Override
|
||||
protected PSBT call() throws Exception {
|
||||
sign(wallet, psbt);
|
||||
return psbt;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private class CardPSBTInputSigner implements PSBTInputSigner {
|
||||
private final WalletNode signingNode;
|
||||
private final String fullPath;
|
||||
private ECKey pubkey;
|
||||
|
||||
// todo: provide derivationpath instead of WalletNode??
|
||||
public CardPSBTInputSigner(WalletNode signingNode, String fullPath) {
|
||||
this.signingNode = signingNode;
|
||||
this.fullPath = fullPath;
|
||||
}
|
||||
|
||||
@Override
|
||||
public TransactionSignature sign(Sha256Hash hash, SigHash sigHash, TransactionSignature.Type signatureType) {
|
||||
try {
|
||||
// 2FA is optional, currently not supported in sparrow as it requires to send 2FA to a mobile app through a server.
|
||||
SatoCardStatus cardStatus = getStatus();
|
||||
if(cardStatus.needs2FA()) {
|
||||
throw new CardException("Satochip 2FA is not (yet) supported within Sparrow");
|
||||
}
|
||||
|
||||
// verify PIN
|
||||
APDUResponse rapdu0 = cardProtocol.cardVerifyPIN(0, pin);
|
||||
|
||||
// derive the correct key in satochip and recover pubkey
|
||||
APDUResponse rapdu = cardProtocol.cardBip32GetExtendedKey(fullPath);
|
||||
SatochipParser parser = new SatochipParser();
|
||||
byte[][] extendeKeyBytes = parser.parseBip32GetExtendedKey(rapdu);
|
||||
ECKey internalPubkey = ECKey.fromPublicOnly(extendeKeyBytes[0]);
|
||||
|
||||
if(signatureType == TransactionSignature.Type.ECDSA) {
|
||||
// for ECDSA, pubkey is the same as internalPubkey
|
||||
pubkey = internalPubkey;
|
||||
// do the signature with satochip
|
||||
byte keynbr = (byte) 0xFF;
|
||||
byte[] chalresponse = null;
|
||||
APDUResponse rapdu2 = cardProtocol.cardSignTransactionHash(keynbr, hash.getBytes(), chalresponse);
|
||||
byte[] sigBytes = rapdu2.getData();
|
||||
ECDSASignature ecdsaSig = ECDSASignature.decodeFromDER(sigBytes).toCanonicalised();
|
||||
TransactionSignature txSig = new TransactionSignature(ecdsaSig, sigHash);
|
||||
|
||||
// verify
|
||||
boolean isCorrect = pubkey.verify(hash, txSig);
|
||||
return txSig;
|
||||
} else {
|
||||
// Satochip supports schnorr signature only for version >= 0.14 !
|
||||
byte[] versionBytes = cardStatus.getCardVersion();
|
||||
int protocolVersion = versionBytes[0] * 256 + versionBytes[1];
|
||||
if(protocolVersion < (256 * 0 + 14)) {
|
||||
throw new CardException(WalletModel.SATOCHIP.toDisplayString() + " (with version below v0.14) cannot sign Taproot transactions");
|
||||
}
|
||||
|
||||
// tweak the bip32 key according to bip341
|
||||
byte keynbr = (byte) 0xFF;
|
||||
byte[] tweak = null;
|
||||
APDUResponse rapduTweak = cardProtocol.cardTaprootTweakPrivkey(keynbr, tweak);
|
||||
byte[] tweakedPubkeyBytes = new byte[65];
|
||||
System.arraycopy(rapduTweak.getData(), 2, tweakedPubkeyBytes, 0, 65);
|
||||
pubkey = ECKey.fromPublicOnly(tweakedPubkeyBytes);
|
||||
|
||||
byte[] chalresponse = null;
|
||||
APDUResponse rapdu2 = cardProtocol.cardSignSchnorrHash(keynbr, hash.getBytes(), chalresponse);
|
||||
byte[] sigBytes = rapdu2.getData();
|
||||
SchnorrSignature schnorrSig = SchnorrSignature.decode(sigBytes);
|
||||
TransactionSignature txSig = new TransactionSignature(schnorrSig, sigHash);
|
||||
|
||||
// verify sig with outputPubkey...
|
||||
boolean isCorrect2 = pubkey.verify(hash, txSig);
|
||||
|
||||
return txSig; //new TransactionSignature(schnorrSig, sigHash);
|
||||
}
|
||||
} catch(Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public ECKey getPubKey() {
|
||||
return pubkey;
|
||||
}
|
||||
}
|
||||
|
||||
public class SignMessageService extends Service<String> {
|
||||
private final String message;
|
||||
private final ScriptType scriptType;
|
||||
private final List<ChildNumber> derivation;
|
||||
private final StringProperty messageProperty;
|
||||
|
||||
public SignMessageService(String message, ScriptType scriptType, List<ChildNumber> derivation, StringProperty messageProperty) {
|
||||
this.message = message;
|
||||
this.scriptType = scriptType;
|
||||
this.derivation = derivation;
|
||||
this.messageProperty = messageProperty;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Task<String> createTask() {
|
||||
return new Task<>() {
|
||||
@Override
|
||||
protected String call() throws Exception {
|
||||
return signMessage(message, scriptType, derivation);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package com.sparrowwallet.sparrow.io.satochip;
|
||||
|
||||
/**
|
||||
* Parses the result of a GET STATUS command retrieving application status.
|
||||
*/
|
||||
public class SatoCardStatus {
|
||||
private boolean setup_done = false;
|
||||
private boolean is_seeded = false;
|
||||
private boolean needs_secure_channel = false;
|
||||
private boolean needs_2FA = false;
|
||||
|
||||
private byte protocol_major_version = (byte) 0;
|
||||
private byte protocol_minor_version = (byte) 0;
|
||||
private byte applet_major_version = (byte) 0;
|
||||
private byte applet_minor_version = (byte) 0;
|
||||
|
||||
private byte PIN0_remaining_tries = (byte) 0;
|
||||
private byte PUK0_remaining_tries = (byte) 0;
|
||||
private byte PIN1_remaining_tries = (byte) 0;
|
||||
private byte PUK1_remaining_tries = (byte) 0;
|
||||
|
||||
private int protocol_version = 0; //(d["protocol_major_version"]<<8)+d["protocol_minor_version"]
|
||||
|
||||
/**
|
||||
* Constructor from TLV data
|
||||
*
|
||||
* @param rapdu the TLV data
|
||||
* @throws IllegalArgumentException if the TLV does not follow the expected format
|
||||
*/
|
||||
public SatoCardStatus(APDUResponse rapdu) {
|
||||
int sw = rapdu.getSw();
|
||||
|
||||
if(sw == 0x9000) {
|
||||
byte[] data = rapdu.getData();
|
||||
protocol_major_version = data[0];
|
||||
protocol_minor_version = data[1];
|
||||
applet_major_version = data[2];
|
||||
applet_minor_version = data[3];
|
||||
protocol_version = (protocol_major_version << 8) + protocol_minor_version;
|
||||
|
||||
if(data.length >= 8) {
|
||||
PIN0_remaining_tries = data[4];
|
||||
PUK0_remaining_tries = data[5];
|
||||
PIN1_remaining_tries = data[6];
|
||||
PUK1_remaining_tries = data[7];
|
||||
needs_2FA = false; //default value
|
||||
}
|
||||
if(data.length >= 9) {
|
||||
needs_2FA = data[8] != 0X00;
|
||||
}
|
||||
if(data.length >= 10) {
|
||||
is_seeded = data[9] != 0X00;
|
||||
}
|
||||
if(data.length >= 11) {
|
||||
setup_done = data[10] != 0X00;
|
||||
} else {
|
||||
setup_done = true;
|
||||
}
|
||||
if(data.length >= 12) {
|
||||
needs_secure_channel = data[11] != 0X00;
|
||||
} else {
|
||||
needs_secure_channel = false;
|
||||
needs_2FA = false; //default value
|
||||
}
|
||||
} else if(sw == 0x9c04) {
|
||||
setup_done = false;
|
||||
is_seeded = false;
|
||||
needs_secure_channel = false;
|
||||
} else {
|
||||
throw new IllegalArgumentException("Invalid getStatus data");
|
||||
}
|
||||
}
|
||||
|
||||
// getters
|
||||
public boolean isSeeded() {
|
||||
return is_seeded;
|
||||
}
|
||||
|
||||
public boolean isSetupDone() {
|
||||
return setup_done;
|
||||
}
|
||||
|
||||
public boolean isInitialized() {
|
||||
return (setup_done && is_seeded);
|
||||
}
|
||||
|
||||
public boolean needsSecureChannel() {
|
||||
return needs_secure_channel;
|
||||
}
|
||||
|
||||
public boolean needs2FA() {
|
||||
return needs_2FA;
|
||||
}
|
||||
|
||||
public byte getPin0RemainingCounter() {
|
||||
return PIN0_remaining_tries;
|
||||
}
|
||||
|
||||
public byte[] getCardVersion() {
|
||||
byte[] versionBytes = new byte[4];
|
||||
versionBytes[0] = protocol_major_version;
|
||||
versionBytes[1] = protocol_minor_version;
|
||||
versionBytes[2] = applet_major_version;
|
||||
versionBytes[3] = applet_minor_version;
|
||||
return versionBytes;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "setup_done: " + setup_done + "\n" +
|
||||
"is_seeded: " + is_seeded + "\n" +
|
||||
"needs_2FA: " + needs_2FA + "\n" +
|
||||
"needs_secure_channel: " + needs_secure_channel + "\n" +
|
||||
"protocol_major_version: " + protocol_major_version + "\n" +
|
||||
"protocol_minor_version: " + protocol_minor_version + "\n" +
|
||||
"applet_major_version: " + applet_major_version + "\n" +
|
||||
"applet_minor_version: " + applet_minor_version;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.sparrowwallet.sparrow.io.satochip;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import javax.smartcardio.*;
|
||||
import javax.smartcardio.CardChannel;
|
||||
import java.util.List;
|
||||
import java.util.*;
|
||||
|
||||
public class SatoCardTransport {
|
||||
private static final Logger log = LoggerFactory.getLogger(SatoCardTransport.class);
|
||||
|
||||
private final Card connection;
|
||||
|
||||
SatoCardTransport(byte[] appletAid) throws CardException {
|
||||
TerminalFactory tf = TerminalFactory.getDefault();
|
||||
List<CardTerminal> terminals = tf.terminals().list();
|
||||
if(terminals.isEmpty()) {
|
||||
throw new IllegalStateException("No reader connected");
|
||||
}
|
||||
|
||||
Card connection = null;
|
||||
for(Iterator<CardTerminal> iter = terminals.iterator(); iter.hasNext(); ) {
|
||||
try {
|
||||
connection = getConnection(iter.next(), appletAid);
|
||||
break;
|
||||
} catch(CardException e) {
|
||||
if(!iter.hasNext()) {
|
||||
log.info(e.getMessage());
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.connection = connection;
|
||||
}
|
||||
|
||||
private Card getConnection(CardTerminal cardTerminal, byte[] appletAid) throws CardException {
|
||||
Card connection = cardTerminal.connect("*");
|
||||
|
||||
CardChannel cardChannel = connection.getBasicChannel();
|
||||
ResponseAPDU resp = cardChannel.transmit(new CommandAPDU(0, 0xA4, 4, 0, appletAid));
|
||||
if(resp.getSW() != APDUResponse.SW_OK) {
|
||||
throw new CardException("Card initialization error, response was 0x" + Integer.toHexString(resp.getSW()));
|
||||
}
|
||||
|
||||
return connection;
|
||||
}
|
||||
|
||||
APDUResponse send(APDUCommand capdu) throws CardException {
|
||||
javax.smartcardio.CardChannel cardChannel = this.connection.getBasicChannel();
|
||||
|
||||
CommandAPDU cmd = new CommandAPDU(capdu.getCla(), capdu.getIns(), capdu.getP1(), capdu.getP2(), capdu.getData());
|
||||
ResponseAPDU resp = cardChannel.transmit(cmd);
|
||||
|
||||
return new APDUResponse(resp.getBytes());
|
||||
}
|
||||
|
||||
void disconnect() throws CardException {
|
||||
connection.disconnect(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.sparrowwallet.sparrow.io.satochip;
|
||||
|
||||
import com.sparrowwallet.drongo.crypto.ChildNumber;
|
||||
import com.sparrowwallet.drongo.wallet.Keystore;
|
||||
import com.sparrowwallet.drongo.wallet.WalletModel;
|
||||
import com.sparrowwallet.sparrow.io.KeystoreCardImport;
|
||||
import com.sparrowwallet.sparrow.io.ImportException;
|
||||
import javafx.beans.property.StringProperty;
|
||||
|
||||
import javax.smartcardio.CardException;
|
||||
import java.util.List;
|
||||
|
||||
public class Satochip implements KeystoreCardImport {
|
||||
@Override
|
||||
public boolean isInitialized() throws CardException {
|
||||
SatoCardApi cardApi = null;
|
||||
try {
|
||||
cardApi = new SatoCardApi(WalletModel.SATOCHIP, null);
|
||||
return cardApi.isInitialized();
|
||||
} finally {
|
||||
if(cardApi != null) {
|
||||
cardApi.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void initialize(String pin, byte[] entropy, StringProperty messageProperty) throws CardException {
|
||||
if(pin.length() < 4) {
|
||||
throw new CardException("PIN too short.");
|
||||
}
|
||||
|
||||
if(pin.length() > 16) {
|
||||
throw new CardException("PIN too long.");
|
||||
}
|
||||
|
||||
SatoCardApi cardApi = null;
|
||||
try {
|
||||
cardApi = new SatoCardApi(WalletModel.SATOCHIP, pin);
|
||||
SatoCardStatus cardStatus = cardApi.getStatus();
|
||||
if(cardStatus.isInitialized()) {
|
||||
throw new IllegalStateException("Card is already initialized.");
|
||||
}
|
||||
|
||||
cardApi.initialize(0, entropy);
|
||||
} finally {
|
||||
if(cardApi != null) {
|
||||
cardApi.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Keystore getKeystore(String pin, List<ChildNumber> derivation, StringProperty messageProperty) throws ImportException {
|
||||
if(pin.length() < 4) {
|
||||
throw new ImportException("PIN too short.");
|
||||
}
|
||||
|
||||
if(pin.length() > 16) {
|
||||
throw new ImportException("PIN too long.");
|
||||
}
|
||||
|
||||
SatoCardApi cardApi = null;
|
||||
try {
|
||||
cardApi = new SatoCardApi(WalletModel.SATOCHIP, pin);
|
||||
SatoCardStatus cardStatus = cardApi.getStatus();
|
||||
if(!cardStatus.isInitialized()) {
|
||||
throw new IllegalStateException("Card is not initialized.");
|
||||
}
|
||||
cardApi.setDerivation(derivation);
|
||||
return cardApi.getKeystore();
|
||||
} catch(Exception e) {
|
||||
throw new ImportException(e);
|
||||
} finally {
|
||||
if(cardApi != null) {
|
||||
cardApi.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getKeystoreImportDescription(int account) {
|
||||
return "Import the keystore from your Satochip by inserting or placing it on the card reader.";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "Satochip";
|
||||
}
|
||||
|
||||
@Override
|
||||
public WalletModel getWalletModel() {
|
||||
return WalletModel.SATOCHIP;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
package com.sparrowwallet.sparrow.io.satochip;
|
||||
|
||||
import com.sparrowwallet.drongo.ExtendedKey;
|
||||
import com.sparrowwallet.drongo.Utils;
|
||||
import com.sparrowwallet.drongo.protocol.Base58;
|
||||
|
||||
import com.sparrowwallet.sparrow.io.CardAuthorizationException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
|
||||
import static com.sparrowwallet.sparrow.io.satochip.Constants.*;
|
||||
|
||||
import javax.smartcardio.*;
|
||||
|
||||
/**
|
||||
* This class is used to send APDU to the applet. Each method corresponds to an APDU as defined in the APPLICATION.md
|
||||
* file. Some APDUs map to multiple methods for the sake of convenience since their payload or response require some
|
||||
* pre/post processing.
|
||||
*/
|
||||
public class SatochipCommandSet {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SatochipCommandSet.class);
|
||||
|
||||
private final SatoCardTransport cardTransport;
|
||||
private final SecureChannelSession secureChannel;
|
||||
private SatoCardStatus status;
|
||||
private SatochipParser parser = null;
|
||||
|
||||
private String pinCached = null;
|
||||
|
||||
public static final byte[] SATOCHIP_AID = Utils.hexToBytes("5361746f43686970"); //SatoChip
|
||||
|
||||
/**
|
||||
* Creates a SatochipCommandSet using the given APDU Channel
|
||||
*/
|
||||
public SatochipCommandSet() throws CardException {
|
||||
this.cardTransport = new SatoCardTransport(SATOCHIP_AID);
|
||||
this.secureChannel = new SecureChannelSession();
|
||||
this.parser = new SatochipParser();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the application info as stored from the last sent SELECT command. Returns null if no succesful SELECT
|
||||
* command has been sent using this command set.
|
||||
*
|
||||
* @return the application info object
|
||||
*/
|
||||
public SatoCardStatus getApplicationStatus() {
|
||||
if(this.status == null) {
|
||||
this.cardGetStatus();
|
||||
}
|
||||
|
||||
return this.status;
|
||||
}
|
||||
|
||||
/****************************************
|
||||
* AUTHENTIKEY *
|
||||
****************************************/
|
||||
|
||||
public APDUResponse cardTransmit(APDUCommand plainApdu) {
|
||||
// we try to transmit the APDU until we receive the answer or we receive an unrecoverable error
|
||||
boolean isApduTransmitted = false;
|
||||
do {
|
||||
try {
|
||||
byte[] apduBytes = plainApdu.serialize();
|
||||
byte ins = apduBytes[1];
|
||||
boolean isEncrypted = false;
|
||||
|
||||
// check if status available
|
||||
if(status == null) {
|
||||
APDUCommand statusCapdu = new APDUCommand(0xB0, INS_GET_STATUS, 0x00, 0x00, new byte[0]);
|
||||
APDUResponse statusRapdu = this.cardTransport.send(statusCapdu);
|
||||
status = new SatoCardStatus(statusRapdu);
|
||||
}
|
||||
|
||||
APDUCommand capdu = plainApdu;
|
||||
if(status.needsSecureChannel() && (ins != 0xA4) && (ins != 0x81) && (ins != 0x82) && (ins != INS_GET_STATUS)) {
|
||||
if(!secureChannel.initializedSecureChannel()) {
|
||||
// get card's public key
|
||||
APDUResponse secChannelRapdu = this.cardInitiateSecureChannel();
|
||||
byte[] pubkey = this.parser.parseInitiateSecureChannel(secChannelRapdu);
|
||||
// setup secure channel
|
||||
this.secureChannel.initiateSecureChannel(pubkey);
|
||||
}
|
||||
// encrypt apdu
|
||||
capdu = secureChannel.encryptSecureChannel(plainApdu);
|
||||
isEncrypted = true;
|
||||
}
|
||||
|
||||
APDUResponse rapdu = this.cardTransport.send(capdu);
|
||||
int sw12 = rapdu.getSw();
|
||||
|
||||
// check answer
|
||||
if(sw12 == 0x9000) { // ok!
|
||||
if(isEncrypted) {
|
||||
// decrypt
|
||||
rapdu = secureChannel.decryptSecureChannel(rapdu);
|
||||
}
|
||||
isApduTransmitted = true; // leave loop
|
||||
return rapdu;
|
||||
}
|
||||
// PIN authentication is required
|
||||
else if(sw12 == 0x9C06) {
|
||||
//cardVerifyPIN();
|
||||
log.error("Error, Satochip PIN required");
|
||||
throw new CardAuthorizationException("PIN is required");
|
||||
}
|
||||
// SecureChannel is not initialized
|
||||
else if(sw12 == 0x9C21) {
|
||||
log.error("Error, Satochip secure channel required");
|
||||
secureChannel.resetSecureChannel();
|
||||
} else {
|
||||
// cannot resolve issue at this point
|
||||
isApduTransmitted = true; // leave loop
|
||||
return rapdu;
|
||||
}
|
||||
} catch(Exception e) {
|
||||
log.warn("Error transmitting Satochip command set" + e);
|
||||
return new APDUResponse(new byte[0], (byte) 0x00, (byte) 0x00); // return empty APDUResponse
|
||||
}
|
||||
} while(!isApduTransmitted);
|
||||
|
||||
return new APDUResponse(new byte[0], (byte) 0x00, (byte) 0x00); // should not happen
|
||||
}
|
||||
|
||||
public void cardDisconnect() {
|
||||
secureChannel.resetSecureChannel();
|
||||
status = null;
|
||||
pinCached = null;
|
||||
try {
|
||||
cardTransport.disconnect();
|
||||
} catch(CardException e) {
|
||||
log.error("Error disconnecting Satochip" + e);
|
||||
}
|
||||
}
|
||||
|
||||
public void cardGetStatus() {
|
||||
APDUCommand plainApdu = new APDUCommand(0xB0, INS_GET_STATUS, 0x00, 0x00, new byte[0]);
|
||||
APDUResponse respApdu = this.cardTransmit(plainApdu);
|
||||
this.status = new SatoCardStatus(respApdu);
|
||||
}
|
||||
|
||||
public APDUResponse cardInitiateSecureChannel() throws CardException {
|
||||
byte[] pubkey = secureChannel.getPublicKey();
|
||||
APDUCommand plainApdu = new APDUCommand(0xB0, INS_INIT_SECURE_CHANNEL, 0x00, 0x00, pubkey);
|
||||
|
||||
return this.cardTransport.send(plainApdu);
|
||||
}
|
||||
|
||||
/****************************************
|
||||
* CARD MGMT *
|
||||
****************************************/
|
||||
|
||||
public APDUResponse cardSetup(byte pin_tries0, byte[] pin0) {
|
||||
// use random values for pin1, ublk0, ublk1
|
||||
SecureRandom random = new SecureRandom();
|
||||
byte[] ublk0 = new byte[8];
|
||||
byte[] ublk1 = new byte[8];
|
||||
byte[] pin1 = new byte[8];
|
||||
random.nextBytes(ublk0);
|
||||
random.nextBytes(ublk1);
|
||||
random.nextBytes(pin1);
|
||||
|
||||
byte ublk_tries0 = (byte) 0x01;
|
||||
byte ublk_tries1 = (byte) 0x01;
|
||||
byte pin_tries1 = (byte) 0x01;
|
||||
|
||||
return cardSetup(pin_tries0, ublk_tries0, pin0, ublk0, pin_tries1, ublk_tries1, pin1, ublk1);
|
||||
}
|
||||
|
||||
public APDUResponse cardSetup(byte pin_tries0, byte ublk_tries0, byte[] pin0, byte[] ublk0,
|
||||
byte pin_tries1, byte ublk_tries1, byte[] pin1, byte[] ublk1) {
|
||||
|
||||
byte[] pin = {0x4D, 0x75, 0x73, 0x63, 0x6C, 0x65, 0x30, 0x30}; //default pin
|
||||
byte cla = (byte) 0xB0;
|
||||
byte ins = INS_SETUP;
|
||||
byte p1 = 0;
|
||||
byte p2 = 0;
|
||||
|
||||
// data=[pin_length(1) | pin |
|
||||
// pin_tries0(1) | ublk_tries0(1) | pin0_length(1) | pin0 | ublk0_length(1) | ublk0 |
|
||||
// pin_tries1(1) | ublk_tries1(1) | pin1_length(1) | pin1 | ublk1_length(1) | ublk1 |
|
||||
// memsize(2) | memsize2(2) | ACL(3) |
|
||||
// option_flags(2) | hmacsha160_key(20) | amount_limit(8)]
|
||||
int optionsize = 0;
|
||||
int option_flags = 0; // do not use option (mostly deprecated)
|
||||
int offset = 0;
|
||||
int datasize = 16 + pin.length + pin0.length + pin1.length + ublk0.length + ublk1.length + optionsize;
|
||||
byte[] data = new byte[datasize];
|
||||
|
||||
data[offset++] = (byte) pin.length;
|
||||
System.arraycopy(pin, 0, data, offset, pin.length);
|
||||
offset += pin.length;
|
||||
// pin0 & ublk0
|
||||
data[offset++] = pin_tries0;
|
||||
data[offset++] = ublk_tries0;
|
||||
data[offset++] = (byte) pin0.length;
|
||||
System.arraycopy(pin0, 0, data, offset, pin0.length);
|
||||
offset += pin0.length;
|
||||
data[offset++] = (byte) ublk0.length;
|
||||
System.arraycopy(ublk0, 0, data, offset, ublk0.length);
|
||||
offset += ublk0.length;
|
||||
// pin1 & ublk1
|
||||
data[offset++] = pin_tries1;
|
||||
data[offset++] = ublk_tries1;
|
||||
data[offset++] = (byte) pin1.length;
|
||||
System.arraycopy(pin1, 0, data, offset, pin1.length);
|
||||
offset += pin1.length;
|
||||
data[offset++] = (byte) ublk1.length;
|
||||
System.arraycopy(ublk1, 0, data, offset, ublk1.length);
|
||||
offset += ublk1.length;
|
||||
|
||||
// memsize default (deprecated)
|
||||
data[offset++] = (byte) 00;
|
||||
data[offset++] = (byte) 32;
|
||||
data[offset++] = (byte) 00;
|
||||
data[offset++] = (byte) 32;
|
||||
|
||||
// ACL (deprecated)
|
||||
data[offset++] = (byte) 0x01;
|
||||
data[offset++] = (byte) 0x01;
|
||||
data[offset++] = (byte) 0x01;
|
||||
|
||||
APDUCommand plainApdu = new APDUCommand(cla, ins, p1, p2, data);
|
||||
APDUResponse respApdu = this.cardTransmit(plainApdu);
|
||||
|
||||
if(respApdu.getSw() == 0x9000) {
|
||||
//setPin0(pin0); // todo: cache value...
|
||||
} else {
|
||||
log.error("Error " + respApdu.toHexString());
|
||||
}
|
||||
|
||||
return respApdu;
|
||||
}
|
||||
|
||||
|
||||
/****************************************
|
||||
* PIN MGMT *
|
||||
****************************************/
|
||||
|
||||
public APDUResponse cardVerifyPIN() throws CardException {
|
||||
return this.cardVerifyPIN((byte) 0, pinCached);
|
||||
}
|
||||
|
||||
public APDUResponse cardVerifyPIN(int pinNbr, String pin) throws CardException {
|
||||
if(pin == null) {
|
||||
if(pinCached == null) {
|
||||
throw new CardException("PIN required!");
|
||||
}
|
||||
pin = this.pinCached;
|
||||
}
|
||||
|
||||
byte[] pinBytes = pin.getBytes(StandardCharsets.UTF_8);
|
||||
if(pinBytes.length > 16) {
|
||||
throw new CardException("PIN should be maximum 16 characters!");
|
||||
}
|
||||
|
||||
APDUCommand capdu = new APDUCommand(0xB0, INS_VERIFY_PIN, (byte) pinNbr, 0x00, pinBytes);
|
||||
APDUResponse rapdu = this.cardTransmit(capdu);
|
||||
|
||||
// correct PIN: cache PIN value
|
||||
int sw = rapdu.getSw();
|
||||
if(sw == 0x9000) {
|
||||
this.pinCached = pin; //set cached PIN value
|
||||
}
|
||||
// wrong PIN, get remaining tries available (since v0.11)
|
||||
else if((sw & 0xffc0) == 0x63c0) {
|
||||
this.pinCached = null; //reset cached PIN value
|
||||
int pinLeft = (sw & ~0xffc0);
|
||||
throw new CardAuthorizationException("Wrong PIN, remaining tries: " + pinLeft);
|
||||
}
|
||||
// wrong PIN (legacy before v0.11)
|
||||
else if(sw == 0x9c02) {
|
||||
this.pinCached = null; //reset cached PIN value
|
||||
SatoCardStatus cardStatus = this.getApplicationStatus();
|
||||
int pinLeft = cardStatus.getPin0RemainingCounter();
|
||||
throw new CardAuthorizationException("Wrong PIN, remaining tries: " + pinLeft);
|
||||
}
|
||||
// blocked PIN
|
||||
else if(sw == 0x9c0c) {
|
||||
throw new CardException("Card is blocked!");
|
||||
}
|
||||
|
||||
return rapdu;
|
||||
}
|
||||
|
||||
public void cardChangePIN(int pinNbr, String oldPin, String newPin) throws CardException {
|
||||
byte[] oldPinBytes = oldPin.getBytes(StandardCharsets.UTF_8);
|
||||
byte[] newPinBytes = newPin.getBytes(StandardCharsets.UTF_8);
|
||||
int lc = 1 + oldPinBytes.length + 1 + newPinBytes.length;
|
||||
byte[] data = new byte[lc];
|
||||
|
||||
data[0] = (byte) oldPinBytes.length;
|
||||
int offset = 1;
|
||||
System.arraycopy(oldPinBytes, 0, data, offset, oldPinBytes.length);
|
||||
offset += oldPinBytes.length;
|
||||
data[offset] = (byte) newPinBytes.length;
|
||||
offset += 1;
|
||||
System.arraycopy(newPinBytes, 0, data, offset, newPinBytes.length);
|
||||
|
||||
APDUCommand capdu = new APDUCommand(0xB0, INS_CHANGE_PIN, (byte) pinNbr, 0x00, data);
|
||||
APDUResponse rapdu = this.cardTransmit(capdu);
|
||||
|
||||
// correct PIN: cache PIN value
|
||||
int sw = rapdu.getSw();
|
||||
if(sw == 0x9000) {
|
||||
this.pinCached = newPin;
|
||||
}
|
||||
// wrong PIN, get remaining tries available (since v0.11)
|
||||
else if((sw & 0xffc0) == 0x63c0) {
|
||||
int pinLeft = (sw & ~0xffc0);
|
||||
throw new CardAuthorizationException("Wrong PIN, remaining tries: " + pinLeft);
|
||||
}
|
||||
// wrong PIN (legacy before v0.11)
|
||||
else if(sw == 0x9c02) {
|
||||
SatoCardStatus cardStatus = this.getApplicationStatus();
|
||||
int pinLeft = cardStatus.getPin0RemainingCounter();
|
||||
throw new CardAuthorizationException("Wrong PIN, remaining tries: " + pinLeft);
|
||||
}
|
||||
// blocked PIN
|
||||
else if(sw == 0x9c0c) {
|
||||
throw new CardException("Card is blocked!");
|
||||
}
|
||||
}
|
||||
|
||||
/****************************************
|
||||
* BIP32 *
|
||||
****************************************/
|
||||
|
||||
public APDUResponse cardBip32ImportSeed(byte[] masterseed) {
|
||||
APDUCommand plainApdu = new APDUCommand(0xB0, INS_BIP32_IMPORT_SEED, masterseed.length, 0x00, masterseed);
|
||||
return this.cardTransmit(plainApdu);
|
||||
}
|
||||
|
||||
public APDUResponse cardBip32GetExtendedKey(String stringPath) {
|
||||
KeyPath keyPath = new KeyPath(stringPath);
|
||||
byte[] bytePath = keyPath.getData();
|
||||
return cardBip32GetExtendedKey(bytePath);
|
||||
}
|
||||
|
||||
public APDUResponse cardBip32GetExtendedKey(byte[] bytePath) {
|
||||
byte p1 = (byte) (bytePath.length / 4);
|
||||
|
||||
APDUCommand plainApdu = new APDUCommand(0xB0, INS_BIP32_GET_EXTENDED_KEY, p1, 0x40, bytePath);
|
||||
return this.cardTransmit(plainApdu);
|
||||
}
|
||||
|
||||
/*
|
||||
* Get the BIP32 xpub for given path.
|
||||
*
|
||||
* Parameters:
|
||||
* path (str): the path; if given as a string, it will be converted to bytes (4 bytes for each path index)
|
||||
* xtype (str): the type of transaction such as 'standard', 'p2wpkh-p2sh', 'p2wpkh', 'p2wsh-p2sh', 'p2wsh'
|
||||
* is_mainnet (bool): is mainnet or testnet
|
||||
*
|
||||
* Return:
|
||||
* xpub (str): the corresponding xpub value
|
||||
*/
|
||||
public String cardBip32GetXpub(String stringPath, ExtendedKey.Header xtype) throws CardException {
|
||||
// path is of the form 44'/0'/1'
|
||||
KeyPath keyPath = new KeyPath(stringPath);
|
||||
byte[] bytePath = keyPath.getData();
|
||||
int depth = bytePath.length / 4;
|
||||
|
||||
APDUResponse rapdu = this.cardBip32GetExtendedKey(bytePath);
|
||||
byte[][] extendedkey = this.parser.parseBip32GetExtendedKey(rapdu);
|
||||
|
||||
byte[] fingerprint = new byte[4];
|
||||
byte[] childNumber = new byte[4];
|
||||
if(depth == 0) { //masterkey
|
||||
// fingerprint and childnumber set to all-zero bytes by default
|
||||
//fingerprint= bytes([0,0,0,0])
|
||||
//childNumber= bytes([0,0,0,0])
|
||||
} else { //get parent info
|
||||
byte[] bytePathParent = Arrays.copyOfRange(bytePath, 0, bytePath.length - 4);
|
||||
APDUResponse rapdu2 = this.cardBip32GetExtendedKey(bytePathParent);
|
||||
byte[][] extendedkeyParent = this.parser.parseBip32GetExtendedKey(rapdu2);
|
||||
byte[] identifier = Utils.sha256hash160(extendedkeyParent[0]);
|
||||
fingerprint = Arrays.copyOfRange(identifier, 0, 4);
|
||||
childNumber = Arrays.copyOfRange(bytePath, bytePath.length - 4, bytePath.length);
|
||||
}
|
||||
|
||||
ByteBuffer buffer = ByteBuffer.allocate(78);
|
||||
buffer.putInt(xtype.getHeader());
|
||||
buffer.put((byte) depth);
|
||||
buffer.put(fingerprint);
|
||||
buffer.put(childNumber);
|
||||
buffer.put(extendedkey[1]); // chaincode
|
||||
buffer.put(extendedkey[0]); // pubkey (compressed)
|
||||
byte[] xpubByte = buffer.array();
|
||||
|
||||
return Base58.encodeChecked(xpubByte);
|
||||
}
|
||||
|
||||
/****************************************
|
||||
* SIGNATURES *
|
||||
****************************************/
|
||||
|
||||
public APDUResponse cardSignTransactionHash(byte keynbr, byte[] txhash, byte[] chalresponse) throws CardException {
|
||||
byte[] data;
|
||||
if(txhash.length != 32) {
|
||||
throw new CardException("Wrong txhash length (should be 32)");
|
||||
}
|
||||
if(chalresponse == null) {
|
||||
data = new byte[32];
|
||||
System.arraycopy(txhash, 0, data, 0, txhash.length);
|
||||
} else if(chalresponse.length == 20) {
|
||||
data = new byte[32 + 2 + 20];
|
||||
int offset = 0;
|
||||
System.arraycopy(txhash, 0, data, offset, txhash.length);
|
||||
offset += 32;
|
||||
data[offset++] = (byte) 0x80; // 2 middle bytes for 2FA flag
|
||||
data[offset++] = (byte) 0x00;
|
||||
System.arraycopy(chalresponse, 0, data, offset, chalresponse.length);
|
||||
} else {
|
||||
throw new CardException("Wrong challenge-response length (should be 20)");
|
||||
}
|
||||
|
||||
APDUCommand plainApdu = new APDUCommand(0xB0, INS_SIGN_TRANSACTION_HASH, keynbr, 0x00, data);
|
||||
return this.cardTransmit(plainApdu);
|
||||
}
|
||||
|
||||
/**
|
||||
* This function signs a given hash with a std or the last extended key
|
||||
* If 2FA is enabled, a HMAC must be provided as an additional security layer. *
|
||||
* ins: 0x7B
|
||||
* p1: key number or 0xFF for the last derived Bip32 extended key
|
||||
* p2: 0x00
|
||||
* data: [hash(32b) | option: 2FA-flag(2b)|hmac(20b)]
|
||||
* return: [sig]
|
||||
*/
|
||||
public APDUResponse cardSignSchnorrHash(byte keynbr, byte[] txhash, byte[] chalresponse) throws CardException {
|
||||
byte[] data;
|
||||
if(txhash.length != 32) {
|
||||
throw new CardException("Wrong txhash length (should be 32)");
|
||||
}
|
||||
if(chalresponse == null) {
|
||||
data = new byte[32];
|
||||
System.arraycopy(txhash, 0, data, 0, txhash.length);
|
||||
} else if(chalresponse.length == 20) {
|
||||
data = new byte[32 + 2 + 20];
|
||||
int offset = 0;
|
||||
System.arraycopy(txhash, 0, data, offset, txhash.length);
|
||||
offset += 32;
|
||||
data[offset++] = (byte) 0x80; // 2 middle bytes for 2FA flag
|
||||
data[offset++] = (byte) 0x00;
|
||||
System.arraycopy(chalresponse, 0, data, offset, chalresponse.length);
|
||||
} else {
|
||||
throw new CardException("Wrong challenge-response length (should be 20)");
|
||||
}
|
||||
|
||||
APDUCommand plainApdu = new APDUCommand(0xB0, 0x7B, keynbr, 0x00, data);
|
||||
return this.cardTransmit(plainApdu);
|
||||
}
|
||||
|
||||
/**
|
||||
* This function tweak the currently available private stored in the Satochip.
|
||||
* Tweaking is based on the 'taproot_tweak_seckey(seckey0, h)' algorithm specification defined here:
|
||||
* https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki#constructing-and-spending-taproot-outputs
|
||||
* <p>
|
||||
* ins: 0x7C
|
||||
* p1: key number or 0xFF for the last derived Bip32 extended key
|
||||
* p2: 0x00
|
||||
* data: [hash(32b) | option: 2FA-flag(2b)|hmac(20b)]
|
||||
* return: [sig]
|
||||
*/
|
||||
public APDUResponse cardTaprootTweakPrivkey(byte keynbr, byte[] tweak) throws CardException {
|
||||
byte[] data;
|
||||
if(tweak == null) {
|
||||
tweak = new byte[32]; // by default use a 32-byte vector filled with '0x00'
|
||||
}
|
||||
if(tweak.length != 32) {
|
||||
throw new CardException("Wrong tweak length (should be 32)");
|
||||
}
|
||||
data = new byte[33];
|
||||
data[0] = (byte) 32;
|
||||
System.arraycopy(tweak, 0, data, 1, tweak.length);
|
||||
|
||||
APDUCommand plainApdu = new APDUCommand(0xB0, 0x7C, keynbr, 0x00, data);
|
||||
return this.cardTransmit(plainApdu);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.sparrowwallet.sparrow.io.satochip;
|
||||
|
||||
import com.sparrowwallet.drongo.protocol.Sha256Hash;
|
||||
import com.sparrowwallet.drongo.crypto.ECKey;
|
||||
import com.sparrowwallet.drongo.crypto.ECDSASignature;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import javax.smartcardio.CardException;
|
||||
import java.util.Arrays;
|
||||
|
||||
public class SatochipParser {
|
||||
private static final Logger log = LoggerFactory.getLogger(SatochipParser.class);
|
||||
|
||||
public SatochipParser() {
|
||||
}
|
||||
|
||||
/****************************************
|
||||
* PARSER *
|
||||
****************************************/
|
||||
|
||||
public byte[] parseInitiateSecureChannel(APDUResponse rapdu) throws CardException {
|
||||
try {
|
||||
byte[] data = rapdu.getData();
|
||||
|
||||
// data= [coordxSize | coordx | sig1Size | sig1 | sig2Size | sig2]
|
||||
int offset = 0;
|
||||
int coordxSize = 256 * data[offset++] + data[offset++];
|
||||
|
||||
byte[] coordx = new byte[coordxSize];
|
||||
System.arraycopy(data, offset, coordx, 0, coordxSize);
|
||||
offset += coordxSize;
|
||||
|
||||
// msg1 is [coordx_size | coordx]
|
||||
byte[] msg1 = new byte[2 + coordxSize];
|
||||
System.arraycopy(data, 0, msg1, 0, msg1.length);
|
||||
|
||||
int sig1Size = 256 * data[offset++] + data[offset++];
|
||||
byte[] sig1 = new byte[sig1Size];
|
||||
System.arraycopy(data, offset, sig1, 0, sig1Size);
|
||||
offset += sig1Size;
|
||||
|
||||
// msg2 is [coordxSize | coordx | sig1Size | sig1]
|
||||
byte[] msg2 = new byte[2 + coordxSize + 2 + sig1Size];
|
||||
System.arraycopy(data, 0, msg2, 0, msg2.length);
|
||||
|
||||
int sig2Size = 256 * data[offset++] + data[offset++];
|
||||
byte[] sig2 = new byte[sig2Size];
|
||||
System.arraycopy(data, offset, sig2, 0, sig2Size);
|
||||
offset += sig2Size;
|
||||
|
||||
return recoverPubkey(msg1, sig1, coordx, false);
|
||||
} catch(Exception e) {
|
||||
throw new CardException("Error parsing Satochip response", e);
|
||||
}
|
||||
}
|
||||
|
||||
public byte[][] parseBip32GetExtendedKey(APDUResponse rapdu) throws CardException {
|
||||
try {
|
||||
byte[][] extendedkey = new byte[2][];
|
||||
extendedkey[0] = new byte[33]; // pubkey
|
||||
extendedkey[1] = new byte[32]; // chaincode
|
||||
|
||||
byte[] data = rapdu.getData();
|
||||
//data: [chaincode(32b) | coordx_size(2b) | coordx | sig_size(2b) | sig | sig_size(2b) | sig2]
|
||||
|
||||
int offset = 0;
|
||||
byte[] chaincode = new byte[32];
|
||||
System.arraycopy(data, offset, chaincode, 0, chaincode.length);
|
||||
offset += 32;
|
||||
|
||||
int coordxSize = 256 * (data[offset++] & 0x7f) + data[offset++]; // (data[32] & 0x80) is ignored (optimization flag)
|
||||
byte[] coordx = new byte[coordxSize];
|
||||
System.arraycopy(data, offset, coordx, 0, coordxSize);
|
||||
offset += coordxSize;
|
||||
|
||||
// msg1 is [chaincode | coordx_size | coordx]
|
||||
byte[] msg1 = new byte[32 + 2 + coordxSize];
|
||||
System.arraycopy(data, 0, msg1, 0, msg1.length);
|
||||
|
||||
int sig1Size = 256 * data[offset++] + data[offset++];
|
||||
byte[] sig1 = new byte[sig1Size];
|
||||
System.arraycopy(data, offset, sig1, 0, sig1Size);
|
||||
offset += sig1Size;
|
||||
|
||||
// msg2 is [chaincode | coordxSize | coordx | sig1Size | sig1]
|
||||
byte[] msg2 = new byte[32 + 2 + coordxSize + 2 + sig1Size];
|
||||
System.arraycopy(data, 0, msg2, 0, msg2.length);
|
||||
|
||||
int sig2Size = 256 * data[offset++] + data[offset++];
|
||||
byte[] sig2 = new byte[sig2Size];
|
||||
System.arraycopy(data, offset, sig2, 0, sig2Size);
|
||||
offset += sig2Size;
|
||||
|
||||
byte[] pubkey = recoverPubkey(msg1, sig1, coordx, true); // true: compressed (33 bytes)
|
||||
|
||||
// todo: recover from si2
|
||||
System.arraycopy(pubkey, 0, extendedkey[0], 0, pubkey.length);
|
||||
System.arraycopy(chaincode, 0, extendedkey[1], 0, chaincode.length);
|
||||
return extendedkey;
|
||||
} catch(Exception e) {
|
||||
throw new CardException("Error parsing Satochip extended key", e);
|
||||
}
|
||||
}
|
||||
|
||||
/****************************************
|
||||
* recovery methods *
|
||||
****************************************/
|
||||
|
||||
public byte[] recoverPubkey(byte[] msg, byte[] dersig, byte[] coordx, Boolean compressed) throws CardException {
|
||||
// convert msg to hash
|
||||
//byte[] hash = Sha256Hash.hash(msg);
|
||||
ECDSASignature ecdsaSig = ECDSASignature.decodeFromDER(dersig);
|
||||
|
||||
byte recId = -1;
|
||||
ECKey k = null;
|
||||
for(byte i = 0; i < 4; i++) {
|
||||
k = ECKey.recoverFromSignature(i, ecdsaSig, Sha256Hash.of(msg), compressed);
|
||||
if(k != null && Arrays.equals(k.getPubKeyXCoord(), coordx)) {
|
||||
recId = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if(recId == -1) {
|
||||
throw new CardException("Could not construct a recoverable key. This should never happen.");
|
||||
}
|
||||
|
||||
return k.getPubKey();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package com.sparrowwallet.sparrow.io.satochip;
|
||||
|
||||
import com.sparrowwallet.drongo.crypto.AESKeyCrypter;
|
||||
|
||||
import com.sparrowwallet.drongo.bip47.SecretPoint;
|
||||
import com.sparrowwallet.drongo.crypto.ECKey;
|
||||
import com.sparrowwallet.drongo.crypto.Key;
|
||||
import com.sparrowwallet.drongo.crypto.EncryptedData;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import javax.smartcardio.CardException;
|
||||
import java.security.SecureRandom;
|
||||
import java.nio.ByteBuffer;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* Handles a SecureChannel session with the card.
|
||||
*/
|
||||
public class SecureChannelSession {
|
||||
private static final Logger log = LoggerFactory.getLogger(SecureChannelSession.class);
|
||||
|
||||
public static final int SC_SECRET_LENGTH = 16;
|
||||
public static final int SC_BLOCK_SIZE = 16;
|
||||
public static final int IV_SIZE = 16;
|
||||
public static final int MAC_SIZE = 20;
|
||||
|
||||
// secure channel constants
|
||||
private final static byte INS_INIT_SECURE_CHANNEL = (byte) 0x81;
|
||||
private final static byte INS_PROCESS_SECURE_CHANNEL = (byte) 0x82;
|
||||
private final static short SW_SECURE_CHANNEL_REQUIRED = (short) 0x9C20;
|
||||
private final static short SW_SECURE_CHANNEL_UNINITIALIZED = (short) 0x9C21;
|
||||
private final static short SW_SECURE_CHANNEL_WRONG_IV = (short) 0x9C22;
|
||||
private final static short SW_SECURE_CHANNEL_WRONG_MAC = (short) 0x9C23;
|
||||
|
||||
private boolean initialized_secure_channel = false;
|
||||
|
||||
// secure channel keys
|
||||
private byte[] secret;
|
||||
private byte[] iv;
|
||||
private int ivCounter;
|
||||
byte[] derived_key;
|
||||
byte[] mac_key;
|
||||
|
||||
// for ECDH
|
||||
private SecretPoint secretPoint;
|
||||
private final ECKey eckey;
|
||||
|
||||
// for session encryption
|
||||
private final SecureRandom random;
|
||||
private final AESKeyCrypter aesCipher;
|
||||
|
||||
/**
|
||||
* Constructs a SecureChannel session on the client.
|
||||
*/
|
||||
public SecureChannelSession() {
|
||||
random = new SecureRandom();
|
||||
|
||||
// generate keypair
|
||||
eckey = new ECKey();
|
||||
aesCipher = new AESKeyCrypter();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a pairing secret. This should be called before each session. The public key of the card is used as input
|
||||
* for the EC-DH algorithm. The output is stored as the secret.
|
||||
*
|
||||
* @param pubkeyData the public key returned by the applet as response to the SELECT command
|
||||
*/
|
||||
public void initiateSecureChannel(byte[] pubkeyData) { //TODO: check keyData format
|
||||
try {
|
||||
byte[] privkeyData = this.eckey.getPrivKeyBytes();
|
||||
secretPoint = new SecretPoint(privkeyData, pubkeyData);
|
||||
secret = secretPoint.ECDHSecretAsBytes();
|
||||
//log.trace("SATOCHIP SecureChannelSession initiateSecureChannel() secret: " + Utils.bytesToHex(secret));
|
||||
|
||||
// derive session encryption key
|
||||
byte[] msg_key = "sc_key".getBytes();
|
||||
byte[] derived_key_2Ob = getHmacSha1Hash(secret, msg_key);
|
||||
derived_key = new byte[16];
|
||||
System.arraycopy(derived_key_2Ob, 0, derived_key, 0, 16);
|
||||
//log.trace("SATOCHIP SecureChannelSession initiateSecureChannel() derived_key: " + Utils.bytesToHex(derived_key));
|
||||
// derive session mac key
|
||||
byte[] msg_mac = "sc_mac".getBytes();
|
||||
mac_key = getHmacSha1Hash(secret, msg_mac);
|
||||
//log.trace("SATOCHIP SecureChannelSession initiateSecureChannel() mac_key: " + Utils.bytesToHex(mac_key));
|
||||
|
||||
ivCounter = 1;
|
||||
initialized_secure_channel = true;
|
||||
} catch(Exception e) {
|
||||
log.error("Error initiating secure channel", e);
|
||||
}
|
||||
}
|
||||
|
||||
public APDUCommand encryptSecureChannel(APDUCommand plainApdu) throws CardException {
|
||||
try {
|
||||
byte[] plainBytes = plainApdu.serialize();
|
||||
|
||||
// set iv
|
||||
iv = new byte[SC_BLOCK_SIZE];
|
||||
random.nextBytes(iv);
|
||||
ByteBuffer bb = ByteBuffer.allocate(4);
|
||||
bb.putInt(ivCounter); // big endian
|
||||
byte[] ivCounterBytes = bb.array();
|
||||
System.arraycopy(ivCounterBytes, 0, iv, 12, 4);
|
||||
ivCounter += 2;
|
||||
|
||||
// encrypt data
|
||||
Key aesKey = new Key(derived_key, null, null);
|
||||
byte[] encrypted = aesCipher.encrypt(plainBytes, iv, aesKey).getEncryptedBytes();
|
||||
|
||||
// mac
|
||||
int offset = 0;
|
||||
byte[] data_to_mac = new byte[IV_SIZE + 2 + encrypted.length];
|
||||
System.arraycopy(iv, offset, data_to_mac, offset, IV_SIZE);
|
||||
offset += IV_SIZE;
|
||||
data_to_mac[offset++] = (byte) (encrypted.length >> 8);
|
||||
data_to_mac[offset++] = (byte) (encrypted.length % 256);
|
||||
System.arraycopy(encrypted, 0, data_to_mac, offset, encrypted.length);
|
||||
// log.trace("SATOCHIP data_to_mac: "+ SatochipParser.toHexString(data_to_mac));
|
||||
byte[] mac = getHmacSha1Hash(mac_key, data_to_mac);
|
||||
|
||||
// copy all data to new data buffer
|
||||
offset = 0;
|
||||
byte[] data = new byte[IV_SIZE + 2 + encrypted.length + 2 + MAC_SIZE];
|
||||
System.arraycopy(iv, offset, data, offset, IV_SIZE);
|
||||
offset += IV_SIZE;
|
||||
data[offset++] = (byte) (encrypted.length >> 8);
|
||||
data[offset++] = (byte) (encrypted.length % 256);
|
||||
System.arraycopy(encrypted, 0, data, offset, encrypted.length);
|
||||
offset += encrypted.length;
|
||||
data[offset++] = (byte) (mac.length >> 8);
|
||||
data[offset++] = (byte) (mac.length % 256);
|
||||
System.arraycopy(mac, 0, data, offset, mac.length);
|
||||
|
||||
// convert to C-APDU
|
||||
return new APDUCommand(0xB0, INS_PROCESS_SECURE_CHANNEL, 0x00, 0x00, data);
|
||||
} catch(Exception e) {
|
||||
throw new CardException("Error encrypting secure channel", e);
|
||||
}
|
||||
}
|
||||
|
||||
public APDUResponse decryptSecureChannel(APDUResponse encryptedApdu) throws CardException {
|
||||
try {
|
||||
byte[] encryptedBytes = encryptedApdu.getData();
|
||||
if(encryptedBytes.length == 0) {
|
||||
return encryptedApdu; // no decryption needed
|
||||
} else if(encryptedBytes.length < 40) {
|
||||
// has at least (IV_SIZE + 2 + 2 + 20)
|
||||
throw new RuntimeException("Encrypted response has wrong length: " + encryptedBytes.length);
|
||||
}
|
||||
|
||||
int offset = 0;
|
||||
byte[] iv = new byte[IV_SIZE];
|
||||
System.arraycopy(encryptedBytes, offset, iv, 0, IV_SIZE);
|
||||
offset += IV_SIZE;
|
||||
int ciphertext_size = ((encryptedBytes[offset++] & 0xff) << 8) + (encryptedBytes[offset++] & 0xff);
|
||||
if((encryptedBytes.length - offset) != ciphertext_size) {
|
||||
throw new RuntimeException("Encrypted response has wrong length ciphertext_size: " + ciphertext_size);
|
||||
}
|
||||
byte[] ciphertext = new byte[ciphertext_size];
|
||||
System.arraycopy(encryptedBytes, offset, ciphertext, 0, ciphertext.length);
|
||||
|
||||
// decrypt data
|
||||
Key aesKey = new Key(derived_key, null, null);
|
||||
EncryptedData encryptedData = new EncryptedData(iv, ciphertext, null, null);
|
||||
byte[] decrypted = aesCipher.decrypt(encryptedData, aesKey);
|
||||
|
||||
return new APDUResponse(decrypted, (byte) 0x90, (byte) 0x00);
|
||||
} catch(Exception e) {
|
||||
throw new CardException("Error decrypting secure channel", e);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean initializedSecureChannel() {
|
||||
return initialized_secure_channel;
|
||||
}
|
||||
|
||||
public byte[] getPublicKey() {
|
||||
return eckey.getPubKey(false);
|
||||
}
|
||||
|
||||
public void resetSecureChannel() {
|
||||
initialized_secure_channel = false;
|
||||
}
|
||||
|
||||
public static byte[] getHmacSha1Hash(byte[] key, byte[] data) {
|
||||
try {
|
||||
SecretKeySpec secretKeySpec = new SecretKeySpec(key, "HmacSHA1");
|
||||
Mac mac = Mac.getInstance("HmacSHA1");
|
||||
mac.init(secretKeySpec);
|
||||
return mac.doFinal(data);
|
||||
} catch(Exception e) {
|
||||
throw new RuntimeException("Error computing HmacSHA1", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import com.sparrowwallet.sparrow.control.FileKeystoreImportPane;
|
||||
import com.sparrowwallet.sparrow.control.TitledDescriptionPane;
|
||||
import com.sparrowwallet.sparrow.io.*;
|
||||
import com.sparrowwallet.sparrow.io.ckcard.Tapsigner;
|
||||
import com.sparrowwallet.sparrow.io.satochip.Satochip;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.scene.control.Accordion;
|
||||
import org.slf4j.Logger;
|
||||
@@ -24,9 +25,9 @@ public class HwAirgappedController extends KeystoreImportDetailController {
|
||||
public void initializeView() {
|
||||
List<KeystoreFileImport> fileImporters = Collections.emptyList();
|
||||
if(getMasterController().getWallet().getPolicyType().equals(PolicyType.SINGLE)) {
|
||||
fileImporters = List.of(new ColdcardSinglesig(), new CoboVaultSinglesig(), new Jade(), new KeystoneSinglesig(), new PassportSinglesig(), new SeedSigner(), new GordianSeedTool(), new SpecterDIY());
|
||||
fileImporters = List.of(new ColdcardSinglesig(), new CoboVaultSinglesig(), new Jade(), new KeystoneSinglesig(), new PassportSinglesig(), new SeedSigner(), new GordianSeedTool(), new SpecterDIY(), new Krux());
|
||||
} else if(getMasterController().getWallet().getPolicyType().equals(PolicyType.MULTI)) {
|
||||
fileImporters = List.of(new Bip129(), new ColdcardMultisig(), new CoboVaultMultisig(), new Jade(), new KeystoneMultisig(), new PassportMultisig(), new SeedSigner(), new GordianSeedTool(), new SpecterDIY());
|
||||
fileImporters = List.of(new Bip129(), new ColdcardMultisig(), new CoboVaultMultisig(), new Jade(), new KeystoneMultisig(), new PassportMultisig(), new SeedSigner(), new GordianSeedTool(), new SpecterDIY(), new Krux());
|
||||
}
|
||||
|
||||
for(KeystoreFileImport importer : fileImporters) {
|
||||
@@ -38,7 +39,7 @@ public class HwAirgappedController extends KeystoreImportDetailController {
|
||||
}
|
||||
}
|
||||
|
||||
List<KeystoreCardImport> cardImporters = List.of(new Tapsigner());
|
||||
List<KeystoreCardImport> cardImporters = List.of(new Tapsigner(), new Satochip());
|
||||
for(KeystoreCardImport importer : cardImporters) {
|
||||
if(!importer.isDeprecated() || Config.get().isShowDeprecatedImportExport()) {
|
||||
CardImportPane importPane = new CardImportPane(getMasterController().getWallet(), importer, getMasterController().getRequiredDerivation());
|
||||
|
||||
@@ -355,14 +355,14 @@ public class ElectrumServer {
|
||||
//Because node children are added sequentially in WalletNode.fillToIndex, we can simply look at the number of children to determine the highest filled index
|
||||
int historySize = purposeNode.getChildren().size();
|
||||
//The gap limit size takes the highest used index in the retrieved history and adds the gap limit (plus one to be comparable to the number of children since index is zero based)
|
||||
int gapLimitSize = getGapLimitSize(wallet, nodeTransactionMap);
|
||||
int gapLimitSize = getGapLimitSize(wallet, nodeTransactionMap, purposeNode);
|
||||
while(historySize < gapLimitSize) {
|
||||
purposeNode.fillToIndex(wallet, gapLimitSize - 1);
|
||||
subscribeWalletNodes(wallet, getAddressNodes(wallet, purposeNode), nodeTransactionMap, historySize);
|
||||
getReferences(wallet, nodeTransactionMap.keySet(), nodeTransactionMap, historySize);
|
||||
getReferencedTransactions(wallet, nodeTransactionMap);
|
||||
historySize = purposeNode.getChildren().size();
|
||||
gapLimitSize = getGapLimitSize(wallet, nodeTransactionMap);
|
||||
gapLimitSize = getGapLimitSize(wallet, nodeTransactionMap, purposeNode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,8 +377,9 @@ public class ElectrumServer {
|
||||
return purposeNode.getChildren().stream().filter(walletNode -> walletNode.getIndex() >= startFromIndex).collect(Collectors.toCollection(TreeSet::new));
|
||||
}
|
||||
|
||||
private int getGapLimitSize(Wallet wallet, Map<WalletNode, Set<BlockTransactionHash>> nodeTransactionMap) {
|
||||
int highestIndex = nodeTransactionMap.keySet().stream().filter(node -> node.getDerivation().size() > 1).map(WalletNode::getIndex).max(Comparator.comparing(Integer::valueOf)).orElse(-1);
|
||||
private int getGapLimitSize(Wallet wallet, Map<WalletNode, Set<BlockTransactionHash>> nodeTransactionMap, WalletNode purposeNode) {
|
||||
int highestIndex = nodeTransactionMap.keySet().stream().filter(node -> node.getDerivation().size() > 1 && purposeNode.getKeyPurpose() == node.getKeyPurpose())
|
||||
.map(WalletNode::getIndex).max(Comparator.comparing(Integer::valueOf)).orElse(-1);
|
||||
return highestIndex + wallet.getGapLimit() + 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -96,9 +96,8 @@ public final class IpAddressMatcher {
|
||||
private InetAddress parseAddress(String address) {
|
||||
try {
|
||||
return InetAddress.getByName(address);
|
||||
}
|
||||
catch (UnknownHostException e) {
|
||||
throw new IllegalArgumentException("Failed to parse address: " + address, e);
|
||||
} catch(UnknownHostException e) {
|
||||
throw new IllegalArgumentException("Failed to resolve address: " + address, e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,9 @@ public enum PublicElectrumServer {
|
||||
EMZY_DE("electrum.emzy.de", "ssl://electrum.emzy.de:50002", Network.MAINNET),
|
||||
BITAROO_NET("electrum.bitaroo.net", "ssl://electrum.bitaroo.net:50002", Network.MAINNET),
|
||||
DIYNODES_COM("electrum.diynodes.com", "ssl://electrum.diynodes.com:50022", Network.MAINNET),
|
||||
TESTNET_ARANGUREN_ORG("testnet.aranguren.org", "ssl://testnet.aranguren.org:51002", Network.TESTNET);
|
||||
SETHFORPRIVACY_COM("fulcrum.sethforprivacy.com", "ssl://fulcrum.sethforprivacy.com:50002", Network.MAINNET),
|
||||
TESTNET_ARANGUREN_ORG("testnet.aranguren.org", "ssl://testnet.aranguren.org:51002", Network.TESTNET),
|
||||
TESTNET_QTORNADO_COM("testnet.qtornado.com", "ssl://testnet.qtornado.com:51002", Network.TESTNET);
|
||||
|
||||
PublicElectrumServer(String name, String url, Network network) {
|
||||
this.server = new Server(url, name);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.sparrowwallet.sparrow.net;
|
||||
|
||||
import com.github.arteam.simplejsonrpc.client.Transport;
|
||||
import com.github.arteam.simplejsonrpc.server.JsonRpcServer;
|
||||
import com.google.common.base.Splitter;
|
||||
import com.google.common.net.HostAndPort;
|
||||
@@ -19,6 +18,7 @@ import java.net.SocketTimeoutException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.locks.Condition;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
@@ -40,6 +40,8 @@ public class TcpTransport implements CloseableTransport, TimeoutCounter {
|
||||
|
||||
private String response;
|
||||
|
||||
private final CountDownLatch readReadySignal = new CountDownLatch(1);
|
||||
|
||||
private final ReentrantLock readLock = new ReentrantLock();
|
||||
private final Condition readingCondition = readLock.newCondition();
|
||||
|
||||
@@ -110,6 +112,17 @@ public class TcpTransport implements CloseableTransport, TimeoutCounter {
|
||||
}
|
||||
|
||||
private String readResponse() throws IOException {
|
||||
if(firstRead) {
|
||||
try {
|
||||
//Ensure read thread has started
|
||||
if(!readReadySignal.await(2, TimeUnit.SECONDS)) {
|
||||
throw new IOException("Read thread did not start");
|
||||
}
|
||||
} catch(InterruptedException e) {
|
||||
throw new IOException("Read ready await interrupted");
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if(!readLock.tryLock((readTimeouts[readTimeoutIndex] * 1000L) + (requestIdCount * PER_REQUEST_READ_TIMEOUT_MILLIS), TimeUnit.MILLISECONDS)) {
|
||||
readTimeoutIndex = Math.min(readTimeoutIndex + 1, readTimeouts.length - 1);
|
||||
@@ -155,6 +168,7 @@ public class TcpTransport implements CloseableTransport, TimeoutCounter {
|
||||
|
||||
public void readInputLoop() throws ServerException {
|
||||
readLock.lock();
|
||||
readReadySignal.countDown();
|
||||
|
||||
try {
|
||||
try {
|
||||
|
||||
@@ -86,6 +86,10 @@ public class Tor {
|
||||
dormantCanceledByStartup.set(TorConfig.Option.AorTorF.getTrue());
|
||||
builder.put(dormantCanceledByStartup);
|
||||
|
||||
TorConfig.Setting.Ports.Control controlPort = new TorConfig.Setting.Ports.Control();
|
||||
controlPort.set(TorConfig.Option.AorDorPort.Auto.INSTANCE);
|
||||
builder.put(controlPort);
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
};
|
||||
|
||||
+30
-17
@@ -81,7 +81,7 @@ public class BitcoindClient {
|
||||
|
||||
private final List<String> pruneWarnedDescriptors = new ArrayList<>();
|
||||
|
||||
private final Map<String, MempoolEntry> mempoolEntries = new ConcurrentHashMap<>();
|
||||
private final Map<Sha256Hash, VsizeFeerate> mempoolEntries = new ConcurrentHashMap<>();
|
||||
private MempoolEntriesState mempoolEntriesState = MempoolEntriesState.UNINITIALIZED;
|
||||
private long timerTaskCount;
|
||||
|
||||
@@ -141,11 +141,13 @@ public class BitcoindClient {
|
||||
boolean exists = listWalletDirResult.wallets().stream().anyMatch(walletDirResult -> walletDirResult.name().equals(CORE_WALLET_NAME));
|
||||
legacyWalletExists = listWalletDirResult.wallets().stream().anyMatch(walletDirResult -> walletDirResult.name().equals(Bwt.DEFAULT_CORE_WALLET));
|
||||
|
||||
if(!exists) {
|
||||
List<String> loadedWallets = getBitcoindService().listWallets();
|
||||
boolean loaded = loadedWallets.contains(CORE_WALLET_NAME);
|
||||
|
||||
if(!exists && !loaded) {
|
||||
getBitcoindService().createWallet(CORE_WALLET_NAME, true, true, "", true, true, true, false);
|
||||
} else {
|
||||
List<String> wallets = getBitcoindService().listWallets();
|
||||
if(!wallets.contains(CORE_WALLET_NAME)) {
|
||||
if(!loaded) {
|
||||
getBitcoindService().loadWallet(CORE_WALLET_NAME, true);
|
||||
}
|
||||
}
|
||||
@@ -155,7 +157,16 @@ public class BitcoindClient {
|
||||
}
|
||||
|
||||
private ListSinceBlock getListSinceBlock(String blockHash) {
|
||||
return getBitcoindService().listSinceBlock(blockHash, 1, true, true, true);
|
||||
try {
|
||||
return getBitcoindService().listSinceBlock(blockHash, 1, true, true, true);
|
||||
} catch(JsonRpcException e) {
|
||||
if(e.getErrorMessage() != null && e.getErrorMessage().getMessage() != null && e.getErrorMessage().getMessage().contains("is not loaded")) {
|
||||
getBitcoindService().loadWallet(CORE_WALLET_NAME, true);
|
||||
return getBitcoindService().listSinceBlock(blockHash, 1, true, true, true);
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public void importWallets(Collection<Wallet> wallets) throws ImportFailedException {
|
||||
@@ -530,18 +541,20 @@ public class BitcoindClient {
|
||||
mempoolEntriesState = MempoolEntriesState.INITIALIZING;
|
||||
|
||||
long start = System.currentTimeMillis();
|
||||
Set<String> txids = getBitcoindService().getRawMempool();
|
||||
Set<Sha256Hash> txids = getBitcoindService().getRawMempool();
|
||||
long end = System.currentTimeMillis();
|
||||
|
||||
if(end - start < 1000) {
|
||||
//Fast system, fetch all mempool data at once
|
||||
mempoolEntries.putAll(getBitcoindService().getRawMempool(true));
|
||||
Map<Sha256Hash, VsizeFeerate> entries = getBitcoindService().getRawMempool(true).entrySet().stream()
|
||||
.collect(Collectors.toMap(Map.Entry::getKey, entry -> entry.getValue().getVsizeFeerate(), (u, v) -> u, HashMap::new));
|
||||
mempoolEntries.putAll(entries);
|
||||
} else {
|
||||
//Slow system, fetch mempool entries one-by-one to avoid risking a node crash
|
||||
for(String txid : txids) {
|
||||
for(Sha256Hash txid : txids) {
|
||||
try {
|
||||
MempoolEntry mempoolEntry = getBitcoindService().getMempoolEntry(txid);
|
||||
mempoolEntries.put(txid, mempoolEntry);
|
||||
MempoolEntry mempoolEntry = getBitcoindService().getMempoolEntry(txid.toString());
|
||||
mempoolEntries.put(txid, mempoolEntry.getVsizeFeerate());
|
||||
} catch(JsonRpcException e) {
|
||||
//ignore, probably tx has been removed from mempool
|
||||
}
|
||||
@@ -552,23 +565,23 @@ public class BitcoindClient {
|
||||
}
|
||||
|
||||
public void updateMempoolEntries() {
|
||||
Set<String> txids = getBitcoindService().getRawMempool();
|
||||
Set<Sha256Hash> txids = getBitcoindService().getRawMempool();
|
||||
|
||||
Set<String> removed = new HashSet<>(Sets.difference(mempoolEntries.keySet(), txids));
|
||||
Set<Sha256Hash> removed = new HashSet<>(Sets.difference(mempoolEntries.keySet(), txids));
|
||||
mempoolEntries.keySet().removeAll(removed);
|
||||
|
||||
Set<String> added = Sets.difference(txids, mempoolEntries.keySet());
|
||||
for(String txid : added) {
|
||||
Set<Sha256Hash> added = Sets.difference(txids, mempoolEntries.keySet());
|
||||
for(Sha256Hash txid : added) {
|
||||
try {
|
||||
MempoolEntry mempoolEntry = getBitcoindService().getMempoolEntry(txid);
|
||||
mempoolEntries.put(txid, mempoolEntry);
|
||||
MempoolEntry mempoolEntry = getBitcoindService().getMempoolEntry(txid.toString());
|
||||
mempoolEntries.put(txid, mempoolEntry.getVsizeFeerate());
|
||||
} catch(JsonRpcException e) {
|
||||
//ignore, probably tx has been removed from mempool
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Map<String, MempoolEntry> getMempoolEntries() {
|
||||
public Map<Sha256Hash, VsizeFeerate> getMempoolEntries() {
|
||||
return mempoolEntries;
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -6,6 +6,7 @@ import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcMethod;
|
||||
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcOptional;
|
||||
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcParam;
|
||||
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcService;
|
||||
import com.sparrowwallet.drongo.protocol.Sha256Hash;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -24,10 +25,10 @@ public interface BitcoindClientService {
|
||||
FeeInfo estimateSmartFee(@JsonRpcParam("conf_target") int blocks);
|
||||
|
||||
@JsonRpcMethod("getrawmempool")
|
||||
Set<String> getRawMempool();
|
||||
Set<Sha256Hash> getRawMempool();
|
||||
|
||||
@JsonRpcMethod("getrawmempool")
|
||||
Map<String, MempoolEntry> getRawMempool(@JsonRpcParam("verbose") boolean verbose);
|
||||
Map<Sha256Hash, MempoolEntry> getRawMempool(@JsonRpcParam("verbose") boolean verbose);
|
||||
|
||||
@JsonRpcMethod("getmempoolinfo")
|
||||
MempoolInfo getMempoolInfo();
|
||||
|
||||
@@ -12,4 +12,8 @@ public record MempoolEntry(int vsize, int ancestorsize, boolean bip125_replaceab
|
||||
public TxEntry getTxEntry(String txid) {
|
||||
return new TxEntry(hasUnconfirmedParents() ? -1 : 0, 0, txid);
|
||||
}
|
||||
|
||||
public VsizeFeerate getVsizeFeerate() {
|
||||
return new VsizeFeerate(vsize, fees().base());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.sparrowwallet.sparrow.net.cormorant.bitcoind;
|
||||
|
||||
import com.sparrowwallet.drongo.protocol.Transaction;
|
||||
|
||||
public class VsizeFeerate implements Comparable<VsizeFeerate> {
|
||||
private final int vsize;
|
||||
private final float feerate;
|
||||
|
||||
public VsizeFeerate(int vsize, double fee) {
|
||||
this.vsize = vsize;
|
||||
double feeRate = fee / vsize * Transaction.SATOSHIS_PER_BITCOIN;
|
||||
//Round down to 0.1 sats/vb precision
|
||||
this.feerate = (float) (Math.floor(10 * feeRate) / 10);
|
||||
}
|
||||
|
||||
public int getVsize() {
|
||||
return vsize;
|
||||
}
|
||||
|
||||
public double getFeerate() {
|
||||
return feerate;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int compareTo(VsizeFeerate o) {
|
||||
return Float.compare(o.feerate, feerate);
|
||||
}
|
||||
}
|
||||
+7
-23
@@ -5,7 +5,7 @@ import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcMethod;
|
||||
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcOptional;
|
||||
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcParam;
|
||||
import com.github.arteam.simplejsonrpc.core.annotation.JsonRpcService;
|
||||
import com.sparrowwallet.drongo.protocol.Transaction;
|
||||
import com.sparrowwallet.drongo.protocol.Sha256Hash;
|
||||
import com.sparrowwallet.sparrow.EventManager;
|
||||
import com.sparrowwallet.sparrow.SparrowWallet;
|
||||
import com.sparrowwallet.sparrow.event.MempoolEntriesInitializedEvent;
|
||||
@@ -79,22 +79,22 @@ public class ElectrumServerService {
|
||||
|
||||
return Collections.emptyList();
|
||||
} else {
|
||||
Map<String, MempoolEntry> mempoolEntries = bitcoindClient.getMempoolEntries();
|
||||
|
||||
List<VsizeFeerate> vsizeFeerates = mempoolEntries.values().stream().map(entry -> new VsizeFeerate(entry.vsize(), entry.fees().base())).sorted().toList();
|
||||
Map<Sha256Hash, VsizeFeerate> mempoolEntries = bitcoindClient.getMempoolEntries();
|
||||
List<VsizeFeerate> vsizeFeerates = new ArrayList<>(mempoolEntries.values());
|
||||
Collections.sort(vsizeFeerates);
|
||||
|
||||
List<List<Number>> histogram = new ArrayList<>();
|
||||
long binSize = 0;
|
||||
double lastFeerate = 0.0;
|
||||
|
||||
for(VsizeFeerate vsizeFeerate : vsizeFeerates) {
|
||||
if(binSize > VSIZE_BIN_WIDTH && Math.abs(lastFeerate - vsizeFeerate.feerate) > 0.0d) {
|
||||
if(binSize > VSIZE_BIN_WIDTH && Math.abs(lastFeerate - vsizeFeerate.getFeerate()) > 0.0d) {
|
||||
// vsize of transactions paying >= last_feerate
|
||||
histogram.add(List.of(lastFeerate, binSize));
|
||||
binSize = 0;
|
||||
}
|
||||
binSize += vsizeFeerate.vsize;
|
||||
lastFeerate = vsizeFeerate.feerate;
|
||||
binSize += vsizeFeerate.getVsize();
|
||||
lastFeerate = vsizeFeerate.getFeerate();
|
||||
}
|
||||
|
||||
if(binSize > 0) {
|
||||
@@ -213,20 +213,4 @@ public class ElectrumServerService {
|
||||
}
|
||||
}
|
||||
|
||||
private static class VsizeFeerate implements Comparable<VsizeFeerate> {
|
||||
private final int vsize;
|
||||
private final double feerate;
|
||||
|
||||
public VsizeFeerate(int vsize, double fee) {
|
||||
this.vsize = vsize;
|
||||
double feeRate = fee / vsize * Transaction.SATOSHIS_PER_BITCOIN;
|
||||
//Round down to 0.1 sats/vb precision
|
||||
this.feerate = Math.floor(10 * feeRate) / 10;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int compareTo(VsizeFeerate o) {
|
||||
return Double.compare(o.feerate, feerate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,11 +243,13 @@ public class PayNymController {
|
||||
|
||||
public void showQR(ActionEvent event) {
|
||||
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(getMasterWallet().getPaymentCode().toString());
|
||||
qrDisplayDialog.initOwner(payNymName.getScene().getWindow());
|
||||
qrDisplayDialog.showAndWait();
|
||||
}
|
||||
|
||||
public void scanQR(ActionEvent event) {
|
||||
QRScanDialog qrScanDialog = new QRScanDialog();
|
||||
qrScanDialog.initOwner(payNymName.getScene().getWindow());
|
||||
Optional<QRScanDialog.Result> optResult = qrScanDialog.showAndWait();
|
||||
if(optResult.isPresent()) {
|
||||
QRScanDialog.Result result = optResult.get();
|
||||
@@ -373,6 +375,7 @@ public class PayNymController {
|
||||
Optional<ButtonType> optButtonType = AppServices.showAlertDialog("Link contacts?", "Some contacts were found that may be already linked. Link these contacts? Your password is required to check.", Alert.AlertType.CONFIRMATION, ButtonType.NO, ButtonType.YES);
|
||||
if(optButtonType.isPresent() && optButtonType.get() == ButtonType.YES) {
|
||||
WalletPasswordDialog dlg = new WalletPasswordDialog(wallet.getMasterName(), WalletPasswordDialog.PasswordRequirement.LOAD);
|
||||
dlg.initOwner(payNymName.getScene().getWindow());
|
||||
Optional<SecureString> password = dlg.showAndWait();
|
||||
if(password.isPresent()) {
|
||||
Storage.DecryptWalletService decryptWalletService = new Storage.DecryptWalletService(wallet.copy(), password.get());
|
||||
@@ -465,7 +468,7 @@ public class PayNymController {
|
||||
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())));
|
||||
EventManager.get().post(new SendActionEvent(wallet, new ArrayList<>(wallet.getSpendableUtxos().keySet())));
|
||||
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(wallet, List.of(payment), List.of(new byte[80]), paymentCode)));
|
||||
closeProperty.set(true);
|
||||
} else {
|
||||
@@ -495,6 +498,7 @@ public class PayNymController {
|
||||
Storage storage = AppServices.get().getOpenWallets().get(wallet);
|
||||
if(wallet.isEncrypted()) {
|
||||
WalletPasswordDialog dlg = new WalletPasswordDialog(wallet.getMasterName(), WalletPasswordDialog.PasswordRequirement.LOAD);
|
||||
dlg.initOwner(payNymName.getScene().getWindow());
|
||||
Optional<SecureString> password = dlg.showAndWait();
|
||||
if(password.isPresent()) {
|
||||
Storage.DecryptWalletService decryptWalletService = new Storage.DecryptWalletService(wallet.copy(), password.get());
|
||||
@@ -604,10 +608,10 @@ public class PayNymController {
|
||||
boolean includeMempoolOutputs = Config.get().isIncludeMempoolOutputs();
|
||||
|
||||
long noInputsFee = getMasterWallet().getNoInputsFee(payments, feeRate);
|
||||
List<UtxoSelector> utxoSelectors = List.of(utxos == null ? new KnapsackUtxoSelector(noInputsFee) : new PresetUtxoSelector(utxos, true));
|
||||
List<UtxoFilter> utxoFilters = List.of(new FrozenUtxoFilter(), new CoinbaseUtxoFilter(wallet));
|
||||
List<UtxoSelector> utxoSelectors = List.of(utxos == null ? new KnapsackUtxoSelector(noInputsFee) : new PresetUtxoSelector(utxos, true, false));
|
||||
List<TxoFilter> txoFilters = List.of(new SpentTxoFilter(), new FrozenTxoFilter(), new CoinbaseTxoFilter(wallet));
|
||||
|
||||
return wallet.createWalletTransaction(utxoSelectors, utxoFilters, payments, opReturns, Collections.emptySet(), feeRate, minimumFeeRate, null, AppServices.getCurrentBlockHeight(), groupByAddress, includeMempoolOutputs, false);
|
||||
return wallet.createWalletTransaction(utxoSelectors, txoFilters, payments, opReturns, Collections.emptySet(), feeRate, minimumFeeRate, null, AppServices.getCurrentBlockHeight(), groupByAddress, includeMempoolOutputs);
|
||||
}
|
||||
|
||||
private Map<BlockTransaction, WalletNode> getNotificationTransaction(PaymentCode externalPaymentCode) {
|
||||
|
||||
@@ -18,6 +18,7 @@ import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.scene.control.ComboBox;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.util.StringConverter;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -45,6 +46,9 @@ public class GeneralPreferencesController extends PreferencesDetailController {
|
||||
@FXML
|
||||
private ComboBox<ExchangeSource> exchangeSource;
|
||||
|
||||
@FXML
|
||||
private Label currenciesLoadWarning;
|
||||
|
||||
@FXML
|
||||
private UnlabeledToggleSwitch loadRecentWallets;
|
||||
|
||||
@@ -87,6 +91,9 @@ public class GeneralPreferencesController extends PreferencesDetailController {
|
||||
EventManager.get().post(new FeeRatesSourceChangedEvent(newValue));
|
||||
});
|
||||
|
||||
currenciesLoadWarning.managedProperty().bind(currenciesLoadWarning.visibleProperty());
|
||||
currenciesLoadWarning.setVisible(false);
|
||||
|
||||
blockExplorers.setItems(getBlockExplorerList());
|
||||
blockExplorers.setConverter(new StringConverter<>() {
|
||||
@Override
|
||||
@@ -111,6 +118,7 @@ public class GeneralPreferencesController extends PreferencesDetailController {
|
||||
if(newValue != null) {
|
||||
if(newValue == CUSTOM_BLOCK_EXPLORER) {
|
||||
TextfieldDialog textfieldDialog = new TextfieldDialog();
|
||||
textfieldDialog.initOwner(blockExplorers.getScene().getWindow());
|
||||
textfieldDialog.setTitle("Enter Block Explorer URL");
|
||||
textfieldDialog.setHeaderText("Enter the URL of the block explorer.\n\nIf present, the characters {0} will be replaced with the txid.\nFor example, https://localhost or https://localhost/tx/{0}\n");
|
||||
textfieldDialog.getEditor().setPromptText("https://localhost");
|
||||
@@ -237,6 +245,8 @@ public class GeneralPreferencesController extends PreferencesDetailController {
|
||||
fiatCurrency.setDisable(true);
|
||||
}
|
||||
|
||||
currenciesLoadWarning.setVisible(exchangeSource.getValue() != ExchangeSource.NONE && currencies.isEmpty());
|
||||
|
||||
//Always fire event regardless of previous selection to update rates
|
||||
EventManager.get().post(new FiatCurrencySelectedEvent(exchangeSource.getValue(), fiatCurrency.getValue()));
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import java.util.stream.Collectors;
|
||||
public class ServerAliasDialog extends Dialog<Server> {
|
||||
private final ServerType serverType;
|
||||
private final TableView<ServerEntry> serverTable;
|
||||
private final Button closeButton;
|
||||
|
||||
public ServerAliasDialog(ServerType serverType) {
|
||||
this.serverType = serverType;
|
||||
@@ -76,6 +77,7 @@ public class ServerAliasDialog extends Dialog<Server> {
|
||||
|
||||
Button selectButton = (Button)dialogPane.lookupButton(selectButtonType);
|
||||
Button deleteButton = (Button)dialogPane.lookupButton(deleteButtonType);
|
||||
closeButton = (Button)dialogPane.lookupButton(ButtonType.CLOSE);
|
||||
selectButton.setDefaultButton(true);
|
||||
selectButton.setDisable(true);
|
||||
deleteButton.setDisable(true);
|
||||
@@ -112,8 +114,14 @@ public class ServerAliasDialog extends Dialog<Server> {
|
||||
serverTable.getItems().remove(serverEntry);
|
||||
if(serverType == ServerType.BITCOIN_CORE) {
|
||||
Config.get().removeRecentCoreServer(serverEntry.getServer());
|
||||
if(serverEntry.getServer().equals(Config.get().getCoreServer()) && !serverTable.getItems().isEmpty()) {
|
||||
closeButton.setDisable(true);
|
||||
}
|
||||
} else {
|
||||
Config.get().removeRecentElectrumServer(serverEntry.getServer());
|
||||
if(serverEntry.getServer().equals(Config.get().getElectrumServer()) && !serverTable.getItems().isEmpty()) {
|
||||
closeButton.setDisable(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -282,6 +282,7 @@ public class ServerPreferencesController extends PreferencesDetailController {
|
||||
if(newValue != null) {
|
||||
if(newValue == MANAGE_ALIASES_SERVER) {
|
||||
ServerAliasDialog serverAliasDialog = new ServerAliasDialog(ServerType.BITCOIN_CORE);
|
||||
serverAliasDialog.initOwner(recentCoreServers.getScene().getWindow());
|
||||
Optional<Server> optServer = serverAliasDialog.showAndWait();
|
||||
recentCoreServers.setItems(getObservableServerList(Config.get().getRecentCoreServers()));
|
||||
Server selectedServer = optServer.orElseGet(() -> Config.get().getCoreServer());
|
||||
@@ -306,6 +307,7 @@ public class ServerPreferencesController extends PreferencesDetailController {
|
||||
if(newValue != null) {
|
||||
if(newValue == MANAGE_ALIASES_SERVER) {
|
||||
ServerAliasDialog serverAliasDialog = new ServerAliasDialog(ServerType.ELECTRUM_SERVER);
|
||||
serverAliasDialog.initOwner(recentElectrumServers.getScene().getWindow());
|
||||
Optional<Server> optServer = serverAliasDialog.showAndWait();
|
||||
recentElectrumServers.setItems(getObservableServerList(Config.get().getRecentElectrumServers()));
|
||||
Server selectedServer = optServer.orElseGet(() -> Config.get().getElectrumServer());
|
||||
@@ -646,6 +648,8 @@ public class ServerPreferencesController extends PreferencesDetailController {
|
||||
reason += "\n\nSee https://sparrowwallet.com/docs/connect-node.html";
|
||||
} else if(reason != null && (reason.startsWith("Cannot connect to hidden service"))) {
|
||||
reason += " on the server. Check that the onion address and port are correct, and that both Tor and the Electrum server are running on the node. Usually SSL is not enabled, and the port is 50001.";
|
||||
} else if(reason != null && (reason.startsWith("Cannot find Bitcoin Core cookie file at"))) {
|
||||
reason += "\n\nMake sure server=1 has been added to bitcoin.conf";
|
||||
}
|
||||
|
||||
testResults.setText("Could not connect:\n\n" + reason);
|
||||
|
||||
@@ -294,11 +294,9 @@ public class CounterpartyController extends SorobanController {
|
||||
sorobanProgressLabel.setText("Creating mix transaction...");
|
||||
|
||||
Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId);
|
||||
Map<BlockTransactionHashIndex, WalletNode> walletUtxos = wallet.getWalletUtxos();
|
||||
Map<BlockTransactionHashIndex, WalletNode> walletUtxos = wallet.getSpendableUtxos();
|
||||
for(Map.Entry<BlockTransactionHashIndex, WalletNode> entry : walletUtxos.entrySet()) {
|
||||
if(entry.getKey().getStatus() != Status.FROZEN) {
|
||||
counterpartyCahootsWallet.addUtxo(entry.getValue(), wallet.getWalletTransaction(entry.getKey().getHash()), (int)entry.getKey().getIndex());
|
||||
}
|
||||
counterpartyCahootsWallet.addUtxo(entry.getValue(), wallet.getWalletTransaction(entry.getKey().getHash()), (int)entry.getKey().getIndex());
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -411,12 +409,14 @@ public class CounterpartyController extends SorobanController {
|
||||
|
||||
public void showPayNym(ActionEvent event) {
|
||||
PayNymDialog payNymDialog = new PayNymDialog(walletId);
|
||||
payNymDialog.initOwner(payNym.getScene().getWindow());
|
||||
payNymDialog.showAndWait();
|
||||
}
|
||||
|
||||
public void showPayNymQR(ActionEvent event) {
|
||||
Wallet masterWallet = wallet.isMasterWallet() ? wallet : wallet.getMasterWallet();
|
||||
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(masterWallet.getPaymentCode().toString());
|
||||
qrDisplayDialog.initOwner(payNym.getScene().getWindow());
|
||||
qrDisplayDialog.showAndWait();
|
||||
}
|
||||
|
||||
|
||||
@@ -363,6 +363,7 @@ public class InitiatorController extends SorobanController {
|
||||
if(wallet.isEncrypted()) {
|
||||
Wallet copy = wallet.copy();
|
||||
WalletPasswordDialog dlg = new WalletPasswordDialog(copy.getMasterName(), WalletPasswordDialog.PasswordRequirement.LOAD);
|
||||
dlg.initOwner(payNymFollowers.getScene().getWindow());
|
||||
Optional<SecureString> password = dlg.showAndWait();
|
||||
if(password.isPresent()) {
|
||||
Storage storage = AppServices.get().getOpenWallets().get(wallet);
|
||||
@@ -466,11 +467,9 @@ public class InitiatorController extends SorobanController {
|
||||
Soroban soroban = AppServices.getSorobanServices().getSoroban(walletId);
|
||||
|
||||
Payment payment = walletTransaction.getPayments().get(0);
|
||||
Map<BlockTransactionHashIndex, WalletNode> firstSetUtxos = walletTransaction.isCoinControlUsed() ? walletTransaction.getSelectedUtxoSets().get(0) : wallet.getWalletUtxos();
|
||||
Map<BlockTransactionHashIndex, WalletNode> firstSetUtxos = walletTransaction.isCoinControlUsed() ? walletTransaction.getSelectedUtxoSets().get(0) : wallet.getSpendableUtxos();
|
||||
for(Map.Entry<BlockTransactionHashIndex, WalletNode> entry : firstSetUtxos.entrySet()) {
|
||||
if(entry.getKey().getStatus() != Status.FROZEN) {
|
||||
initiatorCahootsWallet.addUtxo(entry.getValue(), wallet.getWalletTransaction(entry.getKey().getHash()), (int)entry.getKey().getIndex());
|
||||
}
|
||||
initiatorCahootsWallet.addUtxo(entry.getValue(), wallet.getWalletTransaction(entry.getKey().getHash()), (int)entry.getKey().getIndex());
|
||||
}
|
||||
|
||||
SorobanCahootsService sorobanCahootsService = soroban.getSorobanCahootsService(initiatorCahootsWallet);
|
||||
@@ -651,6 +650,7 @@ public class InitiatorController extends SorobanController {
|
||||
|
||||
public void findPayNym(ActionEvent event) {
|
||||
PayNymDialog payNymDialog = new PayNymDialog(walletId, PayNymDialog.Operation.SELECT, false);
|
||||
payNymDialog.initOwner(payNymFollowers.getScene().getWindow());
|
||||
Optional<PayNym> optPayNym = payNymDialog.showAndWait();
|
||||
optPayNym.ifPresent(payNym -> {
|
||||
counterpartyPayNymName.set(payNym.nymName());
|
||||
|
||||
@@ -137,6 +137,7 @@ public class InitiatorDialog extends Dialog<Transaction> {
|
||||
private void acceptAndBroadcast(InitiatorController initiatorController, String walletId, Wallet wallet) {
|
||||
if(confirmationRequired && wallet.isEncrypted()) {
|
||||
WalletPasswordDialog dlg = new WalletPasswordDialog(wallet.getMasterName(), WalletPasswordDialog.PasswordRequirement.LOAD);
|
||||
dlg.initOwner(getDialogPane().getScene().getWindow());
|
||||
Optional<SecureString> password = dlg.showAndWait();
|
||||
if(password.isPresent()) {
|
||||
Storage storage = AppServices.get().getOpenWallets().get(wallet);
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.sparrowwallet.sparrow.terminal;
|
||||
|
||||
import com.google.common.net.HostAndPort;
|
||||
import com.googlecode.lanterna.gui2.Label;
|
||||
import com.sparrowwallet.sparrow.AppServices;
|
||||
import com.sparrowwallet.sparrow.io.Config;
|
||||
import javafx.concurrent.ScheduledService;
|
||||
import javafx.concurrent.Task;
|
||||
import javafx.concurrent.Worker;
|
||||
import javafx.util.Duration;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.Socket;
|
||||
|
||||
public class ProxyStatusLabel extends Label {
|
||||
private static final Logger log = LoggerFactory.getLogger(ProxyStatusLabel.class);
|
||||
|
||||
private final ProxyConnectionTest proxyConnectionTest = new ProxyConnectionTest();
|
||||
|
||||
public ProxyStatusLabel() {
|
||||
super("");
|
||||
}
|
||||
|
||||
public void update() {
|
||||
if(!AppServices.isUsingProxy()) {
|
||||
proxyConnectionTest.cancel();
|
||||
getTextGUI().getGUIThread().invokeLater(() -> setText(""));
|
||||
} else if(!Config.get().isUseProxy()) {
|
||||
proxyConnectionTest.cancel();
|
||||
if(AppServices.isTorRunning()) {
|
||||
getTextGUI().getGUIThread().invokeLater(() -> setText("Proxy enabled"));
|
||||
}
|
||||
} else if(!proxyConnectionTest.isRunning()) {
|
||||
if(proxyConnectionTest.getState() == Worker.State.CANCELLED || proxyConnectionTest.getState() == Worker.State.FAILED) {
|
||||
proxyConnectionTest.reset();
|
||||
}
|
||||
proxyConnectionTest.setPeriod(Duration.seconds(20.0));
|
||||
proxyConnectionTest.setBackoffStrategy(null);
|
||||
proxyConnectionTest.setOnSucceeded(workerStateEvent -> {
|
||||
getTextGUI().getGUIThread().invokeLater(() -> setText("Proxy enabled"));
|
||||
});
|
||||
proxyConnectionTest.setOnFailed(workerStateEvent -> {
|
||||
getTextGUI().getGUIThread().invokeLater(() -> setText("Proxy error!"));
|
||||
log.warn("Failed to connect to external Tor proxy: " + workerStateEvent.getSource().getException().getMessage());
|
||||
});
|
||||
proxyConnectionTest.start();
|
||||
}
|
||||
}
|
||||
|
||||
private static class ProxyConnectionTest extends ScheduledService<Void> {
|
||||
@Override
|
||||
protected Task<Void> createTask() {
|
||||
return new Task<>() {
|
||||
protected Void call() throws IOException {
|
||||
HostAndPort proxyHostAndPort = HostAndPort.fromString(Config.get().getProxyServer());
|
||||
Socket socket = new Socket(proxyHostAndPort.getHost(), proxyHostAndPort.getPort());
|
||||
socket.close();
|
||||
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import com.sparrowwallet.sparrow.event.*;
|
||||
import com.sparrowwallet.sparrow.io.Config;
|
||||
import com.sparrowwallet.sparrow.net.ServerType;
|
||||
import javafx.animation.*;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.property.DoubleProperty;
|
||||
import javafx.beans.property.SimpleDoubleProperty;
|
||||
import javafx.util.Duration;
|
||||
@@ -20,6 +21,7 @@ public class SparrowTextGui extends MultiWindowTextGUI {
|
||||
private final Panel titleBar;
|
||||
private final Panel statusBar;
|
||||
|
||||
private final ProxyStatusLabel proxyStatusLabel;
|
||||
private final Label connectedLabel;
|
||||
private final Label statusLabel;
|
||||
private final ProgressBar statusProgress;
|
||||
@@ -36,8 +38,10 @@ public class SparrowTextGui extends MultiWindowTextGUI {
|
||||
|
||||
Panel panel = new Panel(new BorderLayout());
|
||||
|
||||
titleBar = new Panel(new GridLayout(2));
|
||||
titleBar = new Panel(new GridLayout(3));
|
||||
new Label("Sparrow Terminal").addTo(titleBar);
|
||||
this.proxyStatusLabel = new ProxyStatusLabel();
|
||||
titleBar.addComponent(proxyStatusLabel, GridLayout.createLayoutData(GridLayout.Alignment.END, GridLayout.Alignment.CENTER, true, false));
|
||||
this.connectedLabel = new Label("Disconnected");
|
||||
titleBar.addComponent(connectedLabel, GridLayout.createLayoutData(GridLayout.Alignment.END, GridLayout.Alignment.CENTER, true, false));
|
||||
panel.addComponent(titleBar, BorderLayout.Location.TOP);
|
||||
@@ -77,6 +81,10 @@ public class SparrowTextGui extends MultiWindowTextGUI {
|
||||
});
|
||||
}
|
||||
|
||||
private void updateProxyStatusLabel() {
|
||||
Platform.runLater(proxyStatusLabel::update);
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void connectionStart(ConnectionStartEvent event) {
|
||||
statusUpdated(new StatusEvent(event.getStatus(), 120));
|
||||
@@ -85,12 +93,14 @@ public class SparrowTextGui extends MultiWindowTextGUI {
|
||||
@Subscribe
|
||||
public void connectionFailed(ConnectionFailedEvent event) {
|
||||
setDisconnectedLabel();
|
||||
updateProxyStatusLabel();
|
||||
statusUpdated(new StatusEvent("Connection failed: " + event.getMessage()));
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void connection(ConnectionEvent event) {
|
||||
setConnectedLabel(event.getBlockHeight());
|
||||
updateProxyStatusLabel();
|
||||
statusUpdated(new StatusEvent("Connected to " + Config.get().getServerDisplayName() + " at height " + event.getBlockHeight()));
|
||||
}
|
||||
|
||||
@@ -107,6 +117,21 @@ public class SparrowTextGui extends MultiWindowTextGUI {
|
||||
setConnectedLabel(event.getHeight());
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void torBootStatus(TorBootStatusEvent event) {
|
||||
updateProxyStatusLabel();
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void torFailedStatus(TorFailedStatusEvent event) {
|
||||
updateProxyStatusLabel();
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void torReadyStatus(TorReadyStatusEvent event) {
|
||||
updateProxyStatusLabel();
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void statusUpdated(StatusEvent event) {
|
||||
getGUIThread().invokeLater(() -> statusLabel.setText(event.getStatus()));
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@ public class PublicElectrumDialog extends ServerProxyDialog {
|
||||
url.setSelectedItem(PublicElectrumServer.fromServer(Config.get().getPublicElectrumServer()));
|
||||
url.addListener((selectedIndex, previousSelection, changedByUserInteraction) -> {
|
||||
if(selectedIndex != previousSelection) {
|
||||
Config.get().setPublicElectrumServer(PublicElectrumServer.values()[selectedIndex].getServer());
|
||||
Config.get().setPublicElectrumServer(PublicElectrumServer.getServers().get(selectedIndex).getServer());
|
||||
}
|
||||
});
|
||||
mainPanel.addComponent(url);
|
||||
|
||||
@@ -118,6 +118,20 @@ public class UtxosDialog extends WalletDialog {
|
||||
updateMixSelectedButton();
|
||||
}
|
||||
});
|
||||
utxos.setInputFilter((interactable, keyStroke) -> {
|
||||
if(keyStroke.getCharacter() == Character.valueOf('f')) {
|
||||
if(utxos.getTableModel().getRowCount() > utxos.getSelectedRow()) {
|
||||
TableCell dateCell = utxos.getTableModel().getRow(utxos.getSelectedRow()).get(0);
|
||||
if(dateCell.getEntry() instanceof UtxoEntry utxoEntry) {
|
||||
utxoEntry.getHashIndex().setStatus(utxoEntry.getHashIndex().getStatus() == Status.FROZEN ? null : Status.FROZEN);
|
||||
utxos.invalidate();
|
||||
EventManager.get().post(new WalletUtxoStatusChangedEvent(utxoEntry.getWallet(), List.of(utxoEntry.getHashIndex())));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
updateLabels(walletUtxosEntry);
|
||||
updateHistory(getWalletForm().getWalletUtxosEntry());
|
||||
@@ -192,6 +206,9 @@ public class UtxosDialog extends WalletDialog {
|
||||
SparrowTerminal.get().getGuiThread().invokeLater(() -> {
|
||||
TableModel<TableCell> tableModel = getTableModel(walletUtxosEntry);
|
||||
utxos.setTableModel(tableModel);
|
||||
if(utxos.getTheme() != null && utxos.getRenderer().getViewTopRow() >= tableModel.getRowCount()) {
|
||||
utxos.getRenderer().setViewTopRow(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.sparrowwallet.sparrow.terminal.wallet.table;
|
||||
|
||||
import com.sparrowwallet.drongo.wallet.Status;
|
||||
import com.sparrowwallet.sparrow.wallet.Entry;
|
||||
import com.sparrowwallet.sparrow.wallet.TransactionEntry;
|
||||
import com.sparrowwallet.sparrow.wallet.UtxoEntry;
|
||||
@@ -24,6 +25,10 @@ public class DateTableCell extends TableCell {
|
||||
return "(*) " + unselected.substring(Math.min(4, unselected.length()));
|
||||
}
|
||||
|
||||
if(entry instanceof UtxoEntry utxoEntry && utxoEntry.getHashIndex().getStatus() == Status.FROZEN) {
|
||||
return "(f) " + unselected.substring(Math.min(4, unselected.length()));
|
||||
}
|
||||
|
||||
return unselected;
|
||||
}
|
||||
|
||||
|
||||
@@ -95,6 +95,9 @@ public class HeadersController extends TransactionFormController implements Init
|
||||
@FXML
|
||||
private TransactionDiagram transactionDiagram;
|
||||
|
||||
@FXML
|
||||
private TransactionDiagramLabel transactionDiagramLabel;
|
||||
|
||||
@FXML
|
||||
private IntegerSpinner version;
|
||||
|
||||
@@ -173,12 +176,6 @@ public class HeadersController extends TransactionFormController implements Init
|
||||
@FXML
|
||||
private CopyableLabel blockTimestamp;
|
||||
|
||||
@FXML
|
||||
private Field blockHashField;
|
||||
|
||||
@FXML
|
||||
private IdLabel blockHash;
|
||||
|
||||
@FXML
|
||||
private Form signingWalletForm;
|
||||
|
||||
@@ -235,7 +232,7 @@ public class HeadersController extends TransactionFormController implements Init
|
||||
|
||||
private ElectrumServer.TransactionMempoolService transactionMempoolService;
|
||||
|
||||
private final Map<Integer, String> outputIndexLabels = new HashMap<>();
|
||||
private final Map<Integer, String> outputIndexLabels = new TreeMap<>();
|
||||
|
||||
@Override
|
||||
public void initialize(URL location, ResourceBundle resources) {
|
||||
@@ -440,6 +437,7 @@ public class HeadersController extends TransactionFormController implements Init
|
||||
updateFee(feeAmt);
|
||||
}
|
||||
|
||||
transactionDiagram.labelProperty().set(transactionDiagramLabel);
|
||||
transactionDiagram.update(getWalletTransaction(headersForm.getInputTransactions()));
|
||||
|
||||
blockchainForm.managedProperty().bind(blockchainForm.visibleProperty());
|
||||
@@ -628,7 +626,11 @@ public class HeadersController extends TransactionFormController implements Init
|
||||
payments.add(new Payment(txOutput.getScript().getToAddress(), ".." + changeNode + " (Mix)", txOutput.getValue(), false, Payment.Type.MIX));
|
||||
}
|
||||
} else {
|
||||
changeMap.put(changeNode, txOutput.getValue());
|
||||
if(changeMap.containsKey(changeNode)) {
|
||||
payments.add(new Payment(txOutput.getScript().getToAddress(), headersForm.getName(), txOutput.getValue(), false, Payment.Type.DEFAULT));
|
||||
} else {
|
||||
changeMap.put(changeNode, txOutput.getValue());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Payment.Type paymentType = Payment.Type.DEFAULT;
|
||||
@@ -738,11 +740,11 @@ public class HeadersController extends TransactionFormController implements Init
|
||||
|
||||
blockHeightField.managedProperty().bind(blockHeightField.visibleProperty());
|
||||
blockTimestampField.managedProperty().bind(blockTimestampField.visibleProperty());
|
||||
blockHashField.managedProperty().bind(blockHashField.visibleProperty());
|
||||
|
||||
if(blockTransaction.getHeight() > 0) {
|
||||
blockHeightField.setVisible(true);
|
||||
blockHeight.setText(Integer.toString(blockTransaction.getHeight()));
|
||||
blockHeight.setContextMenu(new BlockHeightContextMenu(blockTransaction));
|
||||
} else {
|
||||
blockHeightField.setVisible(false);
|
||||
}
|
||||
@@ -751,17 +753,10 @@ public class HeadersController extends TransactionFormController implements Init
|
||||
blockTimestampField.setVisible(true);
|
||||
SimpleDateFormat dateFormat = new SimpleDateFormat(BLOCK_TIMESTAMP_DATE_FORMAT);
|
||||
blockTimestamp.setText(dateFormat.format(blockTransaction.getDate()));
|
||||
blockTimestamp.setContextMenu(new BlockHeightContextMenu(blockTransaction));
|
||||
} else {
|
||||
blockTimestampField.setVisible(false);
|
||||
}
|
||||
|
||||
if(blockTransaction.getBlockHash() != null && !blockTransaction.getBlockHash().equals(Sha256Hash.ZERO_HASH)) {
|
||||
blockHashField.setVisible(true);
|
||||
blockHash.setText(blockTransaction.getBlockHash().toString());
|
||||
blockHash.setContextMenu(new BlockHeightContextMenu(blockTransaction.getBlockHash()));
|
||||
} else {
|
||||
blockHashField.setVisible(false);
|
||||
}
|
||||
}
|
||||
|
||||
private void initializeSignButton(Wallet signingWallet) {
|
||||
@@ -801,15 +796,38 @@ public class HeadersController extends TransactionFormController implements Init
|
||||
}
|
||||
|
||||
private static class BlockHeightContextMenu extends ContextMenu {
|
||||
public BlockHeightContextMenu(Sha256Hash blockHash) {
|
||||
MenuItem copyBlockHash = new MenuItem("Copy Block Hash");
|
||||
copyBlockHash.setOnAction(AE -> {
|
||||
hide();
|
||||
ClipboardContent content = new ClipboardContent();
|
||||
content.putString(blockHash.toString());
|
||||
Clipboard.getSystemClipboard().setContent(content);
|
||||
});
|
||||
getItems().add(copyBlockHash);
|
||||
public BlockHeightContextMenu(BlockTransaction blockTransaction) {
|
||||
if(blockTransaction.getHeight() > 0) {
|
||||
MenuItem copyBlockHeight = new MenuItem("Copy Block Height");
|
||||
copyBlockHeight.setOnAction(AE -> {
|
||||
hide();
|
||||
ClipboardContent content = new ClipboardContent();
|
||||
content.putString(Integer.toString(blockTransaction.getHeight()));
|
||||
Clipboard.getSystemClipboard().setContent(content);
|
||||
});
|
||||
getItems().add(copyBlockHeight);
|
||||
}
|
||||
if(blockTransaction.getDate() != null) {
|
||||
MenuItem copyBlockTimestamp = new MenuItem("Copy Block Timestamp");
|
||||
copyBlockTimestamp.setOnAction(AE -> {
|
||||
hide();
|
||||
ClipboardContent content = new ClipboardContent();
|
||||
SimpleDateFormat dateFormat = new SimpleDateFormat(BLOCK_TIMESTAMP_DATE_FORMAT);
|
||||
content.putString(dateFormat.format(blockTransaction.getDate()));
|
||||
Clipboard.getSystemClipboard().setContent(content);
|
||||
});
|
||||
getItems().add(copyBlockTimestamp);
|
||||
}
|
||||
if(blockTransaction.getBlockHash() != null && !blockTransaction.getBlockHash().equals(Sha256Hash.ZERO_HASH)) {
|
||||
MenuItem copyBlockHash = new MenuItem("Copy Block Hash");
|
||||
copyBlockHash.setOnAction(AE -> {
|
||||
hide();
|
||||
ClipboardContent content = new ClipboardContent();
|
||||
content.putString(blockTransaction.getBlockHash().toString());
|
||||
Clipboard.getSystemClipboard().setContent(content);
|
||||
});
|
||||
getItems().add(copyBlockHash);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -874,8 +892,12 @@ public class HeadersController extends TransactionFormController implements Init
|
||||
//Don't include non witness utxo fields for segwit wallets when displaying the PSBT as a QR - it can add greatly to the time required for scanning
|
||||
boolean includeNonWitnessUtxos = !Arrays.asList(ScriptType.WITNESS_TYPES).contains(headersForm.getSigningWallet().getScriptType());
|
||||
CryptoPSBT cryptoPSBT = new CryptoPSBT(headersForm.getPsbt().serialize(true, includeNonWitnessUtxos));
|
||||
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(cryptoPSBT.toUR(), addLegacyEncodingOption);
|
||||
qrDisplayDialog.show();
|
||||
QRDisplayDialog qrDisplayDialog = new QRDisplayDialog(cryptoPSBT.toUR(), addLegacyEncodingOption, true);
|
||||
qrDisplayDialog.initOwner(toggleButton.getScene().getWindow());
|
||||
Optional<ButtonType> optButtonType = qrDisplayDialog.showAndWait();
|
||||
if(optButtonType.isPresent() && optButtonType.get().getButtonData() == ButtonBar.ButtonData.NEXT_FORWARD) {
|
||||
scanPSBT(event);
|
||||
}
|
||||
}
|
||||
|
||||
public void scanPSBT(ActionEvent event) {
|
||||
@@ -883,6 +905,7 @@ public class HeadersController extends TransactionFormController implements Init
|
||||
toggleButton.setSelected(false);
|
||||
|
||||
QRScanDialog qrScanDialog = new QRScanDialog();
|
||||
qrScanDialog.initOwner(toggleButton.getScene().getWindow());
|
||||
Optional<QRScanDialog.Result> optionalResult = qrScanDialog.showAndWait();
|
||||
if(optionalResult.isPresent()) {
|
||||
QRScanDialog.Result result = optionalResult.get();
|
||||
@@ -982,6 +1005,7 @@ public class HeadersController extends TransactionFormController implements Init
|
||||
|
||||
if(copy.isEncrypted()) {
|
||||
WalletPasswordDialog dlg = new WalletPasswordDialog(copy.getMasterName(), WalletPasswordDialog.PasswordRequirement.LOAD);
|
||||
dlg.initOwner(signButton.getScene().getWindow());
|
||||
Optional<SecureString> password = dlg.showAndWait();
|
||||
if(password.isPresent()) {
|
||||
Storage.DecryptWalletService decryptWalletService = new Storage.DecryptWalletService(copy, password.get());
|
||||
@@ -1026,6 +1050,7 @@ public class HeadersController extends TransactionFormController implements Init
|
||||
}
|
||||
|
||||
DeviceSignDialog dlg = new DeviceSignDialog(headersForm.getSigningWallet(), fingerprints, headersForm.getPsbt());
|
||||
dlg.initOwner(signButton.getScene().getWindow());
|
||||
dlg.initModality(Modality.NONE);
|
||||
Stage stage = (Stage)dlg.getDialogPane().getScene().getWindow();
|
||||
stage.setAlwaysOnTop(true);
|
||||
@@ -1327,7 +1352,6 @@ public class HeadersController extends TransactionFormController implements Init
|
||||
errorGlyph.getStyleClass().add("failure");
|
||||
blockHeightField.setVisible(false);
|
||||
blockTimestampField.setVisible(false);
|
||||
blockHashField.setVisible(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1340,6 +1364,20 @@ public class HeadersController extends TransactionFormController implements Init
|
||||
public void openWallets(OpenWalletsEvent event) {
|
||||
if(id.getScene().getWindow().equals(event.getWindow()) && headersForm.getPsbt() != null && headersForm.getBlockTransaction() == null) {
|
||||
List<Wallet> availableWallets = event.getWallets().stream().filter(wallet -> wallet.canSign(headersForm.getPsbt())).sorted(new WalletSignComparator()).collect(Collectors.toList());
|
||||
if(availableWallets.isEmpty()) {
|
||||
for(Wallet wallet : event.getWalletsMap().keySet()) {
|
||||
if(wallet.isValid() && !wallet.getSigningKeystores(headersForm.getPsbt()).isEmpty()) {
|
||||
int currentGapLimit = wallet.getGapLimit();
|
||||
Integer requiredGapLimit = wallet.getRequiredGapLimit(headersForm.getPsbt());
|
||||
if(requiredGapLimit != null && requiredGapLimit > currentGapLimit) {
|
||||
wallet.setGapLimit(requiredGapLimit);
|
||||
EventManager.get().post(new WalletGapLimitChangedEvent(event.getStorage(wallet).getWalletId(wallet), wallet, currentGapLimit));
|
||||
Platform.runLater(() -> EventManager.get().post(new RequestOpenWalletsEvent()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Map<Wallet, Storage> availableWalletsMap = new LinkedHashMap<>(event.getWalletsMap());
|
||||
availableWalletsMap.keySet().retainAll(availableWallets);
|
||||
headersForm.getAvailableWallets().keySet().retainAll(availableWallets);
|
||||
@@ -1502,7 +1540,20 @@ public class HeadersController extends TransactionFormController implements Init
|
||||
List<Entry> changedLabelEntries = new ArrayList<>();
|
||||
BlockTransaction blockTransaction = event.getWallet().getWalletTransaction(txid);
|
||||
if(blockTransaction != null && blockTransaction.getLabel() == null) {
|
||||
blockTransaction.setLabel(headersForm.getName());
|
||||
String name = headersForm.getName();
|
||||
if(outputIndexLabels.size() > 1) {
|
||||
StringJoiner joiner = new StringJoiner(", ");
|
||||
outputIndexLabels.values().forEach(joiner::add);
|
||||
name = joiner.toString();
|
||||
|
||||
Matcher matcher = EntryCell.REPLACED_BY_FEE_SUFFIX.matcher(name);
|
||||
name = matcher.replaceAll("$1");
|
||||
matcher.reset();
|
||||
if(matcher.find()) {
|
||||
name += matcher.group(2);
|
||||
}
|
||||
}
|
||||
blockTransaction.setLabel(name != null && name.length() > 255 ? name.substring(0, 255) : name);
|
||||
changedLabelEntries.add(new TransactionEntry(event.getWallet(), blockTransaction, Collections.emptyMap(), Collections.emptyMap()));
|
||||
}
|
||||
|
||||
|
||||
@@ -70,14 +70,14 @@ public class TransactionController implements Initializable {
|
||||
public void initializeView() {
|
||||
fetchTransactions();
|
||||
initializeTxTree();
|
||||
transactionMasterDetail.setDividerPosition(0.82);
|
||||
transactionMasterDetail.setDividerPosition(0.85);
|
||||
transactionMasterDetail.setShowDetailNode(Config.get().isShowTransactionHex());
|
||||
txhex.setTransaction(getTransaction());
|
||||
highlightTxHex();
|
||||
|
||||
transactionMasterDetail.sceneProperty().addListener((observable, oldScene, newScene) -> {
|
||||
if(oldScene == null && newScene != null) {
|
||||
transactionMasterDetail.setDividerPosition(AppServices.isReducedWindowHeight(transactionMasterDetail) ? 0.9 : 0.82);
|
||||
transactionMasterDetail.setDividerPosition(AppServices.isReducedWindowHeight(transactionMasterDetail) ? 0.9 : 0.85);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -176,6 +176,7 @@ public class AddressesController extends WalletFormController implements Initial
|
||||
|
||||
public void showPayNymAddresses(ActionEvent event) {
|
||||
PayNymAddressesDialog payNymAddressesDialog = new PayNymAddressesDialog(getWalletForm());
|
||||
payNymAddressesDialog.initOwner(showPayNymAddresses.getScene().getWindow());
|
||||
payNymAddressesDialog.showAndWait();
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user