diff --git a/crates/cli/src/output/renderers.rs b/crates/cli/src/output/renderers.rs index 8e8dec64..5092801e 100644 --- a/crates/cli/src/output/renderers.rs +++ b/crates/cli/src/output/renderers.rs @@ -499,6 +499,88 @@ pub fn render_fee_breakdown(fee: &FeeBreakdown) -> String { out } +/// Render a human-readable summary of an [`AuthCredential`], distinguishing +/// Ed25519 accounts from Smart Wallet contracts. +/// +/// * **Ed25519** — shows the label, public key, and any decoded signature hexes. +/// * **Smart Wallet** — shows the label and the contract ID. +/// * **SourceAccount** — shows a brief note that the transaction source account authorized it. +pub fn render_auth_credential( + credential: &prism_core::AuthCredential, + signatures: &[String], +) -> String { + use prism_core::AuthCredential; + use prism_core::SignatureKind; + + let palette = ColorPalette::default(); + let mut out = String::new(); + + match credential { + AuthCredential::SourceAccount => { + out.push_str(&palette.metadata_text("Authorization Type: SourceAccount")); + out.push('\n'); + out.push_str(" (Authorized by the transaction source account — no separate signature)\n"); + } + AuthCredential::Address(addr_cred) => { + let kind = addr_cred.signature_kind(); + let type_label = kind.label(); + + out.push_str(&palette.accent_text(&format!("Authorization Type: {type_label}"))); + out.push('\n'); + + match kind { + SignatureKind::Ed25519 => { + out.push_str(&format!( + " Public Key: {}\n", + palette.success_text(&addr_cred.address) + )); + if signatures.is_empty() { + out.push_str(&format!( + " Signature: {}\n", + palette.muted_text("(none)") + )); + } else { + for sig in signatures { + out.push_str(&format!( + " Signature: {}\n", + palette.metadata_text(sig) + )); + } + } + } + SignatureKind::SmartWallet => { + out.push_str(&format!( + " Contract ID: {}\n", + palette.warning_text(&addr_cred.address) + )); + if !signatures.is_empty() { + for sig in signatures { + out.push_str(&format!( + " Auth Data: {}\n", + palette.metadata_text(sig) + )); + } + } + } + SignatureKind::Unknown => { + out.push_str(&format!( + " Address: {}\n", + palette.error_text(&addr_cred.address) + )); + } + } + + out.push_str(&format!(" Nonce: {}\n", addr_cred.nonce)); + out.push_str(&format!( + " Expires: ledger {}\n", + addr_cred.signature_expiration_ledger + )); + } + } + + out +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/core/src/decode/auth.rs b/crates/core/src/decode/auth.rs index 9aab5f17..a6db0e2a 100644 --- a/crates/core/src/decode/auth.rs +++ b/crates/core/src/decode/auth.rs @@ -10,6 +10,7 @@ use crate::error::PrismResult; use crate::xdr::codec::XdrCodec; +use crate::decode::auth_signature::SignatureKind; use serde::{Deserialize, Serialize}; use stellar_xdr::curr::{ AccountId, Hash, PublicKey, ScAddress, ScVal, SorobanAddressCredentials, @@ -41,6 +42,18 @@ pub struct AddressCredential { pub signed: bool, } +impl AddressCredential { + /// Detect whether this credential is an Ed25519 account or a Smart Wallet + /// contract by inspecting the address strkey prefix. + /// + /// * `G...` → [`SignatureKind::Ed25519`] + /// * `C...` → [`SignatureKind::SmartWallet`] + /// * anything else → [`SignatureKind::Unknown`] + pub fn signature_kind(&self) -> SignatureKind { + SignatureKind::from_address(&self.address) + } +} + /// The kind of host function a single invocation step authorizes. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -421,4 +434,56 @@ mod tests { fn invalid_base64_is_an_error() { assert!(AuthChain::from_xdr_base64("!!!not-valid!!!").is_err()); } + + // --- Issue #271: signature_kind() on AddressCredential --- + + #[test] + fn address_credential_ed25519_kind_from_account_address() { + use crate::decode::auth_signature::SignatureKind; + let entry = SorobanAuthorizationEntry { + credentials: SorobanCredentials::Address(SorobanAddressCredentials { + address: account_address(1), + nonce: 0, + signature_expiration_ledger: 0, + signature: ScVal::Void, + }), + root_invocation: invocation( + contract_fn(contract_address(2), "fn", vec![]), + empty_subs(), + ), + }; + let chain = AuthChain::from_entry(&entry); + match chain.credential { + AuthCredential::Address(cred) => { + assert_eq!(cred.signature_kind(), SignatureKind::Ed25519); + assert!(cred.address.starts_with('G')); + } + other => panic!("expected Address credential, got {other:?}"), + } + } + + #[test] + fn address_credential_smart_wallet_kind_from_contract_address() { + use crate::decode::auth_signature::SignatureKind; + let entry = SorobanAuthorizationEntry { + credentials: SorobanCredentials::Address(SorobanAddressCredentials { + address: contract_address(5), + nonce: 0, + signature_expiration_ledger: 0, + signature: ScVal::Void, + }), + root_invocation: invocation( + contract_fn(contract_address(6), "fn", vec![]), + empty_subs(), + ), + }; + let chain = AuthChain::from_entry(&entry); + match chain.credential { + AuthCredential::Address(cred) => { + assert_eq!(cred.signature_kind(), SignatureKind::SmartWallet); + assert!(cred.address.starts_with('C')); + } + other => panic!("expected Address credential, got {other:?}"), + } + } } diff --git a/crates/core/src/decode/auth_signature.rs b/crates/core/src/decode/auth_signature.rs index 41edb6b0..07903162 100644 --- a/crates/core/src/decode/auth_signature.rs +++ b/crates/core/src/decode/auth_signature.rs @@ -1,9 +1,81 @@ use base64::{engine::general_purpose::STANDARD, Engine as _}; +use serde::{Deserialize, Serialize}; use stellar_xdr::curr::{ Limits, ReadXdr, ScMap, ScVal, SorobanAddressCredentials, SorobanAuthorizationEntry, SorobanCredentials, }; +/// The kind of signer for a Soroban authorization credential. +/// +/// An address starting with `G` is a classic Ed25519 key-pair account. +/// An address starting with `C` is a smart contract (smart wallet). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SignatureKind { + /// Classic Ed25519 public-key account (`G...` strkey). + Ed25519, + /// Smart-wallet contract account (`C...` strkey). + SmartWallet, + /// Address format not recognised (should not occur in practice). + Unknown, +} + +impl SignatureKind { + /// Infer the kind from a strkey address string. + /// + /// * Addresses that start with `'G'` are Ed25519 accounts. + /// * Addresses that start with `'C'` are smart-wallet contracts. + /// * Everything else is `Unknown`. + pub fn from_address(address: &str) -> Self { + match address.chars().next() { + Some('G') => SignatureKind::Ed25519, + Some('C') => SignatureKind::SmartWallet, + _ => SignatureKind::Unknown, + } + } + + /// Human-readable label for display purposes. + pub fn label(self) -> &'static str { + match self { + SignatureKind::Ed25519 => "Ed25519", + SignatureKind::SmartWallet => "Smart Wallet", + SignatureKind::Unknown => "Unknown", + } + } +} + +/// Structured information about a single authorization credential, combining +/// the detected [`SignatureKind`] with the address (public key or contract ID) +/// and any decoded signature hex strings. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AuthSignatureInfo { + /// Whether this is an Ed25519 account or a smart-wallet contract. + pub kind: SignatureKind, + /// The authorizing address as a strkey. + /// + /// * For `Ed25519` this is the public key (`G...`). + /// * For `SmartWallet` this is the contract ID (`C...`). + pub address: String, + /// Decoded signature hex strings extracted from the credential payload. + /// Empty when no signatures are present (e.g. `SourceAccount` credentials). + pub signatures: Vec, +} + +impl AuthSignatureInfo { + /// Build an [`AuthSignatureInfo`] from a `SorobanAddressCredentials` value. + pub fn from_address_credentials(creds: &SorobanAddressCredentials) -> Self { + use crate::decode::auth::scaddress_to_strkey; + let address = scaddress_to_strkey(&creds.address); + let kind = SignatureKind::from_address(&address); + let signatures = extract_signatures_from_scval(&creds.signature); + Self { + kind, + address, + signatures, + } + } +} + /// Decode a single raw signature bytes value into a hex string. /// Returns an error label string if bytes are empty or not a valid 64-byte ed25519 signature. pub fn decode_signature_bytes(bytes: &[u8]) -> String { @@ -38,6 +110,32 @@ pub fn decode_auth_entry_signatures(auth_entry_b64: &str) -> Vec { } } +/// Decode a `SorobanAuthorizationEntry` XDR base64 string into an [`AuthSignatureInfo`], +/// detecting whether the credential is Ed25519 or Smart Wallet. +/// +/// Returns `None` for source-account credentials (which carry no address). +/// Returns `Err` for base64 / XDR decode failures. +pub fn decode_auth_entry_signature_info( + auth_entry_b64: &str, +) -> Result, String> { + let bytes = match STANDARD.decode(auth_entry_b64) { + Ok(b) => b, + Err(_) => return Err("".to_string()), + }; + + let entry = match SorobanAuthorizationEntry::from_xdr(&bytes, Limits::none()) { + Ok(e) => e, + Err(_) => return Err("".to_string()), + }; + + match &entry.credentials { + SorobanCredentials::SourceAccount => Ok(None), + SorobanCredentials::Address(creds) => { + Ok(Some(AuthSignatureInfo::from_address_credentials(creds))) + } + } +} + /// Recursively extract signature hex strings from a ScVal. /// Handles both a single ScBytes (direct signature) and a ScMap / ScVec of signatures. fn extract_signatures_from_scval(val: &ScVal) -> Vec { @@ -75,6 +173,40 @@ fn extract_signatures_from_scval(val: &ScVal) -> Vec { #[cfg(test)] mod tests { use super::*; + use stellar_xdr::curr::{ + AccountId, Hash, InvokeContractArgs, PublicKey, ScAddress, ScSymbol, + SorobanAuthorizedFunction, SorobanAuthorizedInvocation, Uint256, + }; + + fn account_address(seed: u8) -> ScAddress { + ScAddress::Account(AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([seed; 32])))) + } + + fn contract_address(seed: u8) -> ScAddress { + ScAddress::Contract(Hash([seed; 32])) + } + + fn make_auth_entry( + address: ScAddress, + signature: ScVal, + ) -> SorobanAuthorizationEntry { + SorobanAuthorizationEntry { + credentials: SorobanCredentials::Address(SorobanAddressCredentials { + address, + nonce: 0, + signature_expiration_ledger: 0, + signature, + }), + root_invocation: SorobanAuthorizedInvocation { + function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs { + contract_address: contract_address(1), + function_name: ScSymbol("f".try_into().unwrap()), + args: vec![].try_into().unwrap(), + }), + sub_invocations: vec![].try_into().unwrap(), + }, + } + } #[test] fn decode_valid_64_bytes() { @@ -109,4 +241,108 @@ mod tests { let result = decode_auth_entry_signatures("AAAA"); assert_eq!(result, vec![""]); } + + // --- Issue #271: SignatureKind detection tests --- + + #[test] + fn signature_kind_from_g_address_is_ed25519() { + let kind = SignatureKind::from_address("GABC..."); + assert_eq!(kind, SignatureKind::Ed25519); + assert_eq!(kind.label(), "Ed25519"); + } + + #[test] + fn signature_kind_from_c_address_is_smart_wallet() { + let kind = SignatureKind::from_address("CABC..."); + assert_eq!(kind, SignatureKind::SmartWallet); + assert_eq!(kind.label(), "Smart Wallet"); + } + + #[test] + fn signature_kind_from_unknown_prefix_is_unknown() { + let kind = SignatureKind::from_address("XABC..."); + assert_eq!(kind, SignatureKind::Unknown); + assert_eq!(kind.label(), "Unknown"); + } + + #[test] + fn signature_kind_from_empty_address_is_unknown() { + let kind = SignatureKind::from_address(""); + assert_eq!(kind, SignatureKind::Unknown); + } + + #[test] + fn auth_signature_info_ed25519_from_account_address() { + use crate::xdr::codec::XdrCodec; + let entry = make_auth_entry(account_address(3), ScVal::Void); + let b64 = XdrCodec::to_xdr_base64(&entry).expect("encode"); + let info = decode_auth_entry_signature_info(&b64) + .expect("no error") + .expect("address credential"); + assert_eq!(info.kind, SignatureKind::Ed25519); + assert!(info.address.starts_with('G'), "address should start with G, got {}", info.address); + } + + #[test] + fn auth_signature_info_smart_wallet_from_contract_address() { + use crate::xdr::codec::XdrCodec; + let entry = make_auth_entry(contract_address(7), ScVal::Void); + let b64 = XdrCodec::to_xdr_base64(&entry).expect("encode"); + let info = decode_auth_entry_signature_info(&b64) + .expect("no error") + .expect("address credential"); + assert_eq!(info.kind, SignatureKind::SmartWallet); + assert!(info.address.starts_with('C'), "address should start with C, got {}", info.address); + } + + #[test] + fn contract_id_extracted_for_smart_wallet() { + use crate::xdr::codec::XdrCodec; + let entry = make_auth_entry(contract_address(42), ScVal::Void); + let b64 = XdrCodec::to_xdr_base64(&entry).expect("encode"); + let info = decode_auth_entry_signature_info(&b64) + .expect("no error") + .expect("address credential"); + // Contract ID must be a valid strkey starting with 'C'. + assert!(info.address.starts_with('C')); + assert!(!info.address.is_empty()); + } + + #[test] + fn decode_auth_entry_signature_info_invalid_payload() { + let result = decode_auth_entry_signature_info("!!!bad-base64!!!"); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), ""); + } + + #[test] + fn decode_auth_entry_signature_info_source_account_returns_none() { + use crate::xdr::codec::XdrCodec; + use stellar_xdr::curr::InvokeContractArgs; + let entry = SorobanAuthorizationEntry { + credentials: SorobanCredentials::SourceAccount, + root_invocation: SorobanAuthorizedInvocation { + function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs { + contract_address: contract_address(1), + function_name: ScSymbol("g".try_into().unwrap()), + args: vec![].try_into().unwrap(), + }), + sub_invocations: vec![].try_into().unwrap(), + }, + }; + let b64 = XdrCodec::to_xdr_base64(&entry).expect("encode"); + let result = decode_auth_entry_signature_info(&b64).expect("no error"); + assert!(result.is_none(), "SourceAccount should yield None"); + } + + #[test] + fn existing_decode_auth_entry_signatures_unchanged() { + // Existing behavior: invalid base64 returns a single error label. + let result = decode_auth_entry_signatures("not-valid-base64!!!"); + assert_eq!(result, vec![""]); + + // Existing behavior: invalid XDR returns a single error label. + let result = decode_auth_entry_signatures("AAAA"); + assert_eq!(result, vec![""]); + } } diff --git a/crates/core/src/decode/mod.rs b/crates/core/src/decode/mod.rs index bf9d9ec6..74d8534b 100644 --- a/crates/core/src/decode/mod.rs +++ b/crates/core/src/decode/mod.rs @@ -2,7 +2,6 @@ pub mod auth; pub mod auth_address_nonce; pub mod auth_signature; -pub mod decode_context; pub mod contract_error; pub mod cross_contract; pub mod decode_context; @@ -16,6 +15,7 @@ pub use auth::{ AddressCredential, AuthChain, AuthCredential, AuthFunctionKind, AuthInvocation, }; pub use auth_address_nonce::AddressWithNonce; +pub use auth_signature::{AuthSignatureInfo, SignatureKind}; pub use walker::{ walk_diagnostic_events, DiagnosticEventKind, DiagnosticEventWalker, StructuredDiagnosticEvent, diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 1e07a8d6..7c7e1e64 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -20,8 +20,8 @@ pub use error::{PrismError, PrismResult}; pub use types::report::DiagnosticReport; pub use decode::{ walk_diagnostic_events, AddressCredential, AddressWithNonce, AuthChain, AuthCredential, - AuthFunctionKind, AuthInvocation, DiagnosticEventKind, DiagnosticEventWalker, - StructuredDiagnosticEvent, + AuthFunctionKind, AuthInvocation, AuthSignatureInfo, DiagnosticEventKind, + DiagnosticEventWalker, SignatureKind, StructuredDiagnosticEvent, }; pub const VERSION: &str = env!("CARGO_PKG_VERSION");