Skip to main content

arete_hash/
idl.rs

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/// Strictly parsed IDL plus every authoritative v1 IDL projection.
25#[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 pdas = extract_pdas(document.parsed_idl());
227        let instructions =
228            extract_instructions(document.parsed_idl(), &pdas, document.program_id());
229        Self {
230            schema: PROGRAM_SPEC_SCHEMA_V1.to_string(),
231            program_id: document.program_id.clone(),
232            idl_content_hash: document.hashes.content,
233            portable_idl_hash: document.hashes.portable,
234            normalized_idl_hash: document.hashes.normalized,
235            idl_snapshot: document.snapshot.clone(),
236            pdas,
237            instructions,
238        }
239    }
240
241    pub fn hash(&self) -> Result<HashId<ProgramSpec>, HashError> {
242        self.validate()?;
243        hash_jcs(self)
244    }
245
246    pub fn validate(&self) -> Result<(), HashError> {
247        if self.schema != PROGRAM_SPEC_SCHEMA_V1 {
248            return Err(HashError::UnknownVersion(self.schema.clone()));
249        }
250        if self.idl_snapshot.normalization_version != arete_idl::IDL_NORMALIZATION_VERSION {
251            return Err(HashError::UnknownVersion(format!(
252                "IDL normalization version {}",
253                self.idl_snapshot.normalization_version
254            )));
255        }
256        if self.program_id.is_empty() {
257            return Err(HashError::MissingProgramId);
258        }
259        if self.idl_snapshot.snapshot.program_id.as_deref() != Some(self.program_id.as_str()) {
260            return Err(HashError::InvalidProjection {
261                projection: "program spec",
262                reason: "programId must match idlSnapshot.program_id".to_string(),
263            });
264        }
265        for pda in self.pdas.values() {
266            validate_pda_seeds(&pda.seeds)?;
267        }
268        for instruction in &self.instructions {
269            for account in &instruction.accounts {
270                if let AccountResolutionV1::PdaInline { seeds, .. } = &account.resolution {
271                    validate_pda_seeds(seeds)?;
272                }
273            }
274        }
275        Ok(())
276    }
277
278    pub fn oss_release(&self) -> Result<OssGeneratedProgramReleaseV1, HashError> {
279        Ok(OssGeneratedProgramReleaseV1::new(
280            self.program_id.clone(),
281            self.hash()?,
282            self.idl_content_hash,
283            self.normalized_idl_hash,
284        ))
285    }
286
287    pub fn oss_release_hash(&self) -> Result<HashId<crate::ProgramRelease>, HashError> {
288        self.oss_release()?.hash()
289    }
290
291    pub fn oss_identity(&self) -> Result<OssProgramIdentityV1, HashError> {
292        OssProgramIdentityV1::new(self.clone())
293    }
294}
295
296#[derive(Debug, Clone)]
297pub struct OssProgramIdentityV1 {
298    pub program_spec: ProgramSpecV1,
299    pub program_spec_hash: HashId<ProgramSpec>,
300    pub release: OssGeneratedProgramReleaseV1,
301    pub release_hash: HashId<ProgramRelease>,
302}
303
304impl OssProgramIdentityV1 {
305    pub fn new(program_spec: ProgramSpecV1) -> Result<Self, HashError> {
306        let program_spec_hash = program_spec.hash()?;
307        let release = OssGeneratedProgramReleaseV1::new(
308            program_spec.program_id.clone(),
309            program_spec_hash,
310            program_spec.idl_content_hash,
311            program_spec.normalized_idl_hash,
312        );
313        let release_hash = release.hash()?;
314        Ok(Self {
315            program_spec,
316            program_spec_hash,
317            release,
318            release_hash,
319        })
320    }
321
322    pub fn from_document(document: &CanonicalIdlDocument) -> Result<Self, HashError> {
323        Self::new(ProgramSpecV1::from_document(document))
324    }
325}
326
327pub fn build_program_spec_v1_from_bytes(
328    bytes: &[u8],
329    explicit_program_id: Option<&str>,
330) -> Result<ProgramSpecV1, HashError> {
331    let document = CanonicalIdlDocument::parse(bytes, explicit_program_id)?;
332    Ok(ProgramSpecV1::from_document(&document))
333}
334
335pub fn build_oss_program_identity_v1_from_bytes(
336    bytes: &[u8],
337    explicit_program_id: Option<&str>,
338) -> Result<OssProgramIdentityV1, HashError> {
339    OssProgramIdentityV1::new(build_program_spec_v1_from_bytes(
340        bytes,
341        explicit_program_id,
342    )?)
343}
344
345/// Compatibility adapter for callers that no longer have the original bytes.
346///
347/// New ingress should always call `build_program_spec_v1_from_bytes` so
348/// `idl-content` is derived from the complete parsed source document.
349pub fn build_program_spec_v1_from_idl(
350    idl: &IdlSpec,
351    explicit_program_id: Option<&str>,
352) -> Result<ProgramSpecV1, HashError> {
353    let bytes =
354        serde_json::to_vec(idl).map_err(|error| HashError::Serialization(error.to_string()))?;
355    build_program_spec_v1_from_bytes(&bytes, explicit_program_id)
356}
357
358#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
359pub struct PdaDefinitionV1 {
360    pub name: String,
361    pub seeds: Vec<PdaSeedV1>,
362    /// Legacy/static PDA program. Retained so existing ProgramSpec hashes do
363    /// not change when the PDA program is a literal.
364    #[serde(default, skip_serializing_if = "Option::is_none")]
365    pub program_id: Option<String>,
366    /// Dynamic PDA program selector. This is emitted only when the owning
367    /// program must be resolved from another account or instruction argument.
368    #[serde(default, skip_serializing_if = "Option::is_none")]
369    pub program: Option<PdaProgramV1>,
370}
371
372#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
373#[serde(tag = "type", rename_all = "camelCase")]
374pub enum PdaProgramV1 {
375    AccountRef { account_name: String },
376    ArgRef { arg_name: String },
377}
378
379#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
380#[serde(tag = "type", rename_all = "camelCase")]
381pub enum PdaSeedV1 {
382    Literal {
383        value: String,
384    },
385    Bytes {
386        value: Vec<u8>,
387    },
388    ArgRef {
389        arg_name: String,
390        #[serde(default, skip_serializing_if = "Option::is_none")]
391        arg_type: Option<String>,
392    },
393    AccountRef {
394        account_name: String,
395    },
396}
397
398#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
399#[serde(tag = "category", rename_all = "camelCase")]
400pub enum AccountResolutionV1 {
401    Signer,
402    Known {
403        address: String,
404    },
405    PdaRef {
406        pda_name: String,
407    },
408    PdaInline {
409        seeds: Vec<PdaSeedV1>,
410        #[serde(default, skip_serializing_if = "Option::is_none")]
411        program_id: Option<String>,
412        #[serde(default, skip_serializing_if = "Option::is_none")]
413        program: Option<PdaProgramV1>,
414    },
415    UserProvided,
416}
417
418#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
419pub struct InstructionAccountV1 {
420    pub name: String,
421    #[serde(default)]
422    pub is_signer: bool,
423    #[serde(default)]
424    pub is_writable: bool,
425    pub resolution: AccountResolutionV1,
426    #[serde(default)]
427    pub is_optional: bool,
428    #[serde(default, skip_serializing_if = "Vec::is_empty")]
429    pub docs: Vec<String>,
430}
431
432#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
433#[serde(rename_all = "camelCase")]
434pub struct InstructionAmountHintV1 {
435    pub decimals_source: AmountDecimalsSourceV1,
436}
437
438#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
439#[serde(
440    tag = "kind",
441    rename_all = "camelCase",
442    rename_all_fields = "camelCase"
443)]
444pub enum AmountDecimalsSourceV1 {
445    ArgMint { arg_name: String },
446    ArgDecimals { arg_name: String },
447    KnownAccount { account_name: String },
448    Constant { decimals: u8 },
449}
450
451#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
452pub struct InstructionArgumentV1 {
453    pub name: String,
454    #[serde(rename = "type")]
455    pub arg_type: String,
456    #[serde(default, skip_serializing_if = "Vec::is_empty")]
457    pub docs: Vec<String>,
458    #[serde(default, skip_serializing_if = "Option::is_none")]
459    pub amount_hint: Option<InstructionAmountHintV1>,
460}
461
462fn default_discriminator_size() -> usize {
463    8
464}
465
466#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
467pub struct InstructionDefinitionV1 {
468    pub name: String,
469    pub discriminator: Vec<u8>,
470    #[serde(default = "default_discriminator_size")]
471    pub discriminator_size: usize,
472    pub accounts: Vec<InstructionAccountV1>,
473    pub args: Vec<InstructionArgumentV1>,
474    #[serde(default, skip_serializing_if = "Vec::is_empty")]
475    pub errors: Vec<IdlErrorSnapshot>,
476    #[serde(default, skip_serializing_if = "Option::is_none")]
477    pub program_id: Option<String>,
478    #[serde(default, skip_serializing_if = "Vec::is_empty")]
479    pub docs: Vec<String>,
480}
481
482fn extract_pdas(idl: &IdlSpec) -> BTreeMap<String, PdaDefinitionV1> {
483    let mut pdas = BTreeMap::new();
484    let mut named_pdas = BTreeSet::new();
485    for pda in &idl.pdas {
486        let name = sanitize_identifier(&pda.name);
487        named_pdas.insert(name.clone());
488        pdas.insert(
489            name.clone(),
490            convert_pda(&name, &pda.seeds, pda.program.as_ref()),
491        );
492    }
493    let mut conflicting_account_pdas = BTreeSet::new();
494    for instruction in &idl.instructions {
495        for account in instruction.flattened_accounts() {
496            if let Some(pda) = &account.pda {
497                let name = sanitize_identifier(pda.name.as_deref().unwrap_or(&account.name));
498                if named_pdas.contains(&name) || conflicting_account_pdas.contains(&name) {
499                    continue;
500                }
501                let candidate = convert_pda(&name, &pda.seeds, pda.program.as_ref());
502                match pdas.get(&name) {
503                    None => {
504                        pdas.insert(name, candidate);
505                    }
506                    Some(existing) if existing == &candidate => {}
507                    Some(_) => {
508                        // Account-level PDAs are instruction-local. Publishing one
509                        // arbitrary definition under a shared name makes other
510                        // instructions derive a plausible but incorrect address.
511                        pdas.remove(&name);
512                        conflicting_account_pdas.insert(name);
513                    }
514                }
515            }
516        }
517    }
518    pdas
519}
520
521fn extract_instructions(
522    idl: &IdlSpec,
523    pdas: &BTreeMap<String, PdaDefinitionV1>,
524    program_id: &str,
525) -> Vec<InstructionDefinitionV1> {
526    let discriminator_size = idl.instruction_discriminator_size();
527
528    idl.instructions
529        .iter()
530        .map(|instruction| InstructionDefinitionV1 {
531            name: instruction.name.clone(),
532            discriminator: instruction.get_discriminator(),
533            discriminator_size,
534            accounts: instruction
535                .flattened_accounts()
536                .iter()
537                .map(|account| convert_account(account, pdas))
538                .collect(),
539            args: instruction
540                .args
541                .iter()
542                .map(|argument| InstructionArgumentV1 {
543                    name: argument.name.clone(),
544                    arg_type: idl_type_to_rust_string(&argument.type_),
545                    docs: Vec::new(),
546                    amount_hint: argument.amount_hint.as_ref().map(convert_amount_hint),
547                })
548                .collect(),
549            errors: Vec::new(),
550            program_id: Some(program_id.to_string()),
551            docs: instruction.docs.clone(),
552        })
553        .collect()
554}
555
556fn convert_pda(
557    name: &str,
558    seeds: &[arete_idl::IdlPdaSeed],
559    program: Option<&arete_idl::IdlPdaProgram>,
560) -> PdaDefinitionV1 {
561    let seeds = seeds
562        .iter()
563        .map(|seed| match seed {
564            arete_idl::IdlPdaSeed::Const { value } => convert_const_pda_seed(value),
565            arete_idl::IdlPdaSeed::Account { path, .. } => PdaSeedV1::AccountRef {
566                account_name: sanitize_seed_path(path),
567            },
568            arete_idl::IdlPdaSeed::Arg { path, arg_type } => PdaSeedV1::ArgRef {
569                arg_name: sanitize_seed_path(path),
570                arg_type: arg_type.clone(),
571            },
572        })
573        .collect();
574    let (program_id, program) = match program {
575        Some(arete_idl::IdlPdaProgram::Literal { value, .. }) => (Some(value.clone()), None),
576        Some(arete_idl::IdlPdaProgram::Const { value, .. }) => {
577            (Some(bs58::encode(value).into_string()), None)
578        }
579        Some(arete_idl::IdlPdaProgram::Account { path, .. }) => (
580            None,
581            Some(PdaProgramV1::AccountRef {
582                account_name: sanitize_seed_path(path),
583            }),
584        ),
585        None => (None, None),
586    };
587    PdaDefinitionV1 {
588        name: name.to_string(),
589        seeds,
590        program_id,
591        program,
592    }
593}
594
595fn convert_const_pda_seed(value: &[u8]) -> PdaSeedV1 {
596    match String::from_utf8(value.to_vec()) {
597        Ok(value) if !value.contains('\0') => PdaSeedV1::Literal { value },
598        _ => PdaSeedV1::Bytes {
599            value: value.to_vec(),
600        },
601    }
602}
603
604fn validate_pda_seeds(seeds: &[PdaSeedV1]) -> Result<(), HashError> {
605    if seeds
606        .iter()
607        .any(|seed| matches!(seed, PdaSeedV1::Literal { value } if value.contains('\0')))
608    {
609        return Err(HashError::InvalidProjection {
610            projection: "program spec",
611            reason: "literal PDA seeds must not contain NUL bytes; use a bytes seed".to_string(),
612        });
613    }
614    Ok(())
615}
616
617fn convert_account(
618    account: &arete_idl::IdlAccountArg,
619    pdas: &BTreeMap<String, PdaDefinitionV1>,
620) -> InstructionAccountV1 {
621    let resolution = if account.is_signer && account.address.is_none() && account.pda.is_none() {
622        AccountResolutionV1::Signer
623    } else if let Some(address) = &account.address {
624        AccountResolutionV1::Known {
625            address: address.clone(),
626        }
627    } else if let Some(pda) = &account.pda {
628        let name = sanitize_identifier(pda.name.as_deref().unwrap_or(&account.name));
629        let converted = convert_pda(&name, &pda.seeds, pda.program.as_ref());
630        if pdas.get(&name) == Some(&converted) {
631            AccountResolutionV1::PdaRef { pda_name: name }
632        } else {
633            AccountResolutionV1::PdaInline {
634                seeds: converted.seeds,
635                program_id: converted.program_id,
636                program: converted.program,
637            }
638        }
639    } else {
640        let name = sanitize_identifier(&account.name);
641        if pdas.contains_key(&name) {
642            AccountResolutionV1::PdaRef { pda_name: name }
643        } else {
644            AccountResolutionV1::UserProvided
645        }
646    };
647    InstructionAccountV1 {
648        name: sanitize_identifier(&account.name),
649        is_signer: account.is_signer,
650        is_writable: account.is_mut,
651        resolution,
652        is_optional: account.optional,
653        docs: account.docs.clone(),
654    }
655}
656
657fn convert_amount_hint(hint: &IdlAmountHint) -> InstructionAmountHintV1 {
658    InstructionAmountHintV1 {
659        decimals_source: match &hint.decimals_source {
660            IdlAmountDecimalsSource::ArgMint { arg_name } => AmountDecimalsSourceV1::ArgMint {
661                arg_name: arg_name.clone(),
662            },
663            IdlAmountDecimalsSource::ArgDecimals { arg_name } => {
664                AmountDecimalsSourceV1::ArgDecimals {
665                    arg_name: arg_name.clone(),
666                }
667            }
668            IdlAmountDecimalsSource::KnownAccount { account_name } => {
669                AmountDecimalsSourceV1::KnownAccount {
670                    account_name: account_name.clone(),
671                }
672            }
673            IdlAmountDecimalsSource::Constant { decimals } => AmountDecimalsSourceV1::Constant {
674                decimals: *decimals,
675            },
676        },
677    }
678}
679
680fn sanitize_identifier(name: &str) -> String {
681    let mut sanitized = String::new();
682    for character in name.chars() {
683        if character.is_ascii_alphanumeric() || character == '_' {
684            sanitized.push(character);
685        } else if !sanitized.ends_with('_') {
686            sanitized.push('_');
687        }
688    }
689    let sanitized = sanitized.trim_matches('_').to_string();
690    if sanitized.is_empty() {
691        return "value".to_string();
692    }
693    if sanitized
694        .chars()
695        .next()
696        .is_some_and(|character| character.is_ascii_digit())
697    {
698        return format!("_{sanitized}");
699    }
700    sanitized
701}
702
703fn sanitize_seed_path(path: &str) -> String {
704    path.split('.')
705        .map(sanitize_identifier)
706        .collect::<Vec<_>>()
707        .join(".")
708}
709
710fn idl_type_to_rust_string(idl_type: &IdlType) -> String {
711    match idl_type {
712        IdlType::Simple(simple) => match simple.as_str() {
713            "string" => "String".to_string(),
714            "publicKey" | "pubkey" => "solana_pubkey::Pubkey".to_string(),
715            "bytes" => "Vec<u8>".to_string(),
716            other => other.to_string(),
717        },
718        IdlType::Array(array) if array.array.len() == 2 => {
719            match (&array.array[0], &array.array[1]) {
720                (IdlTypeArrayElement::Type(name), IdlTypeArrayElement::Size(size)) => {
721                    format!(
722                        "[{}; {size}]",
723                        idl_type_to_rust_string(&IdlType::Simple(name.clone()))
724                    )
725                }
726                (IdlTypeArrayElement::Nested(ty), IdlTypeArrayElement::Size(size)) => {
727                    format!("[{}; {size}]", idl_type_to_rust_string(ty))
728                }
729                _ => "Vec<u8>".to_string(),
730            }
731        }
732        IdlType::Array(_) => "Vec<u8>".to_string(),
733        IdlType::Option(option) => format!("Option<{}>", idl_type_to_rust_string(&option.option)),
734        // `VecU64Len<T>` signals the bincode-style prefix to the SDK generators.
735        IdlType::Vec(vec_type) => {
736            let inner = idl_type_to_rust_string(&vec_type.vec);
737            if matches!(
738                vec_type.length_prefix,
739                Some(arete_idl::types::IdlLengthPrefix::U64)
740            ) {
741                format!("VecU64Len<{inner}>")
742            } else {
743                format!("Vec<{inner}>")
744            }
745        }
746        IdlType::HashMap(hash_map) => format!(
747            "std::collections::HashMap<{}, {}>",
748            idl_type_to_rust_string(&hash_map.hash_map.0),
749            idl_type_to_rust_string(&hash_map.hash_map.1)
750        ),
751        IdlType::Tuple(tuple) => format!(
752            "({})",
753            tuple
754                .tuple
755                .iter()
756                .map(idl_type_to_rust_string)
757                .collect::<Vec<_>>()
758                .join(", ")
759        ),
760        IdlType::Defined(defined) => match &defined.defined {
761            IdlTypeDefinedInner::Named { name } => name.clone(),
762            IdlTypeDefinedInner::Simple(simple) => simple.clone(),
763        },
764    }
765}
766
767#[cfg(test)]
768mod tests {
769    use super::*;
770
771    #[test]
772    fn preserves_account_selected_pda_programs() {
773        let definition = convert_pda(
774            "metadata",
775            &[arete_idl::IdlPdaSeed::Const {
776                value: b"metadata".to_vec(),
777            }],
778            Some(&arete_idl::IdlPdaProgram::Account {
779                kind: "account".to_string(),
780                path: "metadata_program".to_string(),
781            }),
782        );
783
784        assert_eq!(definition.program_id, None);
785        assert_eq!(
786            definition.program,
787            Some(PdaProgramV1::AccountRef {
788                account_name: "metadata_program".to_string(),
789            })
790        );
791    }
792
793    #[test]
794    fn preserves_nul_const_pda_seeds_as_bytes() {
795        let definition = convert_pda(
796            "binary",
797            &[
798                arete_idl::IdlPdaSeed::Const {
799                    value: b"text".to_vec(),
800                },
801                arete_idl::IdlPdaSeed::Const { value: vec![0, 0] },
802            ],
803            None,
804        );
805
806        assert_eq!(
807            definition.seeds,
808            vec![
809                PdaSeedV1::Literal {
810                    value: "text".to_string(),
811                },
812                PdaSeedV1::Bytes { value: vec![0, 0] },
813            ]
814        );
815    }
816
817    #[test]
818    fn rejects_nul_in_literal_pda_seeds() {
819        let source = br#"{
820            "address":"11111111111111111111111111111111",
821            "metadata":{"name":"demo","version":"0.1.0","spec":"0.1.0"},
822            "instructions":[
823                {"name":"create","discriminator":[1],"accounts":[
824                    {"name":"state","pda":{"seeds":[{"kind":"const","value":[115,116,97,116,101]}]}}
825                ],"args":[]}
826            ],
827            "accounts":[],"types":[],"events":[],"errors":[]
828        }"#;
829        let document = CanonicalIdlDocument::parse(source, None).unwrap();
830        let mut spec = ProgramSpecV1::from_document(&document);
831        spec.pdas.get_mut("state").unwrap().seeds[0] = PdaSeedV1::Literal {
832            value: "\0".to_string(),
833        };
834
835        assert!(matches!(
836            spec.validate(),
837            Err(HashError::InvalidProjection {
838                projection: "program spec",
839                ..
840            })
841        ));
842    }
843
844    #[test]
845    fn keeps_conflicting_account_pdas_inline_per_instruction() {
846        let source = br#"{
847            "address":"11111111111111111111111111111111",
848            "metadata":{"name":"demo","version":"0.1.0","spec":"0.1.0"},
849            "instructions":[
850                {"name":"create","discriminator":[1,0,0,0,0,0,0,0],"accounts":[
851                    {"name":"state","pda":{"seeds":[{"kind":"const","value":[99,114,101,97,116,101]}]}}
852                ],"args":[]},
853                {"name":"update","discriminator":[2,0,0,0,0,0,0,0],"accounts":[
854                    {"name":"state","pda":{"seeds":[{"kind":"const","value":[117,112,100,97,116,101]}]}}
855                ],"args":[]}
856            ],
857            "accounts":[],"types":[],"events":[],"errors":[]
858        }"#;
859        let document = CanonicalIdlDocument::parse(source, None).unwrap();
860        let spec = ProgramSpecV1::from_document(&document);
861
862        assert!(!spec.pdas.contains_key("state"));
863        for instruction in &spec.instructions {
864            assert!(matches!(
865                &instruction.accounts[0].resolution,
866                AccountResolutionV1::PdaInline { .. }
867            ));
868        }
869    }
870}