Skip to main content

arete_hash/protocol/
receipt.rs

1//! Execution receipts: a signed statement that one transaction of an
2//! execution intent landed with the attested memo of its deployment.
3//!
4//! A receipt is `arete.protocol-execution-receipt/v1` JSON. Its **body** is
5//! every field except `signatures`; each signature is an Ed25519 signature
6//! by a receipt key over [`receipt_signing_message`]:
7//!
8//! ```text
9//! arete.protocol-execution-receipt/v1
10//! <keyId>
11//! <lowercase hex SHA-256 of the body's Arete JCS bytes>
12//! ```
13//!
14//! The receipt keys are published as an `arete.protocol-receipt-keys/v1`
15//! key set. Anyone can check a receipt offline against that key set, and
16//! check its claims against the transaction itself, fetched from any RPC:
17//! the message hash, and the attested Memo v2
18//! (`arete:p2:<shortId>:<scope|->:<instance>:<participant|->:<step>:<n>/<count>`)
19//! signed by the attestation key. [`parse_memo_v2`] and [`memo_id`] give the
20//! memo form of the receipt's fields.
21//!
22//! A receipt has no hash kind: it is identified by its transaction
23//! signature.
24
25use 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
41/// At most this many receipt-key signatures, sorted by `keyId`.
42pub const MAX_RECEIPT_SIGNATURES: usize = 4;
43/// At most this many keys in a published key set.
44pub const MAX_RECEIPT_KEYS: usize = 32;
45/// The most transactions one intent can have.
46const 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/// A signed execution receipt.
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(rename_all = "camelCase", deny_unknown_fields)]
73pub struct ProtocolExecutionReceiptV1 {
74    pub schema: String,
75    /// The transaction's first signature, base58.
76    pub signature: String,
77    /// The slot the transaction landed in, as a decimal string.
78    pub slot: String,
79    pub deployment: ReceiptDeploymentV1,
80    pub trust_tier: TrustTier,
81    /// The release review, present exactly when `trustTier` is `reviewed`.
82    #[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    /// Lowercase hex SHA-256 of the exact message bytes.
90    pub message_sha256: String,
91    pub intent_hash: HashId<ExecutionIntent>,
92    /// The revision's attestation key: the memo's only account and a
93    /// signer of the message.
94    pub attestation_pubkey: String,
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub fees: Option<ReceiptFeesV1>,
97    pub status: ReceiptStatus,
98    /// Unix seconds.
99    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    /// The deployment short id the memo carries.
108    pub short_id: String,
109    pub revision: u32,
110    pub release_hash: HashId<ProtocolRelease>,
111}
112
113/// Which transaction of the intent this is: `index` counts from 0, the
114/// memo's `<n>/<count>` counts from 1.
115#[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/// The fee legs charged in this intent, in base units of `asset`.
123#[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    /// Base64 (standard, padded) Ed25519 signature over
143    /// [`receipt_signing_message`].
144    pub signature: String,
145}
146
147impl ProtocolExecutionReceiptV1 {
148    /// The body's Arete JCS bytes: the receipt without `signatures`.
149    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    /// Lowercase hex SHA-256 of [`Self::canonical_body`].
156    pub fn body_sha256(&self) -> Result<String, HashError> {
157        Ok(hex::encode(Sha256::digest(self.canonical_body()?)))
158    }
159
160    /// The bytes receipt key `key_id` signs.
161    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
166/// The body of a receipt JSON value (`signatures` removed) in Arete JCS
167/// form. Works on any receipt-shaped value, so verifiers can hash the body
168/// exactly as served.
169pub 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
176/// The exact bytes a receipt key signs:
177/// `arete.protocol-execution-receipt/v1\n<keyId>\n<bodySha256>`.
178pub 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
182/// A base58 Solana transaction signature (64 bytes).
183fn 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
200/// `^[A-Za-z0-9_-]{1,64}$`, the memo component alphabet.
201fn 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// ---------------------------------------------------------------------------
366// Key set
367// ---------------------------------------------------------------------------
368
369/// The published receipt keys. A retired key still verifies receipts issued
370/// before `retiredAt`, so old receipts stay checkable after a rotation.
371#[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    /// Base58 Ed25519 public key.
383    pub public_key: String,
384    /// Unix seconds. Receipts issued at or after this time do not verify
385    /// with this key.
386    #[serde(default, skip_serializing_if = "Option::is_none")]
387    pub retired_at: Option<i64>,
388}
389
390impl ProtocolReceiptKeySetV1 {
391    /// The key that may have signed a receipt issued at `issued_at`.
392    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// ---------------------------------------------------------------------------
437// Memo v2
438// ---------------------------------------------------------------------------
439
440/// The fields of an attested Memo v2:
441/// `arete:p2:<shortId>:<scope|->:<instance>:<participant|->:<step>:<n>/<count>`.
442#[derive(Debug, Clone, PartialEq, Eq)]
443pub struct MemoV2 {
444    pub short_id: String,
445    pub scope: Option<String>,
446    /// [`memo_id`] of the instance id.
447    pub instance: String,
448    /// [`memo_id`] of the participant id.
449    pub participant: Option<String>,
450    pub step: String,
451    /// 1-based.
452    pub n: u32,
453    pub count: u32,
454}
455
456/// Parse an attested Memo v2 text. `None` when it is not one.
457pub 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
494/// The memo form of an instance or participant UUID: base58 of its 16
495/// bytes. `None` for text that is not a lowercase UUID.
496pub 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}