Skip to content
This repository was archived by the owner on Feb 3, 2025. It is now read-only.

Commit 6d98501

Browse files
committed
Receive payjoin
1 parent 077c0a8 commit 6d98501

File tree

5 files changed

+236
-1
lines changed

5 files changed

+236
-1
lines changed

mutiny-core/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ aes = { version = "0.8" }
4141
jwt-compact = { version = "0.8.0-beta.1", features = ["es256k"] }
4242
argon2 = { version = "0.5.0", features = ["password-hash", "alloc"] }
4343
hashbrown = { version = "0.8" }
44+
payjoin = { git = "https://github.com/DanGould/rust-payjoin.git", branch = "serverless-payjoin", features = ["v2", "send", "receive", "base64"] }
4445

4546
base64 = "0.13.0"
4647
pbkdf2 = "0.11"

mutiny-core/src/nodemanager.rs

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ pub struct MutinyBip21RawMaterials {
162162
pub invoice: Option<Bolt11Invoice>,
163163
pub btc_amount: Option<String>,
164164
pub labels: Vec<String>,
165+
pub pj: Option<String>,
165166
}
166167

167168
#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq)]
@@ -1009,7 +1010,7 @@ impl<S: MutinyStorage> NodeManager<S> {
10091010
Err(MutinyError::WalletOperationFailed)
10101011
}
10111012

1012-
/// Creates a BIP 21 invoice. This creates a new address and a lightning invoice.
1013+
/// Creates a BIP 21 invoice. This creates a new address, a lightning invoice, and payjoin session.
10131014
/// The lightning invoice may return errors related to the LSP. Check the error and
10141015
/// fallback to `get_new_address` and warn the user that Lightning is not available.
10151016
///
@@ -1052,14 +1053,126 @@ impl<S: MutinyStorage> NodeManager<S> {
10521053
return Err(MutinyError::WalletOperationFailed);
10531054
};
10541055

1056+
// If we are in safe mode, we don't create payjoin sessions
1057+
let pj = {
1058+
// TODO get from &self config
1059+
const PJ_RELAY_URL: &str = "http://localhost:8080";
1060+
const OH_RELAY_URL: &str = "http://localhost:8080";
1061+
const OHTTP_CONFIG_BASE64: &str = "AQAg7YjKSn1zBziW3LvPCQ8X18hH0dU67G-vOcMHu0-m81AABAABAAM";
1062+
let mut enroller = payjoin::receive::Enroller::from_relay_config(
1063+
PJ_RELAY_URL,
1064+
OHTTP_CONFIG_BASE64,
1065+
OH_RELAY_URL,
1066+
//Some("c53989e590b0f02edeec42a9c43fd1e4e960aec243bb1e6064324bd2c08ec498")
1067+
);
1068+
let http_client = reqwest::Client::builder()
1069+
//.danger_accept_invalid_certs(true) ? is tls unchecked :O
1070+
.build()
1071+
.unwrap();
1072+
// enroll client
1073+
let (req, context) = enroller.extract_req().unwrap();
1074+
let ohttp_response = http_client
1075+
.post(req.url)
1076+
.body(req.body)
1077+
.send()
1078+
.await
1079+
.unwrap();
1080+
let ohttp_response = ohttp_response.bytes().await.unwrap();
1081+
let enrolled = enroller
1082+
.process_res(ohttp_response.as_ref(), context)
1083+
.map_err(|e| anyhow!("parse error {}", e))
1084+
.unwrap();
1085+
let pj_uri = enrolled.fallback_target();
1086+
log_debug!(self.logger, "{pj_uri}");
1087+
let wallet = self.wallet.clone();
1088+
// run await payjoin task in the background as it'll keep polling the relay
1089+
wasm_bindgen_futures::spawn_local(async move {
1090+
let wallet = wallet.clone();
1091+
let pj_txid = Self::receive_payjoin(wallet, enrolled).await.unwrap();
1092+
log::info!("Received payjoin txid: {}", pj_txid);
1093+
});
1094+
Some(pj_uri)
1095+
};
1096+
10551097
Ok(MutinyBip21RawMaterials {
10561098
address,
10571099
invoice,
10581100
btc_amount: amount.map(|amount| bitcoin::Amount::from_sat(amount).to_btc().to_string()),
10591101
labels,
1102+
pj,
10601103
})
10611104
}
10621105

