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) {
if (!trade.getIsDepositUnlocked()) {
if (!trade.getIsDepositsUnlocked()) {
fail(format("INVALID_PHASE for trade %s in STATE=%s PHASE=%s, deposit tx never unlocked.",
trade.getShortId(),
trade.getState(),
@ -182,9 +182,9 @@ public class AbstractTradeTest extends AbstractOfferTest {
assertEquals(EXPECTED_PROTOCOL_STATUS.phase.name(), trade.getPhase());
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.isPaymentReceivedMessageSent, trade.getIsPaymentReceived());
assertEquals(EXPECTED_PROTOCOL_STATUS.isPayoutPublished, trade.getIsPayoutPublished());

View file

@ -231,7 +231,7 @@ public class BotClient {
* @return boolean
*/
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) -> {
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());
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());
return true;
} else {

View file

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

View file

@ -164,7 +164,7 @@ public class CoreAccountService {
public void deleteAccount(Runnable onShutdown) {
try {
keyRing.lockKeys();
if (isAccountOpen()) closeAccount();
synchronized (listeners) {
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;
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
@ -268,6 +271,8 @@ public final class CoreMoneroConnectionsService {
return false;
}
// ----------------------------- APP METHODS ------------------------------
public ReadOnlyIntegerProperty numPeersProperty() {
return numPeers;
}

View file

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

View file

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

View file

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

View file

@ -91,11 +91,15 @@ public class Balances {
private void updatedBalances() {
if (!xmrWalletService.isWalletReady()) return;
try {
updateAvailableBalance();
updatePendingBalance();
updateReservedOfferBalance();
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

View file

@ -491,6 +491,7 @@ public class XmrWalletService {
synchronized (txCache) {
// fetch txs
if (getDaemon() == null) connectionsService.verifyConnection(); // will throw
List<MoneroTx> txs = getDaemon().getTxs(txHashes, true);
// store to cache
@ -549,6 +550,7 @@ public class XmrWalletService {
}
private void maybeInitMainWallet() {
if (wallet != null) throw new RuntimeException("Main wallet is already initialized");
// open or create wallet
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
if (wallet != null) {
try {
wallet.sync(); // blocking
wallet.startSyncing(connectionsService.getDefaultRefreshPeriodMs()); // start syncing wallet in background
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();
}
// sync wallet which updates app startup state
trySyncMainWallet();
if (connectionsService.getDaemon() == null) System.out.println("Daemon: null");
else {
@ -671,12 +668,25 @@ public class XmrWalletService {
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) {
log.info("Setting wallet daemon connection: " + (connection == null ? null : connection.getUri()));
if (wallet == null) maybeInitMainWallet();
if (wallet != null) {
else {
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
if (!connectionService.isChainHeightSyncedWithinTolerance()) {
if (!connectionService.isSyncedWithinTolerance()) {
errorMessage = "We got a handleOfferAvailabilityRequest but our chain is not synced.";
log.info(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 {
runInterceptHook();
// verify monero connection
model.getXmrWalletService().getConnectionsService().verifyConnection();
// create reserve tx
BigInteger makerFee = HavenoUtils.coinToAtomicUnits(offer.getMakerFee());
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.CoreNotificationService;
import bisq.core.locale.Res;
import bisq.core.support.dispute.Dispute;
import bisq.core.support.messages.ChatMessage;
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.MailboxMessageComparator;
import bisq.network.p2p.AckMessage;
@ -51,6 +54,7 @@ import javax.annotation.Nullable;
@Slf4j
public abstract class SupportManager {
protected final P2PService p2PService;
protected final TradeManager tradeManager;
protected final CoreMoneroConnectionsService connectionService;
protected final CoreNotificationService notificationService;
protected final Map<String, Timer> delayMsgMap = new HashMap<>();
@ -65,11 +69,15 @@ public abstract class SupportManager {
// Constructor
///////////////////////////////////////////////////////////////////////////////////////////
public SupportManager(P2PService p2PService, CoreMoneroConnectionsService connectionService, CoreNotificationService notificationService) {
public SupportManager(P2PService p2PService,
CoreMoneroConnectionsService connectionService,
CoreNotificationService notificationService,
TradeManager tradeManager) {
this.p2PService = p2PService;
this.connectionService = connectionService;
this.mailboxMessageService = p2PService.getMailboxMessageService();
this.notificationService = notificationService;
this.tradeManager = tradeManager;
// We get first the message handler called then the onBootstrapped
p2PService.addDecryptedDirectMessageListener((decryptedMessageWithPubKey, senderAddress) -> {
@ -181,6 +189,18 @@ public abstract class SupportManager {
if (ackMessage.isSuccess()) {
log.info("Received AckMessage for {} with tradeId {} and uid {}",
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 {
log.warn("Received AckMessage with error state for {} with tradeId {} and errorMessage={}",
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.P2PService;
import bisq.network.p2p.SendMailboxMessageListener;
import bisq.common.UserThread;
import bisq.common.app.Version;
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 {
protected final TradeWalletService tradeWalletService;
protected final XmrWalletService xmrWalletService;
protected final TradeManager tradeManager;
protected final ClosedTradableManager closedTradableManager;
protected final OpenOfferManager openOfferManager;
protected final KeyRing keyRing;
@ -122,11 +120,10 @@ public abstract class DisputeManager<T extends DisputeList<Dispute>> extends Sup
DisputeListService<T> disputeListService,
Config config,
PriceFeedService priceFeedService) {
super(p2PService, connectionService, notificationService);
super(p2PService, connectionService, notificationService, tradeManager);
this.tradeWalletService = tradeWalletService;
this.xmrWalletService = xmrWalletService;
this.tradeManager = tradeManager;
this.closedTradableManager = closedTradableManager;
this.openOfferManager = openOfferManager;
this.keyRing = keyRing;
@ -234,8 +231,10 @@ public abstract class DisputeManager<T extends DisputeList<Dispute>> extends Sup
}
protected T getDisputeList() {
synchronized(disputeListService.getDisputeList()) {
return disputeListService.getDisputeList();
}
}
public Set<String> getDisputedTradeIds() {
return disputeListService.getDisputedTradeIds();
@ -367,7 +366,7 @@ public abstract class DisputeManager<T extends DisputeList<Dispute>> extends Sup
UUID.randomUUID().toString(),
getSupportType(),
updatedMultisigHex,
trade.getBuyer().getPaymentSentMessage());
trade.getProcessModel().getPaymentSentMessage());
log.info("Send {} to peer {}. tradeId={}, openNewDisputeMessage.uid={}, " +
"chatMessage.uid={}",
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
// the state, as that is displayed to the user and we only persist that msg
chatMessage.setArrived(true);
trade.setDisputeStateIfProgress(Trade.DisputeState.DISPUTE_OPENED);
trade.setDisputeStateIfProgress(Trade.DisputeState.DISPUTE_REQUESTED);
requestPersistence();
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
// the state, as that is displayed to the user and we only persist that msg
chatMessage.setStoredInMailbox(true);
trade.setDisputeStateIfProgress(Trade.DisputeState.DISPUTE_OPENED);
trade.setDisputeStateIfProgress(Trade.DisputeState.DISPUTE_REQUESTED);
requestPersistence();
resultHandler.handleResult();
}
@ -441,6 +440,11 @@ public abstract class DisputeManager<T extends DisputeList<Dispute>> extends Sup
Dispute dispute = message.getDispute();
log.info("{}.onDisputeOpenedMessage() with trade {}, dispute {}", getClass().getSimpleName(), dispute.getTradeId(), dispute.getId());
Trade trade = null;
String errorMessage = null;
PubKeyRing senderPubKeyRing = null;
try {
// intialize
T disputeList = getDisputeList();
if (disputeList == null) {
@ -448,7 +452,7 @@ public abstract class DisputeManager<T extends DisputeList<Dispute>> extends Sup
return;
}
dispute.setSupportType(message.getSupportType());
dispute.setState(Dispute.State.NEW); // TODO: unused, remove?
dispute.setState(Dispute.State.NEW);
Contract contract = dispute.getContract();
// validate dispute
@ -461,19 +465,19 @@ public abstract class DisputeManager<T extends DisputeList<Dispute>> extends Sup
} catch (TradeDataValidation.AddressException |
TradeDataValidation.NodeAddressException |
TradeDataValidation.InvalidPaymentAccountPayloadException e) {
log.error(e.toString());
validationExceptions.add(e);
throw e;
}
// get trade
Trade trade = tradeManager.getTrade(dispute.getTradeId());
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();
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");
@ -500,7 +504,6 @@ public abstract class DisputeManager<T extends DisputeList<Dispute>> extends Sup
if (trade instanceof ArbitratorTrade) addPriceInfoMessage(dispute, 0);
// add dispute
String errorMessage = null;
synchronized (disputeList) {
if (!disputeList.contains(dispute)) {
Optional<Dispute> storedDisputeOptional = findDispute(dispute);
@ -517,11 +520,18 @@ public abstract class DisputeManager<T extends DisputeList<Dispute>> extends Sup
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 {
errorMessage = "We got a dispute msg that we have already stored. TradeId = " + dispute.getTradeId();
log.warn(errorMessage);
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
ObservableList<ChatMessage> messages = message.getDispute().getChatMessages();
@ -530,9 +540,6 @@ public abstract class DisputeManager<T extends DisputeList<Dispute>> extends Sup
sendAckMessage(msg, senderPubKeyRing, errorMessage == null, errorMessage);
}
// add chat message with mediation info if applicable // TODO: not applicable in haveno
addMediationResultMessage(dispute);
requestPersistence();
}
@ -635,7 +642,7 @@ public abstract class DisputeManager<T extends DisputeList<Dispute>> extends Sup
UUID.randomUUID().toString(),
getSupportType(),
updatedMultisigHex,
trade.getSelf().getPaymentSentMessage());
trade.getProcessModel().getPaymentSentMessage());
log.info("Send {} to peer {}. tradeId={}, peerOpenedDisputeMessage.uid={}, chatMessage.uid={}",
peerOpenedDisputeMessage.getClass().getSimpleName(), peersNodeAddress,

View file

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

View file

@ -56,8 +56,12 @@ import com.google.inject.Singleton;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import lombok.extern.slf4j.Slf4j;
@ -77,6 +81,8 @@ public final class ArbitrationManager extends DisputeManager<ArbitrationDisputeL
private final ArbitratorManager arbitratorManager;
private Map<String, Integer> reprocessDisputeClosedMessageCounts = new HashMap<>();
///////////////////////////////////////////////////////////////////////////////////////////
// Constructor
///////////////////////////////////////////////////////////////////////////////////////////
@ -117,6 +123,7 @@ public final class ArbitrationManager extends DisputeManager<ArbitrationDisputeL
log.info("Received {} from {} with tradeId {} and uid {}",
message.getClass().getSimpleName(), message.getSenderNodeAddress(), message.getTradeId(), message.getUid());
new Thread(() -> {
if (message instanceof DisputeOpenedMessage) {
handleDisputeOpenedMessage((DisputeOpenedMessage) message);
} else if (message instanceof ChatMessage) {
@ -126,6 +133,7 @@ public final class ArbitrationManager extends DisputeManager<ArbitrationDisputeL
} else {
log.warn("Unsupported message at dispatchMessage. message={}", message);
}
}).start();
}
}
@ -173,24 +181,38 @@ public final class ArbitrationManager extends DisputeManager<ArbitrationDisputeL
// received by both peers when arbitrator closes disputes
@Override
public void handleDisputeClosedMessage(DisputeClosedMessage disputeClosedMessage) {
DisputeResult disputeResult = disputeClosedMessage.getDisputeResult();
ChatMessage chatMessage = disputeResult.getChatMessage();
checkNotNull(chatMessage, "chatMessage must not be null");
String tradeId = disputeResult.getTradeId();
handleDisputeClosedMessage(disputeClosedMessage, true);
}
// get trade
Trade trade = tradeManager.getTrade(tradeId);
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", tradeId);
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();
@ -208,11 +230,12 @@ public final class ArbitrationManager extends DisputeManager<ArbitrationDisputeL
}
return;
}
Dispute dispute = disputeOptional.get();
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;
}
@ -229,6 +252,12 @@ public final class ArbitrationManager extends DisputeManager<ArbitrationDisputeL
}
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());
@ -239,26 +268,18 @@ public final class ArbitrationManager extends DisputeManager<ArbitrationDisputeL
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();
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, disputeClosedMessage.getUnsignedPayoutTxHex());
signAndPublishDisputePayoutTx(trade);
} catch (Exception e) {
// check if payout published again
@ -266,10 +287,7 @@ public final class ArbitrationManager extends DisputeManager<ArbitrationDisputeL
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;
throw new RuntimeException("Failed to sign and publish dispute payout tx from arbitrator: " + e.getMessage() + ". TradeId = " + tradeId);
}
}
} else {
@ -282,12 +300,47 @@ public final class ArbitrationManager extends DisputeManager<ArbitrationDisputeL
// 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);
sendAckMessage(chatMessage, dispute.getAgentPubKeyRing(), true, null);
requestPersistence();
}).start();
} 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;
}
private MoneroTxSet signAndPublishDisputePayoutTx(Trade trade, String payoutTxHex) {
// 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
MoneroWallet multisigWallet = trade.getWallet();
@ -296,6 +349,7 @@ public final class ArbitrationManager extends DisputeManager<ArbitrationDisputeL
Dispute dispute = disputeOptional.get();
Contract contract = dispute.getContract();
DisputeResult disputeResult = dispute.getDisputeResultProperty().get();
String unsignedPayoutTxHex = trade.getProcessModel().getDisputeClosedMessage().getUnsignedPayoutTxHex();
// 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
@ -303,9 +357,9 @@ public final class ArbitrationManager extends DisputeManager<ArbitrationDisputeL
// BigInteger tradeAmount = BigInteger.valueOf(contract.getTradeAmount().value).multiply(ParsingUtils.XMR_SATOSHI_MULTIPLIER);
// parse arbitrator-signed payout tx
MoneroTxSet signedTxSet = multisigWallet.describeTxSet(new MoneroTxSet().setMultisigTxHex(payoutTxHex));
if (signedTxSet.getTxs() == null || signedTxSet.getTxs().size() != 1) throw new RuntimeException("Bad arbitrator-signed payout tx"); // TODO (woodser): nack
MoneroTxWallet arbitratorSignedPayoutTx = signedTxSet.getTxs().get(0);
MoneroTxSet disputeTxSet = multisigWallet.describeTxSet(new MoneroTxSet().setMultisigTxHex(unsignedPayoutTxHex));
if (disputeTxSet.getTxs() == null || disputeTxSet.getTxs().size() != 1) throw new RuntimeException("Bad arbitrator-signed payout tx"); // TODO (woodser): nack
MoneroTxWallet arbitratorSignedPayoutTx = disputeTxSet.getTxs().get(0);
// verify payout tx has 1 or 2 destinations
int numDestinations = arbitratorSignedPayoutTx.getOutgoingTransfer() == null || arbitratorSignedPayoutTx.getOutgoingTransfer().getDestinations() == null ? 0 : arbitratorSignedPayoutTx.getOutgoingTransfer().getDestinations().size();
@ -353,11 +407,27 @@ public final class ArbitrationManager extends DisputeManager<ArbitrationDisputeL
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);
// check wallet's daemon connection
trade.checkWalletConnection();
// determine if we already signed dispute payout tx
// TODO: better way, such as by saving signed dispute payout tx hex in designated field instead of shared payoutTxHex field?
Set<String> nonSignedDisputePayoutTxHexes = new HashSet<String>();
if (trade.getProcessModel().getPaymentSentMessage() != null) nonSignedDisputePayoutTxHexes.add(trade.getProcessModel().getPaymentSentMessage().getPayoutTxHex());
if (trade.getProcessModel().getPaymentReceivedMessage() != null) {
nonSignedDisputePayoutTxHexes.add(trade.getProcessModel().getPaymentReceivedMessage().getUnsignedPayoutTxHex());
nonSignedDisputePayoutTxHexes.add(trade.getProcessModel().getPaymentReceivedMessage().getSignedPayoutTxHex());
}
boolean signed = trade.getPayoutTxHex() != null && !nonSignedDisputePayoutTxHexes.contains(trade.getPayoutTxHex());
// sign arbitrator-signed payout tx
MoneroMultisigSignResult result = multisigWallet.signMultisigTxHex(payoutTxHex);
if (!signed) {
MoneroMultisigSignResult result = multisigWallet.signMultisigTxHex(unsignedPayoutTxHex);
if (result.getSignedMultisigTxHex() == null) throw new RuntimeException("Error signing arbitrator-signed payout tx");
String signedMultisigTxHex = result.getSignedMultisigTxHex();
signedTxSet.setMultisigTxHex(signedMultisigTxHex);
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?
@ -370,19 +440,22 @@ public final class ArbitrationManager extends DisputeManager<ArbitrationDisputeL
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 RuntimeException("Miner fee is not within " + (XmrWalletService.MINER_FEE_TOLERANCE * 100) + "% of estimated fee, expected " + feeEstimate + " but was " + arbitratorSignedPayoutTx.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 " + 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
List<String> txHashes = multisigWallet.submitMultisigTxHex(signedTxSet.getMultisigTxHex());
signedTxSet.getTxs().get(0).setHash(txHashes.get(0)); // manually update hash which is known after signed
List<String> txHashes = multisigWallet.submitMultisigTxHex(disputeTxSet.getMultisigTxHex());
disputeTxSet.getTxs().get(0).setHash(txHashes.get(0)); // manually update hash which is known after signed
// update state
trade.setPayoutTx(signedTxSet.getTxs().get(0)); // TODO (woodser): is trade.payoutTx() mutually exclusive from dispute payout tx?
trade.setPayoutTxId(signedTxSet.getTxs().get(0).getHash());
trade.setPayoutTx(disputeTxSet.getTxs().get(0)); // TODO (woodser): is trade.payoutTx() mutually exclusive from dispute payout tx?
trade.setPayoutTxId(disputeTxSet.getTxs().get(0).getHash());
trade.setPayoutState(Trade.PayoutState.PAYOUT_PUBLISHED);
dispute.setDisputePayoutTxId(signedTxSet.getTxs().get(0).getHash());
return signedTxSet;
dispute.setDisputePayoutTxId(disputeTxSet.getTxs().get(0).getHash());
return disputeTxSet;
}
}

View file

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

View file

@ -294,13 +294,13 @@ public class HavenoUtils {
// verify signature
String errMessage = "The buyer signature is invalid for the " + message.getClass().getSimpleName() + " for " + trade.getClass().getSimpleName() + " " + trade.getId();
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) {
throw new RuntimeException(errMessage);
throw new IllegalArgumentException(errMessage);
}
// 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
String errMessage = "The seller signature is invalid for the " + message.getClass().getSimpleName() + " for " + trade.getClass().getSimpleName() + " " + trade.getId();
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) {
throw new RuntimeException(errMessage);
throw new IllegalArgumentException(errMessage);
}
// 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
verifyPaymentSentMessage(trade, message.getPaymentSentMessage());

View file

@ -17,6 +17,7 @@
package bisq.core.trade;
import bisq.core.api.CoreMoneroConnectionsService;
import bisq.core.btc.model.XmrAddressEntry;
import bisq.core.btc.wallet.XmrWalletService;
import bisq.core.locale.CurrencyUtil;
@ -240,7 +241,7 @@ public abstract class Trade implements Tradable, Model {
public enum DisputeState {
NO_DISPUTE,
DISPUTE_REQUESTED, // TODO: not currently used; can use by subscribing to chat message ack in DisputeManager
DISPUTE_REQUESTED,
DISPUTE_OPENED,
ARBITRATOR_SENT_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();
}
public boolean isRequested() {
return ordinal() >= DisputeState.DISPUTE_REQUESTED.ordinal();
}
public boolean isOpen() {
return this == DisputeState.DISPUTE_OPENED;
}
public boolean isClosed() {
return this == DisputeState.DISPUTE_CLOSED;
}
@ -404,6 +413,9 @@ public abstract class Trade implements Tradable, Model {
@Setter
private long lockTime;
@Getter
@Setter
private long startTime; // added for haveno
@Getter
@Nullable
private RefundResultState refundResultState = RefundResultState.UNDEFINED_REFUND_RESULT;
transient final private ObjectProperty<RefundResultState> refundResultStateProperty = new SimpleObjectProperty<>(refundResultState);
@ -444,8 +456,8 @@ public abstract class Trade implements Tradable, Model {
@Getter
@Setter
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
@ -588,7 +600,7 @@ public abstract class Trade implements Tradable, Model {
// handle trade state events
tradePhaseSubscription = EasyBind.subscribe(phaseProperty, newValue -> {
if (!isInitialized) return;
if (isDepositPublished() && !isPayoutUnlocked()) updateWalletRefreshPeriod();
if (isDepositsPublished() && !isPayoutUnlocked()) updateWalletRefreshPeriod();
if (isCompleted()) {
UserThread.execute(() -> {
if (tradePhaseSubscription != null) {
@ -648,6 +660,10 @@ public abstract class Trade implements Tradable, Model {
}
}
public void requestPersistence() {
processModel.getTradeManager().requestPersistence();
}
public TradeProtocol getProtocol() {
return processModel.getTradeManager().getTradeProtocol(this);
}
@ -664,6 +680,22 @@ public abstract class Trade implements Tradable, Model {
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.
*
@ -717,6 +749,9 @@ public abstract class Trade implements Tradable, Model {
BigInteger buyerPayoutAmount = buyerDepositAmount.add(tradeAmount);
BigInteger sellerPayoutAmount = sellerDepositAmount.subtract(tradeAmount);
// check connection to monero daemon
checkWalletConnection();
// create transaction to get fee estimate
MoneroTxWallet feeEstimateTx = multisigWallet.createTx(new MoneroTxConfig()
.setAccountIndex(0)
@ -760,20 +795,19 @@ public abstract class Trade implements Tradable, Model {
log.info("Verifying payout tx");
// gather relevant info
XmrWalletService walletService = processModel.getProvider().getXmrWalletService();
MoneroWallet multisigWallet = walletService.getMultisigWallet(getId());
MoneroWallet wallet = getWallet();
Contract contract = getContract();
BigInteger sellerDepositAmount = multisigWallet.getTx(getSeller().getDepositTxHash()).getIncomingAmount(); // TODO (woodser): redundancy of processModel.getPreparedDepositTxId() vs this.getDepositTxId() necessary or avoidable?
BigInteger buyerDepositAmount = multisigWallet.getTx(getBuyer().getDepositTxHash()).getIncomingAmount();
BigInteger sellerDepositAmount = wallet.getTx(getSeller().getDepositTxHash()).getIncomingAmount(); // TODO (woodser): redundancy of processModel.getPreparedDepositTxId() vs this.getDepositTxId() necessary or avoidable?
BigInteger buyerDepositAmount = wallet.getTx(getBuyer().getDepositTxHash()).getIncomingAmount();
BigInteger tradeAmount = HavenoUtils.coinToAtomicUnits(getAmount());
// describe payout tx
MoneroTxSet describedTxSet = multisigWallet.describeTxSet(new MoneroTxSet().setMultisigTxHex(payoutTxHex));
if (describedTxSet.getTxs() == null || describedTxSet.getTxs().size() != 1) throw new RuntimeException("Bad payout tx"); // TODO (woodser): test nack
MoneroTxSet describedTxSet = wallet.describeTxSet(new MoneroTxSet().setMultisigTxHex(payoutTxHex));
if (describedTxSet.getTxs() == null || describedTxSet.getTxs().size() != 1) throw new IllegalArgumentException("Bad payout tx"); // TODO (woodser): test nack
MoneroTxWallet payoutTx = describedTxSet.getTxs().get(0);
// 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)
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);
// verify payout addresses
if (!buyerPayoutDestination.getAddress().equals(contract.getBuyerPayoutAddressString())) throw new RuntimeException("Buyer payout address does not match contract");
if (!sellerPayoutDestination.getAddress().equals(contract.getSellerPayoutAddressString())) throw new RuntimeException("Seller 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 IllegalArgumentException("Seller payout address does not match contract");
// 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
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
BigInteger txCost = payoutTx.getFee().add(payoutTx.getChangeAmount());
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
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
if (sign) {
// sign tx
MoneroMultisigSignResult result = multisigWallet.signMultisigTxHex(payoutTxHex);
MoneroMultisigSignResult result = wallet.signMultisigTxHex(payoutTxHex);
if (result.getSignedMultisigTxHex() == null) throw new RuntimeException("Error signing payout tx");
payoutTxHex = result.getSignedMultisigTxHex();
describedTxSet = multisigWallet.describeMultisigTxSet(payoutTxHex); // update described set
describedTxSet = wallet.describeMultisigTxSet(payoutTxHex); // update described set
payoutTx = describedTxSet.getTxs().get(0);
// verify fee is within tolerance by recreating payout tx
@ -820,7 +857,7 @@ public abstract class Trade implements Tradable, Model {
if (feeEstimateTx != null) {
BigInteger feeEstimate = feeEstimateTx.getFee();
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);
}
}
@ -831,7 +868,8 @@ public abstract class Trade implements Tradable, Model {
// submit payout tx
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();
}
}
@ -926,14 +964,8 @@ public abstract class Trade implements Tradable, Model {
}
public void syncWallet() {
if (getWallet() == null) {
log.warn("Cannot sync multisig wallet because it doesn't exist 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;
}
if (getWallet() == null) throw new RuntimeException("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());
log.info("Syncing wallet for {} {}", getClass().getSimpleName(), getId());
getWallet().sync();
pollWallet();
@ -941,6 +973,14 @@ public abstract class Trade implements Tradable, Model {
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) {
syncNormalStartTime = System.currentTimeMillis();
setWalletRefreshPeriod(xmrWalletService.getConnectionsService().getDefaultRefreshPeriodMs());
@ -957,7 +997,7 @@ public abstract class Trade implements Tradable, Model {
if (xmrWalletService.multisigWalletExists(getId())) {
// 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());
return;
}
@ -1258,20 +1298,29 @@ public abstract class Trade implements Tradable, Model {
}
private long getStartTime() {
if (startTime != null) return startTime;
long now = System.currentTimeMillis();
if (isDepositConfirmed() && getTakeOfferDate() != null) {
if (isDepositUnlocked()) {
if (isDepositsConfirmed() && getTakeOfferDate() != null) {
if (isDepositsUnlocked()) {
if (startTime <= 0) setStartTimeFromUnlockedTxs(); // save to model
return startTime;
} else {
log.debug("depositTx not confirmed yet. We don't start counting remaining trade period yet. makerTxId={}, takerTxId={}", getMaker().getDepositTxHash(), getTaker().getDepositTxHash());
return now;
}
} else {
return now;
}
}
private void setStartTimeFromUnlockedTxs() {
long now = System.currentTimeMillis();
final long tradeTime = getTakeOfferDate().getTime();
long maxHeight = Math.max(getMakerDepositTx().getHeight(), getTakerDepositTx().getHeight());
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 (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 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;
@ -1280,14 +1329,6 @@ public abstract class Trade implements Tradable, Model {
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 {
log.debug("depositTx not confirmed yet. We don't start counting remaining trade period yet. makerTxId={}, takerTxId={}", getMaker().getDepositTxHash(), getTaker().getDepositTxHash());
startTime = now;
}
} else {
startTime = now;
}
return startTime;
}
public boolean hasFailed() {
@ -1306,19 +1347,19 @@ public abstract class Trade implements Tradable, Model {
return getState() == Trade.State.PUBLISH_DEPOSIT_TX_REQUEST_FAILED;
}
public boolean isDepositPublished() {
public boolean isDepositsPublished() {
return getState().getPhase().ordinal() >= Phase.DEPOSITS_PUBLISHED.ordinal();
}
public boolean isFundsLockedIn() {
return isDepositPublished() && !isPayoutPublished();
return isDepositsPublished() && !isPayoutPublished();
}
public boolean isDepositConfirmed() {
public boolean isDepositsConfirmed() {
return getState().getPhase().ordinal() >= Phase.DEPOSITS_CONFIRMED.ordinal();
}
public boolean isDepositUnlocked() {
public boolean isDepositsUnlocked() {
return getState().getPhase().ordinal() >= Phase.DEPOSITS_UNLOCKED.ordinal();
}
@ -1458,6 +1499,19 @@ public abstract class Trade implements Tradable, Model {
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
@ -1479,18 +1533,27 @@ public abstract class Trade implements Tradable, Model {
}
private void setDaemonConnection(MoneroRpcConnection connection) {
if (getWallet() == null) return;
log.info("Setting daemon connection for trade wallet {}: {}: ", getId() , connection == null ? null : connection.getUri());
if (getWallet() != null) getWallet().setDaemonConnection(connection);
MoneroWallet wallet = getWallet();
if (wallet == null) return;
log.info("Setting daemon connection for trade wallet {}: {}", getId() , connection == null ? null : connection.getUri());
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() {
if (!isIdling()) syncWallet();
if (!isIdling()) trySyncWallet();
else {
long startSyncingInMs = ThreadLocalRandom.current().nextLong(0, getWalletRefreshPeriod()); // random time to start syncing
UserThread.runAfter(() -> {
if (isInitialized) syncWallet();
if (isInitialized) trySyncWallet();
}, startSyncingInMs / 1000l);
}
}
@ -1525,7 +1588,7 @@ public abstract class Trade implements Tradable, Model {
if (isPayoutUnlocked()) return;
// rescan spent if deposits unlocked
if (isDepositUnlocked()) getWallet().rescanSpent();
if (isDepositsUnlocked()) getWallet().rescanSpent();
// get txs with outputs
List<MoneroTxWallet> txs;
@ -1538,7 +1601,7 @@ public abstract class Trade implements Tradable, Model {
}
// check deposit txs
if (!isDepositUnlocked()) {
if (!isDepositsUnlocked()) {
if (txs.size() == 2) {
setStateDepositsPublished();
boolean makerFirst = txs.get(0).getHash().equals(processModel.getMaker().getDepositTxHash());
@ -1585,19 +1648,22 @@ public abstract class Trade implements Tradable, Model {
}
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() {
if (!isDepositPublished()) setState(State.DEPOSIT_TXS_SEEN_IN_NETWORK);
if (!isDepositsPublished()) setState(State.DEPOSIT_TXS_SEEN_IN_NETWORK);
}
private void setStateDepositsConfirmed() {
if (!isDepositConfirmed()) setState(State.DEPOSIT_TXS_CONFIRMED_IN_BLOCKCHAIN);
if (!isDepositsConfirmed()) setState(State.DEPOSIT_TXS_CONFIRMED_IN_BLOCKCHAIN);
}
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() {
@ -1634,6 +1700,7 @@ public abstract class Trade implements Tradable, Model {
.map(msg -> msg.toProtoNetworkEnvelope().getChatMessage())
.collect(Collectors.toList()))
.setLockTime(lockTime)
.setStartTime(startTime)
.setUid(uid);
Optional.ofNullable(payoutTxId).ifPresent(builder::setPayoutTxId);
@ -1668,6 +1735,7 @@ public abstract class Trade implements Tradable, Model {
trade.setMediationResultState(MediationResultState.fromProto(proto.getMediationResultState()));
trade.setRefundResultState(RefundResultState.fromProto(proto.getRefundResultState()));
trade.setLockTime(proto.getLockTime());
trade.setStartTime(proto.getStartTime());
trade.setCounterCurrencyExtraData(ProtoUtil.stringOrNullFromProto(proto.getCounterCurrencyExtraData()));
AssetTxProofResult persistedAssetTxProofResult = ProtoUtil.enumFromProto(AssetTxProofResult.class, proto.getAssetTxProofResult());
@ -1722,6 +1790,7 @@ public abstract class Trade implements Tradable, Model {
",\n mediationResultState=" + mediationResultState +
",\n mediationResultStateProperty=" + mediationResultStateProperty +
",\n lockTime=" + lockTime +
",\n startTime=" + startTime +
",\n refundResultState=" + refundResultState +
",\n refundResultStateProperty=" + refundResultStateProperty +
"\n}";

View file

@ -369,6 +369,7 @@ public class TradeManager implements PersistedDataHost, DecryptedDirectMessageLi
xmrWalletService.swapTradeEntryToAvailableEntry(addressEntry.getOfferId(), addressEntry.getContext());
});
// notify that persisted trades initialized
persistedTradesInitialized.set(true);
// 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) {
if (trade.isDepositRequested() && !trade.isDepositPublished()) {
if (trade.isDepositRequested() && !trade.isDepositsPublished()) {
log.warn("Scheduling to delete trade if unfunded for {} {}", trade.getClass().getSimpleName(), trade.getId());
UserThread.runAfter(() -> {
if (isShutDown) return;

View file

@ -23,10 +23,8 @@ import bisq.network.p2p.NodeAddress;
import bisq.common.app.Version;
import bisq.common.proto.ProtoUtil;
import bisq.common.proto.network.NetworkEnvelope;
import java.util.Optional;
import java.util.UUID;
import lombok.EqualsAndHashCode;
import lombok.Getter;
@ -124,7 +122,7 @@ public final class PaymentReceivedMessage extends TradeMailboxMessage {
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
// are empty/null, so we check for the signature to see if we got a valid buyerSignedWitness.
protobuf.AccountAgeWitness protoAccountAgeWitness = proto.getBuyerAccountAgeWitness();

View file

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

View file

@ -55,7 +55,7 @@ public class BuyerProtocol extends DisputeProtocol {
// re-send payment sent message if not arrived
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();
given(anyPhase(Trade.Phase.PAYMENT_SENT)
.with(BuyerEvent.STARTUP))

View file

@ -31,10 +31,13 @@ import bisq.core.payment.payload.PaymentAccountPayload;
import bisq.core.proto.CoreProtoResolver;
import bisq.core.support.dispute.arbitration.arbitrator.ArbitratorManager;
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.trade.MakerTrade;
import bisq.core.trade.Trade;
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.statistics.ReferralIdService;
import bisq.core.trade.statistics.TradeStatisticsManager;
@ -43,7 +46,7 @@ import bisq.core.user.User;
import bisq.network.p2p.AckMessage;
import bisq.network.p2p.NodeAddress;
import bisq.network.p2p.P2PService;
import bisq.common.app.Version;
import bisq.common.crypto.KeyRing;
import bisq.common.crypto.PubKeyRing;
import bisq.common.proto.ProtoUtil;
@ -175,6 +178,18 @@ public class ProcessModel implements Model, PersistablePayload {
@Getter
@Setter
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
// 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(makerSignature).ifPresent(e -> builder.setMakerSignature(makerSignature));
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();
}
@ -267,6 +285,9 @@ public class ProcessModel implements Model, PersistablePayload {
MessageState paymentStartedMessageState = ProtoUtil.enumFromProto(MessageState.class, paymentStartedMessageStateString);
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;
}

View file

@ -51,7 +51,7 @@ public class SellerProtocol extends DisputeProtocol {
// re-send payment received message if not arrived
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();
given(anyPhase(Trade.Phase.PAYMENT_RECEIVED)
.with(SellerEvent.STARTUP))

View file

@ -74,7 +74,6 @@ import java.util.Comparator;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
import org.fxmisc.easybind.EasyBind;
@ -94,6 +93,7 @@ public abstract class TradeProtocol implements DecryptedDirectMessageListener, D
protected TradeResultHandler tradeResultHandler;
protected ErrorMessageHandler errorMessageHandler;
private int reprocessPaymentReceivedMessageCount;
///////////////////////////////////////////////////////////////////////////////////////////
// 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) {
@ -462,17 +479,23 @@ public abstract class TradeProtocol implements DecryptedDirectMessageListener, D
// received by buyer and arbitrator
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)");
if (!(trade instanceof BuyerTrade || trade instanceof ArbitratorTrade)) {
log.warn("Ignoring PaymentReceivedMessage since not buyer or arbitrator");
return;
}
if (trade instanceof ArbitratorTrade && !trade.isPayoutUnlocked()) trade.syncWallet(); // arbitrator syncs slowly after deposits confirmed
synchronized (trade) {
latchTrade();
Validator.checkTradeId(processModel.getOfferId(), 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)
.from(peer))
.setup(tasks(
@ -482,7 +505,19 @@ public abstract class TradeProtocol implements DecryptedDirectMessageListener, D
handleTaskRunnerSuccess(peer, message);
},
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);
awaitTradeLatch();
@ -548,8 +583,7 @@ public abstract class TradeProtocol implements DecryptedDirectMessageListener, D
private void onAckMessage(AckMessage ackMessage, NodeAddress peer) {
// 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
// TODO (woodser): add AckMessage for InitTradeRequest and support automatic re-send ?
if (ackMessage.getSourceMsgClassName().equals(PaymentSentMessage.class.getSimpleName())) {
if (ackMessage.getSourceMsgClassName().equals(PaymentSentMessage.class.getSimpleName()) && trade.getTradingPeer(peer) == trade.getSeller()) {
processModel.setPaymentStartedAckMessage(ackMessage);
}

View file

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

View file

@ -52,6 +52,13 @@ public class BuyerPreparePaymentSentMessage extends TradeTask {
try {
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
Preconditions.checkNotNull(trade.getSeller().getPaymentAccountPayload(), "Seller's payment account payload is null");
Preconditions.checkNotNull(trade.getAmount(), "trade.getTradeAmount() must not be null");

View file

@ -73,7 +73,7 @@ public abstract class BuyerSendPaymentSentMessage extends SendMailboxMessageTask
@Override
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
// 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);
byte[] sig = Sig.sign(processModel.getP2PService().getKeyRing().getSignatureKeyPair().getPrivate(), messageAsJson.getBytes(Charsets.UTF_8));
message.setBuyerSignature(sig);
trade.getSelf().setPaymentSentMessage(message);
processModel.setPaymentSentMessage(message);
trade.requestPersistence();
} catch (Exception e) {
throw new RuntimeException (e);
}
}
return trade.getSelf().getPaymentSentMessage();
return processModel.getPaymentSentMessage();
}
@Override

View file

@ -36,6 +36,8 @@ import static com.google.common.base.Preconditions.checkNotNull;
import java.util.ArrayList;
import java.util.List;
import org.apache.commons.lang3.StringUtils;
@Slf4j
public class ProcessPaymentReceivedMessage extends TradeTask {
public ProcessPaymentReceivedMessage(TaskRunner<Trade> taskHandler, Trade trade) {
@ -46,6 +48,7 @@ public class ProcessPaymentReceivedMessage extends TradeTask {
protected void run() {
try {
runInterceptHook();
log.debug("current trade state " + trade.getState());
PaymentReceivedMessage message = (PaymentReceivedMessage) processModel.getTradeMessage();
checkNotNull(message);
@ -54,6 +57,12 @@ public class ProcessPaymentReceivedMessage extends TradeTask {
// verify signature of payment received message
HavenoUtils.verifyPaymentReceivedMessage(trade, message);
// save message for reprocessing
processModel.setPaymentReceivedMessage(message);
trade.requestPersistence();
// set state
trade.getSeller().setUpdatedMultisigHex(message.getUpdatedMultisigHex());
trade.getBuyer().setUpdatedMultisigHex(message.getPaymentSentMessage().getUpdatedMultisigHex());
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
// 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);
for (Dispute dispute : trade.getDisputes()) {
dispute.setIsClosed();
}
}
// ensure connected to monero network
trade.checkWalletConnection();
// process payout tx unless already unlocked
if (!trade.isPayoutUnlocked()) processPayoutTx(message);
@ -83,25 +95,32 @@ public class ProcessPaymentReceivedMessage extends TradeTask {
// complete
trade.setStateIfProgress(Trade.State.SELLER_SENT_PAYMENT_RECEIVED_MSG); // arbitrator auto completes when payout published
processModel.getTradeManager().requestPersistence();
trade.requestPersistence();
complete();
} catch (Throwable t) {
// do not reprocess illegal argument
if (t instanceof IllegalArgumentException) {
processModel.setPaymentReceivedMessage(null); // do not reprocess
trade.requestPersistence();
}
failed(t);
}
}
private void processPayoutTx(PaymentReceivedMessage message) {
// sync and save wallet
trade.syncWallet();
trade.saveWallet();
// import multisig hex
List<String> updatedMultisigHexes = new ArrayList<String>();
if (trade.getSeller().getUpdatedMultisigHex() != null) updatedMultisigHexes.add(trade.getSeller().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();
// handle if payout tx not published
if (!trade.isPayoutPublished()) {
@ -110,18 +129,23 @@ public class ProcessPaymentReceivedMessage extends TradeTask {
if (trade instanceof ArbitratorTrade && !isSigned && message.isDeferPublishPayout()) {
log.info("Deferring signing and publishing payout tx for {} {}", trade.getClass().getSimpleName(), trade.getId());
GenUtils.waitFor(Trade.DEFER_PUBLISH_MS);
trade.syncWallet();
if (!trade.isPayoutUnlocked()) trade.syncWallet();
}
// verify and publish payout tx
if (!trade.isPayoutPublished()) {
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);
} else {
log.info("{} verifying, signing, and publishing seller's payout tx", trade.getClass().getSimpleName());
try {
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) {
if (trade.isPayoutPublished()) log.info("Payout tx already published for {} {}", trade.getClass().getName(), trade.getId());
else throw e;

View file

@ -44,10 +44,10 @@ public class ProcessPaymentSentMessage extends TradeTask {
// verify signature of payment sent message
HavenoUtils.verifyPaymentSentMessage(trade, message);
// update buyer info
// set state
processModel.setPaymentSentMessage(message);
trade.setPayoutTxHex(message.getPayoutTxHex());
trade.getBuyer().setUpdatedMultisigHex(message.getUpdatedMultisigHex());
trade.getBuyer().setPaymentSentMessage(message);
trade.getSeller().setAccountAgeWitness(message.getSellerAccountAgeWitness());
// if seller, decrypt buyer's payment account payload
@ -62,7 +62,7 @@ public class ProcessPaymentSentMessage extends TradeTask {
String counterCurrencyExtraData = message.getCounterCurrencyExtraData();
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);
processModel.getTradeManager().requestPersistence();
trade.requestPersistence();
complete();
} catch (Throwable t) {
failed(t);

View file

@ -17,7 +17,6 @@
package bisq.core.trade.protocol.tasks;
import bisq.core.btc.wallet.XmrWalletService;
import bisq.core.trade.Trade;
import java.util.ArrayList;
@ -42,6 +41,12 @@ public class SellerPreparePaymentReceivedMessage extends TradeTask {
try {
runInterceptHook();
// check connection
trade.checkWalletConnection();
// handle first time preparation
if (processModel.getPaymentReceivedMessage() == null) {
// import multisig hex
MoneroWallet multisigWallet = trade.getWallet();
List<String> updatedMultisigHexes = new ArrayList<String>();
@ -64,6 +69,12 @@ public class SellerPreparePaymentReceivedMessage extends TradeTask {
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();
complete();

View file

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

View file

@ -18,6 +18,8 @@
package bisq.core.trade.protocol.tasks;
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.network.p2p.NodeAddress;
import bisq.common.crypto.PubKeyRing;
@ -34,6 +36,15 @@ public class SellerSendPaymentReceivedMessageToBuyer extends SellerSendPaymentRe
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() {
return trade.getBuyer().getNodeAddress();
}

View file

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

View file

@ -409,7 +409,7 @@ public class OfferDetailsWindow extends Overlay<OfferDetailsWindow> {
placeOfferHandlerOptional.ifPresent(Runnable::run);
} else {
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);
// update trade state progress
@ -417,7 +417,7 @@ public class OfferDetailsWindow extends Overlay<OfferDetailsWindow> {
Trade trade = tradeManager.getTrade(offer.getId());
if (trade == null) return;
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);
// unsubscribe when done

View file

@ -299,7 +299,7 @@ public class TradeDetailsWindow extends Overlay<TradeDetailsWindow> {
textArea.scrollTopProperty().addListener(changeListener);
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,
@ -322,10 +322,13 @@ public class TradeDetailsWindow extends Overlay<TradeDetailsWindow> {
viewContractButton.setOnAction(e -> {
TextArea textArea = new HavenoTextArea();
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 += "\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.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.BuyerTrade;
import bisq.core.trade.ClosedTradableManager;
import bisq.core.trade.Contract;
import bisq.core.trade.HavenoUtils;
import bisq.core.trade.SellerTrade;
import bisq.core.trade.Trade;
@ -433,21 +432,19 @@ public class PendingTradesViewModel extends ActivatableWithDataModel<PendingTrad
buyerState.set(BuyerState.STEP2);
break;
// seller step 3
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
// payment received
case SELLER_SENT_PAYMENT_RECEIVED_MSG:
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;
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:
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;
case TRADE_COMPLETED:

View file

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

View file

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

View file

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

View file

@ -37,7 +37,7 @@ public class SellerStep1View extends TradeStepView {
super.onPendingTradesInitialized();
//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?
checkForTimeout();
checkForUnconfirmedTimeout();
}
///////////////////////////////////////////////////////////////////////////////////////////

View file

@ -306,11 +306,16 @@ public class SellerStep3View extends TradeStepView {
HBox hBox = tuple.fourth;
GridPane.setColumnSpan(tuple.fourth, 2);
confirmButton = tuple.first;
confirmButton.setDisable(!confirmPaymentReceivedPermitted());
confirmButton.setOnAction(e -> onPaymentReceived());
busyAnimation = tuple.second;
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
@ -357,7 +362,7 @@ public class SellerStep3View extends TradeStepView {
protected void updateDisputeState(Trade.DisputeState 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());
busyAnimation.play();
statusLabel.setText(Res.get("shared.sendingConfirmation"));
confirmButton.setDisable(true);
model.dataModel.onPaymentReceived(() -> {
}, errorMessage -> {
busyAnimation.stop();
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.Trade;
import bisq.core.trade.TradeManager;
import bisq.core.trade.Trade.DisputeState;
import bisq.core.user.Preferences;
import bisq.core.util.FormattingUtils;
import bisq.core.util.coin.CoinFormatter;
@ -1341,18 +1342,21 @@ public abstract class DisputeView extends ActivatableView<VBox, Void> {
ReadOnlyBooleanProperty closedProperty;
ChangeListener<Boolean> listener;
Subscription subscription;
@Override
public void updateItem(final Dispute item, boolean empty) {
super.updateItem(item, empty);
UserThread.execute(() -> {
if (item != null && !empty) {
if (closedProperty != null) {
closedProperty.removeListener(listener);
if (closedProperty != null) closedProperty.removeListener(listener);
if (subscription != null) {
subscription.unsubscribe();
subscription = null;
}
listener = (observable, oldValue, newValue) -> {
setText(newValue ? Res.get("support.closed") : Res.get("support.open"));
setText(getDisputeStateText(item));
if (getTableRow() != null)
getTableRow().setOpacity(newValue && item.getBadgeCountProperty().get() == 0 ? 0.4 : 1);
if (item.isClosed() && item == chatPopup.getSelectedDispute())
@ -1361,14 +1365,23 @@ public abstract class DisputeView extends ActivatableView<VBox, Void> {
closedProperty = item.isClosedProperty();
closedProperty.addListener(listener);
boolean isClosed = item.isClosed();
setText(isClosed ? Res.get("support.closed") : Res.get("support.open"));
setText(getDisputeStateText(item));
if (getTableRow() != null)
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 {
if (closedProperty != null) {
closedProperty.removeListener(listener);
closedProperty = null;
}
if (subscription != null) {
subscription.unsubscribe();
subscription = null;
}
setText("");
}
});
@ -1379,6 +1392,33 @@ public abstract class DisputeView extends ActivatableView<VBox, Void> {
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) {
chatPopup.openChat(dispute, getConcreteDisputeChatSession(dispute), getCounterpartyName());
dispute.setDisputeSeen(senderFlag());

View file

@ -738,11 +738,18 @@ public class GUIUtil {
return false;
}
try {
connectionService.verifyConnection();
} catch (Exception e) {
new Popup().information(e.getMessage()).show();
return false;
}
return true;
}
public static boolean isChainHeightSyncedWithinToleranceOrShowPopup(CoreMoneroConnectionsService connectionService) {
if (!connectionService.isChainHeightSyncedWithinTolerance()) {
if (!connectionService.isSyncedWithinTolerance()) {
new Popup().information(Res.get("popup.warning.chainNotSynced")).show();
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 payout_state = 20;
string dispute_state = 21;
bool is_deposit_published = 22;
bool is_deposit_confirmed = 23;
bool is_deposit_unlocked = 24;
bool is_deposits_published = 22;
bool is_deposits_confirmed = 23;
bool is_deposits_unlocked = 24;
bool is_payment_sent = 25;
bool is_payment_received = 26;
bool is_payout_published = 27;

View file

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