restructure agora-scripts modules

In order to allow reusing this code in the future, I've made it so that
Agora-specific code all lives in `Main`, and everything else lives in
`ScriptExport` or other non-Agora modules.
This commit is contained in:
Emily Martins 2022-06-23 19:54:34 +02:00
parent 7466901875
commit a5765a355d
10 changed files with 345 additions and 267 deletions

View file

@ -1,144 +0,0 @@
{-# LANGUAGE TemplateHaskell #-}
{- | Module : API
Maintainer : emi@haskell.fyi
Description: API for script exporter.
API for script exporter.
-}
module API (
AgoraScripts (..),
ScriptParams (..),
API,
agoraScripts,
runServer,
) where
import Agora.AuthorityToken (AuthorityToken (..), authorityTokenPolicy)
import Agora.Governor (Governor (..))
import Agora.Governor qualified as Governor
import Agora.Governor.Scripts (authorityTokenFromGovernor, authorityTokenSymbolFromGovernor, governorPolicy, governorValidator, proposalFromGovernor, stakeFromGovernor)
import Agora.Proposal (Proposal (..))
import Agora.Proposal.Scripts (proposalPolicy, proposalValidator)
import Agora.ScriptInfo (mkPolicyInfo, mkValidatorInfo)
import Agora.Stake (Stake (..))
import Agora.Stake.Scripts (stakePolicy, stakeValidator)
import Agora.Treasury (treasuryValidator)
import Codec.Serialise.Orphans ()
import Data.Aeson qualified as Aeson
import Data.Cache.Cached (cachedFor)
import Data.Default.Class (Default (def))
import Data.Function ((&))
import Data.Proxy (Proxy (Proxy))
import Development.GitRev (gitBranch, gitHash)
import Network.HTTP.Types qualified as Http
import Network.Wai qualified as Wai
import Network.Wai.Handler.Warp qualified as Warp
import Network.Wai.Middleware.Cors (CorsResourcePolicy (corsRequestHeaders), cors, simpleCorsResourcePolicy)
import Options (Options (..))
import Plutarch.Api.V1 (mintingPolicySymbol, mkMintingPolicy)
import PlutusLedgerApi.V1.Value (AssetClass, CurrencySymbol)
import PlutusLedgerApi.V1.Value qualified as Value
import Prettyprinter (defaultLayoutOptions, hsep, layoutPretty, viaShow, (<+>))
import Prettyprinter.Render.String (renderString)
import Servant.API (JSON, Post, ReqBody, (:<|>) (..), type (:>))
import Servant.Server qualified as Servant
import System.Clock (TimeSpec (TimeSpec))
import Text.Printf (printf)
import Types (AgoraScripts (..), Builders, ScriptParams (..), ScriptQuery, insertBuilder, runQuery)
-- | Servant API type for script generation.
type API =
"generate-scripts" :> ReqBody '[JSON] ScriptParams :> Post '[JSON] AgoraScripts
:<|> "query-script" :> ReqBody '[JSON] ScriptQuery :> Post '[JSON] Aeson.Value
-- | Run a Warp server that exposes a script generation endpoint.
runServer :: Options -> IO ()
runServer options = do
let settings =
Warp.defaultSettings
& Warp.setPort options.port
& Warp.setLogger
( \req status _maybeFileSize ->
putStrLn . renderString . layoutPretty defaultLayoutOptions $
hsep
[ "[info]"
, "[" <> "Status:" <+> viaShow (Http.statusCode status) <> "]"
, viaShow $ Wai.requestMethod req
, viaShow $ Wai.rawPathInfo req
]
)
corsPolicy =
simpleCorsResourcePolicy
{ -- NOTE: Webpack dev server requires this for CORS workaround.
corsRequestHeaders = "content-type" : corsRequestHeaders simpleCorsResourcePolicy
}
corsMiddleware = cors . const $ Just corsPolicy
-- Scripts stay cached for five minutes
agoraScripts' <- cachedFor (Just $ TimeSpec 300 0) agoraScripts
query <- cachedFor (Just $ TimeSpec 300 0) (`runQuery` agoraBuilders)
printf "[info] Running 'agora-scripts' on :%d\n" (Warp.getPort settings)
Servant.serve
(Proxy @API)
(agoraScripts' :<|> query)
& corsMiddleware
& Warp.runSettings settings
agoraBuilders :: Builders
agoraBuilders =
def
& insertBuilder "governorPolicy" ((.governorPolicyInfo) . agoraScripts)
& insertBuilder "governorValidator" ((.governorValidatorInfo) . agoraScripts)
& insertBuilder "stakePolicyInfo" ((.stakePolicyInfo) . agoraScripts)
& insertBuilder "stakeValidatorInfo" ((.stakeValidatorInfo) . agoraScripts)
& insertBuilder "proposalPolicyInfo" ((.proposalPolicyInfo) . agoraScripts)
& insertBuilder "proposalValidatorInfo" ((.proposalValidatorInfo) . agoraScripts)
& insertBuilder "treasuryValidatorInfo" ((.treasuryValidatorInfo) . agoraScripts)
& insertBuilder "authorityTokenPolicyInfo" ((.authorityTokenPolicyInfo) . agoraScripts)
-- | Create scripts from params.
agoraScripts :: ScriptParams -> AgoraScripts
agoraScripts params =
AgoraScripts
{ gitRev = revision
, governorPolicyInfo = mkPolicyInfo (governorPolicy governor)
, governorValidatorInfo = mkValidatorInfo (governorValidator governor)
, stakePolicyInfo = mkPolicyInfo (stakePolicy params.gtClassRef)
, stakeValidatorInfo = mkValidatorInfo (stakeValidator stake)
, proposalPolicyInfo = mkPolicyInfo (proposalPolicy governorSTAssetClass)
, proposalValidatorInfo = mkValidatorInfo (proposalValidator proposal)
, treasuryValidatorInfo = mkValidatorInfo (treasuryValidator authorityTokenSymbol)
, authorityTokenPolicyInfo = mkPolicyInfo (authorityTokenPolicy authorityToken)
}
where
-- This encodes the git revision of the server. It's useful for the caller
-- to be able to ensure they are compatible with it.
revision :: String
revision = $(gitBranch) <> "@" <> $(gitHash)
governor :: Governor
governor =
Governor
{ Governor.gstOutRef = params.governorInitialSpend
, Governor.gtClassRef = params.gtClassRef
, Governor.maximumCosigners = params.maximumCosigners
}
authorityToken :: AuthorityToken
authorityToken = authorityTokenFromGovernor governor
authorityTokenSymbol :: CurrencySymbol
authorityTokenSymbol = authorityTokenSymbolFromGovernor governor
governorSTAssetClass :: AssetClass
governorSTAssetClass =
Value.assetClass (mintingPolicySymbol $ mkMintingPolicy $ governorPolicy governor) ""
proposal :: Proposal
proposal = proposalFromGovernor governor
stake :: Stake
stake = stakeFromGovernor governor

View file

@ -9,12 +9,10 @@ Orphan instances for Serialising and Hashing Cardano types.
-}
module Codec.Serialise.Orphans () where
import Codec.Serialise (Serialise, serialise)
import Data.Hashable (Hashable (hashWithSalt))
import Codec.Serialise (Serialise)
import Data.Tagged (Tagged (Tagged))
import PlutusLedgerApi.V1 (TxId, TxOutRef)
import PlutusLedgerApi.V1.Value (AssetClass, CurrencySymbol, TokenName)
import Types (ScriptParams)
deriving anyclass instance
Serialise TxOutRef
@ -34,8 +32,3 @@ deriving anyclass instance
deriving newtype instance
Serialise a =>
Serialise (Tagged s a)
deriving anyclass instance Serialise ScriptParams
instance Hashable ScriptParams where
hashWithSalt s scriptParams = hashWithSalt s (serialise scriptParams)

144
agora-scripts/Main.hs Normal file
View file

@ -0,0 +1,144 @@
{-# LANGUAGE TemplateHaskell #-}
{- | Module : Main
Maintainer : emi@haskell.fyi
Description: Export scripts given configuration.
Export scripts given configuration.
-}
module Main (main) where
import Agora.AuthorityToken (AuthorityToken, authorityTokenPolicy)
import Agora.Governor (Governor (Governor))
import Agora.Governor qualified as Governor
import Agora.Governor.Scripts (authorityTokenFromGovernor, authorityTokenSymbolFromGovernor, governorPolicy, governorValidator, proposalFromGovernor, stakeFromGovernor)
import Agora.Proposal (Proposal)
import Agora.Proposal.Scripts (proposalPolicy, proposalValidator)
import Agora.SafeMoney (GTTag)
import Agora.ScriptInfo (PolicyInfo, ValidatorInfo, mkPolicyInfo, mkValidatorInfo)
import Agora.Stake (Stake)
import Agora.Stake.Scripts (stakePolicy, stakeValidator)
import Agora.Treasury (treasuryValidator)
import Data.Aeson qualified as Aeson
import Data.Default (def)
import Data.Function ((&))
import Data.Tagged (Tagged)
import Data.Text (Text)
import Development.GitRev (gitBranch, gitHash)
import GHC.Generics qualified as GHC
import Plutarch.Api.V1 (mintingPolicySymbol, mkMintingPolicy)
import PlutusLedgerApi.V1 (TxOutRef)
import PlutusLedgerApi.V1.Value (AssetClass, CurrencySymbol)
import PlutusLedgerApi.V1.Value qualified as Value
import ScriptExport.API (runServer)
import ScriptExport.Options (parseOptions)
import ScriptExport.Types (Builders, insertBuilder)
main :: IO ()
main =
parseOptions >>= runServer revision builders
where
-- This encodes the git revision of the server. It's useful for the caller
-- to be able to ensure they are compatible with it.
revision :: Text
revision = $(gitBranch) <> "@" <> $(gitHash)
{- | Builders for Agora scripts.
@since 0.2.0
-}
builders :: Builders
builders =
def
& insertBuilder "governorPolicy" ((.governorPolicyInfo) . agoraScripts)
& insertBuilder "governorValidator" ((.governorValidatorInfo) . agoraScripts)
& insertBuilder "stakePolicyInfo" ((.stakePolicyInfo) . agoraScripts)
& insertBuilder "stakeValidatorInfo" ((.stakeValidatorInfo) . agoraScripts)
& insertBuilder "proposalPolicyInfo" ((.proposalPolicyInfo) . agoraScripts)
& insertBuilder "proposalValidatorInfo" ((.proposalValidatorInfo) . agoraScripts)
& insertBuilder "treasuryValidatorInfo" ((.treasuryValidatorInfo) . agoraScripts)
& insertBuilder "authorityTokenPolicyInfo" ((.authorityTokenPolicyInfo) . agoraScripts)
{- | Create scripts from params.
@since 0.2.0
-}
agoraScripts :: ScriptParams -> AgoraScripts
agoraScripts params =
AgoraScripts
{ governorPolicyInfo = mkPolicyInfo (governorPolicy governor)
, governorValidatorInfo = mkValidatorInfo (governorValidator governor)
, stakePolicyInfo = mkPolicyInfo (stakePolicy params.gtClassRef)
, stakeValidatorInfo = mkValidatorInfo (stakeValidator stake)
, proposalPolicyInfo = mkPolicyInfo (proposalPolicy governorSTAssetClass)
, proposalValidatorInfo = mkValidatorInfo (proposalValidator proposal)
, treasuryValidatorInfo = mkValidatorInfo (treasuryValidator authorityTokenSymbol)
, authorityTokenPolicyInfo = mkPolicyInfo (authorityTokenPolicy authorityToken)
}
where
governor :: Governor
governor =
Governor
{ Governor.gstOutRef = params.governorInitialSpend
, Governor.gtClassRef = params.gtClassRef
, Governor.maximumCosigners = params.maximumCosigners
}
authorityToken :: AuthorityToken
authorityToken = authorityTokenFromGovernor governor
authorityTokenSymbol :: CurrencySymbol
authorityTokenSymbol = authorityTokenSymbolFromGovernor governor
governorSTAssetClass :: AssetClass
governorSTAssetClass =
Value.assetClass (mintingPolicySymbol $ mkMintingPolicy $ governorPolicy governor) ""
proposal :: Proposal
proposal = proposalFromGovernor governor
stake :: Stake
stake = stakeFromGovernor governor
{- | Params required for creating script export.
@since 0.2.0
-}
data ScriptParams where
ScriptParams ::
{ governorInitialSpend :: TxOutRef
, gtClassRef :: Tagged GTTag AssetClass
, maximumCosigners :: Integer
} ->
ScriptParams
deriving anyclass (Aeson.ToJSON, Aeson.FromJSON)
deriving stock (Show, Eq, GHC.Generic, Ord)
{- | Scripts that get exported.
@since 0.2.0
-}
data AgoraScripts = AgoraScripts
{ governorPolicyInfo :: PolicyInfo
, governorValidatorInfo :: ValidatorInfo
, stakePolicyInfo :: PolicyInfo
, stakeValidatorInfo :: ValidatorInfo
, proposalPolicyInfo :: PolicyInfo
, proposalValidatorInfo :: ValidatorInfo
, treasuryValidatorInfo :: ValidatorInfo
, authorityTokenPolicyInfo :: PolicyInfo
}
deriving anyclass
( -- | @since 0.2.0
Aeson.ToJSON
, -- | @since 0.2.0
Aeson.FromJSON
)
deriving stock
( -- | @since 0.2.0
Show
, -- | @since 0.2.0
Eq
, -- | @since 0.2.0
GHC.Generic
)

View file

@ -0,0 +1,100 @@
{- | Module : ScriptExport.API
Maintainer : emi@haskell.fyi
Description: API for script exporter.
API for script exporter.
-}
module ScriptExport.API (
API,
runServer,
) where
import Codec.Serialise.Orphans ()
import Data.Aeson qualified as Aeson
import Data.Cache.Cached (cachedFor)
import Data.Function ((&))
import Data.Proxy (Proxy (Proxy))
import Data.Text (Text)
import GHC.Generics qualified as GHC
import Network.HTTP.Types qualified as Http
import Network.Wai qualified as Wai
import Network.Wai.Handler.Warp qualified as Warp
import Network.Wai.Middleware.Cors (CorsResourcePolicy (corsRequestHeaders), cors, simpleCorsResourcePolicy)
import Prettyprinter (Pretty (pretty), defaultLayoutOptions, hsep, layoutPretty, viaShow)
import Prettyprinter.Render.String (renderString)
import ScriptExport.Options (Options (..))
import ScriptExport.Types (Builders, ScriptQuery, runQuery)
import ScriptExport.Types qualified as Builders
import Servant.API (Get, JSON, Post, ReqBody, (:<|>) (..), type (:>))
import Servant.Server qualified as Servant
import System.Clock (TimeSpec (TimeSpec))
import Text.Printf (printf)
{- | Servant API type for script generation.
@since 0.2.0
-}
type API =
"query-script" :> ReqBody '[JSON] ScriptQuery :> Post '[JSON] Aeson.Value
:<|> "info" :> Get '[JSON] ServerInfo
{- | Information about the server.
@since 0.2.0
-}
data ServerInfo = ServerInfo
{ revision :: Text
, exposedBuilders :: [Text]
}
deriving anyclass
( -- | @since 0.2.0
Aeson.ToJSON
, -- | @since 0.2.0
Aeson.FromJSON
)
deriving stock
( -- | @since 0.2.0
Show
, -- | @since 0.2.0
Eq
, -- | @since 0.2.0
GHC.Generic
)
-- | Run a Warp server that exposes a script generation endpoint.
runServer :: Text -> Builders -> Options -> IO ()
runServer revision builders options = do
let settings =
Warp.defaultSettings
& Warp.setPort options.port
& Warp.setLogger
( \req status _maybeFileSize ->
putStrLn . renderString . layoutPretty defaultLayoutOptions $
hsep
[ "[info]"
, viaShow $ Wai.requestMethod req
, viaShow $ Wai.rawPathInfo req
, "(" <> pretty (Http.statusCode status) <> ")"
]
)
corsPolicy =
simpleCorsResourcePolicy
{ -- NOTE: Webpack dev server requires this for CORS workaround.
corsRequestHeaders = "content-type" : corsRequestHeaders simpleCorsResourcePolicy
}
corsMiddleware = cors . const $ Just corsPolicy
-- Scripts stay cached for five minutes
query <- cachedFor (Just $ TimeSpec 300 0) (`runQuery` builders)
let serverInfo =
ServerInfo
{ revision = revision
, exposedBuilders = Builders.toList builders
}
printf "[info] Running 'agora-scripts' on :%d\n" (Warp.getPort settings)
Servant.serve (Proxy @API) (query :<|> pure serverInfo)
& corsMiddleware
& Warp.runSettings settings

View file

@ -1,11 +1,11 @@
{- |
Module : Options
Module : ScriptExport.Options
Maintainer : emi@haskell.fyi
Description: Command line options for 'agora-scripts'.
Command line options for 'agora-scripts'.
-}
module Options (Options (..), parseOptions) where
module ScriptExport.Options (Options (..), parseOptions) where
import Network.Wai.Handler.Warp qualified as Warp
import Options.Applicative ((<**>))

View file

@ -0,0 +1,94 @@
{- |
Module : ScriptExport.Types
Maintainer : emi@haskell.fyi
Description: Param and script types for generation.
Param and script types for generation.
-}
module ScriptExport.Types (
ScriptQuery (..),
Builders,
runQuery,
insertBuilder,
toList,
) where
import Data.Aeson qualified as Aeson
import Data.Coerce (coerce)
import Data.Default.Class (Default (def))
import Data.Hashable (Hashable)
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Text (Text)
import GHC.Generics qualified as GHC
{- | Query data for getting script info.
@since 0.2.0
-}
data ScriptQuery = ScriptQuery
{ name :: Text
, paramsPayload :: Aeson.Value
}
deriving anyclass
( -- | @since 0.2.0
Aeson.ToJSON
, -- | @since 0.2.0
Aeson.FromJSON
)
deriving stock
( -- | @since 0.2.0
Show
, -- | @since 0.2.0
Eq
, -- | @since 0.2.0
GHC.Generic
, -- | @since 0.2.0
Ord
)
deriving anyclass
( -- | @since 0.2.0
Hashable
)
{- | Run a query on Builders.
@since 0.2.0
-}
runQuery :: ScriptQuery -> Builders -> Aeson.Value
runQuery s =
maybe Aeson.Null ($ s.paramsPayload) . Map.lookup s.name . getBuilders
{- | Represents a list of named pure functions.
@since 0.2.0
-}
newtype Builders = Builders
{ getBuilders :: Map Text (Aeson.Value -> Aeson.Value)
}
-- | @since 0.2.0
instance Default Builders where
def = Builders Map.empty
{- | Insert a pure function into the Builders map.
@since 0.2.0
-}
insertBuilder ::
forall p s.
(Aeson.FromJSON p, Aeson.ToJSON s) =>
Text ->
(p -> s) ->
Builders ->
Builders
insertBuilder k = coerce . Map.insert k . throughJSON
where
throughJSON f = Aeson.toJSON . \case { Aeson.Error _ -> Nothing; Aeson.Success v -> Just (f v) } . Aeson.fromJSON
{- | Get a list of the available builders.
@since 0.2.0
-}
toList :: Builders -> [Text]
toList = Map.keys . getBuilders

View file

@ -1,14 +0,0 @@
{- | Module : Scripts
Maintainer : emi@haskell.fyi
Description: Export scripts given configuration.
Export scripts given configuration.
-}
module Scripts (main) where
import API (runServer)
import Options (parseOptions)
main :: IO ()
main =
parseOptions >>= runServer

View file

@ -1,84 +0,0 @@
{- |
Module : Types
Maintainer : emi@haskell.fyi
Description: Param and script types for generation.
Param and script types for generation.
-}
module Types (
ScriptParams (..),
AgoraScripts (..),
ScriptQuery (..),
Builders (..),
throughJSON,
runQuery,
insertBuilder,
) where
import Agora.SafeMoney (GTTag)
import Agora.ScriptInfo (PolicyInfo, ValidatorInfo)
import Data.Aeson qualified as Aeson
import Data.Coerce (coerce)
import Data.Default.Class (Default (def))
import Data.Hashable (Hashable)
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Tagged (Tagged)
import Data.Text (Text)
import GHC.Generics qualified as GHC
import PlutusLedgerApi.V1 (TxOutRef)
import PlutusLedgerApi.V1.Value (AssetClass)
-- | Params required for creating script export.
data ScriptParams where
ScriptParams ::
{ governorInitialSpend :: TxOutRef
, gtClassRef :: Tagged GTTag AssetClass
, maximumCosigners :: Integer
} ->
ScriptParams
deriving anyclass (Aeson.ToJSON, Aeson.FromJSON)
deriving stock (Show, Eq, GHC.Generic, Ord)
-- | Scripts that get exported.
data AgoraScripts = AgoraScripts
{ gitRev :: String
, governorPolicyInfo :: PolicyInfo
, governorValidatorInfo :: ValidatorInfo
, stakePolicyInfo :: PolicyInfo
, stakeValidatorInfo :: ValidatorInfo
, proposalPolicyInfo :: PolicyInfo
, proposalValidatorInfo :: ValidatorInfo
, treasuryValidatorInfo :: ValidatorInfo
, authorityTokenPolicyInfo :: PolicyInfo
}
deriving anyclass (Aeson.ToJSON, Aeson.FromJSON)
deriving stock (Show, Eq, GHC.Generic)
data ScriptQuery = ScriptQuery
{ name :: Text
, paramsPayload :: Aeson.Value
}
deriving anyclass (Aeson.ToJSON, Aeson.FromJSON)
deriving stock (Show, Eq, GHC.Generic, Ord)
deriving anyclass (Hashable)
-- | Run a query on Builders.
runQuery :: ScriptQuery -> Builders -> Aeson.Value
runQuery s (Builders b) =
maybe Aeson.Null ($ s.paramsPayload) (Map.lookup s.name b)
throughJSON :: (Aeson.FromJSON p, Aeson.ToJSON s) => (p -> s) -> (Aeson.Value -> Aeson.Value)
throughJSON f = Aeson.toJSON . \case { Aeson.Error _ -> Nothing; Aeson.Success v -> Just (f v) } . Aeson.fromJSON
-- | Represents a list of named pure functions.
newtype Builders = Builders
{ getBuilders :: Map Text (Aeson.Value -> Aeson.Value)
}
instance Default Builders where
def = Builders Map.empty
-- | Insert a pure function into the Builders map.
insertBuilder :: (Aeson.FromJSON p, Aeson.ToJSON s) => Text -> (p -> s) -> Builders -> Builders
insertBuilder k = coerce . Map.insert k . throughJSON

View file

@ -1,11 +0,0 @@
{
"governorInitialSpend": {
"txOutRefId": "7be688c61c209dd7a9f4948090db0b031b11a8850b0ee4695f786fea85fbfdee",
"txOutRefIdx": 0
},
"gtClassRef": [
"",
""
],
"maximumCosigners": 5
}

View file

@ -232,14 +232,14 @@ benchmark agora-bench
executable agora-scripts
import: lang, deps, exe-opts
main-is: Scripts.hs
main-is: Main.hs
hs-source-dirs: agora-scripts
other-modules:
API
Codec.Serialise.Orphans
Data.Cache.Cached
Options
Types
ScriptExport.API
ScriptExport.Options
ScriptExport.Types
build-depends:
, agora