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 pub programs: BTreeMap<String, StackProgramSource>,
98 #[serde(skip_serializing_if = "Option::is_none")]
100 pub attestation: Option<String>,
101 #[serde(skip_serializing_if = "Option::is_none")]
104 pub deployment_short_id: Option<String>,
105 #[serde(skip_serializing_if = "Option::is_none")]
108 pub memo_program: Option<StackProgramSource>,
109 #[serde(skip_serializing_if = "Option::is_none")]
112 pub generator: Option<GeneratorV1>,
113}
114
115#[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
134fn 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
164fn 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
198struct ActivityGroup {
202 step: String,
204 steps: Vec<StepActivity>,
206 sources: Vec<String>,
208 instance_account: String,
209 wallet_account: String,
210 amount_arg: Option<String>,
211}
212
213const GROUP_SEPARATOR: &str = "_or_";
215
216fn 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 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 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 Ok(steps
277 .into_values()
278 .map(|mut group| {
279 group.sources.sort();
280 group
281 })
282 .collect())
283}
284
285fn 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
362fn 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
380fn 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
396fn 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
405fn 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
415fn 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
457fn 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
598pub 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
627fn 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 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
662fn 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 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#[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 memo: String,
761 attested: String,
763 scoped: bool,
764}
765
766impl MemoContext {
767 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
777fn 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 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 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 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 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 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
1110fn 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 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
1241fn 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
1249fn 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
1286pub 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 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 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 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 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}