use std::fmt;
use std::{
cmp::{self, Ordering},
collections::HashMap,
fmt::Display,
};
use bincode::{Decode, Encode};
use monero::cryptonote::subaddress;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
const PICONEROS_PER_XMR: u64 = 1_000_000_000_000;
/// Representation of an invoice. `Invoice`s are created by the [`PaymentGateway`](crate::PaymentGateway), and are
/// initially unpaid.
///
/// `Invoice`s have an expiration block, after which they are considered expired. However, note that
/// the payment gateway by default will continue updating invoices even after expiration.
///
/// To receive updates for a given `Invoice`, use a [`Subscriber`](crate::subscriber::Subscriber).
#[derive(Debug, Clone, Encode, Decode)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct Invoice {
address: String,
index: SubIndex,
creation_height: u64,
amount_requested: u64,
pub(crate) amount_paid: u64,
pub(crate) paid_height: Option<u64>,
confirmations_required: u64,
pub(crate) current_height: u64,
expiration_height: u64,
pub(crate) transfers: Vec<Transfer>,
description: String,
}
impl Invoice {
pub(crate) fn new(
address: String,
index: SubIndex,
creation_height: u64,
amount_requested: u64,
confirmations_required: u64,
expiration_in: u64,
description: String,
) -> Invoice {
let expiration_height = creation_height + expiration_in;
Invoice {
address,
index,
creation_height,
amount_requested,
amount_paid: 0,
/// The height at which the `Invoice` was fully paid. Will be `None` if not yet fully
/// paid, or if the required XMR is still in the txpool (which has no height).
paid_height: None,
confirmations_required,
current_height: 0,
expiration_height,
transfers: Vec::new(),
description,
}
}
/// Returns a URI containing the address and amount due as a `String`. For example:
///
/// ```no run
/// "monero:4A1WSBQdCbUCqt3DaGfmqVFchXScF43M6c5r4B6JXT3dUwuALncU9XTEnRPmUMcB3c16kVP9Y7thFLCJ5BaMW3UmSy93w3w?tx_amount=0.001"
/// ```
///
/// Monero URIs can be thought of as fancy addresses that pre-fill the amount field for the user
/// (and sometimes the description field as well). They are supported by all major wallets.
#[must_use]
#[allow(clippy::cast_precision_loss)]
pub fn uri(&self) -> String {
let piconeros_due = self.amount_requested.saturating_sub(self.amount_paid);
let whole_xmr_due = piconeros_due / PICONEROS_PER_XMR;
let fractional_xmr_due =
(piconeros_due % PICONEROS_PER_XMR) as f64 / PICONEROS_PER_XMR as f64;
format!(
"monero:{}?tx_amount={}.{}",
&self.address,
whole_xmr_due,
fractional_xmr_due.to_string().trim_start_matches("0.")
)
}
/// Returns `true` if the `Invoice` has received the required number of confirmations.
#[must_use]
pub fn is_confirmed(&self) -> bool {
self.confirmations().map_or(false, |confirmations| {
confirmations >= self.confirmations_required
})
}
/// Returns `true` if the `Invoice`'s current block is greater than or equal to its expiration
/// block.
#[must_use]
pub fn is_expired(&self) -> bool {
// At or passed the expiration block, AND not paid in full.
(self.current_height >= self.expiration_height) && self.paid_height.is_none()
}
/// Returns the base 58 encoded subaddress of this `Invoice`.
#[must_use]
pub fn address(&self) -> &str {
&self.address
}
/// Returns the ID of this invoice.
#[must_use]
pub fn id(&self) -> InvoiceId {
InvoiceId {
sub_index: self.index,
creation_height: self.creation_height,
}
}
/// Returns the [subaddress index](SubIndex) of this `Invoice`.
#[must_use]
pub fn index(&self) -> SubIndex {
self.index
}
/// Returns the blockchain height at which the `Invoice` was created.
#[must_use]
pub fn creation_height(&self) -> u64 {
self.creation_height
}
/// Returns the amount of monero requested in piconeros.
#[must_use]
pub fn amount_requested(&self) -> u64 {
self.amount_requested
}
/// Returns the amount of monero paid in piconeros.
#[must_use]
pub fn amount_paid(&self) -> u64 {
self.amount_paid
}
/// Returns the amount of monero requested in XMR.
///
/// Note that rounding may occur because the precision of `f64` is insufficient for
/// representing large amounts of XMR out to many decimal places. If accuracy is desired,
/// [`amount_requested()`](#method.amount_requested) should be preferred.
///
/// # Examples
///
/// ```
/// # #[tokio::main]
/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
/// #
/// # use acceptxmr::PaymentGatewayBuilder;
/// # use tempfile::Builder;
/// #
/// # let temp_dir = Builder::new()
/// # .prefix("temp_db_")
/// # .rand_bytes(16)
/// # .tempdir()
/// # .expect("failed to generate temporary directory");
/// #
/// # let private_view_key = "ad2093a5705b9f33e6f0f0c1bc1f5f639c756cdfc168c8f2ac6127ccbdab3a03";
/// # let primary_address = "4613YiHLM6JMH4zejMB2zJY5TwQCxL8p65ufw8kBP5yxX9itmuGLqp1dS4tkVoTxjyH3aYhYNrtGHbQzJQP5bFus3KHVdmf";
/// #
/// # let payment_gateway = PaymentGatewayBuilder::new(private_view_key.to_string(), primary_address.to_string())
/// # .db_path(
/// # temp_dir
/// # .path()
/// # .to_str()
/// # .expect("failed to get temporary directory path")
/// # .to_string(),
/// # )
/// # .build()?;
/// // Create a new `Invoice` for 1 millinero.
/// let invoice_id = payment_gateway.new_invoice(1_000_000_000, 3, 5, "for pizza".to_string()).await?;
/// let small_invoice = payment_gateway.get_invoice(invoice_id)?.expect("invoice ID not found");
///
/// // One millinero, as expected.
/// assert_eq!(small_invoice.xmr_requested(), 0.001);
///
/// // Create a new `Invoice` for 18446744.073709551615 XMR.
/// let invoice_id = payment_gateway.new_invoice(18_446_744_073_709_551_615, 3, 5, "for lambo".to_string()).await?;
/// let large_invoice = payment_gateway.get_invoice(invoice_id)?.expect("invoice ID not found");
///
/// // The large value has been rounded slightly due to f64 precision limitations.
/// assert_eq!(large_invoice.xmr_requested(), 18446744.073709551245);
/// # Ok(())
/// # }
/// ```
#[must_use]
#[allow(clippy::cast_precision_loss)]
pub fn xmr_requested(&self) -> f64 {
let whole_xmr = self.amount_requested / PICONEROS_PER_XMR;
let fractional_xmr =
(self.amount_requested % PICONEROS_PER_XMR) as f64 / PICONEROS_PER_XMR as f64;
whole_xmr as f64 + fractional_xmr
}
/// Returns the amount of monero paid in XMR.
///
/// Note that rounding may occur because the precision of `f64` is insufficient for
/// representing large amounts of XMR out to many decimal places. If accuracy is desired,
/// [`amount_paid()`](#method.amount_paid) should be preferred.
///
/// For an example of possible rounding error, see [`xmr_requested()`](#method.xmr_requested)
#[must_use]
#[allow(clippy::cast_precision_loss)]
pub fn xmr_paid(&self) -> f64 {
let whole_xmr = self.amount_paid / PICONEROS_PER_XMR;
let fractional_xmr =
(self.amount_paid % PICONEROS_PER_XMR) as f64 / PICONEROS_PER_XMR as f64;
whole_xmr as f64 + fractional_xmr
}
/// Returns the number of confirmations this `Invoice` requires before it is considered fully confirmed.
#[must_use]
pub fn confirmations_required(&self) -> u64 {
self.confirmations_required
}
/// Returns the number of confirmations this `Invoice` has received since it was paid in full.
/// Returns `None` if the `Invoice` has not yet been paid in full.
#[must_use]
pub fn confirmations(&self) -> Option<u64> {
if self.amount_paid >= self.amount_requested {
self.paid_height.map_or(Some(0), |paid_at| {
Some(self.current_height.saturating_sub(paid_at))
})
} else {
None
}
}
/// Returns the last daemon height at which this `Invoice` was updated.
#[must_use]
pub fn current_height(&self) -> u64 {
self.current_height
}
/// Returns the daemon height at which this `Invoice` will expire.
#[must_use]
pub fn expiration_height(&self) -> u64 {
self.expiration_height
}
/// Returns the number of blocks before expiration.
///
/// # Examples
///
/// ```no_run
/// # #[tokio::main]
/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
/// #
/// # use acceptxmr::PaymentGatewayBuilder;
/// #
/// # let private_view_key = "ad2093a5705b9f33e6f0f0c1bc1f5f639c756cdfc168c8f2ac6127ccbdab3a03";
/// # let primary_address = "4613YiHLM6JMH4zejMB2zJY5TwQCxL8p65ufw8kBP5yxX9itmuGLqp1dS4tkVoTxjyH3aYhYNrtGHbQzJQP5bFus3KHVdmf";
/// #
/// # let payment_gateway = PaymentGatewayBuilder::new(private_view_key.to_string(), primary_address.to_string())
/// # .build()?;
/// #
/// # payment_gateway.run().await?;
/// #
/// // Create a new `Invoice` requiring 3 confirmations, and expiring in 5 blocks.
/// let invoice_id = payment_gateway.new_invoice(10000, 3, 5, "for pizza".to_string()).await?;
/// let mut subscriber = payment_gateway.subscribe(invoice_id)?.expect("invoice ID not found");
/// let invoice = subscriber.recv()?;
///
/// assert_eq!(invoice.expiration_in(), 5);
/// # Ok(())
/// # }
/// ```
#[must_use]
pub fn expiration_in(&self) -> u64 {
self.expiration_height.saturating_sub(self.current_height)
}
/// Returns the description of this invoice.
#[must_use]
pub fn description(&self) -> &str {
&self.description
}
}
impl fmt::Display for Invoice {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let confirmations = match self.confirmations() {
Some(height) => height.to_string(),
None => "N/A".to_string(),
};
let mut str = format!(
"Index {}: \
\nPaid: {}/{} \
\nConfirmations: {} \
\nStarted at: {} \
\nCurrent height: {} \
\nExpiration at: {} \
\nDescription: \"{}\" \
\ntransfers: \
\n[",
self.index,
monero::Amount::from_pico(self.amount_paid).as_xmr(),
monero::Amount::from_pico(self.amount_requested).as_xmr(),
confirmations,
self.creation_height,
self.current_height,
self.expiration_height,
self.description,
);
for transfer in &self.transfers {
let height = match transfer.height {
Some(h) => h.to_string(),
None => "N/A".to_string(),
};
str.push_str(&format!(
"\n {{Amount: {}, Height: {:?}}}",
transfer.amount, height
));
}
if self.transfers.is_empty() {
str.push(']');
} else {
str.push_str("\n]");
}
write!(f, "{}", str)
}
}
/// This custom `PartialEq` implementation is necessary so that the order of `Transfer`s can be
/// ignored while comparing `Invoice`s.
impl PartialEq for Invoice {
fn eq(&self, other: &Self) -> bool {
let mut lhs_transfers = HashMap::new();
let mut rhs_transfers = HashMap::new();
for i in &self.transfers {
*lhs_transfers.entry(i).or_insert(0) += 1;
}
for i in &other.transfers {
*rhs_transfers.entry(i).or_insert(0) += 1;
}
lhs_transfers == rhs_transfers
&& self.address == other.address
&& self.index == other.index
&& self.creation_height == other.creation_height
&& self.amount_requested == other.amount_requested
&& self.amount_paid == other.amount_paid
&& self.paid_height == other.paid_height
&& self.confirmations_required == other.confirmations_required
&& self.current_height == other.current_height
&& self.expiration_height == other.expiration_height
&& self.description == other.description
}
}
/// An invoice ID consists uniquely identifies a given invoice by the combination of its subaddress
/// index and creation height.
#[derive(Debug, Copy, Clone, Hash, Encode, Decode, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct InvoiceId {
/// The [subaddress index](SubIndex) of the invoice.
pub sub_index: SubIndex,
/// The creation height of the invoice.
pub creation_height: u64,
}
impl InvoiceId {
/// Create a new `InvoiceId` from subaddress index and creation height.
#[must_use]
pub fn new(sub_index: SubIndex, creation_height: u64) -> InvoiceId {
InvoiceId {
sub_index,
creation_height,
}
}
}
impl Display for InvoiceId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "({},{})", self.sub_index, self.creation_height)
}
}
/// A subaddress index.
#[derive(Debug, Copy, Clone, Hash, Encode, Decode, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct SubIndex {
/// Subadress major index.
pub major: u32,
/// Subaddress minor index.
pub minor: u32,
}
impl SubIndex {
/// Create a new subaddress index from major and minor indexes.
#[must_use]
pub fn new(major: u32, minor: u32) -> SubIndex {
SubIndex { major, minor }
}
}
impl Ord for SubIndex {
fn cmp(&self, other: &Self) -> Ordering {
match self.major.cmp(&other.major) {
Ordering::Equal => self.minor.cmp(&other.minor),
Ordering::Greater => Ordering::Greater,
Ordering::Less => Ordering::Less,
}
}
}
impl PartialOrd for SubIndex {
fn partial_cmp(&self, other: &Self) -> Option<cmp::Ordering> {
Some(self.cmp(other))
}
}
impl fmt::Display for SubIndex {
fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
write!(formatter, "{}/{}", self.major, self.minor)
}
}
impl From<subaddress::Index> for SubIndex {
fn from(index: subaddress::Index) -> SubIndex {
SubIndex {
major: index.major,
minor: index.minor,
}
}
}
impl From<SubIndex> for subaddress::Index {
fn from(index: SubIndex) -> subaddress::Index {
subaddress::Index {
major: index.major,
minor: index.minor,
}
}
}
/// A `Transfer` represents a sum of owned outputs at a given height. When part of an `Invoice`, it
/// specifically represents the sum of owned outputs for that invoice's subaddress, at a given
/// height.
#[derive(Debug, Clone, PartialEq, Encode, Decode, Copy, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub(crate) struct Transfer {
/// Amount transferred in piconeros.
pub amount: u64,
/// Block height of the transfer, or None if the outputs are in the txpool.
pub height: Option<u64>,
}
impl Transfer {
pub(crate) fn new(amount: u64, height: Option<u64>) -> Transfer {
Transfer { amount, height }
}
/// Compare two transfers by height. Newer is greater.
pub(crate) fn cmp_by_height(&self, other: &Self) -> cmp::Ordering {
match self.height {
Some(height) => match other.height {
Some(other_height) => height.cmp(&other_height),
None => cmp::Ordering::Less,
},
None => match other.height {
Some(_) => cmp::Ordering::Greater,
None => cmp::Ordering::Equal,
},
}
}
}
#[cfg(test)]
mod tests {
use std::env;
use crate::{Invoice, SubIndex};
fn init_logger() {
env::set_var(
"RUST_LOG",
"debug,mio=debug,want=debug,reqwest=info,sled=info,hyper=info,tracing=debug,httpmock=info,isahc=info",
);
let _ = env_logger::builder().is_test(true).try_init();
}
#[test]
fn payment_request_small() {
// Setup.
init_logger();
check_payment_request(1, 0, "0.000000000001");
}
#[test]
fn payment_request_big() {
// Setup.
init_logger();
check_payment_request(u64::MAX, 0, "18446744.073709551615");
}
#[test]
fn payment_request_zero() {
// Setup.
init_logger();
check_payment_request(1, 1, "0.0");
}
#[test]
fn payment_request_partially_paid() {
// Setup.
init_logger();
check_payment_request(2_460_000_000_000, 1_230_000_000_000, "1.23");
}
fn check_payment_request(requested: u64, paid: u64, expected_tx_amount: &str) {
let mut invoice = Invoice::new(
"testaddress".to_string(),
SubIndex::new(0, 1),
0,
requested,
5,
10,
"test_description".to_string(),
);
invoice.amount_paid = paid;
assert_eq!(
invoice.uri(),
format!("monero:testaddress?tx_amount={}", expected_tx_amount)
);
}
}