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

Commit 2ca4379

Browse files
committed
Receive payjoin
1 parent 2c6c4ef commit 2ca4379

11 files changed

Lines changed: 828 additions & 239 deletions

File tree

Cargo.lock

Lines changed: 512 additions & 235 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

mutiny-core/Cargo.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ lightning-rapid-gossip-sync = { version = "0.0.121" }
3333
lightning-background-processor = { version = "0.0.121", features = ["futures"] }
3434
lightning-transaction-sync = { version = "0.0.121", default-features = false, features = ["esplora-async-https"] }
3535
lightning-liquidity = "0.1.0-alpha.2"
36-
chrono = "0.4.22"
36+
chrono = "0.4.33"
3737
futures-util = { version = "0.3", default-features = false }
3838
reqwest = { version = "0.11", default-features = false, features = ["multipart", "json"] }
3939
async-trait = "0.1.68"
@@ -44,7 +44,8 @@ cbc = { version = "0.1", features = ["alloc"] }
4444
aes = { version = "0.8" }
4545
jwt-compact = { version = "0.8.0-beta.1", features = ["es256k"] }
4646
argon2 = { version = "0.5.0", features = ["password-hash", "alloc"] }
47-
payjoin = { version = "0.13.0", features = ["send", "base64"] }
47+
once_cell = "1.18.0"
48+
payjoin = { version = "0.15.0", features = ["v2", "send", "receive", "base64"] }
4849
bincode = "1.3.3"
4950
hex-conservative = "0.1.1"
5051
async-lock = "3.2.0"

mutiny-core/src/error.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,9 @@ pub enum MutinyError {
154154
/// Cannot change password to the same password
155155
#[error("Cannot change password to the same password.")]
156156
SamePassword,
157+
/// Error with payjoin
158+
#[error("Payjoin error: {0}")]
159+
Payjoin(crate::payjoin::Error),
157160
/// Payjoin request creation failed.
158161
#[error("Failed to create payjoin request.")]
159162
PayjoinCreateRequest,
@@ -574,6 +577,12 @@ impl From<nostr_sdk::signer::Error> for MutinyError {
574577
}
575578
}
576579

580+
impl From<crate::payjoin::Error> for MutinyError {
581+
fn from(e: crate::payjoin::Error) -> Self {
582+
Self::Payjoin(e)
583+
}
584+
}
585+
577586
impl From<payjoin::send::CreateRequestError> for MutinyError {
578587
fn from(_e: payjoin::send::CreateRequestError) -> Self {
579588
Self::PayjoinCreateRequest

mutiny-core/src/lib.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
type_alias_bounds
99
)]
1010
extern crate core;
11+
extern crate payjoin as pj;
1112

1213
pub mod auth;
1314
pub mod blindauth;
@@ -33,6 +34,7 @@ mod node;
3334
pub mod nodemanager;
3435
pub mod nostr;
3536
mod onchain;
37+
mod payjoin;
3638
mod peermanager;
3739
pub mod scorer;
3840
pub mod storage;
@@ -1573,11 +1575,31 @@ impl<S: MutinyStorage> MutinyWallet<S> {
15731575
return Err(MutinyError::WalletOperationFailed);
15741576
};
15751577

1578+
let (pj, ohttp) = match self.node_manager.start_payjoin_session().await {
1579+
Ok((enrolled, ohttp_keys)) => {
1580+
let pj_uri = enrolled.fallback_target();
1581+
self.node_manager.spawn_payjoin_receiver(enrolled);
1582+
let ohttp = base64::encode_config(
1583+
ohttp_keys
1584+
.encode()
1585+
.map_err(|_| MutinyError::PayjoinConfigError)?,
1586+
base64::URL_SAFE_NO_PAD,
1587+
);
1588+
(Some(pj_uri), Some(ohttp))
1589+
}
1590+
Err(e) => {
1591+
log_error!(self.logger, "Error enrolling payjoin: {e}");
1592+
(None, None)
1593+
}
1594+
};
1595+
15761596
Ok(MutinyBip21RawMaterials {
15771597
address,
15781598
invoice,
15791599
btc_amount: amount.map(|amount| bitcoin::Amount::from_sat(amount).to_btc().to_string()),
15801600
labels,
1601+
pj,
1602+
ohttp,
15811603
})
15821604
}
15831605

