diff --git a/internal/graphql/resolvers/estimated_rewards.go b/internal/graphql/resolvers/estimated_rewards.go new file mode 100644 index 00000000..f4cec04a --- /dev/null +++ b/internal/graphql/resolvers/estimated_rewards.go @@ -0,0 +1,164 @@ +package resolvers + +import ( + "fantom-api-graphql/internal/repository" + "fantom-api-graphql/internal/types" + "fmt" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "math/big" +) + +// constants used in reward calculations +const ( + erwSecondsInDay uint64 = 86400 + erwSecondsInWeek uint64 = 86400 * 7 + erwSecondsInYear uint64 = 31556926 + erwSecondsInMonth = erwSecondsInYear / 12 +) + +// EstimatedRewards represents resolvable estimated rewards structure. +type EstimatedRewards struct { + repo repository.Repository + Staked hexutil.Uint64 + TotalStaked hexutil.Big + LastEpoch types.Epoch +} + +// NewEstimatedRewards builds new resolvable estimated rewards structure. +func NewEstimatedRewards(ep *types.Epoch, amount *hexutil.Uint64, total *hexutil.Big, repo repository.Repository) EstimatedRewards { + return EstimatedRewards{ + repo: repo, + Staked: *amount, + TotalStaked: *total, + LastEpoch: *ep, + } +} + +// estimateRewardsByAddress instantiates the estimated rewards for specified address if possible. +func (rs *rootResolver) estimateRewardsByAddress(addr *common.Address, ep *types.Epoch, total *hexutil.Big) (EstimatedRewards, error) { + // try to get the address involved + acc, err := rs.repo.Account(addr) + if err != nil { + // log issue and return empty data + rs.log.Error("invalid address or address not found") + return EstimatedRewards{}, fmt.Errorf("address not found") + } + + // inform to debug + rs.log.Debugf("calculating rewards estimation for address [%s]", acc.Address.String()) + + // get the address balance + balance, err := rs.repo.AccountBalance(acc) + if err != nil { + // log issue and return empty data + rs.log.Errorf("can not get balance for address [%s]", acc.Address.String()) + return EstimatedRewards{}, fmt.Errorf("address balance not found") + } + + // get the value of the balance as Uint64 value + val := hexutil.Uint64(new(big.Int).Div(balance.ToInt(), new(big.Int).SetUint64(1000000000000000000)).Uint64()) + return NewEstimatedRewards(ep, &val, total, rs.repo), nil +} + +// EstimateRewards resolves reward estimation for the given address or amount staked. +func (rs *rootResolver) EstimateRewards(args *struct { + Address *common.Address + Amount *hexutil.Uint64 +}) (EstimatedRewards, error) { + // at least one of the parameters must be present + if args == nil || (args.Address == nil && args.Amount == nil) { + // log issue and return empty data + rs.log.Error("can not calculate estimated rewards without parameters") + return EstimatedRewards{}, fmt.Errorf("missing both address and amount") + } + + // get the latest sealed epoch + // the data could be delayed behind the real-time sealed epoch due to caching, + // but we don't need that precise reflection here + ep, err := rs.repo.CurrentSealedEpoch() + if err != nil { + // log issue and return empty data + rs.log.Errorf("can not get the current sealed epoch information; %s", err.Error()) + return EstimatedRewards{}, fmt.Errorf("current sealed epoch not found") + } + + // get the current total staked amount + total, err := rs.repo.TotalStaked() + if err != nil { + // log issue and return empty data + rs.log.Errorf("can not get the current total staked amount; %s", err.Error()) + return EstimatedRewards{}, fmt.Errorf("current total staked amount not found") + } + + // if address is specified, pull the estimation from it + if args.Address != nil { + return rs.estimateRewardsByAddress(args.Address, ep, total) + } + + // get the value directly from the provided amount + return NewEstimatedRewards(ep, args.Amount, total, rs.repo), nil +} + +// canCalculateRewards checks if the reward can actually be calculated +func (erw EstimatedRewards) canCalculateRewards() bool { + zero := new(big.Int) + return erw.Staked > 0 && + erw.LastEpoch.BaseRewardPerSecond.ToInt().Cmp(zero) > 0 && + erw.TotalStaked.ToInt().Cmp(zero) > 0 +} + +// getRewards calculates the rewards value for the given time period. +func (erw EstimatedRewards) getRewards(period uint64) hexutil.Big { + // validate that we can actually calculate the value + if !erw.canCalculateRewards() { + fmt.Printf("can not calculate!") + return hexutil.Big{} + } + + // prep values and calculate results + // (perSecond * period * stakedAmount) / totalStakedAmount + base := new(big.Int).Mul(erw.LastEpoch.BaseRewardPerSecond.ToInt(), new(big.Int).SetUint64(period)) + staked := new(big.Int).Mul(base, new(big.Int).SetUint64(uint64(erw.Staked))) + val := new(big.Int).Div(staked, erw.TotalStaked.ToInt()) + + // return the value + return hexutil.Big(*val) +} + +// DailyReward calculates daily rewards for the given rewards estimation. +func (erw EstimatedRewards) DailyReward() hexutil.Big { + return erw.getRewards(erwSecondsInDay) +} + +// WeeklyReward calculates daily rewards for the given rewards estimation. +func (erw EstimatedRewards) WeeklyReward() hexutil.Big { + return erw.getRewards(erwSecondsInWeek) +} + +// MonthlyReward calculates daily rewards for the given rewards estimation. +func (erw EstimatedRewards) MonthlyReward() hexutil.Big { + return erw.getRewards(erwSecondsInMonth) +} + +// YearlyReward calculates daily rewards for the given rewards estimation. +func (erw EstimatedRewards) YearlyReward() hexutil.Big { + return erw.getRewards(erwSecondsInYear) +} + +// CurrentRewardRateYearly calculates average reward rate +// for any staked amount in average per year +func (erw EstimatedRewards) CurrentRewardRateYearly() int32 { + // validate that we can actually calculate the value + if !erw.canCalculateRewards() { + return 0 + } + + // prep and calculate the rate + base := new(big.Int).Mul(erw.LastEpoch.BaseRewardPerSecond.ToInt(), new(big.Int).SetUint64(erwSecondsInYear)) + toPct := new(big.Int).Mul(base, new(big.Int).SetUint64(100)) + val := new(big.Int).Div(toPct, erw.TotalStaked.ToInt()) + + // get the value + return int32(val.Int64()) +} diff --git a/internal/graphql/resolvers/root.go b/internal/graphql/resolvers/root.go index e278f46b..ee20739c 100644 --- a/internal/graphql/resolvers/root.go +++ b/internal/graphql/resolvers/root.go @@ -84,6 +84,12 @@ type ApiResolver interface { // GasPrice resolves the current amount of WEI for single Gas. GasPrice() (hexutil.Uint64, error) + // EstimateRewards resolves reward estimation for the given address or amount staked. + EstimateRewards(*struct { + Address *common.Address + Amount *hexutil.Uint64 + }) (EstimatedRewards, error) + // SendTransaction sends raw signed and RLP encoded transaction to the block chain. SendTransaction(*struct{ Tx hexutil.Bytes }) (*Transaction, error) diff --git a/internal/graphql/schema/bundle.go b/internal/graphql/schema/bundle.go index efccef99..c654e60d 100644 --- a/internal/graphql/schema/bundle.go +++ b/internal/graphql/schema/bundle.go @@ -1,6 +1,6 @@ package gqlschema -// Auto generated GraphQL schema bundle; created 2020-04-16 18:54 +// Auto generated GraphQL schema bundle; created 2020-05-01 00:34 const schema = ` # StakerInfo represents extended staker information from smart contract. type StakerInfo { @@ -94,6 +94,44 @@ type Account { delegation: Delegator } +# EstimatedRewards represents a calculated rewards etimation for an account or amount staked +type EstimatedRewards { + "Amount of FTM tokens expected to be staked for the calculation." + staked: Long! + + "dailyReward represents amount of FTM tokens estimated to be rewarded for staked amount in average per day." + dailyReward: BigInt! + + "weeklyReward represents amount of FTM tokens estimated to be rewarded for staked amount in average per week." + weeklyReward: BigInt! + + "monthlyReward represents amount of FTM tokens estimated to be rewarded for staked amount in average per month." + monthlyReward: BigInt! + + "yearlyReward represents amount of FTM tokens estimated to be rewarded for staked amount in average per year." + yearlyReward: BigInt! + + """ + currentRewardYearRate represents average reward rate for any staked amount in average per year. + The value is calculated as linear gross proceeds for staked amount of tokens yearly. + """ + currentRewardRateYearly: Int! + + """ + Total amount of staked FTM tokens used for the calculation. + The estimation uses total staked amount, not the effective amount provided + by the last epoch. The effective amount does include current undelegations and also + skips offline self-stakings and flagged stakings. + """ + totalStaked: BigInt! + + """ + Information about the last sealed epoch of the Opera blockchain. + The epoch provides useful information about total FTM supply, + total amount staked, rewards rate and weight, fee, etc. + """ + lastEpoch: Epoch! +} # TransactionList is a list of transaction edges provided by sequential access request. type TransactionList { # Edges contains provided edges of the sequential list. @@ -480,6 +518,13 @@ type Query { "Get price details of the Opera blockchain token for the given target symbols." price(to:String!):Price! + + """ + Get calculated staking rewards for an account or given staking amount. + At least one of the address and amount parameters must be provided. + If you provide both, the address takes precedence and the amount is ignored. + """ + estimateRewards(address:Address, amount:Long):EstimatedRewards! } # Mutation andpoints for modifying the data diff --git a/internal/graphql/schema/definition/.graphqlconfig b/internal/graphql/schema/definition/.graphqlconfig index e394e15e..afd0f01a 100644 --- a/internal/graphql/schema/definition/.graphqlconfig +++ b/internal/graphql/schema/definition/.graphqlconfig @@ -6,7 +6,8 @@ "Local GraphQL Endpoint": { "url": "http://localhost:16761/api", "headers": { - "user-agent": "JS GraphQL" + "user-agent": "JS GraphQL", + "origin": "http://localhost" }, "introspect": false }, @@ -18,7 +19,14 @@ "introspect": false }, "Remote GraphQL Endpoint": { - "url": "https://fantom.rocks/fapi", + "url": "https://api.fantom.rocks/api", + "headers": { + "user-agent": "JS GraphQL" + }, + "introspect": false + }, + "Remote GraphQL Endpoint API2": { + "url": "https://api2.fantom.rocks/graphql", "headers": { "user-agent": "JS GraphQL" }, diff --git a/internal/graphql/schema/definition/schema.graphql b/internal/graphql/schema/definition/schema.graphql index 9d9dc6a9..953d5548 100644 --- a/internal/graphql/schema/definition/schema.graphql +++ b/internal/graphql/schema/definition/schema.graphql @@ -68,10 +68,11 @@ type Query { price(to:String!):Price! """ - Get calculated staking rewards for an account or given staking amount. + Get calculated staking rewards for an account or given staking amount in FTM tokens. At least one of the address and amount parameters must be provided. + If you provide both, the address takes precedence and the amount is ignored. """ - estimateRewards(address:Address, amount:BigInt!):EstimatedRewards! + estimateRewards(address:Address, amount:Long):EstimatedRewards! } # Mutation andpoints for modifying the data diff --git a/internal/graphql/schema/definition/types/estimated_rewards.graphql b/internal/graphql/schema/definition/types/estimated_rewards.graphql index 1ca4d4e1..43f6aaa7 100644 --- a/internal/graphql/schema/definition/types/estimated_rewards.graphql +++ b/internal/graphql/schema/definition/types/estimated_rewards.graphql @@ -1,14 +1,34 @@ # EstimatedRewards represents a calculated rewards etimation for an account or amount staked type EstimatedRewards { "Amount of FTM tokens expected to be staked for the calculation." - staked: BigInt! + staked: Long! - "monthlyReward represents amount of FTM tokens estimated to be rewarded in average for staked amount per month." + "dailyReward represents amount of FTM tokens estimated to be rewarded for staked amount in average per day." + dailyReward: BigInt! + + "weeklyReward represents amount of FTM tokens estimated to be rewarded for staked amount in average per week." + weeklyReward: BigInt! + + "monthlyReward represents amount of FTM tokens estimated to be rewarded for staked amount in average per month." monthlyReward: BigInt! - "yearlyReward represents amount of FTM tokens estimated to be rewarded in average for staked amount per year." + "yearlyReward represents amount of FTM tokens estimated to be rewarded for staked amount in average per year." yearlyReward: BigInt! + """ + currentRewardYearRate represents average reward rate for any staked amount in average per year. + The value is calculated as linear gross proceeds for staked amount of tokens yearly. + """ + currentRewardRateYearly: Int! + + """ + Total amount of staked FTM tokens used for the calculation in WEI units. + The estimation uses total staked amount, not the effective amount provided + by the last epoch. The effective amount does include current undelegations and also + skips offline self-stakings and flagged stakings. + """ + totalStaked: BigInt! + """ Information about the last sealed epoch of the Opera blockchain. The epoch provides useful information about total FTM supply, diff --git a/internal/repository/cache/epoch.go b/internal/repository/cache/epoch.go new file mode 100644 index 00000000..c6080729 --- /dev/null +++ b/internal/repository/cache/epoch.go @@ -0,0 +1,46 @@ +package cache + +import ( + "fantom-api-graphql/internal/types" + "fmt" +) + +// lastEpochCacheKey represents the in-memory cache key for the latest sealed Epoch data. +const lastEpochCacheKey = "last-sealed-epoch" + +// PullLastEpoch extracts information about the latest Epoch from the in-memory cache if available. +func (b *MemBridge) PullLastEpoch() *types.Epoch { + // try to get the Epoch data from the cache + data, err := b.cache.Get(lastEpochCacheKey) + if err != nil { + // cache returns ErrEntryNotFound if the key does not exist + return nil + } + + // do we have the data? + ep, err := types.UnmarshalEpoch(data) + if err != nil { + b.log.Criticalf("can not decode epoch data from in-memory cache; %s", err.Error()) + return nil + } + + return ep +} + +// PushLastEpoch stores provided latest sealed Epoch in the in-memory cache. +func (b *MemBridge) PushLastEpoch(ep *types.Epoch) error { + // we need valid account + if nil == ep { + return fmt.Errorf("undefined epoch can not be pushed to the in-memory cache") + } + + // encode account + data, err := ep.Marshal() + if err != nil { + b.log.Criticalf("can not marshal epoch to JSON; %s", err.Error()) + return err + } + + // set the data to cache by block number + return b.cache.Set(lastEpochCacheKey, data) +} diff --git a/internal/repository/cache/sti.go b/internal/repository/cache/sti.go index 896cbdeb..e4cd5565 100644 --- a/internal/repository/cache/sti.go +++ b/internal/repository/cache/sti.go @@ -3,13 +3,18 @@ package cache import ( "fantom-api-graphql/internal/types" + "fmt" "github.com/ethereum/go-ethereum/common/hexutil" + "math/big" "strings" ) // stiCacheKeyPrefix is the prefix used for cache key to store staker information. const stiCacheKeyPrefix = "staker_info_" +// stiTotalStakedKey is the cache key used to store total staked amount +const stiTotalStakedKey = "staked_total" + // PullStakerInfo extracts staker information from the in-memory cache if available. func (b *MemBridge) PullStakerInfo(id hexutil.Uint64) *types.StakerInfo { // try to get the account data from the cache @@ -52,3 +57,28 @@ func getStakerInfoKey(id hexutil.Uint64) string { return sb.String() } + +// PullTotalStaked extracts total staked amount from the in-memory cache if available. +func (b *MemBridge) PullTotalStaked() *hexutil.Big { + // try to get the account data from the cache + data, err := b.cache.Get(stiTotalStakedKey) + if err != nil { + return nil + } + + // do we have the data? + val := new(big.Int).SetBytes(data) + return (*hexutil.Big)(val) +} + +// PushTotalStaked stores provided total staked amount information in the in-memory cache. +func (b *MemBridge) PushTotalStaked(amount *hexutil.Big) error { + // we must have the value + if amount == nil { + b.log.Criticalf("can not store invalid amount") + return fmt.Errorf("amount not provided") + } + + // encode account + return b.cache.Set(stiTotalStakedKey, amount.ToInt().Bytes()) +} diff --git a/internal/repository/repository.go b/internal/repository/repository.go index 7f91527a..42bdad1a 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -63,6 +63,9 @@ type Repository interface { // CurrentEpoch returns the id of the current epoch. CurrentEpoch() (hexutil.Uint64, error) + // CurrentSealedEpoch returns the data of the latest sealed epoch. + CurrentSealedEpoch() (*types.Epoch, error) + // Epoch returns the id of the current epoch. Epoch(hexutil.Uint64) (types.Epoch, error) @@ -95,6 +98,9 @@ type Repository interface { // Staker extract a staker information by address. StakerByAddress(common.Address) (*types.Staker, error) + // TotalStaked calculates current total staked amount for all stakers. + TotalStaked() (*hexutil.Big, error) + // StakerInfo extracts an extended staker information from smart contact. PullStakerInfo(hexutil.Uint64) (*types.StakerInfo, error) diff --git a/internal/repository/sfc.go b/internal/repository/sfc.go index 5401d18c..6fab374c 100644 --- a/internal/repository/sfc.go +++ b/internal/repository/sfc.go @@ -12,6 +12,7 @@ import ( "fantom-api-graphql/internal/types" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" + "math/big" ) // CurrentEpoch returns the id of the current epoch. @@ -59,3 +60,89 @@ func (p *proxy) DelegationRewards(addr string) (types.PendingRewards, error) { p.log.Debugf("processing %s", addr) return p.rpc.DelegationRewards(addr) } + +// TotalStaked calculates current total staked amount for all stakers. +func (p *proxy) TotalStaked() (*hexutil.Big, error) { + // try cache first + value := p.cache.PullTotalStaked() + if value != nil { + p.log.Debugf("total staked amount loaded from memory cache") + return value, nil + } + + // get the top staker + topId, err := p.rpc.LastStakerId() + if err != nil { + p.log.Errorf("can not get the last staker; %s", err.Error()) + return nil, err + } + + // make new accumulator + total := new(big.Int) + + // go over all the validators + var id uint64 + for id = 1; id <= uint64(topId); id++ { + // get this validator + val, err := p.rpc.Staker(hexutil.Uint64(id)) + if err == nil && val.TotalStake != nil { + // advance the total sum + total = new(big.Int).Add(total, val.TotalStake.ToInt()) + } + } + + // store in cache + if err := p.cache.PushTotalStaked((*hexutil.Big)(total)); err != nil { + // log issue + p.log.Errorf("can not store total staked amount in memory; %s", err.Error()) + } + + // return the value + return (*hexutil.Big)(total), nil +} + +// CurrentSealedEpoch returns the data of the latest sealed epoch. +// This is used for reward estimation calculation and we don't need +// real time data, but rather faster response time. +// So, we use cache for handling the response. +// It will not be updated in sync with the SFC contract. +// If you need real time response, please use the Epoch(id) function instead. +func (p *proxy) CurrentSealedEpoch() (*types.Epoch, error) { + // inform what we do + p.log.Debug("latest sealed epoch requested") + + // try to use the in-memory cache + if ep := p.cache.PullLastEpoch(); ep != nil { + // inform what we do + p.log.Debug("latest sealed epoch loaded from cache") + + // return the block + return ep, nil + } + + // we need to go the slow path + id, err := p.rpc.CurrentSealedEpoch() + if err != nil { + // inform what we do + p.log.Errorf("can not get the id of the last sealed epoch; %s", err.Error()) + return nil, err + } + + // get the epoch from SFC + ep, err := p.rpc.Epoch(id) + if err != nil { + // inform what we do + p.log.Errorf("can not get data of the last sealed epoch; %s", err.Error()) + return nil, err + } + + // try to store the block in cache for future use + err = p.cache.PushLastEpoch(&ep) + if err != nil { + p.log.Error(err) + } + + // inform what we do + p.log.Debugf("epoch [%s] loaded from sfc", id.String()) + return &ep, nil +} diff --git a/internal/types/epoch.go b/internal/types/epoch.go index cc0bb86b..da7fbd83 100644 --- a/internal/types/epoch.go +++ b/internal/types/epoch.go @@ -2,6 +2,7 @@ package types import ( + "encoding/json" "github.com/ethereum/go-ethereum/common/hexutil" ) @@ -18,3 +19,15 @@ type Epoch struct { DelegationsTotalAmount hexutil.Big TotalSupply hexutil.Big } + +// UnmarshalEpoch parses the JSON-encoded Epoch data. +func UnmarshalEpoch(data []byte) (*Epoch, error) { + var ep Epoch + err := json.Unmarshal(data, &ep) + return &ep, err +} + +// Marshal returns the JSON encoding of Epoch. +func (e *Epoch) Marshal() ([]byte, error) { + return json.Marshal(e) +}