packages feed

morley-client-0.2.0: test/Test/Util.hs

-- SPDX-FileCopyrightText: 2021 Oxhead Alpha
-- SPDX-License-Identifier: LicenseRef-MIT-OA

-- | Module with various helpers that are used in morley-client fake tests.
module Test.Util
  ( chainOperationHandlers
  , dumbContractState
  , dumbImplicitContractState
  , dumbManagerKey
  , mapToContractStateBigMap
  , handleGetBigMapValue

    -- * Internals
  , handleRunOperationInternal
  , assertHeadBlockId
  ) where

import Control.Exception.Safe (throwString)
import Control.Lens (at, (?~))
import Data.Aeson (encode)
import Data.ByteArray (ScrubbedBytes)
import Data.ByteString.Lazy qualified as LBS (toStrict)
import Data.Map as Map (elems, insert, lookup, toList)
import Data.Singletons (demote)
import Fmt ((+|), (|+))
import Network.HTTP.Types.Status (status404)
import Network.HTTP.Types.Version (http20)
import Servant.Client.Core
  (BaseUrl(..), ClientError(..), RequestF(..), ResponseF(..), Scheme(..), defaultRequest)
import Text.Hex (encodeHex)

import Lorentz as L (compileLorentz, drop)
import Lorentz.Constraints
import Lorentz.Pack
import Morley.Client.RPC.Types
import Morley.Client.Types
import Morley.Micheline
import Morley.Michelson.Typed
import Morley.Tezos.Address
import Morley.Tezos.Address.Alias (AddressOrAlias(..), Alias(..))
import Morley.Tezos.Core
import Morley.Tezos.Crypto
import Morley.Tezos.Crypto.Ed25519 qualified as Ed25519
import Morley.Util.ByteString
import TestM

-- | Function to convert given map to big map representation
-- used in fake state.
mapToContractStateBigMap
  :: forall k v. (NicePackedValue k, NicePackedValue v)
  => BigMapId k v -> Map k v -> ContractStateBigMap
mapToContractStateBigMap (BigMapId bigMapId) map' = ContractStateBigMap
  { csbmKeyType = toExpression $ demote @(ToT k)
  , csbmValueType = toExpression $ demote @(ToT v)
  , csbmMap = fromList $
    map (bimap (encodeBase58Check . valueToScriptExpr) lEncodeValue) $
    Map.toList map'
  , csbmId = bigMapId
  }

-- | Initial simple contract fake state.
dumbContractState :: ContractState
dumbContractState = ContractState
  { csCounter = 100500
  , csAlias = "genesis2"
  , csContractData = ContractData
      OriginationScript
        { osCode    = toExpression $ compileLorentz L.drop
        , osStorage = toExpression $ toVal ()
        }
      Nothing
  }

dumbImplicitContractState :: ContractState
dumbImplicitContractState = ContractState
  { csCounter = 100500
  , csAlias = "genesis1"
  , csContractData = ImplicitContractData Nothing
  }

-- | Fake handlers used for transaction sending and contract origination.
chainOperationHandlers :: Monad m => Handlers (TestT m)
chainOperationHandlers = defaultHandlers
  { hGetBlockHash = handleGetBlockHash
  , hGetCounter = handleGetCounter
  , hGetBlockConstants = handleGetBlockConstants
  , hGetProtocolParameters = handleGetProtocolParameters
  , hRunOperation = handleRunOperation
  , hPreApplyOperations = mapM . handlePreApplyOperation
  , hForgeOperation = handleForgeOperation
  , hInjectOperation = pure . OperationHash . (<> "_injected") . encodeHex . unHexJSONByteString
  , hGetContractScript = handleGetContractScript
  , hSignBytes =
    \_ _ -> pure . SignatureEd25519 . Ed25519.sign testSecretKey
  , hWaitForOperation = id
  , hGetAlias = handleGetAlias
  , hResolveAddressMaybe = handleResolveAddressMaybe
  , hRememberContract = handleRememberContract
  , hCalcTransferFee = \_ _ _ _ -> pure $ [TezosMutez [tz|100500u|]]
  , hCalcOriginationFee = \_ -> pure $ TezosMutez [tz|100500u|]
  , hGetKeyPassword = \_ -> pure Nothing
  , hGenKey = handleGenKey
  , hGetManagerKey = handleGetManagerKey
  , hRevealKey = handleRevealKey
  }
  where
    testSecretKey :: Ed25519.SecretKey
    testSecretKey = Ed25519.detSecretKey "\001\002\003\004"

