diff --git a/client/core/core.go b/client/core/core.go index 80179181ed..a8c24a748a 100644 --- a/client/core/core.go +++ b/client/core/core.go @@ -237,16 +237,23 @@ func (dc *dexConnection) findOrder(oid order.OrderID) (tracker *trackedTrade, pr func (c *Core) tryCancel(dc *dexConnection, oid order.OrderID) (found bool, err error) { tracker, _, _ := dc.findOrder(oid) if tracker == nil { - return + return // false, nil + } + return true, c.tryCancelTrade(dc, tracker) +} + +// tryCancelTrade attempts to cancel the order. +func (c *Core) tryCancelTrade(dc *dexConnection, tracker *trackedTrade) error { + oid := tracker.ID() + if lo, ok := tracker.Order.(*order.LimitOrder); !ok || lo.Force != order.StandingTiF { + return fmt.Errorf("cannot cancel %s order %s that is not a standing limit order", tracker.Type(), oid) } - found = true tracker.mtx.Lock() defer tracker.mtx.Unlock() - if lo, ok := tracker.Order.(*order.LimitOrder); !ok || lo.Force != order.StandingTiF { - err = fmt.Errorf("cannot cancel %s order %s that is not a standing limit order", tracker.Type(), oid) - return + if status := tracker.metaData.Status; status != order.OrderStatusEpoch && status != order.OrderStatusBooked { + return fmt.Errorf("order %v not cancellable in status %v", oid, status) } if tracker.cancel != nil { @@ -255,8 +262,8 @@ func (c *Core) tryCancel(dc *dexConnection, oid order.OrderID) (found bool, err tracker.deleteStaleCancelOrder() if tracker.cancel != nil { - err = fmt.Errorf("order %s - only one cancel order can be submitted per order per epoch. still waiting on cancel order %s to match", oid, tracker.cancel.ID()) - return + return fmt.Errorf("order %s - only one cancel order can be submitted per order per epoch. "+ + "still waiting on cancel order %s to match", oid, tracker.cancel.ID()) } } @@ -274,9 +281,9 @@ func (c *Core) tryCancel(dc *dexConnection, oid order.OrderID) (found bool, err }, TargetOrderID: oid, } - err = order.ValidateOrder(co, order.OrderStatusEpoch, 0) + err := order.ValidateOrder(co, order.OrderStatusEpoch, 0) if err != nil { - return + return err } // Create and send the order message. Check the response before using it. @@ -284,20 +291,18 @@ func (c *Core) tryCancel(dc *dexConnection, oid order.OrderID) (found bool, err var result = new(msgjson.OrderResult) err = dc.signAndRequest(msgOrder, route, result, DefaultResponseTimeout) if err != nil { - return + return err } err = validateOrderResponse(dc, result, co, msgOrder) if err != nil { - err = fmt.Errorf("Abandoning order. preimage: %x, server time: %d: %w", + return fmt.Errorf("Abandoning order. preimage: %x, server time: %d: %w", preImg[:], result.ServerTime, err) - return } // Store the cancel order with the tracker. err = tracker.cancelTrade(co, preImg) if err != nil { - err = fmt.Errorf("error storing cancel order info %s: %w", co.ID(), err) - return + return fmt.Errorf("error storing cancel order info %s: %w", co.ID(), err) } // Now that the trackedTrade is updated, sync with the preimage request. @@ -317,8 +322,7 @@ func (c *Core) tryCancel(dc *dexConnection, oid order.OrderID) (found bool, err Order: co, }) if err != nil { - err = fmt.Errorf("failed to store order in database: %w", err) - return + return fmt.Errorf("failed to store order in database: %w", err) } c.log.Infof("Cancel order %s targeting order %s at %s has been placed", @@ -326,7 +330,7 @@ func (c *Core) tryCancel(dc *dexConnection, oid order.OrderID) (found bool, err c.notify(newOrderNote(SubjectCancellingOrder, "A cancel order has been submitted for order "+tracker.token(), db.Poke, tracker.coreOrderInternal())) - return + return nil } // Synchronize with the preimage request, in case that request came before @@ -386,7 +390,9 @@ type serverMatches struct { cancel *msgjson.Match } -// parseMatches sorts the list of matches and associates them with a trade. +// parseMatches sorts the list of matches and associates them with a trade. This +// may be called from handleMatchRoute on receipt of a new 'match' request, or +// by authDEX with the list of active matches returned by the 'connect' request. func (dc *dexConnection) parseMatches(msgMatches []*msgjson.Match, checkSigs bool) (map[order.OrderID]*serverMatches, []msgjson.Acknowledgement, error) { var acks []msgjson.Acknowledgement matches := make(map[order.OrderID]*serverMatches) @@ -3124,6 +3130,31 @@ func (c *Core) authDEX(dc *dexConnection) error { c.resolveMatchConflicts(dc, matchConflicts) } + // List and cancel standing limit orders that are in epoch or booked status, + // but without funding coins for new matches. This should be done after the + // order status resolution done above. + var brokenTrades []*trackedTrade + dc.tradeMtx.RLock() + for _, trade := range dc.trades { + if lo, ok := trade.Order.(*order.LimitOrder); !ok || lo.Force != order.StandingTiF { + continue // only standing limit orders need to be canceled + } + trade.mtx.RLock() + status := trade.metaData.Status + if (status == order.OrderStatusEpoch || status == order.OrderStatusBooked) && + !trade.hasFundingCoins() { + brokenTrades = append(brokenTrades, trade) + } + trade.mtx.RUnlock() + } + dc.tradeMtx.RUnlock() + for _, trade := range brokenTrades { + c.log.Warnf("Canceling unfunded standing limit order %v", trade.ID()) + if err = c.tryCancelTrade(dc, trade); err != nil { + c.log.Warnf("Unable to cancel unfunded trade %v: %v", trade.ID(), err) + } + } + if len(updatedAssets) > 0 { c.updateBalances(updatedAssets) } @@ -3484,12 +3515,14 @@ func (c *Core) loadDBTrades(dc *dexConnection, crypter encrypt.Crypter, failed m if err != nil { baseFailed = true failed[base] = struct{}{} + c.log.Errorf("Connecting to wallet %v failed: %v", unbip(quote), err) } else if !baseWallet.unlocked() { err = baseWallet.Unlock(crypter) if err != nil { baseFailed = true failed[base] = struct{}{} } + c.log.Errorf("Unlock wallet %d failed: %v", unbip(quote), err) } } if !baseFailed && !quoteFailed { @@ -3497,12 +3530,14 @@ func (c *Core) loadDBTrades(dc *dexConnection, crypter encrypt.Crypter, failed m if err != nil { quoteFailed = true failed[quote] = struct{}{} + c.log.Errorf("Connecting to wallet %v failed: %v", unbip(quote), err) } else if !quoteWallet.unlocked() { err = quoteWallet.Unlock(crypter) if err != nil { quoteFailed = true failed[quote] = struct{}{} } + c.log.Errorf("Unlock wallet %d failed: %v", unbip(quote), err) } } if baseFailed { @@ -3528,19 +3563,24 @@ func (c *Core) resumeTrades(dc *dexConnection, trackers []*trackedTrade) assetMa c.notify(newOrderNote(subject, detail, db.ErrorLevel, tracker.coreOrder())) } - // When an active order fails to load funding coins, it must be revoked - // along with any matches that require funding coins. The trade should still - // enter the trades map so that any recovery of funds from other matches can - // take place before the order is retired. - revokeUnfunded := func(trade *trackedTrade, matches []*matchTracker) { - if trade.metaData.Status < order.OrderStatusExecuted { - trade.revoke() - } + // markUnfunded is used to allow an unfunded order to enter the trades map + // so that status resolution and match negotiation for unaffected matches + // may continue. By not self-revoking, the user may have the opportunity to + // resolve any wallet issues that may have lead to a failure to find the + // funding coins. Otherwise the server will (or already did) revoke some or + // all of the matches and the order itself. + markUnfunded := func(trade *trackedTrade, matches []*matchTracker) { + // Block negotiating new matches. + trade.changeLocked = false + trade.coinsLocked = false + // Block swaps txn attempts on matches needing funds. for _, match := range matches { - if !match.MetaData.Proof.IsRevoked() { - _ = trade.revokeMatch(match.id, false) // it will be found - } + match.swapErr = errors.New("no funding coins for swap") } + // Will not be retired until revoke of order and all matches, which may + // happen on status resolution after authenticating with the DEX server, + // or from a revoke_match/revoke_order notification after timeout. + // However, the order should be unconditionally canceled. } relocks := make(assetMap) @@ -3657,7 +3697,7 @@ func (c *Core) resumeTrades(dc *dexConnection, trackers []*trackedTrade) assetMa tracker.coins = map[string]asset.Coin{} // should already be if len(coinIDs) == 0 { notifyErr(SubjectOrderCoinError, "No funding coins recorded for active order %s", tracker.token()) - revokeUnfunded(tracker, matchesNeedingCoins) // bug - no user resolution + markUnfunded(tracker, matchesNeedingCoins) // bug - no user resolution } else { byteIDs := make([]dex.Bytes, 0, len(coinIDs)) for _, cid := range coinIDs { @@ -3667,16 +3707,15 @@ func (c *Core) resumeTrades(dc *dexConnection, trackers []*trackedTrade) assetMa if err != nil || len(coins) == 0 { notifyErr(SubjectOrderCoinError, "Source coins retrieval error for order %s (%s): %v", tracker.token(), unbip(wallets.fromAsset.ID), err) - // Only revoke if requested so that the user can try again - // after investigating (wrong wallet connected?). - if c.cfg.RevokeUnfunded { - revokeUnfunded(tracker, matchesNeedingCoins) - } else { - c.log.Warnf("Check the status of your %s wallet and the coins logged above. "+ - "Otherwise restart with --revokeunfunded to safely (and permanently) abandon this order.", - strings.ToUpper(unbip(wallets.fromAsset.ID))) - continue // omit this trade from the map - } + // Block matches needing funding coins. + markUnfunded(tracker, matchesNeedingCoins) + c.log.Warnf("Check the status of your %s wallet and the coins logged above! "+ + "Resolve the wallet issue if possible and restart the DEX client.", + strings.ToUpper(unbip(wallets.fromAsset.ID))) + // Note: tracker is still added to trades map for (1) status + // resolution, (2) continued settlement of matches that no + // longer require funding coins, and (3) cancellation in + // authDEX if the order is booked. } else { // NOTE: change and changeLocked are not set even if the // funding coins were loaded from the DB's ChangeCoin. @@ -3728,7 +3767,7 @@ func generateDEXMaps(host string, cfg *msgjson.ConfigResult) (map[uint32]*dex.As } // runMatches runs the sorted matches returned from parseMatches. -func (c *Core) runMatches(dc *dexConnection, tradeMatches map[order.OrderID]*serverMatches) (assetMap, error) { +func (c *Core) runMatches(tradeMatches map[order.OrderID]*serverMatches) (assetMap, error) { runMatch := func(sm *serverMatches) (assetMap, error) { updatedAssets := make(assetMap) tracker := sm.tracker @@ -4387,21 +4426,36 @@ func handleMatchRoute(c *Core, dc *dexConnection, msg *msgjson.Message) error { // requirement, which requires changes to the server's handling. return err } + + // Warn about new matches for unfunded orders. We still must ack all the + // matches in the 'match' request for the server to accept it, although the + // server doesn't require match acks. See (*Swapper).processMatchAcks. + for oid, srvMatch := range matches { + if srvMatch.tracker.hasFundingCoins() { + c.log.Warnf("Received new match for unfunded order %v!", oid) + // In runMatches>tracker.negotiate we generate the matchTracker and + // set swapErr after updating order status and filled amount, and + // storing the match to the DB. It may still be possible for the + // user to recover if the issue is just that the wrong wallet is + // connected by fixing wallet config and restarting. p.s. Hopefully + // we are maker. + } + } + resp, err := msgjson.NewResponse(msg.ID, acks, nil) if err != nil { return err } // Send the match acknowledgments. - // TODO: Consider a "QueueSend" or similar, but do not bail on the matches. err = dc.Send(resp) if err != nil { + // Do not bail on the matches on error, just log it. c.log.Errorf("Send match response: %v", err) - // dc.addPendingSend(resp) // e.g. } // Begin match negotiation. - updatedAssets, err := c.runMatches(dc, matches) + updatedAssets, err := c.runMatches(matches) if len(updatedAssets) > 0 { c.updateBalances(updatedAssets) } diff --git a/client/core/core_test.go b/client/core/core_test.go index b7de4eff1f..784a34c78e 100644 --- a/client/core/core_test.go +++ b/client/core/core_test.go @@ -160,10 +160,10 @@ func tNewAccount() *dexAccount { } } -func testDexConnection() (*dexConnection, *TWebsocket, *dexAccount) { +func testDexConnection(ctx context.Context) (*dexConnection, *TWebsocket, *dexAccount) { conn := newTWebsocket() connMaster := dex.NewConnectionMaster(conn) - connMaster.Connect(tCtx) + connMaster.Connect(ctx) acct := tNewAccount() return &dexConnection{ WsConn: conn, @@ -784,13 +784,14 @@ func randomMsgMarket() (baseAsset, quoteAsset *msgjson.Asset) { } type testRig struct { - core *Core - db *TDB - queue *wait.TickerQueue - ws *TWebsocket - dc *dexConnection - acct *dexAccount - crypter *tCrypter + shutdown func() + core *Core + db *TDB + queue *wait.TickerQueue + ws *TWebsocket + dc *dexConnection + acct *dexAccount + crypter *tCrypter } func newTestRig() *testRig { @@ -811,15 +812,28 @@ func newTestRig() *testRig { // Set the global waiter expiration, and start the waiter. queue := wait.NewTickerQueue(time.Millisecond * 5) - go queue.Run(tCtx) + ctx, cancel := context.WithCancel(tCtx) + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + queue.Run(ctx) + }() + + dc, conn, acct := testDexConnection(ctx) - dc, conn, acct := testDexConnection() + shutdown := func() { + cancel() + wg.Wait() + dc.connMaster.Wait() + } crypter := &tCrypter{} rig := &testRig{ + shutdown: shutdown, core: &Core{ - ctx: tCtx, + ctx: ctx, cfg: &Config{}, db: tdb, log: tLogger, @@ -936,6 +950,7 @@ func TestMain(m *testing.M) { func TestMarkets(t *testing.T) { rig := newTestRig() + defer rig.shutdown() // The test rig's dexConnection comes with a market. Clear that for this test. rig.dc.cfgMtx.Lock() rig.dc.cfg.Markets = nil @@ -993,6 +1008,7 @@ func TestMarkets(t *testing.T) { func TestDexConnectionOrderBook(t *testing.T) { rig := newTestRig() + defer rig.shutdown() tCore := rig.core dc := rig.dc @@ -1215,6 +1231,7 @@ func (drv *tDriver) Info() *asset.WalletInfo { func TestCreateWallet(t *testing.T) { rig := newTestRig() + defer rig.shutdown() tCore := rig.core // Create a new asset. @@ -1308,6 +1325,7 @@ func TestCreateWallet(t *testing.T) { func TestGetFee(t *testing.T) { rig := newTestRig() + defer rig.shutdown() tCore := rig.core cert := []byte{} @@ -1342,6 +1360,7 @@ func TestRegister(t *testing.T) { // This test takes a little longer because the key is decrypted every time // Register is called. rig := newTestRig() + defer rig.shutdown() tCore := rig.core dc := rig.dc delete(tCore.conns, tDexHost) @@ -1638,6 +1657,7 @@ func TestRegister(t *testing.T) { func TestLogin(t *testing.T) { rig := newTestRig() + defer rig.shutdown() tCore := rig.core rig.acct.markFeePaid() @@ -1670,6 +1690,7 @@ func TestLogin(t *testing.T) { // 'connect' route error. rig = newTestRig() + defer rig.shutdown() tCore = rig.core rig.acct.unauth() rig.ws.queueResponse(msgjson.ConnectRoute, func(msg *msgjson.Message, f msgFunc) error { @@ -1686,6 +1707,7 @@ func TestLogin(t *testing.T) { // Success with some matches in the response. rig = newTestRig() + defer rig.shutdown() dc := rig.dc qty := 3 * tDCR.LotSize lo, dbOrder, preImg, addr := makeLimitOrder(dc, true, qty, tBTC.RateStep*10) @@ -1798,6 +1820,7 @@ func TestLogin(t *testing.T) { func TestLoginAccountNotFoundError(t *testing.T) { rig := newTestRig() + defer rig.shutdown() tCore := rig.core rig.acct.markFeePaid() @@ -1822,6 +1845,7 @@ func TestLoginAccountNotFoundError(t *testing.T) { func TestInitializeDEXConnectionsSuccess(t *testing.T) { rig := newTestRig() + defer rig.shutdown() tCore := rig.core rig.acct.markFeePaid() rig.queueConnect(nil, nil, nil) @@ -1840,6 +1864,7 @@ func TestInitializeDEXConnectionsSuccess(t *testing.T) { func TestInitializeDEXConnectionsAccountNotFoundError(t *testing.T) { rig := newTestRig() + defer rig.shutdown() tCore := rig.core rig.acct.markFeePaid() expectedErrorMessage := "test account not found error" @@ -1865,6 +1890,7 @@ func TestInitializeDEXConnectionsAccountNotFoundError(t *testing.T) { func TestConnectDEX(t *testing.T) { rig := newTestRig() + defer rig.shutdown() tCore := rig.core ai := &db.AccountInfo{ @@ -1925,6 +1951,7 @@ func TestConnectDEX(t *testing.T) { func TestInitializeClient(t *testing.T) { rig := newTestRig() + defer rig.shutdown() tCore := rig.core rig.db.existValues[keyParamsKey] = false @@ -1957,6 +1984,7 @@ func TestInitializeClient(t *testing.T) { func TestWithdraw(t *testing.T) { rig := newTestRig() + defer rig.shutdown() tCore := rig.core wallet, tWallet := newTWallet(tDCR.ID) tCore.wallets[tDCR.ID] = wallet @@ -2011,6 +2039,7 @@ func TestWithdraw(t *testing.T) { func TestTrade(t *testing.T) { rig := newTestRig() + defer rig.shutdown() tCore := rig.core dcrWallet, tDcrWallet := newTWallet(tDCR.ID) tCore.wallets[tDCR.ID] = dcrWallet @@ -2310,6 +2339,7 @@ func TestTrade(t *testing.T) { func TestCancel(t *testing.T) { rig := newTestRig() + defer rig.shutdown() dc := rig.dc lo, dbOrder, preImg, _ := makeLimitOrder(dc, true, 0, 0) lo.Force = order.StandingTiF @@ -2371,6 +2401,7 @@ func TestCancel(t *testing.T) { func TestHandlePreimageRequest(t *testing.T) { rig := newTestRig() + defer rig.shutdown() ord := &order.LimitOrder{P: order.Prefix{ServerTime: time.Now()}} oid := ord.ID() preImg := newPreimage() @@ -2420,6 +2451,7 @@ func TestHandlePreimageRequest(t *testing.T) { func TestHandleRevokeOrderMsg(t *testing.T) { rig := newTestRig() + defer rig.shutdown() dc := rig.dc tCore := rig.core dcrWallet, tDcrWallet := newTWallet(tDCR.ID) @@ -2491,6 +2523,7 @@ func TestHandleRevokeOrderMsg(t *testing.T) { func TestHandleRevokeMatchMsg(t *testing.T) { rig := newTestRig() + defer rig.shutdown() dc := rig.dc tCore := rig.core dcrWallet, tDcrWallet := newTWallet(tDCR.ID) @@ -2563,6 +2596,7 @@ func TestHandleRevokeMatchMsg(t *testing.T) { func TestTradeTracking(t *testing.T) { rig := newTestRig() + defer rig.shutdown() dc := rig.dc tCore := rig.core dcrWallet, tDcrWallet := newTWallet(tDCR.ID) @@ -3152,6 +3186,7 @@ func TestTradeTracking(t *testing.T) { func TestReconcileTrades(t *testing.T) { rig := newTestRig() + defer rig.shutdown() dc := rig.dc mkt := dc.marketConfig(tDcrBtcMktName) @@ -3368,10 +3403,11 @@ func makeTradeTracker(rig *testRig, mkt *msgjson.Market, walletSet *walletSet, f func TestRefunds(t *testing.T) { rig := newTestRig() + defer rig.shutdown() checkStatus := func(tag string, match *matchTracker, wantStatus order.MatchStatus) { t.Helper() if match.Match.Status != wantStatus { - t.Fatalf("%s: wrong status wanted %d, got %d", tag, match.Match.Status, wantStatus) + t.Fatalf("%s: wrong status wanted %v, got %v", tag, wantStatus, match.Match.Status) } } checkRefund := func(tracker *trackedTrade, match *matchTracker, expectAmt uint64) { @@ -3438,8 +3474,11 @@ func TestRefunds(t *testing.T) { t.Fatalf("walletSet error: %v", err) } mkt := dc.marketConfig(tDcrBtcMktName) + fundCoinsDCR := asset.Coins{&tCoin{id: encode.RandomBytes(36)}} + tDcrWallet.fundingCoins = fundCoinsDCR + tDcrWallet.fundRedeemScripts = []dex.Bytes{nil} tracker := newTrackedTrade(dbOrder, preImgL, dc, mkt.EpochLen, rig.core.lockTimeTaker, rig.core.lockTimeMaker, - rig.db, rig.queue, walletSet, nil, rig.core.notify) + rig.db, rig.queue, walletSet, fundCoinsDCR, rig.core.notify) rig.dc.trades[tracker.ID()] = tracker // MAKER REFUND, INVALID TAKER COUNTERSWAP @@ -3499,6 +3538,14 @@ func TestRefunds(t *testing.T) { // TAKER REFUND, NO MAKER REDEEM // + // Reset funding coins in the trackedTrade, wipe change coin. + tracker.mtx.Lock() + tracker.coins = mapifyCoins(fundCoinsDCR) + tracker.coinsLocked = true + tracker.changeLocked = false + tracker.change = nil + tracker.metaData.ChangeCoin = nil + tracker.mtx.Unlock() mid = ordertest.RandomMatchID() msgMatch = &msgjson.Match{ OrderID: loid[:], @@ -3564,11 +3611,13 @@ func TestRefunds(t *testing.T) { } func TestNotifications(t *testing.T) { - tCore := newTestRig().core + rig := newTestRig() + defer rig.shutdown() // Insert a notification into the database. typedNote := newOrderNote("abc", "def", 100, nil) + tCore := rig.core ch := tCore.NotificationFeed() tCore.notify(typedNote) select { @@ -3581,6 +3630,7 @@ func TestNotifications(t *testing.T) { func TestResolveActiveTrades(t *testing.T) { rig := newTestRig() + defer rig.shutdown() tCore := rig.core btcWallet, tBtcWallet := newTWallet(tBTC.ID) @@ -3744,9 +3794,10 @@ func TestResolveActiveTrades(t *testing.T) { tBtcWallet.unlockErr = nil tBtcWallet.locked = false - // Funding coin error. + // Funding coin error still puts it in the trades map, just with no coins + // locked. tDcrWallet.fundingCoinErr = tErr - ensureFail("funding coin") + ensureGood("funding coin", 0) tDcrWallet.fundingCoinErr = nil // No matches @@ -3828,6 +3879,7 @@ func TestResolveActiveTrades(t *testing.T) { func TestCompareServerMatches(t *testing.T) { rig := newTestRig() + defer rig.shutdown() preImg := newPreimage() dc := rig.dc @@ -4090,6 +4142,7 @@ func tMsgAudit(oid order.OrderID, mid order.MatchID, recipient string, val uint6 func TestHandleEpochOrderMsg(t *testing.T) { rig := newTestRig() + defer rig.shutdown() ord := &order.LimitOrder{P: order.Prefix{ServerTime: time.Now()}} oid := ord.ID() payload := &msgjson.EpochOrderNote{ @@ -4142,6 +4195,7 @@ func makeMatchProof(preimages []order.Preimage, commitments []order.Commitment) func TestHandleMatchProofMsg(t *testing.T) { rig := newTestRig() + defer rig.shutdown() pimg := newPreimage() cmt := pimg.Commit() @@ -4193,6 +4247,7 @@ func TestHandleMatchProofMsg(t *testing.T) { func TestLogout(t *testing.T) { rig := newTestRig() + defer rig.shutdown() tCore := rig.core dcrWallet, tDcrWallet := newTWallet(tDCR.ID) @@ -4255,6 +4310,7 @@ func TestLogout(t *testing.T) { func TestSetEpoch(t *testing.T) { rig := newTestRig() + defer rig.shutdown() dc := rig.dc dc.books[tDcrBtcMktName] = newBookie(tLogger, func() {}) @@ -4302,6 +4358,7 @@ func makeLimitOrder(dc *dexConnection, sell bool, qty, rate uint64) (*order.Limi Commit: preImg.Commit(), }, T: order.Trade{ + // Coins needed? Sell: sell, Quantity: qty, Address: addr, @@ -4402,6 +4459,7 @@ func TestAddrHost(t *testing.T) { func TestAssetBalance(t *testing.T) { rig := newTestRig() + defer rig.shutdown() tCore := rig.core wallet, tWallet := newTWallet(tDCR.ID) @@ -4441,6 +4499,7 @@ func TestAssetCounter(t *testing.T) { func TestHandleTradeSuspensionMsg(t *testing.T) { rig := newTestRig() + defer rig.shutdown() tCore := rig.core dc := rig.dc @@ -4638,6 +4697,7 @@ func verifyRevokeNotification(ch chan *OrderNote, expectedSubject string, t *tes func TestHandleTradeResumptionMsg(t *testing.T) { rig := newTestRig() + defer rig.shutdown() tCore := rig.core dcrWallet, _ := newTWallet(tDCR.ID) @@ -4735,6 +4795,7 @@ func TestHandleTradeResumptionMsg(t *testing.T) { func TestHandleNomatch(t *testing.T) { rig := newTestRig() + defer rig.shutdown() tCore := rig.core dc := rig.dc mkt := dc.marketConfig(tDcrBtcMktName) @@ -4846,6 +4907,7 @@ func TestHandleNomatch(t *testing.T) { func TestWalletSettings(t *testing.T) { rig := newTestRig() + defer rig.shutdown() tCore := rig.core rig.db.wallet = &db.Wallet{ Settings: map[string]string{ @@ -4883,6 +4945,7 @@ func TestWalletSettings(t *testing.T) { func TestReconfigureWallet(t *testing.T) { rig := newTestRig() + defer rig.shutdown() tCore := rig.core rig.db.wallet = &db.Wallet{ Settings: map[string]string{ @@ -4915,7 +4978,7 @@ func TestReconfigureWallet(t *testing.T) { return xyzWallet.Wallet, nil }, }) - xyzWallet.Connect(tCtx) + xyzWallet.Connect(tCore.ctx) // Connect error tXyzWallet.connectErr = tErr @@ -4988,6 +5051,7 @@ func TestReconfigureWallet(t *testing.T) { func TestSetWalletPassword(t *testing.T) { rig := newTestRig() + defer rig.shutdown() tCore := rig.core rig.db.wallet = &db.Wallet{ EncryptedPW: []byte("abc"), @@ -5058,6 +5122,7 @@ func TestSetWalletPassword(t *testing.T) { func TestHandlePenaltyMsg(t *testing.T) { rig := newTestRig() + defer rig.shutdown() tCore := rig.core dc := rig.dc penalty := &msgjson.Penalty{ @@ -5125,6 +5190,7 @@ func TestHandlePenaltyMsg(t *testing.T) { func TestPreimageSync(t *testing.T) { rig := newTestRig() + defer rig.shutdown() tCore := rig.core dcrWallet, tDcrWallet := newTWallet(tDCR.ID) tCore.wallets[tDCR.ID] = dcrWallet @@ -5217,6 +5283,7 @@ func TestPreimageSync(t *testing.T) { func TestMatchStatusResolution(t *testing.T) { rig := newTestRig() + defer rig.shutdown() tCore := rig.core dc := rig.dc mkt := dc.marketConfig(tDcrBtcMktName) @@ -5710,6 +5777,7 @@ func TestMatchStatusResolution(t *testing.T) { func TestSuspectTrades(t *testing.T) { rig := newTestRig() + defer rig.shutdown() dc := rig.dc tCore := rig.core @@ -5901,6 +5969,7 @@ func TestSuspectTrades(t *testing.T) { func TestWalletSyncing(t *testing.T) { rig := newTestRig() + defer rig.shutdown() tCore := rig.core noteFeed := tCore.NotificationFeed() @@ -5909,7 +5978,7 @@ func TestWalletSyncing(t *testing.T) { defer tDcrWallet.connectWG.Done() dcrWallet.synced = false dcrWallet.syncProgress = 0 - dcrWallet.Connect(tCtx) + dcrWallet.Connect(tCore.ctx) tStart := time.Now() testDuration := 100 * time.Millisecond diff --git a/client/core/trade.go b/client/core/trade.go index ad03ce2dea..720c05b581 100644 --- a/client/core/trade.go +++ b/client/core/trade.go @@ -231,6 +231,11 @@ func (t *trackedTrade) coreOrderInternal() *Order { return corder } +// hasFundingCoins indicates if either funding or change coins are locked. +func (t *trackedTrade) hasFundingCoins() bool { + return t.changeLocked || t.coinsLocked +} + // lockedAmount is the total value of all coins currently locked for this trade. // Returns the value sum of the initial funding coins if no swap has been sent, // otherwise, the value of the locked change coin is returned. @@ -424,6 +429,15 @@ func (t *trackedTrade) negotiate(msgMatches []*msgjson.Match) error { match.MetaData.Proof.SelfRevoked = true } + // If this order has no funding coins, block swaps attempts on the new + // match. Do not revoke however since the user may be able to resolve + // wallet configuration issues and restart to restore funding coins. + // Otherwise the server will end up revoking these matches. + if !t.hasFundingCoins() { + t.dc.log.Errorf("Unable to begin swap negotiation for unfunded order %v", t.ID()) + match.swapErr = errors.New("no funding coins for swap") + } + err := t.db.UpdateMatch(&match.MetaMatch) if err != nil { // Don't abandon other matches because of this error, attempt @@ -1127,7 +1141,6 @@ func (c *Core) tick(t *trackedTrade) (assetMap, error) { details := fmt.Sprintf("Error encountered sending redemptions worth %.8f %s on order %s", float64(qty)/conversionFactor, unbip(toAsset), t.token()) t.notify(newOrderNote(SubjectRedemptionError, details, db.ErrorLevel, corder)) - c.log.Errorf("redemption error details: %v", details, err) } else { details := fmt.Sprintf("Redeemed %.8f %s on order %s", float64(qty)/conversionFactor, unbip(toAsset), t.token())