{-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE GeneralizedNewtypeDeriving #-} {-# LANGUAGE RecordWildCards #-} {-| Request functions & response types for the Gemini Exchange API. -} module Web.Gemini ( GeminiApiM , runApi , GeminiConfig(..) , GeminiError(..) -- * Requests -- ** Symbol Details , getSymbolDetails , SymbolDetails(..) -- ** Trade History , getMyTrades , Trade(..) -- ** Transfer History , getMyTransfers , Transfer(..) -- ** Earn History , getMyEarnTransactions , EarnHistory(..) , EarnTransaction(..) -- * Helpers , protectedGeminiRequest , retryWithRateLimit , createSignature , makeNonce ) where import Control.Concurrent ( threadDelay ) import Control.Exception.Safe ( MonadCatch , MonadThrow , try ) import Control.Monad.Reader ( MonadIO(liftIO) , MonadReader(ask) , ReaderT(..) , lift ) import Crypto.Hash ( SHA384 ) import Crypto.MAC.HMAC ( hmac , hmacGetDigest ) import Data.Aeson ( (.:) , (.:?) , FromJSON(..) , ToJSON(..) , Value(..) , eitherDecode , encode , withObject ) import Data.Base64.Types ( extractBase64 ) import Data.ByteString.Base64 ( encodeBase64' ) import Data.Maybe ( fromMaybe , listToMaybe , mapMaybe ) import Data.Ratio ( (%) ) import Data.Scientific ( Scientific ) import Data.Text ( Text ) import Data.Text.Encoding ( encodeUtf8 ) import Data.Time ( UTCTime ) import Data.Time.Clock.POSIX ( POSIXTime , getPOSIXTime , utcTimeToPOSIXSeconds ) import Data.Version ( showVersion ) import GHC.Generics ( Generic ) import Network.HTTP.Client ( HttpException(..) , HttpExceptionContent(..) , responseStatus ) import Network.HTTP.Req ( (/:) , GET(..) , HttpBodyAllowed , HttpException(..) , HttpMethod(..) , JsonResponse , MonadHttp(..) , NoReqBody(..) , Option , POST(..) , ProvidesBody , Req , Url , defaultHttpConfig , header , https , jsonResponse , req , responseBody , runReq ) import Network.HTTP.Types ( Status(..) ) import Text.Read ( readMaybe ) import Paths_gemini_exports ( version ) import qualified Data.Aeson.KeyMap as KM import qualified Data.ByteString as BS import qualified Data.ByteString.Char8 as BC import qualified Data.ByteString.Lazy as LBS import qualified Data.Text as T -- | Required configuration data for making requests to the Gemini API. data GeminiConfig = GeminiConfig { gcApiKey :: Text , gcApiSecret :: Text } deriving (Show, Read, Eq, Ord) -- | Monad in which Gemini API requests are run. newtype GeminiApiM a = GeminiApiM { runGeminiApiM :: ReaderT GeminiConfig Req a } deriving (Functor, Applicative, Monad, MonadIO, MonadReader GeminiConfig, MonadThrow, MonadCatch) -- | Run a series of API requests with the given Config. runApi :: GeminiConfig -> GeminiApiM a -> IO a runApi cfg = runReq defaultHttpConfig . flip runReaderT cfg . runGeminiApiM -- | Use 'MonadHttp' from the 'Req' monad. instance MonadHttp GeminiApiM where handleHttpException = GeminiApiM . lift . handleHttpException -- | Potential error response body from the API. data GeminiError = GeminiError { geReason :: Text , geMessage :: Text } deriving (Show, Read, Eq, Ord) instance FromJSON GeminiError where parseJSON = withObject "GeminiError" $ \o -> GeminiError <$> o .: "reason" <*> o .: "message" -- SYMBOL DETAILS -- | Fetch the details on a supported symbol. getSymbolDetails :: MonadHttp m => Text -> m SymbolDetails getSymbolDetails symbol = responseBody <$> req GET ( https "api.gemini.com" /: "v1" /: "symbols" /: "details" /: symbol ) NoReqBody jsonResponse userAgentHeader -- | Currency & Precision details for a 'Trade' Symbol. data SymbolDetails = SymbolDetails { sdSymbol :: Text , sdBaseCurrency :: Text , sdBasePrecision :: Scientific , sdQuoteCurrency :: Text , sdQuotePrecision :: Scientific } deriving (Show, Read, Eq, Ord, Generic) instance FromJSON SymbolDetails where parseJSON = withObject "SymbolDetails" $ \o -> do sdSymbol <- o .: "symbol" sdBaseCurrency <- o .: "base_currency" sdBasePrecision <- o .: "tick_size" sdQuoteCurrency <- o .: "quote_currency" sdQuotePrecision <- o .: "quote_increment" return SymbolDetails { .. } -- TRADE HISTORY -- | Fetch all my Gemini Trades getMyTrades :: Maybe (UTCTime, UTCTime) -- ^ Optional @(start, end)@ ranges for fetching. -> GeminiApiM [Trade] getMyTrades = fetchAllPages getTradeBatch tTimestamp where getTradeBatch :: Integer -> GeminiApiM [Trade] getTradeBatch timestamp = do nonce <- makeNonce let parameters = KM.fromList [ ("request" , String "/v1/mytrades") , ("nonce" , toJSON nonce) , ("timestamp" , toJSON $ timestampToSeconds timestamp) , ("limit_trades", Number 500) ] responseBody <$> protectedGeminiRequest POST (https "api.gemini.com" /: "v1" /: "mytrades") parameters -- | A single, completed Trade. data Trade = Trade { tId :: Integer , tSymbol :: Text , tPrice :: Scientific , tAmount :: Scientific , tFeeCurrency :: Text , tFeeAmount :: Scientific , tIsBuy :: Bool , tIsAggressor :: Bool , tTimestamp :: POSIXTime , tOrderId :: Text } deriving (Show, Read, Eq, Ord, Generic) instance FromJSON Trade where parseJSON = withObject "Trade" $ \o -> do tId <- o .: "tid" tSymbol <- o .: "symbol" tPrice <- read <$> o .: "price" tAmount <- read <$> o .: "amount" tFeeCurrency <- o .: "fee_currency" tFeeAmount <- read <$> o .: "fee_amount" tIsBuy <- (== ("Buy" :: String)) <$> o .: "type" tIsAggressor <- o .: "aggressor" tTimestamp <- (/ 1000.0) <$> o .: "timestampms" tOrderId <- o .: "order_id" return Trade { .. } -- TRANSFER HISTORY -- | Fetch all my Gemini Transfers getMyTransfers :: Maybe (UTCTime, UTCTime) -- ^ Optional @(start, end)@ ranges for fetching. -> GeminiApiM [Transfer] getMyTransfers = fetchAllPages getTransferBatch trTimestamp where getTransferBatch :: Integer -> GeminiApiM [Transfer] getTransferBatch timestamp = do nonce <- makeNonce let parameters = KM.fromList [ ("request" , String "/v1/transfers") , ("nonce" , toJSON nonce) , ("timestamp" , toJSON $ timestampToSeconds timestamp) , ("limit_transfers", Number 50) ] responseBody <$> protectedGeminiRequest POST (https "api.gemini.com" /: "v1" /: "transfers") parameters -- | A single fiat or cryptocurrency transfer, credit, deposit, or withdrawal. data Transfer = Transfer { trId :: Integer , trType :: Text , trStatus :: Text , trCurrency :: Text , trAmount :: Scientific , trMethod :: Maybe Text , trPurpose :: Maybe Text , trTimestamp :: POSIXTime } deriving (Show, Read, Eq, Ord, Generic) instance FromJSON Transfer where parseJSON = withObject "Transfer" $ \o -> do trId <- o .: "eid" trType <- o .: "type" trStatus <- o .: "status" trCurrency <- o .: "currency" trAmount <- read <$> o .: "amount" trMethod <- o .:? "method" trPurpose <- o .:? "purpose" trTimestamp <- (/ 1000) <$> o .: "timestampms" return Transfer { .. } -- EARN HISTORY -- | Fetch all my Gemini Earn Transactions getMyEarnTransactions :: Maybe (UTCTime, UTCTime) -> GeminiApiM [EarnTransaction] getMyEarnTransactions = fetchAllPages getEarnBatch etTimestamp where getEarnBatch :: Integer -> GeminiApiM [EarnTransaction] getEarnBatch timestamp = do nonce <- makeNonce let parameters = KM.fromList [ ("request", String "/v1/earn/history") , ("nonce" , toJSON nonce) , ("since" , toJSON timestamp) , ("sortAsc", toJSON True) , ("limit" , Number 500) ] concatMap @[] ehTransactions . responseBody <$> protectedGeminiRequest POST (https "api.gemini.com" /: "v1" /: "earn" /: "history") parameters -- | Earn Transactions grouped by a Provider/Borrower. data EarnHistory = EarnHistory { ehProviderId :: Text , ehTransactions :: [EarnTransaction] } instance FromJSON EarnHistory where parseJSON = withObject "EarnHistory" $ \o -> EarnHistory <$> o .: "providerId" <*> o .: "transactions" -- | A single Earn transaction. data EarnTransaction = EarnTransaction { etId :: Text , etType :: Text , etAmountCurrency :: Text , etAmount :: Scientific , etPriceCurrency :: Maybe Text , etPrice :: Maybe Scientific , etTimestamp :: POSIXTime } deriving (Show, Read, Eq, Ord, Generic) instance FromJSON EarnTransaction where parseJSON = withObject "EarnTransaction" $ \o -> do etId <- o .: "earnTransactionId" etType <- o .: "transactionType" etAmountCurrency <- o .: "amountCurrency" etAmount <- o .: "amount" etPriceCurrency <- o .:? "priceCurrency" etPrice <- o .:? "priceAmount" etTimestamp <- (/ 1000.0) <$> o .: "dateTime" return EarnTransaction { .. } -- UTILS -- | Run a request that requires authorization against the Gemini API. protectedGeminiRequest :: ( MonadHttp m , HttpMethod method , HttpBodyAllowed (AllowsBody method) (ProvidesBody NoReqBody) , ToJSON body , FromJSON response , MonadReader GeminiConfig m ) => method -> Url scheme -> body -> m (JsonResponse response) protectedGeminiRequest method url body = do cfg <- ask let payload = extractBase64 . encodeBase64' . LBS.toStrict $ encode body signature = createSignature cfg payload let authorizedOptions = mconcat [ header "Content-Type" "text/plain" , header "X-GEMINI-APIKEY" (encodeUtf8 $ gcApiKey cfg) , header "X-GEMINI-PAYLOAD" payload , header "X-GEMINI-SIGNATURE" signature , header "Cache-Control" "no-cache" , userAgentHeader ] req method url NoReqBody jsonResponse authorizedOptions -- | Attempt a request & retry if a @429@ @RateLimited@ error is returned. -- We attempt to parse the retry wait time from the @message@ field but -- fallback to one second. retryWithRateLimit :: (MonadHttp m, MonadCatch m) => m a -> m a retryWithRateLimit request = try request >>= \case Left e@(VanillaHttpException (HttpExceptionRequest _ (StatusCodeException (statusCode . responseStatus -> 429) body))) -> case eitherDecode $ LBS.fromStrict body of Left _ -> handleHttpException e Right r -> if geReason r == "RateLimited" then let msToWait = fromMaybe 1000 . listToMaybe . mapMaybe (readMaybe . T.unpack) . T.words $ geMessage r in do liftIO . threadDelay $ msToWait * 1000 retryWithRateLimit request else handleHttpException e Left e -> handleHttpException e Right r -> return r -- | Fetch all pages of a response by calling the API with increasing -- timestamp fields until it returns an empty response. Takes an optional -- start & end date to offset the initial fetch & stop fetching early. fetchAllPages :: (Integer -> GeminiApiM [a]) -- ^ Make a request for items above the given milliseconds @timestamp@ -> (a -> POSIXTime) -- ^ Pull a timestamp from an item -> Maybe (UTCTime, UTCTime) -- ^ Optional @(start, end)@ range -> GeminiApiM [a] fetchAllPages mkRequest getTimestamp mbRange = do let startTimestamp = maybe 0 (truncate . (1000 *) . utcTimeToPOSIXSeconds . fst) mbRange fetchAll [] startTimestamp where fetchAll prevResults timestamp = do newResults <- retryWithRateLimit $ mkRequest timestamp if null newResults then return prevResults else let maxTimestamp = maximum $ map getTimestamp newResults nextTimestamp = truncate $ 1000 * maxTimestamp + 1 continueFetching = fetchAll (newResults <> prevResults) nextTimestamp filteredResults end = filter ((<= end) . getTimestamp) newResults in case mbRange of Nothing -> continueFetching Just (_, utcTimeToPOSIXSeconds -> end) -> if maxTimestamp >= end then return $ filteredResults end <> prevResults else continueFetching -- | Given a timestamp in ms, convert it to a timestamp param in seconds by -- dividing & rounding up. timestampToSeconds :: Integer -> Integer timestampToSeconds = ceiling . (% 1000) -- | Generate a 'Crypto.MAC.HMAC.HMAC' 'SHA384' signature for an authorized -- API request. createSignature :: GeminiConfig -- ^ API Credentials -> BS.ByteString -- ^ Base64-encoded request body. -> BS.ByteString createSignature cfg body = let digest = hmacGetDigest @SHA384 $ hmac (encodeUtf8 $ gcApiSecret cfg) body in BC.pack $ show digest -- | Generate a nonce for authorized requests from the current timestamp in -- milliseconds. makeNonce :: MonadIO m => m Integer makeNonce = truncate . (1000 *) <$> liftIO getPOSIXTime -- | Generate a @User-Agent@ header with the library's current version. userAgentHeader :: Option scheme userAgentHeader = header "User-Agent" . BC.pack $ "gemini-exports/v" <> showVersion version