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