1use crate::{signing, Did, Error, Result, RootKey};
4use chrono::{DateTime, Utc};
5use ed25519_dalek::{Signature, Verifier};
6use serde::{Deserialize, Serialize};
7use sha2::{Digest, Sha256};
8use std::collections::HashMap;
9use uuid::Uuid;
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum InteractionType {
15 Message,
17 Reply,
19 Collaboration,
21 Transaction,
23 Endorsement,
25 Dispute,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32pub enum InteractionOutcome {
33 Completed,
35 InProgress,
37 Cancelled,
39 Failed,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
45#[serde(rename_all = "camelCase")]
46pub struct InteractionContext {
47 pub platform: String,
49
50 pub channel: String,
52
53 pub interaction_type: InteractionType,
55
56 #[serde(skip_serializing_if = "Option::is_none")]
58 pub content_hash: Option<String>,
59
60 #[serde(skip_serializing_if = "Option::is_none")]
62 pub parent_id: Option<String>,
63
64 #[serde(skip_serializing_if = "Option::is_none")]
66 pub metadata: Option<serde_json::Value>,
67}
68
69impl InteractionContext {
70 pub fn new(
72 platform: impl Into<String>,
73 channel: impl Into<String>,
74 interaction_type: InteractionType,
75 ) -> Self {
76 Self {
77 platform: platform.into(),
78 channel: channel.into(),
79 interaction_type,
80 content_hash: None,
81 parent_id: None,
82 metadata: None,
83 }
84 }
85
86 pub fn with_content(mut self, content: &[u8]) -> Self {
88 let hash = Sha256::digest(content);
89 self.content_hash = Some(format!("sha256:{}", hex::encode(hash)));
90 self
91 }
92
93 pub fn with_parent(mut self, parent_id: impl Into<String>) -> Self {
95 self.parent_id = Some(parent_id.into());
96 self
97 }
98
99 pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self {
101 self.metadata = Some(metadata);
102 self
103 }
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
108#[serde(rename_all = "camelCase")]
109pub struct ParticipantSignature {
110 pub key: String,
112
113 pub sig: String,
115
116 pub signed_at: i64,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
122#[serde(rename_all = "camelCase")]
123pub struct InteractionReceipt {
124 #[serde(rename = "type")]
126 pub type_: String,
127
128 pub version: String,
130
131 pub id: String,
133
134 pub participants: Vec<String>,
136
137 pub initiator: String,
139
140 pub timestamp: i64,
142
143 pub context: InteractionContext,
145
146 pub outcome: InteractionOutcome,
148
149 #[serde(default)]
151 pub signatures: HashMap<String, ParticipantSignature>,
152}
153
154impl InteractionReceipt {
155 pub fn new(initiator: Did, participants: Vec<Did>, context: InteractionContext) -> Self {
157 Self {
158 type_: "InteractionReceipt".to_string(),
159 version: "1.0".to_string(),
160 id: Uuid::now_v7().to_string(),
161 participants: participants.iter().map(|d| d.to_string()).collect(),
162 initiator: initiator.to_string(),
163 timestamp: Utc::now().timestamp_millis(),
164 context,
165 outcome: InteractionOutcome::InProgress,
166 signatures: HashMap::new(),
167 }
168 }
169
170 pub fn with_outcome(mut self, outcome: InteractionOutcome) -> Self {
172 self.outcome = outcome;
173 self
174 }
175
176 pub fn at(mut self, time: DateTime<Utc>) -> Self {
178 self.timestamp = time.timestamp_millis();
179 self
180 }
181
182 fn signing_data(&self) -> Result<Vec<u8>> {
184 let data = serde_json::json!({
185 "type": self.type_,
186 "version": self.version,
187 "id": self.id,
188 "participants": self.participants,
189 "initiator": self.initiator,
190 "timestamp": self.timestamp,
191 "context": self.context,
192 "outcome": self.outcome,
193 });
194 signing::canonicalize(&data)
195 }
196
197 pub fn sign(&mut self, signer: &RootKey, key_id: impl Into<String>) -> Result<()> {
199 let did = signer.did().to_string();
200
201 if !self.participants.contains(&did) {
203 return Err(Error::Validation("Signer is not a participant".into()));
204 }
205
206 let canonical = self.signing_data()?;
207 let sig = signer.sign(&canonical);
208
209 self.signatures.insert(
210 did,
211 ParticipantSignature {
212 key: key_id.into(),
213 sig: base64::Engine::encode(
214 &base64::engine::general_purpose::STANDARD,
215 sig.to_bytes(),
216 ),
217 signed_at: Utc::now().timestamp_millis(),
218 },
219 );
220
221 Ok(())
222 }
223
224 pub fn verify_participant(&self, participant_did: &str) -> Result<()> {
226 let sig_data = self
227 .signatures
228 .get(participant_did)
229 .ok_or_else(|| Error::Validation("No signature from participant".into()))?;
230
231 let sig_bytes =
232 base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &sig_data.sig)
233 .map_err(|_| Error::InvalidSignature)?;
234
235 let signature =
236 Signature::from_bytes(&sig_bytes.try_into().map_err(|_| Error::InvalidSignature)?);
237
238 let did: Did = participant_did.parse()?;
239 let public_key = did.public_key()?;
240
241 let canonical = self.signing_data()?;
242
243 public_key
244 .verify(&canonical, &signature)
245 .map_err(|_| Error::InvalidSignature)
246 }
247
248 pub fn verify_all(&self) -> Result<()> {
250 for did in self.signatures.keys() {
251 self.verify_participant(did)?;
252 }
253 Ok(())
254 }
255
256 pub fn is_fully_signed(&self) -> bool {
258 self.participants
259 .iter()
260 .all(|p| self.signatures.contains_key(p))
261 }
262
263 pub fn pending_signatures(&self) -> Vec<&str> {
265 self.participants
266 .iter()
267 .filter(|p| !self.signatures.contains_key(*p))
268 .map(|s| s.as_str())
269 .collect()
270 }
271
272 pub fn signature_count(&self) -> usize {
274 self.signatures.len()
275 }
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281
282 #[test]
283 fn test_create_receipt() {
284 let agent_a = RootKey::generate();
285 let agent_b = RootKey::generate();
286
287 let context = InteractionContext::new("moltbook", "public_post", InteractionType::Reply)
288 .with_content(b"Hello, world!");
289
290 let receipt =
291 InteractionReceipt::new(agent_a.did(), vec![agent_a.did(), agent_b.did()], context);
292
293 assert_eq!(receipt.participants.len(), 2);
294 assert_eq!(receipt.initiator, agent_a.did().to_string());
295 assert!(receipt.context.content_hash.is_some());
296 }
297
298 #[test]
299 fn test_sign_receipt() {
300 let agent_a = RootKey::generate();
301 let agent_b = RootKey::generate();
302
303 let context = InteractionContext::new("discord", "dm", InteractionType::Message);
304
305 let mut receipt =
306 InteractionReceipt::new(agent_a.did(), vec![agent_a.did(), agent_b.did()], context)
307 .with_outcome(InteractionOutcome::Completed);
308
309 receipt
311 .sign(&agent_a, format!("{}#session-1", agent_a.did()))
312 .unwrap();
313 receipt
314 .sign(&agent_b, format!("{}#session-1", agent_b.did()))
315 .unwrap();
316
317 assert!(receipt.is_fully_signed());
318 receipt.verify_all().unwrap();
319 }
320
321 #[test]
322 fn test_partial_signatures() {
323 let agent_a = RootKey::generate();
324 let agent_b = RootKey::generate();
325
326 let context = InteractionContext::new("moltbook", "post", InteractionType::Endorsement);
327
328 let mut receipt =
329 InteractionReceipt::new(agent_a.did(), vec![agent_a.did(), agent_b.did()], context);
330
331 receipt
332 .sign(&agent_a, format!("{}#root", agent_a.did()))
333 .unwrap();
334
335 assert!(!receipt.is_fully_signed());
336 assert_eq!(receipt.pending_signatures().len(), 1);
337 assert_eq!(receipt.signature_count(), 1);
338 }
339
340 #[test]
341 fn test_non_participant_cannot_sign() {
342 let agent_a = RootKey::generate();
343 let agent_b = RootKey::generate();
344 let outsider = RootKey::generate();
345
346 let context = InteractionContext::new("platform", "channel", InteractionType::Message);
347
348 let mut receipt =
349 InteractionReceipt::new(agent_a.did(), vec![agent_a.did(), agent_b.did()], context);
350
351 let result = receipt.sign(&outsider, format!("{}#root", outsider.did()));
352 assert!(result.is_err());
353 }
354
355 #[test]
356 fn test_reply_context() {
357 let _agent = RootKey::generate();
358
359 let context = InteractionContext::new("twitter", "reply", InteractionType::Reply)
360 .with_parent("parent-tweet-id-123")
361 .with_content(b"Great point!")
362 .with_metadata(serde_json::json!({"likes": 42}));
363
364 assert_eq!(context.parent_id, Some("parent-tweet-id-123".to_string()));
365 assert!(context.content_hash.is_some());
366 assert!(context.metadata.is_some());
367 }
368}