diff --git a/client/core/bookie.go b/client/core/bookie.go index d9991f6336..203f43a3ea 100644 --- a/client/core/bookie.go +++ b/client/core/bookie.go @@ -291,8 +291,10 @@ func handleBookOrderMsg(_ *Core, dc *dexConnection, msg *msgjson.Message) error return err } book.send(&BookUpdate{ - Action: msg.Route, - Payload: minifyOrder(note.OrderID, ¬e.TradeNote, 0), + Action: msg.Route, + DEX: dc.acct.url, + MarketID: note.MarketID, + Payload: minifyOrder(note.OrderID, ¬e.TradeNote, 0), }) return nil } @@ -319,8 +321,10 @@ func handleUnbookOrderMsg(_ *Core, dc *dexConnection, msg *msgjson.Message) erro return err } book.send(&BookUpdate{ - Action: msg.Route, - Payload: &MiniOrder{Token: token(note.OrderID)}, + Action: msg.Route, + DEX: dc.acct.url, + MarketID: note.MarketID, + Payload: &MiniOrder{Token: token(note.OrderID)}, }) return nil @@ -348,7 +352,9 @@ func handleUpdateRemainingMsg(_ *Core, dc *dexConnection, msg *msgjson.Message) return err } book.send(&BookUpdate{ - Action: msg.Route, + Action: msg.Route, + DEX: dc.acct.url, + MarketID: note.MarketID, Payload: &RemainingUpdate{ Token: token(note.OrderID), Qty: float64(note.Remaining) / conversionFactor, @@ -366,7 +372,9 @@ func handleEpochOrderMsg(c *Core, dc *dexConnection, msg *msgjson.Message) error return fmt.Errorf("epoch order note unmarshal error: %v", err) } - c.setEpoch(note.Epoch) + if dc.setEpoch(note.MarketID, note.Epoch) { + c.refreshUser() + } dc.booksMtx.RLock() defer dc.booksMtx.RUnlock() @@ -383,8 +391,10 @@ func handleEpochOrderMsg(c *Core, dc *dexConnection, msg *msgjson.Message) error } // Send a mini-order for book updates. book.send(&BookUpdate{ - Action: msg.Route, - Payload: minifyOrder(note.OrderID, ¬e.TradeNote, note.Epoch), + Action: msg.Route, + DEX: dc.acct.url, + MarketID: note.MarketID, + Payload: minifyOrder(note.OrderID, ¬e.TradeNote, note.Epoch), }) return nil diff --git a/client/core/core.go b/client/core/core.go index e7875bb89d..35462fedb1 100644 --- a/client/core/core.go +++ b/client/core/core.go @@ -60,6 +60,9 @@ type dexConnection struct { tradeMtx sync.RWMutex trades map[order.OrderID]*trackedTrade + + epochMtx sync.RWMutex + epoch map[string]uint64 } // refreshMarkets rebuilds, saves, and returns the market map. The map itself @@ -264,17 +267,71 @@ func (dc *dexConnection) compareServerMatches(matches map[order.OrderID]*serverM } // tickAsset checks open matches related to a specific asset for needed action. -func (dc *dexConnection) tickAsset(assetID uint32) { +func (dc *dexConnection) tickAsset(assetID uint32) (numUpdated int) { dc.tradeMtx.RLock() defer dc.tradeMtx.RUnlock() for _, trade := range dc.trades { if trade.Base() == assetID || trade.Quote() == assetID { - err := trade.tick() + n, err := trade.tick() if err != nil { log.Errorf("%s tick error: %v", dc.acct.url, err) } + numUpdated += n } } + return +} + +// market gets the *Market from the marketMap, or nil if mktID is unknown. +func (dc *dexConnection) market(mktID string) *Market { + dc.marketMtx.RLock() + defer dc.marketMtx.RUnlock() + return dc.marketMap[mktID] +} + +// setEpoch sets the epoch. If the passed epoch is greater than the highest +// previously passed epoch, send an epoch notification to all subscribers. +func (dc *dexConnection) setEpoch(mktID string, epochIdx uint64) bool { + dc.epochMtx.Lock() + defer dc.epochMtx.Unlock() + if epochIdx > dc.epoch[mktID] { + dc.epoch[mktID] = epochIdx + dc.notify(newEpochNotification(dc.acct.url, mktID, epochIdx)) + var updateCount int + dc.tradeMtx.Lock() + for _, trade := range dc.trades { + if trade.processEpoch(epochIdx) { + updateCount++ + } + } + dc.tradeMtx.Unlock() + if updateCount > 0 { + dc.refreshMarkets() + return true + } + } + return false +} + +// marketEpochDuration gets the market's epoch duration. If the market is not +// known, an error is logged and 0 is returned. +func (dc *dexConnection) marketEpochDuration(mktID string) uint64 { + mkt := dc.market(mktID) + if mkt == nil { + log.Errorf("marketEpoch called for unknown market %s", mktID) + return 0 + } + return mkt.EpochLen +} + +// marketEpoch gets the epoch index for the specified market and time stamp. If +// the market is not known, 0 is returned. +func (dc *dexConnection) marketEpoch(mktID string, stamp time.Time) uint64 { + epochLen := dc.marketEpochDuration(mktID) + if epochLen == 0 { + return 0 + } + return encode.UnixMilliU(stamp) / epochLen } // blockWaiter is a message waiting to be stamped, signed, and sent once a @@ -323,9 +380,6 @@ type Core struct { noteMtx sync.RWMutex noteChans []chan Notification - - epochMtx sync.RWMutex - epoch uint64 } // New is the constructor for a new Core. @@ -1175,6 +1229,12 @@ func (c *Core) Trade(pw []byte, form *TradeForm) (*Order, error) { return nil, fmt.Errorf("unknown DEX %s", form.DEX) } + mktID := marketName(form.Base, form.Quote) + mkt := dc.market(mktID) + if mkt == nil { + return nil, fmt.Errorf("order placed for unknown market") + } + rate, qty := form.Rate, form.Qty if form.IsLimit && rate == 0 { return nil, fmt.Errorf("zero-rate order not allowed") @@ -1326,7 +1386,7 @@ func (c *Core) Trade(pw []byte, form *TradeForm) (*Order, error) { } // Prepare and store the tracker and get the core.Order to return. - tracker := newTrackedTrade(dbOrder, preImg, dc, c.db, c.latencyQ, wallets, coins, c.notify) + tracker := newTrackedTrade(dbOrder, preImg, dc, mkt.EpochLen, c.db, c.latencyQ, wallets, coins, c.notify) corder, _ := tracker.coreOrder() dc.tradeMtx.Lock() dc.trades[tracker.ID()] = tracker @@ -1742,6 +1802,12 @@ func (c *Core) dbTrackers(dc *dexConnection) (map[order.OrderID]*trackedTrade, e trackers := make(map[order.OrderID]*trackedTrade, len(dbOrders)) for _, dbOrder := range dbOrders { ord, md := dbOrder.Order, dbOrder.MetaData + mktID := marketName(ord.Base(), ord.Quote()) + mkt := dc.market(mktID) + if mkt == nil { + log.Errorf("active %s order retrieved for unknown market %s", ord.ID(), mktID) + continue + } var preImg order.Preimage copy(preImg[:], md.Proof.Preimage) if co, ok := ord.(*order.CancelOrder); ok { @@ -1750,7 +1816,7 @@ func (c *Core) dbTrackers(dc *dexConnection) (map[order.OrderID]*trackedTrade, e } else { var preImg order.Preimage copy(preImg[:], dbOrder.MetaData.Proof.Preimage) - trackers[dbOrder.Order.ID()] = newTrackedTrade(dbOrder, preImg, dc, c.db, c.latencyQ, nil, nil, c.notify) + trackers[dbOrder.Order.ID()] = newTrackedTrade(dbOrder, preImg, dc, mkt.EpochLen, c.db, c.latencyQ, nil, nil, c.notify) } } for oid := range cancels { @@ -1991,6 +2057,7 @@ func (c *Core) connectDEX(acctInfo *db.AccountInfo) (*dexConnection, error) { } marketMap := make(map[string]*Market) + epochMap := make(map[string]uint64) for _, mkt := range dexCfg.Markets { base, quote := assets[mkt.Base], assets[mkt.Quote] market := &Market{ @@ -2004,6 +2071,7 @@ func (c *Core) connectDEX(acctInfo *db.AccountInfo) (*dexConnection, error) { MarketBuyBuffer: mkt.MarketBuyBuffer, } marketMap[mkt.Name] = market + epochMap[mkt.Name] = 0 } // Create the dexConnection and listen for incoming messages. @@ -2017,6 +2085,7 @@ func (c *Core) connectDEX(acctInfo *db.AccountInfo) (*dexConnection, error) { marketMap: marketMap, trades: make(map[order.OrderID]*trackedTrade), notify: c.notify, + epoch: epochMap, } dc.refreshMarkets() @@ -2027,17 +2096,6 @@ func (c *Core) connectDEX(acctInfo *db.AccountInfo) (*dexConnection, error) { return dc, nil } -// If the passed epoch is greater than the highest previously passed epoch, -// send an epoch notification to all subscribers. -func (c *Core) setEpoch(epochIdx uint64) { - c.epochMtx.Lock() - defer c.epochMtx.Unlock() - if epochIdx > c.epoch { - c.epoch = epochIdx - c.notify(newEpochNotification(epochIdx)) - } -} - // handleReconnect is called when a WsConn indicates that a lost connection has // been re-established. func (c *Core) handleReconnect(uri string) { @@ -2053,7 +2111,9 @@ func handleMatchProofMsg(c *Core, dc *dexConnection, msg *msgjson.Message) error } // Expire the epoch - c.setEpoch(note.Epoch + 1) + if dc.setEpoch(note.MarketID, note.Epoch+1) { + c.refreshUser() + } dc.booksMtx.RLock() defer dc.booksMtx.RUnlock() @@ -2164,14 +2224,20 @@ out: log.Error(err) } case <-ticker.C: + var numUpdated int dc.tradeMtx.Lock() for _, trade := range dc.trades { - err := trade.tick() + n, err := trade.tick() if err != nil { log.Error(err) } + numUpdated += n } dc.tradeMtx.Unlock() + if numUpdated > 0 { + dc.refreshMarkets() + c.refreshUser() + } case <-c.ctx.Done(): break out } @@ -2260,7 +2326,12 @@ func handleAuditRoute(c *Core, dc *dexConnection, msg *msgjson.Message) error { if err != nil { return err } - return tracker.tick() + numUpdated, err := tracker.tick() + if numUpdated > 0 { + dc.refreshMarkets() + c.refreshUser() + } + return err } // handleRedemptionRoute handles the DEX-originating redemption request, which @@ -2281,7 +2352,12 @@ func handleRedemptionRoute(c *Core, dc *dexConnection, msg *msgjson.Message) err if err != nil { return err } - return tracker.tick() + numUpdated, err := tracker.tick() + if numUpdated > 0 { + dc.refreshMarkets() + c.refreshUser() + } + return err } // removeWaiter removes a blockWaiter from the map. @@ -2319,10 +2395,18 @@ func (c *Core) tipChange(assetID uint32, nodeErr error) { } c.waiterMtx.Unlock() c.connMtx.RLock() + var numUpdated int for _, dc := range c.conns { - dc.tickAsset(assetID) + n := dc.tickAsset(assetID) + if n > 0 { + dc.refreshMarkets() + } + numUpdated += n } c.connMtx.RUnlock() + if numUpdated > 0 { + c.refreshUser() + } } // convertAssetInfo converts from a *msgjson.Asset to the nearly identical diff --git a/client/core/core_test.go b/client/core/core_test.go index 86e531743f..e08851a0df 100644 --- a/client/core/core_test.go +++ b/client/core/core_test.go @@ -181,6 +181,7 @@ func testDexConnection() (*dexConnection, *TWebsocket, *dexAccount) { notify: func(Notification) {}, marketMap: map[string]*Market{tDcrBtcMktName: mkt}, trades: make(map[order.OrderID]*trackedTrade), + epoch: map[string]uint64{tDcrBtcMktName: 0}, }, conn, acct } @@ -672,14 +673,13 @@ func TestMarkets(t *testing.T) { func TestDexConnectionOrderBook(t *testing.T) { rig := newTestRig() tCore := rig.core - mid := marketName(tDCR.ID, tBTC.ID) dc := rig.dc // Ensure handleOrderBookMsg creates an order book as expected. oid1 := ordertest.RandomOrderID() bookMsg, err := msgjson.NewResponse(1, &msgjson.OrderBook{ Seq: 1, - MarketID: mid, + MarketID: tDcrBtcMktName, Orders: []*msgjson.BookOrderNote{ { TradeNote: msgjson.TradeNote{ @@ -689,7 +689,7 @@ func TestDexConnectionOrderBook(t *testing.T) { }, OrderNote: msgjson.OrderNote{ Seq: 1, - MarketID: mid, + MarketID: tDcrBtcMktName, OrderID: oid1[:], }, }, @@ -708,7 +708,7 @@ func TestDexConnectionOrderBook(t *testing.T) { }, OrderNote: msgjson.OrderNote{ Seq: 2, - MarketID: mid, + MarketID: tDcrBtcMktName, OrderID: oid2[:], }, }) @@ -781,7 +781,7 @@ func TestDexConnectionOrderBook(t *testing.T) { }, OrderNote: msgjson.OrderNote{ Seq: 3, - MarketID: mid, + MarketID: tDcrBtcMktName, OrderID: oid3[:], }, }) @@ -816,7 +816,7 @@ func TestDexConnectionOrderBook(t *testing.T) { bookNote, _ = msgjson.NewNotification(msgjson.BookOrderRoute, &msgjson.UpdateRemainingNote{ OrderNote: msgjson.OrderNote{ Seq: 4, - MarketID: mid, + MarketID: tDcrBtcMktName, OrderID: oid3[:], }, Remaining: 5 * 1e8, @@ -842,7 +842,7 @@ func TestDexConnectionOrderBook(t *testing.T) { // order book as expected. unbookNote, _ := msgjson.NewNotification(msgjson.UnbookOrderRoute, &msgjson.UnbookOrderNote{ Seq: 5, - MarketID: mid, + MarketID: tDcrBtcMktName, OrderID: oid1[:], }) @@ -1695,29 +1695,10 @@ func TestTrade(t *testing.T) { func TestCancel(t *testing.T) { rig := newTestRig() dc := rig.dc - preImg := newPreimage() - lo := &order.LimitOrder{ - P: order.Prefix{ - OrderType: order.LimitOrderType, - BaseAsset: tDCR.ID, - QuoteAsset: tBTC.ID, - ClientTime: time.Now(), - ServerTime: time.Now(), - Commit: preImg.Commit(), - }, - } - dbOrder := &db.MetaOrder{ - MetaData: &db.OrderMetaData{ - Status: order.OrderStatusEpoch, - DEX: dc.acct.url, - Proof: db.OrderProof{ - Preimage: preImg[:], - }, - }, - Order: lo, - } + lo, dbOrder, preImg, _ := makeLimitOrder(dc, true, 0, 0) oid := lo.ID() - tracker := newTrackedTrade(dbOrder, preImg, dc, rig.db, rig.queue, nil, nil, rig.core.notify) + mkt := dc.market(tDcrBtcMktName) + tracker := newTrackedTrade(dbOrder, preImg, dc, mkt.EpochLen, rig.db, rig.queue, nil, nil, rig.core.notify) dc.trades[oid] = tracker handleCancel := func(msg *msgjson.Message, f msgFunc) error { @@ -1783,9 +1764,10 @@ func TestHandlePreimageRequest(t *testing.T) { req, _ := msgjson.NewRequest(rig.dc.NextID(), msgjson.PreimageRoute, payload) tracker := &trackedTrade{ - Order: ord, - preImg: preImg, - dc: rig.dc, + Order: ord, + preImg: preImg, + dc: rig.dc, + metaData: &db.OrderMetaData{}, } rig.dc.trades[oid] = tracker err := handlePreimageRequest(rig.core, rig.dc, req) @@ -1872,42 +1854,15 @@ func TestTradeTracking(t *testing.T) { cancelledQty := tDCR.LotSize qty := 2*matchSize + cancelledQty rate := tBTC.RateStep * 10 - preImgL := newPreimage() - addr := ordertest.RandomAddress() - lo := &order.LimitOrder{ - P: order.Prefix{ - AccountID: dc.acct.ID(), - BaseAsset: tDCR.ID, - QuoteAsset: tBTC.ID, - OrderType: order.LimitOrderType, - ClientTime: time.Now(), - ServerTime: time.Now().Add(time.Millisecond), - Commit: preImgL.Commit(), - }, - T: order.Trade{ - Sell: true, - Quantity: qty, - Address: addr, - }, - Rate: tBTC.RateStep, - } - dbOrder := &db.MetaOrder{ - MetaData: &db.OrderMetaData{ - Status: order.OrderStatusEpoch, - DEX: dc.acct.url, - Proof: db.OrderProof{ - Preimage: preImgL[:], - }, - }, - Order: lo, - } + lo, dbOrder, preImgL, addr := makeLimitOrder(dc, true, qty, tBTC.RateStep) loid := lo.ID() mid := ordertest.RandomMatchID() walletSet, err := tCore.walletSet(dc, tDCR.ID, tBTC.ID, true) if err != nil { t.Fatalf("walletSet error: %v", err) } - tracker := newTrackedTrade(dbOrder, preImgL, dc, rig.db, rig.queue, walletSet, nil, rig.core.notify) + mkt := dc.market(tDcrBtcMktName) + tracker := newTrackedTrade(dbOrder, preImgL, dc, mkt.EpochLen, rig.db, rig.queue, walletSet, nil, rig.core.notify) rig.dc.trades[tracker.ID()] = tracker var match *matchTracker checkStatus := func(tag string, wantStatus order.MatchStatus) { @@ -2408,10 +2363,11 @@ func TestReadConnectMatches(t *testing.T) { } oid := lo.ID() dbOrder := &db.MetaOrder{ - // MetaData: &db.OrderMetaData{}, - Order: lo, + MetaData: &db.OrderMetaData{}, + Order: lo, } - tracker := newTrackedTrade(dbOrder, preImg, dc, rig.db, rig.queue, nil, nil, notify) + mkt := dc.market(tDcrBtcMktName) + tracker := newTrackedTrade(dbOrder, preImg, dc, mkt.EpochLen, rig.db, rig.queue, nil, nil, notify) metaMatch := db.MetaMatch{ MetaData: &db.MatchMetaData{}, Match: &order.UserMatch{}, @@ -2576,11 +2532,10 @@ func TestHandleEpochOrderMsg(t *testing.T) { rig := newTestRig() ord := &order.LimitOrder{P: order.Prefix{ServerTime: time.Now()}} oid := ord.ID() - mid := hex.EncodeToString(encode.RandomBytes(order.OrderIDSize)) payload := &msgjson.EpochOrderNote{ BookOrderNote: msgjson.BookOrderNote{ OrderNote: msgjson.OrderNote{ - MarketID: mid, + MarketID: tDcrBtcMktName, OrderID: oid.Bytes(), }, TradeNote: msgjson.TradeNote{ @@ -2601,7 +2556,7 @@ func TestHandleEpochOrderMsg(t *testing.T) { t.Fatal("[handleEpochOrderMsg] expected a non-existent orderbook error") } - rig.dc.books[mid] = newBookie(func() {}) + rig.dc.books[tDcrBtcMktName] = newBookie(func() {}) err = handleEpochOrderMsg(rig.core, rig.dc, req) if err != nil { @@ -2627,7 +2582,6 @@ func makeMatchProof(preimages []order.Preimage, commitments []order.Commitment) func TestHandleMatchProofMsg(t *testing.T) { rig := newTestRig() - mid := hex.EncodeToString(encode.RandomBytes(order.OrderIDSize)) pimg := newPreimage() cmt := pimg.Commit() @@ -2637,7 +2591,7 @@ func TestHandleMatchProofMsg(t *testing.T) { } payload := &msgjson.MatchProofNote{ - MarketID: mid, + MarketID: tDcrBtcMktName, Epoch: 1, Preimages: []dex.Bytes{pimg[:]}, CSum: csum[:], @@ -2647,7 +2601,7 @@ func TestHandleMatchProofMsg(t *testing.T) { eo := &msgjson.EpochOrderNote{ BookOrderNote: msgjson.BookOrderNote{ OrderNote: msgjson.OrderNote{ - MarketID: mid, + MarketID: tDcrBtcMktName, OrderID: encode.RandomBytes(order.OrderIDSize), }, }, @@ -2664,9 +2618,9 @@ func TestHandleMatchProofMsg(t *testing.T) { t.Fatal("[handleMatchProofMsg] expected a non-existent orderbook error") } - rig.dc.books[mid] = newBookie(func() {}) + rig.dc.books[tDcrBtcMktName] = newBookie(func() {}) - err = rig.dc.books[mid].Enqueue(eo) + err = rig.dc.books[tDcrBtcMktName].Enqueue(eo) if err != nil { t.Fatalf("[Enqueue] unexpected error: %v", err) } @@ -2722,3 +2676,103 @@ func TestLogout(t *testing.T) { tDcrWallet.lockErr = tErr ensureErr("lock wallet") } + +func TestSetEpoch(t *testing.T) { + rig := newTestRig() + dc := rig.dc + rig.dc.books[tDcrBtcMktName] = newBookie(func() {}) + lo, dbOrder, preImg, _ := makeLimitOrder(dc, true, 0, 0) + mkt := dc.market(tDcrBtcMktName) + tracker := newTrackedTrade(dbOrder, preImg, dc, mkt.EpochLen, rig.db, rig.queue, nil, nil, rig.core.notify) + dc.trades[lo.ID()] = tracker + metaData := tracker.metaData + + payload := &msgjson.MatchProofNote{ + MarketID: tDcrBtcMktName, + Epoch: uint64(tracker.Time()) / tracker.epochLen, + } + nextReq := func() *msgjson.Message { + payload.Epoch++ + req, _ := msgjson.NewRequest(rig.dc.NextID(), msgjson.MatchProofRoute, payload) + return req + } + + ensureStatus := func(tag string, status order.OrderStatus) { + err := handleMatchProofMsg(rig.core, rig.dc, nextReq()) + if err != nil { + t.Fatalf("error setting epoch for %s: %v", tag, err) + } + if metaData.Status != status { + t.Fatalf("wrong status for %s. expected %s, got %s", tag, status, metaData.Status) + } + corder, _ := tracker.coreOrderInternal() + if corder.Status != status { + t.Fatalf("wrong core order status for %s. expected %s, got %s", tag, status, corder.Status) + } + } + + // Immediate limit order + ensureStatus("immediate limit order", order.OrderStatusExecuted) + + // Standing limit order + metaData.Status = order.OrderStatusEpoch + lo.Force = order.StandingTiF + ensureStatus("standing limit order", order.OrderStatusBooked) + + // Market order + mo := &order.MarketOrder{ + P: lo.P, + T: *lo.T.Copy(), + } + mo.P.OrderType = order.LimitOrderType + dbOrder = &db.MetaOrder{ + MetaData: &db.OrderMetaData{ + Status: order.OrderStatusEpoch, + }, + Order: mo, + } + tracker = newTrackedTrade(dbOrder, preImg, dc, mkt.EpochLen, rig.db, rig.queue, nil, nil, rig.core.notify) + metaData = tracker.metaData + dc.trades[mo.ID()] = tracker + ensureStatus("market order", order.OrderStatusExecuted) + + // Same epoch shouldn't result in change. + payload.Epoch-- + metaData.Status = order.OrderStatusEpoch + ensureStatus("market order unchanged", order.OrderStatusEpoch) + +} + +func makeLimitOrder(dc *dexConnection, sell bool, qty, rate uint64) (*order.LimitOrder, *db.MetaOrder, order.Preimage, string) { + preImg := newPreimage() + addr := ordertest.RandomAddress() + lo := &order.LimitOrder{ + P: order.Prefix{ + AccountID: dc.acct.ID(), + BaseAsset: tDCR.ID, + QuoteAsset: tBTC.ID, + OrderType: order.LimitOrderType, + ClientTime: time.Now(), + ServerTime: time.Now().Add(time.Millisecond), + Commit: preImg.Commit(), + }, + T: order.Trade{ + Sell: true, + Quantity: qty, + Address: addr, + }, + Rate: tBTC.RateStep, + Force: order.ImmediateTiF, + } + dbOrder := &db.MetaOrder{ + MetaData: &db.OrderMetaData{ + Status: order.OrderStatusEpoch, + DEX: dc.acct.url, + Proof: db.OrderProof{ + Preimage: preImg[:], + }, + }, + Order: lo, + } + return lo, dbOrder, preImg, addr +} diff --git a/client/core/notification.go b/client/core/notification.go index 49d3173af4..27e0b63dd0 100644 --- a/client/core/notification.go +++ b/client/core/notification.go @@ -154,11 +154,15 @@ func (on *OrderNote) String() string { // EpochNotification is a data notification that a new epoch has begun. type EpochNotification struct { db.Notification - Epoch uint64 `json:"epoch"` + DEX string `json:"dex"` + MarketID string `json:"marketID"` + Epoch uint64 `json:"epoch"` } -func newEpochNotification(epochIdx uint64) *EpochNotification { +func newEpochNotification(dexAddr, mktID string, epochIdx uint64) *EpochNotification { return &EpochNotification{ + DEX: dexAddr, + MarketID: mktID, Notification: db.NewNotification("epoch", "", "", db.Data), Epoch: epochIdx, } diff --git a/client/core/trade.go b/client/core/trade.go index 90e07d031a..1c620c9484 100644 --- a/client/core/trade.go +++ b/client/core/trade.go @@ -88,12 +88,12 @@ type trackedTrade struct { matchMtx sync.RWMutex matches map[order.MatchID]*matchTracker notify func(Notification) + epochLen uint64 } // newTrackedTrade is a constructor for a trackedTrade. -func newTrackedTrade(dbOrder *db.MetaOrder, preImg order.Preimage, dc *dexConnection, +func newTrackedTrade(dbOrder *db.MetaOrder, preImg order.Preimage, dc *dexConnection, epochLen uint64, db db.DB, latencyQ *wait.TickerQueue, wallets *walletSet, coins asset.Coins, notify func(Notification)) *trackedTrade { - ord := dbOrder.Order return &trackedTrade{ Order: ord, @@ -107,6 +107,7 @@ func newTrackedTrade(dbOrder *db.MetaOrder, preImg order.Preimage, dc *dexConnec coins: mapifyCoins(coins), matches: make(map[order.MatchID]*matchTracker), notify: notify, + epochLen: epochLen, } } @@ -135,33 +136,38 @@ func (t *trackedTrade) coreOrder() (*Order, *Order) { // should be called with both the mtx and the matchMtx locked. func (t *trackedTrade) coreOrderInternal() (*Order, *Order) { prefix, trade := t.Prefix(), t.Trade() + cancelling := t.cancel != nil + canceled := cancelling && t.cancel.matches.maker != nil + + orderEpoch := t.dc.marketEpoch(t.mktID, prefix.ServerTime) var tif order.TimeInForce if lo, ok := t.Order.(*order.LimitOrder); ok { tif = lo.Force } - cancelling := t.cancel != nil corder := &Order{ - DEX: t.dc.acct.url, - MarketID: t.mktID, - Type: prefix.OrderType, - ID: t.ID().String(), - Stamp: encode.UnixMilliU(prefix.ServerTime), - Rate: t.rate(), - Qty: trade.Quantity, - Sell: trade.Sell, - Filled: trade.Filled(), - Cancelling: cancelling, - Canceled: cancelling && t.cancel.matches.maker != nil, - + DEX: t.dc.acct.url, + MarketID: t.mktID, + Type: prefix.OrderType, + ID: t.ID().String(), + Stamp: encode.UnixMilliU(prefix.ServerTime), + Status: t.metaData.Status, + Epoch: orderEpoch, + Rate: t.rate(), + Qty: trade.Quantity, + Sell: trade.Sell, + Filled: trade.Filled(), + Cancelling: cancelling, + Canceled: canceled, TimeInForce: tif, } for _, match := range t.matches { dbMatch := match.Match corder.Matches = append(corder.Matches, &Match{ MatchID: match.id.String(), - Step: dbMatch.Status, + Status: dbMatch.Status, Rate: dbMatch.Rate, Qty: dbMatch.Quantity, + Side: dbMatch.Side, }) } var cancelOrder *Order @@ -171,6 +177,7 @@ func (t *trackedTrade) coreOrderInternal() (*Order, *Order) { MarketID: t.mktID, Type: order.CancelOrderType, Stamp: encode.UnixMilliU(t.cancel.ServerTime), + Epoch: orderEpoch, TargetID: t.cancel.TargetOrderID.String(), } } @@ -327,27 +334,6 @@ func (t *trackedTrade) negotiate(msgMatches []*msgjson.Match) error { // The filled amount includes all of the trackedTrade's matches, so the // filled amount must be set, not just increased. trade.SetFill(filled) - corder, cancelOrder := t.coreOrderInternal() - t.matchMtx.RUnlock() - // Send notifications. Call coreOrderInternal with the matchMtx locked. - if includesCancellation { - details := fmt.Sprintf("%s order on %s-%s at %s has been canceled (%s)", - strings.Title(sellString(trade.Sell)), unbip(t.Base()), unbip(t.Quote()), t.dc.acct.url, t.token()) - t.notify(newOrderNote("Order canceled", details, db.Success, corder)) - // Also send out a data notification with the cancel order information. - t.notify(newOrderNote("cancel", "", db.Data, cancelOrder)) - // Set the order status for both orders. - t.metaData.Status = order.OrderStatusCanceled - t.db.UpdateOrderStatus(t.cancel.ID(), order.OrderStatusExecuted) - } - if includesTrades { - fillRatio := float64(trade.Filled()) / float64(trade.Quantity) - details := fmt.Sprintf("%s order on %s-%s %.1f%% filled (%s)", - strings.Title(sellString(trade.Sell)), unbip(t.Base()), unbip(t.Quote()), fillRatio*100, t.token()) - log.Debugf("trade order %v matched with %d orders", t.ID(), len(msgMatches)) - t.notify(newOrderNote("Matches made", details, db.Poke, corder)) - } - // Set the order as executed depending on type and fill. if t.metaData.Status != order.OrderStatusCanceled { switch t.Prefix().Type() { @@ -361,13 +347,34 @@ func (t *trackedTrade) negotiate(msgMatches []*msgjson.Match) error { t.metaData.Status = order.OrderStatusExecuted } } + corder, cancelOrder := t.coreOrderInternal() + t.matchMtx.RUnlock() err := t.db.UpdateOrder(t.metaOrder()) if err != nil { return fmt.Errorf("Failed to update order in db") } - return t.tick() + // Send notifications. + if includesCancellation { + details := fmt.Sprintf("%s order on %s-%s at %s has been canceled (%s)", + strings.Title(sellString(trade.Sell)), unbip(t.Base()), unbip(t.Quote()), t.dc.acct.url, t.token()) + t.notify(newOrderNote("Order canceled", details, db.Success, corder)) + // Also send out a data notification with the cancel order information. + t.notify(newOrderNote("cancel", "", db.Data, cancelOrder)) + // Set the order status for both orders. + t.metaData.Status = order.OrderStatusCanceled + t.db.UpdateOrderStatus(t.cancel.ID(), order.OrderStatusExecuted) + } + if includesTrades { + fillRatio := float64(trade.Filled()) / float64(trade.Quantity) + details := fmt.Sprintf("%s order on %s-%s %.1f%% filled (%s)", + strings.Title(sellString(trade.Sell)), unbip(t.Base()), unbip(t.Quote()), fillRatio*100, t.token()) + log.Debugf("trade order %v matched with %d orders", t.ID(), len(msgMatches)) + t.notify(newOrderNote("Matches made", details, db.Poke, corder)) + } + _, err = t.tick() + return err } func (t *trackedTrade) metaOrder() *db.MetaOrder { @@ -466,9 +473,10 @@ func (t *trackedTrade) isRedeemable(match *matchTracker) bool { } // tick will check for and perform any match actions necessary. -func (t *trackedTrade) tick() error { +func (t *trackedTrade) tick() (int, error) { var swaps []*matchTracker var redeems []*matchTracker + errs := newErrorSet(t.dc.acct.url + " tick: ") t.matchMtx.Lock() defer t.matchMtx.Unlock() @@ -495,11 +503,10 @@ func (t *trackedTrade) tick() error { // notifications before swapMatches. corder, _ := t.coreOrderInternal() if err != nil { - log.Errorf("swapMatches: %v", err) + errs.add("swapMatches: %v", err) details := fmt.Sprintf("Error encountered sending a swap output(s) worth %.8f %s on order %s", float64(qty)/conversionFactor, unbip(fromID), t.token()) t.notify(newOrderNote("Swap error", details, db.ErrorLevel, corder)) - return err } else { details := fmt.Sprintf("Sent swaps worth %.8f %s on order %s", float64(qty)/conversionFactor, unbip(fromID), t.token()) @@ -516,18 +523,17 @@ func (t *trackedTrade) tick() error { err := t.redeemMatches(redeems) corder, _ := t.coreOrderInternal() if err != nil { - log.Errorf("redeemMatches: %v", err) + errs.add("redeemMatches: %v", err) details := fmt.Sprintf("Error encountered sending redemptions worth %.8f %s on order %s", float64(qty)/conversionFactor, unbip(toAsset), t.token()) t.notify(newOrderNote("Redemption error", details, db.ErrorLevel, corder)) - return err } else { details := fmt.Sprintf("Redeemed %.8f %s on order %s", float64(qty)/conversionFactor, unbip(toAsset), t.token()) t.notify(newOrderNote("Match complete", details, db.Poke, corder)) } } - return nil + return len(swaps) + len(redeems), errs.ifany() } // swapMatches will send a transaction with swap outputs for the specified @@ -900,6 +906,25 @@ func (t *trackedTrade) processRedemption(msgID uint64, redemption *msgjson.Redem return nil } +// processEpoch processes the new epoch index. If an existing trade has an epoch +// index < the new index and has a status of OrderStatusEpoch, the order will +// be assumed to have matched without error, and the status set to executed or +// booked, depending on the order type. +func (t *trackedTrade) processEpoch(epochIdx uint64) bool { + t.mtx.Lock() + defer t.mtx.Unlock() + tradeEpoch := uint64(t.Time()) / t.epochLen + if t.metaData.Status != order.OrderStatusEpoch || epochIdx <= tradeEpoch { + return false + } + t.metaData.Status = order.OrderStatusExecuted + if lo, ok := t.Order.(*order.LimitOrder); ok && lo.Force == order.StandingTiF { + t.metaData.Status = order.OrderStatusBooked + } + t.db.UpdateOrderStatus(t.ID(), t.metaData.Status) + return true +} + // mapifyCoins converts the slice of coins to a map keyed by hex coin ID. func mapifyCoins(coins asset.Coins) map[string]asset.Coin { coinMap := make(map[string]asset.Coin, len(coins)) diff --git a/client/core/types.go b/client/core/types.go index 9aa0d1e984..8588fd3da1 100644 --- a/client/core/types.go +++ b/client/core/types.go @@ -111,9 +111,10 @@ type RegisterForm struct { // Match represents a match on an order. An order may have many matches. type Match struct { MatchID string `json:"matchID"` - Step order.MatchStatus `json:"status"` + Status order.MatchStatus `json:"status"` Rate uint64 `json:"rate"` Qty uint64 `json:"qty"` + Side order.MatchSide `json:"side"` } // Order is core's general type for an order. An order may be a market, limit, @@ -124,6 +125,8 @@ type Order struct { Type order.OrderType `json:"type"` ID string `json:"id"` Stamp uint64 `json:"stamp"` + Status order.OrderStatus `json:"status"` + Epoch uint64 `json:"epoch"` Qty uint64 `json:"qty"` Sell bool `json:"sell"` Filled uint64 `json:"filled"` @@ -199,11 +202,12 @@ func newDisplayIDFromSymbols(base, quote string) string { // MiniOrder is minimal information about an order in a market's order book. type MiniOrder struct { - Qty float64 `json:"qty"` - Rate float64 `json:"rate"` - Epoch uint64 `json:"epoch"` - Sell bool `json:"sell"` - Token string `json:"token"` + Qty float64 `json:"qty"` + Rate float64 `json:"rate"` + Epoch uint64 `json:"epoch"` + Sell bool `json:"sell"` + Token string `json:"token"` + MarketID string `json:"marketID"` } // RemainingUpdate is an update to the quantity for an order on the order book. @@ -228,8 +232,10 @@ const ( // BookUpdate is an order book update. type BookUpdate struct { - Action string `json:"action"` - Payload interface{} `json:"payload"` + Action string `json:"action"` + DEX string `json:"dex"` + MarketID string `json:"marketID"` + Payload interface{} `json:"payload"` } // dexAccount is the core type to represent the client's account information for @@ -417,7 +423,7 @@ type TradeForm struct { TifNow bool `json:"tifnow"` } -// mktID is a string ID constructed from the asset IDs. +// marketName is a string ID constructed from the asset IDs. func marketName(b, q uint32) string { mkt, _ := dex.MarketName(b, q) return mkt diff --git a/client/webserver/live_test.go b/client/webserver/live_test.go index 8f18afeade..3f8ab699ee 100644 --- a/client/webserver/live_test.go +++ b/client/webserver/live_test.go @@ -51,33 +51,61 @@ func randomMagnitude(low, high int) float64 { return mantissa * math.Pow10(exponent) } +func userOrders() (ords []*core.Order) { + orderCount := rand.Intn(5) + for i := 0; i < orderCount; i++ { + qty := uint64(randomMagnitude(7, 11)) + filled := uint64(rand.Float64() * float64(qty)) + orderType := order.OrderType(rand.Intn(2) + 1) + status := order.OrderStatusEpoch + epoch := encode.UnixMilliU(time.Now()) / uint64(epochDuration.Milliseconds()) + isLimit := orderType == order.LimitOrderType + if rand.Float32() > 0.5 { + epoch -= 1 + if isLimit { + status = order.OrderStatusBooked + } else { + status = order.OrderStatusExecuted + } + } + var tif order.TimeInForce + if isLimit && rand.Float32() > 0.66 { + tif = order.StandingTiF + } + ords = append(ords, &core.Order{ + ID: ordertest.RandomOrderID().String(), + Type: orderType, + Stamp: encode.UnixMilliU(time.Now()) - uint64(rand.Float64()*600_000), + Status: status, + Epoch: epoch, + Rate: uint64(randomMagnitude(-2, 4)), + Qty: qty, + Sell: rand.Intn(2) > 0, + Filled: filled, + Matches: []*core.Match{ + { + Qty: uint64(rand.Float64() * float64(filled)), + Status: order.MatchComplete, + }, + }, + TimeInForce: tif, + }) + } + return +} + func mkMrkt(base, quote string) *core.Market { baseID, _ := dex.BipSymbolID(base) quoteID, _ := dex.BipSymbolID(quote) - market := &core.Market{ + return &core.Market{ Name: fmt.Sprintf("%s-%s", base, quote), BaseID: baseID, BaseSymbol: base, QuoteID: quoteID, QuoteSymbol: quote, MarketBuyBuffer: rand.Float64() + 1, + EpochLen: uint64(epochDuration.Milliseconds()), } - orderCount := rand.Intn(5) - qty := uint64(randomMagnitude(7, 11)) - for i := 0; i < orderCount; i++ { - market.Orders = append(market.Orders, &core.Order{ - ID: ordertest.RandomOrderID().String(), - Type: order.OrderType(rand.Intn(2) + 1), - Stamp: encode.UnixMilliU(time.Now()) - uint64(rand.Float64()*86_400_000), - Rate: uint64(randomMagnitude(-2, 4)), - Qty: qty, - Sell: rand.Intn(2) > 0, - Filled: uint64(rand.Float64() * float64(qty)), - Matches: nil, - }) - } - - return market } func mkSupportedAsset(symbol string, state *tWalletState, bal uint64) *core.SupportedAsset { @@ -218,19 +246,21 @@ type tWalletState struct { } type TCore struct { - reg *core.RegisterForm - inited bool - mtx sync.RWMutex - wallets map[uint32]*tWalletState - balances map[uint32]uint64 - midGap float64 - maxQty float64 - feed *core.BookFeed - killFeed context.CancelFunc - buys map[string]*core.MiniOrder - sells map[string]*core.MiniOrder - noteFeed chan core.Notification - orderMtx sync.Mutex + reg *core.RegisterForm + inited bool + mtx sync.RWMutex + wallets map[uint32]*tWalletState + balances map[uint32]uint64 + dexAddr string + marketID string + midGap float64 + maxQty float64 + feed *core.BookFeed + killFeed context.CancelFunc + buys map[string]*core.MiniOrder + sells map[string]*core.MiniOrder + noteFeed chan core.Notification + orderMtx sync.Mutex epochOrders []*core.BookUpdate } @@ -275,9 +305,14 @@ func (c *TCore) Register(r *core.RegisterForm) error { func (c *TCore) Login([]byte) ([]*db.Notification, error) { return nil, nil } func (c *TCore) Logout() error { return nil } -func (c *TCore) Sync(dex string, base, quote uint32) (*core.OrderBook, *core.BookFeed, error) { +func (c *TCore) Sync(dexAddr string, base, quote uint32) (*core.OrderBook, *core.BookFeed, error) { c.midGap = randomMagnitude(-2, 4) c.maxQty = randomMagnitude(-2, 4) + mktID, _ := dex.MarketName(base, quote) + c.mtx.Lock() + c.dexAddr = dexAddr + c.marketID = mktID + c.mtx.Unlock() if c.feed != nil { c.killFeed() @@ -297,7 +332,6 @@ func (c *TCore) Sync(dex string, base, quote uint32) (*core.OrderBook, *core.Boo switch { case r < 0.80: // Book order - sell := rand.Float32() < 0.5 ord := randomOrder(sell, c.maxQty, c.midGap, 0.05*c.midGap, true) c.orderMtx.Lock() @@ -307,7 +341,9 @@ func (c *TCore) Sync(dex string, base, quote uint32) (*core.OrderBook, *core.Boo } side[ord.Token] = ord epochOrder := &core.BookUpdate{ - Action: msgjson.EpochOrderRoute, + Action: msgjson.EpochOrderRoute, + DEX: c.dexAddr, + MarketID: mktID, Payload: ord, } c.trySend(epochOrder) @@ -333,7 +369,9 @@ func (c *TCore) Sync(dex string, base, quote uint32) (*core.OrderBook, *core.Boo c.orderMtx.Unlock() c.trySend(&core.BookUpdate{ - Action: msgjson.UnbookOrderRoute, + Action: msgjson.UnbookOrderRoute, + DEX: c.dexAddr, + MarketID: mktID, Payload: &core.MiniOrder{Token: tkn}, }) } @@ -527,11 +565,15 @@ func (c *TCore) Wallets() []*core.WalletState { } func (c *TCore) User() *core.User { - // unregistered user should not have exchanges exchanges := map[string]*core.Exchange{} if c.reg != nil { exchanges = tExchanges } + for _, xc := range tExchanges { + for _, mkt := range xc.Markets { + mkt.Orders = userOrders() + } + } user := &core.User{ Exchanges: exchanges, Initialized: c.inited, @@ -596,7 +638,13 @@ out: select { case <-epochTick: epochTick = time.NewTimer(epochDuration - time.Since(time.Now().Truncate(epochDuration))).C + c.mtx.RLock() + dexAddr := c.dexAddr + mktID := c.marketID + c.mtx.RUnlock() c.noteFeed <- &core.EpochNotification{ + DEX: dexAddr, + MarketID: mktID, Notification: db.NewNotification("epoch", "", "", db.Data), Epoch: getEpoch(), } @@ -604,11 +652,11 @@ out: // Send limit orders as newly booked. for _, o := range c.epochOrders { miniOrder := o.Payload.(*core.MiniOrder) - if (miniOrder.Rate > 0) { + if miniOrder.Rate > 0 { miniOrder.Epoch = 0 o.Action = msgjson.BookOrderRoute c.trySend(o) - if (miniOrder.Sell) { + if miniOrder.Sell { c.sells[miniOrder.Token] = miniOrder } else { c.buys[miniOrder.Token] = miniOrder diff --git a/client/webserver/site/src/html/markets.tmpl b/client/webserver/site/src/html/markets.tmpl index de3f92a9d3..84139d82e4 100644 --- a/client/webserver/site/src/html/markets.tmpl +++ b/client/webserver/site/src/html/markets.tmpl @@ -201,21 +201,27 @@ + + + + + + diff --git a/client/webserver/site/src/js/markets.js b/client/webserver/site/src/js/markets.js index e632a90012..ea4a9b08f9 100644 --- a/client/webserver/site/src/js/markets.js +++ b/client/webserver/site/src/js/markets.js @@ -11,7 +11,7 @@ var app const bind = Doc.bind const LIMIT = 1 -// const MARKET = 2 +const MARKET = 2 // const CANCEL = 3 const bookRoute = 'book' @@ -22,6 +22,25 @@ const epochOrderRoute = 'epoch_order' const bookUpdateRoute = 'bookupdate' const unmarketRoute = 'unmarket' +/* The match statuses are a mirror of dex/order.MatchStatus. */ +// const newlyMatched = 0 +// const makerSwapCast = 1 +// const takerSwapCast = 2 +const makerRedeemed = 3 +// const matchComplete = 4 + +/* The time-in-force specifiers are a mirror of dex/order.TimeInForce. */ +const immediateTiF = 0 +// const standingTiF = 1 + +/* The order statuses are a mirror of dex/order.OrderStatus. */ +const statusUnknown = 0 +const statusEpoch = 1 +const statusBooked = 2 +const statusExecuted = 3 +const statusCanceled = 4 +const statusRevoked = 5 + const animationLength = 500 const check = document.createElement('span') @@ -178,6 +197,13 @@ export default class MarketsPage extends BasePage { // it was found. const firstEntry = mktFound ? lastMarket : this.markets[0] this.setMarket(firstEntry.dex, firstEntry.base, firstEntry.quote) + + // Start a ticker to update time-since values. + this.secondTicker = setInterval(() => { + this.page.liveList.querySelectorAll('[data-col=age]').forEach(td => { + td.textContent = Doc.timeSince(td.parentNode.order.stamp) + }) + }, 1000) } /* isSell is true if the user has selected sell in the order options. */ @@ -416,17 +442,11 @@ export default class MarketsPage extends BasePage { Doc.empty(page.liveList) for (const order of orders) { const row = page.liveTemplate.cloneNode(true) + row.order = order orderRows[order.id] = row - const set = (col, s) => { row.querySelector(`[data-col=${col}]`).textContent = s } - set('side', order.sell ? 'sell' : 'buy') - set('age', Doc.timeSince(order.stamp)) - set('rate', Doc.formatCoinValue(order.rate / 1e8)) - set('qty', Doc.formatCoinValue(order.qty / 1e8)) - set('filled', `${(order.filled / order.qty * 100).toFixed(1)}%`) + updateUserOrderRow(row, order) if (order.type === LIMIT) { - if (order.cancelling) { - set('cancel', order.canceled ? 'canceled' : 'cancelling') - } else if (order.filled !== order.qty) { + if (!order.cancelling && order.filled !== order.qty) { const icon = row.querySelector('[data-col=cancel] > span') Doc.show(icon) bind(icon, 'click', e => { @@ -476,6 +496,7 @@ export default class MarketsPage extends BasePage { /* handleBookOrderRoute is the handler for 'book_order' notifications. */ handleBookOrderRoute (data) { + if (data.dex !== this.market.dex.url || data.marketID !== this.market.sid) return const order = data.payload if (order.rate > 0) this.book.add(order) this.addTableOrder(order) @@ -484,6 +505,7 @@ export default class MarketsPage extends BasePage { /* handleUnbookOrderRoute is the handler for 'unbook_order' notifications. */ handleUnbookOrderRoute (data) { + if (data.dex !== this.market.dex.url || data.marketID !== this.market.sid) return const order = data.payload this.book.remove(order.token) this.removeTableOrder(order) @@ -495,6 +517,7 @@ export default class MarketsPage extends BasePage { * notifications. */ handleUpdateRemainingRoute (data) { + if (data.dex !== this.market.dex.url || data.marketID !== this.market.sid) return const update = data.payload this.book.updateRemaining(update.token, update.qty) this.updateTableOrder(update) @@ -503,6 +526,7 @@ export default class MarketsPage extends BasePage { /* handleEpochOrderRoute is the handler for 'epoch_order' notifications. */ handleEpochOrderRoute (data) { + if (data.dex !== this.market.dex.url || data.marketID !== this.market.sid) return const order = data.payload if (order.rate > 0) this.book.add(order) this.addTableOrder(order) @@ -579,8 +603,7 @@ export default class MarketsPage extends BasePage { const page = this.page const remaining = order.qty - order.filled page.cancelRemain.textContent = Doc.formatCoinValue(remaining / 1e8) - const isMarketBuy = !order.isLimit && !order.sell - const symbol = isMarketBuy ? this.market.quote.symbol : this.market.base.symbol + const symbol = isMarketBuy(order) ? this.market.quote.symbol : this.market.base.symbol page.cancelUnit.textContent = symbol.toUpperCase() this.showForm(page.cancelForm) bind(page.cancelSubmit, 'click', async () => { @@ -646,11 +669,10 @@ export default class MarketsPage extends BasePage { } else { const row = this.orderRows[order.id] if (!row) return - const td = row.querySelector('[data-col=filled]') - td.textContent = `${(order.filled / order.qty * 100).toFixed(1)}%` + updateUserOrderRow(row, order) if (order.filled === order.qty) { // Remove the cancellation button. - row.querySelector('[data-col=cancel]').textContent = '' + updateDataCol(row, 'cancel', '') } } } @@ -659,12 +681,28 @@ export default class MarketsPage extends BasePage { * handleEpochNote handles notifications signalling the start of a new epoch. */ handleEpochNote (note) { + if (note.dex !== this.market.dex.url || note.marketID !== this.market.sid) return if (this.book) { this.book.setEpoch(note.epoch) this.chart.draw() } this.clearOrderTableEpochs(note.epoch) - this.chart.draw() + for (const tr of Array.from(this.page.liveList.children)) { + const order = tr.order + const alreadyMatched = note.epoch > order.epoch + const statusTD = tr.querySelector('[data-col=status]') + switch (true) { + case order.type === LIMIT && order.status === statusEpoch && alreadyMatched: + statusTD.textContent = order.tif === immediateTiF ? 'executed' : 'booked' + order.status = order.tif === immediateTiF ? statusExecuted : statusBooked + break + case order.type === MARKET && order.status === statusEpoch: + // Tehcnically don't know if this should be 'executed' or 'settling'. + statusTD.textContent = 'executed' + order.status = statusExecuted + break + } + } } /* @@ -690,12 +728,11 @@ export default class MarketsPage extends BasePage { // ordering. Grab updated info. const baseWallet = app.walletMap[market.base.id] const quoteWallet = app.walletMap[market.quote.id] + await app.fetchUser() if (!baseWallet.open || !quoteWallet.open) { - await app.fetchUser() this.updateWallet(market.base.id) this.updateWallet(market.quote.id) } - app.orders(order.dex, order.base, order.quote).push(res.order) this.refreshActiveOrders() } @@ -929,6 +966,7 @@ export default class MarketsPage extends BasePage { ws.deregisterRoute(bookOrderRoute) ws.deregisterRoute(unbookOrderRoute) this.chart.unattach() + clearInterval(this.secondTicker) } } @@ -954,3 +992,68 @@ function swapBttns (before, now) { before.classList.remove('selected') now.classList.add('selected') } + +/* sumSettled sums the quantities of the matches that have completed. */ +function sumSettled (order) { + if (!order.matches) return 0 + const qty = isMarketBuy(order) ? m => m.qty * m.rate * 1e-8 : m => m.qty + return order.matches.reduce((settled, match) => { + // >= makerRedeemed is used because the maker never actually goes to + // matchComplete (once at makerRedeemed, nothing left to do), and the taker + // never goes to makerRedeemed, since at that point, they just complete the + // swap. + return (match.status >= makerRedeemed) ? settled + qty(match) : settled + }, 0) +} + +/* + * hasLiveMatches returns true if the order has matches that have not completed + * settlement yet. + */ +function hasLiveMatches (order) { + if (!order.matches) return false + for (const match of order.matches) { + if (match.status < makerRedeemed) return true + } + return false +} + +/* statusString converts the order status to a string */ +function statusString (order) { + const isLive = hasLiveMatches(order) + switch (order.status) { + case statusUnknown: return 'unknown' + case statusEpoch: return 'epoch' + case statusBooked: return order.cancelling ? 'cancelling' : 'booked' + case statusExecuted: return isLive ? 'settling' : 'excecuted' + case statusCanceled: return isLive ? 'canceled/settling' : 'canceled' + case statusRevoked: return isLive ? 'revoked/settling' : 'revoked' + } +} + +/* + * updateDataCol sets the value of data-col=[col] . + */ +function updateDataCol (tr, col, s) { + tr.querySelector(`[data-col=${col}]`).textContent = s +} + +/* + * updateUserOrderRow sets the td contents of the user's order table row. + */ +function updateUserOrderRow (tr, order) { + updateDataCol(tr, 'type', order.type === LIMIT ? 'limit' : 'market') + updateDataCol(tr, 'side', order.sell ? 'sell' : 'buy') + updateDataCol(tr, 'age', Doc.timeSince(order.stamp)) + updateDataCol(tr, 'rate', Doc.formatCoinValue(order.rate / 1e8)) + updateDataCol(tr, 'qty', Doc.formatCoinValue(order.qty / 1e8)) + updateDataCol(tr, 'filled', `${(order.filled / order.qty * 100).toFixed(1)}%`) + updateDataCol(tr, 'settled', `${(sumSettled(order) / order.qty * 100).toFixed(1)}%`) + updateDataCol(tr, 'status', statusString(order)) +} + +/* isMarketBuy will return true if the order is a market buy order. */ +function isMarketBuy (order) { + return order.type === MARKET && !order.sell +}
Type Side Age Rate Quantity FilledSettledStatus
element that is a child + * of the specified