From 86b44153288eca305fbe6c3fab5e906747e3811f Mon Sep 17 00:00:00 2001 From: GracedEternalKingCabbageMan <151803062+GracedEternalKingCabbageMan@users.noreply.github.com> Date: Thu, 8 Oct 2026 04:45:08 +0200 Subject: [PATCH] contracts: the contract engine, in the kit and its wasm build A wallet that holds a key a contract names had no way to read the contract, recognise its output or spend it: lwk_simplicity handles one leaf with no tree, no annex, no budget check and no fee asset, and it is in no shipped wasm build. lwk_contracts reads a sequentia-contracts descriptor (versions 1 and 2) with that repository's own reader, and checks it with its pinned compiler from the resolved source texts it is handed, so it runs in a browser. It recomputes an instance's output, address and control blocks exactly as the golden vectors and the other readers do, lists each spending path and what it needs, and builds the spend of a chosen path from a request: it refuses an output the request mislabels, a confidential output, a missing or second fee and unbalanced outputs, and a lock the chain would refuse now. It signs only with a contract key (m/8383h/{coin}h/0h, the Simplex fork's account) and only the one the path names, runs the program against the final transaction (naming the source check that fails when it refuses), and pads under the budget rule. The faucet drip covenant has a planner for its tier, interval and successor. The kit carries the three published templates, their vectors and the refusal corpus, pinned in templates/PIN.json. The Simplex SDK needs Rust 1.87 against the kit's 1.85, so the engine uses its libraries directly and ports its budget rule; that moved bitcoin_hashes and semver one patch each and held psm at 0.1.26. lwk_wasm exposes ContractTemplate, ContractInstance and ContractSpend. build-web.sh builds the browser package with every build-machine path remapped and fails if one remains. CI gains a contracts job. --- .github/workflows/cont_integration.yml | 17 + CLAUDE.md | 9 +- Cargo.lock | 208 ++++- Cargo.toml | 1 + README.md | 17 +- SEQUENTIA.md | 25 + lwk_contracts/Cargo.toml | 14 + lwk_contracts/README.md | 58 ++ lwk_contracts/src/budget.rs | 107 +++ lwk_contracts/src/drip.rs | 119 +++ lwk_contracts/src/error.rs | 27 + lwk_contracts/src/hex.rs | 34 + lwk_contracts/src/known.rs | 79 ++ lwk_contracts/src/lib.rs | 28 + lwk_contracts/src/spend.rs | 859 ++++++++++++++++++ lwk_contracts/src/template.rs | 524 +++++++++++ lwk_contracts/templates/PIN.json | 20 + .../templates/faucet_drip/descriptor.json | 265 ++++++ .../templates/faucet_drip/faucet_drip.simf | 206 +++++ .../templates/faucet_drip/vectors.json | 350 +++++++ .../fixtures/one_key_as_v2/descriptor.json | 79 ++ .../fixtures/one_key_as_v2/one_key.simf | 25 + .../fixtures/one_key_as_v2/vectors.json | 162 ++++ .../templates/fixtures/refusals.json | 82 ++ .../templates/one_key/descriptor.json | 71 ++ lwk_contracts/templates/one_key/one_key.simf | 25 + lwk_contracts/templates/one_key/vectors.json | 115 +++ .../templates/one_key_exit/descriptor.json | 136 +++ .../templates/one_key_exit/one_key_exit.simf | 31 + .../templates/one_key_exit/vectors.json | 336 +++++++ lwk_contracts/tests/drip_regtest.rs | 654 +++++++++++++ lwk_contracts/tests/vectors.rs | 247 +++++ lwk_wasm/Cargo.toml | 2 + lwk_wasm/README.md | 6 +- lwk_wasm/build-web.sh | 39 + lwk_wasm/src/contract_engine.rs | 304 +++++++ lwk_wasm/src/lib.rs | 2 + lwk_wasm/tests/node/contract_vectors.js | 104 +++ 38 files changed, 5361 insertions(+), 26 deletions(-) create mode 100644 lwk_contracts/Cargo.toml create mode 100644 lwk_contracts/README.md create mode 100644 lwk_contracts/src/budget.rs create mode 100644 lwk_contracts/src/drip.rs create mode 100644 lwk_contracts/src/error.rs create mode 100644 lwk_contracts/src/hex.rs create mode 100644 lwk_contracts/src/known.rs create mode 100644 lwk_contracts/src/lib.rs create mode 100644 lwk_contracts/src/spend.rs create mode 100644 lwk_contracts/src/template.rs create mode 100644 lwk_contracts/templates/PIN.json create mode 100644 lwk_contracts/templates/faucet_drip/descriptor.json create mode 100644 lwk_contracts/templates/faucet_drip/faucet_drip.simf create mode 100644 lwk_contracts/templates/faucet_drip/vectors.json create mode 100644 lwk_contracts/templates/fixtures/one_key_as_v2/descriptor.json create mode 100644 lwk_contracts/templates/fixtures/one_key_as_v2/one_key.simf create mode 100644 lwk_contracts/templates/fixtures/one_key_as_v2/vectors.json create mode 100644 lwk_contracts/templates/fixtures/refusals.json create mode 100644 lwk_contracts/templates/one_key/descriptor.json create mode 100644 lwk_contracts/templates/one_key/one_key.simf create mode 100644 lwk_contracts/templates/one_key/vectors.json create mode 100644 lwk_contracts/templates/one_key_exit/descriptor.json create mode 100644 lwk_contracts/templates/one_key_exit/one_key_exit.simf create mode 100644 lwk_contracts/templates/one_key_exit/vectors.json create mode 100644 lwk_contracts/tests/drip_regtest.rs create mode 100644 lwk_contracts/tests/vectors.rs create mode 100755 lwk_wasm/build-web.sh create mode 100644 lwk_wasm/src/contract_engine.rs create mode 100644 lwk_wasm/tests/node/contract_vectors.js diff --git a/.github/workflows/cont_integration.yml b/.github/workflows/cont_integration.yml index 7f647067d..069eeba03 100644 --- a/.github/workflows/cont_integration.yml +++ b/.github/workflows/cont_integration.yml @@ -39,6 +39,23 @@ jobs: - run: "cargo test -p lwk_wollet --lib --no-default-features --features ark ark::" - run: cargo test -p lwk_wollet --no-default-features --features ark --test ark_regtest --no-run + # The contract engine: every golden vector and refusal of the templates it + # carries, and the budget rule. The regtest drip needs a sequentiad binary, + # so it is built here, not run; the wasm bindings are checked for wasm32. + contracts: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@1.85.0 + with: + targets: wasm32-unknown-unknown + - run: sudo apt-get update && sudo apt-get install -y libudev-dev clang llvm + - run: cargo test -p lwk_contracts + - run: cargo check -p lwk_wasm --target wasm32-unknown-unknown + env: + CC_wasm32_unknown_unknown: clang + AR_wasm32_unknown_unknown: llvm-ar + nix: runs-on: ubuntu-22.04 steps: diff --git a/CLAUDE.md b/CLAUDE.md index 3ab1b7a1b..702853b48 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,10 +41,15 @@ fail; treat those as expected fork behaviour rather than as something you broke. ## The WASM build, and who consumes it ```sh -cd lwk_wasm -wasm-pack build --target web --release # needs clang for the secp256k1 build +lwk_wasm/build-web.sh # wasm-pack build --target web --release, paths remapped ``` +The script remaps every build-machine path (home directory, checkout, cargo +caches) out of the `.wasm` and fails if one remains: a plain `wasm-pack +build` embeds them in panic locations, so the published file would name this +machine and differ from a build made elsewhere. It needs clang with a wasm32 +backend, for the secp256k1 and Simplicity C libraries. + `--target web` is not optional. The output lands in `lwk_wasm/pkg/` (gitignored, never committed) and is consumed by [`sequentia-web-wallet`](https://github.com/ConcatenaLabs/sequentia-web-wallet), diff --git a/Cargo.lock b/Cargo.lock index 210543bc1..38a3401f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -122,6 +122,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "amp2_mock" version = "0.18.0" @@ -222,6 +228,16 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "ariadne" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31beedec3ce83ae6da3a79592b3d8d7afd146a5b15bb9bb940279aced60faa89" +dependencies = [ + "unicode-width 0.1.14", + "yansi", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -322,7 +338,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c8d66485a3a2ea485c1913c4572ce0256067a5377ac8c75c4960e1cda98605f" dependencies = [ "bitcoin-internals 0.3.0", - "bitcoin_hashes 0.14.0", + "bitcoin_hashes 0.14.1", ] [[package]] @@ -427,7 +443,7 @@ dependencies = [ "bitcoin-internals 0.3.0", "bitcoin-io", "bitcoin-units", - "bitcoin_hashes 0.14.0", + "bitcoin_hashes 0.14.1", "hex-conservative 0.2.1", "hex_lit", "secp256k1 0.29.1", @@ -496,9 +512,9 @@ dependencies = [ [[package]] name = "bitcoin_hashes" -version = "0.14.0" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb18c03d0db0247e147a21a6faafd5a7eb851c743db062de72018b6b7e8e4d16" +checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b" dependencies = [ "bitcoin-io", "hex-conservative 0.2.1", @@ -769,6 +785,20 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "chumsky" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acc17a6284abccac6e50db35c1cee87f605474a72939b959a3a67d9371800efd" +dependencies = [ + "hashbrown 0.15.5", + "regex-automata 0.3.9", + "serde", + "stacker", + "unicode-ident", + "unicode-segmentation", +] + [[package]] name = "chunked_transfer" version = "1.5.0" @@ -1181,7 +1211,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec4f825369fc7134da70ca4040fddc8e03b80a46d249ae38d9c1c39b7b4476bf" dependencies = [ - "bitcoin_hashes 0.14.0", + "bitcoin_hashes 0.14.1", "tokio", ] @@ -1438,6 +1468,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1570,7 +1606,7 @@ version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" dependencies = [ - "unicode-width", + "unicode-width 0.2.2", ] [[package]] @@ -1696,6 +1732,17 @@ dependencies = [ "ahash", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "hashbrown" version = "0.16.0" @@ -2661,6 +2708,18 @@ dependencies = [ "testcontainers", ] +[[package]] +name = "lwk_contracts" +version = "0.18.1" +dependencies = [ + "lwk_signer", + "sequentia-contracts", + "serde", + "serde_json", + "simplicityhl 0.7.2", + "thiserror 2.0.18", +] + [[package]] name = "lwk_hwi" version = "0.10.0" @@ -2759,7 +2818,7 @@ dependencies = [ "lwk_wollet", "serde", "serde_json", - "simplicityhl", + "simplicityhl 0.4.1", "thiserror 2.0.18", "uuid", ] @@ -2806,6 +2865,7 @@ dependencies = [ "console_error_panic_hook", "lwk_boltz", "lwk_common", + "lwk_contracts", "lwk_jade", "lwk_ledger", "lwk_signer", @@ -3347,6 +3407,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psm" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e944464ec8536cd1beb0bbfd96987eb5e3b72f2ecdafdc5c769a37f1fa2ae1f" +dependencies = [ + "cc", +] + [[package]] name = "pulldown-cmark" version = "0.9.6" @@ -3534,8 +3603,19 @@ checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" dependencies = [ "aho-corasick", "memchr", - "regex-automata", - "regex-syntax", + "regex-automata 0.4.13", + "regex-syntax 0.8.8", +] + +[[package]] +name = "regex-automata" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59b23e92ee4318893fa3fe3e6fb365258efbfe6ac6ab30f090cdcbb7aa37efa9" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax 0.7.5", ] [[package]] @@ -3546,7 +3626,7 @@ checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" dependencies = [ "aho-corasick", "memchr", - "regex-syntax", + "regex-syntax 0.8.8", ] [[package]] @@ -3555,6 +3635,12 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8d942b98df5e658f56f20d592c7f868833fe38115e65c33003d8cd224b0155da" +[[package]] +name = "regex-syntax" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbb5fb1acd8a1a18b3dd5be62d25485eb770e05afb408a9627d14d451bae12da" + [[package]] name = "regex-syntax" version = "0.8.8" @@ -3973,14 +4059,26 @@ checksum = "0f7d95a54511e0c7be3f51e8867aa8cf35148d7b9445d44de2f943e2b206e749" [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" dependencies = [ "serde", "serde_core", ] +[[package]] +name = "sequentia-contracts" +version = "0.1.0" +source = "git+https://github.com/ConcatenaLabs/sequentia-contracts?rev=ad8ed18#ad8ed18ffce61bbc6feeaf19599775848137b745" +dependencies = [ + "secp256k1 0.29.1", + "serde", + "serde_json", + "simplicityhl 0.7.2", + "toml 0.8.23", +] + [[package]] name = "serde" version = "1.0.228" @@ -4196,7 +4294,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70e57bd4d84853974a212eab24ed89da54f49fbccf5e33e93bcd29f0a6591cd5" dependencies = [ "bitcoin", - "bitcoin_hashes 0.14.0", + "bitcoin_hashes 0.14.1", "byteorder", "elements 0.25.3", "getrandom 0.2.16", @@ -4204,7 +4302,24 @@ dependencies = [ "hex-conservative 0.2.1", "miniscript", "santiago", - "simplicity-sys", + "simplicity-sys 0.6.2", +] + +[[package]] +name = "simplicity-lang" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13ed081e3046d66c146d7201bcbf3b655ca3436cb83f6efc26d7895bd2b79d06" +dependencies = [ + "bitcoin", + "bitcoin_hashes 0.14.1", + "byteorder", + "elements 0.25.3", + "getrandom 0.2.16", + "ghost-cell", + "hex-conservative 0.2.1", + "miniscript", + "simplicity-sys 0.7.0", ] [[package]] @@ -4213,7 +4328,17 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3401ee7331f183a5458c0f5a4b3d5d00bde0fd12e2e03728c537df34efae289" dependencies = [ - "bitcoin_hashes 0.14.0", + "bitcoin_hashes 0.14.1", + "cc", +] + +[[package]] +name = "simplicity-sys" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96d1ec5477c7650b8ef511aa56dccb28f2e8cdb6e87f260ecffdaf0ebfef2d3b" +dependencies = [ + "bitcoin_hashes 0.14.1", "cc", ] @@ -4233,7 +4358,27 @@ dependencies = [ "pest_derive", "serde", "serde_json", - "simplicity-lang", + "simplicity-lang 0.7.0", +] + +[[package]] +name = "simplicityhl" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae77168fa3367d234b11bb3999cbd6e04d7180db0b9e11f83de259528e3b23d1" +dependencies = [ + "ariadne", + "base64 0.21.7", + "chumsky", + "clap", + "either", + "getrandom 0.2.16", + "itertools 0.13.0", + "miniscript", + "semver", + "serde", + "serde_json", + "simplicity-lang 0.8.0", ] [[package]] @@ -4297,6 +4442,19 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "stacker" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.60.2", +] + [[package]] name = "static_assertions" version = "1.1.0" @@ -4817,6 +4975,18 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unicode-width" version = "0.2.2" @@ -5639,6 +5809,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "yoke" version = "0.8.0" diff --git a/Cargo.toml b/Cargo.toml index 4da482e8f..1e81a0f1f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ members = [ "lwk_boltz", "lwk_payment_instructions", "lwk_simplicity", + "lwk_contracts", "amp2_mock", ] resolver = "2" diff --git a/README.md b/README.md index 1f3084f72..3cdf0a0f1 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,7 @@ Consumers of SWK: ## Workspace crates Sequentia changes are concentrated in `lwk_common`, `lwk_wollet`, `lwk_signer`, -`lwk_wasm`, and the vendored `rust-elements`; the other crates are upstream LWK, apart from +`lwk_wasm`, `lwk_contracts` (the fork's own crate), and the vendored `rust-elements`; the other crates are upstream LWK, apart from a `Contract::from_parts` call site in `lwk_app` and `lwk_bindings` and one Sequentia example in `lwk_simplicity`. @@ -124,10 +124,11 @@ Sequentia example in `lwk_simplicity`. | `lwk_wollet` | The watch-only wallet core (CT descriptors, scanning, balances, PSET create/finalize). Sequentia additions: explicit-output handling, any-asset fees + RBF/CPFP rescue, staking output and two-step unbonding, SeqDEX swap/HTLC builders, SeqOB covenant fill/refund, staking-pool delegation, CoinJoin input signing, the OpenAMP client (feature `openamp`), adaptor signatures (feature `adaptor`), the Arca leaf module (feature `ark`, `src/ark/`), and the whole Bitcoin parent-chain module (`src/btc/`). | | `lwk_common` | Shared types. Sequentia addition: `Network::sequentia_testnet()` and Sequentia address parameters. | | `lwk_signer` | Software signer (BIP39 mnemonic to PSET signatures); signs Sequentia PSETs as-is. Sequentia additions: `sign_tapscript`, a script-path signature at any leaf version over the Elements signature hash, for covenant leaves whose signature opcodes check the wallet's key, under `SIGHASH_DEFAULT` or `SIGHASH_ALL` unless the caller names another type; `sign_csfs`, a message signature for `OP_CHECKSIGFROMSTACK` over Arca's three messages, which rebuilds the digest from the message's fields, refuses a mismatch or another chain than the wallet's, and caps what a rebind leaves to whoever broadcasts. Each has a `describe` for the approval screen. | -| `lwk_wasm` | WebAssembly bindings (wasm-bindgen). Sequentia additions: `Network.sequentiaTestnet()`, `BtcWallet`, the `xchain*` HTLC helpers, SeqDEX bindings, `buildCovenantFillTx` / `buildCovenantRefundTx`, delegation (`buildDelegationCreateTx`, `buildDelegationSpendTx`, `findDelegationRecords`), unbonding (`buildUnbondTx`, `buildUnbondClaimTx`), `coinjoinSignInputs` / `coinjoinUnblindOutputs`, the `Openamp` client and enclave helpers, `adaptor*`, `Signer.signTapscript` / `tapscriptSighash` / `tapscriptDescribe`, `Signer.signCsfs` / `csfsDigest` / `csfsDescribe`, Arca leaves (`ArkVerifier`, `ArkStore`, `arkParseRecord`, `Signer.arkLeafKey`), staking and any-asset-fee bindings. | +| `lwk_wasm` | WebAssembly bindings (wasm-bindgen). Sequentia additions: `Network.sequentiaTestnet()`, `BtcWallet`, the `xchain*` HTLC helpers, SeqDEX bindings, `buildCovenantFillTx` / `buildCovenantRefundTx`, delegation (`buildDelegationCreateTx`, `buildDelegationSpendTx`, `findDelegationRecords`), unbonding (`buildUnbondTx`, `buildUnbondClaimTx`), the contract engine (`ContractTemplate`, `ContractInstance`, `ContractSpend`), `coinjoinSignInputs` / `coinjoinUnblindOutputs`, the `Openamp` client and enclave helpers, `adaptor*`, `Signer.signTapscript` / `tapscriptSighash` / `tapscriptDescribe`, `Signer.signCsfs` / `csfsDigest` / `csfsDescribe`, Arca leaves (`ArkVerifier`, `ArkStore`, `arkParseRecord`, `Signer.arkLeafKey`), staking and any-asset-fee bindings. | | `lwk_bindings` | UniFFI bindings (Python, Kotlin, Swift, C#, Go, C++). Upstream API surface (no Sequentia network exposed yet); only the `Contract::from_parts` call changed. | | `lwk_cli` / `lwk_app` / `lwk_rpc_model` / `lwk_tiny_jrpc` | JSON-RPC wallet server and CLI client. Upstream apart from `lwk_app`'s `Contract::from_parts` call: no `sequentia` network selector yet (networks: liquid, liquid-testnet, regtest). | | `lwk_jade`, `lwk_ledger`, `lwk_hwi` | Hardware-signer support (upstream; not wired to Sequentia flows). | +| `lwk_contracts` | The contract engine (a crate of this fork): reads a `sequentia-contracts` descriptor with that repository's reader and pinned compiler, recomputes an instance's address and leaves, lists its paths and what each needs, builds the spend of a path, runs its program against the final transaction, and signs with a contract key. See `lwk_contracts/README.md`. | | `lwk_simplicity` | Upstream Simplicity utilities plus one Sequentia example, `examples/live_covenant.rs`, which derives and spends a Simplicity leaf on the live testnet. | | `lwk_boltz`, `lwk_payment_instructions`, `amp2_mock`, `lwk_containers`, `lwk_test_util` | Upstream LWK crates (Boltz swaps, payment-URI parsing, test infrastructure). Unmodified on this branch. | | `rust-elements` (vendored, not a workspace member) | Fork of the `elements` crate wired in via `[patch.crates-io]`, with a `sequentia` cargo feature for anchored headers, issuance denomination, and `tb`/`tsqb` address parsing. | @@ -180,14 +181,18 @@ lwk_wollet = { features = ["btc-async"] } # wasm / async apps `TxBuilder.feeAsset()` / `addStakeOutput()` / `addExplicitRecipient()` / `addRecordAuthorization()`, `Signer.stakerPublicKey()`, `buildCovenantFillTx()`, `buildDelegationCreateTx()` / `buildDelegationSpendTx()`, `buildUnbondTx()` / -`buildUnbondClaimTx()`, `coinjoinSignInputs()`, the `Openamp` client, and the -`adaptor*` functions. +`buildUnbondClaimTx()`, `coinjoinSignInputs()`, the `Openamp` client, the +`adaptor*` functions, and the contract engine (`ContractTemplate`, +`ContractInstance`, `ContractSpend`). ```sh -cd lwk_wasm -wasm-pack build --target web --release # needs clang for the secp256k1 build +lwk_wasm/build-web.sh # wasm-pack build --target web --release; needs clang ``` +The script remaps the build machine's paths out of the `.wasm` and fails if +one remains, so a published package names no machine and two builds of one +commit agree. + The fork is not published to npm; the `lwk_wasm` npm package is upstream LWK. Consume the fork by building `pkg/` yourself (this is what [sequentia-web-wallet](https://github.com/ConcatenaLabs/sequentia-web-wallet) diff --git a/SEQUENTIA.md b/SEQUENTIA.md index 2cef331b8..7bb9bc3e0 100644 --- a/SEQUENTIA.md +++ b/SEQUENTIA.md @@ -449,6 +449,22 @@ Changes by file: - `examples/rescue_test.rs`: live functional test of bump/replace/CPFP against the testnet (needs a funded wallet). +## `lwk_contracts` + +A crate of this fork: the contract engine for templates written with +[`sequentia-contracts`](https://github.com/ConcatenaLabs/sequentia-contracts) +(descriptor versions 1 and 2). It depends on that repository's reader and its +pinned compiler, `simplicityhl` 0.7.2 (with `simplicity-lang` 0.8.0, whose C +library carries its own symbol prefix, so it links beside the 0.7.0 that +`lwk_simplicity` uses). `lwk_contracts/README.md` describes what it does; +`templates/PIN.json` names the revision of the templates it carries. + +Taking these libraries moved three entries of `Cargo.lock`: `bitcoin_hashes` +0.14.0 to 0.14.1 and `semver` 1.0.27 to 1.0.28, which they require, and `psm` +held at 0.1.26, the last release that builds with the pinned Rust 1.85. The +Simplex SDK itself needs Rust 1.87, so the engine uses its libraries directly +and ports its budget rule. + ## `lwk_wasm` Built with `lwk_wollet` features `sequentia`, `openamp`, `adaptor`, @@ -465,6 +481,14 @@ The fork is not published to npm; consumers build `pkg/` with `wasm-pack`. - `src/xchain.rs`: the `xchain*` helper functions (secret and key derivation, BTC HTLC, Sequentia redeem script, Sequentia claim, BTC claim and refund) wrapping `lwk_wollet::btc::xchain` for the web wallet. +- `src/contract_engine.rs`: the contract engine (`lwk_contracts`): + `ContractTemplate` (a descriptor and its resolved sources, checked; the + carried templates by hash, `knownList`), `ContractInstance` (the output, + address, derivation and paths of an instance; `planDrip` for the faucet + drip covenant) and `ContractSpend` (the spend of a path, its outputs' + roles, its locks). Values cross as JSON text. +- `build-web.sh`: the browser build (`wasm-pack build --target web + --release`) with every build-machine path remapped, failing if one remains. - `src/seqdex_swap.rs`: `SwapRequest` (same-chain SeqDEX swap proposal). - `src/seqdex_htlc.rs`: `generateSwapSecret`, `htlcKeypair`, `buildSeqHtlcRedeemScript`, `buildSeqHtlcClaimTx`, `buildSeqHtlcRefundTx`. @@ -609,6 +633,7 @@ purposes (BIP44 `44'`, BIP49 `49'`, BIP84 `84'`, BIP86 `86'`, AMP2 `87'`): | `m/3/0` | The SeqDEX HTLC key (`htlcKeypair`), and the cross-chain swap's Sequentia claim key in its legacy relative mode; the cross-chain swap's own keys are at `m/84'/1'/0'/2/0` (BTC refund), `/3/0` (Sequentia claim) and `/4/0` (BTC claim) | | `m/5/0` | The OpenAMP enclave key, which signs raw 32-byte digests (`openampSignSighash`) | | `m/6'/'/c1'/c2'/c3'/c4'` | Arca leaf keys, one per leaf, from the leaf's owner nonce (`ark::keys`) | +| `m/8383'/'/0'/...` | Contract keys (`lwk_contracts`): the keys a contract template names, `0/0` by default; `` is 1776 on mainnet and 1 elsewhere. The account and its default are the Simplex fork's, so a key made there signs here | The SeqLN device keys of the web wallet and Ambra are under `m/1017'`. A new role takes the next free number here; a key that signs raw digests never diff --git a/lwk_contracts/Cargo.toml b/lwk_contracts/Cargo.toml new file mode 100644 index 000000000..c4f97de0c --- /dev/null +++ b/lwk_contracts/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "lwk_contracts" +version = "0.18.1" +edition = "2021" +description = "Sequentia Wallet Kit - the contract engine: descriptors, addresses, paths and spends of contract templates" +license = "MIT OR BSD-2-Clause" + +[dependencies] +lwk_signer = { version = "0.18.1", default-features = false } +sequentia-contracts = { git = "https://github.com/ConcatenaLabs/sequentia-contracts", rev = "ad8ed18" } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +thiserror = { workspace = true } +simplicityhl = "=0.7.2" diff --git a/lwk_contracts/README.md b/lwk_contracts/README.md new file mode 100644 index 000000000..8e2e8c3c9 --- /dev/null +++ b/lwk_contracts/README.md @@ -0,0 +1,58 @@ +# lwk_contracts: the contract engine + +The kit's engine for Sequentia contracts written with +[`sequentia-contracts`](https://github.com/ConcatenaLabs/sequentia-contracts). +A **template** is a descriptor (version 1 or 2) and the source of each of its +Simplicity programs; an **instance** is the template's parameter and slot +values on one chain. The engine: + +- reads a descriptor with `sequentia-contracts`' own reader and checks it with + that repository's pinned compiler, from source texts it is handed (each + with its helper includes resolved, as `seqc expand` prints it), so it runs + in a browser with no file system; +- recomputes an instance's output, its address on a chain and each leaf's + control block, exactly as the golden vectors and the Rust, Python, + JavaScript and Go readers do; +- lists the template's spending paths and what each needs: the witness values + and their sources, the key that signs, and the relative lock or lock time a + tapscript leaf checks; +- builds the spend of a chosen path from a request, and refuses an output the + request mislabels (a "return to the contract" that pays another script, a + "payment to this wallet" that is not the wallet's), a confidential output, a + missing or second fee, and outputs that do not balance the coin; +- refuses a spend whose relative lock (BIP68) or lock time the chain would + refuse now, from the chain facts it is given; +- signs with a contract key (under `m/8383h/{coin}h/0h`, `0/0` by default), + only the one the path names; runs the program against the final + transaction, naming the source check that fails when it refuses; and pads + the witness with an annex when the program costs more than it earns under + the budget rule (`min(4 × witness bytes + 50, 4000050)` weight units). + +The faucet drip covenant (`sequentia/faucet-drip`) has a planner, `drip::plan`, +which computes a drip's sequence, successor and tier as its program checks +them. + +## The templates the kit carries + +`templates/` holds `sequentia/one-key`, `sequentia/one-key-exit` and +`sequentia/faucet-drip` with their vectors, the version 2 fixture and the +shared refusal corpus, copied from `sequentia-contracts` at the revision +`templates/PIN.json` names (with each file's SHA-256). The `.simf` files there +are the resolved texts. To move the pin, copy the files again from that +repository at the new revision (`seqc expand` for each source), rewrite +`PIN.json`, change the `rev` in `Cargo.toml` and `CONTRACTS_REVISION`, and +run the tests. + +## Tests + +```sh +cargo test -p lwk_contracts # vectors, refusals, budget +SEQUENTIA_BIN=/path/to/Sequentia/src cargo test -p lwk_contracts --test drip_regtest -- --nocapture +``` + +`tests/vectors.rs` reproduces every golden vector and makes every refusal of +the corpus for its reason. `tests/drip_regtest.rs` starts a local +`elementsregtest` chain with Simplicity active and `-par=1`, drips from a +covenant, and forces three bad drips into blocks (before the interval, above +the tier, a successor that is not the covenant), asserting each refusal's +reason in the mempool and in the block. diff --git a/lwk_contracts/src/budget.rs b/lwk_contracts/src/budget.rs new file mode 100644 index 000000000..870697e46 --- /dev/null +++ b/lwk_contracts/src/budget.rs @@ -0,0 +1,107 @@ +//! The execution budget of a Simplicity spend, and the annex that raises it. +//! +//! The rule is the node's and the descriptor's `budget`: a spend may cost +//! `min(per_witness_byte × w + offset, max)` weight units, `w` the serialized +//! size of its input's witness stack. A program whose cost bound is above +//! what its witness earns is padded with an annex, the last witness item, +//! tagged `0x50`; a full signature hash commits to it, so the padding is +//! fixed before anything is signed. Ported from the Simplex fork's +//! `BudgetRule` (ConcatenaLabs/smplx, `crates/sdk/src/program/budget.rs`). + +use sequentia_contracts::descriptor::Budget; +use simplicityhl::simplicity::Cost; + +/// The first byte of a taproot annex (BIP 341). +pub const ANNEX_TAG: u8 = 0x50; + +/// The largest annex, tag included, a Sequentia node relays on a Simplicity spend. +pub const MAX_STANDARD_ANNEX: usize = 100_000; + +/// A cost bound in milli weight units. +pub fn cost_milliweight(cost: Cost) -> u64 { + cost.to_string() + .parse() + .expect("a cost prints as its milli weight units") +} + +/// The size of a witness stack as consensus serializes it. +pub fn serialized_size(stack: &[Vec]) -> usize { + compact_size_len(stack.len()) + + stack + .iter() + .map(|item| compact_size_len(item.len()) + item.len()) + .sum::() +} + +fn compact_size_len(n: usize) -> usize { + match n { + 0..=0xfc => 1, + 0xfd..=0xffff => 3, + 0x1_0000..=0xffff_ffff => 5, + _ => 9, + } +} + +/// The budget a witness stack earns, in weight units. +pub fn earned(rule: &Budget, stack: &[Vec]) -> u64 { + rule.earned(u64::try_from(serialized_size(stack)).unwrap_or(u64::MAX)) +} + +/// The smallest annex that lets a program of this cost run once appended to +/// `stack` (which carries none); `None` when the stack earns enough already. +pub fn padding(rule: &Budget, cost_mwu: u64, stack: &[Vec]) -> Result>, String> { + if cost_mwu > rule.max * 1000 { + return Err(format!( + "the program costs {cost_mwu} milli-WU, above the {} WU any spend can be given", + rule.max + )); + } + if cost_mwu <= earned(rule, stack) * 1000 { + return Ok(None); + } + let target = cost_mwu.div_ceil(1000).saturating_sub(rule.offset); + let needed = usize::try_from(target.div_ceil(rule.per_witness_byte)).unwrap_or(usize::MAX); + let without = serialized_size(stack) - compact_size_len(stack.len()); + let fixed = without + compact_size_len(stack.len() + 1); + let size_with = |len: usize| fixed + compact_size_len(len) + len; + let mut len = needed.saturating_sub(fixed + 9).max(1); + while size_with(len) < needed { + len += 1; + } + if len > MAX_STANDARD_ANNEX { + return Err(format!( + "the program costs {cost_mwu} milli-WU and needs a {len}-byte annex; a node relays at most {MAX_STANDARD_ANNEX}" + )); + } + let mut annex = vec![0; len]; + annex[0] = ANNEX_TAG; + Ok(Some(annex)) +} + +#[cfg(test)] +mod tests { + use super::*; + use sequentia_contracts::descriptor::SEQUENTIA_BUDGET; + + #[test] + fn padding_reaches_exactly_the_budget_needed() { + // S1.2's measured case: a witness of 1,369 bytes earns 5,526 WU; a + // program of 8,503,470 milli-WU needs the 742-byte annex, and 741 is short. + let stack = vec![vec![0u8; 1365]]; + assert_eq!(serialized_size(&stack), 1369); + assert_eq!(earned(&SEQUENTIA_BUDGET, &stack), 5526); + let annex = padding(&SEQUENTIA_BUDGET, 8_503_470, &stack) + .unwrap() + .unwrap(); + assert_eq!(annex.len(), 742); + assert_eq!(annex[0], ANNEX_TAG); + let mut padded = stack.clone(); + padded.push(annex.clone()); + assert!(earned(&SEQUENTIA_BUDGET, &padded) * 1000 >= 8_503_470); + let mut short = stack.clone(); + short.push(annex[..annex.len() - 1].to_vec()); + assert!(earned(&SEQUENTIA_BUDGET, &short) * 1000 < 8_503_470); + assert_eq!(padding(&SEQUENTIA_BUDGET, 1000, &stack).unwrap(), None); + assert!(padding(&SEQUENTIA_BUDGET, 4_000_051_000, &stack).is_err()); + } +} diff --git a/lwk_contracts/src/drip.rs b/lwk_contracts/src/drip.rs new file mode 100644 index 000000000..123743539 --- /dev/null +++ b/lwk_contracts/src/drip.rs @@ -0,0 +1,119 @@ +//! The faucet drip covenant's spends (`sequentia/faucet-drip`), planned from +//! its parameters the way its program checks them. The program run against +//! the final transaction remains the authority; these checks only name the +//! rule a request breaks before the program is asked. + +use crate::error::Error; +use crate::spend::{CoinRequest, OutputRequest, SpendRequest}; +use crate::template::{Contract, SEQUENCE_TIME_FLAG}; + +/// The drip's path and the recovery's, as the template names them. +pub const DRIP_PATH: &str = "drip"; +pub const RECOVER_PATH: &str = "recover"; + +/// The most a drip may pay from a reserve of `reserve`, as the program computes it. +pub fn tier_for(contract: &Contract, reserve: u64) -> Result { + let tiers = [ + ( + contract.value_u64("TIER1_FLOOR")?, + contract.value_u64("TIER1_MAX")?, + ), + ( + contract.value_u64("TIER2_FLOOR")?, + contract.value_u64("TIER2_MAX")?, + ), + ( + contract.value_u64("TIER3_FLOOR")?, + contract.value_u64("TIER3_MAX")?, + ), + (0, contract.value_u64("TIER4_MAX")?), + ]; + Ok(tiers + .into_iter() + .find(|(floor, _)| reserve >= *floor) + .map_or(0, |(_, max)| max)) +} + +/// The input sequence a drip carries: the interval, in units of 512 seconds. +pub fn drip_sequence(contract: &Contract) -> Result { + Ok(SEQUENCE_TIME_FLAG + | u32::try_from(contract.value_u64("INTERVAL")?) + .map_err(|e| Error::Spend(e.to_string()))?) +} + +/// A drip of `amount` from the reserve `coin` to `to` (an address), paying +/// `fee` in the reserve's asset; the rest returns to the covenant as output 0. +pub fn plan( + contract: &Contract, + coin: &CoinRequest, + to_address: &str, + amount: u64, + fee: u64, +) -> Result { + if contract.template.hash() != crate::known::FAUCET_DRIP { + return Err(Error::Spend("not a faucet drip covenant".into())); + } + let asset_internal = contract.value_bytes("ASSET")?; + let asset: String = crate::hex::hex(&asset_internal.iter().rev().copied().collect::>()); + if coin.asset != asset { + return Err(Error::Spend(format!( + "the reserve holds {}, the covenant drips {asset}", + coin.asset + ))); + } + let max = tier_for(contract, coin.amount)?; + if amount > max { + return Err(Error::Spend(format!( + "a drip of {amount} is above the {max} a reserve of {} may pay", + coin.amount + ))); + } + let cap = contract.value_u64("FEE_CAP")?; + if fee > cap { + return Err(Error::Spend(format!( + "a fee of {fee} is above the covenant's cap of {cap}" + ))); + } + let rest = coin + .amount + .checked_sub(amount) + .and_then(|x| x.checked_sub(fee)) + .ok_or_else(|| Error::Spend("the reserve holds less than the drip and its fee".into()))?; + Ok(SpendRequest { + path: DRIP_PATH.into(), + coin: coin.clone(), + sequence: Some(drip_sequence(contract)?), + lock_time: None, + outputs: vec![ + OutputRequest { + to: "contract".into(), + address: None, + script: Some(contract.derived.script_pubkey.clone()), + asset: asset.clone(), + amount: rest, + }, + OutputRequest { + to: "pay".into(), + address: Some(to_address.into()), + script: None, + asset: asset.clone(), + amount, + }, + OutputRequest { + to: "fee".into(), + address: None, + script: None, + asset, + amount: fee, + }, + ], + spender: Default::default(), + next_slots: None, + }) +} + +/// The fee for `vsize` virtual bytes at `rate_per_kvb` atoms of the fee +/// asset per 1,000 virtual bytes, rounded up. +pub fn fee_for(vsize: u64, rate_per_kvb: u64) -> u64 { + (vsize * rate_per_kvb).div_ceil(1000) +} diff --git a/lwk_contracts/src/error.rs b/lwk_contracts/src/error.rs new file mode 100644 index 000000000..e2d83fdc6 --- /dev/null +++ b/lwk_contracts/src/error.rs @@ -0,0 +1,27 @@ +//! The engine's errors. Each says why, in words a wallet can show. + +/// Why the engine refused. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum Error { + /// The descriptor breaks the specification, or its sources do not give it. + #[error("descriptor refused: {0}")] + Descriptor(String), + /// The instance does not fit the template. + #[error("instance refused: {0}")] + Instance(String), + /// The spend cannot be built as asked. + #[error("spend refused: {0}")] + Spend(String), + /// The contract's program refuses the transaction. + #[error("the contract's program refuses this transaction: {0}")] + Program(String), + /// The engine cannot say what an input or output of the transaction does. + #[error("refused: {0}")] + Unaccounted(String), + /// The chain would refuse the transaction now. + #[error("refused: {0}")] + Chain(String), + /// A five-point signing rule does not hold. + #[error("not signed: {0}")] + Signing(String), +} diff --git a/lwk_contracts/src/hex.rs b/lwk_contracts/src/hex.rs new file mode 100644 index 000000000..0b97873ab --- /dev/null +++ b/lwk_contracts/src/hex.rs @@ -0,0 +1,34 @@ +//! Lowercase hex of exact width. + +/// Lowercase hex. +pub fn hex(bytes: &[u8]) -> String { + use std::fmt::Write; + bytes.iter().fold(String::new(), |mut s, b| { + let _ = write!(s, "{b:02x}"); + s + }) +} + +/// Bytes from lowercase hex of exactly `width` bytes. +pub fn unhex(s: &str, width: usize) -> Result, String> { + if s.len() != 2 * width { + return Err(format!("{s:?} is not {width} bytes of hex")); + } + if !s + .bytes() + .all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)) + { + return Err(format!("{s:?} is not lowercase hex")); + } + (0..width) + .map(|i| u8::from_str_radix(&s[2 * i..2 * i + 2], 16).map_err(|e| e.to_string())) + .collect() +} + +/// Bytes from lowercase hex of any even length. +pub fn unhex_any(s: &str) -> Result, String> { + if s.len() % 2 != 0 { + return Err(format!("{s:?} has an odd length")); + } + unhex(s, s.len() / 2) +} diff --git a/lwk_contracts/src/known.rs b/lwk_contracts/src/known.rs new file mode 100644 index 000000000..5a2949772 --- /dev/null +++ b/lwk_contracts/src/known.rs @@ -0,0 +1,79 @@ +//! The templates this kit knows: copies of `sequentia-contracts`' published +//! templates at the revision `templates/PIN.json` names, each source with its +//! helper includes resolved (`seqc expand`), so the engine checks them with +//! no file system. A wallet's list of known templates starts here. + +use std::collections::BTreeMap; + +use crate::error::Error; +use crate::template::Template; + +/// A template the kit carries. +pub struct KnownTemplate { + /// Its directory under `templates/`. + pub dir: &'static str, + pub hash: &'static str, + pub descriptor: &'static str, + /// Source name and resolved text of each Simplicity leaf. + pub sources: &'static [(&'static str, &'static str)], + pub vectors: &'static str, +} + +/// `sequentia/one-key`, version 1. +pub const ONE_KEY: &str = "ad33258c1eb94ba8a748cde489498fda5b80a1026839c25bca20b3d3e77367a7"; +/// `sequentia/one-key-exit`, version 1. +pub const ONE_KEY_EXIT: &str = "c8c53a337fd5f6af1c0ae6ccb30690adf3850265a3c135384082b63133d83c42"; +/// `sequentia/faucet-drip`, version 1. +pub const FAUCET_DRIP: &str = "12986f202fbfb850f7699c5d5188f261f276de6c7038142f0a28bbb672b5af34"; + +pub const KNOWN: &[KnownTemplate] = &[ + KnownTemplate { + dir: "one_key", + hash: ONE_KEY, + descriptor: include_str!("../templates/one_key/descriptor.json"), + sources: &[( + "one_key.simf", + include_str!("../templates/one_key/one_key.simf"), + )], + vectors: include_str!("../templates/one_key/vectors.json"), + }, + KnownTemplate { + dir: "one_key_exit", + hash: ONE_KEY_EXIT, + descriptor: include_str!("../templates/one_key_exit/descriptor.json"), + sources: &[( + "one_key_exit.simf", + include_str!("../templates/one_key_exit/one_key_exit.simf"), + )], + vectors: include_str!("../templates/one_key_exit/vectors.json"), + }, + KnownTemplate { + dir: "faucet_drip", + hash: FAUCET_DRIP, + descriptor: include_str!("../templates/faucet_drip/descriptor.json"), + sources: &[( + "faucet_drip.simf", + include_str!("../templates/faucet_drip/faucet_drip.simf"), + )], + vectors: include_str!("../templates/faucet_drip/vectors.json"), + }, +]; + +impl KnownTemplate { + pub fn sources(&self) -> BTreeMap { + self.sources + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect() + } + + /// The template, checked as any other. + pub fn template(&self) -> Result { + Template::new(self.descriptor, &self.sources()) + } +} + +/// A known template, by hash. +pub fn known(hash: &str) -> Option<&'static KnownTemplate> { + KNOWN.iter().find(|k| k.hash == hash) +} diff --git a/lwk_contracts/src/lib.rs b/lwk_contracts/src/lib.rs new file mode 100644 index 000000000..a294785f8 --- /dev/null +++ b/lwk_contracts/src/lib.rs @@ -0,0 +1,28 @@ +//! The contract engine of the Sequentia Wallet Kit. +//! +//! A contract is a template (a descriptor of `sequentia-contracts`, read with +//! that repository's own reader and checked with its pinned compiler) and an +//! instance (the template's parameter and slot values, and a chain). The +//! engine recomputes an instance's output, lists its spending paths and what +//! each needs, builds the spend of a chosen path, runs the path's program +//! against the final transaction, pads it under the budget rule, and signs it +//! with a key reserved for contracts. +//! +//! `templates/` holds the templates the kit carries, copied from +//! `sequentia-contracts` at the revision `templates/PIN.json` names. + +pub mod budget; +pub mod drip; +pub mod error; +pub mod hex; +pub mod known; +pub mod spend; +pub mod template; + +pub use error::Error; +pub use known::{known, KnownTemplate, KNOWN}; +pub use spend::{ChainFacts, CoinRequest, OutputRequest, Spend, SpendRequest}; +pub use template::{Contract, Instance, PathInfo, Template}; + +/// The `sequentia-contracts` revision the reader and the known templates come from. +pub const CONTRACTS_REVISION: &str = "ad8ed18"; diff --git a/lwk_contracts/src/spend.rs b/lwk_contracts/src/spend.rs new file mode 100644 index 000000000..fb8626e90 --- /dev/null +++ b/lwk_contracts/src/spend.rs @@ -0,0 +1,859 @@ +//! The spend of one path of a contract: built from a request, run against its +//! final transaction, padded under the budget rule, and finalized. + +use std::collections::BTreeMap; +use std::str::FromStr; +use std::sync::Arc; + +use lwk_signer::tapscript::ScriptPathSpend; +use lwk_signer::SwSigner; +use serde::{Deserialize, Serialize}; +use simplicityhl::elements::bitcoin::bip32::DerivationPath; +use simplicityhl::elements::confidential::{Asset, Nonce, Value as ConfValue}; +use simplicityhl::elements::hashes::Hash; +use simplicityhl::elements::schnorr::Keypair; +use simplicityhl::elements::secp256k1_zkp::{Message, Secp256k1, XOnlyPublicKey}; +use simplicityhl::elements::sighash::SchnorrSighashType; +use simplicityhl::elements::taproot::ControlBlock; +use simplicityhl::elements::{ + AssetId, BlockHash, LockTime, OutPoint, Script, Sequence, Transaction, TxIn, TxInWitness, + TxOut, TxOutWitness, Txid, +}; +use simplicityhl::simplicity::jet::elements::{ElementsEnv, ElementsUtxo}; +use simplicityhl::simplicity::BitMachine; +use simplicityhl::str::WitnessName; +use simplicityhl::{Value as HlValue, WitnessValues}; + +use crate::budget::{self, ANNEX_TAG}; +use crate::error::Error; +use crate::hex::{hex, unhex_any}; +use crate::template::{Contract, PathInfo, SEQUENCE_DISABLE_FLAG, SEQUENCE_TIME_FLAG}; + +/// The purpose number of contract keys: the account `m/8383h/{coin}h/0h`, +/// apart from the wallet's funding keys, so a contract key never answers for +/// a wallet coin nor a wallet key for a contract. The Simplex fork settled it +/// (`CONTRACT_KEY_PURPOSE`); the kit uses the same. +pub const CONTRACT_KEY_PURPOSE: u32 = 8383; + +/// The contract account's path on a chain: `m/8383h/1776h/0h` on mainnet, +/// `m/8383h/1h/0h` elsewhere. +pub fn contract_account(mainnet: bool) -> String { + format!( + "m/{CONTRACT_KEY_PURPOSE}h/{}h/0h", + if mainnet { 1776 } else { 1 } + ) +} + +/// The default contract key's path: `0/0` under the contract account. +pub fn default_contract_key_path(mainnet: bool) -> String { + format!("{}/0/0", contract_account(mainnet)) +} + +/// The contract coin being spent, as the chain holds it. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct CoinRequest { + pub txid: String, + pub vout: u32, + /// The output's script, hex. + pub script_pubkey: String, + /// The asset id, display hex (as an RPC prints it). + pub asset: String, + pub amount: u64, +} + +/// One output of the spend, and what the request says it does. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct OutputRequest { + /// `contract` (back to this contract, or to its next state), `wallet` + /// (one of the wallet's own scripts), `pay` (anyone, named here) or `fee`. + pub to: String, + /// The recipient's unblinded address, for `pay` and `wallet`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub address: Option, + /// Or the recipient's script, hex. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub script: Option, + /// The asset id, display hex. + pub asset: String, + pub amount: u64, +} + +/// What a wallet or a site asks the engine to build. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct SpendRequest { + pub path: String, + pub coin: CoinRequest, + /// The input's sequence. Defaults to the value of the path's relative-lock + /// parameter when the leaf has one, else to no relative lock. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sequence: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub lock_time: Option, + pub outputs: Vec, + /// Values of `spender` witness entries, by name, hex of their width. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub spender: BTreeMap, + /// The slots of the contract's next state, when an output returns to it in another state. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub next_slots: Option>, +} + +impl SpendRequest { + pub fn parse(text: &str) -> Result { + serde_json::from_str(text).map_err(|e| Error::Spend(format!("request: {e}"))) + } +} + +/// What the chain says about the coin and the tip, for its locks. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct ChainFacts { + pub tip_height: u64, + /// The tip's median time past. + pub tip_median_time: u64, + /// The height of the block holding the coin; `None` while unconfirmed. + #[serde(default)] + pub coin_height: Option, + /// The median time past of the block before the coin's, from which BIP68 + /// counts a time lock. + #[serde(default)] + pub coin_start_median_time: Option, +} + +/// The chain a spend is for. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Chain { + pub genesis: BlockHash, + pub mainnet: bool, +} + +/// One output, classified. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct OutputRole { + pub index: usize, + /// `contract`, `contract next state`, `wallet`, `pay` or `fee`. + pub role: String, + pub script_pubkey: String, + pub asset: String, + pub amount: u64, +} + +/// A built spend: the transaction, the leaf, and what each output does. +#[derive(Debug, Clone)] +pub struct Spend { + pub contract: Arc, + pub path: PathInfo, + pub chain: Chain, + pub coin: TxOut, + pub outpoint: OutPoint, + /// The transaction with an empty witness. + pub tx: Transaction, + pub outputs: Vec, + pub spender: BTreeMap, + pub facts: Option, +} + +/// A finalized spend. +#[derive(Debug, Clone)] +pub struct Finalized { + pub tx: Transaction, + /// Program cost bound, in milli weight units; 0 for a tapscript leaf. + pub cost_mwu: u64, + /// What the witness earns, in weight units; 0 for a tapscript leaf. + pub budget_wu: u64, + /// The annex, tag included, when padding was needed. + pub annex_bytes: usize, +} + +fn display_asset(asset: &str) -> Result { + AssetId::from_str(asset).map_err(|e| Error::Spend(format!("asset {asset}: {e}"))) +} + +impl Spend { + /// Builds the transaction a request describes, refusing anything the + /// engine cannot account for. + pub fn build( + contract: Arc, + chain: Chain, + request: &SpendRequest, + wallet_scripts: &[Script], + facts: Option, + ) -> Result { + let template = &contract.template; + let path = template + .paths() + .into_iter() + .find(|p| p.name == request.path) + .ok_or_else(|| { + Error::Spend(format!( + "the template has no path {:?}; its paths are {:?}", + request.path, + template + .paths() + .iter() + .map(|p| p.name.clone()) + .collect::>() + )) + })?; + if path.kind == "key" { + return Err(Error::Spend(format!( + "path {} spends by the internal key; the engine spends leaves only", + path.name + ))); + } + if let Some(g) = &contract.instance.genesis { + if *g != chain.genesis.to_string() { + return Err(Error::Spend(format!( + "the instance is for the chain with genesis {g}; this wallet is on {}", + chain.genesis + ))); + } + } else { + return Err(Error::Spend( + "the instance names no chain (genesis null), so a signature for it could be replayed on any chain that holds the same output".into(), + )); + } + + // The coin: the contract's script, recomputed here, and explicit. + let script = contract.script_pubkey(); + let coin_script = Script::from( + unhex_any(&request.coin.script_pubkey) + .map_err(|e| Error::Spend(format!("coin script: {e}")))?, + ); + if coin_script != script { + return Err(Error::Spend(format!( + "the coin's script {} is not the contract's, which the engine recomputes as {}", + request.coin.script_pubkey, + hex(script.as_bytes()) + ))); + } + let coin_asset = display_asset(&request.coin.asset)?; + let coin = TxOut { + asset: Asset::Explicit(coin_asset), + value: ConfValue::Explicit(request.coin.amount), + nonce: Nonce::Null, + script_pubkey: coin_script, + witness: TxOutWitness::default(), + }; + let outpoint = OutPoint::new( + Txid::from_str(&request.coin.txid).map_err(|e| Error::Spend(format!("txid: {e}")))?, + request.coin.vout, + ); + + // The sequence the leaf needs, when it says so. + let sequence = match (&path.sequence_param, request.sequence) { + (Some(p), None) => { + u32::try_from(contract.value_u64(p)?).map_err(|e| Error::Spend(e.to_string()))? + } + (Some(p), Some(s)) => { + let need = u32::try_from(contract.value_u64(p)?) + .map_err(|e| Error::Spend(e.to_string()))?; + if s != need { + return Err(Error::Spend(format!( + "path {} checks the relative lock {p} = {need:#x}; the request sets {s:#x}", + path.name + ))); + } + s + } + (None, Some(s)) => s, + (None, None) => 0xffff_fffd, + }; + let lock_time = match (&path.lock_time_param, request.lock_time) { + (Some(p), _) => { + u32::try_from(contract.value_u64(p)?).map_err(|e| Error::Spend(e.to_string()))? + } + (None, l) => l.unwrap_or(0), + }; + + // The outputs, each with what it does. + let next = match &request.next_slots { + Some(s) => Some(contract.with_slots(s.clone())?), + None => None, + }; + let mut outputs = Vec::new(); + let mut roles = Vec::new(); + for (index, o) in request.outputs.iter().enumerate() { + let asset = display_asset(&o.asset)?; + let out_script = if o.to == "fee" { + if o.address.is_some() || o.script.is_some() { + return Err(Error::Unaccounted(format!( + "output {index} is a fee, which pays no script" + ))); + } + Script::new() + } else { + output_script(index, o)? + }; + let role = classify( + index, + o, + &out_script, + &script, + next.as_ref(), + wallet_scripts, + )?; + if o.amount == 0 && role != "fee" { + return Err(Error::Unaccounted(format!("output {index} pays nothing"))); + } + outputs.push(TxOut { + asset: Asset::Explicit(asset), + value: ConfValue::Explicit(o.amount), + nonce: Nonce::Null, + script_pubkey: out_script.clone(), + witness: TxOutWitness::default(), + }); + roles.push(OutputRole { + index, + role, + script_pubkey: hex(out_script.as_bytes()), + asset: o.asset.clone(), + amount: o.amount, + }); + } + if roles.iter().filter(|r| r.role == "fee").count() != 1 { + return Err(Error::Unaccounted( + "a spend has exactly one fee output, in one asset".into(), + )); + } + // Every asset balances: what the coin holds is what the outputs pay. + let mut balance: BTreeMap = BTreeMap::new(); + *balance.entry(request.coin.asset.clone()).or_default() += i128::from(request.coin.amount); + for r in &roles { + *balance.entry(r.asset.clone()).or_default() -= i128::from(r.amount); + } + for (asset, left) in balance { + if left != 0 { + return Err(Error::Unaccounted(if left > 0 { + format!("{left} atoms of {asset} are in the coin and in no output") + } else { + format!( + "the outputs pay {} atoms of {asset} that the coin does not hold", + -left + ) + })); + } + } + + let tx = Transaction { + version: 2, + lock_time: LockTime::from_consensus(lock_time), + input: vec![TxIn { + previous_output: outpoint, + sequence: Sequence::from_consensus(sequence), + ..Default::default() + }], + output: outputs, + }; + for (name, value) in &request.spender { + if !path + .witness + .iter() + .any(|w| w.name == *name && w.source == "spender") + { + return Err(Error::Spend(format!( + "{name} is not a value the spender chooses on path {}", + path.name + ))); + } + unhex_any(value).map_err(|e| Error::Spend(format!("{name}: {e}")))?; + } + Ok(Spend { + contract, + path, + chain, + coin, + outpoint, + tx, + outputs: roles, + spender: request.spender.clone(), + facts, + }) + } + + /// The input's sequence. + pub fn sequence(&self) -> u32 { + self.tx.input[0].sequence.to_consensus_u32() + } + + /// Refuses a spend the chain would refuse now for its locks: a relative + /// lock (BIP68) that has not matured since the coin confirmed, or a lock + /// time not yet reached. + pub fn check_locks(&self) -> Result, Error> { + let seq = self.sequence(); + let relative = + self.tx.version >= 2 && seq & SEQUENCE_DISABLE_FLAG == 0 && seq & 0xffff != 0; + let absolute = self.tx.lock_time.to_consensus_u32() != 0 && seq != 0xffff_ffff; + if !relative && !absolute { + return Ok(None); + } + let facts = self.facts.as_ref().ok_or_else(|| { + Error::Chain("the spend carries a lock, and the chain's height and times were not given to check it".into()) + })?; + let mut said = Vec::new(); + if relative { + let n = u64::from(seq & 0xffff); + let height = facts.coin_height.ok_or_else(|| { + Error::Chain( + "the coin is not confirmed, so its relative lock has not started".into(), + ) + })?; + if seq & SEQUENCE_TIME_FLAG != 0 { + let start = facts.coin_start_median_time.ok_or_else(|| { + Error::Chain("the median time before the coin's block was not given".into()) + })?; + let due = start + n * 512; + if facts.tip_median_time < due { + return Err(Error::Chain(format!( + "the coin's relative lock of {n} × 512 s ends at median time {due}; the chain's median time is {}, so a block would refuse this spend for another {} (non-BIP68-final)", + facts.tip_median_time, + crate::template::show_duration(due - facts.tip_median_time) + ))); + } + said.push(format!( + "relative lock of {n} × 512 s passed at median time {due}" + )); + } else { + let due = height + n; + if facts.tip_height + 1 < due { + return Err(Error::Chain(format!( + "the coin's relative lock of {n} blocks ends at height {due}; the next block is {} (non-BIP68-final)", + facts.tip_height + 1 + ))); + } + said.push(format!( + "relative lock of {n} blocks passed at height {due}" + )); + } + } + if absolute { + let l = self.tx.lock_time.to_consensus_u32(); + let ok = if l < 500_000_000 { + u64::from(l) < facts.tip_height + 1 + } else { + u64::from(l) < facts.tip_median_time + }; + if !ok { + return Err(Error::Chain(format!( + "the lock time {l} is not yet reached (non-final)" + ))); + } + said.push(format!("lock time {l} reached")); + } + Ok(Some(said.join("; "))) + } + + /// The contract key at `path`, refused unless it is under the contract + /// account and is the key the leaf names. + pub fn contract_keypair( + &self, + signer: &SwSigner, + key_path: &str, + ) -> Result<(Keypair, String), Error> { + let account = contract_account(self.chain.mainnet); + let canon = key_path.replace('\'', "h"); + if !canon.starts_with(&format!("{account}/")) { + return Err(Error::Signing(format!( + "{key_path} is not a contract key: contract keys are under {account}, apart from the wallet's funding keys" + ))); + } + let path = DerivationPath::from_str(&canon) + .map_err(|e| Error::Signing(format!("{key_path}: {e}")))?; + let xprv = signer + .derive_xprv(&path) + .map_err(|e| Error::Signing(format!("{key_path}: {e}")))?; + let secp = Secp256k1::new(); + let keypair = Keypair::from_secret_key(&secp, &xprv.private_key); + let xonly = hex(&keypair.x_only_public_key().0.serialize()); + let named = self.signing_param()?; + let want = hex(&self.contract.value_bytes(&named)?); + if xonly != want { + return Err(Error::Signing(format!( + "path {} is signed by {named} = {want}; this wallet's contract key at {key_path} is {xonly}", + self.path.name + ))); + } + Ok((keypair, named)) + } + + /// The parameter naming the key that signs this path. + pub fn signing_param(&self) -> Result { + let keys: Vec = self + .path + .witness + .iter() + .filter_map(|w| w.source.strip_prefix("signature:").map(str::to_string)) + .collect(); + match keys.as_slice() { + [one] => Ok(one.clone()), + [] => Err(Error::Signing(format!( + "path {} takes no signature", + self.path.name + ))), + _ => Err(Error::Signing(format!( + "path {} takes {} signatures; the engine signs paths of one key", + self.path.name, + keys.len() + ))), + } + } + + fn witness_values(&self, sig: &[u8; 64]) -> Result { + let leaf = self.path.leaf.as_deref().unwrap_or_default(); + let program = self + .contract + .template + .program(leaf) + .ok_or_else(|| Error::Spend(format!("{leaf} is not a Simplicity leaf")))?; + let types = program.witness_types(); + let mut map = std::collections::HashMap::new(); + for w in &self.path.witness { + let ty = types + .iter() + .find(|(n, _)| AsRef::::as_ref(*n) == w.name) + .map(|(_, t)| t) + .ok_or_else(|| { + Error::Spend(format!("the program declares no witness {}", w.name)) + })?; + let bytes = if w.source.starts_with("signature:") { + sig.to_vec() + } else if let Some(p) = w + .source + .strip_prefix("param:") + .or_else(|| w.source.strip_prefix("slot:")) + { + self.contract.value_bytes(p)? + } else { + let v = self.spender.get(&w.name).ok_or_else(|| { + Error::Spend(format!("the spender's value {} is not given", w.name)) + })?; + unhex_any(v).map_err(|e| Error::Spend(e.to_string()))? + }; + let literal = match w.ty.as_str() { + "u1" | "u2" | "u4" | "u8" | "u16" | "u32" | "u64" | "u128" => { + let n = bytes + .iter() + .fold(0u128, |acc, x| (acc << 8) | u128::from(*x)); + n.to_string() + } + _ => format!("0x{}", hex(&bytes)), + }; + let value = HlValue::parse_from_str(&literal, ty) + .map_err(|e| Error::Spend(format!("witness {}: {e}", w.name)))?; + map.insert(WitnessName::from_str_unchecked(&w.name), value); + } + Ok(WitnessValues::from(map)) + } + + fn control_block(&self) -> Result { + self.contract + .control_block(self.path.leaf.as_deref().unwrap_or_default()) + } + + /// The environment a Simplicity leaf runs in, for `tx` as it stands, with + /// the annex, if any, read the way the node reads it. + fn env( + &self, + tx: &Transaction, + annex: Option<&[u8]>, + ) -> Result>, Error> { + let leaf = self.path.leaf.as_deref().unwrap_or_default(); + let program = self + .contract + .template + .program(leaf) + .expect("a Simplicity leaf"); + let mut tx = tx.clone(); + // The signature hash commits to every input's annex: a lone placeholder + // annex is read after an empty item, as the node would read the final stack. + tx.input[0].witness.script_witness = match annex { + Some(a) => vec![Vec::new(), a.to_vec()], + None => Vec::new(), + }; + Ok(ElementsEnv::new( + Arc::new(tx), + vec![ElementsUtxo { + script_pubkey: self.coin.script_pubkey.clone(), + asset: self.coin.asset, + value: self.coin.value, + }], + 0, + program.commit().cmr(), + self.control_block()?, + annex.map(|a| a[1..].to_vec()), + self.chain.genesis, + )) + } + + /// The hash a Simplicity leaf's signature signs: `sig_all_hash`. + pub fn sig_all_hash(&self, annex: Option<&[u8]>) -> Result<[u8; 32], Error> { + Ok(self + .env(&self.tx, annex)? + .c_tx_env() + .sighash_all() + .to_byte_array()) + } + + /// Runs the program against `tx` with this signature and annex; returns + /// the witness stack (witness, program, root, control block) and the + /// pruned program's cost bound in milli weight units. + fn run(&self, sig: &[u8; 64], annex: Option<&[u8]>) -> Result<(Vec>, u64), Error> { + let leaf = self.path.leaf.as_deref().unwrap_or_default(); + let program = self + .contract + .template + .program(leaf) + .expect("a Simplicity leaf"); + let satisfied = program + .satisfy(self.witness_values(sig)?) + .map_err(Error::Program)?; + let env = self.env(&self.tx, annex)?; + let pruned = match satisfied.redeem().prune(&env) { + Ok(p) => p, + Err(e) => { + return Err(Error::Program(match self.diagnose(sig, annex) { + Some(check) => format!("{e}: the check that fails is `{check}`"), + None => e.to_string(), + })) + } + }; + let mut mac = + BitMachine::for_program(&pruned).map_err(|e| Error::Program(e.to_string()))?; + mac.exec(&pruned, &env) + .map_err(|e| Error::Program(e.to_string()))?; + let (program_bytes, witness_bytes) = pruned.to_vec_with_witness(); + let stack = vec![ + witness_bytes, + program_bytes, + pruned.cmr().as_ref().to_vec(), + self.control_block()?.serialize(), + ]; + Ok((stack, budget::cost_milliweight(pruned.bounds().cost))) + } + + /// The source text of the check a refused run fails at: the program is + /// compiled again with debug symbols (which change its root, so the + /// environment keeps the committed root and the signature stays valid) + /// and run with a tracker that notes each assertion it enters. + fn diagnose(&self, sig: &[u8; 64], annex: Option<&[u8]>) -> Option { + use simplicityhl::debug::TrackedCallName; + use simplicityhl::simplicity::bit_machine::{ExecTracker, FrameIter, NodeOutput}; + use simplicityhl::simplicity::node::Inner; + use simplicityhl::simplicity::RedeemNode; + + struct Last<'a> { + symbols: &'a simplicityhl::debug::DebugSymbols, + last: Option, + failed: Option, + } + impl ExecTracker for Last<'_> { + fn visit_node(&mut self, node: &RedeemNode, _: FrameIter, output: NodeOutput) { + if let Inner::AssertL(_, cmr) = node.inner() { + if let Some(call) = self.symbols.get(cmr) { + if matches!( + call.name(), + TrackedCallName::Assert + | TrackedCallName::Unwrap + | TrackedCallName::Panic + ) { + self.last = Some(call.text().to_string()); + } + } + } + if matches!(output, NodeOutput::JetFailed) && self.failed.is_none() { + self.failed = self.last.clone(); + } + } + } + + let leaf = self.path.leaf.as_deref()?; + let source = self.contract.template.source_text(leaf)?; + let debug = simplicityhl::TemplateProgram::new( + source, + Box::new(simplicityhl::ast::ElementsJetHinter::new()), + ) + .ok()? + .instantiate(simplicityhl::Arguments::default(), true) + .ok()?; + let satisfied = debug.satisfy(self.witness_values(sig).ok()?).ok()?; + let env = self.env(&self.tx, annex).ok()?; + let mut tracker = Last { + symbols: satisfied.debug_symbols(), + last: None, + failed: None, + }; + let mut mac = BitMachine::for_program(satisfied.redeem()).ok()?; + let _ = mac.exec_with_tracker(satisfied.redeem(), &env, &mut tracker); + tracker.failed.or(tracker.last) + } + + /// Signs with the contract key at `key_path`, runs the program against the + /// final transaction, pads it under the budget rule, and returns it. The + /// key is checked first: under the contract account, and the one the path + /// names. + pub fn finalize(&self, signer: &SwSigner, key_path: &str) -> Result { + let (keypair, _) = self.contract_keypair(signer, key_path)?; + let secp = Secp256k1::new(); + match self.path.kind.as_str() { + "simplicity" => { + let rule = self.contract.template.model().budget; + let sign = |annex: Option<&[u8]>| -> Result<[u8; 64], Error> { + let msg = Message::from_digest(self.sig_all_hash(annex)?); + Ok(secp.sign_schnorr_no_aux_rand(&msg, &keypair).serialize()) + }; + let sig = sign(None)?; + let (stack, cost) = self.run(&sig, None)?; + let (stack, annex) = + match budget::padding(&rule, cost, &stack).map_err(Error::Program)? { + None => (stack, None), + Some(annex) => { + let sig = sign(Some(&annex))?; + let (mut stack, cost2) = self.run(&sig, Some(&annex))?; + debug_assert_eq!(cost, cost2); + stack.push(annex.clone()); + (stack, Some(annex)) + } + }; + let earned = budget::earned(&rule, &stack); + if cost > earned * 1000 { + return Err(Error::Program(format!( + "the program costs {cost} milli-WU and its witness earns {earned} WU" + ))); + } + debug_assert!(annex.as_ref().is_none_or(|a| a[0] == ANNEX_TAG)); + let mut tx = self.tx.clone(); + tx.input[0].witness = TxInWitness { + script_witness: stack, + ..Default::default() + }; + Ok(Finalized { + tx, + cost_mwu: cost, + budget_wu: earned, + annex_bytes: annex.map_or(0, |a| a.len()), + }) + } + "tapscript" => { + let leaf = self.path.leaf.as_deref().unwrap_or_default(); + let script = self.contract.leaf_script(leaf)?; + let cb = self.control_block()?; + let prevouts = [self.coin.clone()]; + let spend = ScriptPathSpend { + tx: &self.tx, + input_index: 0, + prevouts: &prevouts, + leaf_script: &script, + control_block: &cb, + sighash_type: SchnorrSighashType::Default, + genesis_hash: self.chain.genesis, + }; + let path = DerivationPath::from_str(&key_path.replace('\'', "h")) + .map_err(|e| Error::Signing(e.to_string()))?; + let sig = signer + .sign_tapscript(&path, &spend) + .map_err(|e| Error::Signing(e.to_string()))?; + let mut tx = self.tx.clone(); + tx.input[0].witness = TxInWitness { + script_witness: vec![sig.to_vec(), script.to_bytes(), cb.serialize()], + ..Default::default() + }; + Ok(Finalized { + tx, + cost_mwu: 0, + budget_wu: 0, + annex_bytes: 0, + }) + } + k => Err(Error::Spend(format!( + "a {k} path is not spent by the engine" + ))), + } + } + + /// The x-only key a signer holds at `key_path`, for display and checks. + pub fn key_at(signer: &SwSigner, key_path: &str) -> Result { + let path = DerivationPath::from_str(&key_path.replace('\'', "h")) + .map_err(|e| Error::Signing(e.to_string()))?; + signer + .xonly_public_key(&path) + .map_err(|e| Error::Signing(e.to_string())) + } +} + +fn output_script(index: usize, o: &OutputRequest) -> Result { + match (&o.address, &o.script) { + (Some(a), None) => { + let addr = simplicityhl::elements::Address::from_str(a).map_err(|e| { + Error::Unaccounted(format!("output {index}: {a} is not an address: {e}")) + })?; + if addr.is_blinded() { + return Err(Error::Unaccounted(format!( + "output {index} pays the confidential address {a}; the engine accounts for explicit outputs only, so give the recipient's transparent address" + ))); + } + Ok(addr.script_pubkey()) + } + (None, Some(s)) => { + Ok(Script::from(unhex_any(s).map_err(|e| { + Error::Unaccounted(format!("output {index}: {e}")) + })?)) + } + _ => Err(Error::Unaccounted(format!( + "output {index} names neither an address nor a script (exactly one)" + ))), + } +} + +/// What an output does, checked against what the request says it does. +fn classify( + index: usize, + o: &OutputRequest, + out_script: &Script, + contract_script: &Script, + next: Option<&Contract>, + wallet_scripts: &[Script], +) -> Result { + let is_contract = out_script == contract_script; + let is_next = next.is_some_and(|n| *out_script == n.script_pubkey()); + let is_wallet = wallet_scripts.contains(out_script); + match o.to.as_str() { + "fee" => Ok("fee".into()), + "contract" if is_contract => Ok("contract".into()), + "contract" if is_next => Ok("contract next state".into()), + "contract" => Err(Error::Unaccounted(format!( + "output {index} is said to return to the contract, but pays the script {}, which is neither the contract's ({}){}", + hex(out_script.as_bytes()), + hex(contract_script.as_bytes()), + match next { + Some(n) => format!(" nor its next state's ({})", hex(n.script_pubkey().as_bytes())), + None => String::new(), + } + ))), + "wallet" if is_wallet => Ok("wallet".into()), + "wallet" => Err(Error::Unaccounted(format!( + "output {index} is said to pay this wallet, but its script {} is none of the wallet's", + hex(out_script.as_bytes()) + ))), + "pay" if is_contract || is_next => Err(Error::Unaccounted(format!( + "output {index} is said to pay someone, but pays the contract itself" + ))), + "pay" if is_wallet => Ok("wallet".into()), + "pay" => Ok("pay".into()), + other => Err(Error::Unaccounted(format!( + "output {index}: {other:?} is not something an output can do (contract, wallet, pay or fee)" + ))), + } +} + +/// Lowercase hex of a script. +pub fn script_hex(s: &Script) -> String { + hex(s.as_bytes()) +} + +/// A script from hex. +pub fn script_from_hex(s: &str) -> Result { + Ok(Script::from(unhex_any(s).map_err(Error::Spend)?)) +} diff --git a/lwk_contracts/src/template.rs b/lwk_contracts/src/template.rs new file mode 100644 index 000000000..a084c1ee9 --- /dev/null +++ b/lwk_contracts/src/template.rs @@ -0,0 +1,524 @@ +//! Templates, instances and the outputs they derive. +//! +//! A template is read with `sequentia-contracts`' own reader (the reference +//! implementation of the descriptor specification), checked with the pinned +//! compiler from the source texts it was handed, and its Simplicity leaves are +//! kept compiled so a spend can be run against its final transaction. + +use std::collections::BTreeMap; +use std::sync::Arc; + +pub use sequentia_contracts::descriptor::Node; +use sequentia_contracts::descriptor::{ + parse_json, Descriptor, Model, Param, Path2, ScriptItem, SimplicityLeaf, TreeDerived, + WitnessValue, NUMS_KEY, +}; +use serde::Serialize; +use serde_json::Value; +use simplicityhl::elements::taproot::ControlBlock; +use simplicityhl::elements::Script; +use simplicityhl::CompiledProgram; + +use crate::error::Error; +use crate::hex::{hex, unhex}; + +/// BIP68's type flag: a relative lock counted in units of 512 seconds. +pub const SEQUENCE_TIME_FLAG: u32 = 1 << 22; +/// BIP68's disable flag. +pub const SEQUENCE_DISABLE_FLAG: u32 = 1 << 31; + +/// A checked template: its descriptor, its model, and each Simplicity leaf +/// compiled from the text it was handed. +#[derive(Clone)] +pub struct Template { + descriptor: Descriptor, + model: Model, + programs: BTreeMap>, + sources: BTreeMap, +} + +impl std::fmt::Debug for Template { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Template") + .field("hash", &self.descriptor.template_hash) + .finish() + } +} + +/// What a spending path needs, as a wallet shows and supplies it. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct PathInfo { + pub name: String, + pub who: String, + pub effect: String, + /// `simplicity`, `tapscript` or `key`. + pub kind: String, + /// The leaf it spends; `None` for the key path. + pub leaf: Option, + /// Each value the spend's witness carries, in order. + pub witness: Vec, + /// The parameter whose value is the BIP68 sequence the input must carry, + /// when the leaf checks one with `OP_CHECKSEQUENCEVERIFY`. + pub sequence_param: Option, + /// The parameter whose value is the lock time the transaction must carry, + /// when the leaf checks one with `OP_CHECKLOCKTIMEVERIFY`. + pub lock_time_param: Option, +} + +/// One witness value of a path. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct WitnessNeed { + pub name: String, + #[serde(rename = "type")] + pub ty: String, + /// `param:`, `slot:`, `signature:` or `spender`. + pub source: String, +} + +impl Template { + /// Reads and checks a descriptor. `sources` maps each Simplicity leaf's + /// `source` name to its text with the helper includes resolved (what + /// `seqc expand` prints): every check `seqc descriptor check` makes, + /// including compiling each source with the pinned compiler and comparing + /// its root, witness and cost bound with the descriptor's. + pub fn new(descriptor_json: &str, sources: &BTreeMap) -> Result { + let descriptor = Descriptor::parse(descriptor_json).map_err(Error::Descriptor)?; + descriptor + .validate_sources(sources) + .map_err(Error::Descriptor)?; + let model = descriptor.model().map_err(Error::Descriptor)?; + let mut programs = BTreeMap::new(); + let mut leaf_sources = BTreeMap::new(); + for (leaf, _) in model.tree.leaves() { + if let Node::Simplicity { name, program } = leaf { + let text = &sources[&program.source]; + leaf_sources.insert(name.clone(), text.clone()); + let compiled = + sequentia_contracts::compile_expanded(text, simplicityhl::Arguments::default()) + .map_err(Error::Descriptor)?; + programs.insert(name.clone(), Arc::new(compiled)); + } + } + Ok(Template { + descriptor, + model, + programs, + sources: leaf_sources, + }) + } + + pub fn descriptor(&self) -> &Descriptor { + &self.descriptor + } + + pub fn model(&self) -> &Model { + &self.model + } + + /// The template hash: its identity. + pub fn hash(&self) -> &str { + &self.descriptor.template_hash + } + + fn field(&self, key: &str) -> Option<&Value> { + self.descriptor.template.get(key) + } + + /// The name the template gives itself. Only a registry vouches for a name. + pub fn self_name(&self) -> String { + self.field("name") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string() + } + + pub fn version(&self) -> u64 { + self.field("version").and_then(Value::as_u64).unwrap_or(0) + } + + pub fn summary(&self) -> String { + self.field("summary") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string() + } + + pub fn params(&self) -> &[Param] { + &self.model.params + } + + pub fn slots(&self) -> &[Param] { + &self.model.slots + } + + /// The parameter or slot of this name, and whether it is a slot. + pub fn field_param(&self, name: &str) -> Option<(&Param, bool)> { + self.model + .params + .iter() + .find(|p| p.name == name) + .map(|p| (p, false)) + .or_else(|| { + self.model + .slots + .iter() + .find(|p| p.name == name) + .map(|p| (p, true)) + }) + } + + /// A leaf of the tree, by name. + pub fn leaf(&self, name: &str) -> Option<&Node> { + self.model + .tree + .leaves() + .into_iter() + .map(|(n, _)| n) + .find(|n| n.name() == Some(name)) + } + + /// The compiled program of a Simplicity leaf. + pub fn program(&self, leaf: &str) -> Option<&Arc> { + self.programs.get(leaf) + } + + /// The resolved source text of a Simplicity leaf. + pub fn source_text(&self, leaf: &str) -> Option<&str> { + self.sources.get(leaf).map(String::as_str) + } + + /// The Simplicity leaf's descriptor entry. + pub fn simplicity_leaf(&self, leaf: &str) -> Option<&SimplicityLeaf> { + match self.leaf(leaf) { + Some(Node::Simplicity { program, .. }) => Some(program), + _ => None, + } + } + + pub fn path(&self, name: &str) -> Option<&Path2> { + self.model.paths.iter().find(|p| p.name == name) + } + + /// Every way to spend, with what each needs. + pub fn paths(&self) -> Vec { + self.model.paths.iter().map(|p| self.path_info(p)).collect() + } + + fn path_info(&self, p: &Path2) -> PathInfo { + let (kind, witness, seq, lock) = match p.leaf.as_deref().and_then(|l| self.leaf(l)) { + Some(Node::Simplicity { program, .. }) => ( + "simplicity", + program.witness.iter().map(need).collect(), + None, + None, + ), + Some(Node::Tapscript { items, .. }) => { + let keys = tapscript_keys(items); + let witness = keys + .iter() + .rev() + .map(|k| WitnessNeed { + name: format!("signature of {k}"), + ty: "Signature".into(), + source: format!("signature:{k}"), + }) + .collect(); + let (s, l) = tapscript_locks(items); + ("tapscript", witness, s, l) + } + _ => ("key", Vec::new(), None, None), + }; + PathInfo { + name: p.name.clone(), + who: p.who.clone(), + effect: p.effect.clone(), + kind: kind.into(), + leaf: p.leaf.clone(), + witness, + sequence_param: seq, + lock_time_param: lock, + } + } + + /// Whether the template has a key path (an internal key other than NUMS). + pub fn has_key_path(&self) -> bool { + self.model.internal_key != NUMS_KEY + } +} + +fn need(w: &WitnessValue) -> WitnessNeed { + let source = match w.source.strip_prefix("signature:sig_all_hash:") { + Some(k) => format!("signature:{k}"), + None => w.source.clone(), + }; + WitnessNeed { + name: w.name.clone(), + ty: w.ty.clone(), + source, + } +} + +/// The parameters a tapscript pushes as keys checked by a signature opcode: +/// each `push` immediately followed by `OP_CHECKSIG`, `OP_CHECKSIGVERIFY` +/// or `OP_CHECKSIGADD`. +pub fn tapscript_keys(items: &[ScriptItem]) -> Vec { + let mut out = Vec::new(); + for (i, item) in items.iter().enumerate() { + if let ScriptItem::Push(p) = item { + if let Some(ScriptItem::Bytes(next)) = items.get(i + 1) { + if matches!(next.first(), Some(0xac | 0xad | 0xba)) { + out.push(p.clone()); + } + } + } + } + out +} + +/// The parameters a tapscript checks as a relative lock (`num` then +/// `OP_CHECKSEQUENCEVERIFY`) and as a lock time (`num` then +/// `OP_CHECKLOCKTIMEVERIFY`). +pub fn tapscript_locks(items: &[ScriptItem]) -> (Option, Option) { + let (mut seq, mut lock) = (None, None); + for (i, item) in items.iter().enumerate() { + if let ScriptItem::Num(p) = item { + match items.get(i + 1) { + Some(ScriptItem::Bytes(b)) if b.first() == Some(&0xb2) => seq = Some(p.clone()), + Some(ScriptItem::Bytes(b)) if b.first() == Some(&0xb1) => lock = Some(p.clone()), + _ => {} + } + } + } + (seq, lock) +} + +/// An instance: a template hash, its values and its chain. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Instance { + pub instance: u64, + pub template_hash: String, + pub params: BTreeMap, + pub slots: BTreeMap, + /// The chain's genesis hash, display hex; `None` names no chain. + pub genesis: Option, +} + +impl Instance { + /// Reads an instance record. Unknown fields are refused, as the + /// descriptor's readers refuse them. + pub fn parse(text: &str) -> Result { + let v = parse_json(text).map_err(Error::Instance)?; + let obj = v + .as_object() + .ok_or_else(|| Error::Instance("an instance is a JSON object".into()))?; + for k in obj.keys() { + if !["instance", "template_hash", "params", "slots", "genesis"].contains(&k.as_str()) { + return Err(Error::Instance(format!("unknown field {k}"))); + } + } + let version = v["instance"] + .as_u64() + .filter(|n| *n == 1 || *n == 2) + .ok_or_else(|| Error::Instance("instance is not 1 or 2".into()))?; + let template_hash = v["template_hash"] + .as_str() + .ok_or_else(|| Error::Instance("no template_hash".into()))? + .to_string(); + let strings = |key: &str| -> Result, Error> { + match v.get(key) { + None if key == "slots" => Ok(BTreeMap::new()), + None => Err(Error::Instance(format!("no {key}"))), + Some(Value::Object(m)) => m + .iter() + .map(|(k, x)| { + x.as_str() + .map(|s| (k.clone(), s.to_string())) + .ok_or_else(|| Error::Instance(format!("{key}.{k} is not a string"))) + }) + .collect(), + Some(_) => Err(Error::Instance(format!("{key} is not an object"))), + } + }; + if version == 1 && v.get("slots").is_some() { + return Err(Error::Instance("a version 1 instance has no slots".into())); + } + let genesis = match v.get("genesis") { + Some(Value::String(s)) => { + unhex(s, 32).map_err(|e| Error::Instance(format!("genesis: {e}")))?; + Some(s.clone()) + } + Some(Value::Null) | None => None, + Some(_) => return Err(Error::Instance("genesis is a hash or null".into())), + }; + Ok(Instance { + instance: version, + template_hash, + params: strings("params")?, + slots: strings("slots")?, + genesis, + }) + } +} + +/// A template and an instance of it: an output recomputed from both. +#[derive(Debug, Clone)] +pub struct Contract { + pub template: Arc