From d924e626bf65d4f5c068b3bde064917caf759ea6 Mon Sep 17 00:00:00 2001 From: GracedEternalKingCabbageMan <151803062+GracedEternalKingCabbageMan@users.noreply.github.com> Date: Thu, 8 Oct 2026 04:51:19 +0200 Subject: [PATCH] contracts: sign a contract spend only as the wallet showed it The engine could sign any spend it built. A wallet must sign for a contract only when five things hold: the template is on its list, it recomputed the output, it ran the program against the final transaction, it showed the template, path, parameters by role and its own balance change, and the key is one reserved for contracts. Approval::prepare checks the first, second, third and fifth, and the chain's locks, and writes the summary the wallet shows (the template by the registry's name, else its commitment root; every parameter by role; the wallet's balance change in every asset beside the contract's, the payments and the fee) with a digest over it. Approval::sign signs only when handed that digest back, after preparing the spend again and running the program once more against the transaction it returns. Spend::finalize is no longer public, so the gate is the only way in. lwk_wasm exposes ContractApproval and Signer.signContractSpend, so a browser wallet shows the summary and signs what it showed. --- README.md | 7 +- SEQUENTIA.md | 7 +- lwk_contracts/README.md | 36 +- lwk_contracts/src/approval.rs | 369 +++++++++++++++++++ lwk_contracts/src/lib.rs | 2 + lwk_contracts/src/spend.rs | 5 +- lwk_contracts/tests/approval.rs | 164 +++++++++ lwk_contracts/tests/drip_regtest.rs | 118 +++--- lwk_wasm/README.md | 3 +- lwk_wasm/src/contract_engine.rs | 56 +++ lwk_wasm/src/lib.rs | 2 +- lwk_wasm/src/signer.rs | 18 + lwk_wasm/tests/node/contract_drip_regtest.js | 215 +++++++++++ 13 files changed, 927 insertions(+), 75 deletions(-) create mode 100644 lwk_contracts/src/approval.rs create mode 100644 lwk_contracts/tests/approval.rs create mode 100644 lwk_wasm/tests/node/contract_drip_regtest.js diff --git a/README.md b/README.md index 3cdf0a0f1..08ec98222 100644 --- a/README.md +++ b/README.md @@ -124,11 +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`), 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_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`, `ContractApproval`, `Signer.signContractSpend`), `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_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 only what the wallet showed (the five-point rule). 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. | @@ -183,7 +183,8 @@ lwk_wollet = { features = ["btc-async"] } # wasm / async apps `buildDelegationCreateTx()` / `buildDelegationSpendTx()`, `buildUnbondTx()` / `buildUnbondClaimTx()`, `coinjoinSignInputs()`, the `Openamp` client, the `adaptor*` functions, and the contract engine (`ContractTemplate`, -`ContractInstance`, `ContractSpend`). +`ContractInstance`, `ContractSpend`, `ContractApproval`, +`Signer.signContractSpend`). ```sh lwk_wasm/build-web.sh # wasm-pack build --target web --release; needs clang diff --git a/SEQUENTIA.md b/SEQUENTIA.md index 7bb9bc3e0..b5dcb8a31 100644 --- a/SEQUENTIA.md +++ b/SEQUENTIA.md @@ -485,8 +485,11 @@ The fork is not published to npm; consumers build `pkg/` with `wasm-pack`. `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. + drip covenant), `ContractSpend` (the spend of a path, its outputs' + roles, its locks) and `ContractApproval` (the spend checked under the + five-point signing rule, with the summary a wallet shows and its digest). + `Signer.signContractSpend(approval, shownDigest)` signs only that digest's + spend. 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). diff --git a/lwk_contracts/README.md b/lwk_contracts/README.md index 8e2e8c3c9..11405c189 100644 --- a/lwk_contracts/README.md +++ b/lwk_contracts/README.md @@ -22,12 +22,37 @@ values on one chain. The engine: 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 +- signs, through the five-point gate below, 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). +## Signing under the five-point rule + +A wallet signs a contract spend only through `approval::Approval`, which holds +the five points of the signing rule in the contracts' specification: + +1. the template hash is on the wallet's list of known templates; +2. the engine recomputed the output from the template and the instance, and + the coin pays it; +3. the engine ran the program against the final transaction; +4. the approval shows the template (by the registry's name where the wallet + has one, else its commitment root), the path, every parameter by its role + (assets by ticker, amounts in the asset's precision, keys marked when they + are the wallet's, relative locks as a delay), and the wallet's own balance + change in every asset, beside the contract's, the payments and the fee; +5. the key is a contract key, the one the path names. + +`Approval::prepare` checks 1, 2, 3 and 5 and the chain's locks, and writes the +summary of 4 with a digest over it. `Approval::sign` signs only when handed that +digest back: it prepares the spend again from the same inputs, refuses if the +digest differs, and runs the program once more against the transaction it +returns. To run a program before the approval, the engine signs inside itself +(the program checks the signature) and drops that signature; the only one that +leaves the engine is made by `sign`. + 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. @@ -46,13 +71,14 @@ run the tests. ## Tests ```sh -cargo test -p lwk_contracts # vectors, refusals, budget +cargo test -p lwk_contracts # vectors, refusals, budget, the gate 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 +the corpus for its reason. `tests/approval.rs` holds the gate to its rule with +no chain. `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 +covenant through the gate, 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/approval.rs b/lwk_contracts/src/approval.rs new file mode 100644 index 000000000..886b0329a --- /dev/null +++ b/lwk_contracts/src/approval.rs @@ -0,0 +1,369 @@ +//! Signing for a contract under the five-point rule. +//! +//! A wallet signs a contract spend only when all of these hold: +//! +//! 1. the template hash is on its list of known templates; +//! 2. it recomputed the output script from the template and the instance, +//! and it equals the coin being spent; +//! 3. it ran the program against the final transaction itself; +//! 4. the approval shows the template (by registry name where registered, +//! else its root), the path, the parameters by role, and the wallet's own +//! balance change in every asset; +//! 5. the signing key is one reserved for contracts. +//! +//! [`Approval::prepare`] checks 1, 2, 3 and 5, and the chain's locks, and +//! writes the summary of 4 with a digest over it. [`Approval::sign`] signs only +//! when it is handed that digest back, so a wallet signs only what it showed; +//! it prepares the spend again from the same inputs, refuses if anything +//! differs, and runs the program once more against the transaction it returns. +//! +//! To run a program before the approval, the engine signs inside itself +//! (the program checks the signature) and drops that signature: the only one +//! that leaves the engine is made by [`Approval::sign`]. + +use std::collections::BTreeMap; + +use lwk_signer::SwSigner; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use simplicityhl::elements::hashes::{sha256, Hash, HashEngine}; +use simplicityhl::elements::{encode, Transaction}; + +use crate::error::Error; +use crate::hex::hex; +use crate::spend::{Finalized, Spend}; +use crate::template::{show_sequence, show_value}; + +/// The name and version a registry gives a template hash. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct RegistryName { + pub name: String, + pub version: u64, +} + +/// How a wallet labels an asset. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct AssetLabel { + pub ticker: String, + pub precision: u8, +} + +/// What the wallet brings to an approval. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct WalletView { + /// The template hashes on the wallet's list. + pub known: Vec, + /// The registry's name for this template, when it has one. + #[serde(default)] + pub registry: Option, + /// Labels of assets, by display hex. + #[serde(default)] + pub assets: BTreeMap, + /// The contract key's path. Defaults to `0/0` under the contract account. + #[serde(default)] + pub key_path: Option, +} + +/// A spend checked under the five-point rule, with what the wallet must show. +#[derive(Debug, Clone)] +pub struct Approval { + spend: Spend, + view: WalletView, + key_path: String, + summary: Value, + digest: String, +} + +fn amount(view: &WalletView, asset: &str, atoms: i128) -> String { + match view.assets.get(asset) { + Some(l) => { + let p = u32::from(l.precision); + let sign = if atoms < 0 { "-" } else { "" }; + let a = atoms.unsigned_abs(); + let unit = 10u128.pow(p); + if p == 0 { + format!("{sign}{a} {}", l.ticker) + } else { + format!( + "{sign}{}.{:0width$} {}", + a / unit, + a % unit, + l.ticker, + width = p as usize + ) + } + } + None => format!("{atoms} atoms of {asset}"), + } +} + +impl Approval { + /// Checks a spend under points 1, 2, 3 and 5 and the chain's locks, and + /// writes the summary point 4 asks the wallet to show. + pub fn prepare(spend: Spend, view: WalletView, signer: &SwSigner) -> Result { + let key_path = view + .key_path + .clone() + .unwrap_or_else(|| crate::spend::default_contract_key_path(spend.chain.mainnet)); + let (summary, _) = summarize(&spend, &view, &key_path, signer)?; + let digest = digest(&summary); + Ok(Approval { + spend, + view, + key_path, + summary, + digest, + }) + } + + /// What the wallet shows, as JSON; its `digest` is what [`Self::sign`] takes back. + pub fn summary(&self) -> Value { + let mut s = self.summary.clone(); + s["digest"] = self.digest.clone().into(); + s + } + + pub fn digest(&self) -> &str { + &self.digest + } + + pub fn spend(&self) -> &Spend { + &self.spend + } + + /// Signs the spend the wallet showed: `shown_digest` must be the digest of + /// the summary it showed. Everything is checked again, and the program is + /// run once more against the transaction returned. + pub fn sign(&self, shown_digest: &str, signer: &SwSigner) -> Result { + if shown_digest != self.digest { + return Err(Error::Signing(format!( + "the approval shown has digest {shown_digest}; this spend's is {}, so it is not what was shown", + self.digest + ))); + } + let (summary, finalized) = summarize(&self.spend, &self.view, &self.key_path, signer)?; + let again = digest(&summary); + if again != self.digest { + return Err(Error::Signing(format!( + "the spend prepared again has digest {again}, not the {} shown", + self.digest + ))); + } + Ok(finalized.tx) + } +} + +fn digest(summary: &Value) -> String { + let mut e = sha256::Hash::engine(); + e.input(b"lwk_contracts/approval/1\0"); + e.input(summary.to_string().as_bytes()); + hex(sha256::Hash::from_engine(e).as_ref()) +} + +/// The checks, the run, and the summary. Returns the finalized transaction +/// as well, which only [`Approval::sign`] lets out. +fn summarize( + spend: &Spend, + view: &WalletView, + key_path: &str, + signer: &SwSigner, +) -> Result<(Value, Finalized), Error> { + let contract = &spend.contract; + let template = &contract.template; + + // 1. The template is on the wallet's list. + if !view.known.iter().any(|h| h == template.hash()) { + return Err(Error::Signing(format!( + "template {} ({}) is not on this wallet's list of known templates", + template.hash(), + template.self_name() + ))); + } + // 2. The output was recomputed by `Spend::build`, which refuses a coin whose + // script is not the contract's; checked again here. + if spend.coin.script_pubkey != contract.script_pubkey() { + return Err(Error::Signing( + "the coin is not the contract's output".into(), + )); + } + // The chain's locks. + let locks = spend.check_locks()?; + // 5. A contract key, and the one the path names. + let (keypair, key_param) = spend.contract_keypair(signer, key_path)?; + // 3. The program, run against the final transaction. + let finalized = spend.finalize(signer, key_path)?; + let weight = finalized.tx.weight(); + let vsize = weight.div_ceil(4); + + // 4. The summary. + let leaf = spend.path.leaf.clone().unwrap_or_default(); + let roots: Vec = template + .model() + .tree + .leaves() + .into_iter() + .filter_map(|(n, _)| match n { + crate::template::Node::Simplicity { program, .. } => Some(program.cmr.clone()), + _ => None, + }) + .collect(); + let shown_template = match &view.registry { + Some(r) => format!("{} v{} (registered)", r.name, r.version), + None => format!( + "an unregistered template, root {}", + roots + .first() + .cloned() + .unwrap_or_else(|| template.hash().to_string()) + ), + }; + let our_key = hex(&keypair.x_only_public_key().0.serialize()); + let params: Vec = template + .params() + .iter() + .map(|p| { + let v = &contract.instance.params[&p.name]; + let mut shown = show_value(&p.role, v); + if p.role == "asset" { + if let Some(l) = view.assets.get(&shown) { + shown = format!("{} ({shown})", l.ticker); + } + } + if p.role == "amount" { + if let Some(asset) = template_asset(spend) { + let atoms = i128::from(u64::from_str_radix(v, 16).unwrap_or(0)); + shown = amount(view, &asset, atoms); + } + } + if p.role == "pubkey" && *v == our_key { + shown = format!("{v} (this wallet's contract key, {key_path})"); + } + json!({"name": p.name, "label": p.label, "role": p.role, "type": p.ty, "value": v, "shown": shown}) + }) + .collect(); + let slots: Vec = template + .slots() + .iter() + .map(|p| { + let v = &contract.instance.slots[&p.name]; + json!({"name": p.name, "label": p.label, "role": p.role, "value": v, "shown": show_value(&p.role, v)}) + }) + .collect(); + + // Balance changes, per asset: the wallet's, the contract's, payments, the fee. + let coin_asset = spend + .outputs + .first() + .map(|_| contract_asset(spend).unwrap_or_default()) + .unwrap_or_default(); + let coin_amount = i128::from(spend.coin.value.explicit().unwrap_or(0)); + let mut wallet: BTreeMap = BTreeMap::new(); + let mut contract_change: BTreeMap = BTreeMap::new(); + *contract_change.entry(coin_asset.clone()).or_default() -= coin_amount; + let mut payments = Vec::new(); + let mut fee = Vec::new(); + for o in &spend.outputs { + let a = i128::from(o.amount); + match o.role.as_str() { + "wallet" => *wallet.entry(o.asset.clone()).or_default() += a, + "contract" | "contract next state" => { + *contract_change.entry(o.asset.clone()).or_default() += a + } + "pay" => payments.push(json!({ + "index": o.index, "script_pubkey": o.script_pubkey, "asset": o.asset, + "amount": o.amount, "shown": amount(view, &o.asset, a)})), + _ => fee.push( + json!({"asset": o.asset, "amount": o.amount, "shown": amount(view, &o.asset, a)}), + ), + } + } + let show_map = |m: &BTreeMap| -> Vec { + m.iter() + .map(|(asset, d)| json!({"asset": asset, "change": d.to_string(), "shown": amount(view, asset, *d)})) + .collect() + }; + let wallet_change = if wallet.is_empty() { + vec![ + json!({"asset": null, "change": "0", "shown": "no change in any asset: this spend moves the contract's coin, not the wallet's"}), + ] + } else { + show_map(&wallet) + }; + + let summary = json!({ + "template": { + "shown": shown_template, + "hash": template.hash(), + "registered": view.registry.is_some(), + "names_itself": template.self_name(), + "version": template.version(), + "summary": template.summary(), + "roots": roots, + }, + "path": { + "name": spend.path.name, + "who": spend.path.who, + "effect": spend.path.effect, + "leaf": leaf, + "kind": spend.path.kind, + }, + "params": params, + "slots": slots, + "contract": { + "script_pubkey": contract.derived.script_pubkey, + "coin": format!("{}:{}", spend.outpoint.txid, spend.outpoint.vout), + "change": show_map(&contract_change), + }, + "wallet_change": wallet_change, + "payments": payments, + "fee": fee, + "outputs": spend.outputs, + "sequence": format!("{:#010x} ({})", spend.sequence(), show_sequence(spend.sequence())), + "locks": locks, + "checks": { + "1_known_template": format!("{} is on this wallet's list", template.hash()), + "2_output_recomputed": format!("the engine derived {} from the template and the instance; the coin pays it", contract.derived.script_pubkey), + "3_program_run": if spend.path.kind == "simplicity" { + format!("the program ran against the final transaction: cost {} milli-WU of a {} WU budget{}", + finalized.cost_mwu, finalized.budget_wu, + if finalized.annex_bytes > 0 { format!(", padded with a {}-byte annex", finalized.annex_bytes) } else { String::new() }) + } else { + "a tapscript leaf: no program to run; its script is recomputed from the template".into() + }, + "4_shown": "this summary", + "5_contract_key": format!("{key_param} is this wallet's contract key at {key_path}"), + }, + "unsigned_txid": spend.tx.txid().to_string(), + "unsigned_tx": encode::serialize_hex(&spend.tx), + "weight": weight, + "vsize": vsize, + }); + Ok((summary, finalized)) +} + +/// The asset the template's amounts are in: its one `asset` parameter, when +/// it has exactly one. Otherwise an amount is shown in atoms. +fn template_asset(spend: &Spend) -> Option { + let assets: Vec<_> = spend + .contract + .template + .params() + .iter() + .filter(|p| p.role == "asset") + .collect(); + match assets.as_slice() { + [one] => Some(show_value( + "asset", + &spend.contract.instance.params[&one.name], + )), + _ => None, + } +} + +/// The asset of the coin, display hex. +fn contract_asset(spend: &Spend) -> Option { + spend.coin.asset.explicit().map(|a| a.to_string()) +} diff --git a/lwk_contracts/src/lib.rs b/lwk_contracts/src/lib.rs index a294785f8..5c6c02966 100644 --- a/lwk_contracts/src/lib.rs +++ b/lwk_contracts/src/lib.rs @@ -11,6 +11,7 @@ //! `templates/` holds the templates the kit carries, copied from //! `sequentia-contracts` at the revision `templates/PIN.json` names. +pub mod approval; pub mod budget; pub mod drip; pub mod error; @@ -19,6 +20,7 @@ pub mod known; pub mod spend; pub mod template; +pub use approval::{Approval, AssetLabel, RegistryName, WalletView}; pub use error::Error; pub use known::{known, KnownTemplate, KNOWN}; pub use spend::{ChainFacts, CoinRequest, OutputRequest, Spend, SpendRequest}; diff --git a/lwk_contracts/src/spend.rs b/lwk_contracts/src/spend.rs index fb8626e90..370e69eab 100644 --- a/lwk_contracts/src/spend.rs +++ b/lwk_contracts/src/spend.rs @@ -693,8 +693,9 @@ impl Spend { /// 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 { + /// names. A wallet reaches it only through the five-point gate, + /// [`crate::approval::Approval`], which signs only what it showed. + pub(crate) 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() { diff --git a/lwk_contracts/tests/approval.rs b/lwk_contracts/tests/approval.rs new file mode 100644 index 000000000..68b9a3f75 --- /dev/null +++ b/lwk_contracts/tests/approval.rs @@ -0,0 +1,164 @@ +//! The five-point gate, with no chain: a one-key contract's spend is shown and +//! signed only as the rule allows. `drip_regtest.rs` makes the same checks on a +//! chain, with a covenant, and puts the transactions in blocks. + +use std::collections::BTreeMap; +use std::str::FromStr; +use std::sync::Arc; + +use lwk_contracts::approval::{Approval, RegistryName, WalletView}; +use lwk_contracts::spend::{ + default_contract_key_path, Chain, CoinRequest, OutputRequest, Spend, SpendRequest, +}; +use lwk_contracts::{known, Contract, Instance}; +use lwk_signer::SwSigner; +use serde_json::json; +use simplicityhl::elements::BlockHash; + +// Public test mnemonics. +const OWNER: &str = "exist carry drive collect lend cereal occur much tiger just involve mean"; +const OTHER: &str = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; +const GENESIS: &str = "ddd11d54c87a2bd94400fd31ce05d8e1110bb4b78e7103f738342086fc4ea92e"; +const ASSET: &str = "b2e15d0d7a0c94e4e2ce0fe6e8691b9e451377f6e46e8045a86f7c4b5d4f0f23"; + +fn setup() -> (Spend, SwSigner) { + let owner = SwSigner::new(OWNER, false).unwrap(); + let pk = Spend::key_at(&owner, &default_contract_key_path(false)).unwrap(); + let template = Arc::new(known(known::ONE_KEY).unwrap().template().unwrap()); + let instance = Instance { + instance: 1, + template_hash: known::ONE_KEY.into(), + params: BTreeMap::from([("PK".into(), pk.to_string())]), + slots: BTreeMap::new(), + genesis: Some(GENESIS.into()), + }; + let contract = Arc::new(Contract::new(template, instance).unwrap()); + let request = SpendRequest { + path: "spend".into(), + coin: CoinRequest { + txid: "11".repeat(32), + vout: 1, + script_pubkey: contract.derived.script_pubkey.clone(), + asset: ASSET.into(), + amount: 100_000, + }, + sequence: None, + lock_time: None, + outputs: vec![ + OutputRequest { + to: "pay".into(), + address: None, + script: Some(format!("0014{}", "22".repeat(20))), + asset: ASSET.into(), + amount: 99_000, + }, + OutputRequest { + to: "fee".into(), + address: None, + script: None, + asset: ASSET.into(), + amount: 1_000, + }, + ], + spender: BTreeMap::new(), + next_slots: None, + }; + let chain = Chain { + genesis: BlockHash::from_str(GENESIS).unwrap(), + mainnet: false, + }; + ( + Spend::build(contract, chain, &request, &[], None).unwrap(), + owner, + ) +} + +fn view() -> WalletView { + WalletView { + known: vec![known::ONE_KEY.into()], + ..Default::default() + } +} + +#[test] +fn the_approval_shows_what_is_signed_and_only_that_is_signed() { + let (spend, owner) = setup(); + let a = Approval::prepare(spend.clone(), view(), &owner).unwrap(); + let s = a.summary(); + assert_eq!( + s["template"]["shown"], + json!("an unregistered template, root c1a71ea2b1ccb88c315419a16ab142eab3864976863ee3c23ccb6bcaeee546fa") + ); + assert_eq!(s["path"]["name"], json!("spend")); + assert_eq!(s["params"][0]["role"], json!("pubkey")); + assert!(s["params"][0]["shown"] + .as_str() + .unwrap() + .contains("this wallet's contract key")); + assert_eq!(s["wallet_change"][0]["change"], json!("0")); + assert_eq!(s["payments"][0]["amount"], json!(99_000)); + assert_eq!(s["fee"][0]["amount"], json!(1_000)); + assert!(s["checks"]["3_program_run"] + .as_str() + .unwrap() + .starts_with("the program ran against the final transaction")); + + // Only the digest shown signs. + let e = a.sign(&"00".repeat(32), &owner).unwrap_err().to_string(); + assert!(e.contains("is not what was shown"), "{e}"); + let tx = a.sign(a.digest(), &owner).unwrap(); + assert_eq!(tx.input[0].witness.script_witness.len(), 4); + // Another wallet cannot sign what this one prepared. + let other = SwSigner::new(OTHER, false).unwrap(); + let e = a.sign(a.digest(), &other).unwrap_err().to_string(); + assert!(e.contains("is signed by PK"), "{e}"); + + // The registry's name, where it has one. + let mut v = view(); + v.registry = Some(RegistryName { + name: "sequentia/one-key".into(), + version: 1, + }); + let b = Approval::prepare(spend, v, &owner).unwrap(); + assert_eq!( + b.summary()["template"]["shown"], + json!("sequentia/one-key v1 (registered)") + ); + assert_ne!( + a.digest(), + b.digest(), + "a different screen is a different digest" + ); +} + +#[test] +fn the_rule_refuses_what_it_must() { + let (spend, owner) = setup(); + // 1. A template not on the wallet's list. + let e = Approval::prepare(spend.clone(), WalletView::default(), &owner) + .unwrap_err() + .to_string(); + assert!( + e.contains("is not on this wallet's list of known templates"), + "{e}" + ); + // 5. A key outside the contract account, and a contract key the path does not name. + let mut v = view(); + v.key_path = Some("m/84h/1h/0h/0/0".into()); + let e = Approval::prepare(spend.clone(), v, &owner) + .unwrap_err() + .to_string(); + assert!(e.contains("is not a contract key"), "{e}"); + let mut v = view(); + v.key_path = Some("m/8383h/1h/0h/0/1".into()); + let e = Approval::prepare(spend.clone(), v, &owner) + .unwrap_err() + .to_string(); + assert!(e.contains("is signed by PK"), "{e}"); + let other = SwSigner::new(OTHER, false).unwrap(); + let e = Approval::prepare(spend, view(), &other) + .unwrap_err() + .to_string(); + assert!(e.contains("is signed by PK"), "{e}"); +} diff --git a/lwk_contracts/tests/drip_regtest.rs b/lwk_contracts/tests/drip_regtest.rs index ba1371c49..759160c57 100644 --- a/lwk_contracts/tests/drip_regtest.rs +++ b/lwk_contracts/tests/drip_regtest.rs @@ -1,6 +1,6 @@ -//! The engine drips from a faucet drip covenant on a local chain, and refuses -//! three bad drips before signing: one before the interval, one above the -//! tier, one whose successor is not the covenant. Each +//! The engine drips from a faucet drip covenant on a local chain, under the +//! five-point rule, and refuses three bad drips before signing: one before the +//! interval, one above the tier, one whose successor is not the covenant. Each //! bad drip is then forced on the chain as an adversary would build it (the //! control's pruned program, re-signed for the bad transaction, as the //! contracts harness does) and refused by the mempool and in a block made with @@ -18,9 +18,8 @@ use std::process::{Child, Command, Stdio}; use std::str::FromStr; use std::sync::Arc; -use lwk_contracts::spend::{ - default_contract_key_path, Chain, ChainFacts, CoinRequest, Finalized, Spend, -}; +use lwk_contracts::approval::{Approval, AssetLabel, WalletView}; +use lwk_contracts::spend::{default_contract_key_path, Chain, ChainFacts, CoinRequest, Spend}; use lwk_contracts::{drip, known, Contract, Instance}; use lwk_signer::SwSigner; use serde_json::{json, Value}; @@ -273,7 +272,7 @@ fn rec(log: &mut Vec, label: &str, v: Value) { } #[test] -fn a_drip_built_and_signed_by_the_engine() { +fn a_drip_under_the_five_point_rule() { let Some(bin) = std::env::var_os("SEQUENTIA_BIN").map(PathBuf::from) else { println!("skipped: set SEQUENTIA_BIN to a directory holding sequentiad and sequentia-cli"); return; @@ -395,22 +394,32 @@ fn a_drip_built_and_signed_by_the_engine() { } }; let dest = node.ok(&["getnewaddress", "", "bech32"]); + let view = WalletView { + known: vec![known::FAUCET_DRIP.into()], + registry: None, + assets: BTreeMap::from([( + policy.clone(), + AssetLabel { + ticker: "tSEQ".into(), + precision: 8, + }, + )]), + key_path: None, + }; let rate = 1000; // atoms per 1,000 vB, in the dripped asset - // A drip as a wallet makes it: planned, its locks checked, signed and run - // against its final transaction, then planned again at its real size. + // A drip as the wallet makes it: planned, measured, planned again at its real size. let prepare = |coin: &CoinRequest, amount: u64, facts: ChainFacts| - -> Result<(Spend, Finalized), lwk_contracts::Error> { + -> Result { let mut fee = drip::fee_for(581, rate); for _ in 0..2 { let req = drip::plan(&contract, coin, &dest, amount, fee)?; let spend = Spend::build(contract.clone(), chain, &req, &[], Some(facts.clone()))?; - spend.check_locks()?; - let f = spend.finalize(&faucet, &key_path)?; - let need = drip::fee_for(u64::try_from(f.tx.weight().div_ceil(4)).unwrap(), rate); + let a = Approval::prepare(spend, view.clone(), &faucet)?; + let need = drip::fee_for(a.summary()["vsize"].as_u64().unwrap(), rate); if need == fee { - return Ok((spend, f)); + return Ok(a); } fee = need; } @@ -431,42 +440,18 @@ fn a_drip_built_and_signed_by_the_engine() { // The first drip. node.advance(INTERVAL * 512); - let (_, f1) = prepare(&coin, 500 * COIN, node.facts(&coin.txid)).unwrap(); - rec( - &mut log, - "drip 1 built", - json!({"cost_mwu": f1.cost_mwu, "budget_wu": f1.budget_wu, "annex_bytes": f1.annex_bytes}), - ); - // Another wallet's contract key is not the faucet key. - let other = SwSigner::new(TREASURY, false).unwrap(); - let req = drip::plan(&contract, &coin, &dest, 500 * COIN, 581).unwrap(); - let e = Spend::build( - contract.clone(), - chain, - &req, - &[], - Some(node.facts(&coin.txid)), - ) - .unwrap() - .finalize(&other, &key_path) - .unwrap_err() - .to_string(); - assert!(e.contains("is signed by FAUCET_KEY"), "{e}"); - rec(&mut log, "another wallet's key", json!(e)); - let e = Spend::build( - contract.clone(), - chain, - &req, - &[], - Some(node.facts(&coin.txid)), - ) - .unwrap() - .finalize(&faucet, "m/84h/1h/0h/0/0") - .unwrap_err() - .to_string(); - assert!(e.contains("is not a contract key"), "{e}"); - rec(&mut log, "a key outside the contract account", json!(e)); - let tx1 = f1.tx; + let a = prepare(&coin, 500 * COIN, node.facts(&coin.txid)).unwrap(); + let summary = a.summary(); + rec(&mut log, "approval shown for drip 1", summary.clone()); + assert_eq!(summary["template"]["shown"], json!("an unregistered template, root 5251ec00d9799dbcdb31da4534f25ef9960321f195e2e24ef7125c46f24b972a")); + assert_eq!(summary["path"]["name"], json!("drip")); + // A signature over any other digest is refused. + let e = a.sign(&"00".repeat(32), &faucet).unwrap_err().to_string(); + assert!(e.contains("is not what was shown"), "{e}"); + rec(&mut log, "a digest other than the one shown", json!(e)); + let tx1 = a + .sign(summary["digest"].as_str().unwrap(), &faucet) + .unwrap(); let hex1 = serialize_hex(&tx1); let txid1 = node.ok(&["sendrawtransaction", &hex1]); node.mine(1); @@ -475,7 +460,7 @@ fn a_drip_built_and_signed_by_the_engine() { rec( &mut log, "drip 1 confirmed", - json!({"txid": txid1, "vsize": conf["vsize"], "weight": conf["weight"], "fee_atoms": tx1.output[2].value.explicit()}), + json!({"txid": txid1, "vsize": conf["vsize"], "weight": conf["weight"], "fee_atoms": summary["fee"][0]["amount"]}), ); // The second drip, too early: refused by the engine before signing... @@ -492,8 +477,10 @@ fn a_drip_built_and_signed_by_the_engine() { // ...and by the chain when an engine is told the interval has passed. let mut lie = node.facts(&coin.txid); lie.tip_median_time += 10_000; - let (_, f) = prepare(&coin, 500 * COIN, lie).unwrap(); - let early = f.tx; + let a = prepare(&coin, 500 * COIN, lie).unwrap(); + let early = a + .sign(a.summary()["digest"].as_str().unwrap(), &faucet) + .unwrap(); let early_hex = serialize_hex(&early); let (m, b) = (node.mempool(&early_hex), node.force(&early_hex)); assert_eq!(m, "non-BIP68-final"); @@ -507,15 +494,20 @@ fn a_drip_built_and_signed_by_the_engine() { node.advance(INTERVAL * 512); let facts = node.facts(&coin.txid); // The control: a good drip for this coin, which the node accepts. - let (control_spend, control_f) = prepare(&coin, 500 * COIN, facts.clone()).unwrap(); - let control = control_f.tx; + let control_a = prepare(&coin, 500 * COIN, facts.clone()).unwrap(); + let control = control_a + .sign(control_a.summary()["digest"].as_str().unwrap(), &faucet) + .unwrap(); assert_eq!(node.mempool(&serialize_hex(&control)), "allowed"); - let (keypair, _) = control_spend.contract_keypair(&faucet, &key_path).unwrap(); + let (keypair, _) = control_a + .spend() + .contract_keypair(&faucet, &key_path) + .unwrap(); let control_sig: [u8; 64] = { let cb = ControlBlock::from_slice(&control.input[0].witness.script_witness[3]).unwrap(); let msg = Message::from_digest(sig_all_hash( &control, - &control_spend.coin, + &control_a.spend().coin, "5251ec00d9799dbcdb31da4534f25ef9960321f195e2e24ef7125c46f24b972a", &cb, genesis, @@ -543,7 +535,9 @@ fn a_drip_built_and_signed_by_the_engine() { req.outputs[1].amount = tier + 1; req.outputs[0].amount -= 1; let spend = Spend::build(contract.clone(), chain, &req, &[], Some(facts.clone())).unwrap(); - let e = spend.finalize(&faucet, &key_path).unwrap_err().to_string(); + let e = Approval::prepare(spend, view.clone(), &faucet) + .unwrap_err() + .to_string(); assert!( e.contains("the contract's program refuses this transaction"), "{e}" @@ -563,7 +557,7 @@ fn a_drip_built_and_signed_by_the_engine() { &control_sig, &bad, &keypair, - &control_spend.coin, + &control_a.spend().coin, cmr, genesis, ); @@ -598,7 +592,9 @@ fn a_drip_built_and_signed_by_the_engine() { ); req.outputs[0].to = "pay".into(); let spend = Spend::build(contract.clone(), chain, &req, &[], Some(facts.clone())).unwrap(); - let e = spend.finalize(&faucet, &key_path).unwrap_err().to_string(); + let e = Approval::prepare(spend, view.clone(), &faucet) + .unwrap_err() + .to_string(); assert!( e.contains("the contract's program refuses this transaction"), "{e}" @@ -619,7 +615,7 @@ fn a_drip_built_and_signed_by_the_engine() { &control_sig, &bad, &keypair, - &control_spend.coin, + &control_a.spend().coin, cmr, genesis, ); diff --git a/lwk_wasm/README.md b/lwk_wasm/README.md index 3c96c1b4c..3c554bdbf 100644 --- a/lwk_wasm/README.md +++ b/lwk_wasm/README.md @@ -13,7 +13,8 @@ > `buildCovenantFillTx()` / `buildCovenantRefundTx()` (SeqOB covenant orders), > `coinjoinSignInputs()` / `coinjoinUnblindOutputs()` (CoinJoin), the `Openamp` > client, `adaptor*` functions, and the contract engine (`ContractTemplate`, -> `ContractInstance`, `ContractSpend`). See +> `ContractInstance`, `ContractSpend`, `ContractApproval`, +> `Signer.signContractSpend`). See > [SEQUENTIA.md](../SEQUENTIA.md). This fork is NOT published to npm (the > `lwk_wasm` npm package is upstream LWK): build `pkg/` yourself with > `./build-web.sh` (`wasm-pack build --target web --release` with the build diff --git a/lwk_wasm/src/contract_engine.rs b/lwk_wasm/src/contract_engine.rs index 2aa2360be..f63e8f440 100644 --- a/lwk_wasm/src/contract_engine.rs +++ b/lwk_wasm/src/contract_engine.rs @@ -302,3 +302,59 @@ impl ContractSpend { Ok(self.inner.check_locks().map_err(err)?.unwrap_or_default()) } } + +/// A contract spend checked under the five-point rule, with the summary the +/// wallet shows before it signs. +#[wasm_bindgen] +pub struct ContractApproval { + pub(crate) inner: lwk_contracts::Approval, +} + +#[wasm_bindgen] +impl ContractApproval { + /// Checks `spend` for `signer`: the template is on the wallet's list + /// (`viewJson.known`), the output was recomputed and is the coin's, the + /// chain's locks have passed, the key is the wallet's contract key the + /// path names (`viewJson.key_path`, by default `0/0` under + /// `m/8383h/{coin}h/0h`), and the program accepts the final transaction. + /// `viewJson` may also carry the registry's name for the template + /// (`registry: {name, version}`) and asset labels + /// (`assets: {"": {ticker, precision}}`). Throws the first rule that fails. + pub fn prepare( + spend: &ContractSpend, + signer: &crate::Signer, + view_json: &str, + ) -> Result { + let view: lwk_contracts::WalletView = serde_json::from_str(view_json)?; + if crate::contract_engine::chain_of_signer(signer) != spend.inner.chain { + return Err(Error::Generic( + "the signer is for another chain than the spend".into(), + )); + } + Ok(ContractApproval { + inner: lwk_contracts::Approval::prepare(spend.inner.clone(), view, &signer.inner) + .map_err(err)?, + }) + } + + /// What the wallet shows, as JSON: the template (by registry name, else + /// its root), the path, the parameters by role, the wallet's balance + /// change in every asset, the contract's, payments, the fee, the checks, + /// and the `digest` that `Signer.signContractSpend` takes back. + pub fn summary(&self) -> Result { + json(&self.inner.summary()) + } + + /// The digest of the summary. + pub fn digest(&self) -> String { + self.inner.digest().to_string() + } +} + +/// The chain a signer was made for. +pub(crate) fn chain_of_signer(signer: &crate::Signer) -> Chain { + Chain { + genesis: signer.network.genesis_hash(), + mainnet: signer.network.is_mainnet(), + } +} diff --git a/lwk_wasm/src/lib.rs b/lwk_wasm/src/lib.rs index b0561c0d4..ff3dd2c8e 100644 --- a/lwk_wasm/src/lib.rs +++ b/lwk_wasm/src/lib.rs @@ -121,7 +121,7 @@ pub use blockdata::wallet_tx_out::{OptionWalletTxOut, WalletTxOut}; pub use boltz::LightningPayment; pub use boltz::{BoltzSession, BoltzSessionBuilder, Invoice}; pub use contract::Contract; -pub use contract_engine::{ContractInstance, ContractSpend, ContractTemplate}; +pub use contract_engine::{ContractApproval, ContractInstance, ContractSpend, ContractTemplate}; pub use csfs::{csfs_describe, csfs_digest}; #[cfg(feature = "simplicity")] pub use control_block::ControlBlock; diff --git a/lwk_wasm/src/signer.rs b/lwk_wasm/src/signer.rs index ac6ab1cd5..74f199b4e 100644 --- a/lwk_wasm/src/signer.rs +++ b/lwk_wasm/src/signer.rs @@ -209,6 +209,24 @@ impl Signer { Ok(sig.to_vec().to_hex()) } + /// Signs a contract spend the wallet has shown: `shownDigest` is the + /// `digest` of the approval's summary as displayed. Refuses any other + /// digest, prepares the spend again and refuses if anything differs, then + /// signs with the contract key and runs the program against the + /// transaction it returns (hex, ready to broadcast). + #[wasm_bindgen(js_name = signContractSpend)] + pub fn sign_contract_spend( + &self, + approval: &crate::ContractApproval, + shown_digest: &str, + ) -> Result { + let tx = approval + .inner + .sign(shown_digest, &self.inner) + .map_err(|e| Error::Generic(e.to_string()))?; + Ok(lwk_wollet::elements::encode::serialize_hex(&tx)) + } + /// The genesis hash (display hex) of the network this signer was made for. #[wasm_bindgen(js_name = genesisHash)] pub fn genesis_hash(&self) -> String { diff --git a/lwk_wasm/tests/node/contract_drip_regtest.js b/lwk_wasm/tests/node/contract_drip_regtest.js new file mode 100644 index 000000000..801861d94 --- /dev/null +++ b/lwk_wasm/tests/node/contract_drip_regtest.js @@ -0,0 +1,215 @@ +// A wallet built on the kit's wasm drips from a faucet drip covenant on a +// local chain, under the five-point rule: it recomputes the covenant's +// address, prepares the drip (the program runs against the final +// transaction), shows the approval (template, path, parameters by role, its +// own balance change in every asset), and signs only that digest. The drip +// confirms. A second drip before the interval, a drip above the tier and a +// drip whose successor is not the covenant are refused by the engine before +// anything is signed; the early drip, signed by an engine that is told the +// interval has passed, is refused by the mempool and in a forced block. +// (The other two are forced into blocks by lwk_contracts/tests/drip_regtest.rs, +// which can build what this kit's wasm will not.) +// +// SEQUENTIA_BIN=/path/to/Sequentia/src node tests/node/contract_drip_regtest.js [log.json] +// +// The chain's data directory is a fresh temporary directory, removed at the end. +const fs = require('fs') +const os = require('os') +const path = require('path') +const net = require('net') +const assert = require('assert') +const { execFileSync, spawn } = require('child_process') +const lwk = require(process.env.LWK_WASM_PKG || 'lwk_node') + +const BIN = process.env.SEQUENTIA_BIN +if (!BIN) { console.log('skipped: set SEQUENTIA_BIN'); process.exit(0) } +const LOG = process.argv[2] + +// Public test mnemonics, never funded anywhere but a local chain. +const FAUCET = 'exist carry drive collect lend cereal occur much tiger just involve mean' +const TREASURY = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' +const COIN = 100000000n +const RESERVE = 2000000n * COIN +const FEE_CAP = 100000n +const INTERVAL = 1 +const TIME = 1 << 22 +const RATE = 1000n // atoms of the dripped asset per 1,000 vB +const KEY_PATH = "m/8383h/1h/0h/0/0" +const DRIP_HASH = '12986f202fbfb850f7699c5d5188f261f276de6c7038142f0a28bbb672b5af34' + +const errText = e => (e && (e.message || (typeof e.toString === 'function' && e.toString()))) || String(e) +const hex16 = (n, w) => BigInt(n).toString(16).padStart(w, '0') +const freePort = () => new Promise((resolve, reject) => { + const s = net.createServer(); s.once('error', reject) + s.listen(0, '127.0.0.1', () => { const { port } = s.address(); s.close(() => resolve(port)) }) +}) +const log = [] +const rec = (step, result) => { console.log(`${step}: ${typeof result === 'string' ? result : JSON.stringify(result)}`); log.push({ step, result }) } + +async function main () { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'swk-contract-drip-')) + const [port, rpcport] = [await freePort(), await freePort()] + fs.writeFileSync(path.join(dir, 'elements.conf'), [ + 'chain=elementsregtest', '[elementsregtest]', 'server=1', 'listen=0', `port=${port}`, `rpcport=${rpcport}`, + 'rpcbind=127.0.0.1', 'rpcallowip=127.0.0.1', 'initialfreecoins=2100000000000000', 'anyonecanspendaremine=1', + 'blindedaddresses=0', 'con_default_blinded_addresses=0', 'validatepegin=0', 'con_parent_chain_signblockscript=51', + 'con_any_asset_fees=1', 'evbparams=simplicity:-1:::', 'par=1', 'txindex=1', 'fallbackfee=0.0001', 'maxtxfee=100', '' + ].join('\n')) + const node = spawn(path.join(BIN, 'sequentiad'), [`-datadir=${dir}`], { stdio: 'ignore' }) + const cli = (...a) => execFileSync(path.join(BIN, 'sequentia-cli'), [`-datadir=${dir}`, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim() + const tryCli = (...a) => { try { return { ok: cli(...a) } } catch (e) { return { err: String(e.stderr || e.message).trim() } } } + const json = (...a) => JSON.parse(cli(...a)) + const stop = () => new Promise(resolve => { + const done = () => { fs.rmSync(dir, { recursive: true, force: true }); resolve() } + try { cli('stop') } catch (e) { node.kill() } + if (node.exitCode !== null) done(); else node.once('exit', done) + }) + try { + for (let i = 0; ; i++) { + try { cli('getblockchaininfo'); break } catch (e) { + if (i > 240) throw new Error('the node did not start') + await new Promise(resolve => setTimeout(resolve, 500)) + } + } + rec('node', cli('-version').split('\n')[0]) + cli('createwallet', 'treasury') + const mine = n => cli('generatetoaddress', String(n), cli('getnewaddress')) + mine(101); cli('rescanblockchain') + cli('-named', 'sendtoaddress', `address=${cli('getnewaddress')}`, 'amount=1000000', 'fee_asset_label=bitcoin') + mine(1) + const policy = json('getsidechaininfo').pegged_asset + const genesis = cli('getblockhash', '0') + const tip = () => json('getblockheader', cli('getbestblockhash')) + const advance = secs => { cli('setmocktime', String(tip().time + secs + 60)); mine(12) } + const facts = txid => { + const height = json('getblockheader', json('getrawtransaction', txid, 'true').blockhash).height + const t = tip() + return { tip_height: t.height, tip_median_time: t.mediantime, coin_height: height, coin_start_median_time: json('getblockheader', cli('getblockhash', String(height - 1))).mediantime } + } + + // The wallet: the kit's signer on this chain. Its contract key is the faucet key. + const network = lwk.Network.regtestWithGenesis(new lwk.AssetId(policy), genesis) + const wallet = new lwk.Signer(new lwk.Mnemonic(FAUCET), network) + const treasury = new lwk.Signer(new lwk.Mnemonic(TREASURY), network) + const faucetKey = wallet.xonlyPublicKeyAt(KEY_PATH) + const params = { + ASSET: Buffer.from(policy, 'hex').reverse().toString('hex'), + FAUCET_KEY: faucetKey, + TREASURY_KEY: treasury.xonlyPublicKeyAt(KEY_PATH), + INTERVAL: hex16(INTERVAL, 4), + FEE_CAP: hex16(FEE_CAP, 16), + RECOVERY_DELAY: hex16(TIME | 2, 8) + } + const tiers = [1000000n, 500n, 100000n, 200n, 10000n, 20n, 2n].map(n => n * COIN) + ;['TIER1_FLOOR', 'TIER1_MAX', 'TIER2_FLOOR', 'TIER2_MAX', 'TIER3_FLOOR', 'TIER3_MAX', 'TIER4_MAX'].forEach((k, i) => { params[k] = hex16(tiers[i], 16) }) + const template = lwk.ContractTemplate.known(DRIP_HASH) + const instance = new lwk.ContractInstance(template, JSON.stringify({ instance: 2, template_hash: DRIP_HASH, params, slots: {}, genesis })) + const covenant = instance.address(network) + // Point 2: the node decodes the engine's address to the engine's own script. + assert.strictEqual(json('getaddressinfo', covenant).scriptPubKey, instance.scriptPubkey()) + rec('covenant', { address: covenant, script_pubkey: instance.scriptPubkey(), paths: JSON.parse(instance.paths()).map(p => `${p.name} (${p.kind})`) }) + + const fund = cli('-named', 'sendtoaddress', `address=${covenant}`, `amount=${(RESERVE / COIN).toString()}`, 'fee_asset_label=bitcoin') + mine(1) + const coinOf = txid => { + const o = json('getrawtransaction', txid, 'true').vout.find(o => o.scriptPubKey.hex === instance.scriptPubkey()) + return { txid, vout: o.n, script_pubkey: instance.scriptPubkey(), asset: o.asset, amount: Number(BigInt(Math.round(o.value * 1e8))) } + } + // The drip pays this wallet's own address on the chain. + const dest = cli('getnewaddress', '', 'bech32') + const destScript = json('getaddressinfo', dest).scriptPubKey + const view = { known: lwk.ContractTemplate.knownList() && JSON.parse(lwk.ContractTemplate.knownList()).map(k => k.hash), registry: { name: 'sequentia/faucet-drip', version: 1 }, assets: { [policy]: { ticker: 'tSEQ', precision: 8 } } } + const prepare = (coin, amount, chain) => { + let fee = (581n * RATE + 999n) / 1000n + for (let i = 0; i < 2; i++) { + const req = instance.planDrip(JSON.stringify(coin), dest, BigInt(amount), fee) + const spend = lwk.ContractSpend.build(instance, network, req, JSON.stringify([destScript]), JSON.stringify(chain)) + const approval = lwk.ContractApproval.prepare(spend, wallet, JSON.stringify(view)) + const need = (BigInt(JSON.parse(approval.summary()).vsize) * RATE + 999n) / 1000n + if (need === fee) return approval + fee = need + } + throw new Error('the size does not depend on the fee') + } + const refusedBy = (label, f, want) => { + let e + try { f(); assert.fail(`${label}: not refused`) } catch (x) { e = errText(x) } + assert.ok(e.includes(want), `${label}: refused, but not for ${want}: ${e}`) + rec(label, e) + } + + let coin = coinOf(fund) + refusedBy('engine refuses the first drip before the interval', () => prepare(coin, 500n * COIN, facts(coin.txid)), 'non-BIP68-final') + + advance(INTERVAL * 512) + const a = prepare(coin, 500n * COIN, facts(coin.txid)) + const shown = JSON.parse(a.summary()) + // What the wallet shows before it signs. + const screen = { + template: shown.template.shown, + path: `${shown.path.name}: ${shown.path.effect}`, + who: shown.path.who, + params: shown.params.map(p => `${p.label} [${p.role}]: ${p.shown}`), + wallet_change: shown.wallet_change.map(c => c.shown), + contract_change: shown.contract.change.map(c => c.shown), + payments: shown.payments.map(p => p.shown), + fee: shown.fee.map(f => f.shown), + checks: shown.checks, + digest: shown.digest + } + rec('approval shown for drip 1', screen) + assert.strictEqual(shown.template.shown, 'sequentia/faucet-drip v1 (registered)') + assert.strictEqual(shown.path.name, 'drip') + assert.deepStrictEqual(shown.wallet_change.map(c => c.shown), ['500.00000000 tSEQ']) + refusedBy('a digest other than the one shown', () => wallet.signContractSpend(a, '00'.repeat(32)), 'is not what was shown') + refusedBy('another wallet signing the approval', () => treasury.signContractSpend(a, shown.digest), 'is signed by FAUCET_KEY') + const tx1 = wallet.signContractSpend(a, shown.digest) + const txid1 = cli('sendrawtransaction', tx1) + mine(1) + const c1 = json('getrawtransaction', txid1, 'true') + assert.ok(c1.confirmations >= 1) + assert.strictEqual(c1.vsize, shown.vsize) + rec('drip 1 confirmed', { txid: txid1, vsize: c1.vsize, weight: c1.weight, fee: shown.fee[0].shown }) + + coin = coinOf(txid1) + refusedBy('engine refuses drip 2 before the interval', () => prepare(coin, 500n * COIN, facts(coin.txid)), 'non-BIP68-final') + const lie = facts(coin.txid); lie.tip_median_time += 10000 + const early = prepare(coin, 500n * COIN, lie) + const earlyHex = wallet.signContractSpend(early, JSON.parse(early.summary()).digest) + const m = json('testmempoolaccept', JSON.stringify([earlyHex]))[0]['reject-reason'] + const b = tryCli('generateblock', cli('getnewaddress'), JSON.stringify([earlyHex])).err + assert.strictEqual(m, 'non-BIP68-final') + assert.ok(b.includes('bad-txns-nonfinal'), b) + rec('chain refuses drip 2 before the interval (signed by an engine told the interval had passed)', { mempool: m, block: b }) + + advance(INTERVAL * 512) + const f2 = facts(coin.txid) + const tier = instance.dripTier(BigInt(coin.amount)) + refusedBy('engine refuses a drip above the tier', () => prepare(coin, tier + 1n, f2), 'is above the') + // The same, asked for directly: the program run refuses it. + const req = JSON.parse(instance.planDrip(JSON.stringify(coin), dest, tier, 581n)) + req.outputs[1].amount = Number(tier) + 1; req.outputs[0].amount -= 1 + refusedBy('engine refuses a drip above the tier, asked for directly (program run)', () => lwk.ContractApproval.prepare(lwk.ContractSpend.build(instance, network, JSON.stringify(req), '[]', JSON.stringify(f2)), wallet, JSON.stringify(view)), 'the check that fails is `assert!(jet::le_64(drip, tier(reserve, floors, maxes)))`') + const bad = JSON.parse(instance.planDrip(JSON.stringify(coin), dest, 500n * COIN, 581n)) + bad.outputs[0].script = undefined; bad.outputs[0].address = dest + refusedBy('engine refuses a successor that is not the covenant', () => lwk.ContractSpend.build(instance, network, JSON.stringify(bad), '[]', JSON.stringify(f2)), 'is said to return to the contract, but pays the script') + bad.outputs[0].to = 'pay' + refusedBy('engine refuses a successor that is not the covenant, named as a payment (program run)', () => lwk.ContractApproval.prepare(lwk.ContractSpend.build(instance, network, JSON.stringify(bad), '[]', JSON.stringify(f2)), wallet, JSON.stringify(view)), 'the check that fails is') + // A template the wallet does not know is not signed. + refusedBy('a template not on the wallet\'s list', () => lwk.ContractApproval.prepare(lwk.ContractSpend.build(instance, network, instance.planDrip(JSON.stringify(coin), dest, 500n * COIN, 581n), '[]', JSON.stringify(f2)), wallet, JSON.stringify({ known: [] })), 'is not on this wallet\'s list of known templates') + // A key outside the contract account is not used. + refusedBy('a key that is not a contract key', () => lwk.ContractApproval.prepare(lwk.ContractSpend.build(instance, network, instance.planDrip(JSON.stringify(coin), dest, 500n * COIN, 581n), '[]', JSON.stringify(f2)), wallet, JSON.stringify({ known: [DRIP_HASH], key_path: "m/84h/1h/0h/0/0" })), 'is not a contract key') + + const a2 = prepare(coin, 500n * COIN, f2) + const txid2 = cli('sendrawtransaction', wallet.signContractSpend(a2, JSON.parse(a2.summary()).digest)) + mine(1) + assert.ok(json('getrawtransaction', txid2, 'true').confirmations >= 1) + rec('drip 2 confirmed', { txid: txid2 }) + if (LOG) fs.writeFileSync(LOG, JSON.stringify(log, null, 2)) + console.log('all passed') + } finally { + await stop() + } +} + +main().catch(e => { console.error(e); process.exit(1) })