1use std::{collections::HashMap, str::FromStr};
2
3use super::{
4 error::{PayjoinError, PayjoinResult},
5 model::{
6 AcceptedAsset, AcceptedAssetsRequest, AcceptedAssetsResponse, Request, Response,
7 SignRequest, StartRequest, Utxo,
8 },
9 pset::PsetInput,
10 utxo_select::utxo_select,
11 PayjoinService,
12};
13use boltz_client::Secp256k1;
14use log::{debug, error};
15use lwk_wollet::{
16 bitcoin::base64::{self, Engine as _},
17 elements::{
18 self,
19 confidential::{self, AssetBlindingFactor, ValueBlindingFactor},
20 pset::PartiallySignedTransaction,
21 secp256k1_zkp::Generator,
22 Address, AssetId, Transaction, TxOutSecrets,
23 },
24};
25use sdk_common::{
26 ensure_sdk,
27 prelude::{parse_json, FiatAPI, RestClient},
28 utils::Arc,
29};
30use serde::{de::DeserializeOwned, Serialize};
31use tokio::sync::OnceCell;
32
33use crate::payjoin::{
34 model::{InOut, Recipient},
35 network_fee::TxFee,
36 pset::blind::remove_explicit_values,
37 utxo_select::UtxoSelectRequest,
38};
39use crate::persist::Persister;
40use crate::{
41 model::{Config, LiquidNetwork},
42 payjoin::pset::{construct_pset, ConstructPsetRequest, PsetOutput},
43};
44use crate::{utils, wallet::OnchainWallet};
45
46const PRODUCTION_SIDESWAP_URL: &str = "https://api.sideswap.io/payjoin";
47const TESTNET_SIDESWAP_URL: &str = "https://api-testnet.sideswap.io/payjoin";
48const SIDESWAP_BASE_USD_FEE_SAT: f64 = 4_000_000.0;
50
51pub(crate) struct SideSwapPayjoinService {
52 config: Config,
53 fiat_api: Arc<dyn FiatAPI>,
54 persister: Arc<Persister>,
55 onchain_wallet: Arc<dyn OnchainWallet>,
56 rest_client: Arc<dyn RestClient>,
57 accepted_assets: OnceCell<AcceptedAssetsResponse>,
58}
59
60impl SideSwapPayjoinService {
61 pub fn new(
62 config: Config,
63 fiat_api: Arc<dyn FiatAPI>,
64 persister: Arc<Persister>,
65 onchain_wallet: Arc<dyn OnchainWallet>,
66 rest_client: Arc<dyn RestClient>,
67 ) -> Self {
68 Self {
69 config,
70 fiat_api,
71 persister,
72 onchain_wallet,
73 rest_client,
74 accepted_assets: OnceCell::new(),
75 }
76 }
77
78 fn get_url(&self) -> PayjoinResult<&str> {
79 match self.config.network {
80 LiquidNetwork::Mainnet => Ok(PRODUCTION_SIDESWAP_URL),
81 LiquidNetwork::Testnet => Ok(TESTNET_SIDESWAP_URL),
82 network => Err(PayjoinError::generic(format!(
83 "Payjoin not supported on {network}"
84 ))),
85 }
86 }
87
88 async fn post_request<I: Serialize, O: DeserializeOwned>(&self, body: &I) -> PayjoinResult<O> {
89 let headers = HashMap::from([("Content-Type".to_string(), "application/json".to_string())]);
90 let body = serde_json::to_string(body)?;
91 debug!("Posting request to SideSwap: {body}");
92 let (response, status_code) = self
93 .rest_client
94 .post(self.get_url()?, Some(headers), Some(body))
95 .await?;
96 if status_code != 200 {
97 error!("Received status code {status_code} response from SideSwap");
98 return Err(PayjoinError::service_connectivity(format!(
99 "Failed to post request to SideSwap: {response}"
100 )));
101 }
102 debug!("Received response from SideSwap: {response}");
103 Ok(parse_json(&response)?)
104 }
105}
106
107#[sdk_macros::async_trait]
108impl PayjoinService for SideSwapPayjoinService {
109 async fn fetch_accepted_assets(&self) -> PayjoinResult<Vec<AcceptedAsset>> {
110 let accepted_assets = self
111 .accepted_assets
112 .get_or_try_init(|| async {
113 debug!("Initializing accepted_assets from SideSwap");
114 let accepted_assets_request = Request::AcceptedAssets(AcceptedAssetsRequest {});
115 let response: Response = self.post_request(&accepted_assets_request).await?;
116 match response {
117 Response::AcceptedAssets(accepted_assets) => Ok(accepted_assets),
118 _ => Err(PayjoinError::service_connectivity(
119 "Failed to request accepted assets from SideSwap",
120 )),
121 }
122 })
123 .await?;
124
125 Ok(accepted_assets.accepted_asset.clone())
126 }
127
128 async fn estimate_payjoin_tx_fee(&self, asset_id: &str, amount_sat: u64) -> PayjoinResult<f64> {
129 let fee_asset = AssetId::from_str(asset_id)?;
131 let accepted_assets = self.fetch_accepted_assets().await?;
132 ensure_sdk!(
133 accepted_assets
134 .iter()
135 .any(|asset| asset.asset_id == asset_id),
136 PayjoinError::generic("Asset not accepted by SideSwap")
137 );
138
139 let wallet_asset_balance: u64 = self
141 .onchain_wallet
142 .asset_utxos(&fee_asset)
143 .await?
144 .iter()
145 .map(|utxo| utxo.unblinded.value)
146 .sum();
147 ensure_sdk!(
148 wallet_asset_balance > amount_sat,
149 PayjoinError::InsufficientFunds
150 );
151
152 let asset_metadata =
154 self.persister
155 .get_asset_metadata(asset_id)?
156 .ok_or(PayjoinError::generic(format!(
157 "No asset metadata available for {asset_id}"
158 )))?;
159 let Some(fiat_id) = asset_metadata.fiat_id.clone() else {
160 return Err(PayjoinError::generic(format!(
161 "No fiat ID available in asset metadata for {asset_id}"
162 )));
163 };
164 let fiat_rates = self.fiat_api.fetch_fiat_rates().await?;
165 let usd_index_price = fiat_rates
166 .iter()
167 .find(|rate| rate.coin == "USD")
168 .map(|rate| rate.value)
169 .ok_or(PayjoinError::generic("No rate available for USD"))?;
170 let asset_index_price = fiat_rates
171 .iter()
172 .find(|rate| rate.coin == fiat_id)
173 .map(|rate| rate.value)
174 .ok_or(PayjoinError::generic(format!(
175 "No rate available for {fiat_id}"
176 )))?;
177
178 let fixed_fee = (SIDESWAP_BASE_USD_FEE_SAT / usd_index_price * asset_index_price) as u64;
179 let network_fee = TxFee {
187 server_inputs: 1,
188 user_inputs: 1,
189 outputs: 4,
190 }
191 .fee();
192 let fee_sat = (network_fee as f64 * asset_index_price) as u64 + fixed_fee;
193 ensure_sdk!(
194 wallet_asset_balance >= amount_sat + fee_sat,
195 PayjoinError::InsufficientFunds
196 );
197
198 let mut fee = asset_metadata.amount_from_sat(fee_sat);
200 fee = (fee * 100.0).ceil() / 100.0;
201
202 debug!("Estimated payjoin server fee: {fee} ({fee_sat} satoshi units)");
203
204 Ok(fee)
205 }
206
207 async fn build_payjoin_tx(
208 &self,
209 recipient_address: &str,
210 asset_id: &str,
211 amount_sat: u64,
212 ) -> PayjoinResult<(Transaction, u64)> {
213 let fee_asset = AssetId::from_str(asset_id)?;
214 let wallet_utxos = self
215 .onchain_wallet
216 .asset_utxos(&fee_asset)
217 .await?
218 .iter()
219 .map(Utxo::from)
220 .collect::<Vec<_>>();
221 ensure_sdk!(!wallet_utxos.is_empty(), PayjoinError::InsufficientFunds);
222
223 let address = Address::from_str(recipient_address).map_err(|e| {
224 PayjoinError::generic(format!(
225 "Recipient address {recipient_address} is not a valid ElementsAddress: {e:?}"
226 ))
227 })?;
228 let recipients = vec![Recipient {
229 address,
230 asset_id: fee_asset,
231 amount: amount_sat,
232 }];
233
234 let start_request = Request::Start(StartRequest {
235 asset_id: asset_id.to_string(),
236 user_agent: "breezsdk".to_string(),
237 api_key: self.config.sideswap_api_key.clone(),
238 });
239 let response: Response = self.post_request(&start_request).await?;
240 let Response::Start(start_response) = response else {
241 return Err(PayjoinError::service_connectivity(
242 "Failed to start payjoin",
243 ));
244 };
245 ensure_sdk!(
246 start_response.fee_address.is_blinded(),
247 PayjoinError::generic("Server fee address is not blinded")
248 );
249 ensure_sdk!(
250 start_response.change_address.is_blinded(),
251 PayjoinError::generic("Server change address is not blinded")
252 );
253 ensure_sdk!(
254 !start_response.utxos.is_empty(),
255 PayjoinError::generic("Server utxos are empty")
256 );
257
258 let policy_asset = utils::lbtc_asset_id(self.config.network);
259 let utxo_select_res = utxo_select(UtxoSelectRequest {
260 policy_asset,
261 fee_asset,
262 price: start_response.price,
263 fixed_fee: start_response.fixed_fee,
264 wallet_utxos: wallet_utxos.iter().map(Into::into).collect(),
265 server_utxos: start_response.utxos.iter().map(Into::into).collect(),
266 user_outputs: recipients
267 .iter()
268 .map(|recipient| InOut {
269 asset_id: recipient.asset_id,
270 value: recipient.amount,
271 })
272 .collect(),
273 })?;
274 ensure_sdk!(
275 utxo_select_res.user_outputs.len() == recipients.len(),
276 PayjoinError::generic("Output/recipient lengths mismatch")
277 );
278
279 let mut inputs = Vec::new();
280 let mut outputs = Vec::new();
281
282 inputs.append(&mut select_utxos(
284 wallet_utxos,
285 utxo_select_res
286 .user_inputs
287 .into_iter()
288 .chain(utxo_select_res.client_inputs.into_iter())
289 .collect(),
290 )?);
291 inputs.append(&mut select_utxos(
292 start_response.utxos,
293 utxo_select_res.server_inputs,
294 )?);
295
296 let server_fee = utxo_select_res.server_fee;
298
299 for (output, recipient) in utxo_select_res
301 .user_outputs
302 .iter()
303 .zip(recipients.into_iter())
304 {
305 debug!(
306 "Payjoin recipent output: {} value: {}",
307 recipient.address, output.value
308 );
309 outputs.push(PsetOutput {
310 asset_id: output.asset_id,
311 amount: output.value,
312 address: recipient.address,
313 });
314 }
315
316 for output in utxo_select_res
318 .change_outputs
319 .iter()
320 .chain(utxo_select_res.fee_change.iter())
321 {
322 let address = self.onchain_wallet.next_unused_change_address().await?;
323 debug!("Payjoin change output: {address} value: {}", output.value);
324 outputs.push(PsetOutput {
325 asset_id: output.asset_id,
326 amount: output.value,
327 address,
328 });
329 }
330
331 debug!(
333 "Payjoin server fee output: {} value: {}",
334 start_response.fee_address, server_fee.value
335 );
336 outputs.push(PsetOutput {
337 asset_id: server_fee.asset_id,
338 amount: server_fee.value,
339 address: start_response.fee_address,
340 });
341
342 if let Some(output) = utxo_select_res.server_change {
344 debug!(
345 "Payjoin server change output: {} value: {}",
346 start_response.change_address, output.value
347 );
348 outputs.push(PsetOutput {
349 asset_id: output.asset_id,
350 amount: output.value,
351 address: start_response.change_address,
352 });
353 }
354
355 let blinded_pset = construct_pset(ConstructPsetRequest {
357 policy_asset,
358 inputs,
359 outputs,
360 network_fee: utxo_select_res.network_fee.value,
361 })?;
362
363 let mut pset = blinded_pset.clone();
364 remove_explicit_values(&mut pset);
365 let server_pset = elements::encode::serialize(&pset);
366
367 let sign_request = Request::Sign(SignRequest {
369 order_id: start_response.order_id,
370 pset: base64::engine::general_purpose::STANDARD.encode(&server_pset),
371 });
372 let response: Response = self.post_request(&sign_request).await?;
373 let Response::Sign(sign_response) = response else {
374 return Err(PayjoinError::service_connectivity("Failed to sign payjoin"));
375 };
376
377 let server_signed_pset = elements::encode::deserialize::<PartiallySignedTransaction>(
379 &base64::engine::general_purpose::STANDARD.decode(&sign_response.pset)?,
380 )?;
381 let server_signed_blinded_pset = copy_signatures(blinded_pset, server_signed_pset)?;
382
383 let tx = self
384 .onchain_wallet
385 .sign_pset(server_signed_blinded_pset)
386 .await?;
387 Ok((tx, server_fee.value))
388 }
389}
390
391impl From<&Utxo> for InOut {
392 fn from(utxo: &Utxo) -> Self {
393 Self {
394 asset_id: utxo.asset_id,
395 value: utxo.value,
396 }
397 }
398}
399
400fn copy_signatures(
401 mut dst_pset: PartiallySignedTransaction,
402 src_pset: PartiallySignedTransaction,
403) -> PayjoinResult<PartiallySignedTransaction> {
404 ensure_sdk!(
405 dst_pset.inputs().len() == src_pset.inputs().len(),
406 PayjoinError::generic("Input lengths mismatch")
407 );
408 ensure_sdk!(
409 dst_pset.outputs().len() == src_pset.outputs().len(),
410 PayjoinError::generic("Output lengths mismatch")
411 );
412 for (dst_input, src_input) in dst_pset
413 .inputs_mut()
414 .iter_mut()
415 .zip(src_pset.inputs().iter())
416 {
417 if src_input.final_script_witness.is_some() {
418 dst_input.final_script_sig = src_input.final_script_sig.clone();
419 dst_input.final_script_witness = src_input.final_script_witness.clone();
420 }
421 }
422 Ok(dst_pset)
423}
424
425fn select_utxos(mut utxos: Vec<Utxo>, in_outs: Vec<InOut>) -> PayjoinResult<Vec<PsetInput>> {
426 let secp = Secp256k1::new();
427 let mut selected = Vec::new();
428 for in_out in in_outs {
429 let index = utxos
430 .iter()
431 .position(|utxo| utxo.asset_id == in_out.asset_id && utxo.value == in_out.value)
432 .ok_or(PayjoinError::generic("Failed to find utxo"))?;
433 let utxo = utxos.remove(index);
434
435 let (asset_commitment, value_commitment) = if utxo.asset_bf == AssetBlindingFactor::zero()
436 || utxo.value_bf == ValueBlindingFactor::zero()
437 {
438 (
439 confidential::Asset::Explicit(utxo.asset_id),
440 confidential::Value::Explicit(utxo.value),
441 )
442 } else {
443 let gen =
444 Generator::new_blinded(&secp, utxo.asset_id.into_tag(), utxo.asset_bf.into_inner());
445 (
446 confidential::Asset::Confidential(gen),
447 confidential::Value::new_confidential(&secp, utxo.value, gen, utxo.value_bf),
448 )
449 };
450
451 let input = PsetInput {
452 txid: utxo.txid,
453 vout: utxo.vout,
454 script_pub_key: utxo.script_pub_key,
455 asset_commitment,
456 value_commitment,
457 tx_out_sec: TxOutSecrets {
458 asset: utxo.asset_id,
459 asset_bf: utxo.asset_bf,
460 value: utxo.value,
461 value_bf: utxo.value_bf,
462 },
463 };
464 debug!("Payjoin input: {} vout: {}", input.txid, input.vout);
465 selected.push(input);
466 }
467 Ok(selected)
468}
469
470#[cfg(test)]
471mod tests {
472 use super::*;
473
474 use anyhow::Result;
475 use lwk_wollet::{
476 elements::{OutPoint, Script, Txid},
477 Chain, WalletTxOut,
478 };
479 use sdk_common::prelude::{BreezServer, MockResponse, MockRestClient, STAGING_BREEZSERVER_URL};
480 use serde_json::json;
481
482 use crate::{
483 model::Signer,
484 test_utils::{
485 persist::create_persister,
486 wallet::{MockSigner, MockWallet},
487 },
488 };
489
490 #[cfg(all(target_family = "wasm", target_os = "unknown"))]
491 wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
492
493 fn create_sideswap_payjoin_service(
494 persister: Arc<Persister>,
495 ) -> Result<(Arc<MockWallet>, Arc<MockRestClient>, SideSwapPayjoinService)> {
496 let config = Config::testnet_esplora(None);
497 let breez_server = Arc::new(BreezServer::new(STAGING_BREEZSERVER_URL.to_string(), None)?);
498 let signer: Arc<Box<dyn Signer>> = Arc::new(Box::new(MockSigner::new()?));
499 let onchain_wallet = Arc::new(MockWallet::new(signer.clone())?);
500 let rest_client = Arc::new(MockRestClient::new());
501
502 Ok((
503 onchain_wallet.clone(),
504 rest_client.clone(),
505 SideSwapPayjoinService::new(
506 config,
507 breez_server,
508 persister,
509 onchain_wallet,
510 rest_client,
511 ),
512 ))
513 }
514
515 fn create_utxos(asset: AssetId, values: Vec<u64>) -> Vec<WalletTxOut> {
516 let txid =
517 Txid::from_str("0000000000000000000000000000000000000000000000000000000000000001")
518 .unwrap();
519 let script_pubkey =
520 Script::from_str("76a914000000000000000000000000000000000000000088ac").unwrap();
521
522 values.into_iter().map(|value| {
523 WalletTxOut {
524 outpoint: OutPoint::new(txid, 0),
525 script_pubkey: script_pubkey.clone(),
526 height: Some(10),
527 unblinded: TxOutSecrets {
528 asset,
529 value,
530 asset_bf: AssetBlindingFactor::zero(),
531 value_bf: ValueBlindingFactor::zero(),
532 },
533 wildcard_index: 0,
534 ext_int: Chain::Internal,
535 is_spent: false,
536 address: Address::from_str("lq1pqw8ct25kd47dejyesyvk3g2kaf8s9uhq4se7r2kj9y9hhvu9ug5thxlpn9y63s78kc2mcp6nujavckvr42q7hwkhqq9hfz46nth22hfp3em0ulm4nsuf").unwrap(),
537 }
538 }).collect()
539 }
540
541 #[sdk_macros::async_test_all]
542 async fn test_fetch_accepted_assets_error() -> Result<()> {
543 create_persister!(persister);
544 let (_, mock_rest_client, payjoin_service) =
545 create_sideswap_payjoin_service(persister).unwrap();
546
547 mock_rest_client.add_response(MockResponse::new(400, "".to_string()));
548
549 let res = payjoin_service.fetch_accepted_assets().await;
550 assert!(res.is_err());
551
552 Ok(())
553 }
554
555 #[sdk_macros::async_test_all]
556 async fn test_fetch_accepted_assets() -> Result<()> {
557 create_persister!(persister);
558 let (_, mock_rest_client, payjoin_service) =
559 create_sideswap_payjoin_service(persister).unwrap();
560 let asset_id = AssetId::from_slice(&[2; 32]).unwrap().to_string();
561
562 let response_body =
563 json!({"accepted_assets": {"accepted_asset":[{"asset_id": asset_id}]}}).to_string();
564 mock_rest_client.add_response(MockResponse::new(200, response_body));
565
566 let res = payjoin_service.fetch_accepted_assets().await;
567 assert!(res.is_ok());
568 let accepted_assets = res.unwrap();
569 assert_eq!(accepted_assets.len(), 1);
570 assert_eq!(accepted_assets[0].asset_id, asset_id);
571
572 Ok(())
573 }
574
575 #[sdk_macros::async_test_all]
576 async fn test_estimate_payjoin_tx_fee_error() -> Result<()> {
577 create_persister!(persister);
578 let (_, mock_rest_client, payjoin_service) =
579 create_sideswap_payjoin_service(persister).unwrap();
580 let asset_id = AssetId::from_slice(&[2; 32]).unwrap().to_string();
581
582 mock_rest_client.add_response(MockResponse::new(400, "".to_string()));
583
584 let amount_sat = 500_000;
585 let res = payjoin_service
586 .estimate_payjoin_tx_fee(&asset_id, amount_sat)
587 .await;
588 assert!(res.is_err());
589
590 Ok(())
591 }
592
593 #[sdk_macros::async_test_all]
594 async fn test_estimate_payjoin_tx_fee_no_utxos() -> Result<()> {
595 create_persister!(persister);
596 let (_, mock_rest_client, payjoin_service) =
597 create_sideswap_payjoin_service(persister).unwrap();
598 let asset_id = AssetId::from_slice(&[2; 32]).unwrap().to_string();
599
600 let response_body =
601 json!({"accepted_assets": {"accepted_asset":[{"asset_id": asset_id}]}}).to_string();
602 mock_rest_client.add_response(MockResponse::new(200, response_body));
603
604 let amount_sat = 500_000;
605 let res = payjoin_service
606 .estimate_payjoin_tx_fee(&asset_id, amount_sat)
607 .await;
608 assert!(res.is_err());
609 assert_eq!(res.unwrap_err().to_string(), "Cannot pay: not enough funds");
610
611 Ok(())
612 }
613
614 #[sdk_macros::async_test_all]
615 async fn test_estimate_payjoin_tx_fee_no_asset_metadata() -> Result<()> {
616 create_persister!(persister);
617 let (mock_wallet, mock_rest_client, payjoin_service) =
618 create_sideswap_payjoin_service(persister).unwrap();
619 let asset_id = AssetId::from_slice(&[2; 32]).unwrap();
620 let asset_id_str = asset_id.to_string();
621
622 let accepted_assets_response = json!({
624 "accepted_assets": {
625 "accepted_asset":[{"asset_id": asset_id_str}]
626 }
627 })
628 .to_string();
629 mock_rest_client.add_response(MockResponse::new(200, accepted_assets_response));
630
631 let utxos = create_utxos(asset_id, vec![1_000_000]);
633 mock_wallet.set_utxos(utxos);
634
635 let amount_sat = 500_000;
636 let res = payjoin_service
637 .estimate_payjoin_tx_fee(&asset_id_str, amount_sat)
638 .await;
639
640 assert_eq!(res.unwrap_err().to_string(), "No asset metadata available for 0202020202020202020202020202020202020202020202020202020202020202");
641
642 Ok(())
643 }
644
645 #[sdk_macros::async_test_all]
646 #[ignore = "Requires a mockable FiatAPI"]
647
648 async fn test_estimate_payjoin_tx_fee() -> Result<()> {
649 create_persister!(persister);
650 let (mock_wallet, mock_rest_client, payjoin_service) =
651 create_sideswap_payjoin_service(persister).unwrap();
652 let asset_id =
653 AssetId::from_str("b612eb46313a2cd6ebabd8b7a8eed5696e29898b87a43bff41c94f51acef9d73")
654 .unwrap();
655 let asset_id_str = asset_id.to_string();
656
657 let accepted_assets_response = json!({
659 "accepted_assets": {
660 "accepted_asset":[{"asset_id": asset_id_str}]
661 }
662 })
663 .to_string();
664 mock_rest_client.add_response(MockResponse::new(200, accepted_assets_response));
665
666 let utxos = create_utxos(asset_id, vec![1_000_000]);
670 mock_wallet.set_utxos(utxos);
671
672 let amount_sat = 500_000;
673 let res = payjoin_service
674 .estimate_payjoin_tx_fee(&asset_id_str, amount_sat)
675 .await;
676
677 assert_eq!(res.unwrap_err().to_string(), "Cannot pay: not enough funds");
678
679 Ok(())
680 }
681}