diff --git a/client/core/core.go b/client/core/core.go index b3884390ea..233ae8d42c 100644 --- a/client/core/core.go +++ b/client/core/core.go @@ -10665,9 +10665,9 @@ func (c *Core) deleteRequestedAction(uniqueID string) { c.requestedActionMtx.Unlock() } -// handleRetryRedemptionAction handles a response to a user response to an -// ActionRequiredNote for a rejected redemption transaction. -func (c *Core) handleRetryRedemptionAction(actionB []byte) error { +// handleRetryTxAction handles a response to a user response to an +// ActionRequiredNote for a rejected redemption or refund transaction. +func (c *Core) handleRetryTxAction(actionB []byte, isRedeem bool) error { var req struct { OrderID dex.Bytes `json:"orderID"` CoinID dex.Bytes `json:"coinID"` @@ -10703,23 +10703,31 @@ func (c *Core) handleRetryRedemptionAction(actionB []byte) error { coinID = match.MetaData.Proof.MakerRedeem } if bytes.Equal(coinID, req.CoinID) { - if match.Side == order.Taker && match.Status == order.MatchComplete { - // Try to redeem again. - match.redemptionRejected = false - match.MetaData.Proof.TakerRedeem = nil - match.Status = order.MakerRedeemed - if err := c.db.UpdateMatch(&match.MetaMatch); err != nil { - c.log.Errorf("Failed to update match in DB: %v", err) + if isRedeem { + if match.Side == order.Taker && match.Status == order.MatchComplete { + // Try to redeem again. + match.redemptionRejected = false + match.MetaData.Proof.TakerRedeem = nil + match.Status = order.MakerRedeemed + if err := c.db.UpdateMatch(&match.MetaMatch); err != nil { + c.log.Errorf("Failed to update match in DB: %v", err) + } + } else if match.Side == order.Maker && match.Status == order.MakerRedeemed { + match.redemptionRejected = false + match.MetaData.Proof.MakerRedeem = nil + match.Status = order.TakerSwapCast + if err := c.db.UpdateMatch(&match.MetaMatch); err != nil { + c.log.Errorf("Failed to update match in DB: %v", err) + } + } else { + c.log.Errorf("Redemption retry attempted for order side %s status %s", match.Side, match.Status) } - } else if match.Side == order.Maker && match.Status == order.MakerRedeemed { - match.redemptionRejected = false - match.MetaData.Proof.MakerRedeem = nil - match.Status = order.TakerSwapCast + } else { + match.MetaData.Proof.RefundCoin = nil + match.refundRejected = false if err := c.db.UpdateMatch(&match.MetaMatch); err != nil { c.log.Errorf("Failed to update match in DB: %v", err) } - } else { - c.log.Errorf("Redemption retry attempted for order side %s status %s", match.Side, match.Status) } } } @@ -10731,7 +10739,9 @@ func (c *Core) handleRetryRedemptionAction(actionB []byte) error { func (c *Core) handleCoreAction(actionID string, actionB json.RawMessage) ( /* handled */ bool, error) { switch actionID { case ActionIDRedeemRejected: - return true, c.handleRetryRedemptionAction(actionB) + return true, c.handleRetryTxAction(actionB, true) + case ActionIDRefundRejected: + return true, c.handleRetryTxAction(actionB, false) } return false, nil } diff --git a/client/core/core_test.go b/client/core/core_test.go index d470d38b54..dde74c1713 100644 --- a/client/core/core_test.go +++ b/client/core/core_test.go @@ -716,9 +716,9 @@ type TXCWallet struct { findBond *asset.BondDetails findBondErr error - confirmRedemptionResult *asset.ConfirmRedemptionStatus - confirmRedemptionErr error - confirmRedemptionCalled bool + confirmTxResult *asset.ConfirmTxStatus + confirmTxErr error + confirmTxCalled bool estFee uint64 estFeeErr error @@ -795,9 +795,9 @@ func (w *TXCWallet) Balance() (*asset.Balance, error) { return w.bal, nil } -func (w *TXCWallet) ConfirmRedemption(coinID dex.Bytes, redemption *asset.Redemption, feeSuggestion uint64) (*asset.ConfirmRedemptionStatus, error) { - w.confirmRedemptionCalled = true - return w.confirmRedemptionResult, w.confirmRedemptionErr +func (w *TXCWallet) ConfirmTransaction(coinID dex.Bytes, confirmTx *asset.ConfirmTx, feeSuggestion uint64) (*asset.ConfirmTxStatus, error) { + w.confirmTxCalled = true + return w.confirmTxResult, w.confirmTxErr } func (w *TXCWallet) FundOrder(ord *asset.Order) (asset.Coins, []dex.Bytes, uint64, error) { @@ -4537,8 +4537,8 @@ func TestTradeTracking(t *testing.T) { btcWallet.address = "12DXGkvxFjuq5btXYkwWfBZaz1rVwFgini" btcWallet.Unlock(rig.crypter) - tBtcWallet.confirmRedemptionErr = errors.New("") - tDcrWallet.confirmRedemptionErr = errors.New("") + tBtcWallet.confirmTxErr = errors.New("") + tDcrWallet.confirmTxErr = errors.New("") matchSize := 4 * dcrBtcLotSize cancelledQty := dcrBtcLotSize @@ -5348,6 +5348,7 @@ func TestRefunds(t *testing.T) { tCore.wallets[tACCTAsset.ID] = ethWallet ethWallet.address = "18d65fb8d60c1199bb1ad381be47aa692b482605" ethWallet.Unlock(rig.crypter) + tEthWallet.confirmTxResult = new(asset.ConfirmTxStatus) checkStatus := func(tag string, match *matchTracker, wantStatus order.MatchStatus) { t.Helper() @@ -8740,7 +8741,7 @@ func TestMatchStatusResolution(t *testing.T) { } } -func TestConfirmRedemption(t *testing.T) { +func TestConfirmTx(t *testing.T) { rig := newTestRig() defer rig.shutdown() dc := rig.dc @@ -8769,7 +8770,7 @@ func TestConfirmRedemption(t *testing.T) { tBtcWallet.redeemCoins = []dex.Bytes{tUpdatedCoinID} ourContract := encode.RandomBytes(90) - setupMatch := func(status order.MatchStatus, side order.MatchSide) { + setupMatch := func(status order.MatchStatus, side order.MatchSide, isRefunded bool) { matchID := ordertest.RandomMatchID() _, auditInfo := tMsgAudit(oid, matchID, addr, 0, secretHash[:]) matchTime := time.Now() @@ -8827,9 +8828,17 @@ func TestConfirmRedemption(t *testing.T) { proof.Secret = secret } } + if status == order.MatchComplete { + proof.TakerRedeem = tCoinID + } if status >= order.MatchComplete { proof.TakerRedeem = tCoinID } + if isRefunded { + proof.RefundCoin = tCoinID + } else { + proof.RefundCoin = nil + } } type note struct { @@ -8838,19 +8847,21 @@ func TestConfirmRedemption(t *testing.T) { } tests := []struct { - name string - matchStatus order.MatchStatus - matchSide order.MatchSide - expectedNotifications []*note - confirmRedemptionResult *asset.ConfirmRedemptionStatus - confirmRedemptionErr error - - expectConfirmRedemptionCalled bool - expectedStatus order.MatchStatus - expectTicksDelayed bool + name string + matchStatus order.MatchStatus + matchSide order.MatchSide + expectedNotifications []*note + confirmTxResult, refundConfirmTxResult *asset.ConfirmTxStatus + confirmTxErr, refundConfirmTxErr error + + expectConfirmTxCalled, expectRefundConfirmTxCalled bool + expectedStatus order.MatchStatus + expectTicksDelayed bool + + isRefund bool }{ { - name: "maker, makerRedeemed, confirmedRedemption", + name: "maker, makerRedeemed, confirmedTx", matchStatus: order.MakerRedeemed, matchSide: order.Maker, expectedNotifications: []*note{ @@ -8859,13 +8870,13 @@ func TestConfirmRedemption(t *testing.T) { topic: TopicRedemptionConfirmed, }, }, - confirmRedemptionResult: &asset.ConfirmRedemptionStatus{ + confirmTxResult: &asset.ConfirmTxStatus{ Confs: 10, Req: 10, CoinID: tCoinID, }, - expectConfirmRedemptionCalled: true, - expectedStatus: order.MatchConfirmed, + expectConfirmTxCalled: true, + expectedStatus: order.MatchConfirmed, }, { name: "maker, makerRedeemed, confirmedRedemption, more confs than required", @@ -8877,13 +8888,13 @@ func TestConfirmRedemption(t *testing.T) { topic: TopicRedemptionConfirmed, }, }, - confirmRedemptionResult: &asset.ConfirmRedemptionStatus{ + confirmTxResult: &asset.ConfirmTxStatus{ Confs: 15, Req: 10, CoinID: tCoinID, }, - expectConfirmRedemptionCalled: true, - expectedStatus: order.MatchConfirmed, + expectConfirmTxCalled: true, + expectedStatus: order.MatchConfirmed, }, { name: "taker, matchComplete, confirmedRedemption", @@ -8895,13 +8906,13 @@ func TestConfirmRedemption(t *testing.T) { topic: TopicRedemptionConfirmed, }, }, - confirmRedemptionResult: &asset.ConfirmRedemptionStatus{ + confirmTxResult: &asset.ConfirmTxStatus{ Confs: 10, Req: 10, CoinID: tCoinID, }, - expectConfirmRedemptionCalled: true, - expectedStatus: order.MatchConfirmed, + expectConfirmTxCalled: true, + expectedStatus: order.MatchConfirmed, }, { name: "maker, makerRedeemed, incomplete", @@ -8913,13 +8924,13 @@ func TestConfirmRedemption(t *testing.T) { topic: TopicConfirms, }, }, - confirmRedemptionResult: &asset.ConfirmRedemptionStatus{ + confirmTxResult: &asset.ConfirmTxStatus{ Confs: 5, Req: 10, CoinID: tCoinID, }, - expectConfirmRedemptionCalled: true, - expectedStatus: order.MakerRedeemed, + expectConfirmTxCalled: true, + expectedStatus: order.MakerRedeemed, }, { name: "maker, makerRedeemed, replacedTx", @@ -8935,13 +8946,13 @@ func TestConfirmRedemption(t *testing.T) { topic: TopicConfirms, }, }, - confirmRedemptionResult: &asset.ConfirmRedemptionStatus{ + confirmTxResult: &asset.ConfirmTxStatus{ Confs: 0, Req: 10, CoinID: tUpdatedCoinID, }, - expectConfirmRedemptionCalled: true, - expectedStatus: order.MakerRedeemed, + expectConfirmTxCalled: true, + expectedStatus: order.MakerRedeemed, }, { name: "taker, matchComplete, replacedTx", @@ -8957,13 +8968,13 @@ func TestConfirmRedemption(t *testing.T) { topic: TopicConfirms, }, }, - confirmRedemptionResult: &asset.ConfirmRedemptionStatus{ + confirmTxResult: &asset.ConfirmTxStatus{ Confs: 0, Req: 10, CoinID: tUpdatedCoinID, }, - expectConfirmRedemptionCalled: true, - expectedStatus: order.MatchComplete, + expectConfirmTxCalled: true, + expectedStatus: order.MatchComplete, }, { // This case could happen if the dex was shut down right after @@ -8981,90 +8992,260 @@ func TestConfirmRedemption(t *testing.T) { topic: TopicRedemptionConfirmed, }, }, - confirmRedemptionResult: &asset.ConfirmRedemptionStatus{ + confirmTxResult: &asset.ConfirmTxStatus{ Confs: 10, Req: 10, CoinID: tUpdatedCoinID, }, - expectConfirmRedemptionCalled: true, - expectedStatus: order.MatchConfirmed, + expectConfirmTxCalled: true, + expectedStatus: order.MatchConfirmed, }, { - name: "maker, makerRedeemed, error", - matchStatus: order.MakerRedeemed, - matchSide: order.Maker, - confirmRedemptionErr: errors.New("err"), - expectedStatus: order.MakerRedeemed, - expectTicksDelayed: true, - expectConfirmRedemptionCalled: true, + name: "maker, makerRedeemed, error", + matchStatus: order.MakerRedeemed, + matchSide: order.Maker, + confirmTxErr: errors.New("err"), + expectedStatus: order.MakerRedeemed, + expectTicksDelayed: true, + expectConfirmTxCalled: true, }, { - name: "maker, makerRedeemed, swap refunded error", - matchStatus: order.MakerRedeemed, - matchSide: order.Maker, - confirmRedemptionErr: asset.ErrSwapRefunded, - expectedStatus: order.MatchConfirmed, + name: "maker, makerRedeemed, swap refunded error", + matchStatus: order.MakerRedeemed, + matchSide: order.Maker, + confirmTxErr: asset.ErrSwapRefunded, + expectedStatus: order.MatchConfirmed, expectedNotifications: []*note{ { severity: db.ErrorLevel, topic: TopicSwapRefunded, }, }, - expectConfirmRedemptionCalled: true, + expectConfirmTxCalled: true, }, { - name: "taker, takerRedeemed, redemption tx rejected error", - matchStatus: order.MatchComplete, - matchSide: order.Taker, - confirmRedemptionErr: asset.ErrTxRejected, - expectedStatus: order.MatchComplete, + name: "taker, takerRedeemed, redemption tx rejected error", + matchStatus: order.MatchComplete, + matchSide: order.Taker, + confirmTxErr: asset.ErrTxRejected, + expectedStatus: order.MatchComplete, expectedNotifications: []*note{ { severity: db.Data, topic: TopicRedeemRejected, }, }, - expectConfirmRedemptionCalled: true, + expectConfirmTxCalled: true, + }, + { + name: "maker, makerRedeemed, redemption tx lost", + matchStatus: order.MakerRedeemed, + matchSide: order.Maker, + confirmTxErr: asset.ErrTxLost, + expectedStatus: order.TakerSwapCast, + expectConfirmTxCalled: true, + }, + { + name: "taker, takerRedeemed, redemption tx lost", + matchStatus: order.MatchComplete, + matchSide: order.Taker, + confirmTxErr: asset.ErrTxLost, + expectedStatus: order.MakerRedeemed, + expectConfirmTxCalled: true, + }, + { + name: "maker, matchConfirmed", + matchStatus: order.MatchConfirmed, + matchSide: order.Maker, + expectedStatus: order.MatchConfirmed, + expectedNotifications: []*note{}, + expectConfirmTxCalled: false, + }, + { + name: "maker, TakerSwapCast", + matchStatus: order.TakerSwapCast, + matchSide: order.Maker, + expectedStatus: order.TakerSwapCast, + expectedNotifications: []*note{}, + expectConfirmTxCalled: false, + }, + { + name: "taker, TakerSwapCast", + matchStatus: order.TakerSwapCast, + matchSide: order.Taker, + expectedStatus: order.TakerSwapCast, + expectedNotifications: []*note{}, + expectConfirmTxCalled: false, + }, + { + name: "maker, taker swap cast, confirmedTx, refund", + matchStatus: order.MakerSwapCast, + matchSide: order.Maker, + expectedNotifications: []*note{ + { + severity: db.Success, + topic: TopicRefundConfirmed, + }, + }, + refundConfirmTxResult: &asset.ConfirmTxStatus{ + Confs: 10, + Req: 10, + CoinID: tCoinID, + }, + expectRefundConfirmTxCalled: true, + expectedStatus: order.MatchConfirmed, + isRefund: true, + }, + { + name: "taker, takerSwapCast, confirmedTx, refund", + matchStatus: order.TakerSwapCast, + matchSide: order.Taker, + expectedNotifications: []*note{ + { + severity: db.Success, + topic: TopicRefundConfirmed, + }, + }, + refundConfirmTxResult: &asset.ConfirmTxStatus{ + Confs: 10, + Req: 10, + CoinID: tCoinID, + }, + expectRefundConfirmTxCalled: true, + expectedStatus: order.MatchConfirmed, + isRefund: true, + }, + { + name: "maker, makerSwapCast, incomplete, refund", + matchStatus: order.MakerSwapCast, + matchSide: order.Maker, + expectedNotifications: []*note{ + { + severity: db.Data, + topic: TopicConfirms, + }, + }, + refundConfirmTxResult: &asset.ConfirmTxStatus{ + Confs: 5, + Req: 10, + CoinID: tCoinID, + }, + expectRefundConfirmTxCalled: true, + expectedStatus: order.MakerSwapCast, + isRefund: true, + }, + { + name: "taker, takerSwapCast, replacedTx, refund", + matchStatus: order.TakerSwapCast, + matchSide: order.Taker, + expectedNotifications: []*note{ + { + severity: db.WarningLevel, + topic: TopicRefundResubmitted, + }, + { + severity: db.Data, + topic: TopicConfirms, + }, + }, + refundConfirmTxResult: &asset.ConfirmTxStatus{ + Confs: 0, + Req: 10, + CoinID: tUpdatedCoinID, + }, + expectRefundConfirmTxCalled: true, + expectedStatus: order.TakerSwapCast, + isRefund: true, + }, + { + name: "maker, makerSwapCast, replacedTx confirmed, refund", + matchStatus: order.MakerSwapCast, + matchSide: order.Maker, + expectedNotifications: []*note{ + { + severity: db.WarningLevel, + topic: TopicRefundResubmitted, + }, + { + severity: db.Success, + topic: TopicRefundConfirmed, + }, + }, + refundConfirmTxResult: &asset.ConfirmTxStatus{ + Confs: 15, + Req: 10, + CoinID: tUpdatedCoinID, + }, + expectRefundConfirmTxCalled: true, + expectedStatus: order.MatchConfirmed, + isRefund: true, }, { - name: "maker, makerRedeemed, redemption tx lost", - matchStatus: order.MakerRedeemed, - matchSide: order.Maker, - confirmRedemptionErr: asset.ErrTxLost, - expectedStatus: order.TakerSwapCast, - expectConfirmRedemptionCalled: true, + name: "maker, makerSwapCast, error", + matchStatus: order.MakerSwapCast, + matchSide: order.Maker, + refundConfirmTxErr: errors.New("err"), + expectedStatus: order.MakerSwapCast, + expectTicksDelayed: true, + expectRefundConfirmTxCalled: true, + isRefund: true, }, { - name: "taker, takerRedeemed, redemption tx lost", - matchStatus: order.MatchComplete, - matchSide: order.Taker, - confirmRedemptionErr: asset.ErrTxLost, - expectedStatus: order.MakerRedeemed, - expectConfirmRedemptionCalled: true, + name: "maker, makerSwapCast, swap redeemed error", + matchStatus: order.MakerSwapCast, + matchSide: order.Maker, + refundConfirmTxErr: asset.ErrSwapRedeemed, + expectedStatus: order.MatchConfirmed, + expectedNotifications: []*note{ + { + severity: db.ErrorLevel, + topic: TopicSwapRedeemed, + }, + }, + expectRefundConfirmTxCalled: true, + isRefund: true, }, { - name: "maker, matchConfirmed", - matchStatus: order.MatchConfirmed, - matchSide: order.Maker, - expectedStatus: order.MatchConfirmed, - expectedNotifications: []*note{}, - expectConfirmRedemptionCalled: false, + name: "taker, takerSwapCast, refund tx rejected error", + matchStatus: order.TakerSwapCast, + matchSide: order.Taker, + refundConfirmTxErr: asset.ErrTxRejected, + expectedStatus: order.TakerSwapCast, + expectedNotifications: []*note{ + { + severity: db.Data, + topic: TopicRefundRejected, + }, + }, + expectRefundConfirmTxCalled: true, + isRefund: true, + }, + { + name: "maker, makerSwapCast, refund tx lost", + matchStatus: order.MakerSwapCast, + matchSide: order.Maker, + refundConfirmTxErr: asset.ErrTxLost, + expectedStatus: order.MakerSwapCast, + expectRefundConfirmTxCalled: true, + isRefund: true, }, { - name: "maker, TakerSwapCast", - matchStatus: order.TakerSwapCast, - matchSide: order.Maker, - expectedStatus: order.TakerSwapCast, - expectedNotifications: []*note{}, - expectConfirmRedemptionCalled: false, + name: "taker, takerSwapCast, refund tx lost", + matchStatus: order.TakerSwapCast, + matchSide: order.Taker, + refundConfirmTxErr: asset.ErrTxLost, + expectedStatus: order.TakerSwapCast, + expectRefundConfirmTxCalled: true, + isRefund: true, }, { - name: "taker, TakerSwapCast", - matchStatus: order.TakerSwapCast, - matchSide: order.Taker, - expectedStatus: order.TakerSwapCast, - expectedNotifications: []*note{}, - expectConfirmRedemptionCalled: false, + name: "maker, matchConfirmed, refund", + matchStatus: order.MatchConfirmed, + matchSide: order.Maker, + expectedStatus: order.MatchConfirmed, + expectedNotifications: []*note{}, + expectConfirmTxCalled: false, + isRefund: true, }, } @@ -9072,18 +9253,27 @@ func TestConfirmRedemption(t *testing.T) { for _, test := range tests { tracker.mtx.Lock() - setupMatch(test.matchStatus, test.matchSide) + setupMatch(test.matchStatus, test.matchSide, test.isRefund) tracker.mtx.Unlock() - tBtcWallet.confirmRedemptionResult = test.confirmRedemptionResult - tBtcWallet.confirmRedemptionErr = test.confirmRedemptionErr - tBtcWallet.confirmRedemptionCalled = false + tBtcWallet.confirmTxResult = test.confirmTxResult + tBtcWallet.confirmTxErr = test.confirmTxErr + tBtcWallet.confirmTxCalled = false + + tDcrWallet.confirmTxResult = test.refundConfirmTxResult + tDcrWallet.confirmTxErr = test.refundConfirmTxErr + tDcrWallet.confirmTxCalled = false tCore.tickAsset(dc, tUTXOAssetB.ID) - if tBtcWallet.confirmRedemptionCalled != test.expectConfirmRedemptionCalled { - t.Fatalf("%s: expected confirm redemption to be called=%v but got=%v", - test.name, test.expectConfirmRedemptionCalled, tBtcWallet.confirmRedemptionCalled) + if tBtcWallet.confirmTxCalled != test.expectConfirmTxCalled { + t.Fatalf("%s: expected confirm tx for redemption to be called=%v but got=%v", + test.name, test.expectConfirmTxCalled, tBtcWallet.confirmTxCalled) + } + + if tDcrWallet.confirmTxCalled != test.expectRefundConfirmTxCalled { + t.Fatalf("%s: expected confirm tx for refund to be called=%v but got=%v", + test.name, test.expectRefundConfirmTxCalled, tDcrWallet.confirmTxCalled) } for _, expectedNotification := range test.expectedNotifications { @@ -9107,23 +9297,30 @@ func TestConfirmRedemption(t *testing.T) { } tracker.mtx.RLock() - if test.confirmRedemptionResult != nil { + if test.confirmTxResult != nil { var redeemCoin order.CoinID if test.matchSide == order.Maker { redeemCoin = match.MetaData.Proof.MakerRedeem } else { redeemCoin = match.MetaData.Proof.TakerRedeem } - if !bytes.Equal(redeemCoin, test.confirmRedemptionResult.CoinID) { - t.Fatalf("%s: expected coin %v != actual %v", test.name, test.confirmRedemptionResult.CoinID, redeemCoin) + if !bytes.Equal(redeemCoin, test.confirmTxResult.CoinID) { + t.Fatalf("%s: expected coin %v != actual %v", test.name, test.confirmTxResult.CoinID, redeemCoin) } - if test.confirmRedemptionResult.Confs >= test.confirmRedemptionResult.Req { + if test.confirmTxResult.Confs >= test.confirmTxResult.Req { if len(tDcrWallet.returnedContracts) != 1 || !bytes.Equal(ourContract, tDcrWallet.returnedContracts[0]) { t.Fatalf("%s: refund address not returned", test.name) } } } + if test.refundConfirmTxResult != nil { + refundCoinID := match.MetaData.Proof.RefundCoin + if !bytes.Equal(refundCoinID, test.refundConfirmTxResult.CoinID) { + t.Fatalf("%s: expected coin %v != actual %v", test.name, test.refundConfirmTxResult.CoinID, refundCoinID) + } + } + ticksDelayed := match.tickGovernor != nil if ticksDelayed != test.expectTicksDelayed { t.Fatalf("%s: expected ticks delayed %v but got %v", test.name, test.expectTicksDelayed, ticksDelayed) diff --git a/client/core/notification.go b/client/core/notification.go index f4d0f844fe..4cf0aa030c 100644 --- a/client/core/notification.go +++ b/client/core/notification.go @@ -435,8 +435,11 @@ const ( TopicCounterConfirms Topic = "CounterConfirms" TopicConfirms Topic = "Confirms" TopicRedemptionResubmitted Topic = "RedemptionResubmitted" + TopicRefundResubmitted Topic = "RefundResubmitted" TopicSwapRefunded Topic = "SwapRefunded" + TopicSwapRedeemed Topic = "SwapRefunded" TopicRedemptionConfirmed Topic = "RedemptionConfirmed" + TopicRefundConfirmed Topic = "RefundConfirmed" ) func newMatchNote(topic Topic, subject, details string, severity db.Severity, t *trackedTrade, match *matchTracker) *MatchNote { @@ -451,7 +454,8 @@ func newMatchNote(topic Topic, subject, details string, severity db.Severity, t OrderID: t.ID().Bytes(), Match: matchFromMetaMatchWithConfs(t.Order, &match.MetaMatch, swapConfs, int64(t.metaData.FromSwapConf), counterConfs, int64(t.metaData.ToSwapConf), - int64(match.redemptionConfs), int64(match.redemptionConfsReq)), + int64(match.redemptionConfs), int64(match.redemptionConfsReq), + int64(match.refundConfs), int64(match.refundConfsReq)), Host: t.dc.acct.host, MarketID: marketName(t.Base(), t.Quote()), } @@ -761,6 +765,8 @@ func newUnknownBondTierZeroNote(subject, details string) *db.Notification { const ( ActionIDRedeemRejected = "redeemRejected" TopicRedeemRejected = "RedeemRejected" + ActionIDRefundRejected = "refundRejected" + TopicRefundRejected = "RefundRejected" ) func newActionRequiredNote(actionID, uniqueID string, payload any) *asset.ActionRequiredNote { @@ -774,28 +780,36 @@ func newActionRequiredNote(actionID, uniqueID string, payload any) *asset.Action return n } -type RejectedRedemptionData struct { +type RejectedTxData struct { OrderID dex.Bytes `json:"orderID"` CoinID dex.Bytes `json:"coinID"` AssetID uint32 `json:"assetID"` CoinFmt string `json:"coinFmt"` + TxType string `json:"txType"` } // ActionRequiredNote is structured like a WalletNote. The payload will be // an *asset.ActionRequiredNote. This is done for compatibility reasons. type ActionRequiredNote WalletNote -func newRejectedRedemptionNote(assetID uint32, oid order.OrderID, coinID []byte) (*asset.ActionRequiredNote, *ActionRequiredNote) { - data := &RejectedRedemptionData{ +func newRejectedTxNote(assetID uint32, oid order.OrderID, coinID []byte, txType asset.ConfirmTxType) (*asset.ActionRequiredNote, *ActionRequiredNote) { + data := &RejectedTxData{ AssetID: assetID, OrderID: oid[:], CoinID: coinID, CoinFmt: coinIDString(assetID, coinID), + TxType: txType.String(), } uniqueID := dex.Bytes(coinID).String() - actionNote := newActionRequiredNote(ActionIDRedeemRejected, uniqueID, data) + actionID := ActionIDRedeemRejected + topic := db.Topic(TopicRedeemRejected) + if txType == asset.CTRefund { + actionID = ActionIDRefundRejected + topic = TopicRefundRejected + } + actionNote := newActionRequiredNote(actionID, uniqueID, data) coreNote := &ActionRequiredNote{ - Notification: db.NewNotification(NoteTypeActionRequired, TopicRedeemRejected, "", "", db.Data), + Notification: db.NewNotification(NoteTypeActionRequired, topic, "", "", db.Data), Payload: actionNote, } return actionNote, coreNote diff --git a/client/core/trade.go b/client/core/trade.go index e69489b8d0..32199d7f87 100644 --- a/client/core/trade.go +++ b/client/core/trade.go @@ -107,6 +107,18 @@ type matchTracker struct { // if we redeem as taker anyway. matchCompleteSent bool + // confirmRefundNumTries is just used for logging. + confirmRefundNumTries int + // refundConfs and refundConfsReq are updated while the refund + // confirmation process is running. Their values are not updated after the + // match reaches MatchConfirmed status. + refundConfs uint64 + refundConfsReq uint64 + // refundRejected will be true if a refund tx was rejected. A + // a rejected tx may indicate a serious internal issue, so we will seek + // user approval before replacing the tx. + refundRejected bool + // The fields below need to be modified without the parent trackedTrade's // mutex being write locked, so they have dedicated mutexes. @@ -673,7 +685,8 @@ func (t *trackedTrade) coreOrderInternal() *Order { corder.Matches = append(corder.Matches, matchFromMetaMatchWithConfs(t, &mt.MetaMatch, swapConfs, int64(t.metaData.FromSwapConf), counterConfs, int64(t.metaData.ToSwapConf), - int64(mt.redemptionConfs), int64(mt.redemptionConfsReq))) + int64(mt.redemptionConfs), int64(mt.redemptionConfsReq), + int64(mt.refundConfs), int64(mt.refundConfsReq))) } corder.AllFeesConfirmed = allFeesConfirmed @@ -1843,6 +1856,23 @@ func shouldConfirmRedemption(match *matchTracker) bool { return len(proof.TakerRedeem) > 0 } +// shouldConfirmRefund will return true if a refund transaction has been +// broadcast, but it has not yet been confirmed. +// +// This method accesses match fields and MUST be called with the trackedTrade +// mutex lock held for reads. +func shouldConfirmRefund(match *matchTracker) bool { + if match.Status == order.MatchConfirmed { + return false + } + + if match.refundRejected { + return false + } + + return len(match.MetaData.Proof.RefundCoin) > 0 +} + // tick will check for and perform any match actions necessary. func (c *Core) tick(t *trackedTrade) (assetMap, error) { assets := make(assetMap) // callers expect non-nil map even on error :( @@ -1866,7 +1896,7 @@ func (c *Core) tick(t *trackedTrade) (assetMap, error) { tLock = time.Since(tStart) var swaps, redeems, refunds, revokes, searches, redemptionConfirms, - dynamicSwapFeeConfirms, dynamicRedemptionFeeConfirms []*matchTracker + refundConfirms, dynamicSwapFeeConfirms, dynamicRedemptionFeeConfirms []*matchTracker var sent, quoteSent, received, quoteReceived uint64 checkMatch := func(match *matchTracker) error { // only errors on context.DeadlineExceeded or context.Canceled @@ -1955,6 +1985,11 @@ func (c *Core) tick(t *trackedTrade) (assetMap, error) { return nil } + if shouldConfirmRefund(match) { + refundConfirms = append(refundConfirms, match) + return nil + } + // For certain "self-governed" trades where the market or server has // vanished, we should revoke the match to allow it to retire without // having sent any pending redeem requests. Note that self-governed is @@ -2028,7 +2063,8 @@ func (c *Core) tick(t *trackedTrade) (assetMap, error) { if !rmCancel && len(swaps) == 0 && len(refunds) == 0 && len(redeems) == 0 && len(revokes) == 0 && len(searches) == 0 && len(redemptionConfirms) == 0 && - len(dynamicSwapFeeConfirms) == 0 && len(dynamicRedemptionFeeConfirms) == 0 { + len(refundConfirms) == 0 && len(dynamicSwapFeeConfirms) == 0 && + len(dynamicRedemptionFeeConfirms) == 0 { return assets, nil // nothing to do, don't acquire the write-lock } @@ -2140,6 +2176,12 @@ func (c *Core) tick(t *trackedTrade) (assetMap, error) { c.confirmRedemptions(t, redemptionConfirms) } + if len(refundConfirms) > 0 { + for _, match := range refundConfirms { + c.confirmRefund(t, match) + } + } + for _, match := range dynamicSwapFeeConfirms { t.updateDynamicSwapOrRedemptionFeesPaid(c.ctx, match, true) } @@ -2974,6 +3016,23 @@ func (t *trackedTrade) redeemFee() uint64 { return feeSuggestion } +func (t *trackedTrade) refundFee() uint64 { + // Try not to use (*Core).feeSuggestion here, since it can incur an RPC + // request to the server. t.redeemFeeSuggestion is updated every tick and + // uses a rate directly from our wallet, if available. Only go looking for + // one if we don't have one cached. + var feeSuggestion uint64 + if _, is := t.accountRefunder(); is { + feeSuggestion = t.metaData.MaxFeeRate + } else { + feeSuggestion = t.redeemFeeSuggestion.get() + } + if feeSuggestion == 0 { + feeSuggestion = t.dc.bestBookFeeSuggestion(t.wallets.fromWallet.AssetID) + } + return feeSuggestion +} + // confirmRedemption attempts to confirm the redemptions for each match, and // then return any refund addresses that we won't be using. func (c *Core) confirmRedemptions(t *trackedTrade, matches []*matchTracker) { @@ -3043,10 +3102,8 @@ func (c *Core) confirmRedemption(t *trackedTrade, match *matchTracker) (bool, er match.confirmRedemptionNumTries++ - redemptionStatus, err := toWallet.Wallet.ConfirmRedemption(dex.Bytes(redeemCoinID), &asset.Redemption{ - Spends: match.counterSwap, - Secret: proof.Secret, - }, t.redeemFee()) + redemptionStatus, err := toWallet.Wallet.ConfirmTransaction(dex.Bytes(redeemCoinID), + asset.NewRedeemConfTx(match.counterSwap, proof.Secret), t.redeemFee()) switch { case err == nil: case errors.Is(err, asset.ErrSwapRefunded): @@ -3064,7 +3121,7 @@ func (c *Core) confirmRedemption(t *trackedTrade, match *matchTracker) (bool, er match.redemptionRejected = true // We need to seek user approval before trying again, since new fees // could be incurred. - actionRequest, note := newRejectedRedemptionNote(toWallet.AssetID, t.ID(), redeemCoinID) + actionRequest, note := newRejectedTxNote(toWallet.AssetID, t.ID(), redeemCoinID, asset.CTRedeem) t.notify(note) c.requestedActionMtx.Lock() c.requestedActions[dex.Bytes(redeemCoinID).String()] = actionRequest @@ -3138,6 +3195,133 @@ func (c *Core) confirmRedemption(t *trackedTrade, match *matchTracker) (bool, er return redemptionConfirmed, nil } +// confirmRefund checks if the user's refund has been confirmed, +// and if so, updates the match's status to MatchConfirmed. +// +// This method accesses match fields and MUST be called with the trackedTrade +// mutex lock held for writes. +func (c *Core) confirmRefund(t *trackedTrade, match *matchTracker) (bool, error) { + if confs := match.refundConfs; confs > 0 && confs >= match.refundConfsReq { // already there, stop checking + if match.Status == order.MatchConfirmed { + return true, nil + } + match.Status = order.MatchConfirmed + err := t.db.UpdateMatch(&match.MetaMatch) + if err != nil { + t.dc.log.Errorf("failed to update match in db: %v", err) + } + subject, details := t.formatDetails(TopicRefundConfirmed, match.token(), makeOrderToken(t.token())) + note := newMatchNote(TopicRefundConfirmed, subject, details, db.Success, t, match) + t.notify(note) + return true, nil + } + + // In some cases the wallet will need to send a new refund transaction. + fromWallet := t.wallets.fromWallet + + if err := fromWallet.checkPeersAndSyncStatus(); err != nil { + return false, err + } + + didUnlock, err := fromWallet.refreshUnlock() + if err != nil { // Just log it and try anyway. + t.dc.log.Errorf("refreshUnlock error checking refund %s: %v", fromWallet.Symbol, err) + } + if didUnlock { + t.dc.log.Warnf("Unexpected unlock needed for the %s wallet to check a refund", fromWallet.Symbol) + } + + proof := &match.MetaData.Proof + refundCoinID := proof.RefundCoin + secretHash := proof.SecretHash + var swapCoinID dex.Bytes + if match.Side == order.Maker { + swapCoinID = dex.Bytes(match.MetaData.Proof.MakerSwap) + } else { + swapCoinID = dex.Bytes(match.MetaData.Proof.TakerSwap) + } + contractToRefund := match.MetaData.Proof.ContractData + + match.confirmRedemptionNumTries++ + + refundStatus, err := fromWallet.Wallet.ConfirmTransaction(dex.Bytes(refundCoinID), + asset.NewRefundConfTx(swapCoinID, contractToRefund, secretHash), t.refundFee()) + switch { + case err == nil: + case errors.Is(err, asset.ErrSwapRedeemed): + subject, details := t.formatDetails(TopicSwapRedeemed, match.token(), makeOrderToken(t.token())) + note := newMatchNote(TopicSwapRedeemed, subject, details, db.ErrorLevel, t, match) + t.notify(note) + match.Status = order.MatchConfirmed + err := t.db.UpdateMatch(&match.MetaMatch) + if err != nil { + t.dc.log.Errorf("Failed to update match in db %v", err) + } + return false, errors.New("swap was already redeemed by the counterparty") + + case errors.Is(err, asset.ErrTxRejected): + match.refundRejected = true + // We need to seek user approval before trying again, since new fees + // could be incurred. + actionRequest, note := newRejectedTxNote(fromWallet.AssetID, t.ID(), refundCoinID, asset.CTRefund) + t.notify(note) + c.requestedActionMtx.Lock() + c.requestedActions[dex.Bytes(refundCoinID).String()] = actionRequest + c.requestedActionMtx.Unlock() + return false, fmt.Errorf("%s transaction %s was rejected. Seeking user approval before trying again", + unbip(fromWallet.AssetID), coinIDString(fromWallet.AssetID, refundCoinID)) + case errors.Is(err, asset.ErrTxLost): + // The transaction was nonce-replaced or otherwise lost without + // rejection or with user acknowlegement. Try again. + match.MetaData.Proof.RefundCoin = nil + c.log.Infof("Redemption %s (%s) has been noted as lost.", refundCoinID, unbip(fromWallet.AssetID)) + + if err := t.db.UpdateMatch(&match.MetaMatch); err != nil { + t.dc.log.Errorf("failed to update match after lost tx reported: %v", err) + } + return false, nil + default: + match.delayTicks(time.Minute * 15) + return false, fmt.Errorf("error confirming refund for coin %v. already tried %d times, will retry later: %v", + refundCoinID, match.confirmRefundNumTries, err) + } + + var refundResubmitted, refundConfirmed bool + if !bytes.Equal(refundCoinID, refundStatus.CoinID) { + refundResubmitted = true + match.MetaData.Proof.RefundCoin = order.CoinID(refundStatus.CoinID) + } + + match.refundConfs, match.refundConfsReq = refundStatus.Confs, refundStatus.Req + + if refundStatus.Confs >= refundStatus.Req { + refundConfirmed = true + match.Status = order.MatchConfirmed + } + if refundResubmitted || refundConfirmed { + err := t.db.UpdateMatch(&match.MetaMatch) + if err != nil { + t.dc.log.Errorf("failed to update match in db: %v", err) + } + } + + if refundResubmitted { + subject, details := t.formatDetails(TopicRefundResubmitted, match.token(), makeOrderToken(t.token())) + note := newMatchNote(TopicRefundResubmitted, subject, details, db.WarningLevel, t, match) + t.notify(note) + } + + if refundConfirmed { + subject, details := t.formatDetails(TopicRefundConfirmed, match.token(), makeOrderToken(t.token())) + note := newMatchNote(TopicRefundConfirmed, subject, details, db.Success, t, match) + t.notify(note) + } else { + note := newMatchNote(TopicConfirms, "", "", db.Data, t, match) + t.notify(note) + } + return refundConfirmed, nil +} + // findMakersRedemption starts a goroutine to search for the redemption of // taker's contract. // diff --git a/client/core/types.go b/client/core/types.go index f05a9f806e..3f9c7fdd8d 100644 --- a/client/core/types.go +++ b/client/core/types.go @@ -302,12 +302,12 @@ func (c *Coin) SetConfirmations(confs, confReq int64) { // This function is intended for use with inactive matches. For active matches, // use matchFromMetaMatchWithConfs. func matchFromMetaMatch(ord order.Order, metaMatch *db.MetaMatch) *Match { - return matchFromMetaMatchWithConfs(ord, metaMatch, 0, 0, 0, 0, 0, 0) + return matchFromMetaMatchWithConfs(ord, metaMatch, 0, 0, 0, 0, 0, 0, 0, 0) } // matchFromMetaMatchWithConfs constructs a *Match from a *MetaMatch, // and sets the confirmations for swaps-in-waiting. -func matchFromMetaMatchWithConfs(ord order.Order, metaMatch *db.MetaMatch, swapConfs, swapReq, counterSwapConfs, counterReq, redeemConfs, redeemReq int64) *Match { +func matchFromMetaMatchWithConfs(ord order.Order, metaMatch *db.MetaMatch, swapConfs, swapReq, counterSwapConfs, counterReq, redeemConfs, redeemReq, refundConfs, refundReq int64) *Match { if _, isCancel := ord.(*order.CancelOrder); isCancel { fmt.Println("matchFromMetaMatchWithConfs got a cancel order for match", metaMatch) return &Match{} @@ -333,6 +333,7 @@ func matchFromMetaMatchWithConfs(ord order.Order, metaMatch *db.MetaMatch, swapC var refund, redeem, counterRedeem, swap, counterSwap *Coin if len(proof.RefundCoin) > 0 { refund = NewCoin(fromID, proof.RefundCoin) + refund.SetConfirmations(refundConfs, refundReq) } if len(swapCoin) > 0 { swap = NewCoin(fromID, swapCoin) diff --git a/client/db/types.go b/client/db/types.go index d20bd4fae5..8495a03e0c 100644 --- a/client/db/types.go +++ b/client/db/types.go @@ -513,11 +513,6 @@ func MatchIsActive(match *order.UserMatch, proof *MatchProof) bool { return false } - // Refunded matches are inactive regardless of status. - if len(proof.RefundCoin) > 0 { - return false - } - // Revoked matches may need to be refunded or auto-redeemed first. if proof.IsRevoked() { // - NewlyMatched requires no further action from either side diff --git a/client/webserver/site/src/html/order.tmpl b/client/webserver/site/src/html/order.tmpl index b393ca12de..ae6ae5c9b8 100644 --- a/client/webserver/site/src/html/order.tmpl +++ b/client/webserver/site/src/html/order.tmpl @@ -188,10 +188,15 @@ -
- [[[Refund]]] (, [[[you]]])
- - +
+
+
+ [[[Refund]]] (, [[[you]]]) + +
+ + +
diff --git a/client/webserver/site/src/js/app.ts b/client/webserver/site/src/js/app.ts index aa035b90f0..560f88e2f5 100644 --- a/client/webserver/site/src/js/app.ts +++ b/client/webserver/site/src/js/app.ts @@ -55,7 +55,7 @@ import { ActionResolvedNote, TransactionActionNote, CoreActionRequiredNote, - RejectedRedemptionData, + RejectedTxData, MarketMakingStatus, RunStatsNote, MMBotStatus, @@ -644,8 +644,8 @@ export default class Application { return this.missingNoncesAction(req) case 'lostNonce': return this.lostNonceAction(req) - case 'redeemRejected': - return this.redeemRejectedAction(req) + case 'txRejected': + return this.txRejectedAction(req) } throw Error('unknown required action ID ' + req.actionID) } @@ -754,14 +754,15 @@ export default class Application { return div } - redeemRejectedAction (req: ActionRequiredNote) { - const { orderID, coinID, coinFmt, assetID } = req.payload as RejectedRedemptionData - const div = this.page.rejectedRedemptionTmpl.cloneNode(true) as PageElement + txRejectedAction (req: ActionRequiredNote) { + const { orderID, coinID, coinFmt, assetID, txType } = req.payload as RejectedTxData + const div = this.page.rejectedTxTmpl.cloneNode(true) as PageElement const tmpl = Doc.parseTemplate(div) const { name, token } = this.assets[assetID] tmpl.assetName.textContent = name tmpl.txid.textContent = coinFmt tmpl.txid.dataset.explorerCoin = coinID + tmpl.txType.textContent = txType setCoinHref(token ? token.parentID : assetID, tmpl.txid) Doc.bind(tmpl.doNothingBttn, 'click', () => { this.submitAction(req, { orderID, coinID, retry: false }, tmpl.errMsg) diff --git a/client/webserver/site/src/js/order.ts b/client/webserver/site/src/js/order.ts index 47a56b71e0..c6948cb0e4 100644 --- a/client/webserver/site/src/js/order.ts +++ b/client/webserver/site/src/js/order.ts @@ -307,23 +307,27 @@ export default class OrderPage extends BasePage { if (m.status === OrderUtil.MakerSwapCast && !m.revoked && !m.refund) { const c = makerSwapCoin(m) tmpl.makerSwapMsg.textContent = confirmationString(c) - Doc.hide(tmpl.takerSwapMsg, tmpl.makerRedeemMsg, tmpl.takerRedeemMsg) + Doc.hide(tmpl.takerSwapMsg, tmpl.makerRedeemMsg, tmpl.takerRedeemMsg, tmpl.refundMsg) Doc.show(tmpl.makerSwapMsg) } else if (m.status === OrderUtil.TakerSwapCast && !m.revoked && !m.refund) { const c = takerSwapCoin(m) tmpl.takerSwapMsg.textContent = confirmationString(c) - Doc.hide(tmpl.makerSwapMsg, tmpl.makerRedeemMsg, tmpl.takerRedeemMsg) + Doc.hide(tmpl.makerSwapMsg, tmpl.makerRedeemMsg, tmpl.takerRedeemMsg, tmpl.refundMsg) Doc.show(tmpl.takerSwapMsg) } else if (inConfirmingMakerRedeem(m) && !m.revoked && !m.refund) { tmpl.makerRedeemMsg.textContent = confirmationString(m.redeem) - Doc.hide(tmpl.makerSwapMsg, tmpl.takerSwapMsg, tmpl.takerRedeemMsg) + Doc.hide(tmpl.makerSwapMsg, tmpl.takerSwapMsg, tmpl.takerRedeemMsg, tmpl.refundMsg) Doc.show(tmpl.makerRedeemMsg) } else if (inConfirmingTakerRedeem(m) && !m.revoked && !m.refund) { tmpl.takerRedeemMsg.textContent = confirmationString(m.redeem) - Doc.hide(tmpl.makerSwapMsg, tmpl.takerSwapMsg, tmpl.makerRedeemMsg) + Doc.hide(tmpl.makerSwapMsg, tmpl.takerSwapMsg, tmpl.makerRedeemMsg, tmpl.refundMsg) Doc.show(tmpl.takerRedeemMsg) - } else { + } else if (inConfirmingRefund(m)) { + tmpl.refundMsg.textContent = confirmationString(m.refund) Doc.hide(tmpl.makerSwapMsg, tmpl.takerSwapMsg, tmpl.makerRedeemMsg, tmpl.takerRedeemMsg) + Doc.show(tmpl.refundMsg) + } else { + Doc.hide(tmpl.makerSwapMsg, tmpl.takerSwapMsg, tmpl.makerRedeemMsg, tmpl.takerRedeemMsg, tmpl.refundMsg) } if (!m.revoked) { @@ -558,3 +562,10 @@ function inConfirmingMakerRedeem (m: Match) { function inConfirmingTakerRedeem (m: Match) { return m.status < OrderUtil.MatchConfirmed && m.side === OrderUtil.Taker && m.status >= OrderUtil.MatchComplete } + +/* +* inConfirmingRefund will be true if we are waiting on confirmations for our refund. +*/ +function inConfirmingRefund (m: Match) { + return m.status < OrderUtil.MatchConfirmed && m.refund && m.refund.confs.count <= m.refund.confs.required +} diff --git a/client/webserver/site/src/js/registry.ts b/client/webserver/site/src/js/registry.ts index d4832abee4..440d308ae4 100644 --- a/client/webserver/site/src/js/registry.ts +++ b/client/webserver/site/src/js/registry.ts @@ -462,11 +462,12 @@ export interface CoreActionRequiredNote extends CoreNote { payload: ActionRequiredNote } -export interface RejectedRedemptionData { +export interface RejectedTxData { assetID: number orderID: string coinID: string coinFmt: string + txType: string } export interface SpotPriceNote extends CoreNote { diff --git a/dex/order/match.go b/dex/order/match.go index ebae50e0b3..1d144b9b9c 100644 --- a/dex/order/match.go +++ b/dex/order/match.go @@ -93,7 +93,7 @@ const ( // sent the details to the maker. MatchComplete // 4 // MatchConfirmed is a status used only by the client that represents - // that the user's redemption transaction has been confirmed. + // that the user's redemption or refund transaction has been confirmed. MatchConfirmed // 5 )