packages feed

morley-client-0.3.2: 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
  , mapToContractStateBigMap
  , handleGetBigMapValue
  , genesisState
  , revealKeyState
  , addrAndAliasFromGenesisState

    -- * Internals
  , handleRunOperationInternal
  , assertHeadBlockId
  ) where

import Control.Exception.Safe (throwString)
import Control.Lens (at, (?=), (?~))
import Data.Aeson (encode)
import Data.ByteString.Lazy qualified as LBS (toStrict)
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 (toAddress)
import Lorentz as L (compileLorentz, drop)
import Lorentz.Constraints
import Lorentz.Pack
import Morley.Client.RPC.Types
import Morley.Client.TezosClient (AliasBehavior(..), TezosClientError(DuplicateAlias))
import Morley.Client.Types
import Morley.Client.Types.AliasesAndAddresses
import Morley.Micheline
import Morley.Michelson.Runtime.GState
import Morley.Michelson.Typed
import Morley.Tezos.Address
import Morley.Tezos.Address.Alias
import Morley.Tezos.Address.Kinds
import Morley.Tezos.Crypto
import Morley.Tezos.Crypto.Ed25519 qualified as Ed25519
import Morley.Util.ByteString
import Morley.Util.Constrained
import Morley.Util.Peano
import Morley.Util.SizedList qualified as Sized
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) $ toPairs map'
  , csbmId = bigMapId
  }

-- | Initial simple contract fake state.
dumbContractState :: AccountState 'AddressKindContract
dumbContractState = AccountState
  { asCounter = 100500
  , asAlias = "genesis2"
  , asAccountData = ContractData
      OriginationScript
        { osCode    = toExpression $ compileLorentz L.drop
        , osStorage = toExpression $ toVal ()
        }
      Nothing
  }

