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
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 when its expression references `ephemeral.<name>` for a key the
605/// create step declares, outside a string literal.
606pub fn instance_owned_accounts(spec: &ProtocolSpecV1) -> BTreeSet<String> {
607    let Some((bind, ephemeral)) = spec
608        .create_step()
609        .and_then(|(_, step)| step.bind.as_ref().map(|bind| (bind, &step.ephemeral)))
610    else {
611        return BTreeSet::new();
612    };
613    bind.accounts
614        .iter()
615        .filter(|(_, expr)| {
616            ephemeral_references(expr)
617                .iter()
618                .any(|name| ephemeral.iter().any(|key| key == name))
619        })
620        .map(|(name, _)| name.clone())
621        .collect()
622}
623
624fn is_ident_byte(byte: u8) -> bool {
625    byte.is_ascii_alphanumeric() || byte == b'_'
626}
627
628const EPHEMERAL_PREFIX: &str = "ephemeral.";
629
630/// The `<name>` of every `ephemeral.<name>` in an expression, skipping
631/// string literals. Byte-wise; only ASCII bytes are significant, so it reads
632/// the same text as the TypeScript scanner's UTF-16 walk.
633fn ephemeral_references(expr: &str) -> Vec<&str> {
634    let bytes = expr.as_bytes();
635    let mut names = Vec::new();
636    let mut i = 0;
637    while i < bytes.len() {
638        let byte = bytes[i];
639        if byte == b'"' || byte == b'\'' {
640            // A string literal: skip to its closing quote, honouring escapes.
641            i += 1;
642            while i < bytes.len() && bytes[i] != byte {
643                i += if bytes[i] == b'\\' { 2 } else { 1 };
644            }
645            i += 1;
646            continue;
647        }
648        let before = if i == 0 { None } else { Some(bytes[i - 1]) };
649        let boundary = before.is_none_or(|b| !is_ident_byte(b) && b != b'.');
650        if boundary && bytes[i..].starts_with(EPHEMERAL_PREFIX.as_bytes()) {
651            let start = i + EPHEMERAL_PREFIX.len();
652            let mut end = start;
653            while end < bytes.len() && is_ident_byte(bytes[end]) {
654                end += 1;
655            }
656            names.push(&expr[start..end]);
657            i = end;
658            continue;
659        }
660        i += 1;
661    }
662    names
663}
664
665/// The one instruction account each role is read from, across every
666/// activity group.
667fn shared_account(
668    groups: &[ActivityGroup],
669    role: fn(&ActivityGroup) -> &str,
670    role_name: &str,
671    place: &str,
672) -> GenResult<String> {
673    let names: BTreeSet<&str> = groups.iter().map(role).collect();
674    if names.len() != 1 {
675        let found: Vec<&str> = names.into_iter().collect();
676        return Err(StackGenerationError::new(format!(
677            "{place} needs every stack.activity entry to name the same {role_name}; found {}",
678            found.join(", ")
679        )));
680    }
681    Ok(names.into_iter().next().unwrap_or_default().to_string())
682}
683
684fn interim_participant_entity(groups: &[ActivityGroup]) -> GenResult<Value> {
685    let all = all_sources(groups);
686    // A row key is one field (generated SDKs support no composite keys), and
687    // its expression reads the same account names from every instruction.
688    let instance_account = shared_account(
689        groups,
690        instance_of,
691        "instanceAccount",
692        "ProtocolParticipant",
693    )?;
694    let wallet_account = shared_account(groups, wallet_of, "walletAccount", "ProtocolParticipant")?;
695    let mut activity = Fields::new();
696    let mut keys = Vec::new();
697    for group in groups {
698        let count = format!("{}_count", group.step);
699        activity.insert(
700            count.clone(),
701            json!({ "type": "u64", "from": group.sources, "expr": "1", "strategy": "sum" }),
702        );
703        keys.push(count);
704        if let Some(arg) = &group.amount_arg {
705            let amount = format!("{}_amount", group.step);
706            activity.insert(
707                amount.clone(),
708                json!({ "from": relative(&group.sources, &format!("args.{arg}")), "strategy": "sum" }),
709            );
710            keys.push(amount);
711        }
712    }
713    activity.insert(
714        "last_slot".into(),
715        json!({ "from": relative(&all, "__slot"), "strategy": "max" }),
716    );
717    keys.push("last_slot".into());
718    assert_no_collisions(keys.iter(), "ProtocolParticipant.activity")?;
719    Ok(json!({
720        "primary_key": "id.key",
721        "indexes": ["id.instance"],
722        "fields": {
723            "id": { "fields": {
724                "key": {
725                    "type": "string",
726                    "from": all,
727                    "expr": format!("concat(accounts.{instance_account}, \":\", accounts.{wallet_account})"),
728                    "strategy": "set_once",
729                },
730                "instance": { "from": account_refs(groups, instance_of), "strategy": "set_once" },
731                "wallet": { "from": account_refs(groups, wallet_of), "strategy": "set_once" },
732            }},
733            "activity": { "fields": activity },
734        },
735        "views": {
736            "byInstance": {
737                "pipeline": [{ "sort": { "by": "id.instance", "order": "asc" } }],
738                "output": "collection",
739            },
740        },
741    }))
742}
743
744// ---------------------------------------------------------------------------
745// Full profile
746// ---------------------------------------------------------------------------
747
748/// Memo v2 field positions, counting from 1 as `split_part` does.
749#[derive(Clone, Copy)]
750enum MemoField {
751    Scope = 4,
752    Instance = 5,
753    Participant = 6,
754    Step = 7,
755}
756
757fn memo_part(field: MemoField) -> String {
758    format!("split_part(args.memo, \":\", {})", field as u8)
759}
760
761struct MemoContext {
762    /// `<alias>.instructions.memo`.
763    memo: String,
764    /// Holds for an attested Memo v2 of this deployment.
765    attested: String,
766    scoped: bool,
767}
768
769impl MemoContext {
770    /// A string field written once from an attested memo.
771    fn string(&self, expr: impl Into<String>) -> Value {
772        self.string_guarded(expr, &self.attested)
773    }
774
775    fn string_guarded(&self, expr: impl Into<String>, guard: &str) -> Value {
776        json!({ "type": "string", "from": self.memo, "expr": expr.into(), "strategy": "set_once", "guard": guard })
777    }
778}
779
780/// A step's attested amount on the instance row: the `last_<step>_signature`
781/// field (hidden) and the `<step>_protocol_amount` field joined through it. An
782/// instruction adds to a step's amount only in a transaction whose attested
783/// memo names that step, so it counts once, under the step the memo names.
784fn protocol_amount(
785    step: &StepActivity,
786    ctx: &MemoContext,
787) -> Option<(String, Value, String, Value)> {
788    let arg = step.amount_arg.as_ref()?;
789    let signature = json!({
790        "type": "string",
791        "from": ctx.memo,
792        "expr": "__signature",
793        "strategy": "last_write",
794        "guard": format!("{} && {} == {}", ctx.attested, memo_part(MemoField::Step), literal(&step.step)),
795        "emit": false,
796    });
797    let amount = json!({
798        "from": relative(&step.sources, &format!("args.{arg}")),
799        "strategy": "sum",
800        "lookup_by": "__signature",
801        "via": last_step_signature(&step.step),
802    });
803    Some((
804        last_step_signature(&step.step),
805        signature,
806        format!("{}_protocol_amount", step.step),
807        amount,
808    ))
809}
810
811fn full_instance_entity(
812    spec: &ProtocolSpecV1,
813    primary: Option<&str>,
814    groups: &[ActivityGroup],
815    ctx: &MemoContext,
816) -> GenResult<Value> {
817    // First-appearance order over the step-sorted groups, as the TypeScript
818    // `Set` keeps it.
819    let mut instance_accounts: Vec<&str> = Vec::new();
820    for group in groups {
821        if !instance_accounts.contains(&group.instance_account.as_str()) {
822            instance_accounts.push(&group.instance_account);
823        }
824    }
825    if instance_accounts.len() > 1 {
826        return Err(StackGenerationError::new(format!(
827            "the full profile routes all activity through one instance account; stack.activity names {}",
828            instance_accounts.join(", ")
829        )));
830    }
831    // Direct activity is matched only on the instance's own accounts.
832    let direct = instance_accounts.len() == 1
833        && instance_owned_accounts(spec).contains(instance_accounts[0]);
834    let lookup = instance_accounts
835        .first()
836        .filter(|_| direct)
837        .map(|account| format!("accounts.{account}"));
838    let all = all_sources(groups);
839
840    let registrations: Vec<Value> = groups
841        .iter()
842        .filter(|_| direct)
843        .flat_map(|group| {
844            group.sources.iter().map(|source| {
845                json!({
846                    "instruction": ctx.memo,
847                    "sibling": source,
848                    "pda_field": format!("sibling.accounts.{}", group.instance_account),
849                    "seed_expr": memo_part(MemoField::Instance),
850                    "guard": ctx.attested,
851                    "lookup_name": "instance_accounts",
852                })
853            })
854        })
855        .collect();
856
857    let mut id = Fields::new();
858    id.insert(
859        "instance".into(),
860        ctx.string(memo_part(MemoField::Instance)),
861    );
862    if ctx.scoped {
863        id.insert("scope".into(), ctx.string(memo_part(MemoField::Scope)));
864    }
865
866    let mut activity = Fields::new();
867    let mut step_signatures = Fields::new();
868    let mut keys = Vec::new();
869    if let Some(lookup) = &lookup {
870        let via = json!({ "lookup_by": lookup, "via": "instance_accounts" });
871        for group in groups {
872            // The instructions count once, under the group; the memo names
873            // the step, so the attested part is counted per step.
874            let step = &group.step;
875            let total = format!("(activity.{step}_count ?? 0)");
876            let parts: Vec<String> = group
877                .steps
878                .iter()
879                .map(|member| format!("(activity.{}_protocol_count ?? 0)", member.step))
880                .collect();
881            let attested = if parts.len() == 1 {
882                parts[0].clone()
883            } else {
884                format!("({})", parts.join(" + "))
885            };
886            let mut put = |key: String, value: Value| {
887                keys.push(key.clone());
888                activity.insert(key, value);
889            };
890            put(
891                format!("{step}_count"),
892                json!({ "aggregate": with(json!({ "from": group.sources, "op": "count" }), &via) }),
893            );
894            for member in &group.steps {
895                put(
896                    format!("{}_protocol_count", member.step),
897                    json!({
898                        "type": "u64",
899                        "from": ctx.memo,
900                        "expr": "1",
901                        "strategy": "sum",
902                        "guard": format!("{} && {} == {}", ctx.attested, memo_part(MemoField::Step), literal(&member.step)),
903                    }),
904                );
905                if let Some((name, signature, key, amount)) = protocol_amount(member, ctx) {
906                    step_signatures.insert(name, signature);
907                    put(key, amount);
908                }
909            }
910            put(
911                format!("{step}_direct_count"),
912                json!({
913                    "type": "u64",
914                    "compute": format!(
915                        "if({total} >= {attested}, {total} - {attested}, 0)"
916                    ),
917                }),
918            );
919            if let Some(arg) = &group.amount_arg {
920                put(
921                    format!("{step}_amount"),
922                    json!({ "aggregate": with(
923                        json!({ "from": group.sources, "op": "sum", "field": format!("args.{arg}") }),
924                        &via,
925                    )}),
926                );
927            }
928        }
929        let mut put = |key: &str, value: Value| {
930            keys.push(key.to_string());
931            activity.insert(key.to_string(), value);
932        };
933        put(
934            "last_signature",
935            with(json!({ "from": relative(&all, "__signature") }), &via),
936        );
937        put(
938            "last_ix_path",
939            with(json!({ "from": relative(&all, "__ix_path") }), &via),
940        );
941        put(
942            "last_slot",
943            with(
944                json!({ "from": relative(&all, "__slot"), "strategy": "max" }),
945                &via,
946            ),
947        );
948        put(
949            "last_timestamp",
950            with(
951                json!({ "from": relative(&all, "__timestamp"), "strategy": "max" }),
952                &via,
953            ),
954        );
955    } else if !groups.is_empty() {
956        // No account belongs to the instance alone, so only attested memos
957        // reach the row: per-step protocol counts, and the last protocol
958        // transaction.
959        let mut put = |key: String, value: Value| {
960            keys.push(key.clone());
961            activity.insert(key, value);
962        };
963        for member in groups.iter().flat_map(|group| &group.steps) {
964            put(
965                format!("{}_protocol_count", member.step),
966                json!({
967                    "type": "u64",
968                    "from": ctx.memo,
969                    "expr": "1",
970                    "strategy": "sum",
971                    "guard": format!("{} && {} == {}", ctx.attested, memo_part(MemoField::Step), literal(&member.step)),
972                }),
973            );
974            if let Some((name, signature, key, amount)) = protocol_amount(member, ctx) {
975                step_signatures.insert(name, signature);
976                put(key, amount);
977            }
978        }
979        let memo_field = |field: &str| format!("{}.{field}", ctx.memo);
980        put(
981            "last_signature".into(),
982            json!({ "from": memo_field("__signature"), "guard": ctx.attested }),
983        );
984        put(
985            "last_ix_path".into(),
986            json!({ "from": memo_field("__ix_path"), "guard": ctx.attested }),
987        );
988        put(
989            "last_slot".into(),
990            json!({ "from": memo_field("__slot"), "strategy": "max", "guard": ctx.attested }),
991        );
992        put(
993            "last_timestamp".into(),
994            json!({ "from": memo_field("__timestamp"), "strategy": "max", "guard": ctx.attested }),
995        );
996    }
997    assert_no_collisions(keys.iter(), "ProtocolInstance.activity")?;
998
999    let mut fields = Fields::new();
1000    fields.insert("id".into(), json!({ "fields": id }));
1001    let mut indexes = vec!["id.instance".to_string()];
1002    let mut signature_names: Vec<String> = step_signatures.keys().cloned().collect();
1003    signature_names.sort();
1004    indexes.extend(signature_names);
1005    fields.extend(step_signatures);
1006    let state = match (primary, &lookup) {
1007        (Some(primary), Some(_)) => instance_state(spec, primary),
1008        _ => Fields::new(),
1009    };
1010    if !state.is_empty() {
1011        fields.insert("state".into(), json!({ "fields": state }));
1012    }
1013    if !activity.is_empty() {
1014        fields.insert("activity".into(), json!({ "fields": activity }));
1015    }
1016    let mut entity = Fields::new();
1017    entity.insert("primary_key".into(), json!("id.instance"));
1018    if ctx.scoped {
1019        entity.insert("scope".into(), json!("id.scope"));
1020    }
1021    entity.insert("indexes".into(), json!(indexes));
1022    if !registrations.is_empty() {
1023        entity.insert("register_from".into(), Value::Array(registrations));
1024    }
1025    entity.insert("fields".into(), object(fields));
1026    entity.insert(
1027        "views".into(),
1028        json!({
1029            "byId": {
1030                "pipeline": [{ "sort": { "by": "id.instance", "order": "asc" } }],
1031                "output": "collection",
1032            },
1033        }),
1034    );
1035    Ok(object(entity))
1036}
1037
1038fn full_activity_entity(groups: &[ActivityGroup], ctx: &MemoContext) -> Value {
1039    let mut fields = Fields::new();
1040    fields.insert(
1041        "id".into(),
1042        json!({ "fields": { "signature": ctx.string("__signature") } }),
1043    );
1044    fields.insert(
1045        "instance".into(),
1046        ctx.string(memo_part(MemoField::Instance)),
1047    );
1048    fields.insert(
1049        "participant".into(),
1050        ctx.string(memo_part(MemoField::Participant)),
1051    );
1052    fields.insert("step".into(), ctx.string(memo_part(MemoField::Step)));
1053    fields.insert(
1054        "attested".into(),
1055        json!({ "type": "bool", "from": ctx.memo, "expr": "true", "strategy": "set_once", "guard": ctx.attested }),
1056    );
1057    fields.insert("via".into(), ctx.string(literal("protocol")));
1058    fields.insert(
1059        "slot".into(),
1060        json!({ "from": format!("{}.__slot", ctx.memo), "strategy": "set_once", "guard": ctx.attested }),
1061    );
1062    fields.insert(
1063        "timestamp".into(),
1064        json!({ "from": format!("{}.__timestamp", ctx.memo), "strategy": "set_once", "guard": ctx.attested }),
1065    );
1066    if ctx.scoped {
1067        fields.insert("scope".into(), ctx.string(memo_part(MemoField::Scope)));
1068    }
1069    if !groups.is_empty() {
1070        let join = json!({ "lookup_by": "__signature", "via": "id.signature" });
1071        fields.insert(
1072            "wallet".into(),
1073            with(
1074                json!({ "from": account_refs(groups, wallet_of), "strategy": "set_once" }),
1075                &join,
1076            ),
1077        );
1078        let amounts = amount_refs(groups);
1079        if !amounts.is_empty() {
1080            fields.insert(
1081                "amount".into(),
1082                with(json!({ "from": amounts, "strategy": "sum" }), &join),
1083            );
1084        }
1085        // The account the step's activity entry names (`instanceAccount`).
1086        fields.insert(
1087            "activity_account".into(),
1088            with(
1089                json!({ "from": account_refs(groups, instance_of), "strategy": "set_once" }),
1090                &join,
1091            ),
1092        );
1093    }
1094    let mut entity = Fields::new();
1095    entity.insert("primary_key".into(), json!("id.signature"));
1096    if ctx.scoped {
1097        entity.insert("scope".into(), json!("scope"));
1098    }
1099    entity.insert("indexes".into(), json!(["id.signature", "instance"]));
1100    entity.insert("fields".into(), object(fields));
1101    entity.insert(
1102        "views".into(),
1103        json!({
1104            "byInstance": {
1105                "pipeline": [{ "sort": { "by": "slot", "order": "desc" } }],
1106                "output": "collection",
1107            },
1108        }),
1109    );
1110    object(entity)
1111}
1112
1113/// The instance or participant field holding the signature of the last
1114/// attested memo naming `step`.
1115fn last_step_signature(step: &str) -> String {
1116    format!("last_{step}_signature")
1117}
1118
1119fn full_participant_entity(
1120    steps: &[StepActivity],
1121    groups: &[ActivityGroup],
1122    ctx: &MemoContext,
1123) -> GenResult<Value> {
1124    let with_participant = format!(
1125        "{} && {} != \"-\"",
1126        ctx.attested,
1127        memo_part(MemoField::Participant)
1128    );
1129    let mut activity = Fields::new();
1130    let mut step_signatures = Fields::new();
1131    let mut keys = Vec::new();
1132    for step in steps {
1133        let named = format!(
1134            "{with_participant} && {} == {}",
1135            memo_part(MemoField::Step),
1136            literal(&step.step)
1137        );
1138        let count = format!("{}_count", step.step);
1139        activity.insert(
1140            count.clone(),
1141            json!({ "type": "u64", "from": ctx.memo, "expr": "1", "strategy": "sum", "guard": named }),
1142        );
1143        keys.push(count);
1144        if let Some(arg) = &step.amount_arg {
1145            // An instruction adds to a step's amount only in a transaction
1146            // whose attested memo names that step, so an instruction several
1147            // steps list counts once, under the step the memo names.
1148            step_signatures.insert(
1149                last_step_signature(&step.step),
1150                json!({
1151                    "type": "string",
1152                    "from": ctx.memo,
1153                    "expr": "__signature",
1154                    "strategy": "last_write",
1155                    "guard": named,
1156                    "emit": false,
1157                }),
1158            );
1159            let amount = format!("{}_amount", step.step);
1160            activity.insert(
1161                amount.clone(),
1162                json!({
1163                    "from": relative(&step.sources, &format!("args.{arg}")),
1164                    "strategy": "sum",
1165                    "lookup_by": "__signature",
1166                    "via": last_step_signature(&step.step),
1167                }),
1168            );
1169            keys.push(amount);
1170        }
1171    }
1172    activity.insert(
1173        "last_slot".into(),
1174        json!({ "from": format!("{}.__slot", ctx.memo), "strategy": "max", "guard": with_participant }),
1175    );
1176    keys.push("last_slot".into());
1177    assert_no_collisions(keys.iter(), "ProtocolParticipant.activity")?;
1178    let mut fields = Fields::new();
1179    fields.insert(
1180        "id".into(),
1181        json!({ "fields": { "participant": ctx.string_guarded(memo_part(MemoField::Participant), &with_participant) } }),
1182    );
1183    fields.insert(
1184        "instance".into(),
1185        ctx.string_guarded(memo_part(MemoField::Instance), &with_participant),
1186    );
1187    fields.insert(
1188        "last_signature".into(),
1189        json!({
1190            "type": "string",
1191            "from": ctx.memo,
1192            "expr": "__signature",
1193            "strategy": "last_write",
1194            "guard": with_participant,
1195            "emit": false,
1196        }),
1197    );
1198    let mut indexes = vec![
1199        "id.participant".to_string(),
1200        "instance".to_string(),
1201        "last_signature".to_string(),
1202    ];
1203    let mut signature_names: Vec<String> = step_signatures.keys().cloned().collect();
1204    signature_names.sort();
1205    indexes.extend(signature_names);
1206    fields.extend(step_signatures);
1207    fields.insert("activity".into(), json!({ "fields": activity }));
1208    if ctx.scoped {
1209        fields.insert(
1210            "scope".into(),
1211            ctx.string_guarded(memo_part(MemoField::Scope), &with_participant),
1212        );
1213    }
1214    if !groups.is_empty() {
1215        fields.insert(
1216            "wallet".into(),
1217            json!({
1218                "from": account_refs(groups, wallet_of),
1219                "strategy": "set_once",
1220                "lookup_by": "__signature",
1221                "via": "last_signature",
1222            }),
1223        );
1224    }
1225    let mut entity = Fields::new();
1226    entity.insert("primary_key".into(), json!("id.participant"));
1227    if ctx.scoped {
1228        entity.insert("scope".into(), json!("scope"));
1229    }
1230    entity.insert("indexes".into(), json!(indexes));
1231    entity.insert("fields".into(), object(fields));
1232    entity.insert(
1233        "views".into(),
1234        json!({
1235            "byInstance": {
1236                "pipeline": [{ "sort": { "by": "instance", "order": "asc" } }],
1237                "output": "collection",
1238            },
1239        }),
1240    );
1241    Ok(object(entity))
1242}
1243
1244/// `^[A-Za-z0-9_-]{1,64}$`.
1245fn is_short_id(text: &str) -> bool {
1246    (1..=64).contains(&text.len())
1247        && text
1248            .bytes()
1249            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
1250}
1251
1252/// A base58 text of a 32-byte key, at most 44 bytes long.
1253fn is_pubkey(text: &str) -> bool {
1254    text.len() <= 44
1255        && bs58::decode(text)
1256            .into_vec()
1257            .is_ok_and(|bytes| bytes.len() == 32)
1258}
1259
1260fn memo_context(spec: &ProtocolSpecV1, options: &StackSourceOptions) -> GenResult<MemoContext> {
1261    let attestation = match options.attestation.as_deref() {
1262        Some(key) if is_pubkey(key) => key,
1263        _ => {
1264            return Err(StackGenerationError::new(
1265                "the full profile needs options.attestation: the deployment revision's attestation public key (base58)",
1266            ))
1267        }
1268    };
1269    let prefix = match options.deployment_short_id.as_deref() {
1270        None => MEMO_V2_PREFIX.to_string(),
1271        Some(short_id) if is_short_id(short_id) => format!("{MEMO_V2_PREFIX}{short_id}:"),
1272        Some(_) => {
1273            return Err(StackGenerationError::new(
1274                "options.deploymentShortId must be 1-64 characters of [A-Za-z0-9_-]",
1275            ))
1276        }
1277    };
1278    Ok(MemoContext {
1279        memo: format!("{MEMO_PROGRAM_ALIAS}.instructions.memo"),
1280        attested: format!(
1281            "starts_with(args.memo, {}) && accounts.signer == {} && accounts.signer.is_signer",
1282            literal(&prefix),
1283            literal(attestation)
1284        ),
1285        scoped: spec.instance_visibility == super::model::InstanceVisibility::Account,
1286    })
1287}
1288
1289// ---------------------------------------------------------------------------
1290// Entry point
1291// ---------------------------------------------------------------------------
1292
1293fn guard_operation_activity(value: &mut Value, sources: &[String], attestation: &str) {
1294    match value {
1295        Value::Array(values) => {
1296            for value in values {
1297                guard_operation_activity(value, sources, attestation);
1298            }
1299        }
1300        Value::Object(object) => {
1301            if object.get("sibling").is_some_and(Value::is_string) {
1302                object.insert(
1303                    "sibling_where".into(),
1304                    json!({"__protocol_activity_attestation":attestation}),
1305                );
1306            }
1307            let references = match object.get("from") {
1308                Some(Value::String(from)) => vec![from.as_str()],
1309                Some(Value::Array(from)) => from.iter().filter_map(Value::as_str).collect(),
1310                _ => vec![],
1311            };
1312            if references.iter().any(|reference| {
1313                sources.iter().any(|source| {
1314                    *reference == source || reference.starts_with(&format!("{source}."))
1315                })
1316            }) {
1317                let member = format!(
1318                    "__protocol_activity_attestation == {}",
1319                    literal(attestation)
1320                );
1321                let guard =
1322                    if object.get("via").and_then(Value::as_str) == Some("instance_accounts") {
1323                        "__protocol_attestation == \"\" || __protocol_activity_attestation != \"\""
1324                            .to_string()
1325                    } else {
1326                        member
1327                    };
1328                let guard = match object.get("guard").and_then(Value::as_str) {
1329                    Some(existing) => format!("({existing}) && ({guard})"),
1330                    None => guard,
1331                };
1332                object.insert("guard".into(), Value::String(guard));
1333            }
1334            for value in object.values_mut() {
1335                guard_operation_activity(value, sources, attestation);
1336            }
1337        }
1338        _ => {}
1339    }
1340}
1341
1342/// Generate the protocol stack source of a validated spec.
1343///
1344/// The output depends only on `spec` and `options`. With the TypeScript
1345/// generator's identity in `options.generator`, its canonical JSON is
1346/// byte-identical to the TypeScript output.
1347pub fn generate_stack_source(
1348    spec: &ProtocolSpecV1,
1349    options: &StackSourceOptions,
1350) -> Result<ProtocolStackSourceV1, StackGenerationError> {
1351    let profile = options.profile.unwrap_or(StackProfile::Interim);
1352    let primary = spec
1353        .stack
1354        .instance_accounts
1355        .keys()
1356        .next()
1357        .map(String::as_str);
1358    let steps = group_activity(&spec.stack.activity)?;
1359    let groups = merge_shared_steps(&steps)?;
1360    if primary.is_none() && groups.is_empty() {
1361        return Err(StackGenerationError::new(
1362            "the protocol stack needs stack.instanceAccounts or stack.activity; declare what the stack should track",
1363        ));
1364    }
1365
1366    let mut aliases: BTreeSet<&str> = spec
1367        .stack
1368        .activity
1369        .iter()
1370        .map(|entry| entry.program.as_str())
1371        .collect();
1372    if let Some(primary) = primary {
1373        aliases.insert(&spec.stack.instance_accounts[primary].program);
1374    }
1375    let mut programs = Fields::new();
1376    for alias in &aliases {
1377        programs.insert(
1378            alias.to_string(),
1379            program_declaration(spec, alias, options.programs.get(*alias)),
1380        );
1381    }
1382
1383    let mut entities = Fields::new();
1384    match profile {
1385        StackProfile::Interim => {
1386            if !groups.is_empty() {
1387                entities.insert("ProtocolActivity".into(), interim_activity_entity(&groups));
1388                entities.insert(
1389                    "ProtocolParticipant".into(),
1390                    interim_participant_entity(&groups)?,
1391                );
1392            }
1393            if let Some(primary) = primary {
1394                entities.insert(
1395                    "ProtocolInstance".into(),
1396                    interim_instance_entity(spec, primary, &groups)?,
1397                );
1398            }
1399        }
1400        StackProfile::Full => {
1401            if aliases.contains(MEMO_PROGRAM_ALIAS) {
1402                return Err(StackGenerationError::new(format!(
1403                    "the program alias '{MEMO_PROGRAM_ALIAS}' is reserved for the memo program"
1404                )));
1405            }
1406            let ctx = memo_context(spec, options)?;
1407            let memo = options
1408                .memo_program
1409                .clone()
1410                .unwrap_or_else(StackProgramSource::default_memo);
1411            programs.insert(MEMO_PROGRAM_ALIAS.into(), memo_declaration(&memo));
1412            entities.insert(
1413                "ProtocolActivity".into(),
1414                full_activity_entity(&groups, &ctx),
1415            );
1416            entities.insert(
1417                "ProtocolInstance".into(),
1418                full_instance_entity(spec, primary, &groups, &ctx)?,
1419            );
1420            entities.insert(
1421                "ProtocolParticipant".into(),
1422                full_participant_entity(&steps, &groups, &ctx)?,
1423            );
1424            if options.operation_boundaries {
1425                let attestation = options
1426                    .attestation
1427                    .as_deref()
1428                    .expect("full context validated");
1429                let sources = all_sources(&groups);
1430                for entity in entities.values_mut() {
1431                    guard_operation_activity(entity, &sources, attestation);
1432                }
1433            }
1434        }
1435    }
1436
1437    let base = format!("{}Protocol", pascal(&spec.name));
1438    let name = if base.len() <= 64 {
1439        base
1440    } else {
1441        pascal(&spec.name)
1442    };
1443    let mut stack_source = json!({
1444        "schema": STACK_SOURCE_SCHEMA_V1,
1445        "name": name,
1446        "programs": programs,
1447        "entities": entities,
1448    });
1449    // The full profile joins by signature through per-step indexes; strict
1450    // lookups resolve each join through the index it names only.
1451    if profile == StackProfile::Full {
1452        stack_source["lookups"] = json!("strict");
1453    }
1454    Ok(ProtocolStackSourceV1 {
1455        schema: PROTOCOL_STACK_SOURCE_SCHEMA_V1.to_string(),
1456        generator: options
1457            .generator
1458            .clone()
1459            .unwrap_or_else(default_stack_generator),
1460        profile,
1461        stack_source,
1462    })
1463}
1464
1465#[cfg(test)]
1466mod tests {
1467    use super::*;
1468    use crate::protocol::{parse_contract_json, validate_protocol_spec_value};
1469
1470    const ATTESTATION: &str = "Attest1111111111111111111111111111111111111";
1471
1472    fn token_raise() -> ProtocolSpecV1 {
1473        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1474            .join("../test-vectors/protocol/token-raise.protocol-spec.json");
1475        let value = parse_contract_json(&std::fs::read(path).unwrap()).unwrap();
1476        validate_protocol_spec_value(&value).unwrap()
1477    }
1478
1479    fn full() -> StackSourceOptions {
1480        StackSourceOptions {
1481            profile: Some(StackProfile::Full),
1482            attestation: Some(ATTESTATION.into()),
1483            deployment_short_id: Some("raise1".into()),
1484            ..StackSourceOptions::default()
1485        }
1486    }
1487
1488    fn activity(step: &str, instruction: &str) -> StackActivityV1 {
1489        StackActivityV1 {
1490            step: step.into(),
1491            program: "meteoraPresale".into(),
1492            instruction: instruction.into(),
1493            instance_account: "presale".into(),
1494            wallet_account: "owner".into(),
1495            amount_arg: None,
1496        }
1497    }
1498
1499    fn message(spec: &ProtocolSpecV1, options: &StackSourceOptions) -> String {
1500        generate_stack_source(spec, options).unwrap_err().message
1501    }
1502
1503    #[test]
1504    fn kebab_matches_the_javascript_regex() {
1505        assert_eq!(kebab("meteoraPresale"), "meteora-presale");
1506        assert_eq!(kebab("token_v2Vault9XProgram"), "token-v2-vault9-xprogram");
1507        assert_eq!(kebab("aBC"), "a-bc");
1508        assert_eq!(kebab("a1B2C"), "a1-b2-c");
1509        assert_eq!(kebab("ABC"), "abc");
1510    }
1511
1512    #[test]
1513    fn rejects_colliding_generated_names() {
1514        let mut spec = token_raise();
1515        spec.stack.activity = vec![activity("a_b", "first"), activity("aB", "second")];
1516        assert_eq!(
1517            message(&spec, &StackSourceOptions::default()),
1518            "ProtocolParticipant.activity: generated names 'aB_count' and 'a_b_count' collide"
1519        );
1520        assert_eq!(
1521            message(&spec, &full()),
1522            "ProtocolInstance.activity: generated names 'aB_count' and 'a_b_count' collide"
1523        );
1524    }
1525
1526    #[test]
1527    fn only_the_full_profile_declares_strict_lookups() {
1528        let spec = token_raise();
1529        let full = generate_stack_source(&spec, &full()).unwrap().stack_source;
1530        assert_eq!(full["lookups"], "strict");
1531        let interim = generate_stack_source(&spec, &StackSourceOptions::default())
1532            .unwrap()
1533            .stack_source;
1534        assert!(interim.get("lookups").is_none());
1535    }
1536
1537    #[test]
1538    fn counts_an_instruction_several_steps_list_once() {
1539        let mut spec = token_raise();
1540        let mut first = activity("contribute", "deposit");
1541        first.amount_arg = Some("max_amount".into());
1542        let mut second = activity("refund", "deposit");
1543        second.amount_arg = Some("max_amount".into());
1544        spec.stack.activity = vec![first, second];
1545        let full = generate_stack_source(&spec, &full()).unwrap().stack_source;
1546        let entities = &full["entities"];
1547        assert_eq!(
1548            entities["ProtocolActivity"]["fields"]["amount"]["from"],
1549            json!(["meteoraPresale.instructions.deposit.args.max_amount"])
1550        );
1551        let participant = &entities["ProtocolParticipant"]["fields"];
1552        assert_eq!(
1553            participant["activity"]["fields"]["refund_amount"]["via"],
1554            "last_refund_signature"
1555        );
1556        assert!(entities["ProtocolInstance"]["fields"]["activity"]["fields"]
1557            ["contribute_or_refund_direct_count"]
1558            .is_object());
1559        let interim = generate_stack_source(&spec, &StackSourceOptions::default())
1560            .unwrap()
1561            .stack_source;
1562        assert!(
1563            interim["entities"]["ProtocolInstance"]["fields"]["activity"]["fields"]
1564                ["contribute_or_refund_amount"]
1565                .is_object()
1566        );
1567    }
1568
1569    #[test]
1570    fn instance_owned_accounts_are_bound_from_ephemeral_keys() {
1571        let mut spec = token_raise();
1572        let owned: Vec<String> = instance_owned_accounts(&spec).into_iter().collect();
1573        assert_eq!(owned, ["mint", "multisig", "presale", "vault"]);
1574        let bind = spec.steps.get_mut("create").unwrap().bind.as_mut().unwrap();
1575        bind.accounts = [
1576            ("creator", "wallet"),
1577            ("recipient", "args.recipient"),
1578            (
1579                "config",
1580                r#"pda("meteoraPresale", "config", params.quoteMint)"#,
1581            ),
1582            ("quoted", r#"pda("meteoraPresale", "x", "ephemeral.mint")"#),
1583            ("undeclared", "ephemeral.other"),
1584            ("nested", "instance.ephemeral.mint"),
1585            ("fresh", "ephemeral.mint"),
1586            (
1587                "derived",
1588                r#"pda("meteoraPresale", "presale", ephemeral.presaleBase)"#,
1589            ),
1590        ]
1591        .into_iter()
1592        .map(|(name, expr)| (name.to_string(), expr.to_string()))
1593        .collect();
1594        let owned: Vec<String> = instance_owned_accounts(&spec).into_iter().collect();
1595        assert_eq!(owned, ["derived", "fresh"]);
1596    }
1597
1598    #[test]
1599    fn matches_no_direct_activity_on_a_wallet() {
1600        // A tip jar: each step is a bare transfer whose instance account is
1601        // the creator's wallet.
1602        let mut spec = token_raise();
1603        let bind = spec.steps.get_mut("create").unwrap().bind.as_mut().unwrap();
1604        bind.accounts.insert("creator".into(), "wallet".into());
1605        let mut open = activity("contribute", "deposit");
1606        open.instance_account = "creator".into();
1607        open.amount_arg = Some("max_amount".into());
1608        let mut close = activity("refund", "deposit");
1609        close.instance_account = "creator".into();
1610        close.amount_arg = Some("max_amount".into());
1611        spec.stack.activity = vec![open, close];
1612        let full = generate_stack_source(&spec, &full()).unwrap().stack_source;
1613        let instance = &full["entities"]["ProtocolInstance"];
1614        assert!(instance.get("register_from").is_none());
1615        let fields = instance["fields"]["activity"]["fields"]
1616            .as_object()
1617            .unwrap();
1618        let names: Vec<&str> = fields.keys().map(String::as_str).collect();
1619        assert_eq!(
1620            names,
1621            [
1622                "contribute_protocol_amount",
1623                "contribute_protocol_count",
1624                "last_ix_path",
1625                "last_signature",
1626                "last_slot",
1627                "last_timestamp",
1628                "refund_protocol_amount",
1629                "refund_protocol_count",
1630            ]
1631        );
1632        assert_eq!(
1633            fields["last_signature"]["from"],
1634            "areteMemo.instructions.memo.__signature"
1635        );
1636        // Each transfer's amount counts once, under the step its attested
1637        // memo names, joined by signature rather than through the wallet.
1638        assert_eq!(
1639            fields["refund_protocol_amount"],
1640            json!({
1641                "from": ["meteoraPresale.instructions.deposit.args.max_amount"],
1642                "strategy": "sum",
1643                "lookup_by": "__signature",
1644                "via": "last_refund_signature",
1645            })
1646        );
1647        assert_eq!(instance["fields"]["last_refund_signature"]["emit"], false);
1648        assert_eq!(
1649            instance["indexes"],
1650            json!([
1651                "id.instance",
1652                "last_contribute_signature",
1653                "last_refund_signature"
1654            ])
1655        );
1656    }
1657
1658    #[test]
1659    fn rejects_shared_instructions_that_disagree_or_merged_names_that_clash() {
1660        let mut spec = token_raise();
1661        let mut first = activity("contribute", "deposit");
1662        first.amount_arg = Some("max_amount".into());
1663        spec.stack.activity = vec![first, activity("refund", "deposit")];
1664        assert_eq!(
1665            message(&spec, &StackSourceOptions::default()),
1666            "stack.activity: steps 'contribute' and 'refund' both list meteoraPresale.instructions.deposit, so they must name the same instanceAccount, walletAccount and amountArg"
1667        );
1668        spec.stack.activity = vec![
1669            activity("a", "deposit"),
1670            activity("b", "deposit"),
1671            activity("a_or_b", "claim"),
1672        ];
1673        assert_eq!(
1674            message(&spec, &StackSourceOptions::default()),
1675            "stack.activity: the merged step name 'a_or_b' is also a step"
1676        );
1677    }
1678
1679    #[test]
1680    fn checks_the_deployment_short_id_bounds() {
1681        let spec = token_raise();
1682        let with_id = |id: String| StackSourceOptions {
1683            deployment_short_id: Some(id),
1684            ..full()
1685        };
1686        assert!(generate_stack_source(&spec, &with_id("a".repeat(64))).is_ok());
1687        assert!(generate_stack_source(&spec, &with_id("A-z_09".into())).is_ok());
1688        for bad in [
1689            "a".repeat(65),
1690            String::new(),
1691            "raise\n".into(),
1692            "räise".into(),
1693        ] {
1694            assert_eq!(
1695                message(&spec, &with_id(bad)),
1696                "options.deploymentShortId must be 1-64 characters of [A-Za-z0-9_-]"
1697            );
1698        }
1699    }
1700
1701    #[test]
1702    fn checks_the_attestation_key() {
1703        let spec = token_raise();
1704        let expected = "the full profile needs options.attestation: the deployment revision's attestation public key (base58)";
1705        for key in [None, Some("not-a-key"), Some("0OIl"), Some("")] {
1706            let options = StackSourceOptions {
1707                attestation: key.map(str::to_string),
1708                ..full()
1709            };
1710            assert_eq!(message(&spec, &options), expected);
1711        }
1712    }
1713
1714    #[test]
1715    fn gives_each_attestation_key_and_deployment_its_own_stack() {
1716        let spec = token_raise();
1717        let variants = [
1718            full(),
1719            StackSourceOptions {
1720                attestation: Some("SQDS4ep65T869zMMBKyuUq6aD6EgTu8psMjkvj52pCf".into()),
1721                ..full()
1722            },
1723            StackSourceOptions {
1724                deployment_short_id: Some("raise2".into()),
1725                ..full()
1726            },
1727            StackSourceOptions {
1728                deployment_short_id: None,
1729                ..full()
1730            },
1731        ];
1732        let outputs: BTreeSet<Vec<u8>> = variants
1733            .iter()
1734            .map(|options| {
1735                crate::canonicalize_jcs(&generate_stack_source(&spec, options).unwrap()).unwrap()
1736            })
1737            .collect();
1738        assert_eq!(outputs.len(), 4);
1739    }
1740
1741    #[test]
1742    fn is_deterministic_and_keys_entities_by_one_field() {
1743        let spec = token_raise();
1744        for options in [StackSourceOptions::default(), full()] {
1745            let first = generate_stack_source(&spec, &options).unwrap();
1746            assert_eq!(
1747                first,
1748                generate_stack_source(&spec.clone(), &options).unwrap()
1749            );
1750            for entity in first.stack_source["entities"].as_object().unwrap().values() {
1751                assert!(entity["primary_key"].is_string());
1752            }
1753        }
1754    }
1755
1756    #[test]
1757    fn options_parse_like_the_typescript_options() {
1758        let options: StackSourceOptions = serde_json::from_value(json!({
1759            "profile": "full",
1760            "programs": {
1761                "a": { "registry": "a", "version": "=1.0.0" },
1762                "b": { "idl": "./idl/b.json" },
1763            },
1764            "attestation": ATTESTATION,
1765            "deploymentShortId": "x",
1766            "memoProgram": { "registry": "spl-memo", "version": "^3" },
1767        }))
1768        .unwrap();
1769        assert_eq!(options.profile, Some(StackProfile::Full));
1770        assert_eq!(
1771            options.programs["b"],
1772            StackProgramSource::Idl {
1773                idl: "./idl/b.json".into()
1774            }
1775        );
1776        // An unknown profile cannot be expressed, so it fails at the boundary.
1777        assert!(
1778            serde_json::from_value::<StackSourceOptions>(json!({ "profile": "other" })).is_err()
1779        );
1780        assert!(serde_json::from_value::<StackSourceOptions>(json!({ "extra": 1 })).is_err());
1781        assert!(serde_json::from_value::<StackProgramSource>(json!({ "registry": "a" })).is_err());
1782    }
1783}