reprocess payout messages on error to improve resilience

reprocess on curved schedule, restart, or connection change
invalid messages are nacked using IllegalArgumentException
disputes are considered open by ack on chat message
don't show trade completion screen until payout published
cannot confirm payment sent/received while disconnected from monerod
add operation manual w/ instructions to manually open dispute
close account before deletion
fix popup with error "still unconfirmed after X hours" for arbitrator
misc refactoring and cleanup
This commit is contained in:
woodser 2023-02-02 15:16:14 -05:00
parent ef4c55e32f
commit 15d2c24a82
49 changed files with 841 additions and 471 deletions

View file

@ -109,7 +109,7 @@ public class AbstractTradeTest extends AbstractOfferTest {
} }
protected final void verifyTakerDepositConfirmed(TradeInfo trade) { protected final void verifyTakerDepositConfirmed(TradeInfo trade) {
if (!trade.getIsDepositUnlocked()) { if (!trade.getIsDepositsUnlocked()) {
fail(format("INVALID_PHASE for trade %s in STATE=%s PHASE=%s, deposit tx never unlocked.", fail(format("INVALID_PHASE for trade %s in STATE=%s PHASE=%s, deposit tx never unlocked.",
trade.getShortId(), trade.getShortId(),
trade.getState(), trade.getState(),
@ -182,9 +182,9 @@ public class AbstractTradeTest extends AbstractOfferTest {
assertEquals(EXPECTED_PROTOCOL_STATUS.phase.name(), trade.getPhase()); assertEquals(EXPECTED_PROTOCOL_STATUS.phase.name(), trade.getPhase());
if (!isLongRunningTest) if (!isLongRunningTest)
assertEquals(EXPECTED_PROTOCOL_STATUS.isDepositPublished, trade.getIsDepositPublished()); assertEquals(EXPECTED_PROTOCOL_STATUS.isDepositPublished, trade.getIsDepositsPublished());
assertEquals(EXPECTED_PROTOCOL_STATUS.isDepositConfirmed, trade.getIsDepositUnlocked()); assertEquals(EXPECTED_PROTOCOL_STATUS.isDepositConfirmed, trade.getIsDepositsUnlocked());
assertEquals(EXPECTED_PROTOCOL_STATUS.isPaymentStartedMessageSent, trade.getIsPaymentSent()); assertEquals(EXPECTED_PROTOCOL_STATUS.isPaymentStartedMessageSent, trade.getIsPaymentSent());
assertEquals(EXPECTED_PROTOCOL_STATUS.isPaymentReceivedMessageSent, trade.getIsPaymentReceived()); assertEquals(EXPECTED_PROTOCOL_STATUS.isPaymentReceivedMessageSent, trade.getIsPaymentReceived());
assertEquals(EXPECTED_PROTOCOL_STATUS.isPayoutPublished, trade.getIsPayoutPublished()); assertEquals(EXPECTED_PROTOCOL_STATUS.isPayoutPublished, trade.getIsPayoutPublished());

View file

@ -231,7 +231,7 @@ public class BotClient {
* @return boolean * @return boolean
*/ */
public boolean isTakerDepositFeeTxConfirmed(String tradeId) { public boolean isTakerDepositFeeTxConfirmed(String tradeId) {
return grpcClient.getTrade(tradeId).getIsDepositUnlocked(); return grpcClient.getTrade(tradeId).getIsDepositsUnlocked();
} }
/** /**

View file

@ -301,10 +301,10 @@ public abstract class BotProtocol {
} }
private final Predicate<TradeInfo> isDepositFeeTxStepComplete = (trade) -> { private final Predicate<TradeInfo> isDepositFeeTxStepComplete = (trade) -> {
if (currentProtocolStep.equals(WAIT_FOR_TAKER_DEPOSIT_TX_PUBLISHED) && trade.getIsDepositPublished()) { if (currentProtocolStep.equals(WAIT_FOR_TAKER_DEPOSIT_TX_PUBLISHED) && trade.getIsDepositsPublished()) {
log.info("Taker deposit fee tx {} has been published.", trade.getTakerDepositTxId()); log.info("Taker deposit fee tx {} has been published.", trade.getTakerDepositTxId());
return true; return true;
} else if (currentProtocolStep.equals(WAIT_FOR_TAKER_DEPOSIT_TX_CONFIRMED) && trade.getIsDepositUnlocked()) { } else if (currentProtocolStep.equals(WAIT_FOR_TAKER_DEPOSIT_TX_CONFIRMED) && trade.getIsDepositsUnlocked()) {
log.info("Taker deposit fee tx {} has been confirmed.", trade.getTakerDepositTxId()); log.info("Taker deposit fee tx {} has been confirmed.", trade.getTakerDepositTxId());
return true; return true;
} else { } else {

View file

@ -65,8 +65,8 @@ class TradeDetailTableBuilder extends AbstractTradeListBuilder {
colAmount.addRow(toTradeAmount.apply(trade)); colAmount.addRow(toTradeAmount.apply(trade));
colMinerTxFee.addRow(toMyMinerTxFee.apply(trade)); colMinerTxFee.addRow(toMyMinerTxFee.apply(trade));
colBisqTradeFee.addRow(toMyMakerOrTakerFee.apply(trade)); colBisqTradeFee.addRow(toMyMakerOrTakerFee.apply(trade));
colIsDepositPublished.addRow(trade.getIsDepositPublished()); colIsDepositPublished.addRow(trade.getIsDepositsPublished());
colIsDepositConfirmed.addRow(trade.getIsDepositUnlocked()); colIsDepositConfirmed.addRow(trade.getIsDepositsUnlocked());
colTradeCost.addRow(toTradeVolumeAsString.apply(trade)); colTradeCost.addRow(toTradeVolumeAsString.apply(trade));
colIsPaymentStartedMessageSent.addRow(trade.getIsPaymentSent()); colIsPaymentStartedMessageSent.addRow(trade.getIsPaymentSent());
colIsPaymentReceivedMessageSent.addRow(trade.getIsPaymentReceived()); colIsPaymentReceivedMessageSent.addRow(trade.getIsPaymentReceived());

View file

@ -164,7 +164,7 @@ public class CoreAccountService {
public void deleteAccount(Runnable onShutdown) { public void deleteAccount(Runnable onShutdown) {
try { try {
keyRing.lockKeys(); if (isAccountOpen()) closeAccount();
synchronized (listeners) { synchronized (listeners) {
for (AccountServiceListener listener : listeners) listener.onAccountDeleted(onShutdown); for (AccountServiceListener listener : listeners) listener.onAccountDeleted(onShutdown);
} }

View file

@ -254,9 +254,12 @@ public final class CoreMoneroConnectionsService {
} }
} }
// ----------------------------- APP METHODS ------------------------------ public void verifyConnection() {
if (daemon == null) throw new RuntimeException("No connection to Monero node");
if (!isSyncedWithinTolerance()) throw new RuntimeException("Monero node is not synced");
}
public boolean isChainHeightSyncedWithinTolerance() { public boolean isSyncedWithinTolerance() {
if (daemon == null) return false; if (daemon == null) return false;
Long targetHeight = lastInfo.getTargetHeight(); // the last time the node thought it was behind the network and was in active sync mode to catch up Long targetHeight = lastInfo.getTargetHeight(); // the last time the node thought it was behind the network and was in active sync mode to catch up
if (targetHeight == 0) return true; // monero-daemon-rpc sync_info's target_height returns 0 when node is fully synced if (targetHeight == 0) return true; // monero-daemon-rpc sync_info's target_height returns 0 when node is fully synced
@ -268,6 +271,8 @@ public final class CoreMoneroConnectionsService {
return false; return false;
} }
// ----------------------------- APP METHODS ------------------------------
public ReadOnlyIntegerProperty numPeersProperty() { public ReadOnlyIntegerProperty numPeersProperty() {
return numPeers; return numPeers;
} }

View file

@ -82,9 +82,9 @@ public class TradeInfo implements Payload {
private final String periodState; private final String periodState;
private final String payoutState; private final String payoutState;
private final String disputeState; private final String disputeState;
private final boolean isDepositPublished; private final boolean isDepositsPublished;
private final boolean isDepositConfirmed; private final boolean isDepositsConfirmed;
private final boolean isDepositUnlocked; private final boolean isDepositsUnlocked;
private final boolean isPaymentSent; private final boolean isPaymentSent;
private final boolean isPaymentReceived; private final boolean isPaymentReceived;
private final boolean isPayoutPublished; private final boolean isPayoutPublished;
@ -117,9 +117,9 @@ public class TradeInfo implements Payload {
this.periodState = builder.getPeriodState(); this.periodState = builder.getPeriodState();
this.payoutState = builder.getPayoutState(); this.payoutState = builder.getPayoutState();
this.disputeState = builder.getDisputeState(); this.disputeState = builder.getDisputeState();
this.isDepositPublished = builder.isDepositPublished(); this.isDepositsPublished = builder.isDepositsPublished();
this.isDepositConfirmed = builder.isDepositConfirmed(); this.isDepositsConfirmed = builder.isDepositsConfirmed();
this.isDepositUnlocked = builder.isDepositUnlocked(); this.isDepositsUnlocked = builder.isDepositsUnlocked();
this.isPaymentSent = builder.isPaymentSent(); this.isPaymentSent = builder.isPaymentSent();
this.isPaymentReceived = builder.isPaymentReceived(); this.isPaymentReceived = builder.isPaymentReceived();
this.isPayoutPublished = builder.isPayoutPublished(); this.isPayoutPublished = builder.isPayoutPublished();
@ -175,9 +175,9 @@ public class TradeInfo implements Payload {
.withPeriodState(trade.getPeriodState().name()) .withPeriodState(trade.getPeriodState().name())
.withPayoutState(trade.getPayoutState().name()) .withPayoutState(trade.getPayoutState().name())
.withDisputeState(trade.getDisputeState().name()) .withDisputeState(trade.getDisputeState().name())
.withIsDepositPublished(trade.isDepositPublished()) .withIsDepositsPublished(trade.isDepositsPublished())
.withIsDepositConfirmed(trade.isDepositConfirmed()) .withIsDepositsConfirmed(trade.isDepositsConfirmed())
.withIsDepositUnlocked(trade.isDepositUnlocked()) .withIsDepositsUnlocked(trade.isDepositsUnlocked())
.withIsPaymentSent(trade.isPaymentSent()) .withIsPaymentSent(trade.isPaymentSent())
.withIsPaymentReceived(trade.isPaymentReceived()) .withIsPaymentReceived(trade.isPaymentReceived())
.withIsPayoutPublished(trade.isPayoutPublished()) .withIsPayoutPublished(trade.isPayoutPublished())
@ -219,9 +219,9 @@ public class TradeInfo implements Payload {
.setPeriodState(periodState) .setPeriodState(periodState)
.setPayoutState(payoutState) .setPayoutState(payoutState)
.setDisputeState(disputeState) .setDisputeState(disputeState)
.setIsDepositPublished(isDepositPublished) .setIsDepositsPublished(isDepositsPublished)
.setIsDepositConfirmed(isDepositConfirmed) .setIsDepositsConfirmed(isDepositsConfirmed)
.setIsDepositUnlocked(isDepositUnlocked) .setIsDepositsUnlocked(isDepositsUnlocked)
.setIsPaymentSent(isPaymentSent) .setIsPaymentSent(isPaymentSent)
.setIsPaymentReceived(isPaymentReceived) .setIsPaymentReceived(isPaymentReceived)
.setIsCompleted(isCompleted) .setIsCompleted(isCompleted)
@ -257,9 +257,9 @@ public class TradeInfo implements Payload {
.withPhase(proto.getPhase()) .withPhase(proto.getPhase())
.withArbitratorNodeAddress(proto.getArbitratorNodeAddress()) .withArbitratorNodeAddress(proto.getArbitratorNodeAddress())
.withTradingPeerNodeAddress(proto.getTradingPeerNodeAddress()) .withTradingPeerNodeAddress(proto.getTradingPeerNodeAddress())
.withIsDepositPublished(proto.getIsDepositPublished()) .withIsDepositsPublished(proto.getIsDepositsPublished())
.withIsDepositConfirmed(proto.getIsDepositConfirmed()) .withIsDepositsConfirmed(proto.getIsDepositsConfirmed())
.withIsDepositUnlocked(proto.getIsDepositUnlocked()) .withIsDepositsUnlocked(proto.getIsDepositsUnlocked())
.withIsPaymentSent(proto.getIsPaymentSent()) .withIsPaymentSent(proto.getIsPaymentSent())
.withIsPaymentReceived(proto.getIsPaymentReceived()) .withIsPaymentReceived(proto.getIsPaymentReceived())
.withIsCompleted(proto.getIsCompleted()) .withIsCompleted(proto.getIsCompleted())
@ -294,9 +294,9 @@ public class TradeInfo implements Payload {
", periodState='" + periodState + '\'' + "\n" + ", periodState='" + periodState + '\'' + "\n" +
", payoutState='" + payoutState + '\'' + "\n" + ", payoutState='" + payoutState + '\'' + "\n" +
", disputeState='" + disputeState + '\'' + "\n" + ", disputeState='" + disputeState + '\'' + "\n" +
", isDepositPublished=" + isDepositPublished + "\n" + ", isDepositsPublished=" + isDepositsPublished + "\n" +
", isDepositConfirmed=" + isDepositConfirmed + "\n" + ", isDepositsConfirmed=" + isDepositsConfirmed + "\n" +
", isDepositUnlocked=" + isDepositUnlocked + "\n" + ", isDepositsUnlocked=" + isDepositsUnlocked + "\n" +
", isPaymentSent=" + isPaymentSent + "\n" + ", isPaymentSent=" + isPaymentSent + "\n" +
", isPaymentReceived=" + isPaymentReceived + "\n" + ", isPaymentReceived=" + isPaymentReceived + "\n" +
", isPayoutPublished=" + isPayoutPublished + "\n" + ", isPayoutPublished=" + isPayoutPublished + "\n" +

View file

@ -55,9 +55,9 @@ public final class TradeInfoV1Builder {
private String periodState; private String periodState;
private String payoutState; private String payoutState;
private String disputeState; private String disputeState;
private boolean isDepositPublished; private boolean isDepositsPublished;
private boolean isDepositConfirmed; private boolean isDepositsConfirmed;
private boolean isDepositUnlocked; private boolean isDepositsUnlocked;
private boolean isPaymentSent; private boolean isPaymentSent;
private boolean isPaymentReceived; private boolean isPaymentReceived;
private boolean isPayoutPublished; private boolean isPayoutPublished;
@ -183,18 +183,18 @@ public final class TradeInfoV1Builder {
return this; return this;
} }
public TradeInfoV1Builder withIsDepositPublished(boolean isDepositPublished) { public TradeInfoV1Builder withIsDepositsPublished(boolean isDepositsPublished) {
this.isDepositPublished = isDepositPublished; this.isDepositsPublished = isDepositsPublished;
return this; return this;
} }
public TradeInfoV1Builder withIsDepositConfirmed(boolean isDepositConfirmed) { public TradeInfoV1Builder withIsDepositsConfirmed(boolean isDepositsConfirmed) {
this.isDepositConfirmed = isDepositConfirmed; this.isDepositsConfirmed = isDepositsConfirmed;
return this; return this;
} }
public TradeInfoV1Builder withIsDepositUnlocked(boolean isDepositUnlocked) { public TradeInfoV1Builder withIsDepositsUnlocked(boolean isDepositsUnlocked) {
this.isDepositUnlocked = isDepositUnlocked; this.isDepositsUnlocked = isDepositsUnlocked;
return this; return this;
} }

View file

@ -500,7 +500,7 @@ public class HavenoSetup {
revolutAccountsUpdateHandler, revolutAccountsUpdateHandler,
amazonGiftCardAccountsUpdateHandler); amazonGiftCardAccountsUpdateHandler);
if (walletsSetup.downloadPercentageProperty().get() == 1) { if (walletsSetup.downloadPercentageProperty().get() == 1) { // TODO: update for XMR
checkForLockedUpFunds(); checkForLockedUpFunds();
checkForInvalidMakerFeeTxs(); checkForInvalidMakerFeeTxs();
} }

View file

@ -91,11 +91,15 @@ public class Balances {
private void updatedBalances() { private void updatedBalances() {
if (!xmrWalletService.isWalletReady()) return; if (!xmrWalletService.isWalletReady()) return;
updateAvailableBalance(); try {
updatePendingBalance(); updateAvailableBalance();
updateReservedOfferBalance(); updatePendingBalance();
updateReservedTradeBalance(); updateReservedOfferBalance();
updateReservedBalance(); updateReservedTradeBalance();
updateReservedBalance();
} catch (Exception e) {
if (xmrWalletService.isWalletReady()) throw e; // ignore exception if wallet isn't ready
}
} }
// TODO (woodser): converting to long should generally be avoided since can lose precision, but in practice these amounts are below max value // TODO (woodser): converting to long should generally be avoided since can lose precision, but in practice these amounts are below max value

View file

@ -491,6 +491,7 @@ public class XmrWalletService {
synchronized (txCache) { synchronized (txCache) {
// fetch txs // fetch txs
if (getDaemon() == null) connectionsService.verifyConnection(); // will throw
List<MoneroTx> txs = getDaemon().getTxs(txHashes, true); List<MoneroTx> txs = getDaemon().getTxs(txHashes, true);
// store to cache // store to cache
@ -549,6 +550,7 @@ public class XmrWalletService {
} }
private void maybeInitMainWallet() { private void maybeInitMainWallet() {
if (wallet != null) throw new RuntimeException("Main wallet is already initialized");
// open or create wallet // open or create wallet
MoneroWalletConfig walletConfig = new MoneroWalletConfig().setPath(MONERO_WALLET_NAME).setPassword(getWalletPassword()); MoneroWalletConfig walletConfig = new MoneroWalletConfig().setPath(MONERO_WALLET_NAME).setPassword(getWalletPassword());
@ -560,14 +562,9 @@ public class XmrWalletService {
// wallet is not initialized until connected to a daemon // wallet is not initialized until connected to a daemon
if (wallet != null) { if (wallet != null) {
try {
wallet.sync(); // blocking // sync wallet which updates app startup state
wallet.startSyncing(connectionsService.getDefaultRefreshPeriodMs()); // start syncing wallet in background trySyncMainWallet();
connectionsService.doneDownload(); // TODO: using this to signify both daemon and wallet synced, refactor sync handling of both
saveMainWallet(false); // skip backup on open
} catch (Exception e) {
e.printStackTrace();
}
if (connectionsService.getDaemon() == null) System.out.println("Daemon: null"); if (connectionsService.getDaemon() == null) System.out.println("Daemon: null");
else { else {
@ -671,12 +668,25 @@ public class XmrWalletService {
return MONERO_WALLET_RPC_MANAGER.startInstance(cmd); return MONERO_WALLET_RPC_MANAGER.startInstance(cmd);
} }
private void trySyncMainWallet() {
try {
log.info("Syncing main wallet");
wallet.startSyncing(connectionsService.getDefaultRefreshPeriodMs()); // start syncing wallet in background
wallet.sync(); // blocking
connectionsService.doneDownload(); // TODO: using this to signify both daemon and wallet synced, refactor sync handling of both
log.info("Done syncing main wallet");
saveMainWallet(false); // skip backup on open
} catch (Exception e) {
log.warn("Error syncing main wallet: {}", e.getMessage());
}
}
private void setDaemonConnection(MoneroRpcConnection connection) { private void setDaemonConnection(MoneroRpcConnection connection) {
log.info("Setting wallet daemon connection: " + (connection == null ? null : connection.getUri())); log.info("Setting wallet daemon connection: " + (connection == null ? null : connection.getUri()));
if (wallet == null) maybeInitMainWallet(); if (wallet == null) maybeInitMainWallet();
if (wallet != null) { else {
wallet.setDaemonConnection(connection); wallet.setDaemonConnection(connection);
wallet.startSyncing(connectionsService.getDefaultRefreshPeriodMs()); if (connection != null) new Thread(() -> trySyncMainWallet()).start();
} }
} }

View file

@ -1008,7 +1008,7 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
} }
// Don't allow trade start if Monero node is not fully synced // Don't allow trade start if Monero node is not fully synced
if (!connectionService.isChainHeightSyncedWithinTolerance()) { if (!connectionService.isSyncedWithinTolerance()) {
errorMessage = "We got a handleOfferAvailabilityRequest but our chain is not synced."; errorMessage = "We got a handleOfferAvailabilityRequest but our chain is not synced.";
log.info(errorMessage); log.info(errorMessage);
sendAckMessage(request.getClass(), peer, request.getPubKeyRing(), request.getOfferId(), request.getUid(), false, errorMessage); sendAckMessage(request.getClass(), peer, request.getPubKeyRing(), request.getOfferId(), request.getUid(), false, errorMessage);

View file

@ -50,6 +50,9 @@ public class MakerReserveOfferFunds extends Task<PlaceOfferModel> {
try { try {
runInterceptHook(); runInterceptHook();
// verify monero connection
model.getXmrWalletService().getConnectionsService().verifyConnection();
// create reserve tx // create reserve tx
BigInteger makerFee = HavenoUtils.coinToAtomicUnits(offer.getMakerFee()); BigInteger makerFee = HavenoUtils.coinToAtomicUnits(offer.getMakerFee());
BigInteger sendAmount = HavenoUtils.coinToAtomicUnits(offer.getDirection() == OfferDirection.BUY ? Coin.ZERO : offer.getAmount()); BigInteger sendAmount = HavenoUtils.coinToAtomicUnits(offer.getDirection() == OfferDirection.BUY ? Coin.ZERO : offer.getAmount());

View file

@ -20,8 +20,11 @@ package bisq.core.support;
import bisq.core.api.CoreMoneroConnectionsService; import bisq.core.api.CoreMoneroConnectionsService;
import bisq.core.api.CoreNotificationService; import bisq.core.api.CoreNotificationService;
import bisq.core.locale.Res; import bisq.core.locale.Res;
import bisq.core.support.dispute.Dispute;
import bisq.core.support.messages.ChatMessage; import bisq.core.support.messages.ChatMessage;
import bisq.core.support.messages.SupportMessage; import bisq.core.support.messages.SupportMessage;
import bisq.core.trade.Trade;
import bisq.core.trade.TradeManager;
import bisq.core.trade.protocol.TradeProtocol; import bisq.core.trade.protocol.TradeProtocol;
import bisq.core.trade.protocol.TradeProtocol.MailboxMessageComparator; import bisq.core.trade.protocol.TradeProtocol.MailboxMessageComparator;
import bisq.network.p2p.AckMessage; import bisq.network.p2p.AckMessage;
@ -51,6 +54,7 @@ import javax.annotation.Nullable;
@Slf4j @Slf4j
public abstract class SupportManager { public abstract class SupportManager {
protected final P2PService p2PService; protected final P2PService p2PService;
protected final TradeManager tradeManager;
protected final CoreMoneroConnectionsService connectionService; protected final CoreMoneroConnectionsService connectionService;
protected final CoreNotificationService notificationService; protected final CoreNotificationService notificationService;
protected final Map<String, Timer> delayMsgMap = new HashMap<>(); protected final Map<String, Timer> delayMsgMap = new HashMap<>();
@ -65,11 +69,15 @@ public abstract class SupportManager {
// Constructor // Constructor
/////////////////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////////////////
public SupportManager(P2PService p2PService, CoreMoneroConnectionsService connectionService, CoreNotificationService notificationService) { public SupportManager(P2PService p2PService,
CoreMoneroConnectionsService connectionService,
CoreNotificationService notificationService,
TradeManager tradeManager) {
this.p2PService = p2PService; this.p2PService = p2PService;
this.connectionService = connectionService; this.connectionService = connectionService;
this.mailboxMessageService = p2PService.getMailboxMessageService(); this.mailboxMessageService = p2PService.getMailboxMessageService();
this.notificationService = notificationService; this.notificationService = notificationService;
this.tradeManager = tradeManager;
// We get first the message handler called then the onBootstrapped // We get first the message handler called then the onBootstrapped
p2PService.addDecryptedDirectMessageListener((decryptedMessageWithPubKey, senderAddress) -> { p2PService.addDecryptedDirectMessageListener((decryptedMessageWithPubKey, senderAddress) -> {
@ -181,6 +189,18 @@ public abstract class SupportManager {
if (ackMessage.isSuccess()) { if (ackMessage.isSuccess()) {
log.info("Received AckMessage for {} with tradeId {} and uid {}", log.info("Received AckMessage for {} with tradeId {} and uid {}",
ackMessage.getSourceMsgClassName(), ackMessage.getSourceId(), ackMessage.getSourceUid()); ackMessage.getSourceMsgClassName(), ackMessage.getSourceId(), ackMessage.getSourceUid());
// dispute is opened by ack on chat message
if (ackMessage.getSourceMsgClassName().equals(ChatMessage.class.getSimpleName())) {
Trade trade = tradeManager.getTrade(ackMessage.getSourceId());
for (Dispute dispute : trade.getDisputes()) {
for (ChatMessage chatMessage : dispute.getChatMessages()) {
if (chatMessage.getUid().equals(ackMessage.getSourceUid())) {
trade.setDisputeStateIfProgress(Trade.DisputeState.DISPUTE_OPENED);
}
}
}
}
} else { } else {
log.warn("Received AckMessage with error state for {} with tradeId {} and errorMessage={}", log.warn("Received AckMessage with error state for {} with tradeId {} and errorMessage={}",
ackMessage.getSourceMsgClassName(), ackMessage.getSourceId(), ackMessage.getErrorMessage()); ackMessage.getSourceMsgClassName(), ackMessage.getSourceId(), ackMessage.getErrorMessage());

View file

@ -47,7 +47,6 @@ import bisq.network.p2p.BootstrapListener;
import bisq.network.p2p.NodeAddress; import bisq.network.p2p.NodeAddress;
import bisq.network.p2p.P2PService; import bisq.network.p2p.P2PService;
import bisq.network.p2p.SendMailboxMessageListener; import bisq.network.p2p.SendMailboxMessageListener;
import bisq.common.UserThread; import bisq.common.UserThread;
import bisq.common.app.Version; import bisq.common.app.Version;
import bisq.common.config.Config; import bisq.common.config.Config;
@ -94,7 +93,6 @@ import static com.google.common.base.Preconditions.checkNotNull;
public abstract class DisputeManager<T extends DisputeList<Dispute>> extends SupportManager { public abstract class DisputeManager<T extends DisputeList<Dispute>> extends SupportManager {
protected final TradeWalletService tradeWalletService; protected final TradeWalletService tradeWalletService;
protected final XmrWalletService xmrWalletService; protected final XmrWalletService xmrWalletService;
protected final TradeManager tradeManager;
protected final ClosedTradableManager closedTradableManager; protected final ClosedTradableManager closedTradableManager;
protected final OpenOfferManager openOfferManager; protected final OpenOfferManager openOfferManager;
protected final KeyRing keyRing; protected final KeyRing keyRing;
@ -122,11 +120,10 @@ public abstract class DisputeManager<T extends DisputeList<Dispute>> extends Sup
DisputeListService<T> disputeListService, DisputeListService<T> disputeListService,
Config config, Config config,
PriceFeedService priceFeedService) { PriceFeedService priceFeedService) {
super(p2PService, connectionService, notificationService); super(p2PService, connectionService, notificationService, tradeManager);
this.tradeWalletService = tradeWalletService; this.tradeWalletService = tradeWalletService;
this.xmrWalletService = xmrWalletService; this.xmrWalletService = xmrWalletService;
this.tradeManager = tradeManager;
this.closedTradableManager = closedTradableManager; this.closedTradableManager = closedTradableManager;
this.openOfferManager = openOfferManager; this.openOfferManager = openOfferManager;
this.keyRing = keyRing; this.keyRing = keyRing;
@ -234,7 +231,9 @@ public abstract class DisputeManager<T extends DisputeList<Dispute>> extends Sup
} }
protected T getDisputeList() { protected T getDisputeList() {
return disputeListService.getDisputeList(); synchronized(disputeListService.getDisputeList()) {
return disputeListService.getDisputeList();
}
} }
public Set<String> getDisputedTradeIds() { public Set<String> getDisputedTradeIds() {
@ -367,7 +366,7 @@ public abstract class DisputeManager<T extends DisputeList<Dispute>> extends Sup
UUID.randomUUID().toString(), UUID.randomUUID().toString(),
getSupportType(), getSupportType(),
updatedMultisigHex, updatedMultisigHex,
trade.getBuyer().getPaymentSentMessage()); trade.getProcessModel().getPaymentSentMessage());
log.info("Send {} to peer {}. tradeId={}, openNewDisputeMessage.uid={}, " + log.info("Send {} to peer {}. tradeId={}, openNewDisputeMessage.uid={}, " +
"chatMessage.uid={}", "chatMessage.uid={}",
disputeOpenedMessage.getClass().getSimpleName(), agentNodeAddress, disputeOpenedMessage.getClass().getSimpleName(), agentNodeAddress,
@ -388,7 +387,7 @@ public abstract class DisputeManager<T extends DisputeList<Dispute>> extends Sup
// We use the chatMessage wrapped inside the openNewDisputeMessage for // We use the chatMessage wrapped inside the openNewDisputeMessage for
// the state, as that is displayed to the user and we only persist that msg // the state, as that is displayed to the user and we only persist that msg
chatMessage.setArrived(true); chatMessage.setArrived(true);
trade.setDisputeStateIfProgress(Trade.DisputeState.DISPUTE_OPENED); trade.setDisputeStateIfProgress(Trade.DisputeState.DISPUTE_REQUESTED);
requestPersistence(); requestPersistence();
resultHandler.handleResult(); resultHandler.handleResult();
} }
@ -404,7 +403,7 @@ public abstract class DisputeManager<T extends DisputeList<Dispute>> extends Sup
// We use the chatMessage wrapped inside the openNewDisputeMessage for // We use the chatMessage wrapped inside the openNewDisputeMessage for
// the state, as that is displayed to the user and we only persist that msg // the state, as that is displayed to the user and we only persist that msg
chatMessage.setStoredInMailbox(true); chatMessage.setStoredInMailbox(true);
trade.setDisputeStateIfProgress(Trade.DisputeState.DISPUTE_OPENED); trade.setDisputeStateIfProgress(Trade.DisputeState.DISPUTE_REQUESTED);
requestPersistence(); requestPersistence();
resultHandler.handleResult(); resultHandler.handleResult();
} }
@ -441,86 +440,97 @@ public abstract class DisputeManager<T extends DisputeList<Dispute>> extends Sup
Dispute dispute = message.getDispute(); Dispute dispute = message.getDispute();
log.info("{}.onDisputeOpenedMessage() with trade {}, dispute {}", getClass().getSimpleName(), dispute.getTradeId(), dispute.getId()); log.info("{}.onDisputeOpenedMessage() with trade {}, dispute {}", getClass().getSimpleName(), dispute.getTradeId(), dispute.getId());
// intialize Trade trade = null;
T disputeList = getDisputeList();
if (disputeList == null) {
log.warn("disputes is null");
return;
}
dispute.setSupportType(message.getSupportType());
dispute.setState(Dispute.State.NEW); // TODO: unused, remove?
Contract contract = dispute.getContract();
// validate dispute
try {
TradeDataValidation.validatePaymentAccountPayload(dispute);
TradeDataValidation.validateDonationAddress(dispute.getDonationAddressOfDelayedPayoutTx());
//TradeDataValidation.testIfDisputeTriesReplay(dispute, disputeList.getList()); // TODO (woodser): disabled for xmr, needed?
TradeDataValidation.validateNodeAddress(dispute, contract.getBuyerNodeAddress(), config);
TradeDataValidation.validateNodeAddress(dispute, contract.getSellerNodeAddress(), config);
} catch (TradeDataValidation.AddressException |
TradeDataValidation.NodeAddressException |
TradeDataValidation.InvalidPaymentAccountPayloadException e) {
log.error(e.toString());
validationExceptions.add(e);
}
// get trade
Trade trade = tradeManager.getTrade(dispute.getTradeId());
if (trade == null) {
log.warn("Dispute trade {} does not exist", dispute.getTradeId());
return;
}
// get sender
PubKeyRing senderPubKeyRing = trade.isArbitrator() ? (dispute.isDisputeOpenerIsBuyer() ? contract.getBuyerPubKeyRing() : contract.getSellerPubKeyRing()) : trade.getArbitrator().getPubKeyRing();
TradingPeer sender = trade.getTradingPeer(senderPubKeyRing);
if (sender == null) throw new RuntimeException("Pub key ring is not from arbitrator, buyer, or seller");
// message to trader is expected from arbitrator
if (!trade.isArbitrator() && sender != trade.getArbitrator()) {
throw new RuntimeException(message.getClass().getSimpleName() + " to trader is expected only from arbitrator");
}
// arbitrator verifies signature of payment sent message if given
if (trade.isArbitrator() && message.getPaymentSentMessage() != null) {
HavenoUtils.verifyPaymentSentMessage(trade, message.getPaymentSentMessage());
trade.getBuyer().setUpdatedMultisigHex(message.getPaymentSentMessage().getUpdatedMultisigHex());
trade.setStateIfProgress(sender == trade.getBuyer() ? Trade.State.BUYER_SENT_PAYMENT_SENT_MSG : Trade.State.SELLER_RECEIVED_PAYMENT_SENT_MSG);
}
// update multisig hex
if (message.getUpdatedMultisigHex() != null) sender.setUpdatedMultisigHex(message.getUpdatedMultisigHex());
// update peer node address
// TODO: tests can reuse the same addresses so nullify equal peer
sender.setNodeAddress(message.getSenderNodeAddress());
// add chat message with price info
if (trade instanceof ArbitratorTrade) addPriceInfoMessage(dispute, 0);
// add dispute
String errorMessage = null; String errorMessage = null;
synchronized (disputeList) { PubKeyRing senderPubKeyRing = null;
if (!disputeList.contains(dispute)) { try {
Optional<Dispute> storedDisputeOptional = findDispute(dispute);
if (!storedDisputeOptional.isPresent()) { // intialize
disputeList.add(dispute); T disputeList = getDisputeList();
trade.setDisputeStateIfProgress(Trade.DisputeState.DISPUTE_OPENED); if (disputeList == null) {
log.warn("disputes is null");
// send dispute opened message to peer if arbitrator return;
if (trade.isArbitrator()) sendDisputeOpenedMessageToPeer(dispute, contract, dispute.isDisputeOpenerIsBuyer() ? contract.getSellerPubKeyRing() : contract.getBuyerPubKeyRing(), trade.getSelf().getUpdatedMultisigHex());
tradeManager.requestPersistence();
errorMessage = null;
} else {
// valid case if both have opened a dispute and agent was not online
log.debug("We got a dispute already open for that trade and trading peer. TradeId = {}",
dispute.getTradeId());
}
} else {
errorMessage = "We got a dispute msg that we have already stored. TradeId = " + dispute.getTradeId();
log.warn(errorMessage);
} }
dispute.setSupportType(message.getSupportType());
dispute.setState(Dispute.State.NEW);
Contract contract = dispute.getContract();
// validate dispute
try {
TradeDataValidation.validatePaymentAccountPayload(dispute);
TradeDataValidation.validateDonationAddress(dispute.getDonationAddressOfDelayedPayoutTx());
//TradeDataValidation.testIfDisputeTriesReplay(dispute, disputeList.getList()); // TODO (woodser): disabled for xmr, needed?
TradeDataValidation.validateNodeAddress(dispute, contract.getBuyerNodeAddress(), config);
TradeDataValidation.validateNodeAddress(dispute, contract.getSellerNodeAddress(), config);
} catch (TradeDataValidation.AddressException |
TradeDataValidation.NodeAddressException |
TradeDataValidation.InvalidPaymentAccountPayloadException e) {
validationExceptions.add(e);
throw e;
}
// get trade
trade = tradeManager.getTrade(dispute.getTradeId());
if (trade == null) {
log.warn("Dispute trade {} does not exist", dispute.getTradeId());
return;
}
// get sender
senderPubKeyRing = trade.isArbitrator() ? (dispute.isDisputeOpenerIsBuyer() ? contract.getBuyerPubKeyRing() : contract.getSellerPubKeyRing()) : trade.getArbitrator().getPubKeyRing();
TradingPeer sender = trade.getTradingPeer(senderPubKeyRing);
if (sender == null) throw new RuntimeException("Pub key ring is not from arbitrator, buyer, or seller");
// message to trader is expected from arbitrator
if (!trade.isArbitrator() && sender != trade.getArbitrator()) {
throw new RuntimeException(message.getClass().getSimpleName() + " to trader is expected only from arbitrator");
}
// arbitrator verifies signature of payment sent message if given
if (trade.isArbitrator() && message.getPaymentSentMessage() != null) {
HavenoUtils.verifyPaymentSentMessage(trade, message.getPaymentSentMessage());
trade.getBuyer().setUpdatedMultisigHex(message.getPaymentSentMessage().getUpdatedMultisigHex());
trade.setStateIfProgress(sender == trade.getBuyer() ? Trade.State.BUYER_SENT_PAYMENT_SENT_MSG : Trade.State.SELLER_RECEIVED_PAYMENT_SENT_MSG);
}
// update multisig hex
if (message.getUpdatedMultisigHex() != null) sender.setUpdatedMultisigHex(message.getUpdatedMultisigHex());
// update peer node address
// TODO: tests can reuse the same addresses so nullify equal peer
sender.setNodeAddress(message.getSenderNodeAddress());
// add chat message with price info
if (trade instanceof ArbitratorTrade) addPriceInfoMessage(dispute, 0);
// add dispute
synchronized (disputeList) {
if (!disputeList.contains(dispute)) {
Optional<Dispute> storedDisputeOptional = findDispute(dispute);
if (!storedDisputeOptional.isPresent()) {
disputeList.add(dispute);
trade.setDisputeStateIfProgress(Trade.DisputeState.DISPUTE_OPENED);
// send dispute opened message to peer if arbitrator
if (trade.isArbitrator()) sendDisputeOpenedMessageToPeer(dispute, contract, dispute.isDisputeOpenerIsBuyer() ? contract.getSellerPubKeyRing() : contract.getBuyerPubKeyRing(), trade.getSelf().getUpdatedMultisigHex());
tradeManager.requestPersistence();
errorMessage = null;
} else {
// valid case if both have opened a dispute and agent was not online
log.debug("We got a dispute already open for that trade and trading peer. TradeId = {}",
dispute.getTradeId());
}
// add chat message with mediation info if applicable
addMediationResultMessage(dispute);
} else {
throw new RuntimeException("We got a dispute msg that we have already stored. TradeId = " + dispute.getTradeId());
}
}
} catch (Exception e) {
errorMessage = e.getMessage();
log.warn(errorMessage);
if (trade != null) trade.setErrorMessage(errorMessage);
} }
// use chat message instead of open dispute message for the ack // use chat message instead of open dispute message for the ack
@ -530,9 +540,6 @@ public abstract class DisputeManager<T extends DisputeList<Dispute>> extends Sup
sendAckMessage(msg, senderPubKeyRing, errorMessage == null, errorMessage); sendAckMessage(msg, senderPubKeyRing, errorMessage == null, errorMessage);
} }
// add chat message with mediation info if applicable // TODO: not applicable in haveno
addMediationResultMessage(dispute);
requestPersistence(); requestPersistence();
} }
@ -635,7 +642,7 @@ public abstract class DisputeManager<T extends DisputeList<Dispute>> extends Sup
UUID.randomUUID().toString(), UUID.randomUUID().toString(),
getSupportType(), getSupportType(),
updatedMultisigHex, updatedMultisigHex,
trade.getSelf().getPaymentSentMessage()); trade.getProcessModel().getPaymentSentMessage());
log.info("Send {} to peer {}. tradeId={}, peerOpenedDisputeMessage.uid={}, chatMessage.uid={}", log.info("Send {} to peer {}. tradeId={}, peerOpenedDisputeMessage.uid={}, chatMessage.uid={}",
peerOpenedDisputeMessage.getClass().getSimpleName(), peersNodeAddress, peerOpenedDisputeMessage.getClass().getSimpleName(), peersNodeAddress,

View file

@ -71,7 +71,7 @@ public class DisputeSummaryVerification {
String fullAddress = textToSign.split("\n")[1].split(": ")[1]; String fullAddress = textToSign.split("\n")[1].split(": ")[1];
NodeAddress nodeAddress = new NodeAddress(fullAddress); NodeAddress nodeAddress = new NodeAddress(fullAddress);
DisputeAgent disputeAgent = arbitratorMediator.getDisputeAgentByNodeAddress(nodeAddress).orElse(null); DisputeAgent disputeAgent = arbitratorMediator.getDisputeAgentByNodeAddress(nodeAddress).orElse(null);
checkNotNull(disputeAgent); checkNotNull(disputeAgent, "Dispute agent is null");
PublicKey pubKey = disputeAgent.getPubKeyRing().getSignaturePubKey(); PublicKey pubKey = disputeAgent.getPubKeyRing().getSignaturePubKey();
String sigString = parts[1].split(SEPARATOR2)[0]; String sigString = parts[1].split(SEPARATOR2)[0];

View file

@ -56,8 +56,12 @@ import com.google.inject.Singleton;
import java.math.BigInteger; import java.math.BigInteger;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.Set;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@ -77,6 +81,8 @@ public final class ArbitrationManager extends DisputeManager<ArbitrationDisputeL
private final ArbitratorManager arbitratorManager; private final ArbitratorManager arbitratorManager;
private Map<String, Integer> reprocessDisputeClosedMessageCounts = new HashMap<>();
/////////////////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////////////////
// Constructor // Constructor
/////////////////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////////////////
@ -117,15 +123,17 @@ public final class ArbitrationManager extends DisputeManager<ArbitrationDisputeL
log.info("Received {} from {} with tradeId {} and uid {}", log.info("Received {} from {} with tradeId {} and uid {}",
message.getClass().getSimpleName(), message.getSenderNodeAddress(), message.getTradeId(), message.getUid()); message.getClass().getSimpleName(), message.getSenderNodeAddress(), message.getTradeId(), message.getUid());
if (message instanceof DisputeOpenedMessage) { new Thread(() -> {
handleDisputeOpenedMessage((DisputeOpenedMessage) message); if (message instanceof DisputeOpenedMessage) {
} else if (message instanceof ChatMessage) { handleDisputeOpenedMessage((DisputeOpenedMessage) message);
handleChatMessage((ChatMessage) message); } else if (message instanceof ChatMessage) {
} else if (message instanceof DisputeClosedMessage) { handleChatMessage((ChatMessage) message);
handleDisputeClosedMessage((DisputeClosedMessage) message); } else if (message instanceof DisputeClosedMessage) {
} else { handleDisputeClosedMessage((DisputeClosedMessage) message);
log.warn("Unsupported message at dispatchMessage. message={}", message); } else {
} log.warn("Unsupported message at dispatchMessage. message={}", message);
}
}).start();
} }
} }
@ -173,121 +181,166 @@ public final class ArbitrationManager extends DisputeManager<ArbitrationDisputeL
// received by both peers when arbitrator closes disputes // received by both peers when arbitrator closes disputes
@Override @Override
public void handleDisputeClosedMessage(DisputeClosedMessage disputeClosedMessage) { public void handleDisputeClosedMessage(DisputeClosedMessage disputeClosedMessage) {
DisputeResult disputeResult = disputeClosedMessage.getDisputeResult(); handleDisputeClosedMessage(disputeClosedMessage, true);
ChatMessage chatMessage = disputeResult.getChatMessage();
checkNotNull(chatMessage, "chatMessage must not be null");
String tradeId = disputeResult.getTradeId();
// get trade
Trade trade = tradeManager.getTrade(tradeId);
if (trade == null) {
log.warn("Dispute trade {} does not exist", tradeId);
return;
}
log.info("Processing {} for {} {}", disputeClosedMessage.getClass().getSimpleName(), trade.getClass().getSimpleName(), disputeResult.getTradeId());
// verify arbitrator signature
String summaryText = chatMessage.getMessage();
DisputeSummaryVerification.verifySignature(summaryText, arbitratorManager);
// get dispute
Optional<Dispute> disputeOptional = findDispute(disputeResult);
String uid = disputeClosedMessage.getUid();
if (!disputeOptional.isPresent()) {
log.warn("We got a dispute closed msg but we don't have a matching dispute. " +
"That might happen when we get the DisputeClosedMessage before the dispute was created. " +
"We try again after 2 sec. to apply the DisputeClosedMessage. TradeId = " + tradeId);
if (!delayMsgMap.containsKey(uid)) {
// We delay 2 sec. to be sure the comm. msg gets added first
Timer timer = UserThread.runAfter(() -> handleDisputeClosedMessage(disputeClosedMessage), 2);
delayMsgMap.put(uid, timer);
} else {
log.warn("We got a dispute closed msg after we already repeated to apply the message after a delay. " +
"That should never happen. TradeId = " + tradeId);
}
return;
}
Dispute dispute = disputeOptional.get();
// verify that arbitrator does not get DisputeClosedMessage
if (keyRing.getPubKeyRing().equals(dispute.getAgentPubKeyRing())) {
log.error("Arbitrator received disputeResultMessage. That should never happen.");
return;
}
// set dispute state
cleanupRetryMap(uid);
if (!dispute.getChatMessages().contains(chatMessage)) {
dispute.addAndPersistChatMessage(chatMessage);
} else {
log.warn("We got a dispute mail msg what we have already stored. TradeId = " + chatMessage.getTradeId());
}
dispute.setIsClosed();
if (dispute.disputeResultProperty().get() != null) {
log.info("We already got a dispute result, indicating the message was resent after updating multisig info. TradeId = " + tradeId);
}
dispute.setDisputeResult(disputeResult);
// import multisig hex
List<String> updatedMultisigHexes = new ArrayList<String>();
if (trade.getTradingPeer().getUpdatedMultisigHex() != null) updatedMultisigHexes.add(trade.getTradingPeer().getUpdatedMultisigHex());
if (trade.getArbitrator().getUpdatedMultisigHex() != null) updatedMultisigHexes.add(trade.getArbitrator().getUpdatedMultisigHex());
if (!updatedMultisigHexes.isEmpty()) trade.getWallet().importMultisigHex(updatedMultisigHexes.toArray(new String[0])); // TODO (monero-project): fails if multisig hex imported individually
// sync and save wallet
trade.syncWallet();
trade.saveWallet();
// run off main thread
new Thread(() -> {
String errorMessage = null;
boolean success = true;
// attempt to sign and publish dispute payout tx if given and not already published
if (disputeClosedMessage.getUnsignedPayoutTxHex() != null && !trade.isPayoutPublished()) {
// wait to sign and publish payout tx if defer flag set
if (disputeClosedMessage.isDeferPublishPayout()) {
log.info("Deferring signing and publishing dispute payout tx for {} {}", trade.getClass().getSimpleName(), trade.getId());
GenUtils.waitFor(Trade.DEFER_PUBLISH_MS);
trade.syncWallet();
}
// sign and publish dispute payout tx if peer still has not published
if (!trade.isPayoutPublished()) {
try {
log.info("Signing and publishing dispute payout tx for {} {}", trade.getClass().getSimpleName(), trade.getId());
signAndPublishDisputePayoutTx(trade, disputeClosedMessage.getUnsignedPayoutTxHex());
} catch (Exception e) {
// check if payout published again
trade.syncWallet();
if (trade.isPayoutPublished()) {
log.info("Dispute payout tx already published for {} {}", trade.getClass().getSimpleName(), trade.getId());
} else {
e.printStackTrace();
errorMessage = "Failed to sign and publish dispute payout tx from arbitrator: " + e.getMessage() + ". TradeId = " + tradeId;
log.warn(errorMessage);
success = false;
}
}
} else {
log.info("Dispute payout tx already published for {} {}", trade.getClass().getSimpleName(), trade.getId());
}
} else {
if (trade.isPayoutPublished()) log.info("Dispute payout tx already published for {} {}", trade.getClass().getSimpleName(), trade.getId());
else if (disputeClosedMessage.getUnsignedPayoutTxHex() == null) log.info("{} did not receive unsigned dispute payout tx for trade {} because the arbitrator did not have their updated multisig info (can happen if trader went offline after trade started)", trade.getClass().getName(), trade.getId());
}
// We use the chatMessage as we only persist those not the DisputeClosedMessage.
// If we would use the DisputeClosedMessage we could not lookup for the msg when we receive the AckMessage.
sendAckMessage(chatMessage, dispute.getAgentPubKeyRing(), success, errorMessage);
requestPersistence();
}).start();
} }
private MoneroTxSet signAndPublishDisputePayoutTx(Trade trade, String payoutTxHex) { private void handleDisputeClosedMessage(DisputeClosedMessage disputeClosedMessage, boolean reprocessOnError) {
// get dispute's trade
final Trade trade = tradeManager.getTrade(disputeClosedMessage.getTradeId());
if (trade == null) {
log.warn("Dispute trade {} does not exist", disputeClosedMessage.getTradeId());
return;
}
// try to process dispute closed message
ChatMessage chatMessage = null;
Dispute dispute = null;
synchronized (trade) {
try {
DisputeResult disputeResult = disputeClosedMessage.getDisputeResult();
chatMessage = disputeResult.getChatMessage();
checkNotNull(chatMessage, "chatMessage must not be null");
String tradeId = disputeResult.getTradeId();
log.info("Processing {} for {} {}", disputeClosedMessage.getClass().getSimpleName(), trade.getClass().getSimpleName(), disputeResult.getTradeId());
// verify arbitrator signature
String summaryText = chatMessage.getMessage();
DisputeSummaryVerification.verifySignature(summaryText, arbitratorManager);
// save dispute closed message for reprocessing
trade.getProcessModel().setDisputeClosedMessage(disputeClosedMessage);
requestPersistence();
// get dispute
Optional<Dispute> disputeOptional = findDispute(disputeResult);
String uid = disputeClosedMessage.getUid();
if (!disputeOptional.isPresent()) {
log.warn("We got a dispute closed msg but we don't have a matching dispute. " +
"That might happen when we get the DisputeClosedMessage before the dispute was created. " +
"We try again after 2 sec. to apply the DisputeClosedMessage. TradeId = " + tradeId);
if (!delayMsgMap.containsKey(uid)) {
// We delay 2 sec. to be sure the comm. msg gets added first
Timer timer = UserThread.runAfter(() -> handleDisputeClosedMessage(disputeClosedMessage), 2);
delayMsgMap.put(uid, timer);
} else {
log.warn("We got a dispute closed msg after we already repeated to apply the message after a delay. " +
"That should never happen. TradeId = " + tradeId);
}
return;
}
dispute = disputeOptional.get();
// verify that arbitrator does not get DisputeClosedMessage
if (keyRing.getPubKeyRing().equals(dispute.getAgentPubKeyRing())) {
log.error("Arbitrator received disputeResultMessage. That should never happen.");
trade.getProcessModel().setDisputeClosedMessage(null); // don't reprocess
return;
}
// set dispute state
cleanupRetryMap(uid);
if (!dispute.getChatMessages().contains(chatMessage)) {
dispute.addAndPersistChatMessage(chatMessage);
} else {
log.warn("We got a dispute mail msg what we have already stored. TradeId = " + chatMessage.getTradeId());
}
dispute.setIsClosed();
if (dispute.disputeResultProperty().get() != null) {
log.info("We already got a dispute result, indicating the message was resent after updating multisig info. TradeId = " + tradeId);
}
dispute.setDisputeResult(disputeResult);
// attempt to sign and publish dispute payout tx if given and not already published
if (disputeClosedMessage.getUnsignedPayoutTxHex() != null && !trade.isPayoutPublished()) {
// check wallet connection
trade.checkWalletConnection();
// import multisig hex
List<String> updatedMultisigHexes = new ArrayList<String>();
if (trade.getTradingPeer().getUpdatedMultisigHex() != null) updatedMultisigHexes.add(trade.getTradingPeer().getUpdatedMultisigHex());
if (trade.getArbitrator().getUpdatedMultisigHex() != null) updatedMultisigHexes.add(trade.getArbitrator().getUpdatedMultisigHex());
if (!updatedMultisigHexes.isEmpty()) trade.getWallet().importMultisigHex(updatedMultisigHexes.toArray(new String[0])); // TODO (monero-project): fails if multisig hex imported individually
// sync and save wallet
trade.syncWallet();
trade.saveWallet();
// wait to sign and publish payout tx if defer flag set
if (disputeClosedMessage.isDeferPublishPayout()) {
log.info("Deferring signing and publishing dispute payout tx for {} {}", trade.getClass().getSimpleName(), trade.getId());
GenUtils.waitFor(Trade.DEFER_PUBLISH_MS);
if (!trade.isPayoutUnlocked()) trade.syncWallet();
}
// sign and publish dispute payout tx if peer still has not published
if (!trade.isPayoutPublished()) {
try {
log.info("Signing and publishing dispute payout tx for {} {}", trade.getClass().getSimpleName(), trade.getId());
signAndPublishDisputePayoutTx(trade);
} catch (Exception e) {
// check if payout published again
trade.syncWallet();
if (trade.isPayoutPublished()) {
log.info("Dispute payout tx already published for {} {}", trade.getClass().getSimpleName(), trade.getId());
} else {
throw new RuntimeException("Failed to sign and publish dispute payout tx from arbitrator: " + e.getMessage() + ". TradeId = " + tradeId);
}
}
} else {
log.info("Dispute payout tx already published for {} {}", trade.getClass().getSimpleName(), trade.getId());
}
} else {
if (trade.isPayoutPublished()) log.info("Dispute payout tx already published for {} {}", trade.getClass().getSimpleName(), trade.getId());
else if (disputeClosedMessage.getUnsignedPayoutTxHex() == null) log.info("{} did not receive unsigned dispute payout tx for trade {} because the arbitrator did not have their updated multisig info (can happen if trader went offline after trade started)", trade.getClass().getName(), trade.getId());
}
// We use the chatMessage as we only persist those not the DisputeClosedMessage.
// If we would use the DisputeClosedMessage we could not lookup for the msg when we receive the AckMessage.
sendAckMessage(chatMessage, dispute.getAgentPubKeyRing(), true, null);
requestPersistence();
} catch (Exception e) {
log.warn("Error processing dispute closed message: " + e.getMessage());
e.printStackTrace();
requestPersistence();
// nack bad message and do not reprocess
if (e instanceof IllegalArgumentException) {
trade.getProcessModel().setPaymentReceivedMessage(null); // message is processed
sendAckMessage(chatMessage, dispute.getAgentPubKeyRing(), false, e.getMessage());
requestPersistence();
throw e;
}
// reprocess on error
if (trade.getProcessModel().getDisputeClosedMessage() != null) {
if (!reprocessDisputeClosedMessageCounts.containsKey(trade.getId())) reprocessDisputeClosedMessageCounts.put(trade.getId(), 0);
UserThread.runAfter(() -> {
reprocessDisputeClosedMessageCounts.put(trade.getId(), reprocessDisputeClosedMessageCounts.get(trade.getId()) + 1); // increment reprocess count
maybeReprocessDisputeClosedMessage(trade, reprocessOnError);
}, trade.getReprocessDelayInSeconds(reprocessDisputeClosedMessageCounts.get(trade.getId())));
}
}
}
}
public void maybeReprocessDisputeClosedMessage(Trade trade, boolean reprocessOnError) {
synchronized (trade) {
// skip if no need to reprocess
if (trade.isArbitrator() || trade.getProcessModel().getDisputeClosedMessage() == null || trade.getProcessModel().getDisputeClosedMessage().getUnsignedPayoutTxHex() == null || trade.getDisputeState().ordinal() >= Trade.DisputeState.DISPUTE_CLOSED.ordinal()) {
return;
}
log.warn("Reprocessing dispute closed message for {} {}", trade.getClass().getSimpleName(), trade.getId());
new Thread(() -> handleDisputeClosedMessage(trade.getProcessModel().getDisputeClosedMessage(), reprocessOnError)).start();
}
}
private MoneroTxSet signAndPublishDisputePayoutTx(Trade trade) {
// gather trade info // gather trade info
MoneroWallet multisigWallet = trade.getWallet(); MoneroWallet multisigWallet = trade.getWallet();
@ -296,6 +349,7 @@ public final class ArbitrationManager extends DisputeManager<ArbitrationDisputeL
Dispute dispute = disputeOptional.get(); Dispute dispute = disputeOptional.get();
Contract contract = dispute.getContract(); Contract contract = dispute.getContract();
DisputeResult disputeResult = dispute.getDisputeResultProperty().get(); DisputeResult disputeResult = dispute.getDisputeResultProperty().get();
String unsignedPayoutTxHex = trade.getProcessModel().getDisputeClosedMessage().getUnsignedPayoutTxHex();
// Offer offer = checkNotNull(trade.getOffer(), "offer must not be null"); // Offer offer = checkNotNull(trade.getOffer(), "offer must not be null");
// BigInteger sellerDepositAmount = multisigWallet.getTx(trade instanceof MakerTrade ? trade.getMaker().getDepositTxHash() : trade.getTaker().getDepositTxHash()).getIncomingAmount(); // TODO (woodser): use contract instead of trade to get deposit tx ids when contract has deposit tx ids // BigInteger sellerDepositAmount = multisigWallet.getTx(trade instanceof MakerTrade ? trade.getMaker().getDepositTxHash() : trade.getTaker().getDepositTxHash()).getIncomingAmount(); // TODO (woodser): use contract instead of trade to get deposit tx ids when contract has deposit tx ids
@ -303,9 +357,9 @@ public final class ArbitrationManager extends DisputeManager<ArbitrationDisputeL
// BigInteger tradeAmount = BigInteger.valueOf(contract.getTradeAmount().value).multiply(ParsingUtils.XMR_SATOSHI_MULTIPLIER); // BigInteger tradeAmount = BigInteger.valueOf(contract.getTradeAmount().value).multiply(ParsingUtils.XMR_SATOSHI_MULTIPLIER);
// parse arbitrator-signed payout tx // parse arbitrator-signed payout tx
MoneroTxSet signedTxSet = multisigWallet.describeTxSet(new MoneroTxSet().setMultisigTxHex(payoutTxHex)); MoneroTxSet disputeTxSet = multisigWallet.describeTxSet(new MoneroTxSet().setMultisigTxHex(unsignedPayoutTxHex));
if (signedTxSet.getTxs() == null || signedTxSet.getTxs().size() != 1) throw new RuntimeException("Bad arbitrator-signed payout tx"); // TODO (woodser): nack if (disputeTxSet.getTxs() == null || disputeTxSet.getTxs().size() != 1) throw new RuntimeException("Bad arbitrator-signed payout tx"); // TODO (woodser): nack
MoneroTxWallet arbitratorSignedPayoutTx = signedTxSet.getTxs().get(0); MoneroTxWallet arbitratorSignedPayoutTx = disputeTxSet.getTxs().get(0);
// verify payout tx has 1 or 2 destinations // verify payout tx has 1 or 2 destinations
int numDestinations = arbitratorSignedPayoutTx.getOutgoingTransfer() == null || arbitratorSignedPayoutTx.getOutgoingTransfer().getDestinations() == null ? 0 : arbitratorSignedPayoutTx.getOutgoingTransfer().getDestinations().size(); int numDestinations = arbitratorSignedPayoutTx.getOutgoingTransfer() == null || arbitratorSignedPayoutTx.getOutgoingTransfer().getDestinations() == null ? 0 : arbitratorSignedPayoutTx.getOutgoingTransfer().getDestinations().size();
@ -353,36 +407,55 @@ public final class ArbitrationManager extends DisputeManager<ArbitrationDisputeL
if (!expectedWinnerAmount.equals(actualWinnerAmount)) throw new RuntimeException("Unexpected winner payout: " + expectedWinnerAmount + " vs " + actualWinnerAmount); if (!expectedWinnerAmount.equals(actualWinnerAmount)) throw new RuntimeException("Unexpected winner payout: " + expectedWinnerAmount + " vs " + actualWinnerAmount);
if (!expectedLoserAmount.equals(actualLoserAmount)) throw new RuntimeException("Unexpected loser payout: " + expectedLoserAmount + " vs " + actualLoserAmount); if (!expectedLoserAmount.equals(actualLoserAmount)) throw new RuntimeException("Unexpected loser payout: " + expectedLoserAmount + " vs " + actualLoserAmount);
// sign arbitrator-signed payout tx // check wallet's daemon connection
MoneroMultisigSignResult result = multisigWallet.signMultisigTxHex(payoutTxHex); trade.checkWalletConnection();
if (result.getSignedMultisigTxHex() == null) throw new RuntimeException("Error signing arbitrator-signed payout tx");
String signedMultisigTxHex = result.getSignedMultisigTxHex();
signedTxSet.setMultisigTxHex(signedMultisigTxHex);
// verify mining fee is within tolerance by recreating payout tx // determine if we already signed dispute payout tx
// TODO (monero-project): creating tx will require exchanging updated multisig hex if message needs reprocessed. provide weight with describe_transfer so fee can be estimated? // TODO: better way, such as by saving signed dispute payout tx hex in designated field instead of shared payoutTxHex field?
MoneroTxWallet feeEstimateTx = null; Set<String> nonSignedDisputePayoutTxHexes = new HashSet<String>();
try { if (trade.getProcessModel().getPaymentSentMessage() != null) nonSignedDisputePayoutTxHexes.add(trade.getProcessModel().getPaymentSentMessage().getPayoutTxHex());
feeEstimateTx = createDisputePayoutTx(trade, dispute, disputeResult, true); if (trade.getProcessModel().getPaymentReceivedMessage() != null) {
} catch (Exception e) { nonSignedDisputePayoutTxHexes.add(trade.getProcessModel().getPaymentReceivedMessage().getUnsignedPayoutTxHex());
log.warn("Could not recreate dispute payout tx to verify fee: " + e.getMessage()); nonSignedDisputePayoutTxHexes.add(trade.getProcessModel().getPaymentReceivedMessage().getSignedPayoutTxHex());
} }
if (feeEstimateTx != null) { boolean signed = trade.getPayoutTxHex() != null && !nonSignedDisputePayoutTxHexes.contains(trade.getPayoutTxHex());
BigInteger feeEstimate = feeEstimateTx.getFee();
double feeDiff = arbitratorSignedPayoutTx.getFee().subtract(feeEstimate).abs().doubleValue() / feeEstimate.doubleValue(); // TODO: use BigDecimal? // sign arbitrator-signed payout tx
if (feeDiff > XmrWalletService.MINER_FEE_TOLERANCE) throw new RuntimeException("Miner fee is not within " + (XmrWalletService.MINER_FEE_TOLERANCE * 100) + "% of estimated fee, expected " + feeEstimate + " but was " + arbitratorSignedPayoutTx.getFee()); if (!signed) {
log.info("Payout tx fee {} is within tolerance, diff %={}", arbitratorSignedPayoutTx.getFee(), feeDiff); MoneroMultisigSignResult result = multisigWallet.signMultisigTxHex(unsignedPayoutTxHex);
if (result.getSignedMultisigTxHex() == null) throw new RuntimeException("Error signing arbitrator-signed payout tx");
String signedMultisigTxHex = result.getSignedMultisigTxHex();
disputeTxSet.setMultisigTxHex(signedMultisigTxHex);
trade.setPayoutTxHex(signedMultisigTxHex);
requestPersistence();
// verify mining fee is within tolerance by recreating payout tx
// TODO (monero-project): creating tx will require exchanging updated multisig hex if message needs reprocessed. provide weight with describe_transfer so fee can be estimated?
MoneroTxWallet feeEstimateTx = null;
try {
feeEstimateTx = createDisputePayoutTx(trade, dispute, disputeResult, true);
} catch (Exception e) {
log.warn("Could not recreate dispute payout tx to verify fee: " + e.getMessage());
}
if (feeEstimateTx != null) {
BigInteger feeEstimate = feeEstimateTx.getFee();
double feeDiff = arbitratorSignedPayoutTx.getFee().subtract(feeEstimate).abs().doubleValue() / feeEstimate.doubleValue(); // TODO: use BigDecimal?
if (feeDiff > XmrWalletService.MINER_FEE_TOLERANCE) throw new IllegalArgumentException("Miner fee is not within " + (XmrWalletService.MINER_FEE_TOLERANCE * 100) + "% of estimated fee, expected " + feeEstimate + " but was " + arbitratorSignedPayoutTx.getFee());
log.info("Payout tx fee {} is within tolerance, diff %={}", arbitratorSignedPayoutTx.getFee(), feeDiff);
}
} else {
disputeTxSet.setMultisigTxHex(trade.getPayoutTxHex());
} }
// submit fully signed payout tx to the network // submit fully signed payout tx to the network
List<String> txHashes = multisigWallet.submitMultisigTxHex(signedTxSet.getMultisigTxHex()); List<String> txHashes = multisigWallet.submitMultisigTxHex(disputeTxSet.getMultisigTxHex());
signedTxSet.getTxs().get(0).setHash(txHashes.get(0)); // manually update hash which is known after signed disputeTxSet.getTxs().get(0).setHash(txHashes.get(0)); // manually update hash which is known after signed
// update state // update state
trade.setPayoutTx(signedTxSet.getTxs().get(0)); // TODO (woodser): is trade.payoutTx() mutually exclusive from dispute payout tx? trade.setPayoutTx(disputeTxSet.getTxs().get(0)); // TODO (woodser): is trade.payoutTx() mutually exclusive from dispute payout tx?
trade.setPayoutTxId(signedTxSet.getTxs().get(0).getHash()); trade.setPayoutTxId(disputeTxSet.getTxs().get(0).getHash());
trade.setPayoutState(Trade.PayoutState.PAYOUT_PUBLISHED); trade.setPayoutState(Trade.PayoutState.PAYOUT_PUBLISHED);
dispute.setDisputePayoutTxId(signedTxSet.getTxs().get(0).getHash()); dispute.setDisputePayoutTxId(disputeTxSet.getTxs().get(0).getHash());
return signedTxSet; return disputeTxSet;
} }
} }

View file

@ -47,7 +47,6 @@ import lombok.extern.slf4j.Slf4j;
@Slf4j @Slf4j
@Singleton @Singleton
public class TraderChatManager extends SupportManager { public class TraderChatManager extends SupportManager {
private final TradeManager tradeManager;
private final PubKeyRingProvider pubKeyRingProvider; private final PubKeyRingProvider pubKeyRingProvider;
@ -61,8 +60,7 @@ public class TraderChatManager extends SupportManager {
CoreNotificationService notificationService, CoreNotificationService notificationService,
TradeManager tradeManager, TradeManager tradeManager,
PubKeyRingProvider pubKeyRingProvider) { PubKeyRingProvider pubKeyRingProvider) {
super(p2PService, connectionService, notificationService); super(p2PService, connectionService, notificationService, tradeManager);
this.tradeManager = tradeManager;
this.pubKeyRingProvider = pubKeyRingProvider; this.pubKeyRingProvider = pubKeyRingProvider;
} }

View file

@ -294,13 +294,13 @@ public class HavenoUtils {
// verify signature // verify signature
String errMessage = "The buyer signature is invalid for the " + message.getClass().getSimpleName() + " for " + trade.getClass().getSimpleName() + " " + trade.getId(); String errMessage = "The buyer signature is invalid for the " + message.getClass().getSimpleName() + " for " + trade.getClass().getSimpleName() + " " + trade.getId();
try { try {
if (!Sig.verify(trade.getBuyer().getPubKeyRing().getSignaturePubKey(), unsignedMessageAsJson.getBytes(Charsets.UTF_8), signature)) throw new RuntimeException(errMessage); if (!Sig.verify(trade.getBuyer().getPubKeyRing().getSignaturePubKey(), unsignedMessageAsJson.getBytes(Charsets.UTF_8), signature)) throw new IllegalArgumentException(errMessage);
} catch (Exception e) { } catch (Exception e) {
throw new RuntimeException(errMessage); throw new IllegalArgumentException(errMessage);
} }
// verify trade id // verify trade id
if (!trade.getId().equals(message.getTradeId())) throw new RuntimeException("The " + message.getClass().getSimpleName() + " has the wrong trade id, expected " + trade.getId() + " but was " + message.getTradeId()); if (!trade.getId().equals(message.getTradeId())) throw new IllegalArgumentException("The " + message.getClass().getSimpleName() + " has the wrong trade id, expected " + trade.getId() + " but was " + message.getTradeId());
} }
/** /**
@ -325,13 +325,13 @@ public class HavenoUtils {
// verify signature // verify signature
String errMessage = "The seller signature is invalid for the " + message.getClass().getSimpleName() + " for " + trade.getClass().getSimpleName() + " " + trade.getId(); String errMessage = "The seller signature is invalid for the " + message.getClass().getSimpleName() + " for " + trade.getClass().getSimpleName() + " " + trade.getId();
try { try {
if (!Sig.verify(trade.getSeller().getPubKeyRing().getSignaturePubKey(), unsignedMessageAsJson.getBytes(Charsets.UTF_8), signature)) throw new RuntimeException(errMessage); if (!Sig.verify(trade.getSeller().getPubKeyRing().getSignaturePubKey(), unsignedMessageAsJson.getBytes(Charsets.UTF_8), signature)) throw new IllegalArgumentException(errMessage);
} catch (Exception e) { } catch (Exception e) {
throw new RuntimeException(errMessage); throw new IllegalArgumentException(errMessage);
} }
// verify trade id // verify trade id
if (!trade.getId().equals(message.getTradeId())) throw new RuntimeException("The " + message.getClass().getSimpleName() + " has the wrong trade id, expected " + trade.getId() + " but was " + message.getTradeId()); if (!trade.getId().equals(message.getTradeId())) throw new IllegalArgumentException("The " + message.getClass().getSimpleName() + " has the wrong trade id, expected " + trade.getId() + " but was " + message.getTradeId());
// verify buyer signature of payment sent message // verify buyer signature of payment sent message
verifyPaymentSentMessage(trade, message.getPaymentSentMessage()); verifyPaymentSentMessage(trade, message.getPaymentSentMessage());

View file

@ -17,6 +17,7 @@
package bisq.core.trade; package bisq.core.trade;
import bisq.core.api.CoreMoneroConnectionsService;
import bisq.core.btc.model.XmrAddressEntry; import bisq.core.btc.model.XmrAddressEntry;
import bisq.core.btc.wallet.XmrWalletService; import bisq.core.btc.wallet.XmrWalletService;
import bisq.core.locale.CurrencyUtil; import bisq.core.locale.CurrencyUtil;
@ -240,7 +241,7 @@ public abstract class Trade implements Tradable, Model {
public enum DisputeState { public enum DisputeState {
NO_DISPUTE, NO_DISPUTE,
DISPUTE_REQUESTED, // TODO: not currently used; can use by subscribing to chat message ack in DisputeManager DISPUTE_REQUESTED,
DISPUTE_OPENED, DISPUTE_OPENED,
ARBITRATOR_SENT_DISPUTE_CLOSED_MSG, ARBITRATOR_SENT_DISPUTE_CLOSED_MSG,
ARBITRATOR_SEND_FAILED_DISPUTE_CLOSED_MSG, ARBITRATOR_SEND_FAILED_DISPUTE_CLOSED_MSG,
@ -281,6 +282,14 @@ public abstract class Trade implements Tradable, Model {
return this.ordinal() >= DisputeState.DISPUTE_REQUESTED.ordinal(); return this.ordinal() >= DisputeState.DISPUTE_REQUESTED.ordinal();
} }
public boolean isRequested() {
return ordinal() >= DisputeState.DISPUTE_REQUESTED.ordinal();
}
public boolean isOpen() {
return this == DisputeState.DISPUTE_OPENED;
}
public boolean isClosed() { public boolean isClosed() {
return this == DisputeState.DISPUTE_CLOSED; return this == DisputeState.DISPUTE_CLOSED;
} }
@ -404,6 +413,9 @@ public abstract class Trade implements Tradable, Model {
@Setter @Setter
private long lockTime; private long lockTime;
@Getter @Getter
@Setter
private long startTime; // added for haveno
@Getter
@Nullable @Nullable
private RefundResultState refundResultState = RefundResultState.UNDEFINED_REFUND_RESULT; private RefundResultState refundResultState = RefundResultState.UNDEFINED_REFUND_RESULT;
transient final private ObjectProperty<RefundResultState> refundResultStateProperty = new SimpleObjectProperty<>(refundResultState); transient final private ObjectProperty<RefundResultState> refundResultStateProperty = new SimpleObjectProperty<>(refundResultState);
@ -444,8 +456,8 @@ public abstract class Trade implements Tradable, Model {
@Getter @Getter
@Setter @Setter
private String payoutTxKey; private String payoutTxKey;
private Long startTime; // cache
private static final long MAX_REPROCESS_DELAY_SECONDS = 7200; // max delay to reprocess messages (once per 2 hours)
/////////////////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////////////////
// Constructors // Constructors
@ -588,7 +600,7 @@ public abstract class Trade implements Tradable, Model {
// handle trade state events // handle trade state events
tradePhaseSubscription = EasyBind.subscribe(phaseProperty, newValue -> { tradePhaseSubscription = EasyBind.subscribe(phaseProperty, newValue -> {
if (!isInitialized) return; if (!isInitialized) return;
if (isDepositPublished() && !isPayoutUnlocked()) updateWalletRefreshPeriod(); if (isDepositsPublished() && !isPayoutUnlocked()) updateWalletRefreshPeriod();
if (isCompleted()) { if (isCompleted()) {
UserThread.execute(() -> { UserThread.execute(() -> {
if (tradePhaseSubscription != null) { if (tradePhaseSubscription != null) {
@ -648,6 +660,10 @@ public abstract class Trade implements Tradable, Model {
} }
} }
public void requestPersistence() {
processModel.getTradeManager().requestPersistence();
}
public TradeProtocol getProtocol() { public TradeProtocol getProtocol() {
return processModel.getTradeManager().getTradeProtocol(this); return processModel.getTradeManager().getTradeProtocol(this);
} }
@ -664,6 +680,22 @@ public abstract class Trade implements Tradable, Model {
return getArbitrator() == null ? null : getArbitrator().getNodeAddress(); return getArbitrator() == null ? null : getArbitrator().getNodeAddress();
} }
public void checkWalletConnection() {
CoreMoneroConnectionsService connectionService = xmrWalletService.getConnectionsService();
connectionService.checkConnection();
connectionService.verifyConnection();
if (!getWallet().isConnectedToDaemon()) throw new RuntimeException("Wallet is not connected to a Monero node");
}
public boolean isWalletConnected() {
try {
checkWalletConnection();
return true;
} catch (Exception e) {
return false;
}
}
/** /**
* Create a contract based on the current state. * Create a contract based on the current state.
* *
@ -717,6 +749,9 @@ public abstract class Trade implements Tradable, Model {
BigInteger buyerPayoutAmount = buyerDepositAmount.add(tradeAmount); BigInteger buyerPayoutAmount = buyerDepositAmount.add(tradeAmount);
BigInteger sellerPayoutAmount = sellerDepositAmount.subtract(tradeAmount); BigInteger sellerPayoutAmount = sellerDepositAmount.subtract(tradeAmount);
// check connection to monero daemon
checkWalletConnection();
// create transaction to get fee estimate // create transaction to get fee estimate
MoneroTxWallet feeEstimateTx = multisigWallet.createTx(new MoneroTxConfig() MoneroTxWallet feeEstimateTx = multisigWallet.createTx(new MoneroTxConfig()
.setAccountIndex(0) .setAccountIndex(0)
@ -760,20 +795,19 @@ public abstract class Trade implements Tradable, Model {
log.info("Verifying payout tx"); log.info("Verifying payout tx");
// gather relevant info // gather relevant info
XmrWalletService walletService = processModel.getProvider().getXmrWalletService(); MoneroWallet wallet = getWallet();
MoneroWallet multisigWallet = walletService.getMultisigWallet(getId());
Contract contract = getContract(); Contract contract = getContract();
BigInteger sellerDepositAmount = multisigWallet.getTx(getSeller().getDepositTxHash()).getIncomingAmount(); // TODO (woodser): redundancy of processModel.getPreparedDepositTxId() vs this.getDepositTxId() necessary or avoidable? BigInteger sellerDepositAmount = wallet.getTx(getSeller().getDepositTxHash()).getIncomingAmount(); // TODO (woodser): redundancy of processModel.getPreparedDepositTxId() vs this.getDepositTxId() necessary or avoidable?
BigInteger buyerDepositAmount = multisigWallet.getTx(getBuyer().getDepositTxHash()).getIncomingAmount(); BigInteger buyerDepositAmount = wallet.getTx(getBuyer().getDepositTxHash()).getIncomingAmount();
BigInteger tradeAmount = HavenoUtils.coinToAtomicUnits(getAmount()); BigInteger tradeAmount = HavenoUtils.coinToAtomicUnits(getAmount());
// describe payout tx // describe payout tx
MoneroTxSet describedTxSet = multisigWallet.describeTxSet(new MoneroTxSet().setMultisigTxHex(payoutTxHex)); MoneroTxSet describedTxSet = wallet.describeTxSet(new MoneroTxSet().setMultisigTxHex(payoutTxHex));
if (describedTxSet.getTxs() == null || describedTxSet.getTxs().size() != 1) throw new RuntimeException("Bad payout tx"); // TODO (woodser): test nack if (describedTxSet.getTxs() == null || describedTxSet.getTxs().size() != 1) throw new IllegalArgumentException("Bad payout tx"); // TODO (woodser): test nack
MoneroTxWallet payoutTx = describedTxSet.getTxs().get(0); MoneroTxWallet payoutTx = describedTxSet.getTxs().get(0);
// verify payout tx has exactly 2 destinations // verify payout tx has exactly 2 destinations
if (payoutTx.getOutgoingTransfer() == null || payoutTx.getOutgoingTransfer().getDestinations() == null || payoutTx.getOutgoingTransfer().getDestinations().size() != 2) throw new RuntimeException("Payout tx does not have exactly two destinations"); if (payoutTx.getOutgoingTransfer() == null || payoutTx.getOutgoingTransfer().getDestinations() == null || payoutTx.getOutgoingTransfer().getDestinations().size() != 2) throw new IllegalArgumentException("Payout tx does not have exactly two destinations");
// get buyer and seller destinations (order not preserved) // get buyer and seller destinations (order not preserved)
boolean buyerFirst = payoutTx.getOutgoingTransfer().getDestinations().get(0).getAddress().equals(contract.getBuyerPayoutAddressString()); boolean buyerFirst = payoutTx.getOutgoingTransfer().getDestinations().get(0).getAddress().equals(contract.getBuyerPayoutAddressString());
@ -781,32 +815,35 @@ public abstract class Trade implements Tradable, Model {
MoneroDestination sellerPayoutDestination = payoutTx.getOutgoingTransfer().getDestinations().get(buyerFirst ? 1 : 0); MoneroDestination sellerPayoutDestination = payoutTx.getOutgoingTransfer().getDestinations().get(buyerFirst ? 1 : 0);
// verify payout addresses // verify payout addresses
if (!buyerPayoutDestination.getAddress().equals(contract.getBuyerPayoutAddressString())) throw new RuntimeException("Buyer payout address does not match contract"); if (!buyerPayoutDestination.getAddress().equals(contract.getBuyerPayoutAddressString())) throw new IllegalArgumentException("Buyer payout address does not match contract");
if (!sellerPayoutDestination.getAddress().equals(contract.getSellerPayoutAddressString())) throw new RuntimeException("Seller payout address does not match contract"); if (!sellerPayoutDestination.getAddress().equals(contract.getSellerPayoutAddressString())) throw new IllegalArgumentException("Seller payout address does not match contract");
// verify change address is multisig's primary address // verify change address is multisig's primary address
if (!payoutTx.getChangeAmount().equals(BigInteger.ZERO) && !payoutTx.getChangeAddress().equals(multisigWallet.getPrimaryAddress())) throw new RuntimeException("Change address is not multisig wallet's primary address"); if (!payoutTx.getChangeAmount().equals(BigInteger.ZERO) && !payoutTx.getChangeAddress().equals(wallet.getPrimaryAddress())) throw new IllegalArgumentException("Change address is not multisig wallet's primary address");
// verify sum of outputs = destination amounts + change amount // verify sum of outputs = destination amounts + change amount
if (!payoutTx.getOutputSum().equals(buyerPayoutDestination.getAmount().add(sellerPayoutDestination.getAmount()).add(payoutTx.getChangeAmount()))) throw new RuntimeException("Sum of outputs != destination amounts + change amount"); if (!payoutTx.getOutputSum().equals(buyerPayoutDestination.getAmount().add(sellerPayoutDestination.getAmount()).add(payoutTx.getChangeAmount()))) throw new IllegalArgumentException("Sum of outputs != destination amounts + change amount");
// verify buyer destination amount is deposit amount + this amount - 1/2 tx costs // verify buyer destination amount is deposit amount + this amount - 1/2 tx costs
BigInteger txCost = payoutTx.getFee().add(payoutTx.getChangeAmount()); BigInteger txCost = payoutTx.getFee().add(payoutTx.getChangeAmount());
BigInteger expectedBuyerPayout = buyerDepositAmount.add(tradeAmount).subtract(txCost.divide(BigInteger.valueOf(2))); BigInteger expectedBuyerPayout = buyerDepositAmount.add(tradeAmount).subtract(txCost.divide(BigInteger.valueOf(2)));
if (!buyerPayoutDestination.getAmount().equals(expectedBuyerPayout)) throw new RuntimeException("Buyer destination amount is not deposit amount + trade amount - 1/2 tx costs, " + buyerPayoutDestination.getAmount() + " vs " + expectedBuyerPayout); if (!buyerPayoutDestination.getAmount().equals(expectedBuyerPayout)) throw new IllegalArgumentException("Buyer destination amount is not deposit amount + trade amount - 1/2 tx costs, " + buyerPayoutDestination.getAmount() + " vs " + expectedBuyerPayout);
// verify seller destination amount is deposit amount - this amount - 1/2 tx costs // verify seller destination amount is deposit amount - this amount - 1/2 tx costs
BigInteger expectedSellerPayout = sellerDepositAmount.subtract(tradeAmount).subtract(txCost.divide(BigInteger.valueOf(2))); BigInteger expectedSellerPayout = sellerDepositAmount.subtract(tradeAmount).subtract(txCost.divide(BigInteger.valueOf(2)));
if (!sellerPayoutDestination.getAmount().equals(expectedSellerPayout)) throw new RuntimeException("Seller destination amount is not deposit amount - trade amount - 1/2 tx costs, " + sellerPayoutDestination.getAmount() + " vs " + expectedSellerPayout); if (!sellerPayoutDestination.getAmount().equals(expectedSellerPayout)) throw new IllegalArgumentException("Seller destination amount is not deposit amount - trade amount - 1/2 tx costs, " + sellerPayoutDestination.getAmount() + " vs " + expectedSellerPayout);
// check wallet's daemon connection
checkWalletConnection();
// handle tx signing // handle tx signing
if (sign) { if (sign) {
// sign tx // sign tx
MoneroMultisigSignResult result = multisigWallet.signMultisigTxHex(payoutTxHex); MoneroMultisigSignResult result = wallet.signMultisigTxHex(payoutTxHex);
if (result.getSignedMultisigTxHex() == null) throw new RuntimeException("Error signing payout tx"); if (result.getSignedMultisigTxHex() == null) throw new RuntimeException("Error signing payout tx");
payoutTxHex = result.getSignedMultisigTxHex(); payoutTxHex = result.getSignedMultisigTxHex();
describedTxSet = multisigWallet.describeMultisigTxSet(payoutTxHex); // update described set describedTxSet = wallet.describeMultisigTxSet(payoutTxHex); // update described set
payoutTx = describedTxSet.getTxs().get(0); payoutTx = describedTxSet.getTxs().get(0);
// verify fee is within tolerance by recreating payout tx // verify fee is within tolerance by recreating payout tx
@ -820,7 +857,7 @@ public abstract class Trade implements Tradable, Model {
if (feeEstimateTx != null) { if (feeEstimateTx != null) {
BigInteger feeEstimate = feeEstimateTx.getFee(); BigInteger feeEstimate = feeEstimateTx.getFee();
double feeDiff = payoutTx.getFee().subtract(feeEstimate).abs().doubleValue() / feeEstimate.doubleValue(); // TODO: use BigDecimal? double feeDiff = payoutTx.getFee().subtract(feeEstimate).abs().doubleValue() / feeEstimate.doubleValue(); // TODO: use BigDecimal?
if (feeDiff > XmrWalletService.MINER_FEE_TOLERANCE) throw new RuntimeException("Miner fee is not within " + (XmrWalletService.MINER_FEE_TOLERANCE * 100) + "% of estimated fee, expected " + feeEstimate + " but was " + payoutTx.getFee()); if (feeDiff > XmrWalletService.MINER_FEE_TOLERANCE) throw new IllegalArgumentException("Miner fee is not within " + (XmrWalletService.MINER_FEE_TOLERANCE * 100) + "% of estimated fee, expected " + feeEstimate + " but was " + payoutTx.getFee());
log.info("Payout tx fee {} is within tolerance, diff %={}", payoutTx.getFee(), feeDiff); log.info("Payout tx fee {} is within tolerance, diff %={}", payoutTx.getFee(), feeDiff);
} }
} }
@ -831,7 +868,8 @@ public abstract class Trade implements Tradable, Model {
// submit payout tx // submit payout tx
if (publish) { if (publish) {
multisigWallet.submitMultisigTxHex(payoutTxHex); //if (true) throw new RuntimeException("Let's pretend there's an error last second submitting tx to daemon, so we need to resubmit payout hex");
wallet.submitMultisigTxHex(payoutTxHex);
pollWallet(); pollWallet();
} }
} }
@ -926,14 +964,8 @@ public abstract class Trade implements Tradable, Model {
} }
public void syncWallet() { public void syncWallet() {
if (getWallet() == null) { if (getWallet() == null) throw new RuntimeException("Cannot sync multisig wallet because it doesn't exist for " + getClass().getSimpleName() + ", " + getId());
log.warn("Cannot sync multisig wallet because it doesn't exist for {}, {}", getClass().getSimpleName(), getId()); if (getWallet().getDaemonConnection() == null) throw new RuntimeException("Cannot sync multisig wallet because it's not connected to a Monero daemon for " + getClass().getSimpleName() + ", " + getId());
return;
}
if (getWallet().getDaemonConnection() == null) {
log.warn("Cannot sync multisig wallet because it's not connected to a Monero daemon for {}, {}", getClass().getSimpleName(), getId());
return;
}
log.info("Syncing wallet for {} {}", getClass().getSimpleName(), getId()); log.info("Syncing wallet for {} {}", getClass().getSimpleName(), getId());
getWallet().sync(); getWallet().sync();
pollWallet(); pollWallet();
@ -941,6 +973,14 @@ public abstract class Trade implements Tradable, Model {
updateWalletRefreshPeriod(); updateWalletRefreshPeriod();
} }
private void trySyncWallet() {
try {
syncWallet();
} catch (Exception e) {
log.warn("Error syncing wallet for {} {}: {}", getClass().getSimpleName(), getId(), e.getMessage());
}
}
public void syncWalletNormallyForMs(long syncNormalDuration) { public void syncWalletNormallyForMs(long syncNormalDuration) {
syncNormalStartTime = System.currentTimeMillis(); syncNormalStartTime = System.currentTimeMillis();
setWalletRefreshPeriod(xmrWalletService.getConnectionsService().getDefaultRefreshPeriodMs()); setWalletRefreshPeriod(xmrWalletService.getConnectionsService().getDefaultRefreshPeriodMs());
@ -957,7 +997,7 @@ public abstract class Trade implements Tradable, Model {
if (xmrWalletService.multisigWalletExists(getId())) { if (xmrWalletService.multisigWalletExists(getId())) {
// delete trade wallet unless funded // delete trade wallet unless funded
if (isDepositPublished() && !isPayoutUnlocked()) { if (isDepositsPublished() && !isPayoutUnlocked()) {
log.warn("Refusing to delete wallet for {} {} because it could be funded", getClass().getSimpleName(), getId()); log.warn("Refusing to delete wallet for {} {} because it could be funded", getClass().getSimpleName(), getId());
return; return;
} }
@ -1258,36 +1298,37 @@ public abstract class Trade implements Tradable, Model {
} }
private long getStartTime() { private long getStartTime() {
if (startTime != null) return startTime;
long now = System.currentTimeMillis(); long now = System.currentTimeMillis();
if (isDepositConfirmed() && getTakeOfferDate() != null) { if (isDepositsConfirmed() && getTakeOfferDate() != null) {
if (isDepositUnlocked()) { if (isDepositsUnlocked()) {
final long tradeTime = getTakeOfferDate().getTime(); if (startTime <= 0) setStartTimeFromUnlockedTxs(); // save to model
long maxHeight = Math.max(getMakerDepositTx().getHeight(), getTakerDepositTx().getHeight()); return startTime;
MoneroDaemon daemonRpc = xmrWalletService.getDaemon();
long blockTime = daemonRpc.getBlockByHeight(maxHeight).getTimestamp();
// if (depositTx.getConfidence().getDepthInBlocks() > 0) {
// final long tradeTime = getTakeOfferDate().getTime();
// // Use tx.getIncludedInBestChainAt() when available, otherwise use tx.getUpdateTime()
// long blockTime = depositTx.getIncludedInBestChainAt() != null ? depositTx.getIncludedInBestChainAt().getTime() : depositTx.getUpdateTime().getTime();
// If block date is in future (Date in Bitcoin blocks can be off by +/- 2 hours) we use our current date.
// If block date is earlier than our trade date we use our trade date.
if (blockTime > now)
startTime = now;
else
startTime = Math.max(blockTime, tradeTime);
log.debug("We set the start for the trade period to {}. Trade started at: {}. Block got mined at: {}",
new Date(startTime), new Date(tradeTime), new Date(blockTime));
} else { } else {
log.debug("depositTx not confirmed yet. We don't start counting remaining trade period yet. makerTxId={}, takerTxId={}", getMaker().getDepositTxHash(), getTaker().getDepositTxHash()); log.debug("depositTx not confirmed yet. We don't start counting remaining trade period yet. makerTxId={}, takerTxId={}", getMaker().getDepositTxHash(), getTaker().getDepositTxHash());
startTime = now; return now;
} }
} else { } else {
startTime = now; return now;
} }
return startTime; }
private void setStartTimeFromUnlockedTxs() {
long now = System.currentTimeMillis();
final long tradeTime = getTakeOfferDate().getTime();
MoneroDaemon daemonRpc = xmrWalletService.getDaemon();
if (daemonRpc == null) throw new RuntimeException("Cannot set start time for trade " + getId() + " because it has no connection to monerod");
long maxHeight = Math.max(getMakerDepositTx().getHeight(), getTakerDepositTx().getHeight());
long blockTime = daemonRpc.getBlockByHeight(maxHeight).getTimestamp();
// If block date is in future (Date in blocks can be off by +/- 2 hours) we use our current date.
// If block date is earlier than our trade date we use our trade date.
if (blockTime > now)
startTime = now;
else
startTime = Math.max(blockTime, tradeTime);
log.debug("We set the start for the trade period to {}. Trade started at: {}. Block got mined at: {}",
new Date(startTime), new Date(tradeTime), new Date(blockTime));
} }
public boolean hasFailed() { public boolean hasFailed() {
@ -1306,19 +1347,19 @@ public abstract class Trade implements Tradable, Model {
return getState() == Trade.State.PUBLISH_DEPOSIT_TX_REQUEST_FAILED; return getState() == Trade.State.PUBLISH_DEPOSIT_TX_REQUEST_FAILED;
} }
public boolean isDepositPublished() { public boolean isDepositsPublished() {
return getState().getPhase().ordinal() >= Phase.DEPOSITS_PUBLISHED.ordinal(); return getState().getPhase().ordinal() >= Phase.DEPOSITS_PUBLISHED.ordinal();
} }
public boolean isFundsLockedIn() { public boolean isFundsLockedIn() {
return isDepositPublished() && !isPayoutPublished(); return isDepositsPublished() && !isPayoutPublished();
} }
public boolean isDepositConfirmed() { public boolean isDepositsConfirmed() {
return getState().getPhase().ordinal() >= Phase.DEPOSITS_CONFIRMED.ordinal(); return getState().getPhase().ordinal() >= Phase.DEPOSITS_CONFIRMED.ordinal();
} }
public boolean isDepositUnlocked() { public boolean isDepositsUnlocked() {
return getState().getPhase().ordinal() >= Phase.DEPOSITS_UNLOCKED.ordinal(); return getState().getPhase().ordinal() >= Phase.DEPOSITS_UNLOCKED.ordinal();
} }
@ -1458,6 +1499,19 @@ public abstract class Trade implements Tradable, Model {
processModel.getTaker().getDepositTxHash() == null; processModel.getTaker().getDepositTxHash() == null;
} }
/**
* Get the duration to delay reprocessing a message based on its reprocess count.
*
* @return the duration to delay in seconds
*/
public long getReprocessDelayInSeconds(int reprocessCount) {
int retryCycles = 3; // reprocess on next refresh periods for first few attempts (app might auto switch to a good connection)
if (reprocessCount < retryCycles) return xmrWalletService.getConnectionsService().getDefaultRefreshPeriodMs() / 1000;
long delay = 60;
for (int i = retryCycles; i < reprocessCount; i++) delay *= 2;
return Math.min(MAX_REPROCESS_DELAY_SECONDS, delay);
}
/////////////////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////////////////
// Private // Private
@ -1479,18 +1533,27 @@ public abstract class Trade implements Tradable, Model {
} }
private void setDaemonConnection(MoneroRpcConnection connection) { private void setDaemonConnection(MoneroRpcConnection connection) {
if (getWallet() == null) return; MoneroWallet wallet = getWallet();
log.info("Setting daemon connection for trade wallet {}: {}: ", getId() , connection == null ? null : connection.getUri()); if (wallet == null) return;
if (getWallet() != null) getWallet().setDaemonConnection(connection); log.info("Setting daemon connection for trade wallet {}: {}", getId() , connection == null ? null : connection.getUri());
updateSyncing(); wallet.setDaemonConnection(connection);
// sync and reprocess messages on new thread
new Thread(() -> {
updateSyncing();
// reprocess pending payout messages
this.getProtocol().maybeReprocessPaymentReceivedMessage(false);
HavenoUtils.arbitrationManager.maybeReprocessDisputeClosedMessage(this, false);
}).start();
} }
private void updateSyncing() { private void updateSyncing() {
if (!isIdling()) syncWallet(); if (!isIdling()) trySyncWallet();
else { else {
long startSyncingInMs = ThreadLocalRandom.current().nextLong(0, getWalletRefreshPeriod()); // random time to start syncing long startSyncingInMs = ThreadLocalRandom.current().nextLong(0, getWalletRefreshPeriod()); // random time to start syncing
UserThread.runAfter(() -> { UserThread.runAfter(() -> {
if (isInitialized) syncWallet(); if (isInitialized) trySyncWallet();
}, startSyncingInMs / 1000l); }, startSyncingInMs / 1000l);
} }
} }
@ -1525,7 +1588,7 @@ public abstract class Trade implements Tradable, Model {
if (isPayoutUnlocked()) return; if (isPayoutUnlocked()) return;
// rescan spent if deposits unlocked // rescan spent if deposits unlocked
if (isDepositUnlocked()) getWallet().rescanSpent(); if (isDepositsUnlocked()) getWallet().rescanSpent();
// get txs with outputs // get txs with outputs
List<MoneroTxWallet> txs; List<MoneroTxWallet> txs;
@ -1538,7 +1601,7 @@ public abstract class Trade implements Tradable, Model {
} }
// check deposit txs // check deposit txs
if (!isDepositUnlocked()) { if (!isDepositsUnlocked()) {
if (txs.size() == 2) { if (txs.size() == 2) {
setStateDepositsPublished(); setStateDepositsPublished();
boolean makerFirst = txs.get(0).getHash().equals(processModel.getMaker().getDepositTxHash()); boolean makerFirst = txs.get(0).getHash().equals(processModel.getMaker().getDepositTxHash());
@ -1585,19 +1648,22 @@ public abstract class Trade implements Tradable, Model {
} }
private boolean isIdling() { private boolean isIdling() {
return this instanceof ArbitratorTrade && isDepositConfirmed(); // arbitrator idles trade after deposits confirm return this instanceof ArbitratorTrade && isDepositsConfirmed(); // arbitrator idles trade after deposits confirm
} }
private void setStateDepositsPublished() { private void setStateDepositsPublished() {
if (!isDepositPublished()) setState(State.DEPOSIT_TXS_SEEN_IN_NETWORK); if (!isDepositsPublished()) setState(State.DEPOSIT_TXS_SEEN_IN_NETWORK);
} }
private void setStateDepositsConfirmed() { private void setStateDepositsConfirmed() {
if (!isDepositConfirmed()) setState(State.DEPOSIT_TXS_CONFIRMED_IN_BLOCKCHAIN); if (!isDepositsConfirmed()) setState(State.DEPOSIT_TXS_CONFIRMED_IN_BLOCKCHAIN);
} }
private void setStateDepositsUnlocked() { private void setStateDepositsUnlocked() {
if (!isDepositUnlocked()) setState(State.DEPOSIT_TXS_UNLOCKED_IN_BLOCKCHAIN); if (!isDepositsUnlocked()) {
setState(State.DEPOSIT_TXS_UNLOCKED_IN_BLOCKCHAIN);
setStartTimeFromUnlockedTxs();
}
} }
private void setPayoutStatePublished() { private void setPayoutStatePublished() {
@ -1634,6 +1700,7 @@ public abstract class Trade implements Tradable, Model {
.map(msg -> msg.toProtoNetworkEnvelope().getChatMessage()) .map(msg -> msg.toProtoNetworkEnvelope().getChatMessage())
.collect(Collectors.toList())) .collect(Collectors.toList()))
.setLockTime(lockTime) .setLockTime(lockTime)
.setStartTime(startTime)
.setUid(uid); .setUid(uid);
Optional.ofNullable(payoutTxId).ifPresent(builder::setPayoutTxId); Optional.ofNullable(payoutTxId).ifPresent(builder::setPayoutTxId);
@ -1668,6 +1735,7 @@ public abstract class Trade implements Tradable, Model {
trade.setMediationResultState(MediationResultState.fromProto(proto.getMediationResultState())); trade.setMediationResultState(MediationResultState.fromProto(proto.getMediationResultState()));
trade.setRefundResultState(RefundResultState.fromProto(proto.getRefundResultState())); trade.setRefundResultState(RefundResultState.fromProto(proto.getRefundResultState()));
trade.setLockTime(proto.getLockTime()); trade.setLockTime(proto.getLockTime());
trade.setStartTime(proto.getStartTime());
trade.setCounterCurrencyExtraData(ProtoUtil.stringOrNullFromProto(proto.getCounterCurrencyExtraData())); trade.setCounterCurrencyExtraData(ProtoUtil.stringOrNullFromProto(proto.getCounterCurrencyExtraData()));
AssetTxProofResult persistedAssetTxProofResult = ProtoUtil.enumFromProto(AssetTxProofResult.class, proto.getAssetTxProofResult()); AssetTxProofResult persistedAssetTxProofResult = ProtoUtil.enumFromProto(AssetTxProofResult.class, proto.getAssetTxProofResult());
@ -1722,6 +1790,7 @@ public abstract class Trade implements Tradable, Model {
",\n mediationResultState=" + mediationResultState + ",\n mediationResultState=" + mediationResultState +
",\n mediationResultStateProperty=" + mediationResultStateProperty + ",\n mediationResultStateProperty=" + mediationResultStateProperty +
",\n lockTime=" + lockTime + ",\n lockTime=" + lockTime +
",\n startTime=" + startTime +
",\n refundResultState=" + refundResultState + ",\n refundResultState=" + refundResultState +
",\n refundResultStateProperty=" + refundResultStateProperty + ",\n refundResultStateProperty=" + refundResultStateProperty +
"\n}"; "\n}";

View file

@ -369,6 +369,7 @@ public class TradeManager implements PersistedDataHost, DecryptedDirectMessageLi
xmrWalletService.swapTradeEntryToAvailableEntry(addressEntry.getOfferId(), addressEntry.getContext()); xmrWalletService.swapTradeEntryToAvailableEntry(addressEntry.getOfferId(), addressEntry.getContext());
}); });
// notify that persisted trades initialized
persistedTradesInitialized.set(true); persistedTradesInitialized.set(true);
// We do not include failed trades as they should not be counted anyway in the trade statistics // We do not include failed trades as they should not be counted anyway in the trade statistics
@ -1100,7 +1101,7 @@ public class TradeManager implements PersistedDataHost, DecryptedDirectMessageLi
} }
private void scheduleDeletionIfUnfunded(Trade trade) { private void scheduleDeletionIfUnfunded(Trade trade) {
if (trade.isDepositRequested() && !trade.isDepositPublished()) { if (trade.isDepositRequested() && !trade.isDepositsPublished()) {
log.warn("Scheduling to delete trade if unfunded for {} {}", trade.getClass().getSimpleName(), trade.getId()); log.warn("Scheduling to delete trade if unfunded for {} {}", trade.getClass().getSimpleName(), trade.getId());
UserThread.runAfter(() -> { UserThread.runAfter(() -> {
if (isShutDown) return; if (isShutDown) return;

View file

@ -23,10 +23,8 @@ import bisq.network.p2p.NodeAddress;
import bisq.common.app.Version; import bisq.common.app.Version;
import bisq.common.proto.ProtoUtil; import bisq.common.proto.ProtoUtil;
import bisq.common.proto.network.NetworkEnvelope;
import java.util.Optional; import java.util.Optional;
import java.util.UUID;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import lombok.Getter; import lombok.Getter;
@ -124,7 +122,7 @@ public final class PaymentReceivedMessage extends TradeMailboxMessage {
return getNetworkEnvelopeBuilder().setPaymentReceivedMessage(builder).build(); return getNetworkEnvelopeBuilder().setPaymentReceivedMessage(builder).build();
} }
public static NetworkEnvelope fromProto(protobuf.PaymentReceivedMessage proto, String messageVersion) { public static PaymentReceivedMessage fromProto(protobuf.PaymentReceivedMessage proto, String messageVersion) {
// There is no method to check for a nullable non-primitive data type object but we know that all fields // There is no method to check for a nullable non-primitive data type object but we know that all fields
// are empty/null, so we check for the signature to see if we got a valid buyerSignedWitness. // are empty/null, so we check for the signature to see if we got a valid buyerSignedWitness.
protobuf.AccountAgeWitness protoAccountAgeWitness = proto.getBuyerAccountAgeWitness(); protobuf.AccountAgeWitness protoAccountAgeWitness = proto.getBuyerAccountAgeWitness();

View file

@ -20,14 +20,12 @@ package bisq.core.trade.messages;
import bisq.core.proto.CoreProtoResolver; import bisq.core.proto.CoreProtoResolver;
import bisq.network.p2p.DirectMessage; import bisq.network.p2p.DirectMessage;
import bisq.network.p2p.NodeAddress;
import java.util.Optional; import java.util.Optional;
import javax.annotation.Nullable; import javax.annotation.Nullable;
import com.google.protobuf.ByteString; import com.google.protobuf.ByteString;
import bisq.common.crypto.PubKeyRing;
import bisq.common.proto.ProtoUtil; import bisq.common.proto.ProtoUtil;
import bisq.common.util.Utilities; import bisq.common.util.Utilities;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;

View file

@ -55,7 +55,7 @@ public class BuyerProtocol extends DisputeProtocol {
// re-send payment sent message if not arrived // re-send payment sent message if not arrived
synchronized (trade) { synchronized (trade) {
if (trade.getState().ordinal() >= Trade.State.BUYER_CONFIRMED_IN_UI_PAYMENT_SENT.ordinal() && trade.getState().ordinal() <= Trade.State.BUYER_SEND_FAILED_PAYMENT_SENT_MSG.ordinal()) { if (trade.getState().ordinal() >= Trade.State.BUYER_SENT_PAYMENT_SENT_MSG.ordinal() && trade.getState().ordinal() <= Trade.State.BUYER_SEND_FAILED_PAYMENT_SENT_MSG.ordinal()) {
latchTrade(); latchTrade();
given(anyPhase(Trade.Phase.PAYMENT_SENT) given(anyPhase(Trade.Phase.PAYMENT_SENT)
.with(BuyerEvent.STARTUP)) .with(BuyerEvent.STARTUP))

View file

@ -31,10 +31,13 @@ import bisq.core.payment.payload.PaymentAccountPayload;
import bisq.core.proto.CoreProtoResolver; import bisq.core.proto.CoreProtoResolver;
import bisq.core.support.dispute.arbitration.arbitrator.ArbitratorManager; import bisq.core.support.dispute.arbitration.arbitrator.ArbitratorManager;
import bisq.core.support.dispute.mediation.mediator.MediatorManager; import bisq.core.support.dispute.mediation.mediator.MediatorManager;
import bisq.core.support.dispute.messages.DisputeClosedMessage;
import bisq.core.support.dispute.refund.refundagent.RefundAgentManager; import bisq.core.support.dispute.refund.refundagent.RefundAgentManager;
import bisq.core.trade.MakerTrade; import bisq.core.trade.MakerTrade;
import bisq.core.trade.Trade; import bisq.core.trade.Trade;
import bisq.core.trade.TradeManager; import bisq.core.trade.TradeManager;
import bisq.core.trade.messages.PaymentReceivedMessage;
import bisq.core.trade.messages.PaymentSentMessage;
import bisq.core.trade.messages.TradeMessage; import bisq.core.trade.messages.TradeMessage;
import bisq.core.trade.statistics.ReferralIdService; import bisq.core.trade.statistics.ReferralIdService;
import bisq.core.trade.statistics.TradeStatisticsManager; import bisq.core.trade.statistics.TradeStatisticsManager;
@ -43,7 +46,7 @@ import bisq.core.user.User;
import bisq.network.p2p.AckMessage; import bisq.network.p2p.AckMessage;
import bisq.network.p2p.NodeAddress; import bisq.network.p2p.NodeAddress;
import bisq.network.p2p.P2PService; import bisq.network.p2p.P2PService;
import bisq.common.app.Version;
import bisq.common.crypto.KeyRing; import bisq.common.crypto.KeyRing;
import bisq.common.crypto.PubKeyRing; import bisq.common.crypto.PubKeyRing;
import bisq.common.proto.ProtoUtil; import bisq.common.proto.ProtoUtil;
@ -175,6 +178,18 @@ public class ProcessModel implements Model, PersistablePayload {
@Getter @Getter
@Setter @Setter
private boolean isDepositsConfirmedMessagesDelivered; private boolean isDepositsConfirmedMessagesDelivered;
@Nullable
@Setter
@Getter
private PaymentSentMessage paymentSentMessage;
@Nullable
@Setter
@Getter
private PaymentReceivedMessage paymentReceivedMessage;
@Nullable
@Setter
@Getter
private DisputeClosedMessage disputeClosedMessage;
// We want to indicate the user the state of the message delivery of the // We want to indicate the user the state of the message delivery of the
// PaymentSentMessage. As well we do an automatic re-send in case it was not ACKed yet. // PaymentSentMessage. As well we do an automatic re-send in case it was not ACKed yet.
@ -233,6 +248,9 @@ public class ProcessModel implements Model, PersistablePayload {
Optional.ofNullable(tempTradingPeerNodeAddress).ifPresent(e -> builder.setTempTradingPeerNodeAddress(tempTradingPeerNodeAddress.toProtoMessage())); Optional.ofNullable(tempTradingPeerNodeAddress).ifPresent(e -> builder.setTempTradingPeerNodeAddress(tempTradingPeerNodeAddress.toProtoMessage()));
Optional.ofNullable(makerSignature).ifPresent(e -> builder.setMakerSignature(makerSignature)); Optional.ofNullable(makerSignature).ifPresent(e -> builder.setMakerSignature(makerSignature));
Optional.ofNullable(multisigAddress).ifPresent(e -> builder.setMultisigAddress(multisigAddress)); Optional.ofNullable(multisigAddress).ifPresent(e -> builder.setMultisigAddress(multisigAddress));
Optional.ofNullable(paymentSentMessage).ifPresent(e -> builder.setPaymentSentMessage(paymentSentMessage.toProtoNetworkEnvelope().getPaymentSentMessage()));
Optional.ofNullable(paymentReceivedMessage).ifPresent(e -> builder.setPaymentReceivedMessage(paymentReceivedMessage.toProtoNetworkEnvelope().getPaymentReceivedMessage()));
Optional.ofNullable(disputeClosedMessage).ifPresent(e -> builder.setDisputeClosedMessage(disputeClosedMessage.toProtoNetworkEnvelope().getDisputeClosedMessage()));
return builder.build(); return builder.build();
} }
@ -267,6 +285,9 @@ public class ProcessModel implements Model, PersistablePayload {
MessageState paymentStartedMessageState = ProtoUtil.enumFromProto(MessageState.class, paymentStartedMessageStateString); MessageState paymentStartedMessageState = ProtoUtil.enumFromProto(MessageState.class, paymentStartedMessageStateString);
processModel.setPaymentStartedMessageState(paymentStartedMessageState); processModel.setPaymentStartedMessageState(paymentStartedMessageState);
processModel.setPaymentSentMessage(proto.hasPaymentSentMessage() ? PaymentSentMessage.fromProto(proto.getPaymentSentMessage(), Version.getP2PMessageVersion()) : null);
processModel.setPaymentReceivedMessage(proto.hasPaymentReceivedMessage() ? PaymentReceivedMessage.fromProto(proto.getPaymentReceivedMessage(), Version.getP2PMessageVersion()) : null);
processModel.setDisputeClosedMessage(proto.hasDisputeClosedMessage() ? DisputeClosedMessage.fromProto(proto.getDisputeClosedMessage(), Version.getP2PMessageVersion()) : null);
return processModel; return processModel;
} }

View file

@ -51,7 +51,7 @@ public class SellerProtocol extends DisputeProtocol {
// re-send payment received message if not arrived // re-send payment received message if not arrived
synchronized (trade) { synchronized (trade) {
if (trade.getState().ordinal() >= Trade.State.SELLER_CONFIRMED_IN_UI_PAYMENT_RECEIPT.ordinal() && trade.getState().ordinal() <= Trade.State.SELLER_SEND_FAILED_PAYMENT_RECEIVED_MSG.ordinal()) { if (trade.getState().ordinal() >= Trade.State.SELLER_SENT_PAYMENT_RECEIVED_MSG.ordinal() && trade.getState().ordinal() <= Trade.State.SELLER_SEND_FAILED_PAYMENT_RECEIVED_MSG.ordinal()) {
latchTrade(); latchTrade();
given(anyPhase(Trade.Phase.PAYMENT_RECEIVED) given(anyPhase(Trade.Phase.PAYMENT_RECEIVED)
.with(SellerEvent.STARTUP)) .with(SellerEvent.STARTUP))

View file

@ -74,7 +74,6 @@ import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.concurrent.CountDownLatch; import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.fxmisc.easybind.EasyBind; import org.fxmisc.easybind.EasyBind;
@ -94,6 +93,7 @@ public abstract class TradeProtocol implements DecryptedDirectMessageListener, D
protected TradeResultHandler tradeResultHandler; protected TradeResultHandler tradeResultHandler;
protected ErrorMessageHandler errorMessageHandler; protected ErrorMessageHandler errorMessageHandler;
private int reprocessPaymentReceivedMessageCount;
/////////////////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////////////////
// Constructor // Constructor
@ -267,6 +267,23 @@ public abstract class TradeProtocol implements DecryptedDirectMessageListener, D
} }
}); });
} }
// reprocess payout messages if pending
maybeReprocessPaymentReceivedMessage(true);
HavenoUtils.arbitrationManager.maybeReprocessDisputeClosedMessage(trade, true);
}
public void maybeReprocessPaymentReceivedMessage(boolean reprocessOnError) {
synchronized (trade) {
// skip if no need to reprocess
if (trade.isSeller() || trade.getProcessModel().getPaymentReceivedMessage() == null || trade.getState().ordinal() >= Trade.State.SELLER_SENT_PAYMENT_RECEIVED_MSG.ordinal()) {
return;
}
log.warn("Reprocessing payment received message for {} {}", trade.getClass().getSimpleName(), trade.getId());
new Thread(() -> handle(trade.getProcessModel().getPaymentReceivedMessage(), trade.getProcessModel().getPaymentReceivedMessage().getSenderNodeAddress(), reprocessOnError)).start();
}
} }
public void handleInitMultisigRequest(InitMultisigRequest request, NodeAddress sender) { public void handleInitMultisigRequest(InitMultisigRequest request, NodeAddress sender) {
@ -462,17 +479,23 @@ public abstract class TradeProtocol implements DecryptedDirectMessageListener, D
// received by buyer and arbitrator // received by buyer and arbitrator
protected void handle(PaymentReceivedMessage message, NodeAddress peer) { protected void handle(PaymentReceivedMessage message, NodeAddress peer) {
handle(message, peer, true);
}
private void handle(PaymentReceivedMessage message, NodeAddress peer, boolean reprocessOnError) {
System.out.println(getClass().getSimpleName() + ".handle(PaymentReceivedMessage)"); System.out.println(getClass().getSimpleName() + ".handle(PaymentReceivedMessage)");
if (!(trade instanceof BuyerTrade || trade instanceof ArbitratorTrade)) { if (!(trade instanceof BuyerTrade || trade instanceof ArbitratorTrade)) {
log.warn("Ignoring PaymentReceivedMessage since not buyer or arbitrator"); log.warn("Ignoring PaymentReceivedMessage since not buyer or arbitrator");
return; return;
} }
if (trade instanceof ArbitratorTrade && !trade.isPayoutUnlocked()) trade.syncWallet(); // arbitrator syncs slowly after deposits confirmed
synchronized (trade) { synchronized (trade) {
latchTrade(); latchTrade();
Validator.checkTradeId(processModel.getOfferId(), message); Validator.checkTradeId(processModel.getOfferId(), message);
processModel.setTradeMessage(message); processModel.setTradeMessage(message);
expect(anyPhase(trade.isBuyer() ? new Trade.Phase[] {Trade.Phase.PAYMENT_SENT, Trade.Phase.PAYMENT_RECEIVED} : new Trade.Phase[] {Trade.Phase.DEPOSITS_UNLOCKED, Trade.Phase.PAYMENT_SENT}) expect(anyPhase(
trade.isBuyer() ? new Trade.Phase[] {Trade.Phase.PAYMENT_SENT, Trade.Phase.PAYMENT_RECEIVED} :
trade.isArbitrator() ? new Trade.Phase[] {Trade.Phase.DEPOSITS_CONFIRMED, Trade.Phase.DEPOSITS_UNLOCKED, Trade.Phase.PAYMENT_SENT} : // arbitrator syncs slowly after deposits confirmed
new Trade.Phase[] {Trade.Phase.DEPOSITS_UNLOCKED, Trade.Phase.PAYMENT_SENT})
.with(message) .with(message)
.from(peer)) .from(peer))
.setup(tasks( .setup(tasks(
@ -482,7 +505,19 @@ public abstract class TradeProtocol implements DecryptedDirectMessageListener, D
handleTaskRunnerSuccess(peer, message); handleTaskRunnerSuccess(peer, message);
}, },
errorMessage -> { errorMessage -> {
handleTaskRunnerFault(peer, message, errorMessage); log.warn("Error processing payment received message: " + errorMessage);
processModel.getTradeManager().requestPersistence();
// reprocess message depending on error
if (trade.getProcessModel().getPaymentReceivedMessage() != null) {
UserThread.runAfter(() -> {
reprocessPaymentReceivedMessageCount++;
maybeReprocessPaymentReceivedMessage(reprocessOnError);
}, trade.getReprocessDelayInSeconds(reprocessPaymentReceivedMessageCount));
} else {
handleTaskRunnerFault(peer, message, errorMessage); // otherwise send nack
}
unlatchTrade();
}))) })))
.executeTasks(true); .executeTasks(true);
awaitTradeLatch(); awaitTradeLatch();
@ -548,8 +583,7 @@ public abstract class TradeProtocol implements DecryptedDirectMessageListener, D
private void onAckMessage(AckMessage ackMessage, NodeAddress peer) { private void onAckMessage(AckMessage ackMessage, NodeAddress peer) {
// We handle the ack for PaymentSentMessage and DepositTxAndDelayedPayoutTxMessage // We handle the ack for PaymentSentMessage and DepositTxAndDelayedPayoutTxMessage
// as we support automatic re-send of the msg in case it was not ACKed after a certain time // as we support automatic re-send of the msg in case it was not ACKed after a certain time
// TODO (woodser): add AckMessage for InitTradeRequest and support automatic re-send ? if (ackMessage.getSourceMsgClassName().equals(PaymentSentMessage.class.getSimpleName()) && trade.getTradingPeer(peer) == trade.getSeller()) {
if (ackMessage.getSourceMsgClassName().equals(PaymentSentMessage.class.getSimpleName())) {
processModel.setPaymentStartedAckMessage(ackMessage); processModel.setPaymentStartedAckMessage(ackMessage);
} }

View file

@ -21,9 +21,7 @@ import bisq.core.account.witness.AccountAgeWitness;
import bisq.core.btc.model.RawTransactionInput; import bisq.core.btc.model.RawTransactionInput;
import bisq.core.payment.payload.PaymentAccountPayload; import bisq.core.payment.payload.PaymentAccountPayload;
import bisq.core.proto.CoreProtoResolver; import bisq.core.proto.CoreProtoResolver;
import bisq.core.trade.messages.PaymentSentMessage;
import bisq.network.p2p.NodeAddress; import bisq.network.p2p.NodeAddress;
import bisq.common.app.Version;
import bisq.common.crypto.PubKeyRing; import bisq.common.crypto.PubKeyRing;
import bisq.common.proto.ProtoUtil; import bisq.common.proto.ProtoUtil;
import bisq.common.proto.persistable.PersistablePayload; import bisq.common.proto.persistable.PersistablePayload;
@ -131,8 +129,6 @@ public final class TradingPeer implements PersistablePayload {
private String depositTxKey; private String depositTxKey;
@Nullable @Nullable
private String updatedMultisigHex; private String updatedMultisigHex;
@Nullable
private PaymentSentMessage paymentSentMessage;
public TradingPeer() { public TradingPeer() {
} }
@ -173,7 +169,6 @@ public final class TradingPeer implements PersistablePayload {
Optional.ofNullable(depositTxHex).ifPresent(e -> builder.setDepositTxHex(depositTxHex)); Optional.ofNullable(depositTxHex).ifPresent(e -> builder.setDepositTxHex(depositTxHex));
Optional.ofNullable(depositTxKey).ifPresent(e -> builder.setDepositTxKey(depositTxKey)); Optional.ofNullable(depositTxKey).ifPresent(e -> builder.setDepositTxKey(depositTxKey));
Optional.ofNullable(updatedMultisigHex).ifPresent(e -> builder.setUpdatedMultisigHex(updatedMultisigHex)); Optional.ofNullable(updatedMultisigHex).ifPresent(e -> builder.setUpdatedMultisigHex(updatedMultisigHex));
Optional.ofNullable(paymentSentMessage).ifPresent(e -> builder.setPaymentSentMessage(paymentSentMessage.toProtoNetworkEnvelope().getPaymentSentMessage()));
builder.setCurrentDate(currentDate); builder.setCurrentDate(currentDate);
return builder.build(); return builder.build();
@ -224,7 +219,6 @@ public final class TradingPeer implements PersistablePayload {
tradingPeer.setDepositTxHex(ProtoUtil.stringOrNullFromProto(proto.getDepositTxHex())); tradingPeer.setDepositTxHex(ProtoUtil.stringOrNullFromProto(proto.getDepositTxHex()));
tradingPeer.setDepositTxKey(ProtoUtil.stringOrNullFromProto(proto.getDepositTxKey())); tradingPeer.setDepositTxKey(ProtoUtil.stringOrNullFromProto(proto.getDepositTxKey()));
tradingPeer.setUpdatedMultisigHex(ProtoUtil.stringOrNullFromProto(proto.getUpdatedMultisigHex())); tradingPeer.setUpdatedMultisigHex(ProtoUtil.stringOrNullFromProto(proto.getUpdatedMultisigHex()));
tradingPeer.setPaymentSentMessage(proto.hasPaymentSentMessage() ? PaymentSentMessage.fromProto(proto.getPaymentSentMessage(), Version.getP2PMessageVersion()) : null);
return tradingPeer; return tradingPeer;
} }
} }

View file

@ -52,6 +52,13 @@ public class BuyerPreparePaymentSentMessage extends TradeTask {
try { try {
runInterceptHook(); runInterceptHook();
// skip if already created
if (processModel.getPaymentSentMessage() != null) {
log.warn("Skipping preparation of payment sent message since it's already created for {} {}", trade.getClass().getSimpleName(), trade.getId());
complete();
return;
}
// validate state // validate state
Preconditions.checkNotNull(trade.getSeller().getPaymentAccountPayload(), "Seller's payment account payload is null"); Preconditions.checkNotNull(trade.getSeller().getPaymentAccountPayload(), "Seller's payment account payload is null");
Preconditions.checkNotNull(trade.getAmount(), "trade.getTradeAmount() must not be null"); Preconditions.checkNotNull(trade.getAmount(), "trade.getTradeAmount() must not be null");
@ -67,7 +74,7 @@ public class BuyerPreparePaymentSentMessage extends TradeTask {
List<String> updatedMultisigHexes = new ArrayList<String>(); List<String> updatedMultisigHexes = new ArrayList<String>();
if (trade.getSeller().getUpdatedMultisigHex() != null) updatedMultisigHexes.add(trade.getSeller().getUpdatedMultisigHex()); if (trade.getSeller().getUpdatedMultisigHex() != null) updatedMultisigHexes.add(trade.getSeller().getUpdatedMultisigHex());
if (trade.getArbitrator().getUpdatedMultisigHex() != null) updatedMultisigHexes.add(trade.getArbitrator().getUpdatedMultisigHex()); if (trade.getArbitrator().getUpdatedMultisigHex() != null) updatedMultisigHexes.add(trade.getArbitrator().getUpdatedMultisigHex());
if (!updatedMultisigHexes.isEmpty()) { if (!updatedMultisigHexes.isEmpty()) {
multisigWallet.importMultisigHex(updatedMultisigHexes.toArray(new String[0])); // TODO (monero-project): fails if multisig hex imported individually multisigWallet.importMultisigHex(updatedMultisigHexes.toArray(new String[0])); // TODO (monero-project): fails if multisig hex imported individually
trade.saveWallet(); trade.saveWallet();
} }

View file

@ -73,7 +73,7 @@ public abstract class BuyerSendPaymentSentMessage extends SendMailboxMessageTask
@Override @Override
protected TradeMailboxMessage getTradeMailboxMessage(String tradeId) { protected TradeMailboxMessage getTradeMailboxMessage(String tradeId) {
if (trade.getSelf().getPaymentSentMessage() == null) { if (processModel.getPaymentSentMessage() == null) {
// We do not use a real unique ID here as we want to be able to re-send the exact same message in case the // We do not use a real unique ID here as we want to be able to re-send the exact same message in case the
// peer does not respond with an ACK msg in a certain time interval. To avoid that we get dangling mailbox // peer does not respond with an ACK msg in a certain time interval. To avoid that we get dangling mailbox
@ -99,12 +99,13 @@ public abstract class BuyerSendPaymentSentMessage extends SendMailboxMessageTask
String messageAsJson = JsonUtil.objectToJson(message); String messageAsJson = JsonUtil.objectToJson(message);
byte[] sig = Sig.sign(processModel.getP2PService().getKeyRing().getSignatureKeyPair().getPrivate(), messageAsJson.getBytes(Charsets.UTF_8)); byte[] sig = Sig.sign(processModel.getP2PService().getKeyRing().getSignatureKeyPair().getPrivate(), messageAsJson.getBytes(Charsets.UTF_8));
message.setBuyerSignature(sig); message.setBuyerSignature(sig);
trade.getSelf().setPaymentSentMessage(message); processModel.setPaymentSentMessage(message);
trade.requestPersistence();
} catch (Exception e) { } catch (Exception e) {
throw new RuntimeException (e); throw new RuntimeException (e);
} }
} }
return trade.getSelf().getPaymentSentMessage(); return processModel.getPaymentSentMessage();
} }
@Override @Override

View file

@ -36,6 +36,8 @@ import static com.google.common.base.Preconditions.checkNotNull;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import org.apache.commons.lang3.StringUtils;
@Slf4j @Slf4j
public class ProcessPaymentReceivedMessage extends TradeTask { public class ProcessPaymentReceivedMessage extends TradeTask {
public ProcessPaymentReceivedMessage(TaskRunner<Trade> taskHandler, Trade trade) { public ProcessPaymentReceivedMessage(TaskRunner<Trade> taskHandler, Trade trade) {
@ -46,6 +48,7 @@ public class ProcessPaymentReceivedMessage extends TradeTask {
protected void run() { protected void run() {
try { try {
runInterceptHook(); runInterceptHook();
log.debug("current trade state " + trade.getState()); log.debug("current trade state " + trade.getState());
PaymentReceivedMessage message = (PaymentReceivedMessage) processModel.getTradeMessage(); PaymentReceivedMessage message = (PaymentReceivedMessage) processModel.getTradeMessage();
checkNotNull(message); checkNotNull(message);
@ -54,6 +57,12 @@ public class ProcessPaymentReceivedMessage extends TradeTask {
// verify signature of payment received message // verify signature of payment received message
HavenoUtils.verifyPaymentReceivedMessage(trade, message); HavenoUtils.verifyPaymentReceivedMessage(trade, message);
// save message for reprocessing
processModel.setPaymentReceivedMessage(message);
trade.requestPersistence();
// set state
trade.getSeller().setUpdatedMultisigHex(message.getUpdatedMultisigHex()); trade.getSeller().setUpdatedMultisigHex(message.getUpdatedMultisigHex());
trade.getBuyer().setUpdatedMultisigHex(message.getPaymentSentMessage().getUpdatedMultisigHex()); trade.getBuyer().setUpdatedMultisigHex(message.getPaymentSentMessage().getUpdatedMultisigHex());
trade.getBuyer().setAccountAgeWitness(message.getBuyerAccountAgeWitness()); trade.getBuyer().setAccountAgeWitness(message.getBuyerAccountAgeWitness());
@ -63,13 +72,16 @@ public class ProcessPaymentReceivedMessage extends TradeTask {
if (trade.getSeller().getNodeAddress().equals(trade.getBuyer().getNodeAddress())) trade.getBuyer().setNodeAddress(null); // tests can reuse addresses if (trade.getSeller().getNodeAddress().equals(trade.getBuyer().getNodeAddress())) trade.getBuyer().setNodeAddress(null); // tests can reuse addresses
// close open disputes // close open disputes
if (trade.getDisputeState().ordinal() >= Trade.DisputeState.DISPUTE_OPENED.ordinal()) { if (trade.getDisputeState().ordinal() >= Trade.DisputeState.DISPUTE_REQUESTED.ordinal()) {
trade.setDisputeStateIfProgress(Trade.DisputeState.DISPUTE_CLOSED); trade.setDisputeStateIfProgress(Trade.DisputeState.DISPUTE_CLOSED);
for (Dispute dispute : trade.getDisputes()) { for (Dispute dispute : trade.getDisputes()) {
dispute.setIsClosed(); dispute.setIsClosed();
} }
} }
// ensure connected to monero network
trade.checkWalletConnection();
// process payout tx unless already unlocked // process payout tx unless already unlocked
if (!trade.isPayoutUnlocked()) processPayoutTx(message); if (!trade.isPayoutUnlocked()) processPayoutTx(message);
@ -83,25 +95,32 @@ public class ProcessPaymentReceivedMessage extends TradeTask {
// complete // complete
trade.setStateIfProgress(Trade.State.SELLER_SENT_PAYMENT_RECEIVED_MSG); // arbitrator auto completes when payout published trade.setStateIfProgress(Trade.State.SELLER_SENT_PAYMENT_RECEIVED_MSG); // arbitrator auto completes when payout published
processModel.getTradeManager().requestPersistence(); trade.requestPersistence();
complete(); complete();
} catch (Throwable t) { } catch (Throwable t) {
// do not reprocess illegal argument
if (t instanceof IllegalArgumentException) {
processModel.setPaymentReceivedMessage(null); // do not reprocess
trade.requestPersistence();
}
failed(t); failed(t);
} }
} }
private void processPayoutTx(PaymentReceivedMessage message) { private void processPayoutTx(PaymentReceivedMessage message) {
// sync and save wallet
trade.syncWallet();
trade.saveWallet();
// import multisig hex // import multisig hex
List<String> updatedMultisigHexes = new ArrayList<String>(); List<String> updatedMultisigHexes = new ArrayList<String>();
if (trade.getSeller().getUpdatedMultisigHex() != null) updatedMultisigHexes.add(trade.getSeller().getUpdatedMultisigHex()); if (trade.getSeller().getUpdatedMultisigHex() != null) updatedMultisigHexes.add(trade.getSeller().getUpdatedMultisigHex());
if (trade.getArbitrator().getUpdatedMultisigHex() != null) updatedMultisigHexes.add(trade.getArbitrator().getUpdatedMultisigHex()); if (trade.getArbitrator().getUpdatedMultisigHex() != null) updatedMultisigHexes.add(trade.getArbitrator().getUpdatedMultisigHex());
if (!updatedMultisigHexes.isEmpty()) trade.getWallet().importMultisigHex(updatedMultisigHexes.toArray(new String[0])); // TODO (monero-project): fails if multisig hex imported individually if (!updatedMultisigHexes.isEmpty()) trade.getWallet().importMultisigHex(updatedMultisigHexes.toArray(new String[0])); // TODO (monero-project): fails if multisig hex imported individually
// sync and save wallet
trade.syncWallet();
trade.saveWallet();
// handle if payout tx not published // handle if payout tx not published
if (!trade.isPayoutPublished()) { if (!trade.isPayoutPublished()) {
@ -110,18 +129,23 @@ public class ProcessPaymentReceivedMessage extends TradeTask {
if (trade instanceof ArbitratorTrade && !isSigned && message.isDeferPublishPayout()) { if (trade instanceof ArbitratorTrade && !isSigned && message.isDeferPublishPayout()) {
log.info("Deferring signing and publishing payout tx for {} {}", trade.getClass().getSimpleName(), trade.getId()); log.info("Deferring signing and publishing payout tx for {} {}", trade.getClass().getSimpleName(), trade.getId());
GenUtils.waitFor(Trade.DEFER_PUBLISH_MS); GenUtils.waitFor(Trade.DEFER_PUBLISH_MS);
trade.syncWallet(); if (!trade.isPayoutUnlocked()) trade.syncWallet();
} }
// verify and publish payout tx // verify and publish payout tx
if (!trade.isPayoutPublished()) { if (!trade.isPayoutPublished()) {
if (isSigned) { if (isSigned) {
log.info("{} publishing signed payout tx from seller", trade.getClass().getSimpleName()); log.info("{} {} publishing signed payout tx from seller", trade.getClass().getSimpleName(), trade.getId());
trade.verifyPayoutTx(message.getSignedPayoutTxHex(), false, true); trade.verifyPayoutTx(message.getSignedPayoutTxHex(), false, true);
} else { } else {
log.info("{} verifying, signing, and publishing seller's payout tx", trade.getClass().getSimpleName());
try { try {
trade.verifyPayoutTx(message.getUnsignedPayoutTxHex(), true, true); if (StringUtils.equals(trade.getPayoutTxHex(), trade.getProcessModel().getPaymentSentMessage().getPayoutTxHex())) { // unsigned
log.info("{} {} verifying, signing, and publishing seller's payout tx", trade.getClass().getSimpleName(), trade.getId());
trade.verifyPayoutTx(message.getUnsignedPayoutTxHex(), true, true);
} else {
log.info("{} {} re-verifying and publishing payout tx", trade.getClass().getSimpleName(), trade.getId());
trade.verifyPayoutTx(trade.getPayoutTxHex(), false, true);
}
} catch (Exception e) { } catch (Exception e) {
if (trade.isPayoutPublished()) log.info("Payout tx already published for {} {}", trade.getClass().getName(), trade.getId()); if (trade.isPayoutPublished()) log.info("Payout tx already published for {} {}", trade.getClass().getName(), trade.getId());
else throw e; else throw e;

View file

@ -44,10 +44,10 @@ public class ProcessPaymentSentMessage extends TradeTask {
// verify signature of payment sent message // verify signature of payment sent message
HavenoUtils.verifyPaymentSentMessage(trade, message); HavenoUtils.verifyPaymentSentMessage(trade, message);
// update buyer info // set state
processModel.setPaymentSentMessage(message);
trade.setPayoutTxHex(message.getPayoutTxHex()); trade.setPayoutTxHex(message.getPayoutTxHex());
trade.getBuyer().setUpdatedMultisigHex(message.getUpdatedMultisigHex()); trade.getBuyer().setUpdatedMultisigHex(message.getUpdatedMultisigHex());
trade.getBuyer().setPaymentSentMessage(message);
trade.getSeller().setAccountAgeWitness(message.getSellerAccountAgeWitness()); trade.getSeller().setAccountAgeWitness(message.getSellerAccountAgeWitness());
// if seller, decrypt buyer's payment account payload // if seller, decrypt buyer's payment account payload
@ -62,7 +62,7 @@ public class ProcessPaymentSentMessage extends TradeTask {
String counterCurrencyExtraData = message.getCounterCurrencyExtraData(); String counterCurrencyExtraData = message.getCounterCurrencyExtraData();
if (counterCurrencyExtraData != null && counterCurrencyExtraData.length() < 100) trade.setCounterCurrencyExtraData(counterCurrencyExtraData); if (counterCurrencyExtraData != null && counterCurrencyExtraData.length() < 100) trade.setCounterCurrencyExtraData(counterCurrencyExtraData);
trade.setStateIfProgress(trade.isSeller() ? Trade.State.SELLER_RECEIVED_PAYMENT_SENT_MSG : Trade.State.BUYER_SENT_PAYMENT_SENT_MSG); trade.setStateIfProgress(trade.isSeller() ? Trade.State.SELLER_RECEIVED_PAYMENT_SENT_MSG : Trade.State.BUYER_SENT_PAYMENT_SENT_MSG);
processModel.getTradeManager().requestPersistence(); trade.requestPersistence();
complete(); complete();
} catch (Throwable t) { } catch (Throwable t) {
failed(t); failed(t);

View file

@ -17,7 +17,6 @@
package bisq.core.trade.protocol.tasks; package bisq.core.trade.protocol.tasks;
import bisq.core.btc.wallet.XmrWalletService;
import bisq.core.trade.Trade; import bisq.core.trade.Trade;
import java.util.ArrayList; import java.util.ArrayList;
@ -42,27 +41,39 @@ public class SellerPreparePaymentReceivedMessage extends TradeTask {
try { try {
runInterceptHook(); runInterceptHook();
// import multisig hex // check connection
MoneroWallet multisigWallet = trade.getWallet(); trade.checkWalletConnection();
List<String> updatedMultisigHexes = new ArrayList<String>();
if (trade.getBuyer().getUpdatedMultisigHex() != null) updatedMultisigHexes.add(trade.getBuyer().getUpdatedMultisigHex());
if (trade.getArbitrator().getUpdatedMultisigHex() != null) updatedMultisigHexes.add(trade.getArbitrator().getUpdatedMultisigHex());
if (!updatedMultisigHexes.isEmpty()) {
multisigWallet.importMultisigHex(updatedMultisigHexes.toArray(new String[0]));
trade.saveWallet();
}
// verify, sign, and publish payout tx if given. otherwise create payout tx // handle first time preparation
if (trade.getPayoutTxHex() != null) { if (processModel.getPaymentReceivedMessage() == null) {
log.info("Seller verifying, signing, and publishing payout tx for trade {}", trade.getId());
trade.verifyPayoutTx(trade.getPayoutTxHex(), true, true);
} else {
// create unsigned payout tx // import multisig hex
log.info("Seller creating unsigned payout tx for trade {}", trade.getId()); MoneroWallet multisigWallet = trade.getWallet();
MoneroTxWallet payoutTx = trade.createPayoutTx(); List<String> updatedMultisigHexes = new ArrayList<String>();
trade.setPayoutTx(payoutTx); if (trade.getBuyer().getUpdatedMultisigHex() != null) updatedMultisigHexes.add(trade.getBuyer().getUpdatedMultisigHex());
trade.setPayoutTxHex(payoutTx.getTxSet().getMultisigTxHex()); if (trade.getArbitrator().getUpdatedMultisigHex() != null) updatedMultisigHexes.add(trade.getArbitrator().getUpdatedMultisigHex());
if (!updatedMultisigHexes.isEmpty()) {
multisigWallet.importMultisigHex(updatedMultisigHexes.toArray(new String[0]));
trade.saveWallet();
}
// verify, sign, and publish payout tx if given. otherwise create payout tx
if (trade.getPayoutTxHex() != null) {
log.info("Seller verifying, signing, and publishing payout tx for trade {}", trade.getId());
trade.verifyPayoutTx(trade.getPayoutTxHex(), true, true);
} else {
// create unsigned payout tx
log.info("Seller creating unsigned payout tx for trade {}", trade.getId());
MoneroTxWallet payoutTx = trade.createPayoutTx();
trade.setPayoutTx(payoutTx);
trade.setPayoutTxHex(payoutTx.getTxSet().getMultisigTxHex());
}
} else if (processModel.getPaymentReceivedMessage().getSignedPayoutTxHex() != null && !trade.isPayoutPublished()) {
// republish payout tx from previous message
log.info("Seller re-verifying and publishing payout tx for trade {}", trade.getId());
trade.verifyPayoutTx(processModel.getPaymentReceivedMessage().getSignedPayoutTxHex(), false, true);
} }
processModel.getTradeManager().requestPersistence(); processModel.getTradeManager().requestPersistence();

View file

@ -39,8 +39,8 @@ import com.google.common.base.Charsets;
@Slf4j @Slf4j
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public abstract class SellerSendPaymentReceivedMessage extends SendMailboxMessageTask { public abstract class SellerSendPaymentReceivedMessage extends SendMailboxMessageTask {
SignedWitness signedWitness = null;
PaymentReceivedMessage message = null; PaymentReceivedMessage message = null;
SignedWitness signedWitness = null;
public SellerSendPaymentReceivedMessage(TaskRunner<Trade> taskHandler, Trade trade) { public SellerSendPaymentReceivedMessage(TaskRunner<Trade> taskHandler, Trade trade) {
super(taskHandler, trade); super(taskHandler, trade);
@ -87,7 +87,7 @@ public abstract class SellerSendPaymentReceivedMessage extends SendMailboxMessag
trade.getState().ordinal() >= Trade.State.SELLER_SAW_ARRIVED_PAYMENT_RECEIVED_MSG.ordinal(), // informs to expect payout trade.getState().ordinal() >= Trade.State.SELLER_SAW_ARRIVED_PAYMENT_RECEIVED_MSG.ordinal(), // informs to expect payout
trade.getTradingPeer().getAccountAgeWitness(), trade.getTradingPeer().getAccountAgeWitness(),
signedWitness, signedWitness,
trade.getBuyer().getPaymentSentMessage() processModel.getPaymentSentMessage()
); );
// sign message // sign message
@ -95,6 +95,8 @@ public abstract class SellerSendPaymentReceivedMessage extends SendMailboxMessag
String messageAsJson = JsonUtil.objectToJson(message); String messageAsJson = JsonUtil.objectToJson(message);
byte[] sig = Sig.sign(processModel.getP2PService().getKeyRing().getSignatureKeyPair().getPrivate(), messageAsJson.getBytes(Charsets.UTF_8)); byte[] sig = Sig.sign(processModel.getP2PService().getKeyRing().getSignatureKeyPair().getPrivate(), messageAsJson.getBytes(Charsets.UTF_8));
message.setSellerSignature(sig); message.setSellerSignature(sig);
processModel.setPaymentReceivedMessage(message);
trade.requestPersistence();
} catch (Exception e) { } catch (Exception e) {
throw new RuntimeException(e); throw new RuntimeException(e);
} }

View file

@ -18,6 +18,8 @@
package bisq.core.trade.protocol.tasks; package bisq.core.trade.protocol.tasks;
import bisq.core.trade.Trade; import bisq.core.trade.Trade;
import bisq.core.trade.messages.PaymentReceivedMessage;
import bisq.core.trade.messages.TradeMailboxMessage;
import bisq.core.trade.messages.TradeMessage; import bisq.core.trade.messages.TradeMessage;
import bisq.network.p2p.NodeAddress; import bisq.network.p2p.NodeAddress;
import bisq.common.crypto.PubKeyRing; import bisq.common.crypto.PubKeyRing;
@ -34,6 +36,15 @@ public class SellerSendPaymentReceivedMessageToBuyer extends SellerSendPaymentRe
super(taskHandler, trade); super(taskHandler, trade);
} }
@Override
protected TradeMailboxMessage getTradeMailboxMessage(String tradeId) {
if (processModel.getPaymentReceivedMessage() == null) {
processModel.setPaymentReceivedMessage((PaymentReceivedMessage) super.getTradeMailboxMessage(tradeId)); // save payment received message for buyer
}
return processModel.getPaymentReceivedMessage();
}
protected NodeAddress getReceiverNodeAddress() { protected NodeAddress getReceiverNodeAddress() {
return trade.getBuyer().getNodeAddress(); return trade.getBuyer().getNodeAddress();
} }

View file

@ -1139,6 +1139,7 @@ support.role=Role
support.agent=Support agent support.agent=Support agent
support.state=State support.state=State
support.chat=Chat support.chat=Chat
support.requested=Requested
support.closed=Closed support.closed=Closed
support.open=Open support.open=Open
support.process=Process support.process=Process
@ -1967,6 +1968,7 @@ tradeDetailsWindow.txFee=Mining fee
tradeDetailsWindow.tradingPeersOnion=Trading peers onion address tradeDetailsWindow.tradingPeersOnion=Trading peers onion address
tradeDetailsWindow.tradingPeersPubKeyHash=Trading peers pubkey hash tradeDetailsWindow.tradingPeersPubKeyHash=Trading peers pubkey hash
tradeDetailsWindow.tradeState=Trade state tradeDetailsWindow.tradeState=Trade state
tradeDetailsWindow.tradePhase=Trade phase
tradeDetailsWindow.agentAddresses=Arbitrator/Mediator tradeDetailsWindow.agentAddresses=Arbitrator/Mediator
tradeDetailsWindow.detailData=Detail data tradeDetailsWindow.detailData=Detail data

View file

@ -409,7 +409,7 @@ public class OfferDetailsWindow extends Overlay<OfferDetailsWindow> {
placeOfferHandlerOptional.ifPresent(Runnable::run); placeOfferHandlerOptional.ifPresent(Runnable::run);
} else { } else {
State lastState = Trade.State.ARBITRATOR_PUBLISHED_DEPOSIT_TXS; State lastState = Trade.State.ARBITRATOR_PUBLISHED_DEPOSIT_TXS;
spinnerInfoLabel.setText(Res.get("takeOffer.fundsBox.takeOfferSpinnerInfo") + " 1/" + (lastState.ordinal())); spinnerInfoLabel.setText(Res.get("takeOffer.fundsBox.takeOfferSpinnerInfo") + " 1/" + (lastState.ordinal() + 1));
takeOfferHandlerOptional.ifPresent(Runnable::run); takeOfferHandlerOptional.ifPresent(Runnable::run);
// update trade state progress // update trade state progress
@ -417,7 +417,7 @@ public class OfferDetailsWindow extends Overlay<OfferDetailsWindow> {
Trade trade = tradeManager.getTrade(offer.getId()); Trade trade = tradeManager.getTrade(offer.getId());
if (trade == null) return; if (trade == null) return;
tradeStateSubscription = EasyBind.subscribe(trade.stateProperty(), newState -> { tradeStateSubscription = EasyBind.subscribe(trade.stateProperty(), newState -> {
String progress = (newState.ordinal() + 1) + "/" + (lastState.ordinal()); String progress = (newState.ordinal() + 1) + "/" + (lastState.ordinal() + 1);
spinnerInfoLabel.setText(Res.get("takeOffer.fundsBox.takeOfferSpinnerInfo") + " " + progress); spinnerInfoLabel.setText(Res.get("takeOffer.fundsBox.takeOfferSpinnerInfo") + " " + progress);
// unsubscribe when done // unsubscribe when done

View file

@ -299,7 +299,7 @@ public class TradeDetailsWindow extends Overlay<TradeDetailsWindow> {
textArea.scrollTopProperty().addListener(changeListener); textArea.scrollTopProperty().addListener(changeListener);
textArea.setScrollTop(30); textArea.setScrollTop(30);
addConfirmationLabelTextField(gridPane, ++rowIndex, Res.get("tradeDetailsWindow.tradeState"), trade.getPhase().name()); addConfirmationLabelTextField(gridPane, ++rowIndex, Res.get("tradeDetailsWindow.tradePhase"), trade.getPhase().name());
} }
Tuple3<Button, Button, HBox> tuple = add2ButtonsWithBox(gridPane, ++rowIndex, Tuple3<Button, Button, HBox> tuple = add2ButtonsWithBox(gridPane, ++rowIndex,
@ -322,10 +322,13 @@ public class TradeDetailsWindow extends Overlay<TradeDetailsWindow> {
viewContractButton.setOnAction(e -> { viewContractButton.setOnAction(e -> {
TextArea textArea = new HavenoTextArea(); TextArea textArea = new HavenoTextArea();
textArea.setText(trade.getContractAsJson()); textArea.setText(trade.getContractAsJson());
String data = "Contract as json:\n"; String data = "Trade state: " + trade.getState();
data += "\nTrade payout state: " + trade.getPayoutState();
data += "\nTrade dispute state: " + trade.getDisputeState();
data += "\n\nContract as json:\n";
data += trade.getContractAsJson(); data += trade.getContractAsJson();
data += "\n\nOther detail data:"; data += "\n\nOther detail data:";
if (!trade.isDepositPublished()) { if (!trade.isDepositsPublished()) {
data += "\n\n" + (trade.getMaker() == trade.getBuyer() ? "Buyer" : "Seller") + " as maker reserve tx hex: " + trade.getMaker().getReserveTxHex(); data += "\n\n" + (trade.getMaker() == trade.getBuyer() ? "Buyer" : "Seller") + " as maker reserve tx hex: " + trade.getMaker().getReserveTxHex();
data += "\n\n" + (trade.getTaker() == trade.getBuyer() ? "Buyer" : "Seller") + " as taker reserve tx hex: " + trade.getTaker().getReserveTxHex(); data += "\n\n" + (trade.getTaker() == trade.getBuyer() ? "Buyer" : "Seller") + " as taker reserve tx hex: " + trade.getTaker().getReserveTxHex();
} }

View file

@ -32,7 +32,6 @@ import bisq.core.provider.mempool.MempoolService;
import bisq.core.trade.ArbitratorTrade; import bisq.core.trade.ArbitratorTrade;
import bisq.core.trade.BuyerTrade; import bisq.core.trade.BuyerTrade;
import bisq.core.trade.ClosedTradableManager; import bisq.core.trade.ClosedTradableManager;
import bisq.core.trade.Contract;
import bisq.core.trade.HavenoUtils; import bisq.core.trade.HavenoUtils;
import bisq.core.trade.SellerTrade; import bisq.core.trade.SellerTrade;
import bisq.core.trade.Trade; import bisq.core.trade.Trade;
@ -433,21 +432,19 @@ public class PendingTradesViewModel extends ActivatableWithDataModel<PendingTrad
buyerState.set(BuyerState.STEP2); buyerState.set(BuyerState.STEP2);
break; break;
// seller step 3 // payment received
case SELLER_RECEIVED_PAYMENT_SENT_MSG: // PAYMENT_SENT_MSG received
sellerState.set(SellerState.STEP3);
break;
// seller step 4
case SELLER_CONFIRMED_IN_UI_PAYMENT_RECEIPT: // UI action
case SELLER_SENT_PAYMENT_RECEIVED_MSG: case SELLER_SENT_PAYMENT_RECEIVED_MSG:
if (trade instanceof BuyerTrade) buyerState.set(BuyerState.STEP4); if (trade instanceof BuyerTrade) buyerState.set(BuyerState.STEP4);
else if (trade instanceof SellerTrade) sellerState.set(SellerState.STEP3); else if (trade instanceof SellerTrade) sellerState.set(trade.isPayoutPublished() ? SellerState.STEP4 : SellerState.STEP3);
break; break;
case SELLER_SAW_ARRIVED_PAYMENT_RECEIVED_MSG:
case SELLER_STORED_IN_MAILBOX_PAYMENT_RECEIVED_MSG: // seller step 3
case SELLER_RECEIVED_PAYMENT_SENT_MSG: // PAYMENT_SENT_MSG received
case SELLER_CONFIRMED_IN_UI_PAYMENT_RECEIPT:
case SELLER_SEND_FAILED_PAYMENT_RECEIVED_MSG: case SELLER_SEND_FAILED_PAYMENT_RECEIVED_MSG:
sellerState.set(SellerState.STEP4); case SELLER_STORED_IN_MAILBOX_PAYMENT_RECEIVED_MSG:
case SELLER_SAW_ARRIVED_PAYMENT_RECEIVED_MSG:
sellerState.set(trade.isPayoutPublished() ? SellerState.STEP4 : SellerState.STEP3);
break; break;
case TRADE_COMPLETED: case TRADE_COMPLETED:

View file

@ -801,8 +801,9 @@ public abstract class TradeStepView extends AnchorPane {
// } // }
// } // }
protected void checkForTimeout() { protected void checkForUnconfirmedTimeout() {
long unconfirmedHours = Duration.between(trade.getTakeOfferDate().toInstant(), Instant.now()).toHours(); if (trade.isDepositsConfirmed()) return;
long unconfirmedHours = Duration.between(trade.getDate().toInstant(), Instant.now()).toHours();
if (unconfirmedHours >= 3 && !trade.hasFailed()) { if (unconfirmedHours >= 3 && !trade.hasFailed()) {
String key = "tradeUnconfirmedTooLong_" + trade.getShortId(); String key = "tradeUnconfirmedTooLong_" + trade.getShortId();
if (DontShowAgainLookup.showAgain(key)) { if (DontShowAgainLookup.showAgain(key)) {

View file

@ -37,7 +37,7 @@ public class BuyerStep1View extends TradeStepView {
super.onPendingTradesInitialized(); super.onPendingTradesInitialized();
//validatePayoutTx(); // TODO (woodser): no payout tx in xmr integration, do something else? //validatePayoutTx(); // TODO (woodser): no payout tx in xmr integration, do something else?
//validateDepositInputs(); //validateDepositInputs();
checkForTimeout(); checkForUnconfirmedTimeout();
} }

View file

@ -17,7 +17,6 @@
package bisq.desktop.main.portfolio.pendingtrades.steps.buyer; package bisq.desktop.main.portfolio.pendingtrades.steps.buyer;
import bisq.desktop.components.AutoTooltipButton;
import bisq.desktop.components.BusyAnimation; import bisq.desktop.components.BusyAnimation;
import bisq.desktop.components.TextFieldWithCopyIcon; import bisq.desktop.components.TextFieldWithCopyIcon;
import bisq.desktop.components.TitledGroupBg; import bisq.desktop.components.TitledGroupBg;
@ -155,7 +154,7 @@ public class BuyerStep2View extends TradeStepView {
if (timeoutTimer != null) if (timeoutTimer != null)
timeoutTimer.stop(); timeoutTimer.stop();
if (trade.isDepositUnlocked() && !trade.isPaymentSent()) { if (trade.isDepositsUnlocked() && !trade.isPaymentSent()) {
showPopup(); showPopup();
} else if (state.ordinal() <= Trade.State.BUYER_SEND_FAILED_PAYMENT_SENT_MSG.ordinal()) { } else if (state.ordinal() <= Trade.State.BUYER_SEND_FAILED_PAYMENT_SENT_MSG.ordinal()) {
if (!trade.hasFailed()) { if (!trade.hasFailed()) {
@ -481,6 +480,10 @@ public class BuyerStep2View extends TradeStepView {
return; return;
} }
if (!model.dataModel.isReadyForTxBroadcast()) {
return;
}
PaymentAccountPayload sellersPaymentAccountPayload = model.dataModel.getSellersPaymentAccountPayload(); PaymentAccountPayload sellersPaymentAccountPayload = model.dataModel.getSellersPaymentAccountPayload();
Trade trade = checkNotNull(model.dataModel.getTrade(), "trade must not be null"); Trade trade = checkNotNull(model.dataModel.getTrade(), "trade must not be null");
if (sellersPaymentAccountPayload instanceof CashDepositAccountPayload) { if (sellersPaymentAccountPayload instanceof CashDepositAccountPayload) {

View file

@ -37,7 +37,7 @@ public class SellerStep1View extends TradeStepView {
super.onPendingTradesInitialized(); super.onPendingTradesInitialized();
//validateDepositInputs(); //validateDepositInputs();
log.warn("Need to validate fee and/or deposit txs in SellerStep1View for XMR?"); // TODO (woodser): need to validate fee and/or deposit txs in SellerStep1View? log.warn("Need to validate fee and/or deposit txs in SellerStep1View for XMR?"); // TODO (woodser): need to validate fee and/or deposit txs in SellerStep1View?
checkForTimeout(); checkForUnconfirmedTimeout();
} }
/////////////////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////////////////

View file

@ -306,11 +306,16 @@ public class SellerStep3View extends TradeStepView {
HBox hBox = tuple.fourth; HBox hBox = tuple.fourth;
GridPane.setColumnSpan(tuple.fourth, 2); GridPane.setColumnSpan(tuple.fourth, 2);
confirmButton = tuple.first; confirmButton = tuple.first;
confirmButton.setDisable(!confirmPaymentReceivedPermitted());
confirmButton.setOnAction(e -> onPaymentReceived()); confirmButton.setOnAction(e -> onPaymentReceived());
busyAnimation = tuple.second; busyAnimation = tuple.second;
statusLabel = tuple.third; statusLabel = tuple.third;
} }
private boolean confirmPaymentReceivedPermitted() {
if (!trade.confirmPermitted()) return false;
return trade.getState().ordinal() >= Trade.State.BUYER_SENT_PAYMENT_SENT_MSG.ordinal() && trade.getState().ordinal() < Trade.State.SELLER_SENT_PAYMENT_RECEIVED_MSG.ordinal(); // TODO: test that can resen with same payout tx hex if delivery failed
}
/////////////////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////////////////
// Info // Info
@ -357,7 +362,7 @@ public class SellerStep3View extends TradeStepView {
protected void updateDisputeState(Trade.DisputeState disputeState) { protected void updateDisputeState(Trade.DisputeState disputeState) {
super.updateDisputeState(disputeState); super.updateDisputeState(disputeState);
confirmButton.setDisable(!trade.confirmPermitted()); confirmButton.setDisable(!confirmPaymentReceivedPermitted());
} }
@ -463,11 +468,14 @@ public class SellerStep3View extends TradeStepView {
log.info("User pressed the [Confirm payment receipt] button for Trade {}", trade.getShortId()); log.info("User pressed the [Confirm payment receipt] button for Trade {}", trade.getShortId());
busyAnimation.play(); busyAnimation.play();
statusLabel.setText(Res.get("shared.sendingConfirmation")); statusLabel.setText(Res.get("shared.sendingConfirmation"));
confirmButton.setDisable(true);
model.dataModel.onPaymentReceived(() -> { model.dataModel.onPaymentReceived(() -> {
}, errorMessage -> { }, errorMessage -> {
busyAnimation.stop(); busyAnimation.stop();
new Popup().warning(Res.get("popup.warning.sendMsgFailed")).show(); new Popup().warning(Res.get("popup.warning.sendMsgFailed")).show();
confirmButton.setDisable(!confirmPaymentReceivedPermitted());
UserThread.execute(() -> statusLabel.setText("Error confirming payment received."));
}); });
} }

View file

@ -50,6 +50,7 @@ import bisq.core.support.messages.ChatMessage;
import bisq.core.trade.Contract; import bisq.core.trade.Contract;
import bisq.core.trade.Trade; import bisq.core.trade.Trade;
import bisq.core.trade.TradeManager; import bisq.core.trade.TradeManager;
import bisq.core.trade.Trade.DisputeState;
import bisq.core.user.Preferences; import bisq.core.user.Preferences;
import bisq.core.util.FormattingUtils; import bisq.core.util.FormattingUtils;
import bisq.core.util.coin.CoinFormatter; import bisq.core.util.coin.CoinFormatter;
@ -1341,18 +1342,21 @@ public abstract class DisputeView extends ActivatableView<VBox, Void> {
ReadOnlyBooleanProperty closedProperty; ReadOnlyBooleanProperty closedProperty;
ChangeListener<Boolean> listener; ChangeListener<Boolean> listener;
Subscription subscription;
@Override @Override
public void updateItem(final Dispute item, boolean empty) { public void updateItem(final Dispute item, boolean empty) {
super.updateItem(item, empty); super.updateItem(item, empty);
UserThread.execute(() -> { UserThread.execute(() -> {
if (item != null && !empty) { if (item != null && !empty) {
if (closedProperty != null) { if (closedProperty != null) closedProperty.removeListener(listener);
closedProperty.removeListener(listener); if (subscription != null) {
subscription.unsubscribe();
subscription = null;
} }
listener = (observable, oldValue, newValue) -> { listener = (observable, oldValue, newValue) -> {
setText(newValue ? Res.get("support.closed") : Res.get("support.open")); setText(getDisputeStateText(item));
if (getTableRow() != null) if (getTableRow() != null)
getTableRow().setOpacity(newValue && item.getBadgeCountProperty().get() == 0 ? 0.4 : 1); getTableRow().setOpacity(newValue && item.getBadgeCountProperty().get() == 0 ? 0.4 : 1);
if (item.isClosed() && item == chatPopup.getSelectedDispute()) if (item.isClosed() && item == chatPopup.getSelectedDispute())
@ -1361,14 +1365,23 @@ public abstract class DisputeView extends ActivatableView<VBox, Void> {
closedProperty = item.isClosedProperty(); closedProperty = item.isClosedProperty();
closedProperty.addListener(listener); closedProperty.addListener(listener);
boolean isClosed = item.isClosed(); boolean isClosed = item.isClosed();
setText(isClosed ? Res.get("support.closed") : Res.get("support.open")); setText(getDisputeStateText(item));
if (getTableRow() != null) if (getTableRow() != null)
getTableRow().setOpacity(isClosed && item.getBadgeCountProperty().get() == 0 ? 0.4 : 1); getTableRow().setOpacity(isClosed && item.getBadgeCountProperty().get() == 0 ? 0.4 : 1);
// subscribe to trade's dispute state
Trade trade = tradeManager.getTrade(item.getTradeId());
if (trade == null) log.warn("Dispute's trade is null for trade {}", item.getTradeId());
else subscription = EasyBind.subscribe(trade.disputeStateProperty(), disputeState -> setText(getDisputeStateText(disputeState)));
} else { } else {
if (closedProperty != null) { if (closedProperty != null) {
closedProperty.removeListener(listener); closedProperty.removeListener(listener);
closedProperty = null; closedProperty = null;
} }
if (subscription != null) {
subscription.unsubscribe();
subscription = null;
}
setText(""); setText("");
} }
}); });
@ -1379,6 +1392,33 @@ public abstract class DisputeView extends ActivatableView<VBox, Void> {
return column; return column;
} }
private String getDisputeStateText(DisputeState disputeState) {
switch (disputeState) {
case DISPUTE_REQUESTED:
return Res.get("support.requested");
case DISPUTE_CLOSED:
return Res.get("support.closed");
default:
return Res.get("support.open");
}
}
private String getDisputeStateText(Dispute dispute) {
Trade trade = tradeManager.getTrade(dispute.getTradeId());
if (trade == null) {
log.warn("Dispute's trade is null for trade {}", dispute.getTradeId());
return Res.get("support.closed");
}
switch (trade.getDisputeState()) {
case DISPUTE_REQUESTED:
return Res.get("support.requested");
case DISPUTE_CLOSED:
return Res.get("support.closed");
default:
return Res.get("support.open");
}
}
private void openChat(Dispute dispute) { private void openChat(Dispute dispute) {
chatPopup.openChat(dispute, getConcreteDisputeChatSession(dispute), getCounterpartyName()); chatPopup.openChat(dispute, getConcreteDisputeChatSession(dispute), getCounterpartyName());
dispute.setDisputeSeen(senderFlag()); dispute.setDisputeSeen(senderFlag());

View file

@ -738,11 +738,18 @@ public class GUIUtil {
return false; return false;
} }
try {
connectionService.verifyConnection();
} catch (Exception e) {
new Popup().information(e.getMessage()).show();
return false;
}
return true; return true;
} }
public static boolean isChainHeightSyncedWithinToleranceOrShowPopup(CoreMoneroConnectionsService connectionService) { public static boolean isChainHeightSyncedWithinToleranceOrShowPopup(CoreMoneroConnectionsService connectionService) {
if (!connectionService.isChainHeightSyncedWithinTolerance()) { if (!connectionService.isSyncedWithinTolerance()) {
new Popup().information(Res.get("popup.warning.chainNotSynced")).show(); new Popup().information(Res.get("popup.warning.chainNotSynced")).show();
return false; return false;
} }

14
docs/operation_manual.md Normal file
View file

@ -0,0 +1,14 @@
# Operation Manual
This operation manual describes how to operate a Haveno network by:
- Forking Haveno
- Creating and registering seed nodes
- Creating and registering arbitrators
- Building binaries of the application
TODO
## Manually open dispute by keyboard shortcut
In the event a dispute does not open properly, try manually reopening the dispute with a keyboard shortcut: `ctrl+o`

View file

@ -841,9 +841,9 @@ message TradeInfo {
string period_state = 19; string period_state = 19;
string payout_state = 20; string payout_state = 20;
string dispute_state = 21; string dispute_state = 21;
bool is_deposit_published = 22; bool is_deposits_published = 22;
bool is_deposit_confirmed = 23; bool is_deposits_confirmed = 23;
bool is_deposit_unlocked = 24; bool is_deposits_unlocked = 24;
bool is_payment_sent = 25; bool is_payment_sent = 25;
bool is_payment_received = 26; bool is_payment_received = 26;
bool is_payout_published = 27; bool is_payout_published = 27;

View file

@ -1652,11 +1652,12 @@ message Trade {
repeated ChatMessage chat_message = 22; repeated ChatMessage chat_message = 22;
MediationResultState mediation_result_state = 23; MediationResultState mediation_result_state = 23;
int64 lock_time = 24; int64 lock_time = 24;
NodeAddress refund_agent_node_address = 25; int64 start_time = 25;
RefundResultState refund_result_state = 26; NodeAddress refund_agent_node_address = 26;
string counter_currency_extra_data = 27; RefundResultState refund_result_state = 27;
string asset_tx_proof_result = 28; // name of AssetTxProofResult enum string counter_currency_extra_data = 28;
string uid = 29; string asset_tx_proof_result = 29; // name of AssetTxProofResult enum
string uid = 30;
} }
message BuyerAsMakerTrade { message BuyerAsMakerTrade {
@ -1708,6 +1709,10 @@ message ProcessModel {
TradingPeer arbitrator = 1004; TradingPeer arbitrator = 1004;
NodeAddress temp_trading_peer_node_address = 1005; NodeAddress temp_trading_peer_node_address = 1005;
string multisig_address = 1006; string multisig_address = 1006;
PaymentSentMessage payment_sent_message = 1012;
PaymentReceivedMessage payment_received_message = 1013;
DisputeClosedMessage dispute_closed_message = 1014;
} }
message TradingPeer { message TradingPeer {
@ -1745,7 +1750,6 @@ message TradingPeer {
string deposit_tx_hex = 1009; string deposit_tx_hex = 1009;
string deposit_tx_key = 1010; string deposit_tx_key = 1010;
string updated_multisig_hex = 1011; string updated_multisig_hex = 1011;
PaymentSentMessage payment_sent_message = 1012;
} }
/////////////////////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////////////////////