mkRunOperationResult :: [Address] -> RunOperationResult
mkRunOperationResult originatedContracts = RunOperationResult
  { rrOperationContents =
    one $ OperationContent $ RunMetadata
      { rmOperationResult = OperationApplied $
        AppliedResult 100500 100500 100500 originatedContracts 0
      , rmInternalOperationResults = []
      }
  }

handleGetBlockHash :: Monad m => BlockId -> TestT m BlockHash
handleGetBlockHash blkId = do
  unless (blkId == FinalHeadId) do
    throwString "Expected `getBlockHash` to be called with `head~2`."

  FakeState{..} <- get
  pure fsFinalHeadBlock

handleGetCounter
  :: ( MonadState FakeState m
     , MonadThrow m
     )
  => BlockId -> Address -> m TezosInt64
handleGetCounter blk addr = do
  assertHeadBlockId blk
  FakeState{..} <- get
  case lookup addr fsContracts of
    Nothing -> throwM $ UnknownContract $ AddressResolved addr
    Just ContractState{..} -> pure $ csCounter

handleGetBlockConstants
  :: MonadState FakeState m
  => BlockId -> m BlockConstants
handleGetBlockConstants blkId = do
  FakeState{..} <- get
  pure $ fsBlockConstants blkId

handleGetProtocolParameters
  :: (MonadState FakeState m, MonadThrow m)
  => BlockId -> m ProtocolParameters
handleGetProtocolParameters blk = do
  assertHeadBlockId blk
  FakeState{..} <- get
  pure $ fsProtocolParameters

handleRunOperation :: Monad m => BlockId -> RunOperation -> TestT m RunOperationResult
handleRunOperation blk RunOperation{..} = do
  assertHeadBlockId blk
  FakeState{..} <- get
  -- Ensure that passed chain id matches with one that fake state has
  unless (roChainId == bcChainId (fsBlockConstants blk)) (throwM $ InvalidChainId)
  -- As of release of the ithaca protocol, the "branch" field should be "head~2".
  -- https://web.archive.org/web/20220305165609/https://tezos.gitlab.io/protocols/tenderbake.html
  unless (roiBranch roOperation == fsFinalHeadBlock) do
    throwM $ InvalidBranch $ roiBranch roOperation
  originatedContracts <- handleRunOperationInternal roOperation
  pure $ mkRunOperationResult originatedContracts

handlePreApplyOperation :: Monad m => BlockId -> PreApplyOperation -> TestT m RunOperationResult
handlePreApplyOperation blk PreApplyOperation{..} = do
  assertHeadBlockId blk
  FakeState{..} <- get
  -- Ensure that passed protocol matches with one that mock state has
  unless (paoProtocol == bcProtocol (fsBlockConstants blk)) $
    throwM InvalidProtocol
  -- As of release of the ithaca protocol, the "branch" field should be "head~2".
  -- https://web.archive.org/web/20220305165609/https://tezos.gitlab.io/protocols/tenderbake.html
  unless (paoBranch == fsFinalHeadBlock) do
    throwM $ InvalidBranch paoBranch
  originatedContracts <- concatMapM handleTransactionOrOrigination paoContents
  pure $ mkRunOperationResult originatedContracts

handleForgeOperation :: Monad m => BlockId -> ForgeOperation -> TestT m HexJSONByteString
handleForgeOperation blkId op = do
  assertHeadBlockId blkId
  ms <- get
  -- As of release of the ithaca protocol, the "branch" field should be "head~2".
  -- https://web.archive.org/web/20220305165609/https://tezos.gitlab.io/protocols/tenderbake.html
  unless (foBranch op == fsFinalHeadBlock ms) do
    throwM $ InvalidBranch $ foBranch op
  pure . HexJSONByteString . LBS.toStrict . encode $ op

