Creating Endpoints
Now that we are confident with our smart contract, it's time that we make it accessible to end user.
The approach here would be
- Front-end asks to construct transaction for the concerned operation.
- It then receives this transaction, which is complete besides missing for signature for spending inputs belonging to browser wallet. It calls wallet api's
signTx
method upon this body to get this signature (key witness). - Frontend now passes this unsigned transaction along with the witness it received to our backend endpoint which will add this witness to the transaction, making it complete and would then submit it.
We'll use Servant (opens in a new tab) to create our endpoints and one may understand it by following their easy to understand tutorial here (opens in a new tab).
Do note that we can also sign the transactions in server using the signGYTxBody
function defined in GeniusYield.Types.TxBody
(opens in a new tab) module.
Providing Data Provider
Defining Provider Configuration
As noted earlier, building transaction bodies require gathering suitable information from the blockchain. For this purpose, we'll require a provider. Atlas is unopinionated and allows user to plug in provider of their choice, including a locally hosted one.
Currently Atlas supports the following providers (& it would be highly appreciated if community enriches this by contributing to Atlas (opens in a new tab)):
- Maestro (opens in a new tab).
- Locally ran node along with Kupo (opens in a new tab). We have tested with version 8.1.2 of
cardano-node
and 2.7.2 of Kupo. - Blockfrost (opens in a new tab).
Following API functions don't have an optimal implementation for Blockfrost:
utxosAtTxOutRefs
utxosAtTxOutRefsWithDatums
utxosAtAddressWithDatums
utxosAtAddresses
utxosAtAddressesWithDatums
utxosAtPaymentCredentialWithDatums
utxosAtPaymentCredentials
utxosAtPaymentCredentialsWithDatums
In general, we recommend either Maestro or local node with Kupo as provider.
To provide information about the provider, we will create a config.json
file whose contents could be as follows:
We have given a sample config.json
file here (opens in a new tab).
{
"coreProvider": ...,
"networkId": "preprod",
"logging": [{ "type": { "tag": "stderr" }, "severity": "Debug", "verbosity": "V2" }],
"logTiming": false
}
where coreProvider
field can have one of following possible values:
"coreProvider": { "maestroToken": "<Your-API-Key>", "turboSubmit": false },
Here is the explanation for each of the JSON keys above:
coreProvider
: This field is the differentiating factor between different providers.- For Maestro,
maestroToken
holds the api key andturboSubmit
field dictates whether the transactions are to be submitted via their turbo submit (opens in a new tab) endpoint. - For Local Node with Kupo provider,
socketPath
is the path towards node socket (usually namednode.socket
) file andkupoUrl
is the url where endpoints are made available by Kupo, it is usuallyhttp://localhost:1442
. - For Blockfrost,
blockfrostKey
holds the required api key.
- For Maestro,
networkId
: Specifies your network and must be one ofmainnet
,preprod
,preview
,testnet
(for legacy testnet) &privnet
(for local private network).logging
: It's a list of scribes (opens in a new tab) to register. Its parameters (likeseverity
,verbosity
) and its general usage can be understood by going over their official haddock documentation here (opens in a new tab). Katip is also explained in this (opens in a new tab) book on web development in Haskell. Please have a look at haddock forFromJSON
andToJSON
instances ofGYLogScriptType
(opens in a new tab) to see sample usage.logTiming
: (Optional) If set totrue
, operations involving provider are timed and corresponding durations are logged.
Parsing Given Configuration
The file server-main.hs
(opens in a new tab) fires up our server. It reads & parses the configuration file and using it makes our endpoints (which we will define shortly) available. Here is its entire code.
-- | Getting path for our core configuration.
parseArgs :: IO FilePath
parseArgs = do
args <- getArgs
case args of
coreCfg: _ -> return coreCfg
_invalidArgument -> fail "Error: wrong arguments, needed a path to the CoreConfig JSON configuration file\n"
main :: IO ()
main = do
putStrLn "Writing Swagger file ..."
BL8.writeFile "swagger-api.json" (encodePretty apiSwagger)
putStrLn "parsing Config ..."
coreCfgPath <- parseArgs
coreCfg <- coreConfigIO coreCfgPath -- Parsing our core configuration.
putStrLn "Loading Providers ..."
withCfgProviders coreCfg "api-server" $ \providers -> do
let port = 8081
ctx = Ctx coreCfg providers
putStrLn $ "Starting server at \n " <> "http://localhost:" <> show port
run port $ app ctx
app :: Ctx -> Application
app ctx = cors (const $ Just simpleCorsResourcePolicy { corsRequestHeaders = [HttpTypes.hContentType] }) $ serve appApi $ hoistServer appApi (Handler . ExceptT . try) $ apiServer ctx
Focussing on the highlighted lines, you can see that it first reads the path to the configuration file (you would for instance run this file like so cabal run betref-server -- config.json
) in line coreCfgPath <- parseArgs
, then it parses this file coreCfg <- coreConfigIO coreCfgPath
.
We then see the use of an interesting function withCfgProviders
. It's type is withCfgProviders :: GYCoreConfig -> GYLogNamespace -> (GYProviders -> IO a) -> IO a
, thus, this function first takes our parsed configuration file, then a namespace, finally followed by a continuation GYProviders -> IO a
. Idea here is that this function will setup a GYProviders
from the parsed configuration file and send it to this continuation to obtain its result.
Defining Endpoints
Shared Context
Entire code for it is available here (opens in a new tab)
Our endpoints would need an information for our provider, thus we have created the type for it, called Ctx
. It's usage is made clear by function defined next, runQuery
and runTx
.
Note about our handling of collateral: Browser wallets usually have the option to set for collateral, in such a case wallets would create an UTxO specifically to be used as collateral and such an UTxO will be reserved, i.e., wallet won't be spending it. CIP 40 (opens in a new tab) changed the properties related to collateral and therefore we can safely take even that UTxO as collateral which has large amounts of ada and it could also contain multiple assets. Therefore if there is no collateral set by browser wallet, framework is capable of choosing suitable UTxO as collateral (and also sets for return collateral & total collateral fields appropriately) and in that case it is also free to spend it, if required by transaction builder. But if however there is a 5-ada collateral set by wallet, then framework would use it as collateral and would also reserve it, i.e., it won't pick to spend it unless explicitly mentioned by transaction skeleton. Also note that, we'll use browser wallet's getCollateral()
method to get for collateral. This method usually returns a list of ada-only UTxOs in wallet within a specific range (like in case of Nami, it is those with ada less than or equal to 50). We would send first element of this list (if exists) to backend and framework would check if the value contained in this UTxO is exactly 5 ada or not (like Nami's getCollateral
method returns only a singleton list if collateral is set in wallet), if not, framework would ignore this (i.e., would not reserve for it) and would itself pick suitable UTxO as collateral. If however you want this to be reserved (& of course used as collateral) regardless of it's value, see the comment in call to runGYTxBuilderMonadIO
in runTx
function.
-- | Our Context.
data Ctx = Ctx
{ ctxCoreCfg :: !GYCoreConfig
, ctxProviders :: !GYProviders
}
-- | To run for simple queries, the one which don't requiring building for transaction skeleton.
runQuery :: Ctx -> GYTxQueryMonadIO a -> IO a
runQuery ctx q = do
let nid = cfgNetworkId $ ctxCoreCfg ctx
providers = ctxProviders ctx
runGYTxQueryMonadIO nid providers q
-- | Tries to build for given skeleton.
runTx ::
Ctx ->
-- | User's used addresses.
[GYAddress] ->
-- | User's change address.
GYAddress ->
-- | Browser wallet's reserved collateral (if set).
Maybe GYTxOutRefCbor ->
GYTxBuilderMonadIO (GYTxSkeleton v) ->
IO GYTxBody
runTx ctx addrs addr collateral skeleton = do
let nid = cfgNetworkId $ ctxCoreCfg ctx
providers = ctxProviders ctx
runGYTxBuilderMonadIO
nid
providers
addrs
addr
( collateral
>>= ( \c ->
Just
( getTxOutRefHex c
, True -- Make this as `False` to not do 5-ada-only check for value in this given UTxO to be used as collateral.
)
)
)
(skeleton >>= buildTxBody)
Submit Endpoint
Entire code for it is available here (opens in a new tab)
We'll soon see endpoints which will return for unsigned transaction to the browser but assuming that we already have a unsigned transaction CBOR & the missing signature, let's see how we can define an endpoint which will add this missing key witness to the transaction body and would then submit it using our provider.
Input to this endpoint is a type AddWitAndSubmitParams
encapsulating our unsigned transaction body & missing key witness.
Then we have our function handleAddWitAndSubmitTx
which adds the witness to the transaction making it complete and then it submits it. The response generated here is of type SubmitTxResponse
and you can modify the same to include other fields if required.
-- | Return type of API when submitting a transaction.
data SubmitTxResponse = SubmitTxResponse
{ submitTxFee :: !Integer
, submitTxId :: !GYTxId
} deriving (Show, Generic, ToJSON, Swagger.ToSchema)
-- | Input parameters to add for reference script.
data AddWitAndSubmitParams = AddWitAndSubmitParams
{ awasTxUnsigned :: !GYTx
, awasTxWit :: !GYTxWitness
} deriving (Generic, FromJSON, Swagger.ToSchema)
-- | Construct `SubmitTxResponse` return type from the given signed transaction body.
txBodySubmitTxResponse :: GYTxBody -> SubmitTxResponse
txBodySubmitTxResponse txBody = SubmitTxResponse
{ submitTxFee = txBodyFee txBody
, submitTxId = txBodyTxId txBody
}
-- | Type for our Servant API.
type TxAPI =
"add-wit-and-submit"
:> ReqBody '[JSON] AddWitAndSubmitParams
:> Post '[JSON] SubmitTxResponse
-- | Serving our API.
handleTx :: Ctx -> ServerT TxAPI IO
handleTx = handleAddWitAndSubmitTx
-- | Handle for adding key witness to the unsigned transaction & then submit it.
handleAddWitAndSubmitTx :: Ctx -> AddWitAndSubmitParams -> IO SubmitTxResponse
handleAddWitAndSubmitTx ctx AddWitAndSubmitParams{..} = do
let txBody = getTxBody awasTxUnsigned
void $ gySubmitTx (ctxProviders ctx) $ makeSignedTransaction awasTxWit txBody
return $ txBodySubmitTxResponse txBody
Transaction Building Endpoints
Entire code for it is available here (opens in a new tab)
At this point, it should be easy to follow the code here. We first define the input type for our endpoint, we also derive its FromJSON
instance so that we can parse it from JSON that our front-end will send for it and we also derive its Swagger.ToSchema
instance so as to document our endpoint. Then our endpoint calls the relevant operation which we defined before to get transactoin skeleton, using which we obtain the transaction body with the help of functions such as runTxI
and return the result (wrapped in our UnsignedTxResponse
type).
You can see that all of our endpoints here ask for a list of used addresses, this makes them compatible with wallets that are not in single address mode (by default) such as Eternl (opens in a new tab).
-- | Input wrapper around corresponding Plutus type.
data BetRefParams = BetRefParams
{ brpOracleAddress :: !GYAddress
, brpBetUntil :: !GYTime
, brpBetReveal :: !GYTime
, brpBetStep :: !GYValue
} deriving (Show, Generic, FromJSON, Swagger.ToSchema)
-- | Convert the above `BetRefParams` with corresponding representation defined in our Plutus validator script.
betParamsToScript :: BetRefParams -> Script.BetRefParams
betParamsToScript brp = Script.BetRefParams
{ Script.brpOraclePkh = pubKeyHashToPlutus $ fromJust $ addressToPubKeyHash $ brpOracleAddress brp
, Script.brpBetUntil = timeToPlutus $ brpBetUntil brp
, Script.brpBetReveal = timeToPlutus $ brpBetReveal brp
, Script.brpBetStep = valueToPlutus $ brpBetStep brp
}
-- | Input parameters for place bet operation.
data PlaceBetRefParams = PlaceBetRefParams
{ pbrUsedAddrs :: ![GYAddress]
, pbrChangeAddr :: !GYAddress
, pbrCollateral :: !(Maybe GYTxOutRefCbor)
, pbrBetParams :: !BetRefParams
, pbrBetGuess :: !Integer
, pbrBetAmt :: !GYValue
, pbrRefScript :: !GYTxOutRef
, pbrPrevBetRef :: !(Maybe GYTxOutRef)
} deriving (Show, Generic, FromJSON, Swagger.ToSchema)
-- | Input parameters for take bets operation.
data TakeBetRefParams = TakeBetRefParams
{ tbrUsedAddrs :: ![GYAddress]
, tbrChangeAddr :: !GYAddress
, tbrCollateral :: !(Maybe GYTxOutRefCbor)
, tbrBetParams :: !BetRefParams
, tbrRefScript :: !GYTxOutRef
, tbrPrevBetRef :: !GYTxOutRef
, tbrOracleRefInputRef :: !GYTxOutRef
} deriving (Show, Generic, FromJSON, Swagger.ToSchema)
-- | Input parameters to add for reference script.
data AddRefScriptParams = AddRefScriptParams
{ arsUsedAddrs :: ![GYAddress]
, arsChangeAddr :: !GYAddress
, arsCollateral :: !(Maybe GYTxOutRefCbor)
, arsPutAddress :: !GYAddress
, arsBetParams :: !BetRefParams
} deriving (Show, Generic, FromJSON, Swagger.ToSchema)
-- | Input parameters to add for reference input.
data AddRefInputParams = AddRefInputParams
{ ariUsedAddrs :: ![GYAddress]
, ariChangeAddr :: !GYAddress
, ariCollateral :: !(Maybe GYTxOutRefCbor)
, ariPutAddress :: !GYAddress
, ariBetAnswer :: !Integer
} deriving (Show, Generic, FromJSON, Swagger.ToSchema)
-- | Return type for our API endpoints defined here.
data UnsignedTxResponse = UnsignedTxResponse
{ urspTxBodyHex :: !T.Text -- ^ Unsigned transaction cbor.
, urspTxFee :: !(Maybe Integer) -- ^ Tx fees.
, urspUtxoRef :: !(Maybe GYTxOutRef) -- ^ Some operations might need to show for relevant UTxO generated.
} deriving (Show, Generic, FromJSON, ToJSON, Swagger.ToSchema)
-- | Construct `UnsignedTxResponse` return type for our endpoint given the transaction body & relevant index for UTxO (if such exists).
unSignedTxWithFee :: GYTxBody -> Maybe GYTxOutRef -> UnsignedTxResponse
unSignedTxWithFee txBody mUtxoRef = UnsignedTxResponse
{ urspTxBodyHex = T.pack $ txToHex $ unsignedTx txBody
, urspTxFee = Just $ txBodyFee txBody
, urspUtxoRef = mUtxoRef
}
-- | Type for our Servant API.
type BetRefApi =
"place"
:> ReqBody '[JSON] PlaceBetRefParams
:> Post '[JSON] UnsignedTxResponse
:<|> "take"
:> ReqBody '[JSON] TakeBetRefParams
:> Post '[JSON] UnsignedTxResponse
:<|> "add-ref-script"
:> ReqBody '[JSON] AddRefScriptParams
:> Post '[JSON] UnsignedTxResponse
:<|> "add-ref-input"
:> ReqBody '[JSON] AddRefInputParams
:> Post '[JSON] UnsignedTxResponse
-- | Serving our API.
handleBetRefApi :: Ctx -> ServerT BetRefApi IO
handleBetRefApi ctx = handlePlaceBet ctx
:<|> handleTakeBet ctx
:<|> handleAddRefScript ctx
:<|> handleOracleRefInput ctx
-- | Handle for place bet operation.
handlePlaceBet :: Ctx -> PlaceBetRefParams -> IO UnsignedTxResponse
handlePlaceBet ctx PlaceBetRefParams{..} = do
let brp = betParamsToScript pbrBetParams
validatorAddress <- runQuery ctx (betRefAddress brp)
txBody <- runTxI ctx pbrUsedAddrs pbrChangeAddr pbrCollateral
$ placeBet pbrRefScript (betParamsToScript pbrBetParams) (Script.OracleAnswerDatum pbrBetGuess) pbrBetAmt (head pbrUsedAddrs) pbrPrevBetRef
placeUtxoRef <- case find (\utxo -> utxoAddress utxo == validatorAddress) $ utxosToList $ txBodyUTxOs txBody of
Nothing -> fail "Shouldn't happen: No reference for placed bet in body"
Just utxo -> pure $ utxoRef utxo
pure $ unSignedTxWithFee txBody $ Just placeUtxoRef
-- | Handle for take bets operation.
handleTakeBet :: Ctx -> TakeBetRefParams -> IO UnsignedTxResponse
handleTakeBet ctx TakeBetRefParams{..} = do
txBody <- runTxI ctx tbrUsedAddrs tbrChangeAddr tbrCollateral
$ takeBets tbrRefScript (betParamsToScript tbrBetParams) tbrPrevBetRef (head tbrUsedAddrs) tbrOracleRefInputRef
pure $ unSignedTxWithFee txBody Nothing
-- | Handle for adding reference script.
handleAddRefScript :: Ctx -> AddRefScriptParams -> IO UnsignedTxResponse
handleAddRefScript ctx AddRefScriptParams{..} = do
let validator = betRefValidator' (betParamsToScript arsBetParams)
txBody <- runTxI ctx arsUsedAddrs arsChangeAddr arsCollateral
$ pure $ addRefScript' arsPutAddress validator
let refs = Limbo.findRefScriptsInBody txBody
outRef <- case Map.lookup (Some (validatorToScript validator)) refs of
Nothing -> fail "Shouldn't happen: No reference for added Script in body"
Just ref -> return ref
pure $ unSignedTxWithFee txBody $ Just outRef
-- | Handle for adding reference input.
handleOracleRefInput :: Ctx -> AddRefInputParams -> IO UnsignedTxResponse
handleOracleRefInput ctx AddRefInputParams{..} = do
let ourDatumPlutus = Script.OracleAnswerDatum ariBetAnswer
ourDatumGY = datumFromPlutusData ourDatumPlutus
txBody <- runTxI ctx ariUsedAddrs ariChangeAddr ariCollateral
$ pure $ addRefInput' ariPutAddress ourDatumPlutus
let utxos = utxosToList $ txBodyUTxOs txBody
ourDatumHash = hashDatum ourDatumGY
mRefInputUtxo = find (\utxo ->
case utxoOutDatum utxo of
GYOutDatumHash dh -> ourDatumHash == dh
GYOutDatumInline d -> ourDatumGY == d
GYOutDatumNone -> False
) utxos
case mRefInputUtxo of
Nothing -> fail "Shouldn't happen: Couldn't find the desired UTxO in Tx outputs"
Just GYUTxO {utxoRef} -> pure $ unSignedTxWithFee txBody $ Just utxoRef
Wrap-Up
Our both the endpoints file (transaction submition & transaction building) our wrapped up in our Api.hs
(opens in a new tab) following the usual servant boilerplate.
-- | Type for our Servant API.
type Api =
"tx" :> TxAPI
:<|> "betref" :> BetRefApi
appApi :: Proxy Api
appApi = Proxy
apiSwagger :: Swagger
apiSwagger = toSwagger appApi
apiServer :: Ctx -> ServerT Api IO
apiServer ctx =
handleTx ctx
:<|> handleBetRefApi ctx
Now coming back to our server-main.hs
(opens in a new tab) file, we can now understand the highlighted code sections which relates to obtaining the Swagger file (generated from apiSwagger
function above) and running up our servant server.
We follow simpleCorsResourcePolicy
(also allowing Content-Type
request header) so that calls by our front-end (which runs on different origin) don't get blocked.
-- | Getting path for our core configuration.
parseArgs :: IO FilePath
parseArgs = do
args <- getArgs
case args of
coreCfg: _ -> return coreCfg
_invalidArgument -> fail "Error: wrong arguments, needed a path to the CoreConfig JSON configuration file\n"
main :: IO ()
main = do
putStrLn "Writing Swagger file ..."
BL8.writeFile "swagger-api.json" (encodePretty apiSwagger)
putStrLn "parsing Config ..."
coreCfgPath <- parseArgs
coreCfg <- coreConfigIO coreCfgPath -- Parsing our core configuration.
putStrLn "Loading Providers ..."
withCfgProviders coreCfg "api-server" $ \providers -> do
let port = 8081
ctx = Ctx coreCfg providers
putStrLn $ "Starting server at \n " <> "http://localhost:" <> show port
run port $ app ctx
app :: Ctx -> Application
app ctx = cors (const $ Just simpleCorsResourcePolicy { corsRequestHeaders = [HttpTypes.hContentType] }) $ serve appApi $ hoistServer appApi (Handler . ExceptT . try) $ apiServer ctx
Next we'll see how to call these endpoints in our front-end!