use crate::{signing, Did, Error, Result, RootKey};
use chrono::{DateTime, Utc};
use ed25519_dalek::{Signature, Verifier};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InteractionType {
Message,
Reply,
Collaboration,
Transaction,
Endorsement,
Dispute,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InteractionOutcome {
Completed,
InProgress,
Cancelled,
Failed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InteractionContext {
pub platform: String,
pub channel: String,
pub interaction_type: InteractionType,
#[serde(skip_serializing_if = "Option::is_none")]
pub content_hash: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<serde_json::Value>,
}
impl InteractionContext {
pub fn new(
platform: impl Into<String>,
channel: impl Into<String>,
interaction_type: InteractionType,
) -> Self {
Self {
platform: platform.into(),
channel: channel.into(),
interaction_type,
content_hash: None,
parent_id: None,
metadata: None,
}
}
pub fn with_content(mut self, content: &[u8]) -> Self {
let hash = Sha256::digest(content);
self.content_hash = Some(format!("sha256:{}", hex::encode(hash)));
self
}
pub fn with_parent(mut self, parent_id: impl Into<String>) -> Self {
self.parent_id = Some(parent_id.into());
self
}
pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self {
self.metadata = Some(metadata);
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ParticipantSignature {
pub key: String,
pub sig: String,
pub signed_at: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InteractionReceipt {
#[serde(rename = "type")]
pub type_: String,
pub version: String,
pub id: String,
pub participants: Vec<String>,
pub initiator: String,
pub timestamp: i64,
pub context: InteractionContext,
pub outcome: InteractionOutcome,
#[serde(default)]
pub signatures: HashMap<String, ParticipantSignature>,
}
impl InteractionReceipt {
pub fn new(initiator: Did, participants: Vec<Did>, context: InteractionContext) -> Self {
Self {
type_: "InteractionReceipt".to_string(),
version: "1.0".to_string(),
id: Uuid::now_v7().to_string(),
participants: participants.iter().map(|d| d.to_string()).collect(),
initiator: initiator.to_string(),
timestamp: Utc::now().timestamp_millis(),
context,
outcome: InteractionOutcome::InProgress,
signatures: HashMap::new(),
}
}
pub fn with_outcome(mut self, outcome: InteractionOutcome) -> Self {
self.outcome = outcome;
self
}
pub fn at(mut self, time: DateTime<Utc>) -> Self {
self.timestamp = time.timestamp_millis();
self
}
fn signing_data(&self) -> Result<Vec<u8>> {
let data = serde_json::json!({
"type": self.type_,
"version": self.version,
"id": self.id,
"participants": self.participants,
"initiator": self.initiator,
"timestamp": self.timestamp,
"context": self.context,
"outcome": self.outcome,
});
signing::canonicalize(&data)
}
pub fn sign(&mut self, signer: &RootKey, key_id: impl Into<String>) -> Result<()> {
let did = signer.did().to_string();
if !self.participants.contains(&did) {
return Err(Error::Validation("Signer is not a participant".into()));
}
let canonical = self.signing_data()?;
let sig = signer.sign(&canonical);
self.signatures.insert(
did,
ParticipantSignature {
key: key_id.into(),
sig: base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
sig.to_bytes(),
),
signed_at: Utc::now().timestamp_millis(),
},
);
Ok(())
}
pub fn verify_participant(&self, participant_did: &str) -> Result<()> {
let sig_data = self
.signatures
.get(participant_did)
.ok_or_else(|| Error::Validation("No signature from participant".into()))?;
let sig_bytes =
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &sig_data.sig)
.map_err(|_| Error::InvalidSignature)?;
let signature =
Signature::from_bytes(&sig_bytes.try_into().map_err(|_| Error::InvalidSignature)?);
let did: Did = participant_did.parse()?;
let public_key = did.public_key()?;
let canonical = self.signing_data()?;
public_key
.verify(&canonical, &signature)
.map_err(|_| Error::InvalidSignature)
}
pub fn verify_all(&self) -> Result<()> {
for did in self.signatures.keys() {
self.verify_participant(did)?;
}
Ok(())
}
pub fn is_fully_signed(&self) -> bool {
self.participants
.iter()
.all(|p| self.signatures.contains_key(p))
}
pub fn pending_signatures(&self) -> Vec<&str> {
self.participants
.iter()
.filter(|p| !self.signatures.contains_key(*p))
.map(|s| s.as_str())
.collect()
}
pub fn signature_count(&self) -> usize {
self.signatures.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_create_receipt() {
let agent_a = RootKey::generate();
let agent_b = RootKey::generate();
let context = InteractionContext::new("moltbook", "public_post", InteractionType::Reply)
.with_content(b"Hello, world!");
let receipt =
InteractionReceipt::new(agent_a.did(), vec![agent_a.did(), agent_b.did()], context);
assert_eq!(receipt.participants.len(), 2);
assert_eq!(receipt.initiator, agent_a.did().to_string());
assert!(receipt.context.content_hash.is_some());
}
#[test]
fn test_sign_receipt() {
let agent_a = RootKey::generate();
let agent_b = RootKey::generate();
let context = InteractionContext::new("discord", "dm", InteractionType::Message);
let mut receipt =
InteractionReceipt::new(agent_a.did(), vec![agent_a.did(), agent_b.did()], context)
.with_outcome(InteractionOutcome::Completed);
receipt
.sign(&agent_a, format!("{}#session-1", agent_a.did()))
.unwrap();
receipt
.sign(&agent_b, format!("{}#session-1", agent_b.did()))
.unwrap();
assert!(receipt.is_fully_signed());
receipt.verify_all().unwrap();
}
#[test]
fn test_partial_signatures() {
let agent_a = RootKey::generate();
let agent_b = RootKey::generate();
let context = InteractionContext::new("moltbook", "post", InteractionType::Endorsement);
let mut receipt =
InteractionReceipt::new(agent_a.did(), vec![agent_a.did(), agent_b.did()], context);
receipt
.sign(&agent_a, format!("{}#root", agent_a.did()))
.unwrap();
assert!(!receipt.is_fully_signed());
assert_eq!(receipt.pending_signatures().len(), 1);
assert_eq!(receipt.signature_count(), 1);
}
#[test]
fn test_non_participant_cannot_sign() {
let agent_a = RootKey::generate();
let agent_b = RootKey::generate();
let outsider = RootKey::generate();
let context = InteractionContext::new("platform", "channel", InteractionType::Message);
let mut receipt =
InteractionReceipt::new(agent_a.did(), vec![agent_a.did(), agent_b.did()], context);
let result = receipt.sign(&outsider, format!("{}#root", outsider.did()));
assert!(result.is_err());
}
#[test]
fn test_reply_context() {
let _agent = RootKey::generate();
let context = InteractionContext::new("twitter", "reply", InteractionType::Reply)
.with_parent("parent-tweet-id-123")
.with_content(b"Great point!")
.with_metadata(serde_json::json!({"likes": 42}));
assert_eq!(context.parent_id, Some("parent-tweet-id-123".to_string()));
assert!(context.content_hash.is_some());
assert!(context.metadata.is_some());
}
}