Repository: trustwallet/blockatlas
Pull request #923 (2020-02-24 20:17:36)

[Harmony] Adding get validators and get delegations #733
Moved gupadhyaya 's old PR (https://github.com/trustwallet/blockatlas/pull/733) here 


Patch:
diff --git a/api/collection.go b/api/collection.go
index 35c57e0..9d1081f 100644
--- a/api/collection.go
+++ b/api/collection.go
@@ -211,6 +211,76 @@ func makeCategoriesBatchRoute(router gin.IRouter) {
 	})
 }
 
+// @Description Get collection categories
+// @ID collection_categories_v4
+// @Summary Get list of collections from a specific coin and addresses
+// @Accept json
+// @Produce json
+// @Tags Collections
+// @Param data body string true "Payload" default({"60": ["0xb3624367b1ab37daef42e1a3a2ced012359659b0"]})
+// @Success 200 {object} blockatlas.DocsResponse
+// @Router /v4/collectibles/categories [post]
+func makeCategoriesBatchRouteV4(router gin.IRouter) {
+	router.POST("/collectibles/categories", func(c *gin.Context) {
+		var reqs map[string][]string
+		if err := c.BindJSON(&reqs); err != nil {
+			ginutils.ErrorResponse(c).Message(err.Error()).Render()
+			return
+		}
+
+		batch := make(blockatlas.CollectionPage, 0)
+		for key, addresses := range reqs {
+			coinId, err := strconv.Atoi(key)
+			if err != nil {
+				continue
+			}
+			p, ok := platform.CollectionAPIs[uint(coinId)]
+			if !ok {
+				continue
+			}
+			for _, address := range addresses {
+				collections, err := p.GetCollectionsV4(address)
+				if err != nil {
+					continue
+				}
+				batch = append(batch, collections...)
+			}
+		}
+		ginutils.RenderSuccess(c, batch)
+	})
+}
+
+// @Summary Get Collection
+// @ID collection_v4
+// @Description Get a collection from the address
+// @Accept json
+// @Produce json
+// @Tags Collections
+// @Param coin path string true "the coin name" default(ethereum)
+// @Param owner path string true "the query address" default(0x0875BCab22dE3d02402bc38aEe4104e1239374a7)
+// @Param collection_id path string true "the query collection" default(0x06012c8cf97bead5deae237070f9587f8e7a266d)
+// @Success 200 {object} blockatlas.CollectionPage
+// @Failure 500 {object} ginutils.ApiError
+// @Router /v4/{coin}/collections/{owner}/collection/{collection_id} [get]
+func makeCollectionRouteV4(router gin.IRouter, api blockatlas.Platform) {
+	var collectionAPI blockatlas.CollectionAPI
+	collectionAPI, _ = api.(blockatlas.CollectionAPI)
+
+	if collectionAPI == nil {
+		return
+	}
+
+	router.GET("/collections/:owner/collection/:collection_id", func(c *gin.Context) {
+		collectibles, err := collectionAPI.GetCollectiblesV4(c.Param("owner"), c.Param("collection_id"))
+		if err != nil {
+			ginutils.ErrorResponse(c).Message(err.Error()).Render()
+			return
+		}
+
+		ginutils.RenderSuccess(c, collectibles)
+	})
+}
+
 func emptyPage(c *gin.Context) {
 	var page blockatlas.TxPage
 	ginutils.RenderSuccess(c, &page)
diff --git a/api/routes.go b/api/routes.go
index b4e8a9a..c1ce1ba 100644
--- a/api/routes.go
+++ b/api/routes.go
@@ -22,18 +22,16 @@ func SetupObserverAPI(router gin.IRouter, db *storage.Storage) {
 	observerAPI.POST("/webhook/register", addCall(db))
 	observerAPI.DELETE("/webhook/register", deleteCall(db))
 	observerAPI.GET("/status", statusCall(db))
-
-	logger.Info("Routes set up", logger.Params{"routes": len(routers)})
 }
 
 func SetupPlatformAPI(root gin.IRouter) {
-
 	root.GET("/", GetRoot)
 	root.GET("/status", GetStatus)
 
 	v1 := root.Group("/v1")
 	v2 := root.Group("/v2")
 	v3 := root.Group("/v3")
+	v4 := root.Group("/v4")
 
 	v1.GET("/", GetSupportedEndpoints)
 
@@ -59,12 +57,15 @@ func SetupPlatformAPI(root gin.IRouter) {
 	for _, collectionAPI := range platform.Platforms {
 		routerV2 := getRouter(v2, collectionAPI.Coin().Handle)
 		routerV3 := getRouter(v3, collectionAPI.Coin().Handle)
+		routerV4 := getRouter(v4, collectionAPI.Coin().Handle)
 
 		makeCollectionsRoute(routerV3, collectionAPI)
 		makeCollectionRoute(routerV3, collectionAPI)
 
 		oldMakeCollectionRoute(routerV2, collectionAPI)
 		oldMakeCollectionsRoute(routerV2, collectionAPI)
+
+		makeCollectionRouteV4(routerV4, collectionAPI)
 	}
 
 	for _, customAPI := range platform.CustomAPIs {
@@ -79,6 +80,7 @@ func SetupPlatformAPI(root gin.IRouter) {
 
 	oldMakeCategoriesBatchRoute(v2)
 	makeCategoriesBatchRoute(v3)
+	makeCategoriesBatchRouteV4(v4)
 	makeStakingDelegationsBatchRoute(v2)
 	makeStakingDelegationsSimpleBatchRoute(v2)
 
diff --git a/docs/docs.go b/docs/docs.go
index a64b11e..99b46c3 100644
--- a/docs/docs.go
+++ b/docs/docs.go
@@ -1,6 +1,6 @@
 // GENERATED BY THE COMMAND ABOVE; DO NOT EDIT
 // This file was generated by swaggo/swag at
-// 2020-02-18 21:29:30.368853 -0300 -03 m=+0.159220320
+// 2020-02-26 03:37:11.056244 +0300 MSK m=+0.121112696
 
 package docs
 
@@ -997,6 +997,98 @@ var doc = `{
                     }
                 }
             }
+        },
+        "/v4/collectibles/categories": {
+            "post": {
+                "description": "Get collection categories",
+                "consumes": [
+                    "application/json"
+                ],
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "Collections"
+                ],
+                "summary": "Get list of collections from a specific coin and addresses",
+                "operationId": "collection_categories_v4",
+                "parameters": [
+                    {
+                        "default": "{\"60\": [\"0xb3624367b1ab37daef42e1a3a2ced012359659b0\"]}",
+                        "description": "Payload",
+                        "name": "data",
+                        "in": "body",
+                        "required": true,
+                        "schema": {
+                            "type": "string"
+                        }
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "$ref": "#/definitions/blockatlas.DocsResponse"
+                        }
+                    }
+                }
+            }
+        },
+        "/v4/{coin}/collections/{owner}/collection/{collection_id}": {
+            "get": {
+                "description": "Get a collection from the address",
+                "consumes": [
+                    "application/json"
+                ],
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "Collections"
+                ],
+                "summary": "Get Collection",
+                "operationId": "collection_v4",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "default": "ethereum",
+                        "description": "the coin name",
+                        "name": "coin",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "type": "string",
+                        "default": "0x0875BCab22dE3d02402bc38aEe4104e1239374a7",
+                        "description": "the query address",
+                        "name": "owner",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "type": "string",
+                        "default": "0x06012c8cf97bead5deae237070f9587f8e7a266d",
+                        "description": "the query collection",
+                        "name": "collection_id",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "$ref": "#/definitions/blockatlas.CollectionPage"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/ginutils.ApiError"
+                        }
+                    }
+                }
+            }
         }
     },
     "definitions": {
@@ -1168,6 +1260,7 @@ var doc = `{
                     "type": "string"
                 },
                 "nft_version": {
+                    "description": "Delete in the future version, as it's now part of Collectible",
                     "type": "string"
                 },
                 "slug": {
diff --git a/docs/swagger.json b/docs/swagger.json
index 9832557..e348326 100644
--- a/docs/swagger.json
+++ b/docs/swagger.json
@@ -976,6 +976,98 @@
                     }
                 }
             }
+        },
+        "/v4/collectibles/categories": {
+            "post": {
+                "description": "Get collection categories",
+                "consumes": [
+                    "application/json"
+                ],
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "Collections"
+                ],
+                "summary": "Get list of collections from a specific coin and addresses",
+                "operationId": "collection_categories_v4",
+                "parameters": [
+                    {
+                        "default": "{\"60\": [\"0xb3624367b1ab37daef42e1a3a2ced012359659b0\"]}",
+                        "description": "Payload",
+                        "name": "data",
+                        "in": "body",
+                        "required": true,
+                        "schema": {
+                            "type": "string"
+                        }
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "$ref": "#/definitions/blockatlas.DocsResponse"
+                        }
+                    }
+                }
+            }
+        },
+        "/v4/{coin}/collections/{owner}/collection/{collection_id}": {
+            "get": {
+                "description": "Get a collection from the address",
+                "consumes": [
+                    "application/json"
+                ],
+                "produces": [
+                    "application/json"
+                ],
+                "tags": [
+                    "Collections"
+                ],
+                "summary": "Get Collection",
+                "operationId": "collection_v4",
+                "parameters": [
+                    {
+                        "type": "string",
+                        "default": "ethereum",
+                        "description": "the coin name",
+                        "name": "coin",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "type": "string",
+                        "default": "0x0875BCab22dE3d02402bc38aEe4104e1239374a7",
+                        "description": "the query address",
+                        "name": "owner",
+                        "in": "path",
+                        "required": true
+                    },
+                    {
+                        "type": "string",
+                        "default": "0x06012c8cf97bead5deae237070f9587f8e7a266d",
+                        "description": "the query collection",
+                        "name": "collection_id",
+                        "in": "path",
+                        "required": true
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "OK",
+                        "schema": {
+                            "$ref": "#/definitions/blockatlas.CollectionPage"
+                        }
+                    },
+                    "500": {
+                        "description": "Internal Server Error",
+                        "schema": {
+                            "$ref": "#/definitions/ginutils.ApiError"
+                        }
+                    }
+                }
+            }
         }
     },
     "definitions": {
@@ -1147,6 +1239,7 @@
                     "type": "string"
                 },
                 "nft_version": {
+                    "description": "Delete in the future version, as it's now part of Collectible",
                     "type": "string"
                 },
                 "slug": {
diff --git a/docs/swagger.yaml b/docs/swagger.yaml
index be121c2..428a6fa 100644
--- a/docs/swagger.yaml
+++ b/docs/swagger.yaml
@@ -109,6 +109,7 @@ definitions:
       name:
         type: string
       nft_version:
+        description: Delete in the future version, as it's now part of Collectible
         type: string
       slug:
         type: string
@@ -1032,4 +1033,67 @@ paths:
       summary: Get list of collections from a specific coin and addresses
       tags:
       - Collections
+  /v4/{coin}/collections/{owner}/collection/{collection_id}:
+    get:
+      consumes:
+      - application/json
+      description: Get a collection from the address
+      operationId: collection_v4
+      parameters:
+      - default: ethereum
+        description: the coin name
+        in: path
+        name: coin
+        required: true
+        type: string
+      - default: 0x0875BCab22dE3d02402bc38aEe4104e1239374a7
+        description: the query address
+        in: path
+        name: owner
+        required: true
+        type: string
+      - default: 0x06012c8cf97bead5deae237070f9587f8e7a266d
+        description: the query collection
+        in: path
+        name: collection_id
+        required: true
+        type: string
+      produces:
+      - application/json
+      responses:
+        "200":
+          description: OK
+          schema:
+            $ref: '#/definitions/blockatlas.CollectionPage'
+        "500":
+          description: Internal Server Error
+          schema:
+            $ref: '#/definitions/ginutils.ApiError'
+      summary: Get Collection
+      tags:
+      - Collections
+  /v4/collectibles/categories:
+    post:
+      consumes:
+      - application/json
+      description: Get collection categories
+      operationId: collection_categories_v4
+      parameters:
+      - default: '{"60": ["0xb3624367b1ab37daef42e1a3a2ced012359659b0"]}'
+        description: Payload
+        in: body
+        name: data
+        required: true
+        schema:
+          type: string
+      produces:
+      - application/json
+      responses:
+        "200":
+          description: OK
+          schema:
+            $ref: '#/definitions/blockatlas.DocsResponse'
+      summary: Get list of collections from a specific coin and addresses
+      tags:
+      - Collections
 swagger: "2.0"
diff --git a/pkg/blockatlas/api.go b/pkg/blockatlas/api.go
index 141637b..123d246 100644
--- a/pkg/blockatlas/api.go
+++ b/pkg/blockatlas/api.go
@@ -58,6 +58,9 @@ type CollectionAPI interface {
 	//TODO: remove once most of the clients will be updated (deadline: March 17th)
 	OldGetCollections(owner string) (CollectionPage, error)
 	OldGetCollectibles(owner, collectibleID string) (CollectiblePage, error)
+
+	GetCollectionsV4(owner string) (CollectionPage, error)
+	GetCollectiblesV4(owner, collectibleID string) (CollectiblePage, error)
 }
 
 // CustomAPI provides custom HTTP routes
diff --git a/pkg/blockatlas/client.go b/pkg/blockatlas/client.go
index c10c2dc..3c5f0d0 100644
--- a/pkg/blockatlas/client.go
+++ b/pkg/blockatlas/client.go
@@ -47,7 +47,7 @@ func (r *Request) Get(result interface{}, path string, query url.Values) error {
 		queryStr = query.Encode()
 	}
 	uri := strings.Join([]string{r.GetBase(path), queryStr}, "?")
-	return r.Execute("GET", uri, nil, result, errors.Params{"path": path})
+	return r.Execute("GET", uri, nil, result)
 }
 
 func (r *Request) Post(result interface{}, path string, body interface{}) error {
@@ -56,14 +56,13 @@ func (r *Request) Post(result interface{}, path string, body interface{}) error
 		return err
 	}
 	uri := r.GetBase(path)
