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.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
348/// Compatibility adapter for callers that no longer have the original bytes.
349///
350/// New ingress should always call `build_program_spec_v1_from_bytes` so
351/// `idl-content` is derived from the complete parsed source document.
352pub 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    /// Legacy/static PDA program. Retained so existing ProgramSpec hashes do
366    /// not change when the PDA program is a literal.
367    #[serde(default, skip_serializing_if = "Option::is_none")]
368    pub program_id: Option<String>,
369    /// Dynamic PDA program selector. This is emitted only when the owning
370    /// program must be resolved from another account or instruction argument.
371    #[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/// PDA definitions extracted from an IDL, kept apart by provenance.
486///
487/// Provenance, not account name, decides how an instruction account resolves:
488///
489/// - `explicit` definitions come from the top-level `idl.pdas` array. They are
490///   program-level, reusable declarations, so an account that declares no PDA
491///   of its own but shares an explicit definition's name resolves to it.
492/// - `discovered` definitions are instruction-account PDAs that have exactly
493///   one consistent definition across the program. They are published as
494///   named derivations for SDK consumers, but they never resolve a same-named
495///   account that declares no PDA: such a discovery is instruction-local, not
496///   a program-wide declaration.
497///
498/// Names that carry conflicting account-level definitions are omitted from
499/// both maps; each instruction keeps its own definition inline.
500#[derive(Debug, Default, Clone, PartialEq)]
501struct ExtractedPdas {
502    explicit: BTreeMap<String, PdaDefinitionV1>,
503    discovered: BTreeMap<String, PdaDefinitionV1>,
504}
505
506impl ExtractedPdas {
507    /// The published `ProgramSpec.pdas` map: explicit definitions first, then
508    /// the consistent discovered ones under names no explicit definition uses.
509    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    /// Whether `name` is an explicit, reusable program-level PDA declaration.
518    fn is_explicit(&self, name: &str) -> bool {
519        self.explicit.contains_key(name)
520    }
521
522    /// The definition a local PDA named `name` may reference instead of
523    /// inlining, if one is published under that name.
524    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                        // Account-level PDAs are instruction-local. Publishing one
556                        // arbitrary definition under a shared name makes other
557                        // instructions derive a plausible but incorrect address.
558                        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        // The account declares its own derivation. Reference the published
676        // definition only when it is identical; otherwise keep it inline.
677        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        // The account declares no derivation. Only an explicit top-level IDL
690        // PDA may claim it by name; a PDA discovered on another instruction's
691        // same-named account is instruction-local and must not leak here.
692        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        // `VecU64Len<T>` signals the bincode-style prefix to the SDK generators.
787        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        // `StringU64Len` is the bincode-style string, as `VecU64Len<T>` is for sequences.
799        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    /// Pinned in `typescript/hash/src/index.test.ts` ("u64-prefixed string"): both
827    /// implementations must hash this IDL identically.
828    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        // Without the prefix the arg is an ordinary string, and the hash differs.
847        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    /// Matrix row: local PDA in one instruction, same-named plain account in
991    /// another. The plain account must stay user-provided; the discovered
992    /// definition is instruction-local, not a program-wide declaration.
993    #[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        // The consistent discovered derivation stays published as a named helper...
1007        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        // ...but never claims an account that declares no PDA of its own.
1013        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    /// Matrix row: an explicit top-level PDA is reusable by name from every
1024    /// instruction, including accounts that declare no PDA.
1025    #[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    /// Matrix row: a local PDA that differs from the same-named explicit PDA
1050    /// stays inline; the explicit definition is what the name means.
1051    #[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    /// Matrix row: no local PDA and no explicit PDA is user-provided, even
1084    /// when a nested/composite account elsewhere carries the same name.
1085    #[test]
1086    fn nested_local_pda_does_not_leak_to_same_named_plain_accounts() {
1087        // Composite accounts flatten to `<group><Child>`; the nested PDA is
1088        // published under that flattened name.
1089        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    /// Address Lookup Table declares `lookup_table` as a PDA on create only.
1113    #[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}