Repository: trustwallet/blockatlas
Pull request #962 (2020-03-11 20:15:35)

Add Initial Solana implementation
One note about our implementation: solana requires that stake accounts be created with more than the minimum balance for rent exemption, so that they can't be rent-collected out of existence. We have used this value as the basis of Validator.Details.MinimumAmount. However, only lamports above the minimum rent balance will be activated as stake. Not sure if there is a better way to encode/communicate this.

EnoRage: Great start! Can I ask you to: 
- [ ] Add public endpoints to config.yml if they are exists 
- [ ] Run go fmt


CriesofCarrots: >     * [x]  Add public endpoints to config.yml if they are exists
> 
>     * [x]  Run go fmt

EnoRage: Can you also add 
```
coin.Solana().Handle:       solana.Init(GetApiVar(coin.SOL)),
```
to the getPlatformMap() function at platform/platform.go

EnoRage: Can you please also ensure that current endpoints are working now:

```
http://localhost:8420/v2/solana/staking/validators
Current response: 

{"code":500,"error":{"error":"invalid character ':' after top-level value: unable to fetch validators list from the registry","meta":{"coin":"solana"}}}

http://localhost:8420/v2/solana/staking/delegations/boot1Z6jb15CLqpaMTn2CxktktwZpRAVAgHZEW6SxQ7

Current response: 

{"code":500,"error":{"error":"Post \"https://api.mainnet-beta.solana.com\": dial tcp 34.82.103.142:443: i/o timeout: Unable to fetch delegations list","meta":{}}}
```
Sorry for the delays, it will be better now


CriesofCarrots: @EnoRage , it looks to me like these failures are because we haven't updated the assets repo yet. Does that seem right? I created this PR today: https://github.com/trustwallet/assets/pull/1466, and will be populating list.json soon.
Is there any tooling to mock validator data in order to test the blockatlas implementation independently? 
kolya182: > @EnoRage , it looks to me like these failures are because we haven't updated the assets repo yet. Does that seem right? I created this PR today: [trustwallet/assets#1466](https://github.com/trustwallet/assets/pull/1466), and will be populating list.json soon.
> Is there any tooling to mock validator data in order to test the blockatlas implementation independently?

Merged
EnoRage: > @EnoRage , it looks to me like these failures are because we haven't updated the assets repo yet. Does that seem right? I created this PR today: [trustwallet/assets#1466](https://github.com/trustwallet/assets/pull/1466), and will be populating list.json soon.
> Is there any tooling to mock validator data in order to test the blockatlas implementation independently?

Not yet, currently it is an open issue for us, we will have an option to run and test everything with mocks soon

Patch:
diff --git a/coin/coins.go b/coin/coins.go
index a9a0ba4..6fa1f08 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-03-22 19:36:28.030503 -0700 PDT m=&#43;0.019507357
+// 2020-03-19 12:32:14.57052 -0600 MDT m=&#43;0.001450289
 // using data from coins.yml
 package coin
 
@@ -71,6 +71,7 @@ const (
 	DGB = 20
 	ONE = 1023
 	KSM = 434
+	SOL = 501
 	NEAR = 397
 )
 
@@ -535,6 +536,16 @@ var Coins = map[uint]Coin{
 		MinConfirmations: 0,
 		SampleAddr:       "HewiDTQv92L2bVtkziZC8ASxrFUxr6ajQ62RXAnwQ8FDVmg",
 	},
