1use 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
36#[serde(rename_all = "camelCase", deny_unknown_fields)]
37pub struct ProviderSnapshotV1 {
38 pub schema: String,
39 pub context_slot: String,
41 pub reads: Vec<SnapshotReadV1>,
43 pub ctx_calls: Vec<CtxCallRecordV1>,
45 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#[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 pub args: Value,
80 pub result_sha256: String,
82}
83
84#[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#[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 pub role: Option<String>,
236 pub caller_account: String,
238 pub session_key_class: SessionKeyClass,
239 pub transaction_format: TransactionFormat,
240 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 #[default]
273 V1,
274 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 #[serde(default, skip_serializing_if = "Option::is_none")]
292 pub fee_adjustment: Option<FeeAdjustmentV1>,
293}
294
295#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
297#[serde(rename_all = "camelCase", deny_unknown_fields)]
298pub struct FeeAdjustmentV1 {
299 pub kind: FeeAdjustmentKind,
300 pub id: String,
302}
303
304#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
305#[serde(rename_all = "camelCase")]
306pub enum FeeAdjustmentKind {
307 Cap,
309 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 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 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
351pub(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}