use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::error::{Error, Result};
use crate::types::wire::wire_enum;
wire_enum! {
pub enum TokenizationIssuer {
Xstocks => "xstocks",
St0x => "st0x",
}
}
wire_enum! {
pub enum TokenizationNetwork {
Solana => "solana",
Arbitrum => "arbitrum",
Ethereum => "ethereum",
Binance => "binance",
Base => "base",
Ton => "ton",
Tron => "tron",
Mantle => "mantle",
Cronos => "cronos",
HyperEvm => "hyperevm",
}
}
wire_enum! {
pub enum TokenizationStatus {
Pending => "pending",
Rejected => "rejected",
Completed => "completed",
}
}
wire_enum! {
pub enum TokenizationType {
Mint => "mint",
Redeem => "redeem",
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct TokenizationRequest {
pub tokenization_request_id: String,
#[serde(rename = "type", default)]
pub request_type: Option<TokenizationType>,
pub status: TokenizationStatus,
pub underlying_symbol: String,
pub token_symbol: String,
#[serde(with = "crate::types::decimal")]
pub qty: Decimal,
pub issuer: TokenizationIssuer,
pub network: TokenizationNetwork,
#[serde(default)]
pub wallet_address: Option<String>,
#[serde(default, with = "crate::types::option_decimal")]
pub fees: Option<Decimal>,
#[serde(default)]
pub tx_hash: Option<String>,
#[serde(default)]
pub client_request_id: Option<String>,
#[serde(default)]
pub issuer_request_id: Option<String>,
#[serde(default)]
pub account: Option<String>,
#[serde(default)]
pub client_account_id: Option<Uuid>,
#[serde(default)]
pub client_external_account_id: Option<String>,
#[serde(default)]
pub issuer_account: Option<String>,
pub created_at: DateTime<Utc>,
#[serde(default)]
pub updated_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct MintTokenRequest {
pub underlying_symbol: String,
#[serde(with = "crate::types::decimal")]
pub qty: Decimal,
pub issuer: TokenizationIssuer,
pub network: TokenizationNetwork,
pub wallet_address: String,
}
impl MintTokenRequest {
#[must_use]
pub fn new(
underlying_symbol: impl Into<String>,
qty: Decimal,
issuer: TokenizationIssuer,
network: TokenizationNetwork,
wallet_address: impl Into<String>,
) -> Self {
Self {
underlying_symbol: underlying_symbol.into(),
qty,
issuer,
network,
wallet_address: wallet_address.into(),
}
}
pub fn validate(&self) -> Result<()> {
if self.qty <= Decimal::ZERO {
return Err(Error::InvalidRequest(
"qty must be greater than zero".to_owned(),
));
}
Ok(())
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct GetTokenizationRequestsRequest {
#[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
pub request_type: Option<TokenizationType>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status: Option<TokenizationStatus>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub underlying_symbol: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub issuer: Option<TokenizationIssuer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub network: Option<TokenizationNetwork>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub after: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub before: Option<DateTime<Utc>>,
}
impl GetTokenizationRequestsRequest {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn request_type(mut self, request_type: TokenizationType) -> Self {
self.request_type = Some(request_type);
self
}
#[must_use]
pub fn status(mut self, status: TokenizationStatus) -> Self {
self.status = Some(status);
self
}
}
fn exactly_one_account(has_account_id: bool, has_external_id: bool) -> Result<()> {
match (has_account_id, has_external_id) {
(false, false) => Err(Error::InvalidRequest(
"one of client_account_id and client_external_account_id must be set".to_owned(),
)),
(true, true) => Err(Error::InvalidRequest(
"client_account_id and client_external_account_id are mutually exclusive".to_owned(),
)),
_ => Ok(()),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct TokenizationMintCallback {
pub tokenization_request_id: Uuid,
pub tx_hash: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_account_id: Option<Uuid>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_external_account_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub network: Option<TokenizationNetwork>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wallet_address: Option<String>,
}
impl TokenizationMintCallback {
#[must_use]
pub fn new(tokenization_request_id: Uuid, tx_hash: impl Into<String>) -> Self {
Self {
tokenization_request_id,
tx_hash: tx_hash.into(),
client_account_id: None,
client_external_account_id: None,
client_id: None,
network: None,
wallet_address: None,
}
}
pub fn validate(&self) -> Result<()> {
exactly_one_account(
self.client_account_id.is_some(),
self.client_external_account_id.is_some() || self.client_id.is_some(),
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct TokenizationRedeemRequest {
pub issuer_request_id: String,
pub underlying_symbol: String,
pub token_symbol: String,
#[serde(with = "crate::types::decimal")]
pub qty: Decimal,
pub network: TokenizationNetwork,
pub wallet_address: String,
pub tx_hash: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_account_id: Option<Uuid>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_external_account_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_id: Option<String>,
}
impl TokenizationRedeemRequest {
#[must_use]
pub fn new(
issuer_request_id: impl Into<String>,
underlying_symbol: impl Into<String>,
token_symbol: impl Into<String>,
qty: Decimal,
network: TokenizationNetwork,
wallet_address: impl Into<String>,
tx_hash: impl Into<String>,
) -> Self {
Self {
issuer_request_id: issuer_request_id.into(),
underlying_symbol: underlying_symbol.into(),
token_symbol: token_symbol.into(),
qty,
network,
wallet_address: wallet_address.into(),
tx_hash: tx_hash.into(),
client_account_id: None,
client_external_account_id: None,
client_id: None,
}
}
pub fn validate(&self) -> Result<()> {
exactly_one_account(
self.client_account_id.is_some(),
self.client_external_account_id.is_some() || self.client_id.is_some(),
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct TokenizationRedeemResponse {
pub tokenization_request_id: String,
pub status: TokenizationStatus,
pub underlying_symbol: String,
pub token_symbol: String,
#[serde(with = "crate::types::decimal")]
pub qty: Decimal,
pub issuer: TokenizationIssuer,
pub network: TokenizationNetwork,
pub created_at: DateTime<Utc>,
#[serde(rename = "type", default)]
pub request_type: Option<TokenizationType>,
#[serde(default)]
pub issuer_request_id: Option<String>,
#[serde(default)]
pub wallet_address: Option<String>,
#[serde(default)]
pub tx_hash: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ByClientRequestId {
pub client_request_id: String,
}
impl ByClientRequestId {
#[must_use]
pub fn new(client_request_id: impl Into<String>) -> Self {
Self {
client_request_id: client_request_id.into(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_mint_response_decodes_without_the_listing_only_fields() {
let request: TokenizationRequest = serde_json::from_value(serde_json::json!({
"tokenization_request_id": "abc",
"status": "pending",
"underlying_symbol": "AAPL",
"token_symbol": "AAPLx",
"qty": "1.5",
"issuer": "xstocks",
"network": "solana",
"created_at": "2026-01-02T15:04:05Z",
}))
.unwrap();
assert_eq!(request.status, TokenizationStatus::Pending);
assert_eq!(request.request_type, None);
assert_eq!(request.wallet_address, None);
}
#[test]
fn the_direction_stays_on_the_wire_as_type() {
let request: TokenizationRequest = serde_json::from_value(serde_json::json!({
"tokenization_request_id": "abc",
"type": "redeem",
"status": "pending",
"underlying_symbol": "AAPL",
"token_symbol": "AAPLx",
"qty": "1.5",
"issuer": "xstocks",
"network": "solana",
"created_at": "2026-01-02T15:04:05Z",
}))
.unwrap();
assert_eq!(request.request_type, Some(TokenizationType::Redeem));
let encoded = serde_json::to_value(&request).unwrap();
assert_eq!(encoded["type"], "redeem");
assert!(encoded.get("request_type").is_none(), "{encoded}");
}
#[test]
fn a_redeem_response_keeps_type_on_the_wire_and_tolerates_the_over_required() {
let response: TokenizationRedeemResponse = serde_json::from_value(serde_json::json!({
"tokenization_request_id": "abc",
"status": "completed",
"underlying_symbol": "AAPL",
"token_symbol": "AAPLx",
"qty": "1.5",
"issuer": "xstocks",
"network": "solana",
"created_at": "2026-01-02T15:04:05Z",
}))
.unwrap();
assert_eq!(response.request_type, None);
assert_eq!(response.issuer_request_id, None);
assert_eq!(response.wallet_address, None);
assert_eq!(response.tx_hash, None);
assert_eq!(response.qty, Decimal::new(15, 1));
let full: TokenizationRedeemResponse = serde_json::from_value(serde_json::json!({
"tokenization_request_id": "abc",
"type": "redeem",
"status": "completed",
"underlying_symbol": "AAPL",
"token_symbol": "AAPLx",
"qty": "1.5",
"issuer": "xstocks",
"network": "solana",
"created_at": "2026-01-02T15:04:05Z",
}))
.unwrap();
assert_eq!(full.request_type, Some(TokenizationType::Redeem));
let encoded = serde_json::to_value(&full).unwrap();
assert_eq!(encoded["type"], "redeem");
assert!(encoded.get("request_type").is_none(), "{encoded}");
}
#[test]
fn a_zero_mint_is_refused_before_it_is_sent() {
let request = MintTokenRequest::new(
"AAPL",
Decimal::ZERO,
TokenizationIssuer::Xstocks,
TokenizationNetwork::Solana,
"wallet",
);
assert!(request.validate().is_err());
}
#[test]
fn a_mint_callback_naming_no_account_or_two_is_refused() {
let mut callback = TokenizationMintCallback::new(Uuid::nil(), "0xdead");
assert!(callback.validate().is_err());
let encoded = serde_json::to_value(&callback).unwrap();
assert!(encoded.get("client_account_id").is_none(), "{encoded}");
assert!(
encoded.get("client_external_account_id").is_none(),
"{encoded}"
);
callback.client_account_id = Some(Uuid::nil());
assert!(callback.validate().is_ok());
callback.client_external_account_id = Some("cust-1".to_owned());
assert!(callback.validate().is_err());
callback.client_account_id = None;
assert!(callback.validate().is_ok());
let mut aliased = TokenizationMintCallback::new(Uuid::nil(), "0xdead");
aliased.client_id = Some("cust-1".to_owned());
assert!(aliased.validate().is_ok());
aliased.client_account_id = Some(Uuid::nil());
assert!(aliased.validate().is_err());
}
#[test]
fn a_redeem_naming_no_account_or_two_is_refused() {
let mut request = TokenizationRedeemRequest::new(
"iss-1",
"AAPL",
"AAPLx",
Decimal::ONE,
TokenizationNetwork::Solana,
"wallet",
"0xdead",
);
assert!(request.validate().is_err());
request.client_external_account_id = Some("cust-1".to_owned());
assert!(request.validate().is_ok());
request.client_account_id = Some(Uuid::nil());
assert!(request.validate().is_err());
request.client_external_account_id = None;
assert!(request.validate().is_ok());
}
}