1106+
/// Poll the payjoin relay to maintain a payjoin session and create a payjoin proposal.
1107+
pub async fn receive_payjoin(
1108+
wallet: Arc<OnChainWallet<S>>,
1109+
mut enrolled: payjoin::receive::Enrolled,
1110+
) -> Result<Txid, MutinyError> {
1111+
let http_client = reqwest::Client::builder()
1112+
//.danger_accept_invalid_certs(true) ? is tls unchecked :O
1113+
.build()
1114+
.unwrap();
1115+
let proposal: payjoin::receive::UncheckedProposal =
1116+
Self::poll_for_fallback_psbt(&http_client, &mut enrolled)
1117+
.await
1118+
.unwrap();
1119+
let payjoin_proposal = wallet.process_payjoin_proposal(proposal).unwrap();
1120+
1121+
let (req, ohttp_ctx) = payjoin_proposal
1122+
.extract_v2_req()
1123+
.unwrap(); // extraction failed
1124+
let res = http_client
1125+
.post(req.url)
1126+
.body(req.body)
1127+
.send()
1128+
.await
1129+
.unwrap();
1130+
let res = res.bytes().await.unwrap();
1131+
// enroll must succeed
1132+
let _res = payjoin_proposal
1133+
.deserialize_res(res.to_vec(), ohttp_ctx)
1134+
.unwrap();
1135+
// convert from bitcoin 29 to 30
1136+
let txid = payjoin_proposal.psbt().clone().extract_tx().txid();
1137+
let txid = Txid::from_str(&txid.to_string()).unwrap();
1138+
Ok(txid)
1139+
}
1140+
1141+
async fn poll_for_fallback_psbt(
1142+
client: &reqwest::Client,
1143+
enroller: &mut payjoin::receive::Enrolled,
1144+
) -> Result<payjoin::receive::UncheckedProposal, ()> {
1145+
loop {
1146+
let (req, context) = enroller.extract_req().unwrap();
1147+
let ohttp_response = client
1148+
.post(req.url)
1149+
.body(req.body)
1150+
.send()
1151+
.await
1152+
.unwrap();
1153+
let ohttp_response = ohttp_response.bytes().await.unwrap();
1154+
let proposal = enroller
1155+
.process_res(ohttp_response.as_ref(), context)
1156+
.map_err(|e| anyhow!("parse error {}", e))
1157+
.unwrap();
1158+
match proposal {
1159+
Some(proposal) => return Ok(proposal),
1160+
None => Self::delay(5000).await.unwrap(),
1161+
}
1162+
}
1163+
}
1164+
1165+
async fn delay(millis: u32) -> Result<(), wasm_bindgen::JsValue> {
1166+
let promise = js_sys::Promise::new(&mut |yes, _| {
1167+
let win = web_sys::window().expect("should have a Window");
1168+
win.set_timeout_with_callback_and_timeout_and_arguments_0(&yes, millis as i32)
1169+
.expect("should set a timeout");
1170+
});
1171+
1172+
wasm_bindgen_futures::JsFuture::from(promise).await?;
1173+
Ok(())
1174+
}
1175+
10631176
/// Sends an on-chain transaction to the given address.
10641177
/// The amount is in satoshis and the fee rate is in sat/vbyte.
10651178
///

mutiny-core/src/onchain.rs

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use anyhow::anyhow;
2+
use esplora_client::FromHex;
23
use std::collections::{BTreeMap, HashSet};
34
use std::str::FromStr;
45
use std::sync::atomic::{AtomicBool, Ordering};
@@ -285,10 +286,122 @@ impl<S: MutinyStorage> OnChainWallet<S> {
285286
Ok(())
286287
}
287288

289+
fn is_mine(&self, script: &Script) -> Result<bool, MutinyError> {
290+
Ok(self.wallet.try_read()?.is_mine(script))
291+
}
292+
288293
pub fn list_utxos(&self) -> Result<Vec<LocalUtxo>, MutinyError> {
289294
Ok(self.wallet.try_read()?.list_unspent().collect())
290295
}
291296