+	SOL: {
+		ID:               501,
+		Handle:           "solana",
+		Symbol:           "SOL",
+		Name:             "Solana",
+		Decimals:         9,
+		BlockTime:        500,
+		MinConfirmations: 0,
+		SampleAddr:       "boot1Z6jb15CLqpaMTn2CxktktwZpRAVAgHZEW6SxQ7",
+	},
 	NEAR: {
 		ID:               397,
 		Handle:           "near",
@@ -684,6 +695,9 @@ func Harmony() Coin {
 func Kusama() Coin {
 	return Coins[KSM]
 }
+func Solana() Coin {
+	return Coins[SOL]
+}
 func Near() Coin {
 	return Coins[NEAR]
 }
diff --git a/coin/coins.yml b/coin/coins.yml
index df23ee9..92a442f 100644
--- a/coin/coins.yml
+++ b/coin/coins.yml
@@ -364,6 +364,14 @@
   blockTime: 6000
   sampleAddress: 'HewiDTQv92L2bVtkziZC8ASxrFUxr6ajQ62RXAnwQ8FDVmg'
 
+- id: 501
+  symbol: SOL
+  handle: solana
+  name: Solana
+  decimals: 9
+  blockTime: 500
+  sampleAddress: 'boot1Z6jb15CLqpaMTn2CxktktwZpRAVAgHZEW6SxQ7'
+
 - id: 397
   symbol: NEAR
   handle: near
diff --git a/config.yml b/config.yml
index 462139f..3b281c3 100644
--- a/config.yml
+++ b/config.yml
@@ -212,5 +212,8 @@ kava:
 kusama:
   api: https://kusama.subscan.io/api
 
+solana:
+  api: https://api.mainnet-beta.solana.com
+
 near:
   api: https://staging-rpc.nearprotocol.com
diff --git a/platform/platform.go b/platform/platform.go
index ce97f1d..b19940f 100644
--- a/platform/platform.go
+++ b/platform/platform.go
@@ -24,6 +24,7 @@ import (
 	"github.com/trustwallet/blockatlas/platform/ontology"
 	"github.com/trustwallet/blockatlas/platform/polkadot"
 	"github.com/trustwallet/blockatlas/platform/ripple"
+	"github.com/trustwallet/blockatlas/platform/solana"
 	"github.com/trustwallet/blockatlas/platform/stellar"
 	"github.com/trustwallet/blockatlas/platform/tezos"
 	"github.com/trustwallet/blockatlas/platform/theta"
@@ -71,6 +72,7 @@ func getPlatformMap() blockatlas.Platforms {
 		coin.Ontology().Handle:     ontology.Init(GetApiVar(coin.ONT)),
 		coin.Algorand().Handle:     algorand.Init(GetApiVar(coin.ALGO)),
 		coin.Aeternity().Handle:    aeternity.Init(GetApiVar(coin.AE)),
+		coin.Solana().Handle:       solana.Init(GetApiVar(coin.SOL)),
 		coin.Tezos().Handle:        tezos.Init(GetApiVar(coin.XTZ), GetRpcVar(coin.XTZ)),
 		coin.Binance().Handle:      binance.Init(GetApiVar(coin.BNB), GetVar("binance.dex")),
 		coin.Zilliqa().Handle:      zilliqa.Init(GetApiVar(coin.ZIL), GetVar("zilliqa.key"), GetRpcVar(coin.ZIL), GetVar("zilliqa.lookup")),
diff --git a/platform/solana/base.go b/platform/solana/base.go
new file mode 100644
index 0000000..f14ad7a
--- /dev/null
+++ b/platform/solana/base.go
@@ -0,0 +1,22 @@
+package solana
+
+import (
+	"github.com/trustwallet/blockatlas/coin"
+	"github.com/trustwallet/blockatlas/pkg/blockatlas"
+)
+
+type Platform struct {
+	client Client
+}
+
+func Init(api string) *Platform {
+	p := &Platform{
+		client: Client{blockatlas.InitClient(api)},
+	}
+	p.client.Headers["Content-Type"] = "application/json"
+	return p
+}
+
+func (p *Platform) Coin() coin.Coin {
+	return coin.Coins[coin.SOL]
+}
diff --git a/platform/solana/client.go b/platform/solana/client.go
new file mode 100644
index 0000000..94c2bc6
--- /dev/null
+++ b/platform/solana/client.go
@@ -0,0 +1,38 @@
+package solana
+
+import (
+	"github.com/trustwallet/blockatlas/pkg/blockatlas"
+)
+
+const stakeProgramId = "Stake11111111111111111111111111111111111111"
+
+type Client struct {
+	blockatlas.Request
+}
+
+func (c *Client) GetCurrentVoteAccounts() (validators []VoteAccount, err error) {
+	var v VoteAccounts
+	err = c.RpcCall(&v, "getVoteAccounts", []string{})
+	return v.Current, err
+}
+
+func (c *Client) GetStakeAccounts() (accounts []KeyedAccount, err error) {
+	err = c.RpcCall(&accounts, "getProgramAccounts", []string{stakeProgramId})
+	return
+}
+
+func (c *Client) GetAccount(pubkey string) (account Account, err error) {
+	var r RpcAccount
+	err = c.RpcCall(&r, "getAccountInfo", []string{pubkey})
+	return r.Account, err
+}
+
+func (c *Client) GetEpochInfo() (epochInfo EpochInfo, err error) {
+	err = c.RpcCall(&epochInfo, "getEpochInfo", []string{})
+	return
+}
+
+func (c *Client) GetMinimumBalanceForRentExemption() (minimumBalance uint64, err error) {
+	err = c.RpcCall(&minimumBalance, "getMinimumBalanceForRentExemption", []uint64{4008})
+	return
+}
diff --git a/platform/solana/model.go b/platform/solana/model.go
new file mode 100644
index 0000000..bffac00
--- /dev/null
+++ b/platform/solana/model.go
@@ -0,0 +1,62 @@
+package solana
+
+type VoteAccount struct {
+	NodePubkey       string     `json:"nodePubkey"`
+	VotePubkey       string     `json:"votePubkey"`
+	Commission       uint64     `json:"commission"`
+	ActivatedStake   uint64     `json:"activatedStake"`
+	RootSlot         uint64     `json:"rootSlot"`
+	LastVote         uint64     `json:"lastVote"`
+	EpochCredits     [][]uint64 `json:"epochCredits"`
+	EpochVoteAccount bool       `json:"epochVoteAccount"`
+}
+
+type VoteAccounts struct {
+	Current    []VoteAccount `json:"current"`
+	Delinquent []VoteAccount `json:"delinquent"`
+}
+
+type Account struct {
+	Data       string `json:"data"`
+	Executable bool   `json:"executable"`
+	Lamports   uint64 `json:"lamports"`
+	Owner      string `json:"owner"`
+	RentEpoch  uint64 `json:"rentEpoch"`
+}
+
+type KeyedAccount struct {
+	Account Account `json:"account"`
+	Pubkey  string  `json:"pubkey"`
+}
+
+type RpcAccount struct {
+	Context RpcContext `json:"context"`
+	Account Account    `json:"value"`
+}
+
+type RpcContext struct {
+	Slot uint64 `json:"slot"`
+}
+
+type StakeState struct {
+	State                uint32
+	RentExemptReserve    uint64
+	AuthorizedStaker     [32]byte
+	AuthorizedWithdrawer [32]byte
+	UnixTimestamp        int64
+	LockupEpoch          uint64
+	Custodian            [32]byte
+	VoterPubkey          [32]byte
+	Stake                uint64
+	ActivationEpoch      uint64
+	DeactivationEpoch    uint64
+	WarmupCooldownRate   float64
+	CreditsObserved      uint64
+}
+
+type EpochInfo struct {
+	AbsoluteSlot uint64 `json:"absoluteSlot"`
+	Epoch        uint64 `json:"epoch"`
+	SlotIndex    uint64 `json:"slotIndex"`
+	SlotsInEpoch uint64 `json:"slotsInEpoch"`
+}
diff --git a/platform/solana/stake.go b/platform/solana/stake.go
new file mode 100644
index 0000000..c69de5f
--- /dev/null
+++ b/platform/solana/stake.go
@@ -0,0 +1,138 @@
+package solana
+
+import (
+	"bytes"
+	"encoding/binary"
+	"github.com/btcsuite/btcutil/base58"
+	"github.com/trustwallet/blockatlas/pkg/blockatlas"
+	"github.com/trustwallet/blockatlas/pkg/errors"
+	services "github.com/trustwallet/blockatlas/services/assets"
+	"strconv"
+)
+
+func arrayOfPubkey(pubkey string) [32]byte {
+	var array [32]byte
+	copy(array[:], base58.Decode(pubkey))
+	return array
+}
+
+func (p *Platform) GetValidators() (blockatlas.ValidatorPage, error) {
+	results := make(blockatlas.ValidatorPage, 0)
+
+	validators, err := p.client.GetCurrentVoteAccounts()
+	if err != nil {
+		return results, err
+	}
+
+	minimumBalance, err := p.client.GetMinimumBalanceForRentExemption()
+	if err != nil {
+		minimumBalance = 0
+	}
+
+	for _, v := range validators {
+		results = append(results, normalizeValidator(v, minimumBalance))
+	}
+	return results, nil
+}
+
+func (p *Platform) GetDetails() blockatlas.StakingDetails {
+	return getDetails()
+}
+
+func getDetails() blockatlas.StakingDetails {
+	return blockatlas.StakingDetails{
+		Reward:        blockatlas.StakingReward{Annual: 0},
+		MinimumAmount: "0",
+		LockTime:      0,
+		Type:          blockatlas.DelegationTypeDelegate,
+	}
+}
+
+func normalizeValidator(v VoteAccount, minimumBalance uint64) (validator blockatlas.Validator) {
+	minimumAmount := strconv.FormatUint(minimumBalance+1, 10) // Must stake at least 1 lamport
+	return blockatlas.Validator{
+		Status: true,
+		ID:     v.VotePubkey,
+		Details: blockatlas.StakingDetails{
+			Reward:        blockatlas.StakingReward{Annual: 0},
+			MinimumAmount: blockatlas.Amount(minimumAmount),
+			LockTime:      0,
+			Type:          blockatlas.DelegationTypeDelegate,
+		},
+	}
+}
+
+func (p *Platform) GetDelegations(address string) (blockatlas.DelegationsPage, error) {
+	accounts, err := p.client.GetStakeAccounts()
+	if err != nil {
+		return nil, err
+	}
+
+	stakeAccounts := make([]StakeState, 0)
+	for _, keyedAccount := range accounts {
+		account, err := parseStakeData(keyedAccount.Account)
+		if err != nil {
+			return nil, err
+		}
+		if isAuthorized(account, address) {
+			stakeAccounts = append(stakeAccounts, account)
+		}
+	}
+	if len(stakeAccounts) == 0 {
+		return make(blockatlas.DelegationsPage, 0), nil
+	}
+
+	validators, err := services.GetValidatorsMap(p)
+	if err != nil {
+		return nil, err
+	}
+
+	epochInfo, err := p.client.GetEpochInfo()
+	if err != nil {
+		return nil, err
+	}
+
+	return NormalizeDelegations(stakeAccounts, validators, epochInfo)
+}
+
+func parseStakeData(account Account) (stakeAccount StakeState, err error) {
+	buffer := base58.Decode(account.Data)
+	r := bytes.NewReader(buffer)
+	err = binary.Read(r, binary.LittleEndian, &stakeAccount)
+	return
+}
+
+func isAuthorized(stakeAccount StakeState, address string) bool {
+	return stakeAccount.AuthorizedStaker == arrayOfPubkey(address)
+}
+
+func (p *Platform) UndelegatedBalance(address string) (string, error) {
+	account, err := p.client.GetAccount(address)
+	if err != nil {
+		return "0", err
+	}
+	return strconv.FormatUint(account.Lamports, 10), nil
+}
+
+func NormalizeDelegations(stakeAccounts []StakeState, validators blockatlas.ValidatorMap, epochInfo EpochInfo) (blockatlas.DelegationsPage, error) {
+	results := make([]blockatlas.Delegation, 0)
+	for _, stakeState := range stakeAccounts {
+		votePubkey := base58.Encode(stakeState.VoterPubkey[:])
+		validator, ok := validators[votePubkey]
+		if !ok {
+			return nil, errors.E("Validator not found",
+				errors.Params{"Validator": votePubkey, "Platform": "solana"})
+		}
+		status := blockatlas.DelegationStatusPending
+		if stakeState.ActivationEpoch > 0 && stakeState.ActivationEpoch <= epochInfo.Epoch {
+			status = blockatlas.DelegationStatusActive
+		}
+		delegation := blockatlas.Delegation{
+			Delegator: validator,
+			Value:     strconv.FormatUint(stakeState.Stake, 10),
+			Status:    status,
+		}
+		results = append(results, delegation)
+	}
+	return results, nil
+}
diff --git a/platform/solana/transaction.go b/platform/solana/transaction.go
new file mode 100644
index 0000000..8ded470
--- /dev/null
+++ b/platform/solana/transaction.go
@@ -0,0 +1,10 @@
+package solana
+
+import (
+	"github.com/trustwallet/blockatlas/pkg/blockatlas"
+)
+
+func (p *Platform) GetTxsByAddress(address string) (blockatlas.TxPage, error) {
+	results := make(blockatlas.TxPage, 0)
+	return results, nil
+}


Test patch:
diff --git a/platform/solana/stake_test.go b/platform/solana/stake_test.go
new file mode 100644
index 0000000..4817d23
--- /dev/null
+++ b/platform/solana/stake_test.go
@@ -0,0 +1,151 @@
+package solana
+
+import (
+	"encoding/json"
+	"github.com/trustwallet/blockatlas/pkg/blockatlas"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+const currentValidators = `
+[
+  {
+    "activatedStake": 3733867423940,
+    "commission": 100,
+    "epochCredits": [
+      [70,524224,516032],[71,532416,524224],[72,540608,532416],[73,548800,540608],[74,556992,548800],[75,565184,556992],[76,573376,565184],[77,581568,573376],[78,589760,581568],[79,597952,589760],[80,601055,597952]
+    ],
+    "epochVoteAccount": true,
+    "lastVote": 601085,
+    "nodePubkey": "boot1Z6jb15CLqpaMTn2CxktktwZpRAVAgHZEW6SxQ7",
+    "rootSlot": 601054,
+    "votePubkey": "2Afu38M1KaSfDBpjZjnJb9BSWP6YkBkoPiBfnFedD7JW"
+  },
+  {
+    "activatedStake": 10540011934,
+    "commission": 100 ,
+    "epochCredits": [
+      [78,4760,0],[79,12952,4760],[80,16055,12952]
+    ],
+    "epochVoteAccount": true,
+    "lastVote": 601085,
+    "nodePubkey": "B52Da5MCyTcyVJEsR9RUnbf715YuBAJMxCEEPzyZXgvY",
+    "rootSlot": 601054,
+    "votePubkey": "5CgQubGD1uwodwCe5UXDADbC69SiqXR8qq6pDMSm7ut5"
+  }
+]`
+
+var expectedValidators = []blockatlas.Validator{
+	blockatlas.Validator{
+		Status: true,
+		ID:     "2Afu38M1KaSfDBpjZjnJb9BSWP6YkBkoPiBfnFedD7JW",
+		Details: blockatlas.StakingDetails{
+			Reward:        blockatlas.StakingReward{Annual: 0},
+			MinimumAmount: blockatlas.Amount("2282881"),
+			LockTime:      0,
+			Type:          blockatlas.DelegationTypeDelegate,
+		},
+	},
+	blockatlas.Validator{
+		Status: true,
+		ID:     "5CgQubGD1uwodwCe5UXDADbC69SiqXR8qq6pDMSm7ut5",
+		Details: blockatlas.StakingDetails{
+			Reward:        blockatlas.StakingReward{Annual: 0},
+			MinimumAmount: blockatlas.Amount("2282881"),
+			LockTime:      0,
+			Type:          blockatlas.DelegationTypeDelegate,
+		},
+	},
+}
+
+func TestNormalizeValidator(t *testing.T) {
+	var validators []VoteAccount
+	minimumBalanceForRentExemption := uint64(2282880) // Arbitrary value; minimumBalanceForRentExemption is calculated from account data size
+	err := json.Unmarshal([]byte(currentValidators), &validators)
+	assert.Nil(t, err)
+	for i, v := range validators {
+		result := normalizeValidator(v, minimumBalanceForRentExemption)
+		assert.Equal(t, expectedValidators[i], result)
+	}
+}
+
+var keyedStakeAccount = KeyedAccount{
+	Account: Account{
+		Data:       "mrWMHx6j3BkmepJy67XLycC7LVeiq2NBESfV2YNmZvY62xT5jTgKnMzRBQheYtAuajncAniTEmU8QxgkpnytnXynTrMSJN4p6ihefU5cobkyCeSwMKugKGuBbyDLjQoUMu6BUKjDTFvjpJUHFCgz1Vaa8HSVscUqqRcioByf3owMUwHmEYsF8vuouLAqEQmo61wFkKfZELxLrhBbi2PQQZucryrnNDKXV4DY3oegLy9aMnMDZUeoDtDPPiJeM2F1Trh8ZkH1sQL6sQ5V",
+		Executable: false,
+		Lamports:   100000000000,
+		Owner:      "Stake11111111111111111111111111111111111111",
+		RentEpoch:  80,
+	},
+	Pubkey: "EgR17fgGmwQQaMZPsuJdk9oHw2xY8TJQj3Bp44o24mar",
+}
+
+var stakeState = StakeState{
+	State:                2,
+	RentExemptReserve:    2282880,
+	AuthorizedStaker:     arrayOfPubkey("B52Da5MCyTcyVJEsR9RUnbf715YuBAJMxCEEPzyZXgvY"),
+	AuthorizedWithdrawer: arrayOfPubkey("B52Da5MCyTcyVJEsR9RUnbf715YuBAJMxCEEPzyZXgvY"),
+	UnixTimestamp:        0,
+	LockupEpoch:          0,
+	Custodian:            arrayOfPubkey("11111111111111111111111111111111"),
+	VoterPubkey:          arrayOfPubkey("5CgQubGD1uwodwCe5UXDADbC69SiqXR8qq6pDMSm7ut5"),
+	Stake:                99997717120,
+	ActivationEpoch:      79,
+	DeactivationEpoch:    ^uint64(0),
+	WarmupCooldownRate:   0.25,
+	CreditsObserved:      21143,
+}
+
+func TestParseStakeData(t *testing.T) {
+
+	result, err := parseStakeData(keyedStakeAccount.Account)
+	assert.Nil(t, err)
+	assert.Equal(t, result, stakeState)
+}
+
+func TestIsAuthorized(t *testing.T) {
+	address := "B52Da5MCyTcyVJEsR9RUnbf715YuBAJMxCEEPzyZXgvY"
+	account, _ := parseStakeData(keyedStakeAccount.Account)
+	assert.True(t, isAuthorized(account, address))
+
+	address = "BADaddresscyVJEsR9RUnbf715YuBAJMxCEEPzyZXgvY"
+	assert.False(t, isAuthorized(account, address))
+}
+
+var stakeValidator = blockatlas.StakeValidator{
+	ID:     "5CgQubGD1uwodwCe5UXDADbC69SiqXR8qq6pDMSm7ut5",
+	Status: true,
+	Info: blockatlas.StakeValidatorInfo{
+		Name:        "Certus One",
+		Description: "Stake and earn rewards with the most secure and stable validator. Winner of the Game of Stakes. Operated by Certus One Inc. By delegating, you confirm that you are aware of the risk of slashing and that Certus One Inc is not liable for any potential damages to your investment.",
+		Image:       "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/solana/validators/assets/2Afu38M1KaSfDBpjZjnJb9BSWP6YkBkoPiBfnFedD7JW/logo.png",
+		Website:     "https://certus.one",
+	},
+	Details: getDetails(),
+}
+
+var validatorMap = blockatlas.ValidatorMap{
+	"5CgQubGD1uwodwCe5UXDADbC69SiqXR8qq6pDMSm7ut5": stakeValidator,
+}
+
+var epochInfo = EpochInfo{
+	AbsoluteSlot: 165763,
+	Epoch:        80,
+	SlotIndex:    1923,
+	SlotsInEpoch: 2048,
+}
+
+var delegation = blockatlas.DelegationsPage{
+	{
+		Delegator: stakeValidator,
+		Value:     "99997717120",
+		Status:    blockatlas.DelegationStatusActive,
+	},
+}
+
+func TestNormalizeDelegations(t *testing.T) {
+	result, err := NormalizeDelegations([]StakeState{stakeState}, validatorMap, epochInfo)
+	assert.NoError(t, err)
+	assert.Equal(t, delegation, result)
+}