diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..507eec7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,43 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: Swatinem/rust-cache@v2 + - run: cargo test --workspace --locked + + fmt: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: cargo fmt --all --check + + clippy-native: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: Swatinem/rust-cache@v2 + - run: cargo clippy --workspace --all-targets --locked -- -D warnings + + clippy-wasm: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: Swatinem/rust-cache@v2 + - run: rustup target add wasm32-unknown-unknown + - run: > + cargo clippy --target wasm32-unknown-unknown --locked + -p stroemnet-wasm -p stroemnet-protocol -p stroemnet-data + -p stroemnet-p2p -p stroemnet-handler -p stroemnet-node + -p stroemnet-amounts + -- -D warnings diff --git a/.gitignore b/.gitignore index 9384167..ef9a55c 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ stroemnet.toml /target contracts/ethereum/deploy.sh todo.md +/contracts/ethereum/broadcast diff --git a/Cargo.lock b/Cargo.lock index 1b431be..2cd3189 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -49,7 +49,6 @@ dependencies = [ "alloy-eips", "alloy-genesis", "alloy-network", - "alloy-node-bindings", "alloy-provider", "alloy-rpc-client", "alloy-rpc-types", @@ -253,19 +252,6 @@ dependencies = [ "serde_with", ] -[[package]] -name = "alloy-hardforks" -version = "0.2.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165210652f71dfc094b051602bafd691f506c54050a174b1cba18fb5ef706a3" -dependencies = [ - "alloy-chains", - "alloy-eip2124", - "alloy-primitives", - "auto_impl", - "dyn-clone", -] - [[package]] name = "alloy-json-abi" version = "1.6.0" @@ -332,28 +318,6 @@ dependencies = [ "serde", ] -[[package]] -name = "alloy-node-bindings" -version = "2.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3f2f7dac66147d165063c670dabca7b34807428130bef4583a7976523140f8d" -dependencies = [ - "alloy-genesis", - "alloy-hardforks", - "alloy-network", - "alloy-primitives", - "alloy-signer", - "alloy-signer-local", - "k256", - "libc", - "rand 0.8.5", - "serde_json", - "tempfile", - "thiserror 2.0.18", - "tracing", - "url", -] - [[package]] name = "alloy-primitives" version = "1.6.0" @@ -394,10 +358,8 @@ dependencies = [ "alloy-json-rpc", "alloy-network", "alloy-network-primitives", - "alloy-node-bindings", "alloy-primitives", "alloy-rpc-client", - "alloy-rpc-types-anvil", "alloy-rpc-types-eth", "alloy-signer", "alloy-sol-types", @@ -480,18 +442,6 @@ dependencies = [ "serde", ] -[[package]] -name = "alloy-rpc-types-anvil" -version = "2.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ff111a54268dc0bbd3b17f98571a7e27cc661dc081ad2999d91888647eb2e11" -dependencies = [ - "alloy-primitives", - "alloy-rpc-types-eth", - "alloy-serde", - "serde", -] - [[package]] name = "alloy-rpc-types-any" version = "2.0.5" @@ -1414,17 +1364,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" -[[package]] -name = "chacha20" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "rand_core 0.10.1", -] - [[package]] name = "chrono" version = "0.4.43" @@ -1480,16 +1419,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "console_error_panic_hook" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" -dependencies = [ - "cfg-if", - "wasm-bindgen", -] - [[package]] name = "const-hex" version = "1.17.0" @@ -2383,7 +2312,6 @@ dependencies = [ "js-sys", "libc", "r-efi 6.0.0", - "rand_core 0.10.1", "wasip2", "wasip3", "wasm-bindgen", @@ -4385,17 +4313,6 @@ dependencies = [ "serde", ] -[[package]] -name = "rand" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" -dependencies = [ - "chacha20", - "getrandom 0.4.2", - "rand_core 0.10.1", -] - [[package]] name = "rand_chacha" version = "0.3.1" @@ -4435,12 +4352,6 @@ dependencies = [ "serde", ] -[[package]] -name = "rand_core" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" - [[package]] name = "rand_xorshift" version = "0.4.0" @@ -5386,7 +5297,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "stroemnet-amounts" -version = "0.1.0" +version = "1.0.0" dependencies = [ "ahash", "alloy-primitives", @@ -5399,14 +5310,14 @@ dependencies = [ [[package]] name = "stroemnet-data" -version = "0.1.0" +version = "1.0.0" dependencies = [ "ahash", "alloy", + "borsh", "futures", "getrandom 0.2.16", "getrandom 0.3.4", - "gloo-timers", "hex", "indexmap 2.13.0", "itertools 0.14.0", @@ -5418,6 +5329,7 @@ dependencies = [ "kaspa-txscript", "kaspa-txscript-errors", "kaspa-wrpc-client", + "parking_lot", "rand 0.9.2", "ring", "secp256k1 0.31.1", @@ -5428,17 +5340,15 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tracing", - "wasm-bindgen-futures", ] [[package]] name = "stroemnet-handler" -version = "0.1.0" +version = "1.0.0" dependencies = [ "ahash", "alloy", "getrandom 0.2.16", - "hex", "sha2 0.11.0", "stroemnet-amounts", "stroemnet-protocol", @@ -5449,16 +5359,14 @@ dependencies = [ [[package]] name = "stroemnet-node" -version = "0.1.0" +version = "1.0.0" dependencies = [ "ahash", - "alloy", "futures", "getrandom 0.2.16", "getrandom 0.4.2", - "gloo-timers", "hex", - "rand 0.10.1", + "parking_lot", "reqwest 0.13.4", "rustls", "serde", @@ -5471,7 +5379,6 @@ dependencies = [ "stroemnet-protocol", "stroemnet-storage", "stroemnet-test-harness", - "tempfile", "thiserror 2.0.18", "tokio", "toml 1.1.2+spec-1.1.0", @@ -5485,13 +5392,12 @@ dependencies = [ [[package]] name = "stroemnet-p2p" -version = "0.1.0" +version = "1.0.0" dependencies = [ "borsh", "futures", "getrandom 0.3.4", "gloo-net", - "gloo-timers", "hex", "sha2 0.11.0", "stroemnet-protocol", @@ -5500,12 +5406,11 @@ dependencies = [ "tokio-tungstenite 0.29.0", "tracing", "url", - "wasm-bindgen-futures", ] [[package]] name = "stroemnet-protocol" -version = "0.1.0" +version = "1.0.0" dependencies = [ "ahash", "borsh", @@ -5522,8 +5427,9 @@ dependencies = [ [[package]] name = "stroemnet-storage" -version = "0.1.0" +version = "1.0.0" dependencies = [ + "hex", "redb", "stroemnet-data", "stroemnet-protocol", @@ -5536,7 +5442,7 @@ dependencies = [ [[package]] name = "stroemnet-test-harness" -version = "0.1.0" +version = "1.0.0" dependencies = [ "ahash", "stroemnet-amounts", @@ -5548,14 +5454,14 @@ dependencies = [ [[package]] name = "stroemnet-wasm" -version = "0.1.0" +version = "1.0.0" dependencies = [ "ahash", "alloy-primitives", - "console_error_panic_hook", "getrandom 0.3.4", "js-sys", "kaspa-addresses", + "parking_lot", "serde", "serde-wasm-bindgen", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 8bdf792..c795c4b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,14 +9,24 @@ members = [ "crates/storage", "crates/test-harness", "crates/wasm", - "crates/data" + "crates/data", ] [workspace.package] -version = "0.1.0" +version = "1.0.0" edition = "2024" license = "MIT" +[workspace.lints.rust] +dead_code = "deny" +unused_must_use = "deny" + +[workspace.lints.clippy] +unwrap_used = "deny" +expect_used = "deny" +panic = "deny" +indexing_slicing = "deny" + # Centralized `getrandom` specs. Three majors coexist in our dep tree: # - 0.2 is pulled by older rand_core consumers (rand 0.8, kaspa stack) # - 0.3 is pulled by newer rand_core consumers (rand 0.9, alloy stack) @@ -38,9 +48,7 @@ ring = { version = "0.17", features = ["wasm32_unknown_unknown_js"] } ahash = "0.8.12" alloy = { version = "2.0.5", features = ["essentials"] } alloy-primitives = "1.6.0" -async-channel = "2.5.0" borsh = { version = "1.6.1", features = ["derive"] } -console_error_panic_hook = "0.1" futures = "0.3.32" gloo-net = { version = "0.5", features = ["websocket"] } gloo-timers = { version = "0.3", features = ["futures"] } diff --git a/README.md b/README.md index 46d6d81..66c3943 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,15 @@ # stroemnet +> ! **UNAUDITED: COMPLETELY EXPERIMENTAL. DO NOT USE IN PRODUCTION.** ! +> This code has not been security-audited and is under active development. It may +> contain bugs that lead to loss of funds. Use at your own risk on testnets only. + A trustless cross-chain atomic swap protocol. +![stroemnet architecture](docs/architecture.svg) + +Diagram generated by [`scripts/architecture_diagram.py`](scripts/architecture_diagram.py) — run `python3 scripts/architecture_diagram.py` to regenerate. + ## Roles Stroemnet has two kinds of participant, split by where they run: @@ -53,7 +61,7 @@ lp = true [channels.ethereum-sepolia] private_key = "0x" rpc_url = "https://eth-sepolia.api.onfinality.io/public" -htlc_address = "0x3AB5f1089f521D982ad67193E8523eB2fD34Da53" +htlc_address = "0xb249849CAc76bcd86A566016AcA5e7FC63dA5122" [channels.kaspa-tn10] private_key = "" @@ -85,7 +93,7 @@ lp = false participate_ccr = true private_key = "0x" # needed to broadcast settlement txs rpc_url = "https://eth-sepolia.api.onfinality.io/public" -htlc_address = "0x3AB5f1089f521D982ad67193E8523eB2fD34Da53" +htlc_address = "0xb249849CAc76bcd86A566016AcA5e7FC63dA5122" [channels.kaspa-tn10] participate_ccr = false @@ -127,7 +135,7 @@ min_confirmations = 30 # optional — finality threshold private_key = "0x" # required when lp = true; optional for observers participate_ccr = true # optional, default false rpc_url = "https://eth-sepolia.api.onfinality.io/public" # required for EVM channels -htlc_address = "0x3AB5f1089f521D982ad67193E8523eB2fD34Da53" # required for EVM channels +htlc_address = "0xb249849CAc76bcd86A566016AcA5e7FC63dA5122" # required for EVM channels min_confirmations = 1 # optional — finality threshold [channels.igra-galleon] diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..5335d88 --- /dev/null +++ b/clippy.toml @@ -0,0 +1,3 @@ +too-many-lines-threshold = 100 +avoid-breaking-exported-api = false +too-many-arguments-threshold = 8 diff --git a/crates/amounts/Cargo.toml b/crates/amounts/Cargo.toml index af6ec09..a39cdf2 100644 --- a/crates/amounts/Cargo.toml +++ b/crates/amounts/Cargo.toml @@ -14,3 +14,6 @@ tracing = { workspace = true } [target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies] getrandom-03 = { workspace = true } + +[lints] +workspace = true diff --git a/crates/amounts/src/amount_out.rs b/crates/amounts/src/amount_out.rs index 87a59c3..622e680 100644 --- a/crates/amounts/src/amount_out.rs +++ b/crates/amounts/src/amount_out.rs @@ -3,19 +3,16 @@ use alloy_primitives::U256; use crate::error::AmountError; use crate::result::Result; -/// Utility struct for amount calculations and conversions. pub struct Amounts; impl Amounts { - /// Stroemnets internal decimals that is used for all amounts and representations. + /// The number of decimal places to use for price calculations. + /// This is a canonical decimal precision for any kind of reasoning with amounts. pub const PRICE_DECIMALS: u8 = 8; - /// Computes amount out given a number of parameters. - /// This is used across all chains to compute the expected output amount for a swap, - /// given the input amount, source and destination prices and decimals, and the spread percentage. - /// - /// This was grouped to ensure that all chains and all callers compute the exact same amount in order - /// to avoid discrepancies that could lead to failed swaps or user confusion. + /// A canonical function for computing the output amount for a swap + /// given input amount, source and destination prices, and a spread percentage. + /// The spread percentage is a value between 0 and 100, representing the fee taken from the output amount. pub fn amount_out( amount_in: U256, source_usd_price: f64, @@ -28,149 +25,93 @@ impl Amounts { "Calculating amount_out with amount_in: {amount_in}, source_usd_price: {source_usd_price}, source_decimals: {source_decimals}, destination_usd_price: {destination_usd_price}, destination_decimals: {destination_decimals}, spread_percent: {spread_percent}" ); - // If the destination price is 0 there is no need to compute anything - if destination_usd_price <= 0.0 { - return Err(AmountError::InvalidPriceData(destination_usd_price)); + // Validate that the source price is finite and non-negative, + if !source_usd_price.is_finite() || source_usd_price < 0.0 { + return Err(AmountError::InvalidPriceData(source_usd_price)); } - // If the source price is negative, it's also invalid data - if source_usd_price < 0.0 { - return Err(AmountError::InvalidPriceData(source_usd_price)); + // Validate that the destination price is finite and positive, + if !destination_usd_price.is_finite() || destination_usd_price <= 0.0 { + return Err(AmountError::InvalidPriceData(destination_usd_price)); } - // We only validate valid spread percentages. + // Ensure that spread is within 0, 100% this is the spread set by the LP node if !(0.0..100.0).contains(&spread_percent) { return Err(AmountError::InvalidPriceData(spread_percent)); } - // Compute the price-scaled amount in source decimals, + // Compute the price scale let price_scale = 10f64.powi(Self::PRICE_DECIMALS as i32); - // Convert both prices to the fixedpoint representation with - // our internal number of decimals. + // We increase the source and destination price by the price scale in accordance + // with the price decimals. Essentially 1e8 + // We need ot scale these prices in order to turn them into U256 which allows for easy multiplication + // without too much loss at the precision level. let source_price_fixed = U256::from((source_usd_price * price_scale).round() as u128); let dest_price_fixed = U256::from((destination_usd_price * price_scale).round() as u128); - // It is in source decimals because we inherit from amount_in which is in source decimals + // The output calculation works in the way that we multiply the source input (amount in) + // by the source price in order to compute the usd value of the input. Then we simply divide + // by the destination price in order to get the output amount. let output_in_source_decimals = amount_in .checked_mul(source_price_fixed) .ok_or(AmountError::ArithmeticOverflow(amount_in))? .checked_div(dest_price_fixed) .ok_or(AmountError::DivisionByZero(dest_price_fixed))?; - // Compute the spread basis points and compute the multiplier that - // we apply to the output amount. We use basis points here - // since we are dealing with non-float u256. + // Compute the bps instead of spread. let spread_bps = (spread_percent * 100.0) as u32; + + // Compute the spread multiplier which will be how much we need to reduce + // the output by in order to account for the spread. let spread_multiplier = U256::from(10_000u32.saturating_sub(spread_bps)); let spread_divisor = U256::from(10_000u32); - // Apply the spread to the output amount. - // Then divide by 100 bps to get the final amount after spread. + // Multiply the output (which is in source decimals) by the spread multiplier + // but then divide it by the full divisor effectively reducing it by spread_bps. let output_after_spread = output_in_source_decimals .checked_mul(spread_multiplier) .ok_or(AmountError::ArithmeticOverflow(output_in_source_decimals))? .checked_div(spread_divisor) .ok_or(AmountError::DivisionByZero(spread_divisor))?; - // Finally, rescale the output amount from source decimals to destination decimals. + // Now the output is fully ready, we just need to rescale it from the source decimals + // to the destination decimals Self::rescale(output_after_spread, source_decimals, destination_decimals) } - /// A universal rescaling function that can be used to convert amounts between different decimal representations. + /// Rescales any value either up or down depending on the size of from and to decimals + /// The final value is in to_decimals. pub fn rescale(amount: U256, from_decimals: u8, to_decimals: u8) -> Result { - // Compute the difference in decimals. + // Compute the diff let diff = to_decimals as i32 - from_decimals as i32; - // If the difference is larger than 0 we need to multiply by 10^(diff) to get to the new decimals. + // If the diff is greater than 0 + // we need to multiply it by the diff to rescale it up to the larger + // decimals if diff > 0 { amount .checked_mul(U256::from(10u64).pow(U256::from(diff as u64))) .ok_or(AmountError::AmountOverflow(amount)) } else if diff < 0 { - // If the difference is smaller than 0 we need to divide by 10^(-diff) to get to the new decimals. + // If its less we do the opposite, we divide by the 10^-diff in order to scale it down. amount .checked_div(U256::from(10u64).pow(U256::from((-diff) as u64))) .ok_or(AmountError::AmountUnderflow(amount)) } else { - // If the difference is 0, we can return the same amount since it's already in the correct decimals. Ok(amount) } } - - /// Rescales the amount and formats it as a string with the correct number of decimals, - /// applying a ceiling-like behavior to ensure that we don't under-represent small amounts when displaying to users. - /// I.e. if we display only 8 decimals but the actual amount has 18 decimals, we want to ensure - /// we over-represent the amount rather than under-representing it, to avoid insufficient deposits or failed swaps - pub fn rescale_display_like_ceil( - amount: U256, - from_decimals: u8, - to_decimals: u8, - ) -> Result { - let rescaled = Self::rescale_ceil(amount, from_decimals, to_decimals)?; - Self::format_fixed_point(rescaled, to_decimals) - } - - /// Similar to rescale but applies a ceiling-like behavior when scaling down, to avoid under-representing small amounts. - fn rescale_ceil(amount: U256, from_decimals: u8, to_decimals: u8) -> Result { - // Compute the difference in decimals. - let diff = to_decimals as i32 - from_decimals as i32; - if diff >= 0 { - // If we are scaling up or keeping the same decimals, - // we can just rescale normally since there is no risk of under-representation. - return Self::rescale(amount, from_decimals, to_decimals); - } - - // If we are scaling down, we need to apply the ceiling behavior. - let divisor = U256::from(10u64).pow(U256::from((-diff) as u64)); - - // Compute the quotient and remainder to determine if we need to apply the ceiling. - let q = amount - .checked_div(divisor) - .ok_or(AmountError::DivisionByZero(divisor))?; - - // If there is a remainder, we need to add 1 to the quotient to apply the ceiling. - let r = amount % divisor; - if r > U256::ZERO { - // Bump up the quotient by 1 to apply the ceiling, but check for overflow first. - q.checked_add(U256::from(1u64)) - .ok_or(AmountError::AmountOverflow(q)) - } else { - // If there is no remainder, we can return the quotient as is. - Ok(q) - } - } - - /// Formats a U256 amount as a fixed-point decimal string with the given number of decimals. - fn format_fixed_point(amount: U256, decimals: u8) -> Result { - if decimals == 0 { - // If there are no decimals, we can just return the amount as a string. - return Ok(amount.to_string()); - } - - // Compute the divisor for the decimals to split the whole and fractional parts. - let divisor = U256::from(10u64).pow(U256::from(decimals as u64)); - - // Compute the whole amounts - let whole = amount / divisor; - - // Compute fracntional amount. - let frac = amount % divisor; - - // Format the fractional part as a string. - let frac_str = frac.to_string(); - - // Left-pad the fractional string with zeros to ensure it has the correct number of decimal places. - // and replace some of it with the frac_str. - let padded = format!("{frac_str:0>width$}", width = decimals as usize); - - // Return the formatted string in the form "whole.fractional". - Ok(format!("{whole}.{padded}")) - } } #[cfg(test)] mod tests { + #![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing + )] use super::*; const KAS_DEC: u8 = 8; @@ -224,38 +165,6 @@ mod tests { assert_eq!(Amounts::rescale(U256::ZERO, 0, 18).unwrap(), U256::ZERO); } - #[test] - fn rescale_display_like_ceil_bumps_subunit_to_one() { - assert_eq!( - Amounts::rescale_display_like_ceil(U256::from(1u64), 18, 8).unwrap(), - "0.00000001", - ); - } - - #[test] - fn rescale_display_like_ceil_exact_value_no_bump() { - assert_eq!( - Amounts::rescale_display_like_ceil(U256::from(10_000_000_000u64), 18, 8).unwrap(), - "0.00000001", - ); - } - #[test] - fn rescale_display_like_ceil_zero_target_decimals() { - assert_eq!( - Amounts::rescale_display_like_ceil(U256::from(1_500_000_000_000_000_000u128), 18, 0) - .unwrap(), - "2", - ); - } - - #[test] - fn rescale_display_like_ceil_zero_input() { - assert_eq!( - Amounts::rescale_display_like_ceil(U256::ZERO, 18, 8).unwrap(), - "0.00000000", - ); - } - #[test] fn kas_to_eth_basic() { let result = Amounts::amount_out(kas(1000), 0.15, KAS_DEC, 3000.0, ETH_DEC, 0.3).unwrap(); diff --git a/crates/amounts/src/lib.rs b/crates/amounts/src/lib.rs index ce97449..42c192c 100644 --- a/crates/amounts/src/lib.rs +++ b/crates/amounts/src/lib.rs @@ -1,12 +1,8 @@ -//! Amounts crate for handling token amounts, conversions, and price storage. - -/// A submodule for computing amounts out given some parameters pub mod amount_out; mod error; mod result; mod storage; -// Re-export the main types and results for external use. pub use amount_out::Amounts; pub use error::AmountError; pub use storage::PriceStorage; diff --git a/crates/amounts/src/storage.rs b/crates/amounts/src/storage.rs index b4360ab..13181a7 100644 --- a/crates/amounts/src/storage.rs +++ b/crates/amounts/src/storage.rs @@ -3,66 +3,113 @@ use std::sync::Arc; use ahash::{AHashMap, AHashSet}; use parking_lot::RwLock; use stroemnet_protocol::ChannelId; +use stroemnet_protocol::now_unix_secs; + +/// Maximum age of the price +const DEFAULT_MAX_AGE_SECS: u64 = 300; + +/// Maximum price deviation from last measurement. +const MAX_JUMP_RATIO: f64 = 0.5; #[derive(Debug, Clone)] -/// Thread-safe storage for channel price information, allowing concurrent reads and writes. -/// Used to store and retrieve the latest price for each channel. -/// -/// This is used by LPs to track the latest price for each channel, -/// which is needed to compute output amount for a swap. +/// A price storage storing USD valued prices for any channels +/// native token pub struct PriceStorage { + /// all the channels that we are calculating prices for channels: Arc>>, - prices: Arc>>, + /// The prices themselves, we want to separate this + /// from the data so that we can zero out the data + /// if the prices are stale + prices: Arc>>, + max_age_secs: u64, } impl PriceStorage { - /// Creates a new PriceStorage with the given channels initialized to a price of 0.0. + /// Creates a new price storage with designated channels pub fn new(channels: Vec) -> Self { - let channel_set = channels.iter().copied().collect::>(); - let prices = channels - .into_iter() - .map(|channel| (channel, 0.0)) - .collect::>(); + Self::with_max_age(channels, DEFAULT_MAX_AGE_SECS) + } + /// Create a new price storage with a specified time for which prices are valid for + pub fn with_max_age(channels: Vec, max_age_secs: u64) -> Self { + let channel_set = channels.into_iter().collect::>(); Self { channels: Arc::new(RwLock::new(channel_set)), - prices: Arc::new(RwLock::new(prices)), + prices: Arc::new(RwLock::new(AHashMap::new())), + max_age_secs, } } - /// Retrieves the price for the given channel, if it exists. + /// Retrieve a price based on a given channel id pub fn get(&self, channel: &ChannelId) -> Option { - self.prices.read().get(channel).cloned() + let (price, ts) = *self.prices.read().get(channel)?; + + // Ensure the price itself is valid and that its not too old + if price.is_finite() + && price > 0.0 + && now_unix_secs().saturating_sub(ts) <= self.max_age_secs + { + Some(price) + } else { + // If its too old we return none + None + } } - /// Returns a list of all channels currently stored. + /// Get all the channels for which we are tracking prices for pub fn channels(&self) -> Vec { self.channels.read().iter().copied().collect() } - /// Sets the price for the given channel. + /// Set the price of a channel pub fn set(&self, channel: ChannelId, price: f64) { + // Ensure the price that we are setting is valid and not lt 0 + if !price.is_finite() || price <= 0.0 { + tracing::warn!("rejecting invalid price {price} for {channel:?}"); + return; + } + + // We need to ensure that prices have not deviated too far from the last + // price. Before we can do that we need to ensure the last price itself + // was valid, and then we ensure that it does not exceed the max jump ratio + // to reduce the risk of oracle failures. + if let Some(last) = self.get(&channel) + && (price - last).abs() / last > MAX_JUMP_RATIO + { + tracing::warn!( + "rejecting price {price} for {channel:?}: exceeds {MAX_JUMP_RATIO} jump from {last}" + ); + return; + } + // Now add the channel to the channels if it is the case that we actually + // did not track this before, this allows us to simply add new channels to the system + // if needed. self.channels.write().insert(channel); - self.prices.write().insert(channel, price); - } - /// Clears all stored prices. - pub fn clear(&self) { - self.prices.write().clear(); + // Finally, update the price for this channel. + self.prices + .write() + .insert(channel, (price, now_unix_secs())); } } #[cfg(test)] mod tests { + #![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing + )] use super::*; use std::thread; #[test] - fn test_new_storage_initializes_with_zero_prices() { + fn test_new_storage_has_no_price_until_set() { let channels = vec![ChannelId::KaspaTn10, ChannelId::EthereumSepolia]; let storage = PriceStorage::new(channels.clone()); for channel in channels { - assert_eq!(storage.get(&channel), Some(0.0)); + assert_eq!(storage.get(&channel), None); } } @@ -75,7 +122,7 @@ mod tests { #[test] fn test_new_storage_with_single_channel() { let storage = PriceStorage::new(vec![ChannelId::KaspaTn10]); - assert_eq!(storage.get(&ChannelId::KaspaTn10), Some(0.0)); + assert_eq!(storage.get(&ChannelId::KaspaTn10), None); let stored = storage.channels(); assert_eq!(stored.len(), 1); assert!(stored.contains(&ChannelId::KaspaTn10)); @@ -98,9 +145,19 @@ mod tests { fn test_get_returns_most_recent_value() { let storage = PriceStorage::new(vec![ChannelId::EthereumSepolia]); storage.set(ChannelId::EthereumSepolia, 1000.0); - storage.set(ChannelId::EthereumSepolia, 2000.0); - storage.set(ChannelId::EthereumSepolia, 3000.0); - assert_eq!(storage.get(&ChannelId::EthereumSepolia), Some(3000.0)); + storage.set(ChannelId::EthereumSepolia, 1100.0); + storage.set(ChannelId::EthereumSepolia, 1200.0); + assert_eq!(storage.get(&ChannelId::EthereumSepolia), Some(1200.0)); + } + + #[test] + fn test_circuit_breaker_rejects_large_jump() { + let storage = PriceStorage::new(vec![ChannelId::KaspaTn10]); + storage.set(ChannelId::KaspaTn10, 0.15); + storage.set(ChannelId::KaspaTn10, 0.30); + assert_eq!(storage.get(&ChannelId::KaspaTn10), Some(0.15)); + storage.set(ChannelId::KaspaTn10, 0.16); + assert_eq!(storage.get(&ChannelId::KaspaTn10), Some(0.16)); } #[test] @@ -118,17 +175,17 @@ mod tests { } #[test] - fn test_set_with_zero_price() { + fn test_set_with_zero_price_is_rejected() { let storage = PriceStorage::new(vec![ChannelId::KaspaTn10]); storage.set(ChannelId::KaspaTn10, 0.0); - assert_eq!(storage.get(&ChannelId::KaspaTn10), Some(0.0)); + assert_eq!(storage.get(&ChannelId::KaspaTn10), None); } #[test] - fn test_set_with_negative_price() { + fn test_set_with_negative_price_is_rejected() { let storage = PriceStorage::new(vec![ChannelId::KaspaTn10]); storage.set(ChannelId::KaspaTn10, -100.0); - assert_eq!(storage.get(&ChannelId::KaspaTn10), Some(-100.0)); + assert_eq!(storage.get(&ChannelId::KaspaTn10), None); } #[test] @@ -259,33 +316,39 @@ mod tests { } #[test] - fn test_special_float_values() { + fn test_special_float_values_are_rejected() { let storage = PriceStorage::new(vec![ChannelId::KaspaTn10, ChannelId::EthereumSepolia]); storage.set(ChannelId::KaspaTn10, f64::INFINITY); - assert_eq!(storage.get(&ChannelId::KaspaTn10), Some(f64::INFINITY)); + assert_eq!(storage.get(&ChannelId::KaspaTn10), None); storage.set(ChannelId::EthereumSepolia, f64::NEG_INFINITY); - assert_eq!( - storage.get(&ChannelId::EthereumSepolia), - Some(f64::NEG_INFINITY) - ); + assert_eq!(storage.get(&ChannelId::EthereumSepolia), None); } #[test] - fn test_nan_handling() { + fn test_nan_is_rejected() { let storage = PriceStorage::new(vec![ChannelId::KaspaTn10]); storage.set(ChannelId::KaspaTn10, f64::NAN); - let p = storage.get(&ChannelId::KaspaTn10); - assert!(p.is_some()); - assert!(p.unwrap().is_nan()); + assert_eq!(storage.get(&ChannelId::KaspaTn10), None); + } + + #[test] + fn test_stale_price_reads_as_missing() { + let storage = PriceStorage::with_max_age(vec![ChannelId::KaspaTn10], 0); + storage.set(ChannelId::KaspaTn10, 0.15); + std::thread::sleep(std::time::Duration::from_millis(1100)); + assert_eq!(storage.get(&ChannelId::KaspaTn10), None); } #[test] - fn test_rapid_sequential_updates() { + fn test_rapid_sequential_updates_within_band() { let storage = PriceStorage::new(vec![ChannelId::KaspaTn10]); - for i in 0..1000 { - storage.set(ChannelId::KaspaTn10, i as f64); + let mut p = 1.0; + storage.set(ChannelId::KaspaTn10, p); + for _ in 0..1000 { + p *= 1.1; + storage.set(ChannelId::KaspaTn10, p); } - assert_eq!(storage.get(&ChannelId::KaspaTn10), Some(999.0)); + assert_eq!(storage.get(&ChannelId::KaspaTn10), Some(p)); } #[test] @@ -313,10 +376,10 @@ mod tests { let _ = storage.get(c); } for c in &channels { - storage.set(*c, 200.0); + storage.set(*c, 120.0); } for c in &channels { - assert_eq!(storage.get(c), Some(200.0)); + assert_eq!(storage.get(c), Some(120.0)); } } @@ -330,24 +393,4 @@ mod tests { assert_eq!(storage.get(&ChannelId::EthereumSepolia), Some(3000.0)); assert_eq!(storage.channels().len(), all.len()); } - - #[test] - fn test_clear_removes_prices_but_keeps_channels() { - let storage = PriceStorage::new(vec![ChannelId::KaspaTn10, ChannelId::EthereumSepolia]); - storage.set(ChannelId::KaspaTn10, 0.15); - storage.set(ChannelId::EthereumSepolia, 3000.0); - storage.clear(); - assert_eq!(storage.get(&ChannelId::KaspaTn10), None); - assert_eq!(storage.get(&ChannelId::EthereumSepolia), None); - assert_eq!(storage.channels().len(), 2); - assert!(storage.channels().contains(&ChannelId::KaspaTn10)); - assert!(storage.channels().contains(&ChannelId::EthereumSepolia)); - } - - #[test] - fn test_clear_empty_storage_is_noop() { - let storage = PriceStorage::new(vec![]); - storage.clear(); - assert!(storage.channels().is_empty()); - } } diff --git a/crates/data/Cargo.toml b/crates/data/Cargo.toml index d29b56e..b1ff2ad 100644 --- a/crates/data/Cargo.toml +++ b/crates/data/Cargo.toml @@ -7,6 +7,8 @@ license.workspace = true [dependencies] stroemnet-protocol = { path = "../protocol" } ahash = { workspace = true, features = ["serde"] } +parking_lot.workspace = true +borsh = { workspace = true } futures = { workspace = true } hex = { workspace = true } indexmap = { workspace = true } @@ -32,8 +34,6 @@ tokio = { workspace = true, features = ["full"] } [target.'cfg(target_arch = "wasm32")'.dependencies] alloy = { workspace = true, features = ["wasm-bindgen"] } tokio = { workspace = true, features = ["sync", "macros", "rt"] } -gloo-timers = { workspace = true } -wasm-bindgen-futures = { workspace = true } getrandom-02 = { workspace = true } getrandom-03 = { workspace = true } ring = { workspace = true } @@ -41,3 +41,6 @@ ring = { workspace = true } [dev-dependencies] secp256k1 = { workspace = true } rand = "0.9.2" + +[lints] +workspace = true diff --git a/crates/data/src/buffer.rs b/crates/data/src/buffer.rs new file mode 100644 index 0000000..3ab14ab --- /dev/null +++ b/crates/data/src/buffer.rs @@ -0,0 +1,123 @@ +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +use stroemnet_protocol::ChannelId; +use stroemnet_protocol::v1::{ChainEvent, CommitmentV1}; + +use crate::{BufFut, DataError, MaybeSend, ProposalVerification, Result, ScriptAnnouncement}; + +#[cfg(not(target_arch = "wasm32"))] +pub(crate) type TaskFut = Pin + Send + 'static>>; +#[cfg(target_arch = "wasm32")] +pub(crate) type TaskFut = Pin + 'static>>; + +pub(crate) trait ChainDataBuffer: MaybeSend { + /// Used to retrieve the lp address for the nodes that are LPs or do CCR + fn lp_address(&self) -> Result; + + /// The settler task that drives settlement of detected swaps/claims/refunds + fn settler_task(self: Arc) -> Option { + None + } + + /// Compute the deposit address based on some commitment + fn derive_deposit(&self, _commitment: &CommitmentV1) -> Result<(String, Vec)> { + Err(DataError::Other( + "channel does not support P2SH deposits".into(), + )) + } + + /// Retrieves the next finalized chunk of data from all registered channels + fn finalized_chunk(&self) -> BufFut<'_, Vec<(ChannelId, ChainEvent)>>; + + /// Compute the timestamp for some chaindatabuffer + fn chain_now(&self) -> Option { + None + } + + /// Broadcasts an event + fn broadcast_event<'a>(&'a self, event: &'a ChainEvent) -> BufFut<'a, ()>; + + /// Signs a message whilst on the requirement that a minimumm balance is maintained + fn sign_message<'a>( + &'a self, + digest: [u8; 32], + required_balance: &'a str, + ) -> BufFut<'a, (String, Vec)>; + + /// Verifies the signature of a message whilst also guaranteeing that the claimed address + /// has the require balance specified + fn verify_message<'a>( + &'a self, + digest: [u8; 32], + claimed_address: &'a str, + signature: &'a [u8], + required_balance: &'a str, + ) -> BufFut<'a, ProposalVerification>; + + /// An element that detects utxo scripts and matches them to swaps based on their p2sh signature + fn utxo_script_detector(&self) -> Option<&dyn UtxoScriptDetector> { + None + } + + /// Extract and remove all the current utxo script announcements + fn take_utxo_script_announcements(&self) -> Vec { + Vec::new() + } +} + +pub(crate) trait UtxoScriptDetector: MaybeSend { + fn register_script<'a>( + &'a self, + address: String, + redeem_script: Vec, + swap_id: [u8; 32], + unlock_ts: u64, + deposit_target: String, + ) -> BufFut<'a, ()>; +} + +#[cfg(test)] +mod tests { + use super::*; + + struct Dummy; + + impl ChainDataBuffer for Dummy { + fn lp_address(&self) -> Result { + Ok("lp".into()) + } + fn finalized_chunk(&self) -> BufFut<'_, Vec<(ChannelId, ChainEvent)>> { + Box::pin(async { Ok(Vec::new()) }) + } + fn broadcast_event<'a>(&'a self, _event: &'a ChainEvent) -> BufFut<'a, ()> { + Box::pin(async { Ok(()) }) + } + fn sign_message<'a>(&'a self, _d: [u8; 32], _r: &'a str) -> BufFut<'a, (String, Vec)> { + Box::pin(async { Ok((String::new(), Vec::new())) }) + } + fn verify_message<'a>( + &'a self, + _d: [u8; 32], + _c: &'a str, + _s: &'a [u8], + _r: &'a str, + ) -> BufFut<'a, ProposalVerification> { + Box::pin(async { + Ok(ProposalVerification { + address_matches: false, + balance_sufficient: false, + }) + }) + } + } + + #[test] + fn trait_defaults_are_inert() { + assert!(Dummy.chain_now().is_none()); + assert!(Dummy.utxo_script_detector().is_none()); + assert!(Dummy.take_utxo_script_announcements().is_empty()); + assert!(Arc::new(Dummy).settler_task().is_none()); + } +} diff --git a/crates/data/src/chains/evm/broadcast.rs b/crates/data/src/chains/evm/broadcast.rs deleted file mode 100644 index 87be5b9..0000000 --- a/crates/data/src/chains/evm/broadcast.rs +++ /dev/null @@ -1,170 +0,0 @@ -use alloy::primitives::{Address, FixedBytes, U256}; -use alloy::providers::Provider; -use stroemnet_protocol::v1::{CommitmentV1, RevealV1}; - -use super::GasPayment; -use super::contracts::StroemHTLCV1; -use crate::{DataError, Result}; - -/// Parses a hex string into an Ethereum address, returning a DataError if parsing fails. -fn parse_address(label: &str, value: &str) -> Result
{ - value - .parse() - .map_err(|e| DataError::Broadcast(format!("{label} address {value}: {e}"))) -} - -/// Parses a decimal string into a U256, returning a DataError if parsing fails. -fn parse_value(value: &str) -> Result { - U256::from_str_radix(value, 10) - .map_err(|e| DataError::Broadcast(format!("amount {value}: {e}"))) -} - -async fn resolve_gas_price( - provider: &P, - gas_payment: GasPayment, -) -> Result> { - match gas_payment { - GasPayment::Eip1559 => Ok(None), - GasPayment::Legacy => provider - .get_gas_price() - .await - .map(|gp| Some(gp.saturating_mul(6) / 5)) - .map_err(|e| DataError::Broadcast(format!("gas price: {e}"))), - } -} - -/// Submits a new commitment to the EVM chain by calling the `newSwap` function on the HTLC contract. -/// Used by lp providers to commit to a swap after a user submits a commitment -pub(super) async fn submit_commitment( - provider: &P, - htlc_address: Address, - commitment: &CommitmentV1, - gas_payment: GasPayment, -) -> Result<()> { - // Parse the addresses - let sender_addr = parse_address("sender", &commitment.addresses.sender)?; - let receiver_addr = parse_address("receiver", &commitment.addresses.receiver)?; - - // Instantiate a new contract instance - let stroem_htlc = StroemHTLCV1::new(htlc_address, provider); - - // Call the newSwap function with the appropriate parameters from the commitment - let mut call = stroem_htlc - .newSwap( - sender_addr, // The sender is on the behalf of - receiver_addr, // Receiver of the swap on the EVM side - commitment // Our destination address on the other chain - .addresses - .sender_destination - .as_bytes() - .to_vec() - .into(), - commitment.secret_hash.into(), // The secret hash of this swap - U256::from(commitment.unlock_ts), // unlock timestamp for this swap i.e. when it can be refunded - commitment.destination, // the destination channel id - commitment.swap_id.into(), // unique swap id for this swap - ) - .value(parse_value(&commitment.amount.value)?); - if let Some(gp) = resolve_gas_price(provider, gas_payment).await? { - call = call.gas_price(gp); - } - - // Send the transaction and wait for it to be mined, - // returning a DataError if any step fails - let pending = call - .send() - .await - .map_err(|e| DataError::Broadcast(format!("newSwap send: {e}")))?; - - // Wait for the transaction to be mined and get the receipt - let receipt = pending - .get_receipt() - .await - .map_err(|e| DataError::Broadcast(format!("newSwap receipt: {e}")))?; - tracing::info!( - "EVM commitment mined in block {:?}, tx {:?}", - receipt.block_number, - receipt.transaction_hash - ); - Ok(()) -} - -/// Submits a claim transaction to the EVM chain by calling the `claim` function on the HTLC contract. -/// Used by lp providers to claim a swap after a user reveals the secret on the other chain -/// Or also as CCR participant to claim a swap after the counterparty reveals the secret via p2p -pub(super) async fn submit_claim( - provider: &P, - htlc_address: Address, - reveal: &RevealV1, - gas_payment: GasPayment, -) -> Result<()> { - // Instantiate a new contract instance - let stroem_htlc = StroemHTLCV1::new(htlc_address, provider); - let mut call = stroem_htlc.claim( - FixedBytes::from(reveal.swap_id), - FixedBytes::from(reveal.secret), - ); - if let Some(gp) = resolve_gas_price(provider, gas_payment).await? { - call = call.gas_price(gp); - } - // Send the transaction and wait for it to be mined, returning a DataError if any step fails - let pending = call - .send() - .await - .map_err(|e| DataError::Broadcast(format!("claim send: {e}")))?; - // Wait for the transaction to be mined and get the receipt - let receipt = pending - .get_receipt() - .await - .map_err(|e| DataError::Broadcast(format!("claim receipt: {e}")))?; - tracing::info!( - "EVM claim mined in block {:?}, tx {:?}", - receipt.block_number, - receipt.transaction_hash - ); - Ok(()) -} - -/// Submits a refund transaction to the EVM chain by calling the `refund` function on the HTLC contract. -/// Used by lp providers to refund a swap after the unlock timestamp has passed without a reveal -pub(super) async fn submit_refund( - provider: &P, - htlc_address: Address, - swap_id: [u8; 32], - gas_payment: GasPayment, -) -> Result<()> { - // Instantiate a new contract instance - let stroem_htlc = StroemHTLCV1::new(htlc_address, provider); - let mut call = stroem_htlc.refund(FixedBytes::from(swap_id)); - if let Some(gp) = resolve_gas_price(provider, gas_payment).await? { - call = call.gas_price(gp); - } - - // Send the transaction and wait for it to be mined, returning a DataError if any step fails - let pending = call - .send() - .await - .map_err(|e| DataError::Broadcast(format!("refund send: {e}")))?; - // Wait for the transaction to be mined and get the receipt - let receipt = pending - .get_receipt() - .await - .map_err(|e| DataError::Broadcast(format!("refund receipt: {e}")))?; - tracing::info!( - "EVM refund mined in block {:?}, tx {:?}", - receipt.block_number, - receipt.transaction_hash - ); - Ok(()) -} - -/// Gets the timestamp of the latest block on the EVM chain, returning None if the block cannot be retrieved -pub(super) async fn current_block_timestamp(provider: &P) -> Option { - match provider - .get_block_by_number(alloy::eips::BlockNumberOrTag::Latest) - .await - { - Ok(Some(block)) => Some(block.header.timestamp), - _ => None, - } -} diff --git a/crates/data/src/chains/evm/broadcast/claim.rs b/crates/data/src/chains/evm/broadcast/claim.rs new file mode 100644 index 0000000..9d5d54f --- /dev/null +++ b/crates/data/src/chains/evm/broadcast/claim.rs @@ -0,0 +1,159 @@ +use alloy::primitives::{Address, FixedBytes}; +use alloy::providers::Provider; +use sha2::{Digest, Sha256}; +use stroemnet_protocol::v1::RevealV1; + +use super::super::GasPayment; +use super::super::contracts::StroemHTLCV1; +use super::super::provider::current_block_timestamp; +use super::apply_gas_and_nonce; +use crate::chains::net::{NETWORK_TIMEOUT, RECEIPT_TIMEOUT, timed}; +use crate::{DataError, Result}; + +/// Submits an EVM HTLC claim over the EVM network +pub(crate) async fn submit_claim( + provider: &P, // a provider + htlc_address: Address, // the contract address for the htlc + reveal: &RevealV1, // the reveal for the htlc + nonce: u64, // the nonce for this transaction, we pass it in manually in order to support RBF + gas_price: u128, // manual specification of gas price for legacy networks + gas_payment: GasPayment, // Whether we use legacy or eip1559 +) -> Result<()> { + // Create a new HTLC instance + let stroem_htlc = StroemHTLCV1::new(htlc_address, provider); + + // Create a timed call to query the swap that we are trying to claim + let swap = timed( + NETWORK_TIMEOUT, + stroem_htlc.swaps(FixedBytes::from(reveal.swap_id)).call(), + ) + .await + .ok_or_else(|| DataError::Broadcast("swaps lookup: timed out".into()))? + .map_err(|e| DataError::Broadcast(format!("swaps lookup: {e}")))?; + + // Validate that the secret matches the swap + let secret_ok = secret_matches(&reveal.secret, swap.secretHash.as_slice()); + + // Read the current block timestamp from the provider + let chain_now = current_block_timestamp(provider).await; + + // Compute the timelock for which the swap is refundable + let timelock = u64::try_from(swap.timelock).unwrap_or(u64::MAX); + + // Check if this swap has to be skipped + if let Some(reason) = claim_skip_reason( + swap.initialized, + swap.finalized, + secret_ok, + chain_now, + timelock, + ) { + tracing::warn!( + "EVM claim skipped for {}: {reason}", + hex::encode(reveal.swap_id) + ); + return Ok(()); + } + + // Build the call + let base = stroem_htlc.claim( + FixedBytes::from(reveal.swap_id), + FixedBytes::from(reveal.secret), + ); + + // Apply the gas for the call + let call = apply_gas_and_nonce(base, nonce, gas_price, gas_payment); + + // Create a timed blockchain transmission fo the transaction + let pending = timed(NETWORK_TIMEOUT, call.send()) + .await + .ok_or_else(|| DataError::Broadcast("claim send: timed out".into()))? + .map_err(|e| DataError::Broadcast(format!("claim send: {e}")))?; + + // Similarly for the blockchain transmission create a timed call to get the receipt + let receipt = timed(RECEIPT_TIMEOUT, pending.get_receipt()) + .await + .ok_or_else(|| DataError::Broadcast("claim receipt: timed out".into()))? + .map_err(|e| DataError::Broadcast(format!("claim receipt: {e}")))?; + + // If the receipt shows a non-ok status, we revert as well. + if !receipt.status() { + return Err(DataError::Broadcast(format!( + "claim reverted: tx {:?}", + receipt.transaction_hash + ))); + } + + // Log and return, todo in the future this should probably be downgraded to a lower log level + tracing::info!( + "EVM claim mined in block {:?}, tx {:?}", + receipt.block_number, + receipt.transaction_hash + ); + Ok(()) +} + +/// Compute a hash and return bool if it matches the claimed secret. +fn secret_matches(secret: &[u8; 32], secret_hash: &[u8]) -> bool { + let mut hasher = Sha256::new(); + hasher.update(secret); + hasher.finalize().as_slice() == secret_hash +} + +/// A function that quickly determines +/// whether a claim should be skipped. Mostly if its not initialized or finalized +/// or if its expired and should be refunded instead. +fn claim_skip_reason( + initialized: bool, + finalized: bool, + secret_ok: bool, + chain_now: Option, + timelock: u64, +) -> Option<&'static str> { + if !initialized || finalized { + return Some("not claimable"); + } + if !secret_ok { + return Some("revealed secret does not match on-chain hash"); + } + match chain_now { + Some(now) if now >= timelock => Some("timelock expired"), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn secret_matches_known_hash() { + use sha2::{Digest, Sha256}; + let secret = [7u8; 32]; + let hash: [u8; 32] = Sha256::digest(secret).into(); + assert!(secret_matches(&secret, &hash)); + assert!(!secret_matches(&[1u8; 32], &hash)); + } + + #[test] + fn claim_skip_reason_covers_each_guard() { + assert_eq!( + claim_skip_reason(false, false, true, None, 0), + Some("not claimable") + ); + assert_eq!( + claim_skip_reason(true, true, true, None, 0), + Some("not claimable") + ); + assert_eq!( + claim_skip_reason(true, false, false, None, 0), + Some("revealed secret does not match on-chain hash") + ); + assert_eq!( + claim_skip_reason(true, false, true, Some(100), 50), + Some("timelock expired") + ); + assert_eq!(claim_skip_reason(true, false, true, Some(10), 50), None); + assert_eq!(claim_skip_reason(true, false, true, None, 50), None); + } +} diff --git a/crates/data/src/chains/evm/broadcast/commit.rs b/crates/data/src/chains/evm/broadcast/commit.rs new file mode 100644 index 0000000..1d7584a --- /dev/null +++ b/crates/data/src/chains/evm/broadcast/commit.rs @@ -0,0 +1,116 @@ +use alloy::primitives::U256; +use alloy::providers::Provider; +use stroemnet_protocol::v1::CommitmentV1; + +use super::super::GasPayment; +use super::super::contracts::StroemHTLCV1; +use crate::chains::evm::parse_address; +use crate::chains::net::{NETWORK_TIMEOUT, RECEIPT_TIMEOUT, retry_timed, timed}; +use crate::{DataError, Result}; + +/// A function to submit a commitment to an HTLC swap over the EVM network. +pub(crate) async fn submit_commitment( + provider: &P, + htlc_address: alloy::primitives::Address, + commitment: &CommitmentV1, + gas_payment: GasPayment, +) -> Result<()> { + // Parse the sender address + let sender_addr = parse_address("sender", &commitment.addresses.sender)?; + + // Parse the receiver address + let receiver_addr = parse_address("receiver", &commitment.addresses.receiver)?; + + // Create a new stroem htlc v1 instance + let stroem_htlc = StroemHTLCV1::new(htlc_address, provider); + + // Build the call to create a new swap + // We are doing it on behalf of the sender and receiver address + let mut call = stroem_htlc + .newSwap( + sender_addr, // the sender of this swap + receiver_addr, // the receiver of the funds + commitment // the senders destination address on the destination chain + .addresses + .sender_destination + .as_bytes() + .to_vec() + .into(), + commitment.secret_hash.into(), // secret hash that allows for the unlock + U256::from(commitment.unlock_ts), // when this leg of the swap is refundable + commitment.destination, // the destination chain + commitment.swap_id.into(), // a unique swap id + ) + .value(parse_value(&commitment.amount.value)?); // parse + + // Try to resolve a gas price depending on the payment type + if let Some(gp) = resolve_gas_price(provider, gas_payment).await? { + call = call.gas_price(gp); + } + + // Transmit onchain with a timeout + let pending = timed(NETWORK_TIMEOUT, call.send()) + .await + .ok_or_else(|| DataError::Broadcast("newSwap send: timed out".into()))? + .map_err(|e| DataError::Broadcast(format!("newSwap send: {e}")))?; + + // Try to receive a receipt but also on a timed basis, + // returning an error if we dont receive it fast enough + let receipt = timed(RECEIPT_TIMEOUT, pending.get_receipt()) + .await + .ok_or_else(|| DataError::Broadcast("newSwap receipt: timed out".into()))? + .map_err(|e| DataError::Broadcast(format!("newSwap receipt: {e}")))?; + + // Revert if its non-ok + if !receipt.status() { + return Err(DataError::Broadcast(format!( + "newSwap reverted: tx {:?}", + receipt.transaction_hash + ))); + } + tracing::info!( + "EVM commitment mined in block {:?}, tx {:?}", + receipt.block_number, + receipt.transaction_hash + ); + Ok(()) +} + +/// Parse a string to U256 +fn parse_value(value: &str) -> Result { + U256::from_str_radix(value, 10) + .map_err(|e| DataError::Broadcast(format!("amount {value}: {e}"))) +} + +/// Resolve the gas price depending on a specific payment type variant +async fn resolve_gas_price( + provider: &P, + gas_payment: GasPayment, +) -> Result> { + match gas_payment { + GasPayment::Eip1559 => Ok(None), // for eip1559 we use the alloy built-in handling + GasPayment::Legacy => { + // for legacy networks we get the gas price and bump it slightly + let gp = retry_timed("gas_price", || provider.get_gas_price()) + .await + .ok_or_else(|| DataError::Broadcast("gas price: timed out".into()))?; + Ok(Some(gp.saturating_mul(6) / 5)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_address_rejects_garbage() { + assert!(parse_address("sender", "not-an-address").is_err()); + } + + #[test] + fn parse_value_parses_decimal() { + assert_eq!(parse_value("1000").ok(), Some(U256::from(1000u64))); + assert!(parse_value("0xff").is_err()); + } +} diff --git a/crates/data/src/chains/evm/broadcast/mod.rs b/crates/data/src/chains/evm/broadcast/mod.rs new file mode 100644 index 0000000..55d9ba3 --- /dev/null +++ b/crates/data/src/chains/evm/broadcast/mod.rs @@ -0,0 +1,40 @@ +#[cfg(not(target_arch = "wasm32"))] +mod claim; +mod commit; +#[cfg(not(target_arch = "wasm32"))] +mod refund; + +#[cfg(not(target_arch = "wasm32"))] +pub(super) use claim::submit_claim; +pub(super) use commit::submit_commitment; +#[cfg(not(target_arch = "wasm32"))] +pub(super) use refund::submit_refund; + +#[cfg(not(target_arch = "wasm32"))] +use super::GasPayment; + +#[cfg(not(target_arch = "wasm32"))] +/// Applies gas and nonce to an alloy call depending on the `GasPayment` variant. +pub(super) fn apply_gas_and_nonce( + base: alloy::contract::CallBuilder, + nonce: u64, + gas_price: u128, + gas_payment: GasPayment, +) -> alloy::contract::CallBuilder +where + P: alloy::providers::Provider, + D: alloy::contract::CallDecoder, + N: alloy::network::Network, +{ + // Add the nonce to the base call + let base = base.nonce(nonce); + + // For simplicity we set the gas price to be the same for eip1559 + // as well but for the future we should probably make this a bit more efficient + match gas_payment { + GasPayment::Eip1559 => base + .max_fee_per_gas(gas_price) + .max_priority_fee_per_gas(gas_price), + GasPayment::Legacy => base.gas_price(gas_price), + } +} diff --git a/crates/data/src/chains/evm/broadcast/refund.rs b/crates/data/src/chains/evm/broadcast/refund.rs new file mode 100644 index 0000000..af82c88 --- /dev/null +++ b/crates/data/src/chains/evm/broadcast/refund.rs @@ -0,0 +1,53 @@ +use alloy::primitives::{Address, FixedBytes}; +use alloy::providers::Provider; + +use super::super::GasPayment; +use super::super::contracts::StroemHTLCV1; +use super::apply_gas_and_nonce; +use crate::chains::net::{NETWORK_TIMEOUT, RECEIPT_TIMEOUT, timed}; +use crate::{DataError, Result}; + +/// Submits a refund over the blockchain for a specific HTLCv1 swap +pub(crate) async fn submit_refund( + provider: &P, // the evm provider + htlc_address: Address, // the htlc addresss + swap_id: [u8; 32], // the swap id + nonce: u64, // a nonce for this transaction (rbf) + gas_price: u128, // a gas price + gas_payment: GasPayment, // variant of how we are going to pay gas for this transaction legacy or eip1559 +) -> Result<()> { + // Create a new stroem htlc v1 instance + let stroem_htlc = StroemHTLCV1::new(htlc_address, provider); + + // Build the refund call + let base = stroem_htlc.refund(FixedBytes::from(swap_id)); + + // Apply the gas and the nonce to the transaction + let call = apply_gas_and_nonce(base, nonce, gas_price, gas_payment); + + // Dispatch the transaction with a network timeout + let pending = timed(NETWORK_TIMEOUT, call.send()) + .await + .ok_or_else(|| DataError::Broadcast("refund send: timed out".into()))? + .map_err(|e| DataError::Broadcast(format!("refund send: {e}")))?; + + // Wait for the receipt also on a timeout + let receipt = timed(RECEIPT_TIMEOUT, pending.get_receipt()) + .await + .ok_or_else(|| DataError::Broadcast("refund receipt: timed out".into()))? + .map_err(|e| DataError::Broadcast(format!("refund receipt: {e}")))?; + + // Revert if receipt is non ok + if !receipt.status() { + return Err(DataError::Broadcast(format!( + "refund reverted: tx {:?}", + receipt.transaction_hash + ))); + } + tracing::info!( + "EVM refund mined in block {:?}, tx {:?}", + receipt.block_number, + receipt.transaction_hash + ); + Ok(()) +} diff --git a/crates/data/src/chains/evm/buffer.rs b/crates/data/src/chains/evm/buffer.rs new file mode 100644 index 0000000..dba5bdf --- /dev/null +++ b/crates/data/src/chains/evm/buffer.rs @@ -0,0 +1,103 @@ +use alloy::primitives::U256; +use stroemnet_protocol::ChannelId; +use stroemnet_protocol::now_unix_secs; +use stroemnet_protocol::v1::ChainEvent; + +use super::Evm; +use super::signing; +#[cfg(not(target_arch = "wasm32"))] +use crate::TaskFut; +use crate::{BufFut, ChainDataBuffer, DataError, ProposalVerification, Result}; +#[cfg(not(target_arch = "wasm32"))] +use std::sync::Arc; + +impl ChainDataBuffer for Evm { + /// Retrieve the chain specific address for this LP + fn lp_address(&self) -> Result { + // Derive the public key from the private key + let pk = self + .private_key + .as_deref() + .ok_or(DataError::MissingKey(self.channel_id))?; + + // Simply return the address from the private key + signing::address_from_private_key(pk) + } + + #[cfg(not(target_arch = "wasm32"))] + /// Returns the future task which represents the function that helps settle + /// pending actions, such as refunds or claims + fn settler_task(self: Arc) -> Option { + let metrics = self.metrics.clone(); + Some(crate::chains::settlement::settler_loop(self, metrics)) + } + + /// Retrieve the next chunk of finalized events for this channel + /// Stroemnet works in a cursor based fashion, not via subscriptions, for robustness. + fn finalized_chunk(&self) -> BufFut<'_, Vec<(ChannelId, ChainEvent)>> { + Box::pin(self.poll_finalized()) + } + + /// Retrieve the current onchain timestamp ensuring that it is below + /// Some maximum age, which is for now the polling interval *3 but a maximum + /// of 30 seconds. + fn chain_now(&self) -> Option { + let max_age = self.poll_interval_secs.saturating_mul(3).max(30); + let (ts, observed) = self.state.lock().last_block_ts?; + if now_unix_secs().saturating_sub(observed) > max_age { + return None; + } + Some(ts) + } + + /// Broadcast an event across the channel, these are commitments, refunds, claims + fn broadcast_event<'a>(&'a self, event: &'a ChainEvent) -> BufFut<'a, ()> { + Box::pin(self.emit_event(event)) + } + + /// Signs a message with this configured channel + /// Used for proving the validity of your quotes and ensuring that you indeed + /// have enough balance to cover the swap + fn sign_message<'a>( + &'a self, + digest: [u8; 32], // the digest of the swap + required_balance: &'a str, // the minimum required balance + ) -> BufFut<'a, (String, Vec)> { + Box::pin(async move { + let pk = self + .private_key + .as_deref() + .ok_or(DataError::MissingKey(self.channel_id))?; + let required = U256::from_str_radix(required_balance, 10) + .map_err(|e| DataError::Sign(format!("required_balance: {e}")))?; + + // Sign the message and prove you have enough balance too + signing::sign_message(&self.read_provider, pk, digest, required).await + }) + } + + /// Verifies a message for other components, checking their signature + /// and that they have enough balance to fulfill the swap. + fn verify_message<'a>( + &'a self, + digest: [u8; 32], // the digest of the message + claimed_address: &'a str, // which address they are claiming to be + signature: &'a [u8], // signature for the digest + required_balance: &'a str, // minimum required balance to fulfill this swap + ) -> BufFut<'a, ProposalVerification> { + Box::pin(async move { + let required = U256::from_str_radix(required_balance, 10) + .map_err(|e| DataError::Sign(format!("required_balance: {e}")))?; + + // Verify the message and ensure the balance is satisfied + signing::verify_message( + &self.read_provider, + digest, + claimed_address, + signature, + required, + ) + .await + }) + } +} diff --git a/crates/data/src/chains/evm/config.rs b/crates/data/src/chains/evm/config.rs new file mode 100644 index 0000000..2c4ec1f --- /dev/null +++ b/crates/data/src/chains/evm/config.rs @@ -0,0 +1,69 @@ +use serde::Deserialize; + +use super::GasPayment; +use super::finality::{DEFAULT_MAX_BLOCKS_PER_POLL, DEFAULT_POLL_INTERVAL_MS}; + +#[derive(Deserialize)] +/// EVM channel configuration +pub(super) struct EvmConfig { + /// the RPC url to connec to the evm network + pub rpc_url: String, + /// The htlc address, i.e. contract address on this chain + pub htlc_address: String, + #[serde(default)] + /// Minimum number of block confirmations to consider a chain event to be confirmed + pub minimum_block_confirmations: u64, + #[serde(default = "default_poll_interval_ms")] + /// How frequently to poll the RPC for new data + pub poll_interval_ms: u64, + #[serde(default = "default_max_blocks_per_poll")] + /// Maximum amount of blocks to poll per each rpc request + pub max_blocks_per_poll: u64, + #[serde(default)] + /// Whether to participate in CCR, requires gas balance + pub participate_ccr: bool, + #[serde(default)] + /// Whether the network is a legacy or eip1559 network + pub gas_payment: GasPayment, +} + +/// Default poll interval +fn default_poll_interval_ms() -> u64 { + DEFAULT_POLL_INTERVAL_MS +} + +/// Default maximum blocks per poll +fn default_max_blocks_per_poll() -> u64 { + DEFAULT_MAX_BLOCKS_PER_POLL +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + + #[test] + fn applies_defaults_when_absent() { + let cfg: EvmConfig = serde_json::from_value(serde_json::json!({ + "rpc_url": "http://localhost:8545", + "htlc_address": "0x0000000000000000000000000000000000000000" + })) + .unwrap(); + assert_eq!(cfg.poll_interval_ms, DEFAULT_POLL_INTERVAL_MS); + assert_eq!(cfg.max_blocks_per_poll, DEFAULT_MAX_BLOCKS_PER_POLL); + assert_eq!(cfg.minimum_block_confirmations, 0); + assert!(!cfg.participate_ccr); + assert!(matches!(cfg.gas_payment, GasPayment::Eip1559)); + } + + #[test] + fn parses_gas_payment_lowercase() { + let cfg: EvmConfig = serde_json::from_value(serde_json::json!({ + "rpc_url": "u", + "htlc_address": "a", + "gas_payment": "legacy" + })) + .unwrap(); + assert!(matches!(cfg.gas_payment, GasPayment::Legacy)); + } +} diff --git a/crates/data/src/chains/evm/connect.rs b/crates/data/src/chains/evm/connect.rs new file mode 100644 index 0000000..74ef995 --- /dev/null +++ b/crates/data/src/chains/evm/connect.rs @@ -0,0 +1,112 @@ +use parking_lot::Mutex; +use std::sync::Arc; + +use alloy::providers::Provider; +use serde_json::Value; +use stroemnet_protocol::ChannelId; +use stroemnet_protocol::now_unix_secs; + +use super::config::EvmConfig; +use super::finality::PollState; +use super::provider::build_providers; +use super::{Evm, EvmState}; +use crate::CursorStore; +use crate::SwapStore; +use crate::chains::evm::parse_address; +use crate::chains::net::retry_timed; +use crate::chains::record::restore; +use crate::chains::settlement::{SettlementMetrics, or_noop, seed_queue}; +use crate::{DataError, Result}; + +impl Evm { + /// Connects to the EVM channel, restores cursors and reconciles pending swap data + pub(crate) async fn connect( + channel_id: ChannelId, // channel id for the network + cfg: &Value, // the arbitrary value of the configuration + private_key: Option, // maybe a private key if this is an lp or participates in ccr + cursor_store: Option>, // storage for storing cursors + swap_store: Option>, // storing swaps + metrics: Option>, // metrics for statistics + ) -> Result { + // Try parse the evm config + let cfg: EvmConfig = serde_json::from_value(cfg.clone()) + .map_err(|e| DataError::Config(format!("evm config: {e}")))?; + + // Parse the htlc address + let htlc_address = parse_address("htlc_address", &cfg.htlc_address)?; + + // Build providers to connect to the EVM network + let (read_provider, signed_provider) = + build_providers(&cfg.rpc_url, private_key.as_deref()).await?; + + // Retrieve the current head of the evm chain + let head = retry_timed("connect get_block_number", || { + read_provider.get_block_number() + }) + .await + .ok_or_else(|| DataError::Connect("get_block_number: timed out".into()))?; + + // Compute the fallback cursor which is the minimum block confirmations + 1 + // this is essentially means that we start from the stable finalized head + // from our perspective. + let fallback_cursor = head + .saturating_sub(cfg.minimum_block_confirmations) + .saturating_add(1); + + // Retrieve the cursor for this channel id and convert it back to + // a u64 of fallback to the fallback cursor + let cursor = cursor_store + .as_ref() + .and_then(|s| s.load(channel_id)) + .and_then(|b| <[u8; 8]>::try_from(b.as_slice()).ok()) + .map(u64::from_le_bytes) + .unwrap_or(fallback_cursor); + + tracing::info!( + "EVM buffer {channel_id} connected to {} — polling from block {cursor} (confirmations {}, ccr {})", + cfg.rpc_url, + cfg.minimum_block_confirmations, + cfg.participate_ccr, + ); + + // Restore old swaps based on the channel id + let restored = restore(swap_store.as_ref(), channel_id); + + // Some of the restored swaps might need to be claimed or refunded + // so lets seed the queue and try + let queue = seed_queue(&restored, now_unix_secs()); + + // Track the pending refunds and claims + let pending_refunds = restored.pending_refunds; + let pending_claims = restored.pending_claims; + + // Create the evm buffer + let buffer = Self { + channel_id, + htlc_address, + minimum_block_confirmations: cfg.minimum_block_confirmations, + poll_interval_secs: (cfg.poll_interval_ms / 1000).max(1), + max_blocks_per_poll: cfg.max_blocks_per_poll, + participate_ccr: cfg.participate_ccr, + gas_payment: cfg.gas_payment, + private_key, + read_provider, + signed_provider, + state: Mutex::new(EvmState { + poll: PollState { cursor }, + pending_refunds, + pending_claims, + next_poll_secs: 0, + last_block_ts: None, + }), + cursor_store, + swap_store, + queue, + metrics: or_noop(metrics), + }; + #[cfg(not(target_arch = "wasm32"))] + // Check if any of the restored swaps have been finished during the outage or offline time + crate::chains::settlement::reconcile_on_boot(&buffer, buffer.metrics.as_ref()).await; + Ok(buffer) + } +} diff --git a/crates/data/src/chains/evm/contracts.rs b/crates/data/src/chains/evm/contracts.rs index 9814145..4dd548b 100644 --- a/crates/data/src/chains/evm/contracts.rs +++ b/crates/data/src/chains/evm/contracts.rs @@ -3,21 +3,19 @@ use alloy::sol; sol! { #[sol(rpc)] contract StroemHTLCV1 { - struct Swap { - address sender; - bytes sender_destination_address; - address receiver; - uint256 amount; - bytes32 secretHash; - uint256 timelock; - bool initialized; - bool finalized; - } - - mapping(bytes32 => Swap) public swaps; - mapping(bytes32 => bool) public secretHashes; - - uint256 public constant BPS_DENOMINATOR; + function swaps(bytes32 swapId) + external + view + returns ( + address sender, + bytes sender_destination_address, + address receiver, + uint256 amount, + bytes32 secretHash, + uint256 timelock, + bool initialized, + bool finalized + ); event Commitment( bytes32 indexed swapId, @@ -55,3 +53,45 @@ sol! { function refund(bytes32 _swapId) external; } } + +#[cfg(test)] +mod tests { + #![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing + )] + use super::StroemHTLCV1; + use alloy::primitives::{Address, B256, Bytes, U256}; + use alloy::sol_types::{SolCall, SolValue}; + + #[test] + fn swaps_return_matches_eight_field_solidity_struct() { + let encoded = ( + Address::repeat_byte(0x11), + Bytes::from(vec![0xAA, 0xBB, 0xCC]), + Address::repeat_byte(0x22), + U256::from(1000u64), + B256::repeat_byte(0x33), + U256::from(1_700_000_000u64), + true, + false, + ) + .abi_encode_params(); + + let ret = StroemHTLCV1::swapsCall::abi_decode_returns(&encoded).unwrap(); + + assert_eq!(ret.sender, Address::repeat_byte(0x11)); + assert_eq!( + ret.sender_destination_address, + Bytes::from(vec![0xAA, 0xBB, 0xCC]) + ); + assert_eq!(ret.receiver, Address::repeat_byte(0x22)); + assert_eq!(ret.amount, U256::from(1000u64)); + assert_eq!(ret.secretHash, B256::repeat_byte(0x33)); + assert_eq!(ret.timelock, U256::from(1_700_000_000u64)); + assert!(ret.initialized); + assert!(!ret.finalized); + } +} diff --git a/crates/data/src/chains/evm/decode.rs b/crates/data/src/chains/evm/decode.rs index 2e8acfb..3e22944 100644 --- a/crates/data/src/chains/evm/decode.rs +++ b/crates/data/src/chains/evm/decode.rs @@ -4,38 +4,48 @@ use stroemnet_protocol::v1::{AddressesV1, AmountV1, ChainEvent, CommitmentV1, Re use super::contracts::StroemHTLCV1; -/// Decodes an EVM log into a ChainEvent -/// if it matches the HTLC contract's Commitment, Claim, or Refund events +/// Decodes an EVM log into a canonical Stroem ChainEvent pub(super) fn decode_log(log: &Log, channel_id: ChannelId) -> Option { + // Attempt to decode into a commitmentv1 if let Ok(decoded) = log.log_decode::() { - // Decode the Commitment event into our protocol's CommitmentV1 struct let commitment = CommitmentV1::new( - decoded.inner.swapId.into(), // swap id + decoded.inner.swapId.into(), AddressesV1::new( - // Compute addresses struct as per protocol format!("{}", decoded.inner.sender), format!("{}", decoded.inner.receiver), String::from_utf8_lossy(&decoded.inner.sender_destination_address).to_string(), ), - AmountV1::new(decoded.inner.amount.to_string(), channel_id.decimals()), // amount with correct decimals - decoded.inner.secretHash.into(), // the secret hash of this swap - decoded.inner.timelock.to::(), // when the swap can be refunded - channel_id as u8, // source chain id is this EVM chain - decoded.inner.destination, // destination chain id as specified in the event + AmountV1::new(decoded.inner.amount.to_string(), channel_id.decimals()), + decoded.inner.secretHash.into(), + decoded.inner.timelock.to::(), + channel_id as u8, + decoded.inner.destination, ); Some(ChainEvent::Commitment(commitment)) + + // Attempt to decode into a claim event } else if let Ok(decoded) = log.log_decode::() { - // Decode the Claim event into our protocol's RevealV1 struct let swap_id: [u8; 32] = decoded.inner.swapId.into(); Some(ChainEvent::Reveal(RevealV1::new( swap_id, decoded.inner.secret.into(), ))) + // Attempt to decode into a refund event } else if let Ok(decoded) = log.log_decode::() { - // Decode the Refund event into our protocol's RefundV1 struct let swap_id: [u8; 32] = decoded.inner.swapId.into(); Some(ChainEvent::Refund(RefundV1::new(swap_id))) } else { None } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unrecognized_log_decodes_to_none() { + let log = Log::default(); + assert!(decode_log(&log, ChannelId::EthereumSepolia).is_none()); + } +} diff --git a/crates/data/src/chains/evm/emit.rs b/crates/data/src/chains/evm/emit.rs new file mode 100644 index 0000000..65c4dca --- /dev/null +++ b/crates/data/src/chains/evm/emit.rs @@ -0,0 +1,38 @@ +use stroemnet_protocol::now_unix_secs; +use stroemnet_protocol::v1::ChainEvent; + +use super::Evm; +use super::broadcast; +use crate::Result; +use crate::chains::settlement::ActionKey; + +impl Evm { + /// Emit the chain event across the current evm entwork + pub(super) async fn emit_event<'a>(&'a self, event: &'a ChainEvent) -> Result<()> { + match event { + // forward to the commitment submitted + ChainEvent::Commitment(c) => { + broadcast::submit_commitment(self.signed()?, self.htlc_address, c, self.gas_payment) + .await + } + ChainEvent::Reveal(r) => { + // We only transmit reveals if we participate in CCR + if self.participate_ccr { + // Add this as a pending claim, we cannot add it earlier since we cannot + // predetermine when a claim is pending + super::super::push_pending_claim(&mut self.state.lock().pending_claims, r); + // Add a note that we have attempted to claim + self.queue + .ensure(ActionKey::claim(r.swap_id), now_unix_secs()); + + // Sync the claim status for this swap id to disk + self.persist_swap(r.swap_id); + } + Ok(()) + } + // Refunds are handled elsewhere in the code and are reactions to + // commitments that we see onchain. So we do not handle them here. + ChainEvent::Refund(_) => Ok(()), + } + } +} diff --git a/crates/data/src/chains/evm/finality.rs b/crates/data/src/chains/evm/finality.rs index d466c7a..57759b3 100644 --- a/crates/data/src/chains/evm/finality.rs +++ b/crates/data/src/chains/evm/finality.rs @@ -4,46 +4,48 @@ use alloy::rpc::types::{Filter, Log}; use alloy::sol_types::SolEvent; use super::contracts::StroemHTLCV1; +use crate::chains::net::retry_timed; -/// Default interval and poll parameters for the EVM chain poller, -/// can be overridden by config and are tested in the PollState tests +/// The default polling interval milliseconds pub(crate) const DEFAULT_POLL_INTERVAL_MS: u64 = 10_000; + +// Maximum blocks per rpc call pub(crate) const DEFAULT_MAX_BLOCKS_PER_POLL: u64 = 1000; #[derive(Debug, Clone, Copy)] -/// A container for tracking the next -/// block to poll for events, -/// and computing the next block range to poll based on the current chain head +/// Tracks the last block that we have successfully polled, ensures +/// that we never miss a block pub(super) struct PollState { pub cursor: u64, } impl PollState { - /// Computes the next block range to poll based on the current chain head, - /// the required number of confirmations, and the maximum blocks to poll at once. - /// Returns None if there are no new blocks to poll yet. + /// Compute the next range of blocks to poll from the rpc pub(super) fn next_range( &self, - current_block: u64, - confirmations: u64, - max_blocks_per_poll: u64, + current_block: u64, // the current block number + confirmations: u64, // number of confirmations that we need + max_blocks_per_poll: u64, // maximum blocks per fetch ) -> Option<(u64, u64)> { - // Exclusive upper bound: one past the deepest confirmed block. + // We want to fetch until the current latest block back - confirmations +1 since we do up until but not + // including let confirmed_end = current_block.checked_sub(confirmations)?.checked_add(1)?; - // The cursor is the next unread block, we start from here. let from = self.cursor; + + // If from is from is greater then the confirmed end it means we havent confirmed enough blocks if from >= confirmed_end { return None; } - // Cap the range to max_blocks_per_poll, or 1 if max_blocks_per_poll is zero + // At least one block per poll let max = max_blocks_per_poll.max(1); - // Half-open end: at most max blocks ahead, never past the confirmed end. + // The end is the smallest of the end and from + the amount of blocks we poll let end = confirmed_end.min(from.saturating_add(max)); Some((from, end)) } + /// Advance the cursor to another block range end pub(super) fn advance(&mut self, end: u64) { debug_assert!( end >= self.cursor, @@ -51,40 +53,35 @@ impl PollState { self.cursor, end ); - // Update the cursor to the new block ensuring that - // caller doesnt try to move it backwards, which would risk missing events self.cursor = end; } - /// Polls the EVM chain for logs from the HTLC contract in the next block range, - /// returning the logs or an empty vector if there are no new blocks to poll or if - /// there was an error fetching the block number or logs (in which case the cursor is unchanged to allow retrying) + /// Poll once in accorance with the block range pub(super) async fn poll_once( &mut self, - provider: &P, - htlc_address: Address, - minimum_block_confirmations: u64, - max_blocks_per_poll: u64, + provider: &P, // the provider + htlc_address: Address, // the contract address + minimum_block_confirmations: u64, // minimum amount of blocks to wait for conf + max_blocks_per_poll: u64, // maximum amount of blocks per poll ) -> Vec { - // Get the current block number - let current = match provider.get_block_number().await { - Ok(n) => n, - Err(e) => { - tracing::warn!("eth_blockNumber failed: {e} — cursor unchanged, will retry"); + // Get the block number and retry a few times but its timeout based + let current = match retry_timed("eth_blockNumber", || provider.get_block_number()).await { + Some(n) => n, + None => { + tracing::warn!("eth_blockNumber failed — cursor unchanged, will retry"); return Vec::new(); } }; - // Compute the next block range to poll, if any - // If there are no new blocks to poll yet, return an empty vector + // Compute the next range to fetch from + // or return an empty vec if we are not ready to fetch more let Some((from, end)) = self.next_range(current, minimum_block_confirmations, max_blocks_per_poll) else { return Vec::new(); }; - // Create a filter which is by our address but also - // the signatures of the events that we are looking for. + // Create a simple evm filter let filter = Filter::new() .address(htlc_address) .events([ @@ -92,15 +89,15 @@ impl PollState { StroemHTLCV1::Claim::SIGNATURE.as_bytes(), StroemHTLCV1::Refund::SIGNATURE.as_bytes(), ]) - .from_block(from) - .to_block(end - 1); // end is exclusive + .from_block(from) // the from block + .to_block(end - 1); // this is inclusive, but our range is up and to (non-inclusive) so we do -1 - // Retrieve logs from the provider in accordance with the filter - let logs = match provider.get_logs(&filter).await { - Ok(l) => l, - Err(e) => { + // Retrieve the logs in accordance with the created filter + let logs = match retry_timed("eth_getLogs", || provider.get_logs(&filter)).await { + Some(l) => l, + None => { tracing::warn!( - "eth_getLogs {from}..{end} failed: {e} — cursor unchanged, will retry" + "eth_getLogs {from}..{end} timed out — cursor unchanged, will retry" ); return Vec::new(); } @@ -111,7 +108,7 @@ impl PollState { logs.len() ); - // Advance the cursor to the end of the range + // After succesfully fetching we should advance self.advance(end); logs } @@ -119,6 +116,12 @@ impl PollState { #[cfg(test)] mod tests { + #![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing + )] use super::*; fn state(cursor: u64) -> PollState { diff --git a/crates/data/src/chains/evm/mod.rs b/crates/data/src/chains/evm/mod.rs index f81749a..647ea03 100644 --- a/crates/data/src/chains/evm/mod.rs +++ b/crates/data/src/chains/evm/mod.rs @@ -1,439 +1,216 @@ mod broadcast; +mod buffer; +mod config; +mod connect; mod contracts; mod decode; +mod emit; mod finality; +mod persist; +mod poll; +mod provider; +#[cfg(not(target_arch = "wasm32"))] +mod reconcile; +#[cfg(not(target_arch = "wasm32"))] +mod replace; +#[cfg(not(target_arch = "wasm32"))] +mod settle; +#[cfg(not(target_arch = "wasm32"))] +mod settler; mod signing; -use std::sync::{Arc, Mutex}; +use parking_lot::Mutex; +use std::sync::Arc; -use alloy::primitives::{Address, U256}; -use alloy::providers::{DynProvider, Provider, ProviderBuilder}; -use alloy::signers::local::PrivateKeySigner; +use alloy::primitives::Address; +use alloy::providers::DynProvider; use serde::Deserialize; -use serde_json::Value; use stroemnet_protocol::ChannelId; -use stroemnet_protocol::now_unix_secs; -use stroemnet_protocol::v1::{ChainEvent, RefundV1, RevealV1}; - -use crate::{BufFut, ChainDataBuffer, DataError, ProposalVerification, Result}; -use finality::{DEFAULT_MAX_BLOCKS_PER_POLL, DEFAULT_POLL_INTERVAL_MS, PollState}; +use stroemnet_protocol::v1::{RefundV1, RevealV1}; +use crate::chains::settlement::{RetryQueue, SettlementMetrics}; +use crate::{CursorStore, DataError, Result, SwapStore}; +use finality::PollState; #[derive(Deserialize, Clone, Copy, Default, Debug)] #[serde(rename_all = "lowercase")] +/// Gas variant. Some networks use legacy, not all are eip1559 compatible pub(crate) enum GasPayment { #[default] Eip1559, Legacy, } -#[derive(Deserialize)] -/// Configuration for the EVM chain data buffer -struct EvmConfig { - /// The RPC URL of the EVM node to connect to - rpc_url: String, - /// The address of the HTLC contract to monitor and interact with - htlc_address: String, - #[serde(default)] - /// The number of block confirmations required before considering an event final - minimum_block_confirmations: u64, - #[serde(default = "default_poll_interval_ms")] - /// The interval in milliseconds between polling the chain for new events - poll_interval_ms: u64, - #[serde(default = "default_max_blocks_per_poll")] - /// The maximum number of blocks to query in each poll - max_blocks_per_poll: u64, - #[serde(default)] - /// Whether to participate in CCR by submitting claims - /// and refunds on the destination chain - participate_ccr: bool, - #[serde(default)] - gas_payment: GasPayment, -} - -fn default_poll_interval_ms() -> u64 { - DEFAULT_POLL_INTERVAL_MS -} - -fn default_max_blocks_per_poll() -> u64 { - DEFAULT_MAX_BLOCKS_PER_POLL -} - -/// The state of the evm from the perspective of polling +/// The state of the EVM channel struct EvmState { + /// The current cursor of the evm channel poll: PollState, + /// Pending refunds that have the time for which they can be refunded pending_refunds: Vec<(RefundV1, u64)>, + /// Pending claims for already revealed secrets pending_claims: Vec, + /// When is the next time for which we should poll the network next_poll_secs: u64, + /// Last safe block timestamp for evm network last_block_ts: Option<(u64, u64)>, } -/// The main Evm struct that polls and emits confirmed data +/// The general struct for the EVM channel pub(crate) struct Evm { - channel_id: ChannelId, // the channel that it operates on - htlc_address: Address, // the address of the htlc contract - minimum_block_confirmations: u64, // the number of confirmations required before considering an event final - poll_interval_secs: u64, // the interval in seconds between polling the chain for new events - max_blocks_per_poll: u64, // the maximum number of blocks to query in each poll - participate_ccr: bool, // whether to participate in CCR - gas_payment: GasPayment, // how to price transactions (eip1559 default, or legacy) - private_key: Option, // private key (only for LP) - read_provider: DynProvider, // provider for reading from the chain - signed_provider: Option, // provider for signing and broadcasting transactions (only for LP) - state: Mutex, // the state of the evm buffer, including the poll state and pending refunds - cursor_store: Option>, // optional cursor store for persisting the polling state (for native) + /// Identifies which channel this is + /// todo: currently non-evm channels can be represented here + /// maybe channelids should be categorized to make invalid state + /// non-representable + channel_id: ChannelId, + /// Contract address of the htlc contract + htlc_address: Address, + /// Minimum number of block confirmations + minimum_block_confirmations: u64, + /// How often to poll from the network + poll_interval_secs: u64, + /// Maximum amount of blocks per poll + max_blocks_per_poll: u64, + /// Whether to participate in ccr + participate_ccr: bool, + /// Which variant of gas payment that we should do + gas_payment: GasPayment, + /// If this is an LP then we also utilize a private key + private_key: Option, + /// A read provider used for read only operations + read_provider: DynProvider, + /// Signed providers for LP's and CCR nodes + signed_provider: Option, + /// The state of the channel, tracking current state + state: Mutex, + /// trait backed cursor store to support both wasm and native impls + cursor_store: Option>, + /// trait backed cursor store to support both wasm and native impls + swap_store: Option>, + /// A queue for actions that have been executed by the settler, and also retry attempts + queue: RetryQueue, + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + /// Statistics about swaps in general + metrics: Arc, } -impl Evm { - /// Connects to the EVM chain using the provided configuration - /// and optional private key for signing transactions - /// Returns an instance of the EVM buffer ready to poll for - /// events and broadcast transactions - pub(crate) async fn connect( - channel_id: ChannelId, - cfg: &Value, - private_key: Option, - cursor_store: Option>, - ) -> Result { - // Parse the configuration from the provided JSON value - let cfg: EvmConfig = serde_json::from_value(cfg.clone()) - .map_err(|e| DataError::Config(format!("evm config: {e}")))?; +/// Function to parse a string EVM address into the alloy variant +fn parse_address(label: &str, value: &str) -> Result
{ + value + .parse() + .map_err(|e| DataError::Broadcast(format!("{label} address {value}: {e}"))) +} - // Parse the HTLC contract address from the configuration - let htlc_address: Address = cfg - .htlc_address - .parse() - .map_err(|e| DataError::Config(format!("htlc_address: {e}")))?; +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + use crate::chains::record::restore; + use crate::{ChainDataBuffer, SwapStore}; + use alloy::providers::{Provider, ProviderBuilder}; + use std::collections::HashMap; + use std::sync::Mutex as StdMutex; + use stroemnet_protocol::v1::ChainEvent; + + type Rows = StdMutex>>; + + #[derive(Default)] + struct MemSwapStore { + rows: Rows, + } - // Instantiate a provider for reading from the chain and another for signing transactions if a private key is provided + impl crate::SwapStore for MemSwapStore { + fn load_channel(&self, channel_id: ChannelId) -> Vec<([u8; 32], Vec)> { + self.rows + .lock() + .unwrap() + .iter() + .filter(|((c, _), _)| *c == channel_id as u8) + .map(|((_, id), v)| (*id, v.clone())) + .collect() + } + fn save(&self, channel_id: ChannelId, swap_id: [u8; 32], record: &[u8]) { + self.rows + .lock() + .unwrap() + .insert((channel_id as u8, swap_id), record.to_vec()); + } + fn delete(&self, channel_id: ChannelId, swap_id: [u8; 32]) { + self.rows + .lock() + .unwrap() + .remove(&(channel_id as u8, swap_id)); + } + } + + async fn test_evm(swap_store: Option>) -> Evm { let read_provider = ProviderBuilder::new() - .connect(&cfg.rpc_url) + .connect("http://127.0.0.1:1") .await - .map_err(|e| DataError::Connect(format!("evm provider: {e}")))? + .unwrap() .erased(); - - // If a private key is provided, create a signed provider for broadcasting transactions - let signed_provider = match &private_key { - Some(pk) => { - let signer: PrivateKeySigner = pk - .parse() - .map_err(|e| DataError::Config(format!("private_key: {e}")))?; - Some( - ProviderBuilder::new() - .wallet(signer) - .connect(&cfg.rpc_url) - .await - .map_err(|e| DataError::Connect(format!("evm signed provider: {e}")))? - .erased(), - ) - } - None => None, - }; - - // Calculate the initial cursor for polling based on the current chain head and the required number of confirmations - let head = read_provider - .get_block_number() - .await - .map_err(|e| DataError::Connect(format!("get_block_number: {e}")))?; - - // If a cursor store is provided, attempt to load the last saved cursor for this channel - let cursor = match cursor_store - .as_ref() - .and_then(|s| s.load(channel_id)) - .filter(|b| b.len() == 8) - { - // convert the cursor from bytes to u64 - Some(bytes) => u64::from_le_bytes(bytes.try_into().unwrap()), - None => { - head // otherwise take the head-minimum_block_confirmations+1 as the starting cursor - .saturating_sub(cfg.minimum_block_confirmations) - .saturating_add(1) - } - }; - - tracing::info!( - "EVM buffer {channel_id} connected to {} — polling from block {cursor} (confirmations {}, ccr {})", - cfg.rpc_url, - cfg.minimum_block_confirmations, - cfg.participate_ccr, - ); - - // Return a new instance of the EVM buffer with - // the initialized state and providers - Ok(Self { - channel_id, - htlc_address, - minimum_block_confirmations: cfg.minimum_block_confirmations, - poll_interval_secs: (cfg.poll_interval_ms / 1000).max(1), - max_blocks_per_poll: cfg.max_blocks_per_poll, - participate_ccr: cfg.participate_ccr, - gas_payment: cfg.gas_payment, - private_key, + Evm { + channel_id: ChannelId::IgraGalleon, + htlc_address: Address::ZERO, + minimum_block_confirmations: 0, + poll_interval_secs: 1, + max_blocks_per_poll: 1, + participate_ccr: true, + gas_payment: GasPayment::Legacy, + private_key: None, read_provider, - signed_provider, + signed_provider: None, state: Mutex::new(EvmState { - poll: PollState { cursor }, + poll: PollState { cursor: 0 }, pending_refunds: Vec::new(), pending_claims: Vec::new(), next_poll_secs: 0, last_block_ts: None, }), - cursor_store, - }) - } - - /// Notify that an event should be tracked - fn track_actionable_event(&self, event: &ChainEvent) { - let mut st = self.state.lock().unwrap(); - super::queue_dequeue_refund_event(&mut st.pending_refunds, event, self.participate_ccr); - match event { - ChainEvent::Reveal(r) => st.pending_claims.retain(|c| c.swap_id != r.swap_id), - ChainEvent::Refund(r) => st.pending_claims.retain(|c| c.swap_id != r.swap_id), - ChainEvent::Commitment(_) => {} + cursor_store: None, + swap_store, + queue: RetryQueue::default(), + metrics: crate::chains::settlement::or_noop(None), } } - /// Retrieve the signer provider - fn signed(&self) -> Result<&DynProvider> { - self.signed_provider - .as_ref() - .ok_or(DataError::MissingKey(self.channel_id)) - } - - /// Check if any pending refunds are ready to be submitted and submit them if so - /// This is used to automatically submit refunds for swaps that have passed - /// their unlock timestamp without a reveal - async fn run_refund_scheduler(&self) { - // If CCR participation is disabled or there is no signing provider, - // skip the refund scheduler - if !self.participate_ccr { - return; - } - - // Retrieve the signed provider and return early if its not configured - let Some(signed) = self.signed_provider.as_ref() else { - return; - }; - - // Check if there are any pending refunds, and if not, skip the rest of the function - let has_pending = { !self.state.lock().unwrap().pending_refunds.is_empty() }; - if !has_pending { - return; - } + #[tokio::test] + async fn reveal_enqueues_and_persists_without_pending_refund() { + let store = Arc::new(MemSwapStore::default()); + let store_dyn: Arc = store.clone(); + let evm = test_evm(Some(store_dyn)).await; + assert!(evm.state.lock().pending_refunds.is_empty()); - // Retrieve the current block timestamp to use for checking which refunds - // are ready to be submitted. - let Some(block_ts) = broadcast::current_block_timestamp(&self.read_provider).await else { - return; - }; - - // Compute the ready swap ids - let ready: Vec<[u8; 32]> = { - let st = self.state.lock().unwrap(); - // Collect the swap ids of all pending refunds whose unlock timestamp has passed - st.pending_refunds - .iter() - .filter(|(_, unlock_ts)| block_ts >= *unlock_ts) - .map(|(r, _)| r.swap_id) - .collect() - }; - - // Loop over each swap id and attempt to submit a refund transaction for it, - // logging any errors that occur - for swap_id in ready { - match broadcast::submit_refund(signed, self.htlc_address, swap_id, self.gas_payment).await - { - Ok(_) => { - self.state - .lock() - .unwrap() - .pending_refunds - .retain(|(r, _)| r.swap_id != swap_id); - } - Err(e) => { - tracing::error!("EVM scheduled refund {}: {e}", hex::encode(swap_id)); - } - } - } + let reveal = RevealV1::new([5u8; 32], [6u8; 32]); + evm.broadcast_event(&ChainEvent::Reveal(reveal.clone())) + .await + .unwrap(); + + assert_eq!(evm.state.lock().pending_claims, vec![reveal.clone()]); + let rows = store.load_channel(ChannelId::IgraGalleon); + assert_eq!(rows.len(), 1); + let store_dyn: Arc = store.clone(); + let restored = restore(Some(&store_dyn), ChannelId::IgraGalleon); + assert_eq!(restored.pending_claims, vec![reveal]); } - async fn run_claim_scheduler(&self) { - if !self.participate_ccr { - return; - } - let Some(signed) = self.signed_provider.as_ref() else { - return; + #[test] + fn seeds_pending_claims_from_store() { + let store = Arc::new(MemSwapStore::default()); + let reveal = RevealV1::new([1u8; 32], [2u8; 32]); + let rec = crate::PersistedSwap { + script: None, + pending_refund: None, + pending_claim: Some(reveal.clone()), + claim_attempt: None, + refund_attempt: None, }; - let claims: Vec = { self.state.lock().unwrap().pending_claims.clone() }; - for reveal in claims { - match broadcast::submit_claim(signed, self.htlc_address, &reveal, self.gas_payment).await - { - Ok(()) => { - self.state - .lock() - .unwrap() - .pending_claims - .retain(|c| c.swap_id != reveal.swap_id); - } - Err(e) => { - tracing::error!("EVM claim retry for {}: {e}", hex::encode(reveal.swap_id)); - } - } - } - } -} - -impl ChainDataBuffer for Evm { - /// Returns the LP address derived from the configured private key, or an error if no private key is configured - fn lp_address(&self) -> Result { - let pk = self - .private_key - .as_deref() - .ok_or(DataError::MissingKey(self.channel_id))?; - signing::address_from_private_key(pk) - } - - /// Finalizes a chunk by polling the chain for new events since the last cursor, - /// decoding them, and returning them as a vector of (ChannelId, ChainEvent) tuples - /// For ethereum this method is already reorg safe as we only poll blocks behind - /// the required confirmation threshold - fn finalized_chunk(&self) -> BufFut<'_, Vec<(ChannelId, ChainEvent)>> { - Box::pin(async move { - let now = now_unix_secs(); - // Check if it's time to poll the chain for new events based on the configured polling interval - let mut poll = { - let mut st = self.state.lock().unwrap(); - if now < st.next_poll_secs { - None - } else { - st.next_poll_secs = now + self.poll_interval_secs; - Some(st.poll) - } - }; - - // Create a container for events - let mut events = Vec::new(); - - // If its time to poll, lets poll. - if let Some(poll) = poll.as_mut() { - // Pull all logs according to the pollstate - let logs = poll - .poll_once( - &self.read_provider, - self.htlc_address, - self.minimum_block_confirmations, - self.max_blocks_per_poll, - ) - .await; - { - self.state.lock().unwrap().poll = *poll; - } - - // If we have a cursor store, save the current cursor to it for persistence - if let Some(store) = &self.cursor_store { - store.save(self.channel_id, &poll.cursor.to_le_bytes()); - } - - // For each log - for log in &logs { - if let Some(event) = decode::decode_log(log, self.channel_id) { - // If we could decode it as a chain event, we queue or dequeue any relevant refunds - self.track_actionable_event(&event); - // Push it as an event - events.push((self.channel_id, event)); - } - } - - if let Some(ts) = broadcast::current_block_timestamp(&self.read_provider).await { - self.state.lock().unwrap().last_block_ts = Some((ts, now_unix_secs())); - } - } - - // After processing the logs we run the refund scheduler to submit any refunds that are ready to be submitted - self.run_refund_scheduler().await; - self.run_claim_scheduler().await; - Ok(events) - }) - } - - fn chain_now(&self) -> Option { - let max_age = self.poll_interval_secs.saturating_mul(3).max(30); - let (ts, observed) = self.state.lock().unwrap().last_block_ts?; - if now_unix_secs().saturating_sub(observed) > max_age { - return None; - } - Some(ts) - } - - /// Broadcasts an incoming event by routing it based on the type of chainevent - fn broadcast_event<'a>(&'a self, event: &'a ChainEvent) -> BufFut<'a, ()> { - Box::pin(async move { - match event { - ChainEvent::Commitment(c) => { - broadcast::submit_commitment(self.signed()?, self.htlc_address, c, self.gas_payment).await - } - ChainEvent::Reveal(r) => { - if self.participate_ccr { - let mut st = self.state.lock().unwrap(); - let known = st.pending_refunds.iter().any(|(p, _)| p.swap_id == r.swap_id); - let queued = st.pending_claims.iter().any(|c| c.swap_id == r.swap_id); - if known && !queued { - st.pending_claims.push(r.clone()); - } - } - Ok(()) - } - ChainEvent::Refund(r) => { - if self.participate_ccr { - // we only submit refunds if we participate in CCR - broadcast::submit_refund(self.signed()?, self.htlc_address, r.swap_id, self.gas_payment).await - } else { - Ok(()) - } - } - } - }) - } - - /// Signs a message digest using the configured private key and returns the signature bytes - /// but also ensures that the signer has the required balance - fn sign_message<'a>( - &'a self, - digest: [u8; 32], - required_balance: &'a str, - ) -> BufFut<'a, (String, Vec)> { - Box::pin(async move { - let pk = self - .private_key - .as_deref() - .ok_or(DataError::MissingKey(self.channel_id))?; - let required = U256::from_str_radix(required_balance, 10) - .map_err(|e| DataError::Sign(format!("required_balance: {e}")))?; - signing::sign_message(&self.read_provider, pk, digest, required).await - }) - } - - /// Verifies a message signature by recovering the signer address and comparing it to the claimed address, - /// and also checks that the signer has the required balance - fn verify_message<'a>( - &'a self, - digest: [u8; 32], - claimed_address: &'a str, - signature: &'a [u8], - required_balance: &'a str, - ) -> BufFut<'a, ProposalVerification> { - Box::pin(async move { - let required = U256::from_str_radix(required_balance, 10) - .map_err(|e| DataError::Sign(format!("required_balance: {e}")))?; - // Verify the message signature and - // return whether the recovered address matches the claimed address, and whether the required balance is met - signing::verify_message( - &self.read_provider, - digest, - claimed_address, - signature, - required, - ) - .await - }) + store.save( + ChannelId::IgraGalleon, + reveal.swap_id, + &crate::chains::record::encode(&rec).unwrap(), + ); + let store_dyn: Arc = store; + let restored = restore(Some(&store_dyn), ChannelId::IgraGalleon); + assert!(restored.pending_refunds.is_empty()); + assert_eq!(restored.pending_claims, vec![reveal]); } } diff --git a/crates/data/src/chains/evm/persist.rs b/crates/data/src/chains/evm/persist.rs new file mode 100644 index 0000000..b9da5a7 --- /dev/null +++ b/crates/data/src/chains/evm/persist.rs @@ -0,0 +1,100 @@ +use alloy::providers::DynProvider; +use stroemnet_protocol::now_unix_secs; +use stroemnet_protocol::v1::ChainEvent; + +use super::Evm; +use crate::chains::record::encode; +use crate::chains::settlement::ActionKey; +use crate::{DataError, PersistedSwap, Result}; + +impl Evm { + /// Retrieve the signer provider for this evm channel + pub(super) fn signed(&self) -> Result<&DynProvider> { + self.signed_provider + .as_ref() + .ok_or(DataError::MissingKey(self.channel_id)) + } + + /// Persist the state of a swap to disk + pub(super) fn persist_swap(&self, swap_id: [u8; 32]) { + // Retrieve the swap store or simply exit + let Some(store) = &self.swap_store else { + return; + }; + + // Create a persisted swap record + let record = { + let st = self.state.lock(); + PersistedSwap { + script: None, + pending_refund: st + .pending_refunds + .iter() + .find(|(r, _)| r.swap_id == swap_id) + .map(|(r, ts)| (r.clone(), *ts)), + pending_claim: st + .pending_claims + .iter() + .find(|c| c.swap_id == swap_id) + .cloned(), + claim_attempt: self.queue.get(ActionKey::claim(swap_id)), + refund_attempt: self.queue.get(ActionKey::refund(swap_id)), + } + }; + if record.is_empty() { + store.delete(self.channel_id, swap_id); + } else { + match encode(&record) { + Ok(bytes) => store.save(self.channel_id, swap_id, &bytes), + Err(e) => tracing::error!( + target: "settlement", + "EVM persist swap {} encode failed: {e}", + hex::encode(swap_id) + ), + } + } + } + + pub(super) fn track_actionable_event(&self, event: &ChainEvent) { + let swap_id = super::super::event_swap_id(event); + { + let mut st = self.state.lock(); + super::super::queue_dequeue_refund_event( + &mut st.pending_refunds, + event, + self.participate_ccr, + ); + if !matches!(event, ChainEvent::Commitment(_)) { + st.pending_claims.retain(|c| c.swap_id != swap_id); + } + } + if matches!(event, ChainEvent::Commitment(_)) { + if self.participate_ccr { + self.queue + .ensure(ActionKey::refund(swap_id), now_unix_secs()); + } + } else { + self.queue.record_success(ActionKey::claim(swap_id)); + self.queue.record_success(ActionKey::refund(swap_id)); + } + self.persist_swap(swap_id); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use stroemnet_protocol::v1::{RefundV1, RevealV1}; + + #[test] + fn event_swap_id_reads_each_variant() { + assert_eq!( + crate::chains::event_swap_id(&ChainEvent::Reveal(RevealV1::new([3u8; 32], [0u8; 32]))), + [3u8; 32] + ); + assert_eq!( + crate::chains::event_swap_id(&ChainEvent::Refund(RefundV1::new([4u8; 32]))), + [4u8; 32] + ); + } +} diff --git a/crates/data/src/chains/evm/poll.rs b/crates/data/src/chains/evm/poll.rs new file mode 100644 index 0000000..22f7a5b --- /dev/null +++ b/crates/data/src/chains/evm/poll.rs @@ -0,0 +1,68 @@ +use stroemnet_protocol::ChannelId; +use stroemnet_protocol::now_unix_secs; +use stroemnet_protocol::v1::ChainEvent; + +use super::Evm; +use super::{decode, provider}; +use crate::Result; + +impl Evm { + /// A wrapper function to poll the finalized blocks from the evm state + pub(super) async fn poll_finalized(&self) -> Result> { + // Compute the unix timestamp + let now = now_unix_secs(); + + // Retrieve the poll state, but only if its truly time to poll + let mut poll = { + let mut st = self.state.lock(); + if now < st.next_poll_secs { + None + } else { + st.next_poll_secs = now + self.poll_interval_secs; + Some(st.poll) + } + }; + + // Create a container to store events that we have found + let mut events = Vec::new(); + + // We only poll if we have a poll state (i.e. its time to poll again) + if let Some(poll) = poll.as_mut() { + // Retrieve logs from the poll + let logs = poll + .poll_once( + &self.read_provider, + self.htlc_address, + self.minimum_block_confirmations, + self.max_blocks_per_poll, + ) + .await; + { + // Update the poll + self.state.lock().poll = *poll; + } + + // Update the cursor to be stored in the cursor storage + if let Some(store) = &self.cursor_store { + store.save(self.channel_id, &poll.cursor.to_le_bytes()); + } + + // Go over all logs, decode the log and then maybe queue a refund + // via track actionable event + for log in &logs { + if let Some(event) = decode::decode_log(log, self.channel_id) { + self.track_actionable_event(&event); + // Then push the event to out container + events.push((self.channel_id, event)); + } + } + + // Retrieve the current block timestamp and update it in our state + if let Some(ts) = provider::current_block_timestamp(&self.read_provider).await { + self.state.lock().last_block_ts = Some((ts, now_unix_secs())); + } + } + + Ok(events) + } +} diff --git a/crates/data/src/chains/evm/provider.rs b/crates/data/src/chains/evm/provider.rs new file mode 100644 index 0000000..043cf51 --- /dev/null +++ b/crates/data/src/chains/evm/provider.rs @@ -0,0 +1,49 @@ +use alloy::providers::{DynProvider, Provider, ProviderBuilder}; +use alloy::signers::local::PrivateKeySigner; + +use crate::chains::net::{NETWORK_TIMEOUT, timed}; +use crate::{DataError, Result}; + +/// Build the provider for the EVM network, this consists of a read provider +/// and an optional signed provider if the user provided the private key. +/// For wasm for example, we dont provide the private key as wasm instances cannot serve as LPs +pub(super) async fn build_providers( + rpc_url: &str, + private_key: Option<&str>, +) -> Result<(DynProvider, Option)> { + // Create the read provider + let read_provider = ProviderBuilder::new() + .connect(rpc_url) + .await + .map_err(|e| DataError::Connect(format!("evm provider: {e}")))? + .erased(); + + // Create a signed provider if we have a stored private key + let signed_provider = match private_key { + Some(pk) => { + let signer: PrivateKeySigner = pk + .parse() + .map_err(|e| DataError::Config(format!("private_key: {e}")))?; + Some( + ProviderBuilder::new() + .wallet(signer) + .connect(rpc_url) + .await + .map_err(|e| DataError::Connect(format!("evm signed provider: {e}")))? + .erased(), + ) + } + None => None, + }; + + Ok((read_provider, signed_provider)) +} + +/// Get a timed out latest block timestamp to use, straight from some provider whilst handling errors (timeouts) +pub(crate) async fn current_block_timestamp(provider: &P) -> Option { + let fetch = provider.get_block_by_number(alloy::eips::BlockNumberOrTag::Latest); + match timed(NETWORK_TIMEOUT, fetch).await { + Some(Ok(Some(block))) => Some(block.header.timestamp), + _ => None, + } +} diff --git a/crates/data/src/chains/evm/reconcile.rs b/crates/data/src/chains/evm/reconcile.rs new file mode 100644 index 0000000..5e8cbb1 --- /dev/null +++ b/crates/data/src/chains/evm/reconcile.rs @@ -0,0 +1,56 @@ +use alloy::eips::BlockId; +use alloy::primitives::FixedBytes; + +use super::Evm; +use super::contracts::StroemHTLCV1; +use crate::chains::net::{NETWORK_TIMEOUT, timed}; +use crate::chains::settlement::{ActionKey, Observation}; + +/// Map the EVM contract booleans into a concrete observation status for a swap +/// that describes its state. +fn observation_from(finalized: bool, initialized: bool) -> Observation { + if finalized { + Observation::Settled + } else if initialized { + Observation::NotSettled + } else { + Observation::Unknown + } +} + +impl Evm { + /// Observe this swaps state onchain, i.e. whether it is settled or not + pub(super) async fn observe_onchain(&self, key: ActionKey) -> Observation { + let stroem = StroemHTLCV1::new(self.htlc_address, &self.read_provider); + match timed( + NETWORK_TIMEOUT, + stroem + .swaps(FixedBytes::from(key.swap_id)) + .block(BlockId::finalized()) + .call(), + ) + .await + { + Some(Ok(swap)) => observation_from(swap.finalized, swap.initialized), + _ => Observation::Unknown, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_finalized_is_settled_uninitialized_is_unknown() { + assert!(matches!(observation_from(true, true), Observation::Settled)); + assert!(matches!( + observation_from(false, false), + Observation::Unknown + )); + assert!(matches!( + observation_from(false, true), + Observation::NotSettled + )); + } +} diff --git a/crates/data/src/chains/evm/replace.rs b/crates/data/src/chains/evm/replace.rs new file mode 100644 index 0000000..f807506 --- /dev/null +++ b/crates/data/src/chains/evm/replace.rs @@ -0,0 +1,95 @@ +use alloy::primitives::Address; +use alloy::providers::Provider; + +use super::Evm; +use super::signing; +use crate::chains::net::retry_timed; +use crate::chains::settlement::ActionKey; + +/// How many times we try to resubmit a transaction with a higher gas price +const MAX_GAS_BUMPS: u32 = 12; + +/// Maximum gas price across the entire application +const MAX_GAS_PRICE_WEI: u128 = 5_000_000_000_000; + +/// Compute the next gas bump based on current base and number of attempts +fn escalated_gas(base: u128, attempt_count: u32) -> u128 { + let mut gas = base; + + // Bump the gas based on the number of attempts + for _ in 0..attempt_count.min(MAX_GAS_BUMPS) { + gas = gas.saturating_mul(115) / 100; + } + + // We never want to exceed the maximum gas price in wei units + gas.min(MAX_GAS_PRICE_WEI) +} + +impl Evm { + /// Computes the next nonce and the gas price for how much to increase the transaction by + pub(super) async fn replacement(&self, key: ActionKey) -> Option<(u64, u128)> { + // Retrieve the state of the attempt + let attempt = self.queue.get(key)?; + + // Read the gas price + let base = retry_timed("settle gas_price", || self.read_provider.get_gas_price()).await?; + + // Compute an increased amount based on the attempt to prioritize our inclusion + let mut gas = escalated_gas(base, attempt.attempt_count); + + // Set the updated gas price value + if let Some(prev) = attempt.last_gas { + gas = gas + .max(prev.saturating_mul(115) / 100) + .min(MAX_GAS_PRICE_WEI); + } + + // Compute the address from the private key + let pk = self.private_key.as_deref()?; + let addr: Address = signing::address_from_private_key(pk).ok()?.parse().ok()?; + + // Retrieve the nonce from onchain + let confirmed = retry_timed("settle account nonce", || { + self.read_provider.get_transaction_count(addr) + }) + .await?; + let nonce = match attempt.nonce { + Some(n) if n >= confirmed => n, // if the attempts nonce is greater than the latest onchain nonce we can just use it + _ => { + let pending = retry_timed("settle pending nonce", || { + // otherwise we need to retrieve the latest pending nonce + self.read_provider.get_transaction_count(addr).pending() + }) + .await?; + // then we take the maximum nonce, either the pending or the confirmed + let n = pending.max(confirmed); + + // then update the nonce that we now have used + self.queue.set_nonce(key, n); + n + } + }; + + // Then also update the gas price that we have used for this attempt + self.queue.set_last_gas(key, gas); + // sync to disk + self.persist_swap(key.swap_id); + + // Return the nonce to use and the gas price + Some((nonce, gas)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn gas_bumps_at_least_one_eighth_per_attempt_then_caps() { + let base = 1_000u128; + assert_eq!(escalated_gas(base, 0), 1_000); + assert!(escalated_gas(base, 1) >= base * 1125 / 1000); + assert!(escalated_gas(base, 2) > escalated_gas(base, 1)); + assert_eq!(escalated_gas(base, 100), escalated_gas(base, MAX_GAS_BUMPS)); + } +} diff --git a/crates/data/src/chains/evm/settle.rs b/crates/data/src/chains/evm/settle.rs new file mode 100644 index 0000000..d9ea0c2 --- /dev/null +++ b/crates/data/src/chains/evm/settle.rs @@ -0,0 +1,108 @@ +use super::Evm; +use super::broadcast; +use super::provider; +use crate::chains::settlement::{ActionKey, SettleOutcome}; + +impl Evm { + /// Attempts to settle a refund onchain on EVM + pub(super) async fn settle_refund(&self, key: ActionKey) -> SettleOutcome { + // Retrieve the swap id + let swap_id = key.swap_id; + + // Ensure that we have a signed provider that can sign transactions + let Ok(signed) = self.signed() else { + return SettleOutcome::Fatal("missing key".into()); + }; + + // Retrieve the unlock timestamp for the swap id + let Some(unlock) = self + .state + .lock() + .pending_refunds + .iter() + .find(|(r, _)| r.swap_id == swap_id) + .map(|(_, ts)| *ts) + else { + return SettleOutcome::Retry("no_refund_intent"); + }; + + // Read the onchain timestamp and ensure that the swap can be refunded + match provider::current_block_timestamp(&self.read_provider).await { + Some(block_ts) if block_ts < unlock => return SettleOutcome::Retry("not_yet_unlocked"), + None => return SettleOutcome::Retry("block_ts_timeout"), + _ => {} + } + + // Compute the nonce to use and gas + // Initially this is zeroed so its safe to use + // i.e. its not just a replacement but also initialization + let Some((nonce, gas)) = self.replacement(key).await else { + return SettleOutcome::Retry("replacement_unavailable"); + }; + + // Try to submit the refund across the chain + match broadcast::submit_refund( + signed, + self.htlc_address, + swap_id, + nonce, + gas, + self.gas_payment, + ) + .await + { + Ok(()) => SettleOutcome::Retry("submitted_awaiting_inclusion"), + Err(e) => { + tracing::warn!(target: "settlement", "evm refund broadcast: {e}"); + SettleOutcome::Retry("broadcast_error") + } + } + } + + /// Attempt to settle a swap by claiming it + pub(super) async fn settle_claim(&self, key: ActionKey) -> SettleOutcome { + // Retrieve the swap id + let swap_id = key.swap_id; + + // Ensure we have a signed provider that can sign onchain transactions + let Ok(signed) = self.signed() else { + return SettleOutcome::Fatal("missing key".into()); + }; + + // Retrieve the reveal v1 which contains the secret needed in order to + // unlock the swap + let Some(reveal) = self + .state + .lock() + .pending_claims + .iter() + .find(|c| c.swap_id == swap_id) + .cloned() + else { + return SettleOutcome::Retry("no_reveal"); + }; + + // Compute the nonce and gas to use for this attempt + let Some((nonce, gas)) = self.replacement(key).await else { + return SettleOutcome::Retry("replacement_unavailable"); + }; + + // Submit the claim across the chain + match broadcast::submit_claim( + signed, + self.htlc_address, + &reveal, + nonce, + gas, + self.gas_payment, + ) + .await + { + Ok(()) => SettleOutcome::Retry("submitted_awaiting_inclusion"), + Err(e) => { + tracing::warn!(target: "settlement", "evm claim broadcast: {e}"); + SettleOutcome::Retry("broadcast_error") + } + } + } +} diff --git a/crates/data/src/chains/evm/settler.rs b/crates/data/src/chains/evm/settler.rs new file mode 100644 index 0000000..1e7f3fb --- /dev/null +++ b/crates/data/src/chains/evm/settler.rs @@ -0,0 +1,68 @@ +use super::Evm; +use crate::chains::settlement::{ + Action, ActionKey, Observation, SettleFut, SettleOutcome, Settler, +}; + +fn jitter(key: ActionKey) -> u64 { + key.swap_id.iter().map(|b| u64::from(*b)).sum() +} + +impl Settler for Evm { + /// Compute which swaps are due for an action right now + fn due_now(&self, now: u64) -> Vec { + self.queue.due_now(now) + } + + /// Execute settlement of a concrete swap (action key) + fn settle(&self, key: ActionKey) -> SettleFut<'_, SettleOutcome> { + Box::pin(async move { + match key.action { + Action::Refund => self.settle_refund(key).await, + Action::Claim => self.settle_claim(key).await, + } + }) + } + + /// Observe a swap and see what is its current state + fn observe(&self, key: ActionKey) -> SettleFut<'_, Observation> { + Box::pin(async move { self.observe_onchain(key).await }) + } + + /// Record the settlement of a swap + fn record_success(&self, key: ActionKey) { + // Remove the swap from the queue + self.queue.record_success(key); + { + let mut st = self.state.lock(); + // Remove the swap from pending claims or refunds depending on what it was representing + match key.action { + Action::Claim => st.pending_claims.retain(|c| c.swap_id != key.swap_id), + Action::Refund => st.pending_refunds.retain(|(r, _)| r.swap_id != key.swap_id), + } + } + // Persist swap state to disk + self.persist_swap(key.swap_id); + } + + /// Record that a swap has failed at the current time + fn record_failure(&self, key: ActionKey, now: u64) { + self.queue.record_failure(key, now, jitter(key)); + self.persist_swap(key.swap_id); + } + + /// Check whether a particular swap is stuck in its settlement + fn is_stuck(&self, key: ActionKey, now: u64) -> bool { + self.queue.is_stuck(key, now) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn jitter_is_deterministic_sum_of_swap_id_bytes() { + assert_eq!(jitter(ActionKey::claim([2u8; 32])), 64); + assert_eq!(jitter(ActionKey::refund([0u8; 32])), 0); + } +} diff --git a/crates/data/src/chains/evm/signing.rs b/crates/data/src/chains/evm/signing.rs index 73def19..9e60de8 100644 --- a/crates/data/src/chains/evm/signing.rs +++ b/crates/data/src/chains/evm/signing.rs @@ -5,10 +5,10 @@ use alloy::providers::Provider; use alloy::signers::Signer; use alloy::signers::local::{LocalSignerError, PrivateKeySigner}; +use crate::chains::net::retry_timed; use crate::{DataError, ProposalVerification, Result}; -/// Derives the Ethereum address from the provided private key string -/// Returns the address as a hex string or a DataError if parsing fails +/// Convert private key to an address in string format pub(super) fn address_from_private_key(private_key: &str) -> Result { let signer: PrivateKeySigner = private_key .parse() @@ -16,8 +16,8 @@ pub(super) fn address_from_private_key(private_key: &str) -> Result { Ok(format!("{}", signer.address())) } -/// Verifies that the provided signature is valid for the given digest and claimed address -/// Returns a tuple of the recovered address and whether it matches the claimed address +/// Verify the signature of an LP (address) +/// based on the provided message hash and signature pub(super) fn verify_lp_signature( digest: [u8; 32], claimed_address: &str, @@ -33,42 +33,43 @@ pub(super) fn verify_lp_signature( Ok((format!("{recovered}"), recovered == claimed)) } -/// Queries the balance of the provided address using the given provider -/// Returns the balance as a U256 or a DataError if the query fails +/// Query the balance of an address pub(super) async fn query_balance(provider: &P, address: &str) -> Result { let addr = Address::from_str(address.trim()) .map_err(|e| DataError::Rpc(format!("invalid address {address}: {e}")))?; - provider - .get_balance(addr) + retry_timed("get_balance", || provider.get_balance(addr)) .await - .map_err(|e| DataError::Rpc(format!("get_balance: {e}"))) + .ok_or_else(|| DataError::Rpc("get_balance: timed out".into())) } -/// Signs the provided digest with the given private key after -/// verifying that the associated address has sufficient balance +/// Sign a message with a private key and also +/// ensure that the private key has enough balance to cover the minimum requirement pub(super) async fn sign_message( provider: &P, private_key: &str, digest: [u8; 32], required_balance: U256, ) -> Result<(String, Vec)> { - // Compute the signer from the passed private ket + // Parse the signer let signer: PrivateKeySigner = private_key .parse() .map_err(|e: LocalSignerError| DataError::Sign(format!("local signer: {e}")))?; - // Get the address let address = signer.address(); - // Query the balance from the provider + // Query the balance of the address let balance = query_balance(provider, &address.to_string()).await?; + + // Having this check here ensures that is irrepresentable to provide a valid signature of a + // swap quote without having enough balance (assuming a valid an honest node) + // But the user will verify sig anyway so it doesnt really matter if balance < required_balance { return Err(DataError::Sign(format!( "insufficient balance at {address}: have {balance}, need {required_balance}" ))); } - // Sign the digest + // Sign the message with the private key let signature = signer .sign_message(&digest) .await @@ -76,9 +77,8 @@ pub(super) async fn sign_message( Ok((format!("{address}"), signature.as_bytes().to_vec())) } -/// Verify that a certain message came from a claimed address -/// and that there is a certain amount of balance for the claimed address -/// Used to prevent impersonation attacks +/// Verify a message by ensuring that the signature corresponds to the claimed address +/// and also that the claimed address has enough funds to cover the swap amount pub(super) async fn verify_message( provider: &P, digest: [u8; 32], @@ -86,14 +86,11 @@ pub(super) async fn verify_message( signature_bytes: &[u8], required_balance: U256, ) -> Result { - // Verify the LP signature first to recover the address and check if it matches the claimed address let (_signer_address, address_matches) = verify_lp_signature(digest, claimed_address, signature_bytes)?; - // Query the balance let balance = query_balance(provider, claimed_address).await?; - // Return the verification result of the proposal Ok(ProposalVerification { address_matches, balance_sufficient: balance >= required_balance, @@ -102,6 +99,12 @@ pub(super) async fn verify_message( #[cfg(test)] mod tests { + #![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing + )] use super::*; fn fixture_digest() -> [u8; 32] { diff --git a/crates/data/src/chains/kaspa/accessors.rs b/crates/data/src/chains/kaspa/accessors.rs new file mode 100644 index 0000000..5442392 --- /dev/null +++ b/crates/data/src/chains/kaspa/accessors.rs @@ -0,0 +1,39 @@ +use stroemnet_protocol::now_unix_secs; +use stroemnet_protocol::v1::CommitmentV1; + +use super::Kaspa; +use crate::{DataError, Result, UtxoScript}; + +impl Kaspa { + /// Return the private key + pub(super) fn key(&self) -> Result<&str> { + self.private_key + .as_deref() + .ok_or(DataError::MissingKey(self.channel_id)) + } + + /// Cache a commitment + pub(super) fn cache_commitment(&self, commitment: &CommitmentV1) { + self.commitments + .lock() + .insert(commitment.swap_id, commitment.clone()); + } + + /// Retrieve a commitment by swap id + pub(super) fn commitment(&self, swap_id: &[u8; 32]) -> Option { + self.commitments.lock().get(swap_id).cloned() + } + + /// Prune announced scripts in according with the script ttl policy + pub(super) async fn prune_scripts(&self) { + let now = now_unix_secs(); + let ttl = self.script_ttl_secs; + let mut scripts = self.utxo_scripts.write().await; + scripts.retain(|_, s| now <= s.unlock_ts.saturating_add(ttl)); + } + + /// Register a script announcement internally + pub(super) async fn register_internal(&self, address: String, script: UtxoScript) { + self.utxo_scripts.write().await.insert(address, script); + } +} diff --git a/crates/data/src/chains/kaspa/broadcast.rs b/crates/data/src/chains/kaspa/broadcast.rs deleted file mode 100644 index 8603950..0000000 --- a/crates/data/src/chains/kaspa/broadcast.rs +++ /dev/null @@ -1,717 +0,0 @@ -use std::sync::Arc; - -use k256::schnorr::SigningKey; -use k256::schnorr::signature::hazmat::PrehashSigner; -use kaspa_addresses::{Address, Prefix, Version}; -use kaspa_consensus_core::constants::{STORAGE_MASS_PARAMETER, TRANSIENT_BYTE_TO_MASS_FACTOR}; -use kaspa_consensus_core::hashing::sighash::{ - SigHashReusedValuesUnsync, calc_schnorr_signature_hash, -}; -use kaspa_consensus_core::hashing::sighash_type::SIG_HASH_ALL; -use kaspa_consensus_core::mass::MassCalculator; -use kaspa_consensus_core::subnets::SUBNETWORK_ID_NATIVE; -use kaspa_consensus_core::tx::{ - MutableTransaction, ScriptPublicKey, Transaction, TransactionInput, TransactionOutpoint, - TransactionOutput, UtxoEntry, -}; -use kaspa_rpc_core::RpcUtxosByAddressesEntry; -use kaspa_rpc_core::api::rpc::RpcApi; -use kaspa_txscript::{ - SEQUENCE_LOCK_TIME_DISABLED, extract_script_pub_key_address, opcodes::codes::OpFalse, - opcodes::codes::OpTrue, pay_to_address_script, pay_to_script_hash_script, - script_builder::ScriptBuilder, -}; -use kaspa_wrpc_client::KaspaRpcClient; -use stroemnet_protocol::v1::{CommitmentV1, RevealV1}; - -use super::contracts::contract_v1::{SOLVER_REWARD, create_htlc_script}; -use super::error::{KaspaError, Result}; -// Mass parameters for fee calculation. -const MASS_PER_TX_BYTE: u64 = 1; -const MASS_PER_SCRIPT_PUB_KEY_BYTE: u64 = 10; -const MASS_PER_SIG_OP: u64 = 1000; -const SCHNORR_SIG_SCRIPT_SIZE: u64 = 66; - -/// The resulting HTLC address and redeem script after -/// preparing and submitting a commitment over the network. -pub(super) struct Announce { - pub address: String, - pub redeem_script: Vec, -} - -/// A bip340 signer that derives the signing key and -/// public key from a given private key string and network prefix. -/// To produce schnorr signatures for Kaspa transactions, which are used in HTLC scripts. -struct Signer340 { - key: SigningKey, - pubkey: [u8; 32], - prefix: Prefix, -} - -impl Signer340 { - /// Derive `Self` from a hex-encoded private key string and a Kaspa network prefix. - fn derive(private_key: &str, prefix: Prefix) -> Result { - // Remove any kind of 0x prefix if its present - let secret = hex::decode(private_key.trim_start_matches("0x")) - .map_err(|e| KaspaError::Other(format!("private key hex: {e}")))?; - let key = SigningKey::from_bytes(&secret) - .map_err(|e| KaspaError::Other(format!("schnorr signing key: {e}")))?; - let pubkey: [u8; 32] = key - .verifying_key() - .to_bytes() - .as_slice() - .try_into() - .map_err(|_| KaspaError::Other("verifying key not 32 bytes".into()))?; - Ok(Self { - key, - pubkey, - prefix, - }) - } - - // Retrieve the kaspa address - fn address(&self) -> Address { - Address::new(self.prefix, Version::PubKey, &self.pubkey) - } - - // Compute the script public key for the signer's address - fn spk(&self) -> ScriptPublicKey { - pay_to_address_script(&self.address()) - } - - /// Sign the input at the given index of the provided mutable transaction, returning the signature script. - fn sign_input( - &self, - mutable_tx: &MutableTransaction, - index: usize, - ) -> Result> { - let reused_values = SigHashReusedValuesUnsync::new(); - let sig_hash = calc_schnorr_signature_hash( - &mutable_tx.as_verifiable(), - index, - SIG_HASH_ALL, - &reused_values, - ); - let sig: k256::schnorr::Signature = - self.key - .sign_prehash(sig_hash.as_bytes().as_slice()) - .map_err(|e| KaspaError::Other(format!("schnorr sign: {e}")))?; - let mut signature = Vec::with_capacity(65); - signature.extend_from_slice(&sig.to_bytes()); - signature.push(SIG_HASH_ALL.to_u8()); - Ok(ScriptBuilder::new() - .add_data(&signature) - .map_err(|e| KaspaError::ScriptBuilder(format!("{e:?}")))? - .drain()) - } -} - -/// Compute the appropriate address prefix for the Kaspa network we are connected to (mainnet, testnet, etc.) -async fn prefix_for(client: &Arc) -> Result { - let network_type = client.get_server_info().await?.network_id.network_type; - Ok(network_type.into()) -} - -/// Calculate the priority fee for a transaction based on its mass and the current fee estimates from the Kaspa network. -async fn calculate_priority_fee( - client: &Arc, - tx: &Transaction, - extra_sig_script_bytes: u64, -) -> Result { - // Retrieve the fee estimate from the Kaspa network, which includes the feerate for the priority bucket. - let fee_estimate = client.get_fee_estimate().await?; - let feerate = fee_estimate.priority_bucket.feerate; - - // Instantiate the mass calculator - let mass_calc = MassCalculator::new( - MASS_PER_TX_BYTE, - MASS_PER_SCRIPT_PUB_KEY_BYTE, - MASS_PER_SIG_OP, - STORAGE_MASS_PARAMETER, - ); - - // Compute the noncontextual masses - let non_contextual = mass_calc.calc_non_contextual_masses(tx); - - // Now compute the esimated schnorr signature size based on the number of inputs and their sig op counts - let schnorr_sig_bytes: u64 = tx - .inputs - .iter() - .filter(|input| input.sig_op_count > 0) - .count() as u64 - * SCHNORR_SIG_SCRIPT_SIZE; - - // Compute the total signature bytes. - let total_sig_bytes = schnorr_sig_bytes + extra_sig_script_bytes; - - // Finally compute both the compute and transient mass used for fee estimation - let compute_mass = non_contextual.compute_mass + total_sig_bytes * MASS_PER_TX_BYTE; - let transient_mass = - non_contextual.transient_mass + total_sig_bytes * TRANSIENT_BYTE_TO_MASS_FACTOR; - - // Take whatever is bigger - let mass = compute_mass.max(transient_mass); - - // Multiply the mass by the feerate and round up to the nearest integer, ensuring a minimum fee of 1. - Ok(((mass as f64 * feerate).ceil() as u64).max(1)) -} - -/// Converts a Kaspa spk to a byte vector, prefixing it with its version. -pub(super) fn spk_to_vec(spk: &ScriptPublicKey) -> Vec { - let mut v = Vec::with_capacity(2 + spk.script().len()); - v.extend_from_slice(&spk.version.to_be_bytes()); - v.extend_from_slice(spk.script()); - v -} - -/// Convert and RPC UTXO to a UtxoEntry -fn rpc_utxo_to_entry(u: &RpcUtxosByAddressesEntry) -> UtxoEntry { - UtxoEntry::new( - u.utxo_entry.amount, - ScriptPublicKey::new( - u.utxo_entry.script_public_key.version, - u.utxo_entry.script_public_key.script().into(), - ), - u.utxo_entry.block_daa_score, - u.utxo_entry.is_coinbase, - ) -} -/// Returns whether a utxo is mature which is when it is not a coinbase -/// or if it is a coinbase it has enough confirmations based on the current DAA score and the coinbase maturity parameter. -fn utxo_is_mature( - utxo: &RpcUtxosByAddressesEntry, - coinbase_maturity: u64, - current_daa: u64, -) -> bool { - !utxo.utxo_entry.is_coinbase - || utxo.utxo_entry.block_daa_score + coinbase_maturity <= current_daa -} - -/// Construct a raw HTLC script and its associated sender and receiver script public keys from a given commitment. -fn htlc_script_from_commitment( - commitment: &CommitmentV1, -) -> Result<(Vec, ScriptPublicKey, ScriptPublicKey, u64)> { - // Convert the unlock timestamp to milliseconds, as Kaspa uses millisecond precision for lock times in scripts. - let unlock_ts_ms = commitment.unlock_ts.saturating_mul(1000); - - // Compute the sender spk - let sender_spk = - pay_to_address_script(&Address::try_from(commitment.addresses.sender.clone())?); - - // Compute the receiver spk - let receiver_spk = - pay_to_address_script(&Address::try_from(commitment.addresses.receiver.clone())?); - - // Create the HTLC redeem script using the provided commitment details, - // including the sender and receiver script public keys, secret hash, unlock time, destination, and swap ID. - let htlc_script = create_htlc_script( - &spk_to_vec(&sender_spk), - commitment.addresses.sender_destination.as_bytes(), - &spk_to_vec(&receiver_spk), - &commitment.secret_hash, - unlock_ts_ms, - commitment.destination, - commitment.swap_id, - ) - .map_err(|e| KaspaError::ScriptBuilder(format!("{e:?}")))?; - - Ok((htlc_script, sender_spk, receiver_spk, unlock_ts_ms)) -} - -/// Submits an HTLC commitment, locking funds in a script until -/// they are either claimed by the receiver with the preimage or refunded to the sender after timeout. -pub(super) async fn submit_commitment( - client: &Arc, - private_key: &str, - coinbase_maturity: u64, - commitment: &CommitmentV1, -) -> Result { - // Compute the kaspa network prefix - // since on kaspa testnet and mainnet have different address prefixes - let prefix = prefix_for(client).await?; - - // Derive the signer from the provided private key and network prefix - let signer = Signer340::derive(private_key, prefix)?; - - // Create the htlc script, we dont need sender,receiver and unlock time for this - // those are used more often for reveal and refund, arguably those could be extracted to their own - // helpers. But we will keep it simple for now. - let (htlc_script, _sender_spk, _receiver_spk, _unlock_ts_ms) = - htlc_script_from_commitment(commitment)?; - - // Now compute the spk of the htlc we just created - let htlc_spk = pay_to_script_hash_script(&htlc_script); - let our_spk = signer.spk(); - - // We need to fund the HTLC output and therefore we need to select some of our UTXOs as inputs for the transaction. - let utxos = client - .get_utxos_by_addresses(vec![signer.address()]) - .await?; - if utxos.is_empty() { - // if there are no utxos then there are no funds - return Err(KaspaError::NoUtxos); - } - - // Retrieve dag info to get the current DAA score, - // which we will use to filter out immature coinbase UTXOs and ensure selected UTXOs are mature enough to be spent. - // since technically some LP's could be miners as well - let dag_info = client.get_block_dag_info().await?; - let current_daa = dag_info.virtual_daa_score; - let amount: u64 = commitment.amount.value.parse()?; - - // Create a container for the selected utxos and total input amount - // so that we can compute the appropriate change amount - let mut selected_utxos = Vec::new(); - let mut total_input: u64 = 0; - for utxo in utxos { - // If this is a coinbase UTXO we need to ensure it is mature before trying to spend it. - if !utxo_is_mature(&utxo, coinbase_maturity, current_daa) { - continue; - } - // Add the UTXO to our selection and update the total input amount. - total_input += utxo.utxo_entry.amount; - - // Add it as a selected UTXO - selected_utxos.push(utxo); - - // If we have enough total input, we can stop here. - if total_input >= amount { - break; - } - } - if total_input < amount { - return Err(KaspaError::InsufficientFunds { - needed: amount, - available: total_input, - }); - } - - // Compute the transaction inputs from the selected UTXOs, - // creating a TransactionInput for each one with an empty signature script for now. - let inputs: Vec = selected_utxos - .iter() - .enumerate() - .map(|(seq, utxo)| TransactionInput { - previous_outpoint: TransactionOutpoint::new( - utxo.outpoint.transaction_id, - utxo.outpoint.index, - ), - signature_script: vec![], - sequence: seq as u64, - sig_op_count: 1, - }) - .collect(); - - // Create output containing just the htlc output for now, we will add change later - let mut outputs = vec![TransactionOutput::new(amount, htlc_spk.clone())]; - let preliminary_change = total_input.saturating_sub(amount); - if preliminary_change > 0 { - // if there is change, we should add a change output to our own spk. - outputs.push(TransactionOutput::new(preliminary_change, our_spk.clone())); - } - - // Create a preliminary transaction with the selected inputs and outputs, - // which we will use to calculate the appropriate fee based on its mass. - let preliminary_tx = Transaction::new( - 0, - inputs.clone(), - outputs, - 0, - SUBNETWORK_ID_NATIVE, - 0, - vec![], - ); - - // Compute the priority fee needed for this transaction. - let fee = calculate_priority_fee(client, &preliminary_tx, 0).await?; - - // If we dont have enough total input to cover both the amount and the fee, we need to return an error. - if total_input < amount + fee { - return Err(KaspaError::InsufficientFunds { - needed: amount + fee, - available: total_input, - }); - } - - // Now compute the actual change amount after accounting for the fee, - // and construct the final outputs for the transaction. - let change = total_input.saturating_sub(amount).saturating_sub(fee); - let mut final_outputs = vec![TransactionOutput::new(amount, htlc_spk.clone())]; - if change > 0 { - // If there is change, add the change output to the final outputs. - final_outputs.push(TransactionOutput::new(change, our_spk.clone())); - } - - // Create the final transaction with the final inputs and outputs - let tx = Transaction::new(0, inputs, final_outputs, 0, SUBNETWORK_ID_NATIVE, 0, vec![]); - - // Compute all the utxo entries for the selected UTXOs, which we will need to sign the transaction. - let utxo_entries: Vec = selected_utxos.iter().map(rpc_utxo_to_entry).collect(); - - // Create a mutable tx that we can sign - let mut mutable_tx = MutableTransaction::with_entries(tx, utxo_entries); - - // For each of the inputs, sign the input and populate the signature script - // using our Signer340, which produces schnorr signatures for Kaspa transactions. - for i in 0..selected_utxos.len() { - mutable_tx.tx.inputs[i].signature_script = signer.sign_input(&mutable_tx, i)?; - } - - // Convert the mutable transaction into an RpcTransaction - let rpc_tx = (&mutable_tx.tx).into(); - - // Broadcast the transaction to the Kaspa network using the RPC client, and retrieve the resulting transaction ID. - let tx_id = client.submit_transaction(rpc_tx, false).await?; - tracing::info!("Kaspa HTLC commitment submitted: txid {tx_id}"); - - // Finally, return the address and redeem script of the HTLC so - // that the receiver can monitor for it and claim it with the preimage. - let address = extract_script_pub_key_address(&htlc_spk, prefix)?.to_string(); - Ok(Announce { - address, - redeem_script: htlc_script, - }) -} - -/// A container for all the necessary information to prepare and submit an HTLC spend transaction, -struct HtlcSpend { - signer: Signer340, - htlc_script: Vec, - sender_spk: ScriptPublicKey, - receiver_spk: ScriptPublicKey, - unlock_ts_ms: u64, - htlc_utxos: Vec, - fee_utxo: RpcUtxosByAddressesEntry, -} - -/// Prepares an HTLC to be spent -async fn prepare_htlc_spend( - client: &Arc, - private_key: &str, - coinbase_maturity: u64, - commitment: &CommitmentV1, -) -> Result { - // Compute the kaspa network prefix for the connected client. - let prefix = prefix_for(client).await?; - - // Derive the signer from the provided private key and network prefix. - let signer = Signer340::derive(private_key, prefix)?; - - // Extract the htlc script, sender and receiver script public keys, and unlock time from the commitment. - // This time we need all of the parameters - let (htlc_script, sender_spk, receiver_spk, unlock_ts_ms) = - htlc_script_from_commitment(commitment)?; - - // Compute the spk of the htlc. - let htlc_spk = pay_to_script_hash_script(&htlc_script); - - // Extract the htlc address from the htlc spk so that we can query for the UTXOs - let htlc_address = extract_script_pub_key_address(&htlc_spk, prefix)?; - - // Retrieve the UTXOs for the HTLC address, which are essentially the locked funds - // we need to unlock, either due to CCR or because this is our counter that we should claim - let htlc_utxos = client.get_utxos_by_addresses(vec![htlc_address]).await?; - if htlc_utxos.is_empty() { - return Err(KaspaError::HtlcUtxoNotFound(commitment.swap_id)); - } - - // Because the HTLC enforces output to the owner of the swap we need to provide a fee from our side to claim the - // HTLC - let our_utxos = client - .get_utxos_by_addresses(vec![signer.address()]) - .await?; - - // Retrieve dag info - let dag_info = client.get_block_dag_info().await?; - let current_daa = dag_info.virtual_daa_score; - let fee_utxo = our_utxos - .iter() - .find(|u| utxo_is_mature(u, coinbase_maturity, current_daa)) - .ok_or(KaspaError::NoUtxos)? - .clone(); - - Ok(HtlcSpend { - signer, - htlc_script, - sender_spk, - receiver_spk, - unlock_ts_ms, - htlc_utxos, - fee_utxo, - }) -} - -/// Parameters required in order to spend an HTLC, -/// either for a reveal or a refund, which have different script paths -/// but largely the same requirements in terms of inputs and signing. -struct SpendParams<'a> { - /// The destination script public key where the funds will be sent after claiming the HTLC, - dest_spk: &'a ScriptPublicKey, - /// Sequence for the htcl input - htlc_sequence: u64, - /// Sequence for the fee input, just a regular utxo - fee_sequence: u64, - /// The unlock time in milliseconds interchangeable with `unlock_ts_ms` - lock_time: u64, - /// An estimate of the extra bytes that will be added to the transaction by the signature scripts, - extra_sig_bytes: u64, - /// The sig script which contains information about - /// whether to take the reveal path or the refund path in the HTLC script, - /// as well as the preimage in case of reveal. - branch_sig_script: Vec, - /// For loggin only - log_label: &'a str, -} - -/// A helper function to submit a htlc spending transaction either -/// as a reveal or a refund depending on the provided `branch_sig_script` -async fn submit_htlc_spend( - client: &Arc, - ctx: &HtlcSpend, - params: SpendParams<'_>, -) -> Result<()> { - let our_spk = ctx.signer.spk(); - - // There could be multiple utxos for the same htlc either by accident, - // griefing attempt, so we need to go over all utxos that match the htlc address - for utxo in ctx.htlc_utxos.iter() { - // Compute the destination amount which is the amount locked in the HTLC minus the solver reward, - let dest_amount = utxo - .utxo_entry - .amount - .checked_sub(SOLVER_REWARD as u64) - .ok_or(KaspaError::InsufficientFunds { - needed: SOLVER_REWARD as u64, - available: utxo.utxo_entry.amount, - })?; - - // Solver reward technically includes the fee utxo as well - let solver_reward_before_fee = (SOLVER_REWARD as u64) - .checked_add(ctx.fee_utxo.utxo_entry.amount) - .ok_or_else(|| KaspaError::Other("Solver reward + fee UTXO overflow".to_string()))?; - - // Create inputs for the transaction where as per protocol - // the first input is always the HTLC utxo and the second - // input is the fee utxo from our wallet that we will use to pay for the transaction. - let inputs = vec![ - TransactionInput { - previous_outpoint: TransactionOutpoint::new( - utxo.outpoint.transaction_id, - utxo.outpoint.index, - ), - signature_script: vec![], - sequence: params.htlc_sequence, - sig_op_count: 0, - }, - TransactionInput { - previous_outpoint: TransactionOutpoint::new( - ctx.fee_utxo.outpoint.transaction_id, - ctx.fee_utxo.outpoint.index, - ), - signature_script: vec![], - sequence: params.fee_sequence, - sig_op_count: 1, - }, - ]; - - // Create preliminary outputs for the transaction, - // which include the destination output for the HTLC claim and a solver reward output to our own spk, - let preliminary_outputs = vec![ - TransactionOutput::new(dest_amount, params.dest_spk.clone()), - TransactionOutput::new(solver_reward_before_fee, our_spk.clone()), - ]; - - // Create a preliminary tx so that we can estimate priority fees - let preliminary_tx = Transaction::new( - 0, - inputs.clone(), - preliminary_outputs, - params.lock_time, - SUBNETWORK_ID_NATIVE, - 0, - vec![], - ); - - // Compute the priority fee - let fee = calculate_priority_fee(client, &preliminary_tx, params.extra_sig_bytes).await?; - - // Now compute the solver reward after accounting for the fee - let solver_reward = - solver_reward_before_fee - .checked_sub(fee) - .ok_or(KaspaError::InsufficientFunds { - needed: fee, - available: solver_reward_before_fee, - })?; - - // Compute the final outputs - let outputs = vec![ - TransactionOutput::new(dest_amount, params.dest_spk.clone()), - TransactionOutput::new(solver_reward, our_spk.clone()), - ]; - - // Create the final transaction with the finalized inputs and outputs, and the provided lock time. - let tx = Transaction::new( - 0, - inputs, - outputs, - params.lock_time, - SUBNETWORK_ID_NATIVE, - 0, - vec![], - ); - - // Create the utxo entries used for signing - let utxo_entries = vec![rpc_utxo_to_entry(utxo), rpc_utxo_to_entry(&ctx.fee_utxo)]; - - // Now create a mutable transaction that we can sign for - let mut mutable_tx = MutableTransaction::with_entries(tx, utxo_entries); - - // The htlc input should be signed with the signature script in the branch signature script - mutable_tx.tx.inputs[0].signature_script = params.branch_sig_script.clone(); - - // The signature script for the fee utxo is a regular utxo so therefore - // it should use the signer to produce a schnorr signature for the input - mutable_tx.tx.inputs[1].signature_script = ctx.signer.sign_input(&mutable_tx, 1)?; - - // Finalize the transaction by converting to rpc transaction - let rpc_tx = (&mutable_tx.tx).into(); - - // Broadcast the transaction over p2p - let tx_id = client.submit_transaction(rpc_tx, false).await?; - tracing::info!("Kaspa {} submitted: txid {tx_id}", params.log_label); - } - Ok(()) -} - -/// Submit the reveal transaction -pub(super) async fn submit_reveal( - client: &Arc, - private_key: &str, - coinbase_maturity: u64, - commitment: &CommitmentV1, - reveal: &RevealV1, -) -> Result<()> { - // Prepare the htlc for spending which essentially means gathering - // all the necessary information and UTXOs for signing and broadcasting the transaction - let ctx = prepare_htlc_spend(client, private_key, coinbase_maturity, commitment).await?; - - // We want to execute the branch that is the claim branch - // and for that we need to push optrue and preimage as a signature - // followed by the original htlc script as `redeem script` for the p2sh - let branch_sig_script = ScriptBuilder::new() - .add_data(&reveal.secret) - .map_err(|e| KaspaError::ScriptBuilder(format!("{e:?}")))? - .add_op(OpTrue) - .map_err(|e| KaspaError::ScriptBuilder(format!("{e:?}")))? - .add_data(&ctx.htlc_script) - .map_err(|e| KaspaError::ScriptBuilder(format!("{e:?}")))? - .drain(); - - // Now we can just submit it to the helper which will - // submit it across the network - submit_htlc_spend( - client, - &ctx, - SpendParams { - dest_spk: &ctx.receiver_spk, - htlc_sequence: 0, // htlc sequence 0 - fee_sequence: 1, // fee sequence 1 - lock_time: 0, // we dont need lock time - extra_sig_bytes: 300, // estimate roughly 300 bytes for the reveal todo:have exact value - branch_sig_script, - log_label: "CCR reveal", // logging only - }, - ) - .await -} - -/// Submit the refund transaction -pub(super) async fn submit_refund( - client: &Arc, - private_key: &str, - coinbase_maturity: u64, - commitment: &CommitmentV1, -) -> Result<()> { - // Prepare the htlc for spending which essentially means gathering - // all the necessary information and UTXOs for signing and broadcasting the transaction - let ctx = prepare_htlc_spend(client, private_key, coinbase_maturity, commitment).await?; - - // This time we want to trigger refund branch which effectively just means - // passing opfalse and setting proper lock time for the transaction - let branch_sig_script = ScriptBuilder::new() - .add_op(OpFalse) - .map_err(|e| KaspaError::ScriptBuilder(format!("{e:?}")))? - .add_data(&ctx.htlc_script) - .map_err(|e| KaspaError::ScriptBuilder(format!("{e:?}")))? - .drain(); - - // Submit the htlc for spending - submit_htlc_spend( - client, - &ctx, - SpendParams { - dest_spk: &ctx.sender_spk, - htlc_sequence: SEQUENCE_LOCK_TIME_DISABLED, - fee_sequence: SEQUENCE_LOCK_TIME_DISABLED, - lock_time: ctx.unlock_ts_ms, - extra_sig_bytes: 260, - branch_sig_script, - log_label: "refund", - }, - ) - .await -} - -#[cfg(test)] -mod tests { - use super::*; - use kaspa_consensus_core::tx::VerifiableTransaction; - use kaspa_hashes::Hash; - use kaspa_txscript::{TxScriptEngine, caches::Cache}; - - #[test] - fn production_signer_p2pk_input_verifies_on_engine() { - let signer = Signer340::derive( - "1111111111111111111111111111111111111111111111111111111111111111", - Prefix::Testnet, - ) - .unwrap(); - let spk = signer.spk(); - let input_value = 1_000_000u64; - let input = TransactionInput { - previous_outpoint: TransactionOutpoint::new(Hash::from_u64_word(1), 0), - signature_script: vec![], - sequence: 0, - sig_op_count: 1, - }; - let output = TransactionOutput::new(input_value - 1_000, spk.clone()); - let tx = Transaction::new( - 0, - vec![input], - vec![output], - 0, - SUBNETWORK_ID_NATIVE, - 0, - vec![], - ); - let entry = UtxoEntry::new(input_value, spk.clone(), 0, false); - let mut mutable_tx = MutableTransaction::with_entries(tx, vec![entry]); - mutable_tx.tx.inputs[0].signature_script = signer.sign_input(&mutable_tx, 0).unwrap(); - - let reused = SigHashReusedValuesUnsync::new(); - let verifiable = mutable_tx.as_verifiable(); - let sig_cache = Cache::new(10_000); - let utxo_entry = verifiable.utxo(0).unwrap().clone(); - let mut vm = TxScriptEngine::from_transaction_input( - &verifiable, - &verifiable.inputs()[0], - 0, - &utxo_entry, - &reused, - &sig_cache, - ); - vm.execute() - .expect("production-signed P2PK input must satisfy CHECKSIG"); - } -} diff --git a/crates/data/src/chains/kaspa/broadcast/commit.rs b/crates/data/src/chains/kaspa/broadcast/commit.rs new file mode 100644 index 0000000..d236374 --- /dev/null +++ b/crates/data/src/chains/kaspa/broadcast/commit.rs @@ -0,0 +1,139 @@ +use std::sync::Arc; + +use kaspa_consensus_core::subnets::SUBNETWORK_ID_NATIVE; +use kaspa_consensus_core::tx::{MutableTransaction, Transaction, TransactionOutput, UtxoEntry}; +use kaspa_rpc_core::api::rpc::RpcApi; +use kaspa_txscript::{extract_script_pub_key_address, pay_to_script_hash_script}; +use kaspa_wrpc_client::KaspaRpcClient; +use stroemnet_protocol::v1::CommitmentV1; + +use super::super::error::{KaspaError, Result}; +use super::fee::calculate_priority_fee; +use super::htlc::{Announce, htlc_script_from_commitment}; +use super::signer::Signer340; +use super::utxo::{prefix_for, rpc_utxo_to_entry, select_funding_utxos, to_inputs}; +use crate::chains::net::{NETWORK_TIMEOUT, retry_timed, timed}; + +/// Submits a commitment across the kaspa network +pub(crate) async fn submit_commitment( + client: &Arc, // the kaspa rpc client + private_key: &str, // private key + coinbase_maturity: u64, // number of daa scores needed for coinbase maturity (miners can be LPs) + commitment: &CommitmentV1, // the commmitment to commit to onchain +) -> Result { + // Retrieve the network refix + let prefix = prefix_for(client).await?; + + // Compute the signer from the prefix and private key + let signer = Signer340::derive(private_key, prefix)?; + + // Conver the commitment into a kaspa canonical htlc script + let (htlc_script, _sender_spk, _receiver_spk, _unlock_ts_ms) = + htlc_script_from_commitment(commitment)?; + + // Compute the p2sh spk of the script + let htlc_spk = pay_to_script_hash_script(&htlc_script); + + // Retrieve out p2pk spk + let our_spk = signer.spk(); + + // Retrieve the utxos that are available for our address + let utxos = client + .get_utxos_by_addresses(vec![signer.address()]) + .await?; + + // If we do not have any utxos then we need to error + if utxos.is_empty() { + return Err(KaspaError::NoUtxos); + } + + // Retrieve the block dag info to get the dag data + let dag_info = retry_timed("get_block_dag_info", || client.get_block_dag_info()) + .await + .ok_or_else(|| KaspaError::Other("get_block_dag_info: timed out".into()))?; + let current_daa = dag_info.virtual_daa_score; + + // Parse the amount needed for the commitment + let amount: u64 = commitment.amount.value.parse()?; + + // Select those utxos which have sufficient maturity and satisfy the value requirement + let (selected_utxos, total_input) = + select_funding_utxos(utxos, amount, coinbase_maturity, current_daa)?; + + // Convert the selected utxos to tx inputs + let inputs = to_inputs(&selected_utxos); + + // Compute the change that we probably will get + let preliminary_change = total_input.saturating_sub(amount); + + // Create an output for the htlc + let mut outputs = vec![TransactionOutput::new(amount, htlc_spk.clone())]; + if preliminary_change > 0 { + // And the change to our address + outputs.push(TransactionOutput::new(preliminary_change, our_spk.clone())); + } + // Create a preliminary tx for mass estimation + let preliminary_tx = Transaction::new( + 0, + inputs.clone(), + outputs, + 0, + SUBNETWORK_ID_NATIVE, + 0, + vec![], + ); + + // Compute the fee + let fee = calculate_priority_fee(client, &preliminary_tx, 0).await?; + + if total_input < amount + fee { + return Err(KaspaError::InsufficientFunds { + needed: amount + fee, + available: total_input, + }); + } + + // Now compute the final change that we get after accounting for fee + let change = total_input.saturating_sub(amount).saturating_sub(fee); + let mut final_outputs = vec![TransactionOutput::new(amount, htlc_spk.clone())]; + if change > 0 { + // Add the change to the final outputs + final_outputs.push(TransactionOutput::new(change, our_spk.clone())); + } + + // Create the final transaction + let tx = Transaction::new(0, inputs, final_outputs, 0, SUBNETWORK_ID_NATIVE, 0, vec![]); + + // Convert the selected utxos to rpc compatible utxo entries + let utxo_entries: Vec = selected_utxos.iter().map(rpc_utxo_to_entry).collect(); + + // Create a mutable transaction + let mut mutable_tx = MutableTransaction::with_entries(tx, utxo_entries); + let mut signed_scripts = Vec::with_capacity(selected_utxos.len()); + + // Go over all the utxos and sign them + for i in 0..selected_utxos.len() { + signed_scripts.push(signer.sign_input(&mutable_tx, i)?); + } + + // Then update the signature scripts with the signatures + for (input, script) in mutable_tx.tx.inputs.iter_mut().zip(signed_scripts) { + input.signature_script = script; + } + + // Convert the mutable tx into an rpc tx finalizing it + let rpc_tx = (&mutable_tx.tx).into(); + + // Submit the transaction over rpc with a timeout + let tx_id = timed(NETWORK_TIMEOUT, client.submit_transaction(rpc_tx, false)) + .await + .ok_or_else(|| KaspaError::Other("submit_transaction: timed out".into()))??; + tracing::info!("Kaspa HTLC commitment submitted: txid {tx_id}"); + + // Create the announcement that we have committed to the specified p2sh address + let address = extract_script_pub_key_address(&htlc_spk, prefix)?.to_string(); + Ok(Announce { + address, + redeem_script: htlc_script, + }) +} diff --git a/crates/data/src/chains/kaspa/broadcast/fee.rs b/crates/data/src/chains/kaspa/broadcast/fee.rs new file mode 100644 index 0000000..3d28d00 --- /dev/null +++ b/crates/data/src/chains/kaspa/broadcast/fee.rs @@ -0,0 +1,101 @@ +use std::sync::Arc; + +use kaspa_consensus_core::constants::{STORAGE_MASS_PARAMETER, TRANSIENT_BYTE_TO_MASS_FACTOR}; +use kaspa_consensus_core::mass::MassCalculator; +use kaspa_consensus_core::tx::Transaction; +use kaspa_rpc_core::api::rpc::RpcApi; +use kaspa_wrpc_client::KaspaRpcClient; + +use super::super::error::{KaspaError, Result}; +use crate::chains::net::retry_timed; + +/// Mass per tx byte +const MASS_PER_TX_BYTE: u64 = 1; +/// How much mass for every spk byte +const MASS_PER_SCRIPT_PUB_KEY_BYTE: u64 = 10; + +/// How much mass per signature operation +const MASS_PER_SIG_OP: u64 = 1000; + +/// The signature script size for schnorr sig +const SCHNORR_SIG_SCRIPT_SIZE: u64 = 66; + +/// Maximum fee rate +const MAX_FEERATE: f64 = 100000.0; + +/// Compute the fee from specified mass +fn fee_from_mass(mass: u64, feerate: f64) -> u64 { + ((mass as f64 * feerate).ceil() as u64).max(1) +} + +/// Calculate the priority fee based on the transaction +pub(super) async fn calculate_priority_fee( + client: &Arc, + tx: &Transaction, + extra_sig_script_bytes: u64, +) -> Result { + // Retrieve the fee estimate from the rpc + let fee_estimate = retry_timed("get_fee_estimate", || client.get_fee_estimate()) + .await + .ok_or_else(|| KaspaError::Other("get_fee_estimate: timed out".into()))?; + // We only work with priority buckets + let feerate = fee_estimate.priority_bucket.feerate; + if !feerate.is_finite() || feerate < 0.0 { + return Err(KaspaError::Other(format!("invalid rpc feerate {feerate}"))); + } + + // Take the smallest of fee rate or maximum + let feerate = feerate.min(MAX_FEERATE); + + // Instantiate a mass calculator with our configured constants + let mass_calc = MassCalculator::new( + MASS_PER_TX_BYTE, + MASS_PER_SCRIPT_PUB_KEY_BYTE, + MASS_PER_SIG_OP, + STORAGE_MASS_PARAMETER, + ); + + // Compute the non contextual masses on the tx + let non_contextual = mass_calc.calc_non_contextual_masses(tx); + + // Compute the signature bytes based on how many inputs + let schnorr_sig_bytes: u64 = tx + .inputs + .iter() + .filter(|input| input.sig_op_count > 0) + .count() as u64 + * SCHNORR_SIG_SCRIPT_SIZE; + + // Compute the total signature bytes that we have + let total_sig_bytes = schnorr_sig_bytes + extra_sig_script_bytes; + + // Compute the compute mass + let compute_mass = non_contextual.compute_mass + total_sig_bytes * MASS_PER_TX_BYTE; + + // Compute transient bytes to mass factor + let transient_mass = + non_contextual.transient_mass + total_sig_bytes * TRANSIENT_BYTE_TO_MASS_FACTOR; + + // The mass is whatever is larger between compute and transient mass + let mass = compute_mass.max(transient_mass); + + // Compute the fee from the mass and return + Ok(fee_from_mass(mass, feerate)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fee_is_at_least_one() { + assert_eq!(fee_from_mass(0, 0.0), 1); + assert_eq!(fee_from_mass(100, 0.0), 1); + } + + #[test] + fn fee_rounds_up() { + assert_eq!(fee_from_mass(10, 1.5), 15); + assert_eq!(fee_from_mass(3, 1.4), 5); + } +} diff --git a/crates/data/src/chains/kaspa/broadcast/htlc.rs b/crates/data/src/chains/kaspa/broadcast/htlc.rs new file mode 100644 index 0000000..fcd2724 --- /dev/null +++ b/crates/data/src/chains/kaspa/broadcast/htlc.rs @@ -0,0 +1,72 @@ +use kaspa_addresses::Address; +use kaspa_consensus_core::tx::ScriptPublicKey; +use kaspa_txscript::pay_to_address_script; +use stroemnet_protocol::v1::CommitmentV1; + +use super::super::contracts::create_htlc_script; +use super::super::error::{Result, script_err}; +use super::utxo::spk_to_vec; + +/// An announcement of an address and its associated redeem script +pub(crate) struct Announce { + pub address: String, + pub redeem_script: Vec, +} + +/// Converts a commitmentv1 into a kaspa canonical utxo script +pub(super) fn htlc_script_from_commitment( + commitment: &CommitmentV1, +) -> Result<(Vec, ScriptPublicKey, ScriptPublicKey, u64)> { + // Conver the unlock timestamp to milliseconds the OpDaaScore opcode uses millis + let unlock_ts_ms = commitment.unlock_ts.saturating_mul(1000); + + // Conver the sender to spk + let sender_spk = + pay_to_address_script(&Address::try_from(commitment.addresses.sender.clone())?); + + // Conver the receiver to spk + let receiver_spk = + pay_to_address_script(&Address::try_from(commitment.addresses.receiver.clone())?); + + // Create the htlc script based on the arguments + let htlc_script = create_htlc_script( + &spk_to_vec(&sender_spk), + commitment.addresses.sender_destination.as_bytes(), + &spk_to_vec(&receiver_spk), + &commitment.secret_hash, + unlock_ts_ms, + commitment.destination, + commitment.swap_id, + ) + .map_err(script_err)?; + + // Return the htlc script, sender,receiver spk and unlock timestamp in millis + Ok((htlc_script, sender_spk, receiver_spk, unlock_ts_ms)) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + use kaspa_addresses::{Prefix, Version}; + use stroemnet_protocol::ChannelId; + use stroemnet_protocol::v1::{AddressesV1, AmountV1}; + + #[test] + fn builds_non_empty_script_and_scales_timelock() { + let sender = Address::new(Prefix::Testnet, Version::PubKey, &[1u8; 32]).to_string(); + let receiver = Address::new(Prefix::Testnet, Version::PubKey, &[2u8; 32]).to_string(); + let commitment = CommitmentV1 { + swap_id: [3u8; 32], + addresses: AddressesV1::new(sender, receiver, "0xdest".into()), + amount: AmountV1::new("1".into(), 8), + secret_hash: [4u8; 32], + unlock_ts: 1000, + source: ChannelId::KaspaTn10 as u8, + destination: ChannelId::EthereumSepolia as u8, + }; + let (script, _s, _r, unlock_ts_ms) = htlc_script_from_commitment(&commitment).unwrap(); + assert!(!script.is_empty()); + assert_eq!(unlock_ts_ms, 1_000_000); + } +} diff --git a/crates/data/src/chains/kaspa/broadcast/mod.rs b/crates/data/src/chains/kaspa/broadcast/mod.rs new file mode 100644 index 0000000..c28128c --- /dev/null +++ b/crates/data/src/chains/kaspa/broadcast/mod.rs @@ -0,0 +1,23 @@ +mod commit; +mod fee; +mod htlc; +#[cfg(not(target_arch = "wasm32"))] +mod prepare; +#[cfg(not(target_arch = "wasm32"))] +mod refund; +#[cfg(not(target_arch = "wasm32"))] +mod reveal; +mod signer; +#[cfg(not(target_arch = "wasm32"))] +mod spend; +#[cfg(not(target_arch = "wasm32"))] +mod txbuild; +mod utxo; + +pub(crate) use commit::submit_commitment; +#[cfg(not(target_arch = "wasm32"))] +pub(crate) use refund::submit_refund; +#[cfg(not(target_arch = "wasm32"))] +pub(crate) use reveal::submit_reveal; + +pub(crate) use utxo::spk_to_vec; diff --git a/crates/data/src/chains/kaspa/broadcast/prepare.rs b/crates/data/src/chains/kaspa/broadcast/prepare.rs new file mode 100644 index 0000000..8aa20c1 --- /dev/null +++ b/crates/data/src/chains/kaspa/broadcast/prepare.rs @@ -0,0 +1,97 @@ +use std::sync::Arc; + +use kaspa_consensus_core::tx::ScriptPublicKey; +use kaspa_rpc_core::RpcUtxosByAddressesEntry; +use kaspa_rpc_core::api::rpc::RpcApi; +use kaspa_txscript::{extract_script_pub_key_address, pay_to_script_hash_script}; +use kaspa_wrpc_client::KaspaRpcClient; +use stroemnet_protocol::v1::CommitmentV1; + +use super::super::error::{KaspaError, Result}; +use super::htlc::htlc_script_from_commitment; +use super::signer::Signer340; +use super::utxo::{prefix_for, utxo_is_mature}; +use crate::chains::net::retry_timed; + +/// A struct organizing the expenditure of an HTLC +/// that is ready to be spend onchain +pub(super) struct HtlcSpend { + /// The signer who is spending this HTLC + pub signer: Signer340, + /// The HTLC script to be spent + pub htlc_script: Vec, + /// The senders spk + pub sender_spk: ScriptPublicKey, + /// The receivers spk + pub receiver_spk: ScriptPublicKey, + /// The timestamp at which the htlc is refundable + pub unlock_ts_ms: u64, + /// All the UTXOs locked to this particular HTLC + pub htlc_utxos: Vec, + /// The fee utxos that are to be used to spend this htlc + pub fee_utxo: RpcUtxosByAddressesEntry, +} + +/// Prepared the htlc to be spent onchain +pub(super) async fn prepare_htlc_spend( + client: &Arc, // the kaspa rpc client + private_key: &str, // private keyof signer + coinbase_maturity: u64, // how many daa score a coinbase utxo has to be matured + commitment: &CommitmentV1, // commitment of the htlc +) -> Result { + // Compute the prefix for this network + let prefix = prefix_for(client).await?; + + // Derive the signer + let signer = Signer340::derive(private_key, prefix)?; + + // Compute the htlc script from the provided commitment + let (htlc_script, sender_spk, receiver_spk, unlock_ts_ms) = + htlc_script_from_commitment(commitment)?; + + // The htlc spk + let htlc_spk = pay_to_script_hash_script(&htlc_script); + + // The htlc p2sh address + let htlc_address = extract_script_pub_key_address(&htlc_spk, prefix)?; + + // Retrieve all the utxos locked with this p2sh address + let htlc_utxos = retry_timed("get_utxos htlc", || { + client.get_utxos_by_addresses(vec![htlc_address.clone()]) + }) + .await + .ok_or_else(|| KaspaError::Other("get_utxos htlc: timed out".into()))?; + if htlc_utxos.is_empty() { + return Err(KaspaError::HtlcUtxoNotFound(commitment.swap_id)); + } + + // Retrieve the signers utxos that will be used to pay transaction fees + let our_utxos = retry_timed("get_utxos self", || { + client.get_utxos_by_addresses(vec![signer.address()]) + }) + .await + .ok_or_else(|| KaspaError::Other("get_utxos self: timed out".into()))?; + + // Retrieve the dag info so that we can know the daa score of this node + let dag_info = retry_timed("get_block_dag_info", || client.get_block_dag_info()) + .await + .ok_or_else(|| KaspaError::Other("get_block_dag_info: timed out".into()))?; + let current_daa = dag_info.virtual_daa_score; + + // Retrieve all the fee utxos that are mature to be used for fee subsidy + let fee_utxo = our_utxos + .iter() + .find(|u| utxo_is_mature(u, coinbase_maturity, current_daa)) + .ok_or(KaspaError::NoUtxos)? + .clone(); + + Ok(HtlcSpend { + signer, + htlc_script, + sender_spk, + receiver_spk, + unlock_ts_ms, + htlc_utxos, + fee_utxo, + }) +} diff --git a/crates/data/src/chains/kaspa/broadcast/refund.rs b/crates/data/src/chains/kaspa/broadcast/refund.rs new file mode 100644 index 0000000..b0acd5c --- /dev/null +++ b/crates/data/src/chains/kaspa/broadcast/refund.rs @@ -0,0 +1,43 @@ +use std::sync::Arc; + +use kaspa_txscript::{opcodes::codes::OpFalse, script_builder::ScriptBuilder}; +use kaspa_wrpc_client::KaspaRpcClient; +use stroemnet_protocol::v1::CommitmentV1; + +use super::super::error::{Result, script_err}; +use super::prepare::prepare_htlc_spend; +use super::spend::{SpendParams, submit_htlc_spend}; + +/// Submit the refund of a htlc leg on kaspa +pub(crate) async fn submit_refund( + client: &Arc, // the kaspa rpc client + private_key: &str, // the private key of signer + coinbase_maturity: u64, // how many daa score to wait until a miner utxo is spendable + commitment: &CommitmentV1, // the commitment for the htlc swap leg +) -> Result<()> { + // Prepare the htlc for spending + let ctx = prepare_htlc_spend(client, private_key, coinbase_maturity, commitment).await?; + + // Create the calldata for executing the htlc branch to refund the swap + let branch_sig_script = ScriptBuilder::new() + .add_op(OpFalse) + .map_err(script_err)? + .add_data(&ctx.htlc_script) + .map_err(script_err)? + .drain(); + + // Submit the htlc to be spent + // we have already encoded the sig script so its a generic dispatch fn + submit_htlc_spend( + client, + &ctx, + SpendParams { + dest_spk: &ctx.sender_spk, + lock_time: ctx.unlock_ts_ms, + extra_sig_bytes: 260, + branch_sig_script, + log_label: "refund", + }, + ) + .await +} diff --git a/crates/data/src/chains/kaspa/broadcast/reveal.rs b/crates/data/src/chains/kaspa/broadcast/reveal.rs new file mode 100644 index 0000000..da5fe28 --- /dev/null +++ b/crates/data/src/chains/kaspa/broadcast/reveal.rs @@ -0,0 +1,45 @@ +use std::sync::Arc; + +use kaspa_txscript::{opcodes::codes::OpTrue, script_builder::ScriptBuilder}; +use kaspa_wrpc_client::KaspaRpcClient; +use stroemnet_protocol::v1::{CommitmentV1, RevealV1}; + +use super::super::error::{Result, script_err}; +use super::prepare::prepare_htlc_spend; +use super::spend::{SpendParams, submit_htlc_spend}; + +/// Submit a reveal across the kaspa network effectively claiming the swap +pub(crate) async fn submit_reveal( + client: &Arc, // the kaspa rpc client + private_key: &str, // the private key of signer + coinbase_maturity: u64, // how many daa to wait until the miner utxo is spendable + commitment: &CommitmentV1, // the initial commitment for this chain + reveal: &RevealV1, // the reveal details +) -> Result<()> { + // prepare the commitment for spending + let ctx = prepare_htlc_spend(client, private_key, coinbase_maturity, commitment).await?; + + // Prepare the calldata executing the claim branch and providing the secret of the htlc hash + let branch_sig_script = ScriptBuilder::new() + .add_data(&reveal.secret) + .map_err(script_err)? + .add_op(OpTrue) + .map_err(script_err)? + .add_data(&ctx.htlc_script) + .map_err(script_err)? + .drain(); + + // Submit the htlc spend onchain + submit_htlc_spend( + client, + &ctx, + SpendParams { + dest_spk: &ctx.receiver_spk, + lock_time: 0, + extra_sig_bytes: 300, + branch_sig_script, + log_label: "CCR reveal", + }, + ) + .await +} diff --git a/crates/data/src/chains/kaspa/broadcast/signer.rs b/crates/data/src/chains/kaspa/broadcast/signer.rs new file mode 100644 index 0000000..7b13e69 --- /dev/null +++ b/crates/data/src/chains/kaspa/broadcast/signer.rs @@ -0,0 +1,133 @@ +use k256::schnorr::SigningKey; +use k256::schnorr::signature::hazmat::PrehashSigner; +use kaspa_addresses::{Address, Prefix, Version}; +use kaspa_consensus_core::hashing::sighash::{ + SigHashReusedValuesUnsync, calc_schnorr_signature_hash, +}; +use kaspa_consensus_core::hashing::sighash_type::SIG_HASH_ALL; +use kaspa_consensus_core::tx::{MutableTransaction, ScriptPublicKey, Transaction}; +use kaspa_txscript::{pay_to_address_script, script_builder::ScriptBuilder}; + +use super::super::error::{KaspaError, Result, script_err}; +use super::super::signing::{pubkey_bytes, signing_key}; + +/// A signer structure for the Kaspa channel +pub(super) struct Signer340 { + key: SigningKey, + pubkey: [u8; 32], + prefix: Prefix, +} + +impl Signer340 { + /// Derive from a provided private key and prefix + pub(super) fn derive(private_key: &str, prefix: Prefix) -> Result { + let key = signing_key(private_key)?; + let pubkey = pubkey_bytes(&key)?; + Ok(Self { + key, + pubkey, + prefix, + }) + } + + /// Compute the address of the signer, taking into account the prefix + pub(super) fn address(&self) -> Address { + Address::new(self.prefix, Version::PubKey, &self.pubkey) + } + + /// Convert the signer to a script public key + pub(super) fn spk(&self) -> ScriptPublicKey { + pay_to_address_script(&self.address()) + } + + /// Sign the input of some mutable transaction at a specified index + pub(super) fn sign_input( + &self, + mutable_tx: &MutableTransaction, + index: usize, + ) -> Result> { + let reused_values = SigHashReusedValuesUnsync::new(); + + // Compute the signature hash + let sig_hash = calc_schnorr_signature_hash( + &mutable_tx.as_verifiable(), + index, + SIG_HASH_ALL, + &reused_values, + ); + // Sign the hash via k256 + let sig: k256::schnorr::Signature = + self.key + .sign_prehash(sig_hash.as_bytes().as_slice()) + .map_err(|e| KaspaError::Other(format!("schnorr sign: {e}")))?; + let mut signature = Vec::with_capacity(65); + // Extend the signature + signature.extend_from_slice(&sig.to_bytes()); + // the signature commits to all inputs and outputs, + // any change to them will invalidate the sig + signature.push(SIG_HASH_ALL.to_u8()); + + // Push the signature as a script and return it + Ok(ScriptBuilder::new() + .add_data(&signature) + .map_err(script_err)? + .drain()) + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)] + use super::*; + use kaspa_consensus_core::subnets::SUBNETWORK_ID_NATIVE; + use kaspa_consensus_core::tx::{ + TransactionInput, TransactionOutpoint, TransactionOutput, UtxoEntry, VerifiableTransaction, + }; + use kaspa_hashes::Hash; + use kaspa_txscript::{TxScriptEngine, caches::Cache}; + + #[test] + fn production_signer_p2pk_input_verifies_on_engine() { + let signer = Signer340::derive( + "1111111111111111111111111111111111111111111111111111111111111111", + Prefix::Testnet, + ) + .unwrap(); + let spk = signer.spk(); + let input_value = 1_000_000u64; + let input = TransactionInput { + previous_outpoint: TransactionOutpoint::new(Hash::from_u64_word(1), 0), + signature_script: vec![], + sequence: 0, + sig_op_count: 1, + }; + let output = TransactionOutput::new(input_value - 1_000, spk.clone()); + let tx = Transaction::new( + 0, + vec![input], + vec![output], + 0, + SUBNETWORK_ID_NATIVE, + 0, + vec![], + ); + let entry = UtxoEntry::new(input_value, spk.clone(), 0, false); + let mut mutable_tx = MutableTransaction::with_entries(tx, vec![entry]); + mutable_tx.tx.inputs[0].signature_script = signer.sign_input(&mutable_tx, 0).unwrap(); + + let reused = SigHashReusedValuesUnsync::new(); + let verifiable = mutable_tx.as_verifiable(); + let sig_cache = Cache::new(10_000); + let utxo_entry = verifiable.utxo(0).unwrap().clone(); + let mut vm = TxScriptEngine::from_transaction_input( + &verifiable, + &verifiable.inputs()[0], + 0, + &utxo_entry, + &reused, + &sig_cache, + ); + vm.execute() + .expect("production-signed P2PK input must satisfy CHECKSIG"); + } +} diff --git a/crates/data/src/chains/kaspa/broadcast/spend.rs b/crates/data/src/chains/kaspa/broadcast/spend.rs new file mode 100644 index 0000000..7919d22 --- /dev/null +++ b/crates/data/src/chains/kaspa/broadcast/spend.rs @@ -0,0 +1,134 @@ +use std::sync::Arc; + +use kaspa_consensus_core::subnets::SUBNETWORK_ID_NATIVE; +use kaspa_consensus_core::tx::{MutableTransaction, ScriptPublicKey, Transaction}; +use kaspa_rpc_core::api::rpc::RpcApi; +use kaspa_wrpc_client::KaspaRpcClient; + +use super::super::contracts::SOLVER_REWARD; +use super::super::error::{KaspaError, Result}; +use super::fee::calculate_priority_fee; +use super::prepare::HtlcSpend; +use super::txbuild::{spend_inputs, spend_outputs}; +use super::utxo::rpc_utxo_to_entry; +use crate::chains::net::{NETWORK_TIMEOUT, timed}; + +const MIN_REWARD_OUTPUT_SOMPI: u64 = 10_000; + +/// The parameters needed in order to spend an htlc +pub(super) struct SpendParams<'a> { + /// Destination spk what we are spending + pub dest_spk: &'a ScriptPublicKey, + /// The lock time for the transaction + pub lock_time: u64, + /// The extra sig bytes to account for non standard sig script + pub extra_sig_bytes: u64, + /// The sig script to execute the wanted branch of the htlc contract + pub branch_sig_script: Vec, + /// Label for logs + pub log_label: &'a str, +} + +/// Submit a generic htlc spending across the kaspa network +pub(super) async fn submit_htlc_spend( + client: &Arc, // the kaspa rpc client + ctx: &HtlcSpend, // needed context for spending + params: SpendParams<'_>, // spending parameters (which branch to exec and so forth) +) -> Result<()> { + // retrieve our spk + let our_spk = ctx.signer.spk(); + + // We need to spend all htlc utxos on their own + // the htlc contract has strict requirements for inputs and output for safety purposes + for utxo in ctx.htlc_utxos.iter() { + // Compute the destination amount which is the htlc value - solver reward + let dest_amount = utxo + .utxo_entry + .amount + .checked_sub(SOLVER_REWARD as u64) + .ok_or(KaspaError::InsufficientFunds { + needed: SOLVER_REWARD as u64, + available: utxo.utxo_entry.amount, + })?; + + // Compute the overall usable capital including the solver reward + let reward_pre = (SOLVER_REWARD as u64) + .checked_add(ctx.fee_utxo.utxo_entry.amount) + .ok_or_else(|| KaspaError::Other("Solver reward + fee UTXO overflow".to_string()))?; + + // Compute the tx inputs + let inputs = spend_inputs(utxo, &ctx.fee_utxo); + + // Create the transaction + let prelim = Transaction::new( + 0, + inputs.clone(), + spend_outputs(dest_amount, reward_pre, params.dest_spk, &our_spk), // create the outputs + params.lock_time, + SUBNETWORK_ID_NATIVE, + 0, + vec![], + ); + // Compute the fee + let fee = calculate_priority_fee(client, &prelim, params.extra_sig_bytes).await?; + + // Compute the final reward after accounting for the fee + let reward = reward_pre + .checked_sub(fee) + .ok_or(KaspaError::InsufficientFunds { + needed: fee, + available: reward_pre, + })?; + + // If the fee is below 10k sompi its not viable to fulfill this swap + if reward < MIN_REWARD_OUTPUT_SOMPI { + return Err(KaspaError::InsufficientFunds { + needed: MIN_REWARD_OUTPUT_SOMPI, + available: reward, + }); + } + + // Create a finalized transaction with the outputs + let tx = Transaction::new( + 0, + inputs, + spend_outputs(dest_amount, reward, params.dest_spk, &our_spk), + params.lock_time, + SUBNETWORK_ID_NATIVE, + 0, + vec![], + ); + + // Compute the entries of the utxos + let entries = vec![rpc_utxo_to_entry(utxo), rpc_utxo_to_entry(&ctx.fee_utxo)]; + + // Create a mutable transaction with those entries + let mut mtx = MutableTransaction::with_entries(tx, entries); + + // Sign the fee utxo which is always at index 1 + let fee_sig = ctx.signer.sign_input(&mtx, 1)?; + + // Ensure that we only have two inputs + match mtx.tx.inputs.as_mut_slice() { + [htlc_in, fee_in] => { + htlc_in.signature_script = params.branch_sig_script.clone(); + fee_in.signature_script = fee_sig; + } + _ => { + return Err(KaspaError::Other( + "htlc spend tx must have exactly 2 inputs".into(), + )); + } + } + + // Conver the tx into finalized rpc transaction + let rpc_tx = (&mtx.tx).into(); + + // Submit the transaction across the network with a timeout. + let tx_id = timed(NETWORK_TIMEOUT, client.submit_transaction(rpc_tx, false)) + .await + .ok_or_else(|| KaspaError::Other("submit_transaction: timed out".into()))??; + tracing::info!("Kaspa {} submitted: txid {tx_id}", params.log_label); + } + Ok(()) +} diff --git a/crates/data/src/chains/kaspa/broadcast/txbuild.rs b/crates/data/src/chains/kaspa/broadcast/txbuild.rs new file mode 100644 index 0000000..8e3a633 --- /dev/null +++ b/crates/data/src/chains/kaspa/broadcast/txbuild.rs @@ -0,0 +1,79 @@ +use kaspa_consensus_core::tx::{ + ScriptPublicKey, TransactionInput, TransactionOutpoint, TransactionOutput, +}; +use kaspa_rpc_core::RpcUtxosByAddressesEntry; + +/// Convert rpc utxo entries into transaction inputs +pub(super) fn spend_inputs( + htlc_utxo: &RpcUtxosByAddressesEntry, + fee_utxo: &RpcUtxosByAddressesEntry, +) -> Vec { + vec![ + TransactionInput { + previous_outpoint: TransactionOutpoint::new( + htlc_utxo.outpoint.transaction_id, + htlc_utxo.outpoint.index, + ), + signature_script: vec![], + sequence: 0, + sig_op_count: 0, + }, + TransactionInput { + previous_outpoint: TransactionOutpoint::new( + fee_utxo.outpoint.transaction_id, + fee_utxo.outpoint.index, + ), + signature_script: vec![], + sequence: 0, + sig_op_count: 1, + }, + ] +} + +/// Create outputs based on the destination amount to the receiver of the swap +/// and the reward which is to us as CCR fulfillers. +pub(super) fn spend_outputs( + dest_amount: u64, + reward: u64, + dest_spk: &ScriptPublicKey, + our_spk: &ScriptPublicKey, +) -> Vec { + vec![ + TransactionOutput::new(dest_amount, dest_spk.clone()), + TransactionOutput::new(reward, our_spk.clone()), + ] +} + +#[cfg(test)] +mod tests { + #![allow(clippy::indexing_slicing)] + use super::*; + use kaspa_hashes::Hash; + use kaspa_rpc_core::{RpcTransactionOutpoint, RpcUtxoEntry}; + + fn entry() -> RpcUtxosByAddressesEntry { + RpcUtxosByAddressesEntry { + address: None, + outpoint: RpcTransactionOutpoint { + transaction_id: Hash::from_u64_word(1), + index: 0, + }, + utxo_entry: RpcUtxoEntry { + amount: 1, + script_public_key: ScriptPublicKey::new(0, vec![].into()), + block_daa_score: 0, + is_coinbase: false, + }, + } + } + + #[test] + fn spend_inputs_assigns_sequences_and_sigops() { + let inputs = spend_inputs(&entry(), &entry()); + assert_eq!(inputs.len(), 2); + assert_eq!(inputs[0].sequence, 0); + assert_eq!(inputs[0].sig_op_count, 0); + assert_eq!(inputs[1].sequence, 0); + assert_eq!(inputs[1].sig_op_count, 1); + } +} diff --git a/crates/data/src/chains/kaspa/broadcast/utxo.rs b/crates/data/src/chains/kaspa/broadcast/utxo.rs new file mode 100644 index 0000000..27b81a0 --- /dev/null +++ b/crates/data/src/chains/kaspa/broadcast/utxo.rs @@ -0,0 +1,155 @@ +use std::sync::Arc; + +use kaspa_addresses::Prefix; +use kaspa_consensus_core::tx::{ScriptPublicKey, TransactionInput, TransactionOutpoint, UtxoEntry}; +use kaspa_rpc_core::RpcUtxosByAddressesEntry; +use kaspa_rpc_core::api::rpc::RpcApi; +use kaspa_wrpc_client::KaspaRpcClient; + +use super::super::error::{KaspaError, Result}; +use crate::chains::net::retry_timed; + +/// Compute the prefix for the connected kaspa network +pub(super) async fn prefix_for(client: &Arc) -> Result { + let info = retry_timed("get_server_info", || client.get_server_info()) + .await + .ok_or_else(|| KaspaError::Other("get_server_info: timed out".into()))?; + Ok(info.network_id.network_type.into()) +} + +/// Conver the spk into vector serialized format +pub(crate) fn spk_to_vec(spk: &ScriptPublicKey) -> Vec { + let mut v = Vec::with_capacity(2 + spk.script().len()); + v.extend_from_slice(&spk.version.to_be_bytes()); + v.extend_from_slice(spk.script()); + v +} + +/// Convert rpc utxo entry to a consensus utxo entry +pub(super) fn rpc_utxo_to_entry(u: &RpcUtxosByAddressesEntry) -> UtxoEntry { + UtxoEntry::new( + u.utxo_entry.amount, + ScriptPublicKey::new( + u.utxo_entry.script_public_key.version, + u.utxo_entry.script_public_key.script().into(), + ), + u.utxo_entry.block_daa_score, + u.utxo_entry.is_coinbase, + ) +} + +/// Compute whether a utxo is mature to be spent +pub(super) fn utxo_is_mature( + utxo: &RpcUtxosByAddressesEntry, + coinbase_maturity: u64, + current_daa: u64, +) -> bool { + !utxo.utxo_entry.is_coinbase + || utxo.utxo_entry.block_daa_score + coinbase_maturity <= current_daa +} + +/// Select which utxos can be used for subsidizing the transaction fee +pub(super) fn select_funding_utxos( + utxos: Vec, // the candidate utxos + amount: u64, // amount needed to cover + coinbase_maturity: u64, // how many daa to wait + current_daa: u64, // the current daa score +) -> Result<(Vec, u64)> { + let mut selected = Vec::new(); + let mut total: u64 = 0; + + // Go over all the utxos + for utxo in utxos { + // If the utxo is not mature due to being a fresh miner utxo we cant use it + if !utxo_is_mature(&utxo, coinbase_maturity, current_daa) { + continue; + } + + // Add the utxos value the total + total += utxo.utxo_entry.amount; + + // Push this utxo as selected + selected.push(utxo); + + // If the total exceeds the required amount we can break + if total >= amount { + break; + } + } + + // If total is not enough then we error + if total < amount { + return Err(KaspaError::InsufficientFunds { + needed: amount, + available: total, + }); + } + Ok((selected, total)) +} + +pub(super) fn to_inputs(utxos: &[RpcUtxosByAddressesEntry]) -> Vec { + utxos + .iter() + .enumerate() + .map(|(seq, utxo)| TransactionInput { + previous_outpoint: TransactionOutpoint::new( + utxo.outpoint.transaction_id, + utxo.outpoint.index, + ), + signature_script: vec![], + sequence: seq as u64, + sig_op_count: 1, + }) + .collect() +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + use kaspa_hashes::Hash; + use kaspa_rpc_core::{RpcTransactionOutpoint, RpcUtxoEntry}; + + fn entry(amount: u64, daa: u64, coinbase: bool) -> RpcUtxosByAddressesEntry { + RpcUtxosByAddressesEntry { + address: None, + outpoint: RpcTransactionOutpoint { + transaction_id: Hash::from_u64_word(1), + index: 0, + }, + utxo_entry: RpcUtxoEntry { + amount, + script_public_key: ScriptPublicKey::new(0, vec![].into()), + block_daa_score: daa, + is_coinbase: coinbase, + }, + } + } + + #[test] + fn coinbase_maturity_respected() { + assert!(!utxo_is_mature(&entry(1, 100, true), 50, 120)); + assert!(utxo_is_mature(&entry(1, 100, true), 50, 150)); + assert!(utxo_is_mature(&entry(1, 100, false), 50, 0)); + } + + #[test] + fn spk_to_vec_prepends_version() { + let spk = ScriptPublicKey::new(0, vec![0xaa, 0xbb].into()); + assert_eq!(spk_to_vec(&spk), vec![0, 0, 0xaa, 0xbb]); + } + + #[test] + fn selection_accumulates_until_target() { + let (sel, total) = + select_funding_utxos(vec![entry(40, 0, false), entry(70, 0, false)], 100, 0, 0) + .unwrap(); + assert_eq!(sel.len(), 2); + assert_eq!(total, 110); + } + + #[test] + fn selection_fails_when_insufficient() { + assert!(select_funding_utxos(vec![entry(10, 0, false)], 100, 0, 0).is_err()); + } +} diff --git a/crates/data/src/chains/kaspa/buffer.rs b/crates/data/src/chains/kaspa/buffer.rs new file mode 100644 index 0000000..bedd1c9 --- /dev/null +++ b/crates/data/src/chains/kaspa/buffer.rs @@ -0,0 +1,96 @@ +use stroemnet_protocol::ChannelId; +use stroemnet_protocol::v1::{ChainEvent, CommitmentV1}; + +use super::Kaspa; +use super::signing; +#[cfg(not(target_arch = "wasm32"))] +use crate::TaskFut; +use crate::{ + BufFut, ChainDataBuffer, DataError, ProposalVerification, Result, ScriptAnnouncement, + UtxoScriptDetector, +}; +#[cfg(not(target_arch = "wasm32"))] +use std::sync::Arc; + +impl ChainDataBuffer for Kaspa { + /// Compute the lp addressa from the private key + fn lp_address(&self) -> Result { + Ok(signing::lp_address_from_private_key( + &self.network_id, + self.key()?, + )?) + } + + #[cfg(not(target_arch = "wasm32"))] + /// Retrieve the settler task which settles claims and refunds + fn settler_task(self: Arc) -> Option { + let metrics = self.metrics.clone(); + Some(crate::chains::settlement::settler_loop(self, metrics)) + } + + /// Compute the deposit address for some commitment + fn derive_deposit(&self, commitment: &CommitmentV1) -> Result<(String, Vec)> { + Ok(signing::p2sh_components(&self.network_id, commitment)?) + } + + /// Retrieve the next chunk of finalized confirmed events from the chain + fn finalized_chunk(&self) -> BufFut<'_, Vec<(ChannelId, ChainEvent)>> { + Box::pin(self.poll_finalized()) + } + + /// Broadcast a chain event across the kaspa network + fn broadcast_event<'a>(&'a self, event: &'a ChainEvent) -> BufFut<'a, ()> { + Box::pin(self.emit_event(event)) + } + + /// Sign a message whilst also requiring a minimum amount of balance + fn sign_message<'a>( + &'a self, + digest: [u8; 32], + required_balance: &'a str, + ) -> BufFut<'a, (String, Vec)> { + Box::pin(async move { + let required: u64 = required_balance + .parse() + .map_err(|e| DataError::Sign(format!("required_balance: {e}")))?; + signing::sign_message( + &self.client, + &self.network_id, + self.key()?, + digest, + required, + ) + .await + .map_err(DataError::from) + }) + } + + /// Verifies a message signature whilst also requiring a minimum amount of balance + /// in order to fulfill the swap. + fn verify_message<'a>( + &'a self, + digest: [u8; 32], + claimed_address: &'a str, + signature: &'a [u8], + required_balance: &'a str, + ) -> BufFut<'a, ProposalVerification> { + Box::pin(async move { + let required: u64 = required_balance + .parse() + .map_err(|e| DataError::Sign(format!("required_balance: {e}")))?; + signing::verify_message(&self.client, digest, claimed_address, signature, required) + .await + .map_err(DataError::from) + }) + } + + /// Retrieve the utxo script detector + fn utxo_script_detector(&self) -> Option<&dyn UtxoScriptDetector> { + Some(self) + } + + /// Retrieve the utxo script announcements + fn take_utxo_script_announcements(&self) -> Vec { + std::mem::take(&mut self.announcements.lock()) + } +} diff --git a/crates/data/src/chains/kaspa/client.rs b/crates/data/src/chains/kaspa/client.rs new file mode 100644 index 0000000..0176204 --- /dev/null +++ b/crates/data/src/chains/kaspa/client.rs @@ -0,0 +1,71 @@ +use std::sync::Arc; + +use kaspa_hashes::Hash; +use kaspa_wrpc_client::prelude::{NetworkId, RpcBlock}; +use kaspa_wrpc_client::{KaspaRpcClient, Resolver, WrpcEncoding}; +use stroemnet_protocol::ChannelId; +use tokio::sync::mpsc::Receiver; + +use super::intake::Intake; +use crate::{CursorStore, DataError, Result}; + +/// Builds a kaspa rpc client +pub(super) async fn build_client( + network_id: NetworkId, + wrpc_url: Option<&str>, +) -> Result> { + // If we have an rpc url we wont use the resolver + let resolver = match wrpc_url { + Some(_) => None, + None => Some(Resolver::default()), + }; + // Create an arced kaspa rpc client + let client = Arc::new( + KaspaRpcClient::new( + WrpcEncoding::Borsh, + wrpc_url, + resolver, + Some(network_id), + None, + ) + .map_err(|e| DataError::Connect(format!("wrpc client: {e}")))?, + ); + + // Connect the client to the rpc + client + .connect(None) + .await + .map_err(|e| DataError::Connect(format!("kaspa connect: {e}")))?; + Ok(client) +} + +/// Spawns the kaspa rpc intake on another task +pub(super) fn spawn_intake( + client: Arc, + minimum_block_confirmations: u64, + channel_id: ChannelId, + initial_cursor: Option, + cursor_store: Option>, +) -> Receiver> { + // Create the tx and rx channels + let (tx, rx) = tokio::sync::mpsc::channel::>(1024); + + // Create the reader + let mut reader = Intake::new( + client, + tx, + minimum_block_confirmations, + channel_id, + initial_cursor, + cursor_store, + ); + // Spawn the reader on a new task + stroemnet_protocol::spawn(async move { + if let Err(e) = reader.read().await { + tracing::error!("kaspa intake loop terminated: {e}"); + } + }); + + // return the receiver so that another task can consumer confirmed blocks + rx +} diff --git a/crates/data/src/chains/kaspa/config.rs b/crates/data/src/chains/kaspa/config.rs new file mode 100644 index 0000000..25df8c0 --- /dev/null +++ b/crates/data/src/chains/kaspa/config.rs @@ -0,0 +1,76 @@ +use serde::Deserialize; + +/// Minimum coinbase maturity +const DEFAULT_COINBASE_MATURITY: u64 = 1000; +/// Minimum block confirmations +const DEFAULT_MINIMUM_BLOCK_CONFIRMATIONS: u64 = 10 * (60 * 10); +/// Amount of time a script is valid for +const DEFAULT_SCRIPT_TTL_SECS: u64 = 4 * 60 * 60; + +#[derive(Deserialize)] +/// The kaspa channel config +pub(super) struct KaspaConfig { + #[serde(default)] + /// Rpc url to connect to + pub wrpc_url: Option, + /// The kaspa specific network id + pub network_id: String, + #[serde(default = "default_min_confirmations")] + /// minimum amount of block confirmations to consider a block finalized + pub minimum_block_confirmations: u64, + #[serde(default = "default_coinbase_maturity")] + /// amount of daa score to wait for miner utxo to be valid + pub coinbase_maturity: u64, + #[serde(default = "default_script_ttl_secs")] + /// how long to keep announced utxo scripts for until they are invalid + pub script_ttl_secs: u64, + #[serde(default)] + /// whether to participate in ccr and earn ccr rewards + pub participate_ccr: bool, +} + +fn default_min_confirmations() -> u64 { + DEFAULT_MINIMUM_BLOCK_CONFIRMATIONS +} + +fn default_coinbase_maturity() -> u64 { + DEFAULT_COINBASE_MATURITY +} + +fn default_script_ttl_secs() -> u64 { + DEFAULT_SCRIPT_TTL_SECS +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + + #[test] + fn applies_defaults_when_absent() { + let cfg: KaspaConfig = serde_json::from_value(serde_json::json!({ + "network_id": "testnet-10" + })) + .unwrap(); + assert_eq!(cfg.coinbase_maturity, DEFAULT_COINBASE_MATURITY); + assert_eq!( + cfg.minimum_block_confirmations, + DEFAULT_MINIMUM_BLOCK_CONFIRMATIONS + ); + assert_eq!(cfg.script_ttl_secs, DEFAULT_SCRIPT_TTL_SECS); + assert!(!cfg.participate_ccr); + assert!(cfg.wrpc_url.is_none()); + } + + #[test] + fn honors_explicit_values() { + let cfg: KaspaConfig = serde_json::from_value(serde_json::json!({ + "network_id": "mainnet", + "coinbase_maturity": 7, + "participate_ccr": true + })) + .unwrap(); + assert_eq!(cfg.coinbase_maturity, 7); + assert!(cfg.participate_ccr); + } +} diff --git a/crates/data/src/chains/kaspa/connect.rs b/crates/data/src/chains/kaspa/connect.rs new file mode 100644 index 0000000..770b740 --- /dev/null +++ b/crates/data/src/chains/kaspa/connect.rs @@ -0,0 +1,107 @@ +use parking_lot::Mutex; +use std::str::FromStr; +use std::sync::Arc; + +use ahash::AHashMap; +use kaspa_addresses::Prefix; +use kaspa_hashes::Hash; +use kaspa_wrpc_client::prelude::NetworkId; +use serde_json::Value; +use stroemnet_protocol::ChannelId; +use tokio::sync::RwLock; + +use stroemnet_protocol::now_unix_secs; + +use super::Kaspa; +use super::client::{build_client, spawn_intake}; +use super::config::KaspaConfig; +use super::contracts::commitments_from_scripts; +use crate::chains::record::restore; +use crate::chains::settlement::{SettlementMetrics, or_noop, seed_queue}; +use crate::{CursorStore, DataError, Result, SwapStore}; + +impl Kaspa { + /// Connect to the kaspa rpc client and setup the channel fully for processing data. + /// I.e. the main entrypoint for this channel + pub(crate) async fn connect( + channel_id: ChannelId, // the channel + cfg: &Value, // the configuration for the channel + private_key: Option, // private key + cursor_store: Option>, // cursor storage + swap_store: Option>, // swap storage + metrics: Option>, // general stats + ) -> Result { + // parse the config + let cfg: KaspaConfig = serde_json::from_value(cfg.clone()) + .map_err(|e| DataError::Config(format!("kaspa config: {e}")))?; + let network_id = NetworkId::from_str(&cfg.network_id) + .map_err(|e| DataError::Config(format!("network_id: {e:?}")))?; + let prefix: Prefix = network_id.into(); + + // Build the kaspa rpc client + let client = build_client(network_id, cfg.wrpc_url.as_deref()).await?; + + // Compute the initial cursor + let initial_cursor = cursor_store + .as_ref() + .and_then(|s| s.load(channel_id)) + .and_then(|b| <[u8; 32]>::try_from(b.as_slice()).ok()) + .map(Hash::from_bytes); + + // Spawn the intake + let rx = spawn_intake( + client.clone(), + cfg.minimum_block_confirmations, + channel_id, + initial_cursor, + cursor_store, + ); + + tracing::info!( + "Kaspa buffer {channel_id} connected to {:?} (confirmations {}, ccr {})", + client.url(), + cfg.minimum_block_confirmations, + cfg.participate_ccr, + ); + + // Restore old swaps + let restored = restore(swap_store.as_ref(), channel_id); + tracing::info!( + "Kaspa buffer {channel_id} restored {} refund(s), {} claim(s) from store", + restored.pending_refunds.len(), + restored.pending_claims.len(), + ); + + // Seed the queue with swaps + let queue = seed_queue(&restored, now_unix_secs()); + + // Compute commitments from restored scripts + let commitments = commitments_from_scripts(&restored.scripts, prefix, channel_id); + + // Create the kaspa channel data buffer + let buffer = Self { + channel_id, + network_id: cfg.network_id, + prefix, + coinbase_maturity: cfg.coinbase_maturity, + script_ttl_secs: cfg.script_ttl_secs, + participate_ccr: cfg.participate_ccr, + private_key, + client, + utxo_scripts: Arc::new(RwLock::new(AHashMap::new())), + safe_blocks: Mutex::new(rx), + commitments: Mutex::new(commitments), + pending_refunds: Mutex::new(restored.pending_refunds), + pending_claims: Mutex::new(restored.pending_claims), + announcements: Mutex::new(Vec::new()), + scripts: Mutex::new(restored.scripts), + swap_store, + queue, + metrics: or_noop(metrics), + }; + #[cfg(not(target_arch = "wasm32"))] + // check if any of the pending stored swaps have been settled while we were away + crate::chains::settlement::reconcile_on_boot(&buffer, buffer.metrics.as_ref()).await; + Ok(buffer) + } +} diff --git a/crates/data/src/chains/kaspa/contracts/contract_v1.rs b/crates/data/src/chains/kaspa/contracts/contract_v1.rs index 2591300..263370c 100644 --- a/crates/data/src/chains/kaspa/contracts/contract_v1.rs +++ b/crates/data/src/chains/kaspa/contracts/contract_v1.rs @@ -6,9 +6,10 @@ use kaspa_txscript::opcodes::codes::{ }; pub(crate) use super::extract::{extract_commitment, extract_reveal_secret, validate_refund_sig}; -pub(crate) use super::script::{SOLVER_REWARD, create_htlc_script}; +#[cfg(not(target_arch = "wasm32"))] +pub(crate) use super::script::SOLVER_REWARD; +pub(crate) use super::script::create_htlc_script; -/// Mock struct to satisfy trait bounds in order to decode scripts. pub(crate) struct VerifiableTransactionMock; impl VerifiableTransaction for VerifiableTransactionMock { fn tx(&self) -> &Transaction { @@ -23,59 +24,59 @@ impl VerifiableTransaction for VerifiableTransactionMock { } #[derive(Debug, Clone, PartialEq, Eq, Hash)] -/// Represents the expected opcode or data at a given position in the HTLC script +/// An enum representing two different types of expected opcodes pub(crate) enum ExpectedOpCode { OpCode(u8), Data, } #[derive(Debug, Clone, PartialEq, Eq, Hash)] -/// The different data types that we expect when we operate -/// on the HTLC script. +/// The different data types the parsers can detect in the stroem htlc v1 script pub enum DataType { - Opcode, // A raw opcode byte, e.g. 0x63 - SecretHash, // The 32-byte hash of the secret, - SwapId, // The 32-byte swap ID, - ReceiverSpk, // The receiver's script public key - SenderSpk, // The sender's script public key - Timelock, // The timelock value (u64) encoded as 8 bytes in little-endian - SenderReceiverAddress, // The senders receiver address on the destination chain - Destination, // The destination chain id (u8) encoded as 1 byte + Opcode, + SecretHash, + SwapId, + ReceiverSpk, + SenderSpk, + Timelock, + SenderReceiverAddress, + Destination, } -/// All the expected opcodes, data and their order in the HTLC script for our version 1 contract. +/// The expected opcode type and the exact datatype expected at a specific position +/// at the script pub(crate) const EXPECTED_OPCODES: &[(ExpectedOpCode, DataType)] = &[ - (ExpectedOpCode::OpCode(OpIf), DataType::Opcode), - (ExpectedOpCode::OpCode(OpSHA256), DataType::Opcode), - (ExpectedOpCode::Data, DataType::SecretHash), - (ExpectedOpCode::OpCode(OpEqualVerify), DataType::Opcode), - (ExpectedOpCode::OpCode(OpTxInputCount), DataType::Opcode), - (ExpectedOpCode::Data, DataType::Opcode), - (ExpectedOpCode::OpCode(OpNumEqualVerify), DataType::Opcode), - (ExpectedOpCode::OpCode(OpTxOutputCount), DataType::Opcode), - (ExpectedOpCode::Data, DataType::Opcode), - (ExpectedOpCode::OpCode(OpNumEqualVerify), DataType::Opcode), - (ExpectedOpCode::Data, DataType::ReceiverSpk), - (ExpectedOpCode::Data, DataType::Opcode), - (ExpectedOpCode::OpCode(OpTxOutputSpk), DataType::Opcode), - (ExpectedOpCode::OpCode(OpEqualVerify), DataType::Opcode), - (ExpectedOpCode::Data, DataType::Opcode), - (ExpectedOpCode::OpCode(OpTxOutputAmount), DataType::Opcode), - (ExpectedOpCode::OpCode(OpTxInputIndex), DataType::Opcode), - (ExpectedOpCode::OpCode(OpTxInputAmount), DataType::Opcode), - (ExpectedOpCode::Data, DataType::Opcode), - (ExpectedOpCode::OpCode(OpSub), DataType::Opcode), + (ExpectedOpCode::OpCode(OpIf), DataType::Opcode), // if + (ExpectedOpCode::OpCode(OpSHA256), DataType::Opcode), // the sha opcode + (ExpectedOpCode::Data, DataType::SecretHash), // the secret hash + (ExpectedOpCode::OpCode(OpEqualVerify), DataType::Opcode), // should be valid with the hashed secret + (ExpectedOpCode::OpCode(OpTxInputCount), DataType::Opcode), // validated input count + (ExpectedOpCode::Data, DataType::Opcode), // and the actual input (hardcoded) + (ExpectedOpCode::OpCode(OpNumEqualVerify), DataType::Opcode), // should be equal + (ExpectedOpCode::OpCode(OpTxOutputCount), DataType::Opcode), // the output count + (ExpectedOpCode::Data, DataType::Opcode), // the output (hardcoded) + (ExpectedOpCode::OpCode(OpNumEqualVerify), DataType::Opcode), // should be equal + (ExpectedOpCode::Data, DataType::ReceiverSpk), // the hardcoded receiver spk + (ExpectedOpCode::Data, DataType::Opcode), // the index of output spk + (ExpectedOpCode::OpCode(OpTxOutputSpk), DataType::Opcode), // the opcode that retrieves output spk + (ExpectedOpCode::OpCode(OpEqualVerify), DataType::Opcode), // should be equal + (ExpectedOpCode::Data, DataType::Opcode), // index for output + (ExpectedOpCode::OpCode(OpTxOutputAmount), DataType::Opcode), // the output amount + (ExpectedOpCode::OpCode(OpTxInputIndex), DataType::Opcode), // the input index + (ExpectedOpCode::OpCode(OpTxInputAmount), DataType::Opcode), // its input amount + (ExpectedOpCode::Data, DataType::Opcode), // the harcoded rewards + (ExpectedOpCode::OpCode(OpSub), DataType::Opcode), // subtracted from the output amount ( - ExpectedOpCode::OpCode(OpGreaterThanOrEqual), + ExpectedOpCode::OpCode(OpGreaterThanOrEqual), // should be geq the full value - reward DataType::Opcode, ), - (ExpectedOpCode::OpCode(OpElse), DataType::Opcode), - (ExpectedOpCode::Data, DataType::Timelock), + (ExpectedOpCode::OpCode(OpElse), DataType::Opcode), // refund branch + (ExpectedOpCode::Data, DataType::Timelock), // ensure time is ready ( ExpectedOpCode::OpCode(OpCheckLockTimeVerify), DataType::Opcode, ), - (ExpectedOpCode::OpCode(OpTxInputCount), DataType::Opcode), + (ExpectedOpCode::OpCode(OpTxInputCount), DataType::Opcode), // same validation again (ExpectedOpCode::Data, DataType::Opcode), (ExpectedOpCode::OpCode(OpNumEqualVerify), DataType::Opcode), (ExpectedOpCode::OpCode(OpTxOutputCount), DataType::Opcode), @@ -96,10 +97,2032 @@ pub(crate) const EXPECTED_OPCODES: &[(ExpectedOpCode, DataType)] = &[ DataType::Opcode, ), (ExpectedOpCode::OpCode(OpEndIf), DataType::Opcode), - (ExpectedOpCode::OpCode(OpFalse), DataType::Opcode), + (ExpectedOpCode::OpCode(OpFalse), DataType::Opcode), // metadata for quickly parsing the data (ExpectedOpCode::OpCode(OpIf), DataType::Opcode), (ExpectedOpCode::Data, DataType::SwapId), (ExpectedOpCode::Data, DataType::SenderReceiverAddress), (ExpectedOpCode::Data, DataType::Destination), (ExpectedOpCode::OpCode(OpEndIf), DataType::Opcode), ]; + +#[cfg(test)] +mod tests { + #![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing + )] + use crate::chains::kaspa::broadcast::spk_to_vec; + use crate::chains::kaspa::contracts::contract_v1::{ + DataType, EXPECTED_OPCODES, ExpectedOpCode, SOLVER_REWARD, VerifiableTransactionMock, + create_htlc_script, extract_commitment, + }; + use crate::chains::kaspa::contracts::contract_v1::{ + extract_reveal_secret, validate_refund_sig, + }; + use crate::chains::kaspa::contracts::script::decode_u64_from_script; + use crate::chains::kaspa::error::KaspaError; + use crate::chains::kaspa::test_helpers::{p2pk_spk, vec_to_spk}; + use kaspa_addresses::Prefix; + use kaspa_consensus_core::hashing::sighash::SigHashReusedValuesUnsync; + use kaspa_txscript::extract_script_pub_key_address; + use kaspa_txscript::opcodes::codes::OpTrue; + use kaspa_txscript::{ + opcodes::{ + OpCodeImplementation, + codes::{ + OpCheckLockTimeVerify, OpCheckSig, OpElse, OpEndIf, OpEqualVerify, OpFalse, + OpGreaterThanOrEqual, OpIf, OpNumEqualVerify, OpReturn, OpSHA256, OpSub, + OpTxInputAmount, OpTxInputCount, OpTxInputIndex, OpTxOutputAmount, OpTxOutputCount, + OpTxOutputSpk, + }, + }, + script_builder::ScriptBuilder, + }; + use rand::Rng; + use secp256k1::{Keypair, Secp256k1}; + use sha2::{Digest, Sha256}; + use stroemnet_protocol::ChannelId; + + const DEFAULT_TIMELOCK_MS: u64 = (1_700_000_000 + 7200) * 1000; + const DEFAULT_DESTINATION: u8 = 0; + const DEFAULT_AMOUNT: &str = "1000000000"; + struct TestFixture { + sender: Keypair, + receiver: Keypair, + secret: [u8; 32], + secret_hash: [u8; 32], + swap_id: [u8; 32], + sender_receiver_address: Vec, + destination: u8, + timelock: u64, + } + + impl TestFixture { + fn new() -> Self { + let secp = Secp256k1::new(); + let mut rng = rand::rng(); + let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let secret: [u8; 32] = rand::random(); + let secret_hash: [u8; 32] = Sha256::digest(secret).into(); + let swap_id: [u8; 32] = rand::random(); + let sender_receiver_address = b"sender_recv_addr_placeholder".to_vec(); + + Self { + sender, + receiver, + secret, + secret_hash, + swap_id, + sender_receiver_address, + destination: DEFAULT_DESTINATION, + timelock: DEFAULT_TIMELOCK_MS, + } + } + + fn sender_pub(&self) -> [u8; 32] { + self.sender.x_only_public_key().0.serialize() + } + + fn sender_spk(&self) -> kaspa_consensus_core::tx::ScriptPublicKey { + p2pk_spk(&self.sender) + } + + fn sender_spk_vec(&self) -> Vec { + spk_to_vec(&self.sender_spk()) + } + + fn receiver_spk_vec(&self) -> Vec { + spk_to_vec(&p2pk_spk(&self.receiver)) + } + + fn build_valid_script(&self) -> Vec { + create_htlc_script( + &self.sender_spk_vec(), + &self.sender_receiver_address, + &self.receiver_spk_vec(), + &self.secret_hash, + self.timelock, + self.destination, + self.swap_id, + ) + .expect("Script creation") + } + + fn build_script_with_mutation_at( + &self, + position: usize, + mutate: impl Fn(&mut ScriptBuilder), + ) -> Vec { + let mut builder = ScriptBuilder::new(); + let receiver_spk = self.receiver_spk_vec(); + let sender_spk = self.sender_spk_vec(); + + for i in 0..EXPECTED_OPCODES.len() { + if i == position { + mutate(&mut builder); + continue; + } + match i { + 0 => { + builder.add_op(OpIf).unwrap(); + } + 1 => { + builder.add_op(OpSHA256).unwrap(); + } + 2 => { + builder.add_data(&self.secret_hash).unwrap(); + } + 3 => { + builder.add_op(OpEqualVerify).unwrap(); + } + 4 => { + builder.add_op(OpTxInputCount).unwrap(); + } + 5 => { + builder.add_i64(2).unwrap(); + } + 6 => { + builder.add_op(OpNumEqualVerify).unwrap(); + } + 7 => { + builder.add_op(OpTxOutputCount).unwrap(); + } + 8 => { + builder.add_i64(2).unwrap(); + } + 9 => { + builder.add_op(OpNumEqualVerify).unwrap(); + } + 10 => { + builder.add_data(&receiver_spk).unwrap(); + } + 11 => { + builder.add_i64(0).unwrap(); + } + 12 => { + builder.add_op(OpTxOutputSpk).unwrap(); + } + 13 => { + builder.add_op(OpEqualVerify).unwrap(); + } + 14 => { + builder.add_i64(0).unwrap(); + } + 15 => { + builder.add_op(OpTxOutputAmount).unwrap(); + } + 16 => { + builder.add_op(OpTxInputIndex).unwrap(); + } + 17 => { + builder.add_op(OpTxInputAmount).unwrap(); + } + 18 => { + builder.add_i64(SOLVER_REWARD).unwrap(); + } + 19 => { + builder.add_op(OpSub).unwrap(); + } + 20 => { + builder.add_op(OpGreaterThanOrEqual).unwrap(); + } + + 21 => { + builder.add_op(OpElse).unwrap(); + } + 22 => { + builder.add_i64(self.timelock as i64).unwrap(); + } + 23 => { + builder.add_op(OpCheckLockTimeVerify).unwrap(); + } + 24 => { + builder.add_op(OpTxInputCount).unwrap(); + } + 25 => { + builder.add_i64(2).unwrap(); + } + 26 => { + builder.add_op(OpNumEqualVerify).unwrap(); + } + 27 => { + builder.add_op(OpTxOutputCount).unwrap(); + } + 28 => { + builder.add_i64(2).unwrap(); + } + 29 => { + builder.add_op(OpNumEqualVerify).unwrap(); + } + 30 => { + builder.add_data(&sender_spk).unwrap(); + } + 31 => { + builder.add_i64(0).unwrap(); + } + 32 => { + builder.add_op(OpTxOutputSpk).unwrap(); + } + 33 => { + builder.add_op(OpEqualVerify).unwrap(); + } + 34 => { + builder.add_i64(0).unwrap(); + } + 35 => { + builder.add_op(OpTxOutputAmount).unwrap(); + } + 36 => { + builder.add_op(OpTxInputIndex).unwrap(); + } + 37 => { + builder.add_op(OpTxInputAmount).unwrap(); + } + 38 => { + builder.add_i64(SOLVER_REWARD).unwrap(); + } + 39 => { + builder.add_op(OpSub).unwrap(); + } + 40 => { + builder.add_op(OpGreaterThanOrEqual).unwrap(); + } + + 41 => { + builder.add_op(OpEndIf).unwrap(); + } + 42 => { + builder.add_op(OpFalse).unwrap(); + } + 43 => { + builder.add_op(OpIf).unwrap(); + } + 44 => { + builder.add_data(&self.swap_id).unwrap(); + } + 45 => { + builder.add_data(&self.sender_receiver_address).unwrap(); + } + 46 => { + builder.add_data(&[self.destination]).unwrap(); + } + 47 => { + builder.add_op(OpEndIf).unwrap(); + } + _ => unreachable!(), + } + } + builder.drain() + } + + fn extract(&self, raw: &[u8]) -> Result { + let parsed = + crate::chains::kaspa::decode::parse_script(raw).collect::, _>>()?; + extract_commitment( + &parsed, + DEFAULT_AMOUNT.to_string(), + Prefix::Devnet, + ChannelId::KaspaTn10, + ) + } + } + + #[test] + fn test_extract_refund_fails_empty() { + let parsed: Vec< + Box>, + > = vec![]; + assert!(matches!( + validate_refund_sig(&parsed).unwrap_err(), + KaspaError::InvalidSigScriptLength { + expected: 2, + got: 0 + } + )); + } + + #[test] + fn test_extract_refund_fails_one_opcode() { + let f = TestFixture::new(); + let htlc_script = f.build_valid_script(); + + let sig_script = ScriptBuilder::new().add_data(&htlc_script).unwrap().drain(); + let parsed = crate::chains::kaspa::decode::parse_script(&sig_script) + .collect::, _>>() + .unwrap(); + assert!(matches!( + validate_refund_sig(&parsed).unwrap_err(), + KaspaError::InvalidSigScriptLength { + expected: 2, + got: 1 + } + )); + } + #[test] + fn test_swap_id_too_short_rejected() { + let f = TestFixture::new(); + let short_id = [0u8; 16]; + let raw = f.build_script_with_mutation_at(44, |b| { + b.add_data(&short_id).unwrap(); + }); + let res = f.extract(&raw); + assert!(res.is_err(), "16-byte swap_id should be rejected"); + match res.unwrap_err() { + KaspaError::InvalidSwapIdLength => {} + other => panic!("Expected InvalidSwapIdLength, got {other:?}"), + } + } + + #[test] + fn test_swap_id_too_long_rejected() { + let f = TestFixture::new(); + let long_id = [0u8; 64]; + let raw = f.build_script_with_mutation_at(44, |b| { + b.add_data(&long_id).unwrap(); + }); + let res = f.extract(&raw); + assert!(res.is_err(), "64-byte swap_id should be rejected"); + match res.unwrap_err() { + KaspaError::InvalidSwapIdLength => {} + other => panic!("Expected InvalidSwapIdLength, got {other:?}"), + } + } + + #[test] + fn test_swap_id_different_value_still_parses() { + let f = TestFixture::new(); + let different_id: [u8; 32] = rand::random(); + let raw = f.build_script_with_mutation_at(44, |b| { + b.add_data(&different_id).unwrap(); + }); + let c = f.extract(&raw).unwrap(); + assert_eq!(c.swap_id, different_id); + } + + #[test] + fn test_sender_receiver_address_different_value_extracts() { + let f = TestFixture::new(); + let other_addr = "completely_different_address".to_string(); + + let raw = f.build_script_with_mutation_at(45, |b| { + b.add_data(other_addr.as_bytes()).unwrap(); + }); + let c = f.extract(&raw).unwrap(); + assert_eq!(c.addresses.sender_destination, other_addr); + } + + #[test] + fn test_swap_id_empty_rejected() { + let f = TestFixture::new(); + let raw = f.build_script_with_mutation_at(44, |b| { + b.add_data(&[]).unwrap(); + }); + let res = f.extract(&raw); + assert!(res.is_err(), "Empty swap_id should be rejected"); + } + + #[test] + fn test_refund_sub_replaced() { + let f = TestFixture::new(); + let raw = f.build_script_with_mutation_at(39, |b| { + b.add_op(OpCheckLockTimeVerify).unwrap(); + }); + assert!(f.extract(&raw).is_err()); + } + + #[test] + fn test_refund_gte_replaced() { + let f = TestFixture::new(); + let raw = f.build_script_with_mutation_at(40, |b| { + b.add_op(OpCheckLockTimeVerify).unwrap(); + }); + assert!(f.extract(&raw).is_err()); + } + + #[test] + fn test_destination_mutated_extracts() { + let f = TestFixture::new(); + let raw = f.build_script_with_mutation_at(46, |b| { + b.add_data(&[42u8]).unwrap(); + }); + let c = f.extract(&raw).unwrap(); + assert_eq!(c.destination, 42); + } + + #[test] + fn test_destination_empty_vec_returns_missing_data() { + let f = TestFixture::new(); + let raw = f.build_script_with_mutation_at(46, |b| { + b.add_op(OpEqualVerify).unwrap(); + }); + let res = f.extract(&raw); + match res { + Err(KaspaError::MissingData(DataType::Destination)) => {} + Err(other) => { + assert!( + matches!(other, KaspaError::MissingData(_)), + "Expected MissingData, got {other:?}" + ); + } + Ok(_) => panic!("Should fail with non-push opcode in destination slot"), + } + } + #[test] + fn test_extract_refund_fails_four_opcodes() { + let f = TestFixture::new(); + let htlc_script = f.build_valid_script(); + let junk: [u8; 32] = rand::random(); + let sig_script = ScriptBuilder::new() + .add_data(&junk) + .unwrap() + .add_data(&junk) + .unwrap() + .add_op(OpFalse) + .unwrap() + .add_data(&htlc_script) + .unwrap() + .drain(); + let parsed = crate::chains::kaspa::decode::parse_script(&sig_script) + .collect::, _>>() + .unwrap(); + assert!(matches!( + validate_refund_sig(&parsed).unwrap_err(), + KaspaError::InvalidSigScriptLength { + expected: 2, + got: 4 + } + )); + } + + #[test] + fn test_extract_refund_fails_non_push_redeem() { + let sig_script = ScriptBuilder::new() + .add_op(OpFalse) + .unwrap() + .add_op(OpElse) + .unwrap() + .drain(); + let parsed = crate::chains::kaspa::decode::parse_script(&sig_script) + .collect::, _>>() + .unwrap(); + assert!(matches!( + validate_refund_sig(&parsed).unwrap_err(), + KaspaError::MissingRedeemScript + )); + } + + #[test] + fn test_extract_refund_fails_with_op_true_selector() { + let f = TestFixture::new(); + let htlc_script = f.build_valid_script(); + + let sig_script = ScriptBuilder::new() + .add_op(OpTrue) + .unwrap() + .add_data(&htlc_script) + .unwrap() + .drain(); + let parsed = crate::chains::kaspa::decode::parse_script(&sig_script) + .collect::, _>>() + .unwrap(); + match validate_refund_sig(&parsed).unwrap_err() { + KaspaError::WrongBranchSelector { expected, got } => { + assert_eq!(expected, OpFalse); + assert_eq!(got, OpTrue); + } + other => panic!("Expected WrongBranchSelector, got {other:?}"), + } + } + + #[test] + fn test_op_checklocktimeverify_replaced() { + let f = TestFixture::new(); + let raw = f.build_script_with_mutation_at(23, |b| { + b.add_op(OpSub).unwrap(); + }); + assert!(f.extract(&raw).is_err()); + } + + #[test] + fn test_metadata_envelope_missing_inner_op_if() { + let f = TestFixture::new(); + let raw = f.build_script_with_mutation_at(41, |b| { + b.add_op(OpElse).unwrap(); + }); + let res = f.extract(&raw); + assert!(res.is_err()); + } + + #[test] + fn test_op_if_at_41_replaced() { + let f = TestFixture::new(); + let raw = f.build_script_with_mutation_at(41, |b| { + b.add_op(OpSub).unwrap(); + }); + assert!(f.extract(&raw).is_err()); + } + + #[test] + fn test_sender_pubkey_different_value_extracts() { + let f = TestFixture::new(); + let secp = Secp256k1::new(); + let mut rng = rand::rng(); + let other = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let other_spk = spk_to_vec(&p2pk_spk(&other)); + + let raw = f.build_script_with_mutation_at(30, |b| { + b.add_data(&other_spk).unwrap(); + }); + let c = f.extract(&raw).unwrap(); + let other_addr = + extract_script_pub_key_address(&vec_to_spk(&other_spk), Prefix::Devnet).unwrap(); + assert_eq!(c.addresses.sender, other_addr.to_string()); + let original_addr = + extract_script_pub_key_address(&f.sender_spk(), Prefix::Devnet).unwrap(); + assert_ne!(c.addresses.sender, original_addr.to_string()); + } + #[test] + fn test_valid_script_extracts_all_fields() { + let f = TestFixture::new(); + let raw = f.build_valid_script(); + let c = f.extract(&raw).expect("Valid script should parse"); + + let f_sender_addr = + extract_script_pub_key_address(&f.sender_spk(), Prefix::Devnet).unwrap(); + let f_receiver_addr = + extract_script_pub_key_address(&vec_to_spk(&f.receiver_spk_vec()), Prefix::Devnet) + .unwrap(); + assert_eq!(c.swap_id, f.swap_id); + assert_eq!(c.secret_hash, f.secret_hash); + assert_eq!(c.addresses.sender, f_sender_addr.to_string()); + assert_eq!(c.addresses.receiver, f_receiver_addr.to_string()); + assert_eq!(c.destination, f.destination); + assert_eq!(c.amount.value, DEFAULT_AMOUNT); + assert_eq!(c.amount.decimals, 8); + assert_eq!( + c.addresses.sender_destination, + String::from_utf8(f.sender_receiver_address.clone()).unwrap() + ); + } + + #[test] + fn test_valid_script_deterministic() { + let f = TestFixture::new(); + let raw1 = f.build_valid_script(); + let raw2 = f.build_valid_script(); + assert_eq!(raw1, raw2); + + let c1 = f.extract(&raw1).unwrap(); + let c2 = f.extract(&raw2).unwrap(); + assert_eq!(c1.swap_id, c2.swap_id); + assert_eq!(c1.secret_hash, c2.secret_hash); + assert_eq!(c1.addresses.sender, c2.addresses.sender); + } + + #[test] + fn test_different_fixtures_produce_different_commitments() { + let f1 = TestFixture::new(); + let f2 = TestFixture::new(); + + let c1 = f1.extract(&f1.build_valid_script()).unwrap(); + let c2 = f2.extract(&f2.build_valid_script()).unwrap(); + + assert_ne!(c1.swap_id, c2.swap_id); + assert_ne!(c1.secret_hash, c2.secret_hash); + assert_ne!(c1.addresses.sender, c2.addresses.sender); + } + + #[test] + fn test_secret_hash_different_value_still_parses() { + let f = TestFixture::new(); + let different_hash: [u8; 32] = rand::random(); + let raw = f.build_script_with_mutation_at(2, |b| { + b.add_data(&different_hash).unwrap(); + }); + let c = f.extract(&raw).unwrap(); + assert_eq!(c.secret_hash, different_hash); + assert_ne!(c.secret_hash, f.secret_hash); + } + + #[test] + fn test_sender_spk_different_value_extracts() { + let f = TestFixture::new(); + let secp = Secp256k1::new(); + let mut rng = rand::rng(); + let other = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let other_spk = spk_to_vec(&p2pk_spk(&other)); + + let raw = f.build_script_with_mutation_at(30, |b| { + b.add_data(&other_spk).unwrap(); + }); + let c = f.extract(&raw).unwrap(); + let other_spk = + extract_script_pub_key_address(&vec_to_spk(&other_spk), Prefix::Devnet).unwrap(); + assert_eq!(c.addresses.sender, other_spk.to_string()); + let original_spk = extract_script_pub_key_address(&f.sender_spk(), Prefix::Devnet).unwrap(); + assert_ne!(c.addresses.sender, original_spk.to_string()); + } + + #[test] + fn test_receiver_spk_different_value_extracts() { + let f = TestFixture::new(); + let secp = Secp256k1::new(); + let mut rng = rand::rng(); + let other = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let other_spk = spk_to_vec(&p2pk_spk(&other)); + + let raw = f.build_script_with_mutation_at(10, |b| { + b.add_data(&other_spk).unwrap(); + }); + let other_spk = + extract_script_pub_key_address(&vec_to_spk(&other_spk), Prefix::Devnet).unwrap(); + let c = f.extract(&raw).unwrap(); + assert_eq!(c.addresses.receiver, other_spk.to_string()); + } + + #[test] + fn test_swap_id_31_bytes_rejected() { + let f = TestFixture::new(); + let raw = f.build_script_with_mutation_at(42, |b| { + b.add_data(&[0xAA; 31]).unwrap(); + }); + let res = f.extract(&raw); + assert!(res.is_err()); + } + + #[test] + fn test_swap_id_33_bytes_rejected() { + let f = TestFixture::new(); + let raw = f.build_script_with_mutation_at(42, |b| { + b.add_data(&[0xBB; 33]).unwrap(); + }); + let res = f.extract(&raw); + assert!(res.is_err()); + } + + #[test] + fn test_secret_hash_too_short_rejected() { + let f = TestFixture::new(); + let raw = f.build_script_with_mutation_at(2, |b| { + b.add_data(&[0u8; 16]).unwrap(); + }); + let res = f.extract(&raw); + assert!(res.is_err(), "16-byte secret_hash should be rejected"); + match res.unwrap_err() { + KaspaError::InvalidSecretHashLength => {} + other => panic!("Expected InvalidSecretHashLength, got {other:?}"), + } + } + + #[test] + fn test_secret_hash_too_long_rejected() { + let f = TestFixture::new(); + let raw = f.build_script_with_mutation_at(2, |b| { + b.add_data(&[0u8; 64]).unwrap(); + }); + let res = f.extract(&raw); + assert!(res.is_err(), "64-byte secret_hash should be rejected"); + } + + #[test] + fn test_secret_hash_empty_rejected() { + let f = TestFixture::new(); + let raw = f.build_script_with_mutation_at(2, |b| { + b.add_data(&[]).unwrap(); + }); + let res = f.extract(&raw); + assert!(res.is_err(), "Empty secret_hash should be rejected"); + } + + #[test] + fn test_all_zero_swap_id_accepted() { + let mut f = TestFixture::new(); + f.swap_id = [0u8; 32]; + let c = f.extract(&f.build_valid_script()).unwrap(); + assert_eq!(c.swap_id, [0u8; 32]); + } + + #[test] + fn test_all_ff_swap_id_accepted() { + let mut f = TestFixture::new(); + f.swap_id = [0xFF; 32]; + let c = f.extract(&f.build_valid_script()).unwrap(); + assert_eq!(c.swap_id, [0xFF; 32]); + } + + #[test] + fn test_missing_data_sender_spk() { + let f = TestFixture::new(); + let raw = f.build_script_with_mutation_at(30, |b| { + b.add_op(OpSub).unwrap(); + }); + let res = f.extract(&raw); + assert!(res.is_err(), "Non-push in sender spk slot should fail"); + } + + #[test] + fn test_missing_data_receiver_spk() { + let f = TestFixture::new(); + let raw = f.build_script_with_mutation_at(10, |b| { + b.add_op(OpSub).unwrap(); + }); + let res = f.extract(&raw); + assert!(res.is_err(), "Non-push in receiver spk slot should fail"); + } + + #[test] + fn test_missing_data_timelock() { + let f = TestFixture::new(); + let raw = f.build_script_with_mutation_at(22, |b| { + b.add_op(OpSub).unwrap(); + }); + let res = f.extract(&raw); + assert!(res.is_err(), "Non-push in timelock slot should fail"); + } + + #[test] + fn test_missing_data_sender_receiver_address() { + let f = TestFixture::new(); + let raw = f.build_script_with_mutation_at(43, |b| { + b.add_op(OpSub).unwrap(); + }); + let res = f.extract(&raw); + assert!( + res.is_err(), + "Non-push in sender_receiver_address slot should fail" + ); + } + + #[test] + fn test_extract_opcode_data_fallthrough_non_push_opcode_in_data_slot() { + let f = TestFixture::new(); + let raw = f.build_script_with_mutation_at(2, |b| { + b.add_op(OpSub).unwrap(); + }); + let res = f.extract(&raw); + assert!(res.is_err(), "Non-push opcode in data slot should fail"); + } + + #[test] + fn test_extract_opcode_data_fallthrough_in_swap_id_slot() { + let f = TestFixture::new(); + let raw = f.build_script_with_mutation_at(42, |b| { + b.add_op(OpCheckSig).unwrap(); + }); + let res = f.extract(&raw); + assert!(res.is_err()); + } + + #[test] + fn test_extract_opcode_data_fallthrough_in_destination_slot() { + let f = TestFixture::new(); + let raw = f.build_script_with_mutation_at(44, |b| { + b.add_op(OpSub).unwrap(); + }); + let res = f.extract(&raw); + assert!(res.is_err()); + } + + #[test] + fn test_extract_reveal_success() { + let f = TestFixture::new(); + let htlc_script = f.build_valid_script(); + + let sig_script = ScriptBuilder::new() + .add_data(&f.secret) + .unwrap() + .add_op(OpTrue) + .unwrap() + .add_data(&htlc_script) + .unwrap() + .drain(); + + let parsed = crate::chains::kaspa::decode::parse_script(&sig_script) + .collect::, _>>() + .unwrap(); + let secret = extract_reveal_secret(&parsed).unwrap(); + + assert_eq!(secret, f.secret); + } + + #[test] + fn test_extract_reveal_deterministic() { + let f = TestFixture::new(); + let htlc_script = f.build_valid_script(); + + let sig_script = ScriptBuilder::new() + .add_data(&f.secret) + .unwrap() + .add_op(OpTrue) + .unwrap() + .add_data(&htlc_script) + .unwrap() + .drain(); + + let p1 = crate::chains::kaspa::decode::parse_script(&sig_script) + .collect::, _>>() + .unwrap(); + let p2 = crate::chains::kaspa::decode::parse_script(&sig_script) + .collect::, _>>() + .unwrap(); + let s1 = extract_reveal_secret(&p1).unwrap(); + let s2 = extract_reveal_secret(&p2).unwrap(); + + assert_eq!(s1, s2); + } + + #[test] + fn test_extract_reveal_fails_with_op_false_selector() { + let f = TestFixture::new(); + let htlc_script = f.build_valid_script(); + + let sig_script = ScriptBuilder::new() + .add_data(&f.secret) + .unwrap() + .add_op(OpFalse) + .unwrap() + .add_data(&htlc_script) + .unwrap() + .drain(); + + let parsed = crate::chains::kaspa::decode::parse_script(&sig_script) + .collect::, _>>() + .unwrap(); + let err = extract_reveal_secret(&parsed).unwrap_err(); + match err { + KaspaError::WrongBranchSelector { expected, got } => { + assert_eq!(expected, 0x51); + assert_eq!(got, 0x00); + } + other => panic!("Expected WrongBranchSelector, got {other:?}"), + } + } + + #[test] + fn test_extract_reveal_fails_with_arbitrary_selector() { + let f = TestFixture::new(); + let htlc_script = f.build_valid_script(); + + let sig_script = ScriptBuilder::new() + .add_data(&f.secret) + .unwrap() + .add_op(OpCheckSig) + .unwrap() + .add_data(&htlc_script) + .unwrap() + .drain(); + + let parsed = crate::chains::kaspa::decode::parse_script(&sig_script) + .collect::, _>>() + .unwrap(); + assert!(matches!( + extract_reveal_secret(&parsed).unwrap_err(), + KaspaError::WrongBranchSelector { .. } + )); + } + + #[test] + fn test_extract_reveal_fails_empty() { + let parsed: Vec< + Box< + dyn kaspa_txscript::opcodes::OpCodeImplementation< + crate::chains::kaspa::contracts::contract_v1::VerifiableTransactionMock, + SigHashReusedValuesUnsync, + >, + >, + > = vec![]; + match extract_reveal_secret(&parsed).unwrap_err() { + KaspaError::InvalidSigScriptLength { + expected: 3, + got: 0, + } => {} + other => panic!("Expected InvalidSigScriptLength, got {other:?}"), + } + } + + #[test] + fn test_extract_reveal_fails_two_opcodes() { + let f = TestFixture::new(); + let sig_script = ScriptBuilder::new() + .add_data(&f.secret) + .unwrap() + .add_op(OpTrue) + .unwrap() + .drain(); + let parsed = crate::chains::kaspa::decode::parse_script(&sig_script) + .collect::, _>>() + .unwrap(); + assert!(matches!( + extract_reveal_secret(&parsed).unwrap_err(), + KaspaError::InvalidSigScriptLength { + expected: 3, + got: 2 + } + )); + } + + #[test] + fn test_extract_reveal_fails_four_opcodes() { + let f = TestFixture::new(); + let htlc_script = f.build_valid_script(); + let junk: [u8; 32] = rand::random(); + let sig_script = ScriptBuilder::new() + .add_data(&junk) + .unwrap() + .add_data(&f.secret) + .unwrap() + .add_op(OpTrue) + .unwrap() + .add_data(&htlc_script) + .unwrap() + .drain(); + let parsed = crate::chains::kaspa::decode::parse_script(&sig_script) + .collect::, _>>() + .unwrap(); + assert!(matches!( + extract_reveal_secret(&parsed).unwrap_err(), + KaspaError::InvalidSigScriptLength { + expected: 3, + got: 4 + } + )); + } + + #[test] + fn test_extract_reveal_fails_secret_too_short() { + let f = TestFixture::new(); + let htlc_script = f.build_valid_script(); + let short_secret = [0xAA; 16]; + let sig_script = ScriptBuilder::new() + .add_data(&short_secret) + .unwrap() + .add_op(OpTrue) + .unwrap() + .add_data(&htlc_script) + .unwrap() + .drain(); + let parsed = crate::chains::kaspa::decode::parse_script(&sig_script) + .collect::, _>>() + .unwrap(); + assert!(matches!( + extract_reveal_secret(&parsed).unwrap_err(), + KaspaError::InvalidSecretLength + )); + } + + #[test] + fn test_extract_reveal_fails_secret_too_long() { + let f = TestFixture::new(); + let htlc_script = f.build_valid_script(); + let long_secret = [0xBB; 64]; + let sig_script = ScriptBuilder::new() + .add_data(&long_secret) + .unwrap() + .add_op(OpTrue) + .unwrap() + .add_data(&htlc_script) + .unwrap() + .drain(); + let parsed = crate::chains::kaspa::decode::parse_script(&sig_script) + .collect::, _>>() + .unwrap(); + assert!(matches!( + extract_reveal_secret(&parsed).unwrap_err(), + KaspaError::InvalidSecretLength + )); + } + + #[test] + fn test_extract_reveal_fails_secret_1_byte() { + let f = TestFixture::new(); + let htlc_script = f.build_valid_script(); + let sig_script = ScriptBuilder::new() + .add_data(&[0x42]) + .unwrap() + .add_op(OpTrue) + .unwrap() + .add_data(&htlc_script) + .unwrap() + .drain(); + let parsed = crate::chains::kaspa::decode::parse_script(&sig_script) + .collect::, _>>() + .unwrap(); + assert!(matches!( + extract_reveal_secret(&parsed).unwrap_err(), + KaspaError::InvalidSecretLength + )); + } + + #[test] + fn test_extract_reveal_fails_secret_31_bytes() { + let f = TestFixture::new(); + let htlc_script = f.build_valid_script(); + let sig_script = ScriptBuilder::new() + .add_data(&[0xCC; 31]) + .unwrap() + .add_op(OpTrue) + .unwrap() + .add_data(&htlc_script) + .unwrap() + .drain(); + let parsed = crate::chains::kaspa::decode::parse_script(&sig_script) + .collect::, _>>() + .unwrap(); + assert!(matches!( + extract_reveal_secret(&parsed).unwrap_err(), + KaspaError::InvalidSecretLength + )); + } + + #[test] + fn test_extract_reveal_fails_secret_33_bytes() { + let f = TestFixture::new(); + let htlc_script = f.build_valid_script(); + let sig_script = ScriptBuilder::new() + .add_data(&[0xDD; 33]) + .unwrap() + .add_op(OpTrue) + .unwrap() + .add_data(&htlc_script) + .unwrap() + .drain(); + let parsed = crate::chains::kaspa::decode::parse_script(&sig_script) + .collect::, _>>() + .unwrap(); + assert!(matches!( + extract_reveal_secret(&parsed).unwrap_err(), + KaspaError::InvalidSecretLength + )); + } + + #[test] + fn test_extract_reveal_fails_non_push_secret() { + let f = TestFixture::new(); + let htlc_script = f.build_valid_script(); + let sig_script = ScriptBuilder::new() + .add_op(OpElse) + .unwrap() + .add_op(OpTrue) + .unwrap() + .add_data(&htlc_script) + .unwrap() + .drain(); + let parsed = crate::chains::kaspa::decode::parse_script(&sig_script) + .collect::, _>>() + .unwrap(); + assert!(matches!( + extract_reveal_secret(&parsed).unwrap_err(), + KaspaError::MissingSecret + )); + } + + #[test] + fn test_extract_reveal_fails_non_push_redeem() { + let f = TestFixture::new(); + let sig_script = ScriptBuilder::new() + .add_data(&f.secret) + .unwrap() + .add_op(OpTrue) + .unwrap() + .add_op(OpElse) + .unwrap() + .drain(); + let parsed = crate::chains::kaspa::decode::parse_script(&sig_script) + .collect::, _>>() + .unwrap(); + assert!(matches!( + extract_reveal_secret(&parsed).unwrap_err(), + KaspaError::MissingRedeemScript + )); + } + + fn build_refund_sig_script(f: &TestFixture) -> Vec { + let htlc_script = f.build_valid_script(); + + ScriptBuilder::new() + .add_op(OpFalse) + .unwrap() + .add_data(&htlc_script) + .unwrap() + .drain() + } + + #[test] + fn test_extract_refund_success() { + let f = TestFixture::new(); + let sig_script = build_refund_sig_script(&f); + let parsed = crate::chains::kaspa::decode::parse_script(&sig_script) + .collect::, _>>() + .unwrap(); + validate_refund_sig(&parsed).unwrap(); + } + + #[test] + fn test_extract_refund_deterministic() { + let f = TestFixture::new(); + let sig_script = build_refund_sig_script(&f); + + let p1 = crate::chains::kaspa::decode::parse_script(&sig_script) + .collect::, _>>() + .unwrap(); + let p2 = crate::chains::kaspa::decode::parse_script(&sig_script) + .collect::, _>>() + .unwrap(); + validate_refund_sig(&p1).unwrap(); + validate_refund_sig(&p2).unwrap(); + } + + #[test] + fn test_extract_refund_fails_with_extra_non_push_opcode() { + let f = TestFixture::new(); + let htlc_script = f.build_valid_script(); + let sig_script = ScriptBuilder::new() + .add_op(OpElse) + .unwrap() + .add_op(OpFalse) + .unwrap() + .add_data(&htlc_script) + .unwrap() + .drain(); + let parsed = crate::chains::kaspa::decode::parse_script(&sig_script) + .collect::, _>>() + .unwrap(); + assert!(matches!( + validate_refund_sig(&parsed).unwrap_err(), + KaspaError::InvalidSigScriptLength { + expected: 2, + got: 3 + } + )); + } + + #[test] + fn test_claim_sig_rejected_by_validate_refund_sig() { + let f = TestFixture::new(); + let htlc_script = f.build_valid_script(); + let sig_script = ScriptBuilder::new() + .add_data(&f.secret) + .unwrap() + .add_op(OpTrue) + .unwrap() + .add_data(&htlc_script) + .unwrap() + .drain(); + let parsed = crate::chains::kaspa::decode::parse_script(&sig_script) + .collect::, _>>() + .unwrap(); + assert!(validate_refund_sig(&parsed).is_err()); + } + + #[test] + fn test_refund_sig_rejected_by_extract_reveal_secret() { + let f = TestFixture::new(); + let sig_script = build_refund_sig_script(&f); + let parsed = crate::chains::kaspa::decode::parse_script(&sig_script) + .collect::, _>>() + .unwrap(); + assert!(extract_reveal_secret(&parsed).is_err()); + } + + #[test] + fn test_extract_reveal_random_bytes() { + let raw: Vec = (0..100).map(|_| rand::random::()).collect(); + if let Ok(p) = crate::chains::kaspa::decode::parse_script(&raw) + .collect::, _>>() + { + let _ = extract_reveal_secret(&p); + } + } + + #[test] + fn test_extract_refund_random_bytes() { + let raw: Vec = (0..100).map(|_| rand::random::()).collect(); + if let Ok(p) = crate::chains::kaspa::decode::parse_script(&raw) + .collect::, _>>() + { + let _ = validate_refund_sig(&p); + } + } + + #[test] + fn test_destination_0x51_round_trips() { + let mut f = TestFixture::new(); + f.destination = 0x51; + let raw = f.build_valid_script(); + let c = f.extract(&raw).unwrap(); + assert_eq!(c.destination, 0x51); + } + + #[test] + fn test_extract_reveal_all_zero_secret() { + let f = TestFixture::new(); + let htlc_script = f.build_valid_script(); + let zero_secret = [0u8; 32]; + + let sig_script = ScriptBuilder::new() + .add_data(&zero_secret) + .unwrap() + .add_op(OpTrue) + .unwrap() + .add_data(&htlc_script) + .unwrap() + .drain(); + + let parsed = crate::chains::kaspa::decode::parse_script(&sig_script) + .collect::, _>>() + .unwrap(); + let secret = extract_reveal_secret(&parsed).unwrap(); + assert_eq!(secret, zero_secret); + } + + #[test] + fn test_extract_reveal_all_ff_secret() { + let f = TestFixture::new(); + let htlc_script = f.build_valid_script(); + let ff_secret = [0xFF; 32]; + + let sig_script = ScriptBuilder::new() + .add_data(&ff_secret) + .unwrap() + .add_op(OpTrue) + .unwrap() + .add_data(&htlc_script) + .unwrap() + .drain(); + + let parsed = crate::chains::kaspa::decode::parse_script(&sig_script) + .collect::, _>>() + .unwrap(); + let secret = extract_reveal_secret(&parsed).unwrap(); + assert_eq!(secret, ff_secret); + } + + #[test] + fn test_destination_zero() { + let mut f = TestFixture::new(); + f.destination = 0; + let c = f.extract(&f.build_valid_script()).unwrap(); + assert_eq!(c.destination, 0); + } + + #[test] + fn test_destination_one() { + let mut f = TestFixture::new(); + f.destination = 1; + let c = f.extract(&f.build_valid_script()).unwrap(); + assert_eq!(c.destination, 1); + } + + #[test] + fn test_destination_max() { + let mut f = TestFixture::new(); + f.destination = 255; + let c = f.extract(&f.build_valid_script()).unwrap(); + assert_eq!(c.destination, 255); + } + + #[test] + fn test_timelock_small() { + let mut f = TestFixture::new(); + + f.timelock = 1_000; + let c = f.extract(&f.build_valid_script()).unwrap(); + assert_eq!(c.unlock_ts, 1); + } + + #[test] + fn test_timelock_large() { + let mut f = TestFixture::new(); + + f.timelock = 2_500_000_000_000; + let c = f.extract(&f.build_valid_script()).unwrap(); + assert_eq!(c.unlock_ts, 2_500_000_000); + } + + #[test] + fn test_empty_script_rejected() { + let f = TestFixture::new(); + let res = f.extract(&[]); + assert!(res.is_err()); + } + + #[test] + fn test_single_opcode_rejected() { + let f = TestFixture::new(); + let raw = ScriptBuilder::new().add_op(OpIf).unwrap().drain(); + let res = f.extract(&raw); + assert!(res.is_err()); + } + + #[test] + fn test_script_one_opcode_short_rejected() { + let f = TestFixture::new(); + let mut raw = f.build_valid_script(); + raw.truncate(raw.len().saturating_sub(2)); + let res = f.extract(&raw); + assert!(res.is_err()); + } + + #[test] + fn test_script_with_extra_trailing_opcode_rejected() { + let f = TestFixture::new(); + let mut raw = f.build_valid_script(); + let extra = ScriptBuilder::new().add_op(OpCheckSig).unwrap().drain(); + raw.extend_from_slice(&extra); + let res = f.extract(&raw); + assert!( + res.is_err(), + "Extra trailing opcode should trigger TooManyOpcodes" + ); + } + + #[test] + fn test_every_fixed_opcode_position_rejects_wrong_opcode() { + let f = TestFixture::new(); + + let fixed_positions: Vec = EXPECTED_OPCODES + .iter() + .enumerate() + .filter_map(|(i, (exp, _))| match exp { + ExpectedOpCode::OpCode(_) => Some(i), + ExpectedOpCode::Data => None, + }) + .collect(); + + for &pos in &fixed_positions { + let raw = f.build_script_with_mutation_at(pos, |b| match pos { + 0 | 28 => { + b.add_op(OpElse).unwrap(); + } + 26 | 32 => { + b.add_op(OpSHA256).unwrap(); + } + 27 => { + b.add_op(OpCheckSig).unwrap(); + } + _ => { + b.add_op(OpReturn).unwrap(); + } + }); + + let res = f.extract(&raw); + assert!( + res.is_err(), + "Position {pos}: wrong opcode should be rejected" + ); + + match res.unwrap_err() { + KaspaError::OpcodeMismatch(p) => assert_eq!(p, pos), + other => panic!("Position {pos}: expected OpcodeMismatch, got {other:?}"), + } + } + } + + #[test] + fn test_op_if_replaced_with_op_else() { + let f = TestFixture::new(); + let raw = f.build_script_with_mutation_at(0, |b| { + b.add_op(OpElse).unwrap(); + }); + assert!(f.extract(&raw).is_err()); + } + + #[test] + fn test_op_sha256_replaced_with_op_checksig() { + let f = TestFixture::new(); + let raw = f.build_script_with_mutation_at(1, |b| { + b.add_op(OpCheckSig).unwrap(); + }); + assert!(f.extract(&raw).is_err()); + } + + #[test] + fn test_op_equalverify_at_3_replaced() { + let f = TestFixture::new(); + let raw = f.build_script_with_mutation_at(3, |b| { + b.add_op(OpSub).unwrap(); + }); + assert!(f.extract(&raw).is_err()); + } + + #[test] + fn test_op_txinputcount_replaced() { + let f = TestFixture::new(); + let raw = f.build_script_with_mutation_at(4, |b| { + b.add_op(OpTxOutputCount).unwrap(); + }); + assert!(f.extract(&raw).is_err()); + } + + #[test] + fn test_op_numequalverify_at_6_replaced() { + let f = TestFixture::new(); + let raw = f.build_script_with_mutation_at(6, |b| { + b.add_op(OpEqualVerify).unwrap(); + }); + assert!(f.extract(&raw).is_err()); + } + + #[test] + fn test_op_txoutputcount_replaced() { + let f = TestFixture::new(); + let raw = f.build_script_with_mutation_at(7, |b| { + b.add_op(OpTxInputCount).unwrap(); + }); + assert!(f.extract(&raw).is_err()); + } + + #[test] + fn test_op_txoutputspk_replaced() { + let f = TestFixture::new(); + let raw = f.build_script_with_mutation_at(12, |b| { + b.add_op(OpTxOutputAmount).unwrap(); + }); + assert!(f.extract(&raw).is_err()); + } + + #[test] + fn test_op_txoutputamount_replaced() { + let f = TestFixture::new(); + let raw = f.build_script_with_mutation_at(15, |b| { + b.add_op(OpTxOutputSpk).unwrap(); + }); + assert!(f.extract(&raw).is_err()); + } + + #[test] + fn test_op_txinputindex_replaced() { + let f = TestFixture::new(); + let raw = f.build_script_with_mutation_at(16, |b| { + b.add_op(OpTxInputAmount).unwrap(); + }); + assert!(f.extract(&raw).is_err()); + } + + #[test] + fn test_op_txinputamount_replaced() { + let f = TestFixture::new(); + let raw = f.build_script_with_mutation_at(17, |b| { + b.add_op(OpTxInputIndex).unwrap(); + }); + assert!(f.extract(&raw).is_err()); + } + + #[test] + fn test_op_sub_replaced() { + let f = TestFixture::new(); + let raw = f.build_script_with_mutation_at(19, |b| { + b.add_op(OpEqualVerify).unwrap(); + }); + assert!(f.extract(&raw).is_err()); + } + + #[test] + fn test_op_gte_replaced() { + let f = TestFixture::new(); + let raw = f.build_script_with_mutation_at(20, |b| { + b.add_op(OpSub).unwrap(); + }); + assert!(f.extract(&raw).is_err()); + } + + #[test] + fn test_op_else_replaced() { + let f = TestFixture::new(); + let raw = f.build_script_with_mutation_at(21, |b| { + b.add_op(OpIf).unwrap(); + }); + assert!(f.extract(&raw).is_err()); + } + + #[test] + fn test_op_cltv_replaced() { + let f = TestFixture::new(); + let raw = f.build_script_with_mutation_at(23, |b| { + b.add_op(OpCheckSig).unwrap(); + }); + assert!(f.extract(&raw).is_err()); + } + + #[test] + fn test_op_endif_at_26_replaced() { + let f = TestFixture::new(); + let raw = f.build_script_with_mutation_at(26, |b| { + b.add_op(OpSHA256).unwrap(); + }); + assert!(f.extract(&raw).is_err()); + } + + #[test] + fn test_op_false_at_27_replaced() { + let f = TestFixture::new(); + let raw = f.build_script_with_mutation_at(27, |b| { + b.add_op(OpCheckSig).unwrap(); + }); + assert!(f.extract(&raw).is_err()); + } + + #[test] + fn test_metadata_op_if_at_41_replaced() { + let f = TestFixture::new(); + let raw = f.build_script_with_mutation_at(41, |b| { + b.add_op(OpElse).unwrap(); + }); + assert!(f.extract(&raw).is_err()); + } + + #[test] + fn test_op_endif_at_32_replaced() { + let f = TestFixture::new(); + let raw = f.build_script_with_mutation_at(32, |b| { + b.add_op(OpSHA256).unwrap(); + }); + assert!(f.extract(&raw).is_err()); + } + + #[test] + fn test_all_zeros_rejected() { + let f = TestFixture::new(); + let raw = vec![0x00; 100]; + let res = f.extract(&raw); + assert!(res.is_err()); + } + + #[test] + fn test_random_bytes_rejected() { + let f = TestFixture::new(); + let raw: Vec = (0..150).map(|_| rand::random::()).collect(); + let res = f.extract(&raw); + assert!(res.is_err()); + } + + #[test] + fn test_valid_p2pk_script_rejected() { + let f = TestFixture::new(); + let spk = p2pk_spk(&f.sender); + let res = f.extract(spk.script()); + assert!(res.is_err(), "P2PK is not an HTLC"); + } + + #[test] + fn test_just_op_return_rejected() { + let f = TestFixture::new(); + let raw = ScriptBuilder::new().add_op(OpReturn).unwrap().drain(); + let res = f.extract(&raw); + assert!(res.is_err()); + } + + #[test] + fn test_metadata_envelope_op_false_replaced_with_op_true() { + let f = TestFixture::new(); + + let raw = f.build_script_with_mutation_at(27, |b| { + b.add_i64(1).unwrap(); + }); + let res = f.extract(&raw); + assert!(res.is_err(), "OP_TRUE at pos 27 should be rejected"); + } + + #[test] + fn test_metadata_envelope_missing_closing_op_endif() { + let f = TestFixture::new(); + + let raw = f.build_script_with_mutation_at(32, |b| { + b.add_op(OpFalse).unwrap(); + }); + let res = f.extract(&raw); + assert!(res.is_err()); + } + + #[test] + fn test_metadata_envelope_duplicate_rejected() { + let f = TestFixture::new(); + let mut raw = f.build_valid_script(); + + let extra = ScriptBuilder::new() + .add_op(OpFalse) + .unwrap() + .add_op(OpIf) + .unwrap() + .add_data(&f.swap_id) + .unwrap() + .add_data(&f.sender_receiver_address) + .unwrap() + .add_data(&[f.destination]) + .unwrap() + .add_op(OpEndIf) + .unwrap() + .drain(); + raw.extend_from_slice(&extra); + let res = f.extract(&raw); + assert!( + res.is_err(), + "Duplicate metadata envelope should be rejected" + ); + } + + #[test] + fn test_fuzz_single_byte_flip() { + let f = TestFixture::new(); + let valid = f.build_valid_script(); + let valid_c = f.extract(&valid).unwrap(); + + for byte_pos in 0..valid.len() { + for flip in [0x01u8, 0x80, 0xFF] { + let mut tampered = valid.clone(); + tampered[byte_pos] ^= flip; + + if tampered == valid { + continue; + } + + let res = f.extract(&tampered); + match res { + Err(_) => {} + Ok(c) => { + let differs = c.swap_id != valid_c.swap_id + || c.secret_hash != valid_c.secret_hash + || c.addresses.sender != valid_c.addresses.sender + || c.addresses.receiver != valid_c.addresses.receiver + || c.destination != valid_c.destination + || c.unlock_ts != valid_c.unlock_ts + || c.addresses.sender_destination + != valid_c.addresses.sender_destination; + + if !differs { + assert_eq!(c.swap_id, valid_c.swap_id); + assert_eq!(c.secret_hash, valid_c.secret_hash); + assert_eq!(c.addresses.sender, valid_c.addresses.sender); + assert_eq!(c.addresses.receiver, valid_c.addresses.receiver); + } + } + } + } + } + } + + #[test] + fn test_fuzz_multi_byte_corruption() { + let f = TestFixture::new(); + let valid = f.build_valid_script(); + let valid_c = f.extract(&valid).unwrap(); + let mut rng = rand::rng(); + + for _ in 0..500 { + let mut tampered = valid.clone(); + + let n_corruptions = rng.random_range(1..=5usize); + for _ in 0..n_corruptions { + let pos = rng.random_range(0..tampered.len()); + tampered[pos] = rand::random::(); + } + + if tampered == valid { + continue; + } + + let res = f.extract(&tampered); + match res { + Err(_) => {} + Ok(c) => { + let _differs = c.swap_id != valid_c.swap_id + || c.secret_hash != valid_c.secret_hash + || c.addresses.sender != valid_c.addresses.sender + || c.addresses.receiver != valid_c.addresses.receiver + || c.destination != valid_c.destination + || c.unlock_ts != valid_c.unlock_ts + || c.addresses.sender_destination != valid_c.addresses.sender_destination; + } + } + } + } + + #[test] + fn test_fuzz_truncation_at_every_length() { + let f = TestFixture::new(); + let valid = f.build_valid_script(); + + for truncate_to in 0..valid.len() { + let truncated = &valid[..truncate_to]; + let res = f.extract(truncated); + assert!( + res.is_err(), + "Truncated to {truncate_to} bytes should be rejected" + ); + } + } + + #[test] + fn test_fuzz_prepend_junk() { + let f = TestFixture::new(); + let valid = f.build_valid_script(); + + for prefix_len in 1..=10 { + let mut junk: Vec = (0..prefix_len).map(|_| rand::random::()).collect(); + junk.extend_from_slice(&valid); + let res = f.extract(&junk); + assert!( + res.is_err(), + "Prepending {prefix_len} junk bytes should be rejected" + ); + } + } + + #[test] + fn test_fuzz_append_junk() { + let f = TestFixture::new(); + let valid = f.build_valid_script(); + + for suffix_len in 1..=10 { + let mut extended = valid.clone(); + let junk: Vec = (0..suffix_len).map(|_| rand::random::()).collect(); + extended.extend_from_slice(&junk); + let res = f.extract(&extended); + assert!( + res.is_err(), + "Appending {suffix_len} junk bytes should be rejected" + ); + } + } + + #[test] + fn test_fuzz_random_scripts() { + let f = TestFixture::new(); + let mut rng = rand::rng(); + + for _ in 0..1000 { + let len = rng.random_range(0..500usize); + let raw: Vec = (0..len).map(|_| rand::random::()).collect(); + let res = f.extract(&raw); + if let Ok(c) = res { + assert_ne!( + c.swap_id, f.swap_id, + "Random script matched our swap_id — astronomically unlikely" + ); + } + } + } + + #[test] + fn test_reversed_script_rejected() { + let f = TestFixture::new(); + let mut raw = f.build_valid_script(); + raw.reverse(); + let res = f.extract(&raw); + assert!(res.is_err(), "Reversed script should be rejected"); + } + + #[test] + fn test_amount_passthrough_various() { + let f = TestFixture::new(); + let raw = f.build_valid_script(); + + for amount_str in ["0", "1", "999999999999", "100000000", ""] { + let parsed = crate::chains::kaspa::decode::parse_script(&raw) + .collect::, _>>() + .unwrap(); + let c = extract_commitment( + &parsed, + amount_str.to_string(), + Prefix::Devnet, + ChannelId::KaspaTn10, + ) + .unwrap(); + assert_eq!(c.amount.value, amount_str); + } + } + + #[test] + fn test_decimals_always_8() { + let f = TestFixture::new(); + let raw = f.build_valid_script(); + let c = f.extract(&raw).unwrap(); + assert_eq!(c.amount.decimals, 8); + } + + #[test] + fn test_empty_sender_receiver_address() { + let mut f = TestFixture::new(); + f.sender_receiver_address = vec![]; + let raw = f.build_valid_script(); + let res = f.extract(&raw); + + if let Ok(c) = res { + assert!( + c.addresses.sender_destination.len() <= 1, + "Empty address should encode as at most 1 byte, got {}", + c.addresses.sender_destination.len() + ); + } + } + + #[test] + fn test_long_sender_receiver_address() { + let mut f = TestFixture::new(); + + let long_addr = "kaspa:".to_string() + &"a".repeat(194); + f.sender_receiver_address = long_addr.as_bytes().to_vec(); + let raw = f.build_valid_script(); + let c = f.extract(&raw).unwrap(); + assert_eq!(c.addresses.sender_destination.len(), 200); + } + + #[test] + fn test_decode_u64_empty_bytes() { + assert_eq!(decode_u64_from_script(&[]), 0); + } + + #[test] + fn test_decode_u64_single_byte() { + assert_eq!(decode_u64_from_script(&[0x01]), 1); + assert_eq!(decode_u64_from_script(&[0xFF]), 255); + } + + #[test] + fn test_decode_u64_exact_8_bytes() { + let val: u64 = 1_700_007_200; + let bytes = val.to_le_bytes(); + assert_eq!(decode_u64_from_script(&bytes), val); + } + + #[test] + fn test_decode_u64_more_than_8_bytes() { + let mut bytes = 42u64.to_le_bytes().to_vec(); + bytes.extend_from_slice(&[0xAA, 0xBB, 0xCC]); + assert_eq!(decode_u64_from_script(&bytes), 42); + } + + #[test] + fn test_decode_u64_max_value() { + assert_eq!(decode_u64_from_script(&u64::MAX.to_le_bytes()), u64::MAX); + } + + #[test] + fn test_extract_opcode_data_op1_through_op16_via_timelock() { + for tl_secs in 1..=16u64 { + let mut f = TestFixture::new(); + f.timelock = tl_secs * 1000; + let raw = f.build_valid_script(); + let c = f.extract(&raw).unwrap(); + assert_eq!( + c.unlock_ts, tl_secs, + "Timelock {tl_secs}s should round-trip via ms" + ); + } + } + + #[test] + fn test_timelock_zero() { + let mut f = TestFixture::new(); + f.timelock = 0; + let raw = f.build_valid_script(); + let c = f.extract(&raw).unwrap(); + assert_eq!(c.unlock_ts, 0); + } + + #[test] + fn test_missing_data_sender_pubkey() { + let f = TestFixture::new(); + + let raw = f.build_script_with_mutation_at(24, |b| { + b.add_op(OpSub).unwrap(); + }); + let res = f.extract(&raw); + assert!(res.is_err(), "Non-push in sender pubkey slot should fail"); + } + + #[test] + fn test_script_exactly_one_fewer_opcode() { + let f = TestFixture::new(); + + let mut builder = ScriptBuilder::new(); + let receiver_spk = f.receiver_spk_vec(); + + builder.add_op(OpIf).unwrap(); + builder.add_op(OpSHA256).unwrap(); + builder.add_data(&f.secret_hash).unwrap(); + builder.add_op(OpEqualVerify).unwrap(); + builder.add_op(OpTxInputCount).unwrap(); + builder.add_i64(2).unwrap(); + builder.add_op(OpNumEqualVerify).unwrap(); + builder.add_op(OpTxOutputCount).unwrap(); + builder.add_i64(2).unwrap(); + builder.add_op(OpNumEqualVerify).unwrap(); + builder.add_data(&receiver_spk).unwrap(); + builder.add_i64(0).unwrap(); + builder.add_op(OpTxOutputSpk).unwrap(); + builder.add_op(OpEqualVerify).unwrap(); + builder.add_i64(0).unwrap(); + builder.add_op(OpTxOutputAmount).unwrap(); + builder.add_op(OpTxInputIndex).unwrap(); + builder.add_op(OpTxInputAmount).unwrap(); + builder.add_i64(SOLVER_REWARD).unwrap(); + builder.add_op(OpSub).unwrap(); + builder.add_op(OpGreaterThanOrEqual).unwrap(); + builder.add_op(OpElse).unwrap(); + builder.add_i64(f.timelock as i64).unwrap(); + builder.add_op(OpCheckLockTimeVerify).unwrap(); + builder.add_data(&f.sender_pub()).unwrap(); + builder.add_op(OpCheckSig).unwrap(); + builder.add_op(OpEndIf).unwrap(); + builder.add_op(OpFalse).unwrap(); + builder.add_op(OpIf).unwrap(); + builder.add_data(&f.swap_id).unwrap(); + builder.add_data(&f.sender_receiver_address).unwrap(); + builder.add_data(&[f.destination]).unwrap(); + + let raw = builder.drain(); + let res = f.extract(&raw); + assert!( + res.is_err(), + "Script missing final OP_ENDIF should be rejected" + ); + } + + #[test] + fn test_script_exactly_one_extra_opcode() { + let f = TestFixture::new(); + let mut raw = f.build_valid_script(); + + let extra = ScriptBuilder::new().add_op(OpFalse).unwrap().drain(); + raw.extend_from_slice(&extra); + let res = f.extract(&raw); + assert!( + res.is_err(), + "Script with one extra opcode should be rejected" + ); + } + + #[test] + fn test_destination_values_1_through_16() { + for d in 1..=16u8 { + let mut f = TestFixture::new(); + f.destination = d; + let raw = f.build_valid_script(); + let c = f.extract(&raw).unwrap(); + assert_eq!(c.destination, d, "Destination {d} should round-trip"); + } + } + + #[test] + fn test_destination_values_outside_small_int_range() { + for d in [17u8, 127, 128, 254] { + let mut f = TestFixture::new(); + f.destination = d; + let raw = f.build_valid_script(); + let c = f.extract(&raw).unwrap(); + assert_eq!(c.destination, d, "Destination {d} should round-trip"); + } + } + + #[test] + fn test_timelock_boundary_values() { + for tl_ms in [ + 0u64, + 1_000, + 15_000, + 16_000, + 17_000, + 127_000, + 128_000, + 255_000, + 256_000, + 32_767_000, + 32_768_000, + 8_388_607_000, + 8_388_608_000, + 2_147_483_647_000, + 2_147_483_648_000, + u64::MAX / 2, + ] { + let mut f = TestFixture::new(); + f.timelock = tl_ms; + let raw = f.build_valid_script(); + let c = f + .extract(&raw) + .unwrap_or_else(|e| panic!("Timelock {tl_ms}ms should parse, got {e:?}")); + assert_eq!( + c.unlock_ts, + tl_ms / 1000, + "Timelock {tl_ms}ms should round-trip to {}s", + tl_ms / 1000 + ); + } + } + + #[test] + fn template_starts_with_op_if_and_has_seven_data_fields() { + assert!(matches!( + EXPECTED_OPCODES.first().map(|(op, _)| op), + Some(ExpectedOpCode::OpCode(_)) + )); + let data_fields = EXPECTED_OPCODES + .iter() + .filter(|(op, ty)| matches!(op, ExpectedOpCode::Data) && *ty != DataType::Opcode) + .count(); + assert_eq!(data_fields, 7); + } +} diff --git a/crates/data/src/chains/kaspa/contracts/extract.rs b/crates/data/src/chains/kaspa/contracts/extract.rs deleted file mode 100644 index 78d1d70..0000000 --- a/crates/data/src/chains/kaspa/contracts/extract.rs +++ /dev/null @@ -1,257 +0,0 @@ -use ahash::AHashMap; -use kaspa_addresses::Prefix; -use kaspa_consensus_core::{hashing::sighash::SigHashReusedValuesUnsync, tx::ScriptPublicKey}; -use kaspa_txscript::{ - extract_script_pub_key_address, - opcodes::{ - OpCodeImplementation, - codes::{OpFalse, OpTrue}, - }, -}; - -use crate::chains::kaspa::error::{KaspaError, Result}; -use stroemnet_protocol::ChannelId; -use stroemnet_protocol::v1::{AddressesV1, AmountV1, CommitmentV1}; - -use super::contract_v1::{DataType, EXPECTED_OPCODES, ExpectedOpCode, VerifiableTransactionMock}; -use super::script::decode_u64_from_script; - -/// Extracts the relevant data from the HTLC script -fn extract_opcode_data( - opcode: &dyn OpCodeImplementation, -) -> Vec { - // If the opcode has associated data, return it. This is the case for pushdata opcodes. - let d = opcode.get_data(); - if !d.is_empty() { - return d.to_vec(); - } - - let val = opcode.value(); - match val { - 0x00 => vec![0u8], // OP_FALSE pushes an empty vector, - 0x51..=0x60 => { - // OP_1 to OP_16 push the numbers 1 to 16, encoded as a single byte with value 0x51 to 0x60. We convert this to the corresponding number. - vec![(val - 0x50)] - } - _ => vec![], // For other opcodes, we return an empty vector - } -} - -/// Compute a script public key from som arbitrary bytes -fn spk_from_bytes(bytes: &[u8]) -> Result { - // A valid script public key must be at least 2 bytes long to contain the version, plus some script data. - if bytes.len() < 2 { - return Err(KaspaError::InvalidSigScriptLength { - expected: 2, - got: bytes.len(), - }); - } - - // Parse the first 2 bytes as the version and the rest as script. - let version = u16::from_be_bytes([bytes[0], bytes[1]]); - let script = bytes[2..].to_vec(); - - Ok(ScriptPublicKey::from_vec(version, script)) -} - -/// Extracts a CommitmentV1 from a given HTLC script, -pub(crate) fn extract_commitment( - script: &Vec< - Box>, - >, - amount: String, - prefix: Prefix, - source_chain_id: ChannelId, -) -> Result { - // If the script has more or less opcodes than we expect for our HTLC contract, it's not valid. - if script.len() != EXPECTED_OPCODES.len() { - return Err(KaspaError::TooManyOpcodes); - } - - let mut data: AHashMap> = AHashMap::new(); - - // Go over all opcodes in the script - for (i, opcode) in script.iter().enumerate() { - // Try and get the opcode, otherwise we have an opcode count mismatch. - let (expected, label) = EXPECTED_OPCODES.get(i).ok_or(KaspaError::TooManyOpcodes)?; - - // Match the opcode against the expected opcode or data type for this position in the script. - match expected { - ExpectedOpCode::OpCode(expected_opcode) => { - // If the opcode value doesn't match the expected opcode, we have an opcode mismatch. - if opcode.value() != *expected_opcode { - tracing::error!( - "Opcode mismatch at position {i}: expected {label:?} (0x{expected_opcode:02x}), got 0x{:02x}", - opcode.value() - ); - return Err(KaspaError::OpcodeMismatch(i)); - } - } - ExpectedOpCode::Data => { - // If we expect data at this position, - // we extract it from the opcode and store it in our data map under the corresponding label. - if *label != DataType::Opcode { - data.insert(label.clone(), extract_opcode_data(opcode.as_ref())); - } - } - } - } - - // Now we have validated all the operations and extracted all data from the script. - - // Retrieve the swap id from the data map, ensuring it's present and has the correct length. - let swap_id: [u8; 32] = data - .get(&DataType::SwapId) - .ok_or(KaspaError::MissingData(DataType::SwapId))? - .clone() - .try_into() - .map_err(|_| KaspaError::InvalidSwapIdLength)?; - - // Retrieve the sender from the data map, ensuring it's present and not empty - let sender = data - .get(&DataType::SenderSpk) - .filter(|v| !v.is_empty()) - .ok_or(KaspaError::MissingData(DataType::SenderSpk))? - .clone(); - - // Retrieve the receiver from the data map, ensuring it's present and not empty - let receiver = data - .get(&DataType::ReceiverSpk) - .filter(|v| !v.is_empty()) - .ok_or(KaspaError::MissingData(DataType::ReceiverSpk))? - .clone(); - - // Retrieve the secret hash from the data map, ensuring it's present and has the correct length. - let secret_hash: [u8; 32] = data - .get(&DataType::SecretHash) - .ok_or(KaspaError::MissingData(DataType::SecretHash))? - .clone() - .try_into() - .map_err(|_| KaspaError::InvalidSecretHashLength)?; - - // Retrieve the timelock from the data map, ensuring it's present and not empty, - // then decode it from bytes to a u64 timestamp in seconds. - let unlock_ts_ms = decode_u64_from_script( - data.get(&DataType::Timelock) - .filter(|v| !v.is_empty()) - .ok_or(KaspaError::MissingData(DataType::Timelock))? - .as_slice(), - ); - - // Convert the unlock timestamp from milliseconds to seconds, - // as we want to work with second precision for timelocks. - let unlock_ts = unlock_ts_ms / 1000; - - // Retrieve the sender's destination address on the target chain from the data map, ensuring it's present and not empty. - let sender_destination_address = data - .get(&DataType::SenderReceiverAddress) - .filter(|v| !v.is_empty()) - .ok_or(KaspaError::MissingData(DataType::SenderReceiverAddress))? - .clone(); - - // Retrieve the destination channel id from the data map, ensuring it's present. - let destination = *data - .get(&DataType::Destination) - .ok_or(KaspaError::MissingData(DataType::Destination))? - .first() - .ok_or(KaspaError::MissingData(DataType::Destination))?; - - // If we've reached this point, it means we've successfully - // validated the script and extracted all necessary data to construct a CommitmentV1, which we do and return. - // Now we just need to decode some of the fields successfully in order to guarantee that this is a valid - // commitment. - Ok(CommitmentV1 { - swap_id, - addresses: AddressesV1::new( - extract_script_pub_key_address(&spk_from_bytes(&sender)?, prefix)?.to_string(), - extract_script_pub_key_address(&spk_from_bytes(&receiver)?, prefix)?.to_string(), - String::from_utf8(sender_destination_address)?, - ), - amount: AmountV1::new(amount, 8), - secret_hash, - unlock_ts, - source: source_chain_id as u8, - destination, - }) -} - -/// Extracts the secret from a reveal transaction sig script -pub(crate) fn extract_reveal_secret( - sig_script: &[Box< - dyn OpCodeImplementation, - >], -) -> Result<[u8; 32]> { - // If the sig script doesn have exactly 3 opcodes (secret, selector and redeem script), it's not valid. - if sig_script.len() != 3 { - return Err(KaspaError::InvalidSigScriptLength { - expected: 3, - got: sig_script.len(), - }); - } - - // Ensure we are in the reveal branch of the script by checking the selector opcode. - // If it's not OP_TRUE, it's not valid. - let selector = sig_script[1].value(); - if selector != OpTrue { - return Err(KaspaError::WrongBranchSelector { - expected: OpTrue, - got: selector, - }); - } - - // Extract the secret from the first opcode, ensuring it's present and has the correct length. - let secret = extract_opcode_data(sig_script[0].as_ref()); - if secret.is_empty() { - return Err(KaspaError::MissingSecret); - } - - // Convert the secret to a fixed-size array, ensuring it has the correct length. - let secret: [u8; 32] = secret - .try_into() - .map_err(|_| KaspaError::InvalidSecretLength)?; - - // Ensure the redeem script is present in the third opcode. We don't actually need to parse it here, - // but its presence is required for a valid reveal transaction. - let redeem_script = extract_opcode_data(sig_script[2].as_ref()); - if redeem_script.is_empty() { - return Err(KaspaError::MissingRedeemScript); - } - - Ok(secret) -} - -/// Validates that a refund transaction sig script is correctly formed, meaning it has the right number of opcodes, -/// the correct selector for the refund branch and includes a redeem script. -/// We don't need to extract any data from the sig script for refunds, -pub(crate) fn validate_refund_sig( - sig_script: &[Box< - dyn OpCodeImplementation, - >], -) -> Result<()> { - // If the sig script doesn have exactly 2 opcodes (selector and redeem script), it's not valid. - if sig_script.len() != 2 { - return Err(KaspaError::InvalidSigScriptLength { - expected: 2, - got: sig_script.len(), - }); - } - - // Ensure we are in the refund branch of the script by checking the selector opcode. - // If it's not OP_FALSE, it's not valid. - let selector = sig_script[0].value(); - if selector != OpFalse { - return Err(KaspaError::WrongBranchSelector { - expected: OpFalse, - got: selector, - }); - } - - // Ensure the redeem script is present in the second opcode. We don't actually need to parse it here, - // but its presence is required for a valid refund transaction. - let redeem_script = extract_opcode_data(sig_script[1].as_ref()); - if redeem_script.is_empty() { - return Err(KaspaError::MissingRedeemScript); - } - - Ok(()) -} diff --git a/crates/data/src/chains/kaspa/contracts/extract/commitment.rs b/crates/data/src/chains/kaspa/contracts/extract/commitment.rs new file mode 100644 index 0000000..3dcf0e9 --- /dev/null +++ b/crates/data/src/chains/kaspa/contracts/extract/commitment.rs @@ -0,0 +1,123 @@ +use kaspa_addresses::Prefix; +use kaspa_consensus_core::{hashing::sighash::SigHashReusedValuesUnsync, tx::ScriptPublicKey}; +use kaspa_txscript::{extract_script_pub_key_address, opcodes::OpCodeImplementation}; + +use super::super::contract_v1::{DataType, VerifiableTransactionMock}; +use super::super::script::decode_u64_from_script; +use super::opdata::collect_data; +use crate::chains::kaspa::error::{KaspaError, Result}; +use stroemnet_protocol::ChannelId; +use stroemnet_protocol::v1::{AddressesV1, AmountV1, CommitmentV1}; + +/// Convert some bytes into a script public key +/// using the serialization format +fn spk_from_bytes(bytes: &[u8]) -> Result { + let [v0, v1, script @ ..] = bytes else { + return Err(KaspaError::InvalidSigScriptLength { + expected: 2, + got: bytes.len(), + }); + }; + + let version = u16::from_be_bytes([*v0, *v1]); + + Ok(ScriptPublicKey::from_vec(version, script.to_vec())) +} + +/// Extract a htlc v1 commitment from the script +pub(crate) fn extract_commitment( + script: &Vec< + Box>, + >, + amount: String, // the value of the utxo + prefix: Prefix, // chain prefix + source_channel_id: ChannelId, // which channel id it came from +) -> Result { + // Compute all the data inside this script + let data = collect_data(script)?; + + // Retrieve the detected swap id + let swap_id: [u8; 32] = data + .get(&DataType::SwapId) + .ok_or(KaspaError::MissingData(DataType::SwapId))? + .clone() + .try_into() + .map_err(|_| KaspaError::InvalidSwapIdLength)?; + + // Retrieve the detected sender + let sender = data + .get(&DataType::SenderSpk) + .filter(|v| !v.is_empty()) + .ok_or(KaspaError::MissingData(DataType::SenderSpk))? + .clone(); + + // Retrieve the detected receiver + let receiver = data + .get(&DataType::ReceiverSpk) + .filter(|v| !v.is_empty()) + .ok_or(KaspaError::MissingData(DataType::ReceiverSpk))? + .clone(); + + // Retrieve the detected secret hash + let secret_hash: [u8; 32] = data + .get(&DataType::SecretHash) + .ok_or(KaspaError::MissingData(DataType::SecretHash))? + .clone() + .try_into() + .map_err(|_| KaspaError::InvalidSecretHashLength)?; + + // Retrieve the detected unlock ts in millis + let unlock_ts_ms = decode_u64_from_script( + data.get(&DataType::Timelock) + .filter(|v| !v.is_empty()) + .ok_or(KaspaError::MissingData(DataType::Timelock))? + .as_slice(), + ); + + // Compute the unlock ts in seconds + let unlock_ts = unlock_ts_ms / 1000; + + // Retrieve the senders destination address + let sender_destination_address = data + .get(&DataType::SenderReceiverAddress) + .filter(|v| !v.is_empty()) + .ok_or(KaspaError::MissingData(DataType::SenderReceiverAddress))? + .clone(); + + // Retrieve the destination address + let destination = *data + .get(&DataType::Destination) + .ok_or(KaspaError::MissingData(DataType::Destination))? + .first() + .ok_or(KaspaError::MissingData(DataType::Destination))?; + + // Create the commitment v1 + Ok(CommitmentV1 { + swap_id, + addresses: AddressesV1::new( + extract_script_pub_key_address(&spk_from_bytes(&sender)?, prefix)?.to_string(), + extract_script_pub_key_address(&spk_from_bytes(&receiver)?, prefix)?.to_string(), + String::from_utf8(sender_destination_address)?, + ), + amount: AmountV1::new(amount, 8), + secret_hash, + unlock_ts, + source: source_channel_id as u8, + destination, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn spk_from_bytes_rejects_short() { + assert!(spk_from_bytes(&[0u8]).is_err()); + } + + #[test] + fn spk_from_bytes_parses_version_and_script() { + assert!(spk_from_bytes(&[0, 1, 0xaa, 0xbb]).is_ok()); + } +} diff --git a/crates/data/src/chains/kaspa/contracts/extract/mod.rs b/crates/data/src/chains/kaspa/contracts/extract/mod.rs new file mode 100644 index 0000000..1e33123 --- /dev/null +++ b/crates/data/src/chains/kaspa/contracts/extract/mod.rs @@ -0,0 +1,8 @@ +mod commitment; +mod opdata; +mod restore; +mod sig; + +pub(crate) use commitment::extract_commitment; +pub(crate) use restore::commitments_from_scripts; +pub(crate) use sig::{extract_reveal_secret, validate_refund_sig}; diff --git a/crates/data/src/chains/kaspa/contracts/extract/opdata.rs b/crates/data/src/chains/kaspa/contracts/extract/opdata.rs new file mode 100644 index 0000000..3c8cb87 --- /dev/null +++ b/crates/data/src/chains/kaspa/contracts/extract/opdata.rs @@ -0,0 +1,87 @@ +use ahash::AHashMap; +use kaspa_consensus_core::hashing::sighash::SigHashReusedValuesUnsync; +use kaspa_txscript::opcodes::OpCodeImplementation; + +use super::super::contract_v1::{ + DataType, EXPECTED_OPCODES, ExpectedOpCode, VerifiableTransactionMock, +}; +use crate::chains::kaspa::error::{KaspaError, Result}; + +/// Small data pushed are converted into opcodes, +/// therefore we must do a little subtraction to extract their data +fn value_to_bytes(val: u8) -> Vec { + match val { + 0x00 => vec![0u8], + 0x51..=0x60 => vec![val - 0x50], + _ => vec![], + } +} + +/// Extract opcode data based on the opcode +pub(super) fn extract_opcode_data( + opcode: &dyn OpCodeImplementation, +) -> Vec { + // Attempt to return data from this opcode + let d = opcode.get_data(); + if !d.is_empty() { + // If this is not empty it was a data push so we can just convert it to vec + return d.to_vec(); + } + // Convert the value of the opcode to bytes + value_to_bytes(opcode.value()) +} + +/// Collect the data from the entire htlc script +pub(super) fn collect_data( + script: &[Box< + dyn OpCodeImplementation, + >], +) -> Result>> { + // if the script doesnt contain the exact amount of opcodes expected in a htlc script reject it + if script.len() != EXPECTED_OPCODES.len() { + return Err(KaspaError::TooManyOpcodes); + } + + let mut data: AHashMap> = AHashMap::new(); + + // Go over each opcode + for (i, opcode) in script.iter().enumerate() { + // Ensure the opcodes is of the expected type + let (expected, label) = EXPECTED_OPCODES.get(i).ok_or(KaspaError::TooManyOpcodes)?; + + match expected { + ExpectedOpCode::OpCode(expected_opcode) => { + // Validate the opcode type + if opcode.value() != *expected_opcode { + tracing::error!( + "Opcode mismatch at position {i}: expected {label:?} (0x{expected_opcode:02x}), got 0x{:02x}", + opcode.value() + ); + return Err(KaspaError::OpcodeMismatch(i)); + } + } + ExpectedOpCode::Data => { + // validate the opcode and then push the data to our container + if *label != DataType::Opcode { + data.insert(label.clone(), extract_opcode_data(opcode.as_ref())); + } + } + } + } + + // Return the data + Ok(data) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn value_to_bytes_maps_push_opcodes() { + assert_eq!(value_to_bytes(0x00), vec![0u8]); + assert_eq!(value_to_bytes(0x51), vec![1u8]); + assert_eq!(value_to_bytes(0x60), vec![16u8]); + assert!(value_to_bytes(0x99).is_empty()); + } +} diff --git a/crates/data/src/chains/kaspa/contracts/extract/restore.rs b/crates/data/src/chains/kaspa/contracts/extract/restore.rs new file mode 100644 index 0000000..581e0d9 --- /dev/null +++ b/crates/data/src/chains/kaspa/contracts/extract/restore.rs @@ -0,0 +1,49 @@ +use ahash::AHashMap; +use kaspa_addresses::Prefix; +use stroemnet_protocol::ChannelId; +use stroemnet_protocol::v1::CommitmentV1; + +use super::extract_commitment; +use crate::UtxoScript; +use crate::chains::kaspa::decode::parse_script; +use crate::chains::kaspa::error::Result; + +/// Convert a hashmap of scripts to a hashmap of commitments +pub(crate) fn commitments_from_scripts( + scripts: &AHashMap<[u8; 32], UtxoScript>, + prefix: Prefix, + source_channel_id: ChannelId, +) -> AHashMap<[u8; 32], CommitmentV1> { + let mut out = AHashMap::new(); + for (swap_id, script) in scripts { + // Reconstruct the commitment from each script and insert it into the map + match reconstruct(script, prefix, source_channel_id) { + Ok(c) => { + out.insert(*swap_id, c); + } + Err(e) => tracing::error!( + target: "settlement", + "reconstruct commitment {} failed: {e} — not settleable until investigated", + hex::encode(swap_id) + ), + } + } + out +} + +/// Convert a script into a commitment +fn reconstruct( + script: &UtxoScript, + prefix: Prefix, + source_channel_id: ChannelId, +) -> Result { + // Parse the script into individual opcodes + let ops = parse_script(&script.redeem_script).collect::, _>>()?; + // Extract the commitment from the vector of opcodes + extract_commitment( + &ops, + script.deposit_target.clone(), + prefix, + source_channel_id, + ) +} diff --git a/crates/data/src/chains/kaspa/contracts/extract/sig.rs b/crates/data/src/chains/kaspa/contracts/extract/sig.rs new file mode 100644 index 0000000..900c443 --- /dev/null +++ b/crates/data/src/chains/kaspa/contracts/extract/sig.rs @@ -0,0 +1,111 @@ +use kaspa_consensus_core::hashing::sighash::SigHashReusedValuesUnsync; +use kaspa_txscript::opcodes::{ + OpCodeImplementation, + codes::{OpFalse, OpTrue}, +}; + +use super::super::contract_v1::VerifiableTransactionMock; +use super::opdata::extract_opcode_data; +use crate::chains::kaspa::error::{KaspaError, Result}; + +/// From a signature script extract the secret +pub(crate) fn extract_reveal_secret( + sig_script: &[Box< + dyn OpCodeImplementation, + >], +) -> Result<[u8; 32]> { + // Extract the signature script exact opcodes + let [secret_op, selector_op, redeem_op] = sig_script else { + return Err(KaspaError::InvalidSigScriptLength { + expected: 3, + got: sig_script.len(), + }); + }; + + // Ensure the selector is true + let selector = selector_op.value(); + if selector != OpTrue { + return Err(KaspaError::WrongBranchSelector { + expected: OpTrue, + got: selector, + }); + } + + // Extract the secret at the secret opcode position + let secret = extract_opcode_data(secret_op.as_ref()); + if secret.is_empty() { + return Err(KaspaError::MissingSecret); + } + + // Convert the secret into expected length + let secret: [u8; 32] = secret + .try_into() + .map_err(|_| KaspaError::InvalidSecretLength)?; + + // Extract the redeem script + // otherwise it is still not a canonical spend + let redeem_script = extract_opcode_data(redeem_op.as_ref()); + if redeem_script.is_empty() { + return Err(KaspaError::MissingRedeemScript); + } + + Ok(secret) +} + +/// Validate that some signature script indeed tries to execute the refund branch of some swap +pub(crate) fn validate_refund_sig( + sig_script: &[Box< + dyn OpCodeImplementation, + >], +) -> Result<()> { + // Decode the expected sig script layout + let [selector_op, redeem_op] = sig_script else { + return Err(KaspaError::InvalidSigScriptLength { + expected: 2, + got: sig_script.len(), + }); + }; + + // Ensure the branch selector is false + let selector = selector_op.value(); + if selector != OpFalse { + return Err(KaspaError::WrongBranchSelector { + expected: OpFalse, + got: selector, + }); + } + + // Ensure the redeemscript is present + let redeem_script = extract_opcode_data(redeem_op.as_ref()); + if redeem_script.is_empty() { + return Err(KaspaError::MissingRedeemScript); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + type Ops = + [Box>]; + + #[test] + fn reveal_rejects_wrong_length() { + let empty: &Ops = &[]; + assert!(matches!( + extract_reveal_secret(empty), + Err(KaspaError::InvalidSigScriptLength { .. }) + )); + } + + #[test] + fn refund_rejects_wrong_length() { + let empty: &Ops = &[]; + assert!(matches!( + validate_refund_sig(empty), + Err(KaspaError::InvalidSigScriptLength { .. }) + )); + } +} diff --git a/crates/data/src/chains/kaspa/contracts/mod.rs b/crates/data/src/chains/kaspa/contracts/mod.rs index a2e72a4..2b6be73 100644 --- a/crates/data/src/chains/kaspa/contracts/mod.rs +++ b/crates/data/src/chains/kaspa/contracts/mod.rs @@ -1,4 +1,11 @@ -pub(super) mod contract_v1; -pub(super) mod extract; -pub(super) mod script; -mod tests; +mod contract_v1; +mod extract; +mod script; + +#[cfg(not(target_arch = "wasm32"))] +pub(crate) use contract_v1::SOLVER_REWARD; +pub(crate) use contract_v1::{ + DataType, VerifiableTransactionMock, create_htlc_script, extract_commitment, + extract_reveal_secret, validate_refund_sig, +}; +pub(crate) use extract::commitments_from_scripts; diff --git a/crates/data/src/chains/kaspa/contracts/script.rs b/crates/data/src/chains/kaspa/contracts/script.rs index 6fb7663..f963516 100644 --- a/crates/data/src/chains/kaspa/contracts/script.rs +++ b/crates/data/src/chains/kaspa/contracts/script.rs @@ -7,13 +7,10 @@ use kaspa_txscript::{ script_builder::{ScriptBuilder, ScriptBuilderResult}, }; -/// The reward amount for the solver in the HTLC script. +/// The solver reward in sompi for fulfilling a swap pub(crate) const SOLVER_REWARD: i64 = 10_000_000; -/// Creates an HTLC script for a swap with the given parameters. The script will allow the receiver to claim the funds -/// if they can provide the correct secret before the timelock expires, or allow the sender -/// to refund the funds after the timelock expires. The script also includes a branch for solvers -/// to claim a reward for helping to execute the swap, which requires providing the swap ID and sender's receiver address. +/// Create a htlc script smart contract with the provided params pub(crate) fn create_htlc_script( sender_spk: &[u8], sender_receiver_address: &[u8], @@ -37,49 +34,49 @@ pub(crate) fn create_htlc_script( tracing::info!(" swap_id: {:02x?}", swap_id); builder .add_op(OpIf)? - .add_op(OpSHA256)? + .add_op(OpSHA256)? // hash the input .add_data(secret_hash)? - .add_op(OpEqualVerify)? - .add_op(OpTxInputCount)? - .add_i64(2)? - .add_op(OpNumEqualVerify)? - .add_op(OpTxOutputCount)? - .add_i64(2)? - .add_op(OpNumEqualVerify)? - .add_data(receiver_spk)? - .add_i64(0)? - .add_op(OpTxOutputSpk)? - .add_op(OpEqualVerify)? - .add_i64(0)? - .add_op(OpTxOutputAmount)? - .add_op(OpTxInputIndex)? - .add_op(OpTxInputAmount)? - .add_i64(SOLVER_REWARD)? - .add_op(OpSub)? - .add_op(OpGreaterThanOrEqual)? - .add_op(OpElse)? - .add_i64(timelock as i64)? - .add_op(OpCheckLockTimeVerify)? - .add_op(OpTxInputCount)? - .add_i64(2)? + .add_op(OpEqualVerify)? // ensure its equal (user can spend they have the preimage) + .add_op(OpTxInputCount)? // get the tx input count + .add_i64(2)? // we only allow 2 inputs + .add_op(OpNumEqualVerify)? // verify eq + .add_op(OpTxOutputCount)? // get output count + .add_i64(2)? // we only allow 2 outputs + .add_op(OpNumEqualVerify)? // verify eq. + .add_data(receiver_spk)? // get the hardcoded receiver + .add_i64(0)? // set index 0 + .add_op(OpTxOutputSpk)? // get output spk at index 0 + .add_op(OpEqualVerify)? // ensure its eq to the receiver spk + .add_i64(0)? // set index 0 + .add_op(OpTxOutputAmount)? // get tx output amount at index 0 + .add_op(OpTxInputIndex)? // get the index of the input we are validating + .add_op(OpTxInputAmount)? // get the value of the htlc + .add_i64(SOLVER_REWARD)? // add solver reward + .add_op(OpSub)? // htlc_value - solver_reward + .add_op(OpGreaterThanOrEqual)? // should be geq than the output amount at index 0 + .add_op(OpElse)? // refund branch + .add_i64(timelock as i64)? // add the unlock time + .add_op(OpCheckLockTimeVerify)? // ensure time has passed + .add_op(OpTxInputCount)? // get input count + .add_i64(2)? // ensure only two inputs .add_op(OpNumEqualVerify)? - .add_op(OpTxOutputCount)? + .add_op(OpTxOutputCount)? // get tx outputs .add_i64(2)? - .add_op(OpNumEqualVerify)? - .add_data(sender_spk)? + .add_op(OpNumEqualVerify)? // ensure only 2 outputs + .add_data(sender_spk)? // get sender spk .add_i64(0)? - .add_op(OpTxOutputSpk)? - .add_op(OpEqualVerify)? + .add_op(OpTxOutputSpk)? // get output spk at index 0 + .add_op(OpEqualVerify)? // ensure they are eq .add_i64(0)? - .add_op(OpTxOutputAmount)? + .add_op(OpTxOutputAmount)? //get tx output at index 0 .add_op(OpTxInputIndex)? - .add_op(OpTxInputAmount)? - .add_i64(SOLVER_REWARD)? + .add_op(OpTxInputAmount)? // get htlc value + .add_i64(SOLVER_REWARD)? // solvers get reward for refunds too .add_op(OpSub)? - .add_op(OpGreaterThanOrEqual)? + .add_op(OpGreaterThanOrEqual)? // ensure its geq than the minimumr required amount .add_op(OpEndIf)? .add_op(OpFalse)? - .add_op(OpIf)? + .add_op(OpIf)? // add metadata .add_data(swap_id.as_slice())? .add_data(sender_receiver_address)? .add_data(&[destination])? @@ -88,9 +85,2897 @@ pub(crate) fn create_htlc_script( Ok(builder.drain()) } +/// Decode u64 from some bytes pub(crate) fn decode_u64_from_script(bytes: &[u8]) -> u64 { let mut padded = [0u8; 8]; - let copy_len = bytes.len().min(8); - padded[..copy_len].copy_from_slice(&bytes[..copy_len]); + for (slot, byte) in padded.iter_mut().zip(bytes) { + *slot = *byte; + } u64::from_le_bytes(padded) } + +#[cfg(test)] +mod tests { + #![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing + )] + use super::decode_u64_from_script; + use crate::chains::kaspa::contracts::contract_v1::{SOLVER_REWARD, create_htlc_script}; + + use kaspa_consensus_core::tx::{ + Transaction, TransactionInput, TransactionOutpoint, TransactionOutput, UtxoEntry, + }; + use kaspa_consensus_core::{ + hashing::{ + sighash::{SigHashReusedValuesUnsync, calc_schnorr_signature_hash}, + sighash_type::SIG_HASH_ALL, + }, + subnets::SUBNETWORK_ID_NATIVE, + tx::{MutableTransaction, VerifiableTransaction}, + }; + use kaspa_hashes::Hash; + use kaspa_txscript::opcodes::codes::{OpFalse, OpTrue}; + use kaspa_txscript::{ + TxScriptEngine, caches::Cache, pay_to_script_hash_script, script_builder::ScriptBuilder, + }; + use secp256k1::{Keypair, Secp256k1}; + use sha2::{Digest, Sha256}; + + use crate::chains::kaspa::broadcast::spk_to_vec; + use crate::chains::kaspa::test_helpers::p2pk_spk; + + fn build_ccr_tx( + htlc_spk: &kaspa_consensus_core::tx::ScriptPublicKey, + htlc_value: u64, + solver_fee_value: u64, + solver_fee_spk: &kaspa_consensus_core::tx::ScriptPublicKey, + outputs: Vec, + lock_time: u64, + ) -> (Transaction, Vec) { + let htlc_utxo = UtxoEntry::new(htlc_value, htlc_spk.clone(), 0, false); + let fee_utxo = UtxoEntry::new(solver_fee_value, solver_fee_spk.clone(), 0, false); + + let input0 = TransactionInput { + previous_outpoint: TransactionOutpoint::new(Hash::from_u64_word(1), 0), + signature_script: vec![], + sequence: 0, + sig_op_count: 0, + }; + let input1 = TransactionInput { + previous_outpoint: TransactionOutpoint::new(Hash::from_u64_word(2), 0), + signature_script: vec![], + sequence: 0, + sig_op_count: 1, + }; + + let tx = Transaction::new( + 1, + vec![input0, input1], + outputs, + lock_time, + SUBNETWORK_ID_NATIVE, + 0, + vec![], + ); + (tx, vec![htlc_utxo, fee_utxo]) + } + + fn build_refund_tx( + htlc_spk: &kaspa_consensus_core::tx::ScriptPublicKey, + htlc_value: u64, + outputs: Vec, + lock_time: u64, + ) -> (Transaction, Vec) { + let utxo = UtxoEntry::new(htlc_value, htlc_spk.clone(), 0, false); + + let input = TransactionInput { + previous_outpoint: TransactionOutpoint::new(Hash::from_u64_word(1), 0), + signature_script: vec![], + sequence: 0, + sig_op_count: 1, + }; + + let tx = Transaction::new( + 1, + vec![input], + outputs, + lock_time, + SUBNETWORK_ID_NATIVE, + 0, + vec![], + ); + (tx, vec![utxo]) + } + + fn build_refund_tx_2in( + htlc_spk: &kaspa_consensus_core::tx::ScriptPublicKey, + htlc_value: u64, + fee_spk: &kaspa_consensus_core::tx::ScriptPublicKey, + fee_value: u64, + outputs: Vec, + lock_time: u64, + ) -> (Transaction, Vec) { + let input0 = TransactionInput { + previous_outpoint: TransactionOutpoint::new(Hash::from_u64_word(1), 0), + signature_script: vec![], + sequence: 0, + sig_op_count: 0, + }; + let input1 = TransactionInput { + previous_outpoint: TransactionOutpoint::new(Hash::from_u64_word(2), 0), + signature_script: vec![], + sequence: 0, + sig_op_count: 1, + }; + + let utxo0 = UtxoEntry::new(htlc_value, htlc_spk.clone(), 0, false); + let utxo1 = UtxoEntry::new(fee_value, fee_spk.clone(), 0, false); + + let tx = Transaction::new( + 1, + vec![input0, input1], + outputs, + lock_time, + SUBNETWORK_ID_NATIVE, + 0, + vec![], + ); + (tx, vec![utxo0, utxo1]) + } + + #[test] + fn test_refund_succeeds_with_exact_amount_minus_solver_reward() { + let secp = Secp256k1::new(); + let mut rng = rand::rng(); + + let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let executor = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + + let secret: [u8; 32] = rand::random(); + let secret_hash: [u8; 32] = Sha256::digest(secret).into(); + let timelock = 1_700_000_000u64 + 7200; + + let receiver_spk_vec = spk_to_vec(&p2pk_spk(&receiver)); + let sender_p2pk = p2pk_spk(&sender); + let sender_spk_vec = spk_to_vec(&sender_p2pk); + let executor_p2pk = p2pk_spk(&executor); + + let htlc_script = create_htlc_script( + &sender_spk_vec, + &[], + &receiver_spk_vec, + &secret_hash, + timelock, + 0, + [0u8; 32], + ) + .expect("Script creation"); + + let spk = pay_to_script_hash_script(&htlc_script); + let input_value = 1_000_000_000u64; + let fee_input_value = 100_000u64; + let fee = 3_000u64; + let refund_amount = input_value - SOLVER_REWARD as u64; + + let output0 = TransactionOutput::new(refund_amount, sender_p2pk.clone()); + let output1 = TransactionOutput::new( + SOLVER_REWARD as u64 + fee_input_value - fee, + executor_p2pk.clone(), + ); + + let (tx, entries) = build_refund_tx_2in( + &spk, + input_value, + &executor_p2pk, + fee_input_value, + vec![output0, output1], + timelock, + ); + let mut mutable_tx = MutableTransaction::with_entries(tx, entries); + + let sig_script = ScriptBuilder::new() + .add_op(OpFalse) + .unwrap() + .add_data(&htlc_script) + .unwrap() + .drain(); + mutable_tx.tx.inputs[0].signature_script = sig_script; + + let reused_values = SigHashReusedValuesUnsync::new(); + let sig_hash = calc_schnorr_signature_hash( + &mutable_tx.as_verifiable(), + 1, + SIG_HASH_ALL, + &reused_values, + ); + let msg = secp256k1::Message::from_digest(sig_hash.as_bytes()); + let sig = executor.sign_schnorr(msg.as_ref()); + let mut signature = Vec::new(); + signature.extend_from_slice(sig.as_ref()); + signature.push(SIG_HASH_ALL.to_u8()); + mutable_tx.tx.inputs[1].signature_script = + ScriptBuilder::new().add_data(&signature).unwrap().drain(); + + let tx = mutable_tx.as_verifiable(); + let sig_cache = Cache::new(10_000); + let utxo_entry = tx.utxo(0).unwrap().clone(); + + let mut vm = TxScriptEngine::from_transaction_input( + &tx, + &tx.inputs()[0], + 0, + &utxo_entry, + &reused_values, + &sig_cache, + ); + vm.execute() + .expect("Refund should succeed with exact amount minus solver reward"); + } + + #[test] + fn test_refund_succeeds_with_more_than_minimum() { + let secp = Secp256k1::new(); + let mut rng = rand::rng(); + + let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let executor = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + + let secret: [u8; 32] = rand::random(); + let secret_hash: [u8; 32] = Sha256::digest(secret).into(); + let timelock = 1_700_000_000u64 + 7200; + + let receiver_spk_vec = spk_to_vec(&p2pk_spk(&receiver)); + let sender_p2pk = p2pk_spk(&sender); + let sender_spk_vec = spk_to_vec(&sender_p2pk); + let executor_p2pk = p2pk_spk(&executor); + + let htlc_script = create_htlc_script( + &sender_spk_vec, + &[], + &receiver_spk_vec, + &secret_hash, + timelock, + 0, + [0u8; 32], + ) + .expect("Script creation"); + + let spk = pay_to_script_hash_script(&htlc_script); + let input_value = 1_000_000_000u64; + let fee_input_value = 100_000u64; + let fee = 3_000u64; + + let output0 = TransactionOutput::new(input_value, sender_p2pk.clone()); + let output1 = TransactionOutput::new(fee_input_value - fee, executor_p2pk.clone()); + + let (tx, entries) = build_refund_tx_2in( + &spk, + input_value, + &executor_p2pk, + fee_input_value, + vec![output0, output1], + timelock, + ); + let mut mutable_tx = MutableTransaction::with_entries(tx, entries); + + let sig_script = ScriptBuilder::new() + .add_op(OpFalse) + .unwrap() + .add_data(&htlc_script) + .unwrap() + .drain(); + mutable_tx.tx.inputs[0].signature_script = sig_script; + + let reused_values = SigHashReusedValuesUnsync::new(); + let sig_hash = calc_schnorr_signature_hash( + &mutable_tx.as_verifiable(), + 1, + SIG_HASH_ALL, + &reused_values, + ); + let msg = secp256k1::Message::from_digest(sig_hash.as_bytes()); + let sig = executor.sign_schnorr(msg.as_ref()); + let mut signature = Vec::new(); + signature.extend_from_slice(sig.as_ref()); + signature.push(SIG_HASH_ALL.to_u8()); + mutable_tx.tx.inputs[1].signature_script = + ScriptBuilder::new().add_data(&signature).unwrap().drain(); + + let tx = mutable_tx.as_verifiable(); + let sig_cache = Cache::new(10_000); + let utxo_entry = tx.utxo(0).unwrap().clone(); + + let mut vm = TxScriptEngine::from_transaction_input( + &tx, + &tx.inputs()[0], + 0, + &utxo_entry, + &reused_values, + &sig_cache, + ); + vm.execute() + .expect("Refund should succeed when sender gets more than minimum"); + } + + #[test] + fn test_refund_fails_when_sender_gets_one_sompi_less_than_minimum() { + let secp = Secp256k1::new(); + let mut rng = rand::rng(); + + let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let executor = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + + let secret: [u8; 32] = rand::random(); + let secret_hash: [u8; 32] = Sha256::digest(secret).into(); + let timelock = 1_700_000_000u64 + 7200; + + let receiver_spk_vec = spk_to_vec(&p2pk_spk(&receiver)); + let sender_p2pk = p2pk_spk(&sender); + let sender_spk_vec = spk_to_vec(&sender_p2pk); + let executor_p2pk = p2pk_spk(&executor); + + let htlc_script = create_htlc_script( + &sender_spk_vec, + &[], + &receiver_spk_vec, + &secret_hash, + timelock, + 0, + [0u8; 32], + ) + .expect("Script creation"); + + let spk = pay_to_script_hash_script(&htlc_script); + let input_value = 1_000_000_000u64; + let fee_input_value = 100_000u64; + + let refund_amount = input_value - SOLVER_REWARD as u64 - 1; + let output0 = TransactionOutput::new(refund_amount, sender_p2pk.clone()); + let output1 = TransactionOutput::new( + SOLVER_REWARD as u64 + 1 + fee_input_value, + executor_p2pk.clone(), + ); + + let (tx, entries) = build_refund_tx_2in( + &spk, + input_value, + &executor_p2pk, + fee_input_value, + vec![output0, output1], + timelock, + ); + let mut mutable_tx = MutableTransaction::with_entries(tx, entries); + + let sig_script = ScriptBuilder::new() + .add_op(OpFalse) + .unwrap() + .add_data(&htlc_script) + .unwrap() + .drain(); + mutable_tx.tx.inputs[0].signature_script = sig_script; + + let tx = mutable_tx.as_verifiable(); + let sig_cache = Cache::new(10_000); + let reused_values = SigHashReusedValuesUnsync::new(); + let utxo_entry = tx.utxo(0).unwrap().clone(); + + let mut vm = TxScriptEngine::from_transaction_input( + &tx, + &tx.inputs()[0], + 0, + &utxo_entry, + &reused_values, + &sig_cache, + ); + vm.execute() + .expect_err("Should fail when sender gets 1 sompi less than minimum"); + } + + #[test] + fn test_refund_fails_when_executor_takes_double_reward() { + let secp = Secp256k1::new(); + let mut rng = rand::rng(); + + let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let executor = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + + let secret: [u8; 32] = rand::random(); + let secret_hash: [u8; 32] = Sha256::digest(secret).into(); + let timelock = 1_700_000_000u64 + 7200; + + let receiver_spk_vec = spk_to_vec(&p2pk_spk(&receiver)); + let sender_p2pk = p2pk_spk(&sender); + let sender_spk_vec = spk_to_vec(&sender_p2pk); + let executor_p2pk = p2pk_spk(&executor); + + let htlc_script = create_htlc_script( + &sender_spk_vec, + &[], + &receiver_spk_vec, + &secret_hash, + timelock, + 0, + [0u8; 32], + ) + .expect("Script creation"); + + let spk = pay_to_script_hash_script(&htlc_script); + let input_value = 1_000_000_000u64; + let fee_input_value = 100_000u64; + + let stolen = SOLVER_REWARD as u64 * 2; + let refund_amount = input_value - stolen; + let output0 = TransactionOutput::new(refund_amount, sender_p2pk.clone()); + let output1 = TransactionOutput::new(stolen + fee_input_value, executor_p2pk.clone()); + + let (tx, entries) = build_refund_tx_2in( + &spk, + input_value, + &executor_p2pk, + fee_input_value, + vec![output0, output1], + timelock, + ); + let mut mutable_tx = MutableTransaction::with_entries(tx, entries); + + let sig_script = ScriptBuilder::new() + .add_op(OpFalse) + .unwrap() + .add_data(&htlc_script) + .unwrap() + .drain(); + mutable_tx.tx.inputs[0].signature_script = sig_script; + + let tx = mutable_tx.as_verifiable(); + let sig_cache = Cache::new(10_000); + let reused_values = SigHashReusedValuesUnsync::new(); + let utxo_entry = tx.utxo(0).unwrap().clone(); + + let mut vm = TxScriptEngine::from_transaction_input( + &tx, + &tx.inputs()[0], + 0, + &utxo_entry, + &reused_values, + &sig_cache, + ); + vm.execute() + .expect_err("Should fail when executor takes double the solver reward"); + } + + #[test] + fn test_refund_fails_when_executor_takes_entire_htlc() { + let secp = Secp256k1::new(); + let mut rng = rand::rng(); + + let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let executor = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + + let secret: [u8; 32] = rand::random(); + let secret_hash: [u8; 32] = Sha256::digest(secret).into(); + let timelock = 1_700_000_000u64 + 7200; + + let receiver_spk_vec = spk_to_vec(&p2pk_spk(&receiver)); + let sender_p2pk = p2pk_spk(&sender); + let sender_spk_vec = spk_to_vec(&sender_p2pk); + let executor_p2pk = p2pk_spk(&executor); + + let htlc_script = create_htlc_script( + &sender_spk_vec, + &[], + &receiver_spk_vec, + &secret_hash, + timelock, + 0, + [0u8; 32], + ) + .expect("Script creation"); + + let spk = pay_to_script_hash_script(&htlc_script); + let input_value = 1_000_000_000u64; + let fee_input_value = 100_000u64; + + let output0 = TransactionOutput::new(0, sender_p2pk.clone()); + let output1 = TransactionOutput::new(input_value + fee_input_value, executor_p2pk.clone()); + + let (tx, entries) = build_refund_tx_2in( + &spk, + input_value, + &executor_p2pk, + fee_input_value, + vec![output0, output1], + timelock, + ); + let mut mutable_tx = MutableTransaction::with_entries(tx, entries); + + let sig_script = ScriptBuilder::new() + .add_op(OpFalse) + .unwrap() + .add_data(&htlc_script) + .unwrap() + .drain(); + mutable_tx.tx.inputs[0].signature_script = sig_script; + + let tx = mutable_tx.as_verifiable(); + let sig_cache = Cache::new(10_000); + let reused_values = SigHashReusedValuesUnsync::new(); + let utxo_entry = tx.utxo(0).unwrap().clone(); + + let mut vm = TxScriptEngine::from_transaction_input( + &tx, + &tx.inputs()[0], + 0, + &utxo_entry, + &reused_values, + &sig_cache, + ); + vm.execute() + .expect_err("Should fail when executor takes entire HTLC amount"); + } + + #[test] + fn test_refund_fails_when_sender_gets_half() { + let secp = Secp256k1::new(); + let mut rng = rand::rng(); + + let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let executor = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + + let secret: [u8; 32] = rand::random(); + let secret_hash: [u8; 32] = Sha256::digest(secret).into(); + let timelock = 1_700_000_000u64 + 7200; + + let receiver_spk_vec = spk_to_vec(&p2pk_spk(&receiver)); + let sender_p2pk = p2pk_spk(&sender); + let sender_spk_vec = spk_to_vec(&sender_p2pk); + let executor_p2pk = p2pk_spk(&executor); + + let htlc_script = create_htlc_script( + &sender_spk_vec, + &[], + &receiver_spk_vec, + &secret_hash, + timelock, + 0, + [0u8; 32], + ) + .expect("Script creation"); + + let spk = pay_to_script_hash_script(&htlc_script); + let input_value = 1_000_000_000u64; + let fee_input_value = 100_000u64; + + let half = input_value / 2; + let output0 = TransactionOutput::new(half, sender_p2pk.clone()); + let output1 = TransactionOutput::new(half + fee_input_value, executor_p2pk.clone()); + + let (tx, entries) = build_refund_tx_2in( + &spk, + input_value, + &executor_p2pk, + fee_input_value, + vec![output0, output1], + timelock, + ); + let mut mutable_tx = MutableTransaction::with_entries(tx, entries); + + let sig_script = ScriptBuilder::new() + .add_op(OpFalse) + .unwrap() + .add_data(&htlc_script) + .unwrap() + .drain(); + mutable_tx.tx.inputs[0].signature_script = sig_script; + + let tx = mutable_tx.as_verifiable(); + let sig_cache = Cache::new(10_000); + let reused_values = SigHashReusedValuesUnsync::new(); + let utxo_entry = tx.utxo(0).unwrap().clone(); + + let mut vm = TxScriptEngine::from_transaction_input( + &tx, + &tx.inputs()[0], + 0, + &utxo_entry, + &reused_values, + &sig_cache, + ); + vm.execute() + .expect_err("Should fail when sender only gets half"); + } + + #[test] + fn test_refund_succeeds_with_small_htlc_amount() { + let secp = Secp256k1::new(); + let mut rng = rand::rng(); + + let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let executor = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + + let secret: [u8; 32] = rand::random(); + let secret_hash: [u8; 32] = Sha256::digest(secret).into(); + let timelock = 1_700_000_000u64 + 7200; + + let receiver_spk_vec = spk_to_vec(&p2pk_spk(&receiver)); + let sender_p2pk = p2pk_spk(&sender); + let sender_spk_vec = spk_to_vec(&sender_p2pk); + let executor_p2pk = p2pk_spk(&executor); + + let htlc_script = create_htlc_script( + &sender_spk_vec, + &[], + &receiver_spk_vec, + &secret_hash, + timelock, + 0, + [0u8; 32], + ) + .expect("Script creation"); + + let spk = pay_to_script_hash_script(&htlc_script); + + let input_value = SOLVER_REWARD as u64; + let fee_input_value = 100_000u64; + let fee = 3_000u64; + + let output0 = TransactionOutput::new(0, sender_p2pk.clone()); + let output1 = + TransactionOutput::new(input_value + fee_input_value - fee, executor_p2pk.clone()); + + let (tx, entries) = build_refund_tx_2in( + &spk, + input_value, + &executor_p2pk, + fee_input_value, + vec![output0, output1], + timelock, + ); + let mut mutable_tx = MutableTransaction::with_entries(tx, entries); + + let sig_script = ScriptBuilder::new() + .add_op(OpFalse) + .unwrap() + .add_data(&htlc_script) + .unwrap() + .drain(); + mutable_tx.tx.inputs[0].signature_script = sig_script; + + let reused_values = SigHashReusedValuesUnsync::new(); + let sig_hash = calc_schnorr_signature_hash( + &mutable_tx.as_verifiable(), + 1, + SIG_HASH_ALL, + &reused_values, + ); + let msg = secp256k1::Message::from_digest(sig_hash.as_bytes()); + let sig = executor.sign_schnorr(msg.as_ref()); + let mut signature = Vec::new(); + signature.extend_from_slice(sig.as_ref()); + signature.push(SIG_HASH_ALL.to_u8()); + mutable_tx.tx.inputs[1].signature_script = + ScriptBuilder::new().add_data(&signature).unwrap().drain(); + + let tx = mutable_tx.as_verifiable(); + let sig_cache = Cache::new(10_000); + let utxo_entry = tx.utxo(0).unwrap().clone(); + + let mut vm = TxScriptEngine::from_transaction_input( + &tx, + &tx.inputs()[0], + 0, + &utxo_entry, + &reused_values, + &sig_cache, + ); + vm.execute() + .expect("Refund should succeed when HTLC equals solver reward (sender gets 0)"); + } + + #[test] + fn test_refund_path_success() { + let secp = Secp256k1::new(); + let mut rng = rand::rng(); + + let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let executor = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + + let secret: [u8; 32] = rand::random(); + let secret_hash: [u8; 32] = Sha256::digest(secret).into(); + let timelock = 1_700_000_000u64 + 7200; + + let receiver_spk_vec = spk_to_vec(&p2pk_spk(&receiver)); + let sender_p2pk = p2pk_spk(&sender); + let sender_spk_vec = spk_to_vec(&sender_p2pk); + let executor_p2pk = p2pk_spk(&executor); + + let htlc_script = create_htlc_script( + &sender_spk_vec, + &[], + &receiver_spk_vec, + &secret_hash, + timelock, + 0, + [0u8; 32], + ) + .expect("Script creation"); + + let spk = pay_to_script_hash_script(&htlc_script); + let input_value = 1_000_000_000u64; + let fee_input_value = 100_000u64; + let fee = 3_000u64; + + let output0 = TransactionOutput::new(input_value, sender_p2pk.clone()); + let output1 = TransactionOutput::new(fee_input_value - fee, executor_p2pk.clone()); + + let (tx, entries) = build_refund_tx_2in( + &spk, + input_value, + &executor_p2pk, + fee_input_value, + vec![output0, output1], + timelock, + ); + let mut mutable_tx = MutableTransaction::with_entries(tx, entries); + + let sig_script = ScriptBuilder::new() + .add_op(OpFalse) + .unwrap() + .add_data(&htlc_script) + .unwrap() + .drain(); + mutable_tx.tx.inputs[0].signature_script = sig_script; + + let reused_values = SigHashReusedValuesUnsync::new(); + let sig_hash = calc_schnorr_signature_hash( + &mutable_tx.as_verifiable(), + 1, + SIG_HASH_ALL, + &reused_values, + ); + let msg = secp256k1::Message::from_digest(sig_hash.as_bytes()); + let sig = executor.sign_schnorr(msg.as_ref()); + let mut signature = Vec::new(); + signature.extend_from_slice(sig.as_ref()); + signature.push(SIG_HASH_ALL.to_u8()); + let fee_sig_script = ScriptBuilder::new().add_data(&signature).unwrap().drain(); + mutable_tx.tx.inputs[1].signature_script = fee_sig_script; + + let tx = mutable_tx.as_verifiable(); + let sig_cache = Cache::new(10_000); + let utxo_entry = tx.utxo(0).unwrap().clone(); + + let mut vm = TxScriptEngine::from_transaction_input( + &tx, + &tx.inputs()[0], + 0, + &utxo_entry, + &reused_values, + &sig_cache, + ); + vm.execute().expect("Refund path should succeed"); + } + + #[test] + fn test_refund_fails_before_timelock() { + let secp = Secp256k1::new(); + let mut rng = rand::rng(); + + let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let executor = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + + let secret: [u8; 32] = rand::random(); + let secret_hash: [u8; 32] = Sha256::digest(secret).into(); + let timelock = 1_700_000_000u64 + 7200; + + let receiver_spk_vec = spk_to_vec(&p2pk_spk(&receiver)); + let sender_p2pk = p2pk_spk(&sender); + let sender_spk_vec = spk_to_vec(&sender_p2pk); + let executor_p2pk = p2pk_spk(&executor); + + let htlc_script = create_htlc_script( + &sender_spk_vec, + &[], + &receiver_spk_vec, + &secret_hash, + timelock, + 0, + [0u8; 32], + ) + .expect("Script creation"); + + let spk = pay_to_script_hash_script(&htlc_script); + let input_value = 1_000_000_000u64; + let fee_input_value = 100_000u64; + let fee = 3_000u64; + + let output0 = TransactionOutput::new(input_value, sender_p2pk.clone()); + let output1 = TransactionOutput::new(fee_input_value - fee, executor_p2pk.clone()); + + let (tx, entries) = build_refund_tx_2in( + &spk, + input_value, + &executor_p2pk, + fee_input_value, + vec![output0, output1], + timelock - 1, + ); + let mut mutable_tx = MutableTransaction::with_entries(tx, entries); + + let sig_script = ScriptBuilder::new() + .add_op(OpFalse) + .unwrap() + .add_data(&htlc_script) + .unwrap() + .drain(); + mutable_tx.tx.inputs[0].signature_script = sig_script; + + let tx = mutable_tx.as_verifiable(); + let sig_cache = Cache::new(10_000); + let reused_values = SigHashReusedValuesUnsync::new(); + let utxo_entry = tx.utxo(0).unwrap().clone(); + + let mut vm = TxScriptEngine::from_transaction_input( + &tx, + &tx.inputs()[0], + 0, + &utxo_entry, + &reused_values, + &sig_cache, + ); + vm.execute().expect_err("Should fail before timelock"); + } + + #[test] + fn test_refund_fails_when_receiver_redirects_funds() { + let secp = Secp256k1::new(); + let mut rng = rand::rng(); + + let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let executor = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + + let secret: [u8; 32] = rand::random(); + let secret_hash: [u8; 32] = Sha256::digest(secret).into(); + let timelock = 1_700_000_000u64 + 7200; + + let receiver_spk_vec = spk_to_vec(&p2pk_spk(&receiver)); + let sender_p2pk = p2pk_spk(&sender); + let sender_spk_vec = spk_to_vec(&sender_p2pk); + let executor_p2pk = p2pk_spk(&executor); + + let htlc_script = create_htlc_script( + &sender_spk_vec, + &[], + &receiver_spk_vec, + &secret_hash, + timelock, + 0, + [0u8; 32], + ) + .expect("Script creation"); + + let spk = pay_to_script_hash_script(&htlc_script); + let input_value = 1_000_000_000u64; + let fee_input_value = 100_000u64; + let fee = 3_000u64; + + let output0 = TransactionOutput::new(input_value, p2pk_spk(&receiver)); + let output1 = TransactionOutput::new(fee_input_value - fee, executor_p2pk.clone()); + + let (tx, entries) = build_refund_tx_2in( + &spk, + input_value, + &executor_p2pk, + fee_input_value, + vec![output0, output1], + timelock, + ); + let mut mutable_tx = MutableTransaction::with_entries(tx, entries); + + let sig_script = ScriptBuilder::new() + .add_op(OpFalse) + .unwrap() + .add_data(&htlc_script) + .unwrap() + .drain(); + mutable_tx.tx.inputs[0].signature_script = sig_script; + + let tx = mutable_tx.as_verifiable(); + let sig_cache = Cache::new(10_000); + let reused_values = SigHashReusedValuesUnsync::new(); + let utxo_entry = tx.utxo(0).unwrap().clone(); + + let mut vm = TxScriptEngine::from_transaction_input( + &tx, + &tx.inputs()[0], + 0, + &utxo_entry, + &reused_values, + &sig_cache, + ); + vm.execute() + .expect_err("Receiver should not be able to redirect refund"); + } + + #[test] + fn test_refund_fails_when_attacker_redirects_funds() { + let secp = Secp256k1::new(); + let mut rng = rand::rng(); + + let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let attacker = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + + let secret: [u8; 32] = rand::random(); + let secret_hash: [u8; 32] = Sha256::digest(secret).into(); + let timelock = 1_700_000_000u64 + 7200; + + let receiver_spk_vec = spk_to_vec(&p2pk_spk(&receiver)); + let sender_p2pk = p2pk_spk(&sender); + let sender_spk_vec = spk_to_vec(&sender_p2pk); + let attacker_p2pk = p2pk_spk(&attacker); + + let htlc_script = create_htlc_script( + &sender_spk_vec, + &[], + &receiver_spk_vec, + &secret_hash, + timelock, + 0, + [0u8; 32], + ) + .expect("Script creation"); + + let spk = pay_to_script_hash_script(&htlc_script); + let input_value = 1_000_000_000u64; + let fee_input_value = 100_000u64; + let fee = 3_000u64; + + let output0 = TransactionOutput::new(input_value, attacker_p2pk.clone()); + let output1 = TransactionOutput::new(fee_input_value - fee, attacker_p2pk.clone()); + + let (tx, entries) = build_refund_tx_2in( + &spk, + input_value, + &attacker_p2pk, + fee_input_value, + vec![output0, output1], + timelock, + ); + let mut mutable_tx = MutableTransaction::with_entries(tx, entries); + + let sig_script = ScriptBuilder::new() + .add_op(OpFalse) + .unwrap() + .add_data(&htlc_script) + .unwrap() + .drain(); + mutable_tx.tx.inputs[0].signature_script = sig_script; + + let tx = mutable_tx.as_verifiable(); + let sig_cache = Cache::new(10_000); + let reused_values = SigHashReusedValuesUnsync::new(); + let utxo_entry = tx.utxo(0).unwrap().clone(); + + let mut vm = TxScriptEngine::from_transaction_input( + &tx, + &tx.inputs()[0], + 0, + &utxo_entry, + &reused_values, + &sig_cache, + ); + vm.execute() + .expect_err("Attacker should not be able to redirect refund"); + } + + #[test] + fn test_refund_fails_with_finalized_sequence() { + let secp = Secp256k1::new(); + let mut rng = rand::rng(); + + let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let executor = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + + let secret: [u8; 32] = rand::random(); + let secret_hash: [u8; 32] = Sha256::digest(secret).into(); + let timelock = 1_700_000_000u64 + 7200; + + let receiver_spk_vec = spk_to_vec(&p2pk_spk(&receiver)); + let sender_p2pk = p2pk_spk(&sender); + let sender_spk_vec = spk_to_vec(&sender_p2pk); + let executor_p2pk = p2pk_spk(&executor); + + let htlc_script = create_htlc_script( + &sender_spk_vec, + &[], + &receiver_spk_vec, + &secret_hash, + timelock, + 0, + [0u8; 32], + ) + .expect("Script creation"); + + let spk = pay_to_script_hash_script(&htlc_script); + let input_value = 1_000_000_000u64; + let fee_input_value = 100_000u64; + let fee = 3_000u64; + + let output0 = TransactionOutput::new(input_value, sender_p2pk.clone()); + let output1 = TransactionOutput::new(fee_input_value - fee, executor_p2pk.clone()); + + let utxo0 = UtxoEntry::new(input_value, spk.clone(), 0, false); + let utxo1 = UtxoEntry::new(fee_input_value, executor_p2pk.clone(), 0, false); + + let input0 = TransactionInput { + previous_outpoint: TransactionOutpoint::new(Hash::from_u64_word(1), 0), + signature_script: vec![], + sequence: u64::MAX, + sig_op_count: 0, + }; + let input1 = TransactionInput { + previous_outpoint: TransactionOutpoint::new(Hash::from_u64_word(2), 0), + signature_script: vec![], + sequence: 0, + sig_op_count: 1, + }; + + let tx = Transaction::new( + 1, + vec![input0, input1], + vec![output0, output1], + timelock, + SUBNETWORK_ID_NATIVE, + 0, + vec![], + ); + let mut mutable_tx = MutableTransaction::with_entries(tx, vec![utxo0, utxo1]); + + let sig_script = ScriptBuilder::new() + .add_op(OpFalse) + .unwrap() + .add_data(&htlc_script) + .unwrap() + .drain(); + mutable_tx.tx.inputs[0].signature_script = sig_script; + + let tx = mutable_tx.as_verifiable(); + let sig_cache = Cache::new(10_000); + let reused_values = SigHashReusedValuesUnsync::new(); + let utxo_entry = tx.utxo(0).unwrap().clone(); + + let mut vm = TxScriptEngine::from_transaction_input( + &tx, + &tx.inputs()[0], + 0, + &utxo_entry, + &reused_values, + &sig_cache, + ); + vm.execute() + .expect_err("Should fail with finalized sequence"); + } + + #[test] + fn test_refund_succeeds_with_locktime_above_timelock() { + let secp = Secp256k1::new(); + let mut rng = rand::rng(); + + let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let executor = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + + let secret: [u8; 32] = rand::random(); + let secret_hash: [u8; 32] = Sha256::digest(secret).into(); + let timelock = 1_700_000_000u64 + 7200; + + let receiver_spk_vec = spk_to_vec(&p2pk_spk(&receiver)); + let sender_p2pk = p2pk_spk(&sender); + let sender_spk_vec = spk_to_vec(&sender_p2pk); + let executor_p2pk = p2pk_spk(&executor); + + let htlc_script = create_htlc_script( + &sender_spk_vec, + &[], + &receiver_spk_vec, + &secret_hash, + timelock, + 0, + [0u8; 32], + ) + .expect("Script creation"); + + let spk = pay_to_script_hash_script(&htlc_script); + let input_value = 1_000_000_000u64; + let fee_input_value = 100_000u64; + let fee = 3_000u64; + + let output0 = TransactionOutput::new(input_value, sender_p2pk.clone()); + let output1 = TransactionOutput::new(fee_input_value - fee, executor_p2pk.clone()); + + let (tx, entries) = build_refund_tx_2in( + &spk, + input_value, + &executor_p2pk, + fee_input_value, + vec![output0, output1], + timelock + 1000, + ); + let mut mutable_tx = MutableTransaction::with_entries(tx, entries); + + let sig_script = ScriptBuilder::new() + .add_op(OpFalse) + .unwrap() + .add_data(&htlc_script) + .unwrap() + .drain(); + mutable_tx.tx.inputs[0].signature_script = sig_script; + + let reused_values = SigHashReusedValuesUnsync::new(); + let sig_hash = calc_schnorr_signature_hash( + &mutable_tx.as_verifiable(), + 1, + SIG_HASH_ALL, + &reused_values, + ); + let msg = secp256k1::Message::from_digest(sig_hash.as_bytes()); + let sig = executor.sign_schnorr(msg.as_ref()); + let mut signature = Vec::new(); + signature.extend_from_slice(sig.as_ref()); + signature.push(SIG_HASH_ALL.to_u8()); + let fee_sig_script = ScriptBuilder::new().add_data(&signature).unwrap().drain(); + mutable_tx.tx.inputs[1].signature_script = fee_sig_script; + + let tx = mutable_tx.as_verifiable(); + let sig_cache = Cache::new(10_000); + let utxo_entry = tx.utxo(0).unwrap().clone(); + + let mut vm = TxScriptEngine::from_transaction_input( + &tx, + &tx.inputs()[0], + 0, + &utxo_entry, + &reused_values, + &sig_cache, + ); + vm.execute() + .expect("Refund should succeed when lock_time > timelock"); + } + + #[test] + fn test_ccr_fails_with_extra_stack_data() { + let secp = Secp256k1::new(); + let mut rng = rand::rng(); + + let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let solver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + + let secret: [u8; 32] = rand::random(); + let secret_hash: [u8; 32] = Sha256::digest(secret).into(); + let timelock = 1_700_000_000u64 + 7200; + + let receiver_p2pk = p2pk_spk(&receiver); + let receiver_spk_vec = spk_to_vec(&receiver_p2pk); + let sender_p2pk = p2pk_spk(&sender); + let sender_spk_vec = spk_to_vec(&sender_p2pk); + + let htlc_script = create_htlc_script( + &sender_spk_vec, + &[], + &receiver_spk_vec, + &secret_hash, + timelock, + 0, + [0u8; 32], + ) + .expect("Script creation"); + + let spk = pay_to_script_hash_script(&htlc_script); + let input_value = 1_000_000_000u64; + let fee_value = 100_000u64; + let receiver_amount = input_value - SOLVER_REWARD as u64; + let solver_p2pk = p2pk_spk(&solver); + + let output0 = TransactionOutput::new(receiver_amount, receiver_p2pk.clone()); + let output1 = TransactionOutput::new(SOLVER_REWARD as u64 + fee_value, solver_p2pk.clone()); + + let (tx, entries) = build_ccr_tx( + &spk, + input_value, + fee_value, + &solver_p2pk, + vec![output0, output1], + 0, + ); + let mut mutable_tx = MutableTransaction::with_entries(tx, entries); + + let junk: [u8; 32] = rand::random(); + let sig_script = ScriptBuilder::new() + .add_data(&junk) + .unwrap() + .add_data(&secret) + .unwrap() + .add_op(OpTrue) + .unwrap() + .add_data(&htlc_script) + .unwrap() + .drain(); + mutable_tx.tx.inputs[0].signature_script = sig_script; + + let tx = mutable_tx.as_verifiable(); + let sig_cache = Cache::new(10_000); + let reused_values = SigHashReusedValuesUnsync::new(); + let utxo_entry = tx.utxo(0).unwrap().clone(); + + let mut vm = TxScriptEngine::from_transaction_input( + &tx, + &tx.inputs()[0], + 0, + &utxo_entry, + &reused_values, + &sig_cache, + ); + vm.execute().expect_err("Should fail with extra stack data"); + } + + #[test] + fn test_refund_fails_with_wrong_output_destination() { + let secp = Secp256k1::new(); + let mut rng = rand::rng(); + + let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let executor = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + + let secret: [u8; 32] = rand::random(); + let secret_hash: [u8; 32] = Sha256::digest(secret).into(); + let timelock = 1_700_000_000u64 + 7200; + + let receiver_spk_vec = spk_to_vec(&p2pk_spk(&receiver)); + let sender_p2pk = p2pk_spk(&sender); + let sender_spk_vec = spk_to_vec(&sender_p2pk); + let executor_p2pk = p2pk_spk(&executor); + + let htlc_script = create_htlc_script( + &sender_spk_vec, + &[], + &receiver_spk_vec, + &secret_hash, + timelock, + 0, + [0u8; 32], + ) + .expect("Script creation"); + + let spk = pay_to_script_hash_script(&htlc_script); + let input_value = 1_000_000_000u64; + let fee_input_value = 100_000u64; + let fee = 3_000u64; + + let output0 = TransactionOutput::new(input_value, p2pk_spk(&receiver)); + let output1 = TransactionOutput::new(fee_input_value - fee, executor_p2pk.clone()); + + let (tx, entries) = build_refund_tx_2in( + &spk, + input_value, + &executor_p2pk, + fee_input_value, + vec![output0, output1], + timelock, + ); + let mut mutable_tx = MutableTransaction::with_entries(tx, entries); + + let sig_script = ScriptBuilder::new() + .add_op(OpFalse) + .unwrap() + .add_data(&htlc_script) + .unwrap() + .drain(); + mutable_tx.tx.inputs[0].signature_script = sig_script; + + let tx = mutable_tx.as_verifiable(); + let sig_cache = Cache::new(10_000); + let reused_values = SigHashReusedValuesUnsync::new(); + let utxo_entry = tx.utxo(0).unwrap().clone(); + + let mut vm = TxScriptEngine::from_transaction_input( + &tx, + &tx.inputs()[0], + 0, + &utxo_entry, + &reused_values, + &sig_cache, + ); + vm.execute() + .expect_err("Should fail when output goes to wrong address"); + } + + #[test] + fn test_refund_fails_with_extra_stack_data() { + let secp = Secp256k1::new(); + let mut rng = rand::rng(); + + let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let executor = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + + let secret: [u8; 32] = rand::random(); + let secret_hash: [u8; 32] = Sha256::digest(secret).into(); + let timelock = 1_700_000_000u64 + 7200; + + let receiver_spk_vec = spk_to_vec(&p2pk_spk(&receiver)); + let sender_p2pk = p2pk_spk(&sender); + let sender_spk_vec = spk_to_vec(&sender_p2pk); + let executor_p2pk = p2pk_spk(&executor); + + let htlc_script = create_htlc_script( + &sender_spk_vec, + &[], + &receiver_spk_vec, + &secret_hash, + timelock, + 0, + [0u8; 32], + ) + .expect("Script creation"); + + let spk = pay_to_script_hash_script(&htlc_script); + let input_value = 1_000_000_000u64; + let fee_input_value = 100_000u64; + let fee = 3_000u64; + + let output0 = TransactionOutput::new(input_value, sender_p2pk.clone()); + let output1 = TransactionOutput::new(fee_input_value - fee, executor_p2pk.clone()); + + let (tx, entries) = build_refund_tx_2in( + &spk, + input_value, + &executor_p2pk, + fee_input_value, + vec![output0, output1], + timelock, + ); + let mut mutable_tx = MutableTransaction::with_entries(tx, entries); + + let junk: [u8; 32] = rand::random(); + let sig_script = ScriptBuilder::new() + .add_data(&junk) + .unwrap() + .add_op(OpFalse) + .unwrap() + .add_data(&htlc_script) + .unwrap() + .drain(); + mutable_tx.tx.inputs[0].signature_script = sig_script; + + let tx = mutable_tx.as_verifiable(); + let sig_cache = Cache::new(10_000); + let reused_values = SigHashReusedValuesUnsync::new(); + let utxo_entry = tx.utxo(0).unwrap().clone(); + + let mut vm = TxScriptEngine::from_transaction_input( + &tx, + &tx.inputs()[0], + 0, + &utxo_entry, + &reused_values, + &sig_cache, + ); + vm.execute().expect_err("Should fail with extra stack data"); + } + + #[test] + fn test_refund_fails_with_three_outputs() { + let secp = Secp256k1::new(); + let mut rng = rand::rng(); + + let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let executor = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + + let secret: [u8; 32] = rand::random(); + let secret_hash: [u8; 32] = Sha256::digest(secret).into(); + let timelock = 1_700_000_000u64 + 7200; + + let receiver_spk_vec = spk_to_vec(&p2pk_spk(&receiver)); + let sender_p2pk = p2pk_spk(&sender); + let sender_spk_vec = spk_to_vec(&sender_p2pk); + let executor_p2pk = p2pk_spk(&executor); + + let htlc_script = create_htlc_script( + &sender_spk_vec, + &[], + &receiver_spk_vec, + &secret_hash, + timelock, + 0, + [0u8; 32], + ) + .expect("Script creation"); + + let spk = pay_to_script_hash_script(&htlc_script); + let input_value = 1_000_000_000u64; + let fee_input_value = 100_000u64; + + let third = input_value / 3; + let output0 = TransactionOutput::new(third, sender_p2pk.clone()); + let output1 = TransactionOutput::new(third, executor_p2pk.clone()); + let output2 = TransactionOutput::new(third, executor_p2pk.clone()); + + let (tx, entries) = build_refund_tx_2in( + &spk, + input_value, + &executor_p2pk, + fee_input_value, + vec![output0, output1, output2], + timelock, + ); + let mut mutable_tx = MutableTransaction::with_entries(tx, entries); + + let sig_script = ScriptBuilder::new() + .add_op(OpFalse) + .unwrap() + .add_data(&htlc_script) + .unwrap() + .drain(); + mutable_tx.tx.inputs[0].signature_script = sig_script; + + let tx = mutable_tx.as_verifiable(); + let sig_cache = Cache::new(10_000); + let reused_values = SigHashReusedValuesUnsync::new(); + let utxo_entry = tx.utxo(0).unwrap().clone(); + + let mut vm = TxScriptEngine::from_transaction_input( + &tx, + &tx.inputs()[0], + 0, + &utxo_entry, + &reused_values, + &sig_cache, + ); + vm.execute() + .expect_err("Should fail with 3 outputs — script enforces exactly 2"); + } + + #[test] + fn test_refund_fails_with_underpaid_sender() { + let secp = Secp256k1::new(); + let mut rng = rand::rng(); + + let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let executor = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + + let secret: [u8; 32] = rand::random(); + let secret_hash: [u8; 32] = Sha256::digest(secret).into(); + let timelock = 1_700_000_000u64 + 7200; + + let receiver_spk_vec = spk_to_vec(&p2pk_spk(&receiver)); + let sender_p2pk = p2pk_spk(&sender); + let sender_spk_vec = spk_to_vec(&sender_p2pk); + let executor_p2pk = p2pk_spk(&executor); + + let htlc_script = create_htlc_script( + &sender_spk_vec, + &[], + &receiver_spk_vec, + &secret_hash, + timelock, + 0, + [0u8; 32], + ) + .expect("Script creation"); + + let spk = pay_to_script_hash_script(&htlc_script); + let input_value = 1_000_000_000u64; + let fee_input_value = 100_000u64; + + let stolen = 500_000_000u64; + let output0 = TransactionOutput::new(input_value - stolen, sender_p2pk.clone()); + let output1 = TransactionOutput::new(fee_input_value + stolen, executor_p2pk.clone()); + + let (tx, entries) = build_refund_tx_2in( + &spk, + input_value, + &executor_p2pk, + fee_input_value, + vec![output0, output1], + timelock, + ); + let mut mutable_tx = MutableTransaction::with_entries(tx, entries); + + let sig_script = ScriptBuilder::new() + .add_op(OpFalse) + .unwrap() + .add_data(&htlc_script) + .unwrap() + .drain(); + mutable_tx.tx.inputs[0].signature_script = sig_script; + + let tx = mutable_tx.as_verifiable(); + let sig_cache = Cache::new(10_000); + let reused_values = SigHashReusedValuesUnsync::new(); + let utxo_entry = tx.utxo(0).unwrap().clone(); + + let mut vm = TxScriptEngine::from_transaction_input( + &tx, + &tx.inputs()[0], + 0, + &utxo_entry, + &reused_values, + &sig_cache, + ); + vm.execute() + .expect_err("Should fail when sender gets less than full HTLC amount"); + } + + #[test] + fn test_ccr_path_success() { + let secp = Secp256k1::new(); + let mut rng = rand::rng(); + + let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let solver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + + let secret: [u8; 32] = rand::random(); + let secret_hash: [u8; 32] = Sha256::digest(secret).into(); + let timelock = 1_700_000_000u64 + 7200; + + let receiver_p2pk = p2pk_spk(&receiver); + let receiver_spk_vec = spk_to_vec(&receiver_p2pk); + + let htlc_script = create_htlc_script( + &sender.x_only_public_key().0.serialize(), + &[], + &receiver_spk_vec, + &secret_hash, + timelock, + 0, + [0u8; 32], + ) + .expect("Script creation"); + + let spk = pay_to_script_hash_script(&htlc_script); + let input_value = 1_000_000_000u64; + let fee_value = 100_000u64; + let receiver_amount = input_value - SOLVER_REWARD as u64; + let solver_amount = SOLVER_REWARD as u64 + fee_value; + + let solver_p2pk = p2pk_spk(&solver); + let output0 = TransactionOutput::new(receiver_amount, receiver_p2pk.clone()); + let output1 = TransactionOutput::new(solver_amount, solver_p2pk.clone()); + + let (tx, entries) = build_ccr_tx( + &spk, + input_value, + fee_value, + &solver_p2pk, + vec![output0, output1], + 0, + ); + let mut mutable_tx = MutableTransaction::with_entries(tx, entries); + + let sig_script = ScriptBuilder::new() + .add_data(&secret) + .unwrap() + .add_op(OpTrue) + .unwrap() + .add_data(&htlc_script) + .unwrap() + .drain(); + mutable_tx.tx.inputs[0].signature_script = sig_script; + + let tx = mutable_tx.as_verifiable(); + let sig_cache = Cache::new(10_000); + let reused_values = SigHashReusedValuesUnsync::new(); + let utxo_entry = tx.utxo(0).unwrap().clone(); + + let mut vm = TxScriptEngine::from_transaction_input( + &tx, + &tx.inputs()[0], + 0, + &utxo_entry, + &reused_values, + &sig_cache, + ); + vm.execute().expect("CCR path should succeed"); + } + + #[test] + fn test_ccr_fails_with_wrong_secret() { + let secp = Secp256k1::new(); + let mut rng = rand::rng(); + + let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let solver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + + let secret: [u8; 32] = rand::random(); + let secret_hash: [u8; 32] = Sha256::digest(secret).into(); + let timelock = 1_700_000_000u64 + 7200; + + let receiver_p2pk = p2pk_spk(&receiver); + let receiver_spk_vec = spk_to_vec(&receiver_p2pk); + + let htlc_script = create_htlc_script( + &sender.x_only_public_key().0.serialize(), + &[], + &receiver_spk_vec, + &secret_hash, + timelock, + 0, + [0u8; 32], + ) + .expect("Script creation"); + + let spk = pay_to_script_hash_script(&htlc_script); + let input_value = 1_000_000_000u64; + let fee_value = 100_000u64; + let receiver_amount = input_value - SOLVER_REWARD as u64; + let solver_p2pk = p2pk_spk(&solver); + + let output0 = TransactionOutput::new(receiver_amount, receiver_p2pk.clone()); + let output1 = TransactionOutput::new(SOLVER_REWARD as u64 + fee_value, solver_p2pk.clone()); + + let (tx, entries) = build_ccr_tx( + &spk, + input_value, + fee_value, + &solver_p2pk, + vec![output0, output1], + 0, + ); + let mut mutable_tx = MutableTransaction::with_entries(tx, entries); + + let wrong_secret: [u8; 32] = rand::random(); + let sig_script = ScriptBuilder::new() + .add_data(&wrong_secret) + .unwrap() + .add_op(OpTrue) + .unwrap() + .add_data(&htlc_script) + .unwrap() + .drain(); + mutable_tx.tx.inputs[0].signature_script = sig_script; + + let tx = mutable_tx.as_verifiable(); + let sig_cache = Cache::new(10_000); + let reused_values = SigHashReusedValuesUnsync::new(); + let utxo_entry = tx.utxo(0).unwrap().clone(); + + let mut vm = TxScriptEngine::from_transaction_input( + &tx, + &tx.inputs()[0], + 0, + &utxo_entry, + &reused_values, + &sig_cache, + ); + vm.execute().expect_err("Should fail with wrong secret"); + } + + #[test] + fn test_ccr_fails_with_wrong_receiver() { + let secp = Secp256k1::new(); + let mut rng = rand::rng(); + + let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let wrong_recv = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let solver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + + let secret: [u8; 32] = rand::random(); + let secret_hash: [u8; 32] = Sha256::digest(secret).into(); + let timelock = 1_700_000_000u64 + 7200; + + let receiver_p2pk = p2pk_spk(&receiver); + let receiver_spk_vec = spk_to_vec(&receiver_p2pk); + + let htlc_script = create_htlc_script( + &sender.x_only_public_key().0.serialize(), + &[], + &receiver_spk_vec, + &secret_hash, + timelock, + 0, + [0u8; 32], + ) + .expect("Script creation"); + + let spk = pay_to_script_hash_script(&htlc_script); + let input_value = 1_000_000_000u64; + let fee_value = 100_000u64; + let receiver_amount = input_value - SOLVER_REWARD as u64; + let solver_p2pk = p2pk_spk(&solver); + + let output0 = TransactionOutput::new(receiver_amount, p2pk_spk(&wrong_recv)); + let output1 = TransactionOutput::new(SOLVER_REWARD as u64 + fee_value, solver_p2pk.clone()); + + let (tx, entries) = build_ccr_tx( + &spk, + input_value, + fee_value, + &solver_p2pk, + vec![output0, output1], + 0, + ); + let mut mutable_tx = MutableTransaction::with_entries(tx, entries); + + let sig_script = ScriptBuilder::new() + .add_data(&secret) + .unwrap() + .add_op(OpTrue) + .unwrap() + .add_data(&htlc_script) + .unwrap() + .drain(); + mutable_tx.tx.inputs[0].signature_script = sig_script; + + let tx = mutable_tx.as_verifiable(); + let sig_cache = Cache::new(10_000); + let reused_values = SigHashReusedValuesUnsync::new(); + let utxo_entry = tx.utxo(0).unwrap().clone(); + + let mut vm = TxScriptEngine::from_transaction_input( + &tx, + &tx.inputs()[0], + 0, + &utxo_entry, + &reused_values, + &sig_cache, + ); + vm.execute() + .expect_err("Should fail when output goes to wrong receiver"); + } + + #[test] + fn test_ccr_fails_with_insufficient_receiver_amount() { + let secp = Secp256k1::new(); + let mut rng = rand::rng(); + + let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let solver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + + let secret: [u8; 32] = rand::random(); + let secret_hash: [u8; 32] = Sha256::digest(secret).into(); + let timelock = 1_700_000_000u64 + 7200; + + let receiver_p2pk = p2pk_spk(&receiver); + let receiver_spk_vec = spk_to_vec(&receiver_p2pk); + + let htlc_script = create_htlc_script( + &sender.x_only_public_key().0.serialize(), + &[], + &receiver_spk_vec, + &secret_hash, + timelock, + 0, + [0u8; 32], + ) + .expect("Script creation"); + + let spk = pay_to_script_hash_script(&htlc_script); + let input_value = 1_000_000_000u64; + let fee_value = 100_000u64; + let solver_p2pk = p2pk_spk(&solver); + + let receiver_amount = input_value - SOLVER_REWARD as u64 - 1; + let output0 = TransactionOutput::new(receiver_amount, receiver_p2pk.clone()); + let output1 = + TransactionOutput::new(SOLVER_REWARD as u64 + 1 + fee_value, solver_p2pk.clone()); + + let (tx, entries) = build_ccr_tx( + &spk, + input_value, + fee_value, + &solver_p2pk, + vec![output0, output1], + 0, + ); + let mut mutable_tx = MutableTransaction::with_entries(tx, entries); + + let sig_script = ScriptBuilder::new() + .add_data(&secret) + .unwrap() + .add_op(OpTrue) + .unwrap() + .add_data(&htlc_script) + .unwrap() + .drain(); + mutable_tx.tx.inputs[0].signature_script = sig_script; + + let tx = mutable_tx.as_verifiable(); + let sig_cache = Cache::new(10_000); + let reused_values = SigHashReusedValuesUnsync::new(); + let utxo_entry = tx.utxo(0).unwrap().clone(); + + let mut vm = TxScriptEngine::from_transaction_input( + &tx, + &tx.inputs()[0], + 0, + &utxo_entry, + &reused_values, + &sig_cache, + ); + vm.execute() + .expect_err("Should fail when receiver gets insufficient amount"); + } + + #[test] + fn test_ccr_fails_with_empty_secret() { + let secp = Secp256k1::new(); + let mut rng = rand::rng(); + + let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let solver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + + let secret: [u8; 32] = rand::random(); + let secret_hash: [u8; 32] = Sha256::digest(secret).into(); + let timelock = 1_700_000_000u64 + 7200; + + let receiver_p2pk = p2pk_spk(&receiver); + let receiver_spk_vec = spk_to_vec(&receiver_p2pk); + + let htlc_script = create_htlc_script( + &sender.x_only_public_key().0.serialize(), + &[], + &receiver_spk_vec, + &secret_hash, + timelock, + 0, + [0u8; 32], + ) + .expect("Script creation"); + + let spk = pay_to_script_hash_script(&htlc_script); + let input_value = 1_000_000_000u64; + let fee_value = 100_000u64; + let receiver_amount = input_value - SOLVER_REWARD as u64; + let solver_p2pk = p2pk_spk(&solver); + + let output0 = TransactionOutput::new(receiver_amount, receiver_p2pk.clone()); + let output1 = TransactionOutput::new(SOLVER_REWARD as u64 + fee_value, solver_p2pk.clone()); + + let (tx, entries) = build_ccr_tx( + &spk, + input_value, + fee_value, + &solver_p2pk, + vec![output0, output1], + 0, + ); + let mut mutable_tx = MutableTransaction::with_entries(tx, entries); + + let empty_secret: [u8; 0] = []; + let sig_script = ScriptBuilder::new() + .add_data(&empty_secret) + .unwrap() + .add_op(OpTrue) + .unwrap() + .add_data(&htlc_script) + .unwrap() + .drain(); + mutable_tx.tx.inputs[0].signature_script = sig_script; + + let tx = mutable_tx.as_verifiable(); + let sig_cache = Cache::new(10_000); + let reused_values = SigHashReusedValuesUnsync::new(); + let utxo_entry = tx.utxo(0).unwrap().clone(); + + let mut vm = TxScriptEngine::from_transaction_input( + &tx, + &tx.inputs()[0], + 0, + &utxo_entry, + &reused_values, + &sig_cache, + ); + vm.execute().expect_err("Should fail with empty secret"); + } + + #[test] + fn test_ccr_succeeds_receiver_gets_more() { + let secp = Secp256k1::new(); + let mut rng = rand::rng(); + + let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let solver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + + let secret: [u8; 32] = rand::random(); + let secret_hash: [u8; 32] = Sha256::digest(secret).into(); + let timelock = 1_700_000_000u64 + 7200; + + let receiver_p2pk = p2pk_spk(&receiver); + let receiver_spk_vec = spk_to_vec(&receiver_p2pk); + + let htlc_script = create_htlc_script( + &sender.x_only_public_key().0.serialize(), + &[], + &receiver_spk_vec, + &secret_hash, + timelock, + 0, + [0u8; 32], + ) + .expect("Script creation"); + + let spk = pay_to_script_hash_script(&htlc_script); + let input_value = 1_000_000_000u64; + let fee_value = 100_000u64; + let solver_p2pk = p2pk_spk(&solver); + + let receiver_amount = input_value - SOLVER_REWARD as u64 + 5_000_000; + let solver_amount = SOLVER_REWARD as u64 - 5_000_000 + fee_value; + + let output0 = TransactionOutput::new(receiver_amount, receiver_p2pk.clone()); + let output1 = TransactionOutput::new(solver_amount, solver_p2pk.clone()); + + let (tx, entries) = build_ccr_tx( + &spk, + input_value, + fee_value, + &solver_p2pk, + vec![output0, output1], + 0, + ); + let mut mutable_tx = MutableTransaction::with_entries(tx, entries); + + let sig_script = ScriptBuilder::new() + .add_data(&secret) + .unwrap() + .add_op(OpTrue) + .unwrap() + .add_data(&htlc_script) + .unwrap() + .drain(); + mutable_tx.tx.inputs[0].signature_script = sig_script; + + let tx = mutable_tx.as_verifiable(); + let sig_cache = Cache::new(10_000); + let reused_values = SigHashReusedValuesUnsync::new(); + let utxo_entry = tx.utxo(0).unwrap().clone(); + + let mut vm = TxScriptEngine::from_transaction_input( + &tx, + &tx.inputs()[0], + 0, + &utxo_entry, + &reused_values, + &sig_cache, + ); + vm.execute() + .expect("CCR should succeed when receiver gets more than minimum"); + } + + #[test] + fn test_ccr_fails_with_one_input() { + let secp = Secp256k1::new(); + let mut rng = rand::rng(); + + let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let solver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + + let secret: [u8; 32] = rand::random(); + let secret_hash: [u8; 32] = Sha256::digest(secret).into(); + let timelock = 1_700_000_000u64 + 7200; + + let receiver_p2pk = p2pk_spk(&receiver); + let receiver_spk_vec = spk_to_vec(&receiver_p2pk); + + let htlc_script = create_htlc_script( + &sender.x_only_public_key().0.serialize(), + &[], + &receiver_spk_vec, + &secret_hash, + timelock, + 0, + [0u8; 32], + ) + .expect("Script creation"); + + let spk = pay_to_script_hash_script(&htlc_script); + let input_value = 1_000_000_000u64; + let receiver_amount = input_value - SOLVER_REWARD as u64; + let solver_p2pk = p2pk_spk(&solver); + + let output0 = TransactionOutput::new(receiver_amount, receiver_p2pk.clone()); + let output1 = TransactionOutput::new(SOLVER_REWARD as u64, solver_p2pk.clone()); + let utxo_entry = UtxoEntry::new(input_value, spk.clone(), 0, false); + + let input = TransactionInput { + previous_outpoint: TransactionOutpoint::new(Hash::from_u64_word(1), 0), + signature_script: vec![], + sequence: 0, + sig_op_count: 0, + }; + + let tx = Transaction::new( + 1, + vec![input], + vec![output0, output1], + 0, + SUBNETWORK_ID_NATIVE, + 0, + vec![], + ); + let mut mutable_tx = MutableTransaction::with_entries(tx, vec![utxo_entry.clone()]); + + let sig_script = ScriptBuilder::new() + .add_data(&secret) + .unwrap() + .add_op(OpTrue) + .unwrap() + .add_data(&htlc_script) + .unwrap() + .drain(); + mutable_tx.tx.inputs[0].signature_script = sig_script; + + let tx = mutable_tx.as_verifiable(); + let sig_cache = Cache::new(10_000); + let reused_values = SigHashReusedValuesUnsync::new(); + + let mut vm = TxScriptEngine::from_transaction_input( + &tx, + &tx.inputs()[0], + 0, + &utxo_entry, + &reused_values, + &sig_cache, + ); + vm.execute().expect_err("Should fail with only 1 input"); + } + + #[test] + fn test_ccr_fails_with_three_inputs() { + let secp = Secp256k1::new(); + let mut rng = rand::rng(); + + let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let solver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + + let secret: [u8; 32] = rand::random(); + let secret_hash: [u8; 32] = Sha256::digest(secret).into(); + let timelock = 1_700_000_000u64 + 7200; + + let receiver_p2pk = p2pk_spk(&receiver); + let receiver_spk_vec = spk_to_vec(&receiver_p2pk); + + let htlc_script = create_htlc_script( + &sender.x_only_public_key().0.serialize(), + &[], + &receiver_spk_vec, + &secret_hash, + timelock, + 0, + [0u8; 32], + ) + .expect("Script creation"); + + let spk = pay_to_script_hash_script(&htlc_script); + let input_value = 1_000_000_000u64; + let fee_value = 100_000u64; + let receiver_amount = input_value - SOLVER_REWARD as u64; + let solver_p2pk = p2pk_spk(&solver); + + let output0 = TransactionOutput::new(receiver_amount, receiver_p2pk.clone()); + let output1 = + TransactionOutput::new(SOLVER_REWARD as u64 + fee_value * 2, solver_p2pk.clone()); + + let htlc_utxo = UtxoEntry::new(input_value, spk.clone(), 0, false); + let fee_utxo1 = UtxoEntry::new(fee_value, solver_p2pk.clone(), 0, false); + let fee_utxo2 = UtxoEntry::new(fee_value, solver_p2pk.clone(), 0, false); + + let input0 = TransactionInput { + previous_outpoint: TransactionOutpoint::new(Hash::from_u64_word(1), 0), + signature_script: vec![], + sequence: 0, + sig_op_count: 0, + }; + let input1 = TransactionInput { + previous_outpoint: TransactionOutpoint::new(Hash::from_u64_word(2), 0), + signature_script: vec![], + sequence: 0, + sig_op_count: 1, + }; + let input2 = TransactionInput { + previous_outpoint: TransactionOutpoint::new(Hash::from_u64_word(3), 0), + signature_script: vec![], + sequence: 0, + sig_op_count: 1, + }; + + let tx = Transaction::new( + 1, + vec![input0, input1, input2], + vec![output0, output1], + 0, + SUBNETWORK_ID_NATIVE, + 0, + vec![], + ); + let mut mutable_tx = + MutableTransaction::with_entries(tx, vec![htlc_utxo.clone(), fee_utxo1, fee_utxo2]); + + let sig_script = ScriptBuilder::new() + .add_data(&secret) + .unwrap() + .add_op(OpTrue) + .unwrap() + .add_data(&htlc_script) + .unwrap() + .drain(); + mutable_tx.tx.inputs[0].signature_script = sig_script; + + let tx = mutable_tx.as_verifiable(); + let sig_cache = Cache::new(10_000); + let reused_values = SigHashReusedValuesUnsync::new(); + + let mut vm = TxScriptEngine::from_transaction_input( + &tx, + &tx.inputs()[0], + 0, + &htlc_utxo, + &reused_values, + &sig_cache, + ); + vm.execute().expect_err("Should fail with 3 inputs"); + } + + #[test] + fn test_ccr_second_input_high_value_cannot_inflate_check() { + let secp = Secp256k1::new(); + let mut rng = rand::rng(); + + let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let solver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + + let secret: [u8; 32] = rand::random(); + let secret_hash: [u8; 32] = Sha256::digest(secret).into(); + let timelock = 1_700_000_000u64 + 7200; + + let receiver_p2pk = p2pk_spk(&receiver); + let receiver_spk_vec = spk_to_vec(&receiver_p2pk); + + let htlc_script = create_htlc_script( + &sender.x_only_public_key().0.serialize(), + &[], + &receiver_spk_vec, + &secret_hash, + timelock, + 0, + [0u8; 32], + ) + .expect("Script creation"); + + let spk = pay_to_script_hash_script(&htlc_script); + let input_value = 1_000_000_000u64; + let attacker_fee_value = 50_000_000_000u64; + let solver_p2pk = p2pk_spk(&solver); + + let receiver_amount = input_value - SOLVER_REWARD as u64 - 1; + let solver_amount = SOLVER_REWARD as u64 + 1 + attacker_fee_value; + + let output0 = TransactionOutput::new(receiver_amount, receiver_p2pk.clone()); + let output1 = TransactionOutput::new(solver_amount, solver_p2pk.clone()); + + let (tx, entries) = build_ccr_tx( + &spk, + input_value, + attacker_fee_value, + &solver_p2pk, + vec![output0, output1], + 0, + ); + let mut mutable_tx = MutableTransaction::with_entries(tx, entries); + + let sig_script = ScriptBuilder::new() + .add_data(&secret) + .unwrap() + .add_op(OpTrue) + .unwrap() + .add_data(&htlc_script) + .unwrap() + .drain(); + mutable_tx.tx.inputs[0].signature_script = sig_script; + + let tx = mutable_tx.as_verifiable(); + let sig_cache = Cache::new(10_000); + let reused_values = SigHashReusedValuesUnsync::new(); + let utxo_entry = tx.utxo(0).unwrap().clone(); + + let mut vm = TxScriptEngine::from_transaction_input( + &tx, + &tx.inputs()[0], + 0, + &utxo_entry, + &reused_values, + &sig_cache, + ); + vm.execute().expect_err( + "Should fail: second input's high value cannot inflate the receiver amount check", + ); + } + + #[test] + fn test_ccr_second_input_high_value_correct_receiver_succeeds() { + let secp = Secp256k1::new(); + let mut rng = rand::rng(); + + let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let solver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + + let secret: [u8; 32] = rand::random(); + let secret_hash: [u8; 32] = Sha256::digest(secret).into(); + let timelock = 1_700_000_000u64 + 7200; + + let receiver_p2pk = p2pk_spk(&receiver); + let receiver_spk_vec = spk_to_vec(&receiver_p2pk); + + let htlc_script = create_htlc_script( + &sender.x_only_public_key().0.serialize(), + &[], + &receiver_spk_vec, + &secret_hash, + timelock, + 0, + [0u8; 32], + ) + .expect("Script creation"); + + let spk = pay_to_script_hash_script(&htlc_script); + let input_value = 1_000_000_000u64; + let large_fee_value = 50_000_000_000u64; + let solver_p2pk = p2pk_spk(&solver); + + let receiver_amount = input_value - SOLVER_REWARD as u64; + let solver_amount = SOLVER_REWARD as u64 + large_fee_value; + + let output0 = TransactionOutput::new(receiver_amount, receiver_p2pk.clone()); + let output1 = TransactionOutput::new(solver_amount, solver_p2pk.clone()); + + let (tx, entries) = build_ccr_tx( + &spk, + input_value, + large_fee_value, + &solver_p2pk, + vec![output0, output1], + 0, + ); + let mut mutable_tx = MutableTransaction::with_entries(tx, entries); + + let sig_script = ScriptBuilder::new() + .add_data(&secret) + .unwrap() + .add_op(OpTrue) + .unwrap() + .add_data(&htlc_script) + .unwrap() + .drain(); + mutable_tx.tx.inputs[0].signature_script = sig_script; + + let tx = mutable_tx.as_verifiable(); + let sig_cache = Cache::new(10_000); + let reused_values = SigHashReusedValuesUnsync::new(); + let utxo_entry = tx.utxo(0).unwrap().clone(); + + let mut vm = TxScriptEngine::from_transaction_input( + &tx, + &tx.inputs()[0], + 0, + &utxo_entry, + &reused_values, + &sig_cache, + ); + vm.execute() + .expect("Should succeed: receiver correctly paid, solver uses own funds"); + } + + #[test] + fn test_ccr_second_input_cannot_redirect_receiver_funds() { + let secp = Secp256k1::new(); + let mut rng = rand::rng(); + + let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let solver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + + let secret: [u8; 32] = rand::random(); + let secret_hash: [u8; 32] = Sha256::digest(secret).into(); + let timelock = 1_700_000_000u64 + 7200; + + let receiver_p2pk = p2pk_spk(&receiver); + let receiver_spk_vec = spk_to_vec(&receiver_p2pk); + + let htlc_script = create_htlc_script( + &sender.x_only_public_key().0.serialize(), + &[], + &receiver_spk_vec, + &secret_hash, + timelock, + 0, + [0u8; 32], + ) + .expect("Script creation"); + + let spk = pay_to_script_hash_script(&htlc_script); + let input_value = 1_000_000_000u64; + let fee_value = 100_000u64; + let solver_p2pk = p2pk_spk(&solver); + + let receiver_amount = input_value / 2; + let solver_amount = input_value / 2 + SOLVER_REWARD as u64 + fee_value; + + let output0 = TransactionOutput::new(receiver_amount, receiver_p2pk.clone()); + let output1 = TransactionOutput::new(solver_amount, solver_p2pk.clone()); + + let (tx, entries) = build_ccr_tx( + &spk, + input_value, + fee_value, + &solver_p2pk, + vec![output0, output1], + 0, + ); + let mut mutable_tx = MutableTransaction::with_entries(tx, entries); + + let sig_script = ScriptBuilder::new() + .add_data(&secret) + .unwrap() + .add_op(OpTrue) + .unwrap() + .add_data(&htlc_script) + .unwrap() + .drain(); + mutable_tx.tx.inputs[0].signature_script = sig_script; + + let tx = mutable_tx.as_verifiable(); + let sig_cache = Cache::new(10_000); + let reused_values = SigHashReusedValuesUnsync::new(); + let utxo_entry = tx.utxo(0).unwrap().clone(); + + let mut vm = TxScriptEngine::from_transaction_input( + &tx, + &tx.inputs()[0], + 0, + &utxo_entry, + &reused_values, + &sig_cache, + ); + vm.execute() + .expect_err("Should fail: receiver gets less than minimum"); + } + + #[test] + fn test_ccr_second_input_zero_value() { + let secp = Secp256k1::new(); + let mut rng = rand::rng(); + + let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let solver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + + let secret: [u8; 32] = rand::random(); + let secret_hash: [u8; 32] = Sha256::digest(secret).into(); + let timelock = 1_700_000_000u64 + 7200; + + let receiver_p2pk = p2pk_spk(&receiver); + let receiver_spk_vec = spk_to_vec(&receiver_p2pk); + + let htlc_script = create_htlc_script( + &sender.x_only_public_key().0.serialize(), + &[], + &receiver_spk_vec, + &secret_hash, + timelock, + 0, + [0u8; 32], + ) + .expect("Script creation"); + + let spk = pay_to_script_hash_script(&htlc_script); + let input_value = 1_000_000_000u64; + let solver_p2pk = p2pk_spk(&solver); + + let receiver_amount = input_value - SOLVER_REWARD as u64; + let solver_amount = SOLVER_REWARD as u64; + + let output0 = TransactionOutput::new(receiver_amount, receiver_p2pk.clone()); + let output1 = TransactionOutput::new(solver_amount, solver_p2pk.clone()); + + let (tx, entries) = build_ccr_tx( + &spk, + input_value, + 0, + &solver_p2pk, + vec![output0, output1], + 0, + ); + let mut mutable_tx = MutableTransaction::with_entries(tx, entries); + + let sig_script = ScriptBuilder::new() + .add_data(&secret) + .unwrap() + .add_op(OpTrue) + .unwrap() + .add_data(&htlc_script) + .unwrap() + .drain(); + mutable_tx.tx.inputs[0].signature_script = sig_script; + + let tx = mutable_tx.as_verifiable(); + let sig_cache = Cache::new(10_000); + let reused_values = SigHashReusedValuesUnsync::new(); + let utxo_entry = tx.utxo(0).unwrap().clone(); + + let mut vm = TxScriptEngine::from_transaction_input( + &tx, + &tx.inputs()[0], + 0, + &utxo_entry, + &reused_values, + &sig_cache, + ); + vm.execute() + .expect("Should succeed with zero-value fee input"); + } + + #[test] + fn test_ccr_fails_with_one_output() { + let secp = Secp256k1::new(); + let mut rng = rand::rng(); + + let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let solver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + + let secret: [u8; 32] = rand::random(); + let secret_hash: [u8; 32] = Sha256::digest(secret).into(); + let timelock = 1_700_000_000u64 + 7200; + + let receiver_p2pk = p2pk_spk(&receiver); + let receiver_spk_vec = spk_to_vec(&receiver_p2pk); + + let htlc_script = create_htlc_script( + &sender.x_only_public_key().0.serialize(), + &[], + &receiver_spk_vec, + &secret_hash, + timelock, + 0, + [0u8; 32], + ) + .expect("Script creation"); + + let spk = pay_to_script_hash_script(&htlc_script); + let input_value = 1_000_000_000u64; + let fee_value = 100_000u64; + let solver_p2pk = p2pk_spk(&solver); + + let output0 = TransactionOutput::new(input_value + fee_value, receiver_p2pk.clone()); + + let (tx, entries) = + build_ccr_tx(&spk, input_value, fee_value, &solver_p2pk, vec![output0], 0); + let mut mutable_tx = MutableTransaction::with_entries(tx, entries); + + let sig_script = ScriptBuilder::new() + .add_data(&secret) + .unwrap() + .add_op(OpTrue) + .unwrap() + .add_data(&htlc_script) + .unwrap() + .drain(); + mutable_tx.tx.inputs[0].signature_script = sig_script; + + let tx = mutable_tx.as_verifiable(); + let sig_cache = Cache::new(10_000); + let reused_values = SigHashReusedValuesUnsync::new(); + let utxo_entry = tx.utxo(0).unwrap().clone(); + + let mut vm = TxScriptEngine::from_transaction_input( + &tx, + &tx.inputs()[0], + 0, + &utxo_entry, + &reused_values, + &sig_cache, + ); + vm.execute().expect_err("Should fail with only 1 output"); + } + + #[test] + fn test_ccr_fails_with_three_outputs() { + let secp = Secp256k1::new(); + let mut rng = rand::rng(); + + let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let solver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let attacker = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + + let secret: [u8; 32] = rand::random(); + let secret_hash: [u8; 32] = Sha256::digest(secret).into(); + let timelock = 1_700_000_000u64 + 7200; + + let receiver_p2pk = p2pk_spk(&receiver); + let receiver_spk_vec = spk_to_vec(&receiver_p2pk); + + let htlc_script = create_htlc_script( + &sender.x_only_public_key().0.serialize(), + &[], + &receiver_spk_vec, + &secret_hash, + timelock, + 0, + [0u8; 32], + ) + .expect("Script creation"); + + let spk = pay_to_script_hash_script(&htlc_script); + let input_value = 1_000_000_000u64; + let fee_value = 100_000u64; + let receiver_amount = input_value - SOLVER_REWARD as u64; + let solver_p2pk = p2pk_spk(&solver); + + let output0 = TransactionOutput::new(receiver_amount, receiver_p2pk.clone()); + let output1 = + TransactionOutput::new(SOLVER_REWARD as u64 / 2 + fee_value, solver_p2pk.clone()); + let output2 = TransactionOutput::new(SOLVER_REWARD as u64 / 2, p2pk_spk(&attacker)); + + let (tx, entries) = build_ccr_tx( + &spk, + input_value, + fee_value, + &solver_p2pk, + vec![output0, output1, output2], + 0, + ); + let mut mutable_tx = MutableTransaction::with_entries(tx, entries); + + let sig_script = ScriptBuilder::new() + .add_data(&secret) + .unwrap() + .add_op(OpTrue) + .unwrap() + .add_data(&htlc_script) + .unwrap() + .drain(); + mutable_tx.tx.inputs[0].signature_script = sig_script; + + let tx = mutable_tx.as_verifiable(); + let sig_cache = Cache::new(10_000); + let reused_values = SigHashReusedValuesUnsync::new(); + let utxo_entry = tx.utxo(0).unwrap().clone(); + + let mut vm = TxScriptEngine::from_transaction_input( + &tx, + &tx.inputs()[0], + 0, + &utxo_entry, + &reused_values, + &sig_cache, + ); + vm.execute().expect_err("Should fail with 3 outputs"); + } + + #[test] + fn test_ccr_fails_with_zero_outputs() { + let secp = Secp256k1::new(); + let mut rng = rand::rng(); + + let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let solver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + + let secret: [u8; 32] = rand::random(); + let secret_hash: [u8; 32] = Sha256::digest(secret).into(); + let timelock = 1_700_000_000u64 + 7200; + + let receiver_spk_vec = spk_to_vec(&p2pk_spk(&receiver)); + let solver_p2pk = p2pk_spk(&solver); + + let htlc_script = create_htlc_script( + &sender.x_only_public_key().0.serialize(), + &[], + &receiver_spk_vec, + &secret_hash, + timelock, + 0, + [0u8; 32], + ) + .expect("Script creation"); + + let spk = pay_to_script_hash_script(&htlc_script); + let input_value = 1_000_000_000u64; + + let (tx, entries) = build_ccr_tx(&spk, input_value, 100_000, &solver_p2pk, vec![], 0); + let mut mutable_tx = MutableTransaction::with_entries(tx, entries); + + let sig_script = ScriptBuilder::new() + .add_data(&secret) + .unwrap() + .add_op(OpTrue) + .unwrap() + .add_data(&htlc_script) + .unwrap() + .drain(); + mutable_tx.tx.inputs[0].signature_script = sig_script; + + let tx = mutable_tx.as_verifiable(); + let sig_cache = Cache::new(10_000); + let reused_values = SigHashReusedValuesUnsync::new(); + let utxo_entry = tx.utxo(0).unwrap().clone(); + + let mut vm = TxScriptEngine::from_transaction_input( + &tx, + &tx.inputs()[0], + 0, + &utxo_entry, + &reused_values, + &sig_cache, + ); + vm.execute().expect_err("Should fail with zero outputs"); + } + + #[test] + fn test_ccr_fails_with_missing_secret() { + let secp = Secp256k1::new(); + let mut rng = rand::rng(); + + let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let solver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + + let secret: [u8; 32] = rand::random(); + let secret_hash: [u8; 32] = Sha256::digest(secret).into(); + let timelock = 1_700_000_000u64 + 7200; + + let receiver_p2pk = p2pk_spk(&receiver); + let receiver_spk_vec = spk_to_vec(&receiver_p2pk); + + let htlc_script = create_htlc_script( + &sender.x_only_public_key().0.serialize(), + &[], + &receiver_spk_vec, + &secret_hash, + timelock, + 0, + [0u8; 32], + ) + .expect("Script creation"); + + let spk = pay_to_script_hash_script(&htlc_script); + let input_value = 1_000_000_000u64; + let fee_value = 100_000u64; + let receiver_amount = input_value - SOLVER_REWARD as u64; + let solver_p2pk = p2pk_spk(&solver); + + let output0 = TransactionOutput::new(receiver_amount, receiver_p2pk.clone()); + let output1 = TransactionOutput::new(SOLVER_REWARD as u64 + fee_value, solver_p2pk.clone()); + + let (tx, entries) = build_ccr_tx( + &spk, + input_value, + fee_value, + &solver_p2pk, + vec![output0, output1], + 0, + ); + let mut mutable_tx = MutableTransaction::with_entries(tx, entries); + + let sig_script = ScriptBuilder::new() + .add_op(OpTrue) + .unwrap() + .add_data(&htlc_script) + .unwrap() + .drain(); + mutable_tx.tx.inputs[0].signature_script = sig_script; + + let tx = mutable_tx.as_verifiable(); + let sig_cache = Cache::new(10_000); + let reused_values = SigHashReusedValuesUnsync::new(); + let utxo_entry = tx.utxo(0).unwrap().clone(); + + let mut vm = TxScriptEngine::from_transaction_input( + &tx, + &tx.inputs()[0], + 0, + &utxo_entry, + &reused_values, + &sig_cache, + ); + vm.execute().expect_err("Should fail with missing secret"); + } + + #[test] + fn test_refund_fails_with_excessive_fee() { + let secp = Secp256k1::new(); + let mut rng = rand::rng(); + + let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + + let secret: [u8; 32] = rand::random(); + let secret_hash: [u8; 32] = Sha256::digest(secret).into(); + let timelock = 1_700_000_000u64 + 7200; + + let receiver_spk_vec = spk_to_vec(&p2pk_spk(&receiver)); + let sender_p2pk = p2pk_spk(&sender); + let sender_spk_vec = spk_to_vec(&sender_p2pk); + + let htlc_script = create_htlc_script( + &sender_spk_vec, + &[], + &receiver_spk_vec, + &secret_hash, + timelock, + 0, + [0u8; 32], + ) + .expect("Script creation"); + + let spk = pay_to_script_hash_script(&htlc_script); + let input_value = 1_000_000_000u64; + + let stolen_amount = input_value - 10_000; + let output = TransactionOutput::new(stolen_amount, sender_p2pk.clone()); + + let (tx, entries) = build_refund_tx(&spk, input_value, vec![output], timelock); + let mut mutable_tx = MutableTransaction::with_entries(tx, entries); + + let sig_script = ScriptBuilder::new() + .add_op(OpFalse) + .unwrap() + .add_data(&htlc_script) + .unwrap() + .drain(); + mutable_tx.tx.inputs[0].signature_script = sig_script; + + let tx = mutable_tx.as_verifiable(); + let sig_cache = Cache::new(10_000); + let reused_values = SigHashReusedValuesUnsync::new(); + let utxo_entry = tx.utxo(0).unwrap().clone(); + + let mut vm = TxScriptEngine::from_transaction_input( + &tx, + &tx.inputs()[0], + 0, + &utxo_entry, + &reused_values, + &sig_cache, + ); + vm.execute() + .expect_err("Should fail when fee exceeds allowance"); + } + + #[test] + fn test_fails_with_only_redeem_script() { + let secp = Secp256k1::new(); + let mut rng = rand::rng(); + + let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + + let secret: [u8; 32] = rand::random(); + let secret_hash: [u8; 32] = Sha256::digest(secret).into(); + let timelock = 1_700_000_000u64 + 7200; + + let receiver_spk_vec = spk_to_vec(&p2pk_spk(&receiver)); + + let htlc_script = create_htlc_script( + &sender.x_only_public_key().0.serialize(), + &[], + &receiver_spk_vec, + &secret_hash, + timelock, + 0, + [0u8; 32], + ) + .expect("Script creation"); + + let spk = pay_to_script_hash_script(&htlc_script); + let input_value = 1_000_000_000u64; + let output = TransactionOutput::new(input_value, spk.clone()); + let utxo_entry = UtxoEntry::new(input_value, spk.clone(), 0, false); + + let input = TransactionInput { + previous_outpoint: TransactionOutpoint::new(Hash::from_u64_word(1), 0), + signature_script: vec![], + sequence: 0, + sig_op_count: 0, + }; + + let tx = Transaction::new( + 1, + vec![input], + vec![output], + 0, + SUBNETWORK_ID_NATIVE, + 0, + vec![], + ); + let mut mutable_tx = MutableTransaction::with_entries(tx, vec![utxo_entry.clone()]); + + let sig_script = ScriptBuilder::new().add_data(&htlc_script).unwrap().drain(); + mutable_tx.tx.inputs[0].signature_script = sig_script; + + let tx = mutable_tx.as_verifiable(); + let sig_cache = Cache::new(10_000); + let reused_values = SigHashReusedValuesUnsync::new(); + + let mut vm = TxScriptEngine::from_transaction_input( + &tx, + &tx.inputs()[0], + 0, + &utxo_entry, + &reused_values, + &sig_cache, + ); + vm.execute().expect_err("Should fail with no arguments"); + } + + #[test] + fn test_script_size() { + let secp = Secp256k1::new(); + let mut rng = rand::rng(); + + let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); + + let secret_hash: [u8; 32] = Sha256::digest(rand::random::<[u8; 32]>()).into(); + let timelock = 1_700_000_000u64; + + let receiver_spk_vec = spk_to_vec(&p2pk_spk(&receiver)); + + let script = create_htlc_script( + &sender.x_only_public_key().0.serialize(), + &[], + &receiver_spk_vec, + &secret_hash, + timelock, + 0, + [0u8; 32], + ) + .expect("Script creation"); + + assert!(script.len() < 250, "Script should be under 250 bytes"); + } + + #[test] + fn decode_u64_pads_and_truncates() { + assert_eq!(decode_u64_from_script(&1234u64.to_le_bytes()), 1234); + assert_eq!(decode_u64_from_script(&[1]), 1); + assert_eq!(decode_u64_from_script(&[]), 0); + assert_eq!(decode_u64_from_script(&[0, 0, 0, 0, 0, 0, 0, 0, 9]), 0); + } + + #[test] + fn create_htlc_script_emits_non_empty_bytes() { + let script = + create_htlc_script(&[1, 2], b"addr", &[3, 4], &[0u8; 32], 1000, 1, [9u8; 32]).unwrap(); + assert!(!script.is_empty()); + } +} diff --git a/crates/data/src/chains/kaspa/contracts/tests/mod.rs b/crates/data/src/chains/kaspa/contracts/tests/mod.rs deleted file mode 100644 index 7887350..0000000 --- a/crates/data/src/chains/kaspa/contracts/tests/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -mod script; -mod validation; diff --git a/crates/data/src/chains/kaspa/contracts/tests/script.rs b/crates/data/src/chains/kaspa/contracts/tests/script.rs deleted file mode 100644 index 5c5a69e..0000000 --- a/crates/data/src/chains/kaspa/contracts/tests/script.rs +++ /dev/null @@ -1,2863 +0,0 @@ -#[cfg(test)] -mod tests { - use crate::chains::kaspa::contracts::contract_v1::{SOLVER_REWARD, create_htlc_script}; - - use kaspa_consensus_core::tx::{ - Transaction, TransactionInput, TransactionOutpoint, TransactionOutput, UtxoEntry, - }; - use kaspa_consensus_core::{ - hashing::{ - sighash::{SigHashReusedValuesUnsync, calc_schnorr_signature_hash}, - sighash_type::SIG_HASH_ALL, - }, - subnets::SUBNETWORK_ID_NATIVE, - tx::{MutableTransaction, VerifiableTransaction}, - }; - use kaspa_hashes::Hash; - use kaspa_txscript::opcodes::codes::{OpFalse, OpTrue}; - use kaspa_txscript::{ - TxScriptEngine, caches::Cache, pay_to_script_hash_script, script_builder::ScriptBuilder, - }; - use secp256k1::{Keypair, Secp256k1}; - use sha2::{Digest, Sha256}; - - use crate::chains::kaspa::broadcast::spk_to_vec; - use crate::chains::kaspa::test_helpers::p2pk_spk; - - fn build_ccr_tx( - htlc_spk: &kaspa_consensus_core::tx::ScriptPublicKey, - htlc_value: u64, - solver_fee_value: u64, - solver_fee_spk: &kaspa_consensus_core::tx::ScriptPublicKey, - outputs: Vec, - lock_time: u64, - ) -> (Transaction, Vec) { - let htlc_utxo = UtxoEntry::new(htlc_value, htlc_spk.clone(), 0, false); - let fee_utxo = UtxoEntry::new(solver_fee_value, solver_fee_spk.clone(), 0, false); - - let input0 = TransactionInput { - previous_outpoint: TransactionOutpoint::new(Hash::from_u64_word(1), 0), - signature_script: vec![], - sequence: 0, - sig_op_count: 0, - }; - let input1 = TransactionInput { - previous_outpoint: TransactionOutpoint::new(Hash::from_u64_word(2), 0), - signature_script: vec![], - sequence: 0, - sig_op_count: 1, - }; - - let tx = Transaction::new( - 1, - vec![input0, input1], - outputs, - lock_time, - SUBNETWORK_ID_NATIVE, - 0, - vec![], - ); - (tx, vec![htlc_utxo, fee_utxo]) - } - - fn build_refund_tx( - htlc_spk: &kaspa_consensus_core::tx::ScriptPublicKey, - htlc_value: u64, - outputs: Vec, - lock_time: u64, - ) -> (Transaction, Vec) { - let utxo = UtxoEntry::new(htlc_value, htlc_spk.clone(), 0, false); - - let input = TransactionInput { - previous_outpoint: TransactionOutpoint::new(Hash::from_u64_word(1), 0), - signature_script: vec![], - sequence: 0, - sig_op_count: 1, - }; - - let tx = Transaction::new( - 1, - vec![input], - outputs, - lock_time, - SUBNETWORK_ID_NATIVE, - 0, - vec![], - ); - (tx, vec![utxo]) - } - - fn build_refund_tx_2in( - htlc_spk: &kaspa_consensus_core::tx::ScriptPublicKey, - htlc_value: u64, - fee_spk: &kaspa_consensus_core::tx::ScriptPublicKey, - fee_value: u64, - outputs: Vec, - lock_time: u64, - ) -> (Transaction, Vec) { - let input0 = TransactionInput { - previous_outpoint: TransactionOutpoint::new(Hash::from_u64_word(1), 0), - signature_script: vec![], - sequence: 0, - sig_op_count: 0, - }; - let input1 = TransactionInput { - previous_outpoint: TransactionOutpoint::new(Hash::from_u64_word(2), 0), - signature_script: vec![], - sequence: 0, - sig_op_count: 1, - }; - - let utxo0 = UtxoEntry::new(htlc_value, htlc_spk.clone(), 0, false); - let utxo1 = UtxoEntry::new(fee_value, fee_spk.clone(), 0, false); - - let tx = Transaction::new( - 1, - vec![input0, input1], - outputs, - lock_time, - SUBNETWORK_ID_NATIVE, - 0, - vec![], - ); - (tx, vec![utxo0, utxo1]) - } - - #[test] - fn test_refund_succeeds_with_exact_amount_minus_solver_reward() { - let secp = Secp256k1::new(); - let mut rng = rand::rng(); - - let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let executor = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - - let secret: [u8; 32] = rand::random(); - let secret_hash: [u8; 32] = Sha256::digest(&secret).into(); - let timelock = 1_700_000_000u64 + 7200; - - let receiver_spk_vec = spk_to_vec(&p2pk_spk(&receiver)); - let sender_p2pk = p2pk_spk(&sender); - let sender_spk_vec = spk_to_vec(&sender_p2pk); - let executor_p2pk = p2pk_spk(&executor); - - let htlc_script = create_htlc_script( - &sender_spk_vec, - &vec![], - &receiver_spk_vec, - &secret_hash, - timelock, - 0, - [0u8; 32], - ) - .expect("Script creation"); - - let spk = pay_to_script_hash_script(&htlc_script); - let input_value = 1_000_000_000u64; - let fee_input_value = 100_000u64; - let fee = 3_000u64; - let refund_amount = input_value - SOLVER_REWARD as u64; - - let output0 = TransactionOutput::new(refund_amount, sender_p2pk.clone()); - let output1 = TransactionOutput::new( - SOLVER_REWARD as u64 + fee_input_value - fee, - executor_p2pk.clone(), - ); - - let (tx, entries) = build_refund_tx_2in( - &spk, - input_value, - &executor_p2pk, - fee_input_value, - vec![output0, output1], - timelock, - ); - let mut mutable_tx = MutableTransaction::with_entries(tx, entries); - - let sig_script = ScriptBuilder::new() - .add_op(OpFalse) - .unwrap() - .add_data(&htlc_script) - .unwrap() - .drain(); - mutable_tx.tx.inputs[0].signature_script = sig_script; - - let reused_values = SigHashReusedValuesUnsync::new(); - let sig_hash = calc_schnorr_signature_hash( - &mutable_tx.as_verifiable(), - 1, - SIG_HASH_ALL, - &reused_values, - ); - let msg = secp256k1::Message::from_digest(sig_hash.as_bytes()); - let sig = executor.sign_schnorr(msg.as_ref()); - let mut signature = Vec::new(); - signature.extend_from_slice(sig.as_ref()); - signature.push(SIG_HASH_ALL.to_u8()); - mutable_tx.tx.inputs[1].signature_script = - ScriptBuilder::new().add_data(&signature).unwrap().drain(); - - let tx = mutable_tx.as_verifiable(); - let sig_cache = Cache::new(10_000); - let utxo_entry = tx.utxo(0).unwrap().clone(); - - let mut vm = TxScriptEngine::from_transaction_input( - &tx, - &tx.inputs()[0], - 0, - &utxo_entry, - &reused_values, - &sig_cache, - ); - vm.execute() - .expect("Refund should succeed with exact amount minus solver reward"); - } - - #[test] - fn test_refund_succeeds_with_more_than_minimum() { - let secp = Secp256k1::new(); - let mut rng = rand::rng(); - - let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let executor = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - - let secret: [u8; 32] = rand::random(); - let secret_hash: [u8; 32] = Sha256::digest(&secret).into(); - let timelock = 1_700_000_000u64 + 7200; - - let receiver_spk_vec = spk_to_vec(&p2pk_spk(&receiver)); - let sender_p2pk = p2pk_spk(&sender); - let sender_spk_vec = spk_to_vec(&sender_p2pk); - let executor_p2pk = p2pk_spk(&executor); - - let htlc_script = create_htlc_script( - &sender_spk_vec, - &vec![], - &receiver_spk_vec, - &secret_hash, - timelock, - 0, - [0u8; 32], - ) - .expect("Script creation"); - - let spk = pay_to_script_hash_script(&htlc_script); - let input_value = 1_000_000_000u64; - let fee_input_value = 100_000u64; - let fee = 3_000u64; - - let output0 = TransactionOutput::new(input_value, sender_p2pk.clone()); - let output1 = TransactionOutput::new(fee_input_value - fee, executor_p2pk.clone()); - - let (tx, entries) = build_refund_tx_2in( - &spk, - input_value, - &executor_p2pk, - fee_input_value, - vec![output0, output1], - timelock, - ); - let mut mutable_tx = MutableTransaction::with_entries(tx, entries); - - let sig_script = ScriptBuilder::new() - .add_op(OpFalse) - .unwrap() - .add_data(&htlc_script) - .unwrap() - .drain(); - mutable_tx.tx.inputs[0].signature_script = sig_script; - - let reused_values = SigHashReusedValuesUnsync::new(); - let sig_hash = calc_schnorr_signature_hash( - &mutable_tx.as_verifiable(), - 1, - SIG_HASH_ALL, - &reused_values, - ); - let msg = secp256k1::Message::from_digest(sig_hash.as_bytes()); - let sig = executor.sign_schnorr(msg.as_ref()); - let mut signature = Vec::new(); - signature.extend_from_slice(sig.as_ref()); - signature.push(SIG_HASH_ALL.to_u8()); - mutable_tx.tx.inputs[1].signature_script = - ScriptBuilder::new().add_data(&signature).unwrap().drain(); - - let tx = mutable_tx.as_verifiable(); - let sig_cache = Cache::new(10_000); - let utxo_entry = tx.utxo(0).unwrap().clone(); - - let mut vm = TxScriptEngine::from_transaction_input( - &tx, - &tx.inputs()[0], - 0, - &utxo_entry, - &reused_values, - &sig_cache, - ); - vm.execute() - .expect("Refund should succeed when sender gets more than minimum"); - } - - #[test] - fn test_refund_fails_when_sender_gets_one_sompi_less_than_minimum() { - let secp = Secp256k1::new(); - let mut rng = rand::rng(); - - let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let executor = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - - let secret: [u8; 32] = rand::random(); - let secret_hash: [u8; 32] = Sha256::digest(&secret).into(); - let timelock = 1_700_000_000u64 + 7200; - - let receiver_spk_vec = spk_to_vec(&p2pk_spk(&receiver)); - let sender_p2pk = p2pk_spk(&sender); - let sender_spk_vec = spk_to_vec(&sender_p2pk); - let executor_p2pk = p2pk_spk(&executor); - - let htlc_script = create_htlc_script( - &sender_spk_vec, - &vec![], - &receiver_spk_vec, - &secret_hash, - timelock, - 0, - [0u8; 32], - ) - .expect("Script creation"); - - let spk = pay_to_script_hash_script(&htlc_script); - let input_value = 1_000_000_000u64; - let fee_input_value = 100_000u64; - - let refund_amount = input_value - SOLVER_REWARD as u64 - 1; - let output0 = TransactionOutput::new(refund_amount, sender_p2pk.clone()); - let output1 = TransactionOutput::new( - SOLVER_REWARD as u64 + 1 + fee_input_value, - executor_p2pk.clone(), - ); - - let (tx, entries) = build_refund_tx_2in( - &spk, - input_value, - &executor_p2pk, - fee_input_value, - vec![output0, output1], - timelock, - ); - let mut mutable_tx = MutableTransaction::with_entries(tx, entries); - - let sig_script = ScriptBuilder::new() - .add_op(OpFalse) - .unwrap() - .add_data(&htlc_script) - .unwrap() - .drain(); - mutable_tx.tx.inputs[0].signature_script = sig_script; - - let tx = mutable_tx.as_verifiable(); - let sig_cache = Cache::new(10_000); - let reused_values = SigHashReusedValuesUnsync::new(); - let utxo_entry = tx.utxo(0).unwrap().clone(); - - let mut vm = TxScriptEngine::from_transaction_input( - &tx, - &tx.inputs()[0], - 0, - &utxo_entry, - &reused_values, - &sig_cache, - ); - vm.execute() - .expect_err("Should fail when sender gets 1 sompi less than minimum"); - } - - #[test] - fn test_refund_fails_when_executor_takes_double_reward() { - let secp = Secp256k1::new(); - let mut rng = rand::rng(); - - let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let executor = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - - let secret: [u8; 32] = rand::random(); - let secret_hash: [u8; 32] = Sha256::digest(&secret).into(); - let timelock = 1_700_000_000u64 + 7200; - - let receiver_spk_vec = spk_to_vec(&p2pk_spk(&receiver)); - let sender_p2pk = p2pk_spk(&sender); - let sender_spk_vec = spk_to_vec(&sender_p2pk); - let executor_p2pk = p2pk_spk(&executor); - - let htlc_script = create_htlc_script( - &sender_spk_vec, - &vec![], - &receiver_spk_vec, - &secret_hash, - timelock, - 0, - [0u8; 32], - ) - .expect("Script creation"); - - let spk = pay_to_script_hash_script(&htlc_script); - let input_value = 1_000_000_000u64; - let fee_input_value = 100_000u64; - - let stolen = SOLVER_REWARD as u64 * 2; - let refund_amount = input_value - stolen; - let output0 = TransactionOutput::new(refund_amount, sender_p2pk.clone()); - let output1 = TransactionOutput::new(stolen + fee_input_value, executor_p2pk.clone()); - - let (tx, entries) = build_refund_tx_2in( - &spk, - input_value, - &executor_p2pk, - fee_input_value, - vec![output0, output1], - timelock, - ); - let mut mutable_tx = MutableTransaction::with_entries(tx, entries); - - let sig_script = ScriptBuilder::new() - .add_op(OpFalse) - .unwrap() - .add_data(&htlc_script) - .unwrap() - .drain(); - mutable_tx.tx.inputs[0].signature_script = sig_script; - - let tx = mutable_tx.as_verifiable(); - let sig_cache = Cache::new(10_000); - let reused_values = SigHashReusedValuesUnsync::new(); - let utxo_entry = tx.utxo(0).unwrap().clone(); - - let mut vm = TxScriptEngine::from_transaction_input( - &tx, - &tx.inputs()[0], - 0, - &utxo_entry, - &reused_values, - &sig_cache, - ); - vm.execute() - .expect_err("Should fail when executor takes double the solver reward"); - } - - #[test] - fn test_refund_fails_when_executor_takes_entire_htlc() { - let secp = Secp256k1::new(); - let mut rng = rand::rng(); - - let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let executor = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - - let secret: [u8; 32] = rand::random(); - let secret_hash: [u8; 32] = Sha256::digest(&secret).into(); - let timelock = 1_700_000_000u64 + 7200; - - let receiver_spk_vec = spk_to_vec(&p2pk_spk(&receiver)); - let sender_p2pk = p2pk_spk(&sender); - let sender_spk_vec = spk_to_vec(&sender_p2pk); - let executor_p2pk = p2pk_spk(&executor); - - let htlc_script = create_htlc_script( - &sender_spk_vec, - &vec![], - &receiver_spk_vec, - &secret_hash, - timelock, - 0, - [0u8; 32], - ) - .expect("Script creation"); - - let spk = pay_to_script_hash_script(&htlc_script); - let input_value = 1_000_000_000u64; - let fee_input_value = 100_000u64; - - let output0 = TransactionOutput::new(0, sender_p2pk.clone()); - let output1 = TransactionOutput::new(input_value + fee_input_value, executor_p2pk.clone()); - - let (tx, entries) = build_refund_tx_2in( - &spk, - input_value, - &executor_p2pk, - fee_input_value, - vec![output0, output1], - timelock, - ); - let mut mutable_tx = MutableTransaction::with_entries(tx, entries); - - let sig_script = ScriptBuilder::new() - .add_op(OpFalse) - .unwrap() - .add_data(&htlc_script) - .unwrap() - .drain(); - mutable_tx.tx.inputs[0].signature_script = sig_script; - - let tx = mutable_tx.as_verifiable(); - let sig_cache = Cache::new(10_000); - let reused_values = SigHashReusedValuesUnsync::new(); - let utxo_entry = tx.utxo(0).unwrap().clone(); - - let mut vm = TxScriptEngine::from_transaction_input( - &tx, - &tx.inputs()[0], - 0, - &utxo_entry, - &reused_values, - &sig_cache, - ); - vm.execute() - .expect_err("Should fail when executor takes entire HTLC amount"); - } - - #[test] - fn test_refund_fails_when_sender_gets_half() { - let secp = Secp256k1::new(); - let mut rng = rand::rng(); - - let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let executor = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - - let secret: [u8; 32] = rand::random(); - let secret_hash: [u8; 32] = Sha256::digest(&secret).into(); - let timelock = 1_700_000_000u64 + 7200; - - let receiver_spk_vec = spk_to_vec(&p2pk_spk(&receiver)); - let sender_p2pk = p2pk_spk(&sender); - let sender_spk_vec = spk_to_vec(&sender_p2pk); - let executor_p2pk = p2pk_spk(&executor); - - let htlc_script = create_htlc_script( - &sender_spk_vec, - &vec![], - &receiver_spk_vec, - &secret_hash, - timelock, - 0, - [0u8; 32], - ) - .expect("Script creation"); - - let spk = pay_to_script_hash_script(&htlc_script); - let input_value = 1_000_000_000u64; - let fee_input_value = 100_000u64; - - let half = input_value / 2; - let output0 = TransactionOutput::new(half, sender_p2pk.clone()); - let output1 = TransactionOutput::new(half + fee_input_value, executor_p2pk.clone()); - - let (tx, entries) = build_refund_tx_2in( - &spk, - input_value, - &executor_p2pk, - fee_input_value, - vec![output0, output1], - timelock, - ); - let mut mutable_tx = MutableTransaction::with_entries(tx, entries); - - let sig_script = ScriptBuilder::new() - .add_op(OpFalse) - .unwrap() - .add_data(&htlc_script) - .unwrap() - .drain(); - mutable_tx.tx.inputs[0].signature_script = sig_script; - - let tx = mutable_tx.as_verifiable(); - let sig_cache = Cache::new(10_000); - let reused_values = SigHashReusedValuesUnsync::new(); - let utxo_entry = tx.utxo(0).unwrap().clone(); - - let mut vm = TxScriptEngine::from_transaction_input( - &tx, - &tx.inputs()[0], - 0, - &utxo_entry, - &reused_values, - &sig_cache, - ); - vm.execute() - .expect_err("Should fail when sender only gets half"); - } - - #[test] - fn test_refund_succeeds_with_small_htlc_amount() { - let secp = Secp256k1::new(); - let mut rng = rand::rng(); - - let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let executor = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - - let secret: [u8; 32] = rand::random(); - let secret_hash: [u8; 32] = Sha256::digest(&secret).into(); - let timelock = 1_700_000_000u64 + 7200; - - let receiver_spk_vec = spk_to_vec(&p2pk_spk(&receiver)); - let sender_p2pk = p2pk_spk(&sender); - let sender_spk_vec = spk_to_vec(&sender_p2pk); - let executor_p2pk = p2pk_spk(&executor); - - let htlc_script = create_htlc_script( - &sender_spk_vec, - &vec![], - &receiver_spk_vec, - &secret_hash, - timelock, - 0, - [0u8; 32], - ) - .expect("Script creation"); - - let spk = pay_to_script_hash_script(&htlc_script); - - let input_value = SOLVER_REWARD as u64; - let fee_input_value = 100_000u64; - let fee = 3_000u64; - - let output0 = TransactionOutput::new(0, sender_p2pk.clone()); - let output1 = - TransactionOutput::new(input_value + fee_input_value - fee, executor_p2pk.clone()); - - let (tx, entries) = build_refund_tx_2in( - &spk, - input_value, - &executor_p2pk, - fee_input_value, - vec![output0, output1], - timelock, - ); - let mut mutable_tx = MutableTransaction::with_entries(tx, entries); - - let sig_script = ScriptBuilder::new() - .add_op(OpFalse) - .unwrap() - .add_data(&htlc_script) - .unwrap() - .drain(); - mutable_tx.tx.inputs[0].signature_script = sig_script; - - let reused_values = SigHashReusedValuesUnsync::new(); - let sig_hash = calc_schnorr_signature_hash( - &mutable_tx.as_verifiable(), - 1, - SIG_HASH_ALL, - &reused_values, - ); - let msg = secp256k1::Message::from_digest(sig_hash.as_bytes()); - let sig = executor.sign_schnorr(msg.as_ref()); - let mut signature = Vec::new(); - signature.extend_from_slice(sig.as_ref()); - signature.push(SIG_HASH_ALL.to_u8()); - mutable_tx.tx.inputs[1].signature_script = - ScriptBuilder::new().add_data(&signature).unwrap().drain(); - - let tx = mutable_tx.as_verifiable(); - let sig_cache = Cache::new(10_000); - let utxo_entry = tx.utxo(0).unwrap().clone(); - - let mut vm = TxScriptEngine::from_transaction_input( - &tx, - &tx.inputs()[0], - 0, - &utxo_entry, - &reused_values, - &sig_cache, - ); - vm.execute() - .expect("Refund should succeed when HTLC equals solver reward (sender gets 0)"); - } - - #[test] - fn test_refund_path_success() { - let secp = Secp256k1::new(); - let mut rng = rand::rng(); - - let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let executor = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - - let secret: [u8; 32] = rand::random(); - let secret_hash: [u8; 32] = Sha256::digest(&secret).into(); - let timelock = 1_700_000_000u64 + 7200; - - let receiver_spk_vec = spk_to_vec(&p2pk_spk(&receiver)); - let sender_p2pk = p2pk_spk(&sender); - let sender_spk_vec = spk_to_vec(&sender_p2pk); - let executor_p2pk = p2pk_spk(&executor); - - let htlc_script = create_htlc_script( - &sender_spk_vec, - &vec![], - &receiver_spk_vec, - &secret_hash, - timelock, - 0, - [0u8; 32], - ) - .expect("Script creation"); - - let spk = pay_to_script_hash_script(&htlc_script); - let input_value = 1_000_000_000u64; - let fee_input_value = 100_000u64; - let fee = 3_000u64; - - let output0 = TransactionOutput::new(input_value, sender_p2pk.clone()); - let output1 = TransactionOutput::new(fee_input_value - fee, executor_p2pk.clone()); - - let (tx, entries) = build_refund_tx_2in( - &spk, - input_value, - &executor_p2pk, - fee_input_value, - vec![output0, output1], - timelock, - ); - let mut mutable_tx = MutableTransaction::with_entries(tx, entries); - - let sig_script = ScriptBuilder::new() - .add_op(OpFalse) - .unwrap() - .add_data(&htlc_script) - .unwrap() - .drain(); - mutable_tx.tx.inputs[0].signature_script = sig_script; - - let reused_values = SigHashReusedValuesUnsync::new(); - let sig_hash = calc_schnorr_signature_hash( - &mutable_tx.as_verifiable(), - 1, - SIG_HASH_ALL, - &reused_values, - ); - let msg = secp256k1::Message::from_digest(sig_hash.as_bytes()); - let sig = executor.sign_schnorr(msg.as_ref()); - let mut signature = Vec::new(); - signature.extend_from_slice(sig.as_ref()); - signature.push(SIG_HASH_ALL.to_u8()); - let fee_sig_script = ScriptBuilder::new().add_data(&signature).unwrap().drain(); - mutable_tx.tx.inputs[1].signature_script = fee_sig_script; - - let tx = mutable_tx.as_verifiable(); - let sig_cache = Cache::new(10_000); - let utxo_entry = tx.utxo(0).unwrap().clone(); - - let mut vm = TxScriptEngine::from_transaction_input( - &tx, - &tx.inputs()[0], - 0, - &utxo_entry, - &reused_values, - &sig_cache, - ); - vm.execute().expect("Refund path should succeed"); - } - - #[test] - fn test_refund_fails_before_timelock() { - let secp = Secp256k1::new(); - let mut rng = rand::rng(); - - let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let executor = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - - let secret: [u8; 32] = rand::random(); - let secret_hash: [u8; 32] = Sha256::digest(&secret).into(); - let timelock = 1_700_000_000u64 + 7200; - - let receiver_spk_vec = spk_to_vec(&p2pk_spk(&receiver)); - let sender_p2pk = p2pk_spk(&sender); - let sender_spk_vec = spk_to_vec(&sender_p2pk); - let executor_p2pk = p2pk_spk(&executor); - - let htlc_script = create_htlc_script( - &sender_spk_vec, - &vec![], - &receiver_spk_vec, - &secret_hash, - timelock, - 0, - [0u8; 32], - ) - .expect("Script creation"); - - let spk = pay_to_script_hash_script(&htlc_script); - let input_value = 1_000_000_000u64; - let fee_input_value = 100_000u64; - let fee = 3_000u64; - - let output0 = TransactionOutput::new(input_value, sender_p2pk.clone()); - let output1 = TransactionOutput::new(fee_input_value - fee, executor_p2pk.clone()); - - let (tx, entries) = build_refund_tx_2in( - &spk, - input_value, - &executor_p2pk, - fee_input_value, - vec![output0, output1], - timelock - 1, - ); - let mut mutable_tx = MutableTransaction::with_entries(tx, entries); - - let sig_script = ScriptBuilder::new() - .add_op(OpFalse) - .unwrap() - .add_data(&htlc_script) - .unwrap() - .drain(); - mutable_tx.tx.inputs[0].signature_script = sig_script; - - let tx = mutable_tx.as_verifiable(); - let sig_cache = Cache::new(10_000); - let reused_values = SigHashReusedValuesUnsync::new(); - let utxo_entry = tx.utxo(0).unwrap().clone(); - - let mut vm = TxScriptEngine::from_transaction_input( - &tx, - &tx.inputs()[0], - 0, - &utxo_entry, - &reused_values, - &sig_cache, - ); - vm.execute().expect_err("Should fail before timelock"); - } - - #[test] - fn test_refund_fails_when_receiver_redirects_funds() { - let secp = Secp256k1::new(); - let mut rng = rand::rng(); - - let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let executor = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - - let secret: [u8; 32] = rand::random(); - let secret_hash: [u8; 32] = Sha256::digest(&secret).into(); - let timelock = 1_700_000_000u64 + 7200; - - let receiver_spk_vec = spk_to_vec(&p2pk_spk(&receiver)); - let sender_p2pk = p2pk_spk(&sender); - let sender_spk_vec = spk_to_vec(&sender_p2pk); - let executor_p2pk = p2pk_spk(&executor); - - let htlc_script = create_htlc_script( - &sender_spk_vec, - &vec![], - &receiver_spk_vec, - &secret_hash, - timelock, - 0, - [0u8; 32], - ) - .expect("Script creation"); - - let spk = pay_to_script_hash_script(&htlc_script); - let input_value = 1_000_000_000u64; - let fee_input_value = 100_000u64; - let fee = 3_000u64; - - let output0 = TransactionOutput::new(input_value, p2pk_spk(&receiver)); - let output1 = TransactionOutput::new(fee_input_value - fee, executor_p2pk.clone()); - - let (tx, entries) = build_refund_tx_2in( - &spk, - input_value, - &executor_p2pk, - fee_input_value, - vec![output0, output1], - timelock, - ); - let mut mutable_tx = MutableTransaction::with_entries(tx, entries); - - let sig_script = ScriptBuilder::new() - .add_op(OpFalse) - .unwrap() - .add_data(&htlc_script) - .unwrap() - .drain(); - mutable_tx.tx.inputs[0].signature_script = sig_script; - - let tx = mutable_tx.as_verifiable(); - let sig_cache = Cache::new(10_000); - let reused_values = SigHashReusedValuesUnsync::new(); - let utxo_entry = tx.utxo(0).unwrap().clone(); - - let mut vm = TxScriptEngine::from_transaction_input( - &tx, - &tx.inputs()[0], - 0, - &utxo_entry, - &reused_values, - &sig_cache, - ); - vm.execute() - .expect_err("Receiver should not be able to redirect refund"); - } - - #[test] - fn test_refund_fails_when_attacker_redirects_funds() { - let secp = Secp256k1::new(); - let mut rng = rand::rng(); - - let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let attacker = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - - let secret: [u8; 32] = rand::random(); - let secret_hash: [u8; 32] = Sha256::digest(&secret).into(); - let timelock = 1_700_000_000u64 + 7200; - - let receiver_spk_vec = spk_to_vec(&p2pk_spk(&receiver)); - let sender_p2pk = p2pk_spk(&sender); - let sender_spk_vec = spk_to_vec(&sender_p2pk); - let attacker_p2pk = p2pk_spk(&attacker); - - let htlc_script = create_htlc_script( - &sender_spk_vec, - &vec![], - &receiver_spk_vec, - &secret_hash, - timelock, - 0, - [0u8; 32], - ) - .expect("Script creation"); - - let spk = pay_to_script_hash_script(&htlc_script); - let input_value = 1_000_000_000u64; - let fee_input_value = 100_000u64; - let fee = 3_000u64; - - let output0 = TransactionOutput::new(input_value, attacker_p2pk.clone()); - let output1 = TransactionOutput::new(fee_input_value - fee, attacker_p2pk.clone()); - - let (tx, entries) = build_refund_tx_2in( - &spk, - input_value, - &attacker_p2pk, - fee_input_value, - vec![output0, output1], - timelock, - ); - let mut mutable_tx = MutableTransaction::with_entries(tx, entries); - - let sig_script = ScriptBuilder::new() - .add_op(OpFalse) - .unwrap() - .add_data(&htlc_script) - .unwrap() - .drain(); - mutable_tx.tx.inputs[0].signature_script = sig_script; - - let tx = mutable_tx.as_verifiable(); - let sig_cache = Cache::new(10_000); - let reused_values = SigHashReusedValuesUnsync::new(); - let utxo_entry = tx.utxo(0).unwrap().clone(); - - let mut vm = TxScriptEngine::from_transaction_input( - &tx, - &tx.inputs()[0], - 0, - &utxo_entry, - &reused_values, - &sig_cache, - ); - vm.execute() - .expect_err("Attacker should not be able to redirect refund"); - } - - #[test] - fn test_refund_fails_with_finalized_sequence() { - let secp = Secp256k1::new(); - let mut rng = rand::rng(); - - let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let executor = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - - let secret: [u8; 32] = rand::random(); - let secret_hash: [u8; 32] = Sha256::digest(&secret).into(); - let timelock = 1_700_000_000u64 + 7200; - - let receiver_spk_vec = spk_to_vec(&p2pk_spk(&receiver)); - let sender_p2pk = p2pk_spk(&sender); - let sender_spk_vec = spk_to_vec(&sender_p2pk); - let executor_p2pk = p2pk_spk(&executor); - - let htlc_script = create_htlc_script( - &sender_spk_vec, - &vec![], - &receiver_spk_vec, - &secret_hash, - timelock, - 0, - [0u8; 32], - ) - .expect("Script creation"); - - let spk = pay_to_script_hash_script(&htlc_script); - let input_value = 1_000_000_000u64; - let fee_input_value = 100_000u64; - let fee = 3_000u64; - - let output0 = TransactionOutput::new(input_value, sender_p2pk.clone()); - let output1 = TransactionOutput::new(fee_input_value - fee, executor_p2pk.clone()); - - let utxo0 = UtxoEntry::new(input_value, spk.clone(), 0, false); - let utxo1 = UtxoEntry::new(fee_input_value, executor_p2pk.clone(), 0, false); - - let input0 = TransactionInput { - previous_outpoint: TransactionOutpoint::new(Hash::from_u64_word(1), 0), - signature_script: vec![], - sequence: u64::MAX, - sig_op_count: 0, - }; - let input1 = TransactionInput { - previous_outpoint: TransactionOutpoint::new(Hash::from_u64_word(2), 0), - signature_script: vec![], - sequence: 0, - sig_op_count: 1, - }; - - let tx = Transaction::new( - 1, - vec![input0, input1], - vec![output0, output1], - timelock, - SUBNETWORK_ID_NATIVE, - 0, - vec![], - ); - let mut mutable_tx = MutableTransaction::with_entries(tx, vec![utxo0, utxo1]); - - let sig_script = ScriptBuilder::new() - .add_op(OpFalse) - .unwrap() - .add_data(&htlc_script) - .unwrap() - .drain(); - mutable_tx.tx.inputs[0].signature_script = sig_script; - - let tx = mutable_tx.as_verifiable(); - let sig_cache = Cache::new(10_000); - let reused_values = SigHashReusedValuesUnsync::new(); - let utxo_entry = tx.utxo(0).unwrap().clone(); - - let mut vm = TxScriptEngine::from_transaction_input( - &tx, - &tx.inputs()[0], - 0, - &utxo_entry, - &reused_values, - &sig_cache, - ); - vm.execute() - .expect_err("Should fail with finalized sequence"); - } - - #[test] - fn test_refund_succeeds_with_locktime_above_timelock() { - let secp = Secp256k1::new(); - let mut rng = rand::rng(); - - let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let executor = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - - let secret: [u8; 32] = rand::random(); - let secret_hash: [u8; 32] = Sha256::digest(&secret).into(); - let timelock = 1_700_000_000u64 + 7200; - - let receiver_spk_vec = spk_to_vec(&p2pk_spk(&receiver)); - let sender_p2pk = p2pk_spk(&sender); - let sender_spk_vec = spk_to_vec(&sender_p2pk); - let executor_p2pk = p2pk_spk(&executor); - - let htlc_script = create_htlc_script( - &sender_spk_vec, - &vec![], - &receiver_spk_vec, - &secret_hash, - timelock, - 0, - [0u8; 32], - ) - .expect("Script creation"); - - let spk = pay_to_script_hash_script(&htlc_script); - let input_value = 1_000_000_000u64; - let fee_input_value = 100_000u64; - let fee = 3_000u64; - - let output0 = TransactionOutput::new(input_value, sender_p2pk.clone()); - let output1 = TransactionOutput::new(fee_input_value - fee, executor_p2pk.clone()); - - let (tx, entries) = build_refund_tx_2in( - &spk, - input_value, - &executor_p2pk, - fee_input_value, - vec![output0, output1], - timelock + 1000, - ); - let mut mutable_tx = MutableTransaction::with_entries(tx, entries); - - let sig_script = ScriptBuilder::new() - .add_op(OpFalse) - .unwrap() - .add_data(&htlc_script) - .unwrap() - .drain(); - mutable_tx.tx.inputs[0].signature_script = sig_script; - - let reused_values = SigHashReusedValuesUnsync::new(); - let sig_hash = calc_schnorr_signature_hash( - &mutable_tx.as_verifiable(), - 1, - SIG_HASH_ALL, - &reused_values, - ); - let msg = secp256k1::Message::from_digest(sig_hash.as_bytes()); - let sig = executor.sign_schnorr(msg.as_ref()); - let mut signature = Vec::new(); - signature.extend_from_slice(sig.as_ref()); - signature.push(SIG_HASH_ALL.to_u8()); - let fee_sig_script = ScriptBuilder::new().add_data(&signature).unwrap().drain(); - mutable_tx.tx.inputs[1].signature_script = fee_sig_script; - - let tx = mutable_tx.as_verifiable(); - let sig_cache = Cache::new(10_000); - let utxo_entry = tx.utxo(0).unwrap().clone(); - - let mut vm = TxScriptEngine::from_transaction_input( - &tx, - &tx.inputs()[0], - 0, - &utxo_entry, - &reused_values, - &sig_cache, - ); - vm.execute() - .expect("Refund should succeed when lock_time > timelock"); - } - - #[test] - fn test_ccr_fails_with_extra_stack_data() { - let secp = Secp256k1::new(); - let mut rng = rand::rng(); - - let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let solver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - - let secret: [u8; 32] = rand::random(); - let secret_hash: [u8; 32] = Sha256::digest(&secret).into(); - let timelock = 1_700_000_000u64 + 7200; - - let receiver_p2pk = p2pk_spk(&receiver); - let receiver_spk_vec = spk_to_vec(&receiver_p2pk); - let sender_p2pk = p2pk_spk(&sender); - let sender_spk_vec = spk_to_vec(&sender_p2pk); - - let htlc_script = create_htlc_script( - &sender_spk_vec, - &vec![], - &receiver_spk_vec, - &secret_hash, - timelock, - 0, - [0u8; 32], - ) - .expect("Script creation"); - - let spk = pay_to_script_hash_script(&htlc_script); - let input_value = 1_000_000_000u64; - let fee_value = 100_000u64; - let receiver_amount = input_value - SOLVER_REWARD as u64; - let solver_p2pk = p2pk_spk(&solver); - - let output0 = TransactionOutput::new(receiver_amount, receiver_p2pk.clone()); - let output1 = TransactionOutput::new(SOLVER_REWARD as u64 + fee_value, solver_p2pk.clone()); - - let (tx, entries) = build_ccr_tx( - &spk, - input_value, - fee_value, - &solver_p2pk, - vec![output0, output1], - 0, - ); - let mut mutable_tx = MutableTransaction::with_entries(tx, entries); - - let junk: [u8; 32] = rand::random(); - let sig_script = ScriptBuilder::new() - .add_data(&junk) - .unwrap() - .add_data(&secret) - .unwrap() - .add_op(OpTrue) - .unwrap() - .add_data(&htlc_script) - .unwrap() - .drain(); - mutable_tx.tx.inputs[0].signature_script = sig_script; - - let tx = mutable_tx.as_verifiable(); - let sig_cache = Cache::new(10_000); - let reused_values = SigHashReusedValuesUnsync::new(); - let utxo_entry = tx.utxo(0).unwrap().clone(); - - let mut vm = TxScriptEngine::from_transaction_input( - &tx, - &tx.inputs()[0], - 0, - &utxo_entry, - &reused_values, - &sig_cache, - ); - vm.execute().expect_err("Should fail with extra stack data"); - } - - #[test] - fn test_refund_fails_with_wrong_output_destination() { - let secp = Secp256k1::new(); - let mut rng = rand::rng(); - - let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let executor = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - - let secret: [u8; 32] = rand::random(); - let secret_hash: [u8; 32] = Sha256::digest(&secret).into(); - let timelock = 1_700_000_000u64 + 7200; - - let receiver_spk_vec = spk_to_vec(&p2pk_spk(&receiver)); - let sender_p2pk = p2pk_spk(&sender); - let sender_spk_vec = spk_to_vec(&sender_p2pk); - let executor_p2pk = p2pk_spk(&executor); - - let htlc_script = create_htlc_script( - &sender_spk_vec, - &vec![], - &receiver_spk_vec, - &secret_hash, - timelock, - 0, - [0u8; 32], - ) - .expect("Script creation"); - - let spk = pay_to_script_hash_script(&htlc_script); - let input_value = 1_000_000_000u64; - let fee_input_value = 100_000u64; - let fee = 3_000u64; - - let output0 = TransactionOutput::new(input_value, p2pk_spk(&receiver)); - let output1 = TransactionOutput::new(fee_input_value - fee, executor_p2pk.clone()); - - let (tx, entries) = build_refund_tx_2in( - &spk, - input_value, - &executor_p2pk, - fee_input_value, - vec![output0, output1], - timelock, - ); - let mut mutable_tx = MutableTransaction::with_entries(tx, entries); - - let sig_script = ScriptBuilder::new() - .add_op(OpFalse) - .unwrap() - .add_data(&htlc_script) - .unwrap() - .drain(); - mutable_tx.tx.inputs[0].signature_script = sig_script; - - let tx = mutable_tx.as_verifiable(); - let sig_cache = Cache::new(10_000); - let reused_values = SigHashReusedValuesUnsync::new(); - let utxo_entry = tx.utxo(0).unwrap().clone(); - - let mut vm = TxScriptEngine::from_transaction_input( - &tx, - &tx.inputs()[0], - 0, - &utxo_entry, - &reused_values, - &sig_cache, - ); - vm.execute() - .expect_err("Should fail when output goes to wrong address"); - } - - #[test] - fn test_refund_fails_with_extra_stack_data() { - let secp = Secp256k1::new(); - let mut rng = rand::rng(); - - let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let executor = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - - let secret: [u8; 32] = rand::random(); - let secret_hash: [u8; 32] = Sha256::digest(&secret).into(); - let timelock = 1_700_000_000u64 + 7200; - - let receiver_spk_vec = spk_to_vec(&p2pk_spk(&receiver)); - let sender_p2pk = p2pk_spk(&sender); - let sender_spk_vec = spk_to_vec(&sender_p2pk); - let executor_p2pk = p2pk_spk(&executor); - - let htlc_script = create_htlc_script( - &sender_spk_vec, - &vec![], - &receiver_spk_vec, - &secret_hash, - timelock, - 0, - [0u8; 32], - ) - .expect("Script creation"); - - let spk = pay_to_script_hash_script(&htlc_script); - let input_value = 1_000_000_000u64; - let fee_input_value = 100_000u64; - let fee = 3_000u64; - - let output0 = TransactionOutput::new(input_value, sender_p2pk.clone()); - let output1 = TransactionOutput::new(fee_input_value - fee, executor_p2pk.clone()); - - let (tx, entries) = build_refund_tx_2in( - &spk, - input_value, - &executor_p2pk, - fee_input_value, - vec![output0, output1], - timelock, - ); - let mut mutable_tx = MutableTransaction::with_entries(tx, entries); - - let junk: [u8; 32] = rand::random(); - let sig_script = ScriptBuilder::new() - .add_data(&junk) - .unwrap() - .add_op(OpFalse) - .unwrap() - .add_data(&htlc_script) - .unwrap() - .drain(); - mutable_tx.tx.inputs[0].signature_script = sig_script; - - let tx = mutable_tx.as_verifiable(); - let sig_cache = Cache::new(10_000); - let reused_values = SigHashReusedValuesUnsync::new(); - let utxo_entry = tx.utxo(0).unwrap().clone(); - - let mut vm = TxScriptEngine::from_transaction_input( - &tx, - &tx.inputs()[0], - 0, - &utxo_entry, - &reused_values, - &sig_cache, - ); - vm.execute().expect_err("Should fail with extra stack data"); - } - - #[test] - fn test_refund_fails_with_three_outputs() { - let secp = Secp256k1::new(); - let mut rng = rand::rng(); - - let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let executor = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - - let secret: [u8; 32] = rand::random(); - let secret_hash: [u8; 32] = Sha256::digest(&secret).into(); - let timelock = 1_700_000_000u64 + 7200; - - let receiver_spk_vec = spk_to_vec(&p2pk_spk(&receiver)); - let sender_p2pk = p2pk_spk(&sender); - let sender_spk_vec = spk_to_vec(&sender_p2pk); - let executor_p2pk = p2pk_spk(&executor); - - let htlc_script = create_htlc_script( - &sender_spk_vec, - &vec![], - &receiver_spk_vec, - &secret_hash, - timelock, - 0, - [0u8; 32], - ) - .expect("Script creation"); - - let spk = pay_to_script_hash_script(&htlc_script); - let input_value = 1_000_000_000u64; - let fee_input_value = 100_000u64; - - let third = input_value / 3; - let output0 = TransactionOutput::new(third, sender_p2pk.clone()); - let output1 = TransactionOutput::new(third, executor_p2pk.clone()); - let output2 = TransactionOutput::new(third, executor_p2pk.clone()); - - let (tx, entries) = build_refund_tx_2in( - &spk, - input_value, - &executor_p2pk, - fee_input_value, - vec![output0, output1, output2], - timelock, - ); - let mut mutable_tx = MutableTransaction::with_entries(tx, entries); - - let sig_script = ScriptBuilder::new() - .add_op(OpFalse) - .unwrap() - .add_data(&htlc_script) - .unwrap() - .drain(); - mutable_tx.tx.inputs[0].signature_script = sig_script; - - let tx = mutable_tx.as_verifiable(); - let sig_cache = Cache::new(10_000); - let reused_values = SigHashReusedValuesUnsync::new(); - let utxo_entry = tx.utxo(0).unwrap().clone(); - - let mut vm = TxScriptEngine::from_transaction_input( - &tx, - &tx.inputs()[0], - 0, - &utxo_entry, - &reused_values, - &sig_cache, - ); - vm.execute() - .expect_err("Should fail with 3 outputs — script enforces exactly 2"); - } - - #[test] - fn test_refund_fails_with_underpaid_sender() { - let secp = Secp256k1::new(); - let mut rng = rand::rng(); - - let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let executor = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - - let secret: [u8; 32] = rand::random(); - let secret_hash: [u8; 32] = Sha256::digest(&secret).into(); - let timelock = 1_700_000_000u64 + 7200; - - let receiver_spk_vec = spk_to_vec(&p2pk_spk(&receiver)); - let sender_p2pk = p2pk_spk(&sender); - let sender_spk_vec = spk_to_vec(&sender_p2pk); - let executor_p2pk = p2pk_spk(&executor); - - let htlc_script = create_htlc_script( - &sender_spk_vec, - &vec![], - &receiver_spk_vec, - &secret_hash, - timelock, - 0, - [0u8; 32], - ) - .expect("Script creation"); - - let spk = pay_to_script_hash_script(&htlc_script); - let input_value = 1_000_000_000u64; - let fee_input_value = 100_000u64; - - let stolen = 500_000_000u64; - let output0 = TransactionOutput::new(input_value - stolen, sender_p2pk.clone()); - let output1 = TransactionOutput::new(fee_input_value + stolen, executor_p2pk.clone()); - - let (tx, entries) = build_refund_tx_2in( - &spk, - input_value, - &executor_p2pk, - fee_input_value, - vec![output0, output1], - timelock, - ); - let mut mutable_tx = MutableTransaction::with_entries(tx, entries); - - let sig_script = ScriptBuilder::new() - .add_op(OpFalse) - .unwrap() - .add_data(&htlc_script) - .unwrap() - .drain(); - mutable_tx.tx.inputs[0].signature_script = sig_script; - - let tx = mutable_tx.as_verifiable(); - let sig_cache = Cache::new(10_000); - let reused_values = SigHashReusedValuesUnsync::new(); - let utxo_entry = tx.utxo(0).unwrap().clone(); - - let mut vm = TxScriptEngine::from_transaction_input( - &tx, - &tx.inputs()[0], - 0, - &utxo_entry, - &reused_values, - &sig_cache, - ); - vm.execute() - .expect_err("Should fail when sender gets less than full HTLC amount"); - } - - #[test] - fn test_ccr_path_success() { - let secp = Secp256k1::new(); - let mut rng = rand::rng(); - - let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let solver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - - let secret: [u8; 32] = rand::random(); - let secret_hash: [u8; 32] = Sha256::digest(&secret).into(); - let timelock = 1_700_000_000u64 + 7200; - - let receiver_p2pk = p2pk_spk(&receiver); - let receiver_spk_vec = spk_to_vec(&receiver_p2pk); - - let htlc_script = create_htlc_script( - &sender.x_only_public_key().0.serialize(), - &vec![], - &receiver_spk_vec, - &secret_hash, - timelock, - 0, - [0u8; 32], - ) - .expect("Script creation"); - - let spk = pay_to_script_hash_script(&htlc_script); - let input_value = 1_000_000_000u64; - let fee_value = 100_000u64; - let receiver_amount = input_value - SOLVER_REWARD as u64; - let solver_amount = SOLVER_REWARD as u64 + fee_value; - - let solver_p2pk = p2pk_spk(&solver); - let output0 = TransactionOutput::new(receiver_amount, receiver_p2pk.clone()); - let output1 = TransactionOutput::new(solver_amount, solver_p2pk.clone()); - - let (tx, entries) = build_ccr_tx( - &spk, - input_value, - fee_value, - &solver_p2pk, - vec![output0, output1], - 0, - ); - let mut mutable_tx = MutableTransaction::with_entries(tx, entries); - - let sig_script = ScriptBuilder::new() - .add_data(&secret) - .unwrap() - .add_op(OpTrue) - .unwrap() - .add_data(&htlc_script) - .unwrap() - .drain(); - mutable_tx.tx.inputs[0].signature_script = sig_script; - - let tx = mutable_tx.as_verifiable(); - let sig_cache = Cache::new(10_000); - let reused_values = SigHashReusedValuesUnsync::new(); - let utxo_entry = tx.utxo(0).unwrap().clone(); - - let mut vm = TxScriptEngine::from_transaction_input( - &tx, - &tx.inputs()[0], - 0, - &utxo_entry, - &reused_values, - &sig_cache, - ); - vm.execute().expect("CCR path should succeed"); - } - - #[test] - fn test_ccr_fails_with_wrong_secret() { - let secp = Secp256k1::new(); - let mut rng = rand::rng(); - - let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let solver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - - let secret: [u8; 32] = rand::random(); - let secret_hash: [u8; 32] = Sha256::digest(&secret).into(); - let timelock = 1_700_000_000u64 + 7200; - - let receiver_p2pk = p2pk_spk(&receiver); - let receiver_spk_vec = spk_to_vec(&receiver_p2pk); - - let htlc_script = create_htlc_script( - &sender.x_only_public_key().0.serialize(), - &vec![], - &receiver_spk_vec, - &secret_hash, - timelock, - 0, - [0u8; 32], - ) - .expect("Script creation"); - - let spk = pay_to_script_hash_script(&htlc_script); - let input_value = 1_000_000_000u64; - let fee_value = 100_000u64; - let receiver_amount = input_value - SOLVER_REWARD as u64; - let solver_p2pk = p2pk_spk(&solver); - - let output0 = TransactionOutput::new(receiver_amount, receiver_p2pk.clone()); - let output1 = TransactionOutput::new(SOLVER_REWARD as u64 + fee_value, solver_p2pk.clone()); - - let (tx, entries) = build_ccr_tx( - &spk, - input_value, - fee_value, - &solver_p2pk, - vec![output0, output1], - 0, - ); - let mut mutable_tx = MutableTransaction::with_entries(tx, entries); - - let wrong_secret: [u8; 32] = rand::random(); - let sig_script = ScriptBuilder::new() - .add_data(&wrong_secret) - .unwrap() - .add_op(OpTrue) - .unwrap() - .add_data(&htlc_script) - .unwrap() - .drain(); - mutable_tx.tx.inputs[0].signature_script = sig_script; - - let tx = mutable_tx.as_verifiable(); - let sig_cache = Cache::new(10_000); - let reused_values = SigHashReusedValuesUnsync::new(); - let utxo_entry = tx.utxo(0).unwrap().clone(); - - let mut vm = TxScriptEngine::from_transaction_input( - &tx, - &tx.inputs()[0], - 0, - &utxo_entry, - &reused_values, - &sig_cache, - ); - vm.execute().expect_err("Should fail with wrong secret"); - } - - #[test] - fn test_ccr_fails_with_wrong_receiver() { - let secp = Secp256k1::new(); - let mut rng = rand::rng(); - - let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let wrong_recv = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let solver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - - let secret: [u8; 32] = rand::random(); - let secret_hash: [u8; 32] = Sha256::digest(&secret).into(); - let timelock = 1_700_000_000u64 + 7200; - - let receiver_p2pk = p2pk_spk(&receiver); - let receiver_spk_vec = spk_to_vec(&receiver_p2pk); - - let htlc_script = create_htlc_script( - &sender.x_only_public_key().0.serialize(), - &vec![], - &receiver_spk_vec, - &secret_hash, - timelock, - 0, - [0u8; 32], - ) - .expect("Script creation"); - - let spk = pay_to_script_hash_script(&htlc_script); - let input_value = 1_000_000_000u64; - let fee_value = 100_000u64; - let receiver_amount = input_value - SOLVER_REWARD as u64; - let solver_p2pk = p2pk_spk(&solver); - - let output0 = TransactionOutput::new(receiver_amount, p2pk_spk(&wrong_recv)); - let output1 = TransactionOutput::new(SOLVER_REWARD as u64 + fee_value, solver_p2pk.clone()); - - let (tx, entries) = build_ccr_tx( - &spk, - input_value, - fee_value, - &solver_p2pk, - vec![output0, output1], - 0, - ); - let mut mutable_tx = MutableTransaction::with_entries(tx, entries); - - let sig_script = ScriptBuilder::new() - .add_data(&secret) - .unwrap() - .add_op(OpTrue) - .unwrap() - .add_data(&htlc_script) - .unwrap() - .drain(); - mutable_tx.tx.inputs[0].signature_script = sig_script; - - let tx = mutable_tx.as_verifiable(); - let sig_cache = Cache::new(10_000); - let reused_values = SigHashReusedValuesUnsync::new(); - let utxo_entry = tx.utxo(0).unwrap().clone(); - - let mut vm = TxScriptEngine::from_transaction_input( - &tx, - &tx.inputs()[0], - 0, - &utxo_entry, - &reused_values, - &sig_cache, - ); - vm.execute() - .expect_err("Should fail when output goes to wrong receiver"); - } - - #[test] - fn test_ccr_fails_with_insufficient_receiver_amount() { - let secp = Secp256k1::new(); - let mut rng = rand::rng(); - - let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let solver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - - let secret: [u8; 32] = rand::random(); - let secret_hash: [u8; 32] = Sha256::digest(&secret).into(); - let timelock = 1_700_000_000u64 + 7200; - - let receiver_p2pk = p2pk_spk(&receiver); - let receiver_spk_vec = spk_to_vec(&receiver_p2pk); - - let htlc_script = create_htlc_script( - &sender.x_only_public_key().0.serialize(), - &vec![], - &receiver_spk_vec, - &secret_hash, - timelock, - 0, - [0u8; 32], - ) - .expect("Script creation"); - - let spk = pay_to_script_hash_script(&htlc_script); - let input_value = 1_000_000_000u64; - let fee_value = 100_000u64; - let solver_p2pk = p2pk_spk(&solver); - - let receiver_amount = input_value - SOLVER_REWARD as u64 - 1; - let output0 = TransactionOutput::new(receiver_amount, receiver_p2pk.clone()); - let output1 = - TransactionOutput::new(SOLVER_REWARD as u64 + 1 + fee_value, solver_p2pk.clone()); - - let (tx, entries) = build_ccr_tx( - &spk, - input_value, - fee_value, - &solver_p2pk, - vec![output0, output1], - 0, - ); - let mut mutable_tx = MutableTransaction::with_entries(tx, entries); - - let sig_script = ScriptBuilder::new() - .add_data(&secret) - .unwrap() - .add_op(OpTrue) - .unwrap() - .add_data(&htlc_script) - .unwrap() - .drain(); - mutable_tx.tx.inputs[0].signature_script = sig_script; - - let tx = mutable_tx.as_verifiable(); - let sig_cache = Cache::new(10_000); - let reused_values = SigHashReusedValuesUnsync::new(); - let utxo_entry = tx.utxo(0).unwrap().clone(); - - let mut vm = TxScriptEngine::from_transaction_input( - &tx, - &tx.inputs()[0], - 0, - &utxo_entry, - &reused_values, - &sig_cache, - ); - vm.execute() - .expect_err("Should fail when receiver gets insufficient amount"); - } - - #[test] - fn test_ccr_fails_with_empty_secret() { - let secp = Secp256k1::new(); - let mut rng = rand::rng(); - - let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let solver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - - let secret: [u8; 32] = rand::random(); - let secret_hash: [u8; 32] = Sha256::digest(&secret).into(); - let timelock = 1_700_000_000u64 + 7200; - - let receiver_p2pk = p2pk_spk(&receiver); - let receiver_spk_vec = spk_to_vec(&receiver_p2pk); - - let htlc_script = create_htlc_script( - &sender.x_only_public_key().0.serialize(), - &vec![], - &receiver_spk_vec, - &secret_hash, - timelock, - 0, - [0u8; 32], - ) - .expect("Script creation"); - - let spk = pay_to_script_hash_script(&htlc_script); - let input_value = 1_000_000_000u64; - let fee_value = 100_000u64; - let receiver_amount = input_value - SOLVER_REWARD as u64; - let solver_p2pk = p2pk_spk(&solver); - - let output0 = TransactionOutput::new(receiver_amount, receiver_p2pk.clone()); - let output1 = TransactionOutput::new(SOLVER_REWARD as u64 + fee_value, solver_p2pk.clone()); - - let (tx, entries) = build_ccr_tx( - &spk, - input_value, - fee_value, - &solver_p2pk, - vec![output0, output1], - 0, - ); - let mut mutable_tx = MutableTransaction::with_entries(tx, entries); - - let empty_secret: [u8; 0] = []; - let sig_script = ScriptBuilder::new() - .add_data(&empty_secret) - .unwrap() - .add_op(OpTrue) - .unwrap() - .add_data(&htlc_script) - .unwrap() - .drain(); - mutable_tx.tx.inputs[0].signature_script = sig_script; - - let tx = mutable_tx.as_verifiable(); - let sig_cache = Cache::new(10_000); - let reused_values = SigHashReusedValuesUnsync::new(); - let utxo_entry = tx.utxo(0).unwrap().clone(); - - let mut vm = TxScriptEngine::from_transaction_input( - &tx, - &tx.inputs()[0], - 0, - &utxo_entry, - &reused_values, - &sig_cache, - ); - vm.execute().expect_err("Should fail with empty secret"); - } - - #[test] - fn test_ccr_succeeds_receiver_gets_more() { - let secp = Secp256k1::new(); - let mut rng = rand::rng(); - - let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let solver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - - let secret: [u8; 32] = rand::random(); - let secret_hash: [u8; 32] = Sha256::digest(&secret).into(); - let timelock = 1_700_000_000u64 + 7200; - - let receiver_p2pk = p2pk_spk(&receiver); - let receiver_spk_vec = spk_to_vec(&receiver_p2pk); - - let htlc_script = create_htlc_script( - &sender.x_only_public_key().0.serialize(), - &vec![], - &receiver_spk_vec, - &secret_hash, - timelock, - 0, - [0u8; 32], - ) - .expect("Script creation"); - - let spk = pay_to_script_hash_script(&htlc_script); - let input_value = 1_000_000_000u64; - let fee_value = 100_000u64; - let solver_p2pk = p2pk_spk(&solver); - - let receiver_amount = input_value - SOLVER_REWARD as u64 + 5_000_000; - let solver_amount = SOLVER_REWARD as u64 - 5_000_000 + fee_value; - - let output0 = TransactionOutput::new(receiver_amount, receiver_p2pk.clone()); - let output1 = TransactionOutput::new(solver_amount, solver_p2pk.clone()); - - let (tx, entries) = build_ccr_tx( - &spk, - input_value, - fee_value, - &solver_p2pk, - vec![output0, output1], - 0, - ); - let mut mutable_tx = MutableTransaction::with_entries(tx, entries); - - let sig_script = ScriptBuilder::new() - .add_data(&secret) - .unwrap() - .add_op(OpTrue) - .unwrap() - .add_data(&htlc_script) - .unwrap() - .drain(); - mutable_tx.tx.inputs[0].signature_script = sig_script; - - let tx = mutable_tx.as_verifiable(); - let sig_cache = Cache::new(10_000); - let reused_values = SigHashReusedValuesUnsync::new(); - let utxo_entry = tx.utxo(0).unwrap().clone(); - - let mut vm = TxScriptEngine::from_transaction_input( - &tx, - &tx.inputs()[0], - 0, - &utxo_entry, - &reused_values, - &sig_cache, - ); - vm.execute() - .expect("CCR should succeed when receiver gets more than minimum"); - } - - #[test] - fn test_ccr_fails_with_one_input() { - let secp = Secp256k1::new(); - let mut rng = rand::rng(); - - let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let solver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - - let secret: [u8; 32] = rand::random(); - let secret_hash: [u8; 32] = Sha256::digest(&secret).into(); - let timelock = 1_700_000_000u64 + 7200; - - let receiver_p2pk = p2pk_spk(&receiver); - let receiver_spk_vec = spk_to_vec(&receiver_p2pk); - - let htlc_script = create_htlc_script( - &sender.x_only_public_key().0.serialize(), - &vec![], - &receiver_spk_vec, - &secret_hash, - timelock, - 0, - [0u8; 32], - ) - .expect("Script creation"); - - let spk = pay_to_script_hash_script(&htlc_script); - let input_value = 1_000_000_000u64; - let receiver_amount = input_value - SOLVER_REWARD as u64; - let solver_p2pk = p2pk_spk(&solver); - - let output0 = TransactionOutput::new(receiver_amount, receiver_p2pk.clone()); - let output1 = TransactionOutput::new(SOLVER_REWARD as u64, solver_p2pk.clone()); - let utxo_entry = UtxoEntry::new(input_value, spk.clone(), 0, false); - - let input = TransactionInput { - previous_outpoint: TransactionOutpoint::new(Hash::from_u64_word(1), 0), - signature_script: vec![], - sequence: 0, - sig_op_count: 0, - }; - - let tx = Transaction::new( - 1, - vec![input], - vec![output0, output1], - 0, - SUBNETWORK_ID_NATIVE, - 0, - vec![], - ); - let mut mutable_tx = MutableTransaction::with_entries(tx, vec![utxo_entry.clone()]); - - let sig_script = ScriptBuilder::new() - .add_data(&secret) - .unwrap() - .add_op(OpTrue) - .unwrap() - .add_data(&htlc_script) - .unwrap() - .drain(); - mutable_tx.tx.inputs[0].signature_script = sig_script; - - let tx = mutable_tx.as_verifiable(); - let sig_cache = Cache::new(10_000); - let reused_values = SigHashReusedValuesUnsync::new(); - - let mut vm = TxScriptEngine::from_transaction_input( - &tx, - &tx.inputs()[0], - 0, - &utxo_entry, - &reused_values, - &sig_cache, - ); - vm.execute().expect_err("Should fail with only 1 input"); - } - - #[test] - fn test_ccr_fails_with_three_inputs() { - let secp = Secp256k1::new(); - let mut rng = rand::rng(); - - let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let solver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - - let secret: [u8; 32] = rand::random(); - let secret_hash: [u8; 32] = Sha256::digest(&secret).into(); - let timelock = 1_700_000_000u64 + 7200; - - let receiver_p2pk = p2pk_spk(&receiver); - let receiver_spk_vec = spk_to_vec(&receiver_p2pk); - - let htlc_script = create_htlc_script( - &sender.x_only_public_key().0.serialize(), - &vec![], - &receiver_spk_vec, - &secret_hash, - timelock, - 0, - [0u8; 32], - ) - .expect("Script creation"); - - let spk = pay_to_script_hash_script(&htlc_script); - let input_value = 1_000_000_000u64; - let fee_value = 100_000u64; - let receiver_amount = input_value - SOLVER_REWARD as u64; - let solver_p2pk = p2pk_spk(&solver); - - let output0 = TransactionOutput::new(receiver_amount, receiver_p2pk.clone()); - let output1 = - TransactionOutput::new(SOLVER_REWARD as u64 + fee_value * 2, solver_p2pk.clone()); - - let htlc_utxo = UtxoEntry::new(input_value, spk.clone(), 0, false); - let fee_utxo1 = UtxoEntry::new(fee_value, solver_p2pk.clone(), 0, false); - let fee_utxo2 = UtxoEntry::new(fee_value, solver_p2pk.clone(), 0, false); - - let input0 = TransactionInput { - previous_outpoint: TransactionOutpoint::new(Hash::from_u64_word(1), 0), - signature_script: vec![], - sequence: 0, - sig_op_count: 0, - }; - let input1 = TransactionInput { - previous_outpoint: TransactionOutpoint::new(Hash::from_u64_word(2), 0), - signature_script: vec![], - sequence: 0, - sig_op_count: 1, - }; - let input2 = TransactionInput { - previous_outpoint: TransactionOutpoint::new(Hash::from_u64_word(3), 0), - signature_script: vec![], - sequence: 0, - sig_op_count: 1, - }; - - let tx = Transaction::new( - 1, - vec![input0, input1, input2], - vec![output0, output1], - 0, - SUBNETWORK_ID_NATIVE, - 0, - vec![], - ); - let mut mutable_tx = - MutableTransaction::with_entries(tx, vec![htlc_utxo.clone(), fee_utxo1, fee_utxo2]); - - let sig_script = ScriptBuilder::new() - .add_data(&secret) - .unwrap() - .add_op(OpTrue) - .unwrap() - .add_data(&htlc_script) - .unwrap() - .drain(); - mutable_tx.tx.inputs[0].signature_script = sig_script; - - let tx = mutable_tx.as_verifiable(); - let sig_cache = Cache::new(10_000); - let reused_values = SigHashReusedValuesUnsync::new(); - - let mut vm = TxScriptEngine::from_transaction_input( - &tx, - &tx.inputs()[0], - 0, - &htlc_utxo, - &reused_values, - &sig_cache, - ); - vm.execute().expect_err("Should fail with 3 inputs"); - } - - #[test] - fn test_ccr_second_input_high_value_cannot_inflate_check() { - let secp = Secp256k1::new(); - let mut rng = rand::rng(); - - let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let solver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - - let secret: [u8; 32] = rand::random(); - let secret_hash: [u8; 32] = Sha256::digest(&secret).into(); - let timelock = 1_700_000_000u64 + 7200; - - let receiver_p2pk = p2pk_spk(&receiver); - let receiver_spk_vec = spk_to_vec(&receiver_p2pk); - - let htlc_script = create_htlc_script( - &sender.x_only_public_key().0.serialize(), - &vec![], - &receiver_spk_vec, - &secret_hash, - timelock, - 0, - [0u8; 32], - ) - .expect("Script creation"); - - let spk = pay_to_script_hash_script(&htlc_script); - let input_value = 1_000_000_000u64; - let attacker_fee_value = 50_000_000_000u64; - let solver_p2pk = p2pk_spk(&solver); - - let receiver_amount = input_value - SOLVER_REWARD as u64 - 1; - let solver_amount = SOLVER_REWARD as u64 + 1 + attacker_fee_value; - - let output0 = TransactionOutput::new(receiver_amount, receiver_p2pk.clone()); - let output1 = TransactionOutput::new(solver_amount, solver_p2pk.clone()); - - let (tx, entries) = build_ccr_tx( - &spk, - input_value, - attacker_fee_value, - &solver_p2pk, - vec![output0, output1], - 0, - ); - let mut mutable_tx = MutableTransaction::with_entries(tx, entries); - - let sig_script = ScriptBuilder::new() - .add_data(&secret) - .unwrap() - .add_op(OpTrue) - .unwrap() - .add_data(&htlc_script) - .unwrap() - .drain(); - mutable_tx.tx.inputs[0].signature_script = sig_script; - - let tx = mutable_tx.as_verifiable(); - let sig_cache = Cache::new(10_000); - let reused_values = SigHashReusedValuesUnsync::new(); - let utxo_entry = tx.utxo(0).unwrap().clone(); - - let mut vm = TxScriptEngine::from_transaction_input( - &tx, - &tx.inputs()[0], - 0, - &utxo_entry, - &reused_values, - &sig_cache, - ); - vm.execute().expect_err( - "Should fail: second input's high value cannot inflate the receiver amount check", - ); - } - - #[test] - fn test_ccr_second_input_high_value_correct_receiver_succeeds() { - let secp = Secp256k1::new(); - let mut rng = rand::rng(); - - let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let solver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - - let secret: [u8; 32] = rand::random(); - let secret_hash: [u8; 32] = Sha256::digest(&secret).into(); - let timelock = 1_700_000_000u64 + 7200; - - let receiver_p2pk = p2pk_spk(&receiver); - let receiver_spk_vec = spk_to_vec(&receiver_p2pk); - - let htlc_script = create_htlc_script( - &sender.x_only_public_key().0.serialize(), - &vec![], - &receiver_spk_vec, - &secret_hash, - timelock, - 0, - [0u8; 32], - ) - .expect("Script creation"); - - let spk = pay_to_script_hash_script(&htlc_script); - let input_value = 1_000_000_000u64; - let large_fee_value = 50_000_000_000u64; - let solver_p2pk = p2pk_spk(&solver); - - let receiver_amount = input_value - SOLVER_REWARD as u64; - let solver_amount = SOLVER_REWARD as u64 + large_fee_value; - - let output0 = TransactionOutput::new(receiver_amount, receiver_p2pk.clone()); - let output1 = TransactionOutput::new(solver_amount, solver_p2pk.clone()); - - let (tx, entries) = build_ccr_tx( - &spk, - input_value, - large_fee_value, - &solver_p2pk, - vec![output0, output1], - 0, - ); - let mut mutable_tx = MutableTransaction::with_entries(tx, entries); - - let sig_script = ScriptBuilder::new() - .add_data(&secret) - .unwrap() - .add_op(OpTrue) - .unwrap() - .add_data(&htlc_script) - .unwrap() - .drain(); - mutable_tx.tx.inputs[0].signature_script = sig_script; - - let tx = mutable_tx.as_verifiable(); - let sig_cache = Cache::new(10_000); - let reused_values = SigHashReusedValuesUnsync::new(); - let utxo_entry = tx.utxo(0).unwrap().clone(); - - let mut vm = TxScriptEngine::from_transaction_input( - &tx, - &tx.inputs()[0], - 0, - &utxo_entry, - &reused_values, - &sig_cache, - ); - vm.execute() - .expect("Should succeed: receiver correctly paid, solver uses own funds"); - } - - #[test] - fn test_ccr_second_input_cannot_redirect_receiver_funds() { - let secp = Secp256k1::new(); - let mut rng = rand::rng(); - - let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let solver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - - let secret: [u8; 32] = rand::random(); - let secret_hash: [u8; 32] = Sha256::digest(&secret).into(); - let timelock = 1_700_000_000u64 + 7200; - - let receiver_p2pk = p2pk_spk(&receiver); - let receiver_spk_vec = spk_to_vec(&receiver_p2pk); - - let htlc_script = create_htlc_script( - &sender.x_only_public_key().0.serialize(), - &vec![], - &receiver_spk_vec, - &secret_hash, - timelock, - 0, - [0u8; 32], - ) - .expect("Script creation"); - - let spk = pay_to_script_hash_script(&htlc_script); - let input_value = 1_000_000_000u64; - let fee_value = 100_000u64; - let solver_p2pk = p2pk_spk(&solver); - - let receiver_amount = input_value / 2; - let solver_amount = input_value / 2 + SOLVER_REWARD as u64 + fee_value; - - let output0 = TransactionOutput::new(receiver_amount, receiver_p2pk.clone()); - let output1 = TransactionOutput::new(solver_amount, solver_p2pk.clone()); - - let (tx, entries) = build_ccr_tx( - &spk, - input_value, - fee_value, - &solver_p2pk, - vec![output0, output1], - 0, - ); - let mut mutable_tx = MutableTransaction::with_entries(tx, entries); - - let sig_script = ScriptBuilder::new() - .add_data(&secret) - .unwrap() - .add_op(OpTrue) - .unwrap() - .add_data(&htlc_script) - .unwrap() - .drain(); - mutable_tx.tx.inputs[0].signature_script = sig_script; - - let tx = mutable_tx.as_verifiable(); - let sig_cache = Cache::new(10_000); - let reused_values = SigHashReusedValuesUnsync::new(); - let utxo_entry = tx.utxo(0).unwrap().clone(); - - let mut vm = TxScriptEngine::from_transaction_input( - &tx, - &tx.inputs()[0], - 0, - &utxo_entry, - &reused_values, - &sig_cache, - ); - vm.execute() - .expect_err("Should fail: receiver gets less than minimum"); - } - - #[test] - fn test_ccr_second_input_zero_value() { - let secp = Secp256k1::new(); - let mut rng = rand::rng(); - - let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let solver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - - let secret: [u8; 32] = rand::random(); - let secret_hash: [u8; 32] = Sha256::digest(&secret).into(); - let timelock = 1_700_000_000u64 + 7200; - - let receiver_p2pk = p2pk_spk(&receiver); - let receiver_spk_vec = spk_to_vec(&receiver_p2pk); - - let htlc_script = create_htlc_script( - &sender.x_only_public_key().0.serialize(), - &vec![], - &receiver_spk_vec, - &secret_hash, - timelock, - 0, - [0u8; 32], - ) - .expect("Script creation"); - - let spk = pay_to_script_hash_script(&htlc_script); - let input_value = 1_000_000_000u64; - let solver_p2pk = p2pk_spk(&solver); - - let receiver_amount = input_value - SOLVER_REWARD as u64; - let solver_amount = SOLVER_REWARD as u64; - - let output0 = TransactionOutput::new(receiver_amount, receiver_p2pk.clone()); - let output1 = TransactionOutput::new(solver_amount, solver_p2pk.clone()); - - let (tx, entries) = build_ccr_tx( - &spk, - input_value, - 0, - &solver_p2pk, - vec![output0, output1], - 0, - ); - let mut mutable_tx = MutableTransaction::with_entries(tx, entries); - - let sig_script = ScriptBuilder::new() - .add_data(&secret) - .unwrap() - .add_op(OpTrue) - .unwrap() - .add_data(&htlc_script) - .unwrap() - .drain(); - mutable_tx.tx.inputs[0].signature_script = sig_script; - - let tx = mutable_tx.as_verifiable(); - let sig_cache = Cache::new(10_000); - let reused_values = SigHashReusedValuesUnsync::new(); - let utxo_entry = tx.utxo(0).unwrap().clone(); - - let mut vm = TxScriptEngine::from_transaction_input( - &tx, - &tx.inputs()[0], - 0, - &utxo_entry, - &reused_values, - &sig_cache, - ); - vm.execute() - .expect("Should succeed with zero-value fee input"); - } - - #[test] - fn test_ccr_fails_with_one_output() { - let secp = Secp256k1::new(); - let mut rng = rand::rng(); - - let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let solver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - - let secret: [u8; 32] = rand::random(); - let secret_hash: [u8; 32] = Sha256::digest(&secret).into(); - let timelock = 1_700_000_000u64 + 7200; - - let receiver_p2pk = p2pk_spk(&receiver); - let receiver_spk_vec = spk_to_vec(&receiver_p2pk); - - let htlc_script = create_htlc_script( - &sender.x_only_public_key().0.serialize(), - &vec![], - &receiver_spk_vec, - &secret_hash, - timelock, - 0, - [0u8; 32], - ) - .expect("Script creation"); - - let spk = pay_to_script_hash_script(&htlc_script); - let input_value = 1_000_000_000u64; - let fee_value = 100_000u64; - let solver_p2pk = p2pk_spk(&solver); - - let output0 = TransactionOutput::new(input_value + fee_value, receiver_p2pk.clone()); - - let (tx, entries) = - build_ccr_tx(&spk, input_value, fee_value, &solver_p2pk, vec![output0], 0); - let mut mutable_tx = MutableTransaction::with_entries(tx, entries); - - let sig_script = ScriptBuilder::new() - .add_data(&secret) - .unwrap() - .add_op(OpTrue) - .unwrap() - .add_data(&htlc_script) - .unwrap() - .drain(); - mutable_tx.tx.inputs[0].signature_script = sig_script; - - let tx = mutable_tx.as_verifiable(); - let sig_cache = Cache::new(10_000); - let reused_values = SigHashReusedValuesUnsync::new(); - let utxo_entry = tx.utxo(0).unwrap().clone(); - - let mut vm = TxScriptEngine::from_transaction_input( - &tx, - &tx.inputs()[0], - 0, - &utxo_entry, - &reused_values, - &sig_cache, - ); - vm.execute().expect_err("Should fail with only 1 output"); - } - - #[test] - fn test_ccr_fails_with_three_outputs() { - let secp = Secp256k1::new(); - let mut rng = rand::rng(); - - let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let solver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let attacker = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - - let secret: [u8; 32] = rand::random(); - let secret_hash: [u8; 32] = Sha256::digest(&secret).into(); - let timelock = 1_700_000_000u64 + 7200; - - let receiver_p2pk = p2pk_spk(&receiver); - let receiver_spk_vec = spk_to_vec(&receiver_p2pk); - - let htlc_script = create_htlc_script( - &sender.x_only_public_key().0.serialize(), - &vec![], - &receiver_spk_vec, - &secret_hash, - timelock, - 0, - [0u8; 32], - ) - .expect("Script creation"); - - let spk = pay_to_script_hash_script(&htlc_script); - let input_value = 1_000_000_000u64; - let fee_value = 100_000u64; - let receiver_amount = input_value - SOLVER_REWARD as u64; - let solver_p2pk = p2pk_spk(&solver); - - let output0 = TransactionOutput::new(receiver_amount, receiver_p2pk.clone()); - let output1 = - TransactionOutput::new(SOLVER_REWARD as u64 / 2 + fee_value, solver_p2pk.clone()); - let output2 = TransactionOutput::new(SOLVER_REWARD as u64 / 2, p2pk_spk(&attacker)); - - let (tx, entries) = build_ccr_tx( - &spk, - input_value, - fee_value, - &solver_p2pk, - vec![output0, output1, output2], - 0, - ); - let mut mutable_tx = MutableTransaction::with_entries(tx, entries); - - let sig_script = ScriptBuilder::new() - .add_data(&secret) - .unwrap() - .add_op(OpTrue) - .unwrap() - .add_data(&htlc_script) - .unwrap() - .drain(); - mutable_tx.tx.inputs[0].signature_script = sig_script; - - let tx = mutable_tx.as_verifiable(); - let sig_cache = Cache::new(10_000); - let reused_values = SigHashReusedValuesUnsync::new(); - let utxo_entry = tx.utxo(0).unwrap().clone(); - - let mut vm = TxScriptEngine::from_transaction_input( - &tx, - &tx.inputs()[0], - 0, - &utxo_entry, - &reused_values, - &sig_cache, - ); - vm.execute().expect_err("Should fail with 3 outputs"); - } - - #[test] - fn test_ccr_fails_with_zero_outputs() { - let secp = Secp256k1::new(); - let mut rng = rand::rng(); - - let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let solver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - - let secret: [u8; 32] = rand::random(); - let secret_hash: [u8; 32] = Sha256::digest(&secret).into(); - let timelock = 1_700_000_000u64 + 7200; - - let receiver_spk_vec = spk_to_vec(&p2pk_spk(&receiver)); - let solver_p2pk = p2pk_spk(&solver); - - let htlc_script = create_htlc_script( - &sender.x_only_public_key().0.serialize(), - &vec![], - &receiver_spk_vec, - &secret_hash, - timelock, - 0, - [0u8; 32], - ) - .expect("Script creation"); - - let spk = pay_to_script_hash_script(&htlc_script); - let input_value = 1_000_000_000u64; - - let (tx, entries) = build_ccr_tx(&spk, input_value, 100_000, &solver_p2pk, vec![], 0); - let mut mutable_tx = MutableTransaction::with_entries(tx, entries); - - let sig_script = ScriptBuilder::new() - .add_data(&secret) - .unwrap() - .add_op(OpTrue) - .unwrap() - .add_data(&htlc_script) - .unwrap() - .drain(); - mutable_tx.tx.inputs[0].signature_script = sig_script; - - let tx = mutable_tx.as_verifiable(); - let sig_cache = Cache::new(10_000); - let reused_values = SigHashReusedValuesUnsync::new(); - let utxo_entry = tx.utxo(0).unwrap().clone(); - - let mut vm = TxScriptEngine::from_transaction_input( - &tx, - &tx.inputs()[0], - 0, - &utxo_entry, - &reused_values, - &sig_cache, - ); - vm.execute().expect_err("Should fail with zero outputs"); - } - - #[test] - fn test_ccr_fails_with_missing_secret() { - let secp = Secp256k1::new(); - let mut rng = rand::rng(); - - let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let solver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - - let secret: [u8; 32] = rand::random(); - let secret_hash: [u8; 32] = Sha256::digest(&secret).into(); - let timelock = 1_700_000_000u64 + 7200; - - let receiver_p2pk = p2pk_spk(&receiver); - let receiver_spk_vec = spk_to_vec(&receiver_p2pk); - - let htlc_script = create_htlc_script( - &sender.x_only_public_key().0.serialize(), - &vec![], - &receiver_spk_vec, - &secret_hash, - timelock, - 0, - [0u8; 32], - ) - .expect("Script creation"); - - let spk = pay_to_script_hash_script(&htlc_script); - let input_value = 1_000_000_000u64; - let fee_value = 100_000u64; - let receiver_amount = input_value - SOLVER_REWARD as u64; - let solver_p2pk = p2pk_spk(&solver); - - let output0 = TransactionOutput::new(receiver_amount, receiver_p2pk.clone()); - let output1 = TransactionOutput::new(SOLVER_REWARD as u64 + fee_value, solver_p2pk.clone()); - - let (tx, entries) = build_ccr_tx( - &spk, - input_value, - fee_value, - &solver_p2pk, - vec![output0, output1], - 0, - ); - let mut mutable_tx = MutableTransaction::with_entries(tx, entries); - - let sig_script = ScriptBuilder::new() - .add_op(OpTrue) - .unwrap() - .add_data(&htlc_script) - .unwrap() - .drain(); - mutable_tx.tx.inputs[0].signature_script = sig_script; - - let tx = mutable_tx.as_verifiable(); - let sig_cache = Cache::new(10_000); - let reused_values = SigHashReusedValuesUnsync::new(); - let utxo_entry = tx.utxo(0).unwrap().clone(); - - let mut vm = TxScriptEngine::from_transaction_input( - &tx, - &tx.inputs()[0], - 0, - &utxo_entry, - &reused_values, - &sig_cache, - ); - vm.execute().expect_err("Should fail with missing secret"); - } - - #[test] - fn test_refund_fails_with_excessive_fee() { - let secp = Secp256k1::new(); - let mut rng = rand::rng(); - - let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - - let secret: [u8; 32] = rand::random(); - let secret_hash: [u8; 32] = Sha256::digest(&secret).into(); - let timelock = 1_700_000_000u64 + 7200; - - let receiver_spk_vec = spk_to_vec(&p2pk_spk(&receiver)); - let sender_p2pk = p2pk_spk(&sender); - let sender_spk_vec = spk_to_vec(&sender_p2pk); - - let htlc_script = create_htlc_script( - &sender_spk_vec, - &vec![], - &receiver_spk_vec, - &secret_hash, - timelock, - 0, - [0u8; 32], - ) - .expect("Script creation"); - - let spk = pay_to_script_hash_script(&htlc_script); - let input_value = 1_000_000_000u64; - - let stolen_amount = input_value - 10_000; - let output = TransactionOutput::new(stolen_amount, sender_p2pk.clone()); - - let (tx, entries) = build_refund_tx(&spk, input_value, vec![output], timelock); - let mut mutable_tx = MutableTransaction::with_entries(tx, entries); - - let sig_script = ScriptBuilder::new() - .add_op(OpFalse) - .unwrap() - .add_data(&htlc_script) - .unwrap() - .drain(); - mutable_tx.tx.inputs[0].signature_script = sig_script; - - let tx = mutable_tx.as_verifiable(); - let sig_cache = Cache::new(10_000); - let reused_values = SigHashReusedValuesUnsync::new(); - let utxo_entry = tx.utxo(0).unwrap().clone(); - - let mut vm = TxScriptEngine::from_transaction_input( - &tx, - &tx.inputs()[0], - 0, - &utxo_entry, - &reused_values, - &sig_cache, - ); - vm.execute() - .expect_err("Should fail when fee exceeds allowance"); - } - - #[test] - fn test_fails_with_only_redeem_script() { - let secp = Secp256k1::new(); - let mut rng = rand::rng(); - - let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - - let secret: [u8; 32] = rand::random(); - let secret_hash: [u8; 32] = Sha256::digest(&secret).into(); - let timelock = 1_700_000_000u64 + 7200; - - let receiver_spk_vec = spk_to_vec(&p2pk_spk(&receiver)); - - let htlc_script = create_htlc_script( - &sender.x_only_public_key().0.serialize(), - &vec![], - &receiver_spk_vec, - &secret_hash, - timelock, - 0, - [0u8; 32], - ) - .expect("Script creation"); - - let spk = pay_to_script_hash_script(&htlc_script); - let input_value = 1_000_000_000u64; - let output = TransactionOutput::new(input_value, spk.clone()); - let utxo_entry = UtxoEntry::new(input_value, spk.clone(), 0, false); - - let input = TransactionInput { - previous_outpoint: TransactionOutpoint::new(Hash::from_u64_word(1), 0), - signature_script: vec![], - sequence: 0, - sig_op_count: 0, - }; - - let tx = Transaction::new( - 1, - vec![input], - vec![output], - 0, - SUBNETWORK_ID_NATIVE, - 0, - vec![], - ); - let mut mutable_tx = MutableTransaction::with_entries(tx, vec![utxo_entry.clone()]); - - let sig_script = ScriptBuilder::new().add_data(&htlc_script).unwrap().drain(); - mutable_tx.tx.inputs[0].signature_script = sig_script; - - let tx = mutable_tx.as_verifiable(); - let sig_cache = Cache::new(10_000); - let reused_values = SigHashReusedValuesUnsync::new(); - - let mut vm = TxScriptEngine::from_transaction_input( - &tx, - &tx.inputs()[0], - 0, - &utxo_entry, - &reused_values, - &sig_cache, - ); - vm.execute().expect_err("Should fail with no arguments"); - } - - #[test] - fn test_script_size() { - let secp = Secp256k1::new(); - let mut rng = rand::rng(); - - let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - - let secret_hash: [u8; 32] = Sha256::digest(&rand::random::<[u8; 32]>()).into(); - let timelock = 1_700_000_000u64; - - let receiver_spk_vec = spk_to_vec(&p2pk_spk(&receiver)); - - let script = create_htlc_script( - &sender.x_only_public_key().0.serialize(), - &vec![], - &receiver_spk_vec, - &secret_hash, - timelock, - 0, - [0u8; 32], - ) - .expect("Script creation"); - - assert!(script.len() < 250, "Script should be under 250 bytes"); - } -} diff --git a/crates/data/src/chains/kaspa/contracts/tests/validation.rs b/crates/data/src/chains/kaspa/contracts/tests/validation.rs deleted file mode 100644 index a5b34fa..0000000 --- a/crates/data/src/chains/kaspa/contracts/tests/validation.rs +++ /dev/null @@ -1,2005 +0,0 @@ -#[cfg(test)] -mod tests { - use crate::chains::kaspa::broadcast::spk_to_vec; - use crate::chains::kaspa::contracts::contract_v1::{ - DataType, EXPECTED_OPCODES, ExpectedOpCode, SOLVER_REWARD, VerifiableTransactionMock, - create_htlc_script, extract_commitment, - }; - use crate::chains::kaspa::contracts::contract_v1::{ - extract_reveal_secret, validate_refund_sig, - }; - use crate::chains::kaspa::contracts::script::decode_u64_from_script; - use crate::chains::kaspa::error::KaspaError; - use crate::chains::kaspa::test_helpers::{p2pk_spk, vec_to_spk}; - use kaspa_addresses::Prefix; - use kaspa_consensus_core::hashing::sighash::SigHashReusedValuesUnsync; - use kaspa_txscript::extract_script_pub_key_address; - use kaspa_txscript::opcodes::codes::OpTrue; - use kaspa_txscript::{ - opcodes::{ - OpCodeImplementation, - codes::{ - OpCheckLockTimeVerify, OpCheckSig, OpElse, OpEndIf, OpEqualVerify, OpFalse, - OpGreaterThanOrEqual, OpIf, OpNumEqualVerify, OpReturn, OpSHA256, OpSub, - OpTxInputAmount, OpTxInputCount, OpTxInputIndex, OpTxOutputAmount, OpTxOutputCount, - OpTxOutputSpk, - }, - }, - script_builder::ScriptBuilder, - }; - use rand::Rng; - use secp256k1::{Keypair, Secp256k1}; - use sha2::{Digest, Sha256}; - use stroemnet_protocol::ChannelId; - - const DEFAULT_TIMELOCK_MS: u64 = (1_700_000_000 + 7200) * 1000; - const DEFAULT_DESTINATION: u8 = 0; - const DEFAULT_AMOUNT: &str = "1000000000"; - struct TestFixture { - sender: Keypair, - receiver: Keypair, - secret: [u8; 32], - secret_hash: [u8; 32], - swap_id: [u8; 32], - sender_receiver_address: Vec, - destination: u8, - timelock: u64, - } - - impl TestFixture { - fn new() -> Self { - let secp = Secp256k1::new(); - let mut rng = rand::rng(); - let sender = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let receiver = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let secret: [u8; 32] = rand::random(); - let secret_hash: [u8; 32] = Sha256::digest(&secret).into(); - let swap_id: [u8; 32] = rand::random(); - let sender_receiver_address = b"sender_recv_addr_placeholder".to_vec(); - - Self { - sender, - receiver, - secret, - secret_hash, - swap_id, - sender_receiver_address, - destination: DEFAULT_DESTINATION, - timelock: DEFAULT_TIMELOCK_MS, - } - } - - fn sender_pub(&self) -> [u8; 32] { - self.sender.x_only_public_key().0.serialize() - } - - fn sender_spk(&self) -> kaspa_consensus_core::tx::ScriptPublicKey { - p2pk_spk(&self.sender) - } - - fn sender_spk_vec(&self) -> Vec { - spk_to_vec(&self.sender_spk()) - } - - fn receiver_spk_vec(&self) -> Vec { - spk_to_vec(&p2pk_spk(&self.receiver)) - } - - fn build_valid_script(&self) -> Vec { - create_htlc_script( - &self.sender_spk_vec(), - &self.sender_receiver_address, - &self.receiver_spk_vec(), - &self.secret_hash, - self.timelock, - self.destination, - self.swap_id, - ) - .expect("Script creation") - } - - fn build_script_with_mutation_at( - &self, - position: usize, - mutate: impl Fn(&mut ScriptBuilder), - ) -> Vec { - let mut builder = ScriptBuilder::new(); - let receiver_spk = self.receiver_spk_vec(); - let sender_spk = self.sender_spk_vec(); - - for i in 0..EXPECTED_OPCODES.len() { - if i == position { - mutate(&mut builder); - continue; - } - match i { - 0 => { - builder.add_op(OpIf).unwrap(); - } - 1 => { - builder.add_op(OpSHA256).unwrap(); - } - 2 => { - builder.add_data(&self.secret_hash).unwrap(); - } - 3 => { - builder.add_op(OpEqualVerify).unwrap(); - } - 4 => { - builder.add_op(OpTxInputCount).unwrap(); - } - 5 => { - builder.add_i64(2).unwrap(); - } - 6 => { - builder.add_op(OpNumEqualVerify).unwrap(); - } - 7 => { - builder.add_op(OpTxOutputCount).unwrap(); - } - 8 => { - builder.add_i64(2).unwrap(); - } - 9 => { - builder.add_op(OpNumEqualVerify).unwrap(); - } - 10 => { - builder.add_data(&receiver_spk).unwrap(); - } - 11 => { - builder.add_i64(0).unwrap(); - } - 12 => { - builder.add_op(OpTxOutputSpk).unwrap(); - } - 13 => { - builder.add_op(OpEqualVerify).unwrap(); - } - 14 => { - builder.add_i64(0).unwrap(); - } - 15 => { - builder.add_op(OpTxOutputAmount).unwrap(); - } - 16 => { - builder.add_op(OpTxInputIndex).unwrap(); - } - 17 => { - builder.add_op(OpTxInputAmount).unwrap(); - } - 18 => { - builder.add_i64(SOLVER_REWARD).unwrap(); - } - 19 => { - builder.add_op(OpSub).unwrap(); - } - 20 => { - builder.add_op(OpGreaterThanOrEqual).unwrap(); - } - - 21 => { - builder.add_op(OpElse).unwrap(); - } - 22 => { - builder.add_i64(self.timelock as i64).unwrap(); - } - 23 => { - builder.add_op(OpCheckLockTimeVerify).unwrap(); - } - 24 => { - builder.add_op(OpTxInputCount).unwrap(); - } - 25 => { - builder.add_i64(2).unwrap(); - } - 26 => { - builder.add_op(OpNumEqualVerify).unwrap(); - } - 27 => { - builder.add_op(OpTxOutputCount).unwrap(); - } - 28 => { - builder.add_i64(2).unwrap(); - } - 29 => { - builder.add_op(OpNumEqualVerify).unwrap(); - } - 30 => { - builder.add_data(&sender_spk).unwrap(); - } - 31 => { - builder.add_i64(0).unwrap(); - } - 32 => { - builder.add_op(OpTxOutputSpk).unwrap(); - } - 33 => { - builder.add_op(OpEqualVerify).unwrap(); - } - 34 => { - builder.add_i64(0).unwrap(); - } - 35 => { - builder.add_op(OpTxOutputAmount).unwrap(); - } - 36 => { - builder.add_op(OpTxInputIndex).unwrap(); - } - 37 => { - builder.add_op(OpTxInputAmount).unwrap(); - } - 38 => { - builder.add_i64(SOLVER_REWARD).unwrap(); - } - 39 => { - builder.add_op(OpSub).unwrap(); - } - 40 => { - builder.add_op(OpGreaterThanOrEqual).unwrap(); - } - - 41 => { - builder.add_op(OpEndIf).unwrap(); - } - 42 => { - builder.add_op(OpFalse).unwrap(); - } - 43 => { - builder.add_op(OpIf).unwrap(); - } - 44 => { - builder.add_data(&self.swap_id).unwrap(); - } - 45 => { - builder.add_data(&self.sender_receiver_address).unwrap(); - } - 46 => { - builder.add_data(&[self.destination]).unwrap(); - } - 47 => { - builder.add_op(OpEndIf).unwrap(); - } - _ => unreachable!(), - } - } - builder.drain() - } - - fn extract(&self, raw: &[u8]) -> Result { - let parsed = - crate::chains::kaspa::decode::parse_script(raw).collect::, _>>()?; - extract_commitment( - &parsed, - DEFAULT_AMOUNT.to_string(), - Prefix::Devnet, - ChannelId::KaspaTn10, - ) - } - } - - #[test] - fn test_extract_refund_fails_empty() { - let parsed: Vec< - Box>, - > = vec![]; - assert!(matches!( - validate_refund_sig(&parsed).unwrap_err(), - KaspaError::InvalidSigScriptLength { - expected: 2, - got: 0 - } - )); - } - - #[test] - fn test_extract_refund_fails_one_opcode() { - let f = TestFixture::new(); - let htlc_script = f.build_valid_script(); - - let sig_script = ScriptBuilder::new().add_data(&htlc_script).unwrap().drain(); - let parsed = crate::chains::kaspa::decode::parse_script(&sig_script) - .collect::, _>>() - .unwrap(); - assert!(matches!( - validate_refund_sig(&parsed).unwrap_err(), - KaspaError::InvalidSigScriptLength { - expected: 2, - got: 1 - } - )); - } - #[test] - fn test_swap_id_too_short_rejected() { - let f = TestFixture::new(); - let short_id = [0u8; 16]; - let raw = f.build_script_with_mutation_at(44, |b| { - b.add_data(&short_id).unwrap(); - }); - let res = f.extract(&raw); - assert!(res.is_err(), "16-byte swap_id should be rejected"); - match res.unwrap_err() { - KaspaError::InvalidSwapIdLength => {} - other => panic!("Expected InvalidSwapIdLength, got {other:?}"), - } - } - - #[test] - fn test_swap_id_too_long_rejected() { - let f = TestFixture::new(); - let long_id = [0u8; 64]; - let raw = f.build_script_with_mutation_at(44, |b| { - b.add_data(&long_id).unwrap(); - }); - let res = f.extract(&raw); - assert!(res.is_err(), "64-byte swap_id should be rejected"); - match res.unwrap_err() { - KaspaError::InvalidSwapIdLength => {} - other => panic!("Expected InvalidSwapIdLength, got {other:?}"), - } - } - - #[test] - fn test_swap_id_different_value_still_parses() { - let f = TestFixture::new(); - let different_id: [u8; 32] = rand::random(); - let raw = f.build_script_with_mutation_at(44, |b| { - b.add_data(&different_id).unwrap(); - }); - let c = f.extract(&raw).unwrap(); - assert_eq!(c.swap_id, different_id); - } - - #[test] - fn test_sender_receiver_address_different_value_extracts() { - let f = TestFixture::new(); - let other_addr = "completely_different_address".to_string(); - - let raw = f.build_script_with_mutation_at(45, |b| { - b.add_data(other_addr.as_bytes()).unwrap(); - }); - let c = f.extract(&raw).unwrap(); - assert_eq!(c.addresses.sender_destination, other_addr); - } - - #[test] - fn test_swap_id_empty_rejected() { - let f = TestFixture::new(); - let raw = f.build_script_with_mutation_at(44, |b| { - b.add_data(&[]).unwrap(); - }); - let res = f.extract(&raw); - assert!(res.is_err(), "Empty swap_id should be rejected"); - } - - #[test] - fn test_refund_sub_replaced() { - let f = TestFixture::new(); - let raw = f.build_script_with_mutation_at(39, |b| { - b.add_op(OpCheckLockTimeVerify).unwrap(); - }); - assert!(f.extract(&raw).is_err()); - } - - #[test] - fn test_refund_gte_replaced() { - let f = TestFixture::new(); - let raw = f.build_script_with_mutation_at(40, |b| { - b.add_op(OpCheckLockTimeVerify).unwrap(); - }); - assert!(f.extract(&raw).is_err()); - } - - #[test] - fn test_destination_mutated_extracts() { - let f = TestFixture::new(); - let raw = f.build_script_with_mutation_at(46, |b| { - b.add_data(&[42u8]).unwrap(); - }); - let c = f.extract(&raw).unwrap(); - assert_eq!(c.destination, 42); - } - - #[test] - fn test_destination_empty_vec_returns_missing_data() { - let f = TestFixture::new(); - let raw = f.build_script_with_mutation_at(46, |b| { - b.add_op(OpEqualVerify).unwrap(); - }); - let res = f.extract(&raw); - match res { - Err(KaspaError::MissingData(DataType::Destination)) => {} - Err(other) => { - assert!( - matches!(other, KaspaError::MissingData(_)), - "Expected MissingData, got {other:?}" - ); - } - Ok(_) => panic!("Should fail with non-push opcode in destination slot"), - } - } - #[test] - fn test_extract_refund_fails_four_opcodes() { - let f = TestFixture::new(); - let htlc_script = f.build_valid_script(); - let junk: [u8; 32] = rand::random(); - let sig_script = ScriptBuilder::new() - .add_data(&junk) - .unwrap() - .add_data(&junk) - .unwrap() - .add_op(OpFalse) - .unwrap() - .add_data(&htlc_script) - .unwrap() - .drain(); - let parsed = crate::chains::kaspa::decode::parse_script(&sig_script) - .collect::, _>>() - .unwrap(); - assert!(matches!( - validate_refund_sig(&parsed).unwrap_err(), - KaspaError::InvalidSigScriptLength { - expected: 2, - got: 4 - } - )); - } - - #[test] - fn test_extract_refund_fails_non_push_redeem() { - let sig_script = ScriptBuilder::new() - .add_op(OpFalse) - .unwrap() - .add_op(OpElse) - .unwrap() - .drain(); - let parsed = crate::chains::kaspa::decode::parse_script(&sig_script) - .collect::, _>>() - .unwrap(); - assert!(matches!( - validate_refund_sig(&parsed).unwrap_err(), - KaspaError::MissingRedeemScript - )); - } - - #[test] - fn test_extract_refund_fails_with_op_true_selector() { - let f = TestFixture::new(); - let htlc_script = f.build_valid_script(); - - let sig_script = ScriptBuilder::new() - .add_op(OpTrue) - .unwrap() - .add_data(&htlc_script) - .unwrap() - .drain(); - let parsed = crate::chains::kaspa::decode::parse_script(&sig_script) - .collect::, _>>() - .unwrap(); - match validate_refund_sig(&parsed).unwrap_err() { - KaspaError::WrongBranchSelector { expected, got } => { - assert_eq!(expected, OpFalse); - assert_eq!(got, OpTrue); - } - other => panic!("Expected WrongBranchSelector, got {other:?}"), - } - } - - #[test] - fn test_op_checklocktimeverify_replaced() { - let f = TestFixture::new(); - let raw = f.build_script_with_mutation_at(23, |b| { - b.add_op(OpSub).unwrap(); - }); - assert!(f.extract(&raw).is_err()); - } - - #[test] - fn test_metadata_envelope_missing_inner_op_if() { - let f = TestFixture::new(); - let raw = f.build_script_with_mutation_at(41, |b| { - b.add_op(OpElse).unwrap(); - }); - let res = f.extract(&raw); - assert!(res.is_err()); - } - - #[test] - fn test_op_if_at_41_replaced() { - let f = TestFixture::new(); - let raw = f.build_script_with_mutation_at(41, |b| { - b.add_op(OpSub).unwrap(); - }); - assert!(f.extract(&raw).is_err()); - } - - #[test] - fn test_sender_pubkey_different_value_extracts() { - let f = TestFixture::new(); - let secp = Secp256k1::new(); - let mut rng = rand::rng(); - let other = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let other_spk = spk_to_vec(&p2pk_spk(&other)); - - let raw = f.build_script_with_mutation_at(30, |b| { - b.add_data(&other_spk).unwrap(); - }); - let c = f.extract(&raw).unwrap(); - let other_addr = - extract_script_pub_key_address(&vec_to_spk(&other_spk), Prefix::Devnet).unwrap(); - assert_eq!(c.addresses.sender, other_addr.to_string()); - let original_addr = - extract_script_pub_key_address(&f.sender_spk(), Prefix::Devnet).unwrap(); - assert_ne!(c.addresses.sender, original_addr.to_string()); - } - #[test] - fn test_valid_script_extracts_all_fields() { - let f = TestFixture::new(); - let raw = f.build_valid_script(); - let c = f.extract(&raw).expect("Valid script should parse"); - - let f_sender_addr = - extract_script_pub_key_address(&f.sender_spk(), Prefix::Devnet).unwrap(); - let f_receiver_addr = - extract_script_pub_key_address(&vec_to_spk(&f.receiver_spk_vec()), Prefix::Devnet) - .unwrap(); - assert_eq!(c.swap_id, f.swap_id); - assert_eq!(c.secret_hash, f.secret_hash); - assert_eq!(c.addresses.sender, f_sender_addr.to_string()); - assert_eq!(c.addresses.receiver, f_receiver_addr.to_string()); - assert_eq!(c.destination, f.destination); - assert_eq!(c.amount.value, DEFAULT_AMOUNT); - assert_eq!(c.amount.decimals, 8); - assert_eq!( - c.addresses.sender_destination, - String::from_utf8(f.sender_receiver_address.clone()).unwrap() - ); - } - - #[test] - fn test_valid_script_deterministic() { - let f = TestFixture::new(); - let raw1 = f.build_valid_script(); - let raw2 = f.build_valid_script(); - assert_eq!(raw1, raw2); - - let c1 = f.extract(&raw1).unwrap(); - let c2 = f.extract(&raw2).unwrap(); - assert_eq!(c1.swap_id, c2.swap_id); - assert_eq!(c1.secret_hash, c2.secret_hash); - assert_eq!(c1.addresses.sender, c2.addresses.sender); - } - - #[test] - fn test_different_fixtures_produce_different_commitments() { - let f1 = TestFixture::new(); - let f2 = TestFixture::new(); - - let c1 = f1.extract(&f1.build_valid_script()).unwrap(); - let c2 = f2.extract(&f2.build_valid_script()).unwrap(); - - assert_ne!(c1.swap_id, c2.swap_id); - assert_ne!(c1.secret_hash, c2.secret_hash); - assert_ne!(c1.addresses.sender, c2.addresses.sender); - } - - #[test] - fn test_secret_hash_different_value_still_parses() { - let f = TestFixture::new(); - let different_hash: [u8; 32] = rand::random(); - let raw = f.build_script_with_mutation_at(2, |b| { - b.add_data(&different_hash).unwrap(); - }); - let c = f.extract(&raw).unwrap(); - assert_eq!(c.secret_hash, different_hash); - assert_ne!(c.secret_hash, f.secret_hash); - } - - #[test] - fn test_sender_spk_different_value_extracts() { - let f = TestFixture::new(); - let secp = Secp256k1::new(); - let mut rng = rand::rng(); - let other = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let other_spk = spk_to_vec(&p2pk_spk(&other)); - - let raw = f.build_script_with_mutation_at(30, |b| { - b.add_data(&other_spk).unwrap(); - }); - let c = f.extract(&raw).unwrap(); - let other_spk = - extract_script_pub_key_address(&vec_to_spk(&other_spk), Prefix::Devnet).unwrap(); - assert_eq!(c.addresses.sender, other_spk.to_string()); - let original_spk = extract_script_pub_key_address(&f.sender_spk(), Prefix::Devnet).unwrap(); - assert_ne!(c.addresses.sender, original_spk.to_string()); - } - - #[test] - fn test_receiver_spk_different_value_extracts() { - let f = TestFixture::new(); - let secp = Secp256k1::new(); - let mut rng = rand::rng(); - let other = Keypair::from_secret_key(&secp, &secp.generate_keypair(&mut rng).0); - let other_spk = spk_to_vec(&p2pk_spk(&other)); - - let raw = f.build_script_with_mutation_at(10, |b| { - b.add_data(&other_spk).unwrap(); - }); - let other_spk = - extract_script_pub_key_address(&vec_to_spk(&other_spk), Prefix::Devnet).unwrap(); - let c = f.extract(&raw).unwrap(); - assert_eq!(c.addresses.receiver, other_spk.to_string()); - } - - #[test] - fn test_swap_id_31_bytes_rejected() { - let f = TestFixture::new(); - let raw = f.build_script_with_mutation_at(42, |b| { - b.add_data(&[0xAA; 31]).unwrap(); - }); - let res = f.extract(&raw); - assert!(res.is_err()); - } - - #[test] - fn test_swap_id_33_bytes_rejected() { - let f = TestFixture::new(); - let raw = f.build_script_with_mutation_at(42, |b| { - b.add_data(&[0xBB; 33]).unwrap(); - }); - let res = f.extract(&raw); - assert!(res.is_err()); - } - - #[test] - fn test_secret_hash_too_short_rejected() { - let f = TestFixture::new(); - let raw = f.build_script_with_mutation_at(2, |b| { - b.add_data(&[0u8; 16]).unwrap(); - }); - let res = f.extract(&raw); - assert!(res.is_err(), "16-byte secret_hash should be rejected"); - match res.unwrap_err() { - KaspaError::InvalidSecretHashLength => {} - other => panic!("Expected InvalidSecretHashLength, got {other:?}"), - } - } - - #[test] - fn test_secret_hash_too_long_rejected() { - let f = TestFixture::new(); - let raw = f.build_script_with_mutation_at(2, |b| { - b.add_data(&[0u8; 64]).unwrap(); - }); - let res = f.extract(&raw); - assert!(res.is_err(), "64-byte secret_hash should be rejected"); - } - - #[test] - fn test_secret_hash_empty_rejected() { - let f = TestFixture::new(); - let raw = f.build_script_with_mutation_at(2, |b| { - b.add_data(&[]).unwrap(); - }); - let res = f.extract(&raw); - assert!(res.is_err(), "Empty secret_hash should be rejected"); - } - - #[test] - fn test_all_zero_swap_id_accepted() { - let mut f = TestFixture::new(); - f.swap_id = [0u8; 32]; - let c = f.extract(&f.build_valid_script()).unwrap(); - assert_eq!(c.swap_id, [0u8; 32]); - } - - #[test] - fn test_all_ff_swap_id_accepted() { - let mut f = TestFixture::new(); - f.swap_id = [0xFF; 32]; - let c = f.extract(&f.build_valid_script()).unwrap(); - assert_eq!(c.swap_id, [0xFF; 32]); - } - - #[test] - fn test_missing_data_sender_spk() { - let f = TestFixture::new(); - let raw = f.build_script_with_mutation_at(30, |b| { - b.add_op(OpSub).unwrap(); - }); - let res = f.extract(&raw); - assert!(res.is_err(), "Non-push in sender spk slot should fail"); - } - - #[test] - fn test_missing_data_receiver_spk() { - let f = TestFixture::new(); - let raw = f.build_script_with_mutation_at(10, |b| { - b.add_op(OpSub).unwrap(); - }); - let res = f.extract(&raw); - assert!(res.is_err(), "Non-push in receiver spk slot should fail"); - } - - #[test] - fn test_missing_data_timelock() { - let f = TestFixture::new(); - let raw = f.build_script_with_mutation_at(22, |b| { - b.add_op(OpSub).unwrap(); - }); - let res = f.extract(&raw); - assert!(res.is_err(), "Non-push in timelock slot should fail"); - } - - #[test] - fn test_missing_data_sender_receiver_address() { - let f = TestFixture::new(); - let raw = f.build_script_with_mutation_at(43, |b| { - b.add_op(OpSub).unwrap(); - }); - let res = f.extract(&raw); - assert!( - res.is_err(), - "Non-push in sender_receiver_address slot should fail" - ); - } - - #[test] - fn test_extract_opcode_data_fallthrough_non_push_opcode_in_data_slot() { - let f = TestFixture::new(); - let raw = f.build_script_with_mutation_at(2, |b| { - b.add_op(OpSub).unwrap(); - }); - let res = f.extract(&raw); - assert!(res.is_err(), "Non-push opcode in data slot should fail"); - } - - #[test] - fn test_extract_opcode_data_fallthrough_in_swap_id_slot() { - let f = TestFixture::new(); - let raw = f.build_script_with_mutation_at(42, |b| { - b.add_op(OpCheckSig).unwrap(); - }); - let res = f.extract(&raw); - assert!(res.is_err()); - } - - #[test] - fn test_extract_opcode_data_fallthrough_in_destination_slot() { - let f = TestFixture::new(); - let raw = f.build_script_with_mutation_at(44, |b| { - b.add_op(OpSub).unwrap(); - }); - let res = f.extract(&raw); - assert!(res.is_err()); - } - - #[test] - fn test_extract_reveal_success() { - let f = TestFixture::new(); - let htlc_script = f.build_valid_script(); - - let sig_script = ScriptBuilder::new() - .add_data(&f.secret) - .unwrap() - .add_op(OpTrue) - .unwrap() - .add_data(&htlc_script) - .unwrap() - .drain(); - - let parsed = crate::chains::kaspa::decode::parse_script(&sig_script) - .collect::, _>>() - .unwrap(); - let secret = extract_reveal_secret(&parsed).unwrap(); - - assert_eq!(secret, f.secret); - } - - #[test] - fn test_extract_reveal_deterministic() { - let f = TestFixture::new(); - let htlc_script = f.build_valid_script(); - - let sig_script = ScriptBuilder::new() - .add_data(&f.secret) - .unwrap() - .add_op(OpTrue) - .unwrap() - .add_data(&htlc_script) - .unwrap() - .drain(); - - let p1 = crate::chains::kaspa::decode::parse_script(&sig_script) - .collect::, _>>() - .unwrap(); - let p2 = crate::chains::kaspa::decode::parse_script(&sig_script) - .collect::, _>>() - .unwrap(); - let s1 = extract_reveal_secret(&p1).unwrap(); - let s2 = extract_reveal_secret(&p2).unwrap(); - - assert_eq!(s1, s2); - } - - #[test] - fn test_extract_reveal_fails_with_op_false_selector() { - let f = TestFixture::new(); - let htlc_script = f.build_valid_script(); - - let sig_script = ScriptBuilder::new() - .add_data(&f.secret) - .unwrap() - .add_op(OpFalse) - .unwrap() - .add_data(&htlc_script) - .unwrap() - .drain(); - - let parsed = crate::chains::kaspa::decode::parse_script(&sig_script) - .collect::, _>>() - .unwrap(); - let err = extract_reveal_secret(&parsed).unwrap_err(); - match err { - KaspaError::WrongBranchSelector { expected, got } => { - assert_eq!(expected, 0x51); - assert_eq!(got, 0x00); - } - other => panic!("Expected WrongBranchSelector, got {other:?}"), - } - } - - #[test] - fn test_extract_reveal_fails_with_arbitrary_selector() { - let f = TestFixture::new(); - let htlc_script = f.build_valid_script(); - - let sig_script = ScriptBuilder::new() - .add_data(&f.secret) - .unwrap() - .add_op(OpCheckSig) - .unwrap() - .add_data(&htlc_script) - .unwrap() - .drain(); - - let parsed = crate::chains::kaspa::decode::parse_script(&sig_script) - .collect::, _>>() - .unwrap(); - assert!(matches!( - extract_reveal_secret(&parsed).unwrap_err(), - KaspaError::WrongBranchSelector { .. } - )); - } - - #[test] - fn test_extract_reveal_fails_empty() { - let parsed: Vec< - Box< - dyn kaspa_txscript::opcodes::OpCodeImplementation< - crate::chains::kaspa::contracts::contract_v1::VerifiableTransactionMock, - SigHashReusedValuesUnsync, - >, - >, - > = vec![]; - match extract_reveal_secret(&parsed).unwrap_err() { - KaspaError::InvalidSigScriptLength { - expected: 3, - got: 0, - } => {} - other => panic!("Expected InvalidSigScriptLength, got {other:?}"), - } - } - - #[test] - fn test_extract_reveal_fails_two_opcodes() { - let f = TestFixture::new(); - let sig_script = ScriptBuilder::new() - .add_data(&f.secret) - .unwrap() - .add_op(OpTrue) - .unwrap() - .drain(); - let parsed = crate::chains::kaspa::decode::parse_script(&sig_script) - .collect::, _>>() - .unwrap(); - assert!(matches!( - extract_reveal_secret(&parsed).unwrap_err(), - KaspaError::InvalidSigScriptLength { - expected: 3, - got: 2 - } - )); - } - - #[test] - fn test_extract_reveal_fails_four_opcodes() { - let f = TestFixture::new(); - let htlc_script = f.build_valid_script(); - let junk: [u8; 32] = rand::random(); - let sig_script = ScriptBuilder::new() - .add_data(&junk) - .unwrap() - .add_data(&f.secret) - .unwrap() - .add_op(OpTrue) - .unwrap() - .add_data(&htlc_script) - .unwrap() - .drain(); - let parsed = crate::chains::kaspa::decode::parse_script(&sig_script) - .collect::, _>>() - .unwrap(); - assert!(matches!( - extract_reveal_secret(&parsed).unwrap_err(), - KaspaError::InvalidSigScriptLength { - expected: 3, - got: 4 - } - )); - } - - #[test] - fn test_extract_reveal_fails_secret_too_short() { - let f = TestFixture::new(); - let htlc_script = f.build_valid_script(); - let short_secret = [0xAA; 16]; - let sig_script = ScriptBuilder::new() - .add_data(&short_secret) - .unwrap() - .add_op(OpTrue) - .unwrap() - .add_data(&htlc_script) - .unwrap() - .drain(); - let parsed = crate::chains::kaspa::decode::parse_script(&sig_script) - .collect::, _>>() - .unwrap(); - assert!(matches!( - extract_reveal_secret(&parsed).unwrap_err(), - KaspaError::InvalidSecretLength - )); - } - - #[test] - fn test_extract_reveal_fails_secret_too_long() { - let f = TestFixture::new(); - let htlc_script = f.build_valid_script(); - let long_secret = [0xBB; 64]; - let sig_script = ScriptBuilder::new() - .add_data(&long_secret) - .unwrap() - .add_op(OpTrue) - .unwrap() - .add_data(&htlc_script) - .unwrap() - .drain(); - let parsed = crate::chains::kaspa::decode::parse_script(&sig_script) - .collect::, _>>() - .unwrap(); - assert!(matches!( - extract_reveal_secret(&parsed).unwrap_err(), - KaspaError::InvalidSecretLength - )); - } - - #[test] - fn test_extract_reveal_fails_secret_1_byte() { - let f = TestFixture::new(); - let htlc_script = f.build_valid_script(); - let sig_script = ScriptBuilder::new() - .add_data(&[0x42]) - .unwrap() - .add_op(OpTrue) - .unwrap() - .add_data(&htlc_script) - .unwrap() - .drain(); - let parsed = crate::chains::kaspa::decode::parse_script(&sig_script) - .collect::, _>>() - .unwrap(); - assert!(matches!( - extract_reveal_secret(&parsed).unwrap_err(), - KaspaError::InvalidSecretLength - )); - } - - #[test] - fn test_extract_reveal_fails_secret_31_bytes() { - let f = TestFixture::new(); - let htlc_script = f.build_valid_script(); - let sig_script = ScriptBuilder::new() - .add_data(&[0xCC; 31]) - .unwrap() - .add_op(OpTrue) - .unwrap() - .add_data(&htlc_script) - .unwrap() - .drain(); - let parsed = crate::chains::kaspa::decode::parse_script(&sig_script) - .collect::, _>>() - .unwrap(); - assert!(matches!( - extract_reveal_secret(&parsed).unwrap_err(), - KaspaError::InvalidSecretLength - )); - } - - #[test] - fn test_extract_reveal_fails_secret_33_bytes() { - let f = TestFixture::new(); - let htlc_script = f.build_valid_script(); - let sig_script = ScriptBuilder::new() - .add_data(&[0xDD; 33]) - .unwrap() - .add_op(OpTrue) - .unwrap() - .add_data(&htlc_script) - .unwrap() - .drain(); - let parsed = crate::chains::kaspa::decode::parse_script(&sig_script) - .collect::, _>>() - .unwrap(); - assert!(matches!( - extract_reveal_secret(&parsed).unwrap_err(), - KaspaError::InvalidSecretLength - )); - } - - #[test] - fn test_extract_reveal_fails_non_push_secret() { - let f = TestFixture::new(); - let htlc_script = f.build_valid_script(); - let sig_script = ScriptBuilder::new() - .add_op(OpElse) - .unwrap() - .add_op(OpTrue) - .unwrap() - .add_data(&htlc_script) - .unwrap() - .drain(); - let parsed = crate::chains::kaspa::decode::parse_script(&sig_script) - .collect::, _>>() - .unwrap(); - assert!(matches!( - extract_reveal_secret(&parsed).unwrap_err(), - KaspaError::MissingSecret - )); - } - - #[test] - fn test_extract_reveal_fails_non_push_redeem() { - let f = TestFixture::new(); - let sig_script = ScriptBuilder::new() - .add_data(&f.secret) - .unwrap() - .add_op(OpTrue) - .unwrap() - .add_op(OpElse) - .unwrap() - .drain(); - let parsed = crate::chains::kaspa::decode::parse_script(&sig_script) - .collect::, _>>() - .unwrap(); - assert!(matches!( - extract_reveal_secret(&parsed).unwrap_err(), - KaspaError::MissingRedeemScript - )); - } - - fn build_refund_sig_script(f: &TestFixture) -> Vec { - let htlc_script = f.build_valid_script(); - - ScriptBuilder::new() - .add_op(OpFalse) - .unwrap() - .add_data(&htlc_script) - .unwrap() - .drain() - } - - #[test] - fn test_extract_refund_success() { - let f = TestFixture::new(); - let sig_script = build_refund_sig_script(&f); - let parsed = crate::chains::kaspa::decode::parse_script(&sig_script) - .collect::, _>>() - .unwrap(); - validate_refund_sig(&parsed).unwrap(); - } - - #[test] - fn test_extract_refund_deterministic() { - let f = TestFixture::new(); - let sig_script = build_refund_sig_script(&f); - - let p1 = crate::chains::kaspa::decode::parse_script(&sig_script) - .collect::, _>>() - .unwrap(); - let p2 = crate::chains::kaspa::decode::parse_script(&sig_script) - .collect::, _>>() - .unwrap(); - validate_refund_sig(&p1).unwrap(); - validate_refund_sig(&p2).unwrap(); - } - - #[test] - fn test_extract_refund_fails_with_extra_non_push_opcode() { - let f = TestFixture::new(); - let htlc_script = f.build_valid_script(); - let sig_script = ScriptBuilder::new() - .add_op(OpElse) - .unwrap() - .add_op(OpFalse) - .unwrap() - .add_data(&htlc_script) - .unwrap() - .drain(); - let parsed = crate::chains::kaspa::decode::parse_script(&sig_script) - .collect::, _>>() - .unwrap(); - assert!(matches!( - validate_refund_sig(&parsed).unwrap_err(), - KaspaError::InvalidSigScriptLength { - expected: 2, - got: 3 - } - )); - } - - #[test] - fn test_claim_sig_rejected_by_validate_refund_sig() { - let f = TestFixture::new(); - let htlc_script = f.build_valid_script(); - let sig_script = ScriptBuilder::new() - .add_data(&f.secret) - .unwrap() - .add_op(OpTrue) - .unwrap() - .add_data(&htlc_script) - .unwrap() - .drain(); - let parsed = crate::chains::kaspa::decode::parse_script(&sig_script) - .collect::, _>>() - .unwrap(); - assert!(validate_refund_sig(&parsed).is_err()); - } - - #[test] - fn test_refund_sig_rejected_by_extract_reveal_secret() { - let f = TestFixture::new(); - let sig_script = build_refund_sig_script(&f); - let parsed = crate::chains::kaspa::decode::parse_script(&sig_script) - .collect::, _>>() - .unwrap(); - assert!(extract_reveal_secret(&parsed).is_err()); - } - - #[test] - fn test_extract_reveal_random_bytes() { - let raw: Vec = (0..100).map(|_| rand::random::()).collect(); - if let Ok(p) = crate::chains::kaspa::decode::parse_script(&raw) - .collect::, _>>() - { - let _ = extract_reveal_secret(&p); - } - } - - #[test] - fn test_extract_refund_random_bytes() { - let raw: Vec = (0..100).map(|_| rand::random::()).collect(); - if let Ok(p) = crate::chains::kaspa::decode::parse_script(&raw) - .collect::, _>>() - { - let _ = validate_refund_sig(&p); - } - } - - #[test] - fn test_destination_0x51_round_trips() { - let mut f = TestFixture::new(); - f.destination = 0x51; - let raw = f.build_valid_script(); - let c = f.extract(&raw).unwrap(); - assert_eq!(c.destination, 0x51); - } - - #[test] - fn test_extract_reveal_all_zero_secret() { - let f = TestFixture::new(); - let htlc_script = f.build_valid_script(); - let zero_secret = [0u8; 32]; - - let sig_script = ScriptBuilder::new() - .add_data(&zero_secret) - .unwrap() - .add_op(OpTrue) - .unwrap() - .add_data(&htlc_script) - .unwrap() - .drain(); - - let parsed = crate::chains::kaspa::decode::parse_script(&sig_script) - .collect::, _>>() - .unwrap(); - let secret = extract_reveal_secret(&parsed).unwrap(); - assert_eq!(secret, zero_secret); - } - - #[test] - fn test_extract_reveal_all_ff_secret() { - let f = TestFixture::new(); - let htlc_script = f.build_valid_script(); - let ff_secret = [0xFF; 32]; - - let sig_script = ScriptBuilder::new() - .add_data(&ff_secret) - .unwrap() - .add_op(OpTrue) - .unwrap() - .add_data(&htlc_script) - .unwrap() - .drain(); - - let parsed = crate::chains::kaspa::decode::parse_script(&sig_script) - .collect::, _>>() - .unwrap(); - let secret = extract_reveal_secret(&parsed).unwrap(); - assert_eq!(secret, ff_secret); - } - - #[test] - fn test_destination_zero() { - let mut f = TestFixture::new(); - f.destination = 0; - let c = f.extract(&f.build_valid_script()).unwrap(); - assert_eq!(c.destination, 0); - } - - #[test] - fn test_destination_one() { - let mut f = TestFixture::new(); - f.destination = 1; - let c = f.extract(&f.build_valid_script()).unwrap(); - assert_eq!(c.destination, 1); - } - - #[test] - fn test_destination_max() { - let mut f = TestFixture::new(); - f.destination = 255; - let c = f.extract(&f.build_valid_script()).unwrap(); - assert_eq!(c.destination, 255); - } - - #[test] - fn test_timelock_small() { - let mut f = TestFixture::new(); - - f.timelock = 1_000; - let c = f.extract(&f.build_valid_script()).unwrap(); - assert_eq!(c.unlock_ts, 1); - } - - #[test] - fn test_timelock_large() { - let mut f = TestFixture::new(); - - f.timelock = 2_500_000_000_000; - let c = f.extract(&f.build_valid_script()).unwrap(); - assert_eq!(c.unlock_ts, 2_500_000_000); - } - - #[test] - fn test_empty_script_rejected() { - let f = TestFixture::new(); - let res = f.extract(&[]); - assert!(res.is_err()); - } - - #[test] - fn test_single_opcode_rejected() { - let f = TestFixture::new(); - let raw = ScriptBuilder::new().add_op(OpIf).unwrap().drain(); - let res = f.extract(&raw); - assert!(res.is_err()); - } - - #[test] - fn test_script_one_opcode_short_rejected() { - let f = TestFixture::new(); - let mut raw = f.build_valid_script(); - raw.truncate(raw.len().saturating_sub(2)); - let res = f.extract(&raw); - assert!(res.is_err()); - } - - #[test] - fn test_script_with_extra_trailing_opcode_rejected() { - let f = TestFixture::new(); - let mut raw = f.build_valid_script(); - let extra = ScriptBuilder::new().add_op(OpCheckSig).unwrap().drain(); - raw.extend_from_slice(&extra); - let res = f.extract(&raw); - assert!( - res.is_err(), - "Extra trailing opcode should trigger TooManyOpcodes" - ); - } - - #[test] - fn test_every_fixed_opcode_position_rejects_wrong_opcode() { - let f = TestFixture::new(); - - let fixed_positions: Vec = EXPECTED_OPCODES - .iter() - .enumerate() - .filter_map(|(i, (exp, _))| match exp { - ExpectedOpCode::OpCode(_) => Some(i), - ExpectedOpCode::Data => None, - }) - .collect(); - - for &pos in &fixed_positions { - let raw = f.build_script_with_mutation_at(pos, |b| match pos { - 0 | 28 => { - b.add_op(OpElse).unwrap(); - } - 26 | 32 => { - b.add_op(OpSHA256).unwrap(); - } - 27 => { - b.add_op(OpCheckSig).unwrap(); - } - _ => { - b.add_op(OpReturn).unwrap(); - } - }); - - let res = f.extract(&raw); - assert!( - res.is_err(), - "Position {pos}: wrong opcode should be rejected" - ); - - match res.unwrap_err() { - KaspaError::OpcodeMismatch(p) => assert_eq!(p, pos), - other => panic!("Position {pos}: expected OpcodeMismatch, got {other:?}"), - } - } - } - - #[test] - fn test_op_if_replaced_with_op_else() { - let f = TestFixture::new(); - let raw = f.build_script_with_mutation_at(0, |b| { - b.add_op(OpElse).unwrap(); - }); - assert!(f.extract(&raw).is_err()); - } - - #[test] - fn test_op_sha256_replaced_with_op_checksig() { - let f = TestFixture::new(); - let raw = f.build_script_with_mutation_at(1, |b| { - b.add_op(OpCheckSig).unwrap(); - }); - assert!(f.extract(&raw).is_err()); - } - - #[test] - fn test_op_equalverify_at_3_replaced() { - let f = TestFixture::new(); - let raw = f.build_script_with_mutation_at(3, |b| { - b.add_op(OpSub).unwrap(); - }); - assert!(f.extract(&raw).is_err()); - } - - #[test] - fn test_op_txinputcount_replaced() { - let f = TestFixture::new(); - let raw = f.build_script_with_mutation_at(4, |b| { - b.add_op(OpTxOutputCount).unwrap(); - }); - assert!(f.extract(&raw).is_err()); - } - - #[test] - fn test_op_numequalverify_at_6_replaced() { - let f = TestFixture::new(); - let raw = f.build_script_with_mutation_at(6, |b| { - b.add_op(OpEqualVerify).unwrap(); - }); - assert!(f.extract(&raw).is_err()); - } - - #[test] - fn test_op_txoutputcount_replaced() { - let f = TestFixture::new(); - let raw = f.build_script_with_mutation_at(7, |b| { - b.add_op(OpTxInputCount).unwrap(); - }); - assert!(f.extract(&raw).is_err()); - } - - #[test] - fn test_op_txoutputspk_replaced() { - let f = TestFixture::new(); - let raw = f.build_script_with_mutation_at(12, |b| { - b.add_op(OpTxOutputAmount).unwrap(); - }); - assert!(f.extract(&raw).is_err()); - } - - #[test] - fn test_op_txoutputamount_replaced() { - let f = TestFixture::new(); - let raw = f.build_script_with_mutation_at(15, |b| { - b.add_op(OpTxOutputSpk).unwrap(); - }); - assert!(f.extract(&raw).is_err()); - } - - #[test] - fn test_op_txinputindex_replaced() { - let f = TestFixture::new(); - let raw = f.build_script_with_mutation_at(16, |b| { - b.add_op(OpTxInputAmount).unwrap(); - }); - assert!(f.extract(&raw).is_err()); - } - - #[test] - fn test_op_txinputamount_replaced() { - let f = TestFixture::new(); - let raw = f.build_script_with_mutation_at(17, |b| { - b.add_op(OpTxInputIndex).unwrap(); - }); - assert!(f.extract(&raw).is_err()); - } - - #[test] - fn test_op_sub_replaced() { - let f = TestFixture::new(); - let raw = f.build_script_with_mutation_at(19, |b| { - b.add_op(OpEqualVerify).unwrap(); - }); - assert!(f.extract(&raw).is_err()); - } - - #[test] - fn test_op_gte_replaced() { - let f = TestFixture::new(); - let raw = f.build_script_with_mutation_at(20, |b| { - b.add_op(OpSub).unwrap(); - }); - assert!(f.extract(&raw).is_err()); - } - - #[test] - fn test_op_else_replaced() { - let f = TestFixture::new(); - let raw = f.build_script_with_mutation_at(21, |b| { - b.add_op(OpIf).unwrap(); - }); - assert!(f.extract(&raw).is_err()); - } - - #[test] - fn test_op_cltv_replaced() { - let f = TestFixture::new(); - let raw = f.build_script_with_mutation_at(23, |b| { - b.add_op(OpCheckSig).unwrap(); - }); - assert!(f.extract(&raw).is_err()); - } - - #[test] - fn test_op_endif_at_26_replaced() { - let f = TestFixture::new(); - let raw = f.build_script_with_mutation_at(26, |b| { - b.add_op(OpSHA256).unwrap(); - }); - assert!(f.extract(&raw).is_err()); - } - - #[test] - fn test_op_false_at_27_replaced() { - let f = TestFixture::new(); - let raw = f.build_script_with_mutation_at(27, |b| { - b.add_op(OpCheckSig).unwrap(); - }); - assert!(f.extract(&raw).is_err()); - } - - #[test] - fn test_metadata_op_if_at_41_replaced() { - let f = TestFixture::new(); - let raw = f.build_script_with_mutation_at(41, |b| { - b.add_op(OpElse).unwrap(); - }); - assert!(f.extract(&raw).is_err()); - } - - #[test] - fn test_op_endif_at_32_replaced() { - let f = TestFixture::new(); - let raw = f.build_script_with_mutation_at(32, |b| { - b.add_op(OpSHA256).unwrap(); - }); - assert!(f.extract(&raw).is_err()); - } - - #[test] - fn test_all_zeros_rejected() { - let f = TestFixture::new(); - let raw = vec![0x00; 100]; - let res = f.extract(&raw); - assert!(res.is_err()); - } - - #[test] - fn test_random_bytes_rejected() { - let f = TestFixture::new(); - let raw: Vec = (0..150).map(|_| rand::random::()).collect(); - let res = f.extract(&raw); - assert!(res.is_err()); - } - - #[test] - fn test_valid_p2pk_script_rejected() { - let f = TestFixture::new(); - let spk = p2pk_spk(&f.sender); - let res = f.extract(spk.script()); - assert!(res.is_err(), "P2PK is not an HTLC"); - } - - #[test] - fn test_just_op_return_rejected() { - let f = TestFixture::new(); - let raw = ScriptBuilder::new().add_op(OpReturn).unwrap().drain(); - let res = f.extract(&raw); - assert!(res.is_err()); - } - - #[test] - fn test_metadata_envelope_op_false_replaced_with_op_true() { - let f = TestFixture::new(); - - let raw = f.build_script_with_mutation_at(27, |b| { - b.add_i64(1).unwrap(); - }); - let res = f.extract(&raw); - assert!(res.is_err(), "OP_TRUE at pos 27 should be rejected"); - } - - #[test] - fn test_metadata_envelope_missing_closing_op_endif() { - let f = TestFixture::new(); - - let raw = f.build_script_with_mutation_at(32, |b| { - b.add_op(OpFalse).unwrap(); - }); - let res = f.extract(&raw); - assert!(res.is_err()); - } - - #[test] - fn test_metadata_envelope_duplicate_rejected() { - let f = TestFixture::new(); - let mut raw = f.build_valid_script(); - - let extra = ScriptBuilder::new() - .add_op(OpFalse) - .unwrap() - .add_op(OpIf) - .unwrap() - .add_data(&f.swap_id) - .unwrap() - .add_data(&f.sender_receiver_address) - .unwrap() - .add_data(&[f.destination]) - .unwrap() - .add_op(OpEndIf) - .unwrap() - .drain(); - raw.extend_from_slice(&extra); - let res = f.extract(&raw); - assert!( - res.is_err(), - "Duplicate metadata envelope should be rejected" - ); - } - - #[test] - fn test_fuzz_single_byte_flip() { - let f = TestFixture::new(); - let valid = f.build_valid_script(); - let valid_c = f.extract(&valid).unwrap(); - - for byte_pos in 0..valid.len() { - for flip in [0x01u8, 0x80, 0xFF] { - let mut tampered = valid.clone(); - tampered[byte_pos] ^= flip; - - if tampered == valid { - continue; - } - - let res = f.extract(&tampered); - match res { - Err(_) => {} - Ok(c) => { - let differs = c.swap_id != valid_c.swap_id - || c.secret_hash != valid_c.secret_hash - || c.addresses.sender != valid_c.addresses.sender - || c.addresses.receiver != valid_c.addresses.receiver - || c.destination != valid_c.destination - || c.unlock_ts != valid_c.unlock_ts - || c.addresses.sender_destination - != valid_c.addresses.sender_destination; - - if !differs { - assert_eq!(c.swap_id, valid_c.swap_id); - assert_eq!(c.secret_hash, valid_c.secret_hash); - assert_eq!(c.addresses.sender, valid_c.addresses.sender); - assert_eq!(c.addresses.receiver, valid_c.addresses.receiver); - } - } - } - } - } - } - - #[test] - fn test_fuzz_multi_byte_corruption() { - let f = TestFixture::new(); - let valid = f.build_valid_script(); - let valid_c = f.extract(&valid).unwrap(); - let mut rng = rand::rng(); - - for _ in 0..500 { - let mut tampered = valid.clone(); - - let n_corruptions = rng.random_range(1..=5usize); - for _ in 0..n_corruptions { - let pos = rng.random_range(0..tampered.len()); - tampered[pos] = rand::random::(); - } - - if tampered == valid { - continue; - } - - let res = f.extract(&tampered); - match res { - Err(_) => {} - Ok(c) => { - let _differs = c.swap_id != valid_c.swap_id - || c.secret_hash != valid_c.secret_hash - || c.addresses.sender != valid_c.addresses.sender - || c.addresses.receiver != valid_c.addresses.receiver - || c.destination != valid_c.destination - || c.unlock_ts != valid_c.unlock_ts - || c.addresses.sender_destination != valid_c.addresses.sender_destination; - } - } - } - } - - #[test] - fn test_fuzz_truncation_at_every_length() { - let f = TestFixture::new(); - let valid = f.build_valid_script(); - - for truncate_to in 0..valid.len() { - let truncated = &valid[..truncate_to]; - let res = f.extract(truncated); - assert!( - res.is_err(), - "Truncated to {truncate_to} bytes should be rejected" - ); - } - } - - #[test] - fn test_fuzz_prepend_junk() { - let f = TestFixture::new(); - let valid = f.build_valid_script(); - - for prefix_len in 1..=10 { - let mut junk: Vec = (0..prefix_len).map(|_| rand::random::()).collect(); - junk.extend_from_slice(&valid); - let res = f.extract(&junk); - assert!( - res.is_err(), - "Prepending {prefix_len} junk bytes should be rejected" - ); - } - } - - #[test] - fn test_fuzz_append_junk() { - let f = TestFixture::new(); - let valid = f.build_valid_script(); - - for suffix_len in 1..=10 { - let mut extended = valid.clone(); - let junk: Vec = (0..suffix_len).map(|_| rand::random::()).collect(); - extended.extend_from_slice(&junk); - let res = f.extract(&extended); - assert!( - res.is_err(), - "Appending {suffix_len} junk bytes should be rejected" - ); - } - } - - #[test] - fn test_fuzz_random_scripts() { - let f = TestFixture::new(); - let mut rng = rand::rng(); - - for _ in 0..1000 { - let len = rng.random_range(0..500usize); - let raw: Vec = (0..len).map(|_| rand::random::()).collect(); - let res = f.extract(&raw); - if let Ok(c) = res { - assert_ne!( - c.swap_id, f.swap_id, - "Random script matched our swap_id — astronomically unlikely" - ); - } - } - } - - #[test] - fn test_reversed_script_rejected() { - let f = TestFixture::new(); - let mut raw = f.build_valid_script(); - raw.reverse(); - let res = f.extract(&raw); - assert!(res.is_err(), "Reversed script should be rejected"); - } - - #[test] - fn test_amount_passthrough_various() { - let f = TestFixture::new(); - let raw = f.build_valid_script(); - - for amount_str in ["0", "1", "999999999999", "100000000", ""] { - let parsed = crate::chains::kaspa::decode::parse_script(&raw) - .collect::, _>>() - .unwrap(); - let c = extract_commitment( - &parsed, - amount_str.to_string(), - Prefix::Devnet, - ChannelId::KaspaTn10, - ) - .unwrap(); - assert_eq!(c.amount.value, amount_str); - } - } - - #[test] - fn test_decimals_always_8() { - let f = TestFixture::new(); - let raw = f.build_valid_script(); - let c = f.extract(&raw).unwrap(); - assert_eq!(c.amount.decimals, 8); - } - - #[test] - fn test_empty_sender_receiver_address() { - let mut f = TestFixture::new(); - f.sender_receiver_address = vec![]; - let raw = f.build_valid_script(); - let res = f.extract(&raw); - - match res { - Ok(c) => { - assert!( - c.addresses.sender_destination.len() <= 1, - "Empty address should encode as at most 1 byte, got {}", - c.addresses.sender_destination.len() - ); - } - Err(_) => {} - } - } - - #[test] - fn test_long_sender_receiver_address() { - let mut f = TestFixture::new(); - - let long_addr = "kaspa:".to_string() + &"a".repeat(194); - f.sender_receiver_address = long_addr.as_bytes().to_vec(); - let raw = f.build_valid_script(); - let c = f.extract(&raw).unwrap(); - assert_eq!(c.addresses.sender_destination.len(), 200); - } - - #[test] - fn test_decode_u64_empty_bytes() { - assert_eq!(decode_u64_from_script(&[]), 0); - } - - #[test] - fn test_decode_u64_single_byte() { - assert_eq!(decode_u64_from_script(&[0x01]), 1); - assert_eq!(decode_u64_from_script(&[0xFF]), 255); - } - - #[test] - fn test_decode_u64_exact_8_bytes() { - let val: u64 = 1_700_007_200; - let bytes = val.to_le_bytes(); - assert_eq!(decode_u64_from_script(&bytes), val); - } - - #[test] - fn test_decode_u64_more_than_8_bytes() { - let mut bytes = 42u64.to_le_bytes().to_vec(); - bytes.extend_from_slice(&[0xAA, 0xBB, 0xCC]); - assert_eq!(decode_u64_from_script(&bytes), 42); - } - - #[test] - fn test_decode_u64_max_value() { - assert_eq!(decode_u64_from_script(&u64::MAX.to_le_bytes()), u64::MAX); - } - - #[test] - fn test_extract_opcode_data_op1_through_op16_via_timelock() { - for tl_secs in 1..=16u64 { - let mut f = TestFixture::new(); - f.timelock = tl_secs * 1000; - let raw = f.build_valid_script(); - let c = f.extract(&raw).unwrap(); - assert_eq!( - c.unlock_ts, tl_secs, - "Timelock {tl_secs}s should round-trip via ms" - ); - } - } - - #[test] - fn test_timelock_zero() { - let mut f = TestFixture::new(); - f.timelock = 0; - let raw = f.build_valid_script(); - let c = f.extract(&raw).unwrap(); - assert_eq!(c.unlock_ts, 0); - } - - #[test] - fn test_missing_data_sender_pubkey() { - let f = TestFixture::new(); - - let raw = f.build_script_with_mutation_at(24, |b| { - b.add_op(OpSub).unwrap(); - }); - let res = f.extract(&raw); - assert!(res.is_err(), "Non-push in sender pubkey slot should fail"); - } - - #[test] - fn test_script_exactly_one_fewer_opcode() { - let f = TestFixture::new(); - - let mut builder = ScriptBuilder::new(); - let receiver_spk = f.receiver_spk_vec(); - - builder.add_op(OpIf).unwrap(); - builder.add_op(OpSHA256).unwrap(); - builder.add_data(&f.secret_hash).unwrap(); - builder.add_op(OpEqualVerify).unwrap(); - builder.add_op(OpTxInputCount).unwrap(); - builder.add_i64(2).unwrap(); - builder.add_op(OpNumEqualVerify).unwrap(); - builder.add_op(OpTxOutputCount).unwrap(); - builder.add_i64(2).unwrap(); - builder.add_op(OpNumEqualVerify).unwrap(); - builder.add_data(&receiver_spk).unwrap(); - builder.add_i64(0).unwrap(); - builder.add_op(OpTxOutputSpk).unwrap(); - builder.add_op(OpEqualVerify).unwrap(); - builder.add_i64(0).unwrap(); - builder.add_op(OpTxOutputAmount).unwrap(); - builder.add_op(OpTxInputIndex).unwrap(); - builder.add_op(OpTxInputAmount).unwrap(); - builder.add_i64(SOLVER_REWARD).unwrap(); - builder.add_op(OpSub).unwrap(); - builder.add_op(OpGreaterThanOrEqual).unwrap(); - builder.add_op(OpElse).unwrap(); - builder.add_i64(f.timelock as i64).unwrap(); - builder.add_op(OpCheckLockTimeVerify).unwrap(); - builder.add_data(&f.sender_pub()).unwrap(); - builder.add_op(OpCheckSig).unwrap(); - builder.add_op(OpEndIf).unwrap(); - builder.add_op(OpFalse).unwrap(); - builder.add_op(OpIf).unwrap(); - builder.add_data(&f.swap_id).unwrap(); - builder.add_data(&f.sender_receiver_address).unwrap(); - builder.add_data(&[f.destination]).unwrap(); - - let raw = builder.drain(); - let res = f.extract(&raw); - assert!( - res.is_err(), - "Script missing final OP_ENDIF should be rejected" - ); - } - - #[test] - fn test_script_exactly_one_extra_opcode() { - let f = TestFixture::new(); - let mut raw = f.build_valid_script(); - - let extra = ScriptBuilder::new().add_op(OpFalse).unwrap().drain(); - raw.extend_from_slice(&extra); - let res = f.extract(&raw); - assert!( - res.is_err(), - "Script with one extra opcode should be rejected" - ); - } - - #[test] - fn test_destination_values_1_through_16() { - for d in 1..=16u8 { - let mut f = TestFixture::new(); - f.destination = d; - let raw = f.build_valid_script(); - let c = f.extract(&raw).unwrap(); - assert_eq!(c.destination, d, "Destination {d} should round-trip"); - } - } - - #[test] - fn test_destination_values_outside_small_int_range() { - for d in [17u8, 127, 128, 254] { - let mut f = TestFixture::new(); - f.destination = d; - let raw = f.build_valid_script(); - let c = f.extract(&raw).unwrap(); - assert_eq!(c.destination, d, "Destination {d} should round-trip"); - } - } - - #[test] - fn test_timelock_boundary_values() { - for tl_ms in [ - 0u64, - 1_000, - 15_000, - 16_000, - 17_000, - 127_000, - 128_000, - 255_000, - 256_000, - 32_767_000, - 32_768_000, - 8_388_607_000, - 8_388_608_000, - 2_147_483_647_000, - 2_147_483_648_000, - u64::MAX / 2, - ] { - let mut f = TestFixture::new(); - f.timelock = tl_ms; - let raw = f.build_valid_script(); - let c = f - .extract(&raw) - .unwrap_or_else(|e| panic!("Timelock {tl_ms}ms should parse, got {e:?}")); - assert_eq!( - c.unlock_ts, - tl_ms / 1000, - "Timelock {tl_ms}ms should round-trip to {}s", - tl_ms / 1000 - ); - } - } -} diff --git a/crates/data/src/chains/kaspa/decode.rs b/crates/data/src/chains/kaspa/decode.rs deleted file mode 100644 index 9100679..0000000 --- a/crates/data/src/chains/kaspa/decode.rs +++ /dev/null @@ -1,321 +0,0 @@ -use crate::UtxoScript; -use crate::chains::kaspa::contracts::contract_v1::VerifiableTransactionMock; -use crate::chains::kaspa::contracts::contract_v1::{ - extract_commitment, extract_reveal_secret, validate_refund_sig, -}; -use crate::chains::kaspa::error::Result; -use ahash::AHashMap; -use itertools::Itertools; -use kaspa_addresses::Prefix; -use kaspa_consensus_core::hashing::sighash::{SigHashReusedValues, SigHashReusedValuesUnsync}; -use kaspa_consensus_core::tx::VerifiableTransaction; -use kaspa_txscript::opcodes::{OpCodeImplementation, deserialize_next_opcode}; -use kaspa_txscript::{extract_script_pub_key_address, pay_to_script_hash_script}; -use kaspa_txscript_errors::TxScriptError; -use kaspa_wrpc_client::prelude::RpcBlock; -use std::sync::Arc; -use stroemnet_protocol::ChannelId; -use stroemnet_protocol::v1::{ChainEvent, RefundV1, RevealV1}; -use tokio::sync::RwLock; - -type DynOpcodeImplementation = Box>; - -pub(super) fn parse_script( - script: &[u8], -) -> impl Iterator, TxScriptError>> + '_ -{ - script.iter().batching(|it| deserialize_next_opcode(it)) -} - -#[derive(Debug)] -/// A container for the outcomes of processing a block, -/// including detected chain events and scheduled refunds. -pub(super) struct BlockOutcomes { - pub events: Vec, - pub refunds: Vec<([u8; 32], u64)>, -} - -/// An isolated function that takes a block and the current known HTLC scripts, -/// and returns the detected events and refunds in that block. -/// This is the core of our UTXO parsing logic, and is designed to be -/// easily testable in isolation from the rest of the system. -pub(super) async fn handle_block_added( - safe_block: &Arc, - utxo_scripts: &Arc>>, - prefix: Prefix, - chain_id: ChannelId, -) -> Result { - // A container for all events that we extract from this block. - let mut events: Vec = Vec::new(); - let mut refunds: Vec<([u8; 32], u64)> = Vec::new(); - - // Log the number of transactions and the number of known HTLC scripts for debugging and monitoring purposes. - let known_count = utxo_scripts.read().await.len(); - tracing::debug!( - "parser: scanning block {} txs against {known_count} registered HTLC scripts", - safe_block.transactions.len() - ); - - // Go over all transactions in the block. - for tx in safe_block.transactions.iter() { - // Create a container to store all the outputs for this transaction that match known HTLC scripts. - let mut matched_outputs: Vec<(usize, String, u64, UtxoScript)> = Vec::new(); - { - // Acquire read lock on the script storage. - let scripts: tokio::sync::RwLockReadGuard<'_, AHashMap> = - utxo_scripts.read().await; - for (output_idx, output) in tx.outputs.iter().enumerate() { - let script_pubkey = &output.script_public_key; - - // Attempt to extract the address from the script pubkey. If this fails, we skip this output. - let address = match extract_script_pub_key_address(script_pubkey, prefix) { - Ok(a) => a, - Err(e) => { - tracing::trace!( - "parser: skip output {output_idx} — address derive failed: {e}" - ); - continue; - } - }; - - // Attempt to retrieve the UTXO script associated with this address. - // If there is a match, it means this output is an HTLC output that we are interested in, - // and we store it in the matched_outputs container for further processing. - let key = address.to_string(); - if let Some(utxo_script) = scripts.get(&key) { - matched_outputs.push((output_idx, key, output.value, utxo_script.clone())); - } - } - } - - // Go over all the outputs that matched known HTLC scripts and attempt to parse them as commitments. - for (output_idx, p2sh_addr, value, utxo_script) in matched_outputs { - tracing::info!("matched HTLC output {output_idx} value={value} at {p2sh_addr}"); - - let commitment_extract = { - let script = parse_script(&utxo_script.redeem_script) - .collect::, _>>()?; - extract_commitment( - &script, - utxo_script.deposit_target.clone(), - prefix, - chain_id, - ) - }; - - // If the parsing fails, we log an error and skip this output. - let commitment = match commitment_extract { - Ok(c) => c, - Err(e) => { - tracing::error!("redeem script parse failed: {e}"); - continue; - } - }; - - // Parse the deposit target from the UTXO script. If this fails or is zero, we log a warning and skip this output. - let target = match utxo_script.deposit_target.parse::() { - Ok(v) if v > 0 => v, - _ => { - tracing::warn!( - "skipping match for swap {}: deposit_target unparseable or zero ({})", - hex::encode(commitment.swap_id), - utxo_script.deposit_target - ); - continue; - } - }; - - // Record the refund information for this commitment. - // A refund is always proactively scheduled for every detected commitment, - refunds.push((commitment.swap_id, commitment.unlock_ts)); - - // We only create a comitment event if the output value meets - // or exceeds the target amount specified in the UTXO script. - if value >= target { - tracing::info!( - "swap {} funded ({value} >= {target}) at {p2sh_addr} — emitting Commitment", - hex::encode(commitment.swap_id), - ); - events.push(ChainEvent::Commitment(commitment)); - } else { - tracing::info!( - "swap {} underpaid in tx ({value} < {target}) at {p2sh_addr} — refund scheduled, no commitment", - hex::encode(commitment.swap_id), - ); - } - } - - // A container for swaps that we detected in this transaction as being revealed or refunded - let mut closed_addrs: Vec = Vec::new(); - let mut emit_dedup: AHashMap<[u8; 32], bool> = AHashMap::new(); - - // Go over all inputs which would capture reveals or refunds. - for input in tx.inputs.iter() { - let redeem_script: Vec = { - // Extract all the opcodes from the signature script - let opcodes = match parse_script::< - VerifiableTransactionMock, - SigHashReusedValuesUnsync, - >(&input.signature_script) - .collect::, _>>() - { - Ok(o) => o, - Err(_) => continue, - }; - // We want to extract the redeem script, which should be the last - // element in the sig script. - match opcodes.last() { - Some(op) => { - // The redeem script should be a data opcode - let d = op.get_data(); - if d.is_empty() { - continue; - } - d.to_vec() - } - None => continue, - } - }; - - // Extract the p2sh address from the redeem script, - // we will check it against the locally computed one. - let addr = match extract_script_pub_key_address( - &pay_to_script_hash_script(&redeem_script), - prefix, - ) { - Ok(a) => a.to_string(), - Err(_) => continue, - }; - - // Acquire read lock and retrieve a stored UtxoScript if we have it - let utxo_script = { - let scripts = utxo_scripts.read().await; - match scripts.get(&addr) { - Some(us) => us.clone(), - None => continue, - } - }; - - tracing::info!("Detected HTLC spend at {addr}"); - - // It seems that the script we have is indeed one that has been - // broadcasted to us before, this means we need to try and parse it into a proper - // chain event. - let event_result: Result> = (|| -> Result> { - // Retrieve the sig script opcodes again - let sig_script_opcodes = parse_script(&input.signature_script) - .collect::, _>>()?; - - // Retrieve the redeem opcodes. - let redeem_opcodes = parse_script(&utxo_script.redeem_script) - .collect::, _>>()?; - - // Regardless of whether we have a refund or claim transaction - // we always operate on the redeem script. As such, we should be able to - // compute the swap id from it by extracting it from the script. - let swap_id = extract_commitment( - &redeem_opcodes, - utxo_script.deposit_target.clone(), - prefix, - chain_id, - ) - .ok() - .map(|c| c.swap_id); - - // Attempt to extract a reveal secret (if this is a claim transaction) - if let Ok(secret) = extract_reveal_secret(&sig_script_opcodes) { - let id = match swap_id { - Some(id) => id, - None => return Ok(None), - }; - // If we are able to we should mark this address as "closed" - // as such, the swap is completed after this stage. At least - // from this party's perspective. - closed_addrs.push(addr.clone()); - return Ok(Some(ChainEvent::Reveal(RevealV1 { - swap_id: id, - secret, - }))); - } - - // Attempt to validate that this is a refund transaction - // in which case we also mark it as closed - // Since a refund is a terminal state, just as a claim. - if validate_refund_sig(&sig_script_opcodes).is_ok() { - let id = match swap_id { - Some(id) => id, - None => return Ok(None), - }; - closed_addrs.push(addr.clone()); - return Ok(Some(ChainEvent::Refund(RefundV1 { swap_id: id }))); - } - - // If we end up here all the parsing attempts failed, - // which means that this script does not contain any useful or actionable message for us. - Ok(None) - })(); - - // Match the output of the event - match event_result { - Ok(Some(event)) => { - let event_swap_id = match &event { - ChainEvent::Reveal(r) => Some(r.swap_id), - ChainEvent::Refund(r) => Some(r.swap_id), - _ => None, - }; - - // Check if this event is already in the dedup set - // since theoretically we can expect multiple duplicate transactions - let already = event_swap_id - .map(|id| emit_dedup.insert(id, true).is_some()) - .unwrap_or(false); - - // If its not in dedup set, push it to the events container. - if !already { - events.push(event); - } - } - Ok(None) => { - tracing::error!("{addr} could not parse as reveal or refund"); - } - Err(e) => { - tracing::error!("Error parsing HTLC spend sig_script at {addr}: {e}"); - } - } - } - - // If we have closed a swap in this parsing, we should remove the UTXO scripts - // from storage - if !closed_addrs.is_empty() { - let mut scripts = utxo_scripts.write().await; - for addr in &closed_addrs { - if scripts.remove(addr).is_some() { - tracing::info!("swap closed — cleared utxo_scripts at {addr}"); - } - } - } - } - Ok(BlockOutcomes { events, refunds }) -} - -#[cfg(test)] -mod tests { - use kaspa_consensus_core::tx::ScriptPublicKey; - - #[test] - fn spk_json_serialization_format() { - let script_bytes = vec![0xaa, 0x20, 0x01, 0x02, 0x03, 0x04, 0x05]; - let spk = ScriptPublicKey::new(0, script_bytes.clone().into()); - - let serialized = serde_json::to_vec(&spk).unwrap(); - let serialized_str = String::from_utf8(serialized.clone()).unwrap(); - - let expected_hex = format!("0000{}", hex::encode(&script_bytes)); - let expected_json = format!("\"{}\"", expected_hex); - - assert_eq!( - serialized_str, expected_json, - "ScriptPublicKey JSON serialization must be a quoted hex string" - ); - } -} diff --git a/crates/data/src/chains/kaspa/decode/block.rs b/crates/data/src/chains/kaspa/decode/block.rs new file mode 100644 index 0000000..c09c2f2 --- /dev/null +++ b/crates/data/src/chains/kaspa/decode/block.rs @@ -0,0 +1,70 @@ +use ahash::AHashMap; +use kaspa_addresses::Prefix; +use kaspa_wrpc_client::prelude::RpcBlock; +use std::sync::Arc; +use stroemnet_protocol::ChannelId; +use tokio::sync::RwLock; + +use super::inputs::scan_inputs; +use super::outputs::scan_outputs; +use super::parse::BlockOutcomes; +use crate::UtxoScript; +use crate::chains::kaspa::error::Result; + +/// Handle the addition of a block +pub(crate) async fn handle_block_added( + safe_block: &Arc, // a safe block that has been confirmed + utxo_scripts: &Arc>>, // the utxo scripts inside this block + prefix: Prefix, // chain prefix + channel_id: ChannelId, // the channel id we are working on +) -> Result { + let mut events = Vec::new(); + let mut refunds = Vec::new(); + + let known_count = utxo_scripts.read().await.len(); + tracing::debug!( + "parser: scanning block {} txs against {known_count} registered HTLC scripts", + safe_block.transactions.len() + ); + + // Go over all the transactions + for tx in safe_block.transactions.iter() { + scan_outputs( + // scan the outputs + tx, + utxo_scripts, + prefix, + channel_id, + &mut events, + &mut refunds, + ) + .await?; + // scan the inputs + scan_inputs(tx, utxo_scripts, prefix, channel_id, &mut events).await; + } + + Ok(BlockOutcomes { events, refunds }) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use kaspa_consensus_core::tx::ScriptPublicKey; + + #[test] + fn spk_json_serialization_format() { + let script_bytes = vec![0xaa, 0x20, 0x01, 0x02, 0x03, 0x04, 0x05]; + let spk = ScriptPublicKey::new(0, script_bytes.clone().into()); + + let serialized = serde_json::to_vec(&spk).unwrap(); + let serialized_str = String::from_utf8(serialized.clone()).unwrap(); + + let expected_hex = format!("0000{}", hex::encode(&script_bytes)); + let expected_json = format!("\"{}\"", expected_hex); + + assert_eq!( + serialized_str, expected_json, + "ScriptPublicKey JSON serialization must be a quoted hex string" + ); + } +} diff --git a/crates/data/src/chains/kaspa/decode/classify.rs b/crates/data/src/chains/kaspa/decode/classify.rs new file mode 100644 index 0000000..d1c7c17 --- /dev/null +++ b/crates/data/src/chains/kaspa/decode/classify.rs @@ -0,0 +1,94 @@ +use kaspa_addresses::Prefix; +use kaspa_consensus_core::hashing::sighash::SigHashReusedValuesUnsync; +use kaspa_txscript::{extract_script_pub_key_address, pay_to_script_hash_script}; +use stroemnet_protocol::ChannelId; +use stroemnet_protocol::v1::{ChainEvent, RefundV1, RevealV1}; + +use super::super::contracts::{ + VerifiableTransactionMock, extract_commitment, extract_reveal_secret, validate_refund_sig, +}; +use super::parse::parse_script; +use crate::UtxoScript; +use crate::chains::kaspa::error::Result; + +// Check if the last sig script at the last position has a redeem script +pub(super) fn last_redeem(sig_script: &[u8]) -> Option> { + let opcodes = parse_script::(sig_script) + .collect::, _>>() + .ok()?; + let op = opcodes.last()?; + let d = op.get_data(); + if d.is_empty() { None } else { Some(d.to_vec()) } +} + +// Compute the p2sh address for some redeem script +pub(super) fn derive_p2sh_addr(redeem_script: &[u8], prefix: Prefix) -> Option { + extract_script_pub_key_address(&pay_to_script_hash_script(redeem_script), prefix) + .ok() + .map(|a| a.to_string()) +} + +/// Classify a utxo script its sig script and what it is trying to do +pub(super) fn classify_spend( + sig_script: &[u8], + utxo_script: &UtxoScript, + prefix: Prefix, + channel_id: ChannelId, +) -> Result<(Option, bool)> { + // Retrieve all the signature opcodes from the sig script + let sig_opcodes = parse_script(sig_script).collect::, _>>()?; + + // Retrieve all the opcodes from the redeem script + let redeem_opcodes = + parse_script(&utxo_script.redeem_script).collect::, _>>()?; + + // Extract the swap id + let swap_id = extract_commitment( + &redeem_opcodes, + utxo_script.deposit_target.clone(), + prefix, + channel_id, + ) + .ok() + .map(|c| c.swap_id); + + // Attempt to extract reveal secret in which case it is a reveal + if let Ok(secret) = extract_reveal_secret(&sig_opcodes) { + let Some(id) = swap_id else { + return Ok((None, false)); + }; + return Ok(( + Some(ChainEvent::Reveal(RevealV1 { + swap_id: id, + secret, + })), + true, + )); + } + + // Or validate the refund signature in which case it is a refund + if validate_refund_sig(&sig_opcodes).is_ok() { + let Some(id) = swap_id else { + return Ok((None, false)); + }; + return Ok((Some(ChainEvent::Refund(RefundV1 { swap_id: id })), true)); + } + + // If both attempts failed then we cannot classify this spend + Ok((None, false)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn last_redeem_empty_is_none() { + assert!(last_redeem(&[]).is_none()); + } + + #[test] + fn derive_p2sh_addr_is_some_for_any_script() { + assert!(derive_p2sh_addr(&[1, 2, 3], Prefix::Testnet).is_some()); + } +} diff --git a/crates/data/src/chains/kaspa/decode/inputs.rs b/crates/data/src/chains/kaspa/decode/inputs.rs new file mode 100644 index 0000000..6180a23 --- /dev/null +++ b/crates/data/src/chains/kaspa/decode/inputs.rs @@ -0,0 +1,113 @@ +use ahash::AHashMap; +use kaspa_addresses::Prefix; +use kaspa_rpc_core::RpcTransaction; +use std::sync::Arc; +use stroemnet_protocol::ChannelId; +use stroemnet_protocol::v1::ChainEvent; +use tokio::sync::RwLock; + +use super::classify::{classify_spend, derive_p2sh_addr, last_redeem}; +use crate::UtxoScript; + +/// Compute the swap id from this event by inspecting the chain events +fn event_swap_id(event: &ChainEvent) -> Option<[u8; 32]> { + match event { + ChainEvent::Reveal(r) => Some(r.swap_id), + ChainEvent::Refund(r) => Some(r.swap_id), + _ => None, + } +} + +/// Scan all the inputs and create chainevents out of them +pub(super) async fn scan_inputs( + tx: &RpcTransaction, // the rpc transaction + utxo_scripts: &Arc>>, // a map of utxo scripts + prefix: Prefix, // the kaspa network prefix + channel_id: ChannelId, // channel id + events: &mut Vec, // events containing chainevents +) { + let mut closed_addrs: Vec = Vec::new(); + let mut emit_dedup: AHashMap<[u8; 32], bool> = AHashMap::new(); + + // Go over all inputs in this transaction + for input in tx.inputs.iter() { + // Try to parse a redeem script + let Some(redeem_script) = last_redeem(&input.signature_script) else { + continue; + }; + + // Compute the p2sh address + let Some(addr) = derive_p2sh_addr(&redeem_script, prefix) else { + continue; + }; + + // Try and extract the full utxo script verifyin that we have known about this script before + let utxo_script = { + let scripts = utxo_scripts.read().await; + match scripts.get(&addr) { + Some(us) => us.clone(), + None => continue, + } + }; + + tracing::info!("Detected HTLC spend at {addr}"); + + // Classify the type of htlc spend + match classify_spend(&input.signature_script, &utxo_script, prefix, channel_id) { + Ok((Some(event), closed)) => { + // If its closed then it means the swap is finalized + if closed { + // Push it as a finalized swap address + closed_addrs.push(addr.clone()); + } + + // Check if we have already seen this swap + let already = event_swap_id(&event) + .map(|id| emit_dedup.insert(id, true).is_some()) + .unwrap_or(false); + if !already { + // If we havent seen this swap we push it to detected events + events.push(event); + } + } + Ok((None, _)) => { + tracing::error!("{addr} could not parse as reveal or refund"); + } + Err(e) => { + tracing::error!("Error parsing HTLC spend sig_script at {addr}: {e}"); + } + } + } + + // For all the closed p2sh addresses that have been fulfilled (fulfilled swaps) + // we simply remove them from the tracked scripts + if !closed_addrs.is_empty() { + let mut scripts = utxo_scripts.write().await; + for addr in &closed_addrs { + if scripts.remove(addr).is_some() { + tracing::info!("swap closed — cleared utxo_scripts at {addr}"); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use stroemnet_protocol::v1::{RefundV1, RevealV1}; + + #[test] + fn event_swap_id_reads_reveal_and_refund() { + assert_eq!( + event_swap_id(&ChainEvent::Reveal(RevealV1 { + swap_id: [1u8; 32], + secret: [0u8; 32] + })), + Some([1u8; 32]) + ); + assert_eq!( + event_swap_id(&ChainEvent::Refund(RefundV1 { swap_id: [2u8; 32] })), + Some([2u8; 32]) + ); + } +} diff --git a/crates/data/src/chains/kaspa/decode/mod.rs b/crates/data/src/chains/kaspa/decode/mod.rs new file mode 100644 index 0000000..7353550 --- /dev/null +++ b/crates/data/src/chains/kaspa/decode/mod.rs @@ -0,0 +1,8 @@ +mod block; +mod classify; +mod inputs; +mod outputs; +mod parse; + +pub(crate) use block::handle_block_added; +pub(crate) use parse::parse_script; diff --git a/crates/data/src/chains/kaspa/decode/outputs.rs b/crates/data/src/chains/kaspa/decode/outputs.rs new file mode 100644 index 0000000..f1a8f55 --- /dev/null +++ b/crates/data/src/chains/kaspa/decode/outputs.rs @@ -0,0 +1,124 @@ +use ahash::AHashMap; +use kaspa_addresses::Prefix; +use kaspa_rpc_core::RpcTransaction; +use kaspa_txscript::extract_script_pub_key_address; +use std::sync::Arc; +use stroemnet_protocol::ChannelId; +use stroemnet_protocol::v1::ChainEvent; +use tokio::sync::RwLock; + +use super::super::contracts::extract_commitment; +use super::parse::parse_script; +use crate::UtxoScript; +use crate::chains::kaspa::error::Result; + +/// Check if the target is a valid u64 +fn deposit_target_valid(target: &str) -> Option { + match target.parse::() { + Ok(v) if v > 0 => Some(v), + _ => None, + } +} + +/// Scan the outputs to detect potential commitments +pub(super) async fn scan_outputs( + tx: &RpcTransaction, + utxo_scripts: &Arc>>, + prefix: Prefix, + channel_id: ChannelId, + events: &mut Vec, + refunds: &mut Vec<([u8; 32], u64)>, +) -> Result<()> { + // The scripts we have successfully matched to be scripts that we have known about + let mut matched: Vec<(usize, String, u64, UtxoScript)> = Vec::new(); + { + // Acquire read lock on scripts + let scripts = utxo_scripts.read().await; + + // Go over all outputs + for (output_idx, output) in tx.outputs.iter().enumerate() { + // Compute the p2sh address for some spk + let address = match extract_script_pub_key_address(&output.script_public_key, prefix) { + Ok(a) => a, + Err(e) => { + tracing::trace!( + "parser: skip output {output_idx} — address derive failed: {e}" + ); + continue; + } + }; + let key = address.to_string(); + + // If the utxo script is known then we have matched it and we add it the DS + if let Some(utxo_script) = scripts.get(&key) { + matched.push((output_idx, key, output.value, utxo_script.clone())); + } + } + } + + // For all those outputs we have matched + for (output_idx, p2sh_addr, value, utxo_script) in matched { + tracing::info!("matched HTLC output {output_idx} value={value} at {p2sh_addr}"); + let commitment = { + // Extract the opcodes from the raw script + let script = parse_script(&utxo_script.redeem_script) + .collect::, _>>()?; + + // Attempt to extract the commitment from the script + match extract_commitment( + &script, + utxo_script.deposit_target.clone(), + prefix, + channel_id, + ) { + Ok(c) => c, + Err(e) => { + tracing::error!("redeem script parse failed: {e}"); + continue; + } + } + }; + // Parse the deposit target to u64 + let target = match deposit_target_valid(&utxo_script.deposit_target) { + Some(v) => v, + None => { + tracing::warn!( + "skipping match for swap {}: deposit_target unparseable or zero ({})", + hex::encode(commitment.swap_id), + utxo_script.deposit_target + ); + continue; + } + }; + // Schedule a proactive refund for this swap + refunds.push((commitment.swap_id, commitment.unlock_ts)); + + // Ensure the value of this htlc is geq than the target + if value >= target { + tracing::info!( + "swap {} funded ({value} >= {target}) at {p2sh_addr} — emitting Commitment", + hex::encode(commitment.swap_id), + ); + events.push(ChainEvent::Commitment(commitment)); + } else { + // if user didnt pay we simply have scheduled a refund + tracing::info!( + "swap {} underpaid in tx ({value} < {target}) at {p2sh_addr} — refund scheduled", + hex::encode(commitment.swap_id), + ); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deposit_target_valid_requires_positive_integer() { + assert_eq!(deposit_target_valid("100"), Some(100)); + assert_eq!(deposit_target_valid("0"), None); + assert_eq!(deposit_target_valid("abc"), None); + } +} diff --git a/crates/data/src/chains/kaspa/decode/parse.rs b/crates/data/src/chains/kaspa/decode/parse.rs new file mode 100644 index 0000000..a5cce42 --- /dev/null +++ b/crates/data/src/chains/kaspa/decode/parse.rs @@ -0,0 +1,47 @@ +use itertools::Itertools; +use kaspa_consensus_core::hashing::sighash::SigHashReusedValues; +use kaspa_consensus_core::tx::VerifiableTransaction; +use kaspa_txscript::opcodes::{OpCodeImplementation, deserialize_next_opcode}; +use kaspa_txscript_errors::TxScriptError; +use stroemnet_protocol::v1::ChainEvent; + +pub(crate) type DynOpcodeImplementation = Box>; + +/// Parses a raw script into an opcode iterator +pub(crate) fn parse_script( + script: &[u8], +) -> impl Iterator, TxScriptError>> + '_ +{ + script.iter().batching(|it| deserialize_next_opcode(it)) +} + +#[derive(Debug)] +/// Computes outcomes in a certain block, such as events or refunds +pub(crate) struct BlockOutcomes { + pub events: Vec, + pub refunds: Vec<([u8; 32], u64)>, +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::super::super::contracts::VerifiableTransactionMock; + use super::*; + use kaspa_consensus_core::hashing::sighash::SigHashReusedValuesUnsync; + + #[test] + fn parse_script_yields_opcodes() { + let ops = parse_script::(&[0x51]) + .collect::, _>>() + .unwrap(); + assert_eq!(ops.len(), 1); + } + + #[test] + fn parse_script_empty_is_empty() { + let ops = parse_script::(&[]) + .collect::, _>>() + .unwrap(); + assert!(ops.is_empty()); + } +} diff --git a/crates/data/src/chains/kaspa/detector.rs b/crates/data/src/chains/kaspa/detector.rs new file mode 100644 index 0000000..9be1801 --- /dev/null +++ b/crates/data/src/chains/kaspa/detector.rs @@ -0,0 +1,42 @@ +use super::Kaspa; +use super::signing; +use crate::{BufFut, UtxoScript, UtxoScriptDetector}; + +impl UtxoScriptDetector for Kaspa { + /// Registers a script in the system + /// this usually comes in from a p2p message where + fn register_script<'a>( + &'a self, + address: String, + redeem_script: Vec, + swap_id: [u8; 32], + unlock_ts: u64, + deposit_target: String, + ) -> BufFut<'a, ()> { + Box::pin(async move { + // validate the script before storing it + signing::validate_script_announce( + &self.network_id, + self.channel_id, + &address, + &redeem_script, + swap_id, + unlock_ts, + )?; + let script = UtxoScript { + redeem_script, + unlock_ts, + deposit_target, + }; + // insert the script in our storage and key it by swap id + self.scripts.lock().insert(swap_id, script.clone()); + // register internally the script by its p2sh address + self.register_internal(address, script).await; + // prune scripts that have expired + self.prune_scripts().await; + // persist any kind of swap state for this swap id to disk + self.persist_swap(swap_id); + Ok(()) + }) + } +} diff --git a/crates/data/src/chains/kaspa/emit.rs b/crates/data/src/chains/kaspa/emit.rs new file mode 100644 index 0000000..4876bfc --- /dev/null +++ b/crates/data/src/chains/kaspa/emit.rs @@ -0,0 +1,64 @@ +use stroemnet_protocol::now_unix_secs; +use stroemnet_protocol::v1::ChainEvent; + +use super::Kaspa; +use super::broadcast; +use crate::chains::settlement::ActionKey; +use crate::{Result, ScriptAnnouncement, UtxoScript}; + +impl Kaspa { + /// Emits an event based on the request that we should emit a chain event + pub(super) async fn emit_event<'a>(&'a self, event: &'a ChainEvent) -> Result<()> { + match event { + ChainEvent::Commitment(c) => { + // Cache the commitment, as we need to unlock it later + self.cache_commitment(c); + + // Submit the commitment across kaspa network + let announce = broadcast::submit_commitment( + &self.client, + self.key()?, + self.coinbase_maturity, + c, + ) + .await?; + + // Since we were the ones creating this script we can populate it with us internally + let script = UtxoScript { + redeem_script: announce.redeem_script, + unlock_ts: c.unlock_ts, + deposit_target: c.amount.value.clone(), + }; + self.scripts.lock().insert(c.swap_id, script.clone()); + self.register_internal(announce.address.clone(), script.clone()) + .await; + + // Announce the script + self.announcements.lock().push(ScriptAnnouncement { + address: announce.address, + swap_id: c.swap_id, + script, + }); + self.persist_swap(c.swap_id); + + // Other peers will discover it via their respective connections + Ok(()) + } + ChainEvent::Reveal(r) => { + // If the node participates in ccr and whether we have a commitment for this swap + if self.participate_ccr && self.commitment(&r.swap_id).is_some() { + // We push a pending claim to the pending DS + super::super::push_pending_claim(&mut self.pending_claims.lock(), r); + // then we register an attempt to claim now in the queue, the settler should pick this up + self.queue + .ensure(ActionKey::claim(r.swap_id), now_unix_secs()); + + // persist any swap changes to disk + self.persist_swap(r.swap_id); + } + Ok(()) + } + ChainEvent::Refund(_) => Ok(()), + } + } +} diff --git a/crates/data/src/chains/kaspa/error.rs b/crates/data/src/chains/kaspa/error.rs index 34a8125..52f606f 100644 --- a/crates/data/src/chains/kaspa/error.rs +++ b/crates/data/src/chains/kaspa/error.rs @@ -4,11 +4,15 @@ use kaspa_txscript_errors::TxScriptError; use kaspa_wrpc_client::prelude::RpcBlock; use thiserror::Error; -use super::contracts::contract_v1::DataType; +use super::contracts::DataType; use crate::DataError; pub(super) type Result = std::result::Result; +pub(super) fn script_err(e: E) -> KaspaError { + KaspaError::ScriptBuilder(format!("{e:?}")) +} + #[derive(Error, Debug)] pub enum KaspaError { #[error("Kaspa Tx Script Error: {0}")] @@ -38,9 +42,6 @@ pub enum KaspaError { #[error("Missing secret in signature script")] MissingSecret, - #[error("Missing signature in signature script")] - MissingSignature, - #[error("Missing redeem script in signature script")] MissingRedeemScript, @@ -53,9 +54,6 @@ pub enum KaspaError { #[error("Script builder error: {0}")] ScriptBuilder(String), - #[error("Failed to parse amount: {0}")] - AmountParse(String), - #[error("Parse int error: {0}")] ParseInt(#[from] std::num::ParseIntError), @@ -65,9 +63,6 @@ pub enum KaspaError { #[error("From utf8 error: {0}")] FromUtf8(#[from] std::string::FromUtf8Error), - #[error("Invalid sigmsg detail length: expected {expected}, got {got}")] - InvalidSigMsgDetailLength { expected: usize, got: usize }, - #[error("Kaspa Rpc Tx Error: {0}")] RpcTx(#[from] kaspa_wrpc_client::prelude::RpcError), @@ -77,9 +72,6 @@ pub enum KaspaError { #[error("Safe block send error: {0}")] SafeBlockSend(#[from] tokio::sync::mpsc::error::SendError>), - #[error("Missing channel id for destination: {0:?}")] - MissingChannelId(stroemnet_protocol::ChannelId), - #[error("Swap not found: {}", hex::encode(_0))] SwapNotFound([u8; 32]), diff --git a/crates/data/src/chains/kaspa/intake/confirm.rs b/crates/data/src/chains/kaspa/intake/confirm.rs new file mode 100644 index 0000000..a122fa7 --- /dev/null +++ b/crates/data/src/chains/kaspa/intake/confirm.rs @@ -0,0 +1,105 @@ +use ahash::AHashSet; +use kaspa_hashes::Hash; + +use crate::chains::kaspa::error::Result; +use crate::chains::kaspa::intake::Intake; + +/// A block is not confirmed if removed contains its and if its below the required daa threshold +fn unconfirmed( + removed: &AHashSet, + hash: &Hash, + block_daa: u64, + virtual_daa: u64, + threshold: u64, +) -> bool { + removed.contains(hash) || virtual_daa.saturating_sub(block_daa) < threshold +} + +impl Intake { + /// Computes which blocks are confirmed and transmits them to the next stage in the block processing pipeline + pub(super) async fn flush_confirmed_blocks( + &mut self, + removed: &AHashSet, // which block hashes have previously been reorged out + chain_blocks: &AHashSet, // the current chain blocks + ) -> Result<()> { + let virtual_daa = self.max_seen_daa; + let threshold = self.minimum_block_confirmations; + + let mut new_cursor: Option = None; + let mut buffer = Vec::new(); + + // For all the blocks that are pending + for (hash, block) in std::mem::take(&mut self.pending_blocks) { + if unconfirmed( + removed, + &hash, + block.header.daa_score, + virtual_daa, + threshold, + ) { + // if we encountered a block that is unconfirmed we cannot proceed beyond it + // it has been reorged out. + break; + } + // Push the block to the buffer + buffer.push(block); + + // If the chain blocks contains this block + // it means its part of the canonical chain + if chain_blocks.contains(&hash) { + // we assume everything will be sent + let mut all_sent = true; + for block in buffer.drain(..) { + // drain all the blocks and send them to the next step + if self.sender.send(block).await.is_err() { + // if anything errored we fail here and exit + all_sent = false; + break; + } + } + // if not everything was sent we break + if !all_sent { + break; + } + + // only if everything was successful do we update the cursor + new_cursor = Some(hash); + } + } + + // If the cursor was updated we update it on disk too + if let Some(hash) = new_cursor { + self.set_cursor(hash); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::indexing_slicing)] + use super::*; + + fn h(b: u8) -> Hash { + Hash::from_bytes([b; 32]) + } + + #[test] + fn removed_block_is_unconfirmed() { + let mut removed = AHashSet::new(); + removed.insert(h(1)); + assert!(unconfirmed(&removed, &h(1), 100, 200, 10)); + } + + #[test] + fn shallow_block_is_unconfirmed() { + let removed = AHashSet::new(); + assert!(unconfirmed(&removed, &h(2), 195, 200, 10)); + } + + #[test] + fn deep_unremoved_block_is_confirmed() { + let removed = AHashSet::new(); + assert!(!unconfirmed(&removed, &h(3), 100, 200, 10)); + } +} diff --git a/crates/data/src/chains/kaspa/intake/mod.rs b/crates/data/src/chains/kaspa/intake/mod.rs index 373c535..702b758 100644 --- a/crates/data/src/chains/kaspa/intake/mod.rs +++ b/crates/data/src/chains/kaspa/intake/mod.rs @@ -1,3 +1,4 @@ +mod confirm; mod read; use std::sync::Arc; @@ -9,31 +10,27 @@ use tokio::sync::mpsc::Sender; use crate::CursorStore; -/// The Intake component is responsible for receiving new blocks from the Kaspa network, -/// performing initial processing and validation, and forwarding them to the appropriate channels. +/// The intake primitive responsible for accepting new blocks and handling reorgs pub(super) struct Intake { - /// The sender channel to forward validated blocks to the next stage of processing. + /// Sender of confirmed blocks sender: Sender>, - /// The Kaspa RPC client used to fetch block data and subscribe to new block notifications. + /// Kaspa rpc client client: Arc, - /// The minimum number of confirmations required before a block is forwarded. This helps ensure - /// that the block is unlikely to be reorged out of the chain before we process it. + /// Minimum amount of block confirmations required before considered safe to act on minimum_block_confirmations: u64, - /// A buffer of recently seen blocks that are waiting for enough confirmations before being forwarded. + /// Pending blocks that are not yet confirmed pending_blocks: IndexMap>, - /// The maximum DAA score seen so far. - /// This is used to filter out old blocks that are too far behind the current chain tip. + /// The maximum seen daa pub(crate) max_seen_daa: u64, - /// The current cursor, representing the last processed block's hash. + /// The cursor that we are tracking cursor: Option, - /// An optional cursor store for persisting the last processed block's hash across restarts. + /// Cursor storage cursor_store: Option>, - /// The channel ID associated with this intake instance, used for identifying the source of blocks. + /// the channel id of the intake (kaspa tn10,mainnet and so forth) channel_id: ChannelId, } impl Intake { - /// Creates a new Intake instance with the given Kaspa RPC client, sender channel, and minimum confirmations. pub(super) fn new( client: Arc, sender: Sender>, diff --git a/crates/data/src/chains/kaspa/intake/read.rs b/crates/data/src/chains/kaspa/intake/read.rs index 2d8fe3f..f940382 100644 --- a/crates/data/src/chains/kaspa/intake/read.rs +++ b/crates/data/src/chains/kaspa/intake/read.rs @@ -6,36 +6,54 @@ use kaspa_rpc_core::api::rpc::RpcApi; use crate::chains::kaspa::error::{KaspaError, Result}; use crate::chains::kaspa::intake::Intake; +use crate::chains::net::retry_timed; + +/// Whether we should continue to poll for new blocks and advance to next page or stop +enum PageStep { + Stop, + Advance(Hash), +} + +/// Compute the next page which is either the lowest hash at that page or if we should stop +fn next_page(last: Option, page_low: Hash, reached_sink: bool) -> PageStep { + if reached_sink { + return PageStep::Stop; + } + // If the last hash is not eq to the page low it means there are still more unchecked blocks + match last { + Some(h) if h != page_low => PageStep::Advance(h), + _ => PageStep::Stop, + } +} impl Intake { - /// Starts the intake process, continuously polling for new blocks and processing them. + /// Starts a continuous loop to poll blocks every second from the rpc pub(crate) async fn read(&mut self) -> Result<()> { loop { - // Poll for new blocks and process them - match self.poll_once().await { - // if its ok reset the consecutive failures counter - Ok(()) => {} - Err(e) => { - tracing::warn!("kaspa intake poll failed for {}: {e}", self.channel_id); - } + if let Err(e) = self.poll_once().await { + tracing::warn!("kaspa intake poll failed for {}: {e}", self.channel_id); } - // Sleep for a short duration before the next poll to avoid overwhelming the RPC server stroemnet_protocol::sleep_secs(1).await; } } + /// Poll for blocks once async fn poll_once(&mut self) -> Result<()> { - // Fetch the current block DAG info to get the virtual DAA score and sink block hash - let dag = self - .client - .get_block_dag_info() + // Get the kaspa rpc + let client = self.client.clone(); + + // Get dag data + let dag = retry_timed("get_block_dag_info", || client.get_block_dag_info()) .await - .map_err(|e| KaspaError::Other(format!("get_block_dag_info: {e}")))?; + .ok_or_else(|| KaspaError::Other("get_block_dag_info: timed out".into()))?; + + // Update the max seen daa self.max_seen_daa = dag.virtual_daa_score; + + // Get the block hash of the sink let sink = dag.sink; - // Determine the starting point for fetching blocks based on the current cursor - // if we dont have a cursor, we set the sink to be the cursor + // Get the cursor or set the sink to be the new cursor let low = match self.cursor { Some(h) => h, None => { @@ -44,47 +62,59 @@ impl Intake { } }; - // clear the pending blocks buffer before fetching new blocks + // Clear all pending blocks + // We will get new ones in this iteration self.pending_blocks.clear(); + + // Compute the start of the page let mut page_low = low; + + // To prevent any kind of rpc rate limit we limit how many pages we advance at once let mut iterations: u32 = 0; loop { iterations += 1; - // Get a page of blocks starting from the current page_low hash, including the sink block - let resp = self - .client - .get_blocks(Some(page_low), true, true) - .await - .map_err(|e| KaspaError::Other(format!("get_blocks: {e}")))?; - // whether we have reached the sink block in this page of results + /// Retrieve the blocks and transactions + let resp = retry_timed("get_blocks", || { + client.get_blocks(Some(page_low), true, true) + }) + .await + .ok_or_else(|| KaspaError::Other("get_blocks: timed out".into()))?; + + // Did we manage to reach the sink? let reached_sink = resp.block_hashes.contains(&sink); + // Do we have the last block hash? + let last = resp.block_hashes.last().copied(); + + if resp.blocks.len() != resp.block_hashes.len() { + return Err(KaspaError::Other( + "get_blocks: blocks/hashes length mismatch".into(), + )); + } + // Go over all blocks - for (i, block) in resp.blocks.into_iter().enumerate() { - let hash = resp.block_hashes[i]; - // If the hash is equal to the page_low, we skip it to avoid processing the same block again + for (block, hash) in resp + .blocks + .into_iter() + .zip(resp.block_hashes.iter().copied()) + { if hash == page_low { + // if the hash is equal to the lower page it is a block that we have already processed continue; } + // Add it to pending blocks self.pending_blocks .entry(hash) .or_insert_with(|| Arc::new(block)); } - // If we have reached the sink block, we can break out of the - // loop as we have fetched all new blocks - if reached_sink { - break; - } - // Otherwise we update the page low as long as the last block hash - // in the response is different from the current page low, otherwise we break to avoid infinite loops - match resp.block_hashes.last().copied() { - Some(last) if last != page_low => page_low = last, - _ => break, + // Compute whether we should advance o the next page + match next_page(last, page_low, reached_sink) { + PageStep::Stop => break, + PageStep::Advance(h) => page_low = h, // if we should advance we update the lower page bound } - // If we have iterated too many times, we log a warning and continue processing on the - // next poll + // break if we have too many pagings at once if iterations >= 10_000 { tracing::warn!( "kaspa intake for {}: get_blocks paging exceeded bound — continuing next poll", @@ -94,82 +124,54 @@ impl Intake { } } - // Retrieve the consensus chain from the node. - let vc = self - .client - .get_virtual_chain_from_block(low, false, None) - .await - .map_err(|e| KaspaError::Other(format!("get_virtual_chain_from_block: {e}")))?; + // Retrieve the virtual chain from the lower bound hash + let vc = retry_timed("get_virtual_chain_from_block", || { + client.get_virtual_chain_from_block(low, false, None) + }) + .await + .ok_or_else(|| KaspaError::Other("get_virtual_chain_from_block: timed out".into()))?; - // Compute all blocks that were removed from the chain and should not be forwarded + // Retrieve the removed blocks due to reorg and all the chain blocks let removed: AHashSet = vc.removed_chain_block_hashes.iter().copied().collect(); - - // Compute all blocks that are part of the current chain and should be forwarded let chain_blocks: AHashSet = vc.added_chain_block_hashes.iter().copied().collect(); - // Attempt to flush all confirmed chain blocks. + // Based on the above data flush the confirmed blocks to the next stage of the pipeline self.flush_confirmed_blocks(&removed, &chain_blocks).await } - async fn flush_confirmed_blocks( - &mut self, - removed: &AHashSet, - chain_blocks: &AHashSet, - ) -> Result<()> { - // Retrieve our current virtual DAA score - let virtual_daa = self.max_seen_daa; - // Retrieve the threshold for forwarding. - let threshold = self.minimum_block_confirmations; - - // Create a new cursor - let mut new_cursor: Option = None; - let mut buffer = Vec::new(); - - // Take all pending blocks and iterate over them in order of insertion - for (hash, block) in std::mem::take(&mut self.pending_blocks) { - // If the block is in the removed set or its DAA score is below the threshold, we break out of the loop - if removed.contains(&hash) - || virtual_daa.saturating_sub(block.header.daa_score) < threshold - { - break; - } - // Otherwise, we add the block to the buffer and check if it is part of the current chain - buffer.push(block); - - // Ensure this hash is a chain block - if chain_blocks.contains(&hash) { - let mut all_sent = true; - // Attempt to send all buffered blocks to the next stage of processing - for block in buffer.drain(..) { - if self.sender.send(block).await.is_err() { - all_sent = false; - break; - } - } - // If we failed to send all blocks, we break out of the loop and will retry on the next poll - // I.e. we dont update the cursor until we have successfully sent all blocks - // The rest of the system is idempotent - if !all_sent { - break; - } - // If we successfully sent all blocks, we update the new cursor to the current hash - new_cursor = Some(hash); - } - } - - // Now if we have a new cursor, we update the current cursor and persist it if a cursor store is available - if let Some(hash) = new_cursor { - self.set_cursor(hash); - } - Ok(()) - } - - /// Helper function to set the current cursor and persist it if a cursor store is available. - fn set_cursor(&mut self, hash: Hash) { + // Update the cursor in memory and save it to disk if there is such storage + pub(super) fn set_cursor(&mut self, hash: Hash) { self.cursor = Some(hash); if let Some(store) = &self.cursor_store { - // save the cursor to the store for persistence across restarts store.save(self.channel_id, &hash.as_bytes()); } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn h(b: u8) -> Hash { + Hash::from_bytes([b; 32]) + } + + #[test] + fn stops_at_sink() { + assert!(matches!(next_page(Some(h(2)), h(1), true), PageStep::Stop)); + } + + #[test] + fn advances_to_last() { + assert!(matches!( + next_page(Some(h(2)), h(1), false), + PageStep::Advance(_) + )); + } + + #[test] + fn stops_when_no_progress() { + assert!(matches!(next_page(Some(h(1)), h(1), false), PageStep::Stop)); + assert!(matches!(next_page(None, h(1), false), PageStep::Stop)); + } +} diff --git a/crates/data/src/chains/kaspa/mod.rs b/crates/data/src/chains/kaspa/mod.rs index ca0347a..393d8fd 100644 --- a/crates/data/src/chains/kaspa/mod.rs +++ b/crates/data/src/chains/kaspa/mod.rs @@ -1,477 +1,79 @@ +mod accessors; mod broadcast; +mod buffer; +mod client; +mod config; +mod connect; mod contracts; mod decode; +mod detector; +mod emit; mod error; mod intake; +mod persist; +mod poll; +#[cfg(not(target_arch = "wasm32"))] +mod reconcile; +#[cfg(not(target_arch = "wasm32"))] +mod settle; +#[cfg(not(target_arch = "wasm32"))] +mod settler; mod signing; #[cfg(test)] mod test_helpers; -use std::str::FromStr; -use std::sync::{Arc, Mutex}; +use parking_lot::Mutex; +use std::sync::Arc; use ahash::AHashMap; use kaspa_addresses::Prefix; -use kaspa_hashes::Hash; -use kaspa_rpc_core::api::rpc::RpcApi; -use kaspa_wrpc_client::prelude::{NetworkId, RpcBlock}; -use kaspa_wrpc_client::{KaspaRpcClient, Resolver, WrpcEncoding}; -use serde::Deserialize; -use serde_json::Value; +use kaspa_wrpc_client::KaspaRpcClient; +use kaspa_wrpc_client::prelude::RpcBlock; use stroemnet_protocol::ChannelId; -use stroemnet_protocol::now_unix_secs; -use stroemnet_protocol::v1::{ChainEvent, CommitmentV1, RefundV1, RevealV1}; +use stroemnet_protocol::v1::{CommitmentV1, RefundV1, RevealV1}; use tokio::sync::RwLock; use tokio::sync::mpsc::Receiver; -use crate::{ - BufFut, ChainDataBuffer, CursorStore, DataError, ProposalVerification, Result, - ScriptAnnouncement, UtxoScript, UtxoScriptDetector, -}; - -const DEFAULT_COINBASE_MATURITY: u64 = 100; -const DEFAULT_MINIMUM_BLOCK_CONFIRMATIONS: u64 = 10 * (60 * 10); -const DEFAULT_SCRIPT_TTL_SECS: u64 = 4 * 60 * 60; - -#[derive(Deserialize)] -struct KaspaConfig { - #[serde(default)] - wrpc_url: Option, - network_id: String, - #[serde(default = "default_min_confirmations")] - minimum_block_confirmations: u64, - #[serde(default = "default_coinbase_maturity")] - coinbase_maturity: u64, - #[serde(default = "default_script_ttl_secs")] - script_ttl_secs: u64, - #[serde(default)] - participate_ccr: bool, -} - -fn default_min_confirmations() -> u64 { - DEFAULT_MINIMUM_BLOCK_CONFIRMATIONS -} -fn default_coinbase_maturity() -> u64 { - DEFAULT_COINBASE_MATURITY -} -fn default_script_ttl_secs() -> u64 { - DEFAULT_SCRIPT_TTL_SECS -} +use crate::chains::settlement::{RetryQueue, SettlementMetrics}; +use crate::{ScriptAnnouncement, SwapStore, UtxoScript}; +/// The kaspa channel pub(crate) struct Kaspa { + /// Channel id of the kaspa channel channel_id: ChannelId, + /// Kaspa specific network id network_id: String, + /// Network prefix prefix: Prefix, + /// How many daa to wait before coinbase utxo is valid coinbase_maturity: u64, + /// Time to live for an announced script utxo script_ttl_secs: u64, + /// Whether to participate in CCR participate_ccr: bool, + /// Optional private key for lp/ccr nodes private_key: Option, + /// Kaspa rpc client client: Arc, + /// Storage of utxo scripts utxo_scripts: Arc>>, + /// A reciver for safe confirmed finalized blocks safe_blocks: Mutex>>, + /// Tracking all commitments by swap id commitments: Mutex>, + /// Queue for pending refunds pending_refunds: Mutex>, + /// Queue for pending claims pending_claims: Mutex>, + /// UTXO script annoucements announcements: Mutex>, -} - -impl Kaspa { - pub(crate) async fn connect( - channel_id: ChannelId, - cfg: &Value, - private_key: Option, - cursor_store: Option>, - ) -> Result { - let cfg: KaspaConfig = serde_json::from_value(cfg.clone()) - .map_err(|e| DataError::Config(format!("kaspa config: {e}")))?; - let network_id = NetworkId::from_str(&cfg.network_id) - .map_err(|e| DataError::Config(format!("network_id: {e:?}")))?; - let prefix: Prefix = network_id.into(); - let resolver = match cfg.wrpc_url.as_deref() { - Some(_) => None, - None => Some(Resolver::default()), - }; - let client = Arc::new( - KaspaRpcClient::new( - WrpcEncoding::Borsh, - cfg.wrpc_url.as_deref(), - resolver, - Some(network_id), - None, - ) - .map_err(|e| DataError::Connect(format!("wrpc client: {e}")))?, - ); - client - .connect(None) - .await - .map_err(|e| DataError::Connect(format!("kaspa connect: {e}")))?; - - // Retrieve the initial cursor from the cursor store if available, and convert it to a Hash - let initial_cursor = cursor_store - .as_ref() - .and_then(|s| s.load(channel_id)) - .and_then(|b| <[u8; 32]>::try_from(b.as_slice()).ok()) - .map(Hash::from_bytes); - - let (tx, rx) = tokio::sync::mpsc::channel::>(1024); - let mut reader = intake::Intake::new( - client.clone(), - tx, - cfg.minimum_block_confirmations, - channel_id, - initial_cursor, - cursor_store, - ); - stroemnet_protocol::spawn(async move { - if let Err(e) = reader.read().await { - tracing::error!("kaspa intake loop terminated: {e}"); - } - }); - - tracing::info!( - "Kaspa buffer {channel_id} connected to {:?} (confirmations {}, ccr {})", - client.url(), - cfg.minimum_block_confirmations, - cfg.participate_ccr, - ); - - Ok(Self { - channel_id, - network_id: cfg.network_id, - prefix, - coinbase_maturity: cfg.coinbase_maturity, - script_ttl_secs: cfg.script_ttl_secs, - participate_ccr: cfg.participate_ccr, - private_key, - client, - utxo_scripts: Arc::new(RwLock::new(AHashMap::new())), - safe_blocks: Mutex::new(rx), - commitments: Mutex::new(AHashMap::new()), - pending_refunds: Mutex::new(Vec::new()), - pending_claims: Mutex::new(Vec::new()), - announcements: Mutex::new(Vec::new()), - }) - } - - fn key(&self) -> Result<&str> { - self.private_key - .as_deref() - .ok_or(DataError::MissingKey(self.channel_id)) - } - - fn cache_commitment(&self, commitment: &CommitmentV1) { - self.commitments - .lock() - .unwrap() - .insert(commitment.swap_id, commitment.clone()); - } - - fn commitment(&self, swap_id: &[u8; 32]) -> Option { - self.commitments.lock().unwrap().get(swap_id).cloned() - } - - fn track_actionable_event(&self, event: &ChainEvent) { - if let ChainEvent::Commitment(c) = event { - self.cache_commitment(c); - } - super::queue_dequeue_refund_event( - &mut self.pending_refunds.lock().unwrap(), - event, - self.participate_ccr, - ); - match event { - ChainEvent::Reveal(r) => self - .pending_claims - .lock() - .unwrap() - .retain(|c| c.swap_id != r.swap_id), - ChainEvent::Refund(r) => self - .pending_claims - .lock() - .unwrap() - .retain(|c| c.swap_id != r.swap_id), - ChainEvent::Commitment(_) => {} - } - } - - async fn prune_scripts(&self) { - let now = now_unix_secs(); - let ttl = self.script_ttl_secs; - let mut scripts = self.utxo_scripts.write().await; - scripts.retain(|_, s| now <= s.unlock_ts.saturating_add(ttl)); - } - - async fn register_internal(&self, address: String, script: UtxoScript) { - self.utxo_scripts.write().await.insert(address, script); - } - - async fn run_refund_scheduler(&self) { - if !self.participate_ccr || self.private_key.is_none() { - return; - } - let has_pending = { !self.pending_refunds.lock().unwrap().is_empty() }; - if !has_pending { - return; - } - let pmt = match self.client.get_block_dag_info().await { - Ok(info) => info.past_median_time, - Err(e) => { - tracing::warn!("kaspa refund scheduler: get_block_dag_info: {e}"); - return; - } - }; - let ready: Vec<[u8; 32]> = { - let pending = self.pending_refunds.lock().unwrap(); - pending - .iter() - .filter(|(_, unlock_ts)| pmt > unlock_ts.saturating_mul(1000)) - .map(|(r, _)| r.swap_id) - .collect() - }; - for swap_id in ready { - let remove = match self.commitment(&swap_id) { - None => true, - Some(commitment) => match broadcast::submit_refund( - &self.client, - self.key().unwrap_or_default(), - self.coinbase_maturity, - &commitment, - ) - .await - { - Ok(_) => true, - Err(e) => { - tracing::error!("kaspa scheduled refund {}: {e}", hex::encode(swap_id)); - false - } - }, - }; - if remove { - self.pending_refunds - .lock() - .unwrap() - .retain(|(r, _)| r.swap_id != swap_id); - } - } - } - - async fn run_claim_scheduler(&self) { - if !self.participate_ccr || self.private_key.is_none() { - return; - } - let claims: Vec = { self.pending_claims.lock().unwrap().clone() }; - for reveal in claims { - let Some(commitment) = self.commitment(&reveal.swap_id) else { - continue; - }; - match broadcast::submit_reveal( - &self.client, - self.key().unwrap_or_default(), - self.coinbase_maturity, - &commitment, - &reveal, - ) - .await - { - Ok(()) => self - .pending_claims - .lock() - .unwrap() - .retain(|c| c.swap_id != reveal.swap_id), - Err(e) => { - tracing::error!("kaspa claim retry for {}: {e}", hex::encode(reveal.swap_id)) - } - } - } - } -} - -impl ChainDataBuffer for Kaspa { - fn lp_address(&self) -> Result { - Ok(signing::lp_address_from_private_key( - &self.network_id, - self.key()?, - )?) - } - - fn derive_deposit(&self, commitment: &CommitmentV1) -> Result<(String, Vec)> { - Ok(signing::p2sh_components(&self.network_id, commitment)?) - } - - fn finalized_chunk(&self) -> BufFut<'_, Vec<(ChannelId, ChainEvent)>> { - Box::pin(async move { - let blocks: Vec> = { - let mut rx = self.safe_blocks.lock().unwrap(); - let mut v = Vec::new(); - while let Ok(block) = rx.try_recv() { - v.push(block); - } - v - }; - - let mut events = Vec::new(); - for block in blocks { - let outcomes = decode::handle_block_added( - &block, - &self.utxo_scripts, - self.prefix, - self.channel_id, - ) - .await?; - if self.participate_ccr { - let mut pending = self.pending_refunds.lock().unwrap(); - for (swap_id, unlock_ts) in outcomes.refunds { - if !pending.iter().any(|(r, _)| r.swap_id == swap_id) { - pending.push((RefundV1::new(swap_id), unlock_ts)); - } - } - } - for event in outcomes.events { - self.track_actionable_event(&event); - events.push((self.channel_id, event)); - } - } - - self.prune_scripts().await; - self.run_refund_scheduler().await; - self.run_claim_scheduler().await; - Ok(events) - }) - } - - fn broadcast_event<'a>(&'a self, event: &'a ChainEvent) -> BufFut<'a, ()> { - Box::pin(async move { - match event { - ChainEvent::Commitment(c) => { - self.cache_commitment(c); - let announce = broadcast::submit_commitment( - &self.client, - self.key()?, - self.coinbase_maturity, - c, - ) - .await?; - let script = UtxoScript { - redeem_script: announce.redeem_script, - unlock_ts: c.unlock_ts, - deposit_target: c.amount.value.clone(), - }; - self.register_internal(announce.address.clone(), script.clone()) - .await; - self.announcements.lock().unwrap().push(ScriptAnnouncement { - address: announce.address, - swap_id: c.swap_id, - script, - }); - Ok(()) - } - ChainEvent::Reveal(r) => { - if self.participate_ccr && self.commitment(&r.swap_id).is_some() { - let mut pending = self.pending_claims.lock().unwrap(); - if !pending.iter().any(|c| c.swap_id == r.swap_id) { - pending.push(r.clone()); - } - } - Ok(()) - } - ChainEvent::Refund(r) => { - if !self.participate_ccr { - return Ok(()); - } - let commitment = - self.commitment(&r.swap_id).ok_or(DataError::Other(format!( - "kaspa refund: unknown commitment for swap {}", - hex::encode(r.swap_id) - )))?; - broadcast::submit_refund( - &self.client, - self.key()?, - self.coinbase_maturity, - &commitment, - ) - .await - .map_err(DataError::from) - } - } - }) - } - - fn sign_message<'a>( - &'a self, - digest: [u8; 32], - required_balance: &'a str, - ) -> BufFut<'a, (String, Vec)> { - Box::pin(async move { - let required: u64 = required_balance - .parse() - .map_err(|e| DataError::Sign(format!("required_balance: {e}")))?; - signing::sign_message( - &self.client, - &self.network_id, - self.key()?, - digest, - required, - ) - .await - .map_err(DataError::from) - }) - } - - fn verify_message<'a>( - &'a self, - digest: [u8; 32], - claimed_address: &'a str, - signature: &'a [u8], - required_balance: &'a str, - ) -> BufFut<'a, ProposalVerification> { - Box::pin(async move { - let required: u64 = required_balance - .parse() - .map_err(|e| DataError::Sign(format!("required_balance: {e}")))?; - signing::verify_message(&self.client, digest, claimed_address, signature, required) - .await - .map_err(DataError::from) - }) - } - - fn utxo_script_detector(&self) -> Option<&dyn UtxoScriptDetector> { - Some(self) - } - - fn take_utxo_script_announcements(&self) -> Vec { - std::mem::take(&mut self.announcements.lock().unwrap()) - } -} - -impl UtxoScriptDetector for Kaspa { - fn register_script<'a>( - &'a self, - address: String, - redeem_script: Vec, - swap_id: [u8; 32], - unlock_ts: u64, - deposit_target: String, - ) -> BufFut<'a, ()> { - Box::pin(async move { - signing::validate_script_announce( - &self.network_id, - self.channel_id, - &address, - &redeem_script, - swap_id, - unlock_ts, - )?; - self.register_internal( - address, - UtxoScript { - redeem_script, - unlock_ts, - deposit_target, - }, - ) - .await; - self.prune_scripts().await; - Ok(()) - }) - } + /// Scripts by swap id + scripts: Mutex>, + /// Permanent disk storage for swaps + swap_store: Option>, + /// A retry queue for both claims and refunds + queue: RetryQueue, + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + /// General stats + metrics: Arc, } diff --git a/crates/data/src/chains/kaspa/persist.rs b/crates/data/src/chains/kaspa/persist.rs new file mode 100644 index 0000000..65382ed --- /dev/null +++ b/crates/data/src/chains/kaspa/persist.rs @@ -0,0 +1,107 @@ +use stroemnet_protocol::v1::ChainEvent; + +use super::Kaspa; +use crate::PersistedSwap; +use crate::chains::record::encode; +use crate::chains::settlement::ActionKey; + +/// Whether a chain event is a resolving type of event +fn is_resolving(event: &ChainEvent) -> bool { + matches!(event, ChainEvent::Reveal(_) | ChainEvent::Refund(_)) +} + +impl Kaspa { + pub(super) fn persist_swap(&self, swap_id: [u8; 32]) { + // We only persist a swap if we have a configured swap store + let Some(store) = &self.swap_store else { + return; + }; + // Find any kind of pending refund + let pending_refund = self + .pending_refunds + .lock() + .iter() + .find(|(r, _)| r.swap_id == swap_id) + .map(|(r, ts)| (r.clone(), *ts)); + + // Pending claim? + let pending_claim = self + .pending_claims + .lock() + .iter() + .find(|c| c.swap_id == swap_id) + .cloned(); + + // Retrieve the potential registered utxo script + let script = self.scripts.lock().get(&swap_id).cloned(); + // Create a record for the swap + let record = PersistedSwap { + script, + pending_refund, + pending_claim, + claim_attempt: self.queue.get(ActionKey::claim(swap_id)), + refund_attempt: self.queue.get(ActionKey::refund(swap_id)), + }; + + // If the record is fully empty it can mean that the swap is settled and we should remove it + if record.is_empty() { + store.delete(self.channel_id, swap_id); + } else { + // Otherwise we serialize it and store it to disk + match encode(&record) { + Ok(bytes) => store.save(self.channel_id, swap_id, &bytes), + Err(e) => tracing::error!( + target: "settlement", + "kaspa persist swap {} encode failed: {e}", + hex::encode(swap_id) + ), + } + } + } + + /// Take an event and track it in the relevant trackers + pub(super) fn track_actionable_event(&self, event: &ChainEvent) { + // Get the swap id from an event + let swap_id = super::super::event_swap_id(event); + if let ChainEvent::Commitment(c) = event { + // If its a commitment we need to cache to react when its time to unlock + self.cache_commitment(c); + } + // Queue the refund + super::super::queue_dequeue_refund_event( + &mut self.pending_refunds.lock(), + event, + self.participate_ccr, + ); + // If its a resolving event + if is_resolving(event) { + // It means we need to remove and untrack this event from respective trackers + self.queue.record_success(ActionKey::claim(swap_id)); + self.queue.record_success(ActionKey::refund(swap_id)); + self.pending_claims.lock().retain(|c| c.swap_id != swap_id); + self.commitments.lock().remove(&swap_id); + self.scripts.lock().remove(&swap_id); + } + // Then as usual sync it to disk + self.persist_swap(swap_id); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use stroemnet_protocol::v1::{RefundV1, RevealV1}; + + #[test] + fn event_swap_id_and_resolving_classification() { + let reveal = ChainEvent::Reveal(RevealV1 { + swap_id: [1u8; 32], + secret: [0u8; 32], + }); + let refund = ChainEvent::Refund(RefundV1 { swap_id: [2u8; 32] }); + assert_eq!(crate::chains::event_swap_id(&reveal), [1u8; 32]); + assert_eq!(crate::chains::event_swap_id(&refund), [2u8; 32]); + assert!(is_resolving(&reveal)); + assert!(is_resolving(&refund)); + } +} diff --git a/crates/data/src/chains/kaspa/poll.rs b/crates/data/src/chains/kaspa/poll.rs new file mode 100644 index 0000000..cc90bff --- /dev/null +++ b/crates/data/src/chains/kaspa/poll.rs @@ -0,0 +1,69 @@ +use std::sync::Arc; + +use kaspa_wrpc_client::prelude::RpcBlock; +use stroemnet_protocol::ChannelId; +use stroemnet_protocol::now_unix_secs; +use stroemnet_protocol::v1::ChainEvent; + +use super::Kaspa; +use super::decode; +use crate::Result; +use crate::chains::push_pending_refund; +use crate::chains::settlement::ActionKey; + +impl Kaspa { + /// Poll finalized blocks from the rpc + pub(super) async fn poll_finalized(&self) -> Result> { + let blocks: Vec> = { + // Get a lock on safe blocks, and receive them + let mut rx = self.safe_blocks.lock(); + let mut v = Vec::new(); + while let Ok(block) = rx.try_recv() { + v.push(block); + } + v + }; + + let mut events = Vec::new(); + // Go over all blocks + for block in blocks { + // Handle all blocks and store their outcomes + let outcomes = decode::handle_block_added( + &block, + &self.utxo_scripts, + self.prefix, + self.channel_id, + ) + .await?; + if self.participate_ccr { + // If we participate in CCR + let mut pushed = Vec::new(); + { + // If there are any refunds we track them and will try to refund them once they expire + let mut pending = self.pending_refunds.lock(); + for (swap_id, unlock_ts) in outcomes.refunds { + if push_pending_refund(&mut pending, swap_id, unlock_ts) { + pushed.push(swap_id); + } + } + } + // We populate the refunds in the retry queue and then also sync to disk + for swap_id in pushed { + self.queue + .ensure(ActionKey::refund(swap_id), now_unix_secs()); + self.persist_swap(swap_id); + } + } + // For all other events we track them depending on what they are then add to the DS + for event in outcomes.events { + self.track_actionable_event(&event); + events.push((self.channel_id, event)); + } + } + + // After this we prune scripts that are expired + self.prune_scripts().await; + // Return events + Ok(events) + } +} diff --git a/crates/data/src/chains/kaspa/reconcile.rs b/crates/data/src/chains/kaspa/reconcile.rs new file mode 100644 index 0000000..dc2330f --- /dev/null +++ b/crates/data/src/chains/kaspa/reconcile.rs @@ -0,0 +1,43 @@ +use kaspa_addresses::Address; +use kaspa_rpc_core::api::rpc::RpcApi; + +use super::Kaspa; +use super::signing; +use crate::chains::net::retry_timed; +use crate::chains::settlement::{ActionKey, Observation}; + +impl Kaspa { + /// Take an action key and compute at which state a swap exists + pub(super) async fn observe_onchain(&self, key: ActionKey) -> Observation { + // Try to get the commitment + let Some(commitment) = self.commitment(&key.swap_id) else { + return Observation::Unknown; + }; + + // Try to get the p2sh address and redeem script + let Ok((address, _redeem)) = signing::p2sh_components(&self.network_id, &commitment) else { + return Observation::Unknown; + }; + + // Try to compute the address + let Ok(addr) = Address::try_from(address.as_str()) else { + return Observation::Unknown; + }; + let client = self.client.clone(); + let utxos = match retry_timed("observe utxos", || { + client.get_utxos_by_addresses(vec![addr.clone()]) + }) + .await + { + Some(u) => u, + None => return Observation::Unknown, + }; + if utxos.is_empty() { + Observation::Unknown + } else { + // if there are utxos then it means its not settled + Observation::NotSettled + } + // But we can never confirm its settled here as kaspa is a utxo based platform + } +} diff --git a/crates/data/src/chains/kaspa/settle.rs b/crates/data/src/chains/kaspa/settle.rs new file mode 100644 index 0000000..1f771ab --- /dev/null +++ b/crates/data/src/chains/kaspa/settle.rs @@ -0,0 +1,73 @@ +use kaspa_rpc_core::api::rpc::RpcApi; + +use super::Kaspa; +use super::broadcast; +use crate::chains::net::retry_timed; +use crate::chains::settlement::SettleOutcome; + +impl Kaspa { + /// Settles a refund based on a swap id and returns a settlement outcome + pub(super) async fn settle_refund(&self, swap_id: [u8; 32]) -> SettleOutcome { + // Retrieve the commitment and the private key needed in order to sign for the transaction + let (Some(commitment), Ok(pk)) = (self.commitment(&swap_id), self.key()) else { + return SettleOutcome::Fatal("missing commitment or key".into()); + }; + + // Retrieve dag info + let client = self.client.clone(); + let pmt = match retry_timed("settle dag_info", || client.get_block_dag_info()).await { + Some(info) => info.past_median_time, + None => return SettleOutcome::Retry("dag_info_timeout"), + }; + + // Check if the past median time has passed the unlock timestamp + if pmt <= commitment.unlock_ts.saturating_mul(1000) { + return SettleOutcome::Retry("not_yet_unlocked"); + } + + // If its unlocked we can try and claim it + match broadcast::submit_refund(&self.client, pk, self.coinbase_maturity, &commitment).await + { + Ok(_) => SettleOutcome::Retry("submitted_awaiting_inclusion"), + Err(e) => { + tracing::warn!(target: "settlement", "kaspa refund broadcast: {e}"); + SettleOutcome::Retry("broadcast_error") + } + } + } + + /// Settles a claim based on a swap id + pub(super) async fn settle_claim(&self, swap_id: [u8; 32]) -> SettleOutcome { + // Retrieve the commitment and the private key that we will use to sign for the tx + let (Some(commitment), Ok(pk)) = (self.commitment(&swap_id), self.key()) else { + return SettleOutcome::Fatal("missing commitment or key".into()); + }; + + // Check if we have a reveal v1 struct so that we can submit the reveal ourselves + let Some(reveal) = self + .pending_claims + .lock() + .iter() + .find(|c| c.swap_id == swap_id) + .cloned() + else { + return SettleOutcome::Retry("no_reveal"); + }; + // Now that we have the details we can submit the reveal onchain + match broadcast::submit_reveal( + &self.client, + pk, + self.coinbase_maturity, + &commitment, + &reveal, + ) + .await + { + Ok(()) => SettleOutcome::Retry("submitted_awaiting_inclusion"), + Err(e) => { + tracing::warn!(target: "settlement", "kaspa claim broadcast: {e}"); + SettleOutcome::Retry("broadcast_error") + } + } + } +} diff --git a/crates/data/src/chains/kaspa/settler.rs b/crates/data/src/chains/kaspa/settler.rs new file mode 100644 index 0000000..18e4b05 --- /dev/null +++ b/crates/data/src/chains/kaspa/settler.rs @@ -0,0 +1,71 @@ +use super::Kaspa; +use crate::chains::settlement::{ + Action, ActionKey, Observation, SettleFut, SettleOutcome, Settler, +}; + +/// Compute a random u64 in order to shift the retry a little bit +fn jitter(key: ActionKey) -> u64 { + key.swap_id.iter().map(|b| u64::from(*b)).sum() +} + +impl Settler for Kaspa { + /// Retrieve the action keys that are due now + fn due_now(&self, now: u64) -> Vec { + self.queue.due_now(now) + } + + /// Settle an achtion key based on the type of action + fn settle(&self, key: ActionKey) -> SettleFut<'_, SettleOutcome> { + Box::pin(async move { + match key.action { + Action::Refund => self.settle_refund(key.swap_id).await, + Action::Claim => self.settle_claim(key.swap_id).await, + } + }) + } + + /// Compute observation based on action key + fn observe(&self, key: ActionKey) -> SettleFut<'_, Observation> { + Box::pin(async move { self.observe_onchain(key).await }) + } + + /// Marks this key as completed and settle which means we should remove it + /// from pending claims and refunds + fn record_success(&self, key: ActionKey) { + self.queue.record_success(key); + match key.action { + Action::Claim => self + .pending_claims + .lock() + .retain(|c| c.swap_id != key.swap_id), + Action::Refund => self + .pending_refunds + .lock() + .retain(|(r, _)| r.swap_id != key.swap_id), + } + // Sync to disk + self.persist_swap(key.swap_id); + } + + /// Record failure which will also retry the key in some jitter + fn record_failure(&self, key: ActionKey, now: u64) { + self.queue.record_failure(key, now, jitter(key)); + self.persist_swap(key.swap_id); + } + + /// Check if an action key is stuck + fn is_stuck(&self, key: ActionKey, now: u64) -> bool { + self.queue.is_stuck(key, now) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn jitter_is_deterministic_sum_of_swap_id_bytes() { + assert_eq!(jitter(ActionKey::claim([1u8; 32])), 32); + assert_eq!(jitter(ActionKey::refund([0u8; 32])), 0); + } +} diff --git a/crates/data/src/chains/kaspa/signing.rs b/crates/data/src/chains/kaspa/signing.rs deleted file mode 100644 index 896d55c..0000000 --- a/crates/data/src/chains/kaspa/signing.rs +++ /dev/null @@ -1,271 +0,0 @@ -use k256::schnorr::signature::{Signer, Verifier}; -use k256::schnorr::{Signature, SigningKey, VerifyingKey}; -use kaspa_addresses::{Address, Prefix, Version}; -use kaspa_consensus_core::hashing::sighash::SigHashReusedValuesUnsync; -use kaspa_consensus_core::tx::ScriptPublicKey; -use kaspa_rpc_core::api::rpc::RpcApi; -use kaspa_txscript::opcodes::{OpCodeImplementation, deserialize_next_opcode}; -use kaspa_txscript::{ - extract_script_pub_key_address, pay_to_address_script, pay_to_script_hash_script, -}; -use kaspa_wrpc_client::KaspaRpcClient; -use stroemnet_protocol::ChannelId; -use stroemnet_protocol::v1::CommitmentV1; - -use crate::ProposalVerification; - -use super::contracts::contract_v1::{ - VerifiableTransactionMock, create_htlc_script, extract_commitment, -}; -use super::error::{KaspaError, Result}; - -/// Compute the network prefix from a string to a network prefix enum -fn parse_network_prefix(network_id: &str) -> Result { - match network_id { - s if s.starts_with("mainnet") => Ok(Prefix::Mainnet), - s if s.starts_with("testnet") => Ok(Prefix::Testnet), - s if s.starts_with("simnet") => Ok(Prefix::Simnet), - s if s.starts_with("devnet") => Ok(Prefix::Devnet), - other => Err(KaspaError::Other(format!( - "unknown kaspa network id: {other}" - ))), - } -} - -/// Convert a ScriptPublicKey to the byte format expected by the HTLC script builder -fn spk_to_bytes(spk: &ScriptPublicKey) -> Vec { - let mut out = Vec::with_capacity(2 + spk.script().len()); - out.extend_from_slice(&spk.version.to_be_bytes()); - out.extend_from_slice(spk.script()); - out -} - -/// Compute a signing key from a hex-encoded private key string -fn signing_key(private_key: &str) -> Result { - let secret_bytes = hex::decode(private_key.trim_start_matches("0x")) - .map_err(|e| KaspaError::Other(format!("private key hex: {e}")))?; - SigningKey::from_bytes(&secret_bytes) - .map_err(|e| KaspaError::Other(format!("schnorr signing key: {e}"))) -} - -/// Compute the public key bytes from a signing key -fn pubkey_bytes(key: &SigningKey) -> Result<[u8; 32]> { - key.verifying_key() - .to_bytes() - .as_slice() - .try_into() - .map_err(|_| KaspaError::Other("verifying key not 32 bytes".into())) -} - -/// Derive the LP's address from the private key and network ID -pub(super) fn lp_address_from_private_key(network_id: &str, private_key: &str) -> Result { - // retrieve the signing key from the provided private key string - let key = signing_key(private_key)?; - let prefix = parse_network_prefix(network_id)?; - // construct the address from the public key and network prefix - Ok(Address::new(prefix, Version::PubKey, &pubkey_bytes(&key)?).to_string()) -} - -/// Queries the balance of the given address by summing the amounts of all UTXOs associated with it -async fn query_balance(client: &KaspaRpcClient, address: &str) -> Result { - let parsed_addr = Address::try_from(address) - .map_err(|e| KaspaError::Other(format!("invalid kaspa address {address}: {e}")))?; - let utxos = client - .get_utxos_by_addresses(vec![parsed_addr]) - .await - .map_err(|e| KaspaError::Other(format!("get_utxos_by_addresses: {e}")))?; - Ok(utxos.into_iter().map(|u| u.utxo_entry.amount).sum()) -} - -/// Signs a message and simultaneously verifies that the address in question -/// has sufficient balance to cover the required amount, which is a prerequisite for the LP's commitment to be valid. -pub(super) async fn sign_message( - client: &KaspaRpcClient, - network_id: &str, - private_key: &str, - digest: [u8; 32], - required_balance: u64, -) -> Result<(String, Vec)> { - // Compute the signing key - let key = signing_key(private_key)?; - - // Parse network prefix - let prefix = parse_network_prefix(network_id)?; - - // Create a kaspa address - let address = Address::new(prefix, Version::PubKey, &pubkey_bytes(&key)?); - let address_str = address.to_string(); - - // Retrieve the balance and ensure it meets the required threshold for the commitment - let balance = query_balance(client, &address_str).await?; - if balance < required_balance { - return Err(KaspaError::Other(format!( - "insufficient balance at {address_str}: have {balance}, need {required_balance}" - ))); - } - - // Sign the message digest with the derived signing key - let signature: Signature = key.sign(&digest); - Ok((address_str, signature.to_bytes().to_vec())) -} - -/// Verifies the LP's signature against the provided digest and claimed address -fn verify_lp_signature( - digest: [u8; 32], - claimed_address: &str, - signature_bytes: &[u8], -) -> Result { - // Parse the claimed address - let claimed = Address::try_from(claimed_address) - .map_err(|e| KaspaError::Other(format!("invalid claimed address: {e}")))?; - - // ensure it is p2pk - if claimed.version != Version::PubKey { - return Err(KaspaError::Other(format!( - "claimed address is not P2PK (version={:?})", - claimed.version - ))); - } - - // Compute the pubkey - let pubkey: [u8; 32] = claimed.payload.as_slice().try_into().map_err(|_| { - KaspaError::Other(format!( - "claimed address payload is {} bytes, expected 32", - claimed.payload.len() - )) - })?; - - // Parse verifying key from the pubkey bytes and verify the signature against the digest - let verifying_key = VerifyingKey::from_bytes(&pubkey) - .map_err(|e| KaspaError::Other(format!("verifying key: {e}")))?; - - // Parse the signature bytes into a Schnorr signature and verify it against the digest using the verifying key - let signature = Signature::try_from(signature_bytes) - .map_err(|e| KaspaError::Other(format!("signature parse: {e}")))?; - Ok(verifying_key.verify(&digest, &signature).is_ok()) -} - -/// Verifies the LP's signature and checks that the claimed address has sufficient balance to cover the required amount -pub(super) async fn verify_message( - client: &KaspaRpcClient, - digest: [u8; 32], - claimed_address: &str, - signature_bytes: &[u8], - required_balance: u64, -) -> Result { - // First verify the signature to ensure the message was signed by the owner of the claimed address - let address_matches = verify_lp_signature(digest, claimed_address, signature_bytes)?; - // Then query the balance of the claimed address to ensure it meets the required threshold for the commitment - let balance = query_balance(client, claimed_address).await?; - Ok(ProposalVerification { - address_matches, - balance_sufficient: balance >= required_balance, - }) -} - -/// Computes the p2sh address and redeem script for a given commitment, -/// which are necessary for the LP to monitor the HTLC on-chain and react to events such as deposits or refunds -pub(super) fn p2sh_components( - network_id: &str, - commitment: &CommitmentV1, -) -> Result<(String, Vec)> { - // Parse the prefix for this network - let prefix = parse_network_prefix(network_id)?; - - // Parse the sender and receiver addresses from the commitment and convert them to script public keys - let sender_addr = Address::try_from(commitment.addresses.sender.as_str()) - .map_err(|e| KaspaError::Other(format!("sender address: {e}")))?; - let receiver_addr = Address::try_from(commitment.addresses.receiver.as_str()) - .map_err(|e| KaspaError::Other(format!("receiver address: {e}")))?; - let sender_spk = pay_to_address_script(&sender_addr); - let receiver_spk = pay_to_address_script(&receiver_addr); - - // Compute the unlock timestamp as milliseconds - let unlock_ts_ms = commitment.unlock_ts.saturating_mul(1000); - - // Create the redeem script for the HTLC using the provided parameters from the commitment - let redeem_script = create_htlc_script( - &spk_to_bytes(&sender_spk), - commitment.addresses.sender_destination.as_bytes(), - &spk_to_bytes(&receiver_spk), - &commitment.secret_hash, - unlock_ts_ms, - commitment.destination, - commitment.swap_id, - ) - .map_err(|e| KaspaError::ScriptBuilder(format!("{e:?}")))?; - - // now compute the spk p2sh for the redeemscript/htlc script - let p2sh_spk = pay_to_script_hash_script(&redeem_script); - // Now derive the address so that the LP can monitor it on-chain for deposits and react accordingly - let p2sh_addr = extract_script_pub_key_address(&p2sh_spk, prefix) - .map_err(|e| KaspaError::Other(format!("p2sh address derive: {e:?}")))?; - - // return - Ok((p2sh_addr.to_string(), redeem_script)) -} - -/// Validate that a redeem script corresponds to the expected -/// swap id, expiration time, and announced address, which is crucial for the LP to ensure -/// that the HTLC they are monitoring on-chain matches the terms of -/// the off-chain commitment they have agreed to with the counterparty. -pub(super) fn validate_script_announce( - network_id: &str, - channel_id: ChannelId, - announced_address: &str, - redeem_script: &[u8], - expected_swap_id: [u8; 32], - expected_expiration_secs: u64, -) -> Result<()> { - // Parse the network prefix for address derivation - let prefix = parse_network_prefix(network_id)?; - // Create an iterator and a container for all the opcodes parsed from the redeem script - let mut iter = redeem_script.iter(); - let mut opcodes: Vec< - Box>, - > = Vec::new(); - - // Parse the redeem script into its constituent opcodes, - // which allows us to analyze the structure of the script and extract the relevant information for validation - while let Some(parsed) = deserialize_next_opcode(&mut iter) { - opcodes.push(parsed.map_err(KaspaError::TxScript)?); - } - - // Attempt to extract a commitment from the parsed opcodes - // the "0" amount is a placeholder since the redeem script itself does not contain the amount - // but it does contain the swap id, unlock timestamp, and other relevant information - // todo: in the future the part that is extractable from redeem script should be isolated as a sub-struct - // within commitmentv1. - let extracted = extract_commitment(&opcodes, "0".to_string(), prefix, channel_id)?; - - // validate swap id - if extracted.swap_id != expected_swap_id { - return Err(KaspaError::ScriptAnnounceSwapIdMismatch( - extracted.swap_id, - expected_swap_id, - )); - } - - // validate unlock timestamp for the swap - if extracted.unlock_ts != expected_expiration_secs { - return Err(KaspaError::ScriptAnnounceTimelockMismatch { - script_secs: extracted.unlock_ts, - announced_secs: expected_expiration_secs, - }); - } - - // Compute the p2sh address from the redeem script and validate that it matches the announced address, - let p2sh_spk = pay_to_script_hash_script(redeem_script); - let derived = extract_script_pub_key_address(&p2sh_spk, prefix) - .map_err(|e| KaspaError::Other(format!("p2sh derive: {e:?}")))?; - let derived_str = derived.to_string(); - - // Ensure the address matches what was announced by the counterparty or us. - if derived_str != announced_address { - return Err(KaspaError::ScriptAnnounceAddressMismatch { - announced: announced_address.to_string(), - derived: derived_str, - }); - } - Ok(()) -} diff --git a/crates/data/src/chains/kaspa/signing/keys.rs b/crates/data/src/chains/kaspa/signing/keys.rs new file mode 100644 index 0000000..c9b9f4a --- /dev/null +++ b/crates/data/src/chains/kaspa/signing/keys.rs @@ -0,0 +1,65 @@ +use k256::schnorr::SigningKey; +use kaspa_addresses::{Address, Prefix, Version}; + +use super::super::error::{KaspaError, Result}; + +/// Parse a string network id to some prefix +pub(super) fn parse_network_prefix(network_id: &str) -> Result { + match network_id { + s if s.starts_with("mainnet") => Ok(Prefix::Mainnet), + s if s.starts_with("testnet") => Ok(Prefix::Testnet), + s if s.starts_with("simnet") => Ok(Prefix::Simnet), + s if s.starts_with("devnet") => Ok(Prefix::Devnet), + other => Err(KaspaError::Other(format!( + "unknown kaspa network id: {other}" + ))), + } +} + +/// Compute a k256 signing key from private key +pub(crate) fn signing_key(private_key: &str) -> Result { + let secret_bytes = hex::decode(private_key.trim_start_matches("0x")) + .map_err(|e| KaspaError::Other(format!("private key hex: {e}")))?; + SigningKey::from_bytes(&secret_bytes) + .map_err(|e| KaspaError::Other(format!("schnorr signing key: {e}"))) +} + +/// Retrieve pubkey bytes from a signing key +pub(crate) fn pubkey_bytes(key: &SigningKey) -> Result<[u8; 32]> { + key.verifying_key() + .to_bytes() + .as_slice() + .try_into() + .map_err(|_| KaspaError::Other("verifying key not 32 bytes".into())) +} + +/// Compute lp address from a private key converting the lp address to string +pub(crate) fn lp_address_from_private_key(network_id: &str, private_key: &str) -> Result { + let key = signing_key(private_key)?; + let prefix = parse_network_prefix(network_id)?; + Ok(Address::new(prefix, Version::PubKey, &pubkey_bytes(&key)?).to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_prefix_known_and_unknown() { + assert!(matches!( + parse_network_prefix("testnet-10"), + Ok(Prefix::Testnet) + )); + assert!(matches!( + parse_network_prefix("mainnet"), + Ok(Prefix::Mainnet) + )); + assert!(parse_network_prefix("bogus").is_err()); + } + + #[test] + fn lp_address_is_derivable() { + let pk = "0101010101010101010101010101010101010101010101010101010101010101"; + assert!(lp_address_from_private_key("testnet-10", pk).is_ok()); + } +} diff --git a/crates/data/src/chains/kaspa/signing/message.rs b/crates/data/src/chains/kaspa/signing/message.rs new file mode 100644 index 0000000..94ea2bf --- /dev/null +++ b/crates/data/src/chains/kaspa/signing/message.rs @@ -0,0 +1,132 @@ +use k256::schnorr::signature::{Signer, Verifier}; +use k256::schnorr::{Signature, VerifyingKey}; +use kaspa_addresses::{Address, Version}; +use kaspa_rpc_core::api::rpc::RpcApi; +use kaspa_wrpc_client::KaspaRpcClient; + +use super::super::error::{KaspaError, Result}; +use super::keys::{parse_network_prefix, pubkey_bytes, signing_key}; +use crate::ProposalVerification; + +/// Query the balance of some address accumulating all their utxos +async fn query_balance(client: &KaspaRpcClient, address: &str) -> Result { + let parsed_addr = Address::try_from(address) + .map_err(|e| KaspaError::Other(format!("invalid kaspa address {address}: {e}")))?; + let utxos = client + .get_utxos_by_addresses(vec![parsed_addr]) + .await + .map_err(|e| KaspaError::Other(format!("get_utxos_by_addresses: {e}")))?; + Ok(utxos.into_iter().map(|u| u.utxo_entry.amount).sum()) +} + +/// Sign a message i.e. a swap ensuring the balance has the required balance in order to +/// fulfill the swap +pub(crate) async fn sign_message( + client: &KaspaRpcClient, + network_id: &str, + private_key: &str, + digest: [u8; 32], + required_balance: u64, +) -> Result<(String, Vec)> { + // Retrieve the signing key + let key = signing_key(private_key)?; + // Compute the network prefix + let prefix = parse_network_prefix(network_id)?; + + // Compute the address + let address = Address::new(prefix, Version::PubKey, &pubkey_bytes(&key)?); + let address_str = address.to_string(); + + // Ensure that the address has the required minimum amount + let balance = query_balance(client, &address_str).await?; + + // If not we failed this signing + if balance < required_balance { + return Err(KaspaError::Other(format!( + "insufficient balance at {address_str}: have {balance}, need {required_balance}" + ))); + } + + let signature: Signature = key.sign(&digest); + Ok((address_str, signature.to_bytes().to_vec())) +} + +/// Verify the LP's signature +fn verify_lp_signature( + digest: [u8; 32], + claimed_address: &str, + signature_bytes: &[u8], +) -> Result { + // Compute the address which they claim to be + let claimed = Address::try_from(claimed_address) + .map_err(|e| KaspaError::Other(format!("invalid claimed address: {e}")))?; + + // We can only validate p2pk + if claimed.version != Version::PubKey { + return Err(KaspaError::Other(format!( + "claimed address is not P2PK (version={:?})", + claimed.version + ))); + } + + // Compute the pubkey + let pubkey: [u8; 32] = claimed.payload.as_slice().try_into().map_err(|_| { + KaspaError::Other(format!( + "claimed address payload is {} bytes, expected 32", + claimed.payload.len() + )) + })?; + + // Compute verifying key + let verifying_key = VerifyingKey::from_bytes(&pubkey) + .map_err(|e| KaspaError::Other(format!("verifying key: {e}")))?; + + // Verify that the signature is valid for claimed address + let signature = Signature::try_from(signature_bytes) + .map_err(|e| KaspaError::Other(format!("signature parse: {e}")))?; + Ok(verifying_key.verify(&digest, &signature).is_ok()) +} + +/// Verify a message from an LP whilst also ensuring that is has enough balance +/// to cover the swap +pub(crate) async fn verify_message( + client: &KaspaRpcClient, + digest: [u8; 32], + claimed_address: &str, + signature_bytes: &[u8], + required_balance: u64, +) -> Result { + let address_matches = verify_lp_signature(digest, claimed_address, signature_bytes)?; + let balance = query_balance(client, claimed_address).await?; + Ok(ProposalVerification { + address_matches, + balance_sufficient: balance >= required_balance, + }) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::super::keys::{pubkey_bytes, signing_key}; + use super::verify_lp_signature; + use k256::schnorr::Signature; + use k256::schnorr::signature::Signer; + use kaspa_addresses::{Address, Prefix, Version}; + + #[test] + fn lp_signature_roundtrips() { + let key = signing_key("0101010101010101010101010101010101010101010101010101010101010101") + .unwrap(); + let digest = [7u8; 32]; + let sig: Signature = key.sign(&digest); + let addr = Address::new( + Prefix::Testnet, + Version::PubKey, + &pubkey_bytes(&key).unwrap(), + ); + assert!(verify_lp_signature(digest, &addr.to_string(), &sig.to_bytes()).unwrap()); + + let other = Address::new(Prefix::Testnet, Version::PubKey, &[9u8; 32]); + assert!(!verify_lp_signature(digest, &other.to_string(), &sig.to_bytes()).unwrap()); + } +} diff --git a/crates/data/src/chains/kaspa/signing/mod.rs b/crates/data/src/chains/kaspa/signing/mod.rs new file mode 100644 index 0000000..536c862 --- /dev/null +++ b/crates/data/src/chains/kaspa/signing/mod.rs @@ -0,0 +1,7 @@ +mod keys; +mod message; +mod p2sh; + +pub(super) use keys::{lp_address_from_private_key, pubkey_bytes, signing_key}; +pub(super) use message::{sign_message, verify_message}; +pub(super) use p2sh::{p2sh_components, validate_script_announce}; diff --git a/crates/data/src/chains/kaspa/signing/p2sh.rs b/crates/data/src/chains/kaspa/signing/p2sh.rs new file mode 100644 index 0000000..0ae1148 --- /dev/null +++ b/crates/data/src/chains/kaspa/signing/p2sh.rs @@ -0,0 +1,162 @@ +use kaspa_addresses::Address; +use kaspa_consensus_core::hashing::sighash::SigHashReusedValuesUnsync; +use kaspa_txscript::opcodes::{OpCodeImplementation, deserialize_next_opcode}; +use kaspa_txscript::{ + extract_script_pub_key_address, pay_to_address_script, pay_to_script_hash_script, +}; +use stroemnet_protocol::ChannelId; +use stroemnet_protocol::v1::CommitmentV1; + +use super::super::broadcast::spk_to_vec; +use super::super::contracts::{VerifiableTransactionMock, create_htlc_script, extract_commitment}; +use super::super::error::{KaspaError, Result, script_err}; +use super::keys::parse_network_prefix; + +/// Compute the p2sh address and redeem script from a network id and commitment v1 +pub(crate) fn p2sh_components( + network_id: &str, + commitment: &CommitmentV1, +) -> Result<(String, Vec)> { + // compute htlc prefix + let prefix = parse_network_prefix(network_id)?; + + // Compute sender receiver spk + let sender_addr = Address::try_from(commitment.addresses.sender.as_str()) + .map_err(|e| KaspaError::Other(format!("sender address: {e}")))?; + let receiver_addr = Address::try_from(commitment.addresses.receiver.as_str()) + .map_err(|e| KaspaError::Other(format!("receiver address: {e}")))?; + let sender_spk = pay_to_address_script(&sender_addr); + let receiver_spk = pay_to_address_script(&receiver_addr); + + // Conver unlock timestamp to milliseconds + let unlock_ts_ms = commitment.unlock_ts.saturating_mul(1000); + + // Create the redeem script + let redeem_script = create_htlc_script( + &spk_to_vec(&sender_spk), + commitment.addresses.sender_destination.as_bytes(), + &spk_to_vec(&receiver_spk), + &commitment.secret_hash, + unlock_ts_ms, + commitment.destination, + commitment.swap_id, + ) + .map_err(script_err)?; + + // Compute the p2sh address + let p2sh_spk = pay_to_script_hash_script(&redeem_script); + let p2sh_addr = extract_script_pub_key_address(&p2sh_spk, prefix) + .map_err(|e| KaspaError::Other(format!("p2sh address derive: {e:?}")))?; + + // Return it + Ok((p2sh_addr.to_string(), redeem_script)) +} + +/// Validate that an announced script matches the redeem script +pub(crate) fn validate_script_announce( + network_id: &str, + channel_id: ChannelId, + announced_address: &str, + redeem_script: &[u8], + expected_swap_id: [u8; 32], + expected_expiration_secs: u64, +) -> Result<()> { + // Parse the network prefix + let prefix = parse_network_prefix(network_id)?; + let mut iter = redeem_script.iter(); + let mut opcodes: Vec< + Box>, + > = Vec::new(); + + // Parse all the opcodes + while let Some(parsed) = deserialize_next_opcode(&mut iter) { + opcodes.push(parsed.map_err(KaspaError::TxScript)?); + } + + // Attempt to extract the commitment + let extracted = extract_commitment(&opcodes, "0".to_string(), prefix, channel_id)?; + + // Ensure the swap id + if extracted.swap_id != expected_swap_id { + return Err(KaspaError::ScriptAnnounceSwapIdMismatch( + extracted.swap_id, + expected_swap_id, + )); + } + + // Ensure the unlock timestamp + if extracted.unlock_ts != expected_expiration_secs { + return Err(KaspaError::ScriptAnnounceTimelockMismatch { + script_secs: extracted.unlock_ts, + announced_secs: expected_expiration_secs, + }); + } + + // Compute the p2sh address + let p2sh_spk = pay_to_script_hash_script(redeem_script); + let derived = extract_script_pub_key_address(&p2sh_spk, prefix) + .map_err(|e| KaspaError::Other(format!("p2sh derive: {e:?}")))?; + let derived_str = derived.to_string(); + + // Ensure that the script derived address matches the one in the announcement + if derived_str != announced_address { + return Err(KaspaError::ScriptAnnounceAddressMismatch { + announced: announced_address.to_string(), + derived: derived_str, + }); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + use kaspa_addresses::{Prefix, Version}; + use stroemnet_protocol::v1::{AddressesV1, AmountV1}; + + fn commitment() -> CommitmentV1 { + let sender = Address::new(Prefix::Testnet, Version::PubKey, &[1u8; 32]).to_string(); + let receiver = Address::new(Prefix::Testnet, Version::PubKey, &[2u8; 32]).to_string(); + CommitmentV1 { + swap_id: [3u8; 32], + addresses: AddressesV1::new(sender, receiver, "0xdest".into()), + amount: AmountV1::new("0".into(), 8), + secret_hash: [4u8; 32], + unlock_ts: 1000, + source: ChannelId::KaspaTn10 as u8, + destination: ChannelId::EthereumSepolia as u8, + } + } + + #[test] + fn p2sh_components_roundtrip_validates() { + let (addr, redeem) = p2sh_components("testnet-10", &commitment()).unwrap(); + assert!(!redeem.is_empty()); + validate_script_announce( + "testnet-10", + ChannelId::KaspaTn10, + &addr, + &redeem, + [3u8; 32], + 1000, + ) + .unwrap(); + } + + #[test] + fn validate_rejects_swap_id_mismatch() { + let (addr, redeem) = p2sh_components("testnet-10", &commitment()).unwrap(); + assert!( + validate_script_announce( + "testnet-10", + ChannelId::KaspaTn10, + &addr, + &redeem, + [9u8; 32], + 1000, + ) + .is_err() + ); + } +} diff --git a/crates/data/src/chains/kaspa/test_helpers.rs b/crates/data/src/chains/kaspa/test_helpers.rs index 72c5573..b80d335 100644 --- a/crates/data/src/chains/kaspa/test_helpers.rs +++ b/crates/data/src/chains/kaspa/test_helpers.rs @@ -1,3 +1,10 @@ +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing +)] + use kaspa_addresses::{Address, Prefix, Version}; use kaspa_consensus_core::tx::ScriptPublicKey; use kaspa_txscript::pay_to_address_script; diff --git a/crates/data/src/chains/mod.rs b/crates/data/src/chains/mod.rs index ef3e7b3..16befd3 100644 --- a/crates/data/src/chains/mod.rs +++ b/crates/data/src/chains/mod.rs @@ -1,35 +1,42 @@ pub(crate) mod evm; pub(crate) mod kaspa; +pub(crate) mod net; +pub(crate) mod record; +pub(crate) mod settlement; use serde_json::Value; use stroemnet_protocol::ChannelId; -use stroemnet_protocol::v1::{ChainEvent, RefundV1}; +use stroemnet_protocol::v1::{ChainEvent, RefundV1, RevealV1}; use std::sync::Arc; use crate::chains::evm::Evm; use crate::chains::kaspa::Kaspa; -use crate::{ChainDataBuffer, CursorStore, Result}; +use crate::chains::settlement::SettlementMetrics; +use crate::{ChainDataBuffer, CursorStore, Result, SwapStore}; -/// A factory function to build a chain data buffer based on the channel ID and configuration provided. +/// Build data buffer for a particular channel id given some parameters pub(crate) async fn build_buffer( - channel_id: ChannelId, - cfg: &Value, - lp_key: Option, - cursor_store: Option>, + channel_id: ChannelId, // channel id + cfg: &Value, // configuration + lp_key: Option, // private key if they are lp or will do ccr + cursor_store: Option>, // trait backed store + swap_store: Option>, // trait backed store + metrics: Option>, // stats ) -> Result> { match channel_id { ChannelId::EthereumSepolia | ChannelId::IgraGalleon => Ok(Box::new( - Evm::connect(channel_id, cfg, lp_key, cursor_store).await?, + // these are all evm chains + Evm::connect(channel_id, cfg, lp_key, cursor_store, swap_store, metrics).await?, )), ChannelId::KaspaTn10 => Ok(Box::new( - Kaspa::connect(channel_id, cfg, lp_key, cursor_store).await?, + // kaspa tn10 is a kaspa network + Kaspa::connect(channel_id, cfg, lp_key, cursor_store, swap_store, metrics).await?, )), } } -/// Used to either queue or dequeue a refund event -/// by matching the inner chain events to the pending refunds and the commitment events that trigger them +/// Queue or deqeueu a refund event based on the received chain event pub(crate) fn queue_dequeue_refund_event( pending: &mut Vec<(RefundV1, u64)>, event: &ChainEvent, @@ -37,15 +44,43 @@ pub(crate) fn queue_dequeue_refund_event( ) { match event { ChainEvent::Commitment(c) => { - // Since this is a commitment we track it if we participate in ccr - // and if we already dont have it + // means we need to schedule refund if participate_ccr && !pending.iter().any(|(r, _)| r.swap_id == c.swap_id) { pending.push((RefundV1::new(c.swap_id), c.unlock_ts)); } } - // if we see any of this we remove any tracked refunds for the swap id since - // it means the swap has been resolved on-chain + // Either of these means that its finalized and we should remove the scheduling here. ChainEvent::Reveal(r) => pending.retain(|(p, _)| p.swap_id != r.swap_id), ChainEvent::Refund(r) => pending.retain(|(p, _)| p.swap_id != r.swap_id), } } + +/// Converts a chain event to its corresponding swap id +pub(crate) fn event_swap_id(event: &ChainEvent) -> [u8; 32] { + match event { + ChainEvent::Commitment(c) => c.swap_id, + ChainEvent::Reveal(r) => r.swap_id, + ChainEvent::Refund(r) => r.swap_id, + } +} + +/// Adds a reveal to the pending queue if it does not exist there +pub(crate) fn push_pending_claim(pending: &mut Vec, reveal: &RevealV1) { + if !pending.iter().any(|c| c.swap_id == reveal.swap_id) { + pending.push(reveal.clone()); + } +} + +/// Adds a refund to pending if its not there yet +pub(crate) fn push_pending_refund( + pending: &mut Vec<(RefundV1, u64)>, + swap_id: [u8; 32], + unlock: u64, +) -> bool { + if pending.iter().any(|(p, _)| p.swap_id == swap_id) { + false + } else { + pending.push((RefundV1::new(swap_id), unlock)); + true + } +} diff --git a/crates/data/src/chains/net.rs b/crates/data/src/chains/net.rs new file mode 100644 index 0000000..89eeada --- /dev/null +++ b/crates/data/src/chains/net.rs @@ -0,0 +1,87 @@ +use std::fmt::Display; +use std::future::IntoFuture; +use std::time::Duration; + +pub(crate) const NETWORK_TIMEOUT: Duration = Duration::from_secs(30); +pub(crate) const RECEIPT_TIMEOUT: Duration = Duration::from_secs(120); +#[cfg(not(target_arch = "wasm32"))] +const RETRY_ATTEMPTS: u32 = 3; +#[cfg(not(target_arch = "wasm32"))] +const RETRY_BASE_MS: u64 = 400; + +#[cfg(not(target_arch = "wasm32"))] +/// Executes a future and races it against a timeout +pub(crate) async fn timed(dur: Duration, fut: F) -> Option { + tokio::time::timeout(dur, fut.into_future()).await.ok() +} + +#[cfg(target_arch = "wasm32")] +pub(crate) async fn timed(_dur: Duration, fut: F) -> Option { + Some(fut.into_future().await) +} + +#[cfg(not(target_arch = "wasm32"))] +/// Executes a future with a number of retry attempts with also a network timeout +pub(crate) async fn retry_timed(label: &str, mut op: Op) -> Option +where + Op: FnMut() -> Fut, + Fut: IntoFuture>, + E: Display, +{ + // Go over each attempt + for attempt in 0..RETRY_ATTEMPTS { + // race against timeout + match timed(NETWORK_TIMEOUT, op()).await { + Some(Ok(value)) => return Some(value), + Some(Err(e)) => { + tracing::warn!(target: "net", "{label} attempt {} error: {e}", attempt + 1) + } + None => tracing::warn!(target: "net", "{label} attempt {} timed out", attempt + 1), + } + if attempt + 1 < RETRY_ATTEMPTS { + tokio::time::sleep(Duration::from_millis(RETRY_BASE_MS << attempt)).await; + } + } + None +} + +#[cfg(target_arch = "wasm32")] +pub(crate) async fn retry_timed(_label: &str, mut op: Op) -> Option +where + Op: FnMut() -> Fut, + Fut: IntoFuture>, + E: Display, +{ + op().into_future().await.ok() +} + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + #![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing + )] + use super::*; + use std::sync::atomic::{AtomicU32, Ordering}; + + #[tokio::test] + async fn timed_returns_none_on_hang() { + let hang = std::future::pending::(); + assert!(timed(Duration::from_millis(10), hang).await.is_none()); + assert_eq!(timed(NETWORK_TIMEOUT, async { 7u8 }).await, Some(7)); + } + + #[tokio::test] + async fn retry_timed_recovers_after_transient_error() { + let calls = AtomicU32::new(0); + let got = retry_timed("op", || { + let n = calls.fetch_add(1, Ordering::SeqCst); + async move { if n == 0 { Err("transient") } else { Ok(99u8) } } + }) + .await; + assert_eq!(got, Some(99)); + assert_eq!(calls.load(Ordering::SeqCst), 2); + } +} diff --git a/crates/data/src/chains/record/attempt.rs b/crates/data/src/chains/record/attempt.rs new file mode 100644 index 0000000..1a3dd46 --- /dev/null +++ b/crates/data/src/chains/record/attempt.rs @@ -0,0 +1,101 @@ +use borsh::{BorshDeserialize, BorshSerialize}; + +#[cfg(not(target_arch = "wasm32"))] +const BASE_DELAY_SECS: u64 = 5; +#[cfg(not(target_arch = "wasm32"))] +const MAX_DELAY_SECS: u64 = 24 * 3600; +#[cfg(not(target_arch = "wasm32"))] +const JITTER_MAX_SECS: u64 = 60; +#[cfg(not(target_arch = "wasm32"))] +const MAX_BACKOFF_SHIFT: u32 = 13; +const DEADLINE_SECS: u64 = 7 * 24 * 3600; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +/// Tracks an attempt and its state in order to compute when an attempt should be retried +pub struct AttemptState { + /// First time we saw the attempt + pub first_seen: u64, + /// When is the next attempt + pub next_attempt_at: u64, + /// How many attempts for this + pub attempt_count: u32, + /// What is the deadline for this attempt record + pub deadline: u64, + /// Last nonce used for this attempt + pub nonce: Option, + /// Last gas used for this attempt + pub last_gas: Option, +} + +impl AttemptState { + /// Create a new attempt record + pub(crate) fn new(now: u64) -> Self { + Self { + first_seen: now, + next_attempt_at: now, + attempt_count: 0, + deadline: now.saturating_add(DEADLINE_SECS), + nonce: None, + last_gas: None, + } + } + + #[cfg(not(target_arch = "wasm32"))] + /// Check whether an attempt record is due by comparing now to when it should be retried + pub(crate) fn due(&self, now: u64) -> bool { + now >= self.next_attempt_at + } + + #[cfg(not(target_arch = "wasm32"))] + /// Check if a record has expired + pub(crate) fn expired(&self, now: u64) -> bool { + now >= self.deadline + } + + #[cfg(not(target_arch = "wasm32"))] + /// In the case that a record failed we need to delay it by a random jitter value + pub(crate) fn on_failure(&mut self, now: u64, jitter: u64) { + // Compute the shift whatever is smallest + let shift = self.attempt_count.min(MAX_BACKOFF_SHIFT); + let delay = BASE_DELAY_SECS + .saturating_mul(1u64 << shift) // multiply by the shift value + .min(MAX_DELAY_SECS) // the minimum of whatever + .saturating_add(jitter % JITTER_MAX_SECS); // random jitter value + self.attempt_count = self.attempt_count.saturating_add(1); // increment attempt count + self.next_attempt_at = now.saturating_add(delay); // update the next attempt at + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_is_due_now_and_expires_at_deadline() { + let s = AttemptState::new(1000); + assert!(s.due(1000)); + assert!(!s.expired(1000)); + assert!(s.expired(1000 + DEADLINE_SECS)); + assert!(!s.due(999)); + } + + #[test] + fn backoff_grows_then_caps_at_shift_bound() { + let mut s = AttemptState::new(0); + s.on_failure(0, 0); + assert_eq!(s.next_attempt_at, BASE_DELAY_SECS); + s.on_failure(0, 0); + assert_eq!(s.next_attempt_at, BASE_DELAY_SECS * 2); + for _ in 0..40 { + s.on_failure(0, 0); + } + assert_eq!(s.next_attempt_at, BASE_DELAY_SECS << MAX_BACKOFF_SHIFT); + } + + #[test] + fn jitter_stays_bounded() { + let mut s = AttemptState::new(0); + s.on_failure(0, 999); + assert!(s.next_attempt_at - BASE_DELAY_SECS < JITTER_MAX_SECS); + } +} diff --git a/crates/data/src/chains/record/codec.rs b/crates/data/src/chains/record/codec.rs new file mode 100644 index 0000000..b16192f --- /dev/null +++ b/crates/data/src/chains/record/codec.rs @@ -0,0 +1,92 @@ +use borsh::{BorshDeserialize, to_vec}; + +use super::error::RecordError; +use super::result::Result; +use crate::PersistedSwap; + +/// Magic number +const MAGIC: u8 = 0x53; +/// We only have one version for now +const VERSION_1: u8 = 1; + +/// An enum for either a persisted swap or corrupted record +pub(crate) enum DecodeOutcome { + Current(Box), + Corrupt(RecordError), +} + +/// Encode a swap to bytes +pub(crate) fn encode(swap: &PersistedSwap) -> Result> { + let body = to_vec(swap)?; + let mut out = Vec::with_capacity(body.len() + 2); + out.push(MAGIC); + out.push(VERSION_1); + out.extend_from_slice(&body); + Ok(out) +} + +/// Decode, match it by the magic number and try to decode it version safe +pub(crate) fn decode(bytes: &[u8]) -> DecodeOutcome { + match bytes.split_first() { + Some((&MAGIC, rest)) => decode_versioned(rest), + _ => DecodeOutcome::Corrupt(RecordError::Truncated), + } +} + +fn decode_versioned(rest: &[u8]) -> DecodeOutcome { + // Decode it based on the matching version + match rest.split_first() { + Some((&VERSION_1, body)) => match PersistedSwap::try_from_slice(body) { + Ok(swap) => DecodeOutcome::Current(Box::new(swap)), + Err(e) => DecodeOutcome::Corrupt(e.into()), + }, + Some((&other, _)) => DecodeOutcome::Corrupt(RecordError::UnknownVersion(other)), + None => DecodeOutcome::Corrupt(RecordError::Truncated), + } +} + +#[cfg(test)] +mod tests { + #![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing + )] + use super::*; + use stroemnet_protocol::v1::{RefundV1, RevealV1}; + + fn sample() -> PersistedSwap { + PersistedSwap { + script: None, + pending_refund: Some((RefundV1::new([3u8; 32]), 1700)), + pending_claim: Some(RevealV1::new([3u8; 32], [9u8; 32])), + claim_attempt: None, + refund_attempt: None, + } + } + + #[test] + fn current_roundtrips() { + let swap = sample(); + let bytes = encode(&swap).unwrap(); + assert_eq!(bytes[0], MAGIC); + assert_eq!(bytes[1], VERSION_1); + match decode(&bytes) { + DecodeOutcome::Current(got) => assert_eq!(got.pending_refund, swap.pending_refund), + _ => panic!("expected Current"), + } + } + + #[test] + fn corrupt_and_unknown_version_are_flagged_not_dropped() { + assert!(matches!( + decode(&[MAGIC, 9, 0, 0, 0, 0]), + DecodeOutcome::Corrupt(RecordError::UnknownVersion(9)) + )); + assert!(matches!( + decode(&[MAGIC]), + DecodeOutcome::Corrupt(RecordError::Truncated) + )); + } +} diff --git a/crates/data/src/chains/record/error.rs b/crates/data/src/chains/record/error.rs new file mode 100644 index 0000000..a9cd90b --- /dev/null +++ b/crates/data/src/chains/record/error.rs @@ -0,0 +1,9 @@ +#[derive(Debug, thiserror::Error)] +pub(crate) enum RecordError { + #[error("record decode: {0}")] + Decode(#[from] std::io::Error), + #[error("unknown record version {0}")] + UnknownVersion(u8), + #[error("record too short")] + Truncated, +} diff --git a/crates/data/src/chains/record/mod.rs b/crates/data/src/chains/record/mod.rs new file mode 100644 index 0000000..130db73 --- /dev/null +++ b/crates/data/src/chains/record/mod.rs @@ -0,0 +1,9 @@ +mod attempt; +mod codec; +pub(crate) mod error; +mod restore; +mod result; + +pub use attempt::AttemptState; +pub(crate) use codec::encode; +pub(crate) use restore::{RestoredSwaps, restore}; diff --git a/crates/data/src/chains/record/restore.rs b/crates/data/src/chains/record/restore.rs new file mode 100644 index 0000000..b70708a --- /dev/null +++ b/crates/data/src/chains/record/restore.rs @@ -0,0 +1,123 @@ +use std::sync::Arc; + +use ahash::AHashMap; +use stroemnet_protocol::ChannelId; +use stroemnet_protocol::v1::{RefundV1, RevealV1}; + +use super::codec::{DecodeOutcome, decode}; +use crate::{AttemptState, SwapStore, UtxoScript}; + +#[derive(Default)] +/// Swaps that have been restored from disk after a restart +pub(crate) struct RestoredSwaps { + /// Scripts keyed by swap id + pub scripts: AHashMap<[u8; 32], UtxoScript>, + /// Pending refund queue + pub pending_refunds: Vec<(RefundV1, u64)>, + /// Pending claim queue + pub pending_claims: Vec, + /// Claim attemps by swap id + pub claim_attempts: AHashMap<[u8; 32], AttemptState>, + /// Refund attempts by swap id + pub refund_attempts: AHashMap<[u8; 32], AttemptState>, +} + +/// Restore swaps from an existing swap store and channel id +pub(crate) fn restore( + swap_store: Option<&Arc>, + channel_id: ChannelId, +) -> RestoredSwaps { + let mut out = RestoredSwaps::default(); + // if we dont have a store we simply return the default empty struct + let Some(store) = swap_store else { + return out; + }; + // Go over the store and try to load the channel which will return all swaps there + for (swap_id, bytes) in store.load_channel(channel_id) { + let rec = match decode(&bytes) { + // attempt to decode + DecodeOutcome::Current(rec) => *rec, + DecodeOutcome::Corrupt(e) => { + tracing::error!( + target: "settlement", + "seed swap {} on {channel_id} corrupt, quarantined: {e}", + hex::encode(swap_id) + ); + store.quarantine(channel_id, swap_id, &bytes, &e.to_string()); + continue; + } + }; + + // Populate all fields if they exist + if let Some(s) = rec.script { + out.scripts.insert(swap_id, s); + } + if let Some(pr) = rec.pending_refund { + out.pending_refunds.push(pr); + } + if let Some(pc) = rec.pending_claim { + out.pending_claims.push(pc); + } + if let Some(a) = rec.claim_attempt { + out.claim_attempts.insert(swap_id, a); + } + if let Some(a) = rec.refund_attempt { + out.refund_attempts.insert(swap_id, a); + } + } + out // return the restored swaps +} + +#[cfg(test)] +mod tests { + #![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing + )] + use super::super::codec::encode; + use super::*; + use crate::PersistedSwap; + use std::sync::Mutex; + + struct MockStore { + rows: Vec<([u8; 32], Vec)>, + quarantined: Mutex>, + } + + impl SwapStore for MockStore { + fn load_channel(&self, _c: ChannelId) -> Vec<([u8; 32], Vec)> { + self.rows.clone() + } + fn save(&self, _c: ChannelId, _s: [u8; 32], _r: &[u8]) {} + fn delete(&self, _c: ChannelId, _s: [u8; 32]) {} + fn quarantine(&self, _c: ChannelId, swap_id: [u8; 32], _raw: &[u8], _reason: &str) { + self.quarantined.lock().unwrap().push(swap_id); + } + } + + #[test] + fn keeps_valid_and_quarantines_corrupt_never_dropping() { + let good = PersistedSwap { + script: None, + pending_refund: Some((RefundV1::new([1u8; 32]), 5)), + pending_claim: None, + claim_attempt: None, + refund_attempt: None, + }; + let mock = Arc::new(MockStore { + rows: vec![ + ([1u8; 32], encode(&good).unwrap()), + ([2u8; 32], vec![0x53, 1, 0xff]), + ], + quarantined: Mutex::new(Vec::new()), + }); + let store: Arc = mock.clone(); + + let restored = restore(Some(&store), ChannelId::KaspaTn10); + + assert_eq!(restored.pending_refunds.len(), 1); + assert_eq!(*mock.quarantined.lock().unwrap(), vec![[2u8; 32]]); + } +} diff --git a/crates/data/src/chains/record/result.rs b/crates/data/src/chains/record/result.rs new file mode 100644 index 0000000..e46bb66 --- /dev/null +++ b/crates/data/src/chains/record/result.rs @@ -0,0 +1 @@ +pub(crate) type Result = core::result::Result; diff --git a/crates/data/src/chains/settlement/action.rs b/crates/data/src/chains/settlement/action.rs new file mode 100644 index 0000000..3157380 --- /dev/null +++ b/crates/data/src/chains/settlement/action.rs @@ -0,0 +1,55 @@ +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +/// The two variants that constitute an action, claim or refund +pub(crate) enum Action { + Claim, + Refund, +} + +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +/// An action key containing a swap id and the relevant action +pub(crate) struct ActionKey { + pub swap_id: [u8; 32], + pub action: Action, +} + +impl ActionKey { + /// Create a new action key which is a claim key + pub(crate) fn claim(swap_id: [u8; 32]) -> Self { + Self { + swap_id, + action: Action::Claim, + } + } + + /// Create a new refund action key + pub(crate) fn refund(swap_id: [u8; 32]) -> Self { + Self { + swap_id, + action: Action::Refund, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ahash::AHashMap; + + #[test] + fn claim_and_refund_keys_are_distinct() { + let c = ActionKey::claim([1u8; 32]); + let r = ActionKey::refund([1u8; 32]); + assert_ne!(c, r); + assert_eq!(c.action, Action::Claim); + assert_eq!(r.action, Action::Refund); + } + + #[test] + fn keys_are_usable_as_map_keys() { + let mut m = AHashMap::new(); + m.insert(ActionKey::claim([1u8; 32]), 1); + m.insert(ActionKey::refund([1u8; 32]), 2); + assert_eq!(m.len(), 2); + assert_eq!(m.get(&ActionKey::claim([1u8; 32])), Some(&1)); + } +} diff --git a/crates/data/src/chains/settlement/engine.rs b/crates/data/src/chains/settlement/engine.rs new file mode 100644 index 0000000..821e472 --- /dev/null +++ b/crates/data/src/chains/settlement/engine.rs @@ -0,0 +1,190 @@ +use std::panic::AssertUnwindSafe; +use std::sync::Arc; +use std::time::Duration; + +use futures::FutureExt; +use stroemnet_protocol::{now_unix_secs, sleep_secs}; + +use super::super::net::{RECEIPT_TIMEOUT, timed}; +use super::metrics::{Gauge, Metric, SettlementMetrics}; +use super::settler::{Observation, SettleOutcome, Settler}; +use crate::TaskFut; + +/// How often we should try to drive the settler +const SETTLE_TICK_SECS: u64 = 5; + +pub(crate) fn settler_loop( + settler: Arc, + metrics: Arc, +) -> TaskFut { + Box::pin(async move { + loop { + // We want to ensure that the settler driver can never panic + // this is not a means for error handling rather it is a way for us + // to ensure that underlying dependencies if incorrectly done wouldnt cause a + // panic to the whole settlement system + let tick = AssertUnwindSafe(drive( + &*settler, + now_unix_secs(), + RECEIPT_TIMEOUT, + metrics.as_ref(), + )) + .catch_unwind() + .await; + if tick.is_err() { + tracing::error!( + target: "settlement", + kind = "panic", + "settlement drive panicked; continuing loop" + ); + } + // Sleep for x amount of seconds then retry + sleep_secs(SETTLE_TICK_SECS).await; + } + }) +} + +pub(crate) async fn drive( + settler: &S, + now: u64, + timeout: Duration, + metrics: &dyn SettlementMetrics, +) { + // Go over all action keys that are due now + for key in settler.due_now(now) { + if settler.is_stuck(key, now) { + // check if the current key is stuck i.e. its past deadline + metrics.incr(Metric::DeadlineExceeded); + // If its settled then we can exit + if let Observation::Settled = settler.observe(key).await { + settler.record_success(key); + metrics.incr(Metric::Reconciled); + continue; + } + tracing::error!( + target: "settlement", + kind = "needs_intervention", + swap = %hex::encode(key.swap_id), + action = ?key.action, + "settlement past deadline but funds still recoverable on-chain; retrying" + ); + } + // Attempt to settle it. + let outcome = match timed(timeout, settler.settle(key)).await { + Some(o) => o, + None => SettleOutcome::Retry("timeout"), + }; + + // The outcome can eeither be a retry or a fatal failure + match outcome { + SettleOutcome::Retry(reason) => { + tracing::info!( + target: "settlement", + reason, + swap = %hex::encode(key.swap_id), + "settlement retry" + ); + settler.record_failure(key, now); + metrics.incr(Metric::Retried); + } + SettleOutcome::Fatal(e) => { + tracing::error!( + target: "settlement", + error = %e, + swap = %hex::encode(key.swap_id), + "settlement fatal" + ); + settler.record_failure(key, now); + metrics.incr(Metric::Fatal); + } + } + } + metrics.gauge(Gauge::QueueDepth, settler.due_now(now).len() as u64); +} + +#[cfg(test)] +mod tests { + use super::super::action::ActionKey; + use super::super::metrics::NoopMetrics; + use super::super::settler::{Observation, SettleFut, Settler}; + use super::*; + use parking_lot::Mutex; + + #[derive(Default)] + struct MockSettler { + outcome: Option<&'static str>, + observed: Option<&'static str>, + stuck: bool, + succeeded: Mutex>, + failed: Mutex>, + } + + impl Settler for MockSettler { + fn due_now(&self, _now: u64) -> Vec { + vec![ActionKey::claim([1u8; 32])] + } + fn settle(&self, _key: ActionKey) -> SettleFut<'_, SettleOutcome> { + let outcome = match self.outcome { + Some("fatal") => SettleOutcome::Fatal("mock".into()), + _ => SettleOutcome::Retry("mock"), + }; + Box::pin(async move { outcome }) + } + fn observe(&self, _key: ActionKey) -> SettleFut<'_, Observation> { + let obs = match self.observed { + Some("settled") => Observation::Settled, + _ => Observation::Unknown, + }; + Box::pin(async move { obs }) + } + fn record_success(&self, key: ActionKey) { + self.succeeded.lock().push(key); + } + fn record_failure(&self, key: ActionKey, _now: u64) { + self.failed.lock().push(key); + } + fn is_stuck(&self, _key: ActionKey, _now: u64) -> bool { + self.stuck + } + } + + #[tokio::test] + async fn retry_and_fatal_record_failure() { + let m = MockSettler { + outcome: Some("retry"), + ..Default::default() + }; + drive(&m, 0, Duration::from_secs(1), &NoopMetrics).await; + assert_eq!(m.failed.lock().len(), 1); + let f = MockSettler { + outcome: Some("fatal"), + ..Default::default() + }; + drive(&f, 0, Duration::from_secs(1), &NoopMetrics).await; + assert_eq!(f.failed.lock().len(), 1); + } + + #[tokio::test] + async fn stuck_but_settled_onchain_is_cleaned_up() { + let m = MockSettler { + stuck: true, + observed: Some("settled"), + ..Default::default() + }; + drive(&m, 0, Duration::from_secs(1), &NoopMetrics).await; + assert_eq!(m.succeeded.lock().len(), 1); + assert!(m.failed.lock().is_empty()); + } + + #[tokio::test] + async fn stuck_but_recoverable_retries_not_abandons() { + let m = MockSettler { + stuck: true, + outcome: Some("retry"), + ..Default::default() + }; + drive(&m, 0, Duration::from_secs(1), &NoopMetrics).await; + assert!(m.succeeded.lock().is_empty()); + assert_eq!(m.failed.lock().len(), 1); + } +} diff --git a/crates/data/src/chains/settlement/metrics.rs b/crates/data/src/chains/settlement/metrics.rs new file mode 100644 index 0000000..1a452d4 --- /dev/null +++ b/crates/data/src/chains/settlement/metrics.rs @@ -0,0 +1,47 @@ +use std::sync::Arc; + +#[derive(Clone, Copy, Debug)] +/// Different type of metrics that can happen to swaps during the settlement loop +pub enum Metric { + Retried, + Fatal, + DeadlineExceeded, + Reconciled, +} + +#[derive(Clone, Copy, Debug)] +/// Different gauges in order to inspect internal system state +pub enum Gauge { + QueueDepth, // Inspects the queue depth +} + +pub trait SettlementMetrics: Send + Sync { + fn incr(&self, metric: Metric); + fn gauge(&self, gauge: Gauge, value: u64); +} + +pub struct NoopMetrics; + +impl SettlementMetrics for NoopMetrics { + fn incr(&self, _metric: Metric) {} + fn gauge(&self, _gauge: Gauge, _value: u64) {} +} + +pub(crate) fn or_noop(metrics: Option>) -> Arc { + metrics.unwrap_or_else(|| Arc::new(NoopMetrics)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn noop_metrics_are_inert() { + let m = or_noop(None); + m.incr(Metric::Retried); + m.incr(Metric::Fatal); + m.incr(Metric::DeadlineExceeded); + m.incr(Metric::Reconciled); + m.gauge(Gauge::QueueDepth, 5); + } +} diff --git a/crates/data/src/chains/settlement/mod.rs b/crates/data/src/chains/settlement/mod.rs new file mode 100644 index 0000000..191ff93 --- /dev/null +++ b/crates/data/src/chains/settlement/mod.rs @@ -0,0 +1,22 @@ +mod action; +#[cfg(not(target_arch = "wasm32"))] +mod engine; +mod metrics; +mod queue; +#[cfg(not(target_arch = "wasm32"))] +mod reconcile; +#[cfg(not(target_arch = "wasm32"))] +mod settler; + +#[cfg(not(target_arch = "wasm32"))] +pub(crate) use action::Action; +pub(crate) use action::ActionKey; +#[cfg(not(target_arch = "wasm32"))] +pub(crate) use engine::settler_loop; +pub(crate) use metrics::or_noop; +pub use metrics::{Gauge, Metric, NoopMetrics, SettlementMetrics}; +pub(crate) use queue::{RetryQueue, seed_queue}; +#[cfg(not(target_arch = "wasm32"))] +pub(crate) use reconcile::reconcile_on_boot; +#[cfg(not(target_arch = "wasm32"))] +pub(crate) use settler::{Observation, SettleFut, SettleOutcome, Settler}; diff --git a/crates/data/src/chains/settlement/queue.rs b/crates/data/src/chains/settlement/queue.rs new file mode 100644 index 0000000..639a618 --- /dev/null +++ b/crates/data/src/chains/settlement/queue.rs @@ -0,0 +1,154 @@ +use ahash::AHashMap; +use parking_lot::Mutex; + +use super::action::ActionKey; +use crate::AttemptState; +use crate::chains::record::RestoredSwaps; + +/// Seeds the retry queue with restored swaps +pub(crate) fn seed_queue(restored: &RestoredSwaps, now: u64) -> RetryQueue { + let queue = RetryQueue::default(); + for (sid, st) in &restored.claim_attempts { + queue.seed(ActionKey::claim(*sid), *st); + } + for (sid, st) in &restored.refund_attempts { + queue.seed(ActionKey::refund(*sid), *st); + } + for c in &restored.pending_claims { + queue.ensure(ActionKey::claim(c.swap_id), now); + } + for (r, _) in &restored.pending_refunds { + queue.ensure(ActionKey::refund(r.swap_id), now); + } + queue +} + +#[derive(Default)] +/// A retry queue that periodically retries each action key +pub(crate) struct RetryQueue { + attempts: Mutex>, +} + +impl RetryQueue { + /// Seed the key with attempt state + pub(crate) fn seed(&self, key: ActionKey, state: AttemptState) { + self.attempts.lock().insert(key, state); + } + + /// Ensure that an action key is present in the queue + pub(crate) fn ensure(&self, key: ActionKey, now: u64) { + self.attempts + .lock() + .entry(key) + .or_insert_with(|| AttemptState::new(now)); + } + + #[cfg(not(target_arch = "wasm32"))] + /// Get all action keys that are due now + pub(crate) fn due_now(&self, now: u64) -> Vec { + self.attempts + .lock() + .iter() + .filter(|(_, s)| s.due(now)) + .map(|(k, _)| *k) + .collect() + } + + /// Mark an action key as settled and remove it from the queue/ + pub(crate) fn record_success(&self, key: ActionKey) { + self.attempts.lock().remove(&key); + } + + #[cfg(not(target_arch = "wasm32"))] + /// Record failure and reschedule the key at a more random retry time + pub(crate) fn record_failure(&self, key: ActionKey, now: u64, jitter: u64) { + if let Some(s) = self.attempts.lock().get_mut(&key) { + s.on_failure(now, jitter); + } + } + + #[cfg(not(target_arch = "wasm32"))] + /// Set the nonce, we will retry with this nonce but a higher gas price in the next RBF attempt + pub(crate) fn set_nonce(&self, key: ActionKey, nonce: u64) { + if let Some(s) = self.attempts.lock().get_mut(&key) { + s.nonce = Some(nonce); + } + } + + #[cfg(not(target_arch = "wasm32"))] + /// Set last used gas price for the attempt + pub(crate) fn set_last_gas(&self, key: ActionKey, gas: u128) { + if let Some(s) = self.attempts.lock().get_mut(&key) { + s.last_gas = Some(gas); + } + } + + #[cfg(not(target_arch = "wasm32"))] + /// Whether the key is expired, because it has not been evicted yet + pub(crate) fn is_stuck(&self, key: ActionKey, now: u64) -> bool { + self.attempts + .lock() + .get(&key) + .is_some_and(|s| s.expired(now)) + } + + /// Retrieve the attemptstate from action key + pub(crate) fn get(&self, key: ActionKey) -> Option { + self.attempts.lock().get(&key).copied() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key() -> ActionKey { + ActionKey::claim([7u8; 32]) + } + + #[test] + fn ensure_inserts_due_now_then_record_success_removes() { + let q = RetryQueue::default(); + q.ensure(key(), 1000); + assert_eq!(q.due_now(1000), vec![key()]); + q.record_success(key()); + assert!(q.due_now(1000).is_empty()); + assert!(q.get(key()).is_none()); + } + + #[test] + fn record_failure_backs_off_so_not_due() { + let q = RetryQueue::default(); + q.ensure(key(), 1000); + q.record_failure(key(), 1000, 0); + assert!(q.due_now(1000).is_empty()); + let later = 1000 + 10_000; + assert_eq!(q.due_now(later), vec![key()]); + } + + #[test] + fn seed_preserves_backoff_and_is_stuck_reflects_deadline() { + let q = RetryQueue::default(); + let mut st = AttemptState::new(0); + st.on_failure(0, 0); + q.seed(key(), st); + assert_eq!(q.get(key()), Some(st)); + assert!(!q.is_stuck(key(), 0)); + assert!(q.is_stuck(key(), st.deadline)); + } + + #[test] + fn seed_queue_makes_pending_due_and_preserves_seeded_backoff() { + use stroemnet_protocol::v1::RevealV1; + let mut restored = RestoredSwaps::default(); + restored + .pending_claims + .push(RevealV1::new([1u8; 32], [0u8; 32])); + let mut backed = AttemptState::new(0); + backed.on_failure(0, 0); + restored.refund_attempts.insert([2u8; 32], backed); + let q = seed_queue(&restored, 1000); + assert!(q.due_now(1000).contains(&ActionKey::claim([1u8; 32]))); + assert_eq!(q.get(ActionKey::refund([2u8; 32])), Some(backed)); + } +} diff --git a/crates/data/src/chains/settlement/reconcile.rs b/crates/data/src/chains/settlement/reconcile.rs new file mode 100644 index 0000000..9839ebe --- /dev/null +++ b/crates/data/src/chains/settlement/reconcile.rs @@ -0,0 +1,87 @@ +use stroemnet_protocol::now_unix_secs; + +use super::action::ActionKey; +use super::metrics::{Metric, SettlementMetrics}; +use super::settler::{Observation, Settler}; + +/// Accepts a trait backed settled which will check if any of the swaps +/// are ready to be resolved and then check it against the onchain observation +pub(crate) async fn reconcile_on_boot(settler: &S, metrics: &dyn SettlementMetrics) { + let keys = settler.due_now(now_unix_secs()); + reconcile(settler, &keys, metrics).await; +} + +pub(crate) async fn reconcile( + settler: &S, + keys: &[ActionKey], + metrics: &dyn SettlementMetrics, +) { + for &key in keys { + // If the key is observed to be settled we can instantly remove it now. + if let Observation::Settled = settler.observe(key).await { + settler.record_success(key); + metrics.incr(Metric::Reconciled); + tracing::info!( + target: "settlement", + swap = %hex::encode(key.swap_id), + action = ?key.action, + "reconciled already-settled on boot" + ); + } + } +} + +#[cfg(test)] +mod tests { + use super::super::metrics::NoopMetrics; + use super::super::settler::{SettleFut, SettleOutcome}; + use super::*; + use parking_lot::Mutex; + + struct MockSettler { + observation: &'static str, + cleared: Mutex>, + } + + impl Settler for MockSettler { + fn due_now(&self, _now: u64) -> Vec { + Vec::new() + } + fn settle(&self, _key: ActionKey) -> SettleFut<'_, SettleOutcome> { + Box::pin(async { SettleOutcome::Retry("mock") }) + } + fn observe(&self, _key: ActionKey) -> SettleFut<'_, Observation> { + let obs = match self.observation { + "settled" => Observation::Settled, + "not" => Observation::NotSettled, + _ => Observation::Unknown, + }; + Box::pin(async move { obs }) + } + fn record_success(&self, key: ActionKey) { + self.cleared.lock().push(key); + } + fn record_failure(&self, _key: ActionKey, _now: u64) {} + fn is_stuck(&self, _key: ActionKey, _now: u64) -> bool { + false + } + } + + #[tokio::test] + async fn settled_is_recorded_others_kept() { + let keys = [ActionKey::refund([2u8; 32])]; + let settled = MockSettler { + observation: "settled", + cleared: Mutex::new(Vec::new()), + }; + reconcile(&settled, &keys, &NoopMetrics).await; + assert_eq!(settled.cleared.lock().len(), 1); + + let not = MockSettler { + observation: "not", + cleared: Mutex::new(Vec::new()), + }; + reconcile(¬, &keys, &NoopMetrics).await; + assert!(not.cleared.lock().is_empty()); + } +} diff --git a/crates/data/src/chains/settlement/settler.rs b/crates/data/src/chains/settlement/settler.rs new file mode 100644 index 0000000..c58eeff --- /dev/null +++ b/crates/data/src/chains/settlement/settler.rs @@ -0,0 +1,50 @@ +use std::future::Future; +use std::pin::Pin; + +use super::action::ActionKey; +use crate::MaybeSend; + +#[cfg(not(target_arch = "wasm32"))] +pub(crate) type SettleFut<'a, T> = Pin + Send + 'a>>; +#[cfg(target_arch = "wasm32")] +pub(crate) type SettleFut<'a, T> = Pin + 'a>>; + +/// A settleoutcome is either a retriable error or a fatal one +pub(crate) enum SettleOutcome { + Retry(&'static str), + Fatal(String), +} + +/// Either settled not settled or unknown. Not all chains can guarantee settled from onchain observation. +/// For example for kaspa we actually need to observe the spending of the htlc utxo as we go over blocks +pub(crate) enum Observation { + Settled, + NotSettled, + Unknown, +} + +pub(crate) trait Settler: MaybeSend { + fn due_now(&self, now: u64) -> Vec; + fn settle(&self, key: ActionKey) -> SettleFut<'_, SettleOutcome>; + fn observe(&self, key: ActionKey) -> SettleFut<'_, Observation>; + fn record_success(&self, key: ActionKey); + fn record_failure(&self, key: ActionKey, now: u64); + fn is_stuck(&self, key: ActionKey, now: u64) -> bool; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn outcome_and_observation_variants_construct() { + let outcomes = [SettleOutcome::Retry("x"), SettleOutcome::Fatal("y".into())]; + let observations = [ + Observation::Settled, + Observation::NotSettled, + Observation::Unknown, + ]; + assert_eq!(outcomes.len(), 2); + assert_eq!(observations.len(), 3); + } +} diff --git a/crates/data/src/lib.rs b/crates/data/src/lib.rs index 0cf07e5..8c670b5 100644 --- a/crates/data/src/lib.rs +++ b/crates/data/src/lib.rs @@ -1,287 +1,20 @@ #![warn(unreachable_pub)] +#![allow(clippy::result_large_err)] +mod buffer; mod chains; pub mod error; +mod sink; +mod store; +mod types; -use std::future::Future; -use std::pin::Pin; -use std::sync::Arc; - -use ahash::AHashMap; -use serde_json::Value; -use stroemnet_protocol::v1::{ChainEvent, CommitmentV1}; -use stroemnet_protocol::{ChainClock, ChannelId}; - -use chains::build_buffer; -pub use error::{DataError, Result}; - -#[cfg(not(target_arch = "wasm32"))] -/// A trait alias for Send + Sync, which is required for buffers that may be used across threads. -/// At least for native code -pub trait MaybeSend: Send + Sync {} #[cfg(not(target_arch = "wasm32"))] -impl MaybeSend for T {} -#[cfg(target_arch = "wasm32")] -/// For wasm we dont require Send + Sync, as everything is single-threaded, so this trait is just a marker with no bounds. -pub trait MaybeSend {} -#[cfg(target_arch = "wasm32")] -impl MaybeSend for T {} - -/// A trait for persisting the last processed block's hash (cursor) across restarts. -pub trait CursorStore: MaybeSend { - fn load(&self, channel_id: ChannelId) -> Option>; - fn save(&self, channel_id: ChannelId, cursor: &[u8]); -} - -#[cfg(not(target_arch = "wasm32"))] -/// A boxed future that is Send, which is required for buffers that may be used across threads. -/// At least for native code -pub(crate) type BufFut<'a, T> = Pin> + Send + 'a>>; -#[cfg(target_arch = "wasm32")] -/// For wasm we dont require Send, as everything is single-threaded, so this is just a boxed future with no Send bound. -pub(crate) type BufFut<'a, T> = Pin> + 'a>>; - -#[derive(Debug, Clone)] -/// The result of verifying a signature proposal, -/// indicating whether the claimed address matches the signature -/// and whether the balance is sufficient for the required amount. -pub struct ProposalVerification { - pub address_matches: bool, - pub balance_sufficient: bool, -} - -#[derive(Debug, Clone)] -/// The data needed to detect and handle a UTXO script on the chain, -/// including the redeem script, its expiration time, and the target address for deposits. -pub struct UtxoScript { - /// The redeem script that we are monitoring - pub redeem_script: Vec, - /// The unlock time for this swap (its a commitment) - pub unlock_ts: u64, - /// The minimum amount that must be deposited to the script for it to be considered valid - pub deposit_target: String, -} - -#[derive(Debug, Clone)] -/// A script announcement, including the address, swap ID, redeem script, expiration time, and deposit target. -pub struct ScriptAnnouncement { - /// The address associated with the script, which may be used for monitoring or deposits. - pub address: String, - /// The unique identifier for the swap, which can be used to correlate on-chain events with off-chain state. - pub swap_id: [u8; 32], - /// A utxo script - pub script: UtxoScript, -} - -/// A trait used for onchain data which buffer confirmed onchain data -/// and give us the ability to broadcast events and sign messages on demand -pub(crate) trait ChainDataBuffer: MaybeSend { - /// Returns the LP address if its configured, will throw error if not configured - fn lp_address(&self) -> Result; - - /// Computes the deposit address if this channel supports p2sh-like deposits - fn derive_deposit(&self, _commitment: &CommitmentV1) -> Result<(String, Vec)> { - Err(DataError::Other( - "channel does not support P2SH deposits".into(), - )) - } - - /// Retrieves finalized chunk of on-chain events, which are considered irreversible and safe to act upon. - /// that is of course as long as the operator has used a safe block confirmation threshold - /// as some operators might take on more risk than others. - fn finalized_chunk(&self) -> BufFut<'_, Vec<(ChannelId, ChainEvent)>>; - - fn chain_now(&self) -> Option { - None - } - - /// Broadcasts a chain event to the associated chain, - /// which will be picked up by other nodes monitoring the chain. - fn broadcast_event<'a>(&'a self, event: &'a ChainEvent) -> BufFut<'a, ()>; - - /// Signs a message digest with the configured private key for this channel, after - /// verifying that the associated address has sufficient balance to meet the required balance threshold. - /// This is used for signing CCR proposals to prove on-chain ownership of the LP address and - /// to ensure that the address has sufficient funds to fulfill the swap in case the proposal is accepted. - fn sign_message<'a>( - &'a self, - digest: [u8; 32], - required_balance: &'a str, - ) -> BufFut<'a, (String, Vec)>; - - /// Verifies a message signature against a claimed address and required balance, - /// returning whether the signature is valid, whether the recovered address matches the claimed address, - /// and whether the balance is sufficient. - fn verify_message<'a>( - &'a self, - digest: [u8; 32], - claimed_address: &'a str, - signature: &'a [u8], - required_balance: &'a str, - ) -> BufFut<'a, ProposalVerification>; - - /// Returns an optional reference to a UTXO script detector if this channel supports UTXO scripts, - /// which can be used to register scripts for monitoring and handling. - fn utxo_script_detector(&self) -> Option<&dyn UtxoScriptDetector> { - None - } - - /// Takes all pending UTXO script announcements from the buffer and returns them as a vector. - fn take_utxo_script_announcements(&self) -> Vec { - Vec::new() - } -} - -pub(crate) trait UtxoScriptDetector: MaybeSend { - fn register_script<'a>( - &'a self, - address: String, - redeem_script: Vec, - swap_id: [u8; 32], - unlock_ts: u64, - deposit_target: String, - ) -> BufFut<'a, ()>; -} - -/// A sink for all chain data across all blockchains -/// The internal buffers implement the same trait as this high level struct -/// and effectively abstract the dfferent chain logic from the rest of the system -pub struct ChainDataSink { - buffers: AHashMap>, -} - -impl ChainDataSink { - pub async fn new( - channels: AHashMap)>, - cursor_store: Option>, - ) -> Result { - let mut buffers: AHashMap> = AHashMap::new(); - for (channel_id, (cfg, lp_key)) in channels { - buffers.insert( - channel_id, - build_buffer(channel_id, &cfg, lp_key, cursor_store.clone()).await?, - ); - } - Ok(Self { buffers }) - } - - pub fn channels(&self) -> impl Iterator + '_ { - self.buffers.keys().copied() - } - - pub fn chain_clock(&self) -> ChainClock { - let mut times = AHashMap::new(); - for (channel, buffer) in &self.buffers { - if let Some(ts) = buffer.chain_now() { - times.insert(*channel, ts); - } - } - ChainClock::new(times) - } - - pub fn knows_channel(&self, channel_id: ChannelId) -> bool { - self.buffers.contains_key(&channel_id) - } - - pub fn script_channel(&self) -> Option { - self.buffers - .iter() - .find(|(_, b)| b.utxo_script_detector().is_some()) - .map(|(id, _)| *id) - } - - fn buffer(&self, channel_id: ChannelId) -> Result<&dyn ChainDataBuffer> { - self.buffers - .get(&channel_id) - .map(|b| b.as_ref()) - .ok_or(DataError::UnknownChannel(channel_id)) - } - - pub async fn register_script( - &self, - channel_id: ChannelId, - address: String, - redeem_script: Vec, - swap_id: [u8; 32], - unlock_ts: u64, - deposit_target: String, - ) -> Result<()> { - match self.utxo_script_detector(channel_id) { - Some(detector) => { - detector - .register_script(address, redeem_script, swap_id, unlock_ts, deposit_target) - .await - } - None => Err(DataError::UnknownChannel(channel_id)), - } - } -} - -impl ChainDataSink { - pub fn lp_address(&self, channel_id: ChannelId) -> Result { - self.buffer(channel_id)?.lp_address() - } - - pub fn derive_deposit( - &self, - channel_id: ChannelId, - commitment: &CommitmentV1, - ) -> Result<(String, Vec)> { - self.buffer(channel_id)?.derive_deposit(commitment) - } - - pub async fn finalized_chunk(&self) -> Result> { - let mut all = Vec::new(); - for buffer in self.buffers.values() { - all.extend(buffer.finalized_chunk().await?); - } - Ok(all) - } - - pub async fn broadcast_event( - &self, - destination_channel_id: ChannelId, - event: &ChainEvent, - ) -> Result<()> { - self.buffer(destination_channel_id)? - .broadcast_event(event) - .await - } - - pub async fn sign_message( - &self, - channel_id: ChannelId, - digest: [u8; 32], - required_balance: &str, - ) -> Result<(String, Vec)> { - self.buffer(channel_id)? - .sign_message(digest, required_balance) - .await - } - - pub async fn verify_message( - &self, - channel_id: ChannelId, - digest: [u8; 32], - claimed_address: &str, - signature: &[u8], - required_balance: &str, - ) -> Result { - self.buffer(channel_id)? - .verify_message(digest, claimed_address, signature, required_balance) - .await - } - - fn utxo_script_detector(&self, channel_id: ChannelId) -> Option<&dyn UtxoScriptDetector> { - self.buffers - .get(&channel_id) - .and_then(|b| b.utxo_script_detector()) - } - - pub fn take_utxo_script_announcements(&self) -> Vec { - self.buffers - .values() - .flat_map(|b| b.take_utxo_script_announcements()) - .collect() - } -} +pub(crate) use buffer::TaskFut; +pub(crate) use buffer::{ChainDataBuffer, UtxoScriptDetector}; +pub use chains::record::AttemptState; +pub use chains::settlement::{Gauge, Metric, NoopMetrics, SettlementMetrics}; +pub use error::{DataError, Result}; +pub use sink::ChainDataSink; +pub use store::{CursorStore, PersistedSwap, SwapStore}; +pub(crate) use types::BufFut; +pub use types::{MaybeSend, ProposalVerification, ScriptAnnouncement, UtxoScript}; diff --git a/crates/data/src/sink/forward.rs b/crates/data/src/sink/forward.rs new file mode 100644 index 0000000..518f82f --- /dev/null +++ b/crates/data/src/sink/forward.rs @@ -0,0 +1,105 @@ +use stroemnet_protocol::ChannelId; +use stroemnet_protocol::v1::{ChainEvent, CommitmentV1}; + +use super::ChainDataSink; +use crate::{ProposalVerification, Result, ScriptAnnouncement}; + +impl ChainDataSink { + /// Retrieves the lp address based on the provided channel id + pub fn lp_address(&self, channel_id: ChannelId) -> Result { + self.buffer(channel_id)?.lp_address() + } + + /// Compute the deposit address based on the channel id and commitment + pub fn derive_deposit( + &self, + channel_id: ChannelId, + commitment: &CommitmentV1, + ) -> Result<(String, Vec)> { + self.buffer(channel_id)?.derive_deposit(commitment) + } + + /// Retrieve the next finalized cunk from all channels + pub async fn finalized_chunk(&self) -> Result> { + // Get all finalized chunks from all registered channels + let polled = + futures::future::join_all(self.buffers.iter().map(|(channel, buffer)| async move { + (*channel, buffer.finalized_chunk().await) + })) + .await; + let mut all = Vec::new(); + // Go over all channel data and add it to DS + for (channel, result) in polled { + match result { + Ok(events) => all.extend(events), + Err(e) => { + tracing::warn!(target: "settlement", "finalized_chunk for {channel}: {e}") + } + } + } + + // return data + Ok(all) + } + + /// Broadcast the event to a destination channel + pub async fn broadcast_event( + &self, + destination_channel_id: ChannelId, + event: &ChainEvent, + ) -> Result<()> { + self.buffer(destination_channel_id)? + .broadcast_event(event) + .await + } + + /// Sign a message and require that the signer has some required balance + pub async fn sign_message( + &self, + channel_id: ChannelId, + digest: [u8; 32], + required_balance: &str, + ) -> Result<(String, Vec)> { + self.buffer(channel_id)? + .sign_message(digest, required_balance) + .await + } + + /// Verify the signature and authenticity of a message whilst also verifying + /// that the signer has the required amount of balance + pub async fn verify_message( + &self, + channel_id: ChannelId, + digest: [u8; 32], + claimed_address: &str, + signature: &[u8], + required_balance: &str, + ) -> Result { + self.buffer(channel_id)? + .verify_message(digest, claimed_address, signature, required_balance) + .await + } + + /// Extract all utxo script announcements + pub fn take_utxo_script_announcements(&self) -> Vec { + self.buffers + .values() + .flat_map(|b| b.take_utxo_script_announcements()) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ahash::AHashMap; + + #[test] + fn empty_sink_forwards_are_inert() { + let sink = ChainDataSink { + buffers: AHashMap::new(), + }; + assert!(sink.take_utxo_script_announcements().is_empty()); + assert!(sink.lp_address(ChannelId::KaspaTn10).is_err()); + } +} diff --git a/crates/data/src/sink/mod.rs b/crates/data/src/sink/mod.rs new file mode 100644 index 0000000..0ffa1c9 --- /dev/null +++ b/crates/data/src/sink/mod.rs @@ -0,0 +1,56 @@ +mod forward; +mod registry; + +use ahash::AHashMap; +use serde_json::Value; +use std::sync::Arc; +use stroemnet_protocol::ChannelId; + +use crate::chains::build_buffer; +use crate::{ChainDataBuffer, CursorStore, DataError, Result, SettlementMetrics, SwapStore}; + +/// Contains all chain data buffers by channel id +pub struct ChainDataSink { + buffers: AHashMap>, +} + +impl ChainDataSink { + /// Create a new chain data sink that will gather data for different chains + pub async fn new( + channels: AHashMap)>, + cursor_store: Option>, + swap_store: Option>, + metrics: Option>, + ) -> Result { + let mut buffers: AHashMap> = AHashMap::new(); + // Go over all channels + for (channel_id, (cfg, lp_key)) in channels { + let buffer: Arc = Arc::from( + // build the channel buffer + build_buffer( + channel_id, + &cfg, + lp_key, + cursor_store.clone(), + swap_store.clone(), + metrics.clone(), + ) + .await?, + ); + // Get the settler task and spawn it + if let Some(task) = buffer.clone().settler_task() { + stroemnet_protocol::spawn(task); + } + buffers.insert(channel_id, buffer); + } + Ok(Self { buffers }) + } + + /// Returns the chain data buffer + fn buffer(&self, channel_id: ChannelId) -> Result<&dyn ChainDataBuffer> { + self.buffers + .get(&channel_id) + .map(|b| b.as_ref()) + .ok_or(DataError::UnknownChannel(channel_id)) + } +} diff --git a/crates/data/src/sink/registry.rs b/crates/data/src/sink/registry.rs new file mode 100644 index 0000000..e2d1f84 --- /dev/null +++ b/crates/data/src/sink/registry.rs @@ -0,0 +1,80 @@ +use ahash::AHashMap; +use stroemnet_protocol::{ChainClock, ChannelId}; + +use super::ChainDataSink; +use crate::{DataError, Result, UtxoScriptDetector}; + +impl ChainDataSink { + /// Retrieves all channels that are registered + pub fn channels(&self) -> impl Iterator + '_ { + self.buffers.keys().copied() + } + + /// Returns a world clock for all registered chains + pub fn chain_clock(&self) -> ChainClock { + let mut times = AHashMap::new(); + for (channel, buffer) in &self.buffers { + if let Some(ts) = buffer.chain_now() { + times.insert(*channel, ts); + } + } + ChainClock::new(times) + } + + /// Whether we have a particular channel id registered as a valid chain data buffer + pub fn knows_channel(&self, channel_id: ChannelId) -> bool { + self.buffers.contains_key(&channel_id) + } + + /// Returns the first channel that has a utxo script detector + pub fn script_channel(&self) -> Option { + self.buffers + .iter() + .find(|(_, b)| b.utxo_script_detector().is_some()) + .map(|(id, _)| *id) + } + + /// Gets the utxo script detector based on a channel id + fn utxo_script_detector(&self, channel_id: ChannelId) -> Option<&dyn UtxoScriptDetector> { + self.buffers + .get(&channel_id) + .and_then(|b| b.utxo_script_detector()) + } + + /// Used to register a script, particular useful for utxo based systems + pub async fn register_script( + &self, + channel_id: ChannelId, + address: String, + redeem_script: Vec, + swap_id: [u8; 32], + unlock_ts: u64, + deposit_target: String, + ) -> Result<()> { + // it only works if the channel has a utxo script detector + match self.utxo_script_detector(channel_id) { + Some(detector) => { + // register the script with the detector + detector + .register_script(address, redeem_script, swap_id, unlock_ts, deposit_target) + .await + } + None => Err(DataError::UnknownChannel(channel_id)), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_sink_knows_nothing() { + let sink = ChainDataSink { + buffers: AHashMap::new(), + }; + assert_eq!(sink.channels().count(), 0); + assert!(sink.script_channel().is_none()); + assert!(!sink.knows_channel(ChannelId::KaspaTn10)); + } +} diff --git a/crates/data/src/store.rs b/crates/data/src/store.rs new file mode 100644 index 0000000..f71bc6f --- /dev/null +++ b/crates/data/src/store.rs @@ -0,0 +1,60 @@ +use stroemnet_protocol::ChannelId; +use stroemnet_protocol::v1::{RefundV1, RevealV1}; + +use crate::{AttemptState, MaybeSend, UtxoScript}; + +pub trait CursorStore: MaybeSend { + fn load(&self, channel_id: ChannelId) -> Option>; + fn save(&self, channel_id: ChannelId, cursor: &[u8]); +} + +#[derive(Debug, Clone, Default, borsh::BorshSerialize, borsh::BorshDeserialize)] +/// A swap that is persisted to disk +pub struct PersistedSwap { + pub script: Option, + pub pending_refund: Option<(RefundV1, u64)>, + pub pending_claim: Option, + pub claim_attempt: Option, + pub refund_attempt: Option, +} + +impl PersistedSwap { + pub fn is_empty(&self) -> bool { + self.script.is_none() + && self.pending_refund.is_none() + && self.pending_claim.is_none() + && self.claim_attempt.is_none() + && self.refund_attempt.is_none() + } +} + +pub trait SwapStore: MaybeSend { + fn load_channel(&self, channel_id: ChannelId) -> Vec<([u8; 32], Vec)>; + fn save(&self, channel_id: ChannelId, swap_id: [u8; 32], record: &[u8]); + fn delete(&self, channel_id: ChannelId, swap_id: [u8; 32]); + fn quarantine(&self, _channel_id: ChannelId, _swap_id: [u8; 32], _raw: &[u8], _reason: &str) {} +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_persisted_swap_is_empty() { + assert!(PersistedSwap::default().is_empty()); + let s = PersistedSwap { + pending_claim: Some(RevealV1 { + swap_id: [0u8; 32], + secret: [0u8; 32], + }), + ..Default::default() + }; + assert!(!s.is_empty()); + + let only_attempt = PersistedSwap { + claim_attempt: Some(AttemptState::new(0)), + ..Default::default() + }; + assert!(!only_attempt.is_empty()); + } +} diff --git a/crates/data/src/types.rs b/crates/data/src/types.rs new file mode 100644 index 0000000..c071d5f --- /dev/null +++ b/crates/data/src/types.rs @@ -0,0 +1,56 @@ +use std::future::Future; +use std::pin::Pin; + +use crate::Result; + +#[cfg(not(target_arch = "wasm32"))] +pub trait MaybeSend: Send + Sync {} +#[cfg(not(target_arch = "wasm32"))] +impl MaybeSend for T {} +#[cfg(target_arch = "wasm32")] +pub trait MaybeSend {} +#[cfg(target_arch = "wasm32")] +impl MaybeSend for T {} + +#[cfg(not(target_arch = "wasm32"))] +pub(crate) type BufFut<'a, T> = Pin> + Send + 'a>>; +#[cfg(target_arch = "wasm32")] +pub(crate) type BufFut<'a, T> = Pin> + 'a>>; + +#[derive(Debug, Clone)] +pub struct ProposalVerification { + pub address_matches: bool, + pub balance_sufficient: bool, +} + +#[derive(Debug, Clone, borsh::BorshSerialize, borsh::BorshDeserialize)] +pub struct UtxoScript { + pub redeem_script: Vec, + pub unlock_ts: u64, + pub deposit_target: String, +} + +#[derive(Debug, Clone)] +pub struct ScriptAnnouncement { + pub address: String, + pub swap_id: [u8; 32], + pub script: UtxoScript, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn _requires_maybe_send() {} + + #[test] + fn send_sync_types_are_maybe_send() { + _requires_maybe_send::(); + let s = UtxoScript { + redeem_script: vec![1, 2], + unlock_ts: 5, + deposit_target: "1".into(), + }; + assert_eq!(s.unlock_ts, 5); + } +} diff --git a/crates/handler/Cargo.toml b/crates/handler/Cargo.toml index 17defad..6cc4013 100644 --- a/crates/handler/Cargo.toml +++ b/crates/handler/Cargo.toml @@ -5,7 +5,6 @@ edition.workspace = true license.workspace = true [dependencies] -hex = { workspace = true } stroemnet-amounts = { path = "../amounts" } stroemnet-protocol = { path = "../protocol" } ahash = { workspace = true } @@ -23,3 +22,6 @@ tokio = { workspace = true, features = ["sync", "macros", "rt", "rt-multi-thread [target.'cfg(target_arch = "wasm32")'.dependencies] tokio = { workspace = true, features = ["sync", "macros", "rt"] } getrandom-02 = { workspace = true } + +[lints] +workspace = true diff --git a/crates/handler/src/address.rs b/crates/handler/src/address.rs index 1433688..9ad6893 100644 --- a/crates/handler/src/address.rs +++ b/crates/handler/src/address.rs @@ -1,19 +1,18 @@ +use std::str::FromStr; + +use alloy::primitives::Address; use stroemnet_protocol::ChannelId; -/// Compare two addresses for equality, normalizing them according to the chain's rules. -pub fn normalised_address_eq(chain: ChannelId, a: &str, b: &str) -> bool { +/// Compute the equivalence of two addresses dependin on the channel id +pub fn normalised_address_eq(channel: ChannelId, a: &str, b: &str) -> bool { let a = a.trim(); let b = b.trim(); if a == b { return true; } - match chain { + match channel { ChannelId::EthereumSepolia | ChannelId::IgraGalleon => { - use std::str::FromStr; - match ( - alloy::primitives::Address::from_str(a), - alloy::primitives::Address::from_str(b), - ) { + match (Address::from_str(a), Address::from_str(b)) { (Ok(la), Ok(lb)) => la == lb, _ => false, } @@ -21,3 +20,38 @@ pub fn normalised_address_eq(chain: ChannelId, a: &str, b: &str) -> bool { ChannelId::KaspaTn10 => a == b, } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn evm_addresses_compare_case_insensitively() { + let lower = "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd"; + let upper = "0xABCDEFABCDEFABCDEFABCDEFABCDEFABCDEFABCD"; + assert!(normalised_address_eq( + ChannelId::EthereumSepolia, + lower, + upper + )); + assert!(!normalised_address_eq( + ChannelId::EthereumSepolia, + lower, + "0x0000000000000000000000000000000000000001" + )); + } + + #[test] + fn kaspa_addresses_compare_exactly_after_trim() { + assert!(normalised_address_eq( + ChannelId::KaspaTn10, + "kaspa:abc", + " kaspa:abc " + )); + assert!(!normalised_address_eq( + ChannelId::KaspaTn10, + "kaspa:abc", + "kaspa:xyz" + )); + } +} diff --git a/crates/handler/src/dispatch.rs b/crates/handler/src/dispatch.rs new file mode 100644 index 0000000..0c02495 --- /dev/null +++ b/crates/handler/src/dispatch.rs @@ -0,0 +1,125 @@ +use sha2::{Digest, Sha256}; +use stroemnet_protocol::v1::{ChainEvent, CommitmentV1}; +use stroemnet_protocol::{ChainClock, ChannelId}; + +use crate::{Handler, HandlerError}; + +#[derive(Debug, Clone)] +/// An onchain event yields an effect in the handler +/// +/// Currently we have two types of effects, a general broadcast +/// and also the transmit reveal effect which is an effect that is commonly +/// used by users, as a signal that they can now transmit the reveal because +/// the LP has counter locked. +pub enum Effect { + Broadcast(ChannelId, ChainEvent), + TransmitReveal(CommitmentV1), +} + +impl Handler { + pub async fn on_chain_event( + &self, + source: ChannelId, + event: ChainEvent, + clock: &ChainClock, + ) -> Result, HandlerError> { + let mut effects = Vec::new(); + match event { + ChainEvent::Commitment(commitment) => { + // Handle the external commitment + self.handle_external_commitment(commitment.clone()).await?; + // Signal to transmit reveal is ok + effects.push(Effect::TransmitReveal(commitment.clone())); + let destination = ChannelId::try_from(commitment.destination)?; + + // If the destination is not the source we also need to forward this to + // to the destination channel as an internal commitment + if destination != source { + match self + .handle_internal_commitment(&commitment, destination, clock) + .await + { + // In many cases we get a counter event, so we need to broadcast this onchain + Ok(counter) => { + effects.push(Effect::Broadcast( + destination, + ChainEvent::Commitment(counter), + )); + } + Err(HandlerError::NotAddressedToUs(_)) + | Err(HandlerError::InvalidState(_)) => {} + Err(e) => tracing::warn!("internal commitment: {e}"), + } + } + } + // It was a reveal event + ChainEvent::Reveal(reveal) => { + // Handle the external reveal evet + self.handle_external_reveal(reveal.clone()).await?; + + // Verify the secret and then add it as a broadcast on the other chain + // so that we can finalize the swap + if let Some(counterparty) = self + .get_counterparty_channel_id(&reveal.swap_id, source) + .await? + && let Some(commitment) = self + .get_commitment_for_channel(&reveal.swap_id, counterparty) + .await? + && Sha256::digest(reveal.secret).as_slice() == commitment.secret_hash + { + effects.push(Effect::Broadcast(counterparty, ChainEvent::Reveal(reveal))); + } + } + ChainEvent::Refund(refund) => { + self.handle_external_refund(refund).await?; + } + } + Ok(effects) + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + use crate::test_fixtures::{create_test_handler_with, default_test_config, lp_addresses}; + use stroemnet_protocol::v1::{AddressesV1, AmountV1}; + + #[tokio::test] + async fn commitment_event_emits_transmit_reveal() { + let (handler, _t) = create_test_handler_with( + default_test_config(), + &[ + (ChannelId::KaspaTn10, 0.15), + (ChannelId::EthereumSepolia, 3000.0), + ], + lp_addresses(), + ); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let commitment = CommitmentV1::new( + [1u8; 32], + AddressesV1::new("0xUser".into(), "0xMm".into(), "kaspa:dest".into()), + AmountV1::new("1000000000000000000".into(), 18), + [0xEE; 32], + now + 3600, + ChannelId::EthereumSepolia as u8, + ChannelId::KaspaTn10 as u8, + ); + let effects = handler + .on_chain_event( + ChannelId::EthereumSepolia, + ChainEvent::Commitment(commitment), + &ChainClock::default(), + ) + .await + .unwrap(); + assert!( + effects + .iter() + .any(|e| matches!(e, Effect::TransmitReveal(_))) + ); + } +} diff --git a/crates/handler/src/error.rs b/crates/handler/src/error.rs index a210274..5483545 100644 --- a/crates/handler/src/error.rs +++ b/crates/handler/src/error.rs @@ -5,7 +5,6 @@ use alloy::{ use thiserror::Error; use stroemnet_protocol::ChannelId; -use stroemnet_protocol::v1::ChainEvent; #[derive(Error, Debug)] pub enum HandlerError { @@ -56,9 +55,6 @@ pub enum HandlerError { #[error("System time error: {0}")] SystemTime(#[from] std::time::SystemTimeError), - #[error("Invalid price data: {0}")] - InvalidPriceData(f64), - #[error("Invalid amount: {0}")] InvalidAmount(U256), @@ -74,12 +70,6 @@ pub enum HandlerError { #[error("Unknown channel: {0:?}")] UnknownChannel(ChannelId), - #[error("Send error event to channel: {0:?}")] - SendEventToChannel(#[from] tokio::sync::mpsc::error::SendError), - - #[error("Recv error from channel: {0:?}")] - RecvFromChannel(#[from] tokio::sync::oneshot::error::RecvError), - #[error("Other error: {0}")] Other(String), } @@ -89,3 +79,46 @@ impl From for HandlerError { HandlerError::Other(s) } } + +impl HandlerError { + pub fn rejection_reason(&self) -> Option { + match self { + HandlerError::TradeTooSmall { .. } | HandlerError::TradeTooLarge { .. } => { + Some(self.to_string()) + } + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + #![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing + )] + use super::*; + + #[test] + fn only_limit_errors_are_user_facing_rejections() { + let small = HandlerError::TradeTooSmall { + amount_in: "1".into(), + amount_in_usd: 0.5, + min_usd: 1.0, + }; + let large = HandlerError::TradeTooLarge { + amount_in: "9".into(), + amount_in_usd: 200.0, + max_usd: 100.0, + }; + assert!(small.rejection_reason().unwrap().contains("below minimum")); + assert!(large.rejection_reason().unwrap().contains("above maximum")); + assert!( + HandlerError::SwapNotFound([0; 32]) + .rejection_reason() + .is_none() + ); + } +} diff --git a/crates/handler/src/get.rs b/crates/handler/src/get.rs index c0d8e81..4874e3d 100644 --- a/crates/handler/src/get.rs +++ b/crates/handler/src/get.rs @@ -4,31 +4,28 @@ use stroemnet_protocol::ChannelId; use stroemnet_protocol::v1::CommitmentV1; impl Handler { - /// Get the counterparty channel id for a given swap and our channel. - /// This is used to know which channel to monitor for the counterparty commitment and reveal/refund events + /// Retrieves the channel id for the counterparty provided some existing channel id pub async fn get_counterparty_channel_id( &self, swap_id: &[u8; 32], our_channel: ChannelId, ) -> Result> { let tracker_read = self.swap_tracker.read().await; - // Try and retrieve the swap record for the given swap id. If it doesn't exist, return None if let Some(record) = tracker_read.get_swap(swap_id) { + // get the swap + + // compute init source let init_source = ChannelId::try_from(record.init_commitment.source)?; - // If there is no counter commitment, we are in the state where only the init commitment has been observed. - if record.counter_commitment.is_none() { - // This means we can simply return the destination of the init commitment as the counterparty channel, - // since the init commitment is always sent by us and received by the counterparty - let init_dest = ChannelId::try_from(record.init_commitment.destination)?; - return Ok(Some(init_dest)); - } + let Some(counter) = record.counter_commitment.as_ref() else { + // if there is no counter we will simply return none + return Ok(None); + }; - // Otherwise simply compute the counter channel id by reading it from the source - // of the counter commitment - let counter_source = - ChannelId::try_from(record.counter_commitment.as_ref().unwrap().source)?; + // compute counter + let counter_source = ChannelId::try_from(counter.source)?; + // depending on what we passed as our channel we will get the other side if init_source == our_channel { Ok(Some(counter_source)) } else { @@ -39,36 +36,38 @@ impl Handler { } } - /// Retrieve a commitment for a given swap id and channel pub async fn get_commitment_for_channel( &self, swap_id: &[u8; 32], channel: ChannelId, ) -> Result> { let tracker_read = self.swap_tracker.read().await; - // If we dont have this swap there is nothing to return let Some(record) = tracker_read.get_swap(swap_id) else { return Ok(None); }; - // Check if the init commitment belongs to the given channel, if yes return it if ChannelId::try_from(record.init_commitment.source)? == channel { return Ok(Some(record.init_commitment.clone())); } - // Check if the counter commitment belongs to the given channel, if yes return it if let Some(counter) = record.counter_commitment.as_ref() - && ChannelId::try_from(counter.source)? == channel { - return Ok(Some(counter.clone())); - } + && ChannelId::try_from(counter.source)? == channel + { + return Ok(Some(counter.clone())); + } - // Otherwise we dont have it Ok(None) } } #[cfg(test)] mod tests { + #![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing + )] use crate::test_fixtures::{ TEST_SECRET, create_test_handler, mock_counter_commitment, mock_init_commitment, }; @@ -102,10 +101,9 @@ mod tests { .get_counterparty_channel_id(&swap_id, ChannelId::KaspaTn10) .await .unwrap(); - assert_eq!( - result, - Some(ChannelId::KaspaTn10), - "InitLock should return its destination as the counterparty channel" + assert!( + result.is_none(), + "no counterparty channel until the counter commitment locks" ); } diff --git a/crates/handler/src/handle/commitment.rs b/crates/handler/src/handle/commitment.rs deleted file mode 100644 index 24dce8f..0000000 --- a/crates/handler/src/handle/commitment.rs +++ /dev/null @@ -1,549 +0,0 @@ -use alloy::primitives::U256; - -use crate::result::Result; -use crate::{Handler, HandlerError, normalised_address_eq, required_init_lock_secs}; -use stroemnet_amounts::Amounts; -use stroemnet_protocol::v1::{AddressesV1, AmountV1, CommitmentV1}; -use stroemnet_protocol::{ChainClock, ChannelId}; - -impl Handler { - /// Handles an internal commitment coming from another channel - /// but from within our own system. - pub async fn handle_internal_commitment( - &self, - commitment: &CommitmentV1, - channel_id: ChannelId, - clock: &ChainClock, - ) -> Result { - tracing::info!("Handling internal commitment: {:?}", commitment); - - // Try and check if this is an existing swap or not. - let tracker_read = self.swap_tracker.read().await; - let record = tracker_read - .get_swap(&commitment.swap_id) - .ok_or(HandlerError::SwapNotFound(commitment.swap_id))?; - - // If the swap exists, we expect it to be in the InitLock stage, - // meaning we have seen the init commitment but not the counter commitment yet. - // If there is a counter commitment already it means that the swap's two parties - // have already locked the swap and we should not be receiving any more commitments for this swap. - if record.counter_commitment.is_some() || record.resolution.is_some() { - tracing::warn!( - "Received commitment for swap that is not in InitLock state. Swap ID: {:?}", - commitment.swap_id - ); - return Err(HandlerError::InvalidState(commitment.swap_id)); - } - - // Since we dont have any counter commitment yet, this means that - // most likely we need to counter this commitment by creating a mirrored commitment - - // Lets read the init commitment - let init_commitment = record.init_commitment.clone(); - - let source_channel = ChannelId::try_from(init_commitment.source)?; - let destination_channel = ChannelId::try_from(init_commitment.destination)?; - - // If the destination channel doesnt match the channel id of this invocation - // it means that this swap is not intended for our channel. - if destination_channel != channel_id { - tracing::error!( - "Received commitment for swap that is not intended for our channel. Swap ID: {:?}, Destination: {:?}, Our Channel: {:?}", - commitment.swap_id, - destination_channel, - channel_id - ); - return Err(HandlerError::InvalidChannelId(destination_channel)); - } - - // Retrieve our address for the source channel of this swap - let our_source_address = self - .address_lookup_table - .get(&source_channel) - .ok_or(HandlerError::MissingAddress(source_channel))?; - - // If we are to receive the proceeds, it must mean that our source address - // matches the direct recipient on the source chain, if it does not - // we should simply skip this commitment as it is not addressed to us - if !normalised_address_eq( - source_channel, - &init_commitment.addresses.receiver, - our_source_address, - ) { - tracing::debug!( - "Skipping commitment not addressed to us. Swap ID: {:?}, \ - receiver: {}, our address on {}: {}", - commitment.swap_id, - init_commitment.addresses.receiver, - source_channel, - our_source_address - ); - return Err(HandlerError::NotAddressedToUs(commitment.swap_id)); - } - - // Parse the amount in as u256 - let amount_in = U256::from_str_radix(&init_commitment.amount.value, 10)?; - let source_usd_price = self - .price_storage - .get(&source_channel) - .ok_or(HandlerError::MissingPriceData(source_channel))?; - - // Compute it in float, scale it to the decimals of the init commitment - let amount_in_f = amount_in.to_string().parse::()?; - let scale = 10u128.pow(record.init_commitment.amount.decimals as u32) as f64; - - // Compute the amountin in USD value - let amount_in_usd = (amount_in_f / scale) * source_usd_price; - - // If the amount in USD is below a minimum trade value or above a minimum trade value - // we skip the swap - if amount_in_usd < self.config.min_trade_usd || amount_in_usd > self.config.max_trade_usd { - tracing::error!( - "Received commitment with USD value out of bounds. USD Value: {}, Min: {}, Max: {}. Swap ID: {:?}", - amount_in_usd, - self.config.min_trade_usd, - self.config.max_trade_usd, - commitment.swap_id - ); - return Err(HandlerError::InvalidAmount(amount_in)); - } - drop(tracker_read); - - // Retrieve the usd price for the destination token - let destination_usd_price = self - .price_storage - .get(&destination_channel) - .ok_or(HandlerError::MissingPriceData(destination_channel))?; - - // Now compute an instant amount out for the specified price - let amount_out = Amounts::amount_out( - amount_in, - source_usd_price, - source_channel.decimals(), - destination_usd_price, - destination_channel.decimals(), - self.config.spread_percent, - )?; - - // Retrieve the current timestamp - let source_now = clock - .now_checked(source_channel) - .ok_or(HandlerError::ChainTimeUnavailable(source_channel))?; - - // Compute how much time left there is for the initiating party - // until they are able to refund. - let lock_time_duration = init_commitment.unlock_ts.saturating_sub(source_now); - - // Compute the required init lock duration that we have configured - let threshold_duration = - required_init_lock_secs(destination_channel, self.config.commit_buffer_secs); - - // If the remaining duration of the initiating party's swap - // we throw an error, because the user did not lock for long enough - // their swap will be refunded via CCR. - if lock_time_duration < threshold_duration { - tracing::error!( - "Init-lock duration too short for {} finality. \ - Duration: {}s, Required: {}s. Swap ID: {:?}", - destination_channel.to_string(), - lock_time_duration, - threshold_duration, - commitment.swap_id - ); - return Err(HandlerError::InvalidLockTimeDuration(lock_time_duration)); - } - - // Now compute our lock time which is basically the current timestamp - // and the finality seconds for this chain. - let dest_now = clock - .now_checked(destination_channel) - .ok_or(HandlerError::ChainTimeUnavailable(destination_channel))?; - let returning_unlock_ts = dest_now + destination_channel.finality_secs(); - - // Retrieve destination address for us - let mm_destination_address = self - .address_lookup_table - .get(&destination_channel) - .ok_or(HandlerError::MissingAddress(destination_channel))?; - - // Retrieve our source address - let mm_source_address = self - .address_lookup_table - .get(&source_channel) - .ok_or(HandlerError::MissingAddress(source_channel))?; - - // Now create a counter commitment with the same swap - let returning_commitment = CommitmentV1 { - swap_id: commitment.swap_id, - addresses: AddressesV1::new( - mm_destination_address.clone(), - init_commitment.addresses.sender_destination.clone(), - mm_source_address.clone(), - ), - amount: AmountV1::new(amount_out.to_string(), destination_channel.decimals()), - secret_hash: init_commitment.secret_hash, - unlock_ts: returning_unlock_ts, - source: destination_channel as u8, - destination: source_channel as u8, - }; - Ok(returning_commitment) - } - - /// Function for internally mapping external commitments to swap states - /// within the system. - pub async fn handle_external_commitment(&self, commitment: CommitmentV1) -> Result<()> { - tracing::info!("Handling external commitment: {:?}", commitment); - - let mut tracker_write = self.swap_tracker.write().await; - // Try and retrieve a swap with this id. - if let Some(record) = tracker_write.get_swap(&commitment.swap_id) { - // If it exists it is a duplicate, and therefore we should discard it - if record.init_commitment == commitment { - tracing::debug!( - "duplicate init commitment for swap {} — ignoring", - hex::encode(commitment.swap_id) - ); - return Ok(()); - } - if record.counter_commitment.as_ref() == Some(&commitment) { - tracing::debug!( - "duplicate counter commitment for swap {} — ignoring", - hex::encode(commitment.swap_id) - ); - return Ok(()); - } - - // We shouldnt allow the initial source to submit a counter commitment - // regardless if they made a mistake or malicious reason - if record.init_commitment.source == commitment.source { - tracing::debug!( - "same-source re-arrival for swap {} (source={}) — ignoring", - hex::encode(commitment.swap_id), - commitment.source - ); - return Ok(()); - } - - // All verifications passed, we can set the counter commitment - tracker_write.set_counter_commitment(commitment.swap_id, commitment)?; - } else { - // If it does not exist, it means it is the first commitment - tracker_write.set_init_commitment(commitment.swap_id, commitment)?; - } - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use std::sync::Arc; - - use tokio::sync::RwLock; - - use crate::HandlerConfig; - use crate::test_fixtures::{create_test_handler_with, default_test_config, lp_addresses}; - use crate::{Handler, HandlerError}; - use stroemnet_protocol::{ChainClock, ChannelId}; - use stroemnet_protocol::swap_tracker::{SwapStage, SwapTracker}; - use stroemnet_protocol::v1::{AddressesV1, AmountV1, CommitmentV1}; - - async fn create_test_handler( - kaspa_price: f64, - eth_price: f64, - ) -> (Handler, Arc>) { - create_test_handler_with( - default_test_config(), - &[ - (ChannelId::KaspaTn10, kaspa_price), - (ChannelId::EthereumSepolia, eth_price), - ], - lp_addresses(), - ) - } - - fn mock_init_commitment(swap_id: [u8; 32]) -> CommitmentV1 { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); - CommitmentV1 { - swap_id, - addresses: AddressesV1::new( - "0xUserEthSender".to_string(), - "0xMmEthereumAddress".to_string(), - "kaspa:user_dest_address".to_string(), - ), - amount: AmountV1::new("1000000000000000000".to_string(), 18), - - secret_hash: [0xEE; 32], - unlock_ts: now + 3600, - source: ChannelId::EthereumSepolia as u8, - destination: ChannelId::KaspaTn10 as u8, - } - } - - fn mock_counter_commitment(swap_id: [u8; 32]) -> CommitmentV1 { - CommitmentV1 { - swap_id, - addresses: AddressesV1::new( - "kaspa:mm_kaspa_address".to_string(), - "kaspa:user_dest_address".to_string(), - "0xMmEthereumAddress".to_string(), - ), - amount: AmountV1::new("50000000".to_string(), 18), - secret_hash: [0xEE; 32], - unlock_ts: u64::MAX, - source: ChannelId::KaspaTn10 as u8, - destination: ChannelId::EthereumSepolia as u8, - } - } - - #[tokio::test] - async fn external_commitment_new_swap_creates_init_lock() { - let (handler, tracker) = create_test_handler(0.15, 3000.0).await; - let swap_id = [1u8; 32]; - let commitment = mock_init_commitment(swap_id); - - handler - .handle_external_commitment(commitment.clone()) - .await - .unwrap(); - - let t = tracker.read().await; - let record = t.get_swap(&swap_id).unwrap(); - assert_eq!(SwapTracker::stage(record), SwapStage::Initialized); - assert_eq!(record.init_commitment.swap_id, swap_id); - assert_eq!( - record.init_commitment.addresses.sender, - commitment.addresses.sender - ); - } - - #[tokio::test] - async fn external_commitment_existing_init_lock_transitions_to_lock() { - let (handler, tracker) = create_test_handler(0.15, 3000.0).await; - let swap_id = [2u8; 32]; - - let init_commitment = mock_init_commitment(swap_id); - handler - .handle_external_commitment(init_commitment.clone()) - .await - .unwrap(); - - let counter = mock_counter_commitment(swap_id); - handler - .handle_external_commitment(counter.clone()) - .await - .unwrap(); - - let t = tracker.read().await; - let record = t.get_swap(&swap_id).unwrap(); - assert_eq!(SwapTracker::stage(record), SwapStage::Locked); - assert_eq!(record.init_commitment.swap_id, swap_id); - assert_eq!( - record.counter_commitment.as_ref().unwrap().addresses.sender, - counter.addresses.sender - ); - } - - #[tokio::test] - async fn internal_commitment_swap_not_found() { - let (handler, _tracker) = create_test_handler(0.15, 3000.0).await; - let swap_id = [3u8; 32]; - let commitment = mock_init_commitment(swap_id); - - let err = handler - .handle_internal_commitment(&commitment, ChannelId::KaspaTn10, &ChainClock::default()) - .await - .unwrap_err(); - assert!(matches!(err, HandlerError::SwapNotFound(_))); - } - - #[tokio::test] - async fn internal_commitment_wrong_state() { - let (handler, tracker) = create_test_handler(0.15, 3000.0).await; - let swap_id = [4u8; 32]; - - let init = mock_init_commitment(swap_id); - let counter = mock_counter_commitment(swap_id); - { - let mut t = tracker.write().await; - t.set_init_commitment(swap_id, init.clone()).unwrap(); - t.set_counter_commitment(swap_id, counter).unwrap(); - } - - let err = handler - .handle_internal_commitment(&init, ChannelId::KaspaTn10, &ChainClock::default()) - .await - .unwrap_err(); - assert!(matches!(err, HandlerError::InvalidState(_))); - } - - #[tokio::test] - async fn internal_commitment_wrong_destination_channel() { - let (handler, tracker) = create_test_handler(0.15, 3000.0).await; - let swap_id = [5u8; 32]; - - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); - let init = CommitmentV1 { - swap_id, - addresses: AddressesV1::new( - "kaspa:sender".to_string(), - "kaspa:receiver".to_string(), - "0xSenderDest".to_string(), - ), - amount: AmountV1::new("100000000".to_string(), 8), - secret_hash: [0xEE; 32], - unlock_ts: now + 3600, - source: ChannelId::KaspaTn10 as u8, - destination: ChannelId::EthereumSepolia as u8, - }; - { - let mut t = tracker.write().await; - t.set_init_commitment(swap_id, init.clone()).unwrap(); - } - - let err = handler - .handle_internal_commitment(&init, ChannelId::KaspaTn10, &ChainClock::default()) - .await - .unwrap_err(); - assert!(matches!(err, HandlerError::InvalidChannelId(_))); - } - - #[tokio::test] - async fn internal_commitment_lock_time_too_close() { - let (handler, tracker) = create_test_handler(0.15, 3000.0).await; - let swap_id = [6u8; 32]; - - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); - - let init = CommitmentV1 { - swap_id, - addresses: AddressesV1::new( - "0xUserEthSender".to_string(), - "0xMmEthereumAddress".to_string(), - "kaspa:user_dest_address".to_string(), - ), - amount: AmountV1::new("1000000000000000000".to_string(), 18), - secret_hash: [0xEE; 32], - unlock_ts: now + 60, - source: ChannelId::EthereumSepolia as u8, - destination: ChannelId::KaspaTn10 as u8, - }; - { - let mut t = tracker.write().await; - t.set_init_commitment(swap_id, init.clone()).unwrap(); - } - - let err = handler - .handle_internal_commitment(&init, ChannelId::KaspaTn10, &ChainClock::default()) - .await - .unwrap_err(); - assert!(matches!(err, HandlerError::InvalidLockTimeDuration(_))); - } - - #[tokio::test] - async fn internal_commitment_valid_returns_mirrored() { - let (handler, tracker) = create_test_handler(0.15, 3000.0).await; - let swap_id = [7u8; 32]; - let commitment = mock_init_commitment(swap_id); - { - let mut t = tracker.write().await; - t.set_init_commitment(swap_id, commitment.clone()).unwrap(); - } - - let result = handler - .handle_internal_commitment(&commitment, ChannelId::KaspaTn10, &ChainClock::default()) - .await - .unwrap(); - - assert_eq!(result.swap_id, swap_id); - - assert_eq!(result.addresses.sender, "kaspa:mm_kaspa_address"); - assert_eq!( - result.addresses.receiver, - commitment.addresses.sender_destination - ); - - assert_eq!(result.addresses.sender_destination, "0xMmEthereumAddress"); - - assert_eq!(result.source, ChannelId::KaspaTn10 as u8); - assert_eq!(result.destination, ChannelId::EthereumSepolia as u8); - assert_eq!(result.secret_hash, commitment.secret_hash); - assert!(result.unlock_ts > 0); - let amount: alloy::primitives::U256 = - alloy::primitives::U256::from_str_radix(&result.amount.value, 10).unwrap(); - assert!(amount > alloy::primitives::U256::ZERO); - } - - #[tokio::test] - async fn internal_commitment_amount_out_of_bounds() { - let (handler, swap_tracker) = create_test_handler_with( - HandlerConfig { - min_trade_usd: 5000.0, - max_trade_usd: 10_000.0, - ..default_test_config() - }, - &[ - (ChannelId::KaspaTn10, 0.15), - (ChannelId::EthereumSepolia, 3000.0), - ], - lp_addresses(), - ); - - let swap_id = [8u8; 32]; - let commitment = mock_init_commitment(swap_id); - { - let mut t = swap_tracker.write().await; - t.set_init_commitment(swap_id, commitment.clone()).unwrap(); - } - - let err = handler - .handle_internal_commitment(&commitment, ChannelId::KaspaTn10, &ChainClock::default()) - .await - .unwrap_err(); - assert!(matches!(err, HandlerError::InvalidAmount(_))); - } - - #[tokio::test] - async fn internal_commitment_rejected_when_not_addressed_to_us() { - let (handler, swap_tracker) = create_test_handler(0.0001, 1000.0).await; - let swap_id = [42u8; 32]; - let mut commitment = mock_init_commitment(swap_id); - - commitment.addresses.receiver = "0xSomeOtherLpEthAddress".to_string(); - - { - let mut t = swap_tracker.write().await; - t.set_init_commitment(swap_id, commitment.clone()).unwrap(); - } - - let err = handler - .handle_internal_commitment(&commitment, ChannelId::KaspaTn10, &ChainClock::default()) - .await - .unwrap_err(); - assert!( - matches!(err, HandlerError::NotAddressedToUs(id) if id == swap_id), - "expected NotAddressedToUs, got {err:?}" - ); - - let our_swap_id = [43u8; 32]; - let our_commit = mock_init_commitment(our_swap_id); - { - let mut t = swap_tracker.write().await; - t.set_init_commitment(our_swap_id, our_commit.clone()) - .unwrap(); - } - let counter = handler - .handle_internal_commitment(&our_commit, ChannelId::KaspaTn10, &ChainClock::default()) - .await - .expect("commit addressed to us should pass the filter"); - assert_eq!(counter.swap_id, our_swap_id); - } -} diff --git a/crates/handler/src/handle/commitment/external.rs b/crates/handler/src/handle/commitment/external.rs new file mode 100644 index 0000000..7e57cdb --- /dev/null +++ b/crates/handler/src/handle/commitment/external.rs @@ -0,0 +1,117 @@ +use crate::Handler; +use crate::result::Result; +use stroemnet_protocol::v1::CommitmentV1; + +impl Handler { + /// Handle a commitment that came from an external chain + pub async fn handle_external_commitment(&self, commitment: CommitmentV1) -> Result<()> { + let mut tracker = self.swap_tracker.write().await; + + // Either retrieve the commitment or set it and return + let Some(record) = tracker.get_swap(&commitment.swap_id) else { + tracker.set_init_commitment(commitment.swap_id, commitment)?; + return Ok(()); + }; + + // If we already have a commitment and its eq to the init commitment + // or if we already have a counter commitment + if record.init_commitment == commitment + || record.counter_commitment.as_ref() == Some(&commitment) + || record.init_commitment.source == commitment.source + { + return Ok(()); + } + + // Then we can attempt to set a counter commitment + tracker.set_counter_commitment(commitment.swap_id, commitment)?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use crate::test_fixtures::{create_test_handler_with, default_test_config, lp_addresses}; + use stroemnet_protocol::ChannelId; + use stroemnet_protocol::swap_tracker::{SwapStage, SwapTracker}; + use stroemnet_protocol::v1::{AddressesV1, AmountV1, CommitmentV1}; + + fn init(swap_id: [u8; 32]) -> CommitmentV1 { + CommitmentV1::new( + swap_id, + AddressesV1::new("0xUser".into(), "0xMm".into(), "kaspa:dest".into()), + AmountV1::new("1000000000000000000".into(), 18), + [0xEE; 32], + u64::MAX, + ChannelId::EthereumSepolia as u8, + ChannelId::KaspaTn10 as u8, + ) + } + + fn counter(swap_id: [u8; 32]) -> CommitmentV1 { + CommitmentV1::new( + swap_id, + AddressesV1::new("kaspa:mm".into(), "kaspa:dest".into(), "0xMm".into()), + AmountV1::new("50000000".into(), 18), + [0xEE; 32], + u64::MAX, + ChannelId::KaspaTn10 as u8, + ChannelId::EthereumSepolia as u8, + ) + } + + #[tokio::test] + async fn new_swap_creates_init_lock_then_counter_locks() { + let (handler, tracker) = create_test_handler_with( + default_test_config(), + &[ + (ChannelId::KaspaTn10, 0.15), + (ChannelId::EthereumSepolia, 3000.0), + ], + lp_addresses(), + ); + let swap_id = [1u8; 32]; + handler + .handle_external_commitment(init(swap_id)) + .await + .unwrap(); + { + let t = tracker.read().await; + assert_eq!( + SwapTracker::stage(t.get_swap(&swap_id).unwrap()), + SwapStage::Initialized + ); + } + handler + .handle_external_commitment(counter(swap_id)) + .await + .unwrap(); + let t = tracker.read().await; + assert_eq!( + SwapTracker::stage(t.get_swap(&swap_id).unwrap()), + SwapStage::Locked + ); + } + + #[tokio::test] + async fn same_source_rearrival_is_ignored() { + let (handler, tracker) = create_test_handler_with( + default_test_config(), + &[ + (ChannelId::KaspaTn10, 0.15), + (ChannelId::EthereumSepolia, 3000.0), + ], + lp_addresses(), + ); + let swap_id = [2u8; 32]; + handler + .handle_external_commitment(init(swap_id)) + .await + .unwrap(); + let mut dup = init(swap_id); + dup.amount = AmountV1::new("999".into(), 18); + handler.handle_external_commitment(dup).await.unwrap(); + let t = tracker.read().await; + assert!(t.get_swap(&swap_id).unwrap().counter_commitment.is_none()); + } +} diff --git a/crates/handler/src/handle/commitment/internal.rs b/crates/handler/src/handle/commitment/internal.rs new file mode 100644 index 0000000..6de2a08 --- /dev/null +++ b/crates/handler/src/handle/commitment/internal.rs @@ -0,0 +1,129 @@ +use crate::result::Result; +use crate::{Handler, HandlerError, normalised_address_eq}; +use stroemnet_protocol::v1::CommitmentV1; +use stroemnet_protocol::{ChainClock, ChannelId}; + +impl Handler { + /// Handle an internval commitment that was sent to us via an internal channel from one + /// of our channels + pub async fn handle_internal_commitment( + &self, + commitment: &CommitmentV1, + channel_id: ChannelId, + clock: &ChainClock, + ) -> Result { + // Try and retrieve the init commitment + let init_commitment = { + let tracker = self.swap_tracker.read().await; + let record = tracker + .get_swap(&commitment.swap_id) + .ok_or(HandlerError::SwapNotFound(commitment.swap_id))?; + if record.counter_commitment.is_some() || record.resolution.is_some() { + return Err(HandlerError::InvalidState(commitment.swap_id)); + } + record.init_commitment.clone() + }; + + // Parse the source and destination channels + let source_channel = ChannelId::try_from(init_commitment.source)?; + let destination_channel = ChannelId::try_from(init_commitment.destination)?; + if destination_channel != channel_id { + return Err(HandlerError::InvalidChannelId(destination_channel)); + } + + // Get our source address + let our_source_address = self + .address_lookup_table + .get(&source_channel) + .ok_or(HandlerError::MissingAddress(source_channel))?; + + // Check if this commitment receiver is our address + if !normalised_address_eq( + source_channel, + &init_commitment.addresses.receiver, + our_source_address, + ) { + return Err(HandlerError::NotAddressedToUs(commitment.swap_id)); + } + + // Its addressed to us we need to build a counter commitment + self.build_counter_commitment( + commitment.swap_id, + &init_commitment, + source_channel, + destination_channel, + clock, + ) + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use crate::test_fixtures::{create_test_handler_with, default_test_config, lp_addresses}; + use crate::{Handler, HandlerError}; + use std::sync::Arc; + use stroemnet_protocol::swap_tracker::SwapTracker; + use stroemnet_protocol::v1::{AddressesV1, AmountV1, CommitmentV1}; + use stroemnet_protocol::{ChainClock, ChannelId}; + use tokio::sync::RwLock; + + pub(super) async fn handler() -> (Handler, Arc>) { + create_test_handler_with( + default_test_config(), + &[ + (ChannelId::KaspaTn10, 0.15), + (ChannelId::EthereumSepolia, 3000.0), + ], + lp_addresses(), + ) + } + + pub(super) fn init(swap_id: [u8; 32], unlock_ts: u64) -> CommitmentV1 { + CommitmentV1::new( + swap_id, + AddressesV1::new( + "0xUserEthSender".into(), + "0xMmEthereumAddress".into(), + "kaspa:user_dest".into(), + ), + AmountV1::new("1000000000000000000".into(), 18), + [0xEE; 32], + unlock_ts, + ChannelId::EthereumSepolia as u8, + ChannelId::KaspaTn10 as u8, + ) + } + + #[tokio::test] + async fn swap_not_found() { + let (handler, _t) = handler().await; + let err = handler + .handle_internal_commitment( + &init([3u8; 32], u64::MAX), + ChannelId::KaspaTn10, + &ChainClock::default(), + ) + .await + .unwrap_err(); + assert!(matches!(err, HandlerError::SwapNotFound(_))); + } + + #[tokio::test] + async fn rejected_when_not_addressed_to_us() { + let (handler, tracker) = handler().await; + let swap_id = [42u8; 32]; + let mut c = init(swap_id, u64::MAX); + c.addresses.receiver = "0xSomeOtherLp".into(); + tracker + .write() + .await + .set_init_commitment(swap_id, c.clone()) + .unwrap(); + let err = handler + .handle_internal_commitment(&c, ChannelId::KaspaTn10, &ChainClock::default()) + .await + .unwrap_err(); + assert!(matches!(err, HandlerError::NotAddressedToUs(id) if id == swap_id)); + } +} diff --git a/crates/handler/src/handle/commitment/mirror.rs b/crates/handler/src/handle/commitment/mirror.rs new file mode 100644 index 0000000..05d17c6 --- /dev/null +++ b/crates/handler/src/handle/commitment/mirror.rs @@ -0,0 +1,214 @@ +use alloy::primitives::U256; + +use crate::result::Result; +use crate::{Handler, HandlerError, required_init_lock_secs}; +use stroemnet_amounts::Amounts; +use stroemnet_protocol::v1::{AddressesV1, AmountV1, CommitmentV1}; +use stroemnet_protocol::{ChainClock, ChannelId}; + +impl Handler { + /// Build a counter commitment based on the users initial commitment + pub(super) fn build_counter_commitment( + &self, + swap_id: [u8; 32], + init_commitment: &CommitmentV1, + source_channel: ChannelId, + destination_channel: ChannelId, + clock: &ChainClock, + ) -> Result { + // Compute the amount in from the commitment + let amount_in = U256::from_str_radix(&init_commitment.amount.value, 10)?; + + // Retrieve the usd source price + let source_usd_price = self + .price_storage + .get(&source_channel) + .ok_or(HandlerError::MissingPriceData(source_channel))?; + + // Compute the amount in in f64 + let amount_in_f = amount_in.to_string().parse::()?; + + // Compute the scale by scaling + let scale = 10u128.pow(init_commitment.amount.decimals as u32) as f64; + + // Scale down the amount in and multiply it by the source usd price to get usd value + let amount_in_usd = (amount_in_f / scale) * source_usd_price; + + // If its below the threshold + if amount_in_usd < self.config.min_trade_usd || amount_in_usd > self.config.max_trade_usd { + return Err(HandlerError::InvalidAmount(amount_in)); + } + + // Get the destination usd price + let destination_usd_price = self + .price_storage + .get(&destination_channel) + .ok_or(HandlerError::MissingPriceData(destination_channel))?; + + // Compute the amount out for the given swap + let amount_out = Amounts::amount_out( + amount_in, + source_usd_price, + source_channel.decimals(), + destination_usd_price, + destination_channel.decimals(), + self.config.spread_percent, + )?; + + // Get the source timestamp right now + let source_now = clock + .now_checked(source_channel) + .ok_or(HandlerError::ChainTimeUnavailable(source_channel))?; + + // Compute how much time is remaining + let remaining = init_commitment.unlock_ts.saturating_sub(source_now); + + // Compute the required threshold for the destination chain + let threshold = + required_init_lock_secs(destination_channel, self.config.commit_buffer_secs, false); + + // If its below the threshold we cannot accept this swap, the only + // way forward is a refund + if remaining < threshold { + return Err(HandlerError::InvalidLockTimeDuration(remaining)); + } + + // Compute the destination time stamp right now + let dest_now = clock + .now_checked(destination_channel) + .ok_or(HandlerError::ChainTimeUnavailable(destination_channel))?; + + // Retrieve the desintation and source addresses for us as MM + let mm_destination = self + .address_lookup_table + .get(&destination_channel) + .ok_or(HandlerError::MissingAddress(destination_channel))?; + let mm_source = self + .address_lookup_table + .get(&source_channel) + .ok_or(HandlerError::MissingAddress(source_channel))?; + + // Create the counter commitment + Ok(CommitmentV1 { + swap_id, + addresses: AddressesV1::new( + mm_destination.clone(), + init_commitment.addresses.sender_destination.clone(), + mm_source.clone(), + ), + amount: AmountV1::new(amount_out.to_string(), destination_channel.decimals()), + secret_hash: init_commitment.secret_hash, + unlock_ts: dest_now + destination_channel.lock_time_secs(), + source: destination_channel as u8, + destination: source_channel as u8, + }) + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use crate::test_fixtures::{create_test_handler_with, default_test_config, lp_addresses}; + use crate::{HandlerConfig, HandlerError}; + use stroemnet_protocol::v1::{AddressesV1, AmountV1, CommitmentV1}; + use stroemnet_protocol::{ChainClock, ChannelId}; + + fn init(swap_id: [u8; 32], unlock_ts: u64) -> CommitmentV1 { + CommitmentV1::new( + swap_id, + AddressesV1::new( + "0xUserEthSender".into(), + "0xMmEthereumAddress".into(), + "kaspa:user_dest".into(), + ), + AmountV1::new("1000000000000000000".into(), 18), + [0xEE; 32], + unlock_ts, + ChannelId::EthereumSepolia as u8, + ChannelId::KaspaTn10 as u8, + ) + } + + #[tokio::test] + async fn valid_returns_mirrored_with_positive_amount() { + let (h, tracker) = create_test_handler_with( + default_test_config(), + &[ + (ChannelId::KaspaTn10, 0.15), + (ChannelId::EthereumSepolia, 3000.0), + ], + lp_addresses(), + ); + let swap_id = [7u8; 32]; + let c = init(swap_id, u64::MAX); + tracker + .write() + .await + .set_init_commitment(swap_id, c.clone()) + .unwrap(); + let out = h + .handle_internal_commitment(&c, ChannelId::KaspaTn10, &ChainClock::default()) + .await + .unwrap(); + assert_eq!(out.source, ChannelId::KaspaTn10 as u8); + assert_eq!(out.destination, ChannelId::EthereumSepolia as u8); + assert_eq!(out.secret_hash, c.secret_hash); + assert!(out.unlock_ts > 0); + } + + #[tokio::test] + async fn lock_time_too_close_is_rejected() { + let (h, tracker) = create_test_handler_with( + default_test_config(), + &[ + (ChannelId::KaspaTn10, 0.15), + (ChannelId::EthereumSepolia, 3000.0), + ], + lp_addresses(), + ); + let swap_id = [6u8; 32]; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let c = init(swap_id, now + 60); + tracker + .write() + .await + .set_init_commitment(swap_id, c.clone()) + .unwrap(); + let err = h + .handle_internal_commitment(&c, ChannelId::KaspaTn10, &ChainClock::default()) + .await + .unwrap_err(); + assert!(matches!(err, HandlerError::InvalidLockTimeDuration(_))); + } + + #[tokio::test] + async fn amount_out_of_bounds_is_rejected() { + let (h, tracker) = create_test_handler_with( + HandlerConfig { + min_trade_usd: 5000.0, + max_trade_usd: 10_000.0, + ..default_test_config() + }, + &[ + (ChannelId::KaspaTn10, 0.15), + (ChannelId::EthereumSepolia, 3000.0), + ], + lp_addresses(), + ); + let swap_id = [8u8; 32]; + let c = init(swap_id, u64::MAX); + tracker + .write() + .await + .set_init_commitment(swap_id, c.clone()) + .unwrap(); + let err = h + .handle_internal_commitment(&c, ChannelId::KaspaTn10, &ChainClock::default()) + .await + .unwrap_err(); + assert!(matches!(err, HandlerError::InvalidAmount(_))); + } +} diff --git a/crates/handler/src/handle/commitment/mod.rs b/crates/handler/src/handle/commitment/mod.rs new file mode 100644 index 0000000..ffe096f --- /dev/null +++ b/crates/handler/src/handle/commitment/mod.rs @@ -0,0 +1,3 @@ +mod external; +mod internal; +mod mirror; diff --git a/crates/handler/src/handle/proposal.rs b/crates/handler/src/handle/proposal.rs index adddc58..51b96ac 100644 --- a/crates/handler/src/handle/proposal.rs +++ b/crates/handler/src/handle/proposal.rs @@ -6,68 +6,65 @@ use stroemnet_amounts::Amounts; use stroemnet_protocol::ChannelId; #[derive(Clone, Debug)] -/// A request to create a trade proposal, including the origin and destination channels and the input amount. -/// This is sent by users who dont necessarily run LP nodes. +/// A swap request it contains the origin destination +/// and amount, this is what LPs quote against pub struct SwapRequest { - /// The channel id origin pub origin: u8, - /// The destination channel id pub destination: u8, - /// The amount to swap as a string, which will be parsed according to the origin channel's decimals pub amount: String, } #[derive(Clone, Debug)] -/// A trade proposal generated by an LP -/// in response to a swap request +/// A proposal from an LP containing origin, destination +/// how much amount in yields amount out, the LPs destination address +/// and the offset seconds that the user needs to lock for in order for the +/// LP to consider it valid, too short lock times give in general quite substantial risk to +/// the LP. pub struct TradeProposal { - /// The origin of the swap from the persptive of the LP + /// Where the swap originates from pub origin: ChannelId, - /// The destination of the swap from the perspective of the LP + /// Where the swap is going pub destination: ChannelId, - /// Amount in from the perspective of the LP + /// Amount in to swap pub amount_in: String, - /// Amount out from the perspective of the LP, - /// which is what the user will pay + /// Amount out in the destination token pub amount_out: String, - /// The destination address of the sender, in this case the LP's address on the origin chain - /// which is where the user should send funds to. + /// The senders destination address for which the receiver should lock funds against pub sender_destination_address: String, - /// The number of seconds that the user needs to put as additional offset from their current - /// timestamp as unlock time for the commitment, + /// Required offset i.e. lock time for the user pub commit_unlock_offset_secs: u64, } impl Handler { - /// Create a trade proposal based on a swap request, which includes validating the request, + /// For a user's swap request we need to create a countering trade proposal + /// essentially a quote. pub async fn create_proposal(&self, request: &SwapRequest) -> Result { - // Parse the requested origin and destination channels let origin = ChannelId::try_from(request.origin)?; let destination = ChannelId::try_from(request.destination)?; - // Retrieve the usd price for the origin let source_usd_price = self .price_storage .get(&origin) .ok_or(HandlerError::MissingPriceData(origin))?; - // Retrieve the destination usd price let destination_usd_price = self .price_storage .get(&destination) .ok_or(HandlerError::MissingPriceData(destination))?; - // Conver the amount in to alloy u256 + // Parse amount in let amount_in = U256::from_str_radix(&request.amount, 10)?; - // Convert the amount to float + // Conver tthe amount in to f64 let amount_in_f = amount_in.to_string().parse::()?; - // Scale it from the origin decimal value to human readable "ether" + // Compute the scale for which we need to divide by to get it to 'ether' units let scale = 10u128.pow(origin.decimals() as u32) as f64; + + // Scale down to ether units and multiply by source usd price to get the amount in usd let amount_in_usd = (amount_in_f / scale) * source_usd_price; - // If the amount in usd is smaller than the minimum allowed, return an error + // If the amount in usd is below the minimum threshold we reject if amount_in_usd < self.config.min_trade_usd { return Err(HandlerError::TradeTooSmall { amount_in: request.amount.clone(), @@ -76,7 +73,7 @@ impl Handler { }); } - // If the amount in usd is larger than the maximum allowed, return an error + // If the amount in is above the max threshold we reject too if amount_in_usd > self.config.max_trade_usd { return Err(HandlerError::TradeTooLarge { amount_in: request.amount.clone(), @@ -85,8 +82,7 @@ impl Handler { }); } - // Use the amounts crate to compute proper output amoutn given - // price, spread and decimals, + // Compute amount out, let output = Amounts::amount_out( amount_in, source_usd_price, @@ -101,16 +97,19 @@ impl Handler { self.config.spread_percent ); - // Retrieve our sender destination address, + // Retrieve the sender destination address, from the origin + // (our address) let sender_destination_address = self .address_lookup_table .get(&origin) .ok_or(HandlerError::UnknownChannel(origin))? .clone(); - // Compute the required offset for unlock from our requirements + // Compute the commit unlock offset + // This time we enforce a buffer in order to account for the time it takes for users + // commitment to propagate across all networks and reach us again let commit_unlock_offset_secs = - required_init_lock_secs(destination, self.config.commit_buffer_secs); + required_init_lock_secs(destination, self.config.commit_buffer_secs, true); Ok(TradeProposal { destination: origin, @@ -125,6 +124,12 @@ impl Handler { #[cfg(test)] mod tests { + #![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing + )] use ahash::AHashMap; use alloy::primitives::U256; diff --git a/crates/handler/src/handle/refund.rs b/crates/handler/src/handle/refund.rs index 58513d8..203ba35 100644 --- a/crates/handler/src/handle/refund.rs +++ b/crates/handler/src/handle/refund.rs @@ -3,7 +3,7 @@ use crate::result::Result; use stroemnet_protocol::v1::RefundV1; impl Handler { - /// Handle an external refund which is basically the fact that we mark it as refunded + /// Handle an external refund event by setting the swap id as refunded pub async fn handle_external_refund(&self, refund: RefundV1) -> Result<()> { tracing::info!("Handling refund: {:?}", refund); let mut tracker_write = self.swap_tracker.write().await; @@ -15,6 +15,12 @@ impl Handler { #[cfg(test)] mod tests { + #![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing + )] use crate::test_fixtures::{ create_test_handler, mock_counter_commitment, mock_init_commitment, }; diff --git a/crates/handler/src/handle/reveal.rs b/crates/handler/src/handle/reveal.rs index 28e1664..ce8cf34 100644 --- a/crates/handler/src/handle/reveal.rs +++ b/crates/handler/src/handle/reveal.rs @@ -3,7 +3,7 @@ use crate::result::Result; use stroemnet_protocol::v1::RevealV1; impl Handler { - /// Handle an external reveal which is basically the fact that we mark it as revealed and store the secret + /// Handle an external reveal event which sets the swap to be revealed by the swap id pub async fn handle_external_reveal(&self, reveal: RevealV1) -> Result<()> { tracing::info!("Handling reveal: {:?}", reveal); let mut tracker_write = self.swap_tracker.write().await; @@ -15,22 +15,19 @@ impl Handler { #[cfg(test)] mod tests { + #![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing + )] use crate::test_fixtures::{ create_test_handler, mock_counter_commitment_with_secret, mock_init_commitment_with_secret, + sha256, }; use stroemnet_protocol::swap_tracker::SwapStage; use stroemnet_protocol::v1::RevealV1; - fn sha256(secret: &[u8; 32]) -> [u8; 32] { - use sha2::{Digest, Sha256}; - let mut hasher = Sha256::new(); - hasher.update(secret); - let out = hasher.finalize(); - let mut a = [0u8; 32]; - a.copy_from_slice(&out); - a - } - #[tokio::test] async fn reveal_valid_lock_transitions_to_reveal() { let (handler, tracker) = create_test_handler(); diff --git a/crates/handler/src/lib.rs b/crates/handler/src/lib.rs index 192a690..e6048c7 100644 --- a/crates/handler/src/lib.rs +++ b/crates/handler/src/lib.rs @@ -1,4 +1,7 @@ +#![allow(clippy::result_large_err)] + mod address; +mod dispatch; pub mod error; pub mod get; pub mod handle; @@ -10,60 +13,61 @@ mod test_fixtures; use std::sync::Arc; use ahash::AHashMap; -use sha2::{Digest, Sha256}; use stroemnet_amounts::PriceStorage; +use stroemnet_protocol::ChannelId; use stroemnet_protocol::swap_tracker::SwapTracker; -use stroemnet_protocol::{ChainClock, ChannelId}; -use stroemnet_protocol::v1::{ChainEvent, CommitmentV1}; use tokio::sync::RwLock; pub use address::normalised_address_eq; +pub use dispatch::Effect; pub use error::HandlerError; -pub fn required_init_lock_secs(destination: ChannelId, commit_buffer_secs: u64) -> u64 { - 2 * destination.finality_secs() + commit_buffer_secs +pub fn required_init_lock_secs( + destination: ChannelId, + commit_buffer_secs: u64, + with_buffer: bool, +) -> u64 { + // The user needs to always lock for 2x the LP + the commit buffer + if !with_buffer { + return destination.lock_time_secs() * 2; + } + destination.lock_time_secs() * 2 + commit_buffer_secs } #[derive(Debug, Clone)] +/// Configuration for the handler on trade requirements pub struct HandlerConfig { - /// Minimum trade size in USD, which is used to prevent spam and uneconomic trades + /// Minimum usd amount for trading pub min_trade_usd: f64, - /// Maximum trade size in USD, which is used to prevent large trades that may be too risky for the LP + /// Maximum usd amount for trading pub max_trade_usd: f64, - /// The percentage spread that the LP applies to the trade, which is how the LP makes money on each swap + /// Spread percent for trading pub spread_percent: f64, - /// The number of seconds before the required init lock time that we require for a trade, - /// this is not related to chain finality but rather an additional buffer, - /// based on network delay etc. + /// Buffer in seconds to allow trade to propagate across P2P network. pub commit_buffer_secs: u64, } #[derive(Debug)] pub struct Handler { + /// Price storage tracking prices for all channels pub price_storage: PriceStorage, + /// Tracking all swaps pub swap_tracker: Arc>, + /// Configuration for the handler pub config: HandlerConfig, - + /// Lookup table for us as an LP going from a channel id to that address pub address_lookup_table: Arc>, + /// Required block confirmations pub block_confirmations: Arc>, } -#[derive(Debug, Clone)] -pub struct DetectedCommitment { - pub commitment: CommitmentV1, -} - -#[derive(Debug, Clone)] -pub enum Effect { - Broadcast(ChannelId, ChainEvent), - TransmitReveal(DetectedCommitment), -} - impl Handler { + /// Whether this channel is activated, a bit hacky but works pub fn knows_channel(&self, id: ChannelId) -> bool { self.block_confirmations.contains_key(&id) } + /// Create the handler pub fn new( price_storage: PriceStorage, swap_tracker: Arc>, @@ -79,69 +83,4 @@ impl Handler { block_confirmations, } } - - pub async fn on_chain_event( - &self, - source: ChannelId, - event: ChainEvent, - clock: &ChainClock, - ) -> Result, HandlerError> { - let mut effects = Vec::new(); - match event { - ChainEvent::Commitment(commitment) => { - self.handle_external_commitment(commitment.clone()).await?; - effects.push(Effect::TransmitReveal(DetectedCommitment { - commitment: commitment.clone(), - })); - let destination = ChannelId::try_from(commitment.destination)?; - if destination != source { - match self - .handle_internal_commitment(&commitment, destination, clock) - .await - { - Ok(counter) => { - effects.push(Effect::Broadcast( - destination, - ChainEvent::Commitment(counter), - )); - } - Err(HandlerError::NotAddressedToUs(_)) - | Err(HandlerError::InvalidState(_)) => {} - Err(e) => tracing::warn!("internal commitment: {e}"), - } - } - } - ChainEvent::Reveal(reveal) => { - self.handle_external_reveal(reveal.clone()).await?; - if let Some(counterparty) = self - .get_counterparty_channel_id(&reveal.swap_id, source) - .await? - && let Some(commitment) = self - .get_commitment_for_channel(&reveal.swap_id, counterparty) - .await? - && Sha256::digest(reveal.secret).as_slice() == commitment.secret_hash - { - effects.push(Effect::Broadcast(counterparty, ChainEvent::Reveal(reveal))); - } - } - ChainEvent::Refund(refund) => { - self.handle_external_refund(refund).await?; - } - } - Ok(effects) - } -} - -#[cfg(test)] -mod handler_tests { - use super::*; - - #[test] - fn required_init_lock_includes_propagation_margin() { - let v = required_init_lock_secs(ChannelId::KaspaTn10, 60); - assert_eq!(v, 2 * ChannelId::KaspaTn10.finality_secs() + 60); - - let v = required_init_lock_secs(ChannelId::EthereumSepolia, 30); - assert_eq!(v, 2 * ChannelId::EthereumSepolia.finality_secs() + 30); - } } diff --git a/crates/handler/src/test_fixtures.rs b/crates/handler/src/test_fixtures.rs index ff46b38..350c019 100644 --- a/crates/handler/src/test_fixtures.rs +++ b/crates/handler/src/test_fixtures.rs @@ -12,14 +12,18 @@ use crate::HandlerConfig; pub(crate) const TEST_SECRET: [u8; 32] = [0xAB; 32]; -pub(crate) fn test_secret_hash() -> [u8; 32] { +pub(crate) fn sha256(secret: &[u8; 32]) -> [u8; 32] { use sha2::{Digest, Sha256}; - let out = Sha256::digest(TEST_SECRET); + let out = Sha256::digest(secret); let mut a = [0u8; 32]; a.copy_from_slice(&out); a } +pub(crate) fn test_secret_hash() -> [u8; 32] { + sha256(&TEST_SECRET) +} + pub(crate) fn default_test_config() -> HandlerConfig { HandlerConfig { min_trade_usd: 0.01, diff --git a/crates/node/Cargo.toml b/crates/node/Cargo.toml index b2bf59a..4f1a11d 100644 --- a/crates/node/Cargo.toml +++ b/crates/node/Cargo.toml @@ -18,9 +18,9 @@ stroemnet-handler = { path = "../handler" } stroemnet-p2p = { path = "../p2p" } stroemnet-protocol = { path = "../protocol" } ahash = { workspace = true } +parking_lot.workspace = true futures = { workspace = true } hex = { workspace = true } -rand = "0.10.1" serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } @@ -47,12 +47,10 @@ wasm-bindgen = { workspace = true } tsify-next = { workspace = true } getrandom-02 = { workspace = true } getrandom-04 = { workspace = true } -gloo-timers = { workspace = true } reqwest = { workspace = true } [dev-dependencies] stroemnet-test-harness = { path = "../test-harness" } -tempfile = { workspace = true } -[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] -alloy = { workspace = true, features = ["node-bindings"] } +[lints] +workspace = true diff --git a/crates/node/src/bin/stroemnetd.rs b/crates/node/src/bin/stroemnetd.rs index 18bfcc0..2078bc5 100644 --- a/crates/node/src/bin/stroemnetd.rs +++ b/crates/node/src/bin/stroemnetd.rs @@ -3,7 +3,6 @@ fn main() -> stroemnet_node::result::Result<()> { daemon::main() } -// The daemon is native-only; wasm builds (the SDK) never run this binary. #[cfg(target_arch = "wasm32")] fn main() {} @@ -12,20 +11,21 @@ mod daemon { use std::path::Path; use std::sync::Arc; + use stroemnet_data::{CursorStore, SwapStore}; use stroemnet_node::Node; use stroemnet_node::config::DaemonConfig; use stroemnet_node::error::StroemnetError; use stroemnet_node::result::Result; - use stroemnet_storage::{Peer, PeerDb}; + use stroemnet_storage::{DbCursorStore, DbSwapStore, Peer, PeerDb}; use url::Url; #[tokio::main] - /// Main entry point for stroemnet node + /// The main entrypoint for the stroemnet daemon pub async fn main() -> Result<()> { let _ = rustls::crypto::ring::default_provider().install_default(); tracing_subscriber::fmt::init(); - // Config path: first positional argument, defaulting to `stroemnet.toml`. + /// Read the confguration for the node let config_path = std::env::args() .nth(1) .unwrap_or_else(|| "stroemnet.toml".to_string()); @@ -36,7 +36,7 @@ mod daemon { tracing::info!("LP mode enabled — trade initiation disabled"); } - // Initialize the peer db which is used for peer persistence + // Load or create the peer database let peer_db_path = config.peer_db.clone(); let peer_db = Arc::new(PeerDb::new(Path::new(&peer_db_path))?); @@ -54,22 +54,23 @@ mod daemon { ); } - // Build the node config from the file, merging saved peers into the bootstrap set. + // Compute node configuration and also configure the peers let node_config = config.into_node_config(saved_peers)?; - let cursor_store: Arc = - Arc::new(stroemnet_storage::DbCursorStore::new(peer_db.clone())); - let node = Node::start(node_config, Some(cursor_store)).await?; + let cursor_store: Arc = Arc::new(DbCursorStore::new(peer_db.clone())); + let swap_store: Arc = Arc::new(DbSwapStore::new(peer_db.clone())); + + // Start the node on a separate tokio task + let node = Node::start(node_config, Some(cursor_store), Some(swap_store)).await?; let network_clone = node.network.clone(); let peer_db_clone = peer_db.clone(); - // Spawn a periodic task to save in-memory peers to the disk for persistence + // Create a periodic task to store connected peers to disk tokio::spawn(async move { let mut tick = tokio::time::interval(std::time::Duration::from_secs(60)); tick.tick().await; loop { tick.tick().await; - // Get the currently connected peers from the network let urls: Vec = network_clone .connected_peers .lock() @@ -78,25 +79,22 @@ mod daemon { .filter_map(|p| p.advertised_listen.clone()) .collect(); - // Save all peers to the db, if they are not already present for url_s in urls { let Ok(url) = Url::parse(&url_s) else { continue; }; - // If there is no peer there already, add it, otherwise do nothing if let Ok(None) = peer_db_clone.get_peer(&url) - && let Err(e) = peer_db_clone.add_peer(Peer { url }) { - tracing::warn!("peer persist failed for {url_s}: {e}"); - } + && let Err(e) = peer_db_clone.add_peer(Peer { url }) + { + tracing::warn!("peer persist failed for {url_s}: {e}"); + } } } }); - // Finally wait for shutdown signal and shutdown the node gracefully wait_for_shutdown_signal().await?; - // Stop the node and all its tasks node.shutdown(); Ok(()) } diff --git a/crates/node/src/claim.rs b/crates/node/src/claim.rs index 4b37d6c..a633b18 100644 --- a/crates/node/src/claim.rs +++ b/crates/node/src/claim.rs @@ -2,8 +2,6 @@ use stroemnet_protocol::ChannelId; use stroemnet_protocol::v1::{AmountV1, CommitmentV1}; #[derive(Clone, Debug)] -/// A pending claim, initialized by the wasm user who is waiting for a -/// counter chain commitment with the correct secret hash and destination address pub struct PendingClaim { pub secret: [u8; 32], pub expected_counter_chain: ChannelId, @@ -12,7 +10,6 @@ pub struct PendingClaim { pub expected_amount_out: AmountV1, } -/// A pending claim matches a detected commitment if the expected secret hash, counter chain, and destination address all match. pub fn pending_claim_matches(claim: &PendingClaim, detected: &CommitmentV1) -> bool { claim.expected_secret_hash == detected.secret_hash && detected.source == claim.expected_counter_chain as u8 @@ -23,3 +20,47 @@ pub fn pending_claim_matches(claim: &PendingClaim, detected: &CommitmentV1) -> b ) && detected.amount.at_least(&claim.expected_amount_out) } + +#[cfg(test)] +mod tests { + use super::*; + use stroemnet_protocol::v1::AddressesV1; + + fn claim() -> PendingClaim { + PendingClaim { + secret: [1u8; 32], + expected_counter_chain: ChannelId::KaspaTn10, + expected_secret_hash: [2u8; 32], + expected_destination_address: "kaspa:dest".into(), + expected_amount_out: AmountV1::new("100".into(), 8), + } + } + + fn detected() -> CommitmentV1 { + CommitmentV1::new( + [9u8; 32], + AddressesV1::new("kaspa:sender".into(), "kaspa:dest".into(), "0xx".into()), + AmountV1::new("150".into(), 8), + [2u8; 32], + 0, + ChannelId::KaspaTn10 as u8, + ChannelId::EthereumSepolia as u8, + ) + } + + #[test] + fn matches_when_hash_chain_address_and_amount_satisfied() { + assert!(pending_claim_matches(&claim(), &detected())); + } + + #[test] + fn rejects_on_hash_or_amount_mismatch() { + let mut wrong_hash = detected(); + wrong_hash.secret_hash = [7u8; 32]; + assert!(!pending_claim_matches(&claim(), &wrong_hash)); + + let mut underpaid = detected(); + underpaid.amount = AmountV1::new("50".into(), 8); + assert!(!pending_claim_matches(&claim(), &underpaid)); + } +} diff --git a/crates/node/src/config.rs b/crates/node/src/config.rs deleted file mode 100644 index fc05331..0000000 --- a/crates/node/src/config.rs +++ /dev/null @@ -1,365 +0,0 @@ -use std::collections::HashMap; -use std::net::{SocketAddr, SocketAddrV4}; -use std::path::Path; - -use ahash::AHashMap; -use serde::Deserialize; -use serde_json::json; -use stroemnet_handler::HandlerConfig; -use stroemnet_protocol::ChannelId; - -use crate::coordinator::Role; -use crate::error::StroemnetError; -use crate::result::Result; -use crate::{ChannelSpec, NodeConfig}; - -#[derive(Deserialize)] -/// Stroemnet daemon configuration loaded from a toml file -pub struct DaemonConfig { - /// Which address we are going to bind to in order to listen - pub bind_addr: SocketAddrV4, - /// How other nodes can reach us - pub external_hostname: String, - /// Minimum trade value in usd, used to prevent spam and uneconomic trades - pub min_trade_usd: Option, - /// Maximum trade value in usd, used to prevent large trades that may be too risky for the LP - pub max_trade_usd: Option, - /// Percentage to be used for spread, which is how the LP makes money on each swap - pub spread_percent: Option, - /// How often we update price in seconds - pub price_oracle_update_interval_secs: u64, - #[serde(default = "default_commit_buffer_secs")] - /// The number of seconds we require for propagation buffer - /// which is used to compute the unlock timestamp for a users trades - pub commit_buffer_secs: u64, - #[serde(default)] - /// Bootstrap nodes to connect to the p2p network and discover new nodes, - pub bootstrap_peers: Vec, - #[serde(default)] - /// Whether this node should act as an LP and respond to proposal requests, - /// or just be an observer that tracks swaps and broadcasts reveals. - pub lp: bool, - #[serde(default = "default_peer_db")] - /// Path to the peer db file, which is used to persist known peers across restarts - pub peer_db: String, - #[serde(default)] - /// Channel-specific configurations, where the key is the channel name (e.g. "kaspa-tn10") - pub channels: HashMap, -} - -#[derive(Deserialize)] -pub struct ChannelConfig { - /// The private key used by the LP for this channel - pub private_key: Option, - #[serde(default)] - /// Whether to participate in competitive claim rescue (CCR) for this channel - pub participate_ccr: bool, - /// Minimum amount of block confirmations required - /// in order to consider this chain events as final - pub min_confirmations: Option, - /// The RPC URL for EVM chains, used to interact with the blockchain - pub rpc_url: Option, - /// The HTLC contract address for EVM chains, used to monitor and interact with the contract - pub htlc_address: Option, - /// Gas pricing mode for EVM chains: "eip1559" (default) or "legacy" (for chains whose - /// enforced minimum gas price is decoupled from the base fee, e.g. Igra Galleon) - pub gas_payment: Option, - /// The network ID for Kaspa channels, used to connect to the correct network - pub network_id: Option, - /// The WRPC URL for Kaspa channels, used to interact with the Kaspa node - pub wrpc_url: Option, - /// The coinbase maturity for Kaspa channels, used to determine when mined blocks can be considered final - pub coinbase_maturity: Option, - /// The TTL for the redeem scripts that we monitor on Kaspa, used to determine when a script can be considered expired - pub script_ttl_secs: Option, -} - -/// Default value for the commit buffer seconds, which is used to compute the unlock timestamp for a users trades -fn default_commit_buffer_secs() -> u64 { - 960 -} - -/// Default value for the peer db path, which is used to persist known peers across restarts -fn default_peer_db() -> String { - "./stroemnet-peers.db".to_string() -} - -/// Enum representing the kind of blockchain for a channel -enum ChainKind { - Kaspa, - Evm, -} - -/// The configuration for a single channel, converting a string -/// identifier for channel id -fn channel_id_from_name(name: &str) -> Result { - match name { - "kaspa-tn10" => Ok(ChannelId::KaspaTn10), - "ethereum-sepolia" => Ok(ChannelId::EthereumSepolia), - "igra-galleon" => Ok(ChannelId::IgraGalleon), - other => Err(StroemnetError::Env(format!("unknown channel '{other}'"))), - } -} - -/// Chain kind is to separate between evm and other kind of -/// chain configuration -fn chain_kind(id: ChannelId) -> ChainKind { - match id { - ChannelId::KaspaTn10 => ChainKind::Kaspa, - ChannelId::EthereumSepolia | ChannelId::IgraGalleon => ChainKind::Evm, - } -} - -impl ChannelConfig { - /// Converts a raw channel configuration into a ChannelSpec - fn into_spec(self, id: ChannelId, name: &str) -> Result { - let mut config = match chain_kind(id) { - ChainKind::Evm => json!({ - "rpc_url": self.rpc_url.ok_or_else(|| StroemnetError::Env(format!("channel '{name}': rpc_url is required")))?, - "htlc_address": self.htlc_address.ok_or_else(|| StroemnetError::Env(format!("channel '{name}': htlc_address is required")))?, - "participate_ccr": self.participate_ccr, - }), - ChainKind::Kaspa => { - let mut cfg = json!({ - "network_id": self.network_id.ok_or_else(|| StroemnetError::Env(format!("channel '{name}': network_id is required")))?, - "participate_ccr": self.participate_ccr, - }); - if let Some(wrpc) = self.wrpc_url { - cfg["wrpc_url"] = wrpc.into(); - } - if let Some(cm) = self.coinbase_maturity { - cfg["coinbase_maturity"] = cm.into(); - } - if let Some(ttl) = self.script_ttl_secs { - cfg["script_ttl_secs"] = ttl.into(); - } - cfg - } - }; - if let Some(conf) = self.min_confirmations { - config["minimum_block_confirmations"] = conf.into(); - } - if let Some(gp) = self.gas_payment { - config["gas_payment"] = gp.into(); - } - - Ok(ChannelSpec { - config, - lp_private_key: self.private_key, - }) - } -} - -impl DaemonConfig { - /// Loads the configuration from a toml file at the given path - pub fn load(path: &Path) -> Result { - let raw = std::fs::read_to_string(path) - .map_err(|e| StroemnetError::Env(format!("config read ({}): {e}", path.display())))?; - toml::from_str(&raw) - .map_err(|e| StroemnetError::Env(format!("config parse ({}): {e}", path.display()))) - } - - /// Converts the raw daemon configuration into a NodeConfig, which is used to initialize the node - pub fn into_node_config(self, db_peers: Vec) -> Result { - let mut channels: AHashMap = AHashMap::new(); - // Load the configuration for each channel - for (name, ch) in self.channels { - let id = channel_id_from_name(&name)?; - if self.lp && ch.private_key.is_none() { - return Err(StroemnetError::Env(format!( - "LP mode: channel '{name}' requires private_key" - ))); - } - tracing::info!("Loaded config for channel {id}"); - channels.insert(id, ch.into_spec(id, &name)?); - } - if channels.is_empty() { - return Err(StroemnetError::Env("no channels configured".into())); - } - - let mut bootstrap_peers = self.bootstrap_peers; - - // Merge the bootstrap peers from the config with the peers loaded from the db, avoiding duplicates - for p in db_peers { - if !bootstrap_peers.contains(&p) { - bootstrap_peers.push(p); - } - } - - let (min_trade_usd, max_trade_usd, spread_percent) = if self.lp { - ( - self.min_trade_usd.ok_or_else(|| { - StroemnetError::Env("LP mode: min_trade_usd is required".into()) - })?, - self.max_trade_usd.ok_or_else(|| { - StroemnetError::Env("LP mode: max_trade_usd is required".into()) - })?, - self.spread_percent.ok_or_else(|| { - StroemnetError::Env("LP mode: spread_percent is required".into()) - })?, - ) - } else { - ( - self.min_trade_usd.unwrap_or(0.0), - self.max_trade_usd.unwrap_or(0.0), - self.spread_percent.unwrap_or(0.0), - ) - }; - - Ok(NodeConfig { - handler: HandlerConfig { - min_trade_usd, - max_trade_usd, - spread_percent, - commit_buffer_secs: self.commit_buffer_secs, - }, - channels, - bind_addr: Some(SocketAddr::V4(self.bind_addr)), - price_oracle_update_interval_secs: self.price_oracle_update_interval_secs, - bootstrap_peers, - role: if self.lp { Role::Lp } else { Role::Observer }, - advertised_listen_addr: Some(self.external_hostname.trim_end_matches('/').to_string()), - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - const SAMPLE: &str = r#" -bind_addr = "0.0.0.0:9000" -external_hostname = "wss://example.test/" -min_trade_usd = 1.0 -max_trade_usd = 100000.0 -spread_percent = 0.5 -price_oracle_update_interval_secs = 60 -lp = true - -[channels.kaspa-tn10] -private_key = "deadbeef" -network_id = "testnet-10" - -[channels.ethereum-sepolia] -private_key = "0xkey" -rpc_url = "https://rpc.test" -htlc_address = "0xhtlc" -"#; - - #[test] - fn parses_and_builds_node_config() { - let cfg: DaemonConfig = toml::from_str(SAMPLE).expect("parse"); - let node = cfg.into_node_config(Vec::new()).expect("into_node_config"); - - assert_eq!(node.role, Role::Lp); - assert_eq!(node.channels.len(), 2); - assert!(node.channels.contains_key(&ChannelId::KaspaTn10)); - assert!(node.channels.contains_key(&ChannelId::EthereumSepolia)); - assert_eq!(node.handler.commit_buffer_secs, 960); - assert_eq!(node.price_oracle_update_interval_secs, 60); - - let kas = &node.channels[&ChannelId::KaspaTn10]; - assert_eq!(kas.config["participate_ccr"], json!(false)); - assert_eq!(kas.config["network_id"], json!("testnet-10")); - } - - #[test] - fn evm_channel_missing_rpc_url_errors() { - let raw = r#" -bind_addr = "0.0.0.0:9000" -external_hostname = "wss://x/" -min_trade_usd = 1.0 -max_trade_usd = 2.0 -spread_percent = 0.5 -price_oracle_update_interval_secs = 60 - -[channels.ethereum-sepolia] -private_key = "k" -htlc_address = "0xhtlc" -"#; - let cfg: DaemonConfig = toml::from_str(raw).expect("parse"); - let Err(err) = cfg.into_node_config(Vec::new()) else { - panic!("expected error for missing rpc_url"); - }; - assert!(format!("{err}").contains("rpc_url")); - } - - #[test] - fn observer_channel_without_keys_ok() { - let raw = r#" -bind_addr = "0.0.0.0:9000" -external_hostname = "wss://x/" -min_trade_usd = 1.0 -max_trade_usd = 2.0 -spread_percent = 0.5 -price_oracle_update_interval_secs = 60 - -[channels.kaspa-tn10] -network_id = "testnet-10" -"#; - let cfg: DaemonConfig = toml::from_str(raw).expect("parse"); - let node = cfg.into_node_config(Vec::new()).expect("into_node_config"); - - assert_eq!(node.role, Role::Observer); - let kas = &node.channels[&ChannelId::KaspaTn10]; - assert!(kas.lp_private_key.is_none()); - } - - #[test] - fn lp_channel_without_keys_errors() { - let raw = r#" -bind_addr = "0.0.0.0:9000" -external_hostname = "wss://x/" -min_trade_usd = 1.0 -max_trade_usd = 2.0 -spread_percent = 0.5 -price_oracle_update_interval_secs = 60 -lp = true - -[channels.kaspa-tn10] -network_id = "testnet-10" -"#; - let cfg: DaemonConfig = toml::from_str(raw).expect("parse"); - let Err(err) = cfg.into_node_config(Vec::new()) else { - panic!("expected error for LP channel missing keys"); - }; - assert!(format!("{err}").contains("requires private_key")); - } - - #[test] - fn observer_omits_trade_bounds_ok() { - let raw = r#" -bind_addr = "0.0.0.0:9000" -external_hostname = "wss://x/" -price_oracle_update_interval_secs = 60 - -[channels.kaspa-tn10] -network_id = "testnet-10" -"#; - let cfg: DaemonConfig = toml::from_str(raw).expect("parse"); - let node = cfg.into_node_config(Vec::new()).expect("into_node_config"); - assert_eq!(node.role, Role::Observer); - assert_eq!(node.handler.min_trade_usd, 0.0); - assert_eq!(node.handler.max_trade_usd, 0.0); - assert_eq!(node.handler.spread_percent, 0.0); - } - - #[test] - fn lp_missing_trade_bounds_errors() { - let raw = r#" -bind_addr = "0.0.0.0:9000" -external_hostname = "wss://x/" -price_oracle_update_interval_secs = 60 -lp = true - -[channels.kaspa-tn10] -private_key = "deadbeef" -network_id = "testnet-10" -"#; - let cfg: DaemonConfig = toml::from_str(raw).expect("parse"); - let Err(err) = cfg.into_node_config(Vec::new()) else { - panic!("expected error for LP missing trade bounds"); - }; - assert!(format!("{err}").contains("min_trade_usd")); - } -} diff --git a/crates/node/src/config/daemon.rs b/crates/node/src/config/daemon.rs new file mode 100644 index 0000000..049e23b --- /dev/null +++ b/crates/node/src/config/daemon.rs @@ -0,0 +1,88 @@ +use std::collections::HashMap; +use std::net::SocketAddrV4; + +use serde::Deserialize; + +#[derive(Deserialize)] +/// Stroemnet node daemon configuration +pub struct DaemonConfig { + /// Which address to bind to + pub bind_addr: SocketAddrV4, + /// How other nodes can contact us + pub external_hostname: String, + /// Minimum trade usd optional + pub min_trade_usd: Option, + /// Maximum trade usd optional (only for Lps) + pub max_trade_usd: Option, + /// Spread percent that you as an LP will charge + pub spread_percent: Option, + /// How frequently to update the price from the oracle + pub price_oracle_update_interval_secs: u64, + #[serde(default = "default_commit_buffer_secs")] + /// Perceived network delay to propagate orders + pub commit_buffer_secs: u64, + #[serde(default)] + /// Bootstrap peers to connect to the p2p network + pub bootstrap_peers: Vec, + #[serde(default)] + /// Whether you will act as an LP + pub lp: bool, + #[serde(default = "default_peer_db")] + /// Path to the peer database + pub peer_db: String, + #[serde(default)] + /// Channel specific configurations + pub channels: HashMap, +} + +#[derive(Deserialize)] +pub struct ChannelConfig { + /// Private key for the LP and CCR bot + pub private_key: Option, + #[serde(default)] + /// Whether you will participate in CCR and earn fees from fulfilling swaps + pub participate_ccr: bool, + /// Minimum amount of confirmations + pub min_confirmations: Option, + /// The rpc url in order to connect to the node + pub rpc_url: Option, + /// The contract address to interact with + pub htlc_address: Option, + /// How you plan to pay for gas, relevant for evm networks whether they support eip1559 + pub gas_payment: Option, + /// Network id used on some channels + pub network_id: Option, + /// Whether this channel requires a particular wait time for coinbase transactions to be spend + pub coinbase_maturity: Option, + /// Whether this channel makes use of ttl timeout for storing scripts. + /// Used for UTXO based channels that use the p2sh pattern + pub script_ttl_secs: Option, +} + +/// Default propagation time +fn default_commit_buffer_secs() -> u64 { + 960 +} + +/// Default peer database +fn default_peer_db() -> String { + "./stroemnet-peers.db".to_string() +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + + #[test] + fn defaults_apply_when_omitted() { + let cfg: DaemonConfig = toml::from_str( + "bind_addr = \"0.0.0.0:9000\"\nexternal_hostname = \"wss://x/\"\nprice_oracle_update_interval_secs = 60\n", + ) + .unwrap(); + assert_eq!(cfg.commit_buffer_secs, 960); + assert_eq!(cfg.peer_db, "./stroemnet-peers.db"); + assert!(!cfg.lp); + assert!(cfg.channels.is_empty()); + } +} diff --git a/crates/node/src/config/load.rs b/crates/node/src/config/load.rs new file mode 100644 index 0000000..8e1180f --- /dev/null +++ b/crates/node/src/config/load.rs @@ -0,0 +1,174 @@ +use std::net::SocketAddr; +use std::path::Path; + +use ahash::AHashMap; +use stroemnet_handler::HandlerConfig; +use stroemnet_protocol::ChannelId; + +use super::daemon::DaemonConfig; +use super::spec::channel_id_from_name; +use crate::coordinator::Role; +use crate::error::StroemnetError; +use crate::result::Result; +use crate::{ChannelSpec, NodeConfig}; + +impl DaemonConfig { + /// Loads a daemon configuration from a specified path + pub fn load(path: &Path) -> Result { + let raw = std::fs::read_to_string(path) + .map_err(|e| StroemnetError::Env(format!("config read ({}): {e}", path.display())))?; + toml::from_str(&raw) + .map_err(|e| StroemnetError::Env(format!("config parse ({}): {e}", path.display()))) + } + + /// Convert the configuration into node configuration which also converts the general + /// configuration into channel specific configuration + pub fn into_node_config(self, db_peers: Vec) -> Result { + // Create ds to store all activated channels + let mut channels: AHashMap = AHashMap::new(); + for (name, ch) in self.channels { + // go over all channels + let id = channel_id_from_name(&name)?; + if self.lp && ch.private_key.is_none() { + // lp mode requires private key + return Err(StroemnetError::Env(format!( + "LP mode: channel '{name}' requires private_key" + ))); + } + if ch.participate_ccr && ch.private_key.is_none() { + // ccr mode requires private key with gas + return Err(StroemnetError::Env(format!( + "CCR mode: channel '{name}' requires private_key" + ))); + } + if self.lp && !ch.participate_ccr { + // if you are lp you are by definition ccr as well + return Err(StroemnetError::Env(format!( + "LP mode: channel '{name}' requires participate_ccr = true so the LP claims its own settled legs" + ))); + } + // Convert the generic configuration into the channel spec expected by each channel + channels.insert(id, ch.into_spec(id, &name)?); + } + if channels.is_empty() { + return Err(StroemnetError::Env("no channels configured".into())); + } + if self.lp { + // We need to ensure that the expected propagation time must always be larger than the lock time + // so that there is enough time to propagate before lock time is considered + let min_buffer = channels + .keys() + .map(|c| c.lock_time_secs()) + .max() + .unwrap_or(0); + if self.commit_buffer_secs < min_buffer { + return Err(StroemnetError::Env(format!( + "LP mode: commit_buffer_secs ({}) must be >= {} (max chain lock time among configured channels) to preserve atomic-swap timelock safety", + self.commit_buffer_secs, min_buffer + ))); + } + } + + // Put all saved peers as bootstrap peers + let mut bootstrap_peers = self.bootstrap_peers; + for p in db_peers { + if !bootstrap_peers.contains(&p) { + bootstrap_peers.push(p); + } + } + + // Ensure the trade specific configurations are configured if LP mode is activated + let (min_trade_usd, max_trade_usd, spread_percent) = if self.lp { + ( + self.min_trade_usd.ok_or_else(|| { + StroemnetError::Env("LP mode: min_trade_usd is required".into()) + })?, + self.max_trade_usd.ok_or_else(|| { + StroemnetError::Env("LP mode: max_trade_usd is required".into()) + })?, + self.spread_percent.ok_or_else(|| { + StroemnetError::Env("LP mode: spread_percent is required".into()) + })?, + ) + } else { + ( + self.min_trade_usd.unwrap_or(0.0), + self.max_trade_usd.unwrap_or(0.0), + self.spread_percent.unwrap_or(0.0), + ) + }; + + Ok(NodeConfig { + handler: HandlerConfig { + min_trade_usd, + max_trade_usd, + spread_percent, + commit_buffer_secs: self.commit_buffer_secs, + }, + channels, + bind_addr: Some(SocketAddr::V4(self.bind_addr)), + price_oracle_update_interval_secs: self.price_oracle_update_interval_secs, + bootstrap_peers, + role: if self.lp { Role::Lp } else { Role::Observer }, + advertised_listen_addr: Some(self.external_hostname.trim_end_matches('/').to_string()), + }) + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::panic, clippy::indexing_slicing)] + use super::*; + use serde_json::json; + + fn lp_sample() -> &'static str { + "bind_addr = \"0.0.0.0:9000\"\nexternal_hostname = \"wss://x/\"\nmin_trade_usd = 1.0\nmax_trade_usd = 2.0\nspread_percent = 0.5\nprice_oracle_update_interval_secs = 60\nlp = true\n\n[channels.kaspa-tn10]\nprivate_key = \"deadbeef\"\nparticipate_ccr = true\nnetwork_id = \"testnet-10\"\nmin_confirmations = 30\n" + } + + #[test] + fn parses_and_builds_lp_node_config() { + let cfg: DaemonConfig = toml::from_str(lp_sample()).unwrap(); + let node = cfg.into_node_config(Vec::new()).unwrap(); + assert_eq!(node.role, Role::Lp); + assert_eq!(node.channels.len(), 1); + assert_eq!(node.handler.commit_buffer_secs, 960); + assert_eq!( + node.channels[&ChannelId::KaspaTn10].config["network_id"], + json!("testnet-10") + ); + } + + #[test] + fn lp_channel_without_key_errors() { + let raw = "bind_addr = \"0.0.0.0:9000\"\nexternal_hostname = \"wss://x/\"\nmin_trade_usd = 1.0\nmax_trade_usd = 2.0\nspread_percent = 0.5\nprice_oracle_update_interval_secs = 60\nlp = true\n\n[channels.kaspa-tn10]\nnetwork_id = \"testnet-10\"\n"; + let cfg: DaemonConfig = toml::from_str(raw).unwrap(); + let Err(err) = cfg.into_node_config(Vec::new()) else { + panic!("expected error"); + }; + assert!(format!("{err}").contains("requires private_key")); + } + + #[test] + fn evm_channel_missing_rpc_url_errors() { + let raw = "bind_addr = \"0.0.0.0:9000\"\nexternal_hostname = \"wss://x/\"\nmin_trade_usd = 1.0\nmax_trade_usd = 2.0\nspread_percent = 0.5\nprice_oracle_update_interval_secs = 60\n\n[channels.ethereum-sepolia]\nprivate_key = \"k\"\nhtlc_address = \"0xhtlc\"\n"; + let cfg: DaemonConfig = toml::from_str(raw).unwrap(); + let Err(err) = cfg.into_node_config(Vec::new()) else { + panic!("expected error"); + }; + assert!(format!("{err}").contains("rpc_url")); + } + + #[test] + fn observer_omits_keys_and_bounds() { + let raw = "bind_addr = \"0.0.0.0:9000\"\nexternal_hostname = \"wss://x/\"\nprice_oracle_update_interval_secs = 60\n\n[channels.kaspa-tn10]\nnetwork_id = \"testnet-10\"\nmin_confirmations = 30\n"; + let cfg: DaemonConfig = toml::from_str(raw).unwrap(); + let node = cfg.into_node_config(Vec::new()).unwrap(); + assert_eq!(node.role, Role::Observer); + assert_eq!(node.handler.min_trade_usd, 0.0); + assert!( + node.channels[&ChannelId::KaspaTn10] + .lp_private_key + .is_none() + ); + } +} diff --git a/crates/node/src/config/mod.rs b/crates/node/src/config/mod.rs new file mode 100644 index 0000000..70db1d6 --- /dev/null +++ b/crates/node/src/config/mod.rs @@ -0,0 +1,5 @@ +mod daemon; +mod load; +mod spec; + +pub use daemon::DaemonConfig; diff --git a/crates/node/src/config/spec.rs b/crates/node/src/config/spec.rs new file mode 100644 index 0000000..1056ab4 --- /dev/null +++ b/crates/node/src/config/spec.rs @@ -0,0 +1,114 @@ +use serde_json::{Value, json}; +use stroemnet_protocol::ChannelId; + +use super::daemon::ChannelConfig; +use crate::ChannelSpec; +use crate::error::StroemnetError; +use crate::result::Result; + +/// Insert a value if the target is an object with a key and a value +fn insert_opt(target: &mut Value, key: &str, value: Option) { + // we only insert if this is an object + if let (Some(map), Some(value)) = (target.as_object_mut(), value) { + map.insert(key.to_string(), value); + } +} + +/// Concrete defined channel types +/// These are networks which have corresponding data sinks +/// Could theoretically be handled via traits but we are not passing +/// data sinks in this module so having this just makes it simpler +enum ChainKind { + Kaspa, + Evm, +} + +/// Converts a name into a channel id +pub(super) fn channel_id_from_name(name: &str) -> Result { + match name { + "kaspa-tn10" => Ok(ChannelId::KaspaTn10), + "ethereum-sepolia" => Ok(ChannelId::EthereumSepolia), + "igra-galleon" => Ok(ChannelId::IgraGalleon), + other => Err(StroemnetError::Env(format!("unknown channel '{other}'"))), + } +} + +/// Matches a channel to a chain type +fn chain_kind(id: ChannelId) -> ChainKind { + match id { + ChannelId::KaspaTn10 => ChainKind::Kaspa, + ChannelId::EthereumSepolia | ChannelId::IgraGalleon => ChainKind::Evm, + } +} + +impl ChannelConfig { + pub(super) fn into_spec(self, id: ChannelId, name: &str) -> Result { + let mut config = match chain_kind(id) { + //Create the relevant channel configuration depending on which + // chain we are working on + ChainKind::Evm => json!({ + "rpc_url": self.rpc_url.ok_or_else(|| StroemnetError::Env(format!("channel '{name}': rpc_url is required")))?, + "htlc_address": self.htlc_address.ok_or_else(|| StroemnetError::Env(format!("channel '{name}': htlc_address is required")))?, + "participate_ccr": self.participate_ccr, + }), + ChainKind::Kaspa => { + let mut cfg = json!({ + "network_id": self.network_id.ok_or_else(|| StroemnetError::Env(format!("channel '{name}': network_id is required")))?, + "participate_ccr": self.participate_ccr, + }); + insert_opt(&mut cfg, "wrpc_url", self.rpc_url.map(Value::from)); + insert_opt( + &mut cfg, + "coinbase_maturity", + self.coinbase_maturity.map(Value::from), + ); + insert_opt( + &mut cfg, + "script_ttl_secs", + self.script_ttl_secs.map(Value::from), + ); + cfg + } + }; + + // We always enforce minimum confirmations to be greater than 0 + if self.min_confirmations.unwrap_or(0) == 0 { + return Err(StroemnetError::Env(format!( + "channel '{name}': min_confirmations must be set to a non-zero value for EVM channels (reorg safety)" + ))); + } + insert_opt( + &mut config, + "minimum_block_confirmations", + self.min_confirmations.map(Value::from), + ); + insert_opt( + &mut config, + "gas_payment", + self.gas_payment.map(Value::from), + ); + Ok(ChannelSpec { + config, + lp_private_key: self.private_key, + }) + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + + #[test] + fn channel_id_from_name_maps_known_and_rejects_unknown() { + assert_eq!( + channel_id_from_name("kaspa-tn10").unwrap(), + ChannelId::KaspaTn10 + ); + assert_eq!( + channel_id_from_name("igra-galleon").unwrap(), + ChannelId::IgraGalleon + ); + assert!(channel_id_from_name("nope").is_err()); + } +} diff --git a/crates/node/src/connection.rs b/crates/node/src/connection.rs deleted file mode 100644 index bd9c6a9..0000000 --- a/crates/node/src/connection.rs +++ /dev/null @@ -1,275 +0,0 @@ -#[cfg(not(target_arch = "wasm32"))] -use std::net::SocketAddr; -use std::sync::Arc; -use std::sync::atomic::{AtomicUsize, Ordering}; - -use futures::SinkExt; -use futures::channel::mpsc as futures_mpsc; -#[cfg(not(target_arch = "wasm32"))] -use tokio::task::JoinHandle; - -use stroemnet_p2p::P2p; -use stroemnet_p2p::network::NetEvent; -use stroemnet_protocol::{now_millis, sleep_secs}; - -const MAX_MESSAGES_PER_SECOND: u32 = 100; - -/// Read from a peer connection, forwarding messages to the provided channel, and enforcing a rate limit to prevent abuse. -async fn read_from_peer_tracked( - network: Arc, - peer: stroemnet_p2p::peer::ConnectedPeer, - mut events_tx: futures_mpsc::Sender, - counter: Arc, - is_inbound: bool, -) { - let url = peer.url.clone(); - let peer_node_id = peer.node_id; - let our_node_id = network.config.node_id(); - - // Check if this peer is blacklisted - if network.is_blacklisted(&peer_node_id).await { - tracing::warn!( - "rejecting connection from blacklisted peer node_id={} (url={url})", - hex::encode(peer_node_id) - ); - let _ = peer.disconnect().await; - return; - } - - { - // We connect and now we need to deterministically compute - // which connection should be kept based on node IDs - - // Compute whether we are the lower node ID, which will determine which connection to keep in case of a duplicate - let we_are_lower = our_node_id < peer_node_id; - - // We only want to keep connections that were initiated by the peer with the lower node ID - // which they are if either its an outbound connection and we are lower, or an inbound connection and we are higher - let new_is_lower_initiated = (!is_inbound && we_are_lower) || (is_inbound && !we_are_lower); - - // Check if this is an connected peer - let existing_connected_peer = { - let connected_peers = network.connected_peers.lock().await; - connected_peers - .iter() - .position(|p| p.node_id == peer_node_id) - .map(|i| (i, connected_peers[i].is_inbound)) - }; - if let Some((idx, existing_is_inbound)) = existing_connected_peer { - // If the existing connection is initiated by lower id node - let existing_is_lower_initiated = - (!existing_is_inbound && we_are_lower) || (existing_is_inbound && !we_are_lower); - - // If the new connection is not initiated by the lower id node, we reject it - if !new_is_lower_initiated { - tracing::info!( - "rejecting duplicate peer node_id={} (higher-id-initiated, lower wins)", - hex::encode(peer_node_id) - ); - return; - } - - // This means that lower id node is initiated - // If this existing is also lower initiated we have a tie and simply reject the new one - if existing_is_lower_initiated { - tracing::info!( - "rejecting duplicate peer node_id={} (existing already lower-id-initiated)", - hex::encode(peer_node_id) - ); - return; - } - - // If the existing one is not lower initiated, we replace the existing connection - // with the new one, which is the one initiated by the lower id node - let old = { - let mut peers = network.connected_peers.lock().await; - if idx < peers.len() && peers[idx].node_id == peer_node_id { - // Find the idx - Some(peers.remove(idx)) // Remove if it still exists and return it - } else { - None - } - }; - - // If we removed an existing peer, we need to disconnect it - if let Some(p) = old { - tracing::info!( - "replacing higher-id-initiated peer node_id={} with lower-id-initiated connection", - hex::encode(peer_node_id) - ); - let _ = p.disconnect().await; - } - } - } - - // Access the known peers from this peer - let known_peers = peer.known_peers.clone(); - - // Add the new peer to the network's list of connected peers - network.add_connected_peer(peer.clone()).await; - - // increment the peer count - counter.fetch_add(1, Ordering::SeqCst); - - // now process all the peers from the peer we just discovered - network.process_peer_addrs(known_peers).await; - - // Create timing in order to keep track how many messages we receive from this peer per second - let mut window_start = now_millis(); - let mut count_in_window: u32 = 0; - loop { - match peer.recv_msg().await { - Ok(msg) => { - let now = now_millis(); - if now.saturating_sub(window_start) >= 1000 || now < window_start { - // if one second has passed, we reset the window - window_start = now; - count_in_window = 0; - } - count_in_window += 1; - if count_in_window > MAX_MESSAGES_PER_SECOND { - // if the rate exceeds rate limit, disconnect and blacklist peer - tracing::warn!( - "rate-limit exceeded for peer {url} (node_id={}); blacklisting until reboot", - hex::encode(peer_node_id) - ); - network.blacklist_peer(peer_node_id).await; - let _ = peer.disconnect().await; - break; - } - // otherwise parse the message and forward it to the channel - let evt = NetEvent { - from: url.clone(), - msg, - }; - if events_tx.send(evt).await.is_err() { - tracing::debug!("event channel closed; ending peer task for {url}"); - break; - } - } - Err(e) => { - tracing::info!("peer {url} disconnected: {e}"); - break; - } - } - } - // If we exit this loop, it means the peer has disconnected, so we need to clean up - counter.fetch_sub(1, Ordering::SeqCst); - network.remove_connected_peer(&url).await; -} - -#[cfg(not(target_arch = "wasm32"))] -/// Spawns a task to dial a peer at a given URL and if it -/// is successful we read from this peer and get data. -/// -/// Regardless if it fails or not we remove it from the "pending" dial set -/// which is used to prevent multiple concurrent dial attempts to the same peer. -pub(crate) fn spawn_addr_dial_driver( - network: Arc, - url: String, - counter: Arc, - in_flight: Arc>>, - url_norm: String, -) { - stroemnet_protocol::spawn(async move { - match network.dial(&url).await { - Ok(peer) => { - tracing::info!( - "discovery: dialed {url} (node_id={})", - hex::encode(peer.node_id) - ); - let events_tx = network.events_tx.clone(); - // Read from the peer what it says to us - read_from_peer_tracked(network, peer, events_tx, counter, false).await; - } - Err(e) => tracing::debug!("discovery: dial {url} failed: {e}"), - } - - // Regardless of the result, remove the URL from the in-flight set - let _ = in_flight.lock().map(|mut s| s.remove(&url_norm)); - }); -} - -/// Spawns a task for each bootstrap peer to continuously attempt to connect. -/// This is so that we always try to have an entrypoint with the network. -pub(crate) fn spawn_bootstrap_with_counter(network: Arc, counter: Arc) { - // We clone the bootstrap URLS from the provided network configuration. - let urls = network.config.bootstrap_peers.clone(); - if urls.is_empty() { - tracing::info!("no bootstrap peers configured"); - return; - } - // For all the bootstrap nodes spawn a redial loop. - for url in urls { - let net = network.clone(); - let c = counter.clone(); - stroemnet_protocol::spawn(async move { - bootstrap_redial_loop(net, url, c).await; - }); - } -} - -/// Create a loop that continuously attempts to keep a connection to a bootstrap peer -async fn bootstrap_redial_loop(network: Arc, url: String, counter: Arc) { - // Remove any trailing slashes and lowercase the URL for consistent comparison - let url_norm = url.trim_end_matches('/').to_ascii_lowercase(); - loop { - // Check if we have already connected to this peer - if network.is_connected_peer(&url_norm).await { - sleep_secs(30).await; - continue; - } - - tracing::info!("bootstrap: dialing {url}"); - - // Dial with retry and backoff - let Some(peer) = network.dial_with_backoff(&url).await else { - tracing::warn!("bootstrap: giving up on {url} after 5 failed attempts"); - return; - }; - tracing::info!("bootstrap: connected to {url}"); - let events_tx = network.events_tx.clone(); - - // If we are able to dial, lets read from the connection - read_from_peer_tracked(network.clone(), peer, events_tx, counter.clone(), false).await; - tracing::info!("bootstrap: peer {url} disconnected — redialing"); - // Wait a bit before trying to redial to avoid tight loop in case of persistent failure - sleep_secs(1).await; - } -} - -#[cfg(not(target_arch = "wasm32"))] -/// On native, we are allowing incoming connections, this fn -/// binds a listener to the binding adfdress so that we can accept incoming p2p -/// connections. -pub(crate) async fn spawn_accept( - bind_addr: SocketAddr, - network: Arc, - counter: Arc, - tasks: &mut Vec>, // a list of managed tasks, that we manage from the main loop and cancel when needed -) { - match network.clone().listen(bind_addr).await { - Ok(mut inbound_rx) => { - tracing::info!("P2P listener bound on {bind_addr}"); - let net_for_inbound = network.clone(); - let counter_for_inbound = counter.clone(); - // Spawn a task to accept incoming connections and read from them - tasks.push(tokio::spawn(async move { - while let Some(peer) = inbound_rx.recv().await { - let url = peer.url.clone(); - let events_tx = net_for_inbound.events_tx.clone(); - let net2 = net_for_inbound.clone(); - let c2 = counter_for_inbound.clone(); - // For each incoming connection we spawn a new tokio task to handle it. - tokio::spawn(async move { - read_from_peer_tracked(net2, peer, events_tx, c2, true).await; - tracing::info!("inbound peer {url} disconnected"); - }); - } - })); - } - Err(e) => { - tracing::warn!("Could not bind P2P listener on {bind_addr}: {e}"); - } - } -} diff --git a/crates/node/src/connection/accept.rs b/crates/node/src/connection/accept.rs new file mode 100644 index 0000000..5bfe3e1 --- /dev/null +++ b/crates/node/src/connection/accept.rs @@ -0,0 +1,60 @@ +#[cfg(not(target_arch = "wasm32"))] +use std::net::SocketAddr; +use std::sync::Arc; +use std::sync::atomic::AtomicUsize; + +use tokio::sync::Semaphore; + +#[cfg(not(target_arch = "wasm32"))] +use tokio::task::JoinHandle; + +use stroemnet_p2p::P2p; + +#[cfg(not(target_arch = "wasm32"))] +/// Spawn connection handler that binds to a listening address +/// and handles incoming peer connections +pub(crate) async fn spawn_accept( + bind_addr: SocketAddr, // the address where we are listening + network: Arc, // the p2p network object + counter: Arc, // peer count trackers + tasks: &mut Vec>, // a mutable shared vector where we store tasks to cancel +) { + use super::read::read_from_peer_tracked; + + // Listen and get an rx receiver + match network.clone().listen(bind_addr).await { + Ok(mut inbound_rx) => { + tracing::info!("P2P listener bound on {bind_addr}"); + let net_for_inbound = network.clone(); + let counter_for_inbound = counter.clone(); + + // Limit the maximum amount of connections at a time + let limiter = Arc::new(Semaphore::new(network.config.max_inbound)); + // Spawn a task and add it to the DS + tasks.push(tokio::spawn(async move { + // Wait for an inbound connection + while let Some(peer) = inbound_rx.recv().await { + let url = peer.url.clone(); + // Limit number of connections + let Ok(permit) = limiter.clone().try_acquire_owned() else { + tracing::warn!("inbound peer {url} rejected: max_inbound reached"); + continue; + }; + + // Clone the relevant data needed for handling this connections + let events_tx = net_for_inbound.events_tx.clone(); + let net2 = net_for_inbound.clone(); + let c2 = counter_for_inbound.clone(); + tokio::spawn(async move { + // spawn a new task holding the permit + // and handle the connection + let _permit = permit; + read_from_peer_tracked(net2, peer, events_tx, c2, true).await; + tracing::info!("inbound peer {url} disconnected"); + }); + } + })); + } + Err(e) => tracing::warn!("Could not bind P2P listener on {bind_addr}: {e}"), + } +} diff --git a/crates/node/src/connection/dial.rs b/crates/node/src/connection/dial.rs new file mode 100644 index 0000000..56c0e33 --- /dev/null +++ b/crates/node/src/connection/dial.rs @@ -0,0 +1,93 @@ +#[cfg(not(target_arch = "wasm32"))] +use std::collections::HashSet; +use std::sync::Arc; +use std::sync::atomic::AtomicUsize; + +#[cfg(not(target_arch = "wasm32"))] +use parking_lot::Mutex; + +use super::read::read_from_peer_tracked; +use stroemnet_p2p::P2p; +use stroemnet_protocol::sleep_secs; + +#[cfg(not(target_arch = "wasm32"))] +struct InFlightGuard { + set: Arc>>, + url_norm: String, +} + +#[cfg(not(target_arch = "wasm32"))] +impl Drop for InFlightGuard { + fn drop(&mut self) { + self.set.lock().remove(&self.url_norm); + } +} + +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn spawn_addr_dial_driver( + network: Arc, + url: String, + counter: Arc, + in_flight: Arc>>, + url_norm: String, +) { + // Create a guard which the in flight set + let guard = InFlightGuard { + set: in_flight, + url_norm, + }; + stroemnet_protocol::spawn(async move { + // the guard has a drop immpl meaning that when + // it is dropped the peer that we are dialling will be removed from + // the dedup set + let _guard = guard; + // Attempt to dial the peer with a 10 second timeout + let dialed = + tokio::time::timeout(std::time::Duration::from_secs(10), network.dial(&url)).await; + match dialed { + Ok(Ok(peer)) => { + // The peer is connected so now we should read from the peer + let events_tx = network.events_tx.clone(); + read_from_peer_tracked(network, peer, events_tx, counter, false).await; + } + Ok(Err(e)) => tracing::debug!("discovery: dial {url} failed: {e}"), + Err(_) => tracing::debug!("discovery: dial {url} timed out"), + } + }); +} + +/// Spawn a task and attempt to spawn a redial for bootstrap peers +pub(crate) fn spawn_bootstrap_with_counter(network: Arc, counter: Arc) { + let urls = network.config.bootstrap_peers.clone(); + if urls.is_empty() { + tracing::info!("no bootstrap peers configured"); + return; + } + // Go over all urls + for url in urls { + let net = network.clone(); + let c = counter.clone(); + stroemnet_protocol::spawn(async move { + // redial the bootstrap node and attempt to establish connection + bootstrap_redial_loop(net, url, c).await; + }); + } +} + +/// A loop to try to contact a bootstrap node at least 5 times +async fn bootstrap_redial_loop(network: Arc, url: String, counter: Arc) { + let url_norm = stroemnet_p2p::normalize_listen_addr(&url); + loop { + if network.is_connected_peer(&url_norm).await { + sleep_secs(30).await; + continue; + } + let Some(peer) = network.dial_with_backoff(&url).await else { + tracing::warn!("bootstrap: giving up on {url} after 5 failed attempts"); + return; + }; + let events_tx = network.events_tx.clone(); + read_from_peer_tracked(network.clone(), peer, events_tx, counter.clone(), false).await; + sleep_secs(1).await; + } +} diff --git a/crates/node/src/connection/mod.rs b/crates/node/src/connection/mod.rs new file mode 100644 index 0000000..ef9f22e --- /dev/null +++ b/crates/node/src/connection/mod.rs @@ -0,0 +1,12 @@ +#[cfg(not(target_arch = "wasm32"))] +mod accept; +mod dial; +mod read; +mod resolve; + +pub(crate) use dial::spawn_bootstrap_with_counter; + +#[cfg(not(target_arch = "wasm32"))] +pub(crate) use accept::spawn_accept; +#[cfg(not(target_arch = "wasm32"))] +pub(crate) use dial::spawn_addr_dial_driver; diff --git a/crates/node/src/connection/read.rs b/crates/node/src/connection/read.rs new file mode 100644 index 0000000..8ca0bf2 --- /dev/null +++ b/crates/node/src/connection/read.rs @@ -0,0 +1,87 @@ +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use futures::SinkExt; +use futures::channel::mpsc as futures_mpsc; + +use super::resolve::should_proceed; +use stroemnet_p2p::P2p; +use stroemnet_p2p::network::NetEvent; +use stroemnet_p2p::peer::ConnectedPeer; +use stroemnet_protocol::now_millis; + +/// Maximum messages per second from a peer +const MAX_MESSAGES_PER_SECOND: u32 = 100; + +/// Read data from a connected peer +pub(super) async fn read_from_peer_tracked( + network: Arc, + peer: ConnectedPeer, + mut events_tx: futures_mpsc::Sender, + counter: Arc, + is_inbound: bool, +) { + let url = peer.url.clone(); + let peer_node_id = peer.node_id; + + // If this peer id is blacklisted, no need to talk + if network.is_blacklisted(&peer_node_id).await { + let _ = peer.disconnect().await; + return; + } + if !should_proceed(&network, peer_node_id, is_inbound).await { + return; + } + + let known_peers = peer.known_peers.clone(); + + // Add this peer as a connected peer + network.add_connected_peer(peer.clone()).await; + counter.fetch_add(1, Ordering::SeqCst); + + // Process the peers known address + network.process_peer_addrs(known_peers).await; + + let mut window_start = now_millis(); + let mut count_in_window: u32 = 0; + loop { + // Read a message from the peer + match peer.recv_msg().await { + Ok(msg) => { + let now = now_millis(); + // If the window has expired, we restart the window and count again + if now.saturating_sub(window_start) >= 1000 || now < window_start { + window_start = now; + count_in_window = 0; + } + count_in_window += 1; + // If the count within the window exceeds allowed, + // we disconnect from this peer + if count_in_window > MAX_MESSAGES_PER_SECOND { + tracing::warn!("rate-limit exceeded for {url}; blacklisting until reboot"); + network.blacklist_peer(peer_node_id).await; + let _ = peer.disconnect().await; + break; + } + // Transmit the received data to the internal handler + if events_tx + .send(NetEvent { + from: url.clone(), + msg, + }) + .await + .is_err() + { + break; + } + } + Err(e) => { + // if we disconnect then we exit this loop + tracing::info!("peer {url} disconnected: {e}"); + break; + } + } + } + counter.fetch_sub(1, Ordering::SeqCst); + network.remove_connected_peer(&url).await; +} diff --git a/crates/node/src/connection/resolve.rs b/crates/node/src/connection/resolve.rs new file mode 100644 index 0000000..1a664cc --- /dev/null +++ b/crates/node/src/connection/resolve.rs @@ -0,0 +1,82 @@ +use std::sync::Arc; + +use stroemnet_p2p::P2p; + +/// Whether or not the lower node initiated the connection +/// as we only want to maintain one of them +/// +/// Either the connection is inbound and we are not lower which means +/// the other aprty is lower +/// +/// Or the connection is outbound and we are lower, which means we are lower +fn lower_initiated(is_inbound: bool, we_are_lower: bool) -> bool { + (!is_inbound && we_are_lower) || (is_inbound && !we_are_lower) +} + +/// Compute whether the connection should proceed or be aborted. +pub(super) async fn should_proceed( + network: &Arc, // network instance + peer_node_id: [u8; 32], // id of the peer + is_inbound: bool, // whether the connection is inbound +) -> bool { + // If our node our id is lower then we are lower + let we_are_lower = network.config.node_id() < peer_node_id; + + // Whether this connection is initiated by the lower id node + let new_is_lower_initiated = lower_initiated(is_inbound, we_are_lower); + + // Check if we already have an existing connection with this node id + let existing = { + let peers = network.connected_peers.lock().await; + peers + .iter() + .enumerate() + .find(|(_, p)| p.node_id == peer_node_id) + .map(|(i, p)| (i, p.is_inbound)) + }; + + // If we dont have an existing then we should continue otherwise continue checking + let Some((idx, existing_is_inbound)) = existing else { + return true; + }; + // If the existing connection is already initiated by the + // lower node then we dont need to continue + let existing_is_lower_initiated = lower_initiated(existing_is_inbound, we_are_lower); + if !new_is_lower_initiated || existing_is_lower_initiated { + tracing::info!( + "rejecting duplicate peer node_id={} (lower-id-initiated wins)", + hex::encode(peer_node_id) + ); + return false; + } + + // The old connection was not lower initiated so we update to use this connection instead + // but for that we need to remove the old connection + let old = { + let mut peers = network.connected_peers.lock().await; + if peers.get(idx).map(|p| p.node_id) == Some(peer_node_id) { + Some(peers.remove(idx)) + } else { + None + } + }; + + // disconnect from the old connection + if let Some(p) = old { + let _ = p.disconnect().await; + } + true +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lower_initiated_truth_table() { + assert!(lower_initiated(false, true)); + assert!(lower_initiated(true, false)); + assert!(!lower_initiated(false, false)); + assert!(!lower_initiated(true, true)); + } +} diff --git a/crates/node/src/coordinator.rs b/crates/node/src/coordinator.rs deleted file mode 100644 index 5e6232a..0000000 --- a/crates/node/src/coordinator.rs +++ /dev/null @@ -1,428 +0,0 @@ -use std::sync::Arc; - -use futures::StreamExt; -use stroemnet_data::ChainDataSink; -use stroemnet_handler::Handler; -use stroemnet_p2p::P2p; -use stroemnet_p2p::network::NetEvent; -use stroemnet_p2p::wire::message::{P2pMsg, ProposalRequest, ProposalResponse}; -#[cfg(target_arch = "wasm32")] -use stroemnet_protocol::now_unix_secs; -use stroemnet_protocol::v1::ChainEvent; -#[cfg(target_arch = "wasm32")] -use stroemnet_protocol::v1::RevealV1; -#[cfg(target_arch = "wasm32")] -use tokio::sync::mpsc; - -#[cfg(target_arch = "wasm32")] -use crate::{CheckedQuote, PendingClaim, SwapStage, SwapStatusUpdate}; - -/// A DoS cap on how much external data we accept, -/// to prevent unbounded memory growth -pub const MAX_REDEEM_SCRIPT_BYTES: usize = 512; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Role { - Lp, - Observer, -} - -/// The coordinator is responsible for binding together the p2p network -/// data sink and the handler -pub struct Coordinator { - /// The handler is responsible for tracking swaps and enforcing - /// that they follow correct core protocol logic - pub handler: Arc, - /// The network is responsible for p2p communication with other nodes - /// which includes receiving proposals, sending proposals, broadcasting reveals, etc. - pub network: Arc, - /// The chain data sink is the unit that polls information from the chains - /// and can also broadcast events to the chains - pub sink: Arc, - /// If this node is an LP, it will respond to proposal requests and generate proposals, - /// if its an observer, it will not respond to proposal requests but will still track swaps and broadcast reveals. - pub role: Role, - #[cfg(target_arch = "wasm32")] - pub quote_tx: mpsc::UnboundedSender, - #[cfg(target_arch = "wasm32")] - pub swap_status_tx: mpsc::UnboundedSender, -} - -impl Coordinator { - pub fn new( - handler: Arc, - network: Arc, - sink: Arc, - role: Role, - #[cfg(target_arch = "wasm32")] quote_tx: mpsc::UnboundedSender, - #[cfg(target_arch = "wasm32")] swap_status_tx: mpsc::UnboundedSender, - ) -> Arc { - Arc::new(Self { - handler, - network, - sink, - role, - #[cfg(target_arch = "wasm32")] - quote_tx, - #[cfg(target_arch = "wasm32")] - swap_status_tx, - }) - } - - /// Spawns a background task that listens for incoming network events and dispatches them to the appropriate handlers. - pub fn spawn_dispatch_loop( - self: Arc, - mut events: futures::channel::mpsc::Receiver, - ) -> Option> { - let fut = async move { - while let Some(NetEvent { from, msg }) = events.next().await { - if let Err(e) = self.handle_incoming(&from, msg).await { - tracing::warn!("p2p incoming message error from {from}: {e}"); - } - } - tracing::info!("p2p coordinator: events channel closed; shutting down"); - }; - #[cfg(not(target_arch = "wasm32"))] - { - Some(tokio::spawn(fut)) - } - #[cfg(target_arch = "wasm32")] - { - wasm_bindgen_futures::spawn_local(fut); - None - } - } - - /// Handles an incoming P2P message, dispatching it to the appropriate handler based on its type. - pub(crate) async fn handle_incoming( - &self, - from: &str, - msg: P2pMsg, - ) -> Result<(), Box> { - let handler = &self.handler; - let network = &self.network; - match msg { - // The State message contains information about the peer's known addresses and is used for peer discovery. - P2pMsg::State(s) => { - // process the peer's reported state - network.process_peer_addrs(s.peers).await; - } - - // The ProposalRequest message is sent by a peer to request a swap proposal from this node. - P2pMsg::ProposalRequest(req) => { - // Attempt to parse the origin and destination - let origin = stroemnet_protocol::ChannelId::try_from(req.origin); - let dest = stroemnet_protocol::ChannelId::try_from(req.destination); - match (origin, dest) { - // If both the origin and destination are known channels, proceed with handling the proposal request. - (Ok(o), Ok(d)) if handler.knows_channel(o) && handler.knows_channel(d) => {} - _ => { - tracing::info!( - "dropping ProposalRequest from {from} for unknown/mismatched chains origin={} dest={}", - req.origin, - req.destination - ); - return Ok(()); - } - } - - // Forward the proposal request to other peers in the network, if applicable. - if !network - .forward(from, &P2pMsg::ProposalRequest(req.clone())) - .await? - { - return Ok(()); - } - - // If we are an LP, handle the proposal request and generate a response. - if self.role == Role::Lp { - self.handle_proposal(from, req).await?; - } - } - - // The ProposalResponse message is sent by a peer in response to a ProposalRequest, - // containing the proposed swap details. - P2pMsg::ProposalResponse(r) => { - #[cfg(target_arch = "wasm32")] - { - // LP nodes by definition dont act on proposal responses, and also wasm nodes - // are not allowed to be LPs so this is an efficient gate. - - // Attempt to parse origin and destination - let origin = stroemnet_protocol::ChannelId::try_from(r.origin); - let dest = stroemnet_protocol::ChannelId::try_from(r.destination); - let origin = match (origin, dest) { - (Ok(o), Ok(d)) if handler.knows_channel(o) && handler.knows_channel(d) => o, - _ => { - tracing::info!( - "dropping ProposalResponse from {from} for unknown/mismatched chains origin={} dest={}", - r.origin, - r.destination - ); - return Ok(()); - } - }; - - // Compute the proposal digest for signature verification - let digest = stroemnet_p2p::proposal_digest( - r.swap_id, - r.origin, - r.destination, - &r.amount_in, - &r.amount_out, - &r.sender_destination_address, - &r.lp_sender_address, - r.commit_unlock_offset_secs, - r.lp_block_confirmations, - ); - - // Verify the LP's signature and check if the LP has sufficient balance for the proposed swap. - let (signature_valid, balance_sufficient) = match self - .sink - .verify_message( - origin, - digest, - &r.lp_sender_address, - &r.lp_signature, - &r.amount_in, - ) - .await - { - Ok(v) => (v.address_matches, v.balance_sufficient), - Err(e) => { - tracing::error!("verify_message failed: {e}"); - (false, false) - } - }; - - // Send the checked quote to the quote_tx channel for further processing. - // Even if the signature is invalid or the balance is insufficient, - // we still send the quote for transparency and logging. - // So that the user can see it in the UI. - let _ = self.quote_tx.send(CheckedQuote { - swap_id: r.swap_id, - origin: r.destination, - destination: r.origin, - amount_in: r.amount_out, - amount_out: r.amount_in, - sender_destination_address: r.sender_destination_address, - commit_unlock_offset_secs: r.commit_unlock_offset_secs, - lp_sender_address: r.lp_sender_address, - lp_signature: r.lp_signature, - lp_block_confirmations: r.lp_block_confirmations, - signature_valid, - balance_sufficient, - }); - } - #[cfg(not(target_arch = "wasm32"))] - let _ = r; - } - - // The Reveal message is sent by a peer to reveal the secret for a swap, allowing the swap to be completed. - P2pMsg::Reveal(r) => { - // Directly forward the reveal to other peers in the network so that everyone can - // act on it and finalize the swap. - if !network.forward(from, &P2pMsg::Reveal(r.clone())).await? { - return Ok(()); - } - // Handle the reveal by updating the internal state - if let Err(e) = handler.handle_external_reveal(r.clone()).await { - tracing::error!("reveal state update for {}: {e}", hex::encode(r.swap_id)); - } - let channels: Vec = self.sink.channels().collect(); - // Go over all channels todo: can technically restrict this to only channels that are relevant to the swap - // but gas wastage is not applicable, the chains check the swap id before acting on it. - for chan in channels { - // Broadcast the reveal event to each channel so - // that the chain data sink can act on it and finalize the swap. - if let Err(e) = self - .sink - .broadcast_event(chan, &ChainEvent::Reveal(r.clone())) - .await - { - tracing::error!( - "reveal claim on {chan} for swap {}: {e}", - hex::encode(r.swap_id) - ); - } - } - } - // The ScriptAnnounce message is sent by a peer to announce a new redeem script for a swap, - // allowing the swap to be identified when we process incoming blocks. - P2pMsg::ScriptAnnounce(s) => { - // Reject scripts that are too large to prevent DoS attacks - if s.redeem_script.len() > MAX_REDEEM_SCRIPT_BYTES { - tracing::warn!( - "script-announce: rejected from {from} for swap {} — redeem_script too large ({} bytes, max {})", - hex::encode(s.swap_id), - s.redeem_script.len(), - MAX_REDEEM_SCRIPT_BYTES - ); - return Ok(()); - } - // Forward the script announcement to other peers in the network - if !network - .forward(from, &P2pMsg::ScriptAnnounce(s.clone())) - .await? - { - return Ok(()); - } - - // If we have a script channel, register the script with - // the chain data sink so that it can be monitored and acted upon. - let Some(channel_id) = self.sink.script_channel() else { - return Ok(()); - }; - - // Register the script with the chain data sink, so that chains who rely - // on scripts will see this script. - if let Err(e) = self - .sink - .register_script( - channel_id, - s.address, - s.redeem_script, - s.swap_id, - s.unlock_ts, - s.deposit_target, - ) - .await - { - tracing::warn!( - "script-announce: rejected from {from} for swap {} — {e}", - hex::encode(s.swap_id) - ); - } - } - } - Ok(()) - } - - #[cfg(target_arch = "wasm32")] - /// Emits a swap status update to the swap_status_tx channel, indicating the current stage of the swap. - pub(crate) fn emit_status(&self, swap_id: [u8; 32], stage: SwapStage) { - let _ = self.swap_status_tx.send(SwapStatusUpdate { - swap_id, - stage, - at: now_unix_secs(), - }); - } - - #[cfg(target_arch = "wasm32")] - /// Creates a new reveal broadcast across the network for the given swap ID and pending claim, and emits a status update. - pub fn spawn_reveal_broadcast(&self, swap_id: [u8; 32], claim: PendingClaim) { - let network = self.network.clone(); - let swap_status_tx = self.swap_status_tx.clone(); - - // Create a new future that will broadcast the reveal and emit a status update, and spawn it as a background task. - let fut = async move { - // Create a new revealv1 message - let reveal = RevealV1::new(swap_id, claim.secret); - - // Broadcast the reveal across the network and emit a status update based on the result. - let stage = match network.broadcast(&P2pMsg::Reveal(reveal)).await { - Ok(()) => { - tracing::info!( - "auto-claim: reveal broadcast for swap {}", - hex::encode(swap_id) - ); - SwapStage::Completed - } - Err(e) => { - tracing::warn!( - "auto-claim: reveal broadcast failed for swap {}: {e}", - hex::encode(swap_id) - ); - SwapStage::Failed { - reason: format!("reveal broadcast: {e}"), - } - } - }; - - // Now emit the swap status update to the swap_status_tx channel, indicating the current stage of the swap. - let _ = swap_status_tx.send(SwapStatusUpdate { - swap_id, - stage, - at: now_unix_secs(), - }); - }; - stroemnet_protocol::spawn(fut); - } - - /// Handles an incoming proposal request from a peer, generating a proposal response if this node is an LP. - async fn handle_proposal( - &self, - from: &str, - req: ProposalRequest, - ) -> Result<(), Box> { - use stroemnet_handler::handle::proposal::SwapRequest; - - // Parse the swap request - let swap_request = SwapRequest { - origin: req.origin, - destination: req.destination, - amount: req.amount.clone(), - }; - - // Create a proposal for this request. - let proposal = self.handler.create_proposal(&swap_request).await?; - - // Compute the origin and destination as u8's - let origin = proposal.origin; - let origin_u8 = origin as u8; - let destination_u8 = proposal.destination as u8; - - // Retrieve the number of block confirmations - // that we require so that the user can be aware of how long this swap will take. - let lp_block_confirmations = self - .handler - .block_confirmations - .get(&proposal.destination) - .copied() - .unwrap_or(0); - - // Retrieve the LP's address for the origin channel, which will be used in the proposal response. - let lp_sender_address = self.sink.lp_address(origin)?; - - // Compute a hash of the proposal details, which we will sign - // in order to prove our identity. - let digest = stroemnet_p2p::proposal_digest( - req.swap_id, - origin_u8, - destination_u8, - &proposal.amount_in, - &proposal.amount_out, - &proposal.sender_destination_address, - &lp_sender_address, - proposal.commit_unlock_offset_secs, - lp_block_confirmations, - ); - // Sign the message with our LP's private key for the origin channel, - // which will be included in the proposal response. - let (_addr, lp_signature) = self - .sink - .sign_message(origin, digest, &proposal.amount_in) - .await?; - - // Generate the proposal response and send it back to the requesting peer. - let resp = ProposalResponse { - swap_id: req.swap_id, - origin: origin_u8, - destination: destination_u8, - amount_in: proposal.amount_in, - amount_out: proposal.amount_out, - sender_destination_address: proposal.sender_destination_address, - commit_unlock_offset_secs: proposal.commit_unlock_offset_secs, - lp_sender_address, - lp_signature, - lp_block_confirmations, - extra_data: vec![], - }; - - // Broadcast the proposal response back to the requesting peer, completing the proposal handling process. - self.network - .send_to(from, &P2pMsg::ProposalResponse(resp)) - .await?; - Ok(()) - } -} diff --git a/crates/node/src/coordinator/broadcast.rs b/crates/node/src/coordinator/broadcast.rs new file mode 100644 index 0000000..ffb43a3 --- /dev/null +++ b/crates/node/src/coordinator/broadcast.rs @@ -0,0 +1,55 @@ +use stroemnet_p2p::wire::message::P2pMsg; +use stroemnet_protocol::now_unix_secs; +use stroemnet_protocol::v1::RevealV1; + +use super::Coordinator; +use crate::{PendingClaim, SwapStage, SwapStatusUpdate}; + +impl Coordinator { + /// Emit a status of a swap, this is mostly used in frontend and WASM + pub(crate) fn emit_status(&self, swap_id: [u8; 32], stage: SwapStage) { + let _ = self.swap_status_tx.send(SwapStatusUpdate { + swap_id, + stage, + at: now_unix_secs(), + }); + } + + /// Spawn the broadcast of a reveal, essentially wanting to fulfill the swap + pub fn spawn_reveal_broadcast(&self, swap_id: [u8; 32], claim: PendingClaim) { + let network = self.network.clone(); + let swap_status_tx = self.swap_status_tx.clone(); + + // Create a future which will broadcast the reveal and return + // a stage update for the swap + let fut = async move { + let reveal = RevealV1::new(swap_id, claim.secret); + let stage = match network.broadcast(&P2pMsg::Reveal(reveal)).await { + Ok(()) => { + tracing::info!( + "auto-claim: reveal broadcast for swap {}", + hex::encode(swap_id) + ); + SwapStage::Completed + } + Err(e) => { + tracing::warn!( + "auto-claim: reveal broadcast failed for {}: {e}", + hex::encode(swap_id) + ); + SwapStage::Failed { + reason: format!("reveal broadcast: {e}"), + } + } + }; + + // Transmit the swap status + let _ = swap_status_tx.send(SwapStatusUpdate { + swap_id, + stage, + at: now_unix_secs(), + }); + }; + stroemnet_protocol::spawn(fut); + } +} diff --git a/crates/node/src/coordinator/dispatch.rs b/crates/node/src/coordinator/dispatch.rs new file mode 100644 index 0000000..b0a5ecd --- /dev/null +++ b/crates/node/src/coordinator/dispatch.rs @@ -0,0 +1,75 @@ +use super::{Coordinator, DynResult}; +use stroemnet_p2p::wire::message::{P2pMsg, ProposalError}; +use stroemnet_protocol::ChannelId; +use stroemnet_protocol::v1::{ChainEvent, RevealV1}; + +#[cfg(target_arch = "wasm32")] +use crate::SwapStage; + +impl Coordinator { + /// Handle an incoming p2p message and route it to the appropriate place + pub(crate) async fn handle_incoming(&self, from: &str, msg: P2pMsg) -> DynResult { + match msg { + P2pMsg::State(s) => self.network.process_peer_addrs(s.peers).await, + P2pMsg::ProposalRequest(req) => self.on_proposal_request(from, req).await?, + P2pMsg::ProposalResponse(r) => self.on_proposal_response(from, r).await?, + P2pMsg::Reveal(r) => self.on_reveal(from, r).await?, + P2pMsg::ScriptAnnounce(s) => self.on_script_announce(from, s).await?, + P2pMsg::ProposalError(e) => self.on_proposal_error(from, e), + } + Ok(()) + } + + /// An event handler for when the coordinator receives a reveal message + async fn on_reveal(&self, from: &str, r: RevealV1) -> DynResult { + // Attempt to forward the reveal to other peers + if !self + .network + .forward(from, &P2pMsg::Reveal(r.clone())) + .await? + { + return Ok(()); + } + // Handle this as an external reveal event + if let Err(e) = self.handler.handle_external_reveal(r.clone()).await { + tracing::error!("reveal state update for {}: {e}", hex::encode(r.swap_id)); + } + let channels: Vec = self.sink.channels().collect(); + for chan in channels { + // For each channel broadcast the event to all channels, + // todo: in the future we can restrict to only channels that are involved in this swap + if let Err(e) = self + .sink + .broadcast_event(chan, &ChainEvent::Reveal(r.clone())) + .await + { + tracing::error!( + "reveal claim on {chan} for swap {}: {e}", + hex::encode(r.swap_id) + ); + } + } + Ok(()) + } + + /// For wasm this emits an error event so that we can see why a request + /// was rejected + fn on_proposal_error(&self, from: &str, rejection: ProposalError) { + #[cfg(target_arch = "wasm32")] + { + let _ = from; + self.emit_status( + rejection.swap_id, + SwapStage::Failed { + reason: rejection.reason, + }, + ); + } + #[cfg(not(target_arch = "wasm32"))] + tracing::info!( + "proposal rejected by {from} for swap {}: {}", + hex::encode(rejection.swap_id), + rejection.reason + ); + } +} diff --git a/crates/node/src/coordinator/mod.rs b/crates/node/src/coordinator/mod.rs new file mode 100644 index 0000000..e0e892b --- /dev/null +++ b/crates/node/src/coordinator/mod.rs @@ -0,0 +1,91 @@ +#[cfg(target_arch = "wasm32")] +mod broadcast; +mod dispatch; +mod proposal; +mod request; +mod response; + +use std::sync::Arc; + +use futures::StreamExt; +use stroemnet_data::ChainDataSink; +use stroemnet_handler::Handler; +use stroemnet_p2p::P2p; +use stroemnet_p2p::network::NetEvent; +#[cfg(target_arch = "wasm32")] +use tokio::sync::mpsc; + +#[cfg(target_arch = "wasm32")] +use crate::{CheckedQuote, SwapStatusUpdate}; + +/// Maximum size of the redeem script, i.e. the swap script. +/// This gives a generous margin +pub const MAX_REDEEM_SCRIPT_BYTES: usize = 512; + +pub(super) type DynResult = Result<(), Box>; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// The role of the node, either an LP node or an observer. CCR is gated by a flag +pub enum Role { + Lp, + Observer, +} + +/// The junction that ties multiple components together at least at the message +/// level +pub struct Coordinator { + pub handler: Arc, // knows about swaps and their state as well as prices + pub network: Arc, // the p2p network connecting multiple nodes + pub sink: Arc, // a collection of all the channels + pub role: Role, // what role this coordinator has + #[cfg(target_arch = "wasm32")] + pub quote_tx: mpsc::UnboundedSender, // for wasm only + #[cfg(target_arch = "wasm32")] + pub swap_status_tx: mpsc::UnboundedSender, // wasm only +} + +impl Coordinator { + pub fn new( + handler: Arc, + network: Arc, + sink: Arc, + role: Role, + #[cfg(target_arch = "wasm32")] quote_tx: mpsc::UnboundedSender, + #[cfg(target_arch = "wasm32")] swap_status_tx: mpsc::UnboundedSender, + ) -> Arc { + Arc::new(Self { + handler, + network, + sink, + role, + #[cfg(target_arch = "wasm32")] + quote_tx, + #[cfg(target_arch = "wasm32")] + swap_status_tx, + }) + } + + /// Spawns the loop that reads incomign p2p messages and routes them through the coordinator + pub fn spawn_dispatch_loop( + self: Arc, + mut events: futures::channel::mpsc::Receiver, + ) -> Option> { + let fut = async move { + while let Some(NetEvent { from, msg }) = events.next().await { + if let Err(e) = self.handle_incoming(&from, msg).await { + tracing::warn!("p2p incoming message error from {from}: {e}"); + } + } + tracing::info!("p2p coordinator: events channel closed; shutting down"); + }; + #[cfg(not(target_arch = "wasm32"))] + { + Some(tokio::spawn(fut)) + } + #[cfg(target_arch = "wasm32")] + { + wasm_bindgen_futures::spawn_local(fut); + None + } + } +} diff --git a/crates/node/src/coordinator/proposal.rs b/crates/node/src/coordinator/proposal.rs new file mode 100644 index 0000000..edf5c53 --- /dev/null +++ b/crates/node/src/coordinator/proposal.rs @@ -0,0 +1,88 @@ +use super::{Coordinator, DynResult}; +use stroemnet_handler::handle::proposal::SwapRequest; +use stroemnet_p2p::wire::message::{P2pMsg, ProposalError, ProposalRequest, ProposalResponse}; + +impl Coordinator { + /// Handles an incomign proposal request + pub(super) async fn handle_proposal(&self, from: &str, req: ProposalRequest) -> DynResult { + // Create a swaprequest struct + let swap_request = SwapRequest { + origin: req.origin, + destination: req.destination, + amount: req.amount.clone(), + }; + + // Create a proposal for the users request + let proposal = match self.handler.create_proposal(&swap_request).await { + Ok(proposal) => proposal, + Err(e) => { + // If there was a rejection reason emit a proposal error + // so that the user can see the reason + if let Some(reason) = e.rejection_reason() { + let rejection = ProposalError { + swap_id: req.swap_id, + origin: req.origin, + destination: req.destination, + reason, + }; + self.network + .send_to(from, &P2pMsg::ProposalError(rejection)) + .await?; + return Ok(()); + } + return Err(e.into()); + } + }; + + let origin = proposal.origin; + let origin_u8 = origin as u8; + let destination_u8 = proposal.destination as u8; + let lp_block_confirmations = self + .handler + .block_confirmations + .get(&proposal.destination) + .copied() + .unwrap_or(0); + let lp_sender_address = self.sink.lp_address(origin)?; + + // Hash the proposal so we can guarantee authenticity + let digest = stroemnet_p2p::proposal_digest( + req.swap_id, + origin_u8, + destination_u8, + &proposal.amount_in, + &proposal.amount_out, + &proposal.sender_destination_address, + &lp_sender_address, + proposal.commit_unlock_offset_secs, + lp_block_confirmations, + ); + + // Sign the message attesting our balance as well that we at least have the + // amount in for the proposal + let (_addr, lp_signature) = self + .sink + .sign_message(origin, digest, &proposal.amount_in) + .await?; + + let resp = ProposalResponse { + swap_id: req.swap_id, + origin: origin_u8, + destination: destination_u8, + amount_in: proposal.amount_in, + amount_out: proposal.amount_out, + sender_destination_address: proposal.sender_destination_address, + commit_unlock_offset_secs: proposal.commit_unlock_offset_secs, + lp_sender_address, + lp_signature, + lp_block_confirmations, + extra_data: vec![], + }; + + // Create the proposal and emit it across the p2p network + self.network + .send_to(from, &P2pMsg::ProposalResponse(resp)) + .await?; + Ok(()) + } +} diff --git a/crates/node/src/coordinator/request.rs b/crates/node/src/coordinator/request.rs new file mode 100644 index 0000000..2dd75fb --- /dev/null +++ b/crates/node/src/coordinator/request.rs @@ -0,0 +1,86 @@ +use super::{Coordinator, DynResult, MAX_REDEEM_SCRIPT_BYTES, Role}; +use stroemnet_p2p::wire::message::{P2pMsg, ProposalRequest, ScriptAnnounce}; +use stroemnet_protocol::ChannelId; + +impl Coordinator { + /// An event handler when we get a raw proposal request + pub(super) async fn on_proposal_request(&self, from: &str, req: ProposalRequest) -> DynResult { + let origin = ChannelId::try_from(req.origin); + let dest = ChannelId::try_from(req.destination); + + // Ensure the channels that are requested are ones that we can handle + match (origin, dest) { + (Ok(o), Ok(d)) if self.handler.knows_channel(o) && self.handler.knows_channel(d) => {} + _ => { + tracing::info!( + "dropping ProposalRequest from {from} (unknown chains origin={} dest={})", + req.origin, + req.destination + ); + return Ok(()); + } + } + + // Forward the proposal request to other peers too + if !self + .network + .forward(from, &P2pMsg::ProposalRequest(req.clone())) + .await? + { + return Ok(()); + } + // Only if this node is an lp node do we handle it + if self.role == Role::Lp { + self.handle_proposal(from, req).await?; + } + Ok(()) + } + + /// Event handler for handling script announcements + /// these are announcements that utxo scripts have been received and that we + /// might get them in the future + pub(super) async fn on_script_announce(&self, from: &str, s: ScriptAnnounce) -> DynResult { + // We only allow redeem scripts that are within limits + if s.redeem_script.len() > MAX_REDEEM_SCRIPT_BYTES { + tracing::warn!( + "script-announce: rejected from {from} for swap {} — redeem_script too large", + hex::encode(s.swap_id) + ); + return Ok(()); + } + // Forward the script to other peers + if !self + .network + .forward(from, &P2pMsg::ScriptAnnounce(s.clone())) + .await? + { + return Ok(()); + } + + // Todo: in the future, to support multiple scripts channels + // we will need to distinguish where we send and notify of the script + let Some(channel_id) = self.sink.script_channel() else { + return Ok(()); + }; + + // Register the script with that channel + if let Err(e) = self + .sink + .register_script( + channel_id, + s.address, + s.redeem_script, + s.swap_id, + s.unlock_ts, + s.deposit_target, + ) + .await + { + tracing::warn!( + "script-announce: rejected from {from} for swap {} — {e}", + hex::encode(s.swap_id) + ); + } + Ok(()) + } +} diff --git a/crates/node/src/coordinator/response.rs b/crates/node/src/coordinator/response.rs new file mode 100644 index 0000000..080aed5 --- /dev/null +++ b/crates/node/src/coordinator/response.rs @@ -0,0 +1,86 @@ +use super::{Coordinator, DynResult}; +use stroemnet_p2p::wire::message::ProposalResponse; + +#[cfg(target_arch = "wasm32")] +use crate::CheckedQuote; +#[cfg(target_arch = "wasm32")] +use stroemnet_protocol::ChannelId; + +impl Coordinator { + /// Event handler for reacting to proposal response + /// only relevant for wasm who needs to react to a proposal response + pub(super) async fn on_proposal_response(&self, from: &str, r: ProposalResponse) -> DynResult { + #[cfg(target_arch = "wasm32")] + { + // Ensure these are channels that we know how ot handle + let origin = match ( + ChannelId::try_from(r.origin), + ChannelId::try_from(r.destination), + ) { + (Ok(o), Ok(d)) + if self.handler.knows_channel(o) && self.handler.knows_channel(d) => + { + o + } + _ => { + tracing::info!("dropping ProposalResponse from {from} for unknown chains"); + return Ok(()); + } + }; + + // Recompute the hash + let digest = stroemnet_p2p::proposal_digest( + r.swap_id, + r.origin, + r.destination, + &r.amount_in, + &r.amount_out, + &r.sender_destination_address, + &r.lp_sender_address, + r.commit_unlock_offset_secs, + r.lp_block_confirmations, + ); + + // Verify the message that it is indeed signed by the + // claimed address and also that there is enough balance + // to cover the swap + let (signature_valid, balance_sufficient) = match self + .sink + .verify_message( + origin, + digest, + &r.lp_sender_address, + &r.lp_signature, + &r.amount_in, + ) + .await + { + Ok(v) => (v.address_matches, v.balance_sufficient), + Err(e) => { + tracing::error!("verify_message failed: {e}"); + (false, false) + } + }; + + // Transmit the quote to the rest of the system + // in wasm this will yield changes in sdk/fe state. + let _ = self.quote_tx.send(CheckedQuote { + swap_id: r.swap_id, + origin: r.destination, + destination: r.origin, + amount_in: r.amount_out, + amount_out: r.amount_in, + sender_destination_address: r.sender_destination_address, + commit_unlock_offset_secs: r.commit_unlock_offset_secs, + lp_sender_address: r.lp_sender_address, + lp_signature: r.lp_signature, + lp_block_confirmations: r.lp_block_confirmations, + signature_valid, + balance_sufficient, + }); + } + #[cfg(not(target_arch = "wasm32"))] + let _ = (from, r); + Ok(()) + } +} diff --git a/crates/node/src/lib.rs b/crates/node/src/lib.rs index 80c02f5..f2ca44f 100644 --- a/crates/node/src/lib.rs +++ b/crates/node/src/lib.rs @@ -1,3 +1,5 @@ +#![cfg_attr(target_arch = "wasm32", allow(clippy::arc_with_non_send_sync))] + #[cfg(target_arch = "wasm32")] mod claim; #[cfg(not(target_arch = "wasm32"))] @@ -24,15 +26,12 @@ pub use crate::coordinator::Role as NodeRole; pub use crate::node::Node; #[derive(Clone)] -/// Specification for a chain channel, -/// including its configuration and optional wallet information for signing transactions. pub struct ChannelSpec { pub config: Value, pub lp_private_key: Option, } impl ChannelSpec { - /// Get the minimum block confirmations required for this channel, defaulting to 0 if not specified. pub fn minimum_block_confirmations(&self) -> u64 { self.config .get("minimum_block_confirmations") @@ -41,39 +40,24 @@ impl ChannelSpec { } } -/// Configuration for the node pub struct NodeConfig { - /// Configuration for the handler in terms of - /// min/max swap amounts, spread percents - /// and commit buffer in seconds pub handler: HandlerConfig, - /// Each channel's specific specification containing - /// config and wallet configuration pub channels: AHashMap, #[cfg(not(target_arch = "wasm32"))] - /// The address the node should bind to for incoming peer connections, if any. pub bind_addr: Option, #[cfg(not(target_arch = "wasm32"))] - /// The interval in seconds at which the node should update price information from oracles. pub price_oracle_update_interval_secs: u64, - /// A list of bootstrap peer addresses that the node should connect to for discovering other peers in the network. pub bootstrap_peers: Vec, - /// The role of the node in the network, which can affect its behavior and responsibilities. pub role: NodeRole, - /// This is the address that the node will advertise to other peers - /// that it is listening on. pub advertised_listen_addr: Option, #[cfg(target_arch = "wasm32")] - /// A sender for reporting swap status updates, so that the UI can be updated with the latest information. pub swap_status_tx: tokio::sync::mpsc::UnboundedSender, } #[cfg_attr(target_arch = "wasm32", derive(tsify_next::Tsify))] #[cfg_attr(target_arch = "wasm32", tsify(into_wasm_abi, from_wasm_abi))] #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] -/// Represents the current stage of a swap, including relevant information for each stage. pub enum SwapStage { - /// The swap has been initiated and a quote has been obtained, but the deposit has not yet been made. AwaitingDeposit { #[cfg_attr( target_arch = "wasm32", @@ -83,14 +67,11 @@ pub enum SwapStage { address: String, deposit_target: Option, }, - /// The user has submitted the initial commitment CommitSubmitted { unlock_ts: u64, }, Locked, - /// The swap has been fully completed, with the output amount received and the swap finalized. Completed, - /// The swap has failed, with a reason provided for the failure. Failed { reason: String, }, @@ -99,13 +80,9 @@ pub enum SwapStage { #[cfg_attr(target_arch = "wasm32", derive(tsify_next::Tsify))] #[cfg_attr(target_arch = "wasm32", tsify(into_wasm_abi, from_wasm_abi))] #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] -/// Represents an update to the status of a swap, including the swap ID, current stage, and timestamp of the update. pub struct SwapStatusUpdate { - /// The unique identifier for the swap pub swap_id: [u8; 32], - /// The current stage of the swap, pub stage: SwapStage, - /// The timestamp of when the status update occurred pub at: u64, } @@ -113,30 +90,36 @@ pub struct SwapStatusUpdate { #[cfg_attr(target_arch = "wasm32", tsify(into_wasm_abi, from_wasm_abi))] #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct CheckedQuote { - /// The unique identifier for the swap pub swap_id: [u8; 32], - /// The channel from which the user is sending pub origin: u8, - /// The channel to which the user is sending pub destination: u8, - /// The amount the user is sending pub amount_in: String, - /// The amount the user will receive, pub amount_out: String, - /// The address the user needs to send to in order to complete the swap - /// (its from the LP's perspective) pub sender_destination_address: String, - /// How many extra seconds the user needs to offset their unlock ts in addition to - /// finality pub commit_unlock_offset_secs: u64, - /// The lp's sender address from the destination channel (from LP perspective) pub lp_sender_address: String, - /// The signature provided by the LP for the swap pub lp_signature: Vec, - /// The number of block confirmations required for the LP's transaction pub lp_block_confirmations: u64, - /// Indicates whether the signature is valid pub signature_valid: bool, - /// Indicates whether the balance is sufficient for the swap pub balance_sufficient: bool, } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn channel_spec_reads_min_confirmations_with_default() { + let with = ChannelSpec { + config: json!({ "minimum_block_confirmations": 30 }), + lp_private_key: None, + }; + assert_eq!(with.minimum_block_confirmations(), 30); + let without = ChannelSpec { + config: json!({}), + lp_private_key: None, + }; + assert_eq!(without.minimum_block_confirmations(), 0); + } +} diff --git a/crates/node/src/node.rs b/crates/node/src/node.rs deleted file mode 100644 index 655e237..0000000 --- a/crates/node/src/node.rs +++ /dev/null @@ -1,615 +0,0 @@ -use std::sync::Arc; -use std::sync::atomic::{AtomicUsize, Ordering}; - -use ahash::AHashMap; -use stroemnet_amounts::PriceStorage; -use stroemnet_data::ChainDataSink; -use stroemnet_data::CursorStore; -use stroemnet_handler::{Effect, Handler, HandlerConfig}; -use stroemnet_p2p::wire::message::{P2pMsg, ScriptAnnounce}; -use stroemnet_p2p::{P2p, P2pConfig}; -use stroemnet_protocol::ChannelId; -use stroemnet_protocol::swap_tracker::SwapTracker; -use stroemnet_protocol::v1::ChainEvent; -use tokio::sync::RwLock; -use tokio::sync::mpsc; -#[cfg(not(target_arch = "wasm32"))] -use tokio::task::JoinHandle; - -use crate::connection::spawn_bootstrap_with_counter; -#[cfg(not(target_arch = "wasm32"))] -use crate::connection::{spawn_accept, spawn_addr_dial_driver}; -#[cfg(not(target_arch = "wasm32"))] -use crate::oracle::Oracle; - -use crate::coordinator::{Coordinator, Role}; -use crate::error::StroemnetError; -use crate::result::Result; -use crate::{ChannelSpec, NodeConfig}; - -#[cfg(target_arch = "wasm32")] -use crate::{CheckedQuote, PendingClaim, SwapStage, SwapStatusUpdate, pending_claim_matches}; -#[cfg(target_arch = "wasm32")] -use sha2::{Digest, Sha256}; -#[cfg(target_arch = "wasm32")] -use stroemnet_protocol::now_unix_secs; -#[cfg(target_arch = "wasm32")] -use stroemnet_protocol::v1::{AmountV1, CommitmentV1}; - -/// The main driver interval in milliseconds for the node -/// which represents how often the node checks for new chain events and processes them. -const DRIVER_TICK_MS: u64 = 1000; - -/// The core struct encapsulating the main functionality -/// of the stroemnet node, including the handler, p2p network, chain data sink, and pending claims management. -pub struct Node { - pub handler: Arc, - pub network: Arc, - pub peer_count: Arc, - - #[cfg(target_arch = "wasm32")] - sink: Arc, - #[cfg(target_arch = "wasm32")] - pending_claims: Arc>>, - #[cfg(target_arch = "wasm32")] - swap_status_tx: mpsc::UnboundedSender, - role: Role, - - #[cfg(not(target_arch = "wasm32"))] - tasks: Vec>, -} - -#[cfg(target_arch = "wasm32")] -type StartOutput = (Node, mpsc::UnboundedReceiver); -#[cfg(not(target_arch = "wasm32"))] -type StartOutput = Node; - -impl Node { - /// Starts the stroemnet node with the given configuration, initializing all components and spawning necessary tasks. - pub async fn start( - cfg: NodeConfig, - cursor_store: Option>, - ) -> Result { - tracing::info!("Starting stroemnet node..."); - - // Channel for receiving quote updates from the oracle to be exposed to the user - #[cfg(target_arch = "wasm32")] - let (quote_tx, quote_rx) = mpsc::unbounded_channel(); - - // Channel for emitting swap status updates to the user, if configured - #[cfg(target_arch = "wasm32")] - let swap_status_tx = cfg.swap_status_tx.clone(); - let role = cfg.role; - #[cfg(not(target_arch = "wasm32"))] - let oracle_interval_secs = cfg.price_oracle_update_interval_secs; - - // Build the handler and chain data sink based on the provided configuration, - // which includes channel specifications and other parameters. - let (handler, sink) = - build_handler_and_sink(cfg.handler, cfg.channels, cursor_store).await?; - - #[cfg(not(target_arch = "wasm32"))] - // only native nodes participate in discovering and dialing peers, since they - // cannot directly accept incoming connections - let (discovered_peer_dial_tx, mut discovered_peer_dial_rx) = - mpsc::unbounded_channel::(); - - // Prepare the p2p network configuration - // which contains the bootstrap peers, - // the advertised listen address, - // and the channel for discovered peers to be dialed. - // but on wasm we do not provide the discovered_peer_dial_tx - // since we dont do peer discovery or dialing on wasm - let net_config = P2pConfig { - bootstrap_peers: cfg.bootstrap_peers, - advertised_listen_addr: cfg.advertised_listen_addr.clone(), - #[cfg(not(target_arch = "wasm32"))] - discovered_peer_dial_tx: Some(discovered_peer_dial_tx.clone()), - ..P2pConfig::default() - }; - - // Instantiate p2p instance with the network configuration - let (network, net_events) = P2p::new(net_config); - - // Create an Arc for the network to be shared across tasks - let network = Arc::new(network); - - // Initialize peer count as 0 - let peer_count = Arc::new(AtomicUsize::new(0)); - - // Create a container for tracking pending claims - #[cfg(target_arch = "wasm32")] - let pending_claims: Arc>> = - Arc::new(RwLock::new(AHashMap::new())); - - // Create a new p2p coordinator - let coordinator = Coordinator::new( - handler.clone(), - network.clone(), - sink.clone(), - role, - #[cfg(target_arch = "wasm32")] - quote_tx, - #[cfg(target_arch = "wasm32")] - swap_status_tx.clone(), - ); - - // Spawn the p2p coordinator's dispatch loop to handle incoming messages and dispatch them to the handler. - let _coordinator_task = coordinator.clone().spawn_dispatch_loop(net_events); - - #[cfg(not(target_arch = "wasm32"))] - // collect all tasks so that we can manage their lifetimes and shutdown properly - let mut tasks: Vec> = _coordinator_task.into_iter().collect(); - - // Spawn the main driver loop which periodically checks for new - // chain events from the sink and processes them through the handler. - // This happens for both wasm and native. - spawn_driver_loop( - sink.clone(), - handler.clone(), - network.clone(), - #[cfg(target_arch = "wasm32")] - coordinator.clone(), // coordinator on native is ran above - #[cfg(target_arch = "wasm32")] - pending_claims.clone(), // pending claims are only used in wasm since they are from user perspective. - #[cfg(not(target_arch = "wasm32"))] - &mut tasks, - ); - - #[cfg(not(target_arch = "wasm32"))] - { - // For native only, a future that listens for discovered peers from the discovery mechanism and dials them. - let net = network.clone(); - let counter = peer_count.clone(); - let drainer = async move { - use std::collections::HashSet; - use std::sync::Mutex; - // Contains the set of peer URLs that are alredy being dialed - // to avoid duplicate tasks for the same peer. - let in_flight: Arc>> = Arc::new(Mutex::new(HashSet::new())); - - // Drain discovered peers and attempt to dial them - while let Some(url) = discovered_peer_dial_rx.recv().await { - let url_norm = url.trim_end_matches('/').to_ascii_lowercase(); - if net.is_connected_peer(&url_norm).await { - continue; - } - { - let mut set = in_flight.lock().unwrap(); - if !set.insert(url_norm.clone()) { - continue; - } - } - let net = net.clone(); - let counter = counter.clone(); - let in_flight = in_flight.clone(); - let url_norm_clone = url_norm.clone(); - // Spawn the dial task so we can check if this peer - // is real - spawn_addr_dial_driver(net, url, counter, in_flight, url_norm_clone); - } - tracing::info!("discovery: dial channel closed, dialer exiting"); - }; - // Spawn the drainer task to handle discovered peers - tasks.push(tokio::spawn(drainer)); - } - - #[cfg(not(target_arch = "wasm32"))] - if role == Role::Lp { - // If this is an LP then we need to create the price oracle - let oracle = Oracle::new(handler.price_storage.clone(), oracle_interval_secs)?; - // and run the price fetching - tasks.push(oracle.run_loop()); - } - - #[cfg(not(target_arch = "wasm32"))] - { - let tracker = handler.swap_tracker.clone(); - // Spawn a periodic cleanup task to remove old swaps from the tracker every hour - // removes completed swaps that are older than 24 hours to prevent unbounded growth of the tracker state - tasks.push(tokio::spawn(async move { - loop { - stroemnet_protocol::sleep_secs(3600).await; - tracker.write().await.cleanup_old_swaps(86400); - } - })); - } - - // Spawns a task to continously attempt to connect to bootstrap peers and open - // connections with them - spawn_bootstrap_with_counter(network.clone(), peer_count.clone()); - - #[cfg(not(target_arch = "wasm32"))] - if let Some(bind_addr) = cfg.bind_addr { - // If we are on native we open the accept loop to allow incoming p2p connections on the bind address - spawn_accept(bind_addr, network.clone(), peer_count.clone(), &mut tasks).await; - } - - // Spawn a task to periodically broadcast the node's state to its peers, - // This shares information about the nodes current state such as its peers - network.clone().spawn_periodic_state_broadcast(60); - - tracing::info!("stroemnet node started successfully"); - - let node = Node { - handler, - network, - peer_count, - #[cfg(target_arch = "wasm32")] - sink, - #[cfg(target_arch = "wasm32")] - pending_claims, - #[cfg(target_arch = "wasm32")] - swap_status_tx, - role, - #[cfg(not(target_arch = "wasm32"))] - tasks, - }; - - #[cfg(target_arch = "wasm32")] - { - Ok((node, quote_rx)) - } - #[cfg(not(target_arch = "wasm32"))] - { - Ok(node) - } - } - - /// Returns the current number of connected peers as tracked by the node. - pub fn peer_count(&self) -> usize { - self.peer_count.load(Ordering::SeqCst) - } - - #[cfg(target_arch = "wasm32")] - /// Initiates a quote request for a swap by broadcasting a ProposalRequest message to the network. - pub async fn request_quote( - &self, - swap_id: [u8; 32], - origin: ChannelId, - destination: ChannelId, - amount: String, - ) -> Result<()> { - use stroemnet_p2p::wire::message::ProposalRequest; - if self.role == Role::Lp { - return Err(StroemnetError::LpModeForbidsInitiation); - } - // create a proposal request and broadcast it to the network - let req = ProposalRequest { - swap_id, - origin: origin as u8, - destination: destination as u8, - amount, - extra_data: vec![], - }; - self.network - .broadcast(&P2pMsg::ProposalRequest(req)) - .await - .map_err(|e| StroemnetError::Other(format!("broadcast: {e}")))?; - Ok(()) - } - - pub fn role(&self) -> Role { - self.role - } - - #[cfg(target_arch = "wasm32")] - /// Emits a swap status update through the swap status tx - fn emit_status(&self, swap_id: [u8; 32], stage: SwapStage) { - let _ = self.swap_status_tx.send(SwapStatusUpdate { - swap_id, - stage, - at: now_unix_secs(), - }); - } - - #[cfg(target_arch = "wasm32")] - /// Registers a commitment and its secret with the node, verifying the secret matches the - /// commitment hash and returning the source-chain deposit target. Does not submit the - /// commitment on-chain — the caller performs the deposit separately. - pub async fn register_commitment( - &self, - commitment: CommitmentV1, - secret: [u8; 32], - expected_amount_out: String, - ) -> Result { - // Only if the node is not an lp, - // however wasm32 should never even be LP nodes so this is technically - // unreachable - if self.role == Role::Lp { - return Err(StroemnetError::LpModeForbidsInitiation); - } - let mut hash = [0u8; 32]; - hash.copy_from_slice(&Sha256::digest(secret)); - - // We only allow the actual hash to be submitted - if hash != commitment.secret_hash { - return Err(StroemnetError::SecretHashMismatch); - } - - // Parse the source and destination channel ids - let source = ChannelId::try_from(commitment.source) - .map_err(|e| StroemnetError::Other(format!("source channel id: {e}")))?; - let destination = ChannelId::try_from(commitment.destination) - .map_err(|e| StroemnetError::Other(format!("destination channel id: {e}")))?; - let swap_id = commitment.swap_id; - - let expected_value = expected_amount_out.parse::().map_err(|_| { - StroemnetError::Other(format!( - "expected_amount_out must be a base-unit integer: {expected_amount_out}" - )) - })?; - if expected_value == 0 { - return Err(StroemnetError::Other( - "expected_amount_out must be greater than zero".into(), - )); - } - let expected_amount_out = AmountV1::new(expected_amount_out, destination.decimals()); - - // Store the pending claim information in the pending claims - // as we are waiting for this claim to eventually be claimable - // once the swap is ready to "finalize". - self.pending_claims.write().await.insert( - swap_id, - PendingClaim { - secret, - expected_counter_chain: destination, - expected_secret_hash: commitment.secret_hash, - expected_destination_address: commitment.addresses.sender_destination.clone(), - expected_amount_out, - }, - ); - - // Return the "deposit address", for ethereum - // its just information about the swap itself but the caller already has this info - // only for kaspa rn do we return unique information in the way that we compute the p2sh - // address and redeem script. - match source { - ChannelId::EthereumSepolia | ChannelId::IgraGalleon => { - serde_json::to_string(&commitment) - .map_err(|e| StroemnetError::Other(format!("commitment params: {e}"))) - } - ChannelId::KaspaTn10 => { - // derive p2sh address and redeem scrit - let (p2sh, redeem) = self - .sink - .derive_deposit(source, &commitment) - .map_err(|e| StroemnetError::Other(format!("kaspa deposit derive: {e}")))?; - let target = commitment.amount.value.clone(); - - // register this script so that we can detect the deposit when it happens - self.sink - .register_script( - source, - p2sh.clone(), - redeem.clone(), - swap_id, - commitment.unlock_ts, - target.clone(), - ) - .await - .map_err(|e| StroemnetError::Other(format!("register script: {e}")))?; - - // Other lp nodes need to be able to see this script announcement so we broadcast it to the network. - let announce = ScriptAnnounce { - address: p2sh.clone(), - swap_id, - redeem_script: redeem, - unlock_ts: commitment.unlock_ts, - deposit_target: target.clone(), - }; - - // Broadcast it over p2p - if let Err(e) = self - .network - .broadcast(&P2pMsg::ScriptAnnounce(announce)) - .await - { - tracing::warn!("kas-source script announce failed: {e}"); - } - self.emit_status( - swap_id, - SwapStage::AwaitingDeposit { - chain: source, - address: p2sh.clone(), - deposit_target: Some(target), - }, - ); - Ok(p2sh) - } - } - } - - pub fn shutdown(self) { - #[cfg(not(target_arch = "wasm32"))] - // in wasm we dont have multithreaded env - // so there is nothing to abort - for t in &self.tasks { - t.abort(); - } - tracing::info!("stroemnet node shut down"); - } -} - -/// Helper function to build the configured handler and chain -/// data sink which provides the system with onchain data -async fn build_handler_and_sink( - handler_config: HandlerConfig, - channels: AHashMap, - cursor_store: Option>, -) -> Result<(Arc, Arc)> { - // We extract the channel ids from the configuration to initialize the price storage and swap tracker, - let channel_ids: Vec = channels.keys().copied().collect(); - let price_storage = PriceStorage::new(channel_ids); - let swap_tracker = Arc::new(RwLock::new(SwapTracker::new())); - - let mut block_confirmations_map = AHashMap::new(); - let mut sink_channels = AHashMap::new(); - let mut keyed_channels = Vec::new(); - // Build the channels into their respective collections - for (id, spec) in channels { - if spec.lp_private_key.is_some() { - keyed_channels.push(id); - } - block_confirmations_map.insert(id, spec.minimum_block_confirmations()); - sink_channels.insert(id, (spec.config, spec.lp_private_key)); - } - - // Create a new chain data sink - let sink = ChainDataSink::new(sink_channels, cursor_store) - .await - .map_err(|e| StroemnetError::Other(format!("chain data sink: {e}")))?; - - let mut address_map = AHashMap::new(); - - // Build the lp address lookup table which maps - // a channel id to our lp address on that channel/chain - for id in keyed_channels { - let address = sink - .lp_address(id) - .map_err(|e| StroemnetError::Other(format!("lp address for {id}: {e}")))?; - tracing::info!("Derived LP address for {id}: {address}"); - address_map.insert(id, address); - } - - // Create the handler - let handler = Arc::new(Handler::new( - price_storage, - swap_tracker, - handler_config, - Arc::new(address_map), - Arc::new(block_confirmations_map), - )); - - Ok((handler, Arc::new(sink))) -} - -/// The main driver loop of the node which continuously checks -/// for new finalized chain events from the sink and processes -/// them through the handler, as well as broadcasting any new script announcements to the network. -fn spawn_driver_loop( - sink: Arc, - handler: Arc, - network: Arc, - #[cfg(target_arch = "wasm32")] coordinator: Arc, // coordinator - #[cfg(target_arch = "wasm32")] pending_claims: Arc>>, - #[cfg(not(target_arch = "wasm32"))] tasks: &mut Vec>, -) { - let fut = async move { - loop { - // get a new finalized chunk of chain events from the sink and apply them through the handler - match sink.finalized_chunk().await { - Ok(events) => { - for (source, event) in events { - // apply events - apply_event( - &sink, - &handler, - #[cfg(target_arch = "wasm32")] - &coordinator, - #[cfg(target_arch = "wasm32")] - &pending_claims, - source, - event, - ) - .await; - } - } - Err(e) => tracing::warn!("finalized_chunk: {e}"), - } - // Check if there are any new script announcements from the sink - // and broadcast them to the network so that other nodes can detect deposits to these scripts. - for a in sink.take_utxo_script_announcements() { - let msg = P2pMsg::ScriptAnnounce(ScriptAnnounce { - address: a.address, - swap_id: a.swap_id, - redeem_script: a.script.redeem_script, - unlock_ts: a.script.unlock_ts, - deposit_target: a.script.deposit_target, - }); - if let Err(e) = network.broadcast(&msg).await { - tracing::warn!("script-announce broadcast failed: {e}"); - } - } - stroemnet_protocol::sleep_ms(DRIVER_TICK_MS).await; - } - }; - #[cfg(not(target_arch = "wasm32"))] - tasks.push(tokio::spawn(fut)); - #[cfg(target_arch = "wasm32")] - wasm_bindgen_futures::spawn_local(fut); -} - -/// Computes a side effect from the result of processing an event -/// and forward the effect to the appropriate channel -async fn apply_event( - sink: &Arc, - handler: &Arc, - #[cfg(target_arch = "wasm32")] coordinator: &Arc, - #[cfg(target_arch = "wasm32")] pending_claims: &Arc>>, - source: ChannelId, - event: ChainEvent, -) { - #[cfg(target_arch = "wasm32")] - if let ChainEvent::Commitment(c) = &event { - let own_deposit = pending_claims - .read() - .await - .get(&c.swap_id) - .map(|claim| source != claim.expected_counter_chain) - .unwrap_or(false); - if own_deposit { - coordinator.emit_status(c.swap_id, SwapStage::Locked); - } - } - - // Compute the effects from the event through the handler, if there is an error we log it and skip to the next event - let clock = sink.chain_clock(); - let effects = match handler.on_chain_event(source, event, &clock).await { - Ok(effects) => effects, - Err(e) => { - tracing::warn!("on_chain_event: {e}"); - return; - } - }; - for effect in effects { - match effect { - // Broadcast the event to the network so that other nodes can update their state accordingly - Effect::Broadcast(channel_id, ev) => { - if let Err(e) = sink.broadcast_event(channel_id, &ev).await { - tracing::error!("broadcast_event to {channel_id}: {e}"); - } - } - // If it is the case that we should transmit the reveal of the secret - // then we do it here - Effect::TransmitReveal(detected) => { - #[cfg(target_arch = "wasm32")] - { - let c = &detected.commitment; - let claim_to_fire = { - // retrieve the claim that we are supposed to reveal - let mut map = pending_claims.write().await; - if let Some(claim) = map.remove(&c.swap_id) { - if pending_claim_matches(&claim, c) { - Some(claim) - } else { - map.insert(c.swap_id, claim); - None - } - } else { - None - } - }; - - // if the claim is found spawn a new reveal broadcast - // so that the secret is broadcasted over the p2p network - if let Some(claim) = claim_to_fire { - coordinator.spawn_reveal_broadcast(c.swap_id, claim); - } - } - #[cfg(not(target_arch = "wasm32"))] - let _ = detected; - } - } - } -} diff --git a/crates/node/src/node/apply.rs b/crates/node/src/node/apply.rs new file mode 100644 index 0000000..af51a5e --- /dev/null +++ b/crates/node/src/node/apply.rs @@ -0,0 +1,95 @@ +use std::sync::Arc; + +use stroemnet_data::ChainDataSink; +use stroemnet_handler::{Effect, Handler}; +use stroemnet_protocol::ChannelId; +use stroemnet_protocol::v1::ChainEvent; + +#[cfg(target_arch = "wasm32")] +use crate::coordinator::Coordinator; +#[cfg(target_arch = "wasm32")] +use crate::{PendingClaim, SwapStage, pending_claim_matches}; +#[cfg(target_arch = "wasm32")] +use ahash::AHashMap; +#[cfg(target_arch = "wasm32")] +use tokio::sync::RwLock; + +/// Applies an event to the system +pub(super) async fn apply_event( + sink: &Arc, // the data sink used to communicate with the blockchains + handler: &Arc, // the handler that handles all swaps, tracks swaps + #[cfg(target_arch = "wasm32")] coordinator: &Arc, + #[cfg(target_arch = "wasm32")] pending_claims: &Arc>>, + source: ChannelId, + event: ChainEvent, +) { + // If this is a commitment + #[cfg(target_arch = "wasm32")] + if let ChainEvent::Commitment(c) = &event { + let own_deposit = pending_claims + .read() + .await + .get(&c.swap_id) + .map(|claim| source != claim.expected_counter_chain) + .unwrap_or(false); + if own_deposit { + coordinator.emit_status(c.swap_id, SwapStage::Locked); + } + } + + // Get the clock for all registered chains + let clock = sink.chain_clock(); + + // Compute the effects that arise from this event + let effects = match handler.on_chain_event(source, event, &clock).await { + Ok(effects) => effects, + Err(e) => { + tracing::warn!("on_chain_event: {e}"); + return; + } + }; + + // Go over all effects + for effect in effects { + match effect { + // If the effect is to broadcast to a specific event + // then we broadcast it + Effect::Broadcast(channel_id, ev) => { + if let Err(e) = sink.broadcast_event(channel_id, &ev).await { + tracing::error!("broadcast_event to {channel_id}: {e}"); + } + } + // If its a reveal transmission then it means + // we need to transmit the secret + // this is only used in wasm since we are eagerly waiting + // to submit the secret + Effect::TransmitReveal(detected) => { + #[cfg(target_arch = "wasm32")] + { + let c = &detected; + let claim_to_fire = { + let mut map = pending_claims.write().await; + match map.remove(&c.swap_id) { + // ensures that the claim we computed that needs to + // be revealed is matching with the one we have stored in the + // storage + Some(claim) if pending_claim_matches(&claim, c) => Some(claim), + Some(claim) => { + map.insert(c.swap_id, claim); + None + } + None => None, + } + }; + + // If it passed all checks we should broadcast it on the p2p net + if let Some(claim) = claim_to_fire { + coordinator.spawn_reveal_broadcast(c.swap_id, claim); + } + } + #[cfg(not(target_arch = "wasm32"))] + let _ = detected; + } + } + } +} diff --git a/crates/node/src/node/build.rs b/crates/node/src/node/build.rs new file mode 100644 index 0000000..df8351f --- /dev/null +++ b/crates/node/src/node/build.rs @@ -0,0 +1,107 @@ +use std::sync::Arc; + +use ahash::AHashMap; +use stroemnet_amounts::PriceStorage; +use stroemnet_data::ChainDataSink; +use stroemnet_data::{CursorStore, Gauge, Metric, SettlementMetrics, SwapStore}; +use stroemnet_handler::{Handler, HandlerConfig}; +use stroemnet_p2p::network::NetEvent; +use stroemnet_p2p::{P2p, P2pConfig}; +use stroemnet_protocol::ChannelId; +use stroemnet_protocol::swap_tracker::SwapTracker; +use tokio::sync::RwLock; + +use crate::ChannelSpec; +use crate::error::StroemnetError; +use crate::result::Result; + +/// Builds the p2p struct responsible for communicating with other ndoes +pub(super) fn build_network( + bootstrap_peers: Vec, // the bootstrap peers + advertised: Option, // whether we listen to any address + #[cfg(not(target_arch = "wasm32"))] dial_tx: tokio::sync::mpsc::UnboundedSender, +) -> (Arc, futures::channel::mpsc::Receiver) { + let net_config = P2pConfig { + bootstrap_peers, + advertised_listen_addr: advertised, + #[cfg(not(target_arch = "wasm32"))] + discovered_peer_dial_tx: Some(dial_tx), + ..P2pConfig::default() + }; + let (network, net_events) = P2p::new(net_config); + (Arc::new(network), net_events) +} + +/// Build the swap tracker and the element responsible +/// for tracking swaps and their state +pub(super) async fn build_handler_and_sink( + handler_config: HandlerConfig, // configuration for the swap tracker + channels: AHashMap, // which channels we work on + cursor_store: Option>, // where to store the cursor which we use to maintain chain synk + swap_store: Option>, // where we store swaps after or during their completion to disk +) -> Result<(Arc, Arc)> { + // Retrieve all active channels + let channel_ids: Vec = channels.keys().copied().collect(); + let price_storage = PriceStorage::new(channel_ids); + let swap_tracker = Arc::new(RwLock::new(SwapTracker::new())); + + let mut block_confirmations_map = AHashMap::new(); + let mut sink_channels = AHashMap::new(); + let mut keyed_channels = Vec::new(); + + // Go over all channels and insert their minimum block confirmations and + // configuration, todo: the two DS can be merged into one. + for (id, spec) in channels { + if spec.lp_private_key.is_some() { + keyed_channels.push(id); + } + block_confirmations_map.insert(id, spec.minimum_block_confirmations()); + sink_channels.insert(id, (spec.config, spec.lp_private_key)); + } + + let metrics: Arc = Arc::new(TracingMetrics); + + // Create a new sink which is responsible + // for giving us chain data + let sink = ChainDataSink::new(sink_channels, cursor_store, swap_store, Some(metrics)) + .await + .map_err(|e| StroemnetError::Other(format!("chain data sink: {e}")))?; + + let mut address_map = AHashMap::new(); + + // Go over all channels and map the channel id to the LP address + // so that we can tell users where they should lock funds for + for id in keyed_channels { + let address = sink + .lp_address(id) + .map_err(|e| StroemnetError::Other(format!("lp address for {id}: {e}")))?; + tracing::info!("Derived LP address for {id}: {address}"); + address_map.insert(id, address); + } + + // Create the swap handler + let handler = Arc::new(Handler::new( + price_storage, + swap_tracker, + handler_config, + Arc::new(address_map), + Arc::new(block_confirmations_map), + )); + Ok((handler, Arc::new(sink))) +} + +struct TracingMetrics; + +impl SettlementMetrics for TracingMetrics { + fn incr(&self, metric: Metric) { + match metric { + Metric::Fatal | Metric::DeadlineExceeded => { + tracing::warn!(target: "settlement", kind = "metric", ?metric); + } + _ => tracing::info!(target: "settlement", kind = "metric", ?metric), + } + } + fn gauge(&self, gauge: Gauge, value: u64) { + tracing::info!(target: "settlement", kind = "gauge", ?gauge, value); + } +} diff --git a/crates/node/src/node/deposit.rs b/crates/node/src/node/deposit.rs new file mode 100644 index 0000000..121df3b --- /dev/null +++ b/crates/node/src/node/deposit.rs @@ -0,0 +1,68 @@ +use stroemnet_p2p::wire::message::{P2pMsg, ScriptAnnounce}; +use stroemnet_protocol::ChannelId; +use stroemnet_protocol::v1::CommitmentV1; + +use super::state::Node; +use crate::SwapStage; +use crate::error::StroemnetError; +use crate::result::Result; + +impl Node { + /// Registers a utxo script and computes a p2sh address + /// which is where the user should send funds to + pub(super) async fn register_kaspa_deposit( + &self, + source: ChannelId, + swap_id: [u8; 32], + commitment: &CommitmentV1, + ) -> Result { + // Compute the deposit address based on the commitment + let (p2sh, redeem) = self + .sink + .derive_deposit(source, commitment) + .map_err(|e| StroemnetError::Other(format!("kaspa deposit derive: {e}")))?; + let target = commitment.amount.value.clone(); + + // Register the script + self.sink + .register_script( + source, + p2sh.clone(), + redeem.clone(), + swap_id, + commitment.unlock_ts, + target.clone(), + ) + .await + .map_err(|e| StroemnetError::Other(format!("register script: {e}")))?; + + // Create a script announcement so that many nodes are ready + let announce = ScriptAnnounce { + address: p2sh.clone(), + swap_id, + redeem_script: redeem, + unlock_ts: commitment.unlock_ts, + deposit_target: target.clone(), + }; + + // Broadcast the script announcement over p2p + if let Err(e) = self + .network + .broadcast(&P2pMsg::ScriptAnnounce(announce)) + .await + { + tracing::warn!("kas-source script announce failed: {e}"); + } + + // Emit the status to potential wasm worker + self.emit_status( + swap_id, + SwapStage::AwaitingDeposit { + chain: source, + address: p2sh.clone(), + deposit_target: Some(target), + }, + ); + Ok(p2sh) + } +} diff --git a/crates/node/src/node/driver.rs b/crates/node/src/node/driver.rs new file mode 100644 index 0000000..280688d --- /dev/null +++ b/crates/node/src/node/driver.rs @@ -0,0 +1,104 @@ +use std::sync::Arc; + +use stroemnet_data::ChainDataSink; +use stroemnet_handler::Handler; +use stroemnet_p2p::P2p; +use stroemnet_p2p::wire::message::{P2pMsg, ScriptAnnounce}; +#[cfg(not(target_arch = "wasm32"))] +use tokio::task::JoinHandle; + +use stroemnet_p2p::network::NetEvent; + +use super::apply::apply_event; +use crate::coordinator::Coordinator; + +#[cfg(target_arch = "wasm32")] +use crate::PendingClaim; +#[cfg(target_arch = "wasm32")] +use ahash::AHashMap; +#[cfg(target_arch = "wasm32")] +use tokio::sync::RwLock; + +/// How often to poll events from the sink +const DRIVER_TICK_MS: u64 = 1000; + +/// Spawns the main processing loop and coordinator loop +pub(super) fn spawn_processing( + coordinator: Arc, + sink: Arc, + handler: Arc, + network: Arc, + net_events: futures::channel::mpsc::Receiver, + #[cfg(target_arch = "wasm32")] pending_claims: Arc>>, + #[cfg(not(target_arch = "wasm32"))] tasks: &mut Vec>, +) { + let handle = coordinator.clone().spawn_dispatch_loop(net_events); + #[cfg(not(target_arch = "wasm32"))] + tasks.extend(handle); + #[cfg(target_arch = "wasm32")] + let _ = handle; + spawn_driver_loop( + sink, + handler, + network, + #[cfg(target_arch = "wasm32")] + coordinator, + #[cfg(target_arch = "wasm32")] + pending_claims, + #[cfg(not(target_arch = "wasm32"))] + tasks, + ); +} + +pub(super) fn spawn_driver_loop( + sink: Arc, + handler: Arc, + network: Arc, + #[cfg(target_arch = "wasm32")] coordinator: Arc, + #[cfg(target_arch = "wasm32")] pending_claims: Arc>>, + #[cfg(not(target_arch = "wasm32"))] tasks: &mut Vec>, +) { + let fut = async move { + loop { + // infinitely loop the next finalized chunk of data which can span across many blocks + match sink.finalized_chunk().await { + Ok(events) => { + for (source, event) in events { + // for each of the events, apply them one by one to our state. + apply_event( + &sink, + &handler, + #[cfg(target_arch = "wasm32")] + &coordinator, + #[cfg(target_arch = "wasm32")] + &pending_claims, + source, + event, + ) + .await; + } + } + Err(e) => tracing::warn!("finalized_chunk: {e}"), + } + // Compute all new utxo scripts that we have, and broadcast them over p2p + // todo: this may lead to excessive data transmission + for a in sink.take_utxo_script_announcements() { + let msg = P2pMsg::ScriptAnnounce(ScriptAnnounce { + address: a.address, + swap_id: a.swap_id, + redeem_script: a.script.redeem_script, + unlock_ts: a.script.unlock_ts, + deposit_target: a.script.deposit_target, + }); + if let Err(e) = network.broadcast(&msg).await { + tracing::warn!("script-announce broadcast failed: {e}"); + } + } + stroemnet_protocol::sleep_ms(DRIVER_TICK_MS).await; + } + }; + #[cfg(not(target_arch = "wasm32"))] + tasks.push(tokio::spawn(fut)); + #[cfg(target_arch = "wasm32")] + wasm_bindgen_futures::spawn_local(fut); +} diff --git a/crates/node/src/node/mod.rs b/crates/node/src/node/mod.rs new file mode 100644 index 0000000..4b33073 --- /dev/null +++ b/crates/node/src/node/mod.rs @@ -0,0 +1,14 @@ +mod apply; +mod build; +mod driver; +mod start; +mod state; + +#[cfg(target_arch = "wasm32")] +mod deposit; +#[cfg(not(target_arch = "wasm32"))] +mod services; +#[cfg(target_arch = "wasm32")] +mod taker; + +pub use state::Node; diff --git a/crates/node/src/node/services.rs b/crates/node/src/node/services.rs new file mode 100644 index 0000000..50ccd6b --- /dev/null +++ b/crates/node/src/node/services.rs @@ -0,0 +1,92 @@ +use parking_lot::Mutex; +use std::collections::HashSet; +use std::net::SocketAddr; +use std::sync::Arc; +use std::sync::atomic::AtomicUsize; + +use stroemnet_handler::Handler; +use stroemnet_p2p::P2p; +use tokio::sync::mpsc; +use tokio::task::JoinHandle; + +use crate::connection::{spawn_accept, spawn_addr_dial_driver}; +use crate::coordinator::Role; +use crate::oracle::Oracle; +use crate::result::Result; + +/// Maximum amount of dials to new peers +const MAX_INFLIGHT_DIALS: usize = 64; + +pub(super) fn spawn_discovery_drainer( + network: Arc, + peer_count: Arc, + mut rx: mpsc::UnboundedReceiver, + tasks: &mut Vec>, +) { + let drainer = async move { + // Create a DS to hold which peers we are currently calling to prevent calling the same peer + // multiple times at the same time + let in_flight: Arc>> = Arc::new(Mutex::new(HashSet::new())); + while let Some(url) = rx.recv().await { + // as we get a new peer to dial + let url_norm = stroemnet_p2p::normalize_listen_addr(&url); + // If they are already connected we are not interested in dialling + if network.is_connected_peer(&url_norm).await { + continue; + } + { + // Ensure that we have are within the max amount of peers + // and also that we are not already dialling this peer + let mut set = in_flight.lock(); + if set.len() >= MAX_INFLIGHT_DIALS || !set.insert(url_norm.clone()) { + continue; + } + } + // Spawn the driver that will attempt to dial this peer and establish comms + spawn_addr_dial_driver( + network.clone(), + url, + peer_count.clone(), + in_flight.clone(), + url_norm, + ); + } + tracing::info!("discovery: dial channel closed, dialer exiting"); + }; + tasks.push(tokio::spawn(drainer)); +} + +/// Spawns services that are mainly used in native code execution +/// i.e. for LP nodes. +pub(super) async fn spawn_native_services( + handler: Arc, // handler which tracks swaps + network: Arc, // the p2p network entrypoint + peer_count: Arc, // number of connected peers + role: Role, // either we are lp or observer node + oracle_interval_secs: u64, // how often to update the prices of oracle + bind_addr: Option, // where to listen for data + dial_rx: mpsc::UnboundedReceiver, // where we listen for new peers that we need to dial + tasks: &mut Vec>, // all tasks that we are running +) -> Result<()> { + // Spawn the task that is responsible for discovering new peers and communicating with them + spawn_discovery_drainer(network.clone(), peer_count.clone(), dial_rx, tasks); + if role == Role::Lp { + // If we are an lp we also create a price oracle and poll it for updates + let oracle = Oracle::new(handler.price_storage.clone(), oracle_interval_secs)?; + tasks.push(oracle.run_loop()); + } + let tracker = handler.swap_tracker.clone(); + tasks.push(tokio::spawn(async move { + loop { + // Every 1 hour we remove old swaps from the state + stroemnet_protocol::sleep_secs(3600).await; + tracker.write().await.cleanup_old_swaps(86400); + } + })); + + // If we have an address that we listen to we spawn the acceptance loop + if let Some(bind_addr) = bind_addr { + spawn_accept(bind_addr, network, peer_count, tasks).await; + } + Ok(()) +} diff --git a/crates/node/src/node/start.rs b/crates/node/src/node/start.rs new file mode 100644 index 0000000..16b3e42 --- /dev/null +++ b/crates/node/src/node/start.rs @@ -0,0 +1,120 @@ +use std::sync::Arc; +use std::sync::atomic::AtomicUsize; + +use stroemnet_data::{CursorStore, SwapStore}; + +use super::build::{build_handler_and_sink, build_network}; +use super::driver::spawn_processing; +use super::state::{Node, StartOutput}; +use crate::NodeConfig; +use crate::connection::spawn_bootstrap_with_counter; +use crate::coordinator::Coordinator; +use crate::result::Result; + +#[cfg(not(target_arch = "wasm32"))] +use super::services::spawn_native_services; +#[cfg(not(target_arch = "wasm32"))] +use tokio::task::JoinHandle; + +#[cfg(target_arch = "wasm32")] +use ahash::AHashMap; +#[cfg(target_arch = "wasm32")] +use tokio::sync::RwLock; +#[cfg(target_arch = "wasm32")] +use tokio::sync::mpsc; + +impl Node { + /// Starts the node + pub async fn start( + cfg: NodeConfig, + cursor_store: Option>, + swap_store: Option>, + ) -> Result { + #[cfg(target_arch = "wasm32")] + let (quote_tx, quote_rx) = mpsc::unbounded_channel(); + #[cfg(target_arch = "wasm32")] + let swap_status_tx = cfg.swap_status_tx.clone(); + + // Build the handler and sink which creates them + let (handler, sink) = + build_handler_and_sink(cfg.handler, cfg.channels, cursor_store, swap_store).await?; + + #[cfg(not(target_arch = "wasm32"))] + let (dial_tx, dial_rx) = tokio::sync::mpsc::unbounded_channel::(); + + // build network and receiver for network events + let (network, net_events) = build_network( + cfg.bootstrap_peers, + cfg.advertised_listen_addr.clone(), + #[cfg(not(target_arch = "wasm32"))] + dial_tx, + ); + let peer_count = Arc::new(AtomicUsize::new(0)); + + #[cfg(target_arch = "wasm32")] + let pending_claims = Arc::new(RwLock::new(AHashMap::new())); + + // Create a new coordinator + let coordinator = Coordinator::new( + handler.clone(), + network.clone(), + sink.clone(), + cfg.role, + #[cfg(target_arch = "wasm32")] + quote_tx, + #[cfg(target_arch = "wasm32")] + swap_status_tx.clone(), + ); + #[cfg(not(target_arch = "wasm32"))] + let mut tasks: Vec> = Vec::new(); + + // Spawn the main loops + spawn_processing( + coordinator, + sink.clone(), + handler.clone(), + network.clone(), + net_events, + #[cfg(target_arch = "wasm32")] + pending_claims.clone(), + #[cfg(not(target_arch = "wasm32"))] + &mut tasks, + ); + + #[cfg(not(target_arch = "wasm32"))] + spawn_native_services( + handler.clone(), + network.clone(), + peer_count.clone(), + cfg.role, + cfg.price_oracle_update_interval_secs, + cfg.bind_addr, + dial_rx, + &mut tasks, + ) + .await?; + + spawn_bootstrap_with_counter(network.clone(), peer_count.clone()); + network.clone().spawn_periodic_state_broadcast(60); + + // Create the node + let node = Node::assemble( + handler, + network, + peer_count, + cfg.role, + #[cfg(target_arch = "wasm32")] + sink, + #[cfg(target_arch = "wasm32")] + pending_claims, + #[cfg(target_arch = "wasm32")] + swap_status_tx, + #[cfg(not(target_arch = "wasm32"))] + tasks, + ); + #[cfg(target_arch = "wasm32")] + return Ok((node, quote_rx)); + #[cfg(not(target_arch = "wasm32"))] + Ok(node) + } +} diff --git a/crates/node/src/node/state.rs b/crates/node/src/node/state.rs new file mode 100644 index 0000000..797e81a --- /dev/null +++ b/crates/node/src/node/state.rs @@ -0,0 +1,98 @@ +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use stroemnet_handler::Handler; +use stroemnet_p2p::P2p; +#[cfg(not(target_arch = "wasm32"))] +use tokio::task::JoinHandle; + +use crate::coordinator::Role; + +#[cfg(target_arch = "wasm32")] +use ahash::AHashMap; +#[cfg(target_arch = "wasm32")] +use stroemnet_data::ChainDataSink; +#[cfg(target_arch = "wasm32")] +use tokio::sync::RwLock; +#[cfg(target_arch = "wasm32")] +use tokio::sync::mpsc; + +#[cfg(target_arch = "wasm32")] +use crate::{CheckedQuote, PendingClaim, SwapStage, SwapStatusUpdate}; + +/// The node which collectively stores multiple major components +pub struct Node { + pub handler: Arc, + pub network: Arc, + pub peer_count: Arc, + #[cfg(target_arch = "wasm32")] + pub(super) sink: Arc, + #[cfg(target_arch = "wasm32")] + pub(super) pending_claims: Arc>>, + #[cfg(target_arch = "wasm32")] + pub(super) swap_status_tx: mpsc::UnboundedSender, + pub(super) role: Role, + #[cfg(not(target_arch = "wasm32"))] + pub(super) tasks: Vec>, +} + +#[cfg(target_arch = "wasm32")] +pub(super) type StartOutput = (Node, mpsc::UnboundedReceiver); +#[cfg(not(target_arch = "wasm32"))] +pub(super) type StartOutput = Node; + +impl Node { + /// Assemble multiple components into one + pub(super) fn assemble( + handler: Arc, + network: Arc, + peer_count: Arc, + role: Role, + #[cfg(target_arch = "wasm32")] sink: Arc, + #[cfg(target_arch = "wasm32")] pending_claims: Arc< + RwLock>, + >, + #[cfg(target_arch = "wasm32")] swap_status_tx: mpsc::UnboundedSender, + #[cfg(not(target_arch = "wasm32"))] tasks: Vec>, + ) -> Self { + Self { + handler, + network, + peer_count, + #[cfg(target_arch = "wasm32")] + sink, + #[cfg(target_arch = "wasm32")] + pending_claims, + #[cfg(target_arch = "wasm32")] + swap_status_tx, + role, + #[cfg(not(target_arch = "wasm32"))] + tasks, + } + } + + pub fn peer_count(&self) -> usize { + self.peer_count.load(Ordering::SeqCst) + } + + pub fn role(&self) -> Role { + self.role + } + + pub fn shutdown(self) { + #[cfg(not(target_arch = "wasm32"))] + for t in &self.tasks { + t.abort(); + } + tracing::info!("stroemnet node shut down"); + } + + #[cfg(target_arch = "wasm32")] + pub(super) fn emit_status(&self, swap_id: [u8; 32], stage: SwapStage) { + let _ = self.swap_status_tx.send(SwapStatusUpdate { + swap_id, + stage, + at: stroemnet_protocol::now_unix_secs(), + }); + } +} diff --git a/crates/node/src/node/taker.rs b/crates/node/src/node/taker.rs new file mode 100644 index 0000000..4793290 --- /dev/null +++ b/crates/node/src/node/taker.rs @@ -0,0 +1,104 @@ +use sha2::{Digest, Sha256}; +use stroemnet_p2p::wire::message::{P2pMsg, ProposalRequest}; +use stroemnet_protocol::ChannelId; +use stroemnet_protocol::v1::{AmountV1, CommitmentV1}; + +use super::state::Node; +use crate::PendingClaim; +use crate::coordinator::Role; +use crate::error::StroemnetError; +use crate::result::Result; + +impl Node { + /// Request a quote across the p2p network + /// Only used in wasm + pub async fn request_quote( + &self, + swap_id: [u8; 32], + origin: ChannelId, + destination: ChannelId, + amount: String, + ) -> Result<()> { + if self.role == Role::Lp { + return Err(StroemnetError::LpModeForbidsInitiation); + } + + // Create the proposal request + let req = ProposalRequest { + swap_id, + origin: origin as u8, + destination: destination as u8, + amount, + extra_data: vec![], + }; + + // broadcast the request across the p2p network + self.network + .broadcast(&P2pMsg::ProposalRequest(req)) + .await + .map_err(|e| StroemnetError::Other(format!("broadcast: {e}"))) + } + + /// Register the commitment with ourselves + /// Used in wasm in order to highlight the fact that + /// we soon expect our own deposit to come into the chain. + /// Example: when a kaspa deposit comes in. + /// Returns the deposit address + pub async fn register_commitment( + &self, + commitment: CommitmentV1, + secret: [u8; 32], + expected_amount_out: String, + ) -> Result { + if self.role == Role::Lp { + return Err(StroemnetError::LpModeForbidsInitiation); + } + let mut hash = [0u8; 32]; + hash.copy_from_slice(&Sha256::digest(secret)); + if hash != commitment.secret_hash { + return Err(StroemnetError::SecretHashMismatch); + } + + let source = ChannelId::try_from(commitment.source) + .map_err(|e| StroemnetError::Other(format!("source channel id: {e}")))?; + let destination = ChannelId::try_from(commitment.destination) + .map_err(|e| StroemnetError::Other(format!("destination channel id: {e}")))?; + let swap_id = commitment.swap_id; + + let expected_value = expected_amount_out.parse::().map_err(|_| { + StroemnetError::Other(format!( + "expected_amount_out must be a base-unit integer: {expected_amount_out}" + )) + })?; + if expected_value == 0 { + return Err(StroemnetError::Other( + "expected_amount_out must be greater than zero".into(), + )); + } + + // this automatically becomes a pending claim which needs + // to be refunded or fulfilled + self.pending_claims.write().await.insert( + swap_id, + PendingClaim { + secret, + expected_counter_chain: destination, + expected_secret_hash: commitment.secret_hash, + expected_destination_address: commitment.addresses.sender_destination.clone(), + expected_amount_out: AmountV1::new(expected_amount_out, destination.decimals()), + }, + ); + + // its only relevant to get the deposit address for utxo based systems + match source { + ChannelId::EthereumSepolia | ChannelId::IgraGalleon => { + serde_json::to_string(&commitment) + .map_err(|e| StroemnetError::Other(format!("commitment params: {e}"))) + } + ChannelId::KaspaTn10 => { + self.register_kaspa_deposit(source, swap_id, &commitment) + .await + } + } + } +} diff --git a/crates/node/src/oracle/fetch/aggregate.rs b/crates/node/src/oracle/fetch/aggregate.rs index 8ecae09..041c913 100644 --- a/crates/node/src/oracle/fetch/aggregate.rs +++ b/crates/node/src/oracle/fetch/aggregate.rs @@ -1,26 +1,16 @@ -use crate::oracle::OracleError; -use crate::oracle::result::Result; use ahash::AHashMap; use stroemnet_protocol::ChannelId; use super::PriceFeed; use super::PriceSample; - -// Max retries for each source before giving up, and the backoff strategy (simple fixed backoff here) -const MAX_RETRIES: usize = 3; -#[cfg(not(target_arch = "wasm32"))] -const ATTEMPT_TIMEOUT_SECS: u64 = 30; -const RETRY_BACKOFF_SECS: u64 = 1; +use super::robust::robust_price; +use crate::oracle::OracleError; +use crate::oracle::result::Result; impl PriceFeed { - /// Fetches price data from multiple sources and aggregates it into a single price per channel. - /// The aggregation is done by taking a weighted average of the prices from different sources, where - /// where the weights are based on the reported trading volume in USD for that channel on each source. + /// Computes an aggregation of multiple price sources and their prices + /// for each channels native token pub async fn aggregate(&self, channels: &[ChannelId]) -> Result> { - // Retrieve price samples from all available sources in parallel, with retries. - // Browsers (wasm) can only reach Bybit — Gate.io and MEXC send no CORS headers — - // so they are omitted there to avoid guaranteed failures. Add more CORS-enabled - // sources to the wasm list as they become available. #[cfg(not(target_arch = "wasm32"))] let source_results = { let (mexc_res, bybit_res, gateio_res) = tokio::join!( @@ -41,10 +31,6 @@ impl PriceFeed { )]; let mut by_channel: AHashMap> = AHashMap::new(); - - // Map the results by channel, - // and log any errors without failing the entire aggregation, - // since we want to be resilient to partial failures of sources. for (source_name, source_res) in source_results { match source_res { Ok(samples) => { @@ -52,126 +38,33 @@ impl PriceFeed { by_channel.entry(ch).or_default().push(sample); } } - Err(e) => tracing::warn!("{} source exhausted: {}", source_name, e), + Err(e) => tracing::warn!("{source_name} source exhausted: {e}"), } } let mut out = Vec::new(); - // For each requested channel, compute the weighted average price - //across all sources that provided data for that channel. for ch in channels { - let Some(samples) = by_channel.get(ch) else { - continue; - }; - if samples.is_empty() { - continue; + if let Some(samples) = by_channel.get(ch) + && let Some(price) = robust_price(samples) + { + out.push((*ch, price)); } - // Compute the total volume across all sources for this channel, and then the weighted average price. - let total_volume: f64 = samples.iter().map(|s| s.volume_usd).sum(); - let weighted_price = if total_volume > 0.0 { - (samples.iter().map(|s| s.price * s.volume_usd).sum::()) / total_volume - } else { - // If there is no volume we simply fall back to average price - samples.iter().map(|s| s.price).sum::() / samples.len() as f64 - }; - tracing::debug!( - "Aggregated {} price across {} source(s): ${:.6}", - ch.to_string(), - samples.len(), - weighted_price - ); - // Push the aggregated price for this channel to the output list - out.push((*ch, weighted_price)); } if out.is_empty() { return Err(OracleError::AllSourcesFailed( - "All exchanges failed for every channel".to_string(), + "All exchanges failed or produced insufficient agreeing sources".to_string(), )); } - Ok(out) } - - /// Helper function to perform retries with timeouts for a given async operation, used for fetching from each source. - async fn with_retry(&self, source: &'static str, f: F) -> Result - where - F: Fn() -> Fut, // Accepts a fn that returns future - Fut: std::future::Future>, // standard future that returns result - { - let mut last_error = None; - for attempt in 1..=MAX_RETRIES { - // Native: race the fetch against a timeout. Wasm: the browser fetch has its - // own timeout/abort semantics and tokio timers are unavailable. - #[cfg(not(target_arch = "wasm32"))] - let outcome: Result = match tokio::time::timeout( - std::time::Duration::from_secs(ATTEMPT_TIMEOUT_SECS), - f(), - ) - .await - { - Ok(r) => r, - Err(_) => Err(OracleError::Timeout(ATTEMPT_TIMEOUT_SECS)), - }; - #[cfg(target_arch = "wasm32")] - let outcome: Result = f().await; - - match outcome { - Ok(v) => return Ok(v), - Err(e) => { - tracing::warn!( - "{} fetch attempt {}/{} failed: {}", - source, - attempt, - MAX_RETRIES, - e - ); - last_error = Some(e); - } - } - if attempt < MAX_RETRIES { - stroemnet_protocol::sleep_secs(RETRY_BACKOFF_SECS).await; - } - } - Err(OracleError::RetryExhausted( - MAX_RETRIES, - last_error - .map(|e| e.to_string()) - .unwrap_or_else(|| "Unknown error".to_string()), - )) - } } #[cfg(test)] mod tests { + #![allow(clippy::unwrap_used)] use super::*; - #[tokio::test] - #[ignore = "requires network"] - async fn aggregate_kas_across_three_sources() { - let feed = PriceFeed::new(reqwest::Client::new()); - let prices = feed - .aggregate(&[ChannelId::KaspaTn10]) - .await - .expect("aggregate"); - assert_eq!(prices.len(), 1); - assert!(prices[0].1 > 0.0); - println!("Weighted KAS price: ${:.6}", prices[0].1); - } - - #[tokio::test] - #[ignore = "requires network"] - async fn aggregate_eth_across_three_sources() { - let feed = PriceFeed::new(reqwest::Client::new()); - let prices = feed - .aggregate(&[ChannelId::EthereumSepolia]) - .await - .expect("aggregate"); - assert_eq!(prices.len(), 1); - assert!(prices[0].1 > 0.0); - println!("Weighted ETH price: ${:.2}", prices[0].1); - } - #[tokio::test] #[ignore = "requires network"] async fn aggregate_both_channels() { @@ -179,11 +72,8 @@ mod tests { let prices = feed .aggregate(&[ChannelId::KaspaTn10, ChannelId::EthereumSepolia]) .await - .expect("aggregate"); + .unwrap(); assert_eq!(prices.len(), 2); - for (ch, p) in &prices { - println!("{}: ${:.6}", ch.to_string(), p); - assert!(*p > 0.0); - } + assert!(prices.iter().all(|(_, p)| *p > 0.0)); } } diff --git a/crates/node/src/oracle/fetch/bybit.rs b/crates/node/src/oracle/fetch/bybit.rs index bdf5789..e36b088 100644 --- a/crates/node/src/oracle/fetch/bybit.rs +++ b/crates/node/src/oracle/fetch/bybit.rs @@ -32,15 +32,12 @@ struct BybitTicker { } impl PriceFeed { - /// Fetches price data for the given channels from Bybit's API, - /// mapping our internal channel IDs to Bybit symbols. pub(super) async fn bybit( &self, channels: &[ChannelId], ) -> Result> { let mut symbol_to_channels: AHashMap<&'static str, Vec> = AHashMap::new(); - // go over all channels and map them to bybit symbols for ch in channels { if let Some(sym) = Self::bybit_symbol(ch) { symbol_to_channels.entry(sym).or_default().push(*ch); @@ -53,14 +50,11 @@ impl PriceFeed { )); } - // Go over all the symbols that we need to fetch and fetch them let mut out = Vec::new(); for (symbol, chs) in symbol_to_channels { match self.bybit_single(symbol).await { Ok(sample) => { for ch in chs { - // Since there can be multiple channels mapping - // to same symbol, for example both KaspaTn10 and IgraGalleon map to KASUSDT out.push((ch, sample)); } } @@ -78,7 +72,6 @@ impl PriceFeed { } async fn bybit_single(&self, symbol: &str) -> Result { - // Query bybit api let resp = self .client .get(format!("{}/v5/market/tickers", BYBIT_BASE_URL)) @@ -94,7 +87,6 @@ impl PriceFeed { body.ret_code, symbol, body.ret_msg ))); } - // Extract the price and volume from the rsesponse let ticker = body.result.list.first().ok_or_else(|| { OracleError::NoPriceData(format!("Bybit returned empty list for {}", symbol)) })?; @@ -105,7 +97,6 @@ impl PriceFeed { symbol, price ))); } - // Return data let volume_usd = ticker.turnover_24h.parse::().unwrap_or(0.0); Ok(PriceSample { price, volume_usd }) } @@ -121,6 +112,7 @@ impl PriceFeed { #[cfg(test)] mod tests { + #![allow(clippy::expect_used, clippy::indexing_slicing)] use super::*; #[test] diff --git a/crates/node/src/oracle/fetch/gateio.rs b/crates/node/src/oracle/fetch/gateio.rs index 7e39f59..40b92d5 100644 --- a/crates/node/src/oracle/fetch/gateio.rs +++ b/crates/node/src/oracle/fetch/gateio.rs @@ -16,14 +16,11 @@ struct GateioTicker { } impl PriceFeed { - /// Fetches price data for the given channels from Gate.io's API, - /// mapping our internal channel IDs to Gate.io symbols. pub(super) async fn gateio( &self, channels: &[ChannelId], ) -> Result> { let mut symbol_to_channels: AHashMap<&'static str, Vec> = AHashMap::new(); - // Goes over all channels and maps them to gate io symbols for ch in channels { if let Some(sym) = Self::gateio_symbol(ch) { symbol_to_channels.entry(sym).or_default().push(*ch); @@ -37,12 +34,9 @@ impl PriceFeed { } let mut out = Vec::new(); - // Go over all the symbols that we need to fetch and fetch them for (symbol, chs) in symbol_to_channels { - // Fetch the price for this symbol match self.gateio_single(symbol).await { Ok(sample) => { - // For all channels that map to this symbol, add the sample to the output for ch in chs { out.push((ch, sample)); } @@ -60,7 +54,6 @@ impl PriceFeed { Ok(out) } - /// Fetches price data for a single symbol from Gate.io's API, and extracts the price and volume. async fn gateio_single(&self, symbol: &str) -> Result { let resp = self .client @@ -96,6 +89,7 @@ impl PriceFeed { #[cfg(test)] mod tests { + #![allow(clippy::expect_used, clippy::indexing_slicing)] use super::*; #[test] diff --git a/crates/node/src/oracle/fetch/mexc.rs b/crates/node/src/oracle/fetch/mexc.rs index 6df1a1d..b34277a 100644 --- a/crates/node/src/oracle/fetch/mexc.rs +++ b/crates/node/src/oracle/fetch/mexc.rs @@ -18,14 +18,11 @@ struct MexcTicker { } impl PriceFeed { - /// Fetches price data for the given channels from MEXC's API, - /// mapping our internal channel IDs to MEXC symbols. pub(super) async fn mexc( &self, channels: &[ChannelId], ) -> Result> { let mut symbol_to_channels: AHashMap<&'static str, Vec> = AHashMap::new(); - // Goes over all channels and maps them to mexc symbols for ch in channels { if let Some(sym) = Self::mexc_symbol(ch) { symbol_to_channels.entry(sym).or_default().push(*ch); @@ -39,13 +36,9 @@ impl PriceFeed { } let mut out = Vec::new(); - // Go over all the symbols that we need to fetch and fetch them for (symbol, chs) in symbol_to_channels { match self.mexc_single(symbol).await { Ok(sample) => { - // For all channels that map to this symbol, add the sample to the output - // Since there can be multiple channels mapping - // to same symbol, for example both KaspaTn10 and IgraGalleon map to KASUSDT for ch in chs { out.push((ch, sample)); } @@ -95,6 +88,7 @@ impl PriceFeed { #[cfg(test)] mod tests { + #![allow(clippy::expect_used, clippy::indexing_slicing)] use super::*; #[test] diff --git a/crates/node/src/oracle/fetch/mod.rs b/crates/node/src/oracle/fetch/mod.rs index f2f9941..30c6e04 100644 --- a/crates/node/src/oracle/fetch/mod.rs +++ b/crates/node/src/oracle/fetch/mod.rs @@ -2,8 +2,12 @@ use reqwest::Client; mod aggregate; mod bybit; +#[cfg(not(target_arch = "wasm32"))] mod gateio; +#[cfg(not(target_arch = "wasm32"))] mod mexc; +mod retry; +mod robust; #[derive(Debug, Clone, Copy)] pub(super) struct PriceSample { @@ -12,9 +16,6 @@ pub(super) struct PriceSample { } #[derive(Debug, Clone)] -/// The main price feed struct, -/// which holds a shared HTTP client and provides methods -/// to fetch price data from multiple sources and aggregate it. pub struct PriceFeed { client: Client, } @@ -24,9 +25,6 @@ impl PriceFeed { Self { client } } - /// Builds a PriceFeed with a default HTTP client. On wasm this uses the - /// browser fetch backend; on native a plain client (the Oracle loop builds - /// its own client with a timeout). pub fn with_default_client() -> Self { Self::new(Client::new()) } diff --git a/crates/node/src/oracle/fetch/retry.rs b/crates/node/src/oracle/fetch/retry.rs new file mode 100644 index 0000000..c9aba86 --- /dev/null +++ b/crates/node/src/oracle/fetch/retry.rs @@ -0,0 +1,70 @@ +use super::PriceFeed; +use crate::oracle::OracleError; +use crate::oracle::result::Result; + +const MAX_RETRIES: usize = 3; +#[cfg(not(target_arch = "wasm32"))] +const ATTEMPT_TIMEOUT_SECS: u64 = 30; +const RETRY_BACKOFF_SECS: u64 = 1; + +impl PriceFeed { + pub(super) async fn with_retry(&self, source: &'static str, f: F) -> Result + where + F: Fn() -> Fut, + Fut: std::future::Future>, + { + let mut last_error = None; + for attempt in 1..=MAX_RETRIES { + #[cfg(not(target_arch = "wasm32"))] + let outcome: Result = match tokio::time::timeout( + std::time::Duration::from_secs(ATTEMPT_TIMEOUT_SECS), + f(), + ) + .await + { + Ok(r) => r, + Err(_) => Err(OracleError::Timeout(ATTEMPT_TIMEOUT_SECS)), + }; + #[cfg(target_arch = "wasm32")] + let outcome: Result = f().await; + + match outcome { + Ok(v) => return Ok(v), + Err(e) => { + tracing::warn!("{source} fetch attempt {attempt}/{MAX_RETRIES} failed: {e}"); + last_error = Some(e); + } + } + if attempt < MAX_RETRIES { + stroemnet_protocol::sleep_secs(RETRY_BACKOFF_SECS).await; + } + } + Err(OracleError::RetryExhausted( + MAX_RETRIES, + last_error + .map(|e| e.to_string()) + .unwrap_or_else(|| "Unknown error".to_string()), + )) + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + use std::cell::Cell; + + #[tokio::test] + async fn returns_first_success_without_retrying() { + let feed = PriceFeed::new(reqwest::Client::new()); + let calls = Cell::new(0u32); + let result: Result = feed + .with_retry("x", || { + calls.set(calls.get() + 1); + async { Ok(7u8) } + }) + .await; + assert_eq!(result.unwrap(), 7); + assert_eq!(calls.get(), 1); + } +} diff --git a/crates/node/src/oracle/fetch/robust.rs b/crates/node/src/oracle/fetch/robust.rs new file mode 100644 index 0000000..3020df6 --- /dev/null +++ b/crates/node/src/oracle/fetch/robust.rs @@ -0,0 +1,100 @@ +use super::PriceSample; + +const MAX_DEVIATION: f64 = 0.10; +#[cfg(not(target_arch = "wasm32"))] +const MIN_SOURCES: usize = 2; +#[cfg(target_arch = "wasm32")] +const MIN_SOURCES: usize = 1; + +fn weighted_price(samples: &[PriceSample]) -> f64 { + let total_volume: f64 = samples.iter().map(|s| s.volume_usd).sum(); + if total_volume > 0.0 { + samples.iter().map(|s| s.price * s.volume_usd).sum::() / total_volume + } else { + samples.iter().map(|s| s.price).sum::() / samples.len() as f64 + } +} + +fn median(prices: &mut [f64]) -> Option { + prices.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + let n = prices.len(); + match n % 2 { + 1 => prices.get(n / 2).copied(), + _ => match (prices.get(n / 2 - 1), prices.get(n / 2)) { + (Some(&lo), Some(&hi)) => Some((lo + hi) / 2.0), + _ => None, + }, + } +} + +pub(super) fn robust_price(samples: &[PriceSample]) -> Option { + let valid: Vec = samples + .iter() + .filter(|s| { + s.price.is_finite() && s.price > 0.0 && s.volume_usd.is_finite() && s.volume_usd >= 0.0 + }) + .copied() + .collect(); + if valid.len() < MIN_SOURCES { + return None; + } + let mut prices: Vec = valid.iter().map(|s| s.price).collect(); + let med = median(&mut prices)?; + if med <= 0.0 { + return None; + } + let kept: Vec = valid + .into_iter() + .filter(|s| ((s.price - med) / med).abs() <= MAX_DEVIATION) + .collect(); + if kept.len() < MIN_SOURCES { + return None; + } + let p = weighted_price(&kept); + if p.is_finite() && p > 0.0 { + Some(p) + } else { + None + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + + fn sample(price: f64, volume_usd: f64) -> PriceSample { + PriceSample { price, volume_usd } + } + + #[test] + fn weighted_price_is_volume_weighted() { + let samples = [sample(100.0, 30.0), sample(200.0, 10.0)]; + assert!((weighted_price(&samples) - 125.0).abs() < 1e-9); + } + + #[test] + fn weighted_price_falls_back_to_mean_when_no_volume() { + let samples = [sample(100.0, 0.0), sample(200.0, 0.0)]; + assert!((weighted_price(&samples) - 150.0).abs() < 1e-9); + } + + #[test] + fn robust_price_requires_two_agreeing_sources() { + assert!(robust_price(&[sample(100.0, 10.0)]).is_none()); + let p = robust_price(&[sample(100.0, 10.0), sample(102.0, 10.0)]).unwrap(); + assert!((p - 101.0).abs() < 1e-9); + } + + #[test] + fn robust_price_drops_outliers_and_nonfinite() { + let samples = [ + sample(100.0, 10.0), + sample(101.0, 10.0), + sample(500.0, 10.0), + sample(f64::INFINITY, 10.0), + ]; + let p = robust_price(&samples).unwrap(); + assert!((p - 100.5).abs() < 1e-9); + } +} diff --git a/crates/node/src/oracle/mod.rs b/crates/node/src/oracle/mod.rs index 0066ac0..ead735a 100644 --- a/crates/node/src/oracle/mod.rs +++ b/crates/node/src/oracle/mod.rs @@ -15,8 +15,8 @@ use tokio::time::sleep; #[cfg(not(target_arch = "wasm32"))] #[derive(Debug, Clone)] -/// A group struct for the price storage and price feed which aggregates -/// price data from multiple sources and stores it in the price storage for use by the handler. +/// The oracle responsible for aggregating prices +/// from online sources and storing them pub struct Oracle { price_storage: PriceStorage, feed: PriceFeed, @@ -36,33 +36,26 @@ impl Oracle { }) } - /// Runs the main loop of the oracle, - /// which periodically updates all prices by fetching from multiple sources and aggregating them. pub fn run_loop(self) -> tokio::task::JoinHandle<()> { tokio::spawn(async move { loop { if let Err(e) = self.update_all_prices().await { - tracing::error!("Error updating prices: {}", e); - - self.price_storage.clear(); - tracing::error!("Cleared all prices from storage due to error"); + tracing::warn!( + "price update failed; retaining last baseline (stale prices fail closed): {e}" + ); } - sleep(Duration::from_secs(self.update_interval_secs)).await; + sleep(Duration::from_secs(self.update_interval_secs.max(1))).await; } }) } - /// Updates all prices by fetching from multiple sources and aggregating them. async fn update_all_prices(&self) -> Result<()> { let channels = self.price_storage.channels(); - // Fetches prices for all channels from all sources and aggregates them let prices = self.feed.aggregate(&channels).await?; - // For each channel and price, update the price storage and log the new price. for (channel, price) in prices { self.price_storage.set(channel, price); - tracing::info!("Updated {} price: ${:.6}", channel.to_string(), price); } Ok(()) @@ -71,6 +64,7 @@ impl Oracle { #[cfg(all(test, not(target_arch = "wasm32")))] mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used)] use stroemnet_protocol::ChannelId; use super::*; @@ -86,8 +80,8 @@ mod tests { fn test_oracle_creation() { let channels = vec![ChannelId::KaspaTn10, ChannelId::EthereumSepolia]; let price_storage = PriceStorage::new(channels); - assert_eq!(price_storage.get(&ChannelId::KaspaTn10), Some(0.0)); - assert_eq!(price_storage.get(&ChannelId::EthereumSepolia), Some(0.0)); + assert_eq!(price_storage.get(&ChannelId::KaspaTn10), None); + assert_eq!(price_storage.get(&ChannelId::EthereumSepolia), None); } #[test] diff --git a/crates/node/tests/p2p_bridge.rs b/crates/node/tests/p2p_bridge.rs index 61cdc8d..bd1973f 100644 --- a/crates/node/tests/p2p_bridge.rs +++ b/crates/node/tests/p2p_bridge.rs @@ -1,3 +1,10 @@ +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing +)] + use stroemnet_p2p::wire::message::{P2pMsg, ProposalRequest}; use stroemnet_p2p::wire::{decode, encode}; use stroemnet_test_harness::test_handler; diff --git a/crates/p2p/Cargo.toml b/crates/p2p/Cargo.toml index 64bf99f..7d7b910 100644 --- a/crates/p2p/Cargo.toml +++ b/crates/p2p/Cargo.toml @@ -24,8 +24,9 @@ tokio-tungstenite = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] gloo-net = { workspace = true } -gloo-timers = { workspace = true } -wasm-bindgen-futures = { workspace = true } [dev-dependencies] tokio = { workspace = true, features = ["full"] } + +[lints] +workspace = true diff --git a/crates/p2p/src/addr.rs b/crates/p2p/src/addr.rs new file mode 100644 index 0000000..ea49fd5 --- /dev/null +++ b/crates/p2p/src/addr.rs @@ -0,0 +1,19 @@ +pub fn normalize_listen_addr(s: &str) -> String { + s.trim_end_matches('/').to_ascii_lowercase() +} + +pub fn listen_addrs_equal(a: &str, b: &str) -> bool { + a.trim_end_matches('/') + .eq_ignore_ascii_case(b.trim_end_matches('/')) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_trims_and_lowercases() { + assert_eq!(normalize_listen_addr("WS://X:3000/"), "ws://x:3000"); + assert_eq!(normalize_listen_addr("ws://x:3000"), "ws://x:3000"); + } +} diff --git a/crates/p2p/src/error.rs b/crates/p2p/src/error.rs index 33a3b30..95522a7 100644 --- a/crates/p2p/src/error.rs +++ b/crates/p2p/src/error.rs @@ -16,10 +16,4 @@ pub enum StroemnetP2pError { #[error("Handshake failed: {0}")] HandshakeFailed(String), - - #[error("Url parse: {0}")] - UrlParse(#[from] url::ParseError), - - #[error("Other: {0}")] - Other(String), } diff --git a/crates/p2p/src/gossip/mod.rs b/crates/p2p/src/gossip/mod.rs index 81dc3b2..a5ebd1e 100644 --- a/crates/p2p/src/gossip/mod.rs +++ b/crates/p2p/src/gossip/mod.rs @@ -2,7 +2,7 @@ use std::collections::{HashSet, VecDeque}; use sha2::{Digest, Sha256}; -/// A LRU set of seen message hashes for deduplication in the gossip protocol. +/// Stores all the messages that have been seen so far up to a cap pub struct SeenSet { cap: usize, set: HashSet<[u8; 32]>, @@ -10,7 +10,6 @@ pub struct SeenSet { } impl SeenSet { - /// Create a new SeenSet with the given capacity. pub fn new(cap: usize) -> Self { Self { cap, @@ -19,30 +18,22 @@ impl SeenSet { } } - /// Insert a hash into the set. Returns true if it was not already present. + /// Add a message to the seen set pub fn insert(&mut self, hash: [u8; 32]) -> bool { if !self.set.insert(hash) { return false; } self.order.push_back(hash); + + // After the cap we assume that we wont see this msg again if self.set.len() > self.cap - && let Some(oldest) = self.order.pop_front() { - self.set.remove(&oldest); - } + && let Some(oldest) = self.order.pop_front() + { + self.set.remove(&oldest); + } true } - /// Get the number of hashes in the set. - pub fn len(&self) -> usize { - self.set.len() - } - - /// Check if the set is empty. - pub fn is_empty(&self) -> bool { - self.set.is_empty() - } - - /// Compute the hash of a payload. pub fn hash(payload: &[u8]) -> [u8; 32] { let mut out = [0u8; 32]; out.copy_from_slice(&Sha256::digest(payload)); diff --git a/crates/p2p/src/identity.rs b/crates/p2p/src/identity.rs index 6099e37..004f81e 100644 --- a/crates/p2p/src/identity.rs +++ b/crates/p2p/src/identity.rs @@ -6,6 +6,7 @@ pub struct Identity { } impl Identity { + #[allow(clippy::expect_used)] pub fn generate() -> Self { let mut id = [0u8; 32]; getrandom_03::fill(&mut id).expect("OS RNG unavailable while generating peer identity"); diff --git a/crates/p2p/src/lib.rs b/crates/p2p/src/lib.rs index da405fc..f0208cf 100644 --- a/crates/p2p/src/lib.rs +++ b/crates/p2p/src/lib.rs @@ -1,3 +1,6 @@ +#![cfg_attr(target_arch = "wasm32", allow(clippy::arc_with_non_send_sync))] + +pub mod addr; pub mod error; pub mod gossip; pub mod identity; @@ -6,8 +9,9 @@ pub mod peer; pub mod transport; pub mod wire; +pub use addr::{listen_addrs_equal, normalize_listen_addr}; pub use error::StroemnetP2pError; -pub use identity::{Identity, proposal_digest}; +pub use identity::proposal_digest; pub use network::{P2p, P2pConfig}; pub use transport::WsTransport; pub use wire::P2pMsg; @@ -15,3 +19,13 @@ pub use wire::P2pMsg; pub type Result = std::result::Result; pub const SEED_NODES: &[&str] = &[]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn seed_nodes_default_is_empty() { + assert!(SEED_NODES.is_empty()); + } +} diff --git a/crates/p2p/src/network/config.rs b/crates/p2p/src/network/config.rs new file mode 100644 index 0000000..d16931f --- /dev/null +++ b/crates/p2p/src/network/config.rs @@ -0,0 +1,56 @@ +#[cfg(not(target_arch = "wasm32"))] +use tokio::sync::mpsc::UnboundedSender; + +use crate::identity::Identity; + +#[derive(Debug, Clone)] +/// Configuration for the p2p instance +pub struct P2pConfig { + /// Node identity on the p2p net + pub identity: Identity, + /// Which peers you will connect to + pub bootstrap_peers: Vec, + /// Maximum outbound peers + pub target_outbound: usize, + /// maximum inbound + pub max_inbound: usize, + /// How other peers can reach you + pub advertised_listen_addr: Option, + #[cfg(not(target_arch = "wasm32"))] + /// Where to send discovered peers from other peers + pub discovered_peer_dial_tx: Option>, +} + +impl P2pConfig { + pub fn node_id(&self) -> [u8; 32] { + self.identity.id + } +} + +impl Default for P2pConfig { + fn default() -> Self { + Self { + identity: Identity::generate(), + bootstrap_peers: Vec::new(), + target_outbound: 8, + max_inbound: 125, + advertised_listen_addr: None, + #[cfg(not(target_arch = "wasm32"))] + discovered_peer_dial_tx: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_has_sane_limits_and_node_id() { + let cfg = P2pConfig::default(); + assert_eq!(cfg.target_outbound, 8); + assert_eq!(cfg.max_inbound, 125); + assert!(cfg.bootstrap_peers.is_empty()); + assert_eq!(cfg.node_id(), cfg.identity.id); + } +} diff --git a/crates/p2p/src/network/dial.rs b/crates/p2p/src/network/dial.rs new file mode 100644 index 0000000..a099f20 --- /dev/null +++ b/crates/p2p/src/network/dial.rs @@ -0,0 +1,130 @@ +#[cfg(not(target_arch = "wasm32"))] +use std::sync::Arc; + +use super::state::P2p; +use crate::Result; +#[cfg(not(target_arch = "wasm32"))] +use crate::error::StroemnetP2pError; +use crate::peer::ConnectedPeer; +use crate::transport::WsTransport; +#[cfg(not(target_arch = "wasm32"))] +use tokio::net::TcpListener; + +impl P2p { + /// Dial a peer and handshake which leads to an open connection + pub async fn dial(&self, url: &str) -> Result { + let our_state = self.current_state().await; + let transport = WsTransport::dial(url).await?; + ConnectedPeer::handshake(url.to_string(), transport, our_state, false).await + } + + /// Dial the peer with a backoff and retry + pub async fn dial_with_backoff(&self, url: &str) -> Option { + let mut delay_ms: u64 = 1_000; + let max_ms: u64 = 60_000; + let mut last_error = None; + for _ in 1..=5 { + match self.dial(url).await { + Ok(p) => return Some(p), + Err(e) => { + last_error = Some(e); + stroemnet_protocol::sleep_ms(delay_ms).await; + delay_ms = (delay_ms * 2).min(max_ms); + } + } + } + tracing::warn!("dial {url} giving up after 5 attempts: {last_error:?}"); + None + } + + #[cfg(not(target_arch = "wasm32"))] + /// Create a listener for incoming connections and return a receiver + /// which will emit connections + pub async fn listen( + self: Arc, + bind_addr: std::net::SocketAddr, + ) -> Result> { + // BInd the listener + let listener = TcpListener::bind(bind_addr) + .await + .map_err(|e| StroemnetP2pError::Io(format!("bind {bind_addr}: {e}")))?; + let (tx, rx) = tokio::sync::mpsc::channel(32); + let net = self; + let limiter = Arc::new(tokio::sync::Semaphore::new(net.config.max_inbound)); + tokio::spawn(async move { + loop { + let (stream, addr) = match listener.accept().await { + Ok(p) => p, + Err(e) => { + tracing::warn!("accept failed: {e}"); + continue; + } + }; + + // Limit the number of inbound connections via semaphore + let Ok(permit) = limiter.clone().try_acquire_owned() else { + tracing::warn!("inbound from {addr} rejected: handshake limit reached"); + continue; + }; + let tx = tx.clone(); + let net = net.clone(); + tokio::spawn(async move { + let _permit = permit; + accept_inbound(net, stream, addr, tx).await; + }); + } + }); + Ok(rx) + } +} + +#[cfg(not(target_arch = "wasm32"))] +const HANDSHAKE_TIMEOUT_SECS: u64 = 10; + +#[cfg(not(target_arch = "wasm32"))] +async fn accept_inbound( + net: Arc, + stream: tokio::net::TcpStream, + addr: std::net::SocketAddr, + tx: tokio::sync::mpsc::Sender, +) { + // Accept the websocket connection future closure + let handshake = async { + let ws = tokio_tungstenite::accept_async_with_config( + stream, + Some(crate::transport::ws_config()), + ) + .await + .map_err(|e| crate::error::StroemnetP2pError::Io(format!("ws accept {addr}: {e}")))?; + let transport = WsTransport::from_inbound(ws); + let our_s = net.current_state().await; + ConnectedPeer::handshake(format!("ws://{addr}"), transport, our_s, true).await + }; + let timeout = std::time::Duration::from_secs(HANDSHAKE_TIMEOUT_SECS); + + // Race handhsake against timeout + match tokio::time::timeout(timeout, handshake).await { + Ok(Ok(peer)) => { + tracing::info!( + "inbound peer node_id={} accepted (src={addr})", + hex::encode(peer.node_id) + ); + let _ = tx.send(peer).await; + } + Ok(Err(e)) => tracing::warn!("inbound handshake from {addr} failed: {e}"), + Err(_) => tracing::warn!("inbound handshake from {addr} timed out"), + } +} + +#[cfg(test)] +mod tests { + use super::super::config::P2pConfig; + use super::*; + + #[tokio::test] + #[ignore] + async fn dial_requires_live_peer() { + let (net, _rx) = P2p::new(P2pConfig::default()); + assert!(net.dial("ws://127.0.0.1:1").await.is_err()); + } +} diff --git a/crates/p2p/src/network/discovery.rs b/crates/p2p/src/network/discovery.rs new file mode 100644 index 0000000..97cd6c3 --- /dev/null +++ b/crates/p2p/src/network/discovery.rs @@ -0,0 +1,78 @@ +use super::state::P2p; +use crate::wire::message::PeerAddr; + +impl P2p { + /// Processer a list of peers + pub async fn process_peer_addrs(&self, addrs: Vec) { + #[cfg(target_arch = "wasm32")] + { + let _ = addrs; + } + #[cfg(not(target_arch = "wasm32"))] + self.process_peer_addrs_native(addrs).await; + } + + #[cfg(not(target_arch = "wasm32"))] + /// Process a list of peers + async fn process_peer_addrs_native(&self, addrs: Vec) { + let Some(dial_tx) = self.config.discovered_peer_dial_tx.as_ref() else { + return; + }; + let our_listen = self + .config + .advertised_listen_addr + .as_deref() + .map(crate::normalize_listen_addr); // compute our listening address + + // compute all connected peers listening address + let connected: std::collections::HashSet = self + .connected_peers + .lock() + .await + .iter() + .filter_map(|p| p.advertised_listen.clone()) + .map(|u| crate::normalize_listen_addr(&u)) + .collect(); + let target = self.config.target_outbound; + let mut requested = 0; + + // Go over all addresses + for entry in addrs { + if connected.len() + requested >= target { + break; + } + let url_norm = crate::normalize_listen_addr(&entry.url); + if our_listen.as_deref() == Some(url_norm.as_str()) || connected.contains(&url_norm) { + continue; + } + if !url_norm.starts_with("ws://") && !url_norm.starts_with("wss://") { + tracing::debug!("addr: skipping non-ws URL {}", entry.url); + continue; + } + + // Transmit to the dialler that we request to call this peer + if let Err(e) = dial_tx.send(entry.url.clone()) { + tracing::warn!("discovery: dial channel closed dropping {}: {e}", entry.url); + return; + } + requested += 1; + } + } +} + +#[cfg(test)] +mod tests { + use super::super::config::P2pConfig; + use super::*; + + #[tokio::test] + async fn no_dial_tx_is_a_safe_noop() { + let (net, _rx) = P2p::new(P2pConfig::default()); + net.process_peer_addrs(vec![PeerAddr { + url: "ws://x/".into(), + last_seen: 0, + }]) + .await; + assert!(net.connected_peer_addrs().await.is_empty()); + } +} diff --git a/crates/p2p/src/network/mod.rs b/crates/p2p/src/network/mod.rs index efc5ded..91c10a6 100644 --- a/crates/p2p/src/network/mod.rs +++ b/crates/p2p/src/network/mod.rs @@ -1,368 +1,8 @@ -use std::collections::HashSet; -use std::sync::Arc; - -use futures::channel::mpsc; -use futures::lock::Mutex; -#[cfg(not(target_arch = "wasm32"))] -use tokio::sync::mpsc::UnboundedSender; - -use crate::Result; -use crate::gossip::SeenSet; -use crate::identity::Identity; -use crate::peer::ConnectedPeer; -use crate::wire::encode; -use crate::wire::message::P2pMsg; - -#[derive(Debug, Clone)] -/// Configuration for the p2p protocol -pub struct P2pConfig { - /// A unique identity for each node. - pub identity: Identity, - /// Bootstrap peers for those that are first-joiners to the network. - pub bootstrap_peers: Vec, - /// Target number of outbound connections. - pub target_outbound: usize, - /// Limit the number of inbound connections up to the max - pub max_inbound: usize, - /// If we are an LP node, we have also provided our listen - /// address - pub advertised_listen_addr: Option, - #[cfg(not(target_arch = "wasm32"))] - /// Only for non WASM can we accept peers from - /// other peers' states. - pub discovered_peer_dial_tx: Option>, -} - -impl P2pConfig { - /// Get the node id for this p2p configuration - pub fn node_id(&self) -> [u8; 32] { - self.identity.id - } -} - -impl Default for P2pConfig { - fn default() -> Self { - Self { - identity: Identity::generate(), - bootstrap_peers: Vec::new(), - target_outbound: 8, - max_inbound: 125, - advertised_listen_addr: None, - #[cfg(not(target_arch = "wasm32"))] - discovered_peer_dial_tx: None, - } - } -} - -/// The main struct for managing the p2p network -pub struct P2p { - pub config: P2pConfig, - pub connected_peers: Arc>>, - pub seen: Arc>, - pub events_tx: mpsc::Sender, - pub blacklist: Arc>>, -} - -#[derive(Debug)] -pub struct NetEvent { - pub from: String, - pub msg: P2pMsg, -} - -impl P2p { - pub fn new(config: P2pConfig) -> (Self, mpsc::Receiver) { - let (tx, rx) = mpsc::channel(256); - let net = Self { - config, - connected_peers: Arc::new(Mutex::new(Vec::new())), - seen: Arc::new(Mutex::new(SeenSet::new(50_000))), - events_tx: tx, - blacklist: Arc::new(Mutex::new(HashSet::new())), - }; - (net, rx) - } - - pub async fn blacklist_peer(&self, node_id: [u8; 32]) { - self.blacklist.lock().await.insert(node_id); - } - - pub async fn is_blacklisted(&self, node_id: &[u8; 32]) -> bool { - self.blacklist.lock().await.contains(node_id) - } - - pub async fn add_connected_peer(&self, peer: ConnectedPeer) -> usize { - let mut peers = self.connected_peers.lock().await; - peers.push(peer); - peers.len() - 1 - } - - pub async fn remove_connected_peer(&self, url: &str) { - let mut peers = self.connected_peers.lock().await; - peers.retain(|p| p.url != url); - } - - pub async fn connected_peer_addrs(&self) -> Vec { - let our_listen = self - .config - .advertised_listen_addr - .as_deref() - .map(|s| s.trim_end_matches('/')); - self.connected_peers - .lock() - .await - .iter() - .filter_map(|p| { - let url = p.advertised_listen.clone()?; - if let Some(ours) = our_listen - && url.trim_end_matches('/').eq_ignore_ascii_case(ours) { - return None; - } - Some(crate::wire::message::PeerAddr { - url, - last_seen: p.connected_at, - }) - }) - .collect() - } - - pub async fn process_peer_addrs(&self, addrs: Vec) { - #[cfg(target_arch = "wasm32")] - { - let _ = addrs; - return; - } - #[cfg(not(target_arch = "wasm32"))] - self.process_peer_addrs_native(addrs).await; - } - - #[cfg(not(target_arch = "wasm32"))] - async fn process_peer_addrs_native(&self, addrs: Vec) { - let Some(dial_tx) = self.config.discovered_peer_dial_tx.as_ref() else { - return; - }; - let our_listen = self - .config - .advertised_listen_addr - .as_deref() - .map(|s| s.trim_end_matches('/').to_ascii_lowercase()); - let connected: std::collections::HashSet = self - .connected_peers - .lock() - .await - .iter() - .filter_map(|p| p.advertised_listen.clone()) - .map(|u| u.trim_end_matches('/').to_ascii_lowercase()) - .collect(); - let target = self.config.target_outbound; - let mut requested = 0; - for entry in addrs { - if connected.len() + requested >= target { - break; - } - let url_norm = entry.url.trim_end_matches('/').to_ascii_lowercase(); - if our_listen.as_deref() == Some(url_norm.as_str()) { - continue; - } - if connected.contains(&url_norm) { - continue; - } - if !url_norm.starts_with("ws://") && !url_norm.starts_with("wss://") { - tracing::debug!("addr: skipping non-ws URL {}", entry.url); - continue; - } - if let Err(e) = dial_tx.send(entry.url.clone()) { - tracing::warn!("discovery: dial channel closed dropping {}: {e}", entry.url); - return; - } - requested += 1; - } - } - - pub async fn current_state(&self) -> crate::wire::message::NodeState { - crate::wire::message::NodeState { - node_id: self.config.node_id(), - listen_addr: self.config.advertised_listen_addr.clone(), - peers: self.connected_peer_addrs().await, - } - } - - pub async fn is_connected_peer(&self, listen_url_norm: &str) -> bool { - self.connected_peers.lock().await.iter().any(|p| { - p.advertised_listen - .as_deref() - .map(|u| { - u.trim_end_matches('/') - .eq_ignore_ascii_case(listen_url_norm) - }) - .unwrap_or(false) - }) - } - - pub fn spawn_periodic_state_broadcast(self: Arc, interval_secs: u64) { - let fut = async move { - loop { - stroemnet_protocol::sleep_secs(interval_secs).await; - let state = self.current_state().await; - if state.peers.is_empty() { - continue; - } - if let Err(e) = self.broadcast(&P2pMsg::State(state)).await { - tracing::debug!("periodic state broadcast failed: {e}"); - } - } - }; - stroemnet_protocol::spawn(fut); - } - - pub async fn broadcast(&self, msg: &P2pMsg) -> Result<()> { - let bytes = encode(msg)?; - self.observe(&bytes).await; - self.send_bytes_to_all(bytes, None).await; - Ok(()) - } - - pub async fn send_to(&self, url: &str, msg: &P2pMsg) -> Result<()> { - let bytes = encode(msg)?; - let peers = self.connected_peers.lock().await; - if let Some(peer) = peers.iter().find(|p| p.url == url) { - peer.send_bytes(bytes).await?; - } - Ok(()) - } - - pub async fn forward(&self, from: &str, msg: &P2pMsg) -> Result { - let bytes = encode(msg)?; - if !self.observe(&bytes).await { - return Ok(false); - } - self.send_bytes_to_all(bytes, Some(from)).await; - Ok(true) - } - - pub async fn observe(&self, payload: &[u8]) -> bool { - self.seen.lock().await.insert(SeenSet::hash(payload)) - } - - async fn send_bytes_to_all(&self, bytes: Vec, exclude: Option<&str>) { - let peers = self.connected_peers.lock().await; - for peer in peers.iter() { - if Some(peer.url.as_str()) == exclude { - continue; - } - if let Err(e) = peer.send_bytes(bytes.clone()).await { - tracing::warn!("send to {}: {e}", peer.url); - } - } - } - - pub async fn dial(&self, url: &str) -> Result { - use crate::transport::WsTransport; - - let our_state = self.current_state().await; - let transport = WsTransport::dial(url).await?; - ConnectedPeer::handshake(url.to_string(), transport, our_state, false).await - } - - pub async fn dial_with_backoff(&self, url: &str) -> Option { - let mut delay_ms: u64 = 1_000; - let max_ms: u64 = 60_000; - let mut last_error = None; - for _ in 1..=5 { - match self.dial(url).await { - Ok(p) => return Some(p), - Err(e) => { - last_error = Some(e); - stroemnet_protocol::sleep_ms(delay_ms).await; - delay_ms = (delay_ms * 2).min(max_ms); - } - } - } - tracing::warn!("dial {url} giving up after 5 attempts: {last_error:?}"); - None - } - - #[cfg(not(target_arch = "wasm32"))] - pub async fn listen( - self: Arc, - bind_addr: std::net::SocketAddr, - ) -> Result> { - use crate::error::StroemnetP2pError; - use crate::transport::WsTransport; - use tokio::net::TcpListener; - use tokio_tungstenite::accept_async; - - let listener = TcpListener::bind(bind_addr) - .await - .map_err(|e| StroemnetP2pError::Io(format!("bind {bind_addr}: {e}")))?; - let (tx, rx) = tokio::sync::mpsc::channel(32); - - let net = self; - tokio::spawn(async move { - loop { - let (stream, addr) = match listener.accept().await { - Ok(p) => p, - Err(e) => { - tracing::warn!("accept failed: {e}"); - continue; - } - }; - let tx = tx.clone(); - let net = net.clone(); - tokio::spawn(async move { - let ws = match accept_async(stream).await { - Ok(ws) => ws, - Err(e) => { - tracing::warn!("ws accept from {addr}: {e}"); - return; - } - }; - let transport = WsTransport::from_inbound(ws); - let our_s = net.current_state().await; - match ConnectedPeer::handshake(format!("ws://{addr}"), transport, our_s, true) - .await - { - Ok(peer) => { - match peer.advertised_listen.as_deref() { - Some(listen) => tracing::info!( - "inbound peer node_id={} accepted (listen={listen}, src={addr})", - hex::encode(peer.node_id) - ), - None => tracing::info!( - "inbound peer node_id={} accepted (non-listening, src={addr})", - hex::encode(peer.node_id) - ), - } - let _ = tx.send(peer).await; - } - Err(e) => tracing::warn!("inbound handshake from {addr} failed: {e}"), - } - }); - } - }); - - Ok(rx) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use stroemnet_protocol::v1::RevealV1; - - #[tokio::test] - async fn forward_returns_false_on_duplicate() { - let (net, _evts) = P2p::new(P2pConfig::default()); - let msg = P2pMsg::Reveal(RevealV1::new([7; 32], [0; 32])); - assert!(net.forward("peer-a", &msg).await.unwrap()); - assert!(!net.forward("peer-b", &msg).await.unwrap()); - } - - #[tokio::test] - async fn broadcast_marks_seen_to_block_loopback() { - let (net, _evts) = P2p::new(P2pConfig::default()); - let msg = P2pMsg::Reveal(RevealV1::new([8; 32], [0; 32])); - - net.broadcast(&msg).await.unwrap(); - assert!(!net.forward("relayer", &msg).await.unwrap()); - } -} +mod config; +mod dial; +mod discovery; +mod relay; +mod state; + +pub use config::P2pConfig; +pub use state::{NetEvent, P2p}; diff --git a/crates/p2p/src/network/relay.rs b/crates/p2p/src/network/relay.rs new file mode 100644 index 0000000..2fbfbac --- /dev/null +++ b/crates/p2p/src/network/relay.rs @@ -0,0 +1,114 @@ +use std::sync::Arc; + +use super::state::P2p; +use crate::Result; +use crate::error::StroemnetP2pError; +use crate::gossip::SeenSet; +use crate::peer::ConnectedPeer; +use crate::wire::encode; +use crate::wire::message::P2pMsg; + +const SEND_TIMEOUT_SECS: u64 = 5; + +/// Races a byte transmission to a connected peer with a timeout +async fn send_timed(peer: &ConnectedPeer, bytes: Vec) -> Result<()> { + use futures::future::{Either, select}; + let send = std::pin::pin!(peer.send_bytes(bytes)); + let timer = std::pin::pin!(stroemnet_protocol::sleep_secs(SEND_TIMEOUT_SECS)); + match select(send, timer).await { + Either::Left((r, _)) => r, + Either::Right(_) => Err(StroemnetP2pError::Io("ws send timed out".into())), + } +} + +impl P2p { + /// Spawns a periodic task that will emit the nodes current state to all its peers + /// A bit noisy but its good enough for an initial implementation + pub fn spawn_periodic_state_broadcast(self: Arc, interval_secs: u64) { + let fut = async move { + loop { + stroemnet_protocol::sleep_secs(interval_secs).await; + let state = self.current_state().await; + if state.peers.is_empty() { + continue; + } + if let Err(e) = self.broadcast(&P2pMsg::State(state)).await { + tracing::debug!("periodic state broadcast failed: {e}"); + } + } + }; + stroemnet_protocol::spawn(fut); + } + + pub async fn broadcast(&self, msg: &P2pMsg) -> Result<()> { + let bytes = encode(msg)?; + self.observe(&bytes).await; + self.send_bytes_to_all(bytes, None).await; + Ok(()) + } + + pub async fn send_to(&self, url: &str, msg: &P2pMsg) -> Result<()> { + let bytes = encode(msg)?; + let peer = { + let peers = self.connected_peers.lock().await; + peers.iter().find(|p| p.url == url).cloned() + }; + if let Some(peer) = peer { + send_timed(&peer, bytes).await?; + } + Ok(()) + } + + pub async fn forward(&self, from: &str, msg: &P2pMsg) -> Result { + let bytes = encode(msg)?; + if !self.observe(&bytes).await { + return Ok(false); + } + self.send_bytes_to_all(bytes, Some(from)).await; + Ok(true) + } + + pub async fn observe(&self, payload: &[u8]) -> bool { + self.seen.lock().await.insert(SeenSet::hash(payload)) + } + + async fn send_bytes_to_all(&self, bytes: Vec, exclude: Option<&str>) { + let targets: Vec = { + let peers = self.connected_peers.lock().await; + peers + .iter() + .filter(|p| Some(p.url.as_str()) != exclude) + .cloned() + .collect() + }; + for peer in targets { + if let Err(e) = send_timed(&peer, bytes.clone()).await { + tracing::warn!("send to {}: {e}", peer.url); + } + } + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::super::config::P2pConfig; + use super::*; + use stroemnet_protocol::v1::RevealV1; + + #[tokio::test] + async fn forward_returns_false_on_duplicate() { + let (net, _evts) = P2p::new(P2pConfig::default()); + let msg = P2pMsg::Reveal(RevealV1::new([7; 32], [0; 32])); + assert!(net.forward("peer-a", &msg).await.unwrap()); + assert!(!net.forward("peer-b", &msg).await.unwrap()); + } + + #[tokio::test] + async fn broadcast_marks_seen_to_block_loopback() { + let (net, _evts) = P2p::new(P2pConfig::default()); + let msg = P2pMsg::Reveal(RevealV1::new([8; 32], [0; 32])); + net.broadcast(&msg).await.unwrap(); + assert!(!net.forward("relayer", &msg).await.unwrap()); + } +} diff --git a/crates/p2p/src/network/state.rs b/crates/p2p/src/network/state.rs new file mode 100644 index 0000000..0215e97 --- /dev/null +++ b/crates/p2p/src/network/state.rs @@ -0,0 +1,109 @@ +use std::collections::HashSet; +use std::sync::Arc; + +use futures::channel::mpsc; +use futures::lock::Mutex; + +use super::config::P2pConfig; +use crate::gossip::SeenSet; +use crate::peer::ConnectedPeer; +use crate::wire::message::{NodeState, P2pMsg, PeerAddr}; + +pub struct P2p { + pub config: P2pConfig, + pub connected_peers: Arc>>, + pub seen: Arc>, + pub events_tx: mpsc::Sender, + pub blacklist: Arc>>, +} + +#[derive(Debug)] +pub struct NetEvent { + pub from: String, + pub msg: P2pMsg, +} + +impl P2p { + pub fn new(config: P2pConfig) -> (Self, mpsc::Receiver) { + let (tx, rx) = mpsc::channel(256); + let net = Self { + config, + connected_peers: Arc::new(Mutex::new(Vec::new())), + seen: Arc::new(Mutex::new(SeenSet::new(50_000))), + events_tx: tx, + blacklist: Arc::new(Mutex::new(HashSet::new())), + }; + (net, rx) + } + + pub async fn blacklist_peer(&self, node_id: [u8; 32]) { + self.blacklist.lock().await.insert(node_id); + } + + pub async fn is_blacklisted(&self, node_id: &[u8; 32]) -> bool { + self.blacklist.lock().await.contains(node_id) + } + + pub async fn add_connected_peer(&self, peer: ConnectedPeer) -> usize { + let mut peers = self.connected_peers.lock().await; + peers.push(peer); + peers.len() - 1 + } + + pub async fn remove_connected_peer(&self, url: &str) { + let mut peers = self.connected_peers.lock().await; + peers.retain(|p| p.url != url); + } + + pub async fn connected_peer_addrs(&self) -> Vec { + let our_listen = self.config.advertised_listen_addr.as_deref(); + self.connected_peers + .lock() + .await + .iter() + .filter_map(|p| { + let url = p.advertised_listen.clone()?; + if let Some(ours) = our_listen + && crate::listen_addrs_equal(&url, ours) + { + return None; + } + Some(PeerAddr { + url, + last_seen: p.connected_at, + }) + }) + .collect() + } + + pub async fn is_connected_peer(&self, listen_url_norm: &str) -> bool { + self.connected_peers.lock().await.iter().any(|p| { + p.advertised_listen + .as_deref() + .map(|u| crate::listen_addrs_equal(u, listen_url_norm)) + .unwrap_or(false) + }) + } + + pub async fn current_state(&self) -> NodeState { + NodeState { + node_id: self.config.node_id(), + listen_addr: self.config.advertised_listen_addr.clone(), + peers: self.connected_peer_addrs().await, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn new_starts_empty_and_blacklist_tracks_ids() { + let (net, _rx) = P2p::new(P2pConfig::default()); + assert!(net.connected_peer_addrs().await.is_empty()); + assert!(!net.is_blacklisted(&[1u8; 32]).await); + net.blacklist_peer([1u8; 32]).await; + assert!(net.is_blacklisted(&[1u8; 32]).await); + } +} diff --git a/crates/p2p/src/peer/handshake.rs b/crates/p2p/src/peer/handshake.rs index ec687f0..838be38 100644 --- a/crates/p2p/src/peer/handshake.rs +++ b/crates/p2p/src/peer/handshake.rs @@ -6,6 +6,8 @@ use crate::wire::message::{NodeState, P2pMsg}; use crate::wire::{decode, encode}; impl ConnectedPeer { + /// Performs the inner handshake which creates a confirmed + /// connected and healthy peer connection pub async fn handshake( url: String, transport: WsTransport, @@ -15,16 +17,20 @@ impl ConnectedPeer { let our_id = our_state.node_id; let our_listen = our_state.listen_addr.clone(); + // Transmit the p2p state to the peer let send_fut = async { let bytes = encode(&P2pMsg::State(our_state))?; transport.send(bytes).await }; + // Create closure to read from this peer let recv_fut = async { let bytes = transport.recv().await?; decode(&bytes) }; let (send_res, recv_res) = futures::join!(send_fut, recv_fut); send_res?; + + // We expect to receive a state from the peer too let peer_state = match recv_res? { P2pMsg::State(s) => s, other => { @@ -33,8 +39,10 @@ impl ConnectedPeer { ))); } }; + // Ensure that we are not trying to handshake ourselves Self::check_self_loop(&peer_state, our_id, our_listen.as_deref())?; + // Since we got here means we have valid state from peer let mut peer = ConnectedPeer::new(url.clone(), transport); peer.node_id = peer_state.node_id; peer.advertised_listen = if is_inbound { @@ -45,6 +53,8 @@ impl ConnectedPeer { peer.connected_at = stroemnet_protocol::now_unix_secs(); peer.is_inbound = is_inbound; peer.known_peers = peer_state.peers; + + // Return the connected peer Ok(peer) } @@ -59,27 +69,24 @@ impl ConnectedPeer { )); } if let (Some(ours), Some(theirs)) = (our_listen, peer.listen_addr.as_deref()) - && Self::listen_addrs_equal(ours, theirs) { - tracing::warn!( - "rejecting peer (node_id={}) advertising our own listen_addr {theirs} \ + && crate::listen_addrs_equal(ours, theirs) + { + tracing::warn!( + "rejecting peer (node_id={}) advertising our own listen_addr {theirs} \ — likely a misconfigured EXTERNAL_HOSTNAME shared across multiple nodes", - hex::encode(peer.node_id) - ); - return Err(StroemnetP2pError::HandshakeFailed(format!( - "peer advertises our listen_addr ({theirs})" - ))); - } + hex::encode(peer.node_id) + ); + return Err(StroemnetP2pError::HandshakeFailed(format!( + "peer advertises our listen_addr ({theirs})" + ))); + } Ok(()) } - - fn listen_addrs_equal(a: &str, b: &str) -> bool { - a.trim_end_matches('/') - .eq_ignore_ascii_case(b.trim_end_matches('/')) - } } #[cfg(test)] mod tests { + #![allow(clippy::unwrap_used)] use super::*; use crate::transport::loopback_pair; @@ -180,17 +187,8 @@ mod tests { #[test] fn listen_addrs_equal_ignores_trailing_slash_and_case() { - assert!(ConnectedPeer::listen_addrs_equal( - "ws://x:3000", - "ws://x:3000/" - )); - assert!(ConnectedPeer::listen_addrs_equal( - "WS://X:3000/", - "ws://x:3000" - )); - assert!(!ConnectedPeer::listen_addrs_equal( - "ws://x:3000", - "ws://x:3001" - )); + assert!(crate::listen_addrs_equal("ws://x:3000", "ws://x:3000/")); + assert!(crate::listen_addrs_equal("WS://X:3000/", "ws://x:3000")); + assert!(!crate::listen_addrs_equal("ws://x:3000", "ws://x:3001")); } } diff --git a/crates/p2p/src/peer/mod.rs b/crates/p2p/src/peer/mod.rs index 49b3596..2acb3f5 100644 --- a/crates/p2p/src/peer/mod.rs +++ b/crates/p2p/src/peer/mod.rs @@ -10,13 +10,11 @@ pub struct ConnectedPeer { pub url: String, pub transport: WsTransport, pub node_id: [u8; 32], - + /// A wasm node does not have a listening address + /// hence this is optional pub advertised_listen: Option, - pub connected_at: u64, - pub is_inbound: bool, - pub known_peers: Vec, } diff --git a/crates/p2p/src/transport/mod.rs b/crates/p2p/src/transport/mod.rs index 764f730..dc28724 100644 --- a/crates/p2p/src/transport/mod.rs +++ b/crates/p2p/src/transport/mod.rs @@ -5,6 +5,8 @@ mod web; #[cfg(not(target_arch = "wasm32"))] pub use native::WsTransport; +#[cfg(not(target_arch = "wasm32"))] +pub(crate) use native::ws_config; #[cfg(target_arch = "wasm32")] pub use web::WsTransport; diff --git a/crates/p2p/src/transport/native.rs b/crates/p2p/src/transport/native.rs index e2132f8..4c621d0 100644 --- a/crates/p2p/src/transport/native.rs +++ b/crates/p2p/src/transport/native.rs @@ -5,11 +5,19 @@ use futures::stream::{SplitSink, SplitStream, StreamExt}; use tokio::net::TcpStream; use tokio::sync::Mutex; use tokio_tungstenite::tungstenite::Message; +use tokio_tungstenite::tungstenite::protocol::WebSocketConfig; use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; use crate::Result; use crate::error::StroemnetP2pError; +pub(crate) fn ws_config() -> WebSocketConfig { + let mut config = WebSocketConfig::default(); + config.max_message_size = Some(crate::wire::codec::MAX_MESSAGE_BYTES); + config.max_frame_size = Some(crate::wire::codec::MAX_MESSAGE_BYTES); + config +} + #[derive(Clone)] pub struct WsTransport { sink: Arc>, @@ -31,9 +39,10 @@ enum StreamKind { impl WsTransport { pub async fn dial(url: &str) -> Result { - let (ws, _resp) = tokio_tungstenite::connect_async(url) - .await - .map_err(|e| StroemnetP2pError::Io(format!("dial {url}: {e}")))?; + let (ws, _resp) = + tokio_tungstenite::connect_async_with_config(url, Some(ws_config()), false) + .await + .map_err(|e| StroemnetP2pError::Io(format!("dial {url}: {e}")))?; let (sink, stream) = ws.split(); Ok(Self { sink: Arc::new(Mutex::new(SinkKind::Outbound(sink))), @@ -98,6 +107,7 @@ fn ws_msg_to_bytes(msg: Message) -> Result>> { } #[cfg(any(test, feature = "test-helpers"))] +#[allow(clippy::expect_used)] pub async fn loopback_pair() -> (WsTransport, WsTransport) { use tokio::net::TcpListener; let listener = TcpListener::bind("127.0.0.1:0") @@ -106,7 +116,7 @@ pub async fn loopback_pair() -> (WsTransport, WsTransport) { let addr = listener.local_addr().expect("local_addr"); let server = tokio::spawn(async move { let (stream, _) = listener.accept().await.expect("accept"); - let ws = tokio_tungstenite::accept_async(stream) + let ws = tokio_tungstenite::accept_async_with_config(stream, Some(ws_config())) .await .expect("ws accept"); WsTransport::from_inbound(ws) @@ -119,6 +129,7 @@ pub async fn loopback_pair() -> (WsTransport, WsTransport) { #[cfg(test)] mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used)] use super::*; #[tokio::test] diff --git a/crates/p2p/src/wire/codec.rs b/crates/p2p/src/wire/codec.rs index 05583a8..2895b71 100644 --- a/crates/p2p/src/wire/codec.rs +++ b/crates/p2p/src/wire/codec.rs @@ -22,9 +22,10 @@ pub fn decode(bytes: &[u8]) -> Result { #[cfg(test)] mod tests { + #![allow(clippy::unwrap_used, clippy::panic)] use super::*; use crate::wire::message::{ - NodeState, PeerAddr, ProposalRequest, ProposalResponse, ScriptAnnounce, + NodeState, PeerAddr, ProposalError, ProposalRequest, ProposalResponse, ScriptAnnounce, }; use stroemnet_protocol::v1::RevealV1; @@ -80,6 +81,16 @@ mod tests { rt(P2pMsg::Reveal(RevealV1::new([3; 32], [4; 32]))); } + #[test] + fn proposal_error_roundtrip() { + rt(P2pMsg::ProposalError(ProposalError { + swap_id: [9; 32], + origin: 1, + destination: 0, + reason: "Trade amount 1 USD value 0.5 is below minimum of 1 USD".into(), + })); + } + #[test] fn script_announce_roundtrip() { rt(P2pMsg::ScriptAnnounce(ScriptAnnounce { diff --git a/crates/p2p/src/wire/message.rs b/crates/p2p/src/wire/message.rs index 42e4b38..66e7352 100644 --- a/crates/p2p/src/wire/message.rs +++ b/crates/p2p/src/wire/message.rs @@ -6,25 +6,21 @@ pub enum P2pMsg { State(NodeState), ProposalRequest(ProposalRequest), ProposalResponse(ProposalResponse), - Reveal(RevealV1), - ScriptAnnounce(ScriptAnnounce), + ProposalError(ProposalError), } #[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] pub struct NodeState { pub node_id: [u8; 32], - pub listen_addr: Option, - pub peers: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Hash, BorshSerialize, BorshDeserialize)] pub struct PeerAddr { pub url: String, - pub last_seen: u64, } @@ -52,12 +48,41 @@ pub struct ProposalResponse { pub extra_data: Vec, } +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] +pub struct ProposalError { + pub swap_id: [u8; 32], + pub origin: u8, + pub destination: u8, + pub reason: String, +} + #[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] pub struct ScriptAnnounce { pub address: String, pub swap_id: [u8; 32], pub redeem_script: Vec, pub unlock_ts: u64, - pub deposit_target: String, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn proposal_error_holds_swap_id_and_reason() { + let msg = P2pMsg::ProposalError(ProposalError { + swap_id: [4; 32], + origin: 0, + destination: 1, + reason: "below minimum".into(), + }); + match msg { + P2pMsg::ProposalError(e) => { + assert_eq!(e.swap_id, [4; 32]); + assert_eq!(e.reason, "below minimum"); + } + _ => unreachable!(), + } + } +} diff --git a/crates/protocol/Cargo.toml b/crates/protocol/Cargo.toml index 67853f0..1cbf852 100644 --- a/crates/protocol/Cargo.toml +++ b/crates/protocol/Cargo.toml @@ -21,3 +21,6 @@ gloo-timers = { workspace = true } getrandom-03 = { workspace = true } js-sys = { workspace = true } wasm-bindgen-futures = { workspace = true } + +[lints] +workspace = true diff --git a/crates/protocol/src/channels.rs b/crates/protocol/src/channels.rs index 1c363f0..4599094 100644 --- a/crates/protocol/src/channels.rs +++ b/crates/protocol/src/channels.rs @@ -17,8 +17,6 @@ use borsh::{BorshDeserialize, BorshSerialize}; serde::Serialize, serde::Deserialize, )] -/// An enum containing all the different channels that are currently supported in Stroemnet -/// No differentiation is made between mainnet/testnet channels. pub enum ChannelId { KaspaTn10, EthereumSepolia, @@ -26,7 +24,6 @@ pub enum ChannelId { } impl ChannelId { - /// Returns the number of decimals used by the channel's native token. pub fn decimals(&self) -> u8 { match self { ChannelId::KaspaTn10 => 8, @@ -34,8 +31,7 @@ impl ChannelId { } } - /// Returns the estimated finality time in seconds for the channel. - pub fn finality_secs(&self) -> u64 { + pub fn lock_time_secs(&self) -> u64 { match self { ChannelId::KaspaTn10 => 180, ChannelId::EthereumSepolia => 15 * 60, @@ -47,7 +43,6 @@ impl ChannelId { matches!(self, ChannelId::IgraGalleon) } - /// Returns the ticker symbol for the channel's native token. pub fn ticker_symbol(&self) -> &'static str { match self { ChannelId::KaspaTn10 => "KAS", @@ -56,7 +51,6 @@ impl ChannelId { } } - /// Returns true if the channel is based on the Ethereum Virtual Machine (EVM). pub fn is_evm(self) -> bool { match self { ChannelId::EthereumSepolia | ChannelId::IgraGalleon => true, @@ -64,24 +58,12 @@ impl ChannelId { } } - /// Returns true if the channel is based on the UTXO model. pub fn is_utxo(self) -> bool { match self { ChannelId::KaspaTn10 => true, ChannelId::EthereumSepolia | ChannelId::IgraGalleon => false, } } - - /// Returns a URL to a block explorer for the given transaction hash on this channel. - pub fn explorer_url(self, tx: &str) -> String { - match self { - ChannelId::EthereumSepolia => format!("https://sepolia.etherscan.io/tx/{tx}"), - ChannelId::KaspaTn10 => format!("https://explorer-tn10.kaspa.org/txs/{tx}"), - ChannelId::IgraGalleon => { - format!("https://explorer.galleon-testnet.igralabs.com/tx/{tx}") - } - } - } } impl Display for ChannelId { @@ -123,6 +105,12 @@ impl TryFrom<&str> for ChannelId { #[cfg(test)] mod tests { + #![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing + )] use super::*; #[test] @@ -168,12 +156,6 @@ mod tests { } } - #[test] - fn test_channel_id_finality_secs() { - assert_eq!(ChannelId::KaspaTn10.finality_secs(), 180); - assert_eq!(ChannelId::EthereumSepolia.finality_secs(), 900); - } - #[test] fn test_is_evm_partition() { assert!(ChannelId::EthereumSepolia.is_evm()); @@ -185,15 +167,4 @@ mod tests { assert!(ChannelId::KaspaTn10.is_utxo()); assert!(!ChannelId::EthereumSepolia.is_utxo()); } - - #[test] - fn test_explorer_url_per_chain() { - let eth = ChannelId::EthereumSepolia.explorer_url("0xabc"); - assert!(eth.contains("etherscan")); - assert!(eth.contains("0xabc")); - - let kas = ChannelId::KaspaTn10.explorer_url("deadbeef"); - assert!(kas.contains("kaspa")); - assert!(kas.contains("deadbeef")); - } } diff --git a/crates/protocol/src/clock/native.rs b/crates/protocol/src/clock/native.rs index 622eb5e..49f5fdc 100644 --- a/crates/protocol/src/clock/native.rs +++ b/crates/protocol/src/clock/native.rs @@ -1,6 +1,5 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; -/// Returns the current time in seconds since the UNIX epoch. pub fn now_unix_secs() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -8,7 +7,6 @@ pub fn now_unix_secs() -> u64 { .unwrap_or(0) } -/// Returns the current time in milliseconds since the UNIX epoch. pub fn now_millis() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -16,12 +14,21 @@ pub fn now_millis() -> u64 { .unwrap_or(0) } -/// Sleeps for the specified number of milliseconds. pub async fn sleep_ms(ms: u64) { tokio::time::sleep(Duration::from_millis(ms)).await; } -/// Sleeps for the specified number of seconds. pub async fn sleep_secs(s: u64) { tokio::time::sleep(Duration::from_secs(s)).await; } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn clock_returns_recent_epoch_times() { + assert!(now_unix_secs() > 1_600_000_000); + assert!(now_millis() > 1_600_000_000_000); + } +} diff --git a/crates/protocol/src/clock/wasm.rs b/crates/protocol/src/clock/wasm.rs index b1fae47..615f6a8 100644 --- a/crates/protocol/src/clock/wasm.rs +++ b/crates/protocol/src/clock/wasm.rs @@ -1,19 +1,25 @@ -/// Return the current time in seconds since the UNIX epoch. pub fn now_unix_secs() -> u64 { (js_sys::Date::now() / 1000.0) as u64 } -/// Return the current time in milliseconds since the UNIX epoch. pub fn now_millis() -> u64 { js_sys::Date::now() as u64 } -/// Sleep for the specified number of milliseconds. pub async fn sleep_ms(ms: u64) { gloo_timers::future::TimeoutFuture::new(ms as u32).await; } -/// Sleep for the specified number of seconds. pub async fn sleep_secs(s: u64) { gloo_timers::future::TimeoutFuture::new((s.saturating_mul(1000)) as u32).await; } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unix_secs_is_positive() { + assert!(now_unix_secs() > 0); + } +} diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index c1d03af..b202360 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -22,3 +22,28 @@ where { wasm_bindgen_futures::spawn_local(fut); } + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::time::Duration; + + #[tokio::test] + async fn spawn_runs_the_future() { + let flag = Arc::new(AtomicBool::new(false)); + let f = flag.clone(); + super::spawn(async move { + f.store(true, Ordering::SeqCst); + }); + let mut ran = false; + for _ in 0..200 { + if flag.load(Ordering::SeqCst) { + ran = true; + break; + } + tokio::time::sleep(Duration::from_millis(1)).await; + } + assert!(ran, "spawned future did not run"); + } +} diff --git a/crates/protocol/src/swap_tracker/commit.rs b/crates/protocol/src/swap_tracker/commit.rs new file mode 100644 index 0000000..9e573f2 --- /dev/null +++ b/crates/protocol/src/swap_tracker/commit.rs @@ -0,0 +1,104 @@ +use super::{Result, SwapRecord, SwapTracker, SwapTrackerError}; +use crate::clock::now_unix_secs; +use crate::v1::CommitmentV1; + +impl SwapTracker { + pub fn set_init_commitment( + &mut self, + swap_id: [u8; 32], + commitment: CommitmentV1, + ) -> Result<()> { + if self.swaps.contains_key(&swap_id) { + return Err(SwapTrackerError::DuplicateSwap(swap_id)); + } + let record = SwapRecord { + init_commitment: commitment, + counter_commitment: None, + resolution: None, + created_at: now_unix_secs(), + }; + self.swaps.insert(swap_id, record); + Ok(()) + } + + pub fn set_counter_commitment( + &mut self, + swap_id: [u8; 32], + commitment: CommitmentV1, + ) -> Result<()> { + let record = self + .swaps + .get_mut(&swap_id) + .ok_or(SwapTrackerError::SwapNotFound(swap_id))?; + if record.counter_commitment.is_some() { + return Err(SwapTrackerError::AlreadyCounterLocked(swap_id)); + } + if record.resolution.is_some() { + return Err(SwapTrackerError::AlreadyResolved(swap_id)); + } + let init = &record.init_commitment; + let fail = |reason: String| Err(SwapTrackerError::ValidationFailed { swap_id, reason }); + if init.swap_id != commitment.swap_id { + return fail("Commitment swap_id does not match InitLock".to_string()); + } + if init.addresses.sender_destination != commitment.addresses.receiver { + return fail( + "Commitment receiver does not match InitLock sender_destination".to_string(), + ); + } + if init.addresses.receiver != commitment.addresses.sender_destination { + return fail( + "Commitment sender_destination does not match InitLock receiver".to_string(), + ); + } + if init.secret_hash != commitment.secret_hash { + return fail("Commitment secret_hash does not match InitLock secret_hash".to_string()); + } + if init.source != commitment.destination || init.destination != commitment.source { + return fail(format!( + "source/destination mismatch: init(src={},dst={}) vs counter(src={},dst={})", + init.source, init.destination, commitment.source, commitment.destination, + )); + } + record.counter_commitment = Some(commitment); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + use crate::v1::{AddressesV1, AmountV1}; + + fn init(swap_id: [u8; 32]) -> CommitmentV1 { + CommitmentV1::new( + swap_id, + AddressesV1::new("a".into(), "b".into(), "c".into()), + AmountV1::new("1".into(), 8), + [9u8; 32], + 0, + 1, + 0, + ) + } + + #[test] + fn duplicate_init_is_rejected() { + let mut t = SwapTracker::new(); + t.set_init_commitment([1u8; 32], init([1u8; 32])).unwrap(); + assert!(matches!( + t.set_init_commitment([1u8; 32], init([1u8; 32])), + Err(SwapTrackerError::DuplicateSwap(_)) + )); + } + + #[test] + fn counter_on_missing_swap_errors() { + let mut t = SwapTracker::new(); + assert!(matches!( + t.set_counter_commitment([2u8; 32], init([2u8; 32])), + Err(SwapTrackerError::SwapNotFound(_)) + )); + } +} diff --git a/crates/protocol/src/swap_tracker/expiry.rs b/crates/protocol/src/swap_tracker/expiry.rs new file mode 100644 index 0000000..390c761 --- /dev/null +++ b/crates/protocol/src/swap_tracker/expiry.rs @@ -0,0 +1,89 @@ +use super::SwapTracker; +use crate::clock::now_unix_secs; + +impl SwapTracker { + pub fn is_expired(&self, swap_id: &[u8; 32]) -> bool { + if let Some(record) = self.swaps.get(swap_id) { + if record.resolution.is_some() { + return false; + } + let now = now_unix_secs(); + if let Some(counter) = &record.counter_commitment { + now >= counter.unlock_ts + } else { + now >= record.init_commitment.unlock_ts + } + } else { + false + } + } + + pub fn time_until_init_refund(&self, swap_id: &[u8; 32]) -> Option { + let record = self.swaps.get(swap_id)?; + if record.resolution.is_some() { + return None; + } + let now = now_unix_secs(); + Some(record.init_commitment.unlock_ts.saturating_sub(now)) + } + + pub fn time_until_ctpy_refund(&self, swap_id: &[u8; 32]) -> Option { + let record = self.swaps.get(swap_id)?; + if record.resolution.is_some() { + return None; + } + let counter = record.counter_commitment.as_ref()?; + let now = now_unix_secs(); + Some(counter.unlock_ts.saturating_sub(now)) + } + + pub fn cleanup_old_swaps(&mut self, max_age_secs: u64) { + let now = now_unix_secs(); + self.swaps.retain(|_key, record| { + if record.resolution.is_some() { + let age = now.saturating_sub(record.created_at); + age < max_age_secs + } else { + true + } + }); + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + use crate::v1::{AddressesV1, AmountV1, CommitmentV1}; + + fn tracker_with_init(swap_id: [u8; 32], unlock_ts: u64) -> SwapTracker { + let init = CommitmentV1::new( + swap_id, + AddressesV1::new("a".into(), "b".into(), "c".into()), + AmountV1::new("1".into(), 8), + [0u8; 32], + unlock_ts, + 1, + 0, + ); + let mut t = SwapTracker::new(); + t.set_init_commitment(swap_id, init).unwrap(); + t + } + + #[test] + fn expired_when_past_init_unlock() { + let t = tracker_with_init([1u8; 32], 0); + assert!(t.is_expired(&[1u8; 32])); + assert_eq!(t.time_until_init_refund(&[1u8; 32]), Some(0)); + assert_eq!(t.time_until_ctpy_refund(&[1u8; 32]), None); + assert!(!t.is_expired(&[9u8; 32])); + } + + #[test] + fn cleanup_retains_unresolved() { + let mut t = tracker_with_init([2u8; 32], 0); + t.cleanup_old_swaps(0); + assert!(t.get_swap(&[2u8; 32]).is_some()); + } +} diff --git a/crates/protocol/src/swap_tracker/mod.rs b/crates/protocol/src/swap_tracker/mod.rs index 442c0a9..39d4b93 100644 --- a/crates/protocol/src/swap_tracker/mod.rs +++ b/crates/protocol/src/swap_tracker/mod.rs @@ -1,44 +1,34 @@ +mod commit; mod error; +mod expiry; +mod resolve; mod result; -use crate::{clock::now_unix_secs, v1::CommitmentV1}; +use crate::v1::CommitmentV1; use ahash::AHashMap; pub use error::SwapTrackerError; use result::Result; use std::fmt::Display; #[derive(Debug, Clone, PartialEq, Eq)] -/// An internal record for an entire swap. -/// It is populated throughout the entire process of the swap. It contains the initial commitment -/// by some user, then a counter commitment and eventually a resolution which contains either the secret or a "refunded" string. -/// -/// As all fields except init_commitment are optional, a swap is valid from the moment init_commitment is set. -/// This is because a swap in itself can be refunded, which is considered a valid transition within the protocol. pub struct SwapRecord { - /// Initial commitment of the swap, containing all the details of the swap and the initial lock. pub init_commitment: CommitmentV1, - /// A counter commitment made by an LP who is taking the other side of the swap pub counter_commitment: Option, - /// The resolution of the swap, which can either be the secret used to redeem the funds or a "refunded" string in case of refunds. pub resolution: Option, - /// At what time the swap record was created. This is used for cleanup of old swaps after they are resolved. pub created_at: u64, } #[derive(Debug, Default)] -/// A tracker who maps swap id to swap records, allowing to keep track of -/// the state of each swap and its details throughout the entire process. pub struct SwapTracker { swaps: AHashMap<[u8; 32], SwapRecord>, } #[derive(Debug, Clone, PartialEq, Eq)] -/// An enum representing the different stages of a swap, used for easy tracking and display purposes. pub enum SwapStage { - Initialized, // the swap stage has been initialized with the initial commitment, but no counter commitment has been made yet - Locked, // a counter commitment has been made by an LP, locking the swap but it has not been resolved yet - Completed, // the swap has been completed with the secret revealed and the funds redeemed by the user - Refunded, // the swap has been refunded, either by the user before an LP took the other side, or by the LP after taking the other side but before the secret was revealed + Initialized, + Locked, + Completed, + Refunded, } impl Display for SwapStage { @@ -59,260 +49,675 @@ impl SwapTracker { } } - pub(crate) fn now() -> u64 { - now_unix_secs() + pub fn get_swap(&self, swap_id: &[u8; 32]) -> Option<&SwapRecord> { + self.swaps.get(swap_id) } - /// A valid initial commitment is set for some swap. The swap tracker only allows one - /// initial commitment per swap id. - pub fn set_init_commitment( - &mut self, - swap_id: [u8; 32], - commitment: CommitmentV1, - ) -> Result<()> { - // If a swap contains this key already we do not allow overwriting it - if self.swaps.contains_key(&swap_id) { - return Err(SwapTrackerError::DuplicateSwap(swap_id)); - } + pub fn all_swaps(&self) -> impl Iterator { + self.swaps.iter() + } - // Create a new swap record with the initial commitment and insert it into the tracker - let record = SwapRecord { - init_commitment: commitment, - counter_commitment: None, - resolution: None, - created_at: Self::now(), - }; - self.swaps.insert(swap_id, record); - Ok(()) - } - - /// A counter commitment is set for some swap. - /// This can only be done if an initial commitment already exists for the swap and if a counter commitment has not been set before. - pub fn set_counter_commitment( - &mut self, - swap_id: [u8; 32], - commitment: CommitmentV1, - ) -> Result<()> { - // Retrieve the initial commitment record for the swap, if it does not exist we cannot set a counter commitment - let record = self - .swaps - .get_mut(&swap_id) - .ok_or(SwapTrackerError::SwapNotFound(swap_id))?; - - // A counter commitment already exists, then we cannot set another one - if record.counter_commitment.is_some() { - return Err(SwapTrackerError::AlreadyCounterLocked(swap_id)); + pub fn stage(record: &SwapRecord) -> SwapStage { + match &record.resolution { + Some(r) if r == "refunded" => SwapStage::Refunded, + Some(_) => SwapStage::Completed, + None if record.counter_commitment.is_some() => SwapStage::Locked, + None => SwapStage::Initialized, } + } +} - // The swap has already been resolved, we cannot set a counter commitment on a resolved swap - if record.resolution.is_some() { - return Err(SwapTrackerError::AlreadyResolved(swap_id)); - } +#[cfg(test)] +mod tests { + #![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing + )] + use super::*; + use crate::channels::ChannelId; + use crate::clock::now_unix_secs; + use crate::v1::{AddressesV1, AmountV1}; + + const TEST_SECRET: [u8; 32] = [0xAB; 32]; + + fn test_secret_hash() -> [u8; 32] { + use sha2::{Digest, Sha256}; + let out = Sha256::digest(TEST_SECRET); + let mut a = [0u8; 32]; + a.copy_from_slice(&out); + a + } - // We retrieve the initial commitment to perform some validation against this claimed counter - // commitment to ensure it is consistent with protocol rules. - let init = &record.init_commitment; - - // If the initial commitment doesnt match the counter commitment, - // these swaps are inherently for different swaps. - if init.swap_id != commitment.swap_id { - return Err(SwapTrackerError::ValidationFailed { - swap_id, - reason: "Commitment swap_id does not match InitLock".to_string(), - }); + fn mock_init_commitment(unlock_ts: u64) -> CommitmentV1 { + CommitmentV1 { + swap_id: [0u8; 32], + addresses: AddressesV1::new( + "0xUserEthSender".to_string(), + "0xUserEthReceiver".to_string(), + "kaspa:user_dest_address".to_string(), + ), + amount: AmountV1::new("1000".to_string(), 18), + secret_hash: test_secret_hash(), + unlock_ts, + source: ChannelId::EthereumSepolia as u8, + destination: ChannelId::KaspaTn10 as u8, } + } - // The initial commitments sender destination must match the counter commitment receiver - // Because from the counter commitment the receiver should be the origins sender destination - if init.addresses.sender_destination != commitment.addresses.receiver { - return Err(SwapTrackerError::ValidationFailed { - swap_id, - reason: "Commitment receiver does not match InitLock sender_destination" - .to_string(), - }); + fn mock_counter_commitment(unlock_ts: u64) -> CommitmentV1 { + CommitmentV1 { + swap_id: [0u8; 32], + addresses: AddressesV1::new( + "kaspa:mm_kaspa_sender".to_string(), + "kaspa:user_dest_address".to_string(), + "0xUserEthReceiver".to_string(), + ), + amount: AmountV1::new("2000".to_string(), 18), + secret_hash: test_secret_hash(), + unlock_ts, + source: ChannelId::KaspaTn10 as u8, + destination: ChannelId::EthereumSepolia as u8, } + } - // Like above, the receiver of the initial commitment should be the - // sender destination of the counter commitment, otherwise these swaps are inconsistent with each other. - if init.addresses.receiver != commitment.addresses.sender_destination { - return Err(SwapTrackerError::ValidationFailed { - swap_id, - reason: "Commitment sender_destination does not match InitLock receiver" - .to_string(), - }); - } + fn create_tracker() -> SwapTracker { + SwapTracker::new() + } - // Both commitments' secret hash should be the same otherwise - // they would not be possible to unlock with the same secret and are thus inconsistent with each other. - if init.secret_hash != commitment.secret_hash { - return Err(SwapTrackerError::ValidationFailed { - swap_id, - reason: "Commitment secret_hash does not match InitLock secret_hash".to_string(), - }); - } + #[test] + fn test_init_creates_record() { + let mut t = create_tracker(); + let id = [1u8; 32]; + let now = now_unix_secs(); + let c = mock_init_commitment(now + 600); + t.set_init_commitment(id, c).unwrap(); + + let record = t.get_swap(&id).unwrap(); + assert_eq!(SwapTracker::stage(record), SwapStage::Initialized); + assert!(record.counter_commitment.is_none()); + assert!(record.resolution.is_none()); + } - // Both commitments should have a matching source destination pairing. - // The initial commiments source must be equal to the commitments destination - // and vice versa. - if init.source != commitment.destination || init.destination != commitment.source { - return Err(SwapTrackerError::ValidationFailed { - swap_id, - reason: format!( - "source/destination mismatch: init(src={},dst={}) vs counter(src={},dst={})", - init.source, init.destination, commitment.source, commitment.destination, - ), - }); - } + #[test] + fn test_duplicate_swap_fails() { + let mut t = create_tracker(); + let id = [1u8; 32]; + let now = now_unix_secs(); + t.set_init_commitment(id, mock_init_commitment(now + 600)) + .unwrap(); + let result = t.set_init_commitment(id, mock_init_commitment(now + 600)); + assert!(matches!(result, Err(SwapTrackerError::DuplicateSwap(_)))); + } - record.counter_commitment = Some(commitment); - Ok(()) + #[test] + fn test_counter_commitment_transitions_to_lock() { + let mut t = create_tracker(); + let id = [1u8; 32]; + let now = now_unix_secs(); + t.set_init_commitment(id, mock_init_commitment(now + 600)) + .unwrap(); + t.set_counter_commitment(id, mock_counter_commitment(now + 300)) + .unwrap(); + + let record = t.get_swap(&id).unwrap(); + assert_eq!(SwapTracker::stage(record), SwapStage::Locked); + assert!(record.counter_commitment.is_some()); } - /// A swap is marked as revealed with the secret used to redeem the funds. - /// This can only be done if the swap has been locked with a counter commitment and has not been resolved before. - pub fn set_revealed(&mut self, swap_id: [u8; 32], secret: [u8; 32]) -> Result<()> { - use sha2::{Digest, Sha256}; + #[test] + fn test_cannot_counter_without_init() { + let mut t = create_tracker(); + let id = [1u8; 32]; + let now = now_unix_secs(); + let result = t.set_counter_commitment(id, mock_counter_commitment(now + 300)); + assert!(matches!(result, Err(SwapTrackerError::SwapNotFound(_)))); + } - // Retrieve the existing swap record - let record = self - .swaps - .get_mut(&swap_id) - .ok_or(SwapTrackerError::SwapNotFound(swap_id))?; - - // A swap can only be revealed if it has a counter commitment. - let counter = record - .counter_commitment - .as_ref() - .ok_or(SwapTrackerError::NotCounterLocked(swap_id))?; - - // A swap can only be revealed if it has not been resolved before. - if record.resolution.is_some() { - return Err(SwapTrackerError::AlreadyResolved(swap_id)); - } + #[test] + fn test_cannot_counter_twice() { + let mut t = create_tracker(); + let id = [1u8; 32]; + let now = now_unix_secs(); + t.set_init_commitment(id, mock_init_commitment(now + 600)) + .unwrap(); + t.set_counter_commitment(id, mock_counter_commitment(now + 300)) + .unwrap(); + let result = t.set_counter_commitment(id, mock_counter_commitment(now + 300)); + assert!(matches!( + result, + Err(SwapTrackerError::AlreadyCounterLocked(_)) + )); + } - // Lets compute the hash of the claimed secret - let mut hasher = Sha256::new(); - hasher.update(secret); - let digest = hasher.finalize(); + #[test] + fn test_reveal_transitions_from_lock() { + let mut t = create_tracker(); + let id = [1u8; 32]; + let now = now_unix_secs(); + t.set_init_commitment(id, mock_init_commitment(now + 600)) + .unwrap(); + t.set_counter_commitment(id, mock_counter_commitment(now + 300)) + .unwrap(); + t.set_revealed(id, [0xAB; 32]).unwrap(); + + let record = t.get_swap(&id).unwrap(); + assert_eq!(SwapTracker::stage(record), SwapStage::Completed); + assert_eq!( + record.resolution.as_deref(), + Some(&hex::encode([0xAB; 32])[..]) + ); + } - // The hash must match what was originally set in the counter commitment - // and because we checked counter hash == init hash, this also validates that. - if digest.as_slice() != counter.secret_hash.as_slice() { - return Err(SwapTrackerError::SecretHashMismatch { swap_id }); - } + #[test] + fn test_cannot_reveal_from_init() { + let mut t = create_tracker(); + let id = [1u8; 32]; + let now = now_unix_secs(); + t.set_init_commitment(id, mock_init_commitment(now + 600)) + .unwrap(); + let result = t.set_revealed(id, [0xAB; 32]); + assert!(matches!(result, Err(SwapTrackerError::NotCounterLocked(_)))); + } - // All validations passed, swap is revealed. - record.resolution = Some(hex::encode(secret)); - Ok(()) + #[test] + fn test_cannot_reveal_twice() { + let mut t = create_tracker(); + let id = [1u8; 32]; + let now = now_unix_secs(); + t.set_init_commitment(id, mock_init_commitment(now + 600)) + .unwrap(); + t.set_counter_commitment(id, mock_counter_commitment(now + 300)) + .unwrap(); + t.set_revealed(id, [0xAB; 32]).unwrap(); + let result = t.set_revealed(id, [0xCD; 32]); + assert!(matches!(result, Err(SwapTrackerError::AlreadyResolved(_)))); } - /// Marks a swap as refunded. - pub fn set_refunded(&mut self, swap_id: [u8; 32]) -> Result<()> { - // Retrieve the existing swap record - let record = self - .swaps - .get_mut(&swap_id) - .ok_or(SwapTrackerError::SwapNotFound(swap_id))?; + #[test] + fn test_refund_transitions_from_lock() { + let mut t = create_tracker(); + let id = [1u8; 32]; + let now = now_unix_secs(); + t.set_init_commitment(id, mock_init_commitment(now + 600)) + .unwrap(); + t.set_counter_commitment(id, mock_counter_commitment(now + 300)) + .unwrap(); + t.set_refunded(id).unwrap(); + + let record = t.get_swap(&id).unwrap(); + assert_eq!(SwapTracker::stage(record), SwapStage::Refunded); + assert_eq!(record.resolution.as_deref(), Some("refunded")); + } - // A swap can only be refunded if it has not been resolved before. - // This includes both revealed and already refunded swaps. - if record.counter_commitment.is_none() { - return Err(SwapTrackerError::NotCounterLocked(swap_id)); - } + #[test] + fn test_cannot_refund_from_init() { + let mut t = create_tracker(); + let id = [1u8; 32]; + let now = now_unix_secs(); + t.set_init_commitment(id, mock_init_commitment(now + 600)) + .unwrap(); + let result = t.set_refunded(id); + assert!(matches!(result, Err(SwapTrackerError::NotCounterLocked(_)))); + } - // A swap can only be refunded if it has not been resolved before. - // This includes both revealed and already refunded swaps. - if record.resolution.is_some() { - return Err(SwapTrackerError::AlreadyResolved(swap_id)); - } + #[test] + fn test_cannot_refund_after_reveal() { + let mut t = create_tracker(); + let id = [1u8; 32]; + let now = now_unix_secs(); + t.set_init_commitment(id, mock_init_commitment(now + 600)) + .unwrap(); + t.set_counter_commitment(id, mock_counter_commitment(now + 300)) + .unwrap(); + t.set_revealed(id, [0xAB; 32]).unwrap(); + let result = t.set_refunded(id); + assert!(matches!(result, Err(SwapTrackerError::AlreadyResolved(_)))); + } - // All validations passed, swap is refunded. - record.resolution = Some("refunded".to_string()); - Ok(()) + #[test] + fn test_data_preserved_in_reveal() { + let mut t = create_tracker(); + let id = [1u8; 32]; + let now = now_unix_secs(); + t.set_init_commitment(id, mock_init_commitment(now + 600)) + .unwrap(); + t.set_counter_commitment(id, mock_counter_commitment(now + 300)) + .unwrap(); + t.set_revealed(id, [0xAB; 32]).unwrap(); + + let record = t.get_swap(&id).unwrap(); + assert_eq!(record.init_commitment.swap_id, [0u8; 32]); + assert_eq!( + record.counter_commitment.as_ref().unwrap().swap_id, + [0u8; 32] + ); + assert_eq!( + record.resolution.as_deref(), + Some(&hex::encode([0xAB; 32])[..]) + ); } - /// Retrieve a swap from the tracker - pub fn get_swap(&self, swap_id: &[u8; 32]) -> Option<&SwapRecord> { - self.swaps.get(swap_id) + #[test] + fn test_data_preserved_in_refund() { + let mut t = create_tracker(); + let id = [1u8; 32]; + let now = now_unix_secs(); + t.set_init_commitment(id, mock_init_commitment(now + 600)) + .unwrap(); + t.set_counter_commitment(id, mock_counter_commitment(now + 300)) + .unwrap(); + t.set_refunded(id).unwrap(); + + let record = t.get_swap(&id).unwrap(); + assert_eq!(record.init_commitment.swap_id, [0u8; 32]); + assert_eq!( + record.counter_commitment.as_ref().unwrap().swap_id, + [0u8; 32] + ); + assert_eq!(record.resolution.as_deref(), Some("refunded")); } - /// Retrieve all swaps from the tracker as an iterator of swap id and swap record pairs. - pub fn all_swaps(&self) -> impl Iterator { - self.swaps.iter() + #[test] + fn test_is_expired_for_init() { + let mut t = create_tracker(); + let id = [1u8; 32]; + let now = now_unix_secs(); + t.set_init_commitment(id, mock_init_commitment(now - 100)) + .unwrap(); + assert!(t.is_expired(&id)); } - /// Extract the current stage of a swap based on its record. - /// This is determined by the presence of the counter commitment and resolution. - pub fn stage(record: &SwapRecord) -> SwapStage { - match &record.resolution { - Some(r) if r == "refunded" => SwapStage::Refunded, - Some(_) => SwapStage::Completed, - None if record.counter_commitment.is_some() => SwapStage::Locked, - None => SwapStage::Initialized, + #[test] + fn test_is_not_expired_for_future_unlock() { + let mut t = create_tracker(); + let id = [1u8; 32]; + let now = now_unix_secs(); + t.set_init_commitment(id, mock_init_commitment(now + 600)) + .unwrap(); + assert!(!t.is_expired(&id)); + } + + #[test] + fn test_is_expired_checks_counter_unlock_ts() { + let mut t = create_tracker(); + let id = [1u8; 32]; + let now = now_unix_secs(); + t.set_init_commitment(id, mock_init_commitment(now + 600)) + .unwrap(); + t.set_counter_commitment(id, mock_counter_commitment(now - 50)) + .unwrap(); + assert!(t.is_expired(&id)); + } + + #[test] + fn test_time_until_init_refund() { + let mut t = create_tracker(); + let id = [1u8; 32]; + let now = now_unix_secs(); + t.set_init_commitment(id, mock_init_commitment(now + 300)) + .unwrap(); + t.set_counter_commitment(id, mock_counter_commitment(now + 150)) + .unwrap(); + let time_left = t.time_until_init_refund(&id).unwrap(); + assert!((299..=300).contains(&time_left)); + } + + #[test] + fn test_time_until_ctpy_refund() { + let mut t = create_tracker(); + let id = [1u8; 32]; + let now = now_unix_secs(); + t.set_init_commitment(id, mock_init_commitment(now + 600)) + .unwrap(); + t.set_counter_commitment(id, mock_counter_commitment(now + 150)) + .unwrap(); + let time_left = t.time_until_ctpy_refund(&id).unwrap(); + assert!((149..=150).contains(&time_left)); + } + + #[test] + fn test_time_queries_return_none_for_reveal() { + let mut t = create_tracker(); + let id = [1u8; 32]; + let now = now_unix_secs(); + t.set_init_commitment(id, mock_init_commitment(now + 600)) + .unwrap(); + t.set_counter_commitment(id, mock_counter_commitment(now + 300)) + .unwrap(); + t.set_revealed(id, [0xAB; 32]).unwrap(); + assert!(t.time_until_init_refund(&id).is_none()); + assert!(t.time_until_ctpy_refund(&id).is_none()); + } + + #[test] + fn test_time_queries_return_none_for_refund() { + let mut t = create_tracker(); + let id = [1u8; 32]; + let now = now_unix_secs(); + t.set_init_commitment(id, mock_init_commitment(now + 600)) + .unwrap(); + t.set_counter_commitment(id, mock_counter_commitment(now + 300)) + .unwrap(); + t.set_refunded(id).unwrap(); + assert!(t.time_until_init_refund(&id).is_none()); + assert!(t.time_until_ctpy_refund(&id).is_none()); + } + + #[test] + fn test_cleanup_removes_old_completed_swaps() { + let mut t = create_tracker(); + let now = now_unix_secs(); + + let id1 = [1u8; 32]; + t.set_init_commitment(id1, mock_init_commitment(now + 600)) + .unwrap(); + t.set_counter_commitment(id1, mock_counter_commitment(now + 300)) + .unwrap(); + t.set_revealed(id1, [0xAB; 32]).unwrap(); + + let id2 = [2u8; 32]; + t.set_init_commitment(id2, mock_init_commitment(now + 600)) + .unwrap(); + + t.cleanup_old_swaps(300); + assert!(t.get_swap(&id1).is_some()); + assert!(t.get_swap(&id2).is_some()); + + t.cleanup_old_swaps(0); + assert!(t.get_swap(&id1).is_none()); + assert!(t.get_swap(&id2).is_some()); + } + + #[test] + fn test_multiple_swaps_independent() { + let mut t = create_tracker(); + let now = now_unix_secs(); + + let id1 = [1u8; 32]; + let id2 = [2u8; 32]; + t.set_init_commitment(id1, mock_init_commitment(now + 600)) + .unwrap(); + t.set_init_commitment(id2, mock_init_commitment(now + 600)) + .unwrap(); + + assert!(t.get_swap(&id1).is_some()); + assert!(t.get_swap(&id2).is_some()); + + t.set_counter_commitment(id1, mock_counter_commitment(now + 300)) + .unwrap(); + + assert_eq!( + SwapTracker::stage(t.get_swap(&id1).unwrap()), + SwapStage::Locked + ); + assert_eq!( + SwapTracker::stage(t.get_swap(&id2).unwrap()), + SwapStage::Initialized + ); + } + + #[test] + fn test_cannot_hijack_with_wrong_receiver() { + let mut t = create_tracker(); + let id = [1u8; 32]; + let now = now_unix_secs(); + t.set_init_commitment(id, mock_init_commitment(now + 600)) + .unwrap(); + + let malicious = CommitmentV1 { + swap_id: [0u8; 32], + addresses: AddressesV1::new( + "kaspa:mm_kaspa_sender".to_string(), + "kaspa:WRONG_receiver".to_string(), + "kaspa:user_dest_address".to_string(), + ), + amount: AmountV1::new("2000".to_string(), 18), + secret_hash: [0xCC; 32], + unlock_ts: now + 300, + source: ChannelId::KaspaTn10 as u8, + destination: ChannelId::EthereumSepolia as u8, + }; + + let result = t.set_counter_commitment(id, malicious); + assert!(result.is_err()); + if let Err(SwapTrackerError::ValidationFailed { reason, .. }) = result { + assert!(reason.contains("receiver does not match")); } + assert_eq!( + SwapTracker::stage(t.get_swap(&id).unwrap()), + SwapStage::Initialized + ); } - /// Compute whether a swap is expired based on the current time and the unlock time of its commitments. - pub fn is_expired(&self, swap_id: &[u8; 32]) -> bool { - // A swap is considered expired if the current time - // is past the unlock time of its latest commitment - // (counter commitment if it exists, otherwise initial commitment) and it has not been resolved yet. - if let Some(record) = self.swaps.get(swap_id) { - // If there is a resolution it means the swap has already been completed or refunded, so it cannot be expired. - if record.resolution.is_some() { - return false; - } - let now = Self::now(); - // We check the counter commitment unlock time if it exists, otherwise we check the initial commitment unlock time. - if let Some(counter) = &record.counter_commitment { - // By definition the counter commitment, is shorter which means that we should check against that - // since if the counter is expired, the swap is expired regardless of the initial commitment unlock time. - now >= counter.unlock_ts - } else { - // If the LP never locked, then the user can refund after the initial commitment unlock time, so we check against that. - now >= record.init_commitment.unlock_ts - } - } else { - false + #[test] + fn test_cannot_hijack_with_wrong_sender_destination() { + let mut t = create_tracker(); + let id = [1u8; 32]; + let now = now_unix_secs(); + t.set_init_commitment(id, mock_init_commitment(now + 600)) + .unwrap(); + + let malicious = CommitmentV1 { + swap_id: [0u8; 32], + addresses: AddressesV1::new( + "kaspa:mm_kaspa_sender".to_string(), + "kaspa:user_dest_address".to_string(), + "0xWRONG_sender_dest".to_string(), + ), + amount: AmountV1::new("2000".to_string(), 18), + secret_hash: [0xCC; 32], + unlock_ts: now + 300, + source: ChannelId::KaspaTn10 as u8, + destination: ChannelId::EthereumSepolia as u8, + }; + + let result = t.set_counter_commitment(id, malicious); + assert!(result.is_err()); + if let Err(SwapTrackerError::ValidationFailed { reason, .. }) = result { + assert!(reason.contains("sender_destination does not match")); } + assert_eq!( + SwapTracker::stage(t.get_swap(&id).unwrap()), + SwapStage::Initialized + ); } - /// Compute the how many seconds are left until a swap can be refunded by the - /// user - pub fn time_until_init_refund(&self, swap_id: &[u8; 32]) -> Option { - let record = self.swaps.get(swap_id)?; - // If there is a resolution it means the swap has already been completed or refunded, so there is no refund time. - if record.resolution.is_some() { - return None; + #[test] + fn test_cannot_hijack_with_both_addresses_swapped() { + let mut t = create_tracker(); + let id = [1u8; 32]; + let now = now_unix_secs(); + t.set_init_commitment(id, mock_init_commitment(now + 600)) + .unwrap(); + + let malicious = CommitmentV1 { + swap_id: [0u8; 32], + addresses: AddressesV1::new( + "kaspa:mm_kaspa_sender".to_string(), + "0xUserEthReceiver".to_string(), + "kaspa:user_dest_address".to_string(), + ), + amount: AmountV1::new("2000".to_string(), 18), + secret_hash: [0xCC; 32], + unlock_ts: now + 300, + source: ChannelId::KaspaTn10 as u8, + destination: ChannelId::EthereumSepolia as u8, + }; + + let result = t.set_counter_commitment(id, malicious); + assert!(result.is_err()); + assert_eq!( + SwapTracker::stage(t.get_swap(&id).unwrap()), + SwapStage::Initialized + ); + } + + #[test] + fn test_cannot_hijack_with_different_secret_hash() { + let mut t = create_tracker(); + let id = [1u8; 32]; + let now = now_unix_secs(); + t.set_init_commitment(id, mock_init_commitment(now + 600)) + .unwrap(); + + let malicious = CommitmentV1 { + swap_id: [0u8; 32], + addresses: AddressesV1::new( + "kaspa:mm_kaspa_sender".to_string(), + "kaspa:user_dest_address".to_string(), + "0xUserEthReceiver".to_string(), + ), + amount: AmountV1::new("2000".to_string(), 18), + secret_hash: [0xDD; 32], + unlock_ts: now + 300, + source: ChannelId::KaspaTn10 as u8, + destination: ChannelId::EthereumSepolia as u8, + }; + + let result = t.set_counter_commitment(id, malicious); + assert!(result.is_err()); + if let Err(SwapTrackerError::ValidationFailed { reason, .. }) = result { + assert!(reason.contains("secret_hash")); } - let now = Self::now(); - Some(record.init_commitment.unlock_ts.saturating_sub(now)) + assert_eq!( + SwapTracker::stage(t.get_swap(&id).unwrap()), + SwapStage::Initialized + ); } - /// Compute how many seconds are left until a swap can be refunded by the LP. - pub fn time_until_ctpy_refund(&self, swap_id: &[u8; 32]) -> Option { - let record = self.swaps.get(swap_id)?; - if record.resolution.is_some() || record.counter_commitment.is_none() { - return None; + #[test] + fn test_cannot_hijack_with_different_swap_id() { + let mut t = create_tracker(); + let id = [1u8; 32]; + let now = now_unix_secs(); + t.set_init_commitment(id, mock_init_commitment(now + 600)) + .unwrap(); + + let malicious = CommitmentV1 { + swap_id: [1u8; 32], + addresses: AddressesV1::new( + "kaspa:mm_kaspa_sender".to_string(), + "kaspa:user_dest_address".to_string(), + "0xUserEthReceiver".to_string(), + ), + amount: AmountV1::new("2000".to_string(), 18), + secret_hash: [0xCC; 32], + unlock_ts: now + 300, + source: ChannelId::KaspaTn10 as u8, + destination: ChannelId::EthereumSepolia as u8, + }; + + let result = t.set_counter_commitment(id, malicious); + assert!(result.is_err()); + if let Err(SwapTrackerError::ValidationFailed { reason, .. }) = result { + assert!(reason.contains("swap_id")); } - let now = Self::now(); - let counter = record.counter_commitment.as_ref().unwrap(); - Some(counter.unlock_ts.saturating_sub(now)) - } - - /// Cleanup old swaps that have been resolved for a long time to prevent the tracker from growing indefinitely. - pub fn cleanup_old_swaps(&mut self, max_age_secs: u64) { - let now = Self::now(); - self.swaps.retain(|_key, record| { - if record.resolution.is_some() { - let age = now.saturating_sub(record.created_at); - age < max_age_secs - } else { - true - } - }); + assert_eq!( + SwapTracker::stage(t.get_swap(&id).unwrap()), + SwapStage::Initialized + ); } -} -#[cfg(test)] -mod tests; + #[test] + fn test_cannot_lock_with_wrong_source_destination_mirror() { + let mut t = create_tracker(); + let id = [1u8; 32]; + let now = now_unix_secs(); + t.set_init_commitment(id, mock_init_commitment(now + 600)) + .unwrap(); + + let bad_mirror = CommitmentV1 { + swap_id: [0u8; 32], + addresses: AddressesV1::new( + "kaspa:mm_kaspa_sender".to_string(), + "kaspa:user_dest_address".to_string(), + "0xUserEthReceiver".to_string(), + ), + amount: AmountV1::new("2000".to_string(), 18), + secret_hash: test_secret_hash(), + unlock_ts: now + 300, + source: ChannelId::KaspaTn10 as u8, + destination: ChannelId::KaspaTn10 as u8, + }; + + let result = t.set_counter_commitment(id, bad_mirror); + assert!(result.is_err()); + if let Err(SwapTrackerError::ValidationFailed { reason, .. }) = result { + assert!(reason.contains("source/destination mismatch")); + } + assert_eq!( + SwapTracker::stage(t.get_swap(&id).unwrap()), + SwapStage::Initialized + ); + } + + #[test] + fn test_valid_counter_succeeds_after_failed_attacks() { + let mut t = create_tracker(); + let id = [1u8; 32]; + let now = now_unix_secs(); + t.set_init_commitment(id, mock_init_commitment(now + 600)) + .unwrap(); + + let _ = t.set_counter_commitment( + id, + CommitmentV1 { + swap_id: [0u8; 32], + addresses: AddressesV1::new( + "kaspa:mm_kaspa_sender".to_string(), + "kaspa:WRONG_receiver".to_string(), + "0xUserEthReceiver".to_string(), + ), + amount: AmountV1::new("2000".to_string(), 18), + secret_hash: [0xCC; 32], + unlock_ts: now + 300, + source: ChannelId::KaspaTn10 as u8, + destination: ChannelId::EthereumSepolia as u8, + }, + ); + + let valid = mock_counter_commitment(now + 300); + assert!(t.set_counter_commitment(id, valid).is_ok()); + } + + #[test] + fn test_all_swaps_returns_all() { + let mut t = create_tracker(); + let now = now_unix_secs(); + t.set_init_commitment([1u8; 32], mock_init_commitment(now + 600)) + .unwrap(); + t.set_init_commitment([2u8; 32], mock_init_commitment(now + 600)) + .unwrap(); + let all: Vec<_> = t.all_swaps().collect(); + assert_eq!(all.len(), 2); + } + + #[test] + fn test_get_swap_returns_none_for_missing() { + let t = create_tracker(); + assert!(t.get_swap(&[99u8; 32]).is_none()); + } + + #[test] + fn test_set_revealed_rejects_wrong_preimage() { + let mut t = create_tracker(); + let id = [1u8; 32]; + let now = now_unix_secs(); + t.set_init_commitment(id, mock_init_commitment(now + 600)) + .unwrap(); + t.set_counter_commitment(id, mock_counter_commitment(now + 300)) + .unwrap(); + + let result = t.set_revealed(id, [0x99; 32]); + assert!(matches!( + result, + Err(SwapTrackerError::SecretHashMismatch { .. }) + )); + + t.set_revealed(id, TEST_SECRET).unwrap(); + let record = t.get_swap(&id).unwrap(); + assert_eq!(SwapTracker::stage(record), SwapStage::Completed); + } +} diff --git a/crates/protocol/src/swap_tracker/resolve.rs b/crates/protocol/src/swap_tracker/resolve.rs new file mode 100644 index 0000000..eb2ff98 --- /dev/null +++ b/crates/protocol/src/swap_tracker/resolve.rs @@ -0,0 +1,108 @@ +use super::{Result, SwapRecord, SwapTracker, SwapTrackerError}; + +impl SwapTracker { + fn locked_unresolved_mut(&mut self, swap_id: [u8; 32]) -> Result<&mut SwapRecord> { + let record = self + .swaps + .get_mut(&swap_id) + .ok_or(SwapTrackerError::SwapNotFound(swap_id))?; + if record.counter_commitment.is_none() { + return Err(SwapTrackerError::NotCounterLocked(swap_id)); + } + if record.resolution.is_some() { + return Err(SwapTrackerError::AlreadyResolved(swap_id)); + } + Ok(record) + } + + pub fn set_revealed(&mut self, swap_id: [u8; 32], secret: [u8; 32]) -> Result<()> { + use sha2::{Digest, Sha256}; + + let record = self.locked_unresolved_mut(swap_id)?; + let counter = record + .counter_commitment + .as_ref() + .ok_or(SwapTrackerError::NotCounterLocked(swap_id))?; + + let mut hasher = Sha256::new(); + hasher.update(secret); + let digest = hasher.finalize(); + + if digest.as_slice() != counter.secret_hash.as_slice() { + return Err(SwapTrackerError::SecretHashMismatch { swap_id }); + } + + record.resolution = Some(hex::encode(secret)); + Ok(()) + } + + pub fn set_refunded(&mut self, swap_id: [u8; 32]) -> Result<()> { + let record = self.locked_unresolved_mut(swap_id)?; + record.resolution = Some("refunded".to_string()); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + use crate::v1::{AddressesV1, AmountV1, CommitmentV1}; + + fn locked_tracker(swap_id: [u8; 32], secret_hash: [u8; 32]) -> SwapTracker { + let init = CommitmentV1::new( + swap_id, + AddressesV1::new("snd".into(), "rcv".into(), "sdst".into()), + AmountV1::new("1".into(), 8), + secret_hash, + 0, + 1, + 0, + ); + let counter = CommitmentV1::new( + swap_id, + AddressesV1::new("ksnd".into(), "sdst".into(), "rcv".into()), + AmountV1::new("1".into(), 8), + secret_hash, + 0, + 0, + 1, + ); + let mut t = SwapTracker::new(); + t.set_init_commitment(swap_id, init).unwrap(); + t.set_counter_commitment(swap_id, counter).unwrap(); + t + } + + #[test] + fn reveal_with_wrong_secret_rejected() { + use sha2::{Digest, Sha256}; + let secret = [4u8; 32]; + let hash: [u8; 32] = Sha256::digest(secret).into(); + let mut t = locked_tracker([1u8; 32], hash); + assert!(matches!( + t.set_revealed([1u8; 32], [9u8; 32]), + Err(SwapTrackerError::SecretHashMismatch { .. }) + )); + t.set_revealed([1u8; 32], secret).unwrap(); + } + + #[test] + fn refund_requires_counter_lock() { + let mut t = SwapTracker::new(); + let init = CommitmentV1::new( + [2u8; 32], + AddressesV1::new("a".into(), "b".into(), "c".into()), + AmountV1::new("1".into(), 8), + [0u8; 32], + 0, + 1, + 0, + ); + t.set_init_commitment([2u8; 32], init).unwrap(); + assert!(matches!( + t.set_refunded([2u8; 32]), + Err(SwapTrackerError::NotCounterLocked(_)) + )); + } +} diff --git a/crates/protocol/src/swap_tracker/tests.rs b/crates/protocol/src/swap_tracker/tests.rs deleted file mode 100644 index 4dc53e2..0000000 --- a/crates/protocol/src/swap_tracker/tests.rs +++ /dev/null @@ -1,644 +0,0 @@ -use super::*; -use crate::channels::ChannelId; -use crate::v1::{AddressesV1, AmountV1}; - -const TEST_SECRET: [u8; 32] = [0xAB; 32]; - -fn test_secret_hash() -> [u8; 32] { - use sha2::{Digest, Sha256}; - let out = Sha256::digest(TEST_SECRET); - let mut a = [0u8; 32]; - a.copy_from_slice(&out); - a -} - -fn mock_init_commitment(unlock_ts: u64) -> CommitmentV1 { - CommitmentV1 { - swap_id: [0u8; 32], - addresses: AddressesV1::new( - "0xUserEthSender".to_string(), - "0xUserEthReceiver".to_string(), - "kaspa:user_dest_address".to_string(), - ), - amount: AmountV1::new("1000".to_string(), 18), - secret_hash: test_secret_hash(), - unlock_ts, - source: ChannelId::EthereumSepolia as u8, - destination: ChannelId::KaspaTn10 as u8, - } -} - -fn mock_counter_commitment(unlock_ts: u64) -> CommitmentV1 { - CommitmentV1 { - swap_id: [0u8; 32], - addresses: AddressesV1::new( - "kaspa:mm_kaspa_sender".to_string(), - "kaspa:user_dest_address".to_string(), - "0xUserEthReceiver".to_string(), - ), - amount: AmountV1::new("2000".to_string(), 18), - secret_hash: test_secret_hash(), - unlock_ts, - source: ChannelId::KaspaTn10 as u8, - destination: ChannelId::EthereumSepolia as u8, - } -} - -fn create_tracker() -> SwapTracker { - SwapTracker::new() -} - -#[test] -fn test_init_creates_record() { - let mut t = create_tracker(); - let id = [1u8; 32]; - let now = SwapTracker::now(); - let c = mock_init_commitment(now + 600); - t.set_init_commitment(id, c).unwrap(); - - let record = t.get_swap(&id).unwrap(); - assert_eq!(SwapTracker::stage(record), SwapStage::Initialized); - assert!(record.counter_commitment.is_none()); - assert!(record.resolution.is_none()); -} - -#[test] -fn test_duplicate_swap_fails() { - let mut t = create_tracker(); - let id = [1u8; 32]; - let now = SwapTracker::now(); - t.set_init_commitment(id, mock_init_commitment(now + 600)) - .unwrap(); - let result = t.set_init_commitment(id, mock_init_commitment(now + 600)); - assert!(matches!(result, Err(SwapTrackerError::DuplicateSwap(_)))); -} - -#[test] -fn test_counter_commitment_transitions_to_lock() { - let mut t = create_tracker(); - let id = [1u8; 32]; - let now = SwapTracker::now(); - t.set_init_commitment(id, mock_init_commitment(now + 600)) - .unwrap(); - t.set_counter_commitment(id, mock_counter_commitment(now + 300)) - .unwrap(); - - let record = t.get_swap(&id).unwrap(); - assert_eq!(SwapTracker::stage(record), SwapStage::Locked); - assert!(record.counter_commitment.is_some()); -} - -#[test] -fn test_cannot_counter_without_init() { - let mut t = create_tracker(); - let id = [1u8; 32]; - let now = SwapTracker::now(); - let result = t.set_counter_commitment(id, mock_counter_commitment(now + 300)); - assert!(matches!(result, Err(SwapTrackerError::SwapNotFound(_)))); -} - -#[test] -fn test_cannot_counter_twice() { - let mut t = create_tracker(); - let id = [1u8; 32]; - let now = SwapTracker::now(); - t.set_init_commitment(id, mock_init_commitment(now + 600)) - .unwrap(); - t.set_counter_commitment(id, mock_counter_commitment(now + 300)) - .unwrap(); - let result = t.set_counter_commitment(id, mock_counter_commitment(now + 300)); - assert!(matches!( - result, - Err(SwapTrackerError::AlreadyCounterLocked(_)) - )); -} - -#[test] -fn test_reveal_transitions_from_lock() { - let mut t = create_tracker(); - let id = [1u8; 32]; - let now = SwapTracker::now(); - t.set_init_commitment(id, mock_init_commitment(now + 600)) - .unwrap(); - t.set_counter_commitment(id, mock_counter_commitment(now + 300)) - .unwrap(); - t.set_revealed(id, [0xAB; 32]).unwrap(); - - let record = t.get_swap(&id).unwrap(); - assert_eq!(SwapTracker::stage(record), SwapStage::Completed); - assert_eq!( - record.resolution.as_deref(), - Some(&hex::encode([0xAB; 32])[..]) - ); -} - -#[test] -fn test_cannot_reveal_from_init() { - let mut t = create_tracker(); - let id = [1u8; 32]; - let now = SwapTracker::now(); - t.set_init_commitment(id, mock_init_commitment(now + 600)) - .unwrap(); - let result = t.set_revealed(id, [0xAB; 32]); - assert!(matches!(result, Err(SwapTrackerError::NotCounterLocked(_)))); -} - -#[test] -fn test_cannot_reveal_twice() { - let mut t = create_tracker(); - let id = [1u8; 32]; - let now = SwapTracker::now(); - t.set_init_commitment(id, mock_init_commitment(now + 600)) - .unwrap(); - t.set_counter_commitment(id, mock_counter_commitment(now + 300)) - .unwrap(); - t.set_revealed(id, [0xAB; 32]).unwrap(); - let result = t.set_revealed(id, [0xCD; 32]); - assert!(matches!(result, Err(SwapTrackerError::AlreadyResolved(_)))); -} - -#[test] -fn test_refund_transitions_from_lock() { - let mut t = create_tracker(); - let id = [1u8; 32]; - let now = SwapTracker::now(); - t.set_init_commitment(id, mock_init_commitment(now + 600)) - .unwrap(); - t.set_counter_commitment(id, mock_counter_commitment(now + 300)) - .unwrap(); - t.set_refunded(id).unwrap(); - - let record = t.get_swap(&id).unwrap(); - assert_eq!(SwapTracker::stage(record), SwapStage::Refunded); - assert_eq!(record.resolution.as_deref(), Some("refunded")); -} - -#[test] -fn test_cannot_refund_from_init() { - let mut t = create_tracker(); - let id = [1u8; 32]; - let now = SwapTracker::now(); - t.set_init_commitment(id, mock_init_commitment(now + 600)) - .unwrap(); - let result = t.set_refunded(id); - assert!(matches!(result, Err(SwapTrackerError::NotCounterLocked(_)))); -} - -#[test] -fn test_cannot_refund_after_reveal() { - let mut t = create_tracker(); - let id = [1u8; 32]; - let now = SwapTracker::now(); - t.set_init_commitment(id, mock_init_commitment(now + 600)) - .unwrap(); - t.set_counter_commitment(id, mock_counter_commitment(now + 300)) - .unwrap(); - t.set_revealed(id, [0xAB; 32]).unwrap(); - let result = t.set_refunded(id); - assert!(matches!(result, Err(SwapTrackerError::AlreadyResolved(_)))); -} - -#[test] -fn test_data_preserved_in_reveal() { - let mut t = create_tracker(); - let id = [1u8; 32]; - let now = SwapTracker::now(); - t.set_init_commitment(id, mock_init_commitment(now + 600)) - .unwrap(); - t.set_counter_commitment(id, mock_counter_commitment(now + 300)) - .unwrap(); - t.set_revealed(id, [0xAB; 32]).unwrap(); - - let record = t.get_swap(&id).unwrap(); - assert_eq!(record.init_commitment.swap_id, [0u8; 32]); - assert_eq!( - record.counter_commitment.as_ref().unwrap().swap_id, - [0u8; 32] - ); - assert_eq!( - record.resolution.as_deref(), - Some(&hex::encode([0xAB; 32])[..]) - ); -} - -#[test] -fn test_data_preserved_in_refund() { - let mut t = create_tracker(); - let id = [1u8; 32]; - let now = SwapTracker::now(); - t.set_init_commitment(id, mock_init_commitment(now + 600)) - .unwrap(); - t.set_counter_commitment(id, mock_counter_commitment(now + 300)) - .unwrap(); - t.set_refunded(id).unwrap(); - - let record = t.get_swap(&id).unwrap(); - assert_eq!(record.init_commitment.swap_id, [0u8; 32]); - assert_eq!( - record.counter_commitment.as_ref().unwrap().swap_id, - [0u8; 32] - ); - assert_eq!(record.resolution.as_deref(), Some("refunded")); -} - -#[test] -fn test_is_expired_for_init() { - let mut t = create_tracker(); - let id = [1u8; 32]; - let now = SwapTracker::now(); - t.set_init_commitment(id, mock_init_commitment(now - 100)) - .unwrap(); - assert!(t.is_expired(&id)); -} - -#[test] -fn test_is_not_expired_for_future_unlock() { - let mut t = create_tracker(); - let id = [1u8; 32]; - let now = SwapTracker::now(); - t.set_init_commitment(id, mock_init_commitment(now + 600)) - .unwrap(); - assert!(!t.is_expired(&id)); -} - -#[test] -fn test_is_expired_checks_counter_unlock_ts() { - let mut t = create_tracker(); - let id = [1u8; 32]; - let now = SwapTracker::now(); - t.set_init_commitment(id, mock_init_commitment(now + 600)) - .unwrap(); - t.set_counter_commitment(id, mock_counter_commitment(now - 50)) - .unwrap(); - assert!(t.is_expired(&id)); -} - -#[test] -fn test_time_until_init_refund() { - let mut t = create_tracker(); - let id = [1u8; 32]; - let now = SwapTracker::now(); - t.set_init_commitment(id, mock_init_commitment(now + 300)) - .unwrap(); - t.set_counter_commitment(id, mock_counter_commitment(now + 150)) - .unwrap(); - let time_left = t.time_until_init_refund(&id).unwrap(); - assert!(time_left >= 299 && time_left <= 300); -} - -#[test] -fn test_time_until_ctpy_refund() { - let mut t = create_tracker(); - let id = [1u8; 32]; - let now = SwapTracker::now(); - t.set_init_commitment(id, mock_init_commitment(now + 600)) - .unwrap(); - t.set_counter_commitment(id, mock_counter_commitment(now + 150)) - .unwrap(); - let time_left = t.time_until_ctpy_refund(&id).unwrap(); - assert!(time_left >= 149 && time_left <= 150); -} - -#[test] -fn test_time_queries_return_none_for_reveal() { - let mut t = create_tracker(); - let id = [1u8; 32]; - let now = SwapTracker::now(); - t.set_init_commitment(id, mock_init_commitment(now + 600)) - .unwrap(); - t.set_counter_commitment(id, mock_counter_commitment(now + 300)) - .unwrap(); - t.set_revealed(id, [0xAB; 32]).unwrap(); - assert!(t.time_until_init_refund(&id).is_none()); - assert!(t.time_until_ctpy_refund(&id).is_none()); -} - -#[test] -fn test_time_queries_return_none_for_refund() { - let mut t = create_tracker(); - let id = [1u8; 32]; - let now = SwapTracker::now(); - t.set_init_commitment(id, mock_init_commitment(now + 600)) - .unwrap(); - t.set_counter_commitment(id, mock_counter_commitment(now + 300)) - .unwrap(); - t.set_refunded(id).unwrap(); - assert!(t.time_until_init_refund(&id).is_none()); - assert!(t.time_until_ctpy_refund(&id).is_none()); -} - -#[test] -fn test_cleanup_removes_old_completed_swaps() { - let mut t = create_tracker(); - let now = SwapTracker::now(); - - let id1 = [1u8; 32]; - t.set_init_commitment(id1, mock_init_commitment(now + 600)) - .unwrap(); - t.set_counter_commitment(id1, mock_counter_commitment(now + 300)) - .unwrap(); - t.set_revealed(id1, [0xAB; 32]).unwrap(); - - let id2 = [2u8; 32]; - t.set_init_commitment(id2, mock_init_commitment(now + 600)) - .unwrap(); - - t.cleanup_old_swaps(300); - assert!(t.get_swap(&id1).is_some()); - assert!(t.get_swap(&id2).is_some()); - - t.cleanup_old_swaps(0); - assert!(t.get_swap(&id1).is_none()); - assert!(t.get_swap(&id2).is_some()); -} - -#[test] -fn test_multiple_swaps_independent() { - let mut t = create_tracker(); - let now = SwapTracker::now(); - - let id1 = [1u8; 32]; - let id2 = [2u8; 32]; - t.set_init_commitment(id1, mock_init_commitment(now + 600)) - .unwrap(); - t.set_init_commitment(id2, mock_init_commitment(now + 600)) - .unwrap(); - - assert!(t.get_swap(&id1).is_some()); - assert!(t.get_swap(&id2).is_some()); - - t.set_counter_commitment(id1, mock_counter_commitment(now + 300)) - .unwrap(); - - assert_eq!( - SwapTracker::stage(t.get_swap(&id1).unwrap()), - SwapStage::Locked - ); - assert_eq!( - SwapTracker::stage(t.get_swap(&id2).unwrap()), - SwapStage::Initialized - ); -} - -#[test] -fn test_cannot_hijack_with_wrong_receiver() { - let mut t = create_tracker(); - let id = [1u8; 32]; - let now = SwapTracker::now(); - t.set_init_commitment(id, mock_init_commitment(now + 600)) - .unwrap(); - - let malicious = CommitmentV1 { - swap_id: [0u8; 32], - addresses: AddressesV1::new( - "kaspa:mm_kaspa_sender".to_string(), - "kaspa:WRONG_receiver".to_string(), - "kaspa:user_dest_address".to_string(), - ), - amount: AmountV1::new("2000".to_string(), 18), - secret_hash: [0xCC; 32], - unlock_ts: now + 300, - source: ChannelId::KaspaTn10 as u8, - destination: ChannelId::EthereumSepolia as u8, - }; - - let result = t.set_counter_commitment(id, malicious); - assert!(result.is_err()); - if let Err(SwapTrackerError::ValidationFailed { reason, .. }) = result { - assert!(reason.contains("receiver does not match")); - } - assert_eq!( - SwapTracker::stage(t.get_swap(&id).unwrap()), - SwapStage::Initialized - ); -} - -#[test] -fn test_cannot_hijack_with_wrong_sender_destination() { - let mut t = create_tracker(); - let id = [1u8; 32]; - let now = SwapTracker::now(); - t.set_init_commitment(id, mock_init_commitment(now + 600)) - .unwrap(); - - let malicious = CommitmentV1 { - swap_id: [0u8; 32], - addresses: AddressesV1::new( - "kaspa:mm_kaspa_sender".to_string(), - "kaspa:user_dest_address".to_string(), - "0xWRONG_sender_dest".to_string(), - ), - amount: AmountV1::new("2000".to_string(), 18), - secret_hash: [0xCC; 32], - unlock_ts: now + 300, - source: ChannelId::KaspaTn10 as u8, - destination: ChannelId::EthereumSepolia as u8, - }; - - let result = t.set_counter_commitment(id, malicious); - assert!(result.is_err()); - if let Err(SwapTrackerError::ValidationFailed { reason, .. }) = result { - assert!(reason.contains("sender_destination does not match")); - } - assert_eq!( - SwapTracker::stage(t.get_swap(&id).unwrap()), - SwapStage::Initialized - ); -} - -#[test] -fn test_cannot_hijack_with_both_addresses_swapped() { - let mut t = create_tracker(); - let id = [1u8; 32]; - let now = SwapTracker::now(); - t.set_init_commitment(id, mock_init_commitment(now + 600)) - .unwrap(); - - let malicious = CommitmentV1 { - swap_id: [0u8; 32], - addresses: AddressesV1::new( - "kaspa:mm_kaspa_sender".to_string(), - "0xUserEthReceiver".to_string(), - "kaspa:user_dest_address".to_string(), - ), - amount: AmountV1::new("2000".to_string(), 18), - secret_hash: [0xCC; 32], - unlock_ts: now + 300, - source: ChannelId::KaspaTn10 as u8, - destination: ChannelId::EthereumSepolia as u8, - }; - - let result = t.set_counter_commitment(id, malicious); - assert!(result.is_err()); - assert_eq!( - SwapTracker::stage(t.get_swap(&id).unwrap()), - SwapStage::Initialized - ); -} - -#[test] -fn test_cannot_hijack_with_different_secret_hash() { - let mut t = create_tracker(); - let id = [1u8; 32]; - let now = SwapTracker::now(); - t.set_init_commitment(id, mock_init_commitment(now + 600)) - .unwrap(); - - let malicious = CommitmentV1 { - swap_id: [0u8; 32], - addresses: AddressesV1::new( - "kaspa:mm_kaspa_sender".to_string(), - "kaspa:user_dest_address".to_string(), - "0xUserEthReceiver".to_string(), - ), - amount: AmountV1::new("2000".to_string(), 18), - secret_hash: [0xDD; 32], - unlock_ts: now + 300, - source: ChannelId::KaspaTn10 as u8, - destination: ChannelId::EthereumSepolia as u8, - }; - - let result = t.set_counter_commitment(id, malicious); - assert!(result.is_err()); - if let Err(SwapTrackerError::ValidationFailed { reason, .. }) = result { - assert!(reason.contains("secret_hash")); - } - assert_eq!( - SwapTracker::stage(t.get_swap(&id).unwrap()), - SwapStage::Initialized - ); -} - -#[test] -fn test_cannot_hijack_with_different_swap_id() { - let mut t = create_tracker(); - let id = [1u8; 32]; - let now = SwapTracker::now(); - t.set_init_commitment(id, mock_init_commitment(now + 600)) - .unwrap(); - - let malicious = CommitmentV1 { - swap_id: [1u8; 32], - addresses: AddressesV1::new( - "kaspa:mm_kaspa_sender".to_string(), - "kaspa:user_dest_address".to_string(), - "0xUserEthReceiver".to_string(), - ), - amount: AmountV1::new("2000".to_string(), 18), - secret_hash: [0xCC; 32], - unlock_ts: now + 300, - source: ChannelId::KaspaTn10 as u8, - destination: ChannelId::EthereumSepolia as u8, - }; - - let result = t.set_counter_commitment(id, malicious); - assert!(result.is_err()); - if let Err(SwapTrackerError::ValidationFailed { reason, .. }) = result { - assert!(reason.contains("swap_id")); - } - assert_eq!( - SwapTracker::stage(t.get_swap(&id).unwrap()), - SwapStage::Initialized - ); -} - -#[test] -fn test_cannot_lock_with_wrong_source_destination_mirror() { - let mut t = create_tracker(); - let id = [1u8; 32]; - let now = SwapTracker::now(); - t.set_init_commitment(id, mock_init_commitment(now + 600)) - .unwrap(); - - let bad_mirror = CommitmentV1 { - swap_id: [0u8; 32], - addresses: AddressesV1::new( - "kaspa:mm_kaspa_sender".to_string(), - "kaspa:user_dest_address".to_string(), - "0xUserEthReceiver".to_string(), - ), - amount: AmountV1::new("2000".to_string(), 18), - secret_hash: test_secret_hash(), - unlock_ts: now + 300, - source: ChannelId::KaspaTn10 as u8, - destination: ChannelId::KaspaTn10 as u8, - }; - - let result = t.set_counter_commitment(id, bad_mirror); - assert!(result.is_err()); - if let Err(SwapTrackerError::ValidationFailed { reason, .. }) = result { - assert!(reason.contains("source/destination mismatch")); - } - assert_eq!( - SwapTracker::stage(t.get_swap(&id).unwrap()), - SwapStage::Initialized - ); -} - -#[test] -fn test_valid_counter_succeeds_after_failed_attacks() { - let mut t = create_tracker(); - let id = [1u8; 32]; - let now = SwapTracker::now(); - t.set_init_commitment(id, mock_init_commitment(now + 600)) - .unwrap(); - - let _ = t.set_counter_commitment( - id, - CommitmentV1 { - swap_id: [0u8; 32], - addresses: AddressesV1::new( - "kaspa:mm_kaspa_sender".to_string(), - "kaspa:WRONG_receiver".to_string(), - "0xUserEthReceiver".to_string(), - ), - amount: AmountV1::new("2000".to_string(), 18), - secret_hash: [0xCC; 32], - unlock_ts: now + 300, - source: ChannelId::KaspaTn10 as u8, - destination: ChannelId::EthereumSepolia as u8, - }, - ); - - let valid = mock_counter_commitment(now + 300); - assert!(t.set_counter_commitment(id, valid).is_ok()); -} - -#[test] -fn test_all_swaps_returns_all() { - let mut t = create_tracker(); - let now = SwapTracker::now(); - t.set_init_commitment([1u8; 32], mock_init_commitment(now + 600)) - .unwrap(); - t.set_init_commitment([2u8; 32], mock_init_commitment(now + 600)) - .unwrap(); - let all: Vec<_> = t.all_swaps().collect(); - assert_eq!(all.len(), 2); -} - -#[test] -fn test_get_swap_returns_none_for_missing() { - let t = create_tracker(); - assert!(t.get_swap(&[99u8; 32]).is_none()); -} - -#[test] -fn test_set_revealed_rejects_wrong_preimage() { - let mut t = create_tracker(); - let id = [1u8; 32]; - let now = SwapTracker::now(); - t.set_init_commitment(id, mock_init_commitment(now + 600)) - .unwrap(); - t.set_counter_commitment(id, mock_counter_commitment(now + 300)) - .unwrap(); - - let result = t.set_revealed(id, [0x99; 32]); - assert!(matches!( - result, - Err(SwapTrackerError::SecretHashMismatch { .. }) - )); - - t.set_revealed(id, TEST_SECRET).unwrap(); - let record = t.get_swap(&id).unwrap(); - assert_eq!(SwapTracker::stage(record), SwapStage::Completed); -} diff --git a/crates/protocol/src/v1.rs b/crates/protocol/src/v1.rs deleted file mode 100644 index 4812096..0000000 --- a/crates/protocol/src/v1.rs +++ /dev/null @@ -1,297 +0,0 @@ -use borsh::{BorshDeserialize, BorshSerialize}; - -#[derive(Debug, Clone)] -/// An onchain event containing standardized stroemnet events. -pub enum ChainEvent { - Commitment(CommitmentV1), - Reveal(RevealV1), - Refund(RefundV1), -} - -#[derive( - Debug, - Clone, - PartialEq, - Eq, - BorshSerialize, - BorshDeserialize, - serde::Serialize, - serde::Deserialize, -)] -/// Standardized commitment event for v1 stroemnet swaps. -pub struct CommitmentV1 { - /// A unique identifier for the swap, generated by the client when initiating the swap - pub swap_id: [u8; 32], - /// Addresses involved in this swap - pub addresses: AddressesV1, - /// Amount being swapped encoded with decimals - pub amount: AmountV1, - /// The hash of the secret that allows for redemption of funds. - pub secret_hash: [u8; 32], - /// A timestamp in seconds since the UNIX epoch indicating when the swap can be unlocked by the recipient. - /// (Used for refunds) - pub unlock_ts: u64, - /// The channel from which the user is sending, i.e. the stroemnet channel id. - pub source: u8, - /// The channel to which the user is sending, i.e. the stroemnet channel id. - pub destination: u8, -} -#[derive( - Debug, - Clone, - PartialEq, - Eq, - BorshSerialize, - BorshDeserialize, - serde::Serialize, - serde::Deserialize, -)] -/// Standardized addresses involved in a v1 stroemnet swap. -pub struct AddressesV1 { - /// The address of the sender. - pub sender: String, - /// The address of the receiver. - pub receiver: String, - /// The address of the sender on the destination channel - pub sender_destination: String, -} - -impl AddressesV1 { - pub fn new(sender: String, receiver: String, sender_destination: String) -> Self { - Self { - sender, - receiver, - sender_destination, - } - } -} - -#[derive( - Debug, - Clone, - PartialEq, - Eq, - BorshSerialize, - BorshDeserialize, - serde::Serialize, - serde::Deserialize, -)] -/// Standardized amount for v1 stroemnet swaps, containing the value as a string and the number of decimals. -pub struct AmountV1 { - /// The amount value as a string, encoded with the appropriate number of decimals for the channel. - pub value: String, - /// The number of decimals used by the channel's native token, used to decode the value string into a human-readable format. - pub decimals: u8, -} - -impl AmountV1 { - pub fn new(value: String, decimals: u8) -> Self { - Self { value, decimals } - } - - pub fn at_least(&self, required: &AmountV1) -> bool { - if self.decimals != required.decimals { - return false; - } - match (self.value.parse::(), required.value.parse::()) { - (Ok(have), Ok(need)) => have >= need, - _ => false, - } - } -} - -impl CommitmentV1 { - pub fn new( - swap_id: [u8; 32], - addresses: AddressesV1, - amount: AmountV1, - secret_hash: [u8; 32], - unlock_ts: u64, - source: u8, - destination: u8, - ) -> Self { - Self { - swap_id, - addresses, - amount, - secret_hash, - unlock_ts, - source, - destination, - } - } -} - -#[derive( - Debug, - Clone, - PartialEq, - Eq, - BorshSerialize, - BorshDeserialize, - serde::Serialize, - serde::Deserialize, -)] -/// Standardized reveal event for v1 stroemnet swaps. -pub struct RevealV1 { - /// The unique identifier for the swap. - pub swap_id: [u8; 32], - /// The secret used to redeem the funds. - pub secret: [u8; 32], -} - -impl RevealV1 { - pub fn new(swap_id: [u8; 32], secret: [u8; 32]) -> Self { - Self { swap_id, secret } - } -} - -#[derive( - Debug, - Clone, - PartialEq, - Eq, - BorshSerialize, - BorshDeserialize, - serde::Serialize, - serde::Deserialize, -)] -/// Standardized refund event for v1 stroemnet swaps. -pub struct RefundV1 { - /// The unique identifier for the swap. - pub swap_id: [u8; 32], -} - -impl RefundV1 { - pub fn new(swap_id: [u8; 32]) -> Self { - Self { swap_id } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn amount_at_least_compares_value_with_matching_decimals() { - let need = AmountV1::new("1000000000000000000".into(), 18); - assert!(AmountV1::new("1000000000000000000".into(), 18).at_least(&need)); - assert!(AmountV1::new("2000000000000000000".into(), 18).at_least(&need)); - assert!(!AmountV1::new("1".into(), 18).at_least(&need)); - } - - #[test] - fn amount_at_least_rejects_decimals_mismatch_and_unparseable() { - let need = AmountV1::new("100".into(), 8); - assert!(!AmountV1::new("100".into(), 18).at_least(&need)); - assert!(!AmountV1::new("abc".into(), 8).at_least(&need)); - assert!(!AmountV1::new("100".into(), 8).at_least(&AmountV1::new("xyz".into(), 8))); - } - - fn sample_swap_id() -> [u8; 32] { - let mut id = [0u8; 32]; - for (i, byte) in id.iter_mut().enumerate() { - *byte = i as u8; - } - id - } - - fn sample_secret_hash() -> [u8; 32] { - let mut hash = [0u8; 32]; - for (i, byte) in hash.iter_mut().enumerate() { - *byte = (255 - i) as u8; - } - hash - } - - #[test] - fn test_commitment_new() { - let swap_id = sample_swap_id(); - let sender = "kaspa:sender_address".to_string(); - let sender_dest = "0xSenderDestAddr".to_string(); - let receiver = "kaspa:receiver_address".to_string(); - let amount = "50.12345678".to_string(); - let secret_hash = sample_secret_hash(); - let unlock_ts = 1700000000u64; - let source = 1u8; - let destination = 0u8; - let decimals = 8u8; - - let commitment = CommitmentV1::new( - swap_id, - AddressesV1::new(sender.clone(), receiver.clone(), sender_dest.clone()), - AmountV1::new(amount.clone(), decimals), - secret_hash, - unlock_ts, - source, - destination, - ); - - assert_eq!(commitment.swap_id, swap_id); - assert_eq!(commitment.addresses.sender, sender); - assert_eq!(commitment.addresses.sender_destination, sender_dest); - assert_eq!(commitment.addresses.receiver, receiver); - assert_eq!(commitment.amount.value, amount); - assert_eq!(commitment.amount.decimals, decimals); - assert_eq!(commitment.secret_hash, secret_hash); - assert_eq!(commitment.unlock_ts, unlock_ts); - assert_eq!(commitment.source, source); - assert_eq!(commitment.destination, destination); - } - - #[test] - fn test_commitment_borsh_roundtrip() { - let commitment = CommitmentV1::new( - sample_swap_id(), - AddressesV1::new( - "sender".to_string(), - "receiver".to_string(), - "sender_dest".to_string(), - ), - AmountV1::new("100.0".to_string(), 18), - sample_secret_hash(), - 1700000000, - 1, - 0, - ); - let bytes = borsh::to_vec(&commitment).unwrap(); - let decoded = CommitmentV1::try_from_slice(&bytes).unwrap(); - assert_eq!(decoded, commitment); - } - - #[test] - fn test_reveal_new() { - let swap_id = sample_swap_id(); - let secret = sample_secret_hash(); - - let reveal = RevealV1::new(swap_id, secret); - - assert_eq!(reveal.swap_id, swap_id); - assert_eq!(reveal.secret, secret); - } - - #[test] - fn test_reveal_borsh_roundtrip() { - let reveal = RevealV1::new(sample_swap_id(), sample_secret_hash()); - let bytes = borsh::to_vec(&reveal).unwrap(); - let decoded = RevealV1::try_from_slice(&bytes).unwrap(); - assert_eq!(decoded, reveal); - } - - #[test] - fn test_refund_new() { - let swap_id = sample_swap_id(); - - let refund = RefundV1::new(swap_id); - - assert_eq!(refund.swap_id, swap_id); - } - - #[test] - fn test_refund_borsh_roundtrip() { - let refund = RefundV1::new(sample_swap_id()); - let bytes = borsh::to_vec(&refund).unwrap(); - let decoded = RefundV1::try_from_slice(&bytes).unwrap(); - assert_eq!(decoded, refund); - } -} diff --git a/crates/protocol/src/v1/addresses.rs b/crates/protocol/src/v1/addresses.rs new file mode 100644 index 0000000..4d792d9 --- /dev/null +++ b/crates/protocol/src/v1/addresses.rs @@ -0,0 +1,40 @@ +use borsh::{BorshDeserialize, BorshSerialize}; + +#[derive( + Debug, + Clone, + PartialEq, + Eq, + BorshSerialize, + BorshDeserialize, + serde::Serialize, + serde::Deserialize, +)] +pub struct AddressesV1 { + pub sender: String, + pub receiver: String, + pub sender_destination: String, +} + +impl AddressesV1 { + pub fn new(sender: String, receiver: String, sender_destination: String) -> Self { + Self { + sender, + receiver, + sender_destination, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_sets_all_fields() { + let a = AddressesV1::new("s".into(), "r".into(), "d".into()); + assert_eq!(a.sender, "s"); + assert_eq!(a.receiver, "r"); + assert_eq!(a.sender_destination, "d"); + } +} diff --git a/crates/protocol/src/v1/amount.rs b/crates/protocol/src/v1/amount.rs new file mode 100644 index 0000000..034811c --- /dev/null +++ b/crates/protocol/src/v1/amount.rs @@ -0,0 +1,53 @@ +use borsh::{BorshDeserialize, BorshSerialize}; + +#[derive( + Debug, + Clone, + PartialEq, + Eq, + BorshSerialize, + BorshDeserialize, + serde::Serialize, + serde::Deserialize, +)] +pub struct AmountV1 { + pub value: String, + pub decimals: u8, +} + +impl AmountV1 { + pub fn new(value: String, decimals: u8) -> Self { + Self { value, decimals } + } + + pub fn at_least(&self, required: &AmountV1) -> bool { + if self.decimals != required.decimals { + return false; + } + match (self.value.parse::(), required.value.parse::()) { + (Ok(have), Ok(need)) => have >= need, + _ => false, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn at_least_compares_value_with_matching_decimals() { + let need = AmountV1::new("1000000000000000000".into(), 18); + assert!(AmountV1::new("1000000000000000000".into(), 18).at_least(&need)); + assert!(AmountV1::new("2000000000000000000".into(), 18).at_least(&need)); + assert!(!AmountV1::new("1".into(), 18).at_least(&need)); + } + + #[test] + fn at_least_rejects_decimals_mismatch_and_unparseable() { + let need = AmountV1::new("100".into(), 8); + assert!(!AmountV1::new("100".into(), 18).at_least(&need)); + assert!(!AmountV1::new("abc".into(), 8).at_least(&need)); + assert!(!AmountV1::new("100".into(), 8).at_least(&AmountV1::new("xyz".into(), 8))); + } +} diff --git a/crates/protocol/src/v1/commitment.rs b/crates/protocol/src/v1/commitment.rs new file mode 100644 index 0000000..7228481 --- /dev/null +++ b/crates/protocol/src/v1/commitment.rs @@ -0,0 +1,83 @@ +use borsh::{BorshDeserialize, BorshSerialize}; + +use super::addresses::AddressesV1; +use super::amount::AmountV1; + +#[derive( + Debug, + Clone, + PartialEq, + Eq, + BorshSerialize, + BorshDeserialize, + serde::Serialize, + serde::Deserialize, +)] +pub struct CommitmentV1 { + pub swap_id: [u8; 32], + pub addresses: AddressesV1, + pub amount: AmountV1, + pub secret_hash: [u8; 32], + pub unlock_ts: u64, + pub source: u8, + pub destination: u8, +} + +impl CommitmentV1 { + pub fn new( + swap_id: [u8; 32], + addresses: AddressesV1, + amount: AmountV1, + secret_hash: [u8; 32], + unlock_ts: u64, + source: u8, + destination: u8, + ) -> Self { + Self { + swap_id, + addresses, + amount, + secret_hash, + unlock_ts, + source, + destination, + } + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + + fn sample() -> CommitmentV1 { + CommitmentV1::new( + [1u8; 32], + AddressesV1::new("sender".into(), "receiver".into(), "sender_dest".into()), + AmountV1::new("100.0".into(), 18), + [2u8; 32], + 1700000000, + 1, + 0, + ) + } + + #[test] + fn new_sets_all_fields() { + let c = sample(); + assert_eq!(c.swap_id, [1u8; 32]); + assert_eq!(c.addresses.sender, "sender"); + assert_eq!(c.amount.value, "100.0"); + assert_eq!(c.secret_hash, [2u8; 32]); + assert_eq!(c.unlock_ts, 1700000000); + assert_eq!(c.source, 1); + assert_eq!(c.destination, 0); + } + + #[test] + fn borsh_roundtrip() { + let c = sample(); + let bytes = borsh::to_vec(&c).unwrap(); + assert_eq!(CommitmentV1::try_from_slice(&bytes).unwrap(), c); + } +} diff --git a/crates/protocol/src/v1/event.rs b/crates/protocol/src/v1/event.rs new file mode 100644 index 0000000..5dd4a9f --- /dev/null +++ b/crates/protocol/src/v1/event.rs @@ -0,0 +1,27 @@ +use super::commitment::CommitmentV1; +use super::refund::RefundV1; +use super::reveal::RevealV1; + +#[derive(Debug, Clone)] +pub enum ChainEvent { + Commitment(CommitmentV1), + Reveal(RevealV1), + Refund(RefundV1), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn variants_wrap_their_payloads() { + assert!(matches!( + ChainEvent::Reveal(RevealV1::new([0u8; 32], [0u8; 32])), + ChainEvent::Reveal(_) + )); + assert!(matches!( + ChainEvent::Refund(RefundV1::new([0u8; 32])), + ChainEvent::Refund(_) + )); + } +} diff --git a/crates/protocol/src/v1/mod.rs b/crates/protocol/src/v1/mod.rs new file mode 100644 index 0000000..03bb539 --- /dev/null +++ b/crates/protocol/src/v1/mod.rs @@ -0,0 +1,13 @@ +mod addresses; +mod amount; +mod commitment; +mod event; +mod refund; +mod reveal; + +pub use addresses::AddressesV1; +pub use amount::AmountV1; +pub use commitment::CommitmentV1; +pub use event::ChainEvent; +pub use refund::RefundV1; +pub use reveal::RevealV1; diff --git a/crates/protocol/src/v1/refund.rs b/crates/protocol/src/v1/refund.rs new file mode 100644 index 0000000..52440d6 --- /dev/null +++ b/crates/protocol/src/v1/refund.rs @@ -0,0 +1,39 @@ +use borsh::{BorshDeserialize, BorshSerialize}; + +#[derive( + Debug, + Clone, + PartialEq, + Eq, + BorshSerialize, + BorshDeserialize, + serde::Serialize, + serde::Deserialize, +)] +pub struct RefundV1 { + pub swap_id: [u8; 32], +} + +impl RefundV1 { + pub fn new(swap_id: [u8; 32]) -> Self { + Self { swap_id } + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + + #[test] + fn new_sets_fields() { + assert_eq!(RefundV1::new([5u8; 32]).swap_id, [5u8; 32]); + } + + #[test] + fn borsh_roundtrip() { + let r = RefundV1::new([5u8; 32]); + let bytes = borsh::to_vec(&r).unwrap(); + assert_eq!(RefundV1::try_from_slice(&bytes).unwrap(), r); + } +} diff --git a/crates/protocol/src/v1/reveal.rs b/crates/protocol/src/v1/reveal.rs new file mode 100644 index 0000000..5eeed7c --- /dev/null +++ b/crates/protocol/src/v1/reveal.rs @@ -0,0 +1,42 @@ +use borsh::{BorshDeserialize, BorshSerialize}; + +#[derive( + Debug, + Clone, + PartialEq, + Eq, + BorshSerialize, + BorshDeserialize, + serde::Serialize, + serde::Deserialize, +)] +pub struct RevealV1 { + pub swap_id: [u8; 32], + pub secret: [u8; 32], +} + +impl RevealV1 { + pub fn new(swap_id: [u8; 32], secret: [u8; 32]) -> Self { + Self { swap_id, secret } + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + + #[test] + fn new_sets_fields() { + let r = RevealV1::new([3u8; 32], [4u8; 32]); + assert_eq!(r.swap_id, [3u8; 32]); + assert_eq!(r.secret, [4u8; 32]); + } + + #[test] + fn borsh_roundtrip() { + let r = RevealV1::new([3u8; 32], [4u8; 32]); + let bytes = borsh::to_vec(&r).unwrap(); + assert_eq!(RevealV1::try_from_slice(&bytes).unwrap(), r); + } +} diff --git a/crates/storage/Cargo.toml b/crates/storage/Cargo.toml index 6c5c637..b6dfc74 100644 --- a/crates/storage/Cargo.toml +++ b/crates/storage/Cargo.toml @@ -8,6 +8,7 @@ license.workspace = true redb = { workspace = true } stroemnet-data = { path = "../data" } stroemnet-protocol = { path = "../protocol" } +hex = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } url = { workspace = true } @@ -15,3 +16,6 @@ url = { workspace = true } [dev-dependencies] tempfile = { workspace = true } tracing-subscriber = { workspace = true } + +[lints] +workspace = true diff --git a/crates/storage/src/cursors.rs b/crates/storage/src/cursors.rs index 6ef7814..f5933a3 100644 --- a/crates/storage/src/cursors.rs +++ b/crates/storage/src/cursors.rs @@ -6,11 +6,9 @@ use stroemnet_protocol::ChannelId; use crate::{PeerDb, Result}; -/// The table definition for storing cursors in the database. Each cursor is associated with a specific channel. pub(crate) const CURSORS: TableDefinition<&[u8], &[u8]> = TableDefinition::new("cursors"); impl PeerDb { - /// Retrieves the cursor for the specified channel from the database. pub fn get_cursor(&self, channel: ChannelId) -> Result>> { let read_txn = self.inner.begin_read()?; let table = read_txn.open_table(CURSORS)?; @@ -21,7 +19,6 @@ impl PeerDb { } } - /// Sets the cursor for the specified channel in the database. pub fn set_cursor(&self, channel: ChannelId, cursor: &[u8]) -> Result<()> { let write_txn = self.inner.begin_write()?; { @@ -58,6 +55,12 @@ impl CursorStore for DbCursorStore { #[cfg(test)] mod tests { + #![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing + )] use super::*; use tempfile::tempdir; diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index d7c3b90..77578ac 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -1,6 +1,8 @@ mod cursors; mod error; mod peers; +mod quarantine; +mod swaps; use std::path::Path; use redb::{Database, WriteTransaction}; @@ -8,9 +10,12 @@ use redb::{Database, WriteTransaction}; pub use cursors::DbCursorStore; pub use error::DbError; pub use peers::Peer; +pub use swaps::DbSwapStore; use crate::cursors::CURSORS; use crate::peers::PEERS; +use crate::quarantine::QUARANTINE; +use crate::swaps::SWAPS; pub type Result = std::result::Result; @@ -29,8 +34,24 @@ impl PeerDb { let write_txn = inner.begin_write()?; let _ = write_txn.open_table(PEERS)?; let _ = write_txn.open_table(CURSORS)?; + let _ = write_txn.open_table(SWAPS)?; + let _ = write_txn.open_table(QUARANTINE)?; write_txn.commit()?; } Ok(Self { inner }) } } + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + use tempfile::tempdir; + + #[test] + fn new_opens_all_tables_and_grants_write_txn() { + let dir = tempdir().unwrap(); + let db = PeerDb::new(&dir.path().join("p.db")).unwrap(); + assert!(db.wtx().is_ok()); + } +} diff --git a/crates/storage/src/peers.rs b/crates/storage/src/peers.rs index 3ffffa2..4d45319 100644 --- a/crates/storage/src/peers.rs +++ b/crates/storage/src/peers.rs @@ -78,6 +78,12 @@ impl PeerDb { #[cfg(test)] mod tests { + #![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing + )] use super::*; use std::sync::OnceLock; use tempfile::tempdir; diff --git a/crates/storage/src/quarantine.rs b/crates/storage/src/quarantine.rs new file mode 100644 index 0000000..5cb9b71 --- /dev/null +++ b/crates/storage/src/quarantine.rs @@ -0,0 +1,60 @@ +use redb::{ReadableDatabase, ReadableTableMetadata, TableDefinition}; +use stroemnet_protocol::ChannelId; + +use crate::{PeerDb, Result}; + +pub(crate) const QUARANTINE: TableDefinition<&[u8], &[u8]> = TableDefinition::new("quarantine"); + +fn quarantine_key(channel: ChannelId, swap_id: &[u8; 32]) -> [u8; 33] { + let mut key = [0u8; 33]; + key[0] = channel as u8; + key[1..].copy_from_slice(swap_id); + key +} + +impl PeerDb { + pub fn quarantine_swap( + &self, + channel: ChannelId, + swap_id: &[u8; 32], + raw: &[u8], + ) -> Result<()> { + let write_txn = self.inner.begin_write()?; + { + let mut table = write_txn.open_table(QUARANTINE)?; + table.insert(quarantine_key(channel, swap_id).as_slice(), raw)?; + } + write_txn.commit()?; + Ok(()) + } + + pub fn quarantined_count(&self) -> Result { + let read_txn = self.inner.begin_read()?; + let table = read_txn.open_table(QUARANTINE)?; + Ok(table.len()?) + } +} + +#[cfg(test)] +mod tests { + #![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing + )] + use super::*; + use tempfile::tempdir; + + #[test] + fn quarantine_persists_and_counts() { + let dir = tempdir().unwrap(); + let db = PeerDb::new(&dir.path().join("q.db")).unwrap(); + assert_eq!(db.quarantined_count().unwrap(), 0); + db.quarantine_swap(ChannelId::KaspaTn10, &[4u8; 32], &[0xde, 0xad]) + .unwrap(); + db.quarantine_swap(ChannelId::EthereumSepolia, &[5u8; 32], &[0xbe]) + .unwrap(); + assert_eq!(db.quarantined_count().unwrap(), 2); + } +} diff --git a/crates/storage/src/swaps/mod.rs b/crates/storage/src/swaps/mod.rs new file mode 100644 index 0000000..231dc90 --- /dev/null +++ b/crates/storage/src/swaps/mod.rs @@ -0,0 +1,5 @@ +mod store; +mod table; + +pub use store::DbSwapStore; +pub(crate) use table::SWAPS; diff --git a/crates/storage/src/swaps/store.rs b/crates/storage/src/swaps/store.rs new file mode 100644 index 0000000..1bd4fe3 --- /dev/null +++ b/crates/storage/src/swaps/store.rs @@ -0,0 +1,83 @@ +use std::sync::Arc; + +use stroemnet_data::SwapStore; +use stroemnet_protocol::ChannelId; + +use crate::PeerDb; + +pub struct DbSwapStore { + db: Arc, +} + +impl DbSwapStore { + pub fn new(db: Arc) -> Self { + Self { db } + } +} + +impl SwapStore for DbSwapStore { + fn load_channel(&self, channel_id: ChannelId) -> Vec<([u8; 32], Vec)> { + match self.db.get_swaps_for_channel(channel_id) { + Ok(rows) => rows, + Err(e) => { + tracing::warn!("swap load failed for {channel_id}: {e}"); + Vec::new() + } + } + } + + fn save(&self, channel_id: ChannelId, swap_id: [u8; 32], record: &[u8]) { + if let Err(e) = self.db.set_swap(channel_id, &swap_id, record) { + tracing::warn!( + "swap persist failed for {channel_id} {}: {e}", + hex::encode(swap_id) + ); + } + } + + fn delete(&self, channel_id: ChannelId, swap_id: [u8; 32]) { + if let Err(e) = self.db.remove_swap(channel_id, &swap_id) { + tracing::warn!( + "swap delete failed for {channel_id} {}: {e}", + hex::encode(swap_id) + ); + } + } + + fn quarantine(&self, channel_id: ChannelId, swap_id: [u8; 32], raw: &[u8], reason: &str) { + match self.db.quarantine_swap(channel_id, &swap_id, raw) { + Ok(()) => tracing::error!( + target: "settlement", + "quarantined corrupt swap {} on {channel_id}: {reason}", + hex::encode(swap_id) + ), + Err(e) => { + tracing::warn!( + "swap quarantine failed for {channel_id} {}: {e}", + hex::encode(swap_id) + ) + } + } + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + use tempfile::tempdir; + + #[test] + fn store_roundtrips_via_trait() { + let dir = tempdir().unwrap(); + let db = Arc::new(PeerDb::new(&dir.path().join("s.db")).unwrap()); + let store = DbSwapStore::new(db); + store.save(ChannelId::KaspaTn10, [1u8; 32], &[1, 2, 3]); + assert_eq!( + store.load_channel(ChannelId::KaspaTn10), + vec![([1u8; 32], vec![1, 2, 3])] + ); + store.delete(ChannelId::KaspaTn10, [1u8; 32]); + assert!(store.load_channel(ChannelId::KaspaTn10).is_empty()); + } +} diff --git a/crates/storage/src/swaps/table.rs b/crates/storage/src/swaps/table.rs new file mode 100644 index 0000000..1556887 --- /dev/null +++ b/crates/storage/src/swaps/table.rs @@ -0,0 +1,109 @@ +use redb::{ReadableDatabase, TableDefinition}; +use stroemnet_protocol::ChannelId; + +use crate::{PeerDb, Result}; + +pub(crate) const SWAPS: TableDefinition<&[u8], &[u8]> = TableDefinition::new("swaps"); + +fn swap_key(channel: ChannelId, swap_id: &[u8; 32]) -> [u8; 33] { + let mut key = [0u8; 33]; + key[0] = channel as u8; + key[1..].copy_from_slice(swap_id); + key +} + +impl PeerDb { + pub fn get_swaps_for_channel(&self, channel: ChannelId) -> Result)>> { + let read_txn = self.inner.begin_read()?; + let table = read_txn.open_table(SWAPS)?; + let chan = channel as u8; + let lo = [chan]; + let hi = [chan.wrapping_add(1)]; + let range = if chan == u8::MAX { + table.range(lo.as_slice()..)? + } else { + table.range(lo.as_slice()..hi.as_slice())? + }; + let mut out = Vec::new(); + for row in range { + let (k, v) = row?; + let key = k.value(); + if let Some((_prefix, rest)) = key.split_first() + && let Ok(swap_id) = <[u8; 32]>::try_from(rest) + { + out.push((swap_id, v.value().to_vec())); + } + } + Ok(out) + } + + pub fn set_swap(&self, channel: ChannelId, swap_id: &[u8; 32], record: &[u8]) -> Result<()> { + let write_txn = self.inner.begin_write()?; + { + let mut table = write_txn.open_table(SWAPS)?; + table.insert(swap_key(channel, swap_id).as_slice(), record)?; + } + write_txn.commit()?; + Ok(()) + } + + pub fn remove_swap(&self, channel: ChannelId, swap_id: &[u8; 32]) -> Result<()> { + let write_txn = self.inner.begin_write()?; + { + let mut table = write_txn.open_table(SWAPS)?; + table.remove(swap_key(channel, swap_id).as_slice())?; + } + write_txn.commit()?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + use tempfile::tempdir; + + #[test] + fn swap_set_get_delete_roundtrip() { + let dir = tempdir().unwrap(); + let db = PeerDb::new(&dir.path().join("s.db")).unwrap(); + let id = [7u8; 32]; + assert!( + db.get_swaps_for_channel(ChannelId::IgraGalleon) + .unwrap() + .is_empty() + ); + db.set_swap(ChannelId::IgraGalleon, &id, &[1, 2, 3]) + .unwrap(); + assert_eq!( + db.get_swaps_for_channel(ChannelId::IgraGalleon).unwrap(), + vec![(id, vec![1, 2, 3])] + ); + db.remove_swap(ChannelId::IgraGalleon, &id).unwrap(); + assert!( + db.get_swaps_for_channel(ChannelId::IgraGalleon) + .unwrap() + .is_empty() + ); + } + + #[test] + fn load_channel_isolates_by_channel() { + let dir = tempdir().unwrap(); + let db = PeerDb::new(&dir.path().join("s.db")).unwrap(); + db.set_swap(ChannelId::IgraGalleon, &[1u8; 32], &[0xaa]) + .unwrap(); + db.set_swap(ChannelId::KaspaTn10, &[2u8; 32], &[0xbb]) + .unwrap(); + assert_eq!( + db.get_swaps_for_channel(ChannelId::IgraGalleon).unwrap(), + vec![([1u8; 32], vec![0xaa])] + ); + assert!( + db.get_swaps_for_channel(ChannelId::EthereumSepolia) + .unwrap() + .is_empty() + ); + } +} diff --git a/crates/test-harness/Cargo.toml b/crates/test-harness/Cargo.toml index d62719b..afd0426 100644 --- a/crates/test-harness/Cargo.toml +++ b/crates/test-harness/Cargo.toml @@ -13,3 +13,6 @@ ahash = { workspace = true } tokio = { workspace = true, features = ["full"] } [dev-dependencies] + +[lints] +workspace = true diff --git a/crates/test-harness/src/lib.rs b/crates/test-harness/src/lib.rs index 2b40cfb..a3201ca 100644 --- a/crates/test-harness/src/lib.rs +++ b/crates/test-harness/src/lib.rs @@ -1,3 +1,10 @@ +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing +)] + pub mod fixtures; pub mod network; diff --git a/crates/test-harness/tests/ccr_rescue.rs b/crates/test-harness/tests/ccr_rescue.rs index fda652a..c001c77 100644 --- a/crates/test-harness/tests/ccr_rescue.rs +++ b/crates/test-harness/tests/ccr_rescue.rs @@ -1,3 +1,10 @@ +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing +)] + use std::time::Duration; use stroemnet_p2p::transport::{WsTransport, loopback_pair}; diff --git a/crates/wasm/Cargo.toml b/crates/wasm/Cargo.toml index c75dc51..ae21e2a 100644 --- a/crates/wasm/Cargo.toml +++ b/crates/wasm/Cargo.toml @@ -14,6 +14,7 @@ stroemnet-handler = { path = "../handler" } stroemnet-p2p = { path = "../p2p" } ahash = { workspace = true, features = ["serde"] } +parking_lot.workspace = true serde = { workspace = true } serde_json = { workspace = true } tracing = { workspace = true } @@ -28,8 +29,10 @@ alloy-primitives = { workspace = true } kaspa-addresses = { workspace = true } [target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies] -console_error_panic_hook = { workspace = true } getrandom-03 = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dev-dependencies] wasm-bindgen-test = { workspace = true } + +[lints] +workspace = true diff --git a/crates/wasm/src/address_validation.rs b/crates/wasm/src/address_validation.rs index 4c3d6c3..bd12bb1 100644 --- a/crates/wasm/src/address_validation.rs +++ b/crates/wasm/src/address_validation.rs @@ -3,11 +3,6 @@ use std::str::FromStr; use wasm_bindgen::prelude::*; #[wasm_bindgen(js_name = validateEthAddress)] -/// Validates an EVM address string. -/// It accepts both checksummed and non-checksummed addresses, -/// but if the address contains uppercase letters, -/// it must be correctly checksummed according to EIP-55. -/// Returns an error if the address is malformed or has an invalid checksum. pub fn validate_eth_address(input: &str) -> Result<(), JsError> { let trimmed = input.trim(); if trimmed.is_empty() { @@ -28,10 +23,6 @@ pub fn validate_eth_address(input: &str) -> Result<(), JsError> { } #[wasm_bindgen(js_name = validateKasAddress)] -/// Validates a Kaspa address string. -/// It checks that the address is well-formed and that it belongs to the expected network based on the provided network ID. -/// The network ID can be: `mainnet`, `testnet`, `simnet`, or `devnet`. -/// Returns an error if the address is malformed or if it belongs to a different network than expected. pub fn validate_kas_address(input: &str, network_id: &str) -> Result<(), JsError> { use kaspa_addresses::{Address as KasAddress, Prefix}; let trimmed = input.trim(); diff --git a/crates/wasm/src/defaults/channel_configurations.rs b/crates/wasm/src/defaults/channel_configurations.rs index ab1807c..d7c7969 100644 --- a/crates/wasm/src/defaults/channel_configurations.rs +++ b/crates/wasm/src/defaults/channel_configurations.rs @@ -3,9 +3,6 @@ use wasm_bindgen::JsValue; use wasm_bindgen::prelude::*; #[wasm_bindgen(js_name = defaultObserverChannels)] -/// Returns the default channel configurations for an observer client as a JSON object. -/// If you do not have any special configurations, for your stroemnet client, then you can use -/// this method to quickly connect to the stroemnet network. pub fn default_observer_channels() -> Result { default_observer_channels_json() .serialize(&serde_wasm_bindgen::Serializer::json_compatible()) @@ -16,7 +13,7 @@ pub fn default_observer_channels_json() -> serde_json::Value { serde_json::json!({ "ethereum-sepolia": { "rpc_url": "https://eth-sepolia.api.onfinality.io/public", - "htlc_address": "0x3AB5f1089f521D982ad67193E8523eB2fD34Da53", + "htlc_address": "0xb249849CAc76bcd86A566016AcA5e7FC63dA5122", "minimum_block_confirmations": 1u64, }, "igra-galleon": { diff --git a/crates/wasm/src/defaults/gateway_configuration.rs b/crates/wasm/src/defaults/gateway_configuration.rs index c057c97..7ebdf5c 100644 --- a/crates/wasm/src/defaults/gateway_configuration.rs +++ b/crates/wasm/src/defaults/gateway_configuration.rs @@ -23,6 +23,12 @@ pub fn default_gateway_config() -> Result { #[cfg(test)] mod tests { + #![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing + )] use super::*; use crate::StroemGateway; use wasm_bindgen_test::wasm_bindgen_test; diff --git a/crates/wasm/src/gateway/config.rs b/crates/wasm/src/gateway/config.rs new file mode 100644 index 0000000..68a0d11 --- /dev/null +++ b/crates/wasm/src/gateway/config.rs @@ -0,0 +1,72 @@ +use ahash::AHashMap; +use serde::Deserialize; +use stroemnet_handler::HandlerConfig; +use stroemnet_node::coordinator::Role; +use stroemnet_node::{ChannelSpec, NodeConfig, SwapStatusUpdate}; +use stroemnet_protocol::ChannelId; +use tokio::sync::mpsc; +use wasm_bindgen::JsError; + +use crate::defaults::{default_bootstrap_peers, default_observer_channels_json}; + +#[derive(Deserialize)] +pub(super) struct GatewayConfig { + #[serde(rename = "observerChannels", default)] + observer_channels: Option, + #[serde(rename = "bootstrapPeers", default)] + bootstrap_peers: Option>, + handler: HandlerConfigJs, +} + +#[derive(Deserialize)] +struct HandlerConfigJs { + #[serde(rename = "minTradeUsd")] + min_trade_usd: f64, + #[serde(rename = "maxTradeUsd")] + max_trade_usd: f64, + #[serde(rename = "spreadPercent")] + spread_percent: f64, + #[serde(rename = "commitBufferSecs")] + commit_buffer_secs: u64, +} + +impl GatewayConfig { + pub(super) fn into_node_config( + self, + swap_status_tx: mpsc::UnboundedSender, + ) -> Result { + let raw_channels = self + .observer_channels + .unwrap_or_else(default_observer_channels_json); + let obj = raw_channels + .as_object() + .ok_or_else(|| JsError::new("observerChannels must be a JSON object"))?; + let mut channels: AHashMap = AHashMap::new(); + for (k, v) in obj { + let id = ChannelId::try_from(k.as_str()).map_err(|e| JsError::new(&e))?; + channels.insert( + id, + ChannelSpec { + config: v.clone(), + lp_private_key: None, + }, + ); + } + + let bootstrap_peers = self.bootstrap_peers.unwrap_or_else(default_bootstrap_peers); + + Ok(NodeConfig { + handler: HandlerConfig { + min_trade_usd: self.handler.min_trade_usd, + max_trade_usd: self.handler.max_trade_usd, + spread_percent: self.handler.spread_percent, + commit_buffer_secs: self.handler.commit_buffer_secs, + }, + channels, + bootstrap_peers, + role: Role::Observer, + advertised_listen_addr: None, + swap_status_tx, + }) + } +} diff --git a/crates/wasm/src/gateway/connect.rs b/crates/wasm/src/gateway/connect.rs index 693d582..f4076cb 100644 --- a/crates/wasm/src/gateway/connect.rs +++ b/crates/wasm/src/gateway/connect.rs @@ -5,47 +5,29 @@ use stroemnet_node::Node; use wasm_bindgen::JsValue; use wasm_bindgen::prelude::*; -use crate::gateway::inner::Inner; +use crate::gateway::inner::{EventCallbacks, Inner}; const PEER_COUNT_POLL_MS: u64 = 500; -fn spawn_quote_drain(inner: Arc) { - let Some(mut rx) = inner.quote_rx.lock().unwrap().take() else { +fn spawn_drain( + rx: Option>, + callbacks: Arc>, + select: impl Fn(&EventCallbacks) -> Vec + 'static, + label: &'static str, +) { + let Some(mut rx) = rx else { return; }; - let callbacks = inner.callbacks.clone(); stroemnet_protocol::spawn(async move { - while let Some(row) = rx.recv().await { - let js_value = match row.serialize(&serde_wasm_bindgen::Serializer::json_compatible()) { + while let Some(item) = rx.recv().await { + let js_value = match item.serialize(&serde_wasm_bindgen::Serializer::json_compatible()) + { Ok(v) => v, Err(e) => { - tracing::warn!("serialize CheckedQuote: {e}"); + tracing::warn!("serialize {label}: {e}"); continue; } }; - let listeners: Vec = callbacks.lock().unwrap().quote.clone(); - for f in listeners { - let _ = f.call1(&JsValue::NULL, &js_value); - } - } - }); -} - -fn spawn_swap_status_drain(inner: Arc) { - let Some(mut rx) = inner.swap_status_rx.lock().unwrap().take() else { - return; - }; - let callbacks = inner.callbacks.clone(); - stroemnet_protocol::spawn(async move { - while let Some(update) = rx.recv().await { - let js_value = - match update.serialize(&serde_wasm_bindgen::Serializer::json_compatible()) { - Ok(v) => v, - Err(e) => { - tracing::warn!("serialize SwapStatusUpdate: {e}"); - continue; - } - }; - let listeners: Vec = callbacks.lock().unwrap().swap_status.clone(); + let listeners: Vec = select(&callbacks.lock()); for f in listeners { let _ = f.call1(&JsValue::NULL, &js_value); } @@ -62,7 +44,7 @@ fn spawn_peer_count_poll(inner: Arc, node: Arc) { if last != Some(current) { last = Some(current); let js_value = JsValue::from_f64(current as f64); - let listeners: Vec = callbacks.lock().unwrap().peer_count.clone(); + let listeners: Vec = callbacks.lock().peer_count.clone(); for f in listeners { let _ = f.call1(&JsValue::NULL, &js_value); } @@ -74,13 +56,6 @@ fn spawn_peer_count_poll(inner: Arc, node: Arc) { #[wasm_bindgen] impl StroemGateway { #[wasm_bindgen] - /// Connects the gateway to the stroemnet network using the provided configuration. - /// - /// Errors with: - /// - `gateway already connected` if the gateway is already connected. - /// - `gateway already consumed` if the gateway was connected and then disconnected (not currently - /// - `Node::start: {e}` if the underlying node failed to start for some reason (e.g. invalid config). - /// - `node already set` if the node was somehow set by another concurrent call to connect (should be impossible). pub async fn connect(&self) -> Result<(), JsError> { if self.inner.node.get().is_some() { return Err(JsError::new("gateway already connected")); @@ -89,11 +64,10 @@ impl StroemGateway { .inner .config .lock() - .unwrap() .take() .ok_or_else(|| JsError::new("gateway already consumed"))?; - let (node, quote_rx) = Node::start(cfg, None) + let (node, quote_rx) = Node::start(cfg, None, None) .await .map_err(|e| JsError::new(&format!("Node::start: {e}")))?; let node = Arc::new(node); @@ -101,10 +75,20 @@ impl StroemGateway { .node .set(node.clone()) .map_err(|_| JsError::new("node already set"))?; - *self.inner.quote_rx.lock().unwrap() = Some(quote_rx); + *self.inner.quote_rx.lock() = Some(quote_rx); - spawn_quote_drain(self.inner.clone()); - spawn_swap_status_drain(self.inner.clone()); + spawn_drain( + self.inner.quote_rx.lock().take(), + self.inner.callbacks.clone(), + |c| c.quote.clone(), + "CheckedQuote", + ); + spawn_drain( + self.inner.swap_status_rx.lock().take(), + self.inner.callbacks.clone(), + |c| c.swap_status.clone(), + "SwapStatusUpdate", + ); spawn_peer_count_poll(self.inner.clone(), node); Ok(()) } diff --git a/crates/wasm/src/gateway/event_listeners.rs b/crates/wasm/src/gateway/event_listeners.rs index c95792a..fdb45da 100644 --- a/crates/wasm/src/gateway/event_listeners.rs +++ b/crates/wasm/src/gateway/event_listeners.rs @@ -18,18 +18,18 @@ impl StroemGateway { #[wasm_bindgen(js_name = onQuote)] pub fn on_quote(&self, callback: QuoteCallback) { let f: js_sys::Function = callback.unchecked_into(); - self.inner.callbacks.lock().unwrap().quote.push(f); + self.inner.callbacks.lock().quote.push(f); } #[wasm_bindgen(js_name = onSwapStatus)] pub fn on_swap_status(&self, callback: SwapStatusCallback) { let f: js_sys::Function = callback.unchecked_into(); - self.inner.callbacks.lock().unwrap().swap_status.push(f); + self.inner.callbacks.lock().swap_status.push(f); } #[wasm_bindgen(js_name = onPeerCount)] pub fn on_peer_count(&self, callback: PeerCountCallback) { let f: js_sys::Function = callback.unchecked_into(); - self.inner.callbacks.lock().unwrap().peer_count.push(f); + self.inner.callbacks.lock().peer_count.push(f); } } diff --git a/crates/wasm/src/gateway/inner.rs b/crates/wasm/src/gateway/inner.rs index 288c356..73f1cf2 100644 --- a/crates/wasm/src/gateway/inner.rs +++ b/crates/wasm/src/gateway/inner.rs @@ -1,4 +1,5 @@ -use std::sync::{Arc, Mutex, OnceLock}; +use parking_lot::Mutex; +use std::sync::{Arc, OnceLock}; use stroemnet_node::{Node, NodeConfig, SwapStatusUpdate}; use tokio::sync::mpsc; #[derive(Default)] diff --git a/crates/wasm/src/gateway/mod.rs b/crates/wasm/src/gateway/mod.rs index ed4f468..f8c3eaf 100644 --- a/crates/wasm/src/gateway/mod.rs +++ b/crates/wasm/src/gateway/mod.rs @@ -1,105 +1,21 @@ +mod config; mod connect; mod event_listeners; mod inner; mod swaps; -use std::sync::{Arc, Mutex, OnceLock}; -use ahash::AHashMap; -use serde::Deserialize; -use stroemnet_handler::HandlerConfig; -use stroemnet_node::coordinator::Role; -use stroemnet_node::{ChannelSpec, Node, NodeConfig, SwapStatusUpdate}; -use stroemnet_protocol::ChannelId; +use parking_lot::Mutex; +use std::sync::{Arc, OnceLock}; + use tokio::sync::mpsc; use wasm_bindgen::JsValue; use wasm_bindgen::prelude::*; -use crate::defaults::default_observer_channels_json; +use crate::gateway::config::GatewayConfig; use crate::gateway::inner::{EventCallbacks, Inner}; - -fn canonical_chain_id(name: &str) -> Result { - match name { - "kaspa-tn10" => Ok(ChannelId::KaspaTn10), - "ethereum-sepolia" => Ok(ChannelId::EthereumSepolia), - "igra-galleon" => Ok(ChannelId::IgraGalleon), - other => Err(format!("unknown chain '{other}'")), - } -} - -#[derive(Deserialize)] -struct GatewayConfig { - #[serde(rename = "observerChannels", default)] - observer_channels: Option, - #[serde(rename = "bootstrapPeers", default)] - bootstrap_peers: Option>, - handler: HandlerConfigJs, -} - -#[derive(Deserialize)] -struct HandlerConfigJs { - #[serde(rename = "minTradeUsd")] - min_trade_usd: f64, - #[serde(rename = "maxTradeUsd")] - max_trade_usd: f64, - #[serde(rename = "spreadPercent")] - spread_percent: f64, - #[serde(rename = "commitBufferSecs")] - commit_buffer_secs: u64, -} - -impl GatewayConfig { - fn into_node_config( - self, - swap_status_tx: mpsc::UnboundedSender, - ) -> Result { - let raw_channels = self - .observer_channels - .unwrap_or_else(default_observer_channels_json); - let obj = raw_channels - .as_object() - .ok_or_else(|| JsError::new("observerChannels must be a JSON object"))?; - let mut channels: AHashMap = AHashMap::new(); - for (k, v) in obj { - let id = canonical_chain_id(k).map_err(|e| JsError::new(&e))?; - channels.insert( - id, - ChannelSpec { - config: v.clone(), - lp_private_key: None, - }, - ); - } - - let bootstrap_peers = self.bootstrap_peers.unwrap_or_else(|| { - stroemnet_p2p::SEED_NODES - .iter() - .map(|u| (*u).to_string()) - .collect() - }); - - Ok(NodeConfig { - handler: HandlerConfig { - min_trade_usd: self.handler.min_trade_usd, - max_trade_usd: self.handler.max_trade_usd, - spread_percent: self.handler.spread_percent, - commit_buffer_secs: self.handler.commit_buffer_secs, - }, - channels, - bootstrap_peers, - role: Role::Observer, - advertised_listen_addr: None, - swap_status_tx, - }) - } -} +use stroemnet_node::Node; #[wasm_bindgen] -/// A gateway for interacting with the stroemnet P2P Atomic Swap RFQ network, -/// allowing you to request quotes and submit commitments for cross-chain swaps. -/// -/// The gateway manages an observer-only stroemnet node which connects to the -/// stroemnet network. It observes all chain activity monitoring both for swaps -/// and requests. pub struct StroemGateway { inner: Arc, } @@ -107,8 +23,6 @@ pub struct StroemGateway { #[wasm_bindgen] impl StroemGateway { #[wasm_bindgen(constructor)] - /// Creates a new StroemGateway instance with the provided configuration. - /// You can get the default configuration by calling `getDefaultConfig()`, and then modify it as needed. pub fn new(config: JsValue) -> Result { let cfg: GatewayConfig = serde_wasm_bindgen::from_value(config) .map_err(|e| JsError::new(&format!("config: {e}")))?; @@ -134,7 +48,6 @@ impl StroemGateway { } #[wasm_bindgen(js_name = peerCount)] - /// Returns the current number of connected peers in the stroemnet network. This can be useful for monitoring the connectivity status of the gateway. pub fn peer_count(&self) -> Result { Ok(self.require_node()?.peer_count()) } diff --git a/crates/wasm/src/gateway/swaps.rs b/crates/wasm/src/gateway/swaps.rs index e990dd4..f46b45d 100644 --- a/crates/wasm/src/gateway/swaps.rs +++ b/crates/wasm/src/gateway/swaps.rs @@ -8,9 +8,6 @@ use crate::StroemGateway; #[wasm_bindgen] impl StroemGateway { #[wasm_bindgen(js_name = requestQuote)] - /// Requests a quote for a potential swap with the given parameters. - /// The quote details will be returned asynchronously via the `quote` event listener. - /// Therefore you should first add a listener for the `quote` event, and then call this function to request a quote. pub async fn request_quote( &self, swap_id: Vec, @@ -30,14 +27,6 @@ impl StroemGateway { } #[wasm_bindgen(js_name = registerCommitment)] - /// Registers a commitment and its matching secret with the local node so the node - /// can complete the swap once the on-chain deposit is made, and returns the - /// source-chain deposit target. - /// - /// This does NOT submit anything on-chain: the commitment is built from a `quote` - /// event, registered here together with the secret that matches its secret hash, - /// and the actual deposit is performed separately by the caller (an EVM HTLC - /// transaction, or a transfer to the returned Kaspa P2SH address). pub async fn register_commitment( &self, commitment: JsValue, diff --git a/crates/wasm/src/prices.rs b/crates/wasm/src/prices.rs index 2ebbf7f..dbe3fb7 100644 --- a/crates/wasm/src/prices.rs +++ b/crates/wasm/src/prices.rs @@ -5,12 +5,6 @@ use wasm_bindgen::JsValue; use wasm_bindgen::prelude::*; #[wasm_bindgen(js_name = getPrices)] -/// Fetches current USD prices for the supported channels from the price oracle. -/// In the browser this resolves against Bybit (the only CORS-enabled source); -/// native nodes aggregate across Bybit, Gate.io and MEXC. -/// -/// Returns a plain object keyed by channel id as a string -/// (`"0"` = Kaspa, `"1"` = Ethereum, `"2"` = Igra) mapping to the USD price. pub async fn get_prices() -> Result { let feed = PriceFeed::with_default_client(); let channels = vec![ diff --git a/docs/architecture.svg b/docs/architecture.svg new file mode 100644 index 0000000..ba4986d --- /dev/null +++ b/docs/architecture.svg @@ -0,0 +1,84 @@ + + + +stroemnet architecture +Trustless cross-chain atomic swaps / Kaspa (UTXO) ⇄ EVM (account) via HTLCs / testnet + +stroemnetd native LP / observer node / crates/node + +Taker browser +StroemGateway (WASM SDK) +crates/wasm +holds the secret +requests quotes +reveals to claim + +Peer mesh +other LP / relay nodes +(native, gossip) + +P2P network +crates/p2p +transport / handshake +gossip / discovery + +Coordinator +swap-protocol junction +crates/node + +Handler +crates/handler +LP logic / SwapTracker +pricing / validation + +Oracle +crates/node / amounts +price feeds → PriceStorage + +ChainDataSink / crates/data +intake + settlement engine + +EVM buffer +poll / finality +claim / refund (alloy) + +Kaspa buffer +intake / settlement +HTLC script spends + +Storage / redb / crates/storage +swaps / cursors / peers / quarantine + +EVM chains +Ethereum Sepolia +Igra Galleon +StroemHTLCV1 contract + +Kaspa TN10 +P2SH HTLC (script) + + +P2P / WS + + +gossip + + + + +prices + + +effects + + +on-chain + + +on-chain + + +persist +P2P carries quotes / proposals / reveals / script announcements. Buffers observe on-chain events and broadcast HTLC txs; records persist to redb. +Swap flow: quote → taker commits (source HTLC) → LP counter-commits (dest HTLC) → taker reveals secret → both claim (LP via CCR). + \ No newline at end of file