dumbImplicitState :: PublicKey -> (ImplicitAddress, AccountState 'AddressKindImplicit)
dumbImplicitState pk = (mkKeyAddress pk, AccountState
  { asCounter = 100500
  , asAlias = "genesis1"
  , asAccountData = ImplicitData pk 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
  , hGetAliasesAndAddresses = handleGetAliasesAndAddresses
  , hRememberContract = handleRememberContract
  , hGetKeyPassword = \_ -> pure Nothing
  , hGenKey = handleGenKey
  , hGetManagerKey = handleGetManagerKey
  , hGetPublicKey = handleGetPublicKey
  }
  where
    testSecretKey :: Ed25519.SecretKey
    testSecretKey = Ed25519.detSecretKey "\001\002\003\004"

mkRunOperationResult :: NonEmpty (OperationResp WithSource, AppliedResult) -> RunOperationResult
mkRunOperationResult results = RunOperationResult
  { rrOperationContents = results <&> \(op, ar) ->
      OperationContent op $ RunMetadata
        { rmOperationResult = OperationApplied ar
        , rmInternalOperationResults = []
        }
  }

mkAppliedResult :: [ContractAddress] -> AppliedResult
mkAppliedResult originatedContracts = AppliedResult 100500 100500 100500 originatedContracts 0

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 -> ImplicitAddress -> m TezosInt64
handleGetCounter blk addr = do
  assertHeadBlockId blk
  use (fsImplicitsL . at addr) >>= \case
    Nothing -> throwM $ UnknownAccount $ Constrained addr
    Just AccountState{..} -> pure $ asCounter

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
  mkRunOperationResult <$> handleRunOperationInternal roOperation

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
  results <- handleOperationInput paoContents
  pure $ mkRunOperationResult results

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 (NonEmpty (OperationResp WithSource, AppliedResult))
handleRunOperationInternal RunOperationInternal{roiContents} = handleOperationInput roiContents

handleOperationInput
  :: Monad m
  => NonEmpty OperationInput
  -> TestT m (NonEmpty (OperationResp WithSource, AppliedResult))
handleOperationInput (c :| cs) =
  (:|) <$> go c 1 <*> zipWithM go cs [2, 3..]
  where go op = fmap ((toIntOpData op, ) . mkAppliedResult) . handleTransactionOrOrigination op

toIntOpData :: OperationInput -> OperationResp WithSource
toIntOpData (WithCommonOperationData CommonOperationData{..} op) = case op of
  OpTransfer op' -> ws TransactionOpResp op'
  OpTransferTicket op' -> ws TransferTicketOpResp op'
  OpOriginate op' -> ws OriginationOpResp op'
  OpReveal op' -> ws RevealOpResp op'
    -- reveal operation apparently can't be internal, but why not handle this anyway
  OpDelegation op' -> ws DelegationOpResp op'
  where
    ws :: (WithSource a -> r) -> a -> r
    ws f = f . WithSource (toAddress codSource)

handleTransactionOrOrigination
  :: forall m. (Monad m, HasCallStack) => OperationInput -> TezosInt64 -> TestT m [ContractAddress]
handleTransactionOrOrigination op n = do
  FakeState{..} <- get
  AccountState{..} <-
    maybe (throwM $ UnknownAccount $ Constrained codSource) pure $ fsImplicits ^. at codSource
  unless ((asCounter + n) == codCounter) $ throwM CounterMismatch
  let checkContractExists :: Address -> TestT m ()
      checkContractExists dest'@(MkAddress dest) = case dest of
        ContractAddress{} -> when (isNothing $ fsContracts ^. at dest) $
          throwM $ UnknownAccount dest'
        ImplicitAddress{} -> when (isNothing $ fsImplicits ^. at dest) $
          throwM $ UnknownAccount dest'
        SmartRollupAddress{} -> error "smart rollup unsupported"
  case wcoCustom op of
    OpTransfer TransactionOperation{toDestination} -> do
      checkContractExists toDestination
      pure []
    OpTransferTicket TransferTicketOperation{ttoDestination} -> do
      checkContractExists ttoDestination
      pure []
    OpOriginate{} ->
      pure [[ta|KT1LZwEZqbqtLYhwzaidBp6So9LgYDpkpEv7|]]
    OpReveal RevealOperation{..} -> do
      let addr = mkKeyAddress roPublicKey
      modify $ fsImplicitsL . at addr %~ fmap revealKeyState
      return []
    OpDelegation _ ->
      -- We do not care about delegations 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
  -> ContractAddress
  -> m OriginationScript
handleGetContractScript blockId addr = do
  assertHeadBlockId blockId
  use (fsContractsL . at addr) >>= \case
    Nothing -> throwM $ err404 path
    Just AccountState{..} -> case asAccountData of
      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 $
          toList (fsContracts st) <&> \cs -> case (asAccountData cs) of
            ContractData _ bigMapMaybe -> bigMapMaybe

  -- 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 csbmMap bigMap ^. at scriptExpr 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 => AliasBehavior -> ContractAddress -> ContractAlias -> TestT m ()
handleRememberContract DontSaveAlias _ _ = pass
handleRememberContract replaceExisting addr alias = do
  let
    cs = dumbContractState { asAlias = alias }
    remember addr' cs' = fsContractsL . at addr' ?= cs'

  use (fsContractsL . at addr) >>= \case
    Nothing -> remember addr cs
    _       -> case replaceExisting of
      KeepDuplicateAlias -> pass
      OverwriteDuplicateAlias -> remember addr cs
      ForbidDuplicateAlias -> throwM $ DuplicateAlias $ unAlias alias

handleGenKey :: Monad m => ImplicitAlias -> TestT m ImplicitAddressWithAlias
handleGenKey alias = do
  let
    pk = toPublic . detSecretKey . encodeUtf8 $ unAlias alias
    (addr, newAccountState) = dumbImplicitState pk
  modify $ fsImplicitsL . at addr ?~ newAccountState{ asAlias = alias }
  pure $ AddressWithAlias addr alias

handleGetAliasesAndAddresses
  :: forall m. Monad m
  => TestT m AliasesAndAddresses
handleGetAliasesAndAddresses = do
  FakeState{fsContracts, fsImplicits} <- get
  pure $ mkAliasesAndAddresses $ convert fsContracts <> convert fsImplicits
  where
    convert
      :: Map (KindedAddress kind) (AccountState kind)
      -> [Constrained NullConstraint AddressWithAlias]
    convert m = toPairs m <&> \(awaAddress, AccountState{asAlias=awaAlias}) ->
      Constrained AddressWithAlias{..}

handleGetManagerKey :: (Monad m) => BlockId -> ImplicitAddress -> TestT m (Maybe PublicKey)
handleGetManagerKey blk addr = do
  assertHeadBlockId blk
  s <- get
  let mbCs = s ^. fsImplicitsL . at addr
  case mbCs of
    Just AccountState{..} -> pure $ idManagerKey asAccountData
    Nothing -> throwM $ UnknownAccount $ Constrained 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"
      }

handleGetPublicKey :: Monad m => ImplicitAddressWithAlias -> TestT m PublicKey
handleGetPublicKey aaa@(AddressWithAlias addr alias) = do
  accounts <- gets (toPairs . fsImplicits)
  let accounts' = filter check accounts
      check (addr', AccountState{..}) =  addr' == addr && asAlias == alias
  case accounts' of
    []  -> throwM $ UnknownAccount $ Constrained addr
    [(_, AccountState{..})] -> pure $ idPublicKey asAccountData
    _   ->
      error $ "Multiple accounts found for '" +| aaa |+
        "'. This is most likely a bug in tests."

genesisPk :: forall n. (SingIPeano n, ToPeano 10 > ToPeano n ~ 'True) => PublicKey
genesisPk = toPublic $ Sized.index @n genesisSecrets

genesisState
  :: forall n. (SingIPeano n, ToPeano 10 > ToPeano n ~ 'True)
  => (ImplicitAddress, AccountState 'AddressKindImplicit)
genesisState = dumbImplicitState $ genesisPk @n

addrAndAliasFromGenesisState
  :: forall n. (SingIPeano n, ToPeano 10 > ToPeano n ~ 'True)
  => ImplicitAddressWithAlias
addrAndAliasFromGenesisState =
  let (addr, AccountState{asAlias}) = genesisState @n
  in AddressWithAlias addr asAlias

revealKeyState :: AccountState 'AddressKindImplicit -> AccountState 'AddressKindImplicit
revealKeyState = asAccountDataL %~ \x@ImplicitData{..} -> x{ idManagerKey = Just idPublicKey }