Skip to content

Commit

Permalink
Receive payjoin
Browse files Browse the repository at this point in the history
  • Loading branch information
DanGould committed Nov 14, 2023
1 parent 077c0a8 commit 59641f1
Show file tree
Hide file tree
Showing 5 changed files with 243 additions and 2 deletions.
1 change: 1 addition & 0 deletions mutiny-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ aes = { version = "0.8" }
jwt-compact = { version = "0.8.0-beta.1", features = ["es256k"] }
argon2 = { version = "0.5.0", features = ["password-hash", "alloc"] }
hashbrown = { version = "0.8" }
payjoin = { git = "https://github.com/DanGould/rust-payjoin.git", branch = "serverless-payjoin", features = ["v2", "send", "receive", "base64"] }

base64 = "0.13.0"
pbkdf2 = "0.11"
Expand Down
118 changes: 116 additions & 2 deletions mutiny-core/src/nodemanager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,11 @@ use crate::{gossip::*, scorer::HubPreferentialScorer};
use crate::{labels::LabelStorage, subscription::MutinySubscriptionClient};
use anyhow::anyhow;
use bdk::chain::{BlockId, ConfirmationTime};
use bdk::{wallet::AddressIndex, LocalUtxo};
use bdk::{wallet::AddressIndex, FeeRate, LocalUtxo};
use bitcoin::blockdata::script;
use bitcoin::hashes::hex::ToHex;
use bitcoin::hashes::{sha256, Hash};
use bitcoin::psbt::PartiallySignedTransaction;
use bitcoin::secp256k1::{rand, PublicKey, Secp256k1, SecretKey};
use bitcoin::util::bip32::{DerivationPath, ExtendedPrivKey};
use bitcoin::{Address, Network, OutPoint, Transaction, Txid};
Expand Down Expand Up @@ -62,6 +63,7 @@ use nostr::{EventBuilder, Keys, Kind, Tag, TagKind};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::io::Cursor;
use std::str::FromStr;
use std::sync::atomic::{AtomicBool, Ordering};
use std::{collections::HashMap, ops::Deref, sync::Arc};
Expand Down Expand Up @@ -162,6 +164,7 @@ pub struct MutinyBip21RawMaterials {
pub invoice: Option<Bolt11Invoice>,
pub btc_amount: Option<String>,
pub labels: Vec<String>,
pub pj: Option<String>,
}

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

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

// If we are in safe mode, we don't create payjoin sessions
let pj = {
// TODO get from &self config
const PJ_RELAY_URL: &str = "http://localhost:8080";
const OH_RELAY_URL: &str = "http://localhost:8080";
const OHTTP_CONFIG_BASE64: &str = "AQAg7YjKSn1zBziW3LvPCQ8X18hH0dU67G-vOcMHu0-m81AABAABAAM";
let mut enroller = payjoin::receive::Enroller::from_relay_config(
PJ_RELAY_URL,
OHTTP_CONFIG_BASE64,
OH_RELAY_URL,
//Some("c53989e590b0f02edeec42a9c43fd1e4e960aec243bb1e6064324bd2c08ec498")
);
let http_client = reqwest::Client::builder()
//.danger_accept_invalid_certs(true) ? is tls unchecked :O
.build()
.unwrap();
// enroll client
let (req, context) = enroller.extract_req().unwrap();
let ohttp_response = http_client
.post(req.url)
.body(req.body)
.send()
.await
.unwrap();
let ohttp_response = ohttp_response.bytes().await.unwrap();
let enrolled = enroller
.process_res(ohttp_response.as_ref(), context)
.map_err(|e| anyhow!("parse error {}", e))
.unwrap();
let pj_uri = enrolled.fallback_target();
log_debug!(self.logger, "{pj_uri}");
let wallet = self.wallet.clone();
// run await payjoin task in the background as it'll keep polling the relay
wasm_bindgen_futures::spawn_local(async move {
let wallet = wallet.clone();
let pj_txid = Self::receive_payjoin(wallet, enrolled).await.unwrap();
log::info!("Received payjoin txid: {}", pj_txid);
});
Some(pj_uri)
};

Ok(MutinyBip21RawMaterials {
address,
invoice,
btc_amount: amount.map(|amount| bitcoin::Amount::from_sat(amount).to_btc().to_string()),
labels,
pj,
})
}