handleRunOperationInternal :: Monad m => RunOperationInternal -> TestT m [Address]
handleRunOperationInternal RunOperationInternal{..} = do
  concatMapM handleTransactionOrOrigination roiContents

handleTransactionOrOrigination
  :: Monad m => OperationInput -> TestT m [Address]
handleTransactionOrOrigination op = do
  FakeState{..} <- get
  case wcoCustom op of
    -- Ensure that transaction sender exists
    OpTransfer TransactionOperation{..} -> case lookup codSource fsContracts of
      Nothing -> throwM $ UnknownContract $ AddressResolved codSource
      Just ContractState{..} -> do
        -- Ensure that sender counter matches
        unless (csCounter + 1 == codCounter) (throwM CounterMismatch)
        case lookup toDestination fsContracts of
          Nothing -> throwM $ UnknownContract $ AddressResolved toDestination
          Just _ -> pure []
    -- Ensure that originator exists
    OpOriginate _ -> case lookup codSource fsContracts of
      Nothing -> throwM $ UnknownContract $ AddressResolved codSource
      Just ContractState{..} -> do
        -- Ensure that originator counter matches
        unless (csCounter + 1 == codCounter) (throwM CounterMismatch)
        pure [dummyContractAddr]
      where
        dummyContractAddr = unsafe $ parseAddress "KT1LZwEZqbqtLYhwzaidBp6So9LgYDpkpEv7"
    OpReveal _ ->
      -- We do not care about reveals at the moment
      return []
  where
    CommonOperationData{..} = wcoCommon op

-- | In most places, @morley-client@ executes operations against the @head@ block.
assertHeadBlockId :: (HasCallStack, MonadThrow m) => BlockId -> m ()
assertHeadBlockId blockId = unless (blockId == HeadId) $
  throwString "Accessing non-head block is not supported in tests"

handleGetContractScript
  :: ( MonadState FakeState m
     , MonadThrow m
     )
  => BlockId
  -> Address
  -> m OriginationScript
handleGetContractScript blockId addr = do
  assertHeadBlockId blockId
  FakeState{..} <- get
  case lookup addr fsContracts of
    Nothing -> throwM $ err404 path
    Just ContractState{..} -> case csContractData of
      ImplicitContractData _ -> throwM $ UnexpectedImplicitContract addr
      ContractData script _ -> pure script
  where
    path = "/chains/main/blocks/head/context/contracts/" <> formatAddress addr <> "/script"

handleGetBigMapValue :: Monad m => BlockId -> Natural -> Text -> TestT m Expression
handleGetBigMapValue blockId bigMapId scriptExpr = do
  assertHeadBlockId blockId
  st <- get

  let allBigMaps :: [ContractStateBigMap] =
        catMaybes $
          Map.elems (fsContracts st) <&> \cs -> case (csContractData cs) of
            ContractData _ bigMapMaybe -> bigMapMaybe
            ImplicitContractData _ -> Nothing

  -- Check if a big_map with the given ID exists and, if so, check
  -- whether the giv en key exists.
  case find (\bigMap -> csbmId bigMap == bigMapId) allBigMaps of
    Nothing -> throwM $ err404 path
    Just bigMap ->
      case lookup scriptExpr (csbmMap bigMap ) of
        Nothing -> throwM $ err404 path
        Just serializedValue -> pure $ decodeExpression serializedValue
  where
    path = "/chains/main/blocks/head/context/big_maps/" <> show bigMapId <> "/" <> scriptExpr

handleRememberContract :: Monad m => Bool -> Address -> Alias -> TestT m ()
handleRememberContract replaceExisting addr alias = do
  let
    cs = dumbContractState { csAlias = alias }
    remember addr' cs' FakeState{..} =
      modify $ \s -> s { fsContracts = insert addr' cs' fsContracts }

  st@FakeState{..} <- get
  case lookup addr fsContracts of
    Nothing -> remember addr cs st
    _       -> bool pass (remember addr cs st) replaceExisting

