Skip to content

Commit

Permalink
Claim hermes tokens
Browse files Browse the repository at this point in the history
  • Loading branch information
benthecarman committed Apr 3, 2024
1 parent c1459d6 commit fe92320
Show file tree
Hide file tree
Showing 3 changed files with 257 additions and 22 deletions.
52 changes: 51 additions & 1 deletion mutiny-core/src/federation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use crate::{
use async_lock::RwLock;
use async_trait::async_trait;
use bip39::Mnemonic;
use bitcoin::secp256k1::ThirtyTwoByteHash;
use bitcoin::secp256k1::{SecretKey, ThirtyTwoByteHash};
use bitcoin::{
bip32::{ChildNumber, DerivationPath, ExtendedPrivKey},
hashes::sha256,
Expand Down Expand Up @@ -622,6 +622,56 @@ impl<S: MutinyStorage> FederationClient<S> {
}
}

/// Someone received a payment on our behalf, we need to claim it
pub async fn claim_external_receive(
&self,
secret_key: &SecretKey,
tweaks: Vec<u64>,
) -> Result<(), MutinyError> {
let lightning_module = self
.fedimint_client
.get_first_module::<LightningClientModule>();

let key_pair = fedimint_ln_common::bitcoin::secp256k1::KeyPair::from_seckey_slice(
fedimint_ln_common::bitcoin::secp256k1::SECP256K1,
&secret_key.secret_bytes(),
)
.map_err(|_| MutinyError::InvalidArgumentsError)?;
let operation_ids = lightning_module
.scan_receive_for_user_tweaked(key_pair, tweaks, ())
.await;

if operation_ids.is_empty() {
log_warn!(
self.logger,
"External receive not found, maybe already claimed?"
);
return Ok(());
}

for operation_id in operation_ids {
let mut updates = lightning_module
.subscribe_ln_claim(operation_id)
.await?
.into_stream();

while let Some(update) = updates.next().await {
match update {
LnReceiveState::Claimed => {
log_info!(self.logger, "External receive claimed!");
}
LnReceiveState::Canceled { reason } => {
log_error!(self.logger, "External receive canceled: {reason}");
return Err(MutinyError::InvalidArgumentsError); // todo better error
}
_ => {}
}
}
}

Ok(())
}

pub async fn get_mutiny_federation_identity(&self) -> FederationIdentity {
let gateway_fees = self.gateway_fee().await.ok();

Expand Down
209 changes: 194 additions & 15 deletions mutiny-core/src/hermes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,27 +7,34 @@ use std::{
};

use async_lock::RwLock;
use bitcoin::hashes::hex::FromHex;
use bitcoin::secp256k1::ThirtyTwoByteHash;
use bitcoin::{bip32::ExtendedPrivKey, secp256k1::Secp256k1};
use fedimint_core::config::FederationId;
use futures::{pin_mut, select, FutureExt};
use lightning::util::logger::Logger;
use lightning::{log_error, log_warn};
use nostr::{nips::nip04::decrypt, Keys};
use lightning::{log_error, log_info, log_warn};
use lightning_invoice::Bolt11Invoice;
use nostr::prelude::decrypt_received_private_zap_message;
use nostr::{nips::nip04::decrypt, Event, Keys, Tag};
use nostr::{Filter, Kind, Timestamp};
use nostr_sdk::{Client, RelayPoolNotification};
use reqwest::Method;
use serde::{Deserialize, Serialize};
use tbs::unblind_signature;
use url::Url;

use crate::event::{HTLCStatus, MillisatAmount, PaymentInfo};
use crate::labels::LabelStorage;
use crate::storage::persist_payment_info;
use crate::{
blindauth::{BlindAuthClient, SignedToken},
error::MutinyError,
federation::{FederationClient, FederationIdentity},
logging::MutinyLogger,
nostr::{derive_nostr_key, HERMES_CHAIN_INDEX, SERVICE_ACCOUNT_INDEX},
storage::MutinyStorage,
utils,
utils, PrivacyLevel,
};

const HERMES_SERVICE_ID: u32 = 1;
Expand All @@ -50,7 +57,7 @@ pub struct RegisterResponse {
}

pub struct HermesClient<S: MutinyStorage> {
pub(crate) primary_key: Keys,
pub(crate) dm_key: Keys,
pub public_key: nostr::PublicKey,
pub client: Client,
http_client: reqwest::Client,
Expand Down Expand Up @@ -96,7 +103,7 @@ impl<S: MutinyStorage> HermesClient<S> {
// TODO need to store the fact that we have a LNURL or not...

Ok(Self {
primary_key: keys,
dm_key: keys,
public_key,
client,
http_client: reqwest::Client::new(),
Expand All @@ -112,13 +119,14 @@ impl<S: MutinyStorage> HermesClient<S> {
/// Starts the hermes background checker
/// This should only error if there's an initial unrecoverable error
/// Otherwise it will loop in the background until a stop signal
pub fn start(&self) -> Result<(), MutinyError> {
pub fn start(&self, profile_key: Option<Keys>) -> Result<(), MutinyError> {
let logger = self.logger.clone();
let stop = self.stop.clone();
let client = self.client.clone();
let public_key = self.public_key;
let storage = self.storage.clone();
let primary_key = self.primary_key.clone();
let dm_key = self.dm_key.clone();
let federations = self.federations.clone();

// if we haven't synced before, use now and save to storage
let last_sync_time = storage.get_dm_sync_time(true)?;
Expand All @@ -137,6 +145,8 @@ impl<S: MutinyStorage> HermesClient<S> {
break;
};

log_info!(logger, "Starting Hermes DM listener for key {public_key}");

let received_dm_filter = Filter::new()
.kind(Kind::EncryptedDirectMessage)
.pubkey(public_key)
Expand All @@ -160,9 +170,11 @@ impl<S: MutinyStorage> HermesClient<S> {
if event.verify().is_ok() {
match event.kind {
Kind::EncryptedDirectMessage => {
match decrypt_dm(&primary_key, public_key, &event.content) {
Ok(_) => {
// TODO we need to parse and redeem ecash
match decrypt_ecash_notification(&dm_key, event.pubkey, &event.content) {
Ok(notification) => {
if let Err(e) = handle_ecash_notification(notification, event.created_at, &federations, &storage, &dm_key, profile_key.as_ref(), &logger).await {
log_error!(logger, "Error handling ecash notification: {e}");
}
},
Err(e) => {
log_error!(logger, "Error decrypting DM: {e}");
Expand Down Expand Up @@ -304,12 +316,179 @@ async fn register_name(
}

/// Decrypts a DM using the primary key
pub fn decrypt_dm(
primary_key: &Keys,
fn decrypt_ecash_notification(
dm_key: &Keys,
pubkey: nostr::PublicKey,
message: &str,
) -> Result<String, MutinyError> {
let secret = primary_key.secret_key().expect("must have");
) -> Result<EcashNotification, MutinyError> {
// decrypt the dm first
let secret = dm_key.secret_key().expect("must have");
let decrypted = decrypt(secret, &pubkey, message)?;
Ok(decrypted)
// parse the dm into an ecash notification
let notification = serde_json::from_str(&decrypted)?;
Ok(notification)
}

/// What the hermes client expects to receive from a DM
#[derive(Debug, Clone, Deserialize, Serialize)]
struct EcashNotification {
/// Amount of ecash received in msats
pub amount: u64,
/// Tweak we should use to claim the ecash
pub tweak_index: u64,
/// Federation id that the ecash is for
pub federation_id: FederationId,
/// The zap request that came along with this payment,
/// useful for tagging the payment to a contact
pub zap_request: Option<Event>,
/// The bolt11 invoice for the payment
pub bolt11: Bolt11Invoice,
/// The preimage for the bolt11 invoice
pub preimage: String,
}

/// Attempts to claim the ecash, if successful, saves the payment info
async fn handle_ecash_notification<S: MutinyStorage>(
notification: EcashNotification,
created_at: Timestamp,
federations: &RwLock<HashMap<FederationId, Arc<FederationClient<S>>>>,
storage: &S,
dm_key: &Keys,
profile_key: Option<&Keys>,
logger: &MutinyLogger,
) -> anyhow::Result<()> {
log_info!(
logger,
"Received ecash notification for {} msats!",
notification.amount
);

if let Some(federation) = federations.read().await.get(&notification.federation_id) {
match federation
.claim_external_receive(
dm_key.secret_key().expect("must have"),
vec![notification.tweak_index],
)
.await
{
Ok(_) => {
log_info!(
logger,
"Claimed external receive for {} msats!",
notification.amount
);

let (privacy_level, msg, npub) = match notification.zap_request {
None => (PrivacyLevel::NotAvailable, None, None),
Some(zap_req) => {
// handle private/anon zaps
let anon = zap_req.iter_tags().find_map(|tag| {
if let Tag::Anon { msg } = tag {
if msg.is_some() {
// an Anon tag with a message is a private zap
// try to decrypt the message and use that as the message
handle_private_zap(&zap_req, profile_key, logger)
} else {
// an Anon tag with no message is an anonymous zap
// the content of the zap is the message
Some((
PrivacyLevel::Anonymous,
Some(zap_req.content.clone()),
None,
))
}
} else {
None
}
});

// handled the anon tag, if there wasn't one, it is a public zap
anon.unwrap_or((
PrivacyLevel::Public,
Some(zap_req.content.clone()),
Some(zap_req.pubkey),
))
}
};

// create activity item
let payment_hash = notification.bolt11.payment_hash().into_32();
let preimage = FromHex::from_hex(&notification.preimage).ok();
let info = PaymentInfo {
preimage,
secret: Some(notification.bolt11.payment_secret().0),
status: HTLCStatus::Succeeded,
amt_msat: MillisatAmount(Some(notification.amount)),
fee_paid_msat: None,
payee_pubkey: Some(notification.bolt11.recover_payee_pub_key()),
bolt11: Some(notification.bolt11.clone()),
privacy_level,
// use the notification event's created_at as last update so we can properly sort by time
last_update: created_at.as_u64(),
};
persist_payment_info(storage, &payment_hash, &info, true)?;

// tag the invoice if we can
let mut tags = Vec::with_capacity(2);

// try to tag by npub
if let Some((id, _)) = npub
.map(|n| storage.get_contact_for_npub(n))
.transpose()?
.flatten()
{
tags.push(id);
}

// add message tag if we have one
if let Some(msg) = msg.filter(|m| !m.is_empty()) {
tags.push(msg);
}

// save the tags if we have any
if !tags.is_empty() {
storage.set_invoice_labels(notification.bolt11, tags)?;
}
}
Err(e) => log_error!(logger, "Error claiming external receive: {e}"),
}
} else {
log_warn!(
logger,
"Received DM for unknown federation {}, discarding...",
notification.federation_id
);
}

// save the last sync time
storage.set_dm_sync_time(created_at.as_u64(), true)?;

Ok(())
}

fn handle_private_zap(
zap_req: &Event,
profile_key: Option<&Keys>,
logger: &MutinyLogger,
) -> Option<(PrivacyLevel, Option<String>, Option<nostr::PublicKey>)> {
let key = match profile_key {
Some(k) => k.secret_key().unwrap(),
None => {
log_error!(logger, "No primary key to decrypt private zap");
return None;
}
};
// try to decrypt the message
match decrypt_received_private_zap_message(key, zap_req) {
Ok(event) => Some((
PrivacyLevel::Private,
Some(event.content.clone()),
Some(event.pubkey),
)),
Err(e) => {
// if we can't decrypt, treat it like it's an anonymous zap
log_error!(logger, "Error decrypting private zap: {e}");
Some((PrivacyLevel::Anonymous, Some(zap_req.content.clone()), None))
}
}
}
18 changes: 12 additions & 6 deletions mutiny-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,9 @@ use ::nostr::nips::nip57;
#[cfg(target_arch = "wasm32")]
use ::nostr::prelude::rand::rngs::OsRng;
use ::nostr::prelude::ZapRequestData;
use ::nostr::{EventBuilder, EventId, JsonUtil, Kind};
#[cfg(target_arch = "wasm32")]
use ::nostr::{Keys, Tag};
use ::nostr::Tag;
use ::nostr::{EventBuilder, EventId, JsonUtil, Keys, Kind};
use async_lock::RwLock;
use bdk_chain::ConfirmationTime;
use bip39::Mnemonic;
Expand Down Expand Up @@ -1070,7 +1070,13 @@ impl<S: MutinyStorage> MutinyWalletBuilder<S> {
mw.check_blind_tokens();

// start the hermes background process
mw.start_hermes()?;
// get profile key if we have it, we need this to decrypt private zaps
let profile_key = match &mw.nostr.primary_key {
NostrSigner::Keys(keys) => Some(keys.clone()),
#[cfg(target_arch = "wasm32")]
NostrSigner::NIP07(_) => None,
};
mw.start_hermes(profile_key)?;

log_info!(
mw.logger,
Expand Down Expand Up @@ -2769,9 +2775,9 @@ impl<S: MutinyStorage> MutinyWallet<S> {
}

/// Starts up the hermes client if available
pub fn start_hermes(&self) -> Result<(), MutinyError> {
if let Some(hermes_client) = self.hermes_client.clone() {
hermes_client.start()?
pub fn start_hermes(&self, profile_key: Option<Keys>) -> Result<(), MutinyError> {
if let Some(hermes_client) = self.hermes_client.as_ref() {
hermes_client.start(profile_key)?
}
Ok(())
}
Expand Down

0 comments on commit fe92320

Please sign in to comment.