/// Poll the payjoin relay to maintain a payjoin session and create a payjoin proposal.
pub async fn receive_payjoin(
wallet: Arc<OnChainWallet<S>>,
mut enrolled: payjoin::receive::Enrolled,
) -> Result<Txid, MutinyError> {
let http_client = reqwest::Client::builder()
//.danger_accept_invalid_certs(true) ? is tls unchecked :O
.build()
.unwrap();
let proposal: payjoin::receive::UncheckedProposal =
Self::poll_for_fallback_psbt(&http_client, &mut enrolled)
.await
.unwrap();
let payjoin_proposal = wallet.process_payjoin_proposal(proposal).unwrap();

let (req, ohttp_ctx) = payjoin_proposal
.extract_v2_req()
.unwrap(); // extraction failed
let res = http_client
.post(req.url)
.body(req.body)
.send()
.await
.unwrap();
let res = res.bytes().await.unwrap();
let res = payjoin_proposal
.deserialize_res(res.to_vec(), ohttp_ctx)
.unwrap();
// convert from bitcoin 29 to 30
let txid = payjoin_proposal.psbt().clone().extract_tx().txid();
let txid = Txid::from_str(&txid.to_string()).unwrap();
Ok(txid)
}

async fn poll_for_fallback_psbt(
client: &reqwest::Client,
enroller: &mut payjoin::receive::Enrolled,
) -> Result<payjoin::receive::UncheckedProposal, ()> {
loop {
let (req, context) = enroller.extract_req().unwrap();
let ohttp_response = client
.post(req.url)
.body(req.body)
.send()
.await
.unwrap();
let ohttp_response = ohttp_response.bytes().await.unwrap();
let proposal = enroller
.process_res(ohttp_response.as_ref(), context)
.map_err(|e| anyhow!("parse error {}", e))
.unwrap();
match proposal {
Some(proposal) => return Ok(proposal),
None => Self::delay(5000).await.unwrap(),
}
}
}

async fn delay(millis: u32) -> Result<(), wasm_bindgen::JsValue> {
let promise = js_sys::Promise::new(&mut |yes, _| {
let win = web_sys::window().expect("should have a Window");
win.set_timeout_with_callback_and_timeout_and_arguments_0(&yes, millis as i32)
.expect("should set a timeout");
});

wasm_bindgen_futures::JsFuture::from(promise).await?;
Ok(())
}

/// Sends an on-chain transaction to the given address.
/// The amount is in satoshis and the fee rate is in sat/vbyte.
///
Expand Down
118 changes: 118 additions & 0 deletions mutiny-core/src/onchain.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use anyhow::anyhow;
use esplora_client::FromHex;
use std::collections::{BTreeMap, HashSet};
use std::str::FromStr;
use std::sync::atomic::{AtomicBool, Ordering};
Expand Down Expand Up @@ -285,10 +286,127 @@ impl<S: MutinyStorage> OnChainWallet<S> {
Ok(())
}

fn is_mine(&self, script: &Script) -> Result<bool, MutinyError> {
Ok(self.wallet.try_read()?.is_mine(script))
}

pub fn list_utxos(&self) -> Result<Vec<LocalUtxo>, MutinyError> {
Ok(self.wallet.try_read()?.list_unspent().collect())
}

