From ee2457f3d56e3f45fb8f028c4bd7de7578e87c4b Mon Sep 17 00:00:00 2001 From: Pablo Date: Thu, 19 Feb 2026 01:45:53 -0400 Subject: [PATCH 1/2] feat: add pagination and new filters to query orders --- cmd/rpc/README.md | 105 +++++++++++++++++++++---------- cmd/rpc/query.go | 36 ++++++++--- cmd/rpc/types.go | 7 +++ fsm/key.go | 11 ++++ fsm/swap.go | 153 +++++++++++++++++++++++++++++++++++++++++++++- lib/swap.go | 9 +++ 6 files changed, 279 insertions(+), 42 deletions(-) diff --git a/cmd/rpc/README.md b/cmd/rpc/README.md index e94250535..6d93fc1e5 100644 --- a/cmd/rpc/README.md +++ b/cmd/rpc/README.md @@ -2554,56 +2554,99 @@ $ curl -X POST localhost:50002/v1/query/order \ **Route:** `/v1/query/orders` -**Description**: view all sell orders for a counter-asset pair +**Description**: view all sell orders for a counter-asset pair with optional filters and pagination **HTTP Method**: `POST` **Request**: - **height**: `uint64` – the block height to read data from (optional: use 0 to read from the latest block) -- **id**: `uint64` – the unique identifier of the committee (optional: use 0 to get all committees) +- **committee**: `uint64` – the unique identifier of the committee to filter by (optional: use 0 to get all committees) +- **sellersSendAddress**: `hex-string` – the seller address to filter orders by (optional: when provided, uses indexed lookup for efficient querying) +- **pageNumber**: `int` – the page number to retrieve (optional: starts at 1) +- **perPage**: `int` – the number of orders per page (optional: defaults to system default) **Response**: -- **orders**: `object` - the swap order book from the 'root chain' for the 'nested chain' - - **chainId**: `uint64` - the unique identifier of the committee - - **orders**: `sell order array` - the actual list of sell orders - - **id**: `hex string` - the 20 byte identifier of the order - - **committee**: `uint64` - the id of the committee that is in-charge of escrow for the swap - - **data**: `hex-string` - a generic data field which can allow a committee to execute specific functionality for the swap - - **amountForSale**: `uint64` - amount of 'root-chain-asset' for sale - - **requestedAmount**: `uint64` - amount of 'counter-asset' the seller of the 'root-chain-asset' receives - - **sellerReceiveAddress**: `hex-string` - the external chain address to receive the 'counter-asset' - - **buyerSendAddress**: `hex-string` - if reserved (locked): the address the buyer will be transferring the funds from - - **buyerChainDeadline**: `hex-string` - the external chain height deadline to send the 'tokens' to SellerReceiveAddress - - **sellersSendAddress**: `hex-string` - the signing address of seller who is selling the CNPY - - +- **pageNumber**: `int` - the current page number +- **perPage**: `int` - the number of items per page +- **results**: `sell order array` - the paginated list of sell orders + - **id**: `hex string` - the 20 byte identifier of the order + - **committee**: `uint64` - the id of the committee that is in-charge of escrow for the swap + - **data**: `hex-string` - a generic data field which can allow a committee to execute specific functionality for the swap + - **amountForSale**: `uint64` - amount of 'root-chain-asset' for sale + - **requestedAmount**: `uint64` - amount of 'counter-asset' the seller of the 'root-chain-asset' receives + - **sellerReceiveAddress**: `hex-string` - the external chain address to receive the 'counter-asset' + - **buyerSendAddress**: `hex-string` - if reserved (locked): the address the buyer will be transferring the funds from + - **buyerChainDeadline**: `hex-string` - the external chain height deadline to send the 'tokens' to SellerReceiveAddress + - **sellersSendAddress**: `hex-string` - the signing address of seller who is selling the CNPY +- **type**: `string` - the type of paginated results ("orders") +- **count**: `int` - the number of items in this page +- **totalPages**: `int` - the total number of pages available +- **totalCount**: `int` - the total number of orders matching the filters + +**Example 1: Basic pagination** ``` $ curl -X POST localhost:50002/v1/query/orders \ -H "Content-Type: application/json" \ -d '{ - "chainId": 1, - "height": 1000 + "pageNumber": 1, + "perPage": 10 }' > { - "chainID": 1, - "orders": [ + "pageNumber": 1, + "perPage": 10, + "results": [ { - "id": "abb1f314f5f300d315a56581ccb0f10fe1665f90c8f09666f7c58abcabfbcedb", - "committee": "1", - "data": "", - "amountForSale": 1000000000000, - "requestedAmount": 2000000000000, - "sellersReceiveAddress": "502c0b3d6ccd1c6f164aa5536b2ba2cb9e80c711", - "buyerSendAddress": "aaac0b3d64c12c6f164545545b2ba2ab4d80deff", - "buyerChainDeadline": 17585, - "sellersSendAddress": "bb43c46244cef15f2451a446cea011fc1a2eddfe" - } - ] + "id": "abb1f314f5f300d315a56581ccb0f10fe1665f90c8f09666f7c58abcabfbcedb", + "committee": 1, + "amountForSale": 1000000000000, + "requestedAmount": 2000000000000, + "sellerReceiveAddress": "502c0b3d6ccd1c6f164aa5536b2ba2cb9e80c711", + "sellersSendAddress": "bb43c46244cef15f2451a446cea011fc1a2eddfe" + } + ], + "type": "orders", + "count": 1, + "totalPages": 1, + "totalCount": 1 } ``` +**Example 2: Filter by committee with pagination** +``` +$ curl -X POST localhost:50002/v1/query/orders \ + -H "Content-Type: application/json" \ + -d '{ + "committee": 1, + "pageNumber": 1, + "perPage": 20 + }' +``` + +**Example 3: Filter by sellersSendAddress with pagination (uses indexed lookup)** +``` +$ curl -X POST localhost:50002/v1/query/orders \ + -H "Content-Type: application/json" \ + -d '{ + "sellersSendAddress": "bb43c46244cef15f2451a446cea011fc1a2eddfe", + "pageNumber": 1, + "perPage": 10 + }' +``` + +**Example 4: Filter by both committee and sellersSendAddress with pagination (most efficient)** +``` +$ curl -X POST localhost:50002/v1/query/orders \ + -H "Content-Type: application/json" \ + -d '{ + "committee": 1, + "sellersSendAddress": "bb43c46244cef15f2451a446cea011fc1a2eddfe", + "pageNumber": 1, + "perPage": 10 + }' +``` + ## Dex Batch **Route:** `/v1/query/dex-batch` **Description**: view the locked dex batch for a committee or all dex batches diff --git a/cmd/rpc/query.go b/cmd/rpc/query.go index 23d26067c..90ff7cf11 100644 --- a/cmd/rpc/query.go +++ b/cmd/rpc/query.go @@ -281,18 +281,21 @@ func (s *Server) Order(w http.ResponseWriter, r *http.Request, _ httprouter.Para }) } -// Orders retrieves the order book for a committee +// Orders retrieves the order book for a committee with optional filters and pagination func (s *Server) Orders(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { // Invoke helper with the HTTP request, response writer and an inline callback - s.heightAndIdParams(w, r, func(s *fsm.StateMachine, id uint64) (any, lib.ErrorI) { - if id == 0 { - return s.GetOrderBooks() - } - b, err := s.GetOrderBook(id) - if err != nil { - return nil, err + s.ordersParams(w, r, func(s *fsm.StateMachine, req *ordersRequest) (any, lib.ErrorI) { + // convert seller address if provided + var sellerAddr []byte + if req.SellersSendAddress != "" { + var err lib.ErrorI + sellerAddr, err = lib.StringToBytes(req.SellersSendAddress) + if err != nil { + return nil, err + } } - return &lib.OrderBooks{OrderBooks: []*lib.OrderBook{b}}, nil + // use paginated query + return s.GetOrdersPaginated(sellerAddr, req.Committee, req.PageParams) }) } @@ -677,6 +680,21 @@ func (s *Server) orderParams(w http.ResponseWriter, r *http.Request, callback fu }) } +// ordersParams is a helper function to abstract common workflows around a callback requiring a state machine and orders request +func (s *Server) ordersParams(w http.ResponseWriter, r *http.Request, callback func(s *fsm.StateMachine, request *ordersRequest) (any, lib.ErrorI)) { + req := new(ordersRequest) + + s.readOnlyStateFromHeightParams(w, r, req, func(state *fsm.StateMachine) (err lib.ErrorI) { + p, err := callback(state, req) + if err != nil { + write(w, err, http.StatusBadRequest) + return + } + write(w, p, http.StatusOK) + return + }) +} + // heightParams is a helper function to abstract common workflows around a callback requiring a state machine func (s *Server) heightParams(w http.ResponseWriter, r *http.Request, callback func(s *fsm.StateMachine) (any, lib.ErrorI)) { req := new(heightRequest) diff --git a/cmd/rpc/types.go b/cmd/rpc/types.go index a3684e8e5..2cced2b33 100644 --- a/cmd/rpc/types.go +++ b/cmd/rpc/types.go @@ -31,6 +31,13 @@ type orderRequest struct { heightRequest } +type ordersRequest struct { + Committee uint64 `json:"committee"` + SellersSendAddress string `json:"sellersSendAddress"` + heightRequest + lib.PageParams +} + type heightsRequest struct { heightRequest StartHeight uint64 `json:"startHeight"` diff --git a/fsm/key.go b/fsm/key.go index 576cf7622..5222e7a44 100644 --- a/fsm/key.go +++ b/fsm/key.go @@ -43,6 +43,7 @@ var ( orderBookPrefix = []byte{13} // store key prefix for 'sell orders' before they are bid on retiredCommitteePrefix = []byte{14} // store key prefix for 'retired' (dead) committees dexPrefix = []byte{15} // store key prefix for 'dex' functionality + orderBySellerPrefix = []byte{16} // store key prefix for 'sell orders' indexed by seller address lockedBatchSegment = []byte{1} nextBatchSement = []byte{2} @@ -78,6 +79,16 @@ func OrderBookPrefix(cId uint64) []byte { return lib.JoinLenPrefix(orderBookPref func KeyForOrder(chainId uint64, orderId []byte) []byte { return append(OrderBookPrefix(chainId), lib.JoinLenPrefix(orderId)...) } + +func OrderBySellerPrefix(seller []byte) []byte { + return lib.JoinLenPrefix(orderBySellerPrefix, seller) +} +func OrderBySellerAndChainPrefix(seller []byte, chainId uint64) []byte { + return append(OrderBySellerPrefix(seller), lib.JoinLenPrefix(formatUint64(chainId))...) +} +func KeyForOrderBySeller(seller []byte, chainId uint64, orderId []byte) []byte { + return append(OrderBySellerAndChainPrefix(seller, chainId), lib.JoinLenPrefix(orderId)...) +} func KeyForUnstaking(height uint64, address crypto.AddressI) []byte { return append(UnstakingPrefix(height), lib.JoinLenPrefix(address.Bytes())...) } diff --git a/fsm/swap.go b/fsm/swap.go index 2ed270203..86d12a92c 100644 --- a/fsm/swap.go +++ b/fsm/swap.go @@ -2,6 +2,7 @@ package fsm import ( "bytes" + "encoding/binary" "encoding/json" "github.com/canopy-network/canopy/lib" "github.com/canopy-network/canopy/lib/crypto" @@ -267,12 +268,26 @@ func (s *StateMachine) SetOrder(order *lib.SellOrder, chainId uint64) (err lib.E return } // set the order book in state - return s.Set(KeyForOrder(chainId, order.Id), protoBytes) + if err = s.Set(KeyForOrder(chainId, order.Id), protoBytes); err != nil { + return + } + // set the secondary index for seller address lookup (value is empty, key is sufficient) + return s.Set(KeyForOrderBySeller(order.SellersSendAddress, chainId, order.Id), []byte{}) } // DeleteOrder() deletes an existing order in the order book for a committee in the state db func (s *StateMachine) DeleteOrder(orderId []byte, chainId uint64) (err lib.ErrorI) { - return s.Delete(KeyForOrder(chainId, orderId)) + // get the order first to retrieve the seller address for index cleanup + order, err := s.GetOrder(orderId, chainId) + if err != nil { + return + } + // delete the primary order entry + if err = s.Delete(KeyForOrder(chainId, orderId)); err != nil { + return + } + // delete the secondary index entry + return s.Delete(KeyForOrderBySeller(order.SellersSendAddress, chainId, orderId)) } // GetOrder() gets the sell order from state @@ -396,6 +411,140 @@ func (s *StateMachine) GetOrderBooks() (b *lib.OrderBooks, err lib.ErrorI) { return } +// GetOrdersBySeller() retrieves all orders for a specific seller address, optionally filtered by chainId +func (s *StateMachine) GetOrdersBySeller(seller []byte, chainId uint64) (b *lib.OrderBooks, err lib.ErrorI) { + b = new(lib.OrderBooks) + // determine the prefix to iterate based on whether chainId filter is provided + var prefix []byte + if chainId == 0 { + prefix = OrderBySellerPrefix(seller) + } else { + prefix = OrderBySellerAndChainPrefix(seller, chainId) + } + // create an iterator over the seller index prefix + it, err := s.Iterator(prefix) + if err != nil { + return + } + defer it.Close() + // map to collect orders by chainId + ordersByChain := make(map[uint64][]*lib.SellOrder) + // for each index entry + for ; it.Valid(); it.Next() { + // extract chainId and orderId from the index key + cId, orderId, e := s.parseOrderBySellerKey(it.Key()) + if e != nil { + s.log.Error(e.Error()) + continue + } + // get the actual order from the primary store + order, e := s.GetOrder(orderId, cId) + if e != nil { + s.log.Error(e.Error()) + continue + } + ordersByChain[cId] = append(ordersByChain[cId], order) + } + // convert map to OrderBooks structure + for cId, orders := range ordersByChain { + b.OrderBooks = append(b.OrderBooks, &lib.OrderBook{ + ChainId: cId, + Orders: orders, + }) + } + // sort by chain id for consistent output + sort.Slice(b.OrderBooks, func(i, j int) bool { + return b.OrderBooks[i].ChainId < b.OrderBooks[j].ChainId + }) + return +} + +// parseOrderBySellerKey() extracts chainId and orderId from an order-by-seller index key +func (s *StateMachine) parseOrderBySellerKey(key []byte) (chainId uint64, orderId []byte, err lib.ErrorI) { + // key format: orderBySellerPrefix + seller + chainId + orderId (all length-prefixed) + segments := lib.DecodeLengthPrefixed(key) + if len(segments) < 4 { + return 0, nil, ErrInvalidKey(key) + } + // segments[0] = prefix, segments[1] = seller, segments[2] = chainId, segments[3] = orderId + chainId = binary.BigEndian.Uint64(segments[2]) + orderId = segments[3] + return +} + +// GetOrdersPaginated() retrieves orders with optional filters and pagination +func (s *StateMachine) GetOrdersPaginated(seller []byte, chainId uint64, p lib.PageParams) (*lib.Page, lib.ErrorI) { + // create the page object + page := lib.NewPage(p, "orders") + results := &lib.SellOrders{} + // determine which query path to use based on filters + if len(seller) > 0 { + // use seller index for efficient lookup + return s.getOrdersBySellerPaginated(seller, chainId, page, results) + } + if chainId != 0 { + // filter by chainId only - iterate orders for that chain + return s.getOrdersByChainPaginated(chainId, page, results) + } + // no filters - iterate all orders across all chains + return s.getAllOrdersPaginated(page, results) +} + +// getOrdersBySellerPaginated() retrieves paginated orders using the seller index +func (s *StateMachine) getOrdersBySellerPaginated(seller []byte, chainId uint64, page *lib.Page, results *lib.SellOrders) (*lib.Page, lib.ErrorI) { + // determine the prefix based on whether chainId filter is provided + var prefix []byte + if chainId == 0 { + prefix = OrderBySellerPrefix(seller) + } else { + prefix = OrderBySellerAndChainPrefix(seller, chainId) + } + // use the page Load function with the index prefix + err := page.Load(prefix, false, results, s, func(k, v []byte) lib.ErrorI { + // extract chainId and orderId from the index key + cId, orderId, e := s.parseOrderBySellerKey(k) + if e != nil { + return e + } + // get the actual order from the primary store + order, e := s.GetOrder(orderId, cId) + if e != nil { + return e + } + *results = append(*results, order) + return nil + }) + return page, err +} + +// getOrdersByChainPaginated() retrieves paginated orders for a specific chain +func (s *StateMachine) getOrdersByChainPaginated(chainId uint64, page *lib.Page, results *lib.SellOrders) (*lib.Page, lib.ErrorI) { + // use the page Load function with the order book prefix + err := page.Load(OrderBookPrefix(chainId), false, results, s, func(k, v []byte) lib.ErrorI { + order, e := s.unmarshalOrder(v) + if e != nil { + return e + } + *results = append(*results, order) + return nil + }) + return page, err +} + +// getAllOrdersPaginated() retrieves paginated orders across all chains +func (s *StateMachine) getAllOrdersPaginated(page *lib.Page, results *lib.SellOrders) (*lib.Page, lib.ErrorI) { + // use the page Load function with the base order book prefix + err := page.Load(lib.JoinLenPrefix(orderBookPrefix), false, results, s, func(k, v []byte) lib.ErrorI { + order, e := s.unmarshalOrder(v) + if e != nil { + return e + } + *results = append(*results, order) + return nil + }) + return page, err +} + // GetTotalEscrowed() checks all order books for escrowed funds for a specific address func (s *StateMachine) GetTotalEscrowed(address crypto.AddressI) (total uint64, err lib.ErrorI) { orderBooks, err := s.GetOrderBooks() diff --git a/lib/swap.go b/lib/swap.go index 48f01fbf2..50602c8da 100644 --- a/lib/swap.go +++ b/lib/swap.go @@ -106,3 +106,12 @@ func (x *OrderBooks) UnmarshalJSON(jsonBytes []byte) (err error) { // exit return } + +// SellOrders is a slice of SellOrder pointers that implements the Pageable interface +type SellOrders []*SellOrder + +// Len returns the number of orders in the slice +func (s *SellOrders) Len() int { return len(*s) } + +// New returns a new empty SellOrders instance (satisfies Pageable interface) +func (s *SellOrders) New() Pageable { return &SellOrders{} } From 173efb7a7c6e8892a5124aaa477d4880d781fcb8 Mon Sep 17 00:00:00 2001 From: Pablo Date: Thu, 19 Feb 2026 14:34:52 -0400 Subject: [PATCH 2/2] chore: get better naming convention and add more comments --- cmd/rpc/README.md | 20 +++++++++++++++----- cmd/rpc/client.go | 6 +++--- cmd/rpc/query.go | 10 ++++++---- cmd/rpc/types.go | 4 ++-- 4 files changed, 26 insertions(+), 14 deletions(-) diff --git a/cmd/rpc/README.md b/cmd/rpc/README.md index 6d93fc1e5..bf0fc1e45 100644 --- a/cmd/rpc/README.md +++ b/cmd/rpc/README.md @@ -2506,14 +2506,14 @@ $ curl -X POST localhost:50002/v1/query/tx-by-hash \ **Route:** `/v1/query/order` -**Description**: view a sell order by its unique idnetifier +**Description**: view a sell order by its unique identifier **HTTP Method**: `POST` **Request**: - **height**: `uint64` – the block height to read data from (optional: use 0 to read from the latest block) -- **chainId**: `uint64` – the unique identifier of the committee +- **committee**: `uint64` – the unique identifier of the committee - **orderId**: `hex-string` – the unique identifier of the order **Response**: @@ -2532,7 +2532,7 @@ $ curl -X POST localhost:50002/v1/query/tx-by-hash \ $ curl -X POST localhost:50002/v1/query/order \ -H "Content-Type: application/json" \ -d '{ - "chainId": 1, + "committee": 1, "orderId": "abb1f314f5f300d315a56581ccb0f10fe1665f90c8f09666f7c58abcabfbcedb", "height": 1000 }' @@ -2604,12 +2604,22 @@ $ curl -X POST localhost:50002/v1/query/orders \ "requestedAmount": 2000000000000, "sellerReceiveAddress": "502c0b3d6ccd1c6f164aa5536b2ba2cb9e80c711", "sellersSendAddress": "bb43c46244cef15f2451a446cea011fc1a2eddfe" + }, + { + "id": "ccd2f425f6f411e426b67692ddc1f21gf2776ga1d9g1a777g8d69bcdbacddfec", + "committee": 1, + "amountForSale": 500000000000, + "requestedAmount": 1000000000000, + "sellerReceiveAddress": "613d1c4e7dde2d7g275bb6647c3cb3dc0f91d822", + "buyerSendAddress": "aaac0b3d64c12c6f164545545b2ba2ab4d80deff", + "buyerChainDeadline": 17585, + "sellersSendAddress": "cc54d57355dfg26g3562b557dfb122gd2b3feegh" } ], "type": "orders", - "count": 1, + "count": 2, "totalPages": 1, - "totalCount": 1 + "totalCount": 2 } ``` diff --git a/cmd/rpc/client.go b/cmd/rpc/client.go index df2d54058..c0af13191 100644 --- a/cmd/rpc/client.go +++ b/cmd/rpc/client.go @@ -1018,10 +1018,10 @@ func (c *Client) heightRequest(routeName string, height uint64, ptr any) (err li return } -func (c *Client) orderRequest(routeName string, height uint64, orderId string, chainId uint64, ptr any) (err lib.ErrorI) { +func (c *Client) orderRequest(routeName string, height uint64, orderId string, committee uint64, ptr any) (err lib.ErrorI) { bz, err := lib.MarshalJSON(orderRequest{ - ChainId: chainId, - OrderId: orderId, + Committee: committee, + OrderId: orderId, heightRequest: heightRequest{ Height: height, }, diff --git a/cmd/rpc/query.go b/cmd/rpc/query.go index 90ff7cf11..4fdb22b41 100644 --- a/cmd/rpc/query.go +++ b/cmd/rpc/query.go @@ -269,7 +269,7 @@ func (s *Server) EcoParameters(w http.ResponseWriter, r *http.Request, _ httprou }) } -// Order gets an order for the specified chain +// Order gets an order for the specified committee func (s *Server) Order(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { // Invoke helper with the HTTP request, response writer and an inline callback s.orderParams(w, r, func(s *fsm.StateMachine, p *orderRequest) (any, lib.ErrorI) { @@ -277,7 +277,7 @@ func (s *Server) Order(w http.ResponseWriter, r *http.Request, _ httprouter.Para if err != nil { return nil, err } - return s.GetOrder(orderId, p.ChainId) + return s.GetOrder(orderId, p.Committee) }) } @@ -667,8 +667,9 @@ func (s *Server) IndexerBlobsCached(height uint64, delta bool) (*fsm.IndexerBlob // orderParams is a helper function to abstract common workflows around a callback requiring a state machine and order request func (s *Server) orderParams(w http.ResponseWriter, r *http.Request, callback func(s *fsm.StateMachine, request *orderRequest) (any, lib.ErrorI)) { + // initialize a new orderRequest object req := new(orderRequest) - + // execute the callback with the state machine and request s.readOnlyStateFromHeightParams(w, r, req, func(state *fsm.StateMachine) (err lib.ErrorI) { p, err := callback(state, req) if err != nil { @@ -682,8 +683,9 @@ func (s *Server) orderParams(w http.ResponseWriter, r *http.Request, callback fu // ordersParams is a helper function to abstract common workflows around a callback requiring a state machine and orders request func (s *Server) ordersParams(w http.ResponseWriter, r *http.Request, callback func(s *fsm.StateMachine, request *ordersRequest) (any, lib.ErrorI)) { + // initialize a new ordersRequest object req := new(ordersRequest) - + // execute the callback with the state machine and request s.readOnlyStateFromHeightParams(w, r, req, func(state *fsm.StateMachine) (err lib.ErrorI) { p, err := callback(state, req) if err != nil { diff --git a/cmd/rpc/types.go b/cmd/rpc/types.go index 2cced2b33..de8f15315 100644 --- a/cmd/rpc/types.go +++ b/cmd/rpc/types.go @@ -26,8 +26,8 @@ type chainRequest struct { } type orderRequest struct { - ChainId uint64 `json:"chainId"` - OrderId string `json:"orderId"` + Committee uint64 `json:"committee"` + OrderId string `json:"orderId"` heightRequest }