/*
* 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;
}
}
}
};
}
});
}
}