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

Commit 59641f1

Browse files
committed
Receive payjoin
1 parent 077c0a8 commit 59641f1

File tree

5 files changed

+243
-2
lines changed

5 files changed

+243
-2
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: 116 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,11 @@ use crate::{gossip::*, scorer::HubPreferentialScorer};
3131
use crate::{labels::LabelStorage, subscription::MutinySubscriptionClient};
3232
use anyhow::anyhow;
3333
use bdk::chain::{BlockId, ConfirmationTime};
34-
use bdk::{wallet::AddressIndex, LocalUtxo};
34+
use bdk::{wallet::AddressIndex, FeeRate, LocalUtxo};
3535
use bitcoin::blockdata::script;
3636
use bitcoin::hashes::hex::ToHex;
3737
use bitcoin::hashes::{sha256, Hash};
38+
use bitcoin::psbt::PartiallySignedTransaction;
3839
use bitcoin::secp256k1::{rand, PublicKey, Secp256k1, SecretKey};
3940
use bitcoin::util::bip32::{DerivationPath, ExtendedPrivKey};
4041
use bitcoin::{Address, Network, OutPoint, Transaction, Txid};
@@ -62,6 +63,7 @@ use nostr::{EventBuilder, Keys, Kind, Tag, TagKind};
6263
use reqwest::Client;
6364
use serde::{Deserialize, Serialize};
6465
use serde_json::Value;
66+
use std::io::Cursor;
6567
use std::str::FromStr;
6668
use std::sync::atomic::{AtomicBool, Ordering};
6769
use std::{collections::HashMap, ops::Deref, sync::Arc};
@@ -162,6 +164,7 @@ pub struct MutinyBip21RawMaterials {
162164
pub invoice: Option<Bolt11Invoice>,
163165
pub btc_amount: Option<String>,
164166
pub labels: Vec<String>,
167+
pub pj: Option<String>,
165168
}
166169

167170
#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq)]
@@ -1009,7 +1012,7 @@ impl<S: MutinyStorage> NodeManager<S> {
10091012
Err(MutinyError::WalletOperationFailed)
10101013
}
10111014

1012-
/// Creates a BIP 21 invoice. This creates a new address and a lightning invoice.
1015+
/// Creates a BIP 21 invoice. This creates a new address, a lightning invoice, and payjoin session.
10131016
/// The lightning invoice may return errors related to the LSP. Check the error and
10141017
/// fallback to `get_new_address` and warn the user that Lightning is not available.
10151018
///
@@ -1052,14 +1055,125 @@ impl<S: MutinyStorage> NodeManager<S> {
10521055
return Err(MutinyError::WalletOperationFailed);
10531056
};
10541057

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

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

mutiny-core/src/onchain.rs

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