packages feed

solana-staking-csvs 0.1.2.0 → 0.1.3.0

raw patch · 9 files changed

+455/−315 lines, 9 filesdep +containersdep +exceptionsdep +http-clientsetup-changedPVP: major bump suggested

API removals or changes: PVP suggests a major version bump

Dependencies added: containers, exceptions, http-client, http-types

API changes (from Hackage documentation)

+ Console.SolanaStaking.Api: RateLimitError :: Int -> APIError
+ Console.SolanaStaking.Api: RateLimitResponse :: Int -> APIResponse a
+ Console.SolanaStaking.Api: instance GHC.Num.Num Console.SolanaStaking.Api.Lamports
+ Console.SolanaStaking.Main: [argAggregate] :: Args -> Bool
- Console.SolanaStaking.Api: getAccountStakes :: (MonadIO m, MonadReader Config m) => m (APIResponse StakingAccounts)
+ Console.SolanaStaking.Api: getAccountStakes :: (MonadReader Config m, MonadCatch m, MonadIO m) => m (APIResponse StakingAccounts)
- Console.SolanaStaking.Api: getAllStakeRewards :: (MonadIO m, MonadReader Config m) => StakingPubKey -> m ([APIError], [StakeReward])
+ Console.SolanaStaking.Api: getAllStakeRewards :: (MonadReader Config m, MonadCatch m, MonadIO m) => StakingPubKey -> m ([APIError], [StakeReward])
- Console.SolanaStaking.Api: getBlock :: (MonadIO m, MonadReader Config m) => Integer -> m (APIResponse Block)
+ Console.SolanaStaking.Api: getBlock :: (MonadReader Config m, MonadCatch m, MonadIO m) => Integer -> m (APIResponse Block)
- Console.SolanaStaking.Api: getStakeRewards :: (MonadIO m, MonadReader Config m) => StakingPubKey -> Maybe Integer -> m (APIResponse [StakeReward])
+ Console.SolanaStaking.Api: getStakeRewards :: (MonadReader Config m, MonadCatch m, MonadIO m) => StakingPubKey -> Maybe Integer -> m (APIResponse [StakeReward])
- Console.SolanaStaking.Api: getYearsStakeRewards :: (MonadIO m, MonadReader Config m) => StakingPubKey -> Integer -> m ([APIError], [StakeReward])
+ Console.SolanaStaking.Api: getYearsStakeRewards :: (MonadReader Config m, MonadCatch m, MonadIO m) => StakingPubKey -> Integer -> m ([APIError], [StakeReward])
- Console.SolanaStaking.Main: Args :: String -> String -> Maybe String -> Bool -> Maybe Integer -> Args
+ Console.SolanaStaking.Main: Args :: String -> String -> Maybe String -> Bool -> Maybe Integer -> Bool -> Args

Files

