Skip to content

Commit 4877e4a

Browse files
authored
Merge pull request #49 from mlabs-haskell/nazrhom/raw-tx
Nazrhom/raw tx
2 parents 9cf0c14 + 294e250 commit 4877e4a

File tree

7 files changed

+232
-15
lines changed

7 files changed

+232
-15
lines changed

bot-plutus-interface.cabal

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,7 @@ library
8484
BotPlutusInterface.PreBalance
8585
BotPlutusInterface.Types
8686
BotPlutusInterface.UtxoParser
87-
88-
other-modules: BotPlutusInterface.Server
87+
BotPlutusInterface.Server
8988
build-depends:
9089
, aeson ^>=1.5.0.0
9190
, attoparsec >=0.13.2.2
@@ -106,6 +105,7 @@ library
106105
, http-types
107106
, lens
108107
, memory
108+
, mtl
109109
, playground-common
110110
, plutus-chain-index
111111
, plutus-chain-index-core
@@ -145,6 +145,7 @@ test-suite bot-plutus-interface-test
145145
Spec.BotPlutusInterface.Contract
146146
Spec.BotPlutusInterface.PreBalance
147147
Spec.BotPlutusInterface.UtxoParser
148+
Spec.BotPlutusInterface.Server
148149
Spec.MockContract
149150

150151
build-depends:
@@ -161,12 +162,16 @@ test-suite bot-plutus-interface-test
161162
, data-default-class
162163
, either
163164
, extra
165+
, filepath
164166
, freer-extras
165167
, freer-simple
166168
, generic-arbitrary
167169
, hex
170+
, http-client
171+
, http-types
168172
, lens
169173
, neat-interpolation
174+
, playground-common
170175
, plutus-chain-index
171176
, plutus-chain-index-core
172177
, plutus-contract
@@ -181,11 +186,16 @@ test-suite bot-plutus-interface-test
181186
, quickcheck-instances
182187
, row-types
183188
, serialise
189+
, servant-client
190+
, servant-client-core
184191
, stm
185192
, tasty
186193
, tasty-hunit
187194
, tasty-quickcheck
195+
, temporary
188196
, text ^>=1.2.4.0
189197
, uuid
198+
, utf8-string
199+
, warp
190200

191201
hs-source-dirs: test

examples/plutus-game/app/Main.hs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,5 +66,6 @@ main = do
6666
, pcDryRun = True
6767
, pcLogLevel = Debug
6868
, pcProtocolParamsFile = "./protocol.json"
69+
, pcEnableTxEndpoint = False
6970
}
7071
BotPlutusInterface.runPAB @GameContracts pabConf

examples/plutus-nft/app/Main.hs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,5 +66,6 @@ main = do
6666
, pcDryRun = True
6767
, pcLogLevel = Debug
6868
, pcProtocolParamsFile = "./protocol.json"
69+
, pcEnableTxEndpoint = False
6970
}
7071
BotPlutusInterface.runPAB @MintNFTContracts pabConf

src/BotPlutusInterface/Server.hs

