packages feed

morley-client-0.1.0: src/Morley/Client/App.hs

-- SPDX-FileCopyrightText: 2020 Tocqueville Group
--
-- SPDX-License-Identifier: LicenseRef-MIT-TQ

-- | Functions useful for implementing instances of type classes from this package.
-- Monads and actual instances are defined in separate modules.

module Morley.Client.App
  ( -- * RunClient
    runRequestAcceptStatusImpl
  , throwClientErrorImpl

  -- * HasTezosRpc
  , getBlockHashImpl
  , getCounterImpl
  , getBlockHeaderImpl
  , getBlockConstantsImpl
  , getBlockOperationsImpl
  , getProtocolParametersImpl
  , runOperationImpl
  , preApplyOperationsImpl
  , forgeOperationImpl
  , injectOperationImpl
  , getContractScriptImpl
  , getContractStorageAtBlockImpl
  , getContractBigMapImpl
  , getBigMapValueAtBlockImpl
  , getBigMapValuesAtBlockImpl
  , getBalanceImpl
  , getManagerKeyImpl
  , runCodeImpl
  , getChainIdImpl
  , getDelegateImpl

  -- * Timeouts and retries
  , retryOnTimeout
  , failOnTimeout
  , retryOnceOnTimeout
  , waitBeforeRetry
  , handleInvalidCounterRpc
  ) where

import Control.Concurrent (threadDelay)
import qualified Data.Aeson as Aeson
import qualified Data.Binary.Builder as Binary
import Data.List ((!!))
import Data.Text (isInfixOf)
import Fmt (Buildable(..), Builder, build, pretty, (+|), (|+))
import Network.HTTP.Types (Status(..), renderQuery)
import Servant.Client (ClientEnv, runClientM)
import Servant.Client.Core
  (ClientError(..), Request, RequestBody(..), RequestF(..), Response, ResponseF(..), RunClient)
import Servant.Client.Core.RunClient (runRequest)
import System.Random (randomRIO)
import UnliftIO (MonadUnliftIO)
import UnliftIO.Timeout (timeout)
import qualified Unsafe (fromIntegral)

import Morley.Client.Logging (WithClientLog, logDebug)
import Morley.Client.RPC
import qualified Morley.Client.RPC.API as API
import Morley.Micheline (Expression, TezosInt64, TezosNat, unTezosMutez)
import Morley.Tezos.Address (Address)
import Morley.Tezos.Core (ChainId, Mutez, parseChainId)
import Morley.Tezos.Crypto (KeyHash, PublicKey)
import Morley.Util.ByteString (HexJSONByteString)
import Morley.Util.Exception (throwLeft)

----------------
-- RunClient functions
----------------

runRequestAcceptStatusImpl ::
  (WithClientLog env m, MonadIO m, MonadThrow m) =>
  ClientEnv -> Maybe [Status] -> Request -> m Response
runRequestAcceptStatusImpl env _ req = do
  logRequest req
  response <- either throwClientErrorImpl pure =<<
    liftIO (runClientM (runRequest req) env)
  response <$ logResponse response

throwClientErrorImpl :: forall m a . MonadThrow m => ClientError -> m a
throwClientErrorImpl err = case err of
  FailureResponse _ resp
    | 500 <- statusCode (responseStatusCode resp) ->
      handleInternalError (responseBody resp)
  _ -> throwM err
  where
    -- In some cases RPC returns important useful errors as internal ones.
    -- We try to parse the response to a list of 'InternalError'.
    -- If we receive one 'InternalError', we throw it wrapped into
    -- 'ClientInternalError', that's what we observed in most obvious cases.
    -- If we receive more than one, we wrap them into 'UnexpectedInternalErrors'.
    handleInternalError :: LByteString -> m a
    handleInternalError body = case Aeson.decode @[InternalError] body of
      Nothing -> case Aeson.decode @[RunError] body of
        Nothing -> throwM err
        Just runErrs -> throwM $ RunCodeErrors runErrs
      Just [knownErr] -> throwM $ ClientInternalError knownErr
      Just errs -> throwM $ UnexpectedInternalErrors errs

----------------
-- HasTezosRpc functions
----------------

getBlockHashImpl :: (RunClient m, MonadUnliftIO m, MonadCatch m) => BlockId -> m Text
getBlockHashImpl = retryOnceOnTimeout ... API.getBlockHash API.nodeMethods

getCounterImpl :: (RunClient m, MonadUnliftIO m, MonadCatch m) => BlockId -> Address -> m TezosInt64
getCounterImpl = retryOnceOnTimeout ... API.getCounter API.nodeMethods

getBlockHeaderImpl ::
  (RunClient m, MonadUnliftIO m, MonadCatch m) => BlockId -> m BlockHeader
