Skip to content

Commit

Permalink
Merge pull request #1398 from input-output-hk/get-utxo-endpoint
Browse files Browse the repository at this point in the history
Add a GET /snapshot/utxo endpoint to supersede GetUTxO client input
  • Loading branch information
v0d1ch committed Apr 19, 2024
2 parents fa1f9f8 + 420f91f commit 02dcdc0
Show file tree
Hide file tree
Showing 12 changed files with 124 additions and 51 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
As a minor extension, we also keep a semantic version for the `UNRELEASED`
changes.

## [0.17.0] - UNRELEASED

- Add `GET /snapshot/utxo` API endpoint to query confirmed UTxO set on demand.
- Always responds with the last confirmed UTxO

- _DEPRECATED_ the `GetUTxO` client input and `GetUTxOResponse` server output. Use `GET /snapshot/utxo` instead.

## [0.16.0] - 2024-04-03

Expand Down
16 changes: 6 additions & 10 deletions docs/docs/getting-started/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,18 +273,14 @@ echo "{ \"tag\": \"NewTx\", \"transaction\": $(cat signed-tx.json | jq ".cborHex
{ "tag": "NewTx", "transaction": "84a3008182582009d34606abdcd0b10ebc89307cbfa0b469f9144194137b45b7a04b273961add81902af0181a200581d618fd0ece638ffd53b4bb79a69b87f35b79e647da9aab7525a2d0d7364011a0074483d0200a10081825820c736d40ee64c031851af26007c00a3b6fcbebccfd333a8ee6f14983f9be5331c58404bae506f5235778ec65eca6fdfcf6ec61ab93420b91e0b71ca82d437904f860e999372cf00252246ca77012e19c344b3af60df9f853af53fc86835f95a119609f5f6" }
```
The `--tx-in` value is a UTxO obtained from the reply to the `{ "tag": "GetUTxO" }` message.
E.g.:
The `--tx-in` value is a UTxO obtained from the `GET /snapshot/utxo` request. For example:
```json title="GetUTxOResponse"
```json title="Example response of GET /snapshot/utxo"
{
"tag": "GetUTxOResponse",
"utxo": {
"09d34606abdcd0b10ebc89307cbfa0b469f9144194137b45b7a04b273961add8#687": {
"address": "addr1w9htvds89a78ex2uls5y969ttry9s3k9etww0staxzndwlgmzuul5",
"value": {
"lovelace": 7620669
}
"09d34606abdcd0b10ebc89307cbfa0b469f9144194137b45b7a04b273961add8#687": {
"address": "addr1w9htvds89a78ex2uls5y969ttry9s3k9etww0staxzndwlgmzuul5",
"value": {
"lovelace": 7620669
}
}
}
Expand Down
15 changes: 6 additions & 9 deletions docs/docs/tutorial/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -592,23 +592,20 @@ head.

First, we need to select a UTxO to spend. We can do this either by looking at
the `utxo` field of the last `HeadIsOpen` or `SnapshotConfirmed` message, or
query the API for the current UTxO set through the websocket session:
query the API for the current UTxO set:

```json title="Websocket API"
{ "tag": "GetUTxO" }
```shell
curl -s 127.0.0.1:4001/snapshot/utxo | jq
```

From the response, we would need to select a UTxO that is owned by `alice` to
spend. We can do that also via the `snapshotUtxo` field in the `Greetings`
message and using this `websocat` and `jq` invocation:
spend:

<!-- TODO: make this for both parties -->

```shell
websocat -U "ws://0.0.0.0:4001?history=no" \
| jq "select(.tag == \"Greetings\") \
| .snapshotUtxo \
| with_entries(select(.value.address == \"$(cat credentials/alice-funds.addr)\"))" \
curl -s 127.0.0.1:4001/snapshot/utxo \
| jq "with_entries(select(.value.address == \"$(cat credentials/alice-funds.addr)\"))" \
> utxo.json
```

