> {
+ 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