diff --git a/Cargo.lock b/Cargo.lock index 1e218a85..ee88fa99 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -219,6 +219,7 @@ dependencies = [ name = "arete-idl" version = "0.0.2" dependencies = [ + "bs58", "serde", "serde_json", "sha2", diff --git a/arete-idl/Cargo.toml b/arete-idl/Cargo.toml index 0ecc26dd..9d49c876 100644 --- a/arete-idl/Cargo.toml +++ b/arete-idl/Cargo.toml @@ -12,6 +12,7 @@ keywords = ["arete", "idl", "types", "parsing"] categories = ["development-tools"] [dependencies] +bs58 = "0.5" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" sha2 = "0.10" diff --git a/arete-idl/src/parse.rs b/arete-idl/src/parse.rs index 1a69f5c4..4b371267 100644 --- a/arete-idl/src/parse.rs +++ b/arete-idl/src/parse.rs @@ -17,13 +17,14 @@ pub fn parse_idl_content(content: &str) -> Result { match serde_json::from_str(content) { Ok(idl) => Ok(idl), Err(parse_error) => { - let codama_root: CodamaRoot = serde_json::from_str(content).map_err(|codama_error| { - format!( - "Failed to parse IDL JSON as neither IdlSpec nor Codama root. \ + let codama_root: CodamaRoot = + serde_json::from_str(content).map_err(|codama_error| { + format!( + "Failed to parse IDL JSON as neither IdlSpec nor Codama root. \ IdlSpec error: {}. Codama error: {}", - parse_error, codama_error - ) - })?; + parse_error, codama_error + ) + })?; codama_root_to_idl_spec(&codama_root) } } @@ -46,6 +47,15 @@ fn codama_root_to_idl_spec(root: &CodamaRoot) -> Result { .transpose()? .unwrap_or_default(); + // Index the program's top-level PDA definitions by name so instruction + // accounts referencing them via `pdaLinkNode` can inline their seeds. + let pda_defs: HashMap<&str, &CodamaPdaNode> = root + .program + .pdas + .iter() + .map(|pda| (pda.name.as_str(), pda)) + .collect(); + Ok(IdlSpec { version: root.program.version.clone(), name: Some(root.program.name.clone()), @@ -54,7 +64,7 @@ fn codama_root_to_idl_spec(root: &CodamaRoot) -> Result { .program .instructions .iter() - .map(codama_instruction_to_idl) + .map(|instruction| codama_instruction_to_idl(instruction, &pda_defs)) .collect::, _>>()?, accounts: root .program @@ -155,7 +165,10 @@ fn codama_defined_type_to_idl(defined_type: &CodamaDefinedType) -> Result Result { +fn codama_instruction_to_idl( + instruction: &CodamaInstruction, + pda_defs: &HashMap<&str, &CodamaPdaNode>, +) -> Result { let discriminant = instruction .arguments .iter() @@ -163,7 +176,13 @@ fn codama_instruction_to_idl(instruction: &CodamaInstruction) -> Result Ok(SteelDiscriminant { type_: argument.number_format().unwrap_or("u8").to_string(), - value: *number, + value: u64::try_from(*number).map_err(|_| { + format!( + "Codama instruction '{}' has a negative discriminator value {}; \ + discriminants must be non-negative", + instruction.name, number + ) + })?, }), _ => Err(format!( "Codama instruction '{}' has a 'discriminator' argument without a numeric \ @@ -184,11 +203,25 @@ fn codama_instruction_to_idl(instruction: &CodamaInstruction) -> Result Result, _>>()?, }), CodamaTypeNode::Enum { variants } => { - if let Some(variant) = variants.iter().find(|variant| !variant.fields.is_empty()) { + // Borsh encodes the variant index, so explicit discriminators are + // only representable when they match the positional index. + if let Some((index, variant)) = variants + .iter() + .enumerate() + .find(|(i, v)| v.discriminator.is_some_and(|d| d != *i as u64)) + { return Err(format!( - "Codama enum variant '{}' carries fields, which are not supported by arete-idl enums", - variant.name + "Codama enum variant '{}' has explicit discriminator {:?} != index {}, which Borsh enums cannot represent", + variant.name, variant.discriminator, index )); } @@ -221,10 +260,8 @@ fn codama_type_def_kind_to_idl(type_node: &CodamaTypeNode) -> Result, _>>()?, }) } other => Err(format!( @@ -241,6 +278,55 @@ fn codama_field_to_idl(field: &CodamaFieldNode) -> Result { }) } +/// Convert a Codama enum variant (empty, struct, or tuple) to an arete-idl +/// variant. Codama struct variants nest their fields under a `struct` type +/// node; tuple variants under `tuple.items`; the simplified flattened shape +/// puts fields directly on the variant. +fn codama_enum_variant_to_idl(variant: &CodamaEnumVariant) -> Result { + let mut fields: Vec = Vec::new(); + + if !variant.fields.is_empty() { + for field in &variant.fields { + fields.push(IdlEnumVariantField::Named(codama_field_to_idl(field)?)); + } + } else if let Some(struct_node) = &variant.struct_ { + match struct_node.as_ref() { + CodamaTypeNode::Struct { + fields: struct_fields, + } => { + for field in struct_fields { + fields.push(IdlEnumVariantField::Named(codama_field_to_idl(field)?)); + } + } + other => { + return Err(format!( + "Codama enum variant '{}' struct payload is not a structTypeNode: {:?}", + variant.name, other + )) + } + } + } else if let Some(tuple_node) = &variant.tuple { + match tuple_node.as_ref() { + CodamaTypeNode::Tuple { items } => { + for item in items { + fields.push(IdlEnumVariantField::Tuple(codama_type_to_idl(item)?)); + } + } + other => { + return Err(format!( + "Codama enum variant '{}' tuple payload is not a tupleTypeNode: {:?}", + variant.name, other + )) + } + } + } + + Ok(IdlEnumVariant { + name: variant.name.clone(), + fields, + }) +} + fn codama_type_to_idl(type_node: &CodamaTypeNode) -> Result { match type_node { CodamaTypeNode::Number { format } => Ok(IdlType::Simple(format.clone())), @@ -278,9 +364,9 @@ fn codama_type_to_idl(type_node: &CodamaTypeNode) -> Result { ], })) } - CodamaTypeNode::Unknown => Err( - "unsupported Codama type node kind (not modelled by arete-idl)".to_string(), - ), + CodamaTypeNode::Unknown => { + Err("unsupported Codama type node kind (not modelled by arete-idl)".to_string()) + } other => Err(format!("unsupported Codama field type {:?}", other)), } } @@ -306,6 +392,8 @@ struct CodamaProgram { errors: Vec, #[serde(default)] instructions: Vec, + #[serde(default)] + pdas: Vec, } #[derive(Debug, Deserialize)] @@ -336,10 +424,12 @@ struct CodamaInstruction { struct CodamaInstructionAccount { name: String, #[serde(default)] - is_signer: bool, + is_signer: CodamaSignerFlag, #[serde(default)] is_writable: bool, #[serde(default)] + is_optional: bool, + #[serde(default)] docs: Vec, #[serde(default)] default_value: Option, @@ -354,6 +444,43 @@ impl CodamaInstructionAccount { } } +/// Codama's `isSigner` is `true`, `false`, or the string `"either"` (the +/// account may or may not sign depending on context). The runtime has no +/// maybe-signer concept, so `"either"` maps to non-signer and the fact is +/// surfaced via the account docs. +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum CodamaSignerFlag { + Bool(bool), + Tag(String), +} + +impl Default for CodamaSignerFlag { + fn default() -> Self { + CodamaSignerFlag::Bool(false) + } +} + +impl CodamaSignerFlag { + fn as_bool(&self) -> bool { + matches!(self, CodamaSignerFlag::Bool(true)) + } + + fn is_either(&self) -> bool { + matches!(self, CodamaSignerFlag::Tag(tag) if tag == "either") + } + + /// Returns the tag string for an unrecognised signer flag — any tag other + /// than `"either"` — so callers can surface it rather than silently + /// treating the account as a non-signer. + fn unknown_tag(&self) -> Option<&str> { + match self { + CodamaSignerFlag::Tag(tag) if tag != "either" => Some(tag.as_str()), + _ => None, + } + } +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct CodamaFieldNode { @@ -399,6 +526,8 @@ enum CodamaTypeNode { Struct { fields: Vec }, #[serde(rename = "enumTypeNode")] Enum { variants: Vec }, + #[serde(rename = "tupleTypeNode")] + Tuple { items: Vec }, #[serde(other)] Unknown, } @@ -417,24 +546,306 @@ struct CodamaEnumVariant { name: String, #[serde(default)] discriminator: Option, + /// Simplified/flattened variant shape: fields directly on the variant. #[serde(default)] fields: Vec, + /// `enumStructVariantTypeNode`: payload nested under a structTypeNode. + #[serde(default, rename = "struct")] + struct_: Option>, + /// `enumTupleVariantTypeNode`: payload nested under a tupleTypeNode. + #[serde(default)] + tuple: Option>, } #[derive(Debug, Deserialize)] #[serde(tag = "kind")] enum CodamaValueNode { #[serde(rename = "numberValueNode")] - Number { number: u64 }, + Number { number: i64 }, #[serde(rename = "publicKeyValueNode")] PublicKey { #[serde(rename = "publicKey")] public_key: String, }, + #[serde(rename = "stringValueNode")] + StringValue { string: String }, + #[serde(rename = "bytesValueNode")] + BytesValue { + data: String, + #[serde(default)] + encoding: Option, + }, + /// PDA-derived address: references a PDA definition and binds its variable + /// seeds to instruction accounts/arguments. + #[serde(rename = "pdaValueNode")] + Pda { + pda: CodamaPdaSource, + #[serde(default)] + seeds: Vec, + }, + /// Reference to another instruction account by name (a seed binding). + #[serde(rename = "accountValueNode")] + Account { name: String }, + /// Reference to an instruction argument by name (a seed binding). + #[serde(rename = "argumentValueNode")] + Argument { name: String }, #[serde(other)] Other, } +/// A program-level Codama PDA definition (`pdaNode`). +#[derive(Debug, Deserialize)] +struct CodamaPdaNode { + #[allow(dead_code)] + name: String, + #[serde(default)] + seeds: Vec, + /// Owning program (base58), when the PDA belongs to another program + /// (e.g. associated token accounts). Defaults to the instruction's program. + #[serde(rename = "programId", default)] + program_id: Option, +} + +/// The `pda` field of a `pdaValueNode`: either a link to a top-level PDA +/// definition or an inline one. +#[derive(Debug, Deserialize)] +#[serde(tag = "kind")] +enum CodamaPdaSource { + #[serde(rename = "pdaLinkNode")] + Link { name: String }, + #[serde(rename = "pdaNode")] + Inline { + #[allow(dead_code)] + name: String, + #[serde(default)] + seeds: Vec, + #[serde(rename = "programId", default)] + program_id: Option, + }, +} + +/// A single seed in a `pdaNode` definition. +#[derive(Debug, Deserialize)] +#[serde(tag = "kind")] +enum CodamaPdaSeedNode { + #[serde(rename = "constantPdaSeedNode")] + Constant { + #[serde(rename = "type", default)] + seed_type: Option, + value: CodamaValueNode, + }, + #[serde(rename = "variablePdaSeedNode")] + Variable { + name: String, + #[serde(rename = "type", default)] + seed_type: Option, + }, +} + +/// A seed-value binding in a `pdaValueNode` (`pdaSeedValueNode`). +#[derive(Debug, Deserialize)] +struct CodamaPdaSeedValue { + name: String, + value: CodamaValueNode, +} + +/// Build an `IdlPda` for an instruction account whose default value is a PDA. +/// +/// Returns `None` (degrading to user-provided) when the PDA references an +/// unknown definition or uses seed kinds the runtime cannot represent. +fn codama_account_pda( + account: &CodamaInstructionAccount, + pda_defs: &HashMap<&str, &CodamaPdaNode>, +) -> Option { + let CodamaValueNode::Pda { pda, seeds } = account.default_value.as_ref()? else { + tracing::debug!( + account = %account.name, + "account has no PDA default value; degrading to user-provided" + ); + return None; + }; + + let (pda_seeds, pda_program_id): (&[CodamaPdaSeedNode], Option<&str>) = match pda { + CodamaPdaSource::Inline { + seeds, program_id, .. + } => (seeds.as_slice(), program_id.as_deref()), + CodamaPdaSource::Link { name } => { + let def = pda_defs.get(name.as_str()).or_else(|| { + tracing::warn!( + account = %account.name, + pda = %name, + "PDA link references an unknown pdaNode; degrading to user-provided" + ); + None + })?; + (def.seeds.as_slice(), def.program_id.as_deref()) + } + }; + + // Map variable seed names to their bound values for this account. + let bindings: HashMap<&str, &CodamaValueNode> = seeds + .iter() + .map(|seed| (seed.name.as_str(), &seed.value)) + .collect(); + + let mut idl_seeds = Vec::with_capacity(pda_seeds.len()); + for seed in pda_seeds { + match seed { + CodamaPdaSeedNode::Constant { seed_type, value } => { + let bytes = codama_constant_seed_bytes(value, seed_type.as_ref()).or_else(|| { + tracing::warn!( + account = %account.name, + "failed to encode constant PDA seed; degrading to user-provided" + ); + None + })?; + idl_seeds.push(IdlPdaSeed::Const { value: bytes }); + } + CodamaPdaSeedNode::Variable { name, seed_type } => { + let value = bindings.get(name.as_str()).or_else(|| { + tracing::warn!( + account = %account.name, + seed = %name, + "variable PDA seed has no bound value; degrading to user-provided" + ); + None + })?; + match value { + CodamaValueNode::Account { name } => idl_seeds.push(IdlPdaSeed::Account { + path: name.clone(), + account: None, + }), + CodamaValueNode::Argument { name } => idl_seeds.push(IdlPdaSeed::Arg { + path: name.clone(), + arg_type: seed_type.as_ref().and_then(codama_seed_type_name), + }), + // Constant bindings (rare) or unsupported value kinds. The + // variable seed's declared type drives the encoding width. + value @ (CodamaValueNode::StringValue { .. } + | CodamaValueNode::BytesValue { .. } + | CodamaValueNode::Number { .. } + | CodamaValueNode::PublicKey { .. }) => { + let bytes = + codama_constant_seed_bytes(value, seed_type.as_ref()).or_else(|| { + tracing::warn!( + account = %account.name, + seed = %name, + "failed to encode bound PDA seed value; degrading to user-provided" + ); + None + })?; + idl_seeds.push(IdlPdaSeed::Const { value: bytes }); + } + _ => { + tracing::warn!( + account = %account.name, + seed = %name, + "unsupported PDA seed value kind; degrading to user-provided" + ); + return None; + } + } + } + } + } + + Some(IdlPda { + seeds: idl_seeds, + program: pda_program_id.map(|program_id| IdlPdaProgram::Literal { + kind: "programId".to_string(), + value: program_id.to_string(), + }), + }) +} + +/// Map a Codama seed type node to the simple type name carried by +/// `IdlPdaSeed::Arg` (e.g. `u64`, `publicKey`, `string`). +fn codama_seed_type_name(type_node: &CodamaTypeNode) -> Option { + match type_node { + CodamaTypeNode::Number { format } => Some(format.clone()), + CodamaTypeNode::PublicKey {} => Some("publicKey".to_string()), + CodamaTypeNode::String {} => Some("string".to_string()), + _ => None, + } +} + +/// Decode a Codama constant seed value into raw bytes. +/// +/// Numbers are encoded little-endian at the width declared by the seed's type +/// node; an unknown or missing numeric type degrades (returns `None`) rather +/// than guessing a width and deriving a wrong address. +fn codama_constant_seed_bytes( + value: &CodamaValueNode, + seed_type: Option<&CodamaTypeNode>, +) -> Option> { + match value { + CodamaValueNode::StringValue { string } => Some(string.as_bytes().to_vec()), + CodamaValueNode::Number { number } => { + let Some(CodamaTypeNode::Number { format }) = seed_type else { + return None; + }; + let (width, signed) = match format.as_str() { + "u8" => (1usize, false), + "i8" => (1, true), + "u16" => (2, false), + "i16" => (2, true), + "u32" => (4, false), + "i32" => (4, true), + "u64" => (8, false), + "i64" => (8, true), + "u128" => (16, false), + "i128" => (16, true), + _ => return None, + }; + let bytes = number.to_le_bytes(); + let sign_byte: u8 = if signed && *number < 0 { 0xFF } else { 0x00 }; + if width <= bytes.len() { + // Reject values that do not fit the declared width. For signed + // widths the discarded high bytes must be a sign extension of + // the kept top byte, whose sign bit must agree with `number`; + // for unsigned widths they must simply be zero. + if signed { + let top = bytes[width - 1]; + if ((top & 0x80) == 0) != (*number >= 0) { + return None; + } + } + if bytes[width..].iter().any(|b| *b != sign_byte) { + return None; + } + Some(bytes[..width].to_vec()) + } else { + // Widen (e.g. an i64 literal into an i128 slot): sign- or + // zero-extend rather than pad with zeros unconditionally. + let mut out = bytes.to_vec(); + out.resize(width, sign_byte); + Some(out) + } + } + CodamaValueNode::PublicKey { public_key } => { + let decoded = bs58::decode(public_key).into_vec().ok()?; + (decoded.len() == 32).then_some(decoded) + } + CodamaValueNode::BytesValue { data, encoding } => match encoding.as_deref() { + Some("base16") | Some("hex") => decode_hex(data), + Some("utf8") | None => Some(data.as_bytes().to_vec()), + // base64/base58 seeds are uncommon; degrade rather than guess. + _ => None, + }, + _ => None, + } +} + +fn decode_hex(s: &str) -> Option> { + if !s.len().is_multiple_of(2) { + return None; + } + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok()) + .collect() +} + #[derive(Debug, Deserialize)] struct CodamaError { code: u32, @@ -462,6 +873,300 @@ mod tests { assert_eq!(disc, &Sha256::digest(b"account:LendingMarket")[..8]); } + #[test] + fn test_codama_pda_link_node_resolves_seeds() { + let json = r#"{ + "kind": "rootNode", + "program": { + "kind": "programNode", + "name": "subscriptions", + "publicKey": "De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44", + "version": "0.1.0", + "pdas": [ + { + "kind": "pdaNode", + "name": "subscriptionAuthority", + "seeds": [ + { "kind": "constantPdaSeedNode", "type": { "kind": "stringTypeNode", "encoding": "utf8" }, "value": { "kind": "stringValueNode", "string": "SubscriptionAuthority" } }, + { "kind": "variablePdaSeedNode", "name": "user", "type": { "kind": "publicKeyTypeNode" } }, + { "kind": "variablePdaSeedNode", "name": "tokenMint", "type": { "kind": "publicKeyTypeNode" } } + ] + } + ], + "instructions": [ + { + "kind": "instructionNode", + "name": "initSubscriptionAuthority", + "accounts": [ + { "kind": "instructionAccountNode", "name": "owner", "isSigner": true, "isWritable": true }, + { + "kind": "instructionAccountNode", + "name": "subscriptionAuthority", + "isSigner": false, + "isWritable": true, + "defaultValue": { + "kind": "pdaValueNode", + "pda": { "kind": "pdaLinkNode", "name": "subscriptionAuthority" }, + "seeds": [ + { "kind": "pdaSeedValueNode", "name": "user", "value": { "kind": "accountValueNode", "name": "owner" } }, + { "kind": "pdaSeedValueNode", "name": "tokenMint", "value": { "kind": "accountValueNode", "name": "tokenMint" } } + ] + } + }, + { "kind": "instructionAccountNode", "name": "tokenMint", "isSigner": false, "isWritable": false } + ], + "arguments": [ + { "kind": "instructionArgumentNode", "name": "discriminator", "type": { "kind": "numberTypeNode", "format": "u8" }, "defaultValue": { "kind": "numberValueNode", "number": 0 } } + ] + } + ] + } + }"#; + + let spec = parse_idl_content(json).expect("codama idl should parse"); + let ix = &spec.instructions[0]; + let pda_account = ix + .accounts + .iter() + .find(|a| a.name == "subscriptionAuthority") + .expect("subscriptionAuthority account present"); + + let pda = pda_account.pda.as_ref().expect("PDA should be resolved"); + assert_eq!(pda.seeds.len(), 3); + match &pda.seeds[0] { + IdlPdaSeed::Const { value } => assert_eq!(value, b"SubscriptionAuthority"), + other => panic!("expected const seed, got {:?}", other), + } + match &pda.seeds[1] { + IdlPdaSeed::Account { path, .. } => assert_eq!(path, "owner"), + other => panic!("expected account seed bound to owner, got {:?}", other), + } + match &pda.seeds[2] { + IdlPdaSeed::Account { path, .. } => assert_eq!(path, "tokenMint"), + other => panic!("expected account seed bound to tokenMint, got {:?}", other), + } + + // Non-PDA accounts stay unresolved. + let owner = ix.accounts.iter().find(|a| a.name == "owner").unwrap(); + assert!(owner.pda.is_none()); + } + + #[test] + fn test_codama_fielded_enums_parse() { + let json = r#"{ + "kind": "rootNode", + "program": { + "kind": "programNode", + "name": "demo", + "publicKey": "De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44", + "version": "0.1.0", + "definedTypes": [ + { + "kind": "definedTypeNode", + "name": "op", + "type": { + "kind": "enumTypeNode", + "variants": [ + { "kind": "enumEmptyVariantTypeNode", "name": "noop" }, + { + "kind": "enumStructVariantTypeNode", + "name": "transfer", + "struct": { + "kind": "structTypeNode", + "fields": [ + { "kind": "structFieldTypeNode", "name": "amount", "type": { "kind": "numberTypeNode", "format": "u64" } } + ] + } + }, + { + "kind": "enumTupleVariantTypeNode", + "name": "pair", + "tuple": { + "kind": "tupleTypeNode", + "items": [ + { "kind": "numberTypeNode", "format": "u8" }, + { "kind": "numberTypeNode", "format": "u16" } + ] + } + } + ] + } + } + ], + "instructions": [] + } + }"#; + + let spec = parse_idl_content(json).expect("codama idl with fielded enum should parse"); + let op = spec.types.iter().find(|t| t.name == "op").expect("op type"); + let IdlTypeDefKind::Enum { variants, .. } = &op.type_def else { + panic!("expected enum, got {:?}", op.type_def); + }; + assert_eq!(variants.len(), 3); + assert!(variants[0].fields.is_empty()); + match &variants[1].fields[..] { + [IdlEnumVariantField::Named(field)] => assert_eq!(field.name, "amount"), + other => panic!("expected one named field, got {:?}", other), + } + match &variants[2].fields[..] { + [IdlEnumVariantField::Tuple(_), IdlEnumVariantField::Tuple(_)] => {} + other => panic!("expected two tuple fields, got {:?}", other), + } + } + + #[test] + fn test_codama_is_signer_either_tolerated() { + let json = r#"{ + "kind": "rootNode", + "program": { + "kind": "programNode", + "name": "demo", + "publicKey": "De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44", + "version": "0.1.0", + "instructions": [ + { + "kind": "instructionNode", + "name": "doThing", + "accounts": [ + { "kind": "instructionAccountNode", "name": "payer", "isSigner": true, "isWritable": true }, + { "kind": "instructionAccountNode", "name": "authority", "isSigner": "either", "isWritable": false } + ], + "arguments": [] + } + ] + } + }"#; + + let spec = parse_idl_content(json).expect("idl with isSigner either should parse"); + let ix = &spec.instructions[0]; + let payer = ix.accounts.iter().find(|a| a.name == "payer").unwrap(); + assert!(payer.is_signer); + let authority = ix.accounts.iter().find(|a| a.name == "authority").unwrap(); + assert!(!authority.is_signer, "either maps to non-signer"); + assert!( + authority.docs.iter().any(|d| d.contains("either")), + "either is surfaced in docs" + ); + } + + #[test] + fn test_codama_typed_seeds_program_id_and_optional_accounts() { + let json = r#"{ + "kind": "rootNode", + "program": { + "kind": "programNode", + "name": "vaults", + "publicKey": "De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44", + "version": "0.1.0", + "instructions": [ + { + "kind": "instructionNode", + "name": "deposit", + "accounts": [ + { "kind": "instructionAccountNode", "name": "owner", "isSigner": true, "isWritable": true }, + { "kind": "instructionAccountNode", "name": "referrer", "isSigner": false, "isWritable": false, "isOptional": true }, + { + "kind": "instructionAccountNode", + "name": "vault", + "isSigner": false, + "isWritable": true, + "defaultValue": { + "kind": "pdaValueNode", + "pda": { + "kind": "pdaNode", + "name": "vault", + "programId": "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL", + "seeds": [ + { "kind": "constantPdaSeedNode", "type": { "kind": "numberTypeNode", "format": "u8" }, "value": { "kind": "numberValueNode", "number": 7 } }, + { "kind": "constantPdaSeedNode", "type": { "kind": "publicKeyTypeNode" }, "value": { "kind": "publicKeyValueNode", "publicKey": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" } }, + { "kind": "variablePdaSeedNode", "name": "amount", "type": { "kind": "numberTypeNode", "format": "u64" } } + ] + }, + "seeds": [ + { "kind": "pdaSeedValueNode", "name": "amount", "value": { "kind": "argumentValueNode", "name": "amount" } } + ] + } + } + ], + "arguments": [ + { "kind": "instructionArgumentNode", "name": "discriminator", "type": { "kind": "numberTypeNode", "format": "u8" }, "defaultValue": { "kind": "numberValueNode", "number": 0 } }, + { "kind": "instructionArgumentNode", "name": "amount", "type": { "kind": "numberTypeNode", "format": "u64" } } + ] + } + ] + } + }"#; + + let spec = parse_idl_content(json).expect("codama idl should parse"); + let ix = &spec.instructions[0]; + + let referrer = ix.accounts.iter().find(|a| a.name == "referrer").unwrap(); + assert!(referrer.optional, "isOptional should be parsed"); + + let vault = ix.accounts.iter().find(|a| a.name == "vault").unwrap(); + let pda = vault.pda.as_ref().expect("vault PDA should be resolved"); + + // u8 constant seed encodes at its declared width, not 8 bytes. + match &pda.seeds[0] { + IdlPdaSeed::Const { value } => assert_eq!(value, &vec![7u8]), + other => panic!("expected 1-byte const seed, got {:?}", other), + } + // Pubkey constant seeds decode to their 32 raw bytes. + match &pda.seeds[1] { + IdlPdaSeed::Const { value } => assert_eq!(value.len(), 32), + other => panic!("expected 32-byte pubkey seed, got {:?}", other), + } + // Argument-bound seeds carry the declared type for width-aware encoding. + match &pda.seeds[2] { + IdlPdaSeed::Arg { path, arg_type } => { + assert_eq!(path, "amount"); + assert_eq!(arg_type.as_deref(), Some("u64")); + } + other => panic!("expected arg seed, got {:?}", other), + } + // Cross-program PDAs keep their owning program. + match pda.program.as_ref().expect("program id should be kept") { + IdlPdaProgram::Literal { value, .. } => { + assert_eq!(value, "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL") + } + other => panic!("expected literal program id, got {:?}", other), + } + } + + #[test] + fn test_real_subscriptions_idl_resolves_pdas() { + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../stacks/subscriptions/idl/subscriptions.json" + ); + let json = match std::fs::read_to_string(path) { + Ok(c) => c, + // The stack IDL is not part of this crate; skip if unavailable. + Err(_) => return, + }; + + let spec = parse_idl_content(&json).expect("subscriptions idl should parse"); + let ix = spec + .instructions + .iter() + .find(|i| i.name == "initSubscriptionAuthority") + .expect("initSubscriptionAuthority instruction present"); + let pda_account = ix + .accounts + .iter() + .find(|a| a.name == "subscriptionAuthority") + .expect("subscriptionAuthority account present"); + let pda = pda_account + .pda + .as_ref() + .expect("Codama defaultValue PDA should be resolved from the real IDL"); + assert!( + pda.seeds.len() >= 2, + "expected resolved seeds, got {:?}", + pda.seeds + ); + } + #[test] fn test_legacy_idl_parses_without_discriminator() { let json = r#"{ diff --git a/arete-idl/src/snapshot.rs b/arete-idl/src/snapshot.rs index d40c7b46..a25020b0 100644 --- a/arete-idl/src/snapshot.rs +++ b/arete-idl/src/snapshot.rs @@ -212,7 +212,10 @@ impl IdlInstructionSnapshot { } } - crate::discriminator::anchor_discriminator(&format!("global:{}", self.name)) + crate::discriminator::anchor_discriminator(&format!( + "global:{}", + crate::utils::to_snake_case(&self.name) + )) } } @@ -348,6 +351,19 @@ pub enum IdlTypeDefKindSnapshot { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct IdlEnumVariantSnapshot { pub name: String, + /// Variant payload, when the variant carries data. Skipped when empty so + /// fieldless variants serialize byte-identically to older snapshots. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub fields: Vec, +} + +/// One field of a data-carrying enum variant (named struct field or bare +/// tuple element). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum IdlEnumVariantFieldSnapshot { + Named(IdlFieldSnapshot), + Tuple(IdlTypeSnapshot), } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -370,6 +386,59 @@ pub struct IdlErrorSnapshot { mod tests { use super::*; + #[test] + fn test_fieldless_enum_variant_wire_format_unchanged() { + // Fieldless variants must serialize WITHOUT a `fields` key so existing + // stack.json files (and the CI regenerate-diff) are unaffected by the + // fielded-enum extension. + let fieldless = IdlEnumVariantSnapshot { + name: "Active".to_string(), + fields: vec![], + }; + assert_eq!( + serde_json::to_string(&fieldless).unwrap(), + r#"{"name":"Active"}"# + ); + + // Old-format JSON (no `fields`) still deserializes. + let parsed: IdlEnumVariantSnapshot = serde_json::from_str(r#"{"name":"Active"}"#).unwrap(); + assert!(parsed.fields.is_empty()); + + // Fielded variants round-trip through both named and tuple shapes, + // and the untagged IdlTypeDefKindSnapshot still classifies as Enum. + let fielded = IdlTypeDefKindSnapshot::Enum { + kind: "enum".to_string(), + variants: vec![IdlEnumVariantSnapshot { + name: "Sunset".to_string(), + fields: vec![ + IdlEnumVariantFieldSnapshot::Named(IdlFieldSnapshot { + name: "endTs".to_string(), + type_: IdlTypeSnapshot::Simple("i64".to_string()), + }), + IdlEnumVariantFieldSnapshot::Tuple(IdlTypeSnapshot::Simple( + "u8".to_string(), + )), + ], + }], + }; + let json = serde_json::to_string(&fielded).unwrap(); + let parsed: IdlTypeDefKindSnapshot = serde_json::from_str(&json).unwrap(); + match parsed { + IdlTypeDefKindSnapshot::Enum { variants, .. } => { + assert_eq!(variants[0].fields.len(), 2); + assert!(matches!( + variants[0].fields[0], + IdlEnumVariantFieldSnapshot::Named(_) + )); + assert!(matches!( + variants[0].fields[1], + IdlEnumVariantFieldSnapshot::Tuple(_) + )); + } + other => panic!("untagged round-trip misclassified enum as {:?}", other), + } + } + #[test] fn test_snapshot_serde() { let snapshot = IdlSnapshot { diff --git a/arete-idl/src/types.rs b/arete-idl/src/types.rs index 870b74a3..e66b5840 100644 --- a/arete-idl/src/types.rs +++ b/arete-idl/src/types.rs @@ -81,7 +81,10 @@ impl IdlInstruction { return vec![value]; } - crate::discriminator::anchor_discriminator(&format!("global:{}", self.name)) + crate::discriminator::anchor_discriminator(&format!( + "global:{}", + to_snake_case(&self.name) + )) } } @@ -284,6 +287,20 @@ pub enum IdlTypeDefKind { #[derive(Debug, Clone, Deserialize, Serialize)] pub struct IdlEnumVariant { pub name: String, + /// Variant payload, when the variant carries data. Empty for fieldless + /// variants — and skipped during serialization so their wire format is + /// unchanged from before this field existed. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub fields: Vec, +} + +/// One field of a data-carrying enum variant. Anchor IDLs encode struct +/// variants as `{name, type}` objects and tuple variants as bare types. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(untagged)] +pub enum IdlEnumVariantField { + Named(IdlField), + Tuple(IdlType), } #[derive(Debug, Clone, Deserialize, Serialize)] diff --git a/arete-macros/src/ast/types.rs b/arete-macros/src/ast/types.rs index ae29dc96..29c55365 100644 --- a/arete-macros/src/ast/types.rs +++ b/arete-macros/src/ast/types.rs @@ -310,7 +310,7 @@ pub enum UnaryOp { /// circular dependency between proc-macro crates and their output crates. /// When bumping this version, you MUST also update the constant in the /// interpreter crate. A test in versioned.rs verifies they stay in sync. -pub const CURRENT_AST_VERSION: &str = "0.0.1"; +pub const CURRENT_AST_VERSION: &str = "0.0.2"; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SerializableStreamSpec { diff --git a/arete-macros/src/ast/versioned.rs b/arete-macros/src/ast/versioned.rs index e6548756..11171341 100644 --- a/arete-macros/src/ast/versioned.rs +++ b/arete-macros/src/ast/versioned.rs @@ -25,6 +25,10 @@ use std::fmt; use super::types::{SerializableStackSpec, SerializableStreamSpec, CURRENT_AST_VERSION}; +/// Older versions this build can still deserialize directly (all changes +/// since were additive with serde defaults). +const COMPATIBLE_AST_VERSIONS: &[&str] = &["0.0.1"]; + /// Error type for versioned AST loading failures. // Not yet used within this crate, but part of public API for future use #[allow(dead_code)] @@ -95,15 +99,20 @@ pub fn load_stack_spec(json: &str) -> Result { - // Current version - deserialize directly + v if v == CURRENT_AST_VERSION || COMPATIBLE_AST_VERSIONS.contains(&v) => { serde_json::from_value::(raw) + .map(|mut spec| { + // Normalize so round-tripped specs carry the current version. + spec.ast_version = CURRENT_AST_VERSION.to_string(); + spec + }) .map_err(|e| VersionedLoadError::InvalidStructure(e.to_string())) } - // Add migration arms for old versions here, e.g.: - // "0.0.1" => { migrate_v1_to_latest(raw) } + // Add migration arms for structurally-incompatible old versions here. _ => { // Unknown version Err(VersionedLoadError::UnsupportedVersion(version.to_string())) @@ -135,15 +144,20 @@ pub fn load_stream_spec(json: &str) -> Result { - // Current version - deserialize directly + v if v == CURRENT_AST_VERSION || COMPATIBLE_AST_VERSIONS.contains(&v) => { serde_json::from_value::(raw) + .map(|mut spec| { + // Normalize so round-tripped specs carry the current version. + spec.ast_version = CURRENT_AST_VERSION.to_string(); + spec + }) .map_err(|e| VersionedLoadError::InvalidStructure(e.to_string())) } - // Add migration arms for old versions here, e.g.: - // "0.0.1" => { migrate_v1_to_latest(raw) } + // Add migration arms for structurally-incompatible old versions here. _ => { // Unknown version Err(VersionedLoadError::UnsupportedVersion(version.to_string())) @@ -169,6 +183,8 @@ pub fn load_stream_spec(json: &str) -> Result SerializableStackSpec { match self { - VersionedStackSpec::V1(spec) => spec, + VersionedStackSpec::V1(spec) | VersionedStackSpec::V2(spec) => spec, } } } @@ -204,6 +220,8 @@ impl VersionedStackSpec { pub enum VersionedStreamSpec { #[serde(rename = "0.0.1")] V1(SerializableStreamSpec), + #[serde(rename = "0.0.2")] + V2(SerializableStreamSpec), } impl VersionedStreamSpec { @@ -216,7 +234,7 @@ impl VersionedStreamSpec { #[allow(dead_code)] pub fn into_latest(self) -> SerializableStreamSpec { match self { - VersionedStreamSpec::V1(spec) => spec, + VersionedStreamSpec::V1(spec) | VersionedStreamSpec::V2(spec) => spec, } } } diff --git a/arete-macros/src/ast/writer.rs b/arete-macros/src/ast/writer.rs index 9555a956..4ee5ae8c 100644 --- a/arete-macros/src/ast/writer.rs +++ b/arete-macros/src/ast/writer.rs @@ -121,12 +121,7 @@ pub fn convert_idl_to_snapshot(idl: &idl_parser::IdlSpec) -> IdlSnapshot { idl_parser::IdlTypeDefKind::Enum { kind, variants } => { IdlTypeDefKindSnapshot::Enum { kind: kind.clone(), - variants: variants - .iter() - .map(|v| IdlEnumVariantSnapshot { - name: v.name.clone(), - }) - .collect(), + variants: variants.iter().map(convert_enum_variant).collect(), } } }, @@ -179,12 +174,7 @@ pub fn convert_idl_to_snapshot(idl: &idl_parser::IdlSpec) -> IdlSnapshot { serialization: None, type_def: IdlTypeDefKindSnapshot::Enum { kind: kind.clone(), - variants: variants - .iter() - .map(|v| IdlEnumVariantSnapshot { - name: v.name.clone(), - }) - .collect(), + variants: variants.iter().map(convert_enum_variant).collect(), }, }); } @@ -347,6 +337,33 @@ pub fn convert_idl_type(idl_type: &idl_parser::IdlType) -> IdlTypeSnapshot { } } +fn convert_enum_variant(variant: &idl_parser::IdlEnumVariant) -> IdlEnumVariantSnapshot { + IdlEnumVariantSnapshot { + name: variant.name.clone(), + fields: variant + .fields + .iter() + .map(convert_enum_variant_field) + .collect(), + } +} + +fn convert_enum_variant_field( + field: &idl_parser::IdlEnumVariantField, +) -> IdlEnumVariantFieldSnapshot { + match field { + idl_parser::IdlEnumVariantField::Named(named) => { + IdlEnumVariantFieldSnapshot::Named(IdlFieldSnapshot { + name: named.name.clone(), + type_: convert_idl_type(&named.type_), + }) + } + idl_parser::IdlEnumVariantField::Tuple(tuple) => { + IdlEnumVariantFieldSnapshot::Tuple(convert_idl_type(tuple)) + } + } +} + /// Build handlers from source mappings. pub fn build_handlers_from_sources( sources_by_type: &BTreeMap>, @@ -935,23 +952,17 @@ pub fn extract_instructions_from_idl( }) .collect(); - let errors: Vec = idl - .errors - .iter() - .map(|e| IdlErrorSnapshot { - code: e.code, - name: e.name.clone(), - msg: e.msg.clone(), - }) - .collect(); - + // Program errors are emitted once at the stack level (via the IDL + // snapshot's `errors`), so we do not duplicate the full list onto + // every instruction. Only genuinely instruction-specific errors + // would belong here, which IDLs do not currently express. InstructionDef { name: ix.name.clone(), discriminator: ix.get_discriminator(), discriminator_size, accounts, args, - errors, + errors: Vec::new(), program_id: program_id.clone(), docs: ix.docs.clone(), } @@ -984,6 +995,13 @@ fn convert_account_to_def( } else { AccountResolution::UserProvided } + } else if pdas.contains_key(&acc.name) { + // Steel-style IDLs carry no per-account PDA metadata, so fall back to + // matching the instruction account name against the stack's `pdas!` + // registry. Seeds are inlined by the consumer from the named entry. + AccountResolution::PdaRef { + pda_name: acc.name.clone(), + } } else { AccountResolution::UserProvided }; @@ -997,3 +1015,65 @@ fn convert_account_to_def( docs: acc.docs.clone(), } } + +#[cfg(test)] +mod convert_account_tests { + use super::*; + + fn registry() -> BTreeMap { + let mut m = BTreeMap::new(); + m.insert( + "treasury".to_string(), + PdaDefinition { + name: "treasury".to_string(), + seeds: vec![PdaSeedDef::Literal { + value: "treasury".to_string(), + }], + program_id: None, + }, + ); + m + } + + fn account(name: &str, is_signer: bool) -> idl_parser::IdlAccountArg { + idl_parser::IdlAccountArg { + name: name.to_string(), + is_signer, + is_mut: true, + optional: false, + address: None, + pda: None, + docs: vec![], + } + } + + #[test] + fn steel_account_matches_registry_pda_by_name() { + let def = convert_account_to_def(&account("treasury", false), ®istry()); + assert!(matches!( + def.resolution, + AccountResolution::PdaRef { ref pda_name } if pda_name == "treasury" + )); + } + + #[test] + fn signer_account_stays_signer_even_if_name_in_registry() { + let mut m = registry(); + m.insert( + "signer".to_string(), + PdaDefinition { + name: "signer".to_string(), + seeds: vec![], + program_id: None, + }, + ); + let def = convert_account_to_def(&account("signer", true), &m); + assert!(matches!(def.resolution, AccountResolution::Signer)); + } + + #[test] + fn unknown_account_falls_back_to_user_provided() { + let def = convert_account_to_def(&account("randomThing", false), ®istry()); + assert!(matches!(def.resolution, AccountResolution::UserProvided)); + } +} diff --git a/arete-macros/src/stream_spec/idl_spec.rs b/arete-macros/src/stream_spec/idl_spec.rs index f7c789a2..4baaa387 100644 --- a/arete-macros/src/stream_spec/idl_spec.rs +++ b/arete-macros/src/stream_spec/idl_spec.rs @@ -317,6 +317,11 @@ pub fn process_idl_spec( attr.path().is_ident("resolve_key") || attr.path().is_ident("register_pda") }); !has_declarative_attr + } else if let Item::Macro(item_macro) = item { + // `pdas! { ... }` is consumed during spec construction; it is + // not a real macro, so it must not survive into the emitted + // module or compilation fails with "cannot find macro `pdas`". + !item_macro.mac.path.is_ident("pdas") } else { true } @@ -425,36 +430,48 @@ pub fn process_idl_spec( }) .collect(); - let mut all_pdas: BTreeMap> = - BTreeMap::new(); - let mut all_instructions: Vec = Vec::new(); - for info in &idl_infos { - let pdas = extract_pdas_from_idl(&info.idl); - let flat_pdas: BTreeMap = - pdas.iter().map(|(k, v)| (k.clone(), v.clone())).collect(); - let instructions = extract_instructions_from_idl(&info.idl, &flat_pdas); - all_pdas.insert(info.program_name.clone(), pdas); - all_instructions.extend(instructions); - } - let idl_map: HashMap = idl_infos .iter() .map(|info| (info.program_name.clone(), info.idl.clone())) .collect(); validate_pda_blocks(&idl_map, &manual_pdas_blocks)?; + // Build per-program manual PDA definitions from `pdas!` blocks so they + // can participate in instruction-account resolution below. This matters + // for Steel-style IDLs whose accounts carry no embedded PDA metadata: + // the registry is matched against account names in `convert_account_to_def`. + let mut manual_pdas_by_program: HashMap< + String, + BTreeMap, + > = HashMap::new(); for manual_block in &manual_pdas_blocks { for program_pdas in &manual_block.programs { - let program_entry = all_pdas + let entry = manual_pdas_by_program .entry(program_pdas.program_name.clone()) .or_default(); - for pda in &program_pdas.pdas { - program_entry.insert(pda.name.clone(), pda.to_pda_definition()); + entry.insert(pda.name.clone(), pda.to_pda_definition()); } } } + let mut all_pdas: BTreeMap> = + BTreeMap::new(); + let mut all_instructions: Vec = Vec::new(); + for info in &idl_infos { + let mut pdas = extract_pdas_from_idl(&info.idl); + if let Some(manual) = manual_pdas_by_program.get(&info.program_name) { + for (k, v) in manual { + pdas.insert(k.clone(), v.clone()); + } + } + let flat_pdas: BTreeMap = + pdas.iter().map(|(k, v)| (k.clone(), v.clone())).collect(); + let instructions = extract_instructions_from_idl(&info.idl, &flat_pdas); + all_pdas.insert(info.program_name.clone(), pdas); + all_instructions.extend(instructions); + } + let mut stack_spec = SerializableStackSpec { ast_version: crate::ast::CURRENT_AST_VERSION.to_string(), stack_name: stack_name.clone(), diff --git a/examples/ore-react/src/generated/ore-stack.ts b/examples/ore-react/src/generated/ore-stack.ts index 0220b1e4..88a5550d 100644 --- a/examples/ore-react/src/generated/ore-stack.ts +++ b/examples/ore-react/src/generated/ore-stack.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { pda, literal, account, arg, bytes } from '@usearete/sdk'; export interface OreRoundEntropy { entropy_end_at?: number | null; @@ -447,6 +448,15 @@ export const ORE_STREAM_STACK = { TokenMetadata: TokenMetadataSchema, Treasury: TreasurySchema, }, + pdas: { + ore: { + automation: pda('oreV3EG1i9BEgiAJ8b177Z2S2rMarzak4NMv1kULvWv', literal('automation'), account('authority')), + board: pda('oreV3EG1i9BEgiAJ8b177Z2S2rMarzak4NMv1kULvWv', literal('board')), + config: pda('oreV3EG1i9BEgiAJ8b177Z2S2rMarzak4NMv1kULvWv', literal('config')), + miner: pda('oreV3EG1i9BEgiAJ8b177Z2S2rMarzak4NMv1kULvWv', literal('miner'), account('authority')), + treasury: pda('oreV3EG1i9BEgiAJ8b177Z2S2rMarzak4NMv1kULvWv', literal('treasury')), + }, + }, } as const; /** Type alias for the stack */ diff --git a/examples/ore-typescript/src/generated/ore-stack.ts b/examples/ore-typescript/src/generated/ore-stack.ts index 0220b1e4..88a5550d 100644 --- a/examples/ore-typescript/src/generated/ore-stack.ts +++ b/examples/ore-typescript/src/generated/ore-stack.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { pda, literal, account, arg, bytes } from '@usearete/sdk'; export interface OreRoundEntropy { entropy_end_at?: number | null; @@ -447,6 +448,15 @@ export const ORE_STREAM_STACK = { TokenMetadata: TokenMetadataSchema, Treasury: TreasurySchema, }, + pdas: { + ore: { + automation: pda('oreV3EG1i9BEgiAJ8b177Z2S2rMarzak4NMv1kULvWv', literal('automation'), account('authority')), + board: pda('oreV3EG1i9BEgiAJ8b177Z2S2rMarzak4NMv1kULvWv', literal('board')), + config: pda('oreV3EG1i9BEgiAJ8b177Z2S2rMarzak4NMv1kULvWv', literal('config')), + miner: pda('oreV3EG1i9BEgiAJ8b177Z2S2rMarzak4NMv1kULvWv', literal('miner'), account('authority')), + treasury: pda('oreV3EG1i9BEgiAJ8b177Z2S2rMarzak4NMv1kULvWv', literal('treasury')), + }, + }, } as const; /** Type alias for the stack */ diff --git a/interpreter/src/ast.rs b/interpreter/src/ast.rs index 94757434..751bd179 100644 --- a/interpreter/src/ast.rs +++ b/interpreter/src/ast.rs @@ -11,7 +11,13 @@ pub use arete_idl::snapshot::*; /// circular dependency between proc-macro crates and their output crates. /// When bumping this version, you MUST also update the constant in the /// arete-macros crate. A test in versioned.rs verifies they stay in sync. -pub const CURRENT_AST_VERSION: &str = "0.0.1"; +// 0.0.2: enum variants may carry `fields` (data-carrying enum variants). +// Additive over 0.0.1 — every 0.0.1 file deserializes unchanged. +pub const CURRENT_AST_VERSION: &str = "0.0.2"; + +/// Older versions this build can still deserialize directly (all changes +/// since were additive with serde defaults). +pub const COMPATIBLE_AST_VERSIONS: &[&str] = &["0.0.1"]; fn default_ast_version() -> String { CURRENT_AST_VERSION.to_string() diff --git a/interpreter/src/versioned.rs b/interpreter/src/versioned.rs index 4af6a61b..3fea61d0 100644 --- a/interpreter/src/versioned.rs +++ b/interpreter/src/versioned.rs @@ -18,7 +18,9 @@ use serde::Deserialize; use serde_json::Value; use std::fmt; -use crate::ast::{SerializableStackSpec, SerializableStreamSpec, CURRENT_AST_VERSION}; +use crate::ast::{ + SerializableStackSpec, SerializableStreamSpec, COMPATIBLE_AST_VERSIONS, CURRENT_AST_VERSION, +}; /// Error type for versioned AST loading failures. #[derive(Debug, Clone)] @@ -86,15 +88,20 @@ pub fn load_stack_spec(json: &str) -> Result { - // Current version - deserialize directly + v if v == CURRENT_AST_VERSION || COMPATIBLE_AST_VERSIONS.contains(&v) => { serde_json::from_value::(raw) + .map(|mut spec| { + // Normalize so round-tripped specs carry the current version. + spec.ast_version = CURRENT_AST_VERSION.to_string(); + spec + }) .map_err(|e| VersionedLoadError::InvalidStructure(e.to_string())) } - // Add migration arms for old versions here, e.g.: - // "0.0.1" => { migrate_v1_to_latest(raw) } + // Add migration arms for structurally-incompatible old versions here. _ => { // Unknown version Err(VersionedLoadError::UnsupportedVersion(version.to_string())) @@ -124,15 +131,20 @@ pub fn load_stream_spec(json: &str) -> Result { - // Current version - deserialize directly + v if v == CURRENT_AST_VERSION || COMPATIBLE_AST_VERSIONS.contains(&v) => { serde_json::from_value::(raw) + .map(|mut spec| { + // Normalize so round-tripped specs carry the current version. + spec.ast_version = CURRENT_AST_VERSION.to_string(); + spec + }) .map_err(|e| VersionedLoadError::InvalidStructure(e.to_string())) } - // Add migration arms for old versions here, e.g.: - // "0.0.1" => { migrate_v1_to_latest(raw) } + // Add migration arms for structurally-incompatible old versions here. _ => { // Unknown version Err(VersionedLoadError::UnsupportedVersion(version.to_string())) @@ -156,6 +168,8 @@ pub fn load_stream_spec(json: &str) -> Result SerializableStackSpec { match self { - VersionedStackSpec::V1(spec) => spec, + VersionedStackSpec::V1(spec) | VersionedStackSpec::V2(spec) => spec, } } } @@ -187,6 +201,8 @@ impl VersionedStackSpec { pub enum VersionedStreamSpec { #[serde(rename = "0.0.1")] V1(SerializableStreamSpec), + #[serde(rename = "0.0.2")] + V2(SerializableStreamSpec), } impl VersionedStreamSpec { @@ -197,7 +213,7 @@ impl VersionedStreamSpec { /// instead, which properly sets `ast_version` to `CURRENT_AST_VERSION`. pub fn into_latest(self) -> SerializableStreamSpec { match self { - VersionedStreamSpec::V1(spec) => spec, + VersionedStreamSpec::V1(spec) | VersionedStreamSpec::V2(spec) => spec, } } } diff --git a/rust/arete-a4-sdk/tests/auth_lifecycle.rs b/rust/arete-a4-sdk/tests/auth_lifecycle.rs index 8c95aba7..ec9a51a1 100644 --- a/rust/arete-a4-sdk/tests/auth_lifecycle.rs +++ b/rust/arete-a4-sdk/tests/auth_lifecycle.rs @@ -103,7 +103,12 @@ async fn fetches_token_from_endpoint_and_refreshes_in_band() { let (refresh_tx, mut refresh_rx) = mpsc::channel(4); let ws_server = spawn_websocket_server(handshake_tx, refresh_tx).await; - let token_endpoint = spawn_token_endpoint(vec![61, 3600]).await; + // First expiry sits TOKEN_REFRESH_BUFFER_SECONDS (60s) + 15s ahead so the + // freshly-minted token survives the `set_token` "is expiring" check even + // when the CI runner introduces several seconds of skew between the token + // endpoint's clock and the SDK's. The 15s headroom also pins the in-band + // refresh ~15s after connect; the refresh wait below is sized to match. + let token_endpoint = spawn_token_endpoint(vec![75, 3600]).await; let client = Arete::::builder() .url(&ws_server.url) @@ -142,7 +147,7 @@ async fn fetches_token_from_endpoint_and_refreshes_in_band() { let requested_urls = token_endpoint.state.websocket_urls.lock().await.clone(); assert_eq!(requested_urls, vec![ws_server.url.clone()]); - let refreshed_token = timeout(Duration::from_secs(5), refresh_rx.recv()) + let refreshed_token = timeout(Duration::from_secs(20), refresh_rx.recv()) .await .expect("refresh_auth message should be sent before expiry") .expect("refresh channel should receive a token"); diff --git a/stacks/ore/.arete/OreStream.stack.json b/stacks/ore/.arete/OreStream.stack.json index f922a4eb..61eaeb57 100644 --- a/stacks/ore/.arete/OreStream.stack.json +++ b/stacks/ore/.arete/OreStream.stack.json @@ -7197,7 +7197,61 @@ ], "pdas": { "entropy": {}, - "ore": {} + "ore": { + "automation": { + "name": "automation", + "seeds": [ + { + "type": "literal", + "value": "automation" + }, + { + "type": "accountRef", + "account_name": "authority" + } + ] + }, + "board": { + "name": "board", + "seeds": [ + { + "type": "literal", + "value": "board" + } + ] + }, + "config": { + "name": "config", + "seeds": [ + { + "type": "literal", + "value": "config" + } + ] + }, + "miner": { + "name": "miner", + "seeds": [ + { + "type": "literal", + "value": "miner" + }, + { + "type": "accountRef", + "account_name": "authority" + } + ] + }, + "treasury": { + "name": "treasury", + "seeds": [ + { + "type": "literal", + "value": "treasury" + } + ] + } + } }, "instructions": [ { @@ -7221,7 +7275,8 @@ "is_signer": false, "is_writable": true, "resolution": { - "category": "userProvided" + "category": "pdaRef", + "pda_name": "automation" }, "is_optional": false }, @@ -7239,7 +7294,8 @@ "is_signer": false, "is_writable": true, "resolution": { - "category": "userProvided" + "category": "pdaRef", + "pda_name": "miner" }, "is_optional": false }, @@ -7280,18 +7336,6 @@ "type": "u64" } ], - "errors": [ - { - "code": 0, - "name": "AmountTooSmall", - "msg": "Amount too small" - }, - { - "code": 1, - "name": "NotAuthorized", - "msg": "Not authorized" - } - ], "program_id": "oreV3EG1i9BEgiAJ8b177Z2S2rMarzak4NMv1kULvWv", "docs": [ "Configures or closes a miner automation account.", @@ -7320,7 +7364,8 @@ "is_signer": false, "is_writable": false, "resolution": { - "category": "userProvided" + "category": "pdaRef", + "pda_name": "board" }, "is_optional": false }, @@ -7329,7 +7374,8 @@ "is_signer": false, "is_writable": true, "resolution": { - "category": "userProvided" + "category": "pdaRef", + "pda_name": "miner" }, "is_optional": false }, @@ -7347,7 +7393,8 @@ "is_signer": false, "is_writable": true, "resolution": { - "category": "userProvided" + "category": "pdaRef", + "pda_name": "treasury" }, "is_optional": false }, @@ -7363,18 +7410,6 @@ } ], "args": [], - "errors": [ - { - "code": 0, - "name": "AmountTooSmall", - "msg": "Amount too small" - }, - { - "code": 1, - "name": "NotAuthorized", - "msg": "Not authorized" - } - ], "program_id": "oreV3EG1i9BEgiAJ8b177Z2S2rMarzak4NMv1kULvWv", "docs": [ "Settles miner rewards for a completed round.", @@ -7402,7 +7437,8 @@ "is_signer": false, "is_writable": true, "resolution": { - "category": "userProvided" + "category": "pdaRef", + "pda_name": "board" }, "is_optional": false }, @@ -7411,7 +7447,8 @@ "is_signer": false, "is_writable": true, "resolution": { - "category": "userProvided" + "category": "pdaRef", + "pda_name": "miner" }, "is_optional": false }, @@ -7437,18 +7474,6 @@ } ], "args": [], - "errors": [ - { - "code": 0, - "name": "AmountTooSmall", - "msg": "Amount too small" - }, - { - "code": 1, - "name": "NotAuthorized", - "msg": "Not authorized" - } - ], "program_id": "oreV3EG1i9BEgiAJ8b177Z2S2rMarzak4NMv1kULvWv", "docs": [ "Claims SOL rewards from the miner account." @@ -7475,7 +7500,8 @@ "is_signer": false, "is_writable": true, "resolution": { - "category": "userProvided" + "category": "pdaRef", + "pda_name": "board" }, "is_optional": false }, @@ -7484,7 +7510,8 @@ "is_signer": false, "is_writable": true, "resolution": { - "category": "userProvided" + "category": "pdaRef", + "pda_name": "miner" }, "is_optional": false }, @@ -7512,7 +7539,8 @@ "is_signer": false, "is_writable": true, "resolution": { - "category": "userProvided" + "category": "pdaRef", + "pda_name": "treasury" }, "is_optional": false }, @@ -7567,18 +7595,6 @@ } ], "args": [], - "errors": [ - { - "code": 0, - "name": "AmountTooSmall", - "msg": "Amount too small" - }, - { - "code": 1, - "name": "NotAuthorized", - "msg": "Not authorized" - } - ], "program_id": "oreV3EG1i9BEgiAJ8b177Z2S2rMarzak4NMv1kULvWv", "docs": [ "Claims ORE token rewards from the treasury vault." @@ -7605,7 +7621,8 @@ "is_signer": false, "is_writable": true, "resolution": { - "category": "userProvided" + "category": "pdaRef", + "pda_name": "board" }, "is_optional": false }, @@ -7632,7 +7649,8 @@ "is_signer": false, "is_writable": true, "resolution": { - "category": "userProvided" + "category": "pdaRef", + "pda_name": "treasury" }, "is_optional": false }, @@ -7648,18 +7666,6 @@ } ], "args": [], - "errors": [ - { - "code": 0, - "name": "AmountTooSmall", - "msg": "Amount too small" - }, - { - "code": 1, - "name": "NotAuthorized", - "msg": "Not authorized" - } - ], "program_id": "oreV3EG1i9BEgiAJ8b177Z2S2rMarzak4NMv1kULvWv", "docs": [ "Closes an expired round account and returns rent to the payer.", @@ -7697,7 +7703,8 @@ "is_signer": false, "is_writable": true, "resolution": { - "category": "userProvided" + "category": "pdaRef", + "pda_name": "automation" }, "is_optional": false }, @@ -7706,7 +7713,8 @@ "is_signer": false, "is_writable": true, "resolution": { - "category": "userProvided" + "category": "pdaRef", + "pda_name": "board" }, "is_optional": false }, @@ -7715,7 +7723,8 @@ "is_signer": false, "is_writable": true, "resolution": { - "category": "userProvided" + "category": "pdaRef", + "pda_name": "config" }, "is_optional": false }, @@ -7724,7 +7733,8 @@ "is_signer": false, "is_writable": true, "resolution": { - "category": "userProvided" + "category": "pdaRef", + "pda_name": "miner" }, "is_optional": false }, @@ -7768,18 +7778,6 @@ "type": "u32" } ], - "errors": [ - { - "code": 0, - "name": "AmountTooSmall", - "msg": "Amount too small" - }, - { - "code": 1, - "name": "NotAuthorized", - "msg": "Not authorized" - } - ], "program_id": "oreV3EG1i9BEgiAJ8b177Z2S2rMarzak4NMv1kULvWv", "docs": [ "Deploys SOL to selected squares for the current round.", @@ -7807,18 +7805,6 @@ } ], "args": [], - "errors": [ - { - "code": 0, - "name": "AmountTooSmall", - "msg": "Amount too small" - }, - { - "code": 1, - "name": "NotAuthorized", - "msg": "Not authorized" - } - ], "program_id": "oreV3EG1i9BEgiAJ8b177Z2S2rMarzak4NMv1kULvWv", "docs": [ "Emits an arbitrary log message from the board PDA.", @@ -7846,7 +7832,8 @@ "is_signer": false, "is_writable": true, "resolution": { - "category": "userProvided" + "category": "pdaRef", + "pda_name": "board" }, "is_optional": false }, @@ -7855,7 +7842,8 @@ "is_signer": false, "is_writable": false, "resolution": { - "category": "userProvided" + "category": "pdaRef", + "pda_name": "config" }, "is_optional": false }, @@ -7910,7 +7898,8 @@ "is_signer": false, "is_writable": true, "resolution": { - "category": "userProvided" + "category": "pdaRef", + "pda_name": "treasury" }, "is_optional": false }, @@ -8003,18 +7992,6 @@ } ], "args": [], - "errors": [ - { - "code": 0, - "name": "AmountTooSmall", - "msg": "Amount too small" - }, - { - "code": 1, - "name": "NotAuthorized", - "msg": "Not authorized" - } - ], "program_id": "oreV3EG1i9BEgiAJ8b177Z2S2rMarzak4NMv1kULvWv", "docs": [ "Finalizes the current round, mints rewards, and opens the next round.", @@ -8090,7 +8067,8 @@ "is_signer": false, "is_writable": true, "resolution": { - "category": "userProvided" + "category": "pdaRef", + "pda_name": "treasury" }, "is_optional": false }, @@ -8135,18 +8113,6 @@ "type": "u64" } ], - "errors": [ - { - "code": 0, - "name": "AmountTooSmall", - "msg": "Amount too small" - }, - { - "code": 1, - "name": "NotAuthorized", - "msg": "Not authorized" - } - ], "program_id": "oreV3EG1i9BEgiAJ8b177Z2S2rMarzak4NMv1kULvWv", "docs": [ "Deposits ORE into a staking account.", @@ -8211,7 +8177,8 @@ "is_signer": false, "is_writable": true, "resolution": { - "category": "userProvided" + "category": "pdaRef", + "pda_name": "treasury" }, "is_optional": false }, @@ -8252,18 +8219,6 @@ "type": "u64" } ], - "errors": [ - { - "code": 0, - "name": "AmountTooSmall", - "msg": "Amount too small" - }, - { - "code": 1, - "name": "NotAuthorized", - "msg": "Not authorized" - } - ], "program_id": "oreV3EG1i9BEgiAJ8b177Z2S2rMarzak4NMv1kULvWv", "docs": [ "Withdraws ORE from a staking account.", @@ -8319,7 +8274,8 @@ "is_signer": false, "is_writable": true, "resolution": { - "category": "userProvided" + "category": "pdaRef", + "pda_name": "treasury" }, "is_optional": false }, @@ -8369,18 +8325,6 @@ "type": "u64" } ], - "errors": [ - { - "code": 0, - "name": "AmountTooSmall", - "msg": "Amount too small" - }, - { - "code": 1, - "name": "NotAuthorized", - "msg": "Not authorized" - } - ], "program_id": "oreV3EG1i9BEgiAJ8b177Z2S2rMarzak4NMv1kULvWv", "docs": [ "Claims accrued staking rewards.", @@ -8396,18 +8340,6 @@ "discriminator_size": 1, "accounts": [], "args": [], - "errors": [ - { - "code": 0, - "name": "AmountTooSmall", - "msg": "Amount too small" - }, - { - "code": 1, - "name": "NotAuthorized", - "msg": "Not authorized" - } - ], "program_id": "oreV3EG1i9BEgiAJ8b177Z2S2rMarzak4NMv1kULvWv", "docs": [ "Buys back ORE from the market." @@ -8434,7 +8366,8 @@ "is_signer": false, "is_writable": true, "resolution": { - "category": "userProvided" + "category": "pdaRef", + "pda_name": "board" }, "is_optional": false }, @@ -8443,7 +8376,8 @@ "is_signer": false, "is_writable": false, "resolution": { - "category": "userProvided" + "category": "pdaRef", + "pda_name": "config" }, "is_optional": false }, @@ -8462,7 +8396,8 @@ "is_signer": false, "is_writable": true, "resolution": { - "category": "userProvided" + "category": "pdaRef", + "pda_name": "treasury" }, "is_optional": false }, @@ -8511,18 +8446,6 @@ "type": "u64" } ], - "errors": [ - { - "code": 0, - "name": "AmountTooSmall", - "msg": "Amount too small" - }, - { - "code": 1, - "name": "NotAuthorized", - "msg": "Not authorized" - } - ], "program_id": "oreV3EG1i9BEgiAJ8b177Z2S2rMarzak4NMv1kULvWv", "docs": [ "Swaps vaulted SOL for ORE via a CPI and burns the proceeds.", @@ -8551,7 +8474,8 @@ "is_signer": false, "is_writable": false, "resolution": { - "category": "userProvided" + "category": "pdaRef", + "pda_name": "config" }, "is_optional": false }, @@ -8560,7 +8484,8 @@ "is_signer": false, "is_writable": true, "resolution": { - "category": "userProvided" + "category": "pdaRef", + "pda_name": "treasury" }, "is_optional": false }, @@ -8590,18 +8515,6 @@ "type": "u64" } ], - "errors": [ - { - "code": 0, - "name": "AmountTooSmall", - "msg": "Amount too small" - }, - { - "code": 1, - "name": "NotAuthorized", - "msg": "Not authorized" - } - ], "program_id": "oreV3EG1i9BEgiAJ8b177Z2S2rMarzak4NMv1kULvWv", "docs": [ "Wraps SOL held by the treasury into WSOL for swapping.", @@ -8629,7 +8542,8 @@ "is_signer": false, "is_writable": true, "resolution": { - "category": "userProvided" + "category": "pdaRef", + "pda_name": "config" }, "is_optional": false }, @@ -8650,18 +8564,6 @@ "type": "solana_pubkey::Pubkey" } ], - "errors": [ - { - "code": 0, - "name": "AmountTooSmall", - "msg": "Amount too small" - }, - { - "code": 1, - "name": "NotAuthorized", - "msg": "Not authorized" - } - ], "program_id": "oreV3EG1i9BEgiAJ8b177Z2S2rMarzak4NMv1kULvWv", "docs": [ "Updates the program admin address." @@ -8688,7 +8590,8 @@ "is_signer": false, "is_writable": true, "resolution": { - "category": "userProvided" + "category": "pdaRef", + "pda_name": "board" }, "is_optional": false }, @@ -8697,7 +8600,8 @@ "is_signer": false, "is_writable": true, "resolution": { - "category": "userProvided" + "category": "pdaRef", + "pda_name": "config" }, "is_optional": false }, @@ -8754,18 +8658,6 @@ "type": "u64" } ], - "errors": [ - { - "code": 0, - "name": "AmountTooSmall", - "msg": "Amount too small" - }, - { - "code": 1, - "name": "NotAuthorized", - "msg": "Not authorized" - } - ], "program_id": "oreV3EG1i9BEgiAJ8b177Z2S2rMarzak4NMv1kULvWv", "docs": [ "Creates a new entropy var account through the entropy program." @@ -8792,7 +8684,8 @@ "is_signer": false, "is_writable": true, "resolution": { - "category": "userProvided" + "category": "pdaRef", + "pda_name": "automation" }, "is_optional": false }, @@ -8801,7 +8694,8 @@ "is_signer": false, "is_writable": true, "resolution": { - "category": "userProvided" + "category": "pdaRef", + "pda_name": "miner" }, "is_optional": false }, @@ -8817,18 +8711,6 @@ } ], "args": [], - "errors": [ - { - "code": 0, - "name": "AmountTooSmall", - "msg": "Amount too small" - }, - { - "code": 1, - "name": "NotAuthorized", - "msg": "Not authorized" - } - ], "program_id": "oreV3EG1i9BEgiAJ8b177Z2S2rMarzak4NMv1kULvWv", "docs": [ "Reloads SOL into the automation account." @@ -8842,18 +8724,6 @@ "discriminator_size": 1, "accounts": [], "args": [], - "errors": [ - { - "code": 0, - "name": "AmountTooSmall", - "msg": "Amount too small" - }, - { - "code": 1, - "name": "NotAuthorized", - "msg": "Not authorized" - } - ], "program_id": "oreV3EG1i9BEgiAJ8b177Z2S2rMarzak4NMv1kULvWv", "docs": [ "Compounds staking yield." @@ -8867,18 +8737,6 @@ "discriminator_size": 1, "accounts": [], "args": [], - "errors": [ - { - "code": 0, - "name": "AmountTooSmall", - "msg": "Amount too small" - }, - { - "code": 1, - "name": "NotAuthorized", - "msg": "Not authorized" - } - ], "program_id": "oreV3EG1i9BEgiAJ8b177Z2S2rMarzak4NMv1kULvWv", "docs": [ "Liquidation instruction." @@ -8960,18 +8818,6 @@ "type": "u64" } ], - "errors": [ - { - "code": 0, - "name": "IncompleteDigest", - "msg": "Incomplete digest" - }, - { - "code": 1, - "name": "InvalidSeed", - "msg": "Invalid seed" - } - ], "program_id": "3jSkUuYBoJzQPMEzTvkDFXCZUBksPamrVhrnHR9igu2X", "docs": [ "Creates a new entropy var account.", @@ -9015,18 +8861,6 @@ } ], "args": [], - "errors": [ - { - "code": 0, - "name": "IncompleteDigest", - "msg": "Incomplete digest" - }, - { - "code": 1, - "name": "InvalidSeed", - "msg": "Invalid seed" - } - ], "program_id": "3jSkUuYBoJzQPMEzTvkDFXCZUBksPamrVhrnHR9igu2X", "docs": [ "Closes an entropy var account and returns rent to the authority." @@ -9064,18 +8898,6 @@ "type": "u64" } ], - "errors": [ - { - "code": 0, - "name": "IncompleteDigest", - "msg": "Incomplete digest" - }, - { - "code": 1, - "name": "InvalidSeed", - "msg": "Invalid seed" - } - ], "program_id": "3jSkUuYBoJzQPMEzTvkDFXCZUBksPamrVhrnHR9igu2X", "docs": [ "Updates the var for the next random value sample.", @@ -9114,18 +8936,6 @@ "type": "[u8; 32]" } ], - "errors": [ - { - "code": 0, - "name": "IncompleteDigest", - "msg": "Incomplete digest" - }, - { - "code": 1, - "name": "InvalidSeed", - "msg": "Invalid seed" - } - ], "program_id": "3jSkUuYBoJzQPMEzTvkDFXCZUBksPamrVhrnHR9igu2X", "docs": [ "Reveals the seed and finalizes the random value.", @@ -9169,18 +8979,6 @@ } ], "args": [], - "errors": [ - { - "code": 0, - "name": "IncompleteDigest", - "msg": "Incomplete digest" - }, - { - "code": 1, - "name": "InvalidSeed", - "msg": "Invalid seed" - } - ], "program_id": "3jSkUuYBoJzQPMEzTvkDFXCZUBksPamrVhrnHR9igu2X", "docs": [ "Samples the slot hash at the end_at slot.", @@ -9188,5 +8986,5 @@ ] } ], - "content_hash": "a8bc522847ad19b3a0c70a695dbdf9392f39d6c6ee0b785ffcae0da28e0e0ca5" -} \ No newline at end of file + "content_hash": "c4ee37009abf94c16ee4658e70fe59cb764cb37dc4aa28a6752ec0d7ed0b865a" +} diff --git a/stacks/ore/src/stack.rs b/stacks/ore/src/stack.rs index c6c6c2c1..59a7642b 100644 --- a/stacks/ore/src/stack.rs +++ b/stacks/ore/src/stack.rs @@ -7,6 +7,24 @@ pub mod ore_stream { use serde::{Deserialize, Serialize}; + // Program-derived address seeds for the ORE program. The Steel IDL does not + // embed per-account PDA metadata, so these are declared explicitly and matched + // against instruction account names by the compiler. Seeds are taken from the + // ORE instruction docs. + // + // Note: `round = ["round", round_id]` is intentionally omitted because some + // instructions derive it from a cross-account field (`board.round_id`), which + // is not yet expressible here (follow-up: per-instruction seed binding). + pdas! { + ore { + treasury = [literal("treasury")]; + config = [literal("config")]; + board = [literal("board")]; + miner = [literal("miner"), account("authority")]; + automation = [literal("automation"), account("authority")]; + } + } + #[entity(name = "OreRound")] #[view(name = "latest", sort_by = "id.round_id", order = "desc")] pub struct OreRound {