From c031e6f3590a2069776e46b51fb645af99c5cd09 Mon Sep 17 00:00:00 2001 From: Santiago Date: Fri, 7 Aug 2026 10:15:53 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20journeys=2007-10=20=E2=80=94=20codegen,?= =?UTF-8?q?=20interface,=20protocol-fixture,=20live-network?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fill the journey gaps that became the sole real-binary coverage after tx3-lang/trix#129 hermeticized trix's local suite: - 07-codegen-consume: real `tx3c codegen` with the published codegen-v1beta0 templates, compile the generated ts-client in a host package, then resolve a transfer through it against a local devnet. - 08-interface-consume: build a real TII, prime the interface cache byte-for-byte as `trix use` writes it, then real `tx3c decode` via `inspect tir` (alias + full ref) and codegen-for-interface. - 09-protocol-fixture: the acme/hydra-heads production-scale protocol through check/build/inspect. No `trix test` slice yet: every upstream protocols/ fixture ships a stale scaffold tests/basic.toml referencing a `transfer` tx none of them define (tracked in the umbrella plans). - 10-live-network: secrets-gated preprod resolve via a Demeter TRP endpoint, mirroring the sdks live-e2e env contract. Skips green without the env; new dx-e2e-live.yml runs it weekly + on dispatch. Also: graduate 04-devnet-roundtrip to the stable job (the trix#123 expect fix ships in trix 0.26.2 on both channels — verified locally), add 07/08 to the beta cherry-pick, and add pull_request triggers so journey changes are validated pre-merge now that this is its own repo. All journeys verified green in isolation and in the full suite against trix 0.26.2 / tx3c 0.23.0 (the current stable channel), no leaked daemons. Co-Authored-By: Claude Fable 5 --- .github/workflows/dx-e2e-beta.yml | 9 +- .github/workflows/dx-e2e-live.yml | 32 + .github/workflows/dx-e2e-stable.yml | 9 +- journeys/04-devnet-roundtrip/README.md | 16 +- journeys/07-codegen-consume/README.md | 33 + journeys/07-codegen-consume/journey.sh | 77 ++ journeys/07-codegen-consume/package.json | 12 + journeys/07-codegen-consume/resolve.ts | 21 + journeys/07-codegen-consume/tsconfig.json | 14 + journeys/08-interface-consume/README.md | 30 + journeys/08-interface-consume/journey.sh | 86 ++ journeys/08-interface-consume/main.tx3 | 22 + journeys/08-interface-consume/trix.toml | 8 + journeys/09-protocol-fixture/README.md | 23 + journeys/09-protocol-fixture/journey.sh | 35 + .../09-protocol-fixture/protocol/.env.devnet | 15 + .../09-protocol-fixture/protocol/.env.mainnet | 19 + .../09-protocol-fixture/protocol/.env.preprod | 19 + .../09-protocol-fixture/protocol/.env.preview | 19 + .../09-protocol-fixture/protocol/README.md | 105 ++ .../09-protocol-fixture/protocol/devnet.toml | 11 + .../09-protocol-fixture/protocol/main.tx3 | 1007 +++++++++++++++++ .../09-protocol-fixture/protocol/trix.toml | 29 + journeys/10-live-network/README.md | 26 + journeys/10-live-network/journey.sh | 54 + 25 files changed, 1720 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/dx-e2e-live.yml create mode 100644 journeys/07-codegen-consume/README.md create mode 100755 journeys/07-codegen-consume/journey.sh create mode 100644 journeys/07-codegen-consume/package.json create mode 100644 journeys/07-codegen-consume/resolve.ts create mode 100644 journeys/07-codegen-consume/tsconfig.json create mode 100644 journeys/08-interface-consume/README.md create mode 100755 journeys/08-interface-consume/journey.sh create mode 100644 journeys/08-interface-consume/main.tx3 create mode 100644 journeys/08-interface-consume/trix.toml create mode 100644 journeys/09-protocol-fixture/README.md create mode 100755 journeys/09-protocol-fixture/journey.sh create mode 100644 journeys/09-protocol-fixture/protocol/.env.devnet create mode 100644 journeys/09-protocol-fixture/protocol/.env.mainnet create mode 100644 journeys/09-protocol-fixture/protocol/.env.preprod create mode 100644 journeys/09-protocol-fixture/protocol/.env.preview create mode 100644 journeys/09-protocol-fixture/protocol/README.md create mode 100644 journeys/09-protocol-fixture/protocol/devnet.toml create mode 100644 journeys/09-protocol-fixture/protocol/main.tx3 create mode 100644 journeys/09-protocol-fixture/protocol/trix.toml create mode 100644 journeys/10-live-network/README.md create mode 100755 journeys/10-live-network/journey.sh diff --git a/.github/workflows/dx-e2e-beta.yml b/.github/workflows/dx-e2e-beta.yml index 225f176..8fa3efa 100644 --- a/.github/workflows/dx-e2e-beta.yml +++ b/.github/workflows/dx-e2e-beta.yml @@ -17,6 +17,13 @@ on: - 'lib/**' - '.github/workflows/dx-e2e-beta.yml' - '.github/actions/dx-e2e-run/**' + pull_request: + paths: + - 'run.sh' + - 'journeys/**' + - 'lib/**' + - '.github/workflows/dx-e2e-beta.yml' + - '.github/actions/dx-e2e-run/**' workflow_dispatch: permissions: @@ -47,7 +54,7 @@ jobs: max-parallel: 3 matrix: os: [ubuntu-latest, ubuntu-24.04-arm, macos-latest] - journey: [02-lang-tour, 03-lang-edge, 04-devnet-roundtrip, 05-invoke] + journey: [02-lang-tour, 03-lang-edge, 04-devnet-roundtrip, 05-invoke, 07-codegen-consume, 08-interface-consume] steps: - uses: actions/checkout@v4 - uses: ./.github/actions/dx-e2e-run diff --git a/.github/workflows/dx-e2e-live.yml b/.github/workflows/dx-e2e-live.yml new file mode 100644 index 0000000..265d215 --- /dev/null +++ b/.github/workflows/dx-e2e-live.yml @@ -0,0 +1,32 @@ +name: DX E2E (live) + +# The secrets-gated live-network journey (10-live-network): resolve against a +# real preprod TRP endpoint. Never on push — the offline gate must stay +# secret-free. Until the TRP_*/TEST_PARTY_* secrets are configured on this +# repo, the journey skips green and the scheduled run is a no-op (see the +# journey's README). + +on: + workflow_dispatch: + schedule: + - cron: '0 8 * * 1' # weekly live-drift check + +permissions: + contents: read + +jobs: + live: + name: live (ubuntu-latest · 10-live-network) + runs-on: ubuntu-latest + env: + TRP_ENDPOINT_PREPROD: ${{ secrets.TRP_ENDPOINT_PREPROD }} + TRP_API_KEY_PREPROD: ${{ secrets.TRP_API_KEY_PREPROD }} + TEST_PARTY_A_ADDRESS: ${{ secrets.TEST_PARTY_A_ADDRESS }} + TEST_PARTY_B_ADDRESS: ${{ secrets.TEST_PARTY_B_ADDRESS }} + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/dx-e2e-run + with: + channel: stable + journey: 10-live-network + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/dx-e2e-stable.yml b/.github/workflows/dx-e2e-stable.yml index 3b79172..20ec743 100644 --- a/.github/workflows/dx-e2e-stable.yml +++ b/.github/workflows/dx-e2e-stable.yml @@ -18,6 +18,13 @@ on: - 'lib/**' - '.github/workflows/dx-e2e-stable.yml' - '.github/actions/dx-e2e-run/**' + pull_request: + paths: + - 'run.sh' + - 'journeys/**' + - 'lib/**' + - '.github/workflows/dx-e2e-stable.yml' + - '.github/actions/dx-e2e-run/**' workflow_dispatch: schedule: - cron: '0 7 * * *' # nightly drift check @@ -50,7 +57,7 @@ jobs: max-parallel: 3 matrix: os: [ubuntu-latest, ubuntu-24.04-arm, macos-latest] - journey: [01-basic-init, 02-lang-tour, 03-lang-edge, 05-invoke, 06-hydra] + journey: [01-basic-init, 02-lang-tour, 03-lang-edge, 04-devnet-roundtrip, 05-invoke, 06-hydra, 07-codegen-consume, 08-interface-consume, 09-protocol-fixture] steps: - uses: actions/checkout@v4 - uses: ./.github/actions/dx-e2e-run diff --git a/journeys/04-devnet-roundtrip/README.md b/journeys/04-devnet-roundtrip/README.md index f430b44..25929c1 100644 --- a/journeys/04-devnet-roundtrip/README.md +++ b/journeys/04-devnet-roundtrip/README.md @@ -5,14 +5,12 @@ local Dolos devnet, restores deterministic cshell wallets, submits the scaffolde asserts the resulting balances — exercising trix + tx3c + dolos + cshell + the resolver together. - **Scope:** runtime (needs a working devnet). No secrets, no live network beyond the one-time install. -- **Channels:** runs everywhere (no `tx3c` floor), but the CI workflow currently schedules it on the - **beta** job only. +- **Channels:** runs everywhere (no `tx3c` floor); scheduled on both the **stable** and **beta** jobs. -## Currently failing on released channels — intentionally +## History: parked on beta until trix 0.26.2 -The balance-assertion phase hits a known, tracked trix bug (the expect path queried the wrong cshell -store, passed the `@bob` placeholder, and parsed a mismatched utxo shape). Fixed on trix `main` -(tx3-lang/trix#123) but not yet in a released channel, so this journey **fails** on released binaries. -The assertion is kept **strict** (not a tolerated `xfail`) so the broken round-trip is a real, -visible failure. It goes green automatically once the fix ships to a channel; at that point add this -journey to the `stable` job too. +The balance-assertion phase used to hit a known trix bug (the expect path queried the wrong cshell +store, passed the `@bob` placeholder, and parsed a mismatched utxo shape — tx3-lang/trix#123), so +this journey was scheduled on the beta job only and kept **strict** (not `xfail`) so the broken +round-trip stayed a visible red. The fix ships in trix 0.26.2 on both channels; the journey is on +the stable job since then. diff --git a/journeys/07-codegen-consume/README.md b/journeys/07-codegen-consume/README.md new file mode 100644 index 0000000..c2f96f7 --- /dev/null +++ b/journeys/07-codegen-consume/README.md @@ -0,0 +1,33 @@ +# 07-codegen-consume + +The `trix codegen` → generated SDK path, end to end with real binaries: generate the +`ts-client` bindings (real `tx3c codegen` + the published `codegen-v1beta0` templates from +`tx3-lang/web-sdk`), compile them inside a host package, then resolve a transfer *through the +generated client* against a local devnet TRP. + +Since tx3-lang/trix#129 hermeticized trix's local suite, its contract tests stub `tx3c` and +assert only argv + the per-protocol `.tx3/codegen///` layout. This journey is the +only CI anywhere that runs real `tx3c codegen` — template rendering, binding content, that the +output actually compiles, and that the compiled client can drive the TRP lifecycle. + +- **Scope:** runtime (phase 4 needs a working devnet). Phases 1–3 are offline apart from two + anonymous downloads: the codegen templates (GitHub) and npm packages (registry). No secrets. +- **Toolchain gate:** `#@ min-tx3c: 0.23.0` (matches the verified toolchain; `05-invoke` has the + same floor). Additionally requires `node`/`npm` on PATH — the journey *skips* cleanly when + they're missing (all GitHub-hosted runners ship Node). +- **Fixtures:** `package.json` + `tsconfig.json` + `resolve.ts` — the host application shell the + generated module is dropped into. The templates deliberately emit a bare `protocol.ts` (the + standalone-package files are gated behind `options.standalone`), so a host package is the + representative consumption mode. + +## Phases + +1. `trix init` + `trix codegen --plugin ts-client` — asserts the seeded `[[codegen]]` entry, the + per-protocol output layout (no flat file), and the generated content (protocol identity, + embedded TIR envelope, typed `Client` wrapper). +2. `npm install` + `tsc` — the generated client type-checks and compiles against the released + `tx3-sdk` it imports. +3. `trix devnet --background` + `node resolve.js` — the compiled client binds parties + (deterministic devnet identities), builds the scaffolded `transfer`, and resolves it over TRP + to an unsigned tx (asserted on `cbor` + `hash`). Resolve-only: `trix invoke` semantics minus + signing, same as `05-invoke`. diff --git a/journeys/07-codegen-consume/journey.sh b/journeys/07-codegen-consume/journey.sh new file mode 100755 index 0000000..6cc760b --- /dev/null +++ b/journeys/07-codegen-consume/journey.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# +# Journey 07 — codegen consume. See README.md for what this covers. +# Run via e2e/run.sh, which provides $TRIX and an isolated working directory. +# +#@ min-tx3c: 0.23.0 + +source "${E2E_LIB:?E2E_LIB not set — run this journey via e2e/run.sh}" + +JOURNEY_HOME="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + +journey_begin "07-codegen-consume" "codegen a ts client → compile it → resolve a transfer through it (devnet)" + +# The journey compiles and runs the generated TypeScript client, so a Node +# toolchain is infrastructure here — gate on it like 06 gates on docker. +if ! command -v node >/dev/null 2>&1 || ! command -v npm >/dev/null 2>&1; then + skip "node/npm not found — skipping codegen-consume journey" + journey_end + exit 0 +fi + +# 1. Scaffold under a stable directory name: the project name is inferred from +# it, and it doubles as the per-protocol output subdir we assert on below. +mkdir demo && cd demo +run_cmd "trix init -y — scaffold a new project" "${TRIX}" init -y + +# 2. Generate the ts client: real `tx3c codegen` against the published +# codegen-v1beta0 templates (downloaded from tx3-lang/web-sdk). +run_cmd "trix codegen --plugin ts-client — generate bindings" "${TRIX}" codegen --plugin ts-client +assert_output_contains "Added [[codegen]]" "plugin entry seeded into trix.toml" +assert_output_contains "Bindgen successful for 'demo'" +assert_exists ".tx3/codegen/ts-client/demo/protocol.ts" "per-protocol layout: .tx3/codegen/ts-client/demo/" +[[ -e ".tx3/codegen/ts-client/protocol.ts" ]] && die "bindings leaked outside the per-protocol subdir" +ok "no flat protocol.ts outside the per-protocol subdir" + +run_cmd "read the generated client" cat .tx3/codegen/ts-client/demo/protocol.ts +assert_output_contains 'PROTOCOL_NAME = "demo"' "protocol identity embedded" +assert_output_contains "TRANSFER_TIR" "transfer TIR envelope embedded" +assert_output_contains "class Client" "typed client wrapper emitted" + +# 3. Compile it in a host package. The templates emit a bare module — the +# consuming application provides package.json/tsconfig, exactly what the +# fixture models. npm install talks to the public npm registry (no secrets). +mkdir app +cp .tx3/codegen/ts-client/demo/protocol.ts app/ +cp "${JOURNEY_HOME}/package.json" "${JOURNEY_HOME}/tsconfig.json" "${JOURNEY_HOME}/resolve.ts" app/ +cd app +run_cmd "npm install — fetch tx3-sdk + typescript" npm install --no-fund --no-audit --loglevel=error +run_cmd "tsc — compile the generated client + host consumer" npx tsc +assert_exists "dist/protocol.js" "generated client compiled" +assert_exists "dist/resolve.js" "host consumer compiled" +cd .. + +# 4. Round-trip: resolve a transfer *through the generated client* against a +# local devnet. 05-invoke covers the `trix invoke` path; this is the +# generated-SDK path over the same TRP. Resolve-only — no signing, headless. +ALICE="$("${TRIX}" identities alice address-testnet 2>/dev/null | grep '^addr' | head -n1)" +BOB="$("${TRIX}" identities bob address-testnet 2>/dev/null | grep '^addr' | head -n1)" +[[ -n "${ALICE}" && -n "${BOB}" ]] || die "could not resolve alice/bob testnet addresses" + +run_cmd "trix devnet --background — start a local devnet" "${TRIX}" devnet --background +assert_output_contains "devnet started in background" +DEVNET_PIDS="$(pgrep -f 'dolos.*daemon' | tr '\n' ' ')" +# shellcheck disable=SC2064 +trap "[[ -n \"${DEVNET_PIDS}\" ]] && kill -9 ${DEVNET_PIDS} 2>/dev/null" EXIT +for _ in $(seq 1 30); do + (exec 3<>/dev/tcp/127.0.0.1/8164) 2>/dev/null && { exec 3>&- 3<&-; break; } + sleep 1 +done +sleep 3 + +run_cmd "node resolve.js — resolve a transfer through the generated client" \ + node app/dist/resolve.js "http://127.0.0.1:8164" "${ALICE}" "${BOB}" +assert_output_contains '"cbor"' "generated client resolved to an unsigned transaction" +assert_output_contains '"hash"' "resolved tx carries a hash" + +journey_end diff --git a/journeys/07-codegen-consume/package.json b/journeys/07-codegen-consume/package.json new file mode 100644 index 0000000..b1cb061 --- /dev/null +++ b/journeys/07-codegen-consume/package.json @@ -0,0 +1,12 @@ +{ + "name": "codegen-consume-host", + "private": true, + "type": "module", + "dependencies": { + "tx3-sdk": "^0.13.0" + }, + "devDependencies": { + "@types/node": "^22.14.1", + "typescript": "^5.8.3" + } +} diff --git a/journeys/07-codegen-consume/resolve.ts b/journeys/07-codegen-consume/resolve.ts new file mode 100644 index 0000000..ba6ad70 --- /dev/null +++ b/journeys/07-codegen-consume/resolve.ts @@ -0,0 +1,21 @@ +// Host-app consumer of the generated bindings: bind the parties, build the +// scaffolded `transfer`, and resolve it through a live TRP endpoint. Compiling +// this file type-checks the generated client's surface; running it exercises +// the full generated-SDK resolve path. +import { Party } from "tx3-sdk"; +import { Client, PROTOCOL_NAME, type TransferParams } from "./protocol.js"; + +const [endpoint, sender, receiver] = process.argv.slice(2); +if (!endpoint || !sender || !receiver) { + console.error("usage: resolve.js "); + process.exit(2); +} + +const params: TransferParams = { quantity: 2_000_000 }; + +const client = new Client({ endpoint }, "local") + .withSender(Party.address(sender)) + .withReceiver(Party.address(receiver)); + +const resolved = await client.transfer(params).resolve(); +console.log(JSON.stringify({ protocol: PROTOCOL_NAME, hash: resolved.hash, cbor: resolved.txHex })); diff --git a/journeys/07-codegen-consume/tsconfig.json b/journeys/07-codegen-consume/tsconfig.json new file mode 100644 index 0000000..cfeb44f --- /dev/null +++ b/journeys/07-codegen-consume/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "node16", + "declaration": true, + "outDir": "./dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["*.ts"], + "exclude": ["node_modules"] +} diff --git a/journeys/08-interface-consume/README.md b/journeys/08-interface-consume/README.md new file mode 100644 index 0000000..18a5a21 --- /dev/null +++ b/journeys/08-interface-consume/README.md @@ -0,0 +1,30 @@ +# 08-interface-consume + +The consumer half of the interface story with real binaries, no registry: build a publisher +protocol's TII with the real toolchain, prime a consumer project's +`.tx3/tii////` cache by hand (byte-for-byte what `trix use` writes: +`main.tii`, informative `main.tx3`, `metadata.json` with a matching digest), declare it under +`[interfaces]`, then drive `trix inspect tir` and `trix codegen` through it. + +Since tx3-lang/trix#129 hermeticized trix's local suite, the interface plumbing there (digest +gate, alias → cached-TII routing) runs against a fake `tx3c` and a canned TII fixture. What only +this journey validates is the *real* half: `tx3c decode` of a genuinely published-shaped TII, and +real codegen from a cached interface artifact — the interface is consumed, never recompiled. + +- **Scope:** compile/lower (no devnet). Offline except the codegen templates download (GitHub, + anonymous). No secrets, no registry infrastructure. +- **Toolchain gate:** `#@ min-tx3c: 0.23.0` (matches the verified toolchain). +- **Fixtures:** `trix.toml` + `main.tx3` — the publisher protocol (`acme/widget:0.1.0`, one + `widget_transfer` tx with distinctive party names to assert on). + +## What's asserted + +1. `trix build` puts the publisher TII at `//` in the TII tree. +2. `inspect tir --tx widget::widget_transfer` (alias) and + `inspect tir --tx acme/widget:0.1.0::widget_transfer` (full ref) both decode the cached TII — + asserted on the publisher's party/parameter names appearing in the TIR. +3. `trix codegen --plugin ts-client` generates bindings for the interface from its cached TII into + `.tx3/codegen/ts-client/widget/`, embedding the interface's identity and TIR envelope. + +The **full cut** (publish to a real registry, `trix use` pull path, auth) is a separate, heavier +journey tracked in the umbrella plans. diff --git a/journeys/08-interface-consume/journey.sh b/journeys/08-interface-consume/journey.sh new file mode 100755 index 0000000..71465bf --- /dev/null +++ b/journeys/08-interface-consume/journey.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# +# Journey 08 — interface consume. See README.md for what this covers. +# Run via e2e/run.sh, which provides $TRIX and an isolated working directory. +# +#@ min-tx3c: 0.23.0 + +source "${E2E_LIB:?E2E_LIB not set — run this journey via e2e/run.sh}" + +JOURNEY_HOME="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + +journey_begin "08-interface-consume" "build a real TII → prime the interface cache → inspect + codegen through it" + +sha256_of() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | cut -d' ' -f1 + else + shasum -a 256 "$1" | cut -d' ' -f1 + fi +} + +# 1. Publisher side: compile the interface's TII with the real toolchain — the +# same normative artifact `trix publish` would push to the registry. +mkdir widget && cd widget +cp "${JOURNEY_HOME}/trix.toml" trix.toml +cp "${JOURNEY_HOME}/main.tx3" main.tx3 +run_cmd "trix build — compile the publisher protocol" "${TRIX}" build +TII_SRC=".tx3/tii/acme/widget/0.1.0/main.tii" +assert_exists "${TII_SRC}" "publisher TII at //" +DIGEST="sha256:$(sha256_of "${TII_SRC}")" +cd .. + +# 2. Consumer side: a separate project that declares acme/widget:0.1.0 and +# consumes it from a hand-primed cache — byte-for-byte the layout `trix use` +# writes (main.tii + informative main.tx3 + metadata.json whose digest matches +# the trix.toml pin). No registry involved: a cached interface works offline. +mkdir consumer && cd consumer +run_cmd "trix init -y — scaffold the consumer project" "${TRIX}" init -y +CACHE=".tx3/tii/acme/widget/0.1.0" +mkdir -p "${CACHE}" +cp "../widget/${TII_SRC}" "${CACHE}/main.tii" +cp ../widget/main.tx3 "${CACHE}/main.tx3" +cat > "${CACHE}/metadata.json" <> trix.toml <// from trix.toml" + +# 4. Lower a lifecycle tx to TIR and confirm protocol-specific constructs +# made it through — not just that the build exited 0. +run_cmd "trix inspect tir --tx init — lower the head-init tx" "${TRIX}" inspect tir --tx init +assert_output_contains '"participant"' "participant party lowered" +assert_output_contains "head_utxo_lovelace" "protocol parameter lowered" +assert_output_contains '"collateral"' "collateral input lowered" + +journey_end diff --git a/journeys/09-protocol-fixture/protocol/.env.devnet b/journeys/09-protocol-fixture/protocol/.env.devnet new file mode 100644 index 0000000..53b94d9 --- /dev/null +++ b/journeys/09-protocol-fixture/protocol/.env.devnet @@ -0,0 +1,15 @@ +# Hydra Heads — Devnet profile +# +# Hydra doesn't publish reference scripts on local devnets — the +# operator must run `hydra-node publish-scripts` against the devnet +# and paste the resulting UTxO refs below. Placeholders left as a +# canonical "all-zero" tx hash; replace before use. + +# Protocol constant — same on every network ("HydraHeadV1" in ASCII). +ST_TOKEN_NAME=0x4879647261486561645631 + +# Reference scripts (placeholder — replace with the devnet publish-scripts output) +HEAD_SCRIPT_REF=0x0000000000000000000000000000000000000000000000000000000000000000#0 +INITIAL_SCRIPT_REF=0x0000000000000000000000000000000000000000000000000000000000000000#1 +COMMIT_SCRIPT_REF=0x0000000000000000000000000000000000000000000000000000000000000000#2 +DEPOSIT_SCRIPT_REF=0x0000000000000000000000000000000000000000000000000000000000000000#3 diff --git a/journeys/09-protocol-fixture/protocol/.env.mainnet b/journeys/09-protocol-fixture/protocol/.env.mainnet new file mode 100644 index 0000000..52e4b89 --- /dev/null +++ b/journeys/09-protocol-fixture/protocol/.env.mainnet @@ -0,0 +1,19 @@ +# Hydra Heads — Mainnet profile +# +# Reference scripts are taken from hydra-node v2.1.0: +# https://github.com/cardano-scaling/hydra/blob/master/hydra-node/networks.json +# +# All four scripts are published in a single tx; output indices below +# follow the conventional Hydra publish order +# (Head=0, Initial=1, Commit=2, Deposit=3). VERIFY against the tx +# before submitting real-world transactions, e.g. via +# cardano-cli query utxo --tx-in # + +# Protocol constant — same on every network ("HydraHeadV1" in ASCII). +ST_TOKEN_NAME=0x4879647261486561645631 + +# Reference scripts (v2.1.0 publish tx) +HEAD_SCRIPT_REF=0xa864514b20d560777e9b31e7d54391df4cf5a7958d174dc7f67ee27c45539da2#0 +INITIAL_SCRIPT_REF=0xa864514b20d560777e9b31e7d54391df4cf5a7958d174dc7f67ee27c45539da2#1 +COMMIT_SCRIPT_REF=0xa864514b20d560777e9b31e7d54391df4cf5a7958d174dc7f67ee27c45539da2#2 +DEPOSIT_SCRIPT_REF=0xa864514b20d560777e9b31e7d54391df4cf5a7958d174dc7f67ee27c45539da2#3 diff --git a/journeys/09-protocol-fixture/protocol/.env.preprod b/journeys/09-protocol-fixture/protocol/.env.preprod new file mode 100644 index 0000000..396e8e9 --- /dev/null +++ b/journeys/09-protocol-fixture/protocol/.env.preprod @@ -0,0 +1,19 @@ +# Hydra Heads — Preprod profile +# +# Reference scripts are taken from hydra-node v2.1.0: +# https://github.com/cardano-scaling/hydra/blob/master/hydra-node/networks.json +# +# All four scripts are published in a single tx; output indices below +# follow the conventional Hydra publish order +# (Head=0, Initial=1, Commit=2, Deposit=3). VERIFY against the tx +# before submitting real-world transactions, e.g. via +# cardano-cli query utxo --tx-in # + +# Protocol constant — same on every network ("HydraHeadV1" in ASCII). +ST_TOKEN_NAME=0x4879647261486561645631 + +# Reference scripts (v2.1.0 publish tx) +HEAD_SCRIPT_REF=0x16f275a5338119d7f65fadaa495ec07541eb19524509f3c40f8b1b7854298e46#0 +INITIAL_SCRIPT_REF=0x16f275a5338119d7f65fadaa495ec07541eb19524509f3c40f8b1b7854298e46#1 +COMMIT_SCRIPT_REF=0x16f275a5338119d7f65fadaa495ec07541eb19524509f3c40f8b1b7854298e46#2 +DEPOSIT_SCRIPT_REF=0x16f275a5338119d7f65fadaa495ec07541eb19524509f3c40f8b1b7854298e46#3 diff --git a/journeys/09-protocol-fixture/protocol/.env.preview b/journeys/09-protocol-fixture/protocol/.env.preview new file mode 100644 index 0000000..2ab2d80 --- /dev/null +++ b/journeys/09-protocol-fixture/protocol/.env.preview @@ -0,0 +1,19 @@ +# Hydra Heads — Preview profile +# +# Reference scripts are taken from hydra-node v2.1.0: +# https://github.com/cardano-scaling/hydra/blob/master/hydra-node/networks.json +# +# All four scripts are published in a single tx; output indices below +# follow the conventional Hydra publish order +# (Head=0, Initial=1, Commit=2, Deposit=3). VERIFY against the tx +# before submitting real-world transactions, e.g. via +# cardano-cli query utxo --tx-in # + +# Protocol constant — same on every network ("HydraHeadV1" in ASCII). +ST_TOKEN_NAME=0x4879647261486561645631 + +# Reference scripts (v2.1.0 publish tx) +HEAD_SCRIPT_REF=0x86288ee01e76589955d4a5cc4d7fe105bec1a3c4d14a1b48fc134dd20add66c4#0 +INITIAL_SCRIPT_REF=0x86288ee01e76589955d4a5cc4d7fe105bec1a3c4d14a1b48fc134dd20add66c4#1 +COMMIT_SCRIPT_REF=0x86288ee01e76589955d4a5cc4d7fe105bec1a3c4d14a1b48fc134dd20add66c4#2 +DEPOSIT_SCRIPT_REF=0x86288ee01e76589955d4a5cc4d7fe105bec1a3c4d14a1b48fc134dd20add66c4#3 diff --git a/journeys/09-protocol-fixture/protocol/README.md b/journeys/09-protocol-fixture/protocol/README.md new file mode 100644 index 0000000..831f268 --- /dev/null +++ b/journeys/09-protocol-fixture/protocol/README.md @@ -0,0 +1,105 @@ +# Hydra Heads + +Hydra is Cardano's layer-2 scaling solution: a family of isomorphic state-channel protocols that let a fixed set of participants run transactions off-chain at high throughput and low latency, while preserving Cardano's settlement guarantees on the main chain. A *head* is a single instance of the protocol — a private off-chain ledger shared by N participants, opened by locking funds on L1, run via direct peer-to-peer message passing, and finalised back to L1 when the participants are done. + +Hydra is built and maintained by Input Output's [cardano-scaling](https://github.com/cardano-scaling) team. Typical applications include payment channels, micropayments, gaming and ticketing rails, and any workload where Cardano's ~20 s L1 settlement is too slow and the participant set is small enough to coordinate directly. The protocol is described in the IACR paper [*Hydra: Fast Isomorphic State Channels*](https://eprint.iacr.org/2020/299.pdf) and documented at [hydra.family](https://hydra.family/head-protocol/). + +This tx3 models the **L1 transactions that drive a head through its lifecycle** — `init`, `commit`, `collectCom`, `abort`, `close`, `contest`, `fanout`, plus the incremental `deposit` / `increment` / `decrement` / `recover` family. The off-chain L2 message protocol and snapshot signing happen inside `hydra-node`; tx3 only describes the *off-chain construction* of the L1 transactions. + +## Overview + +A head moves through four phases — Initial, Open, Closed, Final — gated by a state-thread (ST) token minted by `HeadPolicy` and one participation token (PT) per participant. The state UTxO carrying the ST sits at the `µHead` validator; PTs sit at `µInitial` until commit time, at `µCommit` between commit and `collectCom`, and back on the head state UTxO once the head is open. Funds queued for incremental commits sit at `µDeposit`. + +Lifecycle transitions: + +- **Initial → Initial:** `init` opens the head; each `commit` moves one participant's PT from `µInitial` to `µCommit` (repeated up to N times, once per participant). +- **Initial → Open:** `collect_com` aggregates every `µCommit` UTxO into the head state UTxO. +- **Initial → Final:** `abort` cancels the head before opening, burning ST + all PTs and refunding any pending commits. +- **Open → Open:** `deposit` / `recover` manage incremental commit UTxOs at `µDeposit`; `increment` pulls a confirmed deposit into the head; `decrement` releases funds to L1 without closing. +- **Open → Closed:** `close` posts a confirmed L2 snapshot and starts the contestation period (`deadline = now + cp`). +- **Closed → Closed:** `contest` posts a newer snapshot before `deadline`, appending the contester. +- **Closed → Final:** `fanout` runs after `deadline`, distributing the final L2 UTxO set back to L1 and burning ST + all PTs. + +The on-chain validators (`µHead`, `µInitial`, `µCommit`, `µDeposit`, and the `HeadPolicy` minting policy) live in [`hydra-plutus`](https://github.com/cardano-scaling/hydra/tree/master/hydra-plutus) and are referenced from `main.tx3` via CIP-31 reference scripts. + +## Transactions + +| Transaction | Phase | Description | +|---|---|---| +| `init` | → Initial | Any participant announces head parameters (participants, contestation period). Consumes a seed UTxO; HeadPolicy mints the ST and one PT per party. | +| `commit` | Initial | A participant moves their PT from `µInitial` to `µCommit`, attaching the L1 UTxOs they want inside the head. | +| `collect_com` | Initial → Open | Aggregates every `µCommit` UTxO into the head state UTxO. The head is now open. | +| `abort` | Initial → Final | Cancels a head before opening: burns ST + all PTs, refunds any pending commits. | +| `deposit` | Open | User locks new funds at `µDeposit` with a deadline, awaiting inclusion in an L2 snapshot. | +| `increment` | Open | After L2 consensus, the head claims a deposit and bumps `snapshot_number` + `utxo_hash`. | +| `recover` | Open | If a deposit isn't claimed by its deadline, the depositor reclaims it. | +| `decrement` | Open | Produces the L1 output for funds the head agreed to release, without closing it. | +| `close` | Open → Closed | Posts a confirmed L2 snapshot on-chain and starts the contestation period (`deadline = now + cp`). | +| `contest` | Closed | Any participant posts a more recent snapshot before `deadline`. Validator enforces newer-snapshot semantics and appends the contester. | +| `fanout` | Closed → Final | After `deadline`, distributes the final L2 UTxO set back to L1 and burns ST + all PTs. | + +## Important considerations + +- **Identifying tokens.** The **ST** (state-thread token) is present on `init`, `collect_com`, `increment`, `decrement`, `close`, `contest`, `fanout` outputs; it uniquely identifies a head. The **PT** (participation token) — one per participant — is present on `commit`; burned on `abort` / `fanout`. +- **HeadPolicy is parameterised per head.** Its currency symbol (== `head_id`) is unique per head because the policy is parameterised by a seed `OutputRef`. The caller computes `head_id` off-chain before invoking `init` and passes it as a parameter to every subsequent tx. +- **Reference scripts deployed once per network.** `head_script_ref`, `initial_script_ref`, `commit_script_ref`, `deposit_script_ref` live in the env (sourced from upstream [`hydra-node/networks.json`](https://github.com/cardano-scaling/hydra/blob/master/hydra-node/networks.json)). +- **PT bundles instead of per-participant outputs.** Real Hydra emits one `µInitial` UTxO per participant. tx3 models the aggregate via a single output and a `pt_count` parameter. Same simplification applies to `collect_com` (one `µCommit` input in tx3 vs. N on-chain) and `fanout` (one consolidated recipient vs. one output per snapshot UTxO). +- **Validity vs. datum deadlines.** `recover` and `fanout` need `validity.since_slot ≥ deadline`, but tx3 can't yet read the datum into a `validity` block, so the caller passes `deadline_slot` explicitly. +- **`initial_contesters` parameter.** `close` requires an empty contester list, but tx3 has no empty-list literal in this position, so the caller passes an empty `List` at invoke time. +- **ST token name is protocol-wide.** `st_token_name` lives in env and is the same on every network (`"HydraHeadV1"` / hex `4879647261486561645631`). + +## Caller preparation + +Off-chain work required before invoking any lifecycle transaction. Per-parameter documentation is rendered from `main.tx3` docstrings — this section only covers what the caller must produce, query, or compute outside the tx3 module itself. + +### Computing `head_id` + +The `HeadPolicy` minting policy is parameterised by the `seed_ref` consumed in `init`. Apply the seed `OutputRef` to the policy template and take the blake2b-224 hash — that's the head's currency symbol and the value reused as `head_id` in every subsequent transaction. + +### Sourcing reference scripts + +`head_policy_ref` (passed to `init`, `abort`, `fanout`) and the four validator reference scripts in env come from the per-network Hydra deployment table at [`hydra-node/networks.json`](https://github.com/cardano-scaling/hydra/blob/master/hydra-node/networks.json). + +### Pre-computing min-UTxO lovelace + +Every script output needs a lovelace amount satisfying the protocol's min-UTxO rule (`head_utxo_lovelace`, `initial_utxo_lovelace`, `commit_utxo_lovelace`, `deposit_utxo_lovelace`, `head_out_lovelace`). Compute against current protocol params. + +### Querying on-chain UTxOs + +Each lifecycle step needs the live UTxOs it consumes: the `µHead` state UTxO (`head_ref`), a specific participant's `µInitial` PT bucket (`initial_pt_ref`, `initial_ref`), pending `µCommit` UTxOs (`commit_ref`), pending `µDeposit` UTxOs (`deposit_ref`). Look them up by validator address and identifying token (ST for the head state, PT for the others). + +### Canonical CBOR for `committed` / `deposit` + +`commit.committed_cbor` and `deposit.deposit_cbor` are the canonical CBOR encodings of the L1 UTxO bundles being placed inside the head. The on-chain validator hashes these to verify inclusion in the snapshot at `collect_com` / `increment`. + +### Snapshot artifacts from `hydra-node` + +`close`, `contest`, `increment`, `decrement`, and `fanout` carry snapshot data produced by L2 consensus inside `hydra-node`: `snapshot_number`, `utxo_hash` (Merkle root of the L2 UTxO set), `signature` (multi-party sig), and on `collect_com` the initial `initial_utxo_hash`. These all come from outside this tx3. + +### Self-reference in `commit` + +The `ViaCommit` redeemer carries the `OutputRef` of the `µCommit` UTxO that the same transaction produces. Predict the new tx hash and output index (e.g. by building the body first and hashing it) before setting `new_commit_ref_tx` / `new_commit_ref_idx`. + +### Deadline-driven validity bounds + +`recover` and `fanout` need `validity.since_slot ≥ datum.deadline`, but tx3 can't read the datum into the validity block. Pass `deadline_slot` equal to the datum's deadline. + +### List-literal workarounds + +- `close.initial_contesters` — pass `[]` at invoke time (no empty-list literal in this position). +- `contest.contesters` — pass the prior contester list with the caller's vkey hash appended. + +### `pt_count` must match the participant set + +Real Hydra would derive PT count from the participant list, but tx3 has no `length()` builtin. Pass `pt_count` explicitly; it must equal `participants.length` on the mint side and the number of outstanding PTs on the burn side (`abort.pt_burn_count`, `fanout.pt_count`). + +## References + +- **Smart contracts:** PlutusV3 — [cardano-scaling/hydra-plutus](https://github.com/cardano-scaling/hydra/tree/master/hydra-plutus) +- **Source:** [cardano-scaling/hydra](https://github.com/cardano-scaling/hydra) +- **Protocol overview:** [hydra.family/head-protocol](https://hydra.family/head-protocol/docs/protocol-overview) +- **Architecture:** [hydra.family/head-protocol/dev/architecture](https://hydra.family/head-protocol/docs/dev/architecture) +- **Incremental commits / decommits:** [hydra.family/head-protocol/dev/protocol](https://hydra.family/head-protocol/docs/dev/protocol) +- **Paper:** [Hydra: Fast Isomorphic State Channels (IACR ePrint 2020/299)](https://eprint.iacr.org/2020/299.pdf) +- **Reference-script deployment table:** [hydra-node/networks.json](https://github.com/cardano-scaling/hydra/blob/master/hydra-node/networks.json) +- **tx3 language spec:** [tx3-lang/tx3 v1beta0](https://github.com/tx3-lang/tx3/tree/main/specs/v1beta0) diff --git a/journeys/09-protocol-fixture/protocol/devnet.toml b/journeys/09-protocol-fixture/protocol/devnet.toml new file mode 100644 index 0000000..ff6210a --- /dev/null +++ b/journeys/09-protocol-fixture/protocol/devnet.toml @@ -0,0 +1,11 @@ +[[utxos]] +address = "@charlie" +value = 100000000000 + +[[utxos]] +address = "@alice" +value = 100000000000 + +[[utxos]] +address = "@bob" +value = 100000000000 diff --git a/journeys/09-protocol-fixture/protocol/main.tx3 b/journeys/09-protocol-fixture/protocol/main.tx3 new file mode 100644 index 0000000..0afffa2 --- /dev/null +++ b/journeys/09-protocol-fixture/protocol/main.tx3 @@ -0,0 +1,1007 @@ +// Hydra Heads — Cardano L1 lifecycle transactions +// https://github.com/cardano-scaling/hydra +// +// Models the on-chain transactions that drive a Hydra Head: +// Initial — init / commit / abort +// Open — collectCom / deposit / increment / decrement / recover +// Closed — close / contest +// Final — fanout +// +// Tx3 only describes the off-chain *construction* of these transactions; +// the on-chain validators (µHead, µInitial, µCommit, µDeposit and the +// HeadPolicy minting policy) live in `hydra-plutus` and are referenced +// here via `cardano::plutus_witness` / reference scripts. + +// ------------------------------------------------------------------ +// Parties +// ------------------------------------------------------------------ + +/// A head participant wallet that submits L1 txs and signs snapshots. +party Participant; + +/// µHead validator address; locks the head state UTxO carrying the ST token. +party HeadScript; + +/// µInitial validator address; locks each participant's PT between `init` and `commit`/`abort`. +party InitialScript; + +/// µCommit validator address; locks a participant's committed UTxOs between `commit` and `collectCom`/`abort`. +party CommitScript; + +/// µDeposit validator address; locks UTxOs queued for an incremental commit. +party DepositScript; + +// ------------------------------------------------------------------ +// Environment +// ------------------------------------------------------------------ +// Per-head deployment configuration. The HeadPolicy minting policy is +// parameterized by a seed OutputRef, so its hash is unique per head. + +// `env` holds only network-level values: the validator reference scripts +// (deployed once per network — see `hydra-node/networks.json` upstream) +// and the protocol-wide ST token name. Per-head identity (`head_id`, +// which is also the HeadPolicy currency symbol) is passed as a tx +// parameter to keep profiles head-agnostic. +env { + // Hydra hard-codes "HydraHeadV1" ("4879647261486561645631" hex) — same on every network. + /// State Thread token name minted by the HeadPolicy (protocol-wide constant). + st_token_name: Bytes, + + // Reference scripts (CIP-31) for the four head validators. Hydra + // publishes these once per network/version; values come from + // https://github.com/cardano-scaling/hydra/blob/master/hydra-node/networks.json + /// CIP-31 reference script UTxO for the µHead validator on this network. + head_script_ref: UtxoRef, + /// CIP-31 reference script UTxO for the µInitial validator on this network. + initial_script_ref: UtxoRef, + /// CIP-31 reference script UTxO for the µCommit validator on this network. + commit_script_ref: UtxoRef, + /// CIP-31 reference script UTxO for the µDeposit validator on this network. + deposit_script_ref: UtxoRef, +} + +// ------------------------------------------------------------------ +// Types +// ------------------------------------------------------------------ + +// Cardano OutputReference (Constr 0 [tx_hash, ix]). +type OutputRef { + tx_hash: Bytes, + output_index: Int, +} + +// Head parameters set at init time. `participants` and `parties` are +// the cardano vkey hashes and Hydra signing keys respectively; on-chain +// the µHead validator checks the multi-sig against `parties`. +type HeadParameters { + contestation_period: Int, // ms + parties: List, // Hydra signing keys (vkey-style) + participants: List, // cardano vkey hashes (for L1 sigs) + head_id: Bytes, // currency symbol of the HeadPolicy +} + +// The µHead state datum. Cardano-side it's a variant: Initial → Open +// → Closed; `fanout` consumes a Closed and produces no head output. +type HeadState { + // Right after `init`, before any `commit`. + Initial { + params: HeadParameters, + seed: OutputRef, + }, + // Head is open on L2. `utxo_hash` is the Merkle root of the + // currently-committed UTxO set (updated by increment/decrement). + Open { + params: HeadParameters, + utxo_hash: Bytes, + snapshot_number: Int, + }, + // Head is closed; a snapshot is pending fanout. `deadline` is the + // POSIX-ms contestation deadline (T_final). + Closed { + params: HeadParameters, + snapshot_number: Int, + utxo_hash: Bytes, + deadline: Int, + contesters: List, + }, +} + +// Datum at µInitial — the PT sits here until commit or abort. +type InitialDatum { + head_id: Bytes, +} + +// Datum at µCommit — locks a participant's committed UTxOs between +// `commit` and `collectCom`. `committed` is the canonical CBOR of the +// committed L1 outputs; the µHead checks it against the snapshot at +// `collectCom` and against the abort path. +type CommitDatum { + head_id: Bytes, + party: Bytes, + committed: Bytes, +} + +// Datum at µDeposit — locks UTxOs queued for incremental commit. +type DepositDatum { + head_id: Bytes, + deadline: Int, + deposit: Bytes, // canonical CBOR of deposited UTxOs +} + +// Redeemers --------------------------------------------------------- + +// HeadPolicy mints ST + N PTs at init, burns everything at abort/fanout. +type HeadPolicyRedeemer { + Mint { + seed: OutputRef, + }, + Burn, +} + +// µInitial: the PT can be moved to µCommit (commit) or burned (abort). +type InitialRedeemer { + ViaCommit { + committed_ref: OutputRef, // produced µCommit UTxO + }, + ViaAbort, +} + +// µCommit: either collected into the head (collectCom) or refunded +// back to the participant (abort). +type CommitRedeemer { + ViaCollectCom, + ViaAbort, +} + +// µHead — state transitions out of the head state UTxO. +type HeadRedeemer { + CollectCom, + Increment { + snapshot_number: Int, + new_utxo_hash: Bytes, + signature: Bytes, + deposit_ref: OutputRef, + }, + Decrement { + snapshot_number: Int, + new_utxo_hash: Bytes, + signature: Bytes, + }, + Close { + snapshot_number: Int, + utxo_hash: Bytes, + signature: Bytes, + }, + Contest { + snapshot_number: Int, + utxo_hash: Bytes, + signature: Bytes, + }, + Fanout { + snapshot_number: Int, + utxo_hash: Bytes, + }, + Abort, +} + +// µDeposit: either claimed by the head (increment) or refunded to the +// depositor after the deadline (recover). +type DepositRedeemer { + Claim, + Recover, +} + +// ------------------------------------------------------------------ +// Transactions +// ------------------------------------------------------------------ + +/// Open a new Hydra head: consume a seed UTxO and mint ST + one PT per participant. +tx init( + /// Seed UTxO consumed to make the HeadPolicy currency symbol unique. + seed_ref: UtxoRef, + /// Currency symbol of the HeadPolicy for this head (== head identifier). + head_id: Bytes, + /// Reference script UTxO carrying the HeadPolicy minting policy. + head_policy_ref: UtxoRef, + /// Contestation period in milliseconds, baked into the head parameters. + contestation_period: Int, + /// Hydra signing keys (vkey-style) for L2 multi-sig. + parties: List, + /// Cardano vkey hashes of the L1 participants. + participants: List, + // Lovelace pinned to each output (caller pre-computes per protocol params). + /// Lovelace pinned on the µHead state UTxO (min-UTxO from protocol params). + head_utxo_lovelace: Int, + /// Lovelace pinned on the µInitial PT bucket UTxO. + initial_utxo_lovelace: Int, + // Total PT count must match participants count; caller passes amount + // because tx3 has no `length(list)` builtin. + /// Number of participation tokens to mint; must equal `participants` length. + pt_count: Int, +) { + locals { + st: AnyAsset(head_id, st_token_name, 1), + params: HeadParameters { + contestation_period: contestation_period, + parties: parties, + participants: participants, + head_id: head_id, + }, + } + + input* source { + from: Participant, + min_amount: Ada(head_utxo_lovelace + initial_utxo_lovelace) + fees, + } + + input seed { + from: Participant, + ref: seed_ref, + } + + collateral { + from: Participant, + min_amount: fees, + } + + reference policy_ref { + ref: head_policy_ref, + } + + // Mint ST (qty 1) plus one PT per participant. PTs share the same + // policy as ST in Hydra; token names are derived from each + // participant's vkey hash off-chain — modelled here as an aggregate + // mint count tracked by the validator. + mint { + amount: st, + redeemer: HeadPolicyRedeemer::Mint { + seed: OutputRef { + tx_hash: seed_ref.tx_hash, + output_index: seed_ref.output_index, + }, + }, + } + + // Head state UTxO carrying the ST. + output head { + to: HeadScript, + amount: Ada(head_utxo_lovelace) + st, + datum: HeadState::Initial { + params: params, + seed: OutputRef { + tx_hash: seed_ref.tx_hash, + output_index: seed_ref.output_index, + }, + }, + } + + // Pre-allocated PT bucket at µInitial. Real Hydra emits one output + // per participant, each holding that participant's PT; in Tx3 we + // model the aggregate (`pt_count` reflects the participant list size). + output initial { + to: InitialScript, + amount: Ada(initial_utxo_lovelace) + + AnyAsset(head_id, head_id, pt_count), + datum: InitialDatum { + head_id: head_id, + }, + } + + signers { + Participant, + } + + output { + to: Participant, + amount: source + seed + - Ada(head_utxo_lovelace + initial_utxo_lovelace) + - st + - fees, + } +} + +/// Move a participant's PT from µInitial to µCommit, locking the funds they want inside the head. +tx commit( + /// HeadPolicy currency symbol identifying the target head. + head_id: Bytes, + /// Hydra party (signing key) committing the funds. + party: Bytes, + /// Canonical CBOR encoding of the L1 UTxOs being committed. + committed_cbor: Bytes, + /// Ada value of the bundle being committed. + committed_lovelace: Int, + /// Min-UTxO lovelace required on the produced µCommit output. + commit_utxo_lovelace: Int, + /// The µInitial UTxO carrying this participant's PT. + initial_pt_ref: UtxoRef, + /// Tx hash of the µCommit UTxO this commit will produce (self-reference for the redeemer). + new_commit_ref_tx: Bytes, + /// Output index of the µCommit UTxO this commit will produce. + new_commit_ref_idx: Int, +) { + locals { + pt: AnyAsset(head_id, head_id, 1), + } + + reference initial_script { + ref: initial_script_ref, + } + + // The PT-bearing UTxO at µInitial. + input pt_in { + from: InitialScript, + ref: initial_pt_ref, + datum_is: InitialDatum, + redeemer: InitialRedeemer::ViaCommit { + committed_ref: OutputRef { + tx_hash: new_commit_ref_tx, + output_index: new_commit_ref_idx, + }, + }, + } + + // Funds being moved into the head, plus fees. + input* funds { + from: Participant, + min_amount: Ada(committed_lovelace + commit_utxo_lovelace) + fees, + } + + collateral { + from: Participant, + min_amount: fees, + } + + // µCommit output bundling the PT with the committed value. + output commit_out { + to: CommitScript, + amount: Ada(commit_utxo_lovelace + committed_lovelace) + pt, + datum: CommitDatum { + head_id: head_id, + party: party, + committed: committed_cbor, + }, + } + + signers { + Participant, + } + + output { + to: Participant, + amount: funds + pt_in + - Ada(commit_utxo_lovelace + committed_lovelace) + - pt + - fees, + } +} + +/// Cancel an `Initial` head: burn ST + PTs, refund pending µCommit funds. +// Modelled here for a single still-pending µCommit and a single +// remaining µInitial PT — real protocol aggregates N of each. +tx abort( + /// HeadPolicy currency symbol of the head being aborted. + head_id: Bytes, + /// Reference script UTxO carrying the HeadPolicy minting policy. + head_policy_ref: UtxoRef, + /// The µHead state UTxO (in `Initial`) being consumed. + head_ref: UtxoRef, + /// A pending µCommit UTxO to refund. + commit_ref: UtxoRef, + /// A µInitial PT UTxO whose token will be burned. + initial_ref: UtxoRef, + /// Lovelace refunded back to the committing party. + refund_lovelace: Int, + /// Address receiving the refunded committed funds. + refund_recipient: Bytes, + /// Number of participation tokens to burn alongside the ST. + pt_burn_count: Int, +) { + locals { + st: AnyAsset(head_id, st_token_name, 1), + pt_bundle: AnyAsset(head_id, head_id, pt_burn_count), + } + + reference policy_ref { + ref: head_policy_ref, + } + + reference head_script { + ref: head_script_ref, + } + + reference commit_script { + ref: commit_script_ref, + } + + reference initial_script { + ref: initial_script_ref, + } + + input head_in { + from: HeadScript, + ref: head_ref, + datum_is: HeadState, + redeemer: HeadRedeemer::Abort {}, + } + + input commit_in { + from: CommitScript, + ref: commit_ref, + datum_is: CommitDatum, + redeemer: CommitRedeemer::ViaAbort {}, + } + + input initial_in { + from: InitialScript, + ref: initial_ref, + datum_is: InitialDatum, + redeemer: InitialRedeemer::ViaAbort {}, + } + + collateral { + from: Participant, + min_amount: fees, + } + + // Burn ST + every outstanding PT. + burn { + amount: st + pt_bundle, + redeemer: HeadPolicyRedeemer::Burn {}, + } + + // Refund committed funds back to the committing party. + output { + to: refund_recipient, + amount: Ada(refund_lovelace), + } + + signers { + Participant, + } + + output { + to: Participant, + amount: head_in + commit_in + initial_in + - Ada(refund_lovelace) + - st + - pt_bundle + - fees, + } +} + +/// Aggregate every µCommit UTxO into the head; transition Initial → Open. +// Modelled for one µCommit input — extend by replicating `commit_in_*` +// in real deployments. +tx collect_com( + /// HeadPolicy currency symbol of the head being opened. + head_id: Bytes, + /// The µHead state UTxO (in `Initial`) being consumed. + head_ref: UtxoRef, + /// A µCommit UTxO being absorbed into the head. + commit_ref: UtxoRef, + /// Head parameters carried over from the `Initial` datum. + params: HeadParameters, + /// Merkle root of the initial committed UTxO set. + initial_utxo_hash: Bytes, + /// Lovelace pinned on the new µHead `Open` output. + head_out_lovelace: Int, + /// Number of participation tokens accompanying the ST on the head output. + pt_count: Int, +) { + locals { + st: AnyAsset(head_id, st_token_name, 1), + pts: AnyAsset(head_id, head_id, pt_count), + } + + reference head_script { + ref: head_script_ref, + } + + reference commit_script { + ref: commit_script_ref, + } + + input head_in { + from: HeadScript, + ref: head_ref, + datum_is: HeadState, + redeemer: HeadRedeemer::CollectCom {}, + } + + input commit_in { + from: CommitScript, + ref: commit_ref, + datum_is: CommitDatum, + redeemer: CommitRedeemer::ViaCollectCom {}, + } + + collateral { + from: Participant, + min_amount: fees, + } + + // ST + every PT now sit on the head state UTxO. + output head_out { + to: HeadScript, + amount: Ada(head_out_lovelace) + st + pts, + datum: HeadState::Open { + params: params, + utxo_hash: initial_utxo_hash, + snapshot_number: 0, + }, + } + + signers { + Participant, + } + + output { + to: Participant, + amount: head_in + commit_in + - Ada(head_out_lovelace) + - st - pts - fees, + } +} + +/// Lock new funds at µDeposit for an incremental commit, awaiting an L2 snapshot. +tx deposit( + /// HeadPolicy currency symbol of the target head. + head_id: Bytes, + /// POSIX-ms deadline after which the depositor may `recover` the funds. + deadline: Int, + /// Canonical CBOR encoding of the L1 UTxOs being deposited. + deposit_cbor: Bytes, + /// Ada value of the bundle being deposited. + deposit_lovelace: Int, + /// Min-UTxO lovelace required on the produced µDeposit output. + deposit_utxo_lovelace: Int, +) { + input* source { + from: Participant, + min_amount: Ada(deposit_lovelace + deposit_utxo_lovelace) + fees, + } + + output deposit_out { + to: DepositScript, + amount: Ada(deposit_utxo_lovelace + deposit_lovelace), + datum: DepositDatum { + head_id: head_id, + deadline: deadline, + deposit: deposit_cbor, + }, + } + + signers { + Participant, + } + + output { + to: Participant, + amount: source - Ada(deposit_utxo_lovelace + deposit_lovelace) - fees, + } +} + +/// Pull an L2-confirmed deposit into the head, bumping snapshot number and utxo hash. +tx increment( + /// The µHead state UTxO (in `Open`) being consumed. + head_ref: UtxoRef, + /// The µDeposit UTxO being absorbed into the head. + deposit_ref: UtxoRef, + /// Head parameters carried over from the previous `Open` datum. + params: HeadParameters, + /// New snapshot number after absorbing the deposit. + new_snapshot_number: Int, + /// New Merkle root of the committed UTxO set. + new_utxo_hash: Bytes, + /// Multi-party signature over the new snapshot. + signature: Bytes, + /// Lovelace pinned on the new µHead `Open` output, excluding the pulled amount. + head_out_lovelace: Int, + /// Lovelace pulled in from the deposit. + pulled_lovelace: Int, + /// HeadPolicy currency symbol of the target head. + head_id: Bytes, + /// Number of participation tokens accompanying the ST on the head output. + pt_count: Int, +) { + locals { + st: AnyAsset(head_id, st_token_name, 1), + pts: AnyAsset(head_id, head_id, pt_count), + } + + reference head_script { + ref: head_script_ref, + } + + reference deposit_script { + ref: deposit_script_ref, + } + + input head_in { + from: HeadScript, + ref: head_ref, + datum_is: HeadState, + redeemer: HeadRedeemer::Increment { + snapshot_number: new_snapshot_number, + new_utxo_hash: new_utxo_hash, + signature: signature, + deposit_ref: OutputRef { + tx_hash: deposit_ref.tx_hash, + output_index: deposit_ref.output_index, + }, + }, + } + + input deposit_in { + from: DepositScript, + ref: deposit_ref, + datum_is: DepositDatum, + redeemer: DepositRedeemer::Claim {}, + } + + collateral { + from: Participant, + min_amount: fees, + } + + output head_out { + to: HeadScript, + amount: Ada(head_out_lovelace + pulled_lovelace) + st + pts, + datum: HeadState::Open { + params: params, + utxo_hash: new_utxo_hash, + snapshot_number: new_snapshot_number, + }, + } + + signers { + Participant, + } + + output { + to: Participant, + amount: head_in + deposit_in + - Ada(head_out_lovelace + pulled_lovelace) + - st - pts - fees, + } +} + +/// Reclaim a µDeposit UTxO after its deadline has passed. +tx recover( + /// The µDeposit UTxO being reclaimed. + deposit_ref: UtxoRef, + /// Lovelace value carried by the deposit, refunded to the depositor. + refund_lovelace: Int, +) { + reference deposit_script { + ref: deposit_script_ref, + } + + input deposit_in { + from: DepositScript, + ref: deposit_ref, + datum_is: DepositDatum, + redeemer: DepositRedeemer::Recover {}, + } + + collateral { + from: Participant, + min_amount: fees, + } + + // Validity lower bound must be ≥ deadline so the validator accepts + // the recover path. Caller is expected to set `deadline` to the + // datum's deadline (Tx3 can't read it into validity yet). + validity { + since_slot: tip_slot(), + } + + signers { + Participant, + } + + output { + to: Participant, + amount: deposit_in - fees, + } +} + +/// Release funds from the head onto L1 without closing it (incremental decommit). +tx decrement( + /// The µHead state UTxO (in `Open`) being consumed. + head_ref: UtxoRef, + /// Head parameters carried over from the previous `Open` datum. + params: HeadParameters, + /// New snapshot number after the decommit. + new_snapshot_number: Int, + /// New Merkle root of the committed UTxO set. + new_utxo_hash: Bytes, + /// Multi-party signature over the new snapshot. + signature: Bytes, + /// Lovelace pinned on the new µHead `Open` output. + head_out_lovelace: Int, + /// Lovelace amount being released back to L1. + released_lovelace: Int, + /// L1 address receiving the released funds. + release_recipient: Bytes, + /// HeadPolicy currency symbol of the target head. + head_id: Bytes, + /// Number of participation tokens accompanying the ST on the head output. + pt_count: Int, +) { + locals { + st: AnyAsset(head_id, st_token_name, 1), + pts: AnyAsset(head_id, head_id, pt_count), + } + + reference head_script { + ref: head_script_ref, + } + + input head_in { + from: HeadScript, + ref: head_ref, + datum_is: HeadState, + redeemer: HeadRedeemer::Decrement { + snapshot_number: new_snapshot_number, + new_utxo_hash: new_utxo_hash, + signature: signature, + }, + } + + collateral { + from: Participant, + min_amount: fees, + } + + output head_out { + to: HeadScript, + amount: Ada(head_out_lovelace) + st + pts, + datum: HeadState::Open { + params: params, + utxo_hash: new_utxo_hash, + snapshot_number: new_snapshot_number, + }, + } + + output released { + to: release_recipient, + amount: Ada(released_lovelace), + } + + signers { + Participant, + } + + output { + to: Participant, + amount: head_in + - Ada(head_out_lovelace + released_lovelace) + - st - pts - fees, + } +} + +/// Publish a confirmed snapshot to L1, transitioning the head Open → Closed. +tx close( + /// The µHead state UTxO (in `Open`) being consumed. + head_ref: UtxoRef, + /// Head parameters carried over from the `Open` datum. + params: HeadParameters, + /// Snapshot number being posted on close. + snapshot_number: Int, + /// Merkle root of the snapshot's UTxO set. + utxo_hash: Bytes, + /// Multi-party signature over the snapshot. + signature: Bytes, + /// Lovelace pinned on the new µHead `Closed` output. + head_out_lovelace: Int, + /// POSIX-ms contestation deadline (T_final = now + contestation_period). + deadline: Int, + /// HeadPolicy currency symbol of the target head. + head_id: Bytes, + /// Number of participation tokens accompanying the ST on the head output. + pt_count: Int, + // Pass an empty list; on close the contester set starts empty. + // Tx3 has no `[]` literal in this position, so it's a parameter. + /// Initial contester set written into the `Closed` datum (typically empty). + initial_contesters: List, +) { + locals { + st: AnyAsset(head_id, st_token_name, 1), + pts: AnyAsset(head_id, head_id, pt_count), + } + + reference head_script { + ref: head_script_ref, + } + + input head_in { + from: HeadScript, + ref: head_ref, + datum_is: HeadState, + redeemer: HeadRedeemer::Close { + snapshot_number: snapshot_number, + utxo_hash: utxo_hash, + signature: signature, + }, + } + + collateral { + from: Participant, + min_amount: fees, + } + + output head_out { + to: HeadScript, + amount: Ada(head_out_lovelace) + st + pts, + datum: HeadState::Closed { + params: params, + snapshot_number: snapshot_number, + utxo_hash: utxo_hash, + deadline: deadline, + contesters: initial_contesters, + }, + } + + signers { + Participant, + } + + output { + to: Participant, + amount: head_in - Ada(head_out_lovelace) - st - pts - fees, + } +} + +/// Post a newer snapshot during the contestation window to override the closed state. +tx contest( + /// The µHead state UTxO (in `Closed`) being consumed. + head_ref: UtxoRef, + /// Head parameters carried over from the `Closed` datum. + params: HeadParameters, + /// Newer snapshot number being asserted. + snapshot_number: Int, + /// Merkle root of the newer snapshot's UTxO set. + utxo_hash: Bytes, + /// Multi-party signature over the newer snapshot. + signature: Bytes, + /// Vkey hash of the participant posting this contest. + contester: Bytes, + /// Updated contester list (existing entries with `contester` appended). + contesters: List, + /// POSIX-ms contestation deadline carried over (unchanged by contest). + deadline: Int, + /// Lovelace pinned on the new µHead `Closed` output. + head_out_lovelace: Int, + /// HeadPolicy currency symbol of the target head. + head_id: Bytes, + /// Number of participation tokens accompanying the ST on the head output. + pt_count: Int, +) { + locals { + st: AnyAsset(head_id, st_token_name, 1), + pts: AnyAsset(head_id, head_id, pt_count), + } + + reference head_script { + ref: head_script_ref, + } + + input head_in { + from: HeadScript, + ref: head_ref, + datum_is: HeadState, + redeemer: HeadRedeemer::Contest { + snapshot_number: snapshot_number, + utxo_hash: utxo_hash, + signature: signature, + }, + } + + collateral { + from: Participant, + min_amount: fees, + } + + validity { + until_slot: tip_slot() + 600, + } + + output head_out { + to: HeadScript, + amount: Ada(head_out_lovelace) + st + pts, + datum: HeadState::Closed { + params: params, + snapshot_number: snapshot_number, + utxo_hash: utxo_hash, + deadline: deadline, + contesters: contesters, + }, + } + + signers { + Participant, + } + + output { + to: Participant, + amount: head_in - Ada(head_out_lovelace) - st - pts - fees, + } +} + +/// Finalize a Closed head past its deadline: burn ST + PTs and materialize the L2 UTxO set on L1. +// Modelled with one consolidated output to a single recipient; real +// fanout produces one output per UTxO in the final snapshot. +tx fanout( + /// The µHead state UTxO (in `Closed`) being consumed. + head_ref: UtxoRef, + /// HeadPolicy currency symbol of the head being finalized. + head_id: Bytes, + /// Reference script UTxO carrying the HeadPolicy minting policy. + head_policy_ref: UtxoRef, + /// Number of participation tokens to burn alongside the ST. + pt_count: Int, + /// Lovelace materialized back onto L1 from the final snapshot. + fanout_lovelace: Int, + /// Address receiving the consolidated fanout output. + fanout_recipient: Bytes, + /// Slot at or after which the validator accepts the fanout (≥ contestation deadline). + deadline_slot: Int, + /// Final snapshot number being materialized. + final_snapshot_number: Int, + /// Final Merkle root of the L2 UTxO set being materialized. + final_utxo_hash: Bytes, +) { + locals { + st: AnyAsset(head_id, st_token_name, 1), + pts: AnyAsset(head_id, head_id, pt_count), + } + + reference policy_ref { + ref: head_policy_ref, + } + + reference head_script { + ref: head_script_ref, + } + + input head_in { + from: HeadScript, + ref: head_ref, + datum_is: HeadState, + redeemer: HeadRedeemer::Fanout { + snapshot_number: final_snapshot_number, + utxo_hash: final_utxo_hash, + }, + } + + collateral { + from: Participant, + min_amount: fees, + } + + // Lower bound on validity must be ≥ deadline for the validator + // to accept the fanout path. + validity { + since_slot: deadline_slot, + } + + burn { + amount: st + pts, + redeemer: HeadPolicyRedeemer::Burn {}, + } + + output { + to: fanout_recipient, + amount: Ada(fanout_lovelace), + } + + signers { + Participant, + } + + output { + to: Participant, + amount: head_in - Ada(fanout_lovelace) - st - pts - fees, + } +} diff --git a/journeys/09-protocol-fixture/protocol/trix.toml b/journeys/09-protocol-fixture/protocol/trix.toml new file mode 100644 index 0000000..76249b1 --- /dev/null +++ b/journeys/09-protocol-fixture/protocol/trix.toml @@ -0,0 +1,29 @@ +[protocol] +scope = "open-tx3" +name = "hydra-heads" +version = "0.0.0" +description = "Cardano L1 transactions that drive a Hydra Head through its lifecycle" +repository = "https://github.com/open-tx3/acme-protocols" +main = "main.tx3" +readme = "README.md" +logo = "logo.png" + +[ledger] +family = "cardano" + +# Per-network profiles. Each loads `.env.` to populate the +# network-level `env { ... }` block in main.tx3 (validator reference +# scripts and the protocol-wide ST token name). Per-head values +# (head_id, head_policy_ref) are passed as tx parameters. + +[profile.devnet] +env_file = ".env.devnet" + +[profile.preview] +env_file = ".env.preview" + +[profile.preprod] +env_file = ".env.preprod" + +[profile.mainnet] +env_file = ".env.mainnet" diff --git a/journeys/10-live-network/README.md b/journeys/10-live-network/README.md new file mode 100644 index 0000000..2f026f1 --- /dev/null +++ b/journeys/10-live-network/README.md @@ -0,0 +1,26 @@ +# 10-live-network + +A real transfer resolved against a **live Cardano preprod** network through a Demeter TRP +endpoint: scaffold, point the `preprod` network at the provided endpoint, and +`trix invoke -p preprod --skip-submit` a transfer between two funded test parties. This is the +only journey that touches real chain state — everything the offline journeys resolve comes from +a throwaway devnet. + +- **Scope:** runtime against a live network. **Secrets-gated** — never on the fast offline gate. + Scheduled + manually dispatched via `.github/workflows/dx-e2e-live.yml`. +- **Env contract** (mirrors `sdks/scripts/run-e2e-tests.sh`, reuse the same GitHub secrets): + - `TRP_ENDPOINT_PREPROD` — Demeter TRP endpoint. Unset ⇒ the journey **skips green**. + - `TRP_API_KEY_PREPROD` — sent as the `dmtr-api-key` header. + - `TEST_PARTY_A_ADDRESS` / `TEST_PARTY_B_ADDRESS` — funded preprod addresses (sender/receiver). + - Set-but-incomplete env fails loudly rather than half-running. +- **Resolve-only, by design:** `trix invoke` never signs without a TTY, so `--skip-submit` is the + headless mode — the journey validates live UTxO selection and tx construction without leaving + an on-chain footprint or needing keys. The sdks' own live e2e suite covers sign/submit through + the SDKs (that's what the `TEST_PARTY_*_MNEMONIC` secrets are for; this journey deliberately + doesn't consume them). + +## Arming it in CI + +The workflow runs on schedule/dispatch and forwards the four secrets. Until +`TRP_ENDPOINT_PREPROD` & co. are configured as repository (or org) secrets on this repo, the +scheduled run is a green no-op whose log says the journey was skipped. diff --git a/journeys/10-live-network/journey.sh b/journeys/10-live-network/journey.sh new file mode 100755 index 0000000..bfd2659 --- /dev/null +++ b/journeys/10-live-network/journey.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# +# Journey 10 — live network. See README.md for what this covers. +# Run via e2e/run.sh, which provides $TRIX and an isolated working directory. + +source "${E2E_LIB:?E2E_LIB not set — run this journey via e2e/run.sh}" + +journey_begin "10-live-network" "resolve a transfer against a live preprod TRP endpoint (secrets-gated)" + +# Secrets gate: without the live env this journey skips green, so it can sit +# in the auto-discovered suite without poisoning offline runs. A *partially* +# configured env is a misconfiguration and fails loudly instead. +if [[ -z "${TRP_ENDPOINT_PREPROD:-}" ]]; then + skip "TRP_ENDPOINT_PREPROD not set — skipping live-network journey (secrets-gated)" + journey_end + exit 0 +fi +for v in TRP_API_KEY_PREPROD TEST_PARTY_A_ADDRESS TEST_PARTY_B_ADDRESS; do + [[ -n "${!v:-}" ]] || die "TRP_ENDPOINT_PREPROD is set but ${v} is missing — refusing a half-configured live env" +done + +# 1. Scaffold, then point the preprod network at the provided TRP endpoint. +# The u5c endpoint stays on the public preprod one stock trix ships for the +# known network (URL and key are public constants baked into the binary). +run_cmd "trix init -y — scaffold a new project" "${TRIX}" init -y +cat >> trix.toml <