Mesh LogoMesh

End-to-End Hydra Tutorial

Learn how to open a Hydra Head between two participants, execute Layer 2 transactions, and settle back to Cardano mainnet.

Overview

This tutorial walks you through the complete lifecycle of a Hydra Head on Cardano's preprod testnet. You will learn how to:

  • Set up Hydra nodes for two participants
  • Open a Hydra Head and commit funds
  • Execute instant transactions on Layer 2
  • Close the Head and settle funds on Layer 1

This tutorial is adapted from the official Hydra documentation.

Prerequisites

Before starting, ensure you have:

RequirementDescription
Cardano nodeRunning node with cardano-cli access
Hydra nodeInstalled and configured hydra-node binary
Test ADAAt least 100 tADA per participant on preprod
Network accessTwo machines or terminals that can communicate
Mesh SDK@meshsdk/hydra and @meshsdk/core packages

Installation

npm install @meshsdk/hydra @meshsdk/core

For Hydra node setup, see the official installation guide or use the Docker demo setup.

Quick start

If you already have Hydra nodes running, connect to a Head with Mesh:

import { HydraProvider, HydraInstance } from "@meshsdk/hydra";
import { BlockfrostProvider, MeshTxBuilder } from "@meshsdk/core";
import { MeshCardanoHeadlessWallet, AddressType } from "@meshsdk/wallet";

// Connect to Hydra
const hydraProvider = new HydraProvider({
  httpUrl: "http://localhost:4001",
});

const blockfrost = new BlockfrostProvider("<YOUR_BLOCKFROST_KEY>");

const instance = new HydraInstance({
  provider: hydraProvider,
  fetcher: blockfrost,
  submitter: blockfrost,
});

await hydraProvider.connect();

Step 1: Generate keys and fund wallets

Each Hydra participant needs two key pairs:

  1. Cardano keys: For Layer 1 operations and paying fees
  2. Hydra keys: For signing snapshots within the Head

Generate Cardano keys

Create keys for Alice:

mkdir -p credentials

# Node keys (for hydra-node identity and fees)
cardano-cli address key-gen \
  --verification-key-file credentials/alice-node.vk \
  --signing-key-file credentials/alice-node.sk

cardano-cli address build \
  --payment-verification-key-file credentials/alice-node.vk \
  --out-file credentials/alice-node.addr \
  --testnet-magic 1

# Funds keys (for committing to the Head)
cardano-cli address key-gen \
  --verification-key-file credentials/alice-funds.vk \
  --signing-key-file credentials/alice-funds.sk

cardano-cli address build \
  --payment-verification-key-file credentials/alice-funds.vk \
  --out-file credentials/alice-funds.addr \
  --testnet-magic 1

Create keys for Bob:

# Node keys
cardano-cli address key-gen \
  --verification-key-file credentials/bob-node.vk \
  --signing-key-file credentials/bob-node.sk

cardano-cli address build \
  --payment-verification-key-file credentials/bob-node.vk \
  --out-file credentials/bob-node.addr \
  --testnet-magic 1

# Funds keys
cardano-cli address key-gen \
  --verification-key-file credentials/bob-funds.vk \
  --signing-key-file credentials/bob-funds.sk

cardano-cli address build \
  --payment-verification-key-file credentials/bob-funds.vk \
  --out-file credentials/bob-funds.addr \
  --testnet-magic 1

Generate Hydra keys

hydra-node gen-hydra-key --output-file credentials/alice-hydra
hydra-node gen-hydra-key --output-file credentials/bob-hydra

Fund the wallets

Use the Cardano testnet faucet to fund the addresses:

AddressMinimum Amount
alice-node.addr30 tADA
bob-node.addr30 tADA
alice-funds.addrAny amount
bob-funds.addrAny amount

Configure protocol parameters

Fetch and modify the protocol parameters to eliminate fees inside the Head:

cardano-cli query protocol-parameters \
  --testnet-magic 1 \
  --socket-path "${CARDANO_NODE_SOCKET_PATH}" \
  --out-file protocol-parameters.json

Edit protocol-parameters.json and set these values to 0:

  • txFeeFixed
  • txFeePerByte
  • executionUnitPrices.priceMemory
  • executionUnitPrices.priceSteps

Step 2: Configure Hydra nodes

Start the Hydra node for each participant with the correct configuration.

Alice's node

hydra-node \
  --node-id alice-node \
  --api-host 0.0.0.0 \
  --api-port 4001 \
  --listen 172.16.239.10:5001 \
  --monitoring-port 6001 \
  --peer 172.16.239.20:5001 \
  --hydra-scripts-tx-id <HYDRA_SCRIPTS_TX_ID> \
  --cardano-signing-key credentials/alice-node.sk \
  --cardano-verification-key credentials/bob-node.vk \
  --hydra-signing-key credentials/alice-hydra.sk \
  --hydra-verification-key credentials/bob-hydra.vk \
  --ledger-protocol-parameters protocol-parameters.json \
  --testnet-magic 1 \
  --node-socket "${CARDANO_NODE_SOCKET_PATH}" \
  --contestation-period 300s

