From 31889cf29c1479b2320f06e79885e43d1695cb12 Mon Sep 17 00:00:00 2001 From: Damian-4chain Date: Thu, 10 Oct 2024 10:44:59 +0200 Subject: [PATCH] test(SPV-1095): add tests for sync merkleroots --- examples/sync_merkleroots/sync_merkleroots.go | 9 +- fixtures/spv_wallet.go | 121 +++++++++++++++++ fixtures/sync_merkleroots.go | 122 ++++++++++++++++++ models/sync_merkleroots.go | 38 ++++++ sync_merkleroots.go | 55 ++------ sync_merkleroots_test.go | 100 ++++++++++++++ 6 files changed, 396 insertions(+), 49 deletions(-) create mode 100644 fixtures/spv_wallet.go create mode 100644 fixtures/sync_merkleroots.go create mode 100644 models/sync_merkleroots.go create mode 100644 sync_merkleroots_test.go diff --git a/examples/sync_merkleroots/sync_merkleroots.go b/examples/sync_merkleroots/sync_merkleroots.go index d3a09bc..1896109 100644 --- a/examples/sync_merkleroots/sync_merkleroots.go +++ b/examples/sync_merkleroots/sync_merkleroots.go @@ -11,14 +11,15 @@ import ( walletclient "github.com/bitcoin-sv/spv-wallet-go-client" "github.com/bitcoin-sv/spv-wallet-go-client/examples" + "github.com/bitcoin-sv/spv-wallet-go-client/models" ) // simulate a storage of merkle roots that exists on a client side that is using SyncMerkleRoots method type db struct { - MerkleRoots []walletclient.MerkleRoot + MerkleRoots []models.MerkleRoot } -func (db *db) SaveMerkleRoots(syncedMerkleRoots []walletclient.MerkleRoot) error { +func (db *db) SaveMerkleRoots(syncedMerkleRoots []models.MerkleRoot) error { fmt.Print("\nSaveMerkleRoots called\n") db.MerkleRoots = append(db.MerkleRoots, syncedMerkleRoots...) time.Sleep(1 * time.Second) @@ -34,7 +35,7 @@ func (db *db) GetLastMerkleRoot() string { // initalize the storage that exists on a client side var repository = &db{ - MerkleRoots: []walletclient.MerkleRoot{ + MerkleRoots: []models.MerkleRoot{ { MerkleRoot: "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b", BlockHeight: 0, @@ -50,7 +51,7 @@ var repository = &db{ }, } -func getLastFiveOrFewer(merkleroots []walletclient.MerkleRoot) []walletclient.MerkleRoot { +func getLastFiveOrFewer(merkleroots []models.MerkleRoot) []models.MerkleRoot { startIndex := len(merkleroots) - 5 if startIndex < 0 { startIndex = 0 diff --git a/fixtures/spv_wallet.go b/fixtures/spv_wallet.go new file mode 100644 index 0000000..bdfd2fb --- /dev/null +++ b/fixtures/spv_wallet.go @@ -0,0 +1,121 @@ +package fixtures + +import ( + "slices" + + "github.com/bitcoin-sv/spv-wallet-go-client/models" +) + +const ( + SPVWalletURL = "http://localhost:3003/api/v1" +) + +// MockedSPVWalletData is mocked merkle roots data on spv-wallet side +var MockedSPVWalletData = []models.MerkleRoot{ + { + BlockHeight: 0, + MerkleRoot: "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b", + }, + { + BlockHeight: 1, + MerkleRoot: "0e3e2357e806b6cdb1f70b54c3a3a17b6714ee1f0e68bebb44a74b1efd512098", + }, + { + BlockHeight: 2, + MerkleRoot: "9b0fc92260312ce44e74ef369f5c66bbb85848f2eddd5a7a1cde251e54ccfdd5", + }, + { + BlockHeight: 3, + MerkleRoot: "999e1c837c76a1b7fbb7e57baf87b309960f5ffefbf2a9b95dd890602272f644", + }, + { + BlockHeight: 4, + MerkleRoot: "df2b060fa2e5e9c8ed5eaf6a45c13753ec8c63282b2688322eba40cd98ea067a", + }, + { + BlockHeight: 5, + MerkleRoot: "63522845d294ee9b0188ae5cac91bf389a0c3723f084ca1025e7d9cdfe481ce1", + }, + { + BlockHeight: 6, + MerkleRoot: "20251a76e64e920e58291a30d4b212939aae976baca40e70818ceaa596fb9d37", + }, + { + BlockHeight: 7, + MerkleRoot: "8aa673bc752f2851fd645d6a0a92917e967083007d9c1684f9423b100540673f", + }, + { + BlockHeight: 8, + MerkleRoot: "a6f7f1c0dad0f2eb6b13c4f33de664b1b0e9f22efad5994a6d5b6086d85e85e3", + }, + { + BlockHeight: 9, + MerkleRoot: "0437cd7f8525ceed2324359c2d0ba26006d92d856a9c20fa0241106ee5a597c9", + }, + { + BlockHeight: 10, + MerkleRoot: "d3ad39fa52a89997ac7381c95eeffeaf40b66af7a57e9eba144be0a175a12b11", + }, + { + BlockHeight: 11, + MerkleRoot: "f8325d8f7fa5d658ea143629288d0530d2710dc9193ddc067439de803c37066e", + }, + { + BlockHeight: 12, + MerkleRoot: "3b96bb7e197ef276b85131afd4a09c059cc368133a26ca04ebffb0ab4f75c8b8", + }, + { + BlockHeight: 13, + MerkleRoot: "9962d5c704ec27243364cbe9d384808feeac1c15c35ac790dffd1e929829b271", + }, + { + BlockHeight: 14, + MerkleRoot: "e1afd89295b68bc5247fe0ca2885dd4b8818d7ce430faa615067d7bab8640156", + }, +} + +// MockedMerkleRootsAPIResponseFn is a mock of SPV-Wallet it will return a paged response of merkle roots since last evaluated merkle root +func MockedMerkleRootsAPIResponseFn(lastMerkleRoot string) models.ExclusiveStartKeyPage[[]models.MerkleRoot] { + if lastMerkleRoot == "" { + return models.ExclusiveStartKeyPage[[]models.MerkleRoot]{ + Content: MockedSPVWalletData, + Page: models.ExclusiveStartKeyPageInfo{ + LastEvaluatedKey: "", + TotalElements: len(MockedSPVWalletData), + Size: len(MockedSPVWalletData), + }, + } + } + + lastMerkleRootIdx := slices.IndexFunc(MockedSPVWalletData, func(mr models.MerkleRoot) bool { + return mr.MerkleRoot == lastMerkleRoot + }) + + // handle case when lastMerkleRoot is already highest in the servers database + if lastMerkleRootIdx == len(MockedSPVWalletData)-1 { + return models.ExclusiveStartKeyPage[[]models.MerkleRoot]{ + Content: []models.MerkleRoot{}, + Page: models.ExclusiveStartKeyPageInfo{ + LastEvaluatedKey: "", + TotalElements: len(MockedSPVWalletData), + Size: 0, + }, + } + } + + content := MockedSPVWalletData[lastMerkleRootIdx+1:] + lastEvaluatedKey := content[len(content)-1].MerkleRoot + + if lastEvaluatedKey == MockedSPVWalletData[len(MockedSPVWalletData)-1].MerkleRoot { + lastEvaluatedKey = "" + } + + return models.ExclusiveStartKeyPage[[]models.MerkleRoot]{ + Content: content, + Page: models.ExclusiveStartKeyPageInfo{ + LastEvaluatedKey: lastEvaluatedKey, + TotalElements: len(MockedSPVWalletData), + Size: len(content), + }, + } +} diff --git a/fixtures/sync_merkleroots.go b/fixtures/sync_merkleroots.go new file mode 100644 index 0000000..5859abc --- /dev/null +++ b/fixtures/sync_merkleroots.go @@ -0,0 +1,122 @@ +package fixtures + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "time" + + "github.com/bitcoin-sv/spv-wallet-go-client/models" +) + +// simulate a storage of merkle roots that exists on a client side that is using SyncMerkleRoots method +type DB struct { + MerkleRoots []models.MerkleRoot +} + +func (db *DB) SaveMerkleRoots(syncedMerkleRoots []models.MerkleRoot) error { + db.MerkleRoots = append(db.MerkleRoots, syncedMerkleRoots...) + time.Sleep(5 * time.Millisecond) + return nil +} + +func (db *DB) GetLastMerkleRoot() string { + if len(db.MerkleRoots) == 0 { + return "" + } + return db.MerkleRoots[len(db.MerkleRoots)-1].MerkleRoot +} + +// CreateRepository creates a simulated repository a client passes to SyncMerkleRoots() +func CreateRepository(merkleRoots []models.MerkleRoot) *DB { + return &DB{ + MerkleRoots: merkleRoots, + } +} + +func sendJSONResponse(data interface{}, w *http.ResponseWriter) { + (*w).Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(*w).Encode(data); err != nil { + (*w).WriteHeader(http.StatusInternalServerError) + } +} + +func MockMerkleRootsAPIResponseNormal() *httptest.Server { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Sprintf("%+v", r.URL) + switch { + case r.URL.Path == "/api/v1/merkleroots" && r.Method == http.MethodGet: + lastEvaluatedKey := r.URL.Query().Get("lastEvaluatedKey") + sendJSONResponse(MockedMerkleRootsAPIResponseFn(lastEvaluatedKey), &w) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + + return server +} + +func MockMerkleRootsAPIResponseDelayed() *httptest.Server { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/api/v1/merkleroots" && r.Method == http.MethodGet: + lastEvaluatedKey := r.URL.Query().Get("lastEvaluatedKey") + // it is to limit the result up to 3 merkle roots per request to ensure + // that the sync merkleroots will loop more than once and hit the timeout + all := MockedMerkleRootsAPIResponseFn(lastEvaluatedKey) + if len(all.Content) > 3 { + all.Content = all.Content[:3] + } + + all.Page.Size = len(all.Content) + + if len(all.Content) > 0 { + all.Page.LastEvaluatedKey = all.Content[len(all.Content)-1].MerkleRoot + } else { + all.Page.LastEvaluatedKey = "" + } + + time.Sleep(50 * time.Millisecond) + sendJSONResponse(all, &w) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + + return server +} + +func MockMerkleRootsAPIResponseStale() *httptest.Server { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/api/v1/merkleroots" && r.Method == http.MethodGet: + staleLastEvaluatedKeyResponse := models.ExclusiveStartKeyPage[[]models.MerkleRoot]{ + Content: []models.MerkleRoot{ + { + MerkleRoot: "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b", + BlockHeight: 0, + }, + { + MerkleRoot: "0e3e2357e806b6cdb1f70b54c3a3a17b6714ee1f0e68bebb44a74b1efd512098", + BlockHeight: 1, + }, + { + MerkleRoot: "9b0fc92260312ce44e74ef369f5c66bbb85848f2eddd5a7a1cde251e54ccfdd5", + BlockHeight: 2, + }, + }, + Page: models.ExclusiveStartKeyPageInfo{ + LastEvaluatedKey: "9b0fc92260312ce44e74ef369f5c66bbb85848f2eddd5a7a1cde251e54ccfdd5", + Size: 3, + TotalElements: len(MockedSPVWalletData), + }, + } + sendJSONResponse(staleLastEvaluatedKeyResponse, &w) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + + return server +} diff --git a/models/sync_merkleroots.go b/models/sync_merkleroots.go new file mode 100644 index 0000000..978f86c --- /dev/null +++ b/models/sync_merkleroots.go @@ -0,0 +1,38 @@ +package models + +// ExclusiveStartKeyPage represents a paginated response for database records using Exclusive Start Key paging +type ExclusiveStartKeyPage[T any] struct { + // List of records for the response + Content T + // Pagination details + Page ExclusiveStartKeyPageInfo +} + +// ExclusiveStartKeyPageInfo represents the pagination information for limiting and sorting database query results +type ExclusiveStartKeyPageInfo struct { + // Field by which to order the results + OrderByField *string `json:"orderByField,omitempty"` // Optional ordering field + // Direction in which to order the results (ASC or DESC) + SortDirection *string `json:"sortDirection,omitempty"` // Optional sort direction + // Total count of elements + TotalElements int `json:"totalElements"` + // Size of the page or returned data + Size int `json:"size"` + // Last evaluated key returned from the database + LastEvaluatedKey string `json:"lastEvaluatedKey"` +} + +// MerkleRoot holds the content of the synced Merkle root response +type MerkleRoot struct { + MerkleRoot string `json:"merkleRoot"` + BlockHeight int `json:"blockHeight"` +} + +// MerkleRootsRepository is an interface responsible for saving synced merkleroots and getting last evaluat key from database +type MerkleRootsRepository interface { + // GetLastMerkleRoot should return the merkle root with the heighest height from your storage or undefined if empty + GetLastMerkleRoot() string + // SaveMerkleRoots should store newly synced merkle roots into your storage; + // NOTE: items are ordered with ascending order by block height + SaveMerkleRoots(syncedMerkleRoots []MerkleRoot) error +} diff --git a/sync_merkleroots.go b/sync_merkleroots.go index 987fe31..b20ea32 100644 --- a/sync_merkleroots.go +++ b/sync_merkleroots.go @@ -5,47 +5,12 @@ import ( "fmt" "net/http" "time" -) - -// exclusiveStartKeyPage represents a paginated response for database records using Exclusive Start Key paging -type exclusiveStartKeyPage[T any] struct { - // List of records for the response - Content T - // Pagination details - Page exclusiveStartKeyPageInfo -} - -// exclusiveStartKeyPageInfo represents the pagination information for limiting and sorting database query results -type exclusiveStartKeyPageInfo struct { - // Field by which to order the results - OrderByField *string `json:"orderByField,omitempty"` // Optional ordering field - // Direction in which to order the results (ASC or DESC) - SortDirection *string `json:"sortDirection,omitempty"` // Optional sort direction - // Total count of elements - TotalElements int `json:"totalElements"` - // Size of the page or returned data - Size int `json:"size"` - // Last evaluated key returned from the database - LastEvaluatedKey string `json:"lastEvaluatedKey"` -} -// MerkleRoot holds the content of the synced Merkle root response -type MerkleRoot struct { - MerkleRoot string `json:"merkleRoot"` - BlockHeight int `json:"blockHeight"` -} - -// MerkleRootsRepository is an interface responsible for saving synced merkleroots and getting last evaluat key from database -type MerkleRootsRepository interface { - // GetLastMerkleRoot should return the merkle root with the heighest height from your storage or undefined if empty - GetLastMerkleRoot() string - // SaveMerkleRoots should store newly synced merkle roots into your storage; - // NOTE: items are ordered with ascending order by block height - SaveMerkleRoots(syncedMerkleRoots []MerkleRoot) error -} + "github.com/bitcoin-sv/spv-wallet-go-client/models" +) // SyncMerkleRoots syncs merkleroots known to spv-wallet with the client database -func (wc *WalletClient) SyncMerkleRoots(ctx context.Context, repo MerkleRootsRepository, timeoutMs time.Duration) error { +func (wc *WalletClient) SyncMerkleRoots(ctx context.Context, repo models.MerkleRootsRepository, timeoutMs time.Duration) error { var cancel context.CancelFunc if timeoutMs > 0 { ctx, cancel = context.WithTimeout(ctx, timeoutMs) @@ -68,14 +33,15 @@ func (wc *WalletClient) SyncMerkleRoots(ctx context.Context, repo MerkleRootsRep default: url := fmt.Sprintf("/%s%s", requestPath, lastEvaluatedKeyQuery) - var merkleRootsResponse exclusiveStartKeyPage[[]MerkleRoot] + var merkleRootsResponse models.ExclusiveStartKeyPage[[]models.MerkleRoot] err := wc.doHTTPRequest(ctx, http.MethodGet, url, nil, wc.xPriv, true, &merkleRootsResponse) if err != nil { return err } - if previousLastEvaluatedKey == merkleRootsResponse.Page.LastEvaluatedKey { + lastEvaluatedKey = merkleRootsResponse.Page.LastEvaluatedKey + if lastEvaluatedKey != "" && previousLastEvaluatedKey == lastEvaluatedKey { return ErrStaleLastEvaluatedKey } @@ -84,13 +50,12 @@ func (wc *WalletClient) SyncMerkleRoots(ctx context.Context, repo MerkleRootsRep return err } - if merkleRootsResponse.Page.LastEvaluatedKey == "" { - break + previousLastEvaluatedKey = lastEvaluatedKey + if previousLastEvaluatedKey == "" { + return nil } - lastEvaluatedKeyQuery = fmt.Sprintf("?lastEvaluatedKey=%s", merkleRootsResponse.Page.LastEvaluatedKey) - previousLastEvaluatedKey = merkleRootsResponse.Page.LastEvaluatedKey + lastEvaluatedKeyQuery = fmt.Sprintf("?lastEvaluatedKey=%s", previousLastEvaluatedKey) } } - return nil } diff --git a/sync_merkleroots_test.go b/sync_merkleroots_test.go new file mode 100644 index 0000000..5960e19 --- /dev/null +++ b/sync_merkleroots_test.go @@ -0,0 +1,100 @@ +package walletclient + +import ( + "context" + "testing" + + "github.com/bitcoin-sv/spv-wallet-go-client/fixtures" + "github.com/bitcoin-sv/spv-wallet-go-client/models" + "github.com/stretchr/testify/require" +) + +func TestSyncMerkleRoots(t *testing.T) { + + t.Run("Should properly sync database when empty", func(t *testing.T) { + // setup + server := fixtures.MockMerkleRootsAPIResponseNormal() + defer server.Close() + + // given + repo := fixtures.CreateRepository([]models.MerkleRoot{}) + client := NewWithXPriv(server.URL, fixtures.XPrivString) + require.NotNil(t, client.xPriv) + + // when + err := client.SyncMerkleRoots(context.Background(), repo, 0) + + // then + require.NoError(t, err) + require.Equal(t, len(fixtures.MockedSPVWalletData), len(repo.MerkleRoots)) + require.Equal(t, fixtures.MockedSPVWalletData[len(fixtures.MockedSPVWalletData)-1].MerkleRoot, repo.MerkleRoots[len(repo.MerkleRoots)-1].MerkleRoot) + require.Equal(t, fixtures.MockedSPVWalletData[len(fixtures.MockedSPVWalletData)-1].BlockHeight, repo.MerkleRoots[len(repo.MerkleRoots)-1].BlockHeight) + }) + + t.Run("Should properly sync database when partially filled", func(t *testing.T) { + // setup + server := fixtures.MockMerkleRootsAPIResponseNormal() + defer server.Close() + + // given + repo := fixtures.CreateRepository([]models.MerkleRoot{ + { + MerkleRoot: "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b", + BlockHeight: 0, + }, + { + MerkleRoot: "0e3e2357e806b6cdb1f70b54c3a3a17b6714ee1f0e68bebb44a74b1efd512098", + BlockHeight: 1, + }, + { + MerkleRoot: "9b0fc92260312ce44e74ef369f5c66bbb85848f2eddd5a7a1cde251e54ccfdd5", + BlockHeight: 2, + }, + }) + client := NewWithXPriv(server.URL, fixtures.XPrivString) + require.NotNil(t, client.xPriv) + + // when + err := client.SyncMerkleRoots(context.Background(), repo, 0) + + // then + require.NoError(t, err) + require.Equal(t, len(fixtures.MockedSPVWalletData), len(repo.MerkleRoots)) + require.Equal(t, fixtures.MockedSPVWalletData[len(fixtures.MockedSPVWalletData)-1].MerkleRoot, repo.MerkleRoots[len(repo.MerkleRoots)-1].MerkleRoot) + require.Equal(t, fixtures.MockedSPVWalletData[len(fixtures.MockedSPVWalletData)-1].BlockHeight, repo.MerkleRoots[len(repo.MerkleRoots)-1].BlockHeight) + }) + + t.Run("Should fail sync merkleroots due to the time out", func(t *testing.T) { + // setup + server := fixtures.MockMerkleRootsAPIResponseDelayed() + defer server.Close() + + // given + repo := fixtures.CreateRepository([]models.MerkleRoot{}) + client := NewWithXPriv(server.URL, fixtures.XPrivString) + require.NotNil(t, client.xPriv) + + // when + err := client.SyncMerkleRoots(context.Background(), repo, 10) + + // then + require.ErrorIs(t, err, ErrSyncMerkleRootsTimeout) + }) + + t.Run("Should fail sync merkleroots due to last evaluated key being the same in the response", func(t *testing.T) { + // setup + server := fixtures.MockMerkleRootsAPIResponseStale() + defer server.Close() + + // given + repo := fixtures.CreateRepository([]models.MerkleRoot{}) + client := NewWithXPriv(server.URL, fixtures.XPrivString) + require.NotNil(t, client.xPriv) + + // when + err := client.SyncMerkleRoots(context.Background(), repo, 0) + + // then + require.ErrorIs(t, err, ErrStaleLastEvaluatedKey) + }) +}