From 657c2a2fbcc053b5ae02483fd09808ce3f5cc026 Mon Sep 17 00:00:00 2001 From: Arshia Date: Tue, 19 May 2026 18:10:41 +0200 Subject: [PATCH] feat(wallet): implement RGB coloring for PSBTs and on-chain swap tests - Added `color_psbt_stage` and `color_psbt_finalize` methods to handle RGB coloring on PSBTs. - Updated `color_psbt` to utilize the new methods and return asset beneficiaries. - Introduced `generate_transfer` method for creating RGB transfers based on beneficiaries. - Refactored PSBT signing logic to be publicly accessible. - Added tests for on-chain swaps involving RGB assets, covering various scenarios for maker and taker balances. - Included assertions to verify BTC and asset balances post-swap. --- bindings/uniffi/src/lib.rs | 131 ++++- bindings/uniffi/src/rgb-lib.udl | 130 +++++ src/lib.rs | 7 +- src/wallet/mod.rs | 8 +- src/wallet/objects.rs | 192 ++++++++ src/wallet/offline.rs | 773 ++++++++++++++++++++++++++++++ src/wallet/online.rs | 813 ++++++++++++++++++++++++++++++++ src/wallet/rust_only.rs | 190 ++++++-- src/wallet/singlesig.rs | 727 +++++++++++++++++++++++++++- src/wallet/test/mod.rs | 2 + src/wallet/test/onchain_swap.rs | 711 ++++++++++++++++++++++++++++ 11 files changed, 3641 insertions(+), 43 deletions(-) create mode 100644 src/wallet/test/onchain_swap.rs diff --git a/bindings/uniffi/src/lib.rs b/bindings/uniffi/src/lib.rs index d52218e3..dccb1286 100644 --- a/bindings/uniffi/src/lib.rs +++ b/bindings/uniffi/src/lib.rs @@ -19,8 +19,11 @@ use rgb_lib::{ InvoiceData as RgbLibInvoiceData, Media, Metadata, MultisigKeys, MultisigOnlineOptions, MultisigVotingStatus as RgbLibMultisigVotingStatus, MultisigWallet as RgbLibMultisigWallet, Online, OnlineOptions, Operation as RgbLibOperation, OperationInfo as RgbLibOperationInfo, - OperationResult, Outpoint, PendingVanillaTx, ProofOfReserves, PsbtInputInfo, - PsbtInspection, PsbtOutputInfo, ReceiveData, Recipient as RgbLibRecipient, + OnchainSwapAssetHistory, OnchainSwapCompletion, OnchainSwapConsignment, OnchainSwapInput, + OnchainSwapLeg, OnchainSwapLegKind, OnchainSwapOffer, OnchainSwapProposal, + OnchainSwapReceiveResult as RgbLibOnchainSwapReceiveResult, OnchainSwapRequest, + OnchainSwapRole, OperationResult, Outpoint, PendingVanillaTx, ProofOfReserves, + PsbtInputInfo, PsbtInspection, PsbtOutputInfo, ReceiveData, Recipient as RgbLibRecipient, RecipientInfo as RgbLibRecipientInfo, RecipientType, RefreshFilter, RefreshTransferStatus, RefreshedTransfer, RespondToOperation as RgbLibRespondToOperation, RgbAllocation as RgbLibRgbAllocation, RgbInputInfo as RgbLibRgbInputInfo, @@ -98,6 +101,16 @@ impl From for RgbLibAssignment { } } } +pub struct OnchainSwapReceiveResult { + pub assignments: Vec, +} +impl From for OnchainSwapReceiveResult { + fn from(orig: RgbLibOnchainSwapReceiveResult) -> Self { + Self { + assignments: orig.assignments.into_iter().map(|a| a.into()).collect(), + } + } +} pub struct InvoiceData { pub recipient_id: String, pub asset_schema: Option, @@ -1390,6 +1403,86 @@ impl Wallet { self._get_wallet().send_btc_end(online, signed_psbt) } + fn create_swap_offer( + &self, + maker_gives: OnchainSwapLeg, + maker_receives: OnchainSwapLeg, + network_fee_sat: u64, + expiration_timestamp: Option, + proxy_url: Option, + ) -> Result { + self._get_wallet().create_swap_offer( + maker_gives, + maker_receives, + network_fee_sat, + expiration_timestamp, + proxy_url, + ) + } + + fn accept_swap_offer( + &self, + online: Online, + offer: OnchainSwapOffer, + min_confirmations: u8, + skip_sync: bool, + ) -> Result { + self._get_wallet() + .accept_swap_offer(online, offer, min_confirmations, skip_sync) + } + + fn accept_swap_request( + &self, + online: Online, + request: OnchainSwapRequest, + min_confirmations: u8, + skip_sync: bool, + ) -> Result { + self._get_wallet() + .accept_swap_request(online, request, min_confirmations, skip_sync) + } + + fn complete_swap_proposal( + &self, + online: Online, + proposal: OnchainSwapProposal, + min_confirmations: u8, + skip_sync: bool, + ) -> Result { + self._get_wallet() + .complete_swap_proposal(online, proposal, min_confirmations, skip_sync) + } + + fn process_swap_completion( + &self, + online: Online, + completion: OnchainSwapCompletion, + ) -> Result { + self._get_wallet().process_swap_completion(online, completion) + } + + fn broadcast_swap_completion( + &self, + online: Online, + completion: OnchainSwapCompletion, + ) -> Result { + self._get_wallet() + .broadcast_swap_completion(online, completion) + } + + fn accept_swap_transfers( + &self, + online: Online, + completion: OnchainSwapCompletion, + role: OnchainSwapRole, + skip_sync: bool, + ) -> Result { + Ok(self + ._get_wallet() + .accept_swap_transfers(online, completion, role, skip_sync)? + .into()) + } + fn sync(&self, online: Online, options: SyncOptions) -> Result<(), RgbLibError> { self._get_wallet().sync(online, options.into()) } @@ -1812,3 +1905,37 @@ impl MultisigWallet { } uniffi::deps::static_assertions::assert_impl_all!(MultisigWallet: Sync, Send); + +#[cfg(test)] +mod tests { + const UDL: &str = include_str!("rgb-lib.udl"); + + #[test] + fn uniffi_exposes_swap_api_without_raw_rgb_primitives() { + for method in [ + "create_swap_offer", + "accept_swap_offer", + "accept_swap_request", + "complete_swap_proposal", + "process_swap_completion", + "broadcast_swap_completion", + "accept_swap_transfers", + ] { + assert!(UDL.contains(method), "missing {method} from UDL"); + } + + for raw_name in [ + "color_psbt", + "color_psbt_and_consume", + "consume_fascia", + "Fascia", + "RgbTransfer", + "BuilderSeal", + ] { + assert!( + !UDL.contains(raw_name), + "raw primitive {raw_name} must not be exposed in UDL" + ); + } + } +} diff --git a/bindings/uniffi/src/rgb-lib.udl b/bindings/uniffi/src/rgb-lib.udl index 6c101b8d..380b538e 100644 --- a/bindings/uniffi/src/rgb-lib.udl +++ b/bindings/uniffi/src/rgb-lib.udl @@ -768,6 +768,103 @@ dictionary RgbInspection { sequence operations; }; +[Remote] +enum OnchainSwapRole { + "Maker", + "Taker", +}; + +[Remote] +enum OnchainSwapLegKind { + "Btc", + "Rgb", +}; + +[Remote] +dictionary OnchainSwapLeg { + OnchainSwapLegKind kind; + string? asset_id; + u64 amount; +}; + +[Remote] +dictionary OnchainSwapInput { + Outpoint outpoint; + u64 amount_sat; + string script_pubkey_hex; +}; + +[Remote] +dictionary OnchainSwapConsignment { + string asset_id; + string path; + string? endpoint; + string txid; + u32 vout; + u64 blinding; + string recipient_id; +}; + +[Remote] +dictionary OnchainSwapAssetHistory { + string asset_id; + string path; + string? endpoint; + string recipient_id; +}; + +[Remote] +dictionary OnchainSwapOffer { + string swap_id; + OnchainSwapLeg maker_gives; + OnchainSwapLeg maker_receives; + BitcoinNetwork bitcoin_network; + u64 network_fee_sat; + u64 rgb_output_sat; + u64? expiration_timestamp; + string? maker_btc_address; + string? maker_rgb_recipient_id; + string? maker_rgb_script_pubkey_hex; + u64? maker_rgb_blinding; + string? proxy_url; +}; + +[Remote] +dictionary OnchainSwapRequest { + OnchainSwapOffer offer; + sequence taker_inputs; + string? taker_btc_address; + string? taker_rgb_recipient_id; + string? taker_rgb_script_pubkey_hex; + u64? taker_rgb_blinding; + string taker_change_script_pubkey_hex; +}; + +[Remote] +dictionary OnchainSwapProposal { + OnchainSwapRequest request; + sequence maker_inputs; + string maker_change_script_pubkey_hex; + string psbt; + string txid; + sequence consignments; + OnchainSwapAssetHistory? maker_history; +}; + +[Remote] +dictionary OnchainSwapCompletion { + OnchainSwapProposal proposal; + string psbt; + string? finalized_psbt; + string txid; + sequence consignments; + OnchainSwapAssetHistory? taker_history; +}; + +dictionary OnchainSwapReceiveResult { + sequence assignments; +}; + [Remote] dictionary InitOperationResult { string psbt; @@ -979,6 +1076,39 @@ interface Wallet { [Throws=RgbLibError] string send_btc_end(Online online, string signed_psbt); + [Throws=RgbLibError] + OnchainSwapOffer create_swap_offer( + OnchainSwapLeg maker_gives, OnchainSwapLeg maker_receives, + u64 network_fee_sat, u64? expiration_timestamp, string? proxy_url); + + [Throws=RgbLibError] + OnchainSwapRequest accept_swap_offer( + Online online, OnchainSwapOffer offer, u8 min_confirmations, + boolean skip_sync); + + [Throws=RgbLibError] + OnchainSwapProposal accept_swap_request( + Online online, OnchainSwapRequest request, u8 min_confirmations, + boolean skip_sync); + + [Throws=RgbLibError] + OnchainSwapCompletion complete_swap_proposal( + Online online, OnchainSwapProposal proposal, u8 min_confirmations, + boolean skip_sync); + + [Throws=RgbLibError] + OnchainSwapCompletion process_swap_completion( + Online online, OnchainSwapCompletion completion); + + [Throws=RgbLibError] + string broadcast_swap_completion( + Online online, OnchainSwapCompletion completion); + + [Throws=RgbLibError] + OnchainSwapReceiveResult accept_swap_transfers( + Online online, OnchainSwapCompletion completion, OnchainSwapRole role, + boolean skip_sync); + [Throws=RgbLibError] void sync(Online online, SyncOptions options); diff --git a/src/lib.rs b/src/lib.rs index df2ff99a..97f6cf1a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -151,7 +151,10 @@ use bdk_wallet::{ #[cfg(any(feature = "electrum", feature = "esplora"))] use bdk_wallet::{ Update, - bitcoin::{Transaction as BdkTransaction, blockdata::fee_rate::FeeRate, hashes::HashEngine}, + bitcoin::{ + Sequence, Transaction as BdkTransaction, TxIn, Witness, absolute::LockTime, + blockdata::fee_rate::FeeRate, hashes::HashEngine, transaction::Version as TxVersion, + }, chain::{ DescriptorExt, spk_client::{FullScanRequest, FullScanResponse, SyncRequest, SyncResponse}, @@ -270,7 +273,7 @@ use crate::{ error::IndexerError, utils::{ INDEXER_STOP_GAP, OffchainResolver, check_proxy, get_indexer_and_resolver, hash_file, - script_buf_from_recipient_id, + recipient_id_from_script_buf, script_buf_from_recipient_id, }, wallet::{AssignmentsCollection, Indexer, multisig::RespondToOperation}, }; diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 989b06d1..c8fccc26 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -35,9 +35,11 @@ pub use objects::{ }; #[cfg(any(feature = "electrum", feature = "esplora"))] pub use objects::{ - BurnBeginResult, BurnDetails, InflateBeginResult, InflateDetails, OnlineOptions, - OperationResult, RefreshFilter, RefreshResult, RefreshTransferStatus, RefreshedTransfer, - SendBeginResult, SendDetails, + BurnBeginResult, BurnDetails, InflateBeginResult, InflateDetails, OnchainSwapAssetHistory, + OnchainSwapCompletion, OnchainSwapConsignment, OnchainSwapInput, OnchainSwapLeg, + OnchainSwapLegKind, OnchainSwapOffer, OnchainSwapProposal, OnchainSwapReceiveResult, + OnchainSwapRequest, OnchainSwapRole, OnlineOptions, OperationResult, RefreshFilter, + RefreshResult, RefreshTransferStatus, RefreshedTransfer, SendBeginResult, SendDetails, }; pub use offline::RgbWalletOpsOffline; #[cfg(any(feature = "electrum", feature = "esplora"))] diff --git a/src/wallet/objects.rs b/src/wallet/objects.rs index 4dd3d71b..dad32eea 100644 --- a/src/wallet/objects.rs +++ b/src/wallet/objects.rs @@ -1693,6 +1693,198 @@ pub struct SendDetails { pub is_donation: bool, } +/// The role of the wallet for an on-chain swap step. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg(any(feature = "electrum", feature = "esplora"))] +pub enum OnchainSwapRole { + /// The party creating the offer + Maker, + /// The party accepting the offer + Taker, +} + +/// The type of an on-chain swap leg. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg(any(feature = "electrum", feature = "esplora"))] +pub enum OnchainSwapLegKind { + /// A bitcoin amount in satoshis + Btc, + /// An RGB asset amount + Rgb, +} + +/// A single side of an on-chain swap. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg(any(feature = "electrum", feature = "esplora"))] +#[cfg_attr(feature = "camel_case", serde(rename_all = "camelCase"))] +pub struct OnchainSwapLeg { + /// Leg kind + pub kind: OnchainSwapLegKind, + /// RGB asset ID, required when `kind` is `Rgb` + pub asset_id: Option, + /// Amount in satoshis for BTC legs, or asset units for RGB legs + #[serde(deserialize_with = "from_str_or_number_mandatory")] + pub amount: u64, +} + +/// A consignment produced or consumed by an on-chain swap. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg(any(feature = "electrum", feature = "esplora"))] +#[cfg_attr(feature = "camel_case", serde(rename_all = "camelCase"))] +pub struct OnchainSwapConsignment { + /// RGB asset ID + pub asset_id: String, + /// Local consignment cache path, empty for proxy-delivered swap payloads + pub path: String, + /// Proxy transport endpoint used to retrieve the consignment + pub endpoint: Option, + /// Witness transaction ID + pub txid: String, + /// Witness vout receiving the RGB assignment + pub vout: u32, + /// Seal blinding needed by the receiver + #[serde(deserialize_with = "from_str_or_number_mandatory")] + pub blinding: u64, + /// Proxy lookup key (the receiver's RGB recipient ID). Distinct per consignment so that + /// RGB-for-RGB swaps with two consignments under the same witness transaction do not collide + /// in the proxy server's keyspace. + pub recipient_id: String, +} + +/// Asset history needed by a counterparty to validate/import an RGB asset before a swap step. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg(any(feature = "electrum", feature = "esplora"))] +#[cfg_attr(feature = "camel_case", serde(rename_all = "camelCase"))] +pub struct OnchainSwapAssetHistory { + /// RGB asset ID + pub asset_id: String, + /// Local history cache path, empty for proxy-delivered swap payloads + pub path: String, + /// Proxy transport endpoint used to retrieve the history file + pub endpoint: Option, + /// Proxy lookup key + pub recipient_id: String, +} + +/// A Bitcoin input selected for an on-chain swap. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg(any(feature = "electrum", feature = "esplora"))] +#[cfg_attr(feature = "camel_case", serde(rename_all = "camelCase"))] +pub struct OnchainSwapInput { + /// The selected outpoint + pub outpoint: Outpoint, + /// Previous output value in satoshis + #[serde(deserialize_with = "from_str_or_number_mandatory")] + pub amount_sat: u64, + /// Previous output script pubkey + pub script_pubkey_hex: String, +} + +/// A maker-created on-chain swap offer. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg(any(feature = "electrum", feature = "esplora"))] +#[cfg_attr(feature = "camel_case", serde(rename_all = "camelCase"))] +pub struct OnchainSwapOffer { + /// Swap ID + pub swap_id: String, + /// Maker gives this leg + pub maker_gives: OnchainSwapLeg, + /// Maker receives this leg + pub maker_receives: OnchainSwapLeg, + /// Bitcoin network + pub bitcoin_network: BitcoinNetwork, + /// Network fee to reserve for the swap transaction + #[serde(deserialize_with = "from_str_or_number_mandatory")] + pub network_fee_sat: u64, + /// Sat value used for each RGB witness output + #[serde(deserialize_with = "from_str_or_number_mandatory")] + pub rgb_output_sat: u64, + /// Optional expiration timestamp + pub expiration_timestamp: Option, + /// Maker BTC receive address, present if maker receives BTC + pub maker_btc_address: Option, + /// Maker RGB receive recipient ID, present if maker receives RGB + pub maker_rgb_recipient_id: Option, + /// Maker RGB receive script pubkey, present if maker receives RGB + pub maker_rgb_script_pubkey_hex: Option, + /// Maker RGB receive blinding, present if maker receives RGB + pub maker_rgb_blinding: Option, + /// Required proxy URL for consignment and asset-history delivery + pub proxy_url: Option, +} + +/// A taker response to an on-chain swap offer. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg(any(feature = "electrum", feature = "esplora"))] +#[cfg_attr(feature = "camel_case", serde(rename_all = "camelCase"))] +pub struct OnchainSwapRequest { + /// Swap offer + pub offer: OnchainSwapOffer, + /// Taker inputs serialized for coordinated PSBT construction + pub taker_inputs: Vec, + /// Taker BTC receive address, present if taker receives BTC + pub taker_btc_address: Option, + /// Taker RGB receive recipient ID, present if taker receives RGB + pub taker_rgb_recipient_id: Option, + /// Taker RGB receive script pubkey, present if taker receives RGB + pub taker_rgb_script_pubkey_hex: Option, + /// Taker RGB receive blinding, present if taker receives RGB + pub taker_rgb_blinding: Option, + /// Taker BTC/RGB change script pubkey + pub taker_change_script_pubkey_hex: String, +} + +/// A maker-created proposal containing the coordinated PSBT. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg(any(feature = "electrum", feature = "esplora"))] +#[cfg_attr(feature = "camel_case", serde(rename_all = "camelCase"))] +pub struct OnchainSwapProposal { + /// Swap request + pub request: OnchainSwapRequest, + /// Maker inputs serialized for coordinated PSBT construction + pub maker_inputs: Vec, + /// Maker BTC/RGB change script pubkey + pub maker_change_script_pubkey_hex: String, + /// PSBT after maker-side processing + pub psbt: String, + /// Witness transaction ID after any maker coloring + pub txid: String, + /// Consignments produced by the maker + pub consignments: Vec, + /// Maker asset history, required when the maker sends RGB and the counterparty needs the + /// contract history before completing the swap. + pub maker_history: Option, +} + +/// A taker-completed swap ready for maker verification/broadcast. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg(any(feature = "electrum", feature = "esplora"))] +#[cfg_attr(feature = "camel_case", serde(rename_all = "camelCase"))] +pub struct OnchainSwapCompletion { + /// Swap proposal + pub proposal: OnchainSwapProposal, + /// Fully or partially signed PSBT after taker-side processing + pub psbt: String, + /// Finalized PSBT when finalization succeeds + pub finalized_psbt: Option, + /// Witness transaction ID + pub txid: String, + /// Consignments produced by both parties + pub consignments: Vec, + /// Taker asset history, required when the taker sends RGB and the maker needs the contract + /// history before processing the completion. + pub taker_history: Option, +} + +/// Result of accepting swap RGB transfers. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg(any(feature = "electrum", feature = "esplora"))] +#[cfg_attr(feature = "camel_case", serde(rename_all = "camelCase"))] +pub struct OnchainSwapReceiveResult { + /// Assignments accepted from received consignments + pub assignments: Vec, +} + /// The result of an operation. #[derive(Debug, Clone, Deserialize, Serialize)] #[cfg(any(feature = "electrum", feature = "esplora"))] diff --git a/src/wallet/offline.rs b/src/wallet/offline.rs index bad64281..2f5471ea 100644 --- a/src/wallet/offline.rs +++ b/src/wallet/offline.rs @@ -10,6 +10,13 @@ const CONSIGNMENT_RCV_FILE: &str = "rcv_compose.rgbc"; const CONSIGNMENT_RCV_META_FILE: &str = "rcv_compose.meta.json"; +#[cfg(any(feature = "electrum", feature = "esplora"))] +pub(crate) const SWAP_OFFER_FILE: &str = "offer.json"; +#[cfg(any(feature = "electrum", feature = "esplora"))] +pub(crate) const SWAP_REQUEST_FILE: &str = "request.json"; +#[cfg(any(feature = "electrum", feature = "esplora"))] +pub(crate) const SWAP_PROPOSAL_FILE: &str = "proposal.json"; + const ASSET_ID_PREFIX: &str = "rgb:"; pub(crate) const UDA_FIXED_INDEX: u32 = 0; @@ -2602,6 +2609,600 @@ pub trait WalletOffline: WalletBackup { } Ok(()) } + + // ─── On-chain swap ───────────────────────────────────────────────────────── + + #[cfg(any(feature = "electrum", feature = "esplora"))] + fn create_swap_offer_impl( + &mut self, + _txn: &DbTxn, + maker_gives: OnchainSwapLeg, + maker_receives: OnchainSwapLeg, + network_fee_sat: u64, + expiration_timestamp: Option, + proxy_url: Option, + ) -> Result { + swap_validate_legs(&maker_gives, &maker_receives)?; + swap_validate_proxy_url(&proxy_url)?; + let ( + maker_btc_address, + maker_rgb_recipient_id, + maker_rgb_script_pubkey_hex, + maker_rgb_blinding, + ) = if matches!(maker_receives.kind, OnchainSwapLegKind::Btc) { + let addr = self.get_new_addresses(KeychainKind::Internal, 1)?; + (Some(addr.to_string()), None, None, None) + } else { + let script = self + .get_new_addresses(KeychainKind::External, 1)? + .script_pubkey(); + let blinding = swap_random_blinding(); + ( + None, + Some(recipient_id_from_script_buf( + script.clone(), + self.bitcoin_network(), + )), + Some(script.to_hex_string()), + Some(blinding), + ) + }; + Ok(OnchainSwapOffer { + swap_id: swap_random_id(), + maker_gives, + maker_receives, + bitcoin_network: self.bitcoin_network(), + network_fee_sat, + rgb_output_sat: SWAP_DEFAULT_RGB_OUTPUT_SAT, + expiration_timestamp, + maker_btc_address, + maker_rgb_recipient_id, + maker_rgb_script_pubkey_hex, + maker_rgb_blinding, + proxy_url, + }) + } +} + +// ─── On-chain swap: free helper functions (offline-safe) ────────────────────── + +#[cfg(any(feature = "electrum", feature = "esplora"))] +pub(crate) const SWAP_DEFAULT_RGB_OUTPUT_SAT: u64 = 1_000; + +#[cfg(any(feature = "electrum", feature = "esplora"))] +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub(crate) enum SwapDirection { + RgbForBtc, + BtcForRgb, + RgbForRgb, +} + +#[cfg(any(feature = "electrum", feature = "esplora"))] +pub(crate) fn swap_invalid(details: impl Into) -> Error { + Error::InvalidDetails { + details: details.into(), + } +} + +#[cfg(any(feature = "electrum", feature = "esplora"))] +pub(crate) fn swap_random_id() -> String { + rand::rng() + .sample_iter(&Alphanumeric) + .take(32) + .map(char::from) + .collect() +} + +#[cfg(any(feature = "electrum", feature = "esplora"))] +pub(crate) fn swap_random_blinding() -> u64 { + rand::rng().random_range(1..=u64::MAX) +} + +/// Derive a deterministic MPC entropy for a swap from its identifier. Both parties compute the +/// same value so the MPC commitment they each (re)produce is identical. +#[cfg(any(feature = "electrum", feature = "esplora"))] +pub(crate) fn swap_mpc_entropy(swap_id: &str) -> u64 { + let hash = hash_bytes_hex(swap_id.as_bytes()); + let mut bytes = [0u8; 8]; + let src = hash.as_bytes(); + let take = src.len().min(16); + let hex_slice = &hash[..take]; + if let Ok(parsed) = u64::from_str_radix(hex_slice, 16) { + return parsed.max(1); + } + bytes.copy_from_slice(&src[..8]); + u64::from_be_bytes(bytes).max(1) +} + +#[cfg(any(feature = "electrum", feature = "esplora"))] +pub(crate) fn swap_ensure_not_expired(offer: &OnchainSwapOffer) -> Result<(), Error> { + if let Some(expiration_timestamp) = offer.expiration_timestamp { + let current_timestamp = u64::try_from(now().unix_timestamp()).unwrap_or_default(); + if expiration_timestamp <= current_timestamp { + return Err(swap_invalid("swap offer expired")); + } + } + Ok(()) +} + +#[cfg(any(feature = "electrum", feature = "esplora"))] +pub(crate) fn swap_validate_leg(leg: &OnchainSwapLeg) -> Result<(), Error> { + if leg.amount == 0 { + return Err(Error::InvalidAmountZero); + } + match leg.kind { + OnchainSwapLegKind::Btc => { + if leg.asset_id.is_some() { + return Err(swap_invalid("BTC swap legs must not include an asset ID")); + } + } + OnchainSwapLegKind::Rgb => { + let asset_id = leg + .asset_id + .as_ref() + .ok_or_else(|| swap_invalid("RGB swap legs require an asset ID"))?; + ContractId::from_str(asset_id) + .map_err(|e| swap_invalid(format!("invalid RGB asset ID: {e}")))?; + } + } + Ok(()) +} + +#[cfg(any(feature = "electrum", feature = "esplora"))] +pub(crate) fn swap_validate_legs( + gives: &OnchainSwapLeg, + receives: &OnchainSwapLeg, +) -> Result { + swap_validate_leg(gives)?; + swap_validate_leg(receives)?; + if gives == receives { + return Err(swap_invalid("swap legs cannot be identical")); + } + if matches!(gives.kind, OnchainSwapLegKind::Btc) + && matches!(receives.kind, OnchainSwapLegKind::Btc) + { + return Err(swap_invalid("BTC-for-BTC swaps are not supported")); + } + if matches!(gives.kind, OnchainSwapLegKind::Rgb) + && matches!(receives.kind, OnchainSwapLegKind::Rgb) + && gives.asset_id == receives.asset_id + { + return Err(swap_invalid( + "RGB-for-RGB swaps require different asset IDs", + )); + } + Ok(match (gives.kind, receives.kind) { + (OnchainSwapLegKind::Rgb, OnchainSwapLegKind::Btc) => SwapDirection::RgbForBtc, + (OnchainSwapLegKind::Btc, OnchainSwapLegKind::Rgb) => SwapDirection::BtcForRgb, + (OnchainSwapLegKind::Rgb, OnchainSwapLegKind::Rgb) => SwapDirection::RgbForRgb, + (OnchainSwapLegKind::Btc, OnchainSwapLegKind::Btc) => unreachable!("BTC-for-BTC rejected"), + }) +} + +#[cfg(any(feature = "electrum", feature = "esplora"))] +pub(crate) fn swap_parse_script(script_hex: &str) -> Result { + ScriptBuf::from_hex(script_hex).map_err(|e| swap_invalid(format!("invalid script: {e}"))) +} + +#[cfg(any(feature = "electrum", feature = "esplora"))] +pub(crate) fn swap_input_to_outpoint(input: &OnchainSwapInput) -> BdkOutPoint { + BdkOutPoint::from(input.outpoint.clone()) +} + +#[cfg(any(feature = "electrum", feature = "esplora"))] +pub(crate) fn swap_input_txout(input: &OnchainSwapInput) -> Result { + Ok(TxOut { + value: BdkAmount::from_sat(input.amount_sat), + script_pubkey: swap_parse_script(&input.script_pubkey_hex)?, + }) +} + +#[cfg(any(feature = "electrum", feature = "esplora"))] +pub(crate) fn swap_build_input(local_output: &LocalOutput) -> OnchainSwapInput { + OnchainSwapInput { + outpoint: local_output.outpoint.into(), + amount_sat: local_output.txout.value.to_sat(), + script_pubkey_hex: local_output.txout.script_pubkey.to_hex_string(), + } +} + +#[cfg(any(feature = "electrum", feature = "esplora"))] +pub(crate) fn swap_selected_inputs_total(inputs: &[OnchainSwapInput]) -> u64 { + inputs.iter().map(|i| i.amount_sat).sum() +} + +#[cfg(any(feature = "electrum", feature = "esplora"))] +pub(crate) fn swap_require_rgb_destination( + leg: &OnchainSwapLeg, + script_hex: &Option, + blinding: &Option, +) -> Result<(), Error> { + if matches!(leg.kind, OnchainSwapLegKind::Rgb) && (script_hex.is_none() || blinding.is_none()) { + return Err(swap_invalid("missing RGB receive data")); + } + Ok(()) +} + +#[cfg(any(feature = "electrum", feature = "esplora"))] +pub(crate) fn swap_consignment_dir(wallet_dir: &Path, swap_id: &str) -> PathBuf { + wallet_dir.join("transfers").join(format!("swap-{swap_id}")) +} + +#[cfg(any(feature = "electrum", feature = "esplora"))] +pub(crate) fn swap_state_path(wallet_dir: &Path, swap_id: &str, file_name: &str) -> PathBuf { + swap_consignment_dir(wallet_dir, swap_id).join(file_name) +} + +#[cfg(any(feature = "electrum", feature = "esplora"))] +pub(crate) fn swap_save_state( + wallet_dir: &Path, + swap_id: &str, + file_name: &str, + state: &T, +) -> Result<(), Error> { + let path = swap_state_path(wallet_dir, swap_id, file_name); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let serialized = serde_json::to_string(state).map_err(InternalError::from)?; + fs::write(path, serialized)?; + Ok(()) +} + +#[cfg(any(feature = "electrum", feature = "esplora"))] +pub(crate) fn swap_load_state( + wallet_dir: &Path, + swap_id: &str, + file_name: &str, +) -> Result { + let path = swap_state_path(wallet_dir, swap_id, file_name); + if !path.exists() { + return Err(swap_invalid(format!( + "missing local swap state at {}", + path.display() + ))); + } + let serialized = fs::read_to_string(path)?; + serde_json::from_str(&serialized) + .map_err(InternalError::from) + .map_err(Error::from) +} + +#[cfg(any(feature = "electrum", feature = "esplora"))] +pub(crate) fn swap_ensure_state_matches( + expected: &T, + actual: &T, + state_name: &str, +) -> Result<(), Error> { + if expected != actual { + return Err(swap_invalid(format!( + "swap {state_name} does not match local state" + ))); + } + Ok(()) +} + +#[cfg(any(feature = "electrum", feature = "esplora"))] +pub(crate) fn swap_consignment_path(base_dir: &Path, asset_id: &str) -> PathBuf { + let sanitized = asset_id + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') { + ch + } else { + '_' + } + }) + .collect::(); + let asset_id_hash = hash_bytes_hex(asset_id.as_bytes()); + base_dir.join(format!( + "consignment-{sanitized}-{}.rgb", + &asset_id_hash[..16] + )) +} + +#[cfg(any(feature = "electrum", feature = "esplora"))] +pub(crate) fn swap_history_recipient_id(swap_id: &str, asset_id: &str) -> String { + let asset_id_hash = hash_bytes_hex(asset_id.as_bytes()); + format!("swap-history-{swap_id}-{}", &asset_id_hash[..16]) +} + +#[cfg(any(feature = "electrum", feature = "esplora"))] +pub(crate) fn swap_proxy_transport_endpoint(proxy_url: &str) -> Result { + let endpoint = if let Some(rest) = proxy_url.strip_prefix("http://") { + format!("rpc://{rest}") + } else if let Some(rest) = proxy_url.strip_prefix("https://") { + format!("rpcs://{rest}") + } else { + proxy_url.to_string() + }; + let transport = RgbTransport::from_str(&endpoint)?; + TransportEndpoint::try_from(transport)?; + Ok(endpoint) +} + +#[cfg(any(feature = "electrum", feature = "esplora"))] +pub(crate) fn swap_validate_proxy_url(proxy_url: &Option) -> Result<(), Error> { + let proxy_url = proxy_url + .as_deref() + .ok_or_else(|| Error::InvalidTransportEndpoints { + details: s!("on-chain RGB swaps require a proxy URL"), + })?; + swap_proxy_transport_endpoint(proxy_url)?; + Ok(()) +} + +#[cfg(any(feature = "electrum", feature = "esplora"))] +pub(crate) fn swap_rgb_leg_coloring_info( + leg: &OnchainSwapLeg, + psbt: &Psbt, + recipient_vout: u32, + blinding: u64, +) -> Result<(ContractId, rust_only::ColoringInfo), Error> { + let asset_id = leg.asset_id.clone().expect("RGB leg has asset ID"); + let contract_id = ContractId::from_str(&asset_id) + .map_err(|e| swap_invalid(format!("invalid RGB asset ID: {e}")))?; + let coloring_vout = if psbt + .unsigned_tx + .output + .first() + .is_some_and(|o| o.script_pubkey.is_op_return()) + && psbt + .unsigned_tx + .output + .iter() + .any(|o| o.script_pubkey.is_p2tr()) + { + recipient_vout + .checked_sub(1) + .ok_or_else(|| swap_invalid("invalid RGB recipient vout"))? + } else { + recipient_vout + }; + let output_map = HashMap::from_iter([(coloring_vout, leg.amount)]); + let coloring_info = rust_only::ColoringInfo { + asset_info_map: HashMap::from_iter([( + contract_id, + rust_only::AssetColoringInfo { + output_map, + static_blinding: Some(blinding), + }, + )]), + static_blinding: Some(blinding), + nonce: None, + }; + Ok((contract_id, coloring_info)) +} + +#[cfg(any(feature = "electrum", feature = "esplora"))] +pub(crate) fn swap_side_btc_payment(leg: &OnchainSwapLeg) -> u64 { + if matches!(leg.kind, OnchainSwapLegKind::Btc) { + leg.amount + } else { + 0 + } +} + +#[cfg(any(feature = "electrum", feature = "esplora"))] +pub(crate) fn swap_side_rgb_output_cost(receives: &OnchainSwapLeg, rgb_output_sat: u64) -> u64 { + if matches!(receives.kind, OnchainSwapLegKind::Rgb) { + rgb_output_sat + } else { + 0 + } +} + +#[cfg(any(feature = "electrum", feature = "esplora"))] +pub(crate) fn swap_append_side_change( + outputs: &mut Vec, + inputs: &[OnchainSwapInput], + gives: &OnchainSwapLeg, + receives: &OnchainSwapLeg, + rgb_output_sat: u64, + fee_sat: u64, + change_script_hex: &str, +) -> Result<(), Error> { + let input_total = swap_selected_inputs_total(inputs); + let required = swap_side_btc_payment(gives) + .checked_add(swap_side_rgb_output_cost(receives, rgb_output_sat)) + .and_then(|v| v.checked_add(fee_sat)) + .ok_or_else(|| swap_invalid("swap amounts overflow"))?; + if input_total < required { + return Err(Error::InsufficientBitcoins { + needed: required, + available: input_total, + }); + } + let change = input_total - required; + if change > 0 { + outputs.push(TxOut { + value: BdkAmount::from_sat(change), + script_pubkey: swap_parse_script(change_script_hex)?, + }); + } + Ok(()) +} + +#[cfg(any(feature = "electrum", feature = "esplora"))] +pub(crate) fn swap_build_psbt( + proposal: &OnchainSwapProposal, +) -> Result<(Psbt, Option, Option), Error> { + let offer = &proposal.request.offer; + let maker_inputs = &proposal.maker_inputs; + let taker_inputs = &proposal.request.taker_inputs; + let maker_gives = &offer.maker_gives; + let maker_receives = &offer.maker_receives; + let taker_gives = maker_receives; + let taker_receives = maker_gives; + + let mut tx_inputs = vec![]; + for input in maker_inputs.iter().chain(taker_inputs.iter()) { + tx_inputs.push(TxIn { + previous_output: swap_input_to_outpoint(input), + script_sig: ScriptBuf::new(), + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + witness: Witness::new(), + }); + } + + let mut outputs = vec![TxOut { + value: BdkAmount::ZERO, + script_pubkey: ScriptBuf::new_op_return([]), + }]; + let mut maker_rgb_vout = None; + let mut taker_rgb_vout = None; + + if matches!(maker_receives.kind, OnchainSwapLegKind::Btc) { + let address = offer + .maker_btc_address + .as_ref() + .ok_or_else(|| swap_invalid("missing maker BTC receive address"))?; + outputs.push(TxOut { + value: BdkAmount::from_sat(maker_receives.amount), + script_pubkey: parse_address_str(address, offer.bitcoin_network)?.script_pubkey(), + }); + } + if matches!(taker_receives.kind, OnchainSwapLegKind::Btc) { + let address = proposal + .request + .taker_btc_address + .as_ref() + .ok_or_else(|| swap_invalid("missing taker BTC receive address"))?; + outputs.push(TxOut { + value: BdkAmount::from_sat(taker_receives.amount), + script_pubkey: parse_address_str(address, offer.bitcoin_network)?.script_pubkey(), + }); + } + if matches!(maker_receives.kind, OnchainSwapLegKind::Rgb) { + maker_rgb_vout = Some(outputs.len() as u32); + outputs.push(TxOut { + value: BdkAmount::from_sat(offer.rgb_output_sat), + script_pubkey: swap_parse_script( + offer + .maker_rgb_script_pubkey_hex + .as_ref() + .ok_or_else(|| swap_invalid("missing maker RGB receive script"))?, + )?, + }); + } + if matches!(taker_receives.kind, OnchainSwapLegKind::Rgb) { + taker_rgb_vout = Some(outputs.len() as u32); + outputs.push(TxOut { + value: BdkAmount::from_sat(offer.rgb_output_sat), + script_pubkey: swap_parse_script( + proposal + .request + .taker_rgb_script_pubkey_hex + .as_ref() + .ok_or_else(|| swap_invalid("missing taker RGB receive script"))?, + )?, + }); + } + + swap_append_side_change( + &mut outputs, + maker_inputs, + maker_gives, + maker_receives, + offer.rgb_output_sat, + 0, + &proposal.maker_change_script_pubkey_hex, + )?; + swap_append_side_change( + &mut outputs, + taker_inputs, + taker_gives, + taker_receives, + offer.rgb_output_sat, + offer.network_fee_sat, + &proposal.request.taker_change_script_pubkey_hex, + )?; + + let mut psbt = Psbt::from_unsigned_tx(BdkTransaction { + version: TxVersion::TWO, + lock_time: LockTime::ZERO, + input: tx_inputs, + output: outputs, + }) + .map_err(|e| Error::InvalidPsbt { + details: e.to_string(), + })?; + let all_inputs = maker_inputs + .iter() + .chain(taker_inputs.iter()) + .cloned() + .collect::>(); + swap_restore_input_metadata(&mut psbt, &all_inputs)?; + Ok((psbt, maker_rgb_vout, taker_rgb_vout)) +} + +#[cfg(any(feature = "electrum", feature = "esplora"))] +pub(crate) fn swap_restore_input_metadata( + psbt: &mut Psbt, + inputs: &[OnchainSwapInput], +) -> Result<(), Error> { + for (idx, input) in inputs.iter().enumerate() { + let psbt_input = psbt + .inputs + .get_mut(idx) + .ok_or_else(|| swap_invalid("PSBT input metadata mismatch"))?; + psbt_input.witness_utxo = Some(swap_input_txout(input)?); + } + Ok(()) +} + +#[cfg(any(feature = "electrum", feature = "esplora"))] +pub(crate) fn swap_validate_proposal_psbt( + proposal: &OnchainSwapProposal, + psbt: &Psbt, +) -> Result<(), Error> { + let (expected_psbt, _, _) = swap_build_psbt(proposal)?; + let actual_tx = &psbt.unsigned_tx; + let expected_tx = &expected_psbt.unsigned_tx; + + if actual_tx.version != expected_tx.version || actual_tx.lock_time != expected_tx.lock_time { + return Err(swap_invalid("swap PSBT transaction header mismatch")); + } + if actual_tx.input != expected_tx.input { + return Err(swap_invalid("swap PSBT inputs mismatch")); + } + if actual_tx.output.len() != expected_tx.output.len() { + return Err(swap_invalid("swap PSBT output count mismatch")); + } + if psbt.inputs.len() != expected_psbt.inputs.len() { + return Err(swap_invalid("swap PSBT input metadata count mismatch")); + } + for (idx, (actual, expected)) in psbt + .inputs + .iter() + .zip(expected_psbt.inputs.iter()) + .enumerate() + { + if actual.witness_utxo != expected.witness_utxo { + return Err(swap_invalid(format!( + "swap PSBT input {idx} metadata mismatch" + ))); + } + } + if actual_tx + .output + .first() + .is_none_or(|o| o.value != BdkAmount::ZERO || !o.script_pubkey.is_op_return()) + { + return Err(swap_invalid("swap PSBT missing RGB OP_RETURN host")); + } + for (idx, (actual, expected)) in actual_tx + .output + .iter() + .zip(expected_tx.output.iter()) + .enumerate() + .skip(1) + { + if actual != expected { + return Err(swap_invalid(format!("swap PSBT output {idx} mismatch"))); + } + } + Ok(()) } /// Offline operations for a wallet. @@ -2817,3 +3418,175 @@ pub trait RgbWalletOpsOffline: WalletOffline + WalletBackup { Ok(inspection) } } + +#[cfg(test)] +#[cfg(any(feature = "electrum", feature = "esplora"))] +mod swap_unit_tests { + use super::*; + + fn btc(amount: u64) -> OnchainSwapLeg { + OnchainSwapLeg { + kind: OnchainSwapLegKind::Btc, + asset_id: None, + amount, + } + } + + fn rgb(asset_id: &str, amount: u64) -> OnchainSwapLeg { + OnchainSwapLeg { + kind: OnchainSwapLegKind::Rgb, + asset_id: Some(asset_id.to_string()), + amount, + } + } + + const ASSET_1: &str = "rgb:Ar4ouaLv-b7f7Dc_-z5EMvtu-FA5KNh1-nlae~jk-8xMBo7E"; + const ASSET_2: &str = "rgb:4bBH~Lrb-rx8sB_n-WAJLPcn-X5tFL9q-dFDGbSz-8yApPws"; + + fn swap_input(txid: &str, vout: u32, amount_sat: u64) -> OnchainSwapInput { + OnchainSwapInput { + outpoint: Outpoint { + txid: txid.to_string(), + vout, + }, + amount_sat, + script_pubkey_hex: "51".to_string(), + } + } + + fn rgb_rgb_proposal() -> OnchainSwapProposal { + let offer = OnchainSwapOffer { + swap_id: "test-swap".to_string(), + maker_gives: rgb(ASSET_1, 10), + maker_receives: rgb(ASSET_2, 20), + bitcoin_network: BitcoinNetwork::Regtest, + network_fee_sat: 100, + rgb_output_sat: SWAP_DEFAULT_RGB_OUTPUT_SAT, + expiration_timestamp: None, + maker_btc_address: None, + maker_rgb_recipient_id: Some("maker-rgb-recipient".to_string()), + maker_rgb_script_pubkey_hex: Some("51".to_string()), + maker_rgb_blinding: Some(1), + proxy_url: Some("rpc://127.0.0.1:3000".to_string()), + }; + OnchainSwapProposal { + request: OnchainSwapRequest { + offer, + taker_inputs: vec![swap_input( + "1111111111111111111111111111111111111111111111111111111111111111", + 0, + 2_100, + )], + taker_btc_address: None, + taker_rgb_recipient_id: Some("taker-rgb-recipient".to_string()), + taker_rgb_script_pubkey_hex: Some("51".to_string()), + taker_rgb_blinding: Some(2), + taker_change_script_pubkey_hex: "51".to_string(), + }, + maker_inputs: vec![swap_input( + "2222222222222222222222222222222222222222222222222222222222222222", + 0, + 1_000, + )], + maker_change_script_pubkey_hex: "51".to_string(), + psbt: String::new(), + txid: String::new(), + consignments: vec![], + maker_history: None, + } + } + + #[test] + fn validates_supported_leg_pairs() { + assert_eq!( + swap_validate_legs(&rgb(ASSET_1, 1), &btc(1_000)).unwrap(), + SwapDirection::RgbForBtc + ); + assert_eq!( + swap_validate_legs(&btc(1_000), &rgb(ASSET_1, 1)).unwrap(), + SwapDirection::BtcForRgb + ); + assert_eq!( + swap_validate_legs(&rgb(ASSET_1, 1), &rgb(ASSET_2, 1)).unwrap(), + SwapDirection::RgbForRgb + ); + } + + #[test] + fn rejects_invalid_leg_pairs() { + assert!(matches!( + swap_validate_legs(&btc(0), &rgb(ASSET_1, 1)), + Err(Error::InvalidAmountZero) + )); + assert!(swap_validate_legs(&btc(1), &btc(2)).is_err()); + assert!(swap_validate_legs(&rgb(ASSET_1, 1), &rgb(ASSET_1, 2)).is_err()); + assert!(swap_validate_legs(&rgb("invalid", 1), &btc(1)).is_err()); + } + + #[test] + fn rejects_missing_or_extra_asset_ids() { + assert!( + swap_validate_legs( + &OnchainSwapLeg { + kind: OnchainSwapLegKind::Rgb, + asset_id: None, + amount: 1, + }, + &btc(1_000), + ) + .is_err() + ); + assert!( + swap_validate_legs( + &OnchainSwapLeg { + kind: OnchainSwapLegKind::Btc, + asset_id: Some(ASSET_1.to_string()), + amount: 1_000, + }, + &rgb(ASSET_2, 1), + ) + .is_err() + ); + } + + #[test] + fn validates_swap_proxy_transport() { + assert_eq!( + swap_proxy_transport_endpoint("http://127.0.0.1:3000").unwrap(), + "rpc://127.0.0.1:3000" + ); + assert_eq!( + swap_proxy_transport_endpoint("https://example.com/json-rpc").unwrap(), + "rpcs://example.com/json-rpc" + ); + assert!(swap_validate_proxy_url(&Some("rpc://127.0.0.1:3000".to_string())).is_ok()); + assert!(swap_validate_proxy_url(&None).is_err()); + assert!(swap_validate_proxy_url(&Some("ws://127.0.0.1:3000".to_string())).is_err()); + } + + #[test] + fn validates_expected_swap_psbt_shape() { + let mut proposal = rgb_rgb_proposal(); + let (psbt, _, _) = swap_build_psbt(&proposal).unwrap(); + proposal.txid = psbt.unsigned_tx.compute_txid().to_string(); + proposal.psbt = psbt.to_string(); + swap_validate_proposal_psbt(&proposal, &psbt).unwrap(); + } + + #[test] + fn rejects_tampered_swap_psbt_output() { + let mut proposal = rgb_rgb_proposal(); + let (mut psbt, _, _) = swap_build_psbt(&proposal).unwrap(); + proposal.txid = psbt.unsigned_tx.compute_txid().to_string(); + proposal.psbt = psbt.to_string(); + psbt.unsigned_tx.output[1].value = BdkAmount::from_sat(42); + assert!(swap_validate_proposal_psbt(&proposal, &psbt).is_err()); + } + + #[test] + fn rejects_expired_offer() { + let mut proposal = rgb_rgb_proposal(); + proposal.request.offer.expiration_timestamp = Some(1); + assert!(swap_ensure_not_expired(&proposal.request.offer).is_err()); + } +} diff --git a/src/wallet/online.rs b/src/wallet/online.rs index ebfa8f87..102c4386 100644 --- a/src/wallet/online.rs +++ b/src/wallet/online.rs @@ -2,6 +2,11 @@ //! //! This module defines the online wallet methods. +use super::offline::{ + SWAP_DEFAULT_RGB_OUTPUT_SAT, swap_build_input, swap_consignment_dir, swap_consignment_path, + swap_history_recipient_id, swap_invalid, swap_proxy_transport_endpoint, + swap_rgb_leg_coloring_info, swap_selected_inputs_total, +}; use super::*; const SCHEMAS_SUPPORTING_BURN: [database::enums::AssetSchema; 1] = [AssetSchema::Ifa]; @@ -3641,6 +3646,814 @@ pub trait WalletOnline: WalletOffline { } } +// ─── On-chain swap: free helper functions (online) ─────────────────────────── + +pub(crate) fn swap_color_rgb_leg( + txn: &DbTxn, + wallet: &mut Wallet, + psbt: &mut Psbt, + leg: &OnchainSwapLeg, + recipient_id: &str, + recipient_vout: u32, + blinding: u64, + proxy_url: Option<&str>, + swap_id: &str, +) -> Result, Error> { + if !matches!(leg.kind, OnchainSwapLegKind::Rgb) { + return Ok(vec![]); + } + let asset_id = leg.asset_id.clone().expect("RGB leg has asset ID"); + let (_contract_id, coloring_info) = + swap_rgb_leg_coloring_info(leg, psbt, recipient_vout, blinding)?; + let transfers = wallet.color_psbt_and_consume(psbt, coloring_info)?; + let txid = psbt.unsigned_tx.compute_txid().to_string(); + swap_record_outgoing(txn, &asset_id, &txid, psbt)?; + swap_emit_consignments( + wallet, + transfers, + proxy_url, + swap_id, + &txid, + recipient_id, + recipient_vout, + blinding, + ) +} + +pub(crate) fn swap_stage_rgb_leg( + wallet: &Wallet, + psbt: &mut Psbt, + leg: &OnchainSwapLeg, + recipient_vout: u32, + blinding: u64, +) -> Result { + let (_contract_id, coloring_info) = + swap_rgb_leg_coloring_info(leg, psbt, recipient_vout, blinding)?; + wallet.color_psbt_stage(psbt, coloring_info) +} + +pub(crate) fn swap_emit_consignments( + wallet: &Wallet, + transfers: Vec, + proxy_url: Option<&str>, + swap_id: &str, + txid: &str, + recipient_id: &str, + recipient_vout: u32, + blinding: u64, +) -> Result, Error> { + let base_dir = swap_consignment_dir(wallet.wallet_dir(), swap_id); + fs::create_dir_all(&base_dir)?; + let endpoint = proxy_url.map(swap_proxy_transport_endpoint).transpose()?; + + let mut consignments = vec![]; + for transfer in transfers { + let transfer_asset_id = transfer.contract_id().to_string(); + let path = swap_consignment_path(&base_dir, &transfer_asset_id); + transfer.save_file(&path)?; + if let Some(proxy_url) = proxy_url { + wallet.post_consignment( + proxy_url, + recipient_id.to_string(), + path.clone(), + txid.to_string(), + Some(recipient_vout), + )?; + } + consignments.push(OnchainSwapConsignment { + asset_id: transfer_asset_id, + path: if endpoint.is_some() { + String::new() + } else { + path.to_string_lossy().to_string() + }, + endpoint: endpoint.clone(), + txid: txid.to_string(), + vout: recipient_vout, + blinding, + recipient_id: recipient_id.to_string(), + }); + } + Ok(consignments) +} + +pub(crate) fn swap_emit_asset_history( + wallet: &Wallet, + leg: &OnchainSwapLeg, + proxy_url: Option<&str>, + swap_id: &str, +) -> Result, Error> { + if !matches!(leg.kind, OnchainSwapLegKind::Rgb) { + return Ok(None); + } + let asset_id = leg.asset_id.clone().expect("RGB leg has asset ID"); + let path = wallet.wallet_dir().join(ASSETS_DIR).join(&asset_id); + if !path.exists() { + return Err(swap_invalid(format!( + "swap asset history not found at {}", + path.display() + ))); + } + let endpoint = proxy_url.map(swap_proxy_transport_endpoint).transpose()?; + let recipient_id = swap_history_recipient_id(swap_id, &asset_id); + if let Some(proxy_url) = proxy_url { + wallet.post_consignment( + proxy_url, + recipient_id.clone(), + path.clone(), + swap_id.to_string(), + None, + )?; + } + Ok(Some(OnchainSwapAssetHistory { + asset_id, + path: if endpoint.is_some() { + String::new() + } else { + path.to_string_lossy().to_string() + }, + endpoint, + recipient_id, + })) +} + +pub(crate) fn swap_record_outgoing( + txn: &DbTxn, + asset_id: &str, + txid: &str, + psbt: &Psbt, +) -> Result<(), Error> { + let db_data = txn.get_db_data(false)?; + let input_outpoints = psbt + .unsigned_tx + .input + .iter() + .map(|input| input.previous_output) + .collect::>(); + + let batch_transfer = DbBatchTransferActMod { + txid: ActiveValue::Set(Some(txid.to_string())), + status: ActiveValue::Set(TransferStatus::WaitingConfirmations), + expiration: ActiveValue::Set(None), + created_at: ActiveValue::Set(now().unix_timestamp()), + min_confirmations: ActiveValue::Set(1), + ..Default::default() + }; + let batch_transfer_idx = txn.set_batch_transfer(batch_transfer)?; + let asset_transfer = DbAssetTransferActMod { + user_driven: ActiveValue::Set(true), + batch_transfer_idx: ActiveValue::Set(batch_transfer_idx), + asset_id: ActiveValue::Set(Some(asset_id.to_string())), + ..Default::default() + }; + let asset_transfer_idx = txn.set_asset_transfer(asset_transfer)?; + + for txo in db_data + .txos + .iter() + .filter(|txo| input_outpoints.contains(&BdkOutPoint::from((*txo).clone()))) + { + for coloring in db_data.colorings.iter().filter(|coloring| { + coloring.txo_idx == txo.idx + && coloring.incoming() + && db_data.asset_transfers.iter().any(|asset_transfer| { + asset_transfer.idx == coloring.asset_transfer_idx + && asset_transfer.asset_id.as_deref() == Some(asset_id) + }) + && db_data.batch_transfers.iter().any(|batch_transfer| { + db_data.asset_transfers.iter().any(|asset_transfer| { + asset_transfer.idx == coloring.asset_transfer_idx + && asset_transfer.batch_transfer_idx == batch_transfer.idx + }) && !batch_transfer.status.failed() + }) + }) { + let db_coloring = DbColoringActMod { + txo_idx: ActiveValue::Set(txo.idx), + asset_transfer_idx: ActiveValue::Set(asset_transfer_idx), + r#type: ActiveValue::Set(ColoringType::Input), + assignment: ActiveValue::Set(coloring.assignment.clone()), + ..Default::default() + }; + txn.set_coloring(db_coloring)?; + } + } + + Ok(()) +} + +pub(crate) fn swap_record_incoming( + txn: &DbTxn, + asset_id: &str, + txid: &str, + vout: u32, + assignments: &[Assignment], +) -> Result<(), Error> { + let batch_transfer = DbBatchTransferActMod { + txid: ActiveValue::Set(Some(txid.to_string())), + status: ActiveValue::Set(TransferStatus::WaitingConfirmations), + expiration: ActiveValue::Set(None), + created_at: ActiveValue::Set(now().unix_timestamp()), + min_confirmations: ActiveValue::Set(1), + ..Default::default() + }; + let batch_transfer_idx = txn.set_batch_transfer(batch_transfer)?; + let asset_transfer = DbAssetTransferActMod { + user_driven: ActiveValue::Set(true), + batch_transfer_idx: ActiveValue::Set(batch_transfer_idx), + asset_id: ActiveValue::Set(Some(asset_id.to_string())), + ..Default::default() + }; + let asset_transfer_idx = txn.set_asset_transfer(asset_transfer)?; + let db_txo = DbTxoActMod { + txid: ActiveValue::Set(txid.to_string()), + vout: ActiveValue::Set(vout), + btc_amount: ActiveValue::Set(SWAP_DEFAULT_RGB_OUTPUT_SAT.to_string()), + spent: ActiveValue::Set(false), + exists: ActiveValue::Set(false), + pending_witness: ActiveValue::Set(true), + ..Default::default() + }; + let _ = txn.set_txo(db_txo)?; + let txo_idx = txn + .get_txo(&Outpoint { + txid: txid.to_string(), + vout, + })? + .map(|t| t.idx) + .ok_or_else(|| swap_invalid("failed to locate swap output txo after insert"))?; + + for assignment in assignments { + let db_coloring = DbColoringActMod { + txo_idx: ActiveValue::Set(txo_idx), + asset_transfer_idx: ActiveValue::Set(asset_transfer_idx), + r#type: ActiveValue::Set(ColoringType::Receive), + assignment: ActiveValue::Set(assignment.clone()), + ..Default::default() + }; + txn.set_coloring(db_coloring)?; + } + + Ok(()) +} + +pub(crate) fn swap_validate_assignments_for_leg( + assignments: &[Assignment], + leg: &OnchainSwapLeg, +) -> Result<(), Error> { + if !matches!(leg.kind, OnchainSwapLegKind::Rgb) { + return Ok(()); + } + let total = assignments.iter().try_fold(0u64, |acc, assignment| { + acc.checked_add(assignment.main_amount()) + .ok_or_else(|| swap_invalid("swap assignment amounts overflow")) + })?; + if total != leg.amount { + return Err(swap_invalid(format!( + "RGB swap assignment amount mismatch: expected {}, got {total}", + leg.amount + ))); + } + Ok(()) +} + +pub(crate) fn swap_validate_fascia_received_leg( + fascia: &Fascia, + leg: &OnchainSwapLeg, + vout: u32, + blinding: u64, +) -> Result, Error> { + if !matches!(leg.kind, OnchainSwapLegKind::Rgb) { + return Ok(vec![]); + } + let asset_id = leg.asset_id.clone().expect("RGB leg has asset ID"); + let contract_id = ContractId::from_str(&asset_id) + .map_err(|e| swap_invalid(format!("invalid RGB asset ID: {e}")))?; + let mut assignments = vec![]; + for (bundle_contract_id, bundle) in fascia.bundles() { + if *bundle_contract_id != contract_id { + continue; + } + for KnownTransition { transition, .. } in bundle.known_transitions.iter() { + for (ass_type, typed_assigns) in transition.assignments.iter() { + for fungible_assignment in typed_assigns.as_fungible().iter() { + if let Assign::Revealed { seal, state, .. } = fungible_assignment + && seal.txid == TxPtr::WitnessTx + && seal.vout.into_u32() == vout + && seal.blinding == blinding + { + match *ass_type { + OS_ASSET => assignments.push(Assignment::Fungible(state.as_u64())), + OS_INFLATION => { + assignments.push(Assignment::InflationRight(state.as_u64())) + } + _ => {} + } + } + } + for structured_assignment in typed_assigns.as_structured().iter() { + if let Assign::Revealed { seal, .. } = structured_assignment + && seal.txid == TxPtr::WitnessTx + && seal.vout.into_u32() == vout + && seal.blinding == blinding + { + assignments.push(Assignment::NonFungible); + } + } + } + } + } + if assignments.is_empty() { + return Err(swap_invalid("RGB swap PSBT does not assign expected state")); + } + swap_validate_assignments_for_leg(&assignments, leg)?; + Ok(assignments) +} + +fn swap_validate_witness_output( + consignment: &RgbTransfer, + witness_id: RgbTxid, + recipient_id: &str, + vout: u32, +) -> Result<(), Error> { + let Some(anchored_bundle) = consignment + .bundles + .iter() + .find(|bundle| bundle.witness_id() == witness_id) + else { + return Err(swap_invalid( + "RGB swap consignment does not include the expected witness transaction", + )); + }; + let PubWitness::Tx(tx) = &anchored_bundle.pub_witness else { + return Err(swap_invalid( + "RGB swap consignment is missing the witness transaction", + )); + }; + let output = tx + .output + .get(vout as usize) + .ok_or_else(|| swap_invalid("RGB swap consignment missing expected output"))?; + let script_pubkey = script_buf_from_recipient_id(recipient_id.to_string())? + .ok_or_else(|| swap_invalid("RGB swap recipient ID is not witness-based"))?; + if output.script_pubkey != script_pubkey { + return Err(swap_invalid( + "RGB swap consignment output pays an unexpected script", + )); + } + Ok(()) +} + +pub(crate) fn swap_validate_received_transfer_from_file( + wallet: &mut Wallet, + consignment_path: &Path, + leg: &OnchainSwapLeg, + txid: String, + vout: u32, + blinding: u64, + recipient_id: &str, +) -> Result, Error> { + if !matches!(leg.kind, OnchainSwapLegKind::Rgb) { + return Ok(vec![]); + } + let expected_asset_id = leg.asset_id.clone().expect("RGB leg has asset ID"); + let witness_id = RgbTxid::from_str(&txid).map_err(|_| Error::InvalidTxid)?; + let consignment = RgbTransfer::load_file(consignment_path).map_err(InternalError::from)?; + let contract_id = consignment.contract_id(); + let asset_id = contract_id.to_string(); + if asset_id != expected_asset_id { + return Err(swap_invalid(format!( + "RGB swap consignment asset mismatch: expected {expected_asset_id}, got {asset_id}" + ))); + } + let asset_schema: AssetSchema = consignment.schema_id().try_into()?; + wallet.check_schema_support(&asset_schema)?; + + let mut runtime = wallet.rgb_runtime()?; + let graph_seal = GraphSeal::with_blinded_vout(vout, blinding); + runtime.store_secret_seal(graph_seal)?; + + let resolver = OffchainResolver { + witness_id, + consignment: &consignment, + fallback: wallet.blockchain_resolver(), + }; + let validation_config = ValidationConfig { + chain_net: wallet.chain_net(), + trusted_typesystem: asset_schema.types(), + ..Default::default() + }; + match consignment.clone().validate(&resolver, &validation_config) { + Ok(_) => {} + Err(ValidationError::InvalidConsignment(e)) => { + error!(wallet.logger(), "Consignment is invalid: {}", e); + return Err(Error::InvalidConsignment); + } + Err(ValidationError::ResolverError(e)) => { + warn!( + wallet.logger(), + "Network error during consignment validation" + ); + return Err(Error::Network { + details: e.to_string(), + }); + } + }; + swap_validate_witness_output(&consignment, witness_id, recipient_id, vout)?; + + let assignments = wallet + .extract_received_assignments(&consignment, witness_id, Some(vout), None) + .into_values() + .collect::>(); + drop(runtime); + if assignments.is_empty() { + return Err(swap_invalid( + "RGB swap consignment did not assign expected state", + )); + } + swap_validate_assignments_for_leg(&assignments, leg)?; + Ok(assignments) +} + +pub(crate) fn swap_validate_received_swap_leg( + wallet: &mut Wallet, + consignments: &[OnchainSwapConsignment], + leg: &OnchainSwapLeg, + txid: &str, + vout: u32, + blinding: u64, + recipient_id: &str, +) -> Result, Error> { + if !matches!(leg.kind, OnchainSwapLegKind::Rgb) { + return Ok(vec![]); + } + let asset_id = leg.asset_id.as_deref().expect("RGB leg has asset ID"); + let matching = consignments + .iter() + .filter(|consignment| consignment.asset_id == asset_id) + .collect::>(); + if matching.len() != 1 { + return Err(swap_invalid(format!( + "expected exactly one RGB swap consignment for {asset_id}, got {}", + matching.len() + ))); + } + let consignment = matching[0]; + if consignment.txid != txid { + return Err(swap_invalid("RGB swap consignment txid mismatch")); + } + if consignment.vout != vout { + return Err(swap_invalid("RGB swap consignment vout mismatch")); + } + if consignment.blinding != blinding { + return Err(swap_invalid("RGB swap consignment blinding mismatch")); + } + if consignment.recipient_id != recipient_id { + return Err(swap_invalid("RGB swap consignment recipient mismatch")); + } + let local_path = swap_fetch_consignment_to_file(wallet, consignment)?; + swap_validate_received_transfer_from_file( + wallet, + &local_path, + leg, + consignment.txid.clone(), + consignment.vout, + consignment.blinding, + recipient_id, + ) +} + +pub(crate) fn swap_sign_psbt(wallet: &Wallet, psbt: &mut Psbt) -> Result<(), Error> { + let sign_options = SignOptions { + trust_witness_utxo: true, + ..Default::default() + }; + wallet.sign_psbt_impl(psbt, Some(sign_options)) +} + +pub(crate) fn swap_finalize_psbt(wallet: &Wallet, psbt: &Psbt) -> Result, Error> { + wallet + .finalize_psbt(psbt.to_string(), Some(SignOptions::default())) + .map(Some) + .or_else(|e| { + warn!( + wallet.logger(), + "PSBT finalization failed (not all inputs signed yet): {e}" + ); + Ok(None) + }) +} + +// ─── On-chain swap: private helpers (concrete Wallet) ──────────────────────── + +pub(crate) fn swap_import_contract( + wallet: &Wallet, + txn: &DbTxn, + contract_path_str: &str, +) -> Result<(), Error> { + let contract_path = Path::new(contract_path_str); + if !contract_path.exists() { + return Err(swap_invalid(format!( + "swap contract consignment not found at {}", + contract_path.display() + ))); + } + let valid_contract = ValidContract::load_file(contract_path).map_err(InternalError::from)?; + let contract_id = valid_contract.contract_id(); + let asset_id = contract_id.to_string(); + info!( + wallet.logger(), + "Importing swap contract {asset_id} from {}", + contract_path.display() + ); + let asset_schema: AssetSchema = valid_contract.schema_id().try_into()?; + wallet.check_schema_support(&asset_schema)?; + { + let mut runtime = wallet.rgb_runtime()?; + if runtime.contract_schema(contract_id).is_err() { + runtime.import_contract(valid_contract.clone(), &DumbResolver)?; + } + } + let local_path = wallet.wallet_dir().join(ASSETS_DIR).join(&asset_id); + if !local_path.exists() { + if let Some(parent) = local_path.parent() { + fs::create_dir_all(parent)?; + } + fs::copy(contract_path, &local_path)?; + } + if txn.get_asset(asset_id.clone())?.is_none() { + let runtime = wallet.rgb_runtime()?; + wallet.save_new_asset_internal( + txn, + &runtime, + contract_id, + asset_schema, + valid_contract, + None, + )?; + } + Ok(()) +} + +pub(crate) fn swap_import_asset_history( + wallet: &Wallet, + txn: &DbTxn, + history: &OnchainSwapAssetHistory, +) -> Result<(), Error> { + let path = swap_fetch_asset_history_to_file(wallet, history)?; + swap_import_contract(wallet, txn, &path.to_string_lossy()) +} + +pub(crate) fn swap_ensure_inputs_confirmed( + wallet: &Wallet, + inputs: &[OnchainSwapInput], + min_confirmations: u8, +) -> Result<(), Error> { + if min_confirmations == 0 { + return Ok(()); + } + for input in inputs { + let confirmations = wallet + .indexer() + .get_tx_confirmations(&input.outpoint.txid)? + .unwrap_or_default(); + if confirmations < min_confirmations as u64 { + return Err(swap_invalid(format!( + "swap input {} has {confirmations} confirmations, required {min_confirmations}", + input.outpoint + ))); + } + } + Ok(()) +} + +pub(crate) fn swap_accept_transfer_from_file( + wallet: &mut Wallet, + txn: &DbTxn, + consignment_path: &Path, + txid: String, + vout: u32, + blinding: u64, + recipient_id: &str, +) -> Result, Error> { + let witness_id = RgbTxid::from_str(&txid).map_err(|_| Error::InvalidTxid)?; + let consignment = RgbTransfer::load_file(consignment_path).map_err(InternalError::from)?; + let contract_id = consignment.contract_id(); + let asset_id = contract_id.to_string(); + let asset_schema: AssetSchema = consignment.schema_id().try_into()?; + wallet.check_schema_support(&asset_schema)?; + + let mut runtime = wallet.rgb_runtime()?; + let graph_seal = GraphSeal::with_blinded_vout(vout, blinding); + runtime.store_secret_seal(graph_seal)?; + + let resolver = OffchainResolver { + witness_id, + consignment: &consignment, + fallback: wallet.blockchain_resolver(), + }; + let validation_config = ValidationConfig { + chain_net: wallet.chain_net(), + trusted_typesystem: asset_schema.types(), + ..Default::default() + }; + let valid_consignment = match consignment.clone().validate(&resolver, &validation_config) { + Ok(consignment) => consignment, + Err(ValidationError::InvalidConsignment(e)) => { + error!(wallet.logger(), "Consignment is invalid: {}", e); + return Err(Error::InvalidConsignment); + } + Err(ValidationError::ResolverError(e)) => { + warn!( + wallet.logger(), + "Network error during consignment validation" + ); + return Err(Error::Network { + details: e.to_string(), + }); + } + }; + swap_validate_witness_output(&consignment, witness_id, recipient_id, vout)?; + + let valid_contract = valid_consignment.clone().into_valid_contract(); + runtime + .import_contract(valid_contract.clone(), wallet.blockchain_resolver()) + .expect("failure importing validated contract"); + if txn.get_asset(asset_id.clone())?.is_none() { + wallet.save_new_asset_internal( + txn, + &runtime, + contract_id, + asset_schema, + valid_contract.clone(), + Some(valid_consignment.clone()), + )?; + } + let received_rgb_assignments = + wallet.extract_received_assignments(&consignment, witness_id, Some(vout), None); + runtime.accept_transfer(valid_consignment, &resolver)?; + let assignments = received_rgb_assignments.into_values().collect::>(); + drop(runtime); + swap_record_incoming(txn, &asset_id, &txid, vout, &assignments)?; + Ok(assignments) +} + +pub(crate) fn swap_fetch_consignment_to_file( + wallet: &Wallet, + consignment: &OnchainSwapConsignment, +) -> Result { + let endpoint_str = + consignment + .endpoint + .as_deref() + .ok_or_else(|| Error::InvalidTransportEndpoints { + details: s!("swap consignment missing proxy endpoint"), + })?; + let transport = RgbTransport::from_str(endpoint_str)?; + let proxy_url = TransportEndpoint::try_from(transport)?.endpoint; + let proxy_client = ProxyClient::new(&proxy_url)?; + let res = proxy_client.get_consignment(&consignment.recipient_id)?; + let consignment_res = res.result.ok_or(Error::NoConsignment)?; + let consignment_bytes = general_purpose::STANDARD + .decode(consignment_res.consignment) + .map_err(InternalError::from)?; + let target_dir = wallet + .wallet_dir() + .join("swap-receive") + .join(&consignment.recipient_id); + fs::create_dir_all(&target_dir)?; + let target_path = target_dir.join(format!("{}.rgb", consignment.asset_id)); + fs::write(&target_path, &consignment_bytes)?; + Ok(target_path) +} + +pub(crate) fn swap_fetch_asset_history_to_file( + wallet: &Wallet, + history: &OnchainSwapAssetHistory, +) -> Result { + let endpoint_str = + history + .endpoint + .as_deref() + .ok_or_else(|| Error::InvalidTransportEndpoints { + details: s!("swap asset history missing proxy endpoint"), + })?; + let transport = RgbTransport::from_str(endpoint_str)?; + let proxy_url = TransportEndpoint::try_from(transport)?.endpoint; + let proxy_client = ProxyClient::new(&proxy_url)?; + let res = proxy_client.get_consignment(&history.recipient_id)?; + let history_res = res.result.ok_or(Error::NoConsignment)?; + let history_bytes = general_purpose::STANDARD + .decode(history_res.consignment) + .map_err(InternalError::from)?; + let target_dir = wallet + .wallet_dir() + .join("swap-history") + .join(&history.recipient_id); + fs::create_dir_all(&target_dir)?; + let target_path = target_dir.join(&history.asset_id); + fs::write(&target_path, &history_bytes)?; + Ok(target_path) +} + +pub(crate) fn swap_select_btc_inputs( + wallet: &mut Wallet, + online: Online, + needed_sat: u64, + min_confirmations: u8, +) -> Result, Error> { + // The caller (swap_select_inputs) already synced via sync_if_requested; we use + // internal_unspents() directly to avoid opening a second DB connection while the + // caller's transaction is still live (pool size = 1). + let mut selected = vec![]; + let mut total = 0u64; + for output in wallet.internal_unspents().filter(|o| !o.is_spent) { + if min_confirmations > 0 { + let confs = wallet + .indexer() + .get_tx_confirmations(&output.outpoint.txid.to_string())? + .unwrap_or_default(); + if confs < min_confirmations as u64 { + continue; + } + } + total = total + .checked_add(output.txout.value.to_sat()) + .ok_or_else(|| swap_invalid("swap amounts overflow"))?; + selected.push(swap_build_input(&output)); + if total >= needed_sat { + break; + } + } + if total < needed_sat { + return Err(Error::InsufficientBitcoins { + needed: needed_sat, + available: total, + }); + } + let _ = online; // consumed by the caller's sync; kept in signature for clarity + Ok(selected) +} + +pub(crate) fn swap_select_inputs( + wallet: &mut Wallet, + txn: &DbTxn, + online: Online, + gives: &OnchainSwapLeg, + extra_sat: u64, + min_confirmations: u8, + skip_sync: bool, +) -> Result, Error> { + wallet.sync_if_requested(txn, Some(online), skip_sync, KeychainKind::Internal)?; + wallet.sync_if_requested(txn, Some(online), skip_sync, KeychainKind::External)?; + match gives.kind { + OnchainSwapLegKind::Btc => swap_select_btc_inputs( + wallet, + online, + gives + .amount + .checked_add(extra_sat) + .ok_or_else(|| swap_invalid("swap amounts overflow"))?, + min_confirmations, + ), + OnchainSwapLegKind::Rgb => { + let asset_id = gives.asset_id.clone().expect("RGB leg has asset ID"); + txn.check_asset_exists(asset_id.clone())?; + let assignments = AssignmentsCollection { + fungible: gives.amount, + non_fungible: false, + inflation: 0, + }; + let (_, _, input_unspents, _) = wallet.get_transfer_begin_data(txn, 1)?; + let selected = wallet.select_rgb_inputs(asset_id, &assignments, input_unspents)?; + let selected_outpoints = selected + .input_outpoints + .into_iter() + .map(BdkOutPoint::from) + .collect::>(); + let bdk_outputs = wallet.bdk_wallet().list_unspent().collect::>(); + let mut swap_inputs = vec![]; + for output in bdk_outputs { + if selected_outpoints.contains(&output.outpoint) { + swap_inputs.push(swap_build_input(&output)); + } + } + if swap_inputs.is_empty() { + return Err(swap_invalid("could not resolve selected RGB inputs")); + } + let total = swap_selected_inputs_total(&swap_inputs); + let needed = extra_sat; + if total < needed { + return Err(Error::InsufficientBitcoins { + needed, + available: total, + }); + } + swap_ensure_inputs_confirmed(wallet, &swap_inputs, min_confirmations)?; + Ok(swap_inputs) + } + } +} + /// Online operations for a wallet. #[cfg(any(feature = "electrum", feature = "esplora"))] pub trait RgbWalletOpsOnline: RgbWalletOpsOffline + WalletOnline { diff --git a/src/wallet/rust_only.rs b/src/wallet/rust_only.rs index 56132f0d..3dfec7d7 100644 --- a/src/wallet/rust_only.rs +++ b/src/wallet/rust_only.rs @@ -3,6 +3,15 @@ //! This module defines additional utility methods that are not exposed via FFI. use super::*; +use amplify::num::u5; +use rgbstd::{ + containers::SealWitness, + opret::OpretProof, + rgbcore::commit_verify::{ + TryCommitVerify, + mpc::{self, MPC_MINIMAL_DEPTH}, + }, +}; /// RGB asset-specific information to color a transaction #[derive(Debug, Clone)] @@ -89,6 +98,26 @@ impl Wallet { coloring_info: ColoringInfo, ) -> Result<(Fascia, AssetBeneficiariesMap), Error> { info!(self.logger(), "Coloring PSBT..."); + let beneficiaries = self.color_psbt_stage(psbt, coloring_info.clone())?; + let fascia = self.color_psbt_finalize(psbt, coloring_info.static_blinding)?; + info!(self.logger(), "Color PSBT completed"); + Ok((fascia, beneficiaries)) + } + + /// Stage RGB coloring information onto a PSBT without finalizing the MPC commitment. + /// + /// Multiple parties may each call this method on the same PSBT (each for their own contracts), + /// in which case [`Wallet::color_psbt_finalize`] must be invoked once after all staging is + /// complete to produce the [`Fascia`]. + /// + ///
This method is meant for special usage and is normally not needed, use + /// it only if you know what you're doing
+ pub fn color_psbt_stage( + &self, + psbt: &mut Psbt, + coloring_info: ColoringInfo, + ) -> Result { + info!(self.logger(), "Staging RGB coloring onto PSBT..."); let mut transaction = match psbt.clone().extract_tx() { Ok(tx) => tx, Err(ExtractTxError::MissingInputValue { tx }) => tx, // required for non-standard TXs @@ -209,37 +238,95 @@ impl Wallet { asset_beneficiaries.insert(contract_id, beneficiaries); } + for (contract_id, transition) in all_transitions { + for opout in transition.inputs() { + psbt.set_rgb_contract_consumer(contract_id, opout, transition.id()) + .map_err(InternalError::from)?; + } + psbt.push_rgb_transition(transition) + .map_err(InternalError::from)?; + } + + info!(self.logger(), "Stage RGB coloring completed"); + Ok(asset_beneficiaries) + } + + /// Finalize the RGB commitment on a PSBT that has had one or more contracts staged via + /// [`Wallet::color_psbt_stage`]. Must be called exactly once per swap PSBT, after all staging + /// is complete. + /// + ///
This method is meant for special usage and is normally not needed, use + /// it only if you know what you're doing
+ pub fn color_psbt_finalize( + &self, + psbt: &mut Psbt, + static_blinding: Option, + ) -> Result { + info!(self.logger(), "Finalizing RGB commitment on PSBT..."); let opreturn_index = psbt .unsigned_tx .output .iter() .enumerate() .find(|(_, o)| o.script_pubkey.is_op_return()) - .expect("psbt should have an op_return output") + .ok_or_else(|| Error::InvalidColoringInfo { + details: s!("PSBT has no OP_RETURN output to host the RGB commitment"), + })? .0; let opreturn_output = psbt.outputs.get_mut(opreturn_index).unwrap(); opreturn_output.set_opret_host(); - if let Some(blinding) = coloring_info.static_blinding { + if let Some(blinding) = static_blinding { opreturn_output .set_mpc_entropy(blinding) .map_err(InternalError::from)?; } - for (contract_id, transition) in all_transitions { - for opout in transition.inputs() { - psbt.set_rgb_contract_consumer(contract_id, opout, transition.id()) - .map_err(InternalError::from)?; - } - psbt.push_rgb_transition(transition) - .map_err(InternalError::from)?; - } - psbt.set_rgb_close_method(CloseMethod::OpretFirst); psbt.set_as_unmodifiable(); let fascia = psbt.rgb_commit().map_err(InternalError::from)?; - info!(self.logger(), "Color PSBT completed"); - Ok((fascia, asset_beneficiaries)) + info!(self.logger(), "Finalize RGB commitment completed"); + Ok(fascia) + } + + /// Reconstruct an RGB fascia from a PSBT whose RGB commitment has already been finalized. + /// + /// This is used by high-level interactive flows where one party receives a PSBT after the + /// counterparty has finalized the MPC commitment. It keeps the raw fascia internal to Rust while + /// still allowing the wallet to consume the RGB transition data. + pub(crate) fn fascia_from_finalized_psbt(&self, psbt: &Psbt) -> Result { + let bundles = psbt.rgb_bundles().map_err(InternalError::from)?; + let output = psbt + .outputs + .iter() + .find(|output| output.mpc_message_map().is_ok()) + .ok_or_else(|| Error::InvalidColoringInfo { + details: s!("PSBT has no RGB MPC message host"), + })?; + let messages = output.mpc_message_map().map_err(InternalError::from)?; + let min_depth = output + .mpc_min_tree_depth() + .map(u5::with) + .unwrap_or(MPC_MINIMAL_DEPTH); + let source = mpc::MultiSource { + min_depth, + messages, + static_entropy: output.mpc_entropy(), + }; + let merkle_tree = + mpc::MerkleTree::try_commit(&source).map_err(|e| Error::InvalidColoringInfo { + details: format!("failed to reconstruct RGB MPC proof: {e}"), + })?; + let merkle_block = mpc::MerkleBlock::from(merkle_tree); + let bundles = Confined::try_from(bundles).map_err(|_| Error::InvalidColoringInfo { + details: s!("PSBT has no RGB bundles"), + })?; + let seal_witness = SealWitness::new( + PubWitness::with(psbt.unsigned_tx.clone()), + merkle_block, + OpretProof::default().into(), + ); + Ok(Fascia::new(seal_witness, bundles)) } /// Color a PSBT, consume the RGB fascia and return the related consignment. @@ -256,36 +343,55 @@ impl Wallet { let witness_txid = psbt.get_txid(); - let mut runtime = self.rgb_runtime()?; - runtime.consume_fascia(fascia, None)?; + { + let mut runtime = self.rgb_runtime()?; + runtime.consume_fascia(fascia, None)?; + } // drop the runtime here so `generate_transfer` can reacquire the stash lock let mut transfers = vec![]; for (contract_id, beneficiaries) in asset_beneficiaries { - let mut beneficiaries_witness = vec![]; - let mut beneficiaries_blinded = vec![]; - for builder_seal in beneficiaries { - match builder_seal { - BuilderSeal::Revealed(seal) => { - let explicit_seal = ExplicitSeal::with(witness_txid, seal.vout); - beneficiaries_witness.push(explicit_seal); - } - BuilderSeal::Concealed(secret_seal) => { - beneficiaries_blinded.push(secret_seal); - } - }; - } - transfers.push(runtime.transfer( - contract_id, - beneficiaries_witness, - beneficiaries_blinded, - Some(witness_txid), - )?); + transfers.push(self.generate_transfer(contract_id, beneficiaries, witness_txid)?); } info!(self.logger(), "Color PSBT and consume completed"); Ok(transfers) } + /// Produce an [`RgbTransfer`] consignment for a single contract given its beneficiaries + /// (typically obtained from a prior [`Wallet::color_psbt_stage`] call) and the witness + /// transaction ID of the PSBT being colored. Must be called after the fascia has been + /// consumed via [`Wallet::consume_fascia`]. + /// + ///
This method is meant for special usage and is normally not needed, use + /// it only if you know what you're doing
+ pub fn generate_transfer( + &self, + contract_id: ContractId, + beneficiaries: Vec>, + witness_txid: RgbTxid, + ) -> Result { + let runtime = self.rgb_runtime()?; + let mut beneficiaries_witness = vec![]; + let mut beneficiaries_blinded = vec![]; + for builder_seal in beneficiaries { + match builder_seal { + BuilderSeal::Revealed(seal) => { + let explicit_seal = ExplicitSeal::with(witness_txid, seal.vout); + beneficiaries_witness.push(explicit_seal); + } + BuilderSeal::Concealed(secret_seal) => { + beneficiaries_blinded.push(secret_seal); + } + }; + } + Ok(runtime.transfer( + contract_id, + beneficiaries_witness, + beneficiaries_blinded, + Some(witness_txid), + )?) + } + /// Create consignments for a PSBT created with the [`send_begin`](Wallet::send_begin) method. /// ///
This method is meant for special usage and is normally not needed, use @@ -368,13 +474,27 @@ impl Wallet { let valid_contract = valid_consignment.clone().into_valid_contract(); runtime - .import_contract(valid_contract, self.blockchain_resolver()) + .import_contract(valid_contract.clone(), self.blockchain_resolver()) .expect("failure importing validated contract"); + let contract_id = consignment.contract_id(); + let asset_id = contract_id.to_string(); + let txn = self.database().begin_transaction()?; + if txn.get_asset(asset_id)?.is_none() { + self.save_new_asset_internal( + &txn, + &runtime, + contract_id, + asset_schema, + valid_contract.clone(), + Some(valid_consignment.clone()), + )?; + } let received_rgb_assignments = self.extract_received_assignments(&consignment, witness_id, Some(vout), None); runtime.accept_transfer(valid_consignment, &resolver)?; + txn.commit()?; info!(self.logger(), "Accept transfer completed"); Ok(( diff --git a/src/wallet/singlesig.rs b/src/wallet/singlesig.rs index a600e15e..e398c227 100644 --- a/src/wallet/singlesig.rs +++ b/src/wallet/singlesig.rs @@ -2,6 +2,22 @@ //! //! This module defines the methods of the [`Wallet`] structure. +#[cfg(any(feature = "electrum", feature = "esplora"))] +use super::offline::{ + SWAP_OFFER_FILE, SWAP_PROPOSAL_FILE, SWAP_REQUEST_FILE, SwapDirection, swap_build_psbt, + swap_ensure_not_expired, swap_ensure_state_matches, swap_invalid, swap_load_state, + swap_mpc_entropy, swap_random_blinding, swap_require_rgb_destination, + swap_restore_input_metadata, swap_save_state, swap_side_rgb_output_cost, swap_validate_legs, + swap_validate_proposal_psbt, swap_validate_proxy_url, +}; +#[cfg(any(feature = "electrum", feature = "esplora"))] +use super::online::{ + swap_accept_transfer_from_file, swap_color_rgb_leg, swap_emit_asset_history, + swap_emit_consignments, swap_ensure_inputs_confirmed, swap_fetch_consignment_to_file, + swap_finalize_psbt, swap_import_asset_history, swap_record_outgoing, swap_select_inputs, + swap_sign_psbt, swap_stage_rgb_leg, swap_validate_fascia_received_leg, + swap_validate_received_swap_leg, +}; use super::*; /// Keys for the singlesig wallet. @@ -224,7 +240,7 @@ impl Wallet { .0 } - fn sign_psbt_impl( + pub(crate) fn sign_psbt_impl( &self, psbt: &mut Psbt, sign_options: Option, @@ -1226,4 +1242,713 @@ impl Wallet { info!(self.logger(), "Burn (end) completed"); Ok(res) } + + // ─── On-chain swap ───────────────────────────────────────────────────────── + + /// Create a maker offer for an on-chain swap. + /// + /// The maker specifies what they give (`maker_gives`) and what they want in return + /// (`maker_receives`). `network_fee_sat` is the total miner fee the taker will reserve in + /// the swap transaction. `proxy_url` is required; consignments produced during the swap are + /// posted there so the counterparty can fetch them, and it is also used to publish the current + /// asset history so the taker can validate the asset before accepting. + /// + /// This method is **offline** — no network connection is required. + #[cfg(any(feature = "electrum", feature = "esplora"))] + pub fn create_swap_offer( + &mut self, + maker_gives: OnchainSwapLeg, + maker_receives: OnchainSwapLeg, + network_fee_sat: u64, + expiration_timestamp: Option, + proxy_url: Option, + ) -> Result { + info!(self.logger(), "Creating on-chain swap offer..."); + let txn = self.database().begin_transaction()?; + let offer = self.create_swap_offer_impl( + &txn, + maker_gives, + maker_receives, + network_fee_sat, + expiration_timestamp, + proxy_url, + )?; + swap_save_state(self.wallet_dir(), &offer.swap_id, SWAP_OFFER_FILE, &offer)?; + self.update_backup_info(&txn, false)?; + txn.commit()?; + info!(self.logger(), "Create swap offer completed"); + Ok(offer) + } + + /// Accept a maker offer as the taker and return the taker's request message. + /// + /// The taker validates the offer, selects their inputs, and derives the receive destination. + /// The returned [`OnchainSwapRequest`] must be forwarded to the maker. + #[cfg(any(feature = "electrum", feature = "esplora"))] + pub fn accept_swap_offer( + &mut self, + online: Online, + offer: OnchainSwapOffer, + min_confirmations: u8, + skip_sync: bool, + ) -> Result { + info!(self.logger(), "Accepting on-chain swap offer..."); + self.check_online(online)?; + let txn = self.database().begin_transaction()?; + swap_validate_legs(&offer.maker_gives, &offer.maker_receives)?; + swap_validate_proxy_url(&offer.proxy_url)?; + swap_ensure_not_expired(&offer)?; + if offer.bitcoin_network != self.bitcoin_network() { + return Err(Error::BitcoinNetworkMismatch); + } + let taker_gives = offer.maker_receives.clone(); + let taker_receives = offer.maker_gives.clone(); + let taker_inputs = swap_select_inputs( + self, + &txn, + online, + &taker_gives, + swap_side_rgb_output_cost(&taker_receives, offer.rgb_output_sat) + .checked_add(offer.network_fee_sat) + .ok_or_else(|| swap_invalid("swap amounts overflow"))?, + min_confirmations, + skip_sync, + )?; + let ( + taker_btc_address, + taker_rgb_recipient_id, + taker_rgb_script_pubkey_hex, + taker_rgb_blinding, + ) = if matches!(taker_receives.kind, OnchainSwapLegKind::Btc) { + // Use get_new_addresses directly (BDK-only, no DB) to avoid opening a second + // connection while the outer transaction already holds the single pool connection. + ( + Some( + self.get_new_addresses(KeychainKind::Internal, 1)? + .to_string(), + ), + None, + None, + None, + ) + } else { + let script = self + .get_new_addresses(KeychainKind::External, 1)? + .script_pubkey(); + let blinding = swap_random_blinding(); + ( + None, + Some(recipient_id_from_script_buf( + script.clone(), + self.bitcoin_network(), + )), + Some(script.to_hex_string()), + Some(blinding), + ) + }; + let taker_change_script_pubkey_hex = self + .get_new_addresses(KeychainKind::Internal, 1)? + .script_pubkey() + .to_hex_string(); + let request = OnchainSwapRequest { + offer, + taker_inputs, + taker_btc_address, + taker_rgb_recipient_id, + taker_rgb_script_pubkey_hex, + taker_rgb_blinding, + taker_change_script_pubkey_hex, + }; + swap_save_state( + self.wallet_dir(), + &request.offer.swap_id, + SWAP_REQUEST_FILE, + &request, + )?; + self.update_backup_info(&txn, false)?; + txn.commit()?; + info!(self.logger(), "Accept swap offer completed"); + Ok(request) + } + + /// Accept a taker request as the maker and return the maker's PSBT proposal. + /// + /// The maker validates the request, selects their inputs, builds the collaborative PSBT, + /// colors any RGB leg they are sending, and partially signs the PSBT. + /// The returned [`OnchainSwapProposal`] must be forwarded to the taker. + #[cfg(any(feature = "electrum", feature = "esplora"))] + pub fn accept_swap_request( + &mut self, + online: Online, + request: OnchainSwapRequest, + min_confirmations: u8, + skip_sync: bool, + ) -> Result { + info!(self.logger(), "Accepting on-chain swap request..."); + self.check_online(online)?; + let txn = self.database().begin_transaction()?; + let offer = request.offer.clone(); + let local_offer: OnchainSwapOffer = + swap_load_state(self.wallet_dir(), &offer.swap_id, SWAP_OFFER_FILE)?; + swap_ensure_state_matches(&local_offer, &offer, "offer")?; + let direction = swap_validate_legs(&offer.maker_gives, &offer.maker_receives)?; + swap_validate_proxy_url(&offer.proxy_url)?; + swap_ensure_not_expired(&offer)?; + if offer.bitcoin_network != self.bitcoin_network() { + return Err(Error::BitcoinNetworkMismatch); + } + // Correctness: verify the taker's inputs are sufficiently confirmed before the maker + // commits resources to building the PSBT. + swap_ensure_inputs_confirmed(self, &request.taker_inputs, min_confirmations)?; + swap_require_rgb_destination( + &offer.maker_gives, + &request.taker_rgb_script_pubkey_hex, + &request.taker_rgb_blinding, + )?; + swap_require_rgb_destination( + &offer.maker_receives, + &offer.maker_rgb_script_pubkey_hex, + &offer.maker_rgb_blinding, + )?; + let maker_inputs = swap_select_inputs( + self, + &txn, + online, + &offer.maker_gives, + swap_side_rgb_output_cost(&offer.maker_receives, offer.rgb_output_sat), + min_confirmations, + skip_sync, + )?; + let maker_change_script_pubkey_hex = self + .get_new_addresses(KeychainKind::Internal, 1)? + .script_pubkey() + .to_hex_string(); + let mut proposal = OnchainSwapProposal { + request, + maker_inputs, + maker_change_script_pubkey_hex, + psbt: String::new(), + txid: String::new(), + consignments: vec![], + maker_history: None, + }; + let (mut psbt, _maker_rgb_vout, taker_rgb_vout) = swap_build_psbt(&proposal)?; + let mut consignments = vec![]; + let mut maker_history = None; + if matches!(offer.maker_gives.kind, OnchainSwapLegKind::Rgb) { + maker_history = swap_emit_asset_history( + self, + &offer.maker_gives, + offer.proxy_url.as_deref(), + &offer.swap_id, + )?; + let vout = taker_rgb_vout.ok_or_else(|| swap_invalid("missing taker RGB vout"))?; + let blinding = proposal + .request + .taker_rgb_blinding + .ok_or_else(|| swap_invalid("missing taker RGB blinding"))?; + match direction { + SwapDirection::RgbForBtc => { + let recipient_id = proposal + .request + .taker_rgb_recipient_id + .as_deref() + .ok_or_else(|| swap_invalid("missing taker RGB recipient ID"))?; + consignments = swap_color_rgb_leg( + &txn, + self, + &mut psbt, + &offer.maker_gives, + recipient_id, + vout, + blinding, + offer.proxy_url.as_deref(), + &offer.swap_id, + )?; + } + SwapDirection::RgbForRgb => { + swap_stage_rgb_leg(self, &mut psbt, &offer.maker_gives, vout, blinding)?; + } + SwapDirection::BtcForRgb => { + unreachable!("BTC-for-RGB cannot reach maker_gives==Rgb branch") + } + } + let inputs = proposal + .maker_inputs + .iter() + .chain(proposal.request.taker_inputs.iter()) + .cloned() + .collect::>(); + swap_restore_input_metadata(&mut psbt, &inputs)?; + } + if matches!(direction, SwapDirection::RgbForBtc) { + swap_sign_psbt(self, &mut psbt)?; + } + proposal.txid = psbt.unsigned_tx.compute_txid().to_string(); + proposal.psbt = psbt.to_string(); + proposal.consignments = consignments; + proposal.maker_history = maker_history; + swap_save_state( + self.wallet_dir(), + &offer.swap_id, + SWAP_PROPOSAL_FILE, + &proposal, + )?; + self.update_backup_info(&txn, false)?; + txn.commit()?; + info!(self.logger(), "Accept swap request completed"); + Ok(proposal) + } + + /// Complete a maker proposal as the taker. + /// + /// The taker validates the PSBT, colors any RGB leg they are sending, signs the PSBT, and + /// attempts to finalize it. The returned [`OnchainSwapCompletion`] must be forwarded to the + /// maker (who calls [`process_swap_completion`](Wallet::process_swap_completion) before + /// broadcasting) or broadcast directly for single-RGB swaps. + #[cfg(any(feature = "electrum", feature = "esplora"))] + pub fn complete_swap_proposal( + &mut self, + online: Online, + proposal: OnchainSwapProposal, + min_confirmations: u8, + skip_sync: bool, + ) -> Result { + info!(self.logger(), "Completing on-chain swap proposal..."); + self.check_online(online)?; + let txn = self.database().begin_transaction()?; + let offer = &proposal.request.offer; + let local_request: OnchainSwapRequest = + swap_load_state(self.wallet_dir(), &offer.swap_id, SWAP_REQUEST_FILE)?; + swap_ensure_state_matches(&local_request, &proposal.request, "request")?; + let direction = swap_validate_legs(&offer.maker_gives, &offer.maker_receives)?; + swap_validate_proxy_url(&offer.proxy_url)?; + swap_ensure_not_expired(offer)?; + if offer.bitcoin_network != self.bitcoin_network() { + return Err(Error::BitcoinNetworkMismatch); + } + let mut psbt = Psbt::from_str(&proposal.psbt)?; + swap_validate_proposal_psbt(&proposal, &psbt)?; + if proposal.txid != psbt.unsigned_tx.compute_txid().to_string() { + return Err(swap_invalid("swap proposal txid mismatch")); + } + let mut consignments = proposal.consignments.clone(); + let mut taker_history = None; + let (_expected_psbt, maker_rgb_vout, taker_rgb_vout) = swap_build_psbt(&proposal)?; + let all_inputs = proposal + .maker_inputs + .iter() + .chain(proposal.request.taker_inputs.iter()) + .cloned() + .collect::>(); + swap_restore_input_metadata(&mut psbt, &all_inputs)?; + match direction { + SwapDirection::BtcForRgb => { + let vout = maker_rgb_vout.ok_or_else(|| swap_invalid("missing maker RGB vout"))?; + let blinding = offer + .maker_rgb_blinding + .ok_or_else(|| swap_invalid("missing maker RGB blinding"))?; + let recipient_id = offer + .maker_rgb_recipient_id + .as_deref() + .ok_or_else(|| swap_invalid("missing maker RGB recipient ID"))?; + consignments.extend(swap_color_rgb_leg( + &txn, + self, + &mut psbt, + &offer.maker_receives, + recipient_id, + vout, + blinding, + offer.proxy_url.as_deref(), + &offer.swap_id, + )?); + swap_restore_input_metadata(&mut psbt, &all_inputs)?; + } + SwapDirection::RgbForBtc => { + let vout = taker_rgb_vout.ok_or_else(|| swap_invalid("missing taker RGB vout"))?; + let blinding = proposal + .request + .taker_rgb_blinding + .ok_or_else(|| swap_invalid("missing taker RGB blinding"))?; + let recipient_id = proposal + .request + .taker_rgb_recipient_id + .as_deref() + .ok_or_else(|| swap_invalid("missing taker RGB recipient ID"))?; + swap_validate_received_swap_leg( + self, + &proposal.consignments, + &offer.maker_gives, + &proposal.txid, + vout, + blinding, + recipient_id, + )?; + } + SwapDirection::RgbForRgb => { + let maker_history = proposal + .maker_history + .as_ref() + .ok_or_else(|| swap_invalid("missing maker asset history"))?; + swap_import_asset_history(self, &txn, maker_history)?; + taker_history = swap_emit_asset_history( + self, + &offer.maker_receives, + offer.proxy_url.as_deref(), + &offer.swap_id, + )?; + let maker_recv_vout = + maker_rgb_vout.ok_or_else(|| swap_invalid("missing maker RGB vout"))?; + let maker_recv_blinding = offer + .maker_rgb_blinding + .ok_or_else(|| swap_invalid("missing maker RGB blinding"))?; + let taker_beneficiaries = swap_stage_rgb_leg( + self, + &mut psbt, + &offer.maker_receives, + maker_recv_vout, + maker_recv_blinding, + )?; + let mpc_entropy = swap_mpc_entropy(&offer.swap_id); + let fascia = self.color_psbt_finalize(&mut psbt, Some(mpc_entropy))?; + let taker_recv_vout = + taker_rgb_vout.ok_or_else(|| swap_invalid("missing taker RGB vout"))?; + let taker_recv_blinding = proposal + .request + .taker_rgb_blinding + .ok_or_else(|| swap_invalid("missing taker RGB blinding"))?; + swap_validate_fascia_received_leg( + &fascia, + &offer.maker_gives, + taker_recv_vout, + taker_recv_blinding, + )?; + self.consume_fascia(fascia.clone(), None)?; + let witness_txid = psbt.get_txid(); + let recv_asset_id = offer + .maker_receives + .asset_id + .clone() + .expect("RGB leg has asset ID"); + let recv_contract_id = ContractId::from_str(&recv_asset_id) + .map_err(|e| swap_invalid(format!("invalid RGB asset ID: {e}")))?; + let beneficiaries = taker_beneficiaries + .get(&recv_contract_id) + .cloned() + .ok_or_else(|| swap_invalid("missing taker beneficiaries"))?; + let transfer = + self.generate_transfer(recv_contract_id, beneficiaries, witness_txid)?; + let txid_str = witness_txid.to_string(); + swap_record_outgoing(&txn, &recv_asset_id, &txid_str, &psbt)?; + let recv_recipient_id = offer + .maker_rgb_recipient_id + .as_deref() + .ok_or_else(|| swap_invalid("missing maker RGB recipient ID"))?; + consignments.extend(swap_emit_consignments( + self, + vec![transfer], + offer.proxy_url.as_deref(), + &offer.swap_id, + &txid_str, + recv_recipient_id, + maker_recv_vout, + maker_recv_blinding, + )?); + swap_restore_input_metadata(&mut psbt, &all_inputs)?; + } + } + let _ = taker_rgb_vout; + self.sync_if_requested(&txn, Some(online), skip_sync, KeychainKind::Internal)?; + self.sync_if_requested(&txn, Some(online), skip_sync, KeychainKind::External)?; + swap_ensure_inputs_confirmed(self, &proposal.maker_inputs, min_confirmations)?; + swap_ensure_inputs_confirmed(self, &proposal.request.taker_inputs, min_confirmations)?; + swap_sign_psbt(self, &mut psbt)?; + let finalized_psbt = swap_finalize_psbt(self, &psbt)?; + let txid = psbt.unsigned_tx.compute_txid().to_string(); + let completion = OnchainSwapCompletion { + proposal, + psbt: psbt.to_string(), + finalized_psbt, + txid, + consignments, + taker_history, + }; + self.update_backup_info(&txn, false)?; + txn.commit()?; + info!(self.logger(), "Complete swap proposal completed"); + Ok(completion) + } + + /// Process a swap completion as the maker after receiving it from the taker. + /// + /// For BTC-for-RGB and RGB-for-RGB swaps, the maker first validates the incoming RGB + /// consignment and only then signs/finalizes the PSBT. For RGB-for-RGB swaps, the maker also + /// consumes the taker's fascia and generates the consignment for the leg they are sending. + /// + /// The returned (possibly updated) [`OnchainSwapCompletion`] is what both parties should + /// use when calling [`accept_swap_transfers`](Wallet::accept_swap_transfers). + #[cfg(any(feature = "electrum", feature = "esplora"))] + pub fn process_swap_completion( + &mut self, + online: Online, + completion: OnchainSwapCompletion, + ) -> Result { + info!(self.logger(), "Processing on-chain swap completion..."); + self.check_online(online)?; + let offer = completion.proposal.request.offer.clone(); + let local_proposal: OnchainSwapProposal = + swap_load_state(self.wallet_dir(), &offer.swap_id, SWAP_PROPOSAL_FILE)?; + swap_ensure_state_matches(&local_proposal, &completion.proposal, "proposal")?; + let direction = swap_validate_legs(&offer.maker_gives, &offer.maker_receives)?; + swap_validate_proxy_url(&offer.proxy_url)?; + let mut psbt = Psbt::from_str(&completion.psbt)?; + swap_validate_proposal_psbt(&completion.proposal, &psbt)?; + let txid = psbt.unsigned_tx.compute_txid().to_string(); + if completion.txid != txid { + return Err(swap_invalid("swap completion txid mismatch")); + } + let all_inputs = completion + .proposal + .maker_inputs + .iter() + .chain(completion.proposal.request.taker_inputs.iter()) + .cloned() + .collect::>(); + swap_restore_input_metadata(&mut psbt, &all_inputs)?; + let (_expected_psbt, maker_rgb_vout, taker_rgb_vout) = + swap_build_psbt(&completion.proposal)?; + + match direction { + SwapDirection::RgbForBtc => { + info!(self.logger(), "Process swap completion completed"); + Ok(completion) + } + SwapDirection::BtcForRgb => { + let recv_vout = + maker_rgb_vout.ok_or_else(|| swap_invalid("missing maker RGB vout"))?; + let recv_blinding = offer + .maker_rgb_blinding + .ok_or_else(|| swap_invalid("missing maker RGB blinding"))?; + let recv_recipient_id = offer + .maker_rgb_recipient_id + .as_deref() + .ok_or_else(|| swap_invalid("missing maker RGB recipient ID"))?; + swap_validate_received_swap_leg( + self, + &completion.consignments, + &offer.maker_receives, + &txid, + recv_vout, + recv_blinding, + recv_recipient_id, + )?; + + let txn = self.database().begin_transaction()?; + self.sync_if_requested(&txn, Some(online), false, KeychainKind::Internal)?; + self.sync_if_requested(&txn, Some(online), false, KeychainKind::External)?; + swap_ensure_inputs_confirmed(self, &completion.proposal.maker_inputs, 0)?; + swap_ensure_inputs_confirmed(self, &completion.proposal.request.taker_inputs, 0)?; + swap_sign_psbt(self, &mut psbt)?; + let finalized_psbt = swap_finalize_psbt(self, &psbt)?; + let completion = OnchainSwapCompletion { + psbt: psbt.to_string(), + finalized_psbt, + ..completion + }; + self.update_backup_info(&txn, false)?; + txn.commit()?; + info!(self.logger(), "Process swap completion completed"); + Ok(completion) + } + SwapDirection::RgbForRgb => { + let recv_vout = + maker_rgb_vout.ok_or_else(|| swap_invalid("missing maker RGB vout"))?; + let recv_blinding = offer + .maker_rgb_blinding + .ok_or_else(|| swap_invalid("missing maker RGB blinding"))?; + let recv_recipient_id = offer + .maker_rgb_recipient_id + .as_deref() + .ok_or_else(|| swap_invalid("missing maker RGB recipient ID"))?; + swap_validate_received_swap_leg( + self, + &completion.consignments, + &offer.maker_receives, + &txid, + recv_vout, + recv_blinding, + recv_recipient_id, + )?; + + let txn = self.database().begin_transaction()?; + let taker_history = completion + .taker_history + .as_ref() + .ok_or_else(|| swap_invalid("missing taker asset history"))?; + swap_import_asset_history(self, &txn, taker_history)?; + let fascia = self.fascia_from_finalized_psbt(&psbt)?; + self.consume_fascia(fascia, None)?; + let witness_txid = psbt.get_txid(); + let send_vout = + taker_rgb_vout.ok_or_else(|| swap_invalid("missing taker RGB vout"))?; + let send_blinding = completion + .proposal + .request + .taker_rgb_blinding + .ok_or_else(|| swap_invalid("missing taker RGB blinding"))?; + let send_asset_id = offer + .maker_gives + .asset_id + .clone() + .expect("RGB leg has asset ID"); + let send_contract_id = ContractId::from_str(&send_asset_id) + .map_err(|e| swap_invalid(format!("invalid RGB asset ID: {e}")))?; + let beneficiaries = vec![BuilderSeal::Revealed(GraphSeal::with_blinded_vout( + send_vout, + send_blinding, + ))]; + let transfer = + self.generate_transfer(send_contract_id, beneficiaries, witness_txid)?; + let txid_str = witness_txid.to_string(); + swap_record_outgoing(&txn, &send_asset_id, &txid_str, &psbt)?; + let send_recipient_id = completion + .proposal + .request + .taker_rgb_recipient_id + .as_deref() + .ok_or_else(|| swap_invalid("missing taker RGB recipient ID"))?; + let mut consignments = completion.consignments.clone(); + consignments.extend(swap_emit_consignments( + self, + vec![transfer], + offer.proxy_url.as_deref(), + &offer.swap_id, + &txid_str, + send_recipient_id, + send_vout, + send_blinding, + )?); + self.sync_if_requested(&txn, Some(online), false, KeychainKind::Internal)?; + self.sync_if_requested(&txn, Some(online), false, KeychainKind::External)?; + swap_ensure_inputs_confirmed(self, &completion.proposal.maker_inputs, 0)?; + swap_ensure_inputs_confirmed(self, &completion.proposal.request.taker_inputs, 0)?; + swap_sign_psbt(self, &mut psbt)?; + let finalized_psbt = swap_finalize_psbt(self, &psbt)?; + let completion = OnchainSwapCompletion { + psbt: psbt.to_string(), + finalized_psbt, + consignments, + ..completion + }; + self.update_backup_info(&txn, false)?; + txn.commit()?; + info!(self.logger(), "Process swap completion completed"); + Ok(completion) + } + } + } + + /// Broadcast the finalized Bitcoin transaction for a completed on-chain swap. + /// + /// The completion must be the latest processed completion for the swap, with + /// `finalized_psbt` populated by the last signing party. + #[cfg(any(feature = "electrum", feature = "esplora"))] + pub fn broadcast_swap_completion( + &mut self, + online: Online, + completion: OnchainSwapCompletion, + ) -> Result { + info!(self.logger(), "Broadcasting on-chain swap completion..."); + self.check_online(online)?; + let offer = &completion.proposal.request.offer; + swap_validate_legs(&offer.maker_gives, &offer.maker_receives)?; + swap_validate_proxy_url(&offer.proxy_url)?; + let finalized_psbt = completion + .finalized_psbt + .as_deref() + .ok_or_else(|| swap_invalid("swap completion is not finalized"))?; + let psbt = Psbt::from_str(finalized_psbt)?; + swap_validate_proposal_psbt(&completion.proposal, &psbt)?; + let txid = psbt.unsigned_tx.compute_txid().to_string(); + if completion.txid != txid { + return Err(swap_invalid("swap completion txid mismatch")); + } + + let txn = self.database().begin_transaction()?; + let tx = self.broadcast_psbt(&txn, &psbt)?; + self.update_backup_info(&txn, false)?; + txn.commit()?; + let txid = tx.compute_txid().to_string(); + info!(self.logger(), "Broadcast swap completion completed"); + Ok(txid) + } + + /// Accept the RGB transfers received from a completed on-chain swap. + /// + /// Each party calls this method with their own `role` once the swap transaction has been + /// broadcast (or confirmed, depending on `min_confirmations`). The consignments embedded in + /// the completion are validated and accepted into the wallet's RGB state. + /// + /// Returns the list of [`Assignment`]s received, or an empty list if this party is receiving + /// only BTC. + #[cfg(any(feature = "electrum", feature = "esplora"))] + pub fn accept_swap_transfers( + &mut self, + online: Online, + completion: OnchainSwapCompletion, + role: OnchainSwapRole, + skip_sync: bool, + ) -> Result { + info!(self.logger(), "Accepting on-chain swap transfers..."); + self.check_online(online)?; + let offer = &completion.proposal.request.offer; + swap_validate_proxy_url(&offer.proxy_url)?; + let txn = self.database().begin_transaction()?; + self.sync_if_requested(&txn, Some(online), skip_sync, KeychainKind::External)?; + let receives = match role { + OnchainSwapRole::Maker => offer.maker_receives.clone(), + OnchainSwapRole::Taker => offer.maker_gives.clone(), + }; + if !matches!(receives.kind, OnchainSwapLegKind::Rgb) { + let result = OnchainSwapReceiveResult { + assignments: vec![], + }; + self.update_backup_info(&txn, false)?; + txn.commit()?; + return Ok(result); + } + let mut assignments = vec![]; + let matching_consignments = completion + .consignments + .iter() + .filter(|c| receives.asset_id.as_deref() == Some(c.asset_id.as_str())) + .collect::>(); + if matching_consignments.is_empty() { + return Err(swap_invalid("missing RGB swap consignment")); + } + for consignment in matching_consignments { + let local_path = swap_fetch_consignment_to_file(self, consignment)?; + let mut accepted = swap_accept_transfer_from_file( + self, + &txn, + &local_path, + consignment.txid.clone(), + consignment.vout, + consignment.blinding, + &consignment.recipient_id, + )?; + assignments.append(&mut accepted); + } + if assignments.is_empty() { + return Err(swap_invalid( + "RGB swap consignment did not assign any state", + )); + } + let result = OnchainSwapReceiveResult { assignments }; + self.update_backup_info(&txn, false)?; + txn.commit()?; + info!(self.logger(), "Accept swap transfers completed"); + Ok(result) + } } diff --git a/src/wallet/test/mod.rs b/src/wallet/test/mod.rs index b722fb6a..3e921ca3 100644 --- a/src/wallet/test/mod.rs +++ b/src/wallet/test/mod.rs @@ -334,6 +334,8 @@ mod list_unspents; #[cfg(any(feature = "electrum", feature = "esplora"))] mod multisig; mod new; +#[cfg(feature = "electrum")] +mod onchain_swap; mod refresh; mod rust_only; mod send; diff --git a/src/wallet/test/onchain_swap.rs b/src/wallet/test/onchain_swap.rs new file mode 100644 index 00000000..0b3c6a7e --- /dev/null +++ b/src/wallet/test/onchain_swap.rs @@ -0,0 +1,711 @@ +use std::str::FromStr; + +use bdk_wallet::bitcoin::psbt::Psbt; + +use super::*; + +const SWAP_RGB_AMOUNT: u64 = 1_000; +const SWAP_BTC_PRICE: u64 = 100_000; +const SWAP_FEE: u64 = 2_000; + +#[derive(Debug, Clone, Copy)] +struct ExpectedBtcDelta { + maker: i64, + taker: i64, +} + +#[derive(Debug, Clone, Copy)] +struct ExpectedAssetBalance<'a> { + asset_id: &'a str, + maker: u64, + taker: u64, +} + +fn btc(amount: u64) -> OnchainSwapLeg { + OnchainSwapLeg { + kind: OnchainSwapLegKind::Btc, + asset_id: None, + amount, + } +} + +fn rgb(asset_id: &str, amount: u64) -> OnchainSwapLeg { + OnchainSwapLeg { + kind: OnchainSwapLegKind::Rgb, + asset_id: Some(asset_id.to_string()), + amount, + } +} + +#[test] +#[parallel] +fn swap_offer_requires_proxy_url() { + initialize(); + + let (mut maker, maker_online) = get_funded_noutxo_wallet(true, None); + let asset_id = issue_swap_asset( + &mut maker, + maker_online, + "SNOP", + "Swap No Proxy", + SWAP_RGB_AMOUNT, + ); + + let result = maker.create_swap_offer( + rgb(&asset_id, SWAP_RGB_AMOUNT), + btc(SWAP_BTC_PRICE), + SWAP_FEE, + None, + None, + ); + + assert!(matches!( + result, + Err(Error::InvalidTransportEndpoints { .. }) + )); +} + +#[test] +#[parallel] +fn maker_taker_rgb_for_btc_balances() { + initialize(); + + let (mut maker, maker_online) = get_funded_noutxo_wallet(true, None); + let (mut taker, taker_online) = get_funded_noutxo_wallet(true, None); + + let asset_id = issue_swap_asset( + &mut maker, + maker_online, + "SRGB", + "Swap RGB For BTC", + SWAP_RGB_AMOUNT, + ); + let maker_btc_before = total_btc(&mut maker, maker_online); + let taker_btc_before = total_btc(&mut taker, taker_online); + + execute_swap( + &mut maker, + maker_online, + &mut taker, + taker_online, + rgb(&asset_id, SWAP_RGB_AMOUNT), + btc(SWAP_BTC_PRICE), + PROXY_URL, + ); + + assert_btc_delta( + &mut maker, + maker_online, + maker_btc_before, + &mut taker, + taker_online, + taker_btc_before, + ExpectedBtcDelta { + maker: SWAP_BTC_PRICE as i64, + taker: -((SWAP_BTC_PRICE + SWAP_FEE) as i64), + }, + ); + assert_asset_balances( + &maker, + &taker, + &[ExpectedAssetBalance { + asset_id: &asset_id, + maker: 0, + taker: SWAP_RGB_AMOUNT, + }], + ); +} + +#[test] +#[parallel] +fn maker_taker_btc_for_rgb_balances() { + initialize(); + + let (mut maker, maker_online) = get_funded_noutxo_wallet(true, None); + let (mut taker, taker_online) = get_funded_noutxo_wallet(true, None); + + let asset_id = issue_swap_asset( + &mut taker, + taker_online, + "SBTC", + "Swap BTC For RGB", + SWAP_RGB_AMOUNT, + ); + let maker_btc_before = total_btc(&mut maker, maker_online); + let taker_btc_before = total_btc(&mut taker, taker_online); + + execute_swap( + &mut maker, + maker_online, + &mut taker, + taker_online, + btc(SWAP_BTC_PRICE), + rgb(&asset_id, SWAP_RGB_AMOUNT), + PROXY_URL, + ); + + assert_btc_delta( + &mut maker, + maker_online, + maker_btc_before, + &mut taker, + taker_online, + taker_btc_before, + ExpectedBtcDelta { + maker: -(SWAP_BTC_PRICE as i64), + taker: (SWAP_BTC_PRICE as i64) - (SWAP_FEE as i64), + }, + ); + assert_asset_balances( + &maker, + &taker, + &[ExpectedAssetBalance { + asset_id: &asset_id, + maker: SWAP_RGB_AMOUNT, + taker: 0, + }], + ); +} + +#[test] +#[parallel] +fn maker_taker_rgb_for_rgb_balances() { + initialize(); + + let (mut maker, maker_online) = get_funded_noutxo_wallet(true, None); + let (mut taker, taker_online) = get_funded_noutxo_wallet(true, None); + + let maker_asset_id = issue_swap_asset( + &mut maker, + maker_online, + "SRGA", + "Maker RGB Swap Asset", + SWAP_RGB_AMOUNT, + ); + let taker_asset_id = issue_swap_asset( + &mut taker, + taker_online, + "SRGB", + "Taker RGB Swap Asset", + SWAP_RGB_AMOUNT, + ); + let maker_btc_before = total_btc(&mut maker, maker_online); + let taker_btc_before = total_btc(&mut taker, taker_online); + + execute_swap( + &mut maker, + maker_online, + &mut taker, + taker_online, + rgb(&maker_asset_id, SWAP_RGB_AMOUNT), + rgb(&taker_asset_id, SWAP_RGB_AMOUNT), + PROXY_URL, + ); + + assert_btc_delta( + &mut maker, + maker_online, + maker_btc_before, + &mut taker, + taker_online, + taker_btc_before, + ExpectedBtcDelta { + maker: 0, + taker: -(SWAP_FEE as i64), + }, + ); + assert_asset_balances( + &maker, + &taker, + &[ + ExpectedAssetBalance { + asset_id: &maker_asset_id, + maker: 0, + taker: SWAP_RGB_AMOUNT, + }, + ExpectedAssetBalance { + asset_id: &taker_asset_id, + maker: SWAP_RGB_AMOUNT, + taker: 0, + }, + ], + ); +} + +#[test] +#[parallel] +fn maker_rejects_request_with_mutated_offer() { + initialize(); + + let (mut maker, maker_online) = get_funded_noutxo_wallet(true, None); + let (mut taker, taker_online) = get_funded_noutxo_wallet(true, None); + let asset_id = issue_swap_asset( + &mut maker, + maker_online, + "SMOF", + "Swap Mutated Offer", + SWAP_RGB_AMOUNT, + ); + + let offer = maker + .create_swap_offer( + rgb(&asset_id, SWAP_RGB_AMOUNT), + btc(SWAP_BTC_PRICE), + SWAP_FEE, + None, + Some(PROXY_URL.to_string()), + ) + .unwrap(); + let mut request = taker + .accept_swap_offer(taker_online, offer, 0, false) + .unwrap(); + request.offer.network_fee_sat += 1; + + assert!( + maker + .accept_swap_request(maker_online, request, 0, false) + .is_err() + ); +} + +#[test] +#[parallel] +fn taker_rejects_proposal_with_mutated_request() { + initialize(); + + let (mut maker, maker_online) = get_funded_noutxo_wallet(true, None); + let (mut taker, taker_online) = get_funded_noutxo_wallet(true, None); + let asset_id = issue_swap_asset( + &mut maker, + maker_online, + "SMRQ", + "Swap Mutated Request", + SWAP_RGB_AMOUNT, + ); + + let offer = maker + .create_swap_offer( + rgb(&asset_id, SWAP_RGB_AMOUNT), + btc(SWAP_BTC_PRICE), + SWAP_FEE, + None, + Some(PROXY_URL.to_string()), + ) + .unwrap(); + let request = taker + .accept_swap_offer(taker_online, offer, 0, false) + .unwrap(); + let mut proposal = maker + .accept_swap_request(maker_online, request, 0, false) + .unwrap(); + proposal.request.taker_change_script_pubkey_hex = "00".to_string(); + + assert!( + taker + .complete_swap_proposal(taker_online, proposal, 0, false) + .is_err() + ); +} + +#[test] +#[parallel] +fn maker_rejects_tampered_rgb_consignment_before_signing_btc() { + initialize(); + + let (mut maker, maker_online) = get_funded_noutxo_wallet(true, None); + let (mut taker, taker_online) = get_funded_noutxo_wallet(true, None); + let asset_id = issue_swap_asset( + &mut taker, + taker_online, + "SMCS", + "Swap Mutated Consignment", + SWAP_RGB_AMOUNT, + ); + + let offer = maker + .create_swap_offer( + btc(SWAP_BTC_PRICE), + rgb(&asset_id, SWAP_RGB_AMOUNT), + SWAP_FEE, + None, + Some(PROXY_URL.to_string()), + ) + .unwrap(); + let request = taker + .accept_swap_offer(taker_online, offer, 0, false) + .unwrap(); + let proposal = maker + .accept_swap_request(maker_online, request, 0, false) + .unwrap(); + assert_eq!(signature_count(&proposal.psbt), 0); + + let completion = taker + .complete_swap_proposal(taker_online, proposal, 0, false) + .unwrap(); + let mut tampered_completion = completion.clone(); + tampered_completion.consignments[0].blinding += 1; + assert!( + maker + .process_swap_completion(maker_online, tampered_completion) + .is_err() + ); + + let completion = maker + .process_swap_completion(maker_online, completion) + .unwrap(); + assert!(completion.finalized_psbt.is_some()); + assert!(signature_count(&completion.psbt) > 0); +} + +fn issue_swap_asset( + wallet: &mut Wallet, + online: Online, + ticker: &str, + name: &str, + amount: u64, +) -> String { + wallet + .create_utxos(online, false, Some(1), Some(10_000), FEE_RATE, false) + .unwrap(); + let asset = wallet + .issue_asset_nia(ticker.to_string(), name.to_string(), 0, vec![amount]) + .unwrap(); + assert_eq!(asset_amount(wallet, &asset.asset_id), amount); + asset.asset_id +} + +fn execute_swap( + maker: &mut Wallet, + maker_online: Online, + taker: &mut Wallet, + taker_online: Online, + maker_gives: OnchainSwapLeg, + maker_receives: OnchainSwapLeg, + proxy_url: &str, +) { + let maker_receives_rgb = matches!(maker_receives.kind, OnchainSwapLegKind::Rgb); + let taker_receives_rgb = matches!(maker_gives.kind, OnchainSwapLegKind::Rgb); + let maker_gives_btc = matches!(maker_gives.kind, OnchainSwapLegKind::Btc); + let rgb_for_rgb = maker_receives_rgb && taker_receives_rgb; + let maker_gives_asset_id = maker_gives.asset_id.clone(); + let maker_receives_asset_id = maker_receives.asset_id.clone(); + + let offer = maker + .create_swap_offer( + maker_gives, + maker_receives, + SWAP_FEE, + None, + Some(proxy_url.to_string()), + ) + .unwrap(); + let request = taker + .accept_swap_offer(taker_online, offer, 0, false) + .unwrap(); + let proposal = maker + .accept_swap_request(maker_online, request, 0, false) + .unwrap(); + if rgb_for_rgb || maker_gives_btc { + assert_eq!(signature_count(&proposal.psbt), 0); + } else { + assert!(signature_count(&proposal.psbt) > 0); + } + assert_consignment_transport(&proposal.consignments); + if taker_receives_rgb { + assert!( + proposal.maker_history.is_some(), + "maker RGB history should be exposed as a high-level history ref" + ); + assert_asset_history_transport(proposal.maker_history.as_ref()); + } + let completion = taker + .complete_swap_proposal(taker_online, proposal, 0, false) + .unwrap(); + if rgb_for_rgb { + assert!( + completion.taker_history.is_some(), + "taker RGB history should be exposed as a high-level history ref" + ); + assert_asset_history_transport(completion.taker_history.as_ref()); + let mut missing_history_endpoint = completion.clone(); + missing_history_endpoint + .taker_history + .as_mut() + .unwrap() + .endpoint = None; + assert!( + maker + .process_swap_completion(maker_online, missing_history_endpoint) + .is_err() + ); + let mut tampered_completion = completion.clone(); + tampered_completion.txid = + "0000000000000000000000000000000000000000000000000000000000000000".to_string(); + assert!( + maker + .process_swap_completion(maker_online, tampered_completion) + .is_err() + ); + } + // Maker resumes the swap on their side: for RGB-for-RGB this consumes the fascia and emits + // the maker's consignment; for the single-RGB cases this is a no-op. + let completion = maker + .process_swap_completion(maker_online, completion) + .unwrap(); + if rgb_for_rgb || maker_gives_btc { + assert!(completion.finalized_psbt.is_some()); + assert!(signature_count(&completion.psbt) > 0); + } + assert_consignment_transport(&completion.consignments); + + let broadcast_txid = maker + .broadcast_swap_completion(maker_online, completion.clone()) + .unwrap(); + assert_eq!(broadcast_txid, completion.txid); + mine(false); + + if taker_receives_rgb { + let mut bad_endpoint = completion.clone(); + bad_endpoint + .consignments + .iter_mut() + .filter(|c| Some(c.asset_id.as_str()) == maker_gives_asset_id.as_deref()) + .for_each(|c| c.endpoint = Some(format!("rpc://{PROXY_HOST_MOD_PROTO}"))); + assert!( + taker + .accept_swap_transfers(taker_online, bad_endpoint, OnchainSwapRole::Taker, false) + .is_err() + ); + + let mut missing_endpoint = completion.clone(); + missing_endpoint + .consignments + .iter_mut() + .filter(|c| Some(c.asset_id.as_str()) == maker_gives_asset_id.as_deref()) + .for_each(|c| c.endpoint = None); + assert!( + taker + .accept_swap_transfers( + taker_online, + missing_endpoint, + OnchainSwapRole::Taker, + false, + ) + .is_err() + ); + + let mut wrong_recipient = completion.clone(); + let wrong_id = alternate_swap_recipient_id(taker); + wrong_recipient + .consignments + .iter_mut() + .filter(|c| Some(c.asset_id.as_str()) == maker_gives_asset_id.as_deref()) + .for_each(|c| c.recipient_id = wrong_id.clone()); + assert!( + taker + .accept_swap_transfers(taker_online, wrong_recipient, OnchainSwapRole::Taker, false) + .is_err() + ); + + let mut missing_consignment = completion.clone(); + missing_consignment + .consignments + .retain(|c| Some(c.asset_id.as_str()) != maker_gives_asset_id.as_deref()); + assert!( + taker + .accept_swap_transfers( + taker_online, + missing_consignment, + OnchainSwapRole::Taker, + false, + ) + .is_err() + ); + let receive_result = taker + .accept_swap_transfers( + taker_online, + completion.clone(), + OnchainSwapRole::Taker, + false, + ) + .unwrap(); + assert!(!receive_result.assignments.is_empty()); + } + if maker_receives_rgb { + let mut bad_endpoint = completion.clone(); + bad_endpoint + .consignments + .iter_mut() + .filter(|c| Some(c.asset_id.as_str()) == maker_receives_asset_id.as_deref()) + .for_each(|c| c.endpoint = Some(format!("rpc://{PROXY_HOST_MOD_PROTO}"))); + assert!( + maker + .accept_swap_transfers(maker_online, bad_endpoint, OnchainSwapRole::Maker, false) + .is_err() + ); + + let mut missing_endpoint = completion.clone(); + missing_endpoint + .consignments + .iter_mut() + .filter(|c| Some(c.asset_id.as_str()) == maker_receives_asset_id.as_deref()) + .for_each(|c| c.endpoint = None); + assert!( + maker + .accept_swap_transfers( + maker_online, + missing_endpoint, + OnchainSwapRole::Maker, + false, + ) + .is_err() + ); + + let mut wrong_recipient = completion.clone(); + let wrong_id = alternate_swap_recipient_id(maker); + wrong_recipient + .consignments + .iter_mut() + .filter(|c| Some(c.asset_id.as_str()) == maker_receives_asset_id.as_deref()) + .for_each(|c| c.recipient_id = wrong_id.clone()); + assert!( + maker + .accept_swap_transfers(maker_online, wrong_recipient, OnchainSwapRole::Maker, false) + .is_err() + ); + + let mut missing_consignment = completion.clone(); + missing_consignment + .consignments + .retain(|c| Some(c.asset_id.as_str()) != maker_receives_asset_id.as_deref()); + assert!( + maker + .accept_swap_transfers( + maker_online, + missing_consignment, + OnchainSwapRole::Maker, + false, + ) + .is_err() + ); + let receive_result = maker + .accept_swap_transfers(maker_online, completion, OnchainSwapRole::Maker, false) + .unwrap(); + assert!(!receive_result.assignments.is_empty()); + } +} + +fn alternate_swap_recipient_id(wallet: &mut Wallet) -> String { + let address = wallet.get_address().unwrap(); + let script = wallet.get_script_pubkey(&address).unwrap(); + recipient_id_from_script_buf(script, BitcoinNetwork::Regtest) +} + +fn assert_consignment_transport(consignments: &[OnchainSwapConsignment]) { + if consignments.is_empty() { + return; + } + for consignment in consignments { + assert_eq!( + consignment.endpoint.as_deref(), + Some(PROXY_ENDPOINT.as_str()) + ); + assert!( + consignment.path.is_empty(), + "proxy-mode consignments must not expose sender-local paths" + ); + } +} + +fn assert_asset_history_transport(history: Option<&OnchainSwapAssetHistory>) { + let Some(history) = history else { + return; + }; + assert_eq!(history.endpoint.as_deref(), Some(PROXY_ENDPOINT.as_str())); + assert!( + history.path.is_empty(), + "proxy-mode asset histories must not expose sender-local paths" + ); +} + +fn signature_count(psbt: &str) -> usize { + Psbt::from_str(psbt) + .unwrap() + .inputs + .iter() + .map(|input| { + input.partial_sigs.len() + + input.tap_script_sigs.len() + + usize::from(input.tap_key_sig.is_some()) + + usize::from(input.final_script_sig.is_some()) + + usize::from(input.final_script_witness.is_some()) + }) + .sum() +} + +fn total_btc(wallet: &mut Wallet, online: Online) -> u64 { + let balance = wallet.get_btc_balance(Some(online), false).unwrap(); + balance.vanilla.future + balance.colored.future +} + +fn asset_amount(wallet: &Wallet, asset_id: &str) -> u64 { + wallet + .get_asset_balance(asset_id.to_string()) + .map(|balance| balance.future) + .unwrap_or(0) +} + +fn assert_btc_delta( + maker: &mut Wallet, + maker_online: Online, + maker_before: u64, + taker: &mut Wallet, + taker_online: Online, + taker_before: u64, + expected: ExpectedBtcDelta, +) { + let maker_after = total_btc(maker, maker_online); + let taker_after = total_btc(taker, taker_online); + assert_eq!( + maker_after as i64 - maker_before as i64, + expected.maker, + "maker BTC delta mismatch" + ); + assert_eq!( + taker_after as i64 - taker_before as i64, + expected.taker, + "taker BTC delta mismatch" + ); + assert_eq!( + maker_after + taker_after + SWAP_FEE, + maker_before + taker_before, + "total BTC should only decrease by the swap fee" + ); +} + +fn assert_asset_balances( + maker: &Wallet, + taker: &Wallet, + expected_balances: &[ExpectedAssetBalance<'_>], +) { + for expected in expected_balances { + let maker_amount = asset_amount(maker, expected.asset_id); + let taker_amount = asset_amount(taker, expected.asset_id); + assert_eq!( + maker_amount, expected.maker, + "maker asset balance mismatch for {}", + expected.asset_id + ); + assert_eq!( + taker_amount, expected.taker, + "taker asset balance mismatch for {}", + expected.asset_id + ); + assert_eq!( + maker_amount + taker_amount, + SWAP_RGB_AMOUNT, + "asset amount should be conserved for {}", + expected.asset_id + ); + } +}