1use 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
49pub const MEMO_V2_PREFIX: &str = "arete:p2:";
51
52pub const MEMO_PROGRAM_ALIAS: &str = "areteMemo";
54
55pub const DEFAULT_MEMO_PROGRAM_IDL: &str = "./idl/spl-memo.idl.json";
57
58const MEMO_PROGRAM_ID: &str = "MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr";
59
60pub 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(untagged, deny_unknown_fields)]
71pub enum StackProgramSource {
72 Registry { registry: String, version: String },
75 Idl { idl: String },
77}
78
79impl StackProgramSource {
80 pub fn default_memo() -> Self {
82 Self::Idl {
83 idl: DEFAULT_MEMO_PROGRAM_IDL.to_string(),
84 }
85 }
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
91#[serde(rename_all = "camelCase", default, deny_unknown_fields)]
92pub struct StackSourceOptions {
93 #[serde(skip_serializing_if = "Option::is_none")]
95 pub profile: Option<StackProfile>,
96 #[serde(skip_serializing_if = "std::ops::Not::not")]
98 pub operation_boundaries: bool,
99 pub programs: BTreeMap<String, StackProgramSource>,
101 #[serde(skip_serializing_if = "Option::is_none")]
103 pub attestation: Option<String>,
104 #[serde(skip_serializing_if = "Option::is_none")]
107 pub deployment_short_id: Option<String>,
108 #[serde(skip_serializing_if = "Option::is_none")]
111 pub memo_program: Option<StackProgramSource>,
112 #[serde(skip_serializing_if = "Option::is_none")]
115 pub generator: Option<GeneratorV1>,
116}
117
118#[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
137fn 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
167fn 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
201struct ActivityGroup {
205 step: String,
207 steps: Vec<StepActivity>,
209 sources: Vec<String>,
211 instance_account: String,
212 wallet_account: String,
213 amount_arg: Option<String>,
214}
215
216const GROUP_SEPARATOR: &str = "_or_";
218
219fn 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 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 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 Ok(steps
280 .into_values()
281 .map(|mut group| {
282 group.sources.sort();
283 group
284 })
285 .collect())
286}
287
288fn 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
365fn 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
383fn 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
399fn 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
408fn 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
418fn 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
460fn 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
601pub 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
630fn 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 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
665fn 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 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#[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 memo: String,
764 attested: String,
766 scoped: bool,
767}
768
769impl MemoContext {
770 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
780fn 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 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 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 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 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 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
1113fn 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 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
1244fn 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
1252fn 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
1289fn 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
1342pub 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 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 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 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 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}