solana-staking-csvs-0.2.0.0: src/Console/SolanaStaking/Api.hs
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-}
{- | Solana Beach API requests & responses.
TODO: Extract into a @solana-beach-api@ package.
-}
module Console.SolanaStaking.Api
( -- * Configuration
Config (..)
, mkConfig
-- * Requests / Responses
, APIResponse (..)
, APIError (..)
, runApi
, raiseAPIError
-- ** Get Stake Accounts
, getAccountStakes
, StakingAccounts (..)
, StakingAccount (..)
-- ** Get Staking Rewards
, getAllStakeRewards
, getYearsStakeRewards
, getStakeRewards
, StakeReward (..)
-- ** Get Block
, getBlock
, Block (..)
-- * General API Types
, Lamports (..)
, renderLamports
, scientificLamports
, StakingPubKey (..)
) where
import Control.Concurrent (threadDelay)
import Control.Exception (throwIO)
import Control.Monad ((>=>))
import Control.Monad.Catch (MonadCatch, try)
import Control.Monad.Except (MonadError (throwError), runExceptT)
import Control.Monad.Reader (MonadIO, MonadReader, asks, liftIO)
import Data.Aeson
( FromJSON (parseJSON)
, Value (Object)
, eitherDecode
, withObject
, (.:)
, (.:?)
)
import Data.Bifunctor (second)
import Data.Scientific (FPFormat (Fixed), Scientific, formatScientific)
import Data.Text.Encoding (encodeUtf8)
import Data.Time (toGregorian, utctDay)
import Data.Time.Clock.POSIX (POSIXTime, posixSecondsToUTCTime)
import GHC.Generics (Generic)
import Network.HTTP.Client
( HttpException (..)
, HttpExceptionContent (..)
, responseStatus
)
import Network.HTTP.Req
( GET (GET)
, HttpException (..)
, HttpResponse
, HttpResponseBody
, NoReqBody (NoReqBody)
, Option
, Scheme (Https)
, Url
, defaultHttpConfig
, header
, https
, jsonResponse
, queryParam
, renderUrl
, req
, responseBody
, runReq
, (/:)
, (/~)
)
import Network.HTTP.Types (statusCode)
import System.IO (hPutStrLn, stderr)
import Text.Read (readMaybe)
import Data.ByteString.Lazy qualified as LBS
import Data.Text qualified as T
-- | Solana Beach API Configuration
data Config = Config
{ cApiKey :: T.Text
-- ^ Your API Key.
-- Get one here: https://github.com/solana-beach/api
, cAccountPubKey :: T.Text
-- ^ TODO: probably drop this when solana-beach-api is extracted to
-- separate package.
}
deriving (Show, Read, Eq)
-- | Create a program config from the API key & the target account's
-- pubkey.
mkConfig :: String -> String -> Config
mkConfig (T.pack -> cApiKey) (T.pack -> cAccountPubKey) = Config {..}
-- | Base URL to Solana Beach's API
baseUrl :: Url 'Https
baseUrl = https "api.solanabeach.io" /: "v2"
-- | Get the staking accounts for the 'cAccountPubKey'.
getAccountStakes
:: (MonadReader Config m, MonadCatch m, MonadIO m)
=> m (APIResponse StakingAccounts)
getAccountStakes = do
pubkey <- asks cAccountPubKey
getReq (baseUrl /: "account" /~ pubkey /: "stake-accounts") mempty
-- | Single Result Page of Staking Accounts Query.
newtype StakingAccounts = StakingAccounts
{ saResults :: [StakingAccount]
-- ^ The returned staking accounts
}
deriving (Show, Read, Eq, Generic)
instance FromJSON StakingAccounts where
parseJSON v = StakingAccounts <$> parseJSON v
-- | A single Staking Account.
data StakingAccount = StakingAccount
{ saPubKey :: StakingPubKey
-- ^ The Staking Accounts PubKey
, saLamports :: Lamports
-- ^ The Balance of the Staking Account
, saValidatorName :: T.Text
-- ^ The Name of the Staking Account's Validator
}
deriving (Show, Read, Eq, Generic)
instance FromJSON StakingAccount where
parseJSON = withObject "StakingAccount" $ \o -> do
saPubKey <- o .: "stakePubkey"
saLamports <- o .: "stake"
saValidatorName <- o .: "name"
return StakingAccount {..}
-- | A PubKey for a Staking Account.
newtype StakingPubKey = StakingPubKey {fromStakingPubKey :: T.Text}
deriving (Show, Read, Eq, Generic, FromJSON)
-- | An amount of Lamports, each of which represent 0.000000001 SOL.
newtype Lamports = Lamports {fromLamports :: Integer}
deriving (Show, Read, Eq, Num, Generic, FromJSON)
-- | Render an amount of 'Lamports' as text, converting it to SOL.
renderLamports :: Lamports -> T.Text
renderLamports = T.pack . formatScientific Fixed (Just 9) . scientificLamports
-- | Convert Lamports into Scientific representation of SOL.
scientificLamports :: Lamports -> Scientific
scientificLamports = (* 0.000000001) . fromInteger . fromLamports
-- | Get the staking rewards with a staking account's pubkey.
getStakeRewards
:: (MonadReader Config m, MonadCatch m, MonadIO m)
=> StakingPubKey
-> Maybe Integer
-> m (APIResponse StakingRewards)
getStakeRewards (StakingPubKey stakeAccountPubkey) mbOffset = do
getReq
(baseUrl /: "account" /: stakeAccountPubkey /: "staking-rewards")
( queryParam "limit" (Just 1000 :: Maybe Integer)
<> queryParam "offset" mbOffset
)
-- | Get all the staking rewards for the given account.
--
-- The API's @stake-rewards@ route only returns a maximum of 5 rewards, so
-- we have to use the earliest epoch as the @cursor@ in an additional
-- request to see if there are any more rewards.
getAllStakeRewards
:: (MonadReader Config m, MonadCatch m, MonadIO m)
=> StakingPubKey
-> m ([APIError], [StakeReward])
getAllStakeRewards pubkey =
getStakeRewards pubkey Nothing
>>= runApi
>>= getStakeRewardsUntil
pubkey
(const True)
([], [])
-- | Get the year's worth of staking rewards for the given account.
getYearsStakeRewards
:: (MonadReader Config m, MonadCatch m, MonadIO m)
=> StakingPubKey
-> Integer
-> m ([APIError], [StakeReward])
getYearsStakeRewards pubkey year =
fmap (second $ filter ((== year) . rewardYear)) $
getStakeRewards pubkey Nothing
>>= runApi
>>= getStakeRewardsUntil pubkey stopAfterYear ([], [])
where
rewardYear :: StakeReward -> Integer
rewardYear =
(\(y, _, _) -> y)
. toGregorian
. utctDay
. posixSecondsToUTCTime
. srTimestamp
stopAfterYear :: [StakeReward] -> Bool
stopAfterYear rewards =
let years = map rewardYear rewards in any (< year) years
-- | Fetch staking rewards until we get less than 5 rewards or the general
-- predicate returns true.
getStakeRewardsUntil
:: (MonadReader Config m, MonadCatch m, MonadIO m)
=> StakingPubKey
-> ([StakeReward] -> Bool)
-> ([APIError], [StakeReward])
-> Either APIError StakingRewards
-> m ([APIError], [StakeReward])
getStakeRewardsUntil pubkey cond (errs, rws) = \case
Left err -> return (err : errs, rws)
Right res ->
if null res.srRewards || cond res.srRewards
then return (errs, res.srRewards <> rws)
else
let offset = res.srPagination.limit + res.srPagination.offset
in getStakeRewards pubkey (Just offset)
>>= runApi
>>= getStakeRewardsUntil pubkey cond (errs, res.srRewards <> rws)
data StakingRewards = StakingRewards
{ srRewards :: [StakeReward]
, srPagination :: PaginationData
}
deriving (Show, Read, Eq, Generic)
instance FromJSON StakingRewards where
parseJSON = withObject "StakingRewards" $ \o -> do
srRewards <- o .: "rewards"
srPagination <- o .: "pagination"
pure StakingRewards {..}
-- | A Staking Reward Payment.
data StakeReward = StakeReward
{ srEpoch :: Integer
-- ^ The Epoch the reward was paid.
, srSlot :: Integer
-- ^ The 'Block' number of the reward.
, srAmount :: Lamports
-- ^ The total number of 'Lamports' awarded.
, srTimestamp :: POSIXTime
}
deriving (Show, Read, Eq, Generic)
instance FromJSON StakeReward where
parseJSON = withObject "StakeReward" $ \o -> do
-- v1 API returned a different epoch than v2.
-- We continue returning the v1 amount(1 less than v2).
-- Maybe "effective" vs "granted" epoch?
srEpoch <- pred <$> o .: "epoch"
srSlot <- o .: "slot"
srAmount <- o .: "lamports"
srTimestamp <- o .: "timestamp"
return StakeReward {..}
-- | Get information about a specific block number.
getBlock
:: (MonadReader Config m, MonadCatch m, MonadIO m)
=> Integer
-> m (APIResponse Block)
getBlock blockNum = do
getReq (baseUrl /: "block" /~ blockNum) mempty
-- | A single block on the Solana blockchain.
data Block = Block
{ bNumber :: Integer
-- ^ The blocks number.
, bBlockTime :: POSIXTime
-- ^ The blocks absolute timestamp.
}
deriving (Show, Read, Eq, Generic)
instance FromJSON Block where
parseJSON = withObject "Block" $ \o -> do
bNumber <- o .: "blocknumber"
bBlockTime <-
(o .: "blocktime")
>>= fmap (fromInteger . (truncate @Scientific))
. (.: "absolute")
return Block {..}
data PaginationData = PaginationData
{ total :: Integer
, offset :: Integer
, limit :: Integer
}
deriving (Show, Read, Eq, Generic)
instance FromJSON PaginationData where
parseJSON = withObject "PaginationData" $ \o -> do
total <- o .: "total"
offset <- o .: "offset"
limit <- o .: "limit"
pure PaginationData {..}
-- | Generic GET request to the Solana Beach API with up to 5 retries for
-- @ProcessingResponse@.
--
-- Note: Prints to 'stderr' when waiting for request to finish processing.
getReq
:: forall m a
. (MonadReader Config m, FromJSON a, MonadIO m, MonadCatch m)
=> Url 'Https
-> Option 'Https
-> m (APIResponse a)
getReq endpoint options = fetchWithRetries 0
where
maxRetries :: Integer
maxRetries = 5
fetchWithRetries :: Integer -> m (APIResponse a)
fetchWithRetries retryCount =
if retryCount >= maxRetries
then return $ ErrorResponse $ RetriesExceeded $ renderUrl endpoint
else do
apikey <- asks cApiKey
let authHeader =
header "Authorization" $ "Bearer: " <> encodeUtf8 apikey
respBody <-
catchRateLimitError $
runReq
defaultHttpConfig
(req GET endpoint NoReqBody jsonResponse $ authHeader <> options)
case respBody of
ProcessingResponse -> do
liftIO $
hPutStrLn
stderr
"Waiting for API to finish processing request..."
liftIO $ threadDelay $ 10 * 1000000
fetchWithRetries (retryCount + 1)
RateLimitResponse wait -> do
liftIO $
hPutStrLn stderr $
"Exceeded rate limit, waiting "
<> show wait
<> " seconds before retrying..."
liftIO $ threadDelay $ wait * 1000000
fetchWithRetries retryCount
_ -> return respBody
catchRateLimitError
:: (FromJSON (HttpResponseBody b))
=> (HttpResponse b)
=> m b
-> m (HttpResponseBody b)
catchRateLimitError =
try >=> \case
Left e@(VanillaHttpException (HttpExceptionRequest _ (StatusCodeException resp body))) ->
if statusCode (responseStatus resp) == 429
then
either (liftIO . throwIO . JsonHttpException) return $
eitherDecode $
LBS.fromStrict body
else liftIO $ throwIO e
Left e -> liftIO $ throwIO e
Right r -> return $ responseBody r
-- | Wrapper around error & processing responses from the API.
data APIResponse a
= SuccessfulReponse a
| ProcessingResponse
| RateLimitResponse Int
| ErrorResponse APIError
deriving (Show, Read, Eq)
-- | Attempts to parse a processing response, then an error response,
-- & finally the inner @a@ response.
instance (FromJSON a) => FromJSON (APIResponse a) where
parseJSON v = case v of
Object o -> do
o .:? "err" >>= \case
Just errMsg ->
o .:? "processing" >>= \case
Nothing ->
o .:? "retry" >>= \case
Just (readMaybe -> Just i) ->
return $ RateLimitResponse i
_ -> return $ ErrorResponse $ APIError errMsg
Just (_ :: Bool) -> return ProcessingResponse
Nothing -> fmap SuccessfulReponse . parseJSON $ Object o
_ -> SuccessfulReponse <$> parseJSON v
-- | Evaluate an API response.
runApi :: (Monad m) => APIResponse a -> m (Either APIError a)
runApi = runExceptT . raiseAPIError
-- | Pull the inner value out of an 'APIResponse' or throw the respective
-- 'APIError'.
raiseAPIError :: (MonadError APIError m) => APIResponse a -> m a
raiseAPIError = \case
SuccessfulReponse v -> return v
ProcessingResponse ->
throwError $ APIError "Request cancelled during processing wait."
RateLimitResponse i -> throwError $ RateLimitError i
ErrorResponse err -> throwError err
-- | Potential error responses from the Solana Beach API.
data APIError
= -- | Generic API error with message.
APIError T.Text
| -- | Exceeded maximum number of 'ProcessingResponse' retries.
RetriesExceeded T.Text
| -- | Rate limiting 429 error.
RateLimitError Int
deriving (Show, Read, Eq, Generic)