Skip to main content

arete_hash/protocol/
intent.rs

1//! Execution-side contracts: provider snapshots and execution intents.
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6use super::error::{ProtocolContractError, ProtocolResult};
7use super::model::SignerClass;
8use super::release::sorted_entries;
9use super::shape::*;
10use crate::{
11    hash_jcs, ExecutionIntent, HashError, HashId, ProtocolRelease, ProtocolResource,
12    ProviderSnapshot,
13};
14
15pub const PROVIDER_SNAPSHOT_SCHEMA_V1: &str = "arete.provider-snapshot/v1";
16pub const EXECUTION_INTENT_SCHEMA_V1: &str = "arete.execution-intent/v1";
17
18pub const MAX_CTX_CALLS: usize = 64;
19pub const MAX_INTENT_TRANSACTIONS: usize = 8;
20const MAX_SNAPSHOT_READS: usize = 64;
21const MAX_INTENT_SIGNERS: usize = 32;
22const U64_MAX: u128 = u64::MAX as u128;
23const MAX_SAFE: i64 = 9_007_199_254_740_991;
24
25fn model<T: serde::de::DeserializeOwned>(value: &Value) -> ProtocolResult<T> {
26    serde_json::from_value(value.clone()).map_err(|error| {
27        ProtocolContractError::new("invalid-value", "", format!("model mismatch: {error}"))
28    })
29}
30
31// ---------------------------------------------------------------------------
32// Provider snapshot
33// ---------------------------------------------------------------------------
34
35#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
36#[serde(rename_all = "camelCase", deny_unknown_fields)]
37pub struct ProviderSnapshotV1 {
38    pub schema: String,
39    /// Every read and `ctx` call used this slot or a later one.
40    pub context_slot: String,
41    /// Declared-read results, sorted by name.
42    pub reads: Vec<SnapshotReadV1>,
43    /// The ordered `ctx` call log; `seq` counts from 0.
44    pub ctx_calls: Vec<CtxCallRecordV1>,
45    /// Unix seconds.
46    pub observed_at: i64,
47    pub expires_at: i64,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(rename_all = "camelCase", deny_unknown_fields)]
52pub struct SnapshotReadV1 {
53    pub name: String,
54    pub result: AccountReadResultV1,
55}
56
57/// One account as read from chain. A missing account has `exists: false` and
58/// no other data fields.
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(rename_all = "camelCase", deny_unknown_fields)]
61pub struct AccountReadResultV1 {
62    pub address: String,
63    pub exists: bool,
64    pub slot: String,
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub lamports: Option<String>,
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub owner: Option<String>,
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub data_base64: Option<String>,
71}
72
73#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
74#[serde(rename_all = "camelCase", deny_unknown_fields)]
75pub struct CtxCallRecordV1 {
76    pub seq: u32,
77    pub method: CtxMethod,
78    /// Canonical call arguments.
79    pub args: Value,
80    /// Lowercase hex SHA-256 of the canonical JSON result.
81    pub result_sha256: String,
82}
83
84/// The closed `ctx` method set of `arete.protocol-ctx/v1`.
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
86pub enum CtxMethod {
87    #[serde(rename = "solana.account")]
88    SolanaAccount,
89    #[serde(rename = "solana.accounts")]
90    SolanaAccounts,
91    #[serde(rename = "solana.tokenAccountsByOwner")]
92    SolanaTokenAccountsByOwner,
93    #[serde(rename = "stacks.view")]
94    StacksView,
95}
96
97impl CtxMethod {
98    pub const ALL: [&'static str; 4] = [
99        "solana.account",
100        "solana.accounts",
101        "solana.tokenAccountsByOwner",
102        "stacks.view",
103    ];
104}
105
106impl ProviderSnapshotV1 {
107    pub fn hash(&self) -> Result<HashId<ProviderSnapshot>, HashError> {
108        hash_jcs::<ProviderSnapshot, _>(self)
109    }
110}
111
112pub(crate) fn shape_account_read(value: &Value, path: &str) -> ProtocolResult<()> {
113    let read = Obj::new(
114        value,
115        path,
116        &[
117            "address",
118            "exists",
119            "slot",
120            "lamports",
121            "owner",
122            "dataBase64",
123        ],
124    )?;
125    pubkey(read.req("address")?, &read.path_of("address"))?;
126    let exists = as_bool(read.req("exists")?, &read.path_of("exists"))?;
127    decimal(read.req("slot")?, &read.path_of("slot"), U64_MAX)?;
128    for key in ["lamports", "owner", "dataBase64"] {
129        let present = read.opt(key)?.is_some();
130        if present != exists {
131            let message = if exists {
132                format!("existing accounts carry '{key}'")
133            } else {
134                format!("missing accounts carry no '{key}'")
135            };
136            let code = if exists {
137                "missing-field"
138            } else {
139                "invalid-value"
140            };
141            return Err(err(code, &read.path_of(key), message));
142        }
143    }
144    if exists {
145        decimal(read.req("lamports")?, &read.path_of("lamports"), U64_MAX)?;
146        pubkey(read.req("owner")?, &read.path_of("owner"))?;
147        let data = as_str(read.req("dataBase64")?, &read.path_of("dataBase64"))?;
148        if super::release::decode_base64(data).is_none() {
149            return Err(err(
150                "invalid-value",
151                &read.path_of("dataBase64"),
152                "expected canonical base64",
153            ));
154        }
155    }
156    Ok(())
157}
158
159pub fn validate_provider_snapshot_value(value: &Value) -> ProtocolResult<ProviderSnapshotV1> {
160    reject_floats(value, "")?;
161    let root = Obj::new(
162        value,
163        "",
164        &[
165            "schema",
166            "contextSlot",
167            "reads",
168            "ctxCalls",
169            "observedAt",
170            "expiresAt",
171        ],
172    )?;
173    schema_tag(root.req("schema")?, "/schema", PROVIDER_SNAPSHOT_SCHEMA_V1)?;
174    decimal(root.req("contextSlot")?, "/contextSlot", U64_MAX)?;
175    let reads = root.req("reads")?;
176    as_array(reads, "/reads", MAX_SNAPSHOT_READS)?;
177    sorted_entries(reads, "/reads", |entry, path| {
178        let entry = Obj::new(entry, path, &["name", "result"])?;
179        let name = identifier_value(entry.req("name")?, &entry.path_of("name"))?;
180        shape_account_read(entry.req("result")?, &entry.path_of("result"))?;
181        Ok(name.to_string())
182    })?;
183    let calls = as_array(root.req("ctxCalls")?, "/ctxCalls", MAX_CTX_CALLS)?;
184    for (i, call) in calls.iter().enumerate() {
185        let path = index("/ctxCalls", i);
186        let call = Obj::new(call, &path, &["seq", "method", "args", "resultSha256"])?;
187        let seq = as_integer(
188            call.req("seq")?,
189            &call.path_of("seq"),
190            0,
191            MAX_CTX_CALLS as i64,
192        )?;
193        if seq != i as i64 {
194            return Err(err(
195                "invalid-value",
196                &call.path_of("seq"),
197                format!("expected seq {i}"),
198            ));
199        }
200        one_of(
201            call.req("method")?,
202            &call.path_of("method"),
203            &CtxMethod::ALL,
204        )?;
205        call.req("args")?;
206        sha256_hex(call.req("resultSha256")?, &call.path_of("resultSha256"))?;
207    }
208    let observed = as_integer(root.req("observedAt")?, "/observedAt", 0, MAX_SAFE)?;
209    let expires = as_integer(root.req("expiresAt")?, "/expiresAt", 0, MAX_SAFE)?;
210    if expires < observed {
211        return Err(err(
212            "invalid-value",
213            "/expiresAt",
214            "expiresAt is before observedAt",
215        ));
216    }
217    model(value)
218}
219
220// ---------------------------------------------------------------------------
221// Execution intent
222// ---------------------------------------------------------------------------
223
224#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
225#[serde(rename_all = "camelCase", deny_unknown_fields)]
226pub struct ExecutionIntentV1 {
227    pub schema: String,
228    pub execution_id: String,
229    pub deployment: IntentDeploymentV1,
230    pub step: String,
231    pub instance_id: Option<String>,
232    pub participant_id: Option<String>,
233    pub wallet: String,
234    /// The role the wallet signs as, for role steps.
235    pub role: Option<String>,
236    /// Opaque id of the integrator account the session belongs to.
237    pub caller_account: String,
238    pub session_key_class: SessionKeyClass,
239    pub transaction_format: TransactionFormat,
240    /// Lowercase hex SHA-256 of the canonical args JSON.
241    pub input_sha256: String,
242    pub resource_hashes: Vec<HashId<ProtocolResource>>,
243    pub provider_snapshot_hash: HashId<ProviderSnapshot>,
244    pub fees: Option<IntentFeesV1>,
245    pub signers: Vec<IntentSignerV1>,
246    pub transactions: Vec<IntentTransactionV1>,
247    pub blockhash: String,
248    pub last_valid_block_height: String,
249    pub expires_at: i64,
250    pub created_at: i64,
251}
252
253#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
254#[serde(rename_all = "camelCase", deny_unknown_fields)]
255pub struct IntentDeploymentV1 {
256    pub deployment_id: String,
257    pub revision: u32,
258    pub release_hash: HashId<ProtocolRelease>,
259}
260
261#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
262#[serde(rename_all = "camelCase")]
263pub enum SessionKeyClass {
264    Publishable,
265    Secret,
266}
267
268#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
269#[serde(rename_all = "camelCase")]
270pub enum TransactionFormat {
271    /// One Transaction V1 message per transaction. The default.
272    #[default]
273    V1,
274    /// Legacy v0 messages, split to fit 1,232 bytes, for wallets without V1.
275    V0,
276}
277
278#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
279#[serde(rename_all = "camelCase", deny_unknown_fields)]
280pub struct IntentFeesV1 {
281    pub asset: IntentFeeAssetV1,
282    pub gross: String,
283    pub total: String,
284    pub creator: String,
285    pub platform: String,
286    pub placement: FeePlacement,
287    pub platform_destination: String,
288    pub creator_destination: Option<String>,
289    /// Set when a cap or an override changed the platform leg. Additive: an
290    /// intent without it hashes exactly as before the field existed.
291    #[serde(default, skip_serializing_if = "Option::is_none")]
292    pub fee_adjustment: Option<FeeAdjustmentV1>,
293}
294
295/// Why the platform leg differs from the fee policy's rate.
296#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
297#[serde(rename_all = "camelCase", deny_unknown_fields)]
298pub struct FeeAdjustmentV1 {
299    pub kind: FeeAdjustmentKind,
300    /// The cap or override applied: `^[A-Za-z0-9_-]{1,64}$`.
301    pub id: String,
302}
303
304#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
305#[serde(rename_all = "camelCase")]
306pub enum FeeAdjustmentKind {
307    /// A periodic cap shrank the platform leg to the remaining headroom.
308    Cap,
309    /// A negotiated rate replaced the policy's platform rate.
310    Override,
311}
312
313#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
314#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)]
315pub enum IntentFeeAssetV1 {
316    Native,
317    Spl { mint: String },
318}
319
320#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
321#[serde(rename_all = "camelCase")]
322pub enum FeePlacement {
323    Before,
324    After,
325}
326
327#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
328#[serde(rename_all = "camelCase", deny_unknown_fields)]
329pub struct IntentSignerV1 {
330    pub class: SignerClass,
331    pub pubkey: String,
332    /// Ephemeral key name or role name.
333    pub name: Option<String>,
334}
335
336#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
337#[serde(rename_all = "camelCase", deny_unknown_fields)]
338pub struct IntentTransactionV1 {
339    pub index: u32,
340    /// Lowercase hex SHA-256 of the exact message bytes.
341    pub message_sha256: String,
342    pub signers: Vec<String>,
343}
344
345impl ExecutionIntentV1 {
346    pub fn hash(&self) -> Result<HashId<ExecutionIntent>, HashError> {
347        hash_jcs::<ExecutionIntent, _>(self)
348    }
349}
350
351/// Lowercase canonical UUID text.
352pub(crate) fn uuid<'a>(value: &'a Value, path: &str) -> ProtocolResult<&'a str> {
353    let text = as_str(value, path)?;
354    let bytes = text.as_bytes();
355    let valid = bytes.len() == 36
356        && bytes.iter().enumerate().all(|(i, b)| match i {
357            8 | 13 | 18 | 23 => *b == b'-',
358            _ => b.is_ascii_digit() || (b'a'..=b'f').contains(b),
359        });
360    if valid {
361        Ok(text)
362    } else {
363        Err(err("invalid-value", path, "expected a lowercase UUID"))
364    }
365}
366
367const INTENT_KEYS: &[&str] = &[
368    "schema",
369    "executionId",
370    "deployment",
371    "step",
372    "instanceId",
373    "participantId",
374    "wallet",
375    "role",
376    "callerAccount",
377    "sessionKeyClass",
378    "transactionFormat",
379    "inputSha256",
380    "resourceHashes",
381    "providerSnapshotHash",
382    "fees",
383    "signers",
384    "transactions",
385    "blockhash",
386    "lastValidBlockHeight",
387    "expiresAt",
388    "createdAt",
389];
390
391pub fn validate_execution_intent_value(value: &Value) -> ProtocolResult<ExecutionIntentV1> {
392    reject_floats(value, "")?;
393    let root = Obj::new(value, "", INTENT_KEYS)?;
394    schema_tag(root.req("schema")?, "/schema", EXECUTION_INTENT_SCHEMA_V1)?;
395    uuid(root.req("executionId")?, "/executionId")?;
396    let deployment = Obj::new(
397        root.req("deployment")?,
398        "/deployment",
399        &["deploymentId", "revision", "releaseHash"],
400    )?;
401    uuid(deployment.req("deploymentId")?, "/deployment/deploymentId")?;
402    as_integer(
403        deployment.req("revision")?,
404        "/deployment/revision",
405        1,
406        u32::MAX as i64,
407    )?;
408    hash_id::<ProtocolRelease>(deployment.req("releaseHash")?, "/deployment/releaseHash")?;
409    identifier_value(root.req("step")?, "/step")?;
410    if let Some(value) = root.nullable("instanceId")? {
411        uuid(value, "/instanceId")?;
412    }
413    if let Some(value) = root.nullable("participantId")? {
414        uuid(value, "/participantId")?;
415    }
416    pubkey(root.req("wallet")?, "/wallet")?;
417    if let Some(value) = root.nullable("role")? {
418        identifier_value(value, "/role")?;
419    }
420    let caller = bounded_str(root.req("callerAccount")?, "/callerAccount", 64)?;
421    if caller.is_empty() {
422        return Err(err(
423            "invalid-value",
424            "/callerAccount",
425            "callerAccount must not be empty",
426        ));
427    }
428    one_of(
429        root.req("sessionKeyClass")?,
430        "/sessionKeyClass",
431        &["publishable", "secret"],
432    )?;
433    one_of(
434        root.req("transactionFormat")?,
435        "/transactionFormat",
436        &["v1", "v0"],
437    )?;
438    sha256_hex(root.req("inputSha256")?, "/inputSha256")?;
439    sorted_entries(
440        root.req("resourceHashes")?,
441        "/resourceHashes",
442        |entry, path| Ok(hash_id::<ProtocolResource>(entry, path)?.to_string()),
443    )?;
444    hash_id::<ProviderSnapshot>(root.req("providerSnapshotHash")?, "/providerSnapshotHash")?;
445    if let Some(value) = root.nullable("fees")? {
446        shape_fees(value, "/fees")?;
447    }
448    let signers = as_array(root.req("signers")?, "/signers", MAX_INTENT_SIGNERS)?;
449    for (i, signer) in signers.iter().enumerate() {
450        let path = index("/signers", i);
451        let signer = Obj::new(signer, &path, &["class", "pubkey", "name"])?;
452        one_of(
453            signer.req("class")?,
454            &signer.path_of("class"),
455            &SignerClass::ALL,
456        )?;
457        pubkey(signer.req("pubkey")?, &signer.path_of("pubkey"))?;
458        if let Some(value) = signer.nullable("name")? {
459            identifier_value(value, &signer.path_of("name"))?;
460        }
461    }
462    let transactions = as_array(
463        root.req("transactions")?,
464        "/transactions",
465        MAX_INTENT_TRANSACTIONS,
466    )?;
467    if transactions.is_empty() {
468        return Err(err(
469            "invalid-value",
470            "/transactions",
471            "an intent has at least one transaction",
472        ));
473    }
474    for (i, transaction) in transactions.iter().enumerate() {
475        let path = index("/transactions", i);
476        let transaction = Obj::new(transaction, &path, &["index", "messageSha256", "signers"])?;
477        let position = as_integer(
478            transaction.req("index")?,
479            &transaction.path_of("index"),
480            0,
481            7,
482        )?;
483        if position != i as i64 {
484            return Err(err(
485                "invalid-value",
486                &transaction.path_of("index"),
487                format!("expected index {i}"),
488            ));
489        }
490        sha256_hex(
491            transaction.req("messageSha256")?,
492            &transaction.path_of("messageSha256"),
493        )?;
494        let signer_path = transaction.path_of("signers");
495        let signer_list = unique_strings(
496            transaction.req("signers")?,
497            &signer_path,
498            MAX_INTENT_SIGNERS,
499            pubkey,
500        )?;
501        if signer_list.is_empty() {
502            return Err(err(
503                "invalid-value",
504                &signer_path,
505                "a transaction has at least one signer",
506            ));
507        }
508    }
509    pubkey(root.req("blockhash")?, "/blockhash")?;
510    decimal(
511        root.req("lastValidBlockHeight")?,
512        "/lastValidBlockHeight",
513        U64_MAX,
514    )?;
515    let expires = as_integer(root.req("expiresAt")?, "/expiresAt", 0, MAX_SAFE)?;
516    let created = as_integer(root.req("createdAt")?, "/createdAt", 0, MAX_SAFE)?;
517    if expires < created {
518        return Err(err(
519            "invalid-value",
520            "/expiresAt",
521            "expiresAt is before createdAt",
522        ));
523    }
524    model(value)
525}
526
527fn shape_fees(value: &Value, path: &str) -> ProtocolResult<()> {
528    let fees = Obj::new(
529        value,
530        path,
531        &[
532            "asset",
533            "gross",
534            "total",
535            "creator",
536            "platform",
537            "placement",
538            "platformDestination",
539            "creatorDestination",
540            "feeAdjustment",
541        ],
542    )?;
543    let (kind, asset) = super::spec::tagged(
544        fees.req("asset")?,
545        &fees.path_of("asset"),
546        &[("native", &[]), ("spl", &["mint"])],
547    )?;
548    if kind == "spl" {
549        pubkey(asset.req("mint")?, &asset.path_of("mint"))?;
550    }
551    let gross = decimal(fees.req("gross")?, &fees.path_of("gross"), U64_MAX)?;
552    let total = decimal(fees.req("total")?, &fees.path_of("total"), U64_MAX)?;
553    let creator = decimal(fees.req("creator")?, &fees.path_of("creator"), U64_MAX)?;
554    let platform = decimal(fees.req("platform")?, &fees.path_of("platform"), U64_MAX)?;
555    if total > gross {
556        return Err(err(
557            "invalid-value",
558            &fees.path_of("total"),
559            "the fee exceeds the gross amount",
560        ));
561    }
562    if creator + platform != total {
563        return Err(err(
564            "invalid-value",
565            &fees.path_of("platform"),
566            "creator + platform must equal total",
567        ));
568    }
569    one_of(
570        fees.req("placement")?,
571        &fees.path_of("placement"),
572        &["before", "after"],
573    )?;
574    pubkey(
575        fees.req("platformDestination")?,
576        &fees.path_of("platformDestination"),
577    )?;
578    match fees.nullable("creatorDestination")? {
579        Some(value) => {
580            pubkey(value, &fees.path_of("creatorDestination"))?;
581        }
582        None if creator != 0 => {
583            return Err(err(
584                "invalid-value",
585                &fees.path_of("creatorDestination"),
586                "a non-zero creator leg needs a destination",
587            ))
588        }
589        None => {}
590    }
591    if let Some(value) = fees.opt("feeAdjustment")? {
592        let path = fees.path_of("feeAdjustment");
593        let adjustment = Obj::new(value, &path, &["kind", "id"])?;
594        one_of(
595            adjustment.req("kind")?,
596            &adjustment.path_of("kind"),
597            &["cap", "override"],
598        )?;
599        let id_path = adjustment.path_of("id");
600        let id = as_str(adjustment.req("id")?, &id_path)?;
601        let valid = (1..=64).contains(&id.len())
602            && id
603                .bytes()
604                .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-');
605        if !valid {
606            return Err(err(
607                "invalid-identifier",
608                &id_path,
609                "adjustment ids match ^[A-Za-z0-9_-]{1,64}$",
610            ));
611        }
612    }
613    Ok(())
614}