/*
* 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.createoffer;
import com.google.inject.Inject;
import io.bitsquare.app.DevFlags;
import io.bitsquare.app.Version;
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.common.crypto.KeyRing;
import io.bitsquare.common.util.Utilities;
import io.bitsquare.gui.Navigation;
import io.bitsquare.gui.common.model.ActivatableDataModel;
import io.bitsquare.gui.main.offer.createoffer.monetary.Price;
import io.bitsquare.gui.main.offer.createoffer.monetary.Volume;
import io.bitsquare.gui.main.overlays.notifications.Notification;
import io.bitsquare.gui.util.BSFormatter;
import io.bitsquare.locale.CurrencyUtil;
import io.bitsquare.locale.TradeCurrency;
import io.bitsquare.p2p.P2PService;
import io.bitsquare.payment.*;
import io.bitsquare.trade.handlers.TransactionResultHandler;
import io.bitsquare.trade.offer.Offer;
import io.bitsquare.trade.offer.OpenOfferManager;
import io.bitsquare.user.Preferences;
import io.bitsquare.user.User;
import javafx.beans.property.*;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.collections.SetChangeListener;
import org.bitcoinj.core.Coin;
import org.bitcoinj.core.Transaction;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Collectors;
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 CreateOfferDataModel extends ActivatableDataModel {
private final OpenOfferManager openOfferManager;
final WalletService walletService;
final TradeWalletService tradeWalletService;
private final Preferences preferences;
private final User user;
private final KeyRing keyRing;
private final P2PService p2PService;
private final PriceFeedService priceFeedService;
final String shortOfferId;
private Navigation navigation;
private final BlockchainService blockchainService;
private final BSFormatter formatter;
private final String offerId;
private final AddressEntry addressEntry;
private final Coin offerFeeAsCoin;
private final Coin networkFeeAsCoin;
private final Coin securityDepositAsCoin;
private final BalanceListener balanceListener;
private final SetChangeListener<PaymentAccount> paymentAccountsChangeListener;
private Offer.Direction direction;
private TradeCurrency tradeCurrency;
final StringProperty tradeCurrencyCode = new SimpleStringProperty();
final StringProperty btcCode = new SimpleStringProperty();
final BooleanProperty isWalletFunded = new SimpleBooleanProperty();
final BooleanProperty useMarketBasedPrice = new SimpleBooleanProperty();
//final BooleanProperty isMainNet = new SimpleBooleanProperty();
//final BooleanProperty isFeeFromFundingTxSufficient = new SimpleBooleanProperty();
// final ObjectProperty<Coin> feeFromFundingTxProperty = new SimpleObjectProperty(Coin.NEGATIVE_SATOSHI);
final ObjectProperty<Coin> amount = new SimpleObjectProperty<>();
final ObjectProperty<Coin> minAmount = new SimpleObjectProperty<>();
// Price is always otherCurrency/BTC, for altcoins we only invert at the display level.
// If we would change the price representation in the domain we would not be backward compatible
final ObjectProperty<Price> price = new SimpleObjectProperty<>();
final ObjectProperty<Volume> volume = new SimpleObjectProperty<>();
final ObjectProperty<Coin> totalToPayAsCoin = new SimpleObjectProperty<>();
final ObjectProperty<Coin> missingCoin = new SimpleObjectProperty<>(Coin.ZERO);
final ObjectProperty<Coin> balance = new SimpleObjectProperty<>();
private final ObservableList<PaymentAccount> paymentAccounts = FXCollections.observableArrayList();
PaymentAccount paymentAccount;
boolean isTabSelected;
private Notification walletFundedNotification;
boolean useSavingsWallet;
Coin totalAvailableBalance;
private double marketPriceMargin = 0;
///////////////////////////////////////////////////////////////////////////////////////////
// Constructor, lifecycle
///////////////////////////////////////////////////////////////////////////////////////////
@Inject
CreateOfferDataModel(OpenOfferManager openOfferManager, WalletService walletService, TradeWalletService tradeWalletService,
Preferences preferences, User user, KeyRing keyRing, P2PService p2PService, PriceFeedService priceFeedService,
Navigation navigation, BlockchainService blockchainService, BSFormatter formatter) {
this.openOfferManager = openOfferManager;
this.walletService = walletService;
this.tradeWalletService = tradeWalletService;
this.preferences = preferences;
this.user = user;
this.keyRing = keyRing;
this.p2PService = p2PService;
this.priceFeedService = priceFeedService;
this.navigation = navigation;
this.blockchainService = blockchainService;
this.formatter = formatter;
offerId = Utilities.getRandomPrefix(5, 8) + "-" +
UUID.randomUUID().toString() + "-" +
Version.VERSION.replace(".", "");
shortOfferId = Utilities.getShortId(offerId);
addressEntry = walletService.getOrCreateAddressEntry(offerId, AddressEntry.Context.OFFER_FUNDING);
offerFeeAsCoin = FeePolicy.getCreateOfferFee();
networkFeeAsCoin = FeePolicy.getFixedTxFeeForTrades();
securityDepositAsCoin = FeePolicy.getSecurityDeposit();
useMarketBasedPrice.set(preferences.getUsePercentageBasedPrice());
balanceListener = new BalanceListener(getAddressEntry().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(() -> feeFromFundingTxProperty.set(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(() -> feeFromFundingTxProperty.set(FeePolicy.getMinRequiredFeeForFundingTx()))
.closeButtonText("No. Let's cancel that payment.")
.onClose(() -> feeFromFundingTxProperty.set(Coin.ZERO))
.show());
}
});
}*/
}
};
paymentAccountsChangeListener = change -> fillPaymentAccounts();
}
@Override
protected void activate() {
addBindings();
addListeners();
if (!preferences.getUseStickyMarketPrice() && isTabSelected)
priceFeedService.setCurrencyCode(tradeCurrencyCode.get());
updateBalance();
}
@Override
protected void deactivate() {
removeBindings();
removeListeners();
}
private void addBindings() {
btcCode.bind(preferences.btcDenominationProperty());
}
private void removeBindings() {
btcCode.unbind();
}
private void addListeners() {
walletService.addBalanceListener(balanceListener);
user.getPaymentAccountsAsObservable().addListener(paymentAccountsChangeListener);
}
private void removeListeners() {
walletService.removeBalanceListener(balanceListener);
user.getPaymentAccountsAsObservable().removeListener(paymentAccountsChangeListener);
}
///////////////////////////////////////////////////////////////////////////////////////////
// API
///////////////////////////////////////////////////////////////////////////////////////////
boolean initWithData(Offer.Direction direction, TradeCurrency tradeCurrency) {
this.direction = direction;
fillPaymentAccounts();
PaymentAccount account = user.findFirstPaymentAccountWithCurrency(tradeCurrency);
if (account != null && !isUSBankAccount(account)) {
paymentAccount = account;
this.tradeCurrency = tradeCurrency;
} else {
Optional<PaymentAccount> paymentAccountOptional = paymentAccounts.stream().findAny();
if (paymentAccountOptional.isPresent()) {
paymentAccount = paymentAccountOptional.get();
this.tradeCurrency = paymentAccount.getSingleTradeCurrency();
} else {
log.warn("PaymentAccount not available. Should never get called as in offer view you should not be able to open a create offer view");
return false;
}
}
tradeCurrencyCode.set(this.tradeCurrency.getCode());
if (!preferences.getUseStickyMarketPrice())
priceFeedService.setCurrencyCode(tradeCurrencyCode.get());
calculateVolume();
calculateTotalToPay();
return true;
}
void onTabSelected(boolean isSelected) {
this.isTabSelected = isSelected;
if (!preferences.getUseStickyMarketPrice() && isTabSelected)
priceFeedService.setCurrencyCode(tradeCurrencyCode.get());
}
///////////////////////////////////////////////////////////////////////////////////////////
// UI actions
///////////////////////////////////////////////////////////////////////////////////////////
Offer createAndGetOffer() {
long priceAsLong = price.get() != null && !useMarketBasedPrice.get() ? price.get().getValue() : 0L;
// We use precision 8 in AltcoinPrice but in Offer we use Fiat with precision 4. Will be refactored once in a bigger update....
if (CurrencyUtil.isCryptoCurrency(tradeCurrencyCode.get()))
priceAsLong = priceAsLong / 10000;
double marketPriceMarginParam = useMarketBasedPrice.get() ? marketPriceMargin : 0;
long amount = this.amount.get() != null ? this.amount.get().getValue() : 0L;
long minAmount = this.minAmount.get() != null ? this.minAmount.get().getValue() : 0L;
ArrayList<String> acceptedCountryCodes = null;
if (paymentAccount instanceof SepaAccount) {
acceptedCountryCodes = new ArrayList<>();
acceptedCountryCodes.addAll(((SepaAccount) paymentAccount).getAcceptedCountryCodes());
} else if (paymentAccount instanceof CountryBasedPaymentAccount) {
acceptedCountryCodes = new ArrayList<>();
acceptedCountryCodes.add(((CountryBasedPaymentAccount) paymentAccount).getCountry().code);
}
ArrayList<String> acceptedBanks = null;
if (paymentAccount instanceof SpecificBanksAccount) {
acceptedBanks = new ArrayList<>(((SpecificBanksAccount) paymentAccount).getAcceptedBanks());
} else if (paymentAccount instanceof SameBankAccount) {
acceptedBanks = new ArrayList<>();
acceptedBanks.add(((SameBankAccount) paymentAccount).getBankId());
}
String bankId = paymentAccount instanceof BankAccount ? ((BankAccount) paymentAccount).getBankId() : null;
// That is optional and set to null if not supported (AltCoins, OKPay,...)
String countryCode = paymentAccount instanceof CountryBasedPaymentAccount ? ((CountryBasedPaymentAccount) paymentAccount).getCountry().code : null;
checkNotNull(p2PService.getAddress(), "Address must not be null");
return new Offer(offerId,
p2PService.getAddress(),
keyRing.getPubKeyRing(),
direction,
priceAsLong,
marketPriceMarginParam,
useMarketBasedPrice.get(),
amount,
minAmount,
tradeCurrencyCode.get(),
new ArrayList<>(user.getAcceptedArbitratorAddresses()),
paymentAccount.getPaymentMethod().getId(),
paymentAccount.getId(),
countryCode,
acceptedCountryCodes,
bankId,
acceptedBanks,
priceFeedService);
}
void onPlaceOffer(Offer offer, TransactionResultHandler resultHandler) {
openOfferManager.placeOffer(offer, totalToPayAsCoin.get().subtract(offerFeeAsCoin), useSavingsWallet, resultHandler);
}
public void onPaymentAccountSelected(PaymentAccount paymentAccount) {
if (paymentAccount != null) {
if (!this.paymentAccount.equals(paymentAccount)) {
volume.set(null);
price.set(null);
marketPriceMargin = 0;
}
this.paymentAccount = paymentAccount;
}
}
public void onCurrencySelected(TradeCurrency tradeCurrency) {
if (tradeCurrency != null) {
if (!this.tradeCurrency.equals(tradeCurrency)) {
volume.set(null);
price.set(null);
marketPriceMargin = 0;
}
this.tradeCurrency = tradeCurrency;
final String code = tradeCurrency.getCode();
tradeCurrencyCode.set(code);
if (paymentAccount != null)
paymentAccount.setSelectedTradeCurrency(tradeCurrency);
if (!preferences.getUseStickyMarketPrice())
priceFeedService.setCurrencyCode(code);
Optional<TradeCurrency> tradeCurrencyOptional = preferences.getTradeCurrenciesAsObservable().stream().filter(e -> e.getCode().equals(code)).findAny();
if (!tradeCurrencyOptional.isPresent()) {
if (CurrencyUtil.isCryptoCurrency(code)) {
CurrencyUtil.getCryptoCurrency(code).ifPresent(cryptoCurrency -> {
preferences.addCryptoCurrency(cryptoCurrency);
});
} else {
CurrencyUtil.getFiatCurrency(code).ifPresent(fiatCurrency -> {
preferences.addFiatCurrency(fiatCurrency);
});
}
}
}
}
void fundFromSavingsWallet() {
this.useSavingsWallet = true;
updateBalance();
if (!isWalletFunded.get()) {
this.useSavingsWallet = false;
updateBalance();
}
}
void setMarketPriceMargin(double marketPriceMargin) {
this.marketPriceMargin = marketPriceMargin;
}
///////////////////////////////////////////////////////////////////////////////////////////
// Getters
///////////////////////////////////////////////////////////////////////////////////////////
@SuppressWarnings("BooleanMethodIsAlwaysInverted")
boolean isMinAmountLessOrEqualAmount() {
//noinspection SimplifiableIfStatement
if (minAmount.get() != null && amount.get() != null)
return !minAmount.get().isGreaterThan(amount.get());
return true;
}
Offer.Direction getDirection() {
return direction;
}
String getOfferId() {
return offerId;
}
AddressEntry getAddressEntry() {
return addressEntry;
}
public TradeCurrency getTradeCurrency() {
return tradeCurrency;
}
public PaymentAccount getPaymentAccount() {
return paymentAccount;
}
boolean hasAcceptedArbitrators() {
return user.getAcceptedArbitrators().size() > 0;
}
public void setUseMarketBasedPrice(boolean useMarketBasedPrice) {
this.useMarketBasedPrice.set(useMarketBasedPrice);
preferences.setUsePercentageBasedPrice(useMarketBasedPrice);
}
/*boolean isFeeFromFundingTxSufficient() {
return !isMainNet.get() || feeFromFundingTxProperty.get().compareTo(FeePolicy.getMinRequiredFeeForFundingTx()) >= 0;
}*/
public ObservableList<PaymentAccount> getPaymentAccounts() {
return paymentAccounts;
}
double getMarketPriceMargin() {
return marketPriceMargin;
}
///////////////////////////////////////////////////////////////////////////////////////////
// Utils
///////////////////////////////////////////////////////////////////////////////////////////
void calculateVolume() {
if (price.get() != null &&
amount.get() != null &&
!amount.get().isZero() &&
!price.get().isZero()) {
try {
volume.set(new Volume(price.get().getVolumeByAmount(amount.get())));
} catch (Throwable t) {
log.error(t.toString());
}
}
updateBalance();
}
void calculateAmount() {
if (volume.get() != null &&
price.get() != null &&
!volume.get().isZero() &&
!price.get().isZero()) {
try {
amount.set(formatter.reduceTo4Decimals(price.get().getAmountByVolume(volume.get().getMonetary())));
calculateTotalToPay();
} catch (Throwable t) {
log.error(t.toString());
}
}
}
void calculateTotalToPay() {
if (direction != null && amount.get() != null) {
Coin feeAndSecDeposit = offerFeeAsCoin.add(networkFeeAsCoin).add(securityDepositAsCoin);
Coin feeAndSecDepositAndAmount = feeAndSecDeposit.add(amount.get());
Coin required = direction == Offer.Direction.BUY ? feeAndSecDeposit : feeAndSecDepositAndAmount;
totalToPayAsCoin.set(required);
log.debug("totalToPayAsCoin " + totalToPayAsCoin.get().toFriendlyString());
updateBalance();
}
}
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 Coin getOfferFeeAsCoin() {
return offerFeeAsCoin;
}
public Coin getNetworkFeeAsCoin() {
return networkFeeAsCoin;
}
public Coin getSecurityDepositAsCoin() {
return securityDepositAsCoin;
}
public List<Arbitrator> getArbitrators() {
return user.getAcceptedArbitrators();
}
public Preferences getPreferences() {
return preferences;
}
public void swapTradeToSavings() {
walletService.swapTradeEntryToAvailableEntry(offerId, AddressEntry.Context.OFFER_FUNDING);
walletService.swapTradeEntryToAvailableEntry(offerId, AddressEntry.Context.RESERVED_FOR_TRADE);
}
private void fillPaymentAccounts() {
paymentAccounts.setAll(user.getPaymentAccounts().stream()
.filter(e -> !isUSBankAccount(e))
.collect(Collectors.toSet()));
}
private boolean isUSBankAccount(PaymentAccount paymentAccount) {
if (paymentAccount instanceof SameCountryRestrictedBankAccount && paymentAccount.getContractData() instanceof BankAccountContractData)
return ((SameCountryRestrictedBankAccount) paymentAccount).getCountryCode().equals("US");
else
return false;
}
}