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 +5/−0
- LICENSE +30/−0
- README.md +68/−0
- Setup.hs +2/−0
- app/Main.hs +9/−0
- solana-staking-csvs.cabal +118/−0
- src/Console/SolanaStaking/Api.hs +322/−0
- src/Console/SolanaStaking/Csv.hs +76/−0
- src/Console/SolanaStaking/Main.hs +116/−0
- tests/Spec.hs +34/−0
+ 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++[](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)