Lines changed: 66 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,37 @@
11
{-# LANGUAGE AllowAmbiguousTypes #-}
22

3-
module BotPlutusInterface.Server (app, initState) where
3+
module BotPlutusInterface.Server (
4+
app,
5+
initState,
6+
WebSocketEndpoint,
7+
ActivateContractEndpoint,
8+
RawTxEndpoint,
9+
) where
410

511
import BotPlutusInterface.Contract (runContract)
612
import BotPlutusInterface.Types (
713
AppState (AppState),
814
ContractEnvironment (..),
915
ContractState (ContractState, csActivity, csObservableState),
10-
PABConfig,
16+
PABConfig (..),
17+
RawTx,
1118
SomeContractState (SomeContractState),
1219
)
1320
import Control.Concurrent (ThreadId, forkIO)
1421
import Control.Concurrent.STM (TVar, atomically, modifyTVar, newTVarIO, readTVar, readTVarIO, retry)
1522
import Control.Monad (forever, guard, unless, void)
23+
import Control.Monad.Error.Class (throwError)
1624
import Control.Monad.IO.Class (liftIO)
1725
import Data.Aeson (FromJSON, ToJSON (toJSON))
1826
import Data.Aeson qualified as JSON
27+
import Data.ByteString.Lazy qualified as LBS
1928
import Data.Either.Combinators (leftToMaybe)
2029
import Data.Kind (Type)
2130
import Data.Map qualified as Map
2231
import Data.Maybe (catMaybes)
2332
import Data.Proxy (Proxy (Proxy))
2433
import Data.Row (Row)
34+
import Data.Text (Text, unpack)
2535
import Data.UUID.V4 qualified as UUID
2636
import Network.WebSockets (
2737
Connection,
@@ -45,28 +55,43 @@ import Plutus.PAB.Webserver.Types (
4555
ContractActivationArgs (..),
4656
InstanceStatusToClient (ContractFinished, NewObservableState),
4757
)
48-
import Servant.API (JSON, Post, ReqBody, (:<|>) (..), (:>))
58+
import Servant.API (Capture, Get, JSON, Post, ReqBody, (:<|>) (..), (:>))
4959
import Servant.API.WebSocket (WebSocketPending)
50-
import Servant.Server (Application, Handler, Server, serve)
60+
import Servant.Server (Application, Handler, Server, err404, serve)
61+
import System.Directory (canonicalizePath, doesFileExist, makeAbsolute)
62+
import System.FilePath (replaceExtension, takeDirectory, (</>))
5163
import Wallet.Types (ContractInstanceId (..))
5264
import Prelude
5365

5466
initState :: IO AppState
5567
initState = AppState <$> newTVarIO Map.empty
5668

5769
-- | Mock API Schema, stripped endpoints that we don't use in this project
58-
type API a =
59-
("ws" :> WebSocketPending) -- Combined websocket (subscription protocol)
60-
:<|> ( "api"
61-
:> "contract"
62-
:> "activate"
63-
:> ReqBody '[JSON] (ContractActivationArgs a)
64-
:> Post '[JSON] ContractInstanceId -- Start a new instance.
65-
)
70+
type API a = WebSocketEndpoint :<|> ActivateContractEndpoint a :<|> RawTxEndpoint
71+
72+
-- Endpoints are split up so it is easier to test them. In particular servant-client
73+
-- can not generate a client for the WebSocketEndpoint; this allows us to still
74+
-- use servant-client to test the other endpoints
75+
76+
type WebSocketEndpoint = "ws" :> WebSocketPending -- Combined websocket (subscription protocol)
77+
78+
type ActivateContractEndpoint a =
79+
"api"
80+
:> "contract"
81+
:> "activate"
82+
:> ReqBody '[JSON] (ContractActivationArgs a)
83+
:> Post '[JSON] ContractInstanceId -- Start a new instance.
84+
85+
type RawTxEndpoint =
86+
"rawTx"
87+
:> Capture "hash" Text
88+
:> Get '[JSON] RawTx
6689

6790
server :: HasDefinitions t => PABConfig -> AppState -> Server (API t)
6891
server pabConfig state =
69-
websocketHandler state :<|> activateContractHandler pabConfig state
92+
websocketHandler state
93+
:<|> activateContractHandler pabConfig state
94+
:<|> rawTxHandler pabConfig
7095

7196
apiProxy :: forall (t :: Type). Proxy (API t)
7297
apiProxy = Proxy
@@ -206,3 +231,31 @@ handleContract pabConf state@(AppState st) contract = liftIO $ do
206231
let maybeError = toJSON <$> leftToMaybe result
207232
broadcastContractResult @w state contractInstanceID maybeError
208233
pure contractInstanceID
234+
235+
-- | This handler will allow to retrieve raw transactions from the pcTxFileDir if pcEnableTxEndpoint is True
236+
rawTxHandler :: PABConfig -> Text -> Handler RawTx
237+
rawTxHandler config hash = do
238+
-- Check that endpoint is enabled
239+
assert config.pcEnableTxEndpoint
240+
-- Absolute path to pcTxFileDir that is specified in the config
241+
txFolderPath <- liftIO $ makeAbsolute (unpack config.pcTxFileDir)
242+
243+
-- Add/Set .raw extension on path
244+
let suppliedPath :: FilePath
245+
suppliedPath = replaceExtension (txFolderPath </> "tx-" <> unpack hash) ".raw"
246+
-- Resolve path indirections
247+
path <- liftIO $ canonicalizePath suppliedPath
248+
-- ensure it does not try to escape txFolderPath
249+
assert (takeDirectory path == txFolderPath)
250+
-- ensure file exists
251+
fileExists <- liftIO $ doesFileExist path
252+
assert fileExists
253+
254+
contents <- liftIO $ LBS.readFile path
255+
case JSON.decode contents of
256+
Just rawTx -> pure rawTx
257+
Nothing -> throwError err404
258+
where
259+
assert :: Bool -> Handler ()
260+
assert True = pure ()
261+
assert False = throwError err404

src/BotPlutusInterface/Types.hs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{-# LANGUAGE DeriveAnyClass #-}
22
{-# LANGUAGE RankNTypes #-}
3+
{-# LANGUAGE TemplateHaskell #-}
34

45
module BotPlutusInterface.Types (
56
PABConfig (..),
@@ -13,13 +14,15 @@ module BotPlutusInterface.Types (
1314
HasDefinitions (..),
1415
SomeBuiltin (SomeBuiltin),
1516
endpointsToSchemas,
17+
RawTx (..),
1618
) where
1719

1820
import Cardano.Api (NetworkId (Testnet), NetworkMagic (..))
1921
import Cardano.Api.ProtocolParameters (ProtocolParameters)
2022
import Control.Concurrent.STM (TVar)
2123
import Data.Aeson (ToJSON)
2224
import Data.Aeson qualified as JSON
25+
import Data.Aeson.TH (Options (..), defaultOptions, deriveJSON)
2326
import Data.Default (Default (def))
2427
import Data.Kind (Type)
2528
import Data.Map (Map)
@@ -59,6 +62,7 @@ data PABConfig = PABConfig
5962
, pcLogLevel :: !LogLevel
6063
, pcOwnPubKeyHash :: !PubKeyHash
6164
, pcPort :: !Port
65+
, pcEnableTxEndpoint :: !Bool
6266
}
6367
deriving stock (Show, Eq)
6468

@@ -119,4 +123,16 @@ instance Default PABConfig where
119123
, pcLogLevel = Info
120124
, pcOwnPubKeyHash = ""
121125
, pcPort = 9080
126+
, pcEnableTxEndpoint = False
122127
}
128+
129+
data RawTx = RawTx
130+
{ _type :: Text
131+
, _description :: Text
132+
, _cborHex :: Text
133+
}
134+
deriving (Generic, Eq, Show)
135+
136+
-- type is a reserved keyword in haskell and can not be used as a field name
137+
-- when converting this to JSON we drop the _ prefix from each field
138+
deriveJSON defaultOptions {fieldLabelModifier = drop 1} ''RawTx

test/Spec.hs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ module Main (main) where
22

33
import Spec.BotPlutusInterface.Contract qualified
44
import Spec.BotPlutusInterface.PreBalance qualified
5+
import Spec.BotPlutusInterface.Server qualified
56
import Spec.BotPlutusInterface.UtxoParser qualified
67
import Test.Tasty (TestTree, defaultMain, testGroup)
78
import Prelude
@@ -21,4 +22,5 @@ tests =
2122
[ Spec.BotPlutusInterface.Contract.tests
2223
, Spec.BotPlutusInterface.UtxoParser.tests
2324
, Spec.BotPlutusInterface.PreBalance.tests
25+
, Spec.BotPlutusInterface.Server.tests
2426
]
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
module Spec.BotPlutusInterface.Server (tests) where
2+
3+
import BotPlutusInterface.Server (RawTxEndpoint, app, initState)
4+
import BotPlutusInterface.Types (
5+
HasDefinitions (..),
6+
PABConfig (..),
7+
RawTx (..),
8+
SomeBuiltin (..),
9+
)
10+
11+
import Playground.Types (FunctionSchema)
12+
import Schema (FormSchema)
13+
14+
import Test.Tasty (TestTree, testGroup)
15+
import Test.Tasty.HUnit (testCase, (@?=))
16+
17+
import Network.HTTP.Client (defaultManagerSettings, newManager)
18+
import Network.HTTP.Types.Status (status404)
19+
import Network.Wai.Handler.Warp (testWithApplication)
20+
import Servant.Client (ClientEnv, ClientError (..), client, mkClientEnv, responseStatusCode, runClientM)
21+
import Servant.Client.Core.BaseUrl (BaseUrl (..), parseBaseUrl)
22+
23+
import Data.Aeson (FromJSON, ToJSON, encode)
24+
import Data.ByteString.Lazy qualified as LBS
25+
import Data.Default (def)
26+
import Data.Proxy (Proxy (..))
27+
import Data.Text (Text, pack, unpack)
28+
import Data.Void (Void, absurd)
29+
import System.FilePath ((</>))
30+
import System.IO.Temp (withSystemTempDirectory)
31+
import Prelude
32+
33+
type RawTxEndpointResponse = Either ClientError RawTx
34+
type RawTxTest a = (Text -> IO RawTxEndpointResponse) -> IO a
35+
36+
tests :: TestTree
37+
tests =
38+
testGroup
39+
"BotPlutusInterface.Server"
40+
[ rawTxTests
41+
]
42+
43+
rawTxTests :: TestTree
44+
rawTxTests =
45+
testGroup
46+
"rawTx"
47+
[ testCase "Can fetch valid tx file" fetchTx
48+
, testCase "If an extension is supplied, it is replaced by .raw" fetchSignedTx
49+
, testCase "Unable to fetch outside tx folder" fetchOutsideTxFolder
50+
, testCase "Returns 404 for valid request when the endpoint is disabled" fetchWithDefaultConfig
51+
]
52+
where
53+
fetchTx :: IO ()
54+
fetchTx = do
55+
initServerAndClient enableTxEndpointConfig $ \runRawTxClient -> do
56+
result <- runRawTxClient txHash
57+
result @?= Right rawTx
58+
59+
fetchSignedTx :: IO ()
60+
fetchSignedTx = do
61+
initServerAndClient enableTxEndpointConfig $ \runRawTxClient -> do
62+
result <- runRawTxClient $ txHash <> ".signed"
63+
result @?= Right rawTx
64+
65+
fetchOutsideTxFolder :: IO ()
66+
fetchOutsideTxFolder = do
67+
initServerAndClient enableTxEndpointConfig $ \runRawTxClient -> do
68+
Left (FailureResponse _ res) <- runRawTxClient "../somefile"
69+
responseStatusCode res @?= status404
70+
71+
fetchWithDefaultConfig :: IO ()
72+
fetchWithDefaultConfig = do
73+
initServerAndClient def $ \runRawTxClient -> do
74+
Left (FailureResponse _ res) <- runRawTxClient txHash
75+
responseStatusCode res @?= status404
76+
77+
txProxy :: Proxy RawTxEndpoint
78+
txProxy = Proxy
79+
80+
initServerAndClient :: PABConfig -> RawTxTest a -> IO a
81+
initServerAndClient config test = do
82+
withSystemTempDirectory "tx" $ \path -> do
83+
let pabConfig :: PABConfig
84+
pabConfig = config {pcTxFileDir = pack path}
85+
state <- initState
86+
LBS.writeFile (path </> txFileName) txFileContents
87+
testWithApplication (pure $ app @EmptyContract pabConfig state) (initClientOnPort test)
88+
where
89+
initClientOnPort :: RawTxTest a -> Int -> IO a
90+
initClientOnPort testToRun port = do
91+
baseUrl <- parseBaseUrl "http://localhost"
92+
manager <- newManager defaultManagerSettings
93+
94+
let clientEnv :: ClientEnv
95+
clientEnv = mkClientEnv manager $ baseUrl {baseUrlPort = port}
96+
97+
runRawTxClient :: Text -> IO RawTxEndpointResponse
98+
runRawTxClient hash = runClientM (client txProxy hash) clientEnv
99+
100+
testToRun runRawTxClient
101+
102+
txHash :: Text
103+
txHash = "test"
104+
105+
txFileName :: FilePath
106+
txFileName = "tx-" <> unpack txHash <> ".raw"
107+
108+
rawTx :: RawTx
109+
rawTx =
110+
RawTx
111+
{ _type = "TxBodyAlonzo"
112+
, _description = "description"
113+
, _cborHex = "hex"
114+
}
115+
116+
txFileContents :: LBS.ByteString
117+
txFileContents = encode rawTx
118+
119+
enableTxEndpointConfig :: PABConfig
120+
enableTxEndpointConfig = def {pcEnableTxEndpoint = True}
121+
122+
-- Since we are not testing the contract endpoints we just use a newtype around Void as a Contract
123+
newtype EmptyContract = EmptyContract {unEmptyContract :: Void}
124+
deriving newtype (FromJSON, ToJSON)
125+
126+
instance HasDefinitions EmptyContract where
127+
getDefinitions :: [EmptyContract]
128+
getDefinitions = []
129+
130+
getSchema :: EmptyContract -> [FunctionSchema FormSchema]
131+
getSchema = absurd . unEmptyContract
132+
133+
getContract :: (EmptyContract -> SomeBuiltin)
134+
getContract = absurd . unEmptyContract

0 commit comments

Comments
 (0)