Bob's node

hydra-node \
  --node-id bob-node \
  --api-host 0.0.0.0 \
  --api-port 4002 \
  --listen 172.16.239.20:5001 \
  --monitoring-port 6001 \
  --peer 172.16.239.10:5001 \
  --hydra-scripts-tx-id <HYDRA_SCRIPTS_TX_ID> \
  --cardano-signing-key credentials/bob-node.sk \
  --cardano-verification-key credentials/alice-node.vk \
  --hydra-signing-key credentials/bob-hydra.sk \
  --hydra-verification-key credentials/alice-hydra.vk \
  --ledger-protocol-parameters protocol-parameters.json \
  --testnet-magic 1 \
  --node-socket "${CARDANO_NODE_SOCKET_PATH}" \
  --contestation-period 300s

Configuration reference

ParameterDescription
--node-idUnique identifier for the node
--api-portPort for the HTTP/WebSocket API
--listenAddress for peer-to-peer communication
--peerOther participant's listen address
--hydra-scripts-tx-idReference to published Hydra scripts (see networks.json)
--contestation-periodTime window for contesting the final state

For complete configuration options, see the Hydra configuration documentation.

Step 3: Open a Hydra Head

Connect with Mesh

import { HydraProvider, HydraInstance } from "@meshsdk/hydra";
import { BlockfrostProvider } from "@meshsdk/core";
import { MeshCardanoHeadlessWallet, AddressType } from "@meshsdk/wallet";

const blockfrost = new BlockfrostProvider("<YOUR_BLOCKFROST_KEY>");

const hydraProvider = new HydraProvider({
  httpUrl: "http://localhost:4001", // Alice's API
});

const instance = new HydraInstance({
  provider: hydraProvider,
  fetcher: blockfrost,
  submitter: blockfrost,
});

// Connect to the Hydra node
await hydraProvider.connect();

Initialize the Head

await hydraProvider.init();

Commit funds

Set up your wallet and commit funds when the Head is initializing:

import { MeshCardanoHeadlessWallet, AddressType } from "@meshsdk/wallet";

const wallet = await MeshCardanoHeadlessWallet.fromCliKeys({
  networkId: 0, // testnet
  walletAddressType: AddressType.Base,
  fetcher: blockfrost,
  submitter: blockfrost,
  paymentSkey: "credentials/alice-funds.sk",
});

// Listen for Head status changes
hydraProvider.onMessage(async (message) => {
  const status =
    message.tag === "Greetings"
      ? { headStatus: message.headStatus }
      : { tag: message.tag };

  if (
    status.tag === "HeadIsInitializing" ||
    status.headStatus === "Initializing"
  ) {
    // Get UTxOs to commit
    const utxos = await wallet.getUtxosMesh();
    const utxo = utxos[0];

    // Commit funds
    const commitTx = await instance.commitFunds(
      utxo.input.txHash,
      utxo.input.outputIndex
    );
    const signedTx = await wallet.signTx(commitTx, true, false);
    const txHash = await wallet.submitTx(signedTx);

    console.log("Committed funds:", txHash);
  }

  if (status.tag === "HeadIsOpen") {
    console.log("Head is open! Ready for L2 transactions.");
  }
});

Head status flow

StatusDescription
HeadIsInitializingHead creation in progress
CommittedParticipant has committed funds
HeadIsOpenHead is open for transactions

When all participants commit, the Head opens automatically.

Step 4: Transact on Layer 2

With the Head open, you can execute transactions instantly with zero fees.

Fetch UTxOs in the Head

// Get all UTxOs in the Head
const allUtxos = await hydraProvider.fetchUTxOs();

// Get UTxOs for a specific address
const aliceUtxos = await hydraProvider.fetchAddressUTxOs(aliceAddress);

Build and submit a transaction

Send ADA from Alice to Bob inside the Head:

import { MeshTxBuilder } from "@meshsdk/core";

// Fetch protocol parameters from the Head
const protocolParams = await hydraProvider.fetchProtocolParameters();

// Get Alice's UTxOs
const aliceAddress = await wallet.getChangeAddressBech32();
const utxos = await hydraProvider.fetchAddressUTxOs(aliceAddress);

// Build the transaction
const txBuilder = new MeshTxBuilder({
  fetcher: hydraProvider,
  submitter: hydraProvider,
  isHydra: true,
  params: protocolParams,
});

const unsignedTx = await txBuilder
  .txOut(bobAddress, [{ unit: "lovelace", quantity: "3000000" }])
  .changeAddress(aliceAddress)
  .selectUtxosFrom(utxos)
  .setNetwork("preprod")
  .complete();

// Sign and submit
const signedTx = await wallet.signTx(unsignedTx, false);
const txHash = await hydraProvider.submitTx(signedTx);

console.log("L2 transaction submitted:", txHash);

Transaction status flow

