1use serde::{Deserialize, Serialize};
26use serde_json::Value;
27use sha2::{Digest, Sha256};
28
29use super::error::{ProtocolContractError, ProtocolResult};
30use super::intent::{uuid, IntentFeeAssetV1};
31use super::release::{decode_base64, key_id, TrustTier};
32use super::shape::*;
33use super::stack::MEMO_V2_PREFIX;
34use crate::{
35 canonicalize_jcs, ExecutionIntent, HashError, HashId, ProtocolRelease, ProtocolReleaseReview,
36};
37
38pub const PROTOCOL_EXECUTION_RECEIPT_SCHEMA_V1: &str = "arete.protocol-execution-receipt/v1";
39pub const PROTOCOL_RECEIPT_KEYS_SCHEMA_V1: &str = "arete.protocol-receipt-keys/v1";
40
41pub const MAX_RECEIPT_SIGNATURES: usize = 4;
43pub const MAX_RECEIPT_KEYS: usize = 32;
45const MAX_TRANSACTIONS: i64 = 8;
47const U64_MAX: u128 = u64::MAX as u128;
48const MAX_SAFE: i64 = 9_007_199_254_740_991;
49
50const RECEIPT_KEYS: &[&str] = &[
51 "schema",
52 "signature",
53 "slot",
54 "deployment",
55 "trustTier",
56 "reviewHash",
57 "step",
58 "instanceId",
59 "participantId",
60 "transaction",
61 "messageSha256",
62 "intentHash",
63 "attestationPubkey",
64 "fees",
65 "status",
66 "issuedAt",
67 "signatures",
68];
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(rename_all = "camelCase", deny_unknown_fields)]
73pub struct ProtocolExecutionReceiptV1 {
74 pub schema: String,
75 pub signature: String,
77 pub slot: String,
79 pub deployment: ReceiptDeploymentV1,
80 pub trust_tier: TrustTier,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub review_hash: Option<HashId<ProtocolReleaseReview>>,
84 pub step: String,
85 pub instance_id: String,
86 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub participant_id: Option<String>,
88 pub transaction: ReceiptTransactionV1,
89 pub message_sha256: String,
91 pub intent_hash: HashId<ExecutionIntent>,
92 pub attestation_pubkey: String,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub fees: Option<ReceiptFeesV1>,
97 pub status: ReceiptStatus,
98 pub issued_at: i64,
100 pub signatures: Vec<ReceiptSignatureV1>,
101}
102
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104#[serde(rename_all = "camelCase", deny_unknown_fields)]
105pub struct ReceiptDeploymentV1 {
106 pub deployment_id: String,
107 pub short_id: String,
109 pub revision: u32,
110 pub release_hash: HashId<ProtocolRelease>,
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
116#[serde(rename_all = "camelCase", deny_unknown_fields)]
117pub struct ReceiptTransactionV1 {
118 pub index: u32,
119 pub count: u32,
120}
121
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
124#[serde(rename_all = "camelCase", deny_unknown_fields)]
125pub struct ReceiptFeesV1 {
126 pub asset: IntentFeeAssetV1,
127 pub platform: String,
128 pub creator: String,
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
132#[serde(rename_all = "camelCase")]
133pub enum ReceiptStatus {
134 Confirmed,
135 Finalized,
136}
137
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
139#[serde(rename_all = "camelCase", deny_unknown_fields)]
140pub struct ReceiptSignatureV1 {
141 pub key_id: String,
142 pub signature: String,
145}
146
147impl ProtocolExecutionReceiptV1 {
148 pub fn canonical_body(&self) -> Result<Vec<u8>, HashError> {
150 let mut body = serde_json::to_value(self)
151 .map_err(|error| HashError::Serialization(error.to_string()))?;
152 receipt_body_bytes(&mut body)
153 }
154
155 pub fn body_sha256(&self) -> Result<String, HashError> {
157 Ok(hex::encode(Sha256::digest(self.canonical_body()?)))
158 }
159
160 pub fn signing_message(&self, key_id: &str) -> Result<Vec<u8>, HashError> {
162 Ok(receipt_signing_message(key_id, &self.body_sha256()?))
163 }
164}
165
166pub fn receipt_body_bytes(receipt: &mut Value) -> Result<Vec<u8>, HashError> {
170 if let Value::Object(map) = receipt {
171 map.remove("signatures");
172 }
173 canonicalize_jcs(receipt)
174}
175
176pub fn receipt_signing_message(key_id: &str, body_sha256: &str) -> Vec<u8> {
179 format!("{PROTOCOL_EXECUTION_RECEIPT_SCHEMA_V1}\n{key_id}\n{body_sha256}").into_bytes()
180}
181
182fn transaction_signature<'a>(value: &'a Value, path: &str) -> ProtocolResult<&'a str> {
184 let text = as_str(value, path)?;
185 let valid = text.len() <= 88
186 && bs58::decode(text)
187 .into_vec()
188 .is_ok_and(|bytes| bytes.len() == 64);
189 if valid {
190 Ok(text)
191 } else {
192 Err(err(
193 "invalid-signature",
194 path,
195 "expected a base58 64-byte transaction signature",
196 ))
197 }
198}
199
200fn short_id<'a>(value: &'a Value, path: &str) -> ProtocolResult<&'a str> {
202 let text = as_str(value, path)?;
203 let valid = (1..=64).contains(&text.len())
204 && text
205 .bytes()
206 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-');
207 if valid {
208 Ok(text)
209 } else {
210 Err(err(
211 "invalid-identifier",
212 path,
213 "short ids match ^[A-Za-z0-9_-]{1,64}$",
214 ))
215 }
216}
217
218fn base64_signature(value: &Value, path: &str) -> ProtocolResult<()> {
219 let text = as_str(value, path)?;
220 match decode_base64(text) {
221 Some(bytes) if bytes.len() == 64 => Ok(()),
222 _ => Err(err(
223 "invalid-signature",
224 path,
225 "expected a base64 64-byte Ed25519 signature",
226 )),
227 }
228}
229
230fn model<T: serde::de::DeserializeOwned>(value: &Value) -> ProtocolResult<T> {
231 serde_json::from_value(value.clone()).map_err(|error| {
232 ProtocolContractError::new("invalid-value", "", format!("model mismatch: {error}"))
233 })
234}
235
236pub fn validate_protocol_execution_receipt_value(
237 value: &Value,
238) -> ProtocolResult<ProtocolExecutionReceiptV1> {
239 reject_floats(value, "")?;
240 let root = Obj::new(value, "", RECEIPT_KEYS)?;
241 schema_tag(
242 root.req("schema")?,
243 "/schema",
244 PROTOCOL_EXECUTION_RECEIPT_SCHEMA_V1,
245 )?;
246 transaction_signature(root.req("signature")?, "/signature")?;
247 decimal(root.req("slot")?, "/slot", U64_MAX)?;
248 let deployment = Obj::new(
249 root.req("deployment")?,
250 "/deployment",
251 &["deploymentId", "shortId", "revision", "releaseHash"],
252 )?;
253 uuid(deployment.req("deploymentId")?, "/deployment/deploymentId")?;
254 short_id(deployment.req("shortId")?, "/deployment/shortId")?;
255 as_integer(
256 deployment.req("revision")?,
257 "/deployment/revision",
258 1,
259 u32::MAX as i64,
260 )?;
261 hash_id::<ProtocolRelease>(deployment.req("releaseHash")?, "/deployment/releaseHash")?;
262 let tier = one_of(root.req("trustTier")?, "/trustTier", &TrustTier::ALL)?;
263 match (tier, root.opt("reviewHash")?) {
264 ("reviewed", None) => {
265 return Err(err(
266 "missing-field",
267 "/reviewHash",
268 "a reviewed receipt carries 'reviewHash'",
269 ))
270 }
271 ("reviewed", Some(review)) => {
272 hash_id::<ProtocolReleaseReview>(review, "/reviewHash")?;
273 }
274 (_, Some(_)) => {
275 return Err(err(
276 "invalid-value",
277 "/reviewHash",
278 "an unreviewed receipt carries no 'reviewHash'",
279 ))
280 }
281 (_, None) => {}
282 }
283 identifier_value(root.req("step")?, "/step")?;
284 uuid(root.req("instanceId")?, "/instanceId")?;
285 if let Some(participant) = root.opt("participantId")? {
286 uuid(participant, "/participantId")?;
287 }
288 let transaction = Obj::new(
289 root.req("transaction")?,
290 "/transaction",
291 &["index", "count"],
292 )?;
293 let position = as_integer(
294 transaction.req("index")?,
295 "/transaction/index",
296 0,
297 MAX_TRANSACTIONS - 1,
298 )?;
299 let count = as_integer(
300 transaction.req("count")?,
301 "/transaction/count",
302 1,
303 MAX_TRANSACTIONS,
304 )?;
305 if position >= count {
306 return Err(err(
307 "invalid-value",
308 "/transaction/index",
309 "index must be below count",
310 ));
311 }
312 sha256_hex(root.req("messageSha256")?, "/messageSha256")?;
313 hash_id::<ExecutionIntent>(root.req("intentHash")?, "/intentHash")?;
314 pubkey(root.req("attestationPubkey")?, "/attestationPubkey")?;
315 if let Some(fees) = root.opt("fees")? {
316 let fees = Obj::new(fees, "/fees", &["asset", "platform", "creator"])?;
317 let (kind, asset) = super::spec::tagged(
318 fees.req("asset")?,
319 "/fees/asset",
320 &[("native", &[]), ("spl", &["mint"])],
321 )?;
322 if kind == "spl" {
323 pubkey(asset.req("mint")?, "/fees/asset/mint")?;
324 }
325 decimal(fees.req("platform")?, "/fees/platform", U64_MAX)?;
326 decimal(fees.req("creator")?, "/fees/creator", U64_MAX)?;
327 }
328 one_of(root.req("status")?, "/status", &["confirmed", "finalized"])?;
329 as_integer(root.req("issuedAt")?, "/issuedAt", 0, MAX_SAFE)?;
330 let signatures = as_array(
331 root.req("signatures")?,
332 "/signatures",
333 MAX_RECEIPT_SIGNATURES,
334 )?;
335 if signatures.is_empty() {
336 return Err(err(
337 "invalid-value",
338 "/signatures",
339 "a receipt carries at least one signature",
340 ));
341 }
342 let mut previous: Option<&str> = None;
343 for (i, signature) in signatures.iter().enumerate() {
344 let path = index("/signatures", i);
345 let signature = Obj::new(signature, &path, &["keyId", "signature"])?;
346 let key = key_id(signature.req("keyId")?, &signature.path_of("keyId"))?;
347 base64_signature(signature.req("signature")?, &signature.path_of("signature"))?;
348 if let Some(previous) = previous {
349 if key == previous {
350 return Err(err(
351 "duplicate-entry",
352 &path,
353 format!("'{key}' is listed twice"),
354 ));
355 }
356 if key < previous {
357 return Err(err("unsorted-entries", &path, "entries must be sorted"));
358 }
359 }
360 previous = Some(key);
361 }
362 model(value)
363}
364
365#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
372#[serde(rename_all = "camelCase", deny_unknown_fields)]
373pub struct ProtocolReceiptKeySetV1 {
374 pub schema: String,
375 pub keys: Vec<ProtocolReceiptKeyV1>,
376}
377
378#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
379#[serde(rename_all = "camelCase", deny_unknown_fields)]
380pub struct ProtocolReceiptKeyV1 {
381 pub key_id: String,
382 pub public_key: String,
384 #[serde(default, skip_serializing_if = "Option::is_none")]
387 pub retired_at: Option<i64>,
388}
389
390impl ProtocolReceiptKeySetV1 {
391 pub fn usable_key(&self, key_id: &str, issued_at: i64) -> Option<&ProtocolReceiptKeyV1> {
393 self.keys.iter().find(|key| {
394 key.key_id == key_id && key.retired_at.is_none_or(|retired| issued_at < retired)
395 })
396 }
397}
398
399pub fn validate_protocol_receipt_key_set_value(
400 value: &Value,
401) -> ProtocolResult<ProtocolReceiptKeySetV1> {
402 reject_floats(value, "")?;
403 let root = Obj::new(value, "", &["schema", "keys"])?;
404 schema_tag(
405 root.req("schema")?,
406 "/schema",
407 PROTOCOL_RECEIPT_KEYS_SCHEMA_V1,
408 )?;
409 let keys = as_array(root.req("keys")?, "/keys", MAX_RECEIPT_KEYS)?;
410 let mut previous: Option<&str> = None;
411 for (i, key) in keys.iter().enumerate() {
412 let path = index("/keys", i);
413 let key = Obj::new(key, &path, &["keyId", "publicKey", "retiredAt"])?;
414 let id = key_id(key.req("keyId")?, &key.path_of("keyId"))?;
415 pubkey(key.req("publicKey")?, &key.path_of("publicKey"))?;
416 if let Some(retired) = key.opt("retiredAt")? {
417 as_integer(retired, &key.path_of("retiredAt"), 0, MAX_SAFE)?;
418 }
419 if let Some(previous) = previous {
420 if id == previous {
421 return Err(err(
422 "duplicate-entry",
423 &path,
424 format!("'{id}' is listed twice"),
425 ));
426 }
427 if id < previous {
428 return Err(err("unsorted-entries", &path, "entries must be sorted"));
429 }
430 }
431 previous = Some(id);
432 }
433 model(value)
434}
435
436#[derive(Debug, Clone, PartialEq, Eq)]
443pub struct MemoV2 {
444 pub short_id: String,
445 pub scope: Option<String>,
446 pub instance: String,
448 pub participant: Option<String>,
450 pub step: String,
451 pub n: u32,
453 pub count: u32,
454}
455
456pub fn parse_memo_v2(text: &str) -> Option<MemoV2> {
458 let rest = text.strip_prefix(MEMO_V2_PREFIX)?;
459 let parts: Vec<&str> = rest.split(':').collect();
460 let [short_id, scope, instance, participant, step, position] = parts.as_slice() else {
461 return None;
462 };
463 let component = |text: &str| {
464 !text.is_empty()
465 && text
466 .bytes()
467 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
468 };
469 if ![*short_id, *scope, *instance, *participant, *step]
470 .into_iter()
471 .all(component)
472 {
473 return None;
474 }
475 let (n, count) = position.split_once('/')?;
476 let number = |text: &str| -> Option<u32> {
477 if text.is_empty() || !text.bytes().all(|b| b.is_ascii_digit()) || text.starts_with('0') {
478 return None;
479 }
480 text.parse().ok()
481 };
482 let optional = |text: &str| (text != "-").then(|| text.to_string());
483 Some(MemoV2 {
484 short_id: short_id.to_string(),
485 scope: optional(scope),
486 instance: instance.to_string(),
487 participant: optional(participant),
488 step: step.to_string(),
489 n: number(n)?,
490 count: number(count)?,
491 })
492}
493
494pub fn memo_id(uuid_text: &str) -> Option<String> {
497 let hex_text: String = uuid_text.chars().filter(|c| *c != '-').collect();
498 if uuid(&Value::String(uuid_text.to_string()), "").is_err() {
499 return None;
500 }
501 let bytes = hex::decode(hex_text).ok()?;
502 Some(bs58::encode(bytes).into_string())
503}
504
505#[cfg(test)]
506mod tests {
507 use super::*;
508
509 #[test]
510 fn memo_v2_round_trips_the_fields() {
511 let memo = parse_memo_v2("arete:p2:raise1:-:3mJr7AoUXx2Wqd:-:open:1/2").unwrap();
512 assert_eq!(memo.short_id, "raise1");
513 assert_eq!(memo.scope, None);
514 assert_eq!(memo.participant, None);
515 assert_eq!(memo.step, "open");
516 assert_eq!((memo.n, memo.count), (1, 2));
517 for bad in [
518 "arete:p1:a:-:b:-:s:1/1",
519 "arete:p2:a:-:b:-:s:1",
520 "arete:p2:a:-:b:-:s:01/1",
521 "arete:p2:a:-:b:-:s:1/1:extra",
522 "arete:p2::-:b:-:s:1/1",
523 "arete:p2:a:-:b c:-:s:1/1",
524 ] {
525 assert_eq!(parse_memo_v2(bad), None, "{bad}");
526 }
527 }
528
529 #[test]
530 fn memo_ids_are_base58_of_the_uuid_bytes() {
531 assert_eq!(
532 memo_id("00000000-0000-0000-0000-000000000000").as_deref(),
533 Some("1111111111111111")
534 );
535 assert_eq!(memo_id("not-a-uuid"), None);
536 assert_eq!(memo_id("6F1C2B9A-1D3E-4F5A-8B7C-9D0E1F2A3B4C"), None);
537 }
538}