diff --git a/CHANGELOG.md b/CHANGELOG.md index 17314e9..5e03d06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,402 @@ released together. No changelog was kept before 0.2.0; for 0.1.x see the git history. +## [Unreleased] + +**Breaking:** `manifest_version` must now read `"0.3.0"`. The changes below make +a `0.2.0` file read wrongly rather than fail, so the version moves. Every +example was updated. + +**Breaking:** a manifest with covenant `utxo_types` must now declare +`"requires": ["simplicity"]`, or `validate` fails. + +**Breaking:** `chain` is now a closed vocabulary — `elements`, `bitcoin`, and +the aliases `liquid` and `btc`. It was a free-form string that nothing read; +`cross-chain` was previously accepted with a warning and is now refused. + +### Added + +- **`chain` module — the seam between this engine and the ledger it targets.** + Everything here was written against Elements, where a great deal is assumed: + outputs carry an asset id, amounts may be blinded, the fee is its own `TxOut`, + taproot tags are domain-separated with `/elements`, and a Simplicity tapleaf + will be executed. None of that holds on Bitcoin. The module sorts those + assumptions by who settles them: `ChainFamily` properties follow from the + ledger, `Capability` is what a manifest must state because the chain does not + settle it, and `Activation` is what the specific node provides. + +- **`requires`: the features a manifest needs that `chain` does not already + imply.** Deliberately one core capability, `simplicity` — because it is the + only one the chain does not answer. It is live on Elements and a proposed soft + fork on Bitcoin (BINANA 2026-0003, leaf version `0xbe`, not activated on any + public network), so whether a Bitcoin node honours it is a property of that + node. + + `validate` checks it both ways: a covenant manifest omitting `simplicity` is + an error, since `requires` is what a target gets checked against before a + build; declaring what nothing uses is a warning only, because the inference + reads field presence rather than semantics and must not block a run on its own + guess. + + The empty list is the point of the field, not a degenerate case. A manifest + that declares nothing needs nothing a stock node lacks — which is exactly the + manifest that can target Bitcoin today. + +- **Namespaced capabilities.** A bare name is defined by this format and comes + from a closed set, so a typo is an error rather than a silently-ignored + request. A name containing `::` (`custom::my-feature`, `mosaik::tessera`) + belongs to whoever owns the namespace: this crate parses it, round-trips it + verbatim, and judges it in neither direction — never inferred, never reported + unused. A target satisfies one only by naming it in `Activation::extensions`. + Core names normalize `_` to `-`; namespaced ones do not, since rewriting them + would make two spellings this crate treats as equal and their owner may not. + +- **`Manifest::chain_mismatches`** reports, per field, where a manifest uses + something its chain lacks — an issuance input or a blinded output on Bitcoin. + This replaced the `multi-asset` / `asset-issuance` / `confidential-amounts` + capabilities: the check was worth keeping, but making an author *declare* them + was not, because `chain: "bitcoin"` already says there are no native assets. A + restatement is something that can disagree with itself. The rule now reads the + chain directly, and reports the dot-path of each site rather than one verdict — + an author porting a protocol needs the list, not the answer. + + An `asset` naming the policy asset outright (`"lbtc"`) is single-asset + behaviour and stays clean on Bitcoin. That is how the portable examples here + are written, and counting it as multi-asset marked `p2pk` and `last_will` + unportable when they are the two that port most cleanly. + +- **A Bitcoin transaction built by this engine is confirmed on chain.** Against + the `contrib/regtest` node: a `chain: "bitcoin"`, `requires: []` manifest + resolved its input from a UTXO-set scan, built a PSBT, signed a key-path spend, + and the node accepted it — `testmempoolaccept` `allowed: true`, then one + confirmation with 150,000 sat at the declared destination, change to the + wallet, a single 64-byte witness item, and 308 sat over 154 vbytes: exactly the + 2 sat/vB asked for. + + Getting there surfaced four things no unit test could: + + - **The backend selection was never wired.** `BitcoinRun` built an + `EsploraClient` directly, so `bitcoin_backend: "rpc"` was read, validated and + ignored. Every Bitcoin run used Esplora regardless. + - **`default_network` could not be set to any Bitcoin network.** The CLI setter + checked against a two-item list written before Bitcoin support existed, so + regtest could only be configured by editing the file by hand. It now + validates by parsing. + - **`config` showed Elements fields for a Bitcoin config** — an Esplora URL a + Bitcoin run never consults, beside a network that cannot use it. It now shows + the backend the configured chain actually uses, with the RPC password + redacted. + - **Regtest's Esplora fallback pointed at a public signet.** It now points at + localhost, which fails to connect — the right failure. The old default would + have quietly answered questions about somebody else's chain, and did exactly + that until the run caught it. + +- **`TX_MANIFEST_DATA_DIR` relocates the wallet's on-disk state**, config + included. The config lived at a fixed global path that `config::load` resolved + with no argument, so pointing the wallet at a regtest meant overwriting the + config a user's real funds are reached through — which made the thing + untestable by anyone who also used it. + +- **`bitcoin_rpc` — chain access over a node's own JSON-RPC,** selectable with + `bitcoin_backend: "rpc"`. Esplora is right against a public network, where + somebody else runs the indexer; it is the wrong tool against a regtest you just + started, where it means an electrs and an API server to index four blocks. + `scantxoutset` finds our coins from descriptors with no wallet, no import and + no rescan, and `generatetoaddress` funds a regtest wallet without a faucet. + + Amounts are read from Bitcoin Core's decimal BTC with rust-bitcoin's + `amount::serde::as_btc`, which formats the parsed float back to its shortest + decimal and parses that exactly. A naive conversion is a satoshi off for + values like `0.1` BTC (just under 10,000,000, truncated to 9,999,999), and an + amount one satoshi off invalidates every signature committing to it. An + earlier hand-rolled parser fed by `serde_json::Number::to_string()` refused + anything under 1,000 sat — serde_json renders those as `5.46e-6` — so a single + dust-sized coin failed every scan. + + The two backends differ in what bounds a scan, which is worth knowing: Esplora + reads address *history* and finds coins beyond a gap of spent addresses, while + a UTXO-set scan has no history, so the gap limit becomes the hard edge of how + far it looks. + +- **`contrib/regtest` — a Simplicity-enabled regtest.** A container built from + `delta1/bitcoin@simplicity-inquisition`. `SIMPLICITY` is active from height 0, + alongside `OP_CAT`, `CHECKTEMPLATEVERIFY`, `CHECKSIGFROMSTACK` and + `ANYPREVOUT`. It appears as an enabled **script flag**, not as a BIP9 + deployment — so `deployment_active` checks `getdeploymentinfo`'s `script_flags` + as well as its `deployments`, since looking only at the latter reports an + active rule as inactive and would refuse a covenant run that would have worked. + +- **Fixed: a Bitcoin run could have sent money to the wrong place.** Manifest + addresses were parsed as `elements::Address` at both sites that read one, and + that parser rejects every Bitcoin address. Neither site treated the failure as + fatal: the output loop dropped the output and built the transaction *without + it* — the declared payment missing, its value falling into change — and + `from_address` degraded from "spend this specific coin" to "spend anything". + Both failed with only a warning in a long interactive log, behind a + transaction that then built and broadcast successfully. + + `assembly::parse_destination` now parses for the chain in play, and both call + sites treat a failure as fatal. Dropping a declared output or a declared + restriction is not a recoverable condition. Bitcoin addresses are also checked + against the run's network rather than merely parsed — a mainnet address parses + fine on a signet run. + + `select_input` now takes the run's `Network` and derives both the asset it + matches on and its address parser from it, so the two cannot drift apart again. + +- **Fixed: a Bitcoin run would have reported a funded wallet as empty.** + `select_input` resolved the manifest labels `"lbtc"` / `"bitcoin"` through an + `ElementsNetwork`, so on a Bitcoin run it looked for Liquid's policy asset + while the scan produced the synthetic Bitcoin one. The two never match, and the + failure mode was silent: no error, no wrong transaction, just "no wallet UTXOs + available" from a wallet holding funds. `select_input` now takes the run's + policy asset directly — the asset a run spends is a property of the run, not of + a network. + + Every module-level test passed throughout, because the disagreement was + *between* two modules that were each individually right. `tests/bitcoin_path.rs` + now exercises the seam rather than the parts: scanned UTXO → the shared + assembly's vocabulary → the narrowing → a built, signed, extractable + transaction, with the signature verified against the scriptPubKey being spent. + It also pins the specific mismatch, so it cannot come back. + +- **Bitcoin runs are dispatched from `lifecycle::run`.** A Bitcoin manifest now + loads a `BitcoinWallet`, scans Esplora for UTXOs, runs the shared assembly, and + then builds, signs and broadcasts a PSBT. Elements is untouched. + + The two paths diverge at the build, not before: Elements carries on to the + separate signing, covenant dry-run and finalize steps, because a PSET passes + through three stages that can each fail in a way worth reporting. Bitcoin does + all three at once — covenant execution needs the upstream jet FFI, so the only + transactions reachable there are plain payments, and splitting three mechanical + operations across three steps would invent places to stop. A covenant input on + a Bitcoin run is refused with that explanation rather than silently skipped. + + Bitcoin UTXOs are presented to input selection in LWK's `WalletTxOut` shape by + `assembly::bitcoin_spendable_utxos` — the input-side counterpart of the + synthetic policy asset, and safe for the same reason: the outpoint, value and + scriptPubKey are real, the asset and blinding factors are synthetic, and the + narrowing checks and drops the synthetic ones. Doing it this way keeps input + selection — several hundred lines of amount and asset matching — off the list + of things being rewritten, which matters because that code is in the path funds + move along. A test carries a UTXO through the assembly's vocabulary and back + out through the narrowing to confirm the real fields survive. + + `BitcoinRun` carries its own `Network` rather than deriving one from + `network_for_asset`, which is an `ElementsNetwork` computed from the wallet + file's mainnet flag and is meaningless on Bitcoin — taking it from there would + hand the covenant derivation the wrong chain. + +- **`assembly` — the seam between the shared lifecycle and the chain under it.** + `lifecycle::run`'s input/output assembly is ~800 lines of destination + resolution, covenant address derivation, amount evaluation and state metadata. + It turns out to need exactly **six** things from the chain: a covenant's + scriptPubKey, an asset label resolved to an id, a change address, the next + receive address, the policy asset, and whether outputs are confidential by + default. `AssemblyContext` is those six, with `ElementsContext` over LWK and + `BitcoinContext` over `BitcoinWallet`. + + One assembly path rather than two: a duplicate would agree on the day it was + written and drift by the next release, in ways only a funded transaction would + reveal. The lifecycle rewiring is a pure refactor — the existing tests are what + say so. + + The assembly still speaks the Elements vocabulary on both chains: outputs carry + an `AssetId` and an optional blinding key, and on Bitcoin the asset is a single + synthetic constant and the blinding key is always `None`. That is a deliberate + leak. The alternative — a neutral vocabulary both chains widen from — means + rewriting all 800 lines against it, which is the risk the seam exists to avoid. + The synthetic asset is not a fiction that has to hold together on its own: + `psbt_builder::from_pset_request` refuses any second asset or blinding key, so + the narrowing enforces the invariant rather than this module being trusted to + maintain it. + + `AddressInfo` carries both the script and its encoding, because the assembly + genuinely uses both — the script goes into the transaction, the encoding into + the line a user reads to check where their money went. Deriving one from the + other at the call site would mean the shared assembly picking an encoding, + which is exactly what it must not do. + +- **Esplora defaults follow the configured network** across both chains, rather + than choosing between two Liquid URLs on `is_mainnet`. An explicit + `default_esplora` still wins; pointing a Bitcoin wallet at a Liquid instance + would otherwise surface as confusing decode failures rather than an obvious + misconfiguration. + +- **`bitcoin_backend` — Esplora chain access for Bitcoin.** Esplora serves + Bitcoin and Liquid from the same REST shape, so only the base URL differs + (`/api` vs `/liquid/api`) — but `lwk_wollet`'s client decodes Elements + transactions, whose outputs carry asset ids and commitments no Bitcoin response + has. Built on `ureq`, which this crate already used to POST transactions. + + Response decoding is split from fetching, as free functions over `&str`: the + HTTP calls cannot be unit-tested, and the decoding is where the mistakes live — + an amount read as a float, a txid byte order flipped, a missing field defaulted + to zero. The fixtures include a response captured verbatim from + `blockstream.info/signet/api`, so the decoder is checked against what Esplora + actually sends rather than against a fixture written from the same assumptions + as the code. + + Scanning ends on a gap of unused addresses measured by transaction *history*, + not by the presence of UTXOs. The distinction is not hypothetical: the BIP86 + test mnemonic's first signet address has 153 transactions and an empty UTXO + set, and a scan keyed on UTXOs would call it unused and stop early. Regtest has + no default URL, so an operator configures one rather than being pointed at + somebody else's chain. + +- **`bitcoin_wallet` — BIP86 key derivation, addresses and signing** on + `rust-bitcoin` directly rather than on a wallet framework. What this engine + asks of a wallet is small: derive a key, produce an address, sign a hash, know + which UTXOs are ours. Covenant inputs are self-describing and the descriptor is + single-key, so a framework would mostly contribute a descriptor language, + persistence model and coin-selection policy that none of this uses. + + Addresses are single-key P2TR with no script tree, checked against the vectors + published in BIP86 rather than against this implementation's own output — a + wrong derivation still produces valid-looking addresses the wallet will hand + out and watch and then be unable to spend from, and nothing catches that except + an external reference. + + Key-path and covenant signing are separate methods because they are not + interchangeable: a key-path spend must be signed with the *tweaked* key the + output commits to, while a Simplicity program checks against the *untweaked* + key baked into it. Both directions are tested, including that each signature + fails to verify against the other key. `Debug` is written by hand and redacts + the root key, since `Xpriv`'s own `Debug` prints spendable material. + +- **`psbt_builder` — Bitcoin transaction construction.** The counterpart to + `pset_builder`, as a separate module rather than a generic one: the two chains + share the shape of the job and almost none of its substance, and roughly two + thirds of `pset_builder` is machinery for things Bitcoin does not have. What is + genuinely common — the input/output vocabulary and the two-pass fee loop — is + mirrored under the same names, including the guarantee that a declared output's + index in the request is its index in the transaction. + + The difference that reaches furthest is that **the fee is not an output**. + Elements places a fee `TxOut` and the transaction balances by construction; + Bitcoin defines the fee as inputs minus outputs, so nothing writes it down. A + slip that would produce a visibly wrong fee output on Elements produces a + silently overpaid fee here, so the balance is asserted rather than assumed and + `BuildPsbtResult::fee` reports what was actually left over. Change below the + dust threshold folds into the fee, and the reported number says so. + + Also carries the Bitcoin **signing** path: BIP341 key-path sighashes, + signatures stored as `tap_key_sig`, and a finalizer that turns each into a + one-element witness. Only the inputs a caller names are touched, so a + transaction mixing wallet and covenant inputs can be signed here and have its + covenant inputs finalized by `covenant` without either clobbering the other. + Computing a sighash requires *every* input's prevout — a taproot sighash + commits to all spent outputs, so one missing `witness_utxo` would silently + change every signature — and a missing one is refused rather than worked + around. + + `from_pset_request` narrows the Elements request the lifecycle already + assembles into a Bitcoin one. That assembly is ~800 lines of destination + resolution, covenant address derivation and state metadata, almost none of it + chain-specific, so there is one assembly path and the chains part company at + the build boundary rather than in two copies that drift. The conversion is a + narrowing, not a translation: assets, issuance, blinding and confidential + outputs are **refused rather than dropped**. `validate` already rejects those + on a Bitcoin manifest, so anything arriving here with them set got past a check + that should have caught it, and silently ignoring it would turn a bug in that + check into a transaction meaning something other than the manifest said. + + Not yet dispatched from `lifecycle` — that remains the last integration step. + +- **`capabilities` command and `Manifest::supported_by` — the support check + `requires` exists for.** A third-party wallet answers "do I handle this file" + by passing what it implements and reading a verdict, instead of reimplementing + this crate's inference over the manifest body: + + ``` + $ tx-manifest-wallet capabilities m.json # the contract + chain : elements + requires : simplicity + + $ tx-manifest-wallet capabilities m.json --supports simplicity + ✓ supported # exit 0 + + $ tx-manifest-wallet capabilities m.json --supports "" --json + { "supported": false, "missing": ["simplicity"], ... } # exit 1 + ``` + + `--supports` sets the exit code so it can gate CI. Without it the command + reports the contract and stops, rather than answering a question about a + wallet nobody named — exiting 0 there would read as a passing check. The chain + is checked alongside the capabilities and reported differently, because the two + mean different things to an implementor: a capability gap is closable by + implementing something, a wrong chain is not. + + Scope worth stating: a `supported` verdict certifies the *ledger* requirements + only. OP_RETURN outputs, relative timelocks and similar transaction shapes are + not in the capability vocabulary, so an implementor still reads the manifest + body for those. + +- **The jet set is chosen from the chain, and `requires` is enforced at run time.** + `CompileOpts` gained a `family`, so the SimplicityHL jet hinter follows the + manifest's `chain` the way `debug_symbols` already did — it belongs there for + the same reason, since a jet's CMR depends on its position in its jet set and + therefore moves every covenant address. Verified against the Bitcoin-enabled + forks: `p2pk.simf` compiles unchanged under both jet sets and yields two + different CMRs, so a covenant address differs per chain for two independent + reasons (jet CMRs and the taproot tag domain). + + This build still pins upstream SimplicityHL, which ships no `BitcoinJetHinter`, + so a Bitcoin covenant is refused with an error naming the fork that works + rather than being silently compiled against the Elements jet set — which would + succeed for any program using only shared jets and produce an address on the + wrong chain. + +- **`config.json` gained `simplicity_activated` and `extra_capabilities`,** and + `lifecycle::run` now refuses before deriving, signing or broadcasting anything + if the target cannot provide what `requires` declares. `validate` cannot do + this: it is offline, and Simplicity on Bitcoin is a property of the node rather + than of the chain. Also refuses when the manifest's `chain` and the wallet's + network disagree — caught at the gate, where the message can be about the + mistake, rather than deep in address derivation, where it would be about + taproot tags. + +### Changed + +- **Taproot tag domains are derived from the chain rather than hardcoded.** + `build_tapbranch` took the `TapBranch/elements` tag as a constant; it now takes + a `ChainFamily`, as do `dry_run_covenant` and `finalize_covenant_input`. + `compute_covenant_address` derives it from the network it already receives, so + no caller changed. This is the one change here that cannot fail loudly: the + wrong tag yields a well-formed address that no script path can ever satisfy, + so it is now pinned by a test that cross-checks the Elements branch against + `rust-elements`' own tag. + +- **Covenant scriptPubKeys are derived per chain, not just addresses.** The + taproot *tweak* is domain-separated the same way the tag hashes are + (`TapTweak/elements` versus `TapTweak`), so one covenant tree yields different + scriptPubKey **bytes** on the two chains — not merely a different address + string. That is a third independent reason a covenant address is chain-specific, + alongside the jet CMRs and the TapBranch tag, and the only one with no visible + symptom: an Elements-derived script is a perfectly well-formed P2TR output on + Bitcoin, and a transaction paying it looks entirely normal right up until nobody + can ever spend it. + + `covenant_script_pubkey_for` dispatches on the network and + `compute_bitcoin_covenant_address` is its Bitcoin half; both share one + merkle-root computation so they cannot fold different trees while disagreeing + (correctly) about the tweak. A Bitcoin address built from Elements compile + options is refused, so the tweak and the jet set cannot come from different + chains. + +- **The Simplicity leaf version comes from one constant for both chains.** + `simplicity::leaf_version()` returns an `elements::taproot::LeafVersion`, + which is the wrong type the moment a Bitcoin tree is built. The byte is the + same either way (`0xbe`, matching `TAPROOT_LEAF_TAPSIMPLICITY` in the Bitcoin + proposal), so it is now `chain::SIMPLICITY_LEAF_VERSION`. + +- **The generated JSON Schema matches the parser exactly** for the two new + types. The `schemars` derive emits only canonical spellings, which would make + an editor flag `"chain": "liquid"` in files this engine reads happily — + including every example here. `Capability`'s schema is an `anyOf` of the + closed core list and a namespace pattern, since an `enum` cannot express a set + that is closed at one end and open at the other. + ## [0.2.1] - 2026-09-09 A maintenance release. No format change: `manifest_version` stays `0.2.0` and diff --git a/Cargo.lock b/Cargo.lock index 912c54a..fad425b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2913,9 +2913,8 @@ checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] name = "simplicity-lang" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13ed081e3046d66c146d7201bcbf3b655ca3436cb83f6efc26d7895bd2b79d06" +version = "0.7.1" +source = "git+https://github.com/delta1/rust-simplicity?branch=2025-12%2Fupdate-libsimplicity#13c1bcedcf4d4e5659ffdb7329a20d055c36c44e" dependencies = [ "bitcoin", "bitcoin_hashes", @@ -2931,8 +2930,7 @@ dependencies = [ [[package]] name = "simplicity-sys" version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96d1ec5477c7650b8ef511aa56dccb28f2e8cdb6e87f260ecffdaf0ebfef2d3b" +source = "git+https://github.com/delta1/rust-simplicity?branch=2025-12%2Fupdate-libsimplicity#13c1bcedcf4d4e5659ffdb7329a20d055c36c44e" dependencies = [ "bitcoin_hashes", "cc", @@ -2940,8 +2938,8 @@ dependencies = [ [[package]] name = "simplicityhl" -version = "0.7.0" -source = "git+https://github.com/BlockstreamResearch/SimplicityHL#46867308f28affa386a426f4d4e3e2a8c9db9217" +version = "0.7.1" +source = "git+https://github.com/delta1/SimplicityHL?branch=bitcoin#17f0fa4c535c2794df0e79d8c702458d1764a16d" dependencies = [ "ariadne", "base64 0.21.7", @@ -3367,6 +3365,7 @@ dependencies = [ "serde_json", "simplicityhl", "ureq", + "url", ] [[package]] diff --git a/contrib/regtest/Dockerfile b/contrib/regtest/Dockerfile new file mode 100644 index 0000000..4871fa8 --- /dev/null +++ b/contrib/regtest/Dockerfile @@ -0,0 +1,25 @@ +# Bitcoin Core fork with Simplicity activated on regtest. +# Node only: no GUI, no wallet, no tests — `generatetoaddress` and `scantxoutset` +# are node RPCs, so nothing we need requires the wallet. +FROM debian:bookworm-slim AS build +RUN apt-get update && apt-get install -y --no-install-recommends \ + git ca-certificates cmake ninja-build g++ pkg-config \ + libevent-dev libboost-dev python3 \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /src +RUN git clone --depth 1 --branch simplicity-inquisition \ + https://github.com/delta1/bitcoin.git . +RUN cmake -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DBUILD_GUI=OFF -DENABLE_WALLET=OFF \ + -DBUILD_TESTS=OFF -DBUILD_BENCH=OFF -DBUILD_FUZZ_BINARY=OFF +RUN cmake --build build -j"$(nproc)" --target bitcoind bitcoin-cli + +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends \ + libevent-2.1-7 libevent-extra-2.1-7 libevent-pthreads-2.1-7 ca-certificates \ + && rm -rf /var/lib/apt/lists/* +COPY --from=build /src/build/bin/bitcoind /usr/local/bin/ +COPY --from=build /src/build/bin/bitcoin-cli /usr/local/bin/ +EXPOSE 18443 18444 +ENTRYPOINT ["bitcoind"] diff --git a/contrib/regtest/README.md b/contrib/regtest/README.md new file mode 100644 index 0000000..894cf86 --- /dev/null +++ b/contrib/regtest/README.md @@ -0,0 +1,126 @@ +# Simplicity regtest + +A local Bitcoin regtest with Simplicity active, for exercising the Bitcoin path without a +faucet and without waiting on a public network that has not activated the soft fork. + +Built from [`delta1/bitcoin@simplicity-inquisition`](https://github.com/delta1/bitcoin/tree/simplicity-inquisition), +which is the implementation proposed in BINANA 2026-0003. + +## Build and run + +```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 +``` + +Confirm the rules are on. `SIMPLICITY` appears in `script_flags`, not in `deployments` — +it is enabled for the chain rather than being deployed through BIP9: + +```sh +docker exec simplicity-regtest bitcoin-cli -regtest -rpcuser=tx -rpcpassword=manifest \ + getdeploymentinfo | head -20 +``` + +``` +"script_flags": [ "ANYPREVOUT", "CHECKSIGFROMSTACK", "CHECKTEMPLATEVERIFY", + "INTERNALKEY", "NULLDUMMY", "OP_CAT", "P2SH", "SIMPLICITY", + "TAPROOT", "TEMPLATEHASH", "WITNESS" ] +``` + +## Helpers + +Two scripts, sharing one definition of the container and the burn address so they cannot +disagree about either: + +| | | +|---|---| +| `./contrib/regtest/faucet.sh --wallet [--utxos N]` | fund a wallet with spendable coins | +| `./contrib/regtest/mine.sh [N] [--txid ]` | confirm transactions, advance the chain | + +Both take `--config` where a wallet's network has to be resolved, and honour +`FAUCET_CONTAINER` / `FAUCET_RPCUSER` / `FAUCET_RPCPASS`. + +`mine.sh` mines to an unspendable address by default, so advancing the chain never quietly +adds coins to a wallet under test — `faucet.sh` is the one that gives you money. Passing +`--txid` reports that transaction afterwards, and distinguishes "not yet mined" from "never +reached the mempool", which look identical until something tells you which: + +``` +$ ./contrib/regtest/mine.sh --txid 6655a5a2… +✓ mined 1 block(s) — height 1344 → 1345 +✓ 6655a5a22a174239… confirmed (2 confirmation(s)) + out[0] 0.0015 BTC -> bcrt1p5z45vylh6vue39806mze8wl7z360ynn0uhxd8cnr5p4swe6n5gts4pf74z + out[1] 0.38912192 BTC -> bcrt1p5chl5z5t268jja3p6rpknkxsdskxqv7fwxc6d0umhmkgzwyl0qqq0ssl0u +``` + +## Point the wallet at it + +```json +{ + "default_network": "bitcoin-regtest", + "bitcoin_backend": "rpc", + "bitcoin_rpc_url": "http://127.0.0.1:18443", + "bitcoin_rpc_auth": "tx:manifest", + "simplicity_activated": true +} +``` + +The node backend rather than Esplora: an Esplora instance for a four-block chain means an +indexer and an API server alongside the node, and `scantxoutset` finds our coins with no +wallet, no import and no rescan. + +## End to end + +```sh +export TX_MANIFEST_DATA_DIR=/tmp/txm-regtest # keeps this off your real config +mkdir -p "$TX_MANIFEST_DATA_DIR" +cat > "$TX_MANIFEST_DATA_DIR/config.json" <<'JSON' +{ "default_network": "bitcoin-regtest", + "bitcoin_backend": "rpc", + "bitcoin_rpc_url": "http://127.0.0.1:18443", + "bitcoin_rpc_auth": "tx:manifest", + "simplicity_activated": true } +JSON + +tx-manifest-wallet create-wallet --out "$TX_MANIFEST_DATA_DIR/wallet.json" +tx-manifest-wallet info --wallet "$TX_MANIFEST_DATA_DIR/wallet.json" # copy the receive address +``` + +Fund it: + +```sh +./contrib/regtest/faucet.sh --config "$TX_MANIFEST_DATA_DIR/config.json" \ + --wallet "$TX_MANIFEST_DATA_DIR/wallet.json" --utxos 3 +``` + +That mines the coins you asked for to the wallet, then mines 100 more to an unspendable +address to mature them — so you get exactly three spendable UTXOs and nothing else. Mining +201 blocks to your own address works too, but leaves a hundred immature outputs the wallet +has to skip and every later scan has to walk. + +Then run an action. `--export-pset` writes the signed transaction instead of broadcasting, +which is the easy way to inspect it first: + +```sh +tx-manifest-wallet run manifest.json Pay \ + --wallet "$TX_MANIFEST_DATA_DIR/wallet.json" \ + --data-dir "$TX_MANIFEST_DATA_DIR" \ + --params params.json \ + --export-pset signed.json + +docker exec simplicity-regtest bitcoin-cli -regtest -rpcuser=tx -rpcpassword=manifest \ + testmempoolaccept "[\"$(jq -r .tx_hex signed.json)\"]" +``` + +Drop `--export-pset` to broadcast instead; the wallet prompts before it sends. + +The image is built without wallet support, which is fine: `generatetoaddress` and +`scantxoutset` are node RPCs. + +## What works against this today + +Plain payments. Covenant spending needs a SimplicityHL build with the Bitcoin jet hinter, +which this crate does not yet pin — the node will execute Simplicity, but the wallet cannot +yet compile a program for it. See `covenant::jet_hinter`. diff --git a/contrib/regtest/_common.sh b/contrib/regtest/_common.sh new file mode 100644 index 0000000..657a284 --- /dev/null +++ b/contrib/regtest/_common.sh @@ -0,0 +1,42 @@ +# Shared plumbing for the regtest helpers. Sourced, not executed. +# +# Extracted rather than copied into each script: these two must agree about which container +# they talk to, how they report a failure, and where they mine throwaway blocks. Two copies +# agree on the day they are written. + +CONTAINER="${FAUCET_CONTAINER:-simplicity-regtest}" +RPCUSER="${FAUCET_RPCUSER:-tx}" +RPCPASS="${FAUCET_RPCPASS:-manifest}" + +# A P2TR output paying the BIP341 NUMS point — a key nobody holds. Blocks mined here are +# real blocks whose reward is unspendable by anyone, which is what makes it safe to mine +# hundreds of them just to advance the chain. +BURN="bcrt1p2zffkaxp5py4fdutfdsrt6t6tcrc5ks09rkfd428hlhf4n5q8tqq5az5cr" + +die() { printf '\033[31merror:\033[0m %s\n' "$*" >&2; exit 1; } +note() { printf '\033[2m%s\033[0m\n' "$*"; } +ok() { printf '\033[32m✓\033[0m %s\n' "$*"; } + +cli() { + docker exec "$CONTAINER" bitcoin-cli -regtest \ + -rpcuser="$RPCUSER" -rpcpassword="$RPCPASS" "$@" +} + +# Fail before doing anything, with the command that fixes it. +require_node() { + docker inspect "$CONTAINER" >/dev/null 2>&1 \ + || die "container '$CONTAINER' not found. Start it: + docker run -d --name $CONTAINER -p 18443:18443 simplicity-regtest \\ + -regtest -server -rpcbind=0.0.0.0 -rpcallowip=0.0.0.0/0 \\ + -rpcuser=$RPCUSER -rpcpassword=$RPCPASS -fallbackfee=0.0001 -txindex=1" + [ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER")" = "true" ] \ + || die "container '$CONTAINER' exists but is not running: docker start $CONTAINER" + cli getblockcount >/dev/null 2>&1 \ + || die "cannot reach bitcoind in '$CONTAINER' over RPC" +} + +# The header comment block, for --help. Reads the comment rather than a line range, so it +# cannot drift out of step with what it documents. +print_help() { + awk 'NR>1 { if ($0 ~ /^#/) { sub(/^# ?/, ""); print } else exit }' "$1" +} diff --git a/contrib/regtest/faucet.sh b/contrib/regtest/faucet.sh new file mode 100755 index 0000000..e75523d --- /dev/null +++ b/contrib/regtest/faucet.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# +# Fund a wallet on the local Simplicity regtest. +# +# Mining is how a regtest wallet gets money, and a block reward cannot be spent until it is +# 100 blocks deep. Mining 101 blocks to your own address therefore leaves you one spendable +# coin and a hundred you have to wait on — and every later scan has to walk all of them. +# +# So this mines the coins you asked for to your address, then matures them by mining 100 +# blocks to a throwaway address instead. You end up with exactly the UTXOs you wanted, all +# spendable, and nothing extra in your wallet. +# +# ./contrib/regtest/faucet.sh --wallet /tmp/txm-regtest/wallet.json +# ./contrib/regtest/faucet.sh --wallet w.json --utxos 5 +# ./contrib/regtest/faucet.sh --address bcrt1p... +# +set -euo pipefail +# Resolved before the cd, or `$0` stops naming this file. +SELF="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")" +. "$(dirname "$SELF")/_common.sh" + +MATURITY=100 + +wallet="" address="" config="" utxos=1 + + +while [ $# -gt 0 ]; do + case "$1" in + --wallet) wallet="${2:-}"; shift 2 ;; + --address) address="${2:-}"; shift 2 ;; + --config) config="${2:-}"; shift 2 ;; + --utxos) utxos="${2:-}"; shift 2 ;; + -h|--help) print_help "$SELF"; exit 0 ;; + *) die "unknown argument: $1 (try --help)" ;; + esac +done + + +# --- checks, before anything is mined --------------------------------------- +require_node + +case "$utxos" in (*[!0-9]*|"") die "--utxos must be a whole number, got '$utxos'" ;; esac +[ "$utxos" -ge 1 ] || die "--utxos must be at least 1" + +# --- resolve the address ---------------------------------------------------- +if [ -n "$address" ] && [ -n "$wallet" ]; then + die "pass --wallet or --address, not both" +elif [ -z "$address" ]; then + [ -n "$wallet" ] || die "need --wallet or --address (try --help)" + [ -f "$wallet" ] || die "wallet file not found: $wallet" + # Derived through the wallet itself rather than read from the file, so the address is + # the one the wallet will actually scan for — including the network it is configured on. + note "deriving receive address from $wallet…" + cfg_args=() + [ -n "$config" ] && cfg_args=(--config "$config") + address=$(cargo run -q -p tx-manifest-wallet -- "${cfg_args[@]}" \ + info --wallet "$wallet" 2>/dev/null \ + | awk '/Receive Address/{getline; gsub(/ /,""); print; exit}') + [ -n "$address" ] || die "could not derive an address. Is the config pointing at a Bitcoin network? + Try: cargo run -p tx-manifest-wallet -- ${config:+--config $config }info --wallet $wallet" +fi + +case "$address" in + bcrt1*) ;; + *) die "'$address' is not a regtest address (expected bcrt1…). Check the config's default_network." ;; +esac + +# --- mine ------------------------------------------------------------------- +before=$(cli getblockcount) +echo +echo "Funding $address" +note " $utxos coin(s), then $MATURITY blocks to mature them" + +cli generatetoaddress "$utxos" "$address" >/dev/null +cli generatetoaddress "$MATURITY" "$BURN" >/dev/null +after=$(cli getblockcount) + +ok "mined $((after - before)) blocks (height $before → $after)" + +# --- report ----------------------------------------------------------------- +spk=$(cli validateaddress "$address" | sed -n 's/.*"scriptPubKey": "\([0-9a-f]*\)".*/\1/p') +total=$(cli scantxoutset start "[{\"desc\":\"raw($spk)\"}]" \ + | sed -n 's/.*"total_amount": \([0-9.]*\).*/\1/p') +ok "address now holds ${total:-0} BTC, all spendable" +echo +note "check it with:" +note " cargo run -p tx-manifest-wallet -- ${config:+--config $config }sync --wallet ${wallet:-}" +echo diff --git a/contrib/regtest/mine.sh b/contrib/regtest/mine.sh new file mode 100755 index 0000000..e155446 --- /dev/null +++ b/contrib/regtest/mine.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# +# Mine blocks on the local Simplicity regtest. +# +# Nothing confirms on regtest until a block is mined, so this is what you run after +# broadcasting. Blocks go to an unspendable address by default, so mining never quietly +# adds coins to a wallet you are testing with — use `faucet.sh` when you want funds. +# +# ./contrib/regtest/mine.sh # one block +# ./contrib/regtest/mine.sh 6 # six +# ./contrib/regtest/mine.sh --txid # one block, then report that tx +# ./contrib/regtest/mine.sh 144 --to bcrt1p… # to a specific address +# +set -euo pipefail +# Resolved before the cd, or `$0` stops naming this file. +SELF="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")" +. "$(dirname "$SELF")/_common.sh" + +blocks=1 target="" txid="" + +while [ $# -gt 0 ]; do + case "$1" in + --to) target="${2:-}"; shift 2 ;; + --txid) txid="${2:-}"; shift 2 ;; + -h|--help) print_help "$SELF"; exit 0 ;; + -*) die "unknown argument: $1 (try --help)" ;; + *) blocks="$1"; shift ;; + esac +done + +case "$blocks" in (*[!0-9]*|"") die "block count must be a whole number, got '$blocks'" ;; esac +[ "$blocks" -ge 1 ] || die "block count must be at least 1" + +require_node + +if [ -n "$target" ]; then + case "$target" in + bcrt1*) ;; + *) die "'$target' is not a regtest address (expected bcrt1…)" ;; + esac +else + target="$BURN" +fi + +# Checked before mining: a txid that does not exist is almost always a typo or a +# transaction that was never broadcast, and mining first would hide which. +if [ -n "$txid" ] && ! cli getrawtransaction "$txid" true >/dev/null 2>&1; then + die "transaction $txid is not known to this node — was it broadcast?" +fi + +before=$(cli getblockcount) +cli generatetoaddress "$blocks" "$target" >/dev/null +after=$(cli getblockcount) +ok "mined $blocks block(s) — height $before → $after" + +if [ -n "$txid" ]; then + tx_json="$(mktemp)" + trap 'rm -f "$tx_json"' EXIT + cli getrawtransaction "$txid" true > "$tx_json" 2>/dev/null \ + || die "transaction $txid disappeared after mining" + # The file is passed by path, not piped: the heredoc already occupies stdin. + python3 - "$txid" "$tx_json" <<'PY' +import json, sys +with open(sys.argv[2]) as f: + d = json.load(f) +conf = d.get("confirmations", 0) +if conf: + print(f"\033[32m✓\033[0m {sys.argv[1][:16]}… confirmed ({conf} confirmation(s))") +else: + # Mined a block and it is still unconfirmed: it never reached the mempool, which is a + # different problem from "not yet mined" and worth saying so. + print(f"\033[31m✗\033[0m {sys.argv[1][:16]}… still unconfirmed — not in the mempool?") +for i, o in enumerate(d.get("vout", [])): + spk = o.get("scriptPubKey", {}) + print(f" out[{i}] {o.get('value')} BTC -> {spk.get('address', spk.get('type', '?'))}") +PY +fi diff --git a/examples/bitcoin_covenant/README.md b/examples/bitcoin_covenant/README.md new file mode 100644 index 0000000..8b70b1d --- /dev/null +++ b/examples/bitcoin_covenant/README.md @@ -0,0 +1,116 @@ +# bitcoin_covenant + +A Simplicity covenant on **Bitcoin**: coins locked under a program the node itself +executes, then spent by satisfying it. + +The program is `p2pk.simf` — byte-identical to the one in `examples/p2pk`, which runs on +Liquid. Nothing about the source is Bitcoin-specific: + +``` +fn main() { + let sig: Signature = witness::SIGNATURE; + jet::bip_0340_verify((param::PUB_KEY, jet::sig_all_hash()), sig); +} +``` + +It nonetheless produces a **different address on each chain**, for four independent +reasons: a jet's CMR depends on its position in its jet set, and all three taproot tags +(`TapLeaf`, `TapBranch`, `TapTweak`) are domain-separated with an `/elements` suffix. The +same source, the same key, two addresses that have nothing to do with each other. + +Two actions: + +- **`Lock`** — pay into the covenant. An ordinary payment as far as the chain is concerned; + the covenant is just a P2TR output. +- **`Unlock`** — spend it. The engine builds the transaction, computes `sig_all_hash` from + it, signs with the wallet key, and assembles the four-item Simplicity tapscript witness + `[witness, program, CMR, control block]`. The node then *runs the program*. + +## Requirements + +A node that executes Simplicity. No public network has activated it, so this needs +`contrib/regtest`: + +```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: + +``` +Error: network 'bitcoin-regtest' cannot provide what this manifest requires (simplicity) +``` + +## 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: + +```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: + +```sh +cargo run -p tx-manifest-wallet -- $CFG \ + run examples/bitcoin_covenant/txmanifest.json Lock \ + --wallet $W --params examples/bitcoin_covenant/params.json +``` + +`Lock` writes a state file recording the covenant it created, so `Unlock` can find it — +pass the one `Lock` wrote: + +```sh +cargo run -p tx-manifest-wallet -- $CFG \ + run examples/bitcoin_covenant/txmanifest.json Lock \ + --wallet $W --params examples/bitcoin_covenant/params.json \ + --state-out /tmp/cov.state.json + +./contrib/regtest/mine.sh + +cargo run -p tx-manifest-wallet -- $CFG \ + run examples/bitcoin_covenant/txmanifest.json Unlock \ + --wallet $W --params examples/bitcoin_covenant/params.json \ + --state /tmp/cov.state.json --state-out /tmp/cov.state.2.json +``` + +State is written only on broadcast. With `--export-pset` nothing is relayed, so there is no +covenant yet to record — name the outpoint yourself in that case: + +```sh +--input vault_in=:0 +``` + +You should see: + +``` +· Covenant input 'vault_in' — satisfying… OK +``` + +and the node accept a 142-vbyte transaction whose witness is four items: 64 bytes of +Simplicity witness, a 53-byte program, the 32-byte CMR, and a 33-byte control block. + +## Limits worth knowing + +- **A pinned covenant input needs its `amount_sat` supplied.** `--input id=txid:vout` alone + leaves it zero, and `vault_in.amount_sat - fee` then goes negative. The state file carries + the amount, so this only bites when naming an outpoint by hand — use `--inputs-file` + there, until the Bitcoin path looks the value up on chain. +- **No environment pruning.** SimplicityHL's `satisfy_with_env` is typed to `ElementsEnv`, + so the Bitcoin path satisfies unpruned and a program with branches carries its dead ones + into the witness. Fine for `p2pk`, which has none. +- **Roughly 27 of 428 jets have their FFI wired** in `rust-simplicity`. `p2pk` uses + `bip_0340_verify` and `sig_all_hash`, both of which do. Most richer covenants do not yet. +- Output lines say `sat lbtc`. The asset label is Elements vocabulary the shared assembly + still speaks; on Bitcoin there is one asset and it is BTC. diff --git a/examples/bitcoin_covenant/config.json b/examples/bitcoin_covenant/config.json new file mode 100644 index 0000000..1cdcdf0 --- /dev/null +++ b/examples/bitcoin_covenant/config.json @@ -0,0 +1,10 @@ +{ + "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/bitcoin_covenant/config.regtest.json b/examples/bitcoin_covenant/config.regtest.json new file mode 100644 index 0000000..fc9cf9c --- /dev/null +++ b/examples/bitcoin_covenant/config.regtest.json @@ -0,0 +1,7 @@ +{ + "default_network": "bitcoin-regtest", + "bitcoin_backend": "rpc", + "bitcoin_rpc_url": "http://127.0.0.1:18443", + "bitcoin_rpc_auth": "tx:manifest", + "simplicity_activated": true +} diff --git a/examples/bitcoin_covenant/config.signet.json b/examples/bitcoin_covenant/config.signet.json new file mode 100644 index 0000000..1cdcdf0 --- /dev/null +++ b/examples/bitcoin_covenant/config.signet.json @@ -0,0 +1,10 @@ +{ + "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/bitcoin_covenant/p2pk.simf b/examples/bitcoin_covenant/p2pk.simf new file mode 100644 index 0000000..c64aacb --- /dev/null +++ b/examples/bitcoin_covenant/p2pk.simf @@ -0,0 +1,4 @@ +fn main() { + let sig: Signature = witness::SIGNATURE; + jet::bip_0340_verify((param::PUB_KEY, jet::sig_all_hash()), sig); +} \ No newline at end of file diff --git a/examples/bitcoin_covenant/params.json b/examples/bitcoin_covenant/params.json new file mode 100644 index 0000000..c2e9fca --- /dev/null +++ b/examples/bitcoin_covenant/params.json @@ -0,0 +1,5 @@ +{ + "PUB_KEY": "9b55fc3882d53c09168106b2699e4f4fcb24f67c5b6e089a376f74e1768eead1", + "amount_sat": "1000000", + "fee_rate": "0.1" +} diff --git a/examples/bitcoin_covenant/txmanifest.json b/examples/bitcoin_covenant/txmanifest.json new file mode 100644 index 0000000..d412d91 --- /dev/null +++ b/examples/bitcoin_covenant/txmanifest.json @@ -0,0 +1,81 @@ +{ + "$schema": "https://raw.githubusercontent.com/stringhandler/txmanifest-wallet/main/schema/txmanifest.schema.json", + "manifest_version": "0.3.0", + "protocol": "bitcoin-covenant", + "description": "A Simplicity covenant on Bitcoin. Locks coins under a program the node itself executes, then spends them by satisfying it — the same p2pk.simf that runs on Liquid, unchanged.", + "chain": "bitcoin", + "requires": ["simplicity"], + "simplicity_hl": { "debug_symbols": false }, + "utxo_types": { + "p2pk_vault": { + "description": "Coins spendable only by a BIP340 signature over the whole transaction, checked on-chain by Simplicity rather than by Bitcoin script.", + "params": { + "PUB_KEY": { + "type": "pubkey", + "description": "x-only key the covenant checks against. Take it from `info` (Wallet Signing Key), so this wallet can satisfy it." + } + }, + "script": { "type": "simplicity", "source": "./p2pk.simf" } + } + }, + "actions": { + "Lock": { + "description": "Send coins into the covenant.", + "intent": "Lock {params.amount_sat} sat under a Simplicity covenant", + "allow_change": "lbtc_only", + "params": { + "PUB_KEY": { "type": "pubkey", "description": "Key the covenant will require." }, + "amount_sat": { "type": "u64", "description": "Amount to lock.", "default": "1000000" } + }, + "inputs": [ + { + "id": "funding", + "utxo_source": "wallet", + "asset": "lbtc", + "amount_sat": { "min_amount": "params.amount_sat" }, + "ui": { "label": "coin being locked", "role": "funding" } + } + ], + "outputs": [ + { + "id": "vault", + "amount_sat": "params.amount_sat", + "asset": "lbtc", + "destination": { "utxo_type": "p2pk_vault", "args": { "PUB_KEY": "params.PUB_KEY" } }, + "ui": { "label": "coins locked under the covenant", "role": "covenant" } + } + ] + }, + "Unlock": { + "description": "Spend the covenant by satisfying its program.", + "intent": "Unlock the covenant and send to {params.dest}", + "$comment": "No change output: the amount formula accounts for the fee exactly, so any surplus would be an error rather than a silently invented output.", + "params": { + "PUB_KEY": { "type": "pubkey", "description": "Key the covenant requires." } + }, + "inputs": [ + { + "id": "vault_in", + "utxo_source": { "utxo_type": "p2pk_vault", "args": { "PUB_KEY": "params.PUB_KEY" } }, + "$comment": "The witness the program reads. `type: Signature` means the engine computes sig_all_hash from the transaction it just built and signs it with the named key — so the value signed is by construction the one the program checks.", + "witnesses": { + "SIGNATURE": { + "type": "Signature", + "sig_type": "sig_hash_all", + "source": { "key": "params.PUB_KEY" } + } + }, + "ui": { "label": "covenant being unlocked", "role": "covenant" } + } + ], + "outputs": [ + { + "id": "unlocked", + "amount_sat": "vault_in.amount_sat - fee", + "destination": "wallet", + "ui": { "label": "unlocked coins", "role": "payment" } + } + ] + } + } +} diff --git a/examples/bitcoin_pay/README.md b/examples/bitcoin_pay/README.md new file mode 100644 index 0000000..102f584 --- /dev/null +++ b/examples/bitcoin_pay/README.md @@ -0,0 +1,107 @@ +# bitcoin_pay + +The smallest manifest that runs on Bitcoin, and a worked example of what `requires: []` +buys you: no covenant, no Simplicity, nothing a stock node lacks. + +Two actions: + +- **`Pay`** — spend one wallet UTXO to an address, returning the remainder as change. +- **`Sweep`** — send the whole of one UTXO, less the fee. Exercises the `fee` keyword, + which is resolved from the built transaction's size rather than guessed. + +Everything else here is Elements-only, so this is also the reference for what a +Bitcoin-targeted manifest may and may not say. `validate` will tell you if you stray: +an `asset` other than the policy one, an issuance input, or a blinded output is rejected +against `chain: "bitcoin"` with the field named. + +## Running it + +Needs a Bitcoin node. `contrib/regtest` builds one with Simplicity active — overkill for +these two actions, which need none of it, but it is the node that is already there. + +```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` in this directory points at that node. Pass it with `--config`, which keeps +your real wallet config — living in the platform data directory, not the current one — +entirely out of it: + +```sh +cargo run -p tx-manifest-wallet -- --config examples/bitcoin_pay/config.json config +``` + +That prints the file it resolved, which is worth checking once: without `--config` the +wallet reads `~/Library/Application Support/tx-manifest-wallet/config.json` (or the +platform equivalent), and a `config.json` sitting next to a manifest is *not* picked up on +its own. Set `TX_MANIFEST_DATA_DIR` instead if you want the config and the persisted wallet +state to move together. + +Create a wallet and find out where to send funds: + +```sh +CFG="--config examples/bitcoin_pay/config.json" +cargo run -p tx-manifest-wallet -- $CFG create-wallet --out /tmp/txm-regtest/wallet.json +cargo run -p tx-manifest-wallet -- $CFG info --wallet /tmp/txm-regtest/wallet.json +``` + +There is no `--regtest` flag and none is needed: `create-wallet` takes the network from +the config, and records it by name — the file will say `bitcoin-regtest`. + +The same mnemonic works across regtest, signet and testnet, which all use SLIP-44 coin type +`1'`. It does **not** carry to mainnet, which uses `0'` and so derives entirely different +addresses. + +Fund it: + +```sh +./contrib/regtest/faucet.sh --config examples/bitcoin_pay/config.json \ + --wallet /tmp/txm-regtest/wallet.json --utxos 3 +``` + +The faucet handles coinbase maturity for you: it mines your coins, then mines 100 more to +an unspendable address so yours become spendable, leaving nothing immature behind. + +Then run an action. `params.json` here pays to a fixed address; change `dest` to anything +valid for the network — a mainnet address is refused rather than silently accepted. + +```sh +cargo run -p tx-manifest-wallet -- $CFG run examples/bitcoin_pay/txmanifest.json Pay \ + --wallet /tmp/txm-regtest/wallet.json \ + --data-dir /tmp/txm-regtest \ + --params examples/bitcoin_pay/params.json +``` + +It prompts before broadcasting, and nothing confirms until a block is mined: + +```sh +./contrib/regtest/mine.sh --txid +``` + +To inspect the transaction rather than send it, add +`--export-pset signed.json` — that writes `{txid, tx_hex}` and sends nothing: + +```sh +docker exec simplicity-regtest bitcoin-cli -regtest -rpcuser=tx -rpcpassword=manifest \ + testmempoolaccept "[\"$(jq -r .tx_hex signed.json)\"]" +``` + +## What you should see + +`Pay` builds two outputs and `Sweep` one, both at the requested fee rate: + +``` +Pay PSBT constructed (1 inputs, 2 outputs, fee 308 sat). vsize 154 -> 2.00 sat/vB +Sweep Estimated network fee: 222 sat (resolves `fee`) + PSBT constructed (1 inputs, 1 outputs, fee 222 sat). vsize 111 -> 2.00 sat/vB +``` + +## Known cosmetic wrinkles + +Output lines read `150000 sat lbtc`. The asset label is Elements vocabulary the shared +assembly still speaks on both chains; on Bitcoin there is one asset and it is BTC. +`create-wallet` likewise prints `Network : testnet`, which is the wallet file's own +mainnet/testnet flag rather than the chain — `info` shows the real one. diff --git a/examples/bitcoin_pay/config.json b/examples/bitcoin_pay/config.json new file mode 100644 index 0000000..1fed054 --- /dev/null +++ b/examples/bitcoin_pay/config.json @@ -0,0 +1,4 @@ +{ "default_network": "bitcoin-regtest", + "bitcoin_backend": "rpc", + "bitcoin_rpc_url": "http://127.0.0.1:18443", + "bitcoin_rpc_auth": "tx:manifest" } diff --git a/examples/bitcoin_pay/params.json b/examples/bitcoin_pay/params.json new file mode 100644 index 0000000..94bb6ef --- /dev/null +++ b/examples/bitcoin_pay/params.json @@ -0,0 +1,5 @@ +{ + "dest": "bcrt1p5z45vylh6vue39806mze8wl7z360ynn0uhxd8cnr5p4swe6n5gts4pf74z", + "amount_sat": "150000", + "fee_rate": "2" +} diff --git a/examples/bitcoin_pay/txmanifest.json b/examples/bitcoin_pay/txmanifest.json new file mode 100644 index 0000000..3db2cf3 --- /dev/null +++ b/examples/bitcoin_pay/txmanifest.json @@ -0,0 +1,73 @@ +{ + "$schema": "https://raw.githubusercontent.com/stringhandler/txmanifest-wallet/main/schema/txmanifest.schema.json", + "manifest_version": "0.3.0", + "protocol": "bitcoin-pay", + "description": "A plain Bitcoin payment. The smallest manifest that runs on Bitcoin, and the one that shows what `requires: []` buys you: no covenant, no Simplicity, nothing a stock node lacks.", + "chain": "bitcoin", + "requires": [], + "actions": { + "Pay": { + "description": "Spend one wallet UTXO to a destination address, returning the remainder as change.", + "intent": "Send {params.amount_sat} sat to {params.dest}", + "$comment": "allow_change is lbtc_only rather than none because the fee is only known once the transaction is sized, so the leftover cannot be declared as an output amount up front. The name is Elements vocabulary; on Bitcoin it means the one asset there is.", + "allow_change": "lbtc_only", + "params": { + "dest": { + "type": "string", + "description": "Address to pay. Must be valid for the configured network - a mainnet address is refused on a regtest run rather than silently accepted." + }, + "amount_sat": { + "type": "u64", + "description": "Amount to send, in satoshis.", + "default": "150000" + } + }, + "inputs": [ + { + "id": "funding", + "utxo_source": "wallet", + "asset": "lbtc", + "$comment": "min_amount rather than an exact figure: the wallet picks any UTXO large enough to cover the payment and the fee, and the surplus comes back as change.", + "amount_sat": { "min_amount": "params.amount_sat" }, + "ui": { "label": "coin being spent", "role": "funding" } + } + ], + "outputs": [ + { + "id": "payment", + "amount_sat": "params.amount_sat", + "asset": "lbtc", + "destination": "params.dest", + "ui": { "label": "payment to the destination address", "role": "payment" } + } + ] + }, + "Sweep": { + "description": "Send the whole of one UTXO to a destination, leaving only the fee behind.", + "intent": "Sweep a coin to {params.dest}, less the network fee", + "$comment": "No change output is declared, so allow_change stays none: the amount formula accounts for the fee exactly, and any surplus would be an error rather than a silently invented output.", + "params": { + "dest": { "type": "string", "description": "Address to sweep to." } + }, + "inputs": [ + { + "id": "funding", + "utxo_source": "wallet", + "asset": "lbtc", + "amount_sat": { "min_amount": "10000" }, + "ui": { "label": "coin being swept", "role": "funding" } + } + ], + "outputs": [ + { + "id": "swept", + "$comment": "`fee` is a reserved keyword resolved from the built transaction's size. Referencing it is what makes this action account for the fee exactly rather than guessing.", + "amount_sat": "funding.amount_sat - fee", + "asset": "lbtc", + "destination": "params.dest", + "ui": { "label": "everything, less the network fee", "role": "payment" } + } + ] + } + } +} diff --git a/examples/deadcat/txmanifest.json b/examples/deadcat/txmanifest.json index 13db536..36123ab 100644 --- a/examples/deadcat/txmanifest.json +++ b/examples/deadcat/txmanifest.json @@ -1,10 +1,11 @@ { "$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.2.0", + "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 diff --git a/examples/deadcat_v2/txmanifest.json b/examples/deadcat_v2/txmanifest.json index 5b8d3bc..dd1dc4d 100644 --- a/examples/deadcat_v2/txmanifest.json +++ b/examples/deadcat_v2/txmanifest.json @@ -1,10 +1,11 @@ { "$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.2.0", + "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 diff --git a/examples/deadcat_v3/txmanifest.json b/examples/deadcat_v3/txmanifest.json index 1dd0fbf..4c8b1e5 100644 --- a/examples/deadcat_v3/txmanifest.json +++ b/examples/deadcat_v3/txmanifest.json @@ -1,10 +1,11 @@ { "$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.", - "manifest_version": "0.2.0", + "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.", "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 diff --git a/examples/dex/txmanifest.json b/examples/dex/txmanifest.json index 9472c51..592cce3 100644 --- a/examples/dex/txmanifest.json +++ b/examples/dex/txmanifest.json @@ -1,10 +1,11 @@ { "$schema": "../../schema/txmanifest.schema.json", "$comment": "Ported from Mosaik's tessera.simf (github.com/kaleidoswap/mosaik, crates/tessera/contracts/tessera.simf). Upstream substitutes the four offer terms as inline TESSERA_PARAM literals; here they are ordinary compile params. Change tessera.simf and every offer address changes — the terms live in the tapleaf. NOTE: the Refund method is not executable until upnext/12 (absolute nLockTime) lands; see its description.", - "manifest_version": "0.2.0", + "manifest_version": "0.3.0", "protocol": "tessera-dex", "description": "Tessera — a keyless atomic swap offer on Liquid, the primitive a Mosaik DEX is built from. One UTXO is one all-or-nothing offer: it holds asset A and is spendable by ANYONE who pays AMOUNT_B of ASSET_B to the maker (Settle), or, after TIMEOUT, by anyone who returns asset A to the maker (Refund). No signature on either path — the covenant is pure transaction introspection, and the maker is identified only by a scriptPubKey hash. Modelled as a class: one instance per offer.", "chain": "liquid", + "requires": ["simplicity"], "utxo_types": { "tessera_offer": { "description": "The offer UTXO: holds OFFER_AMOUNT of the maker's asset A, spendable via the keyless Settle or Refund paths. Its address commits to the four offer terms (ASSET_B, AMOUNT_B, MAKER_SPK, TIMEOUT) plus MAX_FEE — so the terms cannot change once funded. It does NOT commit to asset A or its amount: the covenant never inspects them on the settle path (see the note in tessera.simf), which is why OFFER_ASSET_ID/OFFER_AMOUNT are instance fields but not compile params.", diff --git a/examples/last_will/txmanifest.json b/examples/last_will/txmanifest.json index 52249f4..e049547 100644 --- a/examples/last_will/txmanifest.json +++ b/examples/last_will/txmanifest.json @@ -1,9 +1,10 @@ { "$schema": "../../schema/txmanifest.schema.json", - "manifest_version": "0.2.0", + "manifest_version": "0.3.0", "protocol": "last-will", "description": "Last Will — a recursive covenant with three spending paths: inherit (after a 180-day timelock), cold-key break-out, and hot-key refresh. Modelled as a class: one instance per will.", "chain": "liquid", + "requires": ["simplicity"], "utxo_types": { "last_will": { "description": "Funds locked under the last-will covenant.", diff --git a/examples/lending/txmanifest.json b/examples/lending/txmanifest.json index 7fb45b2..a550e95 100644 --- a/examples/lending/txmanifest.json +++ b/examples/lending/txmanifest.json @@ -1,7 +1,8 @@ { "$schema": "../../schema/txmanifest.schema.json", - "manifest_version": "0.2.0", + "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": { diff --git a/examples/lending_v2/txmanifest.json b/examples/lending_v2/txmanifest.json index 535b0aa..f25f11d 100644 --- a/examples/lending_v2/txmanifest.json +++ b/examples/lending_v2/txmanifest.json @@ -1,7 +1,8 @@ { "$schema": "../../schema/txmanifest.schema.json", - "manifest_version": "0.2.0", + "manifest_version": "0.3.0", "protocol": "simplicity-lending", + "requires": ["simplicity"], "simplicity_hl": { "debug_symbols": true }, "description": "P2P collateralised lending on Liquid, wire-compatible with the simplicity-lending reference implementation (github BlockstreamResearch/simplicity-lending, `smplx-sdk` covenants). This 'v2' manifest reproduces that protocol's exact on-chain transaction layout so offers created with tx-manifest-wallet are discoverable and settleable by simplicity-lending's own CLI / indexer / web app, and vice-versa. Key differences from the standalone 'lending' example: (1) the principal payout AND the borrower NFT both go to the borrower's plain wallet address (an explicit v0 P2WPKH), committed into the covenant as sha256(scriptPubKey) — there is no p2pk covenant and no separate claim step; (2) the borrower NFT is held in the borrower's wallet during the active loan and spent with an ordinary signature on repayment; (3) NFT ordering is first-params, second-params, borrower, lender; (4) the creation OP_RETURN carries borrower_pubkey || principal_asset_id (64 bytes, internal asset order) for indexer discovery; (5) a single AMOUNTS_DECIMALS drives the bit-packed parameter NFTs, matching the reference wallet.", "utxo_types": { diff --git a/examples/lending_v3/txmanifest.json b/examples/lending_v3/txmanifest.json index 929c503..b77c7fb 100644 --- a/examples/lending_v3/txmanifest.json +++ b/examples/lending_v3/txmanifest.json @@ -1,8 +1,9 @@ { "$schema": "../../schema/txmanifest.schema.json", "$comment": "lending_v3 targets the DEPLOYED simplicity-lending indexer (odev). Interop constants are fixed: factory params (2,0), NUMS key, protocol-fee keeper 38fca2d9…, and the program-id tags below. Change a covenant .simf and you must recompute the matching *_PROGRAM_ID + covenant address.", - "manifest_version": "0.2.0", + "manifest_version": "0.3.0", "protocol": "simplicity-lending", + "requires": ["simplicity"], "simplicity_hl": { "debug_symbols": true }, "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": { diff --git a/examples/p2pk/txmanifest.json b/examples/p2pk/txmanifest.json index 4d6a16e..d33c9be 100644 --- a/examples/p2pk/txmanifest.json +++ b/examples/p2pk/txmanifest.json @@ -1,9 +1,10 @@ { "$schema": "../../schema/txmanifest.schema.json", - "manifest_version": "0.2.0", + "manifest_version": "0.3.0", "protocol": "p2pk-simplicity", "description": "Hello World — Pay-to-public-key using a Simplicity checksig program on Liquid.", "chain": "liquid", + "requires": ["simplicity"], "utxo_types": { "p2pk_output": { "description": "A Liquid UTXO locked to PUBKEY via the compiled p2pk.simf program.", diff --git a/examples/zeroconf/txmanifest.json b/examples/zeroconf/txmanifest.json index 9732baa..e44045f 100644 --- a/examples/zeroconf/txmanifest.json +++ b/examples/zeroconf/txmanifest.json @@ -1,9 +1,10 @@ { "$schema": "../../schema/txmanifest.schema.json", - "manifest_version": "0.2.0", + "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/schema/txmanifest.schema.json b/schema/txmanifest.schema.json index 56ed66d..3ebd972 100644 --- a/schema/txmanifest.schema.json +++ b/schema/txmanifest.schema.json @@ -138,6 +138,33 @@ }, "type": "object" }, + "Capability": { + "anyOf": [ + { + "description": "A capability defined by this format.", + "enum": [ + "simplicity" + ] + }, + { + "description": "A third-party capability, 'namespace::name'. This format does not define its meaning; a target provides it by listing it as an extension.", + "pattern": "^[a-z0-9][a-z0-9_-]*::[a-z0-9][a-z0-9_-]*$", + "type": "string" + } + ], + "description": "A ledger feature this manifest depends on that the 'chain' field does not already settle. Bare names are defined by this format; anything containing '::' belongs to the named namespace.", + "type": "string" + }, + "ChainFamily": { + "description": "Ledger this manifest targets. 'liquid' is an accepted alias for 'elements', and 'btc' for 'bitcoin'. Defaults to 'elements' when absent.", + "enum": [ + "elements", + "liquid", + "bitcoin", + "btc" + ], + "type": "string" + }, "ComputeSpec": { "anyOf": [ { @@ -1113,10 +1140,15 @@ "type": "object" }, "chain": { - "type": [ - "string", - "null" - ] + "anyOf": [ + { + "$ref": "#/definitions/ChainFamily" + }, + { + "type": "null" + } + ], + "description": "Which ledger this protocol is written for: `\"elements\"` (or its alias `\"liquid\"`) or `\"bitcoin\"`. Defaults to [`ChainFamily::DEFAULT`] when absent.\n\nDeclares the *family*, not the network — a protocol that works on Liquid works on Liquid testnet, and pinning one here would be wrong. The wallet's config picks the concrete [`crate::chain::Network`]." }, "contract_templates": { "additionalProperties": { @@ -1141,6 +1173,14 @@ "protocol": { "type": "string" }, + "requires": { + "description": "Ledger features this manifest depends on that [`Manifest::chain`] does not already settle, e.g. `[\"simplicity\"]` or `[\"simplicity\", \"custom::my-feature\"]`.\n\nDeliberately narrow. Whether outputs carry an asset id, whether amounts can be blinded, whether issuance exists — all of that follows from `chain`, so listing it here would be a restatement that can disagree with itself. What is left is the residue the chain does not answer: `simplicity`, which is live on Elements but a soft fork on Bitcoin, and namespaced third-party features this crate cannot know about. See [`crate::chain`].\n\n`validate` checks it both ways — a covenant manifest that omits `simplicity` is an error, and declaring what nothing uses is a warning — because both mistakes are real: the first passes a target check and then fails at broadcast, and the second makes a manifest look less portable than it is.\n\nThe empty default is the honest one and the useful one. A manifest that declares nothing is claiming to need nothing a stock node lacks — which is exactly the manifest that can target Bitcoin today, with no Simplicity activation.", + "items": { + "$ref": "#/definitions/Capability" + }, + "type": "array", + "uniqueItems": true + }, "simplicity_hl": { "anyOf": [ { diff --git a/txmanifest_lib/Cargo.toml b/txmanifest_lib/Cargo.toml index a9b7ca0..594522f 100644 --- a/txmanifest_lib/Cargo.toml +++ b/txmanifest_lib/Cargo.toml @@ -22,6 +22,7 @@ dirs-next = "2.0.0" evalexpr = "11" 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 @@ -30,7 +31,12 @@ base64 = "0.22" # simplicityhl = { git = "https://github.com/stringhandler/SimplicityHL", branch = "feat/compile-function-only", features = [ # "serde", # ] } -simplicityhl = { git = "https://github.com/BlockstreamResearch/SimplicityHL", features = [ +# 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 +# addresses precisely so that a pin change cannot move them unnoticed. +simplicityhl = { git = "https://github.com/delta1/SimplicityHL", branch = "bitcoin", features = [ "serde", ] } schemars = "0.8" diff --git a/txmanifest_lib/src/assembly.rs b/txmanifest_lib/src/assembly.rs new file mode 100644 index 0000000..14c502b --- /dev/null +++ b/txmanifest_lib/src/assembly.rs @@ -0,0 +1,645 @@ +//! What the lifecycle's input/output assembly needs from a wallet and a chain. +//! +//! [`lifecycle::run`](crate::lifecycle::run) contains roughly eight hundred lines that +//! resolve destinations, derive covenant addresses, evaluate amounts and record state +//! metadata. Almost none of it is chain-specific — and duplicating it for Bitcoin would +//! produce two copies that agree on the day they are written and drift by the next +//! release, in ways only a funded transaction would reveal. +//! +//! So there is one assembly path, and this trait is the whole of what it asks of the +//! chain underneath. Six things, no more: +//! +//! 1. a covenant's scriptPubKey, +//! 2. an asset label resolved to an id, +//! 3. a change address, +//! 4. the next receive address, +//! 5. the chain's own unit of account, +//! 6. whether outputs are confidential unless told otherwise. +//! +//! # Why the assembly still speaks Elements +//! +//! The assembly builds [`PsetOutputSpec`](crate::pset_builder::PsetOutputSpec) values, +//! which carry an `AssetId` and an optional blinding key, on both chains. On Bitcoin the +//! asset is a single synthetic constant and the blinding key is always `None`. +//! +//! That looks like a leak, and it is a deliberate one. The alternative — a neutral +//! vocabulary both chains widen from — means every one of those eight hundred lines has to +//! be rewritten against it, which is exactly the risk this trait exists to avoid. Instead +//! the assembly keeps the richer vocabulary and +//! [`psbt_builder::from_pset_request`](crate::psbt_builder::from_pset_request) narrows it, +//! *refusing* anything Bitcoin cannot express. So the synthetic asset is not a fiction +//! that has to hold together on its own: if the assembly ever produced a second asset or a +//! blinding key on a Bitcoin run, the narrowing would reject it rather than let it through. + +use anyhow::Result; +use lwk_wollet::elements::bitcoin::PublicKey; +use lwk_wollet::elements::{AssetId, Script}; + +use crate::chain::{ChainFamily, Network}; +use crate::covenant::CompileOpts; + +/// An address the assembly is about to pay. +pub struct AddressInfo { + pub script_pubkey: Script, + /// The address as a user would read it, encoded for this chain. + /// + /// Carried alongside the script because the assembly genuinely needs both: the script + /// goes into the transaction, and the encoding goes into the line a user reads to + /// check where their money went. Deriving one from the other at the call site would + /// mean the shared assembly picking an encoding, which is precisely what it must not + /// do — a Liquid address and a Bitcoin one are different strings for the same script. + pub display: String, + /// The blinding key, when the address has one. Always `None` on Bitcoin. + pub blinding_pubkey: Option, + /// The derivation index this address came from, so the caller can ask for the next one + /// and not hand out the same address twice in one transaction. + pub index: u32, +} + +/// An address written in a manifest, resolved for the chain in play. +#[derive(Debug)] +pub struct ParsedDestination { + pub script_pubkey: Script, + /// The blinding key the address carries, if any. Always `None` on Bitcoin. + pub blinding_pubkey: Option, +} + +/// Parse an address a manifest names as a destination or an input pin. +/// +/// Keyed on the network rather than hardcoded, because the two chains' address encodings +/// are disjoint: an `elements::Address` parser rejects every Bitcoin address outright. +/// That mattered more than it sounds. Both call sites treated a parse failure as "skip +/// this" — the output loop dropped the output and carried on, and the input pin fell back +/// to selecting from anywhere. So on a Bitcoin run a manifest paying a literal address +/// built a transaction with that payment simply missing, the value falling into change, +/// with nothing but a warning in a long interactive log to say so. +/// +/// A free function rather than only a trait method because input selection runs before any +/// context exists, and the two must agree — an address the selector rejects and the +/// assembly accepts would pin nothing while appearing to. +pub fn parse_destination(address: &str, network: Network) -> Result { + let trimmed = address.trim(); + match network.family() { + ChainFamily::Elements => { + let addr: lwk_wollet::elements::Address = trimmed.parse().map_err(|e| { + anyhow::anyhow!("'{trimmed}' is not a valid {network} address: {e}") + })?; + Ok(ParsedDestination { + script_pubkey: addr.script_pubkey(), + blinding_pubkey: addr.blinding_pubkey.map(compressed), + }) + } + ChainFamily::Bitcoin => { + use lwk_wollet::elements::bitcoin as btc; + let parsed: btc::Address = trimmed + .parse() + .map_err(|e| anyhow::anyhow!("'{trimmed}' is not a valid Bitcoin address: {e}"))?; + // Checked against the run's network, not merely parsed. A mainnet address on a + // signet run parses perfectly well and would send real-network funds nowhere + // recoverable. + let btc_net = bitcoin_network(network)?; + let addr = parsed + .require_network(btc_net) + .map_err(|e| anyhow::anyhow!("'{trimmed}' is not a {network} address: {e}"))?; + Ok(ParsedDestination { + script_pubkey: Script::from(addr.script_pubkey().to_bytes()), + blinding_pubkey: None, + }) + } + } +} + +/// The `rust-bitcoin` network for one of our Bitcoin networks. +pub(crate) fn bitcoin_network(network: Network) -> Result { + use lwk_wollet::elements::bitcoin as btc; + Ok(match network { + Network::Bitcoin => btc::Network::Bitcoin, + Network::BitcoinTestnet => btc::Network::Testnet, + Network::BitcoinSignet => btc::Network::Signet, + Network::BitcoinRegtest => btc::Network::Regtest, + other => anyhow::bail!("{other} is not a Bitcoin network"), + }) +} + +/// The chain-specific half of the lifecycle's assembly. +pub trait AssemblyContext { + /// Which ledger this run targets. + fn family(&self) -> ChainFamily; + + /// The concrete network, for address parsing and encoding. + fn network(&self) -> Network; + + /// The chain's own unit of account — L-BTC's asset id on Elements, a synthetic + /// constant on Bitcoin. + fn policy_asset(&self) -> AssetId; + + /// Resolve a manifest asset label (`"lbtc"`, or a hex id) to an asset id. + /// + /// On Bitcoin every label that survives `validate` denotes the policy asset, so this + /// is near-trivial there — but it stays on the trait rather than being special-cased + /// in the assembly, because "which asset is this" is precisely the question the two + /// chains answer differently. + fn resolve_asset(&self, label: &str) -> Result; + + /// The scriptPubKey of a covenant with these compile parameters and extra leaves. + /// + /// Chain-specific in three independent ways — the jet set fixes the CMR, the TapBranch + /// tag fixes the merkle root, and the TapTweak tag fixes the output key — so this can + /// never be hoisted into the shared assembly. + fn covenant_script_pubkey( + &self, + simf_path: &std::path::Path, + compile_params: &std::collections::HashMap, + type_hints: &std::collections::HashMap, + extra_leaf_payloads: &[Vec], + opts: &CompileOpts, + ) -> Result