-	return r.Execute("POST", uri, buf, result, errors.Params{"path": path})
+	return r.Execute("POST", uri, buf, result)
 }
 
-func (r *Request) Execute(method string, url string, body io.Reader, result interface{}, params errors.Params) error {
-	params["method"] = method
+func (r *Request) Execute(method string, url string, body io.Reader, result interface{}) error {
 	req, err := http.NewRequest(method, url, body)
 	if err != nil {
-		return errors.E(err, errors.TypePlatformRequest, params)
+		return errors.E(err, errors.TypePlatformRequest)
 	}
 
 	for key, value := range r.Headers {
@@ -72,21 +71,21 @@ func (r *Request) Execute(method string, url string, body io.Reader, result inte
 
 	res, err := r.HttpClient.Do(req)
 	if err != nil {
-		return errors.E(err, errors.TypePlatformRequest, params)
+		return errors.E(err, errors.TypePlatformRequest)
 	}
 
 	err = r.ErrorHandler(res, url)
 	if err != nil {
-		return errors.E(err, errors.TypePlatformError, params)
+		return errors.E(err, errors.TypePlatformError)
 	}
 	defer res.Body.Close()
 	b, err := ioutil.ReadAll(res.Body)
 	if err != nil {
-		return errors.E(err, errors.TypePlatformUnmarshal, params)
+		return errors.E(err, errors.TypePlatformUnmarshal)
 	}
 	err = json.Unmarshal(b, result)
 	if err != nil {
-		return errors.E(err, errors.TypePlatformUnmarshal, params)
+		return errors.E(err, errors.TypePlatformUnmarshal)
 	}
 	return err
 }
diff --git a/pkg/blockatlas/collectibles.go b/pkg/blockatlas/collectibles.go
index ac8cacb..d8829b6 100644
--- a/pkg/blockatlas/collectibles.go
+++ b/pkg/blockatlas/collectibles.go
@@ -11,9 +11,10 @@ type Collection struct {
 	Total           int    `json:"total"`
 	CategoryAddress string `json:"category_address"`
 	Address         string `json:"address"`
-	Version         string `json:"nft_version"`
 	Coin            uint   `json:"coin"`
-	Type            string `json:"type"`
+	// Delete in the future version, as it's now part of Collectible
+	Version string `json:"nft_version"`
+	Type    string `json:"type"`
 }
 
 type CollectionPage []Collection
@@ -33,6 +34,7 @@ type Collectible struct {
 	Description     string `json:"description"`
 	Coin            uint   `json:"coin"`
 	Name            string `json:"name"`
+	Version         string `json:"nft_version"`
 }
 
 type CollectiblePage []Collectible
diff --git a/platform/ethereum/collection.go b/platform/ethereum/collection.go
index ffd8cf5..251df61 100644
--- a/platform/ethereum/collection.go
+++ b/platform/ethereum/collection.go
@@ -167,6 +167,7 @@ func OldNormalizeCollectiblePage(c *Collection, srcPage []Collectible, coinIndex
 }
 
 func NormalizeCollection(c Collection, coinIndex uint, owner string) blockatlas.Collection {
+	normalizeSupportedContracts(&c)
 	if len(c.Contracts) == 0 {
 		return blockatlas.Collection{}
 	}
@@ -193,7 +194,19 @@ func NormalizeCollection(c Collection, coinIndex uint, owner string) blockatlas.
 	}
 }
 
+func normalizeSupportedContracts(c *Collection) {
+	supportedContracts := make([]PrimaryAssetContract, 0)
+	for _, contract := range c.Contracts {
+		if _, ok := supportedTypes[contract.Type]; !ok {
+			continue
+		}
+		supportedContracts = append(supportedContracts, contract)
+	}
+	c.Contracts = supportedContracts
+}
+
 func NormalizeCollectiblePage(c *Collection, srcPage []Collectible, coinIndex uint) (page blockatlas.CollectiblePage) {
+	normalizeSupportedContracts(c)
 	if len(c.Contracts) == 0 {
 		return
 	}
diff --git a/platform/ethereum/collection_client.go b/platform/ethereum/collection_client.go
index c41ae63..2c4bdeb 100644
--- a/platform/ethereum/collection_client.go
+++ b/platform/ethereum/collection_client.go
@@ -43,6 +43,18 @@ func (c CollectionsClient) GetCollectibles(owner string, collectibleID string) (
 	return collection, page.Collectibles, err
 }
 
+func (c CollectionsClient) GetCollectiblesV4(owner string, collectibleID string) ([]Collectible, error) {
+	query := url.Values{
+		"owner":      {owner},
+		"collection": {collectibleID},
+		"limit":      {strconv.Itoa(300)},
+	}
+
+	var page CollectiblePage
+	err := c.Get(&page, "api/v1/assets", query)
+	return page.Collectibles, err
+}
+
 //TODO: remove once most of the clients will be updated (deadline: March 17th)
 func (c CollectionsClient) OldGetCollectibles(owner string, collectibleID string) (*Collection, []Collectible, error) {
 	collections, err := c.GetCollections(owner)
diff --git a/platform/ethereum/collection_v4.go b/platform/ethereum/collection_v4.go
new file mode 100644
index 0000000..2eed537
--- /dev/null
+++ b/platform/ethereum/collection_v4.go
@@ -0,0 +1,73 @@
+package ethereum
+
+import (
+	"github.com/trustwallet/blockatlas/pkg/blockatlas"
+	"strings"
+)
+
+func (p *Platform) GetCollectionsV4(owner string) (blockatlas.CollectionPage, error) {
+	collections, err := p.collectionsClient.GetCollections(owner)
+	if err != nil {
+		return nil, err
+	}
+	return NormalizeCollectionsV4(collections, p.CoinIndex, owner), nil
+}
+
+func (p *Platform) GetCollectiblesV4(owner, collectibleID string) (blockatlas.CollectiblePage, error) {
+	items, err := p.collectionsClient.GetCollectiblesV4(owner, collectibleID)
+	if err != nil {
+		return nil, err
+	}
+	return NormalizeCollectiblePageV4(items, p.CoinIndex), nil
+}
+
+func NormalizeCollectionsV4(collections []Collection, coinIndex uint, owner string) (page blockatlas.CollectionPage) {
+	for _, collection := range collections {
+		item := NormalizeCollectionV4(collection, coinIndex, owner)
+		page = append(page, item)
+	}
+	return page
+}
+
+func NormalizeCollectionV4(c Collection, coinIndex uint, owner string) blockatlas.Collection {
+	return blockatlas.Collection{
+		Name:         c.Name,
+		Slug:         c.Slug,
+		ImageUrl:     c.ImageUrl,
+		Description:  c.Description,
+		ExternalLink: c.ExternalUrl,
+		Total:        int(c.Total.Int64()),
+		Id:           c.Slug,
+		Address:      owner,
+		Coin:         coinIndex,
+	}
+}
+
+func NormalizeCollectiblePageV4(collectibles []Collectible, coinIndex uint) (page blockatlas.CollectiblePage) {
+	for _, collectible := range collectibles {
+		item := NormalizeCollectibleV4(collectible, coinIndex)
+		if _, ok := supportedTypes[item.Type]; ok {
+			page = append(page, item)
+		}
+	}
+	return page
+}
+
+func NormalizeCollectibleV4(a Collectible, coinIndex uint) blockatlas.Collectible {
+	id := strings.Join([]string{a.AssetContract.Address, a.TokenId}, "-")
+	return blockatlas.Collectible{
+		ID:              id,
+		CollectionID:    a.Collection.Slug,
+		TokenID:         a.TokenId,
+		ContractAddress: a.AssetContract.Address,
+		Name:            a.Name,
+		Category:        a.Collection.Name,
+		ImageUrl:        a.ImagePreviewUrl,
+		ProviderLink:    a.Permalink,
+		ExternalLink:    a.Collection.ExternalLink,
+		Type:            a.AssetContract.Type,
+		Description:     a.Description,
+		Coin:            coinIndex,
+		Version:         a.AssetContract.Version,
+	}
+}
diff --git a/platform/ethereum/model.go b/platform/ethereum/model.go
index df48153..e4f2e33 100644
--- a/platform/ethereum/model.go
+++ b/platform/ethereum/model.go
@@ -91,19 +91,27 @@ type CollectiblePage struct {
 }
 
 type Collectible struct {
-	TokenId         string              `json:"token_id"`
-	AssetContract   CollectibleContract `json:"asset_contract"`
-	ImageUrl        string              `json:"image_url"`
-	ImagePreviewUrl string              `json:"image_preview_url"`
-	Name            string              `json:"name"`
-	ExternalLink    string              `json:"external_link"`
-	Permalink       string              `json:"permalink"`
-	Description     string              `json:"description"`
+	TokenId         string                 `json:"token_id"`
+	AssetContract   AssetContract          `json:"asset_contract"`
+	ImageUrl        string                 `json:"image_url"`
+	ImagePreviewUrl string                 `json:"image_preview_url"`
+	Name            string                 `json:"name"`
+	ExternalLink    string                 `json:"external_link"`
+	Permalink       string                 `json:"permalink"`
+	Description     string                 `json:"description"`
+	Collection      CollectibleCollections `json:"collection"`
 }
 
-type CollectibleContract struct {
+type CollectibleCollections struct {
+	Name         string `json:"name"`
+	Slug         string `json:"slug"`
+	ExternalLink string `json:"external_url"`
+}
+
+type AssetContract struct {
 	Address      string `json:"address"`
 	Category     string `json:"name"`
 	ExternalLink string `json:"external_link"`
 	Type         string `json:"schema_name"`
+	Version      string `json:"nft_version"`
 }
diff --git a/platform/harmony/client.go b/platform/harmony/client.go
index 67f48c6..50b0b99 100644
--- a/platform/harmony/client.go
+++ b/platform/harmony/client.go
@@ -2,9 +2,11 @@ package harmony
 
 import (
 	"fmt"
+	"strconv"
+
 	"github.com/trustwallet/blockatlas/pkg/blockatlas"
+	"github.com/trustwallet/blockatlas/pkg/logger"
 	"github.com/trustwallet/blockatlas/pkg/numbers"
-	"strconv"
 )
 
 type Client struct {
@@ -41,6 +43,45 @@ func (c *Client) GetBlockByNumber(num int64) (info BlockInfo, err error) {
 	return
 }
 
+func (c *Client) GetValidators() (validators Validators, err error) {
+	err = c.Get(&validators, "hmy_getAllValidatorAddresses", nil)
+	return
+}
+
+func (c *Client) GetDelegations(address string) (delegations Delegations, err error) {
+	err = c.RpcCall(&delegations, "hmy_getDelegationsByDelegator", []interface{}{address})
+	if err != nil {
+		logger.Error(err, "Harmony: Failed to get delegations for address")
+	}
+	return
+}
+
+func (c *Client) GetBalance(address string) (string, error) {
+	var result string
+	err := c.RpcCall(&result, "hmy_getBalance", []interface{}{address, "latest"})
+	if err != nil {
+		return "0", err
+	}
+	balance, err := numbers.HexToDecimal(result)
+	if err != nil {
+		return "0", err
+	}
+	return balance, nil
+}
+
+func (c *Client) GetAPR() (float64, error) {
+	var aprInfo string
+	err := c.RpcCall(&aprInfo, "hmy_getAPR", nil)
+	if err != nil {
+		return 0, err
+	}
+	decimalBlock, err := numbers.HexToDecimal(aprInfo)
+	if err != nil {
+		return 0, err
+	}
+	return strconv.ParseFloat(decimalBlock, 64)
+}
+
 func hexToInt(hex string) (uint64, error) {
 	nonceStr, err := numbers.HexToDecimal(hex)
 	if err != nil {
diff --git a/platform/harmony/model.go b/platform/harmony/model.go
index a4134e2..cc2a75f 100644
--- a/platform/harmony/model.go
+++ b/platform/harmony/model.go
@@ -1,5 +1,7 @@
 package harmony
 
+import "math/big"
+
 type TxResponse struct {
 	Result TxResult `json:"result"`
 }
@@ -26,3 +28,22 @@ type BlockInfo struct {
 	Number       string        `json:"number"`
 	Transactions []Transaction `json:"transactions"`
 }
+
+type Validator struct {
+	Address string `json:"one-address"`
+	Active  bool   `json:"active"`
+}
+
+type Validators struct {
+	Validators []Validator `json:"validators"`
+}
+
+type Delegation struct {
+	DelegatorAddress string   `json:"delegator_address"`
+	ValidatorAddress string   `json:"validator_address"`
+	Amount           *big.Int `json:"amount"`
+}
+
+type Delegations struct {
+	List []Delegation `json:"result"`
+}
diff --git a/platform/harmony/stake.go b/platform/harmony/stake.go
new file mode 100644
index 0000000..162ce5b
--- /dev/null
+++ b/platform/harmony/stake.go
@@ -0,0 +1,90 @@
+package harmony
+
+import (
+	"github.com/trustwallet/blockatlas/pkg/blockatlas"
+	"github.com/trustwallet/blockatlas/pkg/errors"
+	"github.com/trustwallet/blockatlas/pkg/logger"
+	services "github.com/trustwallet/blockatlas/services/assets"
+)
+
+func (p *Platform) GetValidators() (blockatlas.ValidatorPage, error) {
+	results := make(blockatlas.ValidatorPage, 0)
+	validators, err := p.client.GetValidators()
+	if err != nil {
+		return results, err
+	}
+
+	apr, err := p.client.GetAPR()
+	if err != nil {
+		apr = Annual
+	}
+
+	for _, v := range validators.Validators {
+		results = append(results, normalizeValidator(v, apr))
+	}
+	return results, nil
+}
+
+func (p *Platform) GetDetails() blockatlas.StakingDetails {
+	apr, err := p.client.GetAPR()
+	if err != nil {
+		apr = Annual
+	}
+	return getDetails(apr)
+}
+
+func (p *Platform) GetDelegations(address string) (blockatlas.DelegationsPage, error) {
+	delegations, err := p.client.GetDelegations(address)
+	if err != nil {
+		return nil, err
+	}
+
+	validators, err := services.GetValidatorsMap(p)
+	if err != nil {
+		return nil, err
+	}
+	return NormalizeDelegations(delegations.List, validators), nil
+}
+
+func (p *Platform) UndelegatedBalance(address string) (string, error) {
+	balance, err := p.client.GetBalance(address)
+	if err != nil {
+		return "0", err
+	}
+	return balance, nil
+}
+
+func NormalizeDelegations(delegations []Delegation, validators blockatlas.ValidatorMap) []blockatlas.Delegation {
+	results := make([]blockatlas.Delegation, 0)
+	for _, v := range delegations {
+		validator, ok := validators[v.ValidatorAddress]
+		if !ok {
+			logger.Error(errors.E("Validator not found", errors.Params{"address": v.ValidatorAddress, "platform": "harmony", "delegation": v.DelegatorAddress}))
+			continue
+		}
+		delegation := blockatlas.Delegation{
+			Delegator: validator,
+			Value:     v.Amount.String(),
+			Status:    blockatlas.DelegationStatusActive,
+		}
+		results = append(results, delegation)
+	}
+	return results
+}
+
+func getDetails(apr float64) blockatlas.StakingDetails {
+	return blockatlas.StakingDetails{
+		Reward:        blockatlas.StakingReward{Annual: apr},
+		MinimumAmount: blockatlas.Amount("0"),
+		LockTime:      0,
+		Type:          blockatlas.DelegationTypeDelegate,
+	}
+}
+
+func normalizeValidator(v Validator, apr float64) (validator blockatlas.Validator) {
+	return blockatlas.Validator{
+		Status:  true,
+		ID:      v.Address,
+		Details: getDetails(apr),
+	}
+}
diff --git a/platform/harmony/transaction.go b/platform/harmony/transaction.go
index 890b5b4..920ed2c 100644
--- a/platform/harmony/transaction.go
+++ b/platform/harmony/transaction.go
@@ -8,6 +8,8 @@ import (
 	"strconv"
 )
 
+const Annual = 10
+
 func (p *Platform) GetTxsByAddress(address string) (blockatlas.TxPage, error) {
 	result, err := p.client.GetTxsOfAddress(address)
 	if err != nil {
diff --git a/platform/nimiq/transaction.go b/platform/nimiq/transaction.go
index 3bfba60..e16a144 100644
--- a/platform/nimiq/transaction.go
+++ b/platform/nimiq/transaction.go
@@ -3,6 +3,7 @@ package nimiq
 import (
 	"github.com/trustwallet/blockatlas/coin"
 	"github.com/trustwallet/blockatlas/pkg/blockatlas"
+	"sort"
 	"time"
 )
 
@@ -39,6 +40,12 @@ func NormalizeTx(srcTx *Tx) blockatlas.Tx {
 
 // NormalizeTxs converts multiple Nimiq transactions
 func NormalizeTxs(srcTxs []Tx) []blockatlas.Tx {
+	sort.SliceStable(srcTxs, func(i, j int) bool {
+		return srcTxs[i].BlockNumber > srcTxs[j].BlockNumber
+	})
+	if len(srcTxs) > blockatlas.TxPerPage {
+		srcTxs = srcTxs[:blockatlas.TxPerPage]
+	}
 	txs := make([]blockatlas.Tx, len(srcTxs))
 	for i, srcTx := range srcTxs {
 		txs[i] = NormalizeTx(&srcTx)
diff --git a/platform/vechain/transaction.go b/platform/vechain/transaction.go
index 119049b..5d3edfa 100644
--- a/platform/vechain/transaction.go
+++ b/platform/vechain/transaction.go
@@ -77,8 +77,6 @@ func (p *Platform) NormalizeTokenTransaction(srcTx Tx, receipt TxReceipt) (block
 		return blockatlas.TxPage{}, errors.E("NormalizeBlockTransaction: Clauses not found", errors.Params{"tx": srcTx})
 	}
 
-	origin := address.EIP55Checksum(blockatlas.GetValidParameter(srcTx.Origin, srcTx.Meta.TxOrigin))
-
 	fee, err := numbers.HexToDecimal(receipt.Paid)
 	if err != nil {
 		return blockatlas.TxPage{}, err
@@ -90,31 +88,40 @@ func (p *Platform) NormalizeTokenTransaction(srcTx Tx, receipt TxReceipt) (block
 			continue
 		}
 		event := output.Events[0] // TODO add support for multisend
-		to := address.EIP55Checksum(event.Address)
 		value, err := numbers.HexToDecimal(event.Data)
 		if err != nil {
 			continue
 		}
+		originSender := address.EIP55Checksum(blockatlas.GetValidParameter(srcTx.Origin, srcTx.Meta.TxOrigin))
+		originReceiver := address.EIP55Checksum(event.Address)
+		topicsFrom := address.EIP55Checksum(getRecipientAddress(event.Topics[1]))
+		topicsTo := address.EIP55Checksum(getRecipientAddress(event.Topics[2]))
+
+		direction, err := getTokenTransactionDirectory(originSender, topicsFrom, topicsTo)
+		if err != nil {
+			continue
+		}
 
 		txs = append(txs, blockatlas.Tx{
-			ID:     srcTx.Id,
-			Coin:   p.Coin().ID,
-			From:   origin,
-			To:     to,
-			Fee:    blockatlas.Amount(fee),
-			Date:   srcTx.Meta.BlockTimestamp,
-			Type:   blockatlas.TxTokenTransfer,
-			Block:  srcTx.Meta.BlockNumber,
-			Status: blockatlas.StatusCompleted,
+			ID:        srcTx.Id,
+			Coin:      p.Coin().ID,
+			From:      originSender,
+			To:        originReceiver,
+			Fee:       blockatlas.Amount(fee),
+			Date:      srcTx.Meta.BlockTimestamp,
+			Type:      blockatlas.TxTokenTransfer,
+			Block:     srcTx.Meta.BlockNumber,
+			Status:    blockatlas.StatusCompleted,
+			Direction: direction,
 			Meta: blockatlas.TokenTransfer{
 				// the only supported Token on VeChain is its Gas token
 				Name:     gasTokenName,
-				TokenID:  to,
+				TokenID:  originReceiver,
 				Value:    blockatlas.Amount(value),
 				Symbol:   gasTokenSymbol,
 				Decimals: gasTokenDecimals,
-				From:     origin,
-				To:       address.EIP55Checksum(getRecipientAddress(event.Topics[2])),
+				From:     originSender,
+				To:       topicsTo,
 			},
 		})
 	}
@@ -137,7 +144,7 @@ func (p *Platform) GetTxsByAddress(address string) (blockatlas.TxPage, error) {
 		if err != nil {
 			continue
 		}
-		tx, err := p.NormalizeTransaction(t, trxId)
+		tx, err := p.NormalizeTransaction(t, trxId, address)
 		if err != nil {
 			continue
 		}
@@ -146,24 +153,33 @@ func (p *Platform) GetTxsByAddress(address string) (blockatlas.TxPage, error) {
 	return txs, nil
 }
 
-func (p *Platform) NormalizeTransaction(srcTx LogTransfer, trxId Tx) (blockatlas.Tx, error) {
+func (p *Platform) NormalizeTransaction(srcTx LogTransfer, trxId Tx, addr string) (blockatlas.Tx, error) {
 	value, err := numbers.HexToDecimal(srcTx.Amount)
 	if err != nil {
 		return blockatlas.Tx{}, err
 	}
 
 	fee := strconv.Itoa(trxId.Gas)
+	sender := address.EIP55Checksum(srcTx.Sender)
+	recipient := address.EIP55Checksum(srcTx.Recipient)
+	addrChecksum := address.EIP55Checksum(addr)
+
+	directory, err := getTransferDirectory(sender, recipient, addrChecksum)
+	if err != nil {
+		return blockatlas.Tx{}, err
+	}
 
 	return blockatlas.Tx{
-		ID:     srcTx.Meta.TxId,
-		Coin:   p.Coin().ID,
-		From:   address.EIP55Checksum(srcTx.Sender),
-		To:     address.EIP55Checksum(srcTx.Recipient),
-		Fee:    blockatlas.Amount(fee),
-		Date:   srcTx.Meta.BlockTimestamp,
-		Type:   blockatlas.TxTransfer,
-		Block:  srcTx.Meta.BlockNumber,
-		Status: blockatlas.StatusCompleted,
+		ID:        srcTx.Meta.TxId,
+		Coin:      p.Coin().ID,
+		From:      sender,
+		To:        recipient,
+		Fee:       blockatlas.Amount(fee),
+		Date:      srcTx.Meta.BlockTimestamp,
+		Type:      blockatlas.TxTransfer,
+		Block:     srcTx.Meta.BlockNumber,
+		Direction: directory,
+		Status:    blockatlas.StatusCompleted,
 		Meta: blockatlas.Transfer{
 			Value:    blockatlas.Amount(value),
 			Symbol:   p.Coin().Symbol,
@@ -185,3 +201,29 @@ func hexToInt(hex string) (uint64, error) {
 func getRecipientAddress(hex string) string {
 	return "0x" + hex[len(hex)-40:]
 }
+
+func getTokenTransactionDirectory(originSender, topicsFrom, topicsTo string) (dir blockatlas.Direction, err error) {
+	if originSender == topicsFrom && originSender == topicsTo {
+		return blockatlas.DirectionSelf, nil
+	}
+	if originSender == topicsFrom && originSender != topicsTo {
+		return blockatlas.DirectionIncoming, nil
+	}
+	if originSender == topicsTo && originSender != topicsFrom {
+		return blockatlas.DirectionOutgoing, nil
+	}
+	return "", errors.E("Unknown direction")
+}
+
+func getTransferDirectory(sender, recipient, addr string) (dir blockatlas.Direction, err error) {
+	if sender == addr && recipient == addr {
+		return blockatlas.DirectionSelf, nil
+	}
+	if sender == addr && recipient != addr {
+		return blockatlas.DirectionOutgoing, nil
+	}
+	if recipient == addr && sender != addr {
+		return blockatlas.DirectionIncoming, nil
+	}
+	return "", errors.E("Unknown direction")
+}


Test patch:
diff --git a/platform/ethereum/collection_test.go b/platform/ethereum/collection_test.go
index a3b6be1..1a96c8a 100644
--- a/platform/ethereum/collection_test.go
+++ b/platform/ethereum/collection_test.go
@@ -246,3 +246,166 @@ func TestSearchCollection(t *testing.T) {
 	}
 
 }
+
+func TestNormalizeSupportedContracts(t *testing.T) {
+	var contracts []PrimaryAssetContract
+	err := json.Unmarshal([]byte(rawAssetContracts), &contracts)
+	assert.Nil(t, err)
+	var collection = Collection{}
+	collection.Contracts = contracts
+	normalizeSupportedContracts(&collection)
+	assert.Equal(t, len(collection.Contracts), 2, "normalizeSupportedContracts with incorrect len")
+	var expectedContracts []PrimaryAssetContract
+	err = json.Unmarshal([]byte(rawAssetContractsExpected), &expectedContracts)
+	assert.Nil(t, err)
+	assert.Equal(t, collection.Contracts, expectedContracts, "normalizeSupportedContracts expectedContracts")
+}
+
+const rawAssetContracts = `[
+    {
+        "address": "0xee85966b4974d3c6f71a2779cc3b6f53afbc2b68",
+        "asset_contract_type": "fungible",
+        "created_date": "2019-10-16T07:36:16.102163",
+        "name": "Rare Chest",
+        "nft_version": null,
+        "opensea_version": null,
+        "owner": 1610615,
+        "schema_name": "ERC20",
+        "symbol": "",
+        "total_supply": "1",
+        "description": "Gods Unchained is a free-to-play, turn-based competitive trading card game in which cards can be bought and sold on the OpenSea marketplace. Players use their collection to build decks of cards, and select a God to play with at the start of each match. The goal of the game is to reduce your opponent's life to zero. Each deck contains exactly 30 cards. On OpenSea, cards can be sold for a fixed price, auctioned, or sold in bundles.",
+        "external_link": "https://godsunchained.com/?refcode=0x5b3256965e7C3cF26E11FCAf296DfC8807C01073",
+        "image_url": "https://lh3.googleusercontent.com/yArciVdcDv3O2R-O8XCxx3YEYZdzpiCMdossjUgv0kpLIluUQ1bYN_dyEk5xcvBEOgeq0zNIoWOh7TL9DvUEv--OLQ=s60",
+        "default_to_fiat": false,
+        "dev_buyer_fee_basis_points": 0,
+        "dev_seller_fee_basis_points": 0,
+        "only_proxied_transfers": false,
+        "opensea_buyer_fee_basis_points": 0,
+        "opensea_seller_fee_basis_points": 250,
+        "buyer_fee_basis_points": 0,
+        "seller_fee_basis_points": 250,
+        "payout_address": null
+    },
+    {
+        "address": "0x20d4cec36528e1c4563c1bfbe3de06aba70b22b4",
+        "asset_contract_type": "fungible",
+        "created_date": "2019-10-16T08:06:42.727997",
+        "name": "Legendary Chest",
+        "nft_version": null,
+        "opensea_version": null,
+        "owner": 1610615,
+        "schema_name": "ERC20",
+        "symbol": "",
+        "total_supply": "1",
+        "description": "Gods Unchained is a free-to-play, turn-based competitive trading card game in which cards can be bought and sold on the OpenSea marketplace. Players use their collection to build decks of cards, and select a God to play with at the start of each match. The goal of the game is to reduce your opponent's life to zero. Each deck contains exactly 30 cards. On OpenSea, cards can be sold for a fixed price, auctioned, or sold in bundles.",
+        "external_link": "https://godsunchained.com/?refcode=0x5b3256965e7C3cF26E11FCAf296DfC8807C01073",
+        "image_url": "https://lh3.googleusercontent.com/yArciVdcDv3O2R-O8XCxx3YEYZdzpiCMdossjUgv0kpLIluUQ1bYN_dyEk5xcvBEOgeq0zNIoWOh7TL9DvUEv--OLQ=s60",
+        "default_to_fiat": false,
+        "dev_buyer_fee_basis_points": 0,
+        "dev_seller_fee_basis_points": 0,
+        "only_proxied_transfers": false,
+        "opensea_buyer_fee_basis_points": 0,
+        "opensea_seller_fee_basis_points": 250,
+        "buyer_fee_basis_points": 0,
+        "seller_fee_basis_points": 250,
+        "payout_address": null
+    },
+    {
+        "address": "0x0e3a2a1f2146d86a604adc220b4967a898d7fe07",
+        "asset_contract_type": "non-fungible",
+        "created_date": "2019-11-01T06:39:04.363034",
+        "name": "Gods Unchained Cards",
+        "nft_version": "3.0",
+        "opensea_version": null,
+        "owner": 1691695,
+        "schema_name": "ERC721",
+        "symbol": "",
+        "total_supply": "1",
+        "description": "Gods Unchained is a free-to-play, turn-based competitive trading card game in which cards can be bought and sold on the OpenSea marketplace. Players use their collection to build decks of cards, and select a God to play with at the start of each match. The goal of the game is to reduce your opponent's life to zero. Each deck contains exactly 30 cards. On OpenSea, cards can be sold for a fixed price, auctioned, or sold in bundles.",
+        "external_link": "https://godsunchained.com/?refcode=0x5b3256965e7C3cF26E11FCAf296DfC8807C01073",
+        "image_url": "https://lh3.googleusercontent.com/yArciVdcDv3O2R-O8XCxx3YEYZdzpiCMdossjUgv0kpLIluUQ1bYN_dyEk5xcvBEOgeq0zNIoWOh7TL9DvUEv--OLQ=s60",
+        "default_to_fiat": false,
+        "dev_buyer_fee_basis_points": 0,
+        "dev_seller_fee_basis_points": 0,
+        "only_proxied_transfers": false,
+        "opensea_buyer_fee_basis_points": 0,
+        "opensea_seller_fee_basis_points": 250,
+        "buyer_fee_basis_points": 0,
+        "seller_fee_basis_points": 250,
+        "payout_address": null
+    },
+    {
+        "address": "0x564cb55c655f727b61d9baf258b547ca04e9e548",
+        "asset_contract_type": "non-fungible",
+        "created_date": "2019-10-29T12:28:37.643714",
+        "name": "Gods Unchained",
+        "nft_version": "3.0",
+        "opensea_version": null,
+        "owner": 1691695,
+        "schema_name": "ERC721",
+        "symbol": "",
+        "total_supply": "205",
+        "description": "Gods Unchained is a free-to-play, turn-based competitive trading card game in which cards can be bought and sold on the OpenSea marketplace. Players use their collection to build decks of cards, and select a God to play with at the start of each match. The goal of the game is to reduce your opponent's life to zero. Each deck contains exactly 30 cards. On OpenSea, cards can be sold for a fixed price, auctioned, or sold in bundles.",
+        "external_link": "https://godsunchained.com/?refcode=0x5b3256965e7C3cF26E11FCAf296DfC8807C01073",
+        "image_url": "https://lh3.googleusercontent.com/yArciVdcDv3O2R-O8XCxx3YEYZdzpiCMdossjUgv0kpLIluUQ1bYN_dyEk5xcvBEOgeq0zNIoWOh7TL9DvUEv--OLQ=s60",
+        "default_to_fiat": false,
+        "dev_buyer_fee_basis_points": 0,
+        "dev_seller_fee_basis_points": 0,
+        "only_proxied_transfers": false,
+        "opensea_buyer_fee_basis_points": 0,
+        "opensea_seller_fee_basis_points": 250,
+        "buyer_fee_basis_points": 0,
+        "seller_fee_basis_points": 250,
+        "payout_address": null
+    }
+]`
+
+const rawAssetContractsExpected = `[{
+        "address": "0x0e3a2a1f2146d86a604adc220b4967a898d7fe07",
+        "asset_contract_type": "non-fungible",
+        "created_date": "2019-11-01T06:39:04.363034",
+        "name": "Gods Unchained Cards",
+        "nft_version": "3.0",
+        "opensea_version": null,
+        "owner": 1691695,
+        "schema_name": "ERC721",
+        "symbol": "",
+        "total_supply": "1",
+        "description": "Gods Unchained is a free-to-play, turn-based competitive trading card game in which cards can be bought and sold on the OpenSea marketplace. Players use their collection to build decks of cards, and select a God to play with at the start of each match. The goal of the game is to reduce your opponent's life to zero. Each deck contains exactly 30 cards. On OpenSea, cards can be sold for a fixed price, auctioned, or sold in bundles.",
+        "external_link": "https://godsunchained.com/?refcode=0x5b3256965e7C3cF26E11FCAf296DfC8807C01073",
+        "image_url": "https://lh3.googleusercontent.com/yArciVdcDv3O2R-O8XCxx3YEYZdzpiCMdossjUgv0kpLIluUQ1bYN_dyEk5xcvBEOgeq0zNIoWOh7TL9DvUEv--OLQ=s60",
+        "default_to_fiat": false,
+        "dev_buyer_fee_basis_points": 0,
+        "dev_seller_fee_basis_points": 0,
+        "only_proxied_transfers": false,
+        "opensea_buyer_fee_basis_points": 0,
+        "opensea_seller_fee_basis_points": 250,
+        "buyer_fee_basis_points": 0,
+        "seller_fee_basis_points": 250,
+        "payout_address": null
+    },
+    {
+        "address": "0x564cb55c655f727b61d9baf258b547ca04e9e548",
+        "asset_contract_type": "non-fungible",
+        "created_date": "2019-10-29T12:28:37.643714",
+        "name": "Gods Unchained",
+        "nft_version": "3.0",
+        "opensea_version": null,
+        "owner": 1691695,
+        "schema_name": "ERC721",
+        "symbol": "",
+        "total_supply": "205",
+        "description": "Gods Unchained is a free-to-play, turn-based competitive trading card game in which cards can be bought and sold on the OpenSea marketplace. Players use their collection to build decks of cards, and select a God to play with at the start of each match. The goal of the game is to reduce your opponent's life to zero. Each deck contains exactly 30 cards. On OpenSea, cards can be sold for a fixed price, auctioned, or sold in bundles.",
+        "external_link": "https://godsunchained.com/?refcode=0x5b3256965e7C3cF26E11FCAf296DfC8807C01073",
+        "image_url": "https://lh3.googleusercontent.com/yArciVdcDv3O2R-O8XCxx3YEYZdzpiCMdossjUgv0kpLIluUQ1bYN_dyEk5xcvBEOgeq0zNIoWOh7TL9DvUEv--OLQ=s60",
+        "default_to_fiat": false,
+        "dev_buyer_fee_basis_points": 0,
+        "dev_seller_fee_basis_points": 0,
+        "only_proxied_transfers": false,
+        "opensea_buyer_fee_basis_points": 0,
+        "opensea_seller_fee_basis_points": 250,
+        "buyer_fee_basis_points": 0,
+        "seller_fee_basis_points": 250,
+        "payout_address": null
+    }
+]`
diff --git a/platform/ethereum/collection_v4_test.go b/platform/ethereum/collection_v4_test.go
new file mode 100644
index 0000000..ed74216
--- /dev/null
+++ b/platform/ethereum/collection_v4_test.go
@@ -0,0 +1,337 @@
+package ethereum
+
+import (
+	"encoding/json"
+	"github.com/stretchr/testify/assert"
+	"github.com/trustwallet/blockatlas/coin"
+	"github.com/trustwallet/blockatlas/pkg/blockatlas"
+	"testing"
+)
+
+const collectionsOwnerV4 = "0x0875BCab22dE3d02402bc38aEe4104e1239374a7"
+
+const collectionsSrcV4 = `
+[
+   {
+      "primary_asset_contracts":[
+         {
+            "address":"0x06012c8cf97bead5deae237070f9587f8e7a266d",
+            "name":"CryptoKitties",
+            "symbol":"CKITTY",
+            "description":"CryptoKitties is a game centered around breedable, collectible, and oh-so-adorable creatures we call CryptoKitties! Each cat is one-of-a-kind and 100% owned by you; it cannot be replicated, taken away, or destroyed.",
+            "external_link":"https://www.cryptokitties.co/",
+            "nft_version":"1.0",
+            "schema_name":"ERC721",
+            "display_data":{
+               "images":[
+                  "https://storage.googleapis.com/ck-kitty-image/0x06012c8cf97bead5deae237070f9587f8e7a266d/564155.svg",
+                  "https://storage.googleapis.com/ck-kitty-image/0x06012c8cf97bead5deae237070f9587f8e7a266d/546630.svg",
+                  "https://storage.googleapis.com/ck-kitty-image/0x06012c8cf97bead5deae237070f9587f8e7a266d/441529.svg",
+                  "https://storage.googleapis.com/ck-kitty-image/0x06012c8cf97bead5deae237070f9587f8e7a266d/552435.svg",
+                  "https://storage.googleapis.com/ck-kitty-image/0x06012c8cf97bead5deae237070f9587f8e7a266d/524748.png",
+                  "https://storage.googleapis.com/ck-kitty-image/0x06012c8cf97bead5deae237070f9587f8e7a266d/540800.svg"
+               ],
+               "card_display_style":"padded"
+            },
+            "image_url":"https://storage.opensea.io/0x06012c8cf97bead5deae237070f9587f8e7a266d-featured-1556588705.png"
+         }
+      ],
+      "name":"CryptoKitties",
+      "slug":"cryptokitties",
+      "image_url":"https://storage.opensea.io/0x06012c8cf97bead5deae237070f9587f8e7a266d-featured-1556588705.png",
+      "description":"CryptoKitties is a game centered around breedable, collectible, and oh-so-adorable creatures we call CryptoKitties! Each cat is one-of-a-kind and 100% owned by you; it cannot be replicated, taken away, or destroyed.",
+      "external_url":"https://www.cryptokitties.co/",
+      "featured_image_url":"https://storage.opensea.io/0x06012c8cf97bead5deae237070f9587f8e7a266d-featured-1556589429.png",
+      "created_date":"2019-04-26T22:13:04.207050",
+      "owned_asset_count":3
+   },
+   {
+      "primary_asset_contracts":[
+         {
+            "address":"0xfaafdc07907ff5120a76b34b731b278c38d6043c",
+            "name":"Enjin",
+            "symbol":"",
+            "description":"",
+            "external_link":null,
+            "nft_version":null,
+            "schema_name":"ERC1155",
+            "display_data":{
+
+            },
+            "owner":null,
+            "created_date":"2019-08-02T23:43:14.666153",
+            "asset_contract_type":"semi-fungible"
+         }
+      ],
+      "name":"Age of Rust",
+      "slug":"age-of-rust",
+      "image_url":"https://storage.opensea.io/age-of-rust-1561960816.jpg",
+      "description":"Year 4424: The search begins for new life on the other side of the galaxy. Explore abandoned space stations, mysterious caverns, and ruins on far away worlds in order to unlock puzzles and secrets! Beware the rogue machines!",
+      "external_url":"https://www.ageofrust.games/",
+      "featured_image_url":null,
+      "created_date":"2019-09-03T02:35:56.063685",
+      "owned_asset_count":1
+   },
+   {
+      "primary_asset_contracts":[
+         {
+            "address":"0xee85966b4974d3c6f71a2779cc3b6f53afbc2b68",
+            "asset_contract_type":"fungible",
+            "created_date":"2019-10-16T07:36:16.102163",
+            "name":"Rare Chest",
+            "nft_version":null,
+            "opensea_version":null,
+            "owner":1610615,
+            "schema_name":"ERC20",
+            "symbol":"",
+            "total_supply":"1",
+            "description":"Gods Unchained is a free-to-play, turn-based competitive trading card game in which cards can be bought and sold on the OpenSea marketplace. Players use their collection to build decks of cards, and select a God to play with at the start of each match. The goal of the game is to reduce your opponent's life to zero. Each deck contains exactly 30 cards. On OpenSea, cards can be sold for a fixed price, auctioned, or sold in bundles.",
+            "external_link":"https://godsunchained.com/?refcode=0x5b3256965e7C3cF26E11FCAf296DfC8807C01073",
+            "image_url":"https://lh3.googleusercontent.com/yArciVdcDv3O2R-O8XCxx3YEYZdzpiCMdossjUgv0kpLIluUQ1bYN_dyEk5xcvBEOgeq0zNIoWOh7TL9DvUEv--OLQ=s60",
+            "default_to_fiat":false,
+            "dev_buyer_fee_basis_points":0,
+            "dev_seller_fee_basis_points":0,
+            "only_proxied_transfers":false,
+            "opensea_buyer_fee_basis_points":0,
+            "opensea_seller_fee_basis_points":250,
+            "buyer_fee_basis_points":0,
+            "seller_fee_basis_points":250,
+            "payout_address":null
+         },
+         {
+            "address":"0x20d4cec36528e1c4563c1bfbe3de06aba70b22b4",
+            "asset_contract_type":"fungible",
+            "created_date":"2019-10-16T08:06:42.727997",
+            "name":"Legendary Chest",
+            "nft_version":null,
+            "opensea_version":null,
+            "owner":1610615,
+            "schema_name":"ERC20",
+            "symbol":"",
+            "total_supply":"1",
+            "description":"Gods Unchained is a free-to-play, turn-based competitive trading card game in which cards can be bought and sold on the OpenSea marketplace. Players use their collection to build decks of cards, and select a God to play with at the start of each match. The goal of the game is to reduce your opponent's life to zero. Each deck contains exactly 30 cards. On OpenSea, cards can be sold for a fixed price, auctioned, or sold in bundles.",
+            "external_link":"https://godsunchained.com/?refcode=0x5b3256965e7C3cF26E11FCAf296DfC8807C01073",
+            "image_url":"https://lh3.googleusercontent.com/yArciVdcDv3O2R-O8XCxx3YEYZdzpiCMdossjUgv0kpLIluUQ1bYN_dyEk5xcvBEOgeq0zNIoWOh7TL9DvUEv--OLQ=s60",
+            "default_to_fiat":false,
+            "dev_buyer_fee_basis_points":0,
+            "dev_seller_fee_basis_points":0,
+            "only_proxied_transfers":false,
+            "opensea_buyer_fee_basis_points":0,
+            "opensea_seller_fee_basis_points":250,
+            "buyer_fee_basis_points":0,
+            "seller_fee_basis_points":250,
+            "payout_address":null
+         },
+         {
+            "address":"0x0e3a2a1f2146d86a604adc220b4967a898d7fe07",
+            "asset_contract_type":"non-fungible",
+            "created_date":"2019-11-01T06:39:04.363034",
+            "name":"Gods Unchained Cards",
+            "nft_version":"3.0",
+            "opensea_version":null,
+            "owner":1691695,
+            "schema_name":"ERC721",
+            "symbol":"",
+            "total_supply":"1",
+            "description":"Gods Unchained is a free-to-play, turn-based competitive trading card game in which cards can be bought and sold on the OpenSea marketplace. Players use their collection to build decks of cards, and select a God to play with at the start of each match. The goal of the game is to reduce your opponent's life to zero. Each deck contains exactly 30 cards. On OpenSea, cards can be sold for a fixed price, auctioned, or sold in bundles.",
+            "external_link":"https://godsunchained.com/?refcode=0x5b3256965e7C3cF26E11FCAf296DfC8807C01073",
+            "image_url":"https://lh3.googleusercontent.com/yArciVdcDv3O2R-O8XCxx3YEYZdzpiCMdossjUgv0kpLIluUQ1bYN_dyEk5xcvBEOgeq0zNIoWOh7TL9DvUEv--OLQ=s60",
+            "default_to_fiat":false,
+            "dev_buyer_fee_basis_points":0,
+            "dev_seller_fee_basis_points":0,
+            "only_proxied_transfers":false,
+            "opensea_buyer_fee_basis_points":0,
+            "opensea_seller_fee_basis_points":250,
+            "buyer_fee_basis_points":0,
+            "seller_fee_basis_points":250,
+            "payout_address":null
+         },
+         {
+            "address":"0x564cb55c655f727b61d9baf258b547ca04e9e548",
+            "asset_contract_type":"non-fungible",
+            "created_date":"2019-10-29T12:28:37.643714",
+            "name":"Gods Unchained",
+            "nft_version":"3.0",
+            "opensea_version":null,
+            "owner":1691695,
+            "schema_name":"ERC721",
+            "symbol":"",
+            "total_supply":"205",
+            "description":"Gods Unchained is a free-to-play, turn-based competitive trading card game in which cards can be bought and sold on the OpenSea marketplace. Players use their collection to build decks of cards, and select a God to play with at the start of each match. The goal of the game is to reduce your opponent's life to zero. Each deck contains exactly 30 cards. On OpenSea, cards can be sold for a fixed price, auctioned, or sold in bundles.",
+            "external_link":"https://godsunchained.com/?refcode=0x5b3256965e7C3cF26E11FCAf296DfC8807C01073",
+            "image_url":"https://lh3.googleusercontent.com/yArciVdcDv3O2R-O8XCxx3YEYZdzpiCMdossjUgv0kpLIluUQ1bYN_dyEk5xcvBEOgeq0zNIoWOh7TL9DvUEv--OLQ=s60",
+            "default_to_fiat":false,
+            "dev_buyer_fee_basis_points":0,
+            "dev_seller_fee_basis_points":0,
+            "only_proxied_transfers":false,
+            "opensea_buyer_fee_basis_points":0,
+            "opensea_seller_fee_basis_points":250,
+            "buyer_fee_basis_points":0,
+            "seller_fee_basis_points":250,
+            "payout_address":null
+         }
+      ],
+      "traits":{
+         "mana":{
+            "min":1,
+            "max":18
+         },
+         "health":{
+            "min":1,
+            "max":16
+         },
+         "attack":{
+            "min":1,
+            "max":16
+         }
+      },
+      "stats":{
+         "seven_day_volume":270.564697067863,
+         "seven_day_change":-0.014179906699531201,
+         "total_volume":11825.0384171324,
+         "count":6835331,
+         "num_owners":10791,
+         "market_cap":150892.14138332763,
+         "average_price":0.0396514694030496,
+         "items_sold":298233
+      },
+      "banner_image_url":null,
+      "chat_url":null,
+      "created_date":"2019-11-13T03:01:42.051246",
+      "default_to_fiat":false,
+      "description":"Gods Unchained is a free-to-play, turn-based competitive trading card game in which cards can be bought and sold on the OpenSea marketplace. Players use their collection to build decks of cards, and select a God to play with at the start of each match. The goal of the game is to reduce your opponent's life to zero. Each deck contains exactly 30 cards. On OpenSea, cards can be sold for a fixed price, auctioned, or sold in bundles.",
+      "dev_buyer_fee_basis_points":"0",
+      "dev_seller_fee_basis_points":"0",
+      "display_data":{
+         "images":[
+            "https://storage.googleapis.com/opensea-prod.appspot.com/0x6ebeaf8e8e946f0716e6533a6f2cefc83f60e8ab/25233.png",
+            "https://storage.googleapis.com/opensea-prod.appspot.com/0x6ebeaf8e8e946f0716e6533a6f2cefc83f60e8ab/152875.png",
+            "https://storage.googleapis.com/opensea-prod.appspot.com/0x6ebeaf8e8e946f0716e6533a6f2cefc83f60e8ab/25669.png",
+            "https://storage.googleapis.com/opensea-prod.appspot.com/0x6ebeaf8e8e946f0716e6533a6f2cefc83f60e8ab/9237.png",
+            "https://storage.googleapis.com/opensea-prod.appspot.com/0x6ebeaf8e8e946f0716e6533a6f2cefc83f60e8ab/9228.png",
+            "https://storage.googleapis.com/opensea-prod.appspot.com/0x6ebeaf8e8e946f0716e6533a6f2cefc83f60e8ab/9231.png"
+         ],
+         "card_display_style":"contain"
+      },
+      "external_url":"https://godsunchained.com/?refcode=0x5b3256965e7C3cF26E11FCAf296DfC8807C01073",
+      "featured":true,
+      "featured_image_url":"https://storage.opensea.io/0x6ebeaf8e8e946f0716e6533a6f2cefc83f60e8ab-featured-1556589419.png",
+      "hidden":false,
+      "image_url":"https://lh3.googleusercontent.com/yArciVdcDv3O2R-O8XCxx3YEYZdzpiCMdossjUgv0kpLIluUQ1bYN_dyEk5xcvBEOgeq0zNIoWOh7TL9DvUEv--OLQ=s60",
+      "is_subject_to_whitelist":false,
+      "large_image_url":"https://lh3.googleusercontent.com/yArciVdcDv3O2R-O8XCxx3YEYZdzpiCMdossjUgv0kpLIluUQ1bYN_dyEk5xcvBEOgeq0zNIoWOh7TL9DvUEv--OLQ",
+      "name":"Gods Unchained",
+      "only_proxied_transfers":false,
+      "opensea_buyer_fee_basis_points":"0",
+      "opensea_seller_fee_basis_points":"250",
+      "payout_address":null,
+      "require_email":false,
+      "short_description":null,
+      "slug":"gods-unchained",
+      "wiki_url":null,
+      "owned_asset_count":535
+   }
+]
+`
+
+var collection1DstV4 = blockatlas.Collection{
+	Name:         "CryptoKitties",
+	Symbol:       "",
+	Slug:         "cryptokitties",
+	ImageUrl:     "https://storage.opensea.io/0x06012c8cf97bead5deae237070f9587f8e7a266d-featured-1556588705.png",
+	Description:  "CryptoKitties is a game centered around breedable, collectible, and oh-so-adorable creatures we call CryptoKitties! Each cat is one-of-a-kind and 100% owned by you; it cannot be replicated, taken away, or destroyed.",
+	ExternalLink: "https://www.cryptokitties.co/",
+	Total:        3,
+	Id:           "cryptokitties",
+	Address:      "0x0875BCab22dE3d02402bc38aEe4104e1239374a7",
+	Coin:         60,
+}
+
+var collection2DstV4 = blockatlas.Collection{
+	Name:         "Age of Rust",
+	Symbol:       "",
+	Slug:         "age-of-rust",
+	ImageUrl:     "https://storage.opensea.io/age-of-rust-1561960816.jpg",
+	Description:  "Year 4424: The search begins for new life on the other side of the galaxy. Explore abandoned space stations, mysterious caverns, and ruins on far away worlds in order to unlock puzzles and secrets! Beware the rogue machines!",
+	ExternalLink: "https://www.ageofrust.games/",
+	Total:        1,
+	Id:           "age-of-rust",
+	Address:      "0x0875BCab22dE3d02402bc38aEe4104e1239374a7",
+	Coin:         60,
+}
+
+var collection3DstV4 = blockatlas.Collection{
+	Name:         "Gods Unchained",
+	Symbol:       "",
+	Slug:         "gods-unchained",
+	ImageUrl:     "https://lh3.googleusercontent.com/yArciVdcDv3O2R-O8XCxx3YEYZdzpiCMdossjUgv0kpLIluUQ1bYN_dyEk5xcvBEOgeq0zNIoWOh7TL9DvUEv--OLQ=s60",
+	Description:  "Gods Unchained is a free-to-play, turn-based competitive trading card game in which cards can be bought and sold on the OpenSea marketplace. Players use their collection to build decks of cards, and select a God to play with at the start of each match. The goal of the game is to reduce your opponent's life to zero. Each deck contains exactly 30 cards. On OpenSea, cards can be sold for a fixed price, auctioned, or sold in bundles.",
+	ExternalLink: "https://godsunchained.com/?refcode=0x5b3256965e7C3cF26E11FCAf296DfC8807C01073",
+	Total:        535,
+	Id:           "gods-unchained",
+	Address:      "0x0875BCab22dE3d02402bc38aEe4104e1239374a7",
+	Coin:         60,
+}
+
+func TestNormalizeCollectionV4(t *testing.T) {
+	var collections []Collection
+	err := json.Unmarshal([]byte(collectionsSrcV4), &collections)
+	assert.Nil(t, err)
+	page := NormalizeCollectionsV4(collections, coin.ETH, collectionsOwnerV4)
+	assert.Equal(t, 3, len(page), "collections could not be normalized")
+	expected := blockatlas.CollectionPage{collection1DstV4, collection2DstV4, collection3DstV4}
+	assert.Equal(t, page, expected, "collections don't equal")
+}
+
+const collectibleSrcV4 = `
+[
+  {
+    "token_id": "54277541829991970107421667568590323026590803461896874578610080514640537714688",
+    "image_url": "https://storage.opensea.io/0xfaafdc07907ff5120a76b34b731b278c38d6043c/54277541829991970107421667568590323026590803461896874578610080514640537714688-1564858806.png",
+    "image_preview_url": "https://storage.opensea.io/0xfaafdc07907ff5120a76b34b731b278c38d6043c-preview/54277541829991970107421667568590323026590803461896874578610080514640537714688-1564858807.png",
+    "name": "Rustbits",
+    "description": "Rustbits are the main token of use within the Age of Rust game universe. You need Rustbits to not only play Age of Rust, but also to purchase in-game cryptoitems as well. Rustbits are radioactive rust scraped off of hulls of abandoned ships that are in orbit around a hidden planet, which is also a gas giant. The planet is so radioactive, it damages ships and kills anyone that gets close to it. Getting bits of rust off of ships is highly rare and prized.",
+    "external_link": "",
+    "asset_contract": {
+      "address": "0xfaafdc07907ff5120a76b34b731b278c38d6043c",
+      "name": "Enjin",
+      "external_link": null,
+      "nft_version": null,
+      "schema_name": "ERC1155",
+	  "display_data": {}
+    },
+	"collection": {
+		"slug": "age-of-rust",
+        "name": "Age of Rust",
+        "external_url": "https://opensea.io/"
+    },
+    "permalink": "https://opensea.io/assets/0xfaafdc07907ff5120a76b34b731b278c38d6043c/54277541829991970107421667568590323026590803461896874578610080514640537714688"
+  }
+]
+`
+
+var collectibleDstV4 = blockatlas.Collectible{
+	ID:               "0xfaafdc07907ff5120a76b34b731b278c38d6043c-54277541829991970107421667568590323026590803461896874578610080514640537714688",
+	CollectionID:     "age-of-rust",
+	TokenID:          "54277541829991970107421667568590323026590803461896874578610080514640537714688",
+	CategoryContract: "",
+	ContractAddress:  "0xfaafdc07907ff5120a76b34b731b278c38d6043c",
+	Category:         "Age of Rust",
+	ImageUrl:         "https://storage.opensea.io/0xfaafdc07907ff5120a76b34b731b278c38d6043c-preview/54277541829991970107421667568590323026590803461896874578610080514640537714688-1564858807.png",
+	ExternalLink:     "https://opensea.io/",
+	ProviderLink:     "https://opensea.io/assets/0xfaafdc07907ff5120a76b34b731b278c38d6043c/54277541829991970107421667568590323026590803461896874578610080514640537714688",
+	Type:             "ERC1155",
+	Description:      "Rustbits are the main token of use within the Age of Rust game universe. You need Rustbits to not only play Age of Rust, but also to purchase in-game cryptoitems as well. Rustbits are radioactive rust scraped off of hulls of abandoned ships that are in orbit around a hidden planet, which is also a gas giant. The planet is so radioactive, it damages ships and kills anyone that gets close to it. Getting bits of rust off of ships is highly rare and prized.",
+	Coin:             60,
+	Name:             "Rustbits",
+}
+
+func TestNormalizeCollectibleV4(t *testing.T) {
+	var collectibles []Collectible
+	err := json.Unmarshal([]byte(collectibleSrcV4), &collectibles)
+	assert.Nil(t, err)
+	page := NormalizeCollectiblePageV4(collectibles, coin.ETH)
+	assert.Equal(t, len(page), 1, "collectible could not be normalized")
+	expected := blockatlas.CollectiblePage{collectibleDstV4}
+	assert.Equal(t, page, expected, "collectible don't equal")
+}
diff --git a/platform/harmony/stake_test.go b/platform/harmony/stake_test.go
new file mode 100644
index 0000000..f193fc5
--- /dev/null
+++ b/platform/harmony/stake_test.go
@@ -0,0 +1,101 @@
+package harmony
+
+import (
+	"encoding/json"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/trustwallet/blockatlas/pkg/blockatlas"
+)
+
+const validatorSrc = `
+{
+	"one-address": "one1pdv9lrdwl0rg5vglh4xtyrv3wjk3wsqket7zxy",
+	"bls-public-keys": [
+		"65f55eb3052f9e9f632b2923be594ba77c55543f5c58ee1454b9cfd658d25e06373b0f7d42a19c84768139ea294f6204"
+	],
+	"min-self-delegation": 2000000000000000000,
+	"max-total-delegation": 11553255926290448384,
+	"active": true,
+	"commission": {
+		"rate": "0.100000000000000000",
+		"max-rate": "0.900000000000000000",
+		"max-change-rate": "0.050000000000000000"
+	},
+	"description": {
+		"name": "John",
+		"identity": "john",
+		"website": "john@harmony.one",
+		"security_contact": "Alex",
+		"details": "John the validator"
+	},
+	"creation-height": 51
+}`
+
+const delegationsSrc = `
+[
+	{
+		"validator_address": "one1pdv9lrdwl0rg5vglh4xtyrv3wjk3wsqket7zxy",
+		"delegator_address": "one1pf75h0t4am90z8uv3y0dgunfqp4lj8wr3t5rsp",
+		"amount": 10000000000000000000,
+		"reward": 15854399877248931866418,
+		"Undelegations": []
+	}
+]`
+
+func TestNormalizeValidator(t *testing.T) {
+	var v Validator
+	_ = json.Unmarshal([]byte(validatorSrc), &v)
+	expected := blockatlas.Validator{
+		Status: v.Active,
+		ID:     v.Address,
+		Details: blockatlas.StakingDetails{
+			Reward:        blockatlas.StakingReward{Annual: 10},
+			LockTime:      0,
+			MinimumAmount: "0",
+			Type:          blockatlas.DelegationTypeDelegate,
+		},
+	}
+	result := normalizeValidator(v, 10)
+	assert.Equal(t, expected, result)
+}
+
+var validator1 = blockatlas.StakeValidator{
+	ID:     "one1pdv9lrdwl0rg5vglh4xtyrv3wjk3wsqket7zxy",
+	Status: true,
+	Info: blockatlas.StakeValidatorInfo{
+		Name:        "Harmony One",
+		Description: "Stake and earn rewards with the most secure and stable validator. Operated by Harmony One Inc.",
+		Image:       "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/harmony/validators/assets/one1pdv9lrdwl0rg5vglh4xtyrv3wjk3wsqket7zxy/logo.png",
+		Website:     "https://harmony.one",
+	},
+	Details: blockatlas.StakingDetails{
+		Reward: blockatlas.StakingReward{
+			Annual: 10,
+		},
+		LockTime:      0,
+		MinimumAmount: "0",
+	},
+}
+
+var validatorMap = blockatlas.ValidatorMap{
+	"one1pdv9lrdwl0rg5vglh4xtyrv3wjk3wsqket7zxy": validator1,
+}
+
+func TestNormalizeDelegations(t *testing.T) {
+	var delegations []Delegation
+	err := json.Unmarshal([]byte(delegationsSrc), &delegations)
+	assert.NoError(t, err)
+	assert.NotNil(t, delegations)
+
+	expected := []blockatlas.Delegation{
+		{
+			Delegator: validator1,
+			Value:     "10000000000000000000",
+			Status:    blockatlas.DelegationStatusActive,
+		},
+	}
+	result := NormalizeDelegations(delegations, validatorMap)
+	assert.Equal(t, expected, result)
+
+}
diff --git a/platform/nimiq/tests/getTransactionsByAddress_50.json b/platform/nimiq/tests/getTransactionsByAddress_50.json
new file mode 100644
index 0000000..973f15b
--- /dev/null
+++ b/platform/nimiq/tests/getTransactionsByAddress_50.json
@@ -0,0 +1,62 @@
+[
+  {
+    "hash": "61454a43916e075f0e0a830888db7a37e9ffc42503fa3cd2d9eb85fe8067e435",
+    "blockHash": "ec2c6712a977d62e95e0d43eca02bf4161c86197e576073429c0cdc6b9cc1ce1",
+    "blockNumber": 507486,
+    "timestamp": 1554283830,
+    "confirmations": 450839,
+    "from": "99643a627e867dae6cf10ca73b4aff0345d3b72e",
+    "fromAddress": "NQ76 K5J3 LQKX GRXS UT7H 1JKK NJPY 0D2V 7DRE",
+    "to": "0d609a98033b7b85ab5178c6050ef9ef82d9006e",
+    "toAddress": "NQ64 1MG9 M603 7DVQ BASH F330 A3PR VX1D J03E",
+    "value": 199400,
+    "fee": 300,
+    "data": null,
+    "flags": 0
+  },
+  {
+    "hash": "bc38ac8f4fa67222e38e6120073e327a1cc7c38b8419502f6fd5dc3699853296",
+    "blockHash": "58c6afb786829419c90bdcdaf0ab01630b36105affc1718e3d2e772d91d73621",
+    "blockNumber": 952271,
+    "timestamp": 1581094339,
+    "confirmations": 6054,
+    "from": "fdcc85a8e604f23c7d2cd3c13a5d18dc566a6e7c",
+    "fromAddress": "NQ02 YP68 BA76 0KR3 QY9C SF0K LP8Q THB6 LTKU",
+    "to": "99643a627e867dae6cf10ca73b4aff0345d3b72e",
+    "toAddress": "NQ76 K5J3 LQKX GRXS UT7H 1JKK NJPY 0D2V 7DRE",
+    "value": 100000,
+    "fee": 138,
+    "data": null,
+    "flags": 0
+  },
+  {
+    "hash": "54de2a0c420f9c145320c9a020decb106b0b8989fe4be147144af79dc13e8a1c",
+    "blockHash": "25b73d90dd38ff61f6c8d713671ba576f735fa3f544d0ea9a5f74f00aea082ff",
+    "blockNumber": 933566,
+    "timestamp": 1579965313,
+    "confirmations": 24759,
+    "from": "fdcc85a8e604f23c7d2cd3c13a5d18dc566a6e7c",
+    "fromAddress": "NQ02 YP68 BA76 0KR3 QY9C SF0K LP8Q THB6 LTKU",
+    "to": "99643a627e867dae6cf10ca73b4aff0345d3b72e",
+    "toAddress": "NQ76 K5J3 LQKX GRXS UT7H 1JKK NJPY 0D2V 7DRE",
+    "value": 100000,
+    "fee": 138,
+    "data": null,
+    "flags": 0
+  },
+  {
+    "hash": "874494dc2640876ad1417822e230304050ef37924b06ee84a8d8584935f2707b",
+    "blockHash": "c583c5ee1b375d46d07a1289de036b7e10c426184d8d85de7e6d6eec66681167",
+    "blockNumber": 591542,
+    "timestamp": 1559352602,
+    "confirmations": 366783,
+    "from": "fdcc85a8e604f23c7d2cd3c13a5d18dc566a6e7c",
+    "fromAddress": "NQ02 YP68 BA76 0KR3 QY9C SF0K LP8Q THB6 LTKU",
+    "to": "99643a627e867dae6cf10ca73b4aff0345d3b72e",
+    "toAddress": "NQ76 K5J3 LQKX GRXS UT7H 1JKK NJPY 0D2V 7DRE",
+    "value": 100000,
+    "fee": 138,
+    "data": null,
+    "flags": 0
+  }
+]
diff --git a/platform/nimiq/transaction_test.go b/platform/nimiq/transaction_test.go
index edbc3a4..8c0a5f0 100644
--- a/platform/nimiq/transaction_test.go
+++ b/platform/nimiq/transaction_test.go
@@ -5,6 +5,10 @@ import (
 	"github.com/stretchr/testify/assert"
 	"github.com/trustwallet/blockatlas/coin"
 	"github.com/trustwallet/blockatlas/pkg/blockatlas"
+	"io/ioutil"
+	"path/filepath"
+	"runtime"
+	"sort"
 	"testing"
 	"time"
 )
@@ -96,3 +100,26 @@ func TestNormalizeTx1(t *testing.T) {
 		})
 	}
 }
+
+func TestNormalizeTxs_Ordering(t *testing.T) {
+	_, goFile, _, _ := runtime.Caller(0)
+	testFilePath := filepath.Join(filepath.Dir(goFile), "tests", "getTransactionsByAddress_50.json")
+	testFile, err := ioutil.ReadFile(testFilePath)
+	if err != nil {
+		t.Fatal(err)
+	}
+	var srcTxs []Tx
+	if err := json.Unmarshal(testFile, &srcTxs); err != nil {
+		t.Fatal(err)
+	}
+	txs := NormalizeTxs(srcTxs)
+	if len(txs) != 4 {
+		t.Fatalf("Unexpected count: %d", len(txs))
+	}
+	sorted := sort.SliceIsSorted(txs, func(i, j int) bool {
+		return txs[i].Block > txs[j].Block
+	})
+	if !sorted {
+		t.Fatal("Transactions not sorted")
+	}
+}
diff --git a/platform/vechain/transaction_test.go b/platform/vechain/transaction_test.go
index 0383c37..43fd3bb 100644
--- a/platform/vechain/transaction_test.go
+++ b/platform/vechain/transaction_test.go
@@ -27,15 +27,16 @@ const trxId = `{
 }`
 
 var expectedTransfer = blockatlas.Tx{
-	ID:     "0x702edd54bd4e13e0012798cc8b2dfa52f7150173945103d203fae26b8e3d2ed7",
-	Coin:   coin.VET,
-	From:   "0xB5e883349e68aB59307d1604555AC890fAC47128",
-	To:     "0x2c7A8d5ccE0d5E6a8a31233B7Dc3DAE9AaE4b405",
-	Date:   1574410670,
-	Type:   blockatlas.TxTransfer,
-	Fee:    blockatlas.Amount("21000"),
-	Status: blockatlas.StatusCompleted,
-	Block:  4395940,
+	ID:        "0x702edd54bd4e13e0012798cc8b2dfa52f7150173945103d203fae26b8e3d2ed7",
+	Coin:      coin.VET,
+	From:      "0xB5e883349e68aB59307d1604555AC890fAC47128",
+	To:        "0x2c7A8d5ccE0d5E6a8a31233B7Dc3DAE9AaE4b405",
+	Date:      1574410670,
+	Type:      blockatlas.TxTransfer,
+	Fee:       blockatlas.Amount("21000"),
+	Status:    blockatlas.StatusCompleted,
+	Block:     4395940,
+	Direction: blockatlas.DirectionOutgoing,
 	Meta: blockatlas.Transfer{
 		Value:    blockatlas.Amount("1347000000000000000"),
 		Decimals: 18,
@@ -46,11 +47,12 @@ var expectedTransfer = blockatlas.Tx{
 func TestNormalizeTransaction(t *testing.T) {
 	tests := []struct {
 		name     string
+		addr     string
 		txData   string
 		txId     string
 		expected blockatlas.Tx
 	}{
-		{"Test normalize VET transfer transaction", transferSrc, trxId, expectedTransfer},
+		{"Test normalize VET transfer transaction", "0xb5e883349e68ab59307d1604555ac890fac47128", transferSrc, trxId, expectedTransfer},
 	}
 
 	platform := Platform{}
@@ -65,7 +67,7 @@ func TestNormalizeTransaction(t *testing.T) {
 			errTrxID := json.Unmarshal([]byte(tt.txId), &tId)
 			assert.Nil(t, errTrxID)
 
-			actual, err := platform.NormalizeTransaction(tx, tId)
+			actual, err := platform.NormalizeTransaction(tx, tId, tt.addr)
 			assert.Nil(t, err)
 
 			assert.Equal(t, tt.expected, actual, "tx don't equal")
@@ -133,15 +135,16 @@ const trxReceipt = `{
 
 var expectedTransferLog = blockatlas.TxPage{
 	{
-		ID:     "0x42f5eba46ddcc458243c753545a3faa849502d078efbc5b74baddea9e6ea5b04",
-		Coin:   coin.VET,
-		From:   "0x2c7A8d5ccE0d5E6a8a31233B7Dc3DAE9AaE4b405",
-		To:     "0x0000000000000000000000000000456E65726779",
-		Date:   1574278180,
-		Type:   blockatlas.TxTokenTransfer,
-		Fee:    blockatlas.Amount("36582000000000000000"),
-		Status: blockatlas.StatusCompleted,
-		Block:  4382764,
+		ID:        "0x42f5eba46ddcc458243c753545a3faa849502d078efbc5b74baddea9e6ea5b04",
+		Coin:      coin.VET,
+		From:      "0x2c7A8d5ccE0d5E6a8a31233B7Dc3DAE9AaE4b405",
+		To:        "0x0000000000000000000000000000456E65726779",
+		Date:      1574278180,
+		Type:      blockatlas.TxTokenTransfer,
+		Fee:       blockatlas.Amount("36582000000000000000"),
+		Status:    blockatlas.StatusCompleted,
+		Block:     4382764,
+		Direction: blockatlas.DirectionIncoming,
 		Meta: blockatlas.TokenTransfer{
 			Name:     gasTokenName,
 			Symbol:   gasTokenSymbol,
@@ -210,9 +213,6 @@ func Test_hexToInt(t *testing.T) {
 }
 
 func Test_getRecipientAddress(t *testing.T) {
-	type args struct {
-		hex string
-	}
 	tests := []struct {
 		name string
 		hex  string
@@ -229,3 +229,59 @@ func Test_getRecipientAddress(t *testing.T) {
 		})
 	}
 }
+
+func Test_getTokenTransactionDirectory(t *testing.T) {
+	addr1 := "0xb5e883349e68ab59307d1604555ac890fac47128"
+	addr2 := "0eec2bbedbb8b18357dab0b753cd1893bb832284"
+	tests := []struct {
+		name         string
+		originSender string
+		topicsFrom   string
+		topicsTo     string
+		expected     blockatlas.Direction
+		expectErr    bool
+	}{
+		{"Self direction", addr1, addr1, addr1, blockatlas.DirectionSelf, false},
+		{"In direction", addr1, addr1, addr2, blockatlas.DirectionIncoming, false},
+		{"Out direction", addr1, addr2, addr1, blockatlas.DirectionOutgoing, false},
+		{"Unknown direction", addr1, addr2, addr2, "", true},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			actual, err := getTokenTransactionDirectory(tt.originSender, tt.topicsFrom, tt.topicsTo)
+			if tt.expectErr {
+				assert.NotNil(t, err)
+			}
+			assert.Equal(t, tt.expected, actual)
+		})
+	}
+}
+
+func Test_getTransferDirectory(t *testing.T) {
+	addr1 := "0xb5e883349e68ab59307d1604555ac890fac47128"
+	addr2 := "0eec2bbedbb8b18357dab0b753cd1893bb832284"
+	tests := []struct {
+		name      string
+		sender    string
+		recipient string
+		address   string
+		expected  blockatlas.Direction
+		expectErr bool
+	}{
+		{"Self direction for addr1", addr1, addr1, addr1, blockatlas.DirectionSelf, false},
+		{"Self direction for addr2", addr2, addr2, addr2, blockatlas.DirectionSelf, false},
+		{"Out direction", addr1, addr2, addr1, blockatlas.DirectionOutgoing, false},
+		{"In direction", addr1, addr2, addr2, blockatlas.DirectionIncoming, false},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			actual, err := getTransferDirectory(tt.sender, tt.recipient, tt.address)
+			if tt.expectErr {
+				assert.NotNil(t, err)
+			}
+			assert.Equal(t, tt.expected, actual)
+		})
+	}
+}
diff --git a/tests/functional/testdata/body_fixtures.json b/tests/functional/testdata/body_fixtures.json
index d054da4..e99f8dc 100644
--- a/tests/functional/testdata/body_fixtures.json
+++ b/tests/functional/testdata/body_fixtures.json
@@ -30,6 +30,18 @@
         "0x7d8bf18C7cE84b3E175b339c4Ca93aEd1dD166F1"
       ]
     }
+  ],
+  "/v4/collectibles/categories": [
+    {
+      "60": [
+        "0xb3624367b1ab37daef42e1a3a2ced012359659b0"
+      ]
+    },
+    {
+      "60": [
+        "0x7d8bf18C7cE84b3E175b339c4Ca93aEd1dD166F1"
+      ]
+    }
   ]
 }
 
diff --git a/tests/postman/Blockatlas.postman_collection.json b/tests/postman/Blockatlas.postman_collection.json
index be23b38..383850d 100644
--- a/tests/postman/Blockatlas.postman_collection.json
+++ b/tests/postman/Blockatlas.postman_collection.json
@@ -1,6 +1,6 @@
 {
 	"info": {
-		"_postman_id": "9cea8a5c-d3e5-40c1-98da-0a7a1742cf6d",
+		"_postman_id": "63af4255-85a1-4505-87cb-13f8de0453af",
 		"name": "Blockatlas",
 		"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
 	},
@@ -1088,6 +1088,135 @@
 							},
 							"response": []
 						},
+						{
+							"name": "collection v4",
+							"event": [
+								{
+									"listen": "prerequest",
+									"script": {
+										"id": "7312c9e7-5330-421a-9ca8-171101050e38",
+										"exec": [
+											""
+										],
+										"type": "text/javascript"
+									}
+								},
+								{
+									"listen": "test",
+									"script": {
+										"id": "acec8baa-0ef0-4703-ab83-c555260deffa",
+										"exec": [
+											"var Ajv = require('ajv');",
+											"var ajv = new Ajv({logger: console});",
+											"let schema = {",
+											"  \"type\": \"object\",",
+											"  \"properties\": {",
+											"    \"total\": {",
+											"      \"type\": \"integer\"",
+											"    },",
+											"    \"docs\": {",
+											"      \"type\": \"array\",",
+											"      \"minItems\": 1,",
+											"      \"uniqueItems\": true,",
+											"      \"items\": {",
+											"        \"type\": \"object\",",
+											"        \"properties\": {",
+											"          \"id\": {",
+											"            \"type\": \"string\"",
+											"          },",
+											"          \"collection_id\": {",
+											"            \"type\": \"string\"",
+											"          },",
+											"          \"token_id\": {",
+											"            \"type\": \"string\"",
+											"          },",
+											"          \"category_contract\": {",
+											"            \"type\": \"string\"",
+											"          },",
+											"          \"contract_address\": {",
+											"            \"type\": \"string\"",
+											"          },",
+											"          \"category\": {",
+											"            \"type\": \"string\"",
+											"          },",
+											"          \"image_url\": {",
+											"            \"type\": \"string\"",
+											"          },",
+											"          \"external_link\": {",
+											"            \"type\": \"string\"",
+											"          },",
+											"          \"provider_link\": {",
+											"            \"type\": \"string\"",
+											"          },",
+											"          \"type\": {",
+											"            \"type\": \"string\"",
+											"          },",
+											"          \"description\": {",
+											"            \"type\": \"string\"",
+											"          },",
+											"          \"coin\": {",
+											"            \"type\": \"integer\"",
+											"          },",
+											"          \"name\": {",
+											"            \"type\": \"string\"",
+											"          }",
+											"        }",
+											"      }",
+											"    },",
+											"    \"status\": {",
+											"      \"type\": \"boolean\"",
+											"    }",
+											"  }",
+											"};",
+											"",
+											"let handler = pm.variables.get(\"handler\");",
+											"let address = pm.variables.get(\"address\");",
+											"var jsonData = pm.response.json();",
+											"",
+											"pm.test(handler + \" - response must be valid and have a body: \" + address, function () {",
+											"    pm.response.to.have.status(200);",
+											"    pm.response.to.be.ok;",
+											"    pm.response.to.not.be.error;",
+											"    pm.response.to.be.withBody;",
+											"    pm.response.to.be.json;",
+											"});",
+											"",
+											"pm.test(handler + \" - schema is valid: \" + address, function() {",
+											"    pm.expect(ajv.validate(schema, jsonData)).to.be.true;",
+											"    console.log(JSON.stringify(jsonData))",
+											"});",
+											""
+										],
+										"type": "text/javascript"
+									}
+								}
+							],
+							"request": {
+								"method": "GET",
+								"header": [
+									{
+										"key": "Authorization",
+										"value": "Bearer {{platform_auth}}",
+										"type": "text"
+									}
+								],
+								"url": {
+									"raw": "{{host}}/v2/{{handler}}/collections/{{address}}/collection/{{collection}}",
+									"host": [
+										"{{host}}"
+									],
+									"path": [
+										"v2",
+										"{{handler}}",
+										"collections",
+										"{{address}}",
+										"collection",
+										"{{collection}}"
+									]
+								}
+							},
+							"response": []
+						},
 						{
 							"name": "collections",
 							"event": [
@@ -1487,6 +1616,144 @@
 								}
 							},
 							"response": []
+						},
+						{
+							"name": "collections batch v4",
+							"event": [
+								{
+									"listen": "prerequest",
+									"script": {
+										"id": "7312c9e7-5330-421a-9ca8-171101050e38",
+										"exec": [
+											""
+										],
+										"type": "text/javascript"
+									}
+								},
+								{
+									"listen": "test",
+									"script": {
+										"id": "acec8baa-0ef0-4703-ab83-c555260deffa",
+										"exec": [
+											"var Ajv = require('ajv');",
+											"var ajv = new Ajv({logger: console});",
+											"let schema = {",
+											"  \"type\": \"object\",",
+											"  \"properties\": {",
+											"    \"total\": {",
+											"      \"type\": \"integer\"",
+											"    },",
+											"    \"docs\": {",
+											"      \"type\": \"array\",",
+											"      \"minItems\": 1,",
+											"      \"uniqueItems\": true,",
+											"      \"items\": {",
+											"        \"type\": \"object\",",
+											"        \"properties\": {",
+											"          \"id\": {",
+											"            \"type\": \"string\"",
+											"          },",
+											"          \"name\": {",
+											"            \"type\": \"string\"",
+											"          },",
+											"          \"symbol\": {",
+											"            \"type\": \"string\"",
+											"          },",
+											"          \"slug\": {",
+											"            \"type\": \"string\"",
+											"          },",
+											"          \"image_url\": {",
+											"            \"type\": \"string\"",
+											"          },",
+											"          \"external_link\": {",
+											"            \"type\": \"string\"",
+											"          },",
+											"          \"total\": {",
+											"            \"type\": \"integer\"",
+											"          },",
+											"          \"category_address\": {",
+											"            \"type\": \"string\"",
+											"          },",
+											"          \"address\": {",
+											"            \"type\": \"string\"",
+											"          },",
+											"          \"nft_version\": {",
+											"            \"type\": \"string\"",
+											"          },",
+											"          \"coin\": {",
+											"            \"type\": \"integer\"",
+											"          },",
+											"          \"type\": {",
+											"            \"type\": \"string\"",
+											"          }",
+											"        }",
+											"      }",
+											"    },",
+											"    \"status\": {",
+											"      \"type\": \"boolean\"",
+											"    }",
+											"  }",
+											"};",
+											"",
+											"let handler = pm.variables.get(\"handler\");",
+											"let address = pm.variables.get(\"address\");",
+											"var jsonData = pm.response.json();",
+											"",
+											"pm.test(handler + \" - response must be valid and have a body: \" + address, function () {",
+											"    pm.response.to.have.status(200);",
+											"    pm.response.to.be.ok;",
+											"    pm.response.to.not.be.error;",
+											"    pm.response.to.be.withBody;",
+											"    pm.response.to.be.json;",
+											"});",
+											"",
+											"pm.test(handler + \" - schema is valid: \" + address, function() {",
+											"    pm.expect(ajv.validate(schema, jsonData)).to.be.true;",
+											"    console.log(JSON.stringify(jsonData))",
+											"});",
+											""
+										],
+										"type": "text/javascript"
+									}
+								}
+							],
+							"request": {
+								"method": "POST",
+								"header": [
+									{
+										"key": "Authorization",
+										"value": "Bearer {{platform_auth}}",
+										"type": "text"
+									},
+									{
+										"key": "Content-Type",
+										"name": "Content-Type",
+										"value": "application/json",
+										"type": "text"
+									}
+								],
+								"body": {
+									"mode": "raw",
+									"raw": "{\n\t\"{{coin}}\": [\"{{address}}\"]\n}",
+									"options": {
+										"raw": {
+											"language": "json"
+										}
+									}
+								},
+								"url": {
+									"raw": "{{host}}/v3/collectibles/categories",
+									"host": [
+										"{{host}}"
+									],
+									"path": [
+										"v3",
+										"collectibles",
+										"categories"
+									]
+								}
+							},
+							"response": []
 						}
 					],
 					"protocolProfileBehavior": {},
@@ -2529,4 +2796,4 @@
 		}
 	],
 	"protocolProfileBehavior": {}
-}
+}
\ No newline at end of file