diff --git a/Cargo.lock b/Cargo.lock index fb1497c0..a760f9a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5308,6 +5308,7 @@ dependencies = [ "serde_json", "thiserror 2.0.18", "tokio", + "tracing", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 8f198fb7..b3de2ec1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,7 +65,7 @@ crypto-shared = { git = "https://github.com/near-one/mpc", package = "crypto-sha near-mpc-contract-interface = "0.0.1" openssl-sys = { version = "*", features = ["vendored"] } light-client = { path = "bridge-sdk/light-client" } -zcash_primitives = { git = "https://github.com/Near-One/librustzcash", rev = "63fdfdbecec3523dd3ca8eea247af83e4718a032", default-features = false, features = ["circuits", "test-dependencies", "transparent-inputs"] } +zcash_primitives = { git = "https://github.com/Near-One/librustzcash", rev = "63fdfdbecec3523dd3ca8eea247af83e4718a032", default-features = false, features = ["circuits", "test-dependencies", "transparent-inputs", "non-standard-fees"] } zcash_transparent = { git = "https://github.com/Near-One/librustzcash", rev = "63fdfdbecec3523dd3ca8eea247af83e4718a032", features = ["transparent-inputs"] } zcash_protocol = { git = "https://github.com/Near-One/librustzcash", rev = "63fdfdbecec3523dd3ca8eea247af83e4718a032" } pczt = { git = "https://github.com/Near-One/librustzcash", rev = "63fdfdbecec3523dd3ca8eea247af83e4718a032", features = ["std", "signer", "io-finalizer", "prover", "signer", "tx-extractor", "spend-finalizer", "zcp-builder"] } diff --git a/bridge-cli/src/omni_connector_command.rs b/bridge-cli/src/omni_connector_command.rs index 6217ef3a..e78e81a1 100644 --- a/bridge-cli/src/omni_connector_command.rs +++ b/bridge-cli/src/omni_connector_command.rs @@ -203,7 +203,9 @@ struct ManualDepositInput { /// /// - When `manual` is `Some`, builds `BtcDepositArgs` from the supplied /// recipient/fee/msg/refund and resolves `vout` either from the explicit -/// value or by matching it against the derived deposit address. +/// value or by matching it against the derived deposit address (fetched +/// from the UTXO connector contract when `from_contract` is set, from the +/// bridge indexer otherwise). /// - When `manual` is `None`, asks the bridge indexer (via /// `resolve_deposit_from_tx`) which output is a tracked deposit address; /// the recovered `DepositMsg` is wrapped as `BtcDepositArgs::DepositMsg`. @@ -217,6 +219,7 @@ async fn resolve_btc_deposit( btc_tx_hash: &str, vout: Option, manual: Option, + from_contract: bool, ) -> (BtcDepositArgs, usize, Option) { match manual { Some(ManualDepositInput { @@ -250,7 +253,13 @@ async fn resolve_btc_deposit( Some(v) => (v, None), None => { let (v, p) = connector - .resolve_deposit_vout(chain, network, btc_tx_hash, &deposit_args) + .resolve_deposit_vout( + chain, + network, + btc_tx_hash, + &deposit_args, + from_contract, + ) .await .unwrap(); (v, Some(p)) @@ -805,15 +814,11 @@ pub enum OmniConnectorSubCommand { #[command(flatten)] config_cli: CliConfig, }, - #[clap(about = "Request a refund for a never-finalized BTC deposit (Bitcoin only)")] + #[clap(about = "Request a refund for a never-finalized UTXO-chain deposit (Bitcoin/Zcash)")] BtcRequestRefund { - #[clap( - short, - long, - help = "Chain the deposit was made on. Only Bitcoin is currently supported; the flag exists for forward compatibility." - )] + #[clap(short, long, help = "Chain the deposit was made on (Bitcoin/Zcash)")] chain: UTXOChainArg, - #[clap(short, long, help = "Bitcoin deposit tx hash")] + #[clap(short, long, help = "Bitcoin/Zcash deposit tx hash")] btc_tx_hash: String, #[clap( short, @@ -852,16 +857,61 @@ pub enum OmniConnectorSubCommand { no_deposit_refund_address: bool, #[clap(long, help = "Optional custom gas fee in satoshi (DAO/Operator only)")] gas_fee: Option, + #[clap( + long, + help = "Derive the deposit address via the UTXO connector contract view method (true, default) or the bridge indexer service (false). Only used with --recipient-id; auto-discovery always asks the indexer.", + default_value_t = true, + action = clap::ArgAction::Set + )] + from_contract: bool, #[command(flatten)] config_cli: CliConfig, }, - #[clap(about = "Verify the refund BTC transaction is confirmed (Bitcoin only)")] + #[clap(about = "Verify the refund transaction is confirmed on the UTXO chain (Bitcoin/Zcash)")] BtcVerifyRefundFinalize { - #[clap(short, long, help = "Refund Bitcoin tx hash")] + #[clap( + short, + long, + help = "Chain the refund was made on (Bitcoin/Zcash)", + default_value = "btc" + )] + chain: UTXOChainArg, + #[clap(short, long, help = "Refund Bitcoin/Zcash tx hash")] btc_tx_hash: String, #[command(flatten)] config_cli: CliConfig, }, + #[clap( + about = "Execute a previously requested refund, sending the deposit back to the refund address (Bitcoin/Zcash)" + )] + BtcExecuteRefund { + #[clap(short, long, help = "Chain the deposit was made on (Bitcoin/Zcash)")] + chain: UTXOChainArg, + #[clap( + long, + help = "Refund request key in the form `@`. Mutually exclusive with --btc-tx-hash/--vout." + )] + utxo_storage_key: Option, + #[clap( + short, + long, + help = "Deposit tx hash; combined with --vout to form the refund request key. Ignored if --utxo-storage-key is given." + )] + btc_tx_hash: Option, + #[clap( + short, + long, + help = "Index of the deposit output; combined with --btc-tx-hash to form the refund request key." + )] + vout: Option, + #[clap( + long, + help = "Transparent refund: send the deposit back to a transparent address with no Orchard bundle. For Zcash this overrides the default of auto-generating a shielded Orchard bundle; on Bitcoin refunds are always transparent." + )] + transparent: bool, + #[command(flatten)] + config_cli: CliConfig, + }, #[clap(about = "Finalize Transfer from Near on Bitcoin")] BtcFinTransfer { #[clap(short, long, help = "Chain for the UTXO rebalancing (Bitcoin/Zcash)")] @@ -1730,6 +1780,7 @@ pub async fn match_subcommand(cmd: OmniConnectorSubCommand, network: Network) { &btc_tx_hash, vout, manual, + false, ) .await; @@ -1807,13 +1858,10 @@ pub async fn match_subcommand(cmd: OmniConnectorSubCommand, network: Network) { msg, no_deposit_refund_address, gas_fee, + from_contract, config_cli, } => { let chain_kind: ChainKind = chain.into(); - if chain_kind != ChainKind::Btc { - panic!("btc-request-refund currently supports only --chain btc; got {chain:?}"); - } - let connector = omni_connector(network, config_cli); let manual = match recipient_id { @@ -1845,6 +1893,7 @@ pub async fn match_subcommand(cmd: OmniConnectorSubCommand, network: Network) { &btc_tx_hash, vout, manual, + from_contract, ) .await; @@ -1867,6 +1916,7 @@ pub async fn match_subcommand(cmd: OmniConnectorSubCommand, network: Network) { // transaction is printed as an unsigned payload instead of broadcast. connector .btc_request_refund( + chain_kind, btc_tx_hash, resolved_vout, btc_deposit_args, @@ -1879,11 +1929,63 @@ pub async fn match_subcommand(cmd: OmniConnectorSubCommand, network: Network) { .unwrap(); } OmniConnectorSubCommand::BtcVerifyRefundFinalize { + chain, btc_tx_hash, config_cli, } => { omni_connector(network, config_cli) - .btc_verify_refund_finalize(btc_tx_hash, TransactionOptions::default()) + .btc_verify_refund_finalize( + chain.into(), + btc_tx_hash, + TransactionOptions::default(), + ) + .await + .unwrap(); + } + OmniConnectorSubCommand::BtcExecuteRefund { + chain, + utxo_storage_key, + btc_tx_hash, + vout, + transparent, + config_cli, + } => { + let chain_kind: ChainKind = chain.into(); + let utxo_storage_key = match utxo_storage_key { + Some(key) => key, + None => { + let tx_id = btc_tx_hash.expect( + "Provide either --utxo-storage-key or both --btc-tx-hash and --vout", + ); + let vout = vout.expect( + "Provide either --utxo-storage-key or both --btc-tx-hash and --vout", + ); + format!("{tx_id}@{vout}") + } + }; + + let connector = omni_connector(network, config_cli); + + // Zcash defaults to a shielded refund (auto-generated Orchard bundle); + // `--transparent` and Bitcoin refunds carry no bundle. + let chain_specific_data = if transparent || chain_kind != ChainKind::Zcash { + None + } else { + Some( + connector + .build_refund_chain_specific_data(&utxo_storage_key) + .await + .unwrap(), + ) + }; + + connector + .btc_execute_refund( + chain_kind, + utxo_storage_key, + chain_specific_data, + TransactionOptions::default(), + ) .await .unwrap(); } diff --git a/bridge-sdk/bridge-clients/near-bridge-client/src/btc.rs b/bridge-sdk/bridge-clients/near-bridge-client/src/btc.rs index 9a6f21a1..c27eef2a 100644 --- a/bridge-sdk/bridge-clients/near-bridge-client/src/btc.rs +++ b/bridge-sdk/bridge-clients/near-bridge-client/src/btc.rs @@ -29,6 +29,7 @@ const BTC_CANCEL_WITHDRAW_GAS: u64 = 300_000_000_000_000; const BTC_RBF_INCREASE_GAS_FEE_GAS: u64 = 300_000_000_000_000; const BTC_VERIFY_ACTIVE_UTXO_MANAGEMENT_GAS: u64 = 300_000_000_000_000; const BTC_REQUEST_REFUND_GAS: u64 = 300_000_000_000_000; +const BTC_EXECUTE_REFUND_GAS: u64 = 300_000_000_000_000; const BTC_VERIFY_REFUND_FINALIZE_GAS: u64 = 300_000_000_000_000; const SUBMIT_BTC_TRANSFER_GAS: u64 = 300_000_000_000_000; @@ -41,7 +42,6 @@ const BTC_VERIFY_WITHDRAW_DEPOSIT: u128 = 0; const BTC_CANCEL_WITHDRAW_DEPOSIT: u128 = 1; const BTC_RBF_INCREASE_GAS_FEE_DEPOSIT: u128 = 0; const BTC_VERIFY_ACTIVE_UTXO_MANAGEMENT_DEPOSIT: u128 = 0; -const BTC_REQUEST_REFUND_DEPOSIT: u128 = 0; const BTC_VERIFY_REFUND_FINALIZE_DEPOSIT: u128 = 0; const SUBMIT_BTC_TRANSFER_DEPOSIT: u128 = 0; pub const MAX_RATIO: u32 = 10000; @@ -212,6 +212,23 @@ pub struct ChainSpecificData { pub expiry_height: u32, } +/// A pending refund request stored by the UTXO connector (`request_refund`), +/// returned by the `get_refund_requests_paged` view. Mirrors the contract's +/// `RefundRequest`; `execute_refund` consumes it to build the refund tx. +#[derive(Clone, serde::Serialize, serde::Deserialize)] +pub struct RefundRequest { + /// The original deposit message, as the JSON string the contract stored. + pub deposit_msg_json: String, + pub utxo_storage_key: String, + pub tx_bytes: Base64VecU8, + pub vout: usize, + pub amount: u128, + pub refund_address: String, + pub gas_fee: u128, + pub created_at_sec: u32, + pub executed: bool, +} + #[derive(Clone, serde::Serialize, serde::Deserialize)] pub enum TokenReceiverMessage { DepositProtocolFee, @@ -554,6 +571,38 @@ impl NearBridgeClient { Ok(btc_pending_info) } + /// Fetch a single pending refund request by its UTXO storage key + /// (`{tx_id}@{vout}`) from the UTXO connector (Bitcoin/Zcash). + pub async fn get_refund_request( + &self, + chain: ChainKind, + utxo_storage_key: &str, + ) -> Result { + let endpoint = self.endpoint()?; + let btc_connector = self.utxo_chain_connector(chain)?; + + let response = near_rpc_client::view( + endpoint, + ViewRequest { + contract_account_id: btc_connector, + method_name: "get_refund_requests_paged".to_string(), + args: serde_json::json!({}), + }, + ) + .await?; + + let refund_requests: HashMap = serde_json::from_slice(&response)?; + + refund_requests + .get(utxo_storage_key) + .cloned() + .ok_or_else(|| { + BridgeSdkError::InvalidArgument(format!( + "Refund request not found for key {utxo_storage_key}" + )) + }) + } + #[tracing::instrument(skip_all, name = "NEAR BTC RBF INCREASE GAS FEE")] pub async fn btc_rbf_increase_gas_fee( &self, @@ -736,15 +785,22 @@ impl NearBridgeClient { Ok(tx_hash) } - /// Submit a refund request for a never-finalized BTC deposit (Bitcoin only). + /// Submit a refund request for a never-finalized UTXO-chain deposit (Bitcoin/Zcash). #[tracing::instrument(skip_all, name = "NEAR BTC REQUEST REFUND")] pub async fn btc_request_refund( &self, + chain: ChainKind, args: BtcRequestRefundArgs, transaction_options: TransactionOptions, ) -> Result { let endpoint = self.endpoint()?; - let btc_connector = self.utxo_chain_connector(ChainKind::Btc)?; + let btc_connector = self.utxo_chain_connector(chain)?; + + let deposit = self + .required_balance_for_request_refund(chain) + .await? + .as_yoctonear(); + let tx_hash = near_rpc_client::change_and_wait( endpoint, ChangeRequest { @@ -754,7 +810,7 @@ impl NearBridgeClient { method_name: "request_refund".to_string(), args: serde_json::json!(args).to_string().into_bytes(), gas: BTC_REQUEST_REFUND_GAS, - deposit: BTC_REQUEST_REFUND_DEPOSIT, + deposit, }, transaction_options.wait_until, transaction_options.wait_final_outcome_timeout_sec, @@ -768,15 +824,114 @@ impl NearBridgeClient { Ok(tx_hash) } - /// Verify that the refund BTC transaction has been confirmed (Bitcoin only). + /// Query the deposit the UTXO connector requires to be attached to a + /// `request_refund` call (Bitcoin/Zcash). + pub async fn required_balance_for_request_refund( + &self, + chain: ChainKind, + ) -> Result { + let endpoint = self.endpoint()?; + let btc_connector = self.utxo_chain_connector(chain)?; + + let response = near_rpc_client::view( + endpoint, + ViewRequest { + contract_account_id: btc_connector, + method_name: "required_balance_for_request_refund".to_string(), + args: serde_json::json!({}), + }, + ) + .await?; + + Ok(serde_json::from_slice::(&response)?) + } + + /// Execute a previously requested refund: send the deposit UTXO back to the + /// original `refund_address` via the MPC sign pipeline (Bitcoin/Zcash). + /// + /// Requires the request's timelock to have passed (bypassed for a privileged + /// caller). `chain_specific_data` is Zcash-only: `Some` with an Orchard bundle + /// for a shielded refund, `None` for a transparent one; it is ignored on Bitcoin. + #[tracing::instrument(skip_all, name = "NEAR BTC EXECUTE REFUND")] + pub async fn btc_execute_refund( + &self, + chain: ChainKind, + utxo_storage_key: String, + chain_specific_data: Option, + transaction_options: TransactionOptions, + ) -> Result { + let endpoint = self.endpoint()?; + let btc_connector = self.utxo_chain_connector(chain)?; + + // `execute_refund` is `#[payable]`: it stores a pending-verify entry and requires + // an attached deposit of at least `required_balance_for_execute_refund()` (NOT + // refunded — storage + anti-spam fee on this permissionless entrypoint). The amount + // is contract-defined and chain-dependent, so query it instead of hardcoding. + let deposit = self + .required_balance_for_execute_refund(chain) + .await? + .as_yoctonear(); + + let tx_hash = near_rpc_client::change_and_wait( + endpoint, + ChangeRequest { + signer: self.signer()?, + nonce: transaction_options.nonce, + receiver_id: btc_connector, + method_name: "execute_refund".to_string(), + args: serde_json::json!({ + "utxo_storage_key": utxo_storage_key, + "chain_specific_data": chain_specific_data, + }) + .to_string() + .into_bytes(), + gas: BTC_EXECUTE_REFUND_GAS, + deposit, + }, + transaction_options.wait_until, + transaction_options.wait_final_outcome_timeout_sec, + ) + .await?; + + tracing::info!( + tx_hash = tx_hash.to_string(), + "Sent BTC Execute Refund transaction" + ); + Ok(tx_hash) + } + + /// Query the deposit the UTXO connector requires to be attached to an + /// `execute_refund` call (Bitcoin/Zcash). + pub async fn required_balance_for_execute_refund( + &self, + chain: ChainKind, + ) -> Result { + let endpoint = self.endpoint()?; + let btc_connector = self.utxo_chain_connector(chain)?; + + let response = near_rpc_client::view( + endpoint, + ViewRequest { + contract_account_id: btc_connector, + method_name: "required_balance_for_execute_refund".to_string(), + args: serde_json::json!({}), + }, + ) + .await?; + + Ok(serde_json::from_slice::(&response)?) + } + + /// Verify that the refund transaction has been confirmed (Bitcoin/Zcash). #[tracing::instrument(skip_all, name = "NEAR BTC VERIFY REFUND FINALIZE")] pub async fn btc_verify_refund_finalize( &self, + chain: ChainKind, args: BtcVerifyRefundFinalizeArgs, transaction_options: TransactionOptions, ) -> Result { let endpoint = self.endpoint()?; - let btc_connector = self.utxo_chain_connector(ChainKind::Btc)?; + let btc_connector = self.utxo_chain_connector(chain)?; let tx_hash = near_rpc_client::change_and_wait( endpoint, ChangeRequest { diff --git a/bridge-sdk/connectors/omni-connector/src/omni_connector.rs b/bridge-sdk/connectors/omni-connector/src/omni_connector.rs index 3b5b89c3..9b177a4d 100644 --- a/bridge-sdk/connectors/omni-connector/src/omni_connector.rs +++ b/bridge-sdk/connectors/omni-connector/src/omni_connector.rs @@ -816,12 +816,14 @@ impl OmniConnector { .await } - /// Build the args for a BTC refund request without submitting the - /// transaction. Bitcoin only. The `prefetched` proof can be supplied by a + /// Build the args for a UTXO-chain refund request without submitting the + /// transaction. The `prefetched` proof can be supplied by a /// prior `resolve_deposit_vout` / `resolve_deposit_from_tx` call to skip /// the redundant `extract_btc_proof`. + #[allow(clippy::too_many_arguments)] pub async fn build_btc_request_refund_args( &self, + chain: ChainKind, btc_tx_hash: &str, vout: usize, deposit_args: BtcDepositArgs, @@ -835,7 +837,7 @@ impl OmniConnector { Some(p) => p, None => { let proof = self - .utxo_bridge_client(ChainKind::Btc)? + .utxo_bridge_client(chain)? .extract_btc_proof(btc_tx_hash) .await?; PrefetchedTxData { proof } @@ -851,7 +853,7 @@ impl OmniConnector { let deposit_amount = u128::from(deposit_output.value_sat); self.ensure_sufficient_btc_confirmations( - ChainKind::Btc, + chain, proof_data.block_height, deposit_amount, false, @@ -892,10 +894,11 @@ impl OmniConnector { }) } - /// Submit a refund request for a never-finalized BTC deposit. Bitcoin only. + /// Submit a refund request for a never-finalized UTXO-chain deposit (Bitcoin/Zcash). #[allow(clippy::too_many_arguments)] pub async fn btc_request_refund( &self, + chain: ChainKind, btc_tx_hash: String, vout: usize, deposit_args: BtcDepositArgs, @@ -906,6 +909,7 @@ impl OmniConnector { ) -> Result { let args = self .build_btc_request_refund_args( + chain, &btc_tx_hash, vout, deposit_args, @@ -916,27 +920,51 @@ impl OmniConnector { .await?; self.near_bridge_client()? - .btc_request_refund(args, transaction_options) + .btc_request_refund(chain, args, transaction_options) + .await + } + + /// Execute a previously requested refund: send the deposit UTXO back to the + /// original refund address via the MPC sign pipeline (Bitcoin/Zcash). + /// + /// `utxo_storage_key` is the refund request key (`{tx_id}@{vout}`). + /// `chain_specific_data` is Zcash-only (Orchard bundle for a shielded refund); + /// pass `None` for Bitcoin or a transparent Zcash refund. + pub async fn btc_execute_refund( + &self, + chain: ChainKind, + utxo_storage_key: String, + chain_specific_data: Option, + transaction_options: TransactionOptions, + ) -> Result { + self.near_bridge_client()? + .btc_execute_refund( + chain, + utxo_storage_key, + chain_specific_data, + transaction_options, + ) .await } - /// Verify that the refund BTC transaction has been confirmed on Bitcoin. Bitcoin only. + /// Verify that the refund transaction has been confirmed on the UTXO chain (Bitcoin/Zcash). pub async fn btc_verify_refund_finalize( &self, + chain: ChainKind, btc_tx_hash: String, transaction_options: TransactionOptions, ) -> Result { - let utxo_bridge_client = self.utxo_bridge_client(ChainKind::Btc)?; + let utxo_bridge_client = self.utxo_bridge_client(chain)?; let near_bridge_client = self.near_bridge_client()?; let proof_data = utxo_bridge_client.extract_btc_proof(&btc_tx_hash).await?; let pending_info = near_bridge_client - .get_btc_pending_info(ChainKind::Btc, btc_tx_hash.clone()) + .get_btc_pending_info(chain, btc_tx_hash.clone()) .await?; self.ensure_sufficient_btc_confirmations( - ChainKind::Btc, + chain, proof_data.block_height, pending_info.actual_received_amount, false, @@ -955,7 +983,7 @@ impl OmniConnector { }; near_bridge_client - .btc_verify_refund_finalize(args, transaction_options) + .btc_verify_refund_finalize(chain, args, transaction_options) .await } @@ -1011,8 +1039,10 @@ impl OmniConnector { } /// Resolve the deposit `vout` by matching outputs of the BTC/Zcash tx - /// `tx_hash` against the deposit address derived from `deposit_args` via - /// the bridge indexer. + /// `tx_hash` against the deposit address derived from `deposit_args` — + /// via the `get_user_deposit_address` view call on the UTXO connector + /// contract when `from_contract` is `true`, via the bridge indexer + /// otherwise. /// /// Returns `InvalidArgument` if no output matches or if multiple outputs /// match (in which case the candidate vouts are listed in the message). @@ -1027,6 +1057,7 @@ impl OmniConnector { network: Network, tx_hash: &str, deposit_args: &BtcDepositArgs, + from_contract: bool, ) -> Result<(usize, PrefetchedTxData)> { let expected_address = match deposit_args { BtcDepositArgs::OmniDepositArgs { @@ -1034,8 +1065,14 @@ impl OmniConnector { refund_address, fee, } => { - self.get_btc_address(chain, recipient_id, refund_address.clone(), *fee, false) - .await? + self.get_btc_address( + chain, + recipient_id, + refund_address.clone(), + *fee, + from_contract, + ) + .await? } BtcDepositArgs::NearDirectDepositArgs { recipient_id, @@ -1045,12 +1082,12 @@ impl OmniConnector { chain, recipient_id.clone(), refund_address.clone(), - false, + from_contract, ) .await? } BtcDepositArgs::DepositMsg { msg } => { - self.get_btc_address_from_deposit_msg(chain, msg, false) + self.get_btc_address_from_deposit_msg(chain, msg, from_contract) .await? } }; diff --git a/bridge-sdk/connectors/omni-connector/src/zcash.rs b/bridge-sdk/connectors/omni-connector/src/zcash.rs index 6fc8fbe5..89795834 100644 --- a/bridge-sdk/connectors/omni-connector/src/zcash.rs +++ b/bridge-sdk/connectors/omni-connector/src/zcash.rs @@ -5,7 +5,7 @@ use bridge_connector_common::result::{BridgeSdkError, Result}; use bitcoin::hashes::Hash; use bitcoin::key::rand::rngs::OsRng; -use near_bridge_client::btc::VUTXO; +use near_bridge_client::btc::{ChainSpecificData, VUTXO}; use omni_types::ChainKind; use orchard::circuit::OrchardCircuitVersion; use pczt::roles::{ @@ -129,6 +129,45 @@ fn to_block_height(height: u64) -> BlockHeight { BlockHeight::from_u32(height.try_into().unwrap_or(u32::MAX)) } +/// How the transparent → Orchard transaction fee is set when building a bundle. +enum OrchardFee { + /// Standard zip317 fee; the leftover value is absorbed by a transparent change + /// output supplied by the caller (used for withdrawals/RBF). + Zip317, + /// Exact fee in zatoshis, with no transparent change. Used for refunds, where + /// the contract reconstructs the broadcast tx as `input − orchard_output = fee`. + Fixed(u64), +} + +/// Parse a Zcash address and extract its Orchard receiver, erroring if the +/// address carries no Orchard receiver (e.g. a transparent-only t-address). +fn extract_orchard_recipient(recipient: &str) -> Result { + utxo_utils::extract_orchard_address(recipient) + .map_err(|err| { + BridgeSdkError::ZCashOrchardBundleError(format!( + "Error on extract Orchard Address: {err}" + )) + })? + .into_option() + .ok_or_else(|| { + BridgeSdkError::ZCashOrchardBundleError( + "Recipient has no Orchard receiver (use a transparent refund instead)".to_string(), + ) + }) +} + +/// Encode an optional text memo into `MemoBytes` (empty when `None`). +fn parse_memo_bytes(memo: Option) -> Result { + match memo { + Some(m) => Memo::from_str(&m).map(MemoBytes::from).map_err(|err| { + BridgeSdkError::ZCashOrchardBundleError(format!( + "Invalid memo (max 512 bytes): {err:?}" + )) + }), + None => Ok(MemoBytes::empty()), + } +} + impl OmniConnector { /// Returns the Zcash consensus parameters matching the configured bridge /// network. The activation heights (and thus the consensus branch ID used @@ -146,6 +185,7 @@ impl OmniConnector { current_height: u64, input_points: Vec, tx_out_change: Option<&TxOut>, + expiry_height: BlockHeight, ) -> Result< zcash_primitives::transaction::builder::Builder, > { @@ -153,10 +193,6 @@ impl OmniConnector { BridgeSdkError::ConfigError(format!("Near bridge client is not initialized: {err}")) })?; - let expiry_delta = near_bridge_client - .get_expiry_height_gap(ChainKind::Zcash) - .await?; - let params = self.zcash_params()?; let target_height = to_block_height(current_height); let (pool, _) = shielded_pool_and_circuit(¶ms, target_height)?; @@ -164,7 +200,7 @@ impl OmniConnector { let mut builder = zcash_primitives::transaction::builder::Builder::new( params, target_height, - target_height + expiry_delta, + expiry_height, zcash_primitives::transaction::builder::BuildConfig::Standard { sapling_anchor: None, orchard_anchor: Some(orchard::Anchor::empty_tree()), @@ -253,9 +289,15 @@ impl OmniConnector { current_height: u64, input_points: Vec, tx_out_change: Option<&TxOut>, + expiry_height: BlockHeight, ) -> Result>> { let builder = self - .get_builder_with_transparent(current_height, input_points, tx_out_change) + .get_builder_with_transparent( + current_height, + input_points, + tx_out_change, + expiry_height, + ) .await?; Ok(builder.get_transp_bundel()) } @@ -266,6 +308,7 @@ impl OmniConnector { current_height: u64, input_points: Vec, tx_out_change: Option<&TxOut>, + expiry_height: BlockHeight, ) -> Result<()> { let (pool, circuit_version) = shielded_pool_and_circuit(&self.zcash_params()?, to_block_height(current_height))?; @@ -283,7 +326,7 @@ impl OmniConnector { let txid_parts = auth_data.digest(TxIdDigester); let transparent_bundle = self - .get_transparent_bundle(current_height, input_points, tx_out_change) + .get_transparent_bundle(current_height, input_points, tx_out_change, expiry_height) .await?; let shielded_sig_commitment = match pool { @@ -374,34 +417,71 @@ impl OmniConnector { tx_out_change: Option<&TxOut>, memo: Option, ) -> Result<(Vec, u32)> { - let recipient = utxo_utils::extract_orchard_address(&recipient).map_err(|err| { - BridgeSdkError::ZCashOrchardBundleError(format!( - "Error on extract Orchard Address: {err}" - )) - })?; + let recipient = extract_orchard_recipient(&recipient)?; - let utxo_bridge_client = self.utxo_bridge_client(ChainKind::Zcash)?; + let current_height = self + .utxo_bridge_client(ChainKind::Zcash)? + .get_current_height() + .await?; - let current_height = utxo_bridge_client.get_current_height().await?; + // Withdrawal/RBF: expiry = current height + the contract's expiry gap, with the + // zip317 fee absorbed via the caller-supplied transparent change output. + let expiry_delta = self + .near_bridge_client()? + .get_expiry_height_gap(ChainKind::Zcash) + .await?; + let expiry_height = + BlockHeight::from_u32(current_height.try_into().unwrap_or(u32::MAX)) + expiry_delta; - let mut builder = self - .get_builder_with_transparent(current_height, input_points.clone(), tx_out_change) + let memo_bytes = parse_memo_bytes(memo)?; + + let res = self + .build_orchard_bundle( + recipient, + amount, + input_points, + tx_out_change, + memo_bytes, + current_height, + expiry_height, + OrchardFee::Zip317, + ) .await?; - let rng = OsRng; + Ok((res, expiry_height.into())) + } - let recipient = recipient.into_option().ok_or_else(|| { - BridgeSdkError::ZCashOrchardBundleError("Recipient Orchard address is None".to_string()) - })?; + /// Shared Orchard-bundle builder for shielded Zcash payouts. Spends the + /// transparent `input_points` into a single Orchard output of `amount` to + /// `recipient`, then extracts and serializes the v5 Orchard bundle. + /// + /// `expiry_height` and `fee` must match the transaction the counterparty will + /// reconstruct and broadcast, because the Orchard binding signature is bound + /// to the sighash (which covers both). Withdrawals use the contract's expiry + /// gap + zip317 + a transparent change output; refunds use `expiry_height = 0` + /// + a fixed fee equal to the request's `gas_fee` + no change. + #[allow(clippy::too_many_arguments)] + async fn build_orchard_bundle( + &self, + recipient: orchard::Address, + amount: u64, + input_points: Vec, + tx_out_change: Option<&TxOut>, + memo_bytes: MemoBytes, + current_height: u64, + expiry_height: BlockHeight, + fee: OrchardFee, + ) -> Result> { + let mut builder = self + .get_builder_with_transparent( + current_height, + input_points.clone(), + tx_out_change, + expiry_height, + ) + .await?; - let memo_bytes = match memo { - Some(m) => Memo::from_str(&m).map(MemoBytes::from).map_err(|err| { - BridgeSdkError::ZCashOrchardBundleError(format!( - "Invalid memo (max 512 bytes): {err:?}" - )) - })?, - None => MemoBytes::empty(), - }; + let rng = OsRng; let (pool, circuit_version) = shielded_pool_and_circuit(&self.zcash_params()?, to_block_height(current_height))?; @@ -429,11 +509,31 @@ impl OmniConnector { )) })?; - let zcash_primitives::transaction::builder::PcztResult { pczt_parts, .. } = builder - .build_for_pczt(rng, &zip317::FeeRule::standard()) - .map_err(|err| { - BridgeSdkError::ZCashOrchardBundleError(format!("Error on build PCZT: {err}")) - })?; + let pczt_parts = match fee { + OrchardFee::Zip317 => { + builder + .build_for_pczt(rng, &zip317::FeeRule::standard()) + .map_err(|err| { + BridgeSdkError::ZCashOrchardBundleError(format!( + "Error on build PCZT: {err}" + )) + })? + .pczt_parts + } + OrchardFee::Fixed(fee) => { + let fee_rule = zcash_primitives::transaction::fees::fixed::FeeRule::non_standard( + zcash_protocol::value::Zatoshis::const_from_u64(fee), + ); + builder + .build_for_pczt(rng, &fee_rule) + .map_err(|err| { + BridgeSdkError::ZCashOrchardBundleError(format!( + "Error on build PCZT: {err}" + )) + })? + .pczt_parts + } + }; let pczt = Creator::build_from_parts(pczt_parts).ok_or_else(|| { BridgeSdkError::ZCashOrchardBundleError( @@ -489,10 +589,15 @@ impl OmniConnector { } let auth_data = tx.into_data(); - let expiry_height = auth_data.expiry_height().into(); - self.validate_orchard(&auth_data, current_height, input_points, tx_out_change) - .await?; + self.validate_orchard( + &auth_data, + current_height, + input_points, + tx_out_change, + expiry_height, + ) + .await?; let mut res = Vec::new(); match pool { @@ -515,7 +620,90 @@ impl OmniConnector { )) })?; - Ok((res, expiry_height)) + Ok(res) + } + + /// Build the `ChainSpecificData` (Orchard bundle) for a shielded Zcash refund + /// from a pending `RefundRequest`, identified by its UTXO storage key + /// (`{tx_id}@{vout}`). + /// + /// The bundle spends the single deposit UTXO and pays `amount − gas_fee` to the + /// request's `refund_address`, with no transparent change and `expiry_height = 0` + /// — matching the transaction the contract reconstructs in `execute_refund` and + /// broadcasts, so the Orchard binding signature is valid on-network. The + /// `refund_address` must carry an Orchard receiver; for a transparent refund + /// pass `chain_specific_data = None` to `btc_execute_refund` instead. + pub async fn build_refund_chain_specific_data( + &self, + utxo_storage_key: &str, + ) -> Result { + let near_bridge_client = self.near_bridge_client()?; + + let refund_request = near_bridge_client + .get_refund_request(ChainKind::Zcash, utxo_storage_key) + .await?; + + let gas_fee = u64::try_from(refund_request.gas_fee).map_err(|_| { + BridgeSdkError::InvalidArgument("Refund gas_fee overflows u64".to_string()) + })?; + let amount = u64::try_from(refund_request.amount).map_err(|_| { + BridgeSdkError::InvalidArgument("Refund amount overflows u64".to_string()) + })?; + let refund_amount = amount.checked_sub(gas_fee).ok_or_else(|| { + BridgeSdkError::InvalidArgument("Deposit amount too small to cover gas fee".to_string()) + })?; + if refund_amount == 0 { + return Err(BridgeSdkError::InvalidArgument( + "Refund amount is zero after gas fee".to_string(), + )); + } + + // The deposit address path is `sha256(deposit_msg_json)`; the contract stores + // `deposit_msg_json` as exactly the bytes it hashes, so this reproduces the + // path needed to derive the input's MPC public key. + let path = hex::encode(sha2::Sha256::digest( + refund_request.deposit_msg_json.as_bytes(), + )); + + let (txid, _) = parse_utxo_path(utxo_storage_key)?; + let vout = u32::try_from(refund_request.vout).map_err(|_| { + BridgeSdkError::InvalidArgument("Refund vout overflows u32".to_string()) + })?; + + let input_point = InputPoint { + out_point: OutPoint { txid, vout }, + utxo: utxo_utils::UTXO { + path, + tx_bytes: refund_request.tx_bytes.0.clone(), + vout, + balance: amount, + }, + }; + + let recipient = extract_orchard_recipient(&refund_request.refund_address)?; + + let current_height = self + .utxo_bridge_client(ChainKind::Zcash)? + .get_current_height() + .await?; + + let bundle_bytes = self + .build_orchard_bundle( + recipient, + refund_amount, + vec![input_point], + None, + MemoBytes::empty(), + current_height, + BlockHeight::from_u32(0), + OrchardFee::Fixed(gas_fee), + ) + .await?; + + Ok(ChainSpecificData { + orchard_bundle_bytes: bundle_bytes.into(), + expiry_height: 0, + }) } /// Internal helper to regenerate an Orchard bundle for an existing pending transaction. diff --git a/bridge-sdk/near-rpc-client/Cargo.toml b/bridge-sdk/near-rpc-client/Cargo.toml index 3e8e6273..faf0674d 100644 --- a/bridge-sdk/near-rpc-client/Cargo.toml +++ b/bridge-sdk/near-rpc-client/Cargo.toml @@ -16,6 +16,7 @@ near-crypto.workspace = true near-token.workspace = true borsh.workspace = true base64.workspace = true +tracing.workspace = true [dev-dependencies] hex.workspace = true diff --git a/bridge-sdk/near-rpc-client/src/near_rpc_client.rs b/bridge-sdk/near-rpc-client/src/near_rpc_client.rs index 5b21d7a0..907d440b 100644 --- a/bridge-sdk/near-rpc-client/src/near_rpc_client.rs +++ b/bridge-sdk/near-rpc-client/src/near_rpc_client.rs @@ -19,9 +19,12 @@ use tokio::time; pub const DEFAULT_WAIT_FINAL_OUTCOME_TIMEOUT_SEC: u64 = 500; +// Total request timeout. Generous on purpose: UTXO-chain calls like +// `request_refund` carry the raw deposit tx + merkle proof in `args`, and +// uploading that payload to a slow (archival) RPC can far exceed 30s. static DEFAULT_CONNECTOR: LazyLock = LazyLock::new(|| { JsonRpcClient::with(new_near_rpc_client(Some(std::time::Duration::from_secs( - 30, + 120, )))) }); @@ -87,7 +90,10 @@ fn new_near_rpc_client(timeout: Option) -> reqwest::Client let mut builder = reqwest::Client::builder().default_headers(headers); if let Some(timeout) = timeout { - builder = builder.timeout(timeout).connect_timeout(timeout); + // `timeout` bounds the whole request (upload + server processing); + // connecting should still fail fast when the host is unreachable. + let connect_timeout = std::cmp::min(timeout, std::time::Duration::from_secs(10)); + builder = builder.timeout(timeout).connect_timeout(connect_timeout); } builder.build().unwrap() } @@ -242,11 +248,29 @@ pub async fn change( // hardware wallet) instead of signing and broadcasting it. TxSigner::DryRun { .. } => print_unsigned_transaction(&transaction), TxSigner::InMemory(signer) => { - let request = methods::broadcast_tx_async::RpcBroadcastTxAsyncRequest { - signed_transaction: transaction.sign(&near_crypto::Signer::InMemory(signer)), - }; - - Ok(client.call(request).await?) + let signed_transaction = transaction.sign(&near_crypto::Signer::InMemory(signer)); + let tx_hash = signed_transaction.get_hash(); + + tracing::info!( + tx_hash = tx_hash.to_string(), + "Broadcasting NEAR transaction" + ); + + // `send_tx` (unlike `broadcast_tx_async`) reports transaction-pool + // rejections — oversized transaction, not enough balance for the + // attached deposit, invalid nonce — back to the caller instead of + // silently dropping the transaction and leaving its status UNKNOWN + // forever. `Included` returns as soon as the transaction is + // accepted into a block; callers that need a stronger guarantee + // keep polling in `wait_for_tx`. + client + .call(methods::send_tx::RpcSendTransactionRequest { + signed_transaction, + wait_until: near_primitives::views::TxExecutionStatus::Included, + }) + .await?; + + Ok(tx_hash) } } }