/*
* 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.offer.takeoffer;
import com.google.inject.Inject;
import io.bitsquare.app.DevFlags;
import io.bitsquare.arbitration.Arbitrator;
import io.bitsquare.btc.AddressEntry;
import io.bitsquare.btc.FeePolicy;
import io.bitsquare.btc.TradeWalletService;
import io.bitsquare.btc.WalletService;
import io.bitsquare.btc.blockchain.BlockchainService;
import io.bitsquare.btc.listeners.BalanceListener;
import io.bitsquare.btc.pricefeed.PriceFeedService;
import io.bitsquare.gui.common.model.ActivatableDataModel;
import io.bitsquare.gui.main.overlays.notifications.Notification;
import io.bitsquare.gui.main.overlays.popups.Popup;
import io.bitsquare.gui.util.BSFormatter;
import io.bitsquare.locale.CurrencyUtil;
import io.bitsquare.payment.PaymentAccount;
import io.bitsquare.payment.PaymentAccountUtil;
import io.bitsquare.payment.PaymentMethod;
import io.bitsquare.trade.TradeManager;
import io.bitsquare.trade.handlers.TradeResultHandler;
import io.bitsquare.trade.offer.Offer;
import io.bitsquare.user.Preferences;
import io.bitsquare.user.User;
import javafx.beans.property.*;
import javafx.collections.ObservableList;
import org.bitcoinj.core.Coin;
import org.bitcoinj.core.Transaction;
import org.bitcoinj.utils.ExchangeRate;
import org.bitcoinj.utils.Fiat;
import java.util.List;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;
/**
* Domain for that UI element.
* Note that the create offer domain has a deeper scope in the application domain (TradeManager).
* That model is just responsible for the domain specific parts displayed needed in that UI element.
*/
class TakeOfferDataModel extends ActivatableDataModel {
private final TradeManager tradeManager;
final TradeWalletService tradeWalletService;
final WalletService walletService;
private final User user;
private final Preferences preferences;
private final PriceFeedService priceFeedService;
private final BlockchainService blockchainService;
private final BSFormatter formatter;
private final Coin takerFeeAsCoin;
private final Coin networkFeeAsCoin;
private final Coin securityDepositAsCoin;
// Coin feeFromFundingTx = Coin.NEGATIVE_SATOSHI;
private Offer offer;
private AddressEntry addressEntry;
final StringProperty btcCode = new SimpleStringProperty();
final BooleanProperty isWalletFunded = new SimpleBooleanProperty();
// final BooleanProperty isFeeFromFundingTxSufficient = new SimpleBooleanProperty();
// final BooleanProperty isMainNet = new SimpleBooleanProperty();
final ObjectProperty<Coin> amountAsCoin = new SimpleObjectProperty<>();
final ObjectProperty<Fiat> volumeAsFiat = new SimpleObjectProperty<>();
final ObjectProperty<Coin> totalToPayAsCoin = new SimpleObjectProperty<>();
final ObjectProperty<Coin> balance = new SimpleObjectProperty<>();
final ObjectProperty<Coin> missingCoin = new SimpleObjectProperty<>(Coin.ZERO);
private BalanceListener balanceListener;
PaymentAccount paymentAccount;
private boolean isTabSelected;
boolean useSavingsWallet;
Coin totalAvailableBalance;
private Notification walletFundedNotification;
Fiat tradePrice;
///////////////////////////////////////////////////////////////////////////////////////////
// Constructor, lifecycle
///////////////////////////////////////////////////////////////////////////////////////////
@Inject
TakeOfferDataModel(TradeManager tradeManager, TradeWalletService tradeWalletService,
WalletService walletService, User user,
Preferences preferences, PriceFeedService priceFeedService, BlockchainService blockchainService,
BSFormatter formatter) {
this.tradeManager = tradeManager;
this.tradeWalletService = tradeWalletService;
this.walletService = walletService;
this.user = user;
this.preferences = preferences;
this.priceFeedService = priceFeedService;
this.blockchainService = blockchainService;
this.formatter = formatter;
takerFeeAsCoin = FeePolicy.getTakeOfferFee();
networkFeeAsCoin = FeePolicy.getFixedTxFeeForTrades();
securityDepositAsCoin = FeePolicy.getSecurityDeposit();
// isMainNet.set(preferences.getBitcoinNetwork() == BitcoinNetwork.MAINNET);
}
@Override
protected void activate() {
// when leaving screen we reset state
offer.setState(Offer.State.UNDEFINED);
addBindings();
addListeners();
updateBalance();
// TODO In case that we have funded but restarted, or canceled but took again the offer we would need to
// store locally the result when we received the funding tx(s).
// For now we just ignore that rare case and bypass the check by setting a sufficient value
// if (isWalletFunded.get())
// feeFromFundingTxProperty.set(FeePolicy.getMinRequiredFeeForFundingTx());
if (!preferences.getUseStickyMarketPrice() && isTabSelected)
priceFeedService.setCurrencyCode(offer.getCurrencyCode());
tradeManager.checkOfferAvailability(offer,
() -> {
},
errorMessage -> new Popup().warning(errorMessage).show());
}
@Override
protected void deactivate() {
removeBindings();
removeListeners();
if (offer != null)
tradeManager.onCancelAvailabilityRequest(offer);
}
///////////////////////////////////////////////////////////////////////////////////////////
// API
///////////////////////////////////////////////////////////////////////////////////////////
// called before activate
void initWithData(Offer offer) {
this.offer = offer;
tradePrice = offer.getPrice();
addressEntry = walletService.getOrCreateAddressEntry(offer.getId(), AddressEntry.Context.OFFER_FUNDING);
checkNotNull(addressEntry, "addressEntry must not be null");
ObservableList<PaymentAccount> possiblePaymentAccounts = getPossiblePaymentAccounts();
checkArgument(!possiblePaymentAccounts.isEmpty(), "possiblePaymentAccounts.isEmpty()");
paymentAccount = possiblePaymentAccounts.get(0);
amountAsCoin.set(offer.getAmount());
if (DevFlags.DEV_MODE)
amountAsCoin.set(offer.getAmount());
calculateVolume();
calculateTotalToPay();
balanceListener = new BalanceListener(addressEntry.getAddress()) {
@Override
public void onBalanceChanged(Coin balance, Transaction tx) {
updateBalance();
/*if (isMainNet.get()) {
SettableFuture<Coin> future = blockchainService.requestFee(tx.getHashAsString());
Futures.addCallback(future, new FutureCallback<Coin>() {
public void onSuccess(Coin fee) {
UserThread.execute(() -> setFeeFromFundingTx(fee));
}
public void onFailure(@NotNull Throwable throwable) {
UserThread.execute(() -> new Popup()
.warning("We did not get a response for the request of the mining fee used " +
"in the funding transaction.\n\n" +
"Are you sure you used a sufficiently high fee of at least " +
formatter.formatCoinWithCode(FeePolicy.getMinRequiredFeeForFundingTx()) + "?")
.actionButtonText("Yes, I used a sufficiently high fee.")
.onAction(() -> setFeeFromFundingTx(FeePolicy.getMinRequiredFeeForFundingTx()))
.closeButtonText("No. Let's cancel that payment.")
.onClose(() -> setFeeFromFundingTx(Coin.NEGATIVE_SATOSHI))
.show());
}
});
} else {
setFeeFromFundingTx(FeePolicy.getMinRequiredFeeForFundingTx());
isFeeFromFundingTxSufficient.set(feeFromFundingTx.compareTo(FeePolicy.getMinRequiredFeeForFundingTx()) >= 0);
}*/
}
};
offer.resetState();
if (!preferences.getUseStickyMarketPrice())
priceFeedService.setCurrencyCode(offer.getCurrencyCode());
}
void onTabSelected(boolean isSelected) {
this.isTabSelected = isSelected;
if (!preferences.getUseStickyMarketPrice() && isTabSelected)
priceFeedService.setCurrencyCode(offer.getCurrencyCode());
}
///////////////////////////////////////////////////////////////////////////////////////////
// UI actions
///////////////////////////////////////////////////////////////////////////////////////////
// errorMessageHandler is used only in the check availability phase. As soon we have a trade we write the error msg in the trade object as we want to
// have it persisted as well.
void onTakeOffer(TradeResultHandler tradeResultHandler) {
tradeManager.onTakeOffer(amountAsCoin.get(),
tradePrice.getValue(),
totalToPayAsCoin.get().subtract(takerFeeAsCoin),
offer,
paymentAccount.getId(),
useSavingsWallet,
tradeResultHandler,
errorMessage -> {
log.warn(errorMessage);
new Popup<>().warning(errorMessage).show();
}
);
}
public void onPaymentAccountSelected(PaymentAccount paymentAccount) {
if (paymentAccount != null)
this.paymentAccount = paymentAccount;
}
void fundFromSavingsWallet() {
useSavingsWallet = true;
updateBalance();
if (!isWalletFunded.get())
this.useSavingsWallet = false;
}
///////////////////////////////////////////////////////////////////////////////////////////
// Getters
///////////////////////////////////////////////////////////////////////////////////////////
Offer.Direction getDirection() {
return offer.getDirection();
}
public Offer getOffer() {
return offer;
}
ObservableList<PaymentAccount> getPossiblePaymentAccounts() {
return PaymentAccountUtil.getPossiblePaymentAccounts(offer, user.getPaymentAccounts());
}
boolean hasAcceptedArbitrators() {
return user.getAcceptedArbitrators().size() > 0;
}
///////////////////////////////////////////////////////////////////////////////////////////
// Bindings, listeners
///////////////////////////////////////////////////////////////////////////////////////////
private void addBindings() {
btcCode.bind(preferences.btcDenominationProperty());
}
private void removeBindings() {
btcCode.unbind();
}
private void addListeners() {
walletService.addBalanceListener(balanceListener);
}
private void removeListeners() {
walletService.removeBalanceListener(balanceListener);
}
///////////////////////////////////////////////////////////////////////////////////////////
// Utils
///////////////////////////////////////////////////////////////////////////////////////////
void calculateVolume() {
if (tradePrice != null && offer != null &&
amountAsCoin.get() != null &&
!amountAsCoin.get().isZero()) {
volumeAsFiat.set(new ExchangeRate(tradePrice).coinToFiat(amountAsCoin.get()));
updateBalance();
}
}
void setAmount(Coin amount) {
amountAsCoin.set(amount);
calculateTotalToPay();
}
void calculateTotalToPay() {
if (offer != null && amountAsCoin.get() != null) {
if (getDirection() == Offer.Direction.SELL)
totalToPayAsCoin.set(takerFeeAsCoin.add(networkFeeAsCoin).add(securityDepositAsCoin));
else
totalToPayAsCoin.set(takerFeeAsCoin.add(networkFeeAsCoin).add(securityDepositAsCoin).add(amountAsCoin.get()));
updateBalance();
log.debug("totalToPayAsCoin " + totalToPayAsCoin.get().toFriendlyString());
}
}
void updateBalance() {
Coin tradeWalletBalance = walletService.getBalanceForAddress(addressEntry.getAddress());
if (useSavingsWallet) {
Coin savingWalletBalance = walletService.getSavingWalletBalance();
totalAvailableBalance = savingWalletBalance.add(tradeWalletBalance);
if (totalToPayAsCoin.get() != null) {
if (totalAvailableBalance.compareTo(totalToPayAsCoin.get()) > 0)
balance.set(totalToPayAsCoin.get());
else
balance.set(totalAvailableBalance);
}
} else {
balance.set(tradeWalletBalance);
}
if (totalToPayAsCoin.get() != null) {
missingCoin.set(totalToPayAsCoin.get().subtract(balance.get()));
if (missingCoin.get().isNegative())
missingCoin.set(Coin.ZERO);
}
log.debug("missingCoin " + missingCoin.get().toFriendlyString());
isWalletFunded.set(isBalanceSufficient(balance.get()));
if (totalToPayAsCoin.get() != null && isWalletFunded.get() && walletFundedNotification == null && !DevFlags.DEV_MODE) {
walletFundedNotification = new Notification()
.headLine("Trading wallet update")
.notification("Your trading wallet is sufficiently funded.\n" +
"Amount: " + formatter.formatCoinWithCode(totalToPayAsCoin.get()))
.autoClose();
walletFundedNotification.show();
}
}
private boolean isBalanceSufficient(Coin balance) {
return totalToPayAsCoin.get() != null && balance.compareTo(totalToPayAsCoin.get()) >= 0;
}
public void swapTradeToSavings() {
walletService.swapTradeEntryToAvailableEntry(offer.getId(), AddressEntry.Context.OFFER_FUNDING);
walletService.swapTradeEntryToAvailableEntry(offer.getId(), AddressEntry.Context.RESERVED_FOR_TRADE);
}
/* private void setFeeFromFundingTx(Coin fee) {
feeFromFundingTx = fee;
isFeeFromFundingTxSufficient.set(feeFromFundingTx.compareTo(FeePolicy.getMinRequiredFeeForFundingTx()) >= 0);
}*/
boolean isMinAmountLessOrEqualAmount() {
//noinspection SimplifiableIfStatement
if (offer != null && amountAsCoin.get() != null)
return !offer.getMinAmount().isGreaterThan(amountAsCoin.get());
return true;
}
boolean isAmountLargerThanOfferAmount() {
//noinspection SimplifiableIfStatement
if (amountAsCoin.get() != null && offer != null)
return amountAsCoin.get().isGreaterThan(offer.getAmount());
return true;
}
boolean wouldCreateDustForOfferer() {
//noinspection SimplifiableIfStatement
if (amountAsCoin.get() != null && offer != null) {
Coin customAmount = offer.getAmount().subtract(amountAsCoin.get());
Coin dustAndFee = FeePolicy.getFixedTxFeeForTrades().add(Transaction.MIN_NONDUST_OUTPUT);
return customAmount.isPositive() && customAmount.isLessThan(dustAndFee);
} else {
return true;
}
}
public PaymentMethod getPaymentMethod() {
return offer.getPaymentMethod();
}
public String getCurrencyCode() {
return offer.getCurrencyCode();
}
public String getCurrencyNameAndCode() {
return CurrencyUtil.getNameByCode(offer.getCurrencyCode());
}
public Coin getSecurityDepositAsCoin() {
return securityDepositAsCoin;
}
public Coin getTakerFeeAsCoin() {
return takerFeeAsCoin;
}
public Coin getNetworkFeeAsCoin() {
return networkFeeAsCoin;
}
public AddressEntry getAddressEntry() {
return addressEntry;
}
public List<Arbitrator> getArbitrators() {
return user.getAcceptedArbitrators();
}
public Preferences getPreferences() {
return preferences;
}
}