diff --git a/core/src/main/java/haveno/core/offer/OpenOffer.java b/core/src/main/java/haveno/core/offer/OpenOffer.java index f493b1b584..9c51dead66 100644 --- a/core/src/main/java/haveno/core/offer/OpenOffer.java +++ b/core/src/main/java/haveno/core/offer/OpenOffer.java @@ -276,6 +276,10 @@ public final class OpenOffer implements Tradable { return state == State.AVAILABLE; } + public boolean isReserved() { + return state == State.RESERVED; + } + public boolean isDeactivated() { return state == State.DEACTIVATED; } diff --git a/core/src/main/java/haveno/core/offer/OpenOfferManager.java b/core/src/main/java/haveno/core/offer/OpenOfferManager.java index 710cb92d54..49fa770ae2 100644 --- a/core/src/main/java/haveno/core/offer/OpenOfferManager.java +++ b/core/src/main/java/haveno/core/offer/OpenOfferManager.java @@ -661,7 +661,7 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe ErrorMessageHandler errorMessageHandler) { log.info("Canceling open offer: {}", openOffer.getId()); if (!offersToBeEdited.containsKey(openOffer.getId())) { - if (openOffer.isAvailable()) { + if (isOnOfferBook(openOffer)) { openOffer.setState(OpenOffer.State.CANCELED); offerBookService.removeOffer(openOffer.getOffer().getOfferPayload(), () -> { @@ -683,6 +683,10 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe } } + private boolean isOnOfferBook(OpenOffer openOffer) { + return openOffer.isAvailable() || openOffer.isReserved(); + } + public void editOpenOfferStart(OpenOffer openOffer, ResultHandler resultHandler, ErrorMessageHandler errorMessageHandler) { diff --git a/core/src/main/java/haveno/core/trade/TradeManager.java b/core/src/main/java/haveno/core/trade/TradeManager.java index 9befbd6e82..a1f2b5d0b7 100644 --- a/core/src/main/java/haveno/core/trade/TradeManager.java +++ b/core/src/main/java/haveno/core/trade/TradeManager.java @@ -997,7 +997,7 @@ public class TradeManager implements PersistedDataHost, DecryptedDirectMessageLi log.warn("Unregistering {} {}", trade.getClass().getSimpleName(), trade.getId()); removeTrade(trade, true); removeFailedTrade(trade); - xmrWalletService.swapPayoutAddressEntryToAvailable(trade.getId()); // TODO The address entry should have been removed already. Check and if its the case remove that. + if (!trade.isMaker()) xmrWalletService.swapPayoutAddressEntryToAvailable(trade.getId()); // TODO The address entry should have been removed already. Check and if its the case remove that. requestPersistence(); } diff --git a/core/src/main/java/haveno/core/trade/protocol/TradeProtocol.java b/core/src/main/java/haveno/core/trade/protocol/TradeProtocol.java index 5ff09324fd..4249e967a9 100644 --- a/core/src/main/java/haveno/core/trade/protocol/TradeProtocol.java +++ b/core/src/main/java/haveno/core/trade/protocol/TradeProtocol.java @@ -43,6 +43,7 @@ import haveno.common.handlers.ErrorMessageHandler; import haveno.common.proto.network.NetworkEnvelope; import haveno.common.taskrunner.Task; import haveno.core.network.MessageState; +import haveno.core.offer.OpenOffer; import haveno.core.trade.ArbitratorTrade; import haveno.core.trade.BuyerTrade; import haveno.core.trade.HavenoUtils; @@ -55,13 +56,17 @@ import haveno.core.trade.messages.DepositRequest; import haveno.core.trade.messages.DepositResponse; import haveno.core.trade.messages.DepositsConfirmedMessage; import haveno.core.trade.messages.InitMultisigRequest; +import haveno.core.trade.messages.InitTradeRequest; import haveno.core.trade.messages.PaymentReceivedMessage; import haveno.core.trade.messages.PaymentSentMessage; import haveno.core.trade.messages.SignContractRequest; import haveno.core.trade.messages.SignContractResponse; import haveno.core.trade.messages.TradeMessage; import haveno.core.trade.protocol.FluentProtocol.Condition; +import haveno.core.trade.protocol.FluentProtocol.Event; import haveno.core.trade.protocol.tasks.ApplyFilter; +import haveno.core.trade.protocol.tasks.MakerRecreateReserveTx; +import haveno.core.trade.protocol.tasks.MakerSendInitTradeRequestToArbitrator; import haveno.core.trade.protocol.tasks.MaybeSendSignContractRequest; import haveno.core.trade.protocol.tasks.ProcessDepositResponse; import haveno.core.trade.protocol.tasks.ProcessDepositsConfirmedMessage; @@ -110,6 +115,7 @@ public abstract class TradeProtocol implements DecryptedDirectMessageListener, D private boolean depositsConfirmedTasksCalled; private int reprocessPaymentSentMessageCount; private int reprocessPaymentReceivedMessageCount; + private boolean makerInitTradeRequestNacked = false; /////////////////////////////////////////////////////////////////////////////////////////// // Constructor @@ -758,6 +764,18 @@ public abstract class TradeProtocol implements DecryptedDirectMessageListener, D peer.setNodeAddress(sender); } + // TODO: arbitrator may nack maker's InitTradeRequest if reserve tx has become invalid (e.g. check_tx_key shows 0 funds received). recreate reserve tx in this case + if (!ackMessage.isSuccess() && trade.isMaker() && peer == trade.getArbitrator() && ackMessage.getSourceMsgClassName().equals(InitTradeRequest.class.getSimpleName())) { + if (makerInitTradeRequestNacked) { + handleSecondMakerInitTradeRequestNack(ackMessage); + // use default postprocessing + } else { + makerInitTradeRequestNacked = true; + handleFirstMakerInitTradeRequestNack(ackMessage); + return; + } + } + // handle nack of deposit request if (ackMessage.getSourceMsgClassName().equals(DepositRequest.class.getSimpleName())) { if (!ackMessage.isSuccess()) { @@ -774,12 +792,12 @@ public abstract class TradeProtocol implements DecryptedDirectMessageListener, D // handle ack message for PaymentSentMessage, which automatically re-sends if not ACKed in a certain time if (ackMessage.getSourceMsgClassName().equals(PaymentSentMessage.class.getSimpleName())) { - if (trade.getTradePeer(sender) == trade.getSeller()) { + if (peer == trade.getSeller()) { trade.getSeller().setPaymentSentAckMessage(ackMessage); if (ackMessage.isSuccess()) trade.setStateIfValidTransitionTo(Trade.State.SELLER_RECEIVED_PAYMENT_SENT_MSG); else trade.setState(Trade.State.BUYER_SEND_FAILED_PAYMENT_SENT_MSG); processModel.getTradeManager().requestPersistence(); - } else if (trade.getTradePeer(sender) == trade.getArbitrator()) { + } else if (peer == trade.getArbitrator()) { trade.getArbitrator().setPaymentSentAckMessage(ackMessage); processModel.getTradeManager().requestPersistence(); } else { @@ -792,7 +810,7 @@ public abstract class TradeProtocol implements DecryptedDirectMessageListener, D if (ackMessage.getSourceMsgClassName().equals(PaymentReceivedMessage.class.getSimpleName())) { // ack message from buyer - if (trade.getTradePeer(sender) == trade.getBuyer()) { + if (peer == trade.getBuyer()) { trade.getBuyer().setPaymentReceivedAckMessage(ackMessage); // handle successful ack @@ -819,7 +837,7 @@ public abstract class TradeProtocol implements DecryptedDirectMessageListener, D } // ack message from arbitrator - else if (trade.getTradePeer(sender) == trade.getArbitrator()) { + else if (peer == trade.getArbitrator()) { trade.getArbitrator().setPaymentReceivedAckMessage(ackMessage); // handle nack @@ -856,6 +874,48 @@ public abstract class TradeProtocol implements DecryptedDirectMessageListener, D trade.onAckMessage(ackMessage, sender); } + private void handleFirstMakerInitTradeRequestNack(AckMessage ackMessage) { + log.warn("Maker received NACK to InitTradeRequest from arbitrator for {} {}, messageUid={}, errorMessage={}", trade.getClass().getSimpleName(), trade.getId(), ackMessage.getSourceUid(), ackMessage.getErrorMessage()); + ThreadUtils.execute(() -> { + Event event = new Event() { + @Override + public String name() { + return "MakerRecreateReserveTx"; + } + }; + synchronized (trade.getLock()) { + latchTrade(); + expect(phase(Trade.Phase.INIT) + .with(event)) + .setup(tasks( + MakerRecreateReserveTx.class, + MakerSendInitTradeRequestToArbitrator.class) + .using(new TradeTaskRunner(trade, + () -> { + startTimeout(); + unlatchTrade(); + }, + errorMessage -> { + handleError("Failed to re-send InitTradeRequest to arbitrator for " + trade.getClass().getSimpleName() + " " + trade.getId() + ": " + errorMessage); + })) + .withTimeout(TRADE_STEP_TIMEOUT_SECONDS)) + .executeTasks(true); + awaitTradeLatch(); + } + }, trade.getId()); + } + + private void handleSecondMakerInitTradeRequestNack(AckMessage ackMessage) { + log.warn("Maker received 2nd NACK to InitTradeRequest from arbitrator for {} {}, messageUid={}, errorMessage={}", trade.getClass().getSimpleName(), trade.getId(), ackMessage.getSourceUid(), ackMessage.getErrorMessage()); + String warningMessage = "Your offer (" + trade.getOffer().getShortId() + ") has been removed because there was a problem taking the trade.\n\nError message: " + ackMessage.getErrorMessage(); + OpenOffer openOffer = HavenoUtils.openOfferManager.getOpenOffer(trade.getId()).orElse(null); + if (openOffer != null) { + HavenoUtils.openOfferManager.cancelOpenOffer(openOffer, null, null); + HavenoUtils.setTopError(warningMessage); + } + log.warn(warningMessage); + } + private boolean isPaymentReceivedMessageAckedByEither() { if (trade.getBuyer().getPaymentReceivedMessageStateProperty().get() == MessageState.ACKNOWLEDGED) return true; if (trade.getArbitrator().getPaymentReceivedMessageStateProperty().get() == MessageState.ACKNOWLEDGED) return true; @@ -992,11 +1052,11 @@ public abstract class TradeProtocol implements DecryptedDirectMessageListener, D void handleTaskRunnerFault(NodeAddress ackReceiver, @Nullable TradeMessage message, String source, String errorMessage, String updatedMultisigHex) { log.error("Task runner failed with error {}. Triggered from {}. Monerod={}" , errorMessage, source, trade.getXmrWalletService().getXmrConnectionService().getConnection()); + handleError(errorMessage); + if (message != null) { sendAckMessage(ackReceiver, message, false, errorMessage, updatedMultisigHex); } - - handleError(errorMessage); } // these are not thread safe, so they must be used within a lock on the trade @@ -1006,9 +1066,9 @@ public abstract class TradeProtocol implements DecryptedDirectMessageListener, D log.error(errorMessage); trade.setErrorMessage(errorMessage); processModel.getTradeManager().requestPersistence(); + unlatchTrade(); if (errorMessageHandler != null) errorMessageHandler.handleErrorMessage(errorMessage); errorMessageHandler = null; - unlatchTrade(); } protected void latchTrade() { diff --git a/core/src/main/java/haveno/core/trade/protocol/tasks/MakerRecreateReserveTx.java b/core/src/main/java/haveno/core/trade/protocol/tasks/MakerRecreateReserveTx.java new file mode 100644 index 0000000000..05130dbbf3 --- /dev/null +++ b/core/src/main/java/haveno/core/trade/protocol/tasks/MakerRecreateReserveTx.java @@ -0,0 +1,147 @@ +/* + * This file is part of Haveno. + * + * Haveno 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. + * + * Haveno 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 Haveno. If not, see . + */ + +package haveno.core.trade.protocol.tasks; + +import haveno.common.taskrunner.TaskRunner; +import haveno.core.offer.Offer; +import haveno.core.offer.OfferDirection; +import haveno.core.offer.OpenOffer; +import haveno.core.trade.HavenoUtils; +import haveno.core.trade.MakerTrade; +import haveno.core.trade.Trade; +import haveno.core.trade.protocol.TradeProtocol; +import haveno.core.xmr.model.XmrAddressEntry; +import lombok.extern.slf4j.Slf4j; +import monero.common.MoneroRpcConnection; +import monero.wallet.model.MoneroTxWallet; + +import java.math.BigInteger; + +@Slf4j +public class MakerRecreateReserveTx extends TradeTask { + + public MakerRecreateReserveTx(TaskRunner taskHandler, Trade trade) { + super(taskHandler, trade); + } + + @Override + protected void run() { + try { + runInterceptHook(); + + // maker trade expected + if (!(trade instanceof MakerTrade)) { + throw new RuntimeException("Expected maker trade but was " + trade.getClass().getSimpleName() + " " + trade.getShortId() + ". That should never happen."); + } + + // get open offer + OpenOffer openOffer = HavenoUtils.openOfferManager.getOpenOffer(trade.getOffer().getId()).orElse(null); + if (openOffer == null) throw new RuntimeException("Open offer not found for " + trade.getClass().getSimpleName() + " " + trade.getId()); + Offer offer = openOffer.getOffer(); + + // reset reserve tx state + trade.getSelf().setReserveTxHex(null); + trade.getSelf().setReserveTxHash(null); + trade.getSelf().setReserveTxKey(null); + trade.getSelf().setReserveTxKeyImages(null); + + // recreate reserve tx + log.warn("Maker is recreating reserve tx for tradeId={}", trade.getShortId()); + MoneroTxWallet reserveTx = null; + synchronized (HavenoUtils.xmrWalletService.getWalletLock()) { + + // check for timeout + if (isTimedOut()) throw new RuntimeException("Trade protocol has timed out while getting lock to create reserve tx, tradeId=" + trade.getShortId()); + trade.startProtocolTimeout(); + + // thaw reserved key images + log.info("Thawing reserve tx key images for tradeId={}", trade.getShortId()); + HavenoUtils.xmrWalletService.thawOutputs(openOffer.getOffer().getOfferPayload().getReserveTxKeyImages()); + + // check for timeout + if (isTimedOut()) throw new RuntimeException("Trade protocol has timed out while thawing key images, tradeId=" + trade.getShortId()); + trade.startProtocolTimeout(); + + // collect relevant info + BigInteger penaltyFee = HavenoUtils.multiply(offer.getAmount(), offer.getPenaltyFeePct()); + BigInteger makerFee = offer.getMaxMakerFee(); + BigInteger sendAmount = offer.getDirection() == OfferDirection.BUY ? BigInteger.ZERO : offer.getAmount(); + BigInteger securityDeposit = offer.getDirection() == OfferDirection.BUY ? offer.getMaxBuyerSecurityDeposit() : offer.getMaxSellerSecurityDeposit(); + String returnAddress = model.getXmrWalletService().getAddressEntry(offer.getId(), XmrAddressEntry.Context.TRADE_PAYOUT).get().getAddressString(); + XmrAddressEntry fundingEntry = model.getXmrWalletService().getAddressEntry(offer.getId(), XmrAddressEntry.Context.OFFER_FUNDING).orElse(null); + Integer preferredSubaddressIndex = fundingEntry == null ? null : fundingEntry.getSubaddressIndex(); + + // attempt re-creating reserve tx + try { + synchronized (HavenoUtils.getWalletFunctionLock()) { + for (int i = 0; i < TradeProtocol.MAX_ATTEMPTS; i++) { + MoneroRpcConnection sourceConnection = trade.getXmrConnectionService().getConnection(); + try { + reserveTx = model.getXmrWalletService().createReserveTx(penaltyFee, makerFee, sendAmount, securityDeposit, returnAddress, openOffer.isReserveExactAmount(), preferredSubaddressIndex); + } catch (IllegalStateException e) { + log.warn("Illegal state creating reserve tx, tradeId={}, error={}", trade.getShortId(), i + 1, e.getMessage()); + throw e; + } catch (Exception e) { + log.warn("Error creating reserve tx, tradeId={}, attempt={}/{}, error={}", trade.getShortId(), i + 1, TradeProtocol.MAX_ATTEMPTS, e.getMessage()); + trade.getXmrWalletService().handleWalletError(e, sourceConnection); + if (isTimedOut()) throw new RuntimeException("Trade protocol has timed out while creating reserve tx, tradeId=" + trade.getShortId()); + if (i == TradeProtocol.MAX_ATTEMPTS - 1) throw e; + HavenoUtils.waitFor(TradeProtocol.REPROCESS_DELAY_MS); // wait before retrying + } + + // check for timeout + if (isTimedOut()) throw new RuntimeException("Trade protocol has timed out while creating reserve tx, tradeId=" + trade.getShortId()); + if (reserveTx != null) break; + } + } + } catch (Exception e) { + + // reset state + if (reserveTx != null) model.getXmrWalletService().thawOutputs(HavenoUtils.getInputKeyImages(reserveTx)); + model.getXmrWalletService().freezeOutputs(offer.getOfferPayload().getReserveTxKeyImages()); + trade.getSelf().setReserveTxKeyImages(null); + throw e; + } + + // reset protocol timeout + trade.startProtocolTimeout(); + + // update state + trade.getSelf().setReserveTxHash(reserveTx.getHash()); + trade.getSelf().setReserveTxHex(reserveTx.getFullHex()); + trade.getSelf().setReserveTxKey(reserveTx.getKey()); + trade.getSelf().setReserveTxKeyImages(HavenoUtils.getInputKeyImages(reserveTx)); + trade.getXmrWalletService().freezeOutputs(HavenoUtils.getInputKeyImages(reserveTx)); + } + + // save process state + processModel.setReserveTx(reserveTx); // TODO: remove this? how is it used? + processModel.getTradeManager().requestPersistence(); + complete(); + } catch (Throwable t) { + trade.setErrorMessage("An error occurred.\n" + + "Error message:\n" + + t.getMessage()); + failed(t); + } + } + + private boolean isTimedOut() { + return !processModel.getTradeManager().hasOpenTrade(trade); + } +} diff --git a/core/src/main/java/haveno/core/trade/protocol/tasks/TakerReserveTradeFunds.java b/core/src/main/java/haveno/core/trade/protocol/tasks/TakerReserveTradeFunds.java index 9a461b2d83..f7116ee0db 100644 --- a/core/src/main/java/haveno/core/trade/protocol/tasks/TakerReserveTradeFunds.java +++ b/core/src/main/java/haveno/core/trade/protocol/tasks/TakerReserveTradeFunds.java @@ -88,7 +88,7 @@ public class TakerReserveTradeFunds extends TradeTask { } } catch (Exception e) { - // reset state with wallet lock + // reset state model.getXmrWalletService().swapPayoutAddressEntryToAvailable(trade.getId()); if (reserveTx != null) { model.getXmrWalletService().thawOutputs(HavenoUtils.getInputKeyImages(reserveTx)); @@ -101,11 +101,12 @@ public class TakerReserveTradeFunds extends TradeTask { // reset protocol timeout trade.startProtocolTimeout(); - // update trade state - trade.getTaker().setReserveTxHash(reserveTx.getHash()); - trade.getTaker().setReserveTxHex(reserveTx.getFullHex()); - trade.getTaker().setReserveTxKey(reserveTx.getKey()); - trade.getTaker().setReserveTxKeyImages(HavenoUtils.getInputKeyImages(reserveTx)); + // update state + trade.getSelf().setReserveTxHash(reserveTx.getHash()); + trade.getSelf().setReserveTxHex(reserveTx.getFullHex()); + trade.getSelf().setReserveTxKey(reserveTx.getKey()); + trade.getSelf().setReserveTxKeyImages(HavenoUtils.getInputKeyImages(reserveTx)); + trade.getXmrWalletService().freezeOutputs(HavenoUtils.getInputKeyImages(reserveTx)); } }