mutiny-core/src/nodemanager.rs

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use crate::auth::MutinyAuthClient;
22
use crate::labels::LabelStorage;
33
use crate::ldkstorage::CHANNEL_CLOSURE_PREFIX;
44
use crate::logging::LOGGING_KEY;
5+
use crate::payjoin::Error as PayjoinError;
56
use crate::utils::{sleep, spawn};
67
use crate::MutinyInvoice;
78
use crate::MutinyWalletConfig;
@@ -50,6 +51,7 @@ use lightning::{log_debug, log_error, log_info, log_trace, log_warn};
5051
use lightning_invoice::Bolt11Invoice;
5152
use lightning_transaction_sync::EsploraSyncClient;
5253
use payjoin::Uri;
54+
use pj::receive::v2::Enrolled;
5355
use reqwest::Client;
5456
use serde::{Deserialize, Serialize};
5557
use serde_json::Value;
@@ -99,6 +101,8 @@ pub struct MutinyBip21RawMaterials {
99101
pub invoice: Option<Bolt11Invoice>,
100102
pub btc_amount: Option<String>,
101103
pub labels: Vec<String>,
104+
pub pj: Option<String>,
105+
pub ohttp: Option<String>,
102106
}
103107

104108
#[derive(Serialize, Deserialize, Clone, Eq, PartialEq)]
@@ -666,6 +670,34 @@ impl<S: MutinyStorage> NodeManager<S> {
666670
Err(MutinyError::WalletOperationFailed)
667671
}
668672