getBlockHeaderImpl = retryOnceOnTimeout ... API.getBlockHeader API.nodeMethods

getBlockConstantsImpl :: (RunClient m, MonadUnliftIO m, MonadCatch m) => BlockId -> m BlockConstants
getBlockConstantsImpl = retryOnceOnTimeout ... API.getBlockConstants API.nodeMethods

getBlockOperationsImpl :: (RunClient m, MonadUnliftIO m, MonadCatch m) => BlockId -> m [[BlockOperation]]
getBlockOperationsImpl =
  retryOnceOnTimeout ... API.getBlockOperations API.nodeMethods

getProtocolParametersImpl ::
  (RunClient m, MonadUnliftIO m, MonadCatch m) => BlockId -> m ProtocolParameters
getProtocolParametersImpl = retryOnceOnTimeout ... API.getProtocolParameters API.nodeMethods

runOperationImpl ::
  (RunClient m, MonadUnliftIO m, MonadCatch m) => BlockId -> RunOperation -> m RunOperationResult
runOperationImpl = retryOnceOnTimeout ... API.runOperation API.nodeMethods

preApplyOperationsImpl ::
  (RunClient m, MonadUnliftIO m, MonadCatch m) =>
  BlockId -> [PreApplyOperation] -> m [RunOperationResult]
preApplyOperationsImpl =
  retryOnceOnTimeout ... API.preApplyOperations API.nodeMethods

forgeOperationImpl ::
  (RunClient m, MonadUnliftIO m, MonadCatch m) =>
  BlockId -> ForgeOperation -> m HexJSONByteString
forgeOperationImpl = retryOnceOnTimeout ... API.forgeOperation API.nodeMethods

injectOperationImpl ::
  (RunClient m, MonadUnliftIO m, MonadCatch m) =>
  HexJSONByteString -> m OperationHash
injectOperationImpl =
  failOnTimeout ... API.injectOperation API.nodeMethods

getContractScriptImpl ::
  (RunClient m, MonadUnliftIO m, MonadCatch m) =>
  BlockId -> Address -> m OriginationScript
getContractScriptImpl = retryOnceOnTimeout ... API.getScript API.nodeMethods

getContractStorageAtBlockImpl ::
  (RunClient m, MonadUnliftIO m, MonadCatch m) =>
  BlockId -> Address -> m Expression
getContractStorageAtBlockImpl =
  retryOnceOnTimeout ... API.getStorageAtBlock API.nodeMethods

getContractBigMapImpl ::
  (RunClient m, MonadUnliftIO m, MonadCatch m) =>
  BlockId -> Address -> GetBigMap -> m GetBigMapResult
getContractBigMapImpl = retryOnceOnTimeout ... API.getBigMap API.nodeMethods

getBigMapValueAtBlockImpl ::
  (RunClient m, MonadUnliftIO m, MonadCatch m) =>
  BlockId -> Natural -> Text -> m Expression
getBigMapValueAtBlockImpl =
  retryOnceOnTimeout ... API.getBigMapValueAtBlock API.nodeMethods

getBigMapValuesAtBlockImpl ::
  (RunClient m, MonadUnliftIO m, MonadCatch m) =>
  BlockId -> Natural -> Maybe Natural -> Maybe Natural -> m Expression
getBigMapValuesAtBlockImpl =
  retryOnceOnTimeout ... API.getBigMapValuesAtBlock API.nodeMethods

getBalanceImpl ::
  (RunClient m, MonadUnliftIO m, MonadCatch m) => BlockId -> Address -> m Mutez
getBalanceImpl =
  retryOnceOnTimeout ... fmap unTezosMutez ... API.getBalance API.nodeMethods

getDelegateImpl ::
  (RunClient m, MonadUnliftIO m, MonadCatch m) => BlockId -> Address -> m (Maybe KeyHash)
getDelegateImpl =
  retryOnceOnTimeout ... API.getDelegate API.nodeMethods

-- | Similar to 'API.getManagerKey', but retries once on timeout.
getManagerKeyImpl ::
  (RunClient m, MonadUnliftIO m, MonadCatch m) => BlockId -> Address -> m (Maybe PublicKey)
getManagerKeyImpl =
  retryOnceOnTimeout ... API.getManagerKey API.nodeMethods

runCodeImpl :: (RunClient m, MonadCatch m) => BlockId -> RunCode -> m RunCodeResult
runCodeImpl = API.runCode API.nodeMethods

getChainIdImpl :: (RunClient m, MonadCatch m) => m ChainId
getChainIdImpl = throwLeft $ parseChainId <$> API.getChainId API.nodeMethods

----------------
-- Logging of requests and responses
----------------

-- | Convert a bytestring to a string assuming this bytestring stores
-- something readable in UTF-8 encoding.
fromBS :: ConvertUtf8 Text bs => bs -> Text
fromBS = decodeUtf8

