Full End-to-End Hydra Flow (Devnet + kuber-hydra)

This guide is for developers who want to try Cardano Hydra Layer 2 quickly, without spending days wiring node APIs, keys, and transaction flow manually.

Why use Hydra L2?​

Hydra gives you a fast off-chain execution environment (a Hydra Head) while still settling final results back to Cardano L1.

Typical use-cases:

  • High-frequency transactions: many transfers between participants with lower latency.
  • Interactive apps: games, micro-payments, and collaborative workflows where quick finality matters.
  • Batching L1 interaction: run activity in Hydra, settle net result to L1 at close/fanout.

In simple terms: Hydra is useful when your users need speed and responsiveness, but you still want L1 settlement guarantees.

Why use kuber-hydra?​

Directly integrating with Hydra + Cardano can be operationally heavy. kuber-hydra makes this easier by exposing a familiar API for both L1 and Hydra workflows.

With kuber-hydra, developers can:

  • Reuse a consistent transaction-building model.
  • Use simple HTTP relay endpoints for Alice/Bob/Carol.
  • Build, sign, submit, and query with less low-level boilerplate.
  • Start from a working devnet and iterate quickly.

What this full example demonstrates​

The script runs the complete lifecycle end-to-end:

  1. Initialize/open head
  2. Fund participants if needed
  3. Commit UTxOs from Alice, Bob, Carol
  4. Submit in-head transactions
  5. Close head and fanout
  6. Verify L1 settlement

This gives you a realistic baseline for production-oriented flows: open -> transact -> close -> settle.

Prerequisites (minimal)​

  • Docker running locally
  • Node.js 18+ (or compatible)
  • kuber repository cloned locally
  • Devnet ports available:
    • 8082 (Alice relay)
    • 8083 (Bob relay)
    • 8084 (Carol relay)

Quick start (easy path)​

1) Start a clean devnet​

From kuber-hydra/devnet:

./reset-cluster.sh
docker compose ps

You do not need to run each setup script manually. reset-cluster.sh already does it for you.

Behind the scenes it:

  • Recreates runtime and credentials
  • Starts cardano-node
  • Seeds participant wallets
  • Publishes Hydra scripts
  • Starts all services (cardano-node, hydra-node-1..3, kuber-hydra-1..3)

2) Create a local example folder​

From the repository root (kuber):

mkdir -p kuber-hydra/hydra-example
cd kuber-hydra/hydra-example

3) Add package.json and install dependencies​

  1. Create a package.json in kuber-hydra/hydra-example using Example package.json.
  2. Install dependencies:
pnpm install

4) Add the script file​

Create hydra-e2e.ts in kuber-hydra/hydra-example and paste Full E2E example code.

5) Run the full E2E flow​

From kuber-hydra/hydra-example:

pnpm run e2e

What you should expect​

On success, you will see logs showing:

  • Head reached Open
  • Transactions submitted inside Hydra
  • Head moved to Closed
  • fanout completed
  • Final L1 balances printed for Alice/Bob/Carol

This confirms the full L2-to-L1 cycle worked.

Script behavior (high level)​

Network and parties​

The script targets three relays:

  • Alice: http://localhost:8082
  • Bob: http://localhost:8083
  • Carol: http://localhost:8084

Signing keys are loaded from:

  • kuber-hydra/devnet/credentials/alice-hydra.sk
  • kuber-hydra/devnet/credentials/bob-hydra.sk
  • kuber-hydra/devnet/credentials/carol-hydra.sk

Deterministic start​

At flow start, ensureFreshDevnetHead() executes:

cd kuber-hydra/devnet && ./reset-cluster.sh

Then it waits until all three relays respond to queryHeadState.

Head preparation​

ensureHeadOpenAndCommitted() handles different initial states:

  • Open: proceeds directly
  • Closed: waits for contestation deadline, tries fanout, verifies settlement, exits as recovered flow
  • Idle/other: initializes head and commits missing parties

If a party does not have enough L1 balance, the script funds from faucet before commit.

In-head transactions​

After head is open, the script performs:

  • Alice -> Bob: 1A
  • Bob -> Carol: 1A
  • Carol -> Alice: 1A

It retries on transient Hydra submission timing issues (snapshot timing / cluster timing mismatches).

Close and fanout​

The script:

  1. Ensures close collateral exists on L1
  2. Attempts close via Alice, then Bob, then Carol relay
  3. Waits for Closed
  4. Waits until contestation deadline
  5. Calls fanout

Settlement verification​

