Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions crates/aleph-cli/src/commands/credit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ use crate::common::{
};
use aleph_sdk::builder::MessageBuilder;
use aleph_sdk::client::AlephClient;
use aleph_sdk::credit::{
self, CREDIT_TRANSFER_POST_TYPE, CreditEstimate, CreditToken, CreditTransferContent,
CreditTransferEntry, CreditTransferError, CreditTransferList, EthereumConfig,
format_token_amount,
use aleph_sdk::credit::{self, CreditEstimate, CreditToken, EthereumConfig, format_token_amount};
use aleph_sdk::credit_transfer::{
CREDIT_TRANSFER_POST_TYPE, CreditTransferContent, CreditTransferEntry, CreditTransferError,
CreditTransferList,
};
use aleph_types::account::{Account, EvmAccount};
use aleph_types::chain::Address as AlephAddress;
Expand Down Expand Up @@ -364,7 +364,7 @@ mod tests {

#[test]
fn transfer_envelope_shape() {
use aleph_sdk::credit::{
use aleph_sdk::credit_transfer::{
CREDIT_TRANSFER_POST_TYPE, CreditTransferContent, CreditTransferEntry,
CreditTransferList,
};
Expand Down Expand Up @@ -402,7 +402,7 @@ mod tests {

#[test]
fn transfer_self_transfer_error_kind() {
use aleph_sdk::credit::CreditTransferError;
use aleph_sdk::credit_transfer::CreditTransferError;
use aleph_types::chain::Address as AlephAddress;
let addr = AlephAddress::from("0xrecipient".to_string());
let err = CreditTransferError::SelfTransfer(addr.clone());
Expand Down
216 changes: 0 additions & 216 deletions crates/aleph-sdk/src/credit.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
use std::time::Duration;

use aleph_types::chain::Address as AlephAddress;
use alloy_primitives::{Address, U256, address};
use alloy_provider::Provider;
use alloy_sol_types::sol;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

/// ALEPH ERC20 token on Ethereum mainnet.
Expand Down Expand Up @@ -388,76 +386,6 @@ pub async fn buy_credits(
Ok(receipt)
}

pub const CREDIT_TRANSFER_POST_TYPE: &str = "aleph_credit_transfer";

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreditTransferEntry {
pub address: AlephAddress,
pub amount: u64,
#[serde(
default,
with = "chrono::serde::ts_seconds_option",
skip_serializing_if = "Option::is_none"
)]
pub expiration: Option<DateTime<Utc>>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreditTransferList {
pub credits: Vec<CreditTransferEntry>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreditTransferContent {
pub transfer: CreditTransferList,
}

#[derive(Debug, thiserror::Error)]
pub enum CreditTransferError {
#[error("credits list must not be empty")]
EmptyCredits,
#[error("amount must be strictly positive (got {0})")]
NonPositiveAmount(u64),
#[error("recipient address must not be empty")]
EmptyAddress,
#[error("duplicate recipient address: {0}")]
DuplicateRecipient(AlephAddress),
#[error("expiration must not be before the unix epoch (got {0})")]
NegativeExpiration(DateTime<Utc>),
#[error("sender and recipient must differ (got {0})")]
SelfTransfer(AlephAddress),
}

impl CreditTransferContent {
pub fn validate(&self) -> Result<(), CreditTransferError> {
let credits = &self.transfer.credits;
if credits.is_empty() {
return Err(CreditTransferError::EmptyCredits);
}

let mut seen = std::collections::HashSet::with_capacity(credits.len());
for entry in credits {
if entry.amount == 0 {
return Err(CreditTransferError::NonPositiveAmount(entry.amount));
}
if entry.address.as_str().trim().is_empty() {
return Err(CreditTransferError::EmptyAddress);
}
if let Some(exp) = entry.expiration
&& exp.timestamp() < 0
{
return Err(CreditTransferError::NegativeExpiration(exp));
}
if !seen.insert(entry.address.clone()) {
return Err(CreditTransferError::DuplicateRecipient(
entry.address.clone(),
));
}
}
Ok(())
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -677,47 +605,6 @@ mod tests {
assert!((u256_to_f64(U256::from(1u64), 18) - 1e-18).abs() < 1e-30);
}

#[test]
fn credit_transfer_content_round_trips_with_expiration() {
use chrono::TimeZone;

let dt = chrono::Utc
.with_ymd_and_hms(2026, 12, 31, 23, 59, 59)
.unwrap();
let content = CreditTransferContent {
transfer: CreditTransferList {
credits: vec![CreditTransferEntry {
address: AlephAddress::from("0xrecipient".to_string()),
amount: 1500,
expiration: Some(dt),
}],
},
};

let json = serde_json::to_value(&content).unwrap();
assert_eq!(json["transfer"]["credits"][0]["address"], "0xrecipient");
assert_eq!(json["transfer"]["credits"][0]["amount"], 1500);
assert_eq!(json["transfer"]["credits"][0]["expiration"], dt.timestamp(),);

let back: CreditTransferContent = serde_json::from_value(json).unwrap();
assert_eq!(back.transfer.credits[0].amount, 1500);
assert_eq!(back.transfer.credits[0].expiration, Some(dt));
}

#[test]
fn credit_transfer_entry_omits_expiration_when_none() {
let entry = CreditTransferEntry {
address: AlephAddress::from("0xrecipient".to_string()),
amount: 1,
expiration: None,
};
let json = serde_json::to_value(&entry).unwrap();
assert!(
json.get("expiration").is_none(),
"expiration should be omitted when None, got: {json}"
);
}

#[test]
fn format_18_decimal_whole() {
let amount = U256::from(100u64) * U256::from(10u64).pow(U256::from(18u64));
Expand All @@ -735,107 +622,4 @@ mod tests {
let amount = U256::from(1_000_000u64);
assert_eq!(format_token_amount(amount, 6), "1");
}

#[test]
fn validate_accepts_single_recipient_positive_amount() {
let content = CreditTransferContent {
transfer: CreditTransferList {
credits: vec![CreditTransferEntry {
address: AlephAddress::from("0xrecipient".to_string()),
amount: 1,
expiration: None,
}],
},
};
assert!(content.validate().is_ok());
}

#[test]
fn validate_rejects_empty_credits_list() {
let content = CreditTransferContent {
transfer: CreditTransferList { credits: vec![] },
};
assert!(matches!(
content.validate(),
Err(CreditTransferError::EmptyCredits)
));
}

#[test]
fn validate_rejects_zero_amount() {
let content = CreditTransferContent {
transfer: CreditTransferList {
credits: vec![CreditTransferEntry {
address: AlephAddress::from("0xrecipient".to_string()),
amount: 0,
expiration: None,
}],
},
};
assert!(matches!(
content.validate(),
Err(CreditTransferError::NonPositiveAmount(0))
));
}

#[test]
fn validate_rejects_blank_address() {
let content = CreditTransferContent {
transfer: CreditTransferList {
credits: vec![CreditTransferEntry {
address: AlephAddress::from(" ".to_string()),
amount: 1,
expiration: None,
}],
},
};
assert!(matches!(
content.validate(),
Err(CreditTransferError::EmptyAddress)
));
}

#[test]
fn validate_rejects_duplicate_recipients() {
let dup = AlephAddress::from("0xrecipient".to_string());
let content = CreditTransferContent {
transfer: CreditTransferList {
credits: vec![
CreditTransferEntry {
address: dup.clone(),
amount: 1,
expiration: None,
},
CreditTransferEntry {
address: dup,
amount: 2,
expiration: None,
},
],
},
};
assert!(matches!(
content.validate(),
Err(CreditTransferError::DuplicateRecipient(_))
));
}

#[test]
fn validate_rejects_pre_epoch_expiration() {
use chrono::TimeZone;
let pre_epoch = chrono::Utc.with_ymd_and_hms(1969, 1, 1, 0, 0, 0).unwrap();
let content = CreditTransferContent {
transfer: CreditTransferList {
credits: vec![CreditTransferEntry {
address: AlephAddress::from("0xrecipient".to_string()),
amount: 1,
expiration: Some(pre_epoch),
}],
},
};
assert!(matches!(
content.validate(),
Err(CreditTransferError::NegativeExpiration(_))
));
}
}
Loading
Loading