handleGenKey :: Monad m => Alias -> TestT m Address
handleGenKey alias = do
  let
    addr = detGenKeyAddress (encodeUtf8 $ unAlias alias)
    newContractState = dumbImplicitContractState { csAlias =  alias }
  modify $ \s ->
    s & fsContractsL . at addr ?~ newContractState
  pure addr

handleGetAlias :: Monad m => AddressOrAlias -> TestT m Alias
handleGetAlias = \case
  AddressAlias alias -> pure alias
  AddressResolved addr -> do
    FakeState{..} <- get
    case lookup addr fsContracts of
      Nothing                -> throwM $ UnknownContract $ AddressResolved addr
      Just ContractState{..} -> pure $ csAlias

handleGetManagerKey :: (Monad m) => BlockId -> Address -> TestT m (Maybe PublicKey)
handleGetManagerKey blk addr = do
  assertHeadBlockId blk
  s <- get
  let mbCs = s ^. fsContractsL . at addr
  case mbCs of
    Just ContractState{..} -> case csContractData of
      ImplicitContractData mbManagerKey -> pure mbManagerKey
      ContractData _ _ -> throwString "Only implicit account can have a manager key"
    Nothing -> throwM $ UnknownContract $ AddressResolved addr

-- In scenarios where the system under test checks for 404 errors, we
-- use this function to fake and simulate those errors.
err404 :: Text -> ClientError
err404 path = FailureResponse
  (defaultRequest { requestBody = Nothing, requestPath = (baseUrl , "") })
  response
  where
    baseUrl = BaseUrl
      { baseUrlScheme = Http
      , baseUrlHost = "localhost"
      , baseUrlPort = 8732
      , baseUrlPath = toString path
      }
    response = Response
      { responseStatusCode = status404
      , responseHeaders = mempty
      , responseHttpVersion = http20
      , responseBody = "Contract with given address not found"
      }

handleResolveAddressMaybe :: Monad m => AddressOrAlias -> TestT m (Maybe Address)
handleResolveAddressMaybe = \case
  AddressResolved addr -> pure (Just addr)
  AddressAlias alias -> do
    FakeState{..} <- get
    case find checkAlias $ Map.toList fsContracts of
      Just (addr, _) -> pure (Just addr)
      Nothing -> pure Nothing
    where
      checkAlias (_, ContractState { csAlias = alias' }) = alias == alias'

handleRevealKey :: Monad m => Alias -> Maybe ScrubbedBytes -> TestT m ()
handleRevealKey alias _ = do
  contracts <- gets (Map.toList . fsContracts)
  let contracts' = filter (\(_, ContractState{..}) -> csAlias == alias) contracts
  case contracts' of
    []  -> throwM $ UnknownContract $ AddressAlias alias
    [(addr, cs@ContractState{..})] ->
      case (addr, csContractData) of
        (ContractAddress _, ContractData _ _) ->
          throwM $ CantRevealContract addr
        (KeyAddress _, ImplicitContractData (Just _)) ->
          throwM $ AlreadyRevealed addr
        (KeyAddress _, ImplicitContractData Nothing) ->
            -- We don't care about the public key itself, but only its presence.
            let newContractState = cs { csContractData = ImplicitContractData $ Just dumbManagerKey }
            in modify $ \s -> s & fsContractsL . at addr ?~ newContractState
        _ -> error "Inconsitent fake state. This most likely a bug in tests."
    _   ->
      error $ "Multiple contracts have alias '" +| alias |+
        "'. This is most likely a bug in tests."

-- | Dummy public key used in fake tests.
dumbManagerKey :: PublicKey
dumbManagerKey = fromRight (error "impossible") $ parsePublicKey
  "edpkuwTWKgQNnhR5v17H2DYHbfcxYepARyrPGbf1tbMoGQAj8Ljr3V"