Finally, it queries each party L1 address and logs:

  • UTxO count
  • Total ADA settled after fanout

How this helps your own project​

Use this example as a template for:

  • Opening a head for a known participant set
  • Committing initial user liquidity
  • Running your app logic as Hydra transactions
  • Settling final state back to L1

A practical approach is to keep your domain logic separate, and call kuber-hydra only for lifecycle + transaction operations.

Troubleshooting​

  • If relays are not reachable, run docker compose ps in kuber-hydra/devnet and ensure ports 8082-8084 are up.
  • If close fails due to collateral, re-run and allow faucet funding to complete.
  • If a previous head is stuck in Closed, the script tries automatic recovery/fanout; if recovery fails, run ./reset-cluster.sh and retry.

Jump to code sections​

Full E2E example code​

Back to Quick start

import { readFileSync } from "fs";
import { execSync } from "child_process";
import { CardanoKeyAsync, Value } from "libcardano";
import { ShelleyWallet, SimpleCip30Wallet } from "libcardano-wallet";
import { KuberHydraApiProvider } from "kuber-client"; // Adjust path as needed
import { UTxO } from "libcardano/serialization";

// Set your work directory and paths according to your local setup.
// This example assumes a specific structure for the devnet, so adjust as necessary for your environment.
const WORK_DIR = process.env.HOME + "/work/kuber/kuber-hydra/devnet";

const hydraAlice = new KuberHydraApiProvider("http://localhost:8082");
const hydraBob = new KuberHydraApiProvider("http://localhost:8083");
const hydraCarol = new KuberHydraApiProvider("http://localhost:8084");

type PartyConfig = {
name: string;
skPath: string;
hydra: KuberHydraApiProvider;
};

type PartyRuntime = PartyConfig & {
wallet: SimpleCip30Wallet;
address: string;
pubKeyHex?: string;
};

const PARTIES: PartyConfig[] = [
{ name: "Alice", skPath: WORK_DIR + "/credentials/alice-hydra.sk", hydra: hydraAlice },
{ name: "Bob", skPath: WORK_DIR + "/credentials/bob-hydra.sk", hydra: hydraBob },
{ name: "Carol", skPath: WORK_DIR + "/credentials/carol-hydra.sk", hydra: hydraCarol },
];

const DEVNET_RELAYS = [8082, 8083, 8084];

async function waitForRelayHealth(timeoutMs: number = 120000): Promise<void> {
const start = Date.now();

while (Date.now() - start < timeoutMs) {
const checks = await Promise.all(
DEVNET_RELAYS.map(async (port) => {
try {
const state = await new KuberHydraApiProvider(`http://localhost:${port}`).queryHeadState();
return typeof state?.state === "string";
} catch {
return false;
}
}),
);

if (checks.every(Boolean)) {
return;
}

await new Promise((resolve) => setTimeout(resolve, 1000));
}

throw new Error(`Timed out waiting for relays ${DEVNET_RELAYS.join(", ")} to become healthy.`);
}

async function ensureFreshDevnetHead(): Promise<void> {
console.log("Resetting devnet at flow start for deterministic E2E execution...");
execSync(`cd ${WORK_DIR} && ./reset-cluster.sh`, { stdio: "inherit" });
await waitForRelayHealth();
}

async function queryUtxosByAddress(address: string): Promise<UTxO[]> {
const utxos = await hydraAlice.l1Api.queryUTxOByAddress(address);
console.log(`\nUTxOs for address ${address}:`);
if (utxos.length === 0) {
console.log("No UTxOs found for this address.");
return [];
}
utxos.forEach((utxo) => {
const txIn = utxo.txIn;
const balance = Number(utxo.txOut.value.lovelace) / 1_000_000; // Convert from lovelace to ADA
console.log(`${txIn.txHash.toString("hex")}#${txIn.index} - ${balance} ADA`);
});
return utxos;
}

async function getTotalAdaBalanceByAddress(address: string): Promise<number> {
const utxos = await hydraAlice.l1Api.queryUTxOByAddress(address);
let totalBalance = 0;
utxos.forEach((utxo) => {
totalBalance += Number(utxo.txOut.value.lovelace);
});
console.log(`\nTotal balance for address ${address}: ${totalBalance / 1_000_000} ADA`);
return totalBalance / 1_000_000;
}

async function getCip30WalletFromSkFile(skFilePath: string, hydra: KuberHydraApiProvider): Promise<SimpleCip30Wallet> {
const skFile = readFileSync(skFilePath, "utf-8");
const signingKey = await CardanoKeyAsync.fromCardanoCliJson(
JSON.parse(skFile),
);
const shelleyWallet = new ShelleyWallet(signingKey);
return new SimpleCip30Wallet(hydra, hydra, shelleyWallet, 0);
}