673+
pub async fn start_payjoin_session(
674+
&self,
675+
) -> Result<(Enrolled, payjoin::OhttpKeys), PayjoinError> {
676+
use crate::payjoin::{fetch_ohttp_keys, random_ohttp_relay, PAYJOIN_DIR};
677+
678+
let ohttp_keys = fetch_ohttp_keys(PAYJOIN_DIR.to_owned()).await?;
679+
let http_client = reqwest::Client::builder().build()?;
680+
681+
let mut enroller = payjoin::receive::v2::Enroller::from_directory_config(
682+
PAYJOIN_DIR.to_owned(),
683+
ohttp_keys.clone(),
684+
random_ohttp_relay().to_owned(),
685+
);
686+
let (req, context) = enroller.extract_req()?;
687+
let ohttp_response = http_client
688+
.post(req.url)
689+
.header("Content-Type", "message/ohttp-req")
690+
.body(req.body)
691+
.send()
692+
.await?;
693+
let ohttp_response = ohttp_response.bytes().await?;
694+
Ok((
695+
enroller.process_res(ohttp_response.as_ref(), context)?,
696+
ohttp_keys,
697+
))
698+
}
699+
700+
// Send v1 payjoin request
669701
pub async fn send_payjoin(
670702
&self,
671703
uri: Uri<'_, NetworkUnchecked>,
@@ -740,6 +772,78 @@ impl<S: MutinyStorage> NodeManager<S> {
740772
Ok(txid)
741773
}
742774

775+
pub fn spawn_payjoin_receiver(&self, enrolled: Enrolled) {
776+
let logger = self.logger.clone();
777+
let wallet = self.wallet.clone();
778+
utils::spawn(async move {
779+
match Self::receive_payjoin(wallet, enrolled).await {
780+
Ok(txid) => log_info!(logger, "Received payjoin txid: {txid}"),
781+
Err(e) => log_error!(logger, "Error receiving payjoin: {e}"),
782+
};
783+
});
784+
}
785+
786+
/// Poll the payjoin relay to maintain a payjoin session and create a payjoin proposal.
787+
async fn receive_payjoin(
788+
wallet: Arc<OnChainWallet<S>>,
789+
mut enrolled: payjoin::receive::v2::Enrolled,
790+
) -> Result<Txid, MutinyError> {
791+
let http_client = reqwest::Client::builder()
792+
.build()
793+
.map_err(PayjoinError::Reqwest)?;
794+
let proposal: payjoin::receive::v2::UncheckedProposal =
795+
Self::poll_for_fallback_psbt(&http_client, &mut enrolled).await?;
796+
let original_tx = proposal.extract_tx_to_schedule_broadcast();
797+
let mut payjoin_proposal = match wallet
798+
.process_payjoin_proposal(proposal)
799+
.map_err(|e| PayjoinError::ReceiverStateMachine(e.to_string()))
800+
{
801+
Ok(p) => p,
802+
Err(e) => {
803+
wallet.broadcast_transaction(original_tx).await?;
804+
return Err(e.into());
805+
}
806+
};
807+
808+
let (req, ohttp_ctx) = payjoin_proposal
809+
.extract_v2_req()
810+
.map_err(|e| PayjoinError::ReceiverStateMachine(e.to_string()))?;
811+
let res = http_client
812+
.post(req.url)
813+
.header("Content-Type", "message/ohttp-req")
814+
.body(req.body)
815+
.send()
816+
.await
817+
.map_err(PayjoinError::Reqwest)?;
818+
let res = res.bytes().await.map_err(PayjoinError::Reqwest)?;
819+
// enroll must succeed
820+
let _res = payjoin_proposal
821+
.deserialize_res(res.to_vec(), ohttp_ctx)
822+
.map_err(|e| PayjoinError::ReceiverStateMachine(e.to_string()))?;
823+
Ok(payjoin_proposal.psbt().clone().extract_tx().txid())
824+
}
825+
826+
async fn poll_for_fallback_psbt(
827+
client: &reqwest::Client,
828+
enroller: &mut payjoin::receive::v2::Enrolled,
829+
) -> Result<payjoin::receive::v2::UncheckedProposal, PayjoinError> {
830+
loop {
831+
let (req, context) = enroller.extract_req()?;
832+
let ohttp_response = client
833+
.post(req.url)
834+
.header("Content-Type", "message/ohttp-req")
835+
.body(req.body)
836+
.send()
837+
.await?;
838+
let ohttp_response = ohttp_response.bytes().await?;
839+
let proposal = enroller.process_res(ohttp_response.as_ref(), context)?;
840+
match proposal {
841+
Some(proposal) => return Ok(proposal),
842+
None => utils::sleep(5000).await,
843+
}
844+
}
845+
}
846+
743847
/// Sends an on-chain transaction to the given address.
744848
/// The amount is in satoshis and the fee rate is in sat/vbyte.
745849
///

mutiny-core/src/onchain.rs

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ use bdk_esplora::EsploraAsyncExt;
1414
use bitcoin::bip32::{ChildNumber, DerivationPath, ExtendedPrivKey};
1515
use bitcoin::consensus::serialize;
1616
use bitcoin::psbt::{Input, PartiallySignedTransaction};
17-
use bitcoin::{Address, Network, OutPoint, ScriptBuf, Transaction, Txid};
17+
use bitcoin::{Address, Network, OutPoint, Script, ScriptBuf, Transaction, Txid};
1818
use esplora_client::AsyncClient;
1919
use hex_conservative::DisplayHex;
2020
use lightning::events::bump_transaction::{Utxo, WalletSource};
@@ -355,10 +355,103 @@ impl<S: MutinyStorage> OnChainWallet<S> {
355355
Ok(())
356356
}
357357

358+
fn is_mine(&self, script: &Script) -> Result<bool, MutinyError> {
359+
Ok(self.wallet.try_read()?.is_mine(script))
360+
}
361+
358362
pub fn list_utxos(&self) -> Result<Vec<LocalOutput>, MutinyError> {
359363
Ok(self.wallet.try_read()?.list_unspent().collect())
360364
}
361365

366+
pub fn process_payjoin_proposal(
367+
&self,
368+
proposal: payjoin::receive::v2::UncheckedProposal,
369+
) -> Result<payjoin::receive::v2::PayjoinProposal, payjoin::Error> {
370+
use payjoin::Error;
371+
372+
// Receive Check 1 bypass: We're not an automated payment processor.
373+
let proposal = proposal.assume_interactive_receiver();
374+
log::trace!("check1");
375+
376+
// Receive Check 2: receiver can't sign for proposal inputs
377+
let proposal = proposal.check_inputs_not_owned(|input| {
378+
self.is_mine(input).map_err(|e| Error::Server(e.into()))
379+
})?;
380+
log::trace!("check2");
381+
382+
// Receive Check 3: receiver can't sign for proposal inputs
383+
let proposal = proposal.check_no_mixed_input_scripts()?;
384+
log::trace!("check3");
385+
386+
// Receive Check 4: have we seen this input before?
387+
let payjoin = proposal.check_no_inputs_seen_before(|_input| {
388+
// 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**
389+
// assume false since Mutiny is not an automatic payment processor
390+
Ok(false)
391+
})?;
392+
log::trace!("check4");
393+
394+
let mut provisional_payjoin = payjoin.identify_receiver_outputs(|output| {
395+
self.is_mine(output).map_err(|e| Error::Server(e.into()))
396+
})?;
397+
self.try_contributing_inputs(&mut provisional_payjoin)
398+
.map_err(|e| Error::Server(e.into()))?;
399+
400+
// Outputs may be substituted for e.g. batching at this stage
401+
// We're not doing this yet.
402+
403+
// Don't provide input to transactions with a fee rate below the low fee rate
404+
// They might lock our coins up
405+
let min_pj_fee_rate =
406+
bitcoin::FeeRate::from_sat_per_kwu(self.fees.get_low_fee_rate() as u64);
407+
let payjoin_proposal = provisional_payjoin.finalize_proposal(
408+
|psbt| {
409+
let mut psbt = psbt.clone();
410+
let wallet = self
411+
.wallet
412+
.try_read()
413+
.map_err(|_| Error::Server(MutinyError::WalletSigningFailed.into()))?;
414+
wallet
415+
.sign(&mut psbt, SignOptions::default())
416+
.map_err(|_| Error::Server(MutinyError::WalletSigningFailed.into()))?;
417+
Ok(psbt)
418+
},
419+
Some(min_pj_fee_rate),
420+
)?;
421+
let payjoin_proposal_psbt = payjoin_proposal.psbt();
422+
log::debug!(
423+
"Receiver's Payjoin proposal PSBT Rsponse: {:#?}",
424+
payjoin_proposal_psbt
425+
);
426+
Ok(payjoin_proposal)
427+
}
428+
429+
fn try_contributing_inputs(
430+
&self,
431+
payjoin: &mut payjoin::receive::v2::ProvisionalProposal,
432+
) -> Result<(), MutinyError> {
433+
use payjoin::bitcoin::Amount;
434+
435+
let available_inputs = self.list_utxos()?;
436+
let candidate_inputs: std::collections::HashMap<Amount, OutPoint> = available_inputs
437+
.iter()
438+
.filter(|u| u.confirmation_time.is_confirmed())
439+
.map(|i| (Amount::from_sat(i.txout.value), i.outpoint))
440+
.collect();
441+
442+
let selected_outpoint = payjoin
443+
.try_preserving_privacy(candidate_inputs)
444+
.map_err(|_| anyhow!("no privacy-preserving selection available"))?;
445+
let selected_utxo = available_inputs
446+
.iter()
447+
.find(|i| i.outpoint == selected_outpoint)
448+
.ok_or(anyhow!("This shouldn't happen. Failed to retrieve the privacy preserving utxo from those we provided to the seclector."))?;
449+
log::debug!("selected utxo: {:#?}", selected_utxo);
450+
451+
payjoin.contribute_witness_input(selected_utxo.txout.clone(), selected_outpoint);
452+
Ok(())
453+
}
454+
362455
pub fn list_transactions(
363456
&self,
364457
include_raw: bool,

0 commit comments

Comments
 (0)