diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,5 @@
+# CHANGELOG
+
+## v0.1.0.0
+
+* Initial release
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -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.
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -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
diff --git a/Setup.hs b/Setup.hs
new file mode 100644
--- /dev/null
+++ b/Setup.hs
@@ -0,0 +1,2 @@
+import Distribution.Simple
+main = defaultMain
diff --git a/app/Main.hs b/app/Main.hs
new file mode 100644
--- /dev/null
+++ b/app/Main.hs
@@ -0,0 +1,9 @@
+module Main where
+
+import           Console.SolanaStaking.Main     ( getArgs
+                                                , run
+                                                )
+
+
+main :: IO ()
+main = getArgs >>= run
diff --git a/solana-staking-csvs.cabal b/solana-staking-csvs.cabal
new file mode 100644
--- /dev/null
+++ b/solana-staking-csvs.cabal
@@ -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
diff --git a/src/Console/SolanaStaking/Api.hs b/src/Console/SolanaStaking/Api.hs
new file mode 100644
--- /dev/null
+++ b/src/Console/SolanaStaking/Api.hs
@@ -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)
diff --git a/src/Console/SolanaStaking/Csv.hs b/src/Console/SolanaStaking/Csv.hs
new file mode 100644
--- /dev/null
+++ b/src/Console/SolanaStaking/Csv.hs
@@ -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
diff --git a/src/Console/SolanaStaking/Main.hs b/src/Console/SolanaStaking/Main.hs
new file mode 100644
--- /dev/null
+++ b/src/Console/SolanaStaking/Main.hs
@@ -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."
diff --git a/tests/Spec.hs b/tests/Spec.hs
new file mode 100644
--- /dev/null
+++ b/tests/Spec.hs
@@ -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)