ppRequestBody :: RequestBody -> Builder
ppRequestBody = build .
  \case
    RequestBodyLBS lbs -> fromBS lbs
    RequestBodyBS bs -> fromBS bs
    RequestBodySource {} -> "<body is not in memory>"

-- | Pretty print a @servant@'s request.
-- Note that we print only some part of t'Request': method, request
-- path and query, request body. We don't print other things that are
-- subjectively less interesting such as HTTP version or media type.
-- But feel free to add them if you want.
ppRequest :: Request -> Builder
ppRequest Request {..} =
  fromBS requestMethod |+ " " +| fromBS (Binary.toLazyByteString requestPath)
  |+ fromBS (renderQuery True $ toList requestQueryString) |+
  maybe mempty (mappend "\n" . ppRequestBody . fst) requestBody

logRequest :: WithClientLog env m => Request -> m ()
logRequest req = logDebug $ "RPC request: " +| ppRequest req |+ ""

-- | Pretty print a @servant@'s response.
-- Note that we print only status and body,
-- the rest looks not so interesting in our case.
--
-- If response is not human-readable text in UTF-8 encoding it will
-- print some garbage.  Apparently we don't make such requests for now.
ppResponse :: Response -> Builder
ppResponse Response {..} =
  statusCode responseStatusCode |+ " " +|
  fromBS (statusMessage responseStatusCode) |+ "\n" +|
  fromBS responseBody  |+ ""

logResponse :: WithClientLog env m => Response -> m ()
logResponse resp = logDebug $ "RPC response: " +| ppResponse resp |+ ""

----------------
-- Timeouts and retries
----------------

data TimeoutError = TimeoutError
  deriving stock Show

instance Buildable TimeoutError where
  build TimeoutError =
    "Timeout for action call was reached. Probably, something is wrong with \
    \testing environment."

instance Exception TimeoutError where
  displayException = pretty

-- | Helper function that retries a monadic action in case action hasn't succeed
-- in 'timeoutInterval'. In case retry didn't help, error that indicates
-- timeout is thrown.
retryOnTimeout :: (MonadUnliftIO m, MonadThrow m) => Bool -> m a -> m a
retryOnTimeout wasRetried action = do
  res <- timeout timeoutInterval action
  maybe (if wasRetried then throwM TimeoutError else retryOnTimeout True action)
    pure res

-- | Helper function that consider action failed in case of timeout,
-- because it's unsafe to perform some of the actions twice. E.g. performing two 'injectOperation'
-- action can lead to a situation when operation is injected twice.
failOnTimeout :: (MonadUnliftIO m, MonadThrow m) => m a -> m a
failOnTimeout = retryOnTimeout True

-- | Helper function that retries action once in case of timeout. If retry ended up with timeout
-- as well, action is considered failed. It's safe to retry read-only actions that don't update chain state
-- or @tezos-client@ config/environment.
retryOnceOnTimeout :: (MonadUnliftIO m, MonadThrow m) => m a -> m a
retryOnceOnTimeout = retryOnTimeout False

-- | Timeout for 'retryOnTimeout', 'retryOnceOnTimeout' and 'failOnTimeout' helpers in microseconds.
timeoutInterval :: Int
timeoutInterval = 120 * 1e6

-- | Wait for a reasonable amount of time before retrying an action that failed
-- due to invalid counter.
-- The waiting time depends on protocol parameters.
waitBeforeRetry :: (MonadIO m, HasTezosRpc m, WithClientLog env m) => m ()
waitBeforeRetry = do
  i <- liftIO $
    (blockAwaitAmounts !!) <$> randomRIO (0, length blockAwaitAmounts - 1)
  logDebug $ "Invalid counter error occurred, retrying the request after " <> show i <> " blocks"
  ProtocolParameters {..} <- getProtocolParameters
  -- Invalid counter error may occur in case we try to perform multiple operations
  -- from the same address. We should try to wait different amount of times before retry
  -- in case there are multiple actions failed with invalid counter error.
  liftIO $ threadDelay $ i * Unsafe.fromIntegral @TezosNat @Int ppMinimalBlockDelay * 1e6
  where
    blockAwaitAmounts :: [Int]
    blockAwaitAmounts = [1..5]

-- | Retry action if it failed due to invalid counter (already used one).
handleInvalidCounterRpc :: MonadThrow m => m a -> ClientRpcError -> m a
handleInvalidCounterRpc retryAction = \case
  ClientInternalError (CounterInThePast {}) -> retryAction
  ClientInternalError (Failure msg)
    | "Counter" `isInfixOf` msg && "already used for contract" `isInfixOf` msg ->
      retryAction
  anotherErr -> throwM anotherErr