Skip to content

Commit

Permalink
Derive head id and seed from node id in offline mode (#1551)
Browse files Browse the repository at this point in the history
Something requested by @Quantumplation for `hydra-doom`. Also improved documentation of offline mode.

---

* [x] CHANGELOG updated
* [x] Documentation update not needed
* [x] Haddocks update not needed
* [x] No new TODOs introduced or explained herafter
  • Loading branch information
ch1bo authored Aug 8, 2024
1 parent 4800525 commit a1b5f23
Show file tree
Hide file tree
Showing 6 changed files with 126 additions and 77 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ changes.

- Moved several pages from "core concepts" into the user manual and developer docs to futher improve user journey.

- Offline mode of `hydra-node` uses `--node-id` to derive an artificial offline `headId`.

## [0.17.0] - 2024-05-20

- **BREAKING** Change `hydra-node` API `/commit` endpoint for committing from scripts [#1380](https://github.com/cardano-scaling/hydra/pull/1380):
Expand Down
41 changes: 28 additions & 13 deletions docs/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,23 +94,23 @@ hydra-node publish-scripts \
--cardano-signing-key cardano.sk
```

This command outputs a transaction ID upon success. The provided key should hold sufficient funds (> 50 ada) to create multiple **UNSPENDABLE** UTXO entries on-chain, each carrying a script referenced by the Hydra node.
This command outputs a transaction ID upon success. The provided key should hold sufficient funds (> 50 ada) to create multiple **UNSPENDABLE** UTxO entries on-chain, each carrying a script referenced by the Hydra node.

### Ledger parameters

The ledger is at the core of a Hydra head. Hydra is currently integrated with Cardano and assumes a ledger configuration similar to layer 1. This translates as a command-line option `--ledger-protocol-parameters`. This defines the updatable protocol parameters such as fees or transaction sizes. These parameters follow the same format as the `cardano-cli query protocol-parameters` output.

We provide existing files in [hydra-cluster/config](https://github.com/input-output-hk/hydra/blob/master/hydra-cluster/config) which can be used as the basis. In particular, the protocol parameters nullify costs inside a head. Apart from that, they are the direct copy of the current mainnet parameters. An interesting point about Hydra's ledger is that while it re-uses the same rules and code as layer 1 (isomorphic), some parameters can be altered. For example, fees can be adjusted, but not parameters controlling maximum value sizes or minimum ada values, as altering these could make a head unclosable.

A good rule thumb is that anything that applies strictly to transactions (fees, execution units, max tx size...) is safe to change. But anything that could be reflected in the UTXO is not.
A good rule thumb is that anything that applies strictly to transactions (fees, execution units, max tx size...) is safe to change. But anything that could be reflected in the UTxO is not.

:::info About protocol parameters
Many protocol parameters are irrelevant in the Hydra context (eg, there is no treasury or stake pools within a head). Parameters related to reward incentives or delegation rules are therefore unused.
:::

### Fuel v funds

Transactions driving the head lifecycle (`Init`, `Abort`, `Close`, etc) must be submitted to layer 1 and hence incur costs. Any UTXO owned by the `--cardano-signing-key` provided to the `--hydra-node` can be used to pay fees or serve as collateral for these transactions. We refer to this as **fuel**.
Transactions driving the head lifecycle (`Init`, `Abort`, `Close`, etc) must be submitted to layer 1 and hence incur costs. Any UTxO owned by the `--cardano-signing-key` provided to the `--hydra-node` can be used to pay fees or serve as collateral for these transactions. We refer to this as **fuel**.

Consequently, sending some ada to the address of this 'internal wallet' is required. To get the address for the Cardano keys as generated above, one can use, for example, the `cardano-cli`:

Expand All @@ -122,12 +122,12 @@ cardano-cli address build --verification-key-file cardano.vk --mainnet
<!-- TODO: this part below feels a bit odd here, rather move to API or how-to? -->

While the `hydra-node` needs to pay fees for protocol transactions, any wallet can be used to commit **funds** into an `initializing` Hydra head. The `hydra-node` provides an HTTP endpoint at `/commit`, allowing you to specify either:
- A set of `UTXO` outputs to commit (belonging to public keys), or
- A _blueprint_ transaction along with the `UTXO` that resolves it.
- A set of `UTxO` outputs to commit (belonging to public keys), or
- A _blueprint_ transaction along with the `UTxO` that resolves it.

This endpoint returns a commit transaction, which is balanced, and all fees are paid by the `hydra-node`. The integrated wallet must sign and submit this transaction to the Cardano network. See the [API documentation](pathname:///api-reference/#operation-publish-/commit) for details.

If using your own UTXO to commit to a head, send the appropriate JSON representation of the said UTXO to the `/commit` API endpoint.
If using your own UTxO to commit to a head, send the appropriate JSON representation of the said UTxO to the `/commit` API endpoint.
Using a _blueprint_ transaction with `/commit` offers flexibility, as `hydra-node` adds necessary commit transaction data without removing additional information specified in the blueprint transaction (eg, reference inputs, redeemers, validity ranges).

> Note: Outputs of a blueprint transaction are not considered — only inputs are used to commit funds to the head. The `hydra-node` will also **ignore** any minting or burning specified in the blueprint transaction.
Expand Down Expand Up @@ -171,13 +171,28 @@ If the `hydra-node` already tracks a head in its `state` and `--start-chain-from

## Offline mode

Hydra supports an offline mode, which allows for disabling the layer 1 interface (that is, the underlying Cardano blockchain which Hydra heads use to seed funds and ultimately funds are withdrawn to). Disabling layer 1 interactions allows use cases that would otherwise require running and configuring an entire layer 1 private devnet. For example, the offline mode can be used to quickly validate a series of transactions against a UTXO, without having to spin up an entire layer 1 Cardano node.
Hydra supports an offline mode, which allows for disabling the layer 1 interface (that is, the underlying Cardano blockchain which Hydra heads acquire funds and ultimately funds are withdrawn to). Disabling layer 1 interactions allows use cases that would otherwise require running and configuring an entire layer 1 private devnet. For example, the offline mode can be used to quickly validate a series of transactions against a UTxO, without having to spin up an entire layer 1 Cardano node.

In this offline mode, only the layer 2 ledger is run, along with the Hydra API and persistence, to support interacting with Hydra offline. Therefore, ledger genesis parameters that normally influence things like time-based transaction validation, may be set to defaults that aren't reflective of mainnet. To do this, set `--ledger-protocol-parameters` to a non-zero file, as described [here](https://hydra.family/head-protocol/unstable/docs/configuration/#ledger-parameters).
Depending on your use case, you can [configure your node's event source and sinks](./how-to/event-sinks-and-sources.md) to better suit your needs.
To initialize the layer 2 ledger's UTxO state, offline mode takes an obligatory `--initial-utxo` parameter, which points to a JSON-encoded UTxO file. See the [API reference](https://hydra.family/head-protocol/api-reference/#schema-UTxO) for the schema.

To initialize the layer 2 ledger's UTXO state, offline mode takes an obligatory `--initial-utxo` parameter, which points to a JSON-encoded UTXO file. This UTXO is independent of event source loaded events, and the latter are validated against this UTXO. The UTXO follows the following schema `{ txout : {address, value : {asset : quantity}, datum, datumhash, inlinedatum, referenceScript }`
Using this example UTxO:
```json utxo.json
{
"0000000000000000000000000000000000000000000000000000000000000000#0": {
"address": "addr_test1vqg9ywrpx6e50uam03nlu0ewunh3yrscxmjayurmkp52lfskgkq5k",
"value": {
"lovelace": 100000000
}
}
}
```

An offline mode hydra-node can be started with:
```shell
hydra-node offline \
--hydra-signing-key hydra.sk \
--ledger-protocol-parameters protocol-parameters.json \
--initial-utxo utxo.json
```

An example UTXO:
```json
{"1541287c2598ffc682742c961a96343ac64e9b9030e6b03a476bb18c8c50134d#0":{"address":"addr_test1vqg9ywrpx6e50uam03nlu0ewunh3yrscxmjayurmkp52lfskgkq5k","datum":null,"datumhash":null,"inlineDatum":null,"referenceScript":null,"value":{"lovelace":100000000}},"39786f186d94d8dd0b4fcf05d1458b18cd5fd8c6823364612f4a3c11b77e7cc7#0":{"address":"addr_test1vru2drx33ev6dt8gfq245r5k0tmy7ngqe79va69de9dxkrg09c7d3","datum":null,"datumhash":null,"inlineDatum":null,"referenceScript":null,"value":{"lovelace":100000000}}}```
As the node is not connected to a real network, genesis parameters that normally influence things like time-based transaction validation cannot be fetched and are set to defaults. To configure block times, set `--ledger-genesis` to a Shelley genesis file similar to the [shelley-genesis.json](https://book.world.dev.cardano.org/environments/mainnet/shelley-genesis.json).
45 changes: 35 additions & 10 deletions hydra-cluster/test/Test/OfflineChainSpec.hs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import Control.Lens ((^?))
import Data.Aeson qualified as Aeson
import Data.Aeson.Lens (key, _Number)
import Hydra.Cardano.Api (UTxO)
import Hydra.Chain (ChainCallback, ChainEvent (..), initHistory)
import Hydra.Chain (ChainCallback, ChainEvent (..), OnChainTx (..), initHistory)
import Hydra.Chain.Direct.State (initialChainState)
import Hydra.Chain.Offline (withOfflineChain)
import Hydra.Cluster.Fixture (alice)
Expand All @@ -20,6 +20,30 @@ import System.FilePath ((</>))

spec :: Spec
spec = do
it "does derive head id from node id" $ do
withTempDir "hydra-cluster" $ \tmpDir -> do
Aeson.encodeFile (tmpDir </> "utxo.json") (mempty @UTxO)
let offlineConfig =
OfflineChainConfig
{ initialUTxOFile = tmpDir </> "utxo.json"
, ledgerGenesisFile = Nothing
}
-- XXX: this is weird
let chainStateHistory = initHistory initialChainState

(callback, waitNext) <- monitorCallbacks
headId1 <- withOfflineChain "test1" offlineConfig alice chainStateHistory callback $ \_chain ->
waitMatch waitNext 2 $ \case
Observation{observedTx = OnInitTx{headId}} -> pure headId
_ -> Nothing

headId2 <- withOfflineChain "test2" offlineConfig alice chainStateHistory callback $ \_chain ->
waitMatch waitNext 2 $ \case
Observation{observedTx = OnInitTx{headId}} -> pure headId
_ -> Nothing

headId1 `shouldNotBe` headId2

it "does start on slot 0 with no genesis" $ do
withTempDir "hydra-cluster" $ \tmpDir -> do
Aeson.encodeFile (tmpDir </> "utxo.json") (mempty @UTxO)
Expand All @@ -32,11 +56,11 @@ spec = do
let chainStateHistory = initHistory initialChainState

(callback, waitNext) <- monitorCallbacks
withOfflineChain offlineConfig alice chainStateHistory callback $ \_chain -> do
withOfflineChain "test" offlineConfig alice chainStateHistory callback $ \_chain -> do
-- Expect to see a tick of slot 1 within 2 seconds
waitMatch waitNext 2 $ \case
Tick{chainSlot} -> chainSlot > 0
_ -> False
Tick{chainSlot} -> guard $ chainSlot > 0
_ -> Nothing

it "does not start on slot 0 with real genesis file" $ do
withTempDir "hydra-cluster" $ \tmpDir -> do
Expand All @@ -52,11 +76,11 @@ spec = do
let chainStateHistory = initHistory initialChainState

(callback, waitNext) <- monitorCallbacks
withOfflineChain offlineConfig alice chainStateHistory callback $ \_chain -> do
withOfflineChain "test" offlineConfig alice chainStateHistory callback $ \_chain -> do
-- Should not start at 0
waitMatch waitNext 1 $ \case
Tick{chainSlot} -> chainSlot > 1000
_ -> False
Tick{chainSlot} -> guard $ chainSlot > 1000
_ -> Nothing
-- Should produce ticks on each slot, which is defined by genesis.json
Just slotLength <- readFileBS (tmpDir </> "genesis.json") >>= \bs -> pure $ bs ^? key "slotLength" . _Number
slotTime <-
Expand All @@ -79,7 +103,7 @@ monitorCallbacks = do
pure (callback, waitNext)

-- XXX: Dry with the other waitMatch utilities
waitMatch :: (HasCallStack, ToJSON a) => IO a -> DiffTime -> (a -> Bool) -> IO ()
waitMatch :: (HasCallStack, ToJSON a) => IO a -> DiffTime -> (a -> Maybe b) -> IO b
waitMatch waitNext seconds match = do
seen <- newTVarIO []
timeout seconds (go seen) >>= \case
Expand All @@ -97,5 +121,6 @@ waitMatch waitNext seconds match = do
go seen = do
a <- waitNext
atomically (modifyTVar' seen (a :))
unless (match a) $
go seen
case match a of
Just b -> pure b
Nothing -> go seen
15 changes: 12 additions & 3 deletions hydra-node/json-schemas/api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2087,6 +2087,13 @@ components:
- type: string
contentEncoding: base16
- type: "null"
example: |
{
"address": "addr1w9htvds89a78ex2uls5y969ttry9s3k9etww0staxzndwlgmzuul5",
"value": {
"lovelace": 7620669
}
}
HydraNodeVersion:
type: string
Expand Down Expand Up @@ -2116,9 +2123,11 @@ components:
type: object
propertyNames:
pattern: "^[0-9a-f]{64}#[0-9]+$"
items: # REVIEW: does this work? use additionalProperties here?
additionalProperties:
$ref: "api.yaml#/components/schemas/TxOut"
example:
description: |
A set of unspent transaction outputs. Object keys are 'TxIn' and values are 'TxOut'.
example: |
{
"09d34606abdcd0b10ebc89307cbfa0b469f9144194137b45b7a04b273961add8#687": {
"address": "addr1w9htvds89a78ex2uls5y969ttry9s3k9etww0staxzndwlgmzuul5",
Expand All @@ -2141,7 +2150,7 @@ components:
- blueprintTx
- utxo
properties:
blueptintTx:
blueprintTx:
$ref: "api.yaml#/components/schemas/Transaction"
utxo:
$ref: "api.yaml#/components/schemas/UTxO"
Expand Down
98 changes: 48 additions & 50 deletions hydra-node/src/Hydra/Chain/Offline.hs
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,17 @@ import Hydra.HeadId (HeadId (..), HeadSeed (..))
import Hydra.Ledger (ChainSlot (ChainSlot))
import Hydra.Ledger.Cardano.Configuration (readJsonFileThrow)
import Hydra.Ledger.Cardano.Time (slotNoFromUTCTime, slotNoToUTCTime)
import Hydra.Network (NodeId (nodeId))
import Hydra.Options (OfflineChainConfig (..), defaultContestationPeriod)
import Hydra.Party (Party)

-- | Hard-coded 'HeadId' for all offline head instances.
offlineHeadId :: HeadId
offlineHeadId = UnsafeHeadId "offline"
-- | Derived 'HeadId' of offline head.
offlineHeadId :: NodeId -> HeadId
offlineHeadId = UnsafeHeadId . ("offline-" <>) . encodeUtf8 . nodeId

-- | Hard-coded 'HeadSeed' for all offline head instances.
offlineHeadSeed :: HeadSeed
offlineHeadSeed = UnsafeHeadSeed "offline"
-- | Derived 'HeadSeed' of offline head.
offlineHeadSeed :: NodeId -> HeadSeed
offlineHeadSeed = UnsafeHeadSeed . ("offline-" <>) . encodeUtf8 . nodeId

newtype InitialUTxOParseException = InitialUTxOParseException String
deriving stock (Show)
Expand Down Expand Up @@ -66,69 +67,66 @@ loadGenesisFile ledgerGenesisFile =
Left e -> throwIO $ InitialUTxOParseException e

withOfflineChain ::
NodeId ->
OfflineChainConfig ->
Party ->
-- | Last known chain state as loaded from persistence.
ChainStateHistory Tx ->
ChainComponent Tx IO a
withOfflineChain OfflineChainConfig{ledgerGenesisFile, initialUTxOFile} party chainStateHistory callback action = do
initializeOfflineHead chainStateHistory initialUTxOFile party callback
withOfflineChain nodeId OfflineChainConfig{ledgerGenesisFile, initialUTxOFile} party chainStateHistory callback action = do
initializeOfflineHead
genesis <- loadGenesisFile ledgerGenesisFile
withAsync (tickForever genesis callback) $ \tickThread -> do
link tickThread
action chainHandle
where
headId = offlineHeadId nodeId

chainHandle =
Chain
{ submitTx = const $ pure ()
, draftCommitTx = \_ _ -> pure $ Left FailedToDraftTxNotInitializing
, postTx = const $ pure ()
}

initializeOfflineHead ::
ChainStateHistory Tx ->
FilePath ->
Party ->
(ChainEvent Tx -> IO ()) ->
IO ()
initializeOfflineHead chainStateHistory initialUTxOFile ownParty callback = do
let emptyChainStateHistory = initHistory initialChainState
initializeOfflineHead = do
let emptyChainStateHistory = initHistory initialChainState

-- if we don't have a chainStateHistory to restore from disk from, start a new one
when (chainStateHistory == emptyChainStateHistory) $ do
initialUTxO <- readJsonFileThrow parseJSON initialUTxOFile
-- if we don't have a chainStateHistory to restore from disk from, start a new one
when (chainStateHistory == emptyChainStateHistory) $ do
initialUTxO <- readJsonFileThrow parseJSON initialUTxOFile

callback $
Observation
{ newChainState = initialChainState
, observedTx =
OnInitTx
{ headId = offlineHeadId
, headSeed = offlineHeadSeed
, headParameters =
HeadParameters
{ parties = [ownParty]
, -- NOTE: This is irrelevant in offline mode.
contestationPeriod = defaultContestationPeriod
}
, participants = []
}
}
callback $
Observation
{ newChainState = initialChainState
, observedTx =
OnCommitTx
{ party = ownParty
, committed = initialUTxO
, headId = offlineHeadId
}
}
callback $
Observation
{ newChainState = initialChainState
, observedTx = OnCollectComTx{headId = offlineHeadId}
}
callback $
Observation
{ newChainState = initialChainState
, observedTx =
OnInitTx
{ headId
, headSeed = offlineHeadSeed nodeId
, headParameters =
HeadParameters
{ parties = [party]
, -- NOTE: This is irrelevant in offline mode.
contestationPeriod = defaultContestationPeriod
}
, participants = []
}
}
callback $
Observation
{ newChainState = initialChainState
, observedTx =
OnCommitTx
{ party
, committed = initialUTxO
, headId
}
}
callback $
Observation
{ newChainState = initialChainState
, observedTx = OnCollectComTx{headId}
}

tickForever :: GenesisParameters ShelleyEra -> (ChainEvent Tx -> IO ()) -> IO ()
tickForever genesis callback = do
Expand Down
2 changes: 1 addition & 1 deletion hydra-node/src/Hydra/Node/Run.hs
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ run opts = do

prepareChainComponent tracer Environment{party} = \case
Offline cfg ->
pure $ withOfflineChain cfg party
pure $ withOfflineChain nodeId cfg party
Direct cfg -> do
ctx <- loadChainContext cfg party
wallet <- mkTinyWallet (contramap DirectChain tracer) cfg
Expand Down

0 comments on commit a1b5f23

Please sign in to comment.