Skip to content

Commit

Permalink
Merge pull request #6253 from AndriiDiachuk/add-rest-endpoint-GetAcco…
Browse files Browse the repository at this point in the history
…untsBalance

[Access] Add REST endpoint to get an accounts flow balance
  • Loading branch information
peterargue authored Jul 31, 2024
2 parents c158096 + 201414e commit f283050
Show file tree
Hide file tree
Showing 11 changed files with 339 additions and 2 deletions.
24 changes: 24 additions & 0 deletions engine/access/rest/apiproxy/rest_proxy_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
4 changes: 4 additions & 0 deletions engine/access/rest/models/account.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,7 @@ func (a *AccountPublicKeys) Build(accountKeys []flow.AccountPublicKey) {

*a = keys
}

func (b *AccountBalance) Build(balance uint64) {
b.Balance = util.FromUint(balance)
}
14 changes: 14 additions & 0 deletions engine/access/rest/models/model_account_balance.go
Original file line number Diff line number Diff line change
@@ -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"`
}
45 changes: 45 additions & 0 deletions engine/access/rest/request/get_account_balance.go
Original file line number Diff line number Diff line change
@@ -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
}
53 changes: 53 additions & 0 deletions engine/access/rest/request/get_account_balance_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
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))

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)
}
6 changes: 6 additions & 0 deletions engine/access/rest/request/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
40 changes: 40 additions & 0 deletions engine/access/rest/routes/account_balance.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
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.
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)
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("failed to get account balance, reason: %w", err)
return nil, models.NewNotFoundError(err.Error(), err)
}

var response models.AccountBalance
response.Build(balance)
return response, nil
}
135 changes: 135 additions & 0 deletions engine/access/rest/routes/account_balance_test.go
Original file line number Diff line number Diff line change
@@ -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.NewAPI(t)

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,
)
}
1 change: 0 additions & 1 deletion engine/access/rest/routes/account_keys_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down
9 changes: 8 additions & 1 deletion engine/access/rest/routes/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
Expand Down Expand Up @@ -230,7 +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] == "keys" {
if matches[0][5] == "balance" {
parts = append(parts, "balance")
} else if matches[0][5] == "keys" {
parts = append(parts, "keys", "{index}")
}
default:
Expand Down
10 changes: 10 additions & 0 deletions engine/access/rest/routes/router_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down

0 comments on commit f283050

Please sign in to comment.