packages feed

morley-client-0.4.0: src/Morley/Client/TezosClient/Impl.hs

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

{-# OPTIONS_GHC -Wno-orphans #-}

-- | Interface to the @octez-client@ executable expressed in Haskell types.
module Morley.Client.TezosClient.Impl
  ( TezosClientError (..)

  -- * @octez-client@ api
  , signBytes
  , rememberContract
  , importKey
  , genKey
  , genFreshKey
  , revealKey
  , revealKeyUnlessRevealed
  , ResolveError(..)
  , Resolve(..)
  , resolveAddress
  , resolveAddressMaybe
  , getAlias
  , getAliasMaybe
  , getPublicKey
  , getSecretKey
  , getTezosClientConfig
  , calcTransferFee
  , calcOriginationFee
  , calcRevealFee
  , getKeyPassword
  , registerDelegate
  , getAliasesAndAddresses
  , resolveAddressWithAlias
  , resolveAddressWithAliasMaybe

    -- * For tests
  , lookupAliasCache
  ) where

import Data.Aeson (encode)
import Data.ByteArray (ScrubbedBytes)
import Data.ByteString.Lazy.Char8 qualified as C (unpack)
import Data.Constraint ((\\))
import Data.Text qualified as T
import Fmt (pretty, (+|), (|+))
import Text.Printf (printf)
import UnliftIO.IO (hGetEcho, hSetEcho)

import Lorentz.Value
import Morley.Client.App
import Morley.Client.Logging
import Morley.Client.RPC.Getters (getManagerKey)
import Morley.Client.TezosClient.Class qualified as Class
import Morley.Client.TezosClient.Config
import Morley.Client.TezosClient.Helpers
import Morley.Client.TezosClient.Parser
import Morley.Client.TezosClient.Resolve
import Morley.Client.TezosClient.Types
import Morley.Client.TezosClient.Types.Errors
import Morley.Client.TezosClient.Types.MorleyClientM
import Morley.Client.Types
import Morley.Client.Types.AliasesAndAddresses
import Morley.Client.Util (readScrubbedBytes)
import Morley.Micheline
import Morley.Michelson.Typed.Scope
import Morley.Tezos.Address
import Morley.Tezos.Address.Alias
import Morley.Tezos.Crypto
import Morley.Tezos.Crypto.Ed25519 qualified as Ed25519
import Morley.Util.Constrained
import Morley.Util.Peano
import Morley.Util.SizedList.Types

-- Note: if we try to sign with an unknown alias, @octez-client@ will
-- report a fatal error (assert failure) to stdout. It's bad. It's
-- reported in two issues: https://gitlab.com/tezos/tezos/-/issues/653
-- and https://gitlab.com/tezos/tezos/-/issues/813.
-- I (@gromak) currently think it's better to wait for it to be resolved upstream.
-- Currently we will throw 'TezosClientUnexpectedOutputFormat' error.
-- | Sign an arbtrary bytestring using @octez-client@.
-- Secret key of the address corresponding to give 'AddressOrAlias' must be known.
signBytes
  :: ImplicitAlias
  -> Maybe ScrubbedBytes
  -> ByteString
  -> MorleyClientM Signature
signBytes signerAlias mbPassword opHash = do
  logDebug $ "Signing for " <> pretty signerAlias
  output <- callTezosClientStrict
    ["sign", "bytes", toCmdArg opHash, "for", toCmdArg signerAlias] MockupMode mbPassword
  liftIO case T.stripPrefix "Signature: " output of
    Nothing ->
      -- There is additional noise in the stdout in case key is password protected
      case T.stripPrefix "Enter password for encrypted key: Signature: " output of
        Nothing -> throwM $ TezosClientUnexpectedSignatureOutput output
        Just signatureTxt -> txtToSignature signatureTxt
    Just signatureTxt -> txtToSignature signatureTxt
  where
    txtToSignature :: MonadCatch m => Text -> m Signature
    txtToSignature signatureTxt = either (throwM . TezosClientCryptoParseError signatureTxt) pure $
      parseSignature . T.strip $ signatureTxt

-- | Generate a new secret key and save it with given alias.
-- If an address with given alias already exists, it will be returned
-- and no state will be changed.
genKey :: ImplicitAlias -> MorleyClientM ImplicitAddress
genKey name = do
  lookupAliasCache name >>= \case
    Just addr -> pure addr
    Nothing -> do
      let
        isAlreadyExistsError :: Text -> Bool
        -- We can do a bit better here using more complex parsing if necessary.
        isAlreadyExistsError = T.isInfixOf "already exists."

        errHandler _ errOut = pure (isAlreadyExistsError errOut)

      _ <-
        callTezosClient errHandler
        ["gen", "keys", toCmdArg name] MockupMode Nothing
      invalidateAliasCache
      resolveAddress name

-- | Generate a new secret key and save it with given alias.
-- If an address with given alias already exists, it will be removed
-- and replaced with a fresh one.
genFreshKey :: ImplicitAlias -> MorleyClientM ImplicitAddress
genFreshKey name = do
  let
    isNoAliasError :: Text -> Bool
    -- We can do a bit better here using more complex parsing if necessary.
    isNoAliasError = T.isInfixOf "no public key hash alias named"

    errHandler _ errOutput = pure (isNoAliasError errOutput)

  _ <-
    callTezosClient errHandler
    ["forget", "address", toCmdArg name, "--force"] MockupMode Nothing
  callTezosClientStrict ["gen", "keys", toCmdArg name] MockupMode Nothing
  invalidateAliasCache
  resolveAddress name

-- | Reveal public key corresponding to the given alias.
-- Fails if it's already revealed.
revealKey :: ImplicitAlias -> Maybe ScrubbedBytes -> MorleyClientM ()
revealKey alias mbPassword = do
  logDebug $ "Revealing key for " +| alias |+ ""
  let
    alreadyRevealed = T.isInfixOf "previously revealed"
    revealedImplicitAccount = T.isInfixOf "only implicit accounts can be revealed"
    emptyImplicitContract = T.isInfixOf "Empty implicit contract"
    errHandler _ errOut =
      False <$ do
        when (alreadyRevealed errOut) (throwM (AlreadyRevealed alias))
        when (revealedImplicitAccount errOut) (throwM (CantRevealContract alias))
        when (emptyImplicitContract errOut) (throwM (EmptyImplicitContract alias))

  _ <-
    callTezosClient errHandler
    ["reveal", "key", "for", toCmdArg alias] ClientMode mbPassword

  logDebug $ "Successfully revealed key for " +| alias |+ ""

-- | Reveal key for implicit address if necessary.
revealKeyUnlessRevealed :: ImplicitAddressWithAlias -> Maybe ScrubbedBytes -> MorleyClientM ()
revealKeyUnlessRevealed (AddressWithAlias addr alias) mbPassword = do
  mbManagerKey <- getManagerKey addr
  case mbManagerKey of
    Nothing -> revealKey alias mbPassword
    Just _  -> logDebug $ alias |+ " alias has already revealed key"

-- | Register alias as delegate
registerDelegate :: ImplicitAlias -> Maybe ScrubbedBytes -> MorleyClientM ()
registerDelegate alias mbPassword = do
  logDebug $ "Registering " +| alias |+ " as delegate"
  let
    emptyImplicitContract = T.isInfixOf "Empty implicit contract"
    errHandler _ errOut =
      False <$ do
        when (emptyImplicitContract errOut) (throwM (EmptyImplicitContract alias))

  _ <-
    callTezosClient errHandler
    ["register", "key", toCmdArg alias, "as", "delegate"] ClientMode mbPassword

  logDebug $ "Successfully registered " +| alias |+ " as delegate"

-- | Call @octez-client@ to list known addresses or contracts
callListKnown :: String -> MorleyClientM Text
callListKnown objects =
  callTezosClientStrict ["list", "known", objects] MockupMode Nothing

-- | Return 'PublicKey' corresponding to given 'AddressOrAlias'.
getPublicKey
  :: ImplicitAlias
  -> MorleyClientM PublicKey
getPublicKey alias = do
  logDebug $ "Getting " +| alias |+ " public key"
  output <- callTezosClientStrict ["show", "address", toCmdArg alias] MockupMode Nothing
  liftIO case lines output of
    _ : [rawPK] -> do
      pkText <- maybe
        (throwM $ TezosClientUnexpectedOutputFormat rawPK) pure
        (T.stripPrefix "Public Key: " rawPK)
      either (throwM . TezosClientCryptoParseError pkText) pure $
        parsePublicKey pkText
    _ -> throwM $ TezosClientUnexpectedOutputFormat output

-- | Return 'SecretKey' corresponding to given 'AddressOrAlias'.
getSecretKey
  :: ImplicitAlias
  -> MorleyClientM SecretKey
getSecretKey alias = do
  logDebug $ "Getting " +| alias |+ " secret key"
  output <- callTezosClientStrict ["show", "address", toCmdArg alias, "--show-secret"] MockupMode Nothing
  liftIO case lines output of
    _ : _ : [rawSK] -> do
      skText <- maybe
        (throwM $ TezosClientUnexpectedOutputFormat rawSK) pure
        (T.stripPrefix "Secret Key: " rawSK)
      either (throwM . TezosClientCryptoParseError skText) pure $
        parseSecretKey skText
    _ -> throwM $ TezosClientUnexpectedOutputFormat output

-- | Save a contract with given address and alias.
-- If @replaceExisting@ is @False@ and a contract with given alias
-- already exists, this function does nothing.
rememberContract
  :: AliasBehavior
  -> ContractAddress
  -> ContractAlias
  -> MorleyClientM ()
rememberContract aliasBehavior address alias = do
  case aliasBehavior of
    DontSaveAlias ->
      logInfo $ "Not saving " +| address |+ " as " +| alias |+ " as requested"
    OverwriteDuplicateAlias -> do
      void $ callTezosClientStrict (args <> ["--force"]) MockupMode Nothing
      tryUpdateAliasCache alias address
    _ -> do
      lookupAliasCache alias >>= \case
        Nothing -> do
          let errHandler _ errOut
                | isAlreadyExistsError errOut = case aliasBehavior of
                    KeepDuplicateAlias -> pure True
                    ForbidDuplicateAlias -> throwM $ DuplicateAlias $ unAlias alias
                | otherwise = pure False
          void $ callTezosClient errHandler args MockupMode Nothing
          tryUpdateAliasCache alias address
        Just{} -> case aliasBehavior of
          KeepDuplicateAlias -> pass
          ForbidDuplicateAlias -> throwM $ DuplicateAlias $ unAlias alias
  where
    args = ["remember", "contract", toCmdArg alias, pretty address]
    isAlreadyExistsError = T.isInfixOf "already exists"

importKey
  :: Bool
  -> ImplicitAlias
  -> SecretKey
  -> MorleyClientM ImplicitAddressWithAlias
importKey replaceExisting name key = do
  let isAlreadyExistsError = T.isInfixOf "already exists"
      errHandler _ errOut
        | isAlreadyExistsError errOut = throwM $ DuplicateAlias $ unAlias name
        | otherwise = pure False
      args = ["import", "secret", "key", toCmdArg name, toCmdArg key]
  void $ callTezosClient errHandler
    (if replaceExisting then args <> ["--force"] else args)
    MockupMode Nothing
  let addr = mkKeyAddress (toPublic key)
  tryUpdateAliasCache name addr
  pure $ AddressWithAlias addr name

-- | Calc baker fee for transfer using @octez-client@.
calcTransferFee
  :: AddressOrAlias kind
  -> Maybe ScrubbedBytes
  -> TezosInt64
  -> [CalcTransferFeeData]
  -> MorleyClientM [TezosMutez]
calcTransferFee from mbPassword burnCap transferFeeDatas = do
  output <- callTezosClientStrict
    [ "multiple", "transfers", "from", pretty from, "using"
    , C.unpack $ encode transferFeeDatas, "--burn-cap", showBurnCap burnCap, "--dry-run"
    ] ClientMode mbPassword
  withSomePeano (length transferFeeDatas) $
    \(_ :: Proxy n) -> toList <$> feeOutputParser @n output

-- | Calc baker fee for origination using @octez-client@.
calcOriginationFee :: UntypedValScope st => CalcOriginationFeeData cp st -> MorleyClientM TezosMutez
calcOriginationFee CalcOriginationFeeData{..} = do
  output <- callTezosClientStrict
    [ "originate", "contract", "-", "transferring"
    , showTez cofdBalance
    , "from", pretty cofdFrom, "running"
    , toCmdArg cofdContract, "--init"
    , toCmdArg cofdStorage, "--burn-cap"
    , showBurnCap cofdBurnCap, "--dry-run"
    ] ClientMode cofdMbFromPassword
  fees <- feeOutputParser @1 output
  case fees of
    singleFee :< Nil -> return singleFee

-- | Calc baker fee for revealing using @octez-client@.
--
-- Note that @octez-client@ does not support passing an address here,
-- at least at the moment of writing.
calcRevealFee :: ImplicitAlias -> Maybe ScrubbedBytes -> TezosInt64 -> MorleyClientM TezosMutez
calcRevealFee alias mbPassword burnCap = do
  output <- callTezosClientStrict
    [ "reveal", "key", "for", toCmdArg alias
    , "--burn-cap", showBurnCap burnCap
    , "--dry-run"
    ] ClientMode mbPassword
  fees <- feeOutputParser @1 output
  case fees of
    singleFee :< Nil -> return singleFee

feeOutputParser :: forall n. (SingIPeano n) => Text -> MorleyClientM (SizedList n TezosMutez)
feeOutputParser output =
  case parseBakerFeeFromOutput @n output of
    Right fee -> return fee
    Left err -> throwM $ TezosClientParseFeeError output $ pretty err

showBurnCap :: TezosInt64 -> String
showBurnCap x = printf "%.6f" $ (fromIntegralToRealFrac @TezosInt64 @Float x) / 1000

showTez :: TezosMutez -> String
showTez = toCmdArg . unTezosMutez

-- | Get password for secret key associated with given address
-- in case this key is password-protected
getKeyPassword :: ImplicitAlias -> MorleyClientM (Maybe ScrubbedBytes)
getKeyPassword alias = do
  output <- callTezosClientStrict [ "show", "address", pretty alias, "-S"] MockupMode Nothing
  encryptionType <-
    case parseSecretKeyEncryption output of
      Right t -> return t
      Left err -> throwM $ TezosClientParseEncryptionTypeError output $ pretty err
  case encryptionType of
    EncryptedKey -> do
      putTextLn $ "Please enter password for '" <> pretty alias <> "':"
      Just <$> withoutEcho readScrubbedBytes
    _ -> pure Nothing

  where
    -- Hide entered password
    withoutEcho :: (MonadIO m, MonadMask m) => m a -> m a
    withoutEcho action = do
      old <- hGetEcho stdin
      bracket_ (hSetEcho stdin False) (hSetEcho stdin old) action

{- | Calls @octez-client list known contracts@ and returns
a list of @(alias, address)@ pairs.

Note that an alias can be ambiguous: it can refer to __both__ a contract and an implicit account.
When an alias "abc" is ambiguous, the list will contain two entries:

> ("abc", "KT1...")
> ("key:abc", "tz1...")
-}
getAliasesAndAddresses :: MorleyClientM AliasesAndAddresses
getAliasesAndAddresses = do
  env <- ask
  let mv = env ^. mceTezosClientL . tceAliasMapL
  updateMVar' mv $ get >>= \case
    Nothing -> do
      res <- lift $ parseOutput =<< runMorleyClientM env (callListKnown "contracts")
      put $ Just res
      pure res
    Just a -> pure a
  where
    parseOutput :: Text -> IO AliasesAndAddresses
    parseOutput out = mkAliasesAndAddresses <$> mapM parseLine (lines out)

    -- Note: each line has the format "<alias>: <address>"
    parseLine :: Text -> IO (Constrained NullConstraint AddressWithAlias)
    parseLine ln = do
      let (aliasText', addressText) = first (T.dropEnd 2) $ T.breakOnEnd ": " ln
          -- This alias _might_ belong to both an implicit account and a contract,
          -- in which case it might be prefixed with "key".
          -- If so, we have to strip the prefix.
          aliasText = fromMaybe aliasText' $ T.stripPrefix "key:" aliasText'
      Constrained awaAddress <- parseL1Address addressText
      let awaAlias = mkAlias aliasText \\ addressKindSanity awaAddress
      pure . Constrained $ AddressWithAlias{..}

    parseL1Address :: Text -> IO L1Address
    parseL1Address addrText
      = either (throwM . TezosClientParseAddressError addrText) pure
      . parseConstrainedAddress $ addrText

invalidateAliasCache :: MorleyClientM ()
invalidateAliasCache = do
  mv <- view $ tezosClientEnvL . tceAliasMapL
  updateMVar' mv $ put Nothing

tryUpdateAliasCache :: Alias kind -> KindedAddress kind -> MorleyClientM ()
tryUpdateAliasCache alias addr = do
  mv <- view $ tezosClientEnvL . tceAliasMapL
  updateMVar' mv $ modify' $ fmap $ insertAliasAndAddress alias addr

lookupAliasCache :: Alias kind -> MorleyClientM (Maybe (KindedAddress kind))
lookupAliasCache alias = do
  mv <- view $ tezosClientEnvL . tceAliasMapL
  (lookupAddr alias =<<) <$> readMVar mv

instance Class.HasTezosClient MorleyClientM where
  signBytes (AddressWithAlias _ senderAlias) mbPassword opHash = retryOnceOnTimeout $ do
    env <- ask
    case mceSecretKey env of
      Just sk -> pure . SignatureEd25519 $ Ed25519.sign sk opHash
      Nothing -> signBytes senderAlias mbPassword opHash
  rememberContract = failOnTimeout ... rememberContract
  getAliasesAndAddresses = retryOnceOnTimeout ... getAliasesAndAddresses
  genKey alias = flip AddressWithAlias alias <$> genKey alias
  genFreshKey alias = flip AddressWithAlias alias <$> genFreshKey alias
  getKeyPassword (AddressWithAlias _ alias) = retryOnceOnTimeout $ getKeyPassword alias
  getPublicKey (AddressWithAlias _ alias) = getPublicKey alias