Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion pkg/beam-cli/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "beam-cli"
version = "0.2.1"
version = "0.2.2"
edition = "2024"
publish = false

Expand Down
18 changes: 18 additions & 0 deletions pkg/beam-cli/src/apps/error.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use std::error::Error as StdError;

use contextful::{FromContextful, InternalError};

use crate::apps::model::PrivacyCapability;
Expand Down Expand Up @@ -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
}
46 changes: 37 additions & 9 deletions pkg/beam-cli/src/apps/host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -363,10 +363,7 @@ pub async fn chain_read(
request: ChainReadRequest,
) -> Result<Value> {
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,
Expand All @@ -383,17 +380,24 @@ 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,
"label": resolved.label,
}))
}
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) => {
Expand All @@ -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?;
Expand All @@ -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?;
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions pkg/beam-cli/src/apps/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
4 changes: 2 additions & 2 deletions pkg/beam-cli/src/apps/runtime/guest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down Expand Up @@ -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 {
Expand Down
101 changes: 91 additions & 10 deletions pkg/beam-cli/src/tests/apps_host.rs
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
Loading