Merge pull request #912 from kornpow/listswaps_paginate

add simple pagination to the ListSwaps command
This commit is contained in:
András Bánki-Horváth 2025-04-22 19:59:54 +02:00 committed by GitHub
commit 7a620ea4ae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 1295 additions and 864 deletions

View file

@ -35,6 +35,15 @@ var listSwapsCommand = cli.Command{
labelFlag,
channelFlag,
lastHopFlag,
cli.Uint64Flag{
Name: "max_swaps",
Usage: "Max number of swaps to return after filtering",
},
cli.Int64Flag{
Name: "start_time_ns",
Usage: "Unix timestamp in nanoseconds to select swaps initiated " +
"after this time",
},
},
}
@ -99,9 +108,19 @@ func listSwaps(ctx *cli.Context) error {
filter.Label = ctx.String(labelFlag.Name)
}
// Parse start timestamp if set.
if ctx.IsSet("start_time_ns") {
startTimestamp, err := strconv.ParseInt(ctx.String("start_time_ns"), 10, 64)
if err != nil {
return fmt.Errorf("error parsing start timestamp: %w", err)
}
filter.StartTimestampNs = startTimestamp
}
resp, err := client.ListSwaps(
context.Background(), &looprpc.ListSwapsRequest{
ListSwapFilter: filter,
MaxSwaps: ctx.Uint64("max_swaps"),
},
)
if err != nil {

View file

@ -2,11 +2,13 @@ package loopd
import (
"bytes"
"cmp"
"context"
"encoding/hex"
"errors"
"fmt"
"reflect"
"slices"
"sort"
"strings"
"sync"
@ -563,8 +565,11 @@ func (s *swapClientServer) ListSwaps(ctx context.Context,
req *looprpc.ListSwapsRequest) (*looprpc.ListSwapsResponse, error) {
var (
rpcSwaps = []*looprpc.SwapStatus{}
idx = 0
rpcSwaps = []*looprpc.SwapStatus{}
swapInfos = []*loop.SwapInfo{}
maxSwaps = int(req.MaxSwaps)
nextStartTime = int64(0)
canPage = false
)
s.swapsLock.Lock()
@ -580,14 +585,43 @@ func (s *swapClientServer) ListSwaps(ctx context.Context,
continue
}
rpcSwap, err := s.marshallSwap(ctx, &swp)
swapInfos = append(swapInfos, &swp)
}
// Sort the swaps by initiation time in ascending order (oldest first).
slices.SortFunc(swapInfos, func(a, b *loop.SwapInfo) int {
return cmp.Compare(
a.InitiationTime.UnixNano(),
b.InitiationTime.UnixNano(),
)
})
// Apply the maxSwaps limit if specified.
if maxSwaps > 0 && len(swapInfos) > maxSwaps {
canPage = true
swapInfos = swapInfos[:maxSwaps]
}
// Marshal the filtered and limited swaps.
for _, swp := range swapInfos {
rpcSwap, err := s.marshallSwap(ctx, swp)
if err != nil {
return nil, err
}
rpcSwaps = append(rpcSwaps, rpcSwap)
idx++
}
return &looprpc.ListSwapsResponse{Swaps: rpcSwaps}, nil
// Set the next start time for pagination if needed.
if canPage && len(rpcSwaps) > 0 {
// Use the initiation time of the last swap plus 1 nanosecond.
nextStartTime = rpcSwaps[len(rpcSwaps)-1].InitiationTime + 1
}
response := looprpc.ListSwapsResponse{
Swaps: rpcSwaps,
NextStartTime: nextStartTime,
}
return &response, nil
}
// filterSwap filters the given swap based on the provided filter.
@ -617,6 +651,13 @@ func filterSwap(swapInfo *loop.SwapInfo, filter *looprpc.ListSwapsFilter) bool {
return false
}
// If timestamp filters are set, only return swaps within the specified time range.
if filter.StartTimestampNs > 0 &&
swapInfo.InitiationTime.UnixNano() < filter.StartTimestampNs {
return false
}
// If the swap is of type loop out and the outgoing channel filter is
// set, we only return swaps that match the filter.
if swapInfo.SwapType == swap.TypeOut && filter.OutgoingChanSet != nil {

View file

@ -4,6 +4,7 @@ import (
"context"
"os"
"testing"
"time"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/chaincfg"
@ -11,8 +12,11 @@ import (
"github.com/lightninglabs/lndclient"
"github.com/lightninglabs/loop"
"github.com/lightninglabs/loop/labels"
"github.com/lightninglabs/loop/loopdb"
"github.com/lightninglabs/loop/looprpc"
"github.com/lightninglabs/loop/swap"
mock_lnd "github.com/lightninglabs/loop/test"
"github.com/lightningnetwork/lnd/lntypes"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/routing/route"
"github.com/stretchr/testify/require"
@ -595,3 +599,295 @@ func TestHasBandwidth(t *testing.T) {
})
}
}
// TestListSwapsFilterAndPagination tests the filtering and
// paging of the ListSwaps command.
func TestListSwapsFilterAndPagination(t *testing.T) {
unixTime := time.Unix(0, 0)
firstSwapStartTime := unixTime.Add(10 * time.Minute)
secondSwapStartTime := unixTime.Add(20 * time.Minute)
thirdSwapStartTime := unixTime.Add(30 * time.Minute)
// Create a set of test swaps of various types which contain the minimal
// viable amount of info to successfully be run through marshallSwap.
swapInOrder0 := loop.SwapInfo{
SwapStateData: loopdb.SwapStateData{
State: loopdb.StateInitiated,
Cost: loopdb.SwapCost{},
},
SwapContract: loopdb.SwapContract{
InitiationTime: firstSwapStartTime,
},
LastUpdate: time.Now(),
SwapHash: lntypes.Hash{1},
SwapType: swap.TypeIn,
HtlcAddressP2WSH: testnetAddr,
HtlcAddressP2TR: testnetAddr,
}
swapOutOrder1 := loop.SwapInfo{
SwapStateData: loopdb.SwapStateData{
State: loopdb.StateInitiated,
Cost: loopdb.SwapCost{},
},
SwapContract: loopdb.SwapContract{
InitiationTime: secondSwapStartTime,
},
LastUpdate: time.Now(),
SwapHash: lntypes.Hash{2},
SwapType: swap.TypeOut,
HtlcAddressP2WSH: testnetAddr,
HtlcAddressP2TR: testnetAddr,
}
swapOutOrder2 := loop.SwapInfo{
SwapStateData: loopdb.SwapStateData{
State: loopdb.StateInitiated,
Cost: loopdb.SwapCost{},
},
SwapContract: loopdb.SwapContract{
InitiationTime: thirdSwapStartTime,
},
LastUpdate: time.Now(),
SwapHash: lntypes.Hash{3},
SwapType: swap.TypeOut,
HtlcAddressP2WSH: testnetAddr,
HtlcAddressP2TR: testnetAddr,
}
mockSwaps := []loop.SwapInfo{swapInOrder0, swapOutOrder1, swapOutOrder2}
tests := []struct {
name string
// Define the mock swaps that will be stored in the mock client.
mockSwaps []loop.SwapInfo
req *looprpc.ListSwapsRequest
// These hashes must be in the correct return order as the response.
expectedReturnedSwaps []lntypes.Hash
expectedNextStartTime int64
}{
{
name: "fetch with defaults",
mockSwaps: mockSwaps,
req: &looprpc.ListSwapsRequest{},
expectedReturnedSwaps: []lntypes.Hash{
swapInOrder0.SwapHash,
swapOutOrder1.SwapHash,
swapOutOrder2.SwapHash,
},
expectedNextStartTime: 0,
},
{
name: "fetch with swaptype=loopin filter",
mockSwaps: mockSwaps,
req: &looprpc.ListSwapsRequest{
ListSwapFilter: &looprpc.ListSwapsFilter{
SwapType: looprpc.ListSwapsFilter_LOOP_IN},
},
expectedReturnedSwaps: []lntypes.Hash{
swapInOrder0.SwapHash,
},
expectedNextStartTime: 0,
},
{
name: "fetch with swaptype=loopout filter",
mockSwaps: mockSwaps,
req: &looprpc.ListSwapsRequest{
ListSwapFilter: &looprpc.ListSwapsFilter{
SwapType: looprpc.ListSwapsFilter_LOOP_OUT,
},
},
expectedReturnedSwaps: []lntypes.Hash{
swapOutOrder1.SwapHash,
swapOutOrder2.SwapHash,
},
expectedNextStartTime: 0,
},
{
name: "fetch with limit",
mockSwaps: mockSwaps,
req: &looprpc.ListSwapsRequest{
MaxSwaps: 2,
},
expectedReturnedSwaps: []lntypes.Hash{
swapInOrder0.SwapHash,
swapOutOrder1.SwapHash,
},
expectedNextStartTime: secondSwapStartTime.UnixNano() + 1,
},
{
name: "fetch with limit set to default",
mockSwaps: mockSwaps,
req: &looprpc.ListSwapsRequest{
MaxSwaps: 0,
},
expectedReturnedSwaps: []lntypes.Hash{
swapInOrder0.SwapHash,
swapOutOrder1.SwapHash,
swapOutOrder2.SwapHash,
},
expectedNextStartTime: 0,
},
{
name: "fetch with time filter #1",
mockSwaps: mockSwaps,
req: &looprpc.ListSwapsRequest{
ListSwapFilter: &looprpc.ListSwapsFilter{
StartTimestampNs: unixTime.Add(25 * time.Minute).UnixNano(),
},
},
expectedReturnedSwaps: []lntypes.Hash{
swapOutOrder2.SwapHash,
},
expectedNextStartTime: 0,
},
{
name: "fetch with time filter #2",
mockSwaps: mockSwaps,
req: &looprpc.ListSwapsRequest{
ListSwapFilter: &looprpc.ListSwapsFilter{
StartTimestampNs: unixTime.Add(5 * time.Minute).UnixNano(),
},
},
expectedReturnedSwaps: []lntypes.Hash{
swapInOrder0.SwapHash,
swapOutOrder1.SwapHash,
swapOutOrder2.SwapHash,
},
expectedNextStartTime: 0,
},
{
name: "fetch with swaptype=loopout filter, time filter, limit set",
mockSwaps: mockSwaps,
req: &looprpc.ListSwapsRequest{
ListSwapFilter: &looprpc.ListSwapsFilter{
SwapType: looprpc.ListSwapsFilter_LOOP_OUT,
StartTimestampNs: unixTime.Add(15 * time.Minute).UnixNano(),
},
MaxSwaps: 1,
},
expectedReturnedSwaps: []lntypes.Hash{
swapOutOrder1.SwapHash,
},
expectedNextStartTime: secondSwapStartTime.UnixNano() + 1,
},
{
name: "fetch with time filter, limit set",
mockSwaps: mockSwaps,
req: &looprpc.ListSwapsRequest{
ListSwapFilter: &looprpc.ListSwapsFilter{
StartTimestampNs: unixTime.UnixNano(),
},
MaxSwaps: 2,
},
expectedReturnedSwaps: []lntypes.Hash{
swapInOrder0.SwapHash,
swapOutOrder1.SwapHash,
},
expectedNextStartTime: secondSwapStartTime.UnixNano() + 1,
},
{
name: "fetch with time filter, limit set 2",
mockSwaps: mockSwaps,
req: &looprpc.ListSwapsRequest{
ListSwapFilter: &looprpc.ListSwapsFilter{
StartTimestampNs: unixTime.UnixNano(),
},
MaxSwaps: 3,
},
expectedReturnedSwaps: []lntypes.Hash{
swapInOrder0.SwapHash,
swapOutOrder1.SwapHash,
swapOutOrder2.SwapHash,
},
expectedNextStartTime: 0,
},
{
name: "fetch with time filter edge case 1",
mockSwaps: mockSwaps,
req: &looprpc.ListSwapsRequest{
ListSwapFilter: &looprpc.ListSwapsFilter{
StartTimestampNs: secondSwapStartTime.UnixNano(),
},
},
expectedReturnedSwaps: []lntypes.Hash{
swapOutOrder1.SwapHash,
swapOutOrder2.SwapHash,
},
expectedNextStartTime: 0,
},
{
name: "fetch with time filter edge case 2",
mockSwaps: mockSwaps,
req: &looprpc.ListSwapsRequest{
ListSwapFilter: &looprpc.ListSwapsFilter{
StartTimestampNs: secondSwapStartTime.UnixNano() + 1,
},
},
expectedReturnedSwaps: []lntypes.Hash{
swapOutOrder2.SwapHash,
},
expectedNextStartTime: 0,
},
{
name: "fetch with time filter edge case 3",
mockSwaps: mockSwaps,
req: &looprpc.ListSwapsRequest{
ListSwapFilter: &looprpc.ListSwapsFilter{
StartTimestampNs: secondSwapStartTime.UnixNano() - 1,
},
},
expectedReturnedSwaps: []lntypes.Hash{
swapOutOrder1.SwapHash,
swapOutOrder2.SwapHash,
},
expectedNextStartTime: 0,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
// Create the swap client server with our mock client.
server := &swapClientServer{
swaps: make(map[lntypes.Hash]loop.SwapInfo),
}
// Populate the server's swap cache with our mock swaps.
for _, swap := range test.mockSwaps {
server.swaps[swap.SwapHash] = swap
}
// Call the ListSwaps method.
resp, err := server.ListSwaps(context.Background(), test.req)
require.NoError(t, err)
require.Len(
t,
resp.Swaps,
len(test.expectedReturnedSwaps),
"incorrect returned count",
)
// Check order of returned swaps is exactly as expected.
for idx, aswap := range resp.Swaps {
newhash, err := lntypes.MakeHash(aswap.GetIdBytes())
require.NoError(t, err)
require.Equal(
t,
test.expectedReturnedSwaps[idx],
newhash,
"iteration order mismatch",
)
}
require.Equal(
t,
test.expectedNextStartTime,
resp.NextStartTime,
"incorrect next start time",
)
})
}
}

