diff --git a/Cargo.toml b/Cargo.toml index 955063e..75118d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,4 +6,5 @@ members = [ "honey-badger", "mole", "squirrel", + "parrot", ] diff --git a/README.md b/README.md index b0d9e76..8f964ff 100644 --- a/README.md +++ b/README.md @@ -26,3 +26,7 @@ The library allows to register for and list withdraw collect offers (e.g. Lightn ## Squirrel Squirrels love to store nuts for the winter. The library allows backups of local data to be created. + +## Parrot +This feathered companion, similar to its namesakes, repeats what he hears and as such delivers, +important analytics data about payments to the backend. This data is pseudonymized and used to improve our services. diff --git a/graphql/schemas/operations.graphql b/graphql/schemas/operations.graphql index 3763e8e..dceac66 100644 --- a/graphql/schemas/operations.graphql +++ b/graphql/schemas/operations.graphql @@ -186,3 +186,9 @@ mutation RecoverBackup($schemaName: String!) { updatedAt } } + +mutation ReportPaymentTelemetry($telemetryId: String!, $events: PaymentTelemetryEventsInput) { + report_payment_telemetry(telemetryId: $telemetryId, events: $events) { + payFailed + } +} diff --git a/graphql/src/lib.rs b/graphql/src/lib.rs index c263379..6559b40 100644 --- a/graphql/src/lib.rs +++ b/graphql/src/lib.rs @@ -6,6 +6,7 @@ pub use crate::errors::*; pub use perro; pub use reqwest; +use chrono::{DateTime, Utc}; use graphql_client::reqwest::{post_graphql, post_graphql_blocking}; use graphql_client::Response; use perro::{permanent_failure, runtime_error, MapToError, OptionToError}; @@ -202,3 +203,14 @@ mod tests { assert_eq!(timestamp, 1695314361 + 2 * 3600); } } + +pub trait ToRfc3339 { + fn to_rfc3339(&self) -> String; +} + +impl ToRfc3339 for SystemTime { + fn to_rfc3339(&self) -> String { + let datetime: DateTime = DateTime::from(*self); + datetime.to_rfc3339() + } +} diff --git a/graphql/src/schema.rs b/graphql/src/schema.rs index 6cfa435..dfa7ff8 100644 --- a/graphql/src/schema.rs +++ b/graphql/src/schema.rs @@ -207,3 +207,11 @@ pub struct CreateBackup; response_derives = "Debug" )] pub struct RecoverBackup; + +#[derive(GraphQLQuery)] +#[graphql( + schema_path = "schemas/schema_wallet_read.graphql", + query_path = "schemas/operations.graphql", + response_derives = "Debug" +)] +pub struct ReportPaymentTelemetry; diff --git a/parrot/Cargo.toml b/parrot/Cargo.toml new file mode 100644 index 0000000..c2d9a71 --- /dev/null +++ b/parrot/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "parrot" +version = "0.1.0" +edition = "2021" + +[dependencies] +graphql = { path = "../graphql" } +honey-badger = { path = "../honey-badger" } diff --git a/parrot/src/lib.rs b/parrot/src/lib.rs new file mode 100644 index 0000000..7661411 --- /dev/null +++ b/parrot/src/lib.rs @@ -0,0 +1,220 @@ +use graphql::schema::report_payment_telemetry::{ + PayFailedInput, PayInitiatedInput, PaySource, PaySucceededInput, PaymentTelemetryEventsInput, + RequestInitiatedInput, RequestSucceededInput, +}; +use graphql::schema::{report_payment_telemetry, ReportPaymentTelemetry}; +use graphql::{build_client, post_blocking, ToRfc3339}; +use honey_badger::Auth; +use std::sync::Arc; +use std::time::SystemTime; + +pub enum PaymentSource { + Camera, + Clipboard, + Nfc, + Manual, +} +pub enum PayFailureReason { + NoRoute, + Unknown, +} + +pub enum AnalyticsEvent { + PayInitiated { + payment_hash: String, + + paid_amount_msat: u64, + requested_amount_msat: Option, + sats_per_user_currency: Option, + + source: PaymentSource, + user_currency: String, + + process_started_at: SystemTime, + executed_at: SystemTime, + }, + PaySucceeded { + payment_hash: String, + + ln_fees_paid_msat: u64, + confirmed_at: SystemTime, + }, + PayFailed { + payment_hash: String, + + reason: PayFailureReason, + failed_at: SystemTime, + }, + RequestInitiated { + payment_hash: String, + + entered_amount_msat: Option, + sats_per_user_currency: Option, + + user_currency: String, + request_currency: String, + + created_at: SystemTime, + }, + RequestSucceeded { + payment_hash: String, + + paid_amount_sat: u64, + channel_opening_fee_msat: u64, + + received_at: SystemTime, + }, +} + +pub struct AnalyticsClient { + backend_url: String, + analytics_id: String, + auth: Arc, +} + +impl AnalyticsClient { + pub fn new(backend_url: String, analytics_id: String, auth: Arc) -> Self { + Self { + backend_url, + analytics_id, + auth, + } + } + + pub fn report_event(&self, analytics_event: AnalyticsEvent) -> graphql::Result<()> { + let variables = match analytics_event { + AnalyticsEvent::PayInitiated { + payment_hash, + paid_amount_msat, + requested_amount_msat, + sats_per_user_currency, + source, + user_currency, + process_started_at, + executed_at, + } => report_payment_telemetry::Variables { + telemetry_id: self.analytics_id.clone(), + events: Some(PaymentTelemetryEventsInput { + pay_failed: None, + pay_initiated: Some(PayInitiatedInput { + process_started_at: process_started_at.to_rfc3339(), + executed_at: executed_at.to_rfc3339(), + paid_amount_m_sat: paid_amount_msat, + payment_hash, + requested_amount_m_sat: requested_amount_msat.unwrap_or(0), + sats_per_user_currency: sats_per_user_currency.unwrap_or(0) as i64, + source: map_payment_source(source), + user_currency, + }), + pay_succeeded: None, + request_initiated: None, + request_succeeded: None, + }), + }, + AnalyticsEvent::PaySucceeded { + payment_hash, + ln_fees_paid_msat, + confirmed_at, + } => report_payment_telemetry::Variables { + telemetry_id: self.analytics_id.clone(), + events: Some(PaymentTelemetryEventsInput { + pay_failed: None, + pay_initiated: None, + pay_succeeded: Some(PaySucceededInput { + confirmed_at: confirmed_at.to_rfc3339(), + ln_fees_paid_m_sat: ln_fees_paid_msat, + payment_hash, + }), + request_initiated: None, + request_succeeded: None, + }), + }, + AnalyticsEvent::PayFailed { + payment_hash, + reason, + failed_at, + } => report_payment_telemetry::Variables { + telemetry_id: self.analytics_id.clone(), + events: Some(PaymentTelemetryEventsInput { + pay_failed: Some(PayFailedInput { + failed_at: failed_at.to_rfc3339(), + payment_hash, + reason: map_pay_failure_reason(reason), + }), + pay_initiated: None, + pay_succeeded: None, + request_initiated: None, + request_succeeded: None, + }), + }, + AnalyticsEvent::RequestInitiated { + payment_hash, + entered_amount_msat, + sats_per_user_currency, + user_currency, + request_currency, + created_at, + } => report_payment_telemetry::Variables { + telemetry_id: self.analytics_id.clone(), + events: Some(PaymentTelemetryEventsInput { + pay_failed: None, + pay_initiated: None, + pay_succeeded: None, + request_initiated: Some(RequestInitiatedInput { + created_at: created_at.to_rfc3339(), + entered_amount_m_sat: entered_amount_msat.unwrap_or(0), + payment_hash, + request_currency, + sats_per_user_currency: sats_per_user_currency.unwrap_or(0) as i64, + user_currency, + }), + request_succeeded: None, + }), + }, + AnalyticsEvent::RequestSucceeded { + payment_hash, + paid_amount_sat, + channel_opening_fee_msat, + received_at, + } => report_payment_telemetry::Variables { + telemetry_id: self.analytics_id.clone(), + events: Some(PaymentTelemetryEventsInput { + pay_failed: None, + pay_initiated: None, + pay_succeeded: None, + request_initiated: None, + request_succeeded: Some(RequestSucceededInput { + channel_opening_fee_m_sat: channel_opening_fee_msat, + paid_amount_m_sat: paid_amount_sat, + payment_hash, + payment_received_at: received_at.to_rfc3339(), + }), + }), + }, + }; + + let token = self.auth.query_token()?; + let client = build_client(Some(&token))?; + post_blocking::(&client, &self.backend_url, variables)?; + + Ok(()) + } +} + +fn map_payment_source(payment_source: PaymentSource) -> PaySource { + match payment_source { + PaymentSource::Camera => PaySource::CAMERA, + PaymentSource::Clipboard => PaySource::CLIPBOARD, + PaymentSource::Nfc => PaySource::NFC, + PaymentSource::Manual => PaySource::MANUAL, + } +} + +fn map_pay_failure_reason( + pay_failure_reason: PayFailureReason, +) -> report_payment_telemetry::PayFailureReason { + match pay_failure_reason { + PayFailureReason::NoRoute => report_payment_telemetry::PayFailureReason::NO_ROUTE, + PayFailureReason::Unknown => report_payment_telemetry::PayFailureReason::UNKNOWN, + } +} diff --git a/squirrel/Cargo.toml b/squirrel/Cargo.toml index 6e1f5c2..2942d8d 100644 --- a/squirrel/Cargo.toml +++ b/squirrel/Cargo.toml @@ -8,6 +8,7 @@ hex = "0.4.3" graphql = { path = "../graphql" } honey-badger = { path = "../honey-badger" } +bdk = { version = "0.29.0", features = ["keys-bip39"] } [dev-dependencies] bitcoin = { version = "0.29.2" } diff --git a/squirrel/tests/integration_tests.rs b/squirrel/tests/integration_tests.rs index c2a53e8..d8d1ac6 100644 --- a/squirrel/tests/integration_tests.rs +++ b/squirrel/tests/integration_tests.rs @@ -1,4 +1,4 @@ -use bitcoin::Network; +use bdk::bitcoin::Network; use graphql::perro::Error::RuntimeError; use graphql::GraphQlRuntimeErrorCode; use honey_badger::asynchronous::Auth;