use std::{
cmp::{self, max, Ordering},
collections::HashMap,
fmt,
fmt::Display,
};
#[cfg(feature = "bincode")]
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;
#[derive(Debug, Clone, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "bincode", derive(Encode, Decode))]
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>,
pub(crate) 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,
paid_height: None,
confirmations_required,
current_height: 0,
expiration_height,
transfers: Vec::new(),
description,
}
}
#[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.")
)
}
#[must_use]
pub fn is_confirmed(&self) -> bool {
self.confirmations().map_or(false, |confirmations| {
confirmations >= self.confirmations_required
})
}
#[must_use]
pub fn is_paid(&self) -> bool {
self.amount_paid >= self.amount_requested
}
#[must_use]
pub fn is_expired(&self) -> bool {
self.current_height >= self.expiration_height
}
#[must_use]
pub fn address(&self) -> &str {
&self.address
}
#[must_use]
pub fn id(&self) -> InvoiceId {
InvoiceId {
sub_index: self.index,
creation_height: self.creation_height,
}
}
#[must_use]
pub fn index(&self) -> SubIndex {
self.index
}
#[must_use]
pub fn creation_height(&self) -> u64 {
self.creation_height
}
#[must_use]
pub fn amount_requested(&self) -> u64 {
self.amount_requested
}
#[must_use]
pub fn amount_paid(&self) -> u64 {
self.amount_paid
}
#[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
}
#[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
}
#[must_use]
pub fn confirmations_required(&self) -> u64 {
self.confirmations_required
}
#[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
}
}
#[must_use]
pub fn current_height(&self) -> u64 {
self.current_height
}
#[must_use]
pub fn expiration_height(&self) -> u64 {
self.expiration_height
}
#[must_use]
pub fn expiration_in(&self) -> u64 {
let height = max(self.creation_height, self.current_height);
self.expiration_height.saturating_sub(height)
}
#[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}")
}
}
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
}
}
#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "bincode", derive(Encode, Decode))]
pub struct InvoiceId {
pub sub_index: SubIndex,
pub creation_height: u64,
}
impl InvoiceId {
#[must_use]
pub fn new(sub_index: SubIndex, creation_height: u64) -> InvoiceId {
InvoiceId {
sub_index,
creation_height,
}
}
}
impl Ord for InvoiceId {
fn cmp(&self, other: &Self) -> Ordering {
match self.sub_index.cmp(&other.sub_index) {
Ordering::Equal => self.creation_height.cmp(&other.creation_height),
Ordering::Greater => Ordering::Greater,
Ordering::Less => Ordering::Less,
}
}
}
impl PartialOrd for InvoiceId {
fn partial_cmp(&self, other: &Self) -> Option<cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Display for InvoiceId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "({},{})", self.sub_index, self.creation_height)
}
}
#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "bincode", derive(Encode, Decode))]
pub struct SubIndex {
pub major: u32,
pub minor: u32,
}
impl SubIndex {
#[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,
}
}
}
#[derive(Debug, Clone, PartialEq, Copy, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "bincode", derive(Encode, Decode))]
pub(crate) struct Transfer {
pub(crate) amount: u64,
pub(crate) height: Option<u64>,
}
impl Transfer {
pub(crate) fn new(amount: u64, height: Option<u64>) -> Transfer {
Transfer { amount, height }
}
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,
},
}
}
}
impl From<InvoiceId> for u128 {
fn from(value: InvoiceId) -> Self {
let SubIndex { major, minor } = value.sub_index;
let height = value.creation_height;
let mut bytes = [0u8; 16];
bytes[..4].copy_from_slice(&major.to_be_bytes());
bytes[4..8].copy_from_slice(&minor.to_be_bytes());
bytes[8..16].copy_from_slice(&height.to_be_bytes());
u128::from_be_bytes(bytes)
}
}
impl From<u128> for InvoiceId {
fn from(value: u128) -> Self {
let bytes = value.to_be_bytes();
let mut major_index_bytes = [0u8; 4];
let mut minor_index_bytes = [0u8; 4];
let mut height_bytes = [0u8; 8];
major_index_bytes.copy_from_slice(&bytes[..4]);
minor_index_bytes.copy_from_slice(&bytes[4..8]);
height_bytes.copy_from_slice(&bytes[8..16]);
let sub_index = SubIndex {
major: u32::from_be_bytes(major_index_bytes),
minor: u32::from_be_bytes(minor_index_bytes),
};
InvoiceId {
sub_index,
creation_height: u64::from_be_bytes(height_bytes),
}
}
}
#[cfg(test)]
#[allow(clippy::expect_used)]
#[allow(clippy::panic)]
mod tests {
use test_case::test_case;
use testing_utils::init_logger;
use crate::{Invoice, InvoiceId, SubIndex};
#[test_case(1, 0 => "0.000000000001".to_string(); "small")]
#[test_case(u64::MAX, 0 => "18446744.073709551615".to_string(); "big")]
#[test_case(1, 1 => "0.0"; "zero")]
#[test_case(2_460_000_000_000, 1_230_000_000_000 => "1.23"; "partially paid")]
fn payment_request(requested: u64, paid: u64) -> String {
init_logger();
let mut invoice = Invoice::new(
"testAddress".to_string(),
SubIndex::new(0, 1),
0,
requested,
5,
10,
"test_description".to_string(),
);
invoice.amount_paid = paid;
let uri = invoice.uri();
let amount = uri
.strip_prefix("monero:testAddress?tx_amount=")
.expect("unexpected URI format");
amount.to_string()
}
#[test_case(1 => "0.000000000001".to_string(); "small")]
#[test_case(u64::MAX => "18446744.07370955".to_string(); "big")]
#[test_case(0 => "0".to_string(); "zero")]
fn xmr_requested(requested: u64) -> String {
init_logger();
let invoice = Invoice::new(
"testAddress".to_string(),
SubIndex::new(0, 1),
0,
requested,
5,
10,
"test_description".to_string(),
);
invoice.xmr_requested().to_string()
}
#[test]
fn expires_in() {
init_logger();
let invoice = Invoice::new(
"testAddress".to_string(),
SubIndex::new(0, 1),
12345,
1,
5,
10,
"test_description".to_string(),
);
assert_eq!(invoice.expiration_in(), 10);
}
#[test_case(InvoiceId::new(SubIndex::new(0, 0), 0), 0)]
fn invoice_id_integer_roundtrip(invoice_id: InvoiceId, expected_int: u128) {
let actual_int: u128 = invoice_id.into();
assert_eq!(actual_int, expected_int);
let rebuilt_invoice_id: InvoiceId = actual_int.into();
assert_eq!(rebuilt_invoice_id, invoice_id);
}
}