diff --git a/engine/access/rest/apiproxy/rest_proxy_handler.go b/engine/access/rest/apiproxy/rest_proxy_handler.go index 09b43ebcbae..04ad2711c08 100644 --- a/engine/access/rest/apiproxy/rest_proxy_handler.go +++ b/engine/access/rest/apiproxy/rest_proxy_handler.go @@ -227,6 +227,39 @@ func (r *RestProxyHandler) GetAccountBalanceAtBlockHeight(ctx context.Context, a } +// GetAccountKeys returns account keys by account address and block height. +func (r *RestProxyHandler) GetAccountKeys(ctx context.Context, address flow.Address, height uint64) ([]flow.AccountPublicKey, error) { + upstream, closer, err := r.FaultTolerantClient() + if err != nil { + return nil, err + } + defer closer.Close() + + getAccountKeysAtBlockHeightRequest := &accessproto.GetAccountKeysAtBlockHeightRequest{ + Address: address.Bytes(), + BlockHeight: height, + } + + accountKeyResponse, err := upstream.GetAccountKeysAtBlockHeight(ctx, getAccountKeysAtBlockHeightRequest) + r.log("upstream", "GetAccountKeysAtBlockHeight", err) + + if err != nil { + return nil, err + } + + accountKeys := make([]flow.AccountPublicKey, len(accountKeyResponse.GetAccountKeys())) + for i, key := range accountKeyResponse.GetAccountKeys() { + accountKey, err := convert.MessageToAccountKey(key) + if err != nil { + return nil, err + } + + accountKeys[i] = *accountKey + } + + return accountKeys, 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/models/account.go b/engine/access/rest/models/account.go index 1ed894ab859..9855fe22667 100644 --- a/engine/access/rest/models/account.go +++ b/engine/access/rest/models/account.go @@ -14,7 +14,7 @@ func (a *Account) Build(flowAccount *flow.Account, link LinkGenerator, expand ma a.Expandable = &AccountExpandable{} if expand[expandableKeys] { - var keys AccountPublicKeys + var keys AccountKeys keys.Build(flowAccount.Keys) a.Keys = keys } else { @@ -54,9 +54,9 @@ func (a *AccountPublicKey) Build(k flow.AccountPublicKey) { a.Revoked = k.Revoked } -type AccountPublicKeys []AccountPublicKey +type AccountKeys []AccountPublicKey -func (a *AccountPublicKeys) Build(accountKeys []flow.AccountPublicKey) { +func (a *AccountKeys) Build(accountKeys []flow.AccountPublicKey) { keys := make([]AccountPublicKey, len(accountKeys)) for i, k := range accountKeys { var key AccountPublicKey @@ -67,6 +67,19 @@ func (a *AccountPublicKeys) Build(accountKeys []flow.AccountPublicKey) { *a = keys } +// Build function use model AccountPublicKeys type for GetAccountKeys call +// AccountPublicKeys is an auto-generated type from the openapi spec +func (a *AccountPublicKeys) Build(accountKeys []flow.AccountPublicKey) { + keys := make([]AccountPublicKey, len(accountKeys)) + for i, k := range accountKeys { + var key AccountPublicKey + key.Build(k) + keys[i] = key + } + + a.Keys = keys +} + func (b *AccountBalance) Build(balance uint64) { b.Balance = util.FromUint(balance) } diff --git a/engine/access/rest/models/model_account_public_keys.go b/engine/access/rest/models/model_account_public_keys.go new file mode 100644 index 00000000000..81bc0adbcb6 --- /dev/null +++ b/engine/access/rest/models/model_account_public_keys.go @@ -0,0 +1,13 @@ +/* + * 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 AccountPublicKeys struct { + Keys []AccountPublicKey `json:"keys"` +} diff --git a/engine/access/rest/request/get_account_keys.go b/engine/access/rest/request/get_account_keys.go new file mode 100644 index 00000000000..6580135648f --- /dev/null +++ b/engine/access/rest/request/get_account_keys.go @@ -0,0 +1,45 @@ +package request + +import ( + "github.com/onflow/flow-go/model/flow" +) + +type GetAccountKeys struct { + Address flow.Address + Height uint64 +} + +func (g *GetAccountKeys) Build(r *Request) error { + return g.Parse( + r.GetVar(addressVar), + r.GetQueryParam(blockHeightQuery), + r.Chain, + ) +} + +func (g *GetAccountKeys) 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_keys_test.go b/engine/access/rest/request/get_account_keys_test.go new file mode 100644 index 00000000000..d37b4f82ac6 --- /dev/null +++ b/engine/access/rest/request/get_account_keys_test.go @@ -0,0 +1,52 @@ +package request + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/onflow/flow-go/model/flow" +) + +func Test_GetAccountKeys_InvalidParse(t *testing.T) { + var getAccountKeys GetAccountKeys + + 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 := getAccountKeys.Parse(test.address, test.height, chain) + assert.EqualError(t, err, test.err, fmt.Sprintf("test #%d failed", i)) + } +} + +func Test_GetAccountKeys_ValidParse(t *testing.T) { + var getAccountKeys GetAccountKeys + + addr := "f8d6e0586b0a20c7" + chain := flow.Localnet.Chain() + err := getAccountKeys.Parse(addr, "", chain) + assert.NoError(t, err) + assert.Equal(t, getAccountKeys.Address.String(), addr) + assert.Equal(t, getAccountKeys.Height, SealedHeight) + + err = getAccountKeys.Parse(addr, "100", chain) + assert.NoError(t, err) + assert.Equal(t, getAccountKeys.Height, uint64(100)) + + err = getAccountKeys.Parse(addr, sealed, chain) + assert.NoError(t, err) + assert.Equal(t, getAccountKeys.Height, SealedHeight) + + err = getAccountKeys.Parse(addr, final, chain) + assert.NoError(t, err) + assert.Equal(t, getAccountKeys.Height, FinalHeight) +} diff --git a/engine/access/rest/request/request.go b/engine/access/rest/request/request.go index 47cb9c1e5c2..e8eeda9d1e6 100644 --- a/engine/access/rest/request/request.go +++ b/engine/access/rest/request/request.go @@ -60,6 +60,12 @@ func (rd *Request) GetAccountBalanceRequest() (GetAccountBalance, error) { return req, err } +func (rd *Request) GetAccountKeysRequest() (GetAccountKeys, error) { + var req GetAccountKeys + 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_keys.go b/engine/access/rest/routes/account_keys.go index 55f601ec1a5..c77655aba68 100644 --- a/engine/access/rest/routes/account_keys.go +++ b/engine/access/rest/routes/account_keys.go @@ -9,7 +9,7 @@ import ( ) // GetAccountKeyByIndex handler retrieves an account key by address and index and returns the response -func GetAccountKeyByIndex(r *request.Request, backend access.API, link models.LinkGenerator) (interface{}, error) { +func GetAccountKeyByIndex(r *request.Request, backend access.API, _ models.LinkGenerator) (interface{}, error) { req, err := r.GetAccountKeyRequest() if err != nil { return nil, models.NewBadRequestError(err) @@ -37,3 +37,34 @@ func GetAccountKeyByIndex(r *request.Request, backend access.API, link models.Li response.Build(*accountKey) return response, nil } + +// GetAccountKeys handler retrieves an account keys by address and returns the response +func GetAccountKeys(r *request.Request, backend access.API, _ models.LinkGenerator) (interface{}, error) { + req, err := r.GetAccountKeysRequest() + 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 + isFinalized := req.Height == request.FinalHeight + if isFinalized || 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 + } + + accountKeys, err := backend.GetAccountKeysAtBlockHeight(r.Context(), req.Address, req.Height) + if err != nil { + err = fmt.Errorf("failed to get account keys, reason: %w", err) + return nil, models.NewNotFoundError(err.Error(), err) + } + + var response models.AccountPublicKeys + response.Build(accountKeys) + return response, nil +} diff --git a/engine/access/rest/routes/account_keys_test.go b/engine/access/rest/routes/account_keys_test.go index e67f430af0f..ecffbf4f3ca 100644 --- a/engine/access/rest/routes/account_keys_test.go +++ b/engine/access/rest/routes/account_keys_test.go @@ -11,6 +11,9 @@ import ( mocktestify "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "github.com/onflow/crypto" + "github.com/onflow/crypto/hash" + "github.com/onflow/flow-go/access/mock" "github.com/onflow/flow-go/model/flow" "github.com/onflow/flow-go/utils/unittest" @@ -218,6 +221,128 @@ func TestGetAccountKeyByIndex(t *testing.T) { } } +// TestGetAccountKeys tests local getAccountKeys request. +// +// Runs the following tests: +// 1. Get keys by address at latest sealed block. +// 2. Get keys by address at latest finalized block. +// 3. Get keys by address at height. +// 4. Get key by address and index at missing block. +func TestGetAccountKeys(t *testing.T) { + backend := mock.NewAPI(t) + + t.Run("get keys by address at latest sealed block", func(t *testing.T) { + account := accountWithKeysFixture(t) + var height uint64 = 100 + block := unittest.BlockHeaderFixture(unittest.WithHeaderHeight(height)) + + req := getAccountKeysRequest(t, account, sealedHeightQueryParam) + + backend.Mock. + On("GetLatestBlockHeader", mocktestify.Anything, true). + Return(block, flow.BlockStatusSealed, nil) + + backend.Mock. + On("GetAccountKeysAtBlockHeight", mocktestify.Anything, account.Address, height). + Return(account.Keys, nil) + + expected := expectedAccountKeysResponse(account) + + assertOKResponse(t, req, expected, backend) + mocktestify.AssertExpectationsForObjects(t, backend) + }) + + t.Run("get keys by address at latest finalized block", func(t *testing.T) { + account := accountWithKeysFixture(t) + var height uint64 = 100 + block := unittest.BlockHeaderFixture(unittest.WithHeaderHeight(height)) + + req := getAccountKeysRequest(t, account, finalHeightQueryParam) + + backend.Mock. + On("GetLatestBlockHeader", mocktestify.Anything, false). + Return(block, flow.BlockStatusFinalized, nil) + + backend.Mock. + On("GetAccountKeysAtBlockHeight", mocktestify.Anything, account.Address, height). + Return(account.Keys, nil) + + expected := expectedAccountKeysResponse(account) + + assertOKResponse(t, req, expected, backend) + mocktestify.AssertExpectationsForObjects(t, backend) + }) + + t.Run("get keys by address at height", func(t *testing.T) { + var height uint64 = 1337 + account := accountWithKeysFixture(t) + req := getAccountKeysRequest(t, account, "1337") + + backend.Mock. + On("GetAccountKeysAtBlockHeight", mocktestify.Anything, account.Address, height). + Return(account.Keys, nil) + + expected := expectedAccountKeysResponse(account) + + assertOKResponse(t, req, expected, backend) + mocktestify.AssertExpectationsForObjects(t, backend) + }) + + t.Run("get keys by address at missing block", func(t *testing.T) { + backend := mock.NewAPI(t) + account := accountWithKeysFixture(t) + const finalHeight uint64 = math.MaxUint64 - 2 + + req := getAccountKeysRequest(t, account, finalHeightQueryParam) + + err := fmt.Errorf("block with height: %d does not exist", finalHeight) + backend.Mock. + On("GetLatestBlockHeader", mocktestify.Anything, false). + Return(nil, flow.BlockStatusUnknown, err) + + statusCode := 404 + expected := fmt.Sprintf(` + { + "code": %d, + "message": "block with height: %d does not exist" + } + `, statusCode, finalHeight) + + assertResponse(t, req, statusCode, expected, backend) + mocktestify.AssertExpectationsForObjects(t, backend) + }) + + tests := []struct { + name string + url string + out string + }{ + { + "get keys with invalid address", + accountKeysURL(t, "123", "100"), + `{"code":400, "message":"invalid address"}`, + }, + { + "get keys with invalid height", + accountKeysURL( + t, + unittest.AddressFixture().String(), + "-100", + ), + `{"code":400, "message":"invalid height format"}`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + 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()) + }) + } +} + func accountKeyURL(t *testing.T, address string, index string, height string) string { u, err := url.ParseRequestURI( fmt.Sprintf("/v1/accounts/%s/keys/%s", address, index), @@ -233,6 +358,21 @@ func accountKeyURL(t *testing.T, address string, index string, height string) st return u.String() } +func accountKeysURL(t *testing.T, address string, height string) string { + u, err := url.ParseRequestURI( + fmt.Sprintf("/v1/accounts/%s/keys", address), + ) + require.NoError(t, err) + q := u.Query() + + if height != "" { + q.Add("block_height", height) + } + + u.RawQuery = q.Encode() + return u.String() +} + func getAccountKeyByIndexRequest( t *testing.T, account *flow.Account, @@ -249,6 +389,21 @@ func getAccountKeyByIndexRequest( return req } +func getAccountKeysRequest( + t *testing.T, + account *flow.Account, + height string, +) *http.Request { + req, err := http.NewRequest( + "GET", + accountKeysURL(t, account.Address.String(), height), + nil, + ) + require.NoError(t, err) + + return req +} + func expectedAccountKeyResponse(account *flow.Account) string { return fmt.Sprintf(` { @@ -264,11 +419,53 @@ func expectedAccountKeyResponse(account *flow.Account) string { ) } +func expectedAccountKeysResponse(account *flow.Account) string { + return fmt.Sprintf(` + { + "keys":[ + { + "index":"0", + "public_key":"%s", + "signing_algorithm":"ECDSA_P256", + "hashing_algorithm":"SHA3_256", + "sequence_number":"0", + "weight":"1000", + "revoked":false + }, + { + "index":"1", + "public_key":"%s", + "signing_algorithm":"ECDSA_P256", + "hashing_algorithm":"SHA3_256", + "sequence_number":"0", + "weight":"500", + "revoked":false + } + ] + }`, + account.Keys[0].PublicKey.String(), + account.Keys[1].PublicKey.String(), + ) +} + func findAccountKeyByIndex(keys []flow.AccountPublicKey, keyIndex uint32) *flow.AccountPublicKey { for _, key := range keys { - if uint32(key.Index) == keyIndex { + if key.Index == keyIndex { return &key } } return &flow.AccountPublicKey{} } + +func accountWithKeysFixture(t *testing.T) *flow.Account { + account, err := unittest.AccountFixture() + require.NoError(t, err) + + key2, err := unittest.AccountKeyFixture(128, crypto.ECDSAP256, hash.SHA3_256) + require.NoError(t, err) + + account.Keys = append(account.Keys, key2.PublicKey(500)) + account.Keys[1].Index = 1 + + return account +} diff --git a/engine/access/rest/routes/router.go b/engine/access/rest/routes/router.go index c3925cc531d..57e505d7497 100644 --- a/engine/access/rest/routes/router.go +++ b/engine/access/rest/routes/router.go @@ -161,6 +161,11 @@ var Routes = []route{{ Pattern: "/accounts/{address}/keys/{index}", Name: "getAccountKeyByIndex", Handler: GetAccountKeyByIndex, +}, { + Method: http.MethodGet, + Pattern: "/accounts/{address}/keys", + Name: "getAccountKeys", + Handler: GetAccountKeys, }, { Method: http.MethodGet, Pattern: "/events", @@ -186,7 +191,7 @@ var WSRoutes = []wsroute{{ }} var routeUrlMap = map[string]string{} -var routeRE = regexp.MustCompile(`(?i)/v1/(\w+)(/(\w+)(/(\w+))?)?`) +var routeRE = regexp.MustCompile(`(?i)/v1/(\w+)(/(\w+))?(/(\w+))?(/(\w+))?`) func init() { for _, r := range Routes { @@ -212,7 +217,7 @@ func URLToRoute(url string) (string, error) { func normalizeURL(url string) (string, error) { matches := routeRE.FindAllStringSubmatch(url, -1) - if len(matches) != 1 || len(matches[0]) != 6 { + if len(matches) != 1 || len(matches[0]) != 8 { return "", fmt.Errorf("invalid url") } @@ -235,10 +240,10 @@ 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") - } else if matches[0][5] == "keys" { + if matches[0][5] == "keys" && matches[0][7] != "" { parts = append(parts, "keys", "{index}") + } else if matches[0][5] != "" { + parts = append(parts, matches[0][5]) } default: // named resource. e.g. /v1/network/parameters diff --git a/engine/access/rest/routes/router_test.go b/engine/access/rest/routes/router_test.go index c2a1d73d6c3..bcc4c20cc1f 100644 --- a/engine/access/rest/routes/router_test.go +++ b/engine/access/rest/routes/router_test.go @@ -79,6 +79,11 @@ func TestParseURL(t *testing.T) { url: "/v1/accounts/6a587be304c1224c/keys/0", expected: "getAccountKeyByIndex", }, + { + name: "/v1/accounts/{address}/keys", + url: "/v1/accounts/6a587be304c1224c/keys", + expected: "getAccountKeys", + }, { name: "/v1/events", url: "/v1/events", @@ -181,6 +186,11 @@ func TestBenchmarkParseURL(t *testing.T) { url: "/v1/accounts/6a587be304c1224c/keys/0", expected: "getAccountKeyByIndex", }, + { + name: "/v1/accounts/{address}/keys", + url: "/v1/accounts/6a587be304c1224c/keys", + expected: "getAccountKeys", + }, { name: "/v1/events", url: "/v1/events",