1use std::collections::{BTreeMap, BTreeSet};
14
15use serde_json::Value;
16
17use super::error::{ProtocolContractError, ProtocolResult};
18use super::model::*;
19use super::shape::*;
20use crate::{hash_jcs, HashId, ProgramRelease, ProgramSpec, StackManifest};
21
22const MAX_PROGRAMS: usize = 32;
23const MAX_STACKS: usize = 16;
24const MAX_ROLES: usize = 16;
25const MAX_RESOURCES: usize = 32;
26const MAX_READS: usize = 64;
27const MAX_STATES: usize = 64;
28const MAX_DERIVE_RULES: usize = 64;
29const MAX_STEPS: usize = 64;
30const MAX_GUARDS: usize = 32;
31const MAX_EPHEMERAL: usize = 8;
32const MAX_TRANSITIONS: usize = 16;
33const MAX_BIND_ACCOUNTS: usize = 32;
34const MAX_ATAS: usize = 8;
35const MAX_TEMPLATE_INSTRUCTIONS: usize = 64;
36const MAX_TEMPLATE_FIELDS: usize = 64;
37const MAX_REMAINING_ACCOUNTS: usize = 32;
38const MAX_REACTIONS: usize = 128;
39const MAX_EVENTS: usize = 64;
40const MAX_SCHEMA_PROPERTIES: usize = 64;
41const MAX_SCHEMA_DEPTH: usize = 4;
42const MAX_ENUM_VALUES: usize = 64;
43const MAX_METADATA_FIELDS: usize = 64;
44const MAX_STACK_FIELDS: usize = 64;
45const MAX_ACTIVITY: usize = 64;
46const MAX_TITLE_BYTES: usize = 128;
47
48pub fn parse_protocol_spec_v1(bytes: &[u8]) -> ProtocolResult<ProtocolSpecV1> {
50 let value = super::parse_contract_json(bytes)?;
51 validate_protocol_spec_value(&value)
52}
53
54pub const RESERVED_GUARD_CODES: [&str; 5] = [
59 "step_requires_secret_key",
60 "step_not_in_session_scope",
61 "step_not_client_callable",
62 "instance_terminal",
63 "participant_terminal",
64];
65
66pub fn validate_protocol_spec_value(value: &Value) -> ProtocolResult<ProtocolSpecV1> {
67 reject_floats(value, "")?;
68 shape_spec(value)?;
69 let spec: ProtocolSpecV1 = serde_json::from_value(value.clone()).map_err(|error| {
70 ProtocolContractError::new("invalid-value", "", format!("model mismatch: {error}"))
71 })?;
72 check_semantics(&spec)?;
73 Ok(spec)
74}
75
76impl ProtocolSpecV1 {
77 pub fn hash(&self) -> Result<HashId<crate::ProtocolSpec>, crate::HashError> {
78 hash_jcs::<crate::ProtocolSpec, _>(self)
79 }
80
81 pub fn has_handlers(&self) -> bool {
82 self.steps
83 .values()
84 .any(|step| matches!(step.build, BuildV1::Handler { .. }))
85 }
86
87 pub fn create_step(&self) -> Option<(&String, &StepV1)> {
88 self.steps
89 .iter()
90 .find(|(_, step)| step.scope == StepScope::Create)
91 }
92
93 pub fn effective_access(&self, step: &str, narrowed_to_secret: bool) -> Option<StepAccess> {
96 let access = self.steps.get(step)?.access?;
97 Some(if narrowed_to_secret {
98 StepAccess::Secret
99 } else {
100 access
101 })
102 }
103
104 pub fn machine(&self, kind: MachineKind) -> Option<&MachineV1> {
105 match kind {
106 MachineKind::Instance => Some(&self.machines.instance),
107 MachineKind::Participant => self.machines.participant.as_ref(),
108 }
109 }
110}
111
112const SPEC_KEYS: &[&str] = &[
117 "schema",
118 "name",
119 "version",
120 "description",
121 "handlerAbi",
122 "ctxVersion",
123 "programs",
124 "stacks",
125 "params",
126 "roles",
127 "resources",
128 "instanceVisibility",
129 "reads",
130 "machines",
131 "steps",
132 "reactions",
133 "events",
134 "metadata",
135 "stack",
136];
137
138fn shape_spec(value: &Value) -> ProtocolResult<()> {
139 let root = Obj::new(value, "", SPEC_KEYS)?;
140 schema_tag(
141 root.req("schema")?,
142 &root.path_of("schema"),
143 PROTOCOL_SPEC_SCHEMA_V1,
144 )?;
145 protocol_name(
146 as_str(root.req("name")?, &root.path_of("name"))?,
147 &root.path_of("name"),
148 )?;
149 semver(root.req("version")?, &root.path_of("version"))?;
150 if let Some(value) = root.opt("description")? {
151 description(value, &root.path_of("description"))?;
152 }
153 if let Some(value) = root.opt("handlerAbi")? {
154 one_of(
155 value,
156 &root.path_of("handlerAbi"),
157 &[PROTOCOL_HANDLER_ABI_V1],
158 )?;
159 }
160 if let Some(value) = root.opt("ctxVersion")? {
161 one_of(value, &root.path_of("ctxVersion"), &[PROTOCOL_CTX_V1])?;
162 }
163 shape_programs(root.req("programs")?, &root.path_of("programs"))?;
164 shape_stacks(root.req("stacks")?, &root.path_of("stacks"))?;
165 shape_object_schema(root.req("params")?, &root.path_of("params"))?;
166 for (_, role, path) in as_map(
167 root.req("roles")?,
168 &root.path_of("roles"),
169 MAX_ROLES,
170 key_identifier,
171 )? {
172 let role = Obj::new(role, &path, &["description"])?;
173 if let Some(value) = role.opt("description")? {
174 description(value, &role.path_of("description"))?;
175 }
176 }
177 for (_, resource, path) in as_map(
178 root.req("resources")?,
179 &root.path_of("resources"),
180 MAX_RESOURCES,
181 key_identifier,
182 )? {
183 let resource = Obj::new(resource, &path, &["schema", "description"])?;
184 shape_field_schema(resource.req("schema")?, &resource.path_of("schema"), 1)?;
185 if let Some(value) = resource.opt("description")? {
186 description(value, &resource.path_of("description"))?;
187 }
188 }
189 one_of(
190 root.req("instanceVisibility")?,
191 &root.path_of("instanceVisibility"),
192 &["shared", "account"],
193 )?;
194 for (_, read, path) in as_map(
195 root.req("reads")?,
196 &root.path_of("reads"),
197 MAX_READS,
198 key_identifier,
199 )? {
200 shape_read(read, &path)?;
201 }
202 let machines = Obj::new(
203 root.req("machines")?,
204 &root.path_of("machines"),
205 &["instance", "participant"],
206 )?;
207 shape_machine(machines.req("instance")?, &machines.path_of("instance"))?;
208 if let Some(value) = machines.opt("participant")? {
209 shape_machine(value, &machines.path_of("participant"))?;
210 }
211 for (_, step, path) in as_map(
212 root.req("steps")?,
213 &root.path_of("steps"),
214 MAX_STEPS,
215 key_identifier,
216 )? {
217 shape_step(step, &path)?;
218 }
219 let reactions_path = root.path_of("reactions");
220 for (i, reaction) in as_array(root.req("reactions")?, &reactions_path, MAX_REACTIONS)?
221 .iter()
222 .enumerate()
223 {
224 shape_reaction(reaction, &index(&reactions_path, i))?;
225 }
226 for (_, event, path) in as_map(
227 root.req("events")?,
228 &root.path_of("events"),
229 MAX_EVENTS,
230 key_event,
231 )? {
232 let event = Obj::new(event, &path, &["machine", "description"])?;
233 one_of(
234 event.req("machine")?,
235 &event.path_of("machine"),
236 &["instance", "participant"],
237 )?;
238 if let Some(value) = event.opt("description")? {
239 description(value, &event.path_of("description"))?;
240 }
241 }
242 shape_metadata(root.req("metadata")?, &root.path_of("metadata"))?;
243 shape_stack_surface(root.req("stack")?, &root.path_of("stack"))?;
244 Ok(())
245}
246
247pub(crate) fn tagged<'a>(
249 value: &'a Value,
250 path: &str,
251 kinds: &[(&'a str, &[&str])],
252) -> ProtocolResult<(&'a str, Obj<'a>)> {
253 let map = value
254 .as_object()
255 .ok_or_else(|| err("invalid-type", path, "expected an object"))?;
256 let kind_path = child(path, "kind");
257 let kind = match map.get("kind") {
258 None => return Err(err("missing-field", &kind_path, "missing field 'kind'")),
259 Some(Value::Null) => {
260 return Err(err(
261 "null-not-allowed",
262 &kind_path,
263 "field 'kind' must not be null",
264 ))
265 }
266 Some(kind) => as_str(kind, &kind_path)?,
267 };
268 let Some((name, keys)) = kinds.iter().find(|(name, _)| *name == kind) else {
269 let names: Vec<&str> = kinds.iter().map(|(name, _)| *name).collect();
270 return Err(err(
271 "invalid-value",
272 &kind_path,
273 format!("expected one of {}", names.join(", ")),
274 ));
275 };
276 let mut allowed = vec!["kind"];
277 allowed.extend_from_slice(keys);
278 Ok((name, Obj::new(value, path, &allowed)?))
279}
280
281fn shape_programs(value: &Value, path: &str) -> ProtocolResult<()> {
282 let mut program_ids: BTreeMap<String, String> = BTreeMap::new();
283 for (alias, program, program_path) in as_map(value, path, MAX_PROGRAMS, key_identifier)? {
284 let (kind, program) = tagged(
285 program,
286 &program_path,
287 &[
288 (
289 "programSpec",
290 &["programId", "programSpecHash", "programReleaseHash"],
291 ),
292 ("builtin", &["builtin"]),
293 ],
294 )?;
295 let (id, id_path) = if kind == "programSpec" {
296 let id = pubkey(program.req("programId")?, &program.path_of("programId"))?.to_string();
297 hash_id::<ProgramSpec>(
298 program.req("programSpecHash")?,
299 &program.path_of("programSpecHash"),
300 )?;
301 if let Some(value) = program.opt("programReleaseHash")? {
302 hash_id::<ProgramRelease>(value, &program.path_of("programReleaseHash"))?;
303 }
304 (id, program.path_of("programId"))
305 } else {
306 let name = program.req("builtin")?.as_str().unwrap_or_default();
307 if let Some(canonical) = BuiltinProgram::from_alias(name) {
308 return Err(err(
309 "invalid-value",
310 &program.path_of("builtin"),
311 format!("'{name}' is the builtin '{}'", canonical.name()),
312 ));
313 }
314 let builtin = one_of(
315 program.req("builtin")?,
316 &program.path_of("builtin"),
317 &BuiltinProgram::ALL,
318 )?;
319 let builtin: BuiltinProgram =
320 serde_json::from_value(Value::String(builtin.to_string())).map_err(|_| {
321 err(
322 "invalid-value",
323 &program.path_of("builtin"),
324 "unknown builtin",
325 )
326 })?;
327 (builtin.program_id().to_string(), program.path_of("builtin"))
328 };
329 if let Some(previous) = program_ids.insert(id.clone(), alias.clone()) {
330 return Err(err(
331 "duplicate-entry",
332 &id_path,
333 format!("program {id} is already declared as '{previous}'"),
334 ));
335 }
336 }
337 Ok(())
338}
339
340fn shape_stacks(value: &Value, path: &str) -> ProtocolResult<()> {
341 for (_, stack, stack_path) in as_map(value, path, MAX_STACKS, key_identifier)? {
342 let stack = Obj::new(stack, &stack_path, &["stackManifestHash", "package"])?;
343 hash_id::<StackManifest>(
344 stack.req("stackManifestHash")?,
345 &stack.path_of("stackManifestHash"),
346 )?;
347 if let Some(value) = stack.opt("package")? {
348 bounded_str(value, &stack.path_of("package"), MAX_TITLE_BYTES)?;
349 }
350 }
351 Ok(())
352}
353
354const FIELD_SCHEMA_KEYS: &[&str] = &[
355 "type",
356 "format",
357 "enum",
358 "maxLength",
359 "minimum",
360 "maximum",
361 "items",
362 "maxItems",
363 "properties",
364 "required",
365 "additionalProperties",
366 "description",
367];
368
369fn shape_object_schema(value: &Value, path: &str) -> ProtocolResult<()> {
370 let schema = Obj::new(
371 value,
372 path,
373 &["type", "properties", "required", "additionalProperties"],
374 )?;
375 shape_object_body(&schema, 1)
376}
377
378fn shape_object_body(schema: &Obj<'_>, depth: usize) -> ProtocolResult<()> {
379 let type_path = schema.path_of("type");
380 if as_str(schema.req("type")?, &type_path)? != "object" {
381 return Err(err("invalid-schema", &type_path, "expected type 'object'"));
382 }
383 let properties = as_map(
384 schema.req("properties")?,
385 &schema.path_of("properties"),
386 MAX_SCHEMA_PROPERTIES,
387 key_identifier,
388 )?;
389 for (_, property, property_path) in &properties {
390 shape_field_schema(property, property_path, depth + 1)?;
391 }
392 let names: BTreeSet<&str> = properties
393 .iter()
394 .map(|(name, _, _)| name.as_str())
395 .collect();
396 let required_path = schema.path_of("required");
397 let required = unique_strings(
398 schema.req("required")?,
399 &required_path,
400 MAX_SCHEMA_PROPERTIES,
401 identifier_value,
402 )?;
403 for (i, name) in required.iter().enumerate() {
404 if !names.contains(name) {
405 return Err(err(
406 "invalid-schema",
407 &index(&required_path, i),
408 format!("required property '{name}' is not declared"),
409 ));
410 }
411 }
412 let additional_path = schema.path_of("additionalProperties");
413 if as_bool(schema.req("additionalProperties")?, &additional_path)? {
414 return Err(err(
415 "invalid-schema",
416 &additional_path,
417 "additionalProperties must be false",
418 ));
419 }
420 Ok(())
421}
422
423fn shape_field_schema(value: &Value, path: &str, depth: usize) -> ProtocolResult<()> {
424 if depth > MAX_SCHEMA_DEPTH {
425 return Err(err(
426 "invalid-schema",
427 path,
428 format!("schemas nest at most {MAX_SCHEMA_DEPTH} levels"),
429 ));
430 }
431 let schema = Obj::new(value, path, FIELD_SCHEMA_KEYS)?;
432 let field_type = one_of(
433 schema.req("type")?,
434 &schema.path_of("type"),
435 &["string", "boolean", "integer", "array", "object"],
436 )?;
437 let allowed: &[&str] = match field_type {
438 "string" => &["type", "format", "enum", "maxLength", "description"],
439 "boolean" => &["type", "description"],
440 "integer" => &["type", "minimum", "maximum", "description"],
441 "array" => &["type", "items", "maxItems", "description"],
442 _ => &[
443 "type",
444 "properties",
445 "required",
446 "additionalProperties",
447 "description",
448 ],
449 };
450 for key in FIELD_SCHEMA_KEYS {
451 if schema.has(key) && !allowed.contains(key) {
452 return Err(err(
453 "invalid-schema",
454 &schema.path_of(key),
455 format!("'{key}' is not allowed on type '{field_type}'"),
456 ));
457 }
458 }
459 match field_type {
460 "string" => {
461 if let Some(value) = schema.opt("format")? {
462 one_of(value, &schema.path_of("format"), &StringFormat::ALL)?;
463 }
464 if let Some(value) = schema.opt("enum")? {
465 let enum_path = schema.path_of("enum");
466 if schema.has("format") {
467 return Err(err(
468 "invalid-schema",
469 &enum_path,
470 "'enum' cannot be combined with 'format'",
471 ));
472 }
473 let values = unique_strings(value, &enum_path, MAX_ENUM_VALUES, |value, path| {
474 let text = bounded_str(value, path, MAX_IDENTIFIER_BYTES)?;
475 if text.is_empty() {
476 return Err(err("invalid-value", path, "enum values must not be empty"));
477 }
478 Ok(text)
479 })?;
480 if values.is_empty() {
481 return Err(err(
482 "invalid-schema",
483 &enum_path,
484 "'enum' must list at least one value",
485 ));
486 }
487 }
488 if let Some(value) = schema.opt("maxLength")? {
489 as_integer(value, &schema.path_of("maxLength"), 1, 4096)?;
490 }
491 }
492 "integer" => {
493 let minimum = match schema.opt("minimum")? {
494 Some(value) => Some(as_integer(
495 value,
496 &schema.path_of("minimum"),
497 -MAX_SAFE,
498 MAX_SAFE,
499 )?),
500 None => None,
501 };
502 let maximum = match schema.opt("maximum")? {
503 Some(value) => Some(as_integer(
504 value,
505 &schema.path_of("maximum"),
506 -MAX_SAFE,
507 MAX_SAFE,
508 )?),
509 None => None,
510 };
511 if let (Some(minimum), Some(maximum)) = (minimum, maximum) {
512 if minimum > maximum {
513 return Err(err(
514 "invalid-schema",
515 &schema.path_of("maximum"),
516 "maximum is below minimum",
517 ));
518 }
519 }
520 }
521 "array" => {
522 shape_field_schema(schema.req("items")?, &schema.path_of("items"), depth + 1)?;
523 as_integer(schema.req("maxItems")?, &schema.path_of("maxItems"), 1, 256)?;
524 }
525 "object" => shape_object_body(&schema, depth)?,
526 _ => {}
527 }
528 if let Some(value) = schema.opt("description")? {
529 description(value, &schema.path_of("description"))?;
530 }
531 Ok(())
532}
533
534const MAX_SAFE: i64 = 9_007_199_254_740_991;
535
536fn shape_read(value: &Value, path: &str) -> ProtocolResult<()> {
537 let (kind, read) = tagged(
538 value,
539 path,
540 &[
541 ("account", &["program", "account", "address", "optional"]),
542 ("tokenAccount", &["address", "optional"]),
543 ("lamports", &["address"]),
544 ],
545 )?;
546 if kind == "account" {
547 identifier_value(read.req("program")?, &read.path_of("program"))?;
548 identifier_value(read.req("account")?, &read.path_of("account"))?;
549 }
550 expression(read.req("address")?, &read.path_of("address"))?;
551 if kind != "lamports" {
552 as_bool(read.req("optional")?, &read.path_of("optional"))?;
553 }
554 Ok(())
555}
556
557fn shape_machine(value: &Value, path: &str) -> ProtocolResult<()> {
558 let machine = Obj::new(value, path, &["initial", "states", "derive", "reads"])?;
559 snake_value(machine.req("initial")?, &machine.path_of("initial"))?;
560 let states = as_map(
561 machine.req("states")?,
562 &machine.path_of("states"),
563 MAX_STATES,
564 key_snake,
565 )?;
566 if states.is_empty() {
567 return Err(err(
568 "invalid-value",
569 &machine.path_of("states"),
570 "a machine needs at least one state",
571 ));
572 }
573 for (_, state, state_path) in states {
574 let state = Obj::new(state, &state_path, &["kind", "terminal", "description"])?;
575 one_of(
576 state.req("kind")?,
577 &state.path_of("kind"),
578 &["recorded", "derived"],
579 )?;
580 as_bool(state.req("terminal")?, &state.path_of("terminal"))?;
581 if let Some(value) = state.opt("description")? {
582 description(value, &state.path_of("description"))?;
583 }
584 }
585 let derive_path = machine.path_of("derive");
586 for (i, rule) in as_array(machine.req("derive")?, &derive_path, MAX_DERIVE_RULES)?
587 .iter()
588 .enumerate()
589 {
590 let rule = Obj::new(rule, &index(&derive_path, i), &["from", "to", "when", "at"])?;
591 let from = unique_strings(
592 rule.req("from")?,
593 &rule.path_of("from"),
594 MAX_STATES,
595 snake_value,
596 )?;
597 if from.is_empty() {
598 return Err(err(
599 "invalid-value",
600 &rule.path_of("from"),
601 "a derivation needs at least one source state",
602 ));
603 }
604 snake_value(rule.req("to")?, &rule.path_of("to"))?;
605 expression(rule.req("when")?, &rule.path_of("when"))?;
606 if let Some(value) = rule.opt("at")? {
607 expression(value, &rule.path_of("at"))?;
608 }
609 }
610 unique_strings(
611 machine.req("reads")?,
612 &machine.path_of("reads"),
613 MAX_READS,
614 identifier_value,
615 )?;
616 Ok(())
617}
618
619const STEP_KEYS: &[&str] = &[
620 "scope",
621 "title",
622 "description",
623 "authority",
624 "access",
625 "args",
626 "guards",
627 "reads",
628 "ephemeral",
629 "fee",
630 "transitions",
631 "bind",
632 "glue",
633 "build",
634];
635
636fn shape_step(value: &Value, path: &str) -> ProtocolResult<()> {
637 let step = Obj::new(value, path, STEP_KEYS)?;
638 one_of(
639 step.req("scope")?,
640 &step.path_of("scope"),
641 &["create", "instance", "participant"],
642 )?;
643 if let Some(value) = step.opt("title")? {
644 bounded_str(value, &step.path_of("title"), MAX_TITLE_BYTES)?;
645 }
646 if let Some(value) = step.opt("description")? {
647 description(value, &step.path_of("description"))?;
648 }
649 let (kind, authority) = tagged(
650 step.req("authority")?,
651 &step.path_of("authority"),
652 &[
653 ("creator", &[]),
654 ("participant", &[]),
655 ("role", &["role"]),
656 ("keeper", &[]),
657 ("squadsProposer", &["multisig", "vaultIndex"]),
658 ],
659 )?;
660 match kind {
661 "role" => {
662 identifier_value(authority.req("role")?, &authority.path_of("role"))?;
663 }
664 "squadsProposer" => {
665 expression(authority.req("multisig")?, &authority.path_of("multisig"))?;
666 if let Some(value) = authority.opt("vaultIndex")? {
667 let path = authority.path_of("vaultIndex");
668 if as_integer(value, &path, 0, u8::MAX.into())? == 0 {
669 return Err(err(
670 "invalid-value",
671 &path,
672 "omit vaultIndex for vault 0 instead of giving 0",
673 ));
674 }
675 }
676 }
677 _ => {}
678 }
679 let proposes = kind == "squadsProposer";
680 if let Some(value) = step.opt("access")? {
681 one_of(value, &step.path_of("access"), &["public", "secret"])?;
682 }
683 shape_object_schema(step.req("args")?, &step.path_of("args"))?;
684 let guards_path = step.path_of("guards");
685 for (i, guard) in as_array(step.req("guards")?, &guards_path, MAX_GUARDS)?
686 .iter()
687 .enumerate()
688 {
689 let guard = Obj::new(guard, &index(&guards_path, i), &["code", "expr", "message"])?;
690 snake_value(guard.req("code")?, &guard.path_of("code"))?;
691 expression(guard.req("expr")?, &guard.path_of("expr"))?;
692 if let Some(value) = guard.opt("message")? {
693 description(value, &guard.path_of("message"))?;
694 }
695 }
696 unique_strings(
697 step.req("reads")?,
698 &step.path_of("reads"),
699 MAX_READS,
700 identifier_value,
701 )?;
702 unique_strings(
703 step.req("ephemeral")?,
704 &step.path_of("ephemeral"),
705 MAX_EPHEMERAL,
706 identifier_value,
707 )?;
708 shape_fee(step.req("fee")?, &step.path_of("fee"))?;
709 let transitions_path = step.path_of("transitions");
710 for (i, transition) in as_array(step.req("transitions")?, &transitions_path, MAX_TRANSITIONS)?
711 .iter()
712 .enumerate()
713 {
714 let transition = Obj::new(
715 transition,
716 &index(&transitions_path, i),
717 &["machine", "from", "to"],
718 )?;
719 one_of(
720 transition.req("machine")?,
721 &transition.path_of("machine"),
722 &["instance", "participant"],
723 )?;
724 unique_strings(
725 transition.req("from")?,
726 &transition.path_of("from"),
727 MAX_STATES,
728 snake_value,
729 )?;
730 snake_value(transition.req("to")?, &transition.path_of("to"))?;
731 }
732 if let Some(value) = step.opt("bind")? {
733 let bind = Obj::new(value, &step.path_of("bind"), &["accounts", "roles"])?;
734 for (_, expr, expr_path) in as_map(
735 bind.req("accounts")?,
736 &bind.path_of("accounts"),
737 MAX_BIND_ACCOUNTS,
738 key_identifier,
739 )? {
740 expression(expr, &expr_path)?;
741 }
742 for (_, expr, expr_path) in as_map(
743 bind.req("roles")?,
744 &bind.path_of("roles"),
745 MAX_ROLES,
746 key_identifier,
747 )? {
748 expression(expr, &expr_path)?;
749 }
750 }
751 let glue = Obj::new(
752 step.req("glue")?,
753 &step.path_of("glue"),
754 &["atas", "wrapSol", "unwrapSol"],
755 )?;
756 let atas_path = glue.path_of("atas");
757 for (i, ata) in as_array(glue.req("atas")?, &atas_path, MAX_ATAS)?
758 .iter()
759 .enumerate()
760 {
761 let ata = Obj::new(ata, &index(&atas_path, i), &["owner", "mint"])?;
762 expression(ata.req("owner")?, &ata.path_of("owner"))?;
763 expression(ata.req("mint")?, &ata.path_of("mint"))?;
764 }
765 if let Some(value) = glue.opt("wrapSol")? {
766 let wrap = Obj::new(value, &glue.path_of("wrapSol"), &["amount"])?;
767 expression(wrap.req("amount")?, &wrap.path_of("amount"))?;
768 }
769 as_bool(glue.req("unwrapSol")?, &glue.path_of("unwrapSol"))?;
770 let (kind, build) = tagged(
771 step.req("build")?,
772 &step.path_of("build"),
773 &[
774 ("template", &["instructions"]),
775 ("handler", &["export", "programs"]),
776 ],
777 )?;
778 if kind == "template" {
779 let instructions_path = build.path_of("instructions");
780 let instructions = as_array(
784 build.req("instructions")?,
785 &instructions_path,
786 MAX_TEMPLATE_INSTRUCTIONS,
787 )?;
788 if instructions.is_empty() && proposes {
789 return Err(err(
790 "invalid-value",
791 &instructions_path,
792 "a squadsProposer step proposes its instructions, so it needs at least one",
793 ));
794 }
795 for (i, instruction) in instructions.iter().enumerate() {
796 let instruction = Obj::new(
797 instruction,
798 &index(&instructions_path, i),
799 &[
800 "program",
801 "instruction",
802 "args",
803 "accounts",
804 "remainingAccounts",
805 ],
806 )?;
807 identifier_value(instruction.req("program")?, &instruction.path_of("program"))?;
808 identifier_value(
809 instruction.req("instruction")?,
810 &instruction.path_of("instruction"),
811 )?;
812 for key in ["args", "accounts"] {
813 for (_, expr, expr_path) in as_map(
814 instruction.req(key)?,
815 &instruction.path_of(key),
816 MAX_TEMPLATE_FIELDS,
817 key_identifier,
818 )? {
819 expression(expr, &expr_path)?;
820 }
821 }
822 if let Some(value) = instruction.opt("remainingAccounts")? {
823 shape_remaining_accounts(value, &instruction.path_of("remainingAccounts"))?;
824 }
825 }
826 } else {
827 identifier_value(build.req("export")?, &build.path_of("export"))?;
828 unique_strings(
829 build.req("programs")?,
830 &build.path_of("programs"),
831 MAX_PROGRAMS,
832 identifier_value,
833 )?;
834 }
835 Ok(())
836}
837
838fn shape_remaining_accounts(value: &Value, path: &str) -> ProtocolResult<()> {
841 let accounts = as_array(value, path, MAX_REMAINING_ACCOUNTS)?;
842 if accounts.is_empty() {
843 return Err(err(
844 "invalid-value",
845 path,
846 "omit remainingAccounts instead of giving an empty list",
847 ));
848 }
849 for (i, account) in accounts.iter().enumerate() {
850 let account = Obj::new(
851 account,
852 &index(path, i),
853 &["pubkey", "isWritable", "isSigner"],
854 )?;
855 expression(account.req("pubkey")?, &account.path_of("pubkey"))?;
856 as_bool(account.req("isWritable")?, &account.path_of("isWritable"))?;
857 if as_bool(account.req("isSigner")?, &account.path_of("isSigner"))? {
858 return Err(err(
859 "remaining-account-signer",
860 &account.path_of("isSigner"),
861 "remaining accounts cannot sign; name signers as IDL accounts",
862 ));
863 }
864 }
865 Ok(())
866}
867
868fn shape_fee(value: &Value, path: &str) -> ProtocolResult<()> {
869 let (kind, fee) = tagged(
870 value,
871 path,
872 &[
873 ("none", &[]),
874 ("input", &["amountArg", "asset"]),
875 ("fixed", &[]),
876 ],
877 )?;
878 if kind == "input" {
879 identifier_value(fee.req("amountArg")?, &fee.path_of("amountArg"))?;
880 let (asset_kind, asset) = tagged(
881 fee.req("asset")?,
882 &fee.path_of("asset"),
883 &[("native", &[]), ("spl", &["mint", "sourceArg"])],
884 )?;
885 if asset_kind == "spl" {
886 expression(asset.req("mint")?, &asset.path_of("mint"))?;
887 identifier_value(asset.req("sourceArg")?, &asset.path_of("sourceArg"))?;
888 }
889 }
890 Ok(())
891}
892
893fn shape_reaction(value: &Value, path: &str) -> ProtocolResult<()> {
894 let reaction = Obj::new(value, path, &["id", "trigger", "action"])?;
895 identifier_value(reaction.req("id")?, &reaction.path_of("id"))?;
896 let (kind, trigger) = tagged(
897 reaction.req("trigger")?,
898 &reaction.path_of("trigger"),
899 &[
900 ("at", &["machine", "in", "time"]),
901 ("when", &["machine", "in", "condition"]),
902 ("onTransition", &["machine", "from", "to"]),
903 ("onConfirmed", &["step"]),
904 ],
905 )?;
906 match kind {
907 "at" | "when" => {
908 one_of(
909 trigger.req("machine")?,
910 &trigger.path_of("machine"),
911 &["instance", "participant"],
912 )?;
913 let states = unique_strings(
914 trigger.req("in")?,
915 &trigger.path_of("in"),
916 MAX_STATES,
917 snake_value,
918 )?;
919 if states.is_empty() {
920 return Err(err(
921 "invalid-value",
922 &trigger.path_of("in"),
923 "list at least one state",
924 ));
925 }
926 let key = if kind == "at" { "time" } else { "condition" };
927 expression(trigger.req(key)?, &trigger.path_of(key))?;
928 }
929 "onTransition" => {
930 one_of(
931 trigger.req("machine")?,
932 &trigger.path_of("machine"),
933 &["instance", "participant"],
934 )?;
935 unique_strings(
936 trigger.req("from")?,
937 &trigger.path_of("from"),
938 MAX_STATES,
939 snake_value,
940 )?;
941 let to = unique_strings(
942 trigger.req("to")?,
943 &trigger.path_of("to"),
944 MAX_STATES,
945 snake_value,
946 )?;
947 if to.is_empty() {
948 return Err(err(
949 "invalid-value",
950 &trigger.path_of("to"),
951 "list at least one state",
952 ));
953 }
954 }
955 _ => {
956 identifier_value(trigger.req("step")?, &trigger.path_of("step"))?;
957 }
958 }
959 let (kind, action) = tagged(
960 reaction.req("action")?,
961 &reaction.path_of("action"),
962 &[
963 ("runKeeperStep", &["step"]),
964 ("proposeSquads", &["step"]),
965 ("emit", &["event"]),
966 ],
967 )?;
968 if kind == "emit" {
969 let event_path = action.path_of("event");
970 event_name(as_str(action.req("event")?, &event_path)?, &event_path)?;
971 } else {
972 identifier_value(action.req("step")?, &action.path_of("step"))?;
973 }
974 Ok(())
975}
976
977fn shape_metadata(value: &Value, path: &str) -> ProtocolResult<()> {
978 let metadata = Obj::new(value, path, &["instance", "participant"])?;
979 for machine in ["instance", "participant"] {
980 let Some(value) = metadata.opt(machine)? else {
981 continue;
982 };
983 let schema = Obj::new(value, &metadata.path_of(machine), &["fields", "maxBytes"])?;
984 for (_, field, field_path) in as_map(
985 schema.req("fields")?,
986 &schema.path_of("fields"),
987 MAX_METADATA_FIELDS,
988 key_identifier,
989 )? {
990 let field = Obj::new(field, &field_path, &["schema", "writer", "filterable"])?;
991 shape_field_schema(field.req("schema")?, &field.path_of("schema"), 1)?;
992 let writers: &[&str] = if machine == "instance" {
993 &["creator"]
994 } else {
995 &["creator", "participant"]
996 };
997 one_of(field.req("writer")?, &field.path_of("writer"), writers)?;
998 as_bool(field.req("filterable")?, &field.path_of("filterable"))?;
999 }
1000 as_integer(
1001 schema.req("maxBytes")?,
1002 &schema.path_of("maxBytes"),
1003 1,
1004 65_536,
1005 )?;
1006 }
1007 Ok(())
1008}
1009
1010fn field_path_value<'a>(value: &'a Value, path: &str) -> ProtocolResult<&'a str> {
1011 let text = bounded_str(value, path, 128)?;
1012 let valid = !text.is_empty()
1013 && text.split('.').all(|segment| {
1014 let bytes = segment.as_bytes();
1015 !bytes.is_empty()
1016 && (bytes[0].is_ascii_alphabetic() || bytes[0] == b'_')
1017 && bytes[1..]
1018 .iter()
1019 .all(|b| b.is_ascii_alphanumeric() || *b == b'_')
1020 });
1021 if valid {
1022 Ok(text)
1023 } else {
1024 Err(err(
1025 "invalid-identifier",
1026 path,
1027 format!("'{text}' must be a dotted field path"),
1028 ))
1029 }
1030}
1031
1032fn shape_stack_surface(value: &Value, path: &str) -> ProtocolResult<()> {
1033 let stack = Obj::new(
1034 value,
1035 path,
1036 &["instanceAccounts", "instanceFields", "activity"],
1037 )?;
1038 for (_, account, account_path) in as_map(
1039 stack.req("instanceAccounts")?,
1040 &stack.path_of("instanceAccounts"),
1041 MAX_BIND_ACCOUNTS,
1042 key_identifier,
1043 )? {
1044 let account = Obj::new(account, &account_path, &["program", "account"])?;
1045 identifier_value(account.req("program")?, &account.path_of("program"))?;
1046 identifier_value(account.req("account")?, &account.path_of("account"))?;
1047 }
1048 for (_, field, field_path) in as_map(
1049 stack.req("instanceFields")?,
1050 &stack.path_of("instanceFields"),
1051 MAX_STACK_FIELDS,
1052 key_identifier,
1053 )? {
1054 let field = Obj::new(field, &field_path, &["account", "field"])?;
1055 identifier_value(field.req("account")?, &field.path_of("account"))?;
1056 field_path_value(field.req("field")?, &field.path_of("field"))?;
1057 }
1058 let activity_path = stack.path_of("activity");
1059 for (i, activity) in as_array(stack.req("activity")?, &activity_path, MAX_ACTIVITY)?
1060 .iter()
1061 .enumerate()
1062 {
1063 let activity = Obj::new(
1064 activity,
1065 &index(&activity_path, i),
1066 &[
1067 "step",
1068 "program",
1069 "instruction",
1070 "instanceAccount",
1071 "walletAccount",
1072 "amountArg",
1073 ],
1074 )?;
1075 for key in [
1076 "step",
1077 "program",
1078 "instruction",
1079 "instanceAccount",
1080 "walletAccount",
1081 ] {
1082 identifier_value(activity.req(key)?, &activity.path_of(key))?;
1083 }
1084 if let Some(value) = activity.opt("amountArg")? {
1085 identifier_value(value, &activity.path_of("amountArg"))?;
1086 }
1087 }
1088 Ok(())
1089}
1090
1091fn check_semantics(spec: &ProtocolSpecV1) -> ProtocolResult<()> {
1096 check_handler_abi(spec)?;
1097 check_reads(spec)?;
1098 check_machine(spec, MachineKind::Instance)?;
1099 check_participant_machine_present(spec)?;
1100 if spec.machines.participant.is_some() {
1101 check_machine(spec, MachineKind::Participant)?;
1102 }
1103 check_steps(spec)?;
1104 check_stack_surface(spec)?;
1105 check_reachability(spec, MachineKind::Instance)?;
1106 if spec.machines.participant.is_some() {
1107 check_reachability(spec, MachineKind::Participant)?;
1108 }
1109 check_reactions(spec)?;
1110 check_events(spec)?;
1111 Ok(())
1112}
1113
1114fn check_handler_abi(spec: &ProtocolSpecV1) -> ProtocolResult<()> {
1115 let handlers = spec.has_handlers();
1116 match (handlers, spec.handler_abi.is_some()) {
1117 (true, false) => {
1118 return Err(err(
1119 "handler-abi-required",
1120 "/handlerAbi",
1121 "handler steps need handlerAbi",
1122 ))
1123 }
1124 (false, true) => {
1125 return Err(err(
1126 "handler-abi-unused",
1127 "/handlerAbi",
1128 "no step has a handler",
1129 ))
1130 }
1131 _ => {}
1132 }
1133 match (handlers, spec.ctx_version.is_some()) {
1134 (true, false) => Err(err(
1135 "handler-abi-required",
1136 "/ctxVersion",
1137 "handler steps need ctxVersion",
1138 )),
1139 (false, true) => Err(err(
1140 "handler-abi-unused",
1141 "/ctxVersion",
1142 "no step has a handler",
1143 )),
1144 _ => Ok(()),
1145 }
1146}
1147
1148fn spec_program<'a>(
1149 spec: &'a ProtocolSpecV1,
1150 alias: &str,
1151 path: &str,
1152) -> ProtocolResult<&'a ProtocolProgramV1> {
1153 spec.programs.get(alias).ok_or_else(|| {
1154 let hint = BuiltinProgram::from_alias(alias)
1155 .or_else(|| BuiltinProgram::from_name(alias))
1156 .map(|builtin| {
1157 format!(
1158 "; '{alias}' is the builtin '{}', declared as {{\"kind\": \"builtin\", \"builtin\": \"{}\"}}",
1159 builtin.name(),
1160 builtin.name()
1161 )
1162 })
1163 .unwrap_or_default();
1164 err(
1165 "unknown-program",
1166 path,
1167 format!("program '{alias}' is not declared{hint}"),
1168 )
1169 })
1170}
1171
1172fn require_program_spec(spec: &ProtocolSpecV1, alias: &str, path: &str) -> ProtocolResult<()> {
1173 match spec_program(spec, alias, path)? {
1174 ProtocolProgramV1::ProgramSpec { .. } => Ok(()),
1175 ProtocolProgramV1::Builtin { .. } => Err(err(
1176 "unknown-program",
1177 path,
1178 format!("program '{alias}' is a builtin and has no ProgramSpec"),
1179 )),
1180 }
1181}
1182
1183fn check_reads(spec: &ProtocolSpecV1) -> ProtocolResult<()> {
1184 for (name, read) in &spec.reads {
1185 if let ReadV1::Account { program, .. } = read {
1186 require_program_spec(spec, program, &format!("/reads/{name}/program"))?;
1187 }
1188 }
1189 Ok(())
1190}
1191
1192fn require_read(spec: &ProtocolSpecV1, name: &str, path: &str) -> ProtocolResult<()> {
1193 if spec.reads.contains_key(name) {
1194 Ok(())
1195 } else {
1196 Err(err(
1197 "unknown-read",
1198 path,
1199 format!("read '{name}' is not declared"),
1200 ))
1201 }
1202}
1203
1204fn require_state(machine: &MachineV1, state: &str, path: &str) -> ProtocolResult<()> {
1205 if machine.states.contains_key(state) {
1206 Ok(())
1207 } else {
1208 Err(err(
1209 "unknown-state",
1210 path,
1211 format!("state '{state}' is not declared"),
1212 ))
1213 }
1214}
1215
1216fn check_machine(spec: &ProtocolSpecV1, kind: MachineKind) -> ProtocolResult<()> {
1217 let machine = spec
1218 .machine(kind)
1219 .expect("caller checked the machine exists");
1220 let base = format!("/machines/{}", kind.as_str());
1221 require_state(machine, &machine.initial, &format!("{base}/initial"))?;
1222 for (i, rule) in machine.derive.iter().enumerate() {
1223 let rule_path = format!("{base}/derive/{i}");
1224 for (j, from) in rule.from.iter().enumerate() {
1225 require_state(machine, from, &format!("{rule_path}/from/{j}"))?;
1226 }
1227 require_state(machine, &rule.to, &format!("{rule_path}/to"))?;
1228 if machine.states[&rule.to].kind != StateKind::Derived {
1229 return Err(err(
1230 "derive-targets-recorded-state",
1231 &format!("{rule_path}/to"),
1232 format!(
1233 "'{}' is a recorded state; derivations may only target derived states",
1234 rule.to
1235 ),
1236 ));
1237 }
1238 }
1239 for (i, read) in machine.reads.iter().enumerate() {
1240 require_read(spec, read, &format!("{base}/reads/{i}"))?;
1241 }
1242 for (name, state) in &machine.states {
1243 if state.kind == StateKind::Derived
1244 && *name != machine.initial
1245 && !machine.derive.iter().any(|rule| rule.to == *name)
1246 {
1247 return Err(err(
1248 "derived-state-without-rule",
1249 &format!("{base}/states/{name}"),
1250 format!("no derivation targets derived state '{name}'"),
1251 ));
1252 }
1253 }
1254 Ok(())
1255}
1256
1257fn uses_participant_machine(spec: &ProtocolSpecV1) -> Option<String> {
1258 for (name, step) in &spec.steps {
1259 if step.scope == StepScope::Participant {
1260 return Some(format!("/steps/{name}/scope"));
1261 }
1262 for (i, transition) in step.transitions.iter().enumerate() {
1263 if transition.machine == MachineKind::Participant {
1264 return Some(format!("/steps/{name}/transitions/{i}/machine"));
1265 }
1266 }
1267 }
1268 for (i, reaction) in spec.reactions.iter().enumerate() {
1269 let machine = match &reaction.trigger {
1270 TriggerV1::At { machine, .. }
1271 | TriggerV1::When { machine, .. }
1272 | TriggerV1::OnTransition { machine, .. } => Some(*machine),
1273 TriggerV1::OnConfirmed { .. } => None,
1274 };
1275 if machine == Some(MachineKind::Participant) {
1276 return Some(format!("/reactions/{i}/trigger/machine"));
1277 }
1278 }
1279 for (name, event) in &spec.events {
1280 if event.machine == MachineKind::Participant {
1281 return Some(format!("/events/{name}/machine"));
1282 }
1283 }
1284 if spec.metadata.participant.is_some() {
1285 return Some("/metadata/participant".to_string());
1286 }
1287 None
1288}
1289
1290fn check_participant_machine_present(spec: &ProtocolSpecV1) -> ProtocolResult<()> {
1291 if spec.machines.participant.is_none() {
1292 if let Some(path) = uses_participant_machine(spec) {
1293 return Err(err(
1294 "missing-participant-machine",
1295 &path,
1296 "participant scope needs machines.participant",
1297 ));
1298 }
1299 }
1300 Ok(())
1301}
1302
1303fn role_guard_present(step: &StepV1, role: &str) -> bool {
1304 let compact = |text: &str| {
1305 text.chars()
1306 .filter(|c| !c.is_whitespace())
1307 .collect::<String>()
1308 };
1309 let forward = format!("wallet==instance.roles.{role}");
1310 let reverse = format!("instance.roles.{role}==wallet");
1311 step.guards.iter().any(|guard| {
1312 let expr = compact(&guard.expr);
1313 expr == forward || expr == reverse
1314 })
1315}
1316
1317fn arg_schema<'a>(step: &'a StepV1, name: &str) -> Option<&'a FieldSchemaV1> {
1318 step.args.properties.get(name)
1319}
1320
1321fn check_steps(spec: &ProtocolSpecV1) -> ProtocolResult<()> {
1322 let creates: Vec<&String> = spec
1323 .steps
1324 .iter()
1325 .filter(|(_, step)| step.scope == StepScope::Create)
1326 .map(|(name, _)| name)
1327 .collect();
1328 match creates.as_slice() {
1329 [] => {
1330 return Err(err(
1331 "create-step-count",
1332 "/steps",
1333 "exactly one create step is required",
1334 ))
1335 }
1336 [_] => {}
1337 [_, second, ..] => {
1338 return Err(err(
1339 "create-step-count",
1340 &format!("/steps/{second}/scope"),
1341 "exactly one create step is allowed",
1342 ))
1343 }
1344 }
1345 for (name, step) in &spec.steps {
1346 let base = format!("/steps/{name}");
1347 let authority_path = format!("{base}/authority");
1348 let authority_ok = match (&step.authority, step.scope) {
1349 (AuthorityV1::Creator, StepScope::Create) => true,
1350 (AuthorityV1::Participant, StepScope::Participant) => true,
1351 (AuthorityV1::Role { role }, StepScope::Instance | StepScope::Participant) => {
1352 if !spec.roles.contains_key(role) {
1353 return Err(err(
1354 "unknown-role",
1355 &format!("{authority_path}/role"),
1356 format!("role '{role}' is not declared"),
1357 ));
1358 }
1359 true
1360 }
1361 (AuthorityV1::Keeper, StepScope::Instance | StepScope::Participant) => true,
1362 (AuthorityV1::SquadsProposer { .. }, StepScope::Instance) => true,
1363 _ => false,
1364 };
1365 if !authority_ok {
1366 return Err(err(
1367 "invalid-authority",
1368 &authority_path,
1369 "the authority is not allowed for this step scope",
1370 ));
1371 }
1372 match (step.authority.is_client_callable(), step.access.is_some()) {
1373 (true, false) => {
1374 return Err(err(
1375 "missing-access",
1376 &format!("{base}/access"),
1377 "client-callable steps declare access 'public' or 'secret'",
1378 ))
1379 }
1380 (false, true) => {
1381 return Err(err(
1382 "forbidden-access",
1383 &format!("{base}/access"),
1384 "keeper and admin steps are never client-callable",
1385 ))
1386 }
1387 _ => {}
1388 }
1389 let mut codes = BTreeSet::new();
1390 for (i, guard) in step.guards.iter().enumerate() {
1391 if RESERVED_GUARD_CODES.contains(&guard.code.as_str()) {
1392 return Err(err(
1393 "reserved-guard-code",
1394 &format!("{base}/guards/{i}/code"),
1395 format!(
1396 "guard code '{}' is reserved: prepares return it for access or lifecycle rejections",
1397 guard.code
1398 ),
1399 ));
1400 }
1401 if !codes.insert(guard.code.as_str()) {
1402 return Err(err(
1403 "duplicate-guard-code",
1404 &format!("{base}/guards/{i}/code"),
1405 format!("guard code '{}' is used twice", guard.code),
1406 ));
1407 }
1408 }
1409 if let AuthorityV1::Role { role } = &step.authority {
1410 if !role_guard_present(step, role) {
1411 return Err(err(
1412 "missing-role-guard",
1413 &format!("{base}/guards"),
1414 format!("role steps need the guard 'wallet == instance.roles.{role}'"),
1415 ));
1416 }
1417 }
1418 for (i, read) in step.reads.iter().enumerate() {
1419 require_read(spec, read, &format!("{base}/reads/{i}"))?;
1420 }
1421 check_fee(step, &base)?;
1422 check_transitions(spec, name, step, &base)?;
1423 check_bind(spec, step, &base)?;
1424 match &step.build {
1425 BuildV1::Template { instructions } => {
1426 for (i, instruction) in instructions.iter().enumerate() {
1427 spec_program(
1428 spec,
1429 &instruction.program,
1430 &format!("{base}/build/instructions/{i}/program"),
1431 )?;
1432 }
1433 }
1434 BuildV1::Handler { programs, .. } => {
1435 for (i, program) in programs.iter().enumerate() {
1436 spec_program(spec, program, &format!("{base}/build/programs/{i}"))?;
1437 }
1438 }
1439 }
1440 }
1441 Ok(())
1442}
1443
1444fn check_fee(step: &StepV1, base: &str) -> ProtocolResult<()> {
1445 let fee_path = format!("{base}/fee");
1446 match &step.fee {
1447 FeeBasisV1::None => Ok(()),
1448 _ if !step.authority.is_client_callable() => Err(err(
1449 "invalid-fee-basis",
1450 &fee_path,
1451 "only wallet steps carry fees",
1452 )),
1453 FeeBasisV1::Fixed => Ok(()),
1454 FeeBasisV1::Input { amount_arg, asset } => {
1455 let amount_path = format!("{fee_path}/amountArg");
1456 let schema = arg_schema(step, amount_arg).ok_or_else(|| {
1457 err(
1458 "unknown-arg",
1459 &amount_path,
1460 format!("arg '{amount_arg}' is not declared"),
1461 )
1462 })?;
1463 if schema.field_type != FieldType::String || schema.format != Some(StringFormat::U64) {
1464 return Err(err(
1465 "invalid-fee-basis",
1466 &amount_path,
1467 "the amount arg must be a string with format 'u64'",
1468 ));
1469 }
1470 if let FeeAssetV1::Spl { source_arg, .. } = asset {
1471 let source_path = format!("{fee_path}/asset/sourceArg");
1472 let schema = arg_schema(step, source_arg).ok_or_else(|| {
1473 err(
1474 "unknown-arg",
1475 &source_path,
1476 format!("arg '{source_arg}' is not declared"),
1477 )
1478 })?;
1479 if schema.field_type != FieldType::String
1480 || schema.format != Some(StringFormat::Pubkey)
1481 {
1482 return Err(err(
1483 "invalid-fee-basis",
1484 &source_path,
1485 "the source arg must be a string with format 'pubkey'",
1486 ));
1487 }
1488 }
1489 Ok(())
1490 }
1491 }
1492}
1493
1494fn check_transitions(
1495 spec: &ProtocolSpecV1,
1496 _name: &str,
1497 step: &StepV1,
1498 base: &str,
1499) -> ProtocolResult<()> {
1500 if step.scope == StepScope::Create {
1501 if !step.transitions.is_empty() {
1502 return Err(err(
1503 "create-step-transition",
1504 &format!("{base}/transitions/0"),
1505 "a create step starts the instance in its initial state and declares no transitions",
1506 ));
1507 }
1508 return Ok(());
1509 }
1510 for (i, transition) in step.transitions.iter().enumerate() {
1511 let path = format!("{base}/transitions/{i}");
1512 if transition.machine == MachineKind::Participant && step.scope != StepScope::Participant {
1513 return Err(err(
1514 "invalid-value",
1515 &format!("{path}/machine"),
1516 "only participant steps move the participant machine",
1517 ));
1518 }
1519 let machine = spec
1520 .machine(transition.machine)
1521 .expect("participant machine checked");
1522 for (j, from) in transition.from.iter().enumerate() {
1523 require_state(machine, from, &format!("{path}/from/{j}"))?;
1524 }
1525 require_state(machine, &transition.to, &format!("{path}/to"))?;
1526 if machine.states[&transition.to].kind != StateKind::Recorded {
1527 return Err(err(
1528 "transition-targets-derived-state",
1529 &format!("{path}/to"),
1530 format!(
1531 "'{}' is derived; steps may only move to recorded states",
1532 transition.to
1533 ),
1534 ));
1535 }
1536 }
1537 Ok(())
1538}
1539
1540fn check_bind(spec: &ProtocolSpecV1, step: &StepV1, base: &str) -> ProtocolResult<()> {
1541 let bind_path = format!("{base}/bind");
1542 match (step.scope, &step.bind) {
1543 (StepScope::Create, None) => Err(err(
1544 "missing-field",
1545 &bind_path,
1546 "the create step declares bind",
1547 )),
1548 (StepScope::Create, Some(bind)) => {
1549 for role in bind.roles.keys() {
1550 if !spec.roles.contains_key(role) {
1551 return Err(err(
1552 "unknown-role",
1553 &format!("{bind_path}/roles/{role}"),
1554 format!("role '{role}' is not declared"),
1555 ));
1556 }
1557 }
1558 if let Some(role) = spec
1559 .roles
1560 .keys()
1561 .find(|role| !bind.roles.contains_key(*role))
1562 {
1563 return Err(err(
1564 "role-not-bound",
1565 &format!("{bind_path}/roles"),
1566 format!("role '{role}' must be bound by the create step"),
1567 ));
1568 }
1569 Ok(())
1570 }
1571 (_, Some(_)) => Err(err(
1572 "bind-outside-create",
1573 &bind_path,
1574 "only the create step binds",
1575 )),
1576 (_, None) => Ok(()),
1577 }
1578}
1579
1580fn check_stack_surface(spec: &ProtocolSpecV1) -> ProtocolResult<()> {
1581 let bound: BTreeSet<&String> = spec
1582 .create_step()
1583 .and_then(|(_, step)| step.bind.as_ref())
1584 .map(|bind| bind.accounts.keys().collect())
1585 .unwrap_or_default();
1586 for (name, account) in &spec.stack.instance_accounts {
1587 let path = format!("/stack/instanceAccounts/{name}");
1588 if !bound.contains(name) {
1589 return Err(err(
1590 "unknown-bound-account",
1591 &path,
1592 format!("'{name}' is not bound by the create step"),
1593 ));
1594 }
1595 require_program_spec(spec, &account.program, &format!("{path}/program"))?;
1596 }
1597 for (name, field) in &spec.stack.instance_fields {
1598 if !spec.stack.instance_accounts.contains_key(&field.account) {
1599 return Err(err(
1600 "unknown-bound-account",
1601 &format!("/stack/instanceFields/{name}/account"),
1602 format!("'{}' is not a stack instance account", field.account),
1603 ));
1604 }
1605 }
1606 for (i, activity) in spec.stack.activity.iter().enumerate() {
1607 let path = format!("/stack/activity/{i}");
1608 if !spec.steps.contains_key(&activity.step) {
1609 return Err(err(
1610 "unknown-step",
1611 &format!("{path}/step"),
1612 format!("step '{}' is not declared", activity.step),
1613 ));
1614 }
1615 spec_program(spec, &activity.program, &format!("{path}/program"))?;
1616 }
1617 Ok(())
1618}
1619
1620fn machine_edges(spec: &ProtocolSpecV1, kind: MachineKind) -> Vec<(Vec<String>, String)> {
1623 let machine = spec
1624 .machine(kind)
1625 .expect("caller checked the machine exists");
1626 let non_terminal: Vec<String> = machine
1627 .states
1628 .iter()
1629 .filter(|(_, state)| !state.terminal)
1630 .map(|(name, _)| name.clone())
1631 .collect();
1632 let mut edges = Vec::new();
1633 for step in spec.steps.values() {
1634 for transition in step.transitions.iter().filter(|t| t.machine == kind) {
1635 let from = if transition.from.is_empty() {
1636 non_terminal.clone()
1637 } else {
1638 transition.from.clone()
1639 };
1640 edges.push((from, transition.to.clone()));
1641 }
1642 }
1643 for rule in &machine.derive {
1644 edges.push((rule.from.clone(), rule.to.clone()));
1645 }
1646 edges
1647}
1648
1649fn check_reachability(spec: &ProtocolSpecV1, kind: MachineKind) -> ProtocolResult<()> {
1650 let machine = spec
1651 .machine(kind)
1652 .expect("caller checked the machine exists");
1653 let base = format!("/machines/{}", kind.as_str());
1654 let edges = machine_edges(spec, kind);
1655 let mut reachable = BTreeSet::from([machine.initial.clone()]);
1656 loop {
1657 let before = reachable.len();
1658 for (from, to) in &edges {
1659 if from.iter().any(|state| reachable.contains(state)) {
1660 reachable.insert(to.clone());
1661 }
1662 }
1663 if reachable.len() == before {
1664 break;
1665 }
1666 }
1667 for (name, state) in &machine.states {
1668 let path = format!("{base}/states/{name}");
1669 if !reachable.contains(name) {
1670 return Err(err(
1671 "unreachable-state",
1672 &path,
1673 format!("'{name}' cannot be reached from '{}'", machine.initial),
1674 ));
1675 }
1676 let exits = edges
1677 .iter()
1678 .any(|(from, to)| from.contains(name) && to != name);
1679 if state.terminal && exits {
1680 return Err(err(
1681 "terminal-state-exit",
1682 &path,
1683 format!("terminal state '{name}' has an exit"),
1684 ));
1685 }
1686 if !state.terminal && !exits {
1687 return Err(err(
1688 "dead-end-state",
1689 &path,
1690 format!("non-terminal state '{name}' has no exit"),
1691 ));
1692 }
1693 }
1694 Ok(())
1695}
1696
1697fn trigger_machine(spec: &ProtocolSpecV1, trigger: &TriggerV1) -> Option<MachineKind> {
1698 match trigger {
1699 TriggerV1::At { machine, .. }
1700 | TriggerV1::When { machine, .. }
1701 | TriggerV1::OnTransition { machine, .. } => Some(*machine),
1702 TriggerV1::OnConfirmed { step } => spec.steps.get(step).map(|step| {
1703 if step.scope == StepScope::Participant {
1704 MachineKind::Participant
1705 } else {
1706 MachineKind::Instance
1707 }
1708 }),
1709 }
1710}
1711
1712fn check_reactions(spec: &ProtocolSpecV1) -> ProtocolResult<()> {
1713 let mut ids = BTreeSet::new();
1714 for (i, reaction) in spec.reactions.iter().enumerate() {
1715 let base = format!("/reactions/{i}");
1716 if !ids.insert(reaction.id.as_str()) {
1717 return Err(err(
1718 "duplicate-entry",
1719 &format!("{base}/id"),
1720 format!("reaction id '{}' is used twice", reaction.id),
1721 ));
1722 }
1723 let trigger_path = format!("{base}/trigger");
1724 match &reaction.trigger {
1725 TriggerV1::At {
1726 machine, in_states, ..
1727 }
1728 | TriggerV1::When {
1729 machine, in_states, ..
1730 } => {
1731 let machine = spec.machine(*machine).expect("participant machine checked");
1732 for (j, state) in in_states.iter().enumerate() {
1733 require_state(machine, state, &format!("{trigger_path}/in/{j}"))?;
1734 }
1735 }
1736 TriggerV1::OnTransition { machine, from, to } => {
1737 let machine = spec.machine(*machine).expect("participant machine checked");
1738 for (j, state) in from.iter().enumerate() {
1739 require_state(machine, state, &format!("{trigger_path}/from/{j}"))?;
1740 }
1741 for (j, state) in to.iter().enumerate() {
1742 require_state(machine, state, &format!("{trigger_path}/to/{j}"))?;
1743 }
1744 }
1745 TriggerV1::OnConfirmed { step } => {
1746 if !spec.steps.contains_key(step) {
1747 return Err(err(
1748 "unknown-step",
1749 &format!("{trigger_path}/step"),
1750 format!("step '{step}' is not declared"),
1751 ));
1752 }
1753 }
1754 }
1755 let action_path = format!("{base}/action");
1756 match &reaction.action {
1757 ActionV1::RunKeeperStep { step } | ActionV1::ProposeSquads { step } => {
1758 let target = spec.steps.get(step).ok_or_else(|| {
1759 err(
1760 "unknown-step",
1761 &format!("{action_path}/step"),
1762 format!("step '{step}' is not declared"),
1763 )
1764 })?;
1765 let matches = match &reaction.action {
1766 ActionV1::RunKeeperStep { .. } => {
1767 matches!(target.authority, AuthorityV1::Keeper)
1768 }
1769 _ => matches!(target.authority, AuthorityV1::SquadsProposer { .. }),
1770 };
1771 if !matches {
1772 return Err(err(
1773 "reaction-action-mismatch",
1774 &format!("{action_path}/step"),
1775 "runKeeperStep targets keeper steps and proposeSquads targets squadsProposer steps",
1776 ));
1777 }
1778 }
1779 ActionV1::Emit { event } => {
1780 let declared = spec.events.get(event).ok_or_else(|| {
1781 err(
1782 "unknown-event",
1783 &format!("{action_path}/event"),
1784 format!("event '{event}' is not declared"),
1785 )
1786 })?;
1787 if declared.machine == MachineKind::Participant
1788 && trigger_machine(spec, &reaction.trigger) != Some(MachineKind::Participant)
1789 {
1790 return Err(err(
1791 "event-machine-mismatch",
1792 &format!("{action_path}/event"),
1793 "participant events need a participant trigger",
1794 ));
1795 }
1796 }
1797 }
1798 }
1799 Ok(())
1800}
1801
1802fn check_events(spec: &ProtocolSpecV1) -> ProtocolResult<()> {
1803 for name in spec.events.keys() {
1804 let emitted = spec
1805 .reactions
1806 .iter()
1807 .any(|reaction| matches!(&reaction.action, ActionV1::Emit { event } if event == name));
1808 if !emitted {
1809 return Err(err(
1810 "event-not-emitted",
1811 &format!("/events/{name}"),
1812 format!("no emit reaction publishes '{name}'"),
1813 ));
1814 }
1815 }
1816 Ok(())
1817}