/* * 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.trade.offer; import com.google.inject.Inject; import io.bitsquare.app.DevFlags; import io.bitsquare.app.Log; import io.bitsquare.btc.AddressEntry; import io.bitsquare.btc.TradeWalletService; import io.bitsquare.btc.WalletService; import io.bitsquare.btc.pricefeed.PriceFeedService; import io.bitsquare.common.Timer; import io.bitsquare.common.UserThread; import io.bitsquare.common.crypto.KeyRing; import io.bitsquare.common.handlers.ErrorMessageHandler; import io.bitsquare.common.handlers.ResultHandler; import io.bitsquare.crypto.DecryptedMsgWithPubKey; import io.bitsquare.p2p.BootstrapListener; import io.bitsquare.p2p.Message; import io.bitsquare.p2p.NodeAddress; import io.bitsquare.p2p.P2PService; import io.bitsquare.p2p.messaging.DecryptedDirectMessageListener; import io.bitsquare.p2p.messaging.SendDirectMessageListener; import io.bitsquare.p2p.peers.PeerManager; import io.bitsquare.storage.Storage; import io.bitsquare.trade.TradableList; import io.bitsquare.trade.closed.ClosedTradableManager; import io.bitsquare.trade.exceptions.MarketPriceNotAvailableException; import io.bitsquare.trade.exceptions.TradePriceOutOfToleranceException; import io.bitsquare.trade.handlers.TransactionResultHandler; import io.bitsquare.trade.protocol.availability.AvailabilityResult; import io.bitsquare.trade.protocol.availability.messages.OfferAvailabilityRequest; import io.bitsquare.trade.protocol.availability.messages.OfferAvailabilityResponse; import io.bitsquare.trade.protocol.placeoffer.PlaceOfferModel; import io.bitsquare.trade.protocol.placeoffer.PlaceOfferProtocol; import io.bitsquare.user.Preferences; import io.bitsquare.user.User; import javafx.collections.ObservableList; import org.bitcoinj.core.Coin; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.annotation.Nullable; import javax.inject.Named; import java.io.File; import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.concurrent.TimeUnit; import static com.google.inject.internal.util.$Preconditions.checkNotNull; import static io.bitsquare.util.Validator.nonEmptyStringOf; public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMessageListener { private static final Logger log = LoggerFactory.getLogger(OpenOfferManager.class); private static final long RETRY_REPUBLISH_DELAY_SEC = 10; private static final long REPUBLISH_AGAIN_AT_STARTUP_DELAY_SEC = 30; private static final long REPUBLISH_INTERVAL_MS = TimeUnit.MINUTES.toMillis(DevFlags.STRESS_TEST_MODE ? 20 : 20); private static final long REFRESH_INTERVAL_MS = TimeUnit.MINUTES.toMillis(DevFlags.STRESS_TEST_MODE ? 4 : 4); private final KeyRing keyRing; private final User user; private final P2PService p2PService; private final WalletService walletService; private final TradeWalletService tradeWalletService; private final OfferBookService offerBookService; private final ClosedTradableManager closedTradableManager; private Preferences preferences; private final TradableList<OpenOffer> openOffers; private final Storage<TradableList<OpenOffer>> openOffersStorage; private boolean stopped; private Timer periodicRepublishOffersTimer, periodicRefreshOffersTimer, retryRepublishOffersTimer; /////////////////////////////////////////////////////////////////////////////////////////// // Constructor, Initialization /////////////////////////////////////////////////////////////////////////////////////////// @Inject public OpenOfferManager(KeyRing keyRing, User user, P2PService p2PService, WalletService walletService, TradeWalletService tradeWalletService, OfferBookService offerBookService, ClosedTradableManager closedTradableManager, PriceFeedService priceFeedService, Preferences preferences, @Named(Storage.DIR_KEY) File storageDir) { this.keyRing = keyRing; this.user = user; this.p2PService = p2PService; this.walletService = walletService; this.tradeWalletService = tradeWalletService; this.offerBookService = offerBookService; this.closedTradableManager = closedTradableManager; this.preferences = preferences; openOffersStorage = new Storage<>(storageDir); openOffers = new TradableList<>(openOffersStorage, "OpenOffers"); openOffers.forEach(e -> e.getOffer().setPriceFeedService(priceFeedService)); // In case the app did get killed the shutDown from the modules is not called, so we use a shutdown hook Runtime.getRuntime().addShutdownHook(new Thread(() -> { UserThread.execute(OpenOfferManager.this::shutDown); }, "OpenOfferManager.ShutDownHook")); } public void onAllServicesInitialized() { p2PService.addDecryptedDirectMessageListener(this); if (p2PService.isBootstrapped()) onBootstrapComplete(); else p2PService.addP2PServiceListener(new BootstrapListener() { @Override public void onBootstrapComplete() { OpenOfferManager.this.onBootstrapComplete(); } }); } @SuppressWarnings("WeakerAccess") public void shutDown() { shutDown(null); } public void shutDown(@Nullable Runnable completeHandler) { stopped = true; p2PService.getPeerManager().removeListener(this); p2PService.removeDecryptedDirectMessageListener(this); stopPeriodicRefreshOffersTimer(); stopPeriodicRepublishOffersTimer(); stopRetryRepublishOffersTimer(); log.debug("remove all open offers at shutDown"); // we remove own offers from offerbook when we go offline // Normally we use a delay for broadcasting to the peers, but at shut down we want to get it fast out final int size = openOffers.size(); if (offerBookService.isBootstrapped()) { openOffers.forEach(openOffer -> offerBookService.removeOfferAtShutDown(openOffer.getOffer())); if (completeHandler != null) UserThread.runAfter(completeHandler::run, size * 200 + 500, TimeUnit.MILLISECONDS); } else { if (completeHandler != null) completeHandler.run(); } } public void removeAllOpenOffers(@Nullable Runnable completeHandler) { removeOpenOffers(getOpenOffers(), completeHandler); } public void removeOpenOffers(List<OpenOffer> openOffers, @Nullable Runnable completeHandler) { final int size = openOffers.size(); // Copy list as we remove in the loop List<OpenOffer> openOffersList = new ArrayList<>(openOffers); openOffersList.forEach(openOffer -> removeOpenOffer(openOffer, () -> { }, errorMessage -> { })); if (completeHandler != null) UserThread.runAfter(completeHandler::run, size * 200 + 500, TimeUnit.MILLISECONDS); } /////////////////////////////////////////////////////////////////////////////////////////// // DecryptedDirectMessageListener implementation /////////////////////////////////////////////////////////////////////////////////////////// @Override public void onDirectMessage(DecryptedMsgWithPubKey decryptedMsgWithPubKey, NodeAddress peerNodeAddress) { // Handler for incoming offer availability requests // We get an encrypted message but don't do the signature check as we don't know the peer yet. // A basic sig check is in done also at decryption time Message message = decryptedMsgWithPubKey.message; if (message instanceof OfferAvailabilityRequest) handleOfferAvailabilityRequest((OfferAvailabilityRequest) message, peerNodeAddress); } /////////////////////////////////////////////////////////////////////////////////////////// // BootstrapListener delegate /////////////////////////////////////////////////////////////////////////////////////////// public void onBootstrapComplete() { stopped = false; // Republish means we send the complete offer object republishOffers(); startPeriodicRepublishOffersTimer(); // Refresh is started once we get a success from republish // We republish after a bit as it might be that our connected node still has the offer in the data map // but other peers have it already removed because of expired TTL. // Those other not directly connected peers would not get the broadcast of the new offer, as the first // connected peer (seed node) does nto broadcast if it has the data in the map. // To update quickly to the whole network we repeat the republishOffers call after a few seconds when we // are better connected to the network. There is no guarantee that all peers will receive it but we have // also our periodic timer, so after that longer interval the offer should be available to all peers. if (retryRepublishOffersTimer == null) retryRepublishOffersTimer = UserThread.runAfter(OpenOfferManager.this::republishOffers, REPUBLISH_AGAIN_AT_STARTUP_DELAY_SEC); p2PService.getPeerManager().addListener(this); } /////////////////////////////////////////////////////////////////////////////////////////// // PeerManager.Listener implementation /////////////////////////////////////////////////////////////////////////////////////////// @Override public void onAllConnectionsLost() { log.info("onAllConnectionsLost"); stopped = true; stopPeriodicRefreshOffersTimer(); stopPeriodicRepublishOffersTimer(); stopRetryRepublishOffersTimer(); restart(); } @Override public void onNewConnectionAfterAllConnectionsLost() { log.info("onNewConnectionAfterAllConnectionsLost"); stopped = false; restart(); } @Override public void onAwakeFromStandby() { log.info("onAwakeFromStandby"); stopped = false; if (!p2PService.getNetworkNode().getAllConnections().isEmpty()) restart(); } /////////////////////////////////////////////////////////////////////////////////////////// // API /////////////////////////////////////////////////////////////////////////////////////////// public void placeOffer(Offer offer, Coin reservedFundsForOffer, boolean useSavingsWallet, TransactionResultHandler resultHandler) { PlaceOfferModel model = new PlaceOfferModel(offer, reservedFundsForOffer, useSavingsWallet, walletService, tradeWalletService, offerBookService, user); PlaceOfferProtocol placeOfferProtocol = new PlaceOfferProtocol( model, transaction -> { OpenOffer openOffer = new OpenOffer(offer, openOffersStorage); openOffers.add(openOffer); openOffersStorage.queueUpForSave(); resultHandler.handleResult(transaction); if (!stopped) { startPeriodicRepublishOffersTimer(); startPeriodicRefreshOffersTimer(); } else { log.debug("We have stopped already. We ignore that placeOfferProtocol.placeOffer.onResult call."); } } ); placeOfferProtocol.placeOffer(); } // Remove from offerbook public void removeOffer(Offer offer, ResultHandler resultHandler, ErrorMessageHandler errorMessageHandler) { Optional<OpenOffer> openOfferOptional = findOpenOffer(offer.getId()); if (openOfferOptional.isPresent()) { removeOpenOffer(openOfferOptional.get(), resultHandler, errorMessageHandler); } else { log.warn("Offer was not found in our list of open offers. We still try to remove it from the offerbook."); errorMessageHandler.handleErrorMessage("Offer was not found in our list of open offers. " + "We still try to remove it from the offerbook."); offerBookService.removeOffer(offer, () -> offer.setState(Offer.State.REMOVED), null); } } // Remove from my offers public void removeOpenOffer(OpenOffer openOffer, ResultHandler resultHandler, ErrorMessageHandler errorMessageHandler) { Offer offer = openOffer.getOffer(); offerBookService.removeOffer(offer, () -> { offer.setState(Offer.State.REMOVED); openOffer.setState(OpenOffer.State.CANCELED); openOffers.remove(openOffer); closedTradableManager.add(openOffer); walletService.swapTradeEntryToAvailableEntry(offer.getId(), AddressEntry.Context.OFFER_FUNDING); walletService.swapTradeEntryToAvailableEntry(offer.getId(), AddressEntry.Context.RESERVED_FOR_TRADE); resultHandler.handleResult(); }, errorMessageHandler); } // Close openOffer after deposit published public void closeOpenOffer(Offer offer) { findOpenOffer(offer.getId()).ifPresent(openOffer -> { openOffers.remove(openOffer); openOffer.setState(OpenOffer.State.CLOSED); offerBookService.removeOffer(openOffer.getOffer(), () -> log.trace("Successful removed offer"), log::error); }); } public void reserveOpenOffer(OpenOffer openOffer) { openOffer.setState(OpenOffer.State.RESERVED); } /////////////////////////////////////////////////////////////////////////////////////////// // Getters /////////////////////////////////////////////////////////////////////////////////////////// public boolean isMyOffer(Offer offer) { return offer.isMyOffer(keyRing); } public ObservableList<OpenOffer> getOpenOffers() { return openOffers.getObservableList(); } public Optional<OpenOffer> findOpenOffer(String offerId) { return openOffers.stream().filter(openOffer -> openOffer.getId().equals(offerId)).findAny(); } public Optional<OpenOffer> getOpenOfferById(String offerId) { return openOffers.stream().filter(e -> e.getId().equals(offerId)).findFirst(); } /////////////////////////////////////////////////////////////////////////////////////////// // Offer Availability /////////////////////////////////////////////////////////////////////////////////////////// private void handleOfferAvailabilityRequest(OfferAvailabilityRequest message, NodeAddress sender) { log.trace("handleNewMessage: message = " + message.getClass().getSimpleName() + " from " + sender); if (!stopped) { try { nonEmptyStringOf(message.offerId); checkNotNull(message.getPubKeyRing()); } catch (Throwable t) { log.warn("Invalid message " + message.toString()); return; } Optional<OpenOffer> openOfferOptional = findOpenOffer(message.offerId); AvailabilityResult availabilityResult; if (openOfferOptional.isPresent()) { if (openOfferOptional.get().getState() == OpenOffer.State.AVAILABLE) { final Offer offer = openOfferOptional.get().getOffer(); if (!preferences.getIgnoreTradersList().stream().filter(i -> i.equals(offer.getOffererNodeAddress().getHostNameWithoutPostFix())).findAny().isPresent()) { availabilityResult = AvailabilityResult.AVAILABLE; List<NodeAddress> acceptedArbitrators = user.getAcceptedArbitratorAddresses(); if (acceptedArbitrators != null && !acceptedArbitrators.isEmpty()) { // We need to be backward compatible. takersTradePrice was not used before 0.4.9. if (message.takersTradePrice > 0) { // Check also tradePrice to avoid failures after taker fee is paid caused by a too big difference // in trade price between the peers. Also here poor connectivity might cause market price API connection // losses and therefore an outdated market price. try { offer.checkTradePriceTolerance(message.takersTradePrice); } catch (TradePriceOutOfToleranceException e) { log.warn("Trade price check failed because takers price is outside out tolerance."); availabilityResult = AvailabilityResult.PRICE_OUT_OF_TOLERANCE; } catch (MarketPriceNotAvailableException e) { log.warn(e.getMessage()); availabilityResult = AvailabilityResult.MARKET_PRICE_NOT_AVAILABLE; } catch (Throwable e) { log.warn("Trade price check failed. " + e.getMessage()); availabilityResult = AvailabilityResult.UNKNOWN_FAILURE; } } } else { log.warn("acceptedArbitrators is null or empty: acceptedArbitrators=" + acceptedArbitrators); availabilityResult = AvailabilityResult.NO_ARBITRATORS; } } else { availabilityResult = AvailabilityResult.USER_IGNORED; } } else { availabilityResult = AvailabilityResult.OFFER_TAKEN; } } else { log.warn("handleOfferAvailabilityRequest: openOffer not found. That should never happen."); availabilityResult = AvailabilityResult.OFFER_TAKEN; } try { p2PService.sendEncryptedDirectMessage(sender, message.getPubKeyRing(), new OfferAvailabilityResponse(message.offerId, availabilityResult), new SendDirectMessageListener() { @Override public void onArrived() { log.trace("OfferAvailabilityResponse successfully arrived at peer"); } @Override public void onFault() { log.debug("Sending OfferAvailabilityResponse failed."); } }); } catch (Throwable t) { t.printStackTrace(); log.debug("Exception at handleRequestIsOfferAvailableMessage " + t.getMessage()); } } else { log.debug("We have stopped already. We ignore that handleOfferAvailabilityRequest call."); } } /////////////////////////////////////////////////////////////////////////////////////////// // RepublishOffers, refreshOffers /////////////////////////////////////////////////////////////////////////////////////////// private void republishOffers() { int size = openOffers.size(); final ArrayList<OpenOffer> openOffersList = new ArrayList<>(openOffers); Log.traceCall("Number of offer for republish: " + size); if (!stopped) { stopPeriodicRefreshOffersTimer(); for (int i = 0; i < size; i++) { // we delay to avoid reaching throttle limits long delay = 700; final long minDelay = (i + 1) * delay; final long maxDelay = (i + 2) * delay; final OpenOffer openOffer = openOffersList.get(i); UserThread.runAfterRandomDelay(() -> { if (openOffers.contains(openOffer)) { // The openOffer.getId().contains("_") check is because there was once a version // where we encoded the version nr in the offer id with a "_" as separator. // That caused several issues and was reverted. So if there are still old offers out with that // special offer ID format those must not be published as they cause failed taker attempts // with lost taker fee. String id = openOffer.getId(); if (id != null && !id.contains("_")) republishOffer(openOffer); else log.warn("You have an offer with an invalid offer ID: offerID=" + id); } }, minDelay, maxDelay, TimeUnit.MILLISECONDS); } } else { log.debug("We have stopped already. We ignore that republishOffers call."); } } private void republishOffer(OpenOffer openOffer) { offerBookService.addOffer(openOffer.getOffer(), () -> { if (!stopped) { log.debug("Successful added offer to P2P network"); // Refresh means we send only the dat needed to refresh the TTL (hash, signature and sequence no.) if (periodicRefreshOffersTimer == null) startPeriodicRefreshOffersTimer(); } else { log.debug("We have stopped already. We ignore that offerBookService.republishOffers.onSuccess call."); } }, errorMessage -> { if (!stopped) { log.error("Add offer to P2P network failed. " + errorMessage); stopRetryRepublishOffersTimer(); retryRepublishOffersTimer = UserThread.runAfter(OpenOfferManager.this::republishOffers, RETRY_REPUBLISH_DELAY_SEC); } else { log.debug("We have stopped already. We ignore that offerBookService.republishOffers.onFault call."); } }); openOffer.setStorage(openOffersStorage); } private void startPeriodicRepublishOffersTimer() { Log.traceCall(); stopped = false; if (periodicRepublishOffersTimer == null) periodicRepublishOffersTimer = UserThread.runPeriodically(() -> { if (!stopped) { republishOffers(); } else { log.debug("We have stopped already. We ignore that periodicRepublishOffersTimer.run call."); } }, REPUBLISH_INTERVAL_MS, TimeUnit.MILLISECONDS); else log.trace("periodicRepublishOffersTimer already stated"); } private void startPeriodicRefreshOffersTimer() { Log.traceCall(); stopped = false; // refresh sufficiently before offer would expire if (periodicRefreshOffersTimer == null) periodicRefreshOffersTimer = UserThread.runPeriodically(() -> { if (!stopped) { int size = openOffers.size(); Log.traceCall("Number of offer for refresh: " + size); //we clone our list as openOffers might change during our delayed call final ArrayList<OpenOffer> openOffersList = new ArrayList<>(openOffers); for (int i = 0; i < size; i++) { // we delay to avoid reaching throttle limits // roughly 4 offers per second long delay = 300; final long minDelay = (i + 1) * delay; final long maxDelay = (i + 2) * delay; final OpenOffer openOffer = openOffersList.get(i); UserThread.runAfterRandomDelay(() -> { // we need to check if in the meantime the offer has been removed if (openOffers.contains(openOffer)) refreshOffer(openOffer); }, minDelay, maxDelay, TimeUnit.MILLISECONDS); } } else { log.debug("We have stopped already. We ignore that periodicRefreshOffersTimer.run call."); } }, REFRESH_INTERVAL_MS, TimeUnit.MILLISECONDS); else log.trace("periodicRefreshOffersTimer already stated"); } private void refreshOffer(OpenOffer openOffer) { offerBookService.refreshTTL(openOffer.getOffer(), () -> log.debug("Successful refreshed TTL for offer"), errorMessage -> log.warn(errorMessage)); } private void restart() { log.debug("Restart after connection loss"); if (retryRepublishOffersTimer == null) retryRepublishOffersTimer = UserThread.runAfter(() -> { stopped = false; stopRetryRepublishOffersTimer(); republishOffers(); }, RETRY_REPUBLISH_DELAY_SEC); startPeriodicRepublishOffersTimer(); } /////////////////////////////////////////////////////////////////////////////////////////// // Private /////////////////////////////////////////////////////////////////////////////////////////// private void stopPeriodicRefreshOffersTimer() { if (periodicRefreshOffersTimer != null) { periodicRefreshOffersTimer.stop(); periodicRefreshOffersTimer = null; } } private void stopPeriodicRepublishOffersTimer() { if (periodicRepublishOffersTimer != null) { periodicRepublishOffersTimer.stop(); periodicRepublishOffersTimer = null; } } private void stopRetryRepublishOffersTimer() { if (retryRepublishOffersTimer != null) { retryRepublishOffersTimer.stop(); retryRepublishOffersTimer = null; } } }