From d948af4151ed33b67d9ad5f5d93017ae13838d1a Mon Sep 17 00:00:00 2001 From: JoeGruff Date: Thu, 20 Feb 2025 13:56:53 +0900 Subject: [PATCH] tatanka: Add orderbook map. --- tatanka/client/orderbook/orderbook.go | 149 ++++++++++++++++ tatanka/client/orderbook/orderbook_test.go | 196 +++++++++++++++++++++ tatanka/tanka/swaps.go | 60 ++++++- 3 files changed, 397 insertions(+), 8 deletions(-) create mode 100644 tatanka/client/orderbook/orderbook.go create mode 100644 tatanka/client/orderbook/orderbook_test.go diff --git a/tatanka/client/orderbook/orderbook.go b/tatanka/client/orderbook/orderbook.go new file mode 100644 index 0000000000..0ff0832026 --- /dev/null +++ b/tatanka/client/orderbook/orderbook.go @@ -0,0 +1,149 @@ +// This code is available on the terms of the project LICENSE.md file, +// also available online at https://blueoakcouncil.org/license/1.0.0. + +package orderbook + +import ( + "sort" + "sync" + + "decred.org/dcrdex/tatanka/tanka" +) + +type OrderBooker interface { + OrderIDs() [][32]byte + Order(id [32]byte) *tanka.OrderUpdate + Orders(ids [][32]byte) []*tanka.OrderUpdate + FindOrders(filter *OrderFilter) []*tanka.OrderUpdate + AddUpdate(*tanka.OrderUpdate) + Delete(id [32]byte) +} + +type OrderFilter struct { + IsSell *bool + Check func(*tanka.OrderUpdate) (ok, done bool) +} + +var _ OrderBooker = (*OrderBook)(nil) + +type OrderBook struct { + mtx sync.RWMutex + book map[[32]byte]*tanka.OrderUpdate + // buys and sells use the same pointers as the book and are sorted + // with the best trade first. + buys, sells []*tanka.OrderUpdate +} + +func NewOrderBook() *OrderBook { + return &OrderBook{ + book: make(map[[32]byte]*tanka.OrderUpdate), + } +} + +func (ob *OrderBook) OrderIDs() [][32]byte { + ob.mtx.RLock() + defer ob.mtx.RUnlock() + ids := make([][32]byte, len(ob.book)) + i := 0 + for id := range ob.book { + ids[i] = id + i++ + } + return ids +} + +func (ob *OrderBook) Order(id [32]byte) *tanka.OrderUpdate { + ob.mtx.RLock() + defer ob.mtx.RUnlock() + return ob.book[id] +} + +func (ob *OrderBook) Orders(ids [][32]byte) []*tanka.OrderUpdate { + ob.mtx.RLock() + defer ob.mtx.RUnlock() + ords := make([]*tanka.OrderUpdate, 0, len(ids)) + for _, id := range ids { + if ou, has := ob.book[id]; has { + ords = append(ords, ou) + } + } + return ords +} + +func (ob *OrderBook) FindOrders(filter *OrderFilter) (ous []*tanka.OrderUpdate) { + if filter == nil { + return nil + } + find := func(isSell bool) { + ords := ob.buys + if isSell { + ords = ob.sells + } + for _, ou := range ords { + if filter.Check != nil { + ok, done := filter.Check(ou) + if !ok { + continue + } + if done { + break + } + } + ous = append(ous, ou) + } + } + if filter.IsSell != nil { + find(*filter.IsSell) + } else { + find(false) + find(true) + } + return ous +} + +func (ob *OrderBook) addOrderAndSort(ou *tanka.OrderUpdate) { + ords := &ob.buys + sortFn := func(i, j int) bool { return (*ords)[i].Rate > (*ords)[j].Rate } + if ou.Sell { + ords = &ob.sells + sortFn = func(i, j int) bool { return (*ords)[i].Rate < (*ords)[j].Rate } + } + *ords = append(*ords, ou) + sort.Slice(*ords, sortFn) +} + +func (ob *OrderBook) deleteSortedOrder(ou *tanka.OrderUpdate) { + ords := &ob.buys + if ou.Sell { + ords = &ob.sells + } + for i, o := range *ords { + // Comparing pointers. + if o == ou { + *ords = append((*ords)[:i], (*ords)[i+1:]...) + break + } + } +} + +func (ob *OrderBook) AddUpdate(ou *tanka.OrderUpdate) { + id := ou.ID() + ob.mtx.Lock() + defer ob.mtx.Unlock() + oldOU, has := ob.book[id] + ob.book[id] = ou + if has { + ob.deleteSortedOrder(oldOU) + } + ob.addOrderAndSort(ou) +} + +func (ob *OrderBook) Delete(id [32]byte) { + ob.mtx.Lock() + defer ob.mtx.Unlock() + ou, has := ob.book[id] + if has { + delete(ob.book, id) + ob.deleteSortedOrder(ou) + } +} diff --git a/tatanka/client/orderbook/orderbook_test.go b/tatanka/client/orderbook/orderbook_test.go new file mode 100644 index 0000000000..c1045ba906 --- /dev/null +++ b/tatanka/client/orderbook/orderbook_test.go @@ -0,0 +1,196 @@ +package orderbook + +import ( + "testing" + "time" + + "decred.org/dcrdex/dex/encode" + "decred.org/dcrdex/tatanka/tanka" +) + +func testOrders() []*tanka.OrderUpdate { + mustParse := func(s string) time.Time { + t, err := time.Parse(time.RFC1123, s) + if err != nil { + panic(err) + } + return t + } + + var peer tanka.PeerID + copy(peer[:], encode.RandomBytes(32)) + baseID, quoteID := uint32(0), uint32(42) + lowbuy := &tanka.OrderUpdate{ + Sig: encode.RandomBytes(32), + Order: &tanka.Order{ + From: peer, + BaseID: baseID, + QuoteID: quoteID, + Sell: false, + Qty: 1000, + Rate: 123, + LotSize: 3, + Nonce: 0, + Stamp: mustParse("Sun, 12 Dec 2024 12:23:00 UTC"), + }, + Expiration: mustParse("Sun, 12 Jan 2025 12:23:00 UTC"), + Settled: 500, + } + highbuy := &tanka.OrderUpdate{ + Sig: encode.RandomBytes(32), + Order: &tanka.Order{ + From: peer, + BaseID: baseID, + QuoteID: quoteID, + Sell: false, + Qty: 1000, + Rate: 1234, + LotSize: 2, + Nonce: 1, + Stamp: mustParse("Sun, 12 Dec 2024 12:23:00 UTC"), + }, + Expiration: mustParse("Sun, 12 Jan 2025 12:23:00 UTC"), + Settled: 500, + } + lowsell := &tanka.OrderUpdate{ + Sig: encode.RandomBytes(32), + Order: &tanka.Order{ + From: peer, + BaseID: baseID, + QuoteID: quoteID, + Sell: true, + Qty: 1000, + Rate: 12345, + LotSize: 2, + Nonce: 2, + Stamp: mustParse("Sun, 12 Dec 2024 12:23:00 UTC"), + }, + Expiration: mustParse("Sun, 12 Jan 2025 12:23:00 UTC"), + Settled: 500, + } + highsell := &tanka.OrderUpdate{ + Sig: encode.RandomBytes(32), + Order: &tanka.Order{ + From: peer, + BaseID: baseID, + QuoteID: quoteID, + Sell: true, + Qty: 1000, + Rate: 123456, + LotSize: 4, + Nonce: 3, + Stamp: mustParse("Sun, 12 Dec 2024 12:23:00 UTC"), + }, + Expiration: mustParse("Sun, 12 Jan 2025 12:23:00 UTC"), + Settled: 500, + } + return []*tanka.OrderUpdate{lowbuy, highbuy, lowsell, highsell} +} + +func TestOrderIDs(t *testing.T) { + ob := NewOrderBook() + for _, o := range testOrders() { + ob.AddUpdate(o) + } + + ous := ob.OrderIDs() + if len(ous) != 4 { + t.Fatalf("wanted 4 but got %d orders", len(ous)) + } +} + +func TestOrders(t *testing.T) { + ob := NewOrderBook() + var oids [][32]byte + for _, o := range testOrders() { + ob.AddUpdate(o) + oids = append(oids, o.ID()) + } + ous := ob.Orders(oids) + if len(ous) != 4 { + t.Fatalf("wanted 4 but got %d orders", len(oids)) + } +} + +func TestFindOrders(t *testing.T) { + ob := NewOrderBook() + tOrds := testOrders() + for _, o := range tOrds { + ob.AddUpdate(o) + } + yes, no := true, false + + tests := []struct { + name string + filter *OrderFilter + wantOrderLen int + wantOrderIdx []int + }{{ + name: "all orders", + filter: new(OrderFilter), + wantOrderLen: 4, + wantOrderIdx: []int{1, 0, 2, 3}, + }, { + name: "sells", + filter: &OrderFilter{ + IsSell: &yes, + }, + wantOrderLen: 2, + wantOrderIdx: []int{2, 3}, + }, { + name: "buys", + filter: &OrderFilter{ + IsSell: &no, + }, + wantOrderLen: 2, + wantOrderIdx: []int{1, 0}, + }, { + name: "lot size over 2", + filter: &OrderFilter{ + Check: func(ou *tanka.OrderUpdate) (ok, done bool) { + return ou.LotSize > 2, false + }, + }, + wantOrderLen: 2, + wantOrderIdx: []int{0, 3}, + }, { + name: "lot size over 2 and sell", + filter: &OrderFilter{ + IsSell: &yes, + Check: func(ou *tanka.OrderUpdate) (ok, done bool) { + return ou.LotSize > 2, false + }, + }, + wantOrderLen: 1, + wantOrderIdx: []int{3}, + }, { + name: "buy done after one", + filter: &OrderFilter{ + IsSell: &no, + Check: func() func(*tanka.OrderUpdate) (ok, done bool) { + var i int + return func(ou *tanka.OrderUpdate) (ok, done bool) { + defer func() { i++ }() + return true, i == 1 + } + }(), + }, + wantOrderLen: 1, + wantOrderIdx: []int{1}, + }, { + name: "nil filter", + }} + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ous := ob.FindOrders(test.filter) + if len(ous) != test.wantOrderLen { + t.Fatalf("wanted %d but got %d orders", test.wantOrderLen, len(ous)) + } + for i, idx := range test.wantOrderIdx { + if tOrds[idx].ID() != ous[i].ID() { + t.Fatalf("returned order at idx %d not equal to test order at %d", idx, i) + } + } + }) + } +} diff --git a/tatanka/tanka/swaps.go b/tatanka/tanka/swaps.go index de71735c7c..4c5e6b1471 100644 --- a/tatanka/tanka/swaps.go +++ b/tatanka/tanka/swaps.go @@ -25,13 +25,14 @@ type Order struct { // orderbook orders that don't have the requisite lot size. The UI should // show lot size selection in terms of a sliding scale of fee exposure. // Lot sizes can only be powers of 2. - LotSize uint64 `json:"lotSize"` - Stamp time.Time `json:"stamp"` - Expiration time.Time `json:"expiration"` + LotSize uint64 `json:"lotSize"` + Stamp time.Time `json:"stamp"` + // Nonce can be used to force unique ids while other values are the same. + Nonce uint32 `json:"nonce"` } func (ord *Order) ID() [32]byte { - const msgLen = 32 + 4 + 4 + 1 + 8 + 8 + 8 + 8 + 8 + const msgLen = 32 + 4 + 4 + 1 + 8 + 8 + 8 + 8 + 4 b := make([]byte, msgLen) copy(b[:32], ord.From[:]) binary.BigEndian.PutUint32(b[32:36], ord.BaseID) @@ -43,7 +44,7 @@ func (ord *Order) ID() [32]byte { binary.BigEndian.PutUint64(b[49:57], ord.Rate) binary.BigEndian.PutUint64(b[57:65], ord.LotSize) binary.BigEndian.PutUint64(b[65:73], uint64(ord.Stamp.UnixMilli())) - binary.BigEndian.PutUint64(b[73:81], uint64(ord.Expiration.UnixMilli())) + binary.BigEndian.PutUint32(b[73:77], ord.Nonce) return blake256.Sum256(b) } @@ -65,9 +66,6 @@ func (ord *Order) Valid() error { if ord.Rate == 0 { return errors.New("order rate is zero") } - if ord.Expiration.Equal(ord.Stamp) || ord.Expiration.Before(ord.Stamp) { - return errors.New("order is pre-expired") - } return nil } @@ -103,3 +101,49 @@ type MarketParameters struct { BaseID uint32 `json:"baseID"` QuoteID uint32 `json:"quoteID"` } + +const ( + OrderExpiration = time.Hour * 12 + OrderUpdateVersion = 0 +) + +type OrderUpdate struct { + *Order + Version uint8 `json:"version"` + Expiration time.Time `json:"expiration"` + Settled uint64 `json:"settled"` + // The signature of all other serialized fields with private key + // belonging to PeerID. + Sig []byte `json:"sig"` +} + +func NewOrderUpdate(o *Order, settled uint64) *OrderUpdate { + return &OrderUpdate{ + Version: OrderUpdateVersion, + Expiration: o.Stamp.Add(OrderExpiration), + Settled: settled, + Order: o, + } +} + +// Serialize returns the bytes needed to sign the update. +func (ou *OrderUpdate) Serialize() ([]byte, error) { + msgLen := 1 + 32 + 4 + 4 + 1 + 8 + 8 + 8 + 8 + 4 + 8 + 8 + b := make([]byte, msgLen) + b[0] = ou.Version + copy(b[1:33], ou.From[:]) + binary.BigEndian.PutUint32(b[33:37], ou.BaseID) + binary.BigEndian.PutUint32(b[37:41], ou.QuoteID) + if ou.Sell { + b[42] = 1 + } + binary.BigEndian.PutUint64(b[42:50], ou.Qty) + binary.BigEndian.PutUint64(b[50:58], ou.Rate) + binary.BigEndian.PutUint64(b[58:66], ou.LotSize) + binary.BigEndian.PutUint64(b[66:74], uint64(ou.Stamp.UnixMilli())) + binary.BigEndian.PutUint32(b[74:78], ou.Nonce) + binary.BigEndian.PutUint64(b[78:84], uint64(ou.Expiration.UnixMilli())) + binary.BigEndian.PutUint64(b[84:92], ou.Settled) + + return b, nil +}