Skip to main content

arete_hash/protocol/
stack.rs

1//! The generated protocol stack.
2//!
3//! [`generate_stack_source`] turns a spec's `stack` surface into an
4//! `arete.stack-source/v1` document wrapped in
5//! `arete.protocol-stack-source/v1`. It is a port of the TypeScript
6//! generator in `@usearete/protocol` (`src/stack/generate.ts`), which
7//! documents the two profiles and their known gaps. Both generators run the
8//! shared vectors in `test-vectors/protocol-stack/` and must produce the
9//! same canonical bytes for the same spec, options and generator identity.
10//!
11//! **`interim`** reads only the underlying programs: one `ProtocolActivity`
12//! row per protocol instruction, a `ProtocolInstance` keyed by the primary
13//! bound account and a `ProtocolParticipant` keyed by
14//! `<instance account>:<wallet>`.
15//!
16//! **`full`** decodes the attested Memo v2 through the SPL Memo program and
17//! keys rows by the memo's instance and participant ids. The attestation
18//! key, the deployment short id and the program ids are inlined as literals.
19//! It sets `lookups: strict`, so each `via` join resolves through the index it
20//! names only, never through another of the entity's indexes.
21//!
22//! Every instruction occurrence is counted once, even when several steps
23//! list the same instruction: without the memo such steps are read as one
24//! group named by their step names joined with `_or_`, and in the full
25//! profile a participant's per-step amounts (`<step>_amount`) and an
26//! instance's attested per-step amounts (`<step>_protocol_amount`) follow the
27//! step the attested memo names (through `last_<step>_signature`).
28//!
29//! Direct (unattested) activity is matched only on the instance's own
30//! accounts, those the create step binds from its ephemeral keys alone
31//! ([`instance_owned_accounts`]); a step whose instance account is anything
32//! else (a wallet, a role, a shared account) is counted from attested memos
33//! only.
34//!
35//! Generation is deterministic: the output depends only on the spec and the
36//! options.
37
38use std::collections::{BTreeMap, BTreeSet};
39
40use serde::{Deserialize, Serialize};
41use serde_json::{json, Map, Value};
42
43use super::model::{ProtocolProgramV1, ProtocolSpecV1, StackActivityV1};
44use super::release::{
45    GeneratorV1, ProtocolStackSourceV1, StackProfile, PROTOCOL_STACK_SOURCE_SCHEMA_V1,
46    STACK_SOURCE_SCHEMA_V1,
47};
48
49/// The memo prefix of Memo v2.
50pub const MEMO_V2_PREFIX: &str = "arete:p2:";
51
52/// The program alias the full profile gives the SPL Memo program.
53pub const MEMO_PROGRAM_ALIAS: &str = "areteMemo";
54
55/// The default IDL file of the SPL Memo program in the full profile.
56pub const DEFAULT_MEMO_PROGRAM_IDL: &str = "./idl/spl-memo.idl.json";
57
58const MEMO_PROGRAM_ID: &str = "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr";
59
60/// The default generator identity: this crate's name and version.
61pub fn default_stack_generator() -> GeneratorV1 {
62    GeneratorV1 {
63        name: env!("CARGO_PKG_NAME").to_string(),
64        version: env!("CARGO_PKG_VERSION").to_string(),
65    }
66}
67
68/// Where a program's IDL comes from in the generated Stack Source.
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(untagged, deny_unknown_fields)]
71pub enum StackProgramSource {
72    /// A public registry slug with an exact (`=1.2.3`) or range (`^1.2`,
73    /// `~1.2`) version requirement.
74    Registry { registry: String, version: String },
75    /// A local IDL file, relative to the Stack Source file.
76    Idl { idl: String },
77}
78
79impl StackProgramSource {
80    /// The default SPL Memo program source of the full profile.
81    pub fn default_memo() -> Self {
82        Self::Idl {
83            idl: DEFAULT_MEMO_PROGRAM_IDL.to_string(),
84        }
85    }
86}
87
88/// Options of [`generate_stack_source`]. Field names match the TypeScript
89/// `GenerateStackSourceOptions`, so the shared vectors deserialize directly.
90#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
91#[serde(rename_all = "camelCase", default, deny_unknown_fields)]
92pub struct StackSourceOptions {
93    /// `interim` (the default) or `full`.
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub profile: Option<StackProfile>,
96    /// Hosted runtime capability; kept opt-in until runtime deployment.
97    #[serde(skip_serializing_if = "std::ops::Not::not")]
98    pub operation_boundaries: bool,
99    /// Program sources by alias.
100    pub programs: BTreeMap<String, StackProgramSource>,
101    /// Full profile: the deployment revision's attestation public key (base58).
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub attestation: Option<String>,
104    /// Full profile: the deployment's short id; memos of other deployments
105    /// are ignored.
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub deployment_short_id: Option<String>,
108    /// Full profile: the SPL Memo program source. Defaults to
109    /// [`StackProgramSource::default_memo`].
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub memo_program: Option<StackProgramSource>,
112    /// The identity recorded in the output. Defaults to
113    /// [`default_stack_generator`].
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub generator: Option<GeneratorV1>,
116}
117
118/// A spec or option the generator cannot turn into a stack. The messages
119/// match the TypeScript generator's.
120#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
121#[error("{message}")]
122pub struct StackGenerationError {
123    pub message: String,
124}
125
126impl StackGenerationError {
127    fn new(message: impl Into<String>) -> Self {
128        Self {
129            message: message.into(),
130        }
131    }
132}
133
134type GenResult<T> = Result<T, StackGenerationError>;
135type Fields = Map<String, Value>;
136
137/// JavaScript's `/([a-z0-9])([A-Z])/g` → `$1-$2`, then `_` → `-`, lowercased.
138fn kebab(text: &str) -> String {
139    let chars: Vec<char> = text.chars().collect();
140    let mut out = String::with_capacity(text.len() + 4);
141    let mut i = 0;
142    while i < chars.len() {
143        let current = chars[i];
144        if (current.is_ascii_lowercase() || current.is_ascii_digit())
145            && chars.get(i + 1).is_some_and(char::is_ascii_uppercase)
146        {
147            out.push(current);
148            out.push('-');
149            out.push(chars[i + 1]);
150            i += 2;
151        } else {
152            out.push(current);
153            i += 1;
154        }
155    }
156    out.replace('_', "-").to_lowercase()
157}
158
159fn pascal(text: &str) -> String {
160    let mut chars = text.chars();
161    match chars.next() {
162        Some(first) => first.to_uppercase().chain(chars).collect(),
163        None => String::new(),
164    }
165}
166
167/// Stack Source rejects names equal once non-alphanumerics are removed and
168/// case folded. `names` is in insertion order, so the first clash reported is
169/// the one the TypeScript generator reports.
170fn assert_no_collisions<'a>(
171    names: impl IntoIterator<Item = &'a String>,
172    place: &str,
173) -> GenResult<()> {
174    let mut seen: BTreeMap<String, &String> = BTreeMap::new();
175    for name in names {
176        let folded: String = name
177            .chars()
178            .filter(char::is_ascii_alphanumeric)
179            .collect::<String>()
180            .to_ascii_lowercase();
181        if let Some(other) = seen.get(&folded) {
182            if *other != name {
183                return Err(StackGenerationError::new(format!(
184                    "{place}: generated names '{other}' and '{name}' collide"
185                )));
186            }
187        }
188        seen.insert(folded, name);
189    }
190    Ok(())
191}
192
193struct StepActivity {
194    step: String,
195    sources: Vec<String>,
196    instance_account: String,
197    wallet_account: String,
198    amount_arg: Option<String>,
199}
200
201/// The steps whose instructions overlap, read as one unit. Without the memo
202/// an instruction occurrence cannot say which of the steps that list it it
203/// belongs to, so every instruction is counted once, under its group.
204struct ActivityGroup {
205    /// The step name, or the sorted step names joined by `_or_`.
206    step: String,
207    /// The steps of the group, sorted.
208    steps: Vec<StepActivity>,
209    /// The instructions of every step, sorted and unique.
210    sources: Vec<String>,
211    instance_account: String,
212    wallet_account: String,
213    amount_arg: Option<String>,
214}
215
216/// The separator of a merged group's step names.
217const GROUP_SEPARATOR: &str = "_or_";
218
219/// Whether two activity rules name the same accounts and amount.
220fn same_roles(step: &StepActivity, entry: &StackActivityV1) -> bool {
221    step.instance_account == entry.instance_account
222        && step.wallet_account == entry.wallet_account
223        && step.amount_arg == entry.amount_arg
224}
225
226fn group_activity(activity: &[StackActivityV1]) -> GenResult<Vec<StepActivity>> {
227    let mut steps: BTreeMap<&str, StepActivity> = BTreeMap::new();
228    // The step that first listed each instruction.
229    let mut owners: BTreeMap<String, &str> = BTreeMap::new();
230    for entry in activity {
231        let source = format!("{}.instructions.{}", entry.program, entry.instruction);
232        match steps.get_mut(entry.step.as_str()) {
233            None => {
234                steps.insert(
235                    &entry.step,
236                    StepActivity {
237                        step: entry.step.clone(),
238                        sources: vec![source.clone()],
239                        instance_account: entry.instance_account.clone(),
240                        wallet_account: entry.wallet_account.clone(),
241                        amount_arg: entry.amount_arg.clone(),
242                    },
243                );
244            }
245            Some(existing) => {
246                if !same_roles(existing, entry) {
247                    return Err(StackGenerationError::new(format!(
248                        "stack.activity for step '{}': every instruction of a step must name the same instanceAccount, walletAccount and amountArg",
249                        entry.step
250                    )));
251                }
252                if existing.sources.contains(&source) {
253                    return Err(StackGenerationError::new(format!(
254                        "stack.activity lists {source} twice for step '{}'",
255                        entry.step
256                    )));
257                }
258                existing.sources.push(source.clone());
259            }
260        }
261        // One instruction occurrence has one instance account, wallet and
262        // amount, whichever step it is attributed to.
263        match owners.get(&source) {
264            None => {
265                owners.insert(source, &entry.step);
266            }
267            Some(owner) => {
268                let owner_step = &steps[owner];
269                if !same_roles(owner_step, entry) {
270                    return Err(StackGenerationError::new(format!(
271                        "stack.activity: steps '{owner}' and '{}' both list {source}, so they must name the same instanceAccount, walletAccount and amountArg",
272                        entry.step
273                    )));
274                }
275            }
276        }
277    }
278    // Identifiers are ASCII, so byte order is the TypeScript UTF-8 order.
279    Ok(steps
280        .into_values()
281        .map(|mut group| {
282            group.sources.sort();
283            group
284        })
285        .collect())
286}
287
288/// Merge steps that share an instruction into one group (transitively), so
289/// each instruction belongs to exactly one group. A step that shares
290/// nothing is a group of its own, named by the step.
291fn merge_shared_steps(steps: &[StepActivity]) -> GenResult<Vec<ActivityGroup>> {
292    let mut groups: Vec<Vec<&StepActivity>> = Vec::new();
293    for step in steps {
294        let (overlapping, rest): (Vec<_>, Vec<_>) = groups.into_iter().partition(|group| {
295            group.iter().any(|other| {
296                other
297                    .sources
298                    .iter()
299                    .any(|source| step.sources.contains(source))
300            })
301        });
302        groups = rest;
303        let mut merged: Vec<&StepActivity> = overlapping.into_iter().flatten().collect();
304        merged.push(step);
305        groups.push(merged);
306    }
307    let mut merged: Vec<ActivityGroup> = groups
308        .into_iter()
309        .map(|mut members| {
310            members.sort_by(|a, b| a.step.cmp(&b.step));
311            let first = members[0];
312            let sources: BTreeSet<&String> = members
313                .iter()
314                .flat_map(|member| member.sources.iter())
315                .collect();
316            ActivityGroup {
317                step: members
318                    .iter()
319                    .map(|member| member.step.as_str())
320                    .collect::<Vec<_>>()
321                    .join(GROUP_SEPARATOR),
322                sources: sources.into_iter().cloned().collect(),
323                instance_account: first.instance_account.clone(),
324                wallet_account: first.wallet_account.clone(),
325                amount_arg: first.amount_arg.clone(),
326                steps: members
327                    .into_iter()
328                    .map(|member| StepActivity {
329                        step: member.step.clone(),
330                        sources: member.sources.clone(),
331                        instance_account: member.instance_account.clone(),
332                        wallet_account: member.wallet_account.clone(),
333                        amount_arg: member.amount_arg.clone(),
334                    })
335                    .collect(),
336            }
337        })
338        .collect();
339    merged.sort_by(|a, b| a.step.cmp(&b.step));
340    if let Some(pair) = merged.windows(2).find(|pair| pair[0].step == pair[1].step) {
341        return Err(StackGenerationError::new(format!(
342            "stack.activity: the merged step name '{}' is also a step",
343            pair[1].step
344        )));
345    }
346    Ok(merged)
347}
348
349fn relative(sources: &[String], suffix: &str) -> Vec<String> {
350    sources
351        .iter()
352        .map(|source| format!("{source}.{suffix}"))
353        .collect()
354}
355
356fn all_sources(groups: &[ActivityGroup]) -> Vec<String> {
357    let mut all: Vec<String> = groups
358        .iter()
359        .flat_map(|group| group.sources.iter().cloned())
360        .collect();
361    all.sort();
362    all
363}
364
365/// `<source>.accounts.<role account>` of every group, sorted.
366fn account_refs(groups: &[ActivityGroup], role: fn(&ActivityGroup) -> &str) -> Vec<String> {
367    let mut refs: Vec<String> = groups
368        .iter()
369        .flat_map(|group| relative(&group.sources, &format!("accounts.{}", role(group))))
370        .collect();
371    refs.sort();
372    refs
373}
374
375fn instance_of(group: &ActivityGroup) -> &str {
376    &group.instance_account
377}
378
379fn wallet_of(group: &ActivityGroup) -> &str {
380    &group.wallet_account
381}
382
383/// `<source>.args.<amountArg>` of every group with an amount, sorted.
384fn amount_refs(groups: &[ActivityGroup]) -> Vec<String> {
385    let mut refs: Vec<String> = groups
386        .iter()
387        .filter_map(|group| {
388            group
389                .amount_arg
390                .as_ref()
391                .map(|arg| relative(&group.sources, &format!("args.{arg}")))
392        })
393        .flatten()
394        .collect();
395    refs.sort();
396    refs
397}
398
399/// A JSON string literal for the expression language.
400fn literal(text: &str) -> String {
401    Value::String(text.to_string()).to_string()
402}
403
404fn object(fields: Fields) -> Value {
405    Value::Object(fields)
406}
407
408/// Merge `extra`'s fields into an object value.
409fn with(mut value: Value, extra: &Value) -> Value {
410    if let (Some(target), Some(source)) = (value.as_object_mut(), extra.as_object()) {
411        for (key, item) in source {
412            target.insert(key.clone(), item.clone());
413        }
414    }
415    value
416}
417
418// ---------------------------------------------------------------------------
419// Programs
420// ---------------------------------------------------------------------------
421
422fn program_id_of(program: &ProtocolProgramV1) -> &str {
423    match program {
424        ProtocolProgramV1::ProgramSpec { program_id, .. } => program_id,
425        ProtocolProgramV1::Builtin { builtin } => builtin.program_id(),
426    }
427}
428
429fn program_declaration(
430    spec: &ProtocolSpecV1,
431    alias: &str,
432    source: Option<&StackProgramSource>,
433) -> Value {
434    match source {
435        None => json!({ "source": { "registry": kebab(alias) } }),
436        Some(StackProgramSource::Idl { idl }) => {
437            let mut declaration = json!({ "source": { "idl": idl } });
438            if let Some(program) = spec.programs.get(alias) {
439                declaration["program_id"] = json!(program_id_of(program));
440            }
441            declaration
442        }
443        Some(StackProgramSource::Registry { registry, version }) => {
444            json!({ "source": { "registry": registry }, "version": version })
445        }
446    }
447}
448
449fn memo_declaration(source: &StackProgramSource) -> Value {
450    match source {
451        StackProgramSource::Idl { idl } => {
452            json!({ "source": { "idl": idl }, "program_id": MEMO_PROGRAM_ID })
453        }
454        StackProgramSource::Registry { registry, version } => {
455            json!({ "source": { "registry": registry }, "version": version })
456        }
457    }
458}
459
460// ---------------------------------------------------------------------------
461// Interim profile
462// ---------------------------------------------------------------------------
463
464fn interim_activity_entity(groups: &[ActivityGroup]) -> Value {
465    let all = all_sources(groups);
466    let mut step = Fields::new();
467    for group in groups {
468        step.insert(
469            group.step.clone(),
470            json!({ "type": "bool", "from": group.sources, "expr": "true", "strategy": "set_once" }),
471        );
472    }
473    let mut fields = Fields::new();
474    fields.insert(
475        "id".into(),
476        json!({ "fields": {
477            "occurrence": {
478                "type": "string",
479                "from": all,
480                "expr": "concat(__signature, \":\", __ix_path)",
481                "strategy": "set_once",
482            },
483            "signature": { "from": relative(&all, "__signature"), "strategy": "set_once" },
484            "ix_path": { "from": relative(&all, "__ix_path"), "strategy": "set_once" },
485        }}),
486    );
487    fields.insert("step".into(), json!({ "fields": step }));
488    fields.insert(
489        "instance".into(),
490        json!({ "from": account_refs(groups, instance_of), "strategy": "set_once" }),
491    );
492    fields.insert(
493        "wallet".into(),
494        json!({ "from": account_refs(groups, wallet_of), "strategy": "set_once" }),
495    );
496    fields.insert(
497        "slot".into(),
498        json!({ "from": relative(&all, "__slot"), "strategy": "set_once" }),
499    );
500    fields.insert(
501        "timestamp".into(),
502        json!({ "from": relative(&all, "__timestamp"), "strategy": "set_once" }),
503    );
504    let amounts = amount_refs(groups);
505    if !amounts.is_empty() {
506        fields.insert(
507            "amount".into(),
508            json!({ "from": amounts, "strategy": "set_once" }),
509        );
510    }
511    json!({
512        "primary_key": "id.occurrence",
513        "indexes": ["instance", "wallet"],
514        "fields": fields,
515        "views": {
516            "byInstance": {
517                "pipeline": [{ "sort": { "by": "slot", "order": "desc" } }],
518                "output": "collection",
519            },
520        },
521    })
522}
523
524fn account_ref(spec: &ProtocolSpecV1, primary: &str) -> String {
525    let account = &spec.stack.instance_accounts[primary];
526    format!("{}.accounts.{}", account.program, account.account)
527}
528
529fn instance_state(spec: &ProtocolSpecV1, primary: &str) -> Fields {
530    let account = account_ref(spec, primary);
531    let mut state = Fields::new();
532    for (name, field) in &spec.stack.instance_fields {
533        if field.account != primary {
534            continue;
535        }
536        state.insert(name.clone(), json!(format!("{account}.{}", field.field)));
537    }
538    state
539}
540
541fn interim_instance_entity(
542    spec: &ProtocolSpecV1,
543    primary: &str,
544    groups: &[ActivityGroup],
545) -> GenResult<Value> {
546    let account = account_ref(spec, primary);
547    let state = instance_state(spec, primary);
548    let mut activity = Fields::new();
549    let mut keys = Vec::new();
550    for group in groups {
551        let lookup = format!("accounts.{}", group.instance_account);
552        let mut put = |key: String, value: Value| {
553            keys.push(key.clone());
554            activity.insert(key, value);
555        };
556        put(
557            format!("{}_count", group.step),
558            json!({ "aggregate": { "from": group.sources, "op": "count", "lookup_by": lookup } }),
559        );
560        put(
561            format!("{}_last_slot", group.step),
562            json!({ "aggregate": {
563                "from": group.sources, "op": "max", "field": "__slot", "lookup_by": lookup,
564            }}),
565        );
566        if let Some(arg) = &group.amount_arg {
567            put(
568                format!("{}_amount", group.step),
569                json!({ "aggregate": {
570                    "from": group.sources, "op": "sum", "field": format!("args.{arg}"), "lookup_by": lookup,
571                }}),
572            );
573        }
574    }
575    assert_no_collisions(keys.iter(), "ProtocolInstance.activity")?;
576    let mut fields = Fields::new();
577    fields.insert(
578        "id".into(),
579        json!({ "fields": { "address": {
580            "from": format!("{account}.__account_address"), "strategy": "set_once",
581        }}}),
582    );
583    if !state.is_empty() {
584        fields.insert("state".into(), json!({ "fields": state }));
585    }
586    if !activity.is_empty() {
587        fields.insert("activity".into(), json!({ "fields": activity }));
588    }
589    Ok(json!({
590        "primary_key": "id.address",
591        "fields": fields,
592        "views": {
593            "byId": {
594                "pipeline": [{ "sort": { "by": "id.address", "order": "asc" } }],
595                "output": "collection",
596            },
597        },
598    }))
599}
600
601/// The accounts the create step binds from one of its ephemeral keys, the
602/// instance's own accounts: the only accounts the full profile matches direct
603/// activity on (see the TypeScript generator's "Direct activity"). A binding
604/// qualifies only when its whole expression is a pure derivation of a key the
605/// create step declares (see [`is_fresh_derivation`]); one that merely
606/// mentions `ephemeral.<name>` somewhere (under `if`, `??` or an operator,
607/// say) could evaluate to some other, possibly shared, account.
608pub fn instance_owned_accounts(spec: &ProtocolSpecV1) -> BTreeSet<String> {
609    let Some((bind, ephemeral)) = spec
610        .create_step()
611        .and_then(|(_, step)| step.bind.as_ref().map(|bind| (bind, &step.ephemeral)))
612    else {
613        return BTreeSet::new();
614    };
615    bind.accounts
616        .iter()
617        .filter(|(_, expr)| {
618            let tokens = owned_tokens(expr);
619            is_fresh_derivation(&tokens, ephemeral)
620        })
621        .map(|(name, _)| name.clone())
622        .collect()
623}
624
625fn is_ident_byte(byte: u8) -> bool {
626    byte.is_ascii_alphanumeric() || byte == b'_'
627}
628
629/// A token of the instance-owned check: a dotted name, a string literal, or
630/// any other single byte (punctuation, an operator byte, a non-ASCII byte).
631#[derive(Debug, PartialEq, Eq)]
632enum OwnedToken<'a> {
633    Name(&'a str),
634    Text,
635    Byte(u8),
636}
637
638/// Tokenize an expression for [`is_fresh_derivation`]. Byte-wise; only ASCII
639/// bytes are significant, so it reads the same text as the TypeScript
640/// tokenizer's UTF-16 walk. Whatever it cannot read becomes `Byte` tokens,
641/// which never form a derivation.
642fn owned_tokens(expr: &str) -> Vec<OwnedToken<'_>> {
643    let bytes = expr.as_bytes();
644    let mut tokens = Vec::new();
645    let mut i = 0;
646    while i < bytes.len() {
647        let byte = bytes[i];
648        if byte.is_ascii_whitespace() {
649            i += 1;
650        } else if byte == b'"' || byte == b'\'' {
651            // A string literal: skip to its closing quote, honouring escapes.
652            i += 1;
653            while i < bytes.len() && bytes[i] != byte {
654                i += if bytes[i] == b'\\' { 2 } else { 1 };
655            }
656            i += 1;
657            tokens.push(OwnedToken::Text);
658        } else if is_ident_byte(byte) || byte == b'.' {
659            let start = i;
660            while i < bytes.len() && (is_ident_byte(bytes[i]) || bytes[i] == b'.') {
661                i += 1;
662            }
663            tokens.push(OwnedToken::Name(&expr[start..i]));
664        } else {
665            tokens.push(OwnedToken::Byte(byte));
666            i += 1;
667        }
668    }
669    tokens
670}
671
672/// The index of the `)` closing the `(` at `open`, if any.
673fn closing_paren(tokens: &[OwnedToken<'_>], open: usize) -> Option<usize> {
674    let mut depth = 0usize;
675    for (i, token) in tokens.iter().enumerate().skip(open) {
676        match token {
677            OwnedToken::Byte(b'(') => depth += 1,
678            OwnedToken::Byte(b')') => {
679                depth -= 1;
680                if depth == 0 {
681                    return Some(i);
682                }
683            }
684            _ => {}
685        }
686    }
687    None
688}
689
690/// Split call arguments at their top-level commas.
691fn call_arguments<'t, 'a>(tokens: &'t [OwnedToken<'a>]) -> Vec<&'t [OwnedToken<'a>]> {
692    let mut args = Vec::new();
693    let mut depth = 0i64;
694    let mut start = 0;
695    for (i, token) in tokens.iter().enumerate() {
696        match token {
697            OwnedToken::Byte(b'(' | b'[') => depth += 1,
698            OwnedToken::Byte(b')' | b']') => depth -= 1,
699            OwnedToken::Byte(b',') if depth == 0 => {
700                args.push(&tokens[start..i]);
701                start = i + 1;
702            }
703            _ => {}
704        }
705    }
706    args.push(&tokens[start..]);
707    args
708}
709
710/// Whether an expression can only evaluate to an address fresh to one
711/// instance: exactly `ephemeral.<name>` for a declared key, or `pda(...)`
712/// with such an expression among its seeds, or `ata(...)` with one as its
713/// owner or mint, optionally in parentheses. A fresh key makes the derived
714/// address fresh whatever the other arguments are. Anything else, even when
715/// it mentions an ephemeral key, is not instance-owned.
716fn is_fresh_derivation(tokens: &[OwnedToken<'_>], ephemeral: &[String]) -> bool {
717    match tokens {
718        [OwnedToken::Name(name)] => name
719            .strip_prefix("ephemeral.")
720            .is_some_and(|key| ephemeral.iter().any(|declared| declared == key)),
721        [OwnedToken::Byte(b'('), .., OwnedToken::Byte(b')')] => {
722            closing_paren(tokens, 0) == Some(tokens.len() - 1)
723                && is_fresh_derivation(&tokens[1..tokens.len() - 1], ephemeral)
724        }
725        [OwnedToken::Name(function), OwnedToken::Byte(b'('), .., OwnedToken::Byte(b')')]
726            if closing_paren(tokens, 1) == Some(tokens.len() - 1) =>
727        {
728            let args = call_arguments(&tokens[2..tokens.len() - 1]);
729            let fresh = |arg: &&[OwnedToken<'_>]| is_fresh_derivation(arg, ephemeral);
730            match *function {
731                "pda" => args.iter().skip(2).any(fresh),
732                "ata" => args.len() == 2 && args.iter().any(fresh),
733                _ => false,
734            }
735        }
736        _ => false,
737    }
738}
739
740/// The one instruction account each role is read from, across every
741/// activity group.
742fn shared_account(
743    groups: &[ActivityGroup],
744    role: fn(&ActivityGroup) -> &str,
745    role_name: &str,
746    place: &str,
747) -> GenResult<String> {
748    let names: BTreeSet<&str> = groups.iter().map(role).collect();
749    if names.len() != 1 {
750        let found: Vec<&str> = names.into_iter().collect();
751        return Err(StackGenerationError::new(format!(
752            "{place} needs every stack.activity entry to name the same {role_name}; found {}",
753            found.join(", ")
754        )));
755    }
756    Ok(names.into_iter().next().unwrap_or_default().to_string())
757}
758
759fn interim_participant_entity(groups: &[ActivityGroup]) -> GenResult<Value> {
760    let all = all_sources(groups);
761    // A row key is one field (generated SDKs support no composite keys), and
762    // its expression reads the same account names from every instruction.
763    let instance_account = shared_account(
764        groups,
765        instance_of,
766        "instanceAccount",
767        "ProtocolParticipant",
768    )?;
769    let wallet_account = shared_account(groups, wallet_of, "walletAccount", "ProtocolParticipant")?;
770    let mut activity = Fields::new();
771    let mut keys = Vec::new();
772    for group in groups {
773        let count = format!("{}_count", group.step);
774        activity.insert(
775            count.clone(),
776            json!({ "type": "u64", "from": group.sources, "expr": "1", "strategy": "sum" }),
777        );
778        keys.push(count);
779        if let Some(arg) = &group.amount_arg {
780            let amount = format!("{}_amount", group.step);
781            activity.insert(
782                amount.clone(),
783                json!({ "from": relative(&group.sources, &format!("args.{arg}")), "strategy": "sum" }),
784            );
785            keys.push(amount);
786        }
787    }
788    activity.insert(
789        "last_slot".into(),
790        json!({ "from": relative(&all, "__slot"), "strategy": "max" }),
791    );
792    keys.push("last_slot".into());
793    assert_no_collisions(keys.iter(), "ProtocolParticipant.activity")?;
794    Ok(json!({
795        "primary_key": "id.key",
796        "indexes": ["id.instance"],
797        "fields": {
798            "id": { "fields": {
799                "key": {
800                    "type": "string",
801                    "from": all,
802                    "expr": format!("concat(accounts.{instance_account}, \":\", accounts.{wallet_account})"),
803                    "strategy": "set_once",
804                },
805                "instance": { "from": account_refs(groups, instance_of), "strategy": "set_once" },
806                "wallet": { "from": account_refs(groups, wallet_of), "strategy": "set_once" },
807            }},
808            "activity": { "fields": activity },
809        },
810        "views": {
811            "byInstance": {
812                "pipeline": [{ "sort": { "by": "id.instance", "order": "asc" } }],
813                "output": "collection",
814            },
815        },
816    }))
817}
818
819// ---------------------------------------------------------------------------
820// Full profile
821// ---------------------------------------------------------------------------
822
823/// Memo v2 field positions, counting from 1 as `split_part` does.
824#[derive(Clone, Copy)]
825enum MemoField {
826    Scope = 4,
827    Instance = 5,
828    Participant = 6,
829    Step = 7,
830}
831
832fn memo_part(field: MemoField) -> String {
833    format!("split_part(args.memo, \":\", {})", field as u8)
834}
835
836struct MemoContext {
837    /// `<alias>.instructions.memo`.
838    memo: String,
839    /// Holds for an attested Memo v2 of this deployment.
840    attested: String,
841    scoped: bool,
842}
843
844impl MemoContext {
845    /// A string field written once from an attested memo.
846    fn string(&self, expr: impl Into<String>) -> Value {
847        self.string_guarded(expr, &self.attested)
848    }
849
850    fn string_guarded(&self, expr: impl Into<String>, guard: &str) -> Value {
851        json!({ "type": "string", "from": self.memo, "expr": expr.into(), "strategy": "set_once", "guard": guard })
852    }
853}
854
855/// A step's attested amount on the instance row: the `last_<step>_signature`
856/// field (hidden) and the `<step>_protocol_amount` field joined through it. An
857/// instruction adds to a step's amount only in a transaction whose attested
858/// memo names that step, so it counts once, under the step the memo names.
859fn protocol_amount(
860    step: &StepActivity,
861    ctx: &MemoContext,
862) -> Option<(String, Value, String, Value)> {
863    let arg = step.amount_arg.as_ref()?;
864    let signature = json!({
865        "type": "string",
866        "from": ctx.memo,
867        "expr": "__signature",
868        "strategy": "last_write",
869        "guard": format!("{} && {} == {}", ctx.attested, memo_part(MemoField::Step), literal(&step.step)),
870        "emit": false,
871    });
872    let amount = json!({
873        "from": relative(&step.sources, &format!("args.{arg}")),
874        "strategy": "sum",
875        "lookup_by": "__signature",
876        "via": last_step_signature(&step.step),
877    });
878    Some((
879        last_step_signature(&step.step),
880        signature,
881        format!("{}_protocol_amount", step.step),
882        amount,
883    ))
884}
885
886fn full_instance_entity(
887    spec: &ProtocolSpecV1,
888    primary: Option<&str>,
889    groups: &[ActivityGroup],
890    ctx: &MemoContext,
891) -> GenResult<Value> {
892    // First-appearance order over the step-sorted groups, as the TypeScript
893    // `Set` keeps it.
894    let mut instance_accounts: Vec<&str> = Vec::new();
895    for group in groups {
896        if !instance_accounts.contains(&group.instance_account.as_str()) {
897            instance_accounts.push(&group.instance_account);
898        }
899    }
900    if instance_accounts.len() > 1 {
901        return Err(StackGenerationError::new(format!(
902            "the full profile routes all activity through one instance account; stack.activity names {}",
903            instance_accounts.join(", ")
904        )));
905    }
906    // Direct activity is matched only on the instance's own accounts.
907    let direct = instance_accounts.len() == 1
908        && instance_owned_accounts(spec).contains(instance_accounts[0]);
909    let lookup = instance_accounts
910        .first()
911        .filter(|_| direct)
912        .map(|account| format!("accounts.{account}"));
913    let all = all_sources(groups);
914
915    let registrations: Vec<Value> = groups
916        .iter()
917        .filter(|_| direct)
918        .flat_map(|group| {
919            group.sources.iter().map(|source| {
920                json!({
921                    "instruction": ctx.memo,
922                    "sibling": source,
923                    "pda_field": format!("sibling.accounts.{}", group.instance_account),
924                    "seed_expr": memo_part(MemoField::Instance),
925                    "guard": ctx.attested,
926                    "lookup_name": "instance_accounts",
927                })
928            })
929        })
930        .collect();
931
932    let mut id = Fields::new();
933    id.insert(
934        "instance".into(),
935        ctx.string(memo_part(MemoField::Instance)),
936    );
937    if ctx.scoped {
938        id.insert("scope".into(), ctx.string(memo_part(MemoField::Scope)));
939    }
940
941    let mut activity = Fields::new();
942    let mut step_signatures = Fields::new();
943    let mut keys = Vec::new();
944    if let Some(lookup) = &lookup {
945        let via = json!({ "lookup_by": lookup, "via": "instance_accounts" });
946        for group in groups {
947            // The instructions count once, under the group; the memo names
948            // the step, so the attested part is counted per step.
949            let step = &group.step;
950            let total = format!("(activity.{step}_count ?? 0)");
951            let parts: Vec<String> = group
952                .steps
953                .iter()
954                .map(|member| format!("(activity.{}_protocol_count ?? 0)", member.step))
955                .collect();
956            let attested = if parts.len() == 1 {
957                parts[0].clone()
958            } else {
959                format!("({})", parts.join(" + "))
960            };
961            let mut put = |key: String, value: Value| {
962                keys.push(key.clone());
963                activity.insert(key, value);
964            };
965            put(
966                format!("{step}_count"),
967                json!({ "aggregate": with(json!({ "from": group.sources, "op": "count" }), &via) }),
968            );
969            for member in &group.steps {
970                put(
971                    format!("{}_protocol_count", member.step),
972                    json!({
973                        "type": "u64",
974                        "from": ctx.memo,
975                        "expr": "1",
976                        "strategy": "sum",
977                        "guard": format!("{} && {} == {}", ctx.attested, memo_part(MemoField::Step), literal(&member.step)),
978                    }),
979                );
980                if let Some((name, signature, key, amount)) = protocol_amount(member, ctx) {
981                    step_signatures.insert(name, signature);
982                    put(key, amount);
983                }
984            }
985            put(
986                format!("{step}_direct_count"),
987                json!({
988                    "type": "u64",
989                    "compute": format!(
990                        "if({total} >= {attested}, {total} - {attested}, 0)"
991                    ),
992                }),
993            );
994            if let Some(arg) = &group.amount_arg {
995                put(
996                    format!("{step}_amount"),
997                    json!({ "aggregate": with(
998                        json!({ "from": group.sources, "op": "sum", "field": format!("args.{arg}") }),
999                        &via,
1000                    )}),
1001                );
1002            }
1003        }
1004        let mut put = |key: &str, value: Value| {
1005            keys.push(key.to_string());
1006            activity.insert(key.to_string(), value);
1007        };
1008        put(
1009            "last_signature",
1010            with(json!({ "from": relative(&all, "__signature") }), &via),
1011        );
1012        put(
1013            "last_ix_path",
1014            with(json!({ "from": relative(&all, "__ix_path") }), &via),
1015        );
1016        put(
1017            "last_slot",
1018            with(
1019                json!({ "from": relative(&all, "__slot"), "strategy": "max" }),
1020                &via,
1021            ),
1022        );
1023        put(
1024            "last_timestamp",
1025            with(
1026                json!({ "from": relative(&all, "__timestamp"), "strategy": "max" }),
1027                &via,
1028            ),
1029        );
1030    } else if !groups.is_empty() {
1031        // No account belongs to the instance alone, so only attested memos
1032        // reach the row: per-step protocol counts, and the last protocol
1033        // transaction.
1034        let mut put = |key: String, value: Value| {
1035            keys.push(key.clone());
1036            activity.insert(key, value);
1037        };
1038        for member in groups.iter().flat_map(|group| &group.steps) {
1039            put(
1040                format!("{}_protocol_count", member.step),
1041                json!({
1042                    "type": "u64",
1043                    "from": ctx.memo,
1044                    "expr": "1",
1045                    "strategy": "sum",
1046                    "guard": format!("{} && {} == {}", ctx.attested, memo_part(MemoField::Step), literal(&member.step)),
1047                }),
1048            );
1049            if let Some((name, signature, key, amount)) = protocol_amount(member, ctx) {
1050                step_signatures.insert(name, signature);
1051                put(key, amount);
1052            }
1053        }
1054        let memo_field = |field: &str| format!("{}.{field}", ctx.memo);
1055        put(
1056            "last_signature".into(),
1057            json!({ "from": memo_field("__signature"), "guard": ctx.attested }),
1058        );
1059        put(
1060            "last_ix_path".into(),
1061            json!({ "from": memo_field("__ix_path"), "guard": ctx.attested }),
1062        );
1063        put(
1064            "last_slot".into(),
1065            json!({ "from": memo_field("__slot"), "strategy": "max", "guard": ctx.attested }),
1066        );
1067        put(
1068            "last_timestamp".into(),
1069            json!({ "from": memo_field("__timestamp"), "strategy": "max", "guard": ctx.attested }),
1070        );
1071    }
1072    assert_no_collisions(keys.iter(), "ProtocolInstance.activity")?;
1073
1074    let mut fields = Fields::new();
1075    fields.insert("id".into(), json!({ "fields": id }));
1076    let mut indexes = vec!["id.instance".to_string()];
1077    let mut signature_names: Vec<String> = step_signatures.keys().cloned().collect();
1078    signature_names.sort();
1079    indexes.extend(signature_names);
1080    fields.extend(step_signatures);
1081    let state = match (primary, &lookup) {
1082        (Some(primary), Some(_)) => instance_state(spec, primary),
1083        _ => Fields::new(),
1084    };
1085    if !state.is_empty() {
1086        fields.insert("state".into(), json!({ "fields": state }));
1087    }
1088    if !activity.is_empty() {
1089        fields.insert("activity".into(), json!({ "fields": activity }));
1090    }
1091    let mut entity = Fields::new();
1092    entity.insert("primary_key".into(), json!("id.instance"));
1093    if ctx.scoped {
1094        entity.insert("scope".into(), json!("id.scope"));
1095    }
1096    entity.insert("indexes".into(), json!(indexes));
1097    if !registrations.is_empty() {
1098        entity.insert("register_from".into(), Value::Array(registrations));
1099    }
1100    entity.insert("fields".into(), object(fields));
1101    entity.insert(
1102        "views".into(),
1103        json!({
1104            "byId": {
1105                "pipeline": [{ "sort": { "by": "id.instance", "order": "asc" } }],
1106                "output": "collection",
1107            },
1108        }),
1109    );
1110    Ok(object(entity))
1111}
1112
1113fn full_activity_entity(groups: &[ActivityGroup], ctx: &MemoContext) -> Value {
1114    let mut fields = Fields::new();
1115    fields.insert(
1116        "id".into(),
1117        json!({ "fields": { "signature": ctx.string("__signature") } }),
1118    );
1119    fields.insert(
1120        "instance".into(),
1121        ctx.string(memo_part(MemoField::Instance)),
1122    );
1123    fields.insert(
1124        "participant".into(),
1125        ctx.string(memo_part(MemoField::Participant)),
1126    );
1127    fields.insert("step".into(), ctx.string(memo_part(MemoField::Step)));
1128    fields.insert(
1129        "attested".into(),
1130        json!({ "type": "bool", "from": ctx.memo, "expr": "true", "strategy": "set_once", "guard": ctx.attested }),
1131    );
1132    fields.insert("via".into(), ctx.string(literal("protocol")));
1133    fields.insert(
1134        "slot".into(),
1135        json!({ "from": format!("{}.__slot", ctx.memo), "strategy": "set_once", "guard": ctx.attested }),
1136    );
1137    fields.insert(
1138        "timestamp".into(),
1139        json!({ "from": format!("{}.__timestamp", ctx.memo), "strategy": "set_once", "guard": ctx.attested }),
1140    );
1141    if ctx.scoped {
1142        fields.insert("scope".into(), ctx.string(memo_part(MemoField::Scope)));
1143    }
1144    if !groups.is_empty() {
1145        let join = json!({ "lookup_by": "__signature", "via": "id.signature" });
1146        fields.insert(
1147            "wallet".into(),
1148            with(
1149                json!({ "from": account_refs(groups, wallet_of), "strategy": "set_once" }),
1150                &join,
1151            ),
1152        );
1153        let amounts = amount_refs(groups);
1154        if !amounts.is_empty() {
1155            fields.insert(
1156                "amount".into(),
1157                with(json!({ "from": amounts, "strategy": "sum" }), &join),
1158            );
1159        }
1160        // The account the step's activity entry names (`instanceAccount`).
1161        fields.insert(
1162            "activity_account".into(),
1163            with(
1164                json!({ "from": account_refs(groups, instance_of), "strategy": "set_once" }),
1165                &join,
1166            ),
1167        );
1168    }
1169    let mut entity = Fields::new();
1170    entity.insert("primary_key".into(), json!("id.signature"));
1171    if ctx.scoped {
1172        entity.insert("scope".into(), json!("scope"));
1173    }
1174    entity.insert("indexes".into(), json!(["id.signature", "instance"]));
1175    entity.insert("fields".into(), object(fields));
1176    entity.insert(
1177        "views".into(),
1178        json!({
1179            "byInstance": {
1180                "pipeline": [{ "sort": { "by": "slot", "order": "desc" } }],
1181                "output": "collection",
1182            },
1183        }),
1184    );
1185    object(entity)
1186}
1187
1188/// The instance or participant field holding the signature of the last
1189/// attested memo naming `step`.
1190fn last_step_signature(step: &str) -> String {
1191    format!("last_{step}_signature")
1192}
1193
1194fn full_participant_entity(
1195    steps: &[StepActivity],
1196    groups: &[ActivityGroup],
1197    ctx: &MemoContext,
1198) -> GenResult<Value> {
1199    let with_participant = format!(
1200        "{} && {} != \"-\"",
1201        ctx.attested,
1202        memo_part(MemoField::Participant)
1203    );
1204    let mut activity = Fields::new();
1205    let mut step_signatures = Fields::new();
1206    let mut keys = Vec::new();
1207    for step in steps {
1208        let named = format!(
1209            "{with_participant} && {} == {}",
1210            memo_part(MemoField::Step),
1211            literal(&step.step)
1212        );
1213        let count = format!("{}_count", step.step);
1214        activity.insert(
1215            count.clone(),
1216            json!({ "type": "u64", "from": ctx.memo, "expr": "1", "strategy": "sum", "guard": named }),
1217        );
1218        keys.push(count);
1219        if let Some(arg) = &step.amount_arg {
1220            // An instruction adds to a step's amount only in a transaction
1221            // whose attested memo names that step, so an instruction several
1222            // steps list counts once, under the step the memo names.
1223            step_signatures.insert(
1224                last_step_signature(&step.step),
1225                json!({
1226                    "type": "string",
1227                    "from": ctx.memo,
1228                    "expr": "__signature",
1229                    "strategy": "last_write",
1230                    "guard": named,
1231                    "emit": false,
1232                }),
1233            );
1234            let amount = format!("{}_amount", step.step);
1235            activity.insert(
1236                amount.clone(),
1237                json!({
1238                    "from": relative(&step.sources, &format!("args.{arg}")),
1239                    "strategy": "sum",
1240                    "lookup_by": "__signature",
1241                    "via": last_step_signature(&step.step),
1242                }),
1243            );
1244            keys.push(amount);
1245        }
1246    }
1247    activity.insert(
1248        "last_slot".into(),
1249        json!({ "from": format!("{}.__slot", ctx.memo), "strategy": "max", "guard": with_participant }),
1250    );
1251    keys.push("last_slot".into());
1252    assert_no_collisions(keys.iter(), "ProtocolParticipant.activity")?;
1253    let mut fields = Fields::new();
1254    fields.insert(
1255        "id".into(),
1256        json!({ "fields": { "participant": ctx.string_guarded(memo_part(MemoField::Participant), &with_participant) } }),
1257    );
1258    fields.insert(
1259        "instance".into(),
1260        ctx.string_guarded(memo_part(MemoField::Instance), &with_participant),
1261    );
1262    fields.insert(
1263        "last_signature".into(),
1264        json!({
1265            "type": "string",
1266            "from": ctx.memo,
1267            "expr": "__signature",
1268            "strategy": "last_write",
1269            "guard": with_participant,
1270            "emit": false,
1271        }),
1272    );
1273    let mut indexes = vec![
1274        "id.participant".to_string(),
1275        "instance".to_string(),
1276        "last_signature".to_string(),
1277    ];
1278    let mut signature_names: Vec<String> = step_signatures.keys().cloned().collect();
1279    signature_names.sort();
1280    indexes.extend(signature_names);
1281    fields.extend(step_signatures);
1282    fields.insert("activity".into(), json!({ "fields": activity }));
1283    if ctx.scoped {
1284        fields.insert(
1285            "scope".into(),
1286            ctx.string_guarded(memo_part(MemoField::Scope), &with_participant),
1287        );
1288    }
1289    if !groups.is_empty() {
1290        fields.insert(
1291            "wallet".into(),
1292            json!({
1293                "from": account_refs(groups, wallet_of),
1294                "strategy": "set_once",
1295                "lookup_by": "__signature",
1296                "via": "last_signature",
1297            }),
1298        );
1299    }
1300    let mut entity = Fields::new();
1301    entity.insert("primary_key".into(), json!("id.participant"));
1302    if ctx.scoped {
1303        entity.insert("scope".into(), json!("scope"));
1304    }
1305    entity.insert("indexes".into(), json!(indexes));
1306    entity.insert("fields".into(), object(fields));
1307    entity.insert(
1308        "views".into(),
1309        json!({
1310            "byInstance": {
1311                "pipeline": [{ "sort": { "by": "instance", "order": "asc" } }],
1312                "output": "collection",
1313            },
1314        }),
1315    );
1316    Ok(object(entity))
1317}
1318
1319/// `^[A-Za-z0-9_-]{1,64}$`.
1320fn is_short_id(text: &str) -> bool {
1321    (1..=64).contains(&text.len())
1322        && text
1323            .bytes()
1324            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
1325}
1326
1327/// A base58 text of a 32-byte key, at most 44 bytes long.
1328fn is_pubkey(text: &str) -> bool {
1329    text.len() <= 44
1330        && bs58::decode(text)
1331            .into_vec()
1332            .is_ok_and(|bytes| bytes.len() == 32)
1333}
1334
1335fn memo_context(spec: &ProtocolSpecV1, options: &StackSourceOptions) -> GenResult<MemoContext> {
1336    let attestation = match options.attestation.as_deref() {
1337        Some(key) if is_pubkey(key) => key,
1338        _ => {
1339            return Err(StackGenerationError::new(
1340                "the full profile needs options.attestation: the deployment revision's attestation public key (base58)",
1341            ))
1342        }
1343    };
1344    let prefix = match options.deployment_short_id.as_deref() {
1345        None => MEMO_V2_PREFIX.to_string(),
1346        Some(short_id) if is_short_id(short_id) => format!("{MEMO_V2_PREFIX}{short_id}:"),
1347        Some(_) => {
1348            return Err(StackGenerationError::new(
1349                "options.deploymentShortId must be 1-64 characters of [A-Za-z0-9_-]",
1350            ))
1351        }
1352    };
1353    Ok(MemoContext {
1354        memo: format!("{MEMO_PROGRAM_ALIAS}.instructions.memo"),
1355        attested: format!(
1356            "starts_with(args.memo, {}) && accounts.signer == {} && accounts.signer.is_signer",
1357            literal(&prefix),
1358            literal(attestation)
1359        ),
1360        scoped: spec.instance_visibility == super::model::InstanceVisibility::Account,
1361    })
1362}
1363
1364// ---------------------------------------------------------------------------
1365// Entry point
1366// ---------------------------------------------------------------------------
1367
1368fn guard_operation_activity(value: &mut Value, sources: &[String], attestation: &str) {
1369    match value {
1370        Value::Array(values) => {
1371            for value in values {
1372                guard_operation_activity(value, sources, attestation);
1373            }
1374        }
1375        Value::Object(object) => {
1376            if object.get("sibling").is_some_and(Value::is_string) {
1377                object.insert(
1378                    "sibling_where".into(),
1379                    json!({"__protocol_activity_attestation":attestation}),
1380                );
1381            }
1382            let references = match object.get("from") {
1383                Some(Value::String(from)) => vec![from.as_str()],
1384                Some(Value::Array(from)) => from.iter().filter_map(Value::as_str).collect(),
1385                _ => vec![],
1386            };
1387            if references.iter().any(|reference| {
1388                sources.iter().any(|source| {
1389                    *reference == source || reference.starts_with(&format!("{source}."))
1390                })
1391            }) {
1392                let member = format!(
1393                    "__protocol_activity_attestation == {}",
1394                    literal(attestation)
1395                );
1396                let guard =
1397                    if object.get("via").and_then(Value::as_str) == Some("instance_accounts") {
1398                        "__protocol_attestation == \"\" || __protocol_activity_attestation != \"\""
1399                            .to_string()
1400                    } else {
1401                        member
1402                    };
1403                let guard = match object.get("guard").and_then(Value::as_str) {
1404                    Some(existing) => format!("({existing}) && ({guard})"),
1405                    None => guard,
1406                };
1407                object.insert("guard".into(), Value::String(guard));
1408            }
1409            for value in object.values_mut() {
1410                guard_operation_activity(value, sources, attestation);
1411            }
1412        }
1413        _ => {}
1414    }
1415}
1416
1417/// Generate the protocol stack source of a validated spec.
1418///
1419/// The output depends only on `spec` and `options`. With the TypeScript
1420/// generator's identity in `options.generator`, its canonical JSON is
1421/// byte-identical to the TypeScript output.
1422pub fn generate_stack_source(
1423    spec: &ProtocolSpecV1,
1424    options: &StackSourceOptions,
1425) -> Result<ProtocolStackSourceV1, StackGenerationError> {
1426    let profile = options.profile.unwrap_or(StackProfile::Interim);
1427    let primary = spec
1428        .stack
1429        .instance_accounts
1430        .keys()
1431        .next()
1432        .map(String::as_str);
1433    let steps = group_activity(&spec.stack.activity)?;
1434    let groups = merge_shared_steps(&steps)?;
1435    if primary.is_none() && groups.is_empty() {
1436        return Err(StackGenerationError::new(
1437            "the protocol stack needs stack.instanceAccounts or stack.activity; declare what the stack should track",
1438        ));
1439    }
1440
1441    let mut aliases: BTreeSet<&str> = spec
1442        .stack
1443        .activity
1444        .iter()
1445        .map(|entry| entry.program.as_str())
1446        .collect();
1447    if let Some(primary) = primary {
1448        aliases.insert(&spec.stack.instance_accounts[primary].program);
1449    }
1450    let mut programs = Fields::new();
1451    for alias in &aliases {
1452        programs.insert(
1453            alias.to_string(),
1454            program_declaration(spec, alias, options.programs.get(*alias)),
1455        );
1456    }
1457
1458    let mut entities = Fields::new();
1459    match profile {
1460        StackProfile::Interim => {
1461            if !groups.is_empty() {
1462                entities.insert("ProtocolActivity".into(), interim_activity_entity(&groups));
1463                entities.insert(
1464                    "ProtocolParticipant".into(),
1465                    interim_participant_entity(&groups)?,
1466                );
1467            }
1468            if let Some(primary) = primary {
1469                entities.insert(
1470                    "ProtocolInstance".into(),
1471                    interim_instance_entity(spec, primary, &groups)?,
1472                );
1473            }
1474        }
1475        StackProfile::Full => {
1476            if aliases.contains(MEMO_PROGRAM_ALIAS) {
1477                return Err(StackGenerationError::new(format!(
1478                    "the program alias '{MEMO_PROGRAM_ALIAS}' is reserved for the memo program"
1479                )));
1480            }
1481            let ctx = memo_context(spec, options)?;
1482            let memo = options
1483                .memo_program
1484                .clone()
1485                .unwrap_or_else(StackProgramSource::default_memo);
1486            programs.insert(MEMO_PROGRAM_ALIAS.into(), memo_declaration(&memo));
1487            entities.insert(
1488                "ProtocolActivity".into(),
1489                full_activity_entity(&groups, &ctx),
1490            );
1491            entities.insert(
1492                "ProtocolInstance".into(),
1493                full_instance_entity(spec, primary, &groups, &ctx)?,
1494            );
1495            entities.insert(
1496                "ProtocolParticipant".into(),
1497                full_participant_entity(&steps, &groups, &ctx)?,
1498            );
1499            if options.operation_boundaries {
1500                let attestation = options
1501                    .attestation
1502                    .as_deref()
1503                    .expect("full context validated");
1504                let sources = all_sources(&groups);
1505                for entity in entities.values_mut() {
1506                    guard_operation_activity(entity, &sources, attestation);
1507                }
1508            }
1509        }
1510    }
1511
1512    let base = format!("{}Protocol", pascal(&spec.name));
1513    let name = if base.len() <= 64 {
1514        base
1515    } else {
1516        pascal(&spec.name)
1517    };
1518    let mut stack_source = json!({
1519        "schema": STACK_SOURCE_SCHEMA_V1,
1520        "name": name,
1521        "programs": programs,
1522        "entities": entities,
1523    });
1524    // The full profile joins by signature through per-step indexes; strict
1525    // lookups resolve each join through the index it names only.
1526    if profile == StackProfile::Full {
1527        stack_source["lookups"] = json!("strict");
1528    }
1529    Ok(ProtocolStackSourceV1 {
1530        schema: PROTOCOL_STACK_SOURCE_SCHEMA_V1.to_string(),
1531        generator: options
1532            .generator
1533            .clone()
1534            .unwrap_or_else(default_stack_generator),
1535        profile,
1536        stack_source,
1537    })
1538}
1539
1540#[cfg(test)]
1541mod tests {
1542    use super::*;
1543    use crate::protocol::{parse_contract_json, validate_protocol_spec_value};
1544
1545    const ATTESTATION: &str = "Attest1111111111111111111111111111111111111";
1546
1547    fn token_raise() -> ProtocolSpecV1 {
1548        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1549            .join("../test-vectors/protocol/token-raise.protocol-spec.json");
1550        let value = parse_contract_json(&std::fs::read(path).unwrap()).unwrap();
1551        validate_protocol_spec_value(&value).unwrap()
1552    }
1553
1554    fn full() -> StackSourceOptions {
1555        StackSourceOptions {
1556            profile: Some(StackProfile::Full),
1557            attestation: Some(ATTESTATION.into()),
1558            deployment_short_id: Some("raise1".into()),
1559            ..StackSourceOptions::default()
1560        }
1561    }
1562
1563    fn activity(step: &str, instruction: &str) -> StackActivityV1 {
1564        StackActivityV1 {
1565            step: step.into(),
1566            program: "meteoraPresale".into(),
1567            instruction: instruction.into(),
1568            instance_account: "presale".into(),
1569            wallet_account: "owner".into(),
1570            amount_arg: None,
1571        }
1572    }
1573
1574    fn message(spec: &ProtocolSpecV1, options: &StackSourceOptions) -> String {
1575        generate_stack_source(spec, options).unwrap_err().message
1576    }
1577
1578    #[test]
1579    fn kebab_matches_the_javascript_regex() {
1580        assert_eq!(kebab("meteoraPresale"), "meteora-presale");
1581        assert_eq!(kebab("token_v2Vault9XProgram"), "token-v2-vault9-xprogram");
1582        assert_eq!(kebab("aBC"), "a-bc");
1583        assert_eq!(kebab("a1B2C"), "a1-b2-c");
1584        assert_eq!(kebab("ABC"), "abc");
1585    }
1586
1587    #[test]
1588    fn rejects_colliding_generated_names() {
1589        let mut spec = token_raise();
1590        spec.stack.activity = vec![activity("a_b", "first"), activity("aB", "second")];
1591        assert_eq!(
1592            message(&spec, &StackSourceOptions::default()),
1593            "ProtocolParticipant.activity: generated names 'aB_count' and 'a_b_count' collide"
1594        );
1595        assert_eq!(
1596            message(&spec, &full()),
1597            "ProtocolInstance.activity: generated names 'aB_count' and 'a_b_count' collide"
1598        );
1599    }
1600
1601    #[test]
1602    fn only_the_full_profile_declares_strict_lookups() {
1603        let spec = token_raise();
1604        let full = generate_stack_source(&spec, &full()).unwrap().stack_source;
1605        assert_eq!(full["lookups"], "strict");
1606        let interim = generate_stack_source(&spec, &StackSourceOptions::default())
1607            .unwrap()
1608            .stack_source;
1609        assert!(interim.get("lookups").is_none());
1610    }
1611
1612    #[test]
1613    fn counts_an_instruction_several_steps_list_once() {
1614        let mut spec = token_raise();
1615        let mut first = activity("contribute", "deposit");
1616        first.amount_arg = Some("max_amount".into());
1617        let mut second = activity("refund", "deposit");
1618        second.amount_arg = Some("max_amount".into());
1619        spec.stack.activity = vec![first, second];
1620        let full = generate_stack_source(&spec, &full()).unwrap().stack_source;
1621        let entities = &full["entities"];
1622        assert_eq!(
1623            entities["ProtocolActivity"]["fields"]["amount"]["from"],
1624            json!(["meteoraPresale.instructions.deposit.args.max_amount"])
1625        );
1626        let participant = &entities["ProtocolParticipant"]["fields"];
1627        assert_eq!(
1628            participant["activity"]["fields"]["refund_amount"]["via"],
1629            "last_refund_signature"
1630        );
1631        assert!(entities["ProtocolInstance"]["fields"]["activity"]["fields"]
1632            ["contribute_or_refund_direct_count"]
1633            .is_object());
1634        let interim = generate_stack_source(&spec, &StackSourceOptions::default())
1635            .unwrap()
1636            .stack_source;
1637        assert!(
1638            interim["entities"]["ProtocolInstance"]["fields"]["activity"]["fields"]
1639                ["contribute_or_refund_amount"]
1640                .is_object()
1641        );
1642    }
1643
1644    #[test]
1645    fn instance_owned_accounts_are_bound_from_ephemeral_keys() {
1646        let mut spec = token_raise();
1647        let owned: Vec<String> = instance_owned_accounts(&spec).into_iter().collect();
1648        assert_eq!(owned, ["mint", "presale"]);
1649        let bind = spec.steps.get_mut("create").unwrap().bind.as_mut().unwrap();
1650        bind.accounts = [
1651            ("creator", "wallet"),
1652            ("recipient", "args.recipient"),
1653            (
1654                "config",
1655                r#"pda("meteoraPresale", "config", params.quoteMint)"#,
1656            ),
1657            ("quoted", r#"pda("meteoraPresale", "x", "ephemeral.mint")"#),
1658            ("undeclared", "ephemeral.other"),
1659            ("nested", "instance.ephemeral.mint"),
1660            ("fresh", "ephemeral.mint"),
1661            (
1662                "derived",
1663                r#"pda("meteoraPresale", "presale", ephemeral.presaleBase)"#,
1664            ),
1665        ]
1666        .into_iter()
1667        .map(|(name, expr)| (name.to_string(), expr.to_string()))
1668        .collect();
1669        let owned: Vec<String> = instance_owned_accounts(&spec).into_iter().collect();
1670        assert_eq!(owned, ["derived", "fresh"]);
1671    }
1672
1673    #[test]
1674    fn instance_owned_accounts_are_pure_derivations_of_ephemeral_keys() {
1675        let mut spec = token_raise();
1676        let bind = spec.steps.get_mut("create").unwrap().bind.as_mut().unwrap();
1677        bind.accounts = [
1678            // Pure derivations: every evaluation is fresh to the instance.
1679            ("bare", "ephemeral.mint"),
1680            ("spaced", " ( ephemeral.mint ) "),
1681            (
1682                "seeded",
1683                r#"pda("meteoraPresale", "presale", if(args.flag, params.quoteMint, wallet), ephemeral.mint)"#,
1684            ),
1685            (
1686                "nested",
1687                r#"pda("squadsV4", "vault", (pda("squadsV4", "multisig", ephemeral.presaleBase)), 0)"#,
1688            ),
1689            ("ataOwner", "ata(ephemeral.presaleBase, params.quoteMint)"),
1690            ("ataMint", "ata(wallet, ephemeral.mint)"),
1691            // Expressions that mention a key but can evaluate to another account.
1692            ("branch", "if(args.flag, args.pool, ephemeral.mint)"),
1693            ("elseBranch", "if(args.flag, ephemeral.mint, params.quoteMint)"),
1694            ("fallback", "args.pool ?? ephemeral.mint"),
1695            ("fallbackLast", "ephemeral.mint ?? args.pool"),
1696            ("compared", "ephemeral.mint == wallet"),
1697            ("indexed", "ephemeral.mint[0]"),
1698            ("field", "ephemeral.mint.owner"),
1699            ("trailing", r#"pda("meteoraPresale", "presale", ephemeral.mint) ?? wallet"#),
1700            ("splitParens", "(ephemeral.mint) ?? (wallet)"),
1701            ("programOnly", r#"pda(ephemeral.mint, "presale")"#),
1702            ("nameOnly", r#"pda("meteoraPresale", ephemeral.mint)"#),
1703            ("seedBranch", r#"pda("meteoraPresale", "presale", if(args.flag, wallet, ephemeral.mint))"#),
1704            ("ataExtra", "ata(wallet, args.mint, ephemeral.mint)"),
1705            ("otherCall", "some(ephemeral.mint)"),
1706            ("unclosed", r#"pda("meteoraPresale", "presale", ephemeral.mint"#),
1707            ("quotedClose", r#"pda("meteoraPresale", "presale", ephemeral.mint, ")")"#),
1708        ]
1709        .into_iter()
1710        .map(|(name, expr)| (name.to_string(), expr.to_string()))
1711        .collect();
1712        let owned: Vec<String> = instance_owned_accounts(&spec).into_iter().collect();
1713        assert_eq!(
1714            owned,
1715            [
1716                "ataMint",
1717                "ataOwner",
1718                "bare",
1719                "nested",
1720                "quotedClose",
1721                "seeded",
1722                "spaced"
1723            ]
1724        );
1725    }
1726
1727    #[test]
1728    fn matches_no_direct_activity_on_a_wallet() {
1729        // A tip jar: each step is a bare transfer whose instance account is
1730        // the creator's wallet.
1731        let mut spec = token_raise();
1732        let bind = spec.steps.get_mut("create").unwrap().bind.as_mut().unwrap();
1733        bind.accounts.insert("creator".into(), "wallet".into());
1734        let mut open = activity("contribute", "deposit");
1735        open.instance_account = "creator".into();
1736        open.amount_arg = Some("max_amount".into());
1737        let mut close = activity("refund", "deposit");
1738        close.instance_account = "creator".into();
1739        close.amount_arg = Some("max_amount".into());
1740        spec.stack.activity = vec![open, close];
1741        let full = generate_stack_source(&spec, &full()).unwrap().stack_source;
1742        let instance = &full["entities"]["ProtocolInstance"];
1743        assert!(instance.get("register_from").is_none());
1744        let fields = instance["fields"]["activity"]["fields"]
1745            .as_object()
1746            .unwrap();
1747        let names: Vec<&str> = fields.keys().map(String::as_str).collect();
1748        assert_eq!(
1749            names,
1750            [
1751                "contribute_protocol_amount",
1752                "contribute_protocol_count",
1753                "last_ix_path",
1754                "last_signature",
1755                "last_slot",
1756                "last_timestamp",
1757                "refund_protocol_amount",
1758                "refund_protocol_count",
1759            ]
1760        );
1761        assert_eq!(
1762            fields["last_signature"]["from"],
1763            "areteMemo.instructions.memo.__signature"
1764        );
1765        // Each transfer's amount counts once, under the step its attested
1766        // memo names, joined by signature rather than through the wallet.
1767        assert_eq!(
1768            fields["refund_protocol_amount"],
1769            json!({
1770                "from": ["meteoraPresale.instructions.deposit.args.max_amount"],
1771                "strategy": "sum",
1772                "lookup_by": "__signature",
1773                "via": "last_refund_signature",
1774            })
1775        );
1776        assert_eq!(instance["fields"]["last_refund_signature"]["emit"], false);
1777        assert_eq!(
1778            instance["indexes"],
1779            json!([
1780                "id.instance",
1781                "last_contribute_signature",
1782                "last_refund_signature"
1783            ])
1784        );
1785    }
1786
1787    #[test]
1788    fn rejects_shared_instructions_that_disagree_or_merged_names_that_clash() {
1789        let mut spec = token_raise();
1790        let mut first = activity("contribute", "deposit");
1791        first.amount_arg = Some("max_amount".into());
1792        spec.stack.activity = vec![first, activity("refund", "deposit")];
1793        assert_eq!(
1794            message(&spec, &StackSourceOptions::default()),
1795            "stack.activity: steps 'contribute' and 'refund' both list meteoraPresale.instructions.deposit, so they must name the same instanceAccount, walletAccount and amountArg"
1796        );
1797        spec.stack.activity = vec![
1798            activity("a", "deposit"),
1799            activity("b", "deposit"),
1800            activity("a_or_b", "claim"),
1801        ];
1802        assert_eq!(
1803            message(&spec, &StackSourceOptions::default()),
1804            "stack.activity: the merged step name 'a_or_b' is also a step"
1805        );
1806    }
1807
1808    #[test]
1809    fn checks_the_deployment_short_id_bounds() {
1810        let spec = token_raise();
1811        let with_id = |id: String| StackSourceOptions {
1812            deployment_short_id: Some(id),
1813            ..full()
1814        };
1815        assert!(generate_stack_source(&spec, &with_id("a".repeat(64))).is_ok());
1816        assert!(generate_stack_source(&spec, &with_id("A-z_09".into())).is_ok());
1817        for bad in [
1818            "a".repeat(65),
1819            String::new(),
1820            "raise\n".into(),
1821            "räise".into(),
1822        ] {
1823            assert_eq!(
1824                message(&spec, &with_id(bad)),
1825                "options.deploymentShortId must be 1-64 characters of [A-Za-z0-9_-]"
1826            );
1827        }
1828    }
1829
1830    #[test]
1831    fn checks_the_attestation_key() {
1832        let spec = token_raise();
1833        let expected = "the full profile needs options.attestation: the deployment revision's attestation public key (base58)";
1834        for key in [None, Some("not-a-key"), Some("0OIl"), Some("")] {
1835            let options = StackSourceOptions {
1836                attestation: key.map(str::to_string),
1837                ..full()
1838            };
1839            assert_eq!(message(&spec, &options), expected);
1840        }
1841    }
1842
1843    #[test]
1844    fn gives_each_attestation_key_and_deployment_its_own_stack() {
1845        let spec = token_raise();
1846        let variants = [
1847            full(),
1848            StackSourceOptions {
1849                attestation: Some("SQDS4ep65T869zMMBKyuUq6aD6EgTu8psMjkvj52pCf".into()),
1850                ..full()
1851            },
1852            StackSourceOptions {
1853                deployment_short_id: Some("raise2".into()),
1854                ..full()
1855            },
1856            StackSourceOptions {
1857                deployment_short_id: None,
1858                ..full()
1859            },
1860        ];
1861        let outputs: BTreeSet<Vec<u8>> = variants
1862            .iter()
1863            .map(|options| {
1864                crate::canonicalize_jcs(&generate_stack_source(&spec, options).unwrap()).unwrap()
1865            })
1866            .collect();
1867        assert_eq!(outputs.len(), 4);
1868    }
1869
1870    #[test]
1871    fn is_deterministic_and_keys_entities_by_one_field() {
1872        let spec = token_raise();
1873        for options in [StackSourceOptions::default(), full()] {
1874            let first = generate_stack_source(&spec, &options).unwrap();
1875            assert_eq!(
1876                first,
1877                generate_stack_source(&spec.clone(), &options).unwrap()
1878            );
1879            for entity in first.stack_source["entities"].as_object().unwrap().values() {
1880                assert!(entity["primary_key"].is_string());
1881            }
1882        }
1883    }
1884
1885    #[test]
1886    fn options_parse_like_the_typescript_options() {
1887        let options: StackSourceOptions = serde_json::from_value(json!({
1888            "profile": "full",
1889            "programs": {
1890                "a": { "registry": "a", "version": "=1.0.0" },
1891                "b": { "idl": "./idl/b.json" },
1892            },
1893            "attestation": ATTESTATION,
1894            "deploymentShortId": "x",
1895            "memoProgram": { "registry": "spl-memo", "version": "^3" },
1896        }))
1897        .unwrap();
1898        assert_eq!(options.profile, Some(StackProfile::Full));
1899        assert_eq!(
1900            options.programs["b"],
1901            StackProgramSource::Idl {
1902                idl: "./idl/b.json".into()
1903            }
1904        );
1905        // An unknown profile cannot be expressed, so it fails at the boundary.
1906        assert!(
1907            serde_json::from_value::<StackSourceOptions>(json!({ "profile": "other" })).is_err()
1908        );
1909        assert!(serde_json::from_value::<StackSourceOptions>(json!({ "extra": 1 })).is_err());
1910        assert!(serde_json::from_value::<StackProgramSource>(json!({ "registry": "a" })).is_err());
1911    }
1912}