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

Commit 80b57db

Browse files
committed
Receive payjoin
1 parent e5068d4 commit 80b57db

11 files changed

Lines changed: 830 additions & 262 deletions

File tree

Cargo.lock

Lines changed: 536 additions & 258 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,
@@ -569,6 +572,12 @@ impl From<nostr_sdk::signer::Error> for MutinyError {
569572
}
570573
}
571574

575+
impl From<crate::payjoin::Error> for MutinyError {
576+
fn from(e: crate::payjoin::Error) -> Self {
577+
Self::Payjoin(e)
578+
}
579+
}
580+
572581
impl From<payjoin::send::CreateRequestError> for MutinyError {
573582
fn from(_e: payjoin::send::CreateRequestError) -> Self {
574583
Self::PayjoinCreateRequest

mutiny-core/src/lib.rs

Lines changed: 55 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;
@@ -1498,11 +1500,64 @@ impl<S: MutinyStorage> MutinyWallet<S> {
14981500
return Err(MutinyError::WalletOperationFailed);
14991501
};
15001502

1503+
let (pj, ohttp) = {
1504+
use crate::payjoin::{OHTTP_RELAYS, PAYJOIN_DIR};
1505+
use anyhow::anyhow;
1506+
1507+
let ohttp_keys = crate::payjoin::fetch_ohttp_keys(
1508+
OHTTP_RELAYS[0].to_owned(),
1509+
PAYJOIN_DIR.to_owned(),
1510+
)
1511+
.await
1512+
.map_err(|e| anyhow!("Payjoin OHTTP fetch error {}", e))?;
1513+
1514+
let ohttp = base64::encode_config(
1515+
ohttp_keys
1516+
.encode()
1517+
.map_err(|_| MutinyError::PayjoinConfigError)?,
1518+
base64::URL_SAFE_NO_PAD,
1519+
);
1520+
let mut enroller = pj::receive::v2::Enroller::from_directory_config(
1521+
PAYJOIN_DIR.to_owned(),
1522+
ohttp_keys,
1523+
OHTTP_RELAYS[0].to_owned(), // TODO pick ohttp relay at random
1524+
);
1525+
1526+
// enroll client
1527+
let (req, context) = enroller.extract_req().unwrap();
1528+
let http_client = reqwest::Client::builder().build().unwrap();
1529+
let ohttp_response = http_client
1530+
.post(req.url)
1531+
.header("Content-Type", "message/ohttp-req")
1532+
.body(req.body)
1533+
.send()
1534+
.await
1535+
.map_err(|_| MutinyError::PayjoinCreateRequest)?;
1536+
let ohttp_response = ohttp_response.bytes().await.unwrap();
1537+
let enrolled = enroller
1538+
.process_res(ohttp_response.as_ref(), context)
1539+
.map_err(|_| MutinyError::PayjoinCreateRequest)?;
1540+
let pj_uri = enrolled.fallback_target();
1541+
log_debug!(self.logger, "{pj_uri}");
1542+
let wallet = self.node_manager.wallet.clone();
1543+
// run await payjoin task in the background as it'll keep polling the relay
1544+
let logger = self.logger.clone();
1545+
utils::spawn(async move {
1546+
match NodeManager::receive_payjoin(wallet, enrolled).await {
1547+
Ok(pj_txid) => log_info!(logger, "Received payjoin txid: {}", pj_txid),
1548+
Err(e) => log_error!(logger, "Payjoin error: {e}"),
1549+
}
1550+
});
1551+
(Some(pj_uri), Some(ohttp))
1552+
};
1553+
15011554
Ok(MutinyBip21RawMaterials {
15021555
address,
15031556
invoice,
15041557
btc_amount: amount.map(|amount| bitcoin::Amount::from_sat(amount).to_btc().to_string()),
15051558
labels,
1559+
pj,
1560+
ohttp,
15061561
})
15071562
}
15081563

mutiny-core/src/nodemanager.rs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,8 @@ pub struct MutinyBip21RawMaterials {
105105
pub invoice: Option<Bolt11Invoice>,
106106
pub btc_amount: Option<String>,
107107
pub labels: Vec<String>,
108+
pub pj: Option<String>,
109+
pub ohttp: Option<String>,
108110
}
109111

110112
#[derive(Serialize, Deserialize, Clone, Eq, PartialEq)]
@@ -715,6 +717,7 @@ impl<S: MutinyStorage> NodeManager<S> {
715717
Err(MutinyError::WalletOperationFailed)
716718
}
717719

720+
// Send v1 payjoin request
718721
pub async fn send_payjoin(
719722
&self,
720723
uri: Uri<'_, NetworkUnchecked>,
@@ -789,6 +792,61 @@ impl<S: MutinyStorage> NodeManager<S> {
789792
Ok(txid)
790793
}
791794

795+
/// Poll the payjoin relay to maintain a payjoin session and create a payjoin proposal.
796+
pub async fn receive_payjoin(
797+
wallet: Arc<OnChainWallet<S>>,
798+
mut enrolled: payjoin::receive::v2::Enrolled,
799+
) -> Result<Txid, MutinyError> {
800+
use crate::payjoin::Error as PayjoinError;
801+
802+
let http_client = reqwest::Client::builder()
803+
.build()
804+
.map_err(PayjoinError::Reqwest)?;
805+
let proposal: payjoin::receive::v2::UncheckedProposal =
806+
Self::poll_for_fallback_psbt(&http_client, &mut enrolled).await?;
807+
let mut payjoin_proposal = wallet
808+
.process_payjoin_proposal(proposal)
809+
.map_err(|e| PayjoinError::ReceiverStateMachine(e.to_string()))?;
810+
811+
let (req, ohttp_ctx) = payjoin_proposal
812+
.extract_v2_req()
813+
.map_err(|e| PayjoinError::ReceiverStateMachine(e.to_string()))?;
814+
let res = http_client
815+
.post(req.url)
816+
.header("Content-Type", "message/ohttp-req")
817+
.body(req.body)
818+
.send()
819+
.await
820+
.map_err(PayjoinError::Reqwest)?;
821+
let res = res.bytes().await.map_err(PayjoinError::Reqwest)?;
822+
// enroll must succeed
823+
let _res = payjoin_proposal
824+
.deserialize_res(res.to_vec(), ohttp_ctx)
825+
.map_err(|e| PayjoinError::ReceiverStateMachine(e.to_string()))?;
826+
Ok(payjoin_proposal.psbt().clone().extract_tx().txid())
827+
}
828+
829+
async fn poll_for_fallback_psbt(
830+
client: &reqwest::Client,
831+
enroller: &mut payjoin::receive::v2::Enrolled,
832+
) -> Result<payjoin::receive::v2::UncheckedProposal, crate::payjoin::Error> {
833+
loop {
834+
let (req, context) = enroller.extract_req()?;
835+
let ohttp_response = client
836+
.post(req.url)
837+
.header("Content-Type", "message/ohttp-req")
838+
.body(req.body)
839+
.send()
840+
.await?;
841+
let ohttp_response = ohttp_response.bytes().await?;
842+
let proposal = enroller.process_res(ohttp_response.as_ref(), context)?;
843+
match proposal {
844+
Some(proposal) => return Ok(proposal),
845+
None => utils::sleep(5000).await,
846+
}
847+
}
848+
}
849+
792850
/// Sends an on-chain transaction to the given address.
793851
/// The amount is in satoshis and the fee rate is in sat/vbyte.
794852
///

mutiny-core/src/onchain.rs

Lines changed: 90 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,99 @@ 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+
let payjoin_proposal = provisional_payjoin.finalize_proposal(
404+
|psbt| {
405+
let mut psbt = psbt.clone();
406+
let wallet = self
407+
.wallet
408+
.try_read()
409+
.map_err(|_| Error::Server(MutinyError::WalletSigningFailed.into()))?;
410+
wallet
411+
.sign(&mut psbt, SignOptions::default())
412+
.map_err(|_| Error::Server(MutinyError::WalletSigningFailed.into()))?;
413+
Ok(psbt)
414+
},
415+
// TODO: check Mutiny's minfeerate is present here
416+
Some(payjoin::bitcoin::FeeRate::MIN),
417+
)?;
418+
let payjoin_proposal_psbt = payjoin_proposal.psbt();
419+
log::debug!(
420+
"Receiver's Payjoin proposal PSBT Rsponse: {:#?}",
421+
payjoin_proposal_psbt
422+
);
423+
Ok(payjoin_proposal)
424+
}
425+
426+
fn try_contributing_inputs(
427+
&self,
428+
payjoin: &mut payjoin::receive::v2::ProvisionalProposal,
429+
) -> Result<(), MutinyError> {
430+
use payjoin::bitcoin::Amount;
431+
432+
let available_inputs = self.list_utxos()?;
433+
let candidate_inputs: std::collections::HashMap<Amount, OutPoint> = available_inputs
434+
.iter()
435+
.map(|i| (Amount::from_sat(i.txout.value), i.outpoint))
436+
.collect();
437+
438+
let selected_outpoint = payjoin
439+
.try_preserving_privacy(candidate_inputs)
440+
.map_err(|_| anyhow!("no privacy-preserving selection available"))?;
441+
let selected_utxo = available_inputs
442+
.iter()
443+
.find(|i| i.outpoint == selected_outpoint)
444+
.ok_or(anyhow!("This shouldn't happen. Failed to retrieve the privacy preserving utxo from those we provided to the seclector."))?;
445+
log::debug!("selected utxo: {:#?}", selected_utxo);
446+
447+
payjoin.contribute_witness_input(selected_utxo.txout.clone(), selected_outpoint);
448+
Ok(())
449+
}
450+
362451
pub fn list_transactions(
363452
&self,
364453
include_raw: bool,

mutiny-core/src/payjoin.rs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
use once_cell::sync::Lazy;
2+
use payjoin::OhttpKeys;
3+
use url::Url;
4+
5+
pub(crate) static OHTTP_RELAYS: [Lazy<Url>; 3] = [
6+
Lazy::new(|| Url::parse("https://ohttp.payjoin.org").expect("Invalid URL")),
7+
Lazy::new(|| Url::parse("https://ohttp-relay.obscuravpn.io").expect("Invalid URL")),
8+
Lazy::new(|| Url::parse("https://pj.bobspacebkk.com").expect("Invalid URL")),
9+
];
10+
11+
pub(crate) static PAYJOIN_DIR: Lazy<Url> =
12+
Lazy::new(|| Url::parse("https://payjo.in").expect("Invalid URL"));
13+
14+
pub async fn fetch_ohttp_keys(
15+
_ohttp_relay: Url,
16+
directory: Url,
17+
) -> Result<OhttpKeys, Box<dyn std::error::Error>> {
18+
let http_client = reqwest::Client::builder().build()?;
19+
20+
let ohttp_keys_res = http_client
21+
.get(format!("{}/ohttp-keys", directory.as_ref()))
22+
.send()
23+
.await?
24+
.bytes()
25+
.await?;
26+
Ok(OhttpKeys::decode(ohttp_keys_res.as_ref())?)
27+
}
28+
29+
#[derive(Debug)]
30+
pub enum Error {
31+
Reqwest(reqwest::Error),
32+
ReceiverStateMachine(String),
33+
Txid(bitcoin::hashes::hex::Error),
34+
}
35+
36+
impl std::error::Error for Error {}
37+
38+
impl std::fmt::Display for Error {
39+
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
40+
match &self {
41+
Error::Reqwest(e) => write!(f, "Reqwest error: {}", e),
42+
Error::ReceiverStateMachine(e) => write!(f, "Payjoin state machine error: {}", e),
43+
Error::Txid(e) => write!(f, "Payjoin txid error: {}", e),
44+
}
45+
}
46+
}
47+
48+
impl From<reqwest::Error> for Error {
49+
fn from(e: reqwest::Error) -> Self {
50+
Error::Reqwest(e)
51+
}
52+
}
53+
54+
impl From<payjoin::receive::Error> for Error {
55+
fn from(e: payjoin::receive::Error) -> Self {
56+
Error::ReceiverStateMachine(e.to_string())
57+
}
58+
}

mutiny-wasm/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ futures = "0.3.25"
4343
urlencoding = "2.1.2"
4444
once_cell = "1.18.0"
4545
hex-conservative = "0.1.1"
46-
payjoin = { version = "0.13.0", features = ["send", "base64"] }
46+
payjoin = { version = "0.15.0", features = ["send", "base64"] }
4747
fedimint-core = { git = "https://github.com/fedimint/fedimint", rev = "5ade2536015a12a7e003a42b159ccc4a431e1a32" }
4848
moksha-core = { git = "https://github.com/ngutech21/moksha", rev = "18d99977965662d46ccec29fecdb0ce493745917" }
4949

mutiny-wasm/src/error.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,9 @@ pub enum MutinyJsError {
150150
/// Cannot change password to the same password
151151
#[error("Cannot change password to the same password.")]
152152
SamePassword,
153+
/// Payjoin failed
154+
#[error("Payjoin failed: {0}")]
155+
Payjoin(String),
153156
/// Payjoin request creation failed.
154157
#[error("Failed to create payjoin request.")]
155158
PayjoinCreateRequest,
@@ -239,6 +242,7 @@ impl From<MutinyError> for MutinyJsError {
239242
MutinyError::InvalidArgumentsError => MutinyJsError::InvalidArgumentsError,
240243
MutinyError::LspAmountTooHighError => MutinyJsError::LspAmountTooHighError,
241244
MutinyError::NetworkMismatch => MutinyJsError::NetworkMismatch,
245+
MutinyError::Payjoin(e) => MutinyJsError::Payjoin(e.to_string()),
242246
MutinyError::PayjoinConfigError => MutinyJsError::PayjoinConfigError,
243247
MutinyError::PayjoinCreateRequest => MutinyJsError::PayjoinCreateRequest,
244248
MutinyError::PayjoinResponse(e) => MutinyJsError::PayjoinResponse(e.to_string()),

0 commit comments

Comments
 (0)