diff --git a/Cargo.lock b/Cargo.lock index cfa5d38..52cf71f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2083,7 +2083,7 @@ checksum = "0e050f626429857a27ddccb31e0aca21356bfa709c04041aefddac081a8f068a" [[package]] name = "beam-cli" -version = "0.2.1" +version = "0.2.2" dependencies = [ "argon2", "async-trait", diff --git a/pkg/beam-cli/Cargo.toml b/pkg/beam-cli/Cargo.toml index 45e9b41..29bacbc 100644 --- a/pkg/beam-cli/Cargo.toml +++ b/pkg/beam-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "beam-cli" -version = "0.2.1" +version = "0.2.2" edition = "2024" publish = false diff --git a/pkg/beam-cli/src/apps/error.rs b/pkg/beam-cli/src/apps/error.rs index cb5b242..1d4186e 100644 --- a/pkg/beam-cli/src/apps/error.rs +++ b/pkg/beam-cli/src/apps/error.rs @@ -1,3 +1,5 @@ +use std::error::Error as StdError; + use contextful::{FromContextful, InternalError}; use crate::apps::model::PrivacyCapability; @@ -137,3 +139,19 @@ pub enum Error { #[error("[beam-cli/apps] internal error")] Internal(#[from] InternalError), } + +pub(crate) fn format_error_chain(err: &Error) -> String { + let mut message = err.to_string(); + let mut source = StdError::source(err); + let mut previous_message = message.clone(); + while let Some(cause) = source { + let cause_message = cause.to_string(); + if !cause_message.is_empty() && !previous_message.contains(&cause_message) { + message.push_str("; caused by: "); + message.push_str(&cause_message); + } + previous_message = cause_message; + source = cause.source(); + } + message +} diff --git a/pkg/beam-cli/src/apps/host.rs b/pkg/beam-cli/src/apps/host.rs index 9866983..38eee14 100644 --- a/pkg/beam-cli/src/apps/host.rs +++ b/pkg/beam-cli/src/apps/host.rs @@ -19,7 +19,7 @@ use crate::{ permissions::{ensure_chain_scope, glob_matches}, }, evm::{erc20_allowance, erc20_balance, native_balance, simulate_calldata}, - runtime::BeamApp, + runtime::{BeamApp, ResolvedToken}, }; const HTTP_TIMEOUT: Duration = Duration::from_secs(15); @@ -363,10 +363,7 @@ pub async fn chain_read( request: ChainReadRequest, ) -> Result { ensure_chain_read_allowed(permissions, &request)?; - let (chain, client) = app - .active_chain_client() - .await - .context("connect beam app chain client")?; + let chain = app.active_chain().await.context("resolve beam app chain")?; if chain.entry.key != request.chain { return Err(Error::ChainPermissionDenied { chain: request.chain, @@ -383,10 +380,13 @@ pub async fn chain_read( })), ChainReadOperation::TokenMetadata => { let token = token_input(&request)?; - let resolved = app - .token_for_chain(token, &chain.entry.key) - .await - .context("resolve beam app token metadata")?; + let resolved = if is_native_token(token, &chain.entry.native_symbol) { + native_token_metadata(&chain.entry.native_symbol) + } else { + app.token_for_chain(token, &chain.entry.key) + .await + .context("resolve beam app token metadata")? + }; Ok(json!({ "address": format!("{:#x}", resolved.address), "decimals": resolved.decimals, @@ -394,6 +394,10 @@ pub async fn chain_read( })) } ChainReadOperation::Balance => { + let (_, client) = app + .active_chain_client() + .await + .context("connect beam app chain client")?; let owner = owner_address(app, request.owner.as_deref()).await?; let balance = match token_input_optional(&request) { Some(token) if is_native_token(token, &chain.entry.native_symbol) => { @@ -417,6 +421,10 @@ pub async fn chain_read( Ok(json!({ "balance": balance.to_string(), "owner": format!("{owner:#x}") })) } ChainReadOperation::Allowance => { + let (_, client) = app + .active_chain_client() + .await + .context("connect beam app chain client")?; let owner = owner_address(app, request.owner.as_deref()).await?; let spender = required_address("spender", request.spender.as_deref())?; let token = token_address(app, &chain.entry.key, &request).await?; @@ -431,6 +439,10 @@ pub async fn chain_read( })) } ChainReadOperation::Call => { + let (_, client) = app + .active_chain_client() + .await + .context("connect beam app chain client")?; let target = required_address("target", request.target.as_deref())?; let data = parse_hex_data(required("data", request.data.as_deref())?)?; let from = optional_owner_address(app, request.owner.as_deref()).await?; @@ -450,11 +462,19 @@ pub async fn chain_read( Ok(json!({ "raw": format!("0x{}", hex::encode(raw.0)) })) } ChainReadOperation::Nonce => { + let (_, client) = app + .active_chain_client() + .await + .context("connect beam app chain client")?; let owner = owner_address(app, request.owner.as_deref()).await?; let nonce = client.nonce(owner).await.context("fetch beam app nonce")?; Ok(json!({ "nonce": nonce.to_string(), "owner": format!("{owner:#x}") })) } ChainReadOperation::Gas => { + let (_, client) = app + .active_chain_client() + .await + .context("connect beam app chain client")?; let gas_price = client .fast_gas_price() .await @@ -489,6 +509,14 @@ pub async fn chain_read( } } +fn native_token_metadata(native_symbol: &str) -> ResolvedToken { + ResolvedToken { + address: Address::zero(), + decimals: Some(18), + label: native_symbol.to_string(), + } +} + pub fn ensure_transaction_allowed( permissions: &AppPermissions, transaction: &HostTransaction, diff --git a/pkg/beam-cli/src/apps/mod.rs b/pkg/beam-cli/src/apps/mod.rs index fcbad00..ccc0a7d 100644 --- a/pkg/beam-cli/src/apps/mod.rs +++ b/pkg/beam-cli/src/apps/mod.rs @@ -9,4 +9,5 @@ pub mod runtime; pub mod store; pub mod validate; +pub(crate) use error::format_error_chain; pub use error::{Error, Result}; diff --git a/pkg/beam-cli/src/apps/runtime/guest.rs b/pkg/beam-cli/src/apps/runtime/guest.rs index 40ad03d..4cf1394 100644 --- a/pkg/beam-cli/src/apps/runtime/guest.rs +++ b/pkg/beam-cli/src/apps/runtime/guest.rs @@ -9,7 +9,7 @@ use wasmi::{ use crate::{ apps::{ - Error, Result, + Error, Result, format_error_chain, host::{HostCallResponse, HostMetadata, HostRequest, handle_host_request}, model::AppPermissions, }, @@ -186,7 +186,7 @@ fn host_call( caller.data_mut().diagnostics = diagnostics; let response = match result { Ok(value) => HostCallResponse::ok(value), - Err(error) => HostCallResponse::error(error.to_string()), + Err(error) => HostCallResponse::error(format_error_chain(&error)), }; let response = serde_json::to_vec(&response).context("serialize beam app host response")?; if response.len() > response_capacity { diff --git a/pkg/beam-cli/src/tests/apps_host.rs b/pkg/beam-cli/src/tests/apps_host.rs index 7f1cb60..c274600 100644 --- a/pkg/beam-cli/src/tests/apps_host.rs +++ b/pkg/beam-cli/src/tests/apps_host.rs @@ -1,16 +1,27 @@ -use crate::apps::{ - approvals::{ensure_approval_executable, plan_hash}, - host::{ - ChainReadOperation, ChainReadRequest, HostTransaction, ensure_chain_read_allowed, - ensure_http_allowed, ensure_transaction_allowed, - }, - model::{ - ActionBinding, ActionPlan, AppPermissions, ApprovalRecord, ApprovalStatus, ChainOperation, - ChainPermission, HttpPermission, +// lint-long-file-override allow-max-lines=300 +use contextful::ErrorContextExt; + +use crate::{ + apps::{ + self, + approvals::{ensure_approval_executable, plan_hash}, + format_error_chain, + host::{ + ChainReadOperation, ChainReadRequest, HostTransaction, chain_read, + ensure_chain_read_allowed, ensure_http_allowed, ensure_transaction_allowed, + }, + model::{ + ActionBinding, ActionPlan, AppPermissions, ApprovalRecord, ApprovalStatus, + ChainOperation, ChainPermission, HttpPermission, + }, + store::now, }, - store::now, + error, + runtime::InvocationOverrides, }; +use super::fixtures::test_app; + #[test] fn host_http_permissions_allow_declared_https_and_reject_private_hosts() { let permissions = AppPermissions { @@ -93,6 +104,76 @@ fn host_chain_read_permissions_enforce_contract_scope() { ensure_chain_read_allowed(&permissions, &blocked).expect_err("reject blocked read target"); } +#[tokio::test] +async fn host_token_metadata_resolves_native_symbol_without_rpc() { + let (_temp_dir, app) = test_app(InvocationOverrides { + chain: Some("ethereum".to_string()), + rpc: Some("not a url".to_string()), + ..InvocationOverrides::default() + }) + .await; + let permissions = AppPermissions { + chains: vec![ChainPermission { + chain: "ethereum".to_string(), + contracts: None, + operations: vec![ChainOperation::Read], + selectors: None, + spenders: None, + }], + ..Default::default() + }; + + let response = chain_read( + &app, + &permissions, + ChainReadRequest { + address: None, + chain: "ethereum".to_string(), + data: None, + operation: ChainReadOperation::TokenMetadata, + owner: None, + selector: None, + spender: None, + target: Some("eth".to_string()), + token: Some("eth".to_string()), + value: None, + }, + ) + .await + .expect("resolve native token metadata"); + + assert_eq!( + response["address"].as_str(), + Some("0x0000000000000000000000000000000000000000") + ); + assert_eq!(response["decimals"].as_u64(), Some(18)); + assert_eq!(response["label"].as_str(), Some("ETH")); +} + +#[test] +fn app_host_error_chain_includes_internal_causes() { + let error = apps::Error::Internal( + error::Error::UnknownToken { + chain: "ethereum".to_string(), + token: "eth".to_string(), + } + .context("resolve beam app token metadata") + .into(), + ); + + let message = format_error_chain(&error); + + assert!(message.contains("[beam-cli/apps] internal error")); + assert!(message.contains("resolve beam app token metadata")); + assert!(message.contains("[beam-cli] token not configured on ethereum: eth")); + assert_eq!( + message + .matches("[beam-cli] token not configured on ethereum: eth") + .count(), + 1 + ); +} + #[test] fn host_transaction_permissions_allow_broad_optional_globs() { let permissions = AppPermissions {