diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44d913a..555555f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,9 +1,9 @@ name: CI on: push: - branches: [main] + branches: [main, develop] pull_request: - branches: [main] + branches: [main, develop] jobs: test: @@ -19,11 +19,15 @@ jobs: - uses: actions/checkout@v4 - uses: graalvm/setup-graalvm@v1 with: - java-version: '25' + java-version: '25.0.3' distribution: 'graalvm' - uses: oven-sh/setup-bun@v2 - - name: Install pytest - run: python3 -m pip install --break-system-packages pytest + - uses: actions/setup-go@v5 + with: + go-version: 'stable' + - uses: dtolnay/rust-toolchain@stable + - name: Install Python test deps + run: python3 -m pip install --break-system-packages pytest pyyaml - name: Build native library run: ./gradlew :core:nativeCompile - name: Run JVM tests @@ -38,3 +42,22 @@ jobs: run: ./gradlew :wrappers:rust:test - name: Run JS tests run: ./gradlew :wrappers:js:test + + # Windows: verify the native library (libccl.dll) builds and core logic passes. + # Wrapper test coverage on Windows (Go cgo, C Makefile, etc.) is tracked separately + # in TODO.md. + windows: + runs-on: windows-latest + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v4 + - uses: graalvm/setup-graalvm@v1 + with: + java-version: '25.0.3' + distribution: 'graalvm' + - name: Build native library (libccl.dll) + run: ./gradlew :core:nativeCompile + - name: Run JVM tests + run: ./gradlew :core:test diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml new file mode 100644 index 0000000..bc710bb --- /dev/null +++ b/.github/workflows/integration-tests.yml @@ -0,0 +1,76 @@ +name: Integration Tests (DevKit) + +# Runs the wrappers' integration tests against a local Yaci DevKit Cardano devnet. +# These exercise real build -> sign -> submit round trips. The devnet's admin API is +# exposed on :10000. Heavier than the unit CI, so it runs on PRs to main/develop and on demand. +on: + pull_request: + branches: [main, develop] + workflow_dispatch: + +jobs: + integration-tests: + name: Integration Tests (Cardano Dev-Net) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: graalvm/setup-graalvm@v1 + with: + java-version: '25.0.3' + distribution: 'graalvm' + - uses: oven-sh/setup-bun@v2 + - uses: actions/setup-go@v5 + with: + go-version: 'stable' + - uses: dtolnay/rust-toolchain@stable + # Node.js is needed for the npm-based yaci-devkit CLI. + - uses: actions/setup-node@v4 + with: + node-version: '20' + - name: Install Python test deps + run: python3 -m pip install --break-system-packages pytest pyyaml + + - name: Install Yaci DevKit + run: npm install -g @bloxbean/yaci-devkit + + # Start the devnet in the background; it warms up while the native lib builds. + - name: Start Yaci DevKit + run: nohup yaci-devkit up --enable-yaci-store > devkit.log 2>&1 & + + - name: Build native library + run: ./gradlew :core:nativeCompile + + - name: Wait for DevKit admin API (:10000) + run: | + echo "Waiting for Yaci DevKit admin API..." + for i in $(seq 1 60); do + if curl -sf http://localhost:10000/local-cluster/api/admin/devnet >/dev/null 2>&1; then + echo "DevKit is ready (attempt $i)." + exit 0 + fi + echo "Attempt $i/60 - not ready yet, waiting 5s..." + sleep 5 + done + echo "DevKit did not become ready in time." >&2 + exit 1 + + - name: Python integration tests + run: ./gradlew :wrappers:python:integrationTest + + # The Go and Rust test tasks already include their integration tests, which + # skip themselves when DevKit is down and run when it is up. + - name: Go integration tests + run: ./gradlew :wrappers:go:test + + - name: Rust integration tests + run: ./gradlew :wrappers:rust:test + + - name: JS integration tests + run: ./gradlew :wrappers:js:integrationTest + + - name: Upload DevKit log on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: devkit-log + path: devkit.log diff --git a/.github/workflows/portable-linux-lib.yml b/.github/workflows/portable-linux-lib.yml new file mode 100644 index 0000000..34660d5 --- /dev/null +++ b/.github/workflows/portable-linux-lib.yml @@ -0,0 +1,116 @@ +name: Portable Linux lib (glibc baseline) + +# Guards the distro-portability of the shipped libccl.so. native-image can't emit a static library +# (oracle/graal#3053), so portability is achieved by building the .so against an OLD glibc baseline: +# inside manylinux_2_28 the resulting lib references no symbol newer than GLIBC_2.17, so it runs on +# RHEL/CentOS 7+, Amazon Linux 2, Ubuntu 18.04+, Debian 9+ and every newer distro. See +# docs/adr/0008-linux-glibc-baseline-portability.md. This builds it, asserts the glibc floor, proves it +# actually runs on glibc 2.17 (centos:7) — catching any regression that pulls in a newer symbol. + +on: + pull_request: + push: + branches: [develop, main] + workflow_dispatch: + +jobs: + glibc-baseline: + name: Build libccl.so on glibc 2.28 baseline (manylinux_2_28) + runs-on: ubuntu-latest + container: + image: quay.io/pypa/manylinux_2_28_x86_64 + env: + GRAALVM_URL: https://download.oracle.com/graalvm/25/archive/graalvm-jdk-25.0.3_linux-x64_bin.tar.gz + # The guarantee: the .so must require no glibc symbol newer than the builder's glibc (2.28). + # (In practice native-image tops out at 2.17 — the centos:7 job below enforces that strictly.) + BASELINE: "2.28" + steps: + - uses: actions/checkout@v4 + + - name: Show build-container glibc + run: ldd --version | head -1 + + - name: Ensure build prerequisites (gcc toolset, zlib headers) + run: | + set -eux + gcc --version | head -1 || dnf install -y gcc + dnf install -y zlib-devel || true + + - name: Install Oracle GraalVM 25.0.3 + run: | + set -eux + mkdir -p /opt/graalvm + curl -fsSL -o /tmp/graalvm.tar.gz "$GRAALVM_URL" + tar xzf /tmp/graalvm.tar.gz -C /opt/graalvm --strip-components=1 + echo "JAVA_HOME=/opt/graalvm" >> "$GITHUB_ENV" + echo "/opt/graalvm/bin" >> "$GITHUB_PATH" + + - name: Toolchain versions + run: | + set -eux + "$JAVA_HOME/bin/java" -version + "$JAVA_HOME/bin/native-image" --version + + - name: Build native library + run: ./gradlew :core:nativeCompile --no-daemon --stacktrace + + - name: Assert glibc floor (<= 2.28) + run: | + set -eux + SO=core/build/native/nativeCompile/libccl.so + test -f "$SO" + objdump -T "$SO" | grep -oE 'GLIBC_[0-9]+\.[0-9]+(\.[0-9]+)?' | sort -uV > /tmp/syms.txt + echo "---- GLIBC symbol versions required ----"; cat /tmp/syms.txt + MAX=$(sed 's/GLIBC_//' /tmp/syms.txt | sort -V | tail -1) + echo "---- Max required: GLIBC_$MAX (guarantee: <= $BASELINE) ----" + HIGHER=$(printf '%s\n%s\n' "$MAX" "$BASELINE" | sort -V | tail -1) + if [ "$HIGHER" != "$BASELINE" ]; then + echo "FAIL: libccl.so requires GLIBC_$MAX, newer than baseline GLIBC_$BASELINE" + exit 1 + fi + echo "PASS: libccl.so runs on any system with glibc >= $MAX" + + - name: Compile the smoke harness against libccl.so + run: | + set -eux + ND=core/build/native/nativeCompile + gcc native-test/src/smoke.c -I"$ND" -L"$ND" -lccl -Wl,-rpath,'$ORIGIN' -o "$ND/smoke" + + - name: Stage runnable artifact (lib + harness together) + run: | + set -eux + mkdir -p dist + cp core/build/native/nativeCompile/libccl.so dist/ + cp core/build/native/nativeCompile/smoke dist/ + + - uses: actions/upload-artifact@v4 + with: + name: libccl-glibc-baseline + path: dist/ + + run-on-old-distro: + name: Run libccl.so on glibc 2.17 (centos:7) + needs: glibc-baseline + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + with: + name: libccl-glibc-baseline + path: dist + + # Run the prebuilt binary inside centos:7 (glibc 2.17) WITHOUT installing anything. This is a + # strict run-on-2.17 guard: if some change pushes the required glibc above 2.17, the loader + # here fails. We don't use a `container:` for the job because GitHub's node-based actions can't + # run inside glibc 2.17; `docker run` keeps the actions on the modern runner. + - name: Load + run on centos:7 + run: | + set -eux + chmod +x dist/smoke + docker run --rm -v "$PWD/dist:/work" -w /work centos:7 bash -c ' + set -eux + ldd --version | head -1 + echo "---- libccl.so shared-lib deps on this host ----" + ldd ./libccl.so + echo "---- running harness ----" + ./smoke + ' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f9b34da..3df8f9d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,37 +4,86 @@ on: tags: ['v*'] jobs: - build: + # macOS + Windows: stable ABIs, no glibc-portability concern. Built on the native runner. + build-native: strategy: matrix: include: - os: macos-14 platform: macos-aarch64 ext: dylib - - os: ubuntu-latest - platform: linux-x86_64 - ext: so + - os: windows-latest + platform: windows-x86_64 + ext: dll runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - uses: graalvm/setup-graalvm@v1 with: - java-version: '25' + java-version: '25.0.3' distribution: 'graalvm' - name: Build native library run: ./gradlew :core:nativeCompile - name: Package + shell: bash run: | cd core/build/native/nativeCompile + # On Windows native-image also emits an import library (libccl.lib) needed + # to link against the DLL; include it when present. + EXTRA="" + [ -f libccl.lib ] && EXTRA="libccl.lib" tar czf ccl-bridge-${{ github.ref_name }}-${{ matrix.platform }}.tar.gz \ - libccl.${{ matrix.ext }} libccl.h graal_isolate.h + libccl.${{ matrix.ext }} libccl.h graal_isolate.h $EXTRA - uses: actions/upload-artifact@v4 with: name: ccl-bridge-${{ matrix.platform }} path: core/build/native/nativeCompile/ccl-bridge-*.tar.gz + # Linux: built in an OLD-glibc container (manylinux_2_28) so the shipped libccl.so runs on + # glibc >= 2.17 (RHEL/CentOS 7+, Amazon Linux 2, Ubuntu 18.04+, Debian 9+) instead of only the + # very newest distros. Portability is verified continuously by portable-linux-lib.yml; here we + # re-assert the floor before shipping. See docs/adr/0008-linux-glibc-baseline-portability.md. + build-linux: + name: build (linux-x86_64, glibc 2.17 baseline) + runs-on: ubuntu-latest + container: + image: quay.io/pypa/manylinux_2_28_x86_64 + env: + GRAALVM_URL: https://download.oracle.com/graalvm/25/archive/graalvm-jdk-25.0.3_linux-x64_bin.tar.gz + steps: + - uses: actions/checkout@v4 + - name: Ensure build prerequisites + run: dnf install -y zlib-devel || true + - name: Install Oracle GraalVM 25.0.3 + run: | + set -eux + mkdir -p /opt/graalvm + curl -fsSL -o /tmp/graalvm.tar.gz "$GRAALVM_URL" + tar xzf /tmp/graalvm.tar.gz -C /opt/graalvm --strip-components=1 + echo "JAVA_HOME=/opt/graalvm" >> "$GITHUB_ENV" + echo "/opt/graalvm/bin" >> "$GITHUB_PATH" + - name: Build native library + run: ./gradlew :core:nativeCompile --no-daemon --stacktrace + - name: Assert glibc floor (<= 2.28) + run: | + set -eux + SO=core/build/native/nativeCompile/libccl.so + MAX=$(objdump -T "$SO" | grep -oE 'GLIBC_[0-9]+\.[0-9]+(\.[0-9]+)?' | sed 's/GLIBC_//' | sort -V | tail -1) + echo "libccl.so max required: GLIBC_$MAX" + HIGHER=$(printf '%s\n2.28\n' "$MAX" | sort -V | tail -1) + [ "$HIGHER" = "2.28" ] || { echo "FAIL: requires GLIBC_$MAX > 2.28"; exit 1; } + - name: Package + run: | + cd core/build/native/nativeCompile + tar czf ccl-bridge-${{ github.ref_name }}-linux-x86_64.tar.gz \ + libccl.so libccl.h graal_isolate.h + - uses: actions/upload-artifact@v4 + with: + name: ccl-bridge-linux-x86_64 + path: core/build/native/nativeCompile/ccl-bridge-*.tar.gz + release: - needs: build + needs: [build-native, build-linux] runs-on: ubuntu-latest permissions: contents: write diff --git a/CLAUDE.md b/CLAUDE.md index 5a2656c..68f2e29 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,4 +4,4 @@ - **Cardano Client Lib (CCL)**: https://github.com/bloxbean/cardano-client-lib - **Local CCL source**: `/Users/satya/work/bloxbean/reference/cardano-client-lib` -- **Target CCL version**: 0.7.1 +- **Target CCL version**: 0.8.0-pre4 diff --git a/README.md b/README.md index b538d25..303880f 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ ccl-bridge/ **For core developers** (building from source): - **[GraalVM 25+](https://www.graalvm.org/)** (includes `native-image`) ```bash - sdk install java 25.0.2-graal # via SDKMAN + sdk install java 25.0.3-graal # Oracle GraalVM, via SDKMAN ``` **Language runtimes (install whichever you need):** @@ -138,6 +138,11 @@ curl -L https://github.com/bloxbean/ccl-bridge/releases/latest/download/ccl-brid curl -L https://github.com/bloxbean/ccl-bridge/releases/latest/download/ccl-bridge-v0.1.0-linux-x86_64.tar.gz | tar xz -C /usr/local/lib/ ``` +> The Linux `libccl.so` is built against an old **glibc 2.17** baseline (in a `manylinux_2_28` +> container), so it runs on any glibc ≥ 2.17 — RHEL/CentOS 7+, Amazon Linux 2, Ubuntu 18.04+, +> Debian 9+, and all newer distros. (It does **not** run on musl-only systems such as Alpine; a +> musl variant is a possible future addition.) See [ADR-0008](docs/adr/0008-linux-glibc-baseline-portability.md) for the why. + Then set the library path: ```bash @@ -396,8 +401,8 @@ bridge.close(); ## Upstream -- **Cardano Client Lib**: [bloxbean/cardano-client-lib](https://github.com/bloxbean/cardano-client-lib) v0.7.1 -- **GraalVM**: 25.0.2 (`native-image --shared`) +- **Cardano Client Lib**: [bloxbean/cardano-client-lib](https://github.com/bloxbean/cardano-client-lib) v0.8.0-pre4 +- **GraalVM**: Oracle GraalVM 25.0.3 (`native-image --shared`) ## License diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..efebb95 --- /dev/null +++ b/TODO.md @@ -0,0 +1,184 @@ +# CCL Bridge — TODO + +WISHLIST (Satya): +- YAML support for TX building (TxPlan) +- UTxO capture on the client side, callback maybe an issue (e.g. BloxBean) - UTxO selection +- UTxO selection on the client +- Protocol Parameters should be fetched via provider (cost calculation) +- Script Supplier? + +A living, categorized backlog of work for CCL Bridge. CCL Bridge compiles +[Cardano Client Lib](https://github.com/bloxbean/cardano-client-lib) into a GraalVM +native shared library and exposes it to **Python, Go, Rust, and JavaScript (Bun)**. +The project is functionally mature (v0.1.0-preview1) but had no roadmap — this file +is the starting point. **It is meant to be extended**: add, re-prioritize, or check +off items freely as the project evolves. + +**Priority legend:** +- `P0` — blocks real-world adoption / advertised but missing +- `P1` — important; needed for a solid 1.0 +- `P2` — nice-to-have / future polish + +**Supported languages today:** Python, Go, Rust, JavaScript (Bun only). +C is test-only (smoke tests in `native-test/`); C headers ship for raw FFI consumers, +but there is no standalone "C wrapper" product. + +> **Coverage note:** All four wrappers expose all 34 `@CEntryPoint` functions. Python +> is currently the most complete and best-tested wrapper (the de-facto reference); +> Go and Rust trail on test breadth; **JavaScript is the laggard** on QuickTx features. + +--- + +## 1. Development — Wrapper Parity & Features + +- [x] `P0` ~~Audit & confirm JS QuickTx/ScriptTx/compose parity vs Python.~~ **Done (verified against source):** JS is feature-complete — `mintPlutusAssets`, `collectFromScript`, `readFrom`, the full `ScriptTxBuilder`, and `compose()`/`ComposeTxBuilder` all exist in `wrappers/js/src/index.js`. No feature gap. The real gap is test coverage — see §3. +- [ ] `P1` Designate Python as the documented "reference wrapper" and write a parity checklist so all four wrappers stay in lockstep as the API grows. +- [ ] `P2` Split the monolithic Go `wrappers/go/ccl/ccl.go` (~2k LOC) and Rust `wrappers/rust/src/lib.rs` into focused modules for maintainability. +- [ ] `P2` Cross-wrapper error-handling review for consistent `CclError` semantics (codes, messages, idiomatic types). +- [ ] `P2` Give the Go wrapper a clear build-time message when `CGO_ENABLED=0` (a `//go:build cgo` guard + a stub that explains cgo is required), instead of a cryptic linker error. +- [x] `P2` ~~Expose **stake-key signing**~~ **Done** — added `ccl_account_sign_tx_multi(…, keys)`, which signs with any subset of `payment` / `stake` / `drep` / `committee_cold` / `committee_hot` (CCL's `Account.signWith*Key`), wired through all four wrappers (`sign_tx_with_keys` / `SignTxWithKeys` / `signTxWithKeys`). Fixes the `MissingVKeyWitnessesUTXOW` rejection for stake/vote/DRep certs; the original `ccl_account_sign_tx` (payment only) is unchanged. + +## 2. Development — Build, CI & Distribution + +- [x] `P0` ~~Fix the Go wrapper's thread affinity on Linux x86_64.~~ **Done** — all FFI calls now run on a single dedicated OS thread that owns the isolate for the `Bridge`'s lifetime (`runtime.LockOSThread` + a channel-served executor goroutine in `wrappers/go/ccl/ccl.go`). This keeps the executing OS thread and the GraalVM `IsolateThread` in sync, eliminating the Linux "yellow zone" `StackOverflowError`. Linux Go CI is blocking again and green. +- [x] `P0` ~~Add a **Windows** native build (`libccl.dll`) to CI and the release pipeline.~~ **Done** — CI has a `windows-latest` job that builds `libccl.dll` (`:core:nativeCompile`) and runs the JVM tests; `release.yml` produces a `windows-x86_64` artifact (DLL + `libccl.lib` import library + headers). Verified green on CI. +- [ ] `P1` Add **Windows wrapper test coverage** to CI (Python/Rust/JS/Go). The Windows job currently only builds the DLL + runs JVM tests; the wrapper test tasks assume a bash/`python3` shell and Unix `*_LIBRARY_PATH` semantics, and Go cgo + the C `native-test` Makefile need a Windows C toolchain. Each needs Windows-specific wiring. +- [ ] `P0` Bundle or auto-fetch the native lib per wrapper (wheel platform tags / Rust `build.rs` / npm `postinstall`) so users no longer hand-set `CCL_LIB_PATH` / `DYLD_LIBRARY_PATH` / `LD_LIBRARY_PATH`. +- [~] `P1` **Investigate static linking** — *decided; see [ADR-0008](docs/adr/0008-linux-glibc-baseline-portability.md).* **Finding:** `native-image` **cannot** emit a static library (`.a`) — oracle/graal#3053 is still open on GraalVM 25 — and musl's run-anywhere property applies only to static *executables*, not shared libraries. So a fully-static, no-`.so` distribution that keeps the in-process FFI is not possible without re-architecting to a static musl executable behind IPC (rejected as too invasive). **Decision + done: distro/glibc independence via a glibc-baseline build.** Building the Linux `.so` in `manylinux_2_28` yields a lib that requires only **`GLIBC_2.17`** — verified green in CI, and proven to load + run a real key-derivation on `centos:7` (glibc 2.17). Rolled out: `portable-linux-lib.yml` guards it on every PR/develop (objdump floor + centos:7 run), and `release.yml` ships the Linux artifact from the same container. Runs on RHEL/CentOS 7+, Amazon Linux 2, Ubuntu 18.04+, Debian 9+. **Not** Alpine/musl (possible future variant). _Follow-up `P2`: linux-arm64 baseline build; musl variant for Alpine._ +- [ ] `P1` Add **linux-arm64** and **macos-x86_64** to the build/release matrix (currently only `ubuntu-latest` x86_64 + `macos-14` ARM64). +- [ ] `P1` Add **musl / Alpine Linux** native builds. The current glibc-linked `.so` fails on musl-based images (common in Go/Docker). Ship a musl variant (and document the glibc baseline for the standard build). +- [ ] `P1` Publish wrappers to registries: PyPI (`ccl`), crates.io (`ccl`), npm (`@bloxbean/ccl`), and tag the Go module for the proxy. +- [x] `P1` Pin CI to Oracle GraalVM `25.0.3` exactly (CI currently floats `java-version: '25'`) for reproducible builds. +- [ ] `P2` Fill in wrapper manifest metadata (`[project.urls]`, `repository`, `homepage`, `documentation`) in `pyproject.toml` / `Cargo.toml` / `package.json` / `go.mod`. +- [ ] `P2` Automate version bumping from a single source of truth (the version is duplicated across `gradle.properties` and each wrapper manifest). +- [ ] `P2` **Runtime lib↔wrapper version check.** A native lib a version behind its wrapper fails confusingly; have each wrapper call `ccl_version` on init and error clearly on mismatch. +- [ ] `P2` **Sign release artifacts** (cosign/sigstore) for supply-chain trust when pulling a prebuilt native lib. The release already emits `SHA256SUMS`; add signatures + verification docs. + +## 2b. Plutus script evaluation — pluggable evaluators + +The bridge builds Plutus script transactions offline by accepting the redeemers' **execution +units** (mem + CPU steps) as a fourth caller-supplied input to `ccl_quicktx_build` — exactly like +UTXOs and protocol parameters. Internally it wires CCL's `StaticTransactionEvaluator`, so the +bridge never runs the script; the caller computes the units with whatever evaluator they prefer. +This is shipped and tested (`QuickTxApiTest.plutusMint*`). + +- [ ] `P1` **Evaluator abstraction + examples (pick-and-choose).** Give users a clear, per-language + story for *obtaining* the exec units to pass in, with helper/service classes and runnable + examples for each supported evaluator: + - **HTTP / Blockfrost** `…/utils/txs/evaluate` (online) + - **Ogmios** `EvaluateTx` (online) + - **Aiken** UPLC evaluator (offline; e.g. `aiken-java-binding` server-side, or a wrapper-native + binding) + - **Scalus** UPLC evaluator (offline, JVM/Scala) + The bridge stays evaluator-agnostic (it only consumes `[{mem, steps}]`); these are thin, + swappable client-side helpers + docs showing the two-pass flow (build → evaluate → rebuild with + units). Cover Python, Go, Rust, JS. +- [ ] `P2` **Self-contained offline evaluation spike — `aiken-java-binding` inside the GraalVM + native image.** If the Aiken Rust UPLC evaluator can be loaded via JNI from within `libccl` + (the blockers: the binding extracts its `.so` from the classpath jar at runtime — absent in a + native image — plus JNI config and per-platform Rust binaries), the bridge could run scripts + itself and callers would supply *nothing* extra. Prove feasibility before committing. + +## 2c. Chain-data provider helpers — make the API easy in all 4 languages + +`ccl_quicktx_build` is offline by design: the caller supplies **UTXOs**, **protocol parameters**, +and (for Plutus) **execution units**. Today every wrapper is a pure pass-through — it marshals +those three inputs and calls the native lib, but does **nothing** to obtain them. The user has to +make their own HTTP calls to a backend first. That is the single biggest friction point for a +first-time user, in every language. + +The fix keeps the **native lib provider-free** (offline stays offline) and adds the convenience +*entirely in wrapper code*, using each language's own HTTP client — so the offline contract is +untouched and the helpers are optional and swappable. This is the sibling of §2b: §2b obtains the +*exec units*; this obtains the *UTXOs + protocol parameters*. Together they cover all three inputs. + +- [ ] `P1` **Optional per-wrapper chain-data provider helpers (UTXOs + protocol params).** A thin, + optional helper in Python/Go/Rust/JS that fetches the data `build()` needs and returns it in the + exact shape the wrapper already accepts, e.g.: + ``` + provider = BlockfrostProvider(project_id) # or Koios / Ogmios / Yaci DevKit + utxos = provider.utxos(sender_addr) # all UTXOs at the address + pp = provider.protocol_params() + result = quicktx.build(yaml, utxos, pp) # unchanged offline core call + ``` + Notes: + - **No UTXO *selection* needed** — the bridge already selects internally (it hands all sender + UTXOs to `QuickTxBuilder`/`StaticUtxoSupplier`). The helper only needs "UTXOs at address X". + - Define a small provider interface per language (`utxos(addr)`, `protocol_params()`), ship at + least one concrete impl (Blockfrost-style + Yaci DevKit, which the integration tests already + hit), and document a `buildWithProvider(yaml, provider, sender)` convenience that composes + fetch → build. + - Compose cleanly with §2b's exec-unit evaluators so a Plutus build is `fetch → evaluate → build`. +- [ ] `P2` **Reconcile the WISHLIST vs Non-Goals tension.** Satya's wishlist wants provider-fetched + protocol params + client-side UTXO capture; Non-Goals excludes "HTTP provider modules". The + resolution is the split above: *optional wrapper-side helpers are in scope; baking a provider + into the native `libccl` is not.* The Non-Goals note now says this explicitly. + +## 3. Testing + +- [x] `P1` ~~Add JS integration tests for the script/Plutus paths.~~ **Done (and the item's premise was superseded by the TxPlan refactor):** the old fluent `ScriptTxBuilder` / `collectFromScript` / `mintPlutusAssets` / `readFrom` API was deleted — script/Plutus paths are now TxPlan YAML fixtures, covered at the build level in `test/intents.e2e.test.js`: all 20 top-level intents (incl. `reference_input`, `compose`, `native_script`) plus the three `plutus/` fixtures — **mint**, **spend**, and **lock** — each asserting non-empty CBOR + 64-char hash + positive fee, that mint/spend **require** caller-supplied exec units (build throws without them), and that `plutus.dataHash` reproduces the lock fixture's datum hash. Node-level (DevKit): a Plutus-mint **build → sign → submit → assert the minted asset landed on-chain** round-trip in `test/quicktx.integration.test.js`, mirroring Go's `TestIntegrationPlutusMint`. +- [ ] `P1` **Fix JS cost-model key ordering for Plutus builds.** Surfaced while adding the JS Plutus-mint submit test: passing the cost models fetched from a Blockfrost-style provider (`/epochs/parameters` returns them as a map keyed by zero-padded indices `"000".."165"`) into a Plutus `build()` yields a tx the node rejects with `PPViewHashesDontMatch` — JS's JSON parse reorders the non-padded integer-like keys (`"100".."165"`) ahead of the padded ones, scrambling the cost-model order vs the ledger's canonical order and corrupting the script-integrity hash. (Go's `json.Marshal` sorts keys lexicographically, which for zero-padded keys equals numeric order, so the Go path is unaffected.) The integration test works around it by dropping the fetched cost models so the lib uses its built-in standard set; the real fix is to preserve canonical cost-model order in the JS wrapper before handing params to the native lib. Likely affects the §2c provider helpers, which will fetch and pass these params. +- [ ] `P1` Raise Go and Rust test breadth toward Python's (~100 cases vs ~61); port Python's per-module unit tests. +- [ ] `P1` Add a cross-wrapper parity test matrix asserting every `@CEntryPoint` is exercised in every language. +- [ ] `P2` Run the Yaci DevKit integration tests in CI (containerized DevKit) instead of skip-if-not-running. +- [ ] `P2` Expand the C smoke tests and add an FFI memory-leak / valgrind check across the native boundary. +- [ ] `P2` Add benchmarks for FFI call overhead and JSON (de)serialization cost. + +## 4. User Documentation + +- [x] `P1` Per-wrapper `README.md` (install, load the lib, first call) for python / go / rust / js. **Done** — added `wrappers/{python,go,rust,js}/README.md`. +- [x] `P1` Add per-wrapper `examples/` with runnable offline samples. **Done** — each wrapper has account / primitives / transaction examples (offline build+sign, no DevKit). All four verified running locally (Python, Go, Rust, JS/Bun). _Follow-up: richer samples (NFT mint, staking, governance)._ +- [ ] `P2` Generated API reference per language (Sphinx / rustdoc / godoc / JSDoc or TypeDoc). +- [ ] `P2` Add project-meta docs: `CONTRIBUTING.md`, `CHANGELOG.md`, `SECURITY.md`, `CODE_OF_CONDUCT.md`, and GitHub issue/PR templates. +- [ ] `P2` Expand the 7-line `devkit.md` into a proper Yaci DevKit integration-testing guide. +- [ ] `P2` Add an **end-to-end "build → sign → submit" example** per language. The bridge is offline-only, so users get stuck at broadcasting; show submitting the signed CBOR with the language's own HTTP client (e.g. Go `net/http`). +- [ ] `P2` Add CI status + DevKit-integration badges to the README so the working round trips are visible at a glance. + +## 5. Website + +- [ ] `P1` Stand up a **GitHub Pages documentation site** (MkDocs Material or Docusaurus) hosting the README content, per-language guides, and `docs/quicktx.md`. +- [ ] `P2` Auto-deploy the site from CI on release and wire in the generated per-language API references. + +## 6. Upstream CCL — New Modules to Evaluate + +Surfaced by scanning upstream CCL. The bridge now targets **0.8.0-pre4**, so all of these are +available as a current dependency — no further upgrade needed. + +### CIP modules (already a bridge dependency) + +- [ ] `P2` **CIP-30 data signing** — wrap `DataSignature` / `CIP30DataSigner` (COSE_Sign1 `signData` create + verify). Offline. Complements existing CIP-8 message signing with the wallet/dApp data-signature format. +- [ ] `P2` **CIP-27 royalty metadata** — wrap royalty metadata construction/parsing for NFTs. Offline; complements the bridge's existing CIP-25 support. + +### Now available on CCL 0.8.0-pre4 + +- [x] `P1` ~~**Upgrade CCL 0.7.2 → 0.8.0**~~ **Done** — the bridge is on `0.8.0-pre4` (the TxPlan refactor). The QuickTx wrapper was rewritten to TxPlan YAML; the 0.8.0 unified `Tx`/`ScriptTx` + `DepositMode` are exercised by the intent E2E suite. Re-pin to the stable `0.8.0` when it releases. +- [ ] `P2` **`plutus-aiken` blueprint handling** — expose runtime CIP-57 blueprint parsing and apply-params-to-script (parameterized validators). Offline. (The compile-time `@MetadataType` annotation processor is build-time Java codegen and is **not** FFI-able, so it is out of scope for the wrappers.) +- [ ] `P2` **`txflow` multi-step orchestration** — evaluate exposing the offline flow-composition parts. Caveat: confirmation tracking is online/stateful and fits the bridge's stateless-FFI model awkwardly; wrap only the pure-composition surface, if any. +- [ ] `P2` **CIP-102 royalty datum (v2)** — inline royalty datum on UTXOs; extends CIP-27. Offline datum (de)serialization. +- [ ] `P2` **`crypto-ext` VRF/KES** — niche (block-producer / consensus simulation, experimental). Offline. Only if devnet simulation becomes a goal. + +## 7. Maintenance — Existing Wrappers (audit, likely already covered) + +- [ ] `P2` Audit governance key derivation parity (`DRepKey`, `CommitteeColdKey`, `CommitteeHotKey`, gov-action IDs) — the bridge already exposes these; confirm nothing new in CCL is missing. +- [ ] `P2` Audit QuickTx deposit handling against CCL's `DepositMode` (AUTO / CHANGE_OUTPUT / ANY_OUTPUT / NEW_UTXO_SELECTION) when on 0.8.0. + +--- + +## Non-Goals (intentional, for now) + +- **Verified data structures** (`verified-structures`: Merkle Patricia Forestry, + Jellyfish Merkle Tree, RocksDB/RDBMS backends) — out of scope. They require + persistent, stateful storage backends, which is incompatible with the bridge's + stateless, side-effect-free FFI model. The pure-compute proof core could be + reconsidered only if there is explicit demand for Merkle-proof APIs. + +- **Node.js support** — *wanted but blocked.* Node FFI libraries (ffi-napi, koffi) crash + with the GraalVM native-image library due to stack-boundary detection issues on macOS + ARM64. Bun (built-in FFI) is the supported JS runtime. Tracked as a `P2` investigation + item, not a committed deliverable. +- **Backend / HTTP provider modules *in the native `libccl`*** (Blockfrost, Koios, Ogmios) — + deliberately excluded; the native lib stays offline and side-effect-free. **This does not + exclude optional, wrapper-side provider helpers** that fetch UTXOs / protocol params / exec + units using each language's own HTTP client and feed them into the offline `build()` — those + are explicitly in scope and tracked in §2b (exec units) and §2c (UTXOs + protocol params). + The line is: convenience in wrapper code = yes; a provider baked into `libccl` = no. diff --git a/core/build.gradle b/core/build.gradle index c9bc807..1a5280a 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -5,7 +5,7 @@ plugins { base.archivesName = 'ccl-bridge-core' -def cclVersion = '0.7.1' +def cclVersion = '0.8.0-pre4' dependencies { // CCL core modules (offline-only, no backend HTTP) @@ -23,7 +23,7 @@ dependencies { implementation "com.bloxbean.cardano:cardano-client-cip68:${cclVersion}" // GraalVM SDK for @CEntryPoint, CCharPointer, etc. - compileOnly "org.graalvm.sdk:nativeimage:25.0.0" + compileOnly "org.graalvm.sdk:nativeimage:25.0.3" // SLF4J NOP - avoids log framework init issues in native image implementation 'org.slf4j:slf4j-nop:2.0.11' @@ -47,6 +47,9 @@ graalvmNative { '--initialize-at-build-time=net.i2p.crypto.eddsa', '--initialize-at-build-time=com.fasterxml.jackson', '--initialize-at-build-time=co.nstant.in.cbor', + // snakeyaml is pulled in by CCL's TxPlan YAML support; its option enums + // must be initialized at build time for native-image. + '--initialize-at-build-time=org.yaml.snakeyaml', '--enable-url-protocols=http,https', ) } diff --git a/core/src/main/java/com/bloxbean/cardano/bridge/CclBridge.java b/core/src/main/java/com/bloxbean/cardano/bridge/CclBridge.java index 6499df0..f47ae09 100644 --- a/core/src/main/java/com/bloxbean/cardano/bridge/CclBridge.java +++ b/core/src/main/java/com/bloxbean/cardano/bridge/CclBridge.java @@ -8,18 +8,87 @@ import org.graalvm.nativeimage.c.function.CEntryPoint; import org.graalvm.nativeimage.c.type.CCharPointer; +/** + * Core lifecycle and result-retrieval entry points of the CCL Bridge native library. + * + *

What this library is

+ * The CCL Bridge compiles Cardano + * Client Lib into a native shared library ({@code libccl.so} / {@code .dylib} / {@code .dll}) + * with GraalVM {@code native-image}, exposing CCL's offline Cardano operations through a + * flat C ABI. Every public function in this package is a GraalVM {@link CEntryPoint}, i.e. an + * exported C symbol callable from any language with an FFI (Python, Go, Rust, JavaScript, C). + * + *

Calling convention

+ * All entry points follow the same contract, because only C-compatible primitives can cross the + * boundary (no Java objects): + * + * + *

Typical sequence (per logical operation)

+ *
{@code
+ *   int rc = ccl_account_create(thread, networkId);   // 1. do the work; returns status
+ *   if (rc == 0) {
+ *       char* json = ccl_get_result(thread);           // 2. fetch JSON result (thread-local)
+ *       // ... use json ...
+ *       ccl_free_string(thread, json);                 // 3. release the malloc'd string
+ *   } else {
+ *       char* err = ccl_get_last_error(thread);        // or fetch the error message
+ *       ccl_free_string(thread, err);
+ *   }
+ * }
+ * + *

Because the result/error are thread-local, the work call and its {@code ccl_get_result} / + * {@code ccl_get_last_error} retrieval must run on the same isolate thread. + * + * @see ErrorCodes status codes returned by every entry point + */ public final class CclBridge { private static final String VERSION = "0.1.0"; private CclBridge() {} + /** + * Returns the CCL Bridge library version. + * + *

Exported as {@code ccl_version}. On success the version string (e.g. {@code "0.1.0"}) is + * placed in the thread-local result; retrieve it with + * {@link #getResult(IsolateThread) ccl_get_result}. + * + * @param thread the current isolate thread + * @return {@link ErrorCodes#CCL_SUCCESS} + */ @CEntryPoint(name = "ccl_version") public static int version(IsolateThread thread) { ResultState.set(VERSION); return ErrorCodes.CCL_SUCCESS; } + /** + * Returns the result string produced by the most recent successful call on this thread. + * + *

Exported as {@code ccl_get_result}. The returned pointer is malloc'd and owned by the + * caller, who must release it with {@link #freeString(IsolateThread, CCharPointer) ccl_free_string}. + * If no result is set, an empty string is returned (never {@code NULL}). + * + * @param thread the current isolate thread + * @return a newly allocated, null-terminated UTF-8 C string holding the result (often JSON) + */ @CEntryPoint(name = "ccl_get_result") public static CCharPointer getResult(IsolateThread thread) { String result = ResultState.get(); @@ -29,6 +98,17 @@ public static CCharPointer getResult(IsolateThread thread) { return NativeString.toCString(result); } + /** + * Returns the error message produced by the most recent failed call on this thread. + * + *

Exported as {@code ccl_get_last_error}. Call this after an entry point returns a negative + * {@link ErrorCodes status code}. The returned pointer is malloc'd and owned by the caller, who + * must release it with {@link #freeString(IsolateThread, CCharPointer) ccl_free_string}. If no + * error is set, an empty string is returned (never {@code NULL}). + * + * @param thread the current isolate thread + * @return a newly allocated, null-terminated UTF-8 C string holding the error message + */ @CEntryPoint(name = "ccl_get_last_error") public static CCharPointer getLastError(IsolateThread thread) { String error = ErrorState.get(); @@ -38,6 +118,17 @@ public static CCharPointer getLastError(IsolateThread thread) { return NativeString.toCString(error); } + /** + * Frees a string previously returned by this library. + * + *

Exported as {@code ccl_free_string}. Every non-{@code NULL} pointer returned by + * {@code ccl_get_result} / {@code ccl_get_last_error} is allocated in unmanaged memory and + * must be passed here exactly once to avoid a memory leak. Passing {@code NULL} is a + * safe no-op. + * + * @param thread the current isolate thread + * @param ptr the string pointer to release (may be {@code NULL}) + */ @CEntryPoint(name = "ccl_free_string") public static void freeString(IsolateThread thread, CCharPointer ptr) { if (ptr.isNonNull()) { diff --git a/core/src/main/java/com/bloxbean/cardano/bridge/ErrorCodes.java b/core/src/main/java/com/bloxbean/cardano/bridge/ErrorCodes.java index a309b76..fbd20cf 100644 --- a/core/src/main/java/com/bloxbean/cardano/bridge/ErrorCodes.java +++ b/core/src/main/java/com/bloxbean/cardano/bridge/ErrorCodes.java @@ -1,16 +1,36 @@ package com.bloxbean.cardano.bridge; +/** + * Status codes returned by every CCL Bridge entry point. + * + *

{@link #CCL_SUCCESS} ({@code 0}) indicates success; all error codes are negative. On a + * negative return, a human-readable message is available via {@code ccl_get_last_error}. The + * specific code is a coarse category — the message carries the detail. + * + * @see CclBridge#getLastError calling convention and result/error retrieval + */ public final class ErrorCodes { + /** Operation succeeded; a result (if any) is available via {@code ccl_get_result}. */ public static final int CCL_SUCCESS = 0; + /** Unspecified failure not covered by a more specific code. */ public static final int CCL_ERROR_GENERAL = -1; + /** A required argument was missing, malformed, or out of range. */ public static final int CCL_ERROR_INVALID_ARGUMENT = -2; + /** CBOR/JSON (de)serialization failed. */ public static final int CCL_ERROR_SERIALIZATION = -3; + /** A cryptographic operation failed (hashing, signing, verification, key derivation). */ public static final int CCL_ERROR_CRYPTO = -4; + /** The supplied network id is not one of mainnet/testnet/preprod/preview. */ public static final int CCL_ERROR_INVALID_NETWORK = -5; + /** The supplied mnemonic phrase is invalid. */ public static final int CCL_ERROR_INVALID_MNEMONIC = -6; + /** The supplied address is invalid or could not be parsed. */ public static final int CCL_ERROR_INVALID_ADDRESS = -7; + /** Inputs do not cover the transaction's outputs, fees, and deposits. */ public static final int CCL_ERROR_INSUFFICIENT_FUNDS = -8; + /** A transaction could not be parsed, signed, or otherwise processed. */ public static final int CCL_ERROR_INVALID_TRANSACTION = -9; + /** Building a transaction from a QuickTx spec failed. */ public static final int CCL_ERROR_TX_BUILD = -10; private ErrorCodes() {} diff --git a/core/src/main/java/com/bloxbean/cardano/bridge/api/AccountApi.java b/core/src/main/java/com/bloxbean/cardano/bridge/api/AccountApi.java index f0961f8..e8b8321 100644 --- a/core/src/main/java/com/bloxbean/cardano/bridge/api/AccountApi.java +++ b/core/src/main/java/com/bloxbean/cardano/bridge/api/AccountApi.java @@ -13,10 +13,33 @@ import java.util.LinkedHashMap; import java.util.Map; +/** + * Account entry points: deterministic (HD) account creation, key derivation, and signing. + * + *

An "account" is a CIP-1852 HD account derived from a BIP-39 mnemonic. Methods that take a + * {@code networkId} use {@code 0}=mainnet, {@code 1}=testnet, {@code 2}=preprod, {@code 3}=preview. + * The {@code accountIndex}/{@code addressIndex} select the HD derivation path + * ({@code m/1852'/1815'/account'/role/address}). + * + *

See {@link com.bloxbean.cardano.bridge.CclBridge} for the calling convention (status code + + * thread-local result retrieved via {@code ccl_get_result}). Every entry point here is a static + * GraalVM {@code @CEntryPoint}. + */ public final class AccountApi { private AccountApi() {} + /** + * Creates a brand-new account with a freshly generated 24-word mnemonic. + * + *

Exported as {@code ccl_account_create}. On success the result is a JSON object: + *

{@code {"mnemonic","base_address","enterprise_address","stake_address","change_address"}}
+ * + * @param thread the current isolate thread + * @param networkId 0=mainnet, 1=testnet, 2=preprod, 3=preview + * @return {@link ErrorCodes#CCL_SUCCESS}, or {@link ErrorCodes#CCL_ERROR_INVALID_NETWORK} / + * {@link ErrorCodes#CCL_ERROR_GENERAL} + */ @CEntryPoint(name = "ccl_account_create") public static int create(IsolateThread thread, int networkId) { try { @@ -36,6 +59,20 @@ public static int create(IsolateThread thread, int networkId) { } } + /** + * Restores an account from an existing mnemonic at the given derivation indices. + * + *

Exported as {@code ccl_account_from_mnemonic}. On success the result is the same JSON + * object as {@code ccl_account_create}. + * + * @param thread the current isolate thread + * @param networkId 0=mainnet, 1=testnet, 2=preprod, 3=preview + * @param mnemonicPtr the BIP-39 mnemonic phrase (UTF-8 C string) + * @param accountIndex HD account index (typically 0) + * @param addressIndex HD address index (typically 0) + * @return {@link ErrorCodes#CCL_SUCCESS}, or {@link ErrorCodes#CCL_ERROR_INVALID_NETWORK} / + * {@link ErrorCodes#CCL_ERROR_INVALID_MNEMONIC} / {@link ErrorCodes#CCL_ERROR_GENERAL} + */ @CEntryPoint(name = "ccl_account_from_mnemonic") public static int fromMnemonic(IsolateThread thread, int networkId, CCharPointer mnemonicPtr, @@ -68,6 +105,21 @@ public static int fromMnemonic(IsolateThread thread, int networkId, } } + /** + * Derives the account's extended private key. + * + *

Exported as {@code ccl_account_get_private_key}. On success the result is the hex-encoded + * 64-byte extended BIP32-Ed25519 payment private key (128 hex chars). Note: a raw 32-byte + * Ed25519 key (e.g. for {@code ccl_crypto_sign}) is the first 32 bytes / 64 hex chars. + * + * @param thread the current isolate thread + * @param mnemonicPtr the BIP-39 mnemonic phrase (UTF-8 C string) + * @param networkId 0=mainnet, 1=testnet, 2=preprod, 3=preview + * @param accountIndex HD account index + * @param addressIndex HD address index + * @return {@link ErrorCodes#CCL_SUCCESS}, or {@link ErrorCodes#CCL_ERROR_INVALID_NETWORK} / + * {@link ErrorCodes#CCL_ERROR_CRYPTO} + */ @CEntryPoint(name = "ccl_account_get_private_key") public static int getPrivateKey(IsolateThread thread, CCharPointer mnemonicPtr, int networkId, int accountIndex, int addressIndex) { @@ -94,6 +146,20 @@ public static int getPrivateKey(IsolateThread thread, CCharPointer mnemonicPtr, } } + /** + * Derives the account's public key. + * + *

Exported as {@code ccl_account_get_public_key}. On success the result is the hex-encoded + * 32-byte Ed25519 payment public key (64 hex chars). + * + * @param thread the current isolate thread + * @param mnemonicPtr the BIP-39 mnemonic phrase (UTF-8 C string) + * @param networkId 0=mainnet, 1=testnet, 2=preprod, 3=preview + * @param accountIndex HD account index + * @param addressIndex HD address index + * @return {@link ErrorCodes#CCL_SUCCESS}, or {@link ErrorCodes#CCL_ERROR_INVALID_NETWORK} / + * {@link ErrorCodes#CCL_ERROR_CRYPTO} + */ @CEntryPoint(name = "ccl_account_get_public_key") public static int getPublicKey(IsolateThread thread, CCharPointer mnemonicPtr, int networkId, int accountIndex, int addressIndex) { @@ -120,6 +186,23 @@ public static int getPublicKey(IsolateThread thread, CCharPointer mnemonicPtr, } } + /** + * Signs a transaction with the account's payment key. + * + *

Exported as {@code ccl_account_sign_tx}. On success the result is the signed transaction as + * CBOR hex. Only the payment key is added; transactions whose certificates must be authorized by + * the stake key (e.g. vote-power / stake delegation) also need the stake key, which this entry + * point does not yet add. + * + * @param thread the current isolate thread + * @param mnemonicPtr the BIP-39 mnemonic phrase (UTF-8 C string) + * @param networkId 0=mainnet, 1=testnet, 2=preprod, 3=preview + * @param accountIndex HD account index + * @param addressIndex HD address index + * @param txCborHexPtr the unsigned (or partially signed) transaction as CBOR hex + * @return {@link ErrorCodes#CCL_SUCCESS}, or {@link ErrorCodes#CCL_ERROR_INVALID_NETWORK} / + * {@link ErrorCodes#CCL_ERROR_INVALID_TRANSACTION} + */ @CEntryPoint(name = "ccl_account_sign_tx") public static int signTx(IsolateThread thread, CCharPointer mnemonicPtr, int networkId, int accountIndex, int addressIndex, @@ -154,6 +237,87 @@ public static int signTx(IsolateThread thread, CCharPointer mnemonicPtr, } } + /** + * Signs a transaction with one or more of the account's keys, selected by role. + * + *

Exported as {@code ccl_account_sign_tx_multi}. {@code keys} is a comma-separated list of + * roles to add witnesses for, applied in order: {@code payment}, {@code stake}, {@code drep}, + * {@code committee_cold}, {@code committee_hot} (empty/null defaults to {@code payment}). Use + * this when a transaction's certificates need more than the payment key — stake + * registration/deregistration/delegation, reward withdrawal, and DRep/vote operations must also + * be witnessed by the stake or DRep key, or the node rejects them with + * {@code MissingVKeyWitnessesUTXOW}. + * + * @param thread the current isolate thread + * @param mnemonicPtr the BIP-39 mnemonic phrase (UTF-8 C string) + * @param networkId 0=mainnet, 1=testnet, 2=preprod, 3=preview + * @param accountIndex HD account index + * @param addressIndex HD address index + * @param txCborHexPtr the unsigned (or partially signed) transaction as CBOR hex + * @param keysPtr comma-separated signing roles (UTF-8 C string), e.g. {@code "payment,stake"} + * @return {@link ErrorCodes#CCL_SUCCESS}, or an error code + */ + @CEntryPoint(name = "ccl_account_sign_tx_multi") + public static int signTxMulti(IsolateThread thread, CCharPointer mnemonicPtr, + int networkId, int accountIndex, int addressIndex, + CCharPointer txCborHexPtr, CCharPointer keysPtr) { + try { + Network network = NetworkMapper.toNetwork(networkId); + if (network == null) { + ErrorState.set("Invalid network id: " + networkId); + return ErrorCodes.CCL_ERROR_INVALID_NETWORK; + } + String mnemonic = NativeString.toJavaString(mnemonicPtr); + if (mnemonic == null || mnemonic.isEmpty()) { + ErrorState.set("Mnemonic is required"); + return ErrorCodes.CCL_ERROR_INVALID_ARGUMENT; + } + String txCborHex = NativeString.toJavaString(txCborHexPtr); + if (txCborHex == null || txCborHex.isEmpty()) { + ErrorState.set("Transaction CBOR hex is required"); + return ErrorCodes.CCL_ERROR_INVALID_ARGUMENT; + } + String keys = NativeString.toJavaString(keysPtr); + if (keys == null || keys.isBlank()) { + keys = "payment"; + } + + Account account = Account.createFromMnemonic(network, mnemonic, accountIndex, addressIndex); + Transaction tx = Transaction.deserialize(HexUtil.decodeHexString(txCborHex)); + for (String role : keys.split(",")) { + switch (role.trim().toLowerCase()) { + case "payment": tx = account.sign(tx); break; + case "stake": tx = account.signWithStakeKey(tx); break; + case "drep": tx = account.signWithDRepKey(tx); break; + case "committee_cold": tx = account.signWithCommitteeColdKey(tx); break; + case "committee_hot": tx = account.signWithCommitteeHotKey(tx); break; + case "": break; + default: + ErrorState.set("Unknown signing key role: " + role); + return ErrorCodes.CCL_ERROR_INVALID_ARGUMENT; + } + } + ResultState.set(tx.serializeToHex()); + return ErrorCodes.CCL_SUCCESS; + } catch (Exception e) { + ErrorState.set(e.getMessage()); + return ErrorCodes.CCL_ERROR_INVALID_TRANSACTION; + } + } + + /** + * Derives the account's governance DRep ID. + * + *

Exported as {@code ccl_account_get_drep_id}. On success the result is the bech32 DRep ID + * (e.g. {@code drep1...}). Uses address index 0. + * + * @param thread the current isolate thread + * @param mnemonicPtr the BIP-39 mnemonic phrase (UTF-8 C string) + * @param networkId 0=mainnet, 1=testnet, 2=preprod, 3=preview + * @param accountIndex HD account index + * @return {@link ErrorCodes#CCL_SUCCESS}, or {@link ErrorCodes#CCL_ERROR_INVALID_NETWORK} / + * {@link ErrorCodes#CCL_ERROR_GENERAL} + */ @CEntryPoint(name = "ccl_account_get_drep_id") public static int getDrepId(IsolateThread thread, CCharPointer mnemonicPtr, int networkId, int accountIndex) { diff --git a/core/src/main/java/com/bloxbean/cardano/bridge/api/AddressApi.java b/core/src/main/java/com/bloxbean/cardano/bridge/api/AddressApi.java index 46c8b2d..c46b211 100644 --- a/core/src/main/java/com/bloxbean/cardano/bridge/api/AddressApi.java +++ b/core/src/main/java/com/bloxbean/cardano/bridge/api/AddressApi.java @@ -13,10 +13,29 @@ import java.util.LinkedHashMap; import java.util.Map; +/** + * Address entry points: parse, validate, and convert Cardano addresses. + * + *

Operates on bech32 addresses ({@code addr1...}, {@code addr_test1...}, {@code stake1...}) and + * their raw byte (hex) form. See {@link com.bloxbean.cardano.bridge.CclBridge} for the calling + * convention. Every entry point here is a static GraalVM {@code @CEntryPoint}. + */ public final class AddressApi { private AddressApi() {} + /** + * Parses an address into its components. + * + *

Exported as {@code ccl_address_info}. On success the result is a JSON object: + *

{@code {"type","network_id","payment_credential_hash"?,"delegation_credential_hash"?,
+     *  "is_pubkey_payment","is_script_payment"}}
+ * The credential-hash fields are present only when applicable to the address type. + * + * @param thread the current isolate thread + * @param bech32Ptr the bech32 address (UTF-8 C string) + * @return {@link ErrorCodes#CCL_SUCCESS}, or {@link ErrorCodes#CCL_ERROR_INVALID_ADDRESS} + */ @CEntryPoint(name = "ccl_address_info") public static int info(IsolateThread thread, CCharPointer bech32Ptr) { try { @@ -54,6 +73,16 @@ public static int info(IsolateThread thread, CCharPointer bech32Ptr) { } } + /** + * Converts a bech32 address to its raw bytes. + * + *

Exported as {@code ccl_address_to_bytes}. On success the result is the hex-encoded address + * bytes. + * + * @param thread the current isolate thread + * @param bech32Ptr the bech32 address (UTF-8 C string) + * @return {@link ErrorCodes#CCL_SUCCESS}, or {@link ErrorCodes#CCL_ERROR_INVALID_ADDRESS} + */ @CEntryPoint(name = "ccl_address_to_bytes") public static int toBytes(IsolateThread thread, CCharPointer bech32Ptr) { try { @@ -72,6 +101,15 @@ public static int toBytes(IsolateThread thread, CCharPointer bech32Ptr) { } } + /** + * Converts raw address bytes back to a bech32 address. + * + *

Exported as {@code ccl_address_from_bytes}. On success the result is the bech32 address. + * + * @param thread the current isolate thread + * @param hexBytesPtr the hex-encoded address bytes (UTF-8 C string) + * @return {@link ErrorCodes#CCL_SUCCESS}, or {@link ErrorCodes#CCL_ERROR_INVALID_ADDRESS} + */ @CEntryPoint(name = "ccl_address_from_bytes") public static int fromBytes(IsolateThread thread, CCharPointer hexBytesPtr) { try { @@ -91,6 +129,17 @@ public static int fromBytes(IsolateThread thread, CCharPointer hexBytesPtr) { } } + /** + * Validates an address by attempting to parse it. + * + *

Exported as {@code ccl_address_validate}. This reports the result via the status code only + * (no result string): {@link ErrorCodes#CCL_SUCCESS} if valid, + * {@link ErrorCodes#CCL_ERROR_INVALID_ADDRESS} if not. + * + * @param thread the current isolate thread + * @param bech32Ptr the bech32 address to validate (UTF-8 C string) + * @return {@link ErrorCodes#CCL_SUCCESS} (valid) or {@link ErrorCodes#CCL_ERROR_INVALID_ADDRESS} + */ @CEntryPoint(name = "ccl_address_validate") public static int validate(IsolateThread thread, CCharPointer bech32Ptr) { try { diff --git a/core/src/main/java/com/bloxbean/cardano/bridge/api/CryptoApi.java b/core/src/main/java/com/bloxbean/cardano/bridge/api/CryptoApi.java index 70fcc3d..4338034 100644 --- a/core/src/main/java/com/bloxbean/cardano/bridge/api/CryptoApi.java +++ b/core/src/main/java/com/bloxbean/cardano/bridge/api/CryptoApi.java @@ -13,10 +13,27 @@ import org.graalvm.nativeimage.c.function.CEntryPoint; import org.graalvm.nativeimage.c.type.CCharPointer; +/** + * Cryptographic primitives: Blake2b hashing, BIP-39 mnemonics, and Ed25519 sign/verify. + * + *

Hashing and signing take/return hex-encoded bytes. See + * {@link com.bloxbean.cardano.bridge.CclBridge} for the calling convention. Every entry point here + * is a static GraalVM {@code @CEntryPoint}. + */ public final class CryptoApi { private CryptoApi() {} + /** + * Computes a Blake2b-256 hash. + * + *

Exported as {@code ccl_crypto_blake2b_256}. Hex in, hex out; the result is a 32-byte digest + * (64 hex chars). + * + * @param thread the current isolate thread + * @param dataHexPtr the input bytes as hex (UTF-8 C string) + * @return {@link ErrorCodes#CCL_SUCCESS}, or {@link ErrorCodes#CCL_ERROR_CRYPTO} + */ @CEntryPoint(name = "ccl_crypto_blake2b_256") public static int blake2b256(IsolateThread thread, CCharPointer dataHexPtr) { try { @@ -36,6 +53,16 @@ public static int blake2b256(IsolateThread thread, CCharPointer dataHexPtr) { } } + /** + * Computes a Blake2b-224 hash (the size used for Cardano credential/key hashes). + * + *

Exported as {@code ccl_crypto_blake2b_224}. Hex in, hex out; the result is a 28-byte digest + * (56 hex chars). + * + * @param thread the current isolate thread + * @param dataHexPtr the input bytes as hex (UTF-8 C string) + * @return {@link ErrorCodes#CCL_SUCCESS}, or {@link ErrorCodes#CCL_ERROR_CRYPTO} + */ @CEntryPoint(name = "ccl_crypto_blake2b_224") public static int blake2b224(IsolateThread thread, CCharPointer dataHexPtr) { try { @@ -55,6 +82,17 @@ public static int blake2b224(IsolateThread thread, CCharPointer dataHexPtr) { } } + /** + * Generates a new BIP-39 mnemonic. + * + *

Exported as {@code ccl_crypto_generate_mnemonic}. On success the result is the + * space-separated mnemonic phrase. + * + * @param thread the current isolate thread + * @param wordCount number of words: 12, 15, 18, 21, or 24 + * @return {@link ErrorCodes#CCL_SUCCESS}, {@link ErrorCodes#CCL_ERROR_INVALID_ARGUMENT} + * (bad word count), or {@link ErrorCodes#CCL_ERROR_CRYPTO} + */ @CEntryPoint(name = "ccl_crypto_generate_mnemonic") public static int generateMnemonic(IsolateThread thread, int wordCount) { try { @@ -72,6 +110,7 @@ public static int generateMnemonic(IsolateThread thread, int wordCount) { String mnemonic = MnemonicUtil.generateNew(words); ResultState.set(mnemonic); + return ErrorCodes.CCL_SUCCESS; } catch (Exception e) { ErrorState.set(e.getMessage()); @@ -79,6 +118,16 @@ public static int generateMnemonic(IsolateThread thread, int wordCount) { } } + /** + * Validates a BIP-39 mnemonic (word list and checksum). + * + *

Exported as {@code ccl_crypto_validate_mnemonic}. Reported via the status code only (no + * result string). + * + * @param thread the current isolate thread + * @param mnemonicPtr the mnemonic phrase to validate (UTF-8 C string) + * @return {@link ErrorCodes#CCL_SUCCESS} (valid) or {@link ErrorCodes#CCL_ERROR_INVALID_MNEMONIC} + */ @CEntryPoint(name = "ccl_crypto_validate_mnemonic") public static int validateMnemonic(IsolateThread thread, CCharPointer mnemonicPtr) { try { @@ -96,6 +145,18 @@ public static int validateMnemonic(IsolateThread thread, CCharPointer mnemonicPt } } + /** + * Produces an Ed25519 signature. + * + *

Exported as {@code ccl_crypto_sign}. On success the result is the hex-encoded 64-byte + * signature. {@code skHex} must be a raw 32-byte Ed25519 secret key (64 hex chars) — note an + * account's extended private key is 64 bytes, so use its first 32 bytes here. + * + * @param thread the current isolate thread + * @param messageHexPtr the message bytes as hex (UTF-8 C string) + * @param skHexPtr the 32-byte Ed25519 secret key as hex (UTF-8 C string) + * @return {@link ErrorCodes#CCL_SUCCESS}, or {@link ErrorCodes#CCL_ERROR_CRYPTO} + */ @CEntryPoint(name = "ccl_crypto_sign") public static int sign(IsolateThread thread, CCharPointer messageHexPtr, CCharPointer skHexPtr) { try { @@ -122,6 +183,19 @@ public static int sign(IsolateThread thread, CCharPointer messageHexPtr, CCharPo } } + /** + * Verifies an Ed25519 signature. + * + *

Exported as {@code ccl_crypto_verify}. Reported via the status code only: + * {@link ErrorCodes#CCL_SUCCESS} if the signature is valid, + * {@link ErrorCodes#CCL_ERROR_CRYPTO} if it is not. + * + * @param thread the current isolate thread + * @param signatureHexPtr the 64-byte signature as hex (UTF-8 C string) + * @param messageHexPtr the message bytes as hex (UTF-8 C string) + * @param pkHexPtr the 32-byte Ed25519 public key as hex (UTF-8 C string) + * @return {@link ErrorCodes#CCL_SUCCESS} (valid) or {@link ErrorCodes#CCL_ERROR_CRYPTO} + */ @CEntryPoint(name = "ccl_crypto_verify") public static int verify(IsolateThread thread, CCharPointer signatureHexPtr, CCharPointer messageHexPtr, CCharPointer pkHexPtr) { diff --git a/core/src/main/java/com/bloxbean/cardano/bridge/api/GovernanceApi.java b/core/src/main/java/com/bloxbean/cardano/bridge/api/GovernanceApi.java index 09b025f..3b54df6 100644 --- a/core/src/main/java/com/bloxbean/cardano/bridge/api/GovernanceApi.java +++ b/core/src/main/java/com/bloxbean/cardano/bridge/api/GovernanceApi.java @@ -15,10 +15,32 @@ import java.util.LinkedHashMap; import java.util.Map; +/** + * Governance key-derivation entry points (Conway era): DRep and constitutional-committee keys. + * + *

All keys are derived from a mnemonic via CIP-1852/CIP-105 paths. {@code networkId} uses + * {@code 0}=mainnet, {@code 1}=testnet, {@code 2}=preprod, {@code 3}=preview; address index is 0. + * See {@link com.bloxbean.cardano.bridge.CclBridge} for the calling convention. Every entry point + * here is a static GraalVM {@code @CEntryPoint}. + */ public final class GovernanceApi { private GovernanceApi() {} + /** + * Derives the DRep key pair from a mnemonic. + * + *

Exported as {@code ccl_gov_drep_key_from_mnemonic}. On success the result is a JSON object: + *

{@code {"drep_id","verification_key","verification_key_hash",
+     *  "bech32_verification_key","bech32_verification_key_hash"}}
+ * + * @param thread the current isolate thread + * @param mnemonicPtr the BIP-39 mnemonic phrase (UTF-8 C string) + * @param networkId 0=mainnet, 1=testnet, 2=preprod, 3=preview + * @param accountIndex HD account index + * @return {@link ErrorCodes#CCL_SUCCESS}, or {@link ErrorCodes#CCL_ERROR_INVALID_NETWORK} / + * {@link ErrorCodes#CCL_ERROR_GENERAL} + */ @CEntryPoint(name = "ccl_gov_drep_key_from_mnemonic") public static int drepKeyFromMnemonic(IsolateThread thread, CCharPointer mnemonicPtr, int networkId, int accountIndex) { @@ -53,6 +75,21 @@ public static int drepKeyFromMnemonic(IsolateThread thread, CCharPointer mnemoni } } + /** + * Derives the constitutional-committee cold key pair from a mnemonic. + * + *

Exported as {@code ccl_gov_committee_cold_key_from_mnemonic}. On success the result is a + * JSON object: + *

{@code {"id","verification_key","verification_key_hash",
+     *  "bech32_verification_key","bech32_verification_key_hash"}}
+ * + * @param thread the current isolate thread + * @param mnemonicPtr the BIP-39 mnemonic phrase (UTF-8 C string) + * @param networkId 0=mainnet, 1=testnet, 2=preprod, 3=preview + * @param accountIndex HD account index + * @return {@link ErrorCodes#CCL_SUCCESS}, or {@link ErrorCodes#CCL_ERROR_INVALID_NETWORK} / + * {@link ErrorCodes#CCL_ERROR_GENERAL} + */ @CEntryPoint(name = "ccl_gov_committee_cold_key_from_mnemonic") public static int committeeColdKeyFromMnemonic(IsolateThread thread, CCharPointer mnemonicPtr, int networkId, int accountIndex) { @@ -87,6 +124,20 @@ public static int committeeColdKeyFromMnemonic(IsolateThread thread, CCharPointe } } + /** + * Derives the constitutional-committee hot key pair from a mnemonic. + * + *

Exported as {@code ccl_gov_committee_hot_key_from_mnemonic}. On success the result is the + * same JSON shape as {@code ccl_gov_committee_cold_key_from_mnemonic} + * ({@code id}, {@code verification_key}, {@code verification_key_hash}, and the bech32 forms). + * + * @param thread the current isolate thread + * @param mnemonicPtr the BIP-39 mnemonic phrase (UTF-8 C string) + * @param networkId 0=mainnet, 1=testnet, 2=preprod, 3=preview + * @param accountIndex HD account index + * @return {@link ErrorCodes#CCL_SUCCESS}, or {@link ErrorCodes#CCL_ERROR_INVALID_NETWORK} / + * {@link ErrorCodes#CCL_ERROR_GENERAL} + */ @CEntryPoint(name = "ccl_gov_committee_hot_key_from_mnemonic") public static int committeeHotKeyFromMnemonic(IsolateThread thread, CCharPointer mnemonicPtr, int networkId, int accountIndex) { diff --git a/core/src/main/java/com/bloxbean/cardano/bridge/api/PlutusApi.java b/core/src/main/java/com/bloxbean/cardano/bridge/api/PlutusApi.java index 344590c..7d247d0 100644 --- a/core/src/main/java/com/bloxbean/cardano/bridge/api/PlutusApi.java +++ b/core/src/main/java/com/bloxbean/cardano/bridge/api/PlutusApi.java @@ -10,10 +10,27 @@ import org.graalvm.nativeimage.c.function.CEntryPoint; import org.graalvm.nativeimage.c.type.CCharPointer; +/** + * Plutus data (datum/redeemer) entry points: hashing and CBOR↔JSON conversion. + * + *

The JSON form is CCL's PlutusData JSON schema (constructor/fields/bytes/int/list/map). See + * {@link com.bloxbean.cardano.bridge.CclBridge} for the calling convention. Every entry point here + * is a static GraalVM {@code @CEntryPoint}. + */ public final class PlutusApi { private PlutusApi() {} + /** + * Computes the datum hash of a PlutusData value. + * + *

Exported as {@code ccl_plutus_data_hash}. On success the result is the datum hash as hex + * (the value you place in a transaction output's datum-hash field). + * + * @param thread the current isolate thread + * @param datumCborHexPtr the PlutusData as CBOR hex + * @return {@link ErrorCodes#CCL_SUCCESS}, or {@link ErrorCodes#CCL_ERROR_SERIALIZATION} + */ @CEntryPoint(name = "ccl_plutus_data_hash") public static int datumHash(IsolateThread thread, CCharPointer datumCborHexPtr) { try { @@ -27,6 +44,7 @@ public static int datumHash(IsolateThread thread, CCharPointer datumCborHexPtr) PlutusData plutusData = PlutusData.deserialize(datumBytes); String hash = plutusData.getDatumHash(); ResultState.set(hash); + return ErrorCodes.CCL_SUCCESS; } catch (Exception e) { ErrorState.set(e.getMessage()); @@ -34,6 +52,15 @@ public static int datumHash(IsolateThread thread, CCharPointer datumCborHexPtr) } } + /** + * Converts PlutusData CBOR to its JSON representation. + * + *

Exported as {@code ccl_plutus_data_to_json}. On success the result is the PlutusData as JSON. + * + * @param thread the current isolate thread + * @param cborHexPtr the PlutusData as CBOR hex + * @return {@link ErrorCodes#CCL_SUCCESS}, or {@link ErrorCodes#CCL_ERROR_SERIALIZATION} + */ @CEntryPoint(name = "ccl_plutus_data_to_json") public static int toJson(IsolateThread thread, CCharPointer cborHexPtr) { try { @@ -54,6 +81,16 @@ public static int toJson(IsolateThread thread, CCharPointer cborHexPtr) { } } + /** + * Builds PlutusData CBOR from its JSON representation. + * + *

Exported as {@code ccl_plutus_data_from_json}. The inverse of + * {@code ccl_plutus_data_to_json}: on success the result is the PlutusData as CBOR hex. + * + * @param thread the current isolate thread + * @param jsonPtr the PlutusData as JSON (UTF-8 C string) + * @return {@link ErrorCodes#CCL_SUCCESS}, or {@link ErrorCodes#CCL_ERROR_SERIALIZATION} + */ @CEntryPoint(name = "ccl_plutus_data_from_json") public static int fromJson(IsolateThread thread, CCharPointer jsonPtr) { try { diff --git a/core/src/main/java/com/bloxbean/cardano/bridge/api/QuickTxApi.java b/core/src/main/java/com/bloxbean/cardano/bridge/api/QuickTxApi.java index 2fc8463..33c9491 100644 --- a/core/src/main/java/com/bloxbean/cardano/bridge/api/QuickTxApi.java +++ b/core/src/main/java/com/bloxbean/cardano/bridge/api/QuickTxApi.java @@ -9,22 +9,70 @@ import org.graalvm.nativeimage.c.function.CEntryPoint; import org.graalvm.nativeimage.c.type.CCharPointer; +/** + * QuickTx entry point: build an unsigned transaction from a CCL TxPlan (YAML), fully offline. + * + *

The transaction is defined by a TxPlan YAML document + * (CCL's native transaction format). The caller also supplies the chain data the build needs — + * available UTXOs and protocol parameters — as JSON. Nothing is fetched and nothing is submitted; + * the result is the unsigned transaction CBOR. + * + *

See {@link com.bloxbean.cardano.bridge.CclBridge} for the calling convention. The single entry + * point here is a static GraalVM {@code @CEntryPoint}. + */ public final class QuickTxApi { private static final QuickTxService service = new QuickTxService(); private QuickTxApi() {} + /** + * Builds an unsigned transaction from a TxPlan YAML document and caller-supplied chain data. + * + *

Exported as {@code ccl_quicktx_build}. {@code yaml} is the TxPlan transaction definition; + * {@code utxos_json} is a JSON array of the sender's UTXOs and {@code protocol_params_json} a + * JSON protocol-parameters object (both standard CCL data models). On success the result is a + * JSON object: + *

{@code {"tx_cbor","tx_hash","fee"}}
+ * where {@code tx_cbor} is the unsigned transaction; sign it with {@code ccl_account_sign_tx} / + * {@code ccl_tx_sign_with_secret_key} and submit it yourself. + * + *

For Plutus script transactions, pass the redeemers' execution units in + * {@code exec_units_json} (a JSON array of {@code {"mem","steps"}}, one per redeemer in + * transaction order). The caller computes these with any UPLC evaluator (Ogmios, Blockfrost, + * Aiken, Scalus) and passes them in, just like UTXOs and protocol parameters. Pass {@code null} + * (or an empty array) for non-script transactions. A script transaction with no execution units + * fails with {@link ErrorCodes#CCL_ERROR_TX_BUILD}. + * + * @param thread the current isolate thread + * @param yamlPtr the TxPlan YAML (UTF-8 C string) + * @param utxosJsonPtr JSON array of UTXOs (UTF-8 C string) + * @param protocolParamsJsonPtr JSON protocol parameters (UTF-8 C string) + * @param execUnitsJsonPtr JSON array of redeemer execution units, or null (UTF-8 C string) + * @return {@link ErrorCodes#CCL_SUCCESS}; on failure + * {@link ErrorCodes#CCL_ERROR_INVALID_ARGUMENT}, + * {@link ErrorCodes#CCL_ERROR_INSUFFICIENT_FUNDS}, or + * {@link ErrorCodes#CCL_ERROR_TX_BUILD} + */ @CEntryPoint(name = "ccl_quicktx_build") - public static int build(IsolateThread thread, CCharPointer specJsonPtr) { + public static int build(IsolateThread thread, CCharPointer yamlPtr, + CCharPointer utxosJsonPtr, CCharPointer protocolParamsJsonPtr, + CCharPointer execUnitsJsonPtr) { try { - String specJson = NativeString.toJavaString(specJsonPtr); - if (specJson == null || specJson.isEmpty()) { - ErrorState.set("Transaction spec JSON is required"); + String yaml = NativeString.toJavaString(yamlPtr); + if (yaml == null || yaml.isEmpty()) { + ErrorState.set("TxPlan YAML is required"); return ErrorCodes.CCL_ERROR_INVALID_ARGUMENT; } + String utxosJson = NativeString.toJavaString(utxosJsonPtr); + String protocolParamsJson = NativeString.toJavaString(protocolParamsJsonPtr); + if (protocolParamsJson == null || protocolParamsJson.isEmpty()) { + ErrorState.set("Protocol parameters JSON is required"); + return ErrorCodes.CCL_ERROR_INVALID_ARGUMENT; + } + String execUnitsJson = NativeString.toJavaString(execUnitsJsonPtr); - String resultJson = service.buildTransaction(specJson); + String resultJson = service.buildTransaction(yaml, utxosJson, protocolParamsJson, execUnitsJson); ResultState.set(resultJson); return ErrorCodes.CCL_SUCCESS; } catch (IllegalArgumentException e) { @@ -36,7 +84,14 @@ public static int build(IsolateThread thread, CCharPointer specJsonPtr) { ErrorState.set(msg); return ErrorCodes.CCL_ERROR_INSUFFICIENT_FUNDS; } - ErrorState.set(msg != null ? msg : e.getClass().getName()); + // Include the root cause — wrapped exceptions (e.g. YAML deserialization) otherwise + // hide the actual problem behind a generic message. + StringBuilder detail = new StringBuilder(msg != null ? msg : e.getClass().getName()); + for (Throwable c = e.getCause(); c != null && c != c.getCause(); c = c.getCause()) { + detail.append(" | ").append(c.getClass().getSimpleName()) + .append(": ").append(c.getMessage()); + } + ErrorState.set(detail.toString()); return ErrorCodes.CCL_ERROR_TX_BUILD; } } diff --git a/core/src/main/java/com/bloxbean/cardano/bridge/api/ScriptApi.java b/core/src/main/java/com/bloxbean/cardano/bridge/api/ScriptApi.java index 6f59ce6..6147532 100644 --- a/core/src/main/java/com/bloxbean/cardano/bridge/api/ScriptApi.java +++ b/core/src/main/java/com/bloxbean/cardano/bridge/api/ScriptApi.java @@ -14,10 +14,28 @@ import java.util.LinkedHashMap; import java.util.Map; +/** + * Script entry points: parse native scripts and compute script hashes. + * + *

See {@link com.bloxbean.cardano.bridge.CclBridge} for the calling convention. Every entry + * point here is a static GraalVM {@code @CEntryPoint}. + */ public final class ScriptApi { private ScriptApi() {} + /** + * Parses a native script from its JSON form. + * + *

Exported as {@code ccl_script_native_from_json}. Accepts the standard native-script JSON + * (e.g. {@code {"type":"sig","keyHash":"..."}}, {@code all}/{@code any}/{@code atLeast}, + * {@code before}/{@code after}). On success the result is a JSON object: + *

{@code {"policy_id","script_hash","cbor_hex"}}
+ * + * @param thread the current isolate thread + * @param jsonPtr the native script as JSON (UTF-8 C string) + * @return {@link ErrorCodes#CCL_SUCCESS}, or {@link ErrorCodes#CCL_ERROR_SERIALIZATION} + */ @CEntryPoint(name = "ccl_script_native_from_json") public static int nativeScriptFromJson(IsolateThread thread, CCharPointer jsonPtr) { try { @@ -42,6 +60,18 @@ public static int nativeScriptFromJson(IsolateThread thread, CCharPointer jsonPt } } + /** + * Computes a script hash (policy id) from a script's CBOR. + * + *

Exported as {@code ccl_script_hash}. The hash is Blake2b-224 of {@code (typeByte || scriptBytes)}, + * where {@code scriptType} is the language tag: {@code 0}=native, {@code 1}=Plutus V1, + * {@code 2}=Plutus V2, {@code 3}=Plutus V3. On success the result is the 28-byte hash as hex. + * + * @param thread the current isolate thread + * @param scriptCborHexPtr the script as CBOR hex + * @param scriptType language tag (0=native, 1=PlutusV1, 2=PlutusV2, 3=PlutusV3) + * @return {@link ErrorCodes#CCL_SUCCESS}, or {@link ErrorCodes#CCL_ERROR_SERIALIZATION} + */ @CEntryPoint(name = "ccl_script_hash") public static int scriptHash(IsolateThread thread, CCharPointer scriptCborHexPtr, int scriptType) { try { diff --git a/core/src/main/java/com/bloxbean/cardano/bridge/api/TransactionApi.java b/core/src/main/java/com/bloxbean/cardano/bridge/api/TransactionApi.java index 0360a40..0840661 100644 --- a/core/src/main/java/com/bloxbean/cardano/bridge/api/TransactionApi.java +++ b/core/src/main/java/com/bloxbean/cardano/bridge/api/TransactionApi.java @@ -14,10 +14,30 @@ import org.graalvm.nativeimage.c.function.CEntryPoint; import org.graalvm.nativeimage.c.type.CCharPointer; +/** + * Transaction entry points: hash, sign (with a raw secret key), and CBOR↔JSON conversion. + * + *

Transactions are passed as CBOR hex (the on-chain serialization). The JSON form is a readable + * representation for inspection, not the on-chain format. See + * {@link com.bloxbean.cardano.bridge.CclBridge} for the calling convention. Every entry point here + * is a static GraalVM {@code @CEntryPoint}. + */ public final class TransactionApi { private TransactionApi() {} + /** + * Signs a transaction with a raw Ed25519 secret key. + * + *

Exported as {@code ccl_tx_sign_with_secret_key}. On success the result is the signed + * transaction as CBOR hex. Use this when you hold a key directly rather than a mnemonic + * (cf. {@code ccl_account_sign_tx}). + * + * @param thread the current isolate thread + * @param txCborHexPtr the transaction as CBOR hex + * @param skCborHexPtr the secret key as CBOR hex + * @return {@link ErrorCodes#CCL_SUCCESS}, or {@link ErrorCodes#CCL_ERROR_INVALID_TRANSACTION} + */ @CEntryPoint(name = "ccl_tx_sign_with_secret_key") public static int signWithSecretKey(IsolateThread thread, CCharPointer txCborHexPtr, CCharPointer skCborHexPtr) { @@ -45,6 +65,16 @@ public static int signWithSecretKey(IsolateThread thread, CCharPointer txCborHex } } + /** + * Computes the transaction hash (Blake2b-256 of the transaction body). + * + *

Exported as {@code ccl_tx_hash}. On success the result is the 32-byte hash as hex (64 hex + * chars) — i.e. the transaction id. + * + * @param thread the current isolate thread + * @param txCborHexPtr the transaction as CBOR hex + * @return {@link ErrorCodes#CCL_SUCCESS}, or {@link ErrorCodes#CCL_ERROR_INVALID_TRANSACTION} + */ @CEntryPoint(name = "ccl_tx_hash") public static int hash(IsolateThread thread, CCharPointer txCborHexPtr) { try { @@ -65,6 +95,16 @@ public static int hash(IsolateThread thread, CCharPointer txCborHexPtr) { } } + /** + * Converts a transaction's CBOR to a readable JSON representation. + * + *

Exported as {@code ccl_tx_to_json}. On success the result is the transaction as JSON (for + * inspection — this is not the on-chain format). Equivalent to {@code ccl_tx_deserialize}. + * + * @param thread the current isolate thread + * @param txCborHexPtr the transaction as CBOR hex + * @return {@link ErrorCodes#CCL_SUCCESS}, or {@link ErrorCodes#CCL_ERROR_SERIALIZATION} + */ @CEntryPoint(name = "ccl_tx_to_json") public static int toJson(IsolateThread thread, CCharPointer txCborHexPtr) { try { @@ -84,6 +124,16 @@ public static int toJson(IsolateThread thread, CCharPointer txCborHexPtr) { } } + /** + * Builds a transaction's CBOR from its JSON representation. + * + *

Exported as {@code ccl_tx_from_json}. The inverse of {@code ccl_tx_to_json}: on success the + * result is the transaction as CBOR hex. + * + * @param thread the current isolate thread + * @param txJsonPtr the transaction as JSON (UTF-8 C string) + * @return {@link ErrorCodes#CCL_SUCCESS}, or {@link ErrorCodes#CCL_ERROR_SERIALIZATION} + */ @CEntryPoint(name = "ccl_tx_from_json") public static int fromJson(IsolateThread thread, CCharPointer txJsonPtr) { try { @@ -102,6 +152,16 @@ public static int fromJson(IsolateThread thread, CCharPointer txJsonPtr) { } } + /** + * Deserializes a transaction's CBOR into its JSON representation. + * + *

Exported as {@code ccl_tx_deserialize}. On success the result is the transaction as JSON + * (for inspection). Same output as {@code ccl_tx_to_json}. + * + * @param thread the current isolate thread + * @param txCborHexPtr the transaction as CBOR hex + * @return {@link ErrorCodes#CCL_SUCCESS}, or {@link ErrorCodes#CCL_ERROR_SERIALIZATION} + */ @CEntryPoint(name = "ccl_tx_deserialize") public static int deserialize(IsolateThread thread, CCharPointer txCborHexPtr) { try { diff --git a/core/src/main/java/com/bloxbean/cardano/bridge/api/WalletApi.java b/core/src/main/java/com/bloxbean/cardano/bridge/api/WalletApi.java index 4bb6e41..89047c0 100644 --- a/core/src/main/java/com/bloxbean/cardano/bridge/api/WalletApi.java +++ b/core/src/main/java/com/bloxbean/cardano/bridge/api/WalletApi.java @@ -13,12 +13,31 @@ import java.util.List; import java.util.Map; +/** + * HD wallet entry points: create/restore a wallet and derive its addresses. + * + *

Unlike {@link AccountApi} (a single account), a {@code Wallet} exposes a sequence of addresses. + * {@code networkId} uses {@code 0}=mainnet, {@code 1}=testnet, {@code 2}=preprod, {@code 3}=preview. + * See {@link com.bloxbean.cardano.bridge.CclBridge} for the calling convention. Every entry point + * here is a static GraalVM {@code @CEntryPoint}. + */ public final class WalletApi { private static final int DEFAULT_ADDRESS_COUNT = 5; private WalletApi() {} + /** + * Creates a new HD wallet with a freshly generated mnemonic. + * + *

Exported as {@code ccl_wallet_create}. On success the result is a JSON object: + *

{@code {"mnemonic","stake_address","addresses":[ ...first 5 base addresses... ]}}
+ * + * @param thread the current isolate thread + * @param networkId 0=mainnet, 1=testnet, 2=preprod, 3=preview + * @return {@link ErrorCodes#CCL_SUCCESS}, or {@link ErrorCodes#CCL_ERROR_INVALID_NETWORK} / + * {@link ErrorCodes#CCL_ERROR_GENERAL} + */ @CEntryPoint(name = "ccl_wallet_create") public static int create(IsolateThread thread, int networkId) { try { @@ -38,6 +57,18 @@ public static int create(IsolateThread thread, int networkId) { } } + /** + * Restores an HD wallet from an existing mnemonic. + * + *

Exported as {@code ccl_wallet_from_mnemonic}. On success the result is the same JSON object + * as {@code ccl_wallet_create}. + * + * @param thread the current isolate thread + * @param mnemonicPtr the BIP-39 mnemonic phrase (UTF-8 C string) + * @param networkId 0=mainnet, 1=testnet, 2=preprod, 3=preview + * @return {@link ErrorCodes#CCL_SUCCESS}, or {@link ErrorCodes#CCL_ERROR_INVALID_NETWORK} / + * {@link ErrorCodes#CCL_ERROR_GENERAL} + */ @CEntryPoint(name = "ccl_wallet_from_mnemonic") public static int fromMnemonic(IsolateThread thread, CCharPointer mnemonicPtr, int networkId) { try { @@ -63,6 +94,19 @@ public static int fromMnemonic(IsolateThread thread, CCharPointer mnemonicPtr, i } } + /** + * Derives a single base address at the given index from a wallet's mnemonic. + * + *

Exported as {@code ccl_wallet_get_address}. On success the result is the bech32 base address + * at {@code index}. + * + * @param thread the current isolate thread + * @param mnemonicPtr the BIP-39 mnemonic phrase (UTF-8 C string) + * @param networkId 0=mainnet, 1=testnet, 2=preprod, 3=preview + * @param index the address index to derive + * @return {@link ErrorCodes#CCL_SUCCESS}, or {@link ErrorCodes#CCL_ERROR_INVALID_NETWORK} / + * {@link ErrorCodes#CCL_ERROR_GENERAL} + */ @CEntryPoint(name = "ccl_wallet_get_address") public static int getAddress(IsolateThread thread, CCharPointer mnemonicPtr, int networkId, int index) { diff --git a/core/src/main/java/com/bloxbean/cardano/bridge/api/quicktx/ProviderConfig.java b/core/src/main/java/com/bloxbean/cardano/bridge/api/quicktx/ProviderConfig.java deleted file mode 100644 index a18997d..0000000 --- a/core/src/main/java/com/bloxbean/cardano/bridge/api/quicktx/ProviderConfig.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.bloxbean.cardano.bridge.api.quicktx; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.annotation.JsonProperty; - -@JsonIgnoreProperties(ignoreUnknown = true) -public class ProviderConfig { - - @JsonProperty("name") - private String name; - - @JsonProperty("url") - private String url; - - @JsonProperty("api_key") - private String apiKey; - - @JsonProperty("enable_cost_evaluation") - private Boolean enableCostEvaluation; - - public String getName() { return name; } - public void setName(String name) { this.name = name; } - - public String getUrl() { return url; } - public void setUrl(String url) { this.url = url; } - - public String getApiKey() { return apiKey; } - public void setApiKey(String apiKey) { this.apiKey = apiKey; } - - public Boolean getEnableCostEvaluation() { return enableCostEvaluation; } - public void setEnableCostEvaluation(Boolean enableCostEvaluation) { this.enableCostEvaluation = enableCostEvaluation; } - - public void validate() { - if (name == null || name.isEmpty()) - throw new IllegalArgumentException("provider 'name' is required"); - if (url == null || url.isEmpty()) - throw new IllegalArgumentException("provider 'url' is required"); - if (!"yaci".equals(name)) - throw new IllegalArgumentException("Unsupported provider: " + name + ". Supported: yaci"); - } -} diff --git a/core/src/main/java/com/bloxbean/cardano/bridge/api/quicktx/QuickTxService.java b/core/src/main/java/com/bloxbean/cardano/bridge/api/quicktx/QuickTxService.java index ffd0d31..d26c4e7 100644 --- a/core/src/main/java/com/bloxbean/cardano/bridge/api/quicktx/QuickTxService.java +++ b/core/src/main/java/com/bloxbean/cardano/bridge/api/quicktx/QuickTxService.java @@ -2,145 +2,104 @@ import com.bloxbean.cardano.bridge.util.JsonHelper; import com.bloxbean.cardano.client.api.ProtocolParamsSupplier; -import com.bloxbean.cardano.client.api.TransactionEvaluator; import com.bloxbean.cardano.client.api.UtxoSupplier; +import com.bloxbean.cardano.client.api.impl.StaticTransactionEvaluator; +import com.bloxbean.cardano.client.api.model.ProtocolParams; +import com.bloxbean.cardano.client.api.model.Utxo; import com.bloxbean.cardano.client.common.cbor.CborSerializationUtil; import com.bloxbean.cardano.client.crypto.Blake2bUtil; -import com.bloxbean.cardano.client.quicktx.AbstractTx; +import com.bloxbean.cardano.client.plutus.spec.ExUnits; import com.bloxbean.cardano.client.quicktx.QuickTxBuilder; +import com.bloxbean.cardano.client.quicktx.serialization.TxPlan; +import com.bloxbean.cardano.client.quicktx.serialization.YamlSerializer; import com.bloxbean.cardano.client.transaction.spec.Transaction; import com.bloxbean.cardano.client.util.HexUtil; +import java.util.Arrays; +import java.util.Collections; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; /** - * Service that builds unsigned Cardano transactions from a JSON spec - * using QuickTxBuilder for automatic coin selection, fee calculation, - * and change balancing. + * Builds unsigned Cardano transactions from a CCL {@link TxPlan} (YAML), fully offline. + * + *

The transaction is defined by a TxPlan YAML document; the caller supplies the chain data + * (UTXOs and protocol parameters) as JSON. No backend/provider is used and the transaction is never + * submitted — the result is the unsigned CBOR plus its hash and fee. + * + *

Plutus script transactions are supported when the caller supplies the redeemers' execution + * units (memory + CPU steps). Computing those units requires running the script in a UPLC + * evaluator, which the caller does out-of-band (Ogmios, Blockfrost, Aiken, Scalus, …) and passes + * in — exactly as it supplies UTXOs and protocol parameters. With units supplied, a + * {@link StaticTransactionEvaluator} stamps them onto the redeemers, fully offline. A script + * transaction built without execution units fails (no offline evaluator runs the script). */ public class QuickTxService { /** - * Build an unsigned transaction from a JSON spec string. + * Build an unsigned transaction from a TxPlan YAML document and caller-supplied chain data. * - * @param specJson JSON transaction specification - * @return JSON string with tx_cbor, tx_hash, fee + * @param yaml the TxPlan YAML defining the transaction(s) + * @param utxosJson JSON array of UTXOs available to the sender (CCL {@code Utxo} model) + * @param protocolParamsJson JSON protocol parameters (CCL {@code ProtocolParams} model) + * @param execUnitsJson JSON array of redeemer execution units ({@code [{"mem","steps"}]}, + * one per redeemer in transaction order); null/empty for non-script txs + * @return JSON string with {@code tx_cbor}, {@code tx_hash}, {@code fee} */ - public String buildTransaction(String specJson) throws Exception { - // Parse spec - TxSpec spec = JsonHelper.fromJson(specJson, TxSpec.class); - spec.validate(); + public String buildTransaction(String yaml, String utxosJson, String protocolParamsJson, + String execUnitsJson) throws Exception { + TxPlan plan = TxPlan.from(yaml); - // Create suppliers — provider mode uses HTTP, static mode uses inline data - UtxoSupplier utxoSupplier; - ProtocolParamsSupplier ppSupplier; + List utxos = parseUtxos(utxosJson); + ProtocolParams protocolParams = JsonHelper.fromJson(protocolParamsJson, ProtocolParams.class); - if (spec.getProvider() != null) { - String providerUrl = spec.getProvider().getUrl(); - utxoSupplier = new YaciUtxoSupplier(providerUrl); - ppSupplier = spec.getProtocolParams() != null - ? () -> spec.getProtocolParams() - : new YaciProtocolParamsSupplier(providerUrl); - } else { - utxoSupplier = new StaticUtxoSupplier(spec.getUtxos()); - ppSupplier = () -> spec.getProtocolParams(); - } - - // Create evaluator for script cost evaluation if provider is configured - TransactionEvaluator evaluator = null; - if (spec.getProvider() != null) { - boolean enableEval = spec.getProvider().getEnableCostEvaluation() == null - || spec.getProvider().getEnableCostEvaluation(); - if (enableEval) { - evaluator = new YaciTransactionEvaluator(spec.getProvider().getUrl()); - } - } + UtxoSupplier utxoSupplier = new StaticUtxoSupplier(utxos); + ProtocolParamsSupplier ppSupplier = () -> protocolParams; - // Build with QuickTxBuilder + // No TransactionProcessor (offline; never submits). compose(plan) applies the plan's + // context (fee payer, validity, deposit mode, required signers, …) to the TxContext. QuickTxBuilder builder = new QuickTxBuilder(utxoSupplier, ppSupplier, null); - - // Detect compose vs single mode - QuickTxBuilder.TxContext txContext; - if (spec.getTransactions() != null && !spec.getTransactions().isEmpty()) { - // Compose mode — each item can be Tx or ScriptTx - AbstractTx[] txs = spec.getTransactions().stream() - .map(item -> { - if ("script_tx".equals(item.getTxType())) { - return (AbstractTx) ScriptTxSpecMapper.toScriptTx(item); - } else { - return (AbstractTx) TxSpecMapper.toTx(item); - } - }) - .toArray(AbstractTx[]::new); - txContext = builder.compose(txs); - } else { - // Single mode - if ("script_tx".equals(spec.getTxType())) { - txContext = builder.compose(ScriptTxSpecMapper.toScriptTx(spec)); - } else { - txContext = builder.compose(TxSpecMapper.toTx(spec)); - } - } - - // Set additional signers count for fee estimation - int signerCount; - if (spec.getSignerCount() != null) { - signerCount = spec.getSignerCount(); - } else if (spec.getTransactions() != null && !spec.getTransactions().isEmpty()) { - signerCount = spec.getTransactions().size(); - } else { - signerCount = 1; + QuickTxBuilder.TxContext txContext = builder.compose(plan); + + // Plutus script cost: when the caller supplies execution units, a static evaluator stamps + // them onto the redeemers (offline). The caller computes them however it likes (Ogmios, + // Blockfrost, Aiken, Scalus); the bridge does not run the script. + List execUnits = parseExUnits(execUnitsJson); + if (!execUnits.isEmpty()) { + txContext.withTxEvaluator(new StaticTransactionEvaluator(execUnits)); } - txContext.additionalSignersCount(signerCount); - // Set validity interval - if (spec.getValidity() != null) { - if (spec.getValidity().getValidFrom() != null) { - txContext.validFrom(spec.getValidity().getValidFrom()); - } - if (spec.getValidity().getValidTo() != null) { - txContext.validTo(spec.getValidity().getValidTo()); - } - } + // Budget witnesses for fee estimation of the (still unsigned) transaction. + txContext.additionalSignersCount(Math.max(1, plan.getTxs().size())); - // Set merge outputs - if (spec.getMergeOutputs() != null) { - txContext.mergeOutputs(spec.getMergeOutputs()); - } - - // Set fee payer - // For script_tx mode, feePayer is required since ScriptTx.from() is package-private. - // TxContext.feePayer() sets both the feePayer and calls ScriptTx.from() internally. - if (spec.getFeePayer() != null && !spec.getFeePayer().isEmpty()) { - txContext.feePayer(spec.getFeePayer()); - } else if ("script_tx".equals(spec.getTxType()) - && spec.getFrom() != null && !spec.getFrom().isEmpty()) { - txContext.feePayer(spec.getFrom()); - } - - // Set transaction evaluator for script cost evaluation - if (evaluator != null) { - txContext.withTxEvaluator(evaluator); - } - - // Build the transaction Transaction transaction = txContext.build(); - // Serialize and compute hash String txCborHex = transaction.serializeToHex(); byte[] txBodyBytes = CborSerializationUtil.serialize(transaction.getBody().serialize()); String txHash = HexUtil.encodeHexString(Blake2bUtil.blake2bHash256(txBodyBytes)); - - // Get fee from the built transaction String fee = transaction.getBody().getFee().toString(); - // Build result Map result = new LinkedHashMap<>(); result.put("tx_cbor", txCborHex); result.put("tx_hash", txHash); result.put("fee", fee); + return YamlSerializer.serialize(result); + } - return JsonHelper.toJson(result); + private static List parseUtxos(String utxosJson) throws Exception { + if (utxosJson == null || utxosJson.isBlank()) { + return Collections.emptyList(); + } + Utxo[] utxos = JsonHelper.fromJson(utxosJson, Utxo[].class); + return utxos != null ? Arrays.asList(utxos) : Collections.emptyList(); + } + + private static List parseExUnits(String execUnitsJson) throws Exception { + if (execUnitsJson == null || execUnitsJson.isBlank()) { + return Collections.emptyList(); + } + ExUnits[] exUnits = JsonHelper.fromJson(execUnitsJson, ExUnits[].class); + return exUnits != null ? Arrays.asList(exUnits) : Collections.emptyList(); } } diff --git a/core/src/main/java/com/bloxbean/cardano/bridge/api/quicktx/ScriptTxSpecMapper.java b/core/src/main/java/com/bloxbean/cardano/bridge/api/quicktx/ScriptTxSpecMapper.java deleted file mode 100644 index be1cbb3..0000000 --- a/core/src/main/java/com/bloxbean/cardano/bridge/api/quicktx/ScriptTxSpecMapper.java +++ /dev/null @@ -1,736 +0,0 @@ -package com.bloxbean.cardano.bridge.api.quicktx; - -import com.bloxbean.cardano.client.address.Address; -import com.bloxbean.cardano.client.address.Credential; -import com.bloxbean.cardano.client.api.model.Utxo; -import com.bloxbean.cardano.client.metadata.cbor.CBORMetadata; -import com.bloxbean.cardano.client.metadata.cbor.CBORMetadataList; -import com.bloxbean.cardano.client.metadata.cbor.CBORMetadataMap; -import com.bloxbean.cardano.client.plutus.spec.PlutusData; -import com.bloxbean.cardano.client.plutus.spec.PlutusScript; -import com.bloxbean.cardano.client.plutus.spec.PlutusV1Script; -import com.bloxbean.cardano.client.plutus.spec.PlutusV2Script; -import com.bloxbean.cardano.client.plutus.spec.PlutusV3Script; -import com.bloxbean.cardano.client.quicktx.ScriptTx; -import com.bloxbean.cardano.client.spec.UnitInterval; -import com.bloxbean.cardano.client.transaction.spec.Asset; -import com.bloxbean.cardano.client.transaction.spec.ProtocolVersion; -import com.bloxbean.cardano.client.transaction.spec.Withdrawal; -import com.bloxbean.cardano.client.transaction.spec.governance.Anchor; -import com.bloxbean.cardano.client.transaction.spec.governance.Constitution; -import com.bloxbean.cardano.client.transaction.spec.governance.DRep; -import com.bloxbean.cardano.client.transaction.spec.governance.Vote; -import com.bloxbean.cardano.client.transaction.spec.governance.Voter; -import com.bloxbean.cardano.client.transaction.spec.governance.VoterType; -import com.bloxbean.cardano.client.transaction.spec.governance.actions.*; -import com.bloxbean.cardano.client.spec.Script; -import com.bloxbean.cardano.client.transaction.spec.script.NativeScript; -import com.bloxbean.cardano.client.util.HexUtil; - -import java.math.BigInteger; -import java.util.*; - -/** - * Maps a TxSpec/TxItemSpec (parsed from JSON) into a CCL ScriptTx object. - */ -public class ScriptTxSpecMapper { - - public static ScriptTx toScriptTx(TxSpec spec) { - ScriptTx tx = new ScriptTx(); - // Note: ScriptTx.from() is package-private, so 'from' is set by TxContext via feePayer - if (spec.getChangeAddress() != null && !spec.getChangeAddress().isEmpty()) { - applyChangeAddress(tx, spec.getChangeAddress(), spec.getChangeDatumCborHex(), spec.getChangeDatumHash()); - } - applyOperations(tx, spec.getOperations()); - return tx; - } - - public static ScriptTx toScriptTx(TxItemSpec item) { - ScriptTx tx = new ScriptTx(); - // Note: ScriptTx.from() is package-private, so 'from' is set by TxContext via feePayer - if (item.getChangeAddress() != null && !item.getChangeAddress().isEmpty()) { - applyChangeAddress(tx, item.getChangeAddress(), item.getChangeDatumCborHex(), item.getChangeDatumHash()); - } - applyOperations(tx, item.getOperations()); - return tx; - } - - private static void applyChangeAddress(ScriptTx tx, String changeAddress, String datumCborHex, String datumHash) { - if (datumCborHex != null && !datumCborHex.isEmpty()) { - PlutusData datum = parseRedeemer(datumCborHex); - tx.withChangeAddress(changeAddress, datum); - } else if (datumHash != null && !datumHash.isEmpty()) { - tx.withChangeAddress(changeAddress, datumHash); - } else { - tx.withChangeAddress(changeAddress); - } - } - - private static void applyOperations(ScriptTx tx, List operations) { - for (TxOperation op : operations) { - switch (op.getType()) { - case "pay_to_address": - applyPayToAddress(tx, op); - break; - case "pay_to_contract": - applyPayToContract(tx, op); - break; - case "attach_metadata": - applyAttachMetadata(tx, op); - break; - case "collect_from": - applyCollectFrom(tx, op); - break; - case "read_from": - applyReadFrom(tx, op); - break; - case "mint_plutus_assets": - applyMintPlutusAssets(tx, op); - break; - case "attach_spending_validator": - applyAttachValidator(tx, op, "spending"); - break; - case "attach_certificate_validator": - applyAttachValidator(tx, op, "certificate"); - break; - case "attach_reward_validator": - applyAttachValidator(tx, op, "reward"); - break; - case "attach_proposing_validator": - applyAttachValidator(tx, op, "proposing"); - break; - case "attach_voting_validator": - applyAttachValidator(tx, op, "voting"); - break; - // Staking (register_stake_address not available in ScriptTx) - case "register_stake_address": - throw new IllegalArgumentException( - "register_stake_address is not supported in script_tx mode. Use regular tx mode."); - case "deregister_stake_address": - applyDeregisterStakeAddress(tx, op); - break; - case "delegate_to": - applyDelegateTo(tx, op); - break; - case "withdraw": - applyWithdraw(tx, op); - break; - // DRep - case "register_drep": - applyRegisterDRep(tx, op); - break; - case "unregister_drep": - applyUnregisterDRep(tx, op); - break; - case "update_drep": - applyUpdateDRep(tx, op); - break; - // Voting - case "delegate_voting_power_to": - applyDelegateVotingPowerTo(tx, op); - break; - case "create_vote": - applyCreateVote(tx, op); - break; - // Governance - case "create_proposal": - applyCreateProposal(tx, op); - break; - // Treasury donation (Gap 4) - case "donate_to_treasury": - applyDonateToTreasury(tx, op); - break; - case "mint_assets": - throw new IllegalArgumentException( - "Use 'mint_plutus_assets' instead of 'mint_assets' in script_tx mode"); - default: - throw new IllegalArgumentException("Unknown operation type: " + op.getType()); - } - } - } - - private static void applyPayToAddress(ScriptTx tx, TxOperation op) { - if (op.getAddress() == null) { - throw new IllegalArgumentException("pay_to_address requires 'address'"); - } - if (op.getAmounts() == null || op.getAmounts().isEmpty()) { - throw new IllegalArgumentException("pay_to_address requires 'amounts'"); - } - if (op.getScriptRefCborHex() != null && !op.getScriptRefCborHex().isEmpty()) { - Script refScript = parseScriptRef(op.getScriptRefCborHex(), op.getScriptRefType()); - tx.payToAddress(op.getAddress(), op.getAmounts(), refScript); - } else { - tx.payToAddress(op.getAddress(), op.getAmounts()); - } - } - - private static void applyPayToContract(ScriptTx tx, TxOperation op) { - if (op.getAddress() == null) { - throw new IllegalArgumentException("pay_to_contract requires 'address'"); - } - if (op.getAmounts() == null || op.getAmounts().isEmpty()) { - throw new IllegalArgumentException("pay_to_contract requires 'amounts'"); - } - - Script refScript = null; - if (op.getScriptRefCborHex() != null && !op.getScriptRefCborHex().isEmpty()) { - refScript = parseScriptRef(op.getScriptRefCborHex(), op.getScriptRefType()); - } - - if (op.getDatumCborHex() != null && !op.getDatumCborHex().isEmpty()) { - PlutusData datum = parseRedeemer(op.getDatumCborHex()); - if (refScript != null) { - tx.payToContract(op.getAddress(), op.getAmounts(), datum, refScript); - } else { - tx.payToContract(op.getAddress(), op.getAmounts(), datum); - } - } else if (op.getDatumHash() != null && !op.getDatumHash().isEmpty()) { - tx.payToContract(op.getAddress(), op.getAmounts(), op.getDatumHash()); - } else { - throw new IllegalArgumentException("pay_to_contract requires 'datum_cbor_hex' or 'datum_hash'"); - } - } - - @SuppressWarnings("unchecked") - private static void applyAttachMetadata(ScriptTx tx, TxOperation op) { - if (op.getLabel() == null) { - throw new IllegalArgumentException("attach_metadata requires 'label'"); - } - if (op.getMetadata() == null) { - throw new IllegalArgumentException("attach_metadata requires 'metadata'"); - } - - CBORMetadata cborMetadata = new CBORMetadata(); - BigInteger label = BigInteger.valueOf(op.getLabel()); - Object metaValue = op.getMetadata(); - - if (metaValue instanceof String) { - cborMetadata.put(label, (String) metaValue); - } else if (metaValue instanceof Number) { - cborMetadata.put(label, BigInteger.valueOf(((Number) metaValue).longValue())); - } else if (metaValue instanceof List) { - CBORMetadataList list = buildMetadataList((List) metaValue); - cborMetadata.put(label, list); - } else if (metaValue instanceof Map) { - CBORMetadataMap map = buildMetadataMap((Map) metaValue); - cborMetadata.put(label, map); - } else { - throw new IllegalArgumentException("Unsupported metadata value type: " + - (metaValue != null ? metaValue.getClass().getName() : "null")); - } - - tx.attachMetadata(cborMetadata); - } - - @SuppressWarnings("unchecked") - private static CBORMetadataList buildMetadataList(List items) { - CBORMetadataList list = new CBORMetadataList(); - for (Object item : items) { - if (item instanceof String) { - list.add((String) item); - } else if (item instanceof Number) { - list.add(BigInteger.valueOf(((Number) item).longValue())); - } else if (item instanceof List) { - list.add(buildMetadataList((List) item)); - } else if (item instanceof Map) { - list.add(buildMetadataMap((Map) item)); - } - } - return list; - } - - @SuppressWarnings("unchecked") - private static CBORMetadataMap buildMetadataMap(Map map) { - CBORMetadataMap metaMap = new CBORMetadataMap(); - for (Map.Entry entry : map.entrySet()) { - String key = entry.getKey(); - Object value = entry.getValue(); - if (value instanceof String) { - metaMap.put(key, (String) value); - } else if (value instanceof Number) { - metaMap.put(key, BigInteger.valueOf(((Number) value).longValue())); - } else if (value instanceof List) { - metaMap.put(key, buildMetadataList((List) value)); - } else if (value instanceof Map) { - metaMap.put(key, buildMetadataMap((Map) value)); - } - } - return metaMap; - } - - private static void applyCollectFrom(ScriptTx tx, TxOperation op) { - if (op.getCollectUtxos() == null || op.getCollectUtxos().isEmpty()) { - throw new IllegalArgumentException("collect_from requires 'collect_utxos'"); - } - - if (op.getRedeemerCborHex() != null && !op.getRedeemerCborHex().isEmpty()) { - PlutusData redeemer = parseRedeemer(op.getRedeemerCborHex()); - PlutusData datum = null; - if (op.getDatumCborHex() != null && !op.getDatumCborHex().isEmpty()) { - datum = parseRedeemer(op.getDatumCborHex()); - } - if (datum != null) { - tx.collectFrom(op.getCollectUtxos(), redeemer, datum); - } else { - tx.collectFrom(op.getCollectUtxos(), redeemer); - } - } else { - tx.collectFrom(op.getCollectUtxos()); - } - } - - private static void applyReadFrom(ScriptTx tx, TxOperation op) { - if (op.getReferenceInputs() == null || op.getReferenceInputs().isEmpty()) { - throw new IllegalArgumentException("read_from requires 'reference_inputs'"); - } - for (TxOperation.ReferenceInput ref : op.getReferenceInputs()) { - if (ref.getTxHash() == null || ref.getTxHash().isEmpty()) { - throw new IllegalArgumentException("read_from reference_input requires 'tx_hash'"); - } - tx.readFrom(ref.getTxHash(), ref.getOutputIndex()); - } - } - - private static void applyMintPlutusAssets(ScriptTx tx, TxOperation op) { - if (op.getScriptCborHex() == null || op.getScriptCborHex().isEmpty()) { - throw new IllegalArgumentException("mint_plutus_assets requires 'script_cbor_hex'"); - } - if (op.getScriptType() == null || op.getScriptType().isEmpty()) { - throw new IllegalArgumentException("mint_plutus_assets requires 'script_type'"); - } - if (op.getAssets() == null || op.getAssets().isEmpty()) { - throw new IllegalArgumentException("mint_plutus_assets requires 'assets'"); - } - if (op.getRedeemerCborHex() == null || op.getRedeemerCborHex().isEmpty()) { - throw new IllegalArgumentException("mint_plutus_assets requires 'redeemer_cbor_hex'"); - } - - PlutusScript script = parsePlutusScript(op.getScriptCborHex(), op.getScriptType()); - PlutusData redeemer = parseRedeemer(op.getRedeemerCborHex()); - - List assets = new ArrayList<>(); - for (TxOperation.MintAsset ma : op.getAssets()) { - assets.add(new Asset(ma.getName(), new BigInteger(ma.getQuantity()))); - } - - PlutusData outputDatum = null; - if (op.getOutputDatumCborHex() != null && !op.getOutputDatumCborHex().isEmpty()) { - outputDatum = parseRedeemer(op.getOutputDatumCborHex()); - } - - if (op.getReceiver() != null && !op.getReceiver().isEmpty()) { - if (outputDatum != null) { - tx.mintAsset(script, assets, redeemer, op.getReceiver(), outputDatum); - } else { - tx.mintAsset(script, assets, redeemer, op.getReceiver()); - } - } else { - tx.mintAsset(script, assets, redeemer); - } - } - - private static void applyAttachValidator(ScriptTx tx, TxOperation op, String validatorType) { - if (op.getScriptCborHex() == null || op.getScriptCborHex().isEmpty()) { - throw new IllegalArgumentException("attach_" + validatorType + "_validator requires 'script_cbor_hex'"); - } - if (op.getScriptType() == null || op.getScriptType().isEmpty()) { - throw new IllegalArgumentException("attach_" + validatorType + "_validator requires 'script_type'"); - } - - PlutusScript script = parsePlutusScript(op.getScriptCborHex(), op.getScriptType()); - - switch (validatorType) { - case "spending": - tx.attachSpendingValidator(script); - break; - case "certificate": - tx.attachCertificateValidator(script); - break; - case "reward": - tx.attachRewardValidator(script); - break; - case "proposing": - tx.attachProposingValidator(script); - break; - case "voting": - tx.attachVotingValidator(script); - break; - default: - throw new IllegalArgumentException("Unknown validator type: " + validatorType); - } - } - - // --- Staking --- - // Note: In ScriptTx, deregisterStakeAddress, delegateTo, and withdraw always require a redeemer - - private static void applyDeregisterStakeAddress(ScriptTx tx, TxOperation op) { - if (op.getAddress() == null) { - throw new IllegalArgumentException("deregister_stake_address requires 'address'"); - } - if (op.getRedeemerCborHex() == null || op.getRedeemerCborHex().isEmpty()) { - throw new IllegalArgumentException("deregister_stake_address in script_tx mode requires 'redeemer_cbor_hex'"); - } - PlutusData redeemer = parseRedeemer(op.getRedeemerCborHex()); - - if (op.getRefundAddress() != null && !op.getRefundAddress().isEmpty()) { - tx.deregisterStakeAddress(op.getAddress(), redeemer, op.getRefundAddress()); - } else { - tx.deregisterStakeAddress(op.getAddress(), redeemer); - } - } - - private static void applyDelegateTo(ScriptTx tx, TxOperation op) { - if (op.getAddress() == null) { - throw new IllegalArgumentException("delegate_to requires 'address'"); - } - if (op.getPoolId() == null || op.getPoolId().isEmpty()) { - throw new IllegalArgumentException("delegate_to requires 'pool_id'"); - } - if (op.getRedeemerCborHex() == null || op.getRedeemerCborHex().isEmpty()) { - throw new IllegalArgumentException("delegate_to in script_tx mode requires 'redeemer_cbor_hex'"); - } - PlutusData redeemer = parseRedeemer(op.getRedeemerCborHex()); - tx.delegateTo(op.getAddress(), op.getPoolId(), redeemer); - } - - private static void applyWithdraw(ScriptTx tx, TxOperation op) { - if (op.getRewardAddress() == null) { - throw new IllegalArgumentException("withdraw requires 'reward_address'"); - } - if (op.getAmount() == null) { - throw new IllegalArgumentException("withdraw requires 'amount'"); - } - if (op.getRedeemerCborHex() == null || op.getRedeemerCborHex().isEmpty()) { - throw new IllegalArgumentException("withdraw in script_tx mode requires 'redeemer_cbor_hex'"); - } - BigInteger amount = new BigInteger(op.getAmount()); - PlutusData redeemer = parseRedeemer(op.getRedeemerCborHex()); - - if (op.getReceiver() != null && !op.getReceiver().isEmpty()) { - tx.withdraw(op.getRewardAddress(), amount, redeemer, op.getReceiver()); - } else { - tx.withdraw(op.getRewardAddress(), amount, redeemer); - } - } - - // --- DRep --- - - private static void applyRegisterDRep(ScriptTx tx, TxOperation op) { - if (op.getCredentialHash() == null) { - throw new IllegalArgumentException("register_drep requires 'credential_hash'"); - } - if (op.getRedeemerCborHex() == null || op.getRedeemerCborHex().isEmpty()) { - throw new IllegalArgumentException("register_drep in script_tx mode requires 'redeemer_cbor_hex'"); - } - Credential credential = parseCredential(op.getCredentialHash(), op.getCredentialType()); - Anchor anchor = parseAnchor(op.getAnchorUrl(), op.getAnchorDataHash()); - PlutusData redeemer = parseRedeemer(op.getRedeemerCborHex()); - - if (anchor != null) { - tx.registerDRep(credential, anchor, redeemer); - } else { - tx.registerDRep(credential, redeemer); - } - } - - private static void applyUnregisterDRep(ScriptTx tx, TxOperation op) { - if (op.getCredentialHash() == null) { - throw new IllegalArgumentException("unregister_drep requires 'credential_hash'"); - } - if (op.getRedeemerCborHex() == null || op.getRedeemerCborHex().isEmpty()) { - throw new IllegalArgumentException("unregister_drep in script_tx mode requires 'redeemer_cbor_hex'"); - } - Credential credential = parseCredential(op.getCredentialHash(), op.getCredentialType()); - PlutusData redeemer = parseRedeemer(op.getRedeemerCborHex()); - String refundAddress = op.getRefundAddress() != null ? op.getRefundAddress() : null; - BigInteger refundAmount = op.getRefundAmount() != null && !op.getRefundAmount().isEmpty() - ? new BigInteger(op.getRefundAmount()) : null; - - tx.unRegisterDRep(credential, refundAddress, refundAmount, redeemer); - } - - private static void applyUpdateDRep(ScriptTx tx, TxOperation op) { - if (op.getCredentialHash() == null) { - throw new IllegalArgumentException("update_drep requires 'credential_hash'"); - } - if (op.getRedeemerCborHex() == null || op.getRedeemerCborHex().isEmpty()) { - throw new IllegalArgumentException("update_drep in script_tx mode requires 'redeemer_cbor_hex'"); - } - Credential credential = parseCredential(op.getCredentialHash(), op.getCredentialType()); - Anchor anchor = parseAnchor(op.getAnchorUrl(), op.getAnchorDataHash()); - PlutusData redeemer = parseRedeemer(op.getRedeemerCborHex()); - - tx.updateDRep(credential, anchor, redeemer); - } - - // --- Voting --- - - private static void applyDelegateVotingPowerTo(ScriptTx tx, TxOperation op) { - if (op.getAddress() == null) { - throw new IllegalArgumentException("delegate_voting_power_to requires 'address'"); - } - if (op.getDrepType() == null) { - throw new IllegalArgumentException("delegate_voting_power_to requires 'drep_type'"); - } - if (op.getRedeemerCborHex() == null || op.getRedeemerCborHex().isEmpty()) { - throw new IllegalArgumentException("delegate_voting_power_to in script_tx mode requires 'redeemer_cbor_hex'"); - } - DRep drep = parseDRep(op.getDrepType(), op.getDrepHash()); - PlutusData redeemer = parseRedeemer(op.getRedeemerCborHex()); - Address address = new Address(op.getAddress()); - - tx.delegateVotingPowerTo(address, drep, redeemer); - } - - private static void applyCreateVote(ScriptTx tx, TxOperation op) { - if (op.getVoterType() == null) { - throw new IllegalArgumentException("create_vote requires 'voter_type'"); - } - if (op.getVoterHash() == null) { - throw new IllegalArgumentException("create_vote requires 'voter_hash'"); - } - if (op.getGovActionTxHash() == null) { - throw new IllegalArgumentException("create_vote requires 'gov_action_tx_hash'"); - } - if (op.getGovActionIndex() == null) { - throw new IllegalArgumentException("create_vote requires 'gov_action_index'"); - } - if (op.getVote() == null) { - throw new IllegalArgumentException("create_vote requires 'vote'"); - } - - Voter voter = parseVoter(op.getVoterType(), op.getVoterHash()); - GovActionId govActionId = new GovActionId(op.getGovActionTxHash(), op.getGovActionIndex()); - Vote vote = parseVote(op.getVote()); - Anchor anchor = parseAnchor(op.getAnchorUrl(), op.getAnchorDataHash()); - PlutusData redeemer = op.getRedeemerCborHex() != null ? parseRedeemer(op.getRedeemerCborHex()) : null; - - if (redeemer == null) { - throw new IllegalArgumentException("create_vote in script_tx mode requires 'redeemer_cbor_hex'"); - } - tx.createVote(voter, govActionId, vote, anchor, redeemer); - } - - // --- Governance proposals --- - - private static void applyCreateProposal(ScriptTx tx, TxOperation op) { - if (op.getGovActionType() == null) { - throw new IllegalArgumentException("create_proposal requires 'gov_action_type'"); - } - if (op.getReturnAddress() == null) { - throw new IllegalArgumentException("create_proposal requires 'return_address'"); - } - if (op.getRedeemerCborHex() == null || op.getRedeemerCborHex().isEmpty()) { - throw new IllegalArgumentException("create_proposal in script_tx mode requires 'redeemer_cbor_hex'"); - } - - Anchor anchor = parseAnchor(op.getAnchorUrl(), op.getAnchorDataHash()); - PlutusData redeemer = parseRedeemer(op.getRedeemerCborHex()); - - switch (op.getGovActionType().toLowerCase()) { - case "info_action": { - InfoAction action = InfoAction.builder().build(); - tx.createProposal(action, op.getReturnAddress(), anchor, redeemer); - break; - } - case "treasury_withdrawals": { - TreasuryWithdrawalsAction action = TreasuryWithdrawalsAction.builder().build(); - if (op.getWithdrawals() != null) { - for (Map w : op.getWithdrawals()) { - String rewardAddr = w.get("reward_address"); - String amt = w.get("amount"); - if (rewardAddr == null || amt == null) { - throw new IllegalArgumentException( - "Each withdrawal requires 'reward_address' and 'amount'"); - } - action.addWithdrawal(new Withdrawal(rewardAddr, new BigInteger(amt))); - } - } - tx.createProposal(action, op.getReturnAddress(), anchor, redeemer); - break; - } - case "no_confidence": { - NoConfidence.NoConfidenceBuilder builder = NoConfidence.builder(); - GovActionId prevId = parsePrevGovActionId(op.getGovActionTxHash(), op.getGovActionIndex()); - if (prevId != null) builder.prevGovActionId(prevId); - tx.createProposal(builder.build(), op.getReturnAddress(), anchor, redeemer); - break; - } - case "update_committee": { - UpdateCommittee.UpdateCommitteeBuilder builder = UpdateCommittee.builder(); - GovActionId prevId = parsePrevGovActionId(op.getGovActionTxHash(), op.getGovActionIndex()); - if (prevId != null) builder.prevGovActionId(prevId); - if (op.getMembersToRemove() != null) { - Set removals = new LinkedHashSet<>(); - for (Map m : op.getMembersToRemove()) { - removals.add(parseCredential(m.get("hash"), m.get("type"))); - } - builder.membersForRemoval(removals); - } - if (op.getNewMembers() != null) { - Map newMembersMap = new LinkedHashMap<>(); - for (Map m : op.getNewMembers()) { - Credential cred = parseCredential((String) m.get("hash"), (String) m.get("type")); - int epoch = ((Number) m.get("epoch")).intValue(); - newMembersMap.put(cred, epoch); - } - builder.newMembersAndTerms(newMembersMap); - } - if (op.getQuorumNumerator() != null && op.getQuorumDenominator() != null) { - builder.quorumThreshold(new UnitInterval( - new BigInteger(op.getQuorumNumerator()), - new BigInteger(op.getQuorumDenominator()))); - } - tx.createProposal(builder.build(), op.getReturnAddress(), anchor, redeemer); - break; - } - case "new_constitution": { - NewConstitution.NewConstitutionBuilder builder = NewConstitution.builder(); - GovActionId prevId = parsePrevGovActionId(op.getGovActionTxHash(), op.getGovActionIndex()); - if (prevId != null) builder.prevGovActionId(prevId); - Anchor constitutionAnchor = parseAnchor(op.getConstitutionAnchorUrl(), op.getConstitutionAnchorDataHash()); - Constitution.ConstitutionBuilder cBuilder = Constitution.builder(); - if (constitutionAnchor != null) cBuilder.anchor(constitutionAnchor); - if (op.getConstitutionScriptHash() != null) cBuilder.scripthash(op.getConstitutionScriptHash()); - builder.constitution(cBuilder.build()); - tx.createProposal(builder.build(), op.getReturnAddress(), anchor, redeemer); - break; - } - case "hard_fork_initiation": { - HardForkInitiationAction.HardForkInitiationActionBuilder builder = HardForkInitiationAction.builder(); - GovActionId prevId = parsePrevGovActionId(op.getGovActionTxHash(), op.getGovActionIndex()); - if (prevId != null) builder.prevGovActionId(prevId); - if (op.getProtocolVersionMajor() == null || op.getProtocolVersionMinor() == null) { - throw new IllegalArgumentException( - "hard_fork_initiation requires 'protocol_version_major' and 'protocol_version_minor'"); - } - builder.protocolVersion(new ProtocolVersion(op.getProtocolVersionMajor(), op.getProtocolVersionMinor())); - tx.createProposal(builder.build(), op.getReturnAddress(), anchor, redeemer); - break; - } - case "parameter_change": { - ParameterChangeAction.ParameterChangeActionBuilder builder = ParameterChangeAction.builder(); - GovActionId prevId = parsePrevGovActionId(op.getGovActionTxHash(), op.getGovActionIndex()); - if (prevId != null) builder.prevGovActionId(prevId); - if (op.getPolicyHash() != null && !op.getPolicyHash().isEmpty()) { - builder.policyHash(HexUtil.decodeHexString(op.getPolicyHash())); - } - if (op.getProtocolParamUpdateJson() != null && !op.getProtocolParamUpdateJson().isEmpty()) { - throw new IllegalArgumentException( - "parameter_change protocol_param_update_json is not yet supported. " + - "Use the raw transaction builder for parameter change proposals."); - } - tx.createProposal(builder.build(), op.getReturnAddress(), anchor, redeemer); - break; - } - default: - throw new IllegalArgumentException( - "Unsupported gov_action_type: " + op.getGovActionType()); - } - } - - // --- Treasury donation (Gap 4) --- - - private static void applyDonateToTreasury(ScriptTx tx, TxOperation op) { - if (op.getTreasuryValue() == null) { - throw new IllegalArgumentException("donate_to_treasury requires 'treasury_value'"); - } - if (op.getDonationAmount() == null) { - throw new IllegalArgumentException("donate_to_treasury requires 'donation_amount'"); - } - tx.donateToTreasury(new BigInteger(op.getTreasuryValue()), new BigInteger(op.getDonationAmount())); - } - - // --- Helper methods --- - - private static GovActionId parsePrevGovActionId(String txHash, Integer index) { - if (txHash == null || txHash.isEmpty()) return null; - return new GovActionId(txHash, index != null ? index : 0); - } - - private static Script parseScriptRef(String cborHex, String scriptRefType) { - if (scriptRefType == null || scriptRefType.isEmpty()) { - throw new IllegalArgumentException("script_ref_type is required when script_ref_cbor_hex is provided. " + - "Supported: plutus_v1, plutus_v2, plutus_v3"); - } - return switch (scriptRefType.toLowerCase()) { - case "plutus_v1" -> PlutusV1Script.builder().cborHex(cborHex).build(); - case "plutus_v2" -> PlutusV2Script.builder().cborHex(cborHex).build(); - case "plutus_v3" -> PlutusV3Script.builder().cborHex(cborHex).build(); - default -> throw new IllegalArgumentException("Unsupported script_ref_type: " + scriptRefType - + ". Supported: plutus_v1, plutus_v2, plutus_v3"); - }; - } - - static PlutusScript parsePlutusScript(String cborHex, String type) { - return switch (type.toLowerCase()) { - case "plutus_v1" -> PlutusV1Script.builder().cborHex(cborHex).build(); - case "plutus_v2" -> PlutusV2Script.builder().cborHex(cborHex).build(); - case "plutus_v3" -> PlutusV3Script.builder().cborHex(cborHex).build(); - default -> throw new IllegalArgumentException("Unsupported script_type: " + type - + ". Supported: plutus_v1, plutus_v2, plutus_v3"); - }; - } - - static PlutusData parseRedeemer(String cborHex) { - try { - return PlutusData.deserialize(HexUtil.decodeHexString(cborHex)); - } catch (Exception e) { - throw new IllegalArgumentException("Invalid CBOR hex: " + e.getMessage(), e); - } - } - - private static Credential parseCredential(String hash, String type) { - if ("script".equalsIgnoreCase(type)) { - return Credential.fromScript(hash); - } - return Credential.fromKey(hash); - } - - private static Anchor parseAnchor(String url, String dataHash) { - if (url == null || dataHash == null) return null; - return Anchor.builder() - .anchorUrl(url) - .anchorDataHash(HexUtil.decodeHexString(dataHash)) - .build(); - } - - private static DRep parseDRep(String drepType, String drepHash) { - return switch (drepType.toLowerCase()) { - case "abstain" -> DRep.abstain(); - case "no_confidence" -> DRep.noConfidence(); - case "script_hash" -> DRep.scriptHash(drepHash); - default -> DRep.addrKeyHash(drepHash); - }; - } - - private static Vote parseVote(String voteStr) { - return switch (voteStr.toLowerCase()) { - case "yes" -> Vote.YES; - case "no" -> Vote.NO; - case "abstain" -> Vote.ABSTAIN; - default -> throw new IllegalArgumentException("Invalid vote value: " + voteStr - + ". Must be 'yes', 'no', or 'abstain'"); - }; - } - - private static Voter parseVoter(String voterType, String voterHash) { - Credential credential = Credential.fromKey(voterHash); - VoterType type = switch (voterType.toLowerCase()) { - case "drep_key_hash" -> VoterType.DREP_KEY_HASH; - case "drep_script_hash" -> { - credential = Credential.fromScript(voterHash); - yield VoterType.DREP_SCRIPT_HASH; - } - case "staking_pool_key_hash" -> VoterType.STAKING_POOL_KEY_HASH; - case "constitutional_committee_hot_key_hash" -> - VoterType.CONSTITUTIONAL_COMMITTEE_HOT_KEY_HASH; - case "constitutional_committee_hot_script_hash" -> { - credential = Credential.fromScript(voterHash); - yield VoterType.CONSTITUTIONAL_COMMITTEE_HOT_SCRIPT_HASH; - } - default -> throw new IllegalArgumentException("Invalid voter_type: " + voterType); - }; - return new Voter(type, credential); - } -} diff --git a/core/src/main/java/com/bloxbean/cardano/bridge/api/quicktx/TxItemSpec.java b/core/src/main/java/com/bloxbean/cardano/bridge/api/quicktx/TxItemSpec.java deleted file mode 100644 index 13c78d1..0000000 --- a/core/src/main/java/com/bloxbean/cardano/bridge/api/quicktx/TxItemSpec.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.bloxbean.cardano.bridge.api.quicktx; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.annotation.JsonProperty; - -import java.util.List; - -@JsonIgnoreProperties(ignoreUnknown = true) -public class TxItemSpec { - - @JsonProperty("from") - private String from; - - @JsonProperty("change_address") - private String changeAddress; - - @JsonProperty("operations") - private List operations; - - @JsonProperty("tx_type") - private String txType; - - @JsonProperty("change_datum_cbor_hex") - private String changeDatumCborHex; - - @JsonProperty("change_datum_hash") - private String changeDatumHash; - - public String getFrom() { return from; } - public void setFrom(String from) { this.from = from; } - - public String getChangeAddress() { return changeAddress; } - public void setChangeAddress(String changeAddress) { this.changeAddress = changeAddress; } - - public List getOperations() { return operations; } - public void setOperations(List operations) { this.operations = operations; } - - public String getTxType() { return txType; } - public void setTxType(String txType) { this.txType = txType; } - - public String getChangeDatumCborHex() { return changeDatumCborHex; } - public void setChangeDatumCborHex(String changeDatumCborHex) { this.changeDatumCborHex = changeDatumCborHex; } - - public String getChangeDatumHash() { return changeDatumHash; } - public void setChangeDatumHash(String changeDatumHash) { this.changeDatumHash = changeDatumHash; } - - public void validate(int index) { - if (!"script_tx".equals(txType) && (from == null || from.isEmpty())) { - throw new IllegalArgumentException("transactions[" + index + "]: 'from' address is required"); - } - if (operations == null || operations.isEmpty()) { - throw new IllegalArgumentException("transactions[" + index + "]: at least one operation is required"); - } - } -} diff --git a/core/src/main/java/com/bloxbean/cardano/bridge/api/quicktx/TxOperation.java b/core/src/main/java/com/bloxbean/cardano/bridge/api/quicktx/TxOperation.java deleted file mode 100644 index acddf38..0000000 --- a/core/src/main/java/com/bloxbean/cardano/bridge/api/quicktx/TxOperation.java +++ /dev/null @@ -1,430 +0,0 @@ -package com.bloxbean.cardano.bridge.api.quicktx; - -import com.bloxbean.cardano.client.api.model.Amount; -import com.bloxbean.cardano.client.api.model.Utxo; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.annotation.JsonProperty; - -import java.util.List; -import java.util.Map; - -@JsonIgnoreProperties(ignoreUnknown = true) -public class TxOperation { - - @JsonProperty("type") - private String type; - - // pay_to_address / pay_to_contract - @JsonProperty("address") - private String address; - - @JsonProperty("amounts") - private List amounts; - - // pay_to_contract - inline datum CBOR hex - @JsonProperty("datum_cbor_hex") - private String datumCborHex; - - // pay_to_contract - datum hash hex - @JsonProperty("datum_hash") - private String datumHash; - - // mint_assets - @JsonProperty("script_json") - private String scriptJson; - - @JsonProperty("assets") - private List assets; - - @JsonProperty("receiver") - private String receiver; - - // attach_metadata - @JsonProperty("label") - private Integer label; - - @JsonProperty("metadata") - private Object metadata; - - // collect_from - @JsonProperty("collect_utxos") - private List collectUtxos; - - // Staking - @JsonProperty("pool_id") - private String poolId; - - @JsonProperty("reward_address") - private String rewardAddress; - - @JsonProperty("amount") - private String amount; - - @JsonProperty("refund_address") - private String refundAddress; - - // DRep - @JsonProperty("credential_hash") - private String credentialHash; - - @JsonProperty("credential_type") - private String credentialType; - - @JsonProperty("anchor_url") - private String anchorUrl; - - @JsonProperty("anchor_data_hash") - private String anchorDataHash; - - // Voting - @JsonProperty("drep_type") - private String drepType; - - @JsonProperty("drep_hash") - private String drepHash; - - @JsonProperty("voter_type") - private String voterType; - - @JsonProperty("voter_hash") - private String voterHash; - - @JsonProperty("gov_action_tx_hash") - private String govActionTxHash; - - @JsonProperty("gov_action_index") - private Integer govActionIndex; - - @JsonProperty("vote") - private String vote; - - // Governance proposals - @JsonProperty("gov_action_type") - private String govActionType; - - @JsonProperty("return_address") - private String returnAddress; - - @JsonProperty("withdrawals") - private List> withdrawals; - - // Reference script on outputs (Gap 1) - @JsonProperty("script_ref_cbor_hex") - private String scriptRefCborHex; - - @JsonProperty("script_ref_type") - private String scriptRefType; - - // Pool operations (Gap 3) - @JsonProperty("operator") - private String operator; - - @JsonProperty("vrf_key_hash") - private String vrfKeyHash; - - @JsonProperty("pledge") - private String pledge; - - @JsonProperty("cost") - private String cost; - - @JsonProperty("margin_numerator") - private String marginNumerator; - - @JsonProperty("margin_denominator") - private String marginDenominator; - - @JsonProperty("pool_owners") - private List poolOwners; - - @JsonProperty("relays") - private List> relays; - - @JsonProperty("pool_metadata_url") - private String poolMetadataUrl; - - @JsonProperty("pool_metadata_hash") - private String poolMetadataHash; - - @JsonProperty("epoch") - private Integer epoch; - - // Treasury donation (Gap 4) - @JsonProperty("treasury_value") - private String treasuryValue; - - @JsonProperty("donation_amount") - private String donationAmount; - - // Governance additional fields (Gap 2) - @JsonProperty("members_to_remove") - private List> membersToRemove; - - @JsonProperty("new_members") - private List> newMembers; - - @JsonProperty("quorum_numerator") - private String quorumNumerator; - - @JsonProperty("quorum_denominator") - private String quorumDenominator; - - @JsonProperty("constitution_anchor_url") - private String constitutionAnchorUrl; - - @JsonProperty("constitution_anchor_data_hash") - private String constitutionAnchorDataHash; - - @JsonProperty("constitution_script_hash") - private String constitutionScriptHash; - - @JsonProperty("protocol_version_major") - private Integer protocolVersionMajor; - - @JsonProperty("protocol_version_minor") - private Integer protocolVersionMinor; - - @JsonProperty("protocol_param_update_json") - private String protocolParamUpdateJson; - - @JsonProperty("policy_hash") - private String policyHash; - - // DRep unregister refund amount (Gap 6) - @JsonProperty("refund_amount") - private String refundAmount; - - // ScriptTx fields - @JsonProperty("redeemer_cbor_hex") - private String redeemerCborHex; - - @JsonProperty("script_cbor_hex") - private String scriptCborHex; - - @JsonProperty("script_type") - private String scriptType; - - @JsonProperty("reference_inputs") - private List referenceInputs; - - @JsonProperty("output_datum_cbor_hex") - private String outputDatumCborHex; - - public String getType() { return type; } - public void setType(String type) { this.type = type; } - - public String getAddress() { return address; } - public void setAddress(String address) { this.address = address; } - - public List getAmounts() { return amounts; } - public void setAmounts(List amounts) { this.amounts = amounts; } - - public String getDatumCborHex() { return datumCborHex; } - public void setDatumCborHex(String datumCborHex) { this.datumCborHex = datumCborHex; } - - public String getDatumHash() { return datumHash; } - public void setDatumHash(String datumHash) { this.datumHash = datumHash; } - - public String getScriptJson() { return scriptJson; } - public void setScriptJson(String scriptJson) { this.scriptJson = scriptJson; } - - public List getAssets() { return assets; } - public void setAssets(List assets) { this.assets = assets; } - - public String getReceiver() { return receiver; } - public void setReceiver(String receiver) { this.receiver = receiver; } - - public Integer getLabel() { return label; } - public void setLabel(Integer label) { this.label = label; } - - public Object getMetadata() { return metadata; } - public void setMetadata(Object metadata) { this.metadata = metadata; } - - public List getCollectUtxos() { return collectUtxos; } - public void setCollectUtxos(List collectUtxos) { this.collectUtxos = collectUtxos; } - - public String getPoolId() { return poolId; } - public void setPoolId(String poolId) { this.poolId = poolId; } - - public String getRewardAddress() { return rewardAddress; } - public void setRewardAddress(String rewardAddress) { this.rewardAddress = rewardAddress; } - - public String getAmount() { return amount; } - public void setAmount(String amount) { this.amount = amount; } - - public String getRefundAddress() { return refundAddress; } - public void setRefundAddress(String refundAddress) { this.refundAddress = refundAddress; } - - public String getCredentialHash() { return credentialHash; } - public void setCredentialHash(String credentialHash) { this.credentialHash = credentialHash; } - - public String getCredentialType() { return credentialType; } - public void setCredentialType(String credentialType) { this.credentialType = credentialType; } - - public String getAnchorUrl() { return anchorUrl; } - public void setAnchorUrl(String anchorUrl) { this.anchorUrl = anchorUrl; } - - public String getAnchorDataHash() { return anchorDataHash; } - public void setAnchorDataHash(String anchorDataHash) { this.anchorDataHash = anchorDataHash; } - - public String getDrepType() { return drepType; } - public void setDrepType(String drepType) { this.drepType = drepType; } - - public String getDrepHash() { return drepHash; } - public void setDrepHash(String drepHash) { this.drepHash = drepHash; } - - public String getVoterType() { return voterType; } - public void setVoterType(String voterType) { this.voterType = voterType; } - - public String getVoterHash() { return voterHash; } - public void setVoterHash(String voterHash) { this.voterHash = voterHash; } - - public String getGovActionTxHash() { return govActionTxHash; } - public void setGovActionTxHash(String govActionTxHash) { this.govActionTxHash = govActionTxHash; } - - public Integer getGovActionIndex() { return govActionIndex; } - public void setGovActionIndex(Integer govActionIndex) { this.govActionIndex = govActionIndex; } - - public String getVote() { return vote; } - public void setVote(String vote) { this.vote = vote; } - - public String getGovActionType() { return govActionType; } - public void setGovActionType(String govActionType) { this.govActionType = govActionType; } - - public String getReturnAddress() { return returnAddress; } - public void setReturnAddress(String returnAddress) { this.returnAddress = returnAddress; } - - public List> getWithdrawals() { return withdrawals; } - public void setWithdrawals(List> withdrawals) { this.withdrawals = withdrawals; } - - public String getRedeemerCborHex() { return redeemerCborHex; } - public void setRedeemerCborHex(String redeemerCborHex) { this.redeemerCborHex = redeemerCborHex; } - - public String getScriptCborHex() { return scriptCborHex; } - public void setScriptCborHex(String scriptCborHex) { this.scriptCborHex = scriptCborHex; } - - public String getScriptType() { return scriptType; } - public void setScriptType(String scriptType) { this.scriptType = scriptType; } - - public List getReferenceInputs() { return referenceInputs; } - public void setReferenceInputs(List referenceInputs) { this.referenceInputs = referenceInputs; } - - public String getOutputDatumCborHex() { return outputDatumCborHex; } - public void setOutputDatumCborHex(String outputDatumCborHex) { this.outputDatumCborHex = outputDatumCborHex; } - - // Gap 1: Reference script - public String getScriptRefCborHex() { return scriptRefCborHex; } - public void setScriptRefCborHex(String scriptRefCborHex) { this.scriptRefCborHex = scriptRefCborHex; } - - public String getScriptRefType() { return scriptRefType; } - public void setScriptRefType(String scriptRefType) { this.scriptRefType = scriptRefType; } - - // Gap 3: Pool operations - public String getOperator() { return operator; } - public void setOperator(String operator) { this.operator = operator; } - - public String getVrfKeyHash() { return vrfKeyHash; } - public void setVrfKeyHash(String vrfKeyHash) { this.vrfKeyHash = vrfKeyHash; } - - public String getPledge() { return pledge; } - public void setPledge(String pledge) { this.pledge = pledge; } - - public String getCost() { return cost; } - public void setCost(String cost) { this.cost = cost; } - - public String getMarginNumerator() { return marginNumerator; } - public void setMarginNumerator(String marginNumerator) { this.marginNumerator = marginNumerator; } - - public String getMarginDenominator() { return marginDenominator; } - public void setMarginDenominator(String marginDenominator) { this.marginDenominator = marginDenominator; } - - public List getPoolOwners() { return poolOwners; } - public void setPoolOwners(List poolOwners) { this.poolOwners = poolOwners; } - - public List> getRelays() { return relays; } - public void setRelays(List> relays) { this.relays = relays; } - - public String getPoolMetadataUrl() { return poolMetadataUrl; } - public void setPoolMetadataUrl(String poolMetadataUrl) { this.poolMetadataUrl = poolMetadataUrl; } - - public String getPoolMetadataHash() { return poolMetadataHash; } - public void setPoolMetadataHash(String poolMetadataHash) { this.poolMetadataHash = poolMetadataHash; } - - public Integer getEpoch() { return epoch; } - public void setEpoch(Integer epoch) { this.epoch = epoch; } - - // Gap 4: Treasury donation - public String getTreasuryValue() { return treasuryValue; } - public void setTreasuryValue(String treasuryValue) { this.treasuryValue = treasuryValue; } - - public String getDonationAmount() { return donationAmount; } - public void setDonationAmount(String donationAmount) { this.donationAmount = donationAmount; } - - // Gap 2: Additional governance - public List> getMembersToRemove() { return membersToRemove; } - public void setMembersToRemove(List> membersToRemove) { this.membersToRemove = membersToRemove; } - - public List> getNewMembers() { return newMembers; } - public void setNewMembers(List> newMembers) { this.newMembers = newMembers; } - - public String getQuorumNumerator() { return quorumNumerator; } - public void setQuorumNumerator(String quorumNumerator) { this.quorumNumerator = quorumNumerator; } - - public String getQuorumDenominator() { return quorumDenominator; } - public void setQuorumDenominator(String quorumDenominator) { this.quorumDenominator = quorumDenominator; } - - public String getConstitutionAnchorUrl() { return constitutionAnchorUrl; } - public void setConstitutionAnchorUrl(String constitutionAnchorUrl) { this.constitutionAnchorUrl = constitutionAnchorUrl; } - - public String getConstitutionAnchorDataHash() { return constitutionAnchorDataHash; } - public void setConstitutionAnchorDataHash(String constitutionAnchorDataHash) { this.constitutionAnchorDataHash = constitutionAnchorDataHash; } - - public String getConstitutionScriptHash() { return constitutionScriptHash; } - public void setConstitutionScriptHash(String constitutionScriptHash) { this.constitutionScriptHash = constitutionScriptHash; } - - public Integer getProtocolVersionMajor() { return protocolVersionMajor; } - public void setProtocolVersionMajor(Integer protocolVersionMajor) { this.protocolVersionMajor = protocolVersionMajor; } - - public Integer getProtocolVersionMinor() { return protocolVersionMinor; } - public void setProtocolVersionMinor(Integer protocolVersionMinor) { this.protocolVersionMinor = protocolVersionMinor; } - - public String getProtocolParamUpdateJson() { return protocolParamUpdateJson; } - public void setProtocolParamUpdateJson(String protocolParamUpdateJson) { this.protocolParamUpdateJson = protocolParamUpdateJson; } - - public String getPolicyHash() { return policyHash; } - public void setPolicyHash(String policyHash) { this.policyHash = policyHash; } - - // Gap 6: DRep refund amount - public String getRefundAmount() { return refundAmount; } - public void setRefundAmount(String refundAmount) { this.refundAmount = refundAmount; } - - @JsonIgnoreProperties(ignoreUnknown = true) - public static class ReferenceInput { - @JsonProperty("tx_hash") - private String txHash; - - @JsonProperty("output_index") - private int outputIndex; - - public String getTxHash() { return txHash; } - public void setTxHash(String txHash) { this.txHash = txHash; } - - public int getOutputIndex() { return outputIndex; } - public void setOutputIndex(int outputIndex) { this.outputIndex = outputIndex; } - } - - @JsonIgnoreProperties(ignoreUnknown = true) - public static class MintAsset { - @JsonProperty("name") - private String name; - - @JsonProperty("quantity") - private String quantity; - - public String getName() { return name; } - public void setName(String name) { this.name = name; } - - public String getQuantity() { return quantity; } - public void setQuantity(String quantity) { this.quantity = quantity; } - } -} diff --git a/core/src/main/java/com/bloxbean/cardano/bridge/api/quicktx/TxSpec.java b/core/src/main/java/com/bloxbean/cardano/bridge/api/quicktx/TxSpec.java deleted file mode 100644 index e3bc572..0000000 --- a/core/src/main/java/com/bloxbean/cardano/bridge/api/quicktx/TxSpec.java +++ /dev/null @@ -1,144 +0,0 @@ -package com.bloxbean.cardano.bridge.api.quicktx; - -import com.bloxbean.cardano.client.api.model.Amount; -import com.bloxbean.cardano.client.api.model.ProtocolParams; -import com.bloxbean.cardano.client.api.model.Utxo; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.annotation.JsonProperty; - -import java.util.List; - -@JsonIgnoreProperties(ignoreUnknown = true) -public class TxSpec { - - @JsonProperty("operations") - private List operations; - - @JsonProperty("from") - private String from; - - @JsonProperty("change_address") - private String changeAddress; - - @JsonProperty("fee_payer") - private String feePayer; - - @JsonProperty("utxos") - private List utxos; - - @JsonProperty("protocol_params") - private ProtocolParams protocolParams; - - @JsonProperty("validity") - private Validity validity; - - @JsonProperty("merge_outputs") - private Boolean mergeOutputs; - - @JsonProperty("signer_count") - private Integer signerCount; - - @JsonProperty("transactions") - private List transactions; - - @JsonProperty("provider") - private ProviderConfig provider; - - @JsonProperty("tx_type") - private String txType; - - @JsonProperty("change_datum_cbor_hex") - private String changeDatumCborHex; - - @JsonProperty("change_datum_hash") - private String changeDatumHash; - - public List getOperations() { return operations; } - public void setOperations(List operations) { this.operations = operations; } - - public String getFrom() { return from; } - public void setFrom(String from) { this.from = from; } - - public String getChangeAddress() { return changeAddress; } - public void setChangeAddress(String changeAddress) { this.changeAddress = changeAddress; } - - public String getFeePayer() { return feePayer; } - public void setFeePayer(String feePayer) { this.feePayer = feePayer; } - - public List getUtxos() { return utxos; } - public void setUtxos(List utxos) { this.utxos = utxos; } - - public ProtocolParams getProtocolParams() { return protocolParams; } - public void setProtocolParams(ProtocolParams protocolParams) { this.protocolParams = protocolParams; } - - public Validity getValidity() { return validity; } - public void setValidity(Validity validity) { this.validity = validity; } - - public Boolean getMergeOutputs() { return mergeOutputs; } - public void setMergeOutputs(Boolean mergeOutputs) { this.mergeOutputs = mergeOutputs; } - - public Integer getSignerCount() { return signerCount; } - public void setSignerCount(Integer signerCount) { this.signerCount = signerCount; } - - public List getTransactions() { return transactions; } - public void setTransactions(List transactions) { this.transactions = transactions; } - - public ProviderConfig getProvider() { return provider; } - public void setProvider(ProviderConfig provider) { this.provider = provider; } - - public String getTxType() { return txType; } - public void setTxType(String txType) { this.txType = txType; } - - public String getChangeDatumCborHex() { return changeDatumCborHex; } - public void setChangeDatumCborHex(String changeDatumCborHex) { this.changeDatumCborHex = changeDatumCborHex; } - - public String getChangeDatumHash() { return changeDatumHash; } - public void setChangeDatumHash(String changeDatumHash) { this.changeDatumHash = changeDatumHash; } - - public void validate() { - if (transactions != null && !transactions.isEmpty()) { - // Compose mode - if (feePayer == null || feePayer.isEmpty()) { - throw new IllegalArgumentException("'fee_payer' is required when composing multiple transactions"); - } - for (int i = 0; i < transactions.size(); i++) { - transactions.get(i).validate(i); - } - } else { - // Single mode - if (operations == null || operations.isEmpty()) { - throw new IllegalArgumentException("At least one operation is required"); - } - if (!"script_tx".equals(txType) && (from == null || from.isEmpty())) { - throw new IllegalArgumentException("'from' address is required"); - } - } - - // Common validations — provider mode supplies utxos and protocol_params via HTTP - if (provider != null) { - provider.validate(); - } else { - if (utxos == null || utxos.isEmpty()) { - throw new IllegalArgumentException("'utxos' are required"); - } - if (protocolParams == null) { - throw new IllegalArgumentException("'protocol_params' are required"); - } - } - } - - @JsonIgnoreProperties(ignoreUnknown = true) - public static class Validity { - @JsonProperty("valid_from") - private Long validFrom; - - @JsonProperty("valid_to") - private Long validTo; - - public Long getValidFrom() { return validFrom; } - public void setValidFrom(Long validFrom) { this.validFrom = validFrom; } - - public Long getValidTo() { return validTo; } - public void setValidTo(Long validTo) { this.validTo = validTo; } - } -} diff --git a/core/src/main/java/com/bloxbean/cardano/bridge/api/quicktx/TxSpecMapper.java b/core/src/main/java/com/bloxbean/cardano/bridge/api/quicktx/TxSpecMapper.java deleted file mode 100644 index 41f151a..0000000 --- a/core/src/main/java/com/bloxbean/cardano/bridge/api/quicktx/TxSpecMapper.java +++ /dev/null @@ -1,728 +0,0 @@ -package com.bloxbean.cardano.bridge.api.quicktx; - -import com.bloxbean.cardano.client.address.Credential; -import com.bloxbean.cardano.client.metadata.cbor.CBORMetadata; -import com.bloxbean.cardano.client.metadata.cbor.CBORMetadataList; -import com.bloxbean.cardano.client.metadata.cbor.CBORMetadataMap; -import com.bloxbean.cardano.client.plutus.spec.PlutusData; -import com.bloxbean.cardano.client.plutus.spec.PlutusV1Script; -import com.bloxbean.cardano.client.plutus.spec.PlutusV2Script; -import com.bloxbean.cardano.client.plutus.spec.PlutusV3Script; -import com.bloxbean.cardano.client.quicktx.Tx; -import com.bloxbean.cardano.client.spec.UnitInterval; -import com.bloxbean.cardano.client.transaction.spec.Asset; -import com.bloxbean.cardano.client.transaction.spec.ProtocolVersion; -import com.bloxbean.cardano.client.transaction.spec.Withdrawal; -import com.bloxbean.cardano.client.transaction.spec.cert.PoolRegistration; -import com.bloxbean.cardano.client.transaction.spec.cert.SingleHostAddr; -import com.bloxbean.cardano.client.transaction.spec.cert.SingleHostName; -import com.bloxbean.cardano.client.transaction.spec.cert.MultiHostName; -import com.bloxbean.cardano.client.transaction.spec.cert.Relay; -import com.bloxbean.cardano.client.transaction.spec.governance.Anchor; -import com.bloxbean.cardano.client.transaction.spec.governance.Constitution; -import com.bloxbean.cardano.client.transaction.spec.governance.DRep; -import com.bloxbean.cardano.client.transaction.spec.governance.Vote; -import com.bloxbean.cardano.client.transaction.spec.governance.Voter; -import com.bloxbean.cardano.client.transaction.spec.governance.VoterType; -import com.bloxbean.cardano.client.transaction.spec.governance.actions.*; -import com.bloxbean.cardano.client.spec.Script; -import com.bloxbean.cardano.client.transaction.spec.script.NativeScript; -import com.bloxbean.cardano.client.util.HexUtil; - -import java.math.BigInteger; -import java.net.Inet4Address; -import java.net.Inet6Address; -import java.net.UnknownHostException; -import java.util.*; - -/** - * Maps a TxSpec (parsed from JSON) into a CCL Tx object. - */ -public class TxSpecMapper { - - public static Tx toTx(TxSpec spec) { - Tx tx = new Tx(); - tx.from(spec.getFrom()); - if (spec.getChangeAddress() != null && !spec.getChangeAddress().isEmpty()) { - tx.withChangeAddress(spec.getChangeAddress()); - } - applyOperations(tx, spec.getOperations()); - return tx; - } - - public static Tx toTx(TxItemSpec item) { - Tx tx = new Tx(); - tx.from(item.getFrom()); - if (item.getChangeAddress() != null && !item.getChangeAddress().isEmpty()) { - tx.withChangeAddress(item.getChangeAddress()); - } - applyOperations(tx, item.getOperations()); - return tx; - } - - private static void applyOperations(Tx tx, List operations) { - for (TxOperation op : operations) { - switch (op.getType()) { - case "pay_to_address": - applyPayToAddress(tx, op); - break; - case "pay_to_contract": - applyPayToContract(tx, op); - break; - case "mint_assets": - applyMintAssets(tx, op); - break; - case "attach_metadata": - applyAttachMetadata(tx, op); - break; - case "collect_from": - applyCollectFrom(tx, op); - break; - // Staking - case "register_stake_address": - applyRegisterStakeAddress(tx, op); - break; - case "deregister_stake_address": - applyDeregisterStakeAddress(tx, op); - break; - case "delegate_to": - applyDelegateTo(tx, op); - break; - case "withdraw": - applyWithdraw(tx, op); - break; - // DRep - case "register_drep": - applyRegisterDRep(tx, op); - break; - case "unregister_drep": - applyUnregisterDRep(tx, op); - break; - case "update_drep": - applyUpdateDRep(tx, op); - break; - // Voting - case "delegate_voting_power_to": - applyDelegateVotingPowerTo(tx, op); - break; - case "create_vote": - applyCreateVote(tx, op); - break; - // Governance - case "create_proposal": - applyCreateProposal(tx, op); - break; - // Pool operations (Gap 3) - case "register_pool": - applyRegisterPool(tx, op); - break; - case "update_pool": - applyUpdatePool(tx, op); - break; - case "retire_pool": - applyRetirePool(tx, op); - break; - // Treasury donation (Gap 4) - case "donate_to_treasury": - applyDonateToTreasury(tx, op); - break; - // Attach native script standalone (Gap 5) - case "attach_native_script": - applyAttachNativeScript(tx, op); - break; - default: - throw new IllegalArgumentException("Unknown operation type: " + op.getType()); - } - } - } - - private static void applyPayToAddress(Tx tx, TxOperation op) { - if (op.getAddress() == null) { - throw new IllegalArgumentException("pay_to_address requires 'address'"); - } - if (op.getAmounts() == null || op.getAmounts().isEmpty()) { - throw new IllegalArgumentException("pay_to_address requires 'amounts'"); - } - if (op.getScriptRefCborHex() != null && !op.getScriptRefCborHex().isEmpty()) { - Script refScript = parseScriptRef(op.getScriptRefCborHex(), op.getScriptRefType()); - tx.payToAddress(op.getAddress(), op.getAmounts(), refScript); - } else { - tx.payToAddress(op.getAddress(), op.getAmounts()); - } - } - - private static void applyPayToContract(Tx tx, TxOperation op) { - if (op.getAddress() == null) { - throw new IllegalArgumentException("pay_to_contract requires 'address'"); - } - if (op.getAmounts() == null || op.getAmounts().isEmpty()) { - throw new IllegalArgumentException("pay_to_contract requires 'amounts'"); - } - - Script refScript = null; - if (op.getScriptRefCborHex() != null && !op.getScriptRefCborHex().isEmpty()) { - refScript = parseScriptRef(op.getScriptRefCborHex(), op.getScriptRefType()); - } - - if (op.getDatumCborHex() != null && !op.getDatumCborHex().isEmpty()) { - PlutusData datum; - try { - datum = PlutusData.deserialize(HexUtil.decodeHexString(op.getDatumCborHex())); - } catch (Exception e) { - throw new IllegalArgumentException("Invalid datum CBOR: " + e.getMessage(), e); - } - if (refScript != null) { - tx.payToContract(op.getAddress(), op.getAmounts(), datum, refScript); - } else { - tx.payToContract(op.getAddress(), op.getAmounts(), datum); - } - } else if (op.getDatumHash() != null && !op.getDatumHash().isEmpty()) { - tx.payToContract(op.getAddress(), op.getAmounts(), op.getDatumHash()); - } else { - throw new IllegalArgumentException("pay_to_contract requires 'datum_cbor_hex' or 'datum_hash'"); - } - } - - private static void applyMintAssets(Tx tx, TxOperation op) { - if (op.getScriptJson() == null) { - throw new IllegalArgumentException("mint_assets requires 'script_json'"); - } - if (op.getAssets() == null || op.getAssets().isEmpty()) { - throw new IllegalArgumentException("mint_assets requires 'assets'"); - } - if (op.getReceiver() == null || op.getReceiver().isEmpty()) { - throw new IllegalArgumentException("mint_assets requires 'receiver' address"); - } - - NativeScript script; - try { - script = NativeScript.deserializeJson(op.getScriptJson()); - } catch (Exception e) { - throw new IllegalArgumentException("Invalid native script JSON: " + e.getMessage(), e); - } - - List assets = new ArrayList<>(); - for (TxOperation.MintAsset ma : op.getAssets()) { - assets.add(new Asset(ma.getName(), new BigInteger(ma.getQuantity()))); - } - - tx.mintAssets(script, assets, op.getReceiver()); - } - - @SuppressWarnings("unchecked") - private static void applyAttachMetadata(Tx tx, TxOperation op) { - if (op.getLabel() == null) { - throw new IllegalArgumentException("attach_metadata requires 'label'"); - } - if (op.getMetadata() == null) { - throw new IllegalArgumentException("attach_metadata requires 'metadata'"); - } - - CBORMetadata cborMetadata = new CBORMetadata(); - BigInteger label = BigInteger.valueOf(op.getLabel()); - Object metaValue = op.getMetadata(); - - if (metaValue instanceof String) { - cborMetadata.put(label, (String) metaValue); - } else if (metaValue instanceof Number) { - cborMetadata.put(label, BigInteger.valueOf(((Number) metaValue).longValue())); - } else if (metaValue instanceof List) { - CBORMetadataList list = buildMetadataList((List) metaValue); - cborMetadata.put(label, list); - } else if (metaValue instanceof Map) { - CBORMetadataMap map = buildMetadataMap((Map) metaValue); - cborMetadata.put(label, map); - } else { - throw new IllegalArgumentException("Unsupported metadata value type: " + - (metaValue != null ? metaValue.getClass().getName() : "null")); - } - - tx.attachMetadata(cborMetadata); - } - - @SuppressWarnings("unchecked") - private static CBORMetadataList buildMetadataList(List items) { - CBORMetadataList list = new CBORMetadataList(); - for (Object item : items) { - if (item instanceof String) { - list.add((String) item); - } else if (item instanceof Number) { - list.add(BigInteger.valueOf(((Number) item).longValue())); - } else if (item instanceof List) { - list.add(buildMetadataList((List) item)); - } else if (item instanceof Map) { - list.add(buildMetadataMap((Map) item)); - } - } - return list; - } - - @SuppressWarnings("unchecked") - private static CBORMetadataMap buildMetadataMap(Map map) { - CBORMetadataMap metaMap = new CBORMetadataMap(); - for (Map.Entry entry : map.entrySet()) { - String key = entry.getKey(); - Object value = entry.getValue(); - if (value instanceof String) { - metaMap.put(key, (String) value); - } else if (value instanceof Number) { - metaMap.put(key, BigInteger.valueOf(((Number) value).longValue())); - } else if (value instanceof List) { - metaMap.put(key, buildMetadataList((List) value)); - } else if (value instanceof Map) { - metaMap.put(key, buildMetadataMap((Map) value)); - } - } - return metaMap; - } - - private static void applyCollectFrom(Tx tx, TxOperation op) { - if (op.getCollectUtxos() == null || op.getCollectUtxos().isEmpty()) { - throw new IllegalArgumentException("collect_from requires 'collect_utxos'"); - } - tx.collectFrom(op.getCollectUtxos()); - } - - // --- Staking --- - - private static void applyRegisterStakeAddress(Tx tx, TxOperation op) { - if (op.getAddress() == null) { - throw new IllegalArgumentException("register_stake_address requires 'address'"); - } - tx.registerStakeAddress(op.getAddress()); - } - - private static void applyDeregisterStakeAddress(Tx tx, TxOperation op) { - if (op.getAddress() == null) { - throw new IllegalArgumentException("deregister_stake_address requires 'address'"); - } - if (op.getRefundAddress() != null && !op.getRefundAddress().isEmpty()) { - tx.deregisterStakeAddress(op.getAddress(), op.getRefundAddress()); - } else { - tx.deregisterStakeAddress(op.getAddress()); - } - } - - private static void applyDelegateTo(Tx tx, TxOperation op) { - if (op.getAddress() == null) { - throw new IllegalArgumentException("delegate_to requires 'address'"); - } - if (op.getPoolId() == null || op.getPoolId().isEmpty()) { - throw new IllegalArgumentException("delegate_to requires 'pool_id'"); - } - tx.delegateTo(op.getAddress(), op.getPoolId()); - } - - private static void applyWithdraw(Tx tx, TxOperation op) { - if (op.getRewardAddress() == null) { - throw new IllegalArgumentException("withdraw requires 'reward_address'"); - } - if (op.getAmount() == null) { - throw new IllegalArgumentException("withdraw requires 'amount'"); - } - BigInteger amount = new BigInteger(op.getAmount()); - if (op.getReceiver() != null && !op.getReceiver().isEmpty()) { - tx.withdraw(op.getRewardAddress(), amount, op.getReceiver()); - } else { - tx.withdraw(op.getRewardAddress(), amount); - } - } - - // --- DRep --- - - private static void applyRegisterDRep(Tx tx, TxOperation op) { - if (op.getCredentialHash() == null) { - throw new IllegalArgumentException("register_drep requires 'credential_hash'"); - } - Credential credential = parseCredential(op.getCredentialHash(), op.getCredentialType()); - Anchor anchor = parseAnchor(op.getAnchorUrl(), op.getAnchorDataHash()); - if (anchor != null) { - tx.registerDRep(credential, anchor); - } else { - tx.registerDRep(credential); - } - } - - private static void applyUnregisterDRep(Tx tx, TxOperation op) { - if (op.getCredentialHash() == null) { - throw new IllegalArgumentException("unregister_drep requires 'credential_hash'"); - } - Credential credential = parseCredential(op.getCredentialHash(), op.getCredentialType()); - if (op.getRefundAmount() != null && !op.getRefundAmount().isEmpty() - && op.getRefundAddress() != null && !op.getRefundAddress().isEmpty()) { - tx.unregisterDRep(credential, op.getRefundAddress(), new BigInteger(op.getRefundAmount())); - } else if (op.getRefundAddress() != null && !op.getRefundAddress().isEmpty()) { - tx.unregisterDRep(credential, op.getRefundAddress()); - } else { - tx.unregisterDRep(credential); - } - } - - private static void applyUpdateDRep(Tx tx, TxOperation op) { - if (op.getCredentialHash() == null) { - throw new IllegalArgumentException("update_drep requires 'credential_hash'"); - } - Credential credential = parseCredential(op.getCredentialHash(), op.getCredentialType()); - Anchor anchor = parseAnchor(op.getAnchorUrl(), op.getAnchorDataHash()); - if (anchor != null) { - tx.updateDRep(credential, anchor); - } else { - tx.updateDRep(credential); - } - } - - // --- Voting --- - - private static void applyDelegateVotingPowerTo(Tx tx, TxOperation op) { - if (op.getAddress() == null) { - throw new IllegalArgumentException("delegate_voting_power_to requires 'address'"); - } - if (op.getDrepType() == null) { - throw new IllegalArgumentException("delegate_voting_power_to requires 'drep_type'"); - } - DRep drep = parseDRep(op.getDrepType(), op.getDrepHash()); - tx.delegateVotingPowerTo(op.getAddress(), drep); - } - - private static void applyCreateVote(Tx tx, TxOperation op) { - if (op.getVoterType() == null) { - throw new IllegalArgumentException("create_vote requires 'voter_type'"); - } - if (op.getVoterHash() == null) { - throw new IllegalArgumentException("create_vote requires 'voter_hash'"); - } - if (op.getGovActionTxHash() == null) { - throw new IllegalArgumentException("create_vote requires 'gov_action_tx_hash'"); - } - if (op.getGovActionIndex() == null) { - throw new IllegalArgumentException("create_vote requires 'gov_action_index'"); - } - if (op.getVote() == null) { - throw new IllegalArgumentException("create_vote requires 'vote'"); - } - - Voter voter = parseVoter(op.getVoterType(), op.getVoterHash()); - GovActionId govActionId = new GovActionId(op.getGovActionTxHash(), op.getGovActionIndex()); - Vote vote = parseVote(op.getVote()); - Anchor anchor = parseAnchor(op.getAnchorUrl(), op.getAnchorDataHash()); - - if (anchor != null) { - tx.createVote(voter, govActionId, vote, anchor); - } else { - tx.createVote(voter, govActionId, vote); - } - } - - // --- Governance proposals --- - - private static void applyCreateProposal(Tx tx, TxOperation op) { - if (op.getGovActionType() == null) { - throw new IllegalArgumentException("create_proposal requires 'gov_action_type'"); - } - if (op.getReturnAddress() == null) { - throw new IllegalArgumentException("create_proposal requires 'return_address'"); - } - - Anchor anchor = parseAnchor(op.getAnchorUrl(), op.getAnchorDataHash()); - - switch (op.getGovActionType().toLowerCase()) { - case "info_action": { - InfoAction action = InfoAction.builder().build(); - tx.createProposal(action, op.getReturnAddress(), anchor); - break; - } - case "treasury_withdrawals": { - TreasuryWithdrawalsAction action = TreasuryWithdrawalsAction.builder().build(); - if (op.getWithdrawals() != null) { - for (Map w : op.getWithdrawals()) { - String rewardAddr = w.get("reward_address"); - String amt = w.get("amount"); - if (rewardAddr == null || amt == null) { - throw new IllegalArgumentException( - "Each withdrawal requires 'reward_address' and 'amount'"); - } - action.addWithdrawal(new Withdrawal(rewardAddr, new BigInteger(amt))); - } - } - tx.createProposal(action, op.getReturnAddress(), anchor); - break; - } - case "no_confidence": { - NoConfidence.NoConfidenceBuilder builder = NoConfidence.builder(); - GovActionId prevId = parsePrevGovActionId(op.getGovActionTxHash(), op.getGovActionIndex()); - if (prevId != null) builder.prevGovActionId(prevId); - tx.createProposal(builder.build(), op.getReturnAddress(), anchor); - break; - } - case "update_committee": { - UpdateCommittee.UpdateCommitteeBuilder builder = UpdateCommittee.builder(); - GovActionId prevId = parsePrevGovActionId(op.getGovActionTxHash(), op.getGovActionIndex()); - if (prevId != null) builder.prevGovActionId(prevId); - if (op.getMembersToRemove() != null) { - Set removals = new LinkedHashSet<>(); - for (Map m : op.getMembersToRemove()) { - removals.add(parseCredential(m.get("hash"), m.get("type"))); - } - builder.membersForRemoval(removals); - } - if (op.getNewMembers() != null) { - Map newMembersMap = new LinkedHashMap<>(); - for (Map m : op.getNewMembers()) { - Credential cred = parseCredential((String) m.get("hash"), (String) m.get("type")); - int epoch = ((Number) m.get("epoch")).intValue(); - newMembersMap.put(cred, epoch); - } - builder.newMembersAndTerms(newMembersMap); - } - if (op.getQuorumNumerator() != null && op.getQuorumDenominator() != null) { - builder.quorumThreshold(new UnitInterval( - new BigInteger(op.getQuorumNumerator()), - new BigInteger(op.getQuorumDenominator()))); - } - tx.createProposal(builder.build(), op.getReturnAddress(), anchor); - break; - } - case "new_constitution": { - NewConstitution.NewConstitutionBuilder builder = NewConstitution.builder(); - GovActionId prevId = parsePrevGovActionId(op.getGovActionTxHash(), op.getGovActionIndex()); - if (prevId != null) builder.prevGovActionId(prevId); - Anchor constitutionAnchor = parseAnchor(op.getConstitutionAnchorUrl(), op.getConstitutionAnchorDataHash()); - Constitution.ConstitutionBuilder cBuilder = Constitution.builder(); - if (constitutionAnchor != null) cBuilder.anchor(constitutionAnchor); - if (op.getConstitutionScriptHash() != null) cBuilder.scripthash(op.getConstitutionScriptHash()); - builder.constitution(cBuilder.build()); - tx.createProposal(builder.build(), op.getReturnAddress(), anchor); - break; - } - case "hard_fork_initiation": { - HardForkInitiationAction.HardForkInitiationActionBuilder builder = HardForkInitiationAction.builder(); - GovActionId prevId = parsePrevGovActionId(op.getGovActionTxHash(), op.getGovActionIndex()); - if (prevId != null) builder.prevGovActionId(prevId); - if (op.getProtocolVersionMajor() == null || op.getProtocolVersionMinor() == null) { - throw new IllegalArgumentException( - "hard_fork_initiation requires 'protocol_version_major' and 'protocol_version_minor'"); - } - builder.protocolVersion(new ProtocolVersion(op.getProtocolVersionMajor(), op.getProtocolVersionMinor())); - tx.createProposal(builder.build(), op.getReturnAddress(), anchor); - break; - } - case "parameter_change": { - ParameterChangeAction.ParameterChangeActionBuilder builder = ParameterChangeAction.builder(); - GovActionId prevId = parsePrevGovActionId(op.getGovActionTxHash(), op.getGovActionIndex()); - if (prevId != null) builder.prevGovActionId(prevId); - if (op.getPolicyHash() != null && !op.getPolicyHash().isEmpty()) { - builder.policyHash(HexUtil.decodeHexString(op.getPolicyHash())); - } - // ProtocolParamUpdate is complex — pass as CBOR hex if provided - if (op.getProtocolParamUpdateJson() != null && !op.getProtocolParamUpdateJson().isEmpty()) { - throw new IllegalArgumentException( - "parameter_change protocol_param_update_json is not yet supported. " + - "Use the raw transaction builder for parameter change proposals."); - } - tx.createProposal(builder.build(), op.getReturnAddress(), anchor); - break; - } - default: - throw new IllegalArgumentException( - "Unsupported gov_action_type: " + op.getGovActionType()); - } - } - - // --- Pool operations (Gap 3) --- - - private static void applyRegisterPool(Tx tx, TxOperation op) { - tx.registerPool(buildPoolRegistration(op)); - } - - private static void applyUpdatePool(Tx tx, TxOperation op) { - tx.updatePool(buildPoolRegistration(op)); - } - - private static PoolRegistration buildPoolRegistration(TxOperation op) { - if (op.getOperator() == null) throw new IllegalArgumentException("Pool operation requires 'operator'"); - if (op.getVrfKeyHash() == null) throw new IllegalArgumentException("Pool operation requires 'vrf_key_hash'"); - if (op.getPledge() == null) throw new IllegalArgumentException("Pool operation requires 'pledge'"); - if (op.getCost() == null) throw new IllegalArgumentException("Pool operation requires 'cost'"); - if (op.getMarginNumerator() == null || op.getMarginDenominator() == null) { - throw new IllegalArgumentException("Pool operation requires 'margin_numerator' and 'margin_denominator'"); - } - if (op.getRewardAddress() == null) throw new IllegalArgumentException("Pool operation requires 'reward_address'"); - if (op.getPoolOwners() == null || op.getPoolOwners().isEmpty()) { - throw new IllegalArgumentException("Pool operation requires 'pool_owners'"); - } - - PoolRegistration.PoolRegistrationBuilder builder = PoolRegistration.builder() - .operator(HexUtil.decodeHexString(op.getOperator())) - .vrfKeyHash(HexUtil.decodeHexString(op.getVrfKeyHash())) - .pledge(new BigInteger(op.getPledge())) - .cost(new BigInteger(op.getCost())) - .margin(new UnitInterval(new BigInteger(op.getMarginNumerator()), new BigInteger(op.getMarginDenominator()))) - .rewardAccount(op.getRewardAddress()) - .poolOwners(new LinkedHashSet<>(op.getPoolOwners())); - - if (op.getRelays() != null) { - builder.relays(parseRelays(op.getRelays())); - } - if (op.getPoolMetadataUrl() != null) { - builder.poolMetadataUrl(op.getPoolMetadataUrl()); - } - if (op.getPoolMetadataHash() != null) { - builder.poolMetadataHash(op.getPoolMetadataHash()); - } - - return builder.build(); - } - - @SuppressWarnings("unchecked") - private static List parseRelays(List> relayList) { - List relays = new ArrayList<>(); - for (Map r : relayList) { - String type = (String) r.get("type"); - if (type == null) throw new IllegalArgumentException("Relay requires 'type'"); - switch (type.toLowerCase()) { - case "single_host_addr": { - SingleHostAddr.SingleHostAddrBuilder b = SingleHostAddr.builder(); - if (r.get("port") != null) b.port(((Number) r.get("port")).intValue()); - if (r.get("ipv4") != null) { - try { - b.ipv4((Inet4Address) Inet4Address.getByName((String) r.get("ipv4"))); - } catch (UnknownHostException e) { - throw new IllegalArgumentException("Invalid ipv4: " + r.get("ipv4"), e); - } - } - if (r.get("ipv6") != null) { - try { - b.ipv6((Inet6Address) Inet6Address.getByName((String) r.get("ipv6"))); - } catch (UnknownHostException e) { - throw new IllegalArgumentException("Invalid ipv6: " + r.get("ipv6"), e); - } - } - relays.add(b.build()); - break; - } - case "single_host_name": { - SingleHostName.SingleHostNameBuilder b = SingleHostName.builder(); - if (r.get("port") != null) b.port(((Number) r.get("port")).intValue()); - b.dnsName((String) r.get("dns_name")); - relays.add(b.build()); - break; - } - case "multi_host_name": { - relays.add(MultiHostName.builder().dnsName((String) r.get("dns_name")).build()); - break; - } - default: - throw new IllegalArgumentException("Unknown relay type: " + type); - } - } - return relays; - } - - private static void applyRetirePool(Tx tx, TxOperation op) { - if (op.getPoolId() == null) throw new IllegalArgumentException("retire_pool requires 'pool_id'"); - if (op.getEpoch() == null) throw new IllegalArgumentException("retire_pool requires 'epoch'"); - tx.retirePool(op.getPoolId(), op.getEpoch()); - } - - // --- Treasury donation (Gap 4) --- - - private static void applyDonateToTreasury(Tx tx, TxOperation op) { - if (op.getTreasuryValue() == null) { - throw new IllegalArgumentException("donate_to_treasury requires 'treasury_value'"); - } - if (op.getDonationAmount() == null) { - throw new IllegalArgumentException("donate_to_treasury requires 'donation_amount'"); - } - tx.donateToTreasury(new BigInteger(op.getTreasuryValue()), new BigInteger(op.getDonationAmount())); - } - - // --- Attach native script standalone (Gap 5) --- - - private static void applyAttachNativeScript(Tx tx, TxOperation op) { - if (op.getScriptJson() == null) { - throw new IllegalArgumentException("attach_native_script requires 'script_json'"); - } - NativeScript script; - try { - script = NativeScript.deserializeJson(op.getScriptJson()); - } catch (Exception e) { - throw new IllegalArgumentException("Invalid native script JSON: " + e.getMessage(), e); - } - tx.attachNativeScript(script); - } - - // --- Helper methods --- - - private static GovActionId parsePrevGovActionId(String txHash, Integer index) { - if (txHash == null || txHash.isEmpty()) return null; - return new GovActionId(txHash, index != null ? index : 0); - } - - private static Script parseScriptRef(String cborHex, String scriptRefType) { - if (scriptRefType == null || scriptRefType.isEmpty()) { - throw new IllegalArgumentException("script_ref_type is required when script_ref_cbor_hex is provided. " + - "Supported: plutus_v1, plutus_v2, plutus_v3"); - } - return switch (scriptRefType.toLowerCase()) { - case "plutus_v1" -> PlutusV1Script.builder().cborHex(cborHex).build(); - case "plutus_v2" -> PlutusV2Script.builder().cborHex(cborHex).build(); - case "plutus_v3" -> PlutusV3Script.builder().cborHex(cborHex).build(); - default -> throw new IllegalArgumentException("Unsupported script_ref_type: " + scriptRefType - + ". Supported: plutus_v1, plutus_v2, plutus_v3"); - }; - } - - private static Credential parseCredential(String hash, String type) { - if ("script".equalsIgnoreCase(type)) { - return Credential.fromScript(hash); - } - return Credential.fromKey(hash); - } - - private static Anchor parseAnchor(String url, String dataHash) { - if (url == null || dataHash == null) return null; - return Anchor.builder() - .anchorUrl(url) - .anchorDataHash(HexUtil.decodeHexString(dataHash)) - .build(); - } - - private static DRep parseDRep(String drepType, String drepHash) { - return switch (drepType.toLowerCase()) { - case "abstain" -> DRep.abstain(); - case "no_confidence" -> DRep.noConfidence(); - case "script_hash" -> DRep.scriptHash(drepHash); - default -> DRep.addrKeyHash(drepHash); // key_hash - }; - } - - private static Vote parseVote(String voteStr) { - return switch (voteStr.toLowerCase()) { - case "yes" -> Vote.YES; - case "no" -> Vote.NO; - case "abstain" -> Vote.ABSTAIN; - default -> throw new IllegalArgumentException("Invalid vote value: " + voteStr - + ". Must be 'yes', 'no', or 'abstain'"); - }; - } - - private static Voter parseVoter(String voterType, String voterHash) { - Credential credential = Credential.fromKey(voterHash); - VoterType type = switch (voterType.toLowerCase()) { - case "drep_key_hash" -> VoterType.DREP_KEY_HASH; - case "drep_script_hash" -> { - credential = Credential.fromScript(voterHash); - yield VoterType.DREP_SCRIPT_HASH; - } - case "staking_pool_key_hash" -> VoterType.STAKING_POOL_KEY_HASH; - case "constitutional_committee_hot_key_hash" -> - VoterType.CONSTITUTIONAL_COMMITTEE_HOT_KEY_HASH; - case "constitutional_committee_hot_script_hash" -> { - credential = Credential.fromScript(voterHash); - yield VoterType.CONSTITUTIONAL_COMMITTEE_HOT_SCRIPT_HASH; - } - default -> throw new IllegalArgumentException("Invalid voter_type: " + voterType); - }; - return new Voter(type, credential); - } -} diff --git a/core/src/main/java/com/bloxbean/cardano/bridge/api/quicktx/YaciProtocolParamsSupplier.java b/core/src/main/java/com/bloxbean/cardano/bridge/api/quicktx/YaciProtocolParamsSupplier.java deleted file mode 100644 index c426807..0000000 --- a/core/src/main/java/com/bloxbean/cardano/bridge/api/quicktx/YaciProtocolParamsSupplier.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.bloxbean.cardano.bridge.api.quicktx; - -import com.bloxbean.cardano.client.api.ProtocolParamsSupplier; -import com.bloxbean.cardano.client.api.model.ProtocolParams; -import com.fasterxml.jackson.databind.ObjectMapper; - -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; - -public class YaciProtocolParamsSupplier implements ProtocolParamsSupplier { - - private final HttpClient httpClient; - private final String baseUrl; - private final ObjectMapper mapper; - - public YaciProtocolParamsSupplier(String baseUrl) { - this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl; - this.httpClient = HttpClient.newHttpClient(); - this.mapper = new ObjectMapper(); - } - - @Override - public ProtocolParams getProtocolParams() { - String url = baseUrl + "/epochs/parameters"; - - try { - HttpRequest req = HttpRequest.newBuilder() - .uri(URI.create(url)) - .GET() - .build(); - HttpResponse resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString()); - - if (resp.statusCode() != 200) { - throw new RuntimeException("Failed to fetch protocol params: HTTP " + resp.statusCode()); - } - - return mapper.readValue(resp.body(), ProtocolParams.class); - } catch (RuntimeException e) { - throw e; - } catch (Exception e) { - throw new RuntimeException("Failed to fetch protocol params from provider: " + e.getMessage(), e); - } - } -} diff --git a/core/src/main/java/com/bloxbean/cardano/bridge/api/quicktx/YaciTransactionEvaluator.java b/core/src/main/java/com/bloxbean/cardano/bridge/api/quicktx/YaciTransactionEvaluator.java deleted file mode 100644 index 9b827d1..0000000 --- a/core/src/main/java/com/bloxbean/cardano/bridge/api/quicktx/YaciTransactionEvaluator.java +++ /dev/null @@ -1,134 +0,0 @@ -package com.bloxbean.cardano.bridge.api.quicktx; - -import com.bloxbean.cardano.client.api.TransactionEvaluator; -import com.bloxbean.cardano.client.api.exception.ApiException; -import com.bloxbean.cardano.client.api.model.EvaluationResult; -import com.bloxbean.cardano.client.api.model.Result; -import com.bloxbean.cardano.client.api.model.Utxo; -import com.bloxbean.cardano.client.plutus.spec.ExUnits; -import com.bloxbean.cardano.client.plutus.spec.RedeemerTag; -import com.bloxbean.cardano.client.util.HexUtil; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; - -import java.math.BigInteger; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.util.ArrayList; -import java.util.Iterator; -import java.util.List; -import java.util.Map; -import java.util.Set; - -/** - * TransactionEvaluator that uses Yaci Store / Blockfrost-compatible - * evaluate endpoint for script cost evaluation. - * - * POST {baseUrl}/utils/txs/evaluate - * Content-Type: application/cbor - * Body: hex-encoded transaction CBOR - * - * Response: Ogmios JSON format with EvaluationResult map. - */ -public class YaciTransactionEvaluator implements TransactionEvaluator { - - private final String baseUrl; - private final HttpClient httpClient; - private final ObjectMapper objectMapper; - - public YaciTransactionEvaluator(String baseUrl) { - this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl; - this.httpClient = HttpClient.newHttpClient(); - this.objectMapper = new ObjectMapper(); - } - - @Override - public Result> evaluateTx(byte[] cbor, Set inputUtxos) throws ApiException { - return evaluateTx(cbor); - } - - @Override - public Result> evaluateTx(byte[] cbor) throws ApiException { - try { - String txCborHex = HexUtil.encodeHexString(cbor); - String url = baseUrl + "/utils/txs/evaluate"; - - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(url)) - .header("Content-Type", "application/cbor") - .POST(HttpRequest.BodyPublishers.ofString(txCborHex)) - .build(); - - HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - - if (response.statusCode() != 200) { - return Result.>error("Evaluation failed: " + response.body()) - .code(response.statusCode()); - } - - List results = parseResponse(response.body()); - return Result.>success(response.body()) - .code(200) - .withValue(results); - - } catch (Exception e) { - throw new ApiException("Failed to evaluate transaction: " + e.getMessage(), e); - } - } - - private List parseResponse(String responseBody) throws Exception { - List results = new ArrayList<>(); - JsonNode root = objectMapper.readTree(responseBody); - - // Ogmios format: { "type": "jsonwsp/response", "result": { "EvaluationResult": { "spend:0": {...}, ... } } } - JsonNode evalResult = root.path("result").path("EvaluationResult"); - if (evalResult.isMissingNode()) { - // Try alternative format: direct result - evalResult = root.path("result"); - } - if (evalResult.isMissingNode() || !evalResult.isObject()) { - return results; - } - - Iterator> fields = evalResult.fields(); - while (fields.hasNext()) { - Map.Entry entry = fields.next(); - String key = entry.getKey(); // e.g., "spend:0", "mint:0" - JsonNode value = entry.getValue(); - - String[] parts = key.split(":"); - if (parts.length != 2) continue; - - RedeemerTag tag = parseRedeemerTag(parts[0]); - int index = Integer.parseInt(parts[1]); - - long memory = value.path("memory").asLong(); - long steps = value.path("steps").asLong(); - - results.add(EvaluationResult.builder() - .redeemerTag(tag) - .index(index) - .exUnits(ExUnits.builder() - .mem(BigInteger.valueOf(memory)) - .steps(BigInteger.valueOf(steps)) - .build()) - .build()); - } - - return results; - } - - private RedeemerTag parseRedeemerTag(String tag) { - return switch (tag.toLowerCase()) { - case "spend" -> RedeemerTag.Spend; - case "mint" -> RedeemerTag.Mint; - case "cert", "publish", "certificate" -> RedeemerTag.Cert; - case "reward", "withdrawal", "withdraw" -> RedeemerTag.Reward; - case "vote", "voting" -> RedeemerTag.Voting; - case "propose", "proposing" -> RedeemerTag.Proposing; - default -> throw new IllegalArgumentException("Unknown redeemer tag: " + tag); - }; - } -} diff --git a/core/src/main/java/com/bloxbean/cardano/bridge/api/quicktx/YaciUtxoSupplier.java b/core/src/main/java/com/bloxbean/cardano/bridge/api/quicktx/YaciUtxoSupplier.java deleted file mode 100644 index 9194a91..0000000 --- a/core/src/main/java/com/bloxbean/cardano/bridge/api/quicktx/YaciUtxoSupplier.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.bloxbean.cardano.bridge.api.quicktx; - -import com.bloxbean.cardano.client.api.UtxoSupplier; -import com.bloxbean.cardano.client.api.common.OrderEnum; -import com.bloxbean.cardano.client.api.model.Utxo; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; - -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.util.Collections; -import java.util.List; -import java.util.Optional; - -public class YaciUtxoSupplier implements UtxoSupplier { - - private static final TypeReference> UTXO_LIST_TYPE = new TypeReference<>() {}; - - private final HttpClient httpClient; - private final String baseUrl; - private final ObjectMapper mapper; - - public YaciUtxoSupplier(String baseUrl) { - this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl; - this.httpClient = HttpClient.newHttpClient(); - this.mapper = new ObjectMapper(); - } - - @Override - public List getPage(String address, Integer nrOfItems, Integer page, OrderEnum order) { - int pageNum = (page != null) ? page : 0; - int count = (nrOfItems != null && nrOfItems > 0) ? nrOfItems : DEFAULT_NR_OF_ITEMS_TO_FETCH; - String orderStr = (order == OrderEnum.desc) ? "desc" : "asc"; - - String url = baseUrl + "/addresses/" + address + "/utxos" - + "?page=" + pageNum + "&count=" + count + "&order=" + orderStr; - - try { - HttpRequest req = HttpRequest.newBuilder() - .uri(URI.create(url)) - .GET() - .build(); - HttpResponse resp = httpClient.send(req, HttpResponse.BodyHandlers.ofString()); - - if (resp.statusCode() != 200) { - return Collections.emptyList(); - } - - return mapper.readValue(resp.body(), UTXO_LIST_TYPE); - } catch (Exception e) { - throw new RuntimeException("Failed to fetch UTXOs from provider: " + e.getMessage(), e); - } - } - - @Override - public Optional getTxOutput(String txHash, int outputIndex) { - // Not needed for coin selection; return empty - return Optional.empty(); - } -} diff --git a/core/src/main/resources/META-INF/native-image/com.bloxbean.cardano/ccl-bridge/native-image.properties b/core/src/main/resources/META-INF/native-image/com.bloxbean.cardano/ccl-bridge/native-image.properties index eec555e..42f02f8 100644 --- a/core/src/main/resources/META-INF/native-image/com.bloxbean.cardano/ccl-bridge/native-image.properties +++ b/core/src/main/resources/META-INF/native-image/com.bloxbean.cardano/ccl-bridge/native-image.properties @@ -1,4 +1,9 @@ +# -march=compatibility: emit only the baseline instruction set common to all CPUs of the target +# architecture, instead of optimizing for the build machine's CPU (the default). Without it a lib +# built on a modern CI runner can SIGILL on older / datacenter CPUs that lack newer instruction +# sets (AVX2/AVX-512). This is the CPU-portability counterpart to the glibc-baseline build. Args = --no-fallback \ + -march=compatibility \ -H:+ReportExceptionStackTraces \ --initialize-at-build-time=org.slf4j \ --initialize-at-build-time=com.bloxbean.cardano.client.crypto \ diff --git a/core/src/main/resources/META-INF/native-image/com.bloxbean.cardano/ccl-bridge/reflect-config.json b/core/src/main/resources/META-INF/native-image/com.bloxbean.cardano/ccl-bridge/reflect-config.json index 853d77b..35bea32 100644 --- a/core/src/main/resources/META-INF/native-image/com.bloxbean.cardano/ccl-bridge/reflect-config.json +++ b/core/src/main/resources/META-INF/native-image/com.bloxbean.cardano/ccl-bridge/reflect-config.json @@ -448,5 +448,533 @@ "allDeclaredConstructors": true, "allDeclaredMethods": true, "allDeclaredFields": true + }, + { + "name": "com.bloxbean.cardano.client.quicktx.intent.CollectFromIntent", + "allDeclaredConstructors": true, + "allDeclaredFields": true, + "allDeclaredMethods": true + }, + { + "name": "com.bloxbean.cardano.client.quicktx.intent.DRepDeregistrationIntent", + "allDeclaredConstructors": true, + "allDeclaredFields": true, + "allDeclaredMethods": true + }, + { + "name": "com.bloxbean.cardano.client.quicktx.intent.DRepRegistrationIntent", + "allDeclaredConstructors": true, + "allDeclaredFields": true, + "allDeclaredMethods": true + }, + { + "name": "com.bloxbean.cardano.client.quicktx.intent.DRepUpdateIntent", + "allDeclaredConstructors": true, + "allDeclaredFields": true, + "allDeclaredMethods": true + }, + { + "name": "com.bloxbean.cardano.client.quicktx.intent.DepositHelper", + "allDeclaredConstructors": true, + "allDeclaredFields": true, + "allDeclaredMethods": true + }, + { + "name": "com.bloxbean.cardano.client.quicktx.intent.DonationIntent", + "allDeclaredConstructors": true, + "allDeclaredFields": true, + "allDeclaredMethods": true + }, + { + "name": "com.bloxbean.cardano.client.quicktx.intent.GovernanceProposalIntent", + "allDeclaredConstructors": true, + "allDeclaredFields": true, + "allDeclaredMethods": true + }, + { + "name": "com.bloxbean.cardano.client.quicktx.intent.MetadataIntent", + "allDeclaredConstructors": true, + "allDeclaredFields": true, + "allDeclaredMethods": true + }, + { + "name": "com.bloxbean.cardano.client.quicktx.intent.MintingIntent", + "allDeclaredConstructors": true, + "allDeclaredFields": true, + "allDeclaredMethods": true + }, + { + "name": "com.bloxbean.cardano.client.quicktx.intent.NativeScriptAttachmentIntent", + "allDeclaredConstructors": true, + "allDeclaredFields": true, + "allDeclaredMethods": true + }, + { + "name": "com.bloxbean.cardano.client.quicktx.intent.PaymentIntent", + "allDeclaredConstructors": true, + "allDeclaredFields": true, + "allDeclaredMethods": true + }, + { + "name": "com.bloxbean.cardano.client.quicktx.intent.PoolRegistrationIntent", + "allDeclaredConstructors": true, + "allDeclaredFields": true, + "allDeclaredMethods": true + }, + { + "name": "com.bloxbean.cardano.client.quicktx.intent.PoolRetirementIntent", + "allDeclaredConstructors": true, + "allDeclaredFields": true, + "allDeclaredMethods": true + }, + { + "name": "com.bloxbean.cardano.client.quicktx.intent.ReferenceInputIntent", + "allDeclaredConstructors": true, + "allDeclaredFields": true, + "allDeclaredMethods": true + }, + { + "name": "com.bloxbean.cardano.client.quicktx.intent.ScriptCollectFromIntent", + "allDeclaredConstructors": true, + "allDeclaredFields": true, + "allDeclaredMethods": true + }, + { + "name": "com.bloxbean.cardano.client.quicktx.intent.ScriptMintingIntent", + "allDeclaredConstructors": true, + "allDeclaredFields": true, + "allDeclaredMethods": true + }, + { + "name": "com.bloxbean.cardano.client.quicktx.intent.ScriptValidatorAttachmentIntent", + "allDeclaredConstructors": true, + "allDeclaredFields": true, + "allDeclaredMethods": true + }, + { + "name": "com.bloxbean.cardano.client.quicktx.intent.StakeDelegationIntent", + "allDeclaredConstructors": true, + "allDeclaredFields": true, + "allDeclaredMethods": true + }, + { + "name": "com.bloxbean.cardano.client.quicktx.intent.StakeDeregistrationIntent", + "allDeclaredConstructors": true, + "allDeclaredFields": true, + "allDeclaredMethods": true + }, + { + "name": "com.bloxbean.cardano.client.quicktx.intent.StakeRegistrationIntent", + "allDeclaredConstructors": true, + "allDeclaredFields": true, + "allDeclaredMethods": true + }, + { + "name": "com.bloxbean.cardano.client.quicktx.intent.StakeWithdrawalIntent", + "allDeclaredConstructors": true, + "allDeclaredFields": true, + "allDeclaredMethods": true + }, + { + "name": "com.bloxbean.cardano.client.quicktx.intent.TxInputIntent", + "allDeclaredConstructors": true, + "allDeclaredFields": true, + "allDeclaredMethods": true + }, + { + "name": "com.bloxbean.cardano.client.quicktx.intent.TxIntent", + "allDeclaredConstructors": true, + "allDeclaredFields": true, + "allDeclaredMethods": true + }, + { + "name": "com.bloxbean.cardano.client.quicktx.intent.TxScriptAttachmentIntent", + "allDeclaredConstructors": true, + "allDeclaredFields": true, + "allDeclaredMethods": true + }, + { + "name": "com.bloxbean.cardano.client.quicktx.intent.UtxoRef", + "allDeclaredConstructors": true, + "allDeclaredFields": true, + "allDeclaredMethods": true + }, + { + "name": "com.bloxbean.cardano.client.quicktx.intent.VotingDelegationIntent", + "allDeclaredConstructors": true, + "allDeclaredFields": true, + "allDeclaredMethods": true + }, + { + "name": "com.bloxbean.cardano.client.quicktx.intent.VotingIntent", + "allDeclaredConstructors": true, + "allDeclaredFields": true, + "allDeclaredMethods": true + }, + { + "name": "com.bloxbean.cardano.client.quicktx.serialization.MetadataDeserializer", + "allDeclaredConstructors": true, + "allDeclaredFields": true, + "allDeclaredMethods": true + }, + { + "name": "com.bloxbean.cardano.client.quicktx.serialization.MetadataSerializer", + "allDeclaredConstructors": true, + "allDeclaredFields": true, + "allDeclaredMethods": true + }, + { + "name": "com.bloxbean.cardano.client.quicktx.serialization.PlaceholderMetadata", + "allDeclaredConstructors": true, + "allDeclaredFields": true, + "allDeclaredMethods": true + }, + { + "name": "com.bloxbean.cardano.client.quicktx.serialization.PlutusDataYamlUtil", + "allDeclaredConstructors": true, + "allDeclaredFields": true, + "allDeclaredMethods": true + }, + { + "name": "com.bloxbean.cardano.client.quicktx.serialization.TransactionDocument", + "allDeclaredConstructors": true, + "allDeclaredFields": true, + "allDeclaredMethods": true + }, + { + "name": "com.bloxbean.cardano.client.quicktx.serialization.TxPlan", + "allDeclaredConstructors": true, + "allDeclaredFields": true, + "allDeclaredMethods": true + }, + { + "name": "com.bloxbean.cardano.client.quicktx.serialization.VariableResolver", + "allDeclaredConstructors": true, + "allDeclaredFields": true, + "allDeclaredMethods": true + }, + { + "name": "com.bloxbean.cardano.client.quicktx.serialization.YamlSerializer", + "allDeclaredConstructors": true, + "allDeclaredFields": true, + "allDeclaredMethods": true + }, + { + "name": "com.bloxbean.cardano.client.quicktx.serialization.TransactionDocument$TxEntry", + "allDeclaredConstructors": true, + "allDeclaredFields": true, + "allDeclaredMethods": true + }, + { + "name": "com.bloxbean.cardano.client.quicktx.serialization.TransactionDocument$TxContent", + "allDeclaredConstructors": true, + "allDeclaredFields": true, + "allDeclaredMethods": true + }, + { + "name": "com.bloxbean.cardano.client.quicktx.serialization.TransactionDocument$ScriptTxContent", + "allDeclaredConstructors": true, + "allDeclaredFields": true, + "allDeclaredMethods": true + }, + { + "name": "com.bloxbean.cardano.client.quicktx.serialization.TransactionDocument$TxContext", + "allDeclaredConstructors": true, + "allDeclaredFields": true, + "allDeclaredMethods": true + }, + { + "name": "com.bloxbean.cardano.client.quicktx.serialization.TransactionDocument$SignerRef", + "allDeclaredConstructors": true, + "allDeclaredFields": true, + "allDeclaredMethods": true + }, + { + "name": "com.bloxbean.cardano.client.plutus.spec.RedeemerTag", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "name": "com.bloxbean.cardano.client.plutus.spec.PlutusScript", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "name": "com.bloxbean.cardano.client.plutus.spec.Language", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "name": "com.bloxbean.cardano.client.plutus.spec.ExUnitPrices", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "name": "com.bloxbean.cardano.client.plutus.spec.CostMdls", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "name": "com.bloxbean.cardano.client.plutus.spec.CostModel", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "name": "com.bloxbean.cardano.client.plutus.blueprint.model.PlutusVersion", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "name": "com.bloxbean.cardano.client.plutus.spec.serializers.BigIntDataJsonDeserializer", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "name": "com.bloxbean.cardano.client.plutus.spec.serializers.BigIntDataJsonSerializer", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "name": "com.bloxbean.cardano.client.plutus.spec.serializers.BytesDataJsonDeserializer", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "name": "com.bloxbean.cardano.client.plutus.spec.serializers.BytesDataJsonSerializer", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "name": "com.bloxbean.cardano.client.plutus.spec.serializers.ConstrDataJsonDeserializer", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "name": "com.bloxbean.cardano.client.plutus.spec.serializers.ConstrDataJsonSerializer", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "name": "com.bloxbean.cardano.client.plutus.spec.serializers.ListDataJsonDeserializer", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "name": "com.bloxbean.cardano.client.plutus.spec.serializers.ListDataJsonSerializer", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "name": "com.bloxbean.cardano.client.plutus.spec.serializers.MapDataJsonDeserializer", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "name": "com.bloxbean.cardano.client.plutus.spec.serializers.MapDataJsonSerializer", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "name": "com.bloxbean.cardano.client.plutus.spec.serializers.PlutusDataJsonConverter", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "name": "com.bloxbean.cardano.client.plutus.spec.serializers.PlutusDataJsonKeys", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "name": "com.bloxbean.cardano.client.util.serializers.ByteArrayToHexSerializer", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "name": "com.bloxbean.cardano.client.util.serializers.HexToByteArrayDeserializer", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "name": "com.bloxbean.cardano.client.util.serializers.InetAddressDeserializer", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "name": "com.bloxbean.cardano.client.util.serializers.InetAddressSerializer", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "name": "com.bloxbean.cardano.client.transaction.spec.cert.AuthCommitteeHotCert", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "name": "com.bloxbean.cardano.client.transaction.spec.cert.CertificateType", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "name": "com.bloxbean.cardano.client.transaction.spec.cert.GenesisKeyDelegation", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "name": "com.bloxbean.cardano.client.transaction.spec.cert.MirPot", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "name": "com.bloxbean.cardano.client.transaction.spec.cert.MoveInstataneous", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "name": "com.bloxbean.cardano.client.transaction.spec.cert.PoolRetirement", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "name": "com.bloxbean.cardano.client.transaction.spec.cert.RegCert", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "name": "com.bloxbean.cardano.client.transaction.spec.cert.RegDRepCert", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "name": "com.bloxbean.cardano.client.transaction.spec.cert.Relay", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "name": "com.bloxbean.cardano.client.transaction.spec.cert.ResignCommitteeColdCert", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "name": "com.bloxbean.cardano.client.transaction.spec.cert.StakeCredType", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "name": "com.bloxbean.cardano.client.transaction.spec.cert.StakeCredential", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "name": "com.bloxbean.cardano.client.transaction.spec.cert.StakeDelegation", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "name": "com.bloxbean.cardano.client.transaction.spec.cert.StakeDeregistration", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "name": "com.bloxbean.cardano.client.transaction.spec.cert.StakePoolId", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "name": "com.bloxbean.cardano.client.transaction.spec.cert.StakeRegDelegCert", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "name": "com.bloxbean.cardano.client.transaction.spec.cert.StakeRegistration", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "name": "com.bloxbean.cardano.client.transaction.spec.cert.StakeVoteDelegCert", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "name": "com.bloxbean.cardano.client.transaction.spec.cert.StakeVoteRegDelegCert", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "name": "com.bloxbean.cardano.client.transaction.spec.cert.UnregCert", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "name": "com.bloxbean.cardano.client.transaction.spec.cert.UnregDRepCert", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "name": "com.bloxbean.cardano.client.transaction.spec.cert.UpdateDRepCert", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "name": "com.bloxbean.cardano.client.transaction.spec.cert.VoteDelegCert", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "name": "com.bloxbean.cardano.client.transaction.spec.cert.VoteRegDelegCert", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + }, + { + "name": "com.bloxbean.cardano.client.spec.UnitInterval", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true } -] +] \ No newline at end of file diff --git a/core/src/test/java/com/bloxbean/cardano/bridge/api/QuickTxApiTest.java b/core/src/test/java/com/bloxbean/cardano/bridge/api/QuickTxApiTest.java index b296429..8229419 100644 --- a/core/src/test/java/com/bloxbean/cardano/bridge/api/QuickTxApiTest.java +++ b/core/src/test/java/com/bloxbean/cardano/bridge/api/QuickTxApiTest.java @@ -3,8 +3,19 @@ import com.bloxbean.cardano.bridge.api.quicktx.QuickTxService; import com.bloxbean.cardano.client.account.Account; import com.bloxbean.cardano.client.common.model.Networks; +import com.bloxbean.cardano.client.plutus.spec.BigIntPlutusData; +import com.bloxbean.cardano.client.plutus.spec.PlutusData; +import com.bloxbean.cardano.client.plutus.spec.PlutusScript; +import com.bloxbean.cardano.client.plutus.spec.PlutusV2Script; +import com.bloxbean.cardano.client.quicktx.Tx; +import com.bloxbean.cardano.client.quicktx.serialization.TxPlan; +import com.bloxbean.cardano.client.quicktx.serialization.YamlSerializer; +import com.bloxbean.cardano.client.transaction.spec.Asset; +import com.bloxbean.cardano.client.transaction.spec.Transaction; +import com.bloxbean.cardano.client.util.HexUtil; + +import java.math.BigInteger; import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -14,1686 +25,195 @@ import static org.junit.jupiter.api.Assertions.*; +/** + * Builds unsigned transactions from TxPlan YAML, fully offline, with static UTXOs + protocol params. + */ class QuickTxApiTest { private static final String TEST_MNEMONIC = "test walk nut penalty hip pave soap entry language right filter choice"; + private static final String FAKE_TX_HASH = "a".repeat(64); - private final ObjectMapper mapper = new ObjectMapper(); private final QuickTxService service = new QuickTxService(); - private String protocolParamsJson; - // Generate deterministic addresses from test mnemonic + private String protocolParamsJson; private String sender; - private String sender2; private String receiver1; private String receiver2; @BeforeEach void setUp() throws IOException { - InputStream is = getClass().getClassLoader().getResourceAsStream("protocol-params.json"); - protocolParamsJson = new String(is.readAllBytes(), StandardCharsets.UTF_8); - - Account senderAccount = Account.createFromMnemonic(Networks.testnet(), TEST_MNEMONIC, 0, 0); - sender = senderAccount.baseAddress(); - sender2 = Account.createFromMnemonic(Networks.testnet(), TEST_MNEMONIC, 0, 1).baseAddress(); + try (InputStream is = getClass().getClassLoader().getResourceAsStream("protocol-params.json")) { + protocolParamsJson = new String(is.readAllBytes(), StandardCharsets.UTF_8); + } + sender = Account.createFromMnemonic(Networks.testnet(), TEST_MNEMONIC, 0, 0).baseAddress(); receiver1 = new Account(Networks.testnet()).baseAddress(); receiver2 = new Account(Networks.testnet()).baseAddress(); } - @Test - void testSimpleAdaPayment() throws Exception { - String spec = buildSpec(""" - "operations": [ - {"type": "pay_to_address", "address": "%s", - "amounts": [{"unit": "lovelace", "quantity": "5000000"}]} - ] - """.formatted(receiver1)); - - String result = service.buildTransaction(spec); - JsonNode json = mapper.readTree(result); - - assertNotNull(json.get("tx_cbor").asText()); - assertFalse(json.get("tx_cbor").asText().isEmpty()); - assertNotNull(json.get("tx_hash").asText()); - assertEquals(64, json.get("tx_hash").asText().length()); - assertTrue(Long.parseLong(json.get("fee").asText()) > 0); - } - - @Test - void testMultiplePayments() throws Exception { - String spec = buildSpec(""" - "operations": [ - {"type": "pay_to_address", "address": "%s", - "amounts": [{"unit": "lovelace", "quantity": "5000000"}]}, - {"type": "pay_to_address", "address": "%s", - "amounts": [{"unit": "lovelace", "quantity": "3000000"}]} - ] - """.formatted(receiver1, receiver2)); - - String result = service.buildTransaction(spec); - JsonNode json = mapper.readTree(result); - - assertNotNull(json.get("tx_cbor").asText()); - assertEquals(64, json.get("tx_hash").asText().length()); - } - - @Test - void testWithMetadata() throws Exception { - String spec = buildSpec(""" - "operations": [ - {"type": "pay_to_address", "address": "%s", - "amounts": [{"unit": "lovelace", "quantity": "2000000"}]}, - {"type": "attach_metadata", "label": 674, - "metadata": {"msg": ["Hello from CCL Bridge"]}} - ] - """.formatted(receiver1)); - - String result = service.buildTransaction(spec); - JsonNode json = mapper.readTree(result); - - assertNotNull(json.get("tx_cbor").asText()); - // Fee should be slightly higher with metadata - assertTrue(Long.parseLong(json.get("fee").asText()) > 0); - } - - @Test - void testWithChangeAddress() throws Exception { - // change_address = sender is the typical case; change goes back to sender - String spec = """ - { - "operations": [ - {"type": "pay_to_address", "address": "%s", - "amounts": [{"unit": "lovelace", "quantity": "5000000"}]} - ], - "from": "%s", - "change_address": "%s", - "utxos": [ - {"tx_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "output_index": 0, "address": "%s", - "amount": [{"unit": "lovelace", "quantity": "100000000"}]} - ], - "protocol_params": %s, - "signer_count": 1 - } - """.formatted(receiver1, sender, sender, sender, protocolParamsJson); - - String result = service.buildTransaction(spec); - JsonNode json = mapper.readTree(result); - assertNotNull(json.get("tx_cbor").asText()); - } - - @Test - void testWithValidityInterval() throws Exception { - String spec = """ - { - "operations": [ - {"type": "pay_to_address", "address": "%s", - "amounts": [{"unit": "lovelace", "quantity": "2000000"}]} - ], - "from": "%s", - "utxos": [ - {"tx_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "output_index": 0, "address": "%s", - "amount": [{"unit": "lovelace", "quantity": "100000000"}]} - ], - "protocol_params": %s, - "validity": {"valid_from": 1000, "valid_to": 50000}, - "signer_count": 1 - } - """.formatted(receiver1, sender, sender, protocolParamsJson); - - String result = service.buildTransaction(spec); - JsonNode json = mapper.readTree(result); - assertNotNull(json.get("tx_cbor").asText()); - } - - @Test - void testInsufficientFunds() { - String spec = """ - { - "operations": [ - {"type": "pay_to_address", "address": "%s", - "amounts": [{"unit": "lovelace", "quantity": "200000000"}]} - ], - "from": "%s", - "utxos": [ - {"tx_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "output_index": 0, "address": "%s", - "amount": [{"unit": "lovelace", "quantity": "1000000"}]} - ], - "protocol_params": %s, - "signer_count": 1 - } - """.formatted(receiver1, sender, sender, protocolParamsJson); - - assertThrows(Exception.class, () -> service.buildTransaction(spec)); - } - - @Test - void testMissingOperations() { - String spec = """ - { - "operations": [], - "from": "%s", - "utxos": [ - {"tx_hash": "aaaa", "output_index": 0, "address": "%s", - "amount": [{"unit": "lovelace", "quantity": "1000000"}]} - ], - "protocol_params": %s - } - """.formatted(sender, sender, protocolParamsJson); - - assertThrows(IllegalArgumentException.class, () -> service.buildTransaction(spec)); - } - - @Test - void testMissingFrom() { - String spec = """ - { - "operations": [ - {"type": "pay_to_address", "address": "%s", - "amounts": [{"unit": "lovelace", "quantity": "2000000"}]} - ], - "utxos": [ - {"tx_hash": "aaaa", "output_index": 0, "address": "%s", - "amount": [{"unit": "lovelace", "quantity": "1000000"}]} - ], - "protocol_params": %s - } - """.formatted(receiver1, sender, protocolParamsJson); - - assertThrows(IllegalArgumentException.class, () -> service.buildTransaction(spec)); - } - - @Test - void testMissingUtxos() { - String spec = """ - { - "operations": [ - {"type": "pay_to_address", "address": "%s", - "amounts": [{"unit": "lovelace", "quantity": "2000000"}]} - ], - "from": "%s", - "protocol_params": %s - } - """.formatted(receiver1, sender, protocolParamsJson); - - assertThrows(IllegalArgumentException.class, () -> service.buildTransaction(spec)); - } - - @Test - void testProviderModeSkipsUtxoValidation() { - // Provider mode should not require utxos or protocol_params in the spec - // (it will fail at HTTP fetch, not at validation) - String spec = """ - { - "operations": [ - {"type": "pay_to_address", "address": "%s", - "amounts": [{"unit": "lovelace", "quantity": "2000000"}]} - ], - "from": "%s", - "provider": {"name": "yaci", "url": "http://localhost:9999/api/v1"} - } - """.formatted(receiver1, sender); - - // Should fail with connection error, NOT IllegalArgumentException - Exception ex = assertThrows(Exception.class, () -> service.buildTransaction(spec)); - assertFalse(ex instanceof IllegalArgumentException, - "Should not fail validation — expected HTTP error, got: " + ex.getMessage()); - } - - @Test - void testProviderModeInvalidProviderName() { - String spec = """ - { - "operations": [ - {"type": "pay_to_address", "address": "%s", - "amounts": [{"unit": "lovelace", "quantity": "2000000"}]} - ], - "from": "%s", - "provider": {"name": "unknown", "url": "http://localhost:9999/api/v1"} - } - """.formatted(receiver1, sender); - - IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, - () -> service.buildTransaction(spec)); - assertTrue(ex.getMessage().contains("Unsupported provider")); - } - - @Test - void testProviderModeMissingName() { - String spec = """ - { - "operations": [ - {"type": "pay_to_address", "address": "%s", - "amounts": [{"unit": "lovelace", "quantity": "2000000"}]} - ], - "from": "%s", - "provider": {"url": "http://localhost:9999/api/v1"} - } - """.formatted(receiver1, sender); - - IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, - () -> service.buildTransaction(spec)); - assertTrue(ex.getMessage().contains("'name' is required")); - } - - @Test - void testMissingProtocolParams() { - String spec = """ - { - "operations": [ - {"type": "pay_to_address", "address": "%s", - "amounts": [{"unit": "lovelace", "quantity": "2000000"}]} - ], - "from": "%s", - "utxos": [ - {"tx_hash": "aaaa", "output_index": 0, "address": "%s", - "amount": [{"unit": "lovelace", "quantity": "1000000"}]} - ] - } - """.formatted(receiver1, sender, sender); - - assertThrows(IllegalArgumentException.class, () -> service.buildTransaction(spec)); - } - - @Test - void testMultiAssetPayment() throws Exception { - String policyId = "a".repeat(56); - String unit = policyId + "546f6b656e"; // "Token" hex - String spec = """ - { - "operations": [ - {"type": "pay_to_address", "address": "%s", - "amounts": [ - {"unit": "lovelace", "quantity": "2000000"}, - {"unit": "%s", "quantity": "100"} - ]} - ], - "from": "%s", - "utxos": [ - {"tx_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "output_index": 0, "address": "%s", - "amount": [ - {"unit": "lovelace", "quantity": "100000000"}, - {"unit": "%s", "quantity": "500"} - ]} - ], - "protocol_params": %s, - "signer_count": 1 - } - """.formatted(receiver1, unit, sender, sender, unit, protocolParamsJson); - - String result = service.buildTransaction(spec); - JsonNode json = mapper.readTree(result); - assertNotNull(json.get("tx_cbor").asText()); - assertEquals(64, json.get("tx_hash").asText().length()); - } - - // --- Compose (multi-Tx) tests --- - - @Test - void testComposeTwoSenders() throws Exception { - String spec = """ - { - "transactions": [ - { - "from": "%s", - "operations": [ - {"type": "pay_to_address", "address": "%s", - "amounts": [{"unit": "lovelace", "quantity": "5000000"}]} - ] - }, - { - "from": "%s", - "operations": [ - {"type": "pay_to_address", "address": "%s", - "amounts": [{"unit": "lovelace", "quantity": "3000000"}]} - ] - } - ], - "fee_payer": "%s", - "utxos": [ - {"tx_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "output_index": 0, "address": "%s", - "amount": [{"unit": "lovelace", "quantity": "100000000"}]}, - {"tx_hash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "output_index": 0, "address": "%s", - "amount": [{"unit": "lovelace", "quantity": "100000000"}]} - ], - "protocol_params": %s - } - """.formatted(sender, receiver1, sender2, receiver2, - sender, sender, sender2, protocolParamsJson); - - String result = service.buildTransaction(spec); - JsonNode json = mapper.readTree(result); - - assertNotNull(json.get("tx_cbor").asText()); - assertFalse(json.get("tx_cbor").asText().isEmpty()); - assertEquals(64, json.get("tx_hash").asText().length()); - assertTrue(Long.parseLong(json.get("fee").asText()) > 0); - } - - @Test - void testComposeMissingFeePayer() { - String spec = """ - { - "transactions": [ - { - "from": "%s", - "operations": [ - {"type": "pay_to_address", "address": "%s", - "amounts": [{"unit": "lovelace", "quantity": "5000000"}]} - ] - }, - { - "from": "%s", - "operations": [ - {"type": "pay_to_address", "address": "%s", - "amounts": [{"unit": "lovelace", "quantity": "3000000"}]} - ] - } - ], - "utxos": [ - {"tx_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "output_index": 0, "address": "%s", - "amount": [{"unit": "lovelace", "quantity": "100000000"}]} - ], - "protocol_params": %s - } - """.formatted(sender, receiver1, sender2, receiver2, - sender, protocolParamsJson); - - assertThrows(IllegalArgumentException.class, () -> service.buildTransaction(spec)); - } - - @Test - void testComposeMissingFromInItem() { - String spec = """ - { - "transactions": [ - { - "from": "%s", - "operations": [ - {"type": "pay_to_address", "address": "%s", - "amounts": [{"unit": "lovelace", "quantity": "5000000"}]} - ] - }, - { - "operations": [ - {"type": "pay_to_address", "address": "%s", - "amounts": [{"unit": "lovelace", "quantity": "3000000"}]} - ] - } - ], - "fee_payer": "%s", - "utxos": [ - {"tx_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "output_index": 0, "address": "%s", - "amount": [{"unit": "lovelace", "quantity": "100000000"}]} - ], - "protocol_params": %s - } - """.formatted(sender, receiver1, receiver2, - sender, sender, protocolParamsJson); - - IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, - () -> service.buildTransaction(spec)); - assertTrue(ex.getMessage().contains("transactions[1]")); - } - - @Test - void testComposeWithMetadata() throws Exception { - String spec = """ - { - "transactions": [ - { - "from": "%s", - "operations": [ - {"type": "pay_to_address", "address": "%s", - "amounts": [{"unit": "lovelace", "quantity": "5000000"}]}, - {"type": "attach_metadata", "label": 674, - "metadata": {"msg": ["Compose test"]}} - ] - }, - { - "from": "%s", - "operations": [ - {"type": "pay_to_address", "address": "%s", - "amounts": [{"unit": "lovelace", "quantity": "3000000"}]} - ] - } - ], - "fee_payer": "%s", - "utxos": [ - {"tx_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "output_index": 0, "address": "%s", - "amount": [{"unit": "lovelace", "quantity": "100000000"}]}, - {"tx_hash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "output_index": 0, "address": "%s", - "amount": [{"unit": "lovelace", "quantity": "100000000"}]} - ], - "protocol_params": %s - } - """.formatted(sender, receiver1, sender2, receiver2, - sender, sender, sender2, protocolParamsJson); - - String result = service.buildTransaction(spec); - JsonNode json = mapper.readTree(result); - assertNotNull(json.get("tx_cbor").asText()); - assertTrue(Long.parseLong(json.get("fee").asText()) > 0); - } - - @Test - void testComposeSignerCountDefault() throws Exception { - // When signer_count is omitted in compose mode, it defaults to number of transactions - String spec = """ - { - "transactions": [ - { - "from": "%s", - "operations": [ - {"type": "pay_to_address", "address": "%s", - "amounts": [{"unit": "lovelace", "quantity": "5000000"}]} - ] - }, - { - "from": "%s", - "operations": [ - {"type": "pay_to_address", "address": "%s", - "amounts": [{"unit": "lovelace", "quantity": "3000000"}]} - ] - } - ], - "fee_payer": "%s", - "utxos": [ - {"tx_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "output_index": 0, "address": "%s", - "amount": [{"unit": "lovelace", "quantity": "100000000"}]}, - {"tx_hash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "output_index": 0, "address": "%s", - "amount": [{"unit": "lovelace", "quantity": "100000000"}]} - ], - "protocol_params": %s - } - """.formatted(sender, receiver1, sender2, receiver2, - sender, sender, sender2, protocolParamsJson); - - String result = service.buildTransaction(spec); - JsonNode json = mapper.readTree(result); - // Should build successfully with default signer count = 2 - assertNotNull(json.get("tx_cbor").asText()); - assertEquals(64, json.get("tx_hash").asText().length()); - } - - // --- Staking tests --- - - @Test - void testRegisterStakeAddress() throws Exception { - String spec = buildSpec(""" - "operations": [ - {"type": "register_stake_address", "address": "%s"} - ] - """.formatted(sender)); - - String result = service.buildTransaction(spec); - JsonNode json = mapper.readTree(result); - assertNotNull(json.get("tx_cbor").asText()); - assertFalse(json.get("tx_cbor").asText().isEmpty()); - assertEquals(64, json.get("tx_hash").asText().length()); - } - - @Test - void testDeregisterStakeAddress() throws Exception { - String spec = buildSpec(""" - "operations": [ - {"type": "deregister_stake_address", "address": "%s"} - ] - """.formatted(sender)); - - String result = service.buildTransaction(spec); - JsonNode json = mapper.readTree(result); - assertNotNull(json.get("tx_cbor").asText()); - assertEquals(64, json.get("tx_hash").asText().length()); - } - - @Test - void testDeregisterStakeAddressWithRefund() throws Exception { - String spec = buildSpec(""" - "operations": [ - {"type": "deregister_stake_address", "address": "%s", - "refund_address": "%s"} - ] - """.formatted(sender, receiver1)); - - String result = service.buildTransaction(spec); - JsonNode json = mapper.readTree(result); - assertNotNull(json.get("tx_cbor").asText()); - assertEquals(64, json.get("tx_hash").asText().length()); - } - - @Test - void testDelegateTo() throws Exception { - // pool1... bech32 format; using a fake but valid-looking pool ID - String poolId = "pool1pu5jlj4q9w9jlxeu370a3c9myx47md5j5m2str0naunn2q3lkdy"; - String spec = buildSpec(""" - "operations": [ - {"type": "delegate_to", "address": "%s", - "pool_id": "%s"} - ] - """.formatted(sender, poolId)); - - String result = service.buildTransaction(spec); - JsonNode json = mapper.readTree(result); - assertNotNull(json.get("tx_cbor").asText()); - assertEquals(64, json.get("tx_hash").asText().length()); - } - - @Test - void testWithdraw() throws Exception { - Account senderAccount = Account.createFromMnemonic(Networks.testnet(), TEST_MNEMONIC, 0, 0); - String stakeAddr = senderAccount.stakeAddress(); - - String spec = buildSpec(""" - "operations": [ - {"type": "withdraw", "reward_address": "%s", - "amount": "5000000"} - ] - """.formatted(stakeAddr)); - - String result = service.buildTransaction(spec); - JsonNode json = mapper.readTree(result); - assertNotNull(json.get("tx_cbor").asText()); - assertEquals(64, json.get("tx_hash").asText().length()); - } - - @Test - void testWithdrawWithReceiver() throws Exception { - Account senderAccount = Account.createFromMnemonic(Networks.testnet(), TEST_MNEMONIC, 0, 0); - String stakeAddr = senderAccount.stakeAddress(); - - String spec = buildSpec(""" - "operations": [ - {"type": "withdraw", "reward_address": "%s", - "amount": "5000000", "receiver": "%s"} - ] - """.formatted(stakeAddr, receiver1)); - - String result = service.buildTransaction(spec); - JsonNode json = mapper.readTree(result); - assertNotNull(json.get("tx_cbor").asText()); - assertEquals(64, json.get("tx_hash").asText().length()); - } - - // --- DRep tests --- - - @Test - void testRegisterDRep() throws Exception { - String credentialHash = "ab".repeat(28); - String spec = buildSpec(""" - "operations": [ - {"type": "register_drep", "credential_hash": "%s", - "credential_type": "key"} - ] - """.formatted(credentialHash)); - - String result = service.buildTransaction(spec); - JsonNode json = mapper.readTree(result); - assertNotNull(json.get("tx_cbor").asText()); - assertEquals(64, json.get("tx_hash").asText().length()); - } - - @Test - void testRegisterDRepWithAnchor() throws Exception { - String credentialHash = "ab".repeat(28); - String dataHash = "cd".repeat(32); - String spec = buildSpec(""" - "operations": [ - {"type": "register_drep", "credential_hash": "%s", - "credential_type": "key", - "anchor_url": "https://example.com/drep.json", - "anchor_data_hash": "%s"} - ] - """.formatted(credentialHash, dataHash)); - - String result = service.buildTransaction(spec); - JsonNode json = mapper.readTree(result); - assertNotNull(json.get("tx_cbor").asText()); - assertEquals(64, json.get("tx_hash").asText().length()); - } - - @Test - void testUnregisterDRep() throws Exception { - String credentialHash = "ab".repeat(28); - String spec = buildSpec(""" - "operations": [ - {"type": "unregister_drep", "credential_hash": "%s", - "credential_type": "key"} - ] - """.formatted(credentialHash)); - - String result = service.buildTransaction(spec); - JsonNode json = mapper.readTree(result); - assertNotNull(json.get("tx_cbor").asText()); - assertEquals(64, json.get("tx_hash").asText().length()); - } - - @Test - void testUpdateDRep() throws Exception { - String credentialHash = "ab".repeat(28); - String dataHash = "cd".repeat(32); - String spec = buildSpec(""" - "operations": [ - {"type": "update_drep", "credential_hash": "%s", - "credential_type": "key", - "anchor_url": "https://example.com/drep-v2.json", - "anchor_data_hash": "%s"} - ] - """.formatted(credentialHash, dataHash)); - - String result = service.buildTransaction(spec); - JsonNode json = mapper.readTree(result); - assertNotNull(json.get("tx_cbor").asText()); - assertEquals(64, json.get("tx_hash").asText().length()); - } - - // --- Voting tests --- - - @Test - void testDelegateVotingPowerToKeyHash() throws Exception { - String drepHash = "ab".repeat(28); - String spec = buildSpec(""" - "operations": [ - {"type": "delegate_voting_power_to", "address": "%s", - "drep_type": "key_hash", "drep_hash": "%s"} - ] - """.formatted(sender, drepHash)); - - String result = service.buildTransaction(spec); - JsonNode json = mapper.readTree(result); - assertNotNull(json.get("tx_cbor").asText()); - assertEquals(64, json.get("tx_hash").asText().length()); - } - - @Test - void testDelegateVotingPowerToAbstain() throws Exception { - String spec = buildSpec(""" - "operations": [ - {"type": "delegate_voting_power_to", "address": "%s", - "drep_type": "abstain"} - ] - """.formatted(sender)); - - String result = service.buildTransaction(spec); - JsonNode json = mapper.readTree(result); - assertNotNull(json.get("tx_cbor").asText()); - assertEquals(64, json.get("tx_hash").asText().length()); - } - - @Test - void testCreateVote() throws Exception { - String voterHash = "ab".repeat(28); - String govTxHash = "cd".repeat(32); - String spec = buildSpec(""" - "operations": [ - {"type": "create_vote", "voter_type": "drep_key_hash", - "voter_hash": "%s", - "gov_action_tx_hash": "%s", - "gov_action_index": 0, "vote": "yes"} - ] - """.formatted(voterHash, govTxHash)); - - String result = service.buildTransaction(spec); - JsonNode json = mapper.readTree(result); - assertNotNull(json.get("tx_cbor").asText()); - assertEquals(64, json.get("tx_hash").asText().length()); - } - - @Test - void testCreateVoteWithAnchor() throws Exception { - String voterHash = "ab".repeat(28); - String govTxHash = "cd".repeat(32); - String anchorDataHash = "ef".repeat(32); - String spec = buildSpec(""" - "operations": [ - {"type": "create_vote", "voter_type": "drep_key_hash", - "voter_hash": "%s", - "gov_action_tx_hash": "%s", - "gov_action_index": 0, "vote": "no", - "anchor_url": "https://example.com/rationale.json", - "anchor_data_hash": "%s"} - ] - """.formatted(voterHash, govTxHash, anchorDataHash)); - - String result = service.buildTransaction(spec); - JsonNode json = mapper.readTree(result); - assertNotNull(json.get("tx_cbor").asText()); - assertEquals(64, json.get("tx_hash").asText().length()); - } - - // --- Governance proposal tests --- - - @Test - void testCreateInfoActionProposal() throws Exception { - Account senderAccount = Account.createFromMnemonic(Networks.testnet(), TEST_MNEMONIC, 0, 0); - String stakeAddr = senderAccount.stakeAddress(); - String anchorDataHash = "ab".repeat(32); - - // gov_action_deposit = 1000 ADA, so we need a large UTXO - String spec = """ - { - "operations": [ - {"type": "create_proposal", "gov_action_type": "info_action", - "return_address": "%s", - "anchor_url": "https://example.com/proposal.json", - "anchor_data_hash": "%s"} - ], - "from": "%s", - "utxos": [ - {"tx_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "output_index": 0, "address": "%s", - "amount": [{"unit": "lovelace", "quantity": "2000000000"}]} - ], - "protocol_params": %s, - "signer_count": 1 - } - """.formatted(stakeAddr, anchorDataHash, sender, sender, protocolParamsJson); - - String result = service.buildTransaction(spec); - JsonNode json = mapper.readTree(result); - assertNotNull(json.get("tx_cbor").asText()); - assertEquals(64, json.get("tx_hash").asText().length()); - } - - @Test - void testCreateTreasuryWithdrawalsProposal() throws Exception { - Account senderAccount = Account.createFromMnemonic(Networks.testnet(), TEST_MNEMONIC, 0, 0); - String stakeAddr = senderAccount.stakeAddress(); - String anchorDataHash = "ab".repeat(32); - - // gov_action_deposit = 1000 ADA, so we need a large UTXO - String spec = """ - { - "operations": [ - {"type": "create_proposal", "gov_action_type": "treasury_withdrawals", - "return_address": "%s", - "anchor_url": "https://example.com/proposal.json", - "anchor_data_hash": "%s", - "withdrawals": [ - {"reward_address": "%s", "amount": "1000000"} - ]} - ], - "from": "%s", - "utxos": [ - {"tx_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "output_index": 0, "address": "%s", - "amount": [{"unit": "lovelace", "quantity": "2000000000"}]} - ], - "protocol_params": %s, - "signer_count": 1 - } - """.formatted(stakeAddr, anchorDataHash, stakeAddr, sender, sender, protocolParamsJson); - - String result = service.buildTransaction(spec); - JsonNode json = mapper.readTree(result); - assertNotNull(json.get("tx_cbor").asText()); - assertEquals(64, json.get("tx_hash").asText().length()); - } - - @Test - void testCreateVoteInvalidVoteValue() { - String voterHash = "ab".repeat(28); - String govTxHash = "cd".repeat(32); - String spec = buildSpec(""" - "operations": [ - {"type": "create_vote", "voter_type": "drep_key_hash", - "voter_hash": "%s", - "gov_action_tx_hash": "%s", - "gov_action_index": 0, "vote": "maybe"} - ] - """.formatted(voterHash, govTxHash)); - - assertThrows(Exception.class, () -> service.buildTransaction(spec)); - } - - @Test - void testUnsupportedGovActionType() { - Account senderAccount = Account.createFromMnemonic(Networks.testnet(), TEST_MNEMONIC, 0, 0); - String stakeAddr = senderAccount.stakeAddress(); - String spec = buildSpec(""" - "operations": [ - {"type": "create_proposal", "gov_action_type": "unsupported_action", - "return_address": "%s", - "anchor_url": "https://example.com/proposal.json", - "anchor_data_hash": "abcdef"} - ] - """.formatted(stakeAddr)); - - assertThrows(Exception.class, () -> service.buildTransaction(spec)); - } - - // --- Gap 1: Reference Script on Outputs --- - - @Test - void testPayToAddressWithRefScript() throws Exception { - String spec = buildSpec(""" - "operations": [ - {"type": "pay_to_address", "address": "%s", - "amounts": [{"unit": "lovelace", "quantity": "5000000"}], - "script_ref_cbor_hex": "%s", - "script_ref_type": "plutus_v3"} - ] - """.formatted(receiver1, ALWAYS_SUCCEEDS_SCRIPT_CBOR)); - - String result = service.buildTransaction(spec); - JsonNode json = mapper.readTree(result); - assertNotNull(json.get("tx_cbor").asText()); - assertFalse(json.get("tx_cbor").asText().isEmpty()); - assertEquals(64, json.get("tx_hash").asText().length()); - } - - @Test - void testPayToContractWithRefScript() throws Exception { - String spec = buildSpec(""" - "operations": [ - {"type": "pay_to_contract", "address": "%s", - "amounts": [{"unit": "lovelace", "quantity": "5000000"}], - "datum_cbor_hex": "%s", - "script_ref_cbor_hex": "%s", - "script_ref_type": "plutus_v3"} - ] - """.formatted(receiver1, SIMPLE_DATUM, ALWAYS_SUCCEEDS_SCRIPT_CBOR)); - - String result = service.buildTransaction(spec); - JsonNode json = mapper.readTree(result); - assertNotNull(json.get("tx_cbor").asText()); - assertEquals(64, json.get("tx_hash").asText().length()); - } - - @Test - void testPayToAddressRefScriptMissingType() { - String spec = buildSpec(""" - "operations": [ - {"type": "pay_to_address", "address": "%s", - "amounts": [{"unit": "lovelace", "quantity": "5000000"}], - "script_ref_cbor_hex": "%s"} - ] - """.formatted(receiver1, ALWAYS_SUCCEEDS_SCRIPT_CBOR)); - - assertThrows(Exception.class, () -> service.buildTransaction(spec)); - } - - // --- Gap 2: Additional Governance Actions --- - - @Test - void testCreateNoConfidenceProposal() throws Exception { - Account senderAccount = Account.createFromMnemonic(Networks.testnet(), TEST_MNEMONIC, 0, 0); - String stakeAddr = senderAccount.stakeAddress(); - String anchorDataHash = "ab".repeat(32); - - String spec = """ - { - "operations": [ - {"type": "create_proposal", "gov_action_type": "no_confidence", - "return_address": "%s", - "anchor_url": "https://example.com/no-confidence.json", - "anchor_data_hash": "%s"} - ], - "from": "%s", - "utxos": [ - {"tx_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "output_index": 0, "address": "%s", - "amount": [{"unit": "lovelace", "quantity": "2000000000"}]} - ], - "protocol_params": %s, - "signer_count": 1 - } - """.formatted(stakeAddr, anchorDataHash, sender, sender, protocolParamsJson); - - String result = service.buildTransaction(spec); - JsonNode json = mapper.readTree(result); - assertNotNull(json.get("tx_cbor").asText()); - assertEquals(64, json.get("tx_hash").asText().length()); - } - - @Test - void testCreateNoConfidenceWithPrevAction() throws Exception { - Account senderAccount = Account.createFromMnemonic(Networks.testnet(), TEST_MNEMONIC, 0, 0); - String stakeAddr = senderAccount.stakeAddress(); - String anchorDataHash = "ab".repeat(32); - String prevTxHash = "cc".repeat(32); - - String spec = """ - { - "operations": [ - {"type": "create_proposal", "gov_action_type": "no_confidence", - "return_address": "%s", - "anchor_url": "https://example.com/no-confidence.json", - "anchor_data_hash": "%s", - "gov_action_tx_hash": "%s", - "gov_action_index": 0} - ], - "from": "%s", - "utxos": [ - {"tx_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "output_index": 0, "address": "%s", - "amount": [{"unit": "lovelace", "quantity": "2000000000"}]} - ], - "protocol_params": %s, - "signer_count": 1 - } - """.formatted(stakeAddr, anchorDataHash, prevTxHash, sender, sender, protocolParamsJson); - - String result = service.buildTransaction(spec); - JsonNode json = mapper.readTree(result); - assertNotNull(json.get("tx_cbor").asText()); - assertEquals(64, json.get("tx_hash").asText().length()); - } - - @Test - void testCreateUpdateCommitteeProposal() throws Exception { - Account senderAccount = Account.createFromMnemonic(Networks.testnet(), TEST_MNEMONIC, 0, 0); - String stakeAddr = senderAccount.stakeAddress(); - String anchorDataHash = "ab".repeat(32); - String memberHash = "cd".repeat(28); - - String spec = """ - { - "operations": [ - {"type": "create_proposal", "gov_action_type": "update_committee", - "return_address": "%s", - "anchor_url": "https://example.com/committee.json", - "anchor_data_hash": "%s", - "members_to_remove": [{"hash": "%s", "type": "key"}], - "new_members": [{"hash": "%s", "type": "key", "epoch": 500}], - "quorum_numerator": "2", - "quorum_denominator": "3"} - ], - "from": "%s", - "utxos": [ - {"tx_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "output_index": 0, "address": "%s", - "amount": [{"unit": "lovelace", "quantity": "2000000000"}]} - ], - "protocol_params": %s, - "signer_count": 1 - } - """.formatted(stakeAddr, anchorDataHash, memberHash, memberHash, sender, sender, protocolParamsJson); - - String result = service.buildTransaction(spec); - JsonNode json = mapper.readTree(result); - assertNotNull(json.get("tx_cbor").asText()); - assertEquals(64, json.get("tx_hash").asText().length()); - } - - @Test - void testCreateNewConstitutionProposal() throws Exception { - Account senderAccount = Account.createFromMnemonic(Networks.testnet(), TEST_MNEMONIC, 0, 0); - String stakeAddr = senderAccount.stakeAddress(); - String anchorDataHash = "ab".repeat(32); - String constitutionDataHash = "ef".repeat(32); - - String spec = """ - { - "operations": [ - {"type": "create_proposal", "gov_action_type": "new_constitution", - "return_address": "%s", - "anchor_url": "https://example.com/proposal.json", - "anchor_data_hash": "%s", - "constitution_anchor_url": "https://example.com/constitution.json", - "constitution_anchor_data_hash": "%s"} - ], - "from": "%s", - "utxos": [ - {"tx_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "output_index": 0, "address": "%s", - "amount": [{"unit": "lovelace", "quantity": "2000000000"}]} - ], - "protocol_params": %s, - "signer_count": 1 - } - """.formatted(stakeAddr, anchorDataHash, constitutionDataHash, sender, sender, protocolParamsJson); - - String result = service.buildTransaction(spec); - JsonNode json = mapper.readTree(result); - assertNotNull(json.get("tx_cbor").asText()); - assertEquals(64, json.get("tx_hash").asText().length()); - } - - @Test - void testCreateHardForkInitiationProposal() throws Exception { - Account senderAccount = Account.createFromMnemonic(Networks.testnet(), TEST_MNEMONIC, 0, 0); - String stakeAddr = senderAccount.stakeAddress(); - String anchorDataHash = "ab".repeat(32); - - String spec = """ - { - "operations": [ - {"type": "create_proposal", "gov_action_type": "hard_fork_initiation", - "return_address": "%s", - "anchor_url": "https://example.com/hardfork.json", - "anchor_data_hash": "%s", - "protocol_version_major": 10, - "protocol_version_minor": 0} - ], - "from": "%s", - "utxos": [ - {"tx_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "output_index": 0, "address": "%s", - "amount": [{"unit": "lovelace", "quantity": "2000000000"}]} - ], - "protocol_params": %s, - "signer_count": 1 - } - """.formatted(stakeAddr, anchorDataHash, sender, sender, protocolParamsJson); - - String result = service.buildTransaction(spec); - JsonNode json = mapper.readTree(result); - assertNotNull(json.get("tx_cbor").asText()); - assertEquals(64, json.get("tx_hash").asText().length()); - } - - // --- Gap 3: Pool Operations --- - - @Test - void testRegisterPool() throws Exception { - String operatorHash = "ab".repeat(28); - String vrfKeyHash = "cd".repeat(32); - String ownerHash = "ab".repeat(28); - // rewardAccount must be hex-encoded reward address bytes (not bech32) - // e0 prefix = reward address type on testnet, then 28 bytes key hash - String rewardAccountHex = "e0" + "ab".repeat(28); - - String spec = """ - { - "operations": [ - {"type": "register_pool", - "operator": "%s", - "vrf_key_hash": "%s", - "pledge": "100000000", - "cost": "340000000", - "margin_numerator": "1", - "margin_denominator": "100", - "reward_address": "%s", - "pool_owners": ["%s"], - "relays": [{"type": "single_host_addr", "port": 6000, "ipv4": "127.0.0.1"}], - "pool_metadata_url": "https://example.com/pool.json", - "pool_metadata_hash": "%s"} - ], - "from": "%s", - "utxos": [ - {"tx_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "output_index": 0, "address": "%s", - "amount": [{"unit": "lovelace", "quantity": "1000000000"}]} - ], - "protocol_params": %s, - "signer_count": 1 - } - """.formatted(operatorHash, vrfKeyHash, rewardAccountHex, ownerHash, - "ef".repeat(32), sender, sender, protocolParamsJson); - - String result = service.buildTransaction(spec); - JsonNode json = mapper.readTree(result); - assertNotNull(json.get("tx_cbor").asText()); - assertEquals(64, json.get("tx_hash").asText().length()); - } - - @Test - void testRetirePool() throws Exception { - String poolId = "pool1pu5jlj4q9w9jlxeu370a3c9myx47md5j5m2str0naunn2q3lkdy"; - String spec = buildSpec(""" - "operations": [ - {"type": "retire_pool", "pool_id": "%s", "epoch": 500} - ] - """.formatted(poolId)); - - String result = service.buildTransaction(spec); - JsonNode json = mapper.readTree(result); - assertNotNull(json.get("tx_cbor").asText()); - assertEquals(64, json.get("tx_hash").asText().length()); - } - - // --- Gap 4: Treasury Donation --- - - @Test - void testDonateToTreasury() throws Exception { - String spec = buildSpec(""" - "operations": [ - {"type": "donate_to_treasury", - "treasury_value": "10000000000", - "donation_amount": "5000000"} - ] - """); - - String result = service.buildTransaction(spec); - JsonNode json = mapper.readTree(result); - assertNotNull(json.get("tx_cbor").asText()); - assertEquals(64, json.get("tx_hash").asText().length()); - } - - // --- Gap 5: Attach Native Script --- - - @Test - void testAttachNativeScript() throws Exception { - String keyHash = "ab".repeat(28); - String spec = buildSpec(""" - "operations": [ - {"type": "pay_to_address", "address": "%s", - "amounts": [{"unit": "lovelace", "quantity": "2000000"}]}, - {"type": "attach_native_script", "script_json": "{\\"type\\": \\"sig\\", \\"keyHash\\": \\"%s\\"}"} - ] - """.formatted(receiver1, keyHash)); - - String result = service.buildTransaction(spec); - JsonNode json = mapper.readTree(result); - assertNotNull(json.get("tx_cbor").asText()); - assertEquals(64, json.get("tx_hash").asText().length()); - } - - // --- Gap 6: unregisterDRep with refundAmount --- - - @Test - void testUnregisterDRepWithRefundAmount() throws Exception { - String credentialHash = "ab".repeat(28); - String spec = buildSpec(""" - "operations": [ - {"type": "unregister_drep", "credential_hash": "%s", - "credential_type": "key", - "refund_address": "%s", - "refund_amount": "500000000"} - ] - """.formatted(credentialHash, receiver1)); - - String result = service.buildTransaction(spec); - JsonNode json = mapper.readTree(result); - assertNotNull(json.get("tx_cbor").asText()); - assertEquals(64, json.get("tx_hash").asText().length()); - } - - // --- ScriptTx variants of new features --- - - @Test - void testScriptTxPayToAddressWithRefScript() throws Exception { - String spec = """ - { - "tx_type": "script_tx", - "operations": [ - {"type": "pay_to_address", "address": "%s", - "amounts": [{"unit": "lovelace", "quantity": "5000000"}], - "script_ref_cbor_hex": "%s", - "script_ref_type": "plutus_v3"} - ], - "from": "%s", - "utxos": [ - {"tx_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "output_index": 0, "address": "%s", - "amount": [{"unit": "lovelace", "quantity": "100000000"}]} - ], - "protocol_params": %s, - "signer_count": 1 - } - """.formatted(receiver1, ALWAYS_SUCCEEDS_SCRIPT_CBOR, sender, sender, protocolParamsJson); - - String result = service.buildTransaction(spec); - JsonNode json = mapper.readTree(result); - assertNotNull(json.get("tx_cbor").asText()); - assertEquals(64, json.get("tx_hash").asText().length()); - } - - @Test - void testScriptTxDonateToTreasury() throws Exception { - String spec = """ - { - "tx_type": "script_tx", - "operations": [ - {"type": "donate_to_treasury", - "treasury_value": "10000000000", - "donation_amount": "5000000"}, - {"type": "pay_to_address", "address": "%s", - "amounts": [{"unit": "lovelace", "quantity": "2000000"}]} - ], - "from": "%s", - "utxos": [ - {"tx_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "output_index": 0, "address": "%s", - "amount": [{"unit": "lovelace", "quantity": "100000000"}]} - ], - "protocol_params": %s, - "signer_count": 1 - } - """.formatted(receiver1, sender, sender, protocolParamsJson); - - String result = service.buildTransaction(spec); - JsonNode json = mapper.readTree(result); - assertNotNull(json.get("tx_cbor").asText()); - assertEquals(64, json.get("tx_hash").asText().length()); - } - - @Test - void testScriptTxNoConfidenceProposal() throws Exception { - Account senderAccount = Account.createFromMnemonic(Networks.testnet(), TEST_MNEMONIC, 0, 0); - String stakeAddr = senderAccount.stakeAddress(); - String anchorDataHash = "ab".repeat(32); - - String spec = """ - { - "tx_type": "script_tx", - "operations": [ - {"type": "create_proposal", "gov_action_type": "no_confidence", - "return_address": "%s", - "anchor_url": "https://example.com/no-confidence.json", - "anchor_data_hash": "%s", - "redeemer_cbor_hex": "%s"} - ], - "from": "%s", - "utxos": [ - {"tx_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "output_index": 0, "address": "%s", - "amount": [{"unit": "lovelace", "quantity": "2000000000"}]} - ], - "protocol_params": %s, - "signer_count": 1 - } - """.formatted(stakeAddr, anchorDataHash, SIMPLE_REDEEMER, sender, sender, protocolParamsJson); - - String result = service.buildTransaction(spec); - JsonNode json = mapper.readTree(result); - assertNotNull(json.get("tx_cbor").asText()); - assertEquals(64, json.get("tx_hash").asText().length()); - } - - // --- ScriptTx tests --- - - // Always-succeeds PlutusV3 script CBOR (from cardano-client-lib tests) - private static final String ALWAYS_SUCCEEDS_SCRIPT_CBOR = "46450101002499"; - // Simple redeemer: ConstrPlutusData(0, []) = d87980 in CBOR - private static final String SIMPLE_REDEEMER = "d87980"; - // Simple datum same as redeemer - private static final String SIMPLE_DATUM = "d87980"; - - @Test - void testScriptTxCollectFromWithRedeemer() throws Exception { - String scriptAddr = receiver1; // Use any address for test - String spec = """ - { - "tx_type": "script_tx", - "operations": [ - { - "type": "collect_from", - "collect_utxos": [ - {"tx_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "output_index": 0, "address": "%s", - "amount": [{"unit": "lovelace", "quantity": "10000000"}]} - ], - "redeemer_cbor_hex": "%s", - "datum_cbor_hex": "%s" - }, - { - "type": "attach_spending_validator", - "script_cbor_hex": "%s", - "script_type": "plutus_v3" - }, - { - "type": "pay_to_address", - "address": "%s", - "amounts": [{"unit": "lovelace", "quantity": "5000000"}] - } - ], - "from": "%s", - "utxos": [ - {"tx_hash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "output_index": 0, "address": "%s", - "amount": [{"unit": "lovelace", "quantity": "100000000"}]} - ], - "protocol_params": %s, - "signer_count": 1 - } - """.formatted(scriptAddr, SIMPLE_REDEEMER, SIMPLE_DATUM, - ALWAYS_SUCCEEDS_SCRIPT_CBOR, receiver2, sender, sender, protocolParamsJson); - - String result = service.buildTransaction(spec); - JsonNode json = mapper.readTree(result); - assertNotNull(json.get("tx_cbor").asText()); - assertFalse(json.get("tx_cbor").asText().isEmpty()); - assertEquals(64, json.get("tx_hash").asText().length()); - } - - @Test - void testScriptTxReadFrom() throws Exception { - String spec = """ - { - "tx_type": "script_tx", - "operations": [ - { - "type": "read_from", - "reference_inputs": [ - {"tx_hash": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "output_index": 0} - ] - }, - { - "type": "pay_to_address", - "address": "%s", - "amounts": [{"unit": "lovelace", "quantity": "5000000"}] - } - ], - "from": "%s", - "utxos": [ - {"tx_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "output_index": 0, "address": "%s", - "amount": [{"unit": "lovelace", "quantity": "100000000"}]} - ], - "protocol_params": %s, - "signer_count": 1 - } - """.formatted(receiver1, sender, sender, protocolParamsJson); - - String result = service.buildTransaction(spec); - JsonNode json = mapper.readTree(result); - assertNotNull(json.get("tx_cbor").asText()); - assertEquals(64, json.get("tx_hash").asText().length()); - } - - @Test - void testScriptTxMintPlutusAssets() throws Exception { - String spec = """ - { - "tx_type": "script_tx", - "operations": [ - { - "type": "mint_plutus_assets", - "script_cbor_hex": "%s", - "script_type": "plutus_v3", - "assets": [{"name": "TestToken", "quantity": "100"}], - "redeemer_cbor_hex": "%s", - "receiver": "%s" - }, - { - "type": "pay_to_address", - "address": "%s", - "amounts": [{"unit": "lovelace", "quantity": "2000000"}] - } - ], - "from": "%s", - "utxos": [ - {"tx_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "output_index": 0, "address": "%s", - "amount": [{"unit": "lovelace", "quantity": "100000000"}]} - ], - "protocol_params": %s, - "signer_count": 1 - } - """.formatted(ALWAYS_SUCCEEDS_SCRIPT_CBOR, SIMPLE_REDEEMER, receiver1, - receiver1, sender, sender, protocolParamsJson); - - String result = service.buildTransaction(spec); - JsonNode json = mapper.readTree(result); - assertNotNull(json.get("tx_cbor").asText()); - assertEquals(64, json.get("tx_hash").asText().length()); - } - - @Test - void testScriptTxAttachValidators() throws Exception { - String spec = """ - { - "tx_type": "script_tx", - "operations": [ - { - "type": "attach_spending_validator", - "script_cbor_hex": "%s", - "script_type": "plutus_v3" - }, - { - "type": "pay_to_address", - "address": "%s", - "amounts": [{"unit": "lovelace", "quantity": "5000000"}] - } - ], - "from": "%s", - "utxos": [ - {"tx_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "output_index": 0, "address": "%s", - "amount": [{"unit": "lovelace", "quantity": "100000000"}]} - ], - "protocol_params": %s, - "signer_count": 1 - } - """.formatted(ALWAYS_SUCCEEDS_SCRIPT_CBOR, receiver1, sender, sender, protocolParamsJson); - - String result = service.buildTransaction(spec); - JsonNode json = mapper.readTree(result); - assertNotNull(json.get("tx_cbor").asText()); - assertEquals(64, json.get("tx_hash").asText().length()); - } - - @Test - void testScriptTxDelegateWithRedeemer() throws Exception { - String poolId = "pool1pu5jlj4q9w9jlxeu370a3c9myx47md5j5m2str0naunn2q3lkdy"; - String spec = """ - { - "tx_type": "script_tx", - "operations": [ - { - "type": "delegate_to", - "address": "%s", - "pool_id": "%s", - "redeemer_cbor_hex": "%s" - } - ], - "from": "%s", - "utxos": [ - {"tx_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "output_index": 0, "address": "%s", - "amount": [{"unit": "lovelace", "quantity": "100000000"}]} - ], - "protocol_params": %s, - "signer_count": 1 - } - """.formatted(sender, poolId, SIMPLE_REDEEMER, sender, sender, protocolParamsJson); - - String result = service.buildTransaction(spec); - JsonNode json = mapper.readTree(result); - assertNotNull(json.get("tx_cbor").asText()); - assertEquals(64, json.get("tx_hash").asText().length()); - } - - @Test - void testComposeScriptTxWithTx() throws Exception { - String spec = """ - { - "transactions": [ - { - "tx_type": "tx", - "from": "%s", - "operations": [ - {"type": "pay_to_address", "address": "%s", - "amounts": [{"unit": "lovelace", "quantity": "5000000"}]} - ] - }, - { - "tx_type": "script_tx", - "from": "%s", - "operations": [ - { - "type": "read_from", - "reference_inputs": [ - {"tx_hash": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "output_index": 0} - ] - }, - {"type": "pay_to_address", "address": "%s", - "amounts": [{"unit": "lovelace", "quantity": "3000000"}]} - ] - } - ], - "fee_payer": "%s", - "utxos": [ - {"tx_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "output_index": 0, "address": "%s", - "amount": [{"unit": "lovelace", "quantity": "100000000"}]} - ], - "protocol_params": %s - } - """.formatted(sender, receiver1, sender, receiver2, - sender, sender, protocolParamsJson); - - String result = service.buildTransaction(spec); - JsonNode json = mapper.readTree(result); - assertNotNull(json.get("tx_cbor").asText()); - assertEquals(64, json.get("tx_hash").asText().length()); - } - - @Test - void testScriptTxMissingScriptType() { - String spec = """ - { - "tx_type": "script_tx", - "operations": [ - { - "type": "attach_spending_validator", - "script_cbor_hex": "%s" - } - ], - "from": "%s", - "utxos": [ - {"tx_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "output_index": 0, "address": "%s", - "amount": [{"unit": "lovelace", "quantity": "100000000"}]} - ], - "protocol_params": %s, - "signer_count": 1 - } - """.formatted(ALWAYS_SUCCEEDS_SCRIPT_CBOR, sender, sender, protocolParamsJson); - - assertThrows(Exception.class, () -> service.buildTransaction(spec)); - } - - @Test - void testScriptTxInvalidRedeemer() { - String spec = """ - { - "tx_type": "script_tx", - "operations": [ - { - "type": "collect_from", - "collect_utxos": [ - {"tx_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "output_index": 0, "address": "%s", - "amount": [{"unit": "lovelace", "quantity": "10000000"}]} - ], - "redeemer_cbor_hex": "invalidhex" - } - ], - "from": "%s", - "utxos": [ - {"tx_hash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "output_index": 0, "address": "%s", - "amount": [{"unit": "lovelace", "quantity": "100000000"}]} - ], - "protocol_params": %s, - "signer_count": 1 - } - """.formatted(receiver1, sender, sender, protocolParamsJson); - - assertThrows(Exception.class, () -> service.buildTransaction(spec)); - } - - @Test - void testScriptTxMintAssetsNotAllowedInScriptTxMode() { - String spec = """ - { - "tx_type": "script_tx", - "operations": [ - { - "type": "mint_assets", - "script_json": "{}", - "assets": [{"name": "Token", "quantity": "100"}], - "receiver": "%s" - } - ], - "from": "%s", - "utxos": [ - {"tx_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "output_index": 0, "address": "%s", - "amount": [{"unit": "lovelace", "quantity": "100000000"}]} - ], - "protocol_params": %s, - "signer_count": 1 - } - """.formatted(receiver1, sender, sender, protocolParamsJson); - - IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, - () -> service.buildTransaction(spec)); - assertTrue(ex.getMessage().contains("mint_plutus_assets")); - } - - @Test - void testScriptTxFromOptional() throws Exception { - // ScriptTx mode allows 'from' to be omitted - String spec = """ - { - "tx_type": "script_tx", - "operations": [ - { - "type": "pay_to_address", - "address": "%s", - "amounts": [{"unit": "lovelace", "quantity": "5000000"}] - } - ], - "utxos": [ - {"tx_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "output_index": 0, "address": "%s", - "amount": [{"unit": "lovelace", "quantity": "100000000"}]} - ], - "protocol_params": %s, - "signer_count": 1 - } - """.formatted(receiver1, sender, protocolParamsJson); - - // Should not throw IllegalArgumentException for missing 'from' - // but may fail during build since no from means no coin selection source - // Just verify validation passes - try { - service.buildTransaction(spec); - } catch (IllegalArgumentException e) { - fail("Should not fail validation for missing 'from' in script_tx mode: " + e.getMessage()); - } catch (Exception e) { - // Build failures are ok - we're testing validation - } - } - - @Test - void testBackwardCompatibilityDefaultTxType() throws Exception { - // Existing specs without tx_type should default to "tx" and work as before - String spec = buildSpec(""" - "operations": [ - {"type": "pay_to_address", "address": "%s", - "amounts": [{"unit": "lovelace", "quantity": "5000000"}]} - ] - """.formatted(receiver1)); - - String result = service.buildTransaction(spec); - JsonNode json = mapper.readTree(result); - assertNotNull(json.get("tx_cbor").asText()); - assertEquals(64, json.get("tx_hash").asText().length()); - } - - /** - * Helper to build a standard spec JSON with common defaults. - */ - private String buildSpec(String operationsFragment) { + /** A single 100-ADA UTXO at {@code sender}, as a JSON array of the CCL Utxo model. */ + private String utxos() { return """ - { - %s, - "from": "%s", - "utxos": [ - {"tx_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "output_index": 0, "address": "%s", - "amount": [{"unit": "lovelace", "quantity": "100000000"}]} - ], - "protocol_params": %s, - "signer_count": 1 - } - """.formatted(operationsFragment, sender, sender, protocolParamsJson); + [{"tx_hash":"%s","output_index":0,"address":"%s", + "amount":[{"unit":"lovelace","quantity":"100000000"}]}] + """.formatted(FAKE_TX_HASH, sender); + } + + private JsonNode build(String yaml) throws Exception { + // The build result is YAML now; parse it with CCL's YAML mapper. + return YamlSerializer.getYamlMapper() + .readTree(service.buildTransaction(yaml, utxos(), protocolParamsJson, null)); + } + + private static void assertBuilt(JsonNode result) { + assertFalse(result.get("tx_cbor").asText().isEmpty()); + assertEquals(64, result.get("tx_hash").asText().length()); + assertTrue(Long.parseLong(result.get("fee").asText()) > 0); + } + + @Test + void simplePayment() throws Exception { + String yaml = """ + version: 1.0 + transaction: + - tx: + from: %s + intents: + - type: payment + address: %s + amounts: + - unit: lovelace + quantity: "5000000" + """.formatted(sender, receiver1); + assertBuilt(build(yaml)); + } + + @Test + void multiplePayments() throws Exception { + String yaml = """ + version: 1.0 + transaction: + - tx: + from: %s + intents: + - type: payment + address: %s + amounts: + - unit: lovelace + quantity: "5000000" + - type: payment + address: %s + amounts: + - unit: lovelace + quantity: "3000000" + """.formatted(sender, receiver1, receiver2); + assertBuilt(build(yaml)); + } + + @Test + void paymentWithMetadata() throws Exception { + // The metadata intent's value is a scalar string the deserializer auto-detects; JSON + // (starting with '{') is parsed via MetadataBuilder.metadataFromJson. + String yaml = """ + version: 1.0 + transaction: + - tx: + from: %s + intents: + - type: payment + address: %s + amounts: + - unit: lovelace + quantity: "2000000" + - type: metadata + metadata: '{"674": {"msg": "Hello from CCL Bridge"}}' + """.formatted(sender, receiver1); + JsonNode result = build(yaml); + assertBuilt(result); + + // The metadata must actually be attached: the tx body carries an auxiliary data hash. + Transaction tx = Transaction.deserialize(HexUtil.decodeHexString(result.get("tx_cbor").asText())); + assertNotNull(tx.getBody().getAuxiliaryDataHash(), "metadata should set the auxiliary data hash"); + } + + @Test + void variableSubstitution() throws Exception { + String yaml = """ + version: 1.0 + variables: + to: %s + amount: "4000000" + transaction: + - tx: + from: %s + intents: + - type: payment + address: ${to} + amounts: + - unit: lovelace + quantity: ${amount} + """.formatted(receiver1, sender); + assertBuilt(build(yaml)); + } + + @Test + void insufficientFundsFails() { + String yaml = """ + version: 1.0 + transaction: + - tx: + from: %s + intents: + - type: payment + address: %s + amounts: + - unit: lovelace + quantity: "200000000" + """.formatted(sender, receiver1); + assertThrows(Exception.class, () -> service.buildTransaction(yaml, utxos(), protocolParamsJson, null)); + } + + // An always-succeeds Plutus V2 minting policy. The script is never executed offline — the + // StaticTransactionEvaluator stamps the caller-supplied execution units onto the redeemer — + // so any valid Plutus CBOR is enough to build the transaction. + private static final String ALWAYS_SUCCEEDS_V2 = "4e4d01000033222220051200120011"; + + /** Build a Plutus mint as a TxPlan YAML (generated by CCL so the script-intent shape is exact). */ + private String mintScriptYaml() { + PlutusScript mintScript = PlutusV2Script.builder().cborHex(ALWAYS_SUCCEEDS_V2).build(); + Asset asset = new Asset("TestToken", BigInteger.ONE); + PlutusData redeemer = BigIntPlutusData.of(0); + Tx tx = new Tx() + .mintAsset(mintScript, asset, redeemer, sender) + .from(sender); + return TxPlan.from(tx).feePayer(sender).toYaml(); + } + + @Test + void plutusMintWithSuppliedExecUnits() throws Exception { + String yaml = mintScriptYaml(); + // One redeemer (the mint) → one ExUnits, supplied by the caller (as it would from Ogmios, + // Blockfrost, Aiken, Scalus, …). + String execUnits = "[{\"mem\": 2000000, \"steps\": 500000000}]"; + + JsonNode result = YamlSerializer.getYamlMapper() + .readTree(service.buildTransaction(yaml, utxos(), protocolParamsJson, execUnits)); + assertBuilt(result); + + // The built tx must carry the redeemer with exactly the supplied execution units. + Transaction tx = Transaction.deserialize(HexUtil.decodeHexString(result.get("tx_cbor").asText())); + assertNotNull(tx.getWitnessSet().getRedeemers()); + assertEquals(1, tx.getWitnessSet().getRedeemers().size()); + var exUnits = tx.getWitnessSet().getRedeemers().get(0).getExUnits(); + assertEquals(BigInteger.valueOf(2000000), exUnits.getMem()); + assertEquals(BigInteger.valueOf(500000000), exUnits.getSteps()); + } + + @Test + void plutusMintWithoutExecUnitsFails() { + String yaml = mintScriptYaml(); + // No execution units → no offline evaluator runs the script → the build fails. + assertThrows(Exception.class, + () -> service.buildTransaction(yaml, utxos(), protocolParamsJson, null)); } } diff --git a/core/src/test/java/com/bloxbean/cardano/bridge/api/QuickTxIntentsTest.java b/core/src/test/java/com/bloxbean/cardano/bridge/api/QuickTxIntentsTest.java new file mode 100644 index 0000000..f3d1f19 --- /dev/null +++ b/core/src/test/java/com/bloxbean/cardano/bridge/api/QuickTxIntentsTest.java @@ -0,0 +1,398 @@ +package com.bloxbean.cardano.bridge.api; + +import com.bloxbean.cardano.bridge.api.quicktx.QuickTxService; +import com.bloxbean.cardano.client.account.Account; +import com.bloxbean.cardano.client.address.Address; +import com.bloxbean.cardano.client.address.AddressProvider; +import com.bloxbean.cardano.client.address.Credential; +import com.bloxbean.cardano.client.api.model.Amount; +import com.bloxbean.cardano.client.api.model.Utxo; +import com.bloxbean.cardano.client.common.model.Networks; +import com.bloxbean.cardano.client.metadata.Metadata; +import com.bloxbean.cardano.client.metadata.MetadataBuilder; +import com.bloxbean.cardano.client.quicktx.AbstractTx; +import com.bloxbean.cardano.client.plutus.spec.BigIntPlutusData; +import com.bloxbean.cardano.client.plutus.spec.PlutusData; +import com.bloxbean.cardano.client.plutus.spec.PlutusScript; +import com.bloxbean.cardano.client.plutus.spec.PlutusV2Script; +import com.bloxbean.cardano.client.quicktx.Tx; +import com.bloxbean.cardano.client.quicktx.serialization.TxPlan; +import com.bloxbean.cardano.client.transaction.spec.governance.Anchor; +import com.bloxbean.cardano.client.transaction.spec.governance.DRep; +import com.bloxbean.cardano.client.transaction.spec.governance.Vote; +import com.bloxbean.cardano.client.transaction.spec.governance.Voter; +import com.bloxbean.cardano.client.transaction.spec.governance.VoterType; +import com.bloxbean.cardano.client.transaction.spec.governance.actions.GovActionId; +import com.bloxbean.cardano.client.transaction.spec.governance.actions.InfoAction; +import com.bloxbean.cardano.client.spec.UnitInterval; +import com.bloxbean.cardano.client.transaction.spec.Asset; +import com.bloxbean.cardano.client.transaction.spec.Policy; +import com.bloxbean.cardano.client.transaction.spec.cert.PoolRegistration; +import com.bloxbean.cardano.client.transaction.spec.cert.SingleHostAddr; +import com.bloxbean.cardano.client.transaction.spec.script.ScriptAll; +import com.bloxbean.cardano.client.util.HexUtil; +import com.bloxbean.cardano.client.util.PolicyUtil; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.InputStream; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Verifies the bridge builds each non-payment TxPlan intent (staking, governance, DRep, voting, + * proposals, …) offline. Each operation is built programmatically with CCL, serialized to TxPlan + * YAML via {@link TxPlan#toYaml()} (so the exact intent shape is authoritative), then built through + * the bridge with caller-supplied UTXOs + protocol parameters. + */ +class QuickTxIntentsTest { + + private static final String TEST_MNEMONIC = + "test walk nut penalty hip pave soap entry language right filter choice"; + private static final String FAKE_TX_HASH = "a".repeat(64); + private static final String POOL_ID = "pool1pu5jlj4q9w9jlxeu370a3c9myx47md5j5m2str0naunn2q3lkdy"; + private static final String GOV_ACTION_TX = "12745f09b138d4d0a11a560b4591ebb830cf12336347606d2edbbf1893d395c6"; + + private final QuickTxService service = new QuickTxService(); + + private String protocolParamsJson; + private Account account; + private String sender; + private String sender2; + private String stakeAddress; + private Credential drepCredential; + + @BeforeEach + void setUp() throws IOException { + try (InputStream is = getClass().getClassLoader().getResourceAsStream("protocol-params.json")) { + protocolParamsJson = new String(is.readAllBytes(), StandardCharsets.UTF_8); + } + account = Account.createFromMnemonic(Networks.testnet(), TEST_MNEMONIC, 0, 0); + sender = account.baseAddress(); + sender2 = Account.createFromMnemonic(Networks.testnet(), TEST_MNEMONIC, 0, 1).baseAddress(); + stakeAddress = account.stakeAddress(); + drepCredential = account.drepCredential(); + } + + private static final String REF_TX_HASH = "c".repeat(64); + + /** + * UTXOs at {@code sender}: a 2000-ADA one (covers deposits — gov action = 1000 ADA) plus a small + * one that the reference-input test can read. + */ + private String utxos() { + return """ + [{"tx_hash":"%s","output_index":0,"address":"%s", + "amount":[{"unit":"lovelace","quantity":"2000000000"}]}, + {"tx_hash":"%s","output_index":0,"address":"%s", + "amount":[{"unit":"lovelace","quantity":"5000000"}]}, + {"tx_hash":"%s","output_index":1,"address":"%s", + "amount":[{"unit":"lovelace","quantity":"2000000000"}]}] + """.formatted(FAKE_TX_HASH, sender, REF_TX_HASH, sender, FAKE_TX_HASH, sender2); + } + + /** + * Build a Tx through the bridge (TxPlan YAML -> offline build) and assert it produced CBOR. Also + * writes the generated TxPlan YAML to {@code build/intent-yamls/.yaml} so the wrapper + * end-to-end tests (Go) can drive the exact same intent through the native library. + */ + private void assertBuilds(String name, Tx tx) throws Exception { + String yaml = TxPlan.from(tx).feePayer(sender).toYaml(); + + java.nio.file.Path dir = java.nio.file.Path.of("build/intent-yamls"); + java.nio.file.Files.createDirectories(dir); + java.nio.file.Files.writeString(dir.resolve(name + ".yaml"), yaml); + + String resultYaml = service.buildTransaction(yaml, utxos(), protocolParamsJson, null); + var result = com.bloxbean.cardano.client.quicktx.serialization.YamlSerializer + .getYamlMapper().readTree(resultYaml); + assertFalse(result.get("tx_cbor").asText().isEmpty(), "tx_cbor should not be empty"); + assertEquals(64, result.get("tx_hash").asText().length()); + assertTrue(Long.parseLong(result.get("fee").asText()) > 0); + } + + private Anchor anchor() { + return new Anchor("https://example.com/meta.json", + com.bloxbean.cardano.client.util.HexUtil.decodeHexString(FAKE_TX_HASH)); + } + + // --- Staking --- + + @Test + void stakeRegistration() throws Exception { + assertBuilds("stake_registration", new Tx().registerStakeAddress(stakeAddress).from(sender)); + } + + @Test + void stakeDelegation() throws Exception { + // Delegation only (no registration): the integration test registers the stake address and a + // pool first, then delegates to that pool. + assertBuilds("stake_delegation", new Tx().delegateTo(stakeAddress, POOL_ID).from(sender)); + } + + @Test + void stakeDeregistration() throws Exception { + assertBuilds("stake_deregistration", new Tx().deregisterStakeAddress(stakeAddress, sender).from(sender)); + } + + @Test + void stakeWithdrawal() throws Exception { + assertBuilds("stake_withdrawal", new Tx().withdraw(stakeAddress, BigInteger.ZERO).from(sender)); + } + + // --- Metadata --- + + @Test + void metadata() throws Exception { + Metadata md = MetadataBuilder.metadataFromJson("{\"674\":{\"msg\":\"Hello from CCL Bridge\"}}"); + assertBuilds("metadata", new Tx() + .payToAddress(account.enterpriseAddress(), Amount.ada(2)) + .attachMetadata(md) + .from(sender)); + } + + // --- Compose (multiple senders into one transaction) --- + + @Test + void compose() throws Exception { + String receiver = account.enterpriseAddress(); + Tx tx1 = new Tx().payToAddress(receiver, Amount.ada(5)).from(sender); + Tx tx2 = new Tx().payToAddress(receiver, Amount.ada(3)).from(sender2); + String yaml = TxPlan.from(List.>of(tx1, tx2)).feePayer(sender).toYaml(); + + java.nio.file.Path dir = java.nio.file.Path.of("build/intent-yamls"); + java.nio.file.Files.createDirectories(dir); + java.nio.file.Files.writeString(dir.resolve("compose.yaml"), yaml); + + var result = com.bloxbean.cardano.client.quicktx.serialization.YamlSerializer.getYamlMapper() + .readTree(service.buildTransaction(yaml, utxos(), protocolParamsJson, null)); + assertFalse(result.get("tx_cbor").asText().isEmpty()); + assertEquals(64, result.get("tx_hash").asText().length()); + assertTrue(Long.parseLong(result.get("fee").asText()) > 0); + } + + // --- Treasury --- + + @Test + void donation() throws Exception { + // currentTreasuryValue is 0 to match a freshly-reset devnet (the Conway donation cert + // asserts the stated treasury equals the chain's actual value at submit time). + assertBuilds("donation", new Tx() + .donateToTreasury(BigInteger.ZERO, BigInteger.valueOf(1_000_000L)) + .from(sender)); + } + + // --- DRep --- + + @Test + void drepRegistration() throws Exception { + assertBuilds("drep_registration", new Tx().registerDRep(drepCredential, anchor()).from(sender)); + } + + @Test + void drepDeregistration() throws Exception { + assertBuilds("drep_deregistration", new Tx().unregisterDRep(drepCredential).from(sender)); + } + + @Test + void drepUpdate() throws Exception { + assertBuilds("drep_update", new Tx().updateDRep(drepCredential, anchor()).from(sender)); + } + + // --- Voting & proposals --- + + @Test + void voting() throws Exception { + Voter voter = new Voter(VoterType.DREP_KEY_HASH, drepCredential); + assertBuilds("voting", new Tx() + .createVote(voter, new GovActionId(GOV_ACTION_TX, 0), Vote.YES, anchor()) + .from(sender)); + } + + @Test + void votingDelegation() throws Exception { + assertBuilds("voting_delegation", new Tx() + .delegateVotingPowerTo(new Address(stakeAddress), DRep.abstain()) + .from(sender)); + } + + @Test + void governanceProposalInfoAction() throws Exception { + assertBuilds("governance_proposal", new Tx() + .createProposal(new InfoAction(), stakeAddress, anchor()) + .from(sender)); + } + + // --- Stake pools --- + + private PoolRegistration samplePool() { + // Key the pool to the account's stake key (operator + owner + reward account) so the pool + // registration can be witnessed by signing with the account's stake key — no separate pool + // cold key is needed. The reward account must be a registered stake address (the integration + // test registers it first). + byte[] stakeAddrBytes = new Address(account.stakeAddress()).getBytes(); + byte[] stakeKeyHash = java.util.Arrays.copyOfRange(stakeAddrBytes, 1, stakeAddrBytes.length); + return PoolRegistration.builder() + .operator(stakeKeyHash) + .vrfKeyHash(HexUtil.decodeHexString("b95af7a0a58928fbd0e73b03ce81dedd42d4a776685b443cf2016c18438a3b9b")) + .pledge(BigInteger.valueOf(100_000_000L)) + .cost(BigInteger.valueOf(340_000_000L)) + .margin(new UnitInterval(BigInteger.valueOf(1), BigInteger.valueOf(100))) + .rewardAccount(HexUtil.encodeHexString(stakeAddrBytes)) + .poolOwners(Set.of(HexUtil.encodeHexString(stakeKeyHash))) + .relays(List.of(SingleHostAddr.builder().port(3001).build())) + .build(); + } + + @Test + void poolRegistration() throws Exception { + assertBuilds("pool_registration", new Tx().registerPool(samplePool()).from(sender)); + } + + @Test + void poolUpdate() throws Exception { + assertBuilds("pool_update", new Tx().updatePool(samplePool()).from(sender)); + } + + @Test + void poolRetirement() throws Exception { + assertBuilds("pool_retirement", new Tx().retirePool(POOL_ID, 500).from(sender)); + } + + // --- Native scripts, explicit & reference inputs --- + + @Test + void nativeMinting() throws Exception { + // An empty ScriptAll requires no signatures (vacuously true), so the minted policy needs no + // policy-key witness — the fee payer alone can submit it. + ScriptAll noKeyPolicy = new ScriptAll(); + assertBuilds("minting", new Tx() + .mintAssets(noKeyPolicy, new Asset("TestNFT", BigInteger.ONE), account.enterpriseAddress()) + .from(sender)); + } + + @Test + void nativeScriptAttachment() throws Exception { + Policy policy = PolicyUtil.createMultiSigScriptAllPolicy("test-policy", 1); + assertBuilds("native_script", new Tx() + .attachNativeScript(policy.getPolicyScript()) + .payToAddress(account.enterpriseAddress(), Amount.ada(5)) + .from(sender)); + } + + @Test + void collectFromRegular() throws Exception { + Utxo senderUtxo = Utxo.builder() + .txHash(FAKE_TX_HASH).outputIndex(0).address(sender) + .amount(List.of(Amount.ada(2000))) + .build(); + assertBuilds("collect_from", new Tx() + .collectFrom(List.of(senderUtxo)) + .payToAddress(account.enterpriseAddress(), Amount.ada(5)) + .from(sender)); + } + + @Test + void referenceInput() throws Exception { + assertBuilds("reference_input", new Tx() + .readFrom(REF_TX_HASH, 0) + .payToAddress(account.enterpriseAddress(), Amount.ada(5)) + .from(sender)); + } + + // --- Plutus scripts (mint + spend) --- + + private static final String ALWAYS_SUCCEEDS_V2 = "4e4d01000033222220051200120011"; + + @Test + void scriptMinting() throws Exception { + PlutusScript script = PlutusV2Script.builder().cborHex(ALWAYS_SUCCEEDS_V2).build(); + PlutusData redeemer = BigIntPlutusData.of(0); + Tx tx = new Tx() + .mintAsset(script, new Asset("TestToken", BigInteger.ONE), redeemer, account.enterpriseAddress()) + .from(sender); + + String yaml = TxPlan.from(tx).feePayer(sender).toYaml(); + java.nio.file.Path dir = java.nio.file.Path.of("build/intent-yamls"); + java.nio.file.Files.createDirectories(dir); + java.nio.file.Files.writeString(dir.resolve("script_minting.yaml"), yaml); + + String execUnits = "[{\"mem\": 2000000, \"steps\": 500000000}]"; + var result = com.bloxbean.cardano.client.quicktx.serialization.YamlSerializer.getYamlMapper() + .readTree(service.buildTransaction(yaml, utxos(), protocolParamsJson, execUnits)); + assertFalse(result.get("tx_cbor").asText().isEmpty()); + assertEquals(64, result.get("tx_hash").asText().length()); + assertTrue(Long.parseLong(result.get("fee").asText()) > 0); + } + + @Test + void plutusLock() throws Exception { + // Pays a UTXO to the always-succeeds script address carrying the datum hash, so a later + // script spend has something to collect. The integration test submits this first. + PlutusScript script = PlutusV2Script.builder().cborHex(ALWAYS_SUCCEEDS_V2).build(); + String scriptAddr = AddressProvider.getEntAddress(script, Networks.testnet()).toBech32(); + PlutusData datum = BigIntPlutusData.of(42); + + Tx tx = new Tx() + .payToContract(scriptAddr, Amount.ada(10), datum.getDatumHash()) + .from(sender); + + String yaml = TxPlan.from(tx).feePayer(sender).toYaml(); + java.nio.file.Path dir = java.nio.file.Path.of("build/intent-yamls"); + java.nio.file.Files.createDirectories(dir); + java.nio.file.Files.writeString(dir.resolve("plutus_lock.yaml"), yaml); + + var result = com.bloxbean.cardano.client.quicktx.serialization.YamlSerializer.getYamlMapper() + .readTree(service.buildTransaction(yaml, utxos(), protocolParamsJson, null)); + assertFalse(result.get("tx_cbor").asText().isEmpty()); + assertEquals(64, result.get("tx_hash").asText().length()); + } + + @Test + void scriptCollectFrom() throws Exception { + PlutusScript script = PlutusV2Script.builder().cborHex(ALWAYS_SUCCEEDS_V2).build(); + String scriptAddr = AddressProvider.getEntAddress(script, Networks.testnet()).toBech32(); + PlutusData datum = BigIntPlutusData.of(42); + PlutusData redeemer = BigIntPlutusData.of(0); + String scriptTxHash = "b".repeat(64); + + Utxo scriptUtxo = Utxo.builder() + .txHash(scriptTxHash).outputIndex(0).address(scriptAddr) + .amount(List.of(Amount.ada(10))) + .dataHash(datum.getDatumHash()) + .build(); + + Tx tx = new Tx() + .collectFrom(scriptUtxo, redeemer, datum) + .payToAddress(account.enterpriseAddress(), Amount.ada(5)) + .attachSpendingValidator(script) + .from(sender); + + String yaml = TxPlan.from(tx).feePayer(sender).toYaml(); + java.nio.file.Path dir = java.nio.file.Path.of("build/intent-yamls"); + java.nio.file.Files.createDirectories(dir); + java.nio.file.Files.writeString(dir.resolve("script_collect_from.yaml"), yaml); + + // The script UTXO (to spend) + a sender UTXO (fees + collateral). Plutus spends need + // caller-supplied execution units (one redeemer here). + String utxosJson = """ + [{"tx_hash":"%s","output_index":0,"address":"%s", + "amount":[{"unit":"lovelace","quantity":"10000000"}],"data_hash":"%s"}, + {"tx_hash":"%s","output_index":0,"address":"%s", + "amount":[{"unit":"lovelace","quantity":"2000000000"}]}] + """.formatted(scriptTxHash, scriptAddr, datum.getDatumHash(), FAKE_TX_HASH, sender); + String execUnits = "[{\"mem\": 2000000, \"steps\": 500000000}]"; + + String resultYaml = service.buildTransaction(yaml, utxosJson, protocolParamsJson, execUnits); + var result = com.bloxbean.cardano.client.quicktx.serialization.YamlSerializer + .getYamlMapper().readTree(resultYaml); + assertFalse(result.get("tx_cbor").asText().isEmpty()); + assertEquals(64, result.get("tx_hash").asText().length()); + assertTrue(Long.parseLong(result.get("fee").asText()) > 0); + } +} diff --git a/docs/adr/0001-native-shared-library-ffi.md b/docs/adr/0001-native-shared-library-ffi.md new file mode 100644 index 0000000..b406662 --- /dev/null +++ b/docs/adr/0001-native-shared-library-ffi.md @@ -0,0 +1,39 @@ +# ADR-0001: Native shared library via GraalVM native-image + C FFI + +- **Status:** Accepted +- **Date:** 2026-02-11 +- **Deciders:** bloxbean maintainers + +## Context + +Cardano Client Lib (CCL) is a mature, actively-maintained JVM library for Cardano transaction building, +crypto, and serialization. Other ecosystems (Python, Go, Rust, JavaScript) rely on their own native +implementations, which vary in completeness and — as has happened in the Cardano ecosystem — can stop +being maintained abruptly, leaving downstream users stranded. We wanted a **maintained alternative** +that reuses CCL's exact, well-tested behavior from other languages, with native startup and no JVM at +runtime — without having to build and keep four independent reimplementations in lockstep ourselves. + +## Decision + +Compile CCL into a single **native shared library** (`libccl`) using **GraalVM native-image**, exposing +a stable **C ABI** via `@CEntryPoint` exports, and bind to it from each language through that language's +FFI. No JVM is shipped or required at runtime. Data crosses the boundary as C strings (JSON/YAML/hex). + +## Consequences + +- One core codebase reused everywhere; CCL semantics are identical across all languages. +- Native startup, small footprint, no JVM dependency. +- native-image constraints become ours: reflection must be registered (`reflect-config.json`), + some libraries need build-time initialization, builds are slower. +- The C ABI is a lowest-common-denominator interface (strings in/out, manual memory release). +- Portability of the produced `.so`/`.dylib`/`.dll` becomes a real concern — see [ADR-0008](0008-linux-glibc-baseline-portability.md). + +## Alternatives considered + +- **Rely solely on existing per-language native libraries** — they exist and work, but carry the risk + of being abruptly abandoned; CCL Bridge is the maintained fallback. (Building and maintaining our + *own* four independent reimplementations would also be a large, duplicated effort with correctness + drift across languages.) +- **JNI / embedded JVM** — ships and runs a JVM; heavy footprint and startup. +- **REST sidecar service** — network hop, stateful, operational burden; contradicts an offline, + in-process model ([ADR-0002](0002-offline-stateless-no-provider.md)). diff --git a/docs/adr/0002-offline-stateless-no-provider.md b/docs/adr/0002-offline-stateless-no-provider.md new file mode 100644 index 0000000..cf6bc49 --- /dev/null +++ b/docs/adr/0002-offline-stateless-no-provider.md @@ -0,0 +1,35 @@ +# ADR-0002: Offline, stateless bridge — caller-supplied chain data, no HTTP provider in libccl + +- **Status:** Accepted +- **Date:** 2026-02-11 +- **Deciders:** bloxbean maintainers + +## Context + +Transaction building needs chain data — UTxOs and protocol parameters (and, for scripts, execution +units — [ADR-0007](0007-caller-supplied-plutus-exec-units.md)). CCL can fetch these via providers +(Blockfrost/Koios/Ogmios). Baking HTTP providers into the native library would pull networking, retry +state, configuration, and secret handling into what is otherwise a side-effect-free FFI boundary — and +every host language already has excellent HTTP clients. + +## Decision + +`libccl` is **offline, stateless, and side-effect-free**: it makes no network calls and never submits. +The **caller supplies all chain data** as explicit inputs (UTxOs, protocol parameters; exec units for +Plutus). HTTP provider modules are **out of scope for the native lib**. Optional convenience helpers +that *fetch* this data may live in the **wrappers** ([ADR-0003](0003-four-language-wrappers-uniform-ffi.md)), +using each language's own HTTP client — never inside `libccl`. + +## Consequences + +- Deterministic, easily testable; no secrets or keys to manage inside the library. +- Submission/broadcast is the caller's responsibility, with their own client. +- Callers must obtain UTxOs / params / exec units themselves — friction, mitigated by planned + wrapper-side helpers (TODO §2b exec-unit evaluators, §2c chain-data providers). +- No lazy fetching; integration tests pass static data in. + +## Alternatives considered + +- **Built-in HTTP provider in libccl** — rejected: state, networking, and secrets inside an FFI lib. +- **Provider as a separate native module** — possible future, but wrapper-side helpers are preferred + to keep the core pure. diff --git a/docs/adr/0003-four-language-wrappers-uniform-ffi.md b/docs/adr/0003-four-language-wrappers-uniform-ffi.md new file mode 100644 index 0000000..2dc1d52 --- /dev/null +++ b/docs/adr/0003-four-language-wrappers-uniform-ffi.md @@ -0,0 +1,40 @@ +# ADR-0003: One FFI, four language wrappers — a uniform, thin contract with explicit inputs + +- **Status:** Accepted +- **Date:** 2026-02-11 +- **Deciders:** bloxbean maintainers + +## Context + +The native library ([ADR-0001](0001-native-shared-library-ffi.md)) exposes a C ABI. We want first-class +support in **Python, Go, Rust, and JavaScript**, with consistent behavior and minimal maintenance. +Early wrappers carried large per-language *fluent builders* (~10k LOC across the four) whose only job +was emitting the bridge's transaction format; these drifted and duplicated logic. + +## Decision + +Support exactly **four language wrappers** — Python (ctypes), Go (cgo), Rust, JavaScript (Bun FFI — +[ADR-0004](0004-bun-only-javascript-runtime.md)) — all binding the **same** C ABI. Keep wrappers +**thin**: they marshal inputs to the FFI and parse results; all logic lives once in the core. Chain +data (UTxOs, protocol params, Plutus exec units) is passed **explicitly** to the build call rather than +fetched ([ADR-0002](0002-offline-stateless-no-provider.md)). + +We accept that explicit inputs are slightly more work for users today, and **plan an optional +convenience layer** (provider/evaluator helpers) per wrapper to make it easier — without moving logic +out of the core or breaking the offline contract (TODO §2b/§2c). + +## Consequences + +- Consistent semantics across languages; a new feature = one core change + thin wrapper plumbing. +- The thin contract became concrete with the TxPlan migration + ([ADR-0006](0006-txplan-yaml-transaction-format.md)), which deleted the fluent builders. +- Four FFI integrations to maintain, each with quirks (e.g. Go threading — + [ADR-0010](0010-go-isolate-thread-affinity.md)). +- Until the convenience layer ships, users fetch chain data themselves. + +## Alternatives considered + +- **Fat per-language builders** — drift and duplication; removed in ADR-0006. +- **Fewer languages / one blessed language** — less ecosystem reach. +- **Fetching inputs inside the wrappers by default** — kept optional, to preserve the explicit, + offline contract and let users bring their own data source. diff --git a/docs/adr/0004-bun-only-javascript-runtime.md b/docs/adr/0004-bun-only-javascript-runtime.md new file mode 100644 index 0000000..e9d1aed --- /dev/null +++ b/docs/adr/0004-bun-only-javascript-runtime.md @@ -0,0 +1,29 @@ +# ADR-0004: Bun is the only supported JavaScript runtime + +- **Status:** Accepted +- **Date:** 2026-02-11 +- **Deciders:** bloxbean maintainers + +## Context + +The JavaScript wrapper needs FFI into `libccl`. Node.js FFI libraries (`ffi-napi`, `koffi`) crash +against the GraalVM native-image library due to stack-boundary detection issues (notably on macOS +ARM64). Bun ships a built-in, stable FFI (`bun:ffi`). + +## Decision + +Support **Bun** as the only JavaScript runtime for the JS wrapper. **Node.js is not supported** — it is +tracked as a wanted-but-blocked investigation, not a committed deliverable. + +## Consequences + +- A working, stable JS FFI path via `bun:ffi`. +- Node.js users are not served directly; Bun is required to use the JS wrapper. +- Revisit if/when Node FFI stabilizes against GraalVM native-image. + +## Alternatives considered + +- **`ffi-napi` / `koffi` on Node** — crashes against the native-image library (stack boundaries). +- **A WebAssembly build** — different toolchain and a large, separate effort. +- **A local helper service for JS** — rejected; contradicts the in-process, offline model + ([ADR-0002](0002-offline-stateless-no-provider.md)). diff --git a/docs/adr/0005-oracle-graalvm-25.md b/docs/adr/0005-oracle-graalvm-25.md new file mode 100644 index 0000000..736202a --- /dev/null +++ b/docs/adr/0005-oracle-graalvm-25.md @@ -0,0 +1,26 @@ +# ADR-0005: Standardize on Oracle GraalVM 25.0.3 + +- **Status:** Accepted +- **Date:** 2026-06-10 +- **Deciders:** bloxbean maintainers + +## Context + +CI floated `java-version: '25'` (a moving target), while local builds and docs referenced varying +setups. native-image behavior, available flags, and the produced binary can shift across GraalVM +versions, so an unpinned toolchain undermines reproducibility. + +## Decision + +Pin the **entire project** — local builds, CI, and release — to **Oracle GraalVM 25.0.3** exactly +(`distribution: 'graalvm'`, `java-version: '25.0.3'`). + +## Consequences + +- Reproducible builds; consistent native-image behavior across machines and CI. +- Adopting a newer GraalVM patch/feature release is a deliberate, single-point bump. + +## Alternatives considered + +- **Floating `'25'`** — non-reproducible; silent behavior changes on runner image updates. +- **GraalVM Community Edition** — we standardized on Oracle GraalVM (the `graalvm` distribution). diff --git a/docs/adr/0006-txplan-yaml-transaction-format.md b/docs/adr/0006-txplan-yaml-transaction-format.md new file mode 100644 index 0000000..4a4e68f --- /dev/null +++ b/docs/adr/0006-txplan-yaml-transaction-format.md @@ -0,0 +1,38 @@ +# ADR-0006: TxPlan (YAML) as the transaction-building format, replacing the bespoke JSON spec + +- **Status:** Accepted +- **Date:** 2026-06-11 +- **Deciders:** bloxbean maintainers + +## Context + +The bridge originally defined transactions with a **bespoke JSON operations spec**, parsed by +hand-written mappers (~1,500 LOC) into CCL `Tx`/`ScriptTx`, plus large per-language fluent builders +(~10k LOC) whose only job was to emit that JSON. CCL `0.8.0-pre4` ships **TxPlan** — a first-class YAML +transaction format that deserializes into CCL's own `AbstractTx` objects and builds offline to CBOR. + +The bridge is new and pre-1.0 with, as far as we know, **no production consumers yet**, so we were free +to replace the transaction format outright rather than evolve it — and doing so now, before adoption, is +the point at which it costs nothing. + +## Decision + +Adopt CCL **TxPlan (YAML)** as the transaction-building input. `ccl_quicktx_build` takes a TxPlan YAML +document plus caller-supplied chain data ([ADR-0002](0002-offline-stateless-no-provider.md)) and returns +the result as **YAML** (`tx_cbor`, `tx_hash`, `fee`). Delete the bespoke spec, its mappers, the provider +path, and all per-language fluent builders; wrappers become thin pass-throughs +([ADR-0003](0003-four-language-wrappers-uniform-ffi.md)). + +## Consequences + +- ~−11,300 net LOC; one authoritative format (CCL's own) instead of a custom one to maintain. +- Wrappers reduce to `build(yaml, utxos, protocolParams, execUnits?)`. +- Couples us to CCL's TxPlan schema and to a **preview** release (`0.8.0-pre4`) — re-pin when `0.8.0` + is stable. +- The input/output format changed completely, but with no known consumers this was a clean swap, not a + migration — and adopting TxPlan pre-1.0 is what spares us a genuinely breaking change later. + +## Alternatives considered + +- **Keep/extend the bespoke spec** — perpetual maintenance and divergence from CCL. +- **JSON result output** — chose YAML in *and* out for consistency with the TxPlan format. diff --git a/docs/adr/0007-caller-supplied-plutus-exec-units.md b/docs/adr/0007-caller-supplied-plutus-exec-units.md new file mode 100644 index 0000000..1b2b53e --- /dev/null +++ b/docs/adr/0007-caller-supplied-plutus-exec-units.md @@ -0,0 +1,33 @@ +# ADR-0007: Plutus execution units are caller-supplied; the bridge stays evaluator-agnostic + +- **Status:** Accepted +- **Date:** 2026-06-11 +- **Deciders:** bloxbean maintainers + +## Context + +Building Plutus script transactions requires **execution units** (memory + CPU steps) per redeemer, +normally produced by a UPLC evaluator. CCL `0.8.0-pre4` has no offline UPLC evaluator usable inside a +GraalVM native image; running scripts in-library would mean bundling an evaluator (e.g. +`aiken-java-binding`), which is not feasible/initializable in a native image today. + +## Decision + +Treat exec units like UTxOs and protocol params — a **caller-supplied input** (`exec_units_json`, one +`{mem, steps}` per redeemer in transaction order). The bridge wires CCL's `StaticTransactionEvaluator` +to stamp them onto the transaction and **never runs the script**. Callers compute units with whatever +evaluator they prefer (Blockfrost / Ogmios / Aiken / Scalus). A script build with no units fails with a +clear error. + +## Consequences + +- Offline Plutus building works today, consistent with the offline contract + ([ADR-0002](0002-offline-stateless-no-provider.md)). +- The bridge stays evaluator-agnostic; users pick and choose. +- Users need an external evaluator to *obtain* the units — planned wrapper helpers (TODO §2b). +- Self-contained in-library evaluation is deferred (spike: `aiken-java-binding` inside the native image). + +## Alternatives considered + +- **Bundle a UPLC evaluator inside libccl** — not feasible in a native image today; revisit later. +- **Refuse Plutus entirely** — too limiting; scripts are core to Cardano use. diff --git a/docs/adr/0008-linux-glibc-baseline-portability.md b/docs/adr/0008-linux-glibc-baseline-portability.md new file mode 100644 index 0000000..a17da30 --- /dev/null +++ b/docs/adr/0008-linux-glibc-baseline-portability.md @@ -0,0 +1,49 @@ +# ADR-0008: Linux native-lib portability — glibc-baseline build + `-march=compatibility` (not static) + +- **Status:** Accepted +- **Date:** 2026-06-25 +- **Deciders:** bloxbean maintainers (with Satya's review) + +## Context + +The shipped Linux `libccl.so` was built on `ubuntu-latest` (glibc ~2.39), so it failed to load on older +distros (`version 'GLIBC_2.3x' not found`). We explored shipping a **fully static, no-`.so`** library to +be distro-independent. A spike established two hard facts: + +1. GraalVM native-image **cannot emit a static library** (`.a`) — `oracle/graal#3053`, still open — and + musl's run-anywhere property applies only to static **executables**, not shared libraries. A truly + static, no-`.so` distribution would require re-architecting to an IPC subprocess model (rejected as + too invasive). +2. native-image defaults to the **build machine's CPU** instruction set, which can `SIGILL` on older / + datacenter CPUs lacking newer instructions (AVX2/AVX-512). + +## Decision + +Keep the in-process FFI **shared library** and achieve portability on two axes: + +1. **glibc baseline** — build the Linux `.so` inside `manylinux_2_28`. The result requires only + `GLIBC_2.17`, so it runs on **glibc ≥ 2.17** (RHEL/CentOS 7+, Amazon Linux 2, Ubuntu 18.04+, + Debian 9+, and all newer). +2. **CPU baseline** — set `-march=compatibility` in `native-image.properties` so the binary uses only + instructions common to all CPUs of the architecture. + +Verified continuously by `portable-linux-lib.yml` (objdump glibc-floor assertion + a real run on +`centos:7`); `release.yml` ships the Linux artifact from the same container. macOS/Windows are +unaffected (stable ABIs). + +## Consequences + +- One portable `.so` across virtually every non-musl Linux of the last decade — no wrapper or + architecture changes ([ADR-0001](0001-native-shared-library-ffi.md), [ADR-0003](0003-four-language-wrappers-uniform-ffi.md)). +- CPU-portable; no `SIGILL` on older datacenter VMs. +- **Does not run on Alpine / musl-only systems** — documented limitation; a musl variant is deferred + and technically unproven for shared libraries. +- Linux release builds run inside a container (extra CI plumbing). + +## Alternatives considered + +- **Static library** — impossible (`oracle/graal#3053`). +- **IPC static musl executable** — meets "no dynamic linking" literally, but a large re-architecture + with per-call overhead; rejected. +- **musl shared library** — unproven for `--shared`; bloxbean also dropped musl builds for Yaci Store. +- **Build on `ubuntu-latest`** — the status quo that fails on older distros. diff --git a/docs/adr/0010-go-isolate-thread-affinity.md b/docs/adr/0010-go-isolate-thread-affinity.md new file mode 100644 index 0000000..b4b9e16 --- /dev/null +++ b/docs/adr/0010-go-isolate-thread-affinity.md @@ -0,0 +1,30 @@ +# ADR-0010: Go wrapper isolate thread-affinity — all FFI on one dedicated OS thread + +- **Status:** Accepted +- **Date:** 2026-06-10 +- **Deciders:** bloxbean maintainers + +## Context + +A GraalVM isolate's `IsolateThread` is bound to the OS thread that created/attached it. Go goroutines +migrate freely across OS threads, so an FFI call could execute on a different OS thread than the one +that owns the isolate — producing a GraalVM "yellow zone" `StackOverflowError` (observed on Linux +x86_64, which forced the Go CI to be non-blocking). + +## Decision + +In the Go wrapper, pin **all FFI calls to a single dedicated OS thread** for the `Bridge`'s lifetime: a +`runtime.LockOSThread`'d executor goroutine serializes every native call onto the thread that owns the +isolate (calls are submitted over a channel and run there). + +## Consequences + +- Eliminates the thread-migration crash; Linux Go CI is blocking and green again. +- Native calls are **serialized per `Bridge`** — correctness at the FFI boundary over raw concurrency. +- One dedicated OS thread per `Bridge` (acceptable for this workload). + +## Alternatives considered + +- **Attach/detach the isolate on every call** — per-call overhead and easy to get wrong. +- **Only raise the native-image stack size** — masks the symptom without fixing the root cause. +- **Document "don't call from multiple goroutines"** — fragile; pushes a sharp edge onto users. diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 0000000..a08731d --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,34 @@ +# Architecture Decision Records (ADRs) + +This directory records the **significant architectural decisions** for CCL Bridge — the *why* +behind choices that aren't obvious from the code, so a future maintainer doesn't unknowingly +undo them. + +## Convention + +- One decision per file, named `NNNN-kebab-title.md` (zero-padded, e.g. `0001-...`). +- ADRs are **immutable once Accepted**. To change a decision, write a *new* ADR and mark the old + one `Superseded by ADR-XXXX` — don't rewrite history. +- Numbered in the order they are **recorded**. Several here are *retrospective* — they document + decisions taken earlier in the project; the **Date** field reflects when the decision was + actually made, not when it was written down. +- Start from [`template.md`](template.md). + +## Status legend + +`Proposed` → under discussion · `Accepted` → in effect · `Superseded` → replaced by a later ADR · +`Deprecated` → no longer relevant. + +## Index + +| ADR | Title | Status | Decided | +|-----|-------|--------|---------| +| [0001](0001-native-shared-library-ffi.md) | Native shared library via GraalVM native-image + C FFI | Accepted | 2026-02-11 | +| [0002](0002-offline-stateless-no-provider.md) | Offline, stateless bridge — caller-supplied chain data, no HTTP provider in libccl | Accepted | 2026-02-11 | +| [0003](0003-four-language-wrappers-uniform-ffi.md) | One FFI, four language wrappers — uniform thin contract with explicit inputs | Accepted | 2026-02-11 | +| [0004](0004-bun-only-javascript-runtime.md) | Bun is the only supported JavaScript runtime | Accepted | 2026-02-11 | +| [0005](0005-oracle-graalvm-25.md) | Standardize on Oracle GraalVM 25.0.3 | Accepted | 2026-06-10 | +| [0006](0006-txplan-yaml-transaction-format.md) | TxPlan (YAML) transaction format, replacing the bespoke JSON spec | Accepted | 2026-06-11 | +| [0007](0007-caller-supplied-plutus-exec-units.md) | Plutus execution units are caller-supplied; evaluator-agnostic | Accepted | 2026-06-11 | +| [0008](0008-linux-glibc-baseline-portability.md) | Linux portability — glibc-baseline build + `-march=compatibility` (not static) | Accepted | 2026-06-25 | +| [0010](0010-go-isolate-thread-affinity.md) | Go wrapper isolate thread-affinity — all FFI on one dedicated OS thread | Accepted | 2026-06-10 | diff --git a/docs/adr/template.md b/docs/adr/template.md new file mode 100644 index 0000000..e10eafd --- /dev/null +++ b/docs/adr/template.md @@ -0,0 +1,26 @@ +# ADR-NNNN: + +- **Status:** Proposed | Accepted | Superseded by [ADR-XXXX](xxxx-....md) | Deprecated +- **Date:** YYYY-MM-DD +- **Deciders:** + +## Context + +What is the problem and what forces are at play? Constraints, requirements, and the +relevant facts (e.g. tool capabilities, prior decisions this builds on). State things +that were true at decision time; link related ADRs. + +## Decision + +The decision, in active voice: "We will …". Be specific about scope and what is +explicitly *out* of scope. + +## Consequences + +What becomes easier and what becomes harder as a result. Follow-ups, risks accepted, +and anything that would invalidate or revisit this decision. + +## Alternatives considered + +Each option weighed, and why it was not chosen. This is the part future readers come +back for, so capture the ones that looked plausible. diff --git a/docs/quicktx.md b/docs/quicktx.md index 6e43877..bae7ab0 100644 --- a/docs/quicktx.md +++ b/docs/quicktx.md @@ -1,980 +1,293 @@ -# QuickTx — Transaction Builder +# QuickTx — TxPlan (YAML) Transaction Builder -QuickTx is a JSON-driven offline transaction builder exposed through a single C function: `ccl_quicktx_build`. You pass a JSON specification describing what the transaction should do — payments, staking, governance, Plutus scripts — and get back an unsigned transaction in CBOR hex. +QuickTx builds unsigned Cardano transactions **fully offline** from a CCL +[**TxPlan**](https://github.com/bloxbean/cardano-client-lib) — a YAML document describing what the +transaction should do. You pass the TxPlan YAML plus the chain data the build needs (UTXOs and +protocol parameters), and get back an unsigned transaction in CBOR hex. + +The whole interface is YAML: **TxPlan YAML in → YAML result out**. ## Overview -- **Single function**: `ccl_quicktx_build(thread, spec_json)` → returns `0` on success -- **Result**: JSON with `tx_cbor` (unsigned transaction), `tx_hash`, and `fee` -- **Offline by default**: supply UTXOs and protocol params inline — no HTTP calls from the native library -- **Provider mode**: optionally point to a Yaci DevKit (or compatible) API to fetch UTXOs automatically -- **Two transaction types**: `tx` (regular) and `script_tx` (Plutus script transactions) -- **Compose mode**: combine multiple sub-transactions (even from different senders) into one +- **Single function**: `ccl_quicktx_build(thread, yaml, utxos_json, protocol_params_json, exec_units_json)` → returns `0` on success. +- **Result**: a YAML document with `tx_cbor` (unsigned transaction), `tx_hash`, and `fee`. +- **Fully offline**: the caller supplies UTXOs and protocol parameters — the native library makes no + HTTP calls and never submits. (There is no provider mode; fetching chain data is the caller's job.) +- **Client-side chain data**: UTXOs and protocol parameters are passed as JSON (the standard CCL + `Utxo` / `ProtocolParams` models). + +### Entry point + +```c +int ccl_quicktx_build( + graal_isolatethread_t* thread, + const char* yaml, // TxPlan YAML + const char* utxos_json, // JSON array of UTXOs + const char* protocol_params_json, // JSON protocol parameters + const char* exec_units_json // JSON [{mem, steps}] per redeemer, or null (Plutus only) +); +``` -### Return Codes +### Return codes | Code | Meaning | |------|---------| -| `0` | Success — retrieve result via `ccl_get_result(thread)` | -| `-2` | Invalid argument or validation failure | +| `0` | Success — retrieve the result via `ccl_get_result(thread)` | +| `-2` | Invalid argument (e.g. missing YAML or protocol parameters) | | `-8` | Insufficient funds (UTXOs can't cover outputs + fees) | -| `-10` | Transaction build failure | +| `-10`| Transaction build failure (e.g. malformed TxPlan) | -### Success Result +### Success result (YAML) -```json -{ - "tx_cbor": "84a400...", - "tx_hash": "abcd1234...", - "fee": "173333" -} +```yaml +tx_cbor: 84a400... +tx_hash: abcd1234... +fee: "173333" ``` --- -## TxSpec — Top-Level JSON Structure - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `operations` | array | Yes (single mode) | List of operations to perform | -| `from` | string | Yes (`tx` mode) | Sender bech32 address | -| `change_address` | string | No | Change output address (defaults to `from`) | -| `fee_payer` | string | Yes (compose mode) | Address that pays the fee | -| `utxos` | array | Yes (inline mode) | Pre-supplied UTXOs for coin selection | -| `protocol_params` | object | Yes (inline mode) | Protocol parameters (Blockfrost/Koios/DevKit format) | -| `validity` | object | No | Slot-based validity interval | -| `signer_count` | integer | No | Number of signers for fee estimation (default: 1) | -| `merge_outputs` | boolean | No | Merge outputs to the same address | -| `transactions` | array | Yes (compose mode) | Array of sub-transactions | -| `provider` | object | No | Provider config for HTTP-based UTXO fetching | -| `tx_type` | string | No | `"tx"` (default) or `"script_tx"` | -| `change_datum_cbor_hex` | string | No | Inline datum CBOR hex for change output (script_tx) | -| `change_datum_hash` | string | No | Datum hash for change output (script_tx) | - -### Validity - -```json -"validity": { - "valid_from": 1000000, - "valid_to": 2000000 -} -``` - -### UTXO Format - -UTXOs use the standard Blockfrost/Koios/DevKit format: - -```json -{ - "tx_hash": "aaaa...64hex", - "output_index": 0, - "address": "addr_test1...", - "amount": [ - { "unit": "lovelace", "quantity": "100000000" }, - { "unit": "policy_hex+asset_name_hex", "quantity": "500" } - ] -} -``` +## TxPlan — YAML structure + +A TxPlan is a YAML document with an optional `version`, optional `variables` for substitution, and a +`transaction` list. Each list entry has a `tx` block with the sender (`from`) and a list of `intents`. + +```yaml +version: 1.0 + +# Optional: ${name} placeholders are substituted before the plan is parsed. +variables: + to: addr_test1... + amount: "5000000" + +transaction: + - tx: + from: addr_test1... # sender / default fee payer + intents: + - type: payment + address: ${to} + amounts: + - unit: lovelace + quantity: ${amount} +``` + +Multiple entries in `transaction` are **composed** into a single transaction (each `tx` may have a +different `from`). The `tx` block also accepts context such as a fee payer and a validity interval; +those fields follow CCL's TxPlan serialization (see the reference link above). + +### Intents + +Each intent has a `type` discriminator. The full set supported by CCL's TxPlan: + +| `type` | Purpose | +|--------|---------| +| `payment` | Pay ADA / native tokens to an address | +| `minting` | Mint/burn with a native script | +| `metadata` | Attach transaction metadata | +| `donation` | Treasury donation | +| `stake_registration` | Register a stake address | +| `stake_deregistration` | Deregister a stake address | +| `stake_delegation` | Delegate to a stake pool | +| `stake_withdrawal` | Withdraw staking rewards | +| `drep_registration` / `drep_deregistration` / `drep_update` | DRep lifecycle | +| `voting` | Cast a governance vote | +| `voting_delegation` | Delegate voting power to a DRep | +| `governance_proposal` | Submit a governance action | +| `pool_registration` / `pool_update` / `pool_retirement` | Stake-pool lifecycle | +| `collect_from` | Explicitly select input UTXOs | +| `reference_input` | Add read-only reference inputs | +| `native_script` | Attach a native script | +| `script_collect_from` / `script_minting` / `validator` | Plutus script operations | + +> The exact YAML fields for each intent come from CCL's TxPlan serialization. This bridge passes the +> YAML through unchanged, so the authoritative field reference is the CCL `quicktx` module +> (`intent/*Intent.java` and the `TxMetadataSerializationTest` / TxPlan tests at `v0.8.0-pre4`). +> Verified `payment` and `metadata` shapes are shown below. + +> **Plutus script transactions** build offline when you pass the redeemers' **execution units** in +> `exec_units_json` — a JSON array of `[{mem, steps}]`, one per redeemer in transaction order. The +> bridge wires CCL's `StaticTransactionEvaluator` to stamp them on; it never runs the script. You +> compute the units with any UPLC evaluator (Ogmios, Blockfrost, Aiken, Scalus) and pass them in, +> exactly as you pass UTXOs and protocol parameters. A script transaction built with no execution +> units fails with `-10` (no offline evaluator runs the script). --- -## Operations Reference - -### Payments - -#### `pay_to_address` - -Send ADA or tokens to an address. - -```json -{ - "type": "pay_to_address", - "address": "addr_test1...", - "amounts": [ - { "unit": "lovelace", "quantity": "5000000" }, - { "unit": "aabb...policy...asset_hex", "quantity": "100" } - ] -} -``` - -Optional fields: `script_ref_cbor_hex`, `script_ref_type` (attach a reference script to the output). - -#### `pay_to_contract` - -Send to a script address with a datum. - -```json -{ - "type": "pay_to_contract", - "address": "addr_test1wz...", - "amounts": [{ "unit": "lovelace", "quantity": "10000000" }], - "datum_cbor_hex": "d87980" -} -``` - -Use either `datum_cbor_hex` (inline datum) or `datum_hash`. Optional: `script_ref_cbor_hex`, `script_ref_type`. - -### Minting - -#### `mint_assets` (native scripts) - -Mint tokens using a native script (regular `tx` mode only). - -```json -{ - "type": "mint_assets", - "script_json": "{\"type\":\"all\",\"scripts\":[{\"type\":\"sig\",\"keyHash\":\"ab12...\"}]}", - "assets": [ - { "name": "MyToken", "quantity": "1000" } - ], - "receiver": "addr_test1..." -} -``` - -#### `mint_plutus_assets` (Plutus scripts) - -Mint tokens using a Plutus script (`script_tx` mode only). - -```json -{ - "type": "mint_plutus_assets", - "script_cbor_hex": "46450101002499", - "script_type": "plutus_v3", - "assets": [ - { "name": "TestToken", "quantity": "100" } - ], - "redeemer_cbor_hex": "d87980", - "receiver": "addr_test1..." -} -``` - -Optional: `output_datum_cbor_hex` (attach datum to mint output). +## Chain data (caller-supplied) -### Metadata +### UTXO format -#### `attach_metadata` - -Attach transaction metadata. +`utxos_json` is a JSON array in the standard Blockfrost/Koios/DevKit shape: ```json -{ - "type": "attach_metadata", - "label": 674, - "metadata": { - "msg": ["Hello from CCL Bridge"] +[ + { + "tx_hash": "aaaa...64hex", + "output_index": 0, + "address": "addr_test1...", + "amount": [ + { "unit": "lovelace", "quantity": "100000000" }, + { "unit": "policy_hex+asset_name_hex", "quantity": "500" } + ] } -} -``` - -The `metadata` field accepts strings, numbers, lists, and maps. - -### UTXO Collection - -#### `collect_from` - -Explicitly select input UTXOs (bypasses coin selection). In `script_tx` mode, include `redeemer_cbor_hex` and `datum_cbor_hex` for script-locked UTXOs. - -```json -{ - "type": "collect_from", - "collect_utxos": [ - { - "tx_hash": "aaaa...", - "output_index": 0, - "address": "addr_test1...", - "amount": [{ "unit": "lovelace", "quantity": "10000000" }] - } - ], - "redeemer_cbor_hex": "d87980", - "datum_cbor_hex": "d87980" -} -``` - -#### `read_from` (script_tx only) - -Add reference inputs (read-only, not consumed). - -```json -{ - "type": "read_from", - "reference_inputs": [ - { "tx_hash": "abcd...", "output_index": 0 } - ] -} -``` - -### Staking - -#### `register_stake_address` - -Register a stake address (regular `tx` mode only — not available in `script_tx`). - -```json -{ "type": "register_stake_address", "address": "stake_test1..." } -``` - -#### `deregister_stake_address` - -```json -{ - "type": "deregister_stake_address", - "address": "stake_test1...", - "refund_address": "addr_test1..." -} +] ``` -In `script_tx` mode, also requires `redeemer_cbor_hex`. - -#### `delegate_to` +### Protocol parameters -Delegate to a stake pool. - -```json -{ - "type": "delegate_to", - "address": "stake_test1...", - "pool_id": "pool1..." -} -``` - -In `script_tx` mode, also requires `redeemer_cbor_hex`. - -#### `withdraw` - -Withdraw staking rewards. - -```json -{ - "type": "withdraw", - "reward_address": "stake_test1...", - "amount": "5000000", - "receiver": "addr_test1..." -} -``` - -In `script_tx` mode, also requires `redeemer_cbor_hex`. - -### DRep Operations - -#### `register_drep` - -```json -{ - "type": "register_drep", - "credential_hash": "ab12...56hex", - "credential_type": "key", - "anchor_url": "https://example.com/drep.json", - "anchor_data_hash": "cd34...64hex" -} -``` - -`credential_type`: `"key"` (default) or `"script"`. In `script_tx` mode, also requires `redeemer_cbor_hex`. - -#### `unregister_drep` - -```json -{ - "type": "unregister_drep", - "credential_hash": "ab12...56hex", - "refund_address": "addr_test1...", - "refund_amount": "500000000" -} -``` - -#### `update_drep` - -```json -{ - "type": "update_drep", - "credential_hash": "ab12...56hex", - "anchor_url": "https://example.com/drep-updated.json", - "anchor_data_hash": "ef56...64hex" -} -``` - -### Voting - -#### `delegate_voting_power_to` - -```json -{ - "type": "delegate_voting_power_to", - "address": "stake_test1...", - "drep_type": "key_hash", - "drep_hash": "ab12...56hex" -} -``` - -`drep_type` values: `"key_hash"` (default), `"script_hash"`, `"abstain"`, `"no_confidence"`. - -#### `create_vote` - -```json -{ - "type": "create_vote", - "voter_type": "drep_key_hash", - "voter_hash": "ab12...56hex", - "gov_action_tx_hash": "aaaa...64hex", - "gov_action_index": 0, - "vote": "yes", - "anchor_url": "https://example.com/rationale.json", - "anchor_data_hash": "cd34...64hex" -} -``` - -`voter_type` values: `"drep_key_hash"`, `"drep_script_hash"`, `"staking_pool_key_hash"`, `"constitutional_committee_hot_key_hash"`, `"constitutional_committee_hot_script_hash"`. - -`vote` values: `"yes"`, `"no"`, `"abstain"`. - -### Governance Proposals - -#### `create_proposal` - -All proposals require `gov_action_type`, `return_address`, and optionally `anchor_url` / `anchor_data_hash`. Action-specific fields vary by type: - -**`info_action`** — No additional fields. - -```json -{ - "type": "create_proposal", - "gov_action_type": "info_action", - "return_address": "stake_test1...", - "anchor_url": "https://example.com/proposal.json", - "anchor_data_hash": "ab12...64hex" -} -``` - -**`treasury_withdrawals`** - -```json -{ - "type": "create_proposal", - "gov_action_type": "treasury_withdrawals", - "return_address": "stake_test1...", - "withdrawals": [ - { "reward_address": "stake_test1...", "amount": "50000000" } - ] -} -``` - -**`no_confidence`** - -```json -{ - "type": "create_proposal", - "gov_action_type": "no_confidence", - "return_address": "stake_test1...", - "gov_action_tx_hash": "prev_action_tx_hash", - "gov_action_index": 0 -} -``` - -**`update_committee`** - -```json -{ - "type": "create_proposal", - "gov_action_type": "update_committee", - "return_address": "stake_test1...", - "members_to_remove": [{ "hash": "ab12...", "type": "key" }], - "new_members": [{ "hash": "cd34...", "type": "key", "epoch": 500 }], - "quorum_numerator": "2", - "quorum_denominator": "3" -} -``` - -**`new_constitution`** - -```json -{ - "type": "create_proposal", - "gov_action_type": "new_constitution", - "return_address": "stake_test1...", - "constitution_anchor_url": "https://example.com/constitution.json", - "constitution_anchor_data_hash": "ef56...64hex", - "constitution_script_hash": "ab12...56hex" -} -``` - -**`hard_fork_initiation`** - -```json -{ - "type": "create_proposal", - "gov_action_type": "hard_fork_initiation", - "return_address": "stake_test1...", - "protocol_version_major": 10, - "protocol_version_minor": 0 -} -``` - -**`parameter_change`** - -```json -{ - "type": "create_proposal", - "gov_action_type": "parameter_change", - "return_address": "stake_test1...", - "policy_hash": "ab12...56hex" -} -``` - -### Pool Operations - -#### `register_pool` - -```json -{ - "type": "register_pool", - "operator": "ab12...56hex", - "vrf_key_hash": "cd34...64hex", - "pledge": "100000000", - "cost": "340000000", - "margin_numerator": "1", - "margin_denominator": "100", - "reward_address": "e0ab...58hex", - "pool_owners": ["ab12...56hex"], - "relays": [ - { "type": "single_host_addr", "port": 6000, "ipv4": "127.0.0.1" }, - { "type": "single_host_name", "port": 6000, "dns_name": "relay.example.com" }, - { "type": "multi_host_name", "dns_name": "pool.example.com" } - ], - "pool_metadata_url": "https://example.com/pool.json", - "pool_metadata_hash": "ef56...64hex" -} -``` - -#### `update_pool` - -Same fields as `register_pool`. - -#### `retire_pool` - -```json -{ - "type": "retire_pool", - "pool_id": "pool1...", - "epoch": 500 -} -``` - -### Treasury - -#### `donate_to_treasury` - -```json -{ - "type": "donate_to_treasury", - "treasury_value": "1000000000", - "donation_amount": "50000000" -} -``` - -### Script Validators (script_tx only) - -Attach validators to the transaction witness set. Required when spending from script addresses, minting with Plutus, or performing script-based staking/governance. - -```json -{ "type": "attach_spending_validator", "script_cbor_hex": "46450101002499", "script_type": "plutus_v3" } -{ "type": "attach_certificate_validator", "script_cbor_hex": "...", "script_type": "plutus_v3" } -{ "type": "attach_reward_validator", "script_cbor_hex": "...", "script_type": "plutus_v3" } -{ "type": "attach_proposing_validator", "script_cbor_hex": "...", "script_type": "plutus_v3" } -{ "type": "attach_voting_validator", "script_cbor_hex": "...", "script_type": "plutus_v3" } -``` - -`script_type` values: `"plutus_v1"`, `"plutus_v2"`, `"plutus_v3"`. - -### Native Scripts - -#### `attach_native_script` - -Attach a native script to the witness set (regular `tx` mode). - -```json -{ - "type": "attach_native_script", - "script_json": "{\"type\":\"all\",\"scripts\":[{\"type\":\"sig\",\"keyHash\":\"ab12...\"}]}" -} -``` - ---- - -## UTXO Sourcing Modes - -There are three ways to supply UTXOs and protocol parameters to QuickTx. - -### 1. Inline Mode - -The caller fetches UTXOs and protocol params from any source (Blockfrost, Koios, Ogmios, a database, etc.) and passes them directly in the JSON spec. - -``` -App fetches UTXOs + protocol params - ↓ -Builds JSON spec with inline "utxos" and "protocol_params" - ↓ -Calls ccl_quicktx_build - ↓ -Gets unsigned tx CBOR -``` - -**Example:** -```json -{ - "operations": [{ "type": "pay_to_address", "address": "...", "amounts": [...] }], - "from": "addr_test1...", - "utxos": [{ "tx_hash": "...", "output_index": 0, "address": "...", "amount": [...] }], - "protocol_params": { "min_fee_a": 44, "min_fee_b": 155381, "..." : "..." }, - "signer_count": 1 -} -``` - -Best for: full control over UTXO selection, custom backends, offline workflows, caching. - -### 2. Provider Mode - -Instead of inline data, set the `provider` field. The native library fetches UTXOs and protocol params via HTTP internally. - -``` -App builds JSON spec with "provider" config (no "utxos"/"protocol_params") - ↓ -Calls ccl_quicktx_build - ↓ -Library fetches UTXOs + params from provider via HTTP - ↓ -Builds transaction - ↓ -Returns unsigned tx CBOR -``` - -**Provider config:** -```json -{ - "provider": { - "name": "yaci", - "url": "http://localhost:8080/api/v1", - "api_key": "optional-api-key", - "enable_cost_evaluation": true - } -} -``` - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `name` | string | Yes | Provider name (currently only `"yaci"`) | -| `url` | string | Yes | Base URL of the API | -| `api_key` | string | No | API key for authentication | -| `enable_cost_evaluation` | boolean | No | Enable Plutus script cost evaluation (default: true) | - -The provider fetches UTXOs via `GET {url}/addresses/{address}/utxos` and protocol params via `GET {url}/epochs/parameters`. When `enable_cost_evaluation` is true and `script_tx` is used, it evaluates Plutus costs via `POST {url}/utils/txs/evaluate`. - -Best for: simple setups, quick prototyping, when a compatible provider is available. - -### 3. Wrapper-Level Provider - -Some wrappers (Python, JS) support a wrapper-level provider config. The wrapper handles HTTP calls (not the native library), then passes data inline. - -``` -App configures provider on wrapper - ↓ -Calls build via wrapper API - ↓ -Wrapper fetches UTXOs + params via HTTP - ↓ -Constructs inline spec with fetched data - ↓ -Calls ccl_quicktx_build - ↓ -Returns unsigned tx CBOR -``` - -Best for: wrapper users who want provider convenience with wrapper-native HTTP (better error handling, auth headers, retries). - -### Comparison - -| | Inline | Provider | Wrapper Provider | -|---|--------|----------|-----------------| -| **Who fetches UTXOs?** | Your app | Native library | Wrapper library | -| **HTTP calls from native lib?** | No | Yes | No | -| **Provider dependency** | None | Yaci-compatible API | Wrapper-specific | -| **Flexibility** | Maximum | Limited to supported providers | Medium | -| **Simplicity** | More setup | Simplest | Simple | -| **Offline capable?** | Yes | No | No | - ---- - -## Compose Mode - -Compose mode builds a single transaction from multiple sub-transactions, potentially from different senders. Set the `transactions` array instead of top-level `operations`. - -```json -{ - "transactions": [ - { - "from": "addr_test1_sender1...", - "operations": [ - { "type": "pay_to_address", "address": "addr_test1_receiver1...", - "amounts": [{ "unit": "lovelace", "quantity": "5000000" }] } - ] - }, - { - "from": "addr_test1_sender2...", - "operations": [ - { "type": "pay_to_address", "address": "addr_test1_receiver2...", - "amounts": [{ "unit": "lovelace", "quantity": "3000000" }] } - ] - } - ], - "fee_payer": "addr_test1_sender1...", - "utxos": [ - { "tx_hash": "aaa...", "output_index": 0, "address": "addr_test1_sender1...", - "amount": [{ "unit": "lovelace", "quantity": "100000000" }] }, - { "tx_hash": "bbb...", "output_index": 0, "address": "addr_test1_sender2...", - "amount": [{ "unit": "lovelace", "quantity": "100000000" }] } - ], - "protocol_params": { "..." : "..." } -} -``` - -Each sub-transaction item supports: - -| Field | Type | Required | -|-------|------|----------| -| `from` | string | Yes (except `script_tx`) | -| `change_address` | string | No | -| `operations` | array | Yes | -| `tx_type` | string | No (default `"tx"`) | -| `change_datum_cbor_hex` | string | No | -| `change_datum_hash` | string | No | - -You can mix `tx` and `script_tx` items in the same compose transaction. The `signer_count` defaults to the number of sub-transactions if not set. - ---- - -## Script_tx Mode - -Set `"tx_type": "script_tx"` to build Plutus script transactions. Key differences from regular `tx`: - -- `from` is optional (use `fee_payer` to specify who pays fees) -- Staking, DRep, voting, and governance operations require a `redeemer_cbor_hex` field -- `register_stake_address` is **not available** — use regular `tx` mode for that -- `mint_assets` is **not available** — use `mint_plutus_assets` instead -- Additional operations: `read_from`, `mint_plutus_assets`, `attach_*_validator` - -### Example: Collect from Script and Spend - -```json -{ - "tx_type": "script_tx", - "operations": [ - { - "type": "collect_from", - "collect_utxos": [ - { "tx_hash": "aaa...", "output_index": 0, "address": "addr_test1wz...", - "amount": [{ "unit": "lovelace", "quantity": "10000000" }] } - ], - "redeemer_cbor_hex": "d87980", - "datum_cbor_hex": "d87980" - }, - { - "type": "attach_spending_validator", - "script_cbor_hex": "46450101002499", - "script_type": "plutus_v3" - }, - { - "type": "pay_to_address", - "address": "addr_test1...", - "amounts": [{ "unit": "lovelace", "quantity": "5000000" }] - } - ], - "from": "addr_test1...", - "utxos": [ - { "tx_hash": "bbb...", "output_index": 0, "address": "addr_test1...", - "amount": [{ "unit": "lovelace", "quantity": "100000000" }] } - ], - "protocol_params": { "..." : "..." }, - "signer_count": 1 -} -``` +`protocol_params_json` is a JSON object in the standard protocol-parameters shape (`min_fee_a`, +`min_fee_b`, `key_deposit`, `pool_deposit`, `coins_per_utxo_size`, the Conway governance deposits, +etc.). The CCL `ProtocolParams` model deserializes it directly. --- ## Examples -### 1. Simple ADA Payment (Inline UTXOs) - -```json -{ - "operations": [ - { - "type": "pay_to_address", - "address": "addr_test1qz...", - "amounts": [{ "unit": "lovelace", "quantity": "5000000" }] - } - ], - "from": "addr_test1qp...", - "utxos": [ - { - "tx_hash": "aaaa...64hex", - "output_index": 0, - "address": "addr_test1qp...", - "amount": [{ "unit": "lovelace", "quantity": "100000000" }] - } - ], - "protocol_params": { "min_fee_a": 44, "min_fee_b": 155381, "..." : "..." }, - "signer_count": 1 -} -``` - -### 2. Multi-Asset Payment - -```json -{ - "operations": [ - { - "type": "pay_to_address", - "address": "addr_test1qz...", - "amounts": [ - { "unit": "lovelace", "quantity": "2000000" }, - { "unit": "aabb...policyId...assetNameHex", "quantity": "100" } - ] - } - ], - "from": "addr_test1qp...", - "utxos": [ - { - "tx_hash": "aaaa...64hex", - "output_index": 0, - "address": "addr_test1qp...", - "amount": [ - { "unit": "lovelace", "quantity": "100000000" }, - { "unit": "aabb...policyId...assetNameHex", "quantity": "500" } - ] - } - ], - "protocol_params": { "..." : "..." }, - "signer_count": 1 -} -``` - -### 3. Payment with Metadata - -```json -{ - "operations": [ - { - "type": "pay_to_address", - "address": "addr_test1qz...", - "amounts": [{ "unit": "lovelace", "quantity": "2000000" }] - }, - { - "type": "attach_metadata", - "label": 674, - "metadata": { "msg": ["Hello from CCL Bridge"] } - } - ], - "from": "addr_test1qp...", - "utxos": [{ "..." : "..." }], - "protocol_params": { "..." : "..." }, - "signer_count": 1 -} +### 1. Simple ADA payment + +```yaml +version: 1.0 +transaction: + - tx: + from: addr_test1qp... + intents: + - type: payment + address: addr_test1qz... + amounts: + - unit: lovelace + quantity: "5000000" +``` + +### 2. Multiple payments (one sender) + +```yaml +version: 1.0 +transaction: + - tx: + from: addr_test1qp... + intents: + - type: payment + address: addr_test1_receiver1... + amounts: + - unit: lovelace + quantity: "5000000" + - type: payment + address: addr_test1_receiver2... + amounts: + - unit: lovelace + quantity: "3000000" +``` + +### 3. Variable substitution + +```yaml +version: 1.0 +variables: + to: addr_test1qz... + amount: "4000000" +transaction: + - tx: + from: addr_test1qp... + intents: + - type: payment + address: ${to} + amounts: + - unit: lovelace + quantity: ${amount} +``` + +### 4. Payment with metadata + +The `metadata` intent's value is a **scalar string** that the deserializer auto-detects — pass it as +a JSON string (it may also be CBOR hex). Labels are the top-level keys: + +```yaml +version: 1.0 +transaction: + - tx: + from: addr_test1qp... + intents: + - type: payment + address: addr_test1qz... + amounts: + - unit: lovelace + quantity: "2000000" + - type: metadata + metadata: '{"674": {"msg": "Hello from CCL Bridge"}}' +``` + +### 5. Plutus mint (with caller-supplied execution units) + +A script intent goes under `scripts:` (the validator) with the operation in `intents:`. Pass the +redeemer's execution units alongside — the bridge does not run the script. + +```yaml +version: 1.0 +transaction: + - tx: + from: addr_test1qp... + intents: + - type: script_minting + policyId: 793f8c8cffba081b2a56462fc219cc8fe652d6a338b62c7b134876e7 + assets: + - name: TestToken + value: 1 + receiver: addr_test1qp... + redeemer: + int: 0 + scripts: + - type: validator + role: mint + cbor_hex: 4e4d01000033222220051200120011 + version: v2 +``` + +…built with `exec_units_json = [{"mem": 2000000, "steps": 500000000}]` (one entry for the single +mint redeemer). + +### 6. Compose (multiple senders into one transaction) + +The `transaction` list can hold more than one `tx`, each with its own `from`; they are composed into +a single transaction. The `context.fee_payer` pays the fee. Supply UTXOs for every sender. + +```yaml +version: 1.0 +context: + fee_payer: addr_test1_sender1... +transaction: + - tx: + from: addr_test1_sender1... + intents: + - type: payment + address: addr_test1_receiver... + amounts: + - unit: lovelace + quantity: "5000000" + - tx: + from: addr_test1_sender2... + intents: + - type: payment + address: addr_test1_receiver... + amounts: + - unit: lovelace + quantity: "3000000" ``` -### 4. Staking Delegation - -```json -{ - "operations": [ - { - "type": "register_stake_address", - "address": "stake_test1ur..." - }, - { - "type": "delegate_to", - "address": "stake_test1ur...", - "pool_id": "pool1abc..." - } - ], - "from": "addr_test1qp...", - "utxos": [{ "..." : "..." }], - "protocol_params": { "..." : "..." }, - "signer_count": 1 -} -``` - -### 5. Governance — Register DRep and Create Vote - -**Register DRep:** -```json -{ - "operations": [ - { - "type": "register_drep", - "credential_hash": "ab12...56hex", - "anchor_url": "https://example.com/drep.json", - "anchor_data_hash": "cd34...64hex" - } - ], - "from": "addr_test1qp...", - "utxos": [{ "..." : "..." }], - "protocol_params": { "..." : "..." }, - "signer_count": 1 -} -``` - -**Create Vote:** -```json -{ - "operations": [ - { - "type": "create_vote", - "voter_type": "drep_key_hash", - "voter_hash": "ab12...56hex", - "gov_action_tx_hash": "aaaa...64hex", - "gov_action_index": 0, - "vote": "yes", - "anchor_url": "https://example.com/rationale.json", - "anchor_data_hash": "ef56...64hex" - } - ], - "from": "addr_test1qp...", - "utxos": [{ "..." : "..." }], - "protocol_params": { "..." : "..." }, - "signer_count": 1 -} -``` - -**Create Info Action Proposal:** -```json -{ - "operations": [ - { - "type": "create_proposal", - "gov_action_type": "info_action", - "return_address": "stake_test1ur...", - "anchor_url": "https://example.com/proposal.json", - "anchor_data_hash": "ab12...64hex" - } - ], - "from": "addr_test1qp...", - "utxos": [{ "..." : "..." }], - "protocol_params": { "..." : "..." }, - "signer_count": 1 -} -``` - -### 6. ScriptTx — Collect from Script with Plutus Validator - -```json -{ - "tx_type": "script_tx", - "operations": [ - { - "type": "collect_from", - "collect_utxos": [ - { - "tx_hash": "aaa...64hex", - "output_index": 0, - "address": "addr_test1wz...", - "amount": [{ "unit": "lovelace", "quantity": "10000000" }] - } - ], - "redeemer_cbor_hex": "d87980", - "datum_cbor_hex": "d87980" - }, - { - "type": "attach_spending_validator", - "script_cbor_hex": "46450101002499", - "script_type": "plutus_v3" - }, - { - "type": "pay_to_address", - "address": "addr_test1qz...", - "amounts": [{ "unit": "lovelace", "quantity": "5000000" }] - } - ], - "from": "addr_test1qp...", - "utxos": [ - { - "tx_hash": "bbb...64hex", - "output_index": 0, - "address": "addr_test1qp...", - "amount": [{ "unit": "lovelace", "quantity": "100000000" }] - } - ], - "protocol_params": { "..." : "..." }, - "signer_count": 1 -} -``` - -### 7. Compose Mode — Two Senders - -```json -{ - "transactions": [ - { - "from": "addr_test1_sender1...", - "operations": [ - { - "type": "pay_to_address", - "address": "addr_test1_receiver1...", - "amounts": [{ "unit": "lovelace", "quantity": "5000000" }] - } - ] - }, - { - "from": "addr_test1_sender2...", - "operations": [ - { - "type": "pay_to_address", - "address": "addr_test1_receiver2...", - "amounts": [{ "unit": "lovelace", "quantity": "3000000" }] - } - ] - } - ], - "fee_payer": "addr_test1_sender1...", - "utxos": [ - { - "tx_hash": "aaa...", "output_index": 0, - "address": "addr_test1_sender1...", - "amount": [{ "unit": "lovelace", "quantity": "100000000" }] - }, - { - "tx_hash": "bbb...", "output_index": 0, - "address": "addr_test1_sender2...", - "amount": [{ "unit": "lovelace", "quantity": "100000000" }] - } - ], - "protocol_params": { "..." : "..." } -} -``` - -### 8. Provider Mode — Using Yaci DevKit - -```json -{ - "operations": [ - { - "type": "pay_to_address", - "address": "addr_test1qz...", - "amounts": [{ "unit": "lovelace", "quantity": "2000000" }] - } - ], - "from": "addr_test1qp...", - "provider": { - "name": "yaci", - "url": "http://localhost:10000/local-cluster/api" - } -} -``` - -No `utxos` or `protocol_params` needed — the library fetches them from the provider. - --- -## Signing the Transaction +## Using it from the wrappers + +Each wrapper exposes a thin `build(yaml, utxos, protocolParams)` that marshals the chain data to JSON, +calls `ccl_quicktx_build`, and parses the YAML result. The result is an object/dict/struct with +`tx_cbor`, `tx_hash`, and `fee`. `ccl_quicktx_build` returns an **unsigned** transaction — sign +`tx_cbor` with the account sign API, then submit it yourself. -`ccl_quicktx_build` returns an **unsigned** transaction. Sign it with `ccl_account_sign_tx`, which takes the mnemonic, network ID, account/address indices, and the unsigned CBOR hex. +> **Signing stake/governance transactions.** `sign_tx` adds only the **payment** key. Certificates +> in stake registration/deregistration/delegation, reward withdrawal, and DRep/vote operations must +> also be witnessed by the **stake** (or **DRep**) key, or the node rejects the tx with +> `MissingVKeyWitnessesUTXOW`. Use `sign_tx_with_keys(..., keys)` (Go `SignTxWithKeys`, JS +> `signTxWithKeys`) with the roles you need, e.g. `["payment", "stake"]` or `["payment", "drep"]` +> (roles: `payment`, `stake`, `drep`, `committee_cold`, `committee_hot`). ### Python @@ -982,19 +295,8 @@ No `utxos` or `protocol_params` needed — the library fetches them from the pro from ccl import CclLib lib = CclLib() - -# Build -result = lib.quicktx.new_tx() \ - .pay_to_address(receiver_addr, Amount.ada(5)) \ - .from_address(sender_addr) \ - .with_utxos(utxos) \ - .with_protocol_params(pp) \ - .build() - -# Sign -signed_tx = lib.account.sign_tx( - mnemonic, result["tx_cbor"], - CclLib.TESTNET, 0, 0) +result = lib.quicktx.build(txplan_yaml, utxos, protocol_params) # -> {"tx_cbor","tx_hash","fee"} +signed = lib.account.sign_tx(mnemonic, result["tx_cbor"], CclLib.TESTNET, 0, 0) ``` ### JavaScript (Bun) @@ -1003,19 +305,8 @@ signed_tx = lib.account.sign_tx( import { CclBridge, TESTNET } from '@bloxbean/ccl'; const bridge = new CclBridge(); - -// Build -const result = bridge.quicktx - .newTx() - .payToAddress(receiverAddr, Amount.ada(5)) - .from(senderAddr) - .withUtxos(utxos) - .withProtocolParams(pp) - .build(); - -// Sign -const signedTx = bridge.account.signTx( - mnemonic, TESTNET, 0, 0, result.tx_cbor); +const result = bridge.quicktx.build(txplanYaml, utxos, protocolParams); +const signed = bridge.account.signTx(mnemonic, TESTNET, 0, 0, result.tx_cbor); ``` ### Go @@ -1024,17 +315,8 @@ const signedTx = bridge.account.signTx( bridge, _ := ccl.New() defer bridge.Close() -// Build -result, _ := bridge.QuickTx.NewTx(). - PayToAddress(receiverAddr, ccl.Amount{Unit: "lovelace", Quantity: "5000000"}). - From(senderAddr). - WithUtxos(utxos). - WithProtocolParams(pp). - Build() - -// Sign -signedTx, _ := bridge.Account.SignTx( - mnemonic, ccl.Testnet, 0, 0, result.TxCbor) +result, _ := bridge.QuickTx.Build(txplanYaml, utxos, protocolParams) +signed, _ := bridge.Account.SignTx(mnemonic, ccl.Testnet, 0, 0, result.TxCbor) ``` ### Rust @@ -1042,17 +324,10 @@ signedTx, _ := bridge.Account.SignTx( ```rust let bridge = ccl::Bridge::new().unwrap(); -// Build -let result = bridge.quicktx().new_tx() - .pay_to_address(&receiver, &[Amount::ada(5.0)], None, None) - .from(&sender) - .with_utxos(utxos) - .with_protocol_params(pp) - .build() - .unwrap(); - -// Sign -let signed_tx = bridge.account() +let result = bridge.quicktx().build(&txplan_yaml, &utxos, &protocol_params).unwrap(); +let signed = bridge.account() .sign_tx(&mnemonic, ccl::network::TESTNET, 0, 0, &result.tx_cbor) .unwrap(); ``` + +See each wrapper's `examples/transaction.*` for a complete build-and-sign program. diff --git a/native-test/src/smoke.c b/native-test/src/smoke.c new file mode 100644 index 0000000..056fe49 --- /dev/null +++ b/native-test/src/smoke.c @@ -0,0 +1,49 @@ +/* + * Minimal functional smoke for the static-linking spike. + * + * Proves libccl.so doesn't just *load* on an old-glibc distro but actually *runs*: it creates a + * GraalVM isolate (the runtime initialises), reads the version, and derives a testnet account + * (exercises the real crypto path). Compiled in the manylinux_2_28 builder against the headers, + * then executed inside an old container (centos:7, glibc 2.17). Returns non-zero on any failure. + */ +#include +#include +#include "libccl.h" + +int main(void) { + graal_isolatethread_t *thread = NULL; + graal_isolate_t *isolate = NULL; + + if (graal_create_isolate(NULL, &isolate, &thread) != 0) { + fprintf(stderr, "FAIL: graal_create_isolate\n"); + return 1; + } + + if (ccl_version(thread) != 0) { + fprintf(stderr, "FAIL: ccl_version rc\n"); + return 1; + } + char *version = ccl_get_result(thread); + if (version == NULL || strlen(version) == 0) { + fprintf(stderr, "FAIL: empty version\n"); + return 1; + } + printf("libccl version: %s\n", version); + ccl_free_string(thread, version); + + /* A real operation (key derivation), not just a load check. */ + if (ccl_account_create(thread, 1) != 0) { + fprintf(stderr, "FAIL: ccl_account_create rc\n"); + return 1; + } + char *account = ccl_get_result(thread); + if (account == NULL || strstr(account, "addr_test1") == NULL) { + fprintf(stderr, "FAIL: testnet account missing addr_test1\n"); + return 1; + } + printf("account ok (testnet address derived)\n"); + ccl_free_string(thread, account); + + printf("SMOKE OK\n"); + return 0; +} diff --git a/test-fixtures/quicktx-intents/collect_from.yaml b/test-fixtures/quicktx-intents/collect_from.yaml new file mode 100644 index 0000000..7711636 --- /dev/null +++ b/test-fixtures/quicktx-intents/collect_from.yaml @@ -0,0 +1,18 @@ +version: 1.0 +context: + fee_payer: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp +transaction: +- tx: + from: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp + change_address: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp + inputs: + - type: collect_from + utxo_refs: + - tx_hash: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + output_index: 0 + intents: + - type: payment + address: addr_test1vz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzerspjrlsz + amounts: + - unit: lovelace + quantity: 5000000 diff --git a/test-fixtures/quicktx-intents/compose.yaml b/test-fixtures/quicktx-intents/compose.yaml new file mode 100644 index 0000000..00c344d --- /dev/null +++ b/test-fixtures/quicktx-intents/compose.yaml @@ -0,0 +1,22 @@ +version: 1.0 +context: + fee_payer: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp +transaction: +- tx: + from: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp + change_address: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp + intents: + - type: payment + address: addr_test1vz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzerspjrlsz + amounts: + - unit: lovelace + quantity: 5000000 +- tx: + from: addr_test1qz7svwszky8gcmhrfza7a89z9u0dfzd3l7h23sqlc5yml7ejcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwqcqrvr0 + change_address: addr_test1qz7svwszky8gcmhrfza7a89z9u0dfzd3l7h23sqlc5yml7ejcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwqcqrvr0 + intents: + - type: payment + address: addr_test1vz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzerspjrlsz + amounts: + - unit: lovelace + quantity: 3000000 diff --git a/test-fixtures/quicktx-intents/donation.yaml b/test-fixtures/quicktx-intents/donation.yaml new file mode 100644 index 0000000..1c5da7d --- /dev/null +++ b/test-fixtures/quicktx-intents/donation.yaml @@ -0,0 +1,11 @@ +version: 1.0 +context: + fee_payer: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp +transaction: +- tx: + from: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp + change_address: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp + intents: + - type: donation + current_treasury_value: 0 + donation_amount: 1000000 diff --git a/test-fixtures/quicktx-intents/drep_deregistration.yaml b/test-fixtures/quicktx-intents/drep_deregistration.yaml new file mode 100644 index 0000000..93343a3 --- /dev/null +++ b/test-fixtures/quicktx-intents/drep_deregistration.yaml @@ -0,0 +1,11 @@ +version: 1.0 +context: + fee_payer: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp +transaction: +- tx: + from: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp + change_address: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp + intents: + - type: drep_deregistration + drep_credential_hex: a5b45515a3ff8cb7c02ce351834da324eb6dfc41b5779cb5e6b832aa + drep_credential_type: key_hash diff --git a/test-fixtures/quicktx-intents/drep_registration.yaml b/test-fixtures/quicktx-intents/drep_registration.yaml new file mode 100644 index 0000000..85fa731 --- /dev/null +++ b/test-fixtures/quicktx-intents/drep_registration.yaml @@ -0,0 +1,13 @@ +version: 1.0 +context: + fee_payer: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp +transaction: +- tx: + from: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp + change_address: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp + intents: + - type: drep_registration + drep_credential_hex: a5b45515a3ff8cb7c02ce351834da324eb6dfc41b5779cb5e6b832aa + drep_credential_type: key_hash + anchor_url: https://example.com/meta.json + anchor_hash: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa diff --git a/test-fixtures/quicktx-intents/drep_update.yaml b/test-fixtures/quicktx-intents/drep_update.yaml new file mode 100644 index 0000000..7d78f91 --- /dev/null +++ b/test-fixtures/quicktx-intents/drep_update.yaml @@ -0,0 +1,13 @@ +version: 1.0 +context: + fee_payer: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp +transaction: +- tx: + from: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp + change_address: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp + intents: + - type: drep_update + drep_credential_hex: a5b45515a3ff8cb7c02ce351834da324eb6dfc41b5779cb5e6b832aa + drep_credential_type: key_hash + anchor_url: https://example.com/meta.json + anchor_hash: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa diff --git a/test-fixtures/quicktx-intents/governance_proposal.yaml b/test-fixtures/quicktx-intents/governance_proposal.yaml new file mode 100644 index 0000000..14f3a31 --- /dev/null +++ b/test-fixtures/quicktx-intents/governance_proposal.yaml @@ -0,0 +1,13 @@ +version: 1.0 +context: + fee_payer: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp +transaction: +- tx: + from: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp + change_address: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp + intents: + - type: governance_proposal + gov_action_hex: 8106 + return_address: stake_test1uqevw2xnsc0pvn9t9r9c7qryfqfeerchgrlm3ea2nefr9hqp8n5xl + anchor_url: https://example.com/meta.json + anchor_hash: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa diff --git a/test-fixtures/quicktx-intents/metadata.yaml b/test-fixtures/quicktx-intents/metadata.yaml new file mode 100644 index 0000000..55276b4 --- /dev/null +++ b/test-fixtures/quicktx-intents/metadata.yaml @@ -0,0 +1,17 @@ +version: 1.0 +context: + fee_payer: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp +transaction: +- tx: + from: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp + change_address: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp + intents: + - type: payment + address: addr_test1vz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzerspjrlsz + amounts: + - unit: lovelace + quantity: 2000000 + - type: metadata + metadata: | + "674": + msg: "Hello from CCL Bridge" diff --git a/test-fixtures/quicktx-intents/minting.yaml b/test-fixtures/quicktx-intents/minting.yaml new file mode 100644 index 0000000..57c0f8b --- /dev/null +++ b/test-fixtures/quicktx-intents/minting.yaml @@ -0,0 +1,15 @@ +version: 1.0 +context: + fee_payer: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp +transaction: +- tx: + from: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp + change_address: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp + intents: + - type: minting + assets: + - name: TestNFT + value: 1 + receiver: addr_test1vz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzerspjrlsz + script_hex: 820180 + script_type: 0 diff --git a/test-fixtures/quicktx-intents/native_script.yaml b/test-fixtures/quicktx-intents/native_script.yaml new file mode 100644 index 0000000..22304d2 --- /dev/null +++ b/test-fixtures/quicktx-intents/native_script.yaml @@ -0,0 +1,16 @@ +version: 1.0 +context: + fee_payer: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp +transaction: +- tx: + from: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp + change_address: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp + intents: + - type: payment + address: addr_test1vz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzerspjrlsz + amounts: + - unit: lovelace + quantity: 5000000 + scripts: + - type: native_script + script_hex: 8201818200581ca1010fafd65c325fe38e57d249485080f34c33f9ba8a118cc961eab5 diff --git a/test-fixtures/quicktx-intents/plutus/plutus_lock.yaml b/test-fixtures/quicktx-intents/plutus/plutus_lock.yaml new file mode 100644 index 0000000..73c96b8 --- /dev/null +++ b/test-fixtures/quicktx-intents/plutus/plutus_lock.yaml @@ -0,0 +1,14 @@ +version: 1.0 +context: + fee_payer: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp +transaction: +- tx: + from: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp + change_address: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp + intents: + - type: payment + address: addr_test1wpunlryvl7aqsxe22erzlsseej87v5kk5vutvtrmzdy8dect48z0w + amounts: + - unit: lovelace + quantity: 10000000 + datum_hash: 9e1199a988ba72ffd6e9c269cadb3b53b5f360ff99f112d9b2ee30c4d74ad88b diff --git a/test-fixtures/quicktx-intents/plutus/script_collect_from.yaml b/test-fixtures/quicktx-intents/plutus/script_collect_from.yaml new file mode 100644 index 0000000..39e2400 --- /dev/null +++ b/test-fixtures/quicktx-intents/plutus/script_collect_from.yaml @@ -0,0 +1,27 @@ +version: 1.0 +context: + fee_payer: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp +transaction: +- tx: + from: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp + change_address: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp + inputs: + - type: script_collect_from + utxo_refs: + - tx_hash: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb + output_index: 0 + redeemer: + int: 0 + datum: + int: 42 + intents: + - type: payment + address: addr_test1vz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzerspjrlsz + amounts: + - unit: lovelace + quantity: 5000000 + scripts: + - type: validator + role: spend + cbor_hex: 4e4d01000033222220051200120011 + version: v2 diff --git a/test-fixtures/quicktx-intents/plutus/script_minting.yaml b/test-fixtures/quicktx-intents/plutus/script_minting.yaml new file mode 100644 index 0000000..715ef23 --- /dev/null +++ b/test-fixtures/quicktx-intents/plutus/script_minting.yaml @@ -0,0 +1,21 @@ +version: 1.0 +context: + fee_payer: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp +transaction: +- tx: + from: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp + change_address: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp + intents: + - policyId: 793f8c8cffba081b2a56462fc219cc8fe652d6a338b62c7b134876e7 + type: script_minting + assets: + - name: TestToken + value: 1 + receiver: addr_test1vz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzerspjrlsz + redeemer: + int: 0 + scripts: + - type: validator + role: mint + cbor_hex: 4e4d01000033222220051200120011 + version: v2 diff --git a/test-fixtures/quicktx-intents/pool_registration.yaml b/test-fixtures/quicktx-intents/pool_registration.yaml new file mode 100644 index 0000000..f393926 --- /dev/null +++ b/test-fixtures/quicktx-intents/pool_registration.yaml @@ -0,0 +1,26 @@ +version: 1.0 +context: + fee_payer: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp +transaction: +- tx: + from: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp + change_address: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp + intents: + - type: pool_registration + update: false + pool_registration: + type: POOL_REGISTRATION + operator: 32c728d3861e164cab28cb8f006448139c8f1740ffb8e7aa9e5232dc + vrfKeyHash: b95af7a0a58928fbd0e73b03ce81dedd42d4a776685b443cf2016c18438a3b9b + pledge: 100000000 + cost: 340000000 + margin: + numerator: 1 + denominator: 100 + rewardAccount: e032c728d3861e164cab28cb8f006448139c8f1740ffb8e7aa9e5232dc + poolOwners: + - 32c728d3861e164cab28cb8f006448139c8f1740ffb8e7aa9e5232dc + relays: + - relay_type: single_host_addr + port: 3001 + is_update: false diff --git a/test-fixtures/quicktx-intents/pool_retirement.yaml b/test-fixtures/quicktx-intents/pool_retirement.yaml new file mode 100644 index 0000000..72c6c29 --- /dev/null +++ b/test-fixtures/quicktx-intents/pool_retirement.yaml @@ -0,0 +1,11 @@ +version: 1.0 +context: + fee_payer: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp +transaction: +- tx: + from: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp + change_address: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp + intents: + - type: pool_retirement + pool_id: pool1pu5jlj4q9w9jlxeu370a3c9myx47md5j5m2str0naunn2q3lkdy + retirement_epoch: 500 diff --git a/test-fixtures/quicktx-intents/pool_update.yaml b/test-fixtures/quicktx-intents/pool_update.yaml new file mode 100644 index 0000000..9326c52 --- /dev/null +++ b/test-fixtures/quicktx-intents/pool_update.yaml @@ -0,0 +1,26 @@ +version: 1.0 +context: + fee_payer: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp +transaction: +- tx: + from: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp + change_address: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp + intents: + - type: pool_update + update: true + pool_registration: + type: POOL_REGISTRATION + operator: 32c728d3861e164cab28cb8f006448139c8f1740ffb8e7aa9e5232dc + vrfKeyHash: b95af7a0a58928fbd0e73b03ce81dedd42d4a776685b443cf2016c18438a3b9b + pledge: 100000000 + cost: 340000000 + margin: + numerator: 1 + denominator: 100 + rewardAccount: e032c728d3861e164cab28cb8f006448139c8f1740ffb8e7aa9e5232dc + poolOwners: + - 32c728d3861e164cab28cb8f006448139c8f1740ffb8e7aa9e5232dc + relays: + - relay_type: single_host_addr + port: 3001 + is_update: true diff --git a/test-fixtures/quicktx-intents/reference_input.yaml b/test-fixtures/quicktx-intents/reference_input.yaml new file mode 100644 index 0000000..093c992 --- /dev/null +++ b/test-fixtures/quicktx-intents/reference_input.yaml @@ -0,0 +1,18 @@ +version: 1.0 +context: + fee_payer: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp +transaction: +- tx: + from: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp + change_address: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp + inputs: + - type: reference_input + refs: + - tx_hash: cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc + output_index: 0 + intents: + - type: payment + address: addr_test1vz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzerspjrlsz + amounts: + - unit: lovelace + quantity: 5000000 diff --git a/test-fixtures/quicktx-intents/stake_delegation.yaml b/test-fixtures/quicktx-intents/stake_delegation.yaml new file mode 100644 index 0000000..faa5c20 --- /dev/null +++ b/test-fixtures/quicktx-intents/stake_delegation.yaml @@ -0,0 +1,11 @@ +version: 1.0 +context: + fee_payer: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp +transaction: +- tx: + from: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp + change_address: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp + intents: + - type: stake_delegation + stake_address: stake_test1uqevw2xnsc0pvn9t9r9c7qryfqfeerchgrlm3ea2nefr9hqp8n5xl + pool_id: pool1pu5jlj4q9w9jlxeu370a3c9myx47md5j5m2str0naunn2q3lkdy diff --git a/test-fixtures/quicktx-intents/stake_deregistration.yaml b/test-fixtures/quicktx-intents/stake_deregistration.yaml new file mode 100644 index 0000000..f81bc1d --- /dev/null +++ b/test-fixtures/quicktx-intents/stake_deregistration.yaml @@ -0,0 +1,11 @@ +version: 1.0 +context: + fee_payer: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp +transaction: +- tx: + from: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp + change_address: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp + intents: + - type: stake_deregistration + stake_address: stake_test1uqevw2xnsc0pvn9t9r9c7qryfqfeerchgrlm3ea2nefr9hqp8n5xl + refund_address: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp diff --git a/test-fixtures/quicktx-intents/stake_registration.yaml b/test-fixtures/quicktx-intents/stake_registration.yaml new file mode 100644 index 0000000..437be13 --- /dev/null +++ b/test-fixtures/quicktx-intents/stake_registration.yaml @@ -0,0 +1,10 @@ +version: 1.0 +context: + fee_payer: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp +transaction: +- tx: + from: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp + change_address: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp + intents: + - type: stake_registration + stake_address: stake_test1uqevw2xnsc0pvn9t9r9c7qryfqfeerchgrlm3ea2nefr9hqp8n5xl diff --git a/test-fixtures/quicktx-intents/stake_withdrawal.yaml b/test-fixtures/quicktx-intents/stake_withdrawal.yaml new file mode 100644 index 0000000..44e8c27 --- /dev/null +++ b/test-fixtures/quicktx-intents/stake_withdrawal.yaml @@ -0,0 +1,11 @@ +version: 1.0 +context: + fee_payer: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp +transaction: +- tx: + from: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp + change_address: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp + intents: + - type: stake_withdrawal + reward_address: stake_test1uqevw2xnsc0pvn9t9r9c7qryfqfeerchgrlm3ea2nefr9hqp8n5xl + amount: 0 diff --git a/test-fixtures/quicktx-intents/voting.yaml b/test-fixtures/quicktx-intents/voting.yaml new file mode 100644 index 0000000..6a0d27c --- /dev/null +++ b/test-fixtures/quicktx-intents/voting.yaml @@ -0,0 +1,15 @@ +version: 1.0 +context: + fee_payer: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp +transaction: +- tx: + from: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp + change_address: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp + intents: + - type: voting + voter_hex: 8202581ca5b45515a3ff8cb7c02ce351834da324eb6dfc41b5779cb5e6b832aa + gov_action_tx_hash: 12745f09b138d4d0a11a560b4591ebb830cf12336347606d2edbbf1893d395c6 + gov_action_index: 0 + vote: "YES" + anchor_url: https://example.com/meta.json + anchor_hash: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa diff --git a/test-fixtures/quicktx-intents/voting_delegation.yaml b/test-fixtures/quicktx-intents/voting_delegation.yaml new file mode 100644 index 0000000..68dd4de --- /dev/null +++ b/test-fixtures/quicktx-intents/voting_delegation.yaml @@ -0,0 +1,12 @@ +version: 1.0 +context: + fee_payer: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp +transaction: +- tx: + from: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp + change_address: addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp + intents: + - type: voting_delegation + address: stake_test1uqevw2xnsc0pvn9t9r9c7qryfqfeerchgrlm3ea2nefr9hqp8n5xl + drep_hex: 8102 + drep_type: abstain diff --git a/wrappers/go/README.md b/wrappers/go/README.md new file mode 100644 index 0000000..c74d798 --- /dev/null +++ b/wrappers/go/README.md @@ -0,0 +1,95 @@ +# CCL Bridge — Go + +Go bindings for [Cardano Client Lib](https://github.com/bloxbean/cardano-client-lib) +via the CCL Bridge native library, using `cgo`. + +> Part of the [CCL Bridge](../../README.md) project. See the +> [top-level README](../../README.md) for the full API reference and +> [`docs/quicktx.md`](../../docs/quicktx.md) for transaction building. + +## Requirements + +- Go 1.21+ with `cgo` enabled (a C toolchain on `PATH`). +- The native library `libccl.{dylib,so,dll}` for your platform. + +> **Threading:** all FFI calls run on a single dedicated OS thread that the `Bridge` +> pins for its lifetime, so a `Bridge` is safe to share across goroutines and is immune +> to Go's goroutine/OS-thread migration (which otherwise crashes the GraalVM isolate on +> Linux x86_64). Calls are serialized; create multiple `Bridge` instances if you need +> concurrent isolate work. + +## Getting the native library + +The `cgo` directives in `ccl/ccl.go` already point the compiler/linker at +`core/build/native/nativeCompile` (relative to the package), so you only need to build +or download the library there. From the repo root: + +```bash +./gradlew :core:nativeCompile # build from source (needs Oracle GraalVM 25.0.3) +# or: +make download-lib # download a pre-built binary +``` + +At **runtime** the OS loader also needs to find the library, via `DYLD_LIBRARY_PATH` +(macOS) / `LD_LIBRARY_PATH` (Linux). + +## Running the examples + +From `wrappers/go`: + +```bash +LIB_DIR=../../core/build/native/nativeCompile + +DYLD_LIBRARY_PATH=$LIB_DIR LD_LIBRARY_PATH=$LIB_DIR \ + go run ./examples/account +``` + +The [`examples/`](examples/) directory contains: + +| Program | What it shows | +|---------|---------------| +| [`account`](examples/account/main.go) | Create an account, restore from mnemonic, derive keys and a DRep ID | +| [`primitives`](examples/primitives/main.go) | Mnemonics, Blake2b hashing, Ed25519 signing, address parsing/validation | +| [`transaction`](examples/transaction/main.go) | Build an unsigned payment **offline** (QuickTx) and sign it — no node/DevKit needed | + +## Quick start + +```go +package main + +import ( + "fmt" + "log" + + "github.com/bloxbean/ccl-bridge/wrappers/go/ccl" +) + +func main() { + bridge, err := ccl.New() // loads libccl, starts a GraalVM isolate + if err != nil { + log.Fatal(err) + } + defer bridge.Close() // tears down the isolate + + account, err := bridge.Account.Create(ccl.Testnet) + if err != nil { + log.Fatal(err) + } + fmt.Println(account.BaseAddress) // addr_test1... + fmt.Println(account.Mnemonic) // 24-word phrase +} +``` + +## API namespaces + +A `*Bridge` exposes these namespaces (all offline operations): +`bridge.Account`, `bridge.Address`, `bridge.Crypto`, `bridge.Tx`, `bridge.Plutus`, +`bridge.Script`, `bridge.Gov`, `bridge.Wallet`, `bridge.QuickTx`. + +Network IDs: `ccl.Mainnet` (0), `ccl.Testnet` (1), `ccl.Preprod` (2), `ccl.Preview` (3). +Errors are returned as a `*ccl.CclError`. + +Transactions are built from a [TxPlan](https://github.com/bloxbean/cardano-client-lib) +**YAML** document via `bridge.QuickTx.Build(yaml, utxos, protocolParams)`, fully offline — +you supply the UTXOs and protocol parameters. See +[`examples/transaction`](examples/transaction/main.go). diff --git a/wrappers/go/ccl/ccl.go b/wrappers/go/ccl/ccl.go index 9675d0c..aa4b9df 100644 --- a/wrappers/go/ccl/ccl.go +++ b/wrappers/go/ccl/ccl.go @@ -11,8 +11,12 @@ import "C" import ( "encoding/json" "fmt" - "math" + "runtime" + "strings" + "sync" "unsafe" + + goyaml "gopkg.in/yaml.v3" ) // Network IDs @@ -25,15 +29,15 @@ const ( // Error codes const ( - Success = 0 - ErrGeneral = -1 - ErrInvalidArgument = -2 - ErrSerialization = -3 - ErrCrypto = -4 - ErrInvalidNetwork = -5 - ErrInvalidMnemonic = -6 - ErrInvalidAddress = -7 - ErrInsufficientFunds = -8 + Success = 0 + ErrGeneral = -1 + ErrInvalidArgument = -2 + ErrSerialization = -3 + ErrCrypto = -4 + ErrInvalidNetwork = -5 + ErrInvalidMnemonic = -6 + ErrInvalidAddress = -7 + ErrInsufficientFunds = -8 ErrInvalidTransaction = -9 ) @@ -75,19 +79,30 @@ type WalletInfo struct { // GovKeyInfo contains governance key derivation result. type GovKeyInfo struct { - DrepID string `json:"drep_id,omitempty"` - ID string `json:"id,omitempty"` - VerificationKey string `json:"verification_key"` - VerificationKeyHash string `json:"verification_key_hash"` - Bech32VerificationKey string `json:"bech32_verification_key"` + DrepID string `json:"drep_id,omitempty"` + ID string `json:"id,omitempty"` + VerificationKey string `json:"verification_key"` + VerificationKeyHash string `json:"verification_key_hash"` + Bech32VerificationKey string `json:"bech32_verification_key"` Bech32VerificationKeyHash string `json:"bech32_verification_key_hash"` } // Bridge wraps the CCL native library. +// +// All FFI calls are funneled to a single, dedicated OS thread (see loop) for the +// lifetime of the Bridge. A GraalVM IsolateThread is bound to the OS thread that +// created it, but the Go runtime can migrate a goroutine across OS threads between +// (or within) cgo calls. Calling the isolate from a different OS thread than the one +// that created it makes GraalVM read the wrong thread's stack — which on Linux +// x86_64 crashes with a "yellow zone" StackOverflowError. Pinning every call to one +// locked OS thread keeps the isolate thread and the executing thread in sync. type Bridge struct { isolate *C.graal_isolate_t thread *C.graal_isolatethread_t + mu sync.Mutex + calls chan func() + Account *AccountApi Address *AddressApi Crypto *CryptoApi @@ -101,10 +116,12 @@ type Bridge struct { // New creates a new Bridge instance with a GraalVM isolate. func New() (*Bridge, error) { - b := &Bridge{} - rc := C.graal_create_isolate(nil, &b.isolate, &b.thread) - if rc != 0 { - return nil, fmt.Errorf("failed to create GraalVM isolate: %d", rc) + b := &Bridge{calls: make(chan func())} + + ready := make(chan error, 1) + go b.loop(ready) + if err := <-ready; err != nil { + return nil, err } b.Account = &AccountApi{bridge: b} @@ -120,12 +137,78 @@ func New() (*Bridge, error) { return b, nil } -// Close tears down the GraalVM isolate. +// loop owns the isolate's OS thread: it locks the thread, creates the isolate on it, +// then executes every queued FFI closure on that same thread until Close. +func (b *Bridge) loop(ready chan<- error) { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + if rc := C.graal_create_isolate(nil, &b.isolate, &b.thread); rc != 0 { + ready <- fmt.Errorf("failed to create GraalVM isolate: %d", int(rc)) + return + } + ready <- nil + + for fn := range b.calls { + fn() + } +} + +// run executes fn on the isolate's dedicated OS thread and blocks until it finishes. +func (b *Bridge) run(fn func()) { + b.mu.Lock() + defer b.mu.Unlock() + + done := make(chan struct{}) + b.calls <- func() { + fn() + close(done) + } + <-done +} + +// invoke runs a result-returning FFI call on the isolate thread and reads the +// per-thread result/error there, where the Java thread-local state lives. +func (b *Bridge) invoke(call func() C.int) (string, error) { + var s string + var err error + b.run(func() { + if rc := call(); rc != Success { + err = &CclError{Code: int(rc), Message: b.getError()} + } else { + s = b.getResult() + } + }) + return s, err +} + +// invokeRC runs an FFI call on the isolate thread and returns its raw status code, +// for calls (e.g. validate/verify) where the caller interprets the code directly. +func (b *Bridge) invokeRC(call func() C.int) C.int { + var rc C.int + b.run(func() { rc = call() }) + return rc +} + +// Close tears down the GraalVM isolate and stops its OS thread. func (b *Bridge) Close() error { - if b.thread != nil { - C.graal_tear_down_isolate(b.thread) - b.thread = nil + b.mu.Lock() + defer b.mu.Unlock() + + if b.calls == nil { + return nil + } + done := make(chan struct{}) + b.calls <- func() { + if b.thread != nil { + C.graal_tear_down_isolate(b.thread) + b.thread = nil + } + close(done) } + <-done + close(b.calls) + b.calls = nil return nil } @@ -149,21 +232,13 @@ func (b *Bridge) getError() string { return result } -func (b *Bridge) check(rc C.int) (string, error) { - if rc != Success { - return "", &CclError{Code: int(rc), Message: b.getError()} - } - return b.getResult(), nil -} - func cstr(s string) *C.char { return C.CString(s) } // Version returns the library version string. func (b *Bridge) Version() (string, error) { - rc := C.ccl_version(b.thread) - return b.check(rc) + return b.invoke(func() C.int { return C.ccl_version(b.thread) }) } // --- AccountApi --- @@ -173,8 +248,7 @@ type AccountApi struct { } func (a *AccountApi) Create(networkID int) (*AccountInfo, error) { - rc := C.ccl_account_create(a.bridge.thread, C.int(networkID)) - result, err := a.bridge.check(rc) + result, err := a.bridge.invoke(func() C.int { return C.ccl_account_create(a.bridge.thread, C.int(networkID)) }) if err != nil { return nil, err } @@ -189,8 +263,9 @@ func (a *AccountApi) FromMnemonic(mnemonic string, networkID, accountIndex, addr cs := cstr(mnemonic) defer C.free(unsafe.Pointer(cs)) - rc := C.ccl_account_from_mnemonic(a.bridge.thread, C.int(networkID), cs, C.int(accountIndex), C.int(addressIndex)) - result, err := a.bridge.check(rc) + result, err := a.bridge.invoke(func() C.int { + return C.ccl_account_from_mnemonic(a.bridge.thread, C.int(networkID), cs, C.int(accountIndex), C.int(addressIndex)) + }) if err != nil { return nil, err } @@ -205,24 +280,27 @@ func (a *AccountApi) GetPublicKey(mnemonic string, networkID, accountIndex, addr cs := cstr(mnemonic) defer C.free(unsafe.Pointer(cs)) - rc := C.ccl_account_get_public_key(a.bridge.thread, cs, C.int(networkID), C.int(accountIndex), C.int(addressIndex)) - return a.bridge.check(rc) + return a.bridge.invoke(func() C.int { + return C.ccl_account_get_public_key(a.bridge.thread, cs, C.int(networkID), C.int(accountIndex), C.int(addressIndex)) + }) } func (a *AccountApi) GetPrivateKey(mnemonic string, networkID, accountIndex, addressIndex int) (string, error) { cs := cstr(mnemonic) defer C.free(unsafe.Pointer(cs)) - rc := C.ccl_account_get_private_key(a.bridge.thread, cs, C.int(networkID), C.int(accountIndex), C.int(addressIndex)) - return a.bridge.check(rc) + return a.bridge.invoke(func() C.int { + return C.ccl_account_get_private_key(a.bridge.thread, cs, C.int(networkID), C.int(accountIndex), C.int(addressIndex)) + }) } func (a *AccountApi) GetDRepID(mnemonic string, networkID, accountIndex int) (string, error) { cs := cstr(mnemonic) defer C.free(unsafe.Pointer(cs)) - rc := C.ccl_account_get_drep_id(a.bridge.thread, cs, C.int(networkID), C.int(accountIndex)) - return a.bridge.check(rc) + return a.bridge.invoke(func() C.int { + return C.ccl_account_get_drep_id(a.bridge.thread, cs, C.int(networkID), C.int(accountIndex)) + }) } func (a *AccountApi) SignTx(mnemonic string, networkID, accountIndex, addressIndex int, txCborHex string) (string, error) { @@ -231,8 +309,26 @@ func (a *AccountApi) SignTx(mnemonic string, networkID, accountIndex, addressInd csTx := cstr(txCborHex) defer C.free(unsafe.Pointer(csTx)) - rc := C.ccl_account_sign_tx(a.bridge.thread, csMnemonic, C.int(networkID), C.int(accountIndex), C.int(addressIndex), csTx) - return a.bridge.check(rc) + return a.bridge.invoke(func() C.int { + return C.ccl_account_sign_tx(a.bridge.thread, csMnemonic, C.int(networkID), C.int(accountIndex), C.int(addressIndex), csTx) + }) +} + +// SignTxWithKeys signs a transaction with one or more of the account's keys, selected by role +// (any of: payment, stake, drep, committee_cold, committee_hot, applied in order). Use this for +// transactions whose certificates also need the stake or DRep key — stake registration/delegation/ +// withdrawal and DRep/vote operations — which the payment key alone cannot witness. +func (a *AccountApi) SignTxWithKeys(mnemonic string, networkID, accountIndex, addressIndex int, txCborHex string, keys ...string) (string, error) { + csMnemonic := cstr(mnemonic) + defer C.free(unsafe.Pointer(csMnemonic)) + csTx := cstr(txCborHex) + defer C.free(unsafe.Pointer(csTx)) + csKeys := cstr(strings.Join(keys, ",")) + defer C.free(unsafe.Pointer(csKeys)) + + return a.bridge.invoke(func() C.int { + return C.ccl_account_sign_tx_multi(a.bridge.thread, csMnemonic, C.int(networkID), C.int(accountIndex), C.int(addressIndex), csTx, csKeys) + }) } // --- AddressApi --- @@ -245,8 +341,7 @@ func (a *AddressApi) Info(bech32 string) (*AddressInfo, error) { cs := cstr(bech32) defer C.free(unsafe.Pointer(cs)) - rc := C.ccl_address_info(a.bridge.thread, cs) - result, err := a.bridge.check(rc) + result, err := a.bridge.invoke(func() C.int { return C.ccl_address_info(a.bridge.thread, cs) }) if err != nil { return nil, err } @@ -261,24 +356,21 @@ func (a *AddressApi) Validate(bech32 string) bool { cs := cstr(bech32) defer C.free(unsafe.Pointer(cs)) - rc := C.ccl_address_validate(a.bridge.thread, cs) - return rc == Success + return a.bridge.invokeRC(func() C.int { return C.ccl_address_validate(a.bridge.thread, cs) }) == Success } func (a *AddressApi) ToBytes(bech32 string) (string, error) { cs := cstr(bech32) defer C.free(unsafe.Pointer(cs)) - rc := C.ccl_address_to_bytes(a.bridge.thread, cs) - return a.bridge.check(rc) + return a.bridge.invoke(func() C.int { return C.ccl_address_to_bytes(a.bridge.thread, cs) }) } func (a *AddressApi) FromBytes(hexBytes string) (string, error) { cs := cstr(hexBytes) defer C.free(unsafe.Pointer(cs)) - rc := C.ccl_address_from_bytes(a.bridge.thread, cs) - return a.bridge.check(rc) + return a.bridge.invoke(func() C.int { return C.ccl_address_from_bytes(a.bridge.thread, cs) }) } // --- CryptoApi --- @@ -291,29 +383,25 @@ func (c *CryptoApi) Blake2b256(dataHex string) (string, error) { cs := cstr(dataHex) defer C.free(unsafe.Pointer(cs)) - rc := C.ccl_crypto_blake2b_256(c.bridge.thread, cs) - return c.bridge.check(rc) + return c.bridge.invoke(func() C.int { return C.ccl_crypto_blake2b_256(c.bridge.thread, cs) }) } func (c *CryptoApi) Blake2b224(dataHex string) (string, error) { cs := cstr(dataHex) defer C.free(unsafe.Pointer(cs)) - rc := C.ccl_crypto_blake2b_224(c.bridge.thread, cs) - return c.bridge.check(rc) + return c.bridge.invoke(func() C.int { return C.ccl_crypto_blake2b_224(c.bridge.thread, cs) }) } func (c *CryptoApi) GenerateMnemonic(wordCount int) (string, error) { - rc := C.ccl_crypto_generate_mnemonic(c.bridge.thread, C.int(wordCount)) - return c.bridge.check(rc) + return c.bridge.invoke(func() C.int { return C.ccl_crypto_generate_mnemonic(c.bridge.thread, C.int(wordCount)) }) } func (c *CryptoApi) ValidateMnemonic(mnemonic string) bool { cs := cstr(mnemonic) defer C.free(unsafe.Pointer(cs)) - rc := C.ccl_crypto_validate_mnemonic(c.bridge.thread, cs) - return rc == Success + return c.bridge.invokeRC(func() C.int { return C.ccl_crypto_validate_mnemonic(c.bridge.thread, cs) }) == Success } func (c *CryptoApi) Sign(messageHex, skHex string) (string, error) { @@ -322,8 +410,7 @@ func (c *CryptoApi) Sign(messageHex, skHex string) (string, error) { csSk := cstr(skHex) defer C.free(unsafe.Pointer(csSk)) - rc := C.ccl_crypto_sign(c.bridge.thread, csMsg, csSk) - return c.bridge.check(rc) + return c.bridge.invoke(func() C.int { return C.ccl_crypto_sign(c.bridge.thread, csMsg, csSk) }) } func (c *CryptoApi) Verify(signatureHex, messageHex, pkHex string) bool { @@ -334,8 +421,7 @@ func (c *CryptoApi) Verify(signatureHex, messageHex, pkHex string) bool { csPk := cstr(pkHex) defer C.free(unsafe.Pointer(csPk)) - rc := C.ccl_crypto_verify(c.bridge.thread, csSig, csMsg, csPk) - return rc == Success + return c.bridge.invokeRC(func() C.int { return C.ccl_crypto_verify(c.bridge.thread, csSig, csMsg, csPk) }) == Success } // --- TxApi --- @@ -348,8 +434,7 @@ func (t *TxApi) Hash(txCborHex string) (string, error) { cs := cstr(txCborHex) defer C.free(unsafe.Pointer(cs)) - rc := C.ccl_tx_hash(t.bridge.thread, cs) - return t.bridge.check(rc) + return t.bridge.invoke(func() C.int { return C.ccl_tx_hash(t.bridge.thread, cs) }) } func (t *TxApi) SignWithSecretKey(txCborHex, skCborHex string) (string, error) { @@ -358,32 +443,28 @@ func (t *TxApi) SignWithSecretKey(txCborHex, skCborHex string) (string, error) { csSk := cstr(skCborHex) defer C.free(unsafe.Pointer(csSk)) - rc := C.ccl_tx_sign_with_secret_key(t.bridge.thread, csTx, csSk) - return t.bridge.check(rc) + return t.bridge.invoke(func() C.int { return C.ccl_tx_sign_with_secret_key(t.bridge.thread, csTx, csSk) }) } func (t *TxApi) ToJson(txCborHex string) (string, error) { cs := cstr(txCborHex) defer C.free(unsafe.Pointer(cs)) - rc := C.ccl_tx_to_json(t.bridge.thread, cs) - return t.bridge.check(rc) + return t.bridge.invoke(func() C.int { return C.ccl_tx_to_json(t.bridge.thread, cs) }) } func (t *TxApi) FromJson(txJson string) (string, error) { cs := cstr(txJson) defer C.free(unsafe.Pointer(cs)) - rc := C.ccl_tx_from_json(t.bridge.thread, cs) - return t.bridge.check(rc) + return t.bridge.invoke(func() C.int { return C.ccl_tx_from_json(t.bridge.thread, cs) }) } func (t *TxApi) Deserialize(txCborHex string) (string, error) { cs := cstr(txCborHex) defer C.free(unsafe.Pointer(cs)) - rc := C.ccl_tx_deserialize(t.bridge.thread, cs) - return t.bridge.check(rc) + return t.bridge.invoke(func() C.int { return C.ccl_tx_deserialize(t.bridge.thread, cs) }) } // --- PlutusApi --- @@ -396,24 +477,21 @@ func (p *PlutusApi) DataHash(datumCborHex string) (string, error) { cs := cstr(datumCborHex) defer C.free(unsafe.Pointer(cs)) - rc := C.ccl_plutus_data_hash(p.bridge.thread, cs) - return p.bridge.check(rc) + return p.bridge.invoke(func() C.int { return C.ccl_plutus_data_hash(p.bridge.thread, cs) }) } func (p *PlutusApi) DataToJson(cborHex string) (string, error) { cs := cstr(cborHex) defer C.free(unsafe.Pointer(cs)) - rc := C.ccl_plutus_data_to_json(p.bridge.thread, cs) - return p.bridge.check(rc) + return p.bridge.invoke(func() C.int { return C.ccl_plutus_data_to_json(p.bridge.thread, cs) }) } func (p *PlutusApi) DataFromJson(jsonStr string) (string, error) { cs := cstr(jsonStr) defer C.free(unsafe.Pointer(cs)) - rc := C.ccl_plutus_data_from_json(p.bridge.thread, cs) - return p.bridge.check(rc) + return p.bridge.invoke(func() C.int { return C.ccl_plutus_data_from_json(p.bridge.thread, cs) }) } // --- ScriptApi --- @@ -426,16 +504,14 @@ func (s *ScriptApi) NativeFromJson(jsonStr string) (string, error) { cs := cstr(jsonStr) defer C.free(unsafe.Pointer(cs)) - rc := C.ccl_script_native_from_json(s.bridge.thread, cs) - return s.bridge.check(rc) + return s.bridge.invoke(func() C.int { return C.ccl_script_native_from_json(s.bridge.thread, cs) }) } func (s *ScriptApi) Hash(scriptCborHex string, scriptType int) (string, error) { cs := cstr(scriptCborHex) defer C.free(unsafe.Pointer(cs)) - rc := C.ccl_script_hash(s.bridge.thread, cs, C.int(scriptType)) - return s.bridge.check(rc) + return s.bridge.invoke(func() C.int { return C.ccl_script_hash(s.bridge.thread, cs, C.int(scriptType)) }) } // --- GovApi --- @@ -448,8 +524,9 @@ func (g *GovApi) DrepKeyFromMnemonic(mnemonic string, networkID, accountIndex in cs := cstr(mnemonic) defer C.free(unsafe.Pointer(cs)) - rc := C.ccl_gov_drep_key_from_mnemonic(g.bridge.thread, cs, C.int(networkID), C.int(accountIndex)) - result, err := g.bridge.check(rc) + result, err := g.bridge.invoke(func() C.int { + return C.ccl_gov_drep_key_from_mnemonic(g.bridge.thread, cs, C.int(networkID), C.int(accountIndex)) + }) if err != nil { return nil, err } @@ -464,8 +541,9 @@ func (g *GovApi) CommitteeColdKeyFromMnemonic(mnemonic string, networkID, accoun cs := cstr(mnemonic) defer C.free(unsafe.Pointer(cs)) - rc := C.ccl_gov_committee_cold_key_from_mnemonic(g.bridge.thread, cs, C.int(networkID), C.int(accountIndex)) - result, err := g.bridge.check(rc) + result, err := g.bridge.invoke(func() C.int { + return C.ccl_gov_committee_cold_key_from_mnemonic(g.bridge.thread, cs, C.int(networkID), C.int(accountIndex)) + }) if err != nil { return nil, err } @@ -480,8 +558,9 @@ func (g *GovApi) CommitteeHotKeyFromMnemonic(mnemonic string, networkID, account cs := cstr(mnemonic) defer C.free(unsafe.Pointer(cs)) - rc := C.ccl_gov_committee_hot_key_from_mnemonic(g.bridge.thread, cs, C.int(networkID), C.int(accountIndex)) - result, err := g.bridge.check(rc) + result, err := g.bridge.invoke(func() C.int { + return C.ccl_gov_committee_hot_key_from_mnemonic(g.bridge.thread, cs, C.int(networkID), C.int(accountIndex)) + }) if err != nil { return nil, err } @@ -499,8 +578,7 @@ type WalletApi struct { } func (w *WalletApi) Create(networkID int) (*WalletInfo, error) { - rc := C.ccl_wallet_create(w.bridge.thread, C.int(networkID)) - result, err := w.bridge.check(rc) + result, err := w.bridge.invoke(func() C.int { return C.ccl_wallet_create(w.bridge.thread, C.int(networkID)) }) if err != nil { return nil, err } @@ -515,8 +593,7 @@ func (w *WalletApi) FromMnemonic(mnemonic string, networkID int) (*WalletInfo, e cs := cstr(mnemonic) defer C.free(unsafe.Pointer(cs)) - rc := C.ccl_wallet_from_mnemonic(w.bridge.thread, cs, C.int(networkID)) - result, err := w.bridge.check(rc) + result, err := w.bridge.invoke(func() C.int { return C.ccl_wallet_from_mnemonic(w.bridge.thread, cs, C.int(networkID)) }) if err != nil { return nil, err } @@ -531,1693 +608,70 @@ func (w *WalletApi) GetAddress(mnemonic string, networkID, index int) (string, e cs := cstr(mnemonic) defer C.free(unsafe.Pointer(cs)) - rc := C.ccl_wallet_get_address(w.bridge.thread, cs, C.int(networkID), C.int(index)) - return w.bridge.check(rc) + return w.bridge.invoke(func() C.int { return C.ccl_wallet_get_address(w.bridge.thread, cs, C.int(networkID), C.int(index)) }) } // --- QuickTx API --- -// TxResult is the result from building a transaction. +// TxResult is the result of building a transaction: the unsigned CBOR, its hash, and the fee. type TxResult struct { - TxCbor string `json:"tx_cbor"` - TxHash string `json:"tx_hash"` - Fee string `json:"fee"` -} - -// Amount represents a token amount in a transaction. -type Amount struct { - Unit string `json:"unit"` - Quantity string `json:"quantity"` -} - -// Lovelace creates a lovelace Amount. -func Lovelace(quantity int64) Amount { - return Amount{Unit: "lovelace", Quantity: fmt.Sprintf("%d", quantity)} -} - -// Ada creates a lovelace Amount from ADA (1 ADA = 1,000,000 lovelace). -func Ada(ada float64) Amount { - return Amount{Unit: "lovelace", Quantity: fmt.Sprintf("%d", int64(math.Floor(ada*1_000_000)))} -} - -// Asset creates a native asset Amount. -func Asset(unit string, quantity int64) Amount { - return Amount{Unit: unit, Quantity: fmt.Sprintf("%d", quantity)} -} - -// MintAsset represents an asset to mint. -type MintAsset struct { - Name string `json:"name"` - Quantity string `json:"quantity"` -} - -// AnchorOption holds optional anchor fields. -type AnchorOption struct { - AnchorURL string - AnchorDataHash string -} - -// ProviderConfig configures Java-side lazy provider fetching. -type ProviderConfig struct { - Name string `json:"name"` - URL string `json:"url"` - APIKey string `json:"api_key,omitempty"` - EnableCostEvaluation *bool `json:"enable_cost_evaluation,omitempty"` -} - -// ReferenceInput represents a reference input (read-only) for script transactions. -type ReferenceInput struct { - TxHash string `json:"tx_hash"` - OutputIndex int `json:"output_index"` -} - -// Composable is implemented by Tx and ScriptTx for use with Compose(). -type Composable interface { - ToSpec() map[string]interface{} -} - -// ProposalWithdrawal represents a treasury withdrawal in a proposal. -type ProposalWithdrawal struct { - RewardAddress string `json:"reward_address"` - Amount string `json:"amount"` -} - -// PoolOptions holds optional fields for pool registration/update. -type PoolOptions struct { - Relays []interface{} - PoolMetadataURL string - PoolMetadataHash string + TxCbor string `yaml:"tx_cbor"` + TxHash string `yaml:"tx_hash"` + Fee string `yaml:"fee"` } -// ScriptRefOption holds optional script reference fields. -type ScriptRefOption struct { - ScriptRefCborHex string - ScriptRefType string -} - -// UnregisterDRepOption holds optional fields for DRep unregistration. -type UnregisterDRepOption struct { - RefundAddress string - RefundAmount string -} - -// QuickTxApi provides transaction building via QuickTx. +// QuickTxApi builds unsigned transactions from a CCL TxPlan (YAML), fully offline. type QuickTxApi struct { bridge *Bridge } -// NewTx creates a new TxBuilder for building a single transaction. -func (q *QuickTxApi) NewTx() *TxBuilder { - return &TxBuilder{bridge: q.bridge, signerCount: 1} -} - -// Tx creates a new Tx for use with Compose(). -func (q *QuickTxApi) Tx() *Tx { - return &Tx{} -} - -// Compose creates a ComposeTxBuilder from multiple Composable objects (Tx or ScriptTx). -func (q *QuickTxApi) Compose(txs ...Composable) *ComposeTxBuilder { - return &ComposeTxBuilder{bridge: q.bridge, txs: txs} -} - -// NewScriptTx creates a new ScriptTxBuilder for building a single script transaction. -func (q *QuickTxApi) NewScriptTx() *ScriptTxBuilder { - return &ScriptTxBuilder{bridge: q.bridge, signerCount: 1} -} - -// ScriptTx creates a new ScriptTx for use with Compose(). -func (q *QuickTxApi) ScriptTx() *ScriptTx { - return &ScriptTx{} -} - -// --- TxBuilder --- - -// TxBuilder builds a single transaction spec. -type TxBuilder struct { - bridge *Bridge - operations []map[string]interface{} - from string - changeAddress string - feePayer string - utxos interface{} - protocolParams interface{} - validity map[string]interface{} - mergeOutputs *bool - signerCount int -} - -func (tb *TxBuilder) PayToAddress(address string, amounts ...Amount) *TxBuilder { - amountList := make([]Amount, len(amounts)) - copy(amountList, amounts) - tb.operations = append(tb.operations, map[string]interface{}{ - "type": "pay_to_address", - "address": address, - "amounts": amountList, - }) - return tb -} - -// PayToAddressWithScriptRef is like PayToAddress but attaches a reference script to the output. -func (tb *TxBuilder) PayToAddressWithScriptRef(address string, amounts []Amount, scriptRef ScriptRefOption) *TxBuilder { - op := map[string]interface{}{ - "type": "pay_to_address", - "address": address, - "amounts": amounts, - } - if scriptRef.ScriptRefCborHex != "" { - op["script_ref_cbor_hex"] = scriptRef.ScriptRefCborHex - } - if scriptRef.ScriptRefType != "" { - op["script_ref_type"] = scriptRef.ScriptRefType - } - tb.operations = append(tb.operations, op) - return tb -} - -func (tb *TxBuilder) PayToContract(address string, amounts []Amount, datumCborHex, datumHash string, scriptRef ...ScriptRefOption) *TxBuilder { - op := map[string]interface{}{ - "type": "pay_to_contract", - "address": address, - "amounts": amounts, - } - if datumCborHex != "" { - op["datum_cbor_hex"] = datumCborHex - } - if datumHash != "" { - op["datum_hash"] = datumHash - } - if len(scriptRef) > 0 { - if scriptRef[0].ScriptRefCborHex != "" { - op["script_ref_cbor_hex"] = scriptRef[0].ScriptRefCborHex - } - if scriptRef[0].ScriptRefType != "" { - op["script_ref_type"] = scriptRef[0].ScriptRefType - } - } - tb.operations = append(tb.operations, op) - return tb -} - -func (tb *TxBuilder) MintAssets(scriptJSON string, assets []MintAsset, receiver string) *TxBuilder { - tb.operations = append(tb.operations, map[string]interface{}{ - "type": "mint_assets", - "script_json": scriptJSON, - "assets": assets, - "receiver": receiver, - }) - return tb -} - -func (tb *TxBuilder) AttachMetadata(label int, metadata interface{}) *TxBuilder { - tb.operations = append(tb.operations, map[string]interface{}{ - "type": "attach_metadata", - "label": label, - "metadata": metadata, - }) - return tb -} - -func (tb *TxBuilder) CollectFrom(utxos []map[string]interface{}) *TxBuilder { - tb.operations = append(tb.operations, map[string]interface{}{ - "type": "collect_from", - "collect_utxos": utxos, - }) - return tb -} - -// Staking - -func (tb *TxBuilder) RegisterStakeAddress(address string) *TxBuilder { - tb.operations = append(tb.operations, map[string]interface{}{ - "type": "register_stake_address", "address": address, - }) - return tb -} - -func (tb *TxBuilder) DeregisterStakeAddress(address string, refundAddress ...string) *TxBuilder { - op := map[string]interface{}{"type": "deregister_stake_address", "address": address} - if len(refundAddress) > 0 && refundAddress[0] != "" { - op["refund_address"] = refundAddress[0] - } - tb.operations = append(tb.operations, op) - return tb -} - -func (tb *TxBuilder) DelegateTo(address, poolID string) *TxBuilder { - tb.operations = append(tb.operations, map[string]interface{}{ - "type": "delegate_to", "address": address, "pool_id": poolID, - }) - return tb -} - -func (tb *TxBuilder) Withdraw(rewardAddress string, amount int64, receiver ...string) *TxBuilder { - op := map[string]interface{}{ - "type": "withdraw", "reward_address": rewardAddress, "amount": fmt.Sprintf("%d", amount), - } - if len(receiver) > 0 && receiver[0] != "" { - op["receiver"] = receiver[0] - } - tb.operations = append(tb.operations, op) - return tb -} - -// DRep - -func (tb *TxBuilder) RegisterDRep(credHash, credType string, anchor ...AnchorOption) *TxBuilder { - op := map[string]interface{}{ - "type": "register_drep", "credential_hash": credHash, "credential_type": credType, - } - if len(anchor) > 0 { - if anchor[0].AnchorURL != "" { - op["anchor_url"] = anchor[0].AnchorURL - } - if anchor[0].AnchorDataHash != "" { - op["anchor_data_hash"] = anchor[0].AnchorDataHash - } - } - tb.operations = append(tb.operations, op) - return tb -} - -func (tb *TxBuilder) UnregisterDRep(credHash, credType string, opts ...UnregisterDRepOption) *TxBuilder { - op := map[string]interface{}{ - "type": "unregister_drep", "credential_hash": credHash, "credential_type": credType, - } - if len(opts) > 0 { - if opts[0].RefundAddress != "" { - op["refund_address"] = opts[0].RefundAddress - } - if opts[0].RefundAmount != "" { - op["refund_amount"] = opts[0].RefundAmount - } - } - tb.operations = append(tb.operations, op) - return tb -} - -func (tb *TxBuilder) UpdateDRep(credHash, credType string, anchor ...AnchorOption) *TxBuilder { - op := map[string]interface{}{ - "type": "update_drep", "credential_hash": credHash, "credential_type": credType, - } - if len(anchor) > 0 { - if anchor[0].AnchorURL != "" { - op["anchor_url"] = anchor[0].AnchorURL - } - if anchor[0].AnchorDataHash != "" { - op["anchor_data_hash"] = anchor[0].AnchorDataHash - } - } - tb.operations = append(tb.operations, op) - return tb -} - -// Voting - -func (tb *TxBuilder) DelegateVotingPowerTo(address, drepType string, drepHash ...string) *TxBuilder { - op := map[string]interface{}{ - "type": "delegate_voting_power_to", "address": address, "drep_type": drepType, - } - if len(drepHash) > 0 && drepHash[0] != "" { - op["drep_hash"] = drepHash[0] - } - tb.operations = append(tb.operations, op) - return tb -} - -func (tb *TxBuilder) CreateVote(voterType, voterHash, govActionTxHash string, govActionIndex int, vote string, anchor ...AnchorOption) *TxBuilder { - op := map[string]interface{}{ - "type": "create_vote", "voter_type": voterType, "voter_hash": voterHash, - "gov_action_tx_hash": govActionTxHash, "gov_action_index": govActionIndex, "vote": vote, - } - if len(anchor) > 0 { - if anchor[0].AnchorURL != "" { - op["anchor_url"] = anchor[0].AnchorURL - } - if anchor[0].AnchorDataHash != "" { - op["anchor_data_hash"] = anchor[0].AnchorDataHash - } - } - tb.operations = append(tb.operations, op) - return tb -} - -// Governance - -func (tb *TxBuilder) CreateProposal(govActionType, returnAddress, anchorURL, anchorDataHash string, withdrawals ...[]ProposalWithdrawal) *TxBuilder { - op := map[string]interface{}{ - "type": "create_proposal", "gov_action_type": govActionType, - "return_address": returnAddress, "anchor_url": anchorURL, "anchor_data_hash": anchorDataHash, +// Build builds an unsigned transaction from a TxPlan YAML document using the caller-supplied +// UTXOs and protocol parameters. utxos and protocolParams are marshalled to JSON (the standard +// CCL Utxo / ProtocolParams models). The transaction is built offline and never submitted — +// sign the returned TxCbor and submit it yourself. +// +// For Plutus script transactions, pass the redeemers' execution units as the optional execUnits +// argument: a slice of {mem, steps} (one per redeemer, in transaction order). Compute them with any +// evaluator (Ogmios, Blockfrost, Aiken, Scalus); the bridge does not run the script. +func (q *QuickTxApi) Build(yaml string, utxos interface{}, protocolParams interface{}, execUnits ...interface{}) (*TxResult, error) { + utxosJSON, err := json.Marshal(utxos) + if err != nil { + return nil, fmt.Errorf("failed to marshal utxos: %w", err) } - if len(withdrawals) > 0 && len(withdrawals[0]) > 0 { - op["withdrawals"] = withdrawals[0] + ppJSON, err := json.Marshal(protocolParams) + if err != nil { + return nil, fmt.Errorf("failed to marshal protocol params: %w", err) } - tb.operations = append(tb.operations, op) - return tb -} - -// Pool Operations -func (tb *TxBuilder) RegisterPool(operator, vrfKeyHash, pledge, cost, marginNumerator, marginDenominator, rewardAddress string, poolOwners []string, opts ...PoolOptions) *TxBuilder { - op := map[string]interface{}{ - "type": "register_pool", "operator": operator, "vrf_key_hash": vrfKeyHash, - "pledge": pledge, "cost": cost, "margin_numerator": marginNumerator, - "margin_denominator": marginDenominator, "reward_address": rewardAddress, - "pool_owners": poolOwners, - } - if len(opts) > 0 { - if len(opts[0].Relays) > 0 { - op["relays"] = opts[0].Relays - } - if opts[0].PoolMetadataURL != "" { - op["pool_metadata_url"] = opts[0].PoolMetadataURL - } - if opts[0].PoolMetadataHash != "" { - op["pool_metadata_hash"] = opts[0].PoolMetadataHash - } - } - tb.operations = append(tb.operations, op) - return tb -} + yamlCs := cstr(yaml) + defer C.free(unsafe.Pointer(yamlCs)) + utxosCs := cstr(string(utxosJSON)) + defer C.free(unsafe.Pointer(utxosCs)) + ppCs := cstr(string(ppJSON)) + defer C.free(unsafe.Pointer(ppCs)) -func (tb *TxBuilder) UpdatePool(operator, vrfKeyHash, pledge, cost, marginNumerator, marginDenominator, rewardAddress string, poolOwners []string, opts ...PoolOptions) *TxBuilder { - op := map[string]interface{}{ - "type": "update_pool", "operator": operator, "vrf_key_hash": vrfKeyHash, - "pledge": pledge, "cost": cost, "margin_numerator": marginNumerator, - "margin_denominator": marginDenominator, "reward_address": rewardAddress, - "pool_owners": poolOwners, - } - if len(opts) > 0 { - if len(opts[0].Relays) > 0 { - op["relays"] = opts[0].Relays - } - if opts[0].PoolMetadataURL != "" { - op["pool_metadata_url"] = opts[0].PoolMetadataURL - } - if opts[0].PoolMetadataHash != "" { - op["pool_metadata_hash"] = opts[0].PoolMetadataHash + // nil *C.char marshals to a NULL pointer (no execution units). + var execCs *C.char + if len(execUnits) > 0 && execUnits[0] != nil { + execJSON, err := json.Marshal(execUnits[0]) + if err != nil { + return nil, fmt.Errorf("failed to marshal exec units: %w", err) } + execCs = cstr(string(execJSON)) + defer C.free(unsafe.Pointer(execCs)) } - tb.operations = append(tb.operations, op) - return tb -} - -func (tb *TxBuilder) RetirePool(poolID string, epoch int) *TxBuilder { - tb.operations = append(tb.operations, map[string]interface{}{ - "type": "retire_pool", "pool_id": poolID, "epoch": epoch, - }) - return tb -} - -// Treasury -func (tb *TxBuilder) DonateToTreasury(treasuryValue, donationAmount string) *TxBuilder { - tb.operations = append(tb.operations, map[string]interface{}{ - "type": "donate_to_treasury", "treasury_value": treasuryValue, "donation_amount": donationAmount, + result, err := q.bridge.invoke(func() C.int { + return C.ccl_quicktx_build(q.bridge.thread, yamlCs, utxosCs, ppCs, execCs) }) - return tb -} - -// Native Script - -func (tb *TxBuilder) AttachNativeScript(scriptJSON string) *TxBuilder { - tb.operations = append(tb.operations, map[string]interface{}{ - "type": "attach_native_script", "script_json": scriptJSON, - }) - return tb -} - -// Config - -func (tb *TxBuilder) From(address string) *TxBuilder { - tb.from = address - return tb -} - -func (tb *TxBuilder) ChangeAddress(address string) *TxBuilder { - tb.changeAddress = address - return tb -} - -func (tb *TxBuilder) FeePayer(address string) *TxBuilder { - tb.feePayer = address - return tb -} - -func (tb *TxBuilder) WithUtxos(utxos interface{}) *TxBuilder { - tb.utxos = utxos - return tb -} - -func (tb *TxBuilder) WithProtocolParams(params interface{}) *TxBuilder { - tb.protocolParams = params - return tb -} - -func (tb *TxBuilder) ValidFrom(slot int64) *TxBuilder { - if tb.validity == nil { - tb.validity = make(map[string]interface{}) - } - tb.validity["valid_from"] = slot - return tb -} - -func (tb *TxBuilder) ValidTo(slot int64) *TxBuilder { - if tb.validity == nil { - tb.validity = make(map[string]interface{}) - } - tb.validity["valid_to"] = slot - return tb -} - -func (tb *TxBuilder) MergeOutputs(merge bool) *TxBuilder { - tb.mergeOutputs = &merge - return tb -} - -func (tb *TxBuilder) SignerCount(count int) *TxBuilder { - tb.signerCount = count - return tb -} - -func (tb *TxBuilder) buildSpec(providerConfig *ProviderConfig) map[string]interface{} { - spec := map[string]interface{}{ - "operations": tb.operations, - "from": tb.from, - "signer_count": tb.signerCount, - } - if providerConfig != nil { - spec["provider"] = providerConfig - } else { - spec["utxos"] = tb.utxos - } - if tb.protocolParams != nil { - spec["protocol_params"] = tb.protocolParams - } - if tb.changeAddress != "" { - spec["change_address"] = tb.changeAddress - } - if tb.feePayer != "" { - spec["fee_payer"] = tb.feePayer - } - if len(tb.validity) > 0 { - spec["validity"] = tb.validity - } - if tb.mergeOutputs != nil { - spec["merge_outputs"] = *tb.mergeOutputs - } - return spec -} - -// Build builds the transaction. Returns TxResult with tx_cbor, tx_hash, fee. -func (tb *TxBuilder) Build() (*TxResult, error) { - return tb.doBuild(nil) -} - -// BuildWithProvider builds with a Java-side provider config for lazy UTXO fetching. -func (tb *TxBuilder) BuildWithProvider(config ProviderConfig) (*TxResult, error) { - return tb.doBuild(&config) -} - -func (tb *TxBuilder) doBuild(providerConfig *ProviderConfig) (*TxResult, error) { - spec := tb.buildSpec(providerConfig) - specJSON, err := json.Marshal(spec) - if err != nil { - return nil, fmt.Errorf("failed to marshal spec: %w", err) - } - - cs := cstr(string(specJSON)) - defer C.free(unsafe.Pointer(cs)) - - rc := C.ccl_quicktx_build(tb.bridge.thread, cs) - result, err := tb.bridge.check(rc) if err != nil { return nil, err } + // The build result is a YAML document. var txResult TxResult - if err := json.Unmarshal([]byte(result), &txResult); err != nil { + if err := goyaml.Unmarshal([]byte(result), &txResult); err != nil { return nil, fmt.Errorf("failed to parse tx result: %w", err) } return &txResult, nil } - -// --- Tx (for Compose) --- - -// Tx is a lightweight operation collector for use with Compose. -type Tx struct { - operations []map[string]interface{} - from string - changeAddress string -} - -func (tx *Tx) PayToAddress(address string, amounts ...Amount) *Tx { - amountList := make([]Amount, len(amounts)) - copy(amountList, amounts) - tx.operations = append(tx.operations, map[string]interface{}{ - "type": "pay_to_address", - "address": address, - "amounts": amountList, - }) - return tx -} - -// PayToAddressWithScriptRef is like PayToAddress but attaches a reference script to the output. -func (tx *Tx) PayToAddressWithScriptRef(address string, amounts []Amount, scriptRef ScriptRefOption) *Tx { - op := map[string]interface{}{ - "type": "pay_to_address", - "address": address, - "amounts": amounts, - } - if scriptRef.ScriptRefCborHex != "" { - op["script_ref_cbor_hex"] = scriptRef.ScriptRefCborHex - } - if scriptRef.ScriptRefType != "" { - op["script_ref_type"] = scriptRef.ScriptRefType - } - tx.operations = append(tx.operations, op) - return tx -} - -func (tx *Tx) PayToContract(address string, amounts []Amount, datumCborHex, datumHash string, scriptRef ...ScriptRefOption) *Tx { - op := map[string]interface{}{ - "type": "pay_to_contract", - "address": address, - "amounts": amounts, - } - if datumCborHex != "" { - op["datum_cbor_hex"] = datumCborHex - } - if datumHash != "" { - op["datum_hash"] = datumHash - } - if len(scriptRef) > 0 { - if scriptRef[0].ScriptRefCborHex != "" { - op["script_ref_cbor_hex"] = scriptRef[0].ScriptRefCborHex - } - if scriptRef[0].ScriptRefType != "" { - op["script_ref_type"] = scriptRef[0].ScriptRefType - } - } - tx.operations = append(tx.operations, op) - return tx -} - -func (tx *Tx) MintAssets(scriptJSON string, assets []MintAsset, receiver string) *Tx { - tx.operations = append(tx.operations, map[string]interface{}{ - "type": "mint_assets", - "script_json": scriptJSON, - "assets": assets, - "receiver": receiver, - }) - return tx -} - -func (tx *Tx) AttachMetadata(label int, metadata interface{}) *Tx { - tx.operations = append(tx.operations, map[string]interface{}{ - "type": "attach_metadata", - "label": label, - "metadata": metadata, - }) - return tx -} - -func (tx *Tx) CollectFrom(utxos []map[string]interface{}) *Tx { - tx.operations = append(tx.operations, map[string]interface{}{ - "type": "collect_from", - "collect_utxos": utxos, - }) - return tx -} - -func (tx *Tx) RegisterStakeAddress(address string) *Tx { - tx.operations = append(tx.operations, map[string]interface{}{ - "type": "register_stake_address", "address": address, - }) - return tx -} - -func (tx *Tx) DeregisterStakeAddress(address string, refundAddress ...string) *Tx { - op := map[string]interface{}{"type": "deregister_stake_address", "address": address} - if len(refundAddress) > 0 && refundAddress[0] != "" { - op["refund_address"] = refundAddress[0] - } - tx.operations = append(tx.operations, op) - return tx -} - -func (tx *Tx) DelegateTo(address, poolID string) *Tx { - tx.operations = append(tx.operations, map[string]interface{}{ - "type": "delegate_to", "address": address, "pool_id": poolID, - }) - return tx -} - -func (tx *Tx) Withdraw(rewardAddress string, amount int64, receiver ...string) *Tx { - op := map[string]interface{}{ - "type": "withdraw", "reward_address": rewardAddress, "amount": fmt.Sprintf("%d", amount), - } - if len(receiver) > 0 && receiver[0] != "" { - op["receiver"] = receiver[0] - } - tx.operations = append(tx.operations, op) - return tx -} - -func (tx *Tx) RegisterDRep(credHash, credType string, anchor ...AnchorOption) *Tx { - op := map[string]interface{}{ - "type": "register_drep", "credential_hash": credHash, "credential_type": credType, - } - if len(anchor) > 0 { - if anchor[0].AnchorURL != "" { - op["anchor_url"] = anchor[0].AnchorURL - } - if anchor[0].AnchorDataHash != "" { - op["anchor_data_hash"] = anchor[0].AnchorDataHash - } - } - tx.operations = append(tx.operations, op) - return tx -} - -func (tx *Tx) UnregisterDRep(credHash, credType string, opts ...UnregisterDRepOption) *Tx { - op := map[string]interface{}{ - "type": "unregister_drep", "credential_hash": credHash, "credential_type": credType, - } - if len(opts) > 0 { - if opts[0].RefundAddress != "" { - op["refund_address"] = opts[0].RefundAddress - } - if opts[0].RefundAmount != "" { - op["refund_amount"] = opts[0].RefundAmount - } - } - tx.operations = append(tx.operations, op) - return tx -} - -func (tx *Tx) UpdateDRep(credHash, credType string, anchor ...AnchorOption) *Tx { - op := map[string]interface{}{ - "type": "update_drep", "credential_hash": credHash, "credential_type": credType, - } - if len(anchor) > 0 { - if anchor[0].AnchorURL != "" { - op["anchor_url"] = anchor[0].AnchorURL - } - if anchor[0].AnchorDataHash != "" { - op["anchor_data_hash"] = anchor[0].AnchorDataHash - } - } - tx.operations = append(tx.operations, op) - return tx -} - -func (tx *Tx) DelegateVotingPowerTo(address, drepType string, drepHash ...string) *Tx { - op := map[string]interface{}{ - "type": "delegate_voting_power_to", "address": address, "drep_type": drepType, - } - if len(drepHash) > 0 && drepHash[0] != "" { - op["drep_hash"] = drepHash[0] - } - tx.operations = append(tx.operations, op) - return tx -} - -func (tx *Tx) CreateVote(voterType, voterHash, govActionTxHash string, govActionIndex int, vote string, anchor ...AnchorOption) *Tx { - op := map[string]interface{}{ - "type": "create_vote", "voter_type": voterType, "voter_hash": voterHash, - "gov_action_tx_hash": govActionTxHash, "gov_action_index": govActionIndex, "vote": vote, - } - if len(anchor) > 0 { - if anchor[0].AnchorURL != "" { - op["anchor_url"] = anchor[0].AnchorURL - } - if anchor[0].AnchorDataHash != "" { - op["anchor_data_hash"] = anchor[0].AnchorDataHash - } - } - tx.operations = append(tx.operations, op) - return tx -} - -func (tx *Tx) CreateProposal(govActionType, returnAddress, anchorURL, anchorDataHash string, withdrawals ...[]ProposalWithdrawal) *Tx { - op := map[string]interface{}{ - "type": "create_proposal", "gov_action_type": govActionType, - "return_address": returnAddress, "anchor_url": anchorURL, "anchor_data_hash": anchorDataHash, - } - if len(withdrawals) > 0 && len(withdrawals[0]) > 0 { - op["withdrawals"] = withdrawals[0] - } - tx.operations = append(tx.operations, op) - return tx -} - -// Pool Operations - -func (tx *Tx) RegisterPool(operator, vrfKeyHash, pledge, cost, marginNumerator, marginDenominator, rewardAddress string, poolOwners []string, opts ...PoolOptions) *Tx { - op := map[string]interface{}{ - "type": "register_pool", "operator": operator, "vrf_key_hash": vrfKeyHash, - "pledge": pledge, "cost": cost, "margin_numerator": marginNumerator, - "margin_denominator": marginDenominator, "reward_address": rewardAddress, - "pool_owners": poolOwners, - } - if len(opts) > 0 { - if len(opts[0].Relays) > 0 { - op["relays"] = opts[0].Relays - } - if opts[0].PoolMetadataURL != "" { - op["pool_metadata_url"] = opts[0].PoolMetadataURL - } - if opts[0].PoolMetadataHash != "" { - op["pool_metadata_hash"] = opts[0].PoolMetadataHash - } - } - tx.operations = append(tx.operations, op) - return tx -} - -func (tx *Tx) UpdatePool(operator, vrfKeyHash, pledge, cost, marginNumerator, marginDenominator, rewardAddress string, poolOwners []string, opts ...PoolOptions) *Tx { - op := map[string]interface{}{ - "type": "update_pool", "operator": operator, "vrf_key_hash": vrfKeyHash, - "pledge": pledge, "cost": cost, "margin_numerator": marginNumerator, - "margin_denominator": marginDenominator, "reward_address": rewardAddress, - "pool_owners": poolOwners, - } - if len(opts) > 0 { - if len(opts[0].Relays) > 0 { - op["relays"] = opts[0].Relays - } - if opts[0].PoolMetadataURL != "" { - op["pool_metadata_url"] = opts[0].PoolMetadataURL - } - if opts[0].PoolMetadataHash != "" { - op["pool_metadata_hash"] = opts[0].PoolMetadataHash - } - } - tx.operations = append(tx.operations, op) - return tx -} - -func (tx *Tx) RetirePool(poolID string, epoch int) *Tx { - tx.operations = append(tx.operations, map[string]interface{}{ - "type": "retire_pool", "pool_id": poolID, "epoch": epoch, - }) - return tx -} - -// Treasury - -func (tx *Tx) DonateToTreasury(treasuryValue, donationAmount string) *Tx { - tx.operations = append(tx.operations, map[string]interface{}{ - "type": "donate_to_treasury", "treasury_value": treasuryValue, "donation_amount": donationAmount, - }) - return tx -} - -// Native Script - -func (tx *Tx) AttachNativeScript(scriptJSON string) *Tx { - tx.operations = append(tx.operations, map[string]interface{}{ - "type": "attach_native_script", "script_json": scriptJSON, - }) - return tx -} - -func (tx *Tx) From(address string) *Tx { - tx.from = address - return tx -} - -func (tx *Tx) ChangeAddress(address string) *Tx { - tx.changeAddress = address - return tx -} - -func (tx *Tx) ToSpec() map[string]interface{} { - spec := map[string]interface{}{ - "from": tx.from, - "operations": tx.operations, - } - if tx.changeAddress != "" { - spec["change_address"] = tx.changeAddress - } - return spec -} - -// --- ComposeTxBuilder --- - -// ComposeTxBuilder composes multiple Composable objects into a single transaction. -type ComposeTxBuilder struct { - bridge *Bridge - txs []Composable - feePayer string - utxos interface{} - protocolParams interface{} - validity map[string]interface{} - mergeOutputs *bool - signerCount *int -} - -func (cb *ComposeTxBuilder) FeePayer(address string) *ComposeTxBuilder { - cb.feePayer = address - return cb -} - -func (cb *ComposeTxBuilder) WithUtxos(utxos interface{}) *ComposeTxBuilder { - cb.utxos = utxos - return cb -} - -func (cb *ComposeTxBuilder) WithProtocolParams(params interface{}) *ComposeTxBuilder { - cb.protocolParams = params - return cb -} - -func (cb *ComposeTxBuilder) ValidFrom(slot int64) *ComposeTxBuilder { - if cb.validity == nil { - cb.validity = make(map[string]interface{}) - } - cb.validity["valid_from"] = slot - return cb -} - -func (cb *ComposeTxBuilder) ValidTo(slot int64) *ComposeTxBuilder { - if cb.validity == nil { - cb.validity = make(map[string]interface{}) - } - cb.validity["valid_to"] = slot - return cb -} - -func (cb *ComposeTxBuilder) MergeOutputs(merge bool) *ComposeTxBuilder { - cb.mergeOutputs = &merge - return cb -} - -func (cb *ComposeTxBuilder) SignerCount(count int) *ComposeTxBuilder { - cb.signerCount = &count - return cb -} - -// Build builds the composed transaction. -func (cb *ComposeTxBuilder) Build() (*TxResult, error) { - return cb.doBuild(nil) -} - -// BuildWithProvider builds with a Java-side provider config. -func (cb *ComposeTxBuilder) BuildWithProvider(config ProviderConfig) (*TxResult, error) { - return cb.doBuild(&config) -} - -func (cb *ComposeTxBuilder) doBuild(providerConfig *ProviderConfig) (*TxResult, error) { - txSpecs := make([]map[string]interface{}, len(cb.txs)) - for i, tx := range cb.txs { - txSpecs[i] = tx.ToSpec() - } - - spec := map[string]interface{}{ - "transactions": txSpecs, - "fee_payer": cb.feePayer, - } - - if providerConfig != nil { - spec["provider"] = providerConfig - } else { - spec["utxos"] = cb.utxos - } - if cb.protocolParams != nil { - spec["protocol_params"] = cb.protocolParams - } - if cb.signerCount != nil { - spec["signer_count"] = *cb.signerCount - } - if len(cb.validity) > 0 { - spec["validity"] = cb.validity - } - if cb.mergeOutputs != nil { - spec["merge_outputs"] = *cb.mergeOutputs - } - - specJSON, err := json.Marshal(spec) - if err != nil { - return nil, fmt.Errorf("failed to marshal compose spec: %w", err) - } - - cs := cstr(string(specJSON)) - defer C.free(unsafe.Pointer(cs)) - - rc := C.ccl_quicktx_build(cb.bridge.thread, cs) - result, err := cb.bridge.check(rc) - if err != nil { - return nil, err - } - - var txResult TxResult - if err := json.Unmarshal([]byte(result), &txResult); err != nil { - return nil, fmt.Errorf("failed to parse tx result: %w", err) - } - return &txResult, nil -} - -// --- ScriptTxBuilder --- - -// ScriptTxBuilder builds a single script transaction spec. -type ScriptTxBuilder struct { - bridge *Bridge - operations []map[string]interface{} - from string - changeAddress string - feePayer string - utxos interface{} - protocolParams interface{} - validity map[string]interface{} - mergeOutputs *bool - signerCount int - changeDatumCbor string - changeDatumHash string -} - -func (sb *ScriptTxBuilder) PayToAddress(address string, amounts ...Amount) *ScriptTxBuilder { - amountList := make([]Amount, len(amounts)) - copy(amountList, amounts) - sb.operations = append(sb.operations, map[string]interface{}{ - "type": "pay_to_address", - "address": address, - "amounts": amountList, - }) - return sb -} - -// PayToAddressWithScriptRef is like PayToAddress but attaches a reference script to the output. -func (sb *ScriptTxBuilder) PayToAddressWithScriptRef(address string, amounts []Amount, scriptRef ScriptRefOption) *ScriptTxBuilder { - op := map[string]interface{}{ - "type": "pay_to_address", - "address": address, - "amounts": amounts, - } - if scriptRef.ScriptRefCborHex != "" { - op["script_ref_cbor_hex"] = scriptRef.ScriptRefCborHex - } - if scriptRef.ScriptRefType != "" { - op["script_ref_type"] = scriptRef.ScriptRefType - } - sb.operations = append(sb.operations, op) - return sb -} - -func (sb *ScriptTxBuilder) PayToContract(address string, amounts []Amount, datumCborHex, datumHash string, scriptRef ...ScriptRefOption) *ScriptTxBuilder { - op := map[string]interface{}{ - "type": "pay_to_contract", - "address": address, - "amounts": amounts, - } - if datumCborHex != "" { - op["datum_cbor_hex"] = datumCborHex - } - if datumHash != "" { - op["datum_hash"] = datumHash - } - if len(scriptRef) > 0 { - if scriptRef[0].ScriptRefCborHex != "" { - op["script_ref_cbor_hex"] = scriptRef[0].ScriptRefCborHex - } - if scriptRef[0].ScriptRefType != "" { - op["script_ref_type"] = scriptRef[0].ScriptRefType - } - } - sb.operations = append(sb.operations, op) - return sb -} - -func (sb *ScriptTxBuilder) AttachMetadata(label int, metadata interface{}) *ScriptTxBuilder { - sb.operations = append(sb.operations, map[string]interface{}{ - "type": "attach_metadata", - "label": label, - "metadata": metadata, - }) - return sb -} - -func (sb *ScriptTxBuilder) CollectFrom(utxos []map[string]interface{}) *ScriptTxBuilder { - sb.operations = append(sb.operations, map[string]interface{}{ - "type": "collect_from", - "collect_utxos": utxos, - }) - return sb -} - -func (sb *ScriptTxBuilder) CollectFromScript(utxos []map[string]interface{}, redeemerCborHex, datumCborHex string) *ScriptTxBuilder { - op := map[string]interface{}{ - "type": "collect_from", - "collect_utxos": utxos, - "redeemer_cbor_hex": redeemerCborHex, - } - if datumCborHex != "" { - op["datum_cbor_hex"] = datumCborHex - } - sb.operations = append(sb.operations, op) - return sb -} - -func (sb *ScriptTxBuilder) ReadFrom(referenceInputs []ReferenceInput) *ScriptTxBuilder { - sb.operations = append(sb.operations, map[string]interface{}{ - "type": "read_from", - "reference_inputs": referenceInputs, - }) - return sb -} - -func (sb *ScriptTxBuilder) MintPlutusAssets(scriptCborHex, scriptType string, assets []MintAsset, redeemerCborHex, receiver, outputDatumCborHex string) *ScriptTxBuilder { - op := map[string]interface{}{ - "type": "mint_plutus_assets", - "script_cbor_hex": scriptCborHex, - "script_type": scriptType, - "assets": assets, - "redeemer_cbor_hex": redeemerCborHex, - } - if receiver != "" { - op["receiver"] = receiver - } - if outputDatumCborHex != "" { - op["output_datum_cbor_hex"] = outputDatumCborHex - } - sb.operations = append(sb.operations, op) - return sb -} - -func (sb *ScriptTxBuilder) AttachSpendingValidator(scriptCborHex, scriptType string) *ScriptTxBuilder { - sb.operations = append(sb.operations, map[string]interface{}{ - "type": "attach_spending_validator", "script_cbor_hex": scriptCborHex, "script_type": scriptType, - }) - return sb -} - -func (sb *ScriptTxBuilder) AttachCertificateValidator(scriptCborHex, scriptType string) *ScriptTxBuilder { - sb.operations = append(sb.operations, map[string]interface{}{ - "type": "attach_certificate_validator", "script_cbor_hex": scriptCborHex, "script_type": scriptType, - }) - return sb -} - -func (sb *ScriptTxBuilder) AttachRewardValidator(scriptCborHex, scriptType string) *ScriptTxBuilder { - sb.operations = append(sb.operations, map[string]interface{}{ - "type": "attach_reward_validator", "script_cbor_hex": scriptCborHex, "script_type": scriptType, - }) - return sb -} - -func (sb *ScriptTxBuilder) AttachProposingValidator(scriptCborHex, scriptType string) *ScriptTxBuilder { - sb.operations = append(sb.operations, map[string]interface{}{ - "type": "attach_proposing_validator", "script_cbor_hex": scriptCborHex, "script_type": scriptType, - }) - return sb -} - -func (sb *ScriptTxBuilder) AttachVotingValidator(scriptCborHex, scriptType string) *ScriptTxBuilder { - sb.operations = append(sb.operations, map[string]interface{}{ - "type": "attach_voting_validator", "script_cbor_hex": scriptCborHex, "script_type": scriptType, - }) - return sb -} - -// Redeemer-enhanced staking/governance - -func (sb *ScriptTxBuilder) DeregisterStakeAddress(address, redeemerCborHex, refundAddress string) *ScriptTxBuilder { - op := map[string]interface{}{ - "type": "deregister_stake_address", "address": address, "redeemer_cbor_hex": redeemerCborHex, - } - if refundAddress != "" { - op["refund_address"] = refundAddress - } - sb.operations = append(sb.operations, op) - return sb -} - -func (sb *ScriptTxBuilder) DelegateTo(address, poolID, redeemerCborHex string) *ScriptTxBuilder { - sb.operations = append(sb.operations, map[string]interface{}{ - "type": "delegate_to", "address": address, "pool_id": poolID, "redeemer_cbor_hex": redeemerCborHex, - }) - return sb -} - -func (sb *ScriptTxBuilder) Withdraw(rewardAddress, amount, redeemerCborHex, receiver string) *ScriptTxBuilder { - op := map[string]interface{}{ - "type": "withdraw", "reward_address": rewardAddress, "amount": amount, "redeemer_cbor_hex": redeemerCborHex, - } - if receiver != "" { - op["receiver"] = receiver - } - sb.operations = append(sb.operations, op) - return sb -} - -func (sb *ScriptTxBuilder) RegisterDRep(credHash, credType, redeemerCborHex, anchorURL, anchorDataHash string) *ScriptTxBuilder { - op := map[string]interface{}{ - "type": "register_drep", "credential_hash": credHash, "credential_type": credType, - "redeemer_cbor_hex": redeemerCborHex, - } - if anchorURL != "" { - op["anchor_url"] = anchorURL - } - if anchorDataHash != "" { - op["anchor_data_hash"] = anchorDataHash - } - sb.operations = append(sb.operations, op) - return sb -} - -func (sb *ScriptTxBuilder) UnregisterDRep(credHash, credType, redeemerCborHex string, opts ...UnregisterDRepOption) *ScriptTxBuilder { - op := map[string]interface{}{ - "type": "unregister_drep", "credential_hash": credHash, "credential_type": credType, - "redeemer_cbor_hex": redeemerCborHex, - } - if len(opts) > 0 { - if opts[0].RefundAddress != "" { - op["refund_address"] = opts[0].RefundAddress - } - if opts[0].RefundAmount != "" { - op["refund_amount"] = opts[0].RefundAmount - } - } - sb.operations = append(sb.operations, op) - return sb -} - -func (sb *ScriptTxBuilder) UpdateDRep(credHash, credType, redeemerCborHex, anchorURL, anchorDataHash string) *ScriptTxBuilder { - op := map[string]interface{}{ - "type": "update_drep", "credential_hash": credHash, "credential_type": credType, - "redeemer_cbor_hex": redeemerCborHex, - } - if anchorURL != "" { - op["anchor_url"] = anchorURL - } - if anchorDataHash != "" { - op["anchor_data_hash"] = anchorDataHash - } - sb.operations = append(sb.operations, op) - return sb -} - -func (sb *ScriptTxBuilder) DelegateVotingPowerTo(address, drepType, drepHash, redeemerCborHex string) *ScriptTxBuilder { - op := map[string]interface{}{ - "type": "delegate_voting_power_to", "address": address, "drep_type": drepType, - "redeemer_cbor_hex": redeemerCborHex, - } - if drepHash != "" { - op["drep_hash"] = drepHash - } - sb.operations = append(sb.operations, op) - return sb -} - -func (sb *ScriptTxBuilder) CreateVote(voterType, voterHash, govActionTxHash string, govActionIndex int, vote, redeemerCborHex, anchorURL, anchorDataHash string) *ScriptTxBuilder { - op := map[string]interface{}{ - "type": "create_vote", "voter_type": voterType, "voter_hash": voterHash, - "gov_action_tx_hash": govActionTxHash, "gov_action_index": govActionIndex, "vote": vote, - "redeemer_cbor_hex": redeemerCborHex, - } - if anchorURL != "" { - op["anchor_url"] = anchorURL - } - if anchorDataHash != "" { - op["anchor_data_hash"] = anchorDataHash - } - sb.operations = append(sb.operations, op) - return sb -} - -func (sb *ScriptTxBuilder) CreateProposal(govActionType, returnAddress, anchorURL, anchorDataHash, redeemerCborHex string, withdrawals []map[string]string) *ScriptTxBuilder { - op := map[string]interface{}{ - "type": "create_proposal", "gov_action_type": govActionType, - "return_address": returnAddress, "anchor_url": anchorURL, "anchor_data_hash": anchorDataHash, - "redeemer_cbor_hex": redeemerCborHex, - } - if len(withdrawals) > 0 { - op["withdrawals"] = withdrawals - } - sb.operations = append(sb.operations, op) - return sb -} - -// Treasury - -func (sb *ScriptTxBuilder) DonateToTreasury(treasuryValue, donationAmount, redeemerCborHex string) *ScriptTxBuilder { - sb.operations = append(sb.operations, map[string]interface{}{ - "type": "donate_to_treasury", "treasury_value": treasuryValue, - "donation_amount": donationAmount, "redeemer_cbor_hex": redeemerCborHex, - }) - return sb -} - -// Config - -func (sb *ScriptTxBuilder) From(address string) *ScriptTxBuilder { - sb.from = address - return sb -} - -func (sb *ScriptTxBuilder) ChangeAddress(address string) *ScriptTxBuilder { - sb.changeAddress = address - return sb -} - -func (sb *ScriptTxBuilder) ChangeDatum(datumCborHex string) *ScriptTxBuilder { - sb.changeDatumCbor = datumCborHex - return sb -} - -func (sb *ScriptTxBuilder) ChangeDatumHash(hash string) *ScriptTxBuilder { - sb.changeDatumHash = hash - return sb -} - -func (sb *ScriptTxBuilder) FeePayer(address string) *ScriptTxBuilder { - sb.feePayer = address - return sb -} - -func (sb *ScriptTxBuilder) WithUtxos(utxos interface{}) *ScriptTxBuilder { - sb.utxos = utxos - return sb -} - -func (sb *ScriptTxBuilder) WithProtocolParams(params interface{}) *ScriptTxBuilder { - sb.protocolParams = params - return sb -} - -func (sb *ScriptTxBuilder) ValidFrom(slot int64) *ScriptTxBuilder { - if sb.validity == nil { - sb.validity = make(map[string]interface{}) - } - sb.validity["valid_from"] = slot - return sb -} - -func (sb *ScriptTxBuilder) ValidTo(slot int64) *ScriptTxBuilder { - if sb.validity == nil { - sb.validity = make(map[string]interface{}) - } - sb.validity["valid_to"] = slot - return sb -} - -func (sb *ScriptTxBuilder) MergeOutputs(merge bool) *ScriptTxBuilder { - sb.mergeOutputs = &merge - return sb -} - -func (sb *ScriptTxBuilder) SignerCount(count int) *ScriptTxBuilder { - sb.signerCount = count - return sb -} - -func (sb *ScriptTxBuilder) buildSpec(providerConfig *ProviderConfig) map[string]interface{} { - spec := map[string]interface{}{ - "tx_type": "script_tx", - "operations": sb.operations, - "from": sb.from, - "signer_count": sb.signerCount, - } - if providerConfig != nil { - spec["provider"] = providerConfig - } else { - spec["utxos"] = sb.utxos - } - if sb.protocolParams != nil { - spec["protocol_params"] = sb.protocolParams - } - if sb.changeAddress != "" { - spec["change_address"] = sb.changeAddress - } - if sb.feePayer != "" { - spec["fee_payer"] = sb.feePayer - } - if len(sb.validity) > 0 { - spec["validity"] = sb.validity - } - if sb.mergeOutputs != nil { - spec["merge_outputs"] = *sb.mergeOutputs - } - if sb.changeDatumCbor != "" { - spec["change_datum_cbor_hex"] = sb.changeDatumCbor - } - if sb.changeDatumHash != "" { - spec["change_datum_hash"] = sb.changeDatumHash - } - return spec -} - -// Build builds the script transaction. Returns TxResult with tx_cbor, tx_hash, fee. -func (sb *ScriptTxBuilder) Build() (*TxResult, error) { - return sb.doBuild(nil) -} - -// BuildWithProvider builds with a Java-side provider config for lazy UTXO fetching. -func (sb *ScriptTxBuilder) BuildWithProvider(config ProviderConfig) (*TxResult, error) { - return sb.doBuild(&config) -} - -func (sb *ScriptTxBuilder) doBuild(providerConfig *ProviderConfig) (*TxResult, error) { - spec := sb.buildSpec(providerConfig) - specJSON, err := json.Marshal(spec) - if err != nil { - return nil, fmt.Errorf("failed to marshal spec: %w", err) - } - - cs := cstr(string(specJSON)) - defer C.free(unsafe.Pointer(cs)) - - rc := C.ccl_quicktx_build(sb.bridge.thread, cs) - result, err := sb.bridge.check(rc) - if err != nil { - return nil, err - } - - var txResult TxResult - if err := json.Unmarshal([]byte(result), &txResult); err != nil { - return nil, fmt.Errorf("failed to parse tx result: %w", err) - } - return &txResult, nil -} - -// --- ScriptTx (for Compose) --- - -// ScriptTx is a lightweight operation collector for script transactions in Compose. -type ScriptTx struct { - operations []map[string]interface{} - from string - changeAddress string - changeDatumCbor string - changeDatumHash string -} - -func (st *ScriptTx) PayToAddress(address string, amounts ...Amount) *ScriptTx { - amountList := make([]Amount, len(amounts)) - copy(amountList, amounts) - st.operations = append(st.operations, map[string]interface{}{ - "type": "pay_to_address", - "address": address, - "amounts": amountList, - }) - return st -} - -// PayToAddressWithScriptRef is like PayToAddress but attaches a reference script to the output. -func (st *ScriptTx) PayToAddressWithScriptRef(address string, amounts []Amount, scriptRef ScriptRefOption) *ScriptTx { - op := map[string]interface{}{ - "type": "pay_to_address", - "address": address, - "amounts": amounts, - } - if scriptRef.ScriptRefCborHex != "" { - op["script_ref_cbor_hex"] = scriptRef.ScriptRefCborHex - } - if scriptRef.ScriptRefType != "" { - op["script_ref_type"] = scriptRef.ScriptRefType - } - st.operations = append(st.operations, op) - return st -} - -func (st *ScriptTx) PayToContract(address string, amounts []Amount, datumCborHex, datumHash string, scriptRef ...ScriptRefOption) *ScriptTx { - op := map[string]interface{}{ - "type": "pay_to_contract", - "address": address, - "amounts": amounts, - } - if datumCborHex != "" { - op["datum_cbor_hex"] = datumCborHex - } - if datumHash != "" { - op["datum_hash"] = datumHash - } - if len(scriptRef) > 0 { - if scriptRef[0].ScriptRefCborHex != "" { - op["script_ref_cbor_hex"] = scriptRef[0].ScriptRefCborHex - } - if scriptRef[0].ScriptRefType != "" { - op["script_ref_type"] = scriptRef[0].ScriptRefType - } - } - st.operations = append(st.operations, op) - return st -} - -func (st *ScriptTx) AttachMetadata(label int, metadata interface{}) *ScriptTx { - st.operations = append(st.operations, map[string]interface{}{ - "type": "attach_metadata", - "label": label, - "metadata": metadata, - }) - return st -} - -func (st *ScriptTx) CollectFrom(utxos []map[string]interface{}) *ScriptTx { - st.operations = append(st.operations, map[string]interface{}{ - "type": "collect_from", - "collect_utxos": utxos, - }) - return st -} - -func (st *ScriptTx) CollectFromScript(utxos []map[string]interface{}, redeemerCborHex, datumCborHex string) *ScriptTx { - op := map[string]interface{}{ - "type": "collect_from", - "collect_utxos": utxos, - "redeemer_cbor_hex": redeemerCborHex, - } - if datumCborHex != "" { - op["datum_cbor_hex"] = datumCborHex - } - st.operations = append(st.operations, op) - return st -} - -func (st *ScriptTx) ReadFrom(referenceInputs []ReferenceInput) *ScriptTx { - st.operations = append(st.operations, map[string]interface{}{ - "type": "read_from", - "reference_inputs": referenceInputs, - }) - return st -} - -func (st *ScriptTx) MintPlutusAssets(scriptCborHex, scriptType string, assets []MintAsset, redeemerCborHex, receiver, outputDatumCborHex string) *ScriptTx { - op := map[string]interface{}{ - "type": "mint_plutus_assets", - "script_cbor_hex": scriptCborHex, - "script_type": scriptType, - "assets": assets, - "redeemer_cbor_hex": redeemerCborHex, - } - if receiver != "" { - op["receiver"] = receiver - } - if outputDatumCborHex != "" { - op["output_datum_cbor_hex"] = outputDatumCborHex - } - st.operations = append(st.operations, op) - return st -} - -func (st *ScriptTx) AttachSpendingValidator(scriptCborHex, scriptType string) *ScriptTx { - st.operations = append(st.operations, map[string]interface{}{ - "type": "attach_spending_validator", "script_cbor_hex": scriptCborHex, "script_type": scriptType, - }) - return st -} - -func (st *ScriptTx) AttachCertificateValidator(scriptCborHex, scriptType string) *ScriptTx { - st.operations = append(st.operations, map[string]interface{}{ - "type": "attach_certificate_validator", "script_cbor_hex": scriptCborHex, "script_type": scriptType, - }) - return st -} - -func (st *ScriptTx) AttachRewardValidator(scriptCborHex, scriptType string) *ScriptTx { - st.operations = append(st.operations, map[string]interface{}{ - "type": "attach_reward_validator", "script_cbor_hex": scriptCborHex, "script_type": scriptType, - }) - return st -} - -func (st *ScriptTx) AttachProposingValidator(scriptCborHex, scriptType string) *ScriptTx { - st.operations = append(st.operations, map[string]interface{}{ - "type": "attach_proposing_validator", "script_cbor_hex": scriptCborHex, "script_type": scriptType, - }) - return st -} - -func (st *ScriptTx) AttachVotingValidator(scriptCborHex, scriptType string) *ScriptTx { - st.operations = append(st.operations, map[string]interface{}{ - "type": "attach_voting_validator", "script_cbor_hex": scriptCborHex, "script_type": scriptType, - }) - return st -} - -// Redeemer-enhanced staking/governance - -func (st *ScriptTx) DeregisterStakeAddress(address, redeemerCborHex, refundAddress string) *ScriptTx { - op := map[string]interface{}{ - "type": "deregister_stake_address", "address": address, "redeemer_cbor_hex": redeemerCborHex, - } - if refundAddress != "" { - op["refund_address"] = refundAddress - } - st.operations = append(st.operations, op) - return st -} - -func (st *ScriptTx) DelegateTo(address, poolID, redeemerCborHex string) *ScriptTx { - st.operations = append(st.operations, map[string]interface{}{ - "type": "delegate_to", "address": address, "pool_id": poolID, "redeemer_cbor_hex": redeemerCborHex, - }) - return st -} - -func (st *ScriptTx) Withdraw(rewardAddress, amount, redeemerCborHex, receiver string) *ScriptTx { - op := map[string]interface{}{ - "type": "withdraw", "reward_address": rewardAddress, "amount": amount, "redeemer_cbor_hex": redeemerCborHex, - } - if receiver != "" { - op["receiver"] = receiver - } - st.operations = append(st.operations, op) - return st -} - -func (st *ScriptTx) RegisterDRep(credHash, credType, redeemerCborHex, anchorURL, anchorDataHash string) *ScriptTx { - op := map[string]interface{}{ - "type": "register_drep", "credential_hash": credHash, "credential_type": credType, - "redeemer_cbor_hex": redeemerCborHex, - } - if anchorURL != "" { - op["anchor_url"] = anchorURL - } - if anchorDataHash != "" { - op["anchor_data_hash"] = anchorDataHash - } - st.operations = append(st.operations, op) - return st -} - -func (st *ScriptTx) UnregisterDRep(credHash, credType, redeemerCborHex string, opts ...UnregisterDRepOption) *ScriptTx { - op := map[string]interface{}{ - "type": "unregister_drep", "credential_hash": credHash, "credential_type": credType, - "redeemer_cbor_hex": redeemerCborHex, - } - if len(opts) > 0 { - if opts[0].RefundAddress != "" { - op["refund_address"] = opts[0].RefundAddress - } - if opts[0].RefundAmount != "" { - op["refund_amount"] = opts[0].RefundAmount - } - } - st.operations = append(st.operations, op) - return st -} - -func (st *ScriptTx) UpdateDRep(credHash, credType, redeemerCborHex, anchorURL, anchorDataHash string) *ScriptTx { - op := map[string]interface{}{ - "type": "update_drep", "credential_hash": credHash, "credential_type": credType, - "redeemer_cbor_hex": redeemerCborHex, - } - if anchorURL != "" { - op["anchor_url"] = anchorURL - } - if anchorDataHash != "" { - op["anchor_data_hash"] = anchorDataHash - } - st.operations = append(st.operations, op) - return st -} - -func (st *ScriptTx) DelegateVotingPowerTo(address, drepType, drepHash, redeemerCborHex string) *ScriptTx { - op := map[string]interface{}{ - "type": "delegate_voting_power_to", "address": address, "drep_type": drepType, - "redeemer_cbor_hex": redeemerCborHex, - } - if drepHash != "" { - op["drep_hash"] = drepHash - } - st.operations = append(st.operations, op) - return st -} - -func (st *ScriptTx) CreateVote(voterType, voterHash, govActionTxHash string, govActionIndex int, vote, redeemerCborHex, anchorURL, anchorDataHash string) *ScriptTx { - op := map[string]interface{}{ - "type": "create_vote", "voter_type": voterType, "voter_hash": voterHash, - "gov_action_tx_hash": govActionTxHash, "gov_action_index": govActionIndex, "vote": vote, - "redeemer_cbor_hex": redeemerCborHex, - } - if anchorURL != "" { - op["anchor_url"] = anchorURL - } - if anchorDataHash != "" { - op["anchor_data_hash"] = anchorDataHash - } - st.operations = append(st.operations, op) - return st -} - -func (st *ScriptTx) CreateProposal(govActionType, returnAddress, anchorURL, anchorDataHash, redeemerCborHex string, withdrawals []map[string]string) *ScriptTx { - op := map[string]interface{}{ - "type": "create_proposal", "gov_action_type": govActionType, - "return_address": returnAddress, "anchor_url": anchorURL, "anchor_data_hash": anchorDataHash, - "redeemer_cbor_hex": redeemerCborHex, - } - if len(withdrawals) > 0 { - op["withdrawals"] = withdrawals - } - st.operations = append(st.operations, op) - return st -} - -// Treasury - -func (st *ScriptTx) DonateToTreasury(treasuryValue, donationAmount, redeemerCborHex string) *ScriptTx { - st.operations = append(st.operations, map[string]interface{}{ - "type": "donate_to_treasury", "treasury_value": treasuryValue, - "donation_amount": donationAmount, "redeemer_cbor_hex": redeemerCborHex, - }) - return st -} - -func (st *ScriptTx) From(address string) *ScriptTx { - st.from = address - return st -} - -func (st *ScriptTx) ChangeAddress(address string) *ScriptTx { - st.changeAddress = address - return st -} - -func (st *ScriptTx) ChangeDatum(datumCborHex string) *ScriptTx { - st.changeDatumCbor = datumCborHex - return st -} - -func (st *ScriptTx) ChangeDatumHash(hash string) *ScriptTx { - st.changeDatumHash = hash - return st -} - -func (st *ScriptTx) ToSpec() map[string]interface{} { - spec := map[string]interface{}{ - "tx_type": "script_tx", - "from": st.from, - "operations": st.operations, - } - if st.changeAddress != "" { - spec["change_address"] = st.changeAddress - } - if st.changeDatumCbor != "" { - spec["change_datum_cbor_hex"] = st.changeDatumCbor - } - if st.changeDatumHash != "" { - spec["change_datum_hash"] = st.changeDatumHash - } - return spec -} diff --git a/wrappers/go/ccl/ccl_test.go b/wrappers/go/ccl/ccl_test.go index 3d76415..18dee72 100644 --- a/wrappers/go/ccl/ccl_test.go +++ b/wrappers/go/ccl/ccl_test.go @@ -469,51 +469,51 @@ var fakeTxHash = strings.Repeat("a", 64) func testProtocolParams() map[string]interface{} { return map[string]interface{}{ - "min_fee_a": 44, - "min_fee_b": 155381, - "max_block_size": 65536, - "max_tx_size": 16384, - "max_block_header_size": 1100, - "key_deposit": "2000000", - "pool_deposit": "500000000", - "e_max": 18, - "n_opt": 500, - "a0": 0.3, - "rho": 0.003, - "tau": 0.2, - "min_utxo": "34482", - "min_pool_cost": "340000000", - "price_mem": 0.0577, - "price_step": 0.0000721, - "max_tx_ex_mem": "10000000", - "max_tx_ex_steps": "10000000000", - "max_block_ex_mem": "50000000", - "max_block_ex_steps": "40000000000", - "max_val_size": "5000", - "collateral_percent": 150, - "max_collateral_inputs": 3, - "coins_per_utxo_size": "4310", - "coins_per_utxo_word": "34482", - "pvt_motion_no_confidence": 0.51, - "pvt_committee_normal": 0.51, - "pvt_committee_no_confidence": 0.51, - "pvt_hard_fork_initiation": 0.51, - "dvt_motion_no_confidence": 0.51, - "dvt_committee_normal": 0.51, - "dvt_committee_no_confidence": 0.51, - "dvt_update_to_constitution": 0.51, - "dvt_hard_fork_initiation": 0.51, - "dvt_ppnetwork_group": 0.51, - "dvt_ppeconomic_group": 0.51, - "dvt_pptechnical_group": 0.51, - "dvt_ppgov_group": 0.51, - "dvt_treasury_withdrawal": 0.51, - "committee_min_size": 0, - "committee_max_term_length": 200, - "gov_action_lifetime": 10, - "gov_action_deposit": 1000000000, - "drep_deposit": 2000000, - "drep_activity": 20, + "min_fee_a": 44, + "min_fee_b": 155381, + "max_block_size": 65536, + "max_tx_size": 16384, + "max_block_header_size": 1100, + "key_deposit": "2000000", + "pool_deposit": "500000000", + "e_max": 18, + "n_opt": 500, + "a0": 0.3, + "rho": 0.003, + "tau": 0.2, + "min_utxo": "34482", + "min_pool_cost": "340000000", + "price_mem": 0.0577, + "price_step": 0.0000721, + "max_tx_ex_mem": "10000000", + "max_tx_ex_steps": "10000000000", + "max_block_ex_mem": "50000000", + "max_block_ex_steps": "40000000000", + "max_val_size": "5000", + "collateral_percent": 150, + "max_collateral_inputs": 3, + "coins_per_utxo_size": "4310", + "coins_per_utxo_word": "34482", + "pvt_motion_no_confidence": 0.51, + "pvt_committee_normal": 0.51, + "pvt_committee_no_confidence": 0.51, + "pvt_hard_fork_initiation": 0.51, + "dvt_motion_no_confidence": 0.51, + "dvt_committee_normal": 0.51, + "dvt_committee_no_confidence": 0.51, + "dvt_update_to_constitution": 0.51, + "dvt_hard_fork_initiation": 0.51, + "dvt_ppnetwork_group": 0.51, + "dvt_ppeconomic_group": 0.51, + "dvt_pptechnical_group": 0.51, + "dvt_ppgov_group": 0.51, + "dvt_treasury_withdrawal": 0.51, + "committee_min_size": 0, + "committee_max_term_length": 200, + "gov_action_lifetime": 10, + "gov_action_deposit": 1000000000, + "drep_deposit": 2000000, + "drep_activity": 20, "min_fee_ref_script_cost_per_byte": 44, } } @@ -546,644 +546,98 @@ func assertTxResult(t *testing.T, result *TxResult) { } } -func TestQuickTxSimpleADAPayment(t *testing.T) { - sender, err := bridge.Account.Create(Testnet) - if err != nil { - t.Fatalf("create sender: %v", err) - } - receiver, err := bridge.Account.Create(Testnet) - if err != nil { - t.Fatalf("create receiver: %v", err) - } - - result, err := bridge.QuickTx.NewTx(). - PayToAddress(receiver.BaseAddress, Ada(5)). - From(sender.BaseAddress). - WithUtxos(makeUtxos(sender.BaseAddress, 100_000_000)). - WithProtocolParams(testProtocolParams()). - Build() - if err != nil { - t.Fatalf("Build() failed: %v", err) - } - assertTxResult(t, result) -} - -func TestQuickTxMultipleReceivers(t *testing.T) { - sender, err := bridge.Account.Create(Testnet) - if err != nil { - t.Fatalf("create sender: %v", err) - } - receiver1, err := bridge.Account.Create(Testnet) - if err != nil { - t.Fatalf("create receiver1: %v", err) - } - receiver2, err := bridge.Account.Create(Testnet) - if err != nil { - t.Fatalf("create receiver2: %v", err) - } - - result, err := bridge.QuickTx.NewTx(). - PayToAddress(receiver1.BaseAddress, Ada(5)). - PayToAddress(receiver2.BaseAddress, Ada(3)). - From(sender.BaseAddress). - WithUtxos(makeUtxos(sender.BaseAddress, 100_000_000)). - WithProtocolParams(testProtocolParams()). - Build() - if err != nil { - t.Fatalf("Build() failed: %v", err) - } - assertTxResult(t, result) -} - -func TestQuickTxPayToContract(t *testing.T) { - sender, err := bridge.Account.Create(Testnet) - if err != nil { - t.Fatalf("create sender: %v", err) - } - receiver, err := bridge.Account.Create(Testnet) - if err != nil { - t.Fatalf("create receiver: %v", err) - } - - result, err := bridge.QuickTx.NewTx(). - PayToContract(receiver.BaseAddress, []Amount{Ada(5)}, "182a", ""). - From(sender.BaseAddress). - WithUtxos(makeUtxos(sender.BaseAddress, 100_000_000)). - WithProtocolParams(testProtocolParams()). - Build() - if err != nil { - t.Fatalf("Build() failed: %v", err) - } - assertTxResult(t, result) -} - -func TestQuickTxMintAssets(t *testing.T) { - sender, err := bridge.Account.Create(Testnet) - if err != nil { - t.Fatalf("create sender: %v", err) - } - - addrInfo, err := bridge.Address.Info(sender.BaseAddress) - if err != nil { - t.Fatalf("address info: %v", err) - } - - scriptJSON := fmt.Sprintf(`{"type":"sig","keyHash":"%s"}`, addrInfo.PaymentCredentialHash) - assets := []MintAsset{{Name: "TestToken", Quantity: "1000"}} - - result, err := bridge.QuickTx.NewTx(). - MintAssets(scriptJSON, assets, sender.BaseAddress). - From(sender.BaseAddress). - WithUtxos(makeUtxos(sender.BaseAddress, 100_000_000)). - WithProtocolParams(testProtocolParams()). - Build() - if err != nil { - t.Fatalf("Build() failed: %v", err) - } - assertTxResult(t, result) -} - -func TestQuickTxAttachMetadata(t *testing.T) { - sender, err := bridge.Account.Create(Testnet) - if err != nil { - t.Fatalf("create sender: %v", err) - } - receiver, err := bridge.Account.Create(Testnet) - if err != nil { - t.Fatalf("create receiver: %v", err) - } - - result, err := bridge.QuickTx.NewTx(). - PayToAddress(receiver.BaseAddress, Ada(2)). - AttachMetadata(674, map[string]interface{}{"msg": []string{"Hello from Go"}}). - From(sender.BaseAddress). - WithUtxos(makeUtxos(sender.BaseAddress, 100_000_000)). - WithProtocolParams(testProtocolParams()). - Build() - if err != nil { - t.Fatalf("Build() failed: %v", err) - } - assertTxResult(t, result) -} - -func TestQuickTxCollectFrom(t *testing.T) { - sender, err := bridge.Account.Create(Testnet) - if err != nil { - t.Fatalf("create sender: %v", err) - } - receiver, err := bridge.Account.Create(Testnet) - if err != nil { - t.Fatalf("create receiver: %v", err) - } - - utxos := makeUtxos(sender.BaseAddress, 100_000_000) - result, err := bridge.QuickTx.NewTx(). - CollectFrom(utxos). - PayToAddress(receiver.BaseAddress, Ada(2)). - From(sender.BaseAddress). - WithUtxos(utxos). - WithProtocolParams(testProtocolParams()). - Build() - if err != nil { - t.Fatalf("Build() failed: %v", err) - } - assertTxResult(t, result) -} - -func TestQuickTxRegisterStakeAddress(t *testing.T) { - sender, err := bridge.Account.Create(Testnet) - if err != nil { - t.Fatalf("create sender: %v", err) - } - - result, err := bridge.QuickTx.NewTx(). - RegisterStakeAddress(sender.BaseAddress). - From(sender.BaseAddress). - WithUtxos(makeUtxos(sender.BaseAddress, 100_000_000)). - WithProtocolParams(testProtocolParams()). - Build() - if err != nil { - t.Fatalf("Build() failed: %v", err) - } - assertTxResult(t, result) -} - -func TestQuickTxDeregisterStakeAddress(t *testing.T) { - sender, err := bridge.Account.Create(Testnet) - if err != nil { - t.Fatalf("create sender: %v", err) - } - - result, err := bridge.QuickTx.NewTx(). - DeregisterStakeAddress(sender.BaseAddress). - From(sender.BaseAddress). - WithUtxos(makeUtxos(sender.BaseAddress, 100_000_000)). - WithProtocolParams(testProtocolParams()). - Build() - if err != nil { - t.Fatalf("Build() failed: %v", err) - } - assertTxResult(t, result) -} - -func TestQuickTxDelegateTo(t *testing.T) { - sender, err := bridge.Account.Create(Testnet) - if err != nil { - t.Fatalf("create sender: %v", err) - } - - poolID := "pool1pu5jlj4q9w9jlxeu370a3c9myx47md5j5m2str0naunn2q3lkdy" - result, err := bridge.QuickTx.NewTx(). - DelegateTo(sender.BaseAddress, poolID). - From(sender.BaseAddress). - WithUtxos(makeUtxos(sender.BaseAddress, 100_000_000)). - WithProtocolParams(testProtocolParams()). - Build() - if err != nil { - t.Fatalf("Build() failed: %v", err) - } - assertTxResult(t, result) -} - -func TestQuickTxWithdraw(t *testing.T) { - sender, err := bridge.Account.Create(Testnet) - if err != nil { - t.Fatalf("create sender: %v", err) - } - restored, err := bridge.Account.FromMnemonic(sender.Mnemonic, Testnet, 0, 0) - if err != nil { - t.Fatalf("restore: %v", err) - } - - result, err := bridge.QuickTx.NewTx(). - Withdraw(restored.StakeAddress, 5000000). - From(sender.BaseAddress). - WithUtxos(makeUtxos(sender.BaseAddress, 100_000_000)). - WithProtocolParams(testProtocolParams()). - Build() - if err != nil { - t.Fatalf("Build() failed: %v", err) - } - assertTxResult(t, result) -} - -func TestQuickTxRegisterDRep(t *testing.T) { - sender, err := bridge.Account.Create(Testnet) - if err != nil { - t.Fatalf("create sender: %v", err) - } - - credHash := strings.Repeat("ab", 28) - result, err := bridge.QuickTx.NewTx(). - RegisterDRep(credHash, "key"). - From(sender.BaseAddress). - WithUtxos(makeUtxos(sender.BaseAddress, 100_000_000)). - WithProtocolParams(testProtocolParams()). - Build() - if err != nil { - t.Fatalf("Build() failed: %v", err) - } - assertTxResult(t, result) -} - -func TestQuickTxUnregisterDRep(t *testing.T) { - sender, err := bridge.Account.Create(Testnet) - if err != nil { - t.Fatalf("create sender: %v", err) - } - - credHash := strings.Repeat("ab", 28) - result, err := bridge.QuickTx.NewTx(). - UnregisterDRep(credHash, "key"). - From(sender.BaseAddress). - WithUtxos(makeUtxos(sender.BaseAddress, 100_000_000)). - WithProtocolParams(testProtocolParams()). - Build() - // The UnregisterDRep now takes optional UnregisterDRepOption - calling without opts should work - if err != nil { - t.Fatalf("Build() failed: %v", err) - } - assertTxResult(t, result) -} - -func TestQuickTxUpdateDRep(t *testing.T) { - sender, err := bridge.Account.Create(Testnet) - if err != nil { - t.Fatalf("create sender: %v", err) - } - - credHash := strings.Repeat("ab", 28) - dataHash := strings.Repeat("cd", 32) - result, err := bridge.QuickTx.NewTx(). - UpdateDRep(credHash, "key", AnchorOption{ - AnchorURL: "https://example.com/drep-v2.json", - AnchorDataHash: dataHash, - }). - From(sender.BaseAddress). - WithUtxos(makeUtxos(sender.BaseAddress, 100_000_000)). - WithProtocolParams(testProtocolParams()). - Build() - if err != nil { - t.Fatalf("Build() failed: %v", err) - } - assertTxResult(t, result) +// quickTxYaml is a single-payment TxPlan YAML document. +func quickTxYaml(from, to, quantity string) string { + return fmt.Sprintf(` +version: 1.0 +transaction: + - tx: + from: %s + intents: + - type: payment + address: %s + amounts: + - unit: lovelace + quantity: "%s" +`, from, to, quantity) } -func TestQuickTxDelegateVotingPowerTo(t *testing.T) { - sender, err := bridge.Account.Create(Testnet) - if err != nil { - t.Fatalf("create sender: %v", err) - } +func TestQuickTxSimplePayment(t *testing.T) { + sender, _ := bridge.Account.Create(Testnet) + receiver, _ := bridge.Account.Create(Testnet) - drepHash := strings.Repeat("ab", 28) - result, err := bridge.QuickTx.NewTx(). - DelegateVotingPowerTo(sender.BaseAddress, "key_hash", drepHash). - From(sender.BaseAddress). - WithUtxos(makeUtxos(sender.BaseAddress, 100_000_000)). - WithProtocolParams(testProtocolParams()). - Build() + yaml := quickTxYaml(sender.BaseAddress, receiver.BaseAddress, "5000000") + result, err := bridge.QuickTx.Build(yaml, makeUtxos(sender.BaseAddress, 100_000_000), testProtocolParams()) if err != nil { t.Fatalf("Build() failed: %v", err) } assertTxResult(t, result) } -func TestQuickTxCreateVote(t *testing.T) { - sender, err := bridge.Account.Create(Testnet) - if err != nil { - t.Fatalf("create sender: %v", err) - } - - voterHash := strings.Repeat("ab", 28) - govTxHash := strings.Repeat("cd", 32) - result, err := bridge.QuickTx.NewTx(). - CreateVote("drep_key_hash", voterHash, govTxHash, 0, "yes"). - From(sender.BaseAddress). - WithUtxos(makeUtxos(sender.BaseAddress, 100_000_000)). - WithProtocolParams(testProtocolParams()). - Build() +func TestQuickTxMultiplePayments(t *testing.T) { + sender, _ := bridge.Account.Create(Testnet) + r1, _ := bridge.Account.Create(Testnet) + r2, _ := bridge.Account.Create(Testnet) + + yaml := fmt.Sprintf(` +version: 1.0 +transaction: + - tx: + from: %s + intents: + - type: payment + address: %s + amounts: + - unit: lovelace + quantity: "5000000" + - type: payment + address: %s + amounts: + - unit: lovelace + quantity: "3000000" +`, sender.BaseAddress, r1.BaseAddress, r2.BaseAddress) + + result, err := bridge.QuickTx.Build(yaml, makeUtxos(sender.BaseAddress, 100_000_000), testProtocolParams()) if err != nil { t.Fatalf("Build() failed: %v", err) } assertTxResult(t, result) } -func TestQuickTxCreateInfoProposal(t *testing.T) { - sender, err := bridge.Account.Create(Testnet) - if err != nil { - t.Fatalf("create sender: %v", err) - } - restored, err := bridge.Account.FromMnemonic(sender.Mnemonic, Testnet, 0, 0) - if err != nil { - t.Fatalf("restore: %v", err) - } - - anchorDataHash := strings.Repeat("ab", 32) - result, err := bridge.QuickTx.NewTx(). - CreateProposal("info_action", restored.StakeAddress, - "https://example.com/proposal.json", anchorDataHash). - From(sender.BaseAddress). - WithUtxos(makeUtxos(sender.BaseAddress, 2_000_000_000)). - WithProtocolParams(testProtocolParams()). - Build() - if err != nil { - t.Fatalf("Build() failed: %v", err) - } - assertTxResult(t, result) -} - -func TestQuickTxCreateTreasuryWithdrawalsProposal(t *testing.T) { - sender, err := bridge.Account.Create(Testnet) - if err != nil { - t.Fatalf("create sender: %v", err) - } - restored, err := bridge.Account.FromMnemonic(sender.Mnemonic, Testnet, 0, 0) - if err != nil { - t.Fatalf("restore: %v", err) - } - - anchorDataHash := strings.Repeat("ab", 32) - withdrawals := []ProposalWithdrawal{ - {RewardAddress: restored.StakeAddress, Amount: "1000000"}, - } - result, err := bridge.QuickTx.NewTx(). - CreateProposal("treasury_withdrawals", restored.StakeAddress, - "https://example.com/proposal.json", anchorDataHash, withdrawals). - From(sender.BaseAddress). - WithUtxos(makeUtxos(sender.BaseAddress, 2_000_000_000)). - WithProtocolParams(testProtocolParams()). - Build() - if err != nil { - t.Fatalf("Build() failed: %v", err) - } - assertTxResult(t, result) -} - -func TestQuickTxCompose(t *testing.T) { - sender1, err := bridge.Account.Create(Testnet) - if err != nil { - t.Fatalf("create sender1: %v", err) - } - sender2, err := bridge.Account.Create(Testnet) - if err != nil { - t.Fatalf("create sender2: %v", err) - } - receiver1, err := bridge.Account.Create(Testnet) - if err != nil { - t.Fatalf("create receiver1: %v", err) - } - receiver2, err := bridge.Account.Create(Testnet) - if err != nil { - t.Fatalf("create receiver2: %v", err) - } - - tx1 := bridge.QuickTx.Tx(). - PayToAddress(receiver1.BaseAddress, Ada(5)). - From(sender1.BaseAddress) - - tx2 := bridge.QuickTx.Tx(). - PayToAddress(receiver2.BaseAddress, Ada(3)). - From(sender2.BaseAddress) - - utxos := []map[string]interface{}{ - { - "tx_hash": fakeTxHash, - "output_index": 0, - "address": sender1.BaseAddress, - "amount": []map[string]interface{}{ - {"unit": "lovelace", "quantity": "100000000"}, - }, - }, - { - "tx_hash": strings.Repeat("b", 64), - "output_index": 0, - "address": sender2.BaseAddress, - "amount": []map[string]interface{}{ - {"unit": "lovelace", "quantity": "100000000"}, - }, - }, - } - - result, err := bridge.QuickTx.Compose(tx1, tx2). - FeePayer(sender1.BaseAddress). - WithUtxos(utxos). - WithProtocolParams(testProtocolParams()). - SignerCount(2). - Build() +func TestQuickTxVariableSubstitution(t *testing.T) { + sender, _ := bridge.Account.Create(Testnet) + receiver, _ := bridge.Account.Create(Testnet) + + yaml := fmt.Sprintf(` +version: 1.0 +variables: + to: %s + amount: "4000000" +transaction: + - tx: + from: %s + intents: + - type: payment + address: ${to} + amounts: + - unit: lovelace + quantity: ${amount} +`, receiver.BaseAddress, sender.BaseAddress) + + result, err := bridge.QuickTx.Build(yaml, makeUtxos(sender.BaseAddress, 100_000_000), testProtocolParams()) if err != nil { t.Fatalf("Build() failed: %v", err) } assertTxResult(t, result) } -func TestQuickTxProviderConfig(t *testing.T) { - sender, err := bridge.Account.Create(Testnet) - if err != nil { - t.Fatalf("create sender: %v", err) - } - receiver, err := bridge.Account.Create(Testnet) - if err != nil { - t.Fatalf("create receiver: %v", err) - } - - // This test verifies the spec is built correctly with provider config. - // It will fail at the FFI level because there's no actual provider, - // but we verify the builder doesn't error on spec construction. - _, err = bridge.QuickTx.NewTx(). - PayToAddress(receiver.BaseAddress, Ada(5)). - From(sender.BaseAddress). - WithProtocolParams(testProtocolParams()). - BuildWithProvider(ProviderConfig{ - Name: "yaci_devkit", - URL: "http://localhost:3000", - }) - // Provider config will attempt Java-side HTTP; expect an error - if err == nil { - t.Log("BuildWithProvider succeeded (provider was reachable)") - } -} - func TestQuickTxInsufficientFunds(t *testing.T) { - sender, err := bridge.Account.Create(Testnet) - if err != nil { - t.Fatalf("create sender: %v", err) - } - receiver, err := bridge.Account.Create(Testnet) - if err != nil { - t.Fatalf("create receiver: %v", err) - } + sender, _ := bridge.Account.Create(Testnet) + receiver, _ := bridge.Account.Create(Testnet) - _, err = bridge.QuickTx.NewTx(). - PayToAddress(receiver.BaseAddress, Ada(200)). - From(sender.BaseAddress). - WithUtxos(makeUtxos(sender.BaseAddress, 1_000_000)). - WithProtocolParams(testProtocolParams()). - Build() + yaml := quickTxYaml(sender.BaseAddress, receiver.BaseAddress, "200000000") + _, err := bridge.QuickTx.Build(yaml, makeUtxos(sender.BaseAddress, 1_000_000), testProtocolParams()) if err == nil { - t.Fatal("expected error for insufficient funds") - } - cclErr, ok := err.(*CclError) - if !ok { - t.Fatalf("expected CclError, got %T: %v", err, err) + t.Fatal("expected insufficient funds error") } - if cclErr.Code >= 0 { - t.Errorf("expected negative error code, got %d", cclErr.Code) - } -} - -func TestQuickTxRegisterPool(t *testing.T) { - sender, err := bridge.Account.Create(Testnet) - if err != nil { - t.Fatalf("create sender: %v", err) - } - - operatorHash := strings.Repeat("ab", 28) // 28-byte (56 hex chars) pool operator key hash - vrfKeyHash := strings.Repeat("cd", 32) // 32-byte (64 hex chars) VRF key hash - rewardAccountHex := "e0" + strings.Repeat("ab", 28) // testnet reward address (hex) - result, err := bridge.QuickTx.NewTx(). - RegisterPool(operatorHash, vrfKeyHash, "500000000", "340000000", "1", "5", - rewardAccountHex, []string{operatorHash}). - From(sender.BaseAddress). - WithUtxos(makeUtxos(sender.BaseAddress, 1_000_000_000)). - WithProtocolParams(testProtocolParams()). - Build() - if err != nil { - t.Fatalf("Build() failed: %v", err) - } - assertTxResult(t, result) -} - -func TestQuickTxUpdatePool(t *testing.T) { - sender, err := bridge.Account.Create(Testnet) - if err != nil { - t.Fatalf("create sender: %v", err) - } - - operatorHash := strings.Repeat("ab", 28) // 28-byte (56 hex chars) pool operator key hash - vrfKeyHash := strings.Repeat("cd", 32) // 32-byte (64 hex chars) VRF key hash - metadataHash := strings.Repeat("ef", 32) // 32-byte (64 hex chars) metadata hash - rewardAccountHex := "e0" + strings.Repeat("ab", 28) // testnet reward address (hex) - result, err := bridge.QuickTx.NewTx(). - UpdatePool(operatorHash, vrfKeyHash, "600000000", "340000000", "1", "10", - rewardAccountHex, []string{operatorHash}, PoolOptions{ - PoolMetadataURL: "https://example.com/pool.json", - PoolMetadataHash: metadataHash, - }). - From(sender.BaseAddress). - WithUtxos(makeUtxos(sender.BaseAddress, 1_000_000_000)). - WithProtocolParams(testProtocolParams()). - Build() - if err != nil { - t.Fatalf("Build() failed: %v", err) - } - assertTxResult(t, result) -} - -func TestQuickTxRetirePool(t *testing.T) { - sender, err := bridge.Account.Create(Testnet) - if err != nil { - t.Fatalf("create sender: %v", err) - } - - poolID := "pool1pu5jlj4q9w9jlxeu370a3c9myx47md5j5m2str0naunn2q3lkdy" - result, err := bridge.QuickTx.NewTx(). - RetirePool(poolID, 100). - From(sender.BaseAddress). - WithUtxos(makeUtxos(sender.BaseAddress, 100_000_000)). - WithProtocolParams(testProtocolParams()). - Build() - if err != nil { - t.Fatalf("Build() failed: %v", err) - } - assertTxResult(t, result) -} - -func TestQuickTxDonateToTreasury(t *testing.T) { - sender, err := bridge.Account.Create(Testnet) - if err != nil { - t.Fatalf("create sender: %v", err) - } - - result, err := bridge.QuickTx.NewTx(). - DonateToTreasury("5000000", "1000000"). - From(sender.BaseAddress). - WithUtxos(makeUtxos(sender.BaseAddress, 100_000_000)). - WithProtocolParams(testProtocolParams()). - Build() - if err != nil { - t.Fatalf("Build() failed: %v", err) - } - assertTxResult(t, result) -} - -func TestQuickTxAttachNativeScript(t *testing.T) { - sender, err := bridge.Account.Create(Testnet) - if err != nil { - t.Fatalf("create sender: %v", err) - } - - addrInfo, err := bridge.Address.Info(sender.BaseAddress) - if err != nil { - t.Fatalf("address info: %v", err) - } - - scriptJSON := fmt.Sprintf(`{"type":"sig","keyHash":"%s"}`, addrInfo.PaymentCredentialHash) - result, err := bridge.QuickTx.NewTx(). - PayToAddress(sender.BaseAddress, Ada(2)). - AttachNativeScript(scriptJSON). - From(sender.BaseAddress). - WithUtxos(makeUtxos(sender.BaseAddress, 100_000_000)). - WithProtocolParams(testProtocolParams()). - Build() - if err != nil { - t.Fatalf("Build() failed: %v", err) - } - assertTxResult(t, result) -} - -func TestQuickTxPayWithScriptRef(t *testing.T) { - sender, err := bridge.Account.Create(Testnet) - if err != nil { - t.Fatalf("create sender: %v", err) - } - receiver, err := bridge.Account.Create(Testnet) - if err != nil { - t.Fatalf("create receiver: %v", err) - } - - // Always-succeeds PlutusV3 script CBOR - scriptRefCbor := "46450101002499" - - result, err := bridge.QuickTx.NewTx(). - PayToAddressWithScriptRef(receiver.BaseAddress, []Amount{Ada(5)}, ScriptRefOption{ - ScriptRefCborHex: scriptRefCbor, - ScriptRefType: "plutus_v3", - }). - From(sender.BaseAddress). - WithUtxos(makeUtxos(sender.BaseAddress, 100_000_000)). - WithProtocolParams(testProtocolParams()). - Build() - if err != nil { - t.Fatalf("Build() failed: %v", err) - } - assertTxResult(t, result) -} - -func TestQuickTxUnregisterDRepWithRefundAmount(t *testing.T) { - sender, err := bridge.Account.Create(Testnet) - if err != nil { - t.Fatalf("create sender: %v", err) - } - - credHash := strings.Repeat("ab", 28) - result, err := bridge.QuickTx.NewTx(). - UnregisterDRep(credHash, "key", UnregisterDRepOption{RefundAmount: "2000000"}). - From(sender.BaseAddress). - WithUtxos(makeUtxos(sender.BaseAddress, 100_000_000)). - WithProtocolParams(testProtocolParams()). - Build() - if err != nil { - t.Fatalf("Build() failed: %v", err) - } - assertTxResult(t, result) } diff --git a/wrappers/go/ccl/intents_integration_test.go b/wrappers/go/ccl/intents_integration_test.go new file mode 100644 index 0000000..b3e185a --- /dev/null +++ b/wrappers/go/ccl/intents_integration_test.go @@ -0,0 +1,461 @@ +package ccl + +import ( + "fmt" + "os" + "strings" + "testing" +) + +// End-to-end submit tests: build each intent's TxPlan offline, sign it with the right key roles, +// submit it to a Yaci DevKit devnet, and assert the node accepted it (the tx is retrievable +// on-chain). This proves the bridge produces node-acceptable transactions — not just buildable CBOR. +// +// They use the fixed test account the fixtures are derived from (intentMnemonic / intentSender), +// funded fresh on the devnet per test for isolation. They skip when DevKit is not running, so they +// are exercised only by the CI "Integration Tests (DevKit)" job, not locally. + +func readIntentFixture(t *testing.T, rel string) string { + t.Helper() + b, err := os.ReadFile("../../../test-fixtures/quicktx-intents/" + rel) + if err != nil { + t.Fatalf("read fixture %s: %v", rel, err) + } + return string(b) +} + +// buildSignSubmit resets the devnet, funds the fixed account, builds the fixture with its real +// UTXOs, signs with the given key roles, submits, and verifies the tx landed on-chain. +func buildSignSubmit(t *testing.T, fixture string, execUnits []map[string]interface{}, keys ...string) string { + t.Helper() + devkitReset() + waitForBlock() + if err := devkitTopup(intentSender, 6000); err != nil { + t.Fatalf("topup: %v", err) + } + waitForBlock() + + utxos, err := devkitGetUtxos(intentSender) + if err != nil { + t.Fatalf("get utxos: %v", err) + } + return signSubmit(t, readIntentFixture(t, fixture), utxos, devnetPP(t), execUnits, keys...) +} + +// devnetPP fetches the devnet protocol parameters and fills in the Conway deposits DevKit returns as +// null (the node validates the actual values on submit). +func devnetPP(t *testing.T) map[string]interface{} { + t.Helper() + pp, err := devkitGetProtocolParams() + if err != nil { + t.Fatalf("get protocol params: %v", err) + } + pp["drep_deposit"] = "500000000" + pp["gov_action_deposit"] = "1000000000" + pp["pool_deposit"] = "500000000" + return pp +} + +// signSubmit builds the YAML with the given UTXOs + params, signs with the key roles, and submits. +// The devnet's /tx/submit returns 200/202 only after the node has validated and accepted the tx (a +// rejected tx gets a 400 with the ledger error) — that acceptance is the proof. +func signSubmit(t *testing.T, yaml string, utxos []map[string]interface{}, pp map[string]interface{}, execUnits []map[string]interface{}, keys ...string) string { + t.Helper() + var result *TxResult + var err error + if execUnits != nil { + result, err = bridge.QuickTx.Build(yaml, utxos, pp, execUnits) + } else { + result, err = bridge.QuickTx.Build(yaml, utxos, pp) + } + if err != nil { + t.Fatalf("build: %v", err) + } + signed, err := bridge.Account.SignTxWithKeys(intentMnemonic, Testnet, 0, 0, result.TxCbor, keys...) + if err != nil { + t.Fatalf("sign: %v", err) + } + txHash, err := devkitSubmitTx(signed) + if err != nil { + t.Fatalf("submit: %v", err) + } + return txHash +} + +// mintReceiver is the address the mint fixtures pay the minted asset to (account.enterpriseAddress). +const mintReceiver = "addr_test1vz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzerspjrlsz" + +// assertMintedAssetAt confirms a mint actually landed on-chain: the receiver holds a non-lovelace +// asset. ("Submit accepted" alone doesn't prove the intended effect; this does.) +func assertMintedAssetAt(t *testing.T, address string) { + t.Helper() + waitForBlock() + utxos, err := devkitGetUtxos(address) + if err != nil { + t.Fatalf("get receiver utxos: %v", err) + } + for _, u := range utxos { + amounts, _ := u["amount"].([]interface{}) + for _, a := range amounts { + if am, ok := a.(map[string]interface{}); ok { + if unit, _ := am["unit"].(string); unit != "" && unit != "lovelace" { + return // a minted asset is present + } + } + } + } + t.Fatalf("expected a minted asset at %s, found none", address) +} + +// assertUtxoConsumed confirms the given UTXO is no longer present at an address (it was spent). +func assertUtxoConsumed(t *testing.T, address, txHash string) { + t.Helper() + waitForBlock() + utxos, err := devkitGetUtxos(address) + if err != nil { + t.Fatalf("get utxos: %v", err) + } + for _, u := range utxos { + if h, _ := u["tx_hash"].(string); h == txHash { + t.Fatalf("UTXO %s at %s was not consumed", txHash, address) + } + } +} + +func TestIntegrationStakeRegistration(t *testing.T) { + skipIfNoDevKit(t) + buildSignSubmit(t, "stake_registration.yaml", nil, "payment", "stake") +} + +func TestIntegrationDRepRegistration(t *testing.T) { + skipIfNoDevKit(t) + buildSignSubmit(t, "drep_registration.yaml", nil, "payment", "drep") +} + +// Negative test: a DRep registration certificate must be witnessed by the DRep key, so signing with +// the payment key alone must be rejected by the node (MissingVKeyWitnessesUTXOW). This proves the +// extra witness sign_tx_with_keys adds is genuinely required — not cosmetic — and complements the +// positive TestIntegrationDRepRegistration (payment+drep) above. +func TestIntegrationDRepKeyRequired(t *testing.T) { + skipIfNoDevKit(t) + devkitReset() + waitForBlock() + if err := devkitTopup(intentSender, 6000); err != nil { + t.Fatalf("topup: %v", err) + } + waitForBlock() + pp := devnetPP(t) + + u, err := devkitGetUtxos(intentSender) + if err != nil { + t.Fatalf("get utxos: %v", err) + } + built, err := bridge.QuickTx.Build(readIntentFixture(t, "drep_registration.yaml"), u, pp) + if err != nil { + t.Fatalf("build: %v", err) + } + + // Sign with the payment key ONLY (ccl_account_sign_tx), omitting the DRep-key witness. + signedPaymentOnly, err := bridge.Account.SignTx(intentMnemonic, Testnet, 0, 0, built.TxCbor) + if err != nil { + t.Fatalf("sign: %v", err) + } + if _, err := devkitSubmitTx(signedPaymentOnly); err == nil { + t.Fatal("the node accepted a DRep registration signed with the payment key only; " + + "expected rejection (MissingVKeyWitnessesUTXOW)") + } +} + +func TestIntegrationDonation(t *testing.T) { + skipIfNoDevKit(t) + buildSignSubmit(t, "donation.yaml", nil, "payment") +} + +func TestIntegrationInfoProposal(t *testing.T) { + skipIfNoDevKit(t) + // A Conway proposal's deposit-return account must be a registered stake address, so register it + // first, then submit the proposal in the next block. + devkitReset() + waitForBlock() + if err := devkitTopup(intentSender, 6000); err != nil { + t.Fatalf("topup: %v", err) + } + waitForBlock() + pp := devnetPP(t) + + utxos, err := devkitGetUtxos(intentSender) + if err != nil { + t.Fatalf("get utxos: %v", err) + } + signSubmit(t, readIntentFixture(t, "stake_registration.yaml"), utxos, pp, nil, "payment", "stake") + waitForBlock() + + utxos2, err := devkitGetUtxos(intentSender) + if err != nil { + t.Fatalf("get utxos (post-registration): %v", err) + } + signSubmit(t, readIntentFixture(t, "governance_proposal.yaml"), utxos2, pp, nil, "payment") +} + +func TestIntegrationMetadata(t *testing.T) { + skipIfNoDevKit(t) + buildSignSubmit(t, "metadata.yaml", nil, "payment") +} + +func TestIntegrationNativeMint(t *testing.T) { + skipIfNoDevKit(t) + // The fixture mints under an empty-ScriptAll policy that needs no signature, so the fee payer + // alone can submit it. + buildSignSubmit(t, "minting.yaml", nil, "payment") + assertMintedAssetAt(t, mintReceiver) +} + +func TestIntegrationPlutusMint(t *testing.T) { + skipIfNoDevKit(t) + buildSignSubmit(t, "plutus/script_minting.yaml", + []map[string]interface{}{{"mem": 2000000, "steps": 500000000}}, "payment") + assertMintedAssetAt(t, mintReceiver) +} + +// setupThenSubmit resets+funds the devnet, submits a prerequisite fixture (e.g. registering a stake +// address or DRep), then submits the target fixture in the next block. Used for intents whose +// certificate depends on prior on-chain state. +func setupThenSubmit(t *testing.T, setupFixture string, setupKeys []string, fixture string, keys []string) { + t.Helper() + devkitReset() + waitForBlock() + if err := devkitTopup(intentSender, 6000); err != nil { + t.Fatalf("topup: %v", err) + } + waitForBlock() + pp := devnetPP(t) + + u, err := devkitGetUtxos(intentSender) + if err != nil { + t.Fatalf("get utxos: %v", err) + } + signSubmit(t, readIntentFixture(t, setupFixture), u, pp, nil, setupKeys...) + waitForBlock() + + u2, err := devkitGetUtxos(intentSender) + if err != nil { + t.Fatalf("get utxos (post-setup): %v", err) + } + signSubmit(t, readIntentFixture(t, fixture), u2, pp, nil, keys...) +} + +func TestIntegrationVotingDelegation(t *testing.T) { + skipIfNoDevKit(t) + // Delegating voting power requires the stake address to be registered; vote target is abstain. + setupThenSubmit(t, + "stake_registration.yaml", []string{"payment", "stake"}, + "voting_delegation.yaml", []string{"payment", "stake"}) +} + +func TestIntegrationDRepUpdate(t *testing.T) { + skipIfNoDevKit(t) + setupThenSubmit(t, + "drep_registration.yaml", []string{"payment", "drep"}, + "drep_update.yaml", []string{"payment", "drep"}) +} + +func TestIntegrationDRepDeregistration(t *testing.T) { + skipIfNoDevKit(t) + setupThenSubmit(t, + "drep_registration.yaml", []string{"payment", "drep"}, + "drep_deregistration.yaml", []string{"payment", "drep"}) +} + +func TestIntegrationStakeWithdrawal(t *testing.T) { + skipIfNoDevKit(t) + // Conway requires a stake address to be vote-delegated to a DRep before it can withdraw, so the + // sequence is: register stake -> delegate voting power -> withdraw the (zero) reward balance. + devkitReset() + waitForBlock() + if err := devkitTopup(intentSender, 6000); err != nil { + t.Fatalf("topup: %v", err) + } + waitForBlock() + pp := devnetPP(t) + + u, err := devkitGetUtxos(intentSender) + if err != nil { + t.Fatalf("get utxos: %v", err) + } + signSubmit(t, readIntentFixture(t, "stake_registration.yaml"), u, pp, nil, "payment", "stake") + waitForBlock() + + u2, err := devkitGetUtxos(intentSender) + if err != nil { + t.Fatalf("get utxos (post-registration): %v", err) + } + signSubmit(t, readIntentFixture(t, "voting_delegation.yaml"), u2, pp, nil, "payment", "stake") + waitForBlock() + + u3, err := devkitGetUtxos(intentSender) + if err != nil { + t.Fatalf("get utxos (post-vote-delegation): %v", err) + } + signSubmit(t, readIntentFixture(t, "stake_withdrawal.yaml"), u3, pp, nil, "payment", "stake") +} + +// govActionPlaceholder is the gov_action_tx_hash baked into voting.yaml; the voting test repoints it +// at the real proposal it submits. +const govActionPlaceholder = "12745f09b138d4d0a11a560b4591ebb830cf12336347606d2edbbf1893d395c6" + +func TestIntegrationVoting(t *testing.T) { + skipIfNoDevKit(t) + // A vote needs a registered DRep (the voter), a registered stake address (the proposal's return + // account), a live gov action to vote on, and the vote referencing it. + devkitReset() + waitForBlock() + if err := devkitTopup(intentSender, 6000); err != nil { + t.Fatalf("topup: %v", err) + } + waitForBlock() + pp := devnetPP(t) + + u, _ := devkitGetUtxos(intentSender) + signSubmit(t, readIntentFixture(t, "drep_registration.yaml"), u, pp, nil, "payment", "drep") + waitForBlock() + u2, _ := devkitGetUtxos(intentSender) + signSubmit(t, readIntentFixture(t, "stake_registration.yaml"), u2, pp, nil, "payment", "stake") + waitForBlock() + + // Submit an info proposal. Its tx hash (from the build result, not the garbled submit response) + // is the gov action id we vote on. + u3, err := devkitGetUtxos(intentSender) + if err != nil { + t.Fatalf("get utxos: %v", err) + } + proposal, err := bridge.QuickTx.Build(readIntentFixture(t, "governance_proposal.yaml"), u3, pp) + if err != nil { + t.Fatalf("build proposal: %v", err) + } + actionTxHash := proposal.TxHash + signedProposal, err := bridge.Account.SignTxWithKeys(intentMnemonic, Testnet, 0, 0, proposal.TxCbor, "payment") + if err != nil { + t.Fatalf("sign proposal: %v", err) + } + if _, err := devkitSubmitTx(signedProposal); err != nil { + t.Fatalf("submit proposal: %v", err) + } + waitForBlock() + + // Vote on the proposal we just submitted. + u4, _ := devkitGetUtxos(intentSender) + voteYaml := strings.ReplaceAll(readIntentFixture(t, "voting.yaml"), govActionPlaceholder, actionTxHash) + signSubmit(t, voteYaml, u4, pp, nil, "payment", "drep") +} + +// poolPlaceholder is the pool id baked into stake_delegation.yaml; the delegation test repoints it +// at the pool it registers. accountPoolID is that pool's id — the one keyed to the account's stake +// key in pool_registration.yaml (captured from QuickTxIntentsTest). +const ( + poolPlaceholder = "pool1pu5jlj4q9w9jlxeu370a3c9myx47md5j5m2str0naunn2q3lkdy" + accountPoolID = "pool1xtrj35uxrctye2egew8sqezgzwwg796ql7uw02572gedcpgmwck" +) + +func TestIntegrationStakeDelegation(t *testing.T) { + skipIfNoDevKit(t) + // Register the stake address, register a pool keyed to the account, then delegate to that pool. + // (DevKit exposes no pool-list endpoint, so we delegate to a pool we create rather than discover.) + devkitReset() + waitForBlock() + if err := devkitTopup(intentSender, 6000); err != nil { + t.Fatalf("topup: %v", err) + } + waitForBlock() + pp := devnetPP(t) + + u, err := devkitGetUtxos(intentSender) + if err != nil { + t.Fatalf("get utxos: %v", err) + } + signSubmit(t, readIntentFixture(t, "stake_registration.yaml"), u, pp, nil, "payment", "stake") + waitForBlock() + + u2, err := devkitGetUtxos(intentSender) + if err != nil { + t.Fatalf("get utxos (post-registration): %v", err) + } + signSubmit(t, readIntentFixture(t, "pool_registration.yaml"), u2, pp, nil, "payment", "stake") + waitForBlock() + + u3, err := devkitGetUtxos(intentSender) + if err != nil { + t.Fatalf("get utxos (post-pool-registration): %v", err) + } + delegYaml := strings.ReplaceAll(readIntentFixture(t, "stake_delegation.yaml"), poolPlaceholder, accountPoolID) + signSubmit(t, delegYaml, u3, pp, nil, "payment", "stake") +} + +func TestIntegrationPoolRegistration(t *testing.T) { + skipIfNoDevKit(t) + // The fixture keys the pool to the account's stake key (operator, owner, reward account), so + // signing with the stake key witnesses it. The reward account must be a registered stake + // address, so register it first. + setupThenSubmit(t, + "stake_registration.yaml", []string{"payment", "stake"}, + "pool_registration.yaml", []string{"payment", "stake"}) +} + +// Plutus spend: lock a UTXO at the script address (with the datum hash), then spend it. The spend +// fixture references a placeholder UTXO; we repoint it at the real on-chain locked UTXO. +func TestIntegrationPlutusSpend(t *testing.T) { + skipIfNoDevKit(t) + devkitReset() + waitForBlock() + if err := devkitTopup(intentSender, 6000); err != nil { + t.Fatalf("topup: %v", err) + } + waitForBlock() + pp := devnetPP(t) + + // Step 1: lock 10 ADA at the script address with the datum hash. + utxos, err := devkitGetUtxos(intentSender) + if err != nil { + t.Fatalf("get utxos: %v", err) + } + signSubmit(t, readIntentFixture(t, "plutus/plutus_lock.yaml"), utxos, pp, nil, "payment") + waitForBlock() + + // Step 2: find the locked UTXO at the script address. + scriptUtxos, err := devkitGetUtxos(scriptAddr) + if err != nil || len(scriptUtxos) == 0 { + t.Fatalf("no locked UTXO at script address: %v", err) + } + locked := scriptUtxos[0] + lockHash, _ := locked["tx_hash"].(string) + lockIdx := 0 + if idx, ok := locked["output_index"].(float64); ok { + lockIdx = int(idx) + } + + // Step 3: repoint the spend fixture's utxo_ref at the real locked UTXO. + spendYaml := readIntentFixture(t, "plutus/script_collect_from.yaml") + spendYaml = strings.ReplaceAll(spendYaml, scriptTxHash, lockHash) + if lockIdx != 0 { + spendYaml = strings.Replace(spendYaml, "output_index: 0", fmt.Sprintf("output_index: %d", lockIdx), 1) + } + + // Step 4: spend it — supply the locked UTXO (with its datum hash) + a fee/collateral UTXO. + feeUtxos, err := devkitGetUtxos(intentSender) + if err != nil { + t.Fatalf("get fee utxos: %v", err) + } + spendUtxos := []map[string]interface{}{{ + "tx_hash": lockHash, + "output_index": lockIdx, + "address": scriptAddr, + "amount": []map[string]interface{}{{"unit": "lovelace", "quantity": "10000000"}}, + "data_hash": scriptDatumHsh, + }} + spendUtxos = append(spendUtxos, feeUtxos...) + + signSubmit(t, spendYaml, spendUtxos, pp, + []map[string]interface{}{{"mem": 2000000, "steps": 500000000}}, "payment") + + // Confirm the spend actually consumed the locked script UTXO. + assertUtxoConsumed(t, scriptAddr, lockHash) +} diff --git a/wrappers/go/ccl/intents_test.go b/wrappers/go/ccl/intents_test.go new file mode 100644 index 0000000..315424b --- /dev/null +++ b/wrappers/go/ccl/intents_test.go @@ -0,0 +1,75 @@ +package ccl + +import ( + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +// The fee_payer/from address baked into the testdata/intents/*.yaml fixtures (derived from the +// core test mnemonic). The fixtures are generated by the JVM QuickTxIntentsTest via +// TxPlan.from(tx).toYaml(), so they carry CCL's exact intent YAML shapes; here we drive each one +// through the native library end-to-end to prove the bridge builds it offline (and that the +// native-image reflection config covers the governance/staking/DRep classes). +const intentSender = "addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp" + +// Second sender used by the compose fixture (multiple senders into one transaction). +const intentSender2 = "addr_test1qz7svwszky8gcmhrfza7a89z9u0dfzd3l7h23sqlc5yml7ejcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwqcqrvr0" + +func TestQuickTxIntentsE2E(t *testing.T) { + files, err := filepath.Glob("../../../test-fixtures/quicktx-intents/*.yaml") + if err != nil { + t.Fatalf("glob fixtures: %v", err) + } + if len(files) == 0 { + t.Fatal("no intent fixtures found in testdata/intents/") + } + + // A 2000-ADA UTXO at the sender (covers the largest deposit — gov action = 1000 ADA) plus a + // small one that the reference-input fixture reads. + utxos := []map[string]interface{}{ + { + "tx_hash": strings.Repeat("a", 64), + "output_index": 0, + "address": intentSender, + "amount": []map[string]interface{}{{"unit": "lovelace", "quantity": "2000000000"}}, + }, + { + "tx_hash": strings.Repeat("c", 64), + "output_index": 0, + "address": intentSender, + "amount": []map[string]interface{}{{"unit": "lovelace", "quantity": "5000000"}}, + }, + { + "tx_hash": strings.Repeat("a", 64), + "output_index": 1, + "address": intentSender2, + "amount": []map[string]interface{}{{"unit": "lovelace", "quantity": "2000000000"}}, + }, + } + + for _, f := range files { + name := strings.TrimSuffix(filepath.Base(f), ".yaml") + t.Run(name, func(t *testing.T) { + yamlBytes, err := os.ReadFile(f) + if err != nil { + t.Fatalf("read %s: %v", f, err) + } + result, err := bridge.QuickTx.Build(string(yamlBytes), utxos, testProtocolParams()) + if err != nil { + t.Fatalf("build %s: %v", name, err) + } + if len(result.TxCbor) == 0 { + t.Error("tx_cbor should not be empty") + } + if len(result.TxHash) != 64 { + t.Errorf("expected 64-char tx_hash, got %d", len(result.TxHash)) + } + if fee, err := strconv.Atoi(result.Fee); err != nil || fee <= 0 { + t.Errorf("expected positive fee, got %q", result.Fee) + } + }) + } +} diff --git a/wrappers/go/ccl/quicktx_integration_test.go b/wrappers/go/ccl/quicktx_integration_test.go index e0dbf0c..b618976 100644 --- a/wrappers/go/ccl/quicktx_integration_test.go +++ b/wrappers/go/ccl/quicktx_integration_test.go @@ -242,10 +242,7 @@ func TestIntegrationSimpleADATransfer(t *testing.T) { waitForBlock() sender := fundSender(t, 150) - receiver, err := bridge.Account.Create(Testnet) - if err != nil { - t.Fatalf("create receiver: %v", err) - } + receiver, _ := bridge.Account.Create(Testnet) utxos, err := devkitGetUtxos(sender.BaseAddress) if err != nil { @@ -256,25 +253,17 @@ func TestIntegrationSimpleADATransfer(t *testing.T) { t.Fatalf("get pp: %v", err) } - // Build - result, err := bridge.QuickTx.NewTx(). - PayToAddress(receiver.BaseAddress, Ada(5)). - From(sender.BaseAddress). - WithUtxos(utxos). - WithProtocolParams(pp). - Build() + yaml := quickTxYaml(sender.BaseAddress, receiver.BaseAddress, "5000000") + result, err := bridge.QuickTx.Build(yaml, utxos, pp) if err != nil { t.Fatalf("build: %v", err) } assertTxResult(t, result) - // Sign signedTx, err := bridge.Account.SignTx(sender.Mnemonic, Testnet, 0, 0, result.TxCbor) if err != nil { t.Fatalf("sign: %v", err) } - - // Submit txHash, err := devkitSubmitTx(signedTx) if err != nil { t.Fatalf("submit: %v", err) @@ -283,97 +272,71 @@ func TestIntegrationSimpleADATransfer(t *testing.T) { t.Fatal("empty tx hash from submit") } - // Verify waitForBlock() receiverUtxos, err := devkitGetUtxos(receiver.BaseAddress) if err != nil { t.Fatalf("get receiver utxos: %v", err) } - total := totalLovelace(receiverUtxos) - if total != 5_000_000 { + if total := totalLovelace(receiverUtxos); total != 5_000_000 { t.Errorf("expected 5 ADA (5000000), got %d lovelace", total) } } func TestIntegrationMultipleReceivers(t *testing.T) { skipIfNoDevKit(t) + devkitReset() + waitForBlock() sender := fundSender(t, 150) r1, _ := bridge.Account.Create(Testnet) r2, _ := bridge.Account.Create(Testnet) - utxos, _ := devkitGetUtxos(sender.BaseAddress) - pp, _ := devkitGetProtocolParams() - - result, err := bridge.QuickTx.NewTx(). - PayToAddress(r1.BaseAddress, Ada(3)). - PayToAddress(r2.BaseAddress, Ada(2)). - From(sender.BaseAddress). - WithUtxos(utxos). - WithProtocolParams(pp). - Build() + utxos, err := devkitGetUtxos(sender.BaseAddress) if err != nil { - t.Fatalf("build: %v", err) + t.Fatalf("get utxos: %v", err) } - - signedTx, _ := bridge.Account.SignTx(sender.Mnemonic, Testnet, 0, 0, result.TxCbor) - _, err = devkitSubmitTx(signedTx) + pp, err := devkitGetProtocolParams() if err != nil { - t.Fatalf("submit: %v", err) - } - - waitForBlock() - - r1Utxos, _ := devkitGetUtxos(r1.BaseAddress) - r2Utxos, _ := devkitGetUtxos(r2.BaseAddress) - - if total := totalLovelace(r1Utxos); total != 3_000_000 { - t.Errorf("r1: expected 3 ADA, got %d lovelace", total) - } - if total := totalLovelace(r2Utxos); total != 2_000_000 { - t.Errorf("r2: expected 2 ADA, got %d lovelace", total) + t.Fatalf("get pp: %v", err) } -} - -func TestIntegrationWithMetadata(t *testing.T) { - skipIfNoDevKit(t) - sender := fundSender(t, 150) - receiver, _ := bridge.Account.Create(Testnet) - - utxos, _ := devkitGetUtxos(sender.BaseAddress) - pp, _ := devkitGetProtocolParams() - - result, err := bridge.QuickTx.NewTx(). - PayToAddress(receiver.BaseAddress, Ada(2)). - AttachMetadata(674, map[string]interface{}{"msg": []string{"Hello from Go integration"}}). - From(sender.BaseAddress). - WithUtxos(utxos). - WithProtocolParams(pp). - Build() + yaml := fmt.Sprintf(` +version: 1.0 +transaction: + - tx: + from: %s + intents: + - type: payment + address: %s + amounts: + - unit: lovelace + quantity: "3000000" + - type: payment + address: %s + amounts: + - unit: lovelace + quantity: "2000000" +`, sender.BaseAddress, r1.BaseAddress, r2.BaseAddress) + + result, err := bridge.QuickTx.Build(yaml, utxos, pp) if err != nil { t.Fatalf("build: %v", err) } - signedTx, _ := bridge.Account.SignTx(sender.Mnemonic, Testnet, 0, 0, result.TxCbor) - _, err = devkitSubmitTx(signedTx) - if err != nil { + if _, err := devkitSubmitTx(signedTx); err != nil { t.Fatalf("submit: %v", err) } waitForBlock() - - txInfo, err := devkitGetTx(result.TxHash) - if err != nil { - t.Fatalf("get tx: %v", err) - } - if txInfo == nil { - t.Fatal("tx not found on-chain") + if total := totalLovelace(mustUtxos(t, r1.BaseAddress)); total != 3_000_000 { + t.Errorf("expected 3 ADA for r1, got %d", total) } } func TestIntegrationInsufficientFunds(t *testing.T) { skipIfNoDevKit(t) + devkitReset() + waitForBlock() sender := fundSender(t, 2) receiver, _ := bridge.Account.Create(Testnet) @@ -381,177 +344,17 @@ func TestIntegrationInsufficientFunds(t *testing.T) { utxos, _ := devkitGetUtxos(sender.BaseAddress) pp, _ := devkitGetProtocolParams() - _, err := bridge.QuickTx.NewTx(). - PayToAddress(receiver.BaseAddress, Ada(100)). - From(sender.BaseAddress). - WithUtxos(utxos). - WithProtocolParams(pp). - Build() - if err == nil { - t.Fatal("expected error for insufficient funds") + yaml := quickTxYaml(sender.BaseAddress, receiver.BaseAddress, "100000000") + if _, err := bridge.QuickTx.Build(yaml, utxos, pp); err == nil { + t.Fatal("expected insufficient funds error") } } -func TestIntegrationFullRoundTrip(t *testing.T) { - skipIfNoDevKit(t) - - sender := fundSender(t, 150) - receiver, _ := bridge.Account.Create(Testnet) - - utxos, _ := devkitGetUtxos(sender.BaseAddress) - pp, _ := devkitGetProtocolParams() - - // Build - result, err := bridge.QuickTx.NewTx(). - PayToAddress(receiver.BaseAddress, Ada(10)). - From(sender.BaseAddress). - WithUtxos(utxos). - WithProtocolParams(pp). - Build() - if err != nil { - t.Fatalf("build: %v", err) - } - assertTxResult(t, result) - - // Sign - signedTx, err := bridge.Account.SignTx(sender.Mnemonic, Testnet, 0, 0, result.TxCbor) - if err != nil { - t.Fatalf("sign: %v", err) - } - - // Submit - _, err = devkitSubmitTx(signedTx) - if err != nil { - t.Fatalf("submit: %v", err) - } - - waitForBlock() - - // Confirm on-chain - txInfo, err := devkitGetTx(result.TxHash) - if err != nil { - t.Fatalf("get tx: %v", err) - } - if txInfo == nil { - t.Fatal("tx not found on-chain") - } - - // Check receiver balance - receiverUtxos, _ := devkitGetUtxos(receiver.BaseAddress) - total := totalLovelace(receiverUtxos) - if total != 10_000_000 { - t.Errorf("expected 10 ADA (10000000), got %d lovelace", total) - } -} - -// --- Provider Config (Java-side lazy UTXO fetching) tests --- - -func TestIntegrationProviderConfigSimpleTransfer(t *testing.T) { - skipIfNoDevKit(t) - - sender := fundSender(t, 150) - receiver, _ := bridge.Account.Create(Testnet) - - // Build using ProviderConfig — Java fetches UTXOs and PP lazily via HTTP - result, err := bridge.QuickTx.NewTx(). - PayToAddress(receiver.BaseAddress, Ada(5)). - From(sender.BaseAddress). - BuildWithProvider(ProviderConfig{ - Name: "yaci", - URL: devkitProviderURL, - }) - if err != nil { - t.Fatalf("build with provider: %v", err) - } - assertTxResult(t, result) - - // Sign and submit - signedTx, _ := bridge.Account.SignTx(sender.Mnemonic, Testnet, 0, 0, result.TxCbor) - txHash, err := devkitSubmitTx(signedTx) - if err != nil { - t.Fatalf("submit: %v", err) - } - if txHash == "" { - t.Fatal("empty tx hash") - } - - waitForBlock() - receiverUtxos, _ := devkitGetUtxos(receiver.BaseAddress) - total := totalLovelace(receiverUtxos) - if total != 5_000_000 { - t.Errorf("expected 5 ADA (5000000), got %d lovelace", total) - } -} - -func TestIntegrationProviderConfigMultipleReceivers(t *testing.T) { - skipIfNoDevKit(t) - - sender := fundSender(t, 150) - r1, _ := bridge.Account.Create(Testnet) - r2, _ := bridge.Account.Create(Testnet) - - result, err := bridge.QuickTx.NewTx(). - PayToAddress(r1.BaseAddress, Ada(3)). - PayToAddress(r2.BaseAddress, Ada(2)). - From(sender.BaseAddress). - BuildWithProvider(ProviderConfig{ - Name: "yaci", - URL: devkitProviderURL, - }) - if err != nil { - t.Fatalf("build with provider: %v", err) - } - - signedTx, _ := bridge.Account.SignTx(sender.Mnemonic, Testnet, 0, 0, result.TxCbor) - _, err = devkitSubmitTx(signedTx) - if err != nil { - t.Fatalf("submit: %v", err) - } - - waitForBlock() - - r1Utxos, _ := devkitGetUtxos(r1.BaseAddress) - r2Utxos, _ := devkitGetUtxos(r2.BaseAddress) - - if total := totalLovelace(r1Utxos); total != 3_000_000 { - t.Errorf("r1: expected 3 ADA, got %d lovelace", total) - } - if total := totalLovelace(r2Utxos); total != 2_000_000 { - t.Errorf("r2: expected 2 ADA, got %d lovelace", total) - } -} - -func TestIntegrationProviderConfigWithMetadata(t *testing.T) { - skipIfNoDevKit(t) - - sender := fundSender(t, 150) - receiver, _ := bridge.Account.Create(Testnet) - - result, err := bridge.QuickTx.NewTx(). - PayToAddress(receiver.BaseAddress, Ada(2)). - AttachMetadata(674, map[string]interface{}{"msg": []string{"Hello from Go providerConfig"}}). - From(sender.BaseAddress). - BuildWithProvider(ProviderConfig{ - Name: "yaci", - URL: devkitProviderURL, - }) - if err != nil { - t.Fatalf("build with provider: %v", err) - } - - signedTx, _ := bridge.Account.SignTx(sender.Mnemonic, Testnet, 0, 0, result.TxCbor) - _, err = devkitSubmitTx(signedTx) - if err != nil { - t.Fatalf("submit: %v", err) - } - - waitForBlock() - - txInfo, err := devkitGetTx(result.TxHash) +func mustUtxos(t *testing.T, address string) []map[string]interface{} { + t.Helper() + utxos, err := devkitGetUtxos(address) if err != nil { - t.Fatalf("get tx: %v", err) - } - if txInfo == nil { - t.Fatal("tx not found on-chain") + t.Fatalf("get utxos: %v", err) } + return utxos } diff --git a/wrappers/go/ccl/script_spend_test.go b/wrappers/go/ccl/script_spend_test.go new file mode 100644 index 0000000..d615773 --- /dev/null +++ b/wrappers/go/ccl/script_spend_test.go @@ -0,0 +1,88 @@ +package ccl + +import ( + "os" + "strings" + "testing" +) + +// End-to-end Plutus script spend (script_collect_from) through the native lib via Go. Unlike the +// generic intent table, this one needs a UTXO at the script address (with the datum hash) plus a +// fee/collateral UTXO, and caller-supplied execution units for the spend redeemer. The fixture is +// generated by QuickTxIntentsTest.scriptCollectFrom; the script address + datum hash below are the +// deterministic values for the always-succeeds V2 validator and datum int(42). +const ( + scriptAddr = "addr_test1wpunlryvl7aqsxe22erzlsseej87v5kk5vutvtrmzdy8dect48z0w" + scriptDatumHsh = "9e1199a988ba72ffd6e9c269cadb3b53b5f360ff99f112d9b2ee30c4d74ad88b" + scriptTxHash = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +) + +func TestQuickTxScriptSpendE2E(t *testing.T) { + yamlBytes, err := os.ReadFile("../../../test-fixtures/quicktx-intents/plutus/script_collect_from.yaml") + if err != nil { + t.Fatalf("read fixture: %v", err) + } + yaml := string(yamlBytes) + + // The script UTXO to spend (with its datum hash) + a fee/collateral UTXO at the sender. + utxos := []map[string]interface{}{ + { + "tx_hash": scriptTxHash, + "output_index": 0, + "address": scriptAddr, + "amount": []map[string]interface{}{{"unit": "lovelace", "quantity": "10000000"}}, + "data_hash": scriptDatumHsh, + }, + { + "tx_hash": strings.Repeat("a", 64), + "output_index": 0, + "address": intentSender, + "amount": []map[string]interface{}{{"unit": "lovelace", "quantity": "2000000000"}}, + }, + } + execUnits := []map[string]interface{}{{"mem": 2000000, "steps": 500000000}} + + // With execution units: builds. + result, err := bridge.QuickTx.Build(yaml, utxos, testProtocolParams(), execUnits) + if err != nil { + t.Fatalf("script spend build failed: %v", err) + } + if len(result.TxCbor) == 0 || len(result.TxHash) != 64 { + t.Errorf("bad result: cbor=%d hash=%d", len(result.TxCbor), len(result.TxHash)) + } + + // Without execution units: the script can't be costed, so the build must fail. + if _, err := bridge.QuickTx.Build(yaml, utxos, testProtocolParams()); err == nil { + t.Error("expected script spend to fail without execution units") + } +} + +// End-to-end Plutus script mint (script_minting). Only needs a fee/collateral UTXO at the sender +// plus the redeemer's execution units. +func TestQuickTxScriptMintE2E(t *testing.T) { + yamlBytes, err := os.ReadFile("../../../test-fixtures/quicktx-intents/plutus/script_minting.yaml") + if err != nil { + t.Fatalf("read fixture: %v", err) + } + yaml := string(yamlBytes) + + utxos := []map[string]interface{}{{ + "tx_hash": strings.Repeat("a", 64), + "output_index": 0, + "address": intentSender, + "amount": []map[string]interface{}{{"unit": "lovelace", "quantity": "2000000000"}}, + }} + execUnits := []map[string]interface{}{{"mem": 2000000, "steps": 500000000}} + + result, err := bridge.QuickTx.Build(yaml, utxos, testProtocolParams(), execUnits) + if err != nil { + t.Fatalf("script mint build failed: %v", err) + } + if len(result.TxCbor) == 0 || len(result.TxHash) != 64 { + t.Errorf("bad result: cbor=%d hash=%d", len(result.TxCbor), len(result.TxHash)) + } + + if _, err := bridge.QuickTx.Build(yaml, utxos, testProtocolParams()); err == nil { + t.Error("expected script mint to fail without execution units") + } +} diff --git a/wrappers/go/ccl/sign_test.go b/wrappers/go/ccl/sign_test.go new file mode 100644 index 0000000..35ff0ae --- /dev/null +++ b/wrappers/go/ccl/sign_test.go @@ -0,0 +1,53 @@ +package ccl + +import ( + "os" + "strings" + "testing" +) + +// The mnemonic the intent fixtures are derived from (account index 0/0 == intentSender). +const intentMnemonic = "test walk nut penalty hip pave soap entry language right filter choice" + +// A stake registration must be witnessed by the stake key in addition to the payment key, or the +// node rejects it with MissingVKeyWitnessesUTXOW. This verifies SignTxWithKeys adds that second +// witness (the signed CBOR is longer by one vkey witness) where SignTx (payment only) does not. +func TestSignTxWithStakeKey(t *testing.T) { + yamlBytes, err := os.ReadFile("../../../test-fixtures/quicktx-intents/stake_registration.yaml") + if err != nil { + t.Fatalf("read fixture: %v", err) + } + utxos := []map[string]interface{}{{ + "tx_hash": strings.Repeat("a", 64), + "output_index": 0, + "address": intentSender, + "amount": []map[string]interface{}{{"unit": "lovelace", "quantity": "2000000000"}}, + }} + + built, err := bridge.QuickTx.Build(string(yamlBytes), utxos, testProtocolParams()) + if err != nil { + t.Fatalf("build stake registration: %v", err) + } + + signedPayment, err := bridge.Account.SignTx(intentMnemonic, Testnet, 0, 0, built.TxCbor) + if err != nil { + t.Fatalf("sign (payment): %v", err) + } + signedStake, err := bridge.Account.SignTxWithKeys(intentMnemonic, Testnet, 0, 0, built.TxCbor, "payment", "stake") + if err != nil { + t.Fatalf("sign (payment,stake): %v", err) + } + + if len(signedStake) <= len(signedPayment) { + t.Errorf("payment+stake signing should add a witness: payment=%d, payment+stake=%d", + len(signedPayment), len(signedStake)) + } +} + +// An unknown key role is rejected. +func TestSignTxWithKeysRejectsUnknownRole(t *testing.T) { + if _, err := bridge.Account.SignTxWithKeys(intentMnemonic, Testnet, 0, 0, + "84a300d9010281825820"+strings.Repeat("0", 100), "bogus"); err == nil { + t.Error("expected an error for an unknown signing role") + } +} diff --git a/wrappers/go/examples/account/main.go b/wrappers/go/examples/account/main.go new file mode 100644 index 0000000..19a6438 --- /dev/null +++ b/wrappers/go/examples/account/main.go @@ -0,0 +1,51 @@ +// Account creation and key derivation (offline). +// +// Run from wrappers/go: +// +// LIB_DIR=../../core/build/native/nativeCompile +// DYLD_LIBRARY_PATH=$LIB_DIR LD_LIBRARY_PATH=$LIB_DIR go run ./examples/account +package main + +import ( + "fmt" + "log" + + "github.com/bloxbean/ccl-bridge/wrappers/go/ccl" +) + +func main() { + bridge, err := ccl.New() + if err != nil { + log.Fatal(err) + } + defer bridge.Close() + + // 1. Create a brand-new testnet account (random mnemonic). + account, err := bridge.Account.Create(ccl.Testnet) + if err != nil { + log.Fatal(err) + } + fmt.Println("Created account") + fmt.Println(" base address:", account.BaseAddress) + fmt.Println(" mnemonic :", account.Mnemonic) + + // 2. Restore the same account from its mnemonic — the address must match. + restored, err := bridge.Account.FromMnemonic(account.Mnemonic, ccl.Testnet, 0, 0) + if err != nil { + log.Fatal(err) + } + if restored.BaseAddress != account.BaseAddress { + log.Fatal("restored address does not match") + } + fmt.Println("Restored from mnemonic — address matches:", restored.BaseAddress) + + // 3. Derive keys. + priv, _ := bridge.Account.GetPrivateKey(account.Mnemonic, ccl.Testnet, 0, 0) + pub, _ := bridge.Account.GetPublicKey(account.Mnemonic, ccl.Testnet, 0, 0) + fmt.Println(" private key (extended, hex):", priv) + fmt.Println(" public key (hex) :", pub) + + // 4. Derive the governance DRep ID. + drepID, _ := bridge.Account.GetDRepID(account.Mnemonic, ccl.Testnet, 0) + fmt.Println(" DRep ID:", drepID) +} diff --git a/wrappers/go/examples/primitives/main.go b/wrappers/go/examples/primitives/main.go new file mode 100644 index 0000000..e899592 --- /dev/null +++ b/wrappers/go/examples/primitives/main.go @@ -0,0 +1,55 @@ +// Crypto and address primitives (offline). +// +// Run from wrappers/go: +// +// LIB_DIR=../../core/build/native/nativeCompile +// DYLD_LIBRARY_PATH=$LIB_DIR LD_LIBRARY_PATH=$LIB_DIR go run ./examples/primitives +package main + +import ( + "fmt" + "log" + + "github.com/bloxbean/ccl-bridge/wrappers/go/ccl" +) + +func main() { + bridge, err := ccl.New() + if err != nil { + log.Fatal(err) + } + defer bridge.Close() + + // --- Mnemonics --- + mnemonic, _ := bridge.Crypto.GenerateMnemonic(24) + fmt.Println("Generated 24-word mnemonic:", mnemonic) + fmt.Println(" valid?", bridge.Crypto.ValidateMnemonic(mnemonic)) + fmt.Println(" 'not a real mnemonic' valid?", bridge.Crypto.ValidateMnemonic("not a real mnemonic")) + + // --- Blake2b hashing (hex in -> hex out). "Hello" == 48656c6c6f --- + h256, _ := bridge.Crypto.Blake2b256("48656c6c6f") + h224, _ := bridge.Crypto.Blake2b224("48656c6c6f") + fmt.Println("Blake2b-256('Hello'):", h256) + fmt.Println("Blake2b-224('Hello'):", h224) + + // --- Ed25519 signing --- + // GetPrivateKey returns the 64-byte extended key; Sign expects a 32-byte + // Ed25519 key, so take the first 32 bytes (64 hex chars). + acct, _ := bridge.Account.Create(ccl.Testnet) + privExt, _ := bridge.Account.GetPrivateKey(acct.Mnemonic, ccl.Testnet, 0, 0) + pub, _ := bridge.Account.GetPublicKey(acct.Mnemonic, ccl.Testnet, 0, 0) + messageHex := "68656c6c6f" // "hello" + sig, _ := bridge.Crypto.Sign(messageHex, privExt[:64]) + fmt.Println("Ed25519 signature:", sig) + // A tampered signature is correctly rejected. + fmt.Println(" verify(fake signature) ->", bridge.Crypto.Verify("00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", messageHex, pub)) + + // --- Address parsing & validation --- + addr := acct.BaseAddress + fmt.Println("Address valid?", bridge.Address.Validate(addr)) + info, _ := bridge.Address.Info(addr) + fmt.Printf("Address info : %+v\n", info) + raw, _ := bridge.Address.ToBytes(addr) + back, _ := bridge.Address.FromBytes(raw) + fmt.Println("Address -> bytes -> address round-trips:", back == addr) +} diff --git a/wrappers/go/examples/transaction/main.go b/wrappers/go/examples/transaction/main.go new file mode 100644 index 0000000..cdaa11d --- /dev/null +++ b/wrappers/go/examples/transaction/main.go @@ -0,0 +1,80 @@ +// Build and sign a payment transaction fully offline from a TxPlan (YAML). +// +// The transaction is defined as a TxPlan YAML document; we supply the UTXOs and protocol +// parameters ourselves (no node / no provider). The bridge builds the unsigned CBOR, which we +// then sign locally. Submitting it is a separate, online step. +// +// Run from wrappers/go: +// +// LIB_DIR=../../core/build/native/nativeCompile +// DYLD_LIBRARY_PATH=$LIB_DIR LD_LIBRARY_PATH=$LIB_DIR go run ./examples/transaction +package main + +import ( + "fmt" + "log" + "strings" + + "github.com/bloxbean/ccl-bridge/wrappers/go/ccl" +) + +// Minimal protocol parameters (CCL test-resource values), the CCL ProtocolParams model as a map. +var protocolParams = map[string]interface{}{ + "min_fee_a": 44, "min_fee_b": 155381, "max_tx_size": 16384, + "key_deposit": "2000000", "pool_deposit": "500000000", + "coins_per_utxo_size": "4310", "max_val_size": "5000", + "max_tx_ex_mem": "10000000", "max_tx_ex_steps": "10000000000", + "price_mem": 0.0577, "price_step": 0.0000721, "collateral_percent": 150, + "max_collateral_inputs": 3, +} + +func main() { + bridge, err := ccl.New() + if err != nil { + log.Fatal(err) + } + defer bridge.Close() + + sender, _ := bridge.Account.Create(ccl.Testnet) + receiver, _ := bridge.Account.Create(ccl.Testnet) + + // A static UTXO the sender controls (100 ADA), instead of querying a node. + utxos := []map[string]interface{}{{ + "tx_hash": strings.Repeat("a", 64), + "output_index": 0, + "address": sender.BaseAddress, + "amount": []map[string]interface{}{{"unit": "lovelace", "quantity": "100000000"}}, + }} + + // Define the transaction as a TxPlan YAML document: pay 5 ADA to the receiver. + yaml := fmt.Sprintf(` +version: 1.0 +transaction: + - tx: + from: %s + intents: + - type: payment + address: %s + amounts: + - unit: lovelace + quantity: "5000000" +`, sender.BaseAddress, receiver.BaseAddress) + + // Build the unsigned transaction offline. + result, err := bridge.QuickTx.Build(yaml, utxos, protocolParams) + if err != nil { + log.Fatal(err) + } + fmt.Println("Built unsigned transaction from TxPlan YAML") + fmt.Println(" tx hash:", result.TxHash) + fmt.Println(" fee :", result.Fee) + fmt.Println(" cbor :", result.TxCbor[:80], "...") + + // Sign it with the sender's mnemonic. + signed, err := bridge.Account.SignTx(sender.Mnemonic, ccl.Testnet, 0, 0, result.TxCbor) + if err != nil { + log.Fatal(err) + } + fmt.Println("Signed transaction cbor:", signed[:80], "...") + fmt.Println("\nNext step (not shown): submit `signed` to a Cardano node over HTTP.") +} diff --git a/wrappers/go/go.mod b/wrappers/go/go.mod index a818740..0f1a462 100644 --- a/wrappers/go/go.mod +++ b/wrappers/go/go.mod @@ -1,3 +1,5 @@ module github.com/bloxbean/ccl-bridge/wrappers/go go 1.21 + +require gopkg.in/yaml.v3 v3.0.1 diff --git a/wrappers/go/go.sum b/wrappers/go/go.sum new file mode 100644 index 0000000..a62c313 --- /dev/null +++ b/wrappers/go/go.sum @@ -0,0 +1,4 @@ +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/wrappers/js/README.md b/wrappers/js/README.md new file mode 100644 index 0000000..e304871 --- /dev/null +++ b/wrappers/js/README.md @@ -0,0 +1,83 @@ +# CCL Bridge — JavaScript (Bun) + +JavaScript bindings for [Cardano Client Lib](https://github.com/bloxbean/cardano-client-lib) +via the CCL Bridge native library, using Bun's built-in FFI. + +> Part of the [CCL Bridge](../../README.md) project. See the +> [top-level README](../../README.md) for the full API reference and +> [`docs/quicktx.md`](../../docs/quicktx.md) for transaction building. + +## Requirements + +- [Bun](https://bun.sh/) 1.0+. +- The native library `libccl.{dylib,so,dll}` for your platform. + +> **Node.js is not supported.** Node's FFI libraries (ffi-napi, koffi) crash against the +> GraalVM native library due to stack-boundary detection. Use Bun, whose built-in FFI +> works correctly. See the project [`TODO.md`](../../TODO.md) Non-Goals. + +## Getting the native library + +The bindings load the library at runtime via the `CCL_LIB_PATH` environment variable. +Build or download it first. From the repo root: + +```bash +./gradlew :core:nativeCompile # build from source (needs Oracle GraalVM 25.0.3) +# or: +make download-lib # download a pre-built binary +``` + +At **runtime** the OS loader also needs it via `DYLD_LIBRARY_PATH` (macOS) / +`LD_LIBRARY_PATH` (Linux). + +## Running the examples + +From `wrappers/js`: + +```bash +LIB_DIR=../../core/build/native/nativeCompile + +CCL_LIB_PATH=$LIB_DIR DYLD_LIBRARY_PATH=$LIB_DIR LD_LIBRARY_PATH=$LIB_DIR \ + bun examples/account.js +``` + +The [`examples/`](examples/) directory contains: + +| File | What it shows | +|------|---------------| +| [`account.js`](examples/account.js) | Create an account, restore from mnemonic, derive keys and a DRep ID | +| [`primitives.js`](examples/primitives.js) | Mnemonics, Blake2b hashing, Ed25519 signing, address parsing/validation | +| [`transaction.js`](examples/transaction.js) | Build an unsigned payment **offline** (QuickTx) and sign it — no node/DevKit needed | + +## Quick start + +```javascript +import { CclBridge, TESTNET } from './src/index.js'; + +const bridge = new CclBridge(); // loads libccl, starts a GraalVM isolate +try { + const account = bridge.account.create(TESTNET); + console.log(account.base_address); // addr_test1... + console.log(account.mnemonic); // 24-word phrase +} finally { + bridge.close(); // tears down the isolate +} +``` + +## API namespaces + +A `CclBridge` instance exposes these namespaces (all offline operations): +`bridge.account`, `bridge.address`, `bridge.crypto`, `bridge.tx`, `bridge.plutus`, +`bridge.script`, `bridge.gov`, `bridge.wallet`, `bridge.quicktx`. + +Network IDs are exported constants: `MAINNET` (0), `TESTNET` (1), `PREPROD` (2), +`PREVIEW` (3). Errors throw `CclError`. + +Transactions are defined as a [TxPlan](https://github.com/bloxbean/cardano-client-lib) +**YAML** document and built fully offline — you supply the UTXOs and protocol parameters: + +```js +const result = bridge.quicktx.build(yaml, utxos, protocolParams); // { tx_cbor, tx_hash, fee } +``` + +See [`examples/transaction.js`](examples/transaction.js). diff --git a/wrappers/js/build.gradle b/wrappers/js/build.gradle index 527332f..5cc21f8 100644 --- a/wrappers/js/build.gradle +++ b/wrappers/js/build.gradle @@ -17,5 +17,19 @@ task test(type: Exec) { environment 'DYLD_LIBRARY_PATH', nativeDir environment 'LD_LIBRARY_PATH', nativeDir - commandLine 'bash', '-c', 'bun test test/ccl.test.js' + commandLine 'bash', '-c', 'bun install && bun test test/ccl.test.js test/intents.e2e.test.js' +} + +// Full suite including the DevKit integration tests. Requires a running Yaci DevKit +// on :10000 (the integration tests skip themselves if it is not available). +task integrationTest(type: Exec) { + dependsOn copyNativeLib + workingDir projectDir + + def nativeDir = layout.buildDirectory.dir('native').get().asFile.absolutePath + environment 'CCL_LIB_PATH', nativeDir + environment 'DYLD_LIBRARY_PATH', nativeDir + environment 'LD_LIBRARY_PATH', nativeDir + + commandLine 'bash', '-c', 'bun install && bun test test/' } diff --git a/wrappers/js/bun.lock b/wrappers/js/bun.lock new file mode 100644 index 0000000..977061d --- /dev/null +++ b/wrappers/js/bun.lock @@ -0,0 +1,15 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "@bloxbean/ccl", + "dependencies": { + "yaml": "^2.3.0", + }, + }, + }, + "packages": { + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + } +} diff --git a/wrappers/js/examples/account.js b/wrappers/js/examples/account.js new file mode 100644 index 0000000..6d9f893 --- /dev/null +++ b/wrappers/js/examples/account.js @@ -0,0 +1,32 @@ +// Account creation and key derivation (offline). +// +// Run from wrappers/js: +// +// LIB_DIR=../../core/build/native/nativeCompile +// CCL_LIB_PATH=$LIB_DIR DYLD_LIBRARY_PATH=$LIB_DIR LD_LIBRARY_PATH=$LIB_DIR \ +// bun examples/account.js +import { CclBridge, TESTNET } from '../src/index.js'; + +const bridge = new CclBridge(); +try { + // 1. Create a brand-new testnet account (random mnemonic). + const account = bridge.account.create(TESTNET); + const { mnemonic, base_address } = account; + console.log('Created account'); + console.log(' base address:', base_address); + console.log(' mnemonic :', mnemonic); + + // 2. Restore the same account from its mnemonic — the address must match. + const restored = bridge.account.fromMnemonic(mnemonic, TESTNET, 0, 0); + if (restored.base_address !== base_address) throw new Error('address mismatch'); + console.log('Restored from mnemonic — address matches:', restored.base_address); + + // 3. Derive keys. + console.log(' private key (extended, hex):', bridge.account.getPrivateKey(mnemonic, TESTNET)); + console.log(' public key (hex) :', bridge.account.getPublicKey(mnemonic, TESTNET)); + + // 4. Derive the governance DRep ID. + console.log(' DRep ID:', bridge.account.getDrepId(mnemonic, TESTNET)); +} finally { + bridge.close(); +} diff --git a/wrappers/js/examples/primitives.js b/wrappers/js/examples/primitives.js new file mode 100644 index 0000000..147e294 --- /dev/null +++ b/wrappers/js/examples/primitives.js @@ -0,0 +1,41 @@ +// Crypto and address primitives (offline). +// +// Run from wrappers/js: +// +// LIB_DIR=../../core/build/native/nativeCompile +// CCL_LIB_PATH=$LIB_DIR DYLD_LIBRARY_PATH=$LIB_DIR LD_LIBRARY_PATH=$LIB_DIR \ +// bun examples/primitives.js +import { CclBridge, TESTNET } from '../src/index.js'; + +const bridge = new CclBridge(); +try { + // --- Mnemonics --- + const mnemonic = bridge.crypto.generateMnemonic(24); + console.log('Generated 24-word mnemonic:', mnemonic); + console.log(' valid?', bridge.crypto.validateMnemonic(mnemonic)); + console.log(" 'not a real mnemonic' valid?", bridge.crypto.validateMnemonic('not a real mnemonic')); + + // --- Blake2b hashing (hex in -> hex out). "Hello" == 48656c6c6f --- + console.log("Blake2b-256('Hello'):", bridge.crypto.blake2b256('48656c6c6f')); + console.log("Blake2b-224('Hello'):", bridge.crypto.blake2b224('48656c6c6f')); + + // --- Ed25519 signing --- + // getPrivateKey returns the 64-byte extended key; sign expects a 32-byte + // Ed25519 key, so take the first 32 bytes (64 hex chars). + const acct = bridge.account.create(TESTNET); + const sk = bridge.account.getPrivateKey(acct.mnemonic, TESTNET).slice(0, 64); + const pk = bridge.account.getPublicKey(acct.mnemonic, TESTNET); + const messageHex = '68656c6c6f'; // "hello" + console.log('Ed25519 signature:', bridge.crypto.sign(messageHex, sk)); + // A tampered signature is correctly rejected. + console.log(' verify(fake signature) ->', bridge.crypto.verify('00'.repeat(64), messageHex, pk)); + + // --- Address parsing & validation --- + const addr = acct.base_address; + console.log('Address valid?', bridge.address.validate(addr)); + console.log('Address info :', bridge.address.info(addr)); + const raw = bridge.address.toBytes(addr); + console.log('Address -> bytes -> address round-trips:', bridge.address.fromBytes(raw) === addr); +} finally { + bridge.close(); +} diff --git a/wrappers/js/examples/transaction.js b/wrappers/js/examples/transaction.js new file mode 100644 index 0000000..5645eb1 --- /dev/null +++ b/wrappers/js/examples/transaction.js @@ -0,0 +1,64 @@ +// Build and sign a payment transaction fully offline from a TxPlan (YAML). +// +// The transaction is defined as a TxPlan YAML document; we supply the UTXOs and protocol +// parameters ourselves (no node / no provider), build the unsigned CBOR, then sign it locally. +// Submitting it is a separate, online step. +// +// Run from wrappers/js: +// +// LIB_DIR=../../core/build/native/nativeCompile +// CCL_LIB_PATH=$LIB_DIR DYLD_LIBRARY_PATH=$LIB_DIR LD_LIBRARY_PATH=$LIB_DIR \ +// bun examples/transaction.js +import { CclBridge, TESTNET } from '../src/index.js'; + +// Minimal protocol parameters (CCL test-resource values). +const protocolParams = { + min_fee_a: 44, min_fee_b: 155381, max_tx_size: 16384, + key_deposit: '2000000', pool_deposit: '500000000', + coins_per_utxo_size: '4310', max_val_size: '5000', + max_tx_ex_mem: '10000000', max_tx_ex_steps: '10000000000', + price_mem: 0.0577, price_step: 0.0000721, collateral_percent: 150, + max_collateral_inputs: 3, +}; + +const bridge = new CclBridge(); +try { + const sender = bridge.account.create(TESTNET); + const receiver = bridge.account.create(TESTNET); + + // A static UTXO the sender controls (100 ADA), instead of querying a node. + const utxos = [{ + tx_hash: 'a'.repeat(64), + output_index: 0, + address: sender.base_address, + amount: [{ unit: 'lovelace', quantity: '100000000' }], + }]; + + // Define the transaction as a TxPlan YAML document: pay 5 ADA to the receiver. + const yaml = ` +version: 1.0 +transaction: + - tx: + from: ${sender.base_address} + intents: + - type: payment + address: ${receiver.base_address} + amounts: + - unit: lovelace + quantity: "5000000" +`; + + // Build the unsigned transaction offline. + const result = bridge.quicktx.build(yaml, utxos, protocolParams); + console.log('Built unsigned transaction from TxPlan YAML'); + console.log(' tx hash:', result.tx_hash); + console.log(' fee :', result.fee); + console.log(' cbor :', result.tx_cbor.slice(0, 80), '...'); + + // Sign it with the sender's mnemonic. + const signed = bridge.account.signTx(sender.mnemonic, TESTNET, 0, 0, result.tx_cbor); + console.log('Signed transaction cbor:', signed.slice(0, 80), '...'); + console.log('\nNext step (not shown): submit `signed` to a Cardano node over HTTP.'); +} finally { + bridge.close(); +} diff --git a/wrappers/js/package.json b/wrappers/js/package.json index 037e23f..f581689 100644 --- a/wrappers/js/package.json +++ b/wrappers/js/package.json @@ -7,6 +7,9 @@ "scripts": { "test": "bun test test/ccl.test.js" }, + "dependencies": { + "yaml": "^2.3.0" + }, "engines": { "bun": ">=1.0.0" } diff --git a/wrappers/js/src/index.d.ts b/wrappers/js/src/index.d.ts index e853c3d..ee13cea 100644 --- a/wrappers/js/src/index.d.ts +++ b/wrappers/js/src/index.d.ts @@ -45,6 +45,7 @@ export declare class CclBridge { accountGetPublicKey(mnemonic: string, networkId?: number, accountIndex?: number, addressIndex?: number): string; accountGetDrepId(mnemonic: string, networkId?: number, accountIndex?: number): string; accountSignTx(mnemonic: string, networkId: number, accountIndex: number, addressIndex: number, txCborHex: string): string; + accountSignTxWithKeys(mnemonic: string, networkId: number, accountIndex: number, addressIndex: number, txCborHex: string, keys: string[] | string): string; // Address addressInfo(bech32: string): AddressInfo; @@ -86,249 +87,22 @@ export interface QuickTxResult { fee: string; } -export interface AmountSpec { - unit: string; - quantity: string; -} - -export declare class Amount { - static lovelace(quantity: number): AmountSpec; - static ada(adaAmount: number): AmountSpec; - static asset(unit: string, quantity: number): AmountSpec; -} - -export interface ReferenceInput { - txHash: string; - outputIndex: number; -} - -export interface MintAsset { - name: string; - quantity: string; -} - -export interface ProviderConfig { - name: string; - url: string; - apiKey?: string; - enableCostEvaluation?: boolean; -} - -export interface ProposalOptions { - withdrawals?: Array<{ reward_address: string; amount: string }>; - govActionTxHash?: string; - govActionIndex?: number; - membersToRemove?: string[]; - newMembers?: Record; - quorumNumerator?: number; - quorumDenominator?: number; - constitutionAnchorUrl?: string; - constitutionAnchorDataHash?: string; - constitutionScriptHash?: string; - protocolVersionMajor?: number; - protocolVersionMinor?: number; - policyHash?: string; -} - -export interface PoolOptions { - relays?: Array<{ type: string; ipv4?: string; ipv6?: string; port?: number; dns_name?: string }>; - poolMetadataUrl?: string; - poolMetadataHash?: string; -} - -export interface Relay { - type: string; - ipv4?: string; - ipv6?: string; - port?: number; - dns_name?: string; -} - -export declare class TxBuilder { - payToAddress(address: string, ...amounts: (AmountSpec | { scriptRefCborHex?: string; scriptRefType?: string })[]): TxBuilder; - payToContract(address: string, amounts: AmountSpec | AmountSpec[], options?: { datumCborHex?: string; datumHash?: string; scriptRefCborHex?: string; scriptRefType?: string }): TxBuilder; - mintAssets(scriptJson: string | object, assets: Array<{ name: string; quantity: string }>, receiver: string): TxBuilder; - attachMetadata(label: number, metadata: any): TxBuilder; - collectFrom(utxos: any[]): TxBuilder; - // Staking - registerStakeAddress(address: string): TxBuilder; - deregisterStakeAddress(address: string, refundAddress?: string | null): TxBuilder; - delegateTo(address: string, poolId: string): TxBuilder; - withdraw(rewardAddress: string, amount: string | number, receiver?: string | null): TxBuilder; - // DRep - registerDRep(credentialHash: string, credentialType?: string, options?: { anchorUrl?: string; anchorDataHash?: string }): TxBuilder; - unregisterDRep(credentialHash: string, credentialType?: string, options?: { refundAddress?: string; refundAmount?: string | number }): TxBuilder; - updateDRep(credentialHash: string, credentialType?: string, options?: { anchorUrl?: string; anchorDataHash?: string }): TxBuilder; - // Voting - delegateVotingPowerTo(address: string, drepType: string, drepHash?: string | null): TxBuilder; - createVote(voterType: string, voterHash: string, govActionTxHash: string, govActionIndex: number, vote: string, options?: { anchorUrl?: string; anchorDataHash?: string }): TxBuilder; - // Governance - createProposal(govActionType: string, returnAddress: string, anchorUrl: string, anchorDataHash: string, options?: ProposalOptions): TxBuilder; - // Pool operations - registerPool(operator: string, vrfKeyHash: string, pledge: string | number, cost: string | number, marginNumerator: string | number, marginDenominator: string | number, rewardAddress: string, poolOwners: string[], options?: PoolOptions): TxBuilder; - updatePool(operator: string, vrfKeyHash: string, pledge: string | number, cost: string | number, marginNumerator: string | number, marginDenominator: string | number, rewardAddress: string, poolOwners: string[], options?: PoolOptions): TxBuilder; - retirePool(poolId: string, epoch: number): TxBuilder; - // Treasury donation - donateToTreasury(treasuryValue: string | number, donationAmount: string | number): TxBuilder; - // Native script - attachNativeScript(scriptJson: string | object): TxBuilder; - from(address: string): TxBuilder; - changeAddress(address: string): TxBuilder; - feePayer(address: string): TxBuilder; - withUtxos(utxos: any[]): TxBuilder; - withProtocolParams(params: any): TxBuilder; - validFrom(slot: number): TxBuilder; - validTo(slot: number): TxBuilder; - mergeOutputs(merge: boolean): TxBuilder; - signerCount(count: number): TxBuilder; - build(providerConfig?: ProviderConfig | null): QuickTxResult; - buildWithProvider(provider: Provider): Promise; -} - -export declare class Tx { - payToAddress(address: string, ...amounts: (AmountSpec | { scriptRefCborHex?: string; scriptRefType?: string })[]): Tx; - payToContract(address: string, amounts: AmountSpec | AmountSpec[], options?: { datumCborHex?: string; datumHash?: string; scriptRefCborHex?: string; scriptRefType?: string }): Tx; - mintAssets(scriptJson: string | object, assets: Array<{ name: string; quantity: string }>, receiver: string): Tx; - attachMetadata(label: number, metadata: any): Tx; - collectFrom(utxos: any[]): Tx; - // Staking - registerStakeAddress(address: string): Tx; - deregisterStakeAddress(address: string, refundAddress?: string | null): Tx; - delegateTo(address: string, poolId: string): Tx; - withdraw(rewardAddress: string, amount: string | number, receiver?: string | null): Tx; - // DRep - registerDRep(credentialHash: string, credentialType?: string, options?: { anchorUrl?: string; anchorDataHash?: string }): Tx; - unregisterDRep(credentialHash: string, credentialType?: string, options?: { refundAddress?: string; refundAmount?: string | number }): Tx; - updateDRep(credentialHash: string, credentialType?: string, options?: { anchorUrl?: string; anchorDataHash?: string }): Tx; - // Voting - delegateVotingPowerTo(address: string, drepType: string, drepHash?: string | null): Tx; - createVote(voterType: string, voterHash: string, govActionTxHash: string, govActionIndex: number, vote: string, options?: { anchorUrl?: string; anchorDataHash?: string }): Tx; - // Governance - createProposal(govActionType: string, returnAddress: string, anchorUrl: string, anchorDataHash: string, options?: ProposalOptions): Tx; - // Pool operations - registerPool(operator: string, vrfKeyHash: string, pledge: string | number, cost: string | number, marginNumerator: string | number, marginDenominator: string | number, rewardAddress: string, poolOwners: string[], options?: PoolOptions): Tx; - updatePool(operator: string, vrfKeyHash: string, pledge: string | number, cost: string | number, marginNumerator: string | number, marginDenominator: string | number, rewardAddress: string, poolOwners: string[], options?: PoolOptions): Tx; - retirePool(poolId: string, epoch: number): Tx; - // Treasury donation - donateToTreasury(treasuryValue: string | number, donationAmount: string | number): Tx; - // Native script - attachNativeScript(scriptJson: string | object): Tx; - from(address: string): Tx; - changeAddress(address: string): Tx; -} - -export declare class ComposeTxBuilder { - feePayer(address: string): ComposeTxBuilder; - withUtxos(utxos: any[]): ComposeTxBuilder; - withProtocolParams(params: any): ComposeTxBuilder; - validFrom(slot: number): ComposeTxBuilder; - validTo(slot: number): ComposeTxBuilder; - mergeOutputs(merge: boolean): ComposeTxBuilder; - signerCount(count: number): ComposeTxBuilder; - build(providerConfig?: ProviderConfig | null): QuickTxResult; - buildWithProvider(provider: Provider): Promise; -} - -export declare class ScriptTxBuilder { - payToAddress(address: string, ...amounts: (AmountSpec | { scriptRefCborHex?: string; scriptRefType?: string })[]): ScriptTxBuilder; - payToContract(address: string, amounts: AmountSpec | AmountSpec[], options?: { datumCborHex?: string; datumHash?: string; scriptRefCborHex?: string; scriptRefType?: string }): ScriptTxBuilder; - attachMetadata(label: number, metadata: any): ScriptTxBuilder; - collectFrom(utxos: any[]): ScriptTxBuilder; - collectFromScript(utxos: any[], redeemerCborHex: string, datumCborHex?: string | null): ScriptTxBuilder; - readFrom(referenceInputs: ReferenceInput[]): ScriptTxBuilder; - mintPlutusAssets(scriptCborHex: string, scriptType: string, assets: MintAsset[], redeemerCborHex: string, receiver?: string | null, outputDatumCborHex?: string | null): ScriptTxBuilder; - attachSpendingValidator(scriptCborHex: string, scriptType: string): ScriptTxBuilder; - attachCertificateValidator(scriptCborHex: string, scriptType: string): ScriptTxBuilder; - attachRewardValidator(scriptCborHex: string, scriptType: string): ScriptTxBuilder; - attachProposingValidator(scriptCborHex: string, scriptType: string): ScriptTxBuilder; - attachVotingValidator(scriptCborHex: string, scriptType: string): ScriptTxBuilder; - // Staking (with redeemer) - deregisterStakeAddress(address: string, redeemerCborHex: string, refundAddress?: string | null): ScriptTxBuilder; - delegateTo(address: string, poolId: string, redeemerCborHex: string): ScriptTxBuilder; - withdraw(rewardAddress: string, amount: string | number, redeemerCborHex: string, receiver?: string | null): ScriptTxBuilder; - // DRep (with redeemer) - registerDRep(credentialHash: string, credentialType: string, redeemerCborHex: string, options?: { anchorUrl?: string; anchorDataHash?: string }): ScriptTxBuilder; - unregisterDRep(credentialHash: string, credentialType: string, redeemerCborHex: string, options?: { refundAddress?: string; refundAmount?: string | number }): ScriptTxBuilder; - updateDRep(credentialHash: string, credentialType: string, redeemerCborHex: string, options?: { anchorUrl?: string; anchorDataHash?: string }): ScriptTxBuilder; - // Voting (with redeemer) - delegateVotingPowerTo(address: string, drepType: string, drepHash: string, redeemerCborHex: string): ScriptTxBuilder; - createVote(voterType: string, voterHash: string, govActionTxHash: string, govActionIndex: number, vote: string, redeemerCborHex: string, options?: { anchorUrl?: string; anchorDataHash?: string }): ScriptTxBuilder; - // Governance (with redeemer) - createProposal(govActionType: string, returnAddress: string, anchorUrl: string, anchorDataHash: string, redeemerCborHex: string, options?: ProposalOptions): ScriptTxBuilder; - // Treasury donation (with redeemer) - donateToTreasury(treasuryValue: string | number, donationAmount: string | number, redeemerCborHex: string): ScriptTxBuilder; - from(address: string): ScriptTxBuilder; - changeAddress(address: string): ScriptTxBuilder; - changeDatum(datumCborHex: string): ScriptTxBuilder; - changeDatumHash(hash: string): ScriptTxBuilder; - feePayer(address: string): ScriptTxBuilder; - withUtxos(utxos: any[]): ScriptTxBuilder; - withProtocolParams(params: any): ScriptTxBuilder; - validFrom(slot: number): ScriptTxBuilder; - validTo(slot: number): ScriptTxBuilder; - mergeOutputs(merge: boolean): ScriptTxBuilder; - signerCount(count: number): ScriptTxBuilder; - build(providerConfig?: ProviderConfig | null): QuickTxResult; - buildWithProvider(provider: Provider): Promise; -} - -export declare class ScriptTx { - payToAddress(address: string, ...amounts: (AmountSpec | { scriptRefCborHex?: string; scriptRefType?: string })[]): ScriptTx; - payToContract(address: string, amounts: AmountSpec | AmountSpec[], options?: { datumCborHex?: string; datumHash?: string; scriptRefCborHex?: string; scriptRefType?: string }): ScriptTx; - attachMetadata(label: number, metadata: any): ScriptTx; - collectFrom(utxos: any[]): ScriptTx; - collectFromScript(utxos: any[], redeemerCborHex: string, datumCborHex?: string | null): ScriptTx; - readFrom(referenceInputs: ReferenceInput[]): ScriptTx; - mintPlutusAssets(scriptCborHex: string, scriptType: string, assets: MintAsset[], redeemerCborHex: string, receiver?: string | null, outputDatumCborHex?: string | null): ScriptTx; - attachSpendingValidator(scriptCborHex: string, scriptType: string): ScriptTx; - attachCertificateValidator(scriptCborHex: string, scriptType: string): ScriptTx; - attachRewardValidator(scriptCborHex: string, scriptType: string): ScriptTx; - attachProposingValidator(scriptCborHex: string, scriptType: string): ScriptTx; - attachVotingValidator(scriptCborHex: string, scriptType: string): ScriptTx; - // Staking (with redeemer) - deregisterStakeAddress(address: string, redeemerCborHex: string, refundAddress?: string | null): ScriptTx; - delegateTo(address: string, poolId: string, redeemerCborHex: string): ScriptTx; - withdraw(rewardAddress: string, amount: string | number, redeemerCborHex: string, receiver?: string | null): ScriptTx; - // DRep (with redeemer) - registerDRep(credentialHash: string, credentialType: string, redeemerCborHex: string, options?: { anchorUrl?: string; anchorDataHash?: string }): ScriptTx; - unregisterDRep(credentialHash: string, credentialType: string, redeemerCborHex: string, options?: { refundAddress?: string; refundAmount?: string | number }): ScriptTx; - updateDRep(credentialHash: string, credentialType: string, redeemerCborHex: string, options?: { anchorUrl?: string; anchorDataHash?: string }): ScriptTx; - // Voting (with redeemer) - delegateVotingPowerTo(address: string, drepType: string, drepHash: string, redeemerCborHex: string): ScriptTx; - createVote(voterType: string, voterHash: string, govActionTxHash: string, govActionIndex: number, vote: string, redeemerCborHex: string, options?: { anchorUrl?: string; anchorDataHash?: string }): ScriptTx; - // Governance (with redeemer) - createProposal(govActionType: string, returnAddress: string, anchorUrl: string, anchorDataHash: string, redeemerCborHex: string, options?: ProposalOptions): ScriptTx; - // Treasury donation (with redeemer) - donateToTreasury(treasuryValue: string | number, donationAmount: string | number, redeemerCborHex: string): ScriptTx; - from(address: string): ScriptTx; - changeAddress(address: string): ScriptTx; - changeDatum(datumCborHex: string): ScriptTx; - changeDatumHash(hash: string): ScriptTx; -} - export declare class QuickTxApi { - newTx(): TxBuilder; - tx(): Tx; - newScriptTx(): ScriptTxBuilder; - scriptTx(): ScriptTx; - compose(...txs: (Tx | ScriptTx)[]): ComposeTxBuilder; -} - -export declare class Provider { - getUtxos(address: string): Promise; - getProtocolParams(): Promise; - submitTx(txCborHex: string): Promise; -} - -export declare class YaciDevKitProvider extends Provider { - constructor(baseUrl?: string); - getUtxos(address: string): Promise; - getProtocolParams(): Promise; - submitTx(txCborHex: string): Promise; - topup(address: string, adaAmount?: number): Promise; - reset(): Promise; - waitForBlock(ms?: number): Promise; - isAvailable(): Promise; + /** + * Build an unsigned transaction from a CCL TxPlan (YAML), fully offline. + * @param txplanYaml the TxPlan YAML document defining the transaction(s) + * @param utxos UTXOs available to the sender (CCL Utxo model) + * @param protocolParams protocol parameters (CCL ProtocolParams model) + * @param execUnits optional redeemer execution units (one per redeemer, in transaction order) + * for Plutus script transactions; compute them with any evaluator (Ogmios, Blockfrost, Aiken, + * Scalus) — the bridge does not run the script + */ + build( + txplanYaml: string, + utxos: object[], + protocolParams: object, + execUnits?: Array<{ mem: number | string; steps: number | string }>, + ): QuickTxResult; } export declare const MAINNET: number; diff --git a/wrappers/js/src/index.js b/wrappers/js/src/index.js index 38dbb9c..9aaf02c 100644 --- a/wrappers/js/src/index.js +++ b/wrappers/js/src/index.js @@ -1,8 +1,7 @@ import { dlopen, FFIType, ptr, CString } from 'bun:ffi'; import path from 'path'; import os from 'os'; - -export { Provider, YaciDevKitProvider } from './provider.js'; +import { parse as parseYaml } from 'yaml'; // Error codes export const CCL_SUCCESS = 0; @@ -72,6 +71,7 @@ export class CclBridge { ccl_account_get_public_key: { args: [FFIType.ptr, FFIType.cstring, FFIType.i32, FFIType.i32, FFIType.i32], returns: FFIType.i32 }, ccl_account_get_drep_id: { args: [FFIType.ptr, FFIType.cstring, FFIType.i32, FFIType.i32], returns: FFIType.i32 }, ccl_account_sign_tx: { args: [FFIType.ptr, FFIType.cstring, FFIType.i32, FFIType.i32, FFIType.i32, FFIType.cstring], returns: FFIType.i32 }, + ccl_account_sign_tx_multi: { args: [FFIType.ptr, FFIType.cstring, FFIType.i32, FFIType.i32, FFIType.i32, FFIType.cstring, FFIType.cstring], returns: FFIType.i32 }, // Address ccl_address_info: { args: [FFIType.ptr, FFIType.cstring], returns: FFIType.i32 }, @@ -114,7 +114,7 @@ export class CclBridge { ccl_wallet_get_address: { args: [FFIType.ptr, FFIType.cstring, FFIType.i32, FFIType.i32], returns: FFIType.i32 }, // QuickTx - ccl_quicktx_build: { args: [FFIType.ptr, FFIType.cstring], returns: FFIType.i32 }, + ccl_quicktx_build: { args: [FFIType.ptr, FFIType.cstring, FFIType.cstring, FFIType.cstring, FFIType.cstring], returns: FFIType.i32 }, }); this._lib = lib.symbols; @@ -210,6 +210,15 @@ class AccountApi { return this._b._check( this._b._lib.ccl_account_sign_tx(this._b._thread, cstr(mnemonic), networkId, accountIndex, addressIndex, cstr(txCborHex))); } + + // Sign with one or more of the account's keys, selected by role (any of: payment, stake, drep, + // committee_cold, committee_hot, applied in order). Use for transactions whose certificates also + // need the stake or DRep key — stake registration/delegation/withdrawal and DRep/vote operations. + signTxWithKeys(mnemonic, networkId, accountIndex, addressIndex, txCborHex, keys) { + const keysStr = Array.isArray(keys) ? keys.join(",") : keys; + return this._b._check( + this._b._lib.ccl_account_sign_tx_multi(this._b._thread, cstr(mnemonic), networkId, accountIndex, addressIndex, cstr(txCborHex), cstr(keysStr))); + } } class AddressApi { @@ -350,1317 +359,31 @@ class WalletApi { } class QuickTxApi { - constructor(bridge) { this._b = bridge; } - - newTx() { - return new TxBuilder(this._b); - } - - tx() { - return new Tx(); - } - - newScriptTx() { - return new ScriptTxBuilder(this._b); - } - - scriptTx() { - return new ScriptTx(); - } - - compose(...txs) { - return new ComposeTxBuilder(this._b, txs); - } -} - -export class Amount { - static lovelace(quantity) { - return { unit: 'lovelace', quantity: String(Math.floor(quantity)) }; - } - - static ada(adaAmount) { - return { unit: 'lovelace', quantity: String(Math.floor(adaAmount * 1_000_000)) }; - } - - static asset(unit, quantity) { - return { unit, quantity: String(Math.floor(quantity)) }; - } -} - -export class TxBuilder { constructor(bridge) { this._b = bridge; - this._operations = []; - this._from = null; - this._changeAddress = null; - this._feePayer = null; - this._utxos = null; - this._protocolParams = null; - this._validity = {}; - this._mergeOutputs = null; - this._signerCount = 1; - } - - payToAddress(address, ...args) { - let amounts = args; - let options = {}; - if (args.length > 0) { - const last = args[args.length - 1]; - if (last && typeof last === 'object' && !last.unit) { - options = args[args.length - 1]; - amounts = args.slice(0, -1); - } - } - const op = { - type: 'pay_to_address', - address, - amounts: [...amounts], - }; - if (options.scriptRefCborHex) op.script_ref_cbor_hex = options.scriptRefCborHex; - if (options.scriptRefType) op.script_ref_type = options.scriptRefType; - this._operations.push(op); - return this; - } - - payToContract(address, amounts, { datumCborHex, datumHash, scriptRefCborHex, scriptRefType } = {}) { - const op = { - type: 'pay_to_contract', - address, - amounts: Array.isArray(amounts) ? amounts : [amounts], - }; - if (datumCborHex) op.datum_cbor_hex = datumCborHex; - if (datumHash) op.datum_hash = datumHash; - if (scriptRefCborHex) op.script_ref_cbor_hex = scriptRefCborHex; - if (scriptRefType) op.script_ref_type = scriptRefType; - this._operations.push(op); - return this; - } - - mintAssets(scriptJson, assets, receiver) { - this._operations.push({ - type: 'mint_assets', - script_json: typeof scriptJson === 'string' ? scriptJson : JSON.stringify(scriptJson), - assets, - receiver, - }); - return this; - } - - attachMetadata(label, metadata) { - this._operations.push({ - type: 'attach_metadata', - label, - metadata, - }); - return this; - } - - collectFrom(utxos) { - this._operations.push({ - type: 'collect_from', - collect_utxos: utxos, - }); - return this; - } - - // Staking - registerStakeAddress(address) { - this._operations.push({ type: 'register_stake_address', address }); - return this; - } - - deregisterStakeAddress(address, refundAddress = null) { - const op = { type: 'deregister_stake_address', address }; - if (refundAddress) op.refund_address = refundAddress; - this._operations.push(op); - return this; - } - - delegateTo(address, poolId) { - this._operations.push({ type: 'delegate_to', address, pool_id: poolId }); - return this; - } - - withdraw(rewardAddress, amount, receiver = null) { - const op = { type: 'withdraw', reward_address: rewardAddress, amount: String(amount) }; - if (receiver) op.receiver = receiver; - this._operations.push(op); - return this; - } - - // DRep - registerDRep(credentialHash, credentialType = 'key', { anchorUrl, anchorDataHash } = {}) { - const op = { type: 'register_drep', credential_hash: credentialHash, credential_type: credentialType }; - if (anchorUrl) op.anchor_url = anchorUrl; - if (anchorDataHash) op.anchor_data_hash = anchorDataHash; - this._operations.push(op); - return this; - } - - unregisterDRep(credentialHash, credentialType = 'key', { refundAddress, refundAmount } = {}) { - const op = { type: 'unregister_drep', credential_hash: credentialHash, credential_type: credentialType }; - if (refundAddress) op.refund_address = refundAddress; - if (refundAmount != null) op.refund_amount = String(refundAmount); - this._operations.push(op); - return this; - } - - updateDRep(credentialHash, credentialType = 'key', { anchorUrl, anchorDataHash } = {}) { - const op = { type: 'update_drep', credential_hash: credentialHash, credential_type: credentialType }; - if (anchorUrl) op.anchor_url = anchorUrl; - if (anchorDataHash) op.anchor_data_hash = anchorDataHash; - this._operations.push(op); - return this; - } - - // Voting - delegateVotingPowerTo(address, drepType, drepHash = null) { - const op = { type: 'delegate_voting_power_to', address, drep_type: drepType }; - if (drepHash) op.drep_hash = drepHash; - this._operations.push(op); - return this; - } - - createVote(voterType, voterHash, govActionTxHash, govActionIndex, vote, { anchorUrl, anchorDataHash } = {}) { - const op = { - type: 'create_vote', voter_type: voterType, voter_hash: voterHash, - gov_action_tx_hash: govActionTxHash, gov_action_index: govActionIndex, vote, - }; - if (anchorUrl) op.anchor_url = anchorUrl; - if (anchorDataHash) op.anchor_data_hash = anchorDataHash; - this._operations.push(op); - return this; - } - - // Governance - createProposal(govActionType, returnAddress, anchorUrl, anchorDataHash, options = {}) { - const op = { - type: 'create_proposal', gov_action_type: govActionType, - return_address: returnAddress, anchor_url: anchorUrl, anchor_data_hash: anchorDataHash, - }; - if (options.withdrawals) op.withdrawals = options.withdrawals; - if (options.govActionTxHash) op.gov_action_tx_hash = options.govActionTxHash; - if (options.govActionIndex != null) op.gov_action_index = options.govActionIndex; - if (options.membersToRemove) op.members_to_remove = options.membersToRemove; - if (options.newMembers) op.new_members = options.newMembers; - if (options.quorumNumerator != null) op.quorum_numerator = String(options.quorumNumerator); - if (options.quorumDenominator != null) op.quorum_denominator = String(options.quorumDenominator); - if (options.constitutionAnchorUrl) op.constitution_anchor_url = options.constitutionAnchorUrl; - if (options.constitutionAnchorDataHash) op.constitution_anchor_data_hash = options.constitutionAnchorDataHash; - if (options.constitutionScriptHash) op.constitution_script_hash = options.constitutionScriptHash; - if (options.protocolVersionMajor != null) op.protocol_version_major = options.protocolVersionMajor; - if (options.protocolVersionMinor != null) op.protocol_version_minor = options.protocolVersionMinor; - if (options.policyHash) op.policy_hash = options.policyHash; - this._operations.push(op); - return this; - } - - // Pool operations - registerPool(operator, vrfKeyHash, pledge, cost, marginNumerator, marginDenominator, - rewardAddress, poolOwners, options = {}) { - const op = { - type: 'register_pool', operator, vrf_key_hash: vrfKeyHash, - pledge: String(pledge), cost: String(cost), - margin_numerator: String(marginNumerator), margin_denominator: String(marginDenominator), - reward_address: rewardAddress, pool_owners: poolOwners, - }; - if (options.relays) op.relays = options.relays; - if (options.poolMetadataUrl) op.pool_metadata_url = options.poolMetadataUrl; - if (options.poolMetadataHash) op.pool_metadata_hash = options.poolMetadataHash; - this._operations.push(op); - return this; - } - - updatePool(operator, vrfKeyHash, pledge, cost, marginNumerator, marginDenominator, - rewardAddress, poolOwners, options = {}) { - const op = { - type: 'update_pool', operator, vrf_key_hash: vrfKeyHash, - pledge: String(pledge), cost: String(cost), - margin_numerator: String(marginNumerator), margin_denominator: String(marginDenominator), - reward_address: rewardAddress, pool_owners: poolOwners, - }; - if (options.relays) op.relays = options.relays; - if (options.poolMetadataUrl) op.pool_metadata_url = options.poolMetadataUrl; - if (options.poolMetadataHash) op.pool_metadata_hash = options.poolMetadataHash; - this._operations.push(op); - return this; - } - - retirePool(poolId, epoch) { - this._operations.push({ type: 'retire_pool', pool_id: poolId, epoch }); - return this; - } - - // Treasury donation - donateToTreasury(treasuryValue, donationAmount) { - this._operations.push({ - type: 'donate_to_treasury', - treasury_value: String(treasuryValue), - donation_amount: String(donationAmount), - }); - return this; - } - - // Native script attachment - attachNativeScript(scriptJson) { - this._operations.push({ - type: 'attach_native_script', - script_json: typeof scriptJson === 'string' ? scriptJson : JSON.stringify(scriptJson), - }); - return this; - } - - from(address) { - this._from = address; - return this; - } - - changeAddress(address) { - this._changeAddress = address; - return this; - } - - feePayer(address) { - this._feePayer = address; - return this; - } - - withUtxos(utxos) { - this._utxos = utxos; - return this; - } - - withProtocolParams(params) { - this._protocolParams = params; - return this; - } - - validFrom(slot) { - this._validity.valid_from = slot; - return this; - } - - validTo(slot) { - this._validity.valid_to = slot; - return this; - } - - mergeOutputs(merge) { - this._mergeOutputs = merge; - return this; - } - - signerCount(count) { - this._signerCount = count; - return this; - } - - build(providerConfig = null) { - const spec = { - operations: this._operations, - from: this._from, - signer_count: this._signerCount, - }; - - if (providerConfig) { - spec.provider = { name: providerConfig.name, url: providerConfig.url }; - if (providerConfig.apiKey) spec.provider.api_key = providerConfig.apiKey; - if (providerConfig.enableCostEvaluation !== undefined) spec.provider.enable_cost_evaluation = providerConfig.enableCostEvaluation; - if (this._protocolParams !== null) spec.protocol_params = this._protocolParams; - } else { - spec.utxos = this._utxos; - spec.protocol_params = this._protocolParams; - } - - if (this._changeAddress) spec.change_address = this._changeAddress; - if (this._feePayer) spec.fee_payer = this._feePayer; - if (Object.keys(this._validity).length > 0) spec.validity = this._validity; - if (this._mergeOutputs !== null) spec.merge_outputs = this._mergeOutputs; - - const specJson = JSON.stringify(spec); - const rc = this._b._lib.ccl_quicktx_build(this._b._thread, cstr(specJson)); - return JSON.parse(this._b._check(rc)); - } - - async buildWithProvider(provider) { - if (this._utxos === null && this._from) { - this._utxos = await provider.getUtxos(this._from); - } - if (this._protocolParams === null) { - this._protocolParams = await provider.getProtocolParams(); - } - return this.build(); - } -} - -export class Tx { - constructor() { - this._operations = []; - this._from = null; - this._changeAddress = null; - } - - payToAddress(address, ...args) { - let amounts = args; - let options = {}; - if (args.length > 0) { - const last = args[args.length - 1]; - if (last && typeof last === 'object' && !last.unit) { - options = args[args.length - 1]; - amounts = args.slice(0, -1); - } - } - const op = { - type: 'pay_to_address', - address, - amounts: [...amounts], - }; - if (options.scriptRefCborHex) op.script_ref_cbor_hex = options.scriptRefCborHex; - if (options.scriptRefType) op.script_ref_type = options.scriptRefType; - this._operations.push(op); - return this; - } - - payToContract(address, amounts, { datumCborHex, datumHash, scriptRefCborHex, scriptRefType } = {}) { - const op = { - type: 'pay_to_contract', - address, - amounts: Array.isArray(amounts) ? amounts : [amounts], - }; - if (datumCborHex) op.datum_cbor_hex = datumCborHex; - if (datumHash) op.datum_hash = datumHash; - if (scriptRefCborHex) op.script_ref_cbor_hex = scriptRefCborHex; - if (scriptRefType) op.script_ref_type = scriptRefType; - this._operations.push(op); - return this; - } - - mintAssets(scriptJson, assets, receiver) { - this._operations.push({ - type: 'mint_assets', - script_json: typeof scriptJson === 'string' ? scriptJson : JSON.stringify(scriptJson), - assets, - receiver, - }); - return this; - } - - attachMetadata(label, metadata) { - this._operations.push({ - type: 'attach_metadata', - label, - metadata, - }); - return this; - } - - collectFrom(utxos) { - this._operations.push({ - type: 'collect_from', - collect_utxos: utxos, - }); - return this; - } - - // Staking - registerStakeAddress(address) { - this._operations.push({ type: 'register_stake_address', address }); - return this; - } - - deregisterStakeAddress(address, refundAddress = null) { - const op = { type: 'deregister_stake_address', address }; - if (refundAddress) op.refund_address = refundAddress; - this._operations.push(op); - return this; - } - - delegateTo(address, poolId) { - this._operations.push({ type: 'delegate_to', address, pool_id: poolId }); - return this; - } - - withdraw(rewardAddress, amount, receiver = null) { - const op = { type: 'withdraw', reward_address: rewardAddress, amount: String(amount) }; - if (receiver) op.receiver = receiver; - this._operations.push(op); - return this; - } - - // DRep - registerDRep(credentialHash, credentialType = 'key', { anchorUrl, anchorDataHash } = {}) { - const op = { type: 'register_drep', credential_hash: credentialHash, credential_type: credentialType }; - if (anchorUrl) op.anchor_url = anchorUrl; - if (anchorDataHash) op.anchor_data_hash = anchorDataHash; - this._operations.push(op); - return this; - } - - unregisterDRep(credentialHash, credentialType = 'key', { refundAddress, refundAmount } = {}) { - const op = { type: 'unregister_drep', credential_hash: credentialHash, credential_type: credentialType }; - if (refundAddress) op.refund_address = refundAddress; - if (refundAmount != null) op.refund_amount = String(refundAmount); - this._operations.push(op); - return this; - } - - updateDRep(credentialHash, credentialType = 'key', { anchorUrl, anchorDataHash } = {}) { - const op = { type: 'update_drep', credential_hash: credentialHash, credential_type: credentialType }; - if (anchorUrl) op.anchor_url = anchorUrl; - if (anchorDataHash) op.anchor_data_hash = anchorDataHash; - this._operations.push(op); - return this; - } - - // Voting - delegateVotingPowerTo(address, drepType, drepHash = null) { - const op = { type: 'delegate_voting_power_to', address, drep_type: drepType }; - if (drepHash) op.drep_hash = drepHash; - this._operations.push(op); - return this; - } - - createVote(voterType, voterHash, govActionTxHash, govActionIndex, vote, { anchorUrl, anchorDataHash } = {}) { - const op = { - type: 'create_vote', voter_type: voterType, voter_hash: voterHash, - gov_action_tx_hash: govActionTxHash, gov_action_index: govActionIndex, vote, - }; - if (anchorUrl) op.anchor_url = anchorUrl; - if (anchorDataHash) op.anchor_data_hash = anchorDataHash; - this._operations.push(op); - return this; - } - - // Governance - createProposal(govActionType, returnAddress, anchorUrl, anchorDataHash, options = {}) { - const op = { - type: 'create_proposal', gov_action_type: govActionType, - return_address: returnAddress, anchor_url: anchorUrl, anchor_data_hash: anchorDataHash, - }; - if (options.withdrawals) op.withdrawals = options.withdrawals; - if (options.govActionTxHash) op.gov_action_tx_hash = options.govActionTxHash; - if (options.govActionIndex != null) op.gov_action_index = options.govActionIndex; - if (options.membersToRemove) op.members_to_remove = options.membersToRemove; - if (options.newMembers) op.new_members = options.newMembers; - if (options.quorumNumerator != null) op.quorum_numerator = String(options.quorumNumerator); - if (options.quorumDenominator != null) op.quorum_denominator = String(options.quorumDenominator); - if (options.constitutionAnchorUrl) op.constitution_anchor_url = options.constitutionAnchorUrl; - if (options.constitutionAnchorDataHash) op.constitution_anchor_data_hash = options.constitutionAnchorDataHash; - if (options.constitutionScriptHash) op.constitution_script_hash = options.constitutionScriptHash; - if (options.protocolVersionMajor != null) op.protocol_version_major = options.protocolVersionMajor; - if (options.protocolVersionMinor != null) op.protocol_version_minor = options.protocolVersionMinor; - if (options.policyHash) op.policy_hash = options.policyHash; - this._operations.push(op); - return this; - } - - // Pool operations - registerPool(operator, vrfKeyHash, pledge, cost, marginNumerator, marginDenominator, - rewardAddress, poolOwners, options = {}) { - const op = { - type: 'register_pool', operator, vrf_key_hash: vrfKeyHash, - pledge: String(pledge), cost: String(cost), - margin_numerator: String(marginNumerator), margin_denominator: String(marginDenominator), - reward_address: rewardAddress, pool_owners: poolOwners, - }; - if (options.relays) op.relays = options.relays; - if (options.poolMetadataUrl) op.pool_metadata_url = options.poolMetadataUrl; - if (options.poolMetadataHash) op.pool_metadata_hash = options.poolMetadataHash; - this._operations.push(op); - return this; - } - - updatePool(operator, vrfKeyHash, pledge, cost, marginNumerator, marginDenominator, - rewardAddress, poolOwners, options = {}) { - const op = { - type: 'update_pool', operator, vrf_key_hash: vrfKeyHash, - pledge: String(pledge), cost: String(cost), - margin_numerator: String(marginNumerator), margin_denominator: String(marginDenominator), - reward_address: rewardAddress, pool_owners: poolOwners, - }; - if (options.relays) op.relays = options.relays; - if (options.poolMetadataUrl) op.pool_metadata_url = options.poolMetadataUrl; - if (options.poolMetadataHash) op.pool_metadata_hash = options.poolMetadataHash; - this._operations.push(op); - return this; - } - - retirePool(poolId, epoch) { - this._operations.push({ type: 'retire_pool', pool_id: poolId, epoch }); - return this; - } - - // Treasury donation - donateToTreasury(treasuryValue, donationAmount) { - this._operations.push({ - type: 'donate_to_treasury', - treasury_value: String(treasuryValue), - donation_amount: String(donationAmount), - }); - return this; - } - - // Native script attachment - attachNativeScript(scriptJson) { - this._operations.push({ - type: 'attach_native_script', - script_json: typeof scriptJson === 'string' ? scriptJson : JSON.stringify(scriptJson), - }); - return this; - } - - from(address) { - this._from = address; - return this; - } - - changeAddress(address) { - this._changeAddress = address; - return this; - } - - _toSpec() { - const spec = { - from: this._from, - operations: this._operations, - }; - if (this._changeAddress) spec.change_address = this._changeAddress; - return spec; - } -} - -export class ComposeTxBuilder { - constructor(bridge, txs) { - this._b = bridge; - this._txs = [...txs]; - this._feePayer = null; - this._utxos = null; - this._protocolParams = null; - this._validity = {}; - this._mergeOutputs = null; - this._signerCount = null; - } - - feePayer(address) { - this._feePayer = address; - return this; - } - - withUtxos(utxos) { - this._utxos = utxos; - return this; - } - - withProtocolParams(params) { - this._protocolParams = params; - return this; - } - - validFrom(slot) { - this._validity.valid_from = slot; - return this; - } - - validTo(slot) { - this._validity.valid_to = slot; - return this; - } - - mergeOutputs(merge) { - this._mergeOutputs = merge; - return this; - } - - signerCount(count) { - this._signerCount = count; - return this; - } - - build(providerConfig = null) { - const spec = { - transactions: this._txs.map(tx => tx._toSpec()), - fee_payer: this._feePayer, - }; - - if (providerConfig) { - spec.provider = { name: providerConfig.name, url: providerConfig.url }; - if (providerConfig.apiKey) spec.provider.api_key = providerConfig.apiKey; - if (providerConfig.enableCostEvaluation !== undefined) spec.provider.enable_cost_evaluation = providerConfig.enableCostEvaluation; - if (this._protocolParams !== null) spec.protocol_params = this._protocolParams; - } else { - spec.utxos = this._utxos; - spec.protocol_params = this._protocolParams; - } - - if (this._signerCount !== null) spec.signer_count = this._signerCount; - if (Object.keys(this._validity).length > 0) spec.validity = this._validity; - if (this._mergeOutputs !== null) spec.merge_outputs = this._mergeOutputs; - - const specJson = JSON.stringify(spec); - const rc = this._b._lib.ccl_quicktx_build(this._b._thread, cstr(specJson)); - return JSON.parse(this._b._check(rc)); - } - - async buildWithProvider(provider) { - if (this._utxos === null) { - const addresses = new Set(); - for (const tx of this._txs) { - if (tx._from) addresses.add(tx._from); - } - const allUtxos = []; - for (const addr of addresses) { - const utxos = await provider.getUtxos(addr); - allUtxos.push(...utxos); - } - this._utxos = allUtxos; - } - if (this._protocolParams === null) { - this._protocolParams = await provider.getProtocolParams(); - } - return this.build(); - } -} - -export class ScriptTxBuilder { - constructor(bridge) { - this._b = bridge; - this._operations = []; - this._from = null; - this._changeAddress = null; - this._feePayer = null; - this._utxos = null; - this._protocolParams = null; - this._validity = {}; - this._mergeOutputs = null; - this._signerCount = 1; - this._changeDatumCborHex = null; - this._changeDatumHash = null; - } - - payToAddress(address, ...args) { - let amounts = args; - let options = {}; - if (args.length > 0) { - const last = args[args.length - 1]; - if (last && typeof last === 'object' && !last.unit) { - options = args[args.length - 1]; - amounts = args.slice(0, -1); - } - } - const op = { - type: 'pay_to_address', - address, - amounts: [...amounts], - }; - if (options.scriptRefCborHex) op.script_ref_cbor_hex = options.scriptRefCborHex; - if (options.scriptRefType) op.script_ref_type = options.scriptRefType; - this._operations.push(op); - return this; - } - - payToContract(address, amounts, { datumCborHex, datumHash, scriptRefCborHex, scriptRefType } = {}) { - const op = { - type: 'pay_to_contract', - address, - amounts: Array.isArray(amounts) ? amounts : [amounts], - }; - if (datumCborHex) op.datum_cbor_hex = datumCborHex; - if (datumHash) op.datum_hash = datumHash; - if (scriptRefCborHex) op.script_ref_cbor_hex = scriptRefCborHex; - if (scriptRefType) op.script_ref_type = scriptRefType; - this._operations.push(op); - return this; - } - - attachMetadata(label, metadata) { - this._operations.push({ - type: 'attach_metadata', - label, - metadata, - }); - return this; - } - - collectFrom(utxos) { - this._operations.push({ - type: 'collect_from', - collect_utxos: utxos, - }); - return this; - } - - collectFromScript(utxos, redeemerCborHex, datumCborHex = null) { - const op = { - type: 'collect_from', - collect_utxos: utxos, - redeemer_cbor_hex: redeemerCborHex, - }; - if (datumCborHex) op.datum_cbor_hex = datumCborHex; - this._operations.push(op); - return this; - } - - readFrom(referenceInputs) { - this._operations.push({ - type: 'read_from', - reference_inputs: referenceInputs.map(ri => ({ tx_hash: ri.txHash, output_index: ri.outputIndex })), - }); - return this; - } - - mintPlutusAssets(scriptCborHex, scriptType, assets, redeemerCborHex, receiver = null, outputDatumCborHex = null) { - const op = { - type: 'mint_plutus_assets', - script_cbor_hex: scriptCborHex, - script_type: scriptType, - assets, - redeemer_cbor_hex: redeemerCborHex, - }; - if (receiver) op.receiver = receiver; - if (outputDatumCborHex) op.output_datum_cbor_hex = outputDatumCborHex; - this._operations.push(op); - return this; - } - - attachSpendingValidator(scriptCborHex, scriptType) { - this._operations.push({ - type: 'attach_spending_validator', - script_cbor_hex: scriptCborHex, - script_type: scriptType, - }); - return this; - } - - attachCertificateValidator(scriptCborHex, scriptType) { - this._operations.push({ - type: 'attach_certificate_validator', - script_cbor_hex: scriptCborHex, - script_type: scriptType, - }); - return this; - } - - attachRewardValidator(scriptCborHex, scriptType) { - this._operations.push({ - type: 'attach_reward_validator', - script_cbor_hex: scriptCborHex, - script_type: scriptType, - }); - return this; - } - - attachProposingValidator(scriptCborHex, scriptType) { - this._operations.push({ - type: 'attach_proposing_validator', - script_cbor_hex: scriptCborHex, - script_type: scriptType, - }); - return this; - } - - attachVotingValidator(scriptCborHex, scriptType) { - this._operations.push({ - type: 'attach_voting_validator', - script_cbor_hex: scriptCborHex, - script_type: scriptType, - }); - return this; - } - - // Staking (with redeemer) - deregisterStakeAddress(address, redeemerCborHex, refundAddress = null) { - const op = { type: 'deregister_stake_address', address, redeemer_cbor_hex: redeemerCborHex }; - if (refundAddress) op.refund_address = refundAddress; - this._operations.push(op); - return this; - } - - delegateTo(address, poolId, redeemerCborHex) { - this._operations.push({ - type: 'delegate_to', address, pool_id: poolId, redeemer_cbor_hex: redeemerCborHex, - }); - return this; - } - - withdraw(rewardAddress, amount, redeemerCborHex, receiver = null) { - const op = { - type: 'withdraw', reward_address: rewardAddress, amount: String(amount), - redeemer_cbor_hex: redeemerCborHex, - }; - if (receiver) op.receiver = receiver; - this._operations.push(op); - return this; - } - - // DRep (with redeemer) - registerDRep(credentialHash, credentialType, redeemerCborHex, { anchorUrl, anchorDataHash } = {}) { - const op = { - type: 'register_drep', credential_hash: credentialHash, credential_type: credentialType, - redeemer_cbor_hex: redeemerCborHex, - }; - if (anchorUrl) op.anchor_url = anchorUrl; - if (anchorDataHash) op.anchor_data_hash = anchorDataHash; - this._operations.push(op); - return this; - } - - unregisterDRep(credentialHash, credentialType, redeemerCborHex, { refundAddress, refundAmount } = {}) { - const op = { - type: 'unregister_drep', credential_hash: credentialHash, credential_type: credentialType, - redeemer_cbor_hex: redeemerCborHex, - }; - if (refundAddress) op.refund_address = refundAddress; - if (refundAmount != null) op.refund_amount = String(refundAmount); - this._operations.push(op); - return this; - } - - updateDRep(credentialHash, credentialType, redeemerCborHex, { anchorUrl, anchorDataHash } = {}) { - const op = { - type: 'update_drep', credential_hash: credentialHash, credential_type: credentialType, - redeemer_cbor_hex: redeemerCborHex, - }; - if (anchorUrl) op.anchor_url = anchorUrl; - if (anchorDataHash) op.anchor_data_hash = anchorDataHash; - this._operations.push(op); - return this; - } - - // Voting (with redeemer) - delegateVotingPowerTo(address, drepType, drepHash, redeemerCborHex) { - this._operations.push({ - type: 'delegate_voting_power_to', address, drep_type: drepType, - drep_hash: drepHash, redeemer_cbor_hex: redeemerCborHex, - }); - return this; - } - - createVote(voterType, voterHash, govActionTxHash, govActionIndex, vote, redeemerCborHex, { anchorUrl, anchorDataHash } = {}) { - const op = { - type: 'create_vote', voter_type: voterType, voter_hash: voterHash, - gov_action_tx_hash: govActionTxHash, gov_action_index: govActionIndex, vote, - redeemer_cbor_hex: redeemerCborHex, - }; - if (anchorUrl) op.anchor_url = anchorUrl; - if (anchorDataHash) op.anchor_data_hash = anchorDataHash; - this._operations.push(op); - return this; - } - - // Governance (with redeemer) - createProposal(govActionType, returnAddress, anchorUrl, anchorDataHash, redeemerCborHex, options = {}) { - const op = { - type: 'create_proposal', gov_action_type: govActionType, - return_address: returnAddress, anchor_url: anchorUrl, anchor_data_hash: anchorDataHash, - redeemer_cbor_hex: redeemerCborHex, - }; - if (options.withdrawals) op.withdrawals = options.withdrawals; - if (options.govActionTxHash) op.gov_action_tx_hash = options.govActionTxHash; - if (options.govActionIndex != null) op.gov_action_index = options.govActionIndex; - if (options.membersToRemove) op.members_to_remove = options.membersToRemove; - if (options.newMembers) op.new_members = options.newMembers; - if (options.quorumNumerator != null) op.quorum_numerator = String(options.quorumNumerator); - if (options.quorumDenominator != null) op.quorum_denominator = String(options.quorumDenominator); - if (options.constitutionAnchorUrl) op.constitution_anchor_url = options.constitutionAnchorUrl; - if (options.constitutionAnchorDataHash) op.constitution_anchor_data_hash = options.constitutionAnchorDataHash; - if (options.constitutionScriptHash) op.constitution_script_hash = options.constitutionScriptHash; - if (options.protocolVersionMajor != null) op.protocol_version_major = options.protocolVersionMajor; - if (options.protocolVersionMinor != null) op.protocol_version_minor = options.protocolVersionMinor; - if (options.policyHash) op.policy_hash = options.policyHash; - this._operations.push(op); - return this; - } - - // Treasury donation (with redeemer) - donateToTreasury(treasuryValue, donationAmount, redeemerCborHex) { - this._operations.push({ - type: 'donate_to_treasury', - treasury_value: String(treasuryValue), - donation_amount: String(donationAmount), - redeemer_cbor_hex: redeemerCborHex, - }); - return this; - } - - from(address) { - this._from = address; - return this; - } - - changeAddress(address) { - this._changeAddress = address; - return this; - } - - changeDatum(datumCborHex) { - this._changeDatumCborHex = datumCborHex; - return this; - } - - changeDatumHash(hash) { - this._changeDatumHash = hash; - return this; - } - - feePayer(address) { - this._feePayer = address; - return this; - } - - withUtxos(utxos) { - this._utxos = utxos; - return this; - } - - withProtocolParams(params) { - this._protocolParams = params; - return this; - } - - validFrom(slot) { - this._validity.valid_from = slot; - return this; - } - - validTo(slot) { - this._validity.valid_to = slot; - return this; - } - - mergeOutputs(merge) { - this._mergeOutputs = merge; - return this; - } - - signerCount(count) { - this._signerCount = count; - return this; - } - - build(providerConfig = null) { - const spec = { - tx_type: 'script_tx', - operations: this._operations, - from: this._from, - signer_count: this._signerCount, - }; - - if (providerConfig) { - spec.provider = { name: providerConfig.name, url: providerConfig.url }; - if (providerConfig.apiKey) spec.provider.api_key = providerConfig.apiKey; - if (providerConfig.enableCostEvaluation !== undefined) spec.provider.enable_cost_evaluation = providerConfig.enableCostEvaluation; - if (this._protocolParams !== null) spec.protocol_params = this._protocolParams; - } else { - spec.utxos = this._utxos; - spec.protocol_params = this._protocolParams; - } - - if (this._changeAddress) spec.change_address = this._changeAddress; - if (this._feePayer) spec.fee_payer = this._feePayer; - if (Object.keys(this._validity).length > 0) spec.validity = this._validity; - if (this._mergeOutputs !== null) spec.merge_outputs = this._mergeOutputs; - if (this._changeDatumCborHex) spec.change_datum_cbor_hex = this._changeDatumCborHex; - if (this._changeDatumHash) spec.change_datum_hash = this._changeDatumHash; - - const specJson = JSON.stringify(spec); - const rc = this._b._lib.ccl_quicktx_build(this._b._thread, cstr(specJson)); - return JSON.parse(this._b._check(rc)); - } - - async buildWithProvider(provider) { - if (this._utxos === null && this._from) { - this._utxos = await provider.getUtxos(this._from); - } - if (this._protocolParams === null) { - this._protocolParams = await provider.getProtocolParams(); - } - return this.build(); - } -} - -export class ScriptTx { - constructor() { - this._operations = []; - this._from = null; - this._changeAddress = null; - this._changeDatumCborHex = null; - this._changeDatumHash = null; - } - - payToAddress(address, ...args) { - let amounts = args; - let options = {}; - if (args.length > 0) { - const last = args[args.length - 1]; - if (last && typeof last === 'object' && !last.unit) { - options = args[args.length - 1]; - amounts = args.slice(0, -1); - } - } - const op = { - type: 'pay_to_address', - address, - amounts: [...amounts], - }; - if (options.scriptRefCborHex) op.script_ref_cbor_hex = options.scriptRefCborHex; - if (options.scriptRefType) op.script_ref_type = options.scriptRefType; - this._operations.push(op); - return this; - } - - payToContract(address, amounts, { datumCborHex, datumHash, scriptRefCborHex, scriptRefType } = {}) { - const op = { - type: 'pay_to_contract', - address, - amounts: Array.isArray(amounts) ? amounts : [amounts], - }; - if (datumCborHex) op.datum_cbor_hex = datumCborHex; - if (datumHash) op.datum_hash = datumHash; - if (scriptRefCborHex) op.script_ref_cbor_hex = scriptRefCborHex; - if (scriptRefType) op.script_ref_type = scriptRefType; - this._operations.push(op); - return this; - } - - attachMetadata(label, metadata) { - this._operations.push({ - type: 'attach_metadata', - label, - metadata, - }); - return this; - } - - collectFrom(utxos) { - this._operations.push({ - type: 'collect_from', - collect_utxos: utxos, - }); - return this; - } - - collectFromScript(utxos, redeemerCborHex, datumCborHex = null) { - const op = { - type: 'collect_from', - collect_utxos: utxos, - redeemer_cbor_hex: redeemerCborHex, - }; - if (datumCborHex) op.datum_cbor_hex = datumCborHex; - this._operations.push(op); - return this; - } - - readFrom(referenceInputs) { - this._operations.push({ - type: 'read_from', - reference_inputs: referenceInputs.map(ri => ({ tx_hash: ri.txHash, output_index: ri.outputIndex })), - }); - return this; - } - - mintPlutusAssets(scriptCborHex, scriptType, assets, redeemerCborHex, receiver = null, outputDatumCborHex = null) { - const op = { - type: 'mint_plutus_assets', - script_cbor_hex: scriptCborHex, - script_type: scriptType, - assets, - redeemer_cbor_hex: redeemerCborHex, - }; - if (receiver) op.receiver = receiver; - if (outputDatumCborHex) op.output_datum_cbor_hex = outputDatumCborHex; - this._operations.push(op); - return this; - } - - attachSpendingValidator(scriptCborHex, scriptType) { - this._operations.push({ - type: 'attach_spending_validator', - script_cbor_hex: scriptCborHex, - script_type: scriptType, - }); - return this; - } - - attachCertificateValidator(scriptCborHex, scriptType) { - this._operations.push({ - type: 'attach_certificate_validator', - script_cbor_hex: scriptCborHex, - script_type: scriptType, - }); - return this; - } - - attachRewardValidator(scriptCborHex, scriptType) { - this._operations.push({ - type: 'attach_reward_validator', - script_cbor_hex: scriptCborHex, - script_type: scriptType, - }); - return this; - } - - attachProposingValidator(scriptCborHex, scriptType) { - this._operations.push({ - type: 'attach_proposing_validator', - script_cbor_hex: scriptCborHex, - script_type: scriptType, - }); - return this; - } - - attachVotingValidator(scriptCborHex, scriptType) { - this._operations.push({ - type: 'attach_voting_validator', - script_cbor_hex: scriptCborHex, - script_type: scriptType, - }); - return this; - } - - // Staking (with redeemer) - deregisterStakeAddress(address, redeemerCborHex, refundAddress = null) { - const op = { type: 'deregister_stake_address', address, redeemer_cbor_hex: redeemerCborHex }; - if (refundAddress) op.refund_address = refundAddress; - this._operations.push(op); - return this; - } - - delegateTo(address, poolId, redeemerCborHex) { - this._operations.push({ - type: 'delegate_to', address, pool_id: poolId, redeemer_cbor_hex: redeemerCborHex, - }); - return this; - } - - withdraw(rewardAddress, amount, redeemerCborHex, receiver = null) { - const op = { - type: 'withdraw', reward_address: rewardAddress, amount: String(amount), - redeemer_cbor_hex: redeemerCborHex, - }; - if (receiver) op.receiver = receiver; - this._operations.push(op); - return this; - } - - // DRep (with redeemer) - registerDRep(credentialHash, credentialType, redeemerCborHex, { anchorUrl, anchorDataHash } = {}) { - const op = { - type: 'register_drep', credential_hash: credentialHash, credential_type: credentialType, - redeemer_cbor_hex: redeemerCborHex, - }; - if (anchorUrl) op.anchor_url = anchorUrl; - if (anchorDataHash) op.anchor_data_hash = anchorDataHash; - this._operations.push(op); - return this; - } - - unregisterDRep(credentialHash, credentialType, redeemerCborHex, { refundAddress, refundAmount } = {}) { - const op = { - type: 'unregister_drep', credential_hash: credentialHash, credential_type: credentialType, - redeemer_cbor_hex: redeemerCborHex, - }; - if (refundAddress) op.refund_address = refundAddress; - if (refundAmount != null) op.refund_amount = String(refundAmount); - this._operations.push(op); - return this; - } - - updateDRep(credentialHash, credentialType, redeemerCborHex, { anchorUrl, anchorDataHash } = {}) { - const op = { - type: 'update_drep', credential_hash: credentialHash, credential_type: credentialType, - redeemer_cbor_hex: redeemerCborHex, - }; - if (anchorUrl) op.anchor_url = anchorUrl; - if (anchorDataHash) op.anchor_data_hash = anchorDataHash; - this._operations.push(op); - return this; - } - - // Voting (with redeemer) - delegateVotingPowerTo(address, drepType, drepHash, redeemerCborHex) { - this._operations.push({ - type: 'delegate_voting_power_to', address, drep_type: drepType, - drep_hash: drepHash, redeemer_cbor_hex: redeemerCborHex, - }); - return this; - } - - createVote(voterType, voterHash, govActionTxHash, govActionIndex, vote, redeemerCborHex, { anchorUrl, anchorDataHash } = {}) { - const op = { - type: 'create_vote', voter_type: voterType, voter_hash: voterHash, - gov_action_tx_hash: govActionTxHash, gov_action_index: govActionIndex, vote, - redeemer_cbor_hex: redeemerCborHex, - }; - if (anchorUrl) op.anchor_url = anchorUrl; - if (anchorDataHash) op.anchor_data_hash = anchorDataHash; - this._operations.push(op); - return this; - } - - // Governance (with redeemer) - createProposal(govActionType, returnAddress, anchorUrl, anchorDataHash, redeemerCborHex, options = {}) { - const op = { - type: 'create_proposal', gov_action_type: govActionType, - return_address: returnAddress, anchor_url: anchorUrl, anchor_data_hash: anchorDataHash, - redeemer_cbor_hex: redeemerCborHex, - }; - if (options.withdrawals) op.withdrawals = options.withdrawals; - if (options.govActionTxHash) op.gov_action_tx_hash = options.govActionTxHash; - if (options.govActionIndex != null) op.gov_action_index = options.govActionIndex; - if (options.membersToRemove) op.members_to_remove = options.membersToRemove; - if (options.newMembers) op.new_members = options.newMembers; - if (options.quorumNumerator != null) op.quorum_numerator = String(options.quorumNumerator); - if (options.quorumDenominator != null) op.quorum_denominator = String(options.quorumDenominator); - if (options.constitutionAnchorUrl) op.constitution_anchor_url = options.constitutionAnchorUrl; - if (options.constitutionAnchorDataHash) op.constitution_anchor_data_hash = options.constitutionAnchorDataHash; - if (options.constitutionScriptHash) op.constitution_script_hash = options.constitutionScriptHash; - if (options.protocolVersionMajor != null) op.protocol_version_major = options.protocolVersionMajor; - if (options.protocolVersionMinor != null) op.protocol_version_minor = options.protocolVersionMinor; - if (options.policyHash) op.policy_hash = options.policyHash; - this._operations.push(op); - return this; - } - - // Treasury donation (with redeemer) - donateToTreasury(treasuryValue, donationAmount, redeemerCborHex) { - this._operations.push({ - type: 'donate_to_treasury', - treasury_value: String(treasuryValue), - donation_amount: String(donationAmount), - redeemer_cbor_hex: redeemerCborHex, - }); - return this; - } - - from(address) { - this._from = address; - return this; - } - - changeAddress(address) { - this._changeAddress = address; - return this; - } - - changeDatum(datumCborHex) { - this._changeDatumCborHex = datumCborHex; - return this; - } - - changeDatumHash(hash) { - this._changeDatumHash = hash; - return this; } - _toSpec() { - const spec = { - tx_type: 'script_tx', - from: this._from, - operations: this._operations, - }; - if (this._changeAddress) spec.change_address = this._changeAddress; - if (this._changeDatumCborHex) spec.change_datum_cbor_hex = this._changeDatumCborHex; - if (this._changeDatumHash) spec.change_datum_hash = this._changeDatumHash; - return spec; + /** + * Build an unsigned transaction from a CCL TxPlan (YAML), fully offline. + * + * @param {string} txplanYaml - the TxPlan YAML document defining the transaction(s). + * @param {Array} utxos - UTXOs (CCL Utxo model) available to the sender. + * @param {object} protocolParams - protocol parameters (CCL ProtocolParams model). + * @param {Array<{mem: (number|string), steps: (number|string)}>} [execUnits] - optional redeemer + * execution units (one per redeemer, in transaction order) for Plutus script transactions. + * Compute these with any evaluator (Ogmios, Blockfrost, Aiken, Scalus); the bridge does not run + * the script. + * @returns {{tx_cbor: string, tx_hash: string, fee: string}} + */ + build(txplanYaml, utxos, protocolParams, execUnits = null) { + const rc = this._b._lib.ccl_quicktx_build( + this._b._thread, + cstr(txplanYaml), + cstr(JSON.stringify(utxos)), + cstr(JSON.stringify(protocolParams)), + execUnits != null ? cstr(JSON.stringify(execUnits)) : null, + ); + // The build result is a YAML document. + return parseYaml(this._b._check(rc)); } } diff --git a/wrappers/js/src/provider.js b/wrappers/js/src/provider.js deleted file mode 100644 index 4589548..0000000 --- a/wrappers/js/src/provider.js +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Provider abstraction for fetching UTXOs, protocol params, and submitting transactions. - * - * Providers allow TxBuilder to automatically fetch required data from a backend - * (DevKit, Blockfrost, Koios, etc.) instead of requiring manual fetching. - */ - -export class Provider { - async getUtxos(address) { - throw new Error('not implemented'); - } - - async getProtocolParams() { - throw new Error('not implemented'); - } - - async submitTx(txCborHex) { - throw new Error('not implemented'); - } -} - -export class YaciDevKitProvider extends Provider { - constructor(baseUrl = "http://localhost:8080/api/v1", adminBaseUrl = "http://localhost:10000/local-cluster/api") { - super(); - this.baseUrl = baseUrl; - this.adminBaseUrl = adminBaseUrl; - } - - async getUtxos(address) { - const resp = await fetch(`${this.baseUrl}/addresses/${address}/utxos`); - return resp.json(); - } - - async getProtocolParams() { - const resp = await fetch(`${this.baseUrl}/epochs/parameters`); - return resp.json(); - } - - async submitTx(txCborHex) { - const txBytes = Buffer.from(txCborHex, "hex"); - const resp = await fetch(`${this.baseUrl}/tx/submit`, { - method: "POST", - headers: { "Content-Type": "application/cbor" }, - body: txBytes, - }); - const text = await resp.text(); - return text.trim().replace(/"/g, ""); - } - - // Convenience methods (not part of Provider interface) - - async topup(address, adaAmount = 100) { - const resp = await fetch(`${this.adminBaseUrl}/addresses/topup`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ address, adaAmount }), - }); - return resp.json(); - } - - async reset() { - const resp = await fetch(`${this.adminBaseUrl}/admin/devnet/reset`, { - method: "POST", - }); - return resp.status; - } - - async waitForBlock(ms = 2000) { - return new Promise((resolve) => setTimeout(resolve, ms)); - } - - async isAvailable() { - try { - const resp = await fetch(`${this.adminBaseUrl}/admin/devnet`, { - signal: AbortSignal.timeout(3000), - }); - return resp.status === 200; - } catch { - return false; - } - } -} diff --git a/wrappers/js/test/ccl.test.js b/wrappers/js/test/ccl.test.js index 40fca71..b403566 100644 --- a/wrappers/js/test/ccl.test.js +++ b/wrappers/js/test/ccl.test.js @@ -1,5 +1,5 @@ import { describe, it, expect, beforeAll, afterAll } from 'bun:test'; -import { CclBridge, CclError, MAINNET, TESTNET, Amount } from '../src/index.js'; +import { CclBridge, CclError, MAINNET, TESTNET } from '../src/index.js'; // A known valid transaction CBOR hex (built from Java tests) const SAMPLE_TX_CBOR = '84a300d901028182582073198b7ad003862b9798106b88fbccfca464b1a38afb34958275c4a7d7d8d002010181825839009493315cd92eb5d8c4304e67b7e16ae36d61d34502694657811a2c8e32c728d3861e164cab28cb8f006448139c8f1740ffb8e7aa9e5232dc1a001e8480021a00029810a0f5f6'; @@ -294,413 +294,84 @@ describe('CCL Bridge', () => { }]; } - it('should build simple ADA payment', () => { - const sender = bridge.account.create(TESTNET); - const receiver = bridge.account.create(TESTNET); - - const result = bridge.quicktx.newTx() - .payToAddress(receiver.base_address, Amount.ada(5)) - .from(sender.base_address) - .withUtxos(makeUtxos(sender.base_address)) - .withProtocolParams(PROTOCOL_PARAMS) - .build(); + function paymentYaml(from, to, quantity) { + return ` +version: 1.0 +transaction: + - tx: + from: ${from} + intents: + - type: payment + address: ${to} + amounts: + - unit: lovelace + quantity: "${quantity}" +`; + } + function assertBuilt(result) { expect(result.tx_cbor.length).toBeGreaterThan(0); expect(result.tx_hash.length).toBe(64); - expect(Number(result.fee)).toBeGreaterThan(0); + expect(parseInt(result.fee, 10)).toBeGreaterThan(0); + } + + it('should build a simple payment from TxPlan YAML', () => { + const sender = bridge.account.create(TESTNET); + const receiver = bridge.account.create(TESTNET); + const yaml = paymentYaml(sender.base_address, receiver.base_address, '5000000'); + assertBuilt(bridge.quicktx.build(yaml, makeUtxos(sender.base_address), PROTOCOL_PARAMS)); }); it('should build multiple payments', () => { const sender = bridge.account.create(TESTNET); const r1 = bridge.account.create(TESTNET); const r2 = bridge.account.create(TESTNET); - - const result = bridge.quicktx.newTx() - .payToAddress(r1.base_address, Amount.ada(5)) - .payToAddress(r2.base_address, Amount.ada(3)) - .from(sender.base_address) - .withUtxos(makeUtxos(sender.base_address)) - .withProtocolParams(PROTOCOL_PARAMS) - .build(); - - expect(result.tx_hash.length).toBe(64); - }); - - it('should build tx with metadata', () => { + const yaml = ` +version: 1.0 +transaction: + - tx: + from: ${sender.base_address} + intents: + - type: payment + address: ${r1.base_address} + amounts: + - unit: lovelace + quantity: "5000000" + - type: payment + address: ${r2.base_address} + amounts: + - unit: lovelace + quantity: "3000000" +`; + assertBuilt(bridge.quicktx.build(yaml, makeUtxos(sender.base_address), PROTOCOL_PARAMS)); + }); + + it('should substitute variables', () => { const sender = bridge.account.create(TESTNET); const receiver = bridge.account.create(TESTNET); - - const result = bridge.quicktx.newTx() - .payToAddress(receiver.base_address, Amount.ada(2)) - .attachMetadata(674, { msg: ['Hello from JS'] }) - .from(sender.base_address) - .withUtxos(makeUtxos(sender.base_address)) - .withProtocolParams(PROTOCOL_PARAMS) - .build(); - - expect(result.tx_cbor.length).toBeGreaterThan(0); - }); - - it('should build tx with validity interval', () => { - const sender = bridge.account.create(TESTNET); - const receiver = bridge.account.create(TESTNET); - - const result = bridge.quicktx.newTx() - .payToAddress(receiver.base_address, Amount.ada(2)) - .from(sender.base_address) - .withUtxos(makeUtxos(sender.base_address)) - .withProtocolParams(PROTOCOL_PARAMS) - .validFrom(1000) - .validTo(50000) - .build(); - - expect(result.tx_cbor.length).toBeGreaterThan(0); + const yaml = ` +version: 1.0 +variables: + to: ${receiver.base_address} + amount: "4000000" +transaction: + - tx: + from: ${sender.base_address} + intents: + - type: payment + address: \${to} + amounts: + - unit: lovelace + quantity: \${amount} +`; + assertBuilt(bridge.quicktx.build(yaml, makeUtxos(sender.base_address), PROTOCOL_PARAMS)); }); it('should throw on insufficient funds', () => { const sender = bridge.account.create(TESTNET); const receiver = bridge.account.create(TESTNET); - - expect(() => { - bridge.quicktx.newTx() - .payToAddress(receiver.base_address, Amount.ada(200)) - .from(sender.base_address) - .withUtxos(makeUtxos(sender.base_address, 1_000_000)) - .withProtocolParams(PROTOCOL_PARAMS) - .build(); - }).toThrow(); - }); - - it('should build multi-asset payment', () => { - const sender = bridge.account.create(TESTNET); - const receiver = bridge.account.create(TESTNET); - const policyId = 'a'.repeat(56); - const unit = policyId + '546f6b656e'; - - const utxos = [{ - tx_hash: FAKE_TX_HASH, - output_index: 0, - address: sender.base_address, - amount: [ - { unit: 'lovelace', quantity: '100000000' }, - { unit, quantity: '500' }, - ], - }]; - - const result = bridge.quicktx.newTx() - .payToAddress(receiver.base_address, Amount.lovelace(2_000_000), Amount.asset(unit, 100)) - .from(sender.base_address) - .withUtxos(utxos) - .withProtocolParams(PROTOCOL_PARAMS) - .build(); - - expect(result.tx_hash.length).toBe(64); - }); - - it('Amount helpers should produce correct values', () => { - expect(Amount.ada(5)).toEqual({ unit: 'lovelace', quantity: '5000000' }); - expect(Amount.lovelace(2000000)).toEqual({ unit: 'lovelace', quantity: '2000000' }); - expect(Amount.asset('abc123', 100)).toEqual({ unit: 'abc123', quantity: '100' }); - }); - - // --- Compose (multi-Tx) --- - - it('should compose two senders into one transaction', () => { - const sender1 = bridge.account.create(TESTNET); - const sender2 = bridge.account.create(TESTNET); - const r1 = bridge.account.create(TESTNET); - const r2 = bridge.account.create(TESTNET); - - const tx1 = bridge.quicktx.tx() - .payToAddress(r1.base_address, Amount.ada(5)) - .from(sender1.base_address); - - const tx2 = bridge.quicktx.tx() - .payToAddress(r2.base_address, Amount.ada(3)) - .from(sender2.base_address); - - const utxos = [ - { - tx_hash: FAKE_TX_HASH, - output_index: 0, - address: sender1.base_address, - amount: [{ unit: 'lovelace', quantity: '100000000' }], - }, - { - tx_hash: 'b'.repeat(64), - output_index: 0, - address: sender2.base_address, - amount: [{ unit: 'lovelace', quantity: '100000000' }], - }, - ]; - - const result = bridge.quicktx.compose(tx1, tx2) - .feePayer(sender1.base_address) - .withUtxos(utxos) - .withProtocolParams(PROTOCOL_PARAMS) - .signerCount(2) - .build(); - - expect(result.tx_cbor.length).toBeGreaterThan(0); - expect(result.tx_hash.length).toBe(64); - expect(Number(result.fee)).toBeGreaterThan(0); - }); - - // --- Staking --- - - it('should build register stake address tx', () => { - const sender = bridge.account.create(TESTNET); - - const result = bridge.quicktx.newTx() - .registerStakeAddress(sender.base_address) - .from(sender.base_address) - .withUtxos(makeUtxos(sender.base_address)) - .withProtocolParams(PROTOCOL_PARAMS) - .build(); - - expect(result.tx_cbor.length).toBeGreaterThan(0); - expect(result.tx_hash.length).toBe(64); - }); - - it('should build deregister stake address tx', () => { - const sender = bridge.account.create(TESTNET); - - const result = bridge.quicktx.newTx() - .deregisterStakeAddress(sender.base_address) - .from(sender.base_address) - .withUtxos(makeUtxos(sender.base_address)) - .withProtocolParams(PROTOCOL_PARAMS) - .build(); - - expect(result.tx_cbor.length).toBeGreaterThan(0); - expect(result.tx_hash.length).toBe(64); - }); - - it('should build delegate to pool tx', () => { - const sender = bridge.account.create(TESTNET); - const poolId = 'pool1pu5jlj4q9w9jlxeu370a3c9myx47md5j5m2str0naunn2q3lkdy'; - - const result = bridge.quicktx.newTx() - .delegateTo(sender.base_address, poolId) - .from(sender.base_address) - .withUtxos(makeUtxos(sender.base_address)) - .withProtocolParams(PROTOCOL_PARAMS) - .build(); - - expect(result.tx_cbor.length).toBeGreaterThan(0); - expect(result.tx_hash.length).toBe(64); - }); - - it('should build withdraw rewards tx', () => { - const sender = bridge.account.create(TESTNET); - const info = bridge.account.fromMnemonic(sender.mnemonic, TESTNET); - - const result = bridge.quicktx.newTx() - .withdraw(info.stake_address, '5000000') - .from(sender.base_address) - .withUtxos(makeUtxos(sender.base_address)) - .withProtocolParams(PROTOCOL_PARAMS) - .build(); - - expect(result.tx_cbor.length).toBeGreaterThan(0); - expect(result.tx_hash.length).toBe(64); - }); - - // --- DRep --- - - it('should build register DRep tx', () => { - const sender = bridge.account.create(TESTNET); - const credentialHash = 'ab'.repeat(28); - - const result = bridge.quicktx.newTx() - .registerDRep(credentialHash, 'key') - .from(sender.base_address) - .withUtxos(makeUtxos(sender.base_address)) - .withProtocolParams(PROTOCOL_PARAMS) - .build(); - - expect(result.tx_cbor.length).toBeGreaterThan(0); - expect(result.tx_hash.length).toBe(64); - }); - - it('should build register DRep with anchor tx', () => { - const sender = bridge.account.create(TESTNET); - const credentialHash = 'ab'.repeat(28); - const dataHash = 'cd'.repeat(32); - - const result = bridge.quicktx.newTx() - .registerDRep(credentialHash, 'key', { - anchorUrl: 'https://example.com/drep.json', - anchorDataHash: dataHash, - }) - .from(sender.base_address) - .withUtxos(makeUtxos(sender.base_address)) - .withProtocolParams(PROTOCOL_PARAMS) - .build(); - - expect(result.tx_cbor.length).toBeGreaterThan(0); - expect(result.tx_hash.length).toBe(64); - }); - - it('should build unregister DRep tx', () => { - const sender = bridge.account.create(TESTNET); - const credentialHash = 'ab'.repeat(28); - - const result = bridge.quicktx.newTx() - .unregisterDRep(credentialHash, 'key') - .from(sender.base_address) - .withUtxos(makeUtxos(sender.base_address)) - .withProtocolParams(PROTOCOL_PARAMS) - .build(); - - expect(result.tx_cbor.length).toBeGreaterThan(0); - expect(result.tx_hash.length).toBe(64); - }); - - it('should build update DRep tx', () => { - const sender = bridge.account.create(TESTNET); - const credentialHash = 'ab'.repeat(28); - const dataHash = 'cd'.repeat(32); - - const result = bridge.quicktx.newTx() - .updateDRep(credentialHash, 'key', { - anchorUrl: 'https://example.com/drep-v2.json', - anchorDataHash: dataHash, - }) - .from(sender.base_address) - .withUtxos(makeUtxos(sender.base_address)) - .withProtocolParams(PROTOCOL_PARAMS) - .build(); - - expect(result.tx_cbor.length).toBeGreaterThan(0); - expect(result.tx_hash.length).toBe(64); - }); - - // --- Voting --- - - it('should build delegate voting power to key hash', () => { - const sender = bridge.account.create(TESTNET); - const drepHash = 'ab'.repeat(28); - - const result = bridge.quicktx.newTx() - .delegateVotingPowerTo(sender.base_address, 'key_hash', drepHash) - .from(sender.base_address) - .withUtxos(makeUtxos(sender.base_address)) - .withProtocolParams(PROTOCOL_PARAMS) - .build(); - - expect(result.tx_cbor.length).toBeGreaterThan(0); - expect(result.tx_hash.length).toBe(64); - }); - - it('should build delegate voting power to abstain', () => { - const sender = bridge.account.create(TESTNET); - - const result = bridge.quicktx.newTx() - .delegateVotingPowerTo(sender.base_address, 'abstain') - .from(sender.base_address) - .withUtxos(makeUtxos(sender.base_address)) - .withProtocolParams(PROTOCOL_PARAMS) - .build(); - - expect(result.tx_cbor.length).toBeGreaterThan(0); - expect(result.tx_hash.length).toBe(64); - }); - - it('should build create vote tx', () => { - const sender = bridge.account.create(TESTNET); - const voterHash = 'ab'.repeat(28); - const govTxHash = 'cd'.repeat(32); - - const result = bridge.quicktx.newTx() - .createVote('drep_key_hash', voterHash, govTxHash, 0, 'yes') - .from(sender.base_address) - .withUtxos(makeUtxos(sender.base_address)) - .withProtocolParams(PROTOCOL_PARAMS) - .build(); - - expect(result.tx_cbor.length).toBeGreaterThan(0); - expect(result.tx_hash.length).toBe(64); - }); - - it('should build create vote with anchor tx', () => { - const sender = bridge.account.create(TESTNET); - const voterHash = 'ab'.repeat(28); - const govTxHash = 'cd'.repeat(32); - const anchorDataHash = 'ef'.repeat(32); - - const result = bridge.quicktx.newTx() - .createVote('drep_key_hash', voterHash, govTxHash, 0, 'no', { - anchorUrl: 'https://example.com/rationale.json', - anchorDataHash, - }) - .from(sender.base_address) - .withUtxos(makeUtxos(sender.base_address)) - .withProtocolParams(PROTOCOL_PARAMS) - .build(); - - expect(result.tx_cbor.length).toBeGreaterThan(0); - expect(result.tx_hash.length).toBe(64); - }); - - // --- Governance proposals --- - - it('should build info action proposal', () => { - const sender = bridge.account.create(TESTNET); - const info = bridge.account.fromMnemonic(sender.mnemonic, TESTNET); - const anchorDataHash = 'ab'.repeat(32); - - const result = bridge.quicktx.newTx() - .createProposal('info_action', info.stake_address, - 'https://example.com/proposal.json', anchorDataHash) - .from(sender.base_address) - .withUtxos(makeUtxos(sender.base_address, 2_000_000_000)) - .withProtocolParams(PROTOCOL_PARAMS) - .build(); - - expect(result.tx_cbor.length).toBeGreaterThan(0); - expect(result.tx_hash.length).toBe(64); - }); - - it('should build treasury withdrawals proposal', () => { - const sender = bridge.account.create(TESTNET); - const info = bridge.account.fromMnemonic(sender.mnemonic, TESTNET); - const anchorDataHash = 'ab'.repeat(32); - - const result = bridge.quicktx.newTx() - .createProposal('treasury_withdrawals', info.stake_address, - 'https://example.com/proposal.json', anchorDataHash, { - withdrawals: [{ reward_address: info.stake_address, amount: '1000000' }], - }) - .from(sender.base_address) - .withUtxos(makeUtxos(sender.base_address, 2_000_000_000)) - .withProtocolParams(PROTOCOL_PARAMS) - .build(); - - expect(result.tx_cbor.length).toBeGreaterThan(0); - expect(result.tx_hash.length).toBe(64); - }); - - it('should throw on compose without fee_payer', () => { - const sender1 = bridge.account.create(TESTNET); - const sender2 = bridge.account.create(TESTNET); - const r1 = bridge.account.create(TESTNET); - const r2 = bridge.account.create(TESTNET); - - const tx1 = bridge.quicktx.tx() - .payToAddress(r1.base_address, Amount.ada(5)) - .from(sender1.base_address); - - const tx2 = bridge.quicktx.tx() - .payToAddress(r2.base_address, Amount.ada(3)) - .from(sender2.base_address); - - expect(() => { - bridge.quicktx.compose(tx1, tx2) - .withUtxos(makeUtxos(sender1.base_address)) - .withProtocolParams(PROTOCOL_PARAMS) - .build(); - }).toThrow(); + const yaml = paymentYaml(sender.base_address, receiver.base_address, '200000000'); + expect(() => bridge.quicktx.build(yaml, makeUtxos(sender.base_address, 1_000_000), PROTOCOL_PARAMS)).toThrow(); }); // --- Negative / Error Tests --- diff --git a/wrappers/js/test/compose.integration.test.js b/wrappers/js/test/compose.integration.test.js deleted file mode 100644 index 9c4358a..0000000 --- a/wrappers/js/test/compose.integration.test.js +++ /dev/null @@ -1,178 +0,0 @@ -/** - * Integration tests for QuickTx compose (multi-Tx) with Yaci DevKit. - * - * Requires: - * - Yaci DevKit running on port 10000 - * - Native library built: ./gradlew :core:nativeCompile - * - * Run with: - * CCL_LIB_PATH=core/build/native/nativeCompile bun test wrappers/js/test/compose.integration.test.js - */ -import { describe, it, expect, beforeAll, afterAll, setDefaultTimeout } from "bun:test"; -import { CclBridge, TESTNET, Amount } from "../src/index.js"; -import { DevKitHelper } from "./devkit-helper.js"; - -setDefaultTimeout(60_000); - -describe("QuickTx Compose Integration (DevKit)", () => { - let bridge; - let devkit; - let skip = false; - - beforeAll(async () => { - devkit = new DevKitHelper(); - const available = await devkit.isAvailable(); - if (!available) { - skip = true; - console.log("Skipping: Yaci DevKit not available on port 10000"); - return; - } - bridge = new CclBridge(); - await devkit.reset(); - await devkit.waitForBlock(3000); - }); - - afterAll(() => { - if (bridge) bridge.close(); - }); - - async function fundAccount(adaAmount = 150) { - const account = bridge.account.create(TESTNET); - await devkit.topup(account.base_address, adaAmount); - await devkit.waitForBlock(2000); - return account; - } - - function getLovelace(utxos) { - return utxos.reduce((sum, u) => { - const lovelace = u.amount.find((a) => a.unit === "lovelace"); - return sum + (lovelace ? Number(lovelace.quantity) : 0); - }, 0); - } - - it("should compose two senders, sign with both, submit and verify", async () => { - if (skip) return; - - const sender1 = await fundAccount(); - const sender2 = await fundAccount(); - const r1 = bridge.account.create(TESTNET); - const r2 = bridge.account.create(TESTNET); - - const tx1 = bridge.quicktx.tx() - .payToAddress(r1.base_address, Amount.ada(5)) - .from(sender1.base_address); - - const tx2 = bridge.quicktx.tx() - .payToAddress(r2.base_address, Amount.ada(3)) - .from(sender2.base_address); - - // Gather UTXOs for both senders - const utxos1 = await devkit.getUtxos(sender1.base_address); - const utxos2 = await devkit.getUtxos(sender2.base_address); - const pp = await devkit.getProtocolParams(); - - const result = bridge.quicktx.compose(tx1, tx2) - .feePayer(sender1.base_address) - .withUtxos([...utxos1, ...utxos2]) - .withProtocolParams(pp) - .signerCount(2) - .build(); - - expect(result.tx_cbor.length).toBeGreaterThan(0); - expect(result.tx_hash.length).toBe(64); - expect(Number(result.fee)).toBeGreaterThan(0); - - // Sign with both senders - let signed = bridge.account.signTx(sender1.mnemonic, TESTNET, 0, 0, result.tx_cbor); - signed = bridge.account.signTx(sender2.mnemonic, TESTNET, 0, 0, signed); - - // Submit - await devkit.submitTx(signed); - await devkit.waitForBlock(3000); - - // Verify both receivers - const r1Utxos = await devkit.getUtxos(r1.base_address); - const r2Utxos = await devkit.getUtxos(r2.base_address); - expect(getLovelace(r1Utxos)).toBe(5_000_000); - expect(getLovelace(r2Utxos)).toBe(3_000_000); - }); - - it("should compose with metadata", async () => { - if (skip) return; - - const sender1 = await fundAccount(); - const sender2 = await fundAccount(); - const r1 = bridge.account.create(TESTNET); - const r2 = bridge.account.create(TESTNET); - - const tx1 = bridge.quicktx.tx() - .payToAddress(r1.base_address, Amount.ada(5)) - .attachMetadata(674, { msg: ["Compose integration test"] }) - .from(sender1.base_address); - - const tx2 = bridge.quicktx.tx() - .payToAddress(r2.base_address, Amount.ada(3)) - .from(sender2.base_address); - - const utxos1 = await devkit.getUtxos(sender1.base_address); - const utxos2 = await devkit.getUtxos(sender2.base_address); - const pp = await devkit.getProtocolParams(); - - const result = bridge.quicktx.compose(tx1, tx2) - .feePayer(sender1.base_address) - .withUtxos([...utxos1, ...utxos2]) - .withProtocolParams(pp) - .signerCount(2) - .build(); - - let signed = bridge.account.signTx(sender1.mnemonic, TESTNET, 0, 0, result.tx_cbor); - signed = bridge.account.signTx(sender2.mnemonic, TESTNET, 0, 0, signed); - - await devkit.submitTx(signed); - await devkit.waitForBlock(3000); - - const txInfo = await devkit.getTx(result.tx_hash); - expect(txInfo).toBeTruthy(); - - const r1Utxos = await devkit.getUtxos(r1.base_address); - const r2Utxos = await devkit.getUtxos(r2.base_address); - expect(getLovelace(r1Utxos)).toBe(5_000_000); - expect(getLovelace(r2Utxos)).toBe(3_000_000); - }); - - it("should compose with provider for auto-fetching", async () => { - if (skip) return; - - const sender1 = await fundAccount(); - const sender2 = await fundAccount(); - const r1 = bridge.account.create(TESTNET); - const r2 = bridge.account.create(TESTNET); - - const tx1 = bridge.quicktx.tx() - .payToAddress(r1.base_address, Amount.ada(5)) - .from(sender1.base_address); - - const tx2 = bridge.quicktx.tx() - .payToAddress(r2.base_address, Amount.ada(3)) - .from(sender2.base_address); - - const result = await bridge.quicktx.compose(tx1, tx2) - .feePayer(sender1.base_address) - .signerCount(2) - .buildWithProvider(devkit); - - expect(result.tx_cbor.length).toBeGreaterThan(0); - expect(result.tx_hash.length).toBe(64); - - let signed = bridge.account.signTx(sender1.mnemonic, TESTNET, 0, 0, result.tx_cbor); - signed = bridge.account.signTx(sender2.mnemonic, TESTNET, 0, 0, signed); - - await devkit.submitTx(signed); - await devkit.waitForBlock(3000); - - const r1Utxos = await devkit.getUtxos(r1.base_address); - const r2Utxos = await devkit.getUtxos(r2.base_address); - expect(getLovelace(r1Utxos)).toBe(5_000_000); - expect(getLovelace(r2Utxos)).toBe(3_000_000); - }); -}); diff --git a/wrappers/js/test/intents.e2e.test.js b/wrappers/js/test/intents.e2e.test.js new file mode 100644 index 0000000..4ccdf5d --- /dev/null +++ b/wrappers/js/test/intents.e2e.test.js @@ -0,0 +1,101 @@ +// End-to-end coverage of every TxPlan intent through the native library. +// +// The fixtures in test-fixtures/quicktx-intents/ are generated by the JVM QuickTxIntentsTest via +// TxPlan.from(tx).toYaml(); here each is built through the native library via the JS wrapper. +// Mirrors the Go intents_test.go for cross-wrapper parity. + +import { describe, it, expect, beforeAll, afterAll } from "bun:test"; +import { CclBridge } from "../src/index.js"; +import { readdirSync, readFileSync } from "fs"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; + +const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), "../../../test-fixtures/quicktx-intents"); + +const SENDER = "addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp"; +const SENDER2 = "addr_test1qz7svwszky8gcmhrfza7a89z9u0dfzd3l7h23sqlc5yml7ejcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwqcqrvr0"; +const SCRIPT_ADDR = "addr_test1wpunlryvl7aqsxe22erzlsseej87v5kk5vutvtrmzdy8dect48z0w"; +const SCRIPT_DATUM_HASH = "9e1199a988ba72ffd6e9c269cadb3b53b5f360ff99f112d9b2ee30c4d74ad88b"; +const SCRIPT_TX_HASH = "b".repeat(64); +const EXEC_UNITS = [{ mem: 2000000, steps: 500000000 }]; + +// Protocol parameters incl. the Conway deposits the governance/staking/pool intents need. +const PROTOCOL_PARAMS = { + min_fee_a: 44, min_fee_b: 155381, max_tx_size: 16384, max_val_size: "5000", + key_deposit: "2000000", pool_deposit: "500000000", drep_deposit: "2000000", + gov_action_deposit: "1000000000", coins_per_utxo_size: "4310", + max_tx_ex_mem: "14000000", max_tx_ex_steps: "10000000000", + price_mem: 0.0577, price_step: 0.0000721, collateral_percent: 150, + max_collateral_inputs: 3, min_fee_ref_script_cost_per_byte: 15, +}; + +function utxos() { + return [ + { tx_hash: "a".repeat(64), output_index: 0, address: SENDER, + amount: [{ unit: "lovelace", quantity: "2000000000" }] }, + { tx_hash: "c".repeat(64), output_index: 0, address: SENDER, + amount: [{ unit: "lovelace", quantity: "5000000" }] }, + { tx_hash: "a".repeat(64), output_index: 1, address: SENDER2, + amount: [{ unit: "lovelace", quantity: "2000000000" }] }, + ]; +} + +// A successfully built tx: non-empty CBOR, a 64-char hash, and a positive fee. Mirrors the Python +// reference's _assert_built so the wrappers assert the same shape. +function assertBuilt(result) { + expect(result.tx_cbor.length).toBeGreaterThan(0); + expect(result.tx_hash.length).toBe(64); + expect(Number(result.fee)).toBeGreaterThan(0); +} + +describe("QuickTx intents E2E", () => { + let bridge; + beforeAll(() => { bridge = new CclBridge(); }); + afterAll(() => { if (bridge) bridge.close(); }); + + const fixtures = readdirSync(FIXTURES).filter((f) => f.endsWith(".yaml")).sort(); + + for (const f of fixtures) { + it(`builds ${f.replace(".yaml", "")}`, () => { + const yaml = readFileSync(join(FIXTURES, f), "utf8"); + assertBuilt(bridge.quicktx.build(yaml, utxos(), PROTOCOL_PARAMS)); + }); + } + + // --- Plutus / script paths (the plutus/ sub-directory, not covered by the top-level loop) --- + + it("builds a Plutus mint with execution units", () => { + const yaml = readFileSync(join(FIXTURES, "plutus", "script_minting.yaml"), "utf8"); + const u = [{ tx_hash: "a".repeat(64), output_index: 0, address: SENDER, + amount: [{ unit: "lovelace", quantity: "2000000000" }] }]; + assertBuilt(bridge.quicktx.build(yaml, u, PROTOCOL_PARAMS, EXEC_UNITS)); + // The Plutus path requires caller-supplied exec units; without them the build is rejected. + expect(() => bridge.quicktx.build(yaml, u, PROTOCOL_PARAMS)).toThrow(); + }); + + it("builds a Plutus spend with execution units", () => { + const yaml = readFileSync(join(FIXTURES, "plutus", "script_collect_from.yaml"), "utf8"); + const u = [ + { tx_hash: SCRIPT_TX_HASH, output_index: 0, address: SCRIPT_ADDR, + amount: [{ unit: "lovelace", quantity: "10000000" }], data_hash: SCRIPT_DATUM_HASH }, + { tx_hash: "a".repeat(64), output_index: 0, address: SENDER, + amount: [{ unit: "lovelace", quantity: "2000000000" }] }, + ]; + assertBuilt(bridge.quicktx.build(yaml, u, PROTOCOL_PARAMS, EXEC_UNITS)); + expect(() => bridge.quicktx.build(yaml, u, PROTOCOL_PARAMS)).toThrow(); + }); + + it("builds a Plutus lock (pay to a script address with a datum hash)", () => { + // Locking funds at a script address is a plain payment carrying a datum hash — no script runs, + // so no exec units are required (unlike the spend that later unlocks it). + const yaml = readFileSync(join(FIXTURES, "plutus", "plutus_lock.yaml"), "utf8"); + assertBuilt(bridge.quicktx.build(yaml, utxos(), PROTOCOL_PARAMS)); + }); + + it("derives the datum hash the lock fixture commits to", () => { + // plutus_lock.yaml locks under datum_hash 9e1199… — the Plutus data hash of the integer 42 + // (CBOR 182a). Assert the bridge computes the exact value the fixture embeds, tying the + // plutus.dataHash primitive to the on-fixture datum. + expect(bridge.plutus.dataHash("182a")).toBe(SCRIPT_DATUM_HASH); + }); +}); diff --git a/wrappers/js/test/new-features.integration.test.js b/wrappers/js/test/new-features.integration.test.js deleted file mode 100644 index 5af1a6c..0000000 --- a/wrappers/js/test/new-features.integration.test.js +++ /dev/null @@ -1,411 +0,0 @@ -/** - * Integration tests for new QuickTx features with Yaci DevKit. - * - * Tests reference scripts, governance action types, pool ops, treasury donation, - * native script attachment, and unregisterDRep refundAmount. - * - * Requires: - * - Yaci DevKit running on port 10000 - * - Native library built: ./gradlew :core:nativeCompile - * - * Run with: - * CCL_LIB_PATH=core/build/native/nativeCompile bun test wrappers/js/test/new-features.integration.test.js - */ -import { describe, it, expect, beforeAll, afterAll, setDefaultTimeout } from "bun:test"; -import { CclBridge, TESTNET, Amount } from "../src/index.js"; -import { DevKitHelper } from "./devkit-helper.js"; - -setDefaultTimeout(60_000); - -const ANCHOR_URL = "https://bit.ly/3zCH2HL"; -const ANCHOR_DATA_HASH = "cafef700c0039a2efb056a665b3a8bcd94f8670b88d659f7f3db68340f6f0937"; -const ALWAYS_TRUE_PLUTUS_V3 = "46450101002499"; -const DEVKIT_PROVIDER_URL = "http://localhost:10000/local-cluster/api"; - -describe("New QuickTx Features Integration (DevKit)", () => { - let bridge; - let devkit; - let skip = false; - - beforeAll(async () => { - devkit = new DevKitHelper(); - const available = await devkit.isAvailable(); - if (!available) { - skip = true; - console.log("Skipping: Yaci DevKit not available on port 10000"); - return; - } - bridge = new CclBridge(); - await devkit.reset(); - await devkit.waitForBlock(3000); - }); - - afterAll(() => { - if (bridge) bridge.close(); - }); - - async function fundAccount(ada = 500) { - const account = bridge.account.create(TESTNET); - await devkit.topup(account.base_address, ada); - await devkit.waitForBlock(2000); - return account; - } - - function buildSignSubmit(account, result) { - const signedTx = bridge.account.signTx( - account.mnemonic, TESTNET, 0, 0, result.tx_cbor - ); - return devkit.submitTx(signedTx); - } - - async function registerStake(account) { - const result = bridge.quicktx - .newTx() - .registerStakeAddress(account.stake_address) - .from(account.base_address) - .build({ name: "yaci", url: DEVKIT_PROVIDER_URL }); - await buildSignSubmit(account, result); - await devkit.waitForBlock(3000); - return result; - } - - // --- Full E2E tests (payment key signing only) --- - - it("should send ADA with reference script attached", async () => { - if (skip) return; - - const sender = await fundAccount(150); - const receiver = bridge.account.create(TESTNET); - - const result = bridge.quicktx - .newTx() - .payToAddress( - receiver.base_address, - Amount.ada(10), - { scriptRefCborHex: ALWAYS_TRUE_PLUTUS_V3, scriptRefType: "plutus_v3" } - ) - .from(sender.base_address) - .build({ name: "yaci", url: DEVKIT_PROVIDER_URL }); - - expect(result.tx_cbor.length).toBeGreaterThan(0); - expect(result.tx_hash.length).toBe(64); - expect(Number(result.fee)).toBeGreaterThan(0); - - await buildSignSubmit(sender, result); - await devkit.waitForBlock(3000); - - const receiverUtxos = await devkit.getUtxos(receiver.base_address); - const total = receiverUtxos.reduce((sum, u) => { - const lovelace = u.amount.find((a) => a.unit === "lovelace"); - return sum + (lovelace ? Number(lovelace.quantity) : 0); - }, 0); - expect(total).toBeGreaterThanOrEqual(10_000_000); - }); - - it("should attach native script to transaction", async () => { - if (skip) return; - - const sender = await fundAccount(150); - const receiver = bridge.account.create(TESTNET); - - // Get payment key hash from sender address - const addrInfo = bridge.address.info(sender.base_address); - const keyHash = addrInfo.payment_credential_hash; - - const nativeScript = { type: "sig", keyHash }; - - const result = bridge.quicktx - .newTx() - .payToAddress(receiver.base_address, Amount.ada(5)) - .attachNativeScript(nativeScript) - .from(sender.base_address) - .build({ name: "yaci", url: DEVKIT_PROVIDER_URL }); - - expect(result.tx_cbor.length).toBeGreaterThan(0); - expect(result.tx_hash.length).toBe(64); - - await buildSignSubmit(sender, result); - await devkit.waitForBlock(3000); - - const txInfo = await devkit.getTx(result.tx_hash); - expect(txInfo).toBeTruthy(); - }); - - it("should register stake address", async () => { - if (skip) return; - - const sender = await fundAccount(); - - const result = bridge.quicktx - .newTx() - .registerStakeAddress(sender.stake_address) - .from(sender.base_address) - .build({ name: "yaci", url: DEVKIT_PROVIDER_URL }); - - expect(result.tx_cbor.length).toBeGreaterThan(0); - expect(Number(result.fee)).toBeGreaterThan(0); - - await buildSignSubmit(sender, result); - await devkit.waitForBlock(3000); - - const txInfo = await devkit.getTx(result.tx_hash); - expect(txInfo).toBeTruthy(); - }); - - it("should delegate voting power to always_abstain", async () => { - if (skip) return; - - const sender = await fundAccount(); - await registerStake(sender); - - const result = bridge.quicktx - .newTx() - .delegateVotingPowerTo(sender.stake_address, "abstain") - .from(sender.base_address) - .build({ name: "yaci", url: DEVKIT_PROVIDER_URL }); - - expect(result.tx_cbor.length).toBeGreaterThan(0); - - await buildSignSubmit(sender, result); - await devkit.waitForBlock(3000); - - const txInfo = await devkit.getTx(result.tx_hash); - expect(txInfo).toBeTruthy(); - }); - - it("should create info_action proposal", async () => { - if (skip) return; - - const sender = await fundAccount(); - await registerStake(sender); - - const result = bridge.quicktx - .newTx() - .createProposal("info_action", sender.stake_address, ANCHOR_URL, ANCHOR_DATA_HASH) - .from(sender.base_address) - .build({ name: "yaci", url: DEVKIT_PROVIDER_URL }); - - expect(result.tx_cbor.length).toBeGreaterThan(0); - expect(Number(result.fee)).toBeGreaterThan(0); - - await buildSignSubmit(sender, result); - await devkit.waitForBlock(3000); - - const txInfo = await devkit.getTx(result.tx_hash); - expect(txInfo).toBeTruthy(); - }); - - it("should create no_confidence proposal", async () => { - if (skip) return; - - const sender = await fundAccount(); - await registerStake(sender); - - const result = bridge.quicktx - .newTx() - .createProposal("no_confidence", sender.stake_address, ANCHOR_URL, ANCHOR_DATA_HASH) - .from(sender.base_address) - .build({ name: "yaci", url: DEVKIT_PROVIDER_URL }); - - expect(result.tx_cbor.length).toBeGreaterThan(0); - - await buildSignSubmit(sender, result); - await devkit.waitForBlock(3000); - - const txInfo = await devkit.getTx(result.tx_hash); - expect(txInfo).toBeTruthy(); - }); - - it("should create new_constitution proposal", async () => { - if (skip) return; - - const sender = await fundAccount(); - await registerStake(sender); - - const result = bridge.quicktx - .newTx() - .createProposal("new_constitution", sender.stake_address, ANCHOR_URL, ANCHOR_DATA_HASH, { - constitutionAnchorUrl: ANCHOR_URL, - constitutionAnchorDataHash: ANCHOR_DATA_HASH, - }) - .from(sender.base_address) - .build({ name: "yaci", url: DEVKIT_PROVIDER_URL }); - - expect(result.tx_cbor.length).toBeGreaterThan(0); - - await buildSignSubmit(sender, result); - await devkit.waitForBlock(3000); - - const txInfo = await devkit.getTx(result.tx_hash); - expect(txInfo).toBeTruthy(); - }); - - it("should create update_committee proposal", async () => { - if (skip) return; - - const sender = await fundAccount(); - await registerStake(sender); - - const memberHash = "a".repeat(56); - - const result = bridge.quicktx - .newTx() - .createProposal("update_committee", sender.stake_address, ANCHOR_URL, ANCHOR_DATA_HASH, { - newMembers: [{ hash: memberHash, type: "key", epoch: 100 }], - quorumNumerator: 2, - quorumDenominator: 3, - }) - .from(sender.base_address) - .build({ name: "yaci", url: DEVKIT_PROVIDER_URL }); - - expect(result.tx_cbor.length).toBeGreaterThan(0); - - await buildSignSubmit(sender, result); - await devkit.waitForBlock(3000); - - const txInfo = await devkit.getTx(result.tx_hash); - expect(txInfo).toBeTruthy(); - }); - - it("should create hard_fork_initiation proposal", async () => { - if (skip) return; - - const sender = await fundAccount(); - await registerStake(sender); - - const result = bridge.quicktx - .newTx() - .createProposal("hard_fork_initiation", sender.stake_address, ANCHOR_URL, ANCHOR_DATA_HASH, { - protocolVersionMajor: 10, - protocolVersionMinor: 0, - }) - .from(sender.base_address) - .build({ name: "yaci", url: DEVKIT_PROVIDER_URL }); - - expect(result.tx_cbor.length).toBeGreaterThan(0); - - await buildSignSubmit(sender, result); - await devkit.waitForBlock(3000); - - const txInfo = await devkit.getTx(result.tx_hash); - expect(txInfo).toBeTruthy(); - }); - - // --- Build-only tests (need additional key signatures) --- - - it("should build registerDRep tx (build-only)", async () => { - if (skip) return; - - const sender = await fundAccount(); - - const drepKey = bridge.gov.drepKeyFromMnemonic(sender.mnemonic, TESTNET, 0); - const credentialHash = drepKey.verification_key_hash; - - const result = bridge.quicktx - .newTx() - .registerDRep(credentialHash, "key", { - anchorUrl: ANCHOR_URL, - anchorDataHash: ANCHOR_DATA_HASH, - }) - .from(sender.base_address) - .signerCount(2) - .build({ name: "yaci", url: DEVKIT_PROVIDER_URL }); - - expect(result.tx_cbor.length).toBeGreaterThan(0); - expect(result.tx_hash.length).toBe(64); - expect(Number(result.fee)).toBeGreaterThan(0); - }); - - it("should build unregisterDRep tx with refundAmount (build-only)", async () => { - if (skip) return; - - const sender = await fundAccount(); - - const drepKey = bridge.gov.drepKeyFromMnemonic(sender.mnemonic, TESTNET, 0); - const credentialHash = drepKey.verification_key_hash; - - const result = bridge.quicktx - .newTx() - .unregisterDRep(credentialHash, "key", { - refundAddress: sender.base_address, - refundAmount: 500_000_000, - }) - .from(sender.base_address) - .signerCount(2) - .build({ name: "yaci", url: DEVKIT_PROVIDER_URL }); - - expect(result.tx_cbor.length).toBeGreaterThan(0); - expect(result.tx_hash.length).toBe(64); - expect(Number(result.fee)).toBeGreaterThan(0); - }); - - it("should build registerPool tx (build-only)", async () => { - if (skip) return; - - const sender = await fundAccount(); - - const operatorHash = "ab".repeat(14); // 28-byte hex - const vrfKeyHash = "cd".repeat(16); // 32-byte hex - - const result = bridge.quicktx - .newTx() - .registerPool( - operatorHash, - vrfKeyHash, - 500_000_000, // pledge - 340_000_000, // cost - 1, // margin numerator - 100, // margin denominator - sender.stake_address, - [operatorHash] - ) - .from(sender.base_address) - .signerCount(2) - .build({ name: "yaci", url: DEVKIT_PROVIDER_URL }); - - expect(result.tx_cbor.length).toBeGreaterThan(0); - expect(result.tx_hash.length).toBe(64); - expect(Number(result.fee)).toBeGreaterThan(0); - }); - - it("should build donateToTreasury tx (build-only)", async () => { - if (skip) return; - - const sender = await fundAccount(); - - const result = bridge.quicktx - .newTx() - .donateToTreasury(0, 5_000_000) - .from(sender.base_address) - .build({ name: "yaci", url: DEVKIT_PROVIDER_URL }); - - expect(result.tx_cbor.length).toBeGreaterThan(0); - expect(result.tx_hash.length).toBe(64); - expect(Number(result.fee)).toBeGreaterThan(0); - }); - - it("should build createVote tx (build-only)", async () => { - if (skip) return; - - const sender = await fundAccount(); - - const drepKey = bridge.gov.drepKeyFromMnemonic(sender.mnemonic, TESTNET, 0); - const credentialHash = drepKey.verification_key_hash; - - const fakeGovTxHash = "ab".repeat(32); - - const result = bridge.quicktx - .newTx() - .createVote("drep_key_hash", credentialHash, fakeGovTxHash, 0, "yes", { - anchorUrl: ANCHOR_URL, - anchorDataHash: ANCHOR_DATA_HASH, - }) - .from(sender.base_address) - .signerCount(2) - .build({ name: "yaci", url: DEVKIT_PROVIDER_URL }); - - expect(result.tx_cbor.length).toBeGreaterThan(0); - expect(result.tx_hash.length).toBe(64); - expect(Number(result.fee)).toBeGreaterThan(0); - }); -}); diff --git a/wrappers/js/test/provider.integration.test.js b/wrappers/js/test/provider.integration.test.js deleted file mode 100644 index eb11c76..0000000 --- a/wrappers/js/test/provider.integration.test.js +++ /dev/null @@ -1,171 +0,0 @@ -/** - * Integration tests for Provider pattern with Yaci DevKit. - * - * Requires: - * - Yaci DevKit running on port 10000 - * - Native library built: ./gradlew :core:nativeCompile - * - * Run with: - * CCL_LIB_PATH=core/build/native/nativeCompile bun test wrappers/js/test/provider.integration.test.js - */ -import { describe, it, expect, beforeAll, afterAll, setDefaultTimeout } from "bun:test"; -import { CclBridge, TESTNET, Amount, YaciDevKitProvider } from "../src/index.js"; - -setDefaultTimeout(60_000); - -describe("Provider Integration (DevKit)", () => { - let bridge; - let provider; - let skip = false; - - beforeAll(async () => { - provider = new YaciDevKitProvider(); - const available = await provider.isAvailable(); - if (!available) { - skip = true; - console.log("Skipping: Yaci DevKit not available on port 10000"); - return; - } - bridge = new CclBridge(); - await provider.reset(); - await provider.waitForBlock(3000); - }); - - afterAll(() => { - if (bridge) bridge.close(); - }); - - async function fundSender() { - const account = bridge.account.create(TESTNET); - await provider.topup(account.base_address, 150); - await provider.waitForBlock(2000); - return account; - } - - it("should build with provider (auto-fetch UTXOs + PP)", async () => { - if (skip) return; - - const sender = await fundSender(); - const receiver = bridge.account.create(TESTNET); - - // Build with provider - no manual withUtxos/withProtocolParams - const result = await bridge.quicktx - .newTx() - .payToAddress(receiver.base_address, Amount.ada(5)) - .from(sender.base_address) - .buildWithProvider(provider); - - expect(result.tx_cbor.length).toBeGreaterThan(0); - expect(result.tx_hash.length).toBe(64); - expect(Number(result.fee)).toBeGreaterThan(0); - - // Sign - const signedTx = bridge.account.signTx( - sender.mnemonic, TESTNET, 0, 0, result.tx_cbor - ); - - // Submit via provider - const txHash = await provider.submitTx(signedTx); - expect(txHash).toBeTruthy(); - - // Verify - await provider.waitForBlock(3000); - const receiverUtxos = await provider.getUtxos(receiver.base_address); - const total = receiverUtxos.reduce((sum, u) => { - const lovelace = u.amount.find((a) => a.unit === "lovelace"); - return sum + (lovelace ? Number(lovelace.quantity) : 0); - }, 0); - expect(total).toBe(5_000_000); - }); - - it("should allow manual UTXOs to override provider", async () => { - if (skip) return; - - const sender = await fundSender(); - const receiver = bridge.account.create(TESTNET); - - // Manually fetch UTXOs - const utxos = await provider.getUtxos(sender.base_address); - - // Build with provider but override UTXOs - const result = await bridge.quicktx - .newTx() - .payToAddress(receiver.base_address, Amount.ada(3)) - .from(sender.base_address) - .withUtxos(utxos) - .buildWithProvider(provider); - - expect(result.tx_cbor.length).toBeGreaterThan(0); - expect(result.tx_hash.length).toBe(64); - - const signedTx = bridge.account.signTx( - sender.mnemonic, TESTNET, 0, 0, result.tx_cbor - ); - const txHash = await provider.submitTx(signedTx); - expect(txHash).toBeTruthy(); - }); - - it("should send to multiple receivers with provider", async () => { - if (skip) return; - - const sender = await fundSender(); - const r1 = bridge.account.create(TESTNET); - const r2 = bridge.account.create(TESTNET); - - const result = await bridge.quicktx - .newTx() - .payToAddress(r1.base_address, Amount.ada(3)) - .payToAddress(r2.base_address, Amount.ada(2)) - .from(sender.base_address) - .buildWithProvider(provider); - - const signedTx = bridge.account.signTx( - sender.mnemonic, TESTNET, 0, 0, result.tx_cbor - ); - await provider.submitTx(signedTx); - await provider.waitForBlock(3000); - - const r1Utxos = await provider.getUtxos(r1.base_address); - const r2Utxos = await provider.getUtxos(r2.base_address); - - const r1Total = r1Utxos.reduce((s, u) => { - const l = u.amount.find((a) => a.unit === "lovelace"); - return s + (l ? Number(l.quantity) : 0); - }, 0); - const r2Total = r2Utxos.reduce((s, u) => { - const l = u.amount.find((a) => a.unit === "lovelace"); - return s + (l ? Number(l.quantity) : 0); - }, 0); - - expect(r1Total).toBe(3_000_000); - expect(r2Total).toBe(2_000_000); - }); - - it("should send with metadata using provider", async () => { - if (skip) return; - - const sender = await fundSender(); - const receiver = bridge.account.create(TESTNET); - - const result = await bridge.quicktx - .newTx() - .payToAddress(receiver.base_address, Amount.ada(2)) - .attachMetadata(674, { msg: ["Hello from Provider"] }) - .from(sender.base_address) - .buildWithProvider(provider); - - const signedTx = bridge.account.signTx( - sender.mnemonic, TESTNET, 0, 0, result.tx_cbor - ); - const txHash = await provider.submitTx(signedTx); - expect(txHash).toBeTruthy(); - - await provider.waitForBlock(3000); - const receiverUtxos = await provider.getUtxos(receiver.base_address); - const total = receiverUtxos.reduce((sum, u) => { - const lovelace = u.amount.find((a) => a.unit === "lovelace"); - return sum + (lovelace ? Number(lovelace.quantity) : 0); - }, 0); - expect(total).toBe(2_000_000); - }); -}); diff --git a/wrappers/js/test/quicktx.integration.test.js b/wrappers/js/test/quicktx.integration.test.js index d810c39..3a7d5aa 100644 --- a/wrappers/js/test/quicktx.integration.test.js +++ b/wrappers/js/test/quicktx.integration.test.js @@ -1,19 +1,54 @@ -/** - * Integration tests for QuickTx with Yaci DevKit. - * - * Requires: - * - Yaci DevKit running on port 10000 - * - Native library built: ./gradlew :core:nativeCompile - * - * Run with: - * CCL_LIB_PATH=core/build/native/nativeCompile bun test wrappers/js/test/quicktx.integration.test.js - */ +// Integration tests for QuickTx (TxPlan YAML) with Yaci DevKit. +// +// Requires: +// - Yaci DevKit running on port 10000 +// - Native library built: ./gradlew :core:nativeCompile +// +// Run with: +// cd wrappers/js && CCL_LIB_PATH=../../core/build/native/nativeCompile \ +// DYLD_LIBRARY_PATH=../../core/build/native/nativeCompile bun test test/quicktx.integration.test.js + import { describe, it, expect, beforeAll, afterAll, setDefaultTimeout } from "bun:test"; -import { CclBridge, CclError, TESTNET, Amount } from "../src/index.js"; +import { CclBridge, TESTNET } from "../src/index.js"; import { DevKitHelper } from "./devkit-helper.js"; +import { readFileSync } from "fs"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; setDefaultTimeout(60_000); +const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), "../../../test-fixtures/quicktx-intents"); + +// The fixed test account the quicktx-intents fixtures are derived from (account 0/0). A Plutus +// fixture bakes this address in as the fee payer, so submitting it means funding and signing with +// this exact account rather than a freshly-created one. +const INTENT_MNEMONIC = "test walk nut penalty hip pave soap entry language right filter choice"; +const INTENT_SENDER = "addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp"; +// The enterprise address the mint fixtures pay the freshly minted asset to. +const MINT_RECEIVER = "addr_test1vz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzerspjrlsz"; + +function paymentYaml(from, to, quantity) { + return ` +version: 1.0 +transaction: + - tx: + from: ${from} + intents: + - type: payment + address: ${to} + amounts: + - unit: lovelace + quantity: "${quantity}" +`; +} + +function totalLovelace(utxos) { + return utxos.reduce((sum, u) => { + const lovelace = u.amount.find((a) => a.unit === "lovelace"); + return sum + (lovelace ? Number(lovelace.quantity) : 0); + }, 0); +} + describe("QuickTx Integration (DevKit)", () => { let bridge; let devkit; @@ -36,14 +71,14 @@ describe("QuickTx Integration (DevKit)", () => { if (bridge) bridge.close(); }); - async function fundSender() { + async function fundSender(ada = 150) { const account = bridge.account.create(TESTNET); - await devkit.topup(account.base_address, 150); + await devkit.topup(account.base_address, ada); await devkit.waitForBlock(2000); return account; } - it("should build, sign, and submit simple ADA transfer", async () => { + it("should build, sign, and submit a simple ADA transfer", async () => { if (skip) return; const sender = await fundSender(); @@ -52,40 +87,19 @@ describe("QuickTx Integration (DevKit)", () => { const utxos = await devkit.getUtxos(sender.base_address); const pp = await devkit.getProtocolParams(); - // Build - const result = bridge.quicktx - .newTx() - .payToAddress(receiver.base_address, Amount.ada(5)) - .from(sender.base_address) - .withUtxos(utxos) - .withProtocolParams(pp) - .build(); - + const yaml = paymentYaml(sender.base_address, receiver.base_address, "5000000"); + const result = bridge.quicktx.build(yaml, utxos, pp); expect(result.tx_cbor.length).toBeGreaterThan(0); expect(result.tx_hash.length).toBe(64); expect(Number(result.fee)).toBeGreaterThan(0); - // Sign - const signedTx = bridge.account.signTx( - sender.mnemonic, - TESTNET, - 0, - 0, - result.tx_cbor - ); - - // Submit + const signedTx = bridge.account.signTx(sender.mnemonic, TESTNET, 0, 0, result.tx_cbor); const txHash = await devkit.submitTx(signedTx); expect(txHash).toBeTruthy(); - // Verify await devkit.waitForBlock(3000); const receiverUtxos = await devkit.getUtxos(receiver.base_address); - const total = receiverUtxos.reduce((sum, u) => { - const lovelace = u.amount.find((a) => a.unit === "lovelace"); - return sum + (lovelace ? Number(lovelace.quantity) : 0); - }, 0); - expect(total).toBe(5_000_000); + expect(totalLovelace(receiverUtxos)).toBe(5_000_000); }); it("should send to multiple receivers", async () => { @@ -98,233 +112,86 @@ describe("QuickTx Integration (DevKit)", () => { const utxos = await devkit.getUtxos(sender.base_address); const pp = await devkit.getProtocolParams(); - const result = bridge.quicktx - .newTx() - .payToAddress(r1.base_address, Amount.ada(3)) - .payToAddress(r2.base_address, Amount.ada(2)) - .from(sender.base_address) - .withUtxos(utxos) - .withProtocolParams(pp) - .build(); - - const signedTx = bridge.account.signTx( - sender.mnemonic, - TESTNET, - 0, - 0, - result.tx_cbor - ); + const yaml = ` +version: 1.0 +transaction: + - tx: + from: ${sender.base_address} + intents: + - type: payment + address: ${r1.base_address} + amounts: + - unit: lovelace + quantity: "3000000" + - type: payment + address: ${r2.base_address} + amounts: + - unit: lovelace + quantity: "2000000" +`; + const result = bridge.quicktx.build(yaml, utxos, pp); + const signedTx = bridge.account.signTx(sender.mnemonic, TESTNET, 0, 0, result.tx_cbor); await devkit.submitTx(signedTx); - await devkit.waitForBlock(3000); - - const r1Utxos = await devkit.getUtxos(r1.base_address); - const r2Utxos = await devkit.getUtxos(r2.base_address); - - const r1Total = r1Utxos.reduce((s, u) => { - const l = u.amount.find((a) => a.unit === "lovelace"); - return s + (l ? Number(l.quantity) : 0); - }, 0); - const r2Total = r2Utxos.reduce((s, u) => { - const l = u.amount.find((a) => a.unit === "lovelace"); - return s + (l ? Number(l.quantity) : 0); - }, 0); - - expect(r1Total).toBe(3_000_000); - expect(r2Total).toBe(2_000_000); - }); - - it("should send with metadata", async () => { - if (skip) return; - const sender = await fundSender(); - const receiver = bridge.account.create(TESTNET); - - const utxos = await devkit.getUtxos(sender.base_address); - const pp = await devkit.getProtocolParams(); - - const result = bridge.quicktx - .newTx() - .payToAddress(receiver.base_address, Amount.ada(2)) - .attachMetadata(674, { msg: ["Hello from JS"] }) - .from(sender.base_address) - .withUtxos(utxos) - .withProtocolParams(pp) - .build(); - - const signedTx = bridge.account.signTx( - sender.mnemonic, - TESTNET, - 0, - 0, - result.tx_cbor - ); - await devkit.submitTx(signedTx); await devkit.waitForBlock(3000); - - const txInfo = await devkit.getTx(result.tx_hash); - expect(txInfo).toBeTruthy(); - }); - - it("should fail on insufficient funds", async () => { - if (skip) return; - - const sender = bridge.account.create(TESTNET); - await devkit.topup(sender.base_address, 2); - await devkit.waitForBlock(2000); - - const utxos = await devkit.getUtxos(sender.base_address); - const pp = await devkit.getProtocolParams(); - const receiver = bridge.account.create(TESTNET); - - expect(() => { - bridge.quicktx - .newTx() - .payToAddress(receiver.base_address, Amount.ada(100)) - .from(sender.base_address) - .withUtxos(utxos) - .withProtocolParams(pp) - .build(); - }).toThrow(); + const r1Utxos = await devkit.getUtxos(r1.base_address); + expect(totalLovelace(r1Utxos)).toBe(3_000_000); }); - it("should complete full round-trip: build -> sign -> submit -> confirm", async () => { + it("should throw on insufficient funds", async () => { if (skip) return; - const sender = await fundSender(); + const sender = await fundSender(2); const receiver = bridge.account.create(TESTNET); const utxos = await devkit.getUtxos(sender.base_address); const pp = await devkit.getProtocolParams(); - // Build - const result = bridge.quicktx - .newTx() - .payToAddress(receiver.base_address, Amount.ada(10)) - .from(sender.base_address) - .withUtxos(utxos) - .withProtocolParams(pp) - .build(); - - // Sign - const signedTx = bridge.account.signTx( - sender.mnemonic, - TESTNET, - 0, - 0, - result.tx_cbor - ); - - // Submit - await devkit.submitTx(signedTx); - await devkit.waitForBlock(3000); - - // Confirm on-chain - const txInfo = await devkit.getTx(result.tx_hash); - expect(txInfo).toBeTruthy(); - - // Check receiver balance - const receiverUtxos = await devkit.getUtxos(receiver.base_address); - const total = receiverUtxos.reduce((sum, u) => { - const lovelace = u.amount.find((a) => a.unit === "lovelace"); - return sum + (lovelace ? Number(lovelace.quantity) : 0); - }, 0); - expect(total).toBe(10_000_000); + const yaml = paymentYaml(sender.base_address, receiver.base_address, "100000000"); + expect(() => bridge.quicktx.build(yaml, utxos, pp)).toThrow(); }); - // --- Provider Config (server-side lazy UTXO fetching) tests --- - - const DEVKIT_PROVIDER_URL = "http://localhost:10000/local-cluster/api"; - - it("should build with providerConfig (Java-side lazy UTXO fetch)", async () => { + // Plutus round-trip: build the script_minting fixture with caller-supplied exec units, sign with + // the fee payer's payment key, submit, and assert the minted asset landed on-chain. "Submit + // accepted" alone doesn't prove the script ran and minted — the receiver holding a non-lovelace + // asset does. Mirrors the Go TestIntegrationPlutusMint. + // + // We build WITHOUT the devnet's fetched cost models, so the native lib uses its built-in standard + // Conway cost models (which the devnet runs). Passing DevKit's fetched cost models instead is + // rejected with PPViewHashesDontMatch: /epochs/parameters returns them as a map keyed by + // zero-padded indices ("000".."165"), and JS's JSON parse reorders the non-padded integer-like + // keys ("100".."165") ahead of the padded ones, scrambling the cost-model order vs the ledger's + // canonical order and corrupting the script-integrity hash. Go's lexicographic map marshalling + // preserves the order, which is why its equivalent test passes with the fetched params. Threading + // fetched cost models through to Plutus builds needs an order-preserving fix in the wrapper — + // tracked as a follow-up in TODO.md §3. + it("should build, sign, and submit a Plutus mint", async () => { if (skip) return; - const sender = await fundSender(); - const receiver = bridge.account.create(TESTNET); - - // Build using providerConfig — Java fetches UTXOs and PP lazily via HTTP - const result = bridge.quicktx - .newTx() - .payToAddress(receiver.base_address, Amount.ada(5)) - .from(sender.base_address) - .build({ name: "yaci", url: DEVKIT_PROVIDER_URL }); - - expect(result.tx_cbor.length).toBeGreaterThan(0); - expect(result.tx_hash.length).toBe(64); - expect(Number(result.fee)).toBeGreaterThan(0); - - // Sign and submit - const signedTx = bridge.account.signTx( - sender.mnemonic, TESTNET, 0, 0, result.tx_cbor - ); - const txHash = await devkit.submitTx(signedTx); - expect(txHash).toBeTruthy(); - + await devkit.reset(); await devkit.waitForBlock(3000); - const receiverUtxos = await devkit.getUtxos(receiver.base_address); - const total = receiverUtxos.reduce((sum, u) => { - const lovelace = u.amount.find((a) => a.unit === "lovelace"); - return sum + (lovelace ? Number(lovelace.quantity) : 0); - }, 0); - expect(total).toBe(5_000_000); - }); - - it("should build with providerConfig and multiple receivers", async () => { - if (skip) return; - - const sender = await fundSender(); - const r1 = bridge.account.create(TESTNET); - const r2 = bridge.account.create(TESTNET); - - const result = bridge.quicktx - .newTx() - .payToAddress(r1.base_address, Amount.ada(3)) - .payToAddress(r2.base_address, Amount.ada(2)) - .from(sender.base_address) - .build({ name: "yaci", url: DEVKIT_PROVIDER_URL }); - - const signedTx = bridge.account.signTx( - sender.mnemonic, TESTNET, 0, 0, result.tx_cbor - ); - await devkit.submitTx(signedTx); + await devkit.topup(INTENT_SENDER, 6000); await devkit.waitForBlock(3000); - const r1Utxos = await devkit.getUtxos(r1.base_address); - const r2Utxos = await devkit.getUtxos(r2.base_address); - - const r1Total = r1Utxos.reduce((s, u) => { - const l = u.amount.find((a) => a.unit === "lovelace"); - return s + (l ? Number(l.quantity) : 0); - }, 0); - const r2Total = r2Utxos.reduce((s, u) => { - const l = u.amount.find((a) => a.unit === "lovelace"); - return s + (l ? Number(l.quantity) : 0); - }, 0); - - expect(r1Total).toBe(3_000_000); - expect(r2Total).toBe(2_000_000); - }); - - it("should build with providerConfig and metadata", async () => { - if (skip) return; + const utxos = await devkit.getUtxos(INTENT_SENDER); + const pp = await devkit.getProtocolParams(); + const yaml = readFileSync(join(FIXTURES, "plutus", "script_minting.yaml"), "utf8"); - const sender = await fundSender(); - const receiver = bridge.account.create(TESTNET); + const ppForBuild = { ...pp }; + for (const k of ["cost_models", "costModels", "cost_mdls", "costMdls"]) delete ppForBuild[k]; - const result = bridge.quicktx - .newTx() - .payToAddress(receiver.base_address, Amount.ada(2)) - .attachMetadata(674, { msg: ["Hello from providerConfig"] }) - .from(sender.base_address) - .build({ name: "yaci", url: DEVKIT_PROVIDER_URL }); + const result = bridge.quicktx.build(yaml, utxos, ppForBuild, [{ mem: 2000000, steps: 500000000 }]); + expect(result.tx_hash.length).toBe(64); - const signedTx = bridge.account.signTx( - sender.mnemonic, TESTNET, 0, 0, result.tx_cbor - ); - const txHash = await devkit.submitTx(signedTx); - expect(txHash).toBeTruthy(); + const signedTx = bridge.account.signTxWithKeys(INTENT_MNEMONIC, TESTNET, 0, 0, result.tx_cbor, ["payment"]); + // A successful submit returns the 64-char tx hash; a rejection returns an error body. Assert the + // hash so a failed Plutus validation surfaces here, not as a missing asset further down. + const submitResult = await devkit.submitTx(signedTx); + expect(submitResult).toMatch(/^[0-9a-f]{64}$/); await devkit.waitForBlock(3000); - const txInfo = await devkit.getTx(result.tx_hash); - expect(txInfo).toBeTruthy(); + const receiverUtxos = await devkit.getUtxos(MINT_RECEIVER); + const hasMintedAsset = receiverUtxos.some((u) => u.amount.some((a) => a.unit !== "lovelace")); + expect(hasMintedAsset).toBe(true); }); }); diff --git a/wrappers/python/README.md b/wrappers/python/README.md new file mode 100644 index 0000000..d5ff01f --- /dev/null +++ b/wrappers/python/README.md @@ -0,0 +1,101 @@ +# CCL Bridge — Python + +Python bindings for [Cardano Client Lib](https://github.com/bloxbean/cardano-client-lib) +via the CCL Bridge native library. Pure `ctypes` — no JVM, no compiler, no C extension. + +> Part of the [CCL Bridge](../../README.md) project. See the +> [top-level README](../../README.md) for the full API reference and +> [`docs/quicktx.md`](../../docs/quicktx.md) for transaction building. + +## Requirements + +- Python 3.8+ +- The native library `libccl.{dylib,so,dll}` for your platform. + +## Getting the native library + +The bindings load a shared library at runtime; they do not bundle it (yet — see the +project [`TODO.md`](../../TODO.md)). Two ways to get it: + +**Build from source** (needs Oracle GraalVM 25.0.3 — see the top-level README): + +```bash +# from the repo root +./gradlew :core:nativeCompile +# produces core/build/native/nativeCompile/libccl.{dylib,so} +``` + +**Or download a pre-built binary:** + +```bash +make download-lib # fetches into core/build/native/nativeCompile/ +``` + +## Running the examples + +The package finds the library via the `CCL_LIB_PATH` environment variable, and the OS +loader needs it on its search path too. From the repo root: + +```bash +LIB_DIR=core/build/native/nativeCompile + +PYTHONPATH=wrappers/python \ +CCL_LIB_PATH=$LIB_DIR \ +DYLD_LIBRARY_PATH=$LIB_DIR \ +LD_LIBRARY_PATH=$LIB_DIR \ + python3 wrappers/python/examples/01_account_and_keys.py +``` + +(`DYLD_LIBRARY_PATH` is for macOS, `LD_LIBRARY_PATH` for Linux — set both, the unused one +is harmless.) + +The [`examples/`](examples/) directory contains: + +| File | What it shows | +|------|---------------| +| [`01_account_and_keys.py`](examples/01_account_and_keys.py) | Create an account, restore from mnemonic, derive keys and a DRep ID | +| [`02_primitives.py`](examples/02_primitives.py) | Mnemonics, Blake2b hashing, Ed25519 signing, address parsing/validation | +| [`03_build_and_sign_tx.py`](examples/03_build_and_sign_tx.py) | Build an unsigned payment **offline** (QuickTx) and sign it — no node/DevKit needed | + +## Quick start + +```python +from ccl._ffi import CclLib + +lib = CclLib() # loads libccl, starts a GraalVM isolate +try: + account = lib.account.create(CclLib.TESTNET) + print(account["base_address"]) # addr_test1... + print(account["mnemonic"]) # 24-word phrase +finally: + lib.close() # tears down the isolate +``` + +## API namespaces + +A `CclLib` instance exposes these namespaces (all offline operations): + +| Namespace | Examples | +|-----------|----------| +| `lib.account` | `create`, `from_mnemonic`, `get_private_key`, `get_public_key`, `get_drep_id`, `sign_tx` | +| `lib.address` | `info`, `validate`, `to_bytes`, `from_bytes` | +| `lib.crypto` | `blake2b_256`, `blake2b_224`, `generate_mnemonic`, `validate_mnemonic`, `sign`, `verify` | +| `lib.tx` | `hash`, `sign_with_secret_key`, `to_json`, `from_json`, `deserialize` | +| `lib.plutus` | `data_hash`, `data_to_json`, `data_from_json` | +| `lib.script` | `native_from_json`, `hash` | +| `lib.gov` | `drep_key_from_mnemonic`, `committee_cold_key_from_mnemonic`, `committee_hot_key_from_mnemonic` | +| `lib.wallet` | `create`, `from_mnemonic`, `get_address` | +| `lib.quicktx` | `build(yaml, utxos, protocol_params)` — build an unsigned tx from a TxPlan YAML document | + +Network IDs: `CclLib.MAINNET` (0), `CclLib.TESTNET` (1), `CclLib.PREPROD` (2), `CclLib.PREVIEW` (3). + +Errors raise `ccl.CclError`. + +Transactions are defined as a [TxPlan](https://github.com/bloxbean/cardano-client-lib) +**YAML** document and built fully offline — you supply the UTXOs and protocol parameters: + +```python +result = lib.quicktx.build(txplan_yaml, utxos, protocol_params) # -> {"tx_cbor","tx_hash","fee"} +``` + +See [`examples/03_build_and_sign_tx.py`](examples/03_build_and_sign_tx.py). diff --git a/wrappers/python/build.gradle b/wrappers/python/build.gradle index 8ce5d7e..8fe2dc9 100644 --- a/wrappers/python/build.gradle +++ b/wrappers/python/build.gradle @@ -21,3 +21,18 @@ task test(type: Exec) { commandLine 'bash', '-c', 'python3 -m pytest tests/ -v --ignore=tests/test_quicktx_integration.py --ignore=tests/test_new_features_integration.py' } + +// Full suite including the DevKit integration tests. Requires a running Yaci DevKit +// on :10000 (the integration tests skip themselves if it is not available). +task integrationTest(type: Exec) { + dependsOn copyNativeLib + workingDir projectDir + + def nativeDir = layout.buildDirectory.dir('native').get().asFile.absolutePath + environment 'CCL_LIB_PATH', nativeDir + environment 'DYLD_LIBRARY_PATH', nativeDir + environment 'LD_LIBRARY_PATH', nativeDir + environment 'PYTHONPATH', projectDir.absolutePath + + commandLine 'bash', '-c', 'python3 -m pytest tests/ -v' +} diff --git a/wrappers/python/ccl/__init__.py b/wrappers/python/ccl/__init__.py index 3bdd9ec..e3df83a 100644 --- a/wrappers/python/ccl/__init__.py +++ b/wrappers/python/ccl/__init__.py @@ -7,10 +7,7 @@ from ccl.script import Script from ccl.governance import Governance from ccl.wallet import Wallet -from ccl.quicktx import QuickTx, TxBuilder, Amount, Tx, ComposeTxBuilder, ScriptTxBuilder, ScriptTx, ProviderConfig -from ccl.provider import Provider, YaciDevKitProvider +from ccl.quicktx import QuickTx __all__ = ['CclLib', 'CclError', 'Account', 'Address', 'Crypto', 'Transaction', - 'Plutus', 'Script', 'Governance', 'Wallet', 'QuickTx', 'TxBuilder', 'Amount', - 'Tx', 'ComposeTxBuilder', 'ScriptTxBuilder', 'ScriptTx', 'ProviderConfig', - 'Provider', 'YaciDevKitProvider'] + 'Plutus', 'Script', 'Governance', 'Wallet', 'QuickTx'] diff --git a/wrappers/python/ccl/_ffi.py b/wrappers/python/ccl/_ffi.py index cb01fa6..2b81e9b 100644 --- a/wrappers/python/ccl/_ffi.py +++ b/wrappers/python/ccl/_ffi.py @@ -103,6 +103,8 @@ def _setup_functions(self): lib.ccl_account_sign_tx.argtypes = [c_void_p, c_char_p, c_int, c_int, c_int, c_char_p] lib.ccl_account_sign_tx.restype = c_int + lib.ccl_account_sign_tx_multi.argtypes = [c_void_p, c_char_p, c_int, c_int, c_int, c_char_p, c_char_p] + lib.ccl_account_sign_tx_multi.restype = c_int lib.ccl_account_get_drep_id.argtypes = [c_void_p, c_char_p, c_int, c_int] lib.ccl_account_get_drep_id.restype = c_int @@ -193,7 +195,7 @@ def _setup_functions(self): lib.ccl_script_hash.restype = c_int # QuickTx API - lib.ccl_quicktx_build.argtypes = [c_void_p, c_char_p] + lib.ccl_quicktx_build.argtypes = [c_void_p, c_char_p, c_char_p, c_char_p, c_char_p] lib.ccl_quicktx_build.restype = c_int def _get_result(self): diff --git a/wrappers/python/ccl/account.py b/wrappers/python/ccl/account.py index 7f2c0ba..524de13 100644 --- a/wrappers/python/ccl/account.py +++ b/wrappers/python/ccl/account.py @@ -37,6 +37,20 @@ def sign_tx(self, mnemonic, tx_cbor_hex, network_id=0, account_index=0, address_ self._b._encode(tx_cbor_hex)) return self._b._check(rc) + def sign_tx_with_keys(self, mnemonic, tx_cbor_hex, keys, network_id=0, account_index=0, address_index=0): + """Sign a transaction with one or more keys. + + ``keys`` is a list (or comma-separated string) of roles applied in order: ``payment``, + ``stake``, ``drep``, ``committee_cold``, ``committee_hot``. Use this for transactions whose + certificates also need the stake or DRep key (stake registration/delegation/withdrawal, + DRep and vote operations), which the payment key alone cannot witness. + """ + keys_str = keys if isinstance(keys, str) else ",".join(keys) + rc = self._b._lib.ccl_account_sign_tx_multi( + self._b._thread, self._b._encode(mnemonic), network_id, account_index, address_index, + self._b._encode(tx_cbor_hex), self._b._encode(keys_str)) + return self._b._check(rc) + def get_drep_id(self, mnemonic, network_id=0, account_index=0): """Get DRep ID (bech32) from mnemonic.""" rc = self._b._lib.ccl_account_get_drep_id( diff --git a/wrappers/python/ccl/provider.py b/wrappers/python/ccl/provider.py deleted file mode 100644 index 0acf0ed..0000000 --- a/wrappers/python/ccl/provider.py +++ /dev/null @@ -1,115 +0,0 @@ -"""Provider abstraction for fetching UTXOs, protocol params, and submitting transactions. - -Providers allow TxBuilder to automatically fetch required data from a backend -(DevKit, Blockfrost, Koios, etc.) instead of requiring manual fetching. -""" -import json -import time -import urllib.request -import urllib.error - - -class Provider: - """Base provider interface for UTXOs, protocol params, and tx submission.""" - - def get_utxos(self, address): - """Fetch all UTXOs for an address. - - Args: - address: Bech32 address string - - Returns: - List of UTXO dicts in Blockfrost/Koios/DevKit format. - """ - raise NotImplementedError - - def get_protocol_params(self): - """Fetch current protocol parameters. - - Returns: - Protocol params dict in Blockfrost/Koios/DevKit format. - """ - raise NotImplementedError - - def submit_tx(self, tx_cbor_hex): - """Submit a signed transaction. - - Args: - tx_cbor_hex: Signed transaction CBOR hex string - - Returns: - Transaction hash string. - """ - raise NotImplementedError - - -class YaciDevKitProvider(Provider): - """Provider backed by Yaci DevKit local cluster.""" - - def __init__(self, store_url="http://localhost:8080/api/v1", admin_url="http://localhost:10000/local-cluster/api"): - self.store_url = store_url - self.admin_url = admin_url - - def get_utxos(self, address): - """Fetch UTXOs for an address from DevKit.""" - url = f"{self.store_url}/addresses/{address}/utxos" - with urllib.request.urlopen(url) as resp: - return json.loads(resp.read()) - - def get_protocol_params(self): - """Fetch current protocol parameters from DevKit.""" - url = f"{self.store_url}/epochs/parameters" - with urllib.request.urlopen(url) as resp: - return json.loads(resp.read()) - - def submit_tx(self, tx_cbor_hex): - """Submit a signed transaction to DevKit.""" - tx_bytes = bytes.fromhex(tx_cbor_hex) - req = urllib.request.Request( - f"{self.store_url}/tx/submit", - method="POST", - data=tx_bytes, - headers={"Content-Type": "application/cbor"}, - ) - with urllib.request.urlopen(req) as resp: - return resp.read().decode("utf-8").strip().strip('"') - - # Convenience methods (not part of Provider interface) - - def topup(self, address, ada_amount=100): - """Fund an address with ADA (DevKit only).""" - data = json.dumps({"address": address, "adaAmount": ada_amount}).encode() - req = urllib.request.Request( - f"{self.admin_url}/addresses/topup", - method="POST", - data=data, - headers={"Content-Type": "application/json"}, - ) - with urllib.request.urlopen(req) as resp: - return json.loads(resp.read()) - - def reset(self): - """Reset the devnet to initial state.""" - req = urllib.request.Request( - f"{self.admin_url}/admin/devnet/reset", - method="POST", - data=b"", - ) - with urllib.request.urlopen(req) as resp: - return resp.status - - def wait_for_block(self, seconds=2): - """Wait for a new block to be produced.""" - time.sleep(seconds) - - def is_available(self): - """Check if DevKit is running.""" - try: - req = urllib.request.Request( - f"{self.admin_url}/admin/devnet", - method="GET", - ) - with urllib.request.urlopen(req, timeout=3) as resp: - return resp.status == 200 - except (urllib.error.URLError, OSError): - return False diff --git a/wrappers/python/ccl/quicktx.py b/wrappers/python/ccl/quicktx.py index 19779f7..f3ec12d 100644 --- a/wrappers/python/ccl/quicktx.py +++ b/wrappers/python/ccl/quicktx.py @@ -1,1699 +1,41 @@ import json +import yaml -class Amount: - """Helper to build amount objects for transaction operations.""" - @staticmethod - def lovelace(quantity): - """Create a lovelace amount.""" - return {"unit": "lovelace", "quantity": str(int(quantity))} - - @staticmethod - def ada(ada_amount): - """Create a lovelace amount from ADA (1 ADA = 1,000,000 lovelace).""" - return {"unit": "lovelace", "quantity": str(int(ada_amount * 1_000_000))} - - @staticmethod - def asset(unit, quantity): - """Create a native asset amount. Unit = policyId + hex asset name.""" - return {"unit": unit, "quantity": str(int(quantity))} - - -class ProviderConfig: - """Configuration for Java-side provider (lazy UTXO fetching via HTTP).""" - - def __init__(self, name, url, api_key=None, enable_cost_evaluation=None): - self.name = name - self.url = url - self.api_key = api_key - self.enable_cost_evaluation = enable_cost_evaluation - - def to_dict(self): - d = {"name": self.name, "url": self.url} - if self.api_key: - d["api_key"] = self.api_key - if self.enable_cost_evaluation is not None: - d["enable_cost_evaluation"] = self.enable_cost_evaluation - return d - - -def _build_provider_dict(provider_config): - """Convert a provider_config (dict or ProviderConfig) to a spec dict.""" - if isinstance(provider_config, ProviderConfig): - return provider_config.to_dict() - # Assume it's already a dict; copy and add enable_cost_evaluation if present - d = {"name": provider_config["name"], "url": provider_config["url"]} - if provider_config.get("api_key"): - d["api_key"] = provider_config["api_key"] - if provider_config.get("enable_cost_evaluation") is not None: - d["enable_cost_evaluation"] = provider_config["enable_cost_evaluation"] - return d - - -class TxBuilder: - """Builder for QuickTx transaction specifications. +class QuickTx: + """Builds unsigned transactions from a CCL TxPlan (YAML), fully offline. - Builds a JSON spec that is sent to ccl_quicktx_build for automatic - coin selection, fee calculation, and change balancing. + The transaction is defined by a TxPlan YAML document; the caller supplies the chain data + (UTXOs and protocol parameters). Nothing is fetched and nothing is submitted — the result + is the unsigned transaction CBOR plus its hash and fee. """ def __init__(self, bridge): self._bridge = bridge - self._operations = [] - self._from = None - self._change_address = None - self._fee_payer = None - self._utxos = None - self._protocol_params = None - self._validity = {} - self._merge_outputs = None - self._signer_count = 1 - - def pay_to_address(self, address, *amounts, script_ref_cbor_hex=None, script_ref_type=None): - """Add a payment to an address. - - Args: - address: Bech32 destination address - *amounts: One or more Amount dicts (from Amount.ada(), Amount.lovelace(), etc.) - script_ref_cbor_hex: Optional reference script CBOR hex to attach to output - script_ref_type: Script type for ref script ('plutus_v1', 'plutus_v2', 'plutus_v3') - """ - amount_list = list(amounts) - op = { - "type": "pay_to_address", - "address": address, - "amounts": amount_list, - } - if script_ref_cbor_hex: - op["script_ref_cbor_hex"] = script_ref_cbor_hex - if script_ref_type: - op["script_ref_type"] = script_ref_type - self._operations.append(op) - return self - - def pay_to_contract(self, address, amounts, datum_cbor_hex=None, datum_hash=None, - script_ref_cbor_hex=None, script_ref_type=None): - """Add a payment to a contract address with datum. - - Args: - address: Contract address - amounts: List of Amount dicts - datum_cbor_hex: Inline datum as CBOR hex - datum_hash: Datum hash hex - script_ref_cbor_hex: Optional reference script CBOR hex - script_ref_type: Script type for ref script - """ - op = { - "type": "pay_to_contract", - "address": address, - "amounts": amounts if isinstance(amounts, list) else [amounts], - } - if datum_cbor_hex: - op["datum_cbor_hex"] = datum_cbor_hex - if datum_hash: - op["datum_hash"] = datum_hash - if script_ref_cbor_hex: - op["script_ref_cbor_hex"] = script_ref_cbor_hex - if script_ref_type: - op["script_ref_type"] = script_ref_type - self._operations.append(op) - return self - - def mint_assets(self, script_json, assets, receiver): - """Mint native assets. - - Args: - script_json: Native script JSON string - assets: List of {"name": "...", "quantity": "..."} dicts - receiver: Address to receive minted assets - """ - self._operations.append({ - "type": "mint_assets", - "script_json": script_json if isinstance(script_json, str) else json.dumps(script_json), - "assets": assets, - "receiver": receiver, - }) - return self - - def attach_metadata(self, label, metadata): - """Attach metadata to the transaction. - - Args: - label: Integer metadata label (e.g. 674 for CIP-20) - metadata: Metadata value (string, number, list, or dict) - """ - self._operations.append({ - "type": "attach_metadata", - "label": label, - "metadata": metadata, - }) - return self - - def collect_from(self, utxos): - """Specify explicit UTXOs as transaction inputs (bypasses coin selection). - - Args: - utxos: List of UTXO dicts with tx_hash, output_index, address, amount - """ - self._operations.append({ - "type": "collect_from", - "collect_utxos": utxos, - }) - return self - - # Staking - - def register_stake_address(self, address): - """Register a stake address.""" - self._operations.append({"type": "register_stake_address", "address": address}) - return self - - def deregister_stake_address(self, address, refund_address=None): - """Deregister a stake address.""" - op = {"type": "deregister_stake_address", "address": address} - if refund_address: - op["refund_address"] = refund_address - self._operations.append(op) - return self - - def delegate_to(self, address, pool_id): - """Delegate stake to a pool.""" - self._operations.append({"type": "delegate_to", "address": address, "pool_id": pool_id}) - return self - - def withdraw(self, reward_address, amount, receiver=None): - """Withdraw staking rewards.""" - op = {"type": "withdraw", "reward_address": reward_address, "amount": str(amount)} - if receiver: - op["receiver"] = receiver - self._operations.append(op) - return self - - # DRep - - def register_drep(self, credential_hash, credential_type='key', anchor_url=None, anchor_data_hash=None): - """Register a DRep.""" - op = {"type": "register_drep", "credential_hash": credential_hash, "credential_type": credential_type} - if anchor_url: - op["anchor_url"] = anchor_url - if anchor_data_hash: - op["anchor_data_hash"] = anchor_data_hash - self._operations.append(op) - return self - - def unregister_drep(self, credential_hash, credential_type='key', refund_address=None, refund_amount=None): - """Unregister a DRep.""" - op = {"type": "unregister_drep", "credential_hash": credential_hash, "credential_type": credential_type} - if refund_address: - op["refund_address"] = refund_address - if refund_amount is not None: - op["refund_amount"] = str(refund_amount) - self._operations.append(op) - return self - - def update_drep(self, credential_hash, credential_type='key', anchor_url=None, anchor_data_hash=None): - """Update DRep metadata.""" - op = {"type": "update_drep", "credential_hash": credential_hash, "credential_type": credential_type} - if anchor_url: - op["anchor_url"] = anchor_url - if anchor_data_hash: - op["anchor_data_hash"] = anchor_data_hash - self._operations.append(op) - return self - - # Voting - - def delegate_voting_power_to(self, address, drep_type, drep_hash=None): - """Delegate voting power to a DRep.""" - op = {"type": "delegate_voting_power_to", "address": address, "drep_type": drep_type} - if drep_hash: - op["drep_hash"] = drep_hash - self._operations.append(op) - return self - - def create_vote(self, voter_type, voter_hash, gov_action_tx_hash, gov_action_index, vote, - anchor_url=None, anchor_data_hash=None): - """Cast a governance vote.""" - op = { - "type": "create_vote", "voter_type": voter_type, "voter_hash": voter_hash, - "gov_action_tx_hash": gov_action_tx_hash, "gov_action_index": gov_action_index, "vote": vote, - } - if anchor_url: - op["anchor_url"] = anchor_url - if anchor_data_hash: - op["anchor_data_hash"] = anchor_data_hash - self._operations.append(op) - return self - - # Governance - - def create_proposal(self, gov_action_type, return_address, anchor_url, anchor_data_hash, **kwargs): - """Create a governance proposal. - - Supported gov_action_types: info_action, treasury_withdrawals, no_confidence, - update_committee, new_constitution, hard_fork_initiation, parameter_change. - """ - op = { - "type": "create_proposal", "gov_action_type": gov_action_type, - "return_address": return_address, "anchor_url": anchor_url, "anchor_data_hash": anchor_data_hash, - } - if "withdrawals" in kwargs: - op["withdrawals"] = kwargs["withdrawals"] - # Previous governance action reference - if "gov_action_tx_hash" in kwargs: - op["gov_action_tx_hash"] = kwargs["gov_action_tx_hash"] - if "gov_action_index" in kwargs: - op["gov_action_index"] = kwargs["gov_action_index"] - # update_committee fields - if "members_to_remove" in kwargs: - op["members_to_remove"] = kwargs["members_to_remove"] - if "new_members" in kwargs: - op["new_members"] = kwargs["new_members"] - if "quorum_numerator" in kwargs: - op["quorum_numerator"] = str(kwargs["quorum_numerator"]) - if "quorum_denominator" in kwargs: - op["quorum_denominator"] = str(kwargs["quorum_denominator"]) - # new_constitution fields - if "constitution_anchor_url" in kwargs: - op["constitution_anchor_url"] = kwargs["constitution_anchor_url"] - if "constitution_anchor_data_hash" in kwargs: - op["constitution_anchor_data_hash"] = kwargs["constitution_anchor_data_hash"] - if "constitution_script_hash" in kwargs: - op["constitution_script_hash"] = kwargs["constitution_script_hash"] - # hard_fork_initiation fields - if "protocol_version_major" in kwargs: - op["protocol_version_major"] = kwargs["protocol_version_major"] - if "protocol_version_minor" in kwargs: - op["protocol_version_minor"] = kwargs["protocol_version_minor"] - # parameter_change fields - if "policy_hash" in kwargs: - op["policy_hash"] = kwargs["policy_hash"] - self._operations.append(op) - return self - - # Pool operations - - def register_pool(self, operator, vrf_key_hash, pledge, cost, margin_numerator, margin_denominator, - reward_address, pool_owners, relays=None, pool_metadata_url=None, pool_metadata_hash=None): - """Register a staking pool.""" - op = { - "type": "register_pool", "operator": operator, "vrf_key_hash": vrf_key_hash, - "pledge": str(pledge), "cost": str(cost), - "margin_numerator": str(margin_numerator), "margin_denominator": str(margin_denominator), - "reward_address": reward_address, "pool_owners": pool_owners, - } - if relays: - op["relays"] = relays - if pool_metadata_url: - op["pool_metadata_url"] = pool_metadata_url - if pool_metadata_hash: - op["pool_metadata_hash"] = pool_metadata_hash - self._operations.append(op) - return self - - def update_pool(self, operator, vrf_key_hash, pledge, cost, margin_numerator, margin_denominator, - reward_address, pool_owners, relays=None, pool_metadata_url=None, pool_metadata_hash=None): - """Update a staking pool.""" - op = { - "type": "update_pool", "operator": operator, "vrf_key_hash": vrf_key_hash, - "pledge": str(pledge), "cost": str(cost), - "margin_numerator": str(margin_numerator), "margin_denominator": str(margin_denominator), - "reward_address": reward_address, "pool_owners": pool_owners, - } - if relays: - op["relays"] = relays - if pool_metadata_url: - op["pool_metadata_url"] = pool_metadata_url - if pool_metadata_hash: - op["pool_metadata_hash"] = pool_metadata_hash - self._operations.append(op) - return self - - def retire_pool(self, pool_id, epoch): - """Retire a staking pool.""" - self._operations.append({"type": "retire_pool", "pool_id": pool_id, "epoch": epoch}) - return self - - # Treasury donation - - def donate_to_treasury(self, treasury_value, donation_amount): - """Donate ADA to the treasury.""" - self._operations.append({ - "type": "donate_to_treasury", - "treasury_value": str(treasury_value), - "donation_amount": str(donation_amount), - }) - return self - - # Native script attachment - - def attach_native_script(self, script_json): - """Attach a native script to the transaction witness set.""" - self._operations.append({ - "type": "attach_native_script", - "script_json": script_json if isinstance(script_json, str) else json.dumps(script_json), - }) - return self - - def from_address(self, address): - """Set the sender address.""" - self._from = address - return self - - def change_address(self, address): - """Set the change address (defaults to sender).""" - self._change_address = address - return self - - def fee_payer(self, address): - """Set the fee payer address.""" - self._fee_payer = address - return self - - def with_utxos(self, utxos): - """Provide UTXOs for coin selection. - - Args: - utxos: List of UTXO dicts (Blockfrost/Koios/DevKit format) - """ - self._utxos = utxos - return self - - def with_protocol_params(self, params): - """Provide protocol parameters. - - Args: - params: Protocol params dict (Blockfrost/Koios/DevKit format) - """ - self._protocol_params = params - return self - - def valid_from(self, slot): - """Set transaction validity start slot.""" - self._validity["valid_from"] = slot - return self - - def valid_to(self, slot): - """Set transaction validity end slot (TTL).""" - self._validity["valid_to"] = slot - return self - def merge_outputs(self, merge): - """Whether to merge outputs to the same address (default: True).""" - self._merge_outputs = merge - return self - - def signer_count(self, count): - """Set the number of signers for fee estimation (default: 1).""" - self._signer_count = count - return self - - def build(self, provider=None, provider_config=None): - """Build the transaction. Returns dict with tx_cbor, tx_hash, fee. + def build(self, txplan_yaml, utxos, protocol_params, exec_units=None): + """Build an unsigned transaction from a TxPlan YAML document. Args: - provider: Optional Provider instance for auto-fetching UTXOs - and protocol params (wrapper-side). - provider_config: Optional ProviderConfig or dict with 'name', 'url', - and optionally 'api_key' and 'enable_cost_evaluation' for - Java-side lazy UTXO fetching via HTTP. + txplan_yaml: the TxPlan YAML string defining the transaction(s). + utxos: list of UTXO dicts (CCL ``Utxo`` model) available to the sender. + protocol_params: protocol parameters dict (CCL ``ProtocolParams`` model). + exec_units: optional list of redeemer execution units (``[{"mem","steps"}]``), one per + redeemer in transaction order, for Plutus script transactions. Compute these with any + evaluator (Ogmios, Blockfrost, Aiken, Scalus); the bridge does not run the script. - Raises: - ValueError: If both provider and provider_config are specified. + Returns: + dict with ``tx_cbor``, ``tx_hash`` and ``fee`` (parsed from the YAML result). """ - if provider and provider_config: - raise ValueError("Cannot specify both 'provider' and 'provider_config'") - - utxos = self._utxos - protocol_params = self._protocol_params - - if provider_config: - spec = { - "operations": self._operations, - "from": self._from, - "provider": _build_provider_dict(provider_config), - "signer_count": self._signer_count, - } - if protocol_params is not None: - spec["protocol_params"] = protocol_params - elif provider: - if utxos is None and self._from: - utxos = provider.get_utxos(self._from) - if protocol_params is None: - protocol_params = provider.get_protocol_params() - spec = { - "operations": self._operations, - "from": self._from, - "utxos": utxos, - "protocol_params": protocol_params, - "signer_count": self._signer_count, - } - else: - spec = { - "operations": self._operations, - "from": self._from, - "utxos": utxos, - "protocol_params": protocol_params, - "signer_count": self._signer_count, - } - - if self._change_address: - spec["change_address"] = self._change_address - if self._fee_payer: - spec["fee_payer"] = self._fee_payer - if self._validity: - spec["validity"] = self._validity - if self._merge_outputs is not None: - spec["merge_outputs"] = self._merge_outputs - - spec_json = json.dumps(spec) + utxos_json = json.dumps(utxos) + pp_json = json.dumps(protocol_params) + exec_units_json = json.dumps(exec_units) if exec_units is not None else None rc = self._bridge._lib.ccl_quicktx_build( - self._bridge._thread, self._bridge._encode(spec_json)) - return json.loads(self._bridge._check(rc)) - - -class Tx: - """Lightweight operation collector for one transaction in a compose group. - - Use QuickTx.tx() to create, then chain operations and set sender address. - """ - - def __init__(self): - self._operations = [] - self._from = None - self._change_address = None - - def pay_to_address(self, address, *amounts, script_ref_cbor_hex=None, script_ref_type=None): - op = { - "type": "pay_to_address", - "address": address, - "amounts": list(amounts), - } - if script_ref_cbor_hex: - op["script_ref_cbor_hex"] = script_ref_cbor_hex - if script_ref_type: - op["script_ref_type"] = script_ref_type - self._operations.append(op) - return self - - def pay_to_contract(self, address, amounts, datum_cbor_hex=None, datum_hash=None, - script_ref_cbor_hex=None, script_ref_type=None): - op = { - "type": "pay_to_contract", - "address": address, - "amounts": amounts if isinstance(amounts, list) else [amounts], - } - if datum_cbor_hex: - op["datum_cbor_hex"] = datum_cbor_hex - if datum_hash: - op["datum_hash"] = datum_hash - if script_ref_cbor_hex: - op["script_ref_cbor_hex"] = script_ref_cbor_hex - if script_ref_type: - op["script_ref_type"] = script_ref_type - self._operations.append(op) - return self - - def mint_assets(self, script_json, assets, receiver): - self._operations.append({ - "type": "mint_assets", - "script_json": script_json if isinstance(script_json, str) else json.dumps(script_json), - "assets": assets, - "receiver": receiver, - }) - return self - - def attach_metadata(self, label, metadata): - self._operations.append({ - "type": "attach_metadata", - "label": label, - "metadata": metadata, - }) - return self - - def collect_from(self, utxos): - self._operations.append({ - "type": "collect_from", - "collect_utxos": utxos, - }) - return self - - # Staking - - def register_stake_address(self, address): - self._operations.append({"type": "register_stake_address", "address": address}) - return self - - def deregister_stake_address(self, address, refund_address=None): - op = {"type": "deregister_stake_address", "address": address} - if refund_address: - op["refund_address"] = refund_address - self._operations.append(op) - return self - - def delegate_to(self, address, pool_id): - self._operations.append({"type": "delegate_to", "address": address, "pool_id": pool_id}) - return self - - def withdraw(self, reward_address, amount, receiver=None): - op = {"type": "withdraw", "reward_address": reward_address, "amount": str(amount)} - if receiver: - op["receiver"] = receiver - self._operations.append(op) - return self - - # DRep - - def register_drep(self, credential_hash, credential_type='key', anchor_url=None, anchor_data_hash=None): - op = {"type": "register_drep", "credential_hash": credential_hash, "credential_type": credential_type} - if anchor_url: - op["anchor_url"] = anchor_url - if anchor_data_hash: - op["anchor_data_hash"] = anchor_data_hash - self._operations.append(op) - return self - - def unregister_drep(self, credential_hash, credential_type='key', refund_address=None, refund_amount=None): - op = {"type": "unregister_drep", "credential_hash": credential_hash, "credential_type": credential_type} - if refund_address: - op["refund_address"] = refund_address - if refund_amount is not None: - op["refund_amount"] = str(refund_amount) - self._operations.append(op) - return self - - def update_drep(self, credential_hash, credential_type='key', anchor_url=None, anchor_data_hash=None): - op = {"type": "update_drep", "credential_hash": credential_hash, "credential_type": credential_type} - if anchor_url: - op["anchor_url"] = anchor_url - if anchor_data_hash: - op["anchor_data_hash"] = anchor_data_hash - self._operations.append(op) - return self - - # Voting - - def delegate_voting_power_to(self, address, drep_type, drep_hash=None): - op = {"type": "delegate_voting_power_to", "address": address, "drep_type": drep_type} - if drep_hash: - op["drep_hash"] = drep_hash - self._operations.append(op) - return self - - def create_vote(self, voter_type, voter_hash, gov_action_tx_hash, gov_action_index, vote, - anchor_url=None, anchor_data_hash=None): - op = { - "type": "create_vote", "voter_type": voter_type, "voter_hash": voter_hash, - "gov_action_tx_hash": gov_action_tx_hash, "gov_action_index": gov_action_index, "vote": vote, - } - if anchor_url: - op["anchor_url"] = anchor_url - if anchor_data_hash: - op["anchor_data_hash"] = anchor_data_hash - self._operations.append(op) - return self - - # Governance - - def create_proposal(self, gov_action_type, return_address, anchor_url, anchor_data_hash, **kwargs): - op = { - "type": "create_proposal", "gov_action_type": gov_action_type, - "return_address": return_address, "anchor_url": anchor_url, "anchor_data_hash": anchor_data_hash, - } - if "withdrawals" in kwargs: - op["withdrawals"] = kwargs["withdrawals"] - if "gov_action_tx_hash" in kwargs: - op["gov_action_tx_hash"] = kwargs["gov_action_tx_hash"] - if "gov_action_index" in kwargs: - op["gov_action_index"] = kwargs["gov_action_index"] - if "members_to_remove" in kwargs: - op["members_to_remove"] = kwargs["members_to_remove"] - if "new_members" in kwargs: - op["new_members"] = kwargs["new_members"] - if "quorum_numerator" in kwargs: - op["quorum_numerator"] = str(kwargs["quorum_numerator"]) - if "quorum_denominator" in kwargs: - op["quorum_denominator"] = str(kwargs["quorum_denominator"]) - if "constitution_anchor_url" in kwargs: - op["constitution_anchor_url"] = kwargs["constitution_anchor_url"] - if "constitution_anchor_data_hash" in kwargs: - op["constitution_anchor_data_hash"] = kwargs["constitution_anchor_data_hash"] - if "constitution_script_hash" in kwargs: - op["constitution_script_hash"] = kwargs["constitution_script_hash"] - if "protocol_version_major" in kwargs: - op["protocol_version_major"] = kwargs["protocol_version_major"] - if "protocol_version_minor" in kwargs: - op["protocol_version_minor"] = kwargs["protocol_version_minor"] - if "policy_hash" in kwargs: - op["policy_hash"] = kwargs["policy_hash"] - self._operations.append(op) - return self - - # Pool operations - - def register_pool(self, operator, vrf_key_hash, pledge, cost, margin_numerator, margin_denominator, - reward_address, pool_owners, relays=None, pool_metadata_url=None, pool_metadata_hash=None): - op = { - "type": "register_pool", "operator": operator, "vrf_key_hash": vrf_key_hash, - "pledge": str(pledge), "cost": str(cost), - "margin_numerator": str(margin_numerator), "margin_denominator": str(margin_denominator), - "reward_address": reward_address, "pool_owners": pool_owners, - } - if relays: - op["relays"] = relays - if pool_metadata_url: - op["pool_metadata_url"] = pool_metadata_url - if pool_metadata_hash: - op["pool_metadata_hash"] = pool_metadata_hash - self._operations.append(op) - return self - - def update_pool(self, operator, vrf_key_hash, pledge, cost, margin_numerator, margin_denominator, - reward_address, pool_owners, relays=None, pool_metadata_url=None, pool_metadata_hash=None): - op = { - "type": "update_pool", "operator": operator, "vrf_key_hash": vrf_key_hash, - "pledge": str(pledge), "cost": str(cost), - "margin_numerator": str(margin_numerator), "margin_denominator": str(margin_denominator), - "reward_address": reward_address, "pool_owners": pool_owners, - } - if relays: - op["relays"] = relays - if pool_metadata_url: - op["pool_metadata_url"] = pool_metadata_url - if pool_metadata_hash: - op["pool_metadata_hash"] = pool_metadata_hash - self._operations.append(op) - return self - - def retire_pool(self, pool_id, epoch): - self._operations.append({"type": "retire_pool", "pool_id": pool_id, "epoch": epoch}) - return self - - # Treasury donation - - def donate_to_treasury(self, treasury_value, donation_amount): - self._operations.append({ - "type": "donate_to_treasury", - "treasury_value": str(treasury_value), - "donation_amount": str(donation_amount), - }) - return self - - # Native script attachment - - def attach_native_script(self, script_json): - self._operations.append({ - "type": "attach_native_script", - "script_json": script_json if isinstance(script_json, str) else json.dumps(script_json), - }) - return self - - def from_address(self, address): - self._from = address - return self - - def change_address(self, address): - self._change_address = address - return self - - def _to_spec(self): - spec = { - "from": self._from, - "operations": self._operations, - } - if self._change_address: - spec["change_address"] = self._change_address - return spec - - -class ScriptTxBuilder: - """Builder for script (Plutus) transaction specifications. - - Like TxBuilder but produces a spec with tx_type: "script_tx". - Supports script-specific operations such as collect_from with redeemer, - read_from reference inputs, mint_plutus_assets, and attach validators. - """ - - def __init__(self, bridge): - self._bridge = bridge - self._operations = [] - self._from = None - self._change_address = None - self._fee_payer = None - self._utxos = None - self._protocol_params = None - self._validity = {} - self._merge_outputs = None - self._signer_count = 1 - self._change_datum_cbor_hex = None - self._change_datum_hash = None - - # --- Common operations (same as TxBuilder) --- - - def pay_to_address(self, address, *amounts, script_ref_cbor_hex=None, script_ref_type=None): - """Add a payment to an address.""" - op = { - "type": "pay_to_address", - "address": address, - "amounts": list(amounts), - } - if script_ref_cbor_hex: - op["script_ref_cbor_hex"] = script_ref_cbor_hex - if script_ref_type: - op["script_ref_type"] = script_ref_type - self._operations.append(op) - return self - - def pay_to_contract(self, address, amounts, datum_cbor_hex=None, datum_hash=None, - script_ref_cbor_hex=None, script_ref_type=None): - """Add a payment to a contract address with datum.""" - op = { - "type": "pay_to_contract", - "address": address, - "amounts": amounts if isinstance(amounts, list) else [amounts], - } - if datum_cbor_hex: - op["datum_cbor_hex"] = datum_cbor_hex - if datum_hash: - op["datum_hash"] = datum_hash - if script_ref_cbor_hex: - op["script_ref_cbor_hex"] = script_ref_cbor_hex - if script_ref_type: - op["script_ref_type"] = script_ref_type - self._operations.append(op) - return self - - def attach_metadata(self, label, metadata): - """Attach metadata to the transaction.""" - self._operations.append({ - "type": "attach_metadata", - "label": label, - "metadata": metadata, - }) - return self - - def collect_from(self, utxos): - """Specify explicit UTXOs as transaction inputs (without redeemer).""" - self._operations.append({ - "type": "collect_from", - "collect_utxos": utxos, - }) - return self - - # --- Script-specific operations --- - - def collect_from_script(self, utxos, redeemer_cbor_hex, datum_cbor_hex=None): - """Collect UTXOs from a script address with redeemer and optional datum. - - Args: - utxos: List of UTXO dicts with tx_hash, output_index, address, amount - redeemer_cbor_hex: Redeemer CBOR hex string - datum_cbor_hex: Optional datum CBOR hex string - """ - op = { - "type": "collect_from", - "collect_utxos": utxos, - "redeemer_cbor_hex": redeemer_cbor_hex, - } - if datum_cbor_hex: - op["datum_cbor_hex"] = datum_cbor_hex - self._operations.append(op) - return self - - def read_from(self, reference_inputs): - """Add reference inputs to the transaction. - - Args: - reference_inputs: List of dicts with 'tx_hash' and 'output_index' - """ - self._operations.append({ - "type": "read_from", - "reference_inputs": reference_inputs, - }) - return self - - def mint_plutus_assets(self, script_cbor_hex, script_type, assets, redeemer_cbor_hex, - receiver=None, output_datum_cbor_hex=None): - """Mint assets using a Plutus script. - - Args: - script_cbor_hex: Plutus script CBOR hex - script_type: Script type ('plutus_v1', 'plutus_v2', 'plutus_v3') - assets: List of {"name": "...", "quantity": "..."} dicts - redeemer_cbor_hex: Redeemer CBOR hex string - receiver: Optional receiver address for minted assets - output_datum_cbor_hex: Optional output datum CBOR hex - """ - op = { - "type": "mint_plutus_assets", - "script_cbor_hex": script_cbor_hex, - "script_type": script_type, - "assets": assets, - "redeemer_cbor_hex": redeemer_cbor_hex, - } - if receiver: - op["receiver"] = receiver - if output_datum_cbor_hex: - op["output_datum_cbor_hex"] = output_datum_cbor_hex - self._operations.append(op) - return self - - def attach_spending_validator(self, script_cbor_hex, script_type): - """Attach a spending validator script.""" - self._operations.append({ - "type": "attach_spending_validator", - "script_cbor_hex": script_cbor_hex, - "script_type": script_type, - }) - return self - - def attach_certificate_validator(self, script_cbor_hex, script_type): - """Attach a certificate validator script.""" - self._operations.append({ - "type": "attach_certificate_validator", - "script_cbor_hex": script_cbor_hex, - "script_type": script_type, - }) - return self - - def attach_reward_validator(self, script_cbor_hex, script_type): - """Attach a reward validator script.""" - self._operations.append({ - "type": "attach_reward_validator", - "script_cbor_hex": script_cbor_hex, - "script_type": script_type, - }) - return self - - def attach_proposing_validator(self, script_cbor_hex, script_type): - """Attach a proposing validator script.""" - self._operations.append({ - "type": "attach_proposing_validator", - "script_cbor_hex": script_cbor_hex, - "script_type": script_type, - }) - return self - - def attach_voting_validator(self, script_cbor_hex, script_type): - """Attach a voting validator script.""" - self._operations.append({ - "type": "attach_voting_validator", - "script_cbor_hex": script_cbor_hex, - "script_type": script_type, - }) - return self - - # --- Staking (with redeemer) --- - - def deregister_stake_address(self, address, redeemer_cbor_hex, refund_address=None): - """Deregister a stake address with redeemer.""" - op = { - "type": "deregister_stake_address", - "address": address, - "redeemer_cbor_hex": redeemer_cbor_hex, - } - if refund_address: - op["refund_address"] = refund_address - self._operations.append(op) - return self - - def delegate_to(self, address, pool_id, redeemer_cbor_hex): - """Delegate stake to a pool with redeemer.""" - self._operations.append({ - "type": "delegate_to", - "address": address, - "pool_id": pool_id, - "redeemer_cbor_hex": redeemer_cbor_hex, - }) - return self - - def withdraw(self, reward_address, amount, redeemer_cbor_hex, receiver=None): - """Withdraw staking rewards with redeemer.""" - op = { - "type": "withdraw", - "reward_address": reward_address, - "amount": str(amount), - "redeemer_cbor_hex": redeemer_cbor_hex, - } - if receiver: - op["receiver"] = receiver - self._operations.append(op) - return self - - # --- DRep (with redeemer) --- - - def register_drep(self, credential_hash, credential_type, redeemer_cbor_hex, - anchor_url=None, anchor_data_hash=None): - """Register a DRep with redeemer.""" - op = { - "type": "register_drep", - "credential_hash": credential_hash, - "credential_type": credential_type, - "redeemer_cbor_hex": redeemer_cbor_hex, - } - if anchor_url: - op["anchor_url"] = anchor_url - if anchor_data_hash: - op["anchor_data_hash"] = anchor_data_hash - self._operations.append(op) - return self - - def unregister_drep(self, credential_hash, credential_type, redeemer_cbor_hex, - refund_address=None, refund_amount=None): - """Unregister a DRep with redeemer.""" - op = { - "type": "unregister_drep", - "credential_hash": credential_hash, - "credential_type": credential_type, - "redeemer_cbor_hex": redeemer_cbor_hex, - } - if refund_address: - op["refund_address"] = refund_address - if refund_amount is not None: - op["refund_amount"] = str(refund_amount) - self._operations.append(op) - return self - - def update_drep(self, credential_hash, credential_type, redeemer_cbor_hex, - anchor_url=None, anchor_data_hash=None): - """Update DRep metadata with redeemer.""" - op = { - "type": "update_drep", - "credential_hash": credential_hash, - "credential_type": credential_type, - "redeemer_cbor_hex": redeemer_cbor_hex, - } - if anchor_url: - op["anchor_url"] = anchor_url - if anchor_data_hash: - op["anchor_data_hash"] = anchor_data_hash - self._operations.append(op) - return self - - # --- Voting (with redeemer) --- - - def delegate_voting_power_to(self, address, drep_type, drep_hash, redeemer_cbor_hex): - """Delegate voting power to a DRep with redeemer.""" - op = { - "type": "delegate_voting_power_to", - "address": address, - "drep_type": drep_type, - "redeemer_cbor_hex": redeemer_cbor_hex, - } - if drep_hash: - op["drep_hash"] = drep_hash - self._operations.append(op) - return self - - def create_vote(self, voter_type, voter_hash, gov_action_tx_hash, gov_action_index, vote, - redeemer_cbor_hex, anchor_url=None, anchor_data_hash=None): - """Cast a governance vote with redeemer.""" - op = { - "type": "create_vote", - "voter_type": voter_type, - "voter_hash": voter_hash, - "gov_action_tx_hash": gov_action_tx_hash, - "gov_action_index": gov_action_index, - "vote": vote, - "redeemer_cbor_hex": redeemer_cbor_hex, - } - if anchor_url: - op["anchor_url"] = anchor_url - if anchor_data_hash: - op["anchor_data_hash"] = anchor_data_hash - self._operations.append(op) - return self - - # --- Governance (with redeemer) --- - - def create_proposal(self, gov_action_type, return_address, anchor_url, anchor_data_hash, - redeemer_cbor_hex, **kwargs): - """Create a governance proposal with redeemer.""" - op = { - "type": "create_proposal", - "gov_action_type": gov_action_type, - "return_address": return_address, - "anchor_url": anchor_url, - "anchor_data_hash": anchor_data_hash, - "redeemer_cbor_hex": redeemer_cbor_hex, - } - if "withdrawals" in kwargs: - op["withdrawals"] = kwargs["withdrawals"] - if "gov_action_tx_hash" in kwargs: - op["gov_action_tx_hash"] = kwargs["gov_action_tx_hash"] - if "gov_action_index" in kwargs: - op["gov_action_index"] = kwargs["gov_action_index"] - if "members_to_remove" in kwargs: - op["members_to_remove"] = kwargs["members_to_remove"] - if "new_members" in kwargs: - op["new_members"] = kwargs["new_members"] - if "quorum_numerator" in kwargs: - op["quorum_numerator"] = str(kwargs["quorum_numerator"]) - if "quorum_denominator" in kwargs: - op["quorum_denominator"] = str(kwargs["quorum_denominator"]) - if "constitution_anchor_url" in kwargs: - op["constitution_anchor_url"] = kwargs["constitution_anchor_url"] - if "constitution_anchor_data_hash" in kwargs: - op["constitution_anchor_data_hash"] = kwargs["constitution_anchor_data_hash"] - if "constitution_script_hash" in kwargs: - op["constitution_script_hash"] = kwargs["constitution_script_hash"] - if "protocol_version_major" in kwargs: - op["protocol_version_major"] = kwargs["protocol_version_major"] - if "protocol_version_minor" in kwargs: - op["protocol_version_minor"] = kwargs["protocol_version_minor"] - if "policy_hash" in kwargs: - op["policy_hash"] = kwargs["policy_hash"] - self._operations.append(op) - return self - - # Treasury donation - - def donate_to_treasury(self, treasury_value, donation_amount, redeemer_cbor_hex): - """Donate ADA to the treasury with redeemer.""" - self._operations.append({ - "type": "donate_to_treasury", - "treasury_value": str(treasury_value), - "donation_amount": str(donation_amount), - "redeemer_cbor_hex": redeemer_cbor_hex, - }) - return self - - # --- Builder configuration --- - - def from_address(self, address): - """Set the sender address.""" - self._from = address - return self - - def change_address(self, address): - """Set the change address (defaults to sender).""" - self._change_address = address - return self - - def change_datum(self, datum_cbor_hex): - """Set inline datum for the change output.""" - self._change_datum_cbor_hex = datum_cbor_hex - return self - - def change_datum_hash(self, hash): - """Set datum hash for the change output.""" - self._change_datum_hash = hash - return self - - def fee_payer(self, address): - """Set the fee payer address.""" - self._fee_payer = address - return self - - def with_utxos(self, utxos): - """Provide UTXOs for coin selection.""" - self._utxos = utxos - return self - - def with_protocol_params(self, params): - """Provide protocol parameters.""" - self._protocol_params = params - return self - - def valid_from(self, slot): - """Set transaction validity start slot.""" - self._validity["valid_from"] = slot - return self - - def valid_to(self, slot): - """Set transaction validity end slot (TTL).""" - self._validity["valid_to"] = slot - return self - - def merge_outputs(self, merge): - """Whether to merge outputs to the same address (default: True).""" - self._merge_outputs = merge - return self - - def signer_count(self, count): - """Set the number of signers for fee estimation (default: 1).""" - self._signer_count = count - return self - - def build(self, provider=None, provider_config=None): - """Build the script transaction. Returns dict with tx_cbor, tx_hash, fee. - - Args: - provider: Optional Provider instance for auto-fetching UTXOs - and protocol params (wrapper-side). - provider_config: Optional ProviderConfig or dict with 'name', 'url', - and optionally 'api_key' and 'enable_cost_evaluation' for - Java-side lazy UTXO fetching via HTTP. - - Raises: - ValueError: If both provider and provider_config are specified. - """ - if provider and provider_config: - raise ValueError("Cannot specify both 'provider' and 'provider_config'") - - utxos = self._utxos - protocol_params = self._protocol_params - - if provider_config: - spec = { - "tx_type": "script_tx", - "operations": self._operations, - "from": self._from, - "provider": _build_provider_dict(provider_config), - "signer_count": self._signer_count, - } - if protocol_params is not None: - spec["protocol_params"] = protocol_params - elif provider: - if utxos is None and self._from: - utxos = provider.get_utxos(self._from) - if protocol_params is None: - protocol_params = provider.get_protocol_params() - spec = { - "tx_type": "script_tx", - "operations": self._operations, - "from": self._from, - "utxos": utxos, - "protocol_params": protocol_params, - "signer_count": self._signer_count, - } - else: - spec = { - "tx_type": "script_tx", - "operations": self._operations, - "from": self._from, - "utxos": utxos, - "protocol_params": protocol_params, - "signer_count": self._signer_count, - } - - if self._change_address: - spec["change_address"] = self._change_address - if self._fee_payer: - spec["fee_payer"] = self._fee_payer - if self._validity: - spec["validity"] = self._validity - if self._merge_outputs is not None: - spec["merge_outputs"] = self._merge_outputs - if self._change_datum_cbor_hex: - spec["change_datum_cbor_hex"] = self._change_datum_cbor_hex - if self._change_datum_hash: - spec["change_datum_hash"] = self._change_datum_hash - - spec_json = json.dumps(spec) - rc = self._bridge._lib.ccl_quicktx_build( - self._bridge._thread, self._bridge._encode(spec_json)) - return json.loads(self._bridge._check(rc)) - - def build_with_provider(self, provider): - """Build with a Provider that auto-fetches UTXOs and protocol params. - - Args: - provider: Provider instance (e.g. YaciDevKitProvider) - """ - if self._utxos is None and self._from: - self._utxos = provider.get_utxos(self._from) - if self._protocol_params is None: - self._protocol_params = provider.get_protocol_params() - return self.build() - - -class ScriptTx: - """Lightweight operation collector for a script transaction in a compose group. - - Like Tx but with tx_type: "script_tx" in _to_spec(). Supports script-specific - operations such as collect_from with redeemer, read_from, mint_plutus_assets, - and attach validators. - """ - - def __init__(self): - self._operations = [] - self._from = None - self._change_address = None - self._change_datum_cbor_hex = None - self._change_datum_hash = None - - # --- Common operations --- - - def pay_to_address(self, address, *amounts, script_ref_cbor_hex=None, script_ref_type=None): - op = { - "type": "pay_to_address", - "address": address, - "amounts": list(amounts), - } - if script_ref_cbor_hex: - op["script_ref_cbor_hex"] = script_ref_cbor_hex - if script_ref_type: - op["script_ref_type"] = script_ref_type - self._operations.append(op) - return self - - def pay_to_contract(self, address, amounts, datum_cbor_hex=None, datum_hash=None, - script_ref_cbor_hex=None, script_ref_type=None): - op = { - "type": "pay_to_contract", - "address": address, - "amounts": amounts if isinstance(amounts, list) else [amounts], - } - if datum_cbor_hex: - op["datum_cbor_hex"] = datum_cbor_hex - if datum_hash: - op["datum_hash"] = datum_hash - if script_ref_cbor_hex: - op["script_ref_cbor_hex"] = script_ref_cbor_hex - if script_ref_type: - op["script_ref_type"] = script_ref_type - self._operations.append(op) - return self - - def attach_metadata(self, label, metadata): - self._operations.append({ - "type": "attach_metadata", - "label": label, - "metadata": metadata, - }) - return self - - def collect_from(self, utxos): - self._operations.append({ - "type": "collect_from", - "collect_utxos": utxos, - }) - return self - - # --- Script-specific operations --- - - def collect_from_script(self, utxos, redeemer_cbor_hex, datum_cbor_hex=None): - """Collect UTXOs from a script address with redeemer and optional datum.""" - op = { - "type": "collect_from", - "collect_utxos": utxos, - "redeemer_cbor_hex": redeemer_cbor_hex, - } - if datum_cbor_hex: - op["datum_cbor_hex"] = datum_cbor_hex - self._operations.append(op) - return self - - def read_from(self, reference_inputs): - """Add reference inputs to the transaction.""" - self._operations.append({ - "type": "read_from", - "reference_inputs": reference_inputs, - }) - return self - - def mint_plutus_assets(self, script_cbor_hex, script_type, assets, redeemer_cbor_hex, - receiver=None, output_datum_cbor_hex=None): - """Mint assets using a Plutus script.""" - op = { - "type": "mint_plutus_assets", - "script_cbor_hex": script_cbor_hex, - "script_type": script_type, - "assets": assets, - "redeemer_cbor_hex": redeemer_cbor_hex, - } - if receiver: - op["receiver"] = receiver - if output_datum_cbor_hex: - op["output_datum_cbor_hex"] = output_datum_cbor_hex - self._operations.append(op) - return self - - def attach_spending_validator(self, script_cbor_hex, script_type): - self._operations.append({ - "type": "attach_spending_validator", - "script_cbor_hex": script_cbor_hex, - "script_type": script_type, - }) - return self - - def attach_certificate_validator(self, script_cbor_hex, script_type): - self._operations.append({ - "type": "attach_certificate_validator", - "script_cbor_hex": script_cbor_hex, - "script_type": script_type, - }) - return self - - def attach_reward_validator(self, script_cbor_hex, script_type): - self._operations.append({ - "type": "attach_reward_validator", - "script_cbor_hex": script_cbor_hex, - "script_type": script_type, - }) - return self - - def attach_proposing_validator(self, script_cbor_hex, script_type): - self._operations.append({ - "type": "attach_proposing_validator", - "script_cbor_hex": script_cbor_hex, - "script_type": script_type, - }) - return self - - def attach_voting_validator(self, script_cbor_hex, script_type): - self._operations.append({ - "type": "attach_voting_validator", - "script_cbor_hex": script_cbor_hex, - "script_type": script_type, - }) - return self - - # --- Staking (with redeemer) --- - - def deregister_stake_address(self, address, redeemer_cbor_hex, refund_address=None): - op = { - "type": "deregister_stake_address", - "address": address, - "redeemer_cbor_hex": redeemer_cbor_hex, - } - if refund_address: - op["refund_address"] = refund_address - self._operations.append(op) - return self - - def delegate_to(self, address, pool_id, redeemer_cbor_hex): - self._operations.append({ - "type": "delegate_to", - "address": address, - "pool_id": pool_id, - "redeemer_cbor_hex": redeemer_cbor_hex, - }) - return self - - def withdraw(self, reward_address, amount, redeemer_cbor_hex, receiver=None): - op = { - "type": "withdraw", - "reward_address": reward_address, - "amount": str(amount), - "redeemer_cbor_hex": redeemer_cbor_hex, - } - if receiver: - op["receiver"] = receiver - self._operations.append(op) - return self - - # --- DRep (with redeemer) --- - - def register_drep(self, credential_hash, credential_type, redeemer_cbor_hex, - anchor_url=None, anchor_data_hash=None): - op = { - "type": "register_drep", - "credential_hash": credential_hash, - "credential_type": credential_type, - "redeemer_cbor_hex": redeemer_cbor_hex, - } - if anchor_url: - op["anchor_url"] = anchor_url - if anchor_data_hash: - op["anchor_data_hash"] = anchor_data_hash - self._operations.append(op) - return self - - def unregister_drep(self, credential_hash, credential_type, redeemer_cbor_hex, - refund_address=None, refund_amount=None): - op = { - "type": "unregister_drep", - "credential_hash": credential_hash, - "credential_type": credential_type, - "redeemer_cbor_hex": redeemer_cbor_hex, - } - if refund_address: - op["refund_address"] = refund_address - if refund_amount is not None: - op["refund_amount"] = str(refund_amount) - self._operations.append(op) - return self - - def update_drep(self, credential_hash, credential_type, redeemer_cbor_hex, - anchor_url=None, anchor_data_hash=None): - op = { - "type": "update_drep", - "credential_hash": credential_hash, - "credential_type": credential_type, - "redeemer_cbor_hex": redeemer_cbor_hex, - } - if anchor_url: - op["anchor_url"] = anchor_url - if anchor_data_hash: - op["anchor_data_hash"] = anchor_data_hash - self._operations.append(op) - return self - - # --- Voting (with redeemer) --- - - def delegate_voting_power_to(self, address, drep_type, drep_hash, redeemer_cbor_hex): - op = { - "type": "delegate_voting_power_to", - "address": address, - "drep_type": drep_type, - "redeemer_cbor_hex": redeemer_cbor_hex, - } - if drep_hash: - op["drep_hash"] = drep_hash - self._operations.append(op) - return self - - def create_vote(self, voter_type, voter_hash, gov_action_tx_hash, gov_action_index, vote, - redeemer_cbor_hex, anchor_url=None, anchor_data_hash=None): - op = { - "type": "create_vote", - "voter_type": voter_type, - "voter_hash": voter_hash, - "gov_action_tx_hash": gov_action_tx_hash, - "gov_action_index": gov_action_index, - "vote": vote, - "redeemer_cbor_hex": redeemer_cbor_hex, - } - if anchor_url: - op["anchor_url"] = anchor_url - if anchor_data_hash: - op["anchor_data_hash"] = anchor_data_hash - self._operations.append(op) - return self - - # --- Governance (with redeemer) --- - - def create_proposal(self, gov_action_type, return_address, anchor_url, anchor_data_hash, - redeemer_cbor_hex, **kwargs): - op = { - "type": "create_proposal", - "gov_action_type": gov_action_type, - "return_address": return_address, - "anchor_url": anchor_url, - "anchor_data_hash": anchor_data_hash, - "redeemer_cbor_hex": redeemer_cbor_hex, - } - if "withdrawals" in kwargs: - op["withdrawals"] = kwargs["withdrawals"] - if "gov_action_tx_hash" in kwargs: - op["gov_action_tx_hash"] = kwargs["gov_action_tx_hash"] - if "gov_action_index" in kwargs: - op["gov_action_index"] = kwargs["gov_action_index"] - if "members_to_remove" in kwargs: - op["members_to_remove"] = kwargs["members_to_remove"] - if "new_members" in kwargs: - op["new_members"] = kwargs["new_members"] - if "quorum_numerator" in kwargs: - op["quorum_numerator"] = str(kwargs["quorum_numerator"]) - if "quorum_denominator" in kwargs: - op["quorum_denominator"] = str(kwargs["quorum_denominator"]) - if "constitution_anchor_url" in kwargs: - op["constitution_anchor_url"] = kwargs["constitution_anchor_url"] - if "constitution_anchor_data_hash" in kwargs: - op["constitution_anchor_data_hash"] = kwargs["constitution_anchor_data_hash"] - if "constitution_script_hash" in kwargs: - op["constitution_script_hash"] = kwargs["constitution_script_hash"] - if "protocol_version_major" in kwargs: - op["protocol_version_major"] = kwargs["protocol_version_major"] - if "protocol_version_minor" in kwargs: - op["protocol_version_minor"] = kwargs["protocol_version_minor"] - if "policy_hash" in kwargs: - op["policy_hash"] = kwargs["policy_hash"] - self._operations.append(op) - return self - - # Treasury donation - - def donate_to_treasury(self, treasury_value, donation_amount, redeemer_cbor_hex): - self._operations.append({ - "type": "donate_to_treasury", - "treasury_value": str(treasury_value), - "donation_amount": str(donation_amount), - "redeemer_cbor_hex": redeemer_cbor_hex, - }) - return self - - # --- Address configuration --- - - def from_address(self, address): - self._from = address - return self - - def change_address(self, address): - self._change_address = address - return self - - def change_datum(self, datum_cbor_hex): - """Set inline datum for the change output.""" - self._change_datum_cbor_hex = datum_cbor_hex - return self - - def change_datum_hash(self, hash): - """Set datum hash for the change output.""" - self._change_datum_hash = hash - return self - - def _to_spec(self): - spec = { - "tx_type": "script_tx", - "from": self._from, - "operations": self._operations, - } - if self._change_address: - spec["change_address"] = self._change_address - if self._change_datum_cbor_hex: - spec["change_datum_cbor_hex"] = self._change_datum_cbor_hex - if self._change_datum_hash: - spec["change_datum_hash"] = self._change_datum_hash - return spec - - -class ComposeTxBuilder: - """Builder for composing multiple Tx/ScriptTx objects into a single transaction.""" - - def __init__(self, bridge, txs): - self._bridge = bridge - self._txs = list(txs) - self._fee_payer = None - self._utxos = None - self._protocol_params = None - self._validity = {} - self._merge_outputs = None - self._signer_count = None - - def fee_payer(self, address): - self._fee_payer = address - return self - - def with_utxos(self, utxos): - self._utxos = utxos - return self - - def with_protocol_params(self, params): - self._protocol_params = params - return self - - def valid_from(self, slot): - self._validity["valid_from"] = slot - return self - - def valid_to(self, slot): - self._validity["valid_to"] = slot - return self - - def merge_outputs(self, merge): - self._merge_outputs = merge - return self - - def signer_count(self, count): - self._signer_count = count - return self - - def build(self, provider=None, provider_config=None): - """Build the composed transaction. - - Args: - provider: Optional Provider instance for auto-fetching UTXOs - and protocol params (wrapper-side). - provider_config: Optional ProviderConfig or dict with 'name', 'url', - and optionally 'api_key' and 'enable_cost_evaluation' for - Java-side lazy UTXO fetching via HTTP. - - Raises: - ValueError: If both provider and provider_config are specified. - """ - if provider and provider_config: - raise ValueError("Cannot specify both 'provider' and 'provider_config'") - - utxos = self._utxos - protocol_params = self._protocol_params - - if provider_config: - spec = { - "transactions": [tx._to_spec() for tx in self._txs], - "fee_payer": self._fee_payer, - "provider": _build_provider_dict(provider_config), - } - if protocol_params is not None: - spec["protocol_params"] = protocol_params - elif provider: - if utxos is None: - addresses = set() - for tx in self._txs: - if tx._from: - addresses.add(tx._from) - all_utxos = [] - for addr in addresses: - all_utxos.extend(provider.get_utxos(addr)) - utxos = all_utxos - if protocol_params is None: - protocol_params = provider.get_protocol_params() - spec = { - "transactions": [tx._to_spec() for tx in self._txs], - "fee_payer": self._fee_payer, - "utxos": utxos, - "protocol_params": protocol_params, - } - else: - spec = { - "transactions": [tx._to_spec() for tx in self._txs], - "fee_payer": self._fee_payer, - "utxos": utxos, - "protocol_params": protocol_params, - } - - if self._signer_count is not None: - spec["signer_count"] = self._signer_count - if self._validity: - spec["validity"] = self._validity - if self._merge_outputs is not None: - spec["merge_outputs"] = self._merge_outputs - - spec_json = json.dumps(spec) - rc = self._bridge._lib.ccl_quicktx_build( - self._bridge._thread, self._bridge._encode(spec_json)) - return json.loads(self._bridge._check(rc)) - - -class QuickTx: - """QuickTx namespace for CCL bridge.""" - - def __init__(self, bridge): - self._bridge = bridge - - def new_tx(self): - """Create a new TxBuilder.""" - return TxBuilder(self._bridge) - - def tx(self): - """Create a new Tx for use with compose().""" - return Tx() - - def new_script_tx(self): - """Create a new ScriptTxBuilder for Plutus script transactions.""" - return ScriptTxBuilder(self._bridge) - - def script_tx(self): - """Create a new ScriptTx for use with compose().""" - return ScriptTx() - - def compose(self, *txs): - """Compose multiple Tx/ScriptTx objects into a single transaction. - - Args: - *txs: Tx or ScriptTx objects to compose - """ - return ComposeTxBuilder(self._bridge, txs) + self._bridge._thread, + self._bridge._encode(txplan_yaml), + self._bridge._encode(utxos_json), + self._bridge._encode(pp_json), + self._bridge._encode(exec_units_json), + ) + return yaml.safe_load(self._bridge._check(rc)) diff --git a/wrappers/python/examples/01_account_and_keys.py b/wrappers/python/examples/01_account_and_keys.py new file mode 100644 index 0000000..5f40e26 --- /dev/null +++ b/wrappers/python/examples/01_account_and_keys.py @@ -0,0 +1,42 @@ +"""Account creation and key derivation (offline). + +Run from the repo root: + + LIB_DIR=core/build/native/nativeCompile + PYTHONPATH=wrappers/python CCL_LIB_PATH=$LIB_DIR \ + DYLD_LIBRARY_PATH=$LIB_DIR LD_LIBRARY_PATH=$LIB_DIR \ + python3 wrappers/python/examples/01_account_and_keys.py +""" +from ccl._ffi import CclLib + + +def main(): + lib = CclLib() + try: + # 1. Create a brand-new testnet account (random mnemonic). + account = lib.account.create(CclLib.TESTNET) + mnemonic = account["mnemonic"] + print("Created account") + print(" base address:", account["base_address"]) + print(" mnemonic :", mnemonic) + + # 2. Restore the same account from its mnemonic — the address must match. + restored = lib.account.from_mnemonic(mnemonic, CclLib.TESTNET, 0, 0) + assert restored["base_address"] == account["base_address"] + print("Restored from mnemonic — address matches:", restored["base_address"]) + + # 3. Derive keys. + priv = lib.account.get_private_key(mnemonic, CclLib.TESTNET) + pub = lib.account.get_public_key(mnemonic, CclLib.TESTNET) + print(" private key (extended, hex):", priv) + print(" public key (hex) :", pub) + + # 4. Derive the governance DRep ID. + drep_id = lib.account.get_drep_id(mnemonic, CclLib.TESTNET) + print(" DRep ID:", drep_id) + finally: + lib.close() + + +if __name__ == "__main__": + main() diff --git a/wrappers/python/examples/02_primitives.py b/wrappers/python/examples/02_primitives.py new file mode 100644 index 0000000..1bcc14e --- /dev/null +++ b/wrappers/python/examples/02_primitives.py @@ -0,0 +1,50 @@ +"""Crypto and address primitives (offline). + +Run from the repo root: + + LIB_DIR=core/build/native/nativeCompile + PYTHONPATH=wrappers/python CCL_LIB_PATH=$LIB_DIR \ + DYLD_LIBRARY_PATH=$LIB_DIR LD_LIBRARY_PATH=$LIB_DIR \ + python3 wrappers/python/examples/02_primitives.py +""" +from ccl._ffi import CclLib + + +def main(): + lib = CclLib() + try: + # --- Mnemonics --- + mnemonic = lib.crypto.generate_mnemonic(24) + print("Generated 24-word mnemonic:", mnemonic) + print(" valid? ", lib.crypto.validate_mnemonic(mnemonic)) + print(" 'not a real mnemonic' valid?", lib.crypto.validate_mnemonic("not a real mnemonic")) + + # --- Blake2b hashing (hex in -> hex out). "Hello" == 48656c6c6f --- + print("Blake2b-256('Hello'):", lib.crypto.blake2b_256("48656c6c6f")) + print("Blake2b-224('Hello'):", lib.crypto.blake2b_224("48656c6c6f")) + + # --- Ed25519 signing --- + # account_get_private_key returns the 64-byte extended key; ccl_crypto_sign + # expects a 32-byte Ed25519 key, so take the first 32 bytes (64 hex chars). + acct = lib.account.create(CclLib.TESTNET) + sk = lib.account.get_private_key(acct["mnemonic"], CclLib.TESTNET)[:64] + pk = lib.account.get_public_key(acct["mnemonic"], CclLib.TESTNET) + message_hex = "68656c6c6f" # "hello" + signature = lib.crypto.sign(message_hex, sk) + print("Ed25519 signature:", signature) + # A tampered signature is correctly rejected. + print(" verify(fake signature) ->", lib.crypto.verify("00" * 64, message_hex, pk)) + + # --- Address parsing & validation --- + addr = acct["base_address"] + print("Address valid?", lib.address.validate(addr)) + print("Address info :", lib.address.info(addr)) + raw = lib.address.to_bytes(addr) + print("Address -> bytes -> address round-trips:", + lib.address.from_bytes(raw) == addr) + finally: + lib.close() + + +if __name__ == "__main__": + main() diff --git a/wrappers/python/examples/03_build_and_sign_tx.py b/wrappers/python/examples/03_build_and_sign_tx.py new file mode 100644 index 0000000..3180bf5 --- /dev/null +++ b/wrappers/python/examples/03_build_and_sign_tx.py @@ -0,0 +1,70 @@ +"""Build and sign a payment transaction fully offline from a TxPlan (YAML). + +The transaction is defined as a TxPlan YAML document; we supply the UTXOs and protocol +parameters ourselves (no node / no provider), build the unsigned transaction, then sign it +locally. Submitting it to a network is a separate, online step. + +Run from the repo root: + + LIB_DIR=core/build/native/nativeCompile + PYTHONPATH=wrappers/python CCL_LIB_PATH=$LIB_DIR \ + DYLD_LIBRARY_PATH=$LIB_DIR LD_LIBRARY_PATH=$LIB_DIR \ + python3 wrappers/python/examples/03_build_and_sign_tx.py +""" +from ccl._ffi import CclLib + +# Minimal protocol parameters (CCL ProtocolParams model). +PROTOCOL_PARAMS = { + "min_fee_a": 44, "min_fee_b": 155381, "max_tx_size": 16384, + "key_deposit": "2000000", "pool_deposit": "500000000", + "coins_per_utxo_size": "4310", "max_val_size": "5000", + "max_tx_ex_mem": "10000000", "max_tx_ex_steps": "10000000000", + "price_mem": 0.0577, "price_step": 0.0000721, "collateral_percent": 150, + "max_collateral_inputs": 3, +} + + +def main(): + lib = CclLib() + try: + sender = lib.account.create(CclLib.TESTNET) + receiver = lib.account.create(CclLib.TESTNET) + + # A static UTXO the sender controls (100 ADA), instead of querying a node. + utxos = [{ + "tx_hash": "a" * 64, + "output_index": 0, + "address": sender["base_address"], + "amount": [{"unit": "lovelace", "quantity": "100000000"}], + }] + + # Define the transaction as a TxPlan YAML document: pay 5 ADA to the receiver. + txplan_yaml = f""" +version: 1.0 +transaction: + - tx: + from: {sender['base_address']} + intents: + - type: payment + address: {receiver['base_address']} + amounts: + - unit: lovelace + quantity: "5000000" +""" + + result = lib.quicktx.build(txplan_yaml, utxos, PROTOCOL_PARAMS) + print("Built unsigned transaction from TxPlan YAML") + print(" tx hash:", result["tx_hash"]) + print(" fee :", result["fee"]) + print(" cbor :", result["tx_cbor"][:80], "...") + + signed = lib.account.sign_tx( + sender["mnemonic"], result["tx_cbor"], CclLib.TESTNET, 0, 0) + print("Signed transaction cbor:", signed[:80], "...") + print("\nNext step (not shown): submit `signed` to a Cardano node over HTTP.") + finally: + lib.close() + + +if __name__ == "__main__": + main() diff --git a/wrappers/python/pyproject.toml b/wrappers/python/pyproject.toml index ae4b836..3a27875 100644 --- a/wrappers/python/pyproject.toml +++ b/wrappers/python/pyproject.toml @@ -3,6 +3,7 @@ name = "ccl" version = "0.1.0" description = "Python bindings for Cardano Client Lib (CCL) via GraalVM native library" requires-python = ">=3.8" +dependencies = ["pyyaml>=6.0"] [build-system] requires = ["setuptools>=61.0"] diff --git a/wrappers/python/tests/devkit_helper.py b/wrappers/python/tests/devkit_helper.py index eb84d47..7b6841b 100644 --- a/wrappers/python/tests/devkit_helper.py +++ b/wrappers/python/tests/devkit_helper.py @@ -62,8 +62,12 @@ def submit_tx(self, tx_cbor_hex): data=tx_bytes, headers={"Content-Type": "application/cbor"}, ) - with urllib.request.urlopen(req) as resp: - return resp.read().decode("utf-8").strip().strip('"') + try: + with urllib.request.urlopen(req) as resp: + return resp.read().decode("utf-8").strip().strip('"') + except urllib.error.HTTPError as e: + body = e.read().decode("utf-8", "replace") + raise RuntimeError(f"tx submit failed: HTTP {e.code}: {body}") from None def get_tx(self, tx_hash): """Get transaction details by hash.""" diff --git a/wrappers/python/tests/test_compose_integration.py b/wrappers/python/tests/test_compose_integration.py deleted file mode 100644 index b539be9..0000000 --- a/wrappers/python/tests/test_compose_integration.py +++ /dev/null @@ -1,183 +0,0 @@ -"""Integration tests for QuickTx compose (multi-Tx) with Yaci DevKit. - -Requires: -- Yaci DevKit running on port 10000 -- Native library built: ./gradlew :core:nativeCompile - -Run with: - PYTHONPATH=wrappers/python CCL_LIB_PATH=core/build/native/nativeCompile \ - pytest wrappers/python/tests/test_compose_integration.py -v -""" -import time -import pytest -from ccl._ffi import CclLib, CclError -from ccl.quicktx import Amount -from tests.devkit_helper import DevKitHelper - - -@pytest.fixture(scope="module") -def devkit(): - """Provide a DevKit helper, skip if DevKit is not running.""" - helper = DevKitHelper() - if not helper.is_available(): - pytest.skip("Yaci DevKit is not running on port 10000") - helper.reset() - time.sleep(3) # wait for devnet reset - return helper - - -@pytest.fixture(scope="module") -def ccl_lib(): - """Create a shared CclLib instance.""" - lib = CclLib() - yield lib - lib.close() - - -def fund_account(ccl_lib, devkit, ada=150): - """Create and fund a new account.""" - account = ccl_lib.account.create(CclLib.TESTNET) - devkit.topup(account["base_address"], ada) - devkit.wait_for_block(2) - return account - - -def get_lovelace(devkit, address): - """Sum all lovelace at an address.""" - utxos = devkit.get_utxos(address) - return sum( - int(a["quantity"]) - for u in utxos - for a in u["amount"] - if a["unit"] == "lovelace" - ) - - -def test_compose_two_senders(ccl_lib, devkit): - """Compose two Txs from different senders, sign with both, submit, verify.""" - sender1 = fund_account(ccl_lib, devkit) - sender2 = fund_account(ccl_lib, devkit) - receiver1 = ccl_lib.account.create(CclLib.TESTNET) - receiver2 = ccl_lib.account.create(CclLib.TESTNET) - - tx1 = ccl_lib.quicktx.tx() \ - .pay_to_address(receiver1["base_address"], Amount.ada(5)) \ - .from_address(sender1["base_address"]) - - tx2 = ccl_lib.quicktx.tx() \ - .pay_to_address(receiver2["base_address"], Amount.ada(3)) \ - .from_address(sender2["base_address"]) - - # Gather UTXOs for both senders - utxos1 = devkit.get_utxos(sender1["base_address"]) - utxos2 = devkit.get_utxos(sender2["base_address"]) - all_utxos = utxos1 + utxos2 - pp = devkit.get_protocol_params() - - result = ccl_lib.quicktx.compose(tx1, tx2) \ - .fee_payer(sender1["base_address"]) \ - .with_utxos(all_utxos) \ - .with_protocol_params(pp) \ - .signer_count(2) \ - .build() - - assert len(result["tx_cbor"]) > 0 - assert len(result["tx_hash"]) == 64 - assert int(result["fee"]) > 0 - - # Sign with both senders - signed = ccl_lib.account.sign_tx( - sender1["mnemonic"], result["tx_cbor"], - CclLib.TESTNET, 0, 0) - signed = ccl_lib.account.sign_tx( - sender2["mnemonic"], signed, - CclLib.TESTNET, 0, 0) - - # Submit - devkit.submit_tx(signed) - devkit.wait_for_block(3) - - # Verify both receivers got their ADA - assert get_lovelace(devkit, receiver1["base_address"]) == 5_000_000 - assert get_lovelace(devkit, receiver2["base_address"]) == 3_000_000 - - -def test_compose_with_metadata(ccl_lib, devkit): - """Compose two Txs where one has metadata attached.""" - sender1 = fund_account(ccl_lib, devkit) - sender2 = fund_account(ccl_lib, devkit) - receiver1 = ccl_lib.account.create(CclLib.TESTNET) - receiver2 = ccl_lib.account.create(CclLib.TESTNET) - - tx1 = ccl_lib.quicktx.tx() \ - .pay_to_address(receiver1["base_address"], Amount.ada(5)) \ - .attach_metadata(674, {"msg": ["Compose integration test"]}) \ - .from_address(sender1["base_address"]) - - tx2 = ccl_lib.quicktx.tx() \ - .pay_to_address(receiver2["base_address"], Amount.ada(3)) \ - .from_address(sender2["base_address"]) - - utxos1 = devkit.get_utxos(sender1["base_address"]) - utxos2 = devkit.get_utxos(sender2["base_address"]) - pp = devkit.get_protocol_params() - - result = ccl_lib.quicktx.compose(tx1, tx2) \ - .fee_payer(sender1["base_address"]) \ - .with_utxos(utxos1 + utxos2) \ - .with_protocol_params(pp) \ - .signer_count(2) \ - .build() - - signed = ccl_lib.account.sign_tx( - sender1["mnemonic"], result["tx_cbor"], - CclLib.TESTNET, 0, 0) - signed = ccl_lib.account.sign_tx( - sender2["mnemonic"], signed, - CclLib.TESTNET, 0, 0) - - devkit.submit_tx(signed) - devkit.wait_for_block(3) - - # Verify tx on-chain - tx_info = devkit.get_tx(result["tx_hash"]) - assert tx_info is not None - assert get_lovelace(devkit, receiver1["base_address"]) == 5_000_000 - assert get_lovelace(devkit, receiver2["base_address"]) == 3_000_000 - - -def test_compose_with_provider(ccl_lib, devkit): - """Compose using provider for auto-fetching UTXOs and protocol params.""" - sender1 = fund_account(ccl_lib, devkit) - sender2 = fund_account(ccl_lib, devkit) - receiver1 = ccl_lib.account.create(CclLib.TESTNET) - receiver2 = ccl_lib.account.create(CclLib.TESTNET) - - tx1 = ccl_lib.quicktx.tx() \ - .pay_to_address(receiver1["base_address"], Amount.ada(5)) \ - .from_address(sender1["base_address"]) - - tx2 = ccl_lib.quicktx.tx() \ - .pay_to_address(receiver2["base_address"], Amount.ada(3)) \ - .from_address(sender2["base_address"]) - - result = ccl_lib.quicktx.compose(tx1, tx2) \ - .fee_payer(sender1["base_address"]) \ - .signer_count(2) \ - .build(provider=devkit) - - assert len(result["tx_cbor"]) > 0 - assert len(result["tx_hash"]) == 64 - - signed = ccl_lib.account.sign_tx( - sender1["mnemonic"], result["tx_cbor"], - CclLib.TESTNET, 0, 0) - signed = ccl_lib.account.sign_tx( - sender2["mnemonic"], signed, - CclLib.TESTNET, 0, 0) - - devkit.submit_tx(signed) - devkit.wait_for_block(3) - - assert get_lovelace(devkit, receiver1["base_address"]) == 5_000_000 - assert get_lovelace(devkit, receiver2["base_address"]) == 3_000_000 diff --git a/wrappers/python/tests/test_new_features_integration.py b/wrappers/python/tests/test_new_features_integration.py deleted file mode 100644 index 7bf5d3a..0000000 --- a/wrappers/python/tests/test_new_features_integration.py +++ /dev/null @@ -1,419 +0,0 @@ -"""Integration tests for new QuickTx features with Yaci DevKit. - -Tests reference scripts, governance action types, pool ops, treasury donation, -native script attachment, and unregisterDRep refundAmount. - -Requires: -- Yaci DevKit running on port 10000 -- Native library built: ./gradlew :core:nativeCompile - -Run with: - PYTHONPATH=wrappers/python CCL_LIB_PATH=core/build/native/nativeCompile \ - pytest wrappers/python/tests/test_new_features_integration.py -v -""" -import time -import json -import pytest -from ccl._ffi import CclLib, CclError -from ccl.quicktx import Amount -from tests.devkit_helper import DevKitHelper - -ANCHOR_URL = "https://bit.ly/3zCH2HL" -ANCHOR_DATA_HASH = "cafef700c0039a2efb056a665b3a8bcd94f8670b88d659f7f3db68340f6f0937" -ALWAYS_TRUE_PLUTUS_V3 = "46450101002499" - -DEVKIT_PROVIDER_URL = "http://localhost:10000/local-cluster/api" - - -@pytest.fixture(scope="module") -def devkit(): - """Provide a DevKit helper, skip if DevKit is not running.""" - helper = DevKitHelper() - if not helper.is_available(): - pytest.skip("Yaci DevKit is not running on port 10000") - helper.reset() - time.sleep(3) - return helper - - -@pytest.fixture(scope="module") -def ccl_lib(): - """Create a shared CclLib instance.""" - lib = CclLib() - yield lib - lib.close() - - -def fund_account(ccl_lib, devkit, ada=500): - """Create and fund a new account.""" - account = ccl_lib.account.create(CclLib.TESTNET) - devkit.topup(account["base_address"], ada) - devkit.wait_for_block(2) - return account - - -def build_sign_submit(ccl_lib, devkit, account, result): - """Sign and submit a built transaction.""" - signed_tx = ccl_lib.account.sign_tx( - account["mnemonic"], result["tx_cbor"], - CclLib.TESTNET, 0, 0) - tx_hash = devkit.submit_tx(signed_tx) - assert tx_hash is not None - return tx_hash - - -def register_stake(ccl_lib, devkit, account): - """Register the stake address for an account. Returns the tx result.""" - result = ccl_lib.quicktx.new_tx() \ - .register_stake_address(account["stake_address"]) \ - .from_address(account["base_address"]) \ - .build(provider_config={"name": "yaci", "url": DEVKIT_PROVIDER_URL}) - build_sign_submit(ccl_lib, devkit, account, result) - devkit.wait_for_block(3) - return result - - -# --- Full E2E tests (payment key signing only) --- - - -def test_pay_to_address_with_reference_script(ccl_lib, devkit): - """Send ADA to address with a PlutusV3 reference script attached.""" - sender = fund_account(ccl_lib, devkit, 150) - receiver = ccl_lib.account.create(CclLib.TESTNET) - - result = ccl_lib.quicktx.new_tx() \ - .pay_to_address( - receiver["base_address"], Amount.ada(10), - script_ref_cbor_hex=ALWAYS_TRUE_PLUTUS_V3, - script_ref_type="plutus_v3") \ - .from_address(sender["base_address"]) \ - .build(provider_config={"name": "yaci", "url": DEVKIT_PROVIDER_URL}) - - assert len(result["tx_cbor"]) > 0 - assert len(result["tx_hash"]) == 64 - assert int(result["fee"]) > 0 - - build_sign_submit(ccl_lib, devkit, sender, result) - devkit.wait_for_block(3) - - receiver_utxos = devkit.get_utxos(receiver["base_address"]) - total_lovelace = sum( - int(a["quantity"]) - for u in receiver_utxos - for a in u["amount"] - if a["unit"] == "lovelace" - ) - assert total_lovelace >= 10_000_000 - - -def test_attach_native_script(ccl_lib, devkit): - """Build a tx with attachNativeScript and submit.""" - sender = fund_account(ccl_lib, devkit, 150) - receiver = ccl_lib.account.create(CclLib.TESTNET) - - # Get the payment key hash from the sender address - addr_info = ccl_lib.address.info(sender["base_address"]) - key_hash = addr_info["payment_credential_hash"] - - native_script = {"type": "sig", "keyHash": key_hash} - - result = ccl_lib.quicktx.new_tx() \ - .pay_to_address(receiver["base_address"], Amount.ada(5)) \ - .attach_native_script(native_script) \ - .from_address(sender["base_address"]) \ - .build(provider_config={"name": "yaci", "url": DEVKIT_PROVIDER_URL}) - - assert len(result["tx_cbor"]) > 0 - assert len(result["tx_hash"]) == 64 - - build_sign_submit(ccl_lib, devkit, sender, result) - devkit.wait_for_block(3) - - tx_info = devkit.get_tx(result["tx_hash"]) - assert tx_info is not None - - -def test_register_stake_address(ccl_lib, devkit): - """Register sender's stake address and submit.""" - sender = fund_account(ccl_lib, devkit) - - result = ccl_lib.quicktx.new_tx() \ - .register_stake_address(sender["stake_address"]) \ - .from_address(sender["base_address"]) \ - .build(provider_config={"name": "yaci", "url": DEVKIT_PROVIDER_URL}) - - assert len(result["tx_cbor"]) > 0 - assert int(result["fee"]) > 0 - - build_sign_submit(ccl_lib, devkit, sender, result) - devkit.wait_for_block(3) - - tx_info = devkit.get_tx(result["tx_hash"]) - assert tx_info is not None - - -def test_delegate_voting_power_to_always_abstain(ccl_lib, devkit): - """Register stake address, then delegate voting power to always_abstain.""" - sender = fund_account(ccl_lib, devkit) - - # Step 1: Register stake address - register_stake(ccl_lib, devkit, sender) - - # Step 2: Delegate voting power to always_abstain - result = ccl_lib.quicktx.new_tx() \ - .delegate_voting_power_to(sender["stake_address"], "abstain") \ - .from_address(sender["base_address"]) \ - .build(provider_config={"name": "yaci", "url": DEVKIT_PROVIDER_URL}) - - assert len(result["tx_cbor"]) > 0 - - build_sign_submit(ccl_lib, devkit, sender, result) - devkit.wait_for_block(3) - - tx_info = devkit.get_tx(result["tx_hash"]) - assert tx_info is not None - - -def test_create_proposal_info_action(ccl_lib, devkit): - """Create an info_action governance proposal and submit.""" - sender = fund_account(ccl_lib, devkit) - register_stake(ccl_lib, devkit, sender) - - result = ccl_lib.quicktx.new_tx() \ - .create_proposal( - "info_action", - sender["stake_address"], - ANCHOR_URL, - ANCHOR_DATA_HASH) \ - .from_address(sender["base_address"]) \ - .build(provider_config={"name": "yaci", "url": DEVKIT_PROVIDER_URL}) - - assert len(result["tx_cbor"]) > 0 - assert int(result["fee"]) > 0 - - build_sign_submit(ccl_lib, devkit, sender, result) - devkit.wait_for_block(3) - - tx_info = devkit.get_tx(result["tx_hash"]) - assert tx_info is not None - - -def test_create_proposal_no_confidence(ccl_lib, devkit): - """Create a no_confidence governance proposal and submit.""" - sender = fund_account(ccl_lib, devkit) - register_stake(ccl_lib, devkit, sender) - - result = ccl_lib.quicktx.new_tx() \ - .create_proposal( - "no_confidence", - sender["stake_address"], - ANCHOR_URL, - ANCHOR_DATA_HASH) \ - .from_address(sender["base_address"]) \ - .build(provider_config={"name": "yaci", "url": DEVKIT_PROVIDER_URL}) - - assert len(result["tx_cbor"]) > 0 - - build_sign_submit(ccl_lib, devkit, sender, result) - devkit.wait_for_block(3) - - tx_info = devkit.get_tx(result["tx_hash"]) - assert tx_info is not None - - -def test_create_proposal_new_constitution(ccl_lib, devkit): - """Create a new_constitution governance proposal and submit.""" - sender = fund_account(ccl_lib, devkit) - register_stake(ccl_lib, devkit, sender) - - result = ccl_lib.quicktx.new_tx() \ - .create_proposal( - "new_constitution", - sender["stake_address"], - ANCHOR_URL, - ANCHOR_DATA_HASH, - constitution_anchor_url=ANCHOR_URL, - constitution_anchor_data_hash=ANCHOR_DATA_HASH) \ - .from_address(sender["base_address"]) \ - .build(provider_config={"name": "yaci", "url": DEVKIT_PROVIDER_URL}) - - assert len(result["tx_cbor"]) > 0 - - build_sign_submit(ccl_lib, devkit, sender, result) - devkit.wait_for_block(3) - - tx_info = devkit.get_tx(result["tx_hash"]) - assert tx_info is not None - - -def test_create_proposal_update_committee(ccl_lib, devkit): - """Create an update_committee governance proposal and submit.""" - sender = fund_account(ccl_lib, devkit) - register_stake(ccl_lib, devkit, sender) - - # Use a deterministic hash for committee member - member_hash = "a" * 56 - - result = ccl_lib.quicktx.new_tx() \ - .create_proposal( - "update_committee", - sender["stake_address"], - ANCHOR_URL, - ANCHOR_DATA_HASH, - new_members=[{"hash": member_hash, "type": "key", "epoch": 100}], - quorum_numerator=2, - quorum_denominator=3) \ - .from_address(sender["base_address"]) \ - .build(provider_config={"name": "yaci", "url": DEVKIT_PROVIDER_URL}) - - assert len(result["tx_cbor"]) > 0 - - build_sign_submit(ccl_lib, devkit, sender, result) - devkit.wait_for_block(3) - - tx_info = devkit.get_tx(result["tx_hash"]) - assert tx_info is not None - - -def test_create_proposal_hard_fork_initiation(ccl_lib, devkit): - """Create a hard_fork_initiation governance proposal and submit.""" - sender = fund_account(ccl_lib, devkit) - register_stake(ccl_lib, devkit, sender) - - result = ccl_lib.quicktx.new_tx() \ - .create_proposal( - "hard_fork_initiation", - sender["stake_address"], - ANCHOR_URL, - ANCHOR_DATA_HASH, - protocol_version_major=10, - protocol_version_minor=0) \ - .from_address(sender["base_address"]) \ - .build(provider_config={"name": "yaci", "url": DEVKIT_PROVIDER_URL}) - - assert len(result["tx_cbor"]) > 0 - - build_sign_submit(ccl_lib, devkit, sender, result) - devkit.wait_for_block(3) - - tx_info = devkit.get_tx(result["tx_hash"]) - assert tx_info is not None - - -# --- Build-only tests (need additional key signatures) --- - - -def test_register_drep_build_only(ccl_lib, devkit): - """Build a registerDRep tx and verify structure (no submit — needs DRep key).""" - sender = fund_account(ccl_lib, devkit) - - drep_key = ccl_lib.gov.drep_key_from_mnemonic( - sender["mnemonic"], CclLib.TESTNET, 0) - credential_hash = drep_key["verification_key_hash"] - - result = ccl_lib.quicktx.new_tx() \ - .register_drep( - credential_hash, "key", - anchor_url=ANCHOR_URL, - anchor_data_hash=ANCHOR_DATA_HASH) \ - .from_address(sender["base_address"]) \ - .signer_count(2) \ - .build(provider_config={"name": "yaci", "url": DEVKIT_PROVIDER_URL}) - - assert len(result["tx_cbor"]) > 0 - assert len(result["tx_hash"]) == 64 - assert int(result["fee"]) > 0 - - -def test_unregister_drep_with_refund_build_only(ccl_lib, devkit): - """Build an unregisterDRep tx with refundAmount (no submit — needs DRep key).""" - sender = fund_account(ccl_lib, devkit) - - drep_key = ccl_lib.gov.drep_key_from_mnemonic( - sender["mnemonic"], CclLib.TESTNET, 0) - credential_hash = drep_key["verification_key_hash"] - - result = ccl_lib.quicktx.new_tx() \ - .unregister_drep( - credential_hash, "key", - refund_address=sender["base_address"], - refund_amount=500_000_000) \ - .from_address(sender["base_address"]) \ - .signer_count(2) \ - .build(provider_config={"name": "yaci", "url": DEVKIT_PROVIDER_URL}) - - assert len(result["tx_cbor"]) > 0 - assert len(result["tx_hash"]) == 64 - assert int(result["fee"]) > 0 - - -def test_register_pool_build_only(ccl_lib, devkit): - """Build a registerPool tx and verify structure (no submit — needs pool operator key).""" - sender = fund_account(ccl_lib, devkit) - - # Deterministic hashes for pool operator and VRF - operator_hash = "ab" * 14 # 28-byte hex - vrf_key_hash = "cd" * 16 # 32-byte hex - - result = ccl_lib.quicktx.new_tx() \ - .register_pool( - operator=operator_hash, - vrf_key_hash=vrf_key_hash, - pledge=500_000_000, - cost=340_000_000, - margin_numerator=1, - margin_denominator=100, - reward_address=sender["stake_address"], - pool_owners=[operator_hash]) \ - .from_address(sender["base_address"]) \ - .signer_count(2) \ - .build(provider_config={"name": "yaci", "url": DEVKIT_PROVIDER_URL}) - - assert len(result["tx_cbor"]) > 0 - assert len(result["tx_hash"]) == 64 - assert int(result["fee"]) > 0 - - -def test_donate_to_treasury_build_only(ccl_lib, devkit): - """Build a donateToTreasury tx and verify structure.""" - sender = fund_account(ccl_lib, devkit) - - result = ccl_lib.quicktx.new_tx() \ - .donate_to_treasury( - treasury_value=0, - donation_amount=5_000_000) \ - .from_address(sender["base_address"]) \ - .build(provider_config={"name": "yaci", "url": DEVKIT_PROVIDER_URL}) - - assert len(result["tx_cbor"]) > 0 - assert len(result["tx_hash"]) == 64 - assert int(result["fee"]) > 0 - - -def test_create_vote_build_only(ccl_lib, devkit): - """Build a createVote tx (no submit — needs DRep key).""" - sender = fund_account(ccl_lib, devkit) - - drep_key = ccl_lib.gov.drep_key_from_mnemonic( - sender["mnemonic"], CclLib.TESTNET, 0) - credential_hash = drep_key["verification_key_hash"] - - # Use a fake governance action tx hash - fake_gov_tx_hash = "ab" * 32 - - result = ccl_lib.quicktx.new_tx() \ - .create_vote( - voter_type="drep_key_hash", - voter_hash=credential_hash, - gov_action_tx_hash=fake_gov_tx_hash, - gov_action_index=0, - vote="yes", - anchor_url=ANCHOR_URL, - anchor_data_hash=ANCHOR_DATA_HASH) \ - .from_address(sender["base_address"]) \ - .signer_count(2) \ - .build(provider_config={"name": "yaci", "url": DEVKIT_PROVIDER_URL}) - - assert len(result["tx_cbor"]) > 0 - assert len(result["tx_hash"]) == 64 - assert int(result["fee"]) > 0 diff --git a/wrappers/python/tests/test_provider_integration.py b/wrappers/python/tests/test_provider_integration.py deleted file mode 100644 index 4b3335f..0000000 --- a/wrappers/python/tests/test_provider_integration.py +++ /dev/null @@ -1,159 +0,0 @@ -"""Integration tests for Provider pattern with Yaci DevKit. - -Requires: -- Yaci DevKit running on port 10000 -- Native library built: ./gradlew :core:nativeCompile - -Run with: - PYTHONPATH=wrappers/python CCL_LIB_PATH=core/build/native/nativeCompile \ - pytest wrappers/python/tests/test_provider_integration.py -v -""" -import time -import pytest -from ccl._ffi import CclLib, CclError -from ccl.quicktx import Amount -from ccl.provider import YaciDevKitProvider - - -@pytest.fixture(scope="module") -def provider(): - """Provide a YaciDevKitProvider, skip if DevKit is not running.""" - p = YaciDevKitProvider() - if not p.is_available(): - pytest.skip("Yaci DevKit is not running on port 10000") - p.reset() - time.sleep(3) # wait for devnet reset - return p - - -@pytest.fixture(scope="module") -def ccl_lib(): - """Create a shared CclLib instance.""" - lib = CclLib() - yield lib - lib.close() - - -@pytest.fixture -def funded_sender(ccl_lib, provider): - """Create and fund a sender account.""" - account = ccl_lib.account.create(CclLib.TESTNET) - provider.topup(account["base_address"], 150) - provider.wait_for_block(2) - return account - - -def test_build_with_provider(ccl_lib, provider, funded_sender): - """Auto-fetch UTXOs + PP via provider, build, sign, submit, verify.""" - receiver = ccl_lib.account.create(CclLib.TESTNET) - - # Build with provider - no manual withUtxos/withProtocolParams needed - result = ccl_lib.quicktx.new_tx() \ - .pay_to_address(receiver["base_address"], Amount.ada(5)) \ - .from_address(funded_sender["base_address"]) \ - .build(provider=provider) - - assert len(result["tx_cbor"]) > 0 - assert len(result["tx_hash"]) == 64 - assert int(result["fee"]) > 0 - - # Sign - signed_tx = ccl_lib.account.sign_tx( - funded_sender["mnemonic"], result["tx_cbor"], - CclLib.TESTNET, 0, 0) - - # Submit via provider - tx_hash = provider.submit_tx(signed_tx) - assert tx_hash is not None - - # Verify - provider.wait_for_block(3) - receiver_utxos = provider.get_utxos(receiver["base_address"]) - total_lovelace = sum( - int(a["quantity"]) - for u in receiver_utxos - for a in u["amount"] - if a["unit"] == "lovelace" - ) - assert total_lovelace == 5_000_000 - - -def test_provider_with_manual_utxo_override(ccl_lib, provider, funded_sender): - """withUtxos() should override provider auto-fetch.""" - receiver = ccl_lib.account.create(CclLib.TESTNET) - - # Manually fetch UTXOs - utxos = provider.get_utxos(funded_sender["base_address"]) - - # Build with provider but override UTXOs manually - result = ccl_lib.quicktx.new_tx() \ - .pay_to_address(receiver["base_address"], Amount.ada(3)) \ - .from_address(funded_sender["base_address"]) \ - .with_utxos(utxos) \ - .build(provider=provider) - - assert len(result["tx_cbor"]) > 0 - assert len(result["tx_hash"]) == 64 - - signed_tx = ccl_lib.account.sign_tx( - funded_sender["mnemonic"], result["tx_cbor"], - CclLib.TESTNET, 0, 0) - - tx_hash = provider.submit_tx(signed_tx) - assert tx_hash is not None - - -def test_multiple_receivers_with_provider(ccl_lib, provider, funded_sender): - """Send to two receivers using provider.""" - r1 = ccl_lib.account.create(CclLib.TESTNET) - r2 = ccl_lib.account.create(CclLib.TESTNET) - - result = ccl_lib.quicktx.new_tx() \ - .pay_to_address(r1["base_address"], Amount.ada(3)) \ - .pay_to_address(r2["base_address"], Amount.ada(2)) \ - .from_address(funded_sender["base_address"]) \ - .build(provider=provider) - - signed_tx = ccl_lib.account.sign_tx( - funded_sender["mnemonic"], result["tx_cbor"], - CclLib.TESTNET, 0, 0) - - provider.submit_tx(signed_tx) - provider.wait_for_block(3) - - r1_utxos = provider.get_utxos(r1["base_address"]) - r2_utxos = provider.get_utxos(r2["base_address"]) - - r1_lovelace = sum(int(a["quantity"]) for u in r1_utxos for a in u["amount"] if a["unit"] == "lovelace") - r2_lovelace = sum(int(a["quantity"]) for u in r2_utxos for a in u["amount"] if a["unit"] == "lovelace") - - assert r1_lovelace == 3_000_000 - assert r2_lovelace == 2_000_000 - - -def test_metadata_with_provider(ccl_lib, provider, funded_sender): - """Send ADA with metadata using provider.""" - receiver = ccl_lib.account.create(CclLib.TESTNET) - - result = ccl_lib.quicktx.new_tx() \ - .pay_to_address(receiver["base_address"], Amount.ada(2)) \ - .attach_metadata(674, {"msg": ["Hello from Provider"]}) \ - .from_address(funded_sender["base_address"]) \ - .build(provider=provider) - - signed_tx = ccl_lib.account.sign_tx( - funded_sender["mnemonic"], result["tx_cbor"], - CclLib.TESTNET, 0, 0) - - tx_hash = provider.submit_tx(signed_tx) - assert tx_hash is not None - - provider.wait_for_block(3) - receiver_utxos = provider.get_utxos(receiver["base_address"]) - total = sum( - int(a["quantity"]) - for u in receiver_utxos - for a in u["amount"] - if a["unit"] == "lovelace" - ) - assert total == 2_000_000 diff --git a/wrappers/python/tests/test_quicktx.py b/wrappers/python/tests/test_quicktx.py index 291d4da..623b7a0 100644 --- a/wrappers/python/tests/test_quicktx.py +++ b/wrappers/python/tests/test_quicktx.py @@ -1,62 +1,21 @@ -import json +import pytest + from ccl._ffi import CclLib, CclError -from ccl.quicktx import Amount -# Protocol params matching CCL's test resource (Blockfrost/Koios format) +# Minimal protocol parameters (CCL ProtocolParams model). PROTOCOL_PARAMS = { - "min_fee_a": 44, - "min_fee_b": 155381, - "max_block_size": 65536, - "max_tx_size": 16384, - "max_block_header_size": 1100, - "key_deposit": "2000000", - "pool_deposit": "500000000", - "e_max": 18, - "n_opt": 500, - "a0": 0.3, - "rho": 0.003, - "tau": 0.2, - "min_utxo": "34482", - "min_pool_cost": "340000000", - "price_mem": 0.0577, - "price_step": 0.0000721, - "max_tx_ex_mem": "10000000", - "max_tx_ex_steps": "10000000000", - "max_block_ex_mem": "50000000", - "max_block_ex_steps": "40000000000", - "max_val_size": "5000", - "collateral_percent": 150, + "min_fee_a": 44, "min_fee_b": 155381, "max_tx_size": 16384, + "key_deposit": "2000000", "pool_deposit": "500000000", + "coins_per_utxo_size": "4310", "max_val_size": "5000", + "max_tx_ex_mem": "10000000", "max_tx_ex_steps": "10000000000", + "price_mem": 0.0577, "price_step": 0.0000721, "collateral_percent": 150, "max_collateral_inputs": 3, - "coins_per_utxo_size": "4310", - "coins_per_utxo_word": "34482", - "pvt_motion_no_confidence": 0.51, - "pvt_committee_normal": 0.51, - "pvt_committee_no_confidence": 0.51, - "pvt_hard_fork_initiation": 0.51, - "dvt_motion_no_confidence": 0.51, - "dvt_committee_normal": 0.51, - "dvt_committee_no_confidence": 0.51, - "dvt_update_to_constitution": 0.51, - "dvt_hard_fork_initiation": 0.51, - "dvt_ppnetwork_group": 0.51, - "dvt_ppeconomic_group": 0.51, - "dvt_pptechnical_group": 0.51, - "dvt_ppgov_group": 0.51, - "dvt_treasury_withdrawal": 0.51, - "committee_min_size": 0, - "committee_max_term_length": 200, - "gov_action_lifetime": 10, - "gov_action_deposit": 1000000000, - "drep_deposit": 2000000, - "drep_activity": 20, - "min_fee_ref_script_cost_per_byte": 44, } FAKE_TX_HASH = "a" * 64 -def _make_utxos(address, lovelace=100_000_000): - """Create a simple UTXO list for testing.""" +def _utxos(address, lovelace=100_000_000): return [{ "tx_hash": FAKE_TX_HASH, "output_index": 0, @@ -65,443 +24,124 @@ def _make_utxos(address, lovelace=100_000_000): }] -def test_simple_ada_payment(ccl): - """Build a simple ADA payment transaction.""" - sender = ccl.account.create(CclLib.TESTNET) - receiver = ccl.account.create(CclLib.TESTNET) +def _payment_yaml(from_addr, to_addr, quantity): + return f""" +version: 1.0 +transaction: + - tx: + from: {from_addr} + intents: + - type: payment + address: {to_addr} + amounts: + - unit: lovelace + quantity: "{quantity}" +""" - result = ccl.quicktx.new_tx() \ - .pay_to_address(receiver["base_address"], Amount.ada(5)) \ - .from_address(sender["base_address"]) \ - .with_utxos(_make_utxos(sender["base_address"])) \ - .with_protocol_params(PROTOCOL_PARAMS) \ - .build() - assert "tx_cbor" in result +def _assert_built(result): + assert isinstance(result, dict) assert len(result["tx_cbor"]) > 0 - assert "tx_hash" in result assert len(result["tx_hash"]) == 64 - assert "fee" in result assert int(result["fee"]) > 0 -def test_multiple_payments(ccl): - """Build a transaction with multiple payment outputs.""" - sender = ccl.account.create(CclLib.TESTNET) - receiver1 = ccl.account.create(CclLib.TESTNET) - receiver2 = ccl.account.create(CclLib.TESTNET) - - result = ccl.quicktx.new_tx() \ - .pay_to_address(receiver1["base_address"], Amount.ada(5)) \ - .pay_to_address(receiver2["base_address"], Amount.ada(3)) \ - .from_address(sender["base_address"]) \ - .with_utxos(_make_utxos(sender["base_address"])) \ - .with_protocol_params(PROTOCOL_PARAMS) \ - .build() - - assert len(result["tx_hash"]) == 64 - assert int(result["fee"]) > 0 - - -def test_with_metadata(ccl): - """Build a transaction with CIP-20 metadata.""" +def test_simple_payment(ccl): sender = ccl.account.create(CclLib.TESTNET) receiver = ccl.account.create(CclLib.TESTNET) - - result = ccl.quicktx.new_tx() \ - .pay_to_address(receiver["base_address"], Amount.ada(2)) \ - .attach_metadata(674, {"msg": ["Hello from Python"]}) \ - .from_address(sender["base_address"]) \ - .with_utxos(_make_utxos(sender["base_address"])) \ - .with_protocol_params(PROTOCOL_PARAMS) \ - .build() - - assert len(result["tx_cbor"]) > 0 - assert int(result["fee"]) > 0 + yaml_str = _payment_yaml(sender["base_address"], receiver["base_address"], "5000000") + _assert_built(ccl.quicktx.build(yaml_str, _utxos(sender["base_address"]), PROTOCOL_PARAMS)) -def test_with_validity_interval(ccl): - """Build a transaction with validity interval.""" +def test_multiple_payments(ccl): sender = ccl.account.create(CclLib.TESTNET) - receiver = ccl.account.create(CclLib.TESTNET) - - result = ccl.quicktx.new_tx() \ - .pay_to_address(receiver["base_address"], Amount.ada(2)) \ - .from_address(sender["base_address"]) \ - .with_utxos(_make_utxos(sender["base_address"])) \ - .with_protocol_params(PROTOCOL_PARAMS) \ - .valid_from(1000) \ - .valid_to(50000) \ - .build() - - assert len(result["tx_cbor"]) > 0 - - -def test_insufficient_funds(ccl): - """Should raise error when UTXOs don't have enough funds.""" + r1 = ccl.account.create(CclLib.TESTNET) + r2 = ccl.account.create(CclLib.TESTNET) + yaml_str = f""" +version: 1.0 +transaction: + - tx: + from: {sender['base_address']} + intents: + - type: payment + address: {r1['base_address']} + amounts: + - unit: lovelace + quantity: "5000000" + - type: payment + address: {r2['base_address']} + amounts: + - unit: lovelace + quantity: "3000000" +""" + _assert_built(ccl.quicktx.build(yaml_str, _utxos(sender["base_address"]), PROTOCOL_PARAMS)) + + +def test_variable_substitution(ccl): sender = ccl.account.create(CclLib.TESTNET) receiver = ccl.account.create(CclLib.TESTNET) - - try: - ccl.quicktx.new_tx() \ - .pay_to_address(receiver["base_address"], Amount.ada(200)) \ - .from_address(sender["base_address"]) \ - .with_utxos(_make_utxos(sender["base_address"], lovelace=1_000_000)) \ - .with_protocol_params(PROTOCOL_PARAMS) \ - .build() - assert False, "Should have raised CclError" - except CclError: - pass # expected + yaml_str = f""" +version: 1.0 +variables: + to: {receiver['base_address']} + amount: "4000000" +transaction: + - tx: + from: {sender['base_address']} + intents: + - type: payment + address: ${{to}} + amounts: + - unit: lovelace + quantity: ${{amount}} +""" + _assert_built(ccl.quicktx.build(yaml_str, _utxos(sender["base_address"]), PROTOCOL_PARAMS)) -def test_multi_asset_payment(ccl): - """Build a transaction with native asset payment.""" +def test_insufficient_funds(ccl): sender = ccl.account.create(CclLib.TESTNET) receiver = ccl.account.create(CclLib.TESTNET) - - policy_id = "a" * 56 - asset_name_hex = "546f6b656e" # "Token" - unit = policy_id + asset_name_hex - - utxos = [{ - "tx_hash": FAKE_TX_HASH, - "output_index": 0, - "address": sender["base_address"], - "amount": [ - {"unit": "lovelace", "quantity": "100000000"}, - {"unit": unit, "quantity": "500"}, - ], - }] - - result = ccl.quicktx.new_tx() \ - .pay_to_address( - receiver["base_address"], - Amount.lovelace(2_000_000), - Amount.asset(unit, 100), - ) \ - .from_address(sender["base_address"]) \ - .with_utxos(utxos) \ - .with_protocol_params(PROTOCOL_PARAMS) \ - .build() - - assert len(result["tx_hash"]) == 64 - - -def test_compose_two_senders(ccl): - """Compose two Tx objects from different senders into one transaction.""" - sender1 = ccl.account.create(CclLib.TESTNET) - sender2 = ccl.account.create(CclLib.TESTNET) - receiver1 = ccl.account.create(CclLib.TESTNET) - receiver2 = ccl.account.create(CclLib.TESTNET) - - tx1 = ccl.quicktx.tx() \ - .pay_to_address(receiver1["base_address"], Amount.ada(5)) \ - .from_address(sender1["base_address"]) - - tx2 = ccl.quicktx.tx() \ - .pay_to_address(receiver2["base_address"], Amount.ada(3)) \ - .from_address(sender2["base_address"]) - - utxos = [ - { - "tx_hash": FAKE_TX_HASH, - "output_index": 0, - "address": sender1["base_address"], - "amount": [{"unit": "lovelace", "quantity": "100000000"}], - }, - { - "tx_hash": "b" * 64, - "output_index": 0, - "address": sender2["base_address"], - "amount": [{"unit": "lovelace", "quantity": "100000000"}], - }, - ] - - result = ccl.quicktx.compose(tx1, tx2) \ - .fee_payer(sender1["base_address"]) \ - .with_utxos(utxos) \ - .with_protocol_params(PROTOCOL_PARAMS) \ - .signer_count(2) \ - .build() - - assert "tx_cbor" in result - assert len(result["tx_cbor"]) > 0 - assert len(result["tx_hash"]) == 64 - assert int(result["fee"]) > 0 - - -def test_compose_missing_fee_payer(ccl): - """Compose should fail when fee_payer is not set.""" - sender1 = ccl.account.create(CclLib.TESTNET) - sender2 = ccl.account.create(CclLib.TESTNET) - receiver1 = ccl.account.create(CclLib.TESTNET) - receiver2 = ccl.account.create(CclLib.TESTNET) - - tx1 = ccl.quicktx.tx() \ - .pay_to_address(receiver1["base_address"], Amount.ada(5)) \ - .from_address(sender1["base_address"]) - - tx2 = ccl.quicktx.tx() \ - .pay_to_address(receiver2["base_address"], Amount.ada(3)) \ - .from_address(sender2["base_address"]) - - try: - ccl.quicktx.compose(tx1, tx2) \ - .with_utxos(_make_utxos(sender1["base_address"])) \ - .with_protocol_params(PROTOCOL_PARAMS) \ - .build() - assert False, "Should have raised CclError" - except CclError: - pass # expected - - -# --- Staking --- - -def test_register_stake_address(ccl): - """Build a register stake address transaction.""" - sender = ccl.account.create(CclLib.TESTNET) - - result = ccl.quicktx.new_tx() \ - .register_stake_address(sender["base_address"]) \ - .from_address(sender["base_address"]) \ - .with_utxos(_make_utxos(sender["base_address"])) \ - .with_protocol_params(PROTOCOL_PARAMS) \ - .build() - - assert len(result["tx_cbor"]) > 0 - assert len(result["tx_hash"]) == 64 - - -def test_deregister_stake_address(ccl): - """Build a deregister stake address transaction.""" - sender = ccl.account.create(CclLib.TESTNET) - - result = ccl.quicktx.new_tx() \ - .deregister_stake_address(sender["base_address"]) \ - .from_address(sender["base_address"]) \ - .with_utxos(_make_utxos(sender["base_address"])) \ - .with_protocol_params(PROTOCOL_PARAMS) \ - .build() - - assert len(result["tx_cbor"]) > 0 - assert len(result["tx_hash"]) == 64 - - -def test_delegate_to(ccl): - """Build a delegate to pool transaction.""" - sender = ccl.account.create(CclLib.TESTNET) - pool_id = "pool1pu5jlj4q9w9jlxeu370a3c9myx47md5j5m2str0naunn2q3lkdy" - - result = ccl.quicktx.new_tx() \ - .delegate_to(sender["base_address"], pool_id) \ - .from_address(sender["base_address"]) \ - .with_utxos(_make_utxos(sender["base_address"])) \ - .with_protocol_params(PROTOCOL_PARAMS) \ - .build() - - assert len(result["tx_cbor"]) > 0 - assert len(result["tx_hash"]) == 64 - - -def test_withdraw(ccl): - """Build a withdraw rewards transaction.""" - sender = ccl.account.create(CclLib.TESTNET) - info = ccl.account.from_mnemonic(sender["mnemonic"], CclLib.TESTNET) - - result = ccl.quicktx.new_tx() \ - .withdraw(info["stake_address"], "5000000") \ - .from_address(sender["base_address"]) \ - .with_utxos(_make_utxos(sender["base_address"])) \ - .with_protocol_params(PROTOCOL_PARAMS) \ - .build() - - assert len(result["tx_cbor"]) > 0 - assert len(result["tx_hash"]) == 64 - - -# --- DRep --- - -def test_register_drep(ccl): - """Build a register DRep transaction.""" - sender = ccl.account.create(CclLib.TESTNET) - credential_hash = "ab" * 28 - - result = ccl.quicktx.new_tx() \ - .register_drep(credential_hash, "key") \ - .from_address(sender["base_address"]) \ - .with_utxos(_make_utxos(sender["base_address"])) \ - .with_protocol_params(PROTOCOL_PARAMS) \ - .build() - - assert len(result["tx_cbor"]) > 0 - assert len(result["tx_hash"]) == 64 - - -def test_register_drep_with_anchor(ccl): - """Build a register DRep with anchor transaction.""" - sender = ccl.account.create(CclLib.TESTNET) - credential_hash = "ab" * 28 - data_hash = "cd" * 32 - - result = ccl.quicktx.new_tx() \ - .register_drep(credential_hash, "key", - anchor_url="https://example.com/drep.json", - anchor_data_hash=data_hash) \ - .from_address(sender["base_address"]) \ - .with_utxos(_make_utxos(sender["base_address"])) \ - .with_protocol_params(PROTOCOL_PARAMS) \ - .build() - - assert len(result["tx_cbor"]) > 0 - assert len(result["tx_hash"]) == 64 - - -def test_unregister_drep(ccl): - """Build an unregister DRep transaction.""" - sender = ccl.account.create(CclLib.TESTNET) - credential_hash = "ab" * 28 - - result = ccl.quicktx.new_tx() \ - .unregister_drep(credential_hash, "key") \ - .from_address(sender["base_address"]) \ - .with_utxos(_make_utxos(sender["base_address"])) \ - .with_protocol_params(PROTOCOL_PARAMS) \ - .build() - - assert len(result["tx_cbor"]) > 0 - assert len(result["tx_hash"]) == 64 - - -def test_update_drep(ccl): - """Build an update DRep transaction.""" - sender = ccl.account.create(CclLib.TESTNET) - credential_hash = "ab" * 28 - data_hash = "cd" * 32 - - result = ccl.quicktx.new_tx() \ - .update_drep(credential_hash, "key", - anchor_url="https://example.com/drep-v2.json", - anchor_data_hash=data_hash) \ - .from_address(sender["base_address"]) \ - .with_utxos(_make_utxos(sender["base_address"])) \ - .with_protocol_params(PROTOCOL_PARAMS) \ - .build() - - assert len(result["tx_cbor"]) > 0 - assert len(result["tx_hash"]) == 64 - - -# --- Voting --- - -def test_delegate_voting_power_to_key_hash(ccl): - """Build a delegate voting power to key hash transaction.""" - sender = ccl.account.create(CclLib.TESTNET) - drep_hash = "ab" * 28 - - result = ccl.quicktx.new_tx() \ - .delegate_voting_power_to(sender["base_address"], "key_hash", drep_hash) \ - .from_address(sender["base_address"]) \ - .with_utxos(_make_utxos(sender["base_address"])) \ - .with_protocol_params(PROTOCOL_PARAMS) \ - .build() - - assert len(result["tx_cbor"]) > 0 - assert len(result["tx_hash"]) == 64 - - -def test_delegate_voting_power_to_abstain(ccl): - """Build a delegate voting power to abstain transaction.""" - sender = ccl.account.create(CclLib.TESTNET) - - result = ccl.quicktx.new_tx() \ - .delegate_voting_power_to(sender["base_address"], "abstain") \ - .from_address(sender["base_address"]) \ - .with_utxos(_make_utxos(sender["base_address"])) \ - .with_protocol_params(PROTOCOL_PARAMS) \ - .build() - - assert len(result["tx_cbor"]) > 0 - assert len(result["tx_hash"]) == 64 - - -def test_create_vote(ccl): - """Build a create vote transaction.""" - sender = ccl.account.create(CclLib.TESTNET) - voter_hash = "ab" * 28 - gov_tx_hash = "cd" * 32 - - result = ccl.quicktx.new_tx() \ - .create_vote("drep_key_hash", voter_hash, gov_tx_hash, 0, "yes") \ - .from_address(sender["base_address"]) \ - .with_utxos(_make_utxos(sender["base_address"])) \ - .with_protocol_params(PROTOCOL_PARAMS) \ - .build() - - assert len(result["tx_cbor"]) > 0 - assert len(result["tx_hash"]) == 64 - - -def test_create_vote_with_anchor(ccl): - """Build a create vote with anchor transaction.""" - sender = ccl.account.create(CclLib.TESTNET) - voter_hash = "ab" * 28 - gov_tx_hash = "cd" * 32 - anchor_data_hash = "ef" * 32 - - result = ccl.quicktx.new_tx() \ - .create_vote("drep_key_hash", voter_hash, gov_tx_hash, 0, "no", - anchor_url="https://example.com/rationale.json", - anchor_data_hash=anchor_data_hash) \ - .from_address(sender["base_address"]) \ - .with_utxos(_make_utxos(sender["base_address"])) \ - .with_protocol_params(PROTOCOL_PARAMS) \ - .build() - - assert len(result["tx_cbor"]) > 0 - assert len(result["tx_hash"]) == 64 - - -# --- Governance proposals --- - -def test_create_info_action_proposal(ccl): - """Build an info action governance proposal.""" - sender = ccl.account.create(CclLib.TESTNET) - info = ccl.account.from_mnemonic(sender["mnemonic"], CclLib.TESTNET) - anchor_data_hash = "ab" * 32 - - result = ccl.quicktx.new_tx() \ - .create_proposal("info_action", info["stake_address"], - "https://example.com/proposal.json", anchor_data_hash) \ - .from_address(sender["base_address"]) \ - .with_utxos(_make_utxos(sender["base_address"], lovelace=2_000_000_000)) \ - .with_protocol_params(PROTOCOL_PARAMS) \ - .build() - - assert len(result["tx_cbor"]) > 0 - assert len(result["tx_hash"]) == 64 - - -def test_create_treasury_withdrawals_proposal(ccl): - """Build a treasury withdrawals governance proposal.""" - sender = ccl.account.create(CclLib.TESTNET) - info = ccl.account.from_mnemonic(sender["mnemonic"], CclLib.TESTNET) - anchor_data_hash = "ab" * 32 - - result = ccl.quicktx.new_tx() \ - .create_proposal("treasury_withdrawals", info["stake_address"], - "https://example.com/proposal.json", anchor_data_hash, - withdrawals=[{"reward_address": info["stake_address"], "amount": "1000000"}]) \ - .from_address(sender["base_address"]) \ - .with_utxos(_make_utxos(sender["base_address"], lovelace=2_000_000_000)) \ - .with_protocol_params(PROTOCOL_PARAMS) \ - .build() - - assert len(result["tx_cbor"]) > 0 - assert len(result["tx_hash"]) == 64 - - -def test_amount_helpers(): - """Test Amount helper methods.""" - assert Amount.ada(5) == {"unit": "lovelace", "quantity": "5000000"} - assert Amount.lovelace(2000000) == {"unit": "lovelace", "quantity": "2000000"} - assert Amount.asset("abc123", 100) == {"unit": "abc123", "quantity": "100"} + yaml_str = _payment_yaml(sender["base_address"], receiver["base_address"], "200000000") + with pytest.raises(CclError): + ccl.quicktx.build(yaml_str, _utxos(sender["base_address"], 1_000_000), PROTOCOL_PARAMS) + + +# A Plutus mint TxPlan (always-succeeds V2 policy). The script is not executed offline; the +# caller-supplied execution units are stamped onto the redeemer by the static evaluator. +MINT_ADDR = ("addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8" + "ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp") +MINT_YAML = f""" +version: 1.0 +context: + fee_payer: {MINT_ADDR} +transaction: +- tx: + from: {MINT_ADDR} + intents: + - policyId: 793f8c8cffba081b2a56462fc219cc8fe652d6a338b62c7b134876e7 + type: script_minting + assets: + - name: TestToken + value: 1 + receiver: {MINT_ADDR} + redeemer: + int: 0 + scripts: + - type: validator + role: mint + cbor_hex: 4e4d01000033222220051200120011 + version: v2 +""" + + +def test_plutus_mint_with_exec_units(ccl): + # One redeemer (the mint) -> one ExUnits, supplied by the caller. + result = ccl.quicktx.build( + MINT_YAML, _utxos(MINT_ADDR), PROTOCOL_PARAMS, + exec_units=[{"mem": 2000000, "steps": 500000000}]) + _assert_built(result) + + +def test_plutus_mint_without_exec_units_fails(ccl): + with pytest.raises(CclError): + ccl.quicktx.build(MINT_YAML, _utxos(MINT_ADDR), PROTOCOL_PARAMS) diff --git a/wrappers/python/tests/test_quicktx_integration.py b/wrappers/python/tests/test_quicktx_integration.py index caab581..2941a27 100644 --- a/wrappers/python/tests/test_quicktx_integration.py +++ b/wrappers/python/tests/test_quicktx_integration.py @@ -1,4 +1,4 @@ -"""Integration tests for QuickTx with Yaci DevKit. +"""Integration tests for QuickTx (TxPlan YAML) with Yaci DevKit. Requires: - Yaci DevKit running on port 10000 @@ -9,26 +9,25 @@ pytest wrappers/python/tests/test_quicktx_integration.py -v """ import time + import pytest + from ccl._ffi import CclLib, CclError -from ccl.quicktx import Amount from tests.devkit_helper import DevKitHelper @pytest.fixture(scope="module") def devkit(): - """Provide a DevKit helper, skip if DevKit is not running.""" helper = DevKitHelper() if not helper.is_available(): pytest.skip("Yaci DevKit is not running on port 10000") helper.reset() - time.sleep(3) # wait for devnet reset + time.sleep(3) return helper @pytest.fixture(scope="module") def ccl_lib(): - """Create a shared CclLib instance.""" lib = CclLib() yield lib lib.close() @@ -36,253 +35,97 @@ def ccl_lib(): @pytest.fixture def funded_sender(ccl_lib, devkit): - """Create and fund a sender account.""" account = ccl_lib.account.create(CclLib.TESTNET) devkit.topup(account["base_address"], 150) devkit.wait_for_block(2) return account +def _payment_yaml(from_addr, to_addr, quantity): + return f""" +version: 1.0 +transaction: + - tx: + from: {from_addr} + intents: + - type: payment + address: {to_addr} + amounts: + - unit: lovelace + quantity: "{quantity}" +""" + + def test_simple_ada_transfer(ccl_lib, devkit, funded_sender): - """Send 5 ADA to a new address and verify on-chain.""" + """Build a 5 ADA payment from TxPlan YAML, sign, submit, and verify on-chain.""" receiver = ccl_lib.account.create(CclLib.TESTNET) - # Fetch UTXOs and protocol params from DevKit utxos = devkit.get_utxos(funded_sender["base_address"]) pp = devkit.get_protocol_params() - # Build transaction - result = ccl_lib.quicktx.new_tx() \ - .pay_to_address(receiver["base_address"], Amount.ada(5)) \ - .from_address(funded_sender["base_address"]) \ - .with_utxos(utxos) \ - .with_protocol_params(pp) \ - .build() - + yaml_str = _payment_yaml(funded_sender["base_address"], receiver["base_address"], "5000000") + result = ccl_lib.quicktx.build(yaml_str, utxos, pp) assert len(result["tx_cbor"]) > 0 assert len(result["tx_hash"]) == 64 assert int(result["fee"]) > 0 - # Sign signed_tx = ccl_lib.account.sign_tx( - funded_sender["mnemonic"], result["tx_cbor"], - CclLib.TESTNET, 0, 0) - - # Submit + funded_sender["mnemonic"], result["tx_cbor"], CclLib.TESTNET, 0, 0) tx_hash = devkit.submit_tx(signed_tx) - assert tx_hash is not None + assert tx_hash - # Wait and verify devkit.wait_for_block(3) receiver_utxos = devkit.get_utxos(receiver["base_address"]) - total_lovelace = sum( - int(a["quantity"]) - for u in receiver_utxos - for a in u["amount"] - if a["unit"] == "lovelace" - ) - assert total_lovelace == 5_000_000 + total = sum(int(a["quantity"]) for u in receiver_utxos + for a in u["amount"] if a["unit"] == "lovelace") + assert total == 5_000_000 def test_multiple_receivers(ccl_lib, devkit, funded_sender): - """Send to two receivers in one transaction.""" r1 = ccl_lib.account.create(CclLib.TESTNET) r2 = ccl_lib.account.create(CclLib.TESTNET) utxos = devkit.get_utxos(funded_sender["base_address"]) pp = devkit.get_protocol_params() - result = ccl_lib.quicktx.new_tx() \ - .pay_to_address(r1["base_address"], Amount.ada(3)) \ - .pay_to_address(r2["base_address"], Amount.ada(2)) \ - .from_address(funded_sender["base_address"]) \ - .with_utxos(utxos) \ - .with_protocol_params(pp) \ - .build() - + yaml_str = f""" +version: 1.0 +transaction: + - tx: + from: {funded_sender['base_address']} + intents: + - type: payment + address: {r1['base_address']} + amounts: + - unit: lovelace + quantity: "3000000" + - type: payment + address: {r2['base_address']} + amounts: + - unit: lovelace + quantity: "2000000" +""" + result = ccl_lib.quicktx.build(yaml_str, utxos, pp) signed_tx = ccl_lib.account.sign_tx( - funded_sender["mnemonic"], result["tx_cbor"], - CclLib.TESTNET, 0, 0) + funded_sender["mnemonic"], result["tx_cbor"], CclLib.TESTNET, 0, 0) + assert devkit.submit_tx(signed_tx) - devkit.submit_tx(signed_tx) devkit.wait_for_block(3) - r1_utxos = devkit.get_utxos(r1["base_address"]) - r2_utxos = devkit.get_utxos(r2["base_address"]) - - r1_lovelace = sum(int(a["quantity"]) for u in r1_utxos for a in u["amount"] if a["unit"] == "lovelace") - r2_lovelace = sum(int(a["quantity"]) for u in r2_utxos for a in u["amount"] if a["unit"] == "lovelace") - - assert r1_lovelace == 3_000_000 - assert r2_lovelace == 2_000_000 - - -def test_metadata_transfer(ccl_lib, devkit, funded_sender): - """Send ADA with CIP-20 metadata and verify.""" - receiver = ccl_lib.account.create(CclLib.TESTNET) - - utxos = devkit.get_utxos(funded_sender["base_address"]) - pp = devkit.get_protocol_params() - - result = ccl_lib.quicktx.new_tx() \ - .pay_to_address(receiver["base_address"], Amount.ada(2)) \ - .attach_metadata(674, {"msg": ["Hello from CCL Bridge"]}) \ - .from_address(funded_sender["base_address"]) \ - .with_utxos(utxos) \ - .with_protocol_params(pp) \ - .build() - - signed_tx = ccl_lib.account.sign_tx( - funded_sender["mnemonic"], result["tx_cbor"], - CclLib.TESTNET, 0, 0) - - tx_hash = devkit.submit_tx(signed_tx) - devkit.wait_for_block(3) - - # Verify tx exists on-chain - tx_info = devkit.get_tx(result["tx_hash"]) - assert tx_info is not None + total = sum(int(a["quantity"]) for u in r1_utxos + for a in u["amount"] if a["unit"] == "lovelace") + assert total == 3_000_000 -def test_insufficient_funds_error(ccl_lib, devkit): - """Should fail when UTXOs don't have enough funds.""" +def test_insufficient_funds(ccl_lib, devkit): sender = ccl_lib.account.create(CclLib.TESTNET) - receiver = ccl_lib.account.create(CclLib.TESTNET) - - # Fund with only 2 ADA devkit.topup(sender["base_address"], 2) devkit.wait_for_block(2) + receiver = ccl_lib.account.create(CclLib.TESTNET) utxos = devkit.get_utxos(sender["base_address"]) pp = devkit.get_protocol_params() + yaml_str = _payment_yaml(sender["base_address"], receiver["base_address"], "100000000") with pytest.raises(CclError): - ccl_lib.quicktx.new_tx() \ - .pay_to_address(receiver["base_address"], Amount.ada(100)) \ - .from_address(sender["base_address"]) \ - .with_utxos(utxos) \ - .with_protocol_params(pp) \ - .build() - - -def test_round_trip_sign_submit(ccl_lib, devkit, funded_sender): - """Full round trip: build -> sign -> submit -> confirm -> check balance.""" - receiver = ccl_lib.account.create(CclLib.TESTNET) - - utxos = devkit.get_utxos(funded_sender["base_address"]) - pp = devkit.get_protocol_params() - - # Build - result = ccl_lib.quicktx.new_tx() \ - .pay_to_address(receiver["base_address"], Amount.ada(10)) \ - .from_address(funded_sender["base_address"]) \ - .with_utxos(utxos) \ - .with_protocol_params(pp) \ - .build() - - # Sign - signed_tx = ccl_lib.account.sign_tx( - funded_sender["mnemonic"], result["tx_cbor"], - CclLib.TESTNET, 0, 0) - - # Submit - tx_hash = devkit.submit_tx(signed_tx) - devkit.wait_for_block(3) - - # Confirm on-chain - tx_info = devkit.get_tx(result["tx_hash"]) - assert tx_info is not None - - # Check receiver balance - receiver_utxos = devkit.get_utxos(receiver["base_address"]) - total = sum( - int(a["quantity"]) - for u in receiver_utxos - for a in u["amount"] - if a["unit"] == "lovelace" - ) - assert total == 10_000_000 - - -# --- Provider Config (server-side lazy UTXO fetching) tests --- - -DEVKIT_PROVIDER_URL = "http://localhost:10000/local-cluster/api" - - -def test_provider_config_simple_transfer(ccl_lib, devkit, funded_sender): - """Build with provider_config — Java fetches UTXOs lazily via HTTP.""" - receiver = ccl_lib.account.create(CclLib.TESTNET) - - result = ccl_lib.quicktx.new_tx() \ - .pay_to_address(receiver["base_address"], Amount.ada(5)) \ - .from_address(funded_sender["base_address"]) \ - .build(provider_config={"name": "yaci", "url": DEVKIT_PROVIDER_URL}) - - assert len(result["tx_cbor"]) > 0 - assert len(result["tx_hash"]) == 64 - assert int(result["fee"]) > 0 - - # Sign and submit - signed_tx = ccl_lib.account.sign_tx( - funded_sender["mnemonic"], result["tx_cbor"], - CclLib.TESTNET, 0, 0) - tx_hash = devkit.submit_tx(signed_tx) - assert tx_hash is not None - - devkit.wait_for_block(3) - receiver_utxos = devkit.get_utxos(receiver["base_address"]) - total = sum( - int(a["quantity"]) - for u in receiver_utxos - for a in u["amount"] - if a["unit"] == "lovelace" - ) - assert total == 5_000_000 - - -def test_provider_config_multiple_receivers(ccl_lib, devkit, funded_sender): - """Build with provider_config and multiple payment outputs.""" - r1 = ccl_lib.account.create(CclLib.TESTNET) - r2 = ccl_lib.account.create(CclLib.TESTNET) - - result = ccl_lib.quicktx.new_tx() \ - .pay_to_address(r1["base_address"], Amount.ada(3)) \ - .pay_to_address(r2["base_address"], Amount.ada(2)) \ - .from_address(funded_sender["base_address"]) \ - .build(provider_config={"name": "yaci", "url": DEVKIT_PROVIDER_URL}) - - signed_tx = ccl_lib.account.sign_tx( - funded_sender["mnemonic"], result["tx_cbor"], - CclLib.TESTNET, 0, 0) - devkit.submit_tx(signed_tx) - devkit.wait_for_block(3) - - r1_utxos = devkit.get_utxos(r1["base_address"]) - r2_utxos = devkit.get_utxos(r2["base_address"]) - - r1_lovelace = sum(int(a["quantity"]) for u in r1_utxos for a in u["amount"] if a["unit"] == "lovelace") - r2_lovelace = sum(int(a["quantity"]) for u in r2_utxos for a in u["amount"] if a["unit"] == "lovelace") - - assert r1_lovelace == 3_000_000 - assert r2_lovelace == 2_000_000 - - -def test_provider_config_with_metadata(ccl_lib, devkit, funded_sender): - """Build with provider_config and metadata attachment.""" - receiver = ccl_lib.account.create(CclLib.TESTNET) - - result = ccl_lib.quicktx.new_tx() \ - .pay_to_address(receiver["base_address"], Amount.ada(2)) \ - .attach_metadata(674, {"msg": ["Hello from providerConfig"]}) \ - .from_address(funded_sender["base_address"]) \ - .build(provider_config={"name": "yaci", "url": DEVKIT_PROVIDER_URL}) - - signed_tx = ccl_lib.account.sign_tx( - funded_sender["mnemonic"], result["tx_cbor"], - CclLib.TESTNET, 0, 0) - tx_hash = devkit.submit_tx(signed_tx) - assert tx_hash is not None - - devkit.wait_for_block(3) - tx_info = devkit.get_tx(result["tx_hash"]) - assert tx_info is not None + ccl_lib.quicktx.build(yaml_str, utxos, pp) diff --git a/wrappers/python/tests/test_quicktx_intents.py b/wrappers/python/tests/test_quicktx_intents.py new file mode 100644 index 0000000..5b4e6d6 --- /dev/null +++ b/wrappers/python/tests/test_quicktx_intents.py @@ -0,0 +1,97 @@ +"""End-to-end coverage of every TxPlan intent through the native library. + +The fixtures in ``test-fixtures/quicktx-intents/`` are generated by the JVM QuickTxIntentsTest via +``TxPlan.from(tx).toYaml()`` (CCL's exact intent shapes); here each one is built through the native +library via the Python wrapper. Mirrors the Go ``intents_test.go`` for cross-wrapper parity. +""" +import glob +from pathlib import Path + +import pytest + +from ccl._ffi import CclLib, CclError + +FIXTURES = Path(__file__).resolve().parents[3] / "test-fixtures" / "quicktx-intents" + +# The fee_payer baked into the fixtures (derived from the core test mnemonic), and the deterministic +# always-succeeds-V2 script address + datum hash used by the Plutus spend fixture. +SENDER = "addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp" +SENDER2 = "addr_test1qz7svwszky8gcmhrfza7a89z9u0dfzd3l7h23sqlc5yml7ejcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwqcqrvr0" +SCRIPT_ADDR = "addr_test1wpunlryvl7aqsxe22erzlsseej87v5kk5vutvtrmzdy8dect48z0w" +SCRIPT_DATUM_HASH = "9e1199a988ba72ffd6e9c269cadb3b53b5f360ff99f112d9b2ee30c4d74ad88b" +SCRIPT_TX_HASH = "b" * 64 +EXEC_UNITS = [{"mem": 2000000, "steps": 500000000}] + +# Protocol parameters incl. the Conway deposits the governance/staking/pool intents need. +PROTOCOL_PARAMS = { + "min_fee_a": 44, "min_fee_b": 155381, "max_tx_size": 16384, "max_val_size": "5000", + "key_deposit": "2000000", "pool_deposit": "500000000", "drep_deposit": "2000000", + "gov_action_deposit": "1000000000", "coins_per_utxo_size": "4310", + "max_tx_ex_mem": "14000000", "max_tx_ex_steps": "10000000000", + "price_mem": 0.0577, "price_step": 0.0000721, "collateral_percent": 150, + "max_collateral_inputs": 3, "min_fee_ref_script_cost_per_byte": 15, +} + + +def _utxos(): + # A 2000-ADA UTXO (covers the gov-action deposit) + a small one for the reference-input fixture. + return [ + {"tx_hash": "a" * 64, "output_index": 0, "address": SENDER, + "amount": [{"unit": "lovelace", "quantity": "2000000000"}]}, + {"tx_hash": "c" * 64, "output_index": 0, "address": SENDER, + "amount": [{"unit": "lovelace", "quantity": "5000000"}]}, + {"tx_hash": "a" * 64, "output_index": 1, "address": SENDER2, + "amount": [{"unit": "lovelace", "quantity": "2000000000"}]}, + ] + + +def _assert_built(result): + assert len(result["tx_cbor"]) > 0 + assert len(result["tx_hash"]) == 64 + assert int(result["fee"]) > 0 + + +@pytest.mark.parametrize("fixture", sorted(glob.glob(str(FIXTURES / "*.yaml"))), + ids=lambda p: Path(p).stem) +def test_intent_builds(ccl, fixture): + yaml = Path(fixture).read_text() + _assert_built(ccl.quicktx.build(yaml, _utxos(), PROTOCOL_PARAMS)) + + +# The mnemonic the fixtures are derived from (account 0/0 == SENDER). +INTENT_MNEMONIC = "test walk nut penalty hip pave soap entry language right filter choice" + + +def test_sign_with_stake_key(ccl): + # A stake registration must be witnessed by the stake key too; sign_tx_with_keys adds it. + yaml = (FIXTURES / "stake_registration.yaml").read_text() + utxos = [{"tx_hash": "a" * 64, "output_index": 0, "address": SENDER, + "amount": [{"unit": "lovelace", "quantity": "2000000000"}]}] + built = ccl.quicktx.build(yaml, utxos, PROTOCOL_PARAMS) + + signed_payment = ccl.account.sign_tx(INTENT_MNEMONIC, built["tx_cbor"], CclLib.TESTNET, 0, 0) + signed_stake = ccl.account.sign_tx_with_keys( + INTENT_MNEMONIC, built["tx_cbor"], ["payment", "stake"], CclLib.TESTNET, 0, 0) + assert len(signed_stake) > len(signed_payment) + + +def test_plutus_mint(ccl): + yaml = (FIXTURES / "plutus" / "script_minting.yaml").read_text() + utxos = [{"tx_hash": "a" * 64, "output_index": 0, "address": SENDER, + "amount": [{"unit": "lovelace", "quantity": "2000000000"}]}] + _assert_built(ccl.quicktx.build(yaml, utxos, PROTOCOL_PARAMS, exec_units=EXEC_UNITS)) + with pytest.raises(CclError): + ccl.quicktx.build(yaml, utxos, PROTOCOL_PARAMS) + + +def test_plutus_spend(ccl): + yaml = (FIXTURES / "plutus" / "script_collect_from.yaml").read_text() + utxos = [ + {"tx_hash": SCRIPT_TX_HASH, "output_index": 0, "address": SCRIPT_ADDR, + "amount": [{"unit": "lovelace", "quantity": "10000000"}], "data_hash": SCRIPT_DATUM_HASH}, + {"tx_hash": "a" * 64, "output_index": 0, "address": SENDER, + "amount": [{"unit": "lovelace", "quantity": "2000000000"}]}, + ] + _assert_built(ccl.quicktx.build(yaml, utxos, PROTOCOL_PARAMS, exec_units=EXEC_UNITS)) + with pytest.raises(CclError): + ccl.quicktx.build(yaml, utxos, PROTOCOL_PARAMS) diff --git a/wrappers/rust/Cargo.lock b/wrappers/rust/Cargo.lock index 37bf4d9..dc211ad 100644 --- a/wrappers/rust/Cargo.lock +++ b/wrappers/rust/Cargo.lock @@ -30,6 +30,7 @@ version = "0.1.0" dependencies = [ "serde", "serde_json", + "serde_yaml", "ureq", ] @@ -59,6 +60,12 @@ dependencies = [ "syn", ] +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -95,6 +102,12 @@ dependencies = [ "wasi", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "icu_collections" version = "2.1.1" @@ -197,6 +210,16 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "itoa" version = "1.0.17" @@ -325,6 +348,12 @@ dependencies = [ "untrusted", ] +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "serde" version = "1.0.228" @@ -368,6 +397,19 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "shlex" version = "1.3.0" @@ -436,6 +478,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/wrappers/rust/Cargo.toml b/wrappers/rust/Cargo.toml index 85c6102..aa3dd82 100644 --- a/wrappers/rust/Cargo.toml +++ b/wrappers/rust/Cargo.toml @@ -7,6 +7,7 @@ description = "Rust bindings for Cardano Client Lib (CCL) via GraalVM native lib [dependencies] serde = { version = "1", features = ["derive"] } serde_json = "1" +serde_yaml = "0.9" [dev-dependencies] ureq = { version = "2", features = ["json"] } diff --git a/wrappers/rust/README.md b/wrappers/rust/README.md new file mode 100644 index 0000000..fb92e42 --- /dev/null +++ b/wrappers/rust/README.md @@ -0,0 +1,84 @@ +# CCL Bridge — Rust + +Rust bindings for [Cardano Client Lib](https://github.com/bloxbean/cardano-client-lib) +via the CCL Bridge native library. + +> Part of the [CCL Bridge](../../README.md) project. See the +> [top-level README](../../README.md) for the full API reference and +> [`docs/quicktx.md`](../../docs/quicktx.md) for transaction building. + +## Requirements + +- Rust (stable, 2021 edition). +- The native library `libccl.{dylib,so,dll}` for your platform. + +## Getting the native library + +`build.rs` links against `libccl`, looking in `CCL_LIB_PATH` (default: +`../../core/build/native/nativeCompile`, relative to this crate). Build or download it +there first. From the repo root: + +```bash +./gradlew :core:nativeCompile # build from source (needs Oracle GraalVM 25.0.3) +# or: +make download-lib # download a pre-built binary +``` + +At **runtime** the OS loader also needs the library via `DYLD_LIBRARY_PATH` (macOS) / +`LD_LIBRARY_PATH` (Linux). + +## Running the examples + +From `wrappers/rust`: + +```bash +LIB_DIR=../../core/build/native/nativeCompile + +CCL_LIB_PATH=$LIB_DIR DYLD_LIBRARY_PATH=$LIB_DIR LD_LIBRARY_PATH=$LIB_DIR \ + cargo run --example account +``` + +The [`examples/`](examples/) directory contains: + +| `--example` | What it shows | +|-------------|---------------| +| [`account`](examples/account.rs) | Create an account, restore from mnemonic, derive keys and a DRep ID | +| [`primitives`](examples/primitives.rs) | Mnemonics, Blake2b hashing, Ed25519 signing, address parsing/validation | +| [`transaction`](examples/transaction.rs) | Build an unsigned payment **offline** (QuickTx) and sign it — no node/DevKit needed | + +## Quick start + +```rust +use ccl::{Bridge, network}; + +fn main() -> Result<(), Box> { + let bridge = Bridge::new()?; // loads libccl, starts a GraalVM isolate + + // API methods return JSON strings; parse with serde_json. + let account = bridge.account().create(network::TESTNET)?; + let json: serde_json::Value = serde_json::from_str(&account)?; + println!("{}", json["base_address"]); // addr_test1... + println!("{}", json["mnemonic"]); // 24-word phrase + Ok(()) +} // Bridge's Drop tears down the isolate +``` + +## API surface + +A `Bridge` exposes namespaced accessors (all offline operations): +`bridge.account()`, `.address()`, `.crypto()`, `.tx()`, `.plutus()`, `.script()`, +`.gov()`, `.wallet()`, `.quicktx()`. + +Most methods return `Result` where the `String` is JSON — parse it with +`serde_json`. + +Transactions are defined as a [TxPlan](https://github.com/bloxbean/cardano-client-lib) +**YAML** document and built fully offline — you supply the UTXOs and protocol parameters +(as `serde_json::Value`): + +```rust +let result = bridge.quicktx().build(&yaml, &utxos, &protocol_params)?; // -> TxResult { tx_cbor, tx_hash, fee } +``` + +Network IDs: `network::MAINNET` (0), `network::TESTNET` (1), `network::PREPROD` (2), +`network::PREVIEW` (3). Errors are `ccl::CclError`. diff --git a/wrappers/rust/examples/account.rs b/wrappers/rust/examples/account.rs new file mode 100644 index 0000000..93a7a3e --- /dev/null +++ b/wrappers/rust/examples/account.rs @@ -0,0 +1,40 @@ +//! Account creation and key derivation (offline). +//! +//! Run from wrappers/rust: +//! +//! ```text +//! LIB_DIR=../../core/build/native/nativeCompile +//! CCL_LIB_PATH=$LIB_DIR DYLD_LIBRARY_PATH=$LIB_DIR LD_LIBRARY_PATH=$LIB_DIR \ +//! cargo run --example account +//! ``` +use ccl::{network, Bridge}; + +fn main() -> Result<(), Box> { + let bridge = Bridge::new()?; + + // 1. Create a brand-new testnet account (random mnemonic). Methods return JSON. + let created = bridge.account().create(network::TESTNET)?; + let account: serde_json::Value = serde_json::from_str(&created)?; + let mnemonic = account["mnemonic"].as_str().unwrap(); + let base_address = account["base_address"].as_str().unwrap(); + println!("Created account"); + println!(" base address: {}", base_address); + println!(" mnemonic : {}", mnemonic); + + // 2. Restore the same account from its mnemonic — the address must match. + let restored = bridge.account().from_mnemonic(mnemonic, network::TESTNET, 0, 0)?; + let restored: serde_json::Value = serde_json::from_str(&restored)?; + assert_eq!(restored["base_address"].as_str().unwrap(), base_address); + println!("Restored from mnemonic — address matches: {}", base_address); + + // 3. Derive keys. + let priv_key = bridge.account().get_private_key(mnemonic, network::TESTNET, 0, 0)?; + let pub_key = bridge.account().get_public_key(mnemonic, network::TESTNET, 0, 0)?; + println!(" private key (extended, hex): {}", priv_key); + println!(" public key (hex) : {}", pub_key); + + // 4. Derive the governance DRep ID. + let drep_id = bridge.account().get_drep_id(mnemonic, network::TESTNET, 0)?; + println!(" DRep ID: {}", drep_id); + Ok(()) +} diff --git a/wrappers/rust/examples/primitives.rs b/wrappers/rust/examples/primitives.rs new file mode 100644 index 0000000..761f23d --- /dev/null +++ b/wrappers/rust/examples/primitives.rs @@ -0,0 +1,56 @@ +//! Crypto and address primitives (offline). +//! +//! Run from wrappers/rust: +//! +//! ```text +//! LIB_DIR=../../core/build/native/nativeCompile +//! CCL_LIB_PATH=$LIB_DIR DYLD_LIBRARY_PATH=$LIB_DIR LD_LIBRARY_PATH=$LIB_DIR \ +//! cargo run --example primitives +//! ``` +use ccl::{network, Bridge}; + +fn main() -> Result<(), Box> { + let bridge = Bridge::new()?; + + // --- Mnemonics --- + let mnemonic = bridge.crypto().generate_mnemonic(24)?; + println!("Generated 24-word mnemonic: {}", mnemonic); + println!(" valid? {}", bridge.crypto().validate_mnemonic(&mnemonic)); + println!( + " 'not a real mnemonic' valid? {}", + bridge.crypto().validate_mnemonic("not a real mnemonic") + ); + + // --- Blake2b hashing (hex in -> hex out). "Hello" == 48656c6c6f --- + println!("Blake2b-256('Hello'): {}", bridge.crypto().blake2b_256("48656c6c6f")?); + println!("Blake2b-224('Hello'): {}", bridge.crypto().blake2b_224("48656c6c6f")?); + + // --- Ed25519 signing --- + // get_private_key returns the 64-byte extended key; sign expects a 32-byte + // Ed25519 key, so take the first 32 bytes (64 hex chars). + let created = bridge.account().create(network::TESTNET)?; + let account: serde_json::Value = serde_json::from_str(&created)?; + let mnemonic = account["mnemonic"].as_str().unwrap(); + let priv_ext = bridge.account().get_private_key(mnemonic, network::TESTNET, 0, 0)?; + let pub_key = bridge.account().get_public_key(mnemonic, network::TESTNET, 0, 0)?; + let message_hex = "68656c6c6f"; // "hello" + let signature = bridge.crypto().sign(message_hex, &priv_ext[..64])?; + println!("Ed25519 signature: {}", signature); + // A tampered signature is correctly rejected. + let fake_sig = "00".repeat(64); + println!( + " verify(fake signature) -> {}", + bridge.crypto().verify(&fake_sig, message_hex, &pub_key) + ); + + // --- Address parsing & validation --- + let addr = account["base_address"].as_str().unwrap(); + println!("Address valid? {}", bridge.address().validate(addr)); + println!("Address info : {}", bridge.address().info(addr)?); + let raw = bridge.address().to_bytes(addr)?; + println!( + "Address -> bytes -> address round-trips: {}", + bridge.address().from_bytes(&raw)? == addr + ); + Ok(()) +} diff --git a/wrappers/rust/examples/transaction.rs b/wrappers/rust/examples/transaction.rs new file mode 100644 index 0000000..468bfc4 --- /dev/null +++ b/wrappers/rust/examples/transaction.rs @@ -0,0 +1,74 @@ +//! Build and sign a payment transaction fully offline from a TxPlan (YAML). +//! +//! The transaction is defined as a TxPlan YAML document; we supply the UTXOs and protocol +//! parameters ourselves (no node / no provider), build the unsigned CBOR, then sign it locally. +//! Submitting it is a separate, online step. +//! +//! Run from wrappers/rust: +//! +//! ```text +//! LIB_DIR=../../core/build/native/nativeCompile +//! CCL_LIB_PATH=$LIB_DIR DYLD_LIBRARY_PATH=$LIB_DIR LD_LIBRARY_PATH=$LIB_DIR \ +//! cargo run --example transaction +//! ``` +use ccl::{network, Bridge}; +use serde_json::json; + +fn main() -> Result<(), Box> { + let bridge = Bridge::new()?; + + let sender: serde_json::Value = + serde_json::from_str(&bridge.account().create(network::TESTNET)?)?; + let receiver: serde_json::Value = + serde_json::from_str(&bridge.account().create(network::TESTNET)?)?; + let sender_addr = sender["base_address"].as_str().unwrap(); + let receiver_addr = receiver["base_address"].as_str().unwrap(); + + // Minimal protocol parameters (CCL test-resource values). + let protocol_params = json!({ + "min_fee_a": 44, "min_fee_b": 155381, "max_tx_size": 16384, + "key_deposit": "2000000", "pool_deposit": "500000000", + "coins_per_utxo_size": "4310", "max_val_size": "5000", + "max_tx_ex_mem": "10000000", "max_tx_ex_steps": "10000000000", + "price_mem": 0.0577, "price_step": 0.0000721, "collateral_percent": 150, + "max_collateral_inputs": 3 + }); + + // A static UTXO the sender controls (100 ADA), instead of querying a node. + let utxos = json!([{ + "tx_hash": "a".repeat(64), + "output_index": 0, + "address": sender_addr, + "amount": [{"unit": "lovelace", "quantity": "100000000"}] + }]); + + // Define the transaction as a TxPlan YAML document: pay 5 ADA to the receiver. + let yaml = format!( + "version: 1.0\n\ + transaction:\n\ + \x20 - tx:\n\ + \x20 from: {sender_addr}\n\ + \x20 intents:\n\ + \x20 - type: payment\n\ + \x20 address: {receiver_addr}\n\ + \x20 amounts:\n\ + \x20 - unit: lovelace\n\ + \x20 quantity: \"5000000\"\n" + ); + + // Build the unsigned transaction offline. + let result = bridge.quicktx().build(&yaml, &utxos, &protocol_params, None)?; + println!("Built unsigned transaction from TxPlan YAML"); + println!(" tx hash: {}", result.tx_hash); + println!(" fee : {}", result.fee); + println!(" cbor : {}...", &result.tx_cbor[..80]); + + // Sign it with the sender's mnemonic. + let mnemonic = sender["mnemonic"].as_str().unwrap(); + let signed = bridge + .account() + .sign_tx(mnemonic, network::TESTNET, 0, 0, &result.tx_cbor)?; + println!("Signed transaction cbor: {}...", &signed[..80]); + println!("\nNext step (not shown): submit `signed` to a Cardano node over HTTP."); + Ok(()) +} diff --git a/wrappers/rust/src/ffi.rs b/wrappers/rust/src/ffi.rs index 2146c4e..e68c177 100644 --- a/wrappers/rust/src/ffi.rs +++ b/wrappers/rust/src/ffi.rs @@ -59,6 +59,15 @@ extern "C" { address_index: c_int, tx_cbor_hex: *const c_char, ) -> c_int; + pub fn ccl_account_sign_tx_multi( + thread: *mut graal_isolatethread_t, + mnemonic: *const c_char, + network_id: c_int, + account_index: c_int, + address_index: c_int, + tx_cbor_hex: *const c_char, + keys: *const c_char, + ) -> c_int; pub fn ccl_account_get_drep_id( thread: *mut graal_isolatethread_t, mnemonic: *const c_char, @@ -184,6 +193,9 @@ extern "C" { // QuickTx API pub fn ccl_quicktx_build( thread: *mut graal_isolatethread_t, - spec_json: *const c_char, + yaml: *const c_char, + utxos_json: *const c_char, + protocol_params_json: *const c_char, + exec_units_json: *const c_char, ) -> c_int; } diff --git a/wrappers/rust/src/lib.rs b/wrappers/rust/src/lib.rs index 69011c7..9eb1c26 100644 --- a/wrappers/rust/src/lib.rs +++ b/wrappers/rust/src/lib.rs @@ -1,12 +1,10 @@ mod ffi; -use std::collections::HashMap; use std::ffi::{CStr, CString}; use std::os::raw::c_char; use std::ptr; -use serde::Serialize; -use serde_json::{json, Value}; +use serde_json::Value; pub use ffi::*; @@ -281,6 +279,36 @@ impl<'a> AccountApi<'a> { self.bridge.check(rc) } + /// Sign a transaction with one or more of the account's keys, selected by role (any of + /// `payment`, `stake`, `drep`, `committee_cold`, `committee_hot`, applied in order). Use this + /// for transactions whose certificates also need the stake or DRep key — stake + /// registration/delegation/withdrawal and DRep/vote operations. + pub fn sign_tx_with_keys( + &self, + mnemonic: &str, + network_id: i32, + account_index: i32, + address_index: i32, + tx_cbor_hex: &str, + keys: &[&str], + ) -> Result { + let cs_mnemonic = to_cstring(mnemonic)?; + let cs_tx = to_cstring(tx_cbor_hex)?; + let cs_keys = to_cstring(&keys.join(","))?; + let rc = unsafe { + ffi::ccl_account_sign_tx_multi( + self.bridge.thread, + cs_mnemonic.as_ptr(), + network_id, + account_index, + address_index, + cs_tx.as_ptr(), + cs_keys.as_ptr(), + ) + }; + self.bridge.check(rc) + } + pub fn get_drep_id( &self, mnemonic: &str, @@ -575,2146 +603,69 @@ pub struct TxResult { pub fee: String, } -/// Helper for creating amount values. -pub struct Amount; - -impl Amount { - pub fn lovelace(quantity: u64) -> Value { - json!({"unit": "lovelace", "quantity": quantity.to_string()}) - } - - pub fn ada(ada: f64) -> Value { - json!({"unit": "lovelace", "quantity": ((ada * 1_000_000.0) as u64).to_string()}) - } - - pub fn asset(unit: &str, quantity: u64) -> Value { - json!({"unit": unit, "quantity": quantity.to_string()}) - } -} - -/// Asset to mint. -#[derive(Serialize)] -pub struct MintAsset { - pub name: String, - pub quantity: String, -} - -/// Provider configuration for Java-side lazy fetching. -#[derive(Serialize)] -pub struct ProviderConfig { - pub name: String, - pub url: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub api_key: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub enable_cost_evaluation: Option, -} - -/// Withdrawal entry for treasury proposals. -#[derive(Serialize)] -pub struct ProposalWithdrawal { - pub reward_address: String, - pub amount: String, -} - /// QuickTx namespace API. pub struct QuickTxApi<'a> { bridge: &'a Bridge, } impl<'a> QuickTxApi<'a> { - /// Create a new TxBuilder for building a single transaction. - pub fn new_tx(&self) -> TxBuilder<'a> { - TxBuilder { - bridge: self.bridge, - operations: Vec::new(), - from: None, - change_address: None, - fee_payer: None, - utxos: None, - protocol_params: None, - validity: serde_json::Map::new(), - merge_outputs: None, - signer_count: 1, - } - } - - /// Create a new ScriptTxBuilder for building a single script transaction. - pub fn new_script_tx(&self) -> ScriptTxBuilder<'a> { - ScriptTxBuilder { - bridge: self.bridge, - operations: Vec::new(), - from: None, - change_address: None, - fee_payer: None, - utxos: None, - protocol_params: None, - validity: serde_json::Map::new(), - merge_outputs: None, - signer_count: 1, - change_datum_cbor_hex: None, - change_datum_hash: None, - } - } - - /// Create a new Tx for use with compose(). - pub fn tx(&self) -> Tx { - Tx { - operations: Vec::new(), - from: None, - change_address: None, - } - } - - /// Create a new ScriptTx for use with compose(). - pub fn script_tx(&self) -> ScriptTx { - ScriptTx { - operations: Vec::new(), - from: None, - change_address: None, - change_datum_cbor_hex: None, - change_datum_hash: None, - } - } - - /// Compose multiple Tx objects into a single transaction. - pub fn compose(&self, txs: Vec) -> ComposeTxBuilder<'a> { - ComposeTxBuilder { - bridge: self.bridge, - txs, - fee_payer: None, - utxos: None, - protocol_params: None, - validity: serde_json::Map::new(), - merge_outputs: None, - signer_count: None, - } - } -} - -/// Builder for a single transaction spec. -pub struct TxBuilder<'a> { - bridge: &'a Bridge, - operations: Vec, - from: Option, - change_address: Option, - fee_payer: Option, - utxos: Option, - protocol_params: Option, - validity: serde_json::Map, - merge_outputs: Option, - signer_count: i32, -} - -impl<'a> TxBuilder<'a> { - pub fn pay_to_address( - &mut self, - address: &str, - amounts: &[Value], - script_ref_cbor_hex: Option<&str>, - script_ref_type: Option<&str>, - ) -> &mut Self { - let mut op = json!({ - "type": "pay_to_address", - "address": address, - "amounts": amounts, - }); - if let Some(s) = script_ref_cbor_hex { - op["script_ref_cbor_hex"] = json!(s); - } - if let Some(t) = script_ref_type { - op["script_ref_type"] = json!(t); - } - self.operations.push(op); - self - } - - pub fn pay_to_contract( - &mut self, - address: &str, - amounts: &[Value], - datum_cbor_hex: Option<&str>, - datum_hash: Option<&str>, - script_ref_cbor_hex: Option<&str>, - script_ref_type: Option<&str>, - ) -> &mut Self { - let mut op = json!({ - "type": "pay_to_contract", - "address": address, - "amounts": amounts, - }); - if let Some(d) = datum_cbor_hex { - op["datum_cbor_hex"] = json!(d); - } - if let Some(h) = datum_hash { - op["datum_hash"] = json!(h); - } - if let Some(s) = script_ref_cbor_hex { - op["script_ref_cbor_hex"] = json!(s); - } - if let Some(t) = script_ref_type { - op["script_ref_type"] = json!(t); - } - self.operations.push(op); - self - } - - pub fn mint_assets( - &mut self, - script_json: &str, - assets: &[MintAsset], - receiver: &str, - ) -> &mut Self { - self.operations.push(json!({ - "type": "mint_assets", - "script_json": script_json, - "assets": assets, - "receiver": receiver, - })); - self - } - - pub fn attach_metadata(&mut self, label: u64, metadata: Value) -> &mut Self { - self.operations.push(json!({ - "type": "attach_metadata", - "label": label, - "metadata": metadata, - })); - self - } - - pub fn collect_from(&mut self, utxos: &[Value]) -> &mut Self { - self.operations.push(json!({ - "type": "collect_from", - "collect_utxos": utxos, - })); - self - } - - // Staking - - pub fn register_stake_address(&mut self, address: &str) -> &mut Self { - self.operations - .push(json!({"type": "register_stake_address", "address": address})); - self - } - - pub fn deregister_stake_address( - &mut self, - address: &str, - refund_address: Option<&str>, - ) -> &mut Self { - let mut op = json!({"type": "deregister_stake_address", "address": address}); - if let Some(r) = refund_address { - op["refund_address"] = json!(r); - } - self.operations.push(op); - self - } - - pub fn delegate_to(&mut self, address: &str, pool_id: &str) -> &mut Self { - self.operations - .push(json!({"type": "delegate_to", "address": address, "pool_id": pool_id})); - self - } - - pub fn withdraw( - &mut self, - reward_address: &str, - amount: u64, - receiver: Option<&str>, - ) -> &mut Self { - let mut op = - json!({"type": "withdraw", "reward_address": reward_address, "amount": amount.to_string()}); - if let Some(r) = receiver { - op["receiver"] = json!(r); - } - self.operations.push(op); - self - } - - // DRep - - pub fn register_drep( - &mut self, - cred_hash: &str, - cred_type: &str, - anchor_url: Option<&str>, - anchor_data_hash: Option<&str>, - ) -> &mut Self { - let mut op = json!({ - "type": "register_drep", - "credential_hash": cred_hash, - "credential_type": cred_type, - }); - if let Some(u) = anchor_url { - op["anchor_url"] = json!(u); - } - if let Some(h) = anchor_data_hash { - op["anchor_data_hash"] = json!(h); - } - self.operations.push(op); - self - } - - pub fn unregister_drep( - &mut self, - cred_hash: &str, - cred_type: &str, - refund_address: Option<&str>, - refund_amount: Option<&str>, - ) -> &mut Self { - let mut op = json!({ - "type": "unregister_drep", - "credential_hash": cred_hash, - "credential_type": cred_type, - }); - if let Some(r) = refund_address { - op["refund_address"] = json!(r); - } - if let Some(a) = refund_amount { - op["refund_amount"] = json!(a); - } - self.operations.push(op); - self - } - - pub fn update_drep( - &mut self, - cred_hash: &str, - cred_type: &str, - anchor_url: Option<&str>, - anchor_data_hash: Option<&str>, - ) -> &mut Self { - let mut op = json!({ - "type": "update_drep", - "credential_hash": cred_hash, - "credential_type": cred_type, - }); - if let Some(u) = anchor_url { - op["anchor_url"] = json!(u); - } - if let Some(h) = anchor_data_hash { - op["anchor_data_hash"] = json!(h); - } - self.operations.push(op); - self - } - - // Voting - - pub fn delegate_voting_power_to( - &mut self, - address: &str, - drep_type: &str, - drep_hash: Option<&str>, - ) -> &mut Self { - let mut op = - json!({"type": "delegate_voting_power_to", "address": address, "drep_type": drep_type}); - if let Some(h) = drep_hash { - op["drep_hash"] = json!(h); - } - self.operations.push(op); - self - } - - pub fn create_vote( - &mut self, - voter_type: &str, - voter_hash: &str, - gov_action_tx_hash: &str, - gov_action_index: u32, - vote: &str, - anchor_url: Option<&str>, - anchor_data_hash: Option<&str>, - ) -> &mut Self { - let mut op = json!({ - "type": "create_vote", - "voter_type": voter_type, - "voter_hash": voter_hash, - "gov_action_tx_hash": gov_action_tx_hash, - "gov_action_index": gov_action_index, - "vote": vote, - }); - if let Some(u) = anchor_url { - op["anchor_url"] = json!(u); - } - if let Some(h) = anchor_data_hash { - op["anchor_data_hash"] = json!(h); - } - self.operations.push(op); - self - } - - // Governance - - pub fn create_proposal( - &mut self, - gov_action_type: &str, - return_address: &str, - anchor_url: &str, - anchor_data_hash: &str, - withdrawals: Option<&[ProposalWithdrawal]>, - ) -> &mut Self { - let mut op = json!({ - "type": "create_proposal", - "gov_action_type": gov_action_type, - "return_address": return_address, - "anchor_url": anchor_url, - "anchor_data_hash": anchor_data_hash, - }); - if let Some(w) = withdrawals { - op["withdrawals"] = serde_json::to_value(w).unwrap_or_default(); - } - self.operations.push(op); - self - } - - // Pool Operations - - pub fn register_pool( - &mut self, - operator: &str, - vrf_key_hash: &str, - pledge: &str, - cost: &str, - margin_numerator: &str, - margin_denominator: &str, - reward_address: &str, - pool_owners: &[&str], - relays: Option<&[Value]>, - pool_metadata_url: Option<&str>, - pool_metadata_hash: Option<&str>, - ) -> &mut Self { - let mut op = json!({ - "type": "register_pool", - "operator": operator, - "vrf_key_hash": vrf_key_hash, - "pledge": pledge, - "cost": cost, - "margin_numerator": margin_numerator, - "margin_denominator": margin_denominator, - "reward_address": reward_address, - "pool_owners": pool_owners, - }); - if let Some(r) = relays { - op["relays"] = json!(r); - } - if let Some(u) = pool_metadata_url { - op["pool_metadata_url"] = json!(u); - } - if let Some(h) = pool_metadata_hash { - op["pool_metadata_hash"] = json!(h); - } - self.operations.push(op); - self - } - - pub fn update_pool( - &mut self, - operator: &str, - vrf_key_hash: &str, - pledge: &str, - cost: &str, - margin_numerator: &str, - margin_denominator: &str, - reward_address: &str, - pool_owners: &[&str], - relays: Option<&[Value]>, - pool_metadata_url: Option<&str>, - pool_metadata_hash: Option<&str>, - ) -> &mut Self { - let mut op = json!({ - "type": "update_pool", - "operator": operator, - "vrf_key_hash": vrf_key_hash, - "pledge": pledge, - "cost": cost, - "margin_numerator": margin_numerator, - "margin_denominator": margin_denominator, - "reward_address": reward_address, - "pool_owners": pool_owners, - }); - if let Some(r) = relays { - op["relays"] = json!(r); - } - if let Some(u) = pool_metadata_url { - op["pool_metadata_url"] = json!(u); - } - if let Some(h) = pool_metadata_hash { - op["pool_metadata_hash"] = json!(h); - } - self.operations.push(op); - self - } - - pub fn retire_pool(&mut self, pool_id: &str, epoch: u64) -> &mut Self { - self.operations - .push(json!({"type": "retire_pool", "pool_id": pool_id, "epoch": epoch})); - self - } - - // Treasury - - pub fn donate_to_treasury(&mut self, treasury_value: &str, donation_amount: &str) -> &mut Self { - self.operations.push(json!({ - "type": "donate_to_treasury", - "treasury_value": treasury_value, - "donation_amount": donation_amount, - })); - self - } - - // Native Script - - pub fn attach_native_script(&mut self, script_json: &str) -> &mut Self { - self.operations.push(json!({ - "type": "attach_native_script", - "script_json": script_json, - })); - self - } - - // Config - - pub fn from(&mut self, address: &str) -> &mut Self { - self.from = Some(address.to_string()); - self - } - - pub fn change_address(&mut self, address: &str) -> &mut Self { - self.change_address = Some(address.to_string()); - self - } - - pub fn fee_payer(&mut self, address: &str) -> &mut Self { - self.fee_payer = Some(address.to_string()); - self - } - - pub fn with_utxos(&mut self, utxos: Value) -> &mut Self { - self.utxos = Some(utxos); - self - } - - pub fn with_protocol_params(&mut self, params: Value) -> &mut Self { - self.protocol_params = Some(params); - self - } - - pub fn valid_from(&mut self, slot: u64) -> &mut Self { - self.validity - .insert("valid_from".to_string(), json!(slot)); - self - } - - pub fn valid_to(&mut self, slot: u64) -> &mut Self { - self.validity.insert("valid_to".to_string(), json!(slot)); - self - } - - pub fn merge_outputs(&mut self, merge: bool) -> &mut Self { - self.merge_outputs = Some(merge); - self - } - - pub fn signer_count(&mut self, count: i32) -> &mut Self { - self.signer_count = count; - self - } - - fn build_spec(&self, provider_config: Option<&ProviderConfig>) -> Value { - let mut spec = json!({ - "operations": self.operations, - "from": self.from, - "signer_count": self.signer_count, - }); - - if let Some(pc) = provider_config { - spec["provider"] = serde_json::to_value(pc).unwrap_or_default(); - } else if let Some(ref u) = self.utxos { - spec["utxos"] = u.clone(); - } - if let Some(ref pp) = self.protocol_params { - spec["protocol_params"] = pp.clone(); - } - if let Some(ref ca) = self.change_address { - spec["change_address"] = json!(ca); - } - if let Some(ref fp) = self.fee_payer { - spec["fee_payer"] = json!(fp); - } - if !self.validity.is_empty() { - spec["validity"] = Value::Object(self.validity.clone()); - } - if let Some(m) = self.merge_outputs { - spec["merge_outputs"] = json!(m); - } - spec - } - - /// Build the transaction. - pub fn build(&self) -> Result { - self.do_build(None) - } - - /// Build with a Java-side provider config for lazy UTXO fetching. - pub fn build_with_provider(&self, config: &ProviderConfig) -> Result { - self.do_build(Some(config)) - } - - fn do_build(&self, provider_config: Option<&ProviderConfig>) -> Result { - let spec = self.build_spec(provider_config); - let spec_json = serde_json::to_string(&spec).map_err(|e| CclError { + /// Build an unsigned transaction from a CCL TxPlan (YAML), fully offline. + /// + /// `utxos` and `protocol_params` are the caller-supplied chain data (the CCL `Utxo` / + /// `ProtocolParams` JSON models). The transaction is built offline and never submitted — + /// sign the returned `tx_cbor` and submit it yourself. + /// + /// For Plutus script transactions, pass the redeemers' execution units as `exec_units` — a JSON + /// array of `{mem, steps}` (one per redeemer, in transaction order). Compute them with any + /// evaluator (Ogmios, Blockfrost, Aiken, Scalus); the bridge does not run the script. Pass + /// `None` for non-script transactions. + pub fn build( + &self, + yaml: &str, + utxos: &Value, + protocol_params: &Value, + exec_units: Option<&Value>, + ) -> Result { + let utxos_json = serde_json::to_string(utxos).map_err(|e| CclError { code: error_codes::CCL_ERROR_SERIALIZATION, - message: format!("Failed to serialize spec: {}", e), + message: format!("Failed to serialize utxos: {}", e), })?; - - let cs = to_cstring(&spec_json)?; - let rc = unsafe { ffi::ccl_quicktx_build(self.bridge.thread, cs.as_ptr()) }; - let result = self.bridge.check(rc)?; - - serde_json::from_str(&result).map_err(|e| CclError { - code: error_codes::CCL_ERROR_SERIALIZATION, - message: format!("Failed to parse tx result: {}", e), - }) - } -} - -/// Lightweight operation collector for one transaction in a compose group. -pub struct Tx { - operations: Vec, - from: Option, - change_address: Option, -} - -impl Tx { - pub fn pay_to_address( - &mut self, - address: &str, - amounts: &[Value], - script_ref_cbor_hex: Option<&str>, - script_ref_type: Option<&str>, - ) -> &mut Self { - let mut op = json!({ - "type": "pay_to_address", - "address": address, - "amounts": amounts, - }); - if let Some(s) = script_ref_cbor_hex { - op["script_ref_cbor_hex"] = json!(s); - } - if let Some(t) = script_ref_type { - op["script_ref_type"] = json!(t); - } - self.operations.push(op); - self - } - - pub fn pay_to_contract( - &mut self, - address: &str, - amounts: &[Value], - datum_cbor_hex: Option<&str>, - datum_hash: Option<&str>, - script_ref_cbor_hex: Option<&str>, - script_ref_type: Option<&str>, - ) -> &mut Self { - let mut op = json!({ - "type": "pay_to_contract", - "address": address, - "amounts": amounts, - }); - if let Some(d) = datum_cbor_hex { - op["datum_cbor_hex"] = json!(d); - } - if let Some(h) = datum_hash { - op["datum_hash"] = json!(h); - } - if let Some(s) = script_ref_cbor_hex { - op["script_ref_cbor_hex"] = json!(s); - } - if let Some(t) = script_ref_type { - op["script_ref_type"] = json!(t); - } - self.operations.push(op); - self - } - - pub fn mint_assets( - &mut self, - script_json: &str, - assets: &[MintAsset], - receiver: &str, - ) -> &mut Self { - self.operations.push(json!({ - "type": "mint_assets", - "script_json": script_json, - "assets": assets, - "receiver": receiver, - })); - self - } - - pub fn attach_metadata(&mut self, label: u64, metadata: Value) -> &mut Self { - self.operations.push(json!({ - "type": "attach_metadata", - "label": label, - "metadata": metadata, - })); - self - } - - pub fn collect_from(&mut self, utxos: &[Value]) -> &mut Self { - self.operations.push(json!({ - "type": "collect_from", - "collect_utxos": utxos, - })); - self - } - - pub fn register_stake_address(&mut self, address: &str) -> &mut Self { - self.operations - .push(json!({"type": "register_stake_address", "address": address})); - self - } - - pub fn deregister_stake_address( - &mut self, - address: &str, - refund_address: Option<&str>, - ) -> &mut Self { - let mut op = json!({"type": "deregister_stake_address", "address": address}); - if let Some(r) = refund_address { - op["refund_address"] = json!(r); - } - self.operations.push(op); - self - } - - pub fn delegate_to(&mut self, address: &str, pool_id: &str) -> &mut Self { - self.operations - .push(json!({"type": "delegate_to", "address": address, "pool_id": pool_id})); - self - } - - pub fn withdraw( - &mut self, - reward_address: &str, - amount: u64, - receiver: Option<&str>, - ) -> &mut Self { - let mut op = - json!({"type": "withdraw", "reward_address": reward_address, "amount": amount.to_string()}); - if let Some(r) = receiver { - op["receiver"] = json!(r); - } - self.operations.push(op); - self - } - - pub fn register_drep( - &mut self, - cred_hash: &str, - cred_type: &str, - anchor_url: Option<&str>, - anchor_data_hash: Option<&str>, - ) -> &mut Self { - let mut op = json!({ - "type": "register_drep", - "credential_hash": cred_hash, - "credential_type": cred_type, - }); - if let Some(u) = anchor_url { - op["anchor_url"] = json!(u); - } - if let Some(h) = anchor_data_hash { - op["anchor_data_hash"] = json!(h); - } - self.operations.push(op); - self - } - - pub fn unregister_drep( - &mut self, - cred_hash: &str, - cred_type: &str, - refund_address: Option<&str>, - refund_amount: Option<&str>, - ) -> &mut Self { - let mut op = json!({ - "type": "unregister_drep", - "credential_hash": cred_hash, - "credential_type": cred_type, - }); - if let Some(r) = refund_address { - op["refund_address"] = json!(r); - } - if let Some(a) = refund_amount { - op["refund_amount"] = json!(a); - } - self.operations.push(op); - self - } - - pub fn update_drep( - &mut self, - cred_hash: &str, - cred_type: &str, - anchor_url: Option<&str>, - anchor_data_hash: Option<&str>, - ) -> &mut Self { - let mut op = json!({ - "type": "update_drep", - "credential_hash": cred_hash, - "credential_type": cred_type, - }); - if let Some(u) = anchor_url { - op["anchor_url"] = json!(u); - } - if let Some(h) = anchor_data_hash { - op["anchor_data_hash"] = json!(h); - } - self.operations.push(op); - self - } - - pub fn delegate_voting_power_to( - &mut self, - address: &str, - drep_type: &str, - drep_hash: Option<&str>, - ) -> &mut Self { - let mut op = - json!({"type": "delegate_voting_power_to", "address": address, "drep_type": drep_type}); - if let Some(h) = drep_hash { - op["drep_hash"] = json!(h); - } - self.operations.push(op); - self - } - - pub fn create_vote( - &mut self, - voter_type: &str, - voter_hash: &str, - gov_action_tx_hash: &str, - gov_action_index: u32, - vote: &str, - anchor_url: Option<&str>, - anchor_data_hash: Option<&str>, - ) -> &mut Self { - let mut op = json!({ - "type": "create_vote", - "voter_type": voter_type, - "voter_hash": voter_hash, - "gov_action_tx_hash": gov_action_tx_hash, - "gov_action_index": gov_action_index, - "vote": vote, - }); - if let Some(u) = anchor_url { - op["anchor_url"] = json!(u); - } - if let Some(h) = anchor_data_hash { - op["anchor_data_hash"] = json!(h); - } - self.operations.push(op); - self - } - - pub fn create_proposal( - &mut self, - gov_action_type: &str, - return_address: &str, - anchor_url: &str, - anchor_data_hash: &str, - withdrawals: Option<&[ProposalWithdrawal]>, - ) -> &mut Self { - let mut op = json!({ - "type": "create_proposal", - "gov_action_type": gov_action_type, - "return_address": return_address, - "anchor_url": anchor_url, - "anchor_data_hash": anchor_data_hash, - }); - if let Some(w) = withdrawals { - op["withdrawals"] = serde_json::to_value(w).unwrap_or_default(); - } - self.operations.push(op); - self - } - - // Pool Operations - - pub fn register_pool( - &mut self, - operator: &str, - vrf_key_hash: &str, - pledge: &str, - cost: &str, - margin_numerator: &str, - margin_denominator: &str, - reward_address: &str, - pool_owners: &[&str], - relays: Option<&[Value]>, - pool_metadata_url: Option<&str>, - pool_metadata_hash: Option<&str>, - ) -> &mut Self { - let mut op = json!({ - "type": "register_pool", - "operator": operator, - "vrf_key_hash": vrf_key_hash, - "pledge": pledge, - "cost": cost, - "margin_numerator": margin_numerator, - "margin_denominator": margin_denominator, - "reward_address": reward_address, - "pool_owners": pool_owners, - }); - if let Some(r) = relays { - op["relays"] = json!(r); - } - if let Some(u) = pool_metadata_url { - op["pool_metadata_url"] = json!(u); - } - if let Some(h) = pool_metadata_hash { - op["pool_metadata_hash"] = json!(h); - } - self.operations.push(op); - self - } - - pub fn update_pool( - &mut self, - operator: &str, - vrf_key_hash: &str, - pledge: &str, - cost: &str, - margin_numerator: &str, - margin_denominator: &str, - reward_address: &str, - pool_owners: &[&str], - relays: Option<&[Value]>, - pool_metadata_url: Option<&str>, - pool_metadata_hash: Option<&str>, - ) -> &mut Self { - let mut op = json!({ - "type": "update_pool", - "operator": operator, - "vrf_key_hash": vrf_key_hash, - "pledge": pledge, - "cost": cost, - "margin_numerator": margin_numerator, - "margin_denominator": margin_denominator, - "reward_address": reward_address, - "pool_owners": pool_owners, - }); - if let Some(r) = relays { - op["relays"] = json!(r); - } - if let Some(u) = pool_metadata_url { - op["pool_metadata_url"] = json!(u); - } - if let Some(h) = pool_metadata_hash { - op["pool_metadata_hash"] = json!(h); - } - self.operations.push(op); - self - } - - pub fn retire_pool(&mut self, pool_id: &str, epoch: u64) -> &mut Self { - self.operations - .push(json!({"type": "retire_pool", "pool_id": pool_id, "epoch": epoch})); - self - } - - // Treasury - - pub fn donate_to_treasury(&mut self, treasury_value: &str, donation_amount: &str) -> &mut Self { - self.operations.push(json!({ - "type": "donate_to_treasury", - "treasury_value": treasury_value, - "donation_amount": donation_amount, - })); - self - } - - // Native Script - - pub fn attach_native_script(&mut self, script_json: &str) -> &mut Self { - self.operations.push(json!({ - "type": "attach_native_script", - "script_json": script_json, - })); - self - } - - pub fn from(&mut self, address: &str) -> &mut Self { - self.from = Some(address.to_string()); - self - } - - pub fn change_address(&mut self, address: &str) -> &mut Self { - self.change_address = Some(address.to_string()); - self - } - - fn to_spec(&self) -> Value { - let mut spec = json!({ - "from": self.from, - "operations": self.operations, - }); - if let Some(ref ca) = self.change_address { - spec["change_address"] = json!(ca); - } - spec - } -} - -/// Builder for composing multiple Tx/ScriptTx objects into a single transaction. -pub struct ComposeTxBuilder<'a> { - bridge: &'a Bridge, - txs: Vec, - fee_payer: Option, - utxos: Option, - protocol_params: Option, - validity: serde_json::Map, - merge_outputs: Option, - signer_count: Option, -} - -impl<'a> ComposeTxBuilder<'a> { - pub fn fee_payer(&mut self, address: &str) -> &mut Self { - self.fee_payer = Some(address.to_string()); - self - } - - pub fn with_utxos(&mut self, utxos: Value) -> &mut Self { - self.utxos = Some(utxos); - self - } - - pub fn with_protocol_params(&mut self, params: Value) -> &mut Self { - self.protocol_params = Some(params); - self - } - - pub fn valid_from(&mut self, slot: u64) -> &mut Self { - self.validity - .insert("valid_from".to_string(), json!(slot)); - self - } - - pub fn valid_to(&mut self, slot: u64) -> &mut Self { - self.validity.insert("valid_to".to_string(), json!(slot)); - self - } - - pub fn merge_outputs(&mut self, merge: bool) -> &mut Self { - self.merge_outputs = Some(merge); - self - } - - pub fn signer_count(&mut self, count: i32) -> &mut Self { - self.signer_count = Some(count); - self - } - - /// Build the composed transaction. - pub fn build(&self) -> Result { - self.do_build(None) - } - - /// Build with a Java-side provider config. - pub fn build_with_provider(&self, config: &ProviderConfig) -> Result { - self.do_build(Some(config)) - } - - fn do_build(&self, provider_config: Option<&ProviderConfig>) -> Result { - let tx_specs: Vec = self - .txs - .iter() - .map(|tx| match tx { - ComposableTx::Regular(t) => t.to_spec(), - ComposableTx::Script(s) => s.to_spec(), - }) - .collect(); - - let mut spec = json!({ - "transactions": tx_specs, - "fee_payer": self.fee_payer, - }); - - if let Some(pc) = provider_config { - spec["provider"] = serde_json::to_value(pc).unwrap_or_default(); - } else if let Some(ref u) = self.utxos { - spec["utxos"] = u.clone(); - } - if let Some(ref pp) = self.protocol_params { - spec["protocol_params"] = pp.clone(); - } - if let Some(sc) = self.signer_count { - spec["signer_count"] = json!(sc); - } - if !self.validity.is_empty() { - spec["validity"] = Value::Object(self.validity.clone()); - } - if let Some(m) = self.merge_outputs { - spec["merge_outputs"] = json!(m); - } - - let spec_json = serde_json::to_string(&spec).map_err(|e| CclError { + let pp_json = serde_json::to_string(protocol_params).map_err(|e| CclError { code: error_codes::CCL_ERROR_SERIALIZATION, - message: format!("Failed to serialize compose spec: {}", e), + message: format!("Failed to serialize protocol params: {}", e), })?; - let cs = to_cstring(&spec_json)?; - let rc = unsafe { ffi::ccl_quicktx_build(self.bridge.thread, cs.as_ptr()) }; - let result = self.bridge.check(rc)?; - - serde_json::from_str(&result).map_err(|e| CclError { - code: error_codes::CCL_ERROR_SERIALIZATION, - message: format!("Failed to parse tx result: {}", e), - }) - } -} - -// --- ReferenceInput --- - -/// A reference input for read_from operations. -#[derive(Clone, Debug)] -pub struct ReferenceInput { - pub tx_hash: String, - pub output_index: u32, -} - -// --- ComposableTx --- - -/// Enum for composing regular Tx and ScriptTx objects. -pub enum ComposableTx { - Regular(Tx), - Script(ScriptTx), -} - -// --- ScriptTxBuilder --- - -/// Builder for a single script transaction spec. -pub struct ScriptTxBuilder<'a> { - bridge: &'a Bridge, - operations: Vec, - from: Option, - change_address: Option, - fee_payer: Option, - utxos: Option, - protocol_params: Option, - validity: serde_json::Map, - merge_outputs: Option, - signer_count: i32, - change_datum_cbor_hex: Option, - change_datum_hash: Option, -} - -impl<'a> ScriptTxBuilder<'a> { - pub fn pay_to_address( - &mut self, - address: &str, - amounts: &[Value], - script_ref_cbor_hex: Option<&str>, - script_ref_type: Option<&str>, - ) -> &mut Self { - let mut op = json!({ - "type": "pay_to_address", - "address": address, - "amounts": amounts, - }); - if let Some(s) = script_ref_cbor_hex { - op["script_ref_cbor_hex"] = json!(s); - } - if let Some(t) = script_ref_type { - op["script_ref_type"] = json!(t); - } - self.operations.push(op); - self - } - - pub fn pay_to_contract( - &mut self, - address: &str, - amounts: &[Value], - datum_cbor_hex: Option<&str>, - datum_hash: Option<&str>, - script_ref_cbor_hex: Option<&str>, - script_ref_type: Option<&str>, - ) -> &mut Self { - let mut op = json!({ - "type": "pay_to_contract", - "address": address, - "amounts": amounts, - }); - if let Some(d) = datum_cbor_hex { - op["datum_cbor_hex"] = json!(d); - } - if let Some(h) = datum_hash { - op["datum_hash"] = json!(h); - } - if let Some(s) = script_ref_cbor_hex { - op["script_ref_cbor_hex"] = json!(s); - } - if let Some(t) = script_ref_type { - op["script_ref_type"] = json!(t); - } - self.operations.push(op); - self - } - - pub fn attach_metadata(&mut self, label: u64, metadata: Value) -> &mut Self { - self.operations.push(json!({ - "type": "attach_metadata", - "label": label, - "metadata": metadata, - })); - self - } - - pub fn collect_from(&mut self, utxos: &[Value]) -> &mut Self { - self.operations.push(json!({ - "type": "collect_from", - "collect_utxos": utxos, - })); - self - } - - pub fn collect_from_script( - &mut self, - utxos: &[Value], - redeemer_cbor_hex: &str, - datum_cbor_hex: Option<&str>, - ) -> &mut Self { - let mut op = json!({ - "type": "collect_from", - "collect_utxos": utxos, - "redeemer_cbor_hex": redeemer_cbor_hex, - }); - if let Some(d) = datum_cbor_hex { - op["datum_cbor_hex"] = json!(d); - } - self.operations.push(op); - self - } - - pub fn read_from(&mut self, reference_inputs: &[ReferenceInput]) -> &mut Self { - let refs: Vec = reference_inputs - .iter() - .map(|r| json!({"tx_hash": r.tx_hash, "output_index": r.output_index})) - .collect(); - self.operations.push(json!({ - "type": "read_from", - "reference_inputs": refs, - })); - self - } - - pub fn mint_plutus_assets( - &mut self, - script_cbor_hex: &str, - script_type: &str, - assets: &[MintAsset], - redeemer_cbor_hex: &str, - receiver: Option<&str>, - output_datum_cbor_hex: Option<&str>, - ) -> &mut Self { - let mut op = json!({ - "type": "mint_plutus_assets", - "script_cbor_hex": script_cbor_hex, - "script_type": script_type, - "assets": assets, - "redeemer_cbor_hex": redeemer_cbor_hex, - }); - if let Some(r) = receiver { - op["receiver"] = json!(r); - } - if let Some(d) = output_datum_cbor_hex { - op["output_datum_cbor_hex"] = json!(d); - } - self.operations.push(op); - self - } - - pub fn attach_spending_validator( - &mut self, - script_cbor_hex: &str, - script_type: &str, - ) -> &mut Self { - self.operations.push(json!({ - "type": "attach_spending_validator", - "script_cbor_hex": script_cbor_hex, - "script_type": script_type, - })); - self - } - - pub fn attach_certificate_validator( - &mut self, - script_cbor_hex: &str, - script_type: &str, - ) -> &mut Self { - self.operations.push(json!({ - "type": "attach_certificate_validator", - "script_cbor_hex": script_cbor_hex, - "script_type": script_type, - })); - self - } - - pub fn attach_reward_validator( - &mut self, - script_cbor_hex: &str, - script_type: &str, - ) -> &mut Self { - self.operations.push(json!({ - "type": "attach_reward_validator", - "script_cbor_hex": script_cbor_hex, - "script_type": script_type, - })); - self - } - - pub fn attach_proposing_validator( - &mut self, - script_cbor_hex: &str, - script_type: &str, - ) -> &mut Self { - self.operations.push(json!({ - "type": "attach_proposing_validator", - "script_cbor_hex": script_cbor_hex, - "script_type": script_type, - })); - self - } - - pub fn attach_voting_validator( - &mut self, - script_cbor_hex: &str, - script_type: &str, - ) -> &mut Self { - self.operations.push(json!({ - "type": "attach_voting_validator", - "script_cbor_hex": script_cbor_hex, - "script_type": script_type, - })); - self - } - - // Staking (redeemer-enhanced) - - pub fn deregister_stake_address( - &mut self, - address: &str, - redeemer_cbor_hex: &str, - refund_address: Option<&str>, - ) -> &mut Self { - let mut op = json!({ - "type": "deregister_stake_address", - "address": address, - "redeemer_cbor_hex": redeemer_cbor_hex, - }); - if let Some(r) = refund_address { - op["refund_address"] = json!(r); - } - self.operations.push(op); - self - } - - pub fn delegate_to( - &mut self, - address: &str, - pool_id: &str, - redeemer_cbor_hex: &str, - ) -> &mut Self { - self.operations.push(json!({ - "type": "delegate_to", - "address": address, - "pool_id": pool_id, - "redeemer_cbor_hex": redeemer_cbor_hex, - })); - self - } - - pub fn withdraw( - &mut self, - reward_address: &str, - amount: u64, - redeemer_cbor_hex: &str, - receiver: Option<&str>, - ) -> &mut Self { - let mut op = json!({ - "type": "withdraw", - "reward_address": reward_address, - "amount": amount.to_string(), - "redeemer_cbor_hex": redeemer_cbor_hex, - }); - if let Some(r) = receiver { - op["receiver"] = json!(r); - } - self.operations.push(op); - self - } - - // DRep (redeemer-enhanced) - - pub fn register_drep( - &mut self, - cred_hash: &str, - cred_type: &str, - redeemer_cbor_hex: &str, - anchor_url: Option<&str>, - anchor_data_hash: Option<&str>, - ) -> &mut Self { - let mut op = json!({ - "type": "register_drep", - "credential_hash": cred_hash, - "credential_type": cred_type, - "redeemer_cbor_hex": redeemer_cbor_hex, - }); - if let Some(u) = anchor_url { - op["anchor_url"] = json!(u); - } - if let Some(h) = anchor_data_hash { - op["anchor_data_hash"] = json!(h); - } - self.operations.push(op); - self - } - - pub fn unregister_drep( - &mut self, - cred_hash: &str, - cred_type: &str, - redeemer_cbor_hex: &str, - refund_address: Option<&str>, - refund_amount: Option<&str>, - ) -> &mut Self { - let mut op = json!({ - "type": "unregister_drep", - "credential_hash": cred_hash, - "credential_type": cred_type, - "redeemer_cbor_hex": redeemer_cbor_hex, - }); - if let Some(r) = refund_address { - op["refund_address"] = json!(r); - } - if let Some(a) = refund_amount { - op["refund_amount"] = json!(a); - } - self.operations.push(op); - self - } - - pub fn update_drep( - &mut self, - cred_hash: &str, - cred_type: &str, - redeemer_cbor_hex: &str, - anchor_url: Option<&str>, - anchor_data_hash: Option<&str>, - ) -> &mut Self { - let mut op = json!({ - "type": "update_drep", - "credential_hash": cred_hash, - "credential_type": cred_type, - "redeemer_cbor_hex": redeemer_cbor_hex, - }); - if let Some(u) = anchor_url { - op["anchor_url"] = json!(u); - } - if let Some(h) = anchor_data_hash { - op["anchor_data_hash"] = json!(h); - } - self.operations.push(op); - self - } - - // Voting (redeemer-enhanced) - - pub fn delegate_voting_power_to( - &mut self, - address: &str, - drep_type: &str, - drep_hash: Option<&str>, - redeemer_cbor_hex: &str, - ) -> &mut Self { - let mut op = json!({ - "type": "delegate_voting_power_to", - "address": address, - "drep_type": drep_type, - "redeemer_cbor_hex": redeemer_cbor_hex, - }); - if let Some(h) = drep_hash { - op["drep_hash"] = json!(h); - } - self.operations.push(op); - self - } - - pub fn create_vote( - &mut self, - voter_type: &str, - voter_hash: &str, - gov_action_tx_hash: &str, - gov_action_index: u32, - vote: &str, - redeemer_cbor_hex: &str, - anchor_url: Option<&str>, - anchor_data_hash: Option<&str>, - ) -> &mut Self { - let mut op = json!({ - "type": "create_vote", - "voter_type": voter_type, - "voter_hash": voter_hash, - "gov_action_tx_hash": gov_action_tx_hash, - "gov_action_index": gov_action_index, - "vote": vote, - "redeemer_cbor_hex": redeemer_cbor_hex, - }); - if let Some(u) = anchor_url { - op["anchor_url"] = json!(u); - } - if let Some(h) = anchor_data_hash { - op["anchor_data_hash"] = json!(h); - } - self.operations.push(op); - self - } - - // Governance (redeemer-enhanced) - - pub fn create_proposal( - &mut self, - gov_action_type: &str, - return_address: &str, - anchor_url: &str, - anchor_data_hash: &str, - redeemer_cbor_hex: &str, - withdrawals: Option<&[HashMap]>, - ) -> &mut Self { - let mut op = json!({ - "type": "create_proposal", - "gov_action_type": gov_action_type, - "return_address": return_address, - "anchor_url": anchor_url, - "anchor_data_hash": anchor_data_hash, - "redeemer_cbor_hex": redeemer_cbor_hex, - }); - if let Some(w) = withdrawals { - op["withdrawals"] = serde_json::to_value(w).unwrap_or_default(); - } - self.operations.push(op); - self - } - - // Treasury - - pub fn donate_to_treasury( - &mut self, - treasury_value: &str, - donation_amount: &str, - redeemer_cbor_hex: &str, - ) -> &mut Self { - self.operations.push(json!({ - "type": "donate_to_treasury", - "treasury_value": treasury_value, - "donation_amount": donation_amount, - "redeemer_cbor_hex": redeemer_cbor_hex, - })); - self - } - - // Config - - pub fn from(&mut self, address: &str) -> &mut Self { - self.from = Some(address.to_string()); - self - } - - pub fn change_address(&mut self, address: &str) -> &mut Self { - self.change_address = Some(address.to_string()); - self - } - - pub fn change_datum(&mut self, datum_cbor_hex: &str) -> &mut Self { - self.change_datum_cbor_hex = Some(datum_cbor_hex.to_string()); - self - } - - pub fn change_datum_hash(&mut self, hash: &str) -> &mut Self { - self.change_datum_hash = Some(hash.to_string()); - self - } - - pub fn fee_payer(&mut self, address: &str) -> &mut Self { - self.fee_payer = Some(address.to_string()); - self - } - - pub fn with_utxos(&mut self, utxos: Value) -> &mut Self { - self.utxos = Some(utxos); - self - } - - pub fn with_protocol_params(&mut self, params: Value) -> &mut Self { - self.protocol_params = Some(params); - self - } - - pub fn valid_from(&mut self, slot: u64) -> &mut Self { - self.validity - .insert("valid_from".to_string(), json!(slot)); - self - } - - pub fn valid_to(&mut self, slot: u64) -> &mut Self { - self.validity.insert("valid_to".to_string(), json!(slot)); - self - } - - pub fn merge_outputs(&mut self, merge: bool) -> &mut Self { - self.merge_outputs = Some(merge); - self - } - - pub fn signer_count(&mut self, count: i32) -> &mut Self { - self.signer_count = count; - self - } - - fn build_spec(&self, provider_config: Option<&ProviderConfig>) -> Value { - let mut spec = json!({ - "tx_type": "script_tx", - "operations": self.operations, - "from": self.from, - "signer_count": self.signer_count, - }); - - if let Some(pc) = provider_config { - spec["provider"] = serde_json::to_value(pc).unwrap_or_default(); - } else if let Some(ref u) = self.utxos { - spec["utxos"] = u.clone(); - } - if let Some(ref pp) = self.protocol_params { - spec["protocol_params"] = pp.clone(); - } - if let Some(ref ca) = self.change_address { - spec["change_address"] = json!(ca); - } - if let Some(ref fp) = self.fee_payer { - spec["fee_payer"] = json!(fp); - } - if !self.validity.is_empty() { - spec["validity"] = Value::Object(self.validity.clone()); - } - if let Some(m) = self.merge_outputs { - spec["merge_outputs"] = json!(m); - } - if let Some(ref d) = self.change_datum_cbor_hex { - spec["change_datum_cbor_hex"] = json!(d); - } - if let Some(ref h) = self.change_datum_hash { - spec["change_datum_hash"] = json!(h); - } - spec - } - - /// Build the script transaction. - pub fn build(&self) -> Result { - self.do_build(None) - } - - /// Build with a Java-side provider config for lazy UTXO fetching. - pub fn build_with_provider(&self, config: &ProviderConfig) -> Result { - self.do_build(Some(config)) - } - - fn do_build(&self, provider_config: Option<&ProviderConfig>) -> Result { - let spec = self.build_spec(provider_config); - let spec_json = serde_json::to_string(&spec).map_err(|e| CclError { - code: error_codes::CCL_ERROR_SERIALIZATION, - message: format!("Failed to serialize spec: {}", e), - })?; + let yaml_cs = to_cstring(yaml)?; + let utxos_cs = to_cstring(&utxos_json)?; + let pp_cs = to_cstring(&pp_json)?; + + // Optional execution units; null pointer when absent. The CString must outlive the call. + let exec_cs = match exec_units { + Some(eu) => { + let s = serde_json::to_string(eu).map_err(|e| CclError { + code: error_codes::CCL_ERROR_SERIALIZATION, + message: format!("Failed to serialize exec units: {}", e), + })?; + Some(to_cstring(&s)?) + } + None => None, + }; + let exec_ptr = exec_cs.as_ref().map_or(ptr::null(), |c| c.as_ptr()); - let cs = to_cstring(&spec_json)?; - let rc = unsafe { ffi::ccl_quicktx_build(self.bridge.thread, cs.as_ptr()) }; + let rc = unsafe { + ffi::ccl_quicktx_build( + self.bridge.thread, + yaml_cs.as_ptr(), + utxos_cs.as_ptr(), + pp_cs.as_ptr(), + exec_ptr, + ) + }; + // The build result is a YAML document. let result = self.bridge.check(rc)?; - - serde_json::from_str(&result).map_err(|e| CclError { + serde_yaml::from_str(&result).map_err(|e| CclError { code: error_codes::CCL_ERROR_SERIALIZATION, message: format!("Failed to parse tx result: {}", e), }) } } - -// --- ScriptTx --- - -/// Lightweight operation collector for one script transaction in a compose group. -pub struct ScriptTx { - operations: Vec, - from: Option, - change_address: Option, - change_datum_cbor_hex: Option, - change_datum_hash: Option, -} - -impl ScriptTx { - pub fn pay_to_address( - &mut self, - address: &str, - amounts: &[Value], - script_ref_cbor_hex: Option<&str>, - script_ref_type: Option<&str>, - ) -> &mut Self { - let mut op = json!({ - "type": "pay_to_address", - "address": address, - "amounts": amounts, - }); - if let Some(s) = script_ref_cbor_hex { - op["script_ref_cbor_hex"] = json!(s); - } - if let Some(t) = script_ref_type { - op["script_ref_type"] = json!(t); - } - self.operations.push(op); - self - } - - pub fn pay_to_contract( - &mut self, - address: &str, - amounts: &[Value], - datum_cbor_hex: Option<&str>, - datum_hash: Option<&str>, - script_ref_cbor_hex: Option<&str>, - script_ref_type: Option<&str>, - ) -> &mut Self { - let mut op = json!({ - "type": "pay_to_contract", - "address": address, - "amounts": amounts, - }); - if let Some(d) = datum_cbor_hex { - op["datum_cbor_hex"] = json!(d); - } - if let Some(h) = datum_hash { - op["datum_hash"] = json!(h); - } - if let Some(s) = script_ref_cbor_hex { - op["script_ref_cbor_hex"] = json!(s); - } - if let Some(t) = script_ref_type { - op["script_ref_type"] = json!(t); - } - self.operations.push(op); - self - } - - pub fn attach_metadata(&mut self, label: u64, metadata: Value) -> &mut Self { - self.operations.push(json!({ - "type": "attach_metadata", - "label": label, - "metadata": metadata, - })); - self - } - - pub fn collect_from(&mut self, utxos: &[Value]) -> &mut Self { - self.operations.push(json!({ - "type": "collect_from", - "collect_utxos": utxos, - })); - self - } - - pub fn collect_from_script( - &mut self, - utxos: &[Value], - redeemer_cbor_hex: &str, - datum_cbor_hex: Option<&str>, - ) -> &mut Self { - let mut op = json!({ - "type": "collect_from", - "collect_utxos": utxos, - "redeemer_cbor_hex": redeemer_cbor_hex, - }); - if let Some(d) = datum_cbor_hex { - op["datum_cbor_hex"] = json!(d); - } - self.operations.push(op); - self - } - - pub fn read_from(&mut self, reference_inputs: &[ReferenceInput]) -> &mut Self { - let refs: Vec = reference_inputs - .iter() - .map(|r| json!({"tx_hash": r.tx_hash, "output_index": r.output_index})) - .collect(); - self.operations.push(json!({ - "type": "read_from", - "reference_inputs": refs, - })); - self - } - - pub fn mint_plutus_assets( - &mut self, - script_cbor_hex: &str, - script_type: &str, - assets: &[MintAsset], - redeemer_cbor_hex: &str, - receiver: Option<&str>, - output_datum_cbor_hex: Option<&str>, - ) -> &mut Self { - let mut op = json!({ - "type": "mint_plutus_assets", - "script_cbor_hex": script_cbor_hex, - "script_type": script_type, - "assets": assets, - "redeemer_cbor_hex": redeemer_cbor_hex, - }); - if let Some(r) = receiver { - op["receiver"] = json!(r); - } - if let Some(d) = output_datum_cbor_hex { - op["output_datum_cbor_hex"] = json!(d); - } - self.operations.push(op); - self - } - - pub fn attach_spending_validator( - &mut self, - script_cbor_hex: &str, - script_type: &str, - ) -> &mut Self { - self.operations.push(json!({ - "type": "attach_spending_validator", - "script_cbor_hex": script_cbor_hex, - "script_type": script_type, - })); - self - } - - pub fn attach_certificate_validator( - &mut self, - script_cbor_hex: &str, - script_type: &str, - ) -> &mut Self { - self.operations.push(json!({ - "type": "attach_certificate_validator", - "script_cbor_hex": script_cbor_hex, - "script_type": script_type, - })); - self - } - - pub fn attach_reward_validator( - &mut self, - script_cbor_hex: &str, - script_type: &str, - ) -> &mut Self { - self.operations.push(json!({ - "type": "attach_reward_validator", - "script_cbor_hex": script_cbor_hex, - "script_type": script_type, - })); - self - } - - pub fn attach_proposing_validator( - &mut self, - script_cbor_hex: &str, - script_type: &str, - ) -> &mut Self { - self.operations.push(json!({ - "type": "attach_proposing_validator", - "script_cbor_hex": script_cbor_hex, - "script_type": script_type, - })); - self - } - - pub fn attach_voting_validator( - &mut self, - script_cbor_hex: &str, - script_type: &str, - ) -> &mut Self { - self.operations.push(json!({ - "type": "attach_voting_validator", - "script_cbor_hex": script_cbor_hex, - "script_type": script_type, - })); - self - } - - // Staking (redeemer-enhanced) - - pub fn deregister_stake_address( - &mut self, - address: &str, - redeemer_cbor_hex: &str, - refund_address: Option<&str>, - ) -> &mut Self { - let mut op = json!({ - "type": "deregister_stake_address", - "address": address, - "redeemer_cbor_hex": redeemer_cbor_hex, - }); - if let Some(r) = refund_address { - op["refund_address"] = json!(r); - } - self.operations.push(op); - self - } - - pub fn delegate_to( - &mut self, - address: &str, - pool_id: &str, - redeemer_cbor_hex: &str, - ) -> &mut Self { - self.operations.push(json!({ - "type": "delegate_to", - "address": address, - "pool_id": pool_id, - "redeemer_cbor_hex": redeemer_cbor_hex, - })); - self - } - - pub fn withdraw( - &mut self, - reward_address: &str, - amount: u64, - redeemer_cbor_hex: &str, - receiver: Option<&str>, - ) -> &mut Self { - let mut op = json!({ - "type": "withdraw", - "reward_address": reward_address, - "amount": amount.to_string(), - "redeemer_cbor_hex": redeemer_cbor_hex, - }); - if let Some(r) = receiver { - op["receiver"] = json!(r); - } - self.operations.push(op); - self - } - - // DRep (redeemer-enhanced) - - pub fn register_drep( - &mut self, - cred_hash: &str, - cred_type: &str, - redeemer_cbor_hex: &str, - anchor_url: Option<&str>, - anchor_data_hash: Option<&str>, - ) -> &mut Self { - let mut op = json!({ - "type": "register_drep", - "credential_hash": cred_hash, - "credential_type": cred_type, - "redeemer_cbor_hex": redeemer_cbor_hex, - }); - if let Some(u) = anchor_url { - op["anchor_url"] = json!(u); - } - if let Some(h) = anchor_data_hash { - op["anchor_data_hash"] = json!(h); - } - self.operations.push(op); - self - } - - pub fn unregister_drep( - &mut self, - cred_hash: &str, - cred_type: &str, - redeemer_cbor_hex: &str, - refund_address: Option<&str>, - refund_amount: Option<&str>, - ) -> &mut Self { - let mut op = json!({ - "type": "unregister_drep", - "credential_hash": cred_hash, - "credential_type": cred_type, - "redeemer_cbor_hex": redeemer_cbor_hex, - }); - if let Some(r) = refund_address { - op["refund_address"] = json!(r); - } - if let Some(a) = refund_amount { - op["refund_amount"] = json!(a); - } - self.operations.push(op); - self - } - - pub fn update_drep( - &mut self, - cred_hash: &str, - cred_type: &str, - redeemer_cbor_hex: &str, - anchor_url: Option<&str>, - anchor_data_hash: Option<&str>, - ) -> &mut Self { - let mut op = json!({ - "type": "update_drep", - "credential_hash": cred_hash, - "credential_type": cred_type, - "redeemer_cbor_hex": redeemer_cbor_hex, - }); - if let Some(u) = anchor_url { - op["anchor_url"] = json!(u); - } - if let Some(h) = anchor_data_hash { - op["anchor_data_hash"] = json!(h); - } - self.operations.push(op); - self - } - - // Voting (redeemer-enhanced) - - pub fn delegate_voting_power_to( - &mut self, - address: &str, - drep_type: &str, - drep_hash: Option<&str>, - redeemer_cbor_hex: &str, - ) -> &mut Self { - let mut op = json!({ - "type": "delegate_voting_power_to", - "address": address, - "drep_type": drep_type, - "redeemer_cbor_hex": redeemer_cbor_hex, - }); - if let Some(h) = drep_hash { - op["drep_hash"] = json!(h); - } - self.operations.push(op); - self - } - - pub fn create_vote( - &mut self, - voter_type: &str, - voter_hash: &str, - gov_action_tx_hash: &str, - gov_action_index: u32, - vote: &str, - redeemer_cbor_hex: &str, - anchor_url: Option<&str>, - anchor_data_hash: Option<&str>, - ) -> &mut Self { - let mut op = json!({ - "type": "create_vote", - "voter_type": voter_type, - "voter_hash": voter_hash, - "gov_action_tx_hash": gov_action_tx_hash, - "gov_action_index": gov_action_index, - "vote": vote, - "redeemer_cbor_hex": redeemer_cbor_hex, - }); - if let Some(u) = anchor_url { - op["anchor_url"] = json!(u); - } - if let Some(h) = anchor_data_hash { - op["anchor_data_hash"] = json!(h); - } - self.operations.push(op); - self - } - - // Governance (redeemer-enhanced) - - pub fn create_proposal( - &mut self, - gov_action_type: &str, - return_address: &str, - anchor_url: &str, - anchor_data_hash: &str, - redeemer_cbor_hex: &str, - withdrawals: Option<&[HashMap]>, - ) -> &mut Self { - let mut op = json!({ - "type": "create_proposal", - "gov_action_type": gov_action_type, - "return_address": return_address, - "anchor_url": anchor_url, - "anchor_data_hash": anchor_data_hash, - "redeemer_cbor_hex": redeemer_cbor_hex, - }); - if let Some(w) = withdrawals { - op["withdrawals"] = serde_json::to_value(w).unwrap_or_default(); - } - self.operations.push(op); - self - } - - // Treasury - - pub fn donate_to_treasury( - &mut self, - treasury_value: &str, - donation_amount: &str, - redeemer_cbor_hex: &str, - ) -> &mut Self { - self.operations.push(json!({ - "type": "donate_to_treasury", - "treasury_value": treasury_value, - "donation_amount": donation_amount, - "redeemer_cbor_hex": redeemer_cbor_hex, - })); - self - } - - // Config - - pub fn from(&mut self, address: &str) -> &mut Self { - self.from = Some(address.to_string()); - self - } - - pub fn change_address(&mut self, address: &str) -> &mut Self { - self.change_address = Some(address.to_string()); - self - } - - pub fn change_datum(&mut self, datum_cbor_hex: &str) -> &mut Self { - self.change_datum_cbor_hex = Some(datum_cbor_hex.to_string()); - self - } - - pub fn change_datum_hash(&mut self, hash: &str) -> &mut Self { - self.change_datum_hash = Some(hash.to_string()); - self - } - - fn to_spec(&self) -> Value { - let mut spec = json!({ - "tx_type": "script_tx", - "from": self.from, - "operations": self.operations, - }); - if let Some(ref ca) = self.change_address { - spec["change_address"] = json!(ca); - } - if let Some(ref d) = self.change_datum_cbor_hex { - spec["change_datum_cbor_hex"] = json!(d); - } - if let Some(ref h) = self.change_datum_hash { - spec["change_datum_hash"] = json!(h); - } - spec - } -} diff --git a/wrappers/rust/tests/integration_test.rs b/wrappers/rust/tests/integration_test.rs index b654b8b..0edc8ba 100644 --- a/wrappers/rust/tests/integration_test.rs +++ b/wrappers/rust/tests/integration_test.rs @@ -1,4 +1,4 @@ -use ccl::{Amount, Bridge, ComposableTx, MintAsset, ProviderConfig, ProposalWithdrawal, TxResult}; +use ccl::{Bridge, TxResult}; use serde_json::{json, Value}; // A known valid transaction CBOR hex (built from Java tests) @@ -523,632 +523,72 @@ fn assert_tx_result(result: &TxResult) { assert!(fee > 0, "fee should be positive"); } -#[test] -fn test_quicktx_simple_ada_payment() { - let bridge = Bridge::new().expect("Failed to create bridge"); - let sender = get_testnet_addr(&bridge); - let receiver = get_testnet_addr(&bridge); - - let result = bridge - .quicktx() - .new_tx() - .pay_to_address(&receiver, &[Amount::ada(5.0)], None, None) - .from(&sender) - .with_utxos(make_utxos(&sender, 100_000_000)) - .with_protocol_params(test_protocol_params()) - .build() - .expect("Build failed"); - assert_tx_result(&result); +fn payment_yaml(from: &str, to: &str, quantity: &str) -> String { + format!( + "version: 1.0\n\ + transaction:\n\ + \x20 - tx:\n\ + \x20 from: {from}\n\ + \x20 intents:\n\ + \x20 - type: payment\n\ + \x20 address: {to}\n\ + \x20 amounts:\n\ + \x20 - unit: lovelace\n\ + \x20 quantity: \"{quantity}\"\n" + ) } #[test] -fn test_quicktx_multiple_receivers() { - let bridge = Bridge::new().expect("Failed to create bridge"); - let sender = get_testnet_addr(&bridge); - let receiver1 = get_testnet_addr(&bridge); - let receiver2 = get_testnet_addr(&bridge); - - let result = bridge - .quicktx() - .new_tx() - .pay_to_address(&receiver1, &[Amount::ada(5.0)], None, None) - .pay_to_address(&receiver2, &[Amount::ada(3.0)], None, None) - .from(&sender) - .with_utxos(make_utxos(&sender, 100_000_000)) - .with_protocol_params(test_protocol_params()) - .build() - .expect("Build failed"); - assert_tx_result(&result); -} - -#[test] -fn test_quicktx_pay_to_contract() { +fn test_quicktx_simple_payment() { let bridge = Bridge::new().expect("Failed to create bridge"); let sender = get_testnet_addr(&bridge); let receiver = get_testnet_addr(&bridge); + let yaml = payment_yaml(&sender, &receiver, "5000000"); let result = bridge .quicktx() - .new_tx() - .pay_to_contract(&receiver, &[Amount::ada(5.0)], Some("182a"), None, None, None) - .from(&sender) - .with_utxos(make_utxos(&sender, 100_000_000)) - .with_protocol_params(test_protocol_params()) - .build() + .build(&yaml, &make_utxos(&sender, 100_000_000), &test_protocol_params(), None) .expect("Build failed"); assert_tx_result(&result); } #[test] -fn test_quicktx_mint_assets() { - let bridge = Bridge::new().expect("Failed to create bridge"); - let sender = get_testnet_addr(&bridge); - - let info_str = bridge - .address() - .info(&sender) - .expect("Failed to get address info"); - let info: Value = serde_json::from_str(&info_str).expect("Invalid JSON"); - let key_hash = info["payment_credential_hash"].as_str().unwrap(); - - let script_json = format!(r#"{{"type":"sig","keyHash":"{}"}}"#, key_hash); - let assets = vec![MintAsset { - name: "TestToken".to_string(), - quantity: "1000".to_string(), - }]; - - let result = bridge - .quicktx() - .new_tx() - .mint_assets(&script_json, &assets, &sender) - .from(&sender) - .with_utxos(make_utxos(&sender, 100_000_000)) - .with_protocol_params(test_protocol_params()) - .build() - .expect("Build failed"); - assert_tx_result(&result); -} - -#[test] -fn test_quicktx_attach_metadata() { - let bridge = Bridge::new().expect("Failed to create bridge"); - let sender = get_testnet_addr(&bridge); - let receiver = get_testnet_addr(&bridge); - - let result = bridge - .quicktx() - .new_tx() - .pay_to_address(&receiver, &[Amount::ada(2.0)], None, None) - .attach_metadata(674, json!({"msg": ["Hello from Rust"]})) - .from(&sender) - .with_utxos(make_utxos(&sender, 100_000_000)) - .with_protocol_params(test_protocol_params()) - .build() - .expect("Build failed"); - assert_tx_result(&result); -} - -#[test] -fn test_quicktx_collect_from() { +fn test_quicktx_variable_substitution() { let bridge = Bridge::new().expect("Failed to create bridge"); let sender = get_testnet_addr(&bridge); let receiver = get_testnet_addr(&bridge); - let utxos = make_utxos(&sender, 100_000_000); - let utxo_array = utxos.as_array().unwrap().clone(); - - let result = bridge - .quicktx() - .new_tx() - .collect_from(&utxo_array) - .pay_to_address(&receiver, &[Amount::ada(2.0)], None, None) - .from(&sender) - .with_utxos(utxos) - .with_protocol_params(test_protocol_params()) - .build() - .expect("Build failed"); - assert_tx_result(&result); -} - -#[test] -fn test_quicktx_register_stake_address() { - let bridge = Bridge::new().expect("Failed to create bridge"); - let sender = get_testnet_addr(&bridge); - - let result = bridge - .quicktx() - .new_tx() - .register_stake_address(&sender) - .from(&sender) - .with_utxos(make_utxos(&sender, 100_000_000)) - .with_protocol_params(test_protocol_params()) - .build() - .expect("Build failed"); - assert_tx_result(&result); -} - -#[test] -fn test_quicktx_deregister_stake_address() { - let bridge = Bridge::new().expect("Failed to create bridge"); - let sender = get_testnet_addr(&bridge); - - let result = bridge - .quicktx() - .new_tx() - .deregister_stake_address(&sender, None) - .from(&sender) - .with_utxos(make_utxos(&sender, 100_000_000)) - .with_protocol_params(test_protocol_params()) - .build() - .expect("Build failed"); - assert_tx_result(&result); -} - -#[test] -fn test_quicktx_delegate_to() { - let bridge = Bridge::new().expect("Failed to create bridge"); - let sender = get_testnet_addr(&bridge); - let pool_id = "pool1pu5jlj4q9w9jlxeu370a3c9myx47md5j5m2str0naunn2q3lkdy"; - - let result = bridge - .quicktx() - .new_tx() - .delegate_to(&sender, pool_id) - .from(&sender) - .with_utxos(make_utxos(&sender, 100_000_000)) - .with_protocol_params(test_protocol_params()) - .build() - .expect("Build failed"); - assert_tx_result(&result); -} - -#[test] -fn test_quicktx_withdraw() { - let bridge = Bridge::new().expect("Failed to create bridge"); - let (sender, mnemonic) = get_testnet_address(&bridge); - - let restored = bridge - .account() - .from_mnemonic(&mnemonic, ccl::network::TESTNET, 0, 0) - .expect("Failed to restore"); - let restored_json: Value = serde_json::from_str(&restored).expect("Invalid JSON"); - let stake_addr = restored_json["stake_address"].as_str().unwrap(); - - let result = bridge - .quicktx() - .new_tx() - .withdraw(stake_addr, 5000000, None) - .from(&sender) - .with_utxos(make_utxos(&sender, 100_000_000)) - .with_protocol_params(test_protocol_params()) - .build() - .expect("Build failed"); - assert_tx_result(&result); -} - -#[test] -fn test_quicktx_register_drep() { - let bridge = Bridge::new().expect("Failed to create bridge"); - let sender = get_testnet_addr(&bridge); - let cred_hash = "ab".repeat(28); - - let result = bridge - .quicktx() - .new_tx() - .register_drep(&cred_hash, "key", None, None) - .from(&sender) - .with_utxos(make_utxos(&sender, 100_000_000)) - .with_protocol_params(test_protocol_params()) - .build() - .expect("Build failed"); - assert_tx_result(&result); -} - -#[test] -fn test_quicktx_unregister_drep() { - let bridge = Bridge::new().expect("Failed to create bridge"); - let sender = get_testnet_addr(&bridge); - let cred_hash = "ab".repeat(28); - - let result = bridge - .quicktx() - .new_tx() - .unregister_drep(&cred_hash, "key", None, None) - .from(&sender) - .with_utxos(make_utxos(&sender, 100_000_000)) - .with_protocol_params(test_protocol_params()) - .build() - .expect("Build failed"); - assert_tx_result(&result); -} - -#[test] -fn test_quicktx_update_drep() { - let bridge = Bridge::new().expect("Failed to create bridge"); - let sender = get_testnet_addr(&bridge); - let cred_hash = "ab".repeat(28); - let data_hash = "cd".repeat(32); - - let result = bridge - .quicktx() - .new_tx() - .update_drep( - &cred_hash, - "key", - Some("https://example.com/drep-v2.json"), - Some(&data_hash), - ) - .from(&sender) - .with_utxos(make_utxos(&sender, 100_000_000)) - .with_protocol_params(test_protocol_params()) - .build() - .expect("Build failed"); - assert_tx_result(&result); -} - -#[test] -fn test_quicktx_delegate_voting_power_to() { - let bridge = Bridge::new().expect("Failed to create bridge"); - let sender = get_testnet_addr(&bridge); - let drep_hash = "ab".repeat(28); - - let result = bridge - .quicktx() - .new_tx() - .delegate_voting_power_to(&sender, "key_hash", Some(&drep_hash)) - .from(&sender) - .with_utxos(make_utxos(&sender, 100_000_000)) - .with_protocol_params(test_protocol_params()) - .build() - .expect("Build failed"); - assert_tx_result(&result); -} - -#[test] -fn test_quicktx_create_vote() { - let bridge = Bridge::new().expect("Failed to create bridge"); - let sender = get_testnet_addr(&bridge); - let voter_hash = "ab".repeat(28); - let gov_tx_hash = "cd".repeat(32); - - let result = bridge - .quicktx() - .new_tx() - .create_vote( - "drep_key_hash", - &voter_hash, - &gov_tx_hash, - 0, - "yes", - None, - None, - ) - .from(&sender) - .with_utxos(make_utxos(&sender, 100_000_000)) - .with_protocol_params(test_protocol_params()) - .build() - .expect("Build failed"); - assert_tx_result(&result); -} - -#[test] -fn test_quicktx_create_info_proposal() { - let bridge = Bridge::new().expect("Failed to create bridge"); - let (sender, mnemonic) = get_testnet_address(&bridge); - - let restored = bridge - .account() - .from_mnemonic(&mnemonic, ccl::network::TESTNET, 0, 0) - .expect("Failed to restore"); - let restored_json: Value = serde_json::from_str(&restored).expect("Invalid JSON"); - let stake_addr = restored_json["stake_address"].as_str().unwrap(); - let anchor_data_hash = "ab".repeat(32); - - let result = bridge - .quicktx() - .new_tx() - .create_proposal("info_action", stake_addr, "https://example.com/proposal.json", &anchor_data_hash, None) - .from(&sender) - .with_utxos(make_utxos(&sender, 2_000_000_000)) - .with_protocol_params(test_protocol_params()) - .build() - .expect("Build failed"); - assert_tx_result(&result); -} - -#[test] -fn test_quicktx_create_treasury_withdrawals_proposal() { - let bridge = Bridge::new().expect("Failed to create bridge"); - let (sender, mnemonic) = get_testnet_address(&bridge); - - let restored = bridge - .account() - .from_mnemonic(&mnemonic, ccl::network::TESTNET, 0, 0) - .expect("Failed to restore"); - let restored_json: Value = serde_json::from_str(&restored).expect("Invalid JSON"); - let stake_addr = restored_json["stake_address"].as_str().unwrap(); - let anchor_data_hash = "ab".repeat(32); - - let withdrawals = vec![ProposalWithdrawal { - reward_address: stake_addr.to_string(), - amount: "1000000".to_string(), - }]; - - let result = bridge - .quicktx() - .new_tx() - .create_proposal( - "treasury_withdrawals", - stake_addr, - "https://example.com/proposal.json", - &anchor_data_hash, - Some(&withdrawals), - ) - .from(&sender) - .with_utxos(make_utxos(&sender, 2_000_000_000)) - .with_protocol_params(test_protocol_params()) - .build() - .expect("Build failed"); - assert_tx_result(&result); -} - -#[test] -fn test_quicktx_compose() { - let bridge = Bridge::new().expect("Failed to create bridge"); - let sender1 = get_testnet_addr(&bridge); - let sender2 = get_testnet_addr(&bridge); - let receiver1 = get_testnet_addr(&bridge); - let receiver2 = get_testnet_addr(&bridge); - - let mut tx1 = bridge.quicktx().tx(); - tx1.pay_to_address(&receiver1, &[Amount::ada(5.0)], None, None) - .from(&sender1); - - let mut tx2 = bridge.quicktx().tx(); - tx2.pay_to_address(&receiver2, &[Amount::ada(3.0)], None, None) - .from(&sender2); - - let utxos = json!([ - { - "tx_hash": FAKE_TX_HASH, - "output_index": 0, - "address": sender1, - "amount": [{"unit": "lovelace", "quantity": "100000000"}], - }, - { - "tx_hash": "b".repeat(64), - "output_index": 0, - "address": sender2, - "amount": [{"unit": "lovelace", "quantity": "100000000"}], - }, - ]); - + let yaml = format!( + "version: 1.0\n\ + variables:\n\ + \x20 to: {receiver}\n\ + \x20 amount: \"4000000\"\n\ + transaction:\n\ + \x20 - tx:\n\ + \x20 from: {sender}\n\ + \x20 intents:\n\ + \x20 - type: payment\n\ + \x20 address: ${{to}}\n\ + \x20 amounts:\n\ + \x20 - unit: lovelace\n\ + \x20 quantity: ${{amount}}\n" + ); let result = bridge .quicktx() - .compose(vec![ComposableTx::Regular(tx1), ComposableTx::Regular(tx2)]) - .fee_payer(&sender1) - .with_utxos(utxos) - .with_protocol_params(test_protocol_params()) - .signer_count(2) - .build() + .build(&yaml, &make_utxos(&sender, 100_000_000), &test_protocol_params(), None) .expect("Build failed"); assert_tx_result(&result); } -#[test] -fn test_quicktx_provider_config() { - let bridge = Bridge::new().expect("Failed to create bridge"); - let sender = get_testnet_addr(&bridge); - let receiver = get_testnet_addr(&bridge); - - let config = ProviderConfig { - name: "yaci_devkit".to_string(), - url: "http://localhost:3000".to_string(), - api_key: None, - enable_cost_evaluation: None, - }; - - // Provider config will attempt Java-side HTTP; expect an error since no provider is running - let result = bridge - .quicktx() - .new_tx() - .pay_to_address(&receiver, &[Amount::ada(5.0)], None, None) - .from(&sender) - .with_protocol_params(test_protocol_params()) - .build_with_provider(&config); - - if result.is_ok() { - println!("BuildWithProvider succeeded (provider was reachable)"); - } -} - #[test] fn test_quicktx_insufficient_funds() { let bridge = Bridge::new().expect("Failed to create bridge"); let sender = get_testnet_addr(&bridge); let receiver = get_testnet_addr(&bridge); + let yaml = payment_yaml(&sender, &receiver, "200000000"); let result = bridge .quicktx() - .new_tx() - .pay_to_address(&receiver, &[Amount::ada(200.0)], None, None) - .from(&sender) - .with_utxos(make_utxos(&sender, 1_000_000)) - .with_protocol_params(test_protocol_params()) - .build(); - - assert!(result.is_err(), "Expected error for insufficient funds"); - let err = result.unwrap_err(); - assert!(err.code < 0, "Expected negative error code, got {}", err.code); -} - -#[test] -fn test_quicktx_register_pool() { - let bridge = Bridge::new().expect("Failed to create bridge"); - let sender = get_testnet_addr(&bridge); - let operator_hash = "ab".repeat(28); // 28-byte (56 hex chars) pool operator key hash - let vrf_key_hash = "cd".repeat(32); // 32-byte (64 hex chars) VRF key hash - let reward_account_hex = format!("e0{}", "ab".repeat(28)); // testnet reward address (hex) - - let result = bridge - .quicktx() - .new_tx() - .register_pool( - &operator_hash, - &vrf_key_hash, - "500000000", - "340000000", - "1", - "5", - &reward_account_hex, - &[operator_hash.as_str()], - None, - None, - None, - ) - .from(&sender) - .with_utxos(make_utxos(&sender, 1_000_000_000)) - .with_protocol_params(test_protocol_params()) - .build() - .expect("Build failed"); - assert_tx_result(&result); -} - -#[test] -fn test_quicktx_update_pool() { - let bridge = Bridge::new().expect("Failed to create bridge"); - let sender = get_testnet_addr(&bridge); - let operator_hash = "ab".repeat(28); // 28-byte (56 hex chars) pool operator key hash - let vrf_key_hash = "cd".repeat(32); // 32-byte (64 hex chars) VRF key hash - let metadata_hash = "ef".repeat(32); // 32-byte (64 hex chars) metadata hash - let reward_account_hex = format!("e0{}", "ab".repeat(28)); // testnet reward address (hex) - - let result = bridge - .quicktx() - .new_tx() - .update_pool( - &operator_hash, - &vrf_key_hash, - "600000000", - "340000000", - "1", - "10", - &reward_account_hex, - &[operator_hash.as_str()], - None, - Some("https://example.com/pool.json"), - Some(&metadata_hash), - ) - .from(&sender) - .with_utxos(make_utxos(&sender, 1_000_000_000)) - .with_protocol_params(test_protocol_params()) - .build() - .expect("Build failed"); - assert_tx_result(&result); -} - -#[test] -fn test_quicktx_retire_pool() { - let bridge = Bridge::new().expect("Failed to create bridge"); - let sender = get_testnet_addr(&bridge); - let pool_id = "pool1pu5jlj4q9w9jlxeu370a3c9myx47md5j5m2str0naunn2q3lkdy"; - - let result = bridge - .quicktx() - .new_tx() - .retire_pool(pool_id, 100) - .from(&sender) - .with_utxos(make_utxos(&sender, 100_000_000)) - .with_protocol_params(test_protocol_params()) - .build() - .expect("Build failed"); - assert_tx_result(&result); -} - -#[test] -fn test_quicktx_donate_to_treasury() { - let bridge = Bridge::new().expect("Failed to create bridge"); - let sender = get_testnet_addr(&bridge); - - let result = bridge - .quicktx() - .new_tx() - .donate_to_treasury("5000000", "1000000") - .from(&sender) - .with_utxos(make_utxos(&sender, 100_000_000)) - .with_protocol_params(test_protocol_params()) - .build() - .expect("Build failed"); - assert_tx_result(&result); -} - -#[test] -fn test_quicktx_attach_native_script() { - let bridge = Bridge::new().expect("Failed to create bridge"); - let sender = get_testnet_addr(&bridge); - - let info_str = bridge - .address() - .info(&sender) - .expect("Failed to get address info"); - let info: Value = serde_json::from_str(&info_str).expect("Invalid JSON"); - let key_hash = info["payment_credential_hash"].as_str().unwrap(); - - let script_json = format!(r#"{{"type":"sig","keyHash":"{}"}}"#, key_hash); - - let result = bridge - .quicktx() - .new_tx() - .pay_to_address(&sender, &[Amount::ada(2.0)], None, None) - .attach_native_script(&script_json) - .from(&sender) - .with_utxos(make_utxos(&sender, 100_000_000)) - .with_protocol_params(test_protocol_params()) - .build() - .expect("Build failed"); - assert_tx_result(&result); -} - -#[test] -fn test_quicktx_pay_with_script_ref() { - let bridge = Bridge::new().expect("Failed to create bridge"); - let sender = get_testnet_addr(&bridge); - let receiver = get_testnet_addr(&bridge); - - // Always-succeeds PlutusV3 script CBOR - let script_ref_cbor = "46450101002499"; - - let result = bridge - .quicktx() - .new_tx() - .pay_to_address( - &receiver, - &[Amount::ada(5.0)], - Some(script_ref_cbor), - Some("plutus_v3"), - ) - .from(&sender) - .with_utxos(make_utxos(&sender, 100_000_000)) - .with_protocol_params(test_protocol_params()) - .build() - .expect("Build failed"); - assert_tx_result(&result); -} - -#[test] -fn test_quicktx_unregister_drep_with_refund_amount() { - let bridge = Bridge::new().expect("Failed to create bridge"); - let sender = get_testnet_addr(&bridge); - let cred_hash = "ab".repeat(28); - - let result = bridge - .quicktx() - .new_tx() - .unregister_drep(&cred_hash, "key", None, Some("2000000")) - .from(&sender) - .with_utxos(make_utxos(&sender, 100_000_000)) - .with_protocol_params(test_protocol_params()) - .build() - .expect("Build failed"); - assert_tx_result(&result); + .build(&yaml, &make_utxos(&sender, 1_000_000), &test_protocol_params(), None); + assert!(result.is_err(), "expected insufficient funds error"); } diff --git a/wrappers/rust/tests/intents_test.rs b/wrappers/rust/tests/intents_test.rs new file mode 100644 index 0000000..34bd2b8 --- /dev/null +++ b/wrappers/rust/tests/intents_test.rs @@ -0,0 +1,101 @@ +//! End-to-end coverage of every TxPlan intent through the native library. +//! +//! The fixtures in `test-fixtures/quicktx-intents/` are generated by the JVM QuickTxIntentsTest via +//! `TxPlan.from(tx).toYaml()`; here each is built through the native library via the Rust wrapper. +//! Mirrors the Go `intents_test.go` for cross-wrapper parity. +//! +//! Run from wrappers/rust: +//! CCL_LIB_PATH=../../core/build/native/nativeCompile \ +//! cargo test --test intents_test -- --test-threads=1 + +use ccl::Bridge; +use serde_json::{json, Value}; +use std::fs; +use std::path::{Path, PathBuf}; + +const SENDER: &str = "addr_test1qz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3jcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwq2ytjqp"; +const SENDER2: &str = "addr_test1qz7svwszky8gcmhrfza7a89z9u0dfzd3l7h23sqlc5yml7ejcu5d8ps7zex2k2xt3uqxgjqnnj83ws8lhrn648jjxtwqcqrvr0"; +const SCRIPT_ADDR: &str = "addr_test1wpunlryvl7aqsxe22erzlsseej87v5kk5vutvtrmzdy8dect48z0w"; +const SCRIPT_DATUM_HASH: &str = "9e1199a988ba72ffd6e9c269cadb3b53b5f360ff99f112d9b2ee30c4d74ad88b"; +const SCRIPT_TX_HASH: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + +fn fixtures_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../test-fixtures/quicktx-intents") +} + +// Protocol parameters incl. the Conway deposits the governance/staking/pool intents need. +fn protocol_params() -> Value { + json!({ + "min_fee_a": 44, "min_fee_b": 155381, "max_tx_size": 16384, "max_val_size": "5000", + "key_deposit": "2000000", "pool_deposit": "500000000", "drep_deposit": "2000000", + "gov_action_deposit": "1000000000", "coins_per_utxo_size": "4310", + "max_tx_ex_mem": "14000000", "max_tx_ex_steps": "10000000000", + "price_mem": 0.0577, "price_step": 0.0000721, "collateral_percent": 150, + "max_collateral_inputs": 3, "min_fee_ref_script_cost_per_byte": 15 + }) +} + +fn utxos() -> Value { + json!([ + {"tx_hash": "a".repeat(64), "output_index": 0, "address": SENDER, + "amount": [{"unit": "lovelace", "quantity": "2000000000"}]}, + {"tx_hash": "c".repeat(64), "output_index": 0, "address": SENDER, + "amount": [{"unit": "lovelace", "quantity": "5000000"}]}, + {"tx_hash": "a".repeat(64), "output_index": 1, "address": SENDER2, + "amount": [{"unit": "lovelace", "quantity": "2000000000"}]} + ]) +} + +#[test] +fn intents_build_e2e() { + let bridge = Bridge::new().expect("create bridge"); + let pp = protocol_params(); + let u = utxos(); + + let mut count = 0; + for entry in fs::read_dir(fixtures_dir()).expect("read fixtures dir") { + let path = entry.unwrap().path(); + if path.extension().and_then(|e| e.to_str()) != Some("yaml") { + continue; + } + let yaml = fs::read_to_string(&path).unwrap(); + let result = bridge + .quicktx() + .build(&yaml, &u, &pp, None) + .unwrap_or_else(|e| panic!("build {:?}: {:?}", path.file_name().unwrap(), e)); + assert_eq!(result.tx_hash.len(), 64, "{:?}", path.file_name().unwrap()); + assert!(!result.tx_cbor.is_empty()); + count += 1; + } + assert!(count >= 19, "expected >= 19 intent fixtures, found {}", count); +} + +#[test] +fn plutus_mint_e2e() { + let bridge = Bridge::new().expect("create bridge"); + let yaml = fs::read_to_string(fixtures_dir().join("plutus/script_minting.yaml")).unwrap(); + let u = json!([{"tx_hash": "a".repeat(64), "output_index": 0, "address": SENDER, + "amount": [{"unit": "lovelace", "quantity": "2000000000"}]}]); + let exec = json!([{"mem": 2000000, "steps": 500000000}]); + + let result = bridge.quicktx().build(&yaml, &u, &protocol_params(), Some(&exec)).expect("mint"); + assert_eq!(result.tx_hash.len(), 64); + assert!(bridge.quicktx().build(&yaml, &u, &protocol_params(), None).is_err()); +} + +#[test] +fn plutus_spend_e2e() { + let bridge = Bridge::new().expect("create bridge"); + let yaml = fs::read_to_string(fixtures_dir().join("plutus/script_collect_from.yaml")).unwrap(); + let u = json!([ + {"tx_hash": SCRIPT_TX_HASH, "output_index": 0, "address": SCRIPT_ADDR, + "amount": [{"unit": "lovelace", "quantity": "10000000"}], "data_hash": SCRIPT_DATUM_HASH}, + {"tx_hash": "a".repeat(64), "output_index": 0, "address": SENDER, + "amount": [{"unit": "lovelace", "quantity": "2000000000"}]} + ]); + let exec = json!([{"mem": 2000000, "steps": 500000000}]); + + let result = bridge.quicktx().build(&yaml, &u, &protocol_params(), Some(&exec)).expect("spend"); + assert_eq!(result.tx_hash.len(), 64); + assert!(bridge.quicktx().build(&yaml, &u, &protocol_params(), None).is_err()); +} diff --git a/wrappers/rust/tests/quicktx_integration_test.rs b/wrappers/rust/tests/quicktx_integration_test.rs index a6bbc8d..ff0e960 100644 --- a/wrappers/rust/tests/quicktx_integration_test.rs +++ b/wrappers/rust/tests/quicktx_integration_test.rs @@ -8,7 +8,7 @@ //! cd wrappers/rust && DYLD_LIBRARY_PATH=../../core/build/native/nativeCompile \ //! cargo test --test quicktx_integration_test -- --test-threads=1 -use ccl::{Amount, Bridge, ProviderConfig}; +use ccl::Bridge; use serde_json::{json, Value}; use std::thread; use std::time::Duration; @@ -144,9 +144,26 @@ fn total_lovelace(utxos: &Value) -> u64 { // --- Integration Tests --- +fn payment_yaml(from: &str, to: &str, quantity: &str) -> String { + format!( + "version: 1.0\n\ + transaction:\n\ + \x20 - tx:\n\ + \x20 from: {from}\n\ + \x20 intents:\n\ + \x20 - type: payment\n\ + \x20 address: {to}\n\ + \x20 amounts:\n\ + \x20 - unit: lovelace\n\ + \x20 quantity: \"{quantity}\"\n" + ) +} + #[test] fn test_integration_simple_ada_transfer() { - if skip_if_no_devkit() { return; } + if skip_if_no_devkit() { + return; + } devkit_reset(); wait_for_block(); @@ -157,110 +174,30 @@ fn test_integration_simple_ada_transfer() { let utxos = devkit_get_utxos(&sender); let pp = devkit_get_protocol_params(); - // Build - let result = bridge - .quicktx() - .new_tx() - .pay_to_address(&receiver, &[Amount::ada(5.0)], None, None) - .from(&sender) - .with_utxos(utxos) - .with_protocol_params(pp) - .build() - .expect("build failed"); - + let yaml = payment_yaml(&sender, &receiver, "5000000"); + let result = bridge.quicktx().build(&yaml, &utxos, &pp, None).expect("build failed"); assert!(!result.tx_cbor.is_empty()); assert_eq!(result.tx_hash.len(), 64); - assert!(result.fee.parse::().unwrap() > 0); - // Sign let signed_tx = bridge .account() .sign_tx(&mnemonic, ccl::network::TESTNET, 0, 0, &result.tx_cbor) .expect("sign failed"); - - // Submit let tx_hash = devkit_submit_tx(&signed_tx); assert!(!tx_hash.is_empty()); - // Verify wait_for_block(); let receiver_utxos = devkit_get_utxos(&receiver); - let total = total_lovelace(&receiver_utxos); - assert_eq!(total, 5_000_000, "expected 5 ADA, got {} lovelace", total); -} - -#[test] -fn test_integration_multiple_receivers() { - if skip_if_no_devkit() { return; } - - let bridge = Bridge::new().expect("create bridge"); - let (sender, mnemonic) = fund_sender(&bridge, 150); - let (r1, _, _) = get_testnet_account(&bridge); - let (r2, _, _) = get_testnet_account(&bridge); - - let utxos = devkit_get_utxos(&sender); - let pp = devkit_get_protocol_params(); - - let result = bridge - .quicktx() - .new_tx() - .pay_to_address(&r1, &[Amount::ada(3.0)], None, None) - .pay_to_address(&r2, &[Amount::ada(2.0)], None, None) - .from(&sender) - .with_utxos(utxos) - .with_protocol_params(pp) - .build() - .expect("build failed"); - - let signed_tx = bridge - .account() - .sign_tx(&mnemonic, ccl::network::TESTNET, 0, 0, &result.tx_cbor) - .expect("sign failed"); - devkit_submit_tx(&signed_tx); - wait_for_block(); - - let r1_utxos = devkit_get_utxos(&r1); - let r2_utxos = devkit_get_utxos(&r2); - assert_eq!(total_lovelace(&r1_utxos), 3_000_000); - assert_eq!(total_lovelace(&r2_utxos), 2_000_000); -} - -#[test] -fn test_integration_with_metadata() { - if skip_if_no_devkit() { return; } - - let bridge = Bridge::new().expect("create bridge"); - let (sender, mnemonic) = fund_sender(&bridge, 150); - let (receiver, _, _) = get_testnet_account(&bridge); - - let utxos = devkit_get_utxos(&sender); - let pp = devkit_get_protocol_params(); - - let result = bridge - .quicktx() - .new_tx() - .pay_to_address(&receiver, &[Amount::ada(2.0)], None, None) - .attach_metadata(674, json!({"msg": ["Hello from Rust integration"]})) - .from(&sender) - .with_utxos(utxos) - .with_protocol_params(pp) - .build() - .expect("build failed"); - - let signed_tx = bridge - .account() - .sign_tx(&mnemonic, ccl::network::TESTNET, 0, 0, &result.tx_cbor) - .expect("sign failed"); - devkit_submit_tx(&signed_tx); - wait_for_block(); - - let tx_info = devkit_get_tx(&result.tx_hash); - assert!(tx_info.is_some(), "tx not found on-chain"); + assert_eq!(total_lovelace(&receiver_utxos), 5_000_000); } #[test] fn test_integration_insufficient_funds() { - if skip_if_no_devkit() { return; } + if skip_if_no_devkit() { + return; + } + devkit_reset(); + wait_for_block(); let bridge = Bridge::new().expect("create bridge"); let (sender, _) = fund_sender(&bridge, 2); @@ -269,176 +206,7 @@ fn test_integration_insufficient_funds() { let utxos = devkit_get_utxos(&sender); let pp = devkit_get_protocol_params(); - let result = bridge - .quicktx() - .new_tx() - .pay_to_address(&receiver, &[Amount::ada(100.0)], None, None) - .from(&sender) - .with_utxos(utxos) - .with_protocol_params(pp) - .build(); - - assert!(result.is_err(), "expected error for insufficient funds"); -} - -#[test] -fn test_integration_full_round_trip() { - if skip_if_no_devkit() { return; } - - let bridge = Bridge::new().expect("create bridge"); - let (sender, mnemonic) = fund_sender(&bridge, 150); - let (receiver, _, _) = get_testnet_account(&bridge); - - let utxos = devkit_get_utxos(&sender); - let pp = devkit_get_protocol_params(); - - // Build - let result = bridge - .quicktx() - .new_tx() - .pay_to_address(&receiver, &[Amount::ada(10.0)], None, None) - .from(&sender) - .with_utxos(utxos) - .with_protocol_params(pp) - .build() - .expect("build failed"); - - assert!(!result.tx_cbor.is_empty()); - assert_eq!(result.tx_hash.len(), 64); - - // Sign - let signed_tx = bridge - .account() - .sign_tx(&mnemonic, ccl::network::TESTNET, 0, 0, &result.tx_cbor) - .expect("sign failed"); - - // Submit - devkit_submit_tx(&signed_tx); - wait_for_block(); - - // Confirm on-chain - let tx_info = devkit_get_tx(&result.tx_hash); - assert!(tx_info.is_some(), "tx not found on-chain"); - - // Check receiver balance - let receiver_utxos = devkit_get_utxos(&receiver); - let total = total_lovelace(&receiver_utxos); - assert_eq!(total, 10_000_000, "expected 10 ADA, got {} lovelace", total); -} - -// --- Provider Config (Java-side lazy UTXO fetching) tests --- - -#[test] -fn test_integration_provider_config_simple_transfer() { - if skip_if_no_devkit() { return; } - - let bridge = Bridge::new().expect("create bridge"); - let (sender, mnemonic) = fund_sender(&bridge, 150); - let (receiver, _, _) = get_testnet_account(&bridge); - - let config = ProviderConfig { - name: "yaci".to_string(), - url: DEVKIT_PROVIDER_URL.to_string(), - api_key: None, - enable_cost_evaluation: None, - }; - - // Build using ProviderConfig — Java fetches UTXOs and PP lazily via HTTP - let result = bridge - .quicktx() - .new_tx() - .pay_to_address(&receiver, &[Amount::ada(5.0)], None, None) - .from(&sender) - .build_with_provider(&config) - .expect("build with provider failed"); - - assert!(!result.tx_cbor.is_empty()); - assert_eq!(result.tx_hash.len(), 64); - assert!(result.fee.parse::().unwrap() > 0); - - // Sign and submit - let signed_tx = bridge - .account() - .sign_tx(&mnemonic, ccl::network::TESTNET, 0, 0, &result.tx_cbor) - .expect("sign failed"); - let tx_hash = devkit_submit_tx(&signed_tx); - assert!(!tx_hash.is_empty()); - - wait_for_block(); - let receiver_utxos = devkit_get_utxos(&receiver); - let total = total_lovelace(&receiver_utxos); - assert_eq!(total, 5_000_000, "expected 5 ADA, got {} lovelace", total); -} - -#[test] -fn test_integration_provider_config_multiple_receivers() { - if skip_if_no_devkit() { return; } - - let bridge = Bridge::new().expect("create bridge"); - let (sender, mnemonic) = fund_sender(&bridge, 150); - let (r1, _, _) = get_testnet_account(&bridge); - let (r2, _, _) = get_testnet_account(&bridge); - - let config = ProviderConfig { - name: "yaci".to_string(), - url: DEVKIT_PROVIDER_URL.to_string(), - api_key: None, - enable_cost_evaluation: None, - }; - - let result = bridge - .quicktx() - .new_tx() - .pay_to_address(&r1, &[Amount::ada(3.0)], None, None) - .pay_to_address(&r2, &[Amount::ada(2.0)], None, None) - .from(&sender) - .build_with_provider(&config) - .expect("build with provider failed"); - - let signed_tx = bridge - .account() - .sign_tx(&mnemonic, ccl::network::TESTNET, 0, 0, &result.tx_cbor) - .expect("sign failed"); - devkit_submit_tx(&signed_tx); - wait_for_block(); - - let r1_utxos = devkit_get_utxos(&r1); - let r2_utxos = devkit_get_utxos(&r2); - assert_eq!(total_lovelace(&r1_utxos), 3_000_000); - assert_eq!(total_lovelace(&r2_utxos), 2_000_000); -} - -#[test] -fn test_integration_provider_config_with_metadata() { - if skip_if_no_devkit() { return; } - - let bridge = Bridge::new().expect("create bridge"); - let (sender, mnemonic) = fund_sender(&bridge, 150); - let (receiver, _, _) = get_testnet_account(&bridge); - - let config = ProviderConfig { - name: "yaci".to_string(), - url: DEVKIT_PROVIDER_URL.to_string(), - api_key: None, - enable_cost_evaluation: None, - }; - - let result = bridge - .quicktx() - .new_tx() - .pay_to_address(&receiver, &[Amount::ada(2.0)], None, None) - .attach_metadata(674, json!({"msg": ["Hello from Rust providerConfig"]})) - .from(&sender) - .build_with_provider(&config) - .expect("build with provider failed"); - - let signed_tx = bridge - .account() - .sign_tx(&mnemonic, ccl::network::TESTNET, 0, 0, &result.tx_cbor) - .expect("sign failed"); - devkit_submit_tx(&signed_tx); - wait_for_block(); - - let tx_info = devkit_get_tx(&result.tx_hash); - assert!(tx_info.is_some(), "tx not found on-chain"); + let yaml = payment_yaml(&sender, &receiver, "100000000"); + let result = bridge.quicktx().build(&yaml, &utxos, &pp, None); + assert!(result.is_err(), "expected insufficient funds error"); }