Expand Down
9 changes: 2 additions & 7 deletions hydra-cluster/src/Hydra/Cluster/Scenarios.hs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import Hydra.Party (Party)
import HydraNode (
HydraClient (..),
HydraNodeLog,
getSnapshotUTxO,
input,
output,
requestCommitTx,
Expand Down Expand Up @@ -620,15 +621,9 @@ initWithWrongKeys workDir tracer node@RunningNode{nodeSocket} hydraScriptsTxId =
-- NOTE: This relies on zero-fee protocol parameters.
respendUTxO :: HydraClient -> SigningKey PaymentKey -> NominalDiffTime -> IO ()
respendUTxO client sk delay = do
utxo <- getUTxO
utxo <- getSnapshotUTxO client
forever $ respend utxo
where
getUTxO = do
send client $ input "GetUTxO" []
waitMatch 10 client $ \v -> do
guard $ v ^? key "tag" == Just "GetUTxOResponse"
v ^? key "utxo" >>= parseMaybe parseJSON

vk = getVerificationKey sk

respend utxo =
Expand Down
15 changes: 15 additions & 0 deletions hydra-cluster/src/HydraNode.hs
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,21 @@ requestCommitTx :: HydraClient -> UTxO -> IO Tx
requestCommitTx client =
requestCommitTx' client . fmap (`TxOutWithWitness` Nothing)

-- | Get the latest snapshot UTxO from the hydra-node. NOTE: While we usually
-- avoid parsing responses using the same data types as the system under test,
-- this parses the response as a 'UTxO' type as we often need to pick it apart.
getSnapshotUTxO :: HydraClient -> IO UTxO
getSnapshotUTxO HydraClient{hydraNodeId} =
runReq defaultHttpConfig request <&> responseBody
where
request =
Req.req
GET
(Req.http "127.0.0.1" /: "snapshot" /: "utxo")
NoReqBody
(Proxy :: Proxy (JsonResponse UTxO))
(Req.port $ 4_000 + hydraNodeId)

getMetrics :: HasCallStack => HydraClient -> IO ByteString
getMetrics HydraClient{hydraNodeId} = do
failAfter 3 $
Expand Down
4 changes: 2 additions & 2 deletions hydra-cluster/test/Test/EndToEndSpec.hs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ import Hydra.Options
import HydraNode (
HydraClient (..),
getMetrics,
getSnapshotUTxO,
input,
output,
requestCommitTx,
Expand Down Expand Up @@ -822,8 +823,7 @@ initAndClose tmpDir tracer clusterIx hydraScriptsTxId node@RunningNode{nodeSocke
snapshot <- v ^? key "snapshot"
guard $ snapshot == expectedSnapshot

send n1 $ input "GetUTxO" []
waitFor hydraTracer 10 [n1] $ output "GetUTxOResponse" ["utxo" .= newUTxO, "headId" .= headId]
(toJSON <$> getSnapshotUTxO n1) `shouldReturn` toJSON newUTxO

send n1 $ input "Close" []
deadline <- waitMatch 3 n1 $ \v -> do
Expand Down
2 changes: 1 addition & 1 deletion hydra-node/hydra-node.cabal
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
cabal-version: 3.0
name: hydra-node
version: 0.16.0
version: 0.17.0
synopsis: The Hydra node
author: IOG
copyright: 2022 IOG
Expand Down
21 changes: 21 additions & 0 deletions hydra-node/json-schemas/api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,25 @@ channels:
type: response
method: POST
bindingVersion: '0.1.0'
/snapshot/utxo:
servers:
- localhost-http
subscribe:
operationId: getConfirmedUTxO
description: |
Get latest confirmed UTxO.
Possible responses of this endpoint are:
* 200: UTxO of latest confirmed snapshot
* 404: when head was never open
message:
payload:
$ref: "api.yaml#/components/schemas/UTxO"
bindings:
http:
type: response
method: GET
bindingVersion: '0.1.0'
/protocol-parameters:
servers:
- localhost-http
Expand Down Expand Up @@ -277,6 +296,7 @@ components:
GetUTxO:
title: GetUTxO
description: |
DEPRECATED: Use the `GET /snapshot/utxo http endpoint` to query the confirmed UTxO set.
Asynchronously access the current UTxO set of the Hydra node. This eventually triggers a UTxO event from the server.
payload:
type: object
Expand Down Expand Up @@ -405,6 +425,7 @@ components:
GetUTxOResponse:
title: GetUTxOResponse
description: |
DEPRECATED: Use the `GET /snapshot/utxo http endpoint` to query the confirmed UTxO set.
Emitted as a result of a `GetUTxO` to reflect the current UTxO of the underlying node.
payload:
$ref: "api.yaml#/components/schemas/GetUTxOResponse"
Expand Down
42 changes: 29 additions & 13 deletions hydra-node/src/Hydra/API/HTTPServer.hs
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,9 @@ import Hydra.Cardano.Api (
import Hydra.Chain (Chain (..), IsChainState, PostTxError (..), draftCommitTx)
import Hydra.Chain.Direct.State ()
import Hydra.HeadId (HeadId)
import Hydra.Ledger.Cardano ()
import Hydra.Ledger (IsTx (..))
import Hydra.Logging (Tracer, traceWith)
import Network.HTTP.Types (status200, status400, status500)
import Network.HTTP.Types (status200, status400, status404, status500)
import Network.Wai (
Application,
Request (pathInfo, requestMethod),
Expand Down Expand Up @@ -150,19 +150,29 @@ instance Arbitrary TransactionSubmitted where

-- | Hydra HTTP server
httpApp ::
IsTx tx =>
Tracer IO APIServerLog ->
Chain tx IO ->
PParams LedgerEra ->
-- | A means to get the 'HeadId' if initializing the Head.
(STM IO) (Maybe HeadId) ->
IO (Maybe HeadId) ->
-- | Get latest confirmed UTxO snapshot.
IO (Maybe (UTxOType tx)) ->
Application
httpApp tracer directChain pparams getInitializingHeadId request respond = do
httpApp tracer directChain pparams getInitializingHeadId getConfirmedUTxO request respond = do
traceWith tracer $
APIHTTPRequestReceived
{ method = Method $ requestMethod request
, path = PathInfo $ rawPathInfo request
}
case (requestMethod request, pathInfo request) of
("GET", ["snapshot", "utxo"]) ->
-- XXX: Should ensure the UTxO is of the right head and the head is still
-- open. This is something we should fix on the "read model" side of the
-- server.
getConfirmedUTxO >>= \case
Nothing -> respond notFound
Just utxo -> respond $ okJSON utxo
("POST", ["commit"]) ->
consumeRequestBodyStrict request
>>= handleDraftCommitUtxo directChain getInitializingHeadId
Expand Down Expand Up @@ -256,7 +266,7 @@ httpApp tracer directChain pparams getInitializingHeadId request respond = do
handleDraftCommitUtxo ::
Chain tx IO ->
-- | A means to get the 'HeadId' if initializing the Head.
(STM IO) (Maybe HeadId) ->
IO (Maybe HeadId) ->
-- | Request body.
LBS.ByteString ->
IO Response
Expand All @@ -265,17 +275,17 @@ handleDraftCommitUtxo directChain getInitializingHeadId body = do
Left err ->
pure $ responseLBS status400 [] (Aeson.encode $ Aeson.String $ pack err)
Right DraftCommitTxRequest{utxoToCommit} -> do
atomically getInitializingHeadId >>= \case
getInitializingHeadId >>= \case
Just headId -> do
draftCommitTx headId (fromTxOutWithWitness <$> utxoToCommit) <&> \case
Left e ->
-- Distinguish between errors users can actually benefit from and
-- other errors that are turned into 500 responses.
case e of
CannotCommitReferenceScript -> return400 e
CommittedTooMuchADAForMainnet _ _ -> return400 e
UnsupportedLegacyOutput _ -> return400 e
walletUtxoErr@SpendingNodeUtxoForbidden -> return400 walletUtxoErr
CannotCommitReferenceScript -> badRequest e
CommittedTooMuchADAForMainnet _ _ -> badRequest e
UnsupportedLegacyOutput _ -> badRequest e
walletUtxoErr@SpendingNodeUtxoForbidden -> badRequest walletUtxoErr
_ -> responseLBS status500 [] (Aeson.encode $ toJSON e)
Right commitTx ->
responseLBS status200 [] (Aeson.encode $ DraftCommitTxResponse commitTx)
Expand Down Expand Up @@ -312,11 +322,17 @@ handleSubmitUserTx directChain body = do
pure $ responseLBS status400 [] (Aeson.encode $ Aeson.String $ pack err)
Right txToSubmit -> do
try (submitTx txToSubmit) <&> \case
Left (e :: PostTxError Tx) -> return400 e
Left (e :: PostTxError Tx) -> badRequest e
Right _ ->
responseLBS status200 [] (Aeson.encode TransactionSubmitted)
where
Chain{submitTx} = directChain

return400 :: IsChainState tx => PostTxError tx -> Response
return400 = responseLBS status400 [] . Aeson.encode . toJSON
badRequest :: IsChainState tx => PostTxError tx -> Response
badRequest = responseLBS status400 [] . Aeson.encode . toJSON

notFound :: Response
notFound = responseLBS status404 [] ""

okJSON :: ToJSON a => a -> Response
okJSON = responseLBS status200 [] . Aeson.encode
2 changes: 1 addition & 1 deletion hydra-node/src/Hydra/API/Server.hs
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ withAPIServer host port party PersistenceIncremental{loadAll, append} tracer cha
websocketsOr
defaultConnectionOptions
(wsApp party tracer history callback headStatusP snapshotUtxoP responseChannel)
(httpApp tracer chain pparams (getLatest headIdP))
(httpApp tracer chain pparams (atomically $ getLatest headIdP) (atomically $ getLatest snapshotUtxoP))
)
( do
waitForServerRunning
Expand Down
2 changes: 1 addition & 1 deletion hydra-node/src/Hydra/API/ServerOutput.hs
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ projectHeadStatus headStatus = \case
HeadIsFinalized{} -> Final
_other -> headStatus

-- | Projection function related to 'snapshotUtxo' field in 'Greetings' message.
-- | Projection of latest confirmed snapshot UTxO.
projectSnapshotUtxo :: Maybe (UTxOType tx) -> ServerOutput tx -> Maybe (UTxOType tx)
projectSnapshotUtxo snapshotUtxo = \case
SnapshotConfirmed _ snapshot _ -> Just $ Hydra.Snapshot.utxo snapshot
Expand Down
41 changes: 34 additions & 7 deletions hydra-node/test/Hydra/API/HTTPServerSpec.hs
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,16 @@ import Hydra.API.ServerSpec (dummyChainHandle)
import Hydra.Cardano.Api (fromLedgerPParams, serialiseToTextEnvelope, shelleyBasedEra)
import Hydra.Chain.Direct.Fixture (defaultPParams)
import Hydra.JSONSchema (SchemaSelector, prop_validateJSONSchema, validateJSON, withJsonSpecifications)
import Hydra.Ledger (UTxOType)
import Hydra.Ledger.Cardano (Tx)
import Hydra.Ledger.Simple (SimpleTx)
import Hydra.Logging (nullTracer)
import System.FilePath ((</>))
import System.IO.Unsafe (unsafePerformIO)
import Test.Aeson.GenericSpecs (roundtripAndGoldenSpecs)
import Test.Hspec.Wai (MatchBody (..), ResponseMatcher (matchBody), get, shouldRespondWith, with)
import Test.QuickCheck.Property (counterexample, forAll, property)
import Test.Hspec.Wai.Internal (withApplication)
import Test.QuickCheck.Property (counterexample, cover, forAll, property, withMaxSuccess)

spec :: Spec
spec = do
Expand Down Expand Up @@ -70,9 +73,11 @@ spec = do
-- TODO: we should add more tests for other routes here (eg. /commit)
apiServerSpec :: Spec
apiServerSpec = do
with (return webServer) $ do
describe "API should respond correctly" $
describe "GET /protocol-parameters" $ do
describe "API should respond correctly" $ do
let getNothing = pure Nothing

describe "GET /protocol-parameters" $ do
with (return $ httpApp @SimpleTx nullTracer dummyChainHandle defaultPParams getNothing getNothing) $ do
it "matches schema" $
withJsonSpecifications $ \schemaDir -> do
get "/protocol-parameters"
Expand All @@ -88,9 +93,31 @@ apiServerSpec = do
`shouldRespondWith` 200
{ matchBody = matchJSON $ fromLedgerPParams shelleyBasedEra defaultPParams
}
where
webServer = httpApp nullTracer dummyChainHandle defaultPParams getHeadId
getHeadId = pure Nothing

describe "GET /snapshot/utxo" $ do
prop "responds correctly" $ \utxo -> do
let getUTxO = pure utxo
withApplication (httpApp @SimpleTx nullTracer dummyChainHandle defaultPParams getNothing getUTxO) $ do
get "/snapshot/utxo"
`shouldRespondWith` case utxo of
Nothing -> 404
Just u -> 200{matchBody = matchJSON u}

prop "ok response matches schema" $ \(utxo :: UTxOType Tx) ->
withMaxSuccess 4
. cover 1 (null utxo) "empty"
. cover 1 (not $ null utxo) "non empty"
. withJsonSpecifications
$ \schemaDir -> do
let getUTxO = pure $ Just utxo
withApplication (httpApp @Tx nullTracer dummyChainHandle defaultPParams getNothing getUTxO) $ do
get "/snapshot/utxo"
`shouldRespondWith` 200
{ matchBody =
matchValidJSON
(schemaDir </> "api.json")
(key "channels" . key "/snapshot/utxo" . key "subscribe" . key "message" . key "payload")
}

-- * Helpers

Expand Down

0 comments on commit 02dcdc0

Please sign in to comment.