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

@ -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;
}