async function waitForBalance(address: string, minimumBalance: number, timeout: number = 120000): Promise<void> {
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
const balance = await getTotalAdaBalanceByAddress(address);
if (balance >= minimumBalance) {
console.log(`Balance of ${balance} ADA reached for address ${address}`);
return;
}
console.log(`Current balance ${balance} ADA is less than required ${minimumBalance} ADA. Retrying in 1 second...`);
await new Promise((resolve) => setTimeout(resolve, 1000)); // Wait for 1 second before retrying
}
throw new Error(`Timeout: Balance did not reach ${minimumBalance} ADA for address ${address} within ${timeout / 60000} minutes.`);
}

async function fundWalletWithFaucet(walletAddress: string, minimumBalanceAda: number = 10, fundingAda: number = 10) {

const faucetSkPath = WORK_DIR + "/runtime/cardano-node/faucet.sk";
const faucetCip30Wallet = await getCip30WalletFromSkFile(faucetSkPath, hydraAlice);
const faucetAddress = (await faucetCip30Wallet.getChangeAddress()).toBech32();
console.log("Faucet Address:", faucetAddress);
await queryUtxosByAddress(faucetAddress);

// Get total balance of wallet before funding
const balance = await getTotalAdaBalanceByAddress(walletAddress);

if (balance >= minimumBalanceAda) {
console.log(`Wallet already has ${balance} ADA, skipping funding.`);
return;
}

const txBuilder = {
selections: [
faucetAddress, // Funding from faucet address
],
outputs: [{ address: walletAddress, value: `${fundingAda}A` }],
changeAddress: faucetAddress,
};
const buildResult = await hydraAlice.l1Api.buildTx(txBuilder);
const signResult = await faucetCip30Wallet.signTx(buildResult.cborHex);
await hydraAlice.l1Api.submitTx(signResult.transaction.toBytes().toString("hex"));
console.log("Submitted funding transaction hash:", buildResult.hash);

// // // Wait for the transaction to be confirmed
await waitForBalance(walletAddress, minimumBalanceAda, 120000);

}

async function getMaxUtxoAdaByAddress(address: string): Promise<number> {
const utxos = await hydraAlice.l1Api.queryUTxOByAddress(address);
if (utxos.length === 0) return 0;
const maxLovelace = utxos.reduce((max, utxo) => {
const value = Number(utxo.txOut.value.lovelace);
return value > max ? value : max;
}, 0);
return maxLovelace / 1_000_000;
}

async function ensureCloseCollateral(parties: PartyRuntime[]): Promise<void> {
const minimumLargestUtxoAda = 8;

for (const party of parties) {
const largestUtxoAda = await getMaxUtxoAdaByAddress(party.address);
if (largestUtxoAda >= minimumLargestUtxoAda) {
continue;
}

console.log(
`${party.name} largest L1 UTxO is ${largestUtxoAda} ADA (< ${minimumLargestUtxoAda} ADA). Funding collateral buffer...`,
);
await fundWalletWithFaucet(party.address, 12, 12);

const updatedLargestUtxoAda = await getMaxUtxoAdaByAddress(party.address);
if (updatedLargestUtxoAda < minimumLargestUtxoAda) {
throw new Error(
`${party.name} still lacks a large-enough collateral UTxO after funding (largest=${updatedLargestUtxoAda} ADA).`,
);
}
}
}

async function commitToHydraHead(cip30Wallet: SimpleCip30Wallet, hydra: KuberHydraApiProvider, partyName: string) {
// Select UTxOs to commit (e.g., the first one with a value greater than 4 ADA)
const walletAddress = (await cip30Wallet.getChangeAddress()).toBech32();
const l1Utxos = await queryUtxosByAddress(walletAddress);
const selectedUtxos = l1Utxos.filter((x) => x.txOut.value.greaterThan(Value.fromString("4A")));
if (selectedUtxos.length === 0) {
throw new Error(`Not enough balance on ${walletAddress} in L1 chain for commit example`);
}
console.log(`\nSelected UTxOs for commit (${partyName}):`);
selectedUtxos.forEach((utxo) => {
const txIn = utxo.txIn;
const balance = Number(utxo.txOut.value.lovelace) / 1_000_000; // Convert from lovelace to ADA
console.log(`${txIn.txHash.toString("hex")}#${txIn.index} - ${balance} ADA`);
});

const txIn = selectedUtxos[0].txIn;
const utxoToCommit = [`${txIn.txHash.toString("hex")}#${txIn.index}`];

// Build the commit transaction using Hydra API
const commitResult = await hydra.commit({ utxos: utxoToCommit });
console.log(`Transaction to be signed (${partyName}):`, commitResult.hash);

// Sign the transaction using the CIP-30 wallet
const signResult = await cip30Wallet.signTx(commitResult.cborHex);

// Submit the signed transaction to the L1 chain
await hydra.l1Api.submitTx(signResult.transaction.toBytes().toString("hex"));
console.log(`Submitted Commit transaction hash (${partyName}):`, commitResult.hash);

// // Wait for the transaction to be confirmed and head state to change
await hydra.l1Api.waitForUtxoConsumption(selectedUtxos[0].txIn, 280000);
console.log("Commit transaction confirmed.");

}

