From 8b3caa6fbfb3ab3b0a5b80300f573d86340a48b1 Mon Sep 17 00:00:00 2001 From: Jonathan Chappelow Date: Fri, 5 Feb 2021 16:50:53 -0600 Subject: [PATCH] This addresses the issue of orders that lose their funding coins one way or another (e.g. spend outside of dexc) can never be loaded. Prior to this PR, that meant two things: - On login, the user will be forever spammed by an "Order coin error" / "Source coins retrieval error". - Matches for such orders can never be recovered, even if they do not require funding coins. This made recovery a manual task if there were matches in progress or requiring refund, etc. Unfunded orders that require funding (epoch/booked or with matches needing to send swap txns) are explicitly blocked from negotiating new matches, and any offending matches that require funding coins are blocked with swapErr. However, notably, the trackedTrade is added to the dc.trades map, unlike before this change. In addition to allowing unaffected matches to proceed, match status resolution can be performed for the order and its matches, potentially revoking it, and revoke_order/revoke_matches requests from the server will be recognized. This allows recovery or completion of other unaffected matches belonging to the trade. The order and the unfunded matches stay active (but halted via swapErr) until they are either revoked or the user possibly resolves a wallet issue that made the funding coins inaccessible and restarts dexc to try loading the funding coins again. If the coins are not spent, they probably are using the wrong wallet that does not control the referenced coins. Unfunded standing limit orders that are either epoch or booked are also canceled to prevent further matches that will be likely to fail. --- client/core/core.go | 187 ++++++++++++++++++++++++++++----------- client/core/core_test.go | 103 +++++++++++++++++---- client/core/trade.go | 15 +++- 3 files changed, 237 insertions(+), 68 deletions(-) diff --git a/client/core/core.go b/client/core/core.go index 3f7e7fa8ae..9804bfabe8 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) @@ -3154,6 +3160,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) } @@ -3212,10 +3243,10 @@ func (c *Core) initialize() { // for authentication when Login is triggered. wg.Wait() c.log.Infof("Successfully connected to %d out of %d DEX servers", len(c.conns), len(accts)) - for dexName, dc := range c.dexConnections() { + for _, dc := range c.dexConnections() { activeOrders, _ := c.dbOrders(dc) // non-nil error will load 0 orders, and any subsequent db error will cause a shutdown on dex auth or sooner if n := len(activeOrders); n > 0 { - c.log.Warnf("\n\n\t **** IMPORTANT: You have %d active orders on %s. LOGIN immediately! **** \n", n, dexName) + c.log.Warnf("\n\n\t **** IMPORTANT: You have %d active orders on %s. LOGIN immediately! **** \n", n, dc.acct.host) } } } @@ -3514,11 +3545,13 @@ 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 %s failed: %v", unbip(base), err) } else if !baseWallet.unlocked() { err = baseWallet.Unlock(crypter) if err != nil { baseFailed = true failed[base] = struct{}{} + c.log.Errorf("Unlock wallet %s failed: %v", unbip(base), err) } } } @@ -3527,11 +3560,13 @@ 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 %s 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 %s failed: %v", unbip(quote), err) } } } @@ -3557,6 +3592,27 @@ func (c *Core) resumeTrades(dc *dexConnection, trackers []*trackedTrade) assetMa detail := fmt.Sprintf(s, a...) c.notify(newOrderNote(subject, detail, db.ErrorLevel, tracker.coreOrder())) } + + // 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 { + 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) dc.tradeMtx.Lock() defer dc.tradeMtx.Unlock() @@ -3581,14 +3637,15 @@ func (c *Core) resumeTrades(dc *dexConnection, trackers []*trackedTrade) assetMa // If matches haven't redeemed, but the counter-swap has been received, // reload the audit info. isActive := tracker.metaData.Status == order.OrderStatusBooked || tracker.metaData.Status == order.OrderStatusEpoch - var needsCoins bool + var matchesNeedingCoins []*matchTracker for _, match := range tracker.matches { dbMatch, metaData := match.Match, match.MetaData - var needsAuditInfo bool + var needsCoins, needsAuditInfo bool var counterSwap []byte if dbMatch.Side == order.Maker { if dbMatch.Status < order.MakerSwapCast { needsCoins = true + matchesNeedingCoins = append(matchesNeedingCoins, match) } if dbMatch.Status == order.TakerSwapCast { needsAuditInfo = true // maker needs AuditInfo for takers contract @@ -3597,12 +3654,15 @@ func (c *Core) resumeTrades(dc *dexConnection, trackers []*trackedTrade) assetMa } else { // Taker if dbMatch.Status < order.TakerSwapCast { needsCoins = true + matchesNeedingCoins = append(matchesNeedingCoins, match) } if dbMatch.Status < order.MatchComplete && dbMatch.Status >= order.MakerSwapCast { needsAuditInfo = true // taker needs AuditInfo for maker's contract counterSwap = metaData.Proof.MakerSwap } } + c.log.Tracef("Trade %v match %v needs coins = %v, needs audit info = %v", + tracker.ID(), match.id, needsCoins, needsAuditInfo) if needsAuditInfo { // Check for unresolvable states. if len(counterSwap) == 0 { @@ -3656,39 +3716,51 @@ func (c *Core) resumeTrades(dc *dexConnection, trackers []*trackedTrade) assetMa } } - // Active orders and orders with matches with unsent swaps need the funding - // coin(s). + // Active orders and orders with matches with unsent swaps need funding + // coin(s). If they are not available, revoke the order and matches. + needsCoins := len(matchesNeedingCoins) > 0 if isActive || needsCoins { coinIDs := trade.Coins if len(tracker.metaData.ChangeCoin) != 0 { coinIDs = []order.CoinID{tracker.metaData.ChangeCoin} } + tracker.coins = map[string]asset.Coin{} // should already be if len(coinIDs) == 0 { - notifyErr(SubjectNoFundingCoins, "Order %s has no %s funding coins", tracker.token(), unbip(wallets.fromAsset.ID)) - continue - } - byteIDs := make([]dex.Bytes, 0, len(coinIDs)) - for _, cid := range coinIDs { - byteIDs = append(byteIDs, []byte(cid)) - } - if len(byteIDs) == 0 { - notifyErr(SubjectOrderCoinError, "No coins for loaded order %s %s: %v", unbip(wallets.fromAsset.ID), tracker.token(), err) - continue - } - coins, err := wallets.fromWallet.FundingCoins(byteIDs) - if err != nil { - notifyErr(SubjectOrderCoinError, "Source coins retrieval error for %s %s: %v", unbip(wallets.fromAsset.ID), tracker.token(), err) - continue + notifyErr(SubjectOrderCoinError, "No funding coins recorded for active order %s", tracker.token()) + markUnfunded(tracker, matchesNeedingCoins) // bug - no user resolution + } else { + byteIDs := make([]dex.Bytes, 0, len(coinIDs)) + for _, cid := range coinIDs { + byteIDs = append(byteIDs, []byte(cid)) + } + coins, err := wallets.fromWallet.FundingCoins(byteIDs) + if err != nil || len(coins) == 0 { + notifyErr(SubjectOrderCoinError, "Source coins retrieval error for order %s (%s): %v", + tracker.token(), unbip(wallets.fromAsset.ID), err) + // Block matches needing funding coins. + markUnfunded(tracker, matchesNeedingCoins) + // 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. + 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))) + c.log.Warnf("Unfunded order %v will be canceled on connect, but %d active matches need funding coins!", + tracker.ID(), len(matchesNeedingCoins)) + // If the funding coins are spent or inaccessible, the user + // can only wait for match revocation. + } else { + // NOTE: change and changeLocked are not set even if the + // funding coins were loaded from the DB's ChangeCoin. + tracker.coinsLocked = true + tracker.coins = mapifyCoins(coins) + } } - // NOTE: change and changeLocked are not set even if the funding - // coins were loaded from the DB's ChangeCoin. - tracker.coinsLocked = true - tracker.coins = mapifyCoins(coins) } - // Active orders and orders with matches with unsent swaps need the funding - // coin(s). - // Orders with sent but unspent swaps need to recompute contract-locked amts. + // Balances should be updated for any orders with locked wallet coins, + // or orders with funds locked in contracts. if isActive || needsCoins || tracker.unspentContractAmounts() > 0 { relocks.count(wallets.fromAsset.ID) } @@ -3729,7 +3801,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 @@ -4388,21 +4460,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 a3c24097b6..2bd5863e0f 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, @@ -786,13 +786,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 { @@ -813,15 +814,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, @@ -938,6 +952,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 @@ -995,6 +1010,7 @@ func TestMarkets(t *testing.T) { func TestDexConnectionOrderBook(t *testing.T) { rig := newTestRig() + defer rig.shutdown() tCore := rig.core dc := rig.dc @@ -1217,6 +1233,7 @@ func (drv *tDriver) Info() *asset.WalletInfo { func TestCreateWallet(t *testing.T) { rig := newTestRig() + defer rig.shutdown() tCore := rig.core // Create a new asset. @@ -1310,6 +1327,7 @@ func TestCreateWallet(t *testing.T) { func TestGetFee(t *testing.T) { rig := newTestRig() + defer rig.shutdown() tCore := rig.core cert := []byte{} @@ -1344,6 +1362,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) @@ -1640,6 +1659,7 @@ func TestRegister(t *testing.T) { func TestLogin(t *testing.T) { rig := newTestRig() + defer rig.shutdown() tCore := rig.core rig.acct.markFeePaid() @@ -1672,6 +1692,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 { @@ -1688,6 +1709,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) @@ -1800,6 +1822,7 @@ func TestLogin(t *testing.T) { func TestLoginAccountNotFoundError(t *testing.T) { rig := newTestRig() + defer rig.shutdown() tCore := rig.core rig.acct.markFeePaid() @@ -1824,6 +1847,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) @@ -1842,6 +1866,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" @@ -1867,6 +1892,7 @@ func TestInitializeDEXConnectionsAccountNotFoundError(t *testing.T) { func TestConnectDEX(t *testing.T) { rig := newTestRig() + defer rig.shutdown() tCore := rig.core ai := &db.AccountInfo{ @@ -1927,6 +1953,7 @@ func TestConnectDEX(t *testing.T) { func TestInitializeClient(t *testing.T) { rig := newTestRig() + defer rig.shutdown() tCore := rig.core rig.db.existValues[keyParamsKey] = false @@ -1959,6 +1986,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 @@ -2013,6 +2041,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 @@ -2312,6 +2341,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 @@ -2373,6 +2403,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() @@ -2422,6 +2453,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) @@ -2493,6 +2525,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) @@ -2565,6 +2598,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) @@ -3154,6 +3188,7 @@ func TestTradeTracking(t *testing.T) { func TestReconcileTrades(t *testing.T) { rig := newTestRig() + defer rig.shutdown() dc := rig.dc mkt := dc.marketConfig(tDcrBtcMktName) @@ -3370,10 +3405,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) { @@ -3440,8 +3476,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 @@ -3501,6 +3540,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[:], @@ -3566,11 +3613,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 { @@ -3583,6 +3632,7 @@ func TestNotifications(t *testing.T) { func TestResolveActiveTrades(t *testing.T) { rig := newTestRig() + defer rig.shutdown() tCore := rig.core btcWallet, tBtcWallet := newTWallet(tBTC.ID) @@ -3746,9 +3796,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 @@ -3830,6 +3881,7 @@ func TestResolveActiveTrades(t *testing.T) { func TestCompareServerMatches(t *testing.T) { rig := newTestRig() + defer rig.shutdown() preImg := newPreimage() dc := rig.dc @@ -4092,6 +4144,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{ @@ -4144,6 +4197,7 @@ func makeMatchProof(preimages []order.Preimage, commitments []order.Commitment) func TestHandleMatchProofMsg(t *testing.T) { rig := newTestRig() + defer rig.shutdown() pimg := newPreimage() cmt := pimg.Commit() @@ -4195,6 +4249,7 @@ func TestHandleMatchProofMsg(t *testing.T) { func TestLogout(t *testing.T) { rig := newTestRig() + defer rig.shutdown() tCore := rig.core dcrWallet, tDcrWallet := newTWallet(tDCR.ID) @@ -4257,6 +4312,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() {}) @@ -4304,6 +4360,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, @@ -4404,6 +4461,7 @@ func TestAddrHost(t *testing.T) { func TestAssetBalance(t *testing.T) { rig := newTestRig() + defer rig.shutdown() tCore := rig.core wallet, tWallet := newTWallet(tDCR.ID) @@ -4443,6 +4501,7 @@ func TestAssetCounter(t *testing.T) { func TestHandleTradeSuspensionMsg(t *testing.T) { rig := newTestRig() + defer rig.shutdown() tCore := rig.core dc := rig.dc @@ -4640,6 +4699,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) @@ -4737,6 +4797,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) @@ -4848,6 +4909,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{ @@ -4885,6 +4947,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{ @@ -4993,6 +5056,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"), @@ -5063,6 +5127,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{ @@ -5130,6 +5195,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 @@ -5222,6 +5288,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) @@ -5715,6 +5782,7 @@ func TestMatchStatusResolution(t *testing.T) { func TestSuspectTrades(t *testing.T) { rig := newTestRig() + defer rig.shutdown() dc := rig.dc tCore := rig.core @@ -5906,6 +5974,7 @@ func TestSuspectTrades(t *testing.T) { func TestWalletSyncing(t *testing.T) { rig := newTestRig() + defer rig.shutdown() tCore := rig.core noteFeed := tCore.NotificationFeed() 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())