297+
pub fn process_payjoin_proposal(
298+
&self,
299+
proposal: payjoin::receive::UncheckedProposal,
300+
) -> Result<payjoin::receive::PayjoinProposal, payjoin::Error> {
301+
use payjoin::Error;
302+
303+
// Receive Check 1 bypass: We're not an automated payment processor.
304+
let proposal = proposal.assume_interactive_receiver();
305+
log::trace!("check1");
306+
307+
// Receive Check 2: receiver can't sign for proposal inputs
308+
let proposal = proposal.check_inputs_not_owned(|input| {
309+
// convert from payjoin::bitcoin 30 to 29
310+
let input = bitcoin::Script::from_hex(&input.to_hex()).unwrap();
311+
self.is_mine(&input).map_err(|e| Error::Server(e.into()))
312+
})?;
313+
log::trace!("check2");
314+
315+
// Receive Check 3: receiver can't sign for proposal inputs
316+
let proposal = proposal.check_no_mixed_input_scripts()?;
317+
log::trace!("check3");
318+
319+
// Receive Check 4: have we seen this input before?
320+
let payjoin = proposal.check_no_inputs_seen_before(|_input| {
321+
// This check ensures an automated sender does not get phished. It is not necessary for interactive payjoin **where the sender cannot generate bip21s from us**
322+
// assume false since Mutiny is not an automatic payment processor
323+
Ok(false)
324+
})?;
325+
log::trace!("check4");
326+
327+
let mut provisional_payjoin =
328+
payjoin.identify_receiver_outputs(|output: &payjoin::bitcoin::Script| {
329+
// convert from payjoin::bitcoin 30 to 29
330+
let output = bitcoin::Script::from_hex(&output.to_hex()).unwrap();
331+
self.is_mine(&output).map_err(|e| Error::Server(e.into()))
332+
})?;
333+
self.try_contributing_inputs(&mut provisional_payjoin)
334+
.expect("input contribution failed");
335+
336+
// Outputs may be substituted for e.g. batching at this stage
337+
// We're not doing this yet.
338+
339+
let payjoin_proposal = provisional_payjoin.finalize_proposal(
340+
|psbt: &payjoin::bitcoin::psbt::Psbt| {
341+
// convert from payjoin::bitcoin 30.0
342+
let mut psbt = PartiallySignedTransaction::from_str(&psbt.to_string()).unwrap();
343+
let wallet = self
344+
.wallet
345+
.try_read()
346+
.map_err(|_| Error::Server(MutinyError::WalletSigningFailed.into()))?;
347+
wallet
348+
.sign(&mut psbt, SignOptions::default())
349+
.map_err(|_| Error::Server(MutinyError::WalletSigningFailed.into()))?;
350+
// convert back to payjoin::bitcoin
351+
let psbt = payjoin::bitcoin::psbt::Psbt::from_str(&psbt.to_string()).unwrap();
352+
Ok(psbt)
353+
},
354+
// TODO: check Mutiny's minfeerate is present here
355+
Some(payjoin::bitcoin::FeeRate::MIN),
356+
)?;
357+
let payjoin_proposal_psbt = payjoin_proposal.psbt();
358+
log::debug!(
359+
"Receiver's Payjoin proposal PSBT Rsponse: {:#?}",
360+
payjoin_proposal_psbt
361+
);
362+
Ok(payjoin_proposal)
363+
}
364+
365+
fn try_contributing_inputs(
366+
&self,
367+
payjoin: &mut payjoin::receive::ProvisionalProposal,
368+
) -> Result<(), MutinyError> {
369+
use payjoin::bitcoin::{Amount, OutPoint};
370+
371+
let available_inputs = self
372+
.list_utxos()
373+
.expect("Failed to list unspent from bitcoind");
374+
let candidate_inputs: std::collections::HashMap<Amount, OutPoint> = available_inputs
375+
.iter()
376+
.map(|i| {
377+
(
378+
Amount::from_sat(i.txout.value),
379+
OutPoint::from_str(&i.outpoint.to_string()).unwrap(),
380+
)
381+
})
382+
.collect();
383+
384+
let selected_outpoint = payjoin
385+
.try_preserving_privacy(candidate_inputs)
386+
.expect("no privacy-preserving selection available");
387+
let selected_utxo = available_inputs
388+
.iter()
389+
.find(|i| OutPoint::from_str(&i.outpoint.to_string()).unwrap() == selected_outpoint)
390+
.expect("This shouldn't happen. Failed to retrieve the privacy preserving utxo from those we provided to the seclector.");
391+
log::debug!("selected utxo: {:#?}", selected_utxo);
392+
393+
// calculate receiver payjoin outputs given receiver payjoin inputs and original_psbt,
394+
let txo_to_contribute = payjoin::bitcoin::TxOut {
395+
value: selected_utxo.txout.value,
396+
script_pubkey: payjoin::bitcoin::Script::from_bytes(
397+
&selected_utxo.txout.script_pubkey.clone().into_bytes(),
398+
)
399+
.into(),
400+
};
401+
payjoin.contribute_witness_input(txo_to_contribute, selected_outpoint);
402+
Ok(())
403+
}
404+
292405
pub fn list_transactions(
293406
&self,
294407
include_raw: bool,

mutiny-wasm/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,7 @@ impl MutinyWallet {
268268
invoice: None,
269269
btc_amount: None,
270270
labels,
271+
pj: None,
271272
})
272273
}
273274

mutiny-wasm/src/models.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,7 @@ pub struct MutinyBip21RawMaterials {
461461
pub(crate) invoice: Option<String>,
462462
pub(crate) btc_amount: Option<String>,
463463
pub(crate) labels: Vec<String>,
464+
pub(crate) pj: Option<String>,
464465
}
465466

466467
#[wasm_bindgen]
@@ -489,6 +490,11 @@ impl MutinyBip21RawMaterials {
489490
pub fn labels(&self) -> JsValue /* Vec<String> */ {
490491
JsValue::from_serde(&self.labels).unwrap()
491492
}
493+
494+
#[wasm_bindgen(getter)]
495+
pub fn pj(&self) -> Option<String> {
496+
self.pj.clone()
497+
}
492498
}
493499

494500
impl From<nodemanager::MutinyBip21RawMaterials> for MutinyBip21RawMaterials {
@@ -498,6 +504,7 @@ impl From<nodemanager::MutinyBip21RawMaterials> for MutinyBip21RawMaterials {
498504
invoice: m.invoice.map(|i| i.to_string()),
499505
btc_amount: m.btc_amount,
500506
labels: m.labels,
507+
pj: m.pj,
501508
}
502509
}
503510
}

0 commit comments

Comments
 (0)