CHANGELOG.md view
@@ -3,6 +3,16 @@ ## master  ++## v0.1.3.0++* Bump dependency versions(text).+* Add `--aggregate` flag to group rewards by day.+* Fetch rewards until no more rewards are returned instead of stopping when+  less than the 5-item page limit is returned.+* Handle breaking API changes when rate limited.++ ## v0.1.2.0  * Add `--year` flag to allow filtering the output by date.
Setup.hs view
@@ -1,2 +1,4 @@ import Distribution.Simple++ main = defaultMain
app/Main.hs view
@@ -1,8 +1,9 @@ module Main where -import           Console.SolanaStaking.Main     ( getArgs-                                                , run-                                                )+import Console.SolanaStaking.Main+    ( getArgs+    , run+    )   main :: IO ()
solana-staking-csvs.cabal view
@@ -1,11 +1,11 @@ cabal-version: 1.12 --- This file has been generated from package.yaml by hpack version 0.34.4.+-- This file has been generated from package.yaml by hpack version 0.36.0. -- -- see: https://github.com/sol/hpack  name:           solana-staking-csvs-version:        0.1.2.0+version:        0.1.3.0 synopsis:       Generate CSV Exports of your Solana Staking Rewards. description:    @solana-staking-csvs@ is a CLI program that queries the Solana blockchain                 for an account's staking accounts and exports all their staking rewards to@@ -29,7 +29,7 @@ bug-reports:    https://github.com/prikhi/solana-staking-csvs/issues author:         Pavan Rikhi maintainer:     pavan.rikhi@gmail.com-copyright:      2021 Pavan Rikhi+copyright:      2021-2024 Pavan Rikhi license:        BSD3 license-file:   LICENSE build-type:     Simple@@ -54,6 +54,7 @@   default-extensions:       DataKinds       DeriveGeneric+      ImportQualifiedPost       LambdaCase       OverloadedStrings       TupleSections@@ -67,10 +68,14 @@     , cassava <1     , cmdargs >=0.10 && <1     , cointracking-imports <1+    , containers <1+    , exceptions <1+    , http-client <1+    , http-types <1     , mtl ==2.*     , req >=3.4 && <4     , scientific <1-    , text <2+    , text <3     , time <2   default-language: Haskell2010 @@ -83,6 +88,7 @@   default-extensions:       DataKinds       DeriveGeneric+      ImportQualifiedPost       LambdaCase       OverloadedStrings       TupleSections@@ -104,6 +110,7 @@   default-extensions:       DataKinds       DeriveGeneric+      ImportQualifiedPost       LambdaCase       OverloadedStrings       TupleSections
src/Console/SolanaStaking/Api.hs view
@@ -1,93 +1,102 @@-{-| Solana Beach API requests & responses.--TODO: Extract into a @solana-beach-api@ package.--} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE GeneralizedNewtypeDeriving #-} {-# LANGUAGE RecordWildCards #-} {-# LANGUAGE ScopedTypeVariables #-}++{- | Solana Beach API requests & responses.++TODO: Extract into a @solana-beach-api@ package.+-} module Console.SolanaStaking.Api     ( -- * Configuration-      Config(..)+      Config (..)     , mkConfig+       -- * Requests / Responses-    , APIResponse(..)-    , APIError(..)+    , APIResponse (..)+    , APIError (..)     , runApi     , raiseAPIError+       -- ** Get Stake Accounts     , getAccountStakes-    , StakingAccounts(..)-    , StakingAccount(..)+    , StakingAccounts (..)+    , StakingAccount (..)+       -- ** Get Staking Rewards     , getAllStakeRewards     , getYearsStakeRewards     , getStakeRewards-    , StakeReward(..)+    , StakeReward (..)+       -- ** Get Block     , getBlock-    , Block(..)+    , Block (..)+       -- * General API Types-    , Lamports(..)+    , Lamports (..)     , renderLamports     , scientificLamports-    , StakingPubKey(..)+    , StakingPubKey (..)     ) where -import           Control.Concurrent             ( threadDelay )-import           Control.Monad.Except           ( MonadError(throwError)-                                                , runExceptT-                                                )-import           Control.Monad.Reader           ( MonadIO-                                                , MonadReader-                                                , asks-                                                , liftIO-                                                )-import           Data.Aeson                     ( (.:)-                                                , (.:?)-                                                , FromJSON(parseJSON)-                                                , Value(Object)-                                                , 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.Req               ( (/:)-                                                , (/~)-                                                , GET(GET)-                                                , NoReqBody(NoReqBody)-                                                , Option-                                                , Scheme(Https)-                                                , Url-                                                , defaultHttpConfig-                                                , header-                                                , https-                                                , jsonResponse-                                                , queryParam-                                                , renderUrl-                                                , req-                                                , responseBody-                                                , runReq-                                                )-import           System.IO                      ( hPutStrLn-                                                , stderr-                                                )+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 qualified Data.Text                     as T+import Data.ByteString.Lazy qualified as LBS+import Data.Text qualified as T + -- | Solana Beach API Configuration data Config = Config-    { cApiKey        :: T.Text+    { cApiKey :: T.Text     -- ^ Your API Key.     -- Get one here: https://github.com/solana-beach/api     , cAccountPubKey :: T.Text@@ -96,11 +105,13 @@     }     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 { .. }+mkConfig (T.pack -> cApiKey) (T.pack -> cAccountPubKey) = Config {..} + -- | Base URL to Solana Beach's API baseUrl :: Url 'Https baseUrl = https "api.solanabeach.io" /: "v1"@@ -108,62 +119,71 @@  -- | Get the staking accounts for the 'cAccountPubKey'. getAccountStakes-    :: (MonadIO m, MonadReader Config m) => m (APIResponse StakingAccounts)+    :: (MonadReader Config m, MonadCatch m, MonadIO m)+    => m (APIResponse StakingAccounts) getAccountStakes = do     pubkey <- asks cAccountPubKey     getReq (baseUrl /: "account" /~ pubkey /: "stakes") mempty + -- | Single Result Page of Staking Accounts Query. data StakingAccounts = StakingAccounts-    { saResults    :: [StakingAccount]+    { saResults :: [StakingAccount]     -- ^ The returned staking accounts     , saTotalPages :: Integer     -- ^ The total number of pages for the Account's PubKey.     }     deriving (Show, Read, Eq, Generic) + instance FromJSON StakingAccounts where     parseJSON = withObject "StakingAccounts" $ \o -> do-        saResults    <- o .: "data"+        saResults <- o .: "data"         saTotalPages <- o .: "totalPages"-        return StakingAccounts { .. }+        return StakingAccounts {..} + -- | A single Staking Account. data StakingAccount = StakingAccount-    { saPubKey        :: StakingPubKey+    { saPubKey :: StakingPubKey     -- ^ The Staking Accounts PubKey-    , saLamports      :: Lamports+    , 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 .: "pubkey" >>= (.: "address")-        saLamports      <- o .: "lamports"+        saPubKey <- o .: "pubkey" >>= (.: "address")+        saLamports <- o .: "lamports"         saValidatorName <-             o-            .:  "data"-            >>= (.: "stake")-            >>= (.: "delegation")-            >>= (.: "validatorInfo")-            >>= (.: "name")-        return StakingAccount { .. }+                .: "data"+                >>= (.: "stake")+                >>= (.: "delegation")+                >>= (.: "validatorInfo")+                >>= (.: "name")+        return StakingAccount {..} + -- | A PubKey for a Staking Account.-newtype StakingPubKey =-    StakingPubKey { fromStakingPubKey :: T.Text } deriving (Show, Read, Eq, Generic, FromJSON)+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, Generic, FromJSON)+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@@ -171,40 +191,45 @@  -- | Get the staking rewards with a staking account's pubkey. getStakeRewards-    :: (MonadIO m, MonadReader Config m)+    :: (MonadReader Config m, MonadCatch m, MonadIO m)     => StakingPubKey     -> Maybe Integer     -> m (APIResponse [StakeReward]) getStakeRewards (StakingPubKey stakeAccountPubkey) mbEpochCursor = do-    getReq (baseUrl /: "account" /: stakeAccountPubkey /: "stake-rewards")-           (queryParam "cursor" mbEpochCursor)+    getReq+        (baseUrl /: "account" /: stakeAccountPubkey /: "stake-rewards")+        (queryParam "cursor" mbEpochCursor) + -- | 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-    :: (MonadIO m, MonadReader Config m)+    :: (MonadReader Config m, MonadCatch m, MonadIO m)     => StakingPubKey     -> m ([APIError], [StakeReward]) getAllStakeRewards pubkey =-    getStakeRewards pubkey Nothing >>= runApi >>= getStakeRewardsUntil-        pubkey-        (const True)-        ([], [])+    getStakeRewards pubkey Nothing+        >>= runApi+        >>= getStakeRewardsUntil+            pubkey+            (const True)+            ([], []) + -- | Get the year's worth of staking rewards for the given account. getYearsStakeRewards-    :: (MonadIO m, MonadReader Config m)+    :: (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 ([], [])+    fmap (second $ filter ((== year) . rewardYear)) $+        getStakeRewards pubkey Nothing+            >>= runApi+            >>= getStakeRewardsUntil pubkey stopAfterYear ([], [])   where     rewardYear :: StakeReward -> Integer     rewardYear =@@ -218,69 +243,77 @@     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-    :: (MonadIO m, MonadReader Config m)+    :: (MonadReader Config m, MonadCatch m, MonadIO m)     => StakingPubKey     -> ([StakeReward] -> Bool)     -> ([APIError], [StakeReward])     -> Either APIError [StakeReward]     -> m ([APIError], [StakeReward]) getStakeRewardsUntil pubkey cond (errs, rws) = \case-    Left  err     -> return (err : errs, rws)-    Right rewards -> if length rewards < 5 || cond rewards-        then return (errs, rewards <> rws)-        else-            let minEpoch = minimum $ map srEpoch rewards-            in  getStakeRewards pubkey (Just minEpoch)-                >>= runApi-                >>= getStakeRewardsUntil pubkey cond (errs, rewards <> rws)+    Left err -> return (err : errs, rws)+    Right rewards ->+        if null rewards || cond rewards+            then return (errs, rewards <> rws)+            else+                let minEpoch = minimum $ map srEpoch rewards+                 in getStakeRewards pubkey (Just minEpoch)+                        >>= runApi+                        >>= getStakeRewardsUntil pubkey cond (errs, rewards <> rws)   -- | A Staking Reward Payment. data StakeReward = StakeReward-    { srEpoch     :: Integer+    { srEpoch :: Integer     -- ^ The Epoch the reward was paid.-    , srSlot      :: Integer+    , srSlot :: Integer     -- ^ The 'Block' number of the reward.-    , srAmount    :: Lamports+    , srAmount :: Lamports     -- ^ The total number of 'Lamports' awarded.     , srTimestamp :: POSIXTime     }     deriving (Show, Read, Eq, Generic) + instance FromJSON StakeReward where     parseJSON = withObject "StakeReward" $ \o -> do-        srEpoch     <- o .: "epoch"-        srSlot      <- o .: "effectiveSlot"-        srAmount    <- o .: "amount"+        srEpoch <- o .: "epoch"+        srSlot <- o .: "effectiveSlot"+        srAmount <- o .: "amount"         srTimestamp <- o .: "timestamp"-        return StakeReward { .. }+        return StakeReward {..} + -- | Get information about a specific block number. getBlock-    :: (MonadIO m, MonadReader Config m) => Integer -> m (APIResponse Block)+    :: (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+    { 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"+        bNumber <- o .: "blocknumber"         bBlockTime <-             (o .: "blocktime")-            >>= fmap (fromInteger . (truncate @Scientific))-            .   (.: "absolute")-        return Block { .. }+                >>= fmap (fromInteger . (truncate @Scientific))+                    . (.: "absolute")+        return Block {..}   -- | Generic GET request to the Solana Beach API with up to 5 retries for@@ -289,7 +322,7 @@ -- Note: Prints to 'stderr' when waiting for request to finish processing. getReq     :: forall m a-     . (MonadReader Config m, MonadIO m, FromJSON a)+     . (MonadReader Config m, FromJSON a, MonadIO m, MonadCatch m)     => Url 'Https     -> Option 'Https     -> m (APIResponse a)@@ -298,65 +331,103 @@     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 <- responseBody <$> 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)-                _ -> return respBody--+    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+instance (FromJSON a) => FromJSON (APIResponse a) where     parseJSON v = case v of         Object o -> do             o .:? "err" >>= \case-                Just errMsg -> o .:? "processing" >>= \case-                    Nothing          -> return $ ErrorResponse $ APIError errMsg-                    Just (_ :: Bool) -> return ProcessingResponse+                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 :: (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 :: (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-    = APIError T.Text-    -- ^ Generic API error with message.-    | RetriesExceeded T.Text-    -- ^ Exceeded maximum number of 'ProcessingResponse' retries.+    = -- | 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)
src/Console/SolanaStaking/CoinTracking.hs view
@@ -1,7 +1,6 @@ {-# LANGUAGE RecordWildCards #-}-{-| Generate & write/print CoinTracking import files. --}+-- | Generate & write/print CoinTracking import files. module Console.SolanaStaking.CoinTracking     ( makeCoinTrackingImport     , writeOrPrintImportData@@ -9,25 +8,27 @@     , sol     ) where -import           Control.Monad                  ( (>=>) )-import           Data.Time                      ( utcToLocalZonedTime )-import           Data.Time.Clock.POSIX          ( posixSecondsToUTCTime )-import           Web.CoinTracking.Imports       ( Amount(..)-                                                , CTImportData(..)-                                                , CTTransactionType(Staking)-                                                , Currency(..)-                                                , coinTrackingCsvImport-                                                , writeImportDataToFile-                                                )+import Control.Monad ((>=>))+import Data.Time (utcToLocalZonedTime)+import Data.Time.Clock.POSIX (posixSecondsToUTCTime)+import Web.CoinTracking.Imports+    ( Amount (..)+    , CTImportData (..)+    , CTTransactionType (Staking)+    , Currency (..)+    , coinTrackingCsvImport+    , writeImportDataToFile+    ) -import           Console.SolanaStaking.Api      ( StakeReward(..)-                                                , StakingAccount(..)-                                                , StakingPubKey(..)-                                                , scientificLamports-                                                )+import Console.SolanaStaking.Api+    ( StakeReward (..)+    , StakingAccount (..)+    , StakingPubKey (..)+    , scientificLamports+    ) -import qualified Data.ByteString.Lazy.Char8    as LBC-import qualified Data.Text                     as T+import Data.ByteString.Lazy.Char8 qualified as LBC+import Data.Text qualified as T   -- | Generate the Import file for CoinTracking & write to destination or@@ -35,32 +36,40 @@ makeCoinTrackingImport :: FilePath -> [(StakingAccount, StakeReward)] -> IO () makeCoinTrackingImport dest = makeImportData >=> writeOrPrintImportData dest + -- | Write or print the generated import data. writeOrPrintImportData :: FilePath -> [CTImportData] -> IO ()-writeOrPrintImportData dest importData = if dest == "-"-    then LBC.putStrLn $ coinTrackingCsvImport importData-    else writeImportDataToFile dest importData+writeOrPrintImportData dest importData =+    if dest == "-"+        then LBC.putStrLn $ coinTrackingCsvImport importData+        else writeImportDataToFile dest importData + -- | Turn a 'StakeReward' into a 'CTImportData', localizing the reward -- time. makeImportData :: [(StakingAccount, StakeReward)] -> IO [CTImportData] makeImportData = mapM $ \(StakingAccount {..}, StakeReward {..}) -> do     zonedTime <- utcToLocalZonedTime $ posixSecondsToUTCTime srTimestamp-    return CTImportData-        { ctidType      = Staking-        , ctidBuy       = Just $ Amount (scientificLamports srAmount) sol-        , ctidSell      = Nothing-        , ctidFee       = Nothing-        , ctidExchange  = "Solana Staking"-        , ctidGroup     = "Staking"-        , ctidComment   = "Imported from solana-staking-csvs"-        , ctidDate      = zonedTime-        , ctidTradeId   =-            "SOL-STAKE-" <> fromStakingPubKey saPubKey <> "-" <> T.pack-                (show srEpoch)-        , ctidBuyValue  = Nothing-        , ctidSellValue = Nothing-        }+    return+        CTImportData+            { ctidType = Staking+            , ctidBuy = Just $ Amount (scientificLamports srAmount) sol+            , ctidSell = Nothing+            , ctidFee = Nothing+            , ctidExchange = "Solana Staking"+            , ctidGroup = "Staking"+            , ctidComment = "Imported from solana-staking-csvs"+            , ctidDate = zonedTime+            , ctidTradeId =+                "SOL-STAKE-"+                    <> fromStakingPubKey saPubKey+                    <> "-"+                    <> T.pack+                        (show srEpoch)+            , ctidBuyValue = Nothing+            , ctidSellValue = Nothing+            }+  -- | @SOL@ currency with the @SOL2@ ticker & 9 decimals of precision. sol :: Currency
src/Console/SolanaStaking/Csv.hs view
@@ -1,76 +1,79 @@ {-# LANGUAGE OverloadedLists #-} {-# LANGUAGE RecordWildCards #-}-{-| Types responsible for CSV generation. --}+-- | Types responsible for CSV generation. module Console.SolanaStaking.Csv     ( makeCsvContents-    , ExportData(..)+    , ExportData (..)     , toExportData     ) where -import           Data.Csv                       ( (.=)-                                                , DefaultOrdered(..)-                                                , EncodeOptions(..)-                                                , ToNamedRecord(..)-                                                , defaultEncodeOptions-                                                , encodeDefaultOrderedByNameWith-                                                , namedRecord-                                                )-import           Data.Time                      ( defaultTimeLocale-                                                , formatTime-                                                )-import           Data.Time.Clock.POSIX          ( POSIXTime-                                                , posixSecondsToUTCTime-                                                )+import Data.Csv+    ( DefaultOrdered (..)+    , EncodeOptions (..)+    , ToNamedRecord (..)+    , defaultEncodeOptions+    , encodeDefaultOrderedByNameWith+    , namedRecord+    , (.=)+    )+import Data.Time (defaultTimeLocale, formatTime)+import Data.Time.Clock.POSIX (POSIXTime, posixSecondsToUTCTime) -import           Console.SolanaStaking.Api      ( StakeReward(..)-                                                , StakingAccount(..)-                                                , StakingPubKey(..)-                                                , renderLamports-                                                )+import Console.SolanaStaking.Api+    ( StakeReward (..)+    , StakingAccount (..)+    , StakingPubKey (..)+    , renderLamports+    ) -import qualified Data.ByteString.Lazy          as LBS-import qualified Data.Text                     as T+import Data.ByteString.Lazy qualified as LBS+import Data.Text qualified as T   -- | Represents a single row of CSV data. data ExportData = ExportData-    { edTime         :: T.Text-    , edAmount       :: T.Text+    { edTime :: T.Text+    , edAmount :: T.Text     , edStakeAccount :: T.Text-    , edEpoch        :: Integer+    , edEpoch :: Integer     }     deriving (Show, Read, Eq, Ord) + -- | Remove the @ed@ prefixes from the field names. instance ToNamedRecord ExportData where-    toNamedRecord ExportData {..} = namedRecord-        [ "time" .= edTime-        , "amount" .= edAmount-        , "stakeAccount" .= edStakeAccount-        , "epoch" .= edEpoch-        ]+    toNamedRecord ExportData {..} =+        namedRecord+            [ "time" .= edTime+            , "amount" .= edAmount+            , "stakeAccount" .= edStakeAccount+            , "epoch" .= edEpoch+            ] + -- | Column order is @time, amount, stakeAccount, epoch@. instance DefaultOrdered ExportData where     headerOrder _ = ["time", "amount", "stakeAccount", "epoch"] + -- | Convert an Account & Reward into a CSV row. toExportData :: (StakingAccount, StakeReward) -> ExportData-toExportData (StakingAccount {..}, StakeReward {..}) = ExportData-    { edTime         = formatTimestamp srTimestamp-    , edAmount       = renderLamports srAmount-    , edStakeAccount = fromStakingPubKey saPubKey-    , edEpoch        = srEpoch-    }+toExportData (StakingAccount {..}, StakeReward {..}) =+    ExportData+        { edTime = formatTimestamp srTimestamp+        , edAmount = renderLamports srAmount+        , edStakeAccount = fromStakingPubKey saPubKey+        , edEpoch = srEpoch+        }   where     formatTimestamp :: POSIXTime -> T.Text     formatTimestamp =         T.pack . formatTime defaultTimeLocale "%F %T%Z" . posixSecondsToUTCTime + -- | Build the CSV contents with a header row. makeCsvContents :: [(StakingAccount, StakeReward)] -> LBS.ByteString makeCsvContents =-    encodeDefaultOrderedByNameWith defaultEncodeOptions { encUseCrLf = False }+    encodeDefaultOrderedByNameWith defaultEncodeOptions {encUseCrLf = False}         . map toExportData
src/Console/SolanaStaking/Main.hs view
@@ -1,56 +1,47 @@ {-# LANGUAGE DeriveDataTypeable #-} {-# LANGUAGE RecordWildCards #-}-{-| This module contains the @main@ function used by the exectuable. --}+-- | This module contains the @main@ function used by the exectuable. module Console.SolanaStaking.Main     ( run     , getArgs-    , Args(..)+    , Args (..)     ) where -import           Control.Monad                  ( (<=<)-                                                , forM-                                                , unless-                                                )-import           Control.Monad.Except           ( ExceptT-                                                , runExceptT-                                                )-import           Control.Monad.Reader           ( ReaderT-                                                , liftIO-                                                , runReaderT-                                                )-import           Data.Bifunctor                 ( bimap-                                                , second-                                                )-import           Data.List                      ( sortOn )-import           Data.Maybe                     ( fromMaybe )-import           Data.Version                   ( showVersion )-import           System.Console.CmdArgs         ( (&=)-                                                , Data-                                                , Typeable-                                                , argPos-                                                , cmdArgs-                                                , def-                                                , explicit-                                                , help-                                                , helpArg-                                                , name-                                                , program-                                                , summary-                                                , typ-                                                )-import           System.IO                      ( hPutStrLn-                                                , stderr-                                                )+import Control.Monad (foldM, forM, unless, (<=<))+import Control.Monad.Except (ExceptT, runExceptT)+import Control.Monad.Reader (MonadIO, ReaderT, liftIO, runReaderT)+import Data.Bifunctor (bimap, second)+import Data.List (sortOn)+import Data.Maybe (fromMaybe, mapMaybe)+import Data.Time (LocalTime (..), ZonedTime (..), utcToLocalZonedTime)+import Data.Time.Clock.POSIX (posixSecondsToUTCTime)+import Data.Version (showVersion)+import System.Console.CmdArgs+    ( Data+    , Typeable+    , argPos+    , cmdArgs+    , def+    , explicit+    , help+    , helpArg+    , name+    , program+    , summary+    , typ+    , (&=)+    )+import System.IO (hPutStrLn, stderr) -import           Console.SolanaStaking.Api-import           Console.SolanaStaking.CoinTracking-                                                ( makeCoinTrackingImport )-import           Console.SolanaStaking.Csv      ( makeCsvContents )-import           Paths_solana_staking_csvs      ( version )+import Console.SolanaStaking.Api+import Console.SolanaStaking.CoinTracking (makeCoinTrackingImport)+import Console.SolanaStaking.Csv (makeCsvContents)+import Paths_solana_staking_csvs (version) -import qualified Data.ByteString.Lazy          as LBS+import Data.ByteString.Lazy qualified as LBS+import Data.Map.Strict qualified as M+import Data.Text qualified as T   -- | Pull staking rewards data for the account & print a CSV to stdout.@@ -63,78 +54,123 @@     -- Grab rewards for each stake account     (stakeErrors, stakeRewards) <-         fmap (bimap concat concat . unzip) . forM stakes $ \sa -> do--            second (map (sa, ))-                <$> maybe (getAllStakeRewards (saPubKey sa))-                          (getYearsStakeRewards $ saPubKey sa)-                          argYear+            second (map (sa,))+                <$> maybe+                    (getAllStakeRewards (saPubKey sa))+                    (getYearsStakeRewards $ saPubKey sa)+                    argYear     unless (null stakeErrors) . liftIO $ do         hPutStrLn stderr "Got errors while fetching stake rewards:"         mapM_ (hPutStrLn stderr . ("\t" <>) . show) stakeErrors     -- Write the CSV     let orderedRewards = sortOn (srTimestamp . snd) stakeRewards-        outputFile     = fromMaybe "-" argOutputFile+        aggregator = if argAggregate then aggregateRewards else return+        outputFile = fromMaybe "-" argOutputFile+    rewards <- aggregator orderedRewards     if argCoinTracking-        then liftIO $ makeCoinTrackingImport outputFile orderedRewards+        then liftIO $ makeCoinTrackingImport outputFile rewards         else do-            let output = makeCsvContents orderedRewards+            let output = makeCsvContents rewards             if outputFile == "-"                 then liftIO $ LBS.putStr output                 else liftIO $ LBS.writeFile outputFile output   where     runner :: ReaderT Config (ExceptT APIError IO) a -> IO (Either APIError a)     runner = runExceptT . flip runReaderT (mkConfig argApiKey argPubKey)+    aggregateRewards :: (MonadIO m) => [(StakingAccount, StakeReward)] -> m [(StakingAccount, StakeReward)]+    aggregateRewards =+        fmap (mapMaybe (aggregate . snd) . M.toList)+            . foldM+                ( \acc r -> do+                    rewardTime <- liftIO . utcToLocalZonedTime $ posixSecondsToUTCTime $ srTimestamp $ snd r+                    return $+                        M.insertWith+                            (<>)+                            (localDay $ zonedTimeToLocalTime rewardTime)+                            [r]+                            acc+                )+                M.empty +    aggregate :: [(StakingAccount, StakeReward)] -> Maybe (StakingAccount, StakeReward)+    aggregate = \case+        [] -> Nothing+        rs ->+            Just+                ( StakingAccount+                    { saValidatorName = "AGGREGATE-" <> T.pack (show $ length rs)+                    , saPubKey = StakingPubKey $ T.pack argPubKey+                    , saLamports = sum $ map (saLamports . fst) rs+                    }+                , StakeReward+                    { srTimestamp = minimum $ map (srTimestamp . snd) rs+                    , srSlot = srSlot $ snd $ head rs+                    , srEpoch = srEpoch $ snd $ head rs+                    , srAmount = sum $ map (srAmount . snd) rs+                    }+                ) + -- | CLI arguments supported by the executable. data Args = Args-    { argApiKey       :: String+    { argApiKey :: String     -- ^ Solana Beach API Key-    , argPubKey       :: String+    , argPubKey :: String     -- ^ Delegator's PubKey.-    , argOutputFile   :: Maybe String+    , argOutputFile :: Maybe String     -- ^ Optional output file. 'Nothing' or @'Just' "-"@ means print to     -- 'System.IO.stdout'.     , argCoinTracking :: Bool     -- ^ Flag to enable writing/printing files formatted for CoinTracking     -- Imports.-    , argYear         :: Maybe Integer+    , argYear :: Maybe Integer     -- ^ Year to limit output to.+    , argAggregate :: Bool+    -- ^ Aggregate rewards into single-day transactions.     }     deriving (Show, Read, Eq, Data, Typeable) + -- | Parse the CLI arguments with 'System.Console.CmdArgs'. getArgs :: IO Args getArgs = cmdArgs argSpec + argSpec :: Args argSpec =     Args-            { argApiKey       = def &= argPos 0 &= typ "API_KEY"-            , argPubKey       = def &= argPos 1 &= typ "ACCOUNT_PUBKEY"-            , argOutputFile   =-                Nothing+        { argApiKey = def &= argPos 0 &= typ "API_KEY"+        , argPubKey = def &= argPos 1 &= typ "ACCOUNT_PUBKEY"+        , argOutputFile =+            Nothing                 &= help "File to write the export to. Default: stdout"                 &= explicit                 &= name "output-file"                 &= name "o"                 &= typ "FILE"-            , argCoinTracking = False-                                &= help "Generate a CoinTracking Import file."-                                &= explicit-                                &= name "cointracking"-            , argYear         = Nothing-                                &= help "Limit to given year"-                                &= explicit-                                &= name "y"-                                &= name "year"-                                &= typ "YYYY"-            }+        , argCoinTracking =+            False+                &= help "Generate a CoinTracking Import file."+                &= explicit+                &= name "cointracking"+        , argYear =+            Nothing+                &= help "Limit to given year"+                &= explicit+                &= name "y"+                &= name "year"+                &= typ "YYYY"+        , argAggregate =+            False+                &= help "Output one aggregate row per day."+                &= explicit+                &= name "aggregate"+        }         &= summary-               (  "solana-staking-csvs v"-               <> showVersion version-               <> ", Pavan Rikhi 2021"-               )+            ( "solana-staking-csvs v"+                <> showVersion version+                <> ", Pavan Rikhi 2021-2024"+            )         &= program "solana-staking-csvs"         &= helpArg [name "h"]         &= help "Generate CSV Exports of your Solana Staking Rewards."
tests/Spec.hs view
@@ -1,10 +1,10 @@-import           Hedgehog-import           Test.Tasty-import           Test.Tasty.HUnit-import           Test.Tasty.Hedgehog+import Hedgehog+import Test.Tasty+import Test.Tasty.HUnit+import Test.Tasty.Hedgehog -import qualified Hedgehog.Gen                  as Gen-import qualified Hedgehog.Range                as Range+import Hedgehog.Gen qualified as Gen+import Hedgehog.Range qualified as Range   main :: IO ()@@ -23,9 +23,10 @@   properties :: TestTree-properties = testGroup-    "Properties"-    [testProperty "Addition is Communative" testAdditionCommunative]+properties =+    testGroup+        "Properties"+        [testProperty "Addition is Communative" testAdditionCommunative]   where     testAdditionCommunative :: Property     testAdditionCommunative = property $ do