Repository: trustwallet/blockatlas
Pull request #1109 (2020-05-19 06:35:50)

Elrond initial implementation
Elrond Proxy URL : https://api.elrond.com

In order to integrate Elrond in blockatlas we have used the following API calls against our proxy:

    /network/status/4294967295: gets the latest block nonce (number) - for method CurrentBlockNumber()
    /block/4294967295/:nonce: gets a block by nonce (number) - for method GetBlockByNumber()
    /address/:accountAddress/transactions: gets latest transactions of a given account - for method GetTxsOfAddress()


todo[bot]: ## enable pagination of Elrond transactions in the future.

https://github.com/trustwallet/blockatlas/blob/7a15737c676544e76f50f74da50d57fd557feb56/platform/elrond/client.go#L47-L52

---

###### This comment was generated by [todo](https://todo.jasonet.co) based on a `TODO` comment in 7a15737c676544e76f50f74da50d57fd557feb56 in #1109. cc @ElrondNetwork.
todo[bot]: ## currently Elrond only fetches the most recent 20 transactions.

https://github.com/trustwallet/blockatlas/blob/7a15737c676544e76f50f74da50d57fd557feb56/platform/elrond/client.go#L48-L53

---

###### This comment was generated by [todo](https://todo.jasonet.co) based on a `TODO` comment in 7a15737c676544e76f50f74da50d57fd557feb56 in #1109. cc @ElrondNetwork.
codecov[bot]: # [Codecov](https://codecov.io/gh/trustwallet/blockatlas/pull/1109?src=pr&el=h1) Report
> Merging [#1109](https://codecov.io/gh/trustwallet/blockatlas/pull/1109?src=pr&el=desc) into [master](https://codecov.io/gh/trustwallet/blockatlas/commit/7495506f457ca99737e94fa365a529443ff58128&el=desc) will **decrease** coverage by `0%`.
> The diff coverage is `35%`.

[![Impacted file tree graph](https://codecov.io/gh/trustwallet/blockatlas/pull/1109/graphs/tree.svg?width=650&height=150&src=pr&token=jnrjoEQAqv)](https://codecov.io/gh/trustwallet/blockatlas/pull/1109?src=pr&el=tree)

```diff
@@          Coverage Diff           @@
##           master   #1109   +/-   ##
======================================
- Coverage      38%     38%   -1%     
======================================
  Files         156     161    +5     
  Lines        5387    5474   +87     
======================================
+ Hits         2064    2095   +31     
- Misses       3189    3245   +56     
  Partials      134     134           
```


| [Impacted Files](https://codecov.io/gh/trustwallet/blockatlas/pull/1109?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [coin/coins.go](https://codecov.io/gh/trustwallet/blockatlas/pull/1109/diff?src=pr&el=tree#diff-Y29pbi9jb2lucy5nbw==) | `2% <0%> (-1%)` | :arrow_down: |
| [platform/elrond/base.go](https://codecov.io/gh/trustwallet/blockatlas/pull/1109/diff?src=pr&el=tree#diff-cGxhdGZvcm0vZWxyb25kL2Jhc2UuZ28=) | `0% <0%> (ø)` | |
| [platform/elrond/block.go](https://codecov.io/gh/trustwallet/blockatlas/pull/1109/diff?src=pr&el=tree#diff-cGxhdGZvcm0vZWxyb25kL2Jsb2NrLmdv) | `0% <0%> (ø)` | |
| [platform/elrond/client.go](https://codecov.io/gh/trustwallet/blockatlas/pull/1109/diff?src=pr&el=tree#diff-cGxhdGZvcm0vZWxyb25kL2NsaWVudC5nbw==) | `0% <0%> (ø)` | |
| [platform/elrond/model.go](https://codecov.io/gh/trustwallet/blockatlas/pull/1109/diff?src=pr&el=tree#diff-cGxhdGZvcm0vZWxyb25kL21vZGVsLmdv) | `62% <62%> (ø)` | |
| [platform/elrond/transaction.go](https://codecov.io/gh/trustwallet/blockatlas/pull/1109/diff?src=pr&el=tree#diff-cGxhdGZvcm0vZWxyb25kL3RyYW5zYWN0aW9uLmdv) | `70% <70%> (ø)` | |
| ... and [1 more](https://codecov.io/gh/trustwallet/blockatlas/pull/1109/diff?src=pr&el=tree-more) | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/trustwallet/blockatlas/pull/1109?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/trustwallet/blockatlas/pull/1109?src=pr&el=footer). Last update [7495506...7a15737](https://codecov.io/gh/trustwallet/blockatlas/pull/1109?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

catenocrypt: Diff coverage is 35%, should be increased.

Patch:
diff --git a/README.md b/README.md
index ebe256e..c255a8e 100644
--- a/README.md
+++ b/README.md
@@ -36,6 +36,7 @@ The observer API watches the chain for new transactions and generates notificati
 <a href="https://cosmos.network/" target="_blank"><img src="https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/cosmos/info/logo.png" width="32" /></a>
 <a href="https://bitcoin.org/" target="_blank"><img src="https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/bitcoin/info/logo.png" width="32" /></a>
 <a href="https://harmony.one/" target="_blank"><img src="https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/harmony/info/logo.png" width="32" /></a>
+<a href="https://elrond.com/" target="_blank"><img src="https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/elrond/info/logo.png" width="32" /></a>
 
 ## Architecture
 
diff --git a/coin/coins.go b/coin/coins.go
index 88e22e6..9fa098e 100644
--- a/coin/coins.go
+++ b/coin/coins.go
@@ -1,6 +1,6 @@
 // Code generated by go generate; DO NOT EDIT.
 // This file was generated by robots at
-// 2020-05-15 00:06:57.247245 &#43;0300 MSK m=&#43;0.001827650
+// 2020-05-18 14:17:38.050930018 &#43;0300 EEST m=&#43;0.001659149
 // using data from coins.yml
 package coin
 
@@ -73,6 +73,7 @@ const (
 	KSM = 434
 	SOL = 501
 	NEAR = 397
+	ERD = 508
 )
 
 var Coins = map[uint]Coin{
@@ -556,6 +557,16 @@ var Coins = map[uint]Coin{
 		MinConfirmations: 0,
 		SampleAddr:       "NEAR6Y66fCzeKqWiwxoPox5oGeDN9VhNCu7CEQ9M86iniqoN9vg2X",
 	},
+	ERD: {
+		ID:               508,
+		Handle:           "elrond",
+		Symbol:           "ERD",
+		Name:             "Elrond",
+		Decimals:         18,
+		BlockTime:        6000,
+		MinConfirmations: 0,
+		SampleAddr:       "erd12tqtt5zcg6vpw65y4hkanvt49kzq695sr3ctuszjy92xw0ppzcssy2xd5r",
+	},
 }
 func Ethereum() Coin {
 	return Coins[ETH]
@@ -701,4 +712,7 @@ func Solana() Coin {
 func Near() Coin {
 	return Coins[NEAR]
 }
+func Elrond() Coin {
+	return Coins[ERD]
+}
 
diff --git a/coin/coins.yml b/coin/coins.yml
index f1bc5b9..05c583b 100644
--- a/coin/coins.yml
+++ b/coin/coins.yml
@@ -380,3 +380,11 @@
   decimals: 18
   blockTime: 2000
   sampleAddress: 'NEAR6Y66fCzeKqWiwxoPox5oGeDN9VhNCu7CEQ9M86iniqoN9vg2X'
+
+- id: 508
+  symbol: ERD
+  handle: elrond
+  name: Elrond
+  decimals: 18
+  blockTime: 6000
+  sampleAddress: 'erd12tqtt5zcg6vpw65y4hkanvt49kzq695sr3ctuszjy92xw0ppzcssy2xd5r'
diff --git a/config.yml b/config.yml
index f0f6a87..61a8b41 100644
--- a/config.yml
+++ b/config.yml
@@ -217,3 +217,6 @@ solana:
 
 near:
   api: https://staging-rpc.nearprotocol.com
+
+elrond:
+  api: https://api.elrond.com
diff --git a/pkg/numbers/decimal.go b/pkg/numbers/decimal.go
index 667470a..72e40ef 100644
--- a/pkg/numbers/decimal.go
+++ b/pkg/numbers/decimal.go
@@ -11,8 +11,16 @@ import (
 // "12.345" => "12345"
 // "0.0230" => "230"
 func DecimalToSatoshis(dec string) (string, error) {
-	out := strings.Replace(dec, ".", "", 1)
-	out = strings.TrimLeft(out, "0")
+	out := strings.TrimLeft(dec, " ")
+	out = strings.TrimRight(out, " ")
+	out = strings.Replace(out, ".", "", 1)
+	// trim left 0's but keep last
+	if l := len(out); l >= 2 {
+		out = strings.TrimLeft(out[:l-1], "0") + out[l-1:l]
+	}
+	if len(out) == 0 {
+		return "", errors.E("Invalid empty input", errors.Params{"dec": dec, "dec_trimmed": out})
+	}
 	for _, c := range out {
 		if !unicode.IsNumber(c) {
 			return "", errors.E("not a number", errors.Params{"dec": dec, "c": c})
diff --git a/platform/cosmos/transaction.go b/platform/cosmos/transaction.go
index 5a08c1f..3de7d5f 100644
--- a/platform/cosmos/transaction.go
+++ b/platform/cosmos/transaction.go
@@ -146,9 +146,13 @@ func (p *Platform) fillTransfer(tx *blockatlas.Tx, transfer MessageValueTransfer
 }
 
 func (p *Platform) fillDelegate(tx *blockatlas.Tx, delegate MessageValueDelegate, events Events, msgType TxType) {
-	value, err := numbers.DecimalToSatoshis(delegate.Amount.Quantity)
-	if err != nil {
-		return
+	value := ""
+	if len(delegate.Amount.Quantity) > 0 {
+		var err error
+		value, err = numbers.DecimalToSatoshis(delegate.Amount.Quantity)
+		if err != nil {
+			return
+		}
 	}
 	tx.From = delegate.DelegatorAddr
 	tx.To = delegate.ValidatorAddr
diff --git a/platform/elrond/base.go b/platform/elrond/base.go
new file mode 100644
index 0000000..ac154eb
--- /dev/null
+++ b/platform/elrond/base.go
@@ -0,0 +1,22 @@
+package elrond
+
+import (
+	"github.com/trustwallet/blockatlas/coin"
+	"github.com/trustwallet/blockatlas/pkg/blockatlas"
+)
+
+type Platform struct {
+	client    Client
+	CoinIndex uint
+}
+
+func Init(coin uint, api string) *Platform {
+	return &Platform{
+		CoinIndex: coin,
+		client:    Client{blockatlas.InitJSONClient(api)},
+	}
+}
+
+func (p *Platform) Coin() coin.Coin {
+	return coin.Coins[p.CoinIndex]
+}
diff --git a/platform/elrond/block.go b/platform/elrond/block.go
new file mode 100644
index 0000000..65ed53f
--- /dev/null
+++ b/platform/elrond/block.go
@@ -0,0 +1,11 @@
+package elrond
+
+import "github.com/trustwallet/blockatlas/pkg/blockatlas"
+
+func (p *Platform) CurrentBlockNumber() (int64, error) {
+	return p.client.CurrentBlockNumber()
+}
+
+func (p *Platform) GetBlockByNumber(num int64) (*blockatlas.Block, error) {
+	return p.client.GetBlockByNumber(num)
+}
diff --git a/platform/elrond/client.go b/platform/elrond/client.go
new file mode 100644
index 0000000..1040555
--- /dev/null
+++ b/platform/elrond/client.go
@@ -0,0 +1,58 @@
+package elrond
+
+import (
+	"fmt"
+
+	"github.com/trustwallet/blockatlas/pkg/blockatlas"
+)
+
+type Client struct {
+	blockatlas.Request
+}
+
+func (c *Client) CurrentBlockNumber() (num int64, err error) {
+	var networkStatus NetworkStatus
+	path := fmt.Sprintf("network/status/%s", metachainID)
+	err = c.Get(&networkStatus, path, nil)
+	if err != nil {
+		return 0, err
+	}
+
+	latestNonce := networkStatus.NetworkStatus.Status.Nonce
+
+	return int64(latestNonce), nil
+}
+
+func (c *Client) GetBlockByNumber(height int64) (*blockatlas.Block, error) {
+	var blockRes BlockResponse
+
+	path := fmt.Sprintf("block/%s/%d", metachainID, uint64(height))
+	err := c.Get(&blockRes, path, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	block := blockRes.Block
+	txs := NormalizeTxs(block.Transactions, "")
+
+	return &blockatlas.Block{
+		Number: int64(block.Nonce),
+		ID:     block.Hash,
+		Txs:    txs,
+	}, nil
+}
+
+func (c *Client) GetTxsOfAddress(address string) (blockatlas.TxPage, error) {
+	var txPage TransactionsPage
+	// TODO: enable pagination of Elrond transactions in the future.
+	// TODO: currently Elrond only fetches the most recent 20 transactions.
+	path := fmt.Sprintf("address/%s/transactions", address)
+	err := c.Get(&txPage, path, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	txs := NormalizeTxs(txPage.Transactions, address)
+
+	return txs, nil
+}
diff --git a/platform/elrond/model.go b/platform/elrond/model.go
new file mode 100644
index 0000000..98fbdda
--- /dev/null
+++ b/platform/elrond/model.go
@@ -0,0 +1,69 @@
+package elrond
+
+import (
+	"time"
+
+	"github.com/trustwallet/blockatlas/pkg/blockatlas"
+)
+
+type NetworkStatus struct {
+	NetworkStatus Status `json:"message"`
+}
+
+type Status struct {
+	Status StatusDetails `json:"status"`
+}
+
+type StatusDetails struct {
+	Round float64 `json:"erd_current_round"`
+	Epoch float64 `json:"erd_epoch_number"`
+	Nonce float64 `json:"erd_nonce"`
+}
+
+type BlockResponse struct {
+	Block Block `json:"block"`
+}
+
+type Block struct {
+	Nonce        uint64        `json:"nonce"`
+	Hash         string        `json:"hash"`
+	Transactions []Transaction `json:"transactions"`
+}
+
+type TransactionsPage struct {
+	Transactions []Transaction `json:"transactions"`
+}
+
+type Transaction struct {
+	Hash      string        `json:"hash"`
+	Nonce     uint64        `json:"nonce"`
+	Value     string        `json:"value"`
+	Receiver  string        `json:"receiver"`
+	Sender    string        `json:"sender"`
+	Data      string        `json:"data"`
+	Timestamp time.Duration `json:"timestamp"`
+	Status    string        `json:"status"`
+	Fee       string        `json:"fee"`
+}
+
+func (tx *Transaction) TxStatus() blockatlas.Status {
+	switch tx.Status {
+	case "Success":
+		return blockatlas.StatusCompleted
+	case "Pending":
+		return blockatlas.StatusPending
+	default:
+		return blockatlas.StatusError
+	}
+}
+
+func (tx *Transaction) Direction(address string) blockatlas.Direction {
+	switch {
+	case tx.Sender == address && tx.Receiver == address:
+		return blockatlas.DirectionSelf
+	case tx.Sender == address && tx.Receiver != address:
+		return blockatlas.DirectionOutgoing
+	default:
+		return blockatlas.DirectionIncoming
+	}
+}
diff --git a/platform/elrond/transaction.go b/platform/elrond/transaction.go
new file mode 100644
index 0000000..f56ffb9
--- /dev/null
+++ b/platform/elrond/transaction.go
@@ -0,0 +1,54 @@
+package elrond
+
+import (
+	"github.com/trustwallet/blockatlas/coin"
+	"github.com/trustwallet/blockatlas/pkg/blockatlas"
+)
+
+const metachainID = "4294967295"
+
+func (p *Platform) GetTxsByAddress(address string) (blockatlas.TxPage, error) {
+	return p.client.GetTxsOfAddress(address)
+}
+
+// NormalizeTx converts an slice of Elrond transaction info a slice of generic model transaction
+func NormalizeTxs(srcTxs []Transaction, address string) (txs []blockatlas.Tx) {
+	for _, srcTx := range srcTxs {
+		tx, ok := NormalizeTx(srcTx, address)
+		if !ok {
+			continue
+		}
+		txs = append(txs, tx)
+	}
+	return txs
+}
+
+// NormalizeTx converts an Elrond transaction into the generic model
+func NormalizeTx(srcTx Transaction, address string) (tx blockatlas.Tx, ok bool) {
+	tx = blockatlas.Tx{
+		ID:       srcTx.Hash,
+		Coin:     coin.Elrond().ID,
+		Date:     int64(srcTx.Timestamp),
+		From:     srcTx.Sender,
+		To:       srcTx.Receiver,
+		Fee:      blockatlas.Amount(srcTx.Fee),
+		Status:   srcTx.TxStatus(),
+		Sequence: srcTx.Nonce,
+		Memo:     srcTx.Data,
+		Meta: blockatlas.Transfer{
+			Value:    blockatlas.Amount(srcTx.Value),
+			Symbol:   coin.Elrond().Symbol,
+			Decimals: coin.Elrond().Decimals,
+		},
+	}
+	if address != "" {
+		tx.Direction = srcTx.Direction(address)
+	}
+
+	// check if transaction sender is metachain shard (protocol transaction)
+	if srcTx.Sender == metachainID {
+		tx.From = "metachain"
+	}
+
+	return tx, true
+}
diff --git a/platform/harmony/client.go b/platform/harmony/client.go
index 67f48c6..70de06a 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,40 @@ func (c *Client) GetBlockByNumber(num int64) (info BlockInfo, err error) {
 	return
 }
 
+
+func (c *Client) GetValidators() (validators Validators, err error) {
+	err = rpcCallStub(c, &validators.Validators, "hmy_getAllValidatorInformation", []interface{}{-1})
+
+	if err != nil {
+		logger.Error(err, "Harmony: Failed to get all validator addresses")
+	}
+
+	return
+}
+
+func (c *Client) GetDelegations(address string) (delegations Delegations, err error) {
+	err = rpcCallStub(c, &delegations.List, "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 := rpcCallStub(c, &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 hexToInt(hex string) (uint64, error) {
 	nonceStr, err := numbers.HexToDecimal(hex)
 	if err != nil {
@@ -48,3 +84,8 @@ func hexToInt(hex string) (uint64, error) {
 	}
 	return strconv.ParseUint(nonceStr, 10, 64)
 }
+
+// rpcCallStub is can be overwritten by the unit test
+var rpcCallStub = func (c *Client, result interface{}, method string, params interface{}) error {
+	return c.RpcCall(result, method, params)
+}
diff --git a/platform/harmony/model.go b/platform/harmony/model.go
index a4134e2..698465e 100644
--- a/platform/harmony/model.go
+++ b/platform/harmony/model.go
@@ -26,3 +26,31 @@ type BlockInfo struct {
 	Number       string        `json:"number"`
 	Transactions []Transaction `json:"transactions"`
 }
+
+type ValidatorInfo struct {
+	Address string  `json:"address"`
+}
+
+type LifetimeInfo struct {
+	Apr     string `json:"apr"`
+}
+
+type Validator struct {
+	Info      ValidatorInfo    `json:"validator"`
+	Active    bool             `json:"currently-in-committee"`
+	Lifetime  LifetimeInfo      `json:"lifetime"`
+}
+
+type Validators struct {
+	Validators []Validator `json:"result"`
+}
+
+type Delegation struct {
+	DelegatorAddress string   `json:"delegator_address"`
+	ValidatorAddress string   `json:"validator_address"`
+	Amount           float64  `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..7064654
--- /dev/null
+++ b/platform/harmony/stake.go
@@ -0,0 +1,123 @@
+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"
+	"math/big"
+	"strconv"
+)
+
+const (
+	lockTime  = 604800 // in seconds (7 epochs or 7 days)
+)
+
+func (p *Platform) GetValidators() (blockatlas.ValidatorPage, error) {
+	results := make(blockatlas.ValidatorPage, 0)
+	validators, err := p.client.GetValidators()
+	if err != nil {
+		return results, err
+	}
+
+	for _, v := range validators.Validators {
+		var apr float64
+		if apr, err = strconv.ParseFloat(v.Lifetime.Apr, 64); err != nil {
+			apr = 0
+		}
+		results = append(results, normalizeValidator(v, apr))
+	}
+
+	return results, nil
+}
+
+func (p *Platform) GetDetails() blockatlas.StakingDetails {
+	apr := p.GetMaxAPR()
+	return getDetails(apr)
+}
+
+func (p *Platform) GetMaxAPR() float64 {
+	validators, err := p.client.GetValidators()
+	if err != nil {
+		logger.Error("GetMaxAPR", logger.Params{"details": err, "platform": p.Coin().Symbol})
+		return Annual
+	}
+
+	var max = 0.0
+	for _, e := range validators.Validators {
+		var apr float64
+		if apr, err = strconv.ParseFloat(e.Lifetime.Apr, 64); err != nil {
+			apr = 0.0
+		}
+
+		if apr > max {
+			max = apr
+		}
+	}
+
+	return max
+}
+
+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
+		}
+
+		bigval := new(big.Float)
+		bigval.SetFloat64(v.Amount)
+
+		result := new(big.Int)
+		bigval.Int(result) // store converted number in result
+
+		delegation := blockatlas.Delegation{
+			Delegator: validator,
+			Value:     result.String(), // 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("1000"),
+		LockTime:      lockTime,
+		Type:          blockatlas.DelegationTypeDelegate,
+	}
+}
+
+func normalizeValidator(v Validator, apr float64) (validator blockatlas.Validator) {
+	return blockatlas.Validator{
+		Status:  v.Active,
+		ID:      v.Info.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/platform.go b/platform/platform.go
index 7790401..b85fe7b 100644
--- a/platform/platform.go
+++ b/platform/platform.go
@@ -12,6 +12,7 @@ import (
 	"github.com/trustwallet/blockatlas/platform/binance"
 	"github.com/trustwallet/blockatlas/platform/bitcoin"
 	"github.com/trustwallet/blockatlas/platform/cosmos"
+	"github.com/trustwallet/blockatlas/platform/elrond"
 	"github.com/trustwallet/blockatlas/platform/ethereum"
 	"github.com/trustwallet/blockatlas/platform/fio"
 	"github.com/trustwallet/blockatlas/platform/harmony"
@@ -106,6 +107,7 @@ func getAllHandlers() blockatlas.Platforms {
 		coin.Tomochain().Handle:    ethereum.Init(coin.TOMO, GetApiVar(coin.TOMO), GetRpcVar(coin.TOMO)),
 		coin.Ethereum().Handle:     ethereum.InitWitCollection(coin.ETH, GetApiVar(coin.ETH), GetRpcVar(coin.ETH), GetVar("ethereum.blockbook_api"), GetVar("ethereum.collections_api"), GetVar("ethereum.collections_api_key")),
 		coin.Near().Handle:         near.Init(GetApiVar(coin.NEAR)),
+		coin.Elrond().Handle:       elrond.Init(coin.ERD, GetApiVar(coin.ERD)),
 	}
 }
 


Test patch:
diff --git a/pkg/numbers/amount_test.go b/pkg/numbers/amount_test.go
index 5b95462..8db856b 100644
--- a/pkg/numbers/amount_test.go
+++ b/pkg/numbers/amount_test.go
@@ -33,7 +33,7 @@ func Test_addAmount(t *testing.T) {
 	}
 }
 
-func Test_decimalToSatoshis(t *testing.T) {
+func Test_ToSatoshi(t *testing.T) {
 	tests := []struct {
 		name   string
 		amount string
@@ -47,7 +47,7 @@ func Test_decimalToSatoshis(t *testing.T) {
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 			if got := ToSatoshi(tt.amount); got != tt.want {
-				t.Errorf("decimalToSatoshis() = %v, want %v", got, tt.want)
+				t.Errorf("ToSatoshi() = %v, want %v", got, tt.want)
 			}
 		})
 	}
diff --git a/pkg/numbers/decimal_test.go b/pkg/numbers/decimal_test.go
index 2cfcc29..89c3c37 100644
--- a/pkg/numbers/decimal_test.go
+++ b/pkg/numbers/decimal_test.go
@@ -9,14 +9,35 @@ func TestDecimalToSatoshis(t *testing.T) {
 			t.Error(err)
 		}
 		if expected != actual {
-			t.Errorf("expected: %s, got %s", expected, actual)
+			t.Errorf("expected %s, got %s, input %s", expected, actual, input)
+		}
+	}
+
+	assertSatError := func(input string) {
+		actual, err := DecimalToSatoshis(input)
+		if err == nil {
+			t.Errorf("Expected error but no error: got %s, input %s", actual, input)
 		}
 	}
 
 	assertSatEquals("10", "1.0")
 	assertSatEquals("1", "0.1")
 	assertSatEquals("13602", "136.02")
+	assertSatEquals("13602", "0136.02")
 	assertSatEquals("1500000", "0.01500000")
+	assertSatEquals("0", "0")
+	assertSatEquals("2030", "0.002030")
+	assertSatEquals("101010", "0101010")
+	assertSatEquals("11001100", "0011001100")
+	assertSatEquals("376", " 376")
+	assertSatEquals("376", "376 ")
+	
+	assertSatError("12NotNumber34")
+	assertSatError("12,34")
+	assertSatError("")
+	assertSatError(" ")
+	assertSatError("37 6")
+	assertSatError("37,6")
 }
 
 func TestDecimalExp(t *testing.T) {
diff --git a/platform/elrond/transaction_test.go b/platform/elrond/transaction_test.go
new file mode 100644
index 0000000..04c11a2
--- /dev/null
+++ b/platform/elrond/transaction_test.go
@@ -0,0 +1,169 @@
+package elrond
+
+import (
+	"encoding/json"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+	"github.com/trustwallet/blockatlas/coin"
+	"github.com/trustwallet/blockatlas/pkg/blockatlas"
+)
+
+const userAddress = `erd10yagg2vme2jns9zqf9xn8kl86fkc6dr063vnuj0mz2kk2jw0qwuqmfmaw0`
+
+const txTransferSrc1 = `
+{
+	"hash":"30d404cc7a42b0158b95f6adfbf9a517627d60f6c7e497c1442dfdb6460285df",
+	"nonce":0,
+	"round":35462,
+	"value":"82516976060558456822",
+	"receiver":"erd10yagg2vme2jns9zqf9xn8kl86fkc6dr063vnuj0mz2kk2jw0qwuqmfmaw0",
+	"sender":"4294967295",
+	"data":"ok",
+	"signature":"",
+	"timestamp":1587715632,
+	"status":"Success",
+	"fee": "1000"
+}`
+
+const txTransferSrc2 = `
+{
+	"hash":"30d404cc7a42b0158b95f6adfbf9a517627d60f6c7e497c1442dfdb6460285df",
+	"nonce":1,
+	"round":100,
+	"value":"2000",
+	"receiver":"erd10yagg2vme2jns9zqf9xn8kl86fkc6dr063vnuj0mz2kk2jw0qwuqmfmaw0",
+	"sender":"erd10yagg2vme2jns9zqf9xn8kl86fkc6dr063vnuj0mz2kk2jw0qwuqmfmaw0",
+	"data":"money",
+	"signature":"",
+	"timestamp":1588757256,
+	"status":"Pending",
+	"fee": "1500"
+}`
+
+const txTransferSrc3 = `
+{
+	"hash":"30d404cc7a42b0158b95f6adfbf9a517627d60f6c7e497c1442dfdb6460285df",
+	"nonce":19,
+	"round":200,
+	"value":"2",
+	"receiver":"erd1v0ce6rapup6rwma5sltyv05xhp33u543nex75a7j39vsz9m6squq6mxm7y",
+	"sender":"erd10yagg2vme2jns9zqf9xn8kl86fkc6dr063vnuj0mz2kk2jw0qwuqmfmaw0",
+	"data":"bla bla bla",
+	"signature":"",
+	"timestamp":1588757256,
+	"status":"Not executed",
+	"fee": "5000"
+}`
+
+var txTransfer1Normalized = blockatlas.Tx{
+	ID:       "30d404cc7a42b0158b95f6adfbf9a517627d60f6c7e497c1442dfdb6460285df",
+	Coin:     coin.ERD,
+	Date:     int64(1587715632),
+	From:     "metachain",
+	To:       "erd10yagg2vme2jns9zqf9xn8kl86fkc6dr063vnuj0mz2kk2jw0qwuqmfmaw0",
+	Fee:      "1000",
+	Status:   blockatlas.StatusCompleted,
+	Memo:     "ok",
+	Sequence: 0,
+	Meta: blockatlas.Transfer{
+		Value:    "82516976060558456822",
+		Symbol:   coin.Elrond().Symbol,
+		Decimals: coin.Elrond().Decimals,
+	},
+	Direction: blockatlas.DirectionOutgoing,
+}
+
+var txTransfer2Normalized = blockatlas.Tx{
+	ID:       "30d404cc7a42b0158b95f6adfbf9a517627d60f6c7e497c1442dfdb6460285df",
+	Coin:     coin.ERD,
+	Date:     int64(1588757256),
+	From:     "erd10yagg2vme2jns9zqf9xn8kl86fkc6dr063vnuj0mz2kk2jw0qwuqmfmaw0",
+	To:       "erd10yagg2vme2jns9zqf9xn8kl86fkc6dr063vnuj0mz2kk2jw0qwuqmfmaw0",
+	Fee:      "1500",
+	Status:   blockatlas.StatusPending,
+	Memo:     "money",
+	Sequence: 1,
+	Meta: blockatlas.Transfer{
+		Value:    "2000",
+		Symbol:   coin.Elrond().Symbol,
+		Decimals: coin.Elrond().Decimals,
+	},
+	Direction: blockatlas.DirectionSelf,
+}
+
+var txTransfer3Normalized = blockatlas.Tx{
+	ID:       "30d404cc7a42b0158b95f6adfbf9a517627d60f6c7e497c1442dfdb6460285df",
+	Coin:     coin.ERD,
+	Date:     int64(1588757256),
+	From:     "erd10yagg2vme2jns9zqf9xn8kl86fkc6dr063vnuj0mz2kk2jw0qwuqmfmaw0",
+	To:       "erd1v0ce6rapup6rwma5sltyv05xhp33u543nex75a7j39vsz9m6squq6mxm7y",
+	Fee:      "5000",
+	Status:   blockatlas.StatusError,
+	Memo:     "bla bla bla",
+	Sequence: 19,
+	Meta: blockatlas.Transfer{
+		Value:    "2",
+		Symbol:   coin.Elrond().Symbol,
+		Decimals: coin.Elrond().Decimals,
+	},
+	Direction: blockatlas.DirectionOutgoing,
+}
+
+type test struct {
+	name        string
+	apiResponse string
+	expected    *blockatlas.Tx
+}
+
+func TestNormalize(t *testing.T) {
+	testNormalize(t, &test{
+		name:        "transferSuccess",
+		apiResponse: txTransferSrc1,
+		expected:    &txTransfer1Normalized,
+	})
+
+	testNormalize(t, &test{
+		name:        "transferPending",
+		apiResponse: txTransferSrc2,
+		expected:    &txTransfer2Normalized,
+	})
+
+	testNormalize(t, &test{
+		name:        "transferNotExecuted",
+		apiResponse: txTransferSrc3,
+		expected:    &txTransfer3Normalized,
+	})
+}
+
+func TestNormalizeTxs(t *testing.T) {
+	var tx1, tx2, tx3 Transaction
+
+	_ = json.Unmarshal([]byte(txTransferSrc1), &tx1)
+	_ = json.Unmarshal([]byte(txTransferSrc1), &tx2)
+	_ = json.Unmarshal([]byte(txTransferSrc1), &tx3)
+
+	txs := []Transaction{tx1, tx2, tx3}
+	normalizedTxs := NormalizeTxs(txs, userAddress)
+	require.Equal(t, len(txs), len(normalizedTxs))
+}
+
+func testNormalize(t *testing.T, _test *test) {
+	var tx Transaction
+	err := json.Unmarshal([]byte(_test.apiResponse), &tx)
+	if err != nil {
+		t.Error(err)
+		return
+	}
+
+	normalizedTx, ok := NormalizeTx(tx, tx.Sender)
+	require.True(t, ok, _test.name+": cannot normalize tx")
+
+	resJSON, err := json.Marshal(&normalizedTx)
+	require.Nil(t, err)
+
+	dstJSON, err := json.Marshal(&_test.expected)
+	require.Nil(t, err)
+
+	require.Equal(t, string(dstJSON), string(resJSON))
+}
diff --git a/platform/harmony/stake_test.go b/platform/harmony/stake_test.go
new file mode 100644
index 0000000..202672b
--- /dev/null
+++ b/platform/harmony/stake_test.go
@@ -0,0 +1,257 @@
+package harmony
+
+import (
+	"encoding/json"
+	"github.com/stretchr/testify/assert"
+	"github.com/trustwallet/blockatlas/pkg/blockatlas"
+	"strconv"
+	"testing"
+)
+
+const validatorSrc = `
+ {
+	"validator": {
+		"bls-public-keys": [
+			"29fb5d202e2f6f7955b425dc706fc0b29047067e51ba583fbb017c0f51186d5e1eaf6dd4059848be311ab5a49d625309"
+		],
+		"last-epoch-in-committee": 18,
+		"min-self-delegation": 10999000000000000000000,
+		"max-total-delegation": 100000000000000000000000000,
+		"rate": "0.100000000000000000",
+		"max-rate": "0.100000000000000000",
+		"max-change-rate": "0.100000000000000000",
+		"update-height": 88,
+		"name": "sieemma node",
+		"identity": "sieemma node by ankr",
+		"website": "www.ankr.com",
+		"security-contact": "info@ankr.com",
+		"details": "This validator is launched from app.ankr.com",
+		"creation-height": 88,
+		"address": "one1v8pukmelacy3xdap773rpg5pax3tmu40wmwr2j",
+		"delegations": [
+			{
+				"delegator-address": "one1v8pukmelacy3xdap773rpg5pax3tmu40wmwr2j",
+				"amount": 10999000000000000000000,
+				"reward": 2328233463148225437028,
+				"undelegations": []
+			}
+		]
+	},
+	"current-epoch-performance": {
+		"current-epoch-signing-percent": {
+			"current-epoch-signed": 3,
+			"current-epoch-to-sign": 3,
+			"num-beacon-blocks-until-next-epoch": 37,
+			"current-epoch-signing-percentage": "1.000000000000000000"
+		}
+	},
+	"metrics": {
+		"by-bls-key": [
+			{
+				"key": {
+					"bls-public-key": "29fb5d202e2f6f7955b425dc706fc0b29047067e51ba583fbb017c0f51186d5e1eaf6dd4059848be311ab5a49d625309",
+					"group-percent": "0.056856187290969900",
+					"effective-stake": "85000000000000000000000.000000000000000000",
+					"earning-account": "one1v8pukmelacy3xdap773rpg5pax3tmu40wmwr2j",
+					"overall-percent": "0.018193979933110368",
+					"shard-id": 1
+				},
+				"earned-reward": 4478494623655913952
+			}
+		]
+	},
+	"total-delegation": 10999000000000000000000,
+	"currently-in-committee": true,
+	"epos-status": "currently elected",
+	"epos-winning-stake": "85000000000000000000000.000000000000000000",
+	"booted-status": null,
+	"lifetime": {
+		"reward-accumulated": 2328233463148225437028,
+		"blocks": {
+			"to-sign": 525,
+			"signed": 504
+		},
+		"apr": "12.345"
+	}
+}`
+
+const delegationsSrc = `
+[
+	{
+		"validator_address": "one1pdv9lrdwl0rg5vglh4xtyrv3wjk3wsqket7zxy",
+		"delegator_address": "one1pf75h0t4am90z8uv3y0dgunfqp4lj8wr3t5rsp",
+		"amount": 12345678900000000000,
+		"reward": 15854399877248931866418,
+		"Undelegations": []
+	}
+]`
+
+func TestNormalizeValidator(t *testing.T) {
+	var v Validator
+	_ = json.Unmarshal([]byte(validatorSrc), &v)
+	expected := blockatlas.Validator{
+		Status: v.Active,
+		ID:     v.Info.Address,
+		Details: blockatlas.StakingDetails{
+			Reward:        blockatlas.StakingReward{Annual: 12.345},
+			LockTime:      lockTime,
+			MinimumAmount: "1000",
+			Type:          blockatlas.DelegationTypeDelegate,
+		},
+	}
+
+	var apr float64
+	var err error
+	if apr, err = strconv.ParseFloat(v.Lifetime.Apr,64); err != nil {
+		apr = 0
+	}
+
+	result := normalizeValidator(v, apr)
+	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:     "12345678900000000000",
+			Status:    blockatlas.DelegationStatusActive,
+		},
+	}
+	result := NormalizeDelegations(delegations, validatorMap)
+	assert.Equal(t, expected, result)
+}
+
+func TestHexToInt(t *testing.T) {
+	result, _ := hexToInt("0x604800")
+
+	assert.Equal(t,uint64(6309888), result)
+}
+
+func TestGetDetails(t *testing.T) {
+	var expected = blockatlas.StakingDetails{
+			Reward:        blockatlas.StakingReward{Annual: 10},
+			LockTime:      lockTime,
+			MinimumAmount: "1000",
+			Type:          blockatlas.DelegationTypeDelegate,
+		}
+
+	result := getDetails(10)
+	assert.Equal(t,expected, result)
+}
+
+
+func TestGetValidators(t *testing.T) {
+	var c Client
+
+	p := Platform{
+		client: c,
+	}
+
+	var validators = []Validator{
+		Validator{
+			Info: ValidatorInfo{
+				Address: "one1pdv9lrdwl0rg5vglh4xtyrv3wjk3wsqket7zxy",
+			},
+			Active: true,
+			Lifetime: LifetimeInfo{Apr: "10"},
+		},
+	}
+
+	rpcCallStub = func (c *Client, result interface{}, method string, params interface{}) error {
+		jsonData, _ := json.Marshal(validators)
+		 _ = json.Unmarshal(jsonData, result)
+		return nil
+	}
+
+	result, _ := p.GetValidators()
+	assert.Equal(t, lockTime, result[0].Details.LockTime)
+	assert.Equal(t, float64(10), result[0].Details.Reward.Annual)
+}
+
+func TestGetDelegation(t *testing.T) {
+	var c Client
+
+	p := Platform{
+		client: c,
+	}
+
+	var delegations = []Delegation{
+		Delegation{
+			DelegatorAddress: "one1a0au0p33zrns49h3qw7prn02s4wphu0ggcqrhm",
+			ValidatorAddress: "one1a0au0p33zrns49h3qw7prn02s4wphu0ggcqrhm",
+			Amount:           100,
+		},
+	}
+
+	var validators = []Validator{
+		Validator{
+			Info: ValidatorInfo{
+				Address: "one1a0au0p33zrns49h3qw7prn02s4wphu0ggcqrhm",
+			},
+			Active: true,
+			Lifetime: LifetimeInfo{Apr: "10"},
+		},
+	}
+
+	rpcCallStub = func (c *Client, result interface{}, method string, params interface{}) error {
+		if (method == "hmy_getAllValidatorInformation") {
+			jsonData, _ := json.Marshal(validators)
+			_ = json.Unmarshal(jsonData, result)
+		} else {
+			jsonData, _ := json.Marshal(delegations)
+			_ = json.Unmarshal(jsonData, result)
+		}
+		return nil
+	}
+
+	result, _ := p.GetDelegations("one1pdv9lrdwl0rg5vglh4xtyrv3wjk3wsqket7zxy")
+	assert.Equal(t, delegations[0].DelegatorAddress, result[0].Delegator.ID)
+}
+
+
+func TestGeBalance(t *testing.T) {
+	var c Client
+
+	p := Platform{
+		client: c,
+	}
+
+	var balance = "0x100"
+
+	rpcCallStub = func (c *Client, result interface{}, method string, params interface{}) error {
+		jsonData, _ := json.Marshal(balance)
+		_ = json.Unmarshal(jsonData, result)
+		return nil
+	}
+
+	result, _ := p.UndelegatedBalance("one1pdv9lrdwl0rg5vglh4xtyrv3wjk3wsqket7zxy")
+	assert.Equal(t, "256",result)
+}
diff --git a/platform/nimiq/transaction_test.go b/platform/nimiq/transaction_test.go
index 8c0a5f0..f936434 100644
--- a/platform/nimiq/transaction_test.go
+++ b/platform/nimiq/transaction_test.go
@@ -6,6 +6,7 @@ import (
 	"github.com/trustwallet/blockatlas/coin"
 	"github.com/trustwallet/blockatlas/pkg/blockatlas"
 	"io/ioutil"
+	"math"
 	"path/filepath"
 	"runtime"
 	"sort"
@@ -68,7 +69,7 @@ var (
 		From:  "NQ74 SJ0Q 49T1 4XQL KABH 1RUC 8DPT 9F0U 9P0B",
 		To:    "NQ97 18BJ 33YV QGHQ BV2K 56V4 12CH J8TD S9S3",
 		Fee:   "300",
-		Date:  time.Now().Unix(),
+		Date:  666666, // special placholder value
 		Block: 0,
 		Meta: blockatlas.Transfer{
 			Value:    "100000",
@@ -79,6 +80,7 @@ var (
 )
 
 func TestNormalizeTx1(t *testing.T) {
+	now := time.Now().Unix()
 	tests := []struct {
 		name  string
 		srcTx string
@@ -96,6 +98,10 @@ func TestNormalizeTx1(t *testing.T) {
 				return
 			}
 			got := NormalizeTx(&srcTx)
+			// special handling for current date, if around now, replace with special value
+			if math.Abs(float64(got.Date - now)) < 30 {
+				got.Date = 666666
+			}
 			assert.Equal(t, tt.want, got)
 		})
 	}