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 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
346/// Compatibility adapter for callers that no longer have the original bytes.
347///
348/// New ingress should always call `build_program_spec_v1_from_bytes` so
349/// `idl-content` is derived from the complete parsed source document.
350pub 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    /// Legacy/static PDA program. Retained so existing ProgramSpec hashes do
364    /// not change when the PDA program is a literal.
365    #[serde(default, skip_serializing_if = "Option::is_none")]
366    pub program_id: Option<String>,
367    /// Dynamic PDA program selector. This is emitted only when the owning
368    /// program must be resolved from another account or instruction argument.
369    #[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/// PDA definitions extracted from an IDL, kept apart by provenance.
484///
485/// Provenance, not account name, decides how an instruction account resolves:
486///
487/// - `explicit` definitions come from the top-level `idl.pdas` array. They are
488///   program-level, reusable declarations, so an account that declares no PDA
489///   of its own but shares an explicit definition's name resolves to it.
490/// - `discovered` definitions are instruction-account PDAs that have exactly
491///   one consistent definition across the program. They are published as
492///   named derivations for SDK consumers, but they never resolve a same-named
493///   account that declares no PDA: such a discovery is instruction-local, not
494///   a program-wide declaration.
495///
496/// Names that carry conflicting account-level definitions are omitted from
497/// both maps; each instruction keeps its own definition inline.
498#[derive(Debug, Default, Clone, PartialEq)]
499struct ExtractedPdas {
500    explicit: BTreeMap<String, PdaDefinitionV1>,
501    discovered: BTreeMap<String, PdaDefinitionV1>,
502}
503
504impl ExtractedPdas {
505    /// The published `ProgramSpec.pdas` map: explicit definitions first, then
506    /// the consistent discovered ones under names no explicit definition uses.
507    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    /// Whether `name` is an explicit, reusable program-level PDA declaration.
516    fn is_explicit(&self, name: &str) -> bool {
517        self.explicit.contains_key(name)
518    }
519
520    /// The definition a local PDA named `name` may reference instead of
521    /// inlining, if one is published under that name.
522    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                        // Account-level PDAs are instruction-local. Publishing one
554                        // arbitrary definition under a shared name makes other
555                        // instructions derive a plausible but incorrect address.
556                        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        // The account declares its own derivation. Reference the published
674        // definition only when it is identical; otherwise keep it inline.
675        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        // The account declares no derivation. Only an explicit top-level IDL
688        // PDA may claim it by name; a PDA discovered on another instruction's
689        // same-named account is instruction-local and must not leak here.
690        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        // `VecU64Len<T>` signals the bincode-style prefix to the SDK generators.
785        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    /// Matrix row: local PDA in one instruction, same-named plain account in
958    /// another. The plain account must stay user-provided; the discovered
959    /// definition is instruction-local, not a program-wide declaration.
960    #[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        // The consistent discovered derivation stays published as a named helper...
974        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        // ...but never claims an account that declares no PDA of its own.
980        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    /// Matrix row: an explicit top-level PDA is reusable by name from every
991    /// instruction, including accounts that declare no PDA.
992    #[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    /// Matrix row: a local PDA that differs from the same-named explicit PDA
1017    /// stays inline; the explicit definition is what the name means.
1018    #[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    /// Matrix row: no local PDA and no explicit PDA is user-provided, even
1051    /// when a nested/composite account elsewhere carries the same name.
1052    #[test]
1053    fn nested_local_pda_does_not_leak_to_same_named_plain_accounts() {
1054        // Composite accounts flatten to `<group><Child>`; the nested PDA is
1055        // published under that flattened name.
1056        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    /// Address Lookup Table declares `lookup_table` as a PDA on create only.
1080    #[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}