Skip to main content

arete_hash/protocol/
spec.rs

1//! Validation for `arete.protocol-spec/v1`.
2//!
3//! Validation runs in two passes:
4//!
5//! 1. **Shape**: every field, type, bound and identifier, in document order.
6//! 2. **Semantics**: references between sections and the static checks on
7//!    machines, steps, roles, reactions and events.
8//!
9//! Expression sources are only bounded here. The platform's expression
10//! checker parses and type-checks them, and template resolution against
11//! ProgramSpecs lives in [`super::template`].
12
13use 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
48/// Parse and fully validate a ProtocolSpec from JSON bytes.
49pub 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
54/// Validate a ProtocolSpec JSON value and return the typed model.
55/// Rejection codes a prepare returns for access refusals and terminal
56/// lifecycle states. A guard may not use them, so a client can always tell
57/// a guard failure from these.
58pub 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    /// The step's effective access for an integrator that narrowed it, or
94    /// `None` for steps no client can call.
95    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
112// ---------------------------------------------------------------------------
113// Shape
114// ---------------------------------------------------------------------------
115
116const 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
247/// Read a `kind` tag, then open the object with the keys that kind allows.
248pub(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        // An empty template is a state-only step: its transaction carries
781        // only the attested memo (and any glue), and its transitions apply
782        // when that transaction confirms.
783        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
838/// `remainingAccounts`: a non-empty list (omitted when there are none) of
839/// `{ pubkey, isWritable, isSigner }`. Remaining accounts never sign.
840fn 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
1091// ---------------------------------------------------------------------------
1092// Semantics
1093// ---------------------------------------------------------------------------
1094
1095fn 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
1620/// Edges of a machine: step transitions (an empty `from` means every
1621/// non-terminal state) and derivation rules.
1622fn 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}