1use arete_idl::{
2 normalize_idl_snapshot_v1, IdlAmountDecimalsSource, IdlAmountHint, IdlErrorSnapshot,
3 IdlSnapshotV1, IdlSpec, IdlType, IdlTypeArrayElement, IdlTypeDefinedInner,
4};
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use std::collections::{BTreeMap, BTreeSet};
8
9use crate::{
10 canonicalize_jcs, hash_jcs, hash_raw_bytes, HashError, HashId, IdlContent, IdlNormalized,
11 IdlPortable, IdlSource, OssGeneratedProgramReleaseV1, ProgramRelease, ProgramSpec,
12};
13
14pub const PROGRAM_SPEC_SCHEMA_V1: &str = "arete.program-spec/v1";
15
16#[derive(Debug, Clone)]
17pub struct IdlHashes {
18 pub source: HashId<IdlSource>,
19 pub content: HashId<IdlContent>,
20 pub portable: HashId<IdlPortable>,
21 pub normalized: HashId<IdlNormalized>,
22}
23
24#[derive(Debug, Clone)]
26pub struct CanonicalIdlDocument {
27 source_bytes: Vec<u8>,
28 content: Value,
29 portable: Value,
30 idl: IdlSpec,
31 program_id: String,
32 snapshot: IdlSnapshotV1,
33 hashes: IdlHashes,
34}
35
36impl CanonicalIdlDocument {
37 pub fn parse(bytes: &[u8], explicit_program_id: Option<&str>) -> Result<Self, HashError> {
38 let mut content = crate::parse_json_bytes_strict(bytes)?;
39 let source_program_ids = collect_program_ids(&content)?;
40 let source_has_program_id = !source_program_ids.is_empty();
41 let program_id = resolve_program_id(source_program_ids, explicit_program_id)?;
42
43 if !source_has_program_id {
44 content
45 .as_object_mut()
46 .ok_or_else(|| HashError::InvalidIdl("IDL root must be an object".to_string()))?
47 .insert("address".to_string(), Value::String(program_id.clone()));
48 }
49
50 let parser_input = serde_json::to_string(&content)
51 .map_err(|error| HashError::Serialization(error.to_string()))?;
52 let mut idl =
53 arete_idl::parse::parse_idl_content(&parser_input).map_err(HashError::InvalidIdl)?;
54 idl.address = Some(program_id.clone());
55
56 let mut snapshot = normalize_idl_snapshot_v1(&idl);
57 snapshot.snapshot.program_id = Some(program_id.clone());
58 let portable = portable_idl_projection(&content)?;
59 let hashes = IdlHashes {
60 source: hash_raw_bytes(bytes)?,
61 content: hash_jcs(&content)?,
62 portable: hash_jcs(&portable)?,
63 normalized: hash_jcs(&snapshot)?,
64 };
65
66 Ok(Self {
67 source_bytes: bytes.to_vec(),
68 content,
69 portable,
70 idl,
71 program_id,
72 snapshot,
73 hashes,
74 })
75 }
76
77 pub fn source_bytes(&self) -> &[u8] {
78 &self.source_bytes
79 }
80
81 pub fn content_projection(&self) -> &Value {
82 &self.content
83 }
84
85 pub fn portable_projection(&self) -> &Value {
86 &self.portable
87 }
88
89 pub fn parsed_idl(&self) -> &IdlSpec {
90 &self.idl
91 }
92
93 pub fn program_id(&self) -> &str {
94 &self.program_id
95 }
96
97 pub fn normalized_snapshot(&self) -> &IdlSnapshotV1 {
98 &self.snapshot
99 }
100
101 pub fn hashes(&self) -> &IdlHashes {
102 &self.hashes
103 }
104
105 pub fn content_payload(&self) -> Result<Vec<u8>, HashError> {
106 canonicalize_jcs(&self.content)
107 }
108
109 pub fn portable_payload(&self) -> Result<Vec<u8>, HashError> {
110 canonicalize_jcs(&self.portable)
111 }
112
113 pub fn normalized_payload(&self) -> Result<Vec<u8>, HashError> {
114 canonicalize_jcs(&self.snapshot)
115 }
116}
117
118pub fn portable_idl_projection(source: &Value) -> Result<Value, HashError> {
119 let mut portable = source.clone();
120 let object = portable
121 .as_object_mut()
122 .ok_or_else(|| HashError::InvalidIdl("IDL root must be an object".to_string()))?;
123 object.remove("address");
124 object.remove("program_id");
125 if let Some(metadata) = object.get_mut("metadata").and_then(Value::as_object_mut) {
126 metadata.remove("address");
127 }
128 if let Some(program) = object.get_mut("program").and_then(Value::as_object_mut) {
129 program.remove("publicKey");
130 }
131 Ok(portable)
132}
133
134fn collect_program_ids(value: &Value) -> Result<Vec<(&'static str, String)>, HashError> {
135 let object = value
136 .as_object()
137 .ok_or_else(|| HashError::InvalidIdl("IDL root must be an object".to_string()))?;
138 let mut values = Vec::new();
139 collect_program_id(&mut values, "address", object.get("address"))?;
140 collect_program_id(&mut values, "program_id", object.get("program_id"))?;
141 collect_nested_program_id(
142 &mut values,
143 "metadata.address",
144 object.get("metadata"),
145 "address",
146 )?;
147 collect_nested_program_id(
148 &mut values,
149 "program.publicKey",
150 object.get("program"),
151 "publicKey",
152 )?;
153 Ok(values)
154}
155
156fn collect_nested_program_id(
157 output: &mut Vec<(&'static str, String)>,
158 location: &'static str,
159 parent: Option<&Value>,
160 key: &str,
161) -> Result<(), HashError> {
162 match parent {
163 None | Some(Value::Null) => Ok(()),
164 Some(Value::Object(object)) => collect_program_id(output, location, object.get(key)),
165 Some(_) => Err(HashError::InvalidProgramIdLocation { location }),
166 }
167}
168
169fn collect_program_id(
170 output: &mut Vec<(&'static str, String)>,
171 location: &'static str,
172 value: Option<&Value>,
173) -> Result<(), HashError> {
174 match value {
175 None | Some(Value::Null) => Ok(()),
176 Some(Value::String(value)) if value.is_empty() => Ok(()),
177 Some(Value::String(value)) => {
178 output.push((location, value.clone()));
179 Ok(())
180 }
181 Some(_) => Err(HashError::InvalidProgramIdLocation { location }),
182 }
183}
184
185fn resolve_program_id(
186 mut values: Vec<(&'static str, String)>,
187 explicit: Option<&str>,
188) -> Result<String, HashError> {
189 if let Some(explicit) = explicit {
190 if explicit.is_empty() {
191 return Err(HashError::MissingProgramId);
192 }
193 values.push(("explicit", explicit.to_string()));
194 }
195 if values.is_empty() {
196 return Err(HashError::MissingProgramId);
197 }
198
199 let distinct: BTreeSet<&str> = values.iter().map(|(_, value)| value.as_str()).collect();
200 if distinct.len() != 1 {
201 let detail = values
202 .iter()
203 .map(|(location, value)| format!("{location}={value}"))
204 .collect::<Vec<_>>()
205 .join(", ");
206 return Err(HashError::ConflictingProgramIds(detail));
207 }
208 Ok(values.remove(0).1)
209}
210
211#[derive(Debug, Clone, Serialize, Deserialize)]
212#[serde(rename_all = "camelCase")]
213pub struct ProgramSpecV1 {
214 pub schema: String,
215 pub program_id: String,
216 pub idl_content_hash: HashId<IdlContent>,
217 pub portable_idl_hash: HashId<IdlPortable>,
218 pub normalized_idl_hash: HashId<IdlNormalized>,
219 pub idl_snapshot: IdlSnapshotV1,
220 pub pdas: BTreeMap<String, PdaDefinitionV1>,
221 pub instructions: Vec<InstructionDefinitionV1>,
222}
223
224impl ProgramSpecV1 {
225 pub fn from_document(document: &CanonicalIdlDocument) -> Self {
226 let extracted = extract_pdas(document.parsed_idl());
227 let instructions =
228 extract_instructions(document.parsed_idl(), &extracted, document.program_id());
229 let pdas = extracted.published();
230 Self {
231 schema: PROGRAM_SPEC_SCHEMA_V1.to_string(),
232 program_id: document.program_id.clone(),
233 idl_content_hash: document.hashes.content,
234 portable_idl_hash: document.hashes.portable,
235 normalized_idl_hash: document.hashes.normalized,
236 idl_snapshot: document.snapshot.clone(),
237 pdas,
238 instructions,
239 }
240 }
241
242 pub fn hash(&self) -> Result<HashId<ProgramSpec>, HashError> {
243 self.validate()?;
244 hash_jcs(self)
245 }
246
247 pub fn validate(&self) -> Result<(), HashError> {
248 if self.schema != PROGRAM_SPEC_SCHEMA_V1 {
249 return Err(HashError::UnknownVersion(self.schema.clone()));
250 }
251 if self.idl_snapshot.normalization_version != arete_idl::IDL_NORMALIZATION_VERSION {
252 return Err(HashError::UnknownVersion(format!(
253 "IDL normalization version {}",
254 self.idl_snapshot.normalization_version
255 )));
256 }
257 if self.program_id.is_empty() {
258 return Err(HashError::MissingProgramId);
259 }
260 if self.idl_snapshot.snapshot.program_id.as_deref() != Some(self.program_id.as_str()) {
261 return Err(HashError::InvalidProjection {
262 projection: "program spec",
263 reason: "programId must match idlSnapshot.program_id".to_string(),
264 });
265 }
266 for pda in self.pdas.values() {
267 validate_pda_seeds(&pda.seeds)?;
268 }
269 for instruction in &self.instructions {
270 for account in &instruction.accounts {
271 if let AccountResolutionV1::PdaInline { seeds, .. } = &account.resolution {
272 validate_pda_seeds(seeds)?;
273 }
274 }
275 }
276 Ok(())
277 }
278
279 pub fn oss_release(&self) -> Result<OssGeneratedProgramReleaseV1, HashError> {
280 Ok(OssGeneratedProgramReleaseV1::new(
281 self.program_id.clone(),
282 self.hash()?,
283 self.idl_content_hash,
284 self.normalized_idl_hash,
285 ))
286 }
287
288 pub fn oss_release_hash(&self) -> Result<HashId<crate::ProgramRelease>, HashError> {
289 self.oss_release()?.hash()
290 }
291
292 pub fn oss_identity(&self) -> Result<OssProgramIdentityV1, HashError> {
293 OssProgramIdentityV1::new(self.clone())
294 }
295}
296
297#[derive(Debug, Clone)]
298pub struct OssProgramIdentityV1 {
299 pub program_spec: ProgramSpecV1,
300 pub program_spec_hash: HashId<ProgramSpec>,
301 pub release: OssGeneratedProgramReleaseV1,
302 pub release_hash: HashId<ProgramRelease>,
303}
304
305impl OssProgramIdentityV1 {
306 pub fn new(program_spec: ProgramSpecV1) -> Result<Self, HashError> {
307 let program_spec_hash = program_spec.hash()?;
308 let release = OssGeneratedProgramReleaseV1::new(
309 program_spec.program_id.clone(),
310 program_spec_hash,
311 program_spec.idl_content_hash,
312 program_spec.normalized_idl_hash,
313 );
314 let release_hash = release.hash()?;
315 Ok(Self {
316 program_spec,
317 program_spec_hash,
318 release,
319 release_hash,
320 })
321 }
322
323 pub fn from_document(document: &CanonicalIdlDocument) -> Result<Self, HashError> {
324 Self::new(ProgramSpecV1::from_document(document))
325 }
326}
327
328pub fn build_program_spec_v1_from_bytes(
329 bytes: &[u8],
330 explicit_program_id: Option<&str>,
331) -> Result<ProgramSpecV1, HashError> {
332 let document = CanonicalIdlDocument::parse(bytes, explicit_program_id)?;
333 Ok(ProgramSpecV1::from_document(&document))
334}
335
336pub fn build_oss_program_identity_v1_from_bytes(
337 bytes: &[u8],
338 explicit_program_id: Option<&str>,
339) -> Result<OssProgramIdentityV1, HashError> {
340 OssProgramIdentityV1::new(build_program_spec_v1_from_bytes(
341 bytes,
342 explicit_program_id,
343 )?)
344}
345
346pub fn build_program_spec_v1_from_idl(
351 idl: &IdlSpec,
352 explicit_program_id: Option<&str>,
353) -> Result<ProgramSpecV1, HashError> {
354 let bytes =
355 serde_json::to_vec(idl).map_err(|error| HashError::Serialization(error.to_string()))?;
356 build_program_spec_v1_from_bytes(&bytes, explicit_program_id)
357}
358
359#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
360pub struct PdaDefinitionV1 {
361 pub name: String,
362 pub seeds: Vec<PdaSeedV1>,
363 #[serde(default, skip_serializing_if = "Option::is_none")]
366 pub program_id: Option<String>,
367 #[serde(default, skip_serializing_if = "Option::is_none")]
370 pub program: Option<PdaProgramV1>,
371}
372
373#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
374#[serde(tag = "type", rename_all = "camelCase")]
375pub enum PdaProgramV1 {
376 AccountRef { account_name: String },
377 ArgRef { arg_name: String },
378}
379
380#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
381#[serde(tag = "type", rename_all = "camelCase")]
382pub enum PdaSeedV1 {
383 Literal {
384 value: String,
385 },
386 Bytes {
387 value: Vec<u8>,
388 },
389 ArgRef {
390 arg_name: String,
391 #[serde(default, skip_serializing_if = "Option::is_none")]
392 arg_type: Option<String>,
393 },
394 AccountRef {
395 account_name: String,
396 },
397}
398
399#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
400#[serde(tag = "category", rename_all = "camelCase")]
401pub enum AccountResolutionV1 {
402 Signer,
403 Known {
404 address: String,
405 },
406 PdaRef {
407 pda_name: String,
408 },
409 PdaInline {
410 seeds: Vec<PdaSeedV1>,
411 #[serde(default, skip_serializing_if = "Option::is_none")]
412 program_id: Option<String>,
413 #[serde(default, skip_serializing_if = "Option::is_none")]
414 program: Option<PdaProgramV1>,
415 },
416 UserProvided,
417}
418
419#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
420pub struct InstructionAccountV1 {
421 pub name: String,
422 #[serde(default)]
423 pub is_signer: bool,
424 #[serde(default)]
425 pub is_writable: bool,
426 pub resolution: AccountResolutionV1,
427 #[serde(default)]
428 pub is_optional: bool,
429 #[serde(default, skip_serializing_if = "Vec::is_empty")]
430 pub docs: Vec<String>,
431}
432
433#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
434#[serde(rename_all = "camelCase")]
435pub struct InstructionAmountHintV1 {
436 pub decimals_source: AmountDecimalsSourceV1,
437}
438
439#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
440#[serde(
441 tag = "kind",
442 rename_all = "camelCase",
443 rename_all_fields = "camelCase"
444)]
445pub enum AmountDecimalsSourceV1 {
446 ArgMint { arg_name: String },
447 ArgDecimals { arg_name: String },
448 KnownAccount { account_name: String },
449 Constant { decimals: u8 },
450}
451
452#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
453pub struct InstructionArgumentV1 {
454 pub name: String,
455 #[serde(rename = "type")]
456 pub arg_type: String,
457 #[serde(default, skip_serializing_if = "Vec::is_empty")]
458 pub docs: Vec<String>,
459 #[serde(default, skip_serializing_if = "Option::is_none")]
460 pub amount_hint: Option<InstructionAmountHintV1>,
461}
462
463fn default_discriminator_size() -> usize {
464 8
465}
466
467#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
468pub struct InstructionDefinitionV1 {
469 pub name: String,
470 pub discriminator: Vec<u8>,
471 #[serde(default = "default_discriminator_size")]
472 pub discriminator_size: usize,
473 pub accounts: Vec<InstructionAccountV1>,
474 pub args: Vec<InstructionArgumentV1>,
475 #[serde(default, skip_serializing_if = "Vec::is_empty")]
476 pub errors: Vec<IdlErrorSnapshot>,
477 #[serde(default, skip_serializing_if = "Option::is_none")]
478 pub program_id: Option<String>,
479 #[serde(default, skip_serializing_if = "Vec::is_empty")]
480 pub docs: Vec<String>,
481}
482
483#[derive(Debug, Default, Clone, PartialEq)]
499struct ExtractedPdas {
500 explicit: BTreeMap<String, PdaDefinitionV1>,
501 discovered: BTreeMap<String, PdaDefinitionV1>,
502}
503
504impl ExtractedPdas {
505 fn published(&self) -> BTreeMap<String, PdaDefinitionV1> {
508 let mut published = self.discovered.clone();
509 for (name, pda) in &self.explicit {
510 published.insert(name.clone(), pda.clone());
511 }
512 published
513 }
514
515 fn is_explicit(&self, name: &str) -> bool {
517 self.explicit.contains_key(name)
518 }
519
520 fn referenceable(&self, name: &str) -> Option<&PdaDefinitionV1> {
523 self.explicit
524 .get(name)
525 .or_else(|| self.discovered.get(name))
526 }
527}
528
529fn extract_pdas(idl: &IdlSpec) -> ExtractedPdas {
530 let mut extracted = ExtractedPdas::default();
531 for pda in &idl.pdas {
532 let name = sanitize_identifier(&pda.name);
533 extracted.explicit.insert(
534 name.clone(),
535 convert_pda(&name, &pda.seeds, pda.program.as_ref()),
536 );
537 }
538 let mut conflicting_account_pdas = BTreeSet::new();
539 for instruction in &idl.instructions {
540 for account in instruction.flattened_accounts() {
541 if let Some(pda) = &account.pda {
542 let name = sanitize_identifier(pda.name.as_deref().unwrap_or(&account.name));
543 if extracted.is_explicit(&name) || conflicting_account_pdas.contains(&name) {
544 continue;
545 }
546 let candidate = convert_pda(&name, &pda.seeds, pda.program.as_ref());
547 match extracted.discovered.get(&name) {
548 None => {
549 extracted.discovered.insert(name, candidate);
550 }
551 Some(existing) if existing == &candidate => {}
552 Some(_) => {
553 extracted.discovered.remove(&name);
557 conflicting_account_pdas.insert(name);
558 }
559 }
560 }
561 }
562 }
563 extracted
564}
565
566fn extract_instructions(
567 idl: &IdlSpec,
568 pdas: &ExtractedPdas,
569 program_id: &str,
570) -> Vec<InstructionDefinitionV1> {
571 let discriminator_size = idl.instruction_discriminator_size();
572
573 idl.instructions
574 .iter()
575 .map(|instruction| InstructionDefinitionV1 {
576 name: instruction.name.clone(),
577 discriminator: instruction.get_discriminator(),
578 discriminator_size,
579 accounts: instruction
580 .flattened_accounts()
581 .iter()
582 .map(|account| convert_account(account, pdas))
583 .collect(),
584 args: instruction
585 .args
586 .iter()
587 .map(|argument| InstructionArgumentV1 {
588 name: argument.name.clone(),
589 arg_type: idl_type_to_rust_string(&argument.type_),
590 docs: Vec::new(),
591 amount_hint: argument.amount_hint.as_ref().map(convert_amount_hint),
592 })
593 .collect(),
594 errors: Vec::new(),
595 program_id: Some(program_id.to_string()),
596 docs: instruction.docs.clone(),
597 })
598 .collect()
599}
600
601fn convert_pda(
602 name: &str,
603 seeds: &[arete_idl::IdlPdaSeed],
604 program: Option<&arete_idl::IdlPdaProgram>,
605) -> PdaDefinitionV1 {
606 let seeds = seeds
607 .iter()
608 .map(|seed| match seed {
609 arete_idl::IdlPdaSeed::Const { value } => convert_const_pda_seed(value),
610 arete_idl::IdlPdaSeed::Account { path, .. } => PdaSeedV1::AccountRef {
611 account_name: sanitize_seed_path(path),
612 },
613 arete_idl::IdlPdaSeed::Arg { path, arg_type } => PdaSeedV1::ArgRef {
614 arg_name: sanitize_seed_path(path),
615 arg_type: arg_type.clone(),
616 },
617 })
618 .collect();
619 let (program_id, program) = match program {
620 Some(arete_idl::IdlPdaProgram::Literal { value, .. }) => (Some(value.clone()), None),
621 Some(arete_idl::IdlPdaProgram::Const { value, .. }) => {
622 (Some(bs58::encode(value).into_string()), None)
623 }
624 Some(arete_idl::IdlPdaProgram::Account { path, .. }) => (
625 None,
626 Some(PdaProgramV1::AccountRef {
627 account_name: sanitize_seed_path(path),
628 }),
629 ),
630 None => (None, None),
631 };
632 PdaDefinitionV1 {
633 name: name.to_string(),
634 seeds,
635 program_id,
636 program,
637 }
638}
639
640fn convert_const_pda_seed(value: &[u8]) -> PdaSeedV1 {
641 match String::from_utf8(value.to_vec()) {
642 Ok(value) if !value.contains('\0') => PdaSeedV1::Literal { value },
643 _ => PdaSeedV1::Bytes {
644 value: value.to_vec(),
645 },
646 }
647}
648
649fn validate_pda_seeds(seeds: &[PdaSeedV1]) -> Result<(), HashError> {
650 if seeds
651 .iter()
652 .any(|seed| matches!(seed, PdaSeedV1::Literal { value } if value.contains('\0')))
653 {
654 return Err(HashError::InvalidProjection {
655 projection: "program spec",
656 reason: "literal PDA seeds must not contain NUL bytes; use a bytes seed".to_string(),
657 });
658 }
659 Ok(())
660}
661
662fn convert_account(
663 account: &arete_idl::IdlAccountArg,
664 pdas: &ExtractedPdas,
665) -> InstructionAccountV1 {
666 let resolution = if account.is_signer && account.address.is_none() && account.pda.is_none() {
667 AccountResolutionV1::Signer
668 } else if let Some(address) = &account.address {
669 AccountResolutionV1::Known {
670 address: address.clone(),
671 }
672 } else if let Some(pda) = &account.pda {
673 let name = sanitize_identifier(pda.name.as_deref().unwrap_or(&account.name));
676 let converted = convert_pda(&name, &pda.seeds, pda.program.as_ref());
677 if pdas.referenceable(&name) == Some(&converted) {
678 AccountResolutionV1::PdaRef { pda_name: name }
679 } else {
680 AccountResolutionV1::PdaInline {
681 seeds: converted.seeds,
682 program_id: converted.program_id,
683 program: converted.program,
684 }
685 }
686 } else {
687 let name = sanitize_identifier(&account.name);
691 if pdas.is_explicit(&name) {
692 AccountResolutionV1::PdaRef { pda_name: name }
693 } else {
694 AccountResolutionV1::UserProvided
695 }
696 };
697 InstructionAccountV1 {
698 name: sanitize_identifier(&account.name),
699 is_signer: account.is_signer,
700 is_writable: account.is_mut,
701 resolution,
702 is_optional: account.optional,
703 docs: account.docs.clone(),
704 }
705}
706
707fn convert_amount_hint(hint: &IdlAmountHint) -> InstructionAmountHintV1 {
708 InstructionAmountHintV1 {
709 decimals_source: match &hint.decimals_source {
710 IdlAmountDecimalsSource::ArgMint { arg_name } => AmountDecimalsSourceV1::ArgMint {
711 arg_name: arg_name.clone(),
712 },
713 IdlAmountDecimalsSource::ArgDecimals { arg_name } => {
714 AmountDecimalsSourceV1::ArgDecimals {
715 arg_name: arg_name.clone(),
716 }
717 }
718 IdlAmountDecimalsSource::KnownAccount { account_name } => {
719 AmountDecimalsSourceV1::KnownAccount {
720 account_name: account_name.clone(),
721 }
722 }
723 IdlAmountDecimalsSource::Constant { decimals } => AmountDecimalsSourceV1::Constant {
724 decimals: *decimals,
725 },
726 },
727 }
728}
729
730fn sanitize_identifier(name: &str) -> String {
731 let mut sanitized = String::new();
732 for character in name.chars() {
733 if character.is_ascii_alphanumeric() || character == '_' {
734 sanitized.push(character);
735 } else if !sanitized.ends_with('_') {
736 sanitized.push('_');
737 }
738 }
739 let sanitized = sanitized.trim_matches('_').to_string();
740 if sanitized.is_empty() {
741 return "value".to_string();
742 }
743 if sanitized
744 .chars()
745 .next()
746 .is_some_and(|character| character.is_ascii_digit())
747 {
748 return format!("_{sanitized}");
749 }
750 sanitized
751}
752
753fn sanitize_seed_path(path: &str) -> String {
754 path.split('.')
755 .map(sanitize_identifier)
756 .collect::<Vec<_>>()
757 .join(".")
758}
759
760fn idl_type_to_rust_string(idl_type: &IdlType) -> String {
761 match idl_type {
762 IdlType::Simple(simple) => match simple.as_str() {
763 "string" => "String".to_string(),
764 "publicKey" | "pubkey" => "solana_pubkey::Pubkey".to_string(),
765 "bytes" => "Vec<u8>".to_string(),
766 other => other.to_string(),
767 },
768 IdlType::Array(array) if array.array.len() == 2 => {
769 match (&array.array[0], &array.array[1]) {
770 (IdlTypeArrayElement::Type(name), IdlTypeArrayElement::Size(size)) => {
771 format!(
772 "[{}; {size}]",
773 idl_type_to_rust_string(&IdlType::Simple(name.clone()))
774 )
775 }
776 (IdlTypeArrayElement::Nested(ty), IdlTypeArrayElement::Size(size)) => {
777 format!("[{}; {size}]", idl_type_to_rust_string(ty))
778 }
779 _ => "Vec<u8>".to_string(),
780 }
781 }
782 IdlType::Array(_) => "Vec<u8>".to_string(),
783 IdlType::Option(option) => format!("Option<{}>", idl_type_to_rust_string(&option.option)),
784 IdlType::Vec(vec_type) => {
786 let inner = idl_type_to_rust_string(&vec_type.vec);
787 if matches!(
788 vec_type.length_prefix,
789 Some(arete_idl::types::IdlLengthPrefix::U64)
790 ) {
791 format!("VecU64Len<{inner}>")
792 } else {
793 format!("Vec<{inner}>")
794 }
795 }
796 IdlType::HashMap(hash_map) => format!(
797 "std::collections::HashMap<{}, {}>",
798 idl_type_to_rust_string(&hash_map.hash_map.0),
799 idl_type_to_rust_string(&hash_map.hash_map.1)
800 ),
801 IdlType::Tuple(tuple) => format!(
802 "({})",
803 tuple
804 .tuple
805 .iter()
806 .map(idl_type_to_rust_string)
807 .collect::<Vec<_>>()
808 .join(", ")
809 ),
810 IdlType::Defined(defined) => match &defined.defined {
811 IdlTypeDefinedInner::Named { name } => name.clone(),
812 IdlTypeDefinedInner::Simple(simple) => simple.clone(),
813 },
814 }
815}
816
817#[cfg(test)]
818mod tests {
819 use super::*;
820
821 #[test]
822 fn preserves_account_selected_pda_programs() {
823 let definition = convert_pda(
824 "metadata",
825 &[arete_idl::IdlPdaSeed::Const {
826 value: b"metadata".to_vec(),
827 }],
828 Some(&arete_idl::IdlPdaProgram::Account {
829 kind: "account".to_string(),
830 path: "metadata_program".to_string(),
831 }),
832 );
833
834 assert_eq!(definition.program_id, None);
835 assert_eq!(
836 definition.program,
837 Some(PdaProgramV1::AccountRef {
838 account_name: "metadata_program".to_string(),
839 })
840 );
841 }
842
843 #[test]
844 fn preserves_nul_const_pda_seeds_as_bytes() {
845 let definition = convert_pda(
846 "binary",
847 &[
848 arete_idl::IdlPdaSeed::Const {
849 value: b"text".to_vec(),
850 },
851 arete_idl::IdlPdaSeed::Const { value: vec![0, 0] },
852 ],
853 None,
854 );
855
856 assert_eq!(
857 definition.seeds,
858 vec![
859 PdaSeedV1::Literal {
860 value: "text".to_string(),
861 },
862 PdaSeedV1::Bytes { value: vec![0, 0] },
863 ]
864 );
865 }
866
867 #[test]
868 fn rejects_nul_in_literal_pda_seeds() {
869 let source = br#"{
870 "address":"11111111111111111111111111111111",
871 "metadata":{"name":"demo","version":"0.1.0","spec":"0.1.0"},
872 "instructions":[
873 {"name":"create","discriminator":[1],"accounts":[
874 {"name":"state","pda":{"seeds":[{"kind":"const","value":[115,116,97,116,101]}]}}
875 ],"args":[]}
876 ],
877 "accounts":[],"types":[],"events":[],"errors":[]
878 }"#;
879 let document = CanonicalIdlDocument::parse(source, None).unwrap();
880 let mut spec = ProgramSpecV1::from_document(&document);
881 spec.pdas.get_mut("state").unwrap().seeds[0] = PdaSeedV1::Literal {
882 value: "\0".to_string(),
883 };
884
885 assert!(matches!(
886 spec.validate(),
887 Err(HashError::InvalidProjection {
888 projection: "program spec",
889 ..
890 })
891 ));
892 }
893
894 #[test]
895 fn keeps_conflicting_account_pdas_inline_per_instruction() {
896 let source = br#"{
897 "address":"11111111111111111111111111111111",
898 "metadata":{"name":"demo","version":"0.1.0","spec":"0.1.0"},
899 "instructions":[
900 {"name":"create","discriminator":[1,0,0,0,0,0,0,0],"accounts":[
901 {"name":"state","pda":{"seeds":[{"kind":"const","value":[99,114,101,97,116,101]}]}}
902 ],"args":[]},
903 {"name":"update","discriminator":[2,0,0,0,0,0,0,0],"accounts":[
904 {"name":"state","pda":{"seeds":[{"kind":"const","value":[117,112,100,97,116,101]}]}}
905 ],"args":[]}
906 ],
907 "accounts":[],"types":[],"events":[],"errors":[]
908 }"#;
909 let document = CanonicalIdlDocument::parse(source, None).unwrap();
910 let spec = ProgramSpecV1::from_document(&document);
911
912 assert!(!spec.pdas.contains_key("state"));
913 for instruction in &spec.instructions {
914 assert!(matches!(
915 &instruction.accounts[0].resolution,
916 AccountResolutionV1::PdaInline { .. }
917 ));
918 }
919 }
920
921 fn demo_idl(instructions: &str, pdas: &str) -> Vec<u8> {
922 format!(
923 r#"{{
924 "address":"11111111111111111111111111111111",
925 "metadata":{{"name":"demo","version":"0.1.0","spec":"0.1.0"}},
926 "instructions":[{instructions}],
927 {pdas}
928 "accounts":[],"types":[],"events":[],"errors":[]
929 }}"#
930 )
931 .into_bytes()
932 }
933
934 fn resolution<'a>(
935 spec: &'a ProgramSpecV1,
936 instruction: &str,
937 account: &str,
938 ) -> &'a AccountResolutionV1 {
939 &spec
940 .instructions
941 .iter()
942 .find(|candidate| candidate.name == instruction)
943 .unwrap_or_else(|| panic!("instruction {instruction} missing"))
944 .accounts
945 .iter()
946 .find(|candidate| candidate.name == account)
947 .unwrap_or_else(|| panic!("account {instruction}.{account} missing"))
948 .resolution
949 }
950
951 const LOCAL_STATE_PDA: &str =
952 r#"{"name":"state","pda":{"seeds":[{"kind":"const","value":[115,116,97,116,101]}]}}"#;
953 const PLAIN_STATE: &str = r#"{"name":"state"}"#;
954 const EXPLICIT_STATE_PDA: &str =
955 r#""pdas":[{"name":"state","seeds":[{"kind":"const","value":[115,116,97,116,101]}]}],"#;
956
957 #[test]
961 fn local_pda_does_not_leak_to_same_named_plain_accounts() {
962 let source = demo_idl(
963 &format!(
964 r#"{{"name":"create","discriminator":[1],"accounts":[{LOCAL_STATE_PDA}],"args":[]}},
965 {{"name":"update","discriminator":[2],"accounts":[{PLAIN_STATE}],"args":[]}},
966 {{"name":"close","discriminator":[3],"accounts":[{PLAIN_STATE}],"args":[]}}"#
967 ),
968 "",
969 );
970 let document = CanonicalIdlDocument::parse(&source, None).unwrap();
971 let spec = ProgramSpecV1::from_document(&document);
972
973 assert!(spec.pdas.contains_key("state"));
975 assert!(matches!(
976 resolution(&spec, "create", "state"),
977 AccountResolutionV1::PdaRef { pda_name } if pda_name == "state"
978 ));
979 assert_eq!(
981 resolution(&spec, "update", "state"),
982 &AccountResolutionV1::UserProvided
983 );
984 assert_eq!(
985 resolution(&spec, "close", "state"),
986 &AccountResolutionV1::UserProvided
987 );
988 }
989
990 #[test]
993 fn explicit_top_level_pda_is_referenced_by_every_same_named_account() {
994 let source = demo_idl(
995 &format!(
996 r#"{{"name":"create","discriminator":[1],"accounts":[{LOCAL_STATE_PDA}],"args":[]}},
997 {{"name":"update","discriminator":[2],"accounts":[{PLAIN_STATE}],"args":[]}}"#
998 ),
999 EXPLICIT_STATE_PDA,
1000 );
1001 let document = CanonicalIdlDocument::parse(&source, None).unwrap();
1002 let spec = ProgramSpecV1::from_document(&document);
1003
1004 assert_eq!(spec.pdas.len(), 1);
1005 for instruction in ["create", "update"] {
1006 assert!(
1007 matches!(
1008 resolution(&spec, instruction, "state"),
1009 AccountResolutionV1::PdaRef { pda_name } if pda_name == "state"
1010 ),
1011 "{instruction}.state should reference the explicit PDA"
1012 );
1013 }
1014 }
1015
1016 #[test]
1019 fn local_pda_differing_from_explicit_pda_stays_inline() {
1020 let different_local =
1021 r#"{"name":"state","pda":{"seeds":[{"kind":"const","value":[111,116,104,101,114]}]}}"#;
1022 let source = demo_idl(
1023 &format!(
1024 r#"{{"name":"create","discriminator":[1],"accounts":[{different_local}],"args":[]}},
1025 {{"name":"update","discriminator":[2],"accounts":[{PLAIN_STATE}],"args":[]}}"#
1026 ),
1027 EXPLICIT_STATE_PDA,
1028 );
1029 let document = CanonicalIdlDocument::parse(&source, None).unwrap();
1030 let spec = ProgramSpecV1::from_document(&document);
1031
1032 assert_eq!(
1033 spec.pdas["state"].seeds,
1034 vec![PdaSeedV1::Literal {
1035 value: "state".to_string()
1036 }],
1037 "the explicit definition owns the name"
1038 );
1039 assert!(matches!(
1040 resolution(&spec, "create", "state"),
1041 AccountResolutionV1::PdaInline { seeds, .. }
1042 if seeds == &[PdaSeedV1::Literal { value: "other".to_string() }]
1043 ));
1044 assert!(matches!(
1045 resolution(&spec, "update", "state"),
1046 AccountResolutionV1::PdaRef { pda_name } if pda_name == "state"
1047 ));
1048 }
1049
1050 #[test]
1053 fn nested_local_pda_does_not_leak_to_same_named_plain_accounts() {
1054 let nested = r#"{"name":"group","accounts":[{"name":"state","pda":{"seeds":[{"kind":"const","value":[115,116,97,116,101]}]}}]}"#;
1057 let plain_flattened = r#"{"name":"groupState"}"#;
1058 let source = demo_idl(
1059 &format!(
1060 r#"{{"name":"create","discriminator":[1],"accounts":[{nested}],"args":[]}},
1061 {{"name":"update","discriminator":[2],"accounts":[{plain_flattened}],"args":[]}}"#
1062 ),
1063 "",
1064 );
1065 let document = CanonicalIdlDocument::parse(&source, None).unwrap();
1066 let spec = ProgramSpecV1::from_document(&document);
1067
1068 assert!(spec.pdas.contains_key("groupState"));
1069 assert!(matches!(
1070 resolution(&spec, "create", "groupState"),
1071 AccountResolutionV1::PdaRef { pda_name } if pda_name == "groupState"
1072 ));
1073 assert_eq!(
1074 resolution(&spec, "update", "groupState"),
1075 &AccountResolutionV1::UserProvided
1076 );
1077 }
1078
1079 #[test]
1081 fn address_lookup_table_derives_lookup_table_on_create_only() {
1082 let document = CanonicalIdlDocument::parse(
1083 include_bytes!("../../arete-idl/tests/fixtures/address-lookup-table.json"),
1084 None,
1085 )
1086 .unwrap();
1087 let spec = ProgramSpecV1::from_document(&document);
1088
1089 assert_eq!(
1090 spec.program_id,
1091 "AddressLookupTab1e1111111111111111111111111"
1092 );
1093 assert_eq!(spec.pdas.keys().collect::<Vec<_>>(), vec!["lookup_table"]);
1094 assert_eq!(
1095 spec.pdas["lookup_table"].seeds,
1096 vec![
1097 PdaSeedV1::AccountRef {
1098 account_name: "authority".to_string(),
1099 },
1100 PdaSeedV1::ArgRef {
1101 arg_name: "recent_slot".to_string(),
1102 arg_type: Some("u64".to_string()),
1103 },
1104 ]
1105 );
1106 assert!(matches!(
1107 resolution(&spec, "create_lookup_table", "lookup_table"),
1108 AccountResolutionV1::PdaRef { pda_name } if pda_name == "lookup_table"
1109 ));
1110 for instruction in [
1111 "freeze_lookup_table",
1112 "extend_lookup_table",
1113 "deactivate_lookup_table",
1114 "close_lookup_table",
1115 ] {
1116 assert_eq!(
1117 resolution(&spec, instruction, "lookup_table"),
1118 &AccountResolutionV1::UserProvided,
1119 "{instruction}.lookup_table must be caller-provided"
1120 );
1121 }
1122
1123 let extend = spec
1124 .instructions
1125 .iter()
1126 .find(|instruction| instruction.name == "extend_lookup_table")
1127 .unwrap();
1128 assert_eq!(extend.discriminator, vec![2, 0, 0, 0], "u32-LE bincode tag");
1129 assert_eq!(extend.args[0].name, "new_addresses");
1130 assert_eq!(extend.args[0].arg_type, "VecU64Len<solana_pubkey::Pubkey>");
1131 let account = |name: &str| {
1132 extend
1133 .accounts
1134 .iter()
1135 .find(|account| account.name == name)
1136 .unwrap()
1137 };
1138 assert!(account("lookup_table").is_writable && !account("lookup_table").is_signer);
1139 assert!(account("authority").is_signer && !account("authority").is_writable);
1140 assert!(
1141 account("payer").is_signer
1142 && account("payer").is_writable
1143 && account("payer").is_optional
1144 );
1145 assert!(account("system_program").is_optional);
1146 assert!(matches!(
1147 &account("system_program").resolution,
1148 AccountResolutionV1::Known { address } if address == "11111111111111111111111111111111"
1149 ));
1150 let create = spec
1151 .instructions
1152 .iter()
1153 .find(|instruction| instruction.name == "create_lookup_table")
1154 .unwrap();
1155 assert_eq!(create.discriminator, vec![0, 0, 0, 0]);
1156 assert!(create.accounts.iter().any(|account| account.name == "payer"
1157 && account.is_signer
1158 && account.is_writable
1159 && !account.is_optional));
1160 }
1161}