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