use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ActionEnvelope {
pub version: String,
#[serde(rename = "type")]
pub action_type: String,
pub identity: String,
pub payload: Value,
pub timestamp: String,
pub signature: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attestation_chain: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub environment: Option<Value>,
}
#[derive(Debug, Serialize)]
pub struct ActionSigningData<'a> {
pub version: &'a str,
#[serde(rename = "type")]
pub action_type: &'a str,
pub identity: &'a str,
pub payload: &'a Value,
pub timestamp: &'a str,
}
impl ActionEnvelope {
pub fn signing_data(&self) -> ActionSigningData<'_> {
ActionSigningData {
version: &self.version,
action_type: &self.action_type,
identity: &self.identity,
payload: &self.payload,
timestamp: &self.timestamp,
}
}
pub fn canonical_bytes(&self) -> Result<Vec<u8>, String> {
let data = self.signing_data();
json_canon::to_string(&data)
.map(|s| s.into_bytes())
.map_err(|e| format!("canonicalization failed: {e}"))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip_serialization() {
let envelope = ActionEnvelope {
version: "1.0".into(),
action_type: "sign_commit".into(),
identity: "did:keri:Eabc123".into(),
payload: serde_json::json!({"hash": "abc123"}),
timestamp: "2024-01-01T00:00:00Z".into(),
signature: "deadbeef".into(),
attestation_chain: None,
environment: None,
};
let json = serde_json::to_string(&envelope).unwrap();
let parsed: ActionEnvelope = serde_json::from_str(&json).unwrap();
assert_eq!(envelope, parsed);
}
#[test]
fn type_field_renamed_in_json() {
let envelope = ActionEnvelope {
version: "1.0".into(),
action_type: "sign_commit".into(),
identity: "did:keri:Eabc123".into(),
payload: serde_json::json!({}),
timestamp: "2024-01-01T00:00:00Z".into(),
signature: "deadbeef".into(),
attestation_chain: None,
environment: None,
};
let json = serde_json::to_string(&envelope).unwrap();
assert!(json.contains("\"type\":"));
assert!(!json.contains("\"action_type\":"));
}
#[test]
fn optional_fields_omitted_when_none() {
let envelope = ActionEnvelope {
version: "1.0".into(),
action_type: "sign_commit".into(),
identity: "did:keri:Eabc123".into(),
payload: serde_json::json!({}),
timestamp: "2024-01-01T00:00:00Z".into(),
signature: "deadbeef".into(),
attestation_chain: None,
environment: None,
};
let json = serde_json::to_string(&envelope).unwrap();
assert!(!json.contains("attestation_chain"));
assert!(!json.contains("environment"));
}
#[test]
fn wire_compat_with_python_sdk_format() {
let python_wire = serde_json::json!({
"version": "1.0",
"type": "sign_commit",
"identity": "did:keri:Eabc123",
"payload": {"hash": "abc123"},
"timestamp": "2024-01-01T00:00:00Z",
"signature": "deadbeef"
});
let envelope: ActionEnvelope = serde_json::from_value(python_wire.clone()).unwrap();
assert_eq!(envelope.version, "1.0");
assert_eq!(envelope.action_type, "sign_commit");
let reserialized: serde_json::Value =
serde_json::from_str(&serde_json::to_string(&envelope).unwrap()).unwrap();
assert_eq!(python_wire, reserialized);
}
#[test]
fn canonical_bytes_excludes_signature() {
let envelope = ActionEnvelope {
version: "1.0".into(),
action_type: "sign_commit".into(),
identity: "did:keri:Eabc123".into(),
payload: serde_json::json!({"hash": "abc123"}),
timestamp: "2024-01-01T00:00:00Z".into(),
signature: "different_sig".into(),
attestation_chain: Some(serde_json::json!([])),
environment: Some(serde_json::json!({"region": "us-east-1"})),
};
let canonical = String::from_utf8(envelope.canonical_bytes().unwrap()).unwrap();
assert!(!canonical.contains("signature"));
assert!(!canonical.contains("attestation_chain"));
assert!(!canonical.contains("environment"));
assert!(canonical.contains("\"version\""));
assert!(canonical.contains("\"type\""));
}
}