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