async function buildPartyRuntime(): Promise<PartyRuntime[]> {
const runtime: PartyRuntime[] = [];
for (const party of PARTIES) {
const wallet = await getCip30WalletFromSkFile(party.skPath, party.hydra);
const address = (await wallet.getChangeAddress()).toBech32();
const pubKeyHex = wallet?.shelleyWallet?.paymentKey?.public?.toString("hex");
runtime.push({ ...party, wallet, address, pubKeyHex });
}
return runtime;
}

async function prepareHeadForTestFlow(parties: PartyRuntime[]): Promise<void> {
const initialHeadState = await hydraAlice.queryHeadState();
console.log("Current head state:", initialHeadState.state);

if (initialHeadState.state !== "Idle") {
throw new Error(`Expected Idle after reset, got ${initialHeadState.state}`);
}

await hydraAlice.initialize(true);
console.log("Hydra head initialized from Idle.");

for (const party of parties) {
console.log(`${party.name} Address:`, party.address);
await fundWalletWithFaucet(party.address);
await commitToHydraHead(party.wallet, party.hydra, party.name);
}

const openWaitMs = await hydraAlice.waitForHeadState("Open", 300000, true, 3000);
console.log(`Hydra head is OPEN (waited ${openWaitMs} ms).`);
}

async function submitHydraTransactions(parties: PartyRuntime[]): Promise<void> {
const byName = Object.fromEntries(parties.map((x) => [x.name, x])) as Record<string, PartyRuntime>;

const txPlan = [
{ from: "Alice", to: "Bob", value: "1A" },
{ from: "Bob", to: "Carol", value: "1A" },
{ from: "Carol", to: "Alice", value: "1A" },
];

console.log("Submitting Hydra head transactions between all parties...");
for (const tx of txPlan) {
const sender = byName[tx.from];
const receiver = byName[tx.to];
const maxRetries = 8;
let submittedHash = "";

for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const builtTx = await hydraAlice.buildWithWallet(sender.wallet, {
selections: [sender.address],
outputs: [{ address: receiver.address, value: tx.value }],
changeAddress: sender.address,
});
const signedTx = await sender.wallet.signTx(builtTx.cborHex, true);

try {
await hydraAlice.submitTx(signedTx.transaction.toBytes().toString("hex"));
} catch (error: any) {
const type = error?.data?.type;
const message = String(error?.data?.message ?? error?.message ?? "");
const isAcceptedButReportedAsError =
type === "TxSubmissionError" && message.includes("Request created");
if (!isAcceptedButReportedAsError) {
throw error;
}
console.log(`Hydra submit accepted by relay with async response (${tx.from} -> ${tx.to}).`);
}

submittedHash = builtTx.hash ?? "";
console.log(`Hydra tx submitted: ${tx.from} -> ${tx.to} (${tx.value}), hash=${submittedHash}`);
break;
} catch (error: any) {
const type = error?.data?.type;
const message = String(error?.data?.message ?? error?.message ?? "");
const isSnapshotTimingError =
type === "TxSubmissionError" && (message.includes("SnapshotConfirmed") || message.includes("NetworkClusterIDMismatch"));

if (!isSnapshotTimingError || attempt === maxRetries) {
throw error;
}

console.log(`Hydra tx retry (${attempt}/${maxRetries}) for ${tx.from} -> ${tx.to} due to snapshot timing. Waiting 4s...`);
await new Promise((resolve) => setTimeout(resolve, 4000));
}
}

if (!submittedHash) {
throw new Error(`Failed to submit Hydra tx from ${tx.from} to ${tx.to}`);
}

await new Promise((resolve) => setTimeout(resolve, 2000));
}

