Repository: trustwallet/blockatlas
Pull request #924 (2020-02-24 23:57:19)

Add v4 Collectibles endpoint
- Remove use of primary contracts


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/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/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)


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/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/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