StatusDescription
NewTxTransaction submitted to the Head
TxValidAll nodes validated the transaction
SnapshotConfirmedNew state confirmed by all participants

If validation fails, you receive a TxInvalid message with the reason.

Step 5: Close the Head

When you are done transacting, close the Head to settle funds on Layer 1.

Initiate close

Any participant can close the Head:

await hydraProvider.close();

Contestation period

After closing, there is a contestation period (configured with --contestation-period) during which:

  • Participants can contest with a more recent snapshot
  • No new transactions can be submitted
  • The final state is locked for settlement

Fanout to Layer 1

After the contestation deadline, distribute the final state to Layer 1:

await hydraProvider.fanout();

Verify final balances

Check that funds are back on Layer 1:

const aliceBalance = await blockfrost.fetchAddressUTxOs(aliceAddress);
const bobBalance = await blockfrost.fetchAddressUTxOs(bobAddress);

console.log("Alice L1 UTxOs:", aliceBalance);
console.log("Bob L1 UTxOs:", bobBalance);

Complete example

Here is the full workflow in a single script:

import { HydraProvider, HydraInstance } from "@meshsdk/hydra";
import { BlockfrostProvider, MeshTxBuilder } from "@meshsdk/core";
import { MeshCardanoHeadlessWallet, AddressType } from "@meshsdk/wallet";

async function main() {
  // Setup providers
  const blockfrost = new BlockfrostProvider("<YOUR_BLOCKFROST_KEY>");

  const hydraProvider = new HydraProvider({
    httpUrl: "http://localhost:4001",
  });

  const instance = new HydraInstance({
    provider: hydraProvider,
    fetcher: blockfrost,
    submitter: blockfrost,
  });

  // Setup wallet
  const wallet = await MeshCardanoHeadlessWallet.fromCliKeys({
    networkId: 0,
    walletAddressType: AddressType.Base,
    fetcher: blockfrost,
    submitter: blockfrost,
    paymentSkey: "credentials/alice-funds.sk",
  });

  const aliceAddress = await wallet.getChangeAddressBech32();
  const bobAddress = "addr_test1..."; // Bob's address

  // Connect to Hydra
  await hydraProvider.connect();

  // Handle Head lifecycle
  hydraProvider.onMessage(async (message) => {
    switch (message.tag) {
      case "HeadIsInitializing":
        console.log("Head initializing, committing funds...");
        const utxos = await wallet.getUtxosMesh();
        const commitTx = await instance.commitFunds(
          utxos[0].input.txHash,
          utxos[0].input.outputIndex
        );
        const signedCommit = await wallet.signTx(commitTx, true, false);
        await wallet.submitTx(signedCommit);
        break;

      case "HeadIsOpen":
        console.log("Head is open, sending transaction...");

        const pp = await hydraProvider.fetchProtocolParameters();
        const l2Utxos = await hydraProvider.fetchAddressUTxOs(aliceAddress);

        const txBuilder = new MeshTxBuilder({
          fetcher: hydraProvider,
          submitter: hydraProvider,
          isHydra: true,
          params: pp,
        });

        const unsignedTx = await txBuilder
          .txOut(bobAddress, [{ unit: "lovelace", quantity: "5000000" }])
          .changeAddress(aliceAddress)
          .selectUtxosFrom(l2Utxos)
          .setNetwork("preprod")
          .complete();

        const signedTx = await wallet.signTx(unsignedTx, false);
        await hydraProvider.submitTx(signedTx);
        break;

      case "SnapshotConfirmed":
        console.log("Transaction confirmed, closing Head...");
        await hydraProvider.close();
        break;

      case "ReadyToFanout":
        console.log("Contestation period ended, fanning out...");
        await hydraProvider.fanout();
        break;

      case "HeadIsFinalized":
        console.log("Head finalized! Funds are back on L1.");
        break;
    }
  });

  // Initialize the Head
  await hydraProvider.init();
}

main().catch(console.error);

Head lifecycle reference

PhaseStatusDescription
InitializeHeadIsInitializingHead creation started
CommitCommittedFunds committed by participant
OpenHeadIsOpenHead open for transactions
TransactSnapshotConfirmedL2 transaction confirmed
CloseHeadIsClosedHead closing initiated
ContestReadyToFanoutContestation period ended
FanoutHeadIsFinalizedFunds settled on L1

Troubleshooting

Hydra node won't start

Check that the Cardano node socket path is correct and the node is synced:

cardano-cli query tip --testnet-magic 1 --socket-path "${CARDANO_NODE_SOCKET_PATH}"

Nodes can't connect to each other

Ensure firewall rules allow traffic on the listen ports (5001 in the examples) and verify the peer IP addresses are correct.

Commit transaction rejected

Verify that:

  • The UTxO exists and has not been spent
  • The address matches your signing key
  • You have enough ADA to cover the L1 transaction fee

Transaction rejected in Head

Common reasons:

  • Insufficient funds in the Head
  • Invalid transaction format
  • UTxO already spent in a previous transaction

Check the TxInvalid message for specific error details.

On this page