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) })