From b62c4bb93560c4a81c946dd800c27ddc57a53d67 Mon Sep 17 00:00:00 2001 From: Andrii Date: Fri, 19 Jul 2024 15:43:03 +0300 Subject: [PATCH 1/8] Added skeleton for new rest call --- .../rest/request/get_account_balance.go | 45 +++++++++++++++++ .../rest/request/get_account_balance_test.go | 45 +++++++++++++++++ engine/access/rest/request/request.go | 6 +++ engine/access/rest/routes/account_balance.go | 48 +++++++++++++++++++ engine/access/rest/routes/router.go | 5 ++ 5 files changed, 149 insertions(+) create mode 100644 engine/access/rest/request/get_account_balance.go create mode 100644 engine/access/rest/request/get_account_balance_test.go create mode 100644 engine/access/rest/routes/account_balance.go diff --git a/engine/access/rest/request/get_account_balance.go b/engine/access/rest/request/get_account_balance.go new file mode 100644 index 00000000000..42347c3b102 --- /dev/null +++ b/engine/access/rest/request/get_account_balance.go @@ -0,0 +1,45 @@ +package request + +import ( + "github.com/onflow/flow-go/model/flow" +) + +type GetAccountBalance struct { + Address flow.Address + Height uint64 +} + +func (g *GetAccountBalance) Build(r *Request) error { + return g.Parse( + r.GetVar(addressVar), + r.GetQueryParam(blockHeightQuery), + r.Chain, + ) +} + +func (g *GetAccountBalance) Parse( + rawAddress string, + rawHeight string, + chain flow.Chain, +) error { + address, err := ParseAddress(rawAddress, chain) + if err != nil { + return err + } + + var height Height + err = height.Parse(rawHeight) + if err != nil { + return err + } + + g.Address = address + g.Height = height.Flow() + + // default to last block + if g.Height == EmptyHeight { + g.Height = SealedHeight + } + + return nil +} diff --git a/engine/access/rest/request/get_account_balance_test.go b/engine/access/rest/request/get_account_balance_test.go new file mode 100644 index 00000000000..ada67c8d574 --- /dev/null +++ b/engine/access/rest/request/get_account_balance_test.go @@ -0,0 +1,45 @@ +package request + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/onflow/flow-go/model/flow" +) + +func Test_GetAccountBalance_InvalidParse(t *testing.T) { + var getAccountBalance GetAccountBalance + + tests := []struct { + address string + height string + err string + }{ + {"", "", "invalid address"}, + {"f8d6e0586b0a20c7", "-1", "invalid height format"}, + } + + chain := flow.Localnet.Chain() + for i, test := range tests { + err := getAccountBalance.Parse(test.address, test.height, chain) + assert.EqualError(t, err, test.err, fmt.Sprintf("test #%d failed", i)) + } +} + +func Test_GetAccountBalance_ValidParse(t *testing.T) { + + var getAccountBalance GetAccountBalance + + addr := "f8d6e0586b0a20c7" + chain := flow.Localnet.Chain() + err := getAccountBalance.Parse(addr, "", chain) + assert.NoError(t, err) + assert.Equal(t, getAccountBalance.Address.String(), addr) + assert.Equal(t, getAccountBalance.Height, SealedHeight) + + err = getAccountBalance.Parse(addr, "100", chain) + assert.NoError(t, err) + assert.Equal(t, getAccountBalance.Height, uint64(100)) +} diff --git a/engine/access/rest/request/request.go b/engine/access/rest/request/request.go index 9643fefaca4..47cb9c1e5c2 100644 --- a/engine/access/rest/request/request.go +++ b/engine/access/rest/request/request.go @@ -54,6 +54,12 @@ func (rd *Request) GetAccountRequest() (GetAccount, error) { return req, err } +func (rd *Request) GetAccountBalanceRequest() (GetAccountBalance, error) { + var req GetAccountBalance + err := req.Build(rd) + return req, err +} + func (rd *Request) GetAccountKeyRequest() (GetAccountKey, error) { var req GetAccountKey err := req.Build(rd) diff --git a/engine/access/rest/routes/account_balance.go b/engine/access/rest/routes/account_balance.go new file mode 100644 index 00000000000..505a1386a71 --- /dev/null +++ b/engine/access/rest/routes/account_balance.go @@ -0,0 +1,48 @@ +package routes + +import ( + "fmt" + + "github.com/onflow/flow-go/access" + "github.com/onflow/flow-go/engine/access/rest/models" + "github.com/onflow/flow-go/engine/access/rest/request" +) + +// GetAccountBalance handler retrieves an account balance by address and block height and returns the response +func GetAccountBalance(r *request.Request, backend access.API, _ models.LinkGenerator) (interface{}, error) { + req, err := r.GetAccountBalanceRequest() + if err != nil { + return nil, models.NewBadRequestError(err) + } + + // In case we receive special height values 'final' and 'sealed', + // fetch that height and overwrite request with it. + if req.Height == request.FinalHeight || req.Height == request.SealedHeight { + isSealed := req.Height == request.SealedHeight + header, _, err := backend.GetLatestBlockHeader(r.Context(), isSealed) + if err != nil { + err := fmt.Errorf("block with height: %d does not exist", req.Height) + return nil, models.NewNotFoundError(err.Error(), err) + } + req.Height = header.Height + } + + balance, err := backend.GetAccountBalanceAtBlockHeight(r.Context(), req.Address, req.Height) + if err != nil { + err := fmt.Errorf("account with address: %s does not exist", req.Address) + return nil, models.NewNotFoundError(err.Error(), err) + } + + var accountBalance models.Block + + //var response models.AccountPublicKey + //for _, key := range account.Keys { + // if key.Index == req.Index { + // response.Build(key) + // return response, nil + // } + //} + // + //err = fmt.Errorf("account key with index: %d does not exist", req.Index) + //return nil, models.NewNotFoundError(err.Error(), err) +} diff --git a/engine/access/rest/routes/router.go b/engine/access/rest/routes/router.go index 97e3dec07d6..621e2768656 100644 --- a/engine/access/rest/routes/router.go +++ b/engine/access/rest/routes/router.go @@ -151,6 +151,11 @@ var Routes = []route{{ Pattern: "/accounts/{address}", Name: "getAccount", Handler: GetAccount, +}, { + Method: http.MethodGet, + Pattern: "/accounts/{address}/balance", + Name: "getAccountBalance", + Handler: GetAccountBalance, }, { Method: http.MethodGet, Pattern: "/accounts/{address}/keys/{index}", From 653d9708d2085146b3a5dcf211c383240f64950c Mon Sep 17 00:00:00 2001 From: Andrii Date: Tue, 23 Jul 2024 15:12:59 +0300 Subject: [PATCH 2/8] Added rest call model and implementation for method --- engine/access/rest/models/account.go | 4 ++++ .../access/rest/models/model_account_balance.go | 14 ++++++++++++++ engine/access/rest/routes/account_balance.go | 17 ++++------------- 3 files changed, 22 insertions(+), 13 deletions(-) create mode 100644 engine/access/rest/models/model_account_balance.go diff --git a/engine/access/rest/models/account.go b/engine/access/rest/models/account.go index aea3601c2b1..1ed894ab859 100644 --- a/engine/access/rest/models/account.go +++ b/engine/access/rest/models/account.go @@ -66,3 +66,7 @@ func (a *AccountPublicKeys) Build(accountKeys []flow.AccountPublicKey) { *a = keys } + +func (b *AccountBalance) Build(balance uint64) { + b.Balance = util.FromUint(balance) +} diff --git a/engine/access/rest/models/model_account_balance.go b/engine/access/rest/models/model_account_balance.go new file mode 100644 index 00000000000..7449e24d4cd --- /dev/null +++ b/engine/access/rest/models/model_account_balance.go @@ -0,0 +1,14 @@ +/* + * Access API + * + * No description provided (generated by Swagger Codegen https://github.com/swagger-api/swagger-codegen) + * + * API version: 1.0.0 + * Generated by: Swagger Codegen (https://github.com/swagger-api/swagger-codegen.git) + */ +package models + +type AccountBalance struct { + // Flow balance of the account. + Balance string `json:"balance"` +} diff --git a/engine/access/rest/routes/account_balance.go b/engine/access/rest/routes/account_balance.go index 505a1386a71..e3a9ac22fe5 100644 --- a/engine/access/rest/routes/account_balance.go +++ b/engine/access/rest/routes/account_balance.go @@ -29,20 +29,11 @@ func GetAccountBalance(r *request.Request, backend access.API, _ models.LinkGene balance, err := backend.GetAccountBalanceAtBlockHeight(r.Context(), req.Address, req.Height) if err != nil { - err := fmt.Errorf("account with address: %s does not exist", req.Address) + err = fmt.Errorf("failed to get account balance, reason: %w", err) return nil, models.NewNotFoundError(err.Error(), err) } - var accountBalance models.Block - - //var response models.AccountPublicKey - //for _, key := range account.Keys { - // if key.Index == req.Index { - // response.Build(key) - // return response, nil - // } - //} - // - //err = fmt.Errorf("account key with index: %d does not exist", req.Index) - //return nil, models.NewNotFoundError(err.Error(), err) + var response models.AccountBalance + response.Build(balance) + return response, nil } From e6b83e0cc0b3ef60e7c2454441540fedca8a0564 Mon Sep 17 00:00:00 2001 From: Andrii Date: Wed, 24 Jul 2024 14:56:04 +0300 Subject: [PATCH 3/8] Added tests for account balance call --- .../rest/routes/account_balance_test.go | 135 ++++++++++++++++++ .../access/rest/routes/account_keys_test.go | 1 - 2 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 engine/access/rest/routes/account_balance_test.go diff --git a/engine/access/rest/routes/account_balance_test.go b/engine/access/rest/routes/account_balance_test.go new file mode 100644 index 00000000000..1c7f93dd500 --- /dev/null +++ b/engine/access/rest/routes/account_balance_test.go @@ -0,0 +1,135 @@ +package routes + +import ( + "fmt" + "net/http" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + mocktestify "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/onflow/flow-go/access/mock" + "github.com/onflow/flow-go/model/flow" + "github.com/onflow/flow-go/utils/unittest" +) + +// TestGetAccountBalance tests local getAccountBalance request. +// +// Runs the following tests: +// 1. Get account balance by address at latest sealed block. +// 2. Get account balance by address at latest finalized block. +// 3. Get account balance by address at height. +// 4. Get invalid account balance. +func TestGetAccountBalance(t *testing.T) { + backend := &mock.API{} + + t.Run("get balance by address at latest sealed block", func(t *testing.T) { + account := accountFixture(t) + var height uint64 = 100 + block := unittest.BlockHeaderFixture(unittest.WithHeaderHeight(height)) + + req := getAccountBalanceRequest(t, account, sealedHeightQueryParam) + + backend.Mock. + On("GetLatestBlockHeader", mocktestify.Anything, true). + Return(block, flow.BlockStatusSealed, nil) + + backend.Mock. + On("GetAccountBalanceAtBlockHeight", mocktestify.Anything, account.Address, height). + Return(account.Balance, nil) + + expected := expectedAccountBalanceResponse(account) + + assertOKResponse(t, req, expected, backend) + mocktestify.AssertExpectationsForObjects(t, backend) + }) + + t.Run("get balance by address at latest finalized block", func(t *testing.T) { + account := accountFixture(t) + var height uint64 = 100 + block := unittest.BlockHeaderFixture(unittest.WithHeaderHeight(height)) + + req := getAccountBalanceRequest(t, account, finalHeightQueryParam) + + backend.Mock. + On("GetLatestBlockHeader", mocktestify.Anything, false). + Return(block, flow.BlockStatusFinalized, nil) + + backend.Mock. + On("GetAccountBalanceAtBlockHeight", mocktestify.Anything, account.Address, height). + Return(account.Balance, nil) + + expected := expectedAccountBalanceResponse(account) + + assertOKResponse(t, req, expected, backend) + mocktestify.AssertExpectationsForObjects(t, backend) + }) + + t.Run("get balance by address at height", func(t *testing.T) { + account := accountFixture(t) + var height uint64 = 1337 + req := getAccountBalanceRequest(t, account, fmt.Sprintf("%d", height)) + + backend.Mock. + On("GetAccountBalanceAtBlockHeight", mocktestify.Anything, account.Address, height). + Return(account.Balance, nil) + + expected := expectedAccountBalanceResponse(account) + + assertOKResponse(t, req, expected, backend) + mocktestify.AssertExpectationsForObjects(t, backend) + }) + + t.Run("get invalid", func(t *testing.T) { + tests := []struct { + url string + out string + }{ + {accountBalanceURL(t, "123", ""), `{"code":400, "message":"invalid address"}`}, + {accountBalanceURL(t, unittest.AddressFixture().String(), "foo"), `{"code":400, "message":"invalid height format"}`}, + } + + for i, test := range tests { + req, _ := http.NewRequest("GET", test.url, nil) + rr := executeRequest(req, backend) + + assert.Equal(t, http.StatusBadRequest, rr.Code) + assert.JSONEq(t, test.out, rr.Body.String(), fmt.Sprintf("test #%d failed: %v", i, test)) + } + }) +} + +func accountBalanceURL(t *testing.T, address string, height string) string { + u, err := url.ParseRequestURI(fmt.Sprintf("/v1/accounts/%s/balance", address)) + require.NoError(t, err) + q := u.Query() + + if height != "" { + q.Add("block_height", height) + } + + u.RawQuery = q.Encode() + return u.String() +} + +func getAccountBalanceRequest(t *testing.T, account *flow.Account, height string) *http.Request { + req, err := http.NewRequest( + "GET", + accountBalanceURL(t, account.Address.String(), height), + nil, + ) + + require.NoError(t, err) + return req +} + +func expectedAccountBalanceResponse(account *flow.Account) string { + return fmt.Sprintf(` + { + "balance":"%d" + }`, + account.Balance, + ) +} diff --git a/engine/access/rest/routes/account_keys_test.go b/engine/access/rest/routes/account_keys_test.go index 0cc663055ab..6319bf19796 100644 --- a/engine/access/rest/routes/account_keys_test.go +++ b/engine/access/rest/routes/account_keys_test.go @@ -13,7 +13,6 @@ import ( "github.com/onflow/flow-go/access/mock" "github.com/onflow/flow-go/model/flow" - "github.com/onflow/flow-go/utils/unittest" ) From 8ceef54b35aaf4143e6ea97084470705697bda6a Mon Sep 17 00:00:00 2001 From: Andrii Diachuk Date: Thu, 25 Jul 2024 13:33:28 +0300 Subject: [PATCH 4/8] Update engine/access/rest/routes/account_balance.go Co-authored-by: Andrii Slisarchuk --- engine/access/rest/routes/account_balance.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/engine/access/rest/routes/account_balance.go b/engine/access/rest/routes/account_balance.go index e3a9ac22fe5..3320428aabd 100644 --- a/engine/access/rest/routes/account_balance.go +++ b/engine/access/rest/routes/account_balance.go @@ -17,8 +17,9 @@ func GetAccountBalance(r *request.Request, backend access.API, _ models.LinkGene // In case we receive special height values 'final' and 'sealed', // fetch that height and overwrite request with it. - if req.Height == request.FinalHeight || req.Height == request.SealedHeight { - isSealed := req.Height == request.SealedHeight + isSealed := req.Height == request.SealedHeight + isFinal:= req.Height == request.FinalHeight + if isFinal || isSealed { header, _, err := backend.GetLatestBlockHeader(r.Context(), isSealed) if err != nil { err := fmt.Errorf("block with height: %d does not exist", req.Height) From 444babb2a002815a918e867c13d86acad841e780 Mon Sep 17 00:00:00 2001 From: Andrii Date: Thu, 25 Jul 2024 16:11:34 +0300 Subject: [PATCH 5/8] Linted, added missing proxy handler method fot GetAccountBalance call --- .../rest/apiproxy/rest_proxy_handler.go | 24 +++++++++++++++++++ engine/access/rest/routes/account_balance.go | 4 ++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/engine/access/rest/apiproxy/rest_proxy_handler.go b/engine/access/rest/apiproxy/rest_proxy_handler.go index ffe52c73fd9..09b43ebcbae 100644 --- a/engine/access/rest/apiproxy/rest_proxy_handler.go +++ b/engine/access/rest/apiproxy/rest_proxy_handler.go @@ -203,6 +203,30 @@ func (r *RestProxyHandler) GetAccountAtBlockHeight(ctx context.Context, address return convert.MessageToAccount(accountResponse.Account) } +// GetAccountBalanceAtBlockHeight returns account balance by account address and block height. +func (r *RestProxyHandler) GetAccountBalanceAtBlockHeight(ctx context.Context, address flow.Address, height uint64) (uint64, error) { + upstream, closer, err := r.FaultTolerantClient() + if err != nil { + return 0, err + } + defer closer.Close() + + getAccountBalanceAtBlockHeightRequest := &accessproto.GetAccountBalanceAtBlockHeightRequest{ + Address: address.Bytes(), + BlockHeight: height, + } + + accountBalanceResponse, err := upstream.GetAccountBalanceAtBlockHeight(ctx, getAccountBalanceAtBlockHeightRequest) + r.log("upstream", "GetAccountBalanceAtBlockHeight", err) + + if err != nil { + return 0, err + } + + return accountBalanceResponse.GetBalance(), nil + +} + // GetAccountKeyByIndex returns account key by account address, key index and block height. func (r *RestProxyHandler) GetAccountKeyByIndex(ctx context.Context, address flow.Address, keyIndex uint32, height uint64) (*flow.AccountPublicKey, error) { upstream, closer, err := r.FaultTolerantClient() diff --git a/engine/access/rest/routes/account_balance.go b/engine/access/rest/routes/account_balance.go index 3320428aabd..82936beddda 100644 --- a/engine/access/rest/routes/account_balance.go +++ b/engine/access/rest/routes/account_balance.go @@ -18,8 +18,8 @@ func GetAccountBalance(r *request.Request, backend access.API, _ models.LinkGene // In case we receive special height values 'final' and 'sealed', // fetch that height and overwrite request with it. isSealed := req.Height == request.SealedHeight - isFinal:= req.Height == request.FinalHeight - if isFinal || isSealed { + isFinal := req.Height == request.FinalHeight + if isFinal || isSealed { header, _, err := backend.GetLatestBlockHeader(r.Context(), isSealed) if err != nil { err := fmt.Errorf("block with height: %d does not exist", req.Height) From 8fa71f7e108f22f4c0455b21e73c6a627d175811 Mon Sep 17 00:00:00 2001 From: Andrii Date: Fri, 26 Jul 2024 13:22:56 +0300 Subject: [PATCH 6/8] Fixed remarks from comments --- engine/access/rest/request/get_account_balance_test.go | 8 ++++++++ engine/access/rest/routes/account_balance_test.go | 2 +- engine/access/rest/routes/router.go | 3 +++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/engine/access/rest/request/get_account_balance_test.go b/engine/access/rest/request/get_account_balance_test.go index ada67c8d574..4fd2b73c554 100644 --- a/engine/access/rest/request/get_account_balance_test.go +++ b/engine/access/rest/request/get_account_balance_test.go @@ -42,4 +42,12 @@ func Test_GetAccountBalance_ValidParse(t *testing.T) { err = getAccountBalance.Parse(addr, "100", chain) assert.NoError(t, err) assert.Equal(t, getAccountBalance.Height, uint64(100)) + + err = getAccountBalance.Parse(addr, sealed, chain) + assert.NoError(t, err) + assert.Equal(t, getAccountBalance.Height, SealedHeight) + + err = getAccountBalance.Parse(addr, final, chain) + assert.NoError(t, err) + assert.Equal(t, getAccountBalance.Height, FinalHeight) } diff --git a/engine/access/rest/routes/account_balance_test.go b/engine/access/rest/routes/account_balance_test.go index 1c7f93dd500..7b47fc76391 100644 --- a/engine/access/rest/routes/account_balance_test.go +++ b/engine/access/rest/routes/account_balance_test.go @@ -23,7 +23,7 @@ import ( // 3. Get account balance by address at height. // 4. Get invalid account balance. func TestGetAccountBalance(t *testing.T) { - backend := &mock.API{} + backend := mock.NewAPI(t) t.Run("get balance by address at latest sealed block", func(t *testing.T) { account := accountFixture(t) diff --git a/engine/access/rest/routes/router.go b/engine/access/rest/routes/router.go index 621e2768656..78e511fb7c5 100644 --- a/engine/access/rest/routes/router.go +++ b/engine/access/rest/routes/router.go @@ -235,6 +235,9 @@ func normalizeURL(url string) (string, error) { case 16: // address based resource. e.g. /v1/accounts/1234567890abcdef parts = append(parts, "{address}") + if matches[0][5] == "balance" { + parts = append(parts, "balance") + } if matches[0][5] == "keys" { parts = append(parts, "keys", "{index}") } From 5ee18ea6956e1b82c99fa3d47cc7528f0301ff6e Mon Sep 17 00:00:00 2001 From: Andrii Date: Fri, 26 Jul 2024 13:29:15 +0300 Subject: [PATCH 7/8] Added missing else keyword --- engine/access/rest/routes/router.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/engine/access/rest/routes/router.go b/engine/access/rest/routes/router.go index 78e511fb7c5..c3925cc531d 100644 --- a/engine/access/rest/routes/router.go +++ b/engine/access/rest/routes/router.go @@ -237,8 +237,7 @@ func normalizeURL(url string) (string, error) { parts = append(parts, "{address}") if matches[0][5] == "balance" { parts = append(parts, "balance") - } - if matches[0][5] == "keys" { + } else if matches[0][5] == "keys" { parts = append(parts, "keys", "{index}") } default: From 201414e1a51fe54a42729c367000f16500615e30 Mon Sep 17 00:00:00 2001 From: Andrii Date: Tue, 30 Jul 2024 11:57:49 +0300 Subject: [PATCH 8/8] Added missing test for router --- engine/access/rest/routes/router_test.go | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/engine/access/rest/routes/router_test.go b/engine/access/rest/routes/router_test.go index 9112c408155..c2a1d73d6c3 100644 --- a/engine/access/rest/routes/router_test.go +++ b/engine/access/rest/routes/router_test.go @@ -69,6 +69,11 @@ func TestParseURL(t *testing.T) { url: "/v1/accounts/6a587be304c1224c", expected: "getAccount", }, + { + name: "/v1/accounts/{address}/balance", + url: "/v1/accounts/6a587be304c1224c/balance", + expected: "getAccountBalance", + }, { name: "/v1/accounts/{address}/keys/{index}", url: "/v1/accounts/6a587be304c1224c/keys/0", @@ -166,6 +171,11 @@ func TestBenchmarkParseURL(t *testing.T) { url: "/v1/accounts/6a587be304c1224c", expected: "getAccount", }, + { + name: "/v1/accounts/{address}/balance", + url: "/v1/accounts/6a587be304c1224c/balance", + expected: "getAccountBalance", + }, { name: "/v1/accounts/{address}/keys/{index}", url: "/v1/accounts/6a587be304c1224c/keys/0",