use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use std::fmt;
use uuid::Uuid;
use super::user::ValidationError;
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct Token {
pub token_id: Uuid,
pub issuer_user_id: Uuid,
pub symbol: String,
pub name: String,
pub description: Option<String>,
pub total_supply: Decimal,
pub circulating_supply: Decimal,
pub initial_price_btc: Decimal,
pub price_increment_btc: Decimal,
pub created_at: DateTime<Utc>,
pub status: TokenStatus,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, sqlx::Type, PartialEq, Eq)]
#[sqlx(type_name = "varchar", rename_all = "lowercase")]
#[derive(Default)]
pub enum TokenStatus {
#[default]
Active,
Paused,
Closed,
}
impl fmt::Display for Token {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"Token({}, ${}, {})",
self.token_id, self.symbol, self.name
)
}
}
impl fmt::Display for TokenStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
TokenStatus::Active => write!(f, "active"),
TokenStatus::Paused => write!(f, "paused"),
TokenStatus::Closed => write!(f, "closed"),
}
}
}
#[derive(Debug, Deserialize)]
pub struct CreateTokenRequest {
pub symbol: String,
pub name: String,
pub description: Option<String>,
pub total_supply: Decimal,
pub initial_price_btc: Decimal,
pub price_increment_btc: Decimal,
}
impl CreateTokenRequest {
pub fn validate(&self) -> Result<(), ValidationError> {
if self.symbol.is_empty() {
return Err(ValidationError("Symbol is required".to_string()));
}
if self.symbol.len() > 20 {
return Err(ValidationError(
"Symbol must be at most 20 characters".to_string(),
));
}
if !self.symbol.starts_with('$') {
return Err(ValidationError("Symbol must start with '$'".to_string()));
}
let symbol_without_prefix = &self.symbol[1..];
if !symbol_without_prefix
.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())
{
return Err(ValidationError(
"Symbol must contain only uppercase letters and digits after '$'".to_string(),
));
}
if self.name.is_empty() {
return Err(ValidationError("Name is required".to_string()));
}
if self.name.len() > 100 {
return Err(ValidationError(
"Name must be at most 100 characters".to_string(),
));
}
if let Some(ref desc) = self.description {
if desc.len() > 2000 {
return Err(ValidationError(
"Description must be at most 2000 characters".to_string(),
));
}
}
if self.total_supply <= dec!(0) {
return Err(ValidationError("Total supply must be positive".to_string()));
}
if self.total_supply > dec!(1_000_000) {
return Err(ValidationError(
"Total supply must be at most 1,000,000".to_string(),
));
}
if self.initial_price_btc <= dec!(0) {
return Err(ValidationError(
"Initial price must be positive".to_string(),
));
}
if self.initial_price_btc < dec!(0.00001) {
return Err(ValidationError(
"Initial price must be at least 0.00001 BTC".to_string(),
));
}
if self.price_increment_btc < dec!(0) {
return Err(ValidationError(
"Price increment cannot be negative".to_string(),
));
}
Ok(())
}
}
#[derive(Debug, Serialize)]
pub struct TokenWithIssuer {
#[serde(flatten)]
pub token: Token,
pub issuer_username: String,
pub issuer_display_name: Option<String>,
pub issuer_reputation_score: Decimal,
pub current_price: Decimal,
}
#[derive(Debug, Clone, Copy, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum TokenSortBy {
#[default]
CreatedAt,
Symbol,
Name,
TotalSupply,
CirculatingSupply,
InitialPrice,
}
#[derive(Debug, Clone, Copy, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum SortOrder {
Asc,
#[default]
Desc,
}
#[derive(Debug, Deserialize)]
pub struct TokenListQuery {
pub page: Option<u32>,
pub limit: Option<u32>,
pub status: Option<TokenStatus>,
pub search: Option<String>,
pub min_price: Option<Decimal>,
pub max_price: Option<Decimal>,
pub sort_by: Option<TokenSortBy>,
pub order: Option<SortOrder>,
}
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct TokenMetadata {
pub token_id: Uuid,
pub cover_image_url: Option<String>,
pub website_url: Option<String>,
pub twitter_handle: Option<String>,
pub discord_url: Option<String>,
pub github_url: Option<String>,
pub youtube_url: Option<String>,
pub telegram_url: Option<String>,
pub tags: Option<String>,
pub faq_content: Option<String>,
pub terms_of_service: Option<String>,
pub has_utilities: bool,
pub utility_count: i32,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl fmt::Display for TokenMetadata {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "TokenMetadata(token_id={})", self.token_id)
}
}
impl TokenMetadata {
pub fn empty(token_id: Uuid) -> Self {
let now = Utc::now();
Self {
token_id,
cover_image_url: None,
website_url: None,
twitter_handle: None,
discord_url: None,
github_url: None,
youtube_url: None,
telegram_url: None,
tags: None,
faq_content: None,
terms_of_service: None,
has_utilities: false,
utility_count: 0,
created_at: now,
updated_at: now,
}
}
pub fn validate(&self) -> Result<(), ValidationError> {
if let Some(ref url) = self.website_url {
if !url.starts_with("https://") {
return Err(ValidationError("Website URL must use HTTPS".to_string()));
}
}
if let Some(ref url) = self.discord_url {
if !url.starts_with("https://discord.") && !url.starts_with("https://discord.gg/") {
return Err(ValidationError(
"Discord URL must be a valid Discord link".to_string(),
));
}
}
if let Some(ref url) = self.github_url {
if !url.starts_with("https://github.com/") {
return Err(ValidationError(
"GitHub URL must be a valid GitHub link".to_string(),
));
}
}
Ok(())
}
pub fn tags_list(&self) -> Vec<String> {
self.tags
.as_ref()
.map(|t| {
t.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
})
.unwrap_or_default()
}
}
#[derive(Debug, Deserialize)]
pub struct UpdateTokenMetadataRequest {
pub cover_image_url: Option<String>,
pub website_url: Option<String>,
pub twitter_handle: Option<String>,
pub discord_url: Option<String>,
pub github_url: Option<String>,
pub youtube_url: Option<String>,
pub telegram_url: Option<String>,
pub tags: Option<String>,
pub faq_content: Option<String>,
pub terms_of_service: Option<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, sqlx::Type, PartialEq, Eq)]
#[sqlx(type_name = "varchar", rename_all = "snake_case")]
#[derive(Default)]
pub enum UtilityType {
ContentAccess,
DirectMessage,
OneOnOneCall,
GroupCall,
DiscordAccess,
EarlyAccess,
CodeReview,
Mentorship,
#[default]
Custom,
}
impl fmt::Display for UtilityType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
UtilityType::ContentAccess => write!(f, "Content Access"),
UtilityType::DirectMessage => write!(f, "Direct Message"),
UtilityType::OneOnOneCall => write!(f, "1:1 Call"),
UtilityType::GroupCall => write!(f, "Group Call"),
UtilityType::DiscordAccess => write!(f, "Discord Access"),
UtilityType::EarlyAccess => write!(f, "Early Access"),
UtilityType::CodeReview => write!(f, "Code Review"),
UtilityType::Mentorship => write!(f, "Mentorship"),
UtilityType::Custom => write!(f, "Custom"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct UtilityDefinition {
pub utility_id: Uuid,
pub token_id: Uuid,
pub utility_type: UtilityType,
pub title: String,
pub description: Option<String>,
pub min_tokens_required: Decimal,
pub max_redemptions_per_holder: Option<i32>,
pub max_total_redemptions: Option<i32>,
pub total_redemptions_used: i32,
pub is_active: bool,
pub redemption_instructions: Option<String>,
pub redemption_url: Option<String>,
pub estimated_value_btc: Option<Decimal>,
pub expires_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl fmt::Display for UtilityDefinition {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"Utility({}, {}, type={}, min_tokens={})",
self.utility_id, self.title, self.utility_type, self.min_tokens_required
)
}
}
impl UtilityDefinition {
pub fn is_available(&self) -> bool {
if !self.is_active {
return false;
}
if let Some(expires_at) = self.expires_at {
if Utc::now() > expires_at {
return false;
}
}
if let Some(max_total) = self.max_total_redemptions {
if self.total_redemptions_used >= max_total {
return false;
}
}
true
}
pub fn can_access(&self, user_balance: Decimal) -> bool {
self.is_available() && user_balance >= self.min_tokens_required
}
pub fn remaining_redemptions(&self) -> Option<i32> {
self.max_total_redemptions
.map(|max| (max - self.total_redemptions_used).max(0))
}
}
#[derive(Debug, Deserialize)]
pub struct CreateUtilityRequest {
pub utility_type: UtilityType,
pub title: String,
pub description: Option<String>,
pub min_tokens_required: Decimal,
pub max_redemptions_per_holder: Option<i32>,
pub max_total_redemptions: Option<i32>,
pub redemption_instructions: Option<String>,
pub redemption_url: Option<String>,
pub estimated_value_btc: Option<Decimal>,
pub expires_at: Option<DateTime<Utc>>,
}
impl CreateUtilityRequest {
pub fn validate(&self) -> Result<(), ValidationError> {
if self.title.is_empty() {
return Err(ValidationError("Title is required".to_string()));
}
if self.title.len() > 200 {
return Err(ValidationError(
"Title must be at most 200 characters".to_string(),
));
}
if let Some(ref desc) = self.description {
if desc.len() > 2000 {
return Err(ValidationError(
"Description must be at most 2000 characters".to_string(),
));
}
}
if self.min_tokens_required < dec!(0) {
return Err(ValidationError(
"Minimum tokens required cannot be negative".to_string(),
));
}
if let Some(max) = self.max_redemptions_per_holder {
if max <= 0 {
return Err(ValidationError(
"Max redemptions per holder must be positive".to_string(),
));
}
}
if let Some(max) = self.max_total_redemptions {
if max <= 0 {
return Err(ValidationError(
"Max total redemptions must be positive".to_string(),
));
}
}
if let Some(ref url) = self.redemption_url {
if !url.starts_with("https://") {
return Err(ValidationError("Redemption URL must use HTTPS".to_string()));
}
}
if let Some(expires) = self.expires_at {
if expires < Utc::now() {
return Err(ValidationError(
"Expiration date must be in the future".to_string(),
));
}
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct UtilityRedemption {
pub redemption_id: Uuid,
pub utility_id: Uuid,
pub user_id: Uuid,
pub tokens_held: Decimal,
pub status: RedemptionStatus,
pub notes: Option<String>,
pub redeemed_at: DateTime<Utc>,
pub fulfilled_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, sqlx::Type, PartialEq, Eq)]
#[sqlx(type_name = "varchar", rename_all = "lowercase")]
#[derive(Default)]
pub enum RedemptionStatus {
#[default]
Pending,
Confirmed,
Fulfilled,
Cancelled,
Expired,
}
impl fmt::Display for RedemptionStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
RedemptionStatus::Pending => write!(f, "pending"),
RedemptionStatus::Confirmed => write!(f, "confirmed"),
RedemptionStatus::Fulfilled => write!(f, "fulfilled"),
RedemptionStatus::Cancelled => write!(f, "cancelled"),
RedemptionStatus::Expired => write!(f, "expired"),
}
}
}
#[derive(Debug, Serialize)]
pub struct UtilitySummary {
pub token_id: Uuid,
pub total_utilities: i32,
pub active_utilities: i32,
pub utilities: Vec<UtilityDefinition>,
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{Duration, Utc};
use rust_decimal_macros::dec;
use uuid::Uuid;
fn valid_create_token_request() -> CreateTokenRequest {
CreateTokenRequest {
symbol: "$MYTOKEN".to_string(),
name: "My Token".to_string(),
description: None,
total_supply: dec!(1000),
initial_price_btc: dec!(0.001),
price_increment_btc: dec!(0.0001),
}
}
fn make_utility_definition(
is_active: bool,
expires_at: Option<DateTime<Utc>>,
max_total: Option<i32>,
used: i32,
) -> UtilityDefinition {
let now = Utc::now();
UtilityDefinition {
utility_id: Uuid::new_v4(),
token_id: Uuid::new_v4(),
utility_type: UtilityType::ContentAccess,
title: "Test Utility".to_string(),
description: None,
min_tokens_required: dec!(10),
max_redemptions_per_holder: None,
max_total_redemptions: max_total,
total_redemptions_used: used,
is_active,
redemption_instructions: None,
redemption_url: None,
estimated_value_btc: None,
expires_at,
created_at: now,
updated_at: now,
}
}
#[test]
fn test_create_token_request_valid() {
let req = valid_create_token_request();
assert!(
req.validate().is_ok(),
"A well-formed request must pass validation"
);
}
#[test]
fn test_create_token_empty_symbol_rejected() {
let req = CreateTokenRequest {
symbol: "".to_string(),
..valid_create_token_request()
};
match req.validate() {
Err(ValidationError(msg)) => {
assert!(msg.contains("Symbol is required"), "message was: {msg}")
}
Ok(()) => panic!("Expected validation error for empty symbol"),
}
}
#[test]
fn test_create_token_symbol_without_dollar_prefix_rejected() {
let req = CreateTokenRequest {
symbol: "MYTOKEN".to_string(),
..valid_create_token_request()
};
let err = req
.validate()
.expect_err("Symbol without '$' must be rejected");
assert!(
err.0.contains('$'),
"error message should mention '$': {}",
err.0
);
}
#[test]
fn test_create_token_symbol_too_long_rejected() {
let req = CreateTokenRequest {
symbol: "$ABCDEFGHIJKLMNOPQRST".to_string(), ..valid_create_token_request()
};
let err = req
.validate()
.expect_err("Overly long symbol must be rejected");
assert!(err.0.contains("20 characters"), "error: {}", err.0);
}
#[test]
fn test_create_token_symbol_lowercase_rejected() {
let req = CreateTokenRequest {
symbol: "$mytoken".to_string(),
..valid_create_token_request()
};
let err = req
.validate()
.expect_err("Lowercase symbol must be rejected");
assert!(err.0.contains("uppercase"), "error: {}", err.0);
}
#[test]
fn test_create_token_zero_supply_rejected() {
let req = CreateTokenRequest {
total_supply: dec!(0),
..valid_create_token_request()
};
let err = req.validate().expect_err("Zero supply must be rejected");
assert!(err.0.contains("positive"), "error: {}", err.0);
}
#[test]
fn test_create_token_supply_exceeds_maximum_rejected() {
let req = CreateTokenRequest {
total_supply: dec!(1_000_001),
..valid_create_token_request()
};
let err = req
.validate()
.expect_err("Supply beyond 1M must be rejected");
assert!(err.0.contains("1,000,000"), "error: {}", err.0);
}
#[test]
fn test_create_token_zero_price_rejected() {
let req = CreateTokenRequest {
initial_price_btc: dec!(0),
..valid_create_token_request()
};
let err = req.validate().expect_err("Zero price must be rejected");
assert!(err.0.contains("positive"), "error: {}", err.0);
}
#[test]
fn test_create_token_price_below_minimum_rejected() {
let req = CreateTokenRequest {
initial_price_btc: dec!(0.000001),
..valid_create_token_request()
};
let err = req
.validate()
.expect_err("Price below 0.00001 must be rejected");
assert!(err.0.contains("0.00001"), "error: {}", err.0);
}
#[test]
fn test_create_token_negative_price_increment_rejected() {
let req = CreateTokenRequest {
price_increment_btc: dec!(-0.001),
..valid_create_token_request()
};
let err = req
.validate()
.expect_err("Negative price increment must be rejected");
assert!(err.0.contains("negative"), "error: {}", err.0);
}
#[test]
fn test_create_token_empty_name_rejected() {
let req = CreateTokenRequest {
name: "".to_string(),
..valid_create_token_request()
};
let err = req.validate().expect_err("Empty name must be rejected");
assert!(err.0.contains("Name is required"), "error: {}", err.0);
}
#[test]
fn test_token_metadata_empty_is_valid() {
let meta = TokenMetadata::empty(Uuid::new_v4());
assert!(meta.validate().is_ok(), "Empty metadata must be valid");
}
#[test]
fn test_token_metadata_http_website_rejected() {
let mut meta = TokenMetadata::empty(Uuid::new_v4());
meta.website_url = Some("http://example.com".to_string());
let err = meta
.validate()
.expect_err("HTTP website URL must be rejected");
assert!(err.0.contains("HTTPS"), "error: {}", err.0);
}
#[test]
fn test_token_metadata_invalid_github_url_rejected() {
let mut meta = TokenMetadata::empty(Uuid::new_v4());
meta.github_url = Some("https://example.com/repo".to_string());
let err = meta
.validate()
.expect_err("Non-github.com URL must be rejected");
assert!(err.0.contains("GitHub"), "error: {}", err.0);
}
#[test]
fn test_token_metadata_valid_github_url_accepted() {
let mut meta = TokenMetadata::empty(Uuid::new_v4());
meta.github_url = Some("https://github.com/cool-japan/kaccy".to_string());
assert!(meta.validate().is_ok(), "Valid GitHub URL must be accepted");
}
#[test]
fn test_tags_list_parses_comma_separated() {
let mut meta = TokenMetadata::empty(Uuid::new_v4());
meta.tags = Some("defi, nft, web3".to_string());
let tags = meta.tags_list();
assert_eq!(tags, vec!["defi", "nft", "web3"]);
}
#[test]
fn test_tags_list_empty_when_no_tags() {
let meta = TokenMetadata::empty(Uuid::new_v4());
assert!(
meta.tags_list().is_empty(),
"No tags must return empty list"
);
}
#[test]
fn test_tags_list_filters_empty_segments() {
let mut meta = TokenMetadata::empty(Uuid::new_v4());
meta.tags = Some(",defi,,nft,".to_string());
let tags = meta.tags_list();
assert_eq!(tags, vec!["defi", "nft"]);
}
#[test]
fn test_utility_available_when_active_and_not_expired() {
let utility = make_utility_definition(true, None, None, 0);
assert!(
utility.is_available(),
"Active, unexpired utility must be available"
);
}
#[test]
fn test_utility_unavailable_when_inactive() {
let utility = make_utility_definition(false, None, None, 0);
assert!(
!utility.is_available(),
"Inactive utility must not be available"
);
}
#[test]
fn test_utility_unavailable_when_expired() {
let past = Utc::now() - Duration::days(1);
let utility = make_utility_definition(true, Some(past), None, 0);
assert!(
!utility.is_available(),
"Expired utility must not be available"
);
}
#[test]
fn test_utility_unavailable_when_redemptions_exhausted() {
let utility = make_utility_definition(true, None, Some(5), 5);
assert!(
!utility.is_available(),
"Utility with all redemptions used must not be available"
);
}
#[test]
fn test_can_access_with_sufficient_balance() {
let utility = make_utility_definition(true, None, None, 0);
assert!(
utility.can_access(dec!(10)),
"Balance exactly at minimum must grant access"
);
assert!(
utility.can_access(dec!(100)),
"Balance above minimum must grant access"
);
}
#[test]
fn test_cannot_access_with_insufficient_balance() {
let utility = make_utility_definition(true, None, None, 0);
assert!(
!utility.can_access(dec!(9)),
"Balance below minimum must deny access"
);
}
#[test]
fn test_remaining_redemptions_unlimited() {
let utility = make_utility_definition(true, None, None, 0);
assert_eq!(
utility.remaining_redemptions(),
None,
"Unlimited utility must return None for remaining"
);
}
#[test]
fn test_remaining_redemptions_limited() {
let utility = make_utility_definition(true, None, Some(10), 3);
assert_eq!(
utility.remaining_redemptions(),
Some(7),
"Remaining must equal max minus used"
);
}
#[test]
fn test_remaining_redemptions_never_negative() {
let utility = make_utility_definition(true, None, Some(5), 7);
assert_eq!(
utility.remaining_redemptions(),
Some(0),
"Remaining must be clamped to zero even if used > max"
);
}
#[test]
fn test_token_status_display() {
assert_eq!(TokenStatus::Active.to_string(), "active");
assert_eq!(TokenStatus::Paused.to_string(), "paused");
assert_eq!(TokenStatus::Closed.to_string(), "closed");
}
}