mirror of
https://github.com/sparrowwallet/sparrow.git
synced 2026-08-11 09:08:02 +00:00
779 lines
38 KiB
Java
779 lines
38 KiB
Java
package com.sparrowwallet.sparrow.paynym;
|
|
|
|
import com.google.common.eventbus.Subscribe;
|
|
import com.sparrowwallet.drongo.SecureString;
|
|
import com.sparrowwallet.drongo.bip47.PaymentCode;
|
|
import com.sparrowwallet.drongo.bip47.SecretPoint;
|
|
import com.sparrowwallet.drongo.crypto.ECKey;
|
|
import com.sparrowwallet.drongo.protocol.*;
|
|
import com.sparrowwallet.drongo.psbt.PSBT;
|
|
import com.sparrowwallet.drongo.wallet.*;
|
|
import com.sparrowwallet.sparrow.AppServices;
|
|
import com.sparrowwallet.sparrow.EventManager;
|
|
import com.sparrowwallet.sparrow.control.*;
|
|
import com.sparrowwallet.sparrow.event.*;
|
|
import com.sparrowwallet.sparrow.io.Config;
|
|
import com.sparrowwallet.sparrow.io.Storage;
|
|
import com.sparrowwallet.sparrow.net.ElectrumServer;
|
|
import com.sparrowwallet.sparrow.wallet.Entry;
|
|
import com.sparrowwallet.sparrow.wallet.TransactionEntry;
|
|
import javafx.application.Platform;
|
|
import javafx.beans.property.*;
|
|
import javafx.collections.FXCollections;
|
|
import javafx.collections.ObservableList;
|
|
import javafx.event.ActionEvent;
|
|
import javafx.fxml.FXML;
|
|
import javafx.scene.control.*;
|
|
import javafx.scene.input.KeyCode;
|
|
import javafx.scene.input.KeyEvent;
|
|
import javafx.util.Duration;
|
|
import org.slf4j.Logger;
|
|
import org.slf4j.LoggerFactory;
|
|
|
|
import java.util.*;
|
|
import java.util.function.Function;
|
|
import java.util.function.UnaryOperator;
|
|
import java.util.regex.Pattern;
|
|
import java.util.stream.Collectors;
|
|
|
|
import static com.sparrowwallet.sparrow.AppServices.showErrorDialog;
|
|
import static com.sparrowwallet.sparrow.wallet.PaymentController.MINIMUM_P2PKH_OUTPUT_SATS;
|
|
|
|
public class PayNymController {
|
|
private static final Logger log = LoggerFactory.getLogger(PayNymController.class);
|
|
|
|
public static final Pattern SAMOURAI_PAYNYM_REGEX = Pattern.compile("\\+[a-z]+[0-9][0-9a-fA-F][0-9a-fA-F]");
|
|
public static final Pattern ASHIGARU_PAYNYM_REGEX = Pattern.compile("\\+[a-z]+[0-9][0-9]");
|
|
public static final String INVALID_PAYMENT_CODE_LABEL = "Invalid Payment Code";
|
|
|
|
private String walletId;
|
|
private PayNym walletPayNym;
|
|
private boolean requestingPassword;
|
|
|
|
@FXML
|
|
private CopyableTextField payNymName;
|
|
|
|
@FXML
|
|
private Button payNymRetrieve;
|
|
|
|
@FXML
|
|
private ProgressIndicator retrievePayNymProgress;
|
|
|
|
@FXML
|
|
private PaymentCodeTextField paymentCode;
|
|
|
|
@FXML
|
|
private CopyableTextField searchPayNyms;
|
|
|
|
@FXML
|
|
private Button searchPayNymsScan;
|
|
|
|
@FXML
|
|
private ProgressIndicator findPayNym;
|
|
|
|
@FXML
|
|
private PayNymAvatar payNymAvatar;
|
|
|
|
@FXML
|
|
private ListView<PayNym> followingList;
|
|
|
|
@FXML
|
|
private ListView<PayNym> followersList;
|
|
|
|
private final ObjectProperty<PayNym> payNymProperty = new SimpleObjectProperty<>(null);
|
|
|
|
private final StringProperty findNymProperty = new SimpleStringProperty();
|
|
|
|
private final Map<Sha256Hash, PayNym> notificationTransactions = new HashMap<>();
|
|
|
|
private final BooleanProperty closeProperty = new SimpleBooleanProperty(false);
|
|
|
|
public void initializeView(String walletId) {
|
|
this.walletId = walletId;
|
|
|
|
payNymName.managedProperty().bind(payNymName.visibleProperty());
|
|
payNymRetrieve.managedProperty().bind(payNymRetrieve.visibleProperty());
|
|
payNymRetrieve.visibleProperty().bind(payNymName.visibleProperty().not());
|
|
payNymRetrieve.setDisable(!AppServices.isConnected());
|
|
|
|
retrievePayNymProgress.managedProperty().bind(retrievePayNymProgress.visibleProperty());
|
|
retrievePayNymProgress.maxHeightProperty().bind(payNymName.heightProperty());
|
|
retrievePayNymProgress.setVisible(false);
|
|
|
|
Wallet masterWallet = getMasterWallet();
|
|
if(masterWallet.hasPaymentCode()) {
|
|
paymentCode.setPaymentCode(masterWallet.getPaymentCode());
|
|
}
|
|
|
|
findNymProperty.addListener((observable, oldValue, nymIdentifier) -> {
|
|
if(nymIdentifier != null) {
|
|
searchFollowing(nymIdentifier);
|
|
}
|
|
});
|
|
|
|
UnaryOperator<TextFormatter.Change> paymentCodeFilter = change -> {
|
|
String input = change.getControlNewText();
|
|
if(input.startsWith("P") && !input.contains("...")) {
|
|
try {
|
|
PaymentCode paymentCode = new PaymentCode(input);
|
|
if(paymentCode.isValid()) {
|
|
findNymProperty.set(input);
|
|
|
|
TextInputControl control = (TextInputControl)change.getControl();
|
|
change.setText(input.substring(0, 12) + "..." + input.substring(input.length() - 5));
|
|
change.setRange(0, control.getLength());
|
|
change.setAnchor(change.getText().length());
|
|
change.setCaretPosition(change.getText().length());
|
|
}
|
|
} catch(Exception e) {
|
|
//ignore
|
|
}
|
|
} else if(ASHIGARU_PAYNYM_REGEX.matcher(input).matches()) {
|
|
findNymProperty.set(input);
|
|
} else {
|
|
findNymProperty.set(null);
|
|
resetFollowing();
|
|
}
|
|
|
|
return change;
|
|
};
|
|
searchPayNymsScan.disableProperty().bind(searchPayNyms.disableProperty());
|
|
searchPayNyms.setDisable(true);
|
|
searchPayNyms.setTextFormatter(new TextFormatter<>(paymentCodeFilter));
|
|
searchPayNyms.addEventFilter(KeyEvent.ANY, event -> {
|
|
if(event.getCode() == KeyCode.ENTER) {
|
|
findNymProperty.set(searchPayNyms.getText());
|
|
event.consume();
|
|
}
|
|
});
|
|
findPayNym.managedProperty().bind(findPayNym.visibleProperty());
|
|
findPayNym.maxHeightProperty().bind(searchPayNyms.heightProperty());
|
|
findPayNym.setVisible(false);
|
|
|
|
followingList.setCellFactory(param -> {
|
|
return new PayNymCell(this, true);
|
|
});
|
|
|
|
followingList.getSelectionModel().selectedItemProperty().addListener((observable, oldValue, payNym) -> {
|
|
payNymProperty.set(payNym);
|
|
});
|
|
|
|
followersList.setCellFactory(param -> {
|
|
return new PayNymCell(this, false);
|
|
});
|
|
|
|
followersList.setSelectionModel(new NoSelectionModel<>());
|
|
followersList.setFocusTraversable(false);
|
|
|
|
if(isUsePayNym(masterWallet) && AppServices.isConnected() && masterWallet.hasPaymentCode()) {
|
|
refresh();
|
|
} else {
|
|
payNymName.setVisible(false);
|
|
updateFollowing();
|
|
}
|
|
}
|
|
|
|
private void refresh() {
|
|
if(!getMasterWallet().hasPaymentCode()) {
|
|
throw new IllegalStateException("Payment code is not present");
|
|
}
|
|
retrievePayNymProgress.setVisible(true);
|
|
|
|
PayNymService.getPayNym(getMasterWallet().getPaymentCode().toString()).subscribe(payNym -> {
|
|
retrievePayNymProgress.setVisible(false);
|
|
walletPayNym = payNym;
|
|
searchPayNyms.setDisable(false);
|
|
payNymName.setText(payNym.nymName());
|
|
paymentCode.setPaymentCode(payNym.paymentCode());
|
|
payNymAvatar.setPaymentCode(payNym.paymentCode());
|
|
followingList.setUserData(null);
|
|
followingList.setPlaceholder(new Label("No contacts"));
|
|
updateFollowing();
|
|
followersList.setPlaceholder(new Label("No followers"));
|
|
followersList.setItems(FXCollections.observableList(payNym.followers()));
|
|
Platform.runLater(() -> addWalletIfNotificationTransactionPresent(payNym.following()));
|
|
}, error -> {
|
|
retrievePayNymProgress.setVisible(false);
|
|
if(error.getMessage().endsWith("404")) {
|
|
payNymName.setVisible(false);
|
|
updateFollowing();
|
|
} else {
|
|
log.error("Error retrieving PayNym", error);
|
|
Optional<ButtonType> optResponse = showErrorDialog("Error retrieving PayNym", "Could not retrieve PayNym. Try again?", ButtonType.CANCEL, ButtonType.OK);
|
|
if(optResponse.isPresent() && optResponse.get().equals(ButtonType.OK)) {
|
|
refresh();
|
|
} else {
|
|
payNymName.setVisible(false);
|
|
updateFollowing();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
private void resetFollowing() {
|
|
if(followingList.getUserData() != null) {
|
|
followingList.setUserData(null);
|
|
updateFollowing();
|
|
}
|
|
}
|
|
|
|
private void searchFollowing(String nymIdentifier) {
|
|
Optional<PayNym> optExisting = walletPayNym.following().stream().filter(payNym -> payNym.nymName().equals(nymIdentifier) || payNym.paymentCode().toString().equals(nymIdentifier)).findFirst();
|
|
if(optExisting.isPresent()) {
|
|
followingList.setUserData(Boolean.FALSE);
|
|
List<PayNym> existingPayNym = new ArrayList<>();
|
|
existingPayNym.add(optExisting.get());
|
|
followingList.setItems(FXCollections.observableList(existingPayNym));
|
|
} else {
|
|
followingList.setUserData(Boolean.TRUE);
|
|
followingList.setItems(FXCollections.observableList(new ArrayList<>()));
|
|
findPayNym.setVisible(true);
|
|
|
|
PayNymService.getPayNym(nymIdentifier, true).subscribe(searchedPayNym -> {
|
|
findPayNym.setVisible(false);
|
|
List<PayNym> searchList = new ArrayList<>();
|
|
searchList.add(searchedPayNym);
|
|
followingList.setUserData(Boolean.TRUE);
|
|
followingList.setItems(FXCollections.observableList(searchList));
|
|
}, error -> {
|
|
findPayNym.setVisible(false);
|
|
});
|
|
}
|
|
}
|
|
|
|
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();
|
|
if(result.payload != null) {
|
|
searchPayNyms.setText(result.payload);
|
|
} else {
|
|
AppServices.showErrorDialog("Invalid QR Code", "Cannot parse QR code into a payment code");
|
|
}
|
|
}
|
|
}
|
|
|
|
public void retrievePayNym(ActionEvent event) {
|
|
Wallet masterWallet = getMasterWallet();
|
|
setUsePayNym(masterWallet, true);
|
|
PayNymService.createPayNym(masterWallet).subscribe(createMap -> {
|
|
payNymName.setText((String)createMap.get("nymName"));
|
|
payNymAvatar.setPaymentCode(masterWallet.getPaymentCode());
|
|
payNymName.setVisible(true);
|
|
|
|
PayNymService.claimPayNym(masterWallet, createMap, getMasterWallet().getScriptType() != ScriptType.P2PKH);
|
|
refresh();
|
|
}, error -> {
|
|
log.error("Error retrieving PayNym", error);
|
|
Optional<ButtonType> optResponse = showErrorDialog("Error retrieving PayNym", "Could not retrieve PayNym. Try again?", ButtonType.CANCEL, ButtonType.OK);
|
|
if(optResponse.isPresent() && optResponse.get().equals(ButtonType.OK)) {
|
|
retrievePayNym(event);
|
|
}
|
|
});
|
|
}
|
|
|
|
public void followPayNym(PaymentCode contact) {
|
|
Wallet masterWallet = getMasterWallet();
|
|
retrievePayNymProgress.setVisible(true);
|
|
PayNymService.getAuthToken(masterWallet, new HashMap<>()).subscribe(authToken -> {
|
|
String signature = PayNymService.getSignature(masterWallet, authToken);
|
|
PayNymService.followPaymentCode(contact, authToken, signature).subscribe(followMap -> {
|
|
refresh();
|
|
}, error -> {
|
|
retrievePayNymProgress.setVisible(false);
|
|
log.error("Could not follow payment code", error);
|
|
Optional<ButtonType> optResponse = showErrorDialog("Error retrieving PayNym", "Could not follow payment code. Try again?", ButtonType.CANCEL, ButtonType.OK);
|
|
if(optResponse.isPresent() && optResponse.get().equals(ButtonType.OK)) {
|
|
followPayNym(contact);
|
|
} else {
|
|
followingList.refresh();
|
|
followersList.refresh();
|
|
}
|
|
});
|
|
}, error -> {
|
|
retrievePayNymProgress.setVisible(false);
|
|
log.error("Could not follow payment code", error);
|
|
Optional<ButtonType> optResponse = showErrorDialog("Error retrieving PayNym", "Could not follow payment code. Try again?", ButtonType.CANCEL, ButtonType.OK);
|
|
if(optResponse.isPresent() && optResponse.get().equals(ButtonType.OK)) {
|
|
followPayNym(contact);
|
|
} else {
|
|
followingList.refresh();
|
|
followersList.refresh();
|
|
}
|
|
});
|
|
}
|
|
|
|
public Boolean isFollowing(PayNym payNym) {
|
|
if(followingList.getItems() != null) {
|
|
return followingList.getItems().stream().anyMatch(following -> payNym.paymentCode().equals(following.paymentCode()));
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
public boolean isLinked(PayNym payNym) {
|
|
PaymentCode externalPaymentCode = payNym.paymentCode();
|
|
//As per BIP47 a notification transaction must have been sent from this wallet to enable the recipient to restore funds from seed
|
|
return getMasterWallet().getChildWallet(externalPaymentCode, payNym.segwit() ? ScriptType.P2WPKH : ScriptType.P2PKH) != null
|
|
&& !getNotificationTransaction(externalPaymentCode).isEmpty();
|
|
}
|
|
|
|
public void updateFollowing() {
|
|
List<PayNym> followingPayNyms = new ArrayList<>();
|
|
if(walletPayNym != null) {
|
|
followingPayNyms.addAll(walletPayNym.following());
|
|
}
|
|
|
|
Map<PaymentCode, PayNym> payNymMap = followingPayNyms.stream().collect(Collectors.toMap(PayNym::paymentCode, Function.identity(), (u, v) -> u, LinkedHashMap::new));
|
|
followingList.setItems(FXCollections.observableList(getExistingWalletPayNyms(payNymMap)));
|
|
}
|
|
|
|
private List<PayNym> getExistingWalletPayNyms(Map<PaymentCode, PayNym> payNymMap) {
|
|
List<Wallet> childWallets = new ArrayList<>(getMasterWallet().getChildWallets());
|
|
childWallets.sort(Comparator.comparingInt(o -> -o.getScriptType().ordinal()));
|
|
for(Wallet childWallet : childWallets) {
|
|
if(childWallet.isBip47()) {
|
|
PaymentCode externalPaymentCode = childWallet.getKeystores().get(0).getExternalPaymentCode();
|
|
String walletNymName = PayNym.getNymName(childWallet);
|
|
if(payNymMap.get(externalPaymentCode) == null || (walletNymName != null && !walletNymName.equals(payNymMap.get(externalPaymentCode).nymName()))) {
|
|
if(walletNymName != null && payNymMap.get(externalPaymentCode) != null && SAMOURAI_PAYNYM_REGEX.matcher(walletNymName).matches()) {
|
|
childWallet.setLabel(payNymMap.get(externalPaymentCode).nymName() + " " + childWallet.getScriptType().getName());
|
|
EventManager.get().post(new WalletLabelChangedEvent(childWallet));
|
|
} else {
|
|
payNymMap.put(externalPaymentCode, PayNym.fromWallet(childWallet));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return new ArrayList<>(payNymMap.values());
|
|
}
|
|
|
|
private void addWalletIfNotificationTransactionPresent(List<PayNym> following) {
|
|
Unlinked unlinked = getUnlinkedPayNyms(following);
|
|
|
|
Wallet wallet = getMasterWallet();
|
|
if(!unlinked.unlinkedNotifications.isEmpty()) {
|
|
if(wallet.isEncrypted()) {
|
|
Storage storage = AppServices.get().getOpenWallets().get(wallet);
|
|
requestingPassword = true;
|
|
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());
|
|
decryptWalletService.setOnSucceeded(workerStateEvent -> {
|
|
EventManager.get().post(new StorageEvent(storage.getWalletId(wallet), TimedEvent.Action.END, "Done"));
|
|
Wallet decryptedWallet = decryptWalletService.getValue();
|
|
addWalletIfNotificationTransactionPresent(decryptedWallet, getUnlinkedPayNyms(following));
|
|
decryptedWallet.clearPrivate();
|
|
requestingPassword = false;
|
|
});
|
|
decryptWalletService.setOnFailed(workerStateEvent -> {
|
|
EventManager.get().post(new StorageEvent(storage.getWalletId(wallet), TimedEvent.Action.END, "Failed"));
|
|
AppServices.showErrorDialog("Incorrect Password", decryptWalletService.getException().getMessage());
|
|
requestingPassword = false;
|
|
});
|
|
EventManager.get().post(new StorageEvent(storage.getWalletId(wallet), TimedEvent.Action.START, "Decrypting wallet..."));
|
|
decryptWalletService.start();
|
|
}
|
|
}
|
|
} else {
|
|
addWalletIfNotificationTransactionPresent(wallet, unlinked);
|
|
}
|
|
}
|
|
}
|
|
|
|
private Unlinked getUnlinkedPayNyms(List<PayNym> following) {
|
|
Map<BlockTransaction, PayNym> unlinkedPayNyms = new HashMap<>();
|
|
Map<BlockTransaction, WalletNode> unlinkedNotifications = new HashMap<>();
|
|
for(PayNym payNym : following) {
|
|
if(!isLinked(payNym)) {
|
|
PaymentCode externalPaymentCode = payNym.paymentCode();
|
|
Map<BlockTransaction, WalletNode> unlinkedNotification = getNotificationTransaction(externalPaymentCode);
|
|
if(!unlinkedNotification.isEmpty() && !INVALID_PAYMENT_CODE_LABEL.equals(unlinkedNotification.keySet().iterator().next().getLabel())) {
|
|
unlinkedNotifications.putAll(unlinkedNotification);
|
|
unlinkedPayNyms.put(unlinkedNotification.keySet().iterator().next(), payNym);
|
|
}
|
|
}
|
|
}
|
|
|
|
return new Unlinked(unlinkedPayNyms, unlinkedNotifications);
|
|
}
|
|
|
|
private void addWalletIfNotificationTransactionPresent(Wallet decryptedWallet, Unlinked unlinked) {
|
|
List<Wallet> addedWallets = new ArrayList<>();
|
|
for(BlockTransaction blockTransaction : unlinked.unlinkedNotifications.keySet()) {
|
|
try {
|
|
PayNym payNym = unlinked.unlinkedPayNyms.get(blockTransaction);
|
|
PaymentCode externalPaymentCode = payNym.paymentCode();
|
|
WalletNode input0Node = unlinked.unlinkedNotifications.get(blockTransaction);
|
|
Keystore keystore = input0Node.getWallet().isNested() ? decryptedWallet.getChildWallet(input0Node.getWallet().getName()).getKeystores().get(0) : decryptedWallet.getKeystores().get(0);
|
|
ECKey input0Key = keystore.getKey(input0Node);
|
|
TransactionOutPoint input0Outpoint = PaymentCode.getDesignatedInput(blockTransaction.getTransaction()).getOutpoint();
|
|
SecretPoint secretPoint = new SecretPoint(input0Key.getPrivKeyBytes(), externalPaymentCode.getNotificationKey().getPubKey());
|
|
byte[] blindingMask = PaymentCode.getMask(secretPoint.ECDHSecretAsBytes(), input0Outpoint.bitcoinSerialize());
|
|
byte[] blindedPaymentCode = PaymentCode.blind(getMasterWallet().getPaymentCode().getPayload(), blindingMask);
|
|
byte[] opReturnData = PaymentCode.getOpReturnData(blockTransaction.getTransaction());
|
|
if(Arrays.equals(opReturnData, blindedPaymentCode)) {
|
|
addedWallets.addAll(addChildWallets(payNym, externalPaymentCode));
|
|
blockTransaction.setLabel("Link " + payNym.nymName());
|
|
} else {
|
|
blockTransaction.setLabel(INVALID_PAYMENT_CODE_LABEL);
|
|
}
|
|
EventManager.get().post(new WalletEntryLabelsChangedEvent(input0Node.getWallet(), new TransactionEntry(input0Node.getWallet(), blockTransaction, Collections.emptyMap(), Collections.emptyMap())));
|
|
} catch(Exception e) {
|
|
log.error("Error adding linked contact from notification transaction", e);
|
|
}
|
|
}
|
|
|
|
if(!addedWallets.isEmpty()) {
|
|
Wallet masterWallet = getMasterWallet();
|
|
Storage storage = AppServices.get().getOpenWallets().get(masterWallet);
|
|
EventManager.get().post(new ChildWalletsAddedEvent(storage, masterWallet, addedWallets));
|
|
followingList.refresh();
|
|
}
|
|
}
|
|
|
|
public List<Wallet> addChildWallets(PayNym payNym, PaymentCode externalPaymentCode) {
|
|
List<Wallet> addedWallets = new ArrayList<>();
|
|
Wallet masterWallet = getMasterWallet();
|
|
Storage storage = AppServices.get().getOpenWallets().get(masterWallet);
|
|
List<ScriptType> scriptTypes = masterWallet.getScriptType() != ScriptType.P2PKH ? PayNym.getSegwitScriptTypes() : payNym.getScriptTypes();
|
|
for(ScriptType childScriptType : scriptTypes) {
|
|
String label = payNym.nymName() + " " + childScriptType.getName();
|
|
Wallet addedWallet = masterWallet.addChildWallet(externalPaymentCode, childScriptType, label);
|
|
if(!storage.isPersisted(addedWallet)) {
|
|
try {
|
|
storage.saveWallet(addedWallet);
|
|
EventManager.get().post(new NewChildWalletSavedEvent(storage, masterWallet, addedWallet));
|
|
} catch(Exception e) {
|
|
log.error("Error saving wallet", e);
|
|
AppServices.showErrorDialog("Error saving wallet " + addedWallet.getName(), e.getMessage());
|
|
}
|
|
}
|
|
addedWallets.add(addedWallet);
|
|
}
|
|
|
|
return addedWallets;
|
|
}
|
|
|
|
public void linkPayNym(PayNym payNym) {
|
|
ButtonType previewType = new ButtonType("Preview", ButtonBar.ButtonData.LEFT);
|
|
ButtonType sendType = new ButtonType("Send", ButtonBar.ButtonData.YES);
|
|
Optional<ButtonType> optButtonType = AppServices.showAlertDialog("Link PayNym?",
|
|
"Linking to this contact will allow you to send to it directly (non-collaboratively) through unique private addresses you can generate independently.\n\n" +
|
|
"It will cost " + MINIMUM_P2PKH_OUTPUT_SATS + " sats to create the link through a notification transaction, plus the mining fee. Send transaction?", Alert.AlertType.CONFIRMATION, previewType, ButtonType.CANCEL, sendType);
|
|
if(optButtonType.isPresent() && optButtonType.get() == sendType) {
|
|
broadcastNotificationTransaction(payNym);
|
|
} else if(optButtonType.isPresent() && optButtonType.get() == previewType) {
|
|
PaymentCode paymentCode = payNym.paymentCode();
|
|
Payment payment = new Payment(paymentCode.getNotificationAddress(), "Link " + payNym.nymName(), MINIMUM_P2PKH_OUTPUT_SATS, false);
|
|
Wallet wallet = AppServices.get().getWallet(walletId);
|
|
EventManager.get().post(new SendActionEvent(wallet, new ArrayList<>(wallet.getSpendableUtxos().keySet())));
|
|
Platform.runLater(() -> EventManager.get().post(new SpendUtxoEvent(wallet, List.of(payment), List.of(new byte[80]), paymentCode)));
|
|
closeProperty.set(true);
|
|
} else {
|
|
followingList.refresh();
|
|
}
|
|
}
|
|
|
|
public void broadcastNotificationTransaction(PayNym payNym) {
|
|
Wallet masterWallet = getMasterWallet();
|
|
WalletTransaction walletTransaction;
|
|
try {
|
|
walletTransaction = getWalletTransaction(masterWallet, payNym, new byte[80], null);
|
|
} catch(InsufficientFundsException e) {
|
|
try {
|
|
Wallet wallet = AppServices.get().getWallet(walletId);
|
|
walletTransaction = getWalletTransaction(wallet, payNym, new byte[80], null);
|
|
} catch(InsufficientFundsException e2) {
|
|
AppServices.showErrorDialog("Insufficient Funds", "There are not enough funds in this wallet to broadcast the notification transaction.");
|
|
followingList.refresh();
|
|
return;
|
|
}
|
|
}
|
|
|
|
final WalletTransaction walletTx = walletTransaction;
|
|
final PaymentCode paymentCode = masterWallet.getPaymentCode();
|
|
Wallet wallet = walletTransaction.getWallet();
|
|
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());
|
|
decryptWalletService.setOnSucceeded(workerStateEvent -> {
|
|
EventManager.get().post(new StorageEvent(storage.getWalletId(wallet), TimedEvent.Action.END, "Done"));
|
|
Wallet decryptedWallet = decryptWalletService.getValue();
|
|
broadcastNotificationTransaction(decryptedWallet, walletTx, paymentCode, payNym);
|
|
decryptedWallet.clearPrivate();
|
|
});
|
|
decryptWalletService.setOnFailed(workerStateEvent -> {
|
|
EventManager.get().post(new StorageEvent(storage.getWalletId(wallet), TimedEvent.Action.END, "Failed"));
|
|
followingList.refresh();
|
|
AppServices.showErrorDialog("Incorrect Password", decryptWalletService.getException().getMessage());
|
|
});
|
|
EventManager.get().post(new StorageEvent(storage.getWalletId(wallet), TimedEvent.Action.START, "Decrypting wallet..."));
|
|
decryptWalletService.start();
|
|
}
|
|
} else {
|
|
broadcastNotificationTransaction(wallet, walletTx, paymentCode, payNym);
|
|
}
|
|
}
|
|
|
|
private void broadcastNotificationTransaction(Wallet decryptedWallet, WalletTransaction walletTransaction, PaymentCode paymentCode, PayNym payNym) {
|
|
try {
|
|
PaymentCode externalPaymentCode = payNym.paymentCode();
|
|
WalletNode input0Node = walletTransaction.getSelectedUtxos().entrySet().iterator().next().getValue();
|
|
Keystore keystore = input0Node.getWallet().isNested() ? decryptedWallet.getChildWallet(input0Node.getWallet().getName()).getKeystores().get(0) : decryptedWallet.getKeystores().get(0);
|
|
ECKey input0Key = keystore.getKey(input0Node);
|
|
TransactionOutPoint input0Outpoint = walletTransaction.getTransaction().getInputs().iterator().next().getOutpoint();
|
|
SecretPoint secretPoint = new SecretPoint(input0Key.getPrivKeyBytes(), externalPaymentCode.getNotificationKey().getPubKey());
|
|
byte[] blindingMask = PaymentCode.getMask(secretPoint.ECDHSecretAsBytes(), input0Outpoint.bitcoinSerialize());
|
|
byte[] blindedPaymentCode = PaymentCode.blind(paymentCode.getPayload(), blindingMask);
|
|
|
|
WalletTransaction finalWalletTx = getWalletTransaction(decryptedWallet, payNym, blindedPaymentCode, walletTransaction.getSelectedUtxos().keySet());
|
|
PSBT psbt = finalWalletTx.createPSBT();
|
|
decryptedWallet.sign(psbt);
|
|
decryptedWallet.finalise(psbt);
|
|
Transaction transaction = psbt.extractTransaction();
|
|
|
|
ElectrumServer.BroadcastTransactionService broadcastTransactionService = new ElectrumServer.BroadcastTransactionService(transaction, psbt.getFee());
|
|
broadcastTransactionService.setOnSucceeded(successEvent -> {
|
|
ElectrumServer.TransactionMempoolService transactionMempoolService = new ElectrumServer.TransactionMempoolService(walletTransaction.getWallet(), transaction.getTxId(), new HashSet<>(walletTransaction.getSelectedUtxos().values()));
|
|
transactionMempoolService.setDelay(Duration.seconds(2));
|
|
transactionMempoolService.setPeriod(Duration.seconds(5));
|
|
transactionMempoolService.setRestartOnFailure(false);
|
|
transactionMempoolService.setOnSucceeded(mempoolWorkerStateEvent -> {
|
|
Set<String> scriptHashes = transactionMempoolService.getValue();
|
|
if(!scriptHashes.isEmpty()) {
|
|
transactionMempoolService.cancel();
|
|
List<Wallet> addedWallets = addChildWallets(payNym, externalPaymentCode);
|
|
Wallet masterWallet = getMasterWallet();
|
|
Storage storage = AppServices.get().getOpenWallets().get(masterWallet);
|
|
EventManager.get().post(new ChildWalletsAddedEvent(storage, masterWallet, addedWallets));
|
|
retrievePayNymProgress.setVisible(false);
|
|
followingList.refresh();
|
|
|
|
BlockTransaction blockTransaction = walletTransaction.getWallet().getWalletTransaction(transaction.getTxId());
|
|
if(blockTransaction != null && blockTransaction.getLabel() == null) {
|
|
blockTransaction.setLabel("Link " + payNym.nymName());
|
|
TransactionEntry transactionEntry = new TransactionEntry(walletTransaction.getWallet(), blockTransaction, Collections.emptyMap(), Collections.emptyMap());
|
|
EventManager.get().post(new WalletEntryLabelsChangedEvent(walletTransaction.getWallet(), List.of(transactionEntry)));
|
|
}
|
|
}
|
|
|
|
if(transactionMempoolService.getIterationCount() > 5 && transactionMempoolService.isRunning()) {
|
|
transactionMempoolService.cancel();
|
|
retrievePayNymProgress.setVisible(false);
|
|
followingList.refresh();
|
|
log.error("Timeout searching for broadcasted notification transaction");
|
|
AppServices.showErrorDialog("Timeout searching for broadcasted transaction", "The transaction was broadcast but the server did not register it in the mempool. It is safe to try linking again.");
|
|
}
|
|
});
|
|
transactionMempoolService.setOnFailed(mempoolWorkerStateEvent -> {
|
|
transactionMempoolService.cancel();
|
|
log.error("Error searching for broadcasted notification transaction", mempoolWorkerStateEvent.getSource().getException());
|
|
retrievePayNymProgress.setVisible(false);
|
|
followingList.refresh();
|
|
AppServices.showErrorDialog("Timeout searching for broadcasted transaction", "The transaction was broadcast but the server did not register it in the mempool. It is safe to try linking again.");
|
|
});
|
|
transactionMempoolService.start();
|
|
});
|
|
broadcastTransactionService.setOnFailed(failedEvent -> {
|
|
log.error("Error broadcasting notification transaction", failedEvent.getSource().getException());
|
|
retrievePayNymProgress.setVisible(false);
|
|
followingList.refresh();
|
|
AppServices.showErrorDialog("Error broadcasting notification transaction", failedEvent.getSource().getException().getMessage());
|
|
});
|
|
retrievePayNymProgress.setVisible(true);
|
|
notificationTransactions.put(transaction.getTxId(), payNym);
|
|
broadcastTransactionService.start();
|
|
} catch(Exception e) {
|
|
log.error("Error creating notification transaction", e);
|
|
retrievePayNymProgress.setVisible(false);
|
|
followingList.refresh();
|
|
AppServices.showErrorDialog("Error creating notification transaction", e.getMessage());
|
|
}
|
|
}
|
|
|
|
private WalletTransaction getWalletTransaction(Wallet wallet, PayNym payNym, byte[] blindedPaymentCode, Collection<BlockTransactionHashIndex> utxos) throws InsufficientFundsException {
|
|
PaymentCode externalPaymentCode = payNym.paymentCode();
|
|
Payment payment = new Payment(externalPaymentCode.getNotificationAddress(), "Link " + payNym.nymName(), MINIMUM_P2PKH_OUTPUT_SATS, false);
|
|
List<Payment> payments = List.of(payment);
|
|
List<byte[]> opReturns = List.of(blindedPaymentCode);
|
|
Double feeRate = AppServices.getDefaultFeeRate();
|
|
Double minimumFeeRate = AppServices.getMinimumFeeRate();
|
|
Double minRelayFeeRate = AppServices.getMinimumRelayFeeRate();
|
|
boolean groupByAddress = Config.get().isGroupByAddress();
|
|
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, false));
|
|
List<TxoFilter> txoFilters = List.of(new SpentTxoFilter(), new FrozenTxoFilter(), new CoinbaseTxoFilter(wallet));
|
|
|
|
return wallet.createWalletTransaction(utxoSelectors, txoFilters, payments, opReturns, Collections.emptySet(), feeRate, minimumFeeRate, minRelayFeeRate, null,
|
|
AppServices.getCurrentBlockHeight(), groupByAddress, includeMempoolOutputs, true);
|
|
}
|
|
|
|
private Map<BlockTransaction, WalletNode> getNotificationTransaction(PaymentCode externalPaymentCode) {
|
|
Map<BlockTransaction, WalletNode> notificationTransaction = getMasterWallet().getNotificationTransaction(externalPaymentCode);
|
|
if(notificationTransaction.isEmpty()) {
|
|
for(Wallet childWallet : getMasterWallet().getChildWallets()) {
|
|
if(!childWallet.isNested()) {
|
|
notificationTransaction = childWallet.getNotificationTransaction(externalPaymentCode);
|
|
if(!notificationTransaction.isEmpty()) {
|
|
return notificationTransaction;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return notificationTransaction;
|
|
}
|
|
|
|
public Wallet getMasterWallet() {
|
|
Wallet wallet = AppServices.get().getWallet(walletId);
|
|
return wallet.isMasterWallet() ? wallet : wallet.getMasterWallet();
|
|
}
|
|
|
|
protected boolean isUsePayNym(Wallet wallet) {
|
|
//TODO: Remove config setting
|
|
boolean usePayNym = Config.get().isUsePayNym();
|
|
if(usePayNym && wallet != null) {
|
|
setUsePayNym(wallet, true);
|
|
}
|
|
|
|
return usePayNym;
|
|
}
|
|
|
|
protected void setUsePayNym(Wallet wallet, boolean usePayNym) {
|
|
//TODO: Remove config setting
|
|
if(Config.get().isUsePayNym() != usePayNym) {
|
|
Config.get().setUsePayNym(usePayNym);
|
|
}
|
|
|
|
if(wallet != null) {
|
|
WalletConfig walletConfig = wallet.getMasterWalletConfig();
|
|
if(walletConfig.isUsePayNym() != usePayNym) {
|
|
walletConfig.setUsePayNym(usePayNym);
|
|
EventManager.get().post(new WalletConfigChangedEvent(wallet.isMasterWallet() ? wallet : wallet.getMasterWallet()));
|
|
}
|
|
}
|
|
}
|
|
|
|
public PayNym getPayNym() {
|
|
return payNymProperty.get();
|
|
}
|
|
|
|
public ObjectProperty<PayNym> payNymProperty() {
|
|
return payNymProperty;
|
|
}
|
|
|
|
public BooleanProperty closeProperty() {
|
|
return closeProperty;
|
|
}
|
|
|
|
@Subscribe
|
|
public void walletHistoryChanged(WalletHistoryChangedEvent event) {
|
|
List<Entry> changedLabelEntries = new ArrayList<>();
|
|
for(Map.Entry<Sha256Hash, PayNym> notificationTx : notificationTransactions.entrySet()) {
|
|
BlockTransaction blockTransaction = event.getWallet().getWalletTransaction(notificationTx.getKey());
|
|
if(blockTransaction != null && blockTransaction.getLabel() == null) {
|
|
blockTransaction.setLabel("Link " + notificationTx.getValue().nymName());
|
|
changedLabelEntries.add(new TransactionEntry(event.getWallet(), blockTransaction, Collections.emptyMap(), Collections.emptyMap()));
|
|
}
|
|
}
|
|
|
|
if(!changedLabelEntries.isEmpty()) {
|
|
Platform.runLater(() -> EventManager.get().post(new WalletEntryLabelsChangedEvent(event.getWallet(), changedLabelEntries)));
|
|
}
|
|
|
|
if(walletPayNym != null && !requestingPassword) {
|
|
//If we have just linked a PayNym wallet that paid for another notification transaction, attempt to link
|
|
Platform.runLater(() -> addWalletIfNotificationTransactionPresent(walletPayNym.following()));
|
|
}
|
|
}
|
|
|
|
public static class NoSelectionModel<T> extends MultipleSelectionModel<T> {
|
|
|
|
@Override
|
|
public ObservableList<Integer> getSelectedIndices() {
|
|
return FXCollections.emptyObservableList();
|
|
}
|
|
|
|
@Override
|
|
public ObservableList<T> getSelectedItems() {
|
|
return FXCollections.emptyObservableList();
|
|
}
|
|
|
|
@Override
|
|
public void selectIndices(int index, int... indices) {
|
|
}
|
|
|
|
@Override
|
|
public void selectAll() {
|
|
}
|
|
|
|
@Override
|
|
public void selectFirst() {
|
|
}
|
|
|
|
@Override
|
|
public void selectLast() {
|
|
}
|
|
|
|
@Override
|
|
public void clearAndSelect(int index) {
|
|
}
|
|
|
|
@Override
|
|
public void select(int index) {
|
|
}
|
|
|
|
@Override
|
|
public void select(T obj) {
|
|
}
|
|
|
|
@Override
|
|
public void clearSelection(int index) {
|
|
}
|
|
|
|
@Override
|
|
public void clearSelection() {
|
|
}
|
|
|
|
@Override
|
|
public boolean isSelected(int index) {
|
|
return false;
|
|
}
|
|
|
|
@Override
|
|
public boolean isEmpty() {
|
|
return true;
|
|
}
|
|
|
|
@Override
|
|
public void selectPrevious() {
|
|
}
|
|
|
|
@Override
|
|
public void selectNext() {
|
|
}
|
|
}
|
|
|
|
private record Unlinked(Map<BlockTransaction, PayNym> unlinkedPayNyms, Map<BlockTransaction, WalletNode> unlinkedNotifications) {}
|
|
}
|