const headUtxos = await hydraAlice.queryUtxos();
console.log(`Hydra head UTxO count after transfers: ${headUtxos.length}`);
}

async function closeAndFanoutHead(parties: PartyRuntime[]): Promise<void> {
const beforeClose = await hydraAlice.queryHeadState();
if (beforeClose.state !== "Open") {
throw new Error(`Expected head state Open before close, got ${beforeClose.state}`);
}

await ensureCloseCollateral(parties);

console.log("Closing Hydra head...");
const closeProviders = [
{ name: "Alice relay", hydra: hydraAlice },
{ name: "Bob relay", hydra: hydraBob },
{ name: "Carol relay", hydra: hydraCarol },
];

let closeSucceeded = false;
let closeErrors: string[] = [];

for (const provider of closeProviders) {
try {
console.log(`Attempting close via ${provider.name}...`);
await provider.hydra.close(true);
closeSucceeded = true;
console.log(`Close accepted via ${provider.name}.`);
break;
} catch (error: any) {
console.log(error);
const message = String(error?.message ?? "Unknown close error");
closeErrors.push(`${provider.name}: ${message}`);

const currentState = await hydraAlice.queryHeadState();
if (currentState.state === "Closed") {
closeSucceeded = true;
console.log(`Head reached Closed after close attempt on ${provider.name}.`);
break;
}

console.log(`Close failed via ${provider.name}.`);
}
}

if (!closeSucceeded) {
throw new Error(`Unable to close head. Errors: ${closeErrors.join(" | ")}`);
}

const closedWaitMs = await hydraAlice.waitForHeadState("Closed", 300000, true, 3000);
const afterClose = await hydraAlice.queryHeadState();
console.log(`Hydra head state after close: ${afterClose.state} (waited ${closedWaitMs} ms).`);
if (afterClose.state !== "Closed") {
throw new Error(`Close failed. Expected Closed, got ${afterClose.state}`);
}

// Wait until the contestation deadline has passed before attempting fanout.
// The head may expose contestationDeadline in different fields; check both.
const headInfo: any = await hydraAlice.queryHead();
const contestationIso = headInfo?.contents?.contestationDeadline ?? headInfo?.postChainTx?.contestationDeadline ?? null;
if (contestationIso) {
const contestationDate = new Date(contestationIso);
const now = new Date();
if (contestationDate > now) {
const waitMs = contestationDate.getTime() - now.getTime() + 1000; // add small buffer
console.log(`Waiting ${Math.ceil(waitMs / 1000)}s for contestation deadline to pass before fanout...`);
await new Promise((r) => setTimeout(r, waitMs));
}
} else {
// fallback short wait if contestation deadline not available
console.log("No contestationDeadline found on head — sleeping 5s before fanout as fallback.");
await new Promise((r) => setTimeout(r, 5000));
}

console.log("Running fanout...");
await hydraAlice.fanout(true);
console.log("Fanout request completed.");
}

async function verifyL1Settlement(parties: PartyRuntime[]): Promise<void> {
console.log("Verifying L1 UTxOs after fanout...");
for (const party of parties) {
const utxos = await hydraAlice.l1Api.queryUTxOByAddress(party.address);
const totalLovelace = utxos.reduce((acc, utxo) => acc + Number(utxo.txOut.value.lovelace), 0);
const totalAda = totalLovelace / 1_000_000;
console.log(`${party.name} L1 settlement: ${utxos.length} UTxO(s), ${totalAda} ADA at ${party.address}`);
}
}

async function runHydraE2EFlow() {
await ensureFreshDevnetHead();

const parties = await buildPartyRuntime();
await prepareHeadForTestFlow(parties);

await submitHydraTransactions(parties);
await closeAndFanoutHead(parties);
await verifyL1Settlement(parties);

const finalHeadState = await hydraAlice.queryHeadState();
console.log("Final head state:", finalHeadState.state);
}

runHydraE2EFlow();

Example package.json​

Back to Quick start

{
"name": "hydra-example",
"version": "1.0.0",
"private": true,
"license": "MIT",
"dependencies": {
"libcardano": "2.2.8",
"libcardano-wallet": "2.2.8",
"kuber-client": "3.3.1"
},
"devDependencies": {
"@types/node": "^25.2.3",
"tsx": "^4.20.3",
"typescript": "^5.8.3"
},
"scripts": {
"e2e": "tsx hydra-e2e.ts"
}
}

© 2023 Kuberide. All Rights Reserved.

Built withfrom 🇳🇵