/* * This file is part of Bitsquare. * * Bitsquare is free software: you can redistribute it and/or modify it * under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or (at * your option) any later version. * * Bitsquare is distributed in the hope that it will be useful, but WITHOUT * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public * License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Bitsquare. If not, see <http://www.gnu.org/licenses/>. */ package io.bitsquare.gui.main.funds.withdrawal; import com.google.common.util.concurrent.FutureCallback; import de.jensd.fx.fontawesome.AwesomeIcon; import io.bitsquare.app.DevFlags; import io.bitsquare.btc.*; import io.bitsquare.btc.listeners.BalanceListener; import io.bitsquare.common.UserThread; import io.bitsquare.common.util.MathUtils; import io.bitsquare.gui.common.view.ActivatableView; import io.bitsquare.gui.common.view.FxmlView; import io.bitsquare.gui.components.HyperlinkWithIcon; import io.bitsquare.gui.main.overlays.popups.Popup; import io.bitsquare.gui.main.overlays.windows.WalletPasswordWindow; import io.bitsquare.gui.util.BSFormatter; import io.bitsquare.gui.util.GUIUtil; import io.bitsquare.gui.util.validation.BtcAddressValidator; import io.bitsquare.trade.Tradable; import io.bitsquare.trade.Trade; import io.bitsquare.trade.TradeManager; import io.bitsquare.trade.closed.ClosedTradableManager; import io.bitsquare.trade.failed.FailedTradesManager; import io.bitsquare.user.Preferences; import javafx.beans.property.ObjectProperty; import javafx.beans.property.ReadOnlyObjectWrapper; import javafx.beans.property.SimpleObjectProperty; import javafx.beans.value.ChangeListener; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.collections.transformation.SortedList; import javafx.fxml.FXML; import javafx.scene.control.*; import javafx.scene.layout.VBox; import javafx.util.Callback; import org.apache.commons.lang3.StringUtils; import org.bitcoinj.core.*; import org.jetbrains.annotations.NotNull; import org.spongycastle.crypto.params.KeyParameter; import javax.inject.Inject; import java.util.*; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; @FxmlView public class WithdrawalView extends ActivatableView<VBox, Void> { @FXML Button withdrawButton; @FXML TableView<WithdrawalListItem> tableView; @FXML TextField withdrawFromTextField, withdrawToTextField, amountTextField; @FXML TableColumn<WithdrawalListItem, WithdrawalListItem> addressColumn, balanceColumn, selectColumn; private final WalletService walletService; private final TradeManager tradeManager; private final ClosedTradableManager closedTradableManager; private final FailedTradesManager failedTradesManager; private final BSFormatter formatter; private final Preferences preferences; private final BtcAddressValidator btcAddressValidator; private final WalletPasswordWindow walletPasswordWindow; private final ObservableList<WithdrawalListItem> observableList = FXCollections.observableArrayList(); private final SortedList<WithdrawalListItem> sortedList = new SortedList<>(observableList); private Set<WithdrawalListItem> selectedItems = new HashSet<>(); private BalanceListener balanceListener; private Set<String> fromAddresses; private Coin amountOfSelectedItems = Coin.ZERO; private ObjectProperty<Coin> senderAmountAsCoinProperty = new SimpleObjectProperty<>(Coin.ZERO); private ChangeListener<String> amountListener; private ChangeListener<Boolean> amountFocusListener; /////////////////////////////////////////////////////////////////////////////////////////// // Constructor, lifecycle /////////////////////////////////////////////////////////////////////////////////////////// @Inject private WithdrawalView(WalletService walletService, TradeManager tradeManager, ClosedTradableManager closedTradableManager, FailedTradesManager failedTradesManager, BSFormatter formatter, Preferences preferences, BtcAddressValidator btcAddressValidator, WalletPasswordWindow walletPasswordWindow) { this.walletService = walletService; this.tradeManager = tradeManager; this.closedTradableManager = closedTradableManager; this.failedTradesManager = failedTradesManager; this.formatter = formatter; this.preferences = preferences; this.btcAddressValidator = btcAddressValidator; this.walletPasswordWindow = walletPasswordWindow; } @Override public void initialize() { tableView.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY); tableView.setPlaceholder(new Label("No funds are available for withdrawal")); tableView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE); setAddressColumnCellFactory(); setBalanceColumnCellFactory(); setSelectColumnCellFactory(); addressColumn.setComparator((o1, o2) -> o1.getAddressString().compareTo(o2.getAddressString())); balanceColumn.setComparator((o1, o2) -> o1.getBalance().compareTo(o2.getBalance())); balanceColumn.setSortType(TableColumn.SortType.DESCENDING); tableView.getSortOrder().add(balanceColumn); balanceListener = new BalanceListener() { @Override public void onBalanceChanged(Coin balance, Transaction tx) { updateList(); } }; amountListener = (observable, oldValue, newValue) -> { if (amountTextField.focusedProperty().get()) { try { senderAmountAsCoinProperty.set(formatter.parseToCoin(amountTextField.getText())); } catch (Throwable t) { log.error("Error at amountTextField input. " + t.toString()); } } }; amountFocusListener = (observable, oldValue, newValue) -> { if (oldValue && !newValue) { if (senderAmountAsCoinProperty.get().isPositive()) amountTextField.setText(formatter.formatCoin(senderAmountAsCoinProperty.get())); else amountTextField.setText(""); } }; } @Override protected void activate() { sortedList.comparatorProperty().bind(tableView.comparatorProperty()); tableView.setItems(sortedList); updateList(); reset(); amountTextField.textProperty().addListener(amountListener); amountTextField.focusedProperty().addListener(amountFocusListener); walletService.addBalanceListener(balanceListener); } @Override protected void deactivate() { sortedList.comparatorProperty().unbind(); observableList.forEach(WithdrawalListItem::cleanup); walletService.removeBalanceListener(balanceListener); amountTextField.textProperty().removeListener(amountListener); amountTextField.focusedProperty().removeListener(amountFocusListener); } /////////////////////////////////////////////////////////////////////////////////////////// // UI handlers /////////////////////////////////////////////////////////////////////////////////////////// @FXML public void onWithdraw() { if (areInputsValid()) { FutureCallback<Transaction> callback = new FutureCallback<Transaction>() { @Override public void onSuccess(@javax.annotation.Nullable Transaction transaction) { if (transaction != null) { log.debug("onWithdraw onSuccess tx ID:" + transaction.getHashAsString()); } else { log.error("onWithdraw transaction is null"); } List<Trade> trades = new ArrayList<>(tradeManager.getTrades()); trades.stream() .filter(trade -> trade.getState().getPhase() == Trade.Phase.PAYOUT_PAID) .forEach(trade -> { if (walletService.getBalanceForAddress(walletService.getOrCreateAddressEntry(trade.getId(), AddressEntry.Context.TRADE_PAYOUT).getAddress()).isZero()) tradeManager.addTradeToClosedTrades(trade); }); } @Override public void onFailure(@NotNull Throwable t) { log.error("onWithdraw onFailure"); } }; try { // We need to use the max. amount (amountOfSelectedItems) as the senderAmount might be less then // we have available and then the fee calculation would return 0 // TODO Get a proper fee calculation from BitcoinJ directly Coin requiredFee = null; try { requiredFee = walletService.getRequiredFeeForMultipleAddresses(fromAddresses, withdrawToTextField.getText(), amountOfSelectedItems); } catch (InsufficientFundsException e) { try { int txSize = walletService.getTransactionSize(fromAddresses, withdrawToTextField.getText(), senderAmountAsCoinProperty.get().subtract(FeePolicy.getNonTradeFeePerKb())); new Popup<>().warning(e.getMessage() + "\n" + "Transaction size: " + (txSize / 1000d) + " Kb").show(); } catch (InsufficientMoneyException e2) { new Popup<>().warning(e.getMessage()).show(); } } catch (Throwable t) { try { // TODO Using amountOfSelectedItems caused problems if it exceeds the max size (in case of arbitrator) log.warn("Error at getRequiredFeeForMultipleAddresses: " + t.toString() + "\n" + "We use the default fee instead to estimate tx size and then re-calculate fee."); int tempTxSize = walletService.getTransactionSize(fromAddresses, withdrawToTextField.getText(), senderAmountAsCoinProperty.get().subtract(FeePolicy.getNonTradeFeePerKb())); requiredFee = Coin.valueOf(FeePolicy.getNonTradeFeePerKb().value * tempTxSize / 1000); } catch (Throwable t2) { t2.printStackTrace(); log.error(t2.toString()); new Popup<>().error("Error at creating transaction: " + t2.toString()).show(); } } if (requiredFee != null) { Coin receiverAmount = senderAmountAsCoinProperty.get().subtract(requiredFee); int txSize = walletService.getTransactionSize(fromAddresses, withdrawToTextField.getText(), receiverAmount); log.info("Fee for tx with size {}: {} BTC", txSize, requiredFee.toPlainString()); if (receiverAmount.isPositive()) { if (DevFlags.DEV_MODE) { doWithdraw(receiverAmount, callback); } else { double satPerByte = (double) requiredFee.value / (double) txSize; new Popup().headLine("Confirm withdrawal request") .confirmation("Sending: " + formatter.formatCoinWithCode(senderAmountAsCoinProperty.get()) + "\n" + "From address: " + withdrawFromTextField.getText() + "\n" + "To receiving address: " + withdrawToTextField.getText() + ".\n" + "Required transaction fee is: " + formatter.formatCoinWithCode(requiredFee) + " (" + MathUtils.roundDouble(satPerByte, 2) + " Satoshis/byte)\n" + "Transaction size: " + (txSize / 1000d) + " Kb\n\n" + "The recipient will receive: " + formatter.formatCoinWithCode(receiverAmount) + "\n\n" + "Are you sure you want to withdraw that amount?") .actionButtonText("Yes") .onAction(() -> doWithdraw(receiverAmount, callback)) .closeButtonText("Cancel") .show(); } } else { new Popup().warning("The amount you would like to send is too low as the bitcoin transaction fee will be deducted.\n" + "Please use a higher amount.").show(); } } } catch (Throwable e) { e.printStackTrace(); log.error(e.toString()); new Popup().warning(e.getMessage()).show(); } } } private void selectForWithdrawal(WithdrawalListItem item, boolean isSelected) { if (isSelected) selectedItems.add(item); else selectedItems.remove(item); fromAddresses = selectedItems.stream() .map(WithdrawalListItem::getAddressString) .collect(Collectors.toSet()); if (!selectedItems.isEmpty()) { amountOfSelectedItems = Coin.valueOf(selectedItems.stream().mapToLong(e -> e.getBalance().getValue()).sum()); if (amountOfSelectedItems.isPositive()) { senderAmountAsCoinProperty.set(amountOfSelectedItems); amountTextField.setText(formatter.formatCoin(amountOfSelectedItems)); } else { senderAmountAsCoinProperty.set(Coin.ZERO); amountOfSelectedItems = Coin.ZERO; amountTextField.setText(""); withdrawFromTextField.setText(""); } if (selectedItems.size() == 1) { withdrawFromTextField.setText(selectedItems.stream().findAny().get().getAddressEntry().getAddressString()); withdrawFromTextField.setTooltip(null); } else { String tooltipText = "Withdraw from multiple addresses:\n" + selectedItems.stream() .map(WithdrawalListItem::getAddressString) .collect(Collectors.joining(",\n")); int abbr = Math.max(10, 66 / selectedItems.size()); String text = "Withdraw from multiple addresses (" + selectedItems.stream() .map(e -> StringUtils.abbreviate(e.getAddressString(), abbr)) .collect(Collectors.joining(", ")) + ")"; withdrawFromTextField.setText(text); withdrawFromTextField.setTooltip(new Tooltip(tooltipText)); } } else { reset(); } } private void openBlockExplorer(WithdrawalListItem item) { if (item.getAddressString() != null) { try { GUIUtil.openWebPage(preferences.getBlockChainExplorer().addressUrl + item.getAddressString()); } catch (Exception e) { log.error(e.getMessage()); new Popup().warning("Opening browser failed. Please check your internet " + "connection.").show(); } } } /////////////////////////////////////////////////////////////////////////////////////////// // Private /////////////////////////////////////////////////////////////////////////////////////////// private void updateList() { observableList.forEach(WithdrawalListItem::cleanup); observableList.setAll(tradeManager.getAddressEntriesForAvailableBalanceStream() .map(addressEntry -> new WithdrawalListItem(addressEntry, walletService, formatter)) .collect(Collectors.toList())); } private void doWithdraw(Coin amount, FutureCallback<Transaction> callback) { if (walletService.getWallet().isEncrypted()) { UserThread.runAfter(() -> walletPasswordWindow.onAesKey(aesKey -> sendFunds(amount, aesKey, callback)) .show(), 300, TimeUnit.MILLISECONDS); } else { sendFunds(amount, null, callback); } } private void sendFunds(Coin amount, KeyParameter aesKey, FutureCallback<Transaction> callback) { try { walletService.sendFundsForMultipleAddresses(fromAddresses, withdrawToTextField.getText(), amount, null, aesKey, callback); reset(); updateList(); } catch (AddressFormatException e) { new Popup().warning("The address is not correct. Please check the address format.").show(); } catch (Wallet.DustySendRequested e) { new Popup().warning("The amount you would like to send is below the dust limit and would be rejected by the bitcoin network.\n" + "Please use a higher amount.").show(); } catch (AddressEntryException e) { new Popup().error(e.getMessage()).show(); } catch (InsufficientMoneyException e) { log.warn(e.getMessage()); new Popup().warning("You don't have enough fund in your wallet.").show(); } catch (Throwable e) { log.warn(e.getMessage()); new Popup().warning(e.getMessage()).show(); } } private void reset() { selectedItems = new HashSet<>(); tableView.getSelectionModel().clearSelection(); withdrawFromTextField.setText(""); withdrawFromTextField.setPromptText("Select a source address from the table"); withdrawFromTextField.setTooltip(null); amountOfSelectedItems = Coin.ZERO; senderAmountAsCoinProperty.set(Coin.ZERO); amountTextField.setText(""); amountTextField.setPromptText("Set the amount to withdraw"); withdrawToTextField.setText(""); withdrawToTextField.setPromptText("Fill in your destination address"); if (DevFlags.DEV_MODE) withdrawToTextField.setText("mjYhQYSbET2bXJDyCdNqYhqSye5QX2WHPz"); } private Optional<Tradable> getTradable(WithdrawalListItem item) { String offerId = item.getAddressEntry().getOfferId(); Optional<Tradable> tradableOptional = closedTradableManager.getTradableById(offerId); if (tradableOptional.isPresent()) { return tradableOptional; } else if (failedTradesManager.getTradeById(offerId).isPresent()) { return Optional.of(failedTradesManager.getTradeById(offerId).get()); } else { return Optional.empty(); } } private boolean areInputsValid() { if (!senderAmountAsCoinProperty.get().isPositive()) { new Popup().warning("Please fill in a valid value for the amount to send (max. 8 decimal places).").show(); return false; } if (!btcAddressValidator.validate(withdrawToTextField.getText()).isValid) { new Popup().warning("Please fill in a valid receiver bitcoin address.").show(); return false; } if (!amountOfSelectedItems.isPositive()) { new Popup().warning("You need to select a source address in the table above.").show(); return false; } if (senderAmountAsCoinProperty.get().compareTo(amountOfSelectedItems) > 0) { new Popup().warning("Your amount exceeds the available amount for the selected address.\n" + "Consider to select multiple addresses in the table above if you want to withdraw more.").show(); return false; } return true; } /////////////////////////////////////////////////////////////////////////////////////////// // ColumnCellFactories /////////////////////////////////////////////////////////////////////////////////////////// private void setAddressColumnCellFactory() { addressColumn.setCellValueFactory((addressListItem) -> new ReadOnlyObjectWrapper<>(addressListItem.getValue())); addressColumn.setCellFactory( new Callback<TableColumn<WithdrawalListItem, WithdrawalListItem>, TableCell<WithdrawalListItem, WithdrawalListItem>>() { @Override public TableCell<WithdrawalListItem, WithdrawalListItem> call(TableColumn<WithdrawalListItem, WithdrawalListItem> column) { return new TableCell<WithdrawalListItem, WithdrawalListItem>() { private HyperlinkWithIcon hyperlinkWithIcon; @Override public void updateItem(final WithdrawalListItem item, boolean empty) { super.updateItem(item, empty); if (item != null && !empty) { String address = item.getAddressString(); hyperlinkWithIcon = new HyperlinkWithIcon(address, AwesomeIcon.EXTERNAL_LINK); hyperlinkWithIcon.setOnAction(event -> openBlockExplorer(item)); hyperlinkWithIcon.setTooltip(new Tooltip("Open external blockchain explorer for " + "address: " + address)); setGraphic(hyperlinkWithIcon); } else { setGraphic(null); if (hyperlinkWithIcon != null) hyperlinkWithIcon.setOnAction(null); } } }; } }); } private void setBalanceColumnCellFactory() { balanceColumn.setCellValueFactory((addressListItem) -> new ReadOnlyObjectWrapper<>(addressListItem.getValue())); balanceColumn.setCellFactory( new Callback<TableColumn<WithdrawalListItem, WithdrawalListItem>, TableCell<WithdrawalListItem, WithdrawalListItem>>() { @Override public TableCell<WithdrawalListItem, WithdrawalListItem> call(TableColumn<WithdrawalListItem, WithdrawalListItem> column) { return new TableCell<WithdrawalListItem, WithdrawalListItem>() { @Override public void updateItem(final WithdrawalListItem item, boolean empty) { super.updateItem(item, empty); setGraphic((item != null && !empty) ? item.getBalanceLabel() : null); } }; } }); } private void setSelectColumnCellFactory() { selectColumn.setCellValueFactory((addressListItem) -> new ReadOnlyObjectWrapper<>(addressListItem.getValue())); selectColumn.setCellFactory( new Callback<TableColumn<WithdrawalListItem, WithdrawalListItem>, TableCell<WithdrawalListItem, WithdrawalListItem>>() { @Override public TableCell<WithdrawalListItem, WithdrawalListItem> call(TableColumn<WithdrawalListItem, WithdrawalListItem> column) { return new TableCell<WithdrawalListItem, WithdrawalListItem>() { CheckBox checkBox; @Override public void updateItem(final WithdrawalListItem item, boolean empty) { super.updateItem(item, empty); if (item != null && !empty) { if (checkBox == null) { checkBox = new CheckBox(); checkBox.setOnAction(e -> selectForWithdrawal(item, checkBox.isSelected())); setGraphic(checkBox); } } else { setGraphic(null); if (checkBox != null) { checkBox.setOnAction(null); checkBox = null; } } } }; } }); } }