diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0ff74b4..dddcadc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,9 +24,12 @@ jobs: - os: windows-latest target: x86_64-pc-windows-msvc archive: zip + - os: macos-latest + target: aarch64-apple-darwin + archive: tar.gz steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -38,6 +41,10 @@ jobs: with: key: ${{ matrix.target }} + # A tag can point at a commit CI never saw; don't ship a binary that fails its own tests. + - name: Test + run: cargo test --workspace --locked + - name: Build run: cargo build --release --locked -p tx-manifest-wallet --target ${{ matrix.target }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d3ba96..bbe8efe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ User-facing changes to the `tx-manifest-wallet` CLI and the manifest format. Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [Semantic Versioning](https://semver.org/). No changelog was kept before 0.2.0. +## [Unreleased] + +### Added + +- Release binaries for macOS (Apple Silicon). + ## [0.3.0] - 2026-10-06 ### Breaking diff --git a/Cargo.lock b/Cargo.lock index 2e01174..39d6967 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3345,7 +3345,7 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tx-manifest-lib" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "base64 0.22.1", diff --git a/MIGRATION-compile_params-to-instance.md b/MIGRATION-compile_params-to-instance.md deleted file mode 100644 index 3e5717d..0000000 --- a/MIGRATION-compile_params-to-instance.md +++ /dev/null @@ -1,105 +0,0 @@ -# Migration: rename the `compile_params.NAME` reference namespace to `instance.NAME` - -**Status:** TODO -**Origin:** transaction-manifest spec change (tx_manifest_spec repo). This doc tells the -manifest-wallet side what to change to stay in sync. - -## Background - -The manifest formula/reference namespace `compile_params.NAME` was a vestige of an older -spec version that had a top-level `compile_params` block. That block is long gone. In every -current manifest, a `compile_params.NAME` reference resolves to a **class/instance field** -loaded from the instance file (Classes & Instances extension) — verified: 100% of such -references in the example manifests map to an instance field, and none are anything else. - -So the spec renamed the **value reference namespace** from `compile_params.` to `instance.`: - -| Manifest construct | Before | After | -|---|---|---| -| Value read in a formula / asset / amount / witness `source.key` | `compile_params.NAME` | `instance.NAME` | -| Hook `set` target | `"compile_params.NAME": ""` | `"instance.NAME": ""` | -| `create_instance.fields` `$`-substitution | `"$compile_params.NAME"` | `"$instance.NAME"` | - -**Unchanged — do NOT rename these:** - -- The covenant **wiring field** `script.compile_params` (the object that maps a `.simf` - program's own compile-time parameter names to values). It is still called `compile_params` - in the manifest JSON. In the Rust code this is the `compile_params: &HashMap` - arguments in `covenant.rs` — leave them as-is. -- Internal storage names may stay: `ExecutionContext::compile_params`, `get_compile_param()`, - etc. still work — the values they hold are exactly the instance fields. Renaming them to - `instance` / `get_instance_field()` is optional cosmetic cleanup, not required. Only the - **manifest-facing string prefix** must change. - -## Code changes - -Change the recognized reference prefix from `"compile_params."` to `"instance."` (and the -bare namespace token `"compile_params"` to `"instance"`) at these sites: - -**`txmanifest_lib/src/eval.rs`** -- `162` — `name.strip_prefix("compile_params.")` → `strip_prefix("instance.")` -- `321` — `token == "compile_params"` → `token == "instance"` -- `336` — `result.push_str("compile_params.")` → `push_str("instance.")` (unresolved-token passthrough) -- `415` — `"compile_params" => ctx.get_compile_param(key)...` → match arm `"instance" => ...` - -**`txmanifest_lib/src/lifecycle.rs`** — every `strip_prefix("compile_params.")`: -- `565`, `654`, `753`, `1494`, `2922`, `2940`, `3022`, `3150`, `3194`, `3378` - -(Search the whole crate for the literal `"compile_params."` and `"compile_params"` used as a -manifest prefix/namespace token to catch any site this list misses — e.g. -`grep -rn 'compile_params' --include=*.rs txmanifest_lib/src`. Ignore hits that are the -`script.compile_params` wiring map or internal field/method names.) - -### Recommended: transitional alias (optional) - -If any manifests exist outside this repo, accept **both** prefixes for a release, preferring -`instance.`, then drop the legacy one: - -```rust -let key = name.strip_prefix("instance.") - .or_else(|| name.strip_prefix("compile_params.")); // deprecated alias — remove after transition -``` - -If there are no external manifests, a hard cut is fine (the spec has already removed the old -name). - -## Fixtures / examples to update - -These carry `compile_params.` references and must be re-synced: - -- `examples/last_will/txmanifest.json` (4 refs) -- `examples/lending/txmanifest.json` (94 refs) -- Any inline JSON in `#[test]` modules that uses `compile_params.` (grep the `.rs` files). - -The rename is a safe pure-text replacement — the wiring field `"compile_params":` has no -trailing dot, so it is untouched: - -```bash -# from repo root -python - <<'PY' -import pathlib -for f in ["examples/last_will/txmanifest.json","examples/lending/txmanifest.json"]: - p=pathlib.Path(f); b=p.read_bytes() - p.write_bytes(b.replace(b"compile_params.", b"instance.")) -PY -``` - -Then confirm each file is still valid JSON and that `"compile_params":` wiring blocks remain. - -## Related spec change (separate but adjacent) - -The inert `taproot_leaf` witness (conventionally `SPEND_PATH`, `expr: "_leaf"`) was -**removed** from the spec and examples, because the wallet never consumed it — the spend leaf -and control block are derived directly from the covenant program. Optional cleanup here: - -- Drop `"taproot_leaf"` from `KNOWN_WITNESS_TYPES` and remove its no-op arm in - `txmanifest_lib/src/validate.rs` (so a stray one now warns as unrecognized). -- Note the name collision: in `examples/last_will`, `SPEND_PATH` is a **real** `simplicityhl` - program witness (`match witness::SPEND_PATH` in `last_will.simf`) — keep those. Only the - `type: "taproot_leaf"` entries were removed. - -## Acceptance - -- `cargo test` passes with fixtures updated. -- Running the `lending` and `last_will` examples end-to-end produces the same covenant - addresses and PSETs as before (the change is a pure reference rename; behavior is identical). diff --git a/README.md b/README.md index 6230848..2acbeaf 100644 --- a/README.md +++ b/README.md @@ -1,177 +1,135 @@ # tx-manifest -A declarative engine and wallet CLI for executing **transaction manifests** on -[Liquid](https://liquid.net/) / Elements — JSON files that describe a protocol's -UTXO types, actions, and lifecycle, backed by [SimplicityHL](https://github.com/BlockstreamResearch/SimplicityHL) -covenants. +A wallet that runs **transaction manifests**: JSON files describing a protocol's +transactions — what each action spends, what it creates, and which +[SimplicityHL](https://github.com/BlockstreamResearch/SimplicityHL) covenants guard the +outputs. Write the manifest once; the wallet selects UTXOs, derives covenant addresses, +builds and signs the transaction, dry-runs the covenant programs, and broadcasts. No +protocol-specific wallet code. -You write a manifest (`txmanifest.json`) that declares *what* a transaction does — -its inputs, outputs, covenant scripts, compile-time parameters, and validations — -and the wallet figures out *how*: it resolves UTXOs, computes covenant addresses -and tapleaf hashes, builds and signs the PSET, dry-runs the Simplicity programs, -and broadcasts. No bespoke wallet code per protocol. +Runs on **Liquid** (and other Elements networks) and **Bitcoin** (mainnet, testnet, +signet, regtest; covenants only where Simplicity is active). -## Workspace layout +> **Experimental and unaudited.** The wallet holds private keys and signs transactions. +> Use testnets. Do not use it with funds you care about. -This is a Cargo workspace with two crates: +## Install -| Crate | Kind | Purpose | -|-------|------|---------| -| [`tx-manifest-lib`](txmanifest_lib) | library | The manifest model, lifecycle engine, covenant compilation/dry-run, parameter resolution, PSET building, and wallet primitives. | -| [`tx-manifest-wallet`](txmanifest_wallet) | binary | The `tx-manifest-wallet` CLI that drives the library interactively. | +Download a binary for your platform from the +[releases page](https://github.com/stringhandler/txmanifest-wallet/releases), or build +from source (Rust stable): -``` -manifest-wallet/ -├── Cargo.toml # workspace -├── txmanifest_lib/ # library crate -│ └── src/ -│ ├── manifest.rs # manifest schema (deserialized from txmanifest.json) -│ ├── lifecycle.rs # interactive action execution engine -│ ├── covenant.rs # SimplicityHL covenant compile / address / dry-run / finalize -│ ├── eval.rs # expression evaluator (amounts, formulas, references) -│ ├── prepare.rs # UTXO pre-funding / splitting -│ ├── pset_builder.rs # PSET construction -│ ├── validate.rs # static manifest schema checks -│ ├── describe.rs # interactive manifest explorer -│ ├── wallet.rs # key management & signing -│ └── … # config, context, params, instance, state, prompt -├── txmanifest_wallet/ # CLI crate -└── examples/ # sample manifests + .simf programs - ├── p2pk/ # "hello world" — pay-to-public-key via Simplicity - ├── lending/ # P2P collateralised lending protocol - ├── dex/ # keyless atomic swap offers (Mosaik's Tessera covenant) - ├── deadcat/ # binary prediction market with on-chain oracle resolution - ├── deadcat_v2/ # …unblinded tokens — a documented dead end - ├── deadcat_v3/ # …derivable blinding factors; the runnable fork - └── last_will/ # time-locked inheritance +```sh +cargo install --locked --git https://github.com/stringhandler/txmanifest-wallet tx-manifest-wallet ``` -## How a manifest works +## Quick start (Liquid testnet) -A manifest is a JSON document describing a protocol. The key sections: +The default network is Liquid testnet, so this needs no configuration. -- **`utxo_types`** — covenant output types, each referencing a `.simf` SimplicityHL - program and its compile parameters. -- **`actions`** / **`classes`** — the operations a user can perform. Each declares - `params`, `args`, `inputs`, `outputs`, `validations`, and lifecycle hooks. -- **`params`** — values baked into covenant programs at compile time. Derived params - can be auto-computed (arithmetic expressions, tapleaf hashes, or — with the - `simplicity_eval` feature — standalone function calls). A `utxo_type`'s - `script.compile_params` then wires these onto the `.simf`'s own parameter names. - -See [`examples/p2pk/txmanifest.json`](examples/p2pk/txmanifest.json) for a minimal -example, or [`examples/lending/txmanifest.json`](examples/lending/txmanifest.json) -for a full multi-action covenant protocol. +```sh +# 1. Create a wallet, then print a receive address +tx-manifest-wallet create-wallet --out wallet.json +tx-manifest-wallet info --wallet wallet.json -## Building +# 2. Fund that address from a Liquid testnet faucet (e.g. https://liquidtestnet.com/faucet), +# then sync +tx-manifest-wallet sync --wallet wallet.json -```sh -cargo build # whole workspace -cargo test # run the test suite +# 3. Check a manifest, then run one of its actions +tx-manifest-wallet validate examples/p2pk/txmanifest.json +tx-manifest-wallet run examples/p2pk/txmanifest.json Pay --wallet wallet.json ``` -The `simplicityhl` dependency is a git reference. Covenant **dry-runs, address -derivation, and witness building** work against upstream -`BlockstreamResearch/SimplicityHL` (master). The standalone `compile_function` / -expression-eval code paths are gated behind a feature. +`run` walks through the action: it prompts for any parameters you did not pass with +`--params`, picks inputs from the wallet, shows the transaction, and asks before +broadcasting. Pass `--export-pset out.json` to write the signed transaction instead of +sending it. -### The `simplicity_eval` feature +For Bitcoin, start with [`examples/bitcoin_pay`](examples/bitcoin_pay) (plain payments) +and [`examples/bitcoin_covenant`](examples/bitcoin_covenant) (a Simplicity covenant on the +Simplicity signet or a local regtest). -```sh -cargo build --features tx-manifest-wallet/simplicity_eval -``` +## Examples -This enables manifest features that depend on custom SimplicityHL APIs not yet in -master (`TemplateProgram::compile_function`, `CompiledFunction`, `eval_expression`) — -namely the `simf_fn` compute hook and `on_input_resolved` SimplicityHL hooks. +[`examples/`](examples) has a list of every example, from a one-key covenant to a +lending protocol that interoperates with the reference implementation. Each one passes +`validate`. -> ⚠️ **The default `simplicityhl` dependency points at upstream master, which does -> not have these APIs, so `--features simplicity_eval` will _not_ compile as-is.** -> To use it you must repoint the `simplicityhl` dependency in -> [`txmanifest_lib/Cargo.toml`](txmanifest_lib/Cargo.toml) at a branch that provides -> them (e.g. a fork that is a superset of master). With the feature off — the -> default — these specific hooks fail at runtime with a clear message and everything -> else works normally. +## Writing a manifest -## Usage +A manifest declares: -The CLI is `tx-manifest-wallet`. During development, run it via `cargo run --`. +- **`chain`** and **`requires`** — the ledger it targets and what the wallet must + support (e.g. `"simplicity"`). +- **`utxo_types`** — covenant output types, each pointing at a `.simf` program. +- **`actions`** — the operations a user performs, each with `params`, `inputs`, + `outputs` and `validations`. -```sh -# Create a wallet (defaults to Liquid testnet) -cargo run -- create-wallet --out wallet.json +The full format is defined by the JSON Schema in +[`schema/txmanifest.schema.json`](schema/txmanifest.schema.json); point your editor at it +with `"$schema"` for completion and inline errors. `manifest_version` must be `"0.3.0"`. +[`examples/p2pk`](examples/p2pk/txmanifest.json) is the smallest complete manifest. -# Fund it, then check it -cargo run -- info --wallet wallet.json -cargo run -- sync --wallet wallet.json +Check a manifest with `validate` (offline structure checks) and `capabilities` (what a +wallet needs to run it; `--supports` turns it into a CI check for other wallet +implementations). -# Inspect / validate a manifest -cargo run -- describe examples/p2pk/txmanifest.json -cargo run -- validate examples/p2pk/txmanifest.json +## Commands -# Ensure the wallet has the UTXOs an action needs (splits a funding tx if required) -cargo run -- prepare examples/p2pk/txmanifest.json Pay --wallet wallet.json +| Command | What it does | +|---------|--------------| +| `run ` | Run an action: resolve inputs, build, sign, broadcast. | +| `validate ` | Check a manifest without touching the network. | +| `capabilities ` | Report what a wallet must support to run a manifest. | +| `describe ` | Browse a manifest's actions interactively. | +| `prepare ` | Split wallet funds so an action has the UTXOs it needs (Liquid). | +| `create-wallet` | Create a wallet file. | +| `info` | Show the wallet's keys and a receive address. | +| `sync` | Fetch the wallet's UTXOs and show the balance. | +| `get-balance` | Show the last synced balance, offline. | +| `split` | Split one asset into N equal UTXOs (Liquid). | +| `config` | Show or change configuration. | -# Execute an action interactively -cargo run -- run examples/p2pk/txmanifest.json Pay --wallet wallet.json -``` +`tx-manifest-wallet --help` lists every flag. + +## Configuration -### Commands +The config file is chosen in this order: -| Command | Description | -|---------|-------------| -| `run ` | Walk through a manifest action interactively (resolve inputs → build → sign → broadcast). | -| `prepare ` | Ensure the wallet holds the UTXOs the action needs; broadcasts a split tx if not. | -| `validate ` | Static schema/sanity checks on a manifest. | -| `describe ` | Interactively explore a manifest's classes and actions. | -| `create-wallet` | Generate a new wallet JSON file. | -| `info` | Show wallet fingerprint, xpub, oracle key, and a receive address. | -| `sync` | Sync wallet state against an Esplora server and show balance. | -| `get-balance` | Show last-synced balance (no network call). | -| `split` | Split a wallet asset into N equal UTXOs. | -| `config` | Show or update configuration (`default_network`, `default_esplora`). | +1. `--config ` +2. a `config.json` in the same directory as the wallet file +3. `config.json` in the data directory (the platform data directory, or + `$TX_MANIFEST_DATA_DIR` if set) -Run `cargo run -- --help` for full flag details. +`tx-manifest-wallet config` prints the active settings, and `config ` changes +one. The wallet file records its network; a config naming a different one is an error +rather than a silent switch. -### Configuration +**Files `run` writes.** After a broadcast, `run` records the contract's on-chain state in a +numbered file next to the manifest (`txmanifest.state.1.json`, `.2`, …) and, for actions +that create a contract, an instance file. Pass the latest one back with `--state` / +`--instance` to continue the contract. Use `--state-out` / `--instance-out` to choose +where they go. -Config lives in a platform data directory and defaults to **Liquid testnet** -(`https://blockstream.info/liquidtestnet/api`). Switch networks with: +## Building from source ```sh -cargo run -- config default_network mainnet +cargo build +cargo test ``` -## Notes - -- This project was renamed from `compose` to `tx-manifest`. Manifest files are - conventionally named `txmanifest.json` and carry a `manifest_version` naming the - format version they are written against; the current format is `0.2.0`, and a - file declaring anything else is refused at parse time. -- Targets Liquid/Elements. Covenant enforcement is fully on-chain via Simplicity — - no trusted backend. - -## Security & status +The `simplicityhl` dependency is a fork that adds Bitcoin support; see +[`txmanifest_lib/Cargo.toml`](txmanifest_lib/Cargo.toml). +[`contrib/regtest`](contrib/regtest) builds a local Bitcoin node with Simplicity active. +CI runs `cargo fmt --check` and `cargo clippy -- -D warnings`. -This is **experimental software** built on Simplicity, which is itself early-stage. -It has **not** been audited. The wallet manages private keys and signs transactions. - -- Use it on **Liquid testnet** (the default) — do not use it with real funds. -- Never commit wallet files. `wallet*.json`, `*_wallet.json`, `oracle.json`, and - `*.state.json` / `*.instance.json` are gitignored; keep your keys out of version - control regardless. -- No warranty — see the license. +Changes are listed in [CHANGELOG.md](CHANGELOG.md). ## License -Licensed under either of - -- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or - ) -- MIT license ([LICENSE-MIT](LICENSE-MIT) or ) - -at your option. - -Unless you explicitly state otherwise, any contribution intentionally submitted for -inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual -licensed as above, without any additional terms or conditions. +Licensed under either of [Apache License, Version 2.0](LICENSE-APACHE) or +[MIT license](LICENSE-MIT) at your option. Unless you explicitly state otherwise, any +contribution intentionally submitted for inclusion in the work by you, as defined in the +Apache-2.0 license, shall be dual licensed as above, without any additional terms or +conditions. diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..b14edfa --- /dev/null +++ b/examples/README.md @@ -0,0 +1,21 @@ +# Examples + +Each directory holds a `txmanifest.json`, the `.simf` programs it references, and +sometimes a `params.json` with sample values. Every example here passes +`tx-manifest-wallet validate` (CI checks this). + +Roughly in order of complexity: + +| Example | Chain | What it shows | +|---------|-------|---------------| +| [`p2pk`](p2pk) | Liquid | The smallest covenant: lock coins to a key with a Simplicity checksig program, then spend them. Start here. | +| [`bitcoin_pay`](bitcoin_pay) | Bitcoin | A plain payment with no covenant: what a manifest looks like on Bitcoin, and the `fee` keyword. | +| [`bitcoin_covenant`](bitcoin_covenant) | Bitcoin | The `p2pk` program on Bitcoin, run on the Simplicity signet or a local regtest. | +| [`last_will`](last_will) | Liquid | A recursive covenant with a timelocked inheritance path, a cold-key break-out and a hot-key refresh. | +| [`dex`](dex) | Liquid | Tessera: a keyless atomic-swap offer anyone can fill, or refund after a timeout. | +| [`deadcat_v3`](deadcat_v3) | Liquid | A binary prediction market with on-chain oracle resolution and confidential reissuance tokens. Derived from [Deadcat](https://github.com/Resolvr-io/deadcat); not interoperable with it. | +| [`lending_v2`](lending_v2) | Liquid | Peer-to-peer collateralised lending, wire-compatible with [simplicity-lending](https://github.com/BlockstreamResearch/simplicity-lending). | +| [`lending_v3`](lending_v3) | Liquid | Work in progress: the redesigned "issuance factory" version of simplicity-lending — create a factory, then offer, accept, cancel, claim and repay loans. | + +The examples with their own README (`bitcoin_pay`, `bitcoin_covenant`, `lending_v2`) +include full walkthroughs. diff --git a/examples/bitcoin_covenant/README.md b/examples/bitcoin_covenant/README.md index 8b70b1d..91165a2 100644 --- a/examples/bitcoin_covenant/README.md +++ b/examples/bitcoin_covenant/README.md @@ -28,19 +28,23 @@ Two actions: ## Requirements -A node that executes Simplicity. No public network has activated it, so this needs -`contrib/regtest`: +A node that executes Simplicity. Two configs are provided: -```sh -docker build -t simplicity-regtest contrib/regtest -docker run -d --name simplicity-regtest -p 18443:18443 simplicity-regtest \ - -regtest -server -rpcbind=0.0.0.0 -rpcallowip=0.0.0.0/0 \ - -rpcuser=tx -rpcpassword=manifest -fallbackfee=0.0001 -txindex=1 -``` +- **`config.json`** — the public Simplicity signet, through its Esplora at + `signet.simplicity-lang.org`. Nothing to run locally; fund the wallet with signet coins + for that network. +- **`config.regtest.json`** — a local node from [`contrib/regtest`](../../contrib/regtest), + funded by its `faucet.sh`: + + ```sh + docker build -t simplicity-regtest contrib/regtest + docker run -d --name simplicity-regtest -p 18443:18443 simplicity-regtest \ + -regtest -server -rpcbind=0.0.0.0 -rpcallowip=0.0.0.0/0 \ + -rpcuser=tx -rpcpassword=manifest -fallbackfee=0.0001 -txindex=1 + ``` -`config.json` here sets `simplicity_activated: true`. Without it the run is refused before -anything is derived — the wallet will not build a covenant address for a node that cannot -spend it: +Both set `simplicity_activated: true`. Without it the run is refused before anything is +derived — the wallet will not build a covenant address for a node that cannot spend it: ``` Error: network 'bitcoin-regtest' cannot provide what this manifest requires (simplicity) @@ -48,28 +52,24 @@ Error: network 'bitcoin-regtest' cannot provide what this manifest requires (sim ## Running it -Fund a wallet with the faucet, then put its **Wallet Signing Key** into `params.json` as -`PUB_KEY` — that is the key the covenant will demand, so it has -to be one this wallet can produce: +The commands below use the signet config. For regtest, swap in `config.regtest.json`, fund +with `./contrib/regtest/faucet.sh --config examples/bitcoin_covenant/config.regtest.json +--wallet $W`, and run `./contrib/regtest/mine.sh` after each broadcast. + +Create and fund a wallet, then put its **Wallet Signing Key** into `params.json` as +`PUB_KEY` — that is the key the covenant will demand, so it has to be one this wallet can +produce: ```sh CFG="--config examples/bitcoin_covenant/config.json" -W=/tmp/txm-regtest/wallet.json - -./contrib/regtest/faucet.sh --config examples/bitcoin_covenant/config.json --wallet $W -cargo run -p tx-manifest-wallet -- $CFG info --wallet $W # copy "Wallet Signing Key" -``` - -Lock: +W=/tmp/txm-signet/wallet.json -```sh -cargo run -p tx-manifest-wallet -- $CFG \ - run examples/bitcoin_covenant/txmanifest.json Lock \ - --wallet $W --params examples/bitcoin_covenant/params.json +cargo run -p tx-manifest-wallet -- $CFG create-wallet --out $W +cargo run -p tx-manifest-wallet -- $CFG info --wallet $W # fund the address; copy "Wallet Signing Key" ``` -`Lock` writes a state file recording the covenant it created, so `Unlock` can find it — -pass the one `Lock` wrote: +Lock, then unlock. `Lock` writes a state file recording the covenant it created; pass it to +`Unlock` so it can find the coins: ```sh cargo run -p tx-manifest-wallet -- $CFG \ @@ -77,7 +77,7 @@ cargo run -p tx-manifest-wallet -- $CFG \ --wallet $W --params examples/bitcoin_covenant/params.json \ --state-out /tmp/cov.state.json -./contrib/regtest/mine.sh +# wait for the Lock transaction to confirm cargo run -p tx-manifest-wallet -- $CFG \ run examples/bitcoin_covenant/txmanifest.json Unlock \ diff --git a/examples/bitcoin_covenant/config.signet.json b/examples/bitcoin_covenant/config.signet.json deleted file mode 100644 index 1cdcdf0..0000000 --- a/examples/bitcoin_covenant/config.signet.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "default_network": "bitcoin-signet", - "bitcoin_backend": "esplora", - "default_esplora": "https://signet.simplicity-lang.org/explorer/api", - "simplicity_activated": true, - "bitcoin_checkpoint": { - "height": 1296, - "hash": "00000091e713448edf2f57f61a9d9e03a5c35ac0902be6c89b0cc5ea8e7b8564" - } -} diff --git a/examples/deadcat/params.json b/examples/deadcat/params.json deleted file mode 100644 index f14f7ac..0000000 --- a/examples/deadcat/params.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "COLLATERAL_ASSET_ID": "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49", - "COLLATERAL_PER_TOKEN": "1000", - "EXPIRY_TIME": "2560000", - "NO_REISSUANCE_TOKEN": "53d19885868f1b32d9c5ad3c7fdc83576ffefbd5ba573d03c61cf3e557855e5d", - "YES_REISSUANCE_TOKEN": "d8175b4e10fcb1b8eb78f34d4942b7c59635b7b30cb20e4f2801215005d9bf1b", - "ORACLE_PUBLIC_KEY": "5907ed9ec3cb2ff851da548b617b54af6d1967386775e7c7c3c9ad153f5884d9" -} \ No newline at end of file diff --git a/examples/deadcat/prediction_market.simf b/examples/deadcat/prediction_market.simf deleted file mode 100644 index d10668e..0000000 --- a/examples/deadcat/prediction_market.simf +++ /dev/null @@ -1,557 +0,0 @@ -// Binary Prediction Market Covenant -// SimplicityHL contract for Liquid - -// ============================================================================ -// Type aliases -// ============================================================================ - -// PATH dispatch types (7-way nested Either) -type Path1or2 = Either<(), ()>; -type Path3or4 = Either<(), ()>; -type Path1to4 = Either; -type Path5or6 = Either<(), ()>; -type Path5to7 = Either; - -// Blinding factor groupings -type BlindingQuad = (u256, u256, u256, u256); - -// ============================================================================ -// Boolean helpers -// ============================================================================ - -fn not(bit: bool) -> bool { - ::into(jet::complement_1(::into(bit))) -} - -fn or(a: bool, b: bool) -> bool { - ::into(jet::or_1(::into(a), ::into(b))) -} - -fn ensure_zero_bit(b: bool) { - assert!(not(b)); -} - -// ============================================================================ -// Utility functions -// ============================================================================ - -fn get_input_script_hash(index: u32) -> u256 { - unwrap(jet::input_script_hash(index)) -} - -fn get_output_explicit_asset(index: u32) -> u256 { - unwrap_right::<(u1, u256)>(unwrap(jet::output_asset(index))) -} - -fn get_output_explicit_asset_amount(index: u32) -> (u256, u64) { - let (asset, amount): (Asset1, Amount1) = unwrap(jet::output_amount(index)); - let asset_val: u256 = unwrap_right::<(u1, u256)>(asset); - let amount_val: u64 = unwrap_right::<(u1, u256)>(amount); - (asset_val, amount_val) -} - -fn get_input_explicit_asset_amount(index: u32) -> (u256, u64) { - let (asset, amount): (Asset1, Amount1) = unwrap(jet::input_amount(index)); - let asset_val: u256 = unwrap_right::<(u1, u256)>(asset); - let amount_val: u64 = unwrap_right::<(u1, u256)>(amount); - (asset_val, amount_val) -} - -fn ensure_output_script_hash_eq(index: u32, expected: u256) { - let actual: u256 = unwrap(jet::output_script_hash(index)); - assert!(jet::eq_256(actual, expected)); -} - -fn ensure_output_asset_with_amount_eq(index: u32, expected_asset: u256, expected_amount: u64) { - let (asset, amount): (u256, u64) = get_output_explicit_asset_amount(index); - assert!(jet::eq_256(asset, expected_asset)); - assert!(jet::eq_64(amount, expected_amount)); -} - -fn empty_script_hash() -> u256 { - 0xe3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 -} - -fn ensure_output_is_op_return(index: u32) { - let script_hash: u256 = unwrap(jet::output_script_hash(index)); - assert!(jet::eq_256(script_hash, empty_script_hash())); -} - -fn ensure_fee_output(index: u32) { - let script_hash: u256 = unwrap(jet::output_script_hash(index)); - assert!(jet::eq_256(script_hash, empty_script_hash())); -} - -// ============================================================================ -// Pedersen commitment verification -// ============================================================================ - -fn verify_token_commitment( - asset_commitment: (u1, u256), - amount_commitment: (u1, u256), - expected_token_id: u256, - abf: u256, - vbf: u256 -) { - // Recompute the asset generator: H + abf*G - let h_point: Ge = jet::hash_to_curve(expected_token_id); - let abf_point: Gej = jet::generate(abf); - let asset_gen: Gej = jet::gej_ge_add(abf_point, h_point); - - let asset_ge: Ge = unwrap(jet::gej_normalize(asset_gen)); - let (asset_x, asset_y): (u256, u256) = asset_ge; - let (stored_asset_parity, stored_asset_x): (u1, u256) = asset_commitment; - assert!(jet::eq_256(asset_x, stored_asset_x)); - // Generator parity encodes quadratic residue status, not odd/even bit parity. - // is_none(fe_square_root(y)) == true when y is NOT a QR, matching stored parity = 1. - assert!(jet::eq_1(::into(is_none::(jet::fe_square_root(asset_y))), stored_asset_parity)); - - // Recompute the value commitment: asset_gen + vbf*G - let vbf_point: Gej = jet::generate(vbf); - let value_gen: Gej = jet::gej_add(asset_gen, vbf_point); - - let value_ge: Ge = unwrap(jet::gej_normalize(value_gen)); - let (value_x, value_y): (u256, u256) = value_ge; - let (stored_value_parity, stored_value_x): (u1, u256) = amount_commitment; - assert!(jet::eq_256(value_x, stored_value_x)); - assert!(jet::eq_1(::into(is_none::(jet::fe_square_root(value_y))), stored_value_parity)); -} - -fn verify_input_reissuance_token( - index: u32, - expected_token: u256, - abf: u256, - vbf: u256 -) { - let asset_commitment: (u1, u256) = unwrap_left::(unwrap(jet::input_asset(index))); - let (_, amount_either): (Asset1, Amount1) = unwrap(jet::input_amount(index)); - let amount_commitment: (u1, u256) = unwrap_left::(amount_either); - verify_token_commitment(asset_commitment, amount_commitment, expected_token, abf, vbf); -} - -fn verify_output_reissuance_token( - index: u32, - expected_token: u256, - abf: u256, - vbf: u256 -) { - let asset_commitment: (u1, u256) = unwrap_left::(unwrap(jet::output_asset(index))); - let (_, amount_either): (Asset1, Amount1) = unwrap(jet::output_amount(index)); - let amount_commitment: (u1, u256) = unwrap_left::(amount_either); - verify_token_commitment(asset_commitment, amount_commitment, expected_token, abf, vbf); -} - -// ============================================================================ -// Taproot address computation -// ============================================================================ - -fn covenant_nums_key() -> u256 { - 0x50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0 -} - -fn compute_p2tr_script_hash_from_output_key(output_key: u256) -> u256 { - let ctx: Ctx8 = jet::sha_256_ctx_8_init(); - let ctx: Ctx8 = jet::sha_256_ctx_8_add_2(ctx, 0x5120); - let ctx: Ctx8 = jet::sha_256_ctx_8_add_32(ctx, output_key); - jet::sha_256_ctx_8_finalize(ctx) -} - -fn script_hash_for_input_script(state: u64) -> u256 { - let tap_leaf: u256 = jet::tapleaf_hash(); - let state_ctx: Ctx8 = jet::tapdata_init(); - let state_ctx: Ctx8 = jet::sha_256_ctx_8_add_8(state_ctx, state); - let state_leaf: u256 = jet::sha_256_ctx_8_finalize(state_ctx); - let tap_node: u256 = jet::build_tapbranch(tap_leaf, state_leaf); - let tweaked_key: u256 = jet::build_taptweak(covenant_nums_key(), tap_node); - compute_p2tr_script_hash_from_output_key(tweaked_key) -} - -// ============================================================================ -// Arithmetic helpers -// ============================================================================ - -fn safe_multiply(a: u64, b: u64) -> u64 { - let result: u128 = jet::multiply_64(a, b); - let (high, low): (u64, u64) = ::into(result); - assert!(jet::is_zero_64(high)); - low -} - -fn safe_add(a: u64, b: u64) -> u64 { - let (carry, sum): (bool, u64) = jet::add_64(a, b); - ensure_zero_bit(carry); - sum -} - -fn safe_subtract(a: u64, b: u64) -> u64 { - let (borrow, diff): (bool, u64) = jet::subtract_64(a, b); - ensure_zero_bit(borrow); - diff -} - -fn safe_subtract_32(a: u32, b: u32) -> u32 { - let (borrow, diff): (bool, u32) = jet::subtract_32(a, b); - ensure_zero_bit(borrow); - diff -} - -// ============================================================================ -// Market-specific functions -// ============================================================================ - -fn compute_market_id() -> u256 { - let ctx: Ctx8 = jet::sha_256_ctx_8_init(); - let ctx: Ctx8 = jet::sha_256_ctx_8_add_32(ctx, param::YES_TOKEN_ASSET); - let ctx: Ctx8 = jet::sha_256_ctx_8_add_32(ctx, param::NO_TOKEN_ASSET); - jet::sha_256_ctx_8_finalize(ctx) -} - -fn verify_oracle_signature(outcome_yes: bool, signature: Signature) { - let market_id: u256 = compute_market_id(); - let outcome_byte: u8 = match outcome_yes { - true => 1, - false => 0, - }; - let ctx: Ctx8 = jet::sha_256_ctx_8_init(); - let ctx: Ctx8 = jet::sha_256_ctx_8_add_32(ctx, market_id); - let ctx: Ctx8 = jet::sha_256_ctx_8_add_1(ctx, outcome_byte); - let message: u256 = jet::sha_256_ctx_8_finalize(ctx); - jet::bip_0340_verify((param::ORACLE_PUBLIC_KEY, message), signature); -} - -fn ensure_pre_expiry() { - let tx_lock_time: u32 = jet::lock_time(); - assert!(jet::lt_32(tx_lock_time, param::EXPIRY_TIME)); -} - -fn dormant_script_hash() -> u256 { - script_hash_for_input_script(0) -} - -fn unresolved_script_hash() -> u256 { - script_hash_for_input_script(1) -} - -fn collateral_for_pairs(pairs: u64) -> u64 { - let two_cpt: u64 = safe_multiply(2, param::COLLATERAL_PER_TOKEN); - safe_multiply(pairs, two_cpt) -} - -fn get_issuance_amount(index: u32) -> u64 { - unwrap_right::<(u1, u256)>(unwrap(unwrap(jet::issuance_asset_amount(index)))) -} - -// ============================================================================ -// Spending paths -// ============================================================================ - -/// Path 1: Initial Issuance (state 0 → 1) -fn initial_issuance_path(state: u64, yes_bf: BlindingQuad, no_bf: BlindingQuad) { - assert!(jet::is_zero_64(state)); - ensure_pre_expiry(); - assert!(jet::eq_32(jet::current_index(), 0)); - - let unresolved_hash: u256 = unresolved_script_hash(); - let (yes_in_abf, yes_in_vbf, yes_out_abf, yes_out_vbf): (u256, u256, u256, u256) = yes_bf; - let (no_in_abf, no_in_vbf, no_out_abf, no_out_vbf): (u256, u256, u256, u256) = no_bf; - - verify_input_reissuance_token(0, param::YES_REISSUANCE_TOKEN, yes_in_abf, yes_in_vbf); - verify_input_reissuance_token(1, param::NO_REISSUANCE_TOKEN, no_in_abf, no_in_vbf); - - let yes_amount: u64 = get_issuance_amount(0); - let no_amount: u64 = get_issuance_amount(1); - assert!(jet::eq_64(yes_amount, no_amount)); - - let pairs: u64 = yes_amount; - let total_collateral: u64 = collateral_for_pairs(pairs); - - verify_output_reissuance_token(0, param::YES_REISSUANCE_TOKEN, yes_out_abf, yes_out_vbf); - ensure_output_script_hash_eq(0, unresolved_hash); - - verify_output_reissuance_token(1, param::NO_REISSUANCE_TOKEN, no_out_abf, no_out_vbf); - ensure_output_script_hash_eq(1, unresolved_hash); - - ensure_output_asset_with_amount_eq(2, param::COLLATERAL_ASSET_ID, total_collateral); - ensure_output_script_hash_eq(2, unresolved_hash); - - ensure_fee_output(5); -} - -/// Path 2: Subsequent Issuance (state 1 → 1) -fn subsequent_issuance_path(state: u64, yes_bf: BlindingQuad, no_bf: BlindingQuad) { - assert!(jet::eq_64(state, 1)); - ensure_pre_expiry(); - assert!(jet::eq_32(jet::current_index(), 0)); - - let unresolved_hash: u256 = unresolved_script_hash(); - let (yes_in_abf, yes_in_vbf, yes_out_abf, yes_out_vbf): (u256, u256, u256, u256) = yes_bf; - let (no_in_abf, no_in_vbf, no_out_abf, no_out_vbf): (u256, u256, u256, u256) = no_bf; - - verify_input_reissuance_token(0, param::YES_REISSUANCE_TOKEN, yes_in_abf, yes_in_vbf); - verify_input_reissuance_token(1, param::NO_REISSUANCE_TOKEN, no_in_abf, no_in_vbf); - - let yes_amount: u64 = get_issuance_amount(0); - let no_amount: u64 = get_issuance_amount(1); - assert!(jet::eq_64(yes_amount, no_amount)); - - let pairs: u64 = yes_amount; - let new_collateral: u64 = collateral_for_pairs(pairs); - - assert!(jet::eq_256(get_input_script_hash(2), unresolved_hash)); - let (coll_asset, old_collateral): (u256, u64) = get_input_explicit_asset_amount(2); - assert!(jet::eq_256(coll_asset, param::COLLATERAL_ASSET_ID)); - let total_collateral: u64 = safe_add(old_collateral, new_collateral); - - verify_output_reissuance_token(0, param::YES_REISSUANCE_TOKEN, yes_out_abf, yes_out_vbf); - ensure_output_script_hash_eq(0, unresolved_hash); - - verify_output_reissuance_token(1, param::NO_REISSUANCE_TOKEN, no_out_abf, no_out_vbf); - ensure_output_script_hash_eq(1, unresolved_hash); - - ensure_output_asset_with_amount_eq(2, param::COLLATERAL_ASSET_ID, total_collateral); - ensure_output_script_hash_eq(2, unresolved_hash); - - ensure_fee_output(5); -} - -/// Path 3: Oracle Resolve (state 1 → 2 or 3) -fn oracle_resolve_path( - state: u64, - outcome_yes: bool, - sig: Signature, - yes_bf: BlindingQuad, - no_bf: BlindingQuad -) { - assert!(jet::eq_64(state, 1)); - ensure_pre_expiry(); - assert!(jet::eq_32(jet::current_index(), 0)); - - verify_oracle_signature(outcome_yes, sig); - - let new_state: u64 = match outcome_yes { - true => 2, - false => 3, - }; - let new_state_hash: u256 = script_hash_for_input_script(new_state); - - let (yes_in_abf, yes_in_vbf, yes_out_abf, yes_out_vbf): (u256, u256, u256, u256) = yes_bf; - let (no_in_abf, no_in_vbf, no_out_abf, no_out_vbf): (u256, u256, u256, u256) = no_bf; - - verify_input_reissuance_token(0, param::YES_REISSUANCE_TOKEN, yes_in_abf, yes_in_vbf); - verify_input_reissuance_token(1, param::NO_REISSUANCE_TOKEN, no_in_abf, no_in_vbf); - - verify_output_reissuance_token(0, param::YES_REISSUANCE_TOKEN, yes_out_abf, yes_out_vbf); - ensure_output_script_hash_eq(0, new_state_hash); - - verify_output_reissuance_token(1, param::NO_REISSUANCE_TOKEN, no_out_abf, no_out_vbf); - ensure_output_script_hash_eq(1, new_state_hash); - - let unresolved_hash: u256 = unresolved_script_hash(); - assert!(jet::eq_256(get_input_script_hash(2), unresolved_hash)); - let (coll_asset, coll_amount): (u256, u64) = get_input_explicit_asset_amount(2); - assert!(jet::eq_256(coll_asset, param::COLLATERAL_ASSET_ID)); - ensure_output_asset_with_amount_eq(2, param::COLLATERAL_ASSET_ID, coll_amount); - ensure_output_script_hash_eq(2, new_state_hash); - - assert!(jet::eq_32(jet::num_outputs(), 4)); - ensure_fee_output(3); -} - -/// Path 4: Post-Resolution Redemption (state 2 or 3) -fn post_resolution_redemption_path(state: u64, tokens_burned: u64) { - let is_yes: bool = jet::eq_64(state, 2); - let is_no: bool = jet::eq_64(state, 3); - assert!(or(is_yes, is_no)); - - let state_hash: u256 = script_hash_for_input_script(state); - - let winner_asset: u256 = match is_yes { - true => param::YES_TOKEN_ASSET, - false => param::NO_TOKEN_ASSET, - }; - - let (coll_asset, coll_amount): (u256, u64) = get_input_explicit_asset_amount(0); - assert!(jet::eq_256(coll_asset, param::COLLATERAL_ASSET_ID)); - - let payout: u64 = collateral_for_pairs(tokens_burned); - let remaining: u64 = safe_subtract(coll_amount, payout); - - let num_outputs: u32 = jet::num_outputs(); - let is_partial: bool = not(jet::is_zero_64(remaining)); - - match is_partial { - true => { - ensure_output_asset_with_amount_eq(0, param::COLLATERAL_ASSET_ID, remaining); - ensure_output_script_hash_eq(0, state_hash); - ensure_output_asset_with_amount_eq(1, winner_asset, tokens_burned); - ensure_output_is_op_return(1); - let fee_idx: u32 = safe_subtract_32(num_outputs, 1); - ensure_fee_output(fee_idx); - }, - false => { - ensure_output_asset_with_amount_eq(0, winner_asset, tokens_burned); - ensure_output_is_op_return(0); - let fee_idx: u32 = safe_subtract_32(num_outputs, 1); - ensure_fee_output(fee_idx); - }, - }; -} - -/// Path 5: Expiry Redemption (state 1, post-expiry) -fn expiry_redemption_path(state: u64, tokens_burned: u64, burn_asset: u256) { - assert!(jet::eq_64(state, 1)); - jet::check_lock_height(param::EXPIRY_TIME); - - let unresolved_hash: u256 = unresolved_script_hash(); - - let (coll_asset, coll_amount): (u256, u64) = get_input_explicit_asset_amount(0); - assert!(jet::eq_256(coll_asset, param::COLLATERAL_ASSET_ID)); - - let is_yes: bool = jet::eq_256(burn_asset, param::YES_TOKEN_ASSET); - let is_no: bool = jet::eq_256(burn_asset, param::NO_TOKEN_ASSET); - assert!(or(is_yes, is_no)); - - let payout: u64 = safe_multiply(tokens_burned, param::COLLATERAL_PER_TOKEN); - let remaining: u64 = safe_subtract(coll_amount, payout); - - let num_outputs: u32 = jet::num_outputs(); - let is_partial: bool = not(jet::is_zero_64(remaining)); - - match is_partial { - true => { - ensure_output_asset_with_amount_eq(0, param::COLLATERAL_ASSET_ID, remaining); - ensure_output_script_hash_eq(0, unresolved_hash); - ensure_output_asset_with_amount_eq(1, burn_asset, tokens_burned); - ensure_output_is_op_return(1); - let fee_idx: u32 = safe_subtract_32(num_outputs, 1); - ensure_fee_output(fee_idx); - }, - false => { - ensure_output_asset_with_amount_eq(0, burn_asset, tokens_burned); - ensure_output_is_op_return(0); - let fee_idx: u32 = safe_subtract_32(num_outputs, 1); - ensure_fee_output(fee_idx); - }, - }; -} - -/// Path 6: Cancellation (state 1 → 1 partial, 1 → 0 full) -fn cancellation_path(state: u64, pairs_burned: u64, yes_bf: BlindingQuad, no_bf: BlindingQuad) { - assert!(jet::eq_64(state, 1)); - - let unresolved_hash: u256 = unresolved_script_hash(); - - let (coll_asset, coll_amount): (u256, u64) = get_input_explicit_asset_amount(0); - assert!(jet::eq_256(coll_asset, param::COLLATERAL_ASSET_ID)); - - let refund: u64 = collateral_for_pairs(pairs_burned); - let remaining: u64 = safe_subtract(coll_amount, refund); - - let num_outputs: u32 = jet::num_outputs(); - let is_partial: bool = not(jet::is_zero_64(remaining)); - - match is_partial { - true => { - ensure_output_asset_with_amount_eq(0, param::COLLATERAL_ASSET_ID, remaining); - ensure_output_script_hash_eq(0, unresolved_hash); - ensure_output_asset_with_amount_eq(1, param::YES_TOKEN_ASSET, pairs_burned); - ensure_output_is_op_return(1); - ensure_output_asset_with_amount_eq(2, param::NO_TOKEN_ASSET, pairs_burned); - ensure_output_is_op_return(2); - let fee_idx: u32 = safe_subtract_32(num_outputs, 1); - ensure_fee_output(fee_idx); - }, - false => { - let dormant_hash: u256 = dormant_script_hash(); - let (yes_in_abf, yes_in_vbf, yes_out_abf, yes_out_vbf): (u256, u256, u256, u256) = yes_bf; - let (no_in_abf, no_in_vbf, no_out_abf, no_out_vbf): (u256, u256, u256, u256) = no_bf; - - verify_input_reissuance_token(1, param::YES_REISSUANCE_TOKEN, yes_in_abf, yes_in_vbf); - verify_input_reissuance_token(2, param::NO_REISSUANCE_TOKEN, no_in_abf, no_in_vbf); - - verify_output_reissuance_token(0, param::YES_REISSUANCE_TOKEN, yes_out_abf, yes_out_vbf); - ensure_output_script_hash_eq(0, dormant_hash); - - verify_output_reissuance_token(1, param::NO_REISSUANCE_TOKEN, no_out_abf, no_out_vbf); - ensure_output_script_hash_eq(1, dormant_hash); - - ensure_output_asset_with_amount_eq(2, param::YES_TOKEN_ASSET, pairs_burned); - ensure_output_is_op_return(2); - ensure_output_asset_with_amount_eq(3, param::NO_TOKEN_ASSET, pairs_burned); - ensure_output_is_op_return(3); - - let fee_idx: u32 = safe_subtract_32(num_outputs, 1); - ensure_fee_output(fee_idx); - }, - }; -} - -/// Path 7: Secondary Covenant Input -fn secondary_covenant_input_path() { - let my_index: u32 = jet::current_index(); - let my_hash: u256 = get_input_script_hash(my_index); - let primary_hash: u256 = get_input_script_hash(0); - assert!(jet::eq_256(my_hash, primary_hash)); - ensure_zero_bit(jet::eq_32(my_index, 0)); -} - -// ============================================================================ -// Main entry point -// ============================================================================ - -fn main() { - let state: u64 = witness::STATE; - - let expected_hash: u256 = script_hash_for_input_script(state); - let actual_hash: u256 = get_input_script_hash(jet::current_index()); - assert!(jet::eq_256(expected_hash, actual_hash)); - - // Budget padding: these witnesses increase the serialized witness size so that - // the Simplicity execution budget (witness_stack_bytes + 50 WU) covers the - // program's cost. Without this, the pruned program's cost slightly exceeds - // the budget derived from its natural witness + program size. - let budget_pad_a: u256 = witness::BUDGET_PAD_A; - let budget_pad_b: u256 = witness::BUDGET_PAD_B; - assert!(jet::eq_256(budget_pad_a, budget_pad_b)); - let budget_pad_c: u256 = witness::BUDGET_PAD_C; - let budget_pad_d: u256 = witness::BUDGET_PAD_D; - assert!(jet::eq_256(budget_pad_c, budget_pad_d)); - - // Read all witnesses in main (required by SimplicityHL) - let yes_in_abf: u256 = witness::YES_REISSUANCE_INPUT_ABF; - let yes_in_vbf: u256 = witness::YES_REISSUANCE_INPUT_VBF; - let yes_out_abf: u256 = witness::YES_REISSUANCE_OUTPUT_ABF; - let yes_out_vbf: u256 = witness::YES_REISSUANCE_OUTPUT_VBF; - let no_in_abf: u256 = witness::NO_REISSUANCE_INPUT_ABF; - let no_in_vbf: u256 = witness::NO_REISSUANCE_INPUT_VBF; - let no_out_abf: u256 = witness::NO_REISSUANCE_OUTPUT_ABF; - let no_out_vbf: u256 = witness::NO_REISSUANCE_OUTPUT_VBF; - let oracle_sig: Signature = witness::ORACLE_SIGNATURE; - let oracle_outcome: bool = witness::ORACLE_OUTCOME_YES; - let tokens_burned: u64 = witness::TOKENS_BURNED; - let burn_asset: u256 = witness::BURN_TOKEN_ASSET; - let pairs_burned: u64 = witness::PAIRS_BURNED; - - let yes_bf: BlindingQuad = (yes_in_abf, yes_in_vbf, yes_out_abf, yes_out_vbf); - let no_bf: BlindingQuad = (no_in_abf, no_in_vbf, no_out_abf, no_out_vbf); - - match witness::PATH { - Left(l: Path1to4) => match l { - Left(i: Path1or2) => match i { - Left(u: ()) => initial_issuance_path(state, yes_bf, no_bf), - Right(u: ()) => subsequent_issuance_path(state, yes_bf, no_bf), - }, - Right(r: Path3or4) => match r { - Left(u: ()) => oracle_resolve_path(state, oracle_outcome, oracle_sig, yes_bf, no_bf), - Right(u: ()) => post_resolution_redemption_path(state, tokens_burned), - }, - }, - Right(r: Path5to7) => match r { - Left(rd: Path5or6) => match rd { - Left(u: ()) => expiry_redemption_path(state, tokens_burned, burn_asset), - Right(u: ()) => cancellation_path(state, pairs_burned, yes_bf, no_bf), - }, - Right(u: ()) => secondary_covenant_input_path(), - }, - } -} diff --git a/examples/deadcat/txmanifest.json b/examples/deadcat/txmanifest.json deleted file mode 100644 index 36123ab..0000000 --- a/examples/deadcat/txmanifest.json +++ /dev/null @@ -1,2287 +0,0 @@ -{ - "$schema": "../../schema/txmanifest.schema.json", - "$comment": "Ported from Deadcat.Live (github.com/Resolvr-io/deadcat, src-tauri/crates/deadcat-sdk). prediction_market.simf is a verbatim copy of that crate's contract/prediction_market.simf — do not edit it, every byte feeds the CMR and therefore all four covenant addresses. The upstream SDK builds these transactions in Rust (src/pset/*.rs); this manifest is the same seven spending paths expressed declaratively. Deadcat's OTHER covenant, maker_order.simf, is deliberately NOT modelled: it tweaks the MAKER's key as the taproot internal key, while this engine hardcodes the NUMS internal key (covenant.rs::NUMS_KEY_BYTES), so no address it computed would be correct.", - "manifest_version": "0.3.0", - "protocol": "deadcat-prediction-market", - "description": "Deadcat — a binary (YES/NO) prediction market on Liquid. Collateral is locked in a covenant that mints matched YES/NO token pairs at 2 x COLLATERAL_PER_TOKEN per pair; an off-chain oracle commits the outcome ON-CHAIN as a state transition, and winners then burn tokens to draw the whole pair's collateral. The market's state (0 dormant, 1 unresolved, 2 resolved-YES, 3 resolved-NO) is not stored in a variable — it is a tapdata leaf in the covenant's tap tree, so each state is a DIFFERENT address and the covenant proves its own state by comparing the address it is being spent from. Modelled as one template: one instance per market.", - "chain": "liquid", - "requires": ["simplicity"], - "simplicity_hl": { - "$comment": "Deadcat compiles with debug symbols OFF (contract.rs: template.instantiate(args, false)). Flipping this changes every fail-node commitment, hence the CMR, hence all four addresses.", - "debug_symbols": false - }, - "utxo_types": { - "market_dormant": { - "description": "STATE 0 — DORMANT. Holds only the two reissuance tokens, no collateral. This is where CreateMarket parks them and where a full Cancel returns them. The address is the Simplicity leaf branched with a tapdata leaf carrying the u64 0 (big-endian, 8 bytes) — see taproot.rs::tapdata_hash. Nothing else about the address differs between the four states.", - "script": { - "type": "simplicity", - "source": "./prediction_market.simf", - "compile_params": { - "ORACLE_PUBLIC_KEY": "ORACLE_PUBLIC_KEY", - "COLLATERAL_ASSET_ID": "COLLATERAL_ASSET_ID", - "YES_TOKEN_ASSET": "YES_TOKEN_ASSET", - "NO_TOKEN_ASSET": "NO_TOKEN_ASSET", - "YES_REISSUANCE_TOKEN": "YES_REISSUANCE_TOKEN", - "NO_REISSUANCE_TOKEN": "NO_REISSUANCE_TOKEN", - "COLLATERAL_PER_TOKEN": "COLLATERAL_PER_TOKEN", - "EXPIRY_TIME": "EXPIRY_TIME" - }, - "extra_leaves": [ - { - "$comment": "state = 0. jet::tapdata_init() + sha_256_ctx_8_add_8(state) hashes exactly 8 big-endian bytes, so no pad_to here — unlike the lending example's 32-byte storage slots.", - "type": "tapdata", - "payload": [ - { - "value": "0", - "type": "u64", - "endian": "be" - } - ] - } - ] - }, - "asset": "YES_REISSUANCE_TOKEN" - }, - "market_unresolved": { - "description": "STATE 1 — UNRESOLVED, the live market. Holds three UTXOs: the YES reissuance token, the NO reissuance token, and the single consolidated collateral UTXO. 'Single' is load-bearing: every issuance path must consume the existing collateral UTXO and re-emit one consolidated output, so the oracle can move the whole market to a resolved address in one transaction (design doc section 6).", - "script": { - "type": "simplicity", - "source": "./prediction_market.simf", - "compile_params": { - "ORACLE_PUBLIC_KEY": "ORACLE_PUBLIC_KEY", - "COLLATERAL_ASSET_ID": "COLLATERAL_ASSET_ID", - "YES_TOKEN_ASSET": "YES_TOKEN_ASSET", - "NO_TOKEN_ASSET": "NO_TOKEN_ASSET", - "YES_REISSUANCE_TOKEN": "YES_REISSUANCE_TOKEN", - "NO_REISSUANCE_TOKEN": "NO_REISSUANCE_TOKEN", - "COLLATERAL_PER_TOKEN": "COLLATERAL_PER_TOKEN", - "EXPIRY_TIME": "EXPIRY_TIME" - }, - "extra_leaves": [ - { - "type": "tapdata", - "payload": [ - { - "value": "1", - "type": "u64", - "endian": "be" - } - ] - } - ] - }, - "asset": "COLLATERAL_ASSET_ID" - }, - "market_resolved_yes": { - "description": "STATE 2 — RESOLVED YES. The oracle attested YES, so YES tokens redeem at 2 x COLLATERAL_PER_TOKEN each and NO tokens are worth nothing. There is no path from here to state 3: that is the whole point of committing the outcome on-chain rather than checking the oracle signature at redemption time (design doc section 9.2).", - "script": { - "type": "simplicity", - "source": "./prediction_market.simf", - "compile_params": { - "ORACLE_PUBLIC_KEY": "ORACLE_PUBLIC_KEY", - "COLLATERAL_ASSET_ID": "COLLATERAL_ASSET_ID", - "YES_TOKEN_ASSET": "YES_TOKEN_ASSET", - "NO_TOKEN_ASSET": "NO_TOKEN_ASSET", - "YES_REISSUANCE_TOKEN": "YES_REISSUANCE_TOKEN", - "NO_REISSUANCE_TOKEN": "NO_REISSUANCE_TOKEN", - "COLLATERAL_PER_TOKEN": "COLLATERAL_PER_TOKEN", - "EXPIRY_TIME": "EXPIRY_TIME" - }, - "extra_leaves": [ - { - "type": "tapdata", - "payload": [ - { - "value": "2", - "type": "u64", - "endian": "be" - } - ] - } - ] - }, - "asset": "COLLATERAL_ASSET_ID" - }, - "market_resolved_no": { - "description": "STATE 3 — RESOLVED NO. Mirror of state 2: NO tokens redeem, YES tokens are worthless.", - "script": { - "type": "simplicity", - "source": "./prediction_market.simf", - "compile_params": { - "ORACLE_PUBLIC_KEY": "ORACLE_PUBLIC_KEY", - "COLLATERAL_ASSET_ID": "COLLATERAL_ASSET_ID", - "YES_TOKEN_ASSET": "YES_TOKEN_ASSET", - "NO_TOKEN_ASSET": "NO_TOKEN_ASSET", - "YES_REISSUANCE_TOKEN": "YES_REISSUANCE_TOKEN", - "NO_REISSUANCE_TOKEN": "NO_REISSUANCE_TOKEN", - "COLLATERAL_PER_TOKEN": "COLLATERAL_PER_TOKEN", - "EXPIRY_TIME": "EXPIRY_TIME" - }, - "extra_leaves": [ - { - "type": "tapdata", - "payload": [ - { - "value": "3", - "type": "u64", - "endian": "be" - } - ] - } - ] - }, - "asset": "COLLATERAL_ASSET_ID" - } - }, - "contract_templates": { - "binary_market": { - "description": "One binary prediction market. IssueReissuanceTokens is the constructor: it mints the minting rights and writes the instance file that defines the market. CreateMarket then locks those rights into the covenant (a plain Elements transaction — no covenant runs), InitialIssuance brings the market to life, then anyone may MintPairs or Cancel while it is unresolved. It ends one of two ways: the oracle resolves it (ResolveYes / ResolveNo, then RedeemYes / RedeemNo), or it expires unresolved and both sides redeem at half rate (RedeemExpired). All eight fields below are compile params of prediction_market.simf, so changing any one of them is a different market at four different addresses.", - "fields": { - "ORACLE_PUBLIC_KEY": { - "type": "pubkey", - "description": "X-only BIP340 key the resolve path checks. In Deadcat this is the aggregate key of a 2-of-3 FROST committee — on-chain it is just one key, the threshold signing happens off-chain." - }, - "COLLATERAL_ASSET_ID": { - "type": "liquid.asset_id", - "description": "The asset backing the market, normally L-BTC. Also the asset the network fee is paid in, which is why the covenant's fee-output checks are satisfiable." - }, - "COLLATERAL_PER_TOKEN": { - "type": "u64", - "description": "Satoshis backing ONE token. A pair costs 2 x this. Deliberately per-token, not per-pair, so the covenant only ever multiplies — no division means no rounding bug (design doc section 4.3)." - }, - "EXPIRY_TIME": { - "type": "u32", - "description": "Absolute block height. Before it, the oracle may resolve and anyone may mint. At or after it, RedeemExpired unlocks and every token — winning or losing — redeems at 1 x COLLATERAL_PER_TOKEN." - }, - "YES_TOKEN_ASSET": { - "type": "liquid.asset_id", - "description": "Asset id of the YES outcome token, derived from the outpoint pinned as CreateMarket in[0]. Also half of MARKET_ID = sha256(YES || NO), the oracle's domain separator." - }, - "NO_TOKEN_ASSET": { - "type": "liquid.asset_id", - "description": "Asset id of the NO outcome token, derived from the outpoint pinned as CreateMarket in[1]." - }, - "YES_REISSUANCE_TOKEN": { - "type": "liquid.asset_id", - "description": "The reissuance token for YES. Holding it is what permits minting more YES, and it only ever lives at a covenant address — that is the enforcement mechanism behind collateral consolidation." - }, - "NO_REISSUANCE_TOKEN": { - "type": "liquid.asset_id", - "description": "The reissuance token for NO. Same role as YES_REISSUANCE_TOKEN." - }, - "YES_ISSUANCE_ENTROPY": { - "type": "bytes32", - "description": "Issuance entropy of the YES mint — fast_merkle_root([sha256d(defining outpoint), contract_hash]), the value YES_TOKEN_ASSET itself is derived from. Every later reissuance needs it and NOTHING on chain carries it: the reissuance token UTXO holds no trace of the outpoint that created it. Captured by the constructor at the one moment it exists." - }, - "NO_ISSUANCE_ENTROPY": { - "type": "bytes32", - "description": "Issuance entropy of the NO mint. Same role, from the other defining outpoint." - } - }, - "actions": { - "IssueReissuanceTokens": { - "$comment": "The constructor: it is this action, not CreateMarket, that writes the instance file. That is forced by where the asset ids come from — they are derived from the outpoints THIS transaction spends, and nothing downstream can recover them (the reissuance token id and the asset id are sibling hashes of the entropy, so you cannot walk from one to the other). Capturing them here — create_instance reads them straight off the two inputs via $inputs.. — is the only point at which they exist and can still be recorded.\n\nWhy the bootstrap is two transactions at all: CreateMarket pays to the state-0 covenant address, and that address is a function of all four asset ids, which are functions of the outpoints being spent. The engine snapshots compile params BEFORE it resolves any input, so one action cannot both mint an asset and pay it to an address derived from that asset. This action sidesteps it by paying only to the wallet. Upstream Deadcat does both in one transaction (pset/creation.rs) because it computes the ids in Rust before building anything; the two-transaction bootstrap lands the market in exactly the same on-chain state.", - "description": "Step 1 of 2 in bootstrapping a market, and the action that defines it. Mints the YES and NO reissuance tokens — 1 unit each — from two of your L-BTC UTXOs and keeps them in your wallet, then writes the instance file recording the market's full definition: the four asset ids this transaction just fixed, plus the terms you supply below. NOTE: it mints ZERO units of the YES and NO tokens themselves. Only the minting rights exist at this point; outcome tokens appear at InitialIssuance, and they have to, because tokens minted here would be backed by no collateral at all. Run CreateMarket next to lock the two tokens into the covenant.", - "intent": "define a market backed by {params.COLLATERAL_ASSET_ID:symbol} and mint its minting rights", - "params": { - "ORACLE_PUBLIC_KEY": { - "type": "pubkey", - "description": "The oracle's x-only key. Baked into every one of this market's four addresses, so it cannot be changed later." - }, - "COLLATERAL_ASSET_ID": { - "type": "liquid.asset_id", - "description": "Asset backing the market. Use the L-BTC policy asset unless you know why you want something else." - }, - "COLLATERAL_PER_TOKEN": { - "type": "u64", - "default": "1000", - "description": "Satoshis behind one token. A YES/NO pair therefore costs twice this, and a winning token pays out twice this." - }, - "EXPIRY_TIME": { - "type": "u32", - "description": "Block height after which the market expires unresolved and everyone redeems at half rate. Leave the oracle real time to attest." - } - }, - "inputs": [ - { - "id": "yes_defining_in", - "description": "Wallet L-BTC UTXO whose outpoint defines the YES asset pair: YES_TOKEN_ASSET = AssetId::new_issuance(outpoint, zero contract hash) and YES_REISSUANCE_TOKEN = AssetId::new_reissuance_token(outpoint, zero contract hash, confidential=false). Issues 0 asset units and 1 inflation (reissuance) token — YES tokens themselves are not minted until InitialIssuance reissues against this one.", - "utxo_source": "wallet", - "asset": "lbtc", - "required_index": 0, - "issuance": { - "kind": "new", - "asset_amount_sat": 0, - "inflation_amount_sat": 1 - }, - "ui": { - "label": "input that defines the YES asset", - "role": "issuance" - } - }, - { - "id": "no_defining_in", - "description": "Wallet L-BTC UTXO whose outpoint defines the NO asset pair, the same way. Must be a different outpoint from yes_defining_in, or YES and NO would be the same asset.", - "utxo_source": "wallet", - "asset": "lbtc", - "required_index": 1, - "issuance": { - "kind": "new", - "asset_amount_sat": 0, - "inflation_amount_sat": 1 - }, - "ui": { - "label": "input that defines the NO asset", - "role": "issuance" - } - } - ], - "outputs": [ - { - "id": "yes_reissuance_out", - "description": "The YES reissuance token, 1 unit, to your wallet. The asset reference reads the id the engine derived from in[0] once it resolved. `inputs.` is the explicit spelling of an input reference; the bare `yes_defining_in.reissuance_token` also works but is ambiguous by shape with every other namespace.", - "destination": "wallet", - "asset": "inputs.yes_defining_in.reissuance_token", - "amount_sat": 1, - "confidential": false, - "required_index": 0, - "ui": { - "label": "YES minting right, held by you", - "role": "reissuance_token" - } - }, - { - "id": "no_reissuance_out", - "description": "The NO reissuance token, 1 unit, to your wallet.", - "destination": "wallet", - "asset": "inputs.no_defining_in.reissuance_token", - "amount_sat": 1, - "confidential": false, - "required_index": 1, - "ui": { - "label": "NO minting right, held by you", - "role": "reissuance_token" - } - }, - { - "id": "lbtc_change", - "description": "L-BTC change from the two defining inputs.", - "destination": "change", - "asset": "lbtc", - "optional": true, - "ui": { - "label": "change returned to you", - "role": "change" - } - } - ], - "create_instance": { - "$comment": "The four asset ids are read straight off the inputs that created them — `$inputs..` is a string lookup, unlike a bare expression, which is arithmetic and would reject a 32-byte id. Note `issued_asset`, NOT `asset`: on an input carrying an issuance those differ, and `asset` is the asset of the UTXO being spent (L-BTC here). The four terms come from this action's params. All eight are compile params of prediction_market.simf, so from here the market's four covenant addresses are fully determined and every later action can derive them from the instance file alone.", - "fields": { - "ORACLE_PUBLIC_KEY": "$params.ORACLE_PUBLIC_KEY", - "COLLATERAL_ASSET_ID": "$params.COLLATERAL_ASSET_ID", - "COLLATERAL_PER_TOKEN": "$params.COLLATERAL_PER_TOKEN", - "EXPIRY_TIME": "$params.EXPIRY_TIME", - "YES_TOKEN_ASSET": "$inputs.yes_defining_in.issued_asset", - "NO_TOKEN_ASSET": "$inputs.no_defining_in.issued_asset", - "YES_REISSUANCE_TOKEN": "$inputs.yes_defining_in.reissuance_token", - "NO_REISSUANCE_TOKEN": "$inputs.no_defining_in.reissuance_token", - "YES_ISSUANCE_ENTROPY": "$inputs.yes_defining_in.issuance_entropy", - "NO_ISSUANCE_ENTROPY": "$inputs.no_defining_in.issuance_entropy" - } - } - }, - "CreateMarket": { - "$comment": "Run IssueReissuanceTokens first: it writes the instance file this action reads. Every value here comes from `instance.*` — the four asset ids and the four terms — which is why this action takes no params at all. Those fields are loaded into the context before the engine snapshots compile params, so the state-0 address computes correctly even though the assets were minted in a different transaction.", - "description": "Step 2 of 2: lock the minting rights into the covenant. A PLAIN Elements transaction — no covenant input, nothing validated on-chain, so a malformed creation simply produces unspendable UTXOs — that moves both reissuance tokens from your wallet to the state-0 (dormant) address. No outcome tokens are minted and no collateral is deposited; that is InitialIssuance's job. Anyone evaluating a Deadcat market should re-check this transaction by hand: the covenant vouches for everything after it, and for nothing in it.", - "intent": "lock the minting rights into a market backed by {instance.COLLATERAL_ASSET_ID:symbol}", - "inputs": [ - { - "id": "yes_reissuance_in", - "description": "The YES reissuance token in your wallet, minted by IssueReissuanceTokens. Nothing is issued in this transaction — the token merely changes hands, from you to the covenant.", - "utxo_source": "wallet", - "asset": "instance.YES_REISSUANCE_TOKEN", - "amount_sat": 1, - "required_index": 0, - "ui": { - "label": "your YES minting right", - "role": "reissuance_token" - } - }, - { - "id": "no_reissuance_in", - "description": "The NO reissuance token in your wallet, from the same run.", - "utxo_source": "wallet", - "asset": "instance.NO_REISSUANCE_TOKEN", - "amount_sat": 1, - "required_index": 1, - "ui": { - "label": "your NO minting right", - "role": "reissuance_token" - } - }, - { - "id": "fee_input", - "description": "Wallet L-BTC UTXO covering the network fee. Needed here because both other inputs are token UTXOs with no L-BTC in them.", - "utxo_source": "wallet", - "asset": "lbtc", - "ui": { - "label": "input used for paying fees", - "role": "fee" - } - } - ], - "outputs": [ - { - "id": "yes_reissuance_out", - "description": "The YES reissuance token, 1 unit, parked at the state-0 (dormant) address. From here it can only ever move by a covenant spend.", - "destination": { - "utxo_type": "market_dormant" - }, - "asset": "instance.YES_REISSUANCE_TOKEN", - "amount_sat": 1, - "required_index": 0, - "ui": { - "label": "YES minting right, locked in the market", - "role": "reissuance_token" - } - }, - { - "id": "no_reissuance_out", - "description": "The NO reissuance token, 1 unit, at the same state-0 address.", - "destination": { - "utxo_type": "market_dormant" - }, - "asset": "instance.NO_REISSUANCE_TOKEN", - "amount_sat": 1, - "required_index": 1, - "ui": { - "label": "NO minting right, locked in the market", - "role": "reissuance_token" - } - }, - { - "id": "creation_change", - "description": "L-BTC change from the fee input.", - "destination": "change", - "asset": "lbtc", - "optional": true, - "ui": { - "label": "change returned to you", - "role": "change" - } - } - ] - }, - "PrepareInitialIssuance": { - "$comment": "A funding action, not a protocol one: nothing here touches the covenant. It exists because InitialIssuance declares no change output — the covenant pins the fee at output index 5, and a change output would displace it — so whatever L-BTC the collateral input carries beyond the collateral IS the fee. Feeding it an ordinary wallet UTXO therefore pays the entire remainder to miners. This action cuts a UTXO of exactly the right size first, which turns that behaviour from a hazard into an intent: the surplus becomes FEE_ALLOWANCE and nothing more.", - "description": "Cut an exactly-sized collateral UTXO for InitialIssuance. Reads COLLATERAL_PER_TOKEN and COLLATERAL_ASSET_ID from the instance and takes the number of pairs you intend to mint, so the output is PAIRS x 2 x COLLATERAL_PER_TOKEN plus a fee allowance — the exact amount InitialIssuance consumes. Run this first, then InitialIssuance, which will select the UTXO this produced (pin it with --input collateral_in=: if your wallet holds other L-BTC of a similar size).", - "intent": "cut a {params.PAIRS}-pair collateral UTXO for opening the market", - "params": { - "PAIRS": { - "type": "u64", - "description": "How many YES/NO pairs InitialIssuance will mint. Must match the PAIRS you pass there — a mismatch just means the sizing is wrong, and the surplus or shortfall shows up as a fee error." - }, - "FEE_ALLOWANCE": { - "type": "u64", - "default": "2000", - "description": "Extra L-BTC to include on top of the collateral, which becomes InitialIssuance's fee. That transaction is large (three inputs, two of them Simplicity covenant spends with sizeable witnesses), so leave real headroom. Anything unspent here is paid to miners, so do not inflate it either." - } - }, - "inputs": [ - { - "id": "funding_in", - "description": "Wallet L-BTC UTXO to cut from. Must hold at least the collateral plus the fee allowance plus this transaction's own fee.", - "utxo_source": "wallet", - "asset": "instance.COLLATERAL_ASSET_ID", - "amount_sat": { - "min_amount": "params.PAIRS * 2 * instance.COLLATERAL_PER_TOKEN + params.FEE_ALLOWANCE" - }, - "ui": { - "label": "wallet funds to cut the collateral from", - "role": "funding" - } - } - ], - "outputs": [ - { - "id": "sized_collateral", - "description": "The exactly-sized UTXO InitialIssuance will spend as its collateral input: PAIRS x 2 x COLLATERAL_PER_TOKEN of collateral, plus FEE_ALLOWANCE which becomes that transaction's fee.", - "destination": "wallet", - "asset": "instance.COLLATERAL_ASSET_ID", - "amount_sat": "params.PAIRS * 2 * instance.COLLATERAL_PER_TOKEN + params.FEE_ALLOWANCE", - "ui": { - "label": "collateral sized for opening the market", - "role": "collateral" - } - }, - { - "id": "funding_change", - "description": "The rest of the funding UTXO, back to your wallet. Present precisely because this action is allowed a change output, unlike InitialIssuance.", - "destination": "change", - "asset": "instance.COLLATERAL_ASSET_ID", - "optional": true, - "ui": { - "label": "change returned to you", - "role": "change" - } - } - ] - }, - "InitialIssuance": { - "$comment": "Path 1. Fee MUST land at output index 5, which it does only because no change output is declared here: the engine appends declared outputs, then any change, then the fee. Declaring a change output would push the fee to index 6 and the covenant would reject the spend. Size the collateral input exactly (see `prepare`) — surplus L-BTC is swallowed by the fee.", - "description": "First covenant-validated transaction: state 0 -> 1. Reissues the first batch of YES/NO pairs, deposits their collateral, and moves all three covenant UTXOs to the state-1 address. Collateral comes from your wallet, not from the covenant — in the dormant state there is no collateral UTXO yet.", - "intent": "open the market: mint {params.PAIRS} YES/NO pairs and lock their collateral", - "params": { - "PAIRS": { - "type": "u64", - "description": "How many matched YES/NO pairs to mint. You deposit PAIRS x 2 x COLLATERAL_PER_TOKEN and receive PAIRS YES tokens and PAIRS NO tokens — you are the market's first counterparty on both sides, and you can sell either leg." - } - }, - "inputs": [ - { - "id": "yes_reissuance_in", - "description": "The YES reissuance token at the state-0 address, input 0 — the covenant asserts current_index == 0 for this path. Its reissuance mints PAIRS YES tokens.", - "utxo_source": { - "utxo_type": "market_dormant" - }, - "asset": "instance.YES_REISSUANCE_TOKEN", - "amount_sat": 1, - "required_index": 0, - "issuance": { - "kind": "reissue", - "asset_amount_sat": "params.PAIRS", - "entropy": "instance.YES_ISSUANCE_ENTROPY", - "issued_asset": "instance.YES_TOKEN_ASSET" - }, - "witnesses": { - "STATE": { - "type": "simplicityhl", - "simplicity_type": "u64", - "value": "0", - "description": "Claimed state. Not trusted: main() recomputes the state-0 address from it and asserts the input is actually being spent from there, so lying is impossible rather than merely detectable." - }, - "PATH": { - "type": "simplicityhl", - "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", - "value": "Left(Left(Left(())))", - "description": "Path 1 — initial issuance. The seven paths are a nested Either tree; see witness.rs::build_path_value upstream." - }, - "BUDGET_PAD_A": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_B": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_C": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_D": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BURN_TOKEN_ASSET": "unused", - "ORACLE_OUTCOME_YES": "unused", - "ORACLE_SIGNATURE": "unused", - "PAIRS_BURNED": "unused", - "TOKENS_BURNED": "unused" - }, - "ui": { - "label": "YES minting right", - "role": "reissuance_token" - } - }, - { - "id": "no_reissuance_in", - "description": "The NO reissuance token, input 1. It runs path 7 (secondary covenant input), which only proves it is spent from the same address as input 0 and leaves every transaction-level check to input 0's path.", - "utxo_source": { - "utxo_type": "market_dormant" - }, - "asset": "instance.NO_REISSUANCE_TOKEN", - "amount_sat": 1, - "required_index": 1, - "issuance": { - "kind": "reissue", - "asset_amount_sat": "params.PAIRS", - "entropy": "instance.NO_ISSUANCE_ENTROPY", - "issued_asset": "instance.NO_TOKEN_ASSET" - }, - "witnesses": { - "STATE": { - "type": "simplicityhl", - "simplicity_type": "u64", - "value": "0" - }, - "PATH": { - "type": "simplicityhl", - "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", - "value": "Right(Right(()))", - "description": "Path 7 — secondary covenant input." - }, - "BUDGET_PAD_A": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_B": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_C": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_D": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BURN_TOKEN_ASSET": "unused", - "NO_REISSUANCE_INPUT_ABF": "unused", - "NO_REISSUANCE_INPUT_VBF": "unused", - "NO_REISSUANCE_OUTPUT_ABF": "unused", - "NO_REISSUANCE_OUTPUT_VBF": "unused", - "ORACLE_OUTCOME_YES": "unused", - "ORACLE_SIGNATURE": "unused", - "PAIRS_BURNED": "unused", - "TOKENS_BURNED": "unused", - "YES_REISSUANCE_INPUT_ABF": "unused", - "YES_REISSUANCE_INPUT_VBF": "unused", - "YES_REISSUANCE_OUTPUT_ABF": "unused", - "YES_REISSUANCE_OUTPUT_VBF": "unused" - }, - "ui": { - "label": "NO minting right", - "role": "reissuance_token" - } - }, - { - "id": "collateral_in", - "description": "Your collateral, input 2. Unlike every later issuance this comes from the wallet: the dormant market holds no collateral to consolidate with.", - "utxo_source": "wallet", - "asset": "instance.COLLATERAL_ASSET_ID", - "amount_sat": { - "min_amount": "params.PAIRS * 2 * instance.COLLATERAL_PER_TOKEN" - }, - "required_index": 2, - "ui": { - "label": "collateral you are depositing", - "role": "collateral" - } - } - ], - "outputs": [ - { - "id": "yes_reissuance_out", - "description": "YES reissuance token cycled forward to the state-1 address (output 0).", - "destination": { - "utxo_type": "market_unresolved" - }, - "asset": "instance.YES_REISSUANCE_TOKEN", - "amount_sat": 1, - "required_index": 0, - "ui": { - "label": "YES minting right, market now live", - "role": "reissuance_token" - } - }, - { - "id": "no_reissuance_out", - "description": "NO reissuance token cycled forward to the state-1 address (output 1).", - "destination": { - "utxo_type": "market_unresolved" - }, - "asset": "instance.NO_REISSUANCE_TOKEN", - "amount_sat": 1, - "required_index": 1, - "ui": { - "label": "NO minting right, market now live", - "role": "reissuance_token" - } - }, - { - "id": "collateral_out", - "description": "The market's one and only collateral UTXO (output 2), at the state-1 address. Exactly PAIRS x 2 x COLLATERAL_PER_TOKEN — the covenant computes this itself from the issuance amount and rejects anything else.", - "destination": { - "utxo_type": "market_unresolved" - }, - "asset": "instance.COLLATERAL_ASSET_ID", - "amount_sat": "params.PAIRS * 2 * instance.COLLATERAL_PER_TOKEN", - "required_index": 2, - "ui": { - "label": "collateral backing the market", - "role": "collateral" - } - }, - { - "id": "yes_tokens_out", - "description": "The minted YES tokens (output 3), to your wallet. Unconstrained by the covenant beyond asset balance — it only cares that YES and NO were minted in equal amounts and fully collateralised.", - "destination": "wallet", - "asset": "instance.YES_TOKEN_ASSET", - "amount_sat": "params.PAIRS", - "required_index": 3, - "ui": { - "label": "YES tokens minted to you", - "role": "outcome_token" - } - }, - { - "id": "no_tokens_out", - "description": "The minted NO tokens (output 4), to your wallet.", - "destination": "wallet", - "asset": "instance.NO_TOKEN_ASSET", - "amount_sat": "params.PAIRS", - "required_index": 4, - "ui": { - "label": "NO tokens minted to you", - "role": "outcome_token" - } - } - ] - }, - "MintPairs": { - "$comment": "Path 2. Same index-5 fee requirement as InitialIssuance, so again no change output is declared. The difference from path 1 is input 2: the existing collateral UTXO is consumed and re-emitted as old + new, which is what keeps the market to exactly one collateral UTXO.", - "description": "Mint more pairs into a live market (state 1 -> 1). Permissionless: anyone who deposits collateral gets tokens, there is no issuer and no allowlist. Consumes the market's collateral UTXO and re-emits the consolidated total.", - "intent": "mint {params.PAIRS} more YES/NO pairs into the market", - "params": { - "PAIRS": { - "type": "u64", - "description": "Additional pairs to mint. You deposit PAIRS x 2 x COLLATERAL_PER_TOKEN of new collateral on top of whatever the market already holds." - } - }, - "inputs": [ - { - "id": "yes_reissuance_in", - "description": "YES reissuance token from state 1, input 0 (the primary — current_index must be 0).", - "utxo_source": { - "utxo_type": "market_unresolved" - }, - "asset": "instance.YES_REISSUANCE_TOKEN", - "amount_sat": 1, - "required_index": 0, - "issuance": { - "kind": "reissue", - "asset_amount_sat": "params.PAIRS", - "entropy": "instance.YES_ISSUANCE_ENTROPY", - "issued_asset": "instance.YES_TOKEN_ASSET" - }, - "witnesses": { - "STATE": { - "type": "simplicityhl", - "simplicity_type": "u64", - "value": "1" - }, - "PATH": { - "type": "simplicityhl", - "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", - "value": "Left(Left(Right(())))", - "description": "Path 2 — subsequent issuance." - }, - "BUDGET_PAD_A": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_B": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_C": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_D": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BURN_TOKEN_ASSET": "unused", - "ORACLE_OUTCOME_YES": "unused", - "ORACLE_SIGNATURE": "unused", - "PAIRS_BURNED": "unused", - "TOKENS_BURNED": "unused" - }, - "ui": { - "label": "YES minting right", - "role": "reissuance_token" - } - }, - { - "id": "no_reissuance_in", - "description": "NO reissuance token from state 1, input 1, on path 7.", - "utxo_source": { - "utxo_type": "market_unresolved" - }, - "asset": "instance.NO_REISSUANCE_TOKEN", - "amount_sat": 1, - "required_index": 1, - "issuance": { - "kind": "reissue", - "asset_amount_sat": "params.PAIRS", - "entropy": "instance.NO_ISSUANCE_ENTROPY", - "issued_asset": "instance.NO_TOKEN_ASSET" - }, - "witnesses": { - "STATE": { - "type": "simplicityhl", - "simplicity_type": "u64", - "value": "1" - }, - "PATH": { - "type": "simplicityhl", - "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", - "value": "Right(Right(()))", - "description": "Path 7 — secondary covenant input." - }, - "BUDGET_PAD_A": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_B": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_C": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_D": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BURN_TOKEN_ASSET": "unused", - "NO_REISSUANCE_INPUT_ABF": "unused", - "NO_REISSUANCE_INPUT_VBF": "unused", - "NO_REISSUANCE_OUTPUT_ABF": "unused", - "NO_REISSUANCE_OUTPUT_VBF": "unused", - "ORACLE_OUTCOME_YES": "unused", - "ORACLE_SIGNATURE": "unused", - "PAIRS_BURNED": "unused", - "TOKENS_BURNED": "unused", - "YES_REISSUANCE_INPUT_ABF": "unused", - "YES_REISSUANCE_INPUT_VBF": "unused", - "YES_REISSUANCE_OUTPUT_ABF": "unused", - "YES_REISSUANCE_OUTPUT_VBF": "unused" - }, - "ui": { - "label": "NO minting right", - "role": "reissuance_token" - } - }, - { - "id": "existing_collateral_in", - "description": "The market's current collateral UTXO, input 2 — the covenant checks this index's script hash against the state-1 address by hand. Also on path 7. Its amount comes from the state file.", - "utxo_source": { - "utxo_type": "market_unresolved" - }, - "asset": "instance.COLLATERAL_ASSET_ID", - "required_index": 2, - "witnesses": { - "STATE": { - "type": "simplicityhl", - "simplicity_type": "u64", - "value": "1" - }, - "PATH": { - "type": "simplicityhl", - "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", - "value": "Right(Right(()))", - "description": "Path 7 — secondary covenant input." - }, - "BUDGET_PAD_A": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_B": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_C": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_D": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BURN_TOKEN_ASSET": "unused", - "NO_REISSUANCE_INPUT_ABF": "unused", - "NO_REISSUANCE_INPUT_VBF": "unused", - "NO_REISSUANCE_OUTPUT_ABF": "unused", - "NO_REISSUANCE_OUTPUT_VBF": "unused", - "ORACLE_OUTCOME_YES": "unused", - "ORACLE_SIGNATURE": "unused", - "PAIRS_BURNED": "unused", - "TOKENS_BURNED": "unused", - "YES_REISSUANCE_INPUT_ABF": "unused", - "YES_REISSUANCE_INPUT_VBF": "unused", - "YES_REISSUANCE_OUTPUT_ABF": "unused", - "YES_REISSUANCE_OUTPUT_VBF": "unused" - }, - "ui": { - "label": "collateral already in the market", - "role": "collateral" - } - }, - { - "id": "new_collateral_in", - "description": "Your new collateral, input 3, from the wallet.", - "utxo_source": "wallet", - "asset": "instance.COLLATERAL_ASSET_ID", - "amount_sat": { - "min_amount": "params.PAIRS * 2 * instance.COLLATERAL_PER_TOKEN" - }, - "required_index": 3, - "ui": { - "label": "collateral you are adding", - "role": "collateral" - } - } - ], - "outputs": [ - { - "id": "yes_reissuance_out", - "description": "YES reissuance token back to state 1 (output 0).", - "destination": { - "utxo_type": "market_unresolved" - }, - "asset": "instance.YES_REISSUANCE_TOKEN", - "amount_sat": 1, - "required_index": 0, - "ui": { - "label": "YES minting right", - "role": "reissuance_token" - } - }, - { - "id": "no_reissuance_out", - "description": "NO reissuance token back to state 1 (output 1).", - "destination": { - "utxo_type": "market_unresolved" - }, - "asset": "instance.NO_REISSUANCE_TOKEN", - "amount_sat": 1, - "required_index": 1, - "ui": { - "label": "NO minting right", - "role": "reissuance_token" - } - }, - { - "id": "collateral_out", - "description": "The re-consolidated collateral UTXO (output 2): the old amount plus this issuance's deposit, computed by the covenant as safe_add(old, pairs x 2 x CPT).", - "destination": { - "utxo_type": "market_unresolved" - }, - "asset": "instance.COLLATERAL_ASSET_ID", - "amount_sat": "existing_collateral_in.amount_sat + params.PAIRS * 2 * instance.COLLATERAL_PER_TOKEN", - "required_index": 2, - "ui": { - "label": "all collateral backing the market", - "role": "collateral" - } - }, - { - "id": "yes_tokens_out", - "description": "Newly minted YES tokens to your wallet (output 3).", - "destination": "wallet", - "asset": "instance.YES_TOKEN_ASSET", - "amount_sat": "params.PAIRS", - "required_index": 3, - "ui": { - "label": "YES tokens minted to you", - "role": "outcome_token" - } - }, - { - "id": "no_tokens_out", - "description": "Newly minted NO tokens to your wallet (output 4).", - "destination": "wallet", - "asset": "instance.NO_TOKEN_ASSET", - "amount_sat": "params.PAIRS", - "required_index": 4, - "ui": { - "label": "NO tokens minted to you", - "role": "outcome_token" - } - } - ] - }, - "ResolveYes": { - "$comment": "Path 3 with ORACLE_OUTCOME_YES = true. Modelled as two actions rather than one with a runtime outcome because the destination address IS the outcome — state 2 and state 3 are different covenant addresses, and the engine has no conditional destination. The covenant demands exactly 4 outputs, so no change output may be declared: the fee absorbs any L-BTC surplus, which lands it at index 3 as required.", - "description": "Commit a YES outcome on-chain (state 1 -> 2). Permissionless to submit: the oracle signs a message that mentions only the market, never this transaction, so anyone holding the attestation can post it. Nothing moves except the three covenant UTXOs, which all shift to the state-2 address; the collateral amount is preserved exactly.", - "intent": "resolve the market YES using the oracle's attestation", - "params": { - "ORACLE_SIGNATURE": { - "type": "bytes", - "description": "The oracle's 64-byte BIP340 signature over sha256(MARKET_ID || 0x01), where MARKET_ID = sha256(YES_TOKEN_ASSET || NO_TOKEN_ASSET) over the asset ids in internal byte order. Paste it 0x-prefixed. Produced off-chain; the wallet cannot compute it." - } - }, - "inputs": [ - { - "id": "yes_reissuance_in", - "description": "YES reissuance token, input 0 — the primary input, and the one that verifies the oracle signature.", - "utxo_source": { - "utxo_type": "market_unresolved" - }, - "asset": "instance.YES_REISSUANCE_TOKEN", - "amount_sat": 1, - "required_index": 0, - "witnesses": { - "STATE": { - "type": "simplicityhl", - "simplicity_type": "u64", - "value": "1" - }, - "PATH": { - "type": "simplicityhl", - "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", - "value": "Left(Right(Left(())))", - "description": "Path 3 — oracle resolve." - }, - "ORACLE_OUTCOME_YES": { - "type": "simplicityhl", - "simplicity_type": "bool", - "value": "true", - "description": "Picks the YES branch, which both selects the signed message byte 0x01 and sends every covenant output to the state-2 address." - }, - "ORACLE_SIGNATURE": { - "type": "simplicityhl", - "simplicity_type": "[u8; 64]", - "value": "params.ORACLE_SIGNATURE", - "description": "Substituted from the action param before the value is parsed." - }, - "BUDGET_PAD_A": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_B": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_C": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_D": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BURN_TOKEN_ASSET": "unused", - "PAIRS_BURNED": "unused", - "TOKENS_BURNED": "unused" - }, - "ui": { - "label": "YES minting right", - "role": "reissuance_token" - } - }, - { - "id": "no_reissuance_in", - "description": "NO reissuance token, input 1, on path 7.", - "utxo_source": { - "utxo_type": "market_unresolved" - }, - "asset": "instance.NO_REISSUANCE_TOKEN", - "amount_sat": 1, - "required_index": 1, - "witnesses": { - "STATE": { - "type": "simplicityhl", - "simplicity_type": "u64", - "value": "1" - }, - "PATH": { - "type": "simplicityhl", - "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", - "value": "Right(Right(()))" - }, - "BUDGET_PAD_A": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_B": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_C": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_D": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BURN_TOKEN_ASSET": "unused", - "NO_REISSUANCE_INPUT_ABF": "unused", - "NO_REISSUANCE_INPUT_VBF": "unused", - "NO_REISSUANCE_OUTPUT_ABF": "unused", - "NO_REISSUANCE_OUTPUT_VBF": "unused", - "ORACLE_OUTCOME_YES": "unused", - "ORACLE_SIGNATURE": "unused", - "PAIRS_BURNED": "unused", - "TOKENS_BURNED": "unused", - "YES_REISSUANCE_INPUT_ABF": "unused", - "YES_REISSUANCE_INPUT_VBF": "unused", - "YES_REISSUANCE_OUTPUT_ABF": "unused", - "YES_REISSUANCE_OUTPUT_VBF": "unused" - }, - "ui": { - "label": "NO minting right", - "role": "reissuance_token" - } - }, - { - "id": "collateral_in", - "description": "The market's collateral UTXO, input 2, on path 7. Its amount is read from the state file and must be reproduced exactly on output 2.", - "utxo_source": { - "utxo_type": "market_unresolved" - }, - "asset": "instance.COLLATERAL_ASSET_ID", - "required_index": 2, - "witnesses": { - "STATE": { - "type": "simplicityhl", - "simplicity_type": "u64", - "value": "1" - }, - "PATH": { - "type": "simplicityhl", - "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", - "value": "Right(Right(()))" - }, - "BUDGET_PAD_A": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_B": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_C": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_D": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BURN_TOKEN_ASSET": "unused", - "NO_REISSUANCE_INPUT_ABF": "unused", - "NO_REISSUANCE_INPUT_VBF": "unused", - "NO_REISSUANCE_OUTPUT_ABF": "unused", - "NO_REISSUANCE_OUTPUT_VBF": "unused", - "ORACLE_OUTCOME_YES": "unused", - "ORACLE_SIGNATURE": "unused", - "PAIRS_BURNED": "unused", - "TOKENS_BURNED": "unused", - "YES_REISSUANCE_INPUT_ABF": "unused", - "YES_REISSUANCE_INPUT_VBF": "unused", - "YES_REISSUANCE_OUTPUT_ABF": "unused", - "YES_REISSUANCE_OUTPUT_VBF": "unused" - }, - "ui": { - "label": "collateral backing the market", - "role": "collateral" - } - } - ], - "outputs": [ - { - "id": "yes_reissuance_out", - "description": "YES reissuance token moved to the state-2 address (output 0).", - "destination": { - "utxo_type": "market_resolved_yes" - }, - "asset": "instance.YES_REISSUANCE_TOKEN", - "amount_sat": 1, - "required_index": 0, - "ui": { - "label": "YES minting right, market resolved", - "role": "reissuance_token" - } - }, - { - "id": "no_reissuance_out", - "description": "NO reissuance token moved to the state-2 address (output 1). Minting is over, but the tokens are carried along so nothing is stranded.", - "destination": { - "utxo_type": "market_resolved_yes" - }, - "asset": "instance.NO_REISSUANCE_TOKEN", - "amount_sat": 1, - "required_index": 1, - "ui": { - "label": "NO minting right, market resolved", - "role": "reissuance_token" - } - }, - { - "id": "collateral_out", - "description": "All collateral moved intact to the state-2 address (output 2). No value enters or leaves in a resolve.", - "destination": { - "utxo_type": "market_resolved_yes" - }, - "asset": "instance.COLLATERAL_ASSET_ID", - "amount_sat": "collateral_in.amount_sat", - "required_index": 2, - "ui": { - "label": "collateral, now redeemable by YES", - "role": "collateral" - } - } - ] - }, - "ResolveNo": { - "$comment": "Path 3 with ORACLE_OUTCOME_YES = false. Identical to ResolveYes except the signed outcome byte is 0x00 and every covenant output goes to state 3. Same 4-output rule: declare no change.", - "description": "Commit a NO outcome on-chain (state 1 -> 3). Whichever of ResolveYes / ResolveNo confirms first wins — that is the equivocation protection. An oracle that signs both outcomes cannot start a race to drain the pool, because after the first resolve there is no path back to state 1 and none between states 2 and 3.", - "intent": "resolve the market NO using the oracle's attestation", - "params": { - "ORACLE_SIGNATURE": { - "type": "bytes", - "description": "The oracle's 64-byte BIP340 signature over sha256(MARKET_ID || 0x00). Paste it 0x-prefixed." - } - }, - "inputs": [ - { - "id": "yes_reissuance_in", - "description": "YES reissuance token, input 0 — still the primary input on a NO resolve; the covenant's layout is fixed regardless of outcome.", - "utxo_source": { - "utxo_type": "market_unresolved" - }, - "asset": "instance.YES_REISSUANCE_TOKEN", - "amount_sat": 1, - "required_index": 0, - "witnesses": { - "STATE": { - "type": "simplicityhl", - "simplicity_type": "u64", - "value": "1" - }, - "PATH": { - "type": "simplicityhl", - "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", - "value": "Left(Right(Left(())))", - "description": "Path 3 — oracle resolve." - }, - "ORACLE_OUTCOME_YES": { - "type": "simplicityhl", - "simplicity_type": "bool", - "value": "false", - "description": "Picks the NO branch: outcome byte 0x00 and state-3 outputs." - }, - "ORACLE_SIGNATURE": { - "type": "simplicityhl", - "simplicity_type": "[u8; 64]", - "value": "params.ORACLE_SIGNATURE" - }, - "BUDGET_PAD_A": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_B": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_C": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_D": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BURN_TOKEN_ASSET": "unused", - "PAIRS_BURNED": "unused", - "TOKENS_BURNED": "unused" - }, - "ui": { - "label": "YES minting right", - "role": "reissuance_token" - } - }, - { - "id": "no_reissuance_in", - "description": "NO reissuance token, input 1, on path 7.", - "utxo_source": { - "utxo_type": "market_unresolved" - }, - "asset": "instance.NO_REISSUANCE_TOKEN", - "amount_sat": 1, - "required_index": 1, - "witnesses": { - "STATE": { - "type": "simplicityhl", - "simplicity_type": "u64", - "value": "1" - }, - "PATH": { - "type": "simplicityhl", - "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", - "value": "Right(Right(()))" - }, - "BUDGET_PAD_A": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_B": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_C": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_D": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BURN_TOKEN_ASSET": "unused", - "NO_REISSUANCE_INPUT_ABF": "unused", - "NO_REISSUANCE_INPUT_VBF": "unused", - "NO_REISSUANCE_OUTPUT_ABF": "unused", - "NO_REISSUANCE_OUTPUT_VBF": "unused", - "ORACLE_OUTCOME_YES": "unused", - "ORACLE_SIGNATURE": "unused", - "PAIRS_BURNED": "unused", - "TOKENS_BURNED": "unused", - "YES_REISSUANCE_INPUT_ABF": "unused", - "YES_REISSUANCE_INPUT_VBF": "unused", - "YES_REISSUANCE_OUTPUT_ABF": "unused", - "YES_REISSUANCE_OUTPUT_VBF": "unused" - }, - "ui": { - "label": "NO minting right", - "role": "reissuance_token" - } - }, - { - "id": "collateral_in", - "description": "The market's collateral UTXO, input 2, on path 7.", - "utxo_source": { - "utxo_type": "market_unresolved" - }, - "asset": "instance.COLLATERAL_ASSET_ID", - "required_index": 2, - "witnesses": { - "STATE": { - "type": "simplicityhl", - "simplicity_type": "u64", - "value": "1" - }, - "PATH": { - "type": "simplicityhl", - "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", - "value": "Right(Right(()))" - }, - "BUDGET_PAD_A": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_B": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_C": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_D": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BURN_TOKEN_ASSET": "unused", - "NO_REISSUANCE_INPUT_ABF": "unused", - "NO_REISSUANCE_INPUT_VBF": "unused", - "NO_REISSUANCE_OUTPUT_ABF": "unused", - "NO_REISSUANCE_OUTPUT_VBF": "unused", - "ORACLE_OUTCOME_YES": "unused", - "ORACLE_SIGNATURE": "unused", - "PAIRS_BURNED": "unused", - "TOKENS_BURNED": "unused", - "YES_REISSUANCE_INPUT_ABF": "unused", - "YES_REISSUANCE_INPUT_VBF": "unused", - "YES_REISSUANCE_OUTPUT_ABF": "unused", - "YES_REISSUANCE_OUTPUT_VBF": "unused" - }, - "ui": { - "label": "collateral backing the market", - "role": "collateral" - } - } - ], - "outputs": [ - { - "id": "yes_reissuance_out", - "description": "YES reissuance token moved to the state-3 address (output 0).", - "destination": { - "utxo_type": "market_resolved_no" - }, - "asset": "instance.YES_REISSUANCE_TOKEN", - "amount_sat": 1, - "required_index": 0, - "ui": { - "label": "YES minting right, market resolved", - "role": "reissuance_token" - } - }, - { - "id": "no_reissuance_out", - "description": "NO reissuance token moved to the state-3 address (output 1).", - "destination": { - "utxo_type": "market_resolved_no" - }, - "asset": "instance.NO_REISSUANCE_TOKEN", - "amount_sat": 1, - "required_index": 1, - "ui": { - "label": "NO minting right, market resolved", - "role": "reissuance_token" - } - }, - { - "id": "collateral_out", - "description": "All collateral moved intact to the state-3 address (output 2).", - "destination": { - "utxo_type": "market_resolved_no" - }, - "asset": "instance.COLLATERAL_ASSET_ID", - "amount_sat": "collateral_in.amount_sat", - "required_index": 2, - "ui": { - "label": "collateral, now redeemable by NO", - "role": "collateral" - } - } - ] - }, - "RedeemYes": { - "allow_change": "lbtc_only", - "$comment": "Path 4 in state 2, PARTIAL form (remaining collateral > 0). The full-drain form is a different output layout — the burn becomes output 0 and there is no collateral output at all — and needs its own action, not modelled here. The fee is checked at num_outputs - 1, so change outputs are fine on this path.", - "description": "Redeem winning YES tokens for collateral (state 2). Each YES token draws 2 x COLLATERAL_PER_TOKEN — the whole pair's backing, both your stake and the loser's — so the pool drains exactly as all winners redeem, with nothing stranded. Losing NO tokens have no path; they are simply worthless.", - "intent": "redeem {params.TOKENS_BURNED} winning YES tokens for collateral", - "params": { - "TOKENS_BURNED": { - "type": "u64", - "description": "How many YES tokens to burn. Must be strictly less than the market's total remaining backing, or this becomes a full drain and needs the other output layout." - } - }, - "inputs": [ - { - "id": "collateral_in", - "description": "The market's collateral UTXO at the state-2 address, input 0 — this path reads amounts from index 0, so it must come first.", - "utxo_source": { - "utxo_type": "market_resolved_yes" - }, - "asset": "instance.COLLATERAL_ASSET_ID", - "required_index": 0, - "witnesses": { - "STATE": { - "type": "simplicityhl", - "simplicity_type": "u64", - "value": "2", - "description": "State 2 also selects YES_TOKEN_ASSET as the only asset the covenant will accept as a burn." - }, - "PATH": { - "type": "simplicityhl", - "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", - "value": "Left(Right(Right(())))", - "description": "Path 4 — post-resolution redemption." - }, - "TOKENS_BURNED": { - "type": "simplicityhl", - "simplicity_type": "u64", - "value": "params.TOKENS_BURNED", - "description": "Drives both the burn output amount and the payout; the covenant checks both against it." - }, - "BUDGET_PAD_A": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_B": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_C": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_D": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BURN_TOKEN_ASSET": "unused", - "NO_REISSUANCE_INPUT_ABF": "unused", - "NO_REISSUANCE_INPUT_VBF": "unused", - "NO_REISSUANCE_OUTPUT_ABF": "unused", - "NO_REISSUANCE_OUTPUT_VBF": "unused", - "ORACLE_OUTCOME_YES": "unused", - "ORACLE_SIGNATURE": "unused", - "PAIRS_BURNED": "unused", - "YES_REISSUANCE_INPUT_ABF": "unused", - "YES_REISSUANCE_INPUT_VBF": "unused", - "YES_REISSUANCE_OUTPUT_ABF": "unused", - "YES_REISSUANCE_OUTPUT_VBF": "unused" - }, - "ui": { - "label": "collateral pool being drawn down", - "role": "collateral" - } - }, - { - "id": "yes_tokens_in", - "description": "Your winning YES tokens, from the wallet. Size this exactly — any surplus becomes a change output, which is allowed here but pointless.", - "utxo_source": "wallet", - "asset": "instance.YES_TOKEN_ASSET", - "amount_sat": { - "min_amount": "params.TOKENS_BURNED" - }, - "ui": { - "label": "winning YES tokens you are burning", - "role": "outcome_token" - } - } - ], - "outputs": [ - { - "id": "collateral_out", - "description": "What is left of the pool, back to the state-2 address (output 0). The reissuance tokens are untouched by this transaction and stay where they are by consensus.", - "destination": { - "utxo_type": "market_resolved_yes" - }, - "asset": "instance.COLLATERAL_ASSET_ID", - "amount_sat": "collateral_in.amount_sat - params.TOKENS_BURNED * 2 * instance.COLLATERAL_PER_TOKEN", - "required_index": 0, - "ui": { - "label": "collateral left for other winners", - "role": "collateral" - } - }, - { - "id": "yes_tokens_burn", - "description": "The YES tokens destroyed (output 1). The covenant requires a ZERO-LENGTH scriptPubKey here — sha256 of the empty script — not the 1-byte OP_RETURN this engine emits.", - "destination": { - "type": "burn" - }, - "asset": "instance.YES_TOKEN_ASSET", - "amount_sat": "params.TOKENS_BURNED", - "required_index": 1, - "ui": { - "label": "YES tokens burned", - "role": "burn", - "group": "burned / protocol data" - } - }, - { - "id": "payout_out", - "description": "Your winnings (output 2): TOKENS_BURNED x 2 x COLLATERAL_PER_TOKEN. The covenant does not check this leg at all — it constrains what stays behind and what is burned, and the rest follows from asset balance.", - "destination": "wallet", - "asset": "instance.COLLATERAL_ASSET_ID", - "amount_sat": "params.TOKENS_BURNED * 2 * instance.COLLATERAL_PER_TOKEN", - "required_index": 2, - "ui": { - "label": "your winnings", - "role": "settlement" - } - }, - { - "id": "token_change", - "description": "YES token change, if your input held more than you burned.", - "destination": "change", - "asset": "instance.YES_TOKEN_ASSET", - "optional": true, - "ui": { - "label": "unburned YES tokens returned to you", - "role": "change" - } - } - ] - }, - "RedeemNo": { - "allow_change": "lbtc_only", - "$comment": "Path 4 in state 3 — the mirror of RedeemYes. Same partial-only caveat.", - "description": "Redeem winning NO tokens for collateral (state 3). Each NO token draws 2 x COLLATERAL_PER_TOKEN.", - "intent": "redeem {params.TOKENS_BURNED} winning NO tokens for collateral", - "params": { - "TOKENS_BURNED": { - "type": "u64", - "description": "How many NO tokens to burn." - } - }, - "inputs": [ - { - "id": "collateral_in", - "description": "The market's collateral UTXO at the state-3 address, input 0.", - "utxo_source": { - "utxo_type": "market_resolved_no" - }, - "asset": "instance.COLLATERAL_ASSET_ID", - "required_index": 0, - "witnesses": { - "STATE": { - "type": "simplicityhl", - "simplicity_type": "u64", - "value": "3", - "description": "State 3 selects NO_TOKEN_ASSET as the winning side." - }, - "PATH": { - "type": "simplicityhl", - "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", - "value": "Left(Right(Right(())))", - "description": "Path 4 — post-resolution redemption." - }, - "TOKENS_BURNED": { - "type": "simplicityhl", - "simplicity_type": "u64", - "value": "params.TOKENS_BURNED" - }, - "BUDGET_PAD_A": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_B": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_C": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_D": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BURN_TOKEN_ASSET": "unused", - "NO_REISSUANCE_INPUT_ABF": "unused", - "NO_REISSUANCE_INPUT_VBF": "unused", - "NO_REISSUANCE_OUTPUT_ABF": "unused", - "NO_REISSUANCE_OUTPUT_VBF": "unused", - "ORACLE_OUTCOME_YES": "unused", - "ORACLE_SIGNATURE": "unused", - "PAIRS_BURNED": "unused", - "YES_REISSUANCE_INPUT_ABF": "unused", - "YES_REISSUANCE_INPUT_VBF": "unused", - "YES_REISSUANCE_OUTPUT_ABF": "unused", - "YES_REISSUANCE_OUTPUT_VBF": "unused" - }, - "ui": { - "label": "collateral pool being drawn down", - "role": "collateral" - } - }, - { - "id": "no_tokens_in", - "description": "Your winning NO tokens, from the wallet.", - "utxo_source": "wallet", - "asset": "instance.NO_TOKEN_ASSET", - "amount_sat": { - "min_amount": "params.TOKENS_BURNED" - }, - "ui": { - "label": "winning NO tokens you are burning", - "role": "outcome_token" - } - } - ], - "outputs": [ - { - "id": "collateral_out", - "description": "Remaining pool back to the state-3 address (output 0).", - "destination": { - "utxo_type": "market_resolved_no" - }, - "asset": "instance.COLLATERAL_ASSET_ID", - "amount_sat": "collateral_in.amount_sat - params.TOKENS_BURNED * 2 * instance.COLLATERAL_PER_TOKEN", - "required_index": 0, - "ui": { - "label": "collateral left for other winners", - "role": "collateral" - } - }, - { - "id": "no_tokens_burn", - "description": "The NO tokens destroyed (output 1). Same zero-length-script requirement as RedeemYes.", - "destination": { - "type": "burn" - }, - "asset": "instance.NO_TOKEN_ASSET", - "amount_sat": "params.TOKENS_BURNED", - "required_index": 1, - "ui": { - "label": "NO tokens burned", - "role": "burn", - "group": "burned / protocol data" - } - }, - { - "id": "payout_out", - "description": "Your winnings (output 2).", - "destination": "wallet", - "asset": "instance.COLLATERAL_ASSET_ID", - "amount_sat": "params.TOKENS_BURNED * 2 * instance.COLLATERAL_PER_TOKEN", - "required_index": 2, - "ui": { - "label": "your winnings", - "role": "settlement" - } - }, - { - "id": "token_change", - "description": "NO token change, if your input held more than you burned.", - "destination": "change", - "asset": "instance.NO_TOKEN_ASSET", - "optional": true, - "ui": { - "label": "unburned NO tokens returned to you", - "role": "change" - } - } - ] - }, - "RedeemExpired": { - "allow_change": "lbtc_only", - "$comment": "Path 5, partial form. NOT EXECUTABLE YET: this is the one Deadcat path that calls jet::check_lock_height(EXPIRY_TIME), an absolute CLTV, and the engine cannot set a transaction nLockTime — see meta/tasks/upnext/12-engine-absolute-locktime.md. (The pre-expiry paths are fine: they assert lock_time < EXPIRY_TIME, which a locktime of 0 satisfies.) BURN_TOKEN_ASSET must be supplied byte-reversed, because witness values are parsed as raw SimplicityHL literals with no liquid.asset_id type hint to trigger the reversal that compile params get.", - "description": "Redeem after the market expired unresolved (state 1, at or after EXPIRY_TIME). Both sides redeem at 1 x COLLATERAL_PER_TOKEN — half the winner's rate — so YES and NO holders are made whole together and the pool drains exactly. This is the escape hatch for an oracle that never attests.", - "intent": "redeem {params.TOKENS_BURNED} tokens from the expired market", - "params": { - "TOKENS_BURNED": { - "type": "u64", - "description": "How many tokens to burn, of whichever side you hold." - }, - "BURN_TOKEN_ASSET": { - "type": "bytes", - "description": "The asset id of the side you are burning — YES_TOKEN_ASSET or NO_TOKEN_ASSET — 0x-prefixed and in INTERNAL byte order, i.e. the display id reversed. The covenant accepts either, and rejects anything else." - }, - "BURN_TOKEN_ASSET_DISPLAY": { - "type": "liquid.asset_id", - "description": "The same asset id in normal display order, used for the transaction's burn output and token input. Must be the reverse of BURN_TOKEN_ASSET." - } - }, - "inputs": [ - { - "id": "collateral_in", - "description": "The market's collateral UTXO, still at the state-1 address, input 0. The sequence below is ENABLE_LOCKTIME_NO_RBF: any value other than 0xFFFFFFFF enables the nLockTime that check_lock_height reads. The engine will warn that the BIP68 disable bit is set — expected, since this timeout is an absolute height.", - "utxo_source": { - "utxo_type": "market_unresolved" - }, - "asset": "instance.COLLATERAL_ASSET_ID", - "required_index": 0, - "sequence": 4294967294, - "witnesses": { - "STATE": { - "type": "simplicityhl", - "simplicity_type": "u64", - "value": "1" - }, - "PATH": { - "type": "simplicityhl", - "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", - "value": "Right(Left(Left(())))", - "description": "Path 5 — expiry redemption." - }, - "TOKENS_BURNED": { - "type": "simplicityhl", - "simplicity_type": "u64", - "value": "params.TOKENS_BURNED" - }, - "BURN_TOKEN_ASSET": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "params.BURN_TOKEN_ASSET", - "description": "Tells the covenant which side you are burning. It checks the value equals YES_TOKEN_ASSET or NO_TOKEN_ASSET and then holds output 1 to it." - }, - "BUDGET_PAD_A": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_B": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_C": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_D": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "NO_REISSUANCE_INPUT_ABF": "unused", - "NO_REISSUANCE_INPUT_VBF": "unused", - "NO_REISSUANCE_OUTPUT_ABF": "unused", - "NO_REISSUANCE_OUTPUT_VBF": "unused", - "ORACLE_OUTCOME_YES": "unused", - "ORACLE_SIGNATURE": "unused", - "PAIRS_BURNED": "unused", - "YES_REISSUANCE_INPUT_ABF": "unused", - "YES_REISSUANCE_INPUT_VBF": "unused", - "YES_REISSUANCE_OUTPUT_ABF": "unused", - "YES_REISSUANCE_OUTPUT_VBF": "unused" - }, - "ui": { - "label": "collateral pool being drawn down", - "role": "collateral" - } - }, - { - "id": "tokens_in", - "description": "The tokens you are burning, from the wallet — either side.", - "utxo_source": "wallet", - "asset": "params.BURN_TOKEN_ASSET_DISPLAY", - "amount_sat": { - "min_amount": "params.TOKENS_BURNED" - }, - "ui": { - "label": "tokens you are burning", - "role": "outcome_token" - } - } - ], - "outputs": [ - { - "id": "collateral_out", - "description": "Remaining pool back to the state-1 address (output 0) — the market never leaves state 1 on this path.", - "destination": { - "utxo_type": "market_unresolved" - }, - "asset": "instance.COLLATERAL_ASSET_ID", - "amount_sat": "collateral_in.amount_sat - params.TOKENS_BURNED * instance.COLLATERAL_PER_TOKEN", - "required_index": 0, - "ui": { - "label": "collateral left for other holders", - "role": "collateral" - } - }, - { - "id": "tokens_burn", - "description": "The tokens destroyed (output 1). Zero-length script required, as on the other burn paths.", - "destination": { - "type": "burn" - }, - "asset": "params.BURN_TOKEN_ASSET_DISPLAY", - "amount_sat": "params.TOKENS_BURNED", - "required_index": 1, - "ui": { - "label": "tokens burned", - "role": "burn", - "group": "burned / protocol data" - } - }, - { - "id": "payout_out", - "description": "Your refund (output 2): TOKENS_BURNED x COLLATERAL_PER_TOKEN, half the resolved rate, because the other half belongs to the holder of the matching token on the other side.", - "destination": "wallet", - "asset": "instance.COLLATERAL_ASSET_ID", - "amount_sat": "params.TOKENS_BURNED * instance.COLLATERAL_PER_TOKEN", - "required_index": 2, - "ui": { - "label": "your refund", - "role": "settlement" - } - } - ] - }, - "CancelPairs": { - "allow_change": "lbtc_only", - "$comment": "Path 6, partial form (state 1 -> 1). The full form, which drains the pool to zero and cycles the reissuance tokens back to state 0, is CancelAll.", - "description": "Unwind part of your position while the market is unresolved. Burn matched YES and NO in equal amounts and take back the full 2 x COLLATERAL_PER_TOKEN per pair — you are undoing an issuance, so there is no loss and no time constraint. Only matched pairs qualify: burning one side alone would leave the other side under-collateralised.", - "intent": "cancel {params.PAIRS_BURNED} pairs and reclaim their collateral", - "params": { - "PAIRS_BURNED": { - "type": "u64", - "description": "How many matched pairs to burn. You must hold this many of BOTH YES and NO." - } - }, - "inputs": [ - { - "id": "collateral_in", - "description": "The market's collateral UTXO, input 0.", - "utxo_source": { - "utxo_type": "market_unresolved" - }, - "asset": "instance.COLLATERAL_ASSET_ID", - "required_index": 0, - "witnesses": { - "STATE": { - "type": "simplicityhl", - "simplicity_type": "u64", - "value": "1" - }, - "PATH": { - "type": "simplicityhl", - "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", - "value": "Right(Left(Right(())))", - "description": "Path 6 — cancellation. The partial and full forms are the same path; the covenant picks between them by testing whether any collateral remains." - }, - "PAIRS_BURNED": { - "type": "simplicityhl", - "simplicity_type": "u64", - "value": "params.PAIRS_BURNED" - }, - "BUDGET_PAD_A": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_B": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_C": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_D": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BURN_TOKEN_ASSET": "unused", - "NO_REISSUANCE_INPUT_ABF": "unused", - "NO_REISSUANCE_INPUT_VBF": "unused", - "NO_REISSUANCE_OUTPUT_ABF": "unused", - "NO_REISSUANCE_OUTPUT_VBF": "unused", - "ORACLE_OUTCOME_YES": "unused", - "ORACLE_SIGNATURE": "unused", - "TOKENS_BURNED": "unused", - "YES_REISSUANCE_INPUT_ABF": "unused", - "YES_REISSUANCE_INPUT_VBF": "unused", - "YES_REISSUANCE_OUTPUT_ABF": "unused", - "YES_REISSUANCE_OUTPUT_VBF": "unused" - }, - "ui": { - "label": "collateral pool being drawn down", - "role": "collateral" - } - }, - { - "id": "yes_tokens_in", - "description": "Your YES tokens, from the wallet.", - "utxo_source": "wallet", - "asset": "instance.YES_TOKEN_ASSET", - "amount_sat": { - "min_amount": "params.PAIRS_BURNED" - }, - "ui": { - "label": "YES side of the pairs you are burning", - "role": "outcome_token" - } - }, - { - "id": "no_tokens_in", - "description": "Your NO tokens, from the wallet, in the same amount.", - "utxo_source": "wallet", - "asset": "instance.NO_TOKEN_ASSET", - "amount_sat": { - "min_amount": "params.PAIRS_BURNED" - }, - "ui": { - "label": "NO side of the pairs you are burning", - "role": "outcome_token" - } - } - ], - "outputs": [ - { - "id": "collateral_out", - "description": "Remaining pool back to the state-1 address (output 0). Non-zero is what makes this the partial branch — if it were zero the covenant would demand CancelAll's layout instead.", - "destination": { - "utxo_type": "market_unresolved" - }, - "asset": "instance.COLLATERAL_ASSET_ID", - "amount_sat": "collateral_in.amount_sat - params.PAIRS_BURNED * 2 * instance.COLLATERAL_PER_TOKEN", - "required_index": 0, - "ui": { - "label": "collateral still backing the market", - "role": "collateral" - } - }, - { - "id": "yes_tokens_burn", - "description": "YES tokens destroyed (output 1). Zero-length script required.", - "destination": { - "type": "burn" - }, - "asset": "instance.YES_TOKEN_ASSET", - "amount_sat": "params.PAIRS_BURNED", - "required_index": 1, - "ui": { - "label": "YES tokens burned", - "role": "burn", - "group": "burned / protocol data" - } - }, - { - "id": "no_tokens_burn", - "description": "NO tokens destroyed (output 2), same amount — the covenant checks both indices, which is how equal burning is enforced.", - "destination": { - "type": "burn" - }, - "asset": "instance.NO_TOKEN_ASSET", - "amount_sat": "params.PAIRS_BURNED", - "required_index": 2, - "ui": { - "label": "NO tokens burned", - "role": "burn", - "group": "burned / protocol data" - } - }, - { - "id": "refund_out", - "description": "Your refund (output 3): the full deposit for those pairs, PAIRS_BURNED x 2 x COLLATERAL_PER_TOKEN.", - "destination": "wallet", - "asset": "instance.COLLATERAL_ASSET_ID", - "amount_sat": "params.PAIRS_BURNED * 2 * instance.COLLATERAL_PER_TOKEN", - "required_index": 3, - "ui": { - "label": "collateral refunded to you", - "role": "settlement" - } - } - ] - }, - "CancelAll": { - "allow_change": "lbtc_only", - "$comment": "Path 6, full form (state 1 -> 0). PAIRS_BURNED must equal the whole pool: collateral_in.amount_sat / (2 x COLLATERAL_PER_TOKEN). If it does not, the covenant takes the partial branch instead and this output layout is rejected.", - "description": "Wind the market all the way down. Burn every outstanding pair, take back all the collateral, and cycle both reissuance tokens back to the state-0 address so the market is dormant rather than dead. Without this the tokens would be stranded at a collateral-less state-1 address and the market could never be reissued — the reason the dormant state exists at all (design doc section 5.3).", - "intent": "wind the market down and return it to dormant", - "params": { - "PAIRS_BURNED": { - "type": "u64", - "description": "Every outstanding pair. Must equal the pool's total collateral divided by 2 x COLLATERAL_PER_TOKEN, exactly." - } - }, - "inputs": [ - { - "id": "collateral_in", - "description": "The market's collateral UTXO, input 0 — the primary input, running path 6.", - "utxo_source": { - "utxo_type": "market_unresolved" - }, - "asset": "instance.COLLATERAL_ASSET_ID", - "required_index": 0, - "witnesses": { - "STATE": { - "type": "simplicityhl", - "simplicity_type": "u64", - "value": "1" - }, - "PATH": { - "type": "simplicityhl", - "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", - "value": "Right(Left(Right(())))", - "description": "Path 6 — cancellation, full branch (chosen by the covenant when nothing remains)." - }, - "PAIRS_BURNED": { - "type": "simplicityhl", - "simplicity_type": "u64", - "value": "params.PAIRS_BURNED" - }, - "BUDGET_PAD_A": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_B": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_C": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_D": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BURN_TOKEN_ASSET": "unused", - "ORACLE_OUTCOME_YES": "unused", - "ORACLE_SIGNATURE": "unused", - "TOKENS_BURNED": "unused" - }, - "ui": { - "label": "the whole collateral pool", - "role": "collateral" - } - }, - { - "id": "yes_reissuance_in", - "description": "YES reissuance token, input 1 — the full branch verifies it at exactly this index. Runs path 7.", - "utxo_source": { - "utxo_type": "market_unresolved" - }, - "asset": "instance.YES_REISSUANCE_TOKEN", - "amount_sat": 1, - "required_index": 1, - "witnesses": { - "STATE": { - "type": "simplicityhl", - "simplicity_type": "u64", - "value": "1" - }, - "PATH": { - "type": "simplicityhl", - "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", - "value": "Right(Right(()))" - }, - "BUDGET_PAD_A": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_B": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_C": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_D": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BURN_TOKEN_ASSET": "unused", - "NO_REISSUANCE_INPUT_ABF": "unused", - "NO_REISSUANCE_INPUT_VBF": "unused", - "NO_REISSUANCE_OUTPUT_ABF": "unused", - "NO_REISSUANCE_OUTPUT_VBF": "unused", - "ORACLE_OUTCOME_YES": "unused", - "ORACLE_SIGNATURE": "unused", - "PAIRS_BURNED": "unused", - "TOKENS_BURNED": "unused", - "YES_REISSUANCE_INPUT_ABF": "unused", - "YES_REISSUANCE_INPUT_VBF": "unused", - "YES_REISSUANCE_OUTPUT_ABF": "unused", - "YES_REISSUANCE_OUTPUT_VBF": "unused" - }, - "ui": { - "label": "YES minting right", - "role": "reissuance_token" - } - }, - { - "id": "no_reissuance_in", - "description": "NO reissuance token, input 2, also on path 7.", - "utxo_source": { - "utxo_type": "market_unresolved" - }, - "asset": "instance.NO_REISSUANCE_TOKEN", - "amount_sat": 1, - "required_index": 2, - "witnesses": { - "STATE": { - "type": "simplicityhl", - "simplicity_type": "u64", - "value": "1" - }, - "PATH": { - "type": "simplicityhl", - "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", - "value": "Right(Right(()))" - }, - "BUDGET_PAD_A": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_B": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_C": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_D": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BURN_TOKEN_ASSET": "unused", - "NO_REISSUANCE_INPUT_ABF": "unused", - "NO_REISSUANCE_INPUT_VBF": "unused", - "NO_REISSUANCE_OUTPUT_ABF": "unused", - "NO_REISSUANCE_OUTPUT_VBF": "unused", - "ORACLE_OUTCOME_YES": "unused", - "ORACLE_SIGNATURE": "unused", - "PAIRS_BURNED": "unused", - "TOKENS_BURNED": "unused", - "YES_REISSUANCE_INPUT_ABF": "unused", - "YES_REISSUANCE_INPUT_VBF": "unused", - "YES_REISSUANCE_OUTPUT_ABF": "unused", - "YES_REISSUANCE_OUTPUT_VBF": "unused" - }, - "ui": { - "label": "NO minting right", - "role": "reissuance_token" - } - }, - { - "id": "yes_tokens_in", - "description": "All outstanding YES tokens, from the wallet.", - "utxo_source": "wallet", - "asset": "instance.YES_TOKEN_ASSET", - "amount_sat": { - "min_amount": "params.PAIRS_BURNED" - }, - "ui": { - "label": "every YES token, being burned", - "role": "outcome_token" - } - }, - { - "id": "no_tokens_in", - "description": "All outstanding NO tokens, from the wallet.", - "utxo_source": "wallet", - "asset": "instance.NO_TOKEN_ASSET", - "amount_sat": { - "min_amount": "params.PAIRS_BURNED" - }, - "ui": { - "label": "every NO token, being burned", - "role": "outcome_token" - } - } - ], - "outputs": [ - { - "id": "yes_reissuance_out", - "description": "YES reissuance token returned to the state-0 (dormant) address (output 0).", - "destination": { - "utxo_type": "market_dormant" - }, - "asset": "instance.YES_REISSUANCE_TOKEN", - "amount_sat": 1, - "required_index": 0, - "ui": { - "label": "YES minting right, back to dormant", - "role": "reissuance_token" - } - }, - { - "id": "no_reissuance_out", - "description": "NO reissuance token returned to the state-0 address (output 1). From here the market can be reopened with another InitialIssuance.", - "destination": { - "utxo_type": "market_dormant" - }, - "asset": "instance.NO_REISSUANCE_TOKEN", - "amount_sat": 1, - "required_index": 1, - "ui": { - "label": "NO minting right, back to dormant", - "role": "reissuance_token" - } - }, - { - "id": "yes_tokens_burn", - "description": "All YES tokens destroyed (output 2). Zero-length script required.", - "destination": { - "type": "burn" - }, - "asset": "instance.YES_TOKEN_ASSET", - "amount_sat": "params.PAIRS_BURNED", - "required_index": 2, - "ui": { - "label": "YES tokens burned", - "role": "burn", - "group": "burned / protocol data" - } - }, - { - "id": "no_tokens_burn", - "description": "All NO tokens destroyed (output 3).", - "destination": { - "type": "burn" - }, - "asset": "instance.NO_TOKEN_ASSET", - "amount_sat": "params.PAIRS_BURNED", - "required_index": 3, - "ui": { - "label": "NO tokens burned", - "role": "burn", - "group": "burned / protocol data" - } - }, - { - "id": "refund_out", - "description": "The entire pool refunded to you (output 4). The covenant does not constrain this leg; it falls out of asset balance once outputs 0-3 are pinned.", - "destination": "wallet", - "asset": "instance.COLLATERAL_ASSET_ID", - "amount_sat": "collateral_in.amount_sat", - "required_index": 4, - "ui": { - "label": "all collateral refunded to you", - "role": "settlement" - } - } - ] - } - } - } - } -} diff --git a/examples/deadcat_v2/params.json b/examples/deadcat_v2/params.json deleted file mode 100644 index f14f7ac..0000000 --- a/examples/deadcat_v2/params.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "COLLATERAL_ASSET_ID": "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49", - "COLLATERAL_PER_TOKEN": "1000", - "EXPIRY_TIME": "2560000", - "NO_REISSUANCE_TOKEN": "53d19885868f1b32d9c5ad3c7fdc83576ffefbd5ba573d03c61cf3e557855e5d", - "YES_REISSUANCE_TOKEN": "d8175b4e10fcb1b8eb78f34d4942b7c59635b7b30cb20e4f2801215005d9bf1b", - "ORACLE_PUBLIC_KEY": "5907ed9ec3cb2ff851da548b617b54af6d1967386775e7c7c3c9ad153f5884d9" -} \ No newline at end of file diff --git a/examples/deadcat_v2/prediction_market.simf b/examples/deadcat_v2/prediction_market.simf deleted file mode 100644 index b48aa48..0000000 --- a/examples/deadcat_v2/prediction_market.simf +++ /dev/null @@ -1,508 +0,0 @@ -// Binary Prediction Market Covenant - v2, explicit reissuance tokens -// SimplicityHL contract for Liquid -// -// Derived from examples/deadcat/prediction_market.simf (Deadcat.Live), which is kept -// verbatim. The ONLY behavioural difference: the two reissuance tokens are read as -// explicit outputs rather than Pedersen commitments. Upstream verifies them with -// `verify_token_commitment`, recomputing H + abf*G and asset_gen + vbf*G from blinding -// factors supplied as witnesses - which requires the token UTXOs to be blinded, and the -// tx-manifest engine emits every covenant output explicit. -// -// The amount check is not weakened. Upstream's value-commitment step computes -// `asset_gen + vbf*G`, a commitment to value exactly 1, so `eq_64(amount, 1)` here -// asserts the same thing. -// -// This is a FORK: dropping the EC operations changes the CMR and therefore all four -// covenant addresses. Markets built from this file do NOT interoperate with Deadcat's. - -// ============================================================================ -// Type aliases -// ============================================================================ - -// PATH dispatch types (7-way nested Either) -type Path1or2 = Either<(), ()>; -type Path3or4 = Either<(), ()>; -type Path1to4 = Either; -type Path5or6 = Either<(), ()>; -type Path5to7 = Either; - -// ============================================================================ -// Boolean helpers -// ============================================================================ - -fn not(bit: bool) -> bool { - ::into(jet::complement_1(::into(bit))) -} - -fn or(a: bool, b: bool) -> bool { - ::into(jet::or_1(::into(a), ::into(b))) -} - -fn ensure_zero_bit(b: bool) { - assert!(not(b)); -} - -// ============================================================================ -// Utility functions -// ============================================================================ - -fn get_input_script_hash(index: u32) -> u256 { - unwrap(jet::input_script_hash(index)) -} - -fn get_output_explicit_asset(index: u32) -> u256 { - unwrap_right::<(u1, u256)>(unwrap(jet::output_asset(index))) -} - -fn get_output_explicit_asset_amount(index: u32) -> (u256, u64) { - let (asset, amount): (Asset1, Amount1) = unwrap(jet::output_amount(index)); - let asset_val: u256 = unwrap_right::<(u1, u256)>(asset); - let amount_val: u64 = unwrap_right::<(u1, u256)>(amount); - (asset_val, amount_val) -} - -fn get_input_explicit_asset_amount(index: u32) -> (u256, u64) { - let (asset, amount): (Asset1, Amount1) = unwrap(jet::input_amount(index)); - let asset_val: u256 = unwrap_right::<(u1, u256)>(asset); - let amount_val: u64 = unwrap_right::<(u1, u256)>(amount); - (asset_val, amount_val) -} - -fn ensure_output_script_hash_eq(index: u32, expected: u256) { - let actual: u256 = unwrap(jet::output_script_hash(index)); - assert!(jet::eq_256(actual, expected)); -} - -fn ensure_output_asset_with_amount_eq(index: u32, expected_asset: u256, expected_amount: u64) { - let (asset, amount): (u256, u64) = get_output_explicit_asset_amount(index); - assert!(jet::eq_256(asset, expected_asset)); - assert!(jet::eq_64(amount, expected_amount)); -} - -fn ensure_input_asset_with_amount_eq(index: u32, expected_asset: u256, expected_amount: u64) { - let (asset, amount): (u256, u64) = get_input_explicit_asset_amount(index); - assert!(jet::eq_256(asset, expected_asset)); - assert!(jet::eq_64(amount, expected_amount)); -} - -fn empty_script_hash() -> u256 { - 0xe3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 -} - -fn ensure_output_is_op_return(index: u32) { - let script_hash: u256 = unwrap(jet::output_script_hash(index)); - assert!(jet::eq_256(script_hash, empty_script_hash())); -} - -fn ensure_fee_output(index: u32) { - let script_hash: u256 = unwrap(jet::output_script_hash(index)); - assert!(jet::eq_256(script_hash, empty_script_hash())); -} - -// ============================================================================ -// Reissuance token verification (explicit) -// ============================================================================ - -// A reissuance token UTXO always holds exactly 1 unit. Upstream proves this against a -// Pedersen commitment; here the value is in the clear, so the check is a comparison. -fn reissuance_token_amount() -> u64 { - 1 -} - -fn verify_input_reissuance_token(index: u32, expected_token: u256) { - ensure_input_asset_with_amount_eq(index, expected_token, reissuance_token_amount()); -} - -fn verify_output_reissuance_token(index: u32, expected_token: u256) { - ensure_output_asset_with_amount_eq(index, expected_token, reissuance_token_amount()); -} - -// ============================================================================ -// Taproot address computation -// ============================================================================ - -fn covenant_nums_key() -> u256 { - 0x50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0 -} - -fn compute_p2tr_script_hash_from_output_key(output_key: u256) -> u256 { - let ctx: Ctx8 = jet::sha_256_ctx_8_init(); - let ctx: Ctx8 = jet::sha_256_ctx_8_add_2(ctx, 0x5120); - let ctx: Ctx8 = jet::sha_256_ctx_8_add_32(ctx, output_key); - jet::sha_256_ctx_8_finalize(ctx) -} - -fn script_hash_for_input_script(state: u64) -> u256 { - let tap_leaf: u256 = jet::tapleaf_hash(); - let state_ctx: Ctx8 = jet::tapdata_init(); - let state_ctx: Ctx8 = jet::sha_256_ctx_8_add_8(state_ctx, state); - let state_leaf: u256 = jet::sha_256_ctx_8_finalize(state_ctx); - let tap_node: u256 = jet::build_tapbranch(tap_leaf, state_leaf); - let tweaked_key: u256 = jet::build_taptweak(covenant_nums_key(), tap_node); - compute_p2tr_script_hash_from_output_key(tweaked_key) -} - -// ============================================================================ -// Arithmetic helpers -// ============================================================================ - -fn safe_multiply(a: u64, b: u64) -> u64 { - let result: u128 = jet::multiply_64(a, b); - let (high, low): (u64, u64) = ::into(result); - assert!(jet::is_zero_64(high)); - low -} - -fn safe_add(a: u64, b: u64) -> u64 { - let (carry, sum): (bool, u64) = jet::add_64(a, b); - ensure_zero_bit(carry); - sum -} - -fn safe_subtract(a: u64, b: u64) -> u64 { - let (borrow, diff): (bool, u64) = jet::subtract_64(a, b); - ensure_zero_bit(borrow); - diff -} - -fn safe_subtract_32(a: u32, b: u32) -> u32 { - let (borrow, diff): (bool, u32) = jet::subtract_32(a, b); - ensure_zero_bit(borrow); - diff -} - -// ============================================================================ -// Market-specific functions -// ============================================================================ - -fn compute_market_id() -> u256 { - let ctx: Ctx8 = jet::sha_256_ctx_8_init(); - let ctx: Ctx8 = jet::sha_256_ctx_8_add_32(ctx, param::YES_TOKEN_ASSET); - let ctx: Ctx8 = jet::sha_256_ctx_8_add_32(ctx, param::NO_TOKEN_ASSET); - jet::sha_256_ctx_8_finalize(ctx) -} - -fn verify_oracle_signature(outcome_yes: bool, signature: Signature) { - let market_id: u256 = compute_market_id(); - let outcome_byte: u8 = match outcome_yes { - true => 1, - false => 0, - }; - let ctx: Ctx8 = jet::sha_256_ctx_8_init(); - let ctx: Ctx8 = jet::sha_256_ctx_8_add_32(ctx, market_id); - let ctx: Ctx8 = jet::sha_256_ctx_8_add_1(ctx, outcome_byte); - let message: u256 = jet::sha_256_ctx_8_finalize(ctx); - jet::bip_0340_verify((param::ORACLE_PUBLIC_KEY, message), signature); -} - -fn ensure_pre_expiry() { - let tx_lock_time: u32 = jet::lock_time(); - assert!(jet::lt_32(tx_lock_time, param::EXPIRY_TIME)); -} - -fn dormant_script_hash() -> u256 { - script_hash_for_input_script(0) -} - -fn unresolved_script_hash() -> u256 { - script_hash_for_input_script(1) -} - -fn collateral_for_pairs(pairs: u64) -> u64 { - let two_cpt: u64 = safe_multiply(2, param::COLLATERAL_PER_TOKEN); - safe_multiply(pairs, two_cpt) -} - -fn get_issuance_amount(index: u32) -> u64 { - unwrap_right::<(u1, u256)>(unwrap(unwrap(jet::issuance_asset_amount(index)))) -} - -// ============================================================================ -// Spending paths -// ============================================================================ - -/// Path 1: Initial Issuance (state 0 → 1) -fn initial_issuance_path(state: u64) { - assert!(jet::is_zero_64(state)); - ensure_pre_expiry(); - assert!(jet::eq_32(jet::current_index(), 0)); - - let unresolved_hash: u256 = unresolved_script_hash(); - - verify_input_reissuance_token(0, param::YES_REISSUANCE_TOKEN); - verify_input_reissuance_token(1, param::NO_REISSUANCE_TOKEN); - - let yes_amount: u64 = get_issuance_amount(0); - let no_amount: u64 = get_issuance_amount(1); - assert!(jet::eq_64(yes_amount, no_amount)); - - let pairs: u64 = yes_amount; - let total_collateral: u64 = collateral_for_pairs(pairs); - - verify_output_reissuance_token(0, param::YES_REISSUANCE_TOKEN); - ensure_output_script_hash_eq(0, unresolved_hash); - - verify_output_reissuance_token(1, param::NO_REISSUANCE_TOKEN); - ensure_output_script_hash_eq(1, unresolved_hash); - - ensure_output_asset_with_amount_eq(2, param::COLLATERAL_ASSET_ID, total_collateral); - ensure_output_script_hash_eq(2, unresolved_hash); - - ensure_fee_output(5); -} - -/// Path 2: Subsequent Issuance (state 1 → 1) -fn subsequent_issuance_path(state: u64) { - assert!(jet::eq_64(state, 1)); - ensure_pre_expiry(); - assert!(jet::eq_32(jet::current_index(), 0)); - - let unresolved_hash: u256 = unresolved_script_hash(); - - verify_input_reissuance_token(0, param::YES_REISSUANCE_TOKEN); - verify_input_reissuance_token(1, param::NO_REISSUANCE_TOKEN); - - let yes_amount: u64 = get_issuance_amount(0); - let no_amount: u64 = get_issuance_amount(1); - assert!(jet::eq_64(yes_amount, no_amount)); - - let pairs: u64 = yes_amount; - let new_collateral: u64 = collateral_for_pairs(pairs); - - assert!(jet::eq_256(get_input_script_hash(2), unresolved_hash)); - let (coll_asset, old_collateral): (u256, u64) = get_input_explicit_asset_amount(2); - assert!(jet::eq_256(coll_asset, param::COLLATERAL_ASSET_ID)); - let total_collateral: u64 = safe_add(old_collateral, new_collateral); - - verify_output_reissuance_token(0, param::YES_REISSUANCE_TOKEN); - ensure_output_script_hash_eq(0, unresolved_hash); - - verify_output_reissuance_token(1, param::NO_REISSUANCE_TOKEN); - ensure_output_script_hash_eq(1, unresolved_hash); - - ensure_output_asset_with_amount_eq(2, param::COLLATERAL_ASSET_ID, total_collateral); - ensure_output_script_hash_eq(2, unresolved_hash); - - ensure_fee_output(5); -} - -/// Path 3: Oracle Resolve (state 1 → 2 or 3) -fn oracle_resolve_path(state: u64, outcome_yes: bool, sig: Signature) { - assert!(jet::eq_64(state, 1)); - ensure_pre_expiry(); - assert!(jet::eq_32(jet::current_index(), 0)); - - verify_oracle_signature(outcome_yes, sig); - - let new_state: u64 = match outcome_yes { - true => 2, - false => 3, - }; - let new_state_hash: u256 = script_hash_for_input_script(new_state); - - verify_input_reissuance_token(0, param::YES_REISSUANCE_TOKEN); - verify_input_reissuance_token(1, param::NO_REISSUANCE_TOKEN); - - verify_output_reissuance_token(0, param::YES_REISSUANCE_TOKEN); - ensure_output_script_hash_eq(0, new_state_hash); - - verify_output_reissuance_token(1, param::NO_REISSUANCE_TOKEN); - ensure_output_script_hash_eq(1, new_state_hash); - - let unresolved_hash: u256 = unresolved_script_hash(); - assert!(jet::eq_256(get_input_script_hash(2), unresolved_hash)); - let (coll_asset, coll_amount): (u256, u64) = get_input_explicit_asset_amount(2); - assert!(jet::eq_256(coll_asset, param::COLLATERAL_ASSET_ID)); - ensure_output_asset_with_amount_eq(2, param::COLLATERAL_ASSET_ID, coll_amount); - ensure_output_script_hash_eq(2, new_state_hash); - - assert!(jet::eq_32(jet::num_outputs(), 4)); - ensure_fee_output(3); -} - -/// Path 4: Post-Resolution Redemption (state 2 or 3) -fn post_resolution_redemption_path(state: u64, tokens_burned: u64) { - let is_yes: bool = jet::eq_64(state, 2); - let is_no: bool = jet::eq_64(state, 3); - assert!(or(is_yes, is_no)); - - let state_hash: u256 = script_hash_for_input_script(state); - - let winner_asset: u256 = match is_yes { - true => param::YES_TOKEN_ASSET, - false => param::NO_TOKEN_ASSET, - }; - - let (coll_asset, coll_amount): (u256, u64) = get_input_explicit_asset_amount(0); - assert!(jet::eq_256(coll_asset, param::COLLATERAL_ASSET_ID)); - - let payout: u64 = collateral_for_pairs(tokens_burned); - let remaining: u64 = safe_subtract(coll_amount, payout); - - let num_outputs: u32 = jet::num_outputs(); - let is_partial: bool = not(jet::is_zero_64(remaining)); - - match is_partial { - true => { - ensure_output_asset_with_amount_eq(0, param::COLLATERAL_ASSET_ID, remaining); - ensure_output_script_hash_eq(0, state_hash); - ensure_output_asset_with_amount_eq(1, winner_asset, tokens_burned); - ensure_output_is_op_return(1); - let fee_idx: u32 = safe_subtract_32(num_outputs, 1); - ensure_fee_output(fee_idx); - }, - false => { - ensure_output_asset_with_amount_eq(0, winner_asset, tokens_burned); - ensure_output_is_op_return(0); - let fee_idx: u32 = safe_subtract_32(num_outputs, 1); - ensure_fee_output(fee_idx); - }, - }; -} - -/// Path 5: Expiry Redemption (state 1, post-expiry) -fn expiry_redemption_path(state: u64, tokens_burned: u64, burn_asset: u256) { - assert!(jet::eq_64(state, 1)); - jet::check_lock_height(param::EXPIRY_TIME); - - let unresolved_hash: u256 = unresolved_script_hash(); - - let (coll_asset, coll_amount): (u256, u64) = get_input_explicit_asset_amount(0); - assert!(jet::eq_256(coll_asset, param::COLLATERAL_ASSET_ID)); - - let is_yes: bool = jet::eq_256(burn_asset, param::YES_TOKEN_ASSET); - let is_no: bool = jet::eq_256(burn_asset, param::NO_TOKEN_ASSET); - assert!(or(is_yes, is_no)); - - let payout: u64 = safe_multiply(tokens_burned, param::COLLATERAL_PER_TOKEN); - let remaining: u64 = safe_subtract(coll_amount, payout); - - let num_outputs: u32 = jet::num_outputs(); - let is_partial: bool = not(jet::is_zero_64(remaining)); - - match is_partial { - true => { - ensure_output_asset_with_amount_eq(0, param::COLLATERAL_ASSET_ID, remaining); - ensure_output_script_hash_eq(0, unresolved_hash); - ensure_output_asset_with_amount_eq(1, burn_asset, tokens_burned); - ensure_output_is_op_return(1); - let fee_idx: u32 = safe_subtract_32(num_outputs, 1); - ensure_fee_output(fee_idx); - }, - false => { - ensure_output_asset_with_amount_eq(0, burn_asset, tokens_burned); - ensure_output_is_op_return(0); - let fee_idx: u32 = safe_subtract_32(num_outputs, 1); - ensure_fee_output(fee_idx); - }, - }; -} - -/// Path 6: Cancellation (state 1 → 1 partial, 1 → 0 full) -fn cancellation_path(state: u64, pairs_burned: u64) { - assert!(jet::eq_64(state, 1)); - - let unresolved_hash: u256 = unresolved_script_hash(); - - let (coll_asset, coll_amount): (u256, u64) = get_input_explicit_asset_amount(0); - assert!(jet::eq_256(coll_asset, param::COLLATERAL_ASSET_ID)); - - let refund: u64 = collateral_for_pairs(pairs_burned); - let remaining: u64 = safe_subtract(coll_amount, refund); - - let num_outputs: u32 = jet::num_outputs(); - let is_partial: bool = not(jet::is_zero_64(remaining)); - - match is_partial { - true => { - ensure_output_asset_with_amount_eq(0, param::COLLATERAL_ASSET_ID, remaining); - ensure_output_script_hash_eq(0, unresolved_hash); - ensure_output_asset_with_amount_eq(1, param::YES_TOKEN_ASSET, pairs_burned); - ensure_output_is_op_return(1); - ensure_output_asset_with_amount_eq(2, param::NO_TOKEN_ASSET, pairs_burned); - ensure_output_is_op_return(2); - let fee_idx: u32 = safe_subtract_32(num_outputs, 1); - ensure_fee_output(fee_idx); - }, - false => { - let dormant_hash: u256 = dormant_script_hash(); - - verify_input_reissuance_token(1, param::YES_REISSUANCE_TOKEN); - verify_input_reissuance_token(2, param::NO_REISSUANCE_TOKEN); - - verify_output_reissuance_token(0, param::YES_REISSUANCE_TOKEN); - ensure_output_script_hash_eq(0, dormant_hash); - - verify_output_reissuance_token(1, param::NO_REISSUANCE_TOKEN); - ensure_output_script_hash_eq(1, dormant_hash); - - ensure_output_asset_with_amount_eq(2, param::YES_TOKEN_ASSET, pairs_burned); - ensure_output_is_op_return(2); - ensure_output_asset_with_amount_eq(3, param::NO_TOKEN_ASSET, pairs_burned); - ensure_output_is_op_return(3); - - let fee_idx: u32 = safe_subtract_32(num_outputs, 1); - ensure_fee_output(fee_idx); - }, - }; -} - -/// Path 7: Secondary Covenant Input -fn secondary_covenant_input_path() { - let my_index: u32 = jet::current_index(); - let my_hash: u256 = get_input_script_hash(my_index); - let primary_hash: u256 = get_input_script_hash(0); - assert!(jet::eq_256(my_hash, primary_hash)); - ensure_zero_bit(jet::eq_32(my_index, 0)); -} - -// ============================================================================ -// Main entry point -// ============================================================================ - -fn main() { - let state: u64 = witness::STATE; - - let expected_hash: u256 = script_hash_for_input_script(state); - let actual_hash: u256 = get_input_script_hash(jet::current_index()); - assert!(jet::eq_256(expected_hash, actual_hash)); - - // Budget padding: these witnesses increase the serialized witness size so that - // the Simplicity execution budget (witness_stack_bytes + 50 WU) covers the - // program's cost. Without this, the pruned program's cost slightly exceeds - // the budget derived from its natural witness + program size. - let budget_pad_a: u256 = witness::BUDGET_PAD_A; - let budget_pad_b: u256 = witness::BUDGET_PAD_B; - assert!(jet::eq_256(budget_pad_a, budget_pad_b)); - let budget_pad_c: u256 = witness::BUDGET_PAD_C; - let budget_pad_d: u256 = witness::BUDGET_PAD_D; - assert!(jet::eq_256(budget_pad_c, budget_pad_d)); - - // Read all witnesses in main (required by SimplicityHL). The eight reissuance - // blinding factors upstream reads here are gone with the commitment checks. - let oracle_sig: Signature = witness::ORACLE_SIGNATURE; - let oracle_outcome: bool = witness::ORACLE_OUTCOME_YES; - let tokens_burned: u64 = witness::TOKENS_BURNED; - let burn_asset: u256 = witness::BURN_TOKEN_ASSET; - let pairs_burned: u64 = witness::PAIRS_BURNED; - - match witness::PATH { - Left(l: Path1to4) => match l { - Left(i: Path1or2) => match i { - Left(u: ()) => initial_issuance_path(state), - Right(u: ()) => subsequent_issuance_path(state), - }, - Right(r: Path3or4) => match r { - Left(u: ()) => oracle_resolve_path(state, oracle_outcome, oracle_sig), - Right(u: ()) => post_resolution_redemption_path(state, tokens_burned), - }, - }, - Right(r: Path5to7) => match r { - Left(rd: Path5or6) => match rd { - Left(u: ()) => expiry_redemption_path(state, tokens_burned, burn_asset), - Right(u: ()) => cancellation_path(state, pairs_burned), - }, - Right(u: ()) => secondary_covenant_input_path(), - }, - } -} diff --git a/examples/deadcat_v2/txmanifest.json b/examples/deadcat_v2/txmanifest.json deleted file mode 100644 index dd1dc4d..0000000 --- a/examples/deadcat_v2/txmanifest.json +++ /dev/null @@ -1,2183 +0,0 @@ -{ - "$schema": "../../schema/txmanifest.schema.json", - "$comment": "v2 of examples/deadcat — SAME protocol, one forked covenant. prediction_market.simf here treats the two reissuance tokens as EXPLICIT rather than as Pedersen commitments, because the engine emits every covenant output explicit and upstream's commitment check (unwrap_left on a confidential asset) therefore fails on the issuance, resolve and full-cancel paths. Dropping the EC operations changes the CMR, so all four covenant addresses differ from examples/deadcat and markets are NOT interoperable with Deadcat's. Everything else — states, paths, amounts, output layouts — is unchanged; examples/deadcat remains the faithful port and is what deadcat_recon checks against upstream. Original header follows. Ported from Deadcat.Live (github.com/Resolvr-io/deadcat, src-tauri/crates/deadcat-sdk). prediction_market.simf is a verbatim copy of that crate's contract/prediction_market.simf — do not edit it, every byte feeds the CMR and therefore all four covenant addresses. The upstream SDK builds these transactions in Rust (src/pset/*.rs); this manifest is the same seven spending paths expressed declaratively. Deadcat's OTHER covenant, maker_order.simf, is deliberately NOT modelled: it tweaks the MAKER's key as the taproot internal key, while this engine hardcodes the NUMS internal key (covenant.rs::NUMS_KEY_BYTES), so no address it computed would be correct.", - "manifest_version": "0.3.0", - "protocol": "deadcat-prediction-market-v2", - "description": "Deadcat v2 — a binary (YES/NO) prediction market on Liquid, with explicit (unblinded) reissuance tokens so it can actually be executed by this engine. Collateral is locked in a covenant that mints matched YES/NO token pairs at 2 x COLLATERAL_PER_TOKEN per pair; an off-chain oracle commits the outcome ON-CHAIN as a state transition, and winners then burn tokens to draw the whole pair's collateral. The market's state (0 dormant, 1 unresolved, 2 resolved-YES, 3 resolved-NO) is not stored in a variable — it is a tapdata leaf in the covenant's tap tree, so each state is a DIFFERENT address and the covenant proves its own state by comparing the address it is being spent from. Modelled as one template: one instance per market.", - "chain": "liquid", - "requires": ["simplicity"], - "simplicity_hl": { - "$comment": "Deadcat compiles with debug symbols OFF (contract.rs: template.instantiate(args, false)). Flipping this changes every fail-node commitment, hence the CMR, hence all four addresses.", - "debug_symbols": false - }, - "utxo_types": { - "market_dormant": { - "description": "STATE 0 — DORMANT. Holds only the two reissuance tokens, no collateral. This is where CreateMarket parks them and where a full Cancel returns them. The address is the Simplicity leaf branched with a tapdata leaf carrying the u64 0 (big-endian, 8 bytes) — see taproot.rs::tapdata_hash. Nothing else about the address differs between the four states.", - "script": { - "type": "simplicity", - "source": "./prediction_market.simf", - "compile_params": { - "ORACLE_PUBLIC_KEY": "ORACLE_PUBLIC_KEY", - "COLLATERAL_ASSET_ID": "COLLATERAL_ASSET_ID", - "YES_TOKEN_ASSET": "YES_TOKEN_ASSET", - "NO_TOKEN_ASSET": "NO_TOKEN_ASSET", - "YES_REISSUANCE_TOKEN": "YES_REISSUANCE_TOKEN", - "NO_REISSUANCE_TOKEN": "NO_REISSUANCE_TOKEN", - "COLLATERAL_PER_TOKEN": "COLLATERAL_PER_TOKEN", - "EXPIRY_TIME": "EXPIRY_TIME" - }, - "extra_leaves": [ - { - "$comment": "state = 0. jet::tapdata_init() + sha_256_ctx_8_add_8(state) hashes exactly 8 big-endian bytes, so no pad_to here — unlike the lending example's 32-byte storage slots.", - "type": "tapdata", - "payload": [ - { - "value": "0", - "type": "u64", - "endian": "be" - } - ] - } - ] - }, - "asset": "YES_REISSUANCE_TOKEN" - }, - "market_unresolved": { - "description": "STATE 1 — UNRESOLVED, the live market. Holds three UTXOs: the YES reissuance token, the NO reissuance token, and the single consolidated collateral UTXO. 'Single' is load-bearing: every issuance path must consume the existing collateral UTXO and re-emit one consolidated output, so the oracle can move the whole market to a resolved address in one transaction (design doc section 6).", - "script": { - "type": "simplicity", - "source": "./prediction_market.simf", - "compile_params": { - "ORACLE_PUBLIC_KEY": "ORACLE_PUBLIC_KEY", - "COLLATERAL_ASSET_ID": "COLLATERAL_ASSET_ID", - "YES_TOKEN_ASSET": "YES_TOKEN_ASSET", - "NO_TOKEN_ASSET": "NO_TOKEN_ASSET", - "YES_REISSUANCE_TOKEN": "YES_REISSUANCE_TOKEN", - "NO_REISSUANCE_TOKEN": "NO_REISSUANCE_TOKEN", - "COLLATERAL_PER_TOKEN": "COLLATERAL_PER_TOKEN", - "EXPIRY_TIME": "EXPIRY_TIME" - }, - "extra_leaves": [ - { - "type": "tapdata", - "payload": [ - { - "value": "1", - "type": "u64", - "endian": "be" - } - ] - } - ] - }, - "asset": "COLLATERAL_ASSET_ID" - }, - "market_resolved_yes": { - "description": "STATE 2 — RESOLVED YES. The oracle attested YES, so YES tokens redeem at 2 x COLLATERAL_PER_TOKEN each and NO tokens are worth nothing. There is no path from here to state 3: that is the whole point of committing the outcome on-chain rather than checking the oracle signature at redemption time (design doc section 9.2).", - "script": { - "type": "simplicity", - "source": "./prediction_market.simf", - "compile_params": { - "ORACLE_PUBLIC_KEY": "ORACLE_PUBLIC_KEY", - "COLLATERAL_ASSET_ID": "COLLATERAL_ASSET_ID", - "YES_TOKEN_ASSET": "YES_TOKEN_ASSET", - "NO_TOKEN_ASSET": "NO_TOKEN_ASSET", - "YES_REISSUANCE_TOKEN": "YES_REISSUANCE_TOKEN", - "NO_REISSUANCE_TOKEN": "NO_REISSUANCE_TOKEN", - "COLLATERAL_PER_TOKEN": "COLLATERAL_PER_TOKEN", - "EXPIRY_TIME": "EXPIRY_TIME" - }, - "extra_leaves": [ - { - "type": "tapdata", - "payload": [ - { - "value": "2", - "type": "u64", - "endian": "be" - } - ] - } - ] - }, - "asset": "COLLATERAL_ASSET_ID" - }, - "market_resolved_no": { - "description": "STATE 3 — RESOLVED NO. Mirror of state 2: NO tokens redeem, YES tokens are worthless.", - "script": { - "type": "simplicity", - "source": "./prediction_market.simf", - "compile_params": { - "ORACLE_PUBLIC_KEY": "ORACLE_PUBLIC_KEY", - "COLLATERAL_ASSET_ID": "COLLATERAL_ASSET_ID", - "YES_TOKEN_ASSET": "YES_TOKEN_ASSET", - "NO_TOKEN_ASSET": "NO_TOKEN_ASSET", - "YES_REISSUANCE_TOKEN": "YES_REISSUANCE_TOKEN", - "NO_REISSUANCE_TOKEN": "NO_REISSUANCE_TOKEN", - "COLLATERAL_PER_TOKEN": "COLLATERAL_PER_TOKEN", - "EXPIRY_TIME": "EXPIRY_TIME" - }, - "extra_leaves": [ - { - "type": "tapdata", - "payload": [ - { - "value": "3", - "type": "u64", - "endian": "be" - } - ] - } - ] - }, - "asset": "COLLATERAL_ASSET_ID" - } - }, - "contract_templates": { - "binary_market": { - "description": "One binary prediction market. IssueReissuanceTokens is the constructor: it mints the minting rights and writes the instance file that defines the market. CreateMarket then locks those rights into the covenant (a plain Elements transaction — no covenant runs), InitialIssuance brings the market to life, then anyone may MintPairs or Cancel while it is unresolved. It ends one of two ways: the oracle resolves it (ResolveYes / ResolveNo, then RedeemYes / RedeemNo), or it expires unresolved and both sides redeem at half rate (RedeemExpired). All eight fields below are compile params of prediction_market.simf, so changing any one of them is a different market at four different addresses.", - "fields": { - "ORACLE_PUBLIC_KEY": { - "type": "pubkey", - "description": "X-only BIP340 key the resolve path checks. In Deadcat this is the aggregate key of a 2-of-3 FROST committee — on-chain it is just one key, the threshold signing happens off-chain." - }, - "COLLATERAL_ASSET_ID": { - "type": "liquid.asset_id", - "description": "The asset backing the market, normally L-BTC. Also the asset the network fee is paid in, which is why the covenant's fee-output checks are satisfiable." - }, - "COLLATERAL_PER_TOKEN": { - "type": "u64", - "description": "Satoshis backing ONE token. A pair costs 2 x this. Deliberately per-token, not per-pair, so the covenant only ever multiplies — no division means no rounding bug (design doc section 4.3)." - }, - "EXPIRY_TIME": { - "type": "u32", - "description": "Absolute block height. Before it, the oracle may resolve and anyone may mint. At or after it, RedeemExpired unlocks and every token — winning or losing — redeems at 1 x COLLATERAL_PER_TOKEN." - }, - "YES_TOKEN_ASSET": { - "type": "liquid.asset_id", - "description": "Asset id of the YES outcome token, derived from the outpoint pinned as CreateMarket in[0]. Also half of MARKET_ID = sha256(YES || NO), the oracle's domain separator." - }, - "NO_TOKEN_ASSET": { - "type": "liquid.asset_id", - "description": "Asset id of the NO outcome token, derived from the outpoint pinned as CreateMarket in[1]." - }, - "YES_REISSUANCE_TOKEN": { - "type": "liquid.asset_id", - "description": "The reissuance token for YES. Holding it is what permits minting more YES, and it only ever lives at a covenant address — that is the enforcement mechanism behind collateral consolidation." - }, - "NO_REISSUANCE_TOKEN": { - "type": "liquid.asset_id", - "description": "The reissuance token for NO. Same role as YES_REISSUANCE_TOKEN." - }, - "YES_ISSUANCE_ENTROPY": { - "type": "bytes32", - "description": "Issuance entropy of the YES mint — fast_merkle_root([sha256d(defining outpoint), contract_hash]), the value YES_TOKEN_ASSET itself is derived from. Every later reissuance needs it and NOTHING on chain carries it: the reissuance token UTXO holds no trace of the outpoint that created it. Captured by the constructor at the one moment it exists." - }, - "NO_ISSUANCE_ENTROPY": { - "type": "bytes32", - "description": "Issuance entropy of the NO mint. Same role, from the other defining outpoint." - } - }, - "actions": { - "IssueReissuanceTokens": { - "$comment": "The constructor: it is this action, not CreateMarket, that writes the instance file. That is forced by where the asset ids come from — they are derived from the outpoints THIS transaction spends, and nothing downstream can recover them (the reissuance token id and the asset id are sibling hashes of the entropy, so you cannot walk from one to the other). Capturing them here — create_instance reads them straight off the two inputs via $inputs.. — is the only point at which they exist and can still be recorded.\n\nWhy the bootstrap is two transactions at all: CreateMarket pays to the state-0 covenant address, and that address is a function of all four asset ids, which are functions of the outpoints being spent. The engine snapshots compile params BEFORE it resolves any input, so one action cannot both mint an asset and pay it to an address derived from that asset. This action sidesteps it by paying only to the wallet. Upstream Deadcat does both in one transaction (pset/creation.rs) because it computes the ids in Rust before building anything; the two-transaction bootstrap lands the market in exactly the same on-chain state.", - "description": "Step 1 of 2 in bootstrapping a market, and the action that defines it. Mints the YES and NO reissuance tokens — 1 unit each — from two of your L-BTC UTXOs and keeps them in your wallet, then writes the instance file recording the market's full definition: the four asset ids this transaction just fixed, plus the terms you supply below. NOTE: it mints ZERO units of the YES and NO tokens themselves. Only the minting rights exist at this point; outcome tokens appear at InitialIssuance, and they have to, because tokens minted here would be backed by no collateral at all. Run CreateMarket next to lock the two tokens into the covenant.", - "intent": "define a market backed by {params.COLLATERAL_ASSET_ID:symbol} and mint its minting rights", - "params": { - "ORACLE_PUBLIC_KEY": { - "type": "pubkey", - "description": "The oracle's x-only key. Baked into every one of this market's four addresses, so it cannot be changed later." - }, - "COLLATERAL_ASSET_ID": { - "type": "liquid.asset_id", - "description": "Asset backing the market. Use the L-BTC policy asset unless you know why you want something else." - }, - "COLLATERAL_PER_TOKEN": { - "type": "u64", - "default": "1000", - "description": "Satoshis behind one token. A YES/NO pair therefore costs twice this, and a winning token pays out twice this." - }, - "EXPIRY_TIME": { - "type": "u32", - "description": "Block height after which the market expires unresolved and everyone redeems at half rate. Leave the oracle real time to attest." - } - }, - "inputs": [ - { - "id": "yes_defining_in", - "description": "Wallet L-BTC UTXO whose outpoint defines the YES asset pair: YES_TOKEN_ASSET = AssetId::new_issuance(outpoint, zero contract hash) and YES_REISSUANCE_TOKEN = AssetId::new_reissuance_token(outpoint, zero contract hash, confidential=false). Issues 0 asset units and 1 inflation (reissuance) token — YES tokens themselves are not minted until InitialIssuance reissues against this one.", - "utxo_source": "wallet", - "asset": "lbtc", - "required_index": 0, - "issuance": { - "kind": "new", - "asset_amount_sat": 0, - "inflation_amount_sat": 1 - }, - "ui": { - "label": "input that defines the YES asset", - "role": "issuance" - } - }, - { - "id": "no_defining_in", - "description": "Wallet L-BTC UTXO whose outpoint defines the NO asset pair, the same way. Must be a different outpoint from yes_defining_in, or YES and NO would be the same asset.", - "utxo_source": "wallet", - "asset": "lbtc", - "required_index": 1, - "issuance": { - "kind": "new", - "asset_amount_sat": 0, - "inflation_amount_sat": 1 - }, - "ui": { - "label": "input that defines the NO asset", - "role": "issuance" - } - } - ], - "outputs": [ - { - "id": "yes_reissuance_out", - "description": "The YES reissuance token, 1 unit, to your wallet. The asset reference reads the id the engine derived from in[0] once it resolved. `inputs.` is the explicit spelling of an input reference; the bare `yes_defining_in.reissuance_token` also works but is ambiguous by shape with every other namespace.", - "destination": "wallet", - "asset": "inputs.yes_defining_in.reissuance_token", - "amount_sat": 1, - "confidential": false, - "required_index": 0, - "ui": { - "label": "YES minting right, held by you", - "role": "reissuance_token" - } - }, - { - "id": "no_reissuance_out", - "description": "The NO reissuance token, 1 unit, to your wallet.", - "destination": "wallet", - "asset": "inputs.no_defining_in.reissuance_token", - "amount_sat": 1, - "confidential": false, - "required_index": 1, - "ui": { - "label": "NO minting right, held by you", - "role": "reissuance_token" - } - }, - { - "id": "lbtc_change", - "description": "L-BTC change from the two defining inputs.", - "destination": "change", - "asset": "lbtc", - "optional": true, - "ui": { - "label": "change returned to you", - "role": "change" - } - } - ], - "create_instance": { - "$comment": "The four asset ids are read straight off the inputs that created them — `$inputs..` is a string lookup, unlike a bare expression, which is arithmetic and would reject a 32-byte id. Note `issued_asset`, NOT `asset`: on an input carrying an issuance those differ, and `asset` is the asset of the UTXO being spent (L-BTC here). The four terms come from this action's params. All eight are compile params of prediction_market.simf, so from here the market's four covenant addresses are fully determined and every later action can derive them from the instance file alone.", - "fields": { - "ORACLE_PUBLIC_KEY": "$params.ORACLE_PUBLIC_KEY", - "COLLATERAL_ASSET_ID": "$params.COLLATERAL_ASSET_ID", - "COLLATERAL_PER_TOKEN": "$params.COLLATERAL_PER_TOKEN", - "EXPIRY_TIME": "$params.EXPIRY_TIME", - "YES_TOKEN_ASSET": "$inputs.yes_defining_in.issued_asset", - "NO_TOKEN_ASSET": "$inputs.no_defining_in.issued_asset", - "YES_REISSUANCE_TOKEN": "$inputs.yes_defining_in.reissuance_token", - "NO_REISSUANCE_TOKEN": "$inputs.no_defining_in.reissuance_token", - "YES_ISSUANCE_ENTROPY": "$inputs.yes_defining_in.issuance_entropy", - "NO_ISSUANCE_ENTROPY": "$inputs.no_defining_in.issuance_entropy" - } - } - }, - "CreateMarket": { - "$comment": "Run IssueReissuanceTokens first: it writes the instance file this action reads. Every value here comes from `instance.*` — the four asset ids and the four terms — which is why this action takes no params at all. Those fields are loaded into the context before the engine snapshots compile params, so the state-0 address computes correctly even though the assets were minted in a different transaction.", - "description": "Step 2 of 2: lock the minting rights into the covenant. A PLAIN Elements transaction — no covenant input, nothing validated on-chain, so a malformed creation simply produces unspendable UTXOs — that moves both reissuance tokens from your wallet to the state-0 (dormant) address. No outcome tokens are minted and no collateral is deposited; that is InitialIssuance's job. Anyone evaluating a Deadcat market should re-check this transaction by hand: the covenant vouches for everything after it, and for nothing in it.", - "intent": "lock the minting rights into a market backed by {instance.COLLATERAL_ASSET_ID:symbol}", - "inputs": [ - { - "id": "yes_reissuance_in", - "description": "The YES reissuance token in your wallet, minted by IssueReissuanceTokens. Nothing is issued in this transaction — the token merely changes hands, from you to the covenant.", - "utxo_source": "wallet", - "asset": "instance.YES_REISSUANCE_TOKEN", - "amount_sat": 1, - "required_index": 0, - "ui": { - "label": "your YES minting right", - "role": "reissuance_token" - } - }, - { - "id": "no_reissuance_in", - "description": "The NO reissuance token in your wallet, from the same run.", - "utxo_source": "wallet", - "asset": "instance.NO_REISSUANCE_TOKEN", - "amount_sat": 1, - "required_index": 1, - "ui": { - "label": "your NO minting right", - "role": "reissuance_token" - } - }, - { - "id": "fee_input", - "description": "Wallet L-BTC UTXO covering the network fee. Needed here because both other inputs are token UTXOs with no L-BTC in them.", - "utxo_source": "wallet", - "asset": "lbtc", - "ui": { - "label": "input used for paying fees", - "role": "fee" - } - } - ], - "outputs": [ - { - "id": "yes_reissuance_out", - "description": "The YES reissuance token, 1 unit, parked at the state-0 (dormant) address. From here it can only ever move by a covenant spend.", - "destination": { - "utxo_type": "market_dormant" - }, - "asset": "instance.YES_REISSUANCE_TOKEN", - "amount_sat": 1, - "required_index": 0, - "ui": { - "label": "YES minting right, locked in the market", - "role": "reissuance_token" - } - }, - { - "id": "no_reissuance_out", - "description": "The NO reissuance token, 1 unit, at the same state-0 address.", - "destination": { - "utxo_type": "market_dormant" - }, - "asset": "instance.NO_REISSUANCE_TOKEN", - "amount_sat": 1, - "required_index": 1, - "ui": { - "label": "NO minting right, locked in the market", - "role": "reissuance_token" - } - }, - { - "id": "creation_change", - "description": "L-BTC change from the fee input.", - "destination": "change", - "asset": "lbtc", - "optional": true, - "ui": { - "label": "change returned to you", - "role": "change" - } - } - ] - }, - "PrepareInitialIssuance": { - "$comment": "A funding action, not a protocol one: nothing here touches the covenant. It exists because InitialIssuance declares no change output — the covenant pins the fee at output index 5, and a change output would displace it — so whatever L-BTC the collateral input carries beyond the collateral IS the fee. Feeding it an ordinary wallet UTXO therefore pays the entire remainder to miners. This action cuts a UTXO of exactly the right size first, which turns that behaviour from a hazard into an intent: the surplus becomes FEE_ALLOWANCE and nothing more.", - "description": "Cut an exactly-sized collateral UTXO for InitialIssuance. Reads COLLATERAL_PER_TOKEN and COLLATERAL_ASSET_ID from the instance and takes the number of pairs you intend to mint, so the output is PAIRS x 2 x COLLATERAL_PER_TOKEN plus a fee allowance — the exact amount InitialIssuance consumes. Run this first, then InitialIssuance, which will select the UTXO this produced (pin it with --input collateral_in=: if your wallet holds other L-BTC of a similar size).", - "intent": "cut a {params.PAIRS}-pair collateral UTXO for opening the market", - "params": { - "PAIRS": { - "type": "u64", - "description": "How many YES/NO pairs InitialIssuance will mint. Must match the PAIRS you pass there — a mismatch just means the sizing is wrong, and the surplus or shortfall shows up as a fee error." - }, - "FEE_ALLOWANCE": { - "type": "u64", - "default": "2000", - "description": "Extra L-BTC to include on top of the collateral, which becomes InitialIssuance's fee. That transaction is large (three inputs, two of them Simplicity covenant spends with sizeable witnesses), so leave real headroom. Anything unspent here is paid to miners, so do not inflate it either." - } - }, - "inputs": [ - { - "id": "funding_in", - "description": "Wallet L-BTC UTXO to cut from. Must hold at least the collateral plus the fee allowance plus this transaction's own fee.", - "utxo_source": "wallet", - "asset": "instance.COLLATERAL_ASSET_ID", - "amount_sat": { - "min_amount": "params.PAIRS * 2 * instance.COLLATERAL_PER_TOKEN + params.FEE_ALLOWANCE" - }, - "ui": { - "label": "wallet funds to cut the collateral from", - "role": "funding" - } - } - ], - "outputs": [ - { - "id": "sized_collateral", - "description": "The exactly-sized UTXO InitialIssuance will spend as its collateral input: PAIRS x 2 x COLLATERAL_PER_TOKEN of collateral, plus FEE_ALLOWANCE which becomes that transaction's fee.", - "destination": "wallet", - "asset": "instance.COLLATERAL_ASSET_ID", - "amount_sat": "params.PAIRS * 2 * instance.COLLATERAL_PER_TOKEN + params.FEE_ALLOWANCE", - "ui": { - "label": "collateral sized for opening the market", - "role": "collateral" - } - }, - { - "id": "funding_change", - "description": "The rest of the funding UTXO, back to your wallet. Present precisely because this action is allowed a change output, unlike InitialIssuance.", - "destination": "change", - "asset": "instance.COLLATERAL_ASSET_ID", - "optional": true, - "ui": { - "label": "change returned to you", - "role": "change" - } - } - ] - }, - "InitialIssuance": { - "$comment": "Path 1. Fee MUST land at output index 5, which it does only because no change output is declared here: the engine appends declared outputs, then any change, then the fee. Declaring a change output would push the fee to index 6 and the covenant would reject the spend. Size the collateral input exactly (see `prepare`) — surplus L-BTC is swallowed by the fee.", - "description": "First covenant-validated transaction: state 0 -> 1. Reissues the first batch of YES/NO pairs, deposits their collateral, and moves all three covenant UTXOs to the state-1 address. Collateral comes from your wallet, not from the covenant — in the dormant state there is no collateral UTXO yet.", - "intent": "open the market: mint {params.PAIRS} YES/NO pairs and lock their collateral", - "params": { - "PAIRS": { - "type": "u64", - "description": "How many matched YES/NO pairs to mint. You deposit PAIRS x 2 x COLLATERAL_PER_TOKEN and receive PAIRS YES tokens and PAIRS NO tokens — you are the market's first counterparty on both sides, and you can sell either leg." - } - }, - "inputs": [ - { - "id": "yes_reissuance_in", - "description": "The YES reissuance token at the state-0 address, input 0 — the covenant asserts current_index == 0 for this path. Its reissuance mints PAIRS YES tokens.", - "utxo_source": { - "utxo_type": "market_dormant" - }, - "asset": "instance.YES_REISSUANCE_TOKEN", - "amount_sat": 1, - "required_index": 0, - "issuance": { - "kind": "reissue", - "asset_amount_sat": "params.PAIRS", - "entropy": "instance.YES_ISSUANCE_ENTROPY", - "issued_asset": "instance.YES_TOKEN_ASSET" - }, - "witnesses": { - "STATE": { - "type": "simplicityhl", - "simplicity_type": "u64", - "value": "0", - "description": "Claimed state. Not trusted: main() recomputes the state-0 address from it and asserts the input is actually being spent from there, so lying is impossible rather than merely detectable." - }, - "PATH": { - "type": "simplicityhl", - "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", - "value": "Left(Left(Left(())))", - "description": "Path 1 — initial issuance. The seven paths are a nested Either tree; see witness.rs::build_path_value upstream." - }, - "BUDGET_PAD_A": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_B": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_C": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_D": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BURN_TOKEN_ASSET": "unused", - "ORACLE_OUTCOME_YES": "unused", - "ORACLE_SIGNATURE": "unused", - "PAIRS_BURNED": "unused", - "TOKENS_BURNED": "unused" - }, - "ui": { - "label": "YES minting right", - "role": "reissuance_token" - } - }, - { - "id": "no_reissuance_in", - "description": "The NO reissuance token, input 1. It runs path 7 (secondary covenant input), which only proves it is spent from the same address as input 0 and leaves every transaction-level check to input 0's path.", - "utxo_source": { - "utxo_type": "market_dormant" - }, - "asset": "instance.NO_REISSUANCE_TOKEN", - "amount_sat": 1, - "required_index": 1, - "issuance": { - "kind": "reissue", - "asset_amount_sat": "params.PAIRS", - "entropy": "instance.NO_ISSUANCE_ENTROPY", - "issued_asset": "instance.NO_TOKEN_ASSET" - }, - "witnesses": { - "STATE": { - "type": "simplicityhl", - "simplicity_type": "u64", - "value": "0" - }, - "PATH": { - "type": "simplicityhl", - "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", - "value": "Right(Right(()))", - "description": "Path 7 — secondary covenant input." - }, - "BUDGET_PAD_A": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_B": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_C": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_D": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BURN_TOKEN_ASSET": "unused", - "ORACLE_OUTCOME_YES": "unused", - "ORACLE_SIGNATURE": "unused", - "PAIRS_BURNED": "unused", - "TOKENS_BURNED": "unused" - }, - "ui": { - "label": "NO minting right", - "role": "reissuance_token" - } - }, - { - "id": "collateral_in", - "description": "Your collateral, input 2. Unlike every later issuance this comes from the wallet: the dormant market holds no collateral to consolidate with.", - "utxo_source": "wallet", - "asset": "instance.COLLATERAL_ASSET_ID", - "amount_sat": { - "min_amount": "params.PAIRS * 2 * instance.COLLATERAL_PER_TOKEN" - }, - "required_index": 2, - "ui": { - "label": "collateral you are depositing", - "role": "collateral" - } - } - ], - "outputs": [ - { - "id": "yes_reissuance_out", - "description": "YES reissuance token cycled forward to the state-1 address (output 0).", - "destination": { - "utxo_type": "market_unresolved" - }, - "asset": "instance.YES_REISSUANCE_TOKEN", - "amount_sat": 1, - "required_index": 0, - "ui": { - "label": "YES minting right, market now live", - "role": "reissuance_token" - } - }, - { - "id": "no_reissuance_out", - "description": "NO reissuance token cycled forward to the state-1 address (output 1).", - "destination": { - "utxo_type": "market_unresolved" - }, - "asset": "instance.NO_REISSUANCE_TOKEN", - "amount_sat": 1, - "required_index": 1, - "ui": { - "label": "NO minting right, market now live", - "role": "reissuance_token" - } - }, - { - "id": "collateral_out", - "description": "The market's one and only collateral UTXO (output 2), at the state-1 address. Exactly PAIRS x 2 x COLLATERAL_PER_TOKEN — the covenant computes this itself from the issuance amount and rejects anything else.", - "destination": { - "utxo_type": "market_unresolved" - }, - "asset": "instance.COLLATERAL_ASSET_ID", - "amount_sat": "params.PAIRS * 2 * instance.COLLATERAL_PER_TOKEN", - "required_index": 2, - "ui": { - "label": "collateral backing the market", - "role": "collateral" - } - }, - { - "id": "yes_tokens_out", - "description": "The minted YES tokens (output 3), to your wallet. Unconstrained by the covenant beyond asset balance — it only cares that YES and NO were minted in equal amounts and fully collateralised.", - "destination": "wallet", - "asset": "instance.YES_TOKEN_ASSET", - "amount_sat": "params.PAIRS", - "required_index": 3, - "ui": { - "label": "YES tokens minted to you", - "role": "outcome_token" - } - }, - { - "id": "no_tokens_out", - "description": "The minted NO tokens (output 4), to your wallet.", - "destination": "wallet", - "asset": "instance.NO_TOKEN_ASSET", - "amount_sat": "params.PAIRS", - "required_index": 4, - "ui": { - "label": "NO tokens minted to you", - "role": "outcome_token" - } - } - ] - }, - "MintPairs": { - "$comment": "Path 2. Same index-5 fee requirement as InitialIssuance, so again no change output is declared. The difference from path 1 is input 2: the existing collateral UTXO is consumed and re-emitted as old + new, which is what keeps the market to exactly one collateral UTXO.", - "description": "Mint more pairs into a live market (state 1 -> 1). Permissionless: anyone who deposits collateral gets tokens, there is no issuer and no allowlist. Consumes the market's collateral UTXO and re-emits the consolidated total.", - "intent": "mint {params.PAIRS} more YES/NO pairs into the market", - "params": { - "PAIRS": { - "type": "u64", - "description": "Additional pairs to mint. You deposit PAIRS x 2 x COLLATERAL_PER_TOKEN of new collateral on top of whatever the market already holds." - } - }, - "inputs": [ - { - "id": "yes_reissuance_in", - "description": "YES reissuance token from state 1, input 0 (the primary — current_index must be 0).", - "utxo_source": { - "utxo_type": "market_unresolved" - }, - "asset": "instance.YES_REISSUANCE_TOKEN", - "amount_sat": 1, - "required_index": 0, - "issuance": { - "kind": "reissue", - "asset_amount_sat": "params.PAIRS", - "entropy": "instance.YES_ISSUANCE_ENTROPY", - "issued_asset": "instance.YES_TOKEN_ASSET" - }, - "witnesses": { - "STATE": { - "type": "simplicityhl", - "simplicity_type": "u64", - "value": "1" - }, - "PATH": { - "type": "simplicityhl", - "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", - "value": "Left(Left(Right(())))", - "description": "Path 2 — subsequent issuance." - }, - "BUDGET_PAD_A": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_B": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_C": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_D": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BURN_TOKEN_ASSET": "unused", - "ORACLE_OUTCOME_YES": "unused", - "ORACLE_SIGNATURE": "unused", - "PAIRS_BURNED": "unused", - "TOKENS_BURNED": "unused" - }, - "ui": { - "label": "YES minting right", - "role": "reissuance_token" - } - }, - { - "id": "no_reissuance_in", - "description": "NO reissuance token from state 1, input 1, on path 7.", - "utxo_source": { - "utxo_type": "market_unresolved" - }, - "asset": "instance.NO_REISSUANCE_TOKEN", - "amount_sat": 1, - "required_index": 1, - "issuance": { - "kind": "reissue", - "asset_amount_sat": "params.PAIRS", - "entropy": "instance.NO_ISSUANCE_ENTROPY", - "issued_asset": "instance.NO_TOKEN_ASSET" - }, - "witnesses": { - "STATE": { - "type": "simplicityhl", - "simplicity_type": "u64", - "value": "1" - }, - "PATH": { - "type": "simplicityhl", - "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", - "value": "Right(Right(()))", - "description": "Path 7 — secondary covenant input." - }, - "BUDGET_PAD_A": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_B": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_C": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_D": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BURN_TOKEN_ASSET": "unused", - "ORACLE_OUTCOME_YES": "unused", - "ORACLE_SIGNATURE": "unused", - "PAIRS_BURNED": "unused", - "TOKENS_BURNED": "unused" - }, - "ui": { - "label": "NO minting right", - "role": "reissuance_token" - } - }, - { - "id": "existing_collateral_in", - "description": "The market's current collateral UTXO, input 2 — the covenant checks this index's script hash against the state-1 address by hand. Also on path 7. Its amount comes from the state file.", - "utxo_source": { - "utxo_type": "market_unresolved" - }, - "asset": "instance.COLLATERAL_ASSET_ID", - "required_index": 2, - "witnesses": { - "STATE": { - "type": "simplicityhl", - "simplicity_type": "u64", - "value": "1" - }, - "PATH": { - "type": "simplicityhl", - "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", - "value": "Right(Right(()))", - "description": "Path 7 — secondary covenant input." - }, - "BUDGET_PAD_A": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_B": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_C": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_D": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BURN_TOKEN_ASSET": "unused", - "ORACLE_OUTCOME_YES": "unused", - "ORACLE_SIGNATURE": "unused", - "PAIRS_BURNED": "unused", - "TOKENS_BURNED": "unused" - }, - "ui": { - "label": "collateral already in the market", - "role": "collateral" - } - }, - { - "id": "new_collateral_in", - "description": "Your new collateral, input 3, from the wallet.", - "utxo_source": "wallet", - "asset": "instance.COLLATERAL_ASSET_ID", - "amount_sat": { - "min_amount": "params.PAIRS * 2 * instance.COLLATERAL_PER_TOKEN" - }, - "required_index": 3, - "ui": { - "label": "collateral you are adding", - "role": "collateral" - } - } - ], - "outputs": [ - { - "id": "yes_reissuance_out", - "description": "YES reissuance token back to state 1 (output 0).", - "destination": { - "utxo_type": "market_unresolved" - }, - "asset": "instance.YES_REISSUANCE_TOKEN", - "amount_sat": 1, - "required_index": 0, - "ui": { - "label": "YES minting right", - "role": "reissuance_token" - } - }, - { - "id": "no_reissuance_out", - "description": "NO reissuance token back to state 1 (output 1).", - "destination": { - "utxo_type": "market_unresolved" - }, - "asset": "instance.NO_REISSUANCE_TOKEN", - "amount_sat": 1, - "required_index": 1, - "ui": { - "label": "NO minting right", - "role": "reissuance_token" - } - }, - { - "id": "collateral_out", - "description": "The re-consolidated collateral UTXO (output 2): the old amount plus this issuance's deposit, computed by the covenant as safe_add(old, pairs x 2 x CPT).", - "destination": { - "utxo_type": "market_unresolved" - }, - "asset": "instance.COLLATERAL_ASSET_ID", - "amount_sat": "existing_collateral_in.amount_sat + params.PAIRS * 2 * instance.COLLATERAL_PER_TOKEN", - "required_index": 2, - "ui": { - "label": "all collateral backing the market", - "role": "collateral" - } - }, - { - "id": "yes_tokens_out", - "description": "Newly minted YES tokens to your wallet (output 3).", - "destination": "wallet", - "asset": "instance.YES_TOKEN_ASSET", - "amount_sat": "params.PAIRS", - "required_index": 3, - "ui": { - "label": "YES tokens minted to you", - "role": "outcome_token" - } - }, - { - "id": "no_tokens_out", - "description": "Newly minted NO tokens to your wallet (output 4).", - "destination": "wallet", - "asset": "instance.NO_TOKEN_ASSET", - "amount_sat": "params.PAIRS", - "required_index": 4, - "ui": { - "label": "NO tokens minted to you", - "role": "outcome_token" - } - } - ] - }, - "ResolveYes": { - "$comment": "Path 3 with ORACLE_OUTCOME_YES = true. Modelled as two actions rather than one with a runtime outcome because the destination address IS the outcome — state 2 and state 3 are different covenant addresses, and the engine has no conditional destination. The covenant demands exactly 4 outputs, so no change output may be declared: the fee absorbs any L-BTC surplus, which lands it at index 3 as required.", - "description": "Commit a YES outcome on-chain (state 1 -> 2). Permissionless to submit: the oracle signs a message that mentions only the market, never this transaction, so anyone holding the attestation can post it. Nothing moves except the three covenant UTXOs, which all shift to the state-2 address; the collateral amount is preserved exactly.", - "intent": "resolve the market YES using the oracle's attestation", - "params": { - "ORACLE_SIGNATURE": { - "type": "bytes", - "description": "The oracle's 64-byte BIP340 signature over sha256(MARKET_ID || 0x01), where MARKET_ID = sha256(YES_TOKEN_ASSET || NO_TOKEN_ASSET) over the asset ids in internal byte order. Paste it 0x-prefixed. Produced off-chain; the wallet cannot compute it." - } - }, - "inputs": [ - { - "id": "yes_reissuance_in", - "description": "YES reissuance token, input 0 — the primary input, and the one that verifies the oracle signature.", - "utxo_source": { - "utxo_type": "market_unresolved" - }, - "asset": "instance.YES_REISSUANCE_TOKEN", - "amount_sat": 1, - "required_index": 0, - "witnesses": { - "STATE": { - "type": "simplicityhl", - "simplicity_type": "u64", - "value": "1" - }, - "PATH": { - "type": "simplicityhl", - "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", - "value": "Left(Right(Left(())))", - "description": "Path 3 — oracle resolve." - }, - "ORACLE_OUTCOME_YES": { - "type": "simplicityhl", - "simplicity_type": "bool", - "value": "true", - "description": "Picks the YES branch, which both selects the signed message byte 0x01 and sends every covenant output to the state-2 address." - }, - "ORACLE_SIGNATURE": { - "type": "simplicityhl", - "simplicity_type": "[u8; 64]", - "value": "params.ORACLE_SIGNATURE", - "description": "Substituted from the action param before the value is parsed." - }, - "BUDGET_PAD_A": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_B": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_C": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_D": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BURN_TOKEN_ASSET": "unused", - "PAIRS_BURNED": "unused", - "TOKENS_BURNED": "unused" - }, - "ui": { - "label": "YES minting right", - "role": "reissuance_token" - } - }, - { - "id": "no_reissuance_in", - "description": "NO reissuance token, input 1, on path 7.", - "utxo_source": { - "utxo_type": "market_unresolved" - }, - "asset": "instance.NO_REISSUANCE_TOKEN", - "amount_sat": 1, - "required_index": 1, - "witnesses": { - "STATE": { - "type": "simplicityhl", - "simplicity_type": "u64", - "value": "1" - }, - "PATH": { - "type": "simplicityhl", - "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", - "value": "Right(Right(()))" - }, - "BUDGET_PAD_A": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_B": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_C": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_D": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BURN_TOKEN_ASSET": "unused", - "ORACLE_OUTCOME_YES": "unused", - "ORACLE_SIGNATURE": "unused", - "PAIRS_BURNED": "unused", - "TOKENS_BURNED": "unused" - }, - "ui": { - "label": "NO minting right", - "role": "reissuance_token" - } - }, - { - "id": "collateral_in", - "description": "The market's collateral UTXO, input 2, on path 7. Its amount is read from the state file and must be reproduced exactly on output 2.", - "utxo_source": { - "utxo_type": "market_unresolved" - }, - "asset": "instance.COLLATERAL_ASSET_ID", - "required_index": 2, - "witnesses": { - "STATE": { - "type": "simplicityhl", - "simplicity_type": "u64", - "value": "1" - }, - "PATH": { - "type": "simplicityhl", - "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", - "value": "Right(Right(()))" - }, - "BUDGET_PAD_A": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_B": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_C": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_D": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BURN_TOKEN_ASSET": "unused", - "ORACLE_OUTCOME_YES": "unused", - "ORACLE_SIGNATURE": "unused", - "PAIRS_BURNED": "unused", - "TOKENS_BURNED": "unused" - }, - "ui": { - "label": "collateral backing the market", - "role": "collateral" - } - } - ], - "outputs": [ - { - "id": "yes_reissuance_out", - "description": "YES reissuance token moved to the state-2 address (output 0).", - "destination": { - "utxo_type": "market_resolved_yes" - }, - "asset": "instance.YES_REISSUANCE_TOKEN", - "amount_sat": 1, - "required_index": 0, - "ui": { - "label": "YES minting right, market resolved", - "role": "reissuance_token" - } - }, - { - "id": "no_reissuance_out", - "description": "NO reissuance token moved to the state-2 address (output 1). Minting is over, but the tokens are carried along so nothing is stranded.", - "destination": { - "utxo_type": "market_resolved_yes" - }, - "asset": "instance.NO_REISSUANCE_TOKEN", - "amount_sat": 1, - "required_index": 1, - "ui": { - "label": "NO minting right, market resolved", - "role": "reissuance_token" - } - }, - { - "id": "collateral_out", - "description": "All collateral moved intact to the state-2 address (output 2). No value enters or leaves in a resolve.", - "destination": { - "utxo_type": "market_resolved_yes" - }, - "asset": "instance.COLLATERAL_ASSET_ID", - "amount_sat": "collateral_in.amount_sat", - "required_index": 2, - "ui": { - "label": "collateral, now redeemable by YES", - "role": "collateral" - } - } - ] - }, - "ResolveNo": { - "$comment": "Path 3 with ORACLE_OUTCOME_YES = false. Identical to ResolveYes except the signed outcome byte is 0x00 and every covenant output goes to state 3. Same 4-output rule: declare no change.", - "description": "Commit a NO outcome on-chain (state 1 -> 3). Whichever of ResolveYes / ResolveNo confirms first wins — that is the equivocation protection. An oracle that signs both outcomes cannot start a race to drain the pool, because after the first resolve there is no path back to state 1 and none between states 2 and 3.", - "intent": "resolve the market NO using the oracle's attestation", - "params": { - "ORACLE_SIGNATURE": { - "type": "bytes", - "description": "The oracle's 64-byte BIP340 signature over sha256(MARKET_ID || 0x00). Paste it 0x-prefixed." - } - }, - "inputs": [ - { - "id": "yes_reissuance_in", - "description": "YES reissuance token, input 0 — still the primary input on a NO resolve; the covenant's layout is fixed regardless of outcome.", - "utxo_source": { - "utxo_type": "market_unresolved" - }, - "asset": "instance.YES_REISSUANCE_TOKEN", - "amount_sat": 1, - "required_index": 0, - "witnesses": { - "STATE": { - "type": "simplicityhl", - "simplicity_type": "u64", - "value": "1" - }, - "PATH": { - "type": "simplicityhl", - "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", - "value": "Left(Right(Left(())))", - "description": "Path 3 — oracle resolve." - }, - "ORACLE_OUTCOME_YES": { - "type": "simplicityhl", - "simplicity_type": "bool", - "value": "false", - "description": "Picks the NO branch: outcome byte 0x00 and state-3 outputs." - }, - "ORACLE_SIGNATURE": { - "type": "simplicityhl", - "simplicity_type": "[u8; 64]", - "value": "params.ORACLE_SIGNATURE" - }, - "BUDGET_PAD_A": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_B": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_C": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_D": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BURN_TOKEN_ASSET": "unused", - "PAIRS_BURNED": "unused", - "TOKENS_BURNED": "unused" - }, - "ui": { - "label": "YES minting right", - "role": "reissuance_token" - } - }, - { - "id": "no_reissuance_in", - "description": "NO reissuance token, input 1, on path 7.", - "utxo_source": { - "utxo_type": "market_unresolved" - }, - "asset": "instance.NO_REISSUANCE_TOKEN", - "amount_sat": 1, - "required_index": 1, - "witnesses": { - "STATE": { - "type": "simplicityhl", - "simplicity_type": "u64", - "value": "1" - }, - "PATH": { - "type": "simplicityhl", - "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", - "value": "Right(Right(()))" - }, - "BUDGET_PAD_A": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_B": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_C": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_D": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BURN_TOKEN_ASSET": "unused", - "ORACLE_OUTCOME_YES": "unused", - "ORACLE_SIGNATURE": "unused", - "PAIRS_BURNED": "unused", - "TOKENS_BURNED": "unused" - }, - "ui": { - "label": "NO minting right", - "role": "reissuance_token" - } - }, - { - "id": "collateral_in", - "description": "The market's collateral UTXO, input 2, on path 7.", - "utxo_source": { - "utxo_type": "market_unresolved" - }, - "asset": "instance.COLLATERAL_ASSET_ID", - "required_index": 2, - "witnesses": { - "STATE": { - "type": "simplicityhl", - "simplicity_type": "u64", - "value": "1" - }, - "PATH": { - "type": "simplicityhl", - "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", - "value": "Right(Right(()))" - }, - "BUDGET_PAD_A": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_B": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_C": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_D": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BURN_TOKEN_ASSET": "unused", - "ORACLE_OUTCOME_YES": "unused", - "ORACLE_SIGNATURE": "unused", - "PAIRS_BURNED": "unused", - "TOKENS_BURNED": "unused" - }, - "ui": { - "label": "collateral backing the market", - "role": "collateral" - } - } - ], - "outputs": [ - { - "id": "yes_reissuance_out", - "description": "YES reissuance token moved to the state-3 address (output 0).", - "destination": { - "utxo_type": "market_resolved_no" - }, - "asset": "instance.YES_REISSUANCE_TOKEN", - "amount_sat": 1, - "required_index": 0, - "ui": { - "label": "YES minting right, market resolved", - "role": "reissuance_token" - } - }, - { - "id": "no_reissuance_out", - "description": "NO reissuance token moved to the state-3 address (output 1).", - "destination": { - "utxo_type": "market_resolved_no" - }, - "asset": "instance.NO_REISSUANCE_TOKEN", - "amount_sat": 1, - "required_index": 1, - "ui": { - "label": "NO minting right, market resolved", - "role": "reissuance_token" - } - }, - { - "id": "collateral_out", - "description": "All collateral moved intact to the state-3 address (output 2).", - "destination": { - "utxo_type": "market_resolved_no" - }, - "asset": "instance.COLLATERAL_ASSET_ID", - "amount_sat": "collateral_in.amount_sat", - "required_index": 2, - "ui": { - "label": "collateral, now redeemable by NO", - "role": "collateral" - } - } - ] - }, - "RedeemYes": { - "allow_change": "lbtc_only", - "$comment": "Path 4 in state 2, PARTIAL form (remaining collateral > 0). The full-drain form is a different output layout — the burn becomes output 0 and there is no collateral output at all — and needs its own action, not modelled here. The fee is checked at num_outputs - 1, so change outputs are fine on this path.", - "description": "Redeem winning YES tokens for collateral (state 2). Each YES token draws 2 x COLLATERAL_PER_TOKEN — the whole pair's backing, both your stake and the loser's — so the pool drains exactly as all winners redeem, with nothing stranded. Losing NO tokens have no path; they are simply worthless.", - "intent": "redeem {params.TOKENS_BURNED} winning YES tokens for collateral", - "params": { - "TOKENS_BURNED": { - "type": "u64", - "description": "How many YES tokens to burn. Must be strictly less than the market's total remaining backing, or this becomes a full drain and needs the other output layout." - } - }, - "inputs": [ - { - "id": "collateral_in", - "description": "The market's collateral UTXO at the state-2 address, input 0 — this path reads amounts from index 0, so it must come first.", - "utxo_source": { - "utxo_type": "market_resolved_yes" - }, - "asset": "instance.COLLATERAL_ASSET_ID", - "required_index": 0, - "witnesses": { - "STATE": { - "type": "simplicityhl", - "simplicity_type": "u64", - "value": "2", - "description": "State 2 also selects YES_TOKEN_ASSET as the only asset the covenant will accept as a burn." - }, - "PATH": { - "type": "simplicityhl", - "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", - "value": "Left(Right(Right(())))", - "description": "Path 4 — post-resolution redemption." - }, - "TOKENS_BURNED": { - "type": "simplicityhl", - "simplicity_type": "u64", - "value": "params.TOKENS_BURNED", - "description": "Drives both the burn output amount and the payout; the covenant checks both against it." - }, - "BUDGET_PAD_A": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_B": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_C": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_D": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BURN_TOKEN_ASSET": "unused", - "ORACLE_OUTCOME_YES": "unused", - "ORACLE_SIGNATURE": "unused", - "PAIRS_BURNED": "unused" - }, - "ui": { - "label": "collateral pool being drawn down", - "role": "collateral" - } - }, - { - "id": "yes_tokens_in", - "description": "Your winning YES tokens, from the wallet. Size this exactly — any surplus becomes a change output, which is allowed here but pointless.", - "utxo_source": "wallet", - "asset": "instance.YES_TOKEN_ASSET", - "amount_sat": { - "min_amount": "params.TOKENS_BURNED" - }, - "ui": { - "label": "winning YES tokens you are burning", - "role": "outcome_token" - } - } - ], - "outputs": [ - { - "id": "collateral_out", - "description": "What is left of the pool, back to the state-2 address (output 0). The reissuance tokens are untouched by this transaction and stay where they are by consensus.", - "destination": { - "utxo_type": "market_resolved_yes" - }, - "asset": "instance.COLLATERAL_ASSET_ID", - "amount_sat": "collateral_in.amount_sat - params.TOKENS_BURNED * 2 * instance.COLLATERAL_PER_TOKEN", - "required_index": 0, - "ui": { - "label": "collateral left for other winners", - "role": "collateral" - } - }, - { - "id": "yes_tokens_burn", - "description": "The YES tokens destroyed (output 1). The covenant requires a ZERO-LENGTH scriptPubKey here — sha256 of the empty script — not the 1-byte OP_RETURN this engine emits.", - "destination": { - "type": "burn" - }, - "asset": "instance.YES_TOKEN_ASSET", - "amount_sat": "params.TOKENS_BURNED", - "required_index": 1, - "ui": { - "label": "YES tokens burned", - "role": "burn", - "group": "burned / protocol data" - } - }, - { - "id": "payout_out", - "description": "Your winnings (output 2): TOKENS_BURNED x 2 x COLLATERAL_PER_TOKEN. The covenant does not check this leg at all — it constrains what stays behind and what is burned, and the rest follows from asset balance.", - "destination": "wallet", - "asset": "instance.COLLATERAL_ASSET_ID", - "amount_sat": "params.TOKENS_BURNED * 2 * instance.COLLATERAL_PER_TOKEN", - "required_index": 2, - "ui": { - "label": "your winnings", - "role": "settlement" - } - }, - { - "id": "token_change", - "description": "YES token change, if your input held more than you burned.", - "destination": "change", - "asset": "instance.YES_TOKEN_ASSET", - "optional": true, - "ui": { - "label": "unburned YES tokens returned to you", - "role": "change" - } - } - ] - }, - "RedeemNo": { - "allow_change": "lbtc_only", - "$comment": "Path 4 in state 3 — the mirror of RedeemYes. Same partial-only caveat.", - "description": "Redeem winning NO tokens for collateral (state 3). Each NO token draws 2 x COLLATERAL_PER_TOKEN.", - "intent": "redeem {params.TOKENS_BURNED} winning NO tokens for collateral", - "params": { - "TOKENS_BURNED": { - "type": "u64", - "description": "How many NO tokens to burn." - } - }, - "inputs": [ - { - "id": "collateral_in", - "description": "The market's collateral UTXO at the state-3 address, input 0.", - "utxo_source": { - "utxo_type": "market_resolved_no" - }, - "asset": "instance.COLLATERAL_ASSET_ID", - "required_index": 0, - "witnesses": { - "STATE": { - "type": "simplicityhl", - "simplicity_type": "u64", - "value": "3", - "description": "State 3 selects NO_TOKEN_ASSET as the winning side." - }, - "PATH": { - "type": "simplicityhl", - "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", - "value": "Left(Right(Right(())))", - "description": "Path 4 — post-resolution redemption." - }, - "TOKENS_BURNED": { - "type": "simplicityhl", - "simplicity_type": "u64", - "value": "params.TOKENS_BURNED" - }, - "BUDGET_PAD_A": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_B": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_C": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_D": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BURN_TOKEN_ASSET": "unused", - "ORACLE_OUTCOME_YES": "unused", - "ORACLE_SIGNATURE": "unused", - "PAIRS_BURNED": "unused" - }, - "ui": { - "label": "collateral pool being drawn down", - "role": "collateral" - } - }, - { - "id": "no_tokens_in", - "description": "Your winning NO tokens, from the wallet.", - "utxo_source": "wallet", - "asset": "instance.NO_TOKEN_ASSET", - "amount_sat": { - "min_amount": "params.TOKENS_BURNED" - }, - "ui": { - "label": "winning NO tokens you are burning", - "role": "outcome_token" - } - } - ], - "outputs": [ - { - "id": "collateral_out", - "description": "Remaining pool back to the state-3 address (output 0).", - "destination": { - "utxo_type": "market_resolved_no" - }, - "asset": "instance.COLLATERAL_ASSET_ID", - "amount_sat": "collateral_in.amount_sat - params.TOKENS_BURNED * 2 * instance.COLLATERAL_PER_TOKEN", - "required_index": 0, - "ui": { - "label": "collateral left for other winners", - "role": "collateral" - } - }, - { - "id": "no_tokens_burn", - "description": "The NO tokens destroyed (output 1). Same zero-length-script requirement as RedeemYes.", - "destination": { - "type": "burn" - }, - "asset": "instance.NO_TOKEN_ASSET", - "amount_sat": "params.TOKENS_BURNED", - "required_index": 1, - "ui": { - "label": "NO tokens burned", - "role": "burn", - "group": "burned / protocol data" - } - }, - { - "id": "payout_out", - "description": "Your winnings (output 2).", - "destination": "wallet", - "asset": "instance.COLLATERAL_ASSET_ID", - "amount_sat": "params.TOKENS_BURNED * 2 * instance.COLLATERAL_PER_TOKEN", - "required_index": 2, - "ui": { - "label": "your winnings", - "role": "settlement" - } - }, - { - "id": "token_change", - "description": "NO token change, if your input held more than you burned.", - "destination": "change", - "asset": "instance.NO_TOKEN_ASSET", - "optional": true, - "ui": { - "label": "unburned NO tokens returned to you", - "role": "change" - } - } - ] - }, - "RedeemExpired": { - "allow_change": "lbtc_only", - "$comment": "Path 5, partial form. NOT EXECUTABLE YET: this is the one Deadcat path that calls jet::check_lock_height(EXPIRY_TIME), an absolute CLTV, and the engine cannot set a transaction nLockTime — see meta/tasks/upnext/12-engine-absolute-locktime.md. (The pre-expiry paths are fine: they assert lock_time < EXPIRY_TIME, which a locktime of 0 satisfies.) BURN_TOKEN_ASSET must be supplied byte-reversed, because witness values are parsed as raw SimplicityHL literals with no liquid.asset_id type hint to trigger the reversal that compile params get.", - "description": "Redeem after the market expired unresolved (state 1, at or after EXPIRY_TIME). Both sides redeem at 1 x COLLATERAL_PER_TOKEN — half the winner's rate — so YES and NO holders are made whole together and the pool drains exactly. This is the escape hatch for an oracle that never attests.", - "intent": "redeem {params.TOKENS_BURNED} tokens from the expired market", - "params": { - "TOKENS_BURNED": { - "type": "u64", - "description": "How many tokens to burn, of whichever side you hold." - }, - "BURN_TOKEN_ASSET": { - "type": "bytes", - "description": "The asset id of the side you are burning — YES_TOKEN_ASSET or NO_TOKEN_ASSET — 0x-prefixed and in INTERNAL byte order, i.e. the display id reversed. The covenant accepts either, and rejects anything else." - }, - "BURN_TOKEN_ASSET_DISPLAY": { - "type": "liquid.asset_id", - "description": "The same asset id in normal display order, used for the transaction's burn output and token input. Must be the reverse of BURN_TOKEN_ASSET." - } - }, - "inputs": [ - { - "id": "collateral_in", - "description": "The market's collateral UTXO, still at the state-1 address, input 0. The sequence below is ENABLE_LOCKTIME_NO_RBF: any value other than 0xFFFFFFFF enables the nLockTime that check_lock_height reads. The engine will warn that the BIP68 disable bit is set — expected, since this timeout is an absolute height.", - "utxo_source": { - "utxo_type": "market_unresolved" - }, - "asset": "instance.COLLATERAL_ASSET_ID", - "required_index": 0, - "sequence": 4294967294, - "witnesses": { - "STATE": { - "type": "simplicityhl", - "simplicity_type": "u64", - "value": "1" - }, - "PATH": { - "type": "simplicityhl", - "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", - "value": "Right(Left(Left(())))", - "description": "Path 5 — expiry redemption." - }, - "TOKENS_BURNED": { - "type": "simplicityhl", - "simplicity_type": "u64", - "value": "params.TOKENS_BURNED" - }, - "BURN_TOKEN_ASSET": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "params.BURN_TOKEN_ASSET", - "description": "Tells the covenant which side you are burning. It checks the value equals YES_TOKEN_ASSET or NO_TOKEN_ASSET and then holds output 1 to it." - }, - "BUDGET_PAD_A": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_B": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_C": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_D": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "ORACLE_OUTCOME_YES": "unused", - "ORACLE_SIGNATURE": "unused", - "PAIRS_BURNED": "unused" - }, - "ui": { - "label": "collateral pool being drawn down", - "role": "collateral" - } - }, - { - "id": "tokens_in", - "description": "The tokens you are burning, from the wallet — either side.", - "utxo_source": "wallet", - "asset": "params.BURN_TOKEN_ASSET_DISPLAY", - "amount_sat": { - "min_amount": "params.TOKENS_BURNED" - }, - "ui": { - "label": "tokens you are burning", - "role": "outcome_token" - } - } - ], - "outputs": [ - { - "id": "collateral_out", - "description": "Remaining pool back to the state-1 address (output 0) — the market never leaves state 1 on this path.", - "destination": { - "utxo_type": "market_unresolved" - }, - "asset": "instance.COLLATERAL_ASSET_ID", - "amount_sat": "collateral_in.amount_sat - params.TOKENS_BURNED * instance.COLLATERAL_PER_TOKEN", - "required_index": 0, - "ui": { - "label": "collateral left for other holders", - "role": "collateral" - } - }, - { - "id": "tokens_burn", - "description": "The tokens destroyed (output 1). Zero-length script required, as on the other burn paths.", - "destination": { - "type": "burn" - }, - "asset": "params.BURN_TOKEN_ASSET_DISPLAY", - "amount_sat": "params.TOKENS_BURNED", - "required_index": 1, - "ui": { - "label": "tokens burned", - "role": "burn", - "group": "burned / protocol data" - } - }, - { - "id": "payout_out", - "description": "Your refund (output 2): TOKENS_BURNED x COLLATERAL_PER_TOKEN, half the resolved rate, because the other half belongs to the holder of the matching token on the other side.", - "destination": "wallet", - "asset": "instance.COLLATERAL_ASSET_ID", - "amount_sat": "params.TOKENS_BURNED * instance.COLLATERAL_PER_TOKEN", - "required_index": 2, - "ui": { - "label": "your refund", - "role": "settlement" - } - } - ] - }, - "CancelPairs": { - "allow_change": "lbtc_only", - "$comment": "Path 6, partial form (state 1 -> 1). The full form, which drains the pool to zero and cycles the reissuance tokens back to state 0, is CancelAll.", - "description": "Unwind part of your position while the market is unresolved. Burn matched YES and NO in equal amounts and take back the full 2 x COLLATERAL_PER_TOKEN per pair — you are undoing an issuance, so there is no loss and no time constraint. Only matched pairs qualify: burning one side alone would leave the other side under-collateralised.", - "intent": "cancel {params.PAIRS_BURNED} pairs and reclaim their collateral", - "params": { - "PAIRS_BURNED": { - "type": "u64", - "description": "How many matched pairs to burn. You must hold this many of BOTH YES and NO." - } - }, - "inputs": [ - { - "id": "collateral_in", - "description": "The market's collateral UTXO, input 0.", - "utxo_source": { - "utxo_type": "market_unresolved" - }, - "asset": "instance.COLLATERAL_ASSET_ID", - "required_index": 0, - "witnesses": { - "STATE": { - "type": "simplicityhl", - "simplicity_type": "u64", - "value": "1" - }, - "PATH": { - "type": "simplicityhl", - "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", - "value": "Right(Left(Right(())))", - "description": "Path 6 — cancellation. The partial and full forms are the same path; the covenant picks between them by testing whether any collateral remains." - }, - "PAIRS_BURNED": { - "type": "simplicityhl", - "simplicity_type": "u64", - "value": "params.PAIRS_BURNED" - }, - "BUDGET_PAD_A": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_B": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_C": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_D": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BURN_TOKEN_ASSET": "unused", - "ORACLE_OUTCOME_YES": "unused", - "ORACLE_SIGNATURE": "unused", - "TOKENS_BURNED": "unused" - }, - "ui": { - "label": "collateral pool being drawn down", - "role": "collateral" - } - }, - { - "id": "yes_tokens_in", - "description": "Your YES tokens, from the wallet.", - "utxo_source": "wallet", - "asset": "instance.YES_TOKEN_ASSET", - "amount_sat": { - "min_amount": "params.PAIRS_BURNED" - }, - "ui": { - "label": "YES side of the pairs you are burning", - "role": "outcome_token" - } - }, - { - "id": "no_tokens_in", - "description": "Your NO tokens, from the wallet, in the same amount.", - "utxo_source": "wallet", - "asset": "instance.NO_TOKEN_ASSET", - "amount_sat": { - "min_amount": "params.PAIRS_BURNED" - }, - "ui": { - "label": "NO side of the pairs you are burning", - "role": "outcome_token" - } - } - ], - "outputs": [ - { - "id": "collateral_out", - "description": "Remaining pool back to the state-1 address (output 0). Non-zero is what makes this the partial branch — if it were zero the covenant would demand CancelAll's layout instead.", - "destination": { - "utxo_type": "market_unresolved" - }, - "asset": "instance.COLLATERAL_ASSET_ID", - "amount_sat": "collateral_in.amount_sat - params.PAIRS_BURNED * 2 * instance.COLLATERAL_PER_TOKEN", - "required_index": 0, - "ui": { - "label": "collateral still backing the market", - "role": "collateral" - } - }, - { - "id": "yes_tokens_burn", - "description": "YES tokens destroyed (output 1). Zero-length script required.", - "destination": { - "type": "burn" - }, - "asset": "instance.YES_TOKEN_ASSET", - "amount_sat": "params.PAIRS_BURNED", - "required_index": 1, - "ui": { - "label": "YES tokens burned", - "role": "burn", - "group": "burned / protocol data" - } - }, - { - "id": "no_tokens_burn", - "description": "NO tokens destroyed (output 2), same amount — the covenant checks both indices, which is how equal burning is enforced.", - "destination": { - "type": "burn" - }, - "asset": "instance.NO_TOKEN_ASSET", - "amount_sat": "params.PAIRS_BURNED", - "required_index": 2, - "ui": { - "label": "NO tokens burned", - "role": "burn", - "group": "burned / protocol data" - } - }, - { - "id": "refund_out", - "description": "Your refund (output 3): the full deposit for those pairs, PAIRS_BURNED x 2 x COLLATERAL_PER_TOKEN.", - "destination": "wallet", - "asset": "instance.COLLATERAL_ASSET_ID", - "amount_sat": "params.PAIRS_BURNED * 2 * instance.COLLATERAL_PER_TOKEN", - "required_index": 3, - "ui": { - "label": "collateral refunded to you", - "role": "settlement" - } - } - ] - }, - "CancelAll": { - "allow_change": "lbtc_only", - "$comment": "Path 6, full form (state 1 -> 0). PAIRS_BURNED must equal the whole pool: collateral_in.amount_sat / (2 x COLLATERAL_PER_TOKEN). If it does not, the covenant takes the partial branch instead and this output layout is rejected.", - "description": "Wind the market all the way down. Burn every outstanding pair, take back all the collateral, and cycle both reissuance tokens back to the state-0 address so the market is dormant rather than dead. Without this the tokens would be stranded at a collateral-less state-1 address and the market could never be reissued — the reason the dormant state exists at all (design doc section 5.3).", - "intent": "wind the market down and return it to dormant", - "params": { - "PAIRS_BURNED": { - "type": "u64", - "description": "Every outstanding pair. Must equal the pool's total collateral divided by 2 x COLLATERAL_PER_TOKEN, exactly." - } - }, - "inputs": [ - { - "id": "collateral_in", - "description": "The market's collateral UTXO, input 0 — the primary input, running path 6.", - "utxo_source": { - "utxo_type": "market_unresolved" - }, - "asset": "instance.COLLATERAL_ASSET_ID", - "required_index": 0, - "witnesses": { - "STATE": { - "type": "simplicityhl", - "simplicity_type": "u64", - "value": "1" - }, - "PATH": { - "type": "simplicityhl", - "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", - "value": "Right(Left(Right(())))", - "description": "Path 6 — cancellation, full branch (chosen by the covenant when nothing remains)." - }, - "PAIRS_BURNED": { - "type": "simplicityhl", - "simplicity_type": "u64", - "value": "params.PAIRS_BURNED" - }, - "BUDGET_PAD_A": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_B": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_C": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_D": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BURN_TOKEN_ASSET": "unused", - "ORACLE_OUTCOME_YES": "unused", - "ORACLE_SIGNATURE": "unused", - "TOKENS_BURNED": "unused" - }, - "ui": { - "label": "the whole collateral pool", - "role": "collateral" - } - }, - { - "id": "yes_reissuance_in", - "description": "YES reissuance token, input 1 — the full branch verifies it at exactly this index. Runs path 7.", - "utxo_source": { - "utxo_type": "market_unresolved" - }, - "asset": "instance.YES_REISSUANCE_TOKEN", - "amount_sat": 1, - "required_index": 1, - "witnesses": { - "STATE": { - "type": "simplicityhl", - "simplicity_type": "u64", - "value": "1" - }, - "PATH": { - "type": "simplicityhl", - "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", - "value": "Right(Right(()))" - }, - "BUDGET_PAD_A": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_B": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_C": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_D": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BURN_TOKEN_ASSET": "unused", - "ORACLE_OUTCOME_YES": "unused", - "ORACLE_SIGNATURE": "unused", - "PAIRS_BURNED": "unused", - "TOKENS_BURNED": "unused" - }, - "ui": { - "label": "YES minting right", - "role": "reissuance_token" - } - }, - { - "id": "no_reissuance_in", - "description": "NO reissuance token, input 2, also on path 7.", - "utxo_source": { - "utxo_type": "market_unresolved" - }, - "asset": "instance.NO_REISSUANCE_TOKEN", - "amount_sat": 1, - "required_index": 2, - "witnesses": { - "STATE": { - "type": "simplicityhl", - "simplicity_type": "u64", - "value": "1" - }, - "PATH": { - "type": "simplicityhl", - "simplicity_type": "Either, Either<(), ()>>, Either, ()>>", - "value": "Right(Right(()))" - }, - "BUDGET_PAD_A": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_B": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_C": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BUDGET_PAD_D": { - "type": "simplicityhl", - "simplicity_type": "u256", - "value": "0" - }, - "BURN_TOKEN_ASSET": "unused", - "ORACLE_OUTCOME_YES": "unused", - "ORACLE_SIGNATURE": "unused", - "PAIRS_BURNED": "unused", - "TOKENS_BURNED": "unused" - }, - "ui": { - "label": "NO minting right", - "role": "reissuance_token" - } - }, - { - "id": "yes_tokens_in", - "description": "All outstanding YES tokens, from the wallet.", - "utxo_source": "wallet", - "asset": "instance.YES_TOKEN_ASSET", - "amount_sat": { - "min_amount": "params.PAIRS_BURNED" - }, - "ui": { - "label": "every YES token, being burned", - "role": "outcome_token" - } - }, - { - "id": "no_tokens_in", - "description": "All outstanding NO tokens, from the wallet.", - "utxo_source": "wallet", - "asset": "instance.NO_TOKEN_ASSET", - "amount_sat": { - "min_amount": "params.PAIRS_BURNED" - }, - "ui": { - "label": "every NO token, being burned", - "role": "outcome_token" - } - } - ], - "outputs": [ - { - "id": "yes_reissuance_out", - "description": "YES reissuance token returned to the state-0 (dormant) address (output 0).", - "destination": { - "utxo_type": "market_dormant" - }, - "asset": "instance.YES_REISSUANCE_TOKEN", - "amount_sat": 1, - "required_index": 0, - "ui": { - "label": "YES minting right, back to dormant", - "role": "reissuance_token" - } - }, - { - "id": "no_reissuance_out", - "description": "NO reissuance token returned to the state-0 address (output 1). From here the market can be reopened with another InitialIssuance.", - "destination": { - "utxo_type": "market_dormant" - }, - "asset": "instance.NO_REISSUANCE_TOKEN", - "amount_sat": 1, - "required_index": 1, - "ui": { - "label": "NO minting right, back to dormant", - "role": "reissuance_token" - } - }, - { - "id": "yes_tokens_burn", - "description": "All YES tokens destroyed (output 2). Zero-length script required.", - "destination": { - "type": "burn" - }, - "asset": "instance.YES_TOKEN_ASSET", - "amount_sat": "params.PAIRS_BURNED", - "required_index": 2, - "ui": { - "label": "YES tokens burned", - "role": "burn", - "group": "burned / protocol data" - } - }, - { - "id": "no_tokens_burn", - "description": "All NO tokens destroyed (output 3).", - "destination": { - "type": "burn" - }, - "asset": "instance.NO_TOKEN_ASSET", - "amount_sat": "params.PAIRS_BURNED", - "required_index": 3, - "ui": { - "label": "NO tokens burned", - "role": "burn", - "group": "burned / protocol data" - } - }, - { - "id": "refund_out", - "description": "The entire pool refunded to you (output 4). The covenant does not constrain this leg; it falls out of asset balance once outputs 0-3 are pinned.", - "destination": "wallet", - "asset": "instance.COLLATERAL_ASSET_ID", - "amount_sat": "collateral_in.amount_sat", - "required_index": 4, - "ui": { - "label": "all collateral refunded to you", - "role": "settlement" - } - } - ] - } - } - } - } -} diff --git a/examples/deadcat_v3/txmanifest.json b/examples/deadcat_v3/txmanifest.json index 4c8b1e5..4b52b64 100644 --- a/examples/deadcat_v3/txmanifest.json +++ b/examples/deadcat_v3/txmanifest.json @@ -1,6 +1,6 @@ { "$schema": "../../schema/txmanifest.schema.json", - "$comment": "v3 of examples/deadcat - the runnable fork. The reissuance tokens stay confidential (Elements requires it), but each recreated token advances both blinding factors by exactly one, so the covenant checks the outputs as a translation of the inputs and needs no output-side witnesses. The factors are therefore derivable from the previous spend's on-chain witness and nothing has to be persisted; the first pair is the constant abf = vbf = 1, set by CreateMarket. The fee is checked at num_outputs - 1 on every path, which lets these actions declare an L-BTC change output instead of paying the surplus to miners. Different CMR, different addresses, NOT interoperable with Deadcat. examples/deadcat remains the faithful port. Original header follows. Ported from Deadcat.Live (github.com/Resolvr-io/deadcat, src-tauri/crates/deadcat-sdk). prediction_market.simf is a verbatim copy of that crate's contract/prediction_market.simf — do not edit it, every byte feeds the CMR and therefore all four covenant addresses. The upstream SDK builds these transactions in Rust (src/pset/*.rs); this manifest is the same seven spending paths expressed declaratively. Deadcat's OTHER covenant, maker_order.simf, is deliberately NOT modelled: it tweaks the MAKER's key as the taproot internal key, while this engine hardcodes the NUMS internal key (covenant.rs::NUMS_KEY_BYTES), so no address it computed would be correct.", + "$comment": "A fork of Deadcat's prediction market that this engine can run. The reissuance tokens stay confidential (Elements requires it), but each recreated token advances both blinding factors by exactly one, so the covenant checks the outputs as a translation of the inputs and needs no output-side witnesses. The factors are therefore derivable from the previous spend's on-chain witness and nothing has to be persisted; the first pair is the constant abf = vbf = 1, set by CreateMarket. The fee is checked at num_outputs - 1 on every path, which lets these actions declare an L-BTC change output instead of paying the surplus to miners. Different CMR, different addresses, NOT interoperable with Deadcat. Original header follows. Ported from Deadcat.Live (github.com/Resolvr-io/deadcat, src-tauri/crates/deadcat-sdk). prediction_market.simf is a verbatim copy of that crate's contract/prediction_market.simf — do not edit it, every byte feeds the CMR and therefore all four covenant addresses. The upstream SDK builds these transactions in Rust (src/pset/*.rs); this manifest is the same seven spending paths expressed declaratively. Deadcat's OTHER covenant, maker_order.simf, is deliberately NOT modelled: it tweaks the MAKER's key as the taproot internal key, while this engine hardcodes the NUMS internal key (covenant.rs::NUMS_KEY_BYTES), so no address it computed would be correct.", "manifest_version": "0.3.0", "protocol": "deadcat-prediction-market-v3", "description": "Deadcat v3 — a binary (YES/NO) prediction market on Liquid. Collateral is locked in a covenant that mints matched YES/NO token pairs at 2 x COLLATERAL_PER_TOKEN per pair; an off-chain oracle commits the outcome ON-CHAIN as a state transition, and winners then burn tokens to draw the whole pair's collateral. The market's state (0 dormant, 1 unresolved, 2 resolved-YES, 3 resolved-NO) is not stored in a variable — it is a tapdata leaf in the covenant's tap tree, so each state is a DIFFERENT address and the covenant proves its own state by comparing the address it is being spent from. Modelled as one template: one instance per market.", diff --git a/examples/lending/asset_auth.simf b/examples/lending/asset_auth.simf deleted file mode 100644 index 6abb6f5..0000000 --- a/examples/lending/asset_auth.simf +++ /dev/null @@ -1,39 +0,0 @@ -fn get_asset_and_amount(index: u32, is_input_index: bool) -> (u256, u64) { - let pair: (Asset1, Amount1) = match is_input_index { - true => unwrap(jet::input_amount(index)), - false => unwrap(jet::output_amount(index)), - }; - let (asset, amount): (Asset1, Amount1) = pair; - let asset_bits: u256 = unwrap_right::<(u1, u256)>(asset); - let amount: u64 = unwrap_right::<(u1, u256)>(amount); - (asset_bits, amount) -} - -fn ensure_output_is_op_return(index: u32) { - match jet::output_null_datum(index, 0) { - Some(entry: Option>>) => (), - None => panic!(), - } -} - -fn ensure_asset_and_amount_eq(index: u32, is_input_index: bool, expected_asset_bits: u256, expected_amount: u64) { - let (asset_bits, amount): (u256, u64) = get_asset_and_amount(index, is_input_index); - assert!(jet::eq_256(asset_bits, expected_asset_bits)); - assert!(jet::eq_64(amount, expected_amount)); -} - -fn auth_with_burn_check(input_asset_index: u32, output_asset_index: u32) { - ensure_asset_and_amount_eq(input_asset_index, true, param::ASSET_ID, param::ASSET_AMOUNT); - ensure_asset_and_amount_eq(output_asset_index, false, param::ASSET_ID, param::ASSET_AMOUNT); - - match param::WITH_ASSET_BURN { - true => { - ensure_output_is_op_return(output_asset_index); - }, - false => {}, - } -} - -fn main() { - auth_with_burn_check(witness::INPUT_ASSET_INDEX, witness::OUTPUT_ASSET_INDEX); -} \ No newline at end of file diff --git a/examples/lending/lending.simf b/examples/lending/lending.simf deleted file mode 100644 index 163c899..0000000 --- a/examples/lending/lending.simf +++ /dev/null @@ -1,257 +0,0 @@ -// Helper getters - -fn get_script_hash(index: u32, is_input_index: bool) -> u256 { - let script_hash: u256 = match is_input_index { - true => unwrap(jet::input_script_hash(index)), - false => unwrap(jet::output_script_hash(index)), - }; - - script_hash -} - -fn get_asset_and_amount(index: u32, is_input_index: bool) -> (u256, u64) { - let pair: (Asset1, Amount1) = match is_input_index { - true => unwrap(jet::input_amount(index)), - false => unwrap(jet::output_amount(index)), - }; - let (asset, amount): (Asset1, Amount1) = pair; - let asset_bits: u256 = unwrap_right::<(u1, u256)>(asset); - let amount: u64 = unwrap_right::<(u1, u256)>(amount); - (asset_bits, amount) -} - -// Check helpers - -fn check_asset_amounts_eq(asset_amount_1: u64, asset_amount_2: u64) { - assert!(jet::eq_64(asset_amount_1, asset_amount_2)); -} - -fn check_assets_eq(asset_bits_1: u256, asset_bits_2: u256) { - assert!(jet::eq_256(asset_bits_1, asset_bits_2)); -} - -fn check_script_hashes_eq(script_1: u256, script_2: u256) { - assert!(jet::eq_256(script_1, script_2)); -} - -fn ensure_script_hash(index: u32, is_input_index: bool, expected_script_hash: u256) { - let script_hash: u256 = get_script_hash(index, is_input_index); - - check_script_hashes_eq(script_hash, expected_script_hash); -} - -fn ensure_asset_with_amount(index: u32, is_input_index: bool, expected_asset_bits: u256, expected_amount: u64) { - let (asset_bits, amount): (u256, u64) = get_asset_and_amount(index, is_input_index); - - check_assets_eq(asset_bits, expected_asset_bits); - check_asset_amounts_eq(amount, expected_amount); -} - -fn ensure_input_and_output_assets_eq(input_index: u32, output_index: u32, expected_asset_bits: u256) -> u64 { - let (input_asset_bits, input_amount): (u256, u64) = get_asset_and_amount(input_index, true); - let (output_asset_bits, output_amount): (u256, u64) = get_asset_and_amount(output_index, false); - - check_assets_eq(input_asset_bits, expected_asset_bits); - check_assets_eq(input_asset_bits, output_asset_bits); - - check_asset_amounts_eq(input_amount, output_amount); - - input_amount -} - -fn ensure_input_and_output_assets_with_amount_eq(input_index: u32, output_index: u32, expected_asset_bits: u256, expected_amount: u64) { - let (input_asset_bits, input_amount): (u256, u64) = get_asset_and_amount(input_index, true); - let (output_asset_bits, output_amount): (u256, u64) = get_asset_and_amount(output_index, false); - - check_assets_eq(input_asset_bits, expected_asset_bits); - check_assets_eq(input_asset_bits, output_asset_bits); - - check_asset_amounts_eq(input_amount, expected_amount); - check_asset_amounts_eq(input_amount, output_amount); -} - -fn ensure_output_is_op_return(index: u32) { - match jet::output_null_datum(index, 0) { - Some(entry: Option>>) => (), - None => panic!(), - } -} - -fn ensure_zero_bit(bit: bool) { assert!(jet::eq_1(::into(bit), 0)); } - -// Lending parameters functions - -fn count_multiplier(acc: u64, decimals_mantissa: u8, i: u8) -> Either { - match jet::eq_8(decimals_mantissa, i) { - true => Left(acc), - false => { - let new_acc: u128 = jet::multiply_64(acc, 10); - let (_, new_acc): (u64, u64) = ::into(new_acc); - - Right(new_acc) - } - } -} - -fn get_decimals_multiplier(decimals_mantissa: u4) -> u64 { - let decimals_mantissa: u8 = <(u4, u4)>::into((0, decimals_mantissa)); - let multiplier: u64 = unwrap_left::(for_while::(1, decimals_mantissa)); - - multiplier -} - -fn from_base_amount(base_amount: u32, decimals_mantissa: u4) -> u64 { - let base_amount: u64 = jet::left_pad_low_32_64(base_amount); - let multiplier: u64 = get_decimals_multiplier(decimals_mantissa); - - let result_amount: u128 = jet::multiply_64(base_amount, multiplier); - - let (carry, result_amount): (u64, u64) = ::into(result_amount); - - assert!(jet::eq_64(carry, 0)); - - result_amount -} - -fn extract_bits_from_amount(encoded_amount: u64, bits_count: u8, shift: u8) -> (u64, u8) { - let mask: u64 = jet::left_shift_64(shift, jet::left_shift_with_64(1, bits_count, 0)); - let shifted_amount: u64 = jet::right_shift_64(shift, jet::and_64(encoded_amount, mask)); - - let (carry, new_shift): (bool, u8) = jet::add_8(shift, bits_count); - ensure_zero_bit(carry); - - (shifted_amount, new_shift) -} - -fn extract_lending_parameters(first_parameters_amount: u64, second_parameters_amount: u64) -> (u64, u64, u32, u16) { - // Extracting parameter values from the first parameters amount - let interest_rate_bits: u8 = 16; - - let (interest_rate_raw, shift): (u64, u8) = extract_bits_from_amount(first_parameters_amount, interest_rate_bits, 0); - let interest_rate: u16 = jet::rightmost_64_16(interest_rate_raw); - - let loan_expiration_time_bits: u8 = 27; - - let (loan_expiration_time_raw, shift): (u64, u8) = extract_bits_from_amount(first_parameters_amount, loan_expiration_time_bits, shift); - let loan_expiration_time: u32 = jet::rightmost_64_32(loan_expiration_time_raw); - - let decimals_mantissa_bits: u8 = 4; - - let (collateral_decimals_mantissa_raw, shift): (u64, u8) = extract_bits_from_amount(first_parameters_amount, decimals_mantissa_bits, shift); - let collateral_decimals_mantissa: u4 = jet::rightmost_64_4(collateral_decimals_mantissa_raw); - - let (principal_decimals_mantissa_raw, shift): (u64, u8) = extract_bits_from_amount(first_parameters_amount, decimals_mantissa_bits, shift); - let principal_decimals_mantissa: u4 = jet::rightmost_64_4(principal_decimals_mantissa_raw); - - // Extracting parameter values from the second parameters amount - let base_amount_bits: u8 = 25; - - let (collateral_base_amount_raw, shift): (u64, u8) = extract_bits_from_amount(second_parameters_amount, base_amount_bits, 0); - let collateral_base_amount: u32 = jet::rightmost_64_32(collateral_base_amount_raw); - - let (principal_base_amount_raw, shift): (u64, u8) = extract_bits_from_amount(second_parameters_amount, base_amount_bits, shift); - let principal_base_amount: u32 = jet::rightmost_64_32(principal_base_amount_raw); - - let collateral_amount: u64 = from_base_amount(collateral_base_amount, collateral_decimals_mantissa); - let principal_amount: u64 = from_base_amount(principal_base_amount, principal_decimals_mantissa); - - (collateral_amount, principal_amount, loan_expiration_time, interest_rate) -} - -fn validate_lending_params(collateral_amount: u64, principal_amount: u64, loan_expiration_time: u32, interest_rate: u16) { - check_asset_amounts_eq(param::COLLATERAL_AMOUNT, collateral_amount); - check_asset_amounts_eq(param::PRINCIPAL_AMOUNT, principal_amount); - check_asset_amounts_eq(jet::left_pad_low_32_64(param::LOAN_EXPIRATION_TIME), jet::left_pad_low_32_64(loan_expiration_time)); - check_asset_amounts_eq(jet::left_pad_low_16_64(param::PRINCIPAL_INTEREST_RATE), jet::left_pad_low_16_64(interest_rate)); -} - -// Interest logic - -fn calculate_interest(principal_amount: u64, interest_rate: u16) -> u64 { - let MAX_BASIS_POINTS: u64 = 10_000; - - let interest_rate: u64 = jet::left_pad_low_16_64(interest_rate); - - let interest: u128 = jet::multiply_64(principal_amount, interest_rate); - - // TODO: Handle case when hi > 0 - let (hi, lo): (u64, u64) = ::into(interest); - check_asset_amounts_eq(hi, 0); - - let interest: u64 = jet::divide_64(lo, MAX_BASIS_POINTS); - - interest -} - -fn calculate_principal_with_interest(principal_without_interest: u64, interest_rate: u16) -> u64 { - let interest: u64 = calculate_interest(principal_without_interest, interest_rate); - - let (carry, principal_with_interest): (bool, u64) = jet::add_64(principal_without_interest, interest); - ensure_zero_bit(carry); - - principal_with_interest -} - -// Main paths logic - -fn loan_repayment_path() { - assert!(jet::eq_32(jet::current_index(), 0)); - - ensure_input_and_output_assets_with_amount_eq(0, 0, param::COLLATERAL_ASSET_ID, param::COLLATERAL_AMOUNT); - let first_parameters_amount: u64 = ensure_input_and_output_assets_eq(1, 2, param::FIRST_PARAMETERS_NFT_ASSET_ID); - let second_parameters_amount: u64 = ensure_input_and_output_assets_eq(2, 3, param::SECOND_PARAMETERS_NFT_ASSET_ID); - ensure_input_and_output_assets_with_amount_eq(3, 4, param::BORROWER_NFT_ASSET_ID, 1); - - let ( - collateral_amount, - principal_amount, - loan_expiration_time, - interest_rate - ): (u64, u64, u32, u16) = extract_lending_parameters(first_parameters_amount, second_parameters_amount); - - validate_lending_params(collateral_amount, principal_amount, loan_expiration_time, interest_rate); - - let principal_amount_with_interest: u64 = calculate_principal_with_interest(principal_amount, interest_rate); - - ensure_asset_with_amount(1, false, param::PRINCIPAL_ASSET_ID, principal_amount_with_interest); - ensure_script_hash(1, false, param::LENDER_PRINCIPAL_COV_HASH); - - ensure_output_is_op_return(2); - ensure_output_is_op_return(3); - ensure_output_is_op_return(4); -} - -fn loan_liquidation_path() { - assert!(jet::eq_32(jet::current_index(), 0)); - - ensure_input_and_output_assets_with_amount_eq(0, 0, param::COLLATERAL_ASSET_ID, param::COLLATERAL_AMOUNT); - let first_parameters_amount: u64 = ensure_input_and_output_assets_eq(1, 1, param::FIRST_PARAMETERS_NFT_ASSET_ID); - let second_parameters_amount: u64 = ensure_input_and_output_assets_eq(2, 2, param::SECOND_PARAMETERS_NFT_ASSET_ID); - ensure_input_and_output_assets_with_amount_eq(3, 3, param::LENDER_NFT_ASSET_ID, 1); - - let ( - collateral_amount, - principal_amount, - loan_expiration_time, - interest_rate - ): (u64, u64, u32, u16) = extract_lending_parameters(first_parameters_amount, second_parameters_amount); - - validate_lending_params(collateral_amount, principal_amount, loan_expiration_time, interest_rate); - - jet::check_lock_height(loan_expiration_time); - - ensure_output_is_op_return(1); - ensure_output_is_op_return(2); - ensure_output_is_op_return(3); -} - -fn main() { - match witness::PATH { - Left(params: ()) => { - loan_repayment_path(); - }, - Right(params: ()) => { - loan_liquidation_path(); - } - } -} \ No newline at end of file diff --git a/examples/lending/p2pk.simf b/examples/lending/p2pk.simf deleted file mode 100644 index 572ab15..0000000 --- a/examples/lending/p2pk.simf +++ /dev/null @@ -1,4 +0,0 @@ -fn main() { - let sig: Signature = witness::SIGNATURE; - jet::bip_0340_verify((param::PUB_KEY, jet::sig_all_hash()), sig); -} diff --git a/examples/lending/pre_lock.simf b/examples/lending/pre_lock.simf deleted file mode 100644 index d6f8cc9..0000000 --- a/examples/lending/pre_lock.simf +++ /dev/null @@ -1,233 +0,0 @@ -// Helper getters - -fn get_script_hash(index: u32, is_input_index: bool) -> u256 { - let script_hash: u256 = match is_input_index { - true => unwrap(jet::input_script_hash(index)), - false => unwrap(jet::output_script_hash(index)), - }; - - script_hash -} - -fn get_asset_and_amount(index: u32, is_input_index: bool) -> (u256, u64) { - let pair: (Asset1, Amount1) = match is_input_index { - true => unwrap(jet::input_amount(index)), - false => unwrap(jet::output_amount(index)), - }; - let (asset, amount): (Asset1, Amount1) = pair; - let asset_bits: u256 = unwrap_right::<(u1, u256)>(asset); - let amount: u64 = unwrap_right::<(u1, u256)>(amount); - (asset_bits, amount) -} - -// Check helpers - -fn check_asset_amounts_eq(asset_amount_1: u64, asset_amount_2: u64) { - assert!(jet::eq_64(asset_amount_1, asset_amount_2)); -} - -fn check_assets_eq(asset_bits_1: u256, asset_bits_2: u256) { - assert!(jet::eq_256(asset_bits_1, asset_bits_2)); -} - -fn check_script_hashes_eq(script_1: u256, script_2: u256) { - assert!(jet::eq_256(script_1, script_2)); -} - -fn ensure_script_hash(index: u32, is_input_index: bool, expected_script_hash: u256) { - let script_hash: u256 = get_script_hash(index, is_input_index); - - check_script_hashes_eq(script_hash, expected_script_hash); -} - -fn ensure_asset_with_amount(index: u32, is_input_index: bool, expected_asset_bits: u256, expected_amount: u64) { - let (asset_bits, amount): (u256, u64) = get_asset_and_amount(index, is_input_index); - - check_assets_eq(asset_bits, expected_asset_bits); - check_asset_amounts_eq(amount, expected_amount); -} - -fn ensure_input_and_output_assets_eq(input_index: u32, output_index: u32, expected_asset_bits: u256) -> u64 { - let (input_asset_bits, input_amount): (u256, u64) = get_asset_and_amount(input_index, true); - let (output_asset_bits, output_amount): (u256, u64) = get_asset_and_amount(output_index, false); - - check_assets_eq(input_asset_bits, expected_asset_bits); - check_assets_eq(input_asset_bits, output_asset_bits); - - check_asset_amounts_eq(input_amount, output_amount); - - input_amount -} - -fn ensure_input_and_output_assets_with_amount_eq(input_index: u32, output_index: u32, expected_asset_bits: u256, expected_amount: u64) { - let (input_asset_bits, input_amount): (u256, u64) = get_asset_and_amount(input_index, true); - let (output_asset_bits, output_amount): (u256, u64) = get_asset_and_amount(output_index, false); - - check_assets_eq(input_asset_bits, expected_asset_bits); - check_assets_eq(input_asset_bits, output_asset_bits); - - check_asset_amounts_eq(input_amount, expected_amount); - check_asset_amounts_eq(input_amount, output_amount); -} - -fn ensure_output_is_op_return(index: u32) { - match jet::output_null_datum(index, 0) { - Some(entry: Option>>) => (), - None => panic!(), - } -} - -fn ensure_zero_bit(bit: bool) { assert!(jet::eq_1(::into(bit), 0)); } - -// Lending parameters functions - -fn count_multiplier(acc: u64, decimals_mantissa: u8, i: u8) -> Either { - match jet::eq_8(decimals_mantissa, i) { - true => Left(acc), - false => { - let new_acc: u128 = jet::multiply_64(acc, 10); - let (_, new_acc): (u64, u64) = ::into(new_acc); - - Right(new_acc) - } - } -} - -fn get_decimals_multiplier(decimals_mantissa: u4) -> u64 { - let decimals_mantissa: u8 = <(u4, u4)>::into((0, decimals_mantissa)); - let multiplier: u64 = unwrap_left::(for_while::(1, decimals_mantissa)); - - multiplier -} - -fn from_base_amount(base_amount: u32, decimals_mantissa: u4) -> u64 { - let base_amount: u64 = jet::left_pad_low_32_64(base_amount); - let multiplier: u64 = get_decimals_multiplier(decimals_mantissa); - - let result_amount: u128 = jet::multiply_64(base_amount, multiplier); - - let (carry, result_amount): (u64, u64) = ::into(result_amount); - - assert!(jet::eq_64(carry, 0)); - - result_amount -} - -fn extract_bits_from_amount(encoded_amount: u64, bits_count: u8, shift: u8) -> (u64, u8) { - let mask: u64 = jet::left_shift_64(shift, jet::left_shift_with_64(1, bits_count, 0)); - let shifted_amount: u64 = jet::right_shift_64(shift, jet::and_64(encoded_amount, mask)); - - let (carry, new_shift): (bool, u8) = jet::add_8(shift, bits_count); - ensure_zero_bit(carry); - - (shifted_amount, new_shift) -} - -fn extract_lending_parameters(first_parameters_amount: u64, second_parameters_amount: u64) -> (u64, u64, u32, u16) { - // Extracting parameter values from the first parameters amount - let interest_rate_bits: u8 = 16; - - let (interest_rate_raw, shift): (u64, u8) = extract_bits_from_amount(first_parameters_amount, interest_rate_bits, 0); - let interest_rate: u16 = jet::rightmost_64_16(interest_rate_raw); - - let loan_expiration_time_bits: u8 = 27; - - let (loan_expiration_time_raw, shift): (u64, u8) = extract_bits_from_amount(first_parameters_amount, loan_expiration_time_bits, shift); - let loan_expiration_time: u32 = jet::rightmost_64_32(loan_expiration_time_raw); - - let decimals_mantissa_bits: u8 = 4; - - let (collateral_decimals_mantissa_raw, shift): (u64, u8) = extract_bits_from_amount(first_parameters_amount, decimals_mantissa_bits, shift); - let collateral_decimals_mantissa: u4 = jet::rightmost_64_4(collateral_decimals_mantissa_raw); - - let (principal_decimals_mantissa_raw, shift): (u64, u8) = extract_bits_from_amount(first_parameters_amount, decimals_mantissa_bits, shift); - let principal_decimals_mantissa: u4 = jet::rightmost_64_4(principal_decimals_mantissa_raw); - - // Extracting parameter values from the second parameters amount - let base_amount_bits: u8 = 25; - - let (collateral_base_amount_raw, shift): (u64, u8) = extract_bits_from_amount(second_parameters_amount, base_amount_bits, 0); - let collateral_base_amount: u32 = jet::rightmost_64_32(collateral_base_amount_raw); - - let (principal_base_amount_raw, shift): (u64, u8) = extract_bits_from_amount(second_parameters_amount, base_amount_bits, shift); - let principal_base_amount: u32 = jet::rightmost_64_32(principal_base_amount_raw); - - let collateral_amount: u64 = from_base_amount(collateral_base_amount, collateral_decimals_mantissa); - let principal_amount: u64 = from_base_amount(principal_base_amount, principal_decimals_mantissa); - - (collateral_amount, principal_amount, loan_expiration_time, interest_rate) -} - -fn validate_lending_params(collateral_amount: u64, principal_amount: u64, loan_expiration_time: u32, interest_rate: u16) { - check_asset_amounts_eq(param::COLLATERAL_AMOUNT, collateral_amount); - check_asset_amounts_eq(param::PRINCIPAL_AMOUNT, principal_amount); - check_asset_amounts_eq(jet::left_pad_low_32_64(param::LOAN_EXPIRATION_TIME), jet::left_pad_low_32_64(loan_expiration_time)); - check_asset_amounts_eq(jet::left_pad_low_16_64(param::PRINCIPAL_INTEREST_RATE), jet::left_pad_low_16_64(interest_rate)); -} - -// Main paths logic - -fn create_lending_path() { - assert!(jet::eq_32(jet::current_index(), 0)); - - ensure_input_and_output_assets_with_amount_eq(0, 0, param::COLLATERAL_ASSET_ID, param::COLLATERAL_AMOUNT); - let first_parameters_amount: u64 = ensure_input_and_output_assets_eq(1, 2, param::FIRST_PARAMETERS_NFT_ASSET_ID); - let second_parameters_amount: u64 = ensure_input_and_output_assets_eq(2, 3, param::SECOND_PARAMETERS_NFT_ASSET_ID); - ensure_input_and_output_assets_with_amount_eq(3, 4, param::BORROWER_NFT_ASSET_ID, 1); - ensure_input_and_output_assets_with_amount_eq(4, 5, param::LENDER_NFT_ASSET_ID, 1); - - let ( - collateral_amount, - principal_amount, - loan_expiration_time, - interest_rate - ): (u64, u64, u32, u16) = extract_lending_parameters(first_parameters_amount, second_parameters_amount); - - validate_lending_params(collateral_amount, principal_amount, loan_expiration_time, interest_rate); - - ensure_asset_with_amount(1, false, param::PRINCIPAL_ASSET_ID, param::PRINCIPAL_AMOUNT); - - ensure_script_hash(0, false, param::LENDING_COV_HASH); // Lending covenant script hash - ensure_script_hash(1, false, param::PRINCIPAL_OUTPUT_SCRIPT_HASH); // P2PKH script created with the BORROWER_PUB_KEY - ensure_script_hash(2, false, param::PARAMETERS_NFT_OUTPUT_SCRIPT_HASH); // ScriptAuth covenant script hash with the LENDING_COV_HASH as auth script - ensure_script_hash(3, false, param::PARAMETERS_NFT_OUTPUT_SCRIPT_HASH); // ScriptAuth covenant script hash with the LENDING_COV_HASH as auth script - ensure_script_hash(4, false, param::BORROWER_NFT_OUTPUT_SCRIPT_HASH); // P2PKH script created with the BORROWER_PUB_KEY -} - -fn cancel_pre_lock_path(sig: Signature) { - assert!(jet::eq_32(jet::current_index(), 0)); - - jet::bip_0340_verify((param::BORROWER_PUB_KEY, jet::sig_all_hash()), sig); - - ensure_input_and_output_assets_with_amount_eq(0, 0, param::COLLATERAL_ASSET_ID, param::COLLATERAL_AMOUNT); - let first_parameters_amount: u64 = ensure_input_and_output_assets_eq(1, 1, param::FIRST_PARAMETERS_NFT_ASSET_ID); - let second_parameters_amount: u64 = ensure_input_and_output_assets_eq(2, 2, param::SECOND_PARAMETERS_NFT_ASSET_ID); - ensure_input_and_output_assets_with_amount_eq(3, 3, param::BORROWER_NFT_ASSET_ID, 1); - ensure_input_and_output_assets_with_amount_eq(4, 4, param::LENDER_NFT_ASSET_ID, 1); - - let ( - collateral_amount, - principal_amount, - loan_expiration_time, - interest_rate - ): (u64, u64, u32, u16) = extract_lending_parameters(first_parameters_amount, second_parameters_amount); - - validate_lending_params(collateral_amount, principal_amount, loan_expiration_time, interest_rate); - - ensure_output_is_op_return(1); - ensure_output_is_op_return(2); - ensure_output_is_op_return(3); - ensure_output_is_op_return(4); -} - -fn main() { - match witness::PATH { - Left(params: ()) => { - create_lending_path(); - }, - Right(params: ()) => { - let sig: Signature = witness::SIGNATURE; - cancel_pre_lock_path(sig); - } - } -} \ No newline at end of file diff --git a/examples/lending/script_auth.simf b/examples/lending/script_auth.simf deleted file mode 100644 index 35566f9..0000000 --- a/examples/lending/script_auth.simf +++ /dev/null @@ -1,13 +0,0 @@ -fn ensure_input_script_hash(input_script_index: u32, expected_script_hash: u256) { - let actual_script_hash: u256 = unwrap(jet::input_script_hash(input_script_index)); - - assert!(jet::eq_256(actual_script_hash, expected_script_hash)); -} - -fn script_auth_check(input_script_index: u32) { - ensure_input_script_hash(input_script_index, param::SCRIPT_HASH); -} - -fn main() { - script_auth_check(witness::INPUT_SCRIPT_INDEX); -} \ No newline at end of file diff --git a/examples/lending/txmanifest.json b/examples/lending/txmanifest.json deleted file mode 100644 index a550e95..0000000 --- a/examples/lending/txmanifest.json +++ /dev/null @@ -1,1483 +0,0 @@ -{ - "$schema": "../../schema/txmanifest.schema.json", - "manifest_version": "0.3.0", - "protocol": "simplicity-lending", - "requires": ["simplicity"], - "description": "P2P collateralised lending protocol on Liquid using SimplicityHL covenants. Borrower locks collateral in a PreLockCovenant and advertises terms via bit-packed Parameter NFTs. A Lender accepts by providing the principal, activating the LendingCovenant. Settlement is either repayment (borrower returns principal+interest, reclaims collateral) or liquidation (lender claims collateral after loan expiry). All covenants are enforced on-chain via Simplicity programs; no trusted backend is required.", - "utxo_types": { - "p2pk": { - "description": "Simple Schnorr-signature covenant (p2pk.simf) keyed to BORROWER_PUB_KEY. Used as the borrower's principal-payment and NFT-release address.", - "script": { - "type": "simplicity", - "source": "./p2pk.simf", - "compile_params": { - "PUB_KEY": "BORROWER_PUB_KEY" - } - } - }, - "pre_lock": { - "description": "Collateral held in the PreLockCovenant while the borrower's offer is open. Two spending paths: PATH::LEFT (create_lending_path) allows a lender to activate the loan; PATH::RIGHT (cancel_pre_lock_path) allows the borrower to cancel with a Schnorr signature, burning all Utility NFTs.", - "script": { - "type": "simplicity", - "source": "./pre_lock.simf" - }, - "asset": "COLLATERAL_ASSET_ID" - }, - "lending_collateral": { - "description": "Collateral locked in the active LendingCovenant. Released via repayment (borrower returns principal+interest, reclaims collateral) or liquidation (lender claims collateral after LOAN_EXPIRATION_TIME).", - "script": { - "type": "simplicity", - "source": "./lending.simf" - }, - "asset": "COLLATERAL_ASSET_ID" - }, - "prelock_script_auth": { - "description": "ScriptAuth covenant wrapping Utility NFTs during the offer phase. Verifies co-spending with the pre_lock UTXO.", - "script": { - "type": "simplicity", - "source": "./script_auth.simf", - "compile_params": { - "SCRIPT_HASH": "PRE_LOCK_COV_HASH" - } - } - }, - "lending_script_auth": { - "description": "ScriptAuth covenant wrapping Parameter NFTs and the Borrower NFT during the active loan phase. Verifies co-spending with the lending_collateral UTXO.", - "script": { - "type": "simplicity", - "source": "./script_auth.simf", - "compile_params": { - "SCRIPT_HASH": "LENDING_COV_HASH" - } - } - }, - "lender_principal_vault": { - "description": "AssetAuth covenant holding the principal+interest payment from the borrower. The lender withdraws by co-spending and burning the Lender NFT.", - "script": { - "type": "simplicity", - "source": "./asset_auth.simf", - "compile_params": { - "ASSET_ID": "LENDER_NFT_ASSET_ID", - "ASSET_AMOUNT": "1", - "WITH_ASSET_BURN": "true" - } - }, - "asset": "PRINCIPAL_ASSET_ID" - } - }, - "actions": { - "Prepare": { - "description": "Utility method to split a utxo into 4 utxos for the IssueUtilityNFTs action", - "inputs": [ - { - "id": "input", - "description": "Wallet UTXO to split into 4 for IssueUtilityNFTs", - "utxo_source": "wallet", - "asset": "lbtc" - } - ], - "outputs": [ - { - "id": "split_utxo1", - "destination": "wallet", - "amount_sat": 1, - "asset": "lbtc" - }, - { - "id": "split_utxo2", - "destination": "wallet", - "amount_sat": 1, - "asset": "lbtc" - }, - { - "id": "split_utxo3", - "destination": "wallet", - "amount_sat": 1, - "asset": "lbtc" - }, - { - "id": "split_utxo4", - "destination": "change", - "asset": "lbtc" - } - ] - }, - "PrepareLender": { - "allow_change": "any", - "description": "Utility: ensure the lender wallet has a PRINCIPAL_AMOUNT-sat UTXO of PRINCIPAL_ASSET_ID ready for SetupLending. Accepts any UTXO of that asset and splits off the exact amount needed.", - "inputs": [ - { - "id": "principal_input", - "description": "Any wallet UTXO holding PRINCIPAL_ASSET_ID (at least PRINCIPAL_AMOUNT sat).", - "utxo_source": "wallet", - "asset": "instance.PRINCIPAL_ASSET_ID", - "amount_sat": { - "min_amount": "instance.PRINCIPAL_AMOUNT" - } - } - ], - "outputs": [ - { - "id": "principal_out", - "description": "Exact PRINCIPAL_AMOUNT sat ready for SetupLending principal_in.", - "destination": "wallet", - "asset": "instance.PRINCIPAL_ASSET_ID", - "amount_sat": "instance.PRINCIPAL_AMOUNT", - "confidential": false - } - ] - } - }, - "contract_templates": { - "lending_contract": { - "description": "P2P collateralised lending contract. One instance per loan offer. Created by IssueUtilityNFTs; advanced through LockCollateral, SetupLending, RepayLoan / LiquidateAfterExpiry, and ClaimPrincipalWithInterest.", - "fields": { - "BORROWER_NFT_ASSET_ID": { - "type": "liquid.asset_id" - }, - "BORROWER_NFT_OUTPUT_SCRIPT_HASH": { - "type": "bytes32" - }, - "BORROWER_PUB_KEY": { - "type": "pubkey" - }, - "COLLATERAL_AMOUNT": { - "type": "u64" - }, - "COLLATERAL_ASSET_ID": { - "type": "liquid.asset_id" - }, - "COLLATERAL_DECIMALS_MANTISSA": { - "type": "u8" - }, - "FIRST_PARAMETERS_ENCODED": { - "type": "u64" - }, - "FIRST_PARAMETERS_NFT_ASSET_ID": { - "type": "liquid.asset_id" - }, - "LENDER_NFT_ASSET_ID": { - "type": "liquid.asset_id" - }, - "LENDER_PRINCIPAL_COV_HASH": { - "type": "bytes32" - }, - "LENDING_COV_HASH": { - "type": "bytes32" - }, - "LOAN_EXPIRATION_TIME": { - "type": "u32" - }, - "PARAMETERS_NFT_OUTPUT_SCRIPT_HASH": { - "type": "bytes32" - }, - "PRE_LOCK_COV_HASH": { - "type": "bytes32" - }, - "PRELOCK_PARAMETERS_NFT_SCRIPT_HASH": { - "type": "bytes32" - }, - "PRINCIPAL_AMOUNT": { - "type": "u64" - }, - "PRINCIPAL_ASSET_ID": { - "type": "liquid.asset_id" - }, - "PRINCIPAL_DECIMALS_MANTISSA": { - "type": "u8" - }, - "PRINCIPAL_INTEREST_AMOUNT": { - "type": "u64" - }, - "PRINCIPAL_INTEREST_RATE": { - "type": "u16" - }, - "PRINCIPAL_OUTPUT_SCRIPT_HASH": { - "type": "bytes32" - }, - "SECOND_PARAMETERS_ENCODED": { - "type": "u64" - }, - "SECOND_PARAMETERS_NFT_ASSET_ID": { - "type": "liquid.asset_id" - } - }, - "actions": { - "IssueUtilityNFTs": { - "description": "Borrower issues four Liquid NFTs (Borrower NFT, Lender NFT, First/Second Parameters NFTs). Computes all covenant hashes and writes the instance file.", - "params": { - "BORROWER_PUB_KEY": { - "type": "pubkey", - "compute": { - "type": "wallet", "wallet": "key" - }, - "description": "Borrower's BIP340 Schnorr public key. Used for cancellation authorization and as the destination for the principal output." - }, - "COLLATERAL_ASSET_ID": { - "type": "liquid.asset_id", - "description": "Liquid asset ID of the collateral (e.g., L-BTC)." - }, - "COLLATERAL_AMOUNT": { - "type": "u64", - "description": "Exact collateral amount in asset base units." - }, - "COLLATERAL_DECIMALS_MANTISSA": { - "type": "u8", - "default": "8", - "description": "Decimal exponent of the collateral asset (e.g., 8 for L-BTC). Used for parameter NFT encoding." - }, - "PRINCIPAL_ASSET_ID": { - "type": "liquid.asset_id", - "description": "Liquid asset ID of the loan currency (e.g., L-USDT)." - }, - "PRINCIPAL_AMOUNT": { - "type": "u64", - "description": "Exact principal amount requested, in asset base units." - }, - "PRINCIPAL_DECIMALS_MANTISSA": { - "type": "u8", - "description": "Decimal exponent of the principal asset." - }, - "PRINCIPAL_INTEREST_RATE": { - "type": "u16", - "description": "Interest rate in basis points (10,000 = 100%). Max 65,535 basis points." - }, - "LOAN_EXPIRATION_TIME": { - "type": "u32", - "description": "Block height at or after which the lender may liquidate (CLTV)." - } - }, - "on_pre_broadcast": { - "set": { - "instance.FIRST_PARAMETERS_ENCODED": "params.PRINCIPAL_INTEREST_RATE + params.LOAN_EXPIRATION_TIME * 65536 + params.COLLATERAL_DECIMALS_MANTISSA * 8796093022208 + params.PRINCIPAL_DECIMALS_MANTISSA * 140737488355328", - "instance.SECOND_PARAMETERS_ENCODED": "params.COLLATERAL_AMOUNT / pow(10, COLLATERAL_DECIMALS_MANTISSA) + params.PRINCIPAL_AMOUNT / pow(10, PRINCIPAL_DECIMALS_MANTISSA) * 33554432" - } - }, - "create_instance": { - "fields": { - "BORROWER_NFT_ASSET_ID": "$instance.BORROWER_NFT_ASSET_ID", - "BORROWER_NFT_OUTPUT_SCRIPT_HASH": { - "type": "tapleaf", - "simf": "./script_auth.simf", - "params": { - "SCRIPT_HASH": { - "type": "bytes32", - "value": "LENDING_COV_HASH" - } - } - }, - "BORROWER_PUB_KEY": "$params.BORROWER_PUB_KEY", - "COLLATERAL_AMOUNT": "$params.COLLATERAL_AMOUNT", - "COLLATERAL_ASSET_ID": "$params.COLLATERAL_ASSET_ID", - "COLLATERAL_DECIMALS_MANTISSA": "$params.COLLATERAL_DECIMALS_MANTISSA", - "FIRST_PARAMETERS_ENCODED": "$instance.FIRST_PARAMETERS_ENCODED", - "FIRST_PARAMETERS_NFT_ASSET_ID": "$instance.FIRST_PARAMETERS_NFT_ASSET_ID", - "LENDER_NFT_ASSET_ID": "$instance.LENDER_NFT_ASSET_ID", - "LENDER_PRINCIPAL_COV_HASH": { - "type": "tapleaf", - "simf": "./asset_auth.simf", - "params": { - "ASSET_ID": { - "type": "liquid.asset_id", - "value": "LENDER_NFT_ASSET_ID" - }, - "ASSET_AMOUNT": { - "type": "u64", - "value": "1" - }, - "WITH_ASSET_BURN": { - "type": "bool", - "value": "true" - } - } - }, - "LENDING_COV_HASH": { - "type": "tapleaf", - "simf": "./lending.simf", - "params": { - "COLLATERAL_AMOUNT": { - "type": "u64", - "value": "COLLATERAL_AMOUNT" - }, - "PRINCIPAL_AMOUNT": { - "type": "u64", - "value": "PRINCIPAL_AMOUNT" - }, - "LOAN_EXPIRATION_TIME": { - "type": "u32", - "value": "LOAN_EXPIRATION_TIME" - }, - "PRINCIPAL_INTEREST_RATE": { - "type": "u16", - "value": "PRINCIPAL_INTEREST_RATE" - }, - "COLLATERAL_ASSET_ID": { - "type": "liquid.asset_id", - "value": "COLLATERAL_ASSET_ID" - }, - "FIRST_PARAMETERS_NFT_ASSET_ID": { - "type": "liquid.asset_id", - "value": "FIRST_PARAMETERS_NFT_ASSET_ID" - }, - "SECOND_PARAMETERS_NFT_ASSET_ID": { - "type": "liquid.asset_id", - "value": "SECOND_PARAMETERS_NFT_ASSET_ID" - }, - "BORROWER_NFT_ASSET_ID": { - "type": "liquid.asset_id", - "value": "BORROWER_NFT_ASSET_ID" - }, - "PRINCIPAL_ASSET_ID": { - "type": "liquid.asset_id", - "value": "PRINCIPAL_ASSET_ID" - }, - "LENDER_PRINCIPAL_COV_HASH": { - "type": "bytes32", - "value": "LENDER_PRINCIPAL_COV_HASH" - }, - "LENDER_NFT_ASSET_ID": { - "type": "liquid.asset_id", - "value": "LENDER_NFT_ASSET_ID" - } - } - }, - "LOAN_EXPIRATION_TIME": "$params.LOAN_EXPIRATION_TIME", - "PARAMETERS_NFT_OUTPUT_SCRIPT_HASH": { - "type": "tapleaf", - "simf": "./script_auth.simf", - "params": { - "SCRIPT_HASH": { - "type": "bytes32", - "value": "LENDING_COV_HASH" - } - } - }, - "PRE_LOCK_COV_HASH": { - "type": "tapleaf", - "simf": "./pre_lock.simf", - "params": { - "COLLATERAL_AMOUNT": { - "type": "u64", - "value": "COLLATERAL_AMOUNT" - }, - "PRINCIPAL_AMOUNT": { - "type": "u64", - "value": "PRINCIPAL_AMOUNT" - }, - "LOAN_EXPIRATION_TIME": { - "type": "u32", - "value": "LOAN_EXPIRATION_TIME" - }, - "PRINCIPAL_INTEREST_RATE": { - "type": "u16", - "value": "PRINCIPAL_INTEREST_RATE" - }, - "COLLATERAL_ASSET_ID": { - "type": "liquid.asset_id", - "value": "COLLATERAL_ASSET_ID" - }, - "FIRST_PARAMETERS_NFT_ASSET_ID": { - "type": "liquid.asset_id", - "value": "FIRST_PARAMETERS_NFT_ASSET_ID" - }, - "SECOND_PARAMETERS_NFT_ASSET_ID": { - "type": "liquid.asset_id", - "value": "SECOND_PARAMETERS_NFT_ASSET_ID" - }, - "BORROWER_NFT_ASSET_ID": { - "type": "liquid.asset_id", - "value": "BORROWER_NFT_ASSET_ID" - }, - "LENDER_NFT_ASSET_ID": { - "type": "liquid.asset_id", - "value": "LENDER_NFT_ASSET_ID" - }, - "PRINCIPAL_ASSET_ID": { - "type": "liquid.asset_id", - "value": "PRINCIPAL_ASSET_ID" - }, - "LENDING_COV_HASH": { - "type": "bytes32", - "value": "LENDING_COV_HASH" - }, - "PRINCIPAL_OUTPUT_SCRIPT_HASH": { - "type": "bytes32", - "value": "PRINCIPAL_OUTPUT_SCRIPT_HASH" - }, - "PARAMETERS_NFT_OUTPUT_SCRIPT_HASH": { - "type": "bytes32", - "value": "PARAMETERS_NFT_OUTPUT_SCRIPT_HASH" - }, - "BORROWER_NFT_OUTPUT_SCRIPT_HASH": { - "type": "bytes32", - "value": "BORROWER_NFT_OUTPUT_SCRIPT_HASH" - }, - "BORROWER_PUB_KEY": { - "type": "pubkey", - "value": "BORROWER_PUB_KEY" - } - } - }, - "PRELOCK_PARAMETERS_NFT_SCRIPT_HASH": { - "type": "tapleaf", - "simf": "./script_auth.simf", - "params": { - "SCRIPT_HASH": { - "type": "bytes32", - "value": "PRE_LOCK_COV_HASH" - } - } - }, - "PRINCIPAL_AMOUNT": "$params.PRINCIPAL_AMOUNT", - "PRINCIPAL_ASSET_ID": "$params.PRINCIPAL_ASSET_ID", - "PRINCIPAL_DECIMALS_MANTISSA": "$params.PRINCIPAL_DECIMALS_MANTISSA", - "PRINCIPAL_INTEREST_AMOUNT": "params.PRINCIPAL_AMOUNT * params.PRINCIPAL_INTEREST_RATE / 10000", - "PRINCIPAL_INTEREST_RATE": "$params.PRINCIPAL_INTEREST_RATE", - "PRINCIPAL_OUTPUT_SCRIPT_HASH": { - "type": "tapleaf", - "simf": "./p2pk.simf", - "params": { - "PUB_KEY": { - "type": "pubkey", - "value": "BORROWER_PUB_KEY" - } - } - }, - "SECOND_PARAMETERS_ENCODED": "$instance.SECOND_PARAMETERS_ENCODED", - "SECOND_PARAMETERS_NFT_ASSET_ID": "$instance.SECOND_PARAMETERS_NFT_ASSET_ID" - } - }, - "inputs": [ - { - "id": "borrower_nft_issuance_input", - "description": "Wallet UTXO whose outpoint determines BORROWER_NFT_ASSET_ID.", - "utxo_source": "wallet", - "asset": "lbtc", - "issuance": { - "kind": "new", - "asset_amount_sat": 1, - "inflation_amount_sat": 0 - }, - "on_resolved": { - "set": { - "instance.BORROWER_NFT_ASSET_ID": "asset" - } - } - }, - { - "id": "lender_nft_issuance_input", - "description": "Wallet UTXO whose outpoint determines LENDER_NFT_ASSET_ID.", - "utxo_source": "wallet", - "asset": "lbtc", - "issuance": { - "kind": "new", - "asset_amount_sat": 1, - "inflation_amount_sat": 0 - }, - "on_resolved": { - "set": { - "instance.LENDER_NFT_ASSET_ID": "asset" - } - } - }, - { - "id": "first_params_issuance_input", - "description": "Wallet UTXO whose outpoint determines FIRST_PARAMETERS_NFT_ASSET_ID.", - "utxo_source": "wallet", - "asset": "lbtc", - "issuance": { - "kind": "new", - "asset_amount_sat": "instance.FIRST_PARAMETERS_ENCODED", - "inflation_amount_sat": 0 - }, - "on_resolved": { - "set": { - "instance.FIRST_PARAMETERS_NFT_ASSET_ID": "asset" - } - } - }, - { - "id": "second_params_issuance_input", - "description": "Wallet UTXO whose outpoint determines SECOND_PARAMETERS_NFT_ASSET_ID.", - "utxo_source": "wallet", - "asset": "lbtc", - "issuance": { - "kind": "new", - "asset_amount_sat": "instance.SECOND_PARAMETERS_ENCODED", - "inflation_amount_sat": 0 - }, - "on_resolved": { - "set": { - "instance.SECOND_PARAMETERS_NFT_ASSET_ID": "asset" - } - } - } - ], - "outputs": [ - { - "id": "borrower_nft_out", - "description": "Borrower auth NFT. Amount=1, no reissuance.", - "destination": "wallet", - "asset": "instance.BORROWER_NFT_ASSET_ID", - "amount_sat": 1, - "confidential": false - }, - { - "id": "lender_nft_out", - "description": "Lender auth NFT. Amount=1, no reissuance.", - "destination": "wallet", - "asset": "instance.LENDER_NFT_ASSET_ID", - "amount_sat": 1, - "confidential": false - }, - { - "id": "first_params_nft_out", - "description": "First Parameter NFT carrying bit-packed loan terms in amount field.", - "destination": "wallet", - "asset": "instance.FIRST_PARAMETERS_NFT_ASSET_ID", - "amount_sat": "instance.FIRST_PARAMETERS_ENCODED", - "confidential": false - }, - { - "id": "second_params_nft_out", - "description": "Second Parameter NFT carrying bit-packed base amounts in amount field.", - "destination": "wallet", - "asset": "instance.SECOND_PARAMETERS_NFT_ASSET_ID", - "amount_sat": "instance.SECOND_PARAMETERS_ENCODED", - "confidential": false - }, - { - "id": "fee_change", - "description": "L-BTC change back to borrower.", - "destination": "change", - "asset": "lbtc", - "optional": true - } - ] - }, - "LockCollateral": { - "description": "Borrower locks collateral and all four Utility NFTs into their covenant addresses, creating the open offer on-chain.", - "inputs": [ - { - "id": "collateral_in", - "description": "Borrower's collateral UTXO.", - "utxo_source": "wallet", - "asset": "instance.COLLATERAL_ASSET_ID", - "amount_sat": { - "min_amount": "instance.COLLATERAL_AMOUNT" - } - }, - { - "id": "borrower_nft_in", - "description": "Borrower NFT from wallet.", - "utxo_source": "wallet", - "asset": "instance.BORROWER_NFT_ASSET_ID", - "amount_sat": 1 - }, - { - "id": "lender_nft_in", - "description": "Lender NFT from wallet.", - "utxo_source": "wallet", - "asset": "instance.LENDER_NFT_ASSET_ID", - "amount_sat": 1 - }, - { - "id": "first_params_in", - "description": "First Parameter NFT from wallet.", - "utxo_source": "wallet", - "asset": "instance.FIRST_PARAMETERS_NFT_ASSET_ID", - "amount_sat": "instance.FIRST_PARAMETERS_ENCODED" - }, - { - "id": "second_params_in", - "description": "Second Parameter NFT from wallet.", - "utxo_source": "wallet", - "asset": "instance.SECOND_PARAMETERS_NFT_ASSET_ID", - "amount_sat": "instance.SECOND_PARAMETERS_ENCODED" - }, - { - "id": "fee_input", - "description": "Wallet L-BTC UTXO covering the transaction fee.", - "utxo_source": "wallet", - "asset": "lbtc" - } - ], - "outputs": [ - { - "id": "pre_lock_out", - "description": "Collateral locked in the PreLockCovenant.", - "destination": { - "utxo_type": "pre_lock" - }, - "asset": "instance.COLLATERAL_ASSET_ID", - "amount_sat": "instance.COLLATERAL_AMOUNT" - }, - { - "id": "borrower_nft_locked", - "description": "Borrower NFT locked in prelock ScriptAuth.", - "destination": { - "utxo_type": "prelock_script_auth" - }, - "asset": "instance.BORROWER_NFT_ASSET_ID", - "amount_sat": 1 - }, - { - "id": "lender_nft_locked", - "description": "Lender NFT locked in prelock ScriptAuth.", - "destination": { - "utxo_type": "prelock_script_auth" - }, - "asset": "instance.LENDER_NFT_ASSET_ID", - "amount_sat": 1 - }, - { - "id": "first_params_locked", - "description": "First Parameter NFT locked in prelock ScriptAuth.", - "destination": { - "utxo_type": "prelock_script_auth" - }, - "asset": "instance.FIRST_PARAMETERS_NFT_ASSET_ID", - "amount_sat": "instance.FIRST_PARAMETERS_ENCODED" - }, - { - "id": "second_params_locked", - "description": "Second Parameter NFT locked in prelock ScriptAuth.", - "destination": { - "utxo_type": "prelock_script_auth" - }, - "asset": "instance.SECOND_PARAMETERS_NFT_ASSET_ID", - "amount_sat": "instance.SECOND_PARAMETERS_ENCODED" - }, - { - "id": "indexer_op_return", - "description": "OP_RETURN for indexer discovery.", - "destination": { - "type": "op_return" - }, - "data": "concat(instance.BORROWER_PUB_KEY, instance.PRINCIPAL_ASSET_ID)" - }, - { - "id": "collateral_change", - "description": "Collateral change back to borrower.", - "destination": "change", - "asset": "instance.COLLATERAL_ASSET_ID", - "optional": true - }, - { - "id": "fee_change", - "description": "L-BTC change back to borrower.", - "destination": "change", - "asset": "lbtc", - "optional": true - } - ] - }, - "CancelOffer": { - "description": "Borrower cancels the open offer. Requires BORROWER_PUB_KEY signature. Collateral returned; all Utility NFTs burned. Spends pre_lock via PATH::RIGHT.", - "inputs": [ - { - "id": "pre_lock_in", - "description": "Pre-lock collateral UTXO (index 0).", - "utxo_source": { - "utxo_type": "pre_lock" - }, - "witnesses": { - "PATH": { - "type": "simplicityhl", - "simplicity_type": "Either<()>", - "value": "Right(())", - "description": "Selects cancel_pre_lock_path (PATH::RIGHT)." - }, - "SIGNATURE": { - "type": "Signature", - "sig_type": "sig_hash_all", - "source": { - "type": "wallet", - "key": "instance.BORROWER_PUB_KEY" - }, - "description": "BIP340 Schnorr signature from BORROWER_PUB_KEY." - }, - "SPEND_PATH": { - "type": "taproot_leaf", - "source": { - "type": "formula", - "expr": "pre_lock_leaf" - } - } - } - }, - { - "id": "first_params_in", - "description": "First Parameter NFT in prelock ScriptAuth (index 1).", - "utxo_source": { - "utxo_type": "prelock_script_auth" - }, - "asset": "instance.FIRST_PARAMETERS_NFT_ASSET_ID", - "witnesses": { - "INPUT_SCRIPT_INDEX": { - "type": "simplicityhl", - "simplicity_type": "u32", - "value": "0" - }, - "SPEND_PATH": { - "type": "taproot_leaf", - "source": { - "type": "formula", - "expr": "prelock_script_auth_leaf" - } - } - } - }, - { - "id": "second_params_in", - "description": "Second Parameter NFT in prelock ScriptAuth (index 2).", - "utxo_source": { - "utxo_type": "prelock_script_auth" - }, - "asset": "instance.SECOND_PARAMETERS_NFT_ASSET_ID", - "witnesses": { - "INPUT_SCRIPT_INDEX": { - "type": "simplicityhl", - "simplicity_type": "u32", - "value": "0" - }, - "SPEND_PATH": { - "type": "taproot_leaf", - "source": { - "type": "formula", - "expr": "prelock_script_auth_leaf" - } - } - } - }, - { - "id": "borrower_nft_in", - "description": "Borrower NFT in prelock ScriptAuth (index 3).", - "utxo_source": { - "utxo_type": "prelock_script_auth" - }, - "asset": "instance.BORROWER_NFT_ASSET_ID", - "witnesses": { - "INPUT_SCRIPT_INDEX": { - "type": "simplicityhl", - "simplicity_type": "u32", - "value": "0" - }, - "SPEND_PATH": { - "type": "taproot_leaf", - "source": { - "type": "formula", - "expr": "prelock_script_auth_leaf" - } - } - } - }, - { - "id": "lender_nft_in", - "description": "Lender NFT in prelock ScriptAuth (index 4).", - "utxo_source": { - "utxo_type": "prelock_script_auth" - }, - "asset": "instance.LENDER_NFT_ASSET_ID", - "witnesses": { - "INPUT_SCRIPT_INDEX": { - "type": "simplicityhl", - "simplicity_type": "u32", - "value": "0" - }, - "SPEND_PATH": { - "type": "taproot_leaf", - "source": { - "type": "formula", - "expr": "prelock_script_auth_leaf" - } - } - } - }, - { - "id": "fee_input", - "description": "Wallet L-BTC for fees.", - "utxo_source": "wallet", - "asset": "lbtc" - } - ], - "outputs": [ - { - "id": "collateral_returned", - "destination": "wallet", - "asset": "instance.COLLATERAL_ASSET_ID", - "amount_sat": "pre_lock_in.amount_sat" - }, - { - "id": "first_params_burned", - "destination": { - "type": "op_return" - }, - "asset": "instance.FIRST_PARAMETERS_NFT_ASSET_ID", - "amount_sat": "first_params_in.amount_sat" - }, - { - "id": "second_params_burned", - "destination": { - "type": "op_return" - }, - "asset": "instance.SECOND_PARAMETERS_NFT_ASSET_ID", - "amount_sat": "second_params_in.amount_sat" - }, - { - "id": "borrower_nft_burned", - "destination": { - "type": "op_return" - }, - "asset": "instance.BORROWER_NFT_ASSET_ID", - "amount_sat": 1 - }, - { - "id": "lender_nft_burned", - "destination": { - "type": "op_return" - }, - "asset": "instance.LENDER_NFT_ASSET_ID", - "amount_sat": 1 - }, - { - "id": "fee_change", - "destination": "change", - "asset": "lbtc", - "optional": true - } - ] - }, - "SetupLending": { - "description": "Lender activates the loan by spending the pre_lock covenant (PATH::LEFT). Collateral moves to LendingCovenant; principal delivered to borrower.", - "inputs": [ - { - "id": "collateral_in", - "description": "Pre-lock collateral UTXO (index 0).", - "utxo_source": { - "utxo_type": "pre_lock" - }, - "required_index": 0, - "witnesses": { - "PATH": { - "type": "simplicityhl", - "simplicity_type": "Either<()>", - "value": "Left(())", - "description": "Selects create_lending_path (PATH::LEFT)." - }, - "SIGNATURE": "unused", - "SPEND_PATH": { - "type": "taproot_leaf", - "source": { - "type": "formula", - "expr": "pre_lock_leaf" - } - } - } - }, - { - "id": "first_params_in", - "description": "First Parameter NFT in prelock ScriptAuth (index 1).", - "utxo_source": { - "utxo_type": "prelock_script_auth" - }, - "required_index": 1, - "asset": "instance.FIRST_PARAMETERS_NFT_ASSET_ID", - "witnesses": { - "INPUT_SCRIPT_INDEX": { - "type": "simplicityhl", - "simplicity_type": "u32", - "value": "0" - }, - "SPEND_PATH": { - "type": "taproot_leaf", - "source": { - "type": "formula", - "expr": "prelock_script_auth_leaf" - } - } - } - }, - { - "id": "second_params_in", - "description": "Second Parameter NFT in prelock ScriptAuth (index 2).", - "utxo_source": { - "utxo_type": "prelock_script_auth" - }, - "required_index": 2, - "asset": "instance.SECOND_PARAMETERS_NFT_ASSET_ID", - "witnesses": { - "INPUT_SCRIPT_INDEX": { - "type": "simplicityhl", - "simplicity_type": "u32", - "value": "0" - }, - "SPEND_PATH": { - "type": "taproot_leaf", - "source": { - "type": "formula", - "expr": "prelock_script_auth_leaf" - } - } - } - }, - { - "id": "borrower_nft_in", - "description": "Borrower NFT in prelock ScriptAuth (index 3).", - "utxo_source": { - "utxo_type": "prelock_script_auth" - }, - "required_index": 3, - "asset": "instance.BORROWER_NFT_ASSET_ID", - "witnesses": { - "INPUT_SCRIPT_INDEX": { - "type": "simplicityhl", - "simplicity_type": "u32", - "value": "0" - }, - "SPEND_PATH": { - "type": "taproot_leaf", - "source": { - "type": "formula", - "expr": "prelock_script_auth_leaf" - } - } - } - }, - { - "id": "lender_nft_in", - "description": "Lender NFT in prelock ScriptAuth (index 4).", - "utxo_source": { - "utxo_type": "prelock_script_auth" - }, - "required_index": 4, - "asset": "instance.LENDER_NFT_ASSET_ID", - "witnesses": { - "INPUT_SCRIPT_INDEX": { - "type": "simplicityhl", - "simplicity_type": "u32", - "value": "0" - }, - "SPEND_PATH": { - "type": "taproot_leaf", - "source": { - "type": "formula", - "expr": "prelock_script_auth_leaf" - } - } - } - }, - { - "id": "principal_in", - "description": "Lender's wallet UTXO providing the principal.", - "utxo_source": "wallet", - "asset": "instance.PRINCIPAL_ASSET_ID", - "amount_sat": { - "min_amount": "instance.PRINCIPAL_AMOUNT" - }, - "required_index": 5 - }, - { - "id": "fee_input", - "description": "Wallet L-BTC for fees.", - "utxo_source": "wallet", - "asset": "lbtc", - "required_index": 6 - } - ], - "outputs": [ - { - "id": "lending_collateral_out", - "description": "Collateral at LendingCovenant (output 0).", - "destination": { - "utxo_type": "lending_collateral" - }, - "asset": "instance.COLLATERAL_ASSET_ID", - "amount_sat": "instance.COLLATERAL_AMOUNT", - "required_index": 0, - "confidential": false - }, - { - "id": "principal_to_borrower", - "description": "Principal to borrower P2PK (output 1).", - "destination": { - "utxo_type": "p2pk" - }, - "asset": "instance.PRINCIPAL_ASSET_ID", - "amount_sat": "instance.PRINCIPAL_AMOUNT", - "confidential": false, - "required_index": 1 - }, - { - "id": "first_params_relocked", - "description": "First Parameter NFT under lending ScriptAuth (output 2).", - "destination": { - "utxo_type": "lending_script_auth" - }, - "asset": "instance.FIRST_PARAMETERS_NFT_ASSET_ID", - "amount_sat": "first_params_in.amount_sat", - "required_index": 2, - "confidential": false - }, - { - "id": "second_params_relocked", - "description": "Second Parameter NFT under lending ScriptAuth (output 3).", - "destination": { - "utxo_type": "lending_script_auth" - }, - "asset": "instance.SECOND_PARAMETERS_NFT_ASSET_ID", - "amount_sat": "second_params_in.amount_sat", - "required_index": 3, - "confidential": false - }, - { - "id": "borrower_nft_released", - "description": "Borrower NFT under lending ScriptAuth (output 4).", - "destination": { - "utxo_type": "lending_script_auth" - }, - "asset": "instance.BORROWER_NFT_ASSET_ID", - "amount_sat": 1, - "required_index": 4, - "confidential": false - }, - { - "id": "lender_nft_released", - "description": "Lender NFT to lender's wallet (output 5).", - "destination": "wallet", - "asset": "instance.LENDER_NFT_ASSET_ID", - "amount_sat": 1, - "required_index": 5, - "confidential": false - }, - { - "id": "principal_change", - "destination": "change", - "asset": "instance.PRINCIPAL_ASSET_ID", - "optional": true, - "required_index": -2 - }, - { - "id": "fee_change", - "destination": "change", - "asset": "lbtc", - "optional": true, - "required_index": -1 - } - ] - }, - "ClaimLoanFunds": { - "description": "As a borrower, claim the funds from a p2pk.simf into a normal wallet output.", - "inputs": [ - { - "id": "principal_in", - "description": "Principal UTXO at borrower P2PK (index 0).", - "utxo_source": { - "utxo_type": "p2pk" - }, - "witnesses": { - "SPEND_PATH": { - "type": "taproot_leaf", - "source": { - "type": "formula", - "expr": "p2pk_leaf" - } - }, - "SIGNATURE": { - "type": "Signature", - "sig_type": "sig_hash_all", - "source": { - "type": "wallet", - "key": "$params.BORROWER_PUB_KEY" - }, - "description": "BIP340 Schnorr signature from BORROWER_PUB_KEY." - } - } - }, - { - "id": "fee_input", - "description": "Wallet L-BTC for fees (optional if principal is L-BTC).", - "utxo_source": "wallet", - "asset": "lbtc", - "optional": true - } - ], - "outputs": [ - { - "id": "principal_to_borrower", - "description": "Principal to borrower wallet (output 0).", - "destination": "wallet", - "asset": "instance.PRINCIPAL_ASSET_ID", - "amount_sat": "principal_in.amount_sat" - }, - { - "id": "fee_change", - "description": "L-BTC change back to borrower.", - "destination": "change", - "asset": "lbtc", - "optional": true - } - ] - }, - "RepayLoan": { - "description": "Borrower repays principal+interest and reclaims collateral. Spends lending_collateral via PATH::LEFT.", - "inputs": [ - { - "id": "lending_in", - "description": "Active lending collateral UTXO (index 0).", - "utxo_source": { - "utxo_type": "lending_collateral" - }, - "witnesses": { - "PATH": { - "type": "simplicityhl", - "simplicity_type": "Either<()>", - "value": "Left(())", - "description": "Selects loan_repayment_path (PATH::LEFT)." - }, - "SPEND_PATH": { - "type": "taproot_leaf", - "source": { - "type": "formula", - "expr": "lending_leaf" - } - } - } - }, - { - "id": "first_params_in", - "description": "First Parameter NFT in lending ScriptAuth (index 1).", - "utxo_source": { - "utxo_type": "lending_script_auth" - }, - "asset": "instance.FIRST_PARAMETERS_NFT_ASSET_ID", - "witnesses": { - "INPUT_SCRIPT_INDEX": { - "type": "simplicityhl", - "simplicity_type": "u32", - "value": "0" - }, - "SPEND_PATH": { - "type": "taproot_leaf", - "source": { - "type": "formula", - "expr": "lending_script_auth_leaf" - } - } - } - }, - { - "id": "second_params_in", - "description": "Second Parameter NFT in lending ScriptAuth (index 2).", - "utxo_source": { - "utxo_type": "lending_script_auth" - }, - "asset": "instance.SECOND_PARAMETERS_NFT_ASSET_ID", - "witnesses": { - "INPUT_SCRIPT_INDEX": { - "type": "simplicityhl", - "simplicity_type": "u32", - "value": "0" - }, - "SPEND_PATH": { - "type": "taproot_leaf", - "source": { - "type": "formula", - "expr": "lending_script_auth_leaf" - } - } - } - }, - { - "id": "borrower_nft_in", - "description": "Borrower NFT in lending ScriptAuth (index 3).", - "utxo_source": { - "utxo_type": "lending_script_auth" - }, - "asset": "instance.BORROWER_NFT_ASSET_ID", - "witnesses": { - "INPUT_SCRIPT_INDEX": { - "type": "simplicityhl", - "simplicity_type": "u32", - "value": "0" - }, - "SPEND_PATH": { - "type": "taproot_leaf", - "source": { - "type": "formula", - "expr": "lending_script_auth_leaf" - } - } - } - }, - { - "id": "repayment_in", - "description": "Borrower's wallet UTXO providing principal+interest.", - "utxo_source": "wallet", - "asset": "instance.PRINCIPAL_ASSET_ID", - "amount_sat": { - "min_amount": "instance.PRINCIPAL_AMOUNT + instance.PRINCIPAL_INTEREST_AMOUNT" - } - }, - { - "id": "fee_input", - "description": "Wallet L-BTC for fees (optional if principal is L-BTC).", - "utxo_source": "wallet", - "asset": "lbtc", - "optional": true - } - ], - "outputs": [ - { - "id": "collateral_returned", - "description": "Collateral returned to borrower (output 0).", - "destination": "wallet", - "asset": "instance.COLLATERAL_ASSET_ID", - "amount_sat": "instance.COLLATERAL_AMOUNT", - "confidential": false - }, - { - "id": "principal_interest_to_vault", - "description": "Principal+interest to lender vault at LENDER_PRINCIPAL_COV_HASH (output 1).", - "destination": { - "utxo_type": "lender_principal_vault" - }, - "asset": "instance.PRINCIPAL_ASSET_ID", - "amount_sat": "instance.PRINCIPAL_AMOUNT + instance.PRINCIPAL_INTEREST_AMOUNT", - "confidential": false - }, - { - "id": "first_params_burned", - "destination": { - "type": "op_return" - }, - "asset": "instance.FIRST_PARAMETERS_NFT_ASSET_ID", - "amount_sat": "first_params_in.amount_sat" - }, - { - "id": "second_params_burned", - "destination": { - "type": "op_return" - }, - "asset": "instance.SECOND_PARAMETERS_NFT_ASSET_ID", - "amount_sat": "second_params_in.amount_sat" - }, - { - "id": "borrower_nft_burned", - "destination": { - "type": "op_return" - }, - "asset": "instance.BORROWER_NFT_ASSET_ID", - "amount_sat": 1 - }, - { - "id": "repayment_change", - "destination": "change", - "asset": "instance.PRINCIPAL_ASSET_ID", - "optional": true - }, - { - "id": "fee_change", - "destination": "change", - "asset": "lbtc", - "optional": true - } - ] - }, - "LiquidateAfterExpiry": { - "description": "Lender claims collateral after loan expiry. Spends lending_collateral via PATH::RIGHT.", - "inputs": [ - { - "id": "lending_in", - "description": "Active lending collateral UTXO (index 0).", - "utxo_source": { - "utxo_type": "lending_collateral" - }, - "witnesses": { - "PATH": { - "type": "simplicityhl", - "simplicity_type": "Either<()>", - "value": "Right(())", - "description": "Selects loan_liquidation_path (PATH::RIGHT)." - }, - "SPEND_PATH": { - "type": "taproot_leaf", - "source": { - "type": "formula", - "expr": "lending_leaf" - } - } - } - }, - { - "id": "first_params_in", - "description": "First Parameter NFT in lending ScriptAuth (index 1).", - "utxo_source": { - "utxo_type": "lending_script_auth" - }, - "asset": "instance.FIRST_PARAMETERS_NFT_ASSET_ID", - "witnesses": { - "INPUT_SCRIPT_INDEX": { - "type": "simplicityhl", - "simplicity_type": "u32", - "value": "0" - }, - "SPEND_PATH": { - "type": "taproot_leaf", - "source": { - "type": "formula", - "expr": "lending_script_auth_leaf" - } - } - } - }, - { - "id": "second_params_in", - "description": "Second Parameter NFT in lending ScriptAuth (index 2).", - "utxo_source": { - "utxo_type": "lending_script_auth" - }, - "asset": "instance.SECOND_PARAMETERS_NFT_ASSET_ID", - "witnesses": { - "INPUT_SCRIPT_INDEX": { - "type": "simplicityhl", - "simplicity_type": "u32", - "value": "0" - }, - "SPEND_PATH": { - "type": "taproot_leaf", - "source": { - "type": "formula", - "expr": "lending_script_auth_leaf" - } - } - } - }, - { - "id": "lender_nft_in", - "description": "Lender NFT from wallet (index 3).", - "utxo_source": "wallet", - "asset": "instance.LENDER_NFT_ASSET_ID", - "amount_sat": 1 - }, - { - "id": "fee_input", - "description": "Wallet L-BTC for fees.", - "utxo_source": "wallet", - "asset": "lbtc" - } - ], - "outputs": [ - { - "id": "collateral_to_lender", - "description": "Collateral sent to lender (output 0).", - "destination": "wallet", - "asset": "instance.COLLATERAL_ASSET_ID", - "amount_sat": "instance.COLLATERAL_AMOUNT" - }, - { - "id": "first_params_burned", - "destination": { - "type": "op_return" - }, - "asset": "instance.FIRST_PARAMETERS_NFT_ASSET_ID", - "amount_sat": "first_params_in.amount_sat" - }, - { - "id": "second_params_burned", - "destination": { - "type": "op_return" - }, - "asset": "instance.SECOND_PARAMETERS_NFT_ASSET_ID", - "amount_sat": "second_params_in.amount_sat" - }, - { - "id": "lender_nft_burned", - "destination": { - "type": "op_return" - }, - "asset": "instance.LENDER_NFT_ASSET_ID", - "amount_sat": 1 - }, - { - "id": "fee_change", - "destination": "change", - "asset": "lbtc", - "optional": true - } - ] - }, - "ClaimPrincipalWithInterest": { - "description": "Lender withdraws principal+interest from the lender_principal_vault by co-spending and burning the Lender NFT.", - "inputs": [ - { - "id": "vault_in", - "description": "The lender_principal_vault UTXO.", - "utxo_source": { - "utxo_type": "lender_principal_vault" - }, - "witnesses": { - "INPUT_ASSET_INDEX": { - "type": "simplicityhl", - "simplicity_type": "u32", - "value": "1" - }, - "OUTPUT_ASSET_INDEX": { - "type": "simplicityhl", - "simplicity_type": "u32", - "value": "1" - }, - "SPEND_PATH": { - "type": "taproot_leaf", - "source": { - "type": "formula", - "expr": "lender_principal_vault_leaf" - } - } - } - }, - { - "id": "lender_nft_in", - "description": "Lender NFT (amount=1).", - "utxo_source": "wallet", - "asset": "instance.LENDER_NFT_ASSET_ID", - "amount_sat": 1, - "required_index": 1 - }, - { - "id": "fee_input", - "description": "Wallet L-BTC for fees.", - "utxo_source": "wallet", - "asset": "lbtc" - } - ], - "outputs": [ - { - "id": "principal_interest_out", - "description": "Principal+interest delivered to lender.", - "destination": "wallet", - "asset": "instance.PRINCIPAL_ASSET_ID", - "amount_sat": "vault_in.amount_sat" - }, - { - "id": "lender_nft_burned", - "description": "Lender NFT burned (required by WITH_ASSET_BURN=true).", - "destination": { - "type": "op_return" - }, - "asset": "instance.LENDER_NFT_ASSET_ID", - "amount_sat": 1, - "required_index": 1 - }, - { - "id": "fee_change", - "destination": "change", - "asset": "lbtc", - "optional": true - } - ] - } - } - } - } -} \ No newline at end of file diff --git a/examples/lending_v3/txmanifest.json b/examples/lending_v3/txmanifest.json index b77c7fb..94b3cde 100644 --- a/examples/lending_v3/txmanifest.json +++ b/examples/lending_v3/txmanifest.json @@ -8,7 +8,7 @@ "description": "lending_v3 — the redesigned 'issuance factory' lending protocol, wire-compatible with the deployed simplicity-lending indexer/site (odev branch). Phase 3a models factory creation: a persistent issuance_factory covenant plus the wallet-held auth NFT, from which many lending offers are later minted.", "utxo_types": { "issuance_factory": { - "description": "The persistent issuance-factory covenant. Holds 1 unit of the factory asset and, when spent (IssueAssets path), mints borrower/lender NFTs for a new offer while recreating itself. Its address depends ONLY on (ISSUING_UTXOS_COUNT, REISSUANCE_FLAGS) — NOT on the factory asset id — so for the deployed (2, 0) it is the fixed spk 5120456881785cc7d561caaa059e02f1a2823066bd860423996bea3e92c621bb064b (verified reproduced by examples/factory_recon.rs). Compiled with debug symbols to match the deployed CMR.", + "description": "The persistent issuance-factory covenant. Holds 1 unit of the factory asset and, when spent (IssueAssets path), mints borrower/lender NFTs for a new offer while recreating itself. Its address depends ONLY on (ISSUING_UTXOS_COUNT, REISSUANCE_FLAGS) — NOT on the factory asset id — so for the deployed (2, 0) it is the fixed spk 5120456881785cc7d561caaa059e02f1a2823066bd860423996bea3e92c621bb064b (verified reproduced by txmanifest_lib/tests/interop/issuance_factory.rs). Compiled with debug symbols to match the deployed CMR.", "script": { "type": "simplicity", "source": "./issuance_factory.simf", @@ -20,7 +20,7 @@ "asset": "FACTORY_ASSET_ID" }, "lending_collateral": { - "description": "The lending (collateral) covenant — out[5] of an offer-creation tx. Holds COLLATERAL_AMOUNT of the collateral asset with 2 taproot storage slots (slot0 = is_active, slot1 = current_debt). Its address is the CMR of lending.simf compiled with the full offer params + the 5 nested AssetAuth/AssetAuthVault cov-hashes (task 07), folded with the 2 storage leaves (task 01/02). Reproduced byte-exactly for live offer 43ab4efe (examples/lending_recon.rs). NOTE: slot1 (current_debt) is dynamic; expressing it as a computed 32-byte storage leaf needs the engine extension tracked in upnext/10.", + "description": "The lending (collateral) covenant — out[5] of an offer-creation tx. Holds COLLATERAL_AMOUNT of the collateral asset with 2 taproot storage slots (slot0 = is_active, slot1 = current_debt). Its address is the CMR of lending.simf compiled with the full offer params + the 5 nested AssetAuth/AssetAuthVault cov-hashes (task 07), folded with the 2 storage leaves (task 01/02). Reproduced byte-exactly for live offer 43ab4efe (txmanifest_lib/tests/interop/lending_collateral.rs). NOTE: slot1 (current_debt) is dynamic; expressing it as a computed 32-byte storage leaf needs the engine extension tracked in upnext/10.", "script": { "type": "simplicity", "source": "./lending.simf", @@ -63,7 +63,7 @@ "asset": "COLLATERAL_ASSET_ID" }, "lending_collateral_active": { - "description": "The lending (collateral) covenant in the ACTIVE state (after AcceptOffer). Identical to lending_collateral except storage slot0 = is_active = 1 (value 1, byte[31]=0x01), which changes the covenant address. slot1 = CURRENT_DEBT (unchanged on a fresh accept). De-risked by examples/lending_active_recon.rs (address flips vs pending).", + "description": "The lending (collateral) covenant in the ACTIVE state (after AcceptOffer). Identical to lending_collateral except storage slot0 = is_active = 1 (value 1, byte[31]=0x01), which changes the covenant address. slot1 = CURRENT_DEBT (unchanged on a fresh accept). De-risked by txmanifest_lib/tests/interop/lending_active.rs (address flips vs pending).", "script": { "type": "simplicity", "source": "./lending.simf", @@ -309,7 +309,7 @@ } }, "lending_contract": { - "description": "A single P2P lending offer minted from an issuance factory (lending_v3). CreateOffer computes the lending (collateral) covenant address (out[5]) from the offer terms and the nested AssetAuth/AssetAuthVault cov-hash chain (task 07), matching simplicity-lending's LendingOfferParameters::build_arguments. The 5 nested hashes reproduce live offer 43ab4efe out[5] byte-exactly (examples/lending_recon.rs). Full on-chain assembly (dynamic storage leaves, script_auth-over-storage) is tracked in task 06 + upnext/10-11.", + "description": "A single P2P lending offer minted from an issuance factory (lending_v3). CreateOffer computes the lending (collateral) covenant address (out[5]) from the offer terms and the nested AssetAuth/AssetAuthVault cov-hash chain (task 07), matching simplicity-lending's LendingOfferParameters::build_arguments. The 5 nested hashes reproduce live offer 43ab4efe out[5] byte-exactly (txmanifest_lib/tests/interop/lending_collateral.rs). Full on-chain assembly (dynamic storage leaves, script_auth-over-storage) is tracked in task 06 + upnext/10-11.", "fields": { "FACTORY_ASSET_ID": { "type": "liquid.asset_id", diff --git a/examples/zeroconf/txmanifest.json b/examples/zeroconf/txmanifest.json deleted file mode 100644 index e44045f..0000000 --- a/examples/zeroconf/txmanifest.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "$schema": "../../schema/txmanifest.schema.json", - "manifest_version": "0.3.0", - "protocol": "zeroconf", - "description": "Example zeroconf", - "chain": "liquid", - "requires": [], - "utxo_types": {}, - "actions": {} -} \ No newline at end of file diff --git a/txmanifest_lib/Cargo.toml b/txmanifest_lib/Cargo.toml index 594522f..1a015cd 100644 --- a/txmanifest_lib/Cargo.toml +++ b/txmanifest_lib/Cargo.toml @@ -1,9 +1,9 @@ [package] name = "tx-manifest-lib" -version = "0.2.0" +version = "0.3.0" edition = "2021" license = "MIT OR Apache-2.0" -description = "Declarative transaction-manifest engine and SimplicityHL covenant runtime for Liquid/Elements." +description = "Declarative transaction-manifest engine and SimplicityHL covenant runtime for Liquid/Elements and Bitcoin." repository = "https://github.com/stringhandler/txmanifest-wallet" keywords = ["liquid", "elements", "simplicity", "covenant", "bitcoin"] @@ -24,17 +24,10 @@ rand = "0.8" ureq = "2" url = "2" base64 = "0.22" -# Covenant dry-runs, address derivation and witness building only use upstream -# (master) APIs. The `feat/compile-function-only` fork is a superset of master that -# additionally provides the standalone compile_function / expression-eval APIs used -# by the `simplicity_eval` feature, so this one ref satisfies both build configs. -# simplicityhl = { git = "https://github.com/stringhandler/SimplicityHL", branch = "feat/compile-function-only", features = [ -# "serde", -# ] } # delta1's Bitcoin-enabled fork: a superset of upstream that adds `BitcoinJetHinter` # and a real `BitcoinEnv`, without which a Bitcoin covenant cannot be compiled at all. # The Elements jet set, and therefore every Elements CMR and covenant address, must be -# byte-identical to upstream's — `covenant::tests` and the recon examples pin live +# byte-identical to upstream's — `covenant::tests` and `tests/interop` pin live # addresses precisely so that a pin change cannot move them unnoticed. simplicityhl = { git = "https://github.com/delta1/SimplicityHL", branch = "bitcoin", features = [ "serde", diff --git a/txmanifest_lib/examples/covaddr.rs b/txmanifest_lib/examples/covaddr.rs deleted file mode 100644 index e36281a..0000000 --- a/txmanifest_lib/examples/covaddr.rs +++ /dev/null @@ -1,49 +0,0 @@ -// Print the covenant tapleaf / scriptPubKey for a .simf compiled with a fixed -// SCRIPT_HASH, to compare CMRs across Simplicity toolchains. -// cargo run -p tx-manifest-lib --example covaddr -- -use std::collections::HashMap; - -use lwk_wollet::ElementsNetwork; -use tx_manifest_lib::covenant; - -fn hex(b: &[u8]) -> String { - b.iter().map(|x| format!("{x:02x}")).collect() -} - -fn main() { - let simf = std::path::PathBuf::from( - std::env::args() - .nth(1) - .unwrap_or_else(|| "examples/lending_v2/script_auth.simf".into()), - ); - let script_hash = std::env::args().nth(2).unwrap_or_else(|| "00".repeat(32)); - let mut params = HashMap::new(); - params.insert("SCRIPT_HASH".to_string(), script_hash); - let mut hints = HashMap::new(); - hints.insert("SCRIPT_HASH".to_string(), "bytes32".to_string()); - - let tapleaf = covenant::compute_tapleaf_hash(&simf, ¶ms, &hints, true).unwrap(); - let spk_hash = covenant::compute_covenant_script_hash( - &simf, - ¶ms, - &hints, - ElementsNetwork::LiquidTestnet, - true, - ) - .unwrap(); - let addr = covenant::compute_covenant_address( - &simf, - ¶ms, - &hints, - &[], - ElementsNetwork::LiquidTestnet, - true, - ) - .unwrap(); - - eprintln!("---- result ----"); - println!("simf = {}", simf.display()); - println!("tapleaf_hash = {}", hex(&tapleaf)); - println!("spk = {:x}", addr.script_pubkey()); - println!("sha256(spk) = {}", hex(&spk_hash)); -} diff --git a/txmanifest_lib/examples/deadcat_recon.rs b/txmanifest_lib/examples/deadcat_recon.rs deleted file mode 100644 index 16876a6..0000000 --- a/txmanifest_lib/examples/deadcat_recon.rs +++ /dev/null @@ -1,254 +0,0 @@ -// Reproduce Deadcat's four per-state covenant addresses and cross-check the engine's -// tapdata-state encoding against deadcat-sdk/src/taproot.rs. -// -// cargo run -p tx-manifest-lib --example deadcat_recon -// -// The engine reaches its address through `TaprootSpendInfo::new_key_spend` over a merkle -// root it folds itself; deadcat reaches it by hand-rolling the three tagged hashes and -// tweaking the NUMS key directly. This example runs the manifest's own utxo_type wiring -// (compile_params + the `tapdata` extra leaf carrying the u64 state, big-endian) through -// the first path and the transcription of deadcat's code through the second, and asserts -// they agree — which is what pins the state-leaf encoding. Test values use repeated bytes -// so the `liquid.asset_id` byte-reversal is a no-op and the two sides are comparable. -use std::collections::HashMap; - -use lwk_wollet::elements::hashes::{sha256, Hash, HashEngine}; -use lwk_wollet::elements::secp256k1_zkp::{Scalar, Secp256k1, XOnlyPublicKey}; -use lwk_wollet::ElementsNetwork; -use tx_manifest_lib::context::ExecutionContext; -use tx_manifest_lib::covenant; -use tx_manifest_lib::manifest::Manifest; - -/// deadcat-sdk/src/taproot.rs::NUMS_KEY_BYTES — also the engine's covenant internal key. -const NUMS_KEY_BYTES: [u8; 32] = [ - 0x50, 0x92, 0x9b, 0x74, 0xc1, 0xa0, 0x49, 0x54, 0xb7, 0x8b, 0x4b, 0x60, 0x35, 0xe9, 0x7a, 0x5e, - 0x07, 0x8a, 0x5a, 0x0f, 0x28, 0xec, 0x96, 0xd5, 0x47, 0xbf, 0xee, 0x9a, 0xce, 0x80, 0x3a, 0xc0, -]; - -fn hex(b: &[u8]) -> String { - b.iter().map(|x| format!("{x:02x}")).collect() -} - -/// SHA256(SHA256(tag) || SHA256(tag) || data) — deadcat's `tagged_hash`. -fn tagged_hash(tag: &[u8], data: &[u8]) -> [u8; 32] { - let tag_hash = sha256::Hash::hash(tag); - let mut engine = sha256::Hash::engine(); - engine.input(tag_hash.as_ref()); - engine.input(tag_hash.as_ref()); - engine.input(data); - sha256::Hash::from_engine(engine).to_byte_array() -} - -/// deadcat's `covenant_script_pubkey`, transcribed: tapdata(state) branched with the -/// Simplicity leaf, tweaked onto NUMS. -fn deadcat_spk(tapleaf_hash: [u8; 32], state: u64) -> String { - let data_leaf = tagged_hash(b"TapData", &state.to_be_bytes()); - let (a, b) = if tapleaf_hash <= data_leaf { - (tapleaf_hash, data_leaf) - } else { - (data_leaf, tapleaf_hash) - }; - let mut branch_data = Vec::with_capacity(64); - branch_data.extend_from_slice(&a); - branch_data.extend_from_slice(&b); - let branch = tagged_hash(b"TapBranch/elements", &branch_data); - - let mut tweak_data = Vec::with_capacity(64); - tweak_data.extend_from_slice(&NUMS_KEY_BYTES); - tweak_data.extend_from_slice(&branch); - let tweak = tagged_hash(b"TapTweak/elements", &tweak_data); - - let secp = Secp256k1::new(); - let nums = XOnlyPublicKey::from_slice(&NUMS_KEY_BYTES).expect("NUMS key"); - let (tweaked, _parity) = nums - .add_tweak(&secp, &Scalar::from_be_bytes(tweak).expect("scalar")) - .expect("tweak"); - - format!("5120{}", hex(&tweaked.serialize())) -} - -fn main() { - let dir = std::path::Path::new("examples/deadcat"); - let raw = std::fs::read_to_string(dir.join("txmanifest.json")).expect("read manifest"); - let manifest = Manifest::from_json_str(&raw).expect("parse manifest"); - - // Same shape as deadcat-sdk's own ContractParams test fixture. - let fields: [(&str, &str, &str); 8] = [ - ("ORACLE_PUBLIC_KEY", &"aa".repeat(32), "pubkey"), - ("COLLATERAL_ASSET_ID", &"bb".repeat(32), "liquid.asset_id"), - ("YES_TOKEN_ASSET", &"01".repeat(32), "liquid.asset_id"), - ("NO_TOKEN_ASSET", &"02".repeat(32), "liquid.asset_id"), - ("YES_REISSUANCE_TOKEN", &"03".repeat(32), "liquid.asset_id"), - ("NO_REISSUANCE_TOKEN", &"04".repeat(32), "liquid.asset_id"), - ("COLLATERAL_PER_TOKEN", "100000", "u64"), - ("EXPIRY_TIME", "1000000", "u32"), - ]; - - let mut params: HashMap = HashMap::new(); - let mut hints: HashMap = HashMap::new(); - let mut ctx = ExecutionContext::new(); - for (name, value, ty) in &fields { - params.insert((*name).to_string(), (*value).to_string()); - hints.insert((*name).to_string(), (*ty).to_string()); - ctx.set_compile_param(*name, *value); - } - - let simf = dir.join("prediction_market.simf"); - // debug_symbols: false — matches deadcat's `template.instantiate(args, false)`. - let tapleaf = covenant::compute_tapleaf_hash(&simf, ¶ms, &hints, false).expect("tapleaf"); - - eprintln!("---- result ----"); - println!("tapleaf_hash = {}", hex(&tapleaf)); - - let states: [(&str, u64); 4] = [ - ("market_dormant", 0), - ("market_unresolved", 1), - ("market_resolved_yes", 2), - ("market_resolved_no", 3), - ]; - - let mut seen: Vec = Vec::new(); - for (type_name, state) in states { - let ut = manifest.utxo_type(type_name).expect("utxo_type"); - let leaves = ut.resolve_extra_leaf_payloads(&ctx).expect("extra leaves"); - assert_eq!( - leaves, - vec![state.to_be_bytes().to_vec()], - "{type_name}: the tapdata leaf must be the 8-byte big-endian state" - ); - - let addr = covenant::compute_covenant_address( - &simf, - ¶ms, - &hints, - &leaves, - ElementsNetwork::LiquidTestnet, - false, - ) - .expect("covenant address"); - let engine_spk = format!("{:x}", addr.script_pubkey()); - let sdk_spk = deadcat_spk(tapleaf, state); - - println!("state {state} ({type_name})"); - println!(" spk (engine) = {engine_spk}"); - println!(" spk (deadcat) = {sdk_spk}"); - println!(" address = {addr}"); - assert_eq!( - engine_spk, sdk_spk, - "state {state}: engine and deadcat-sdk disagree on the covenant scriptPubKey" - ); - assert!( - !seen.contains(&engine_spk), - "state {state} collides with an earlier state's address" - ); - seen.push(engine_spk); - } - - check_witness_literals(&simf, ¶ms); - - println!("\nOK: four distinct addresses, each matching deadcat-sdk's derivation."); -} - -/// Parse every witness literal the manifest writes against the compiled program's own -/// ABI types. `PATH` is a seven-leaf nested `Either` tree and the literals are written by -/// hand, so this is where a mis-nested `Left`/`Right` would otherwise go unnoticed until a -/// spend failed on chain. -fn check_witness_literals(simf: &std::path::Path, params: &HashMap) { - use simplicityhl::ast::ElementsJetHinter; - use simplicityhl::parse::ParseFromStr; - use simplicityhl::str::WitnessName; - use simplicityhl::value::Value; - use simplicityhl::{Arguments, CompiledProgram}; - - let asset_arg = |name: &str| { - format!( - r#""{name}": {{ "value": "0x{}", "type": "u256" }}"#, - params[name] - ) - }; - let args_json = format!( - "{{{}, {}, {}, {}, {}, {}, {}, {}}}", - asset_arg("ORACLE_PUBLIC_KEY"), - asset_arg("COLLATERAL_ASSET_ID"), - asset_arg("YES_TOKEN_ASSET"), - asset_arg("NO_TOKEN_ASSET"), - asset_arg("YES_REISSUANCE_TOKEN"), - asset_arg("NO_REISSUANCE_TOKEN"), - r#""COLLATERAL_PER_TOKEN": { "value": "100000", "type": "u64" }"#, - r#""EXPIRY_TIME": { "value": "1000000", "type": "u32" }"#, - ); - let arguments: Arguments = serde_json::from_str(&args_json).expect("arguments"); - let source = std::fs::read_to_string(simf).expect("read simf"); - let compiled = - CompiledProgram::new(source, arguments, false, Box::new(ElementsJetHinter::new())) - .expect("compile"); - let abi = compiled.generate_abi_meta().expect("abi"); - - eprintln!("---- witness ABI ----"); - for (name, ty) in abi.witness_types.iter() { - eprintln!(" {name}: {ty}"); - } - - // Every PATH literal the manifest uses, in path order. - let sig_literal = format!("0x{}", "ab".repeat(64)); - let burn_literal = format!("0x{}", "01".repeat(32)); - let literals: [(&str, &str, &str); 13] = [ - ("PATH", "Left(Left(Left(())))", "path 1 — initial issuance"), - ( - "PATH", - "Left(Left(Right(())))", - "path 2 — subsequent issuance", - ), - ("PATH", "Left(Right(Left(())))", "path 3 — oracle resolve"), - ( - "PATH", - "Left(Right(Right(())))", - "path 4 — post-resolution redemption", - ), - ( - "PATH", - "Right(Left(Left(())))", - "path 5 — expiry redemption", - ), - ("PATH", "Right(Left(Right(())))", "path 6 — cancellation"), - ( - "PATH", - "Right(Right(()))", - "path 7 — secondary covenant input", - ), - ("STATE", "0", "dormant"), - ("STATE", "3", "resolved-no"), - ("ORACLE_OUTCOME_YES", "true", "YES resolve"), - ("TOKENS_BURNED", "1234", "a redemption amount"), - ( - "ORACLE_SIGNATURE", - &sig_literal, - "a 0x-prefixed 64-byte oracle signature, as ResolveYes/ResolveNo substitute it", - ), - ( - "BURN_TOKEN_ASSET", - &burn_literal, - "a 0x-prefixed asset id in internal byte order, as RedeemExpired substitutes it", - ), - ]; - - let mut paths: Vec> = Vec::new(); - for (name, literal, note) in literals { - let wname = WitnessName::parse_from_str(name).expect("witness name"); - let ty = abi - .witness_types - .get(&wname) - .unwrap_or_else(|| panic!("{name} is not a witness of the compiled program")); - let value = Value::parse_from_str(literal, ty) - .unwrap_or_else(|e| panic!("{name} = {literal} ({note}) does not parse: {e}")); - if name == "PATH" { - let bits = format!("{value:?}").into_bytes(); - assert!( - !paths.contains(&bits), - "two PATH literals encode the same branch — {literal} ({note})" - ); - paths.push(bits); - } - } - println!("witness literals: all 7 PATH branches distinct and well-typed"); -} diff --git a/txmanifest_lib/examples/deadcat_v2_recon.rs b/txmanifest_lib/examples/deadcat_v2_recon.rs deleted file mode 100644 index 797fca8..0000000 --- a/txmanifest_lib/examples/deadcat_v2_recon.rs +++ /dev/null @@ -1,171 +0,0 @@ -// Check what the deadcat_v2 fork did and did not change. -// -// cargo run -p tx-manifest-lib --example deadcat_v2_recon -// -// `deadcat_recon` proves examples/deadcat reproduces upstream Deadcat's addresses -// byte-for-byte. This one is its complement: it proves examples/deadcat_v2 is a *real* -// fork — every covenant address moved and the blinding-factor witnesses are gone — while -// the parts the fork was not meant to touch still hold. -use std::collections::HashMap; - -use lwk_wollet::ElementsNetwork; -use tx_manifest_lib::context::ExecutionContext; -use tx_manifest_lib::covenant; -use tx_manifest_lib::manifest::Manifest; - -/// The eight witnesses the fork exists to remove. -const BLINDING_WITNESSES: [&str; 8] = [ - "YES_REISSUANCE_INPUT_ABF", - "YES_REISSUANCE_INPUT_VBF", - "YES_REISSUANCE_OUTPUT_ABF", - "YES_REISSUANCE_OUTPUT_VBF", - "NO_REISSUANCE_INPUT_ABF", - "NO_REISSUANCE_INPUT_VBF", - "NO_REISSUANCE_OUTPUT_ABF", - "NO_REISSUANCE_OUTPUT_VBF", -]; - -const STATES: [(&str, u64); 4] = [ - ("market_dormant", 0), - ("market_unresolved", 1), - ("market_resolved_yes", 2), - ("market_resolved_no", 3), -]; - -/// Same fixture as `deadcat_recon`: repeated bytes, so the `liquid.asset_id` reversal is a -/// no-op and the two examples are compared on equal terms. -fn fixture() -> ( - HashMap, - HashMap, - ExecutionContext, -) { - let fields: [(&str, String, &str); 8] = [ - ("ORACLE_PUBLIC_KEY", "aa".repeat(32), "pubkey"), - ("COLLATERAL_ASSET_ID", "bb".repeat(32), "liquid.asset_id"), - ("YES_TOKEN_ASSET", "01".repeat(32), "liquid.asset_id"), - ("NO_TOKEN_ASSET", "02".repeat(32), "liquid.asset_id"), - ("YES_REISSUANCE_TOKEN", "03".repeat(32), "liquid.asset_id"), - ("NO_REISSUANCE_TOKEN", "04".repeat(32), "liquid.asset_id"), - ("COLLATERAL_PER_TOKEN", "100000".to_string(), "u64"), - ("EXPIRY_TIME", "1000000".to_string(), "u32"), - ]; - let mut params = HashMap::new(); - let mut hints = HashMap::new(); - let mut ctx = ExecutionContext::new(); - for (name, value, ty) in &fields { - params.insert((*name).to_string(), value.clone()); - hints.insert((*name).to_string(), (*ty).to_string()); - ctx.set_compile_param(*name, value.clone()); - } - (params, hints, ctx) -} - -/// Derive the four per-state scriptPubKeys for one example directory, through that -/// example's own `utxo_type` wiring. -fn addresses(dir: &str) -> Vec { - let dir = std::path::Path::new(dir); - let raw = std::fs::read_to_string(dir.join("txmanifest.json")).expect("read manifest"); - let manifest = Manifest::from_json_str(&raw).expect("parse manifest"); - let (params, hints, ctx) = fixture(); - let simf = dir.join("prediction_market.simf"); - - STATES - .iter() - .map(|(type_name, _)| { - let ut = manifest.utxo_type(type_name).expect("utxo_type"); - let leaves = ut.resolve_extra_leaf_payloads(&ctx).expect("extra leaves"); - let addr = covenant::compute_covenant_address( - &simf, - ¶ms, - &hints, - &leaves, - ElementsNetwork::LiquidTestnet, - false, - ) - .expect("covenant address"); - format!("{:x}", addr.script_pubkey()) - }) - .collect() -} - -fn main() { - let v1 = addresses("examples/deadcat"); - let v2 = addresses("examples/deadcat_v2"); - - eprintln!("---- result ----"); - for ((type_name, state), (a, b)) in STATES.iter().zip(v1.iter().zip(v2.iter())) { - println!("state {state} ({type_name})"); - println!(" v1 = {a}"); - println!(" v2 = {b}"); - assert_ne!( - a, b, - "state {state}: v2 must NOT share an address with v1. If these ever match, the \ - fork stopped being a fork and tokens could be parked at an address whose \ - program is not the one this example compiles" - ); - } - assert_eq!( - v2.iter().collect::>().len(), - 4, - "v2's four states must still be four distinct addresses" - ); - - // The point of the fork: nothing blinding-related left in the witness surface. - let names = v2_witness_names(); - println!("\nv2 witnesses ({}): {}", names.len(), names.join(", ")); - for gone in BLINDING_WITNESSES { - assert!( - !names.iter().any(|n| n == gone), - "{gone} is still a witness — the commitment check was not fully removed" - ); - } - for kept in [ - "STATE", - "PATH", - "ORACLE_SIGNATURE", - "TOKENS_BURNED", - "PAIRS_BURNED", - ] { - assert!( - names.iter().any(|n| n == kept), - "{kept} must survive the fork" - ); - } - - println!("\nOK: v2 moved all four addresses and dropped all eight blinding witnesses."); -} - -fn v2_witness_names() -> Vec { - use simplicityhl::ast::ElementsJetHinter; - use simplicityhl::{Arguments, CompiledProgram}; - - let (params, _, _) = fixture(); - let asset_arg = |name: &str| { - format!( - r#""{name}": {{ "value": "0x{}", "type": "u256" }}"#, - params[name] - ) - }; - let args_json = format!( - "{{{}, {}, {}, {}, {}, {}, {}, {}}}", - asset_arg("ORACLE_PUBLIC_KEY"), - asset_arg("COLLATERAL_ASSET_ID"), - asset_arg("YES_TOKEN_ASSET"), - asset_arg("NO_TOKEN_ASSET"), - asset_arg("YES_REISSUANCE_TOKEN"), - asset_arg("NO_REISSUANCE_TOKEN"), - r#""COLLATERAL_PER_TOKEN": { "value": "100000", "type": "u64" }"#, - r#""EXPIRY_TIME": { "value": "1000000", "type": "u32" }"#, - ); - let arguments: Arguments = serde_json::from_str(&args_json).expect("arguments"); - let source = std::fs::read_to_string("examples/deadcat_v2/prediction_market.simf") - .expect("read v2 simf"); - let abi = CompiledProgram::new(source, arguments, false, Box::new(ElementsJetHinter::new())) - .expect("compile v2") - .generate_abi_meta() - .expect("abi"); - abi.witness_types - .iter() - .map(|(n, _)| n.to_string()) - .collect() -} diff --git a/txmanifest_lib/examples/deadcat_v3_recon.rs b/txmanifest_lib/examples/deadcat_v3_recon.rs deleted file mode 100644 index 0fad6a9..0000000 --- a/txmanifest_lib/examples/deadcat_v3_recon.rs +++ /dev/null @@ -1,183 +0,0 @@ -// Check what the deadcat_v3 fork did and did not change. -// -// cargo run -p tx-manifest-lib --example deadcat_v3_recon -// -// `deadcat_recon` proves examples/deadcat reproduces upstream Deadcat's addresses -// byte-for-byte. This is its complement for v3: every covenant address moved, the four -// OUTPUT blinding witnesses are gone (they are derived as abf+1 / vbf+1 now), and the four -// INPUT ones remain — those still have to prove the spent token is this market's, and pin -// its amount to 1. -use std::collections::HashMap; - -use lwk_wollet::ElementsNetwork; -use tx_manifest_lib::context::ExecutionContext; -use tx_manifest_lib::covenant; -use tx_manifest_lib::manifest::Manifest; - -/// Removed by the fork: the output factors are now derived, not witnessed. -const REMOVED_WITNESSES: [&str; 4] = [ - "YES_REISSUANCE_OUTPUT_ABF", - "YES_REISSUANCE_OUTPUT_VBF", - "NO_REISSUANCE_OUTPUT_ABF", - "NO_REISSUANCE_OUTPUT_VBF", -]; - -/// Kept by the fork. Dropping these would let anyone plant a blinded UTXO of their own -/// asset at a covenant address and cycle it through the market's paths. -const KEPT_WITNESSES: [&str; 6] = [ - "YES_REISSUANCE_INPUT_ABF", - "YES_REISSUANCE_INPUT_VBF", - "NO_REISSUANCE_INPUT_ABF", - "NO_REISSUANCE_INPUT_VBF", - "STATE", - "PATH", -]; - -const STATES: [(&str, u64); 4] = [ - ("market_dormant", 0), - ("market_unresolved", 1), - ("market_resolved_yes", 2), - ("market_resolved_no", 3), -]; - -/// Same fixture as `deadcat_recon`: repeated bytes, so the `liquid.asset_id` reversal is a -/// no-op and the two examples are compared on equal terms. -fn fixture() -> ( - HashMap, - HashMap, - ExecutionContext, -) { - let fields: [(&str, String, &str); 8] = [ - ("ORACLE_PUBLIC_KEY", "aa".repeat(32), "pubkey"), - ("COLLATERAL_ASSET_ID", "bb".repeat(32), "liquid.asset_id"), - ("YES_TOKEN_ASSET", "01".repeat(32), "liquid.asset_id"), - ("NO_TOKEN_ASSET", "02".repeat(32), "liquid.asset_id"), - ("YES_REISSUANCE_TOKEN", "03".repeat(32), "liquid.asset_id"), - ("NO_REISSUANCE_TOKEN", "04".repeat(32), "liquid.asset_id"), - ("COLLATERAL_PER_TOKEN", "100000".to_string(), "u64"), - ("EXPIRY_TIME", "1000000".to_string(), "u32"), - ]; - let mut params = HashMap::new(); - let mut hints = HashMap::new(); - let mut ctx = ExecutionContext::new(); - for (name, value, ty) in &fields { - params.insert((*name).to_string(), value.clone()); - hints.insert((*name).to_string(), (*ty).to_string()); - ctx.set_compile_param(*name, value.clone()); - } - (params, hints, ctx) -} - -/// Derive the four per-state scriptPubKeys for one example directory, through that -/// example's own `utxo_type` wiring. -fn addresses(dir: &str) -> Vec { - let dir = std::path::Path::new(dir); - let raw = std::fs::read_to_string(dir.join("txmanifest.json")).expect("read manifest"); - let manifest = Manifest::from_json_str(&raw).expect("parse manifest"); - let (params, hints, ctx) = fixture(); - let simf = dir.join("prediction_market.simf"); - - STATES - .iter() - .map(|(type_name, _)| { - let ut = manifest.utxo_type(type_name).expect("utxo_type"); - let leaves = ut.resolve_extra_leaf_payloads(&ctx).expect("extra leaves"); - let addr = covenant::compute_covenant_address( - &simf, - ¶ms, - &hints, - &leaves, - ElementsNetwork::LiquidTestnet, - false, - ) - .expect("covenant address"); - format!("{:x}", addr.script_pubkey()) - }) - .collect() -} - -fn main() { - let v1 = addresses("examples/deadcat"); - let v3 = addresses("examples/deadcat_v3"); - - eprintln!("---- result ----"); - for ((type_name, state), (a, b)) in STATES.iter().zip(v1.iter().zip(v3.iter())) { - println!("state {state} ({type_name})"); - println!(" v1 = {a}"); - println!(" v3 = {b}"); - assert_ne!( - a, b, - "state {state}: v3 must NOT share an address with v1. If these ever match, the \ - fork stopped being a fork and tokens could be parked at an address whose \ - program is not the one this example compiles" - ); - } - assert_eq!( - v3.iter().collect::>().len(), - 4, - "v3's four states must still be four distinct addresses" - ); - - // The point of the fork: the OUTPUT factors are derived, so they stop being witnesses, - // while the INPUT ones stay — they are what binds the spent token to this market. - let names = v3_witness_names(); - println!("\nv3 witnesses ({}): {}", names.len(), names.join(", ")); - for gone in REMOVED_WITNESSES { - assert!( - !names.iter().any(|n| n == gone), - "{gone} is still a witness — the output check was not converted to the shifted form" - ); - } - for kept in KEPT_WITNESSES { - assert!( - names.iter().any(|n| n == kept), - "{kept} must survive the fork" - ); - } - for kept in ["ORACLE_SIGNATURE", "TOKENS_BURNED", "PAIRS_BURNED"] { - assert!( - names.iter().any(|n| n == kept), - "{kept} must survive the fork" - ); - } - - println!( - "\nOK: v3 moved all four addresses, dropped the 4 output blinding witnesses, \ - and kept the 4 input ones." - ); -} - -fn v3_witness_names() -> Vec { - use simplicityhl::ast::ElementsJetHinter; - use simplicityhl::{Arguments, CompiledProgram}; - - let (params, _, _) = fixture(); - let asset_arg = |name: &str| { - format!( - r#""{name}": {{ "value": "0x{}", "type": "u256" }}"#, - params[name] - ) - }; - let args_json = format!( - "{{{}, {}, {}, {}, {}, {}, {}, {}}}", - asset_arg("ORACLE_PUBLIC_KEY"), - asset_arg("COLLATERAL_ASSET_ID"), - asset_arg("YES_TOKEN_ASSET"), - asset_arg("NO_TOKEN_ASSET"), - asset_arg("YES_REISSUANCE_TOKEN"), - asset_arg("NO_REISSUANCE_TOKEN"), - r#""COLLATERAL_PER_TOKEN": { "value": "100000", "type": "u64" }"#, - r#""EXPIRY_TIME": { "value": "1000000", "type": "u32" }"#, - ); - let arguments: Arguments = serde_json::from_str(&args_json).expect("arguments"); - let source = std::fs::read_to_string("examples/deadcat_v3/prediction_market.simf") - .expect("read v3 simf"); - let abi = CompiledProgram::new(source, arguments, false, Box::new(ElementsJetHinter::new())) - .expect("compile v3") - .generate_abi_meta() - .expect("abi"); - abi.witness_types - .iter() - .map(|(n, _)| n.to_string()) - .collect() -} diff --git a/txmanifest_lib/examples/scoring_recon.rs b/txmanifest_lib/examples/scoring_recon.rs deleted file mode 100644 index 4463b90..0000000 --- a/txmanifest_lib/examples/scoring_recon.rs +++ /dev/null @@ -1,295 +0,0 @@ -// Fantasy Dota — Unit 2 (scoring) table-driven reconciliation. -// -// cargo run -p tx-manifest-lib --example scoring_recon -// -// Unit 2's claim is that a weighted linear fantasy score can be computed inside a -// spending condition. This example checks that claim the only way that means anything: -// it EXECUTES the compiled covenant on a table of stat vectors and compares the result -// against an independent reference implementation in Rust. -// -// Every vector is run TWICE — once with the reference answer as EXPECTED (must satisfy) -// and once with a deliberately wrong answer (must fail). A covenant that accepted both -// would be computing nothing at all, and only the negative half catches that. -// -// scoring.simf touches no transaction introspection — it is pure arithmetic — so a dummy -// Elements environment is a faithful place to run it. Nothing here needs a chain. -// -// The weight table is read from the unit's own params.json, the same file the manifest -// wires into the covenant. That is deliberate: if the reference implementation carried -// its own copy, the two could drift and every vector would still pass. -use std::collections::HashMap; - -use simplicityhl::ast::ElementsJetHinter; -use simplicityhl::parse::ParseFromStr as _; -use simplicityhl::simplicity::BitMachine; -use simplicityhl::str::WitnessName; -use simplicityhl::value::Value; -use simplicityhl::{dummy_env, Arguments, CompiledProgram, WitnessValues}; - -const UNIT_DIR: &str = "../fantasy-dota/units/02-scoring"; - -/// A role's weight set, scale 10^4. Field order matches `Weights` in scoring.simf. -#[derive(Clone, Copy, Debug)] -struct Weights { - kills: u64, - deaths: u64, - assists: u64, - last_hits: u64, - gpm: u64, -} - -/// A stat vector. Field order matches `Facts` in scoring.simf. -#[derive(Clone, Copy, Debug)] -struct Facts { - kills: u32, - deaths: u32, - assists: u32, - last_hits: u32, - gpm: u32, -} - -impl Facts { - /// The SimplicityHL literal for this vector, as the covenant's `(u32, u32, u32, u32, - /// u32)` witness. - fn literal(&self) -> String { - format!( - "({}, {}, {}, {}, {})", - self.kills, self.deaths, self.assists, self.last_hits, self.gpm - ) - } -} - -const CORE: u32 = 0; -const MID: u32 = 1; -const SUPPORT: u32 = 2; - -fn role_name(trait_id: u32) -> &'static str { - match trait_id { - CORE => "CORE", - MID => "MID", - SUPPORT => "SUPPORT", - _ => "INVALID", - } -} - -/// THE REFERENCE IMPLEMENTATION. -/// -/// Deliberately written straight, with no shared code with the covenant: the covenant -/// folds its terms pairwise through overflow-checked helpers, this sums them left to -/// right. If both are correct they agree on every vector; if the covenant's associativity -/// or its clamp were wrong, they would not. -fn trait_fn(w: &Weights, f: &Facts) -> u64 { - let term = |weight: u64, fact: u32| { - weight - .checked_mul(u64::from(fact)) - .expect("reference overflowed — the vector is outside the documented bound") - }; - let earned = term(w.kills, f.kills) - + term(w.assists, f.assists) - + term(w.last_hits, f.last_hits) - + term(w.gpm, f.gpm); - let lost = term(w.deaths, f.deaths); - - // Floored at zero, matching clamped_subtract. PROVISIONAL alongside F1 — but a - // wrapping subtraction is not an option under any weight set, since the award becomes - // a quantity of fpoints to mint. - earned.saturating_sub(lost) -} - -/// Load the weight table from the unit's params.json — the same file the manifest wires -/// into the covenant, so there is exactly one copy of these numbers in the project. -fn load_weights() -> (HashMap, [Weights; 3]) { - let path = format!("{UNIT_DIR}/params.json"); - let raw = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {path}: {e}")); - let table: HashMap = - serde_json::from_str(&raw).unwrap_or_else(|e| panic!("parse {path}: {e}")); - - let get = |name: &str| -> u64 { - table - .get(name) - .unwrap_or_else(|| panic!("{path} is missing {name}")) - .parse() - .unwrap_or_else(|e| panic!("{name} is not a u64: {e}")) - }; - let role = |prefix: &str| Weights { - kills: get(&format!("W_{prefix}_KILLS")), - deaths: get(&format!("W_{prefix}_DEATHS")), - assists: get(&format!("W_{prefix}_ASSISTS")), - last_hits: get(&format!("W_{prefix}_LAST_HITS")), - gpm: get(&format!("W_{prefix}_GPM")), - }; - - (table.clone(), [role("CORE"), role("MID"), role("SUPPORT")]) -} - -/// Compile scoring.simf with the weight table baked in as compile params. -fn compile(table: &HashMap) -> CompiledProgram { - let args_json = { - let mut entries: Vec = table - .iter() - .map(|(name, value)| format!(r#""{name}": {{ "value": "{value}", "type": "u64" }}"#)) - .collect(); - entries.sort(); - format!("{{{}}}", entries.join(", ")) - }; - let arguments: Arguments = serde_json::from_str(&args_json).expect("arguments"); - let source = std::fs::read_to_string(format!("{UNIT_DIR}/scoring.simf")).expect("read simf"); - CompiledProgram::new(source, arguments, false, Box::new(ElementsJetHinter::new())) - .expect("compile scoring.simf") -} - -/// Run the covenant with one witness triple. `Ok(())` means the spend would be valid. -fn run( - program: &CompiledProgram, - facts: &Facts, - trait_id: u32, - expected: u64, -) -> Result<(), String> { - let types = program.witness_types(); - let mut map = HashMap::new(); - for (name, literal) in [ - ("FACTS", facts.literal()), - ("TRAIT_ID", trait_id.to_string()), - ("EXPECTED", expected.to_string()), - ] { - let witness_name = WitnessName::parse_from_str(name).expect("witness name"); - let ty = types - .get(&witness_name) - .unwrap_or_else(|| panic!("scoring.simf declares no witness {name}")); - let value = Value::parse_from_str(&literal, ty) - .unwrap_or_else(|e| panic!("cannot parse {name} = {literal}: {e}")); - map.insert(witness_name, value); - } - - let satisfied = program - .satisfy(WitnessValues::from(map)) - .map_err(|e| format!("satisfy: {e}"))?; - let redeem = satisfied.redeem(); - let env = dummy_env::dummy(); - let mut mac = BitMachine::for_program(redeem).map_err(|e| format!("bit machine: {e}"))?; - mac.exec(redeem, &env) - .map(|_| ()) - .map_err(|e| format!("exec: {e}")) -} - -fn main() { - let (table, weights) = load_weights(); - let program = compile(&table); - - // The vector table. Every case the M2 gate names, plus per-role coverage. - // - // MAX is the overflow probe: u32::MAX in every field is far outside anything Dota - // produces, but FACTS is a witness and therefore attacker-chosen, so the documented - // bound has to hold across the whole u32 range — not across plausible stat lines. - let zero = Facts { - kills: 0, - deaths: 0, - assists: 0, - last_hits: 0, - gpm: 0, - }; - let typical = Facts { - kills: 8, - deaths: 3, - assists: 14, - last_hits: 260, - gpm: 545, - }; - let max = Facts { - kills: u32::MAX, - deaths: u32::MAX, - assists: u32::MAX, - last_hits: u32::MAX, - gpm: u32::MAX, - }; - // Deaths dominate: the raw score goes negative and must floor at zero rather than - // wrapping to ~1.8e19 fpoints. - let death_heavy = Facts { - kills: 0, - deaths: 12, - assists: 1, - last_hits: 4, - gpm: 90, - }; - - let vectors: [(&str, Facts, u32); 8] = [ - ("zero", zero, CORE), - ("typical", typical, CORE), - ("typical", typical, MID), - ("typical", typical, SUPPORT), - ("max (overflow probe)", max, CORE), - ("death-heavy (clamp)", death_heavy, CORE), - ("death-heavy (clamp)", death_heavy, MID), - ("death-heavy (clamp)", death_heavy, SUPPORT), - ]; - - eprintln!("---- result ----"); - println!( - "{:<22} {:<8} {:>22} {:>8} {:>8}", - "vector", "role", "award (scale 1e4)", "satisfy", "reject" - ); - - let mut clamp_seen = false; - for (label, facts, trait_id) in vectors { - let expected = trait_fn(&weights[trait_id as usize], &facts); - if expected == 0 && facts.deaths > 0 { - clamp_seen = true; - } - - // Positive half: the reference answer must satisfy the covenant. - run(&program, &facts, trait_id, expected).unwrap_or_else(|e| { - panic!( - "{label} / {}: the covenant REJECTED the reference award {expected}. \ - The on-chain arithmetic and the reference implementation disagree, which \ - is the one thing Unit 2 exists to rule out.\n {e}", - role_name(trait_id) - ) - }); - - // Negative half: a wrong answer must NOT satisfy it. Off by one, because an - // off-by-one is the smallest error a real scoring bug produces and the hardest - // for a sloppy check to catch. - let wrong = expected.wrapping_add(1); - if run(&program, &facts, trait_id, wrong).is_ok() { - panic!( - "{label} / {}: the covenant ACCEPTED a wrong award ({wrong} instead of \ - {expected}). It is not checking the computation.", - role_name(trait_id) - ); - } - - println!( - "{label:<22} {:<8} {expected:>22} {:>8} {:>8}", - role_name(trait_id), - "ok", - "ok" - ); - } - - // An unknown role must be rejected outright rather than silently scoring as SUPPORT. - // TRAIT_ID is a witness, so this is an attacker-reachable input. - let rogue_trait = 3u32; - let as_support = trait_fn(&weights[SUPPORT as usize], &typical); - if run(&program, &typical, rogue_trait, as_support).is_ok() { - panic!( - "TRAIT_ID = {rogue_trait} was accepted and scored as SUPPORT. An unknown role \ - must fail the le_32 bound, or every id above 2 becomes a free extra role." - ); - } - println!( - "{:<22} {:<8} {:>22} {:>8} {:>8}", - "unknown role", "3", "-", "n/a", "ok" - ); - - assert!( - clamp_seen, - "no vector actually exercised the zero clamp — the death-heavy case is not \ - death-heavy enough, so clamped_subtract is untested" - ); - - println!( - "\nOK: {} vectors, each satisfying with the reference award and rejecting an \ - off-by-one, plus the clamp and the unknown-role bound.", - vectors.len() - ); -} diff --git a/txmanifest_lib/src/covenant.rs b/txmanifest_lib/src/covenant.rs index 392ddc2..3665576 100644 --- a/txmanifest_lib/src/covenant.rs +++ b/txmanifest_lib/src/covenant.rs @@ -2263,7 +2263,7 @@ mod tests { fn tapleaf_matches_compiled_cmr() { // Use script_auth.simf — single SCRIPT_HASH (u256) param, no witnesses needed. let crate_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); - let simf_path = crate_dir.join("../examples/lending/script_auth.simf"); + let simf_path = crate_dir.join("../examples/lending_v2/script_auth.simf"); let mut params = HashMap::new(); params.insert( @@ -2331,7 +2331,7 @@ mod tests { #[test] fn pre_lock_script_hash_invariant_to_extra_params() { let crate_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); - let simf_path = crate_dir.join("../examples/lending/pre_lock.simf"); + let simf_path = crate_dir.join("../examples/lending_v2/pre_lock.simf"); let network = lwk_wollet::ElementsNetwork::LiquidTestnet; // Helper to build the 15 explicit params + hints (matching the manifest.json @@ -2558,7 +2558,7 @@ mod tests { #[test] fn pre_lock_script_hash_matches_instance() { let crate_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); - let simf_path = crate_dir.join("../examples/lending/pre_lock.simf"); + let simf_path = crate_dir.join("../examples/lending_v2/pre_lock.simf"); let network = lwk_wollet::ElementsNetwork::LiquidTestnet; let mut params: HashMap = HashMap::new(); diff --git a/txmanifest_lib/src/lifecycle.rs b/txmanifest_lib/src/lifecycle.rs index d775a29..1759b99 100644 --- a/txmanifest_lib/src/lifecycle.rs +++ b/txmanifest_lib/src/lifecycle.rs @@ -5641,7 +5641,7 @@ mod tests { } /// `$inputs..` reads an instance field straight off a resolved input, with - /// no hook in between (examples/deadcat's constructor). + /// no hook in between (as in a Deadcat-style market constructor). /// /// The assertion that matters is `issued_asset` != `asset`. On an input carrying a new /// issuance those are different things — the spent UTXO is L-BTC, the created asset is @@ -5887,7 +5887,7 @@ mod tests { .as_ref() .expect("CreateOffer has create_instance"); - // Live offer 43ab4efe parameters (same as examples/lending_recon.rs). + // Live offer 43ab4efe parameters (same as tests/interop/lending_collateral.rs). let collateral = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; let principal = "38fca2d939696061a8f76d4e6b5eecd54e3b4221c846f24a6b279e79952850a5"; let borrower_nft = "78d61185c79f855fac51a87c191b00266f02d28752f50b3d9092ccf6b978181e"; @@ -6060,7 +6060,7 @@ mod tests { .expect("out[3] lender_nft_script_auth covenant address compiles"); // out[4]: the wired OP_RETURN output reproduces the on-chain 50-byte lending metadata - // (same offer params as examples/opreturn_recon.rs → identical payload). + // (same offer params as tests/interop/lending_opreturn.rs → identical payload). let op_out = action .outputs .as_ref() @@ -6115,7 +6115,7 @@ mod tests { .collect(); // out[0]: active lending covenant (storage slot0 = is_active=1) — the storage-transition - // address from examples/lending_active_recon.rs. + // address from tests/interop/lending_active.rs. let act_ut = manifest .utxo_type("lending_collateral_active") .expect("active utxo_type"); diff --git a/txmanifest_lib/tests/examples_parse.rs b/txmanifest_lib/tests/examples_parse.rs index 20702a1..05c69f7 100644 --- a/txmanifest_lib/tests/examples_parse.rs +++ b/txmanifest_lib/tests/examples_parse.rs @@ -1,4 +1,4 @@ -//! Every manifest under `examples/` must deserialize into [`Manifest`]. +//! Every manifest under `examples/` must deserialize into [`Manifest`] and pass `validate`. //! //! This is the guard rail for schema work: it fails the moment an example uses a //! key the Rust model does not know about (once `deny_unknown_fields` is on), or @@ -8,6 +8,7 @@ use std::path::{Path, PathBuf}; use tx_manifest_lib::manifest::Manifest; +use tx_manifest_lib::validate; /// Absolute path to the workspace-level `examples/` directory. fn examples_dir() -> PathBuf { @@ -52,3 +53,32 @@ fn every_example_manifest_parses() { failures.join("\n") ); } + +/// Every example must also pass `validate` cleanly. Examples are what a new user copies +/// first; one that fails validation belongs in `tests/fixtures/`, not here. +#[test] +fn every_example_manifest_validates() { + let mut failures = Vec::new(); + for path in &example_manifests() { + let raw = std::fs::read_to_string(path).expect("read example manifest"); + let manifest = Manifest::from_json_str(&raw).expect("parsed by the test above"); + let report = validate::validate(&manifest); + if !report.is_ok() || report.warnings() > 0 { + let name = path.parent().and_then(Path::file_name).unwrap_or_default(); + for issue in &report.issues { + failures.push(format!( + " {}: {} — {}", + name.to_string_lossy(), + issue.location, + issue.message + )); + } + } + } + + assert!( + failures.is_empty(), + "example manifests have validation issues:\n{}", + failures.join("\n") + ); +} diff --git a/txmanifest_lib/examples/factory_recon.rs b/txmanifest_lib/tests/interop/issuance_factory.rs similarity index 64% rename from txmanifest_lib/examples/factory_recon.rs rename to txmanifest_lib/tests/interop/issuance_factory.rs index 9048f12..010e4ea 100644 --- a/txmanifest_lib/examples/factory_recon.rs +++ b/txmanifest_lib/tests/interop/issuance_factory.rs @@ -3,8 +3,12 @@ use lwk_wollet::ElementsNetwork; use std::collections::HashMap; use tx_manifest_lib::covenant; -fn main() { - let d = std::path::Path::new("examples/lending_v3"); +#[test] +fn reproduces_onchain_issuance_factory_covenant() { + let d = std::path::Path::new(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../examples/lending_v3" + )); let mut p = HashMap::new(); let mut h = HashMap::new(); p.insert("ISSUING_UTXOS_COUNT".to_string(), "2".to_string()); @@ -20,7 +24,9 @@ fn main() { true, ) .unwrap(); - eprintln!("---- result ----"); - println!("factory out[1] spk (repro) = {:x}", addr.script_pubkey()); - println!("factory out[1] spk (chain) = 5120456881785cc7d561caaa059e02f1a2823066bd860423996bea3e92c621bb064b"); + assert_eq!( + format!("{:x}", addr.script_pubkey()), + "5120456881785cc7d561caaa059e02f1a2823066bd860423996bea3e92c621bb064b", + "factory out[1] spk must match the on-chain one" + ); } diff --git a/txmanifest_lib/examples/factory_opreturn_recon.rs b/txmanifest_lib/tests/interop/issuance_factory_opreturn.rs similarity index 93% rename from txmanifest_lib/examples/factory_opreturn_recon.rs rename to txmanifest_lib/tests/interop/issuance_factory_opreturn.rs index 08606c1..7e45d7c 100644 --- a/txmanifest_lib/examples/factory_opreturn_recon.rs +++ b/txmanifest_lib/tests/interop/issuance_factory_opreturn.rs @@ -17,8 +17,12 @@ fn program_id(simf_path: &std::path::Path) -> String { h[..4].iter().map(|b| format!("{b:02x}")).collect() } -fn main() { - let d = std::path::Path::new("examples/lending_v3"); +#[test] +fn reproduces_factory_creation_opreturn() { + let d = std::path::Path::new(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../examples/lending_v3" + )); let factory_program_id = program_id(&d.join("issuance_factory.simf")); assert_eq!(factory_program_id, "dd1e7f89", "FACTORY_PROGRAM_ID constant in the manifest must equal sha256(issuance_factory.simf source)[..4]"); diff --git a/txmanifest_lib/examples/lending_active_recon.rs b/txmanifest_lib/tests/interop/lending_active.rs similarity index 96% rename from txmanifest_lib/examples/lending_active_recon.rs rename to txmanifest_lib/tests/interop/lending_active.rs index 1933bf2..035eb46 100644 --- a/txmanifest_lib/examples/lending_active_recon.rs +++ b/txmanifest_lib/tests/interop/lending_active.rs @@ -20,14 +20,18 @@ fn hx(b: &[u8]) -> String { b.iter().map(|x| format!("{x:02x}")).collect() } -fn main() { +#[test] +fn activation_flips_the_lending_covenant_address() { let net = ElementsNetwork::LiquidTestnet; - let d = std::path::Path::new("examples/lending_v3"); + let d = std::path::Path::new(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../examples/lending_v3" + )); let sh = |simf: &str, p: &HashMap, h: &HashMap| -> String { hx(&covenant::compute_covenant_script_hash(&d.join(simf), p, h, net, true).unwrap()) }; - // Live offer 43ab4efe params (same as lending_recon.rs). + // Live offer 43ab4efe params (same as lending_collateral.rs). let collateral = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; let principal = "38fca2d939696061a8f76d4e6b5eecd54e3b4221c846f24a6b279e79952850a5"; let borrower_nft = "78d61185c79f855fac51a87c191b00266f02d28752f50b3d9092ccf6b978181e"; diff --git a/txmanifest_lib/examples/lending_recon.rs b/txmanifest_lib/tests/interop/lending_collateral.rs similarity index 95% rename from txmanifest_lib/examples/lending_recon.rs rename to txmanifest_lib/tests/interop/lending_collateral.rs index 1dfc901..93cf299 100644 --- a/txmanifest_lib/examples/lending_recon.rs +++ b/txmanifest_lib/tests/interop/lending_collateral.rs @@ -20,9 +20,13 @@ fn hx(b: &[u8]) -> String { b.iter().map(|x| format!("{x:02x}")).collect() } -fn main() { +#[test] +fn reproduces_onchain_lending_collateral_covenant() { let net = ElementsNetwork::LiquidTestnet; - let d = std::path::Path::new("examples/lending_v3"); + let d = std::path::Path::new(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../examples/lending_v3" + )); let sh = |simf: &str, p: &HashMap, h: &HashMap| -> String { hx(&covenant::compute_covenant_script_hash(&d.join(simf), p, h, net, true).unwrap()) }; @@ -204,4 +208,9 @@ fn main() { println!("principal_out = {principal_out}"); println!("lending out[5] spk (repro) = {:x}", addr.script_pubkey()); println!("lending out[5] spk (chain) = {out5}"); + assert_eq!( + format!("{:x}", addr.script_pubkey()), + out5, + "lending out[5] spk must match the on-chain one" + ); } diff --git a/txmanifest_lib/examples/opreturn_recon.rs b/txmanifest_lib/tests/interop/lending_opreturn.rs similarity index 92% rename from txmanifest_lib/examples/opreturn_recon.rs rename to txmanifest_lib/tests/interop/lending_opreturn.rs index ca681ab..cb45d37 100644 --- a/txmanifest_lib/examples/opreturn_recon.rs +++ b/txmanifest_lib/tests/interop/lending_opreturn.rs @@ -15,8 +15,12 @@ fn program_id(simf_path: &std::path::Path) -> String { h[..4].iter().map(|b| format!("{b:02x}")).collect() } -fn main() { - let d = std::path::Path::new("examples/lending_v3"); +#[test] +fn reproduces_offer_creation_opreturn() { + let d = std::path::Path::new(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../examples/lending_v3" + )); let lending_program_id = program_id(&d.join("lending.simf")); assert_eq!( lending_program_id, "f80c6162", diff --git a/txmanifest_lib/tests/interop/main.rs b/txmanifest_lib/tests/interop/main.rs new file mode 100644 index 0000000..9844124 --- /dev/null +++ b/txmanifest_lib/tests/interop/main.rs @@ -0,0 +1,17 @@ +//! Cross-toolchain parity checks: each module rebuilds covenant addresses, OP_RETURN +//! payloads or witness sets with this engine and compares them to what another +//! implementation (simplicity-lending, its indexer, a longhand taproot derivation) or the +//! chain produced. +//! +//! These pin the engine against outside ground truth, so a SimplicityHL bump or an +//! encoding change that moves a live address fails here rather than on chain. +//! +//! cargo test -p tx-manifest-lib --test interop + +mod issuance_factory; +mod issuance_factory_opreturn; +mod lending_active; +mod lending_collateral; +mod lending_opreturn; +mod pre_lock; +mod tapdata_leaf; diff --git a/txmanifest_lib/examples/prelock_recon.rs b/txmanifest_lib/tests/interop/pre_lock.rs similarity index 92% rename from txmanifest_lib/examples/prelock_recon.rs rename to txmanifest_lib/tests/interop/pre_lock.rs index d4e43fd..7b8f7e9 100644 --- a/txmanifest_lib/examples/prelock_recon.rs +++ b/txmanifest_lib/tests/interop/pre_lock.rs @@ -21,9 +21,13 @@ fn hexs(b: &[u8]) -> String { b.iter().map(|x| format!("{x:02x}")).collect() } -fn main() { +#[test] +fn reproduces_indexer_pre_lock_covenant() { let net = ElementsNetwork::LiquidTestnet; - let dir = std::path::Path::new("examples/lending_v2"); + let dir = std::path::Path::new(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../examples/lending_v2" + )); // Offer params decoded from the on-chain tx (adf5353d...). let collateral_asset = "144c654344aa716d6f3abcc1ca90e5641e4e2a7f633bc09fe3baf64585819a49"; @@ -202,5 +206,10 @@ fn main() { "pre_lock spk (this wallet, debug=true) = {:x}", addr.script_pubkey() ); - println!("indexer reconstruction (simplicity-lending) = 512050... (old) / f2b6fe... (correct)"); + // What the simplicity-lending indexer reconstructs for this offer. + assert_eq!( + format!("{:x}", addr.script_pubkey()), + "5120f2b6fe3cb489b5ee0ce8d6fb0311271939f3b5d5caf3ee0ac8297fc6145625d4", + "pre_lock spk must match the indexer's reconstruction" + ); } diff --git a/txmanifest_lib/tests/interop/tapdata_leaf.rs b/txmanifest_lib/tests/interop/tapdata_leaf.rs new file mode 100644 index 0000000..8908cc0 --- /dev/null +++ b/txmanifest_lib/tests/interop/tapdata_leaf.rs @@ -0,0 +1,137 @@ +// Pin the `tapdata` extra-leaf encoding against an independent derivation. +// +// A covenant can carry its state in a second taproot leaf: `TapData` over the state's +// bytes, branched with the Simplicity leaf, tweaked onto the NUMS key. Other Simplicity +// toolchains build these by hand-rolling the tagged hashes, so if this engine's encoding +// drifts, every stateful covenant address it computes stops matching theirs. +// +// The engine gets there through `TaprootSpendInfo` over the merkle root it folds itself; +// `expected_spk` below writes the same three tagged hashes out longhand. They must agree. +use std::collections::HashMap; + +use lwk_wollet::elements::hashes::{sha256, Hash, HashEngine}; +use lwk_wollet::elements::secp256k1_zkp::{Scalar, Secp256k1, XOnlyPublicKey}; +use lwk_wollet::ElementsNetwork; +use tx_manifest_lib::context::ExecutionContext; +use tx_manifest_lib::covenant; +use tx_manifest_lib::manifest::Manifest; + +/// The engine's covenant internal key (BIP-341 NUMS point). +const NUMS_KEY_BYTES: [u8; 32] = [ + 0x50, 0x92, 0x9b, 0x74, 0xc1, 0xa0, 0x49, 0x54, 0xb7, 0x8b, 0x4b, 0x60, 0x35, 0xe9, 0x7a, 0x5e, + 0x07, 0x8a, 0x5a, 0x0f, 0x28, 0xec, 0x96, 0xd5, 0x47, 0xbf, 0xee, 0x9a, 0xce, 0x80, 0x3a, 0xc0, +]; + +const SIMF: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../examples/p2pk/p2pk.simf"); + +/// SHA256(SHA256(tag) || SHA256(tag) || data). +fn tagged_hash(tag: &[u8], data: &[u8]) -> [u8; 32] { + let tag_hash = sha256::Hash::hash(tag); + let mut engine = sha256::Hash::engine(); + engine.input(tag_hash.as_ref()); + engine.input(tag_hash.as_ref()); + engine.input(data); + sha256::Hash::from_engine(engine).to_byte_array() +} + +/// tapdata(state) branched with the Simplicity leaf, tweaked onto NUMS. +fn expected_spk(tapleaf_hash: [u8; 32], state: u64) -> String { + let data_leaf = tagged_hash(b"TapData", &state.to_be_bytes()); + let (a, b) = if tapleaf_hash <= data_leaf { + (tapleaf_hash, data_leaf) + } else { + (data_leaf, tapleaf_hash) + }; + let branch = tagged_hash(b"TapBranch/elements", &[a, b].concat()); + let tweak = tagged_hash(b"TapTweak/elements", &[NUMS_KEY_BYTES, branch].concat()); + + let secp = Secp256k1::new(); + let nums = XOnlyPublicKey::from_slice(&NUMS_KEY_BYTES).expect("NUMS key"); + let (tweaked, _parity) = nums + .add_tweak(&secp, &Scalar::from_be_bytes(tweak).expect("scalar")) + .expect("tweak"); + let key: String = tweaked + .serialize() + .iter() + .map(|x| format!("{x:02x}")) + .collect(); + format!("5120{key}") +} + +/// One `utxo_type` per state, differing only in the u64 its tapdata leaf carries. +fn manifest(states: &[u64]) -> Manifest { + let utxo_types: serde_json::Map = states + .iter() + .map(|state| { + let ut = serde_json::json!({ + "description": format!("state {state}"), + "script": { + "type": "simplicity", + "source": SIMF, + "compile_params": { "PUB_KEY": "PUB_KEY" }, + "extra_leaves": [{ + "type": "tapdata", + "payload": [{ "value": state.to_string(), "type": "u64", "endian": "be" }] + }] + }, + "asset": "lbtc" + }); + (format!("state_{state}"), ut) + }) + .collect(); + let raw = serde_json::json!({ + "manifest_version": tx_manifest_lib::manifest::FORMAT_VERSION, + "protocol": "tapdata-test", + "description": "One utxo_type per tapdata state.", + "chain": "liquid", + "requires": ["simplicity"], + "utxo_types": utxo_types, + "actions": {} + }); + Manifest::from_json_str(&raw.to_string()).expect("parse manifest") +} + +#[test] +fn tapdata_state_leaf_matches_a_longhand_derivation() { + let pub_key = "aa".repeat(32); + let params = HashMap::from([("PUB_KEY".to_string(), pub_key.clone())]); + let hints = HashMap::from([("PUB_KEY".to_string(), "pubkey".to_string())]); + let mut ctx = ExecutionContext::new(); + ctx.set_compile_param("PUB_KEY", &pub_key); + + let simf = std::path::Path::new(SIMF); + let tapleaf = covenant::compute_tapleaf_hash(simf, ¶ms, &hints, false).expect("tapleaf"); + + let states = [0, 1, 2, u64::MAX]; + let manifest = manifest(&states); + let mut seen = Vec::new(); + for state in states { + let ut = manifest + .utxo_type(&format!("state_{state}")) + .expect("utxo_type"); + let leaves = ut.resolve_extra_leaf_payloads(&ctx).expect("extra leaves"); + assert_eq!( + leaves, + vec![state.to_be_bytes().to_vec()], + "state {state}: the tapdata leaf must be the 8-byte big-endian state" + ); + + let addr = covenant::compute_covenant_address( + simf, + ¶ms, + &hints, + &leaves, + ElementsNetwork::LiquidTestnet, + false, + ) + .expect("covenant address"); + let spk = format!("{:x}", addr.script_pubkey()); + assert_eq!( + spk, + expected_spk(tapleaf, state), + "state {state}: engine and longhand derivation disagree" + ); + assert!(!seen.contains(&spk), "state {state} reuses an address"); + seen.push(spk); + } +} diff --git a/txmanifest_lib/tests/ui_coverage.rs b/txmanifest_lib/tests/ui_coverage.rs index 2455115..fa2be19 100644 --- a/txmanifest_lib/tests/ui_coverage.rs +++ b/txmanifest_lib/tests/ui_coverage.rs @@ -22,10 +22,6 @@ use tx_manifest_lib::manifest::{Action, Manifest}; /// list shrinks to nothing rather than rotting. const EXEMPT: &[(&str, &str)] = &[ ("last_will", "11 legs / 4 actions, no ui text authored yet"), - ( - "lending", - "90 legs / 10 actions; largely superseded by lending_v3", - ), ( "lending_v2", "90 legs / 9 actions; wire-compat variant of lending", diff --git a/txmanifest_wallet/Cargo.toml b/txmanifest_wallet/Cargo.toml index 83ed7dc..9c276e4 100644 --- a/txmanifest_wallet/Cargo.toml +++ b/txmanifest_wallet/Cargo.toml @@ -3,7 +3,7 @@ name = "tx-manifest-wallet" version = "0.3.0" edition = "2021" license = "MIT OR Apache-2.0" -description = "Wallet CLI for executing transaction manifests on Liquid/Elements with SimplicityHL covenants." +description = "Wallet CLI for executing transaction manifests on Liquid/Elements and Bitcoin with SimplicityHL covenants." repository = "https://github.com/stringhandler/txmanifest-wallet" [dependencies]