From 811f469644423f9e6fd8f51627955f6073c2a804 Mon Sep 17 00:00:00 2001 From: Kyryl R Date: Wed, 17 Jun 2026 13:36:20 +0300 Subject: [PATCH 01/13] basic multisig 1 of 3 without nonce check --- Cargo.lock | 26 +- Cargo.toml | 2 +- crates/contracts/Cargo.toml | 7 +- crates/contracts/README.md | 7 +- crates/contracts/build.rs | 20 ++ crates/contracts/simf/multisig_n_of_3.simf | 94 +++++++ crates/contracts/simf/setup_placeholder.simf | 1 - crates/contracts/simf/vote.simf | 31 +++ crates/contracts/src/constants.rs | 11 + crates/contracts/src/lib.rs | 6 + crates/contracts/src/multisig_builder.rs | 279 +++++++++++++++++++ crates/contracts/src/runner.rs | 36 +++ crates/contracts/src/scripts.rs | 25 ++ crates/contracts/src/vote_builder.rs | 68 +++++ crates/contracts/tests/common/mod.rs | 1 + crates/contracts/tests/mod.rs | 2 + crates/contracts/tests/regtest/mod.rs | 1 + crates/contracts/tests/setup.rs | 6 - justfile | 2 +- 19 files changed, 599 insertions(+), 26 deletions(-) create mode 100644 crates/contracts/build.rs create mode 100644 crates/contracts/simf/multisig_n_of_3.simf delete mode 100644 crates/contracts/simf/setup_placeholder.simf create mode 100644 crates/contracts/simf/vote.simf create mode 100644 crates/contracts/src/constants.rs create mode 100644 crates/contracts/src/multisig_builder.rs create mode 100644 crates/contracts/src/runner.rs create mode 100644 crates/contracts/src/scripts.rs create mode 100644 crates/contracts/src/vote_builder.rs create mode 100644 crates/contracts/tests/common/mod.rs create mode 100644 crates/contracts/tests/mod.rs create mode 100644 crates/contracts/tests/regtest/mod.rs delete mode 100644 crates/contracts/tests/setup.rs diff --git a/Cargo.lock b/Cargo.lock index 47748de..b03b68a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1373,8 +1373,8 @@ name = "simplicity-native-multisig-contracts" version = "0.1.0" dependencies = [ "anyhow", + "smplx-build", "smplx-std", - "thiserror", ] [[package]] @@ -1441,9 +1441,9 @@ checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" [[package]] name = "smplx-build" -version = "0.0.5" +version = "0.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3170d79eafea8c119d0b491014aaf2054679ba8c285c453c2acb879b52896b5e" +checksum = "bb75724fd0329de24895b58a4010efeeca9773092294845a324aee141ec3c864" dependencies = [ "glob", "globwalk", @@ -1460,9 +1460,9 @@ dependencies = [ [[package]] name = "smplx-macros" -version = "0.0.5" +version = "0.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05e591d0bb8c971f38ea525b5e1bc18a39b369bc1fdecc07632120142afb7e34" +checksum = "4777f6ff9a27faf98396181d67f04f720e0c472c2a3f1db76c5475b45da8963f" dependencies = [ "smplx-build", "smplx-test", @@ -1471,9 +1471,9 @@ dependencies = [ [[package]] name = "smplx-regtest" -version = "0.0.5" +version = "0.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b42c1ad54f123195eb90543e88a2e3b1519a11479862ac29a5cd7a0e24f13d7" +checksum = "bf3711284f8ad7859ae0605841e90316bdf41be5b09011639e765667f07feb5d" dependencies = [ "electrsd", "hex", @@ -1487,9 +1487,9 @@ dependencies = [ [[package]] name = "smplx-sdk" -version = "0.0.5" +version = "0.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe05e7d0f9cef029968453f087f323df01f92e13fc240796a732cffac734074a" +checksum = "c96c7de4f86f4a74dc4c910d6d0541d63cd661e3f616566054a36e589ffcda2c" dependencies = [ "bip39", "bitcoin_hashes", @@ -1507,9 +1507,9 @@ dependencies = [ [[package]] name = "smplx-std" -version = "0.0.5" +version = "0.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8465bd3dae0d1bf37c88f2ba4144e5b5157d390a3c5e56016ff04b64ee9fc199" +checksum = "38cef91f2523cfb6e8937bb1c8004869d539e0dcd8c62fef68c93cc3a6233117" dependencies = [ "either", "serde", @@ -1521,9 +1521,9 @@ dependencies = [ [[package]] name = "smplx-test" -version = "0.0.5" +version = "0.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1025e34b6101ab8e92b6baa4807227894ca324e0405d22a703c2ddd6c5af979" +checksum = "c9c8878fae6868b9fbf7ed7cb3e261dc308b55ea418bb4421685dad99f98fc1f" dependencies = [ "electrsd", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index dbc5040..cc715a9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,6 @@ stable = "1.95.0" serde = { version = "1.0.228", features = ["derive"] } thiserror = "2.0.18" -smplx-std = "0.0.5" +smplx-std = "0.0.6" uniffi = "0.31.1" wasm-bindgen = "0.2.122" diff --git a/crates/contracts/Cargo.toml b/crates/contracts/Cargo.toml index da9afc6..492e35e 100644 --- a/crates/contracts/Cargo.toml +++ b/crates/contracts/Cargo.toml @@ -34,8 +34,9 @@ allowed_duplicates = [ ] [dependencies] +anyhow = "1" + smplx-std = { workspace = true } -thiserror = { workspace = true } -[dev-dependencies] -anyhow = "1" +[build-dependencies] +smplx-build = "0.0.6" diff --git a/crates/contracts/README.md b/crates/contracts/README.md index f4710d0..f53d3ed 100644 --- a/crates/contracts/README.md +++ b/crates/contracts/README.md @@ -10,4 +10,9 @@ Contract sources live in `simf/`. Generated Rust artifacts are written to `src/a ```bash simplex build -``` \ No newline at end of file +``` + +The current proof-of-concept contract is `simf/native_multisig_poc.simf`. +It models a 2-of-3 covenant multisig where the only contract state is a `nonce`. +The signed proposal message is `jet::outputs_hash()`, and the state transition checks +that the next covenant output commits to `nonce + 1`. diff --git a/crates/contracts/build.rs b/crates/contracts/build.rs new file mode 100644 index 0000000..64199d3 --- /dev/null +++ b/crates/contracts/build.rs @@ -0,0 +1,20 @@ +fn main() { + println!("cargo:rerun-if-changed=Simplex.toml"); + println!("cargo:rerun-if-changed=simf"); + + let src_dir = String::from("simf"); + let simf_files = vec![String::from("*.simf")]; + + let simfs = smplx_build::ArtifactsResolver::resolve_files_to_build(&src_dir, &simf_files) + .expect("failed to resolve Simplex contract sources"); + let out_dir = smplx_build::ArtifactsResolver::resolve_local_dir(&"src/artifacts") + .expect("failed to resolve generated artifact directory"); + let base_dir = std::env::current_dir() + .expect("failed to read current directory") + .join(&src_dir) + .canonicalize() + .expect("failed to canonicalize Simplex source directory"); + + smplx_build::ArtifactsGenerator::generate_artifacts(out_dir, base_dir, &simfs) + .expect("failed to generate Simplex artifacts"); +} diff --git a/crates/contracts/simf/multisig_n_of_3.simf b/crates/contracts/simf/multisig_n_of_3.simf new file mode 100644 index 0000000..3a71c4b --- /dev/null +++ b/crates/contracts/simf/multisig_n_of_3.simf @@ -0,0 +1,94 @@ +fn ensure_zero_bit(bit: bool) { + assert!(jet::eq_1(::into(bit), 0)); +} + +fn increment_checked(value: u32) -> u32 { + let (carry, next): (bool, u32) = jet::increment_32(value); + ensure_zero_bit(carry); + next +} + +fn checksig(pk: Pubkey, sig: Signature) { + jet::bip_0340_verify((pk, jet::outputs_hash()), sig); +} + +// vote_executable_leaf_hash is a tapleaf_hash of the vote executable leaf. +fn script_hash_for_vote(vote_executable_leaf_hash: u256, participant_signature: Signature) -> u256 { + let state_hash_ctx1: Ctx8 = jet::sha_256_ctx_8_init(); + let state_hash_ctx2: Ctx8 = jet::sha_256_ctx_8_add_64(state_hash_ctx1, ::into(participant_signature)); + let state_hash: u256 = jet::sha_256_ctx_8_finalize(state_hash_ctx2); + + let tap_leaf: u256 = vote_executable_leaf_hash; + let state_ctx1: Ctx8 = jet::tapdata_init(); + let state_ctx2: Ctx8 = jet::sha_256_ctx_8_add_32(state_ctx1, state_hash); + let state_leaf: u256 = jet::sha_256_ctx_8_finalize(state_ctx2); + let tap_node: u256 = jet::build_tapbranch(tap_leaf, state_leaf); + + // Compute a taptweak using this. + let bip0341_key: u256 = 0x50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0; + let tweaked_key: u256 = jet::build_taptweak(bip0341_key, tap_node); + + // Turn the taptweak into a script hash + let hash_ctx1: Ctx8 = jet::sha_256_ctx_8_init(); + let hash_ctx2: Ctx8 = jet::sha_256_ctx_8_add_2(hash_ctx1, 0x5120); // Segwit v1, length 32 + let hash_ctx3: Ctx8 = jet::sha_256_ctx_8_add_32(hash_ctx2, tweaked_key); + jet::sha_256_ctx_8_finalize(hash_ctx3) +} + +fn verify_vote_input(input_index: u32, vote_executable_leaf_hash: u256, participant_signature: Signature) { + let expected_script_hash: u256 = script_hash_for_vote(vote_executable_leaf_hash, participant_signature); + let actual_script_hash: u256 = unwrap(jet::input_script_hash(input_index)); + assert!(jet::eq_256(actual_script_hash, expected_script_hash)); +} + +fn count_vote( + valid_votes: u32, + next_vote_input_index: u32, + participant: Pubkey, + maybe_vote: Option<(Signature, u256)> +) -> (u32, u32) { + match maybe_vote { + Some(vote: (Signature, u256)) => { + let (participant_signature, vote_executable_leaf_hash): (Signature, u256) = vote; + verify_vote_input(next_vote_input_index, vote_executable_leaf_hash, participant_signature); + checksig(participant, participant_signature); + + let new_valid_votes: u32 = increment_checked(valid_votes); + let new_vote_input_index: u32 = increment_checked(next_vote_input_index); + (new_valid_votes, new_vote_input_index) + } + None => (valid_votes, next_vote_input_index), + } +} + +fn main() { + // TODO: compiler rejects the parametrized array. For generic usage we would epxect users to handle this limitation themselfs for now. + let participants: [Pubkey; 3] = param::PARTICIPANTS; + let threshold: u32 = param::THRESHOLD; + // A sanity check, to ensure that multisig is spendable + assert!(jet::le_32(1, threshold)); + assert!(jet::le_32(threshold, 3)); + + // We need to ensure that during spending we have enough votes + // if thresold is 3, anmd we have 3 participants, along side multisig covenant we MUST have at least 4 inputs + let (carry, minimum_inputs_num): (bool, u32) = jet::add_32(threshold, 1); + ensure_zero_bit(carry); + assert!(jet::le_32(minimum_inputs_num, jet::num_inputs())); + + // The user that construct transaction after the voting should recpect the order of participants declaration + // If threshold is lower than number of participants it should provide None in case of sig parameter + let votes: [Option<(Signature, u256)>; 3] = witness::VOTES; + let [participant1, participant2, participant3]: [Pubkey; 3] = participants; + let [vote1, vote2, vote3]: [Option<(Signature, u256)>; 3] = votes; + + // Verification is done in following order + // Starting with the input 1, if the signature for the input i is none, we skip it, otherwise + // we calculate script_hash_for_vote and check if the script hash is the same + // we check the signature against the participants[i], if it is correct the verification is done + // the action is performed against all participants + // We also should check that number of non-none signature is bigger or equal to the threshold + let (valid_votes, next_vote_input_index): (u32, u32) = count_vote(0, 1, participant1, vote1); + let (valid_votes, next_vote_input_index): (u32, u32) = count_vote(valid_votes, next_vote_input_index, participant2, vote2); + let (valid_votes, _): (u32, u32) = count_vote(valid_votes, next_vote_input_index, participant3, vote3); + assert!(jet::le_32(threshold, valid_votes)); +} diff --git a/crates/contracts/simf/setup_placeholder.simf b/crates/contracts/simf/setup_placeholder.simf deleted file mode 100644 index f328e4d..0000000 --- a/crates/contracts/simf/setup_placeholder.simf +++ /dev/null @@ -1 +0,0 @@ -fn main() {} diff --git a/crates/contracts/simf/vote.simf b/crates/contracts/simf/vote.simf new file mode 100644 index 0000000..ad8c0f8 --- /dev/null +++ b/crates/contracts/simf/vote.simf @@ -0,0 +1,31 @@ +// The purpose of this covenant to be spent alongside the multisig covenant. +// +// The shape of the multisig covenant would be (inputs): +// +// - input[0] = multisig covenant UTXO +// - input[1] = vote UTXO 1 (this covenant) +// - input[2] = vote UTXO 2 +// +// The idea behind this covenant is to be spent alongside the multisig covenant only. +// +// Its taproot will look like: +// +// merkle_root +// / \ +// / \ +// executable leaf hidden state leaf +// depth = 1 depth = 1 +// +// TapLeaf( TapData(state) +// script = CMR, = SHA256( +// version = SHA256("TapData") +// Simplicity || SHA256("TapData") +// leaf version || SHA256(participant_signature) +// ) ) +// +// The multisig covenant is going to compute the scripthash of the vote covenant and verify the signature provided in the witness data +// exists in the storage of this covenant. +fn main() { + let target_multisig: u256 = unwrap(jet::input_script_hash(0)); + assert!(jet::eq_256(target_multisig, param::TARGET_MULTISIG)); +} diff --git a/crates/contracts/src/constants.rs b/crates/contracts/src/constants.rs new file mode 100644 index 0000000..b6de3c0 --- /dev/null +++ b/crates/contracts/src/constants.rs @@ -0,0 +1,11 @@ +use simplex::simplicityhl::elements::bitcoin::secp256k1; + +/// The unspendable internal key specified in BIP-0341. +pub fn unspendable_internal_key() -> secp256k1::XOnlyPublicKey { + secp256k1::XOnlyPublicKey::from_slice(&[ + 0x50, 0x92, 0x9b, 0x74, 0xc1, 0xa0, 0x49, 0x54, 0xb7, 0x8b, 0x4b, 0x60, 0x35, 0xe9, 0x7a, + 0x5e, 0x07, 0x8a, 0x5a, 0x0f, 0x28, 0xec, 0x96, 0xd5, 0x47, 0xbf, 0xee, 0x9a, 0xce, 0x80, + 0x3a, 0xc0, + ]) + .expect("key is valid") +} diff --git a/crates/contracts/src/lib.rs b/crates/contracts/src/lib.rs index 52eab9b..89800a4 100644 --- a/crates/contracts/src/lib.rs +++ b/crates/contracts/src/lib.rs @@ -1,6 +1,12 @@ //! Core Simplicity contracts for native multisig. pub mod artifacts; +pub mod constants; +pub mod multisig_builder; +pub mod scripts; +pub mod vote_builder; + +pub(crate) mod runner; /// Workspace setup version exposed for binding smoke tests. pub const SETUP_VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/crates/contracts/src/multisig_builder.rs b/crates/contracts/src/multisig_builder.rs new file mode 100644 index 0000000..2e3869a --- /dev/null +++ b/crates/contracts/src/multisig_builder.rs @@ -0,0 +1,279 @@ +use std::collections::HashMap; + +use crate::constants::unspendable_internal_key; +use crate::scripts::script_ver; +use simplex::simplicityhl::elements::bitcoin::secp256k1; +use simplex::simplicityhl::elements::schnorr::XOnlyPublicKey; +use simplex::simplicityhl::elements::taproot::TaprootBuilder; +use simplex::simplicityhl::num::U256; +use simplex::simplicityhl::simplicity::Cmr; +use simplex::simplicityhl::str::WitnessName; +use simplex::simplicityhl::types::TypeConstructible; +use simplex::simplicityhl::value::{UIntValue, ValueConstructible}; +use simplex::simplicityhl::{Arguments, CompiledProgram, TemplateProgram}; + +pub const MULTISIG_SOURCE: &str = include_str!("../simf/multisig_n_of_3.simf"); + +fn get_multisig_program( + threshold: u32, + participants: [XOnlyPublicKey; 3], + is_debug_symbols_enabled: bool, +) -> anyhow::Result { + let template_program = TemplateProgram::new(MULTISIG_SOURCE).map_err(|e| anyhow::anyhow!(e))?; + + let participants: Vec = participants + .iter() + .map(|pubkey| { + simplex::simplicityhl::Value::from(UIntValue::U256(U256::from_byte_array( + pubkey.serialize(), + ))) + }) + .collect(); + + let args = Arguments::from(HashMap::from([ + ( + WitnessName::from_str_unchecked("THRESHOLD"), + simplex::simplicityhl::Value::from(UIntValue::U32(threshold)), + ), + ( + WitnessName::from_str_unchecked("PARTICIPANTS"), + simplex::simplicityhl::Value::array( + participants, + simplex::simplicityhl::types::ResolvedType::u256(), + ), + ), + ])); + + template_program + .instantiate(args, is_debug_symbols_enabled) + .map_err(|e| anyhow::anyhow!(e)) +} + +fn taproot_spend_info( + cmr: Cmr, +) -> anyhow::Result { + let (script, version) = script_ver(cmr); + + let builder = TaprootBuilder::new() + .add_leaf_with_ver(0, script, version) + .map_err(|e| anyhow::anyhow!(e))?; + + builder + .finalize(secp256k1::SECP256K1, unspendable_internal_key()) + .map_err(|e| anyhow::anyhow!(e)) +} + +#[cfg(test)] +mod test { + use crate::multisig_builder::get_multisig_program; + use crate::runner::run_program; + use crate::scripts::script_ver; + use crate::vote_builder::{get_vote_program, taproot_spend_info as vote_taproot_spend_info}; + use std::collections::HashMap; + + use std::sync::Arc; + + use simplex::program::TrackerLogLevel; + use simplex::simplicityhl::ResolvedType; + use simplex::simplicityhl::elements::confidential::{Asset, Value}; + use simplex::simplicityhl::elements::encode; + use simplex::simplicityhl::elements::hashes::{Hash, HashEngine, sha256}; + use simplex::simplicityhl::elements::pset::{Input, Output, PartiallySignedTransaction}; + use simplex::simplicityhl::elements::schnorr::Keypair; + use simplex::simplicityhl::elements::secp256k1_zkp::rand::thread_rng; + use simplex::simplicityhl::elements::secp256k1_zkp::{Message, SECP256K1, SecretKey}; + use simplex::simplicityhl::elements::taproot::{ControlBlock, TapLeafHash}; + use simplex::simplicityhl::elements::{ + AssetId, BlockHash, OutPoint, Script, Transaction, Txid, + }; + use simplex::simplicityhl::num::U256; + use simplex::simplicityhl::simplicity::jet::elements::ElementsEnv; + use simplex::simplicityhl::str::WitnessName; + use simplex::simplicityhl::types::TypeConstructible; + use simplex::simplicityhl::value::{UIntValue, ValueConstructible}; + + fn input_hash(engine: &mut sha256::HashEngine, hash: &sha256::Hash) { + engine.input(hash.as_byte_array()); + } + + fn input_confidential( + engine: &mut sha256::HashEngine, + serialized: &[u8], + even_prefix: u8, + odd_prefix: u8, + ) { + match serialized { + [0x00] => engine.input(&[0x00]), + [0x01, data @ ..] => { + engine.input(&[0x01]); + engine.input(data); + } + [prefix, data @ ..] => { + engine.input(&[if prefix & 1 == 1 { + odd_prefix + } else { + even_prefix + }]); + engine.input(data); + } + [] => unreachable!("serialized confidential values are never empty"), + } + } + + fn outputs_hash(tx: &Transaction) -> sha256::Hash { + let mut output_asset_amounts_hash = sha256::Hash::engine(); + let mut output_nonces_hash = sha256::Hash::engine(); + let mut output_scripts_hash = sha256::Hash::engine(); + let mut output_range_proofs_hash = sha256::Hash::engine(); + + for output in &tx.output { + input_confidential( + &mut output_asset_amounts_hash, + &encode::serialize(&output.asset), + 0x0a, + 0x0b, + ); + input_confidential( + &mut output_asset_amounts_hash, + &encode::serialize(&output.value), + 0x08, + 0x09, + ); + input_confidential( + &mut output_nonces_hash, + &encode::serialize(&output.nonce), + 0x02, + 0x03, + ); + + input_hash( + &mut output_scripts_hash, + &sha256::Hash::hash(output.script_pubkey.as_bytes()), + ); + + let range_proof = output + .witness + .rangeproof + .as_ref() + .map(|proof| proof.serialize()) + .unwrap_or_default(); + input_hash( + &mut output_range_proofs_hash, + &sha256::Hash::hash(&range_proof), + ); + } + + let mut outputs_hash = sha256::Hash::engine(); + input_hash( + &mut outputs_hash, + &sha256::Hash::from_engine(output_asset_amounts_hash), + ); + input_hash( + &mut outputs_hash, + &sha256::Hash::from_engine(output_nonces_hash), + ); + input_hash( + &mut outputs_hash, + &sha256::Hash::from_engine(output_scripts_hash), + ); + input_hash( + &mut outputs_hash, + &sha256::Hash::from_engine(output_range_proofs_hash), + ); + + sha256::Hash::from_engine(outputs_hash) + } + + #[test] + fn test_multisig_spend_1_of_3() -> anyhow::Result<()> { + let alice = Keypair::from_secret_key(&SECP256K1, &SecretKey::new(&mut thread_rng())); + let bob = Keypair::from_secret_key(&SECP256K1, &SecretKey::new(&mut thread_rng())); + let carol = Keypair::from_secret_key(&SECP256K1, &SecretKey::new(&mut thread_rng())); + + let program = get_multisig_program( + 1, + [ + alice.x_only_public_key().0, + bob.x_only_public_key().0, + carol.x_only_public_key().0, + ], + true, + )?; + let cmr = program.commit().cmr(); + + let spend_info = super::taproot_spend_info(cmr)?; + let script_pubkey = Script::new_v1_p2tr_tweaked(spend_info.output_key()); + let (multisig_script, _) = script_ver(cmr); + let vote_program = get_vote_program(multisig_script, true)?; + let vote_cmr = vote_program.commit().cmr(); + let (vote_script, vote_version) = script_ver(vote_cmr); + let vote_leaf_hash = TapLeafHash::from_script(&vote_script, vote_version); + + // Build transaction + let mut pst = PartiallySignedTransaction::new_v2(); + let outpoint0 = OutPoint::new(Txid::from_slice(&[0; 32])?, 0); + let outpoint1 = OutPoint::new(Txid::from_slice(&[1; 32])?, 0); + pst.add_input(Input::from_prevout(outpoint0)); + pst.add_input(Input::from_prevout(outpoint1)); + pst.add_output(Output::new_explicit( + Script::new(), + 0, + AssetId::default(), + None, + )); + + let control_block = spend_info + .control_block(&script_ver(cmr)) + .expect("Must retrieve control block for the script path"); + + let tx = Arc::new(pst.extract_tx()?); + + let message = Message::from_digest(outputs_hash(&tx).to_byte_array()); + let alice_signature = alice.sign_schnorr(message); + let vote_spend_info = vote_taproot_spend_info(alice_signature, vote_cmr)?; + let vote_script_pubkey = Script::new_v1_p2tr_tweaked(vote_spend_info.output_key()); + + // Set up environment + let env = ElementsEnv::new( + tx, + vec![ + simplex::simplicityhl::simplicity::jet::elements::ElementsUtxo { + script_pubkey, + asset: Asset::default(), + value: Value::default(), + }, + simplex::simplicityhl::simplicity::jet::elements::ElementsUtxo { + script_pubkey: vote_script_pubkey, + asset: Asset::default(), + value: Value::default(), + }, + ], + 0, + cmr, + ControlBlock::from_slice(&control_block.serialize())?, + None, + BlockHash::all_zeros(), + ); + + let vote_payload_type = + TypeConstructible::product(ResolvedType::byte_array(64), ResolvedType::u256()); + let empty_vote = || simplex::simplicityhl::Value::none(vote_payload_type.clone()); + let alice_vote = simplex::simplicityhl::Value::some(simplex::simplicityhl::Value::tuple([ + simplex::simplicityhl::Value::byte_array(alice_signature.serialize()), + simplex::simplicityhl::Value::from(UIntValue::U256(U256::from_byte_array( + vote_leaf_hash.to_byte_array(), + ))), + ])); + + let votes = vec![alice_vote, empty_vote(), empty_vote()]; + + let witness = simplex::simplicityhl::WitnessValues::from(HashMap::from([( + WitnessName::from_str_unchecked("VOTES"), + simplex::simplicityhl::Value::array(votes, ResolvedType::option(vote_payload_type)), + )])); + + let _ = run_program(&program, witness, &env, TrackerLogLevel::Trace)?; + + Ok(()) + } +} diff --git a/crates/contracts/src/runner.rs b/crates/contracts/src/runner.rs new file mode 100644 index 0000000..7fc3b53 --- /dev/null +++ b/crates/contracts/src/runner.rs @@ -0,0 +1,36 @@ +use std::sync::Arc; + +use simplex::simplicityhl::simplicity::elements::Transaction; +use simplex::simplicityhl::simplicity::jet::Elements; +use simplex::simplicityhl::simplicity::jet::elements::ElementsEnv; +use simplex::simplicityhl::simplicity::{BitMachine, RedeemNode, Value}; +use simplex::simplicityhl::tracker::{DefaultTracker, TrackerLogLevel}; +use simplex::simplicityhl::{CompiledProgram, WitnessValues}; + +/// Satisfy and execute a compiled program in the provided environment. +/// Returns the pruned program and the resulting value. +/// +/// # Errors +/// Returns error if witness satisfaction or program execution fails. +pub fn run_program( + program: &CompiledProgram, + witness_values: WitnessValues, + env: &ElementsEnv>, + log_level: TrackerLogLevel, +) -> anyhow::Result<(Arc>, Value)> { + let satisfied = program + .satisfy(witness_values) + .map_err(|e| anyhow::anyhow!(e))?; + + let mut tracker = DefaultTracker::new(satisfied.debug_symbols()).with_log_level(log_level); + + let pruned = satisfied + .redeem() + .prune_with_tracker(env, &mut tracker) + .map_err(|e| anyhow::anyhow!(e))?; + let mut mac = BitMachine::for_program(&pruned)?; + + let result = mac.exec(&pruned, env).map_err(|e| anyhow::anyhow!(e))?; + + Ok((pruned, result)) +} diff --git a/crates/contracts/src/scripts.rs b/crates/contracts/src/scripts.rs new file mode 100644 index 0000000..9ca0071 --- /dev/null +++ b/crates/contracts/src/scripts.rs @@ -0,0 +1,25 @@ +use simplex::simplicityhl::elements::Script; +use simplex::simplicityhl::elements::hashes::{Hash, HashEngine, sha256}; +use simplex::simplicityhl::elements::taproot::LeafVersion; +use simplex::simplicityhl::simplicity::Cmr; + +/// Create a SHA256 context, initialized with a "`TapData`" tag and data +/// +/// Based on the C implementation of the `tapdata_init` jet: +/// +#[must_use] +pub fn tap_data_hash(data: &[u8]) -> sha256::Hash { + let tag = sha256::Hash::hash(b"TapData"); + let mut eng = sha256::Hash::engine(); + eng.input(tag.as_byte_array()); + eng.input(tag.as_byte_array()); + eng.input(data); + sha256::Hash::from_engine(eng) +} + +pub(crate) fn script_ver(cmr: Cmr) -> (Script, LeafVersion) { + ( + Script::from(cmr.as_ref().to_vec()), + simplex::simplicityhl::simplicity::leaf_version(), + ) +} diff --git a/crates/contracts/src/vote_builder.rs b/crates/contracts/src/vote_builder.rs new file mode 100644 index 0000000..e114c1b --- /dev/null +++ b/crates/contracts/src/vote_builder.rs @@ -0,0 +1,68 @@ +use crate::scripts::tap_data_hash; + +use std::collections::HashMap; + +use crate::constants::unspendable_internal_key; +use simplex::simplicityhl::elements::Script; +use simplex::simplicityhl::elements::bitcoin::secp256k1; +use simplex::simplicityhl::elements::hashes::{Hash, HashEngine, sha256}; +use simplex::simplicityhl::elements::secp256k1_zkp::schnorr::Signature; +use simplex::simplicityhl::elements::taproot::TaprootBuilder; +use simplex::simplicityhl::num::U256; +use simplex::simplicityhl::simplicity::Cmr; +use simplex::simplicityhl::str::WitnessName; +use simplex::simplicityhl::value::UIntValue; +use simplex::simplicityhl::{Arguments, CompiledProgram, TemplateProgram}; + +pub const VOTE_SOURCE: &str = include_str!("../simf/vote.simf"); + +pub(super) fn get_vote_program( + target_multisig: Script, + is_debug_symbols_enabled: bool, +) -> anyhow::Result { + let template_program = TemplateProgram::new(VOTE_SOURCE).map_err(|e| anyhow::anyhow!(e))?; + + let mut eng = sha256::Hash::engine(); + eng.input(target_multisig.as_bytes()); + let target_multisig_hash = sha256::Hash::from_engine(eng); + + let args = Arguments::from(HashMap::from([( + WitnessName::from_str_unchecked("TARGET_MULTISIG"), + simplex::simplicityhl::Value::from(UIntValue::U256(U256::from_byte_array( + target_multisig_hash.as_byte_array().to_owned(), + ))), + )])); + + template_program + .instantiate(args, is_debug_symbols_enabled) + .map_err(|e| anyhow::anyhow!(e)) +} + +pub(super) fn taproot_spend_info( + multisig_signature: Signature, + cmr: Cmr, +) -> anyhow::Result { + let (script, version) = ( + Script::from(cmr.as_ref().to_vec()), + simplex::simplicityhl::simplicity::leaf_version(), + ); + + let mut eng = sha256::Hash::engine(); + eng.input(&multisig_signature.serialize()); + let state = sha256::Hash::from_engine(eng); + + let state_hash = tap_data_hash(&state.as_byte_array().to_vec()); + + let builder = TaprootBuilder::new() + .add_leaf_with_ver(1, script, version) + .map_err(|e| anyhow::anyhow!(e))? + .add_hidden(1, state_hash) + .map_err(|e| anyhow::anyhow!(e))?; + + builder + .finalize(secp256k1::SECP256K1, unspendable_internal_key()) + .map_err(|e| anyhow::anyhow!(e)) +} + +#[cfg(test)] +mod test {} diff --git a/crates/contracts/tests/common/mod.rs b/crates/contracts/tests/common/mod.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/crates/contracts/tests/common/mod.rs @@ -0,0 +1 @@ + diff --git a/crates/contracts/tests/mod.rs b/crates/contracts/tests/mod.rs new file mode 100644 index 0000000..93f9166 --- /dev/null +++ b/crates/contracts/tests/mod.rs @@ -0,0 +1,2 @@ +pub mod common; +mod regtest; diff --git a/crates/contracts/tests/regtest/mod.rs b/crates/contracts/tests/regtest/mod.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/crates/contracts/tests/regtest/mod.rs @@ -0,0 +1 @@ + diff --git a/crates/contracts/tests/setup.rs b/crates/contracts/tests/setup.rs deleted file mode 100644 index aed4f0a..0000000 --- a/crates/contracts/tests/setup.rs +++ /dev/null @@ -1,6 +0,0 @@ -use simplicity_native_multisig_contracts::setup_version; - -#[test] -fn contracts_crate_is_wired() { - assert_eq!(setup_version(), "0.1.0"); -} diff --git a/justfile b/justfile index f82eba7..8352258 100644 --- a/justfile +++ b/justfile @@ -36,7 +36,7 @@ test-msrv: # Run Simplex contract tests. simplex-test: - cd crates/contracts && simplex test --nocapture + cd crates/contracts && simplex test --show-output --ignored # Run the local CI-equivalent check set. check: simplex-build fmtcheck clippy rbmt test simplex-test From 5ab64fb6b6aa0ab5e03966c79c139c0e2a2a5380 Mon Sep 17 00:00:00 2001 From: Kyryl R Date: Thu, 18 Jun 2026 10:57:48 +0300 Subject: [PATCH 02/13] completed core checks of the multisig --- crates/contracts/README.md | 7 +- crates/contracts/simf/multisig_n_of_3.simf | 219 +++++++++++++++----- crates/contracts/simf/vote.simf | 55 ++++-- crates/contracts/src/multisig_builder.rs | 220 +++++++++++++-------- crates/contracts/src/vote_builder.rs | 142 ++++++++++++- 5 files changed, 490 insertions(+), 153 deletions(-) diff --git a/crates/contracts/README.md b/crates/contracts/README.md index f53d3ed..e1b58bb 100644 --- a/crates/contracts/README.md +++ b/crates/contracts/README.md @@ -12,7 +12,6 @@ Contract sources live in `simf/`. Generated Rust artifacts are written to `src/a simplex build ``` -The current proof-of-concept contract is `simf/native_multisig_poc.simf`. -It models a 2-of-3 covenant multisig where the only contract state is a `nonce`. -The signed proposal message is `jet::outputs_hash()`, and the state transition checks -that the next covenant output commits to `nonce + 1`. +The current proof-of-concept contract is `simf/multisig_n_of_3.simf`. +Participants sign a proposal message built from co-spent multisig input hashes, up +to two proposed output hashes, and the vote executable leaf hash. diff --git a/crates/contracts/simf/multisig_n_of_3.simf b/crates/contracts/simf/multisig_n_of_3.simf index 3a71c4b..3074860 100644 --- a/crates/contracts/simf/multisig_n_of_3.simf +++ b/crates/contracts/simf/multisig_n_of_3.simf @@ -1,5 +1,24 @@ -fn ensure_zero_bit(bit: bool) { - assert!(jet::eq_1(::into(bit), 0)); +// This covenant introduces Simplicity native multisig, which enables programmable signature verification. +// +// Coordination can happen both on-chain (via OP_RETURNs) and off-chain. +// To spend a multisig, a participant MUST lock funds in the vote.simf covenant. In its taproot, +// vote.simf contains a signature. See the message construction below for details. +// +// The direct benefit of this approach is programmable signatures. +// Imagine that Alice, Bob, and an arbiter create a 2-of-3 multisig. +// If Alice and Bob do not agree on the spend, Alice can ask the arbiter to be involved. +// After reviewing the case, the arbiter may provide a signature in Alice's favor, but ask for a 1 BTC fee. +// The arbiter creates a vote that checks whether one of the outputs actually pays that 1 BTC fee. +// Alice cannot spend the multisig without paying it. +// +// This design also allows a third party to execute the multisig once enough participants have provided votes. +// That executor can be rewarded with amounts left in the vote.simf inputs. +// +// Users pay more than they would with a trivial multisig, but they enable a richer feature set. +// In theory, a vote.simf covenant can include blacklist checks, whitelist checks, or any other checks that use transaction introspection. + +fn ensure_zero_bit(bit: bool) { + assert!(jet::eq_1(::into(bit), 0)); } fn increment_checked(value: u32) -> u32 { @@ -8,12 +27,101 @@ fn increment_checked(value: u32) -> u32 { next } -fn checksig(pk: Pubkey, sig: Signature) { - jet::bip_0340_verify((pk, jet::outputs_hash()), sig); +// To collect all multisig input hashes, start at input index 0: +// > for_while::((ctx, 0), jet::current_script_hash()); +// +// Continue iterating over the inputs until the script hash no longer matches. +// This also enforces that multisig inputs start at index 0. +// If the loop runs out of inputs, the covenant fails on the check in main: +// > let (carry, minimum_inputs_num): (bool, u32) = jet::add_32(threshold, multisig_input_count); +// > ensure_zero_bit(carry); +// > assert!(jet::le_32(minimum_inputs_num, jet::num_inputs())); +fn add_multisig_input_hash(acc: (Ctx8, u32), current_script_hash: u256, index: u8) -> Either<(Ctx8, u32), (Ctx8, u32)> { + let (ctx, multisig_input_count): (Ctx8, u32) = acc; + + let index: u32 = jet::left_pad_low_8_32(index); + let input_script_hash: Option = jet::input_script_hash(index); + + match input_script_hash { + Some(input_script_hash: u256) => { + match jet::eq_256(input_script_hash, current_script_hash) { + true => { + let next_ctx: Ctx8 = jet::sha_256_ctx_8_add_32(ctx, unwrap(jet::input_hash(index))); + let next_multisig_input_count: u32 = increment_checked(multisig_input_count); + Right((next_ctx, next_multisig_input_count)) + }, + false => Left((ctx, multisig_input_count)), + } + }, + None => Left((ctx, multisig_input_count)), + } +} + +// The total number of proposed outputs is controlled by the witness parameter. +// Proposed outputs also begin at index 0. +fn add_proposed_output_hash(ctx: Ctx8, total_proposed: u16, index: u16) -> Either { + let output_hash: Option = jet::output_hash(jet::left_pad_low_16_32(index)); + + match output_hash { + Some(output_hash: u256) => { + match jet::eq_16(index, total_proposed) { + false => Right(jet::sha_256_ctx_8_add_32(ctx, output_hash)), + true => Left(ctx), + } + }, + // If we run out of outputs, the proposer decided to enforce fees and change as well. + // That also requires strict amounts in the votes. + None => Left(ctx), + } +} + +// Build the base message that participants sign. +// +// The message signed by each participant includes all multisig inputs, i.e. jet::input_hash(i). +// +// It also includes outputs proposed by the participant, i.e. jet::output_hash(i). +// In theory, there can be 2^16 - 1 outputs. +// +// Therefore, the base message is: +// SHA256(jet::input_hash(0) || ... || jet::input_hash(total_multisig_inputs) || jet::output_hash(0) || ... || jet::output_hash(witness::TOTAL_PROPOSED_OUTPUTS)) +fn base_message_and_input_count(total_proposed_outputs: u16) -> (u256, u32) { + let ctx: Ctx8 = jet::sha_256_ctx_8_init(); + let input_result: Either<(Ctx8, u32), (Ctx8, u32)> = for_while::((ctx, 0), jet::current_script_hash()); + + let (ctx, multisig_input_count): (Ctx8, u32) = unwrap_left::<(Ctx8, u32)>(input_result); + let output_result: Either = for_while::(ctx, total_proposed_outputs); + + let ctx: Ctx8 = unwrap_left::(output_result); + + (jet::sha_256_ctx_8_finalize(ctx), multisig_input_count) } +// Build the final message that is signed by the participant. +// +// To lock in the prechecks that vote.simf could include, every participant signs: +// SHA256(vote_executable_leaf_hash || base_message) +// +// The vote_executable_leaf_hash identifies that participant's own vote.simf executable leaf. +// Participants do not need to sign CMRs of other participants' vote.simf programs. +// +// With this structure, the signature locks in the inputs being spent, the output shape, +// and the additional checks that vote.simf should perform. +fn participant_message(vote_executable_leaf_hash: u256, base_message: u256) -> u256 { + let ctx: Ctx8 = jet::sha_256_ctx_8_init(); + let ctx: Ctx8 = jet::sha_256_ctx_8_add_32(ctx, vote_executable_leaf_hash); + let ctx: Ctx8 = jet::sha_256_ctx_8_add_32(ctx, base_message); + jet::sha_256_ctx_8_finalize(ctx) +} + +fn checksig(pk: Pubkey, sig: Signature, vote_executable_leaf_hash: u256, base_message: u256) { + jet::bip_0340_verify((pk, participant_message(vote_executable_leaf_hash, base_message)), sig); +} + +// This function verifies that the proved signature is committed on-chain through the intended vote.simf program. +// The intended vote.simf program is the one signed by the participant. +// // vote_executable_leaf_hash is a tapleaf_hash of the vote executable leaf. -fn script_hash_for_vote(vote_executable_leaf_hash: u256, participant_signature: Signature) -> u256 { +fn verify_vote_input(input_index: u32, vote_executable_leaf_hash: u256, participant_signature: Signature) { let state_hash_ctx1: Ctx8 = jet::sha_256_ctx_8_init(); let state_hash_ctx2: Ctx8 = jet::sha_256_ctx_8_add_64(state_hash_ctx1, ::into(participant_signature)); let state_hash: u256 = jet::sha_256_ctx_8_finalize(state_hash_ctx2); @@ -24,71 +132,94 @@ fn script_hash_for_vote(vote_executable_leaf_hash: u256, participant_signature: let state_leaf: u256 = jet::sha_256_ctx_8_finalize(state_ctx2); let tap_node: u256 = jet::build_tapbranch(tap_leaf, state_leaf); - // Compute a taptweak using this. + // Compute a taptweak from this tap tree. let bip0341_key: u256 = 0x50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0; let tweaked_key: u256 = jet::build_taptweak(bip0341_key, tap_node); - - // Turn the taptweak into a script hash + + // Turn the taptweak into a script hash. let hash_ctx1: Ctx8 = jet::sha_256_ctx_8_init(); let hash_ctx2: Ctx8 = jet::sha_256_ctx_8_add_2(hash_ctx1, 0x5120); // Segwit v1, length 32 let hash_ctx3: Ctx8 = jet::sha_256_ctx_8_add_32(hash_ctx2, tweaked_key); - jet::sha_256_ctx_8_finalize(hash_ctx3) -} + let expected_vote_script_hash: u256 = jet::sha_256_ctx_8_finalize(hash_ctx3); -fn verify_vote_input(input_index: u32, vote_executable_leaf_hash: u256, participant_signature: Signature) { - let expected_script_hash: u256 = script_hash_for_vote(vote_executable_leaf_hash, participant_signature); - let actual_script_hash: u256 = unwrap(jet::input_script_hash(input_index)); - assert!(jet::eq_256(actual_script_hash, expected_script_hash)); + assert!(jet::eq_256(unwrap(jet::input_script_hash(input_index)), expected_vote_script_hash)); } -fn count_vote( - valid_votes: u32, - next_vote_input_index: u32, - participant: Pubkey, - maybe_vote: Option<(Signature, u256)> -) -> (u32, u32) { - match maybe_vote { +// For every vote entry, verify the presence of the on-chain commitment and the correctness of the signed message. +fn count_vote_entry( + entry: (Pubkey, Option<(Signature, u256)>), + acc: (u32, u32, u256) +) -> (u32, u32, u256) { + let (participant, maybe_vote): (Pubkey, Option<(Signature, u256)>) = entry; + let (valid_votes, next_vote_input_index, base_message): (u32, u32, u256) = acc; + + let (valid_votes, next_vote_input_index): (u32, u32) = match maybe_vote { Some(vote: (Signature, u256)) => { let (participant_signature, vote_executable_leaf_hash): (Signature, u256) = vote; + verify_vote_input(next_vote_input_index, vote_executable_leaf_hash, participant_signature); - checksig(participant, participant_signature); + checksig(participant, participant_signature, vote_executable_leaf_hash, base_message); - let new_valid_votes: u32 = increment_checked(valid_votes); - let new_vote_input_index: u32 = increment_checked(next_vote_input_index); - (new_valid_votes, new_vote_input_index) + (increment_checked(valid_votes), increment_checked(next_vote_input_index)) } None => (valid_votes, next_vote_input_index), - } + }; + + (valid_votes, next_vote_input_index, base_message) +} + +fn count_votes( + participants: [Pubkey; 3], + votes: [Option<(Signature, u256)>; 3], + multisig_input_count: u32, + base_message: u256 +) -> u32 { + let [participant1, participant2, participant3]: [Pubkey; 3] = participants; + let [vote1, vote2, vote3]: [Option<(Signature, u256)>; 3] = votes; + + let entries: [(Pubkey, Option<(Signature, u256)>); 3] = [ + (participant1, vote1), + (participant2, vote2), + (participant3, vote3), + ]; + + let (valid_votes, _, _): (u32, u32, u256) = array_fold::( + entries, + (0, multisig_input_count, base_message) + ); + + valid_votes } fn main() { - // TODO: compiler rejects the parametrized array. For generic usage we would epxect users to handle this limitation themselfs for now. + // TODO: The compiler rejects the parameterized array. + // For generic usage, users are expected to handle this limitation themselves for now. let participants: [Pubkey; 3] = param::PARTICIPANTS; let threshold: u32 = param::THRESHOLD; - // A sanity check, to ensure that multisig is spendable + // Sanity checks to ensure that the multisig is spendable. assert!(jet::le_32(1, threshold)); assert!(jet::le_32(threshold, 3)); - // We need to ensure that during spending we have enough votes - // if thresold is 3, anmd we have 3 participants, along side multisig covenant we MUST have at least 4 inputs - let (carry, minimum_inputs_num): (bool, u32) = jet::add_32(threshold, 1); + let (base_message, multisig_input_count): (u256, u32) = base_message_and_input_count(witness::TOTAL_PROPOSED_OUTPUTS); + // Another sanity check: enforce that multisig covenant inputs start at index 0. + // This fails if the UTXO at index 0 is not a multisig. + assert!(jet::le_32(1, multisig_input_count)); + + // We need to ensure that during spending we have enough votes. + let (carry, minimum_inputs_num): (bool, u32) = jet::add_32(threshold, multisig_input_count); ensure_zero_bit(carry); assert!(jet::le_32(minimum_inputs_num, jet::num_inputs())); - // The user that construct transaction after the voting should recpect the order of participants declaration - // If threshold is lower than number of participants it should provide None in case of sig parameter + // The user who constructs the transaction after voting should respect the participant declaration order. + // If the threshold is lower than the number of participants, it should provide None for missing signatures. let votes: [Option<(Signature, u256)>; 3] = witness::VOTES; - let [participant1, participant2, participant3]: [Pubkey; 3] = participants; - let [vote1, vote2, vote3]: [Option<(Signature, u256)>; 3] = votes; - // Verification is done in following order - // Starting with the input 1, if the signature for the input i is none, we skip it, otherwise - // we calculate script_hash_for_vote and check if the script hash is the same - // we check the signature against the participants[i], if it is correct the verification is done - // the action is performed against all participants - // We also should check that number of non-none signature is bigger or equal to the threshold - let (valid_votes, next_vote_input_index): (u32, u32) = count_vote(0, 1, participant1, vote1); - let (valid_votes, next_vote_input_index): (u32, u32) = count_vote(valid_votes, next_vote_input_index, participant2, vote2); - let (valid_votes, _): (u32, u32) = count_vote(valid_votes, next_vote_input_index, participant3, vote3); + // Verification is done in the following order. + // Starting after the multisig inputs, if the signature for input i is None, skip it. + // Otherwise, calculate the expected vote script hash and check that it matches. + // Then check the signature against participants[i]. + // This action is performed for all participants. + // Finally, check that the number of non-None signatures is greater than or equal to the threshold. + let valid_votes: u32 = count_votes(participants, votes, multisig_input_count, base_message); assert!(jet::le_32(threshold, valid_votes)); } diff --git a/crates/contracts/simf/vote.simf b/crates/contracts/simf/vote.simf index ad8c0f8..e4f8112 100644 --- a/crates/contracts/simf/vote.simf +++ b/crates/contracts/simf/vote.simf @@ -1,14 +1,13 @@ -// The purpose of this covenant to be spent alongside the multisig covenant. -// -// The shape of the multisig covenant would be (inputs): -// +// The purpose of this covenant is to be spent alongside the multisig covenant. +// +// The multisig input shape is: +// // - input[0] = multisig covenant UTXO // - input[1] = vote UTXO 1 (this covenant) -// - input[2] = vote UTXO 2 -// -// The idea behind this covenant is to be spent alongside the multisig covenant only. -// -// Its taproot will look like: +// - input[2] = vote UTXO 2 +// - ... +// +// Its taproot looks like: // // merkle_root // / \ @@ -22,10 +21,38 @@ // Simplicity || SHA256("TapData") // leaf version || SHA256(participant_signature) // ) ) -// -// The multisig covenant is going to compute the scripthash of the vote covenant and verify the signature provided in the witness data -// exists in the storage of this covenant. +// +// The multisig covenant MUST compute the script hash of the vote covenant and MUST verify that +// the signature provided in witness data exists in this covenant's storage. +fn ensure_zero_bit(bit: bool) { + assert!(jet::eq_1(::into(bit), 0)); +} + +fn increment_checked(value: u32) -> u32 { + let (carry, next): (bool, u32) = jet::increment_32(value); + ensure_zero_bit(carry); + next +} + +fn count_target_multisig_input(multisig_input_count: u32, target_multisig: u256, index: u8) -> Either { + let input_script_hash: Option = jet::input_script_hash(jet::left_pad_low_8_32(index)); + + match input_script_hash { + Some(input_script_hash: u256) => { + match jet::eq_256(input_script_hash, target_multisig) { + true => Right(increment_checked(multisig_input_count)), + false => Left(multisig_input_count), + } + }, + None => Left(multisig_input_count), + } +} + fn main() { - let target_multisig: u256 = unwrap(jet::input_script_hash(0)); - assert!(jet::eq_256(target_multisig, param::TARGET_MULTISIG)); + let actual_multisig_inputs_count: u32 = match for_while::(0, param::TARGET_MULTISIG) { + Left(multisig_input_count: u32) => multisig_input_count, + Right(multisig_input_count: u32) => multisig_input_count, + }; + + assert!(jet::eq_32(actual_multisig_inputs_count, param::EXPECTED_MULTISIG_INPUTS_COUNT)); } diff --git a/crates/contracts/src/multisig_builder.rs b/crates/contracts/src/multisig_builder.rs index 2e3869a..2cb7b34 100644 --- a/crates/contracts/src/multisig_builder.rs +++ b/crates/contracts/src/multisig_builder.rs @@ -84,7 +84,7 @@ mod test { use simplex::simplicityhl::elements::secp256k1_zkp::{Message, SECP256K1, SecretKey}; use simplex::simplicityhl::elements::taproot::{ControlBlock, TapLeafHash}; use simplex::simplicityhl::elements::{ - AssetId, BlockHash, OutPoint, Script, Transaction, Txid, + AssetId, BlockHash, OutPoint, Script, Transaction, TxIn, TxOut, Txid, }; use simplex::simplicityhl::num::U256; use simplex::simplicityhl::simplicity::jet::elements::ElementsEnv; @@ -120,72 +120,90 @@ mod test { } } - fn outputs_hash(tx: &Transaction) -> sha256::Hash { - let mut output_asset_amounts_hash = sha256::Hash::engine(); - let mut output_nonces_hash = sha256::Hash::engine(); - let mut output_scripts_hash = sha256::Hash::engine(); - let mut output_range_proofs_hash = sha256::Hash::engine(); - - for output in &tx.output { - input_confidential( - &mut output_asset_amounts_hash, - &encode::serialize(&output.asset), - 0x0a, - 0x0b, - ); - input_confidential( - &mut output_asset_amounts_hash, - &encode::serialize(&output.value), - 0x08, - 0x09, - ); - input_confidential( - &mut output_nonces_hash, - &encode::serialize(&output.nonce), - 0x02, - 0x03, - ); + fn script_hash(script: &Script) -> sha256::Hash { + sha256::Hash::hash(script.as_bytes()) + } - input_hash( - &mut output_scripts_hash, - &sha256::Hash::hash(output.script_pubkey.as_bytes()), - ); + fn tx_input_hash(input: &TxIn) -> sha256::Hash { + assert!(!input.is_pegin, "test helper does not support pegin inputs"); - let range_proof = output - .witness - .rangeproof - .as_ref() - .map(|proof| proof.serialize()) - .unwrap_or_default(); - input_hash( - &mut output_range_proofs_hash, - &sha256::Hash::hash(&range_proof), - ); - } + let mut engine = sha256::Hash::engine(); + engine.input(&[0x00]); + engine.input(input.previous_output.txid.as_byte_array()); + engine.input(&input.previous_output.vout.to_be_bytes()); + engine.input(&input.sequence.to_consensus_u32().to_be_bytes()); + engine.input(&[0x00]); + sha256::Hash::from_engine(engine) + } - let mut outputs_hash = sha256::Hash::engine(); + fn tx_output_hash(output: &TxOut) -> sha256::Hash { + let mut engine = sha256::Hash::engine(); + input_confidential(&mut engine, &encode::serialize(&output.asset), 0x0a, 0x0b); + input_confidential(&mut engine, &encode::serialize(&output.value), 0x08, 0x09); + input_confidential(&mut engine, &encode::serialize(&output.nonce), 0x02, 0x03); input_hash( - &mut outputs_hash, - &sha256::Hash::from_engine(output_asset_amounts_hash), - ); - input_hash( - &mut outputs_hash, - &sha256::Hash::from_engine(output_nonces_hash), - ); - input_hash( - &mut outputs_hash, - &sha256::Hash::from_engine(output_scripts_hash), + &mut engine, + &sha256::Hash::hash(output.script_pubkey.as_bytes()), ); + + let range_proof = output + .witness + .rangeproof + .as_ref() + .map(|proof| proof.serialize()) + .unwrap_or_default(); + input_hash(&mut engine, &sha256::Hash::hash(&range_proof)); + + sha256::Hash::from_engine(engine) + } + + fn base_message( + tx: &Transaction, + input_scripts: &[Script], + current_script: &Script, + ) -> sha256::Hash { + let current_script_hash = script_hash(current_script); + let mut engine = sha256::Hash::engine(); + + for (index, input) in tx.input.iter().take(10).enumerate() { + let Some(input_script) = input_scripts.get(index) else { + continue; + }; + + if script_hash(input_script) == current_script_hash { + input_hash(&mut engine, &tx_input_hash(input)); + } + } + + for output in tx.output.iter().take(2) { + input_hash(&mut engine, &tx_output_hash(output)); + } + + sha256::Hash::from_engine(engine) + } + + fn participant_message( + tx: &Transaction, + input_scripts: &[Script], + current_script: &Script, + vote_leaf_hash: TapLeafHash, + ) -> sha256::Hash { + let mut engine = sha256::Hash::engine(); + engine.input(vote_leaf_hash.as_byte_array()); input_hash( - &mut outputs_hash, - &sha256::Hash::from_engine(output_range_proofs_hash), + &mut engine, + &base_message(tx, input_scripts, current_script), ); - - sha256::Hash::from_engine(outputs_hash) + sha256::Hash::from_engine(engine) } - #[test] - fn test_multisig_spend_1_of_3() -> anyhow::Result<()> { + fn run_multisig_spend_1_of_3( + multisig_input_count: usize, + proposed_output_count: usize, + ) -> anyhow::Result<()> { + assert!((1..=10).contains(&multisig_input_count)); + assert!(proposed_output_count <= 2); + let alice = Keypair::from_secret_key(&SECP256K1, &SecretKey::new(&mut thread_rng())); let bob = Keypair::from_secret_key(&SECP256K1, &SecretKey::new(&mut thread_rng())); let carol = Keypair::from_secret_key(&SECP256K1, &SecretKey::new(&mut thread_rng())); @@ -204,50 +222,68 @@ mod test { let spend_info = super::taproot_spend_info(cmr)?; let script_pubkey = Script::new_v1_p2tr_tweaked(spend_info.output_key()); let (multisig_script, _) = script_ver(cmr); - let vote_program = get_vote_program(multisig_script, true)?; + let vote_program = + get_vote_program(multisig_script, multisig_input_count.try_into()?, true)?; let vote_cmr = vote_program.commit().cmr(); let (vote_script, vote_version) = script_ver(vote_cmr); let vote_leaf_hash = TapLeafHash::from_script(&vote_script, vote_version); // Build transaction let mut pst = PartiallySignedTransaction::new_v2(); - let outpoint0 = OutPoint::new(Txid::from_slice(&[0; 32])?, 0); - let outpoint1 = OutPoint::new(Txid::from_slice(&[1; 32])?, 0); - pst.add_input(Input::from_prevout(outpoint0)); - pst.add_input(Input::from_prevout(outpoint1)); - pst.add_output(Output::new_explicit( - Script::new(), - 0, - AssetId::default(), - None, - )); + for index in 0..multisig_input_count { + let outpoint = OutPoint::new(Txid::from_slice(&[index as u8; 32])?, 0); + pst.add_input(Input::from_prevout(outpoint)); + } + + let vote_outpoint = OutPoint::new(Txid::from_slice(&[0xff; 32])?, 0); + pst.add_input(Input::from_prevout(vote_outpoint)); + + for _ in 0..proposed_output_count { + pst.add_output(Output::new_explicit( + Script::new(), + 0, + AssetId::default(), + None, + )); + } let control_block = spend_info .control_block(&script_ver(cmr)) .expect("Must retrieve control block for the script path"); let tx = Arc::new(pst.extract_tx()?); + let input_scripts = vec![script_pubkey.clone(); multisig_input_count]; - let message = Message::from_digest(outputs_hash(&tx).to_byte_array()); + let message = Message::from_digest( + participant_message(&tx, &input_scripts, &script_pubkey, vote_leaf_hash) + .to_byte_array(), + ); let alice_signature = alice.sign_schnorr(message); let vote_spend_info = vote_taproot_spend_info(alice_signature, vote_cmr)?; let vote_script_pubkey = Script::new_v1_p2tr_tweaked(vote_spend_info.output_key()); // Set up environment - let env = ElementsEnv::new( - tx, - vec![ + let mut utxos = Vec::new(); + for _ in 0..multisig_input_count { + utxos.push( simplex::simplicityhl::simplicity::jet::elements::ElementsUtxo { - script_pubkey, + script_pubkey: script_pubkey.clone(), asset: Asset::default(), value: Value::default(), }, - simplex::simplicityhl::simplicity::jet::elements::ElementsUtxo { - script_pubkey: vote_script_pubkey, - asset: Asset::default(), - value: Value::default(), - }, - ], + ); + } + utxos.push( + simplex::simplicityhl::simplicity::jet::elements::ElementsUtxo { + script_pubkey: vote_script_pubkey, + asset: Asset::default(), + value: Value::default(), + }, + ); + + let env = ElementsEnv::new( + tx, + utxos, 0, cmr, ControlBlock::from_slice(&control_block.serialize())?, @@ -267,13 +303,29 @@ mod test { let votes = vec![alice_vote, empty_vote(), empty_vote()]; - let witness = simplex::simplicityhl::WitnessValues::from(HashMap::from([( - WitnessName::from_str_unchecked("VOTES"), - simplex::simplicityhl::Value::array(votes, ResolvedType::option(vote_payload_type)), - )])); + let witness = simplex::simplicityhl::WitnessValues::from(HashMap::from([ + ( + WitnessName::from_str_unchecked("VOTES"), + simplex::simplicityhl::Value::array(votes, ResolvedType::option(vote_payload_type)), + ), + ( + WitnessName::from_str_unchecked("TOTAL_PROPOSED_OUTPUTS"), + simplex::simplicityhl::Value::from(UIntValue::U16(10)), + ), + ])); let _ = run_program(&program, witness, &env, TrackerLogLevel::Trace)?; Ok(()) } + + #[test] + fn test_multisig_spend_1_of_3() -> anyhow::Result<()> { + run_multisig_spend_1_of_3(1, 1) + } + + #[test] + fn test_multisig_spend_two_inputs_two_outputs() -> anyhow::Result<()> { + run_multisig_spend_1_of_3(2, 2) + } } diff --git a/crates/contracts/src/vote_builder.rs b/crates/contracts/src/vote_builder.rs index e114c1b..429af97 100644 --- a/crates/contracts/src/vote_builder.rs +++ b/crates/contracts/src/vote_builder.rs @@ -18,6 +18,7 @@ pub const VOTE_SOURCE: &str = include_str!("../simf/vote.simf"); pub(super) fn get_vote_program( target_multisig: Script, + expected_multisig_inputs_count: u32, is_debug_symbols_enabled: bool, ) -> anyhow::Result { let template_program = TemplateProgram::new(VOTE_SOURCE).map_err(|e| anyhow::anyhow!(e))?; @@ -26,12 +27,18 @@ pub(super) fn get_vote_program( eng.input(target_multisig.as_bytes()); let target_multisig_hash = sha256::Hash::from_engine(eng); - let args = Arguments::from(HashMap::from([( - WitnessName::from_str_unchecked("TARGET_MULTISIG"), - simplex::simplicityhl::Value::from(UIntValue::U256(U256::from_byte_array( - target_multisig_hash.as_byte_array().to_owned(), - ))), - )])); + let args = Arguments::from(HashMap::from([ + ( + WitnessName::from_str_unchecked("TARGET_MULTISIG"), + simplex::simplicityhl::Value::from(UIntValue::U256(U256::from_byte_array( + target_multisig_hash.as_byte_array().to_owned(), + ))), + ), + ( + WitnessName::from_str_unchecked("EXPECTED_MULTISIG_INPUTS_COUNT"), + simplex::simplicityhl::Value::from(UIntValue::U32(expected_multisig_inputs_count)), + ), + ])); template_program .instantiate(args, is_debug_symbols_enabled) @@ -65,4 +72,125 @@ pub(super) fn taproot_spend_info( } #[cfg(test)] -mod test {} +mod test { + use super::{get_vote_program, taproot_spend_info}; + use crate::runner::run_program; + use crate::scripts::script_ver; + + use std::sync::Arc; + + use simplex::program::TrackerLogLevel; + use simplex::simplicityhl::WitnessValues; + use simplex::simplicityhl::elements::confidential::{Asset, Value}; + use simplex::simplicityhl::elements::hashes::Hash; + use simplex::simplicityhl::elements::pset::{Input, PartiallySignedTransaction}; + use simplex::simplicityhl::elements::schnorr::Keypair; + use simplex::simplicityhl::elements::secp256k1_zkp::rand::thread_rng; + use simplex::simplicityhl::elements::secp256k1_zkp::{Message, SECP256K1, SecretKey}; + use simplex::simplicityhl::elements::taproot::ControlBlock; + use simplex::simplicityhl::elements::{BlockHash, OutPoint, Script, Txid}; + use simplex::simplicityhl::simplicity::jet::elements::ElementsEnv; + + fn run_vote_with_inputs( + target_script: Script, + expected_multisig_inputs_count: u32, + input_scripts: Vec + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..a114bb0 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,1921 @@ +{ + "name": "simplicity-native-multisig-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "simplicity-native-multisig-web", + "version": "0.1.0", + "dependencies": { + "lucide-react": "^0.562.0", + "lwk_wasm": "^0.18.0", + "react": "^19.2.3", + "react-dom": "^19.2.3" + }, + "devDependencies": { + "@playwright/test": "^1.57.0", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "typescript": "^5.9.3", + "vite": "^7.3.0", + "vite-plugin-wasm": "^3.6.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.40", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", + "integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", + "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.10.38", + "caniuse-lite": "^1.0.30001799", + "electron-to-chromium": "^1.5.376", + "node-releases": "^2.0.48", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.379", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.379.tgz", + "integrity": "sha512-v/qV5aV5EUA2pGilzUCq5/eyOloZAqDZBu9UMBIzgPpLlprjSR6zswsWBTv0KpqxLGUAZEwhO95ZCt7srymNVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.562.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz", + "integrity": "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/lwk_wasm": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/lwk_wasm/-/lwk_wasm-0.18.0.tgz", + "integrity": "sha512-yyRBpt8gmvznOAftJbGOLdLXFqf6OLqsA59haHgOq5u2woVBu30CWcH2/acG4HcNRA2446DtzS5MBWAMgD9e8Q==", + "license": "MIT OR BSD-2-Clause" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.50", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", + "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-plugin-wasm": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/vite-plugin-wasm/-/vite-plugin-wasm-3.6.0.tgz", + "integrity": "sha512-mL/QPziiIA4RAA6DkaZZzOstdwbW5jO4Vz7Zenj0wieKWBlNvIvX5L5ljum9lcUX0ShNfBgCNLKTjNkRVVqcsw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "vite": "^2 || ^3 || ^4 || ^5 || ^6 || ^7 || ^8" + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..bf1838e --- /dev/null +++ b/web/package.json @@ -0,0 +1,30 @@ +{ + "name": "simplicity-native-multisig-web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "build:contracts": "wasm-pack build ../crates/wasm --target web --out-dir ../../web/src/generated/contracts --release", + "predev": "npm run build:contracts", + "dev": "vite --host 127.0.0.1", + "prebuild": "npm run build:contracts", + "build": "tsc && vite build", + "preview": "vite preview --host 127.0.0.1", + "test:ui": "playwright test" + }, + "dependencies": { + "lucide-react": "^0.562.0", + "lwk_wasm": "^0.18.0", + "react": "^19.2.3", + "react-dom": "^19.2.3" + }, + "devDependencies": { + "@playwright/test": "^1.57.0", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "typescript": "^5.9.3", + "vite": "^7.3.0", + "vite-plugin-wasm": "^3.6.0" + } +} diff --git a/web/playwright.config.ts b/web/playwright.config.ts new file mode 100644 index 0000000..22271e9 --- /dev/null +++ b/web/playwright.config.ts @@ -0,0 +1,25 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests", + timeout: 60_000, + expect: { + timeout: 10_000, + }, + use: { + baseURL: "http://127.0.0.1:5174", + trace: "retain-on-failure", + }, + webServer: { + command: "npm run dev -- --port 5174 --strictPort", + url: "http://127.0.0.1:5174", + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}); diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..85b6693 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,7 @@ +import { AppShell } from "./app-shell"; +import { useAppModel } from "./app-model"; + +export function App() { + const model = useAppModel(); + return ; +} diff --git a/web/src/app-action-context.ts b/web/src/app-action-context.ts new file mode 100644 index 0000000..7960239 --- /dev/null +++ b/web/src/app-action-context.ts @@ -0,0 +1,71 @@ +import type { Dispatch, SetStateAction } from "react"; +import type { AppTab, BusyAction, ToastMessage } from "./app-model"; +import type { + AnnouncementScanState, + ClaimedParticipant, + FaucetResult, + FinalizedSpendResult, + LiquidTestnetInfo, + MultisigDescriptor, + MultisigSession, + ProposalResult, + ScanState, + ScanVote, + SignedVoteResult, + SpendOutput, + WireUtxo, +} from "./types"; + +type Setter = Dispatch>; + +export type AppActionContext = { + activeMultisigDescriptor?: MultisigDescriptor; + announcementMnemonic: string; + announcementStake: number; + announcementStakeValid: boolean; + changeOutputs: SpendOutput[]; + claimed?: ClaimedParticipant; + claimMnemonic: string; + currentMultisigAddress?: string; + descriptorText: string; + executorFeeRate: number; + executorFundingEnabled: boolean; + executorFundingMnemonic?: string; + feeAmount: number; + info?: LiquidTestnetInfo; + manualVoteTx: string; + outputs: SpendOutput[]; + participantKeys: string[]; + proposal?: ProposalResult; + proposalAmountErrors: string[]; + proposalAmountsValid: boolean; + scan: ScanState; + selectedUtxos: WireUtxo[]; + session?: MultisigSession; + threshold: number; + underfundedAssets: string[]; + vote?: SignedVoteResult; + voteStake: number; + voteStakeValid: boolean; + votesForFinalization: ScanVote[]; + setActiveTab: Setter; + setActivity: Setter; + setAnnouncementMnemonic: Setter; + setAnnouncementScan: Setter; + setBusyAction: Setter; + setClaimed: Setter; + setClaimMnemonic: Setter; + setDescriptorText: Setter; + setFaucetBusy: Setter; + setFaucetResult: Setter; + setFinalSpend: Setter; + setMultisigDescriptor: Setter; + setParticipantKeys: Setter; + setProposal: Setter; + setScan: Setter; + setSelectedInputs: Setter>; + setSession: Setter; + setThreshold: Setter; + setToast: Setter; + setVote: Setter; +}; diff --git a/web/src/app-action-tools.ts b/web/src/app-action-tools.ts new file mode 100644 index 0000000..84bccee --- /dev/null +++ b/web/src/app-action-tools.ts @@ -0,0 +1,59 @@ +import { emptyAnnouncementScan, emptyScan, outputId } from "./app-helpers"; +import type { AppActionContext } from "./app-action-context"; +import type { ToastMessage } from "./app-model"; +import type { MultisigDescriptor } from "./types"; +import { clearProposalState } from "./app-state-reset"; + +export function showToast( + ctx: AppActionContext, + tone: ToastMessage["tone"], + title: string, + message: string, +) { + ctx.setToast({ + id: outputId(), + tone, + title, + message, + }); +} + +export async function run( + ctx: AppActionContext, + label: string, + action: () => Promise, +): Promise { + ctx.setActivity(label); + ctx.setToast(undefined); + try { + const value = await action(); + ctx.setActivity("Ready"); + return value; + } catch (nextError) { + const message = nextError instanceof Error ? nextError.message : String(nextError); + showToast(ctx, "error", label, message); + ctx.setActivity("Needs attention"); + return undefined; + } +} + +export function applyLoadedDescriptor(ctx: AppActionContext, next: MultisigDescriptor) { + const { + setAnnouncementScan, + setClaimed, + setMultisigDescriptor, + setParticipantKeys, + setScan, + setSession, + setThreshold, + } = ctx; + + setMultisigDescriptor(next); + setSession(undefined); + setThreshold(next.threshold); + setParticipantKeys(next.participants.map((participant) => participant.xOnlyPublicKey)); + setScan(emptyScan); + setAnnouncementScan(emptyAnnouncementScan); + clearProposalState(ctx); + setClaimed(undefined); +} diff --git a/web/src/app-actions.ts b/web/src/app-actions.ts new file mode 100644 index 0000000..c1572fd --- /dev/null +++ b/web/src/app-actions.ts @@ -0,0 +1,432 @@ +import { + amountLabel, + assetLabel, + emptyScan, + scanVoteFromDecoded, + utxoKey, +} from "./app-helpers"; +import type { AppActionContext } from "./app-action-context"; +import type { BusyAction } from "./app-model"; +import { applyLoadedDescriptor, run, showToast } from "./app-action-tools"; +import { clearProposalState, clearVoteState } from "./app-state-reset"; +import { + createMultisigDescriptor, + createProposedSpend, + createSignedVote, + decodeVoteTransaction, + decodeVoteTransactionAuto, + deriveParticipantKey, + inspectMultisigDescriptor, +} from "./lib/contracts"; +import { demoMnemonics } from "./lib/demo"; +import { requestFaucetFunds } from "./lib/faucet"; +import { claimParticipant } from "./lib/lwk/participants"; +import { + publishParticipantAnnouncement, + publishVote, +} from "./lib/lwk/publishing"; +import { + discoverParticipantAnnouncements, + scanSession, +} from "./lib/lwk/scan"; +import { finalizeAndBroadcastSpend } from "./lib/lwk/spend"; +import { esploraTxHex } from "./lib/lwk/network"; +import { FaucetAsset, FaucetTarget, ScanTransaction, ScanVote } from "./types"; + +export function createAppActions(ctx: AppActionContext) { + const { + activeMultisigDescriptor, + announcementMnemonic, + announcementStake, + announcementStakeValid, + changeOutputs, + claimed, + claimMnemonic, + currentMultisigAddress, + descriptorText, + executorFeeRate, + executorFundingEnabled, + executorFundingMnemonic, + feeAmount, + info, + manualVoteTx, + outputs, + participantKeys, + proposal, + proposalAmountErrors, + proposalAmountsValid, + scan, + selectedUtxos, + session, + threshold, + underfundedAssets, + vote, + voteStake, + voteStakeValid, + votesForFinalization, + setActiveTab, + setAnnouncementMnemonic, + setAnnouncementScan, + setBusyAction, + setClaimed, + setClaimMnemonic, + setDescriptorText, + setFaucetBusy, + setFaucetResult, + setFinalSpend, + setMultisigDescriptor, + setParticipantKeys, + setProposal, + setScan, + setSelectedInputs, + setSession, + setThreshold, + setVote, + } = ctx; + + async function withBusyAction(action: BusyAction, work: () => Promise): Promise { + setBusyAction(action); + try { + return await work(); + } finally { + setBusyAction(undefined); + } + } + + async function fillDemo() { + await run(ctx, "Deriving demo participants", async () => { + const keys = await Promise.all( + demoMnemonics.map((mnemonic, index) => deriveParticipantKey(mnemonic, index)), + ); + setParticipantKeys(keys.map((key) => key.xOnlyPublicKey)); + setClaimMnemonic(demoMnemonics[0]); + setAnnouncementMnemonic(demoMnemonics[0]); + }); + } + + async function createDescriptor() { + const next = await run(ctx, "Creating multisig descriptor", () => + createMultisigDescriptor(threshold, participantKeys), + ); + if (next) { + applyLoadedDescriptor(ctx, next); + setDescriptorText(JSON.stringify(next, null, 2)); + setActiveTab("create"); + } + } + + async function loadDescriptor() { + const nextDescriptor = await run(ctx, "Inspecting descriptor", () => + inspectMultisigDescriptor(descriptorText), + ); + if (nextDescriptor) { + applyLoadedDescriptor(ctx, nextDescriptor); + await refreshAnnouncements(nextDescriptor); + } + } + + async function refreshAnnouncements(descriptor = activeMultisigDescriptor): Promise { + if (!descriptor || !info) return false; + setAnnouncementScan((current) => ({ + ...current, + status: "scanning", + message: "Scanning descriptor announcements", + })); + + const next = await run(ctx, "Scanning descriptor announcements", () => + discoverParticipantAnnouncements(descriptor, info), + ); + if (!next) { + setAnnouncementScan((current) => ({ + ...current, + status: "error", + message: "Announcement scan failed", + })); + return false; + } + + setAnnouncementScan(next.scan); + if (next.session) { + setSession(next.session); + setMultisigDescriptor(descriptor); + setThreshold(next.session.threshold); + setParticipantKeys( + next.session.participants.map((participant) => participant.xOnlyPublicKey), + ); + setScan(emptyScan); + clearProposalState(ctx); + } + return true; + } + + async function rescan(): Promise { + if (!info) return false; + if (!session) { + return refreshAnnouncements(); + } + + setScan((current) => ({ + ...current, + status: "scanning", + message: "Scanning Liquid testnet", + })); + const next = await run(ctx, "Scanning Liquid testnet", () => scanSession(session, info, claimed)); + if (next) { + setScan(next); + setSelectedInputs(new Set(next.utxos.slice(0, 1).map(utxoKey))); + return true; + } + + setScan((current) => ({ + ...current, + status: "error", + message: "Scan failed", + })); + return false; + } + + async function claim() { + if (!session) return; + const next = await run(ctx, "Verifying participant", () => claimParticipant(session, claimMnemonic)); + if (next) { + setClaimed(next); + } + } + + async function buildProposal() { + if (!session || !info) return; + const next = await run(ctx, "Building proposal", () => { + if (!proposalAmountsValid) { + throw new Error(proposalAmountErrors[0]); + } + if (underfundedAssets.length > 0) { + throw new Error(`Selected inputs do not cover ${underfundedAssets.join(", ")}`); + } + + return createProposedSpend(session, selectedUtxos, [ + ...outputs, + ...changeOutputs, + { + id: "fee", + kind: "fee", + address: "", + asset: info.policyAsset, + value: feeAmount, + }, + ]); + }); + if (next) { + setProposal(next); + clearVoteState(ctx); + } + } + + async function signVote() { + if (!session || !proposal) return; + const mnemonic = claimed?.mnemonic ?? claimMnemonic; + await withBusyAction("create-vote", async () => { + const next = await run(ctx, "Signing vote", () => + createSignedVote(session, proposal.psetBase64, proposal.totalProposedOutputs, mnemonic), + ); + if (next) { + setVote(next); + } + }); + } + + async function broadcastVote() { + if (!info || !session || !claimed || !proposal || !vote) return; + if (!voteStakeValid) { + showToast(ctx, "error", "Publishing vote", amountLabel("Vote amount")); + return; + } + await withBusyAction("publish-vote", async () => { + const published = await run(ctx, "Publishing vote", () => + publishVote(info, claimed, proposal, vote, voteStake), + ); + if (published) { + const decoded = await decodeVoteTransactionAuto(session, published.txHex); + await navigator.clipboard.writeText(published.txid).catch(() => undefined); + await rescan(); + mergeDiscoveredVote(scanVoteFromDecoded(decoded, published.txid, published.explorerUrl)); + showToast(ctx, "success", "Vote published", published.txid); + } + }); + } + + async function fundFromFaucet(target: FaucetTarget, asset: FaucetAsset) { + if (!info) return; + + const address = target === "multisig" ? currentMultisigAddress : claimed?.fundingAddress; + if (!address) return; + + const busyKey = `${target}:${asset}`; + setFaucetBusy(busyKey); + try { + const next = await run(ctx, `Requesting ${assetLabel(asset)} faucet funds`, () => + requestFaucetFunds(info, target, address, asset), + ); + if (next) { + setFaucetResult(next); + } + } finally { + setFaucetBusy(undefined); + } + } + + async function publishAnnouncement() { + if (!info || !activeMultisigDescriptor || !announcementMnemonic.trim()) return; + if (!announcementStakeValid) { + showToast(ctx, "error", "Publishing participant announcement", amountLabel("Dust amount")); + return; + } + await withBusyAction("publish-announcement", async () => { + const next = await run(ctx, "Publishing participant announcement", () => + publishParticipantAnnouncement( + info, + activeMultisigDescriptor, + announcementMnemonic, + announcementStake, + ), + ); + if (next) { + await navigator.clipboard.writeText(next.txid).catch(() => undefined); + if (await refreshAnnouncements(activeMultisigDescriptor)) { + showToast(ctx, "success", `Participant ${next.participantIndex + 1} announced`, next.txid); + } + } + }); + } + + async function decodeManualVote() { + if (!session || !manualVoteTx.trim()) return; + const result = await run(ctx, "Decoding vote transaction", async () => { + const trimmed = manualVoteTx.trim(); + let tx = { hex: trimmed, txid: "manual", explorerUrl: "" }; + if (/^[0-9a-fA-F]{64}$/.test(trimmed)) { + if (!info) { + throw new Error("Load Liquid testnet info before decoding a vote txid."); + } + const txid = trimmed.toLowerCase(); + tx = { + hex: await esploraTxHex(info, txid), + txid, + explorerUrl: `${info.explorerTxUrlPrefix}${txid}`, + }; + } + const decoded = proposal + ? await decodeVoteTransaction(session, tx.hex, proposal.totalProposedOutputs) + : await decodeVoteTransactionAuto(session, tx.hex); + + return { decoded, txid: tx.txid, explorerUrl: tx.explorerUrl }; + }); + if (result) { + mergeDiscoveredVote(scanVoteFromDecoded(result.decoded, result.txid, result.explorerUrl)); + } + } + + function mergeDiscoveredVote(vote: ScanVote) { + setScan((current) => { + const transaction: ScanTransaction = { + txid: vote.txid, + type: "vote", + sources: [`Participant ${vote.participantIndex + 1}`], + explorerUrl: vote.explorerUrl, + }; + const existingTransaction = current.transactions.find( + (item) => item.txid === transaction.txid, + ); + const transactions = existingTransaction + ? current.transactions.map((item) => + item.txid === transaction.txid + ? { + ...item, + type: item.type === "vote" ? item.type : transaction.type, + sources: [...new Set([...item.sources, ...transaction.sources])], + explorerUrl: item.explorerUrl || transaction.explorerUrl, + } + : item, + ) + : [transaction, ...current.transactions]; + + return { + ...current, + votes: [...current.votes.filter((item) => item.txid !== vote.txid), vote], + transactions, + }; + }); + } + + async function loadProposalFromVote(vote: ScanVote) { + const next = await run(ctx, "Loading proposal from vote", async () => { + const inputUtxos = vote.proposalInputOutpoints.map((outpoint) => { + const utxo = scan.utxos.find( + (candidate) => candidate.txid === outpoint.txid && candidate.vout === outpoint.vout, + ); + if (!utxo) { + throw new Error(`Missing multisig UTXO ${outpoint.txid}:${outpoint.vout}`); + } + return utxo; + }); + + return { + psetBase64: vote.proposedPsetBase64, + txHex: vote.proposedTxHex, + totalProposedOutputs: vote.totalProposedOutputs, + messageHash: vote.messageHash, + inputUtxos, + }; + }); + + if (next) { + setProposal(next); + clearVoteState(ctx); + setSelectedInputs(new Set(next.inputUtxos.map(utxoKey))); + setActiveTab("builder"); + } + } + + async function broadcastSpend() { + if (!info || !session || !proposal) return; + await withBusyAction("finalize-spend", async () => { + const next = await run(ctx, "Finalizing multisig spend", () => + finalizeAndBroadcastSpend( + info, + session, + proposal, + votesForFinalization, + executorFundingEnabled && executorFundingMnemonic?.trim() + ? { + mnemonic: executorFundingMnemonic, + feeRate: executorFeeRate, + } + : undefined, + ), + ); + if (next) { + setFinalSpend(next); + await navigator.clipboard.writeText(next.txid).catch(() => undefined); + if (await rescan()) { + showToast(ctx, "success", "Final spend broadcast", next.txid); + } + } + }); + } + + return { + broadcastSpend, + broadcastVote, + buildProposal, + claim, + createDescriptor, + decodeManualVote, + fillDemo, + fundFromFaucet, + loadDescriptor, + loadProposalFromVote, + publishAnnouncement, + refreshAnnouncements, + rescan, + signVote, + }; +} diff --git a/web/src/app-helpers.ts b/web/src/app-helpers.ts new file mode 100644 index 0000000..778b429 --- /dev/null +++ b/web/src/app-helpers.ts @@ -0,0 +1,137 @@ +import type { + DecodedVoteResult, + AnnouncementScanState, + FaucetAsset, + ScanVote, + WireUtxo, + ScanState, +} from "./types"; + +type ProposalGroup = { + messageHash: string; + votes: ScanVote[]; + inputOutpoints: ScanVote["proposalInputOutpoints"]; + totalProposedOutputs: number; + inputValue: number; + ready: boolean; +}; + +export const emptyScan: ScanState = { + status: "idle", + message: "No scan yet", + utxos: [], + transactions: [], + votes: [], + ownerUtxos: [], +}; + +export const emptyAnnouncementScan: AnnouncementScanState = { + status: "idle", + message: "No announcement scan yet", + announcements: [], + transactions: [], +}; + +export function outputId(): string { + return crypto.randomUUID(); +} + +export function utxoKey(utxo: WireUtxo): string { + return `${utxo.txid}:${utxo.vout}`; +} + +export function assetLabel(asset: FaucetAsset): string { + return asset === "test" ? "TEST" : "L-BTC"; +} + +export function amountFromInput(value: string): number { + if (value.trim() === "") { + return 0; + } + const next = Number(value); + return Number.isFinite(next) ? next : 0; +} + +export function amountLabel(label: string): string { + return `${label} must be a whole positive satoshi amount.`; +} + +export function scanVoteFromDecoded( + decoded: DecodedVoteResult, + txid: string, + explorerUrl: string, +): ScanVote { + return { + participantIndex: decoded.participantIndex, + txid: decoded.voteUtxo?.txid ?? txid, + messageHash: decoded.messageHash, + signatureHex: decoded.participantSignatureHex, + proposedPsetBase64: decoded.proposedPsetBase64, + proposedTxHex: decoded.proposedTxHex, + totalProposedOutputs: decoded.totalProposedOutputs, + proposalInputOutpoints: decoded.proposalInputOutpoints, + voteAddress: decoded.voteAddress, + voteUtxo: decoded.voteUtxo, + explorerUrl, + }; +} + +export function groupSpendableProposals( + votes: ScanVote[], + multisigUtxos: WireUtxo[], + threshold: number, +): ProposalGroup[] { + const byMessageHash = new Map(); + for (const vote of votes) { + byMessageHash.set(vote.messageHash, [ + ...(byMessageHash.get(vote.messageHash) ?? []), + vote, + ]); + } + + return [...byMessageHash.entries()] + .map(([messageHash, groupVotes]) => { + const votes = finalizableVotes(groupVotes, messageHash); + const firstVote = votes[0] ?? groupVotes[0]; + const inputValue = firstVote.proposalInputOutpoints.reduce((total, outpoint) => { + const utxo = multisigUtxos.find( + (candidate) => candidate.txid === outpoint.txid && candidate.vout === outpoint.vout, + ); + return total + (utxo?.value ?? 0); + }, 0); + + return { + messageHash, + votes, + inputOutpoints: firstVote.proposalInputOutpoints, + totalProposedOutputs: firstVote.totalProposedOutputs, + inputValue, + ready: threshold > 0 && votes.length >= threshold, + }; + }) + .filter((group) => group.votes.length > 0) + .sort((left, right) => { + if (left.ready !== right.ready) return left.ready ? -1 : 1; + if (left.votes.length !== right.votes.length) return right.votes.length - left.votes.length; + return left.messageHash.localeCompare(right.messageHash); + }); +} + +export function finalizableVotes(votes: ScanVote[], proposalMessageHash?: string): ScanVote[] { + const byParticipant = new Map(); + for (const vote of votes) { + if ( + vote.participantIndex < 0 || + !vote.voteUtxo || + (proposalMessageHash !== undefined && vote.messageHash !== proposalMessageHash) || + byParticipant.has(vote.participantIndex) + ) { + continue; + } + byParticipant.set(vote.participantIndex, vote); + } + + return [...byParticipant.values()].sort( + (left, right) => left.participantIndex - right.participantIndex, + ); +} diff --git a/web/src/app-model.ts b/web/src/app-model.ts new file mode 100644 index 0000000..601ed23 --- /dev/null +++ b/web/src/app-model.ts @@ -0,0 +1,431 @@ +import { useEffect, useMemo, useState } from "react"; +import { + emptyAnnouncementScan, + emptyScan, + finalizableVotes, + groupSpendableProposals, + outputId, + utxoKey, +} from "./app-helpers"; +import { clearProposalState } from "./app-state-reset"; +import { createAppActions } from "./app-actions"; +import { liquidTestnetInfo } from "./lib/contracts"; +import { esploraFeeRateSatsPerVbyte } from "./lib/lwk/network"; +import type { + AnnouncementScanState, + ClaimedParticipant, + FaucetResult, + FinalizedSpendResult, + LiquidTestnetInfo, + MultisigDescriptor, + MultisigSession, + ProposalResult, + ScanState, + SignedVoteResult, + SpendOutput, +} from "./types"; + +export type AppTab = "builder" | "proposals" | "create" | "setup" | "faucet" | "transactions"; +export type BusyAction = "create-vote" | "publish-vote" | "finalize-spend" | "publish-announcement"; +export type TransactionSourceFilter = "all" | "multisig" | "participants" | "votes"; +export type ExecutorFundingSource = "verified" | "mnemonic"; +export type FeeRateStatus = "loading" | "fetched" | "manual" | "fallback"; +export type ToastMessage = { + id: string; + tone: "error" | "success"; + title: string; + message: string; +}; + +function safeSats(value: number): number { + return Number.isFinite(value) ? value : 0; +} + +function isPositiveSafeSats(value: number): boolean { + return Number.isSafeInteger(value) && value > 0 && value <= Number.MAX_SAFE_INTEGER; +} + +function assetTotals(items: Array<{ asset: string; value: number }>): Map { + return items.reduce( + (totals, item) => totals.set(item.asset, (totals.get(item.asset) ?? 0) + safeSats(item.value)), + new Map(), + ); +} + +export function useAppModel() { + const [activeTab, setActiveTab] = useState("builder"); + const [info, setInfo] = useState(); + const [descriptorText, setDescriptorText] = useState(""); + const [threshold, setThreshold] = useState(2); + const [participantKeys, setParticipantKeys] = useState(["", "", ""]); + const [multisigDescriptor, setMultisigDescriptor] = useState(); + const [session, setSession] = useState(); + const [scan, setScan] = useState(emptyScan); + const [announcementScan, setAnnouncementScan] = + useState(emptyAnnouncementScan); + const [selectedInputs, setSelectedInputs] = useState>(new Set()); + const [outputs, setOutputs] = useState([]); + const [proposal, setProposal] = useState(); + const [feeAmount, setFeeAmount] = useState(300); + const [claimMnemonic, setClaimMnemonic] = useState(""); + const [announcementMnemonic, setAnnouncementMnemonic] = useState(""); + const [announcementStake, setAnnouncementStake] = useState(1_000); + const [claimed, setClaimed] = useState(); + const [vote, setVote] = useState(); + const [voteStake, setVoteStake] = useState(1_000); + const [manualVoteTx, setManualVoteTx] = useState(""); + const [faucetBusy, setFaucetBusy] = useState(); + const [faucetResult, setFaucetResult] = useState(); + const [finalSpend, setFinalSpend] = useState(); + const [busyAction, setBusyAction] = useState(); + const [executorFundingEnabled, setExecutorFundingEnabled] = useState(false); + const [executorFundingSource, setExecutorFundingSource] = + useState("verified"); + const [executorMnemonic, setExecutorMnemonic] = useState(""); + const [executorFeeRate, setExecutorFeeRate] = useState(0.1); + const [executorFeeRateStatus, setExecutorFeeRateStatus] = + useState("manual"); + const [transactionSource, setTransactionSource] = useState("all"); + const [transactionQuery, setTransactionQuery] = useState(""); + const [activity, setActivity] = useState("Ready"); + const [toast, setToast] = useState(); + + useEffect(() => { + liquidTestnetInfo() + .then((next) => { + setInfo(next); + setOutputs([ + { + id: outputId(), + kind: "transfer", + address: "", + asset: next.policyAsset, + value: 1_000, + }, + ]); + }) + .catch((nextError: unknown) => { + const message = nextError instanceof Error ? nextError.message : String(nextError); + setToast({ + id: outputId(), + tone: "error", + title: "Loading testnet constants failed", + message, + }); + }); + }, []); + + useEffect(() => { + if (!info) return; + + let cancelled = false; + setExecutorFeeRateStatus("loading"); + esploraFeeRateSatsPerVbyte(info) + .then((next) => { + if (!cancelled) { + setExecutorFeeRate(Number(next.toFixed(2))); + setExecutorFeeRateStatus("fetched"); + } + }) + .catch(() => { + if (!cancelled) { + setExecutorFeeRateStatus("fallback"); + } + }); + + return () => { + cancelled = true; + }; + }, [info]); + + useEffect(() => { + if (!claimed && executorFundingSource === "verified") { + setExecutorFundingSource("mnemonic"); + } + }, [claimed, executorFundingSource]); + + const selectedUtxos = useMemo(() => { + return scan.utxos.filter((utxo) => selectedInputs.has(utxoKey(utxo))); + }, [scan.utxos, selectedInputs]); + + const selectedValue = selectedUtxos.reduce((sum, utxo) => sum + safeSats(utxo.value), 0); + const outputValue = outputs.reduce((sum, output) => sum + safeSats(output.value), 0); + const proposedSpendValue = outputValue + safeSats(feeAmount); + const proposalAmountErrors = [ + ...outputs.flatMap((output, index) => + isPositiveSafeSats(output.value) + ? [] + : [`Output ${index + 1} amount must be a whole positive satoshi amount.`], + ), + ...(Number.isSafeInteger(feeAmount) && feeAmount >= 0 && feeAmount <= Number.MAX_SAFE_INTEGER + ? [] + : ["Fee must be a whole non-negative satoshi amount."]), + ]; + const proposalAmountsValid = proposalAmountErrors.length === 0; + const voteStakeValid = isPositiveSafeSats(voteStake); + const announcementStakeValid = isPositiveSafeSats(announcementStake); + const selectedByAsset = assetTotals(selectedUtxos); + const outputByAsset = assetTotals(outputs); + const requestedAssetValue = (asset: string) => + (outputByAsset.get(asset) ?? 0) + (asset === info?.policyAsset ? safeSats(feeAmount) : 0); + const changeOutputs: SpendOutput[] = + session && info + ? [...selectedByAsset.entries()].flatMap(([asset, value]) => { + const change = value - requestedAssetValue(asset); + return change > 0 + ? [ + { + id: `change-${asset}`, + kind: "transfer" as const, + address: session.multisigAddress, + asset, + value: change, + }, + ] + : []; + }) + : []; + const underfundedAssets = + info === undefined + ? [] + : [...new Set([...outputByAsset.keys(), info.policyAsset])].filter( + (asset) => requestedAssetValue(asset) > (selectedByAsset.get(asset) ?? 0), + ); + const policyChangeValue = + info === undefined + ? 0 + : Math.max( + 0, + (selectedByAsset.get(info.policyAsset) ?? 0) - requestedAssetValue(info.policyAsset), + ); + + const spendableVotes = useMemo(() => { + const liveMultisigOutpoints = new Set(scan.utxos.map(utxoKey)); + return scan.votes.filter( + (voteItem) => + voteItem.participantIndex >= 0 && + voteItem.voteUtxo !== undefined && + voteItem.proposalInputOutpoints.every((outpoint) => + liveMultisigOutpoints.has(`${outpoint.txid}:${outpoint.vout}`), + ), + ); + }, [scan.utxos, scan.votes]); + const proposalGroups = useMemo( + () => groupSpendableProposals(spendableVotes, scan.utxos, session?.threshold ?? 0), + [scan.utxos, session?.threshold, spendableVotes], + ); + const eligibleVotes = useMemo( + () => + finalizableVotes( + spendableVotes.filter((item) => item.participantIndex >= 0), + proposal?.messageHash, + ), + [proposal, spendableVotes], + ); + const builderVotes = proposal ? eligibleVotes : spendableVotes; + const voteTxids = useMemo( + () => new Set([...scan.votes.map((item) => item.txid), ...spendableVotes.map((item) => item.txid)]), + [scan.votes, spendableVotes], + ); + const visibleTransactions = useMemo(() => { + const query = transactionQuery.trim().toLowerCase(); + return scan.transactions.filter((tx) => { + const isVote = voteTxids.has(tx.txid); + const matchesSource = + transactionSource === "all" || + (transactionSource === "votes" && isVote) || + (transactionSource === "multisig" && tx.sources.includes("Multisig")) || + (transactionSource === "participants" && + tx.sources.some((source) => source.startsWith("Participant"))); + if (!matchesSource) { + return false; + } + if (!query) { + return true; + } + return [tx.txid, tx.type, ...tx.sources].some((value) => + value.toLowerCase().includes(query), + ); + }); + }, [scan.transactions, transactionQuery, transactionSource, voteTxids]); + + const votesForFinalization = eligibleVotes.slice(0, session?.threshold ?? 0); + const executorFundingMnemonic = + executorFundingSource === "verified" ? claimed?.mnemonic : executorMnemonic; + const executorFundingReady = + !executorFundingEnabled || Boolean(executorFundingMnemonic?.trim()); + const canFinalize = + Boolean(info && session && proposal) && + votesForFinalization.length >= (session?.threshold ?? Number.POSITIVE_INFINITY) && + executorFundingReady; + const actionBusy = busyAction !== undefined; + const isCreatingVote = busyAction === "create-vote"; + const isPublishingVote = busyAction === "publish-vote"; + const isFinalizingSpend = busyAction === "finalize-spend"; + const isPublishingAnnouncement = busyAction === "publish-announcement"; + const activeMultisigDescriptor = + multisigDescriptor ?? + (session + ? { + version: session.version, + network: session.network, + threshold: session.threshold, + participants: session.participants.map((participant) => ({ + index: participant.index, + xOnlyPublicKey: participant.xOnlyPublicKey, + })), + multisigScriptPubkey: session.multisigScriptPubkey, + multisigAddress: session.multisigAddress, + lwkDescriptor: session.lwkDescriptor, + } + : undefined); + const currentMultisigAddress = + session?.multisigAddress ?? activeMultisigDescriptor?.multisigAddress; + + useEffect(() => { + if ( + proposal && + scan.status === "ready" && + !proposal.inputUtxos.every((utxo) => + scan.utxos.some((candidate) => candidate.txid === utxo.txid && candidate.vout === utxo.vout), + ) + ) { + clearProposalState({ setFinalSpend, setProposal, setVote }); + } + }, [proposal, scan.status, scan.utxos]); + + const actions = createAppActions({ + activeMultisigDescriptor, + announcementMnemonic, + announcementStake, + announcementStakeValid, + changeOutputs, + claimed, + claimMnemonic, + currentMultisigAddress, + descriptorText, + executorFeeRate, + executorFundingEnabled, + executorFundingMnemonic, + feeAmount, + info, + manualVoteTx, + outputs, + participantKeys, + proposal, + proposalAmountErrors, + proposalAmountsValid, + scan, + selectedUtxos, + session, + threshold, + underfundedAssets, + vote, + voteStake, + voteStakeValid, + votesForFinalization, + setActiveTab, + setActivity, + setAnnouncementMnemonic, + setAnnouncementScan, + setBusyAction, + setClaimed, + setClaimMnemonic, + setDescriptorText, + setFaucetBusy, + setFaucetResult, + setFinalSpend, + setMultisigDescriptor, + setParticipantKeys, + setProposal, + setScan, + setSelectedInputs, + setSession, + setThreshold, + setToast, + setVote, + }); + + return { + ...actions, + actionBusy, + activeMultisigDescriptor, + activeTab, + activity, + announcementMnemonic, + announcementScan, + announcementStake, + announcementStakeValid, + builderVotes, + canFinalize, + changeOutputs, + claimed, + claimMnemonic, + currentMultisigAddress, + descriptorText, + eligibleVotes, + executorFeeRate, + executorFeeRateStatus, + executorFundingEnabled, + executorFundingSource, + executorMnemonic, + faucetBusy, + faucetResult, + feeAmount, + finalSpend, + info, + isCreatingVote, + isFinalizingSpend, + isPublishingAnnouncement, + isPublishingVote, + manualVoteTx, + outputs, + outputValue, + participantKeys, + policyChangeValue, + proposal, + proposalAmountErrors, + proposalAmountsValid, + proposalGroups, + proposedSpendValue, + scan, + selectedInputs, + selectedUtxos, + selectedValue, + session, + setActiveTab, + setAnnouncementMnemonic, + setAnnouncementStake, + setClaimMnemonic, + setDescriptorText, + setExecutorFeeRate, + setExecutorFeeRateStatus, + setExecutorFundingEnabled, + setExecutorFundingSource, + setExecutorMnemonic, + setFeeAmount, + setManualVoteTx, + setOutputs, + setParticipantKeys, + setSelectedInputs, + setThreshold, + setToast, + setTransactionQuery, + setTransactionSource, + setVoteStake, + spendableVotes, + threshold, + toast, + transactionQuery, + transactionSource, + underfundedAssets, + visibleTransactions, + vote, + voteStake, + voteStakeValid, + voteTxids, + votesForFinalization, + }; +} + +export type AppModel = ReturnType; diff --git a/web/src/app-shell.tsx b/web/src/app-shell.tsx new file mode 100644 index 0000000..a085e04 --- /dev/null +++ b/web/src/app-shell.tsx @@ -0,0 +1,147 @@ +import { + AlertTriangle, + Check, + CircleDot, + Droplets, + KeyRound, + Loader2, + Megaphone, + Radio, + RefreshCcw, + Send, + ShieldCheck, + Vote, +} from "lucide-react"; +import type { AppModel } from "./app-model"; +import { middle } from "./lib/format"; +import { BuilderView } from "./views/builder-view"; +import { CreateView } from "./views/create-view"; +import { FaucetView } from "./views/faucet-view"; +import { ProposalsView } from "./views/proposals-view"; +import { SetupView } from "./views/setup-view"; +import { TransactionsView } from "./views/transactions-view"; + +type AppShellProps = { + model: AppModel; +}; + +export function AppShell({ model }: AppShellProps) { + const { + actionBusy, + activeMultisigDescriptor, + activeTab, + activity, + announcementScan, + currentMultisigAddress, + info, + scan, + session, + setActiveTab, + setToast, + toast, + rescan, + } = model; + + return ( +
+
+
+
+
+ +
+
+

Simplicity coordination surface

+

Simplicity Native Multisig

+

{currentMultisigAddress ? middle(currentMultisigAddress, 18) : "No descriptor loaded"}

+
+
+
+
+ {scan.status === "scanning" ? : } + {activity} +
+ +
+
+ + + + {toast && ( +
+
+ {toast.tone === "error" ? : } +
+ {toast.title} + {toast.message} +
+ +
+
+ )} + {activeTab === "builder" && } + {activeTab === "proposals" && } + {activeTab === "create" && } + {activeTab === "setup" && } + {activeTab === "faucet" && } + {activeTab === "transactions" && } +
+
+ ); +} diff --git a/web/src/app-state-reset.ts b/web/src/app-state-reset.ts new file mode 100644 index 0000000..62641f4 --- /dev/null +++ b/web/src/app-state-reset.ts @@ -0,0 +1,13 @@ +import type { AppActionContext } from "./app-action-context"; + +export function clearVoteState(ctx: Pick) { + ctx.setVote(undefined); + ctx.setFinalSpend(undefined); +} + +export function clearProposalState( + ctx: Pick, +) { + ctx.setProposal(undefined); + clearVoteState(ctx); +} diff --git a/web/src/components.tsx b/web/src/components.tsx new file mode 100644 index 0000000..03e371e --- /dev/null +++ b/web/src/components.tsx @@ -0,0 +1,58 @@ +import { Copy } from "lucide-react"; +import type { ReactNode } from "react"; + +export function Metric({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +export function Panel({ + title, + icon, + children, + wide = false, +}: { + title: string; + icon: ReactNode; + children: ReactNode; + wide?: boolean; +}) { + return ( +
+
+ {icon} +

{title}

+
+ {children} +
+ ); +} + +export function EmptyRow({ colSpan, label }: { colSpan: number; label: string }) { + return ( + + + {label} + + + ); +} + +export function CodeBlock({ label, value }: { label: string; value: string }) { + return ( +
+
+ {label} + +
+ {value} +
+ ); +} diff --git a/web/src/lib/contracts.ts b/web/src/lib/contracts.ts new file mode 100644 index 0000000..2c45764 --- /dev/null +++ b/web/src/lib/contracts.ts @@ -0,0 +1,220 @@ +import type { + CarrierAppendResult, + DecodedVoteResult, + FinalizedSpendResult, + ExecutorInputSecret, + LiquidTestnetInfo, + MultisigDescriptor, + MultisigSession, + ParticipantAnnouncement, + ParticipantAnnouncementAppendResult, + ParticipantKey, + ProposalResult, + PsetResult, + SignedVoteResult, + SpendOutput, + WireUtxo, + VoteInput, +} from "../types"; + +type ContractModule = typeof import("../generated/contracts/simplicity_native_multisig_wasm.js"); + +let modulePromise: Promise | undefined; + +async function contracts(): Promise { + modulePromise ??= import("../generated/contracts/simplicity_native_multisig_wasm.js").then( + async (contractModule) => { + await contractModule.default(); + return contractModule; + }, + ); + return modulePromise; +} + +async function callJson(call: (contractModule: ContractModule) => string): Promise { + return JSON.parse(call(await contracts())) as T; +} + +export async function liquidTestnetInfo(): Promise { + return callJson((contractModule) => contractModule.liquidTestnetInfo()); +} + +export async function deriveParticipantKey( + mnemonic: string, + account: number, +): Promise { + return callJson((contractModule) => + contractModule.deriveParticipantKey(mnemonic, account), + ); +} + +export async function createMultisigDescriptor( + threshold: number, + participantPubkeys: string[], +): Promise { + return callJson((contractModule) => + contractModule.createMultisigDescriptor( + threshold, + JSON.stringify(participantPubkeys), + ), + ); +} + +export async function inspectMultisigDescriptor( + descriptorJson: string, +): Promise { + return callJson((contractModule) => + contractModule.inspectMultisigDescriptor(descriptorJson), + ); +} + +export async function appendParticipantAnnouncementOutputs( + announcementPsetBase64: string, + multisigDescriptor: MultisigDescriptor, + participantDescriptor: string, + mnemonic: string, +): Promise { + return callJson((contractModule) => + contractModule.appendParticipantAnnouncementOutputs( + announcementPsetBase64, + JSON.stringify(multisigDescriptor), + participantDescriptor, + mnemonic, + ), + ); +} + +export async function decodeParticipantAnnouncementTransaction( + multisigDescriptor: MultisigDescriptor, + txHex: string, +): Promise { + return callJson((contractModule) => + contractModule.decodeParticipantAnnouncementTransaction( + JSON.stringify(multisigDescriptor), + txHex, + ), + ); +} + +export async function createSessionFromParticipantAnnouncements( + multisigDescriptor: MultisigDescriptor, + announcements: ParticipantAnnouncement[], +): Promise { + return callJson((contractModule) => + contractModule.createSessionFromParticipantAnnouncements( + JSON.stringify(multisigDescriptor), + JSON.stringify(announcements), + ), + ); +} + +export async function createProposedSpend( + session: MultisigSession, + utxos: WireUtxo[], + outputs: SpendOutput[], +): Promise { + const wireOutputs = outputs.map((output) => ({ + kind: output.kind, + address: output.kind === "transfer" ? output.address : undefined, + scriptPubkey: output.scriptPubkey, + asset: output.asset, + value: output.value, + })); + + const result = await callJson>((contractModule) => + contractModule.createProposedSpend( + JSON.stringify(session), + JSON.stringify(utxos), + JSON.stringify(wireOutputs), + ), + ); + return { ...result, inputUtxos: utxos }; +} + +export async function createSignedVote( + session: MultisigSession, + proposedPsetBase64: string, + totalProposedOutputs: number, + mnemonic: string, +): Promise { + return callJson((contractModule) => + contractModule.createSignedVote( + JSON.stringify(session), + proposedPsetBase64, + totalProposedOutputs, + mnemonic, + ), + ); +} + +export async function appendVoteCarrierOutputs( + votePsetBase64: string, + proposedPsetBase64: string, + participantSignatureHex: string, +): Promise { + return callJson((contractModule) => + contractModule.appendVoteCarrierOutputs( + votePsetBase64, + proposedPsetBase64, + participantSignatureHex, + ), + ); +} + +export async function decodeVoteTransaction( + session: MultisigSession, + txHex: string, + totalProposedOutputs: number, +): Promise { + return callJson((contractModule) => + contractModule.decodeVoteTransaction( + JSON.stringify(session), + txHex, + totalProposedOutputs, + ), + ); +} + +export async function decodeVoteTransactionAuto( + session: MultisigSession, + txHex: string, +): Promise { + return callJson((contractModule) => + contractModule.decodeVoteTransactionAuto(JSON.stringify(session), txHex), + ); +} + +type SpendPlan = { + session: MultisigSession; + proposedPsetBase64: string; + multisigUtxos: WireUtxo[]; + voteInputs: VoteInput[]; + totalProposedOutputs: number; +}; + +export async function finalizeSpendPlan( + plan: SpendPlan, +): Promise> { + return callJson((contractModule) => + contractModule.finalizeSpendPlan(JSON.stringify(plan)), + ); +} + +export async function prepareExecutorFundedSpend( + plan: SpendPlan & { + executorPsetBase64: string; + executorInputSecrets: ExecutorInputSecret[]; + }, +): Promise { + return callJson((contractModule) => + contractModule.prepareExecutorFundedSpend(JSON.stringify(plan)), + ); +} + +export async function finalizePreparedSpendPlan( + plan: SpendPlan & { preparedPsetBase64: string }, +): Promise> { + return callJson((contractModule) => + contractModule.finalizePreparedSpendPlan(JSON.stringify(plan)), + ); +} diff --git a/web/src/lib/demo.ts b/web/src/lib/demo.ts new file mode 100644 index 0000000..60c5b05 --- /dev/null +++ b/web/src/lib/demo.ts @@ -0,0 +1,5 @@ +export const demoMnemonics = [ + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + "letter advice cage absurd amount doctor acoustic avoid letter advice cage above", + "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong", +]; diff --git a/web/src/lib/faucet.ts b/web/src/lib/faucet.ts new file mode 100644 index 0000000..ac97845 --- /dev/null +++ b/web/src/lib/faucet.ts @@ -0,0 +1,68 @@ +import type { FaucetAsset, FaucetResult, FaucetTarget, LiquidTestnetInfo } from "../types"; + +type FaucetApiResponse = { + result?: string; + result_test?: string; + error?: string; + txid?: string; + balance?: number; + balance_test?: number; +}; + +export async function requestFaucetFunds( + info: LiquidTestnetInfo, + target: FaucetTarget, + address: string, + asset: FaucetAsset, +): Promise { + const normalizedAddress = address.trim(); + if (!normalizedAddress) { + throw new Error("Faucet address is required"); + } + + const query = new URLSearchParams({ address: normalizedAddress, action: asset }); + const response = await fetch(`/liquidtestnet-api/faucet?${query.toString()}`, { + headers: { accept: "application/json" }, + cache: "no-store", + }); + const text = await response.text(); + + if (!response.ok) { + const plainText = text + .replace(/<[^>]*>/g, " ") + .replace(/\s+/g, " ") + .trim(); + throw new Error(`Faucet rejected the request (${response.status}): ${plainText}`); + } + + let data: FaucetApiResponse; + try { + data = JSON.parse(text) as FaucetApiResponse; + } catch { + throw new Error("Faucet did not return JSON. Check the local Vite proxy configuration."); + } + const message = asset === "test" ? data.result_test : data.result; + const error = + data.error || + (!message + ? "missing faucet result" + : ["error", "missing address"].includes(message.trim().toLowerCase()) + ? message + : undefined); + if (error) { + throw new Error(`Faucet error: ${error}`); + } + + return { + target, + asset, + address: normalizedAddress, + message: + message || + (asset === "test" ? "TEST faucet request submitted" : "L-BTC faucet request submitted"), + txid: data.txid, + explorerUrl: data.txid ? `${info.explorerTxUrlPrefix}${data.txid}` : undefined, + balance: data.balance, + balanceTest: data.balance_test, + }; +} diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts new file mode 100644 index 0000000..51d8db3 --- /dev/null +++ b/web/src/lib/format.ts @@ -0,0 +1,11 @@ +export function middle(value: string, size = 8): string { + if (value.length <= size * 2 + 3) { + return value; + } + return `${value.slice(0, size)}...${value.slice(-size)}`; +} + +export function sats(value: number | bigint): string { + const numberValue = typeof value === "bigint" ? Number(value) : value; + return `${new Intl.NumberFormat("en-US").format(numberValue)} sats`; +} diff --git a/web/src/lib/lwk/core.ts b/web/src/lib/lwk/core.ts new file mode 100644 index 0000000..1eb0a81 --- /dev/null +++ b/web/src/lib/lwk/core.ts @@ -0,0 +1,10 @@ +import type * as Lwk from "lwk_wasm"; + +let lwkPromise: Promise | undefined; + +export const walletScanIndex = 1; + +export async function loadLwk(): Promise { + lwkPromise ??= import("lwk_wasm"); + return lwkPromise; +} diff --git a/web/src/lib/lwk/network.ts b/web/src/lib/lwk/network.ts new file mode 100644 index 0000000..754657d --- /dev/null +++ b/web/src/lib/lwk/network.ts @@ -0,0 +1,214 @@ +import type * as Lwk from "lwk_wasm"; +import type { + LiquidTestnetInfo, + MultisigDescriptor, + ScanTransaction, + WireUtxo, +} from "../../types"; + +const waterfallsServerRecipient = + "age1xxzrgrfjm3yrwh3u6a7exgrldked0pdauvr3mx870wl6xzrwm5ps8s2h0p"; + +type EsploraStatus = { + confirmed?: boolean; + block_height?: number; + block_time?: number; +}; + +type EsploraUtxo = { + txid: string; + vout: number; + value?: number; + asset?: string; +}; + +type EsploraTransaction = { + txid: string; + status?: EsploraStatus; + vout?: EsploraOutput[]; +}; + +type EsploraOutput = { + scriptpubkey_address?: string; +}; + +function esploraBaseUrl(info: LiquidTestnetInfo): string { + return info.defaultEsploraUrl.replace(/\/$/, ""); +} + +export async function scanDescriptor( + lwk: typeof Lwk, + network: Lwk.Network, + client: Lwk.EsploraClient, + descriptor: string, + info: LiquidTestnetInfo, + scanToIndex?: number, +): Promise<{ + wallet: Lwk.Wollet; + utxos: WireUtxo[]; + transactions: ScanTransaction[]; +}> { + const wallet = new lwk.Wollet(network, new lwk.WolletDescriptor(descriptor)); + let update: Lwk.Update | undefined; + let scanned = false; + let lastScanError: unknown; + for (let attempt = 0; attempt < 4; attempt += 1) { + try { + const scan = + scanToIndex === undefined + ? client.fullScan(wallet) + : client.fullScanToIndex(wallet, scanToIndex); + update = await new Promise((resolve, reject) => { + const timer = window.setTimeout( + () => reject(new Error("LWK scan timed out")), + 45_000, + ); + scan.then( + (value) => { + window.clearTimeout(timer); + resolve(value); + }, + (error: unknown) => { + window.clearTimeout(timer); + reject(error); + }, + ); + }); + scanned = true; + break; + } catch (error) { + lastScanError = error; + const message = error instanceof Error ? error.message : String(error); + const transient = + message.includes("HTTP 404") || + message.includes("Block not found") || + message.includes("Failed to fetch"); + if (!transient || attempt === 3) { + throw error; + } + await new Promise((resolve) => window.setTimeout(resolve, 1_500 * (attempt + 1))); + } + } + if (!scanned) { + throw lastScanError; + } + + if (update) { + wallet.applyUpdate(update); + } + + return { + wallet, + utxos: wallet.utxos().flatMap((output) => { + try { + const outpoint = output.outpoint(); + const secrets = output.unblinded(); + return [ + { + txid: outpoint.txid().toString(), + vout: outpoint.vout(), + scriptPubkey: output.scriptPubkey().toString(), + asset: secrets.asset().toString(), + value: Number(secrets.value()), + }, + ]; + } catch { + return []; + } + }), + transactions: wallet.transactions().map((tx) => ({ + txid: tx.txid().toString(), + type: tx.txType(), + height: tx.height(), + timestamp: tx.timestamp(), + sources: [], + explorerUrl: `${info.explorerTxUrlPrefix}${tx.txid().toString()}`, + })), + }; +} + +export async function scanMultisigAddress( + descriptor: MultisigDescriptor, + info: LiquidTestnetInfo, +): Promise<{ + utxos: WireUtxo[]; + transactions: ScanTransaction[]; +}> { + const addressPath = encodeURIComponent(descriptor.multisigAddress); + const [utxos, transactions] = await Promise.all([ + esploraJson(info, `/address/${addressPath}/utxo`), + esploraJson(info, `/address/${addressPath}/txs`), + ]); + + return { + utxos: utxos.flatMap((utxo) => + utxo.asset === undefined || utxo.value === undefined + ? [] + : [ + { + txid: utxo.txid, + vout: utxo.vout, + scriptPubkey: descriptor.multisigScriptPubkey, + asset: utxo.asset, + value: utxo.value, + }, + ], + ), + transactions: transactions.map((tx) => { + const receivesToMultisig = tx.vout?.some( + (output) => output.scriptpubkey_address === descriptor.multisigAddress, + ); + return { + txid: tx.txid, + type: receivesToMultisig ? "received" : "spent", + height: tx.status?.confirmed ? tx.status.block_height : undefined, + timestamp: tx.status?.confirmed ? tx.status.block_time : undefined, + sources: ["Multisig"], + explorerUrl: `${info.explorerTxUrlPrefix}${tx.txid}`, + }; + }), + }; +} + +export async function esploraJson(info: LiquidTestnetInfo, path: string): Promise { + const response = await fetch(`${esploraBaseUrl(info)}${path}`); + if (!response.ok) { + throw new Error(`Esplora request failed with HTTP ${response.status}`); + } + return (await response.json()) as T; +} + +export async function esploraTxHex(info: LiquidTestnetInfo, txid: string): Promise { + const response = await fetch(`${esploraBaseUrl(info)}/tx/${txid}/hex`); + if (!response.ok) { + throw new Error(`Esplora request failed with HTTP ${response.status}`); + } + return response.text(); +} + +export async function esploraFeeRateSatsPerVbyte(info: LiquidTestnetInfo): Promise { + const estimates = await esploraJson>(info, "/fee-estimates"); + const estimate = estimates["1"] ?? estimates["2"] ?? estimates["3"] ?? estimates["6"]; + if (!Number.isFinite(estimate) || estimate <= 0) { + throw new Error("Esplora fee estimates did not include a usable fee rate"); + } + return estimate; +} + +export function esploraClient( + lwk: typeof Lwk, + network: Lwk.Network, + info: LiquidTestnetInfo, +): Lwk.EsploraClient { + return new lwk.EsploraClient(network, info.defaultEsploraUrl, false, 4, false); +} + +export async function waterfallsClient( + lwk: typeof Lwk, + network: Lwk.Network, + info: LiquidTestnetInfo, +): Promise { + const client = new lwk.EsploraClient(network, info.defaultWaterfallsUrl, true, 4, false); + await client.setWaterfallsServerRecipient(waterfallsServerRecipient); + return client; +} diff --git a/web/src/lib/lwk/participants.ts b/web/src/lib/lwk/participants.ts new file mode 100644 index 0000000..4a00cbf --- /dev/null +++ b/web/src/lib/lwk/participants.ts @@ -0,0 +1,30 @@ +import { deriveParticipantKey } from "../contracts"; +import type { ClaimedParticipant, MultisigSession } from "../../types"; +import { loadLwk } from "./core"; + +export async function claimParticipant( + session: MultisigSession, + mnemonic: string, +): Promise { + const trimmed = mnemonic.trim(); + for (const participant of session.participants) { + const derived = await deriveParticipantKey(trimmed, participant.index); + if (derived.xOnlyPublicKey === participant.xOnlyPublicKey) { + const lwk = await loadLwk(); + const network = lwk.Network.testnet(); + const signer = new lwk.Signer(new lwk.Mnemonic(trimmed), network); + const descriptor = signer.wpkhSlip77Descriptor(); + const wallet = new lwk.Wollet(network, descriptor); + return { + participantIndex: participant.index, + xOnlyPublicKey: derived.xOnlyPublicKey, + derivationPath: derived.derivationPath, + mnemonic: trimmed, + voteDescriptor: descriptor.toString(), + fundingAddress: wallet.address(0).address().toString(), + }; + } + } + + throw new Error("Mnemonic does not match any participant key in this descriptor"); +} diff --git a/web/src/lib/lwk/publishing.ts b/web/src/lib/lwk/publishing.ts new file mode 100644 index 0000000..9e60926 --- /dev/null +++ b/web/src/lib/lwk/publishing.ts @@ -0,0 +1,124 @@ +import type * as Lwk from "lwk_wasm"; +import { + appendParticipantAnnouncementOutputs, + appendVoteCarrierOutputs, +} from "../contracts"; +import type { + ClaimedParticipant, + LiquidTestnetInfo, + MultisigDescriptor, + ProposalResult, + SignedVoteResult, +} from "../../types"; +import { loadLwk, walletScanIndex } from "./core"; +import { esploraClient, scanDescriptor, waterfallsClient } from "./network"; + +export const publishFundingFeeRate = 1_000; + +type PublishedVoteResult = { + txid: string; + txHex: string; + explorerUrl: string; +}; + +function assertPositiveSafeSats(value: number, label: string) { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`${label} must be a whole positive satoshi amount.`); + } +} + +export async function publishVote( + info: LiquidTestnetInfo, + claimed: ClaimedParticipant, + proposal: ProposalResult, + vote: SignedVoteResult, + stakeSats: number, +): Promise { + assertPositiveSafeSats(stakeSats, "Vote amount"); + const lwk = await loadLwk(); + const network = lwk.Network.testnet(); + const client = await waterfallsClient(lwk, network, info); + const ownerScan = await scanDescriptor( + lwk, + network, + client, + claimed.voteDescriptor, + info, + walletScanIndex, + ); + const signer = new lwk.Signer(new lwk.Mnemonic(claimed.mnemonic), network); + const builder = new lwk.TxBuilder(network) + .feeRate(publishFundingFeeRate) + .addExplicitRecipient( + new lwk.Address(vote.voteAddress), + BigInt(stakeSats), + lwk.AssetId.fromString(info.policyAsset), + ); + const unsignedVotePset = builder.finish(ownerScan.wallet); + const appended = await appendVoteCarrierOutputs( + unsignedVotePset.toString(), + proposal.psetBase64, + vote.signatureHex, + ); + const signed = signer.sign(new lwk.Pset(appended.psetBase64)); + const finalized = ownerScan.wallet.finalize(signed); + const tx = finalized.extractTx(); + const txid = await esploraClient(lwk, network, info).broadcast(finalized); + return { + txid: txid.toString(), + txHex: tx.toString(), + explorerUrl: `${info.explorerTxUrlPrefix}${txid.toString()}`, + }; +} + +export async function publishParticipantAnnouncement( + info: LiquidTestnetInfo, + multisigDescriptor: MultisigDescriptor, + mnemonic: string, + stakeSats: number, +): Promise<{ + txid: string; + participantIndex: number; + participantDescriptor: string; + explorerUrl: string; +}> { + assertPositiveSafeSats(stakeSats, "Dust amount"); + const lwk = await loadLwk(); + const network = lwk.Network.testnet(); + const trimmed = mnemonic.trim(); + const signer: Lwk.Signer = new lwk.Signer(new lwk.Mnemonic(trimmed), network); + const privateFundingDescriptor = signer.wpkhSlip77Descriptor().toString(); + const participantDescriptor = `elwpkh(${signer.keyoriginXpub(lwk.Bip.bip84())}/0/*)`; + const ownerScan = await scanDescriptor( + lwk, + network, + await waterfallsClient(lwk, network, info), + privateFundingDescriptor, + info, + walletScanIndex, + ); + const builder = new lwk.TxBuilder(network) + .feeRate(publishFundingFeeRate) + .addExplicitRecipient( + new lwk.Address(multisigDescriptor.multisigAddress), + BigInt(stakeSats), + lwk.AssetId.fromString(info.policyAsset), + ); + const unsigned = builder.finish(ownerScan.wallet); + const appended = await appendParticipantAnnouncementOutputs( + unsigned.toString(), + multisigDescriptor, + participantDescriptor, + trimmed, + ); + const signed = signer.sign(new lwk.Pset(appended.psetBase64)); + const finalized = ownerScan.wallet.finalize(signed); + const txid = await esploraClient(lwk, network, info).broadcast(finalized); + + return { + txid: txid.toString(), + participantIndex: appended.participantIndex, + participantDescriptor, + explorerUrl: `${info.explorerTxUrlPrefix}${txid.toString()}`, + }; +} diff --git a/web/src/lib/lwk/scan.ts b/web/src/lib/lwk/scan.ts new file mode 100644 index 0000000..5e59316 --- /dev/null +++ b/web/src/lib/lwk/scan.ts @@ -0,0 +1,205 @@ +import { + createSessionFromParticipantAnnouncements, + decodeParticipantAnnouncementTransaction, + decodeVoteTransactionAuto, +} from "../contracts"; +import type { + AnnouncementScanState, + ClaimedParticipant, + LiquidTestnetInfo, + MultisigDescriptor, + MultisigSession, + ParticipantAnnouncement, + ScanState, + ScanVote, + WireUtxo, +} from "../../types"; +import { loadLwk, walletScanIndex } from "./core"; +import { + esploraJson, + esploraTxHex, + scanDescriptor, + scanMultisigAddress, + waterfallsClient, +} from "./network"; + +export async function scanSession( + session: MultisigSession, + info: LiquidTestnetInfo, + claimed?: ClaimedParticipant, +): Promise { + const lwk = await loadLwk(); + const network = lwk.Network.testnet(); + const multisig = await scanMultisigAddress(session, info); + const votes: ScanVote[] = []; + const participantScans = await Promise.all( + session.participants.map(async (participant) => { + const client = await waterfallsClient(lwk, network, info); + return scanDescriptor( + lwk, + network, + client, + participant.voteDescriptor, + info, + walletScanIndex, + ) + .then((scan) => ({ participant, scan })) + .catch(() => ({ participant, scan: undefined })); + }), + ); + + for (const { scan } of participantScans) { + if (!scan) { + continue; + } + + for (const tx of scan.wallet.transactions()) { + try { + const decoded = await decodeVoteTransactionAuto(session, tx.tx().toString()); + const voteUtxo = decoded.voteUtxo; + const proposalInputsLive = decoded.proposalInputOutpoints.every((outpoint) => + multisig.utxos.some((utxo) => utxo.txid === outpoint.txid && utxo.vout === outpoint.vout), + ); + if (!voteUtxo || !proposalInputsLive) { + continue; + } + const outspend = await esploraJson<{ spent: boolean }>( + info, + `/tx/${voteUtxo.txid}/outspend/${voteUtxo.vout}`, + ); + if (outspend.spent) { + continue; + } + + votes.push({ + participantIndex: decoded.participantIndex, + txid: tx.txid().toString(), + messageHash: decoded.messageHash, + signatureHex: decoded.participantSignatureHex, + proposedPsetBase64: decoded.proposedPsetBase64, + proposedTxHex: decoded.proposedTxHex, + totalProposedOutputs: decoded.totalProposedOutputs, + proposalInputOutpoints: decoded.proposalInputOutpoints, + voteAddress: decoded.voteAddress, + voteUtxo, + explorerUrl: `${info.explorerTxUrlPrefix}${tx.txid().toString()}`, + }); + } catch { + // Most wallet transactions are not vote carrier transactions. + } + } + } + + let ownerUtxos: WireUtxo[] = []; + if (claimed) { + ownerUtxos = + ( + await scanDescriptor( + lwk, + network, + await waterfallsClient(lwk, network, info), + claimed.voteDescriptor, + info, + walletScanIndex, + ).catch(() => undefined) + )?.utxos ?? []; + } + + const transactionsByTxid = new Map( + multisig.transactions.map((transaction) => [ + transaction.txid, + { + ...transaction, + sources: [...transaction.sources], + }, + ]), + ); + for (const { participant, scan } of participantScans) { + if (!scan) { + continue; + } + for (const transaction of scan.transactions) { + const current = transactionsByTxid.get(transaction.txid); + if (!current) { + transactionsByTxid.set(transaction.txid, { + ...transaction, + sources: [`Participant ${participant.index + 1}`], + }); + continue; + } + + current.sources = [...new Set([...current.sources, `Participant ${participant.index + 1}`])]; + current.timestamp ??= transaction.timestamp; + current.height ??= transaction.height; + } + } + + return { + status: "ready", + message: "Scan complete", + utxos: multisig.utxos, + transactions: [...transactionsByTxid.values()].sort((left, right) => { + const leftTime = left.timestamp ?? left.height ?? 0; + const rightTime = right.timestamp ?? right.height ?? 0; + return rightTime - leftTime; + }), + votes, + ownerUtxos, + }; +} + +export async function discoverParticipantAnnouncements( + multisigDescriptor: MultisigDescriptor, + info: LiquidTestnetInfo, +): Promise<{ + scan: AnnouncementScanState; + session?: MultisigSession; +}> { + const multisig = await scanMultisigAddress(multisigDescriptor, info); + const announcementsByParticipant = new Map(); + + await Promise.all( + multisig.transactions.map(async (transaction) => { + try { + const txHex = await esploraTxHex(info, transaction.txid); + const announcement = await decodeParticipantAnnouncementTransaction( + multisigDescriptor, + txHex, + ); + const current = announcementsByParticipant.get(announcement.participantIndex); + if (current && current.participantDescriptor !== announcement.participantDescriptor) { + return; + } + announcementsByParticipant.set(announcement.participantIndex, { + ...announcement, + txid: transaction.txid, + explorerUrl: transaction.explorerUrl, + }); + } catch { + // Most multisig funding transactions are not descriptor announcements. + } + }), + ); + + const announcements = [...announcementsByParticipant.values()].sort( + (left, right) => left.participantIndex - right.participantIndex, + ); + const scan: AnnouncementScanState = { + status: "ready", + message: + announcements.length === multisigDescriptor.participants.length + ? "Participant announcements complete" + : "Waiting for participant announcements", + announcements, + transactions: multisig.transactions, + }; + + if (announcements.length !== multisigDescriptor.participants.length) { + return { scan }; + } + + return { + scan, + session: await createSessionFromParticipantAnnouncements(multisigDescriptor, announcements), + }; +} diff --git a/web/src/lib/lwk/spend.ts b/web/src/lib/lwk/spend.ts new file mode 100644 index 0000000..7fa3ffe --- /dev/null +++ b/web/src/lib/lwk/spend.ts @@ -0,0 +1,128 @@ +import type * as Lwk from "lwk_wasm"; +import { + finalizePreparedSpendPlan, + finalizeSpendPlan, + prepareExecutorFundedSpend, +} from "../contracts"; +import type { + ExecutorInputSecret, + FinalizedSpendResult, + LiquidTestnetInfo, + MultisigSession, + ProposalResult, + ScanVote, + VoteInput, +} from "../../types"; +import { loadLwk, walletScanIndex } from "./core"; +import { esploraClient, scanDescriptor, waterfallsClient } from "./network"; + +type ExecutorFeeFunding = { + mnemonic: string; + feeRate: number; +}; + +export async function finalizeAndBroadcastSpend( + info: LiquidTestnetInfo, + session: MultisigSession, + proposal: ProposalResult, + votes: ScanVote[], + executorFunding?: ExecutorFeeFunding, +): Promise { + const voteInputs = votes.flatMap((vote): VoteInput[] => + vote.voteUtxo + ? [ + { + participantIndex: vote.participantIndex, + signatureHex: vote.signatureHex, + utxo: vote.voteUtxo, + }, + ] + : [], + ); + const lwk = await loadLwk(); + const network = lwk.Network.testnet(); + const client = esploraClient(lwk, network, info); + const spendPlan = { + session, + proposedPsetBase64: proposal.psetBase64, + multisigUtxos: proposal.inputUtxos, + voteInputs, + totalProposedOutputs: proposal.totalProposedOutputs, + }; + let finalized: Omit; + + if (executorFunding?.mnemonic.trim()) { + const signer = new lwk.Signer(new lwk.Mnemonic(executorFunding.mnemonic.trim()), network); + const executorScan = await scanDescriptor( + lwk, + network, + await waterfallsClient(lwk, network, info), + signer.wpkhSlip77Descriptor().toString(), + info, + walletScanIndex, + ); + const lbtcUtxos = executorScan.wallet + .utxos() + .filter((utxo) => utxo.unblinded().asset().toString() === info.policyAsset) + .sort((left, right) => Number(right.unblinded().value() - left.unblinded().value())); + if (lbtcUtxos.length === 0) { + throw new Error("Executor wallet has no L-BTC UTXOs for finalization fees"); + } + + let executorPset: Lwk.Pset | undefined; + let executorInputSecrets: ExecutorInputSecret[] = []; + let executorFundingError: unknown; + for (let count = 1; count <= lbtcUtxos.length; count += 1) { + const selected = lbtcUtxos.slice(0, count); + try { + executorPset = new lwk.TxBuilder(network) + .feeRate(executorFunding.feeRate) + .setWalletUtxos(selected.map((utxo) => utxo.outpoint())) + .drainLbtcTo(executorScan.wallet.address(0).address()) + .finish(executorScan.wallet); + executorPset.addDetails(executorScan.wallet); + executorInputSecrets = selected.map((utxo) => { + const unblinded = utxo.unblinded(); + return { + asset: unblinded.asset().toString(), + value: Number(unblinded.value()), + assetBlindingFactor: unblinded.assetBlindingFactor().toString(), + valueBlindingFactor: unblinded.valueBlindingFactor().toString(), + }; + }); + break; + } catch (error) { + executorFundingError = error; + } + } + if (!executorPset) { + const message = + executorFundingError instanceof Error + ? executorFundingError.message + : String(executorFundingError); + throw new Error(`Could not build executor fee funding PSET: ${message}`); + } + + const prepared = await prepareExecutorFundedSpend({ + ...spendPlan, + executorPsetBase64: executorPset.toString(), + executorInputSecrets, + }); + const signed = signer.sign(new lwk.Pset(prepared.psetBase64)); + const blinded = executorScan.wallet.finalize(signed); + finalized = await finalizePreparedSpendPlan({ + ...spendPlan, + preparedPsetBase64: blinded.toString(), + }); + } else { + finalized = await finalizeSpendPlan(spendPlan); + } + + const txid = await client.broadcastTx(lwk.Transaction.fromString(finalized.txHex)); + + return { + ...finalized, + txid: txid.toString(), + explorerUrl: `${info.explorerTxUrlPrefix}${txid.toString()}`, + }; +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..dc783b4 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,10 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App"; +import "./styles.css"; + +createRoot(document.getElementById("root") as HTMLElement).render( + + + , +); diff --git a/web/src/styles.css b/web/src/styles.css new file mode 100644 index 0000000..162627f --- /dev/null +++ b/web/src/styles.css @@ -0,0 +1,5 @@ +@import "./styles/base.css"; +@import "./styles/shell.css"; +@import "./styles/workspace.css"; +@import "./styles/workflows.css"; +@import "./styles/responsive.css"; diff --git a/web/src/styles/base.css b/web/src/styles/base.css new file mode 100644 index 0000000..8a36b8e --- /dev/null +++ b/web/src/styles/base.css @@ -0,0 +1,118 @@ +:root { + color: #18201e; + background: #f6f8f7; + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", + sans-serif; + font-synthesis: none; + text-rendering: optimizeLegibility; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 320px; + min-height: 100vh; +} + +button, +input, +textarea, +select { + font: inherit; +} + +button { + min-height: 36px; + border: 1px solid #d7dfdc; + border-radius: 6px; + background: #ffffff; + color: #1f2a27; + padding: 0 12px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + cursor: pointer; + font-size: 13px; + font-weight: 640; +} + +button:hover:not(:disabled) { + border-color: #97aaa3; +} + +button:disabled { + color: #9aa7a2; + cursor: not-allowed; +} + +button.primary { + background: #145c51; + border-color: #145c51; + color: #ffffff; +} + +button.primary:disabled { + background: #eef2f0; + border-color: #d7dfdc; + color: #9aa7a2; +} + +button.full { + width: 100%; +} + +.button-link { + min-height: 36px; + border: 1px solid #d7dfdc; + border-radius: 6px; + background: #ffffff; + color: #1f2a27; + padding: 0 12px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + font-size: 13px; + font-weight: 640; + text-decoration: none; +} + +input, +textarea, +select { + width: 100%; + border: 1px solid #d7dfdc; + border-radius: 6px; + background: #ffffff; + color: #1f2a27; + padding: 9px 10px; + outline: none; + font-size: 13px; +} + +textarea { + resize: vertical; + line-height: 1.45; + font-family: + "SFMono-Regular", Consolas, "Liberation Mono", Menlo, ui-monospace, monospace; +} + +label { + display: grid; + gap: 6px; + color: #66736f; + font-size: 12px; + font-weight: 700; +} + +a { + color: #126d61; + display: inline-flex; + align-items: center; +} + diff --git a/web/src/styles/responsive.css b/web/src/styles/responsive.css new file mode 100644 index 0000000..dcf3e27 --- /dev/null +++ b/web/src/styles/responsive.css @@ -0,0 +1,72 @@ +@media (max-width: 1080px) { + .summary-grid, + .content-grid, + .setup-grid, + .network-grid, + .proposal-cards { + grid-template-columns: 1fr 1fr; + } + + .demo-row { + grid-template-columns: 1fr; + } +} + +@media (max-width: 760px) { + .workspace, + .side-rail { + padding: 16px; + } + + .topbar, + .topbar-actions, + .empty-state { + align-items: stretch; + flex-direction: column; + } + + .tabs, + .segmented, + .transaction-search { + width: 100%; + } + + .tabs, + .segmented { + flex-wrap: wrap; + } + + .tabs button, + .segmented button { + flex: 1 1 calc(50% - 4px); + min-width: 0; + } + + .summary-grid, + .content-grid, + .setup-grid, + .network-grid, + .proposal-cards, + .proposal-meta { + grid-template-columns: 1fr; + } + + .funding-grid { + grid-template-columns: 1fr; + } + + .output-row, + .proposal-head, + .execute-head, + .executor-controls, + .vote-row, + .proposal-voter, + .announcement-row { + grid-template-columns: 1fr; + } + + .transaction-toolbar { + align-items: stretch; + flex-direction: column; + } +} diff --git a/web/src/styles/shell.css b/web/src/styles/shell.css new file mode 100644 index 0000000..9081525 --- /dev/null +++ b/web/src/styles/shell.css @@ -0,0 +1,326 @@ +.app-shell { + min-height: 100vh; + display: block; +} + +.side-rail { + background: #ffffff; + border-right: 1px solid #dfe6e3; + padding: 24px; + display: flex; + flex-direction: column; + gap: 24px; +} + +.brand { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +.brand-mark { + width: 40px; + height: 40px; + border-radius: 8px; + background: #e6f3ef; + color: #145c51; + display: grid; + place-items: center; +} + +.brand > div:last-child { + min-width: 0; +} + +.brand h1, +.brand p, +.topbar h2, +.topbar p, +.panel-title h3 { + margin: 0; +} + +.brand h1 { + font-size: 17px; + line-height: 1.2; +} + +.brand p { + color: #66736f; + font-size: 13px; + margin-top: 2px; +} + +.rail-section { + display: grid; + gap: 12px; +} + +.section-title, +.panel-title { + display: flex; + align-items: center; + gap: 8px; + color: #31413c; + font-size: 13px; + font-weight: 780; +} + +.participant-input { + display: grid; + gap: 8px; + padding-top: 2px; +} + +.button-row { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} + +.button-row.end { + justify-content: flex-end; +} + +.workspace { + max-width: 1560px; + margin: 0 auto; + padding: 24px; + display: flex; + flex-direction: column; + gap: 16px; + min-width: 0; +} + +.topbar { + min-height: 64px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.topbar h2 { + color: #18201e; + font-size: 20px; + line-height: 1.15; + margin-top: 3px; + overflow-wrap: anywhere; +} + +.muted-label { + color: #74817d; + font-size: 12px; + font-weight: 740; +} + +.topbar-actions { + display: flex; + align-items: center; + gap: 10px; +} + +.tabs, +.segmented { + display: inline-flex; + align-items: center; + gap: 4px; + border: 1px solid #d7dfdc; + border-radius: 8px; + background: #eef3f1; + padding: 4px; + width: fit-content; +} + +.tabs button, +.segmented button { + border: 0; + background: transparent; + min-height: 32px; + color: #566560; +} + +.tabs button.active, +.segmented button.active { + background: #ffffff; + color: #145c51; + box-shadow: 0 1px 2px rgba(21, 36, 32, 0.08); +} + +.status { + min-height: 36px; + border: 1px solid #d7dfdc; + border-radius: 6px; + background: #ffffff; + display: inline-flex; + align-items: center; + gap: 7px; + padding: 0 10px; + font-size: 13px; + font-weight: 700; + color: #566560; +} + +.status.ready { + color: #145c51; + border-color: #bdd8d1; +} + +.status.error { + color: #9a5a00; + border-color: #e1c98f; +} + +.icon-button svg, +.status svg { + width: 15px; + height: 15px; +} + +.spin { + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.notice { + display: flex; + align-items: flex-start; + gap: 10px; + border-radius: 6px; + padding: 12px 14px; + font-size: 13px; + line-height: 1.45; +} + +.notice.warning { + background: #fff7e3; + color: #6d4b12; + border: 1px solid #ead299; +} + +.notice.compact { + padding: 9px 12px; +} + +.notice.error { + background: #fff0ed; + color: #8d281b; + border: 1px solid #efb7ad; +} + +.toast-stack { + position: fixed; + bottom: 18px; + right: 18px; + z-index: 30; + width: min(560px, calc(100vw - 36px)); + display: grid; + gap: 8px; +} + +.toast { + border: 1px solid #d7dfdc; + border-radius: 8px; + background: #ffffff; + box-shadow: 0 10px 30px rgba(20, 34, 30, 0.14); + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 10px; + align-items: start; + padding: 12px; + font-size: 13px; +} + +.toast.error { + border-color: #efb7ad; + background: #fff8f6; + color: #8d281b; +} + +.toast.success { + border-color: #bdd8d1; + background: #f3fbf8; + color: #145c51; +} + +.toast > div { + display: grid; + gap: 4px; + min-width: 0; +} + +.toast span { + color: #42514c; + line-height: 1.45; + overflow-wrap: anywhere; +} + +.toast button { + min-height: 30px; + padding: 0 10px; +} + +.empty-state { + min-height: 64px; + border: 1px solid #bdd8d1; + border-radius: 8px; + background: #eef8f5; + color: #145c51; + padding: 14px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.empty-state > div { + display: grid; + gap: 4px; +} + +.empty-state span { + color: #566560; + font-size: 13px; + font-weight: 650; +} + +.summary-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +.summary-grid.compact .metric { + padding: 12px 14px; +} + +.metric, +.panel { + background: #ffffff; + border: 1px solid #dfe6e3; + border-radius: 8px; +} + +.metric { + padding: 14px; + display: grid; + gap: 6px; +} + +.metric span { + color: #74817d; + font-size: 12px; + font-weight: 720; +} + +.metric strong { + font-size: 18px; +} + diff --git a/web/src/styles/workflows.css b/web/src/styles/workflows.css new file mode 100644 index 0000000..4cc67f9 --- /dev/null +++ b/web/src/styles/workflows.css @@ -0,0 +1,260 @@ +.output-row { + display: grid; + grid-template-columns: 110px minmax(180px, 1fr) minmax(180px, 1fr) 110px 38px; + gap: 8px; + align-items: center; +} + +.icon-only { + width: 38px; + padding: 0; +} + +.stake-input { + width: 104px; +} + +.vote-detail { + padding: 12px; +} + +.manual-decode { + display: grid; + gap: 8px; +} + +.vote-list { + display: grid; + gap: 8px; +} + +.vote-row { + min-height: 40px; + padding: 9px 10px; + display: grid; + grid-template-columns: minmax(100px, 1fr) minmax(120px, 1fr) minmax(110px, 1fr) auto; + gap: 10px; + align-items: center; + font-size: 13px; +} + +.vote-row small { + color: #74817d; + overflow-wrap: anywhere; +} + +.row-actions { + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 8px; +} + +.row-actions button { + min-height: 30px; + padding: 0 10px; +} + +.execute-head { + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + gap: 12px; +} + +.execute-head div { + display: grid; + gap: 4px; +} + +.execute-funding { + border-top: 1px solid #eef2f0; + display: grid; + gap: 12px; + padding-top: 12px; +} + +.execute-funding.enabled { + border-color: #d7dfdc; +} + +.execute-funding-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.toggle-row { + grid-template-columns: auto 1fr; + align-items: center; + color: #1f2a27; + font-size: 13px; +} + +.toggle-row input { + width: auto; +} + +.executor-controls { + display: grid; + grid-template-columns: minmax(180px, 1fr) 120px; + gap: 12px; +} + +.fee-rate-status { + color: #74817d; + font-size: 11px; + font-weight: 720; + white-space: nowrap; +} + +.fee-rate-status.fetched { + color: #126d61; +} + +.fee-rate-status.fallback { + color: #8a6420; +} + +.announcement-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.announcement-head > div { + display: grid; + gap: 4px; + min-width: 0; +} + +.announcement-head strong { + overflow-wrap: anywhere; +} + +.announcement-list { + display: grid; + gap: 8px; +} + +.announcement-row { + min-height: 44px; + border: 1px solid #dfe6e3; + border-radius: 6px; + background: #fbfcfb; + padding: 9px 10px; + display: grid; + grid-template-columns: minmax(160px, 0.7fr) minmax(240px, 1fr) auto; + gap: 10px; + align-items: center; +} + +.announcement-row > div { + display: grid; + gap: 3px; + min-width: 0; +} + +.announcement-row span { + color: #74817d; + font-size: 12px; + font-weight: 720; +} + +.announcement-row code { + min-width: 0; + overflow-wrap: anywhere; + color: #31413c; + font-size: 12px; + font-family: + "SFMono-Regular", Consolas, "Liberation Mono", Menlo, ui-monospace, monospace; +} + +.demo-list { + display: grid; +} + +.demo-row { + display: grid; + grid-template-columns: minmax(180px, 0.4fr) minmax(280px, 1fr) auto; + align-items: center; + gap: 12px; + border-top: 1px solid #eef2f0; + padding: 12px 0; +} + +.demo-row:first-child { + border-top: 0; + padding-top: 0; +} + +.demo-row:last-child { + padding-bottom: 0; +} + +.demo-row > div:first-child { + display: grid; + gap: 4px; +} + +.demo-row span { + color: #74817d; + font-size: 12px; + font-weight: 720; +} + +.demo-row code { + color: #31413c; + font-size: 12px; + line-height: 1.45; + overflow-wrap: anywhere; + font-family: + "SFMono-Regular", Consolas, "Liberation Mono", Menlo, ui-monospace, monospace; +} + +.transaction-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.transaction-search { + max-width: 340px; +} + +.transaction-table { + max-height: calc(100vh - 340px); + min-height: 280px; +} + +.code-block { + display: grid; + gap: 8px; + padding: 10px; +} + +.code-block > div { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + color: #66736f; + font-size: 12px; + font-weight: 700; +} + +.code-block code { + display: block; + max-height: 120px; + overflow: auto; + color: #31413c; + font-size: 12px; + line-height: 1.45; + overflow-wrap: anywhere; + font-family: + "SFMono-Regular", Consolas, "Liberation Mono", Menlo, ui-monospace, monospace; +} diff --git a/web/src/styles/workspace.css b/web/src/styles/workspace.css new file mode 100644 index 0000000..7e6b8e0 --- /dev/null +++ b/web/src/styles/workspace.css @@ -0,0 +1,319 @@ +.content-grid { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr); + gap: 14px; +} + +.setup-grid { + display: grid; + grid-template-columns: minmax(320px, 0.85fr) minmax(420px, 1.15fr); + gap: 14px; +} + +.transaction-stack { + display: grid; + gap: 14px; +} + +.proposal-stack { + display: grid; + gap: 14px; +} + +.proposal-cards { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.proposal-card { + border: 1px solid #dfe6e3; + border-radius: 8px; + background: #ffffff; + padding: 14px; + display: grid; + gap: 12px; + min-width: 0; +} + +.proposal-card-head, +.proposal-card-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.proposal-card-head > div { + display: grid; + gap: 4px; + min-width: 0; +} + +.proposal-card-head strong { + overflow-wrap: anywhere; +} + +.proposal-state { + min-height: 26px; + border-radius: 999px; + padding: 5px 9px; + font-size: 11px; + font-weight: 760; + white-space: nowrap; +} + +.proposal-state.ready { + background: #e6f3ef; + color: #145c51; +} + +.proposal-state.collecting { + background: #fff7e3; + color: #6d4b12; +} + +.proposal-meta { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} + +.proposal-meta > div { + border-top: 1px solid #eef2f0; + padding-top: 9px; + display: grid; + gap: 3px; +} + +.proposal-meta span, +.proposal-voter span { + color: #74817d; + font-size: 11px; + font-weight: 720; +} + +.proposal-voters { + display: grid; + gap: 6px; +} + +.proposal-voter { + min-height: 34px; + border: 1px solid #eef2f0; + border-radius: 6px; + background: #fbfcfb; + padding: 7px 9px; + display: grid; + grid-template-columns: minmax(120px, 1fr) minmax(120px, 1fr) auto; + gap: 8px; + align-items: center; + font-size: 12px; +} + +.panel { + padding: 16px; + display: grid; + gap: 14px; + align-content: start; + min-width: 0; +} + +.panel.wide { + grid-column: 1 / -1; +} + +.panel-title h3 { + font-size: 14px; +} + +.table-wrap { + overflow: auto; +} + +.fixed-table { + min-height: 180px; +} + +table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +th { + color: #74817d; + font-size: 11px; + text-align: left; + padding: 0 8px 9px; + border-bottom: 1px solid #e5ebe8; +} + +td { + padding: 10px 8px; + border-bottom: 1px solid #eef2f0; + white-space: nowrap; +} + +.empty-row { + color: #8c9994; + text-align: center; + height: 112px; +} + +.mnemonic { + min-height: 96px; +} + +.mnemonic.compact { + min-height: 74px; +} + +.claim-box, +.vote-detail, +.vote-row, +.code-block { + border: 1px solid #dfe6e3; + border-radius: 6px; + background: #fbfcfb; +} + +.claim-box { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 12px; + color: #145c51; +} + +.claim-box div, +.vote-detail { + display: grid; + gap: 3px; +} + +.claim-box span, +.vote-detail small, +.empty-copy { + color: #74817d; + font-size: 12px; +} + +.funding-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.funding-target, +.faucet-result { + border: 1px solid #dfe6e3; + border-radius: 6px; + background: #fbfcfb; +} + +.funding-target { + display: grid; + gap: 12px; + padding: 12px; +} + +.funding-target > div:first-child, +.faucet-result > div { + display: grid; + gap: 4px; + min-width: 0; +} + +.funding-target span, +.faucet-result span, +.faucet-note { + color: #74817d; + font-size: 12px; + font-weight: 700; +} + +.funding-target strong, +.faucet-result strong { + overflow-wrap: anywhere; +} + +.faucet-note { + display: flex; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.faucet-result { + min-height: 48px; + padding: 10px 12px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.proposal-head { + display: grid; + grid-template-columns: 1fr 1fr 1fr 120px auto; + align-items: center; + gap: 12px; +} + +.proposal-result { + display: grid; + gap: 10px; +} + +.inline-stat, +.network-grid > div { + display: grid; + gap: 4px; +} + +.inline-stat { + border-top: 1px solid #eef2f0; + padding-top: 10px; +} + +.inline-stat span, +.network-grid span { + color: #74817d; + font-size: 12px; + font-weight: 720; +} + +.network-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +.network-grid.tight { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.network-grid strong { + overflow-wrap: anywhere; +} + +.proposal-head div { + display: grid; + gap: 4px; +} + +.proposal-head div > span:last-child { + color: #74817d; + font-size: 12px; + font-weight: 700; +} + +.fee-input { + min-height: 36px; +} + +.outputs-list { + display: grid; + gap: 8px; +} diff --git a/web/src/types.ts b/web/src/types.ts new file mode 100644 index 0000000..db95dec --- /dev/null +++ b/web/src/types.ts @@ -0,0 +1,215 @@ +type Participant = { + index: number; + xOnlyPublicKey: string; + voteDescriptor: string; +}; + +type MultisigParticipant = { + index: number; + xOnlyPublicKey: string; +}; + +export type MultisigDescriptor = { + version: number; + network: "liquid-testnet"; + threshold: number; + participants: MultisigParticipant[]; + multisigScriptPubkey: string; + multisigAddress: string; + lwkDescriptor: string; +}; + +export type MultisigSession = { + version: number; + network: "liquid-testnet"; + threshold: number; + participants: Participant[]; + multisigScriptPubkey: string; + multisigAddress: string; + lwkDescriptor: string; +}; + +export type ParticipantKey = { + derivationPath: string; + xOnlyPublicKey: string; +}; + +export type LiquidTestnetInfo = { + network: "liquid-testnet"; + policyAsset: string; + genesisHash: string; + defaultEsploraUrl: string; + defaultWaterfallsUrl: string; + explorerTxUrlPrefix: string; +}; + +export type WireUtxo = { + txid: string; + vout: number; + scriptPubkey: string; + asset: string; + value: number; +}; + +type WireOutpoint = { + txid: string; + vout: number; +}; + +type SpendOutputKind = "transfer" | "burn" | "fee"; + +export type SpendOutput = { + id: string; + kind: SpendOutputKind; + address: string; + scriptPubkey?: string; + asset: string; + value: number; +}; + +export type ProposalResult = { + psetBase64: string; + txHex: string; + totalProposedOutputs: number; + messageHash: string; + inputUtxos: WireUtxo[]; +}; + +export type SignedVoteResult = { + participantIndex: number; + derivationPath: string; + xOnlyPublicKey: string; + messageHash: string; + multisigInputCount: number; + signatureHex: string; + voteScriptPubkey: string; + voteAddress: string; + carrierOutputs: OutputSummary[]; +}; + +export type CarrierAppendResult = { + psetBase64: string; + outputCount: number; +}; + +export type ParticipantAnnouncementAppendResult = { + psetBase64: string; + participantIndex: number; + xOnlyPublicKey: string; +}; + +export type ParticipantAnnouncement = { + participantIndex: number; + xOnlyPublicKey: string; + participantDescriptor: string; + signatureHex: string; + txid?: string; + explorerUrl?: string; +}; + +export type DecodedVoteResult = { + participantIndex: number; + proposedPsetBase64: string; + proposedTxHex: string; + participantSignatureHex: string; + messageHash: string; + multisigInputCount: number; + totalProposedOutputs: number; + proposalInputOutpoints: WireOutpoint[]; + voteScriptPubkey: string; + voteAddress: string; + voteUtxo?: WireUtxo; +}; + +export type FinalizedSpendResult = { + psetBase64: string; + txHex: string; + txid: string; + explorerUrl: string; +}; + +export type PsetResult = { + psetBase64: string; +}; + +export type ExecutorInputSecret = { + asset: string; + value: number; + assetBlindingFactor: string; + valueBlindingFactor: string; +}; + +type OutputSummary = { + scriptPubkey: string; + asset: string; + value: number; +}; + +export type VoteInput = { + participantIndex: number; + signatureHex: string; + utxo: WireUtxo; +}; + +export type ScanTransaction = { + txid: string; + type: string; + height?: number; + timestamp?: number; + sources: string[]; + explorerUrl: string; +}; + +export type ScanVote = { + participantIndex: number; + txid: string; + messageHash: string; + signatureHex: string; + proposedPsetBase64: string; + proposedTxHex: string; + totalProposedOutputs: number; + proposalInputOutpoints: WireOutpoint[]; + voteAddress: string; + voteUtxo?: WireUtxo; + explorerUrl: string; +}; + +export type ScanState = { + status: "idle" | "scanning" | "ready" | "error"; + message: string; + utxos: WireUtxo[]; + transactions: ScanTransaction[]; + votes: ScanVote[]; + ownerUtxos: WireUtxo[]; +}; + +export type AnnouncementScanState = { + status: "idle" | "scanning" | "ready" | "error"; + message: string; + announcements: ParticipantAnnouncement[]; + transactions: ScanTransaction[]; +}; + +export type ClaimedParticipant = { + participantIndex: number; + xOnlyPublicKey: string; + derivationPath: string; + mnemonic: string; + voteDescriptor: string; + fundingAddress: string; +}; + +export type FaucetAsset = "lbtc" | "test"; + +export type FaucetTarget = "multisig" | "participant"; + +export type FaucetResult = { + target: FaucetTarget; + asset: FaucetAsset; + address: string; + message: string; + txid?: string; + explorerUrl?: string; + balance?: number; + balanceTest?: number; +}; diff --git a/web/src/views/builder-inputs.tsx b/web/src/views/builder-inputs.tsx new file mode 100644 index 0000000..96ad2ce --- /dev/null +++ b/web/src/views/builder-inputs.tsx @@ -0,0 +1,91 @@ +import { Check } from "lucide-react"; +import type { ReactNode } from "react"; +import type { AppModel } from "../app-model"; +import { EmptyRow, Panel } from "../components"; +import { middle, sats } from "../lib/format"; +import { utxoKey } from "../app-helpers"; + +type BuilderPanelProps = { + icon: ReactNode; + model: AppModel; +}; + +export function MultisigInputsPanel({ icon, model }: BuilderPanelProps) { + const { scan, selectedInputs, setSelectedInputs } = model; + + return ( + +
+ + + + + + + + + + + {scan.utxos.length === 0 ? ( + + ) : ( + scan.utxos.map((utxo) => ( + + + + + + + )) + )} + +
OutpointAmountAsset
+ + setSelectedInputs((current) => { + const next = new Set(current); + const key = utxoKey(utxo); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }) + } + /> + {middle(`${utxo.txid}:${utxo.vout}`, 10)}{sats(utxo.value)}{middle(utxo.asset)}
+
+
+ ); +} + +export function ClaimParticipantPanel({ icon, model }: BuilderPanelProps) { + const { claim, claimed, claimMnemonic, session, setClaimMnemonic } = model; + + return ( + +