diff --git a/.github/workflows/explorer/docker-compose.yaml b/.github/workflows/explorer/docker-compose.yaml index f5ac20aaa04..76ce708a494 100644 --- a/.github/workflows/explorer/docker-compose.yaml +++ b/.github/workflows/explorer/docker-compose.yaml @@ -23,7 +23,8 @@ services: ports: - "80:8080" command: - [ "--node-socket", "/data/node.socket" + [ "direct" + , "--node-socket", "/data/node.socket" , "--testnet-magic", "2" , "--api-port", "8080" # NOTE: Block in which current master scripts were published diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a1e956403f..9cc70648253 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,8 @@ changes. - Overall this results in transactions still to be submitted once per client, but requires signifanctly less book-keeping on the client-side. +- Add **Blockfrost Mode** to `hydra-chain-observer`, to follow the chain via Blockfrost API. + ## [0.19.0] - 2024-09-13 - Tested with `cardano-node 9.1.1` and `cardano-cli 9.2.1.0` @@ -54,7 +56,6 @@ changes. - Add a demo mode to hydra-cluster to facilitate network resiliance tests [#1552](https://github.com/cardano-scaling/hydra/pull/1552) - ## [0.18.1] - 2024-08-15 - New landing page and updated documentation style. [#1560](https://github.com/cardano-scaling/hydra/pull/1560) diff --git a/cabal.project b/cabal.project index 8db45489881..f1c813813d3 100644 --- a/cabal.project +++ b/cabal.project @@ -12,8 +12,8 @@ repository cardano-haskell-packages -- See CONTRIBUTING.md for information about when and how to update these. index-state: - , hackage.haskell.org 2024-09-23T15:45:50Z - , cardano-haskell-packages 2024-09-20T19:39:13Z + , hackage.haskell.org 2024-09-25T13:28:12Z + , cardano-haskell-packages 2024-09-23T21:46:49Z packages: hydra-prelude diff --git a/flake.lock b/flake.lock index 55053db335f..3ab0c44c885 100644 --- a/flake.lock +++ b/flake.lock @@ -871,11 +871,11 @@ "hackage": { "flake": false, "locked": { - "lastModified": 1727224042, - "narHash": "sha256-bobZR+mTiX2UkrjIL5tNIK38uz/835TqXa2HYLBc2IA=", + "lastModified": 1727742653, + "narHash": "sha256-9qfnzdRco5WVW9sJH8oKrcYmPHPIRUyhGaSELHcAq2I=", "owner": "input-output-hk", "repo": "hackage.nix", - "rev": "c06d89f727acc16e5261e2d7832cee5473e5e63d", + "rev": "d7553bdfc658e7eb30a5f730192bc9f83e65a8ba", "type": "github" }, "original": { diff --git a/hydra-chain-observer/README.md b/hydra-chain-observer/README.md index c459e28a32e..bd389a70417 100644 --- a/hydra-chain-observer/README.md +++ b/hydra-chain-observer/README.md @@ -1,14 +1,37 @@ # Hydra Chain Observer -A small executable which connects to a chain like the `hydra-node`, but puts any -observations as traces onto `stdout`. +A lightweight executable designed to connect to a blockchain, such as the `hydra-node`, and streams chain observations as traces to `stdout`. +It supports two modes of operation: **Direct** connection to a node via socket, and connection through **Blockfrost** API. -To run, pass a `--node-socket`, corresponding network id and optionally -`--start-chain-from`. For example: +## Direct Mode + +To run the observer in Direct Mode, provide the following arguments: +- `--node-socket`: path to the node socket file. +- network id: `--testnet-magic` (with magic number) for the testnet or `--mainnet` for the mainnet. +- (optional) `--start-chain-from`: specify a chain point (SLOT.HEADER_HASH) to start observing from. + +For example: ``` shell -hydra-chain-observer \ +hydra-chain-observer direct \ --node-socket testnets/preprod/node.socket \ --testnet-magic 1 \ --start-chain-from "41948777.5d34af0f42be9823ebd35c2d83d5d879c5615ac17f7158bb9aa4ef89072455a7" ``` + + +## Blockfrost Mode + +To run the observer in Blockfrost Mode, provide the following arguments: +- `--project-path`: file path to your Blockfrost project API token hash. +> expected to be prefixed with environment (e.g. testnetA3C2E...) +- (optional) `--start-chain-from`: specify a chain point (SLOT.HEADER_HASH) to start observing from. + +For example: + +``` shell +hydra-chain-observer blockfrost \ + --project-path $PROJECT_TOKEN_HASH_PATH \ + --start-chain-from "41948777.5d34af0f42be9823ebd35c2d83d5d879c5615ac17f7158bb9aa4ef89072455a7" +``` + diff --git a/hydra-chain-observer/exe/Main.hs b/hydra-chain-observer/exe/Main.hs index 2450e080137..0625e510d0b 100644 --- a/hydra-chain-observer/exe/Main.hs +++ b/hydra-chain-observer/exe/Main.hs @@ -1,7 +1,7 @@ module Main where -import Hydra.ChainObserver (defaultObserverHandler) import Hydra.ChainObserver qualified +import Hydra.ChainObserver.NodeClient (defaultObserverHandler) import Hydra.Prelude main :: IO () diff --git a/hydra-chain-observer/hydra-chain-observer.cabal b/hydra-chain-observer/hydra-chain-observer.cabal index 2cd90c64e62..507f532a65f 100644 --- a/hydra-chain-observer/hydra-chain-observer.cabal +++ b/hydra-chain-observer/hydra-chain-observer.cabal @@ -65,17 +65,24 @@ library hs-source-dirs: src ghc-options: -haddock build-depends: + , base16-bytestring + , blockfrost-client >=0.9.1.0 , hydra-cardano-api , hydra-node , hydra-plutus , hydra-prelude , hydra-tx + , io-classes , optparse-applicative , ouroboros-network-protocols + , retry exposed-modules: + Hydra.Blockfrost.ChainObserver Hydra.ChainObserver + Hydra.ChainObserver.NodeClient Hydra.ChainObserver.Options + Hydra.Ouroborus.ChainObserver executable hydra-chain-observer import: project-config diff --git a/hydra-chain-observer/src/Hydra/Blockfrost/ChainObserver.hs b/hydra-chain-observer/src/Hydra/Blockfrost/ChainObserver.hs new file mode 100644 index 00000000000..79894732dc7 --- /dev/null +++ b/hydra-chain-observer/src/Hydra/Blockfrost/ChainObserver.hs @@ -0,0 +1,227 @@ +{-# LANGUAGE DuplicateRecordFields #-} + +module Hydra.Blockfrost.ChainObserver where + +import Hydra.Prelude + +import Blockfrost.Client ( + BlockfrostClientT, + runBlockfrost, + ) +import Blockfrost.Client qualified as Blockfrost +import Control.Concurrent.Class.MonadSTM ( + MonadSTM (readTVarIO), + newTVarIO, + writeTVar, + ) +import Control.Retry (constantDelay, retrying) +import Data.ByteString.Base16 qualified as Base16 +import Hydra.Cardano.Api ( + ChainPoint (..), + HasTypeProxy (..), + Hash, + NetworkId (..), + NetworkMagic (..), + SerialiseAsCBOR (..), + SlotNo (..), + Tx, + UTxO, + serialiseToRawBytes, + ) +import Hydra.Cardano.Api.Prelude ( + BlockHeader (..), + ) +import Hydra.Chain.Direct.Handlers (convertObservation) +import Hydra.ChainObserver.NodeClient ( + ChainObservation (..), + ChainObserverLog (..), + NodeClient (..), + ObserverHandler, + logOnChainTx, + observeAll, + ) +import Hydra.Logging (Tracer, traceWith) +import Hydra.Tx (IsTx (..)) + +data APIBlockfrostError + = BlockfrostError Text + | DecodeError Text + | NotEnoughBlockConfirmations Blockfrost.BlockHash + | MissingBlockNo Blockfrost.BlockHash + | MissingNextBlockHash Blockfrost.BlockHash + deriving (Show, Exception) + +runBlockfrostM :: + (MonadIO m, MonadThrow m) => + Blockfrost.Project -> + BlockfrostClientT IO a -> + m a +runBlockfrostM prj action = do + result <- liftIO $ runBlockfrost prj action + case result of + Left err -> throwIO (BlockfrostError $ show err) + Right val -> pure val + +blockfrostClient :: + Tracer IO ChainObserverLog -> + FilePath -> + Integer -> + NodeClient IO +blockfrostClient tracer projectPath blockConfirmations = do + NodeClient + { follow = \startChainFrom observerHandler -> do + prj <- Blockfrost.projectFromFile projectPath + + Blockfrost.Block{_blockHash = (Blockfrost.BlockHash genesisBlockHash)} <- + runBlockfrostM prj (Blockfrost.getBlock (Left 0)) + + Blockfrost.Genesis + { _genesisActiveSlotsCoefficient + , _genesisSlotLength + , _genesisNetworkMagic + } <- + runBlockfrostM prj Blockfrost.getLedgerGenesis + + let networkId = fromNetworkMagic _genesisNetworkMagic + traceWith tracer ConnectingToExternalNode{networkId} + + chainPoint <- + case startChainFrom of + Just point -> pure point + Nothing -> do + toChainPoint <$> runBlockfrostM prj Blockfrost.getLatestBlock + + traceWith tracer StartObservingFrom{chainPoint} + + let blockTime = realToFrac _genesisSlotLength / realToFrac _genesisActiveSlotsCoefficient + + let blockHash = fromChainPoint chainPoint genesisBlockHash + + stateTVar <- newTVarIO (blockHash, mempty) + void $ + retrying (retryPolicy blockTime) shouldRetry $ \_ -> do + loop tracer prj networkId blockTime observerHandler blockConfirmations stateTVar + `catch` \(ex :: APIBlockfrostError) -> + pure $ Left ex + } + where + shouldRetry _ = \case + Right{} -> pure False + Left err -> pure $ isRetryable err + + retryPolicy blockTime = constantDelay (truncate blockTime * 1000 * 1000) + +-- | Iterative process that follows the chain using a naive roll-forward approach, +-- keeping track of the latest known current block and UTxO view. +-- This process operates at full speed without waiting between calls, +-- favoring the catch-up process. +loop :: + (MonadIO m, MonadThrow m, MonadSTM m) => + Tracer m ChainObserverLog -> + Blockfrost.Project -> + NetworkId -> + DiffTime -> + ObserverHandler m -> + Integer -> + TVar m (Blockfrost.BlockHash, UTxO) -> + m a +loop tracer prj networkId blockTime observerHandler blockConfirmations stateTVar = do + current <- readTVarIO stateTVar + next <- rollForward tracer prj networkId observerHandler blockConfirmations current + atomically $ writeTVar stateTVar next + loop tracer prj networkId blockTime observerHandler blockConfirmations stateTVar + +-- | From the current block and UTxO view, we collect Hydra observations +-- and yield the next block and adjusted UTxO view. +rollForward :: + (MonadIO m, MonadThrow m) => + Tracer m ChainObserverLog -> + Blockfrost.Project -> + NetworkId -> + ObserverHandler m -> + Integer -> + (Blockfrost.BlockHash, UTxO) -> + m (Blockfrost.BlockHash, UTxO) +rollForward tracer prj networkId observerHandler blockConfirmations (blockHash, utxo) = do + block@Blockfrost.Block + { _blockHash + , _blockConfirmations + , _blockNextBlock + , _blockHeight + } <- + runBlockfrostM prj $ Blockfrost.getBlock (Right blockHash) + + -- Check if block within the safe zone to be processes + when (_blockConfirmations < blockConfirmations) $ + throwIO (NotEnoughBlockConfirmations _blockHash) + + -- Check if block contains a reference to its next + nextBlockHash <- maybe (throwIO $ MissingNextBlockHash _blockHash) pure _blockNextBlock + + -- Search block transactions + txHashes <- runBlockfrostM prj . Blockfrost.allPages $ \p -> + Blockfrost.getBlockTxs' (Right _blockHash) p Blockfrost.def + + -- Collect CBOR representations + cborTxs <- traverse (runBlockfrostM prj . Blockfrost.getTxCBOR) txHashes + + -- Convert to cardano-api Tx + receivedTxs <- mapM toTx cborTxs + let receivedTxIds = txId <$> receivedTxs + let point = toChainPoint block + traceWith tracer RollForward{point, receivedTxIds} + + -- Collect head observations + let (adjustedUTxO, observations) = observeAll networkId utxo receivedTxs + let onChainTxs = mapMaybe convertObservation observations + forM_ onChainTxs (traceWith tracer . logOnChainTx) + + blockNo <- maybe (throwIO $ MissingBlockNo _blockHash) (pure . fromInteger) _blockHeight + let observationsAt = HeadObservation point blockNo <$> onChainTxs + + -- Call observer handler + observerHandler $ + if null observationsAt + then [Tick point blockNo] + else observationsAt + + -- Next + pure (nextBlockHash, adjustedUTxO) + +-- * Helpers + +isRetryable :: APIBlockfrostError -> Bool +isRetryable (BlockfrostError _) = True +isRetryable (DecodeError _) = False +isRetryable (NotEnoughBlockConfirmations _) = True +isRetryable (MissingBlockNo _) = True +isRetryable (MissingNextBlockHash _) = True + +toChainPoint :: Blockfrost.Block -> ChainPoint +toChainPoint Blockfrost.Block{_blockSlot, _blockHash} = + ChainPoint slotNo headerHash + where + slotNo :: SlotNo + slotNo = maybe 0 (fromInteger . Blockfrost.unSlot) _blockSlot + + headerHash :: Hash BlockHeader + headerHash = fromString . toString $ Blockfrost.unBlockHash _blockHash + +fromNetworkMagic :: Integer -> NetworkId +fromNetworkMagic = \case + 0 -> Mainnet + magicNbr -> Testnet (NetworkMagic (fromInteger magicNbr)) + +toTx :: MonadThrow m => Blockfrost.TransactionCBOR -> m Tx +toTx (Blockfrost.TransactionCBOR txCbor) = + case decodeBase16 txCbor of + Left decodeErr -> throwIO . DecodeError $ "Bad Base16 Tx CBOR: " <> decodeErr + Right bytes -> + case deserialiseFromCBOR (proxyToAsType (Proxy @Tx)) bytes of + Left deserializeErr -> throwIO . DecodeError $ "Bad Tx CBOR: " <> show deserializeErr + Right tx -> pure tx + +fromChainPoint :: ChainPoint -> Text -> Blockfrost.BlockHash +fromChainPoint chainPoint genesisBlockHash = case chainPoint of + ChainPoint _ headerHash -> Blockfrost.BlockHash (decodeUtf8 . Base16.encode . serialiseToRawBytes $ headerHash) + ChainPointAtGenesis -> Blockfrost.BlockHash genesisBlockHash diff --git a/hydra-chain-observer/src/Hydra/ChainObserver.hs b/hydra-chain-observer/src/Hydra/ChainObserver.hs index 3c8932177cd..ac21e82750b 100644 --- a/hydra-chain-observer/src/Hydra/ChainObserver.hs +++ b/hydra-chain-observer/src/Hydra/ChainObserver.hs @@ -4,230 +4,25 @@ module Hydra.ChainObserver where import Hydra.Prelude -import Hydra.Cardano.Api ( - BlockHeader (BlockHeader), - BlockInMode (..), - BlockNo, - CardanoEra (..), - ChainPoint, - ChainSyncClient, - ChainTip, - ConsensusModeParams (..), - EpochSlots (..), - LocalChainSyncClient (..), - LocalNodeClientProtocols (..), - LocalNodeConnectInfo (..), - NetworkId, - SocketPath, - Tx, - UTxO, - connectToLocalNode, - getChainPoint, - getTxBody, - getTxId, - pattern Block, - ) -import Hydra.Cardano.Api.Prelude (TxId) -import Hydra.Chain (OnChainTx (..)) -import Hydra.Chain.CardanoClient (queryTip) -import Hydra.Chain.Direct.Handlers (convertObservation) -import Hydra.Chain.Direct.Tx ( - HeadObservation (..), - observeHeadTx, - ) -import Hydra.ChainObserver.Options (Options (..), hydraChainObserverOptions) -import Hydra.Contract (ScriptInfo) +import Hydra.Blockfrost.ChainObserver (blockfrostClient) +import Hydra.ChainObserver.NodeClient (ChainObserverLog (..), NodeClient (..), ObserverHandler) +import Hydra.ChainObserver.Options (BlockfrostOptions (..), DirectOptions (..), Options (..), hydraChainObserverOptions) import Hydra.Contract qualified as Contract -import Hydra.Ledger.Cardano (adjustUTxO) -import Hydra.Logging (Tracer, Verbosity (..), traceWith, withTracer) -import Hydra.Tx.HeadId (HeadId (..)) +import Hydra.Logging (Verbosity (..), traceWith, withTracer) +import Hydra.Ouroborus.ChainObserver (ouroborusClient) import Options.Applicative (execParser) -import Ouroboros.Network.Protocol.ChainSync.Client ( - ChainSyncClient (..), - ClientStIdle (..), - ClientStIntersect (..), - ClientStNext (..), - ) - -type ObserverHandler m = [ChainObservation] -> m () - -data ChainObservation - = Tick - { point :: ChainPoint - , blockNo :: BlockNo - } - | HeadObservation - { point :: ChainPoint - , blockNo :: BlockNo - , onChainTx :: OnChainTx Tx - } - deriving stock (Eq, Show, Generic) - -instance Arbitrary ChainObservation where - arbitrary = genericArbitrary - -defaultObserverHandler :: Applicative m => ObserverHandler m -defaultObserverHandler = const $ pure () main :: ObserverHandler IO -> IO () main observerHandler = do - Options{networkId, nodeSocket, startChainFrom} <- execParser hydraChainObserverOptions + opts <- execParser hydraChainObserverOptions withTracer (Verbose "hydra-chain-observer") $ \tracer -> do traceWith tracer KnownScripts{scriptInfo = Contract.scriptInfo} - traceWith tracer ConnectingToNode{nodeSocket, networkId} - chainPoint <- case startChainFrom of - Nothing -> queryTip networkId nodeSocket - Just x -> pure x - traceWith tracer StartObservingFrom{chainPoint} - connectToLocalNode - (connectInfo nodeSocket networkId) - (clientProtocols tracer networkId chainPoint observerHandler) - -type ChainObserverLog :: Type -data ChainObserverLog - = KnownScripts {scriptInfo :: ScriptInfo} - | ConnectingToNode {nodeSocket :: SocketPath, networkId :: NetworkId} - | StartObservingFrom {chainPoint :: ChainPoint} - | HeadInitTx {headId :: HeadId} - | HeadCommitTx {headId :: HeadId} - | HeadCollectComTx {headId :: HeadId} - | HeadDepositTx {headId :: HeadId} - | HeadRecoverTx {headId :: HeadId} - | HeadIncrementTx {headId :: HeadId} - | HeadDecrementTx {headId :: HeadId} - | HeadCloseTx {headId :: HeadId} - | HeadFanoutTx {headId :: HeadId} - | HeadAbortTx {headId :: HeadId} - | HeadContestTx {headId :: HeadId} - | Rollback {point :: ChainPoint} - | RollForward {point :: ChainPoint, receivedTxIds :: [TxId]} - deriving stock (Eq, Show, Generic) - deriving anyclass (ToJSON) - -type BlockType :: Type -type BlockType = BlockInMode - -connectInfo :: SocketPath -> NetworkId -> LocalNodeConnectInfo -connectInfo nodeSocket networkId = - LocalNodeConnectInfo - { -- REVIEW: This was 432000 before, but all usages in the - -- cardano-node repository are using this value. This is only - -- relevant for the Byron era. - localConsensusModeParams = CardanoModeParams (EpochSlots 21600) - , localNodeNetworkId = networkId - , localNodeSocketPath = nodeSocket - } - -clientProtocols :: - Tracer IO ChainObserverLog -> - NetworkId -> - ChainPoint -> - ObserverHandler IO -> - LocalNodeClientProtocols BlockType ChainPoint ChainTip slot tx txid txerr query IO -clientProtocols tracer networkId startingPoint observerHandler = - LocalNodeClientProtocols - { localChainSyncClient = LocalChainSyncClient $ chainSyncClient tracer networkId startingPoint observerHandler - , localTxSubmissionClient = Nothing - , localStateQueryClient = Nothing - , localTxMonitoringClient = Nothing - } - --- | Thrown when the user-provided custom point of intersection is unknown to --- the local node. This may happen if users shut down their node quickly after --- starting them and hold on a not-so-stable point of the chain. When they turn --- the node back on, that point may no longer exist on the network if a fork --- with deeper roots has been adopted in the meantime. -type IntersectionNotFoundException :: Type -newtype IntersectionNotFoundException = IntersectionNotFound {requestedPoint :: ChainPoint} - deriving stock (Show) - -instance Exception IntersectionNotFoundException - --- | Fetch all blocks via chain sync and trace their contents. -chainSyncClient :: - forall m. - MonadThrow m => - Tracer m ChainObserverLog -> - NetworkId -> - ChainPoint -> - ObserverHandler m -> - ChainSyncClient BlockType ChainPoint ChainTip m () -chainSyncClient tracer networkId startingPoint observerHandler = - ChainSyncClient $ - pure $ - SendMsgFindIntersect [startingPoint] clientStIntersect - where - clientStIntersect :: ClientStIntersect BlockType ChainPoint ChainTip m () - clientStIntersect = - ClientStIntersect - { recvMsgIntersectFound = \_ _ -> - ChainSyncClient (pure $ clientStIdle mempty) - , recvMsgIntersectNotFound = \_ -> - ChainSyncClient $ throwIO (IntersectionNotFound startingPoint) - } - - clientStIdle :: UTxO -> ClientStIdle BlockType ChainPoint ChainTip m () - clientStIdle utxo = SendMsgRequestNext (pure ()) (clientStNext utxo) - - clientStNext :: UTxO -> ClientStNext BlockType ChainPoint ChainTip m () - clientStNext utxo = - ClientStNext - { recvMsgRollForward = \blockInMode _tip -> ChainSyncClient $ do - let receivedTxIds = case blockInMode of - BlockInMode ConwayEra (Block _ conwayTxs) -> getTxId . getTxBody <$> conwayTxs - _ -> [] - - (BlockInMode _ (Block bh@(BlockHeader _ _ blockNo) _)) = blockInMode - point = getChainPoint bh - traceWith tracer RollForward{point, receivedTxIds} - - let txs = case blockInMode of - BlockInMode ConwayEra (Block _ conwayTxs) -> conwayTxs - _ -> [] - - (utxo', observations) = observeAll networkId utxo txs - onChainTxs = mapMaybe convertObservation observations - - forM_ onChainTxs (traceWith tracer . logOnChainTx) - let observationsAt = HeadObservation point blockNo <$> onChainTxs - observerHandler $ - if null observationsAt - then [Tick point blockNo] - else observationsAt - - pure $ clientStIdle utxo' - , recvMsgRollBackward = \point _tip -> ChainSyncClient $ do - traceWith tracer Rollback{point} - pure $ clientStIdle utxo - } - - logOnChainTx :: OnChainTx Tx -> ChainObserverLog - logOnChainTx = \case - OnInitTx{headId} -> HeadInitTx{headId} - OnCommitTx{headId} -> HeadCommitTx{headId} - OnCollectComTx{headId} -> HeadCollectComTx{headId} - OnIncrementTx{headId} -> HeadIncrementTx{headId} - OnDepositTx{headId} -> HeadDepositTx{headId} - OnRecoverTx{headId} -> HeadRecoverTx{headId} - OnDecrementTx{headId} -> HeadDecrementTx{headId} - OnCloseTx{headId} -> HeadCloseTx{headId} - OnFanoutTx{headId} -> HeadFanoutTx{headId} - OnAbortTx{headId} -> HeadAbortTx{headId} - OnContestTx{headId} -> HeadContestTx{headId} - -observeTx :: NetworkId -> UTxO -> Tx -> (UTxO, Maybe HeadObservation) -observeTx networkId utxo tx = - let utxo' = adjustUTxO tx utxo - in case observeHeadTx networkId utxo tx of - NoHeadTx -> (utxo, Nothing) - observation -> (utxo', pure observation) - -observeAll :: NetworkId -> UTxO -> [Tx] -> (UTxO, [HeadObservation]) -observeAll networkId utxo txs = - second reverse $ foldr go (utxo, []) txs - where - go :: Tx -> (UTxO, [HeadObservation]) -> (UTxO, [HeadObservation]) - go tx (utxo'', observations) = - case observeTx networkId utxo'' tx of - (utxo', Nothing) -> (utxo', observations) - (utxo', Just observation) -> (utxo', observation : observations) + case opts of + DirectOpts DirectOptions{networkId, nodeSocket, startChainFrom} -> do + let NodeClient{follow} = ouroborusClient tracer nodeSocket networkId + follow startChainFrom observerHandler + BlockfrostOpts BlockfrostOptions{projectPath, startChainFrom} -> do + -- FIXME: should be configurable + let blockConfirmations = 1 + NodeClient{follow} = blockfrostClient tracer projectPath blockConfirmations + follow startChainFrom observerHandler diff --git a/hydra-chain-observer/src/Hydra/ChainObserver/NodeClient.hs b/hydra-chain-observer/src/Hydra/ChainObserver/NodeClient.hs new file mode 100644 index 00000000000..ecf87a10391 --- /dev/null +++ b/hydra-chain-observer/src/Hydra/ChainObserver/NodeClient.hs @@ -0,0 +1,100 @@ +{-# LANGUAGE DuplicateRecordFields #-} + +module Hydra.ChainObserver.NodeClient where + +import Hydra.Prelude + +import Hydra.Cardano.Api ( + BlockNo, + ChainPoint, + NetworkId, + SocketPath, + Tx, + UTxO, + ) +import Hydra.Cardano.Api.Prelude (TxId) +import Hydra.Chain (OnChainTx (..)) +import Hydra.Chain.Direct.Tx ( + HeadObservation (..), + observeHeadTx, + ) +import Hydra.Contract (ScriptInfo) +import Hydra.Ledger.Cardano (adjustUTxO) +import Hydra.Tx.HeadId (HeadId (..)) + +type ObserverHandler m = [ChainObservation] -> m () + +data ChainObservation + = Tick + { point :: ChainPoint + , blockNo :: BlockNo + } + | HeadObservation + { point :: ChainPoint + , blockNo :: BlockNo + , onChainTx :: OnChainTx Tx + } + deriving stock (Eq, Show, Generic) + +instance Arbitrary ChainObservation where + arbitrary = genericArbitrary + +defaultObserverHandler :: Applicative m => ObserverHandler m +defaultObserverHandler = const $ pure () + +newtype NodeClient m = NodeClient + { follow :: Maybe ChainPoint -> ObserverHandler m -> m () + } + +type ChainObserverLog :: Type +data ChainObserverLog + = KnownScripts {scriptInfo :: ScriptInfo} + | ConnectingToNode {nodeSocket :: SocketPath, networkId :: NetworkId} + | ConnectingToExternalNode {networkId :: NetworkId} + | StartObservingFrom {chainPoint :: ChainPoint} + | HeadInitTx {headId :: HeadId} + | HeadCommitTx {headId :: HeadId} + | HeadCollectComTx {headId :: HeadId} + | HeadDepositTx {headId :: HeadId} + | HeadRecoverTx {headId :: HeadId} + | HeadIncrementTx {headId :: HeadId} + | HeadDecrementTx {headId :: HeadId} + | HeadCloseTx {headId :: HeadId} + | HeadFanoutTx {headId :: HeadId} + | HeadAbortTx {headId :: HeadId} + | HeadContestTx {headId :: HeadId} + | Rollback {point :: ChainPoint} + | RollForward {point :: ChainPoint, receivedTxIds :: [TxId]} + deriving stock (Eq, Show, Generic) + deriving anyclass (ToJSON) + +logOnChainTx :: OnChainTx Tx -> ChainObserverLog +logOnChainTx = \case + OnInitTx{headId} -> HeadInitTx{headId} + OnCommitTx{headId} -> HeadCommitTx{headId} + OnCollectComTx{headId} -> HeadCollectComTx{headId} + OnIncrementTx{headId} -> HeadIncrementTx{headId} + OnDepositTx{headId} -> HeadDepositTx{headId} + OnRecoverTx{headId} -> HeadRecoverTx{headId} + OnDecrementTx{headId} -> HeadDecrementTx{headId} + OnCloseTx{headId} -> HeadCloseTx{headId} + OnFanoutTx{headId} -> HeadFanoutTx{headId} + OnAbortTx{headId} -> HeadAbortTx{headId} + OnContestTx{headId} -> HeadContestTx{headId} + +observeTx :: NetworkId -> UTxO -> Tx -> (UTxO, Maybe HeadObservation) +observeTx networkId utxo tx = + let utxo' = adjustUTxO tx utxo + in case observeHeadTx networkId utxo tx of + NoHeadTx -> (utxo, Nothing) + observation -> (utxo', pure observation) + +observeAll :: NetworkId -> UTxO -> [Tx] -> (UTxO, [HeadObservation]) +observeAll networkId utxo txs = + second reverse $ foldr go (utxo, []) txs + where + go :: Tx -> (UTxO, [HeadObservation]) -> (UTxO, [HeadObservation]) + go tx (utxo'', observations) = + case observeTx networkId utxo'' tx of + (utxo', Nothing) -> (utxo', observations) + (utxo', Just observation) -> (utxo', observation : observations) diff --git a/hydra-chain-observer/src/Hydra/ChainObserver/Options.hs b/hydra-chain-observer/src/Hydra/ChainObserver/Options.hs index 6d9ed65f20e..08c77f8b68b 100644 --- a/hydra-chain-observer/src/Hydra/ChainObserver/Options.hs +++ b/hydra-chain-observer/src/Hydra/ChainObserver/Options.hs @@ -1,3 +1,5 @@ +{-# LANGUAGE DuplicateRecordFields #-} + module Hydra.ChainObserver.Options where import Hydra.Prelude @@ -8,10 +10,25 @@ import Hydra.Options ( nodeSocketParser, startChainFromParser, ) -import Options.Applicative (Parser, ParserInfo, fullDesc, header, helper, info, progDesc) +import Options.Applicative ( + Parser, + ParserInfo, + command, + fullDesc, + header, + help, + helper, + hsubparser, + info, + long, + metavar, + option, + progDesc, + str, + value, + ) -type Options :: Type -data Options = Options +data DirectOptions = DirectOptions { networkId :: NetworkId , nodeSocket :: SocketPath , startChainFrom :: Maybe ChainPoint @@ -19,17 +36,64 @@ data Options = Options } deriving stock (Show, Eq) -optionsParser :: Parser Options -optionsParser = - Options - <$> networkIdParser - <*> nodeSocketParser - <*> optional startChainFromParser +data BlockfrostOptions = BlockfrostOptions + { projectPath :: FilePath + , startChainFrom :: Maybe ChainPoint + -- ^ Point at which to start following the chain. + } + deriving stock (Show, Eq) + +type Options :: Type +data Options = DirectOpts DirectOptions | BlockfrostOpts BlockfrostOptions + deriving stock (Show, Eq) + +directOptionsParser :: Parser Options +directOptionsParser = + DirectOpts + <$> ( DirectOptions + <$> networkIdParser + <*> nodeSocketParser + <*> optional startChainFromParser + ) + +blockfrostOptionsParser :: Parser Options +blockfrostOptionsParser = + BlockfrostOpts + <$> ( BlockfrostOptions + <$> projectPathParser + <*> optional startChainFromParser + ) + +projectPathParser :: Parser FilePath +projectPathParser = + option str $ + long "project-path" + <> metavar "BLOCKFROST_TOKEN_PATH" + <> value "project_token_hash" + <> help + "The path where the Blockfrost project token hash is stored.\ + \It expects token prefixed with Blockfrost environment name\ + \e.g.: testnet-someTokenHash" + +directOptionsInfo :: ParserInfo Options +directOptionsInfo = + info + directOptionsParser + (progDesc "Direct Mode") + +blockfrostOptionsInfo :: ParserInfo Options +blockfrostOptionsInfo = + info + blockfrostOptionsParser + (progDesc "Blockfrost Mode") hydraChainObserverOptions :: ParserInfo Options hydraChainObserverOptions = info - ( optionsParser + ( hsubparser + ( command "direct" directOptionsInfo + <> command "blockfrost" blockfrostOptionsInfo + ) <**> helper ) ( fullDesc diff --git a/hydra-chain-observer/src/Hydra/Ouroborus/ChainObserver.hs b/hydra-chain-observer/src/Hydra/Ouroborus/ChainObserver.hs new file mode 100644 index 00000000000..2953267ce87 --- /dev/null +++ b/hydra-chain-observer/src/Hydra/Ouroborus/ChainObserver.hs @@ -0,0 +1,159 @@ +{-# LANGUAGE DuplicateRecordFields #-} + +module Hydra.Ouroborus.ChainObserver where + +import Hydra.Prelude + +import Hydra.Cardano.Api ( + BlockHeader (BlockHeader), + BlockInMode (..), + CardanoEra (..), + ChainPoint, + ChainSyncClient, + ChainTip, + ConsensusModeParams (..), + EpochSlots (..), + LocalChainSyncClient (..), + LocalNodeClientProtocols (..), + LocalNodeConnectInfo (..), + NetworkId, + SocketPath, + UTxO, + connectToLocalNode, + getChainPoint, + getTxBody, + getTxId, + pattern Block, + ) +import Hydra.Chain.CardanoClient (queryTip) +import Hydra.Chain.Direct.Handlers (convertObservation) +import Hydra.ChainObserver.NodeClient ( + ChainObservation (..), + ChainObserverLog (..), + NodeClient (..), + ObserverHandler, + logOnChainTx, + observeAll, + ) +import Hydra.Logging (Tracer, traceWith) +import Ouroboros.Network.Protocol.ChainSync.Client ( + ChainSyncClient (..), + ClientStIdle (..), + ClientStIntersect (..), + ClientStNext (..), + ) + +ouroborusClient :: + Tracer IO ChainObserverLog -> + SocketPath -> + NetworkId -> + NodeClient IO +ouroborusClient tracer nodeSocket networkId = + NodeClient + { follow = \startChainFrom observerHandler -> do + traceWith tracer ConnectingToNode{nodeSocket, networkId} + chainPoint <- case startChainFrom of + Nothing -> queryTip networkId nodeSocket + Just x -> pure x + traceWith tracer StartObservingFrom{chainPoint} + connectToLocalNode + (connectInfo nodeSocket networkId) + (clientProtocols tracer networkId chainPoint observerHandler) + } + +type BlockType :: Type +type BlockType = BlockInMode + +connectInfo :: SocketPath -> NetworkId -> LocalNodeConnectInfo +connectInfo nodeSocket networkId = + LocalNodeConnectInfo + { -- REVIEW: This was 432000 before, but all usages in the + -- cardano-node repository are using this value. This is only + -- relevant for the Byron era. + localConsensusModeParams = CardanoModeParams (EpochSlots 21600) + , localNodeNetworkId = networkId + , localNodeSocketPath = nodeSocket + } + +clientProtocols :: + Tracer IO ChainObserverLog -> + NetworkId -> + ChainPoint -> + ObserverHandler IO -> + LocalNodeClientProtocols BlockType ChainPoint ChainTip slot tx txid txerr query IO +clientProtocols tracer networkId startingPoint observerHandler = + LocalNodeClientProtocols + { localChainSyncClient = LocalChainSyncClient $ chainSyncClient tracer networkId startingPoint observerHandler + , localTxSubmissionClient = Nothing + , localStateQueryClient = Nothing + , localTxMonitoringClient = Nothing + } + +-- | Thrown when the user-provided custom point of intersection is unknown to +-- the local node. This may happen if users shut down their node quickly after +-- starting them and hold on a not-so-stable point of the chain. When they turn +-- the node back on, that point may no longer exist on the network if a fork +-- with deeper roots has been adopted in the meantime. +type IntersectionNotFoundException :: Type +newtype IntersectionNotFoundException = IntersectionNotFound {requestedPoint :: ChainPoint} + deriving stock (Show) + +instance Exception IntersectionNotFoundException + +-- | Fetch all blocks via chain sync and trace their contents. +chainSyncClient :: + forall m. + MonadThrow m => + Tracer m ChainObserverLog -> + NetworkId -> + ChainPoint -> + ObserverHandler m -> + ChainSyncClient BlockType ChainPoint ChainTip m () +chainSyncClient tracer networkId startingPoint observerHandler = + ChainSyncClient $ + pure $ + SendMsgFindIntersect [startingPoint] clientStIntersect + where + clientStIntersect :: ClientStIntersect BlockType ChainPoint ChainTip m () + clientStIntersect = + ClientStIntersect + { recvMsgIntersectFound = \_ _ -> + ChainSyncClient (pure $ clientStIdle mempty) + , recvMsgIntersectNotFound = \_ -> + ChainSyncClient $ throwIO (IntersectionNotFound startingPoint) + } + + clientStIdle :: UTxO -> ClientStIdle BlockType ChainPoint ChainTip m () + clientStIdle utxo = SendMsgRequestNext (pure ()) (clientStNext utxo) + + clientStNext :: UTxO -> ClientStNext BlockType ChainPoint ChainTip m () + clientStNext utxo = + ClientStNext + { recvMsgRollForward = \blockInMode _tip -> ChainSyncClient $ do + let receivedTxIds = case blockInMode of + BlockInMode ConwayEra (Block _ conwayTxs) -> getTxId . getTxBody <$> conwayTxs + _ -> [] + + (BlockInMode _ (Block bh@(BlockHeader _ _ blockNo) _)) = blockInMode + point = getChainPoint bh + traceWith tracer RollForward{point, receivedTxIds} + + let txs = case blockInMode of + BlockInMode ConwayEra (Block _ conwayTxs) -> conwayTxs + _ -> [] + + (utxo', observations) = observeAll networkId utxo txs + onChainTxs = mapMaybe convertObservation observations + + forM_ onChainTxs (traceWith tracer . logOnChainTx) + let observationsAt = HeadObservation point blockNo <$> onChainTxs + observerHandler $ + if null observationsAt + then [Tick point blockNo] + else observationsAt + + pure $ clientStIdle utxo' + , recvMsgRollBackward = \point _tip -> ChainSyncClient $ do + traceWith tracer Rollback{point} + pure $ clientStIdle utxo + } diff --git a/hydra-chain-observer/test/Hydra/ChainObserverSpec.hs b/hydra-chain-observer/test/Hydra/ChainObserverSpec.hs index de9add5d53d..dcea24cabf5 100644 --- a/hydra-chain-observer/test/Hydra/ChainObserverSpec.hs +++ b/hydra-chain-observer/test/Hydra/ChainObserverSpec.hs @@ -7,7 +7,7 @@ import Hydra.Cardano.Api (utxoFromTx) import Hydra.Chain.Direct.State (HasKnownUTxO (getKnownUTxO), genChainStateWithTx) import Hydra.Chain.Direct.State qualified as Transition import Hydra.Chain.Direct.Tx (HeadObservation (..)) -import Hydra.ChainObserver (observeAll, observeTx) +import Hydra.ChainObserver.NodeClient (observeAll, observeTx) import Hydra.Ledger.Cardano (genSequenceOfSimplePaymentTransactions) import Test.Hydra.Tx.Fixture (testNetworkId) import Test.QuickCheck (counterexample, forAll, forAllBlind, property, (=/=), (===)) diff --git a/hydra-cluster/test/Test/ChainObserverSpec.hs b/hydra-cluster/test/Test/ChainObserverSpec.hs index 692c8a8edff..84cce372931 100644 --- a/hydra-cluster/test/Test/ChainObserverSpec.hs +++ b/hydra-cluster/test/Test/ChainObserverSpec.hs @@ -166,7 +166,7 @@ withChainObserver cardanoNode action = process = proc "hydra-chain-observer" - $ ["--node-socket", unFile nodeSocket] + $ ["direct", "--node-socket", unFile nodeSocket] <> case networkId of Mainnet -> ["--mainnet"] Testnet (NetworkMagic magic) -> ["--testnet-magic", show magic] diff --git a/hydra-cluster/test/Test/HydraExplorerSpec.hs b/hydra-cluster/test/Test/HydraExplorerSpec.hs index dae6ef05dbc..1194bc781fd 100644 --- a/hydra-cluster/test/Test/HydraExplorerSpec.hs +++ b/hydra-cluster/test/Test/HydraExplorerSpec.hs @@ -150,7 +150,8 @@ withHydraExplorer cardanoNode mStartChainFrom action = process = proc "hydra-explorer" - $ Options.toArgNodeSocket nodeSocket + $ ["direct"] + <> Options.toArgNodeSocket nodeSocket <> Options.toArgNetworkId networkId <> Options.toArgApiPort 9090 <> toArgStartChainFrom mStartChainFrom diff --git a/hydra-explorer/README.md b/hydra-explorer/README.md index afb451dbb87..852a3894c49 100644 --- a/hydra-explorer/README.md +++ b/hydra-explorer/README.md @@ -3,14 +3,27 @@ A small executable which connects to a chain like the `hydra-node`, but puts any observations as traces onto `stdout`. -To run, pass a `--node-socket` and the corresponding network id. For example: +It supports two modes of operation: **Direct** connection to a node via socket, and connection through **Blockfrost** API. -``` shell -hydra-explorer \ +By definition, hydra-explorer will bind port 9090. + +## Direct Mode + +To run from the tip, just pass a `--node-socket` and the corresponding network id. For example: + +```shell +hydra-explorer direct \ --node-socket testnets/preprod/node.socket \ --testnet-magic 1 ``` Note: this assumes you are running a cardano-node in preprod. -By definition, hydra-explorer will bind port 9090. \ No newline at end of file +## Blockfrost Mode + +To run from the tip, just pass a `--project-path`. For example: + +```shell +hydra-explorer blockfrost \ + --project-path .vscode/blockfrost/project_token_hash +``` diff --git a/hydra-explorer/src/Hydra/Explorer.hs b/hydra-explorer/src/Hydra/Explorer.hs index 89843fbdaf0..e3a57c5f28b 100644 --- a/hydra-explorer/src/Hydra/Explorer.hs +++ b/hydra-explorer/src/Hydra/Explorer.hs @@ -5,9 +5,9 @@ import Hydra.Prelude import Control.Concurrent.Class.MonadSTM (modifyTVar', newTVarIO, readTVarIO) import Hydra.API.APIServerLog (APIServerLog (..), Method (..), PathInfo (..)) -import Hydra.ChainObserver (ChainObservation) +import Hydra.ChainObserver.NodeClient (ChainObservation) import Hydra.Explorer.ExplorerState (ExplorerState (..), HeadState, TickState, aggregateHeadObservations, initialTickState) -import Hydra.Explorer.Options (Options (..), toArgStartChainFrom) +import Hydra.Explorer.Options (BlockfrostOptions (..), DirectOptions (..), Options (..), toArgProjectPath, toArgStartChainFrom) import Hydra.Logging (Tracer, Verbosity (..), traceWith, withTracer) import Hydra.Options qualified as Options import Network.Wai (Middleware, Request (..)) @@ -84,25 +84,30 @@ run opts = do (getExplorerState, modifyExplorerState) <- createExplorerState let chainObserverArgs = - Options.toArgNodeSocket nodeSocket - <> Options.toArgNetworkId networkId - <> toArgStartChainFrom startChainFrom + case opts of + DirectOpts DirectOptions{networkId, nodeSocket, startChainFrom} -> + ["direct"] + <> Options.toArgNodeSocket nodeSocket + <> Options.toArgNetworkId networkId + <> toArgStartChainFrom startChainFrom + BlockfrostOpts BlockfrostOptions{projectPath, startChainFrom} -> + ["blockfrost"] + <> toArgProjectPath projectPath + <> toArgStartChainFrom startChainFrom race_ ( withArgs chainObserverArgs $ Hydra.ChainObserver.main (observerHandler modifyExplorerState) ) (Warp.runSettings (settings tracer) (httpApp tracer getExplorerState)) where + portToBind = + case opts of + DirectOpts DirectOptions{port} -> port + BlockfrostOpts BlockfrostOptions{port} -> port + settings tracer = Warp.defaultSettings - & Warp.setPort (fromIntegral port) + & Warp.setPort (fromIntegral portToBind) & Warp.setHost "0.0.0.0" - & Warp.setBeforeMainLoop (traceWith tracer $ APIServerStarted port) + & Warp.setBeforeMainLoop (traceWith tracer $ APIServerStarted portToBind) & Warp.setOnException (\_ e -> traceWith tracer $ APIConnectionError{reason = show e}) - - Options - { networkId - , port - , nodeSocket - , startChainFrom - } = opts diff --git a/hydra-explorer/src/Hydra/Explorer/ExplorerState.hs b/hydra-explorer/src/Hydra/Explorer/ExplorerState.hs index 45b5143a9ab..e73163bc454 100644 --- a/hydra-explorer/src/Hydra/Explorer/ExplorerState.hs +++ b/hydra-explorer/src/Hydra/Explorer/ExplorerState.hs @@ -10,7 +10,7 @@ import Hydra.Chain (OnChainTx (..)) import Hydra.Chain.Direct.Tx ( headSeedToTxIn, ) -import Hydra.ChainObserver (ChainObservation (..)) +import Hydra.ChainObserver.NodeClient (ChainObservation (..)) import Hydra.Tx.ContestationPeriod (ContestationPeriod, toNominalDiffTime) import Hydra.Tx.HeadParameters (HeadParameters (..)) import Hydra.Tx.OnChainId (OnChainId) diff --git a/hydra-explorer/src/Hydra/Explorer/Options.hs b/hydra-explorer/src/Hydra/Explorer/Options.hs index aa8c3f01327..c59aaa1470d 100644 --- a/hydra-explorer/src/Hydra/Explorer/Options.hs +++ b/hydra-explorer/src/Hydra/Explorer/Options.hs @@ -3,17 +3,33 @@ module Hydra.Explorer.Options where import Hydra.Prelude import Hydra.Cardano.Api (ChainPoint (..), NetworkId, SlotNo (..), SocketPath, serialiseToRawBytesHexText) -import Hydra.Network (PortNumber) +import Hydra.ChainObserver.Options (projectPathParser) +import Hydra.Network (PortNumber, readPort) import Hydra.Options ( - apiPortParser, networkIdParser, nodeSocketParser, startChainFromParser, ) -import Options.Applicative (Parser, ParserInfo, fullDesc, header, helper, info, progDesc) +import Options.Applicative ( + Parser, + ParserInfo, + command, + fullDesc, + header, + help, + helper, + hsubparser, + info, + long, + maybeReader, + metavar, + option, + progDesc, + showDefault, + value, + ) -type Options :: Type -data Options = Options +data DirectOptions = DirectOptions { networkId :: NetworkId , port :: PortNumber , nodeSocket :: SocketPath @@ -21,18 +37,65 @@ data Options = Options } deriving stock (Show, Eq) -optionsParser :: Parser Options -optionsParser = - Options - <$> networkIdParser - <*> apiPortParser - <*> nodeSocketParser - <*> optional startChainFromParser +data BlockfrostOptions = BlockfrostOptions + { port :: PortNumber + , projectPath :: FilePath + , startChainFrom :: Maybe ChainPoint + } + deriving stock (Show, Eq) + +data Options = DirectOpts DirectOptions | BlockfrostOpts BlockfrostOptions + deriving stock (Show, Eq) + +apiPortParser :: Parser PortNumber +apiPortParser = + option + (maybeReader readPort) + ( long "api-port" + <> value 9090 + <> showDefault + <> metavar "PORT" + <> help "Listen port for incoming client API connections." + ) + +directOptionsParser :: Parser Options +directOptionsParser = + DirectOpts + <$> ( DirectOptions + <$> networkIdParser + <*> apiPortParser + <*> nodeSocketParser + <*> optional startChainFromParser + ) + +blockfrostOptionsParser :: Parser Options +blockfrostOptionsParser = + BlockfrostOpts + <$> ( BlockfrostOptions + <$> apiPortParser + <*> projectPathParser + <*> optional startChainFromParser + ) + +directOptionsInfo :: ParserInfo Options +directOptionsInfo = + info + directOptionsParser + (progDesc "Direct Mode") + +blockfrostOptionsInfo :: ParserInfo Options +blockfrostOptionsInfo = + info + blockfrostOptionsParser + (progDesc "Blockfrost Mode") hydraExplorerOptions :: ParserInfo Options hydraExplorerOptions = info - ( optionsParser + ( hsubparser + ( command "direct" directOptionsInfo + <> command "blockfrost" blockfrostOptionsInfo + ) <**> helper ) ( fullDesc @@ -49,3 +112,6 @@ toArgStartChainFrom = \case in ["--start-chain-from", show slotNo <> "." <> headerHashBase16] Nothing -> [] + +toArgProjectPath :: FilePath -> [String] +toArgProjectPath projectPath = ["--project-path", projectPath] diff --git a/hydra-explorer/test/Hydra/Explorer/ExplorerStateSpec.hs b/hydra-explorer/test/Hydra/Explorer/ExplorerStateSpec.hs index 1a348137af3..71bbdf77aed 100644 --- a/hydra-explorer/test/Hydra/Explorer/ExplorerStateSpec.hs +++ b/hydra-explorer/test/Hydra/Explorer/ExplorerStateSpec.hs @@ -3,7 +3,7 @@ module Hydra.Explorer.ExplorerStateSpec where import Hydra.Prelude import Test.Hydra.Prelude -import Hydra.ChainObserver (ChainObservation (..)) +import Hydra.ChainObserver.NodeClient (ChainObservation (..)) import Hydra.Explorer.ExplorerState (ExplorerState (..), HeadState (..), aggregateHeadObservations, initialTickState) import Hydra.Tx.HeadId (HeadId) import Test.QuickCheck (forAll, listOf1, (=/=)) diff --git a/hydra-node/test/Hydra/LoggingSpec.hs b/hydra-node/test/Hydra/LoggingSpec.hs index 9884d0e0e66..545f5915e56 100644 --- a/hydra-node/test/Hydra/LoggingSpec.hs +++ b/hydra-node/test/Hydra/LoggingSpec.hs @@ -19,7 +19,7 @@ spec = do traceWith tracer (object ["foo" .= (42 :: Int)]) -- This test is flakey in CI. Suspected race condition. - liftIO $ threadDelay 2 + liftIO $ threadDelay 5 captured `shouldContain` "{\"foo\":42}"