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