File diff suppressed because it is too large Load diff

View file

@ -675,6 +675,9 @@ enum FailureReason {
message ListSwapsRequest {
// Optional filter to only return swaps that match the filter.
ListSwapsFilter list_swap_filter = 1;
// Set a maximum number of swaps to return in the response.
uint64 max_swaps = 2;
}
message ListSwapsFilter {
@ -704,6 +707,9 @@ message ListSwapsFilter {
// If specified, only returns asset swaps.
bool asset_swap_only = 6;
// If specified, returns swaps initiated after this Unix (ns) timestamp.
int64 start_timestamp_ns = 7;
}
message ListSwapsResponse {
@ -711,6 +717,9 @@ message ListSwapsResponse {
The list of all currently known swaps and their status.
*/
repeated SwapStatus swaps = 1;
// Timestamp to use for paging start_timestamp_ns.
int64 next_start_time = 2;
}
message SwapInfoRequest {

View file

@ -625,6 +625,22 @@
"in": "query",
"required": false,
"type": "boolean"
},
{
"name": "list_swap_filter.start_timestamp_ns",
"description": "If specified, returns swaps initiated after this Unix (ns) timestamp.",
"in": "query",
"required": false,
"type": "string",
"format": "int64"
},
{
"name": "max_swaps",
"description": "Set a maximum number of swaps to return in the response.",
"in": "query",
"required": false,
"type": "string",
"format": "uint64"
}
],
"tags": [
@ -1403,6 +1419,11 @@
"asset_swap_only": {
"type": "boolean",
"description": "If specified, only returns asset swaps."
},
"start_timestamp_ns": {
"type": "string",
"format": "int64",
"description": "If specified, returns swaps initiated after this Unix (ns) timestamp."
}
}
},
@ -1415,6 +1436,11 @@
"$ref": "#/definitions/looprpcSwapStatus"
},
"description": "The list of all currently known swaps and their status."
},
"next_start_time": {
"type": "string",
"format": "int64",
"description": "Timestamp of the last swap returned."
}
}
},

View file

@ -15,6 +15,12 @@ This file tracks release notes for the loop client.
## Next release
#### New Features
* [Enhance](https://github.com/lightninglabs/loop/pull/912) the
`loop listswaps` command by improving the ability to filter the
response. Use `--start_timestamp_ns` to return only swaps after
that timestamp. Use `--max_swaps` to limit total swap outputs.
Paging is enabled using the `next_start_time` field in the response.
#### Breaking Changes