diff --git a/crates/core/src/decode/context.rs b/crates/core/src/decode/context.rs index 9687f4ff..5ad1be03 100644 --- a/crates/core/src/decode/context.rs +++ b/crates/core/src/decode/context.rs @@ -1,49 +1,416 @@ -export class AppError extends Error { - statusCode: number; - error: string; +use crate::decode::auth::{AuthChain, AuthCredential}; +use crate::decode::auth_signature::decode_auth_entry_signatures; +use crate::decode::fee_analyzer::analyze_fee_breakdown; +use crate::error::GratResult; +use crate::types::report::{ + AuthEntryInfo, DiagnosticReport, FeeBreakdown, ResourceSummary, TransactionContext, +}; - constructor(message: string, statusCode: number, error: string) { - super(message); - this.statusCode = statusCode; - this.error = error; - Object.setPrototypeOf(this, AppError.prototype); - } +pub fn enrich_report(report: &mut DiagnosticReport, tx_data: &serde_json::Value) -> GratResult<()> { + let tx_hash = tx_data + .get("hash") + .and_then(|h| h.as_str()) + .unwrap_or("unknown") + .to_string(); + + let ledger_sequence = tx_data + .get("ledger") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0) as u32; + + let context = TransactionContext { + tx_hash, + ledger_sequence, + function_name: extract_function_name(tx_data), + arguments: extract_arguments(tx_data), + return_value: extract_return_value(tx_data), + fee: extract_fee_breakdown(tx_data), + resources: extract_resource_summary(tx_data), + }; + + report.transaction_context = Some(context); + + report.auth_signatures = extract_auth_signatures(tx_data); + + report.auth_entries = extract_auth_entries(tx_data); + + Ok(()) } -export function badRequest(message: string): AppError { - return new AppError(message, 400, "BadRequest"); +fn extract_function_name(tx_data: &serde_json::Value) -> Option { + tx_data + .get("functionName") + .and_then(|f| f.as_str()) + .map(std::string::ToString::to_string) } -export function unauthorized(message: string = "Unauthorized"): AppError { - return new AppError(message, 401, "Unauthorized"); +fn extract_arguments(tx_data: &serde_json::Value) -> Vec { + tx_data + .get("arguments") + .and_then(|a| a.as_array()) + .map(|args| args.iter().map(std::string::ToString::to_string).collect()) + .unwrap_or_default() } -export function forbidden(message: string = "Forbidden"): AppError { - return new AppError(message, 403, "Forbidden"); +fn extract_return_value(tx_data: &serde_json::Value) -> Option { + tx_data + .get("returnValue") + .and_then(|r| r.as_str()) + .map(std::string::ToString::to_string) } -export function notFound(message: string = "Not found"): AppError { - return new AppError(message, 404, "NotFound"); +fn extract_fee_breakdown(tx_data: &serde_json::Value) -> FeeBreakdown { + analyze_fee_breakdown(tx_data) } -export function alreadyVoted(message: string): AppError { - return new AppError(message, 409, "AlreadyVoted"); +fn extract_resource_summary(tx_data: &serde_json::Value) -> ResourceSummary { + let meta = crate::decode::resource_analyzer::TransactionResultMeta::from_tx_data(tx_data); + ResourceSummary { + cpu_instructions_used: meta.resources_consumed.cpu_instructions, + cpu_instructions_limit: meta.resources_allocated.cpu_instructions, + memory_bytes_used: meta.resources_consumed.memory_bytes, + memory_bytes_limit: meta.resources_allocated.memory_bytes, + read_bytes: meta.resources_consumed.read_bytes, + read_bytes_limit: meta.resources_allocated.read_bytes, + write_bytes: meta.resources_consumed.write_bytes, + } } -export function tokenAlreadyIssued(message: string): AppError { - return new AppError(message, 409, "TokenAlreadyIssued"); +fn extract_auth_signatures(tx_data: &serde_json::Value) -> Vec { + let mut signatures = Vec::new(); + + if let Some(auth_array) = tx_data.get("auth").and_then(|a| a.as_array()) { + for entry in auth_array { + if let Some(xdr_b64) = entry.as_str() { + signatures.extend(decode_auth_entry_signatures(xdr_b64)); + } + } + } + + signatures } -export function reissueLimitExceeded( - message: string = "Maximum reissue limit reached (3 requests per 24 hours).", -): AppError { - return new AppError(message, 429, "REISSUE_LIMIT_EXCEEDED"); +fn extract_auth_entries(tx_data: &serde_json::Value) -> Vec { + let mut entries = Vec::new(); + + if let Some(auth_array) = tx_data.get("auth").and_then(|a| a.as_array()) { + for entry in auth_array { + if let Some(xdr_b64) = entry.as_str() { + if let Ok(chain) = AuthChain::from_xdr_base64(xdr_b64) { + if let Some(info) = auth_entry_info_from_chain(&chain) { + entries.push(info); + } + } + } + } + } + + entries } -export function ballotNotEditable(message: string = "Ballot is not editable in its current state"): AppError { - return new AppError(message, 409, "BALLOT_NOT_EDITABLE"); +fn auth_entry_info_from_chain(chain: &AuthChain) -> Option { + match &chain.credential { + AuthCredential::SourceAccount => None, + AuthCredential::Address(cred) => Some(AuthEntryInfo { + auth_type: cred.auth_type.to_string(), + address: cred.address.clone(), + contract_id: cred.contract_id.clone(), + }), + } } -export function ballotNotActive(message: string = "Ballot is not active and cannot accept votes"): AppError { - return new AppError(message, 403, "BALLOT_NOT_ACTIVE"); +#[cfg(test)] +mod tests { + use super::*; + use crate::xdr::codec::XdrCodec; + use stellar_xdr::curr::{ + ExtensionPoint, Memo, MuxedAccount, Preconditions, SequenceNumber, SorobanTransactionMeta, + SorobanTransactionMetaExt, SorobanTransactionMetaExtV1, Transaction, TransactionEnvelope, + TransactionExt, TransactionMeta, TransactionMetaV3, TransactionResult, + TransactionResultResult, TransactionV1Envelope, Uint256, + }; + + #[test] + fn test_extract_resource_summary_read() { + let tx_data = serde_json::json!({ + "diagnosticEvents": [ + { + "type": "budget", + "data": { + "category": "read", + "used": 12345, + "limit": 100000 + } + } + ] + }); + let result = extract_resource_summary(&tx_data); + assert_eq!(result.read_bytes, 12345); + assert_eq!(result.read_bytes_limit, 100000); + assert_eq!(result.cpu_instructions_used, 0); + assert_eq!(result.memory_bytes_used, 0); + } + + #[test] + fn test_extract_resource_summary_empty() { + let tx_data = serde_json::json!({}); + let result = extract_resource_summary(&tx_data); + assert_eq!(result.read_bytes, 0); + assert_eq!(result.read_bytes_limit, 0); + assert_eq!(result.cpu_instructions_used, 0); + assert_eq!(result.memory_bytes_used, 0); + } + + #[test] + fn test_extract_resource_summary_cpu_regression() { + let tx_data = serde_json::json!({ + "diagnosticEvents": [ + { + "type": "budget", + "data": { + "category": "cpu", + "used": 5000, + "limit": 10000 + } + } + ] + }); + let result = extract_resource_summary(&tx_data); + assert_eq!(result.cpu_instructions_used, 5000); + assert_eq!(result.cpu_instructions_limit, 10000); + assert_eq!(result.read_bytes, 0); + assert_eq!(result.read_bytes_limit, 0); + } + + #[test] + fn test_extract_resource_summary_unknown_category() { + let tx_data = serde_json::json!({ + "diagnosticEvents": [ + { + "type": "budget", + "data": { + "category": "unknown_category", + "used": 999, + "limit": 9999 + } + } + ] + }); + let result = extract_resource_summary(&tx_data); + assert_eq!(result.read_bytes, 0); + assert_eq!(result.read_bytes_limit, 0); + assert_eq!(result.cpu_instructions_used, 0); + assert_eq!(result.memory_bytes_used, 0); + } + + #[test] + fn test_extract_fee_breakdown_non_soroban() { + let tx = Transaction { + source_account: MuxedAccount::Ed25519(Uint256([0; 32])), + fee: 150, + seq_num: SequenceNumber(1), + cond: Preconditions::None, + memo: Memo::None, + operations: vec![].try_into().unwrap(), + ext: TransactionExt::V0, + }; + let envelope = TransactionEnvelope::Tx(TransactionV1Envelope { + tx, + signatures: vec![].try_into().unwrap(), + }); + let envelope_xdr = envelope.to_xdr_base64().unwrap(); + + let result = TransactionResult { + fee_charged: 120, + result: TransactionResultResult::TxSuccess(vec![].try_into().unwrap()), + ext: stellar_xdr::curr::TransactionResultExt::V0, + }; + let result_xdr = result.to_xdr_base64().unwrap(); + + let tx_data = serde_json::json!({ + "envelopeXdr": envelope_xdr, + "resultXdr": result_xdr, + }); + + let breakdown = extract_fee_breakdown(&tx_data); + assert_eq!(breakdown.total_charged_fee, 120); + assert_eq!(breakdown.bid_fee, Some(150)); + assert_eq!(breakdown.inclusion_fee, 120); + assert_eq!(breakdown.resource_fee, 0); + assert_eq!(breakdown.refundable_resource_fee, 0); + assert_eq!(breakdown.refundable_fee, 0); + assert_eq!(breakdown.non_refundable_fee, 0); + } + + #[test] + fn test_extract_fee_breakdown_soroban() { + let tx = Transaction { + source_account: MuxedAccount::Ed25519(Uint256([0; 32])), + fee: 500, + seq_num: SequenceNumber(1), + cond: Preconditions::None, + memo: Memo::None, + operations: vec![].try_into().unwrap(), + ext: TransactionExt::V0, + }; + let envelope = TransactionEnvelope::Tx(TransactionV1Envelope { + tx, + signatures: vec![].try_into().unwrap(), + }); + let envelope_xdr = envelope.to_xdr_base64().unwrap(); + + let result = TransactionResult { + fee_charged: 450, + result: TransactionResultResult::TxSuccess(vec![].try_into().unwrap()), + ext: stellar_xdr::curr::TransactionResultExt::V0, + }; + let result_xdr = result.to_xdr_base64().unwrap(); + + let meta = TransactionMeta::V3(TransactionMetaV3 { + ext: ExtensionPoint::V0, + tx_changes_before: vec![].try_into().unwrap(), + operations: vec![].try_into().unwrap(), + tx_changes_after: vec![].try_into().unwrap(), + soroban_meta: Some(SorobanTransactionMeta { + ext: SorobanTransactionMetaExt::V1(SorobanTransactionMetaExtV1 { + ext: ExtensionPoint::V0, + total_non_refundable_resource_fee_charged: 100, + total_refundable_resource_fee_charged: 200, + rent_fee_charged: 50, + }), + events: vec![].try_into().unwrap(), + return_value: stellar_xdr::curr::ScVal::Void, + diagnostic_events: vec![].try_into().unwrap(), + }), + }); + let meta_xdr = meta.to_xdr_base64().unwrap(); + + let tx_data = serde_json::json!({ + "envelopeXdr": envelope_xdr, + "resultXdr": result_xdr, + "resultMetaXdr": meta_xdr, + }); + + let breakdown = extract_fee_breakdown(&tx_data); + assert_eq!(breakdown.total_charged_fee, 450); + assert_eq!(breakdown.bid_fee, Some(500)); + assert_eq!(breakdown.resource_fee, 350); + assert_eq!(breakdown.inclusion_fee, 100); + assert_eq!(breakdown.refundable_resource_fee, 200); + assert_eq!(breakdown.refundable_fee, 250); + assert_eq!(breakdown.non_refundable_fee, 100); + } + + fn ed25519_auth_entry_b64(nonce: i64) -> String { + use stellar_xdr::curr::{ + AccountId, Hash, InvokeContractArgs, PublicKey, ScAddress, ScSymbol, ScVal, + SorobanAddressCredentials, SorobanAuthorizationEntry, SorobanAuthorizedFunction, + SorobanAuthorizedInvocation, SorobanCredentials, Uint256, + }; + let entry = SorobanAuthorizationEntry { + credentials: SorobanCredentials::Address(SorobanAddressCredentials { + address: ScAddress::Account(AccountId(PublicKey::PublicKeyTypeEd25519(Uint256( + [3u8; 32], + )))), + nonce, + signature_expiration_ledger: 100, + signature: ScVal::Void, + }), + root_invocation: SorobanAuthorizedInvocation { + function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs { + contract_address: ScAddress::Contract(Hash([9u8; 32])), + function_name: ScSymbol("transfer".try_into().unwrap()), + args: vec![].try_into().unwrap(), + }), + sub_invocations: vec![].try_into().unwrap(), + }, + }; + XdrCodec::to_xdr_base64(&entry).expect("encode") + } + + fn smart_wallet_auth_entry_b64(nonce: i64) -> String { + use stellar_xdr::curr::{ + Hash, InvokeContractArgs, ScAddress, ScSymbol, ScVal, SorobanAddressCredentials, + SorobanAuthorizationEntry, SorobanAuthorizedFunction, SorobanAuthorizedInvocation, + SorobanCredentials, + }; + let entry = SorobanAuthorizationEntry { + credentials: SorobanCredentials::Address(SorobanAddressCredentials { + address: ScAddress::Contract(Hash([5u8; 32])), + nonce, + signature_expiration_ledger: 200, + signature: ScVal::Void, + }), + root_invocation: SorobanAuthorizedInvocation { + function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs { + contract_address: ScAddress::Contract(Hash([8u8; 32])), + function_name: ScSymbol("invoke".try_into().unwrap()), + args: vec![].try_into().unwrap(), + }), + sub_invocations: vec![].try_into().unwrap(), + }, + }; + XdrCodec::to_xdr_base64(&entry).expect("encode") + } + + #[test] + fn extract_auth_entries_detects_ed25519() { + let b64 = ed25519_auth_entry_b64(42); + let tx_data = serde_json::json!({ "auth": [b64] }); + let entries = extract_auth_entries(&tx_data); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].auth_type, "Ed25519"); + assert!(entries[0].address.starts_with('G')); + assert!(entries[0].contract_id.is_none()); + } + + #[test] + fn extract_auth_entries_detects_smart_wallet() { + let b64 = smart_wallet_auth_entry_b64(99); + let tx_data = serde_json::json!({ "auth": [b64] }); + let entries = extract_auth_entries(&tx_data); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].auth_type, "Smart Wallet"); + assert!(entries[0].address.starts_with('C')); + let contract_id = entries[0] + .contract_id + .as_deref() + .expect("smart wallet must have contract_id"); + assert_eq!(contract_id, entries[0].address); + } + + #[test] + fn extract_auth_entries_handles_multiple_entries() { + let b64_ed = ed25519_auth_entry_b64(1); + let b64_sw = smart_wallet_auth_entry_b64(2); + let tx_data = serde_json::json!({ "auth": [b64_ed, b64_sw] }); + let entries = extract_auth_entries(&tx_data); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].auth_type, "Ed25519"); + assert_eq!(entries[1].auth_type, "Smart Wallet"); + } + + #[test] + fn extract_auth_entries_skips_invalid_payloads() { + let tx_data = serde_json::json!({ "auth": ["!!!not-valid-xdr!!!"] }); + let entries = extract_auth_entries(&tx_data); + + assert!(entries.is_empty()); + } + + #[test] + fn extract_auth_entries_empty_when_no_auth_field() { + let tx_data = serde_json::json!({ "hash": "abc123" }); + let entries = extract_auth_entries(&tx_data); + assert!(entries.is_empty()); + } + + #[test] + fn existing_ed25519_decoding_unchanged() { + let b64 = ed25519_auth_entry_b64(7); + let tx_data = serde_json::json!({ "auth": [b64] }); + + let sigs = extract_auth_signatures(&tx_data); + assert!(sigs.is_empty(), "no signature bytes in void-signed entry"); + } } diff --git a/crates/core/src/spec/decoder.rs b/crates/core/src/spec/decoder.rs index dff2ab17..e8e6d6c9 100644 --- a/crates/core/src/spec/decoder.rs +++ b/crates/core/src/spec/decoder.rs @@ -1,6 +1,8 @@ use crate::error::{GratError, GratResult}; use serde::{Deserialize, Serialize}; -use stellar_xdr::curr::{Limited, Limits, ReadXdr, ScSpecEntry, ScSpecTypeDef}; +use stellar_xdr::curr::{ + Limited, Limits, ReadXdr, ScSpecEntry, ScSpecTypeDef, ScSpecUdtStructV0, +}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ContractErrorEntry { @@ -109,206 +111,221 @@ pub struct ContractSpec { } pub fn decode_contract_spec(wasm_bytes: &[u8]) -> GratResult { - let raw_spec = SpecParser::extract_spec(wasm_bytes)?; + let raw_spec = match SpecParser::extract_spec(wasm_bytes) { + Ok(bytes) => bytes, + Err(_) => { + return Ok(ContractSpec { + errors: Vec::new(), + functions: Vec::new(), + structs: Vec::new(), + enums: Vec::new(), + unions: Vec::new(), + name: None, + version: None, + }); + } + }; let mut errors = Vec::new(); let mut functions = Vec::new(); let mut structs = Vec::new(); let mut enums = Vec::new(); let mut unions = Vec::new(); - let name = None; - let version = None; - - let mut cursor = std::io::Cursor::new(&raw_spec); - let mut limited = Limited::new(&mut cursor, Limits::none()); - while let Ok(entry) = ScSpecEntry::read_xdr(&mut limited) { - match entry { - ScSpecEntry::FunctionV0(func) => { - let func_name = func.name.to_string(); - let doc = if func.doc.is_empty() { - None - } else { - Some(func.doc.to_string()) - }; - - let mut params = Vec::new(); - let mut param_defs = Vec::new(); - for input in func.inputs.iter() { - let param_name = input.name.to_string(); - let param_type = format_type_def(&input.type_); - params.push((param_name.clone(), param_type)); - param_defs.push((param_name, input.type_.clone())); - } - let return_type_def = if func.outputs.is_empty() { - Some(ScSpecTypeDef::Void) - } else { - Some(func.outputs[0].clone()) - }; - - let return_type = if func.outputs.is_empty() { - "Void".to_string() - } else { - format_type_def(&func.outputs[0]) - }; - - functions.push(ContractFunction { - name: func_name, - params, - return_type, - doc, - return_type_def, - param_defs, - }); - } - ScSpecEntry::UdtErrorEnumV0(err_enum) => { - let enum_name = err_enum.name.to_string(); - for case in err_enum.cases.iter() { - let case_name = format!("{}::{}", enum_name, case.name); - let doc = if case.doc.is_empty() { + let cursor = std::io::Cursor::new(&raw_spec); + let mut limited = Limited::new(cursor, Limits::none()); + loop { + match ScSpecEntry::read_xdr(&mut limited) { + Ok(entry) => match entry { + ScSpecEntry::FunctionV0(func) => { + let func_name = func.name.to_string(); + let doc = if func.doc.is_empty() { None } else { - Some(case.doc.to_string()) + Some(func.doc.to_string()) + }; + + let mut params = Vec::new(); + let mut param_defs = Vec::new(); + for input in func.inputs.iter() { + let param_name = input.name.to_string(); + let param_type = format_type_def(&input.type_); + params.push((param_name.clone(), param_type)); + param_defs.push((param_name, input.type_.clone())); + } + + let return_type_def = if func.outputs.is_empty() { + Some(ScSpecTypeDef::Void) + } else { + Some(func.outputs[0].clone()) + }; + + let return_type = if func.outputs.is_empty() { + "Void".to_string() + } else { + format_type_def(&func.outputs[0]) }; - errors.push(ContractErrorEntry { - code: case.value, - name: case_name, + functions.push(ContractFunction { + name: func_name, + params, + return_type, doc, + return_type_def, + param_defs, }); } - } - ScSpecEntry::UdtEnumV0(enum_spec) => { - let enum_name = enum_spec.name.to_string(); - let doc = if enum_spec.doc.is_empty() { - None - } else { - Some(enum_spec.doc.to_string()) - }; - let mut cases = Vec::new(); - for case in enum_spec.cases.iter() { - let case_doc = if case.doc.is_empty() { + ScSpecEntry::UdtErrorEnumV0(err_enum) => { + let enum_name = err_enum.name.to_string(); + let doc = if err_enum.doc.is_empty() { None } else { - Some(case.doc.to_string()) + Some(err_enum.doc.to_string()) }; - cases.push(ContractEnumCase { - name: case.name.to_string(), - value: case.value, - doc: case_doc, + for case in err_enum.cases.iter() { + let case_doc = if case.doc.is_empty() { + doc.clone() + } else { + Some(case.doc.to_string()) + }; + errors.push(ContractErrorEntry { + code: case.value, + name: format!("{}::{}", enum_name, case.name), + doc: case_doc, + }); + } + } + ScSpecEntry::UdtEnumV0(enum_spec) => { + let enum_name = enum_spec.name.to_string(); + let doc = if enum_spec.doc.is_empty() { + None + } else { + Some(enum_spec.doc.to_string()) + }; + let mut cases = Vec::new(); + for case in enum_spec.cases.iter() { + let case_doc = if case.doc.is_empty() { + None + } else { + Some(case.doc.to_string()) + }; + cases.push(ContractEnumCase { + name: case.name.to_string(), + value: case.value, + doc: case_doc, + }); + } + enums.push(ContractEnumDef { + name: enum_name, + cases, + doc, }); } - enums.push(ContractEnumDef { - name: enum_name, - cases, - doc, - }); - } - ScSpecEntry::UdtUnionV0(union_spec) => { - let union_name = union_spec.name.to_string(); - let doc = if union_spec.doc.is_empty() { - None - } else { - Some(union_spec.doc.to_string()) - }; - let mut cases = Vec::new(); - for case in union_spec.cases.iter() { - match case { - stellar_xdr::curr::ScSpecUdtUnionCaseV0::VoidV0(c) => { - let case_doc = if c.doc.is_empty() { - None - } else { - Some(c.doc.to_string()) - }; - cases.push(ContractUnionCase { - name: c.name.to_string(), - doc: case_doc, - value_types: None, - fields: None, - }); - } - stellar_xdr::curr::ScSpecUdtUnionCaseV0::TupleV0(c) => { - let case_doc = if c.doc.is_empty() { - None - } else { - Some(c.doc.to_string()) - }; - let value_types: Vec = - c.type_.iter().cloned().collect(); - cases.push(ContractUnionCase { - name: c.name.to_string(), - doc: case_doc, - value_types: Some(value_types), - fields: None, - }); - } - stellar_xdr::curr::ScSpecUdtUnionCaseV0::StructV0(c) => { - let case_doc = if c.doc.is_empty() { - None - } else { - Some(c.doc.to_string()) - }; - let mut fields = Vec::new(); - for field in c.fields.iter() { - let field_doc = if field.doc.is_empty() { + ScSpecEntry::UdtUnionV0(union_spec) => { + let union_name = union_spec.name.to_string(); + let doc = if union_spec.doc.is_empty() { + None + } else { + Some(union_spec.doc.to_string()) + }; + let mut cases = Vec::new(); + for case in union_spec.cases.iter() { + match case { + stellar_xdr::curr::ScSpecUdtUnionCaseV0::VoidV0(c) => { + let case_doc = if c.doc.is_empty() { None } else { - Some(field.doc.to_string()) + Some(c.doc.to_string()) }; - fields.push(ContractStructField { - name: field.name.to_string(), - type_name: format_type_def(&field.type_), - doc: field_doc, - type_def: Some(field.type_.clone()), + cases.push(ContractUnionCase { + name: c.name.to_string(), + doc: case_doc, + value_types: None, + fields: None, + }); + } + stellar_xdr::curr::ScSpecUdtUnionCaseV0::TupleV0(c) => { + let case_doc = if c.doc.is_empty() { + None + } else { + Some(c.doc.to_string()) + }; + let value_types: Vec = + c.type_.iter().cloned().collect(); + cases.push(ContractUnionCase { + name: c.name.to_string(), + doc: case_doc, + value_types: Some(value_types), + fields: None, + }); + } + stellar_xdr::curr::ScSpecUdtUnionCaseV0::StructV0(c) => { + let case_doc = if c.doc.is_empty() { + None + } else { + Some(c.doc.to_string()) + }; + let mut fields = Vec::new(); + for field in c.fields.iter() { + let field_doc = if field.doc.is_empty() { + None + } else { + Some(field.doc.to_string()) + }; + fields.push(ContractStructField { + name: field.name.to_string(), + type_name: format_type_def(&field.type_), + doc: field_doc, + type_def: Some(field.type_.clone()), + }); + } + cases.push(ContractUnionCase { + name: c.name.to_string(), + doc: case_doc, + value_types: None, + fields: Some(fields), }); } - cases.push(ContractUnionCase { - name: c.name.to_string(), - doc: case_doc, - value_types: None, - fields: Some(fields), - }); } } + unions.push(ContractUnionDef { + name: union_name, + cases, + doc, + }); } - unions.push(ContractUnionDef { - name: union_name, - cases, - doc, - }); - } - ScSpecEntry::UdtStructV0(struct_spec) => { - let struct_name = struct_spec.name.to_string(); - let doc = if struct_spec.doc.is_empty() { - None - } else { - Some(struct_spec.doc.to_string()) - }; - - let mut fields = Vec::new(); - for field in struct_spec.fields.iter() { - let field_name = field.name.to_string(); - let field_type = format_type_def(&field.type_); - let field_doc = if field.doc.is_empty() { + ScSpecEntry::UdtStructV0(struct_spec) => { + let struct_name = struct_spec.name.to_string(); + let doc = if struct_spec.doc.is_empty() { None } else { - Some(field.doc.to_string()) + Some(struct_spec.doc.to_string()) }; - fields.push(ContractStructField { - name: field_name, - type_name: field_type, - doc: field_doc, - type_def: Some(field.type_.clone()), + + let mut fields = Vec::new(); + for field in struct_spec.fields.iter() { + let field_name = field.name.to_string(); + let field_type = format_type_def(&field.type_); + let field_doc = if field.doc.is_empty() { + None + } else { + Some(field.doc.to_string()) + }; + fields.push(ContractStructField { + name: field_name, + type_name: field_type, + doc: field_doc, + type_def: Some(field.type_.clone()), + }); + } + structs.push(ContractStructDef { + name: struct_name, + fields, + doc, }); } - - structs.push(ContractStructDef { - name: struct_name, - fields, - doc, - }); - } - _ => {} + }, + Err(_) => break, } } @@ -316,10 +333,10 @@ pub fn decode_contract_spec(wasm_bytes: &[u8]) -> GratResult { errors, functions, structs, - name, - version, enums, unions, + name: None, + version: None, }) } @@ -368,21 +385,105 @@ pub struct SpecParser; impl SpecParser { pub fn extract_spec(wasm_bytes: &[u8]) -> GratResult> { + Self::extract_raw_section(wasm_bytes, "contractspecv0") + } + + pub fn extract_raw_section(wasm_bytes: &[u8], section_name: &str) -> GratResult> { let parser = wasmparser::Parser::new(0); for payload in parser.parse_all(wasm_bytes) { - let payload = - payload.map_err(|e| GratError::SpecError(format!("WASM parse error: {e}")))?; + let payload = match payload { + Ok(p) => p, + Err(_) => { + continue; + } + }; if let wasmparser::Payload::CustomSection(section) = payload { - if section.name() == "contractspecv0" { + if section.name() == section_name { return Ok(section.data().to_vec()); } } } - Err(GratError::SpecError( - "contractspecv0 custom section not found".into(), - )) + Err(GratError::SpecError(format!( + "{section_name} custom section not found" + ))) + } + + pub fn extract_structs(wasm_bytes: &[u8]) -> GratResult> { + let raw_spec = match Self::extract_spec(wasm_bytes) { + Ok(bytes) => bytes, + Err(_) => return Ok(Vec::new()), + }; + + let mut structs = Vec::new(); + let cursor = std::io::Cursor::new(&raw_spec); + let mut limited = Limited::new(cursor, Limits::none()); + + loop { + match ScSpecEntry::read_xdr(&mut limited) { + Ok(entry) => { + if let ScSpecEntry::UdtStructV0(struct_spec) = entry { + let struct_name = struct_spec.name.to_string(); + let doc = if struct_spec.doc.is_empty() { + None + } else { + Some(struct_spec.doc.to_string()) + }; + + let mut fields = Vec::new(); + for field in struct_spec.fields.iter() { + let field_name = field.name.to_string(); + let field_type = format_type_def(&field.type_); + let field_doc = if field.doc.is_empty() { + None + } else { + Some(field.doc.to_string()) + }; + fields.push(ContractStructField { + name: field_name, + type_name: field_type, + doc: field_doc, + type_def: Some(field.type_.clone()), + }); + } + + structs.push(ContractStructDef { + name: struct_name, + fields, + doc, + }); + } + } + Err(_) => break, + } + } + + Ok(structs) + } + + pub fn extract_raw_structs(wasm_bytes: &[u8]) -> GratResult> { + let raw_spec = match Self::extract_spec(wasm_bytes) { + Ok(bytes) => bytes, + Err(_) => return Ok(Vec::new()), + }; + + let mut structs = Vec::new(); + let cursor = std::io::Cursor::new(&raw_spec); + let mut limited = Limited::new(cursor, Limits::none()); + + loop { + match ScSpecEntry::read_xdr(&mut limited) { + Ok(entry) => { + if let ScSpecEntry::UdtStructV0(struct_spec) = entry { + structs.push(struct_spec); + } + } + Err(_) => break, + } + } + + Ok(structs) } } @@ -390,6 +491,54 @@ pub fn resolve_error_code(spec: &ContractSpec, error_code: u32) -> Option<&Contr spec.errors.iter().find(|e| e.code == error_code) } +#[cfg(test)] +fn build_wasm_with_custom_section(section_name: &str, section_data: &[u8]) -> Vec { + let mut wasm = vec![0x00, 0x61, 0x73, 0x6D, 0x01, 0x00, 0x00, 0x00]; + let mut custom_payload = Vec::new(); + custom_payload.push(section_name.len() as u8); + custom_payload.extend_from_slice(section_name.as_bytes()); + custom_payload.extend_from_slice(section_data); + wasm.push(0); + wasm.push(custom_payload.len() as u8); + wasm.extend(custom_payload); + wasm +} + +#[cfg(test)] +fn make_struct_spec_entry( + name: &str, + doc: &str, + fields: Vec<(&str, &str, ScSpecTypeDef)>, +) -> ScSpecEntry { + use stellar_xdr::curr::{ScSpecUdtStructFieldV0, ScSpecUdtStructV0, VecM}; + + let struct_fields: Vec = fields + .into_iter() + .map(|(fname, fdoc, ftype)| ScSpecUdtStructFieldV0 { + doc: fdoc.try_into().unwrap(), + name: fname.try_into().unwrap(), + type_: ftype, + }) + .collect(); + + ScSpecEntry::UdtStructV0(ScSpecUdtStructV0 { + doc: doc.try_into().unwrap(), + name: name.try_into().unwrap(), + fields: struct_fields.try_into().unwrap(), + }) +} + +#[cfg(test)] +fn make_wasm_with_structs(structs: Vec) -> Vec { + use stellar_xdr::curr::{Limits, WriteXdr}; + let mut section_data = Vec::new(); + for entry in structs { + let bytes = entry.to_xdr(Limits::none()).unwrap(); + section_data.extend_from_slice(&bytes); + } + build_wasm_with_custom_section("contractspecv0", §ion_data) +} + #[cfg(test)] mod tests { use super::*; @@ -404,6 +553,8 @@ mod tests { }], functions: Vec::new(), structs: Vec::new(), + enums: Vec::new(), + unions: Vec::new(), name: None, version: None, }; @@ -413,19 +564,8 @@ mod tests { #[test] fn test_extract_spec_success() { - let mut wasm = vec![0x00, 0x61, 0x73, 0x6D, 0x01, 0x00, 0x00, 0x00]; - let section_name = "contractspecv0"; let section_data = vec![1, 2, 3, 4]; - - let mut custom_payload = Vec::new(); - custom_payload.push(section_name.len() as u8); - custom_payload.extend_from_slice(section_name.as_bytes()); - custom_payload.extend_from_slice(§ion_data); - - wasm.push(0); - wasm.push(custom_payload.len() as u8); - wasm.extend(custom_payload); - + let wasm = build_wasm_with_custom_section("contractspecv0", §ion_data); let result = SpecParser::extract_spec(&wasm).expect("Should find section"); assert_eq!(result, section_data); } @@ -440,4 +580,129 @@ mod tests { _ => panic!("Expected SpecError"), } } + + #[test] + fn test_extract_raw_section_custom_name() { + let section_data = vec![10, 20, 30]; + let wasm = build_wasm_with_custom_section("contractenvmetav0", §ion_data); + let result = + SpecParser::extract_raw_section(&wasm, "contractenvmetav0").expect("Should find section"); + assert_eq!(result, section_data); + } + + #[test] + fn test_extract_raw_section_not_found() { + let wasm = vec![0x00, 0x61, 0x73, 0x6D, 0x01, 0x00, 0x00, 0x00]; + let result = SpecParser::extract_raw_section(&wasm, "nonexistent"); + assert!(result.is_err()); + match result { + Err(GratError::SpecError(msg)) => assert!(msg.contains("nonexistent")), + _ => panic!("Expected SpecError"), + } + } + + #[test] + fn test_extract_structs_returns_empty_on_missing_section() { + let wasm = vec![0x00, 0x61, 0x73, 0x6D, 0x01, 0x00, 0x00, 0x00]; + let result = SpecParser::extract_structs(&wasm).expect("Should not error"); + assert!(result.is_empty()); + } + + #[test] + fn test_extract_structs_handles_empty_section() { + let wasm = build_wasm_with_custom_section("contractspecv0", &[]); + let result = SpecParser::extract_structs(&wasm).expect("Should not error on empty"); + assert!(result.is_empty()); + } + + #[test] + fn test_extract_structs_gracefully_handles_malformed_xdr() { + let wasm = build_wasm_with_custom_section("contractspecv0", &[0xFF, 0xFE, 0xFD, 0xFC]); + let result = SpecParser::extract_structs(&wasm).expect("Should handle malformed XDR"); + assert!(result.is_empty()); + } + + #[test] + fn test_extract_structs_parses_single_struct() { + let entry = make_struct_spec_entry( + "Balance", + "A user balance", + vec![ + ("amount", "The amount", ScSpecTypeDef::I128), + ("asset", "The asset code", ScSpecTypeDef::Symbol), + ], + ); + let wasm = make_wasm_with_structs(vec![entry]); + let result = SpecParser::extract_structs(&wasm).expect("Should parse struct"); + assert_eq!(result.len(), 1); + assert_eq!(result[0].name, "Balance"); + assert_eq!(result[0].doc.as_deref(), Some("A user balance")); + assert_eq!(result[0].fields.len(), 2); + assert_eq!(result[0].fields[0].name, "amount"); + assert_eq!(result[0].fields[0].type_name, "i128"); + assert_eq!(result[0].fields[1].name, "asset"); + assert_eq!(result[0].fields[1].type_name, "Symbol"); + } + + #[test] + fn test_extract_structs_skips_non_struct_entries() { + let entry = ScSpecEntry::FunctionV0(stellar_xdr::curr::ScSpecFunctionV0 { + name: "hello".try_into().unwrap(), + doc: "".try_into().unwrap(), + inputs: vec![].try_into().unwrap(), + outputs: vec![].try_into().unwrap(), + }); + let wasm = make_wasm_with_structs(vec![entry]); + let result = SpecParser::extract_structs(&wasm).expect("Should skip non-struct"); + assert!(result.is_empty()); + } + + #[test] + fn test_extract_raw_structs_returns_sc_spec_udt_struct_v0() { + let entry = make_struct_spec_entry( + "Voter", + "", + vec![("name", "", ScSpecTypeDef::String)], + ); + let wasm = make_wasm_with_structs(vec![entry]); + let result = SpecParser::extract_raw_structs(&wasm).expect("Should extract raw structs"); + assert_eq!(result.len(), 1); + assert_eq!(result[0].name.to_string(), "Voter"); + } + + #[test] + fn test_extract_raw_structs_returns_empty_on_missing_section() { + let wasm = vec![0x00, 0x61, 0x73, 0x6D, 0x01, 0x00, 0x00, 0x00]; + let result = SpecParser::extract_raw_structs(&wasm).expect("Should not error"); + assert!(result.is_empty()); + } + + #[test] + fn test_decode_contract_spec_includes_structs() { + let struct_entry = make_struct_spec_entry( + "Config", + "Contract configuration", + vec![("admin", "", ScSpecTypeDef::Address)], + ); + let wasm = make_wasm_with_structs(vec![struct_entry]); + let spec = decode_contract_spec(&wasm).expect("Should decode spec"); + assert_eq!(spec.structs.len(), 1); + assert_eq!(spec.structs[0].name, "Config"); + } + + #[test] + fn test_decode_contract_spec_returns_empty_on_missing_section() { + let wasm = vec![0x00, 0x61, 0x73, 0x6D, 0x01, 0x00, 0x00, 0x00]; + let spec = decode_contract_spec(&wasm).expect("Should not error"); + assert!(spec.structs.is_empty()); + assert!(spec.functions.is_empty()); + assert!(spec.errors.is_empty()); + } + + #[test] + fn test_decode_contract_spec_handles_malformed_xdr_gracefully() { + let wasm = build_wasm_with_custom_section("contractspecv0", &[0xFF; 32]); + let spec = decode_contract_spec(&wasm).expect("Should handle malformed XDR"); + assert!(spec.structs.is_empty()); + } } diff --git a/crates/core/src/spec/mod.rs b/crates/core/src/spec/mod.rs index c271b767..311306a4 100644 --- a/crates/core/src/spec/mod.rs +++ b/crates/core/src/spec/mod.rs @@ -6,3 +6,4 @@ pub use decoder::{ ContractStructDef, ContractStructField, ContractUnionCase, ContractUnionDef, SpecParser, }; pub use resolver::{ContractId, ResolverStats, SCSpecResolver}; +pub use stellar_xdr::curr::ScSpecUdtStructV0;