use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SecurityType {
ContractBasket,
Bond,
RevenueShare,
}
impl SecurityType {
pub fn as_str(&self) -> &str {
match self {
Self::ContractBasket => "contract_basket",
Self::Bond => "bond",
Self::RevenueShare => "revenue_share",
}
}
pub fn parse(s: &str) -> crate::error::Result<Self> {
match s {
"contract_basket" => Ok(Self::ContractBasket),
"bond" => Ok(Self::Bond),
"revenue_share" => Ok(Self::RevenueShare),
_ => Err(crate::error::Error::InvalidFormat(format!(
"unknown security type: {s}"
))),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum SecurityUnderlying {
Contract { contract_id: String },
Certificate { certificate_id: String },
Stablecash {
amount_units: u64,
contract_id: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecurityDeed {
pub security_id: String,
pub security_type: SecurityType,
pub witness_secret: Option<String>,
pub witness_proof: Option<String>,
pub issuer_fingerprint: String,
pub underlying: Vec<SecurityUnderlying>,
#[serde(skip_serializing_if = "Option::is_none")]
pub face_value_units: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub maturity_date: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub revenue_share_bps: Option<u16>,
pub created_at: String,
pub updated_at: String,
}
impl SecurityDeed {
pub fn new(
security_id: String,
security_type: SecurityType,
issuer_fingerprint: String,
underlying: Vec<SecurityUnderlying>,
) -> Self {
let now = chrono::Utc::now().to_rfc3339();
Self {
security_id,
security_type,
witness_secret: None,
witness_proof: None,
issuer_fingerprint,
underlying,
face_value_units: None,
maturity_date: None,
revenue_share_bps: None,
created_at: now.clone(),
updated_at: now,
}
}
}
pub const SECURITIES_DDL: &str = "
CREATE TABLE IF NOT EXISTS security_deeds (
security_id TEXT PRIMARY KEY,
security_type TEXT NOT NULL,
witness_secret TEXT,
witness_proof TEXT,
issuer_fingerprint TEXT NOT NULL,
underlying_json TEXT NOT NULL DEFAULT '[]',
face_value_units INTEGER,
maturity_date TEXT,
revenue_share_bps INTEGER,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
";
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn security_type_roundtrip() {
for st in [
SecurityType::ContractBasket,
SecurityType::Bond,
SecurityType::RevenueShare,
] {
let s = st.as_str();
let parsed = SecurityType::parse(s).unwrap();
assert_eq!(parsed, st);
}
}
#[test]
fn security_deed_new() {
let deed = SecurityDeed::new(
"SEC_2026_000001".to_string(),
SecurityType::ContractBasket,
"a".repeat(64),
vec![SecurityUnderlying::Contract {
contract_id: "CTR_2026_000123".to_string(),
}],
);
assert_eq!(deed.security_id, "SEC_2026_000001");
assert!(deed.witness_secret.is_none());
assert_eq!(deed.underlying.len(), 1);
}
#[test]
fn security_deed_serialise() {
let deed = SecurityDeed::new(
"SEC_2026_000001".to_string(),
SecurityType::Bond,
"b".repeat(64),
vec![SecurityUnderlying::Stablecash {
amount_units: 1_000_000_000,
contract_id: "USDH_MAIN".to_string(),
}],
);
let json = serde_json::to_string_pretty(&deed).unwrap();
assert!(json.contains("bond"));
assert!(json.contains("SEC_2026_000001"));
}
}