use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Ivms101Envelope {
pub spec_version: String,
pub originator: Ivms101Originator,
pub beneficiary: Ivms101Beneficiary,
pub originating_vasp: Ivms101Vasp,
pub beneficiary_vasp: Ivms101Vasp,
pub transfer: Ivms101TransferData,
}
impl Ivms101Envelope {
pub const CURRENT_SPEC_VERSION: &'static str = "101.2023";
pub fn new(
originator: Ivms101Originator,
beneficiary: Ivms101Beneficiary,
originating_vasp: Ivms101Vasp,
beneficiary_vasp: Ivms101Vasp,
transfer: Ivms101TransferData,
) -> Self {
Self {
spec_version: Self::CURRENT_SPEC_VERSION.to_string(),
originator,
beneficiary,
originating_vasp,
beneficiary_vasp,
transfer,
}
}
pub fn canonical_hash(&self) -> [u8; 32] {
use sha2::{Digest, Sha256};
let json = serde_json::to_value(self).unwrap_or(serde_json::Value::Null);
let canonical = canonical_json(&json);
let mut h = Sha256::new();
h.update(b"tenzro/ivms101/v1");
h.update(canonical.as_bytes());
let d = h.finalize();
let mut out = [0u8; 32];
out.copy_from_slice(&d);
out
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Ivms101Originator {
pub originator_persons: Vec<Ivms101Person>,
pub account_number: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Ivms101Beneficiary {
pub beneficiary_persons: Vec<Ivms101Person>,
pub account_number: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Ivms101Person {
pub natural_person: Option<Ivms101NaturalPerson>,
pub legal_person: Option<Ivms101LegalPerson>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Ivms101NaturalPerson {
pub name: Ivms101NaturalPersonName,
pub geographic_address: Vec<Ivms101Address>,
pub national_identification: Option<Ivms101NationalIdentification>,
pub customer_identification: Option<String>,
pub date_and_place_of_birth: Option<Ivms101DateAndPlaceOfBirth>,
pub country_of_residence: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Ivms101LegalPerson {
pub name: Ivms101LegalPersonName,
pub geographic_address: Vec<Ivms101Address>,
pub customer_number: Option<String>,
pub national_identification: Option<Ivms101NationalIdentification>,
pub country_of_registration: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Ivms101NaturalPersonName {
pub primary_identifier: String,
pub secondary_identifier: Option<String>,
pub name_identifier_type: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Ivms101LegalPersonName {
pub legal_person_name: String,
pub legal_person_name_identifier_type: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Ivms101Address {
pub address_type: String,
pub street_name: Option<String>,
pub building_number: Option<String>,
pub post_code: Option<String>,
pub town_name: Option<String>,
pub country_sub_division: Option<String>,
pub country: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Ivms101NationalIdentification {
pub national_identifier: String,
pub national_identifier_type: String,
pub country_of_issue: Option<String>,
pub registration_authority: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Ivms101DateAndPlaceOfBirth {
pub date_of_birth: String,
pub place_of_birth: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Ivms101Vasp {
pub legal_name: String,
pub lei: Option<String>,
pub country: String,
pub tenzro_did: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Ivms101TransferData {
pub asset_caip19: String,
pub amount_smallest_unit: String,
pub timestamp_iso8601: String,
pub transaction_hash_hex: String,
pub iso20022_message_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Ivms101Binding {
pub envelope_hash: [u8; 32],
pub originating_vasp_did: String,
pub beneficiary_vasp_did: Option<String>,
pub asset_caip19: String,
pub amount_smallest_unit: String,
pub envelope_url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Iso20022Message {
pub message_type: String,
pub message_id: String,
pub creation_iso8601: String,
pub cre_intent_calldata_hex: Option<String>,
}
fn canonical_json(v: &serde_json::Value) -> String {
use serde_json::Value;
match v {
Value::Null => "null".to_string(),
Value::Bool(b) => b.to_string(),
Value::Number(n) => n.to_string(),
Value::String(s) => serde_json::to_string(s).unwrap_or_default(),
Value::Array(arr) => {
let parts: Vec<String> = arr.iter().map(canonical_json).collect();
format!("[{}]", parts.join(","))
}
Value::Object(map) => {
let mut keys: Vec<&String> = map.keys().collect();
keys.sort();
let parts: Vec<String> = keys
.iter()
.map(|k| {
format!(
"{}:{}",
serde_json::to_string(k).unwrap_or_default(),
canonical_json(&map[*k])
)
})
.collect();
format!("{{{}}}", parts.join(","))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_envelope() -> Ivms101Envelope {
Ivms101Envelope::new(
Ivms101Originator {
originator_persons: vec![Ivms101Person {
natural_person: Some(Ivms101NaturalPerson {
name: Ivms101NaturalPersonName {
primary_identifier: "Doe".into(),
secondary_identifier: Some("Jane".into()),
name_identifier_type: "LEGL".into(),
},
geographic_address: vec![],
national_identification: None,
customer_identification: Some("did:tn:human:abc".into()),
date_and_place_of_birth: None,
country_of_residence: Some("US".into()),
}),
legal_person: None,
}],
account_number: vec!["eip155:1:0xabc".into()],
},
Ivms101Beneficiary {
beneficiary_persons: vec![Ivms101Person {
natural_person: Some(Ivms101NaturalPerson {
name: Ivms101NaturalPersonName {
primary_identifier: "Smith".into(),
secondary_identifier: Some("John".into()),
name_identifier_type: "LEGL".into(),
},
geographic_address: vec![],
national_identification: None,
customer_identification: Some("did:tn:human:xyz".into()),
date_and_place_of_birth: None,
country_of_residence: Some("US".into()),
}),
legal_person: None,
}],
account_number: vec!["eip155:1:0xdef".into()],
},
Ivms101Vasp {
legal_name: "Example VASP A".into(),
lei: Some("353800GDOIZJBCHQH482".into()),
country: "US".into(),
tenzro_did: Some("did:tn:vasp:a".into()),
},
Ivms101Vasp {
legal_name: "Example VASP B".into(),
lei: Some("549300LGDDJKR6SQYJ86".into()),
country: "DE".into(),
tenzro_did: Some("did:tn:vasp:b".into()),
},
Ivms101TransferData {
asset_caip19: "eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48".into(),
amount_smallest_unit: "1500000000".into(),
timestamp_iso8601: "2026-06-09T12:00:00Z".into(),
transaction_hash_hex: "deadbeef".into(),
iso20022_message_id: None,
},
)
}
#[test]
fn envelope_round_trips() {
let env = sample_envelope();
let json = serde_json::to_string(&env).unwrap();
let parsed: Ivms101Envelope = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.spec_version, Ivms101Envelope::CURRENT_SPEC_VERSION);
assert_eq!(parsed.originating_vasp.country, "US");
assert_eq!(parsed.beneficiary_vasp.country, "DE");
}
#[test]
fn canonical_hash_is_deterministic() {
let env1 = sample_envelope();
let env2 = sample_envelope();
assert_eq!(env1.canonical_hash(), env2.canonical_hash());
}
#[test]
fn canonical_hash_changes_on_amount_change() {
let env1 = sample_envelope();
let mut env2 = sample_envelope();
env2.transfer.amount_smallest_unit = "9999".into();
assert_ne!(env1.canonical_hash(), env2.canonical_hash());
}
#[test]
fn iso20022_message_round_trips() {
let m = Iso20022Message {
message_type: "pacs.008.001.10".into(),
message_id: "MSG-2026-06-09-0001".into(),
creation_iso8601: "2026-06-09T12:00:00Z".into(),
cre_intent_calldata_hex: Some("0xa9059cbb...".into()),
};
let json = serde_json::to_string(&m).unwrap();
let parsed: Iso20022Message = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.message_type, m.message_type);
}
#[test]
fn binding_serializes_envelope_hash_as_byte_array() {
let env = sample_envelope();
let b = Ivms101Binding {
envelope_hash: env.canonical_hash(),
originating_vasp_did: "did:tn:vasp:a".into(),
beneficiary_vasp_did: Some("did:tn:vasp:b".into()),
asset_caip19: env.transfer.asset_caip19.clone(),
amount_smallest_unit: env.transfer.amount_smallest_unit.clone(),
envelope_url: Some("https://vasp-a.example/trp/envelope/123".into()),
};
let json = serde_json::to_string(&b).unwrap();
let parsed: Ivms101Binding = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.envelope_hash, env.canonical_hash());
}
}