packages feed

solana-staking-csvs (empty) → 0.1.0.0

raw patch · 10 files changed

+780/−0 lines, 10 filesdep +aesondep +basedep +bytestringsetup-changed

Dependencies added: aeson, base, bytestring, cassava, cmdargs, hedgehog, mtl, req, scientific, solana-staking-csvs, tasty, tasty-hedgehog, tasty-hunit, text, time

Files

+ CHANGELOG.md view
@@ -0,0 +1,5 @@+# CHANGELOG++## v0.1.0.0++* Initial release
+ LICENSE view
@@ -0,0 +1,30 @@+Copyright Pavan Rikhi (c) 2021++All rights reserved.++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++    * Redistributions of source code must retain the above copyright+      notice, this list of conditions and the following disclaimer.++    * Redistributions in binary form must reproduce the above+      copyright notice, this list of conditions and the following+      disclaimer in the documentation and/or other materials provided+      with the distribution.++    * Neither the name of Pavan Rikhi nor the names of other+      contributors may be used to endorse or promote products derived+      from this software without specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ README.md view
@@ -0,0 +1,68 @@+# Solana Staking CSVs++[![solana-staking-csvs Build Status](https://github.com/prikhi/solana-staking-csvs/actions/workflows/main.yml/badge.svg)](https://github.com/prikhi/solana-staking-csvs/actions/workflows/main.yml)+++Generate CSVs of your Solana staking rewards.++Requires [`stack`][get-stack] & a SolanaBeach API key, which you can request+[here][solanabeach-api].++```sh+stack run -- <YOUR_API_KEY> <ACCOUNT_PUBKEY>+```++TODO:++* Add `-Y <year>` flag to limit years exported(or start/end flags?).+* Allow sourcing pubkey & apikey from env variables?+* Move SolanaBeach API to separate, published package.+* Publish on Hackage & Stackage.+* Add necessary fields for CoinTracking imports(currency & "trade id"?)++[get-stack]: https://docs.haskellstack.org/en/stable/README/+[solanabeach-api]: https://github.com/solana-beach/api+++## Install++You can install the CLI exe by running `stack install`. This lets you call the+executable directly instead of through stack:++```sh+$ stack install+$ export PATH="${HOME}/.local/bin/:${PATH}"+$ solana-staking-csvs <YOUR_API_KEY> 6MTkiDNY5N5PoJHN862D91jM5ztF3KQWDyBeobo2rSgK+time,amount,stakeAccount,epoch+2021-07-03 19:49:49UTC,27.115357569,8yfoauy7WhfBGA441GsHnjQedeAga8MsZXu8Pn16xMmY,197+2021-07-06 21:44:25UTC,27.197834728,8yfoauy7WhfBGA441GsHnjQedeAga8MsZXu8Pn16xMmY,198+2021-07-10 00:02:06UTC,27.231624940,8yfoauy7WhfBGA441GsHnjQedeAga8MsZXu8Pn16xMmY,199+2021-07-10 00:02:06UTC,27.233380734,7XitpDt2tUwwmmmxfbPC4jJ6cCseuBBQHw5p6kWqmqvn,199+```+++## Build++You can build the project with stack:++```code+stack build+```++For development, you can enable fast builds with file-watching,+documentation-building, & test-running:++```code+stack test --haddock --fast --file-watch --pedantic+```++To build & open the documentation, run++```code+stack haddock --open solana-staking-csv+```+++## LICENSE++BSD-3
+ Setup.hs view
@@ -0,0 +1,2 @@+import Distribution.Simple+main = defaultMain
+ app/Main.hs view
@@ -0,0 +1,9 @@+module Main where++import           Console.SolanaStaking.Main     ( getArgs+                                                , run+                                                )+++main :: IO ()+main = getArgs >>= run
+ solana-staking-csvs.cabal view
@@ -0,0 +1,118 @@+cabal-version: 1.12++-- This file has been generated from package.yaml by hpack version 0.34.4.+--+-- see: https://github.com/sol/hpack++name:           solana-staking-csvs+version:        0.1.0.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+                a CSV file.+                .+                The <https://solanabeach.io/ Solana Beach API> is used to fetch data from+                the blockchain and you will need a+                <https://github.com/solana-beach/api free API key> to use this program.+                .+                You can install @solana-staking-csvs@ with Stack: @stack install --resolver+                nightly solana-staking-csvs@. Then run the following to print out an+                account's rewards in CSV format:+                .+                @+                  solana-staking-csvs \<API_KEY> \<ACCOUNT_PUBKEY>+                @+                .+                See @solana-staking-csvs --help@ for additional options.+category:       Web, Finance, Console+homepage:       https://github.com/prikhi/solana-staking-csv#readme+bug-reports:    https://github.com/prikhi/solana-staking-csvs/issues+author:         Pavan Rikhi+maintainer:     pavan.rikhi@gmail.com+copyright:      2021 Pavan Rikhi+license:        BSD3+license-file:   LICENSE+build-type:     Simple+extra-source-files:+    README.md+    CHANGELOG.md++source-repository head+  type: git+  location: https://github.com/prikhi/solana-staking-csvs++library+  exposed-modules:+      Console.SolanaStaking.Api+      Console.SolanaStaking.Csv+      Console.SolanaStaking.Main+  other-modules:+      Paths_solana_staking_csvs+  hs-source-dirs:+      src+  default-extensions:+      DataKinds+      DeriveGeneric+      LambdaCase+      OverloadedStrings+      TupleSections+      TypeApplications+      ViewPatterns+  ghc-options: -Wall -Wcompat -Wincomplete-record-updates -Wincomplete-uni-patterns -Wredundant-constraints -O2+  build-depends:+      aeson ==1.*+    , base >=4.7 && <5+    , bytestring <1+    , cassava <1+    , cmdargs >=0.10 && <1+    , mtl ==2.*+    , req ==3.*+    , scientific <1+    , text <2+    , time <2+  default-language: Haskell2010++executable solana-staking-csvs+  main-is: Main.hs+  other-modules:+      Paths_solana_staking_csvs+  hs-source-dirs:+      app+  default-extensions:+      DataKinds+      DeriveGeneric+      LambdaCase+      OverloadedStrings+      TupleSections+      TypeApplications+      ViewPatterns+  ghc-options: -Wall -Wcompat -Wincomplete-record-updates -Wincomplete-uni-patterns -Wredundant-constraints -O2 -threaded -rtsopts -with-rtsopts "-N -T"+  build-depends:+      base >=4.7 && <5+    , solana-staking-csvs+  default-language: Haskell2010++test-suite solana-staking-csvs-test+  type: exitcode-stdio-1.0+  main-is: Spec.hs+  other-modules:+      Paths_solana_staking_csvs+  hs-source-dirs:+      tests+  default-extensions:+      DataKinds+      DeriveGeneric+      LambdaCase+      OverloadedStrings+      TupleSections+      TypeApplications+      ViewPatterns+  ghc-options: -Wall -Wcompat -Wincomplete-record-updates -Wincomplete-uni-patterns -Wredundant-constraints -O2 -threaded -rtsopts -with-rtsopts "-N -T"+  build-depends:+      base >=4.7 && <5+    , hedgehog+    , solana-staking-csvs+    , tasty+    , tasty-hedgehog+    , tasty-hunit+  default-language: Haskell2010
+ src/Console/SolanaStaking/Api.hs view
@@ -0,0 +1,322 @@+{-| Solana Beach API requests & responses.++TODO: Extract into a @solana-beach-api@ package.+-}+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE GeneralizedNewtypeDeriving #-}+{-# LANGUAGE RecordWildCards #-}+{-# LANGUAGE ScopedTypeVariables #-}+module Console.SolanaStaking.Api+    ( -- * Configuration+      Config(..)+    , mkConfig+      -- * Requests / Responses+    , APIResponse(..)+    , APIError(..)+    , raiseAPIError+      -- ** Get Stake Accounts+    , getAccountStakes+    , StakingAccounts(..)+    , StakingAccount(..)+      -- ** Get Staking Rewards+    , getAllStakeRewards+    , getStakeRewards+    , StakeReward(..)+      -- ** Get Block+    , getBlock+    , Block(..)+      -- * General API Types+    , Lamports(..)+    , renderLamports+    , 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.Scientific                ( FPFormat(Fixed)+                                                , Scientific+                                                , formatScientific+                                                )+import           Data.Text.Encoding             ( encodeUtf8 )+import           Data.Time.Clock.POSIX          ( POSIXTime )+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 qualified Data.Text                     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" /: "v1"+++-- | Get the staking accounts for the 'cAccountPubKey'.+getAccountStakes+    :: (MonadIO m, MonadReader Config 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]+    -- ^ 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"+        saTotalPages <- o .: "totalPages"+        return StakingAccounts { .. }++-- | 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 .: "pubkey" >>= (.: "address")+        saLamports      <- o .: "lamports"+        saValidatorName <-+            o+            .:  "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)++-- | An amount of Lamports, each of which represent 0.000000001 SOL.+newtype Lamports =+    Lamports { fromLamports :: Integer } deriving (Show, Read, Eq, Generic, FromJSON)++-- | Render an amount of 'Lamports' as text, converting it to SOL.+renderLamports :: Lamports -> T.Text+renderLamports =+    T.pack+        . formatScientific Fixed (Just 9)+        . (* 0.000000001)+        . fromInteger+        . fromLamports+++-- | Get the staking rewards with a staking account's pubkey.+getStakeRewards+    :: (MonadIO m, MonadReader Config m)+    => StakingPubKey+    -> Maybe Integer+    -> m (APIResponse [StakeReward])+getStakeRewards (StakingPubKey stakeAccountPubkey) mbEpochCursor = do+    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+    :: forall m+     . (MonadIO m, MonadReader Config m)+    => StakingPubKey+    -> m ([APIError], [StakeReward])+getAllStakeRewards pubkey =+    getStakeRewards pubkey Nothing >>= runExceptT . raiseAPIError >>= go+        ([], [])+  where+    go+        :: ([APIError], [StakeReward])+        -> Either APIError [StakeReward]+        -> m ([APIError], [StakeReward])+    go (errs, rws) = \case+        Left  err     -> return (err : errs, rws)+        Right rewards -> if length rewards < 5+            then return (errs, rewards <> rws)+            else+                let minEpoch = minimum $ map srEpoch rewards+                in  getStakeRewards pubkey (Just minEpoch)+                    >>= runExceptT+                    .   raiseAPIError+                    >>= go (errs, rewards <> rws)++++-- | 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+        srEpoch     <- o .: "epoch"+        srSlot      <- o .: "effectiveSlot"+        srAmount    <- o .: "amount"+        srTimestamp <- o .: "timestamp"+        return StakeReward { .. }++-- | Get information about a specific block number.+getBlock+    :: (MonadIO m, MonadReader Config 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 { .. }+++-- | 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, MonadIO m, FromJSON a)+    => 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 <- 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+++++-- | Wrapper around error & processing responses from the API.+data APIResponse a+    = SuccessfulReponse a+    | ProcessingResponse+    | 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          -> return $ ErrorResponse $ APIError errMsg+                    Just (_ :: Bool) -> return ProcessingResponse+                Nothing -> fmap SuccessfulReponse . parseJSON $ Object o+        _ -> SuccessfulReponse <$> parseJSON v++-- | 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."+    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.+    deriving (Show, Read, Eq, Generic)
+ src/Console/SolanaStaking/Csv.hs view
@@ -0,0 +1,76 @@+{-# LANGUAGE OverloadedLists #-}+{-# LANGUAGE RecordWildCards #-}+{-| Types responsible for CSV generation.++-}+module Console.SolanaStaking.Csv+    ( makeCsvContents+    , 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           Console.SolanaStaking.Api      ( StakeReward(..)+                                                , StakingAccount(..)+                                                , StakingPubKey(..)+                                                , renderLamports+                                                )++import qualified Data.ByteString.Lazy          as LBS+import qualified Data.Text                     as T+++-- | Represents a single row of CSV data.+data ExportData = ExportData+    { edTime         :: T.Text+    , edAmount       :: T.Text+    , edStakeAccount :: T.Text+    , 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+        ]++-- | 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+    }+  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 }+        . map toExportData
+ src/Console/SolanaStaking/Main.hs view
@@ -0,0 +1,116 @@+{-# LANGUAGE DeriveDataTypeable #-}+{-# LANGUAGE RecordWildCards #-}+{-| This module contains the @main@ function used by the exectuable.++-}+module Console.SolanaStaking.Main+    ( run+    , getArgs+    , 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           Console.SolanaStaking.Api+import           Console.SolanaStaking.Csv      ( makeCsvContents )+import           Paths_solana_staking_csvs      ( version )++import qualified Data.ByteString.Lazy          as LBS+++-- | Pull staking rewards data for the account & print a CSV to stdout.+--+-- TODO: concurrent/pooled requests, but w/ 100 req/s limit+run :: Args -> IO ()+run Args {..} = either (error . show) return <=< runner $ do+    -- Grab all stake accounts+    stakes <- saResults <$> (getAccountStakes >>= raiseAPIError)+    -- Grab rewards for each stake account+    (stakeErrors, stakeRewards) <-+        fmap (bimap concat concat . unzip) . forM stakes $ \sa -> do+            second (map (sa, )) <$> getAllStakeRewards (saPubKey sa)+    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+        output         = makeCsvContents orderedRewards+        outputFile     = fromMaybe "-" argOutputFile+    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)+++-- | CLI arguments supported by the executable.+data Args = Args+    { argApiKey     :: String+    -- ^ Solana Beach API Key+    , argPubKey     :: String+    -- ^ Delegator's PubKey.+    , argOutputFile :: Maybe String+    -- ^ Optional output file. 'Nothing' or @'Just' "-"@ means print to+    -- 'System.IO.stdout'.+    }+    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+                &= help "File to write the export to. Default: stdout"+                &= explicit+                &= name "output-file"+                &= name "o"+                &= typ "FILE"+            }+        &= summary+               (  "solana-staking-csvs v"+               <> showVersion version+               <> ", Pavan Rikhi 2021"+               )+        &= program "solana-staking-csvs"+        &= helpArg [name "h"]+        &= help "Generate CSV Exports of your Solana Staking Rewards."
+ tests/Spec.hs view
@@ -0,0 +1,34 @@+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+++main :: IO ()+main = defaultMain tests+++tests :: TestTree+tests = testGroup "Tests" [unitTests, properties]+++unitTests :: TestTree+unitTests = testGroup "Unit Tests" [testCase "2+2 = 4" testAddition]+  where+    testAddition :: Assertion+    testAddition = (2 + 2) @?= (4 :: Integer)+++properties :: TestTree+properties = testGroup+    "Properties"+    [testProperty "Addition is Communative" testAdditionCommunative]+  where+    testAdditionCommunative :: Property+    testAdditionCommunative = property $ do+        let genInt = Gen.int $ Range.linear 0 9001+        (a, b) <- forAll $ (,) <$> genInt <*> genInt+        (a + b) === (b + a)