From 8bb7cabdd36dce9bbd77c1d5ae2221556425c9f6 Mon Sep 17 00:00:00 2001 From: Blake Date: Wed, 4 Mar 2026 13:56:49 +0800 Subject: [PATCH 1/5] feat: add bridge-out scripts --- crates/client/src/goat_chain/chain_adaptor.rs | 40 +- crates/client/src/goat_chain/evmchain.rs | 35 +- crates/client/src/goat_chain/goat_adaptor.rs | 158 ++- .../src/goat_chain/mock_goat_adaptor.rs | 26 +- crates/client/src/goat_chain/mod.rs | 36 +- node/Cargo.toml | 6 +- node/src/bin/send_bridge_out.rs | 1052 +++++++++++++++++ 7 files changed, 1332 insertions(+), 21 deletions(-) create mode 100644 node/src/bin/send_bridge_out.rs diff --git a/crates/client/src/goat_chain/chain_adaptor.rs b/crates/client/src/goat_chain/chain_adaptor.rs index 72402987..5d7ab488 100644 --- a/crates/client/src/goat_chain/chain_adaptor.rs +++ b/crates/client/src/goat_chain/chain_adaptor.rs @@ -1,7 +1,7 @@ use crate::btc_chain::MerkleProofExtend; use crate::goat_chain::goat_adaptor::{GoatAdaptor, GoatInitConfig}; use crate::goat_chain::mock_goat_adaptor::MockAdaptor; -use alloy::primitives::{Address, U256}; +use alloy::primitives::{Address, Bytes, U256}; use alloy::rpc::types::{ TransactionReceipt, trace::geth::{GethDebugTracingOptions, GethTrace}, @@ -22,6 +22,21 @@ pub trait ChainAdaptor: Send + Sync { tx_hash: &str, trace_options: Option, ) -> anyhow::Result; + async fn swap_initialize( + &self, + contract_address: Address, + escrow: SwapEscrowData, + signature: Bytes, + timeout: U256, + extra_data: Bytes, + value_wei: U256, + max_wait_secs: u64, + ) -> anyhow::Result; + async fn extract_initialize_escrow_hash_from_tx( + &self, + tx_hash: &str, + contract_address: Address, + ) -> anyhow::Result>; async fn gateway_get_min_challenge_amount_sats(&self) -> anyhow::Result; async fn gateway_get_min_pegin_fee_sats(&self) -> anyhow::Result; @@ -218,6 +233,29 @@ pub trait ChainAdaptor: Send + Sync { async fn peg_btc_balance(&self, address: &[u8; 20]) -> anyhow::Result; } + +#[derive(Clone, Debug)] +pub struct SwapEscrowData { + pub offerer: Address, + pub claimer: Address, + pub amount: U256, + pub token: Address, + pub flags: U256, + pub claim_handler: Address, + pub claim_data: [u8; 32], + pub refund_handler: Address, + pub refund_data: [u8; 32], + pub security_deposit: U256, + pub claimer_bounty: U256, + pub deposit_token: Address, + pub success_action_commitment: [u8; 32], +} + +#[derive(Clone, Debug)] +pub struct SwapInitializeResult { + pub tx_hash: String, + pub escrow_hash: String, +} #[derive(Eq, PartialEq, Clone, Copy)] pub enum GoatNetwork { Main, diff --git a/crates/client/src/goat_chain/evmchain.rs b/crates/client/src/goat_chain/evmchain.rs index 47ffb8c5..f84493db 100644 --- a/crates/client/src/goat_chain/evmchain.rs +++ b/crates/client/src/goat_chain/evmchain.rs @@ -2,10 +2,10 @@ use crate::Utxo; use crate::goat_chain::DisproveTxType; use crate::goat_chain::chain_adaptor::{ BitcoinTx, BitcoinTxProof, ChainAdaptor, GraphData, PeginData, SequencerSetUpdateWitness, - WithdrawData, + SwapEscrowData, SwapInitializeResult, WithdrawData, }; use crate::goat_chain::mock_goat_adaptor::MockAdaptor; -use alloy::primitives::{Address, U256}; +use alloy::primitives::{Address, Bytes, U256}; use alloy::rpc::types::{ TransactionReceipt, trace::geth::{GethDebugTracingOptions, GethTrace}, @@ -262,6 +262,37 @@ impl EvmChain { self.adaptor.debug_trace_tx(tx_hash, trace_options).await } + pub async fn swap_initialize( + &self, + contract_address: Address, + escrow: SwapEscrowData, + signature: Bytes, + timeout: U256, + extra_data: Bytes, + value_wei: U256, + max_wait_secs: u64, + ) -> anyhow::Result { + self.adaptor + .swap_initialize( + contract_address, + escrow, + signature, + timeout, + extra_data, + value_wei, + max_wait_secs, + ) + .await + } + + pub async fn extract_initialize_escrow_hash_from_tx( + &self, + tx_hash: &str, + contract_address: Address, + ) -> anyhow::Result> { + self.adaptor.extract_initialize_escrow_hash_from_tx(tx_hash, contract_address).await + } + pub async fn gateway_get_committee_pubkeys( &self, instance_id: &Uuid, diff --git a/crates/client/src/goat_chain/goat_adaptor.rs b/crates/client/src/goat_chain/goat_adaptor.rs index ec9c314a..c2fe4dde 100644 --- a/crates/client/src/goat_chain/goat_adaptor.rs +++ b/crates/client/src/goat_chain/goat_adaptor.rs @@ -1,6 +1,7 @@ use crate::goat_chain::chain_adaptor::{ BitcoinTx, BitcoinTxProof, ChainAdaptor, DisproveTxType, GraphData, PeginData, PeginStatus, - SequencerSetUpdateWitness, Utxo, WithdrawData, WithdrawStatus, + SequencerSetUpdateWitness, SwapEscrowData, SwapInitializeResult, Utxo, WithdrawData, + WithdrawStatus, }; use crate::goat_chain::goat_adaptor::IBitcoinSPV::IBitcoinSPVInstance; use crate::goat_chain::goat_adaptor::ICommitteeManagement::ICommitteeManagementInstance; @@ -17,7 +18,7 @@ use alloy::rpc::types::TransactionReceipt; use alloy::rpc::types::trace::geth::{CallConfig, GethDebugTracingOptions, GethTrace}; use alloy::{ network::{Ethereum, EthereumWallet, NetworkWallet, eip2718::Encodable2718}, - primitives::{Address, Bytes, ChainId, FixedBytes, TxHash, U256}, + primitives::{Address, B256, Bytes, ChainId, FixedBytes, TxHash, U256, keccak256}, providers::{Provider, ProviderBuilder, RootProvider}, rpc::types::TransactionRequest, signers::{Signer, local::PrivateKeySigner}, @@ -180,6 +181,62 @@ sol!( } ); +sol!( + #[derive(Debug)] + #[allow(missing_docs)] + #[sol(rpc)] + interface ISwapEscrowManager { + event Initialize(address indexed offerer, address indexed claimer, bytes32 indexed escrowHash, address claimHandler, address refundHandler); + + struct EscrowData { + address offerer; + address claimer; + uint256 amount; + address token; + uint256 flags; + address claimHandler; + bytes32 claimData; + address refundHandler; + bytes32 refundData; + uint256 securityDeposit; + uint256 claimerBounty; + address depositToken; + bytes32 successActionCommitment; + } + + function initialize(EscrowData calldata escrow, bytes calldata signature, uint256 timeout, bytes memory _extraData) external payable; + } +); + +const INITIALIZE_EVENT_SIGNATURE: &str = "Initialize(address,address,bytes32,address,address)"; + +fn initialize_event_topic0() -> B256 { + keccak256(INITIALIZE_EVENT_SIGNATURE.as_bytes()) +} + +fn extract_escrow_hash_from_initialize_topics(topics: &[B256]) -> Option { + if topics.len() < 4 { + return None; + } + Some(format!("0x{}", hex::encode(topics[3].0))) +} + +fn extract_initialize_escrow_hash_from_receipt( + receipt: &TransactionReceipt, + swap_contract_address: &Address, +) -> Option { + let topic0 = initialize_event_topic0(); + receipt.logs().iter().find_map(|log| { + if log.address() != *swap_contract_address { + return None; + } + if log.topic0() != Some(&topic0) { + return None; + } + extract_escrow_hash_from_initialize_topics(log.topics()) + }) +} + sol!( #[derive(Debug)] #[allow(missing_docs)] @@ -435,10 +492,37 @@ impl GoatAdaptor { .ok_or_else(|| anyhow::anyhow!("StakeManagement not initialized")) } + fn convert_swap_escrow_data(escrow: SwapEscrowData) -> ISwapEscrowManager::EscrowData { + ISwapEscrowManager::EscrowData { + offerer: escrow.offerer, + claimer: escrow.claimer, + amount: escrow.amount, + token: escrow.token, + flags: escrow.flags, + claimHandler: escrow.claim_handler, + claimData: FixedBytes::<32>::from(escrow.claim_data), + refundHandler: escrow.refund_handler, + refundData: FixedBytes::<32>::from(escrow.refund_data), + securityDeposit: escrow.security_deposit, + claimerBounty: escrow.claimer_bounty, + depositToken: escrow.deposit_token, + successActionCommitment: FixedBytes::<32>::from(escrow.success_action_commitment), + } + } + async fn handle_transaction_request( &self, - mut tx_request: TransactionRequest, + tx_request: TransactionRequest, ) -> anyhow::Result { + let (tx_hash, _) = self.handle_transaction_request_with_wait(tx_request, 10).await?; + Ok(tx_hash) + } + + async fn handle_transaction_request_with_wait( + &self, + mut tx_request: TransactionRequest, + max_wait_secs: u64, + ) -> anyhow::Result<(TxHash, TransactionReceipt)> { // update gas price nonce gas_limit tx_request.gas_price = Some(self.provider.clone().get_gas_price().await?); tracing::info!("gas price: {}", tx_request.gas_price.unwrap()); @@ -463,10 +547,12 @@ impl GoatAdaptor { let tx_hash = pending_tx.tx_hash(); tracing::info!("finish send tx_hash: {}", tx_hash.to_string()); - // TODO update latter - let mut is_success = false; - for i in 0..5 { - time::sleep(Duration::from_millis(2000)).await; + let poll_interval_secs = 2_u64; + let max_attempts = (max_wait_secs / poll_interval_secs).max(1); + for i in 0..max_attempts { + if i > 0 { + time::sleep(Duration::from_secs(poll_interval_secs)).await; + } match self.provider.get_transaction_receipt(*tx_hash).await { Err(_) => { tracing::info!( @@ -485,17 +571,15 @@ impl GoatAdaptor { ); continue; } - if receipt.unwrap().status() { - is_success = true; - break; + let receipt = receipt.expect("checked is_some"); + if !receipt.status() { + bail!("tx_hash:{tx_hash} execute failed on chain"); } + return Ok((*tx_hash, receipt)); } }; } - if !is_success { - bail!("tx_hash:{tx_hash} execute failed on chain"); - } - Ok(*tx_hash) + bail!("tx_hash:{} receipt not found within {} seconds", tx_hash.to_string(), max_wait_secs) } } @@ -721,6 +805,52 @@ impl ChainAdaptor for GoatAdaptor { Ok(self.provider.debug_trace_transaction(TxHash::from_str(tx_hash)?, opts).await?) } + async fn swap_initialize( + &self, + contract_address: Address, + escrow: SwapEscrowData, + signature: Bytes, + timeout: U256, + extra_data: Bytes, + value_wei: U256, + max_wait_secs: u64, + ) -> anyhow::Result { + let contract = ISwapEscrowManager::new(contract_address, self.provider.clone()); + let escrow = GoatAdaptor::convert_swap_escrow_data(escrow); + let tx_request: TransactionRequest = contract + .initialize(escrow, signature, timeout, extra_data) + .from(self.get_default_signer_address()) + .chain_id(self.chain_id) + .value(value_wei) + .into_transaction_request(); + let (tx_hash, receipt) = + self.handle_transaction_request_with_wait(tx_request, max_wait_secs).await?; + let escrow_hash = extract_initialize_escrow_hash_from_receipt(&receipt, &contract_address) + .ok_or_else(|| { + anyhow::anyhow!( + "Initialize event with escrowHash not found in tx {} logs for contract {}", + tx_hash, + contract_address + ) + })?; + Ok(SwapInitializeResult { tx_hash: tx_hash.to_string(), escrow_hash }) + } + + async fn extract_initialize_escrow_hash_from_tx( + &self, + tx_hash: &str, + contract_address: Address, + ) -> anyhow::Result> { + let receipt = self.provider.get_transaction_receipt(TxHash::from_str(tx_hash)?).await?; + let Some(receipt) = receipt else { + return Ok(None); + }; + if !receipt.status() { + bail!("tx {} failed on-chain (status = 0)", tx_hash); + } + Ok(extract_initialize_escrow_hash_from_receipt(&receipt, &contract_address)) + } + async fn gateway_get_min_challenge_amount_sats(&self) -> anyhow::Result { let gateway = self.get_gateway()?; Ok(gateway.minChallengeAmountSats().call().await?) diff --git a/crates/client/src/goat_chain/mock_goat_adaptor.rs b/crates/client/src/goat_chain/mock_goat_adaptor.rs index 11baac28..7683147d 100644 --- a/crates/client/src/goat_chain/mock_goat_adaptor.rs +++ b/crates/client/src/goat_chain/mock_goat_adaptor.rs @@ -1,6 +1,6 @@ use crate::goat_chain::chain_adaptor::*; use crate::utils::generate_random_bytes; -use alloy::primitives::{Address, TxHash, U256}; +use alloy::primitives::{Address, Bytes, TxHash, U256}; use alloy::rpc::types::{ TransactionReceipt, trace::geth::{GethDebugTracingOptions, GethTrace, NoopFrame}, @@ -96,6 +96,30 @@ impl ChainAdaptor for MockAdaptor { Ok(GethTrace::NoopTracer(NoopFrame::default())) } + async fn swap_initialize( + &self, + _contract_address: Address, + _escrow: SwapEscrowData, + _signature: Bytes, + _timeout: U256, + _extra_data: Bytes, + _value_wei: U256, + _max_wait_secs: u64, + ) -> anyhow::Result { + Ok(SwapInitializeResult { + tx_hash: format!("0x{}", hex::encode(generate_random_bytes(32))), + escrow_hash: format!("0x{}", hex::encode(generate_random_bytes(32))), + }) + } + + async fn extract_initialize_escrow_hash_from_tx( + &self, + _tx_hash: &str, + _contract_address: Address, + ) -> anyhow::Result> { + Ok(Some(format!("0x{}", "11".repeat(32)))) + } + async fn gateway_get_min_challenge_amount_sats(&self) -> anyhow::Result { Ok(if let Ok(h) = self.gateway_contract_config.lock() { h.min_challenge_amount_sats diff --git a/crates/client/src/goat_chain/mod.rs b/crates/client/src/goat_chain/mod.rs index b5805fa5..30dafbf2 100644 --- a/crates/client/src/goat_chain/mod.rs +++ b/crates/client/src/goat_chain/mod.rs @@ -1,6 +1,6 @@ // Gateway rate multiplier constant const GATEWAY_RATE_MULTIPLIER: u64 = 10000; -use alloy::primitives::{Address, U256}; +use alloy::primitives::{Address, Bytes, U256}; use alloy::rpc::types::{ TransactionReceipt, trace::geth::{GethDebugTracingOptions, GethTrace}, @@ -14,7 +14,8 @@ pub mod utils; use crate::btc_chain::{BTCClient, MerkleProofExtend}; pub use chain_adaptor::{ BitcoinTx, BitcoinTxProof, GoatNetwork, GraphData, PeginData, PeginStatus, - SequencerSetUpdateWitness, WithdrawData, WithdrawStatus, get_chain_adaptor, + SequencerSetUpdateWitness, SwapEscrowData, SwapInitializeResult, WithdrawData, WithdrawStatus, + get_chain_adaptor, }; pub use goat_adaptor::GoatInitConfig; mod chain_adaptor; @@ -82,6 +83,37 @@ impl GOATClient { self.chain_service.debug_trace_tx(tx_hash, trace_options).await } + pub async fn swap_initialize( + &self, + contract_address: Address, + escrow: SwapEscrowData, + signature: Bytes, + timeout: U256, + extra_data: Bytes, + value_wei: U256, + max_wait_secs: u64, + ) -> anyhow::Result { + self.chain_service + .swap_initialize( + contract_address, + escrow, + signature, + timeout, + extra_data, + value_wei, + max_wait_secs, + ) + .await + } + + pub async fn extract_initialize_escrow_hash_from_tx( + &self, + tx_hash: &str, + contract_address: Address, + ) -> anyhow::Result> { + self.chain_service.extract_initialize_escrow_hash_from_tx(tx_hash, contract_address).await + } + pub async fn is_committee_member(&self) -> anyhow::Result { let addr = self.get_default_signer_address(); self.committee_mana_is_committee_member(&addr).await diff --git a/node/Cargo.toml b/node/Cargo.toml index 8ea86a08..c9bd33e0 100644 --- a/node/Cargo.toml +++ b/node/Cargo.toml @@ -27,6 +27,10 @@ path = "src/bin/send_rbf.rs" name = "pegout" path = "src/bin/send_pegout.rs" +[[bin]] +name = "bridge-out" +path = "src/bin/send_bridge_out.rs" + [[bin]] name = "update-db" path = "src/bin/db_inject.rs" @@ -114,4 +118,4 @@ store = { workspace = true } client = { workspace = true } bitcoin-light-client-circuit = { workspace = true } commit-chain = { workspace = true } -cbft-rpc = { workspace = true } \ No newline at end of file +cbft-rpc = { workspace = true } diff --git a/node/src/bin/send_bridge_out.rs b/node/src/bin/send_bridge_out.rs new file mode 100644 index 00000000..22e936e7 --- /dev/null +++ b/node/src/bin/send_bridge_out.rs @@ -0,0 +1,1052 @@ +//! bridge-out: user bridge-out helper (init-tag + escrow-data + swap-initialize) via the node API. +//! +//! Modes: +//! - init-tag: initialize a bridge-out instance record +//! - escrow-data: query escrow data by instance id +//! - swap-initialize: call swap contract initialize and derive escrow hash from tx logs +//! +//! The node API must be reachable at --rpc-url for init-tag/escrow-data. +//! The GOAT RPC/private key environment must be configured for swap-initialize. + +use alloy::eips::BlockNumberOrTag; +use alloy::primitives::{Address as EvmAddress, Bytes, U256}; +use alloy::providers::{Provider, ProviderBuilder}; +use anyhow::{Context, Result, anyhow, bail}; +use bitvm2_noded::env::{ + ENV_GOAT_PRIVATE_KEY, ENV_GOAT_SWAP_CONTRACT_ADDRESS, get_goat_network, goat_config_from_env, +}; +use clap::{Parser, Subcommand}; +use client::goat_chain::{GOATClient, SwapEscrowData}; +use serde::{Deserialize, Serialize}; +use serde_json::Value as JsonValue; +use std::str::FromStr; +use std::time::{SystemTime, UNIX_EPOCH}; +use uuid::Uuid; +const PAY_IN_FLAG: u64 = 0x02; +const ZERO_B256_HEX: &str = "0x0000000000000000000000000000000000000000000000000000000000000000"; +const DEFAULT_NONCE_TIME_OFFSET_SECS: u64 = 700_000_000; +const DEFAULT_NONCE_RANDOM_BITS: u32 = 24; +const DEFAULT_PRIORITY_FEE_WEI: u64 = 1_000_000_000; +const DEFAULT_MAX_BASE_FEE_WEI: u64 = 500_000_000_000; +const DEFAULT_BASE_FEE_MULTIPLIER_NUM: u64 = 125; +const DEFAULT_BASE_FEE_MULTIPLIER_DEN: u64 = 100; +const PEG_BTC_DECIMALS: usize = 18; + +#[derive(Parser, Debug)] +#[command( + name = "bridge-out", + version, + about = "Bridge-out helper (via node API and GOAT RPC)", + long_about = "Initialize bridge-out tag, query escrow data, and call swap initialize with escrow hash extraction from tx logs." +)] +struct Args { + /// Node API base URL + #[arg(long, default_value = "http://localhost:8080")] + rpc_url: String, + + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand, Debug)] +enum Commands { + /// Initialize bridge-out tag + InitTag { + /// Source GOAT address + #[arg(long)] + from_addr: String, + + /// Destination BTC address + #[arg(long)] + to_addr: String, + + /// Escrow hash (0x-prefixed 32-byte hex) + #[arg(long)] + escrow_hash: Option, + + /// Swap initialize tx hash (0x-prefixed 32-byte hex), used to auto-derive escrow hash from logs + #[arg(long)] + swap_init_tx_hash: Option, + + /// GOAT swap contract address; fallback to GOAT_SWAP_CONTRACT_ADDRESS + #[arg(long)] + contract_address: Option, + }, + + /// Query escrow data by instance id + EscrowData { + /// Instance id + #[arg(long)] + instance_id: Uuid, + }, + + /// Fetch escrow params from payInvoice, call swap initialize, and print escrow hash from tx logs + SwapInitialize { + /// payInvoice endpoint URL (chain query included) + #[arg( + long, + default_value = "https://152-32-185-32.nodes.atomiq.exchange:8443/tobtc/payInvoice?chain=GOAT" + )] + pay_invoice_url: String, + + /// BTC receive address, sent to payInvoice as `address` + #[arg(long)] + btc_address: String, + + /// Requested amount in pegBTC (human-readable, e.g. 0.01). Converted to base units automatically. + #[arg(long)] + amount: String, + + /// Whether this quote is exact-in + #[arg(long, default_value_t = true, action = clap::ArgAction::Set)] + exact_in: bool, + + /// Bitcoin confirmation target + #[arg(long, default_value_t = 3)] + confirmation_target: u32, + + /// Required confirmations + #[arg(long, default_value_t = 2)] + confirmations: u32, + + /// Token address + #[arg(long)] + token: String, + + /// Offerer GOAT address + #[arg(long)] + offerer: String, + + /// Additional params JSON object merged into payInvoice body + #[arg(long)] + additional_params_json: Option, + + /// GOAT private key; fallback to GOAT_PRIVATE_KEY + #[arg(long, env = ENV_GOAT_PRIVATE_KEY)] + goat_private_key: Option, + + /// GOAT swap contract address; fallback to GOAT_SWAP_CONTRACT_ADDRESS + #[arg(long)] + contract_address: Option, + + /// Max seconds to wait for tx receipt + #[arg(long, default_value_t = 60)] + max_wait_secs: u64, + }, +} + +#[derive(Debug, Serialize)] +struct BridgeOutInitTagApiRequest { + contract_address: String, + from_addr: String, + to_addr: String, + escrow_hash: String, +} + +#[derive(Debug, Deserialize)] +struct EscrowDataApiResponse { + instance_id: String, + escrow: Option, + error: Option, +} + +#[derive(Debug, Deserialize)] +struct ApiError { + error: String, + message: String, +} + +#[derive(Debug, Deserialize)] +struct PayInvoiceEnvelope { + code: i64, + msg: String, + data: Option, +} + +#[derive(Debug, Deserialize)] +struct PayInvoiceResponseData { + data: PayInvoiceEscrowRaw, + prefix: String, + timeout: String, + signature: String, +} + +#[derive(Debug, Deserialize)] +struct PayInvoiceEscrowRaw { + offerer: String, + claimer: String, + token: String, + #[serde(rename = "refundHandler")] + refund_handler: String, + #[serde(rename = "claimHandler")] + claim_handler: String, + #[serde(rename = "payOut")] + pay_out: bool, + #[serde(rename = "payIn")] + pay_in: bool, + reputation: bool, + sequence: JsonValue, + #[serde(rename = "claimData")] + claim_data: String, + #[serde(rename = "refundData")] + refund_data: String, + amount: JsonValue, + #[serde(rename = "depositToken")] + deposit_token: String, + #[serde(rename = "securityDeposit")] + security_deposit: JsonValue, + #[serde(rename = "claimerBounty")] + claimer_bounty: JsonValue, + #[allow(dead_code)] + kind: Option, + #[serde(rename = "extraData")] + extra_data: Option, + #[serde(rename = "successActionCommitment")] + success_action_commitment: Option, +} + +#[derive(Debug)] +enum EscrowHashInput { + Provided(String), + FromSwapInitTx(String), +} + +fn join_base_path(base_url: &str, path: &str) -> String { + format!("{}{}", base_url.trim_end_matches('/'), path) +} + +fn bridge_out_init_tag_url(base_url: &str) -> String { + join_base_path(base_url, "/v1/instances/bridge-out-init-tag") +} + +fn escrow_data_url(base_url: &str, instance_id: &Uuid) -> String { + join_base_path(base_url, &format!("/v1/instances/{instance_id}/escrow-data")) +} + +fn parse_api_error(body: &str) -> Option { + serde_json::from_str::(body).ok() +} + +fn resolve_contract_address(contract_address: Option) -> Result { + if let Some(addr) = contract_address { + return Ok(addr); + } + + std::env::var(ENV_GOAT_SWAP_CONTRACT_ADDRESS).map_err(|_| { + anyhow!( + "missing --contract-address and env {}, one of them is required", + ENV_GOAT_SWAP_CONTRACT_ADDRESS + ) + }) +} + +fn normalize_32byte_hex(value: &str, field_name: &str) -> Result { + let hex = value.strip_prefix("0x").unwrap_or(value); + let bytes = + hex::decode(hex).map_err(|e| anyhow!("{field_name} is not a valid hex string: {e}"))?; + if bytes.len() != 32 { + bail!("{field_name} must be exactly 32 bytes, got {}", bytes.len()); + } + Ok(format!("0x{}", hex::encode(bytes))) +} + +fn parse_hex_bytes(value: &str, field_name: &str) -> Result> { + let hex = value.strip_prefix("0x").unwrap_or(value); + if hex.is_empty() { + return Ok(vec![]); + } + let bytes = + hex::decode(hex).map_err(|e| anyhow!("{field_name} is not a valid hex string: {e}"))?; + Ok(bytes) +} + +fn parse_bytes32(value: &str, field_name: &str) -> Result<[u8; 32]> { + let normalized = normalize_32byte_hex(value, field_name)?; + let hex = normalized.trim_start_matches("0x"); + let bytes = hex::decode(hex).map_err(|e| anyhow!("{field_name} decode failed: {e}"))?; + let mut out = [0u8; 32]; + out.copy_from_slice(&bytes); + Ok(out) +} + +fn parse_u256(value: &str, field_name: &str) -> Result { + if let Some(hex) = value.strip_prefix("0x") { + let bytes = + hex::decode(hex).map_err(|e| anyhow!("{field_name} is not valid hex u256: {e}"))?; + if bytes.len() > 32 { + bail!("{field_name} exceeds 32 bytes for u256"); + } + let mut padded = [0u8; 32]; + padded[32 - bytes.len()..].copy_from_slice(&bytes); + return Ok(U256::from_be_bytes(padded)); + } + + U256::from_str(value).map_err(|e| anyhow!("{field_name} is not valid decimal u256: {e}")) +} + +fn parse_u256_from_json_value(value: &JsonValue, field_name: &str) -> Result { + match value { + JsonValue::String(v) => parse_u256(v, field_name), + JsonValue::Number(v) => parse_u256(&v.to_string(), field_name), + _ => bail!("{field_name} must be a string or number"), + } +} + +fn convert_pegbtc_amount_to_base_units(amount: &str) -> Result { + let amount = amount.trim(); + if amount.is_empty() { + bail!("amount must not be empty"); + } + if amount.starts_with('-') { + bail!("amount must be non-negative"); + } + + let mut parts = amount.split('.'); + let int_part = parts.next().unwrap_or_default(); + let frac_part = parts.next(); + if parts.next().is_some() { + bail!("amount must be a valid decimal number"); + } + + let int_part = if int_part.is_empty() { "0" } else { int_part }; + if !int_part.chars().all(|c| c.is_ascii_digit()) { + bail!("amount integer part must contain digits only"); + } + + let frac_part = frac_part.unwrap_or(""); + if !frac_part.chars().all(|c| c.is_ascii_digit()) { + bail!("amount fractional part must contain digits only"); + } + if frac_part.len() > PEG_BTC_DECIMALS { + bail!("amount has too many decimal places, max {}", PEG_BTC_DECIMALS); + } + + let mut base_units = String::with_capacity(int_part.len() + PEG_BTC_DECIMALS); + base_units.push_str(int_part); + base_units.push_str(frac_part); + base_units.push_str(&"0".repeat(PEG_BTC_DECIMALS - frac_part.len())); + let normalized = base_units.trim_start_matches('0'); + let normalized = if normalized.is_empty() { "0" } else { normalized }; + + U256::from_str(normalized) + .map_err(|e| anyhow!("amount out of range for u256 after conversion: {e}"))?; + Ok(normalized.to_string()) +} + +fn generate_default_nonce(unix_seconds: u64, random_24_bits: u32) -> String { + let timestamp_part = unix_seconds.saturating_sub(DEFAULT_NONCE_TIME_OFFSET_SECS); + let random_part = u64::from(random_24_bits & 0x00ff_ffff); + ((timestamp_part << DEFAULT_NONCE_RANDOM_BITS) | random_part).to_string() +} + +fn generate_default_fee_rate(base_fee_per_gas: U256) -> String { + let mut adjusted_base_fee = (base_fee_per_gas * U256::from(DEFAULT_BASE_FEE_MULTIPLIER_NUM)) + / U256::from(DEFAULT_BASE_FEE_MULTIPLIER_DEN); + let max_base_fee = U256::from(DEFAULT_MAX_BASE_FEE_WEI); + if adjusted_base_fee > max_base_fee { + adjusted_base_fee = max_base_fee; + } + format!("{},{}", adjusted_base_fee, U256::from(DEFAULT_PRIORITY_FEE_WEI)) +} + +async fn generate_default_nonce_and_fee_rate() -> Result<(String, String)> { + let unix_seconds = SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("system clock is before UNIX_EPOCH")? + .as_secs(); + let nonce = generate_default_nonce(unix_seconds, rand::random::()); + + let cfg = goat_config_from_env().await; + let provider = ProviderBuilder::new().connect_http(cfg.rpc_url.clone()); + let block = provider + .get_block_by_number(BlockNumberOrTag::Latest) + .await + .with_context(|| format!("failed to get latest block from GOAT RPC {}", cfg.rpc_url))? + .ok_or_else(|| anyhow!("latest block not found on GOAT RPC {}", cfg.rpc_url))?; + let base_fee_per_gas = block.header.base_fee_per_gas.ok_or_else(|| { + anyhow!("latest block missing base_fee_per_gas on GOAT RPC {}", cfg.rpc_url) + })?; + let fee_rate = generate_default_fee_rate(U256::from(base_fee_per_gas)); + Ok((nonce, fee_rate)) +} + +fn parse_json_object(value: &str, field_name: &str) -> Result> { + let parsed: JsonValue = + serde_json::from_str(value).map_err(|e| anyhow!("{field_name} is not valid JSON: {e}"))?; + match parsed { + JsonValue::Object(map) => Ok(map), + _ => bail!("{field_name} must be a JSON object"), + } +} + +fn parse_extra_data_from_json(value: Option<&JsonValue>) -> Result { + match value { + None | Some(JsonValue::Null) => Ok(Bytes::default()), + Some(JsonValue::String(v)) => Ok(Bytes::from(parse_hex_bytes(v, "extraData")?)), + _ => bail!("extraData must be null or hex string"), + } +} + +fn build_flags(sequence: U256, pay_out: bool, pay_in: bool, reputation: bool) -> Result { + if sequence > (U256::MAX >> 64) { + bail!("sequence is too large, must fit within 192 bits"); + } + let mut flags = sequence << 64; + if pay_out { + flags += U256::from(1u64); + } + if pay_in { + flags += U256::from(PAY_IN_FLAG); + } + if reputation { + flags += U256::from(4u64); + } + Ok(flags) +} + +fn should_pay_in(flags: U256) -> bool { + (flags & U256::from(PAY_IN_FLAG)) == U256::from(PAY_IN_FLAG) +} + +fn requires_token_approval(sender: EvmAddress, escrow: &SwapEscrowData) -> bool { + let native_token = EvmAddress::ZERO; + should_pay_in(escrow.flags) && escrow.offerer == sender && escrow.token != native_token +} + +fn compute_initialize_value_wei(sender: EvmAddress, escrow: &SwapEscrowData) -> U256 { + let native_token = EvmAddress::ZERO; + let mut value = U256::ZERO; + if should_pay_in(escrow.flags) && escrow.offerer == sender && escrow.token == native_token { + value += escrow.amount; + } + if escrow.deposit_token == native_token { + let total_deposit = if escrow.security_deposit > escrow.claimer_bounty { + escrow.security_deposit + } else { + escrow.claimer_bounty + }; + value += total_deposit; + } + value +} + +async fn ensure_token_approval( + goat_client: &GOATClient, + sender: EvmAddress, + swap_contract: EvmAddress, + escrow: &SwapEscrowData, +) -> Result<()> { + if !requires_token_approval(sender, escrow) { + return Ok(()); + } + + let owner = sender.into_array(); + let spender = swap_contract.into_array(); + let allowance = goat_client + .peg_btc_allowance(&owner, &spender) + .await + .context("failed to query token allowance before swap initialize")?; + if allowance >= escrow.amount { + eprintln!( + "token allowance already sufficient, allowance={}, required={}", + allowance, escrow.amount + ); + return Ok(()); + } + + eprintln!( + "token allowance insufficient, approving token spend now, allowance={}, required={}", + allowance, escrow.amount + ); + let approve_tx_hash = goat_client + .peg_btc_approve(&spender, escrow.amount) + .await + .context("failed to send token approve tx before swap initialize")?; + eprintln!("token approve submitted: {}", approve_tx_hash); + + let latest_allowance = goat_client + .peg_btc_allowance(&owner, &spender) + .await + .context("failed to re-check token allowance after approve")?; + if latest_allowance < escrow.amount { + bail!( + "token allowance still insufficient after approve, allowance={}, required={}", + latest_allowance, + escrow.amount + ); + } + eprintln!("token allowance after approve: {}, required={}", latest_allowance, escrow.amount); + Ok(()) +} + +fn resolve_escrow_hash_input( + escrow_hash: Option, + swap_init_tx_hash: Option, +) -> Result { + match (escrow_hash, swap_init_tx_hash) { + (Some(escrow_hash), _) => { + Ok(EscrowHashInput::Provided(normalize_32byte_hex(&escrow_hash, "escrow_hash")?)) + } + (None, Some(tx_hash)) => Ok(EscrowHashInput::FromSwapInitTx(normalize_32byte_hex( + &tx_hash, + "swap_init_tx_hash", + )?)), + (None, None) => { + bail!("one of --escrow-hash or --swap-init-tx-hash must be provided") + } + } +} + +async fn derive_escrow_hash_from_swap_init_tx( + swap_init_tx_hash: &str, + contract_address: &str, +) -> Result { + let swap_contract_address = EvmAddress::from_str(contract_address) + .map_err(|e| anyhow!("invalid swap contract address {contract_address}: {e}"))?; + let goat_client = GOATClient::new(goat_config_from_env().await, get_goat_network()); + let escrow_hash = goat_client + .extract_initialize_escrow_hash_from_tx(swap_init_tx_hash, swap_contract_address) + .await + .with_context(|| format!("failed to parse initialize log from tx {swap_init_tx_hash}"))?; + escrow_hash.ok_or_else(|| { + anyhow!( + "Initialize event with escrowHash not found in tx {} logs for contract {}", + swap_init_tx_hash, + contract_address + ) + }) +} + +async fn submit_swap_initialize_tx( + goat_private_key: Option, + contract_address: Option, + max_wait_secs: u64, + escrow: SwapEscrowData, + signature: Bytes, + timeout: U256, + extra_data: Bytes, +) -> Result<(String, String)> { + let contract_address = resolve_contract_address(contract_address)?; + let swap_contract = EvmAddress::from_str(&contract_address) + .map_err(|e| anyhow!("invalid swap contract address {contract_address}: {e}"))?; + + let mut cfg = goat_config_from_env().await; + if goat_private_key.is_none() && cfg.private_key.is_none() { + bail!("missing GOAT private key (--goat-private-key or GOAT_PRIVATE_KEY)"); + } + if goat_private_key.is_some() { + cfg = cfg.with_private_key(goat_private_key); + } + cfg = cfg.with_peg_btc_address(Some(escrow.token)); + let goat_client = GOATClient::new(cfg, get_goat_network()); + let sender = goat_client.get_default_signer_address(); + ensure_token_approval(&goat_client, sender, swap_contract, &escrow).await?; + let value_wei = compute_initialize_value_wei(sender, &escrow); + let result = goat_client + .swap_initialize( + swap_contract, + escrow, + signature, + timeout, + extra_data, + value_wei, + max_wait_secs, + ) + .await?; + Ok((result.tx_hash, result.escrow_hash)) +} + +async fn call_pay_invoice( + client: &reqwest::Client, + args: &Commands, +) -> Result<(PayInvoiceResponseData, Option, Option, u64)> { + let Commands::SwapInitialize { + pay_invoice_url, + btc_address, + amount, + exact_in, + confirmation_target, + confirmations, + token, + offerer, + additional_params_json, + goat_private_key, + contract_address, + max_wait_secs, + } = args + else { + bail!("invalid command for call_pay_invoice"); + }; + + let (nonce, fee_rate) = generate_default_nonce_and_fee_rate().await?; + let amount_base_units = convert_pegbtc_amount_to_base_units(amount)?; + eprintln!("auto-generated nonce: {}", nonce); + eprintln!("auto-generated feeRate: {}", fee_rate); + eprintln!("converted amount (pegBTC -> base units): {}", amount_base_units); + + let mut body = serde_json::Map::new(); + if let Some(params_json) = additional_params_json { + body.extend(parse_json_object(params_json, "additional_params_json")?); + } + body.insert("address".to_string(), JsonValue::String(btc_address.clone())); + body.insert("amount".to_string(), JsonValue::String(amount_base_units)); + body.insert("exactIn".to_string(), JsonValue::Bool(*exact_in)); + body.insert("confirmationTarget".to_string(), JsonValue::from(*confirmation_target)); + body.insert("confirmations".to_string(), JsonValue::from(*confirmations)); + body.insert("nonce".to_string(), JsonValue::String(nonce)); + body.insert("token".to_string(), JsonValue::String(token.clone())); + body.insert("offerer".to_string(), JsonValue::String(offerer.clone())); + body.insert("feeRate".to_string(), JsonValue::String(fee_rate)); + + let resp = client + .post(pay_invoice_url) + .json(&JsonValue::Object(body)) + .send() + .await + .with_context(|| format!("failed to reach payInvoice API at {pay_invoice_url}"))?; + + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + if !status.is_success() { + if let Some(api_err) = parse_api_error(&text) { + bail!("payInvoice API error ({}): {}", api_err.error, api_err.message); + } + bail!("payInvoice API returned {status}: {text}"); + } + + let envelope: PayInvoiceEnvelope = + serde_json::from_str(&text).context("failed to parse payInvoice response body")?; + if envelope.code != 20000 { + bail!("payInvoice business error (code {}): {}", envelope.code, envelope.msg); + } + + let data = envelope.data.ok_or_else(|| anyhow!("payInvoice response missing data"))?; + + Ok((data, goat_private_key.clone(), contract_address.clone(), *max_wait_secs)) +} + +fn parse_escrow_from_pay_invoice( + quote: &PayInvoiceResponseData, +) -> Result<(SwapEscrowData, Bytes, U256, Bytes)> { + let escrow_raw = "e.data; + let sequence = parse_u256_from_json_value(&escrow_raw.sequence, "sequence")?; + let flags = + build_flags(sequence, escrow_raw.pay_out, escrow_raw.pay_in, escrow_raw.reputation)?; + + let success_action_commitment = + escrow_raw.success_action_commitment.as_deref().unwrap_or(ZERO_B256_HEX); + let escrow = SwapEscrowData { + offerer: EvmAddress::from_str(&escrow_raw.offerer) + .map_err(|e| anyhow!("invalid offerer address {}: {e}", escrow_raw.offerer))?, + claimer: EvmAddress::from_str(&escrow_raw.claimer) + .map_err(|e| anyhow!("invalid claimer address {}: {e}", escrow_raw.claimer))?, + amount: parse_u256_from_json_value(&escrow_raw.amount, "amount")?, + token: EvmAddress::from_str(&escrow_raw.token) + .map_err(|e| anyhow!("invalid token address {}: {e}", escrow_raw.token))?, + flags, + claim_handler: EvmAddress::from_str(&escrow_raw.claim_handler).map_err(|e| { + anyhow!("invalid claimHandler address {}: {e}", escrow_raw.claim_handler) + })?, + claim_data: parse_bytes32(&escrow_raw.claim_data, "claimData")?, + refund_handler: EvmAddress::from_str(&escrow_raw.refund_handler).map_err(|e| { + anyhow!("invalid refundHandler address {}: {e}", escrow_raw.refund_handler) + })?, + refund_data: parse_bytes32(&escrow_raw.refund_data, "refundData")?, + security_deposit: parse_u256_from_json_value( + &escrow_raw.security_deposit, + "securityDeposit", + )?, + claimer_bounty: parse_u256_from_json_value(&escrow_raw.claimer_bounty, "claimerBounty")?, + deposit_token: EvmAddress::from_str(&escrow_raw.deposit_token).map_err(|e| { + anyhow!("invalid depositToken address {}: {e}", escrow_raw.deposit_token) + })?, + success_action_commitment: parse_bytes32( + success_action_commitment, + "successActionCommitment", + )?, + }; + let signature = Bytes::from(parse_hex_bytes("e.signature, "signature")?); + let timeout = parse_u256("e.timeout, "timeout")?; + let extra_data = parse_extra_data_from_json(escrow_raw.extra_data.as_ref())?; + Ok((escrow, signature, timeout, extra_data)) +} + +async fn run_swap_initialize_from_pay_invoice( + client: &reqwest::Client, + args: &Commands, +) -> Result<(String, String)> { + let (quote, goat_private_key, contract_address, max_wait_secs) = + call_pay_invoice(client, args).await?; + eprintln!("payInvoice prefix: {}", quote.prefix); + let (escrow, signature, timeout, extra_data) = parse_escrow_from_pay_invoice("e)?; + submit_swap_initialize_tx( + goat_private_key, + contract_address, + max_wait_secs, + escrow, + signature, + timeout, + extra_data, + ) + .await +} + +async fn call_bridge_out_init_tag( + client: &reqwest::Client, + base_url: &str, + body: &BridgeOutInitTagApiRequest, +) -> Result<()> { + let url = bridge_out_init_tag_url(base_url); + let resp = client + .put(&url) + .json(body) + .send() + .await + .with_context(|| format!("failed to reach node API at {url}"))?; + + if !resp.status().is_success() { + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + if let Some(api_err) = parse_api_error(&text) { + bail!("API error ({}): {}", api_err.error, api_err.message); + } + bail!("API returned {status}: {text}"); + } + + Ok(()) +} + +async fn call_get_escrow_data( + client: &reqwest::Client, + base_url: &str, + instance_id: &Uuid, +) -> Result { + let url = escrow_data_url(base_url, instance_id); + let resp = client + .get(&url) + .send() + .await + .with_context(|| format!("failed to reach node API at {url}"))?; + + if !resp.status().is_success() { + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + if let Some(api_err) = parse_api_error(&text) { + bail!("API error ({}): {}", api_err.error, api_err.message); + } + bail!("API returned {status}: {text}"); + } + + resp.json().await.context("failed to parse escrow data response") +} + +#[tokio::main] +async fn main() -> Result<()> { + let args = Args::parse(); + let client = reqwest::Client::new(); + + match &args.command { + Commands::InitTag { + from_addr, + to_addr, + escrow_hash, + swap_init_tx_hash, + contract_address, + } => { + let contract_address = resolve_contract_address(contract_address.clone())?; + if escrow_hash.is_some() && swap_init_tx_hash.is_some() { + eprintln!( + "both --escrow-hash and --swap-init-tx-hash provided, using --escrow-hash" + ); + } + let escrow_hash = + match resolve_escrow_hash_input(escrow_hash.clone(), swap_init_tx_hash.clone())? { + EscrowHashInput::Provided(escrow_hash) => escrow_hash, + EscrowHashInput::FromSwapInitTx(swap_init_tx_hash) => { + let tx_hash = + normalize_32byte_hex(&swap_init_tx_hash, "swap_init_tx_hash")?; + let derived = + derive_escrow_hash_from_swap_init_tx(&tx_hash, &contract_address) + .await?; + eprintln!("derived escrow hash from swap init tx {}: {}", tx_hash, derived); + derived + } + }; + + let body = BridgeOutInitTagApiRequest { + contract_address, + from_addr: from_addr.clone(), + to_addr: to_addr.clone(), + escrow_hash, + }; + + call_bridge_out_init_tag(&client, &args.rpc_url, &body).await?; + println!("bridge-out init-tag submitted successfully"); + } + Commands::EscrowData { instance_id } => { + let resp = call_get_escrow_data(&client, &args.rpc_url, instance_id).await?; + println!("instance_id: {}", resp.instance_id); + println!("escrow: {}", resp.escrow.as_deref().unwrap_or("null")); + println!("error: {}", resp.error.as_deref().unwrap_or("null")); + } + Commands::SwapInitialize { .. } => { + let (tx_hash, escrow_hash) = + run_swap_initialize_from_pay_invoice(&client, &args.command).await?; + println!("swap initialize submitted: {}", tx_hash); + println!("escrow_hash (from Initialize log): {}", escrow_hash); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + #[test] + fn test_bridge_out_init_tag_url() { + assert_eq!( + bridge_out_init_tag_url("http://localhost:8080/"), + "http://localhost:8080/v1/instances/bridge-out-init-tag" + ); + } + + #[test] + fn test_escrow_data_url() { + let instance_id = Uuid::parse_str("123e4567-e89b-12d3-a456-426614174000").unwrap(); + assert_eq!( + escrow_data_url("http://localhost:8080", &instance_id), + "http://localhost:8080/v1/instances/123e4567-e89b-12d3-a456-426614174000/escrow-data" + ); + } + + #[test] + fn test_parse_api_error() { + let body = r#"{"error":"PUT_BRIDGE_OUT_INIT_TAG_ERROR","message":"invalid"}"#; + let parsed = parse_api_error(body); + assert!(parsed.is_some()); + let parsed = parsed.unwrap(); + assert_eq!(parsed.error, "PUT_BRIDGE_OUT_INIT_TAG_ERROR"); + assert_eq!(parsed.message, "invalid"); + } + + #[test] + fn test_resolve_contract_address_arg_priority() { + let _guard = ENV_LOCK.lock().unwrap(); + unsafe { + std::env::set_var( + ENV_GOAT_SWAP_CONTRACT_ADDRESS, + "0x2222222222222222222222222222222222222222", + ); + } + let value = resolve_contract_address(Some( + "0x1111111111111111111111111111111111111111".to_string(), + )) + .unwrap(); + assert_eq!(value, "0x1111111111111111111111111111111111111111"); + } + + #[test] + fn test_resolve_contract_address_from_env() { + let _guard = ENV_LOCK.lock().unwrap(); + unsafe { + std::env::set_var( + ENV_GOAT_SWAP_CONTRACT_ADDRESS, + "0x3333333333333333333333333333333333333333", + ); + } + let value = resolve_contract_address(None).unwrap(); + assert_eq!(value, "0x3333333333333333333333333333333333333333"); + } + + #[test] + fn test_resolve_contract_address_missing() { + let _guard = ENV_LOCK.lock().unwrap(); + unsafe { + std::env::remove_var(ENV_GOAT_SWAP_CONTRACT_ADDRESS); + } + let err = resolve_contract_address(None).unwrap_err().to_string(); + assert!(err.contains("GOAT_SWAP_CONTRACT_ADDRESS")); + } + + #[test] + fn test_resolve_escrow_hash_input_uses_provided() { + let input = resolve_escrow_hash_input( + Some(format!("0x{}", "11".repeat(32))), + Some(format!("0x{}", "22".repeat(32))), + ) + .unwrap(); + match input { + EscrowHashInput::Provided(v) => assert_eq!(v, format!("0x{}", "11".repeat(32))), + EscrowHashInput::FromSwapInitTx(_) => panic!("expected provided escrow hash"), + } + } + + #[test] + fn test_resolve_escrow_hash_input_from_tx() { + let input = + resolve_escrow_hash_input(None, Some(format!("0x{}", "aa".repeat(32)))).unwrap(); + match input { + EscrowHashInput::FromSwapInitTx(v) => assert_eq!(v, format!("0x{}", "aa".repeat(32))), + EscrowHashInput::Provided(_) => panic!("expected tx hash source"), + } + } + + #[test] + fn test_resolve_escrow_hash_input_missing() { + let err = resolve_escrow_hash_input(None, None).unwrap_err().to_string(); + assert!(err.contains("--escrow-hash")); + } + + #[test] + fn test_resolve_escrow_hash_input_invalid_len() { + let err = + resolve_escrow_hash_input(Some("0x12".to_string()), None).unwrap_err().to_string(); + assert!(err.contains("exactly 32 bytes")); + } + + #[test] + fn test_parse_u256_decimal_and_hex() { + assert_eq!(parse_u256("10", "amount").unwrap(), U256::from(10u64)); + assert_eq!(parse_u256("0x0a", "amount").unwrap(), U256::from(10u64)); + } + + #[test] + fn test_parse_u256_from_json_value() { + assert_eq!( + parse_u256_from_json_value(&JsonValue::String("10".to_string()), "amount").unwrap(), + U256::from(10u64) + ); + assert_eq!( + parse_u256_from_json_value(&JsonValue::from(15u64), "amount").unwrap(), + U256::from(15u64) + ); + } + + #[test] + fn test_build_flags() { + let flags = build_flags(U256::from(7u64), true, true, false).unwrap(); + let expected = (U256::from(7u64) << 64) + U256::from(3u64); + assert_eq!(flags, expected); + assert!(should_pay_in(flags)); + } + + #[test] + fn test_generate_default_nonce() { + let nonce = generate_default_nonce(700_000_001, 0x12_34_56); + let expected = ((1u64 << 24) | 0x12_34_56u64).to_string(); + assert_eq!(nonce, expected); + } + + #[test] + fn test_generate_default_nonce_masks_high_bits() { + let nonce = generate_default_nonce(700_000_001, 0xab_cd_ef_12); + let expected = ((1u64 << 24) | 0xcd_ef_12u64).to_string(); + assert_eq!(nonce, expected); + } + + #[test] + fn test_generate_default_fee_rate() { + let fee_rate = generate_default_fee_rate(U256::from(100u64)); + assert_eq!(fee_rate, "125,1000000000"); + } + + #[test] + fn test_generate_default_fee_rate_with_cap() { + let fee_rate = generate_default_fee_rate(U256::from(1_000_000_000_000u64)); + assert_eq!(fee_rate, "500000000000,1000000000"); + } + + #[test] + fn test_convert_pegbtc_amount_to_base_units() { + assert_eq!(convert_pegbtc_amount_to_base_units("0.01").unwrap(), "10000000000000000"); + assert_eq!(convert_pegbtc_amount_to_base_units("1").unwrap(), "1000000000000000000"); + assert_eq!(convert_pegbtc_amount_to_base_units(".5").unwrap(), "500000000000000000"); + assert_eq!(convert_pegbtc_amount_to_base_units("0").unwrap(), "0"); + } + + #[test] + fn test_convert_pegbtc_amount_to_base_units_invalid_precision() { + let err = + convert_pegbtc_amount_to_base_units("0.1234567890123456789").unwrap_err().to_string(); + assert!(err.contains("too many decimal places")); + } + + #[test] + fn test_compute_initialize_value_wei() { + let offerer = EvmAddress::from_str("0x1111111111111111111111111111111111111111").unwrap(); + let claimer = EvmAddress::from_str("0x2222222222222222222222222222222222222222").unwrap(); + let escrow = SwapEscrowData { + offerer, + claimer, + amount: U256::from(100u64), + token: EvmAddress::ZERO, + flags: U256::from(PAY_IN_FLAG), + claim_handler: EvmAddress::from_str("0x3333333333333333333333333333333333333333") + .unwrap(), + claim_data: [0u8; 32], + refund_handler: EvmAddress::from_str("0x4444444444444444444444444444444444444444") + .unwrap(), + refund_data: [0u8; 32], + security_deposit: U256::from(8u64), + claimer_bounty: U256::from(5u64), + deposit_token: EvmAddress::ZERO, + success_action_commitment: [0u8; 32], + }; + assert_eq!(compute_initialize_value_wei(offerer, &escrow), U256::from(108u64)); + } + + #[test] + fn test_requires_token_approval_true_for_payin_erc20_offerer() { + let sender = EvmAddress::from_str("0x1111111111111111111111111111111111111111").unwrap(); + let escrow = SwapEscrowData { + offerer: sender, + claimer: EvmAddress::from_str("0x2222222222222222222222222222222222222222").unwrap(), + amount: U256::from(100u64), + token: EvmAddress::from_str("0x3333333333333333333333333333333333333333").unwrap(), + flags: U256::from(PAY_IN_FLAG), + claim_handler: EvmAddress::from_str("0x4444444444444444444444444444444444444444") + .unwrap(), + claim_data: [0u8; 32], + refund_handler: EvmAddress::from_str("0x5555555555555555555555555555555555555555") + .unwrap(), + refund_data: [0u8; 32], + security_deposit: U256::ZERO, + claimer_bounty: U256::ZERO, + deposit_token: EvmAddress::ZERO, + success_action_commitment: [0u8; 32], + }; + assert!(requires_token_approval(sender, &escrow)); + } + + #[test] + fn test_requires_token_approval_false_for_native_or_non_payin() { + let sender = EvmAddress::from_str("0x1111111111111111111111111111111111111111").unwrap(); + let mut escrow = SwapEscrowData { + offerer: sender, + claimer: EvmAddress::from_str("0x2222222222222222222222222222222222222222").unwrap(), + amount: U256::from(100u64), + token: EvmAddress::ZERO, + flags: U256::from(PAY_IN_FLAG), + claim_handler: EvmAddress::from_str("0x4444444444444444444444444444444444444444") + .unwrap(), + claim_data: [0u8; 32], + refund_handler: EvmAddress::from_str("0x5555555555555555555555555555555555555555") + .unwrap(), + refund_data: [0u8; 32], + security_deposit: U256::ZERO, + claimer_bounty: U256::ZERO, + deposit_token: EvmAddress::ZERO, + success_action_commitment: [0u8; 32], + }; + assert!(!requires_token_approval(sender, &escrow)); + + escrow.token = EvmAddress::from_str("0x3333333333333333333333333333333333333333").unwrap(); + escrow.flags = U256::ZERO; + assert!(!requires_token_approval(sender, &escrow)); + } +} From 7b030b98531bac20007428e0fd218345de7b23e4 Mon Sep 17 00:00:00 2001 From: Blake Date: Wed, 4 Mar 2026 16:43:32 +0800 Subject: [PATCH 2/5] feat: implement EIP-1559 transaction handling and fee calculation --- crates/client/src/goat_chain/goat_adaptor.rs | 108 ++++++++++++++++++- node/src/bin/send_bridge_out.rs | 8 +- 2 files changed, 110 insertions(+), 6 deletions(-) diff --git a/crates/client/src/goat_chain/goat_adaptor.rs b/crates/client/src/goat_chain/goat_adaptor.rs index c2fe4dde..fecd4e7c 100644 --- a/crates/client/src/goat_chain/goat_adaptor.rs +++ b/crates/client/src/goat_chain/goat_adaptor.rs @@ -32,6 +32,11 @@ use std::time::Duration; use tokio::time; use uuid::Uuid; +const EIP1559_PRIORITY_FEE_WEI: u128 = 5_000_000; +const EIP1559_MAX_BASE_FEE_WEI: u128 = 2_000_000_000; +const EIP1559_BASE_FEE_MULTIPLIER_NUM: u128 = 125; +const EIP1559_BASE_FEE_MULTIPLIER_DEN: u128 = 100; + fn build_goat_rpc_client() -> reqwest::Client { let timeout = Duration::from_secs(get_goat_rpc_timeout_secs()); match reqwest::Client::builder().timeout(timeout).build() { @@ -518,6 +523,38 @@ impl GoatAdaptor { Ok(tx_hash) } + async fn handle_transaction_request_eip1559( + &self, + tx_request: TransactionRequest, + ) -> anyhow::Result { + let (tx_hash, _) = + self.handle_transaction_request_with_wait_eip1559(tx_request, 10).await?; + Ok(tx_hash) + } + + async fn get_eip1559_fee_params(&self) -> anyhow::Result<(u128, u128)> { + let block = self + .provider + .get_block_by_number(BlockNumberOrTag::Latest) + .await? + .ok_or_else(|| format_err!("latest block not found"))?; + let base_fee = u128::from( + block + .header + .base_fee_per_gas + .ok_or_else(|| format_err!("latest block missing base_fee_per_gas"))?, + ); + let mut adjusted_base_fee = base_fee.saturating_mul(EIP1559_BASE_FEE_MULTIPLIER_NUM) + / EIP1559_BASE_FEE_MULTIPLIER_DEN; + if adjusted_base_fee > EIP1559_MAX_BASE_FEE_WEI { + adjusted_base_fee = EIP1559_MAX_BASE_FEE_WEI; + } + let max_fee = adjusted_base_fee + .checked_add(EIP1559_PRIORITY_FEE_WEI) + .ok_or_else(|| format_err!("max_fee_per_gas overflow"))?; + Ok((max_fee, EIP1559_PRIORITY_FEE_WEI)) + } + async fn handle_transaction_request_with_wait( &self, mut tx_request: TransactionRequest, @@ -581,6 +618,73 @@ impl GoatAdaptor { } bail!("tx_hash:{} receipt not found within {} seconds", tx_hash.to_string(), max_wait_secs) } + + async fn handle_transaction_request_with_wait_eip1559( + &self, + mut tx_request: TransactionRequest, + max_wait_secs: u64, + ) -> anyhow::Result<(TxHash, TransactionReceipt)> { + let (max_fee_per_gas, max_priority_fee_per_gas) = self.get_eip1559_fee_params().await?; + tx_request.gas_price = None; + tx_request.max_fee_per_gas = Some(max_fee_per_gas); + tx_request.max_priority_fee_per_gas = Some(max_priority_fee_per_gas); + tracing::info!( + "eip1559 fee max_fee_per_gas: {}, max_priority_fee_per_gas: {}", + max_fee_per_gas, + max_priority_fee_per_gas + ); + tx_request.nonce = + Some(self.provider.clone().get_transaction_count(tx_request.from.unwrap()).await?); + tracing::info!("tx(type2): {:?}", tx_request); + tx_request.gas = Some(self.provider.clone().estimate_gas(tx_request.clone()).await?); + tracing::info!("estimated gas(type2): {:?}", tx_request.gas); + + let unsigned_tx = + tx_request.build_typed_tx().map_err(|v| format_err!("{v:?} fail to build typed tx"))?; + let signed_tx = >::sign_transaction( + &self.signer, + unsigned_tx, + ) + .await?; + let pending_tx = + self.provider.send_raw_transaction(signed_tx.encoded_2718().as_slice()).await?; + let tx_hash = pending_tx.tx_hash(); + tracing::info!("finish send tx_hash(type2): {}", tx_hash.to_string()); + + let poll_interval_secs = 2_u64; + let max_attempts = (max_wait_secs / poll_interval_secs).max(1); + for i in 0..max_attempts { + if i > 0 { + time::sleep(Duration::from_secs(poll_interval_secs)).await; + } + match self.provider.get_transaction_receipt(*tx_hash).await { + Err(_) => { + tracing::info!( + "Get transaction(type2):{} receipt failed at {} times, will try later", + tx_hash.to_string(), + i + ); + continue; + } + Ok(receipt) => { + if receipt.is_none() { + tracing::info!( + "Get transaction(type2):{} receipt is none at {} times, will try later", + tx_hash.to_string(), + i + ); + continue; + } + let receipt = receipt.expect("checked is_some"); + if !receipt.status() { + bail!("tx_hash:{tx_hash} execute failed on chain"); + } + return Ok((*tx_hash, receipt)); + } + }; + } + bail!("tx_hash:{} receipt not found within {} seconds", tx_hash.to_string(), max_wait_secs) + } } impl From<&BitcoinTx> for IGateway::BitcoinTx { @@ -824,7 +928,7 @@ impl ChainAdaptor for GoatAdaptor { .value(value_wei) .into_transaction_request(); let (tx_hash, receipt) = - self.handle_transaction_request_with_wait(tx_request, max_wait_secs).await?; + self.handle_transaction_request_with_wait_eip1559(tx_request, max_wait_secs).await?; let escrow_hash = extract_initialize_escrow_hash_from_receipt(&receipt, &contract_address) .ok_or_else(|| { anyhow::anyhow!( @@ -1486,7 +1590,7 @@ impl ChainAdaptor for GoatAdaptor { .from(self.get_default_signer_address()) .chain_id(self.chain_id) .into_transaction_request(); - let tx_hash = self.handle_transaction_request(tx_request).await?; + let tx_hash = self.handle_transaction_request_eip1559(tx_request).await?; Ok(tx_hash.to_string()) } diff --git a/node/src/bin/send_bridge_out.rs b/node/src/bin/send_bridge_out.rs index 22e936e7..17335418 100644 --- a/node/src/bin/send_bridge_out.rs +++ b/node/src/bin/send_bridge_out.rs @@ -26,8 +26,8 @@ const PAY_IN_FLAG: u64 = 0x02; const ZERO_B256_HEX: &str = "0x0000000000000000000000000000000000000000000000000000000000000000"; const DEFAULT_NONCE_TIME_OFFSET_SECS: u64 = 700_000_000; const DEFAULT_NONCE_RANDOM_BITS: u32 = 24; -const DEFAULT_PRIORITY_FEE_WEI: u64 = 1_000_000_000; -const DEFAULT_MAX_BASE_FEE_WEI: u64 = 500_000_000_000; +const DEFAULT_PRIORITY_FEE_WEI: u64 = 5_000_000; +const DEFAULT_MAX_BASE_FEE_WEI: u64 = 2_000_000_000; const DEFAULT_BASE_FEE_MULTIPLIER_NUM: u64 = 125; const DEFAULT_BASE_FEE_MULTIPLIER_DEN: u64 = 100; const PEG_BTC_DECIMALS: usize = 18; @@ -952,13 +952,13 @@ mod tests { #[test] fn test_generate_default_fee_rate() { let fee_rate = generate_default_fee_rate(U256::from(100u64)); - assert_eq!(fee_rate, "125,1000000000"); + assert_eq!(fee_rate, "125,5000000"); } #[test] fn test_generate_default_fee_rate_with_cap() { let fee_rate = generate_default_fee_rate(U256::from(1_000_000_000_000u64)); - assert_eq!(fee_rate, "500000000000,1000000000"); + assert_eq!(fee_rate, "2000000000,5000000"); } #[test] From d3b58c59abeadcdc6aef6c3572028eaf16f7fc99 Mon Sep 17 00:00:00 2001 From: Blake Date: Wed, 4 Mar 2026 16:46:24 +0800 Subject: [PATCH 3/5] feat: add documentation for bridge-out script usage and parameters --- .claude/commands/bridge-out.md | 89 ++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 .claude/commands/bridge-out.md diff --git a/.claude/commands/bridge-out.md b/.claude/commands/bridge-out.md new file mode 100644 index 00000000..2d67c419 --- /dev/null +++ b/.claude/commands/bridge-out.md @@ -0,0 +1,89 @@ +Initiate Bridge Out via the `bridge-out` script (payInvoice quote -> swap initialize -> init-tag). + +## Instructions + +1. Ask the user for the following parameters (skip any already provided as arguments: $ARGUMENTS): + - **rpc_url**: Node API base URL. Default: `http://localhost:8080` + - **from_addr**: Source GOAT address (used by `init-tag`) + - **to_addr**: Destination BTC address (used by `init-tag`) + - **pay_invoice_url**: Quote endpoint. Default: `https://152-32-185-32.nodes.atomiq.exchange:8443/tobtc/payInvoice?chain=GOAT` + - **amount**: Amount for payInvoice body (`amount`) + - **exact_in**: Whether quote is exact-in (`true`/`false`). Default: `true` + - **confirmation_target**: Bitcoin confirmation target. Default: `3` + - **confirmations**: Required confirmations. Default: `2` + - **nonce**: Swap nonce (decimal string) + - **token**: Token address + - **offerer**: Offerer GOAT address + - **fee_rate**: Fee rate payload (string/number/JSON object) + - **additional_params_json**: Optional JSON object merged into payInvoice body + - **contract_address**: Optional (fallback to `GOAT_SWAP_CONTRACT_ADDRESS`) + - **max_wait_secs**: Max wait for tx receipt. Default: `60` + +2. Ensure required environment and runtime prerequisites are ready: + - `GOAT_PRIVATE_KEY` must be set (unless user passes `--goat-private-key`) + - `GOAT_CHAIN_URL` and chain config must be valid for on-chain calls + - `GOAT_SWAP_CONTRACT_ADDRESS` should be set if `--contract-address` is omitted + +3. Ensure the script is available: + - Preferred: build from source + ```bash + cargo build -p bitvm2-noded --bin bridge-out + ``` + - Then use: + ```bash + ./target/debug/bridge-out --help + ``` + +4. Run swap initialize (this step calls payInvoice first, then sends swap `initialize` tx): + ```bash + ./target/debug/bridge-out swap-initialize \ + --pay-invoice-url \ + --btc-address \ + --amount \ + --exact-in \ + --confirmation-target \ + --confirmations \ + --nonce \ + --token \ + --offerer \ + --fee-rate '' \ + --max-wait-secs + ``` + - If needed, append: + ```bash + --additional-params-json '' + --contract-address + --goat-private-key + ``` + +5. Parse output from step 4: + - `swap initialize submitted: ` + - `escrow_hash (from Initialize log): ` + Save `` for the next step. + +6. Submit bridge-out init-tag to node API: + ```bash + ./target/debug/bridge-out --rpc-url init-tag \ + --from-addr \ + --to-addr \ + --escrow-hash \ + --contract-address + ``` + - Alternative: derive escrow hash from tx logs directly: + ```bash + ./target/debug/bridge-out --rpc-url init-tag \ + --from-addr \ + --to-addr \ + --swap-init-tx-hash \ + --contract-address + ``` + +7. Optional verification (if instance id is known): + ```bash + ./target/debug/bridge-out --rpc-url escrow-data --instance-id + ``` + +## Notes + +- `swap-initialize` is the recommended mode for Bridge Out. It fetches escrow params from payInvoice and extracts `escrowHash` from `Initialize` logs. +- For troubleshooting or low-level debugging, `swap-initialize-manual` is also available to pass full escrow fields explicitly. From 1a927348fde75e29c373cb42db67093554ad5ed1 Mon Sep 17 00:00:00 2001 From: Blake Date: Wed, 4 Mar 2026 17:35:19 +0800 Subject: [PATCH 4/5] docs: revise bridge-out documentation to reflect binary usage and updated parameters --- .claude/commands/bridge-out.md | 35 ++++++++++++++-------------------- 1 file changed, 14 insertions(+), 21 deletions(-) diff --git a/.claude/commands/bridge-out.md b/.claude/commands/bridge-out.md index 2d67c419..abb60388 100644 --- a/.claude/commands/bridge-out.md +++ b/.claude/commands/bridge-out.md @@ -1,4 +1,4 @@ -Initiate Bridge Out via the `bridge-out` script (payInvoice quote -> swap initialize -> init-tag). +Initiate Bridge Out via the `bridge-out` binary (payInvoice quote -> swap initialize -> init-tag). ## Instructions @@ -7,14 +7,12 @@ Initiate Bridge Out via the `bridge-out` script (payInvoice quote -> swap initia - **from_addr**: Source GOAT address (used by `init-tag`) - **to_addr**: Destination BTC address (used by `init-tag`) - **pay_invoice_url**: Quote endpoint. Default: `https://152-32-185-32.nodes.atomiq.exchange:8443/tobtc/payInvoice?chain=GOAT` - - **amount**: Amount for payInvoice body (`amount`) + - **amount**: Amount in pegBTC (human-readable). Example: `0.0015` - **exact_in**: Whether quote is exact-in (`true`/`false`). Default: `true` - **confirmation_target**: Bitcoin confirmation target. Default: `3` - **confirmations**: Required confirmations. Default: `2` - - **nonce**: Swap nonce (decimal string) - **token**: Token address - **offerer**: Offerer GOAT address - - **fee_rate**: Fee rate payload (string/number/JSON object) - **additional_params_json**: Optional JSON object merged into payInvoice body - **contract_address**: Optional (fallback to `GOAT_SWAP_CONTRACT_ADDRESS`) - **max_wait_secs**: Max wait for tx receipt. Default: `60` @@ -23,30 +21,30 @@ Initiate Bridge Out via the `bridge-out` script (payInvoice quote -> swap initia - `GOAT_PRIVATE_KEY` must be set (unless user passes `--goat-private-key`) - `GOAT_CHAIN_URL` and chain config must be valid for on-chain calls - `GOAT_SWAP_CONTRACT_ADDRESS` should be set if `--contract-address` is omitted + - Optional but recommended: use `node/.env` to manage the above variables consistently -3. Ensure the script is available: - - Preferred: build from source +3. Check if the `bridge-out` binary exists at `./bin/bridge-out`. If not, run the install script to download it: ```bash - cargo build -p bitvm2-noded --bin bridge-out + .claude/commands/install-bitvm2.sh install ``` - - Then use: + To upgrade to the latest version: ```bash - ./target/debug/bridge-out --help + .claude/commands/install-bitvm2.sh upgrade ``` + The script auto-detects the platform (x86_64-linux / aarch64-macos), downloads from GitHub Releases, + verifies the sha256 checksum, and installs all binaries to `./bin/`. -4. Run swap initialize (this step calls payInvoice first, then sends swap `initialize` tx): +4. Run swap initialize (this step calls payInvoice first, then sends token approve if needed, then swap `initialize` tx): ```bash - ./target/debug/bridge-out swap-initialize \ + ./bin/bridge-out --rpc-url swap-initialize \ --pay-invoice-url \ --btc-address \ --amount \ --exact-in \ --confirmation-target \ --confirmations \ - --nonce \ --token \ --offerer \ - --fee-rate '' \ --max-wait-secs ``` - If needed, append: @@ -63,7 +61,7 @@ Initiate Bridge Out via the `bridge-out` script (payInvoice quote -> swap initia 6. Submit bridge-out init-tag to node API: ```bash - ./target/debug/bridge-out --rpc-url init-tag \ + ./bin/bridge-out --rpc-url init-tag \ --from-addr \ --to-addr \ --escrow-hash \ @@ -71,7 +69,7 @@ Initiate Bridge Out via the `bridge-out` script (payInvoice quote -> swap initia ``` - Alternative: derive escrow hash from tx logs directly: ```bash - ./target/debug/bridge-out --rpc-url init-tag \ + ./bin/bridge-out --rpc-url init-tag \ --from-addr \ --to-addr \ --swap-init-tx-hash \ @@ -80,10 +78,5 @@ Initiate Bridge Out via the `bridge-out` script (payInvoice quote -> swap initia 7. Optional verification (if instance id is known): ```bash - ./target/debug/bridge-out --rpc-url escrow-data --instance-id + ./bin/bridge-out --rpc-url escrow-data --instance-id ``` - -## Notes - -- `swap-initialize` is the recommended mode for Bridge Out. It fetches escrow params from payInvoice and extracts `escrowHash` from `Initialize` logs. -- For troubleshooting or low-level debugging, `swap-initialize-manual` is also available to pass full escrow fields explicitly. From 9872d3665a41b34cd8e6cae9f412e88d5d897fa8 Mon Sep 17 00:00:00 2001 From: Blake Date: Wed, 4 Mar 2026 20:34:00 +0800 Subject: [PATCH 5/5] fix clippy --- crates/client/src/goat_chain/chain_adaptor.rs | 1 + crates/client/src/goat_chain/evmchain.rs | 1 + crates/client/src/goat_chain/goat_adaptor.rs | 10 ++++---- crates/client/src/goat_chain/mod.rs | 1 + node/src/bin/send_bridge_out.rs | 23 ++++++++----------- 5 files changed, 17 insertions(+), 19 deletions(-) diff --git a/crates/client/src/goat_chain/chain_adaptor.rs b/crates/client/src/goat_chain/chain_adaptor.rs index 5d7ab488..bc3e7d34 100644 --- a/crates/client/src/goat_chain/chain_adaptor.rs +++ b/crates/client/src/goat_chain/chain_adaptor.rs @@ -22,6 +22,7 @@ pub trait ChainAdaptor: Send + Sync { tx_hash: &str, trace_options: Option, ) -> anyhow::Result; + #[allow(clippy::too_many_arguments)] async fn swap_initialize( &self, contract_address: Address, diff --git a/crates/client/src/goat_chain/evmchain.rs b/crates/client/src/goat_chain/evmchain.rs index f84493db..5b119c9d 100644 --- a/crates/client/src/goat_chain/evmchain.rs +++ b/crates/client/src/goat_chain/evmchain.rs @@ -262,6 +262,7 @@ impl EvmChain { self.adaptor.debug_trace_tx(tx_hash, trace_options).await } + #[allow(clippy::too_many_arguments)] pub async fn swap_initialize( &self, contract_address: Address, diff --git a/crates/client/src/goat_chain/goat_adaptor.rs b/crates/client/src/goat_chain/goat_adaptor.rs index fecd4e7c..354aba8c 100644 --- a/crates/client/src/goat_chain/goat_adaptor.rs +++ b/crates/client/src/goat_chain/goat_adaptor.rs @@ -616,7 +616,7 @@ impl GoatAdaptor { } }; } - bail!("tx_hash:{} receipt not found within {} seconds", tx_hash.to_string(), max_wait_secs) + bail!("tx_hash:{tx_hash} receipt not found within {max_wait_secs} seconds") } async fn handle_transaction_request_with_wait_eip1559( @@ -683,7 +683,7 @@ impl GoatAdaptor { } }; } - bail!("tx_hash:{} receipt not found within {} seconds", tx_hash.to_string(), max_wait_secs) + bail!("tx_hash:{tx_hash} receipt not found within {max_wait_secs} seconds") } } @@ -932,9 +932,7 @@ impl ChainAdaptor for GoatAdaptor { let escrow_hash = extract_initialize_escrow_hash_from_receipt(&receipt, &contract_address) .ok_or_else(|| { anyhow::anyhow!( - "Initialize event with escrowHash not found in tx {} logs for contract {}", - tx_hash, - contract_address + "Initialize event with escrowHash not found in tx {tx_hash} logs for contract {contract_address}" ) })?; Ok(SwapInitializeResult { tx_hash: tx_hash.to_string(), escrow_hash }) @@ -950,7 +948,7 @@ impl ChainAdaptor for GoatAdaptor { return Ok(None); }; if !receipt.status() { - bail!("tx {} failed on-chain (status = 0)", tx_hash); + bail!("tx {tx_hash} failed on-chain (status = 0)"); } Ok(extract_initialize_escrow_hash_from_receipt(&receipt, &contract_address)) } diff --git a/crates/client/src/goat_chain/mod.rs b/crates/client/src/goat_chain/mod.rs index 30dafbf2..67ab134f 100644 --- a/crates/client/src/goat_chain/mod.rs +++ b/crates/client/src/goat_chain/mod.rs @@ -83,6 +83,7 @@ impl GOATClient { self.chain_service.debug_trace_tx(tx_hash, trace_options).await } + #[allow(clippy::too_many_arguments)] pub async fn swap_initialize( &self, contract_address: Address, diff --git a/node/src/bin/send_bridge_out.rs b/node/src/bin/send_bridge_out.rs index 17335418..d585c559 100644 --- a/node/src/bin/send_bridge_out.rs +++ b/node/src/bin/send_bridge_out.rs @@ -234,8 +234,7 @@ fn resolve_contract_address(contract_address: Option) -> Result std::env::var(ENV_GOAT_SWAP_CONTRACT_ADDRESS).map_err(|_| { anyhow!( - "missing --contract-address and env {}, one of them is required", - ENV_GOAT_SWAP_CONTRACT_ADDRESS + "missing --contract-address and env {ENV_GOAT_SWAP_CONTRACT_ADDRESS}, one of them is required" ) }) } @@ -318,7 +317,7 @@ fn convert_pegbtc_amount_to_base_units(amount: &str) -> Result { bail!("amount fractional part must contain digits only"); } if frac_part.len() > PEG_BTC_DECIMALS { - bail!("amount has too many decimal places, max {}", PEG_BTC_DECIMALS); + bail!("amount has too many decimal places, max {PEG_BTC_DECIMALS}"); } let mut base_units = String::with_capacity(int_part.len() + PEG_BTC_DECIMALS); @@ -462,7 +461,7 @@ async fn ensure_token_approval( .peg_btc_approve(&spender, escrow.amount) .await .context("failed to send token approve tx before swap initialize")?; - eprintln!("token approve submitted: {}", approve_tx_hash); + eprintln!("token approve submitted: {approve_tx_hash}"); let latest_allowance = goat_client .peg_btc_allowance(&owner, &spender) @@ -510,9 +509,7 @@ async fn derive_escrow_hash_from_swap_init_tx( .with_context(|| format!("failed to parse initialize log from tx {swap_init_tx_hash}"))?; escrow_hash.ok_or_else(|| { anyhow!( - "Initialize event with escrowHash not found in tx {} logs for contract {}", - swap_init_tx_hash, - contract_address + "Initialize event with escrowHash not found in tx {swap_init_tx_hash} logs for contract {contract_address}" ) }) } @@ -580,9 +577,9 @@ async fn call_pay_invoice( let (nonce, fee_rate) = generate_default_nonce_and_fee_rate().await?; let amount_base_units = convert_pegbtc_amount_to_base_units(amount)?; - eprintln!("auto-generated nonce: {}", nonce); - eprintln!("auto-generated feeRate: {}", fee_rate); - eprintln!("converted amount (pegBTC -> base units): {}", amount_base_units); + eprintln!("auto-generated nonce: {nonce}"); + eprintln!("auto-generated feeRate: {fee_rate}"); + eprintln!("converted amount (pegBTC -> base units): {amount_base_units}"); let mut body = serde_json::Map::new(); if let Some(params_json) = additional_params_json { @@ -768,7 +765,7 @@ async fn main() -> Result<()> { let derived = derive_escrow_hash_from_swap_init_tx(&tx_hash, &contract_address) .await?; - eprintln!("derived escrow hash from swap init tx {}: {}", tx_hash, derived); + eprintln!("derived escrow hash from swap init tx {tx_hash}: {derived}"); derived } }; @@ -792,8 +789,8 @@ async fn main() -> Result<()> { Commands::SwapInitialize { .. } => { let (tx_hash, escrow_hash) = run_swap_initialize_from_pay_invoice(&client, &args.command).await?; - println!("swap initialize submitted: {}", tx_hash); - println!("escrow_hash (from Initialize log): {}", escrow_hash); + println!("swap initialize submitted: {tx_hash}"); + println!("escrow_hash (from Initialize log): {escrow_hash}"); } }