pub fn process_payjoin_proposal(
&self,
proposal: payjoin::receive::UncheckedProposal,
) -> Result<payjoin::receive::PayjoinProposal, payjoin::Error> {
use payjoin::Error;
let network =
payjoin::bitcoin::Network::from_str(self.network.to_string().as_str()).unwrap();

// Receive Check 1 bypass: We're not an automated payment processor.
let proposal = proposal.assume_interactive_receiver();
log::trace!("check1");

// Receive Check 2: receiver can't sign for proposal inputs
let proposal = proposal.check_inputs_not_owned(|input| {
// convert from payjoin::bitcoin 30 to 29
let input = bitcoin::Script::from_hex(&input.to_hex()).unwrap();
self.is_mine(&input).map_err(|e| Error::Server(e.into()))
})?;
log::trace!("check2");

// Receive Check 3: receiver can't sign for proposal inputs
let proposal = proposal.check_no_mixed_input_scripts()?;
log::trace!("check3");

// Receive Check 4: have we seen this input before?
let payjoin = proposal.check_no_inputs_seen_before(|_input| {
// 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**
// assume false since Mutiny is not an automatic payment processor
Ok(false)
})?;
log::trace!("check4");

let mut provisional_payjoin =
payjoin.identify_receiver_outputs(|output: &payjoin::bitcoin::Script| {
// convert from payjoin::bitcoin 30 to 29
let output = bitcoin::Script::from_hex(&output.to_hex()).unwrap();
self.is_mine(&output).map_err(|e| Error::Server(e.into()))
})?;
self.try_contributing_inputs(&mut provisional_payjoin)
.expect("input contribution failed");

// Outputs may be substituted for e.g. batching at this stage
// We're not doing this yet.

let payjoin_proposal = provisional_payjoin.finalize_proposal(
|psbt: &payjoin::bitcoin::psbt::Psbt| {
// convert from payjoin::bitcoin 30.0
let mut psbt = PartiallySignedTransaction::from_str(&psbt.to_string()).unwrap();
dbg!(&psbt);
let wallet = self
.wallet
.try_read()
.map_err(|_| Error::Server(MutinyError::WalletSigningFailed.into()))?;
wallet
.sign(&mut psbt, SignOptions::default())
.map_err(|_| Error::Server(MutinyError::WalletSigningFailed.into()))?;
// convert back to payjoin::bitcoin
dbg!(&psbt);
let psbt = payjoin::bitcoin::psbt::Psbt::from_str(&psbt.to_string()).unwrap();
dbg!(&psbt);
Ok(psbt)
},
// TODO: check Mutiny's minfeerate is present here
Some(payjoin::bitcoin::FeeRate::MIN),
)?;
let payjoin_proposal_psbt = payjoin_proposal.psbt();
log::debug!(
"Receiver's Payjoin proposal PSBT Rsponse: {:#?}",
payjoin_proposal_psbt
);
Ok(payjoin_proposal)
}

fn try_contributing_inputs(
&self,
payjoin: &mut payjoin::receive::ProvisionalProposal,
) -> Result<(), MutinyError> {
use payjoin::bitcoin::{Amount, OutPoint};

let available_inputs = self
.list_utxos()
.expect("Failed to list unspent from bitcoind");
let candidate_inputs: std::collections::HashMap<Amount, OutPoint> = available_inputs
.iter()
.map(|i| {
(
Amount::from_sat(i.txout.value),
OutPoint::from_str(&i.outpoint.to_string()).unwrap(),
)
})
.collect();

let selected_outpoint = payjoin
.try_preserving_privacy(candidate_inputs)
.expect("no privacy-preserving selection available");
let selected_utxo = available_inputs
.iter()
.find(|i| OutPoint::from_str(&i.outpoint.to_string()).unwrap() == selected_outpoint)
.expect("This shouldn't happen. Failed to retrieve the privacy preserving utxo from those we provided to the seclector.");
log::debug!("selected utxo: {:#?}", selected_utxo);

// calculate receiver payjoin outputs given receiver payjoin inputs and original_psbt,
let txo_to_contribute = payjoin::bitcoin::TxOut {
value: selected_utxo.txout.value,
script_pubkey: payjoin::bitcoin::Script::from_bytes(
&selected_utxo.txout.script_pubkey.clone().into_bytes(),
)
.into(),
};
payjoin.contribute_witness_input(txo_to_contribute, selected_outpoint);
Ok(())
}

pub fn list_transactions(
&self,
include_raw: bool,
Expand Down
1 change: 1 addition & 0 deletions mutiny-wasm/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,7 @@ impl MutinyWallet {
invoice: None,
btc_amount: None,
labels,
pj: None,
})
}

Expand Down
7 changes: 7 additions & 0 deletions mutiny-wasm/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,7 @@ pub struct MutinyBip21RawMaterials {
pub(crate) invoice: Option<String>,
pub(crate) btc_amount: Option<String>,
pub(crate) labels: Vec<String>,
pub(crate) pj: Option<String>,
}

#[wasm_bindgen]
Expand Down Expand Up @@ -489,6 +490,11 @@ impl MutinyBip21RawMaterials {
pub fn labels(&self) -> JsValue /* Vec<String> */ {
JsValue::from_serde(&self.labels).unwrap()
}

#[wasm_bindgen(getter)]
pub fn pj(&self) -> Option<String> {
self.pj.clone()
}
}

impl From<nodemanager::MutinyBip21RawMaterials> for MutinyBip21RawMaterials {
Expand All @@ -498,6 +504,7 @@ impl From<nodemanager::MutinyBip21RawMaterials> for MutinyBip21RawMaterials {
invoice: m.invoice.map(|i| i.to_string()),
btc_amount: m.btc_amount,
labels: m.labels,
pj: m.pj,
}
}
}
Expand Down

0 comments on commit 59641f1

Please sign in to comment.