diff --git a/Cargo.lock b/Cargo.lock index 02c9770..9ef713a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2088,6 +2088,7 @@ dependencies = [ "argon2", "async-trait", "base64 0.22.1", + "bip39", "clap", "contextful", "contracts", @@ -2280,6 +2281,17 @@ dependencies = [ "syn 2.0.112", ] +[[package]] +name = "bip39" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" +dependencies = [ + "bitcoin_hashes", + "serde", + "unicode-normalization", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -13985,6 +13997,7 @@ name = "workspace-hack" version = "0.1.0" dependencies = [ "actix-router", + "aead", "ahash", "aho-corasick", "allocator-api2", @@ -14015,6 +14028,7 @@ dependencies = [ "ark-std 0.5.0", "arrayvec", "async-compression", + "axum", "base64 0.13.1", "bindgen 0.71.1", "bitflags 2.10.0", @@ -14043,6 +14057,7 @@ dependencies = [ "darling_core 0.21.3", "dashmap", "data-encoding", + "der", "derive_more 2.1.1", "derive_more-impl 2.1.1", "digest 0.10.7", @@ -14114,6 +14129,7 @@ dependencies = [ "once_cell", "openssl", "openssl-sys", + "p256", "parity-scale-codec", "parking_lot 0.12.5", "percent-encoding", @@ -14166,6 +14182,7 @@ dependencies = [ "socket2 0.5.10", "socket2 0.6.1", "spin 0.9.8", + "spki", "strum 0.27.2", "subtle", "syn 1.0.109", @@ -14198,6 +14215,7 @@ dependencies = [ "windows-sys 0.60.2", "windows-sys 0.61.2", "winnow 0.7.14", + "x25519-dalek 2.0.1", "zeroize", "zstd", "zstd-safe", diff --git a/Cargo.toml b/Cargo.toml index b97a6d8..58e0c49 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,6 +48,8 @@ test-spy-macros = { path = "./pkg/test-spy-macros" } block-store = { path = "./pkg/block-store" } contracts = { path = "./pkg/contracts" } constants = { path = "./pkg/constants" } +contract-test = { path = "./pkg/contract-test" } +contract-test-macro = { path = "./pkg/contract-test-macro" } country = { path = "./pkg/country" } client-http = { path = "./pkg/client-http" } client-http-longpoll = { path = "./pkg/client-http-longpoll" } @@ -70,6 +72,14 @@ hash-poseidon = { path = "./pkg/hash-poseidon" } hmac-sha256-json = { path = "./pkg/hmac-sha256-json" } json-store = { path = "./pkg/json-store" } json-with-logging = { path = "./pkg/json-with-logging" } +jws-es256-interface = { path = "./pkg/jws-es256-interface" } +jws-es256-p256 = { path = "./pkg/jws-es256-p256" } +time-interface = { path = "./pkg/time-interface" } +time-system = { path = "./pkg/time-system" } +email-interface = { path = "./pkg/email-interface" } +email-memory = { path = "./pkg/email-memory" } +evm-json-rpc-interface = { path = "./pkg/evm-json-rpc-interface" } +evm-json-rpc-reqwest = { path = "./pkg/evm-json-rpc-reqwest" } serde_yaml = { path = "./pkg/serde_yaml" } node = { path = "./pkg/node" } node-client-http = { path = "./pkg/node-client-http" } @@ -102,6 +112,56 @@ posthog-interface = { path = "./pkg/posthog-interface" } posthog = { path = "./pkg/posthog" } kyc = { path = "./pkg/kyc" } primitives = { path = "./pkg/primitives" } +payy-auth-embedded-wallet-document = { path = "./pkg/payy-auth-embedded-wallet-document" } +payy-auth-app-admission-interface = { path = "./pkg/payy-auth-app-admission-interface" } +payy-auth-app-admission-catalog = { path = "./pkg/payy-auth-app-admission-catalog" } +payy-auth-app-catalog-interface = { path = "./pkg/payy-auth-app-catalog-interface" } +payy-auth-app-catalog-memory = { path = "./pkg/payy-auth-app-catalog-memory" } +payy-auth-app-catalog-server = { path = "./pkg/payy-auth-app-catalog-server" } +payy-auth-app-catalog-test-support = { path = "./pkg/payy-auth-app-catalog-test-support" } +payy-auth-app-client-scope-interface = { path = "./pkg/payy-auth-app-client-scope-interface" } +payy-auth-app-client-scope = { path = "./pkg/payy-auth-app-client-scope" } +payy-auth-client-access-interface = { path = "./pkg/payy-auth-client-access-interface" } +payy-auth-client-access-catalog = { path = "./pkg/payy-auth-client-access-catalog" } +payy-auth-client-access-test-support = { path = "./pkg/payy-auth-client-access-test-support" } +payy-auth-user-interface = { path = "./pkg/payy-auth-user-interface" } +payy-auth-session-interface = { path = "./pkg/payy-auth-session-interface" } +payy-auth-login-commit-interface = { path = "./pkg/payy-auth-login-commit-interface" } +payy-auth-login-commit-policy-catalog = { path = "./pkg/payy-auth-login-commit-policy-catalog" } +payy-auth-login-commit-test-support = { path = "./pkg/payy-auth-login-commit-test-support" } +payy-auth-refresh-session-core = { path = "./pkg/payy-auth-refresh-session-core" } +payy-auth-passwordless-interface = { path = "./pkg/payy-auth-passwordless-interface" } +payy-auth-passwordless-test-support = { path = "./pkg/payy-auth-passwordless-test-support" } +payy-auth-passwordless = { path = "./pkg/payy-auth-passwordless" } +payy-auth-passwordless-code-os-rng = { path = "./pkg/payy-auth-passwordless-code-os-rng" } +payy-auth-passwordless-challenge-memory = { path = "./pkg/payy-auth-passwordless-challenge-memory" } +payy-auth-passwordless-rate-limit-allow-all = { path = "./pkg/payy-auth-passwordless-rate-limit-allow-all" } +payy-auth-passwordless-policy-catalog = { path = "./pkg/payy-auth-passwordless-policy-catalog" } +payy-auth-passwordless-fail-closed = { path = "./pkg/payy-auth-passwordless-fail-closed" } +payy-auth-passwordless-email = { path = "./pkg/payy-auth-passwordless-email" } +payy-auth-session-memory = { path = "./pkg/payy-auth-session-memory" } +payy-auth-api-request-interface = { path = "./pkg/payy-auth-api-request-interface" } +payy-auth-api-gate = { path = "./pkg/payy-auth-api-gate" } +payy-auth-wallet-interface = { path = "./pkg/payy-auth-wallet-interface" } +payy-auth-wallet = { path = "./pkg/payy-auth-wallet" } +payy-auth-wallet-p256 = { path = "./pkg/payy-auth-wallet-p256" } +payy-auth-wallet-custody-local = { path = "./pkg/payy-auth-wallet-custody-local" } +payy-auth-wallet-storage-core = { path = "./pkg/payy-auth-wallet-storage-core" } +payy-auth-wallet-storage-json = { path = "./pkg/payy-auth-wallet-storage-json" } +payy-auth-wallet-storage-memory = { path = "./pkg/payy-auth-wallet-storage-memory" } +payy-auth-wallet-test-support = { path = "./pkg/payy-auth-wallet-test-support" } +payy-auth-wallet-transaction-evm-json-rpc = { path = "./pkg/payy-auth-wallet-transaction-evm-json-rpc" } +payy-auth-api-bin = { path = "./pkg/payy-auth-api-bin" } +payy-auth-api-http = { path = "./pkg/payy-auth-api-http" } +payy-auth-api-interface = { path = "./pkg/payy-auth-api-interface" } +payy-auth-api-test-support = { path = "./pkg/payy-auth-api-test-support" } +payy-auth-local = { path = "./pkg/payy-auth-local" } +payy-auth-runtime-system = { path = "./pkg/payy-auth-runtime-system" } +payy-auth-session = { path = "./pkg/payy-auth-session" } +payy-auth-session-test-support = { path = "./pkg/payy-auth-session-test-support" } +payy-auth-session-storage-memory = { path = "./pkg/payy-auth-session-storage-memory" } +payy-auth-backend-interface = { path = "./pkg/payy-auth-backend-interface" } +payy-auth-api-server = { path = "./pkg/payy-auth-api-server" } prover = { path = "./pkg/prover" } providers-interface = { path = "./pkg/providers-interface" } rpc = { path = "./pkg/rpc" } @@ -123,6 +183,7 @@ eip7702 = { path = "./pkg/eip7702" } payy_core = { path = "./pkg/payy_core" } payy_core_types = { path = "./pkg/payy_core_types" } payy-app-interface = { path = "./pkg/payy-app-interface" } +payy-auth-app-interface = { path = "./pkg/payy-auth-app-interface" } payy-app-wallet-mobile = { path = "./pkg/payy-app-wallet-mobile" } payy-note = { path = "./pkg/payy-note" } payy-evm-parse-link = { path = "./pkg/payy-evm-parse-link" } @@ -222,6 +283,7 @@ base64 = "0.22.1" bb_rs = { package = "polybase_bb_rs", git = "https://github.com/polybase/aztec-packages", rev = "14afe4c5e350ca8a650f8e5929385a27a0294275" } # v3.0.0-manual.20251030-polybase benchy = "0.1.1" bigdecimal = { version = "0.4.8", features = ["serde"] } +bip39 = "2.2.2" bitvec = "1.0.1" blake2b_simd = "1.0" bs58 = "0.5.1" @@ -262,6 +324,7 @@ futures-timer = "3.0.2" futures-util = "0.3.29" halo2curves = "0.1.0" hex = { version = "0.4", features = ["serde"] } +hpke = "0.13.0" hmac = "0.12" home = "0.5.11" indoc = "2" @@ -288,7 +351,9 @@ num-bigint = "0.4.6" num-traits = "0.2" openssl = { version = "0.10.75", default-features = false } once_cell = "1.19.0" +p256 = { version = "0.13.2", features = ["pkcs8", "pem"] } parking_lot = "0.12.1" +percent-encoding = "2.3.2" phonenumber = "0.3" pretty-hex = "0.3.0" proptest = "1.11.0" @@ -297,6 +362,7 @@ quote = "1.0" quickcheck = "1.0.3" rand = "0.8.5" rand_chacha = "0.3.1" +rand_chacha_09 = { package = "rand_chacha", version = "0.9.0" } rand_xorshift = "0.4" reqwest = { version = "0.12", features = ["json", "multipart", "stream"] } rlp = "0.6.1" @@ -310,6 +376,7 @@ sentry = "0.46.0" sentry-tracing = "0.46.2" serde = { version = "1.0.221", features = ["derive"] } serde_json = "1.0.145" +serde_json_canonicalizer = "0.3.2" serde_urlencoded = "0.7.1" serde_qs = "0.15.0" serde_bytes = "0.11.19" @@ -326,6 +393,7 @@ sha1 = "0.10.1" sha2 = "0.10.6" sha3 = "0.10.1" zstd = "0.13.3" +zeroize = "1" self-replace = "1.5.0" spinoff = "0.8.0" syn = { version = "2.0", features = ["full", "extra-traits"] } diff --git a/README.md b/README.md index 3f4562a..b472e08 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ Run the fast test wrapper during development to avoid rebuilding unaffected crat cargo xtask test ``` -The command detects workspace crates with local changes (and any dependents), builds tests once via `cargo test --workspace --no-run`, then runs only the compiled test binaries for the affected crates (changed first, then their dependents), exiting early if nothing relevant changed. +The command detects workspace crates with local changes (and any dependents), builds tests once via `cargo test --no-run -p -p ...`, then runs only the compiled test binaries for the affected crates (changed first, then their dependents), exiting early if nothing relevant changed. ### Revi diff --git a/beam-apps/README.md b/beam-apps/README.md index d1e3f7d..364ce0e 100644 --- a/beam-apps/README.md +++ b/beam-apps/README.md @@ -26,6 +26,11 @@ scripts/beam-app-registry/build.py scripts/beam-app-registry/verify.py ``` +The build step compiles command-capable app WASM and validates the Beam command +ABI before writing a bundle. A release artifact is rejected if it does not export +`memory`, `beam_alloc`, `beam_free`, and the manifest entrypoint, or if it does +not import `env.beam_host_call`. + Local registry server: ```bash diff --git a/beam-apps/apps/uniswap/README.md b/beam-apps/apps/uniswap/README.md index 0206a19..0da5a32 100644 --- a/beam-apps/apps/uniswap/README.md +++ b/beam-apps/apps/uniswap/README.md @@ -28,6 +28,13 @@ beam apps install uniswap --dry-run beam x uniswap swap [options] ``` +Command help is exported through the app manifest and rendered by Beam without +fetching a quote or invoking guest WASM: + +```bash +beam x uniswap swap --help +``` + Example: ```bash @@ -48,6 +55,9 @@ approve the final plan before Beam signs or submits anything. - `--unlimited-approval` asks Beam to request an unlimited ERC-20 approval instead of the default exact approval. +Token inputs can be Beam token labels, `native`, the active chain's native +symbol, or EVM token addresses. + ## How a Swap Works 1. The app fetches a quote through Beam-mediated HTTPS access. @@ -57,6 +67,12 @@ approve the final plan before Beam signs or submits anything. 5. Beam renders the typed plan, asks for approval, signs, submits, and tracks the receipt. +If an approval is required, Beam submits it first and submits the swap only after +the approval is confirmed. If fresh allowance already satisfies the exact plan, +Beam skips the approval step. Execution output reports confirmed, pending, +dropped, or skipped transaction state; confirmed receipts include the RPC status +value. + ## Agents Agents and other non-interactive callers should prepare a continuation, inspect diff --git a/beam-apps/apps/uniswap/src/error.rs b/beam-apps/apps/uniswap/src/error.rs index 0d95f78..0050b3d 100644 --- a/beam-apps/apps/uniswap/src/error.rs +++ b/beam-apps/apps/uniswap/src/error.rs @@ -25,4 +25,13 @@ pub enum Error { #[error("[beam-app-uniswap] address value is invalid: {value}")] InvalidAddress { value: String }, + + #[error("[beam-app-uniswap] host call failed: {message}")] + HostCallFailed { message: String }, + + #[error("[beam-app-uniswap] host response is invalid: {reason}")] + InvalidHostResponse { reason: String }, + + #[error("[beam-app-uniswap] serialization failed: {reason}")] + Serialization { reason: String }, } diff --git a/beam-apps/apps/uniswap/src/host.rs b/beam-apps/apps/uniswap/src/host.rs index 1b9143e..a86fb5c 100644 --- a/beam-apps/apps/uniswap/src/host.rs +++ b/beam-apps/apps/uniswap/src/host.rs @@ -1,5 +1,11 @@ +// lint-long-file-override allow-max-lines=450 use serde_json::Value; +use crate::{Error, Result, selector}; + +const HOST_API_VERSION: u32 = 1; +const HOST_RESPONSE_CAPACITY: usize = 2 * 1024 * 1024; + #[derive(Clone, Debug, Eq, PartialEq)] pub struct PlanContext { pub app_id: String, @@ -18,6 +24,40 @@ pub struct SwapToken { pub label: String, } +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct GuestInvocation { + pub args: Vec, + pub host_api_version: u32, + pub metadata: HostMetadata, + pub output_mode: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct HostMetadata { + pub app_id: String, + pub app_version: String, + pub chain: String, + pub chain_id: u64, + pub host_api_version: u32, + pub manifest_sha256: String, + pub now: u64, + pub wallet: String, + pub wasm_sha256: String, +} + +impl HostMetadata { + pub fn plan_context(&self) -> PlanContext { + PlanContext { + app_id: self.app_id.clone(), + app_version: self.app_version.clone(), + chain: self.chain.clone(), + manifest_sha256: self.manifest_sha256.clone(), + wallet: self.wallet.clone(), + wasm_sha256: self.wasm_sha256.clone(), + } + } +} + #[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] pub struct ActionPlan { pub app_id: String, @@ -53,3 +93,391 @@ pub struct ActionBinding { pub key: String, pub value: String, } + +#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "kebab-case")] +enum HostRequest { + HttpFetch(HttpFetchRequest), + ChainRead(ChainReadRequest), + SimulateTransaction(HostTransaction), + StructuredOutput { value: Value }, + Diagnostic { level: String, message: String }, + ResolveAddress { value: Option }, + AppStorageGet { key: String }, + AppStorageSet { key: String, value: Value }, + AppStorageRemove { key: String }, +} + +#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] +struct HttpFetchRequest { + method: String, + url: String, + headers: Vec, + body: Vec, +} + +#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] +struct HttpHeader { + name: String, + value: String, +} + +#[derive(Clone, Debug, serde::Deserialize)] +struct HttpFetchResponse { + body: Vec, + status: u16, + url: String, +} + +#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] +struct ChainReadRequest { + chain: String, + operation: ChainReadOperation, + address: Option, + data: Option, + owner: Option, + spender: Option, + target: Option, + token: Option, + value: Option, + selector: Option, +} + +#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "kebab-case")] +enum ChainReadOperation { + TokenMetadata, + Balance, + Allowance, + Gas, +} + +#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] +struct HostTransaction { + chain: String, + data: String, + target: String, + value: String, + selector: Option, + spender: Option, +} + +#[derive(Clone, Debug, serde::Deserialize)] +struct HostCallResponse { + ok: bool, + value: Option, + error: Option, +} + +#[derive(Clone, Debug, serde::Deserialize)] +struct TokenMetadataResponse { + address: String, + decimals: Option, + label: String, +} + +#[derive(Clone, Debug, serde::Deserialize)] +struct BalanceResponse { + balance: String, +} + +#[derive(Clone, Debug, serde::Deserialize)] +struct AllowanceResponse { + allowance: String, +} + +#[derive(Clone, Debug, serde::Deserialize)] +struct GasResponse { + gas_estimate: Option, + gas_price: String, +} + +#[derive(Clone, Debug, serde::Deserialize)] +struct ResolveAddressResponse { + address: String, +} + +#[derive(Clone, Debug, serde::Deserialize)] +struct StorageGetResponse { + exists: bool, + value: Option, +} + +pub fn ensure_host_abi(invocation: &GuestInvocation) -> Result<()> { + if invocation.host_api_version != HOST_API_VERSION + || invocation.metadata.host_api_version != HOST_API_VERSION + { + return Err(Error::InvalidHostResponse { + reason: format!( + "unsupported host abi version {}", + invocation.host_api_version + ), + }); + } + + Ok(()) +} + +pub fn http_json(method: &str, url: &str, value: &Value) -> Result { + let body = serde_json::to_vec(value).map_err(|err| Error::Serialization { + reason: err.to_string(), + })?; + let response = host_call(HostRequest::HttpFetch(HttpFetchRequest { + method: method.to_string(), + url: url.to_string(), + headers: vec![ + HttpHeader { + name: "content-type".to_string(), + value: "application/json".to_string(), + }, + HttpHeader { + name: "x-api-key".to_string(), + value: crate::public_api_key().to_string(), + }, + ], + body, + }))?; + let response = serde_json::from_value::(response).map_err(|err| { + Error::InvalidHostResponse { + reason: err.to_string(), + } + })?; + if !(200..300).contains(&response.status) { + return Err(Error::HostCallFailed { + message: format!("{} returned status {}", response.url, response.status), + }); + } + serde_json::from_slice(&response.body).map_err(|err| Error::InvalidHostResponse { + reason: err.to_string(), + }) +} + +pub fn resolve_address(value: Option<&str>) -> Result { + let response = host_call(HostRequest::ResolveAddress { + value: value.map(str::to_string), + })?; + Ok(parse_host_value::(response)?.address) +} + +pub fn token_metadata(chain: &str, token: &str) -> Result { + let response = chain_read(ChainReadRequest { + address: None, + chain: chain.to_string(), + data: None, + operation: ChainReadOperation::TokenMetadata, + owner: None, + selector: None, + spender: None, + target: Some(token.to_string()), + token: Some(token.to_string()), + value: None, + })?; + let response = parse_host_value::(response)?; + let decimals = response.decimals.ok_or_else(|| Error::InvalidHostResponse { + reason: format!("token {token} missing decimals"), + })?; + Ok(SwapToken { + is_native: is_native_token(&response.address, &response.label), + address: response.address, + decimals, + label: response.label, + }) +} + +pub fn balance(chain: &str, token: &str) -> Result { + let response = chain_read(ChainReadRequest { + address: None, + chain: chain.to_string(), + data: None, + operation: ChainReadOperation::Balance, + owner: None, + selector: None, + spender: None, + target: None, + token: Some(token.to_string()), + value: None, + })?; + Ok(parse_host_value::(response)?.balance) +} + +pub fn allowance(chain: &str, token: &str, spender: &str) -> Result { + let response = chain_read(ChainReadRequest { + address: None, + chain: chain.to_string(), + data: None, + operation: ChainReadOperation::Allowance, + owner: None, + selector: None, + spender: Some(spender.to_string()), + target: Some(token.to_string()), + token: Some(token.to_string()), + value: None, + })?; + Ok(parse_host_value::(response)?.allowance) +} + +pub fn gas(chain: &str, target: &str, data: &str, value: &str) -> Result<(Option, String)> { + let response = chain_read(ChainReadRequest { + address: None, + chain: chain.to_string(), + data: Some(data.to_string()), + operation: ChainReadOperation::Gas, + owner: None, + selector: selector(data), + spender: None, + target: Some(target.to_string()), + token: None, + value: Some(value.to_string()), + })?; + let response = parse_host_value::(response)?; + Ok((response.gas_estimate, response.gas_price)) +} + +pub fn simulate( + chain: &str, + target: &str, + data: &str, + value: &str, + spender: Option<&str>, +) -> Result<()> { + host_call(HostRequest::SimulateTransaction(HostTransaction { + chain: chain.to_string(), + data: data.to_string(), + selector: selector(data), + spender: spender.map(str::to_string), + target: target.to_string(), + value: value.to_string(), + }))?; + Ok(()) +} + +pub fn diagnostic(level: &str, message: &str) -> Result<()> { + host_call(HostRequest::Diagnostic { + level: level.to_string(), + message: message.to_string(), + })?; + Ok(()) +} + +#[expect( + dead_code, + reason = "storage is part of the app SDK even when Uniswap v1 does not persist data" +)] +pub fn storage_get(key: &str) -> Result> { + let response = host_call(HostRequest::AppStorageGet { + key: key.to_string(), + })?; + let response = parse_host_value::(response)?; + if response.exists { + Ok(response.value) + } else { + Ok(None) + } +} + +#[expect( + dead_code, + reason = "storage is part of the app SDK even when Uniswap v1 does not persist data" +)] +pub fn storage_set(key: &str, value: Value) -> Result<()> { + host_call(HostRequest::AppStorageSet { + key: key.to_string(), + value, + })?; + Ok(()) +} + +#[expect( + dead_code, + reason = "storage is part of the app SDK even when Uniswap v1 does not persist data" +)] +pub fn storage_remove(key: &str) -> Result<()> { + host_call(HostRequest::AppStorageRemove { + key: key.to_string(), + })?; + Ok(()) +} + +fn chain_read(request: ChainReadRequest) -> Result { + host_call(HostRequest::ChainRead(request)) +} + +fn host_call(request: HostRequest) -> Result { + let request = serde_json::to_vec(&request).map_err(|err| Error::Serialization { + reason: err.to_string(), + })?; + let mut response = vec![0_u8; HOST_RESPONSE_CAPACITY]; + let len = beam_host_call_wrapper(&request, &mut response)?; + let response = + serde_json::from_slice::(&response[..len]).map_err(|err| { + Error::InvalidHostResponse { + reason: err.to_string(), + } + })?; + if !response.ok { + return Err(Error::HostCallFailed { + message: response + .error + .unwrap_or_else(|| "host call failed without message".to_string()), + }); + } + response.value.ok_or_else(|| Error::InvalidHostResponse { + reason: "successful host response missing value".to_string(), + }) +} + +fn beam_host_call_wrapper(request: &[u8], response: &mut [u8]) -> Result { + #[cfg(target_arch = "wasm32")] + { + let len = unsafe { + beam_host_call( + request.as_ptr(), + request.len(), + response.as_mut_ptr(), + response.len(), + ) + }; + if len < 0 { + return Err(Error::HostCallFailed { + message: format!("host response exceeded buffer: {} bytes", -len), + }); + } + usize::try_from(len).map_err(|_| Error::InvalidHostResponse { + reason: format!("invalid host response length {len}"), + }) + } + + #[cfg(not(target_arch = "wasm32"))] + { + let _ = request; + let _ = response; + Err(Error::HostCallFailed { + message: "host calls are only available in wasm guest execution".to_string(), + }) + } +} + +fn parse_host_value(value: Value) -> Result +where + T: serde::de::DeserializeOwned, +{ + serde_json::from_value::(value).map_err(|err| Error::InvalidHostResponse { + reason: err.to_string(), + }) +} + +fn is_native_token(address: &str, label: &str) -> bool { + address.eq_ignore_ascii_case("0x0000000000000000000000000000000000000000") + || label.eq_ignore_ascii_case("native") + || label.eq_ignore_ascii_case("eth") +} + +#[cfg(target_arch = "wasm32")] +unsafe extern "C" { + fn beam_host_call( + request_ptr: *const u8, + request_len: usize, + response_ptr: *mut u8, + response_capacity: usize, + ) -> i32; +} diff --git a/beam-apps/apps/uniswap/src/lib.rs b/beam-apps/apps/uniswap/src/lib.rs index a6910b2..5792ecd 100644 --- a/beam-apps/apps/uniswap/src/lib.rs +++ b/beam-apps/apps/uniswap/src/lib.rs @@ -14,8 +14,9 @@ pub use api::{ }; pub use args::SwapArgs; pub use error::{Error, Result}; -pub use host::{ActionBinding, ActionPlan, ActionStep, PlanContext, SwapToken}; +pub use host::{ActionBinding, ActionPlan, ActionStep, GuestInvocation, PlanContext, SwapToken}; pub use plan::{SwapPlanInput, build_swap_plan}; +use serde_json::{Value, json}; #[cfg(test)] mod tests; @@ -35,6 +36,225 @@ pub extern "C" fn beam_uniswap_public_api_key_len() -> usize { } #[unsafe(no_mangle)] -pub extern "C" fn beam_app_main() { - let _ = core::hint::black_box(public_api_key()); +pub extern "C" fn beam_alloc(len: usize) -> *mut u8 { + let mut buffer = Vec::::with_capacity(len); + let ptr = buffer.as_mut_ptr(); + core::mem::forget(buffer); + ptr +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn beam_free(ptr: *mut u8, capacity: usize) { + if ptr.is_null() || capacity == 0 { + return; + } + unsafe { + let _ = Vec::::from_raw_parts(ptr, 0, capacity); + } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn beam_app_main(input_ptr: *const u8, input_len: usize) -> u64 { + let result = run_guest(input_ptr, input_len).unwrap_or_else(error_response); + pack_response(result) +} + +fn run_guest(input_ptr: *const u8, input_len: usize) -> Result { + if input_ptr.is_null() { + return Err(Error::InvalidHostResponse { + reason: "guest invocation pointer is null".to_string(), + }); + } + let input = unsafe { core::slice::from_raw_parts(input_ptr, input_len) }; + let invocation = + serde_json::from_slice::(input).map_err(|err| Error::Serialization { + reason: err.to_string(), + })?; + host::ensure_host_abi(&invocation)?; + let command = invocation + .args + .first() + .map(String::as_str) + .ok_or_else(|| Error::UnsupportedCommand { + command: "".to_string(), + })?; + match command { + "swap" => { + let plan = run_swap(invocation)?; + Ok(json!({ + "kind": "action-plan", + "plan": plan, + })) + } + other => Err(Error::UnsupportedCommand { + command: other.to_string(), + }), + } +} + +fn run_swap(invocation: GuestInvocation) -> Result { + let args = SwapArgs::parse(&invocation.args)?; + let chain = invocation.metadata.chain.clone(); + let wallet = invocation.metadata.wallet.clone(); + let recipient = host::resolve_address(args.recipient.as_deref())?; + let sell = host::token_metadata(&chain, &args.sell_token)?; + let buy = host::token_metadata(&chain, &args.buy_token)?; + let amount_raw = amount_to_raw(&args.amount, sell.decimals)?; + let min_receive_raw = args + .min_receive + .as_deref() + .map(|amount| amount_to_raw(amount, buy.decimals)) + .transpose()?; + let quote_request = QuoteRequest { + amount: amount_raw.clone(), + chain_id: invocation.metadata.chain_id, + recipient, + slippage_bps: args.slippage_bps, + token_in: sell.address.clone(), + token_out: buy.address.clone(), + wallet: wallet.clone(), + }; + let quote = parse_quote( + host::http_json( + "POST", + "https://trade-api.gateway.uniswap.org/v1/quote", + "e_payload("e_request), + )?, + "e_request, + )?; + let approval = if sell.is_native { + None + } else { + let value = host::http_json( + "POST", + "https://trade-api.gateway.uniswap.org/v1/check_approval", + &check_approval_payload("e_request), + )?; + Some(ApprovalResponse { + transaction: find_transaction(&value), + }) + }; + let allowance = approval + .as_ref() + .and_then(|approval| approval.transaction.as_ref()) + .and_then(|transaction| approval_spender(&transaction.data)) + .map(|spender| host::allowance(&chain, &sell.address, &spender)) + .transpose()?; + let swap_value = host::http_json( + "POST", + "https://trade-api.gateway.uniswap.org/v1/swap", + &swap_payload("e, &wallet), + )?; + let mut swap = SwapResponse { + transaction: find_transaction(&swap_value).ok_or_else(|| Error::InvalidUniswapResponse { + reason: "swap response missing transaction".to_string(), + })?, + raw: swap_value, + }; + if swap.transaction.gas_limit.is_none() || swap.transaction.gas_price.is_none() { + let (gas_limit, gas_price) = host::gas( + &chain, + &swap.transaction.to, + &swap.transaction.data, + &swap.transaction.value, + )?; + swap.transaction.gas_limit = swap.transaction.gas_limit.or(gas_limit); + swap.transaction.gas_price = swap.transaction.gas_price.or(Some(gas_price)); + } + simulate_best_effort(&chain, approval.as_ref(), &swap); + + build_swap_plan(SwapPlanInput { + allowance, + amount_raw, + args, + buy, + context: invocation.metadata.plan_context(), + expires_at: invocation.metadata.now, + min_receive_raw, + quote, + sell_balance: host::balance(&chain, &sell.address)?, + sell, + approval, + swap, + }) +} + +fn simulate_best_effort(chain: &str, approval: Option<&ApprovalResponse>, swap: &SwapResponse) { + if let Some(transaction) = approval + .and_then(|approval| approval.transaction.as_ref()) + .filter(|transaction| approval_spender(&transaction.data).is_some()) + { + let spender = approval_spender(&transaction.data); + if let Err(err) = host::simulate( + chain, + &transaction.to, + &transaction.data, + &transaction.value, + spender.as_deref(), + ) { + let _ = host::diagnostic("warn", &format!("approval simulation skipped: {err}")); + } + } + if let Err(err) = host::simulate( + chain, + &swap.transaction.to, + &swap.transaction.data, + &swap.transaction.value, + None, + ) { + let _ = host::diagnostic("warn", &format!("swap simulation skipped: {err}")); + } +} + +fn amount_to_raw(amount: &str, decimals: u8) -> Result { + let amount = amount.trim(); + let (whole, fractional) = amount.split_once('.').unwrap_or((amount, "")); + if whole.is_empty() || !whole.chars().all(|char| char.is_ascii_digit()) { + return Err(Error::InvalidInteger { + value: amount.to_string(), + }); + } + if fractional.len() > usize::from(decimals) + || !fractional.chars().all(|char| char.is_ascii_digit()) + { + return Err(Error::InvalidInteger { + value: amount.to_string(), + }); + } + let mut digits = whole.to_string(); + digits.push_str(fractional); + for _ in fractional.len()..usize::from(decimals) { + digits.push('0'); + } + let trimmed = digits.trim_start_matches('0'); + if trimmed.is_empty() { + Ok("0".to_string()) + } else { + Ok(trimmed.to_string()) + } +} + +fn error_response(error: Error) -> Value { + json!({ + "kind": "error", + "message": error.to_string(), + }) +} + +fn pack_response(value: Value) -> u64 { + let bytes = serde_json::to_vec(&value).unwrap_or_else(|err| { + format!( + r#"{{"kind":"error","message":"[beam-app-uniswap] serialization failed: {}"}}"#, + err + ) + .into_bytes() + }); + let ptr = beam_alloc(bytes.len()); + if ptr.is_null() { + return 0; + } + unsafe { + core::ptr::copy_nonoverlapping(bytes.as_ptr(), ptr, bytes.len()); + } + ((ptr as u64) << 32) | bytes.len() as u64 } diff --git a/beam-apps/fixtures/broad-wildcard/apps/uniswap/1.0.0/manifest.json b/beam-apps/fixtures/broad-wildcard/apps/uniswap/1.0.0/manifest.json index 80268e7..770cbe4 100644 --- a/beam-apps/fixtures/broad-wildcard/apps/uniswap/1.0.0/manifest.json +++ b/beam-apps/fixtures/broad-wildcard/apps/uniswap/1.0.0/manifest.json @@ -7,7 +7,7 @@ "description": "Prepare public Uniswap swaps through Beam-mediated HTTP, chain, approval, and transaction planning.", "min_beam_version": "0.1.2", "wasm": { - "sha256": "sha256:b155ff4bbbab293085ee393fc12cd9ddcda0e531694521909cd8b5c433ecb927", + "sha256": "sha256:15dadd2f783c783594c5c586f494f7b31be6dc89e5efbab1c479cf317b15ad0f", "entrypoint": "beam_app_main" }, "catalog": { @@ -219,6 +219,6 @@ "signature": { "algorithm": "sha256-dev", "key_id": "payy-dev-2026-05", - "value": "sha256:4d53155fe87d58d1a3bcbedc8f312b12a377eb7a4ba3956575fdf225f764b45a" + "value": "sha256:7606f4bb87567420778c2688804cb3cbfdc7d8dd49d712f10bb71177fabd4a0f" } } diff --git a/beam-apps/fixtures/broad-wildcard/apps/uniswap/1.0.0/manifest.json.sig b/beam-apps/fixtures/broad-wildcard/apps/uniswap/1.0.0/manifest.json.sig index 4748bf1..d16074d 100644 --- a/beam-apps/fixtures/broad-wildcard/apps/uniswap/1.0.0/manifest.json.sig +++ b/beam-apps/fixtures/broad-wildcard/apps/uniswap/1.0.0/manifest.json.sig @@ -1,5 +1,5 @@ { "algorithm": "sha256-dev", "key_id": "payy-dev-2026-05", - "value": "sha256:6fa02552dc669810b58d6fe9a55cc36546221c3b25cf9340ab6ff74388f372fb" + "value": "sha256:2f47a3e064fdc3572cc68eb52929147275f5e57fc958e82e59e8c2a5f21ab57e" } diff --git a/beam-apps/fixtures/broad-wildcard/apps/uniswap/1.0.0/module.wasm b/beam-apps/fixtures/broad-wildcard/apps/uniswap/1.0.0/module.wasm index a4fb777..1d2b3fd 100644 Binary files a/beam-apps/fixtures/broad-wildcard/apps/uniswap/1.0.0/module.wasm and b/beam-apps/fixtures/broad-wildcard/apps/uniswap/1.0.0/module.wasm differ diff --git a/beam-apps/fixtures/broad-wildcard/apps/uniswap/1.0.0/version.json.sig b/beam-apps/fixtures/broad-wildcard/apps/uniswap/1.0.0/version.json.sig index d6a752e..082e83b 100644 --- a/beam-apps/fixtures/broad-wildcard/apps/uniswap/1.0.0/version.json.sig +++ b/beam-apps/fixtures/broad-wildcard/apps/uniswap/1.0.0/version.json.sig @@ -1,5 +1,5 @@ { "algorithm": "sha256-dev", "key_id": "payy-dev-2026-05", - "value": "sha256:5b8c5aa5f89bf386efbbcdb3a8c670e24bf7a583c9ed0f9c327dedcdeb5a87f0" + "value": "sha256:d66c14c15fb72b7d083a8a61984e2356a130b521fb70e664edefcfabd08ed8dd" } diff --git a/beam-apps/fixtures/broad-wildcard/catalog/apps/uniswap.json b/beam-apps/fixtures/broad-wildcard/catalog/apps/uniswap.json index 45413a2..a6fca62 100644 --- a/beam-apps/fixtures/broad-wildcard/catalog/apps/uniswap.json +++ b/beam-apps/fixtures/broad-wildcard/catalog/apps/uniswap.json @@ -271,7 +271,7 @@ "sensitive_args": [] } ], - "readme_markdown": "# Uniswap App\n\nThe Uniswap app turns a swap request into a Beam-approved action plan. It asks\nthe Uniswap Trading API for a quote, checks your current ERC-20 allowance,\nprepares an exact approval only when one is needed, builds the swap transaction,\nand hands the whole plan to Beam for approval and execution.\n\nBeam owns your wallet boundary. The app never receives private keys, cannot sign\ntransactions, and cannot send a transaction on its own.\n\n## Install\n\n```bash\nbeam apps install uniswap\n```\n\nBeam shows the publisher, version, supported chains, network access, wallet\ncapabilities, and storage permissions before activating the app. To inspect the\nsame permission summary without installing:\n\n```bash\nbeam apps install uniswap --dry-run\n```\n\n## Swap\n\n```bash\nbeam x uniswap swap [options]\n```\n\nExample:\n\n```bash\nbeam x uniswap swap USDC ETH 100 --chain base --from alice\n```\n\nBeam shows the quote, any required approval, and the swap as a single plan. You\napprove the final plan before Beam signs or submits anything.\n\n## Options\n\n- `--min-receive ` sets the minimum acceptable output amount.\n- `--max-gas ` rejects the plan if estimated gas exceeds the limit.\n- `--slippage-bps ` sets max slippage in basis points.\n- `--recipient ` sends output to another wallet, ENS name, or\n EVM address.\n- `--deadline-seconds ` sets the quote and transaction deadline window.\n- `--unlimited-approval` asks Beam to request an unlimited ERC-20 approval\n instead of the default exact approval.\n\n## How a Swap Works\n\n1. The app fetches a quote through Beam-mediated HTTPS access.\n2. Beam reads your token balance and allowance through its chain APIs.\n3. If allowance is short, Beam adds an exact ERC-20 approval step.\n4. The app prepares the swap transaction.\n5. Beam renders the typed plan, asks for approval, signs, submits, and tracks the\n receipt.\n\n## Agents\n\nAgents and other non-interactive callers should prepare a continuation, inspect\nit, then explicitly approve and execute it:\n\n```bash\nbeam x uniswap swap USDC ETH 100 --chain base --from alice --prepare --format json\nbeam apps approvals show \nbeam apps approvals approve --execute\n```\n\n`--no-prompt` fails closed for wallet-affecting swaps unless the command is\npreparing a continuation or executing an already-approved continuation.\n\n## Permissions\n\nThe app requests HTTPS access to the Uniswap Trading API, read/simulate/send\naccess on supported public EVM chains, ERC-20 approval planning, wallet balance\nreads, transaction proposals, and app-local storage. It does not request Beam\nprivacy capabilities in v1.\n\nSupported chains are Ethereum, Base, Arbitrum, Polygon, BNB, and Sepolia.\n\nApprovals default to the exact amount required. Unlimited approvals require the\nexplicit `--unlimited-approval` flag and are shown in the Beam approval prompt.\n", + "readme_markdown": "# Uniswap App\n\nThe Uniswap app turns a swap request into a Beam-approved action plan. It asks\nthe Uniswap Trading API for a quote, checks your current ERC-20 allowance,\nprepares an exact approval only when one is needed, builds the swap transaction,\nand hands the whole plan to Beam for approval and execution.\n\nBeam owns your wallet boundary. The app never receives private keys, cannot sign\ntransactions, and cannot send a transaction on its own.\n\n## Install\n\n```bash\nbeam apps install uniswap\n```\n\nBeam shows the publisher, version, supported chains, network access, wallet\ncapabilities, and storage permissions before activating the app. To inspect the\nsame permission summary without installing:\n\n```bash\nbeam apps install uniswap --dry-run\n```\n\n## Swap\n\n```bash\nbeam x uniswap swap [options]\n```\n\nCommand help is exported through the app manifest and rendered by Beam without\nfetching a quote or invoking guest WASM:\n\n```bash\nbeam x uniswap swap --help\n```\n\nExample:\n\n```bash\nbeam x uniswap swap USDC ETH 100 --chain base --from alice\n```\n\nBeam shows the quote, any required approval, and the swap as a single plan. You\napprove the final plan before Beam signs or submits anything.\n\n## Options\n\n- `--min-receive ` sets the minimum acceptable output amount.\n- `--max-gas ` rejects the plan if estimated gas exceeds the limit.\n- `--slippage-bps ` sets max slippage in basis points.\n- `--recipient ` sends output to another wallet, ENS name, or\n EVM address.\n- `--deadline-seconds ` sets the quote and transaction deadline window.\n- `--unlimited-approval` asks Beam to request an unlimited ERC-20 approval\n instead of the default exact approval.\n\nToken inputs can be Beam token labels, `native`, the active chain's native\nsymbol, or EVM token addresses.\n\n## How a Swap Works\n\n1. The app fetches a quote through Beam-mediated HTTPS access.\n2. Beam reads your token balance and allowance through its chain APIs.\n3. If allowance is short, Beam adds an exact ERC-20 approval step.\n4. The app prepares the swap transaction.\n5. Beam renders the typed plan, asks for approval, signs, submits, and tracks the\n receipt.\n\nIf an approval is required, Beam submits it first and submits the swap only after\nthe approval is confirmed. If fresh allowance already satisfies the exact plan,\nBeam skips the approval step. Execution output reports confirmed, pending,\ndropped, or skipped transaction state; confirmed receipts include the RPC status\nvalue.\n\n## Agents\n\nAgents and other non-interactive callers should prepare a continuation, inspect\nit, then explicitly approve and execute it:\n\n```bash\nbeam x uniswap swap USDC ETH 100 --chain base --from alice --prepare --format json\nbeam apps approvals show \nbeam apps approvals approve --execute\n```\n\n`--no-prompt` fails closed for wallet-affecting swaps unless the command is\npreparing a continuation or executing an already-approved continuation.\n\n## Permissions\n\nThe app requests HTTPS access to the Uniswap Trading API, read/simulate/send\naccess on supported public EVM chains, ERC-20 approval planning, wallet balance\nreads, transaction proposals, and app-local storage. It does not request Beam\nprivacy capabilities in v1.\n\nSupported chains are Ethereum, Base, Arbitrum, Polygon, BNB, and Sepolia.\n\nApprovals default to the exact amount required. Unlimited approvals require the\nexplicit `--unlimited-approval` flag and are shown in the Beam approval prompt.\n", "manifest_summary": { "format_version": 1, "min_beam_version": "0.1.2", @@ -293,6 +293,6 @@ "signature": { "algorithm": "sha256-dev", "key_id": "payy-dev-2026-05", - "value": "sha256:751da26b235d861af214324b036ba5ee5c20062e80e66f6104cd78ba7b2672de" + "value": "sha256:cf1ff80f294be2c782b248b643bf45ad2aa8d1c45d4fd48ef3e160e601037b42" } } diff --git a/beam-apps/fixtures/broad-wildcard/catalog/apps/uniswap.json.sig b/beam-apps/fixtures/broad-wildcard/catalog/apps/uniswap.json.sig index 486afc8..add7d00 100644 --- a/beam-apps/fixtures/broad-wildcard/catalog/apps/uniswap.json.sig +++ b/beam-apps/fixtures/broad-wildcard/catalog/apps/uniswap.json.sig @@ -1,5 +1,5 @@ { "algorithm": "sha256-dev", "key_id": "payy-dev-2026-05", - "value": "sha256:751da26b235d861af214324b036ba5ee5c20062e80e66f6104cd78ba7b2672de" + "value": "sha256:cf1ff80f294be2c782b248b643bf45ad2aa8d1c45d4fd48ef3e160e601037b42" } diff --git a/beam-apps/fixtures/broad-wildcard/index.json b/beam-apps/fixtures/broad-wildcard/index.json index 4ace360..872607e 100644 --- a/beam-apps/fixtures/broad-wildcard/index.json +++ b/beam-apps/fixtures/broad-wildcard/index.json @@ -12,13 +12,13 @@ "version": "1.0.0", "min_beam_version": "0.1.2", "manifest_url": "https://registry.beam.payy.network/apps/uniswap/1.0.0/manifest.json", - "manifest_sha256": "sha256:9b1e84f51ef0e5afd88a79aaaaa9ebf24b8d15a0a321c0ffa93c6da0ba5c8ede", + "manifest_sha256": "sha256:679a551bf48519a586975b25385e310519b077853383a4b3f7c2ff7b66ca3470", "module_url": "https://registry.beam.payy.network/apps/uniswap/1.0.0/module.wasm", - "module_sha256": "sha256:b155ff4bbbab293085ee393fc12cd9ddcda0e531694521909cd8b5c433ecb927", + "module_sha256": "sha256:15dadd2f783c783594c5c586f494f7b31be6dc89e5efbab1c479cf317b15ad0f", "signature": { "algorithm": "sha256-dev", "key_id": "payy-dev-2026-05", - "value": "sha256:5b8c5aa5f89bf386efbbcdb3a8c670e24bf7a583c9ed0f9c327dedcdeb5a87f0" + "value": "sha256:d66c14c15fb72b7d083a8a61984e2356a130b521fb70e664edefcfabd08ed8dd" } } ] @@ -27,6 +27,6 @@ "signature": { "algorithm": "sha256-dev", "key_id": "payy-dev-2026-05", - "value": "sha256:de6a24df0daabff15742faa1754c8c254a1d4157a55107766cee0a873e914411" + "value": "sha256:40a78ac42754c32d9e50aab13e98882852a73f14155a648919d1fdae928e78a8" } } diff --git a/beam-apps/fixtures/broad-wildcard/index.json.sig b/beam-apps/fixtures/broad-wildcard/index.json.sig index c10b635..9c8b5f1 100644 --- a/beam-apps/fixtures/broad-wildcard/index.json.sig +++ b/beam-apps/fixtures/broad-wildcard/index.json.sig @@ -1,5 +1,5 @@ { "algorithm": "sha256-dev", "key_id": "payy-dev-2026-05", - "value": "sha256:de6a24df0daabff15742faa1754c8c254a1d4157a55107766cee0a873e914411" + "value": "sha256:40a78ac42754c32d9e50aab13e98882852a73f14155a648919d1fdae928e78a8" } diff --git a/beam-apps/fixtures/invalid-digest/apps/uniswap/1.0.0/manifest.json b/beam-apps/fixtures/invalid-digest/apps/uniswap/1.0.0/manifest.json index 76c5ba2..a953177 100644 --- a/beam-apps/fixtures/invalid-digest/apps/uniswap/1.0.0/manifest.json +++ b/beam-apps/fixtures/invalid-digest/apps/uniswap/1.0.0/manifest.json @@ -7,7 +7,7 @@ "description": "Prepare public Uniswap swaps through Beam-mediated HTTP, chain, approval, and transaction planning.", "min_beam_version": "0.1.2", "wasm": { - "sha256": "sha256:b155ff4bbbab293085ee393fc12cd9ddcda0e531694521909cd8b5c433ecb927", + "sha256": "sha256:15dadd2f783c783594c5c586f494f7b31be6dc89e5efbab1c479cf317b15ad0f", "entrypoint": "beam_app_main" }, "catalog": { @@ -290,6 +290,6 @@ "signature": { "algorithm": "sha256-dev", "key_id": "payy-dev-2026-05", - "value": "sha256:6fa02552dc669810b58d6fe9a55cc36546221c3b25cf9340ab6ff74388f372fb" + "value": "sha256:2f47a3e064fdc3572cc68eb52929147275f5e57fc958e82e59e8c2a5f21ab57e" } } diff --git a/beam-apps/fixtures/invalid-digest/apps/uniswap/1.0.0/manifest.json.sig b/beam-apps/fixtures/invalid-digest/apps/uniswap/1.0.0/manifest.json.sig index 4748bf1..d16074d 100644 --- a/beam-apps/fixtures/invalid-digest/apps/uniswap/1.0.0/manifest.json.sig +++ b/beam-apps/fixtures/invalid-digest/apps/uniswap/1.0.0/manifest.json.sig @@ -1,5 +1,5 @@ { "algorithm": "sha256-dev", "key_id": "payy-dev-2026-05", - "value": "sha256:6fa02552dc669810b58d6fe9a55cc36546221c3b25cf9340ab6ff74388f372fb" + "value": "sha256:2f47a3e064fdc3572cc68eb52929147275f5e57fc958e82e59e8c2a5f21ab57e" } diff --git a/beam-apps/fixtures/invalid-digest/apps/uniswap/1.0.0/module.wasm b/beam-apps/fixtures/invalid-digest/apps/uniswap/1.0.0/module.wasm index a4fb777..1d2b3fd 100644 Binary files a/beam-apps/fixtures/invalid-digest/apps/uniswap/1.0.0/module.wasm and b/beam-apps/fixtures/invalid-digest/apps/uniswap/1.0.0/module.wasm differ diff --git a/beam-apps/fixtures/invalid-digest/apps/uniswap/1.0.0/version.json.sig b/beam-apps/fixtures/invalid-digest/apps/uniswap/1.0.0/version.json.sig index d6a752e..082e83b 100644 --- a/beam-apps/fixtures/invalid-digest/apps/uniswap/1.0.0/version.json.sig +++ b/beam-apps/fixtures/invalid-digest/apps/uniswap/1.0.0/version.json.sig @@ -1,5 +1,5 @@ { "algorithm": "sha256-dev", "key_id": "payy-dev-2026-05", - "value": "sha256:5b8c5aa5f89bf386efbbcdb3a8c670e24bf7a583c9ed0f9c327dedcdeb5a87f0" + "value": "sha256:d66c14c15fb72b7d083a8a61984e2356a130b521fb70e664edefcfabd08ed8dd" } diff --git a/beam-apps/fixtures/invalid-digest/catalog/apps/uniswap.json b/beam-apps/fixtures/invalid-digest/catalog/apps/uniswap.json index 45413a2..a6fca62 100644 --- a/beam-apps/fixtures/invalid-digest/catalog/apps/uniswap.json +++ b/beam-apps/fixtures/invalid-digest/catalog/apps/uniswap.json @@ -271,7 +271,7 @@ "sensitive_args": [] } ], - "readme_markdown": "# Uniswap App\n\nThe Uniswap app turns a swap request into a Beam-approved action plan. It asks\nthe Uniswap Trading API for a quote, checks your current ERC-20 allowance,\nprepares an exact approval only when one is needed, builds the swap transaction,\nand hands the whole plan to Beam for approval and execution.\n\nBeam owns your wallet boundary. The app never receives private keys, cannot sign\ntransactions, and cannot send a transaction on its own.\n\n## Install\n\n```bash\nbeam apps install uniswap\n```\n\nBeam shows the publisher, version, supported chains, network access, wallet\ncapabilities, and storage permissions before activating the app. To inspect the\nsame permission summary without installing:\n\n```bash\nbeam apps install uniswap --dry-run\n```\n\n## Swap\n\n```bash\nbeam x uniswap swap [options]\n```\n\nExample:\n\n```bash\nbeam x uniswap swap USDC ETH 100 --chain base --from alice\n```\n\nBeam shows the quote, any required approval, and the swap as a single plan. You\napprove the final plan before Beam signs or submits anything.\n\n## Options\n\n- `--min-receive ` sets the minimum acceptable output amount.\n- `--max-gas ` rejects the plan if estimated gas exceeds the limit.\n- `--slippage-bps ` sets max slippage in basis points.\n- `--recipient ` sends output to another wallet, ENS name, or\n EVM address.\n- `--deadline-seconds ` sets the quote and transaction deadline window.\n- `--unlimited-approval` asks Beam to request an unlimited ERC-20 approval\n instead of the default exact approval.\n\n## How a Swap Works\n\n1. The app fetches a quote through Beam-mediated HTTPS access.\n2. Beam reads your token balance and allowance through its chain APIs.\n3. If allowance is short, Beam adds an exact ERC-20 approval step.\n4. The app prepares the swap transaction.\n5. Beam renders the typed plan, asks for approval, signs, submits, and tracks the\n receipt.\n\n## Agents\n\nAgents and other non-interactive callers should prepare a continuation, inspect\nit, then explicitly approve and execute it:\n\n```bash\nbeam x uniswap swap USDC ETH 100 --chain base --from alice --prepare --format json\nbeam apps approvals show \nbeam apps approvals approve --execute\n```\n\n`--no-prompt` fails closed for wallet-affecting swaps unless the command is\npreparing a continuation or executing an already-approved continuation.\n\n## Permissions\n\nThe app requests HTTPS access to the Uniswap Trading API, read/simulate/send\naccess on supported public EVM chains, ERC-20 approval planning, wallet balance\nreads, transaction proposals, and app-local storage. It does not request Beam\nprivacy capabilities in v1.\n\nSupported chains are Ethereum, Base, Arbitrum, Polygon, BNB, and Sepolia.\n\nApprovals default to the exact amount required. Unlimited approvals require the\nexplicit `--unlimited-approval` flag and are shown in the Beam approval prompt.\n", + "readme_markdown": "# Uniswap App\n\nThe Uniswap app turns a swap request into a Beam-approved action plan. It asks\nthe Uniswap Trading API for a quote, checks your current ERC-20 allowance,\nprepares an exact approval only when one is needed, builds the swap transaction,\nand hands the whole plan to Beam for approval and execution.\n\nBeam owns your wallet boundary. The app never receives private keys, cannot sign\ntransactions, and cannot send a transaction on its own.\n\n## Install\n\n```bash\nbeam apps install uniswap\n```\n\nBeam shows the publisher, version, supported chains, network access, wallet\ncapabilities, and storage permissions before activating the app. To inspect the\nsame permission summary without installing:\n\n```bash\nbeam apps install uniswap --dry-run\n```\n\n## Swap\n\n```bash\nbeam x uniswap swap [options]\n```\n\nCommand help is exported through the app manifest and rendered by Beam without\nfetching a quote or invoking guest WASM:\n\n```bash\nbeam x uniswap swap --help\n```\n\nExample:\n\n```bash\nbeam x uniswap swap USDC ETH 100 --chain base --from alice\n```\n\nBeam shows the quote, any required approval, and the swap as a single plan. You\napprove the final plan before Beam signs or submits anything.\n\n## Options\n\n- `--min-receive ` sets the minimum acceptable output amount.\n- `--max-gas ` rejects the plan if estimated gas exceeds the limit.\n- `--slippage-bps ` sets max slippage in basis points.\n- `--recipient ` sends output to another wallet, ENS name, or\n EVM address.\n- `--deadline-seconds ` sets the quote and transaction deadline window.\n- `--unlimited-approval` asks Beam to request an unlimited ERC-20 approval\n instead of the default exact approval.\n\nToken inputs can be Beam token labels, `native`, the active chain's native\nsymbol, or EVM token addresses.\n\n## How a Swap Works\n\n1. The app fetches a quote through Beam-mediated HTTPS access.\n2. Beam reads your token balance and allowance through its chain APIs.\n3. If allowance is short, Beam adds an exact ERC-20 approval step.\n4. The app prepares the swap transaction.\n5. Beam renders the typed plan, asks for approval, signs, submits, and tracks the\n receipt.\n\nIf an approval is required, Beam submits it first and submits the swap only after\nthe approval is confirmed. If fresh allowance already satisfies the exact plan,\nBeam skips the approval step. Execution output reports confirmed, pending,\ndropped, or skipped transaction state; confirmed receipts include the RPC status\nvalue.\n\n## Agents\n\nAgents and other non-interactive callers should prepare a continuation, inspect\nit, then explicitly approve and execute it:\n\n```bash\nbeam x uniswap swap USDC ETH 100 --chain base --from alice --prepare --format json\nbeam apps approvals show \nbeam apps approvals approve --execute\n```\n\n`--no-prompt` fails closed for wallet-affecting swaps unless the command is\npreparing a continuation or executing an already-approved continuation.\n\n## Permissions\n\nThe app requests HTTPS access to the Uniswap Trading API, read/simulate/send\naccess on supported public EVM chains, ERC-20 approval planning, wallet balance\nreads, transaction proposals, and app-local storage. It does not request Beam\nprivacy capabilities in v1.\n\nSupported chains are Ethereum, Base, Arbitrum, Polygon, BNB, and Sepolia.\n\nApprovals default to the exact amount required. Unlimited approvals require the\nexplicit `--unlimited-approval` flag and are shown in the Beam approval prompt.\n", "manifest_summary": { "format_version": 1, "min_beam_version": "0.1.2", @@ -293,6 +293,6 @@ "signature": { "algorithm": "sha256-dev", "key_id": "payy-dev-2026-05", - "value": "sha256:751da26b235d861af214324b036ba5ee5c20062e80e66f6104cd78ba7b2672de" + "value": "sha256:cf1ff80f294be2c782b248b643bf45ad2aa8d1c45d4fd48ef3e160e601037b42" } } diff --git a/beam-apps/fixtures/invalid-digest/catalog/apps/uniswap.json.sig b/beam-apps/fixtures/invalid-digest/catalog/apps/uniswap.json.sig index 486afc8..add7d00 100644 --- a/beam-apps/fixtures/invalid-digest/catalog/apps/uniswap.json.sig +++ b/beam-apps/fixtures/invalid-digest/catalog/apps/uniswap.json.sig @@ -1,5 +1,5 @@ { "algorithm": "sha256-dev", "key_id": "payy-dev-2026-05", - "value": "sha256:751da26b235d861af214324b036ba5ee5c20062e80e66f6104cd78ba7b2672de" + "value": "sha256:cf1ff80f294be2c782b248b643bf45ad2aa8d1c45d4fd48ef3e160e601037b42" } diff --git a/beam-apps/fixtures/invalid-digest/index.json b/beam-apps/fixtures/invalid-digest/index.json index 9a0b6f9..b20a5c9 100644 --- a/beam-apps/fixtures/invalid-digest/index.json +++ b/beam-apps/fixtures/invalid-digest/index.json @@ -12,13 +12,13 @@ "version": "1.0.0", "min_beam_version": "0.1.2", "manifest_url": "https://registry.beam.payy.network/apps/uniswap/1.0.0/manifest.json", - "manifest_sha256": "sha256:9b1e84f51ef0e5afd88a79aaaaa9ebf24b8d15a0a321c0ffa93c6da0ba5c8ede", + "manifest_sha256": "sha256:679a551bf48519a586975b25385e310519b077853383a4b3f7c2ff7b66ca3470", "module_url": "https://registry.beam.payy.network/apps/uniswap/1.0.0/module.wasm", "module_sha256": "sha256:0000000000000000000000000000000000000000000000000000000000000000", "signature": { "algorithm": "sha256-dev", "key_id": "payy-dev-2026-05", - "value": "sha256:5b8c5aa5f89bf386efbbcdb3a8c670e24bf7a583c9ed0f9c327dedcdeb5a87f0" + "value": "sha256:d66c14c15fb72b7d083a8a61984e2356a130b521fb70e664edefcfabd08ed8dd" } } ] @@ -27,6 +27,6 @@ "signature": { "algorithm": "sha256-dev", "key_id": "payy-dev-2026-05", - "value": "sha256:9f831d41929e401c0873a58a80aeaae76a6353ec07da62a2b1864f969536eecf" + "value": "sha256:c71291f48e084cb773bc003d3fa7536acd0c653632547da79d32373107f89d6d" } } diff --git a/beam-apps/fixtures/invalid-digest/index.json.sig b/beam-apps/fixtures/invalid-digest/index.json.sig index c10b635..9c8b5f1 100644 --- a/beam-apps/fixtures/invalid-digest/index.json.sig +++ b/beam-apps/fixtures/invalid-digest/index.json.sig @@ -1,5 +1,5 @@ { "algorithm": "sha256-dev", "key_id": "payy-dev-2026-05", - "value": "sha256:de6a24df0daabff15742faa1754c8c254a1d4157a55107766cee0a873e914411" + "value": "sha256:40a78ac42754c32d9e50aab13e98882852a73f14155a648919d1fdae928e78a8" } diff --git a/beam-apps/fixtures/malformed-permissions/apps/uniswap/1.0.0/manifest.json b/beam-apps/fixtures/malformed-permissions/apps/uniswap/1.0.0/manifest.json index b40b405..43856d7 100644 --- a/beam-apps/fixtures/malformed-permissions/apps/uniswap/1.0.0/manifest.json +++ b/beam-apps/fixtures/malformed-permissions/apps/uniswap/1.0.0/manifest.json @@ -7,7 +7,7 @@ "description": "Prepare public Uniswap swaps through Beam-mediated HTTP, chain, approval, and transaction planning.", "min_beam_version": "0.1.2", "wasm": { - "sha256": "sha256:b155ff4bbbab293085ee393fc12cd9ddcda0e531694521909cd8b5c433ecb927", + "sha256": "sha256:15dadd2f783c783594c5c586f494f7b31be6dc89e5efbab1c479cf317b15ad0f", "entrypoint": "beam_app_main" }, "catalog": { @@ -221,6 +221,6 @@ "signature": { "algorithm": "sha256-dev", "key_id": "payy-dev-2026-05", - "value": "sha256:a1b9a4d8928f731b0e778bdf8cb6c3075c895c9d8f1e06fff1e5cbb328a4dd51" + "value": "sha256:e8ec17a0f387c153dbc952556c9f4b320e15694de2c564fafc998098121fcba5" } } diff --git a/beam-apps/fixtures/malformed-permissions/apps/uniswap/1.0.0/manifest.json.sig b/beam-apps/fixtures/malformed-permissions/apps/uniswap/1.0.0/manifest.json.sig index 4748bf1..d16074d 100644 --- a/beam-apps/fixtures/malformed-permissions/apps/uniswap/1.0.0/manifest.json.sig +++ b/beam-apps/fixtures/malformed-permissions/apps/uniswap/1.0.0/manifest.json.sig @@ -1,5 +1,5 @@ { "algorithm": "sha256-dev", "key_id": "payy-dev-2026-05", - "value": "sha256:6fa02552dc669810b58d6fe9a55cc36546221c3b25cf9340ab6ff74388f372fb" + "value": "sha256:2f47a3e064fdc3572cc68eb52929147275f5e57fc958e82e59e8c2a5f21ab57e" } diff --git a/beam-apps/fixtures/malformed-permissions/apps/uniswap/1.0.0/module.wasm b/beam-apps/fixtures/malformed-permissions/apps/uniswap/1.0.0/module.wasm index a4fb777..1d2b3fd 100644 Binary files a/beam-apps/fixtures/malformed-permissions/apps/uniswap/1.0.0/module.wasm and b/beam-apps/fixtures/malformed-permissions/apps/uniswap/1.0.0/module.wasm differ diff --git a/beam-apps/fixtures/malformed-permissions/apps/uniswap/1.0.0/version.json.sig b/beam-apps/fixtures/malformed-permissions/apps/uniswap/1.0.0/version.json.sig index d6a752e..082e83b 100644 --- a/beam-apps/fixtures/malformed-permissions/apps/uniswap/1.0.0/version.json.sig +++ b/beam-apps/fixtures/malformed-permissions/apps/uniswap/1.0.0/version.json.sig @@ -1,5 +1,5 @@ { "algorithm": "sha256-dev", "key_id": "payy-dev-2026-05", - "value": "sha256:5b8c5aa5f89bf386efbbcdb3a8c670e24bf7a583c9ed0f9c327dedcdeb5a87f0" + "value": "sha256:d66c14c15fb72b7d083a8a61984e2356a130b521fb70e664edefcfabd08ed8dd" } diff --git a/beam-apps/fixtures/malformed-permissions/catalog/apps/uniswap.json b/beam-apps/fixtures/malformed-permissions/catalog/apps/uniswap.json index 45413a2..a6fca62 100644 --- a/beam-apps/fixtures/malformed-permissions/catalog/apps/uniswap.json +++ b/beam-apps/fixtures/malformed-permissions/catalog/apps/uniswap.json @@ -271,7 +271,7 @@ "sensitive_args": [] } ], - "readme_markdown": "# Uniswap App\n\nThe Uniswap app turns a swap request into a Beam-approved action plan. It asks\nthe Uniswap Trading API for a quote, checks your current ERC-20 allowance,\nprepares an exact approval only when one is needed, builds the swap transaction,\nand hands the whole plan to Beam for approval and execution.\n\nBeam owns your wallet boundary. The app never receives private keys, cannot sign\ntransactions, and cannot send a transaction on its own.\n\n## Install\n\n```bash\nbeam apps install uniswap\n```\n\nBeam shows the publisher, version, supported chains, network access, wallet\ncapabilities, and storage permissions before activating the app. To inspect the\nsame permission summary without installing:\n\n```bash\nbeam apps install uniswap --dry-run\n```\n\n## Swap\n\n```bash\nbeam x uniswap swap [options]\n```\n\nExample:\n\n```bash\nbeam x uniswap swap USDC ETH 100 --chain base --from alice\n```\n\nBeam shows the quote, any required approval, and the swap as a single plan. You\napprove the final plan before Beam signs or submits anything.\n\n## Options\n\n- `--min-receive ` sets the minimum acceptable output amount.\n- `--max-gas ` rejects the plan if estimated gas exceeds the limit.\n- `--slippage-bps ` sets max slippage in basis points.\n- `--recipient ` sends output to another wallet, ENS name, or\n EVM address.\n- `--deadline-seconds ` sets the quote and transaction deadline window.\n- `--unlimited-approval` asks Beam to request an unlimited ERC-20 approval\n instead of the default exact approval.\n\n## How a Swap Works\n\n1. The app fetches a quote through Beam-mediated HTTPS access.\n2. Beam reads your token balance and allowance through its chain APIs.\n3. If allowance is short, Beam adds an exact ERC-20 approval step.\n4. The app prepares the swap transaction.\n5. Beam renders the typed plan, asks for approval, signs, submits, and tracks the\n receipt.\n\n## Agents\n\nAgents and other non-interactive callers should prepare a continuation, inspect\nit, then explicitly approve and execute it:\n\n```bash\nbeam x uniswap swap USDC ETH 100 --chain base --from alice --prepare --format json\nbeam apps approvals show \nbeam apps approvals approve --execute\n```\n\n`--no-prompt` fails closed for wallet-affecting swaps unless the command is\npreparing a continuation or executing an already-approved continuation.\n\n## Permissions\n\nThe app requests HTTPS access to the Uniswap Trading API, read/simulate/send\naccess on supported public EVM chains, ERC-20 approval planning, wallet balance\nreads, transaction proposals, and app-local storage. It does not request Beam\nprivacy capabilities in v1.\n\nSupported chains are Ethereum, Base, Arbitrum, Polygon, BNB, and Sepolia.\n\nApprovals default to the exact amount required. Unlimited approvals require the\nexplicit `--unlimited-approval` flag and are shown in the Beam approval prompt.\n", + "readme_markdown": "# Uniswap App\n\nThe Uniswap app turns a swap request into a Beam-approved action plan. It asks\nthe Uniswap Trading API for a quote, checks your current ERC-20 allowance,\nprepares an exact approval only when one is needed, builds the swap transaction,\nand hands the whole plan to Beam for approval and execution.\n\nBeam owns your wallet boundary. The app never receives private keys, cannot sign\ntransactions, and cannot send a transaction on its own.\n\n## Install\n\n```bash\nbeam apps install uniswap\n```\n\nBeam shows the publisher, version, supported chains, network access, wallet\ncapabilities, and storage permissions before activating the app. To inspect the\nsame permission summary without installing:\n\n```bash\nbeam apps install uniswap --dry-run\n```\n\n## Swap\n\n```bash\nbeam x uniswap swap [options]\n```\n\nCommand help is exported through the app manifest and rendered by Beam without\nfetching a quote or invoking guest WASM:\n\n```bash\nbeam x uniswap swap --help\n```\n\nExample:\n\n```bash\nbeam x uniswap swap USDC ETH 100 --chain base --from alice\n```\n\nBeam shows the quote, any required approval, and the swap as a single plan. You\napprove the final plan before Beam signs or submits anything.\n\n## Options\n\n- `--min-receive ` sets the minimum acceptable output amount.\n- `--max-gas ` rejects the plan if estimated gas exceeds the limit.\n- `--slippage-bps ` sets max slippage in basis points.\n- `--recipient ` sends output to another wallet, ENS name, or\n EVM address.\n- `--deadline-seconds ` sets the quote and transaction deadline window.\n- `--unlimited-approval` asks Beam to request an unlimited ERC-20 approval\n instead of the default exact approval.\n\nToken inputs can be Beam token labels, `native`, the active chain's native\nsymbol, or EVM token addresses.\n\n## How a Swap Works\n\n1. The app fetches a quote through Beam-mediated HTTPS access.\n2. Beam reads your token balance and allowance through its chain APIs.\n3. If allowance is short, Beam adds an exact ERC-20 approval step.\n4. The app prepares the swap transaction.\n5. Beam renders the typed plan, asks for approval, signs, submits, and tracks the\n receipt.\n\nIf an approval is required, Beam submits it first and submits the swap only after\nthe approval is confirmed. If fresh allowance already satisfies the exact plan,\nBeam skips the approval step. Execution output reports confirmed, pending,\ndropped, or skipped transaction state; confirmed receipts include the RPC status\nvalue.\n\n## Agents\n\nAgents and other non-interactive callers should prepare a continuation, inspect\nit, then explicitly approve and execute it:\n\n```bash\nbeam x uniswap swap USDC ETH 100 --chain base --from alice --prepare --format json\nbeam apps approvals show \nbeam apps approvals approve --execute\n```\n\n`--no-prompt` fails closed for wallet-affecting swaps unless the command is\npreparing a continuation or executing an already-approved continuation.\n\n## Permissions\n\nThe app requests HTTPS access to the Uniswap Trading API, read/simulate/send\naccess on supported public EVM chains, ERC-20 approval planning, wallet balance\nreads, transaction proposals, and app-local storage. It does not request Beam\nprivacy capabilities in v1.\n\nSupported chains are Ethereum, Base, Arbitrum, Polygon, BNB, and Sepolia.\n\nApprovals default to the exact amount required. Unlimited approvals require the\nexplicit `--unlimited-approval` flag and are shown in the Beam approval prompt.\n", "manifest_summary": { "format_version": 1, "min_beam_version": "0.1.2", @@ -293,6 +293,6 @@ "signature": { "algorithm": "sha256-dev", "key_id": "payy-dev-2026-05", - "value": "sha256:751da26b235d861af214324b036ba5ee5c20062e80e66f6104cd78ba7b2672de" + "value": "sha256:cf1ff80f294be2c782b248b643bf45ad2aa8d1c45d4fd48ef3e160e601037b42" } } diff --git a/beam-apps/fixtures/malformed-permissions/catalog/apps/uniswap.json.sig b/beam-apps/fixtures/malformed-permissions/catalog/apps/uniswap.json.sig index 486afc8..add7d00 100644 --- a/beam-apps/fixtures/malformed-permissions/catalog/apps/uniswap.json.sig +++ b/beam-apps/fixtures/malformed-permissions/catalog/apps/uniswap.json.sig @@ -1,5 +1,5 @@ { "algorithm": "sha256-dev", "key_id": "payy-dev-2026-05", - "value": "sha256:751da26b235d861af214324b036ba5ee5c20062e80e66f6104cd78ba7b2672de" + "value": "sha256:cf1ff80f294be2c782b248b643bf45ad2aa8d1c45d4fd48ef3e160e601037b42" } diff --git a/beam-apps/fixtures/malformed-permissions/index.json b/beam-apps/fixtures/malformed-permissions/index.json index 4ace360..872607e 100644 --- a/beam-apps/fixtures/malformed-permissions/index.json +++ b/beam-apps/fixtures/malformed-permissions/index.json @@ -12,13 +12,13 @@ "version": "1.0.0", "min_beam_version": "0.1.2", "manifest_url": "https://registry.beam.payy.network/apps/uniswap/1.0.0/manifest.json", - "manifest_sha256": "sha256:9b1e84f51ef0e5afd88a79aaaaa9ebf24b8d15a0a321c0ffa93c6da0ba5c8ede", + "manifest_sha256": "sha256:679a551bf48519a586975b25385e310519b077853383a4b3f7c2ff7b66ca3470", "module_url": "https://registry.beam.payy.network/apps/uniswap/1.0.0/module.wasm", - "module_sha256": "sha256:b155ff4bbbab293085ee393fc12cd9ddcda0e531694521909cd8b5c433ecb927", + "module_sha256": "sha256:15dadd2f783c783594c5c586f494f7b31be6dc89e5efbab1c479cf317b15ad0f", "signature": { "algorithm": "sha256-dev", "key_id": "payy-dev-2026-05", - "value": "sha256:5b8c5aa5f89bf386efbbcdb3a8c670e24bf7a583c9ed0f9c327dedcdeb5a87f0" + "value": "sha256:d66c14c15fb72b7d083a8a61984e2356a130b521fb70e664edefcfabd08ed8dd" } } ] @@ -27,6 +27,6 @@ "signature": { "algorithm": "sha256-dev", "key_id": "payy-dev-2026-05", - "value": "sha256:de6a24df0daabff15742faa1754c8c254a1d4157a55107766cee0a873e914411" + "value": "sha256:40a78ac42754c32d9e50aab13e98882852a73f14155a648919d1fdae928e78a8" } } diff --git a/beam-apps/fixtures/malformed-permissions/index.json.sig b/beam-apps/fixtures/malformed-permissions/index.json.sig index c10b635..9c8b5f1 100644 --- a/beam-apps/fixtures/malformed-permissions/index.json.sig +++ b/beam-apps/fixtures/malformed-permissions/index.json.sig @@ -1,5 +1,5 @@ { "algorithm": "sha256-dev", "key_id": "payy-dev-2026-05", - "value": "sha256:de6a24df0daabff15742faa1754c8c254a1d4157a55107766cee0a873e914411" + "value": "sha256:40a78ac42754c32d9e50aab13e98882852a73f14155a648919d1fdae928e78a8" } diff --git a/beam-apps/fixtures/missing-fields/apps/uniswap/1.0.0/manifest.json b/beam-apps/fixtures/missing-fields/apps/uniswap/1.0.0/manifest.json index 83060f0..d6ef0b8 100644 --- a/beam-apps/fixtures/missing-fields/apps/uniswap/1.0.0/manifest.json +++ b/beam-apps/fixtures/missing-fields/apps/uniswap/1.0.0/manifest.json @@ -7,7 +7,7 @@ "description": "Prepare public Uniswap swaps through Beam-mediated HTTP, chain, approval, and transaction planning.", "min_beam_version": "0.1.2", "wasm": { - "sha256": "sha256:b155ff4bbbab293085ee393fc12cd9ddcda0e531694521909cd8b5c433ecb927", + "sha256": "sha256:15dadd2f783c783594c5c586f494f7b31be6dc89e5efbab1c479cf317b15ad0f", "entrypoint": "beam_app_main" }, "catalog": { @@ -129,6 +129,6 @@ "signature": { "algorithm": "sha256-dev", "key_id": "payy-dev-2026-05", - "value": "sha256:bac5e146237b53395f744e1097794b73abaa94a4ec75c6096ab3404e1e738a06" + "value": "sha256:80eaa89c54f1d5eb38198f9de7c261e6446ffe700d6c9313232f076bb783c518" } } diff --git a/beam-apps/fixtures/missing-fields/apps/uniswap/1.0.0/manifest.json.sig b/beam-apps/fixtures/missing-fields/apps/uniswap/1.0.0/manifest.json.sig index 4748bf1..d16074d 100644 --- a/beam-apps/fixtures/missing-fields/apps/uniswap/1.0.0/manifest.json.sig +++ b/beam-apps/fixtures/missing-fields/apps/uniswap/1.0.0/manifest.json.sig @@ -1,5 +1,5 @@ { "algorithm": "sha256-dev", "key_id": "payy-dev-2026-05", - "value": "sha256:6fa02552dc669810b58d6fe9a55cc36546221c3b25cf9340ab6ff74388f372fb" + "value": "sha256:2f47a3e064fdc3572cc68eb52929147275f5e57fc958e82e59e8c2a5f21ab57e" } diff --git a/beam-apps/fixtures/missing-fields/apps/uniswap/1.0.0/module.wasm b/beam-apps/fixtures/missing-fields/apps/uniswap/1.0.0/module.wasm index a4fb777..1d2b3fd 100644 Binary files a/beam-apps/fixtures/missing-fields/apps/uniswap/1.0.0/module.wasm and b/beam-apps/fixtures/missing-fields/apps/uniswap/1.0.0/module.wasm differ diff --git a/beam-apps/fixtures/missing-fields/apps/uniswap/1.0.0/version.json.sig b/beam-apps/fixtures/missing-fields/apps/uniswap/1.0.0/version.json.sig index d6a752e..082e83b 100644 --- a/beam-apps/fixtures/missing-fields/apps/uniswap/1.0.0/version.json.sig +++ b/beam-apps/fixtures/missing-fields/apps/uniswap/1.0.0/version.json.sig @@ -1,5 +1,5 @@ { "algorithm": "sha256-dev", "key_id": "payy-dev-2026-05", - "value": "sha256:5b8c5aa5f89bf386efbbcdb3a8c670e24bf7a583c9ed0f9c327dedcdeb5a87f0" + "value": "sha256:d66c14c15fb72b7d083a8a61984e2356a130b521fb70e664edefcfabd08ed8dd" } diff --git a/beam-apps/fixtures/missing-fields/catalog/apps/uniswap.json b/beam-apps/fixtures/missing-fields/catalog/apps/uniswap.json index 45413a2..a6fca62 100644 --- a/beam-apps/fixtures/missing-fields/catalog/apps/uniswap.json +++ b/beam-apps/fixtures/missing-fields/catalog/apps/uniswap.json @@ -271,7 +271,7 @@ "sensitive_args": [] } ], - "readme_markdown": "# Uniswap App\n\nThe Uniswap app turns a swap request into a Beam-approved action plan. It asks\nthe Uniswap Trading API for a quote, checks your current ERC-20 allowance,\nprepares an exact approval only when one is needed, builds the swap transaction,\nand hands the whole plan to Beam for approval and execution.\n\nBeam owns your wallet boundary. The app never receives private keys, cannot sign\ntransactions, and cannot send a transaction on its own.\n\n## Install\n\n```bash\nbeam apps install uniswap\n```\n\nBeam shows the publisher, version, supported chains, network access, wallet\ncapabilities, and storage permissions before activating the app. To inspect the\nsame permission summary without installing:\n\n```bash\nbeam apps install uniswap --dry-run\n```\n\n## Swap\n\n```bash\nbeam x uniswap swap [options]\n```\n\nExample:\n\n```bash\nbeam x uniswap swap USDC ETH 100 --chain base --from alice\n```\n\nBeam shows the quote, any required approval, and the swap as a single plan. You\napprove the final plan before Beam signs or submits anything.\n\n## Options\n\n- `--min-receive ` sets the minimum acceptable output amount.\n- `--max-gas ` rejects the plan if estimated gas exceeds the limit.\n- `--slippage-bps ` sets max slippage in basis points.\n- `--recipient ` sends output to another wallet, ENS name, or\n EVM address.\n- `--deadline-seconds ` sets the quote and transaction deadline window.\n- `--unlimited-approval` asks Beam to request an unlimited ERC-20 approval\n instead of the default exact approval.\n\n## How a Swap Works\n\n1. The app fetches a quote through Beam-mediated HTTPS access.\n2. Beam reads your token balance and allowance through its chain APIs.\n3. If allowance is short, Beam adds an exact ERC-20 approval step.\n4. The app prepares the swap transaction.\n5. Beam renders the typed plan, asks for approval, signs, submits, and tracks the\n receipt.\n\n## Agents\n\nAgents and other non-interactive callers should prepare a continuation, inspect\nit, then explicitly approve and execute it:\n\n```bash\nbeam x uniswap swap USDC ETH 100 --chain base --from alice --prepare --format json\nbeam apps approvals show \nbeam apps approvals approve --execute\n```\n\n`--no-prompt` fails closed for wallet-affecting swaps unless the command is\npreparing a continuation or executing an already-approved continuation.\n\n## Permissions\n\nThe app requests HTTPS access to the Uniswap Trading API, read/simulate/send\naccess on supported public EVM chains, ERC-20 approval planning, wallet balance\nreads, transaction proposals, and app-local storage. It does not request Beam\nprivacy capabilities in v1.\n\nSupported chains are Ethereum, Base, Arbitrum, Polygon, BNB, and Sepolia.\n\nApprovals default to the exact amount required. Unlimited approvals require the\nexplicit `--unlimited-approval` flag and are shown in the Beam approval prompt.\n", + "readme_markdown": "# Uniswap App\n\nThe Uniswap app turns a swap request into a Beam-approved action plan. It asks\nthe Uniswap Trading API for a quote, checks your current ERC-20 allowance,\nprepares an exact approval only when one is needed, builds the swap transaction,\nand hands the whole plan to Beam for approval and execution.\n\nBeam owns your wallet boundary. The app never receives private keys, cannot sign\ntransactions, and cannot send a transaction on its own.\n\n## Install\n\n```bash\nbeam apps install uniswap\n```\n\nBeam shows the publisher, version, supported chains, network access, wallet\ncapabilities, and storage permissions before activating the app. To inspect the\nsame permission summary without installing:\n\n```bash\nbeam apps install uniswap --dry-run\n```\n\n## Swap\n\n```bash\nbeam x uniswap swap [options]\n```\n\nCommand help is exported through the app manifest and rendered by Beam without\nfetching a quote or invoking guest WASM:\n\n```bash\nbeam x uniswap swap --help\n```\n\nExample:\n\n```bash\nbeam x uniswap swap USDC ETH 100 --chain base --from alice\n```\n\nBeam shows the quote, any required approval, and the swap as a single plan. You\napprove the final plan before Beam signs or submits anything.\n\n## Options\n\n- `--min-receive ` sets the minimum acceptable output amount.\n- `--max-gas ` rejects the plan if estimated gas exceeds the limit.\n- `--slippage-bps ` sets max slippage in basis points.\n- `--recipient ` sends output to another wallet, ENS name, or\n EVM address.\n- `--deadline-seconds ` sets the quote and transaction deadline window.\n- `--unlimited-approval` asks Beam to request an unlimited ERC-20 approval\n instead of the default exact approval.\n\nToken inputs can be Beam token labels, `native`, the active chain's native\nsymbol, or EVM token addresses.\n\n## How a Swap Works\n\n1. The app fetches a quote through Beam-mediated HTTPS access.\n2. Beam reads your token balance and allowance through its chain APIs.\n3. If allowance is short, Beam adds an exact ERC-20 approval step.\n4. The app prepares the swap transaction.\n5. Beam renders the typed plan, asks for approval, signs, submits, and tracks the\n receipt.\n\nIf an approval is required, Beam submits it first and submits the swap only after\nthe approval is confirmed. If fresh allowance already satisfies the exact plan,\nBeam skips the approval step. Execution output reports confirmed, pending,\ndropped, or skipped transaction state; confirmed receipts include the RPC status\nvalue.\n\n## Agents\n\nAgents and other non-interactive callers should prepare a continuation, inspect\nit, then explicitly approve and execute it:\n\n```bash\nbeam x uniswap swap USDC ETH 100 --chain base --from alice --prepare --format json\nbeam apps approvals show \nbeam apps approvals approve --execute\n```\n\n`--no-prompt` fails closed for wallet-affecting swaps unless the command is\npreparing a continuation or executing an already-approved continuation.\n\n## Permissions\n\nThe app requests HTTPS access to the Uniswap Trading API, read/simulate/send\naccess on supported public EVM chains, ERC-20 approval planning, wallet balance\nreads, transaction proposals, and app-local storage. It does not request Beam\nprivacy capabilities in v1.\n\nSupported chains are Ethereum, Base, Arbitrum, Polygon, BNB, and Sepolia.\n\nApprovals default to the exact amount required. Unlimited approvals require the\nexplicit `--unlimited-approval` flag and are shown in the Beam approval prompt.\n", "manifest_summary": { "format_version": 1, "min_beam_version": "0.1.2", @@ -293,6 +293,6 @@ "signature": { "algorithm": "sha256-dev", "key_id": "payy-dev-2026-05", - "value": "sha256:751da26b235d861af214324b036ba5ee5c20062e80e66f6104cd78ba7b2672de" + "value": "sha256:cf1ff80f294be2c782b248b643bf45ad2aa8d1c45d4fd48ef3e160e601037b42" } } diff --git a/beam-apps/fixtures/missing-fields/catalog/apps/uniswap.json.sig b/beam-apps/fixtures/missing-fields/catalog/apps/uniswap.json.sig index 486afc8..add7d00 100644 --- a/beam-apps/fixtures/missing-fields/catalog/apps/uniswap.json.sig +++ b/beam-apps/fixtures/missing-fields/catalog/apps/uniswap.json.sig @@ -1,5 +1,5 @@ { "algorithm": "sha256-dev", "key_id": "payy-dev-2026-05", - "value": "sha256:751da26b235d861af214324b036ba5ee5c20062e80e66f6104cd78ba7b2672de" + "value": "sha256:cf1ff80f294be2c782b248b643bf45ad2aa8d1c45d4fd48ef3e160e601037b42" } diff --git a/beam-apps/fixtures/missing-fields/index.json b/beam-apps/fixtures/missing-fields/index.json index 4ace360..872607e 100644 --- a/beam-apps/fixtures/missing-fields/index.json +++ b/beam-apps/fixtures/missing-fields/index.json @@ -12,13 +12,13 @@ "version": "1.0.0", "min_beam_version": "0.1.2", "manifest_url": "https://registry.beam.payy.network/apps/uniswap/1.0.0/manifest.json", - "manifest_sha256": "sha256:9b1e84f51ef0e5afd88a79aaaaa9ebf24b8d15a0a321c0ffa93c6da0ba5c8ede", + "manifest_sha256": "sha256:679a551bf48519a586975b25385e310519b077853383a4b3f7c2ff7b66ca3470", "module_url": "https://registry.beam.payy.network/apps/uniswap/1.0.0/module.wasm", - "module_sha256": "sha256:b155ff4bbbab293085ee393fc12cd9ddcda0e531694521909cd8b5c433ecb927", + "module_sha256": "sha256:15dadd2f783c783594c5c586f494f7b31be6dc89e5efbab1c479cf317b15ad0f", "signature": { "algorithm": "sha256-dev", "key_id": "payy-dev-2026-05", - "value": "sha256:5b8c5aa5f89bf386efbbcdb3a8c670e24bf7a583c9ed0f9c327dedcdeb5a87f0" + "value": "sha256:d66c14c15fb72b7d083a8a61984e2356a130b521fb70e664edefcfabd08ed8dd" } } ] @@ -27,6 +27,6 @@ "signature": { "algorithm": "sha256-dev", "key_id": "payy-dev-2026-05", - "value": "sha256:de6a24df0daabff15742faa1754c8c254a1d4157a55107766cee0a873e914411" + "value": "sha256:40a78ac42754c32d9e50aab13e98882852a73f14155a648919d1fdae928e78a8" } } diff --git a/beam-apps/fixtures/missing-fields/index.json.sig b/beam-apps/fixtures/missing-fields/index.json.sig index c10b635..9c8b5f1 100644 --- a/beam-apps/fixtures/missing-fields/index.json.sig +++ b/beam-apps/fixtures/missing-fields/index.json.sig @@ -1,5 +1,5 @@ { "algorithm": "sha256-dev", "key_id": "payy-dev-2026-05", - "value": "sha256:de6a24df0daabff15742faa1754c8c254a1d4157a55107766cee0a873e914411" + "value": "sha256:40a78ac42754c32d9e50aab13e98882852a73f14155a648919d1fdae928e78a8" } diff --git a/beam-apps/fixtures/unsupported-beam/apps/uniswap/1.0.0/manifest.json b/beam-apps/fixtures/unsupported-beam/apps/uniswap/1.0.0/manifest.json index 9f1acb7..a8bf809 100644 --- a/beam-apps/fixtures/unsupported-beam/apps/uniswap/1.0.0/manifest.json +++ b/beam-apps/fixtures/unsupported-beam/apps/uniswap/1.0.0/manifest.json @@ -7,7 +7,7 @@ "description": "Prepare public Uniswap swaps through Beam-mediated HTTP, chain, approval, and transaction planning.", "min_beam_version": "999.0.0", "wasm": { - "sha256": "sha256:b155ff4bbbab293085ee393fc12cd9ddcda0e531694521909cd8b5c433ecb927", + "sha256": "sha256:15dadd2f783c783594c5c586f494f7b31be6dc89e5efbab1c479cf317b15ad0f", "entrypoint": "beam_app_main" }, "catalog": { @@ -290,6 +290,6 @@ "signature": { "algorithm": "sha256-dev", "key_id": "payy-dev-2026-05", - "value": "sha256:fe22b97b09a983b2f31844a57dce22187c9fbf08ca4a175d944c10bf4e9a2beb" + "value": "sha256:52aa83b6ee6df8f59c6abbfef5593fcb4f05c0b6d2a9d7468de5c0428ecc0d15" } } diff --git a/beam-apps/fixtures/unsupported-beam/apps/uniswap/1.0.0/manifest.json.sig b/beam-apps/fixtures/unsupported-beam/apps/uniswap/1.0.0/manifest.json.sig index 4748bf1..d16074d 100644 --- a/beam-apps/fixtures/unsupported-beam/apps/uniswap/1.0.0/manifest.json.sig +++ b/beam-apps/fixtures/unsupported-beam/apps/uniswap/1.0.0/manifest.json.sig @@ -1,5 +1,5 @@ { "algorithm": "sha256-dev", "key_id": "payy-dev-2026-05", - "value": "sha256:6fa02552dc669810b58d6fe9a55cc36546221c3b25cf9340ab6ff74388f372fb" + "value": "sha256:2f47a3e064fdc3572cc68eb52929147275f5e57fc958e82e59e8c2a5f21ab57e" } diff --git a/beam-apps/fixtures/unsupported-beam/apps/uniswap/1.0.0/module.wasm b/beam-apps/fixtures/unsupported-beam/apps/uniswap/1.0.0/module.wasm index a4fb777..1d2b3fd 100644 Binary files a/beam-apps/fixtures/unsupported-beam/apps/uniswap/1.0.0/module.wasm and b/beam-apps/fixtures/unsupported-beam/apps/uniswap/1.0.0/module.wasm differ diff --git a/beam-apps/fixtures/unsupported-beam/apps/uniswap/1.0.0/version.json.sig b/beam-apps/fixtures/unsupported-beam/apps/uniswap/1.0.0/version.json.sig index d6a752e..082e83b 100644 --- a/beam-apps/fixtures/unsupported-beam/apps/uniswap/1.0.0/version.json.sig +++ b/beam-apps/fixtures/unsupported-beam/apps/uniswap/1.0.0/version.json.sig @@ -1,5 +1,5 @@ { "algorithm": "sha256-dev", "key_id": "payy-dev-2026-05", - "value": "sha256:5b8c5aa5f89bf386efbbcdb3a8c670e24bf7a583c9ed0f9c327dedcdeb5a87f0" + "value": "sha256:d66c14c15fb72b7d083a8a61984e2356a130b521fb70e664edefcfabd08ed8dd" } diff --git a/beam-apps/fixtures/unsupported-beam/catalog/apps/uniswap.json b/beam-apps/fixtures/unsupported-beam/catalog/apps/uniswap.json index 45413a2..a6fca62 100644 --- a/beam-apps/fixtures/unsupported-beam/catalog/apps/uniswap.json +++ b/beam-apps/fixtures/unsupported-beam/catalog/apps/uniswap.json @@ -271,7 +271,7 @@ "sensitive_args": [] } ], - "readme_markdown": "# Uniswap App\n\nThe Uniswap app turns a swap request into a Beam-approved action plan. It asks\nthe Uniswap Trading API for a quote, checks your current ERC-20 allowance,\nprepares an exact approval only when one is needed, builds the swap transaction,\nand hands the whole plan to Beam for approval and execution.\n\nBeam owns your wallet boundary. The app never receives private keys, cannot sign\ntransactions, and cannot send a transaction on its own.\n\n## Install\n\n```bash\nbeam apps install uniswap\n```\n\nBeam shows the publisher, version, supported chains, network access, wallet\ncapabilities, and storage permissions before activating the app. To inspect the\nsame permission summary without installing:\n\n```bash\nbeam apps install uniswap --dry-run\n```\n\n## Swap\n\n```bash\nbeam x uniswap swap [options]\n```\n\nExample:\n\n```bash\nbeam x uniswap swap USDC ETH 100 --chain base --from alice\n```\n\nBeam shows the quote, any required approval, and the swap as a single plan. You\napprove the final plan before Beam signs or submits anything.\n\n## Options\n\n- `--min-receive ` sets the minimum acceptable output amount.\n- `--max-gas ` rejects the plan if estimated gas exceeds the limit.\n- `--slippage-bps ` sets max slippage in basis points.\n- `--recipient ` sends output to another wallet, ENS name, or\n EVM address.\n- `--deadline-seconds ` sets the quote and transaction deadline window.\n- `--unlimited-approval` asks Beam to request an unlimited ERC-20 approval\n instead of the default exact approval.\n\n## How a Swap Works\n\n1. The app fetches a quote through Beam-mediated HTTPS access.\n2. Beam reads your token balance and allowance through its chain APIs.\n3. If allowance is short, Beam adds an exact ERC-20 approval step.\n4. The app prepares the swap transaction.\n5. Beam renders the typed plan, asks for approval, signs, submits, and tracks the\n receipt.\n\n## Agents\n\nAgents and other non-interactive callers should prepare a continuation, inspect\nit, then explicitly approve and execute it:\n\n```bash\nbeam x uniswap swap USDC ETH 100 --chain base --from alice --prepare --format json\nbeam apps approvals show \nbeam apps approvals approve --execute\n```\n\n`--no-prompt` fails closed for wallet-affecting swaps unless the command is\npreparing a continuation or executing an already-approved continuation.\n\n## Permissions\n\nThe app requests HTTPS access to the Uniswap Trading API, read/simulate/send\naccess on supported public EVM chains, ERC-20 approval planning, wallet balance\nreads, transaction proposals, and app-local storage. It does not request Beam\nprivacy capabilities in v1.\n\nSupported chains are Ethereum, Base, Arbitrum, Polygon, BNB, and Sepolia.\n\nApprovals default to the exact amount required. Unlimited approvals require the\nexplicit `--unlimited-approval` flag and are shown in the Beam approval prompt.\n", + "readme_markdown": "# Uniswap App\n\nThe Uniswap app turns a swap request into a Beam-approved action plan. It asks\nthe Uniswap Trading API for a quote, checks your current ERC-20 allowance,\nprepares an exact approval only when one is needed, builds the swap transaction,\nand hands the whole plan to Beam for approval and execution.\n\nBeam owns your wallet boundary. The app never receives private keys, cannot sign\ntransactions, and cannot send a transaction on its own.\n\n## Install\n\n```bash\nbeam apps install uniswap\n```\n\nBeam shows the publisher, version, supported chains, network access, wallet\ncapabilities, and storage permissions before activating the app. To inspect the\nsame permission summary without installing:\n\n```bash\nbeam apps install uniswap --dry-run\n```\n\n## Swap\n\n```bash\nbeam x uniswap swap [options]\n```\n\nCommand help is exported through the app manifest and rendered by Beam without\nfetching a quote or invoking guest WASM:\n\n```bash\nbeam x uniswap swap --help\n```\n\nExample:\n\n```bash\nbeam x uniswap swap USDC ETH 100 --chain base --from alice\n```\n\nBeam shows the quote, any required approval, and the swap as a single plan. You\napprove the final plan before Beam signs or submits anything.\n\n## Options\n\n- `--min-receive ` sets the minimum acceptable output amount.\n- `--max-gas ` rejects the plan if estimated gas exceeds the limit.\n- `--slippage-bps ` sets max slippage in basis points.\n- `--recipient ` sends output to another wallet, ENS name, or\n EVM address.\n- `--deadline-seconds ` sets the quote and transaction deadline window.\n- `--unlimited-approval` asks Beam to request an unlimited ERC-20 approval\n instead of the default exact approval.\n\nToken inputs can be Beam token labels, `native`, the active chain's native\nsymbol, or EVM token addresses.\n\n## How a Swap Works\n\n1. The app fetches a quote through Beam-mediated HTTPS access.\n2. Beam reads your token balance and allowance through its chain APIs.\n3. If allowance is short, Beam adds an exact ERC-20 approval step.\n4. The app prepares the swap transaction.\n5. Beam renders the typed plan, asks for approval, signs, submits, and tracks the\n receipt.\n\nIf an approval is required, Beam submits it first and submits the swap only after\nthe approval is confirmed. If fresh allowance already satisfies the exact plan,\nBeam skips the approval step. Execution output reports confirmed, pending,\ndropped, or skipped transaction state; confirmed receipts include the RPC status\nvalue.\n\n## Agents\n\nAgents and other non-interactive callers should prepare a continuation, inspect\nit, then explicitly approve and execute it:\n\n```bash\nbeam x uniswap swap USDC ETH 100 --chain base --from alice --prepare --format json\nbeam apps approvals show \nbeam apps approvals approve --execute\n```\n\n`--no-prompt` fails closed for wallet-affecting swaps unless the command is\npreparing a continuation or executing an already-approved continuation.\n\n## Permissions\n\nThe app requests HTTPS access to the Uniswap Trading API, read/simulate/send\naccess on supported public EVM chains, ERC-20 approval planning, wallet balance\nreads, transaction proposals, and app-local storage. It does not request Beam\nprivacy capabilities in v1.\n\nSupported chains are Ethereum, Base, Arbitrum, Polygon, BNB, and Sepolia.\n\nApprovals default to the exact amount required. Unlimited approvals require the\nexplicit `--unlimited-approval` flag and are shown in the Beam approval prompt.\n", "manifest_summary": { "format_version": 1, "min_beam_version": "0.1.2", @@ -293,6 +293,6 @@ "signature": { "algorithm": "sha256-dev", "key_id": "payy-dev-2026-05", - "value": "sha256:751da26b235d861af214324b036ba5ee5c20062e80e66f6104cd78ba7b2672de" + "value": "sha256:cf1ff80f294be2c782b248b643bf45ad2aa8d1c45d4fd48ef3e160e601037b42" } } diff --git a/beam-apps/fixtures/unsupported-beam/catalog/apps/uniswap.json.sig b/beam-apps/fixtures/unsupported-beam/catalog/apps/uniswap.json.sig index 486afc8..add7d00 100644 --- a/beam-apps/fixtures/unsupported-beam/catalog/apps/uniswap.json.sig +++ b/beam-apps/fixtures/unsupported-beam/catalog/apps/uniswap.json.sig @@ -1,5 +1,5 @@ { "algorithm": "sha256-dev", "key_id": "payy-dev-2026-05", - "value": "sha256:751da26b235d861af214324b036ba5ee5c20062e80e66f6104cd78ba7b2672de" + "value": "sha256:cf1ff80f294be2c782b248b643bf45ad2aa8d1c45d4fd48ef3e160e601037b42" } diff --git a/beam-apps/fixtures/unsupported-beam/index.json b/beam-apps/fixtures/unsupported-beam/index.json index 4ace360..872607e 100644 --- a/beam-apps/fixtures/unsupported-beam/index.json +++ b/beam-apps/fixtures/unsupported-beam/index.json @@ -12,13 +12,13 @@ "version": "1.0.0", "min_beam_version": "0.1.2", "manifest_url": "https://registry.beam.payy.network/apps/uniswap/1.0.0/manifest.json", - "manifest_sha256": "sha256:9b1e84f51ef0e5afd88a79aaaaa9ebf24b8d15a0a321c0ffa93c6da0ba5c8ede", + "manifest_sha256": "sha256:679a551bf48519a586975b25385e310519b077853383a4b3f7c2ff7b66ca3470", "module_url": "https://registry.beam.payy.network/apps/uniswap/1.0.0/module.wasm", - "module_sha256": "sha256:b155ff4bbbab293085ee393fc12cd9ddcda0e531694521909cd8b5c433ecb927", + "module_sha256": "sha256:15dadd2f783c783594c5c586f494f7b31be6dc89e5efbab1c479cf317b15ad0f", "signature": { "algorithm": "sha256-dev", "key_id": "payy-dev-2026-05", - "value": "sha256:5b8c5aa5f89bf386efbbcdb3a8c670e24bf7a583c9ed0f9c327dedcdeb5a87f0" + "value": "sha256:d66c14c15fb72b7d083a8a61984e2356a130b521fb70e664edefcfabd08ed8dd" } } ] @@ -27,6 +27,6 @@ "signature": { "algorithm": "sha256-dev", "key_id": "payy-dev-2026-05", - "value": "sha256:de6a24df0daabff15742faa1754c8c254a1d4157a55107766cee0a873e914411" + "value": "sha256:40a78ac42754c32d9e50aab13e98882852a73f14155a648919d1fdae928e78a8" } } diff --git a/beam-apps/fixtures/unsupported-beam/index.json.sig b/beam-apps/fixtures/unsupported-beam/index.json.sig index c10b635..9c8b5f1 100644 --- a/beam-apps/fixtures/unsupported-beam/index.json.sig +++ b/beam-apps/fixtures/unsupported-beam/index.json.sig @@ -1,5 +1,5 @@ { "algorithm": "sha256-dev", "key_id": "payy-dev-2026-05", - "value": "sha256:de6a24df0daabff15742faa1754c8c254a1d4157a55107766cee0a873e914411" + "value": "sha256:40a78ac42754c32d9e50aab13e98882852a73f14155a648919d1fdae928e78a8" } diff --git a/beam-apps/fixtures/valid/apps/uniswap/1.0.0/manifest.json b/beam-apps/fixtures/valid/apps/uniswap/1.0.0/manifest.json index 76c5ba2..a953177 100644 --- a/beam-apps/fixtures/valid/apps/uniswap/1.0.0/manifest.json +++ b/beam-apps/fixtures/valid/apps/uniswap/1.0.0/manifest.json @@ -7,7 +7,7 @@ "description": "Prepare public Uniswap swaps through Beam-mediated HTTP, chain, approval, and transaction planning.", "min_beam_version": "0.1.2", "wasm": { - "sha256": "sha256:b155ff4bbbab293085ee393fc12cd9ddcda0e531694521909cd8b5c433ecb927", + "sha256": "sha256:15dadd2f783c783594c5c586f494f7b31be6dc89e5efbab1c479cf317b15ad0f", "entrypoint": "beam_app_main" }, "catalog": { @@ -290,6 +290,6 @@ "signature": { "algorithm": "sha256-dev", "key_id": "payy-dev-2026-05", - "value": "sha256:6fa02552dc669810b58d6fe9a55cc36546221c3b25cf9340ab6ff74388f372fb" + "value": "sha256:2f47a3e064fdc3572cc68eb52929147275f5e57fc958e82e59e8c2a5f21ab57e" } } diff --git a/beam-apps/fixtures/valid/apps/uniswap/1.0.0/manifest.json.sig b/beam-apps/fixtures/valid/apps/uniswap/1.0.0/manifest.json.sig index 4748bf1..d16074d 100644 --- a/beam-apps/fixtures/valid/apps/uniswap/1.0.0/manifest.json.sig +++ b/beam-apps/fixtures/valid/apps/uniswap/1.0.0/manifest.json.sig @@ -1,5 +1,5 @@ { "algorithm": "sha256-dev", "key_id": "payy-dev-2026-05", - "value": "sha256:6fa02552dc669810b58d6fe9a55cc36546221c3b25cf9340ab6ff74388f372fb" + "value": "sha256:2f47a3e064fdc3572cc68eb52929147275f5e57fc958e82e59e8c2a5f21ab57e" } diff --git a/beam-apps/fixtures/valid/apps/uniswap/1.0.0/module.wasm b/beam-apps/fixtures/valid/apps/uniswap/1.0.0/module.wasm index a4fb777..1d2b3fd 100644 Binary files a/beam-apps/fixtures/valid/apps/uniswap/1.0.0/module.wasm and b/beam-apps/fixtures/valid/apps/uniswap/1.0.0/module.wasm differ diff --git a/beam-apps/fixtures/valid/apps/uniswap/1.0.0/version.json.sig b/beam-apps/fixtures/valid/apps/uniswap/1.0.0/version.json.sig index d6a752e..082e83b 100644 --- a/beam-apps/fixtures/valid/apps/uniswap/1.0.0/version.json.sig +++ b/beam-apps/fixtures/valid/apps/uniswap/1.0.0/version.json.sig @@ -1,5 +1,5 @@ { "algorithm": "sha256-dev", "key_id": "payy-dev-2026-05", - "value": "sha256:5b8c5aa5f89bf386efbbcdb3a8c670e24bf7a583c9ed0f9c327dedcdeb5a87f0" + "value": "sha256:d66c14c15fb72b7d083a8a61984e2356a130b521fb70e664edefcfabd08ed8dd" } diff --git a/beam-apps/fixtures/valid/catalog/apps/uniswap.json b/beam-apps/fixtures/valid/catalog/apps/uniswap.json index 45413a2..a6fca62 100644 --- a/beam-apps/fixtures/valid/catalog/apps/uniswap.json +++ b/beam-apps/fixtures/valid/catalog/apps/uniswap.json @@ -271,7 +271,7 @@ "sensitive_args": [] } ], - "readme_markdown": "# Uniswap App\n\nThe Uniswap app turns a swap request into a Beam-approved action plan. It asks\nthe Uniswap Trading API for a quote, checks your current ERC-20 allowance,\nprepares an exact approval only when one is needed, builds the swap transaction,\nand hands the whole plan to Beam for approval and execution.\n\nBeam owns your wallet boundary. The app never receives private keys, cannot sign\ntransactions, and cannot send a transaction on its own.\n\n## Install\n\n```bash\nbeam apps install uniswap\n```\n\nBeam shows the publisher, version, supported chains, network access, wallet\ncapabilities, and storage permissions before activating the app. To inspect the\nsame permission summary without installing:\n\n```bash\nbeam apps install uniswap --dry-run\n```\n\n## Swap\n\n```bash\nbeam x uniswap swap [options]\n```\n\nExample:\n\n```bash\nbeam x uniswap swap USDC ETH 100 --chain base --from alice\n```\n\nBeam shows the quote, any required approval, and the swap as a single plan. You\napprove the final plan before Beam signs or submits anything.\n\n## Options\n\n- `--min-receive ` sets the minimum acceptable output amount.\n- `--max-gas ` rejects the plan if estimated gas exceeds the limit.\n- `--slippage-bps ` sets max slippage in basis points.\n- `--recipient ` sends output to another wallet, ENS name, or\n EVM address.\n- `--deadline-seconds ` sets the quote and transaction deadline window.\n- `--unlimited-approval` asks Beam to request an unlimited ERC-20 approval\n instead of the default exact approval.\n\n## How a Swap Works\n\n1. The app fetches a quote through Beam-mediated HTTPS access.\n2. Beam reads your token balance and allowance through its chain APIs.\n3. If allowance is short, Beam adds an exact ERC-20 approval step.\n4. The app prepares the swap transaction.\n5. Beam renders the typed plan, asks for approval, signs, submits, and tracks the\n receipt.\n\n## Agents\n\nAgents and other non-interactive callers should prepare a continuation, inspect\nit, then explicitly approve and execute it:\n\n```bash\nbeam x uniswap swap USDC ETH 100 --chain base --from alice --prepare --format json\nbeam apps approvals show \nbeam apps approvals approve --execute\n```\n\n`--no-prompt` fails closed for wallet-affecting swaps unless the command is\npreparing a continuation or executing an already-approved continuation.\n\n## Permissions\n\nThe app requests HTTPS access to the Uniswap Trading API, read/simulate/send\naccess on supported public EVM chains, ERC-20 approval planning, wallet balance\nreads, transaction proposals, and app-local storage. It does not request Beam\nprivacy capabilities in v1.\n\nSupported chains are Ethereum, Base, Arbitrum, Polygon, BNB, and Sepolia.\n\nApprovals default to the exact amount required. Unlimited approvals require the\nexplicit `--unlimited-approval` flag and are shown in the Beam approval prompt.\n", + "readme_markdown": "# Uniswap App\n\nThe Uniswap app turns a swap request into a Beam-approved action plan. It asks\nthe Uniswap Trading API for a quote, checks your current ERC-20 allowance,\nprepares an exact approval only when one is needed, builds the swap transaction,\nand hands the whole plan to Beam for approval and execution.\n\nBeam owns your wallet boundary. The app never receives private keys, cannot sign\ntransactions, and cannot send a transaction on its own.\n\n## Install\n\n```bash\nbeam apps install uniswap\n```\n\nBeam shows the publisher, version, supported chains, network access, wallet\ncapabilities, and storage permissions before activating the app. To inspect the\nsame permission summary without installing:\n\n```bash\nbeam apps install uniswap --dry-run\n```\n\n## Swap\n\n```bash\nbeam x uniswap swap [options]\n```\n\nCommand help is exported through the app manifest and rendered by Beam without\nfetching a quote or invoking guest WASM:\n\n```bash\nbeam x uniswap swap --help\n```\n\nExample:\n\n```bash\nbeam x uniswap swap USDC ETH 100 --chain base --from alice\n```\n\nBeam shows the quote, any required approval, and the swap as a single plan. You\napprove the final plan before Beam signs or submits anything.\n\n## Options\n\n- `--min-receive ` sets the minimum acceptable output amount.\n- `--max-gas ` rejects the plan if estimated gas exceeds the limit.\n- `--slippage-bps ` sets max slippage in basis points.\n- `--recipient ` sends output to another wallet, ENS name, or\n EVM address.\n- `--deadline-seconds ` sets the quote and transaction deadline window.\n- `--unlimited-approval` asks Beam to request an unlimited ERC-20 approval\n instead of the default exact approval.\n\nToken inputs can be Beam token labels, `native`, the active chain's native\nsymbol, or EVM token addresses.\n\n## How a Swap Works\n\n1. The app fetches a quote through Beam-mediated HTTPS access.\n2. Beam reads your token balance and allowance through its chain APIs.\n3. If allowance is short, Beam adds an exact ERC-20 approval step.\n4. The app prepares the swap transaction.\n5. Beam renders the typed plan, asks for approval, signs, submits, and tracks the\n receipt.\n\nIf an approval is required, Beam submits it first and submits the swap only after\nthe approval is confirmed. If fresh allowance already satisfies the exact plan,\nBeam skips the approval step. Execution output reports confirmed, pending,\ndropped, or skipped transaction state; confirmed receipts include the RPC status\nvalue.\n\n## Agents\n\nAgents and other non-interactive callers should prepare a continuation, inspect\nit, then explicitly approve and execute it:\n\n```bash\nbeam x uniswap swap USDC ETH 100 --chain base --from alice --prepare --format json\nbeam apps approvals show \nbeam apps approvals approve --execute\n```\n\n`--no-prompt` fails closed for wallet-affecting swaps unless the command is\npreparing a continuation or executing an already-approved continuation.\n\n## Permissions\n\nThe app requests HTTPS access to the Uniswap Trading API, read/simulate/send\naccess on supported public EVM chains, ERC-20 approval planning, wallet balance\nreads, transaction proposals, and app-local storage. It does not request Beam\nprivacy capabilities in v1.\n\nSupported chains are Ethereum, Base, Arbitrum, Polygon, BNB, and Sepolia.\n\nApprovals default to the exact amount required. Unlimited approvals require the\nexplicit `--unlimited-approval` flag and are shown in the Beam approval prompt.\n", "manifest_summary": { "format_version": 1, "min_beam_version": "0.1.2", @@ -293,6 +293,6 @@ "signature": { "algorithm": "sha256-dev", "key_id": "payy-dev-2026-05", - "value": "sha256:751da26b235d861af214324b036ba5ee5c20062e80e66f6104cd78ba7b2672de" + "value": "sha256:cf1ff80f294be2c782b248b643bf45ad2aa8d1c45d4fd48ef3e160e601037b42" } } diff --git a/beam-apps/fixtures/valid/catalog/apps/uniswap.json.sig b/beam-apps/fixtures/valid/catalog/apps/uniswap.json.sig index 486afc8..add7d00 100644 --- a/beam-apps/fixtures/valid/catalog/apps/uniswap.json.sig +++ b/beam-apps/fixtures/valid/catalog/apps/uniswap.json.sig @@ -1,5 +1,5 @@ { "algorithm": "sha256-dev", "key_id": "payy-dev-2026-05", - "value": "sha256:751da26b235d861af214324b036ba5ee5c20062e80e66f6104cd78ba7b2672de" + "value": "sha256:cf1ff80f294be2c782b248b643bf45ad2aa8d1c45d4fd48ef3e160e601037b42" } diff --git a/beam-apps/fixtures/valid/index.json b/beam-apps/fixtures/valid/index.json index 4ace360..872607e 100644 --- a/beam-apps/fixtures/valid/index.json +++ b/beam-apps/fixtures/valid/index.json @@ -12,13 +12,13 @@ "version": "1.0.0", "min_beam_version": "0.1.2", "manifest_url": "https://registry.beam.payy.network/apps/uniswap/1.0.0/manifest.json", - "manifest_sha256": "sha256:9b1e84f51ef0e5afd88a79aaaaa9ebf24b8d15a0a321c0ffa93c6da0ba5c8ede", + "manifest_sha256": "sha256:679a551bf48519a586975b25385e310519b077853383a4b3f7c2ff7b66ca3470", "module_url": "https://registry.beam.payy.network/apps/uniswap/1.0.0/module.wasm", - "module_sha256": "sha256:b155ff4bbbab293085ee393fc12cd9ddcda0e531694521909cd8b5c433ecb927", + "module_sha256": "sha256:15dadd2f783c783594c5c586f494f7b31be6dc89e5efbab1c479cf317b15ad0f", "signature": { "algorithm": "sha256-dev", "key_id": "payy-dev-2026-05", - "value": "sha256:5b8c5aa5f89bf386efbbcdb3a8c670e24bf7a583c9ed0f9c327dedcdeb5a87f0" + "value": "sha256:d66c14c15fb72b7d083a8a61984e2356a130b521fb70e664edefcfabd08ed8dd" } } ] @@ -27,6 +27,6 @@ "signature": { "algorithm": "sha256-dev", "key_id": "payy-dev-2026-05", - "value": "sha256:de6a24df0daabff15742faa1754c8c254a1d4157a55107766cee0a873e914411" + "value": "sha256:40a78ac42754c32d9e50aab13e98882852a73f14155a648919d1fdae928e78a8" } } diff --git a/beam-apps/fixtures/valid/index.json.sig b/beam-apps/fixtures/valid/index.json.sig index c10b635..9c8b5f1 100644 --- a/beam-apps/fixtures/valid/index.json.sig +++ b/beam-apps/fixtures/valid/index.json.sig @@ -1,5 +1,5 @@ { "algorithm": "sha256-dev", "key_id": "payy-dev-2026-05", - "value": "sha256:de6a24df0daabff15742faa1754c8c254a1d4157a55107766cee0a873e914411" + "value": "sha256:40a78ac42754c32d9e50aab13e98882852a73f14155a648919d1fdae928e78a8" } diff --git a/pkg/beam-cli/Cargo.toml b/pkg/beam-cli/Cargo.toml index 77eb1d1..2a69627 100644 --- a/pkg/beam-cli/Cargo.toml +++ b/pkg/beam-cli/Cargo.toml @@ -12,6 +12,7 @@ path = "src/main.rs" argon2 = { workspace = true } async-trait = { workspace = true } base64 = { workspace = true } +bip39 = { workspace = true } clap = { workspace = true } contextful = { workspace = true } contracts = { workspace = true } diff --git a/pkg/beam-cli/README.md b/pkg/beam-cli/README.md index ffcca9a..f70afa2 100644 --- a/pkg/beam-cli/README.md +++ b/pkg/beam-cli/README.md @@ -250,14 +250,17 @@ Run app commands with the short `x` alias or the explicit lifecycle form: ```bash beam x uniswap --help +beam x uniswap swap --help +beam --chain base --from alice x uniswap swap USDC ETH 100 --prepare +beam apps run uniswap swap USDC ETH 100 --chain base --from alice --prepare ``` Product app business logic lives outside Beam CLI in `beam-apps/apps/`. Beam CLI owns the generic registry, cache, WASM validation, permission checks, -approval records, and execution of approved action plans. The Uniswap app is -built into the registry as WASM, but `beam x uniswap swap ...` remains behind the -generic guest host-ABI invocation milestone; Beam CLI no longer contains a -Uniswap-specific built-in planner. +host ABI, approval records, and execution of approved action plans. The Uniswap +app is built into the registry as WASM and `beam x uniswap swap ...` runs through +the generic guest command path; Beam CLI no longer contains a Uniswap-specific +built-in planner. The Uniswap app will use Beam-mediated HTTPS requests to the Uniswap Trading API. Release registry builds inject the Payy-managed public Trading API key into @@ -285,9 +288,18 @@ beam apps approvals show beam apps approvals approve --execute ``` -`--no-prompt` fails closed for wallet-affecting app commands unless the command is -preparing a continuation. Removing an app keeps app-local data by default; pass -`--purge-data` to delete `~/.beam/apps/data/` as well. +Uniswap token arguments can be configured token labels, `native`, native chain +symbols, or EVM token addresses. Swap options include `--min-receive`, +`--slippage-bps`, `--deadline-seconds`, `--recipient`, `--max-gas`, and +`--unlimited-approval`. Approvals default to the exact amount required and the +swap is only sent after an approval is confirmed or skipped because fresh +allowance is already sufficient. Execution output reports confirmed, pending, +dropped, or skipped transaction state as Beam receives it from the active RPC +path; confirmed receipts include the reported transaction status. + +`--no-prompt` fails closed for wallet-affecting app commands unless the command +is preparing a continuation. Removing an app keeps app-local data by default; +pass `--purge-data` to delete `~/.beam/apps/data/` as well. ## Privacy @@ -336,6 +348,9 @@ Supported wallet commands: ```bash beam wallets create [name] beam wallets import [--name ] [--private-key-stdin | --private-key-fd ] +beam wallets export-private-key [wallet] +beam wallets export-recovery-phrase [wallet] +beam wallets import-recovery-phrase [--name ] [--expected-address
] [--phrase-stdin | --phrase-fd ] beam wallets list beam wallets rename beam wallets address [--private-key-stdin | --private-key-fd ] @@ -348,6 +363,29 @@ Notes: - Each wallet record stores its KDF metadata alongside the encrypted key so future beam releases can keep decrypting older wallets after Argon2 tuning changes. - `beam wallets import` and `beam wallets address` read the private key from a hidden prompt by default. - Use `--private-key-stdin` for pipelines and `--private-key-fd ` for redirected file descriptors. +- `beam wallets export-private-key [wallet]` prints the stored wallet's raw primary EVM private key after prompting for the keystore password. When `[wallet]` is omitted, Beam exports the active wallet: the configured default unless `--from` overrides it. +- **Important:** The exported private key gives full control over that wallet. Do not paste it into command arguments, shell variables, issue trackers, chat, screenshots, or logs. +- The exported private key is the primary EVM private key stored by Beam. +- `beam wallets export-recovery-phrase [wallet]` exports a 24-word BIP39 phrase for the selected + stored wallet. If `[wallet]` is omitted, Beam exports the active wallet: the configured default + unless `--from` overrides it. +- `beam wallets import-recovery-phrase` imports a wallet from a recovery phrase. By default the + phrase is read from a hidden prompt; use `--phrase-stdin` for pipelines and `--phrase-fd ` + for already-open file descriptors. +- Importing a recovery phrase prints the derived EVM wallet address before asking for the new + wallet password. Use `--expected-address
` to fail before persistence if the phrase + derives a different address than expected. +- Recovery phrases are Payy-compatible entropy backups: Beam maps the 32-byte EVM private key + directly to and from a 24-word BIP39 phrase. This is not a MetaMask or HD-wallet seed flow; no + derivation path, account index, or seed expansion is used. +- Importing a recovery phrase restores the same EVM address and the same Payy private address, + because Beam derives Payy privacy keys from the EVM private key. It does not restore local Beam + config, scan state, history, custom RPCs, token labels, or pending claim artifacts. +- Treat the phrase exactly like the private key. Avoid storing it in plaintext files; `--phrase-fd` + is mainly useful for secret-manager streams and tests. +- Do not paste recovery phrases into command arguments or shell variables. Shell history can persist + those values. Prefer the hidden prompt, `--phrase-stdin` from a secret manager, or `--phrase-fd` + with an already-open descriptor. - `beam wallets create` prompts for a wallet name when you omit `[name]`, suggesting the next available `wallet-N` alias and accepting it when you press Enter. - `beam wallets import` uses a verified ENS reverse record as the default wallet name when one resolves back to the imported address; otherwise it falls back to the next `wallet-N` alias. - The CLI prompts for a password when creating/importing a wallet. Press Enter at the password prompt to create a wallet with no password; whitespace-only passwords are rejected. @@ -368,8 +406,14 @@ Examples: ```bash beam wallets import --name alice beam wallets rename alice primary -printf '%s\n' "$BEAM_PRIVATE_KEY" | beam wallets import --private-key-stdin --name alice +beam --format compact wallets export-private-key alice +beam wallets import --private-key-fd 3 --name alice 3< ~/.config/beam/private-key.txt beam wallets address --private-key-fd 3 3< ~/.config/beam/private-key.txt +beam wallets export-recovery-phrase alice +beam wallets import-recovery-phrase --name alice +beam wallets import-recovery-phrase --expected-address 0x1111111111111111111111111111111111111111 --name alice +pass show beam/alice/recovery-phrase | beam wallets import-recovery-phrase --phrase-stdin --name alice +beam wallets import-recovery-phrase --phrase-fd 3 --name alice 3< <(pass show beam/alice/recovery-phrase) ``` The signing flow is built on a `Signer` abstraction so hardware-wallet implementations can @@ -657,7 +701,9 @@ networks use `#E0FF32`. Sensitive wallet and privacy commands are never written to REPL history, and startup immediately rewrites `~/.beam/history.txt` after scrubbing previously persisted `wallets import` / -`wallets address` entries, including mistyped slash-prefixed variants such as `/wallets import`. +`wallets export-private-key` / `wallets import-recovery-phrase` / `wallets address` entries, +including mistyped slash-prefixed variants such as `/wallets import`. `wallets +export-recovery-phrase` may be recorded, but the phrase itself is never part of the command line. Privacy claim artifacts, ephemeral sends, claim-link messages, memos, and private-payment fetch commands are also excluded from persisted history. diff --git a/pkg/beam-cli/src/apps/approvals.rs b/pkg/beam-cli/src/apps/approvals.rs index e0f9a85..d453722 100644 --- a/pkg/beam-cli/src/apps/approvals.rs +++ b/pkg/beam-cli/src/apps/approvals.rs @@ -1,3 +1,4 @@ +// lint-long-file-override allow-max-lines=300 use std::path::Path; use contextful::ResultContextExt; @@ -158,7 +159,8 @@ pub fn ensure_approval_executable(record: &ApprovalRecord) -> Result<()> { pub fn ensure_approval_integrity(record: &ApprovalRecord) -> Result<()> { ensure_approval_active(record)?; - ensure_approval_plan_hash(record) + ensure_approval_plan_hash(record)?; + ensure_uniswap_swap_bindings(record) } pub fn ensure_approval_active(record: &ApprovalRecord) -> Result<()> { @@ -185,3 +187,49 @@ pub fn plan_hash(plan: &ActionPlan) -> Result { let bytes = serde_json::to_vec(plan).context("encode beam app action plan")?; Ok(format!("sha256:{}", hex::encode(Sha256::digest(bytes)))) } + +fn ensure_uniswap_swap_bindings(record: &ApprovalRecord) -> Result<()> { + if record.plan.app_id != "uniswap" || !record.plan.command.starts_with("swap ") { + return Ok(()); + } + + for key in [ + "quote_id", + "quote_expires_at", + "route_hash", + "swap_calldata_hash", + "router", + "sell_token", + "buy_token", + "amount_in", + "amount_out", + ] { + if binding(record, key).is_none() { + return Err(Error::InvalidGuestOutput { + reason: format!("uniswap approval missing binding {key}"), + }); + } + } + + let quote_expires_at = binding(record, "quote_expires_at") + .and_then(|value| value.parse::().ok()) + .ok_or_else(|| Error::InvalidGuestOutput { + reason: "uniswap approval has invalid quote_expires_at binding".to_string(), + })?; + if quote_expires_at < now() { + return Err(Error::ApprovalExpired { + approval_id: record.id.clone(), + }); + } + + Ok(()) +} + +fn binding<'a>(record: &'a ApprovalRecord, key: &str) -> Option<&'a str> { + record + .plan + .bindings + .iter() + .find(|binding| binding.key == key) + .map(|binding| binding.value.as_str()) +} diff --git a/pkg/beam-cli/src/apps/error.rs b/pkg/beam-cli/src/apps/error.rs index ea7ff2f..cb5b242 100644 --- a/pkg/beam-cli/src/apps/error.rs +++ b/pkg/beam-cli/src/apps/error.rs @@ -58,6 +58,15 @@ pub enum Error { #[error("[beam-cli/apps] app module is not a wasm module: {app}")] InvalidWasmModule { app: String }, + #[error("[beam-cli/apps] app wasm missing export `{export}` for {app}")] + MissingWasmExport { app: String, export: String }, + + #[error("[beam-cli/apps] app command failed: {message}")] + GuestCommandFailed { message: String }, + + #[error("[beam-cli/apps] app guest output is invalid: {reason}")] + InvalidGuestOutput { reason: String }, + #[error("[beam-cli/apps] app requested blocked contract target: {target}")] ContractPermissionDenied { target: String }, diff --git a/pkg/beam-cli/src/apps/host.rs b/pkg/beam-cli/src/apps/host.rs index 8401cf4..9866983 100644 --- a/pkg/beam-cli/src/apps/host.rs +++ b/pkg/beam-cli/src/apps/host.rs @@ -1,9 +1,5 @@ -// lint-long-file-override allow-max-lines=550 -#![expect( - dead_code, - reason = "declares host ABI surface before wasm guest bindings call every API" -)] -use std::{net::IpAddr, time::Duration}; +// lint-long-file-override allow-max-lines=700 +use std::{fs, net::IpAddr, path::PathBuf, time::Duration}; use contextful::ResultContextExt; use contracts::{Address, U256}; @@ -31,6 +27,19 @@ const MAX_REDIRECTS: usize = 5; const MAX_REQUEST_BYTES: usize = 64 * 1024; const MAX_RESPONSE_BYTES: usize = 1024 * 1024; +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct HostMetadata { + pub app_id: String, + pub app_version: String, + pub chain: String, + pub chain_id: u64, + pub host_api_version: u32, + pub manifest_sha256: String, + pub now: u64, + pub wallet: String, + pub wasm_sha256: String, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] pub enum HostRequest { @@ -43,6 +52,37 @@ pub enum HostRequest { SimulateTransaction(HostTransaction), SubmitTransaction(HostTransaction), PollReceipt { tx_hash: String }, + ResolveAddress { value: Option }, + AppStorageGet { key: String }, + AppStorageSet { key: String, value: Value }, + AppStorageRemove { key: String }, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct HostCallResponse { + pub ok: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub value: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +impl HostCallResponse { + pub fn ok(value: Value) -> Self { + Self { + ok: true, + value: Some(value), + error: None, + } + } + + pub fn error(error: String) -> Self { + Self { + ok: false, + value: None, + error: Some(error), + } + } } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -110,6 +150,116 @@ pub struct HostTransaction { pub spender: Option, } +pub async fn handle_host_request( + app: &BeamApp, + permissions: &AppPermissions, + metadata: &HostMetadata, + request: HostRequest, + structured_output: &mut Option, + diagnostics: &mut Vec, +) -> Result { + match request { + HostRequest::AppMetadata => Ok(json!(metadata)), + HostRequest::Args { args } => Ok(json!({ "args": args })), + HostRequest::StructuredOutput { value } => { + *structured_output = Some(value.clone()); + Ok(json!({ "accepted": true })) + } + HostRequest::Diagnostic { level, message } => { + diagnostics.push(json!({ + "level": level, + "message": message, + })); + Ok(json!({ "accepted": true })) + } + HostRequest::HttpFetch(request) => Ok(json!(fetch_http(permissions, request).await?)), + HostRequest::ChainRead(request) => chain_read(app, permissions, request).await, + HostRequest::SimulateTransaction(transaction) => { + let (_, client) = app + .active_chain_client() + .await + .context("connect app simulation chain client")?; + let from = app + .active_address() + .await + .context("resolve app simulation wallet")?; + simulate_transaction(&client, from, permissions, &transaction).await?; + Ok(json!({ "ok": true })) + } + HostRequest::SubmitTransaction(_) => Err(Error::InvalidHostRequest { + reason: "transaction submission is only available during approved plan execution" + .to_string(), + }), + HostRequest::PollReceipt { .. } => Err(Error::InvalidHostRequest { + reason: "receipt polling is only available during approved plan execution".to_string(), + }), + HostRequest::ResolveAddress { value } => { + let address = match value.as_deref() { + Some(value) => app + .resolve_wallet_or_address(value) + .await + .context("resolve beam app requested address")?, + None => app + .active_address() + .await + .context("resolve beam app wallet")?, + }; + Ok(json!({ "address": format!("{address:#x}") })) + } + HostRequest::AppStorageGet { key } => { + let path = app_storage_path(app, &metadata.app_id, &key)?; + if !path.exists() { + return Ok(json!({ "value": null, "exists": false })); + } + let value = serde_json::from_slice::( + &fs::read(path).context("read beam app storage value")?, + ) + .context("decode beam app storage value")?; + Ok(json!({ "value": value, "exists": true })) + } + HostRequest::AppStorageSet { key, value } => { + let path = app_storage_path(app, &metadata.app_id, &key)?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).context("create beam app storage directory")?; + } + fs::write( + path, + serde_json::to_vec_pretty(&value).context("encode beam app storage value")?, + ) + .context("write beam app storage value")?; + Ok(json!({ "written": true })) + } + HostRequest::AppStorageRemove { key } => { + let path = app_storage_path(app, &metadata.app_id, &key)?; + if path.exists() { + fs::remove_file(path).context("remove beam app storage value")?; + } + Ok(json!({ "removed": true })) + } + } +} + +fn app_storage_path(app: &BeamApp, app_id: &str, key: &str) -> Result { + if key.is_empty() + || key.starts_with('.') + || !key + .chars() + .all(|char| char.is_ascii_alphanumeric() || matches!(char, '-' | '_' | '.')) + { + return Err(Error::InvalidHostRequest { + reason: format!("invalid app storage key {key}"), + }); + } + + Ok(app + .paths + .root + .join("apps") + .join("data") + .join(app_id) + .join(key)) +} + pub fn ensure_http_allowed(permissions: &AppPermissions, url: &str) -> Result { let url = Url::parse(url).map_err(|_| Error::InvalidPermissionUrl { value: url.to_string(), diff --git a/pkg/beam-cli/src/apps/runtime.rs b/pkg/beam-cli/src/apps/runtime.rs index 16d3771..8bd9aec 100644 --- a/pkg/beam-cli/src/apps/runtime.rs +++ b/pkg/beam-cli/src/apps/runtime.rs @@ -1,11 +1,30 @@ +// lint-long-file-override allow-max-lines=300 use std::path::Path; use contextful::ResultContextExt; -use wasmi::{Engine, Linker, Module, Store}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use wasmi::{Config, Engine, Linker, Module, Store}; -use crate::apps::{Error, Result}; +use crate::{ + apps::{ + Error, Result, + host::HostMetadata, + model::{ActionPlan, AppManifest, InstalledApp}, + store::now, + }, + output::{CommandOutput, OutputMode}, + runtime::BeamApp, +}; + +mod guest; + +use guest::{HostState, guest_alloc, register_host_imports, typed_func, unpack_ptr_len}; const WASM_MAGIC: &[u8; 4] = b"\0asm"; +const HOST_API_VERSION: u32 = 1; +const MAX_GUEST_RESPONSE_BYTES: usize = 2 * 1024 * 1024; +const WASM_FUEL: u64 = 30_000_000; pub fn validate_wasm_module(app_id: &str, entrypoint: &str, path: &Path) -> Result<()> { let bytes = std::fs::read(path).context("read beam app wasm module")?; @@ -14,32 +33,192 @@ pub fn validate_wasm_module(app_id: &str, entrypoint: &str, path: &Path) -> Resu app: app_id.to_string(), }); } - AppRuntime::default().instantiate(app_id, entrypoint, &bytes)?; + AppRuntime::default().instantiate_for_validation(app_id, entrypoint, &bytes)?; Ok(()) } -#[derive(Default)] pub struct AppRuntime { engine: Engine, } +impl Default for AppRuntime { + fn default() -> Self { + let mut config = Config::default(); + config.consume_fuel(true); + Self { + engine: Engine::new(&config), + } + } +} + impl AppRuntime { - fn instantiate(&self, app_id: &str, entrypoint: &str, bytes: &[u8]) -> Result<()> { + fn instantiate_for_validation( + &self, + app_id: &str, + entrypoint: &str, + bytes: &[u8], + ) -> Result<()> { let module = Module::new(&self.engine, bytes).context("compile beam app wasm module")?; - let mut store = Store::new(&self.engine, HostState); - let linker = >::new(&self.engine); + let mut store = Store::new(&self.engine, HostState::validation()); + store + .set_fuel(WASM_FUEL) + .context("set beam app wasm fuel")?; + store.limiter(|state| &mut state.limits); + let mut linker = >::new(&self.engine); + register_host_imports(&mut linker)?; let instance = linker .instantiate_and_start(&mut store, &module) .context("instantiate beam app wasm module")?; if instance.get_func(&store, entrypoint).is_none() { - return Err(Error::InvalidHostRequest { - reason: format!("{app_id} wasm missing entrypoint {entrypoint}"), + return Err(Error::MissingWasmExport { + app: app_id.to_string(), + export: entrypoint.to_string(), }); } Ok(()) } + + pub async fn run_command( + &self, + app: &BeamApp, + manifest: &AppManifest, + installed: &InstalledApp, + module_path: &Path, + args: &[String], + ) -> Result { + let bytes = std::fs::read(module_path).context("read beam app wasm module")?; + let module = Module::new(&self.engine, &bytes).context("compile beam app wasm module")?; + let metadata = self.metadata(app, manifest, installed).await?; + let invocation = GuestInvocation { + args: args.to_vec(), + host_api_version: HOST_API_VERSION, + metadata: metadata.clone(), + output_mode: output_mode_label(app.output_mode).to_string(), + }; + let mut store = Store::new( + &self.engine, + HostState::new(app.clone(), manifest.permissions.clone(), metadata), + ); + store + .set_fuel(WASM_FUEL) + .context("set beam app wasm fuel")?; + store.limiter(|state| &mut state.limits); + let mut linker = >::new(&self.engine); + register_host_imports(&mut linker)?; + let instance = linker + .instantiate_and_start(&mut store, &module) + .context("instantiate beam app wasm module")?; + + let memory = + instance + .get_memory(&store, "memory") + .ok_or_else(|| Error::MissingWasmExport { + app: manifest.id.clone(), + export: "memory".to_string(), + })?; + let alloc = typed_func::(&store, &instance, "beam_alloc", &manifest.id)?; + let free = typed_func::<(i32, i32), ()>(&store, &instance, "beam_free", &manifest.id)?; + let main = typed_func::<(i32, i32), i64>( + &store, + &instance, + &manifest.wasm.entrypoint, + &manifest.id, + )?; + let input = serde_json::to_vec(&invocation).context("serialize beam app invocation")?; + let input_ptr = guest_alloc(&mut store, &alloc, input.len())?; + memory + .write(&mut store, input_ptr, &input) + .context("write beam app invocation")?; + let packed = main + .call(&mut store, (input_ptr as i32, input.len() as i32)) + .context("call beam app command")?; + free.call(&mut store, (input_ptr as i32, input.len() as i32)) + .context("free beam app invocation")?; + let (output_ptr, output_len) = unpack_ptr_len(packed)?; + if output_len > MAX_GUEST_RESPONSE_BYTES { + return Err(Error::InvalidGuestOutput { + reason: format!("guest response too large: {output_len} bytes"), + }); + } + let mut output = vec![0_u8; output_len]; + memory + .read(&store, output_ptr, &mut output) + .context("read beam app command output")?; + free.call(&mut store, (output_ptr as i32, output_len as i32)) + .context("free beam app command output")?; + + let response = + serde_json::from_slice::(&output).context("decode beam app output")?; + match response { + GuestResponse::ActionPlan { plan } => Ok(GuestCommandResult::ActionPlan(*plan)), + GuestResponse::Output { value } => { + let text = value + .get("message") + .and_then(Value::as_str) + .unwrap_or("App command completed") + .to_string(); + Ok(GuestCommandResult::Output(CommandOutput::new(text, value))) + } + GuestResponse::Error { message } => Err(Error::GuestCommandFailed { message }), + } + } + + async fn metadata( + &self, + app: &BeamApp, + manifest: &AppManifest, + installed: &InstalledApp, + ) -> Result { + let chain = app.active_chain().await.context("resolve beam app chain")?; + let wallet = app + .active_address() + .await + .context("resolve beam app wallet")?; + Ok(HostMetadata { + app_id: manifest.id.clone(), + app_version: manifest.version.clone(), + chain: chain.entry.key, + chain_id: chain.entry.chain_id, + host_api_version: HOST_API_VERSION, + manifest_sha256: installed.manifest_sha256.clone(), + now: now(), + wallet: format!("{wallet:#x}"), + wasm_sha256: installed.module_sha256.clone(), + }) + } +} + +#[derive(Debug)] +pub enum GuestCommandResult { + ActionPlan(ActionPlan), + Output(CommandOutput), } -struct HostState; +#[derive(Serialize)] +struct GuestInvocation { + args: Vec, + host_api_version: u32, + metadata: HostMetadata, + output_mode: String, +} + +#[derive(Deserialize)] +#[serde(tag = "kind", rename_all = "kebab-case")] +enum GuestResponse { + ActionPlan { plan: Box }, + Output { value: Value }, + Error { message: String }, +} + +fn output_mode_label(mode: OutputMode) -> &'static str { + match mode { + OutputMode::Default => "default", + OutputMode::Json => "json", + OutputMode::Yaml => "yaml", + OutputMode::Markdown => "markdown", + OutputMode::Compact => "compact", + OutputMode::Quiet => "quiet", + } +} diff --git a/pkg/beam-cli/src/apps/runtime/guest.rs b/pkg/beam-cli/src/apps/runtime/guest.rs new file mode 100644 index 0000000..40ad03d --- /dev/null +++ b/pkg/beam-cli/src/apps/runtime/guest.rs @@ -0,0 +1,225 @@ +// lint-long-file-override allow-max-lines=300 +use contextful::ResultContextExt; +use serde_json::Value; +use tokio::runtime::Handle; +use wasmi::{ + Caller, Extern, Instance, Linker, Memory, Store, StoreLimits, StoreLimitsBuilder, TypedFunc, + WasmParams, WasmResults, +}; + +use crate::{ + apps::{ + Error, Result, + host::{HostCallResponse, HostMetadata, HostRequest, handle_host_request}, + model::AppPermissions, + }, + runtime::BeamApp, +}; + +const MAX_WASM_MEMORY_BYTES: usize = 64 * 1024 * 1024; + +pub(super) struct HostState { + app: Option, + diagnostics: Vec, + pub(super) limits: StoreLimits, + metadata: Option, + permissions: Option, + structured_output: Option, +} + +impl HostState { + pub(super) fn new(app: BeamApp, permissions: AppPermissions, metadata: HostMetadata) -> Self { + Self { + app: Some(app), + diagnostics: Vec::new(), + limits: StoreLimitsBuilder::new() + .memory_size(MAX_WASM_MEMORY_BYTES) + .build(), + metadata: Some(metadata), + permissions: Some(permissions), + structured_output: None, + } + } + + pub(super) fn validation() -> Self { + Self { + app: None, + diagnostics: Vec::new(), + limits: StoreLimitsBuilder::new() + .memory_size(MAX_WASM_MEMORY_BYTES) + .build(), + metadata: None, + permissions: None, + structured_output: None, + } + } +} + +pub(super) fn register_host_imports(linker: &mut Linker) -> Result<()> { + linker + .func_wrap( + "env", + "beam_host_call", + |caller: Caller, + request_ptr: i32, + request_len: i32, + response_ptr: i32, + response_capacity: i32| + -> i32 { + host_call( + caller, + request_ptr, + request_len, + response_ptr, + response_capacity, + ) + .unwrap_or_else(|error| { + serde_json::to_vec(&HostCallResponse::error(error.to_string())) + .ok() + .and_then(|bytes| i32::try_from(bytes.len()).ok()) + .map(|len| -len) + .unwrap_or(i32::MIN) + }) + }, + ) + .context("register beam app host call")?; + Ok(()) +} + +pub(super) fn typed_func( + store: &Store, + instance: &Instance, + name: &str, + app_id: &str, +) -> Result> +where + Params: WasmParams, + Results: WasmResults, +{ + let func = instance + .get_func(store, name) + .ok_or_else(|| Error::MissingWasmExport { + app: app_id.to_string(), + export: name.to_string(), + })?; + Ok(func.typed(store).context("type beam app wasm export")?) +} + +pub(super) fn guest_alloc( + store: &mut Store, + alloc: &TypedFunc, + len: usize, +) -> Result { + let len = i32::try_from(len).map_err(|_| Error::InvalidHostRequest { + reason: format!("guest allocation too large: {len} bytes"), + })?; + let ptr = alloc + .call(store, len) + .context("allocate beam app guest memory")?; + checked_ptr(ptr, "guest allocation pointer") +} + +pub(super) fn unpack_ptr_len(value: i64) -> Result<(usize, usize)> { + let value = u64::try_from(value).map_err(|_| Error::InvalidGuestOutput { + reason: format!("negative guest pointer/length result: {value}"), + })?; + let ptr = (value >> 32) as u32; + let len = (value & 0xffff_ffff) as u32; + Ok((ptr as usize, len as usize)) +} + +fn host_call( + mut caller: Caller, + request_ptr: i32, + request_len: i32, + response_ptr: i32, + response_capacity: i32, +) -> Result { + let request_len = checked_len(request_len, "request length")?; + let response_capacity = checked_len(response_capacity, "response capacity")?; + let memory = caller_memory(&caller)?; + let mut request_bytes = vec![0_u8; request_len]; + memory + .read( + &caller, + checked_ptr(request_ptr, "request pointer")?, + &mut request_bytes, + ) + .context("read beam app host request")?; + let request = serde_json::from_slice::(&request_bytes) + .context("decode beam app host request")?; + let app = caller + .data() + .app + .clone() + .ok_or_else(|| Error::InvalidHostRequest { + reason: "host call is unavailable during validation".to_string(), + })?; + let permissions = + caller + .data() + .permissions + .clone() + .ok_or_else(|| Error::InvalidHostRequest { + reason: "host permissions missing".to_string(), + })?; + let metadata = caller + .data() + .metadata + .clone() + .ok_or_else(|| Error::InvalidHostRequest { + reason: "host metadata missing".to_string(), + })?; + let mut structured_output = caller.data().structured_output.clone(); + let mut diagnostics = caller.data().diagnostics.clone(); + let result = tokio::task::block_in_place(|| { + Handle::current().block_on(handle_host_request( + &app, + &permissions, + &metadata, + request, + &mut structured_output, + &mut diagnostics, + )) + }); + caller.data_mut().structured_output = structured_output; + caller.data_mut().diagnostics = diagnostics; + let response = match result { + Ok(value) => HostCallResponse::ok(value), + Err(error) => HostCallResponse::error(error.to_string()), + }; + let response = serde_json::to_vec(&response).context("serialize beam app host response")?; + if response.len() > response_capacity { + return Ok(-i32::try_from(response.len()).unwrap_or(i32::MAX)); + } + memory + .write( + &mut caller, + checked_ptr(response_ptr, "response pointer")?, + &response, + ) + .context("write beam app host response")?; + Ok(i32::try_from(response.len()).unwrap_or(i32::MAX)) +} + +fn caller_memory(caller: &Caller) -> Result { + match caller.get_export("memory") { + Some(Extern::Memory(memory)) => Ok(memory), + _ => Err(Error::MissingWasmExport { + app: "guest".to_string(), + export: "memory".to_string(), + }), + } +} + +fn checked_ptr(value: i32, field: &str) -> Result { + usize::try_from(value).map_err(|_| Error::InvalidHostRequest { + reason: format!("invalid {field}: {value}"), + }) +} + +fn checked_len(value: i32, field: &str) -> Result { + usize::try_from(value).map_err(|_| Error::InvalidHostRequest { + reason: format!("invalid {field}: {value}"), + }) +} diff --git a/pkg/beam-cli/src/cli/wallet.rs b/pkg/beam-cli/src/cli/wallet.rs index d532721..19c6288 100644 --- a/pkg/beam-cli/src/cli/wallet.rs +++ b/pkg/beam-cli/src/cli/wallet.rs @@ -11,6 +11,19 @@ pub enum WalletAction { #[arg(long)] name: Option, }, + /// Export a stored wallet's raw private key + ExportPrivateKey { wallet: Option }, + /// Export a Payy-compatible wallet recovery phrase + ExportRecoveryPhrase { wallet: Option }, + /// Import a wallet from a Payy-compatible recovery phrase + ImportRecoveryPhrase { + #[command(flatten)] + phrase_source: RecoveryPhraseSourceArgs, + #[arg(long)] + expected_address: Option, + #[arg(long)] + name: Option, + }, /// List stored wallets List, /// Rename a stored wallet @@ -43,8 +56,33 @@ pub struct PrivateKeySourceArgs { pub private_key_fd: Option, } +#[derive(Clone, Debug, Default, Args, PartialEq, Eq)] +pub struct RecoveryPhraseSourceArgs { + #[arg( + long, + default_value_t = false, + conflicts_with = "phrase_fd", + help = "Read the recovery phrase from stdin instead of prompting" + )] + pub phrase_stdin: bool, + + #[arg( + long, + value_name = "FD", + conflicts_with = "phrase_stdin", + help = "Read the recovery phrase from an already-open file descriptor" + )] + pub phrase_fd: Option, +} + impl WalletAction { pub(crate) fn is_sensitive(&self) -> bool { - matches!(self, Self::Import { .. } | Self::Address { .. }) + matches!( + self, + Self::Import { .. } + | Self::ExportPrivateKey { .. } + | Self::ImportRecoveryPhrase { .. } + | Self::Address { .. } + ) } } diff --git a/pkg/beam-cli/src/commands/apps/execution.rs b/pkg/beam-cli/src/commands/apps/execution.rs index 30adb2d..6a07530 100644 --- a/pkg/beam-cli/src/commands/apps/execution.rs +++ b/pkg/beam-cli/src/commands/apps/execution.rs @@ -67,7 +67,11 @@ pub async fn execute_plan(app: &BeamApp, plan: &ActionPlan) -> Result Result bool { + if step.kind != "erc20-approval" { + return true; + } + matches!( + execution, + TransactionExecution::Confirmed(outcome) if outcome.status.unwrap_or(1) != 0 + ) +} + fn render_simulated_execution(plan: &ActionPlan) -> CommandOutput { CommandOutput::new( format!("Executed app action: {}", plan.command), diff --git a/pkg/beam-cli/src/commands/apps/mod.rs b/pkg/beam-cli/src/commands/apps/mod.rs index 873412f..60a2ddf 100644 --- a/pkg/beam-cli/src/commands/apps/mod.rs +++ b/pkg/beam-cli/src/commands/apps/mod.rs @@ -18,7 +18,7 @@ use crate::{ ensure_manifest_matches, fetch_index, fetch_manifest, fetch_module, registry_url_from_env, select_app, select_version, }, - runtime::validate_wasm_module, + runtime::{AppRuntime, GuestCommandResult, validate_wasm_module}, store::AppCache, }, cli::{AppApprovalAction, AppInstallArgs, AppRemoveArgs, AppRunArgs, AppsAction}, @@ -29,12 +29,12 @@ use crate::{ }; use execution::execute_plan; -use plans::{plan_for_command, validate_plan_permissions}; +use plans::{validate_guest_plan, validate_plan_permissions}; use prompt::approve_interactively; use render::{ - approval_json, manifest_json, permissions_json, render_app_help, render_approval, - render_approval_created, render_execution, render_install_summary, render_manifest_info, - render_permission_diff, render_permissions, + app_command_json, approval_json, manifest_json, permissions_json, render_app_command_help, + render_app_help, render_approval, render_approval_created, render_install_summary, + render_manifest_info, render_permission_diff, render_permissions, }; pub async fn run(app: &BeamApp, action: AppsAction) -> Result<()> { @@ -68,12 +68,40 @@ pub async fn run_app(app: &BeamApp, args: AppRunArgs) -> Result<()> { .first() .cloned() .unwrap_or_else(|| "help".to_string()); - if command == "help" || args.args.iter().any(|arg| arg == "--help" || arg == "-h") { + if command == "help" || command == "--help" || command == "-h" || command_args.is_empty() { return CommandOutput::new(render_app_help(&manifest), manifest_json(&manifest)) .print(app.output_mode); } + if is_help_requested(&command_args) { + let app_command = manifest + .commands + .iter() + .find(|candidate| candidate.name == command) + .ok_or_else(|| AppError::UnsupportedAppCommand { + command: command.clone(), + })?; + return CommandOutput::new( + render_app_command_help(&manifest, app_command), + app_command_json(&manifest, app_command), + ) + .print(app.output_mode); + } - let plan = plan_for_command(app, &manifest, &installed, &command_args).await?; + let runtime = AppRuntime::default(); + let result = runtime + .run_command( + app, + &manifest, + &installed, + &cache.module_path(&args.app, &installed.active_version), + &command_args, + ) + .await?; + let plan = match result { + GuestCommandResult::ActionPlan(plan) => plan, + GuestCommandResult::Output(output) => return output.print(app.output_mode), + }; + validate_guest_plan(app, &manifest, &installed, &command_args, &plan).await?; validate_plan_permissions(&manifest.permissions, &plan)?; let approval_required = plan_requires_approval(&plan); @@ -89,7 +117,7 @@ pub async fn run_app(app: &BeamApp, args: AppRunArgs) -> Result<()> { } approve_interactively(&render::render_plan(&plan))?; } - render_execution(&plan).print(app.output_mode) + execute_plan(app, &plan).await?.print(app.output_mode) } fn plan_requires_approval(plan: &ActionPlan) -> bool { @@ -105,6 +133,10 @@ fn filtered_app_args(args: &[String]) -> Vec { .collect() } +fn is_help_requested(args: &[String]) -> bool { + args.iter().any(|arg| arg == "--help" || arg == "-h") +} + async fn install(app: &BeamApp, args: AppInstallArgs) -> Result<()> { let registry_url = registry_url_from_env(); let index = fetch_index(®istry_url).await?; diff --git a/pkg/beam-cli/src/commands/apps/plans.rs b/pkg/beam-cli/src/commands/apps/plans.rs index 7bcc459..7860340 100644 --- a/pkg/beam-cli/src/commands/apps/plans.rs +++ b/pkg/beam-cli/src/commands/apps/plans.rs @@ -5,27 +5,73 @@ use crate::{ ActionPlan, ActionStep, AppManifest, AppPermissions, ChainOperation, InstalledApp, }, permissions::ensure_chain_scope, + store::now, }, error::Result, runtime::BeamApp, }; -pub(super) async fn plan_for_command( - _app: &BeamApp, +pub(super) async fn validate_guest_plan( + app: &BeamApp, manifest: &AppManifest, - _installed: &InstalledApp, - args: &[String], -) -> Result { - match (manifest.id.as_str(), args.first().map(String::as_str)) { - (_, Some(command)) => Err(AppError::UnsupportedAppCommand { - command: command.to_string(), + installed: &InstalledApp, + command_args: &[String], + plan: &ActionPlan, +) -> Result<()> { + let expected_command = command_args + .first() + .ok_or_else(|| AppError::InvalidGuestOutput { + reason: "guest plan missing command".to_string(), + })?; + if !plan.command.starts_with(expected_command) { + return Err(AppError::InvalidGuestOutput { + reason: format!( + "guest plan command `{}` does not match invocation `{expected_command}`", + plan.command + ), + } + .into()); + } + if plan.app_id != manifest.id + || plan.app_version != installed.active_version + || plan.manifest_sha256 != installed.manifest_sha256 + || plan.wasm_sha256 != installed.module_sha256 + { + return Err(AppError::InvalidGuestOutput { + reason: "guest plan artifact identity does not match installed app".to_string(), + } + .into()); + } + if plan.expires_at <= now() { + return Err(AppError::InvalidGuestOutput { + reason: "guest plan is already expired".to_string(), } - .into()), - (_, None) => Err(AppError::UnsupportedAppCommand { - command: "".to_string(), + .into()); + } + + let active_chain = app.active_chain().await?; + if plan.chain != active_chain.entry.key { + return Err(AppError::InvalidGuestOutput { + reason: format!( + "guest plan chain `{}` does not match active chain `{}`", + plan.chain, active_chain.entry.key + ), + } + .into()); + } + if let Some(wallet) = plan.wallet.as_ref() { + let active_wallet = format!("{:#x}", app.active_address().await?); + if !wallet.eq_ignore_ascii_case(&active_wallet) { + return Err(AppError::InvalidGuestOutput { + reason: format!( + "guest plan wallet `{wallet}` does not match active wallet `{active_wallet}`" + ), + } + .into()); } - .into()), } + + Ok(()) } pub(super) fn validate_plan_permissions( diff --git a/pkg/beam-cli/src/commands/apps/render.rs b/pkg/beam-cli/src/commands/apps/render.rs index e75b35c..6efc63f 100644 --- a/pkg/beam-cli/src/commands/apps/render.rs +++ b/pkg/beam-cli/src/commands/apps/render.rs @@ -1,7 +1,11 @@ +// lint-long-file-override allow-max-lines=300 use serde_json::{Value, json}; use crate::{ - apps::model::{ActionPlan, AppManifest, AppPermissions, ApprovalRecord}, + apps::model::{ + ActionPlan, AppCommand, AppCommandExample, AppCommandParameter, AppManifest, + AppPermissions, ApprovalRecord, + }, output::CommandOutput, }; @@ -87,6 +91,83 @@ pub(super) fn render_app_help(manifest: &AppManifest) -> String { lines.join("\n") } +pub(super) fn render_app_command_help(manifest: &AppManifest, command: &AppCommand) -> String { + let mut lines = Vec::new(); + lines.push(format!("{} {}", manifest.display_name, command.name)); + lines.push(command.about.clone()); + lines.push(String::new()); + lines.push(format!( + "Usage: {}", + command_usage(command).unwrap_or_else(|| command.name.clone()) + )); + + if let Some(docs) = &command.docs { + push_parameters(&mut lines, "Arguments", &docs.arguments); + push_parameters(&mut lines, "Options", &docs.options); + push_examples(&mut lines, &docs.examples); + if !docs.output_notes.is_empty() { + lines.push(String::new()); + lines.push("Output:".to_string()); + for note in &docs.output_notes { + lines.push(format!(" - {note}")); + } + } + } + + lines.join("\n") +} + +pub(super) fn app_command_json(manifest: &AppManifest, command: &AppCommand) -> Value { + json!({ + "app": manifest.id, + "command": command, + }) +} + +fn command_usage(command: &AppCommand) -> Option { + command + .docs + .as_ref() + .map(|docs| docs.invocation.clone()) + .or_else(|| (!command.usage.is_empty()).then(|| command.usage.clone())) +} + +fn push_parameters(lines: &mut Vec, title: &str, parameters: &[AppCommandParameter]) { + if parameters.is_empty() { + return; + } + lines.push(String::new()); + lines.push(format!("{title}:")); + for parameter in parameters { + let value_name = parameter + .value_name + .as_ref() + .map(|value| format!(" <{value}>")) + .unwrap_or_default(); + let required = if parameter.required { + "required" + } else { + "optional" + }; + lines.push(format!( + " - {}{} ({required}): {}", + parameter.name, value_name, parameter.description + )); + } +} + +fn push_examples(lines: &mut Vec, examples: &[AppCommandExample]) { + if examples.is_empty() { + return; + } + lines.push(String::new()); + lines.push("Examples:".to_string()); + for example in examples { + lines.push(format!(" - {}: {}", example.title, example.command)); + lines.push(format!(" {}", example.description)); + } +} + pub(super) fn render_plan(plan: &ActionPlan) -> String { let mut lines = vec![ format!("App: {} {}", plan.app_id, plan.app_version), @@ -121,19 +202,6 @@ pub(super) fn render_approval_created(record: &ApprovalRecord) -> CommandOutput ) } -pub(super) fn render_execution(plan: &ActionPlan) -> CommandOutput { - CommandOutput::new( - format!("Executed app action: {}", plan.command), - json!({ - "app": plan.app_id, - "chain": plan.chain, - "command": plan.command, - "state": "executed", - "steps": plan.steps, - }), - ) -} - pub(super) fn render_permission_diff(current: &AppManifest, next: &AppManifest) -> String { format!( "Update {} {} -> {} changes permissions.\n\nCurrent:\n{}\n\nNext:\n{}", @@ -163,3 +231,6 @@ pub(super) fn permissions_json(permissions: &AppPermissions) -> Value { pub(super) fn approval_json(approval: &ApprovalRecord) -> Value { serde_json::to_value(approval).unwrap_or_else(|_| json!({})) } + +#[cfg(test)] +mod tests; diff --git a/pkg/beam-cli/src/commands/apps/render/tests.rs b/pkg/beam-cli/src/commands/apps/render/tests.rs new file mode 100644 index 0000000..3200012 --- /dev/null +++ b/pkg/beam-cli/src/commands/apps/render/tests.rs @@ -0,0 +1,68 @@ +use crate::apps::model::{ + AppCatalogMetadata, AppCommand, AppCommandDocs, AppCommandExample, AppCommandParameter, + AppManifest, AppPermissions, HostApi, RegistrySignature, WasmArtifact, +}; + +use super::render_app_command_help; + +#[test] +fn command_help_renders_manifest_command_details() { + let manifest = AppManifest { + catalog: AppCatalogMetadata::default(), + commands: vec![AppCommand { + about: "Fetch a quote and prepare a swap".to_string(), + docs: Some(AppCommandDocs { + arguments: vec![AppCommandParameter { + default: None, + description: "Token to spend".to_string(), + kind: "string".to_string(), + name: "sell-token".to_string(), + required: true, + sensitive: false, + value_name: None, + }], + examples: vec![AppCommandExample { + command: "beam x uniswap swap USDC ETH 100".to_string(), + description: "Prepare a Base swap".to_string(), + title: "Swap".to_string(), + }], + invocation: "beam x uniswap swap ".to_string(), + options: Vec::new(), + output_notes: vec!["Returns a pending approval".to_string()], + summary: "Prepare a swap".to_string(), + }), + input_schema: serde_json::json!({}), + name: "swap".to_string(), + output_schema: serde_json::json!({}), + sensitive_args: Vec::new(), + usage: "swap ".to_string(), + }], + description: "Uniswap app".to_string(), + display_name: "Uniswap".to_string(), + format_version: 1, + host_api: HostApi::default(), + icon: None, + id: "uniswap".to_string(), + min_beam_version: "0.1.2".to_string(), + permissions: AppPermissions::default(), + publisher: "Payy".to_string(), + signature: RegistrySignature { + algorithm: "sha256-dev".to_string(), + key_id: "test".to_string(), + value: "sha256:0".to_string(), + }, + version: "1.0.0".to_string(), + wasm: WasmArtifact { + entrypoint: "beam_app_main".to_string(), + sha256: "sha256:0".to_string(), + }, + }; + + let help = render_app_command_help(&manifest, &manifest.commands[0]); + + assert!(help.contains("Usage: beam x uniswap swap")); + assert!(help.contains("Arguments:")); + assert!(help.contains("sell-token")); + assert!(help.contains("Examples:")); + assert!(help.contains("Returns a pending approval")); +} diff --git a/pkg/beam-cli/src/commands/interactive_history_sensitive.rs b/pkg/beam-cli/src/commands/interactive_history_sensitive.rs index a90e1b0..9c4b079 100644 --- a/pkg/beam-cli/src/commands/interactive_history_sensitive.rs +++ b/pkg/beam-cli/src/commands/interactive_history_sensitive.rs @@ -10,7 +10,7 @@ pub(super) fn looks_like_sensitive_command(args: &[String]) -> bool { { Some("wallet" | "wallets") => matches!( args.get(command_index + 1).map(String::as_str), - Some("import" | "address") + Some("import" | "export-private-key" | "import-recovery-phrase" | "address") ), Some("privacy") => { matches!( diff --git a/pkg/beam-cli/src/commands/interactive_parse.rs b/pkg/beam-cli/src/commands/interactive_parse.rs index 6e5e018..a1841cc 100644 --- a/pkg/beam-cli/src/commands/interactive_parse.rs +++ b/pkg/beam-cli/src/commands/interactive_parse.rs @@ -146,6 +146,9 @@ fn is_cli_subcommand_invocation(command: &str, args: &[String]) -> bool { Some( "create" | "import" + | "export-private-key" + | "export-recovery-phrase" + | "import-recovery-phrase" | "list" | "rename" | "address" diff --git a/pkg/beam-cli/src/commands/mod.rs b/pkg/beam-cli/src/commands/mod.rs index 2ce7c91..a8fa705 100644 --- a/pkg/beam-cli/src/commands/mod.rs +++ b/pkg/beam-cli/src/commands/mod.rs @@ -25,6 +25,9 @@ pub mod txn; pub mod update; pub mod util; pub mod wallet; +pub(crate) mod wallet_private_key; +pub(crate) mod wallet_recovery; +pub(crate) mod wallet_secret; use crate::{cli::Command, error::Result, runtime::BeamApp}; diff --git a/pkg/beam-cli/src/commands/wallet.rs b/pkg/beam-cli/src/commands/wallet.rs index e929b64..7ef418f 100644 --- a/pkg/beam-cli/src/commands/wallet.rs +++ b/pkg/beam-cli/src/commands/wallet.rs @@ -1,11 +1,13 @@ -// lint-long-file-override allow-max-lines=300 -use std::{fs, io::Read, path::Path}; - +// lint-long-file-override allow-max-lines=400 use contextful::ResultContextExt; -use contracts::Secp256k1SecretKey; -use rand::RngCore; use serde_json::json; +use super::{ + wallet_private_key, wallet_recovery, + wallet_secret::{generate_secret_key, load_secret_key}, +}; +#[cfg(test)] +use crate::keystore::validate_new_password; use crate::{ cli::{PrivateKeySourceArgs, WalletAction}, ens::{import_wallet_name, validate_wallet_name_for_address}, @@ -13,7 +15,7 @@ use crate::{ human_output::{normalize_human_name, sanitize_control_chars}, keystore::{ StoredWallet, encrypt_private_key, next_wallet_name, prompt_new_password, - prompt_private_key, prompt_wallet_name, wallet_address, + prompt_wallet_name, wallet_address, }, output::CommandOutput, runtime::BeamApp, @@ -26,6 +28,25 @@ pub async fn run(app: &BeamApp, action: WalletAction) -> Result<()> { private_key_source, name, } => import_wallet(app, name, &private_key_source).await, + WalletAction::ExportPrivateKey { wallet } => { + wallet_private_key::export_private_key(app, wallet).await + } + WalletAction::ExportRecoveryPhrase { wallet } => { + wallet_recovery::export_recovery_phrase(app, wallet).await + } + WalletAction::ImportRecoveryPhrase { + expected_address, + phrase_source, + name, + } => { + wallet_recovery::import_recovery_phrase( + app, + name, + &phrase_source, + expected_address.as_deref(), + ) + .await + } WalletAction::List => list_wallets(app).await, WalletAction::Rename { name, new_name } => rename_wallet(app, &name, &new_name).await, WalletAction::Address { private_key_source } => { @@ -189,11 +210,41 @@ async fn use_wallet(app: &BeamApp, name: &str) -> Result<()> { .print(app.output_mode) } -async fn store_wallet( +pub(super) async fn store_wallet( app: &BeamApp, requested_name: Option, secret_key: &[u8], ) -> Result<()> { + let prepared = prepare_wallet_store(app, requested_name, secret_key).await?; + let password = prompt_new_password()?; + persist_prepared_wallet(app, prepared, secret_key, &password) + .await? + .print(app.output_mode) +} + +#[cfg(test)] +pub(super) async fn store_wallet_with_password( + app: &BeamApp, + requested_name: Option, + secret_key: &[u8], + password: &str, +) -> Result { + let prepared = prepare_wallet_store(app, requested_name, secret_key).await?; + validate_new_password(password, password)?; + + persist_prepared_wallet(app, prepared, secret_key, password).await +} + +struct PreparedWalletStore { + address: String, + name: String, +} + +async fn prepare_wallet_store( + app: &BeamApp, + requested_name: Option, + secret_key: &[u8], +) -> Result { let keystore = app.keystore_store.get().await; let address = wallet_address(secret_key)?; let name = import_wallet_name(app, &keystore, requested_name, address).await?; @@ -208,8 +259,17 @@ async fn store_wallet( return Err(Error::WalletAddressAlreadyExists { address }); } - let password = prompt_new_password()?; - let encrypted_private_key = encrypt_private_key(secret_key, &password)?; + Ok(PreparedWalletStore { address, name }) +} + +async fn persist_prepared_wallet( + app: &BeamApp, + prepared: PreparedWalletStore, + secret_key: &[u8], + password: &str, +) -> Result { + let PreparedWalletStore { address, name } = prepared; + let encrypted_private_key = encrypt_private_key(secret_key, password)?; let wallet = StoredWallet { address: address.clone(), encrypted_key: encrypted_private_key.encrypted_key, @@ -232,65 +292,16 @@ async fn store_wallet( } let display_name = sanitize_control_chars(&wallet.name); - CommandOutput::new( + Ok(CommandOutput::new( format!("Created wallet {display_name} ({address})"), json!({ "address": wallet.address, "name": wallet.name, }), ) - .compact(format!("{display_name} {address}")) - .print(app.output_mode) + .compact(format!("{display_name} {address}"))) } pub(crate) fn normalize_wallet_name(name: &str) -> Result { normalize_human_name(name).ok_or(Error::WalletNameBlank) } - -fn load_secret_key(private_key_source: &PrivateKeySourceArgs) -> Result> { - let private_key = read_private_key(private_key_source)?; - let secret_key = parse_secret_key(&private_key)?; - Ok(secret_key) -} - -fn read_private_key(private_key_source: &PrivateKeySourceArgs) -> Result { - if private_key_source.private_key_stdin { - return read_private_key_from_stdin(); - } - - if let Some(fd) = private_key_source.private_key_fd { - return read_private_key_from_fd(fd); - } - - prompt_private_key() -} - -fn read_private_key_from_stdin() -> Result { - let mut private_key = String::new(); - std::io::stdin() - .read_to_string(&mut private_key) - .context("read beam private key from stdin")?; - Ok(private_key) -} - -pub(crate) fn read_private_key_from_fd(fd: u32) -> Result { - let path = Path::new("/dev/fd").join(fd.to_string()); - Ok(fs::read_to_string(path).context("read beam private key from file descriptor")?) -} - -fn parse_secret_key(private_key: &str) -> Result> { - let decoded = hex::decode(private_key.trim().trim_start_matches("0x")) - .map_err(|_| Error::InvalidPrivateKey)?; - let _ = Secp256k1SecretKey::from_slice(&decoded).map_err(|_| Error::InvalidPrivateKey)?; - Ok(decoded) -} - -fn generate_secret_key() -> [u8; 32] { - loop { - let mut secret_key = [0u8; 32]; - rand::thread_rng().fill_bytes(&mut secret_key); - if Secp256k1SecretKey::from_slice(&secret_key).is_ok() { - return secret_key; - } - } -} diff --git a/pkg/beam-cli/src/commands/wallet_private_key.rs b/pkg/beam-cli/src/commands/wallet_private_key.rs new file mode 100644 index 0000000..0966c4a --- /dev/null +++ b/pkg/beam-cli/src/commands/wallet_private_key.rs @@ -0,0 +1,74 @@ +use contracts::Secp256k1SecretKey; +use serde_json::json; + +use crate::{ + error::{Error, Result}, + human_output::sanitize_control_chars, + keystore::{StoredWallet, decrypt_private_key, prompt_existing_password}, + output::CommandOutput, + runtime::BeamApp, +}; + +const PRIVATE_KEY_WARNING: &str = + "Store this key securely. Anyone with it can control this wallet."; + +pub(crate) async fn export_private_key(app: &BeamApp, wallet: Option) -> Result<()> { + let wallet = resolve_export_wallet(app, wallet.as_deref()).await?; + let password = prompt_existing_password()?; + + export_private_key_output(&wallet, &password)?.print(app.output_mode) +} + +#[cfg(test)] +pub(crate) async fn export_private_key_output_for_selector( + app: &BeamApp, + wallet: Option<&str>, + password: &str, +) -> Result { + let wallet = resolve_export_wallet(app, wallet).await?; + export_private_key_output(&wallet, password) +} + +async fn resolve_export_wallet(app: &BeamApp, wallet: Option<&str>) -> Result { + match wallet { + Some(selector) => app.resolve_wallet(selector).await, + None => app.active_wallet().await, + } +} + +pub(crate) fn export_private_key_output( + wallet: &StoredWallet, + password: &str, +) -> Result { + let secret_key = decrypt_private_key(wallet, password)?; + render_export_private_key_output(wallet, &secret_key) +} + +fn render_export_private_key_output( + wallet: &StoredWallet, + secret_key: &[u8], +) -> Result { + let _ = Secp256k1SecretKey::from_slice(secret_key).map_err(|_| Error::InvalidPrivateKey)?; + let private_key = format!("0x{}", hex::encode(secret_key)); + let name = sanitize_control_chars(&wallet.name); + let default = format!( + "Private key for {name} ({}):\n{private_key}\n\n{PRIVATE_KEY_WARNING}", + wallet.address + ); + let markdown = format!( + "Private key for {name} (`{}`):\n\n```text\n{private_key}\n```\n\n{PRIVATE_KEY_WARNING}", + wallet.address + ); + + Ok(CommandOutput::new( + default, + json!({ + "address": &wallet.address, + "name": &wallet.name, + "private_key": private_key, + "warning": PRIVATE_KEY_WARNING, + }), + ) + .compact(private_key) + .markdown(markdown)) +} diff --git a/pkg/beam-cli/src/commands/wallet_recovery.rs b/pkg/beam-cli/src/commands/wallet_recovery.rs new file mode 100644 index 0000000..a0687db --- /dev/null +++ b/pkg/beam-cli/src/commands/wallet_recovery.rs @@ -0,0 +1,160 @@ +use std::{fs, io::Read, path::Path}; + +use contextful::ResultContextExt; +use contracts::Address; +use serde_json::json; + +#[cfg(test)] +use crate::commands::wallet::store_wallet_with_password; +use crate::{ + cli::RecoveryPhraseSourceArgs, + commands::wallet::store_wallet, + error::{Error, Result}, + human_output::sanitize_control_chars, + keystore::{ + decrypt_private_key, prompt_existing_password, prompt_recovery_phrase, wallet_address, + }, + output::CommandOutput, + recovery_phrase::{private_key_to_recovery_phrase, recovery_phrase_to_private_key}, + runtime::BeamApp, +}; + +const RECOVERY_PHRASE_KIND: &str = "evm_private_key_bip39_entropy"; +const RECOVERY_PHRASE_SEMANTIC_WARNING: &str = concat!( + "This phrase is a direct BIP39 encoding of the raw EVM private key. ", + "It is not a MetaMask or HD-wallet seed phrase; Beam does not use a derivation path, ", + "account index, or seed expansion." +); + +pub(crate) async fn export_recovery_phrase( + app: &BeamApp, + requested_wallet: Option, +) -> Result<()> { + let password = prompt_existing_password()?; + export_recovery_phrase_output(app, requested_wallet, &password) + .await? + .print(app.output_mode) +} + +pub(crate) async fn export_recovery_phrase_output( + app: &BeamApp, + requested_wallet: Option, + password: &str, +) -> Result { + let wallet = match requested_wallet { + Some(selector) => app.resolve_wallet(&selector).await?, + None => app.active_wallet().await?, + }; + let private_key = decrypt_private_key(&wallet, password)?; + let recovery_phrase = private_key_to_recovery_phrase(&private_key)?; + let display_name = sanitize_control_chars(&wallet.name); + let default_output = format!( + "Recovery phrase for {display_name} ({}):\n{}\n\n{}\n\nStore this phrase securely. Anyone with it can control this wallet.", + wallet.address, recovery_phrase, RECOVERY_PHRASE_SEMANTIC_WARNING + ); + let markdown_output = format!( + "Recovery phrase for `{display_name}` (`{}`):\n\n```text\n{}\n```\n\n{}\n\nStore this phrase securely. Anyone with it can control this wallet.", + wallet.address, recovery_phrase, RECOVERY_PHRASE_SEMANTIC_WARNING + ); + + Ok(CommandOutput::new( + default_output, + json!({ + "address": wallet.address, + "hd_seed_phrase": false, + "name": wallet.name, + "recovery_phrase": recovery_phrase.clone(), + "recovery_phrase_kind": RECOVERY_PHRASE_KIND, + "warning": RECOVERY_PHRASE_SEMANTIC_WARNING, + }), + ) + .compact(recovery_phrase) + .markdown(markdown_output)) +} + +pub(crate) async fn import_recovery_phrase( + app: &BeamApp, + requested_name: Option, + phrase_source: &RecoveryPhraseSourceArgs, + expected_address: Option<&str>, +) -> Result<()> { + let recovery_phrase = read_recovery_phrase(phrase_source)?; + let secret_key = recovery_phrase_to_private_key(&recovery_phrase)?; + let derived_address = recovery_phrase_wallet_address(&secret_key)?; + validate_expected_recovery_phrase_address(expected_address, &derived_address)?; + eprintln!("{}", recovery_phrase_import_warning(&derived_address)); + store_wallet(app, requested_name, &secret_key).await +} + +#[cfg(test)] +pub(crate) async fn import_recovery_phrase_with_password( + app: &BeamApp, + requested_name: Option, + recovery_phrase: &str, + password: &str, + expected_address: Option<&str>, +) -> Result { + let secret_key = recovery_phrase_to_private_key(recovery_phrase)?; + let derived_address = recovery_phrase_wallet_address(&secret_key)?; + validate_expected_recovery_phrase_address(expected_address, &derived_address)?; + store_wallet_with_password(app, requested_name, &secret_key, password).await +} + +pub(crate) fn recovery_phrase_import_warning(derived_address: &str) -> String { + format!( + "Recovery phrase will restore EVM wallet: {derived_address}\n{RECOVERY_PHRASE_SEMANTIC_WARNING}\nContinue only if this address is expected. Press Ctrl-C to cancel." + ) +} + +fn validate_expected_recovery_phrase_address( + expected_address: Option<&str>, + derived_address: &str, +) -> Result<()> { + let Some(expected_address) = expected_address else { + return Ok(()); + }; + + let expected = expected_address + .parse::
() + .map_err(|_| Error::InvalidAddress { + value: expected_address.to_string(), + })?; + let expected = format!("{expected:#x}"); + if expected != derived_address { + return Err(Error::RecoveryPhraseAddressMismatch { + derived: derived_address.to_string(), + expected, + }); + } + + Ok(()) +} + +fn recovery_phrase_wallet_address(secret_key: &[u8]) -> Result { + Ok(format!("{:#x}", wallet_address(secret_key)?)) +} + +fn read_recovery_phrase(phrase_source: &RecoveryPhraseSourceArgs) -> Result { + if phrase_source.phrase_stdin { + return read_recovery_phrase_from_stdin(); + } + + if let Some(fd) = phrase_source.phrase_fd { + return read_recovery_phrase_from_fd(fd); + } + + prompt_recovery_phrase() +} + +fn read_recovery_phrase_from_stdin() -> Result { + let mut recovery_phrase = String::new(); + std::io::stdin() + .read_to_string(&mut recovery_phrase) + .context("read beam recovery phrase from stdin")?; + Ok(recovery_phrase) +} + +pub(crate) fn read_recovery_phrase_from_fd(fd: u32) -> Result { + let path = Path::new("/dev/fd").join(fd.to_string()); + Ok(fs::read_to_string(path).context("read beam recovery phrase from file descriptor")?) +} diff --git a/pkg/beam-cli/src/commands/wallet_secret.rs b/pkg/beam-cli/src/commands/wallet_secret.rs new file mode 100644 index 0000000..429c048 --- /dev/null +++ b/pkg/beam-cli/src/commands/wallet_secret.rs @@ -0,0 +1,64 @@ +use std::{fs, io::Read, path::Path}; + +use contextful::ResultContextExt; +use contracts::Secp256k1SecretKey; +use rand::RngCore; + +use crate::{ + cli::PrivateKeySourceArgs, + error::{Error, Result}, + keystore::prompt_private_key, +}; + +pub(crate) fn load_secret_key(private_key_source: &PrivateKeySourceArgs) -> Result> { + let private_key = read_private_key(private_key_source)?; + let secret_key = parse_secret_key(&private_key)?; + Ok(secret_key) +} + +fn read_private_key(private_key_source: &PrivateKeySourceArgs) -> Result { + if private_key_source.private_key_stdin { + return read_private_key_from_stdin(); + } + + if let Some(fd) = private_key_source.private_key_fd { + return read_private_key_from_fd(fd); + } + + prompt_private_key() +} + +fn read_private_key_from_stdin() -> Result { + let mut private_key = String::new(); + std::io::stdin() + .read_to_string(&mut private_key) + .context("read beam private key from stdin")?; + Ok(private_key) +} + +pub(crate) fn read_private_key_from_fd(fd: u32) -> Result { + let path = Path::new("/dev/fd").join(fd.to_string()); + Ok(fs::read_to_string(path).context("read beam private key from file descriptor")?) +} + +pub(crate) fn parse_secret_key(private_key: &str) -> Result> { + let decoded = hex::decode(private_key.trim().trim_start_matches("0x")) + .map_err(|_| Error::InvalidPrivateKey)?; + validate_secret_key(&decoded)?; + Ok(decoded) +} + +pub(crate) fn validate_secret_key(secret_key: &[u8]) -> Result<()> { + let _ = Secp256k1SecretKey::from_slice(secret_key).map_err(|_| Error::InvalidPrivateKey)?; + Ok(()) +} + +pub(crate) fn generate_secret_key() -> [u8; 32] { + loop { + let mut secret_key = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut secret_key); + if Secp256k1SecretKey::from_slice(&secret_key).is_ok() { + return secret_key; + } + } +} diff --git a/pkg/beam-cli/src/error.rs b/pkg/beam-cli/src/error.rs index ff20bc6..2ebd740 100644 --- a/pkg/beam-cli/src/error.rs +++ b/pkg/beam-cli/src/error.rs @@ -122,6 +122,21 @@ pub enum Error { #[error("[beam-cli] invalid private key")] InvalidPrivateKey, + #[error("[beam-cli] invalid recovery phrase")] + InvalidRecoveryPhrase, + + #[error("[beam-cli] expected {expected} recovery phrase words, got {got}")] + InvalidRecoveryPhraseWordCount { expected: usize, got: usize }, + + #[error("[beam-cli] recovery phrase entropy must be 32 bytes, got {length}")] + InvalidRecoveryPhraseEntropyLength { length: usize }, + + #[error("[beam-cli] recovery phrase maps to an invalid private key")] + InvalidRecoveryPhrasePrivateKey, + + #[error("[beam-cli] recovery phrase derives {derived}, not expected wallet address {expected}")] + RecoveryPhraseAddressMismatch { derived: String, expected: String }, + #[error("[beam-cli] invalid address: {value}")] InvalidAddress { value: String }, diff --git a/pkg/beam-cli/src/keystore.rs b/pkg/beam-cli/src/keystore.rs index 3744a3d..a665159 100644 --- a/pkg/beam-cli/src/keystore.rs +++ b/pkg/beam-cli/src/keystore.rs @@ -140,6 +140,10 @@ pub fn prompt_private_key() -> Result { prompt_secret("beam private key: ", "read beam private key") } +pub fn prompt_recovery_phrase() -> Result { + prompt_secret("beam recovery phrase: ", "read beam recovery phrase") +} + pub fn prompt_wallet_name(default_name: &str) -> Result { let (stdin, stderr) = (std::io::stdin(), std::io::stderr()); prompt_wallet_name_with(default_name, &mut stdin.lock(), &mut stderr.lock()) diff --git a/pkg/beam-cli/src/main.rs b/pkg/beam-cli/src/main.rs index 611d5ac..f9e9b08 100644 --- a/pkg/beam-cli/src/main.rs +++ b/pkg/beam-cli/src/main.rs @@ -16,6 +16,7 @@ mod output; mod privacy; mod privacy_config; mod prompts; +mod recovery_phrase; mod runtime; mod signer; mod table; diff --git a/pkg/beam-cli/src/recovery_phrase.rs b/pkg/beam-cli/src/recovery_phrase.rs new file mode 100644 index 0000000..2a63ead --- /dev/null +++ b/pkg/beam-cli/src/recovery_phrase.rs @@ -0,0 +1,85 @@ +use bip39::{Language, Mnemonic}; +use secp256k1::SecretKey; + +use crate::error::{Error, Result}; + +pub const PRIVATE_KEY_ENTROPY_BYTES: usize = 32; +pub const RECOVERY_PHRASE_WORDS: usize = 24; + +pub fn normalize_recovery_phrase(phrase: &str) -> String { + phrase + .split_whitespace() + .map(str::to_lowercase) + .collect::>() + .join(" ") +} + +/// Encodes secp256k1 private key bytes as BIP39 entropy. +/// This does not use seed or HD derivation. +pub fn private_key_to_recovery_phrase(private_key: &[u8]) -> Result { + let private_key = private_key_entropy(private_key)?; + validate_private_key_bytes(&private_key)?; + + let mnemonic = Mnemonic::from_entropy_in(Language::English, &private_key) + .map_err(|_| Error::InvalidRecoveryPhrase)?; + let phrase = mnemonic.to_string(); + + debug_assert_eq!(phrase.split_whitespace().count(), RECOVERY_PHRASE_WORDS); + Ok(phrase) +} + +/// Decodes BIP39 mnemonic entropy as secp256k1 private key bytes. +/// This does not use seed or HD derivation. +pub fn recovery_phrase_to_private_key(phrase: &str) -> Result<[u8; PRIVATE_KEY_ENTROPY_BYTES]> { + let normalized = normalize_recovery_phrase(phrase); + ensure_recovery_phrase_word_count(&normalized)?; + + let mnemonic = Mnemonic::parse_in(Language::English, &normalized) + .map_err(|_| Error::InvalidRecoveryPhrase)?; + + let private_key: [u8; PRIVATE_KEY_ENTROPY_BYTES] = + mnemonic + .to_entropy() + .try_into() + .map_err( + |entropy: Vec| Error::InvalidRecoveryPhraseEntropyLength { + length: entropy.len(), + }, + )?; + validate_recovery_phrase_private_key_bytes(&private_key)?; + + Ok(private_key) +} + +fn private_key_entropy(private_key: &[u8]) -> Result<[u8; PRIVATE_KEY_ENTROPY_BYTES]> { + private_key + .try_into() + .map_err(|_| Error::InvalidRecoveryPhraseEntropyLength { + length: private_key.len(), + }) +} + +fn ensure_recovery_phrase_word_count(phrase: &str) -> Result<()> { + let word_count = phrase.split_whitespace().count(); + if word_count != RECOVERY_PHRASE_WORDS { + return Err(Error::InvalidRecoveryPhraseWordCount { + expected: RECOVERY_PHRASE_WORDS, + got: word_count, + }); + } + + Ok(()) +} + +fn validate_private_key_bytes(private_key: &[u8; PRIVATE_KEY_ENTROPY_BYTES]) -> Result<()> { + let _ = SecretKey::from_slice(private_key).map_err(|_| Error::InvalidPrivateKey)?; + Ok(()) +} + +fn validate_recovery_phrase_private_key_bytes( + private_key: &[u8; PRIVATE_KEY_ENTROPY_BYTES], +) -> Result<()> { + let _ = + SecretKey::from_slice(private_key).map_err(|_| Error::InvalidRecoveryPhrasePrivateKey)?; + Ok(()) +} diff --git a/pkg/beam-cli/src/tests.rs b/pkg/beam-cli/src/tests.rs index c1e3f55..42d0fb0 100644 --- a/pkg/beam-cli/src/tests.rs +++ b/pkg/beam-cli/src/tests.rs @@ -10,6 +10,7 @@ mod cli_fetch; mod cli_gas; mod cli_metadata; mod cli_privacy; +mod cli_wallet_recovery; mod config; mod display; mod ens; @@ -40,6 +41,8 @@ mod interactive_history; mod interactive_interrupts; mod interactive_state; mod interactive_tokens; +mod interactive_wallet_private_key; +mod interactive_wallet_recovery; mod interactive_wallet_selector; mod keystore; mod keystore_interrupts; @@ -48,6 +51,7 @@ mod output; mod payy_native_token; mod privacy; mod prompts; +mod recovery_phrase; mod rpc_validation; mod runtime; mod runtime_permissions; @@ -64,3 +68,5 @@ mod update_restart; mod util; mod wallet; mod wallet_integrity; +mod wallet_private_key; +mod wallet_recovery; diff --git a/pkg/beam-cli/src/tests/apps_host.rs b/pkg/beam-cli/src/tests/apps_host.rs index b8c7b61..6b790cf 100644 --- a/pkg/beam-cli/src/tests/apps_host.rs +++ b/pkg/beam-cli/src/tests/apps_host.rs @@ -1,4 +1,7 @@ // lint-long-file-override allow-max-lines=300 +use std::path::{Path, PathBuf}; + +use super::fixtures::test_app; use crate::apps::{ approvals::{ensure_approval_executable, plan_hash}, host::{ @@ -7,11 +10,12 @@ use crate::apps::{ }, model::{ ActionBinding, ActionPlan, AppPermissions, ApprovalRecord, ApprovalStatus, ChainOperation, - ChainPermission, HttpPermission, + ChainPermission, HttpPermission, InstalledApp, RegistryIndex, }, - runtime::validate_wasm_module, + runtime::{AppRuntime, validate_wasm_module}, store::now, }; +use crate::runtime::InvocationOverrides; #[test] fn host_http_permissions_allow_declared_https_and_reject_private_hosts() { @@ -122,15 +126,49 @@ fn host_transaction_permissions_allow_broad_optional_globs() { #[test] fn app_runtime_requires_declared_entrypoint() { - let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../..") - .join("beam-apps/fixtures/valid/apps/uniswap/1.0.0/module.wasm"); + let path = repo_root().join("beam-apps/fixtures/valid/apps/uniswap/1.0.0/module.wasm"); validate_wasm_module("uniswap", "beam_app_main", &path).expect("valid app wasm"); validate_wasm_module("uniswap", "missing_entrypoint", &path) .expect_err("reject missing entrypoint"); } +#[tokio::test] +async fn app_runtime_invokes_guest_and_returns_structured_errors() { + let (_temp_dir, app) = test_app(InvocationOverrides { + chain: Some("base".to_string()), + from: Some("0x1111111111111111111111111111111111111111".to_string()), + ..InvocationOverrides::default() + }) + .await; + let bundle = repo_root().join("beam-apps/fixtures/valid"); + let index = read_json::(&bundle.join("index.json")); + let version = &index.apps[0].versions[0]; + let manifest_path = artifact_path(&bundle, &version.manifest_url); + let module_path = artifact_path(&bundle, &version.module_url); + let manifest = read_json(&manifest_path); + let installed = InstalledApp { + active_version: version.version.clone(), + id: index.apps[0].id.clone(), + installed_at: now(), + manifest_sha256: version.manifest_sha256.clone(), + module_sha256: version.module_sha256.clone(), + }; + + let error = AppRuntime::default() + .run_command( + &app, + &manifest, + &installed, + &module_path, + &["unknown".to_string()], + ) + .await + .expect_err("guest should reject unknown command"); + + assert!(error.to_string().contains("unsupported command")); +} + #[test] fn approval_integrity_rejects_tampered_plan() { let mut plan = action_plan(); @@ -193,3 +231,16 @@ fn action_plan() -> ActionPlan { expires_at: now() + 60, } } + +fn repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +fn read_json(path: &Path) -> T { + serde_json::from_slice(&std::fs::read(path).expect("read json")).expect("decode json") +} + +fn artifact_path(bundle: &Path, url: &str) -> PathBuf { + let prefix = "https://registry.beam.payy.network/"; + bundle.join(url.strip_prefix(prefix).expect("registry url")) +} diff --git a/pkg/beam-cli/src/tests/cli.rs b/pkg/beam-cli/src/tests/cli.rs index 44a6bbd..deb18f1 100644 --- a/pkg/beam-cli/src/tests/cli.rs +++ b/pkg/beam-cli/src/tests/cli.rs @@ -91,6 +91,15 @@ fn parses_wallet_and_erc20_subcommands() { && private_key_source.private_key_fd.is_none() )); + let export = Cli::try_parse_from(["beam", "wallets", "export-private-key", "alice"]) + .expect("parse wallet private key export"); + assert!(matches!( + export.command, + Some(Command::Wallet { + action: WalletAction::ExportPrivateKey { wallet } + }) if wallet.as_deref() == Some("alice") + )); + let rename = Cli::try_parse_from(["beam", "wallets", "rename", "alice", "primary"]) .expect("parse wallet rename"); assert!(matches!( diff --git a/pkg/beam-cli/src/tests/cli_wallet_recovery.rs b/pkg/beam-cli/src/tests/cli_wallet_recovery.rs new file mode 100644 index 0000000..aaae89e --- /dev/null +++ b/pkg/beam-cli/src/tests/cli_wallet_recovery.rs @@ -0,0 +1,73 @@ +use clap::Parser; + +use crate::cli::{Cli, Command, WalletAction}; + +#[test] +fn parses_recovery_phrase_wallet_sources() { + let export = Cli::try_parse_from(["beam", "wallets", "export-recovery-phrase", "alice"]) + .expect("parse recovery phrase export"); + assert!(matches!( + export.command, + Some(Command::Wallet { + action: WalletAction::ExportRecoveryPhrase { wallet } + }) if wallet.as_deref() == Some("alice") + )); + + let import = Cli::try_parse_from([ + "beam", + "wallets", + "import-recovery-phrase", + "--phrase-stdin", + "--expected-address", + "0x1111111111111111111111111111111111111111", + "--name", + "alice", + ]) + .expect("parse recovery phrase import"); + assert!(matches!( + import.command, + Some(Command::Wallet { + action: WalletAction::ImportRecoveryPhrase { + expected_address, + phrase_source, + name, + } + }) if name.as_deref() == Some("alice") + && expected_address.as_deref() == Some("0x1111111111111111111111111111111111111111") + && phrase_source.phrase_stdin + && phrase_source.phrase_fd.is_none() + )); + + let fd_import = Cli::try_parse_from([ + "beam", + "wallets", + "import-recovery-phrase", + "--phrase-fd", + "3", + ]) + .expect("parse fd-backed recovery phrase import"); + assert!(matches!( + fd_import.command, + Some(Command::Wallet { + action: WalletAction::ImportRecoveryPhrase { phrase_source, .. } + }) if !phrase_source.phrase_stdin && phrase_source.phrase_fd == Some(3) + )); + + Cli::try_parse_from([ + "beam", + "wallets", + "import-recovery-phrase", + "--phrase-stdin", + "--phrase-fd", + "3", + ]) + .expect_err("reject multiple recovery phrase sources"); + + Cli::try_parse_from([ + "beam", + "wallets", + "import-recovery-phrase", + "abandon abandon abandon abandon", + ]) + .expect_err("reject positional recovery phrase"); +} diff --git a/pkg/beam-cli/src/tests/interactive_history.rs b/pkg/beam-cli/src/tests/interactive_history.rs index 4d2ecf7..02f48f8 100644 --- a/pkg/beam-cli/src/tests/interactive_history.rs +++ b/pkg/beam-cli/src/tests/interactive_history.rs @@ -16,7 +16,7 @@ async fn startup_history_scrub_rewrites_history_file_before_next_save() { let (_temp_dir, app) = test_app(InvocationOverrides::default()).await; fs::write( &app.paths.history, - "wallets import 0x1234\nbalance\n/wallets address 0x1234\n", + "wallets import 0x1234\nbalance\nwallets export-private-key\n/wallets address 0x1234\n/wallets export-private-key alice\nwallets import-recovery-phrase --phrase-stdin\n", ) .expect("write beam history"); @@ -31,7 +31,9 @@ async fn startup_history_scrub_rewrites_history_file_before_next_save() { let persisted = fs::read_to_string(&app.paths.history).expect("read beam history"); assert!(persisted.contains("balance")); assert!(!persisted.contains("wallets import")); + assert!(!persisted.contains("wallets import-recovery-phrase")); assert!(!persisted.contains("/wallets address")); + assert!(!persisted.contains("export-private-key")); let mut reloaded = ReplHistory::new(); reloaded @@ -45,6 +47,14 @@ async fn startup_history_scrub_rewrites_history_file_before_next_save() { #[test] fn privacy_claim_artifacts_are_not_persisted_to_history() { + assert!(!should_persist_history("wallets export-private-key")); + assert!(!should_persist_history( + "--chain base /wallets export-private-key alice" + )); + assert!(!should_persist_history( + "wallets import-recovery-phrase --phrase-stdin" + )); + assert!(should_persist_history("wallets export-recovery-phrase")); assert!(!should_persist_history( "privacy claim payy:secret-artifact" )); diff --git a/pkg/beam-cli/src/tests/interactive_wallet_private_key.rs b/pkg/beam-cli/src/tests/interactive_wallet_private_key.rs new file mode 100644 index 0000000..5a5c2df --- /dev/null +++ b/pkg/beam-cli/src/tests/interactive_wallet_private_key.rs @@ -0,0 +1,20 @@ +use crate::{ + cli::{Command, WalletAction}, + commands::interactive::{ParsedLine, parse_line}, +}; + +#[test] +fn interactive_parser_routes_export_private_key_to_cli_command() { + let parsed = + parse_line("wallets export-private-key alice").expect("parse wallet private key export"); + let ParsedLine::Cli { cli, .. } = parsed else { + panic!("expected clap command"); + }; + + assert!(matches!( + &cli.command, + Some(Command::Wallet { + action: WalletAction::ExportPrivateKey { wallet }, + }) if wallet.as_deref() == Some("alice") + )); +} diff --git a/pkg/beam-cli/src/tests/interactive_wallet_recovery.rs b/pkg/beam-cli/src/tests/interactive_wallet_recovery.rs new file mode 100644 index 0000000..b3132cf --- /dev/null +++ b/pkg/beam-cli/src/tests/interactive_wallet_recovery.rs @@ -0,0 +1,50 @@ +use crate::{ + cli::{Command, WalletAction}, + commands::interactive::{ParsedLine, parse_line, repl_command_args}, +}; + +#[test] +fn recovery_phrase_wallet_commands_are_parsed_as_cli_subcommands_in_repl() { + assert_eq!( + repl_command_args("wallets export-recovery-phrase").expect("parse export command"), + None + ); + assert_eq!( + repl_command_args("wallets import-recovery-phrase --phrase-stdin") + .expect("parse import command"), + None + ); + + match parse_line("wallets export-recovery-phrase alice").expect("parse export line") { + ParsedLine::Cli { cli, .. } => assert!(matches!( + cli.command, + Some(Command::Wallet { + action: WalletAction::ExportRecoveryPhrase { wallet } + }) if wallet.as_deref() == Some("alice") + )), + ParsedLine::ReplCommand(_) | ParsedLine::CliError(_) => { + panic!("expected recovery phrase export to parse as cli") + } + } + + match parse_line( + "wallets import-recovery-phrase --phrase-fd 3 --expected-address 0x1111111111111111111111111111111111111111", + ) + .expect("parse import line") + { + ParsedLine::Cli { cli, .. } => assert!(matches!( + cli.command, + Some(Command::Wallet { + action: WalletAction::ImportRecoveryPhrase { + expected_address, + phrase_source, + .. + } + }) if phrase_source.phrase_fd == Some(3) + && expected_address.as_deref() == Some("0x1111111111111111111111111111111111111111") + )), + ParsedLine::ReplCommand(_) | ParsedLine::CliError(_) => { + panic!("expected recovery phrase import to parse as cli") + } + } +} diff --git a/pkg/beam-cli/src/tests/recovery_phrase.rs b/pkg/beam-cli/src/tests/recovery_phrase.rs new file mode 100644 index 0000000..c5cef58 --- /dev/null +++ b/pkg/beam-cli/src/tests/recovery_phrase.rs @@ -0,0 +1,110 @@ +use crate::{ + error::Error, + keystore::wallet_address, + recovery_phrase::{ + PRIVATE_KEY_ENTROPY_BYTES, RECOVERY_PHRASE_WORDS, normalize_recovery_phrase, + private_key_to_recovery_phrase, recovery_phrase_to_private_key, + }, +}; + +const PRIVATE_KEY: &str = "4f3edf983ac636a65a842ce7c78d9aa706d3b113bce036f6c4d1f06b2d1f6f9d"; +const RECOVERY_PHRASE: &str = "execute want toward intact gloom farm head machine treat detect grit evoke honey sudden exclude orchard dad renew crucial this ready moral salmon pave"; + +#[test] +fn encodes_private_key_bytes_as_payy_compatible_recovery_phrase() { + let private_key = hex::decode(PRIVATE_KEY).expect("decode private key"); + let phrase = private_key_to_recovery_phrase(&private_key).expect("encode recovery phrase"); + + assert_eq!(phrase, RECOVERY_PHRASE); + assert_eq!(phrase.split_whitespace().count(), RECOVERY_PHRASE_WORDS); +} + +#[test] +fn decodes_normalized_recovery_phrase_as_private_key_bytes() { + let noisy_phrase = RECOVERY_PHRASE + .split_whitespace() + .map(str::to_uppercase) + .collect::>() + .join("\n\t"); + + assert_eq!(normalize_recovery_phrase(&noisy_phrase), RECOVERY_PHRASE); + + let private_key = + recovery_phrase_to_private_key(&noisy_phrase).expect("decode recovery phrase"); + assert_eq!(hex::encode(private_key), PRIVATE_KEY); +} + +#[test] +fn rejects_recovery_phrase_with_wrong_word_count() { + let err = recovery_phrase_to_private_key("abandon abandon") + .expect_err("reject short recovery phrase"); + + assert!(matches!( + err, + Error::InvalidRecoveryPhraseWordCount { + expected: RECOVERY_PHRASE_WORDS, + got: 2, + } + )); +} + +#[test] +fn rejects_recovery_phrase_with_bad_checksum() { + let bad_phrase = format!( + "{} zoo", + RECOVERY_PHRASE + .split_whitespace() + .take(RECOVERY_PHRASE_WORDS - 1) + .collect::>() + .join(" ") + ); + let err = recovery_phrase_to_private_key(&bad_phrase) + .expect_err("reject invalid recovery phrase checksum"); + + assert!(matches!(err, Error::InvalidRecoveryPhrase)); +} + +#[test] +fn rejects_recovery_phrase_with_invalid_secp256k1_private_key_entropy() { + let zero_entropy_phrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art"; + let err = + recovery_phrase_to_private_key(zero_entropy_phrase).expect_err("reject zero private key"); + + assert!(matches!(err, Error::InvalidRecoveryPhrasePrivateKey)); +} + +#[test] +fn rejects_private_key_entropy_lengths_other_than_32_bytes() { + let err = private_key_to_recovery_phrase(&[1u8; PRIVATE_KEY_ENTROPY_BYTES - 1]) + .expect_err("reject short private key"); + + assert!(matches!( + err, + Error::InvalidRecoveryPhraseEntropyLength { length: 31 } + )); +} + +#[test] +fn recovered_phrase_preserves_evm_and_privacy_addresses() { + let private_key = hex::decode(PRIVATE_KEY).expect("decode private key"); + let phrase = private_key_to_recovery_phrase(&private_key).expect("encode recovery phrase"); + let recovered = recovery_phrase_to_private_key(&phrase).expect("decode recovery phrase"); + + assert_eq!( + wallet_address(&private_key).expect("derive original evm address"), + wallet_address(&recovered).expect("derive recovered evm address") + ); + + let private_key: [u8; PRIVATE_KEY_ENTROPY_BYTES] = + private_key.try_into().expect("private key bytes"); + let original_privacy_address = + payy_evm_client::LocalPrivacySigner::from_evm_private_key(private_key) + .expect("derive original privacy signer") + .privacy_address(); + let recovered_privacy_address = + payy_evm_client::LocalPrivacySigner::from_evm_private_key(recovered) + .expect("derive recovered privacy signer") + .privacy_address(); + + assert_eq!(original_privacy_address, recovered_privacy_address); +} diff --git a/pkg/beam-cli/src/tests/wallet.rs b/pkg/beam-cli/src/tests/wallet.rs index 3b92c8d..042b5f3 100644 --- a/pkg/beam-cli/src/tests/wallet.rs +++ b/pkg/beam-cli/src/tests/wallet.rs @@ -10,7 +10,7 @@ use super::{ fixtures::test_app_with_output, }; #[cfg(unix)] -use crate::commands::wallet::read_private_key_from_fd; +use crate::commands::wallet_secret::read_private_key_from_fd; use crate::{ cli::{PrivateKeySourceArgs, WalletAction}, commands::wallet::{normalize_wallet_name, rename_wallet, run as run_wallet_command}, diff --git a/pkg/beam-cli/src/tests/wallet_private_key.rs b/pkg/beam-cli/src/tests/wallet_private_key.rs new file mode 100644 index 0000000..3c73158 --- /dev/null +++ b/pkg/beam-cli/src/tests/wallet_private_key.rs @@ -0,0 +1,143 @@ +use serde_json::json; + +use super::fixtures::test_app_with_output; +use crate::{ + commands::wallet_private_key::{ + export_private_key_output, export_private_key_output_for_selector, + }, + error::Error, + keystore::{KeyStore, StoredWallet, encrypt_private_key, wallet_address}, + output::OutputMode, + runtime::InvocationOverrides, +}; + +const ALICE_PRIVATE_KEY: &str = "4f3edf983ac636a65a842ce7c78d9aa706d3b113bce036f6c4d1f06b2d1f6f9d"; +const BOB_PRIVATE_KEY: &str = "0000000000000000000000000000000000000000000000000000000000000002"; +const PASSWORD: &str = "beam-password"; + +#[test] +fn export_private_key_output_includes_raw_key_only() { + let wallet = stored_wallet("alice", ALICE_PRIVATE_KEY, PASSWORD); + let output = export_private_key_output(&wallet, PASSWORD).expect("export private key"); + let private_key = private_key_hex(ALICE_PRIVATE_KEY); + + assert_eq!(output.compact.as_deref(), Some(private_key.as_str())); + assert!(output.default.contains("Private key for alice")); + assert!(output.default.contains(&wallet.address)); + assert!(output.default.contains(&private_key)); + assert!( + output + .default + .contains("Anyone with it can control this wallet.") + ); + assert_eq!(output.value["address"], json!(wallet.address)); + assert_eq!(output.value["name"], json!("alice")); + assert_eq!(output.value["private_key"], json!(private_key)); + assert_eq!( + output.value["warning"], + json!("Store this key securely. Anyone with it can control this wallet.") + ); + assert!(output.value.get("recovery_phrase").is_none()); +} + +#[test] +fn export_private_key_rejects_wrong_password() { + let wallet = stored_wallet("alice", ALICE_PRIVATE_KEY, PASSWORD); + let err = + export_private_key_output(&wallet, "wrong-password").expect_err("reject wrong password"); + + assert!(matches!(err, Error::DecryptionFailed)); +} + +#[tokio::test] +async fn export_private_key_resolves_default_and_explicit_wallets() { + let (_temp_dir, app) = + test_app_with_output(OutputMode::Quiet, InvocationOverrides::default()).await; + let alice = stored_wallet("alice", ALICE_PRIVATE_KEY, PASSWORD); + let bob = stored_wallet("bob", BOB_PRIVATE_KEY, PASSWORD); + app.keystore_store + .set(KeyStore { + wallets: vec![alice, bob], + }) + .await + .expect("persist wallets"); + app.config_store + .update(|config| config.default_wallet = Some("bob".to_string())) + .await + .expect("persist default wallet"); + + let default_output = export_private_key_output_for_selector(&app, None, PASSWORD) + .await + .expect("export default wallet private key"); + assert_eq!( + default_output.value["private_key"], + json!(private_key_hex(BOB_PRIVATE_KEY)) + ); + + let explicit_output = export_private_key_output_for_selector(&app, Some("alice"), PASSWORD) + .await + .expect("export selected wallet private key"); + assert_eq!( + explicit_output.value["private_key"], + json!(private_key_hex(ALICE_PRIVATE_KEY)) + ); + + let err = export_private_key_output_for_selector(&app, Some("missing"), PASSWORD) + .await + .expect_err("reject missing wallet selector"); + assert!(matches!(err, Error::WalletNotFound { selector } if selector == "missing")); +} + +#[tokio::test] +async fn export_private_key_without_selector_uses_from_override_before_default() { + let (_temp_dir, app) = test_app_with_output( + OutputMode::Quiet, + InvocationOverrides { + from: Some("alice".to_string()), + ..InvocationOverrides::default() + }, + ) + .await; + let alice = stored_wallet("alice", ALICE_PRIVATE_KEY, PASSWORD); + let bob = stored_wallet("bob", BOB_PRIVATE_KEY, PASSWORD); + app.keystore_store + .set(KeyStore { + wallets: vec![alice, bob], + }) + .await + .expect("persist wallets"); + app.config_store + .update(|config| config.default_wallet = Some("bob".to_string())) + .await + .expect("persist default wallet"); + + let output = export_private_key_output_for_selector(&app, None, PASSWORD) + .await + .expect("export overridden active wallet private key"); + + assert_eq!( + output.value["private_key"], + json!(private_key_hex(ALICE_PRIVATE_KEY)) + ); +} + +fn stored_wallet(name: &str, private_key: &str, password: &str) -> StoredWallet { + let secret_key = hex::decode(private_key).expect("decode private key"); + let encrypted_private_key = + encrypt_private_key(&secret_key, password).expect("encrypt private key"); + + StoredWallet { + address: format!( + "{:#x}", + wallet_address(&secret_key).expect("derive wallet address") + ), + encrypted_key: encrypted_private_key.encrypted_key, + name: name.to_string(), + salt: encrypted_private_key.salt, + kdf: encrypted_private_key.kdf, + } +} + +fn private_key_hex(private_key: &str) -> String { + format!("0x{private_key}") +} diff --git a/pkg/beam-cli/src/tests/wallet_recovery.rs b/pkg/beam-cli/src/tests/wallet_recovery.rs new file mode 100644 index 0000000..e747d1c --- /dev/null +++ b/pkg/beam-cli/src/tests/wallet_recovery.rs @@ -0,0 +1,235 @@ +// lint-long-file-override allow-max-lines=300 +#[cfg(unix)] +use std::{fs::File, io::Write, os::fd::AsRawFd}; + +#[cfg(unix)] +use tempfile::NamedTempFile; + +#[cfg(unix)] +use crate::commands::wallet_recovery::read_recovery_phrase_from_fd; + +use super::fixtures::test_app_with_output; +use crate::{ + commands::wallet_recovery::{ + export_recovery_phrase_output, import_recovery_phrase_with_password, + recovery_phrase_import_warning, + }, + error::Error, + keystore::{KeyStore, StoredWallet, decrypt_private_key, encrypt_private_key, wallet_address}, + output::OutputMode, + runtime::{BeamApp, InvocationOverrides}, +}; + +const PASSWORD: &str = "beam-password"; +const PRIVATE_KEY: &str = "4f3edf983ac636a65a842ce7c78d9aa706d3b113bce036f6c4d1f06b2d1f6f9d"; +const RECOVERY_PHRASE: &str = "execute want toward intact gloom farm head machine treat detect grit evoke honey sudden exclude orchard dad renew crucial this ready moral salmon pave"; + +#[cfg(unix)] +#[test] +fn reads_recovery_phrase_from_file_descriptor() { + let recovery_phrase = "execute want toward intact gloom farm head machine treat detect grit evoke honey sudden exclude orchard dad renew crucial this ready moral salmon pave"; + let mut temp = NamedTempFile::new().expect("create temp recovery phrase file"); + write!(temp, "{recovery_phrase}").expect("write recovery phrase"); + + let file = File::open(temp.path()).expect("open temp recovery phrase file"); + let actual = read_recovery_phrase_from_fd(file.as_raw_fd() as u32) + .expect("read recovery phrase from file descriptor"); + + assert_eq!(actual, recovery_phrase); +} + +#[tokio::test] +async fn exports_default_wallet_recovery_phrase_with_semantic_warning() { + let (_temp_dir, app) = + test_app_with_output(OutputMode::Quiet, InvocationOverrides::default()).await; + let address = seed_encrypted_wallet(&app, "alice", PASSWORD).await; + + let output = export_recovery_phrase_output(&app, None, PASSWORD) + .await + .expect("export recovery phrase"); + + assert!(output.default.contains("Recovery phrase for alice")); + assert!(output.default.contains(&address)); + assert!(output.default.contains(RECOVERY_PHRASE)); + assert!( + output + .default + .contains("direct BIP39 encoding of the raw EVM private key") + ); + assert!( + output + .default + .contains("not a MetaMask or HD-wallet seed phrase") + ); + assert_eq!(output.compact.as_deref(), Some(RECOVERY_PHRASE)); + assert_eq!(output.value["address"].as_str(), Some(address.as_str())); + assert_eq!(output.value["name"].as_str(), Some("alice")); + assert_eq!( + output.value["recovery_phrase"].as_str(), + Some(RECOVERY_PHRASE) + ); + assert_eq!( + output.value["recovery_phrase_kind"].as_str(), + Some("evm_private_key_bip39_entropy") + ); + assert_eq!(output.value["hd_seed_phrase"].as_bool(), Some(false)); + assert!( + output.value["warning"] + .as_str() + .expect("warning") + .contains("not a MetaMask or HD-wallet seed phrase") + ); +} + +#[tokio::test] +async fn export_recovery_phrase_rejects_wrong_password() { + let (_temp_dir, app) = + test_app_with_output(OutputMode::Quiet, InvocationOverrides::default()).await; + seed_encrypted_wallet(&app, "alice", PASSWORD).await; + + let err = export_recovery_phrase_output(&app, None, "wrong-password") + .await + .expect_err("reject wrong password"); + + assert!(matches!(err, Error::DecryptionFailed)); +} + +#[tokio::test] +async fn imports_recovery_phrase_with_password_and_persists_wallet() { + let (_temp_dir, app) = + test_app_with_output(OutputMode::Quiet, InvocationOverrides::default()).await; + let expected_private_key = private_key_bytes(); + let expected_address = format!( + "{:#x}", + wallet_address(&expected_private_key).expect("derive wallet address") + ); + + let output = import_recovery_phrase_with_password( + &app, + Some("restored".to_string()), + RECOVERY_PHRASE, + PASSWORD, + Some(&expected_address), + ) + .await + .expect("import recovery phrase"); + + assert_eq!( + output.value["address"].as_str(), + Some(expected_address.as_str()) + ); + assert_eq!(output.value["name"].as_str(), Some("restored")); + + let keystore = app.keystore_store.get().await; + assert_eq!(keystore.wallets.len(), 1); + let wallet = &keystore.wallets[0]; + assert_eq!(wallet.address, expected_address); + assert_eq!(wallet.name, "restored"); + assert_eq!( + decrypt_private_key(wallet, PASSWORD).expect("decrypt imported wallet"), + expected_private_key + ); + + let config = app.config_store.get().await; + assert_eq!(config.default_wallet.as_deref(), Some("restored")); + + let exported = export_recovery_phrase_output(&app, None, PASSWORD) + .await + .expect("export imported default wallet"); + assert_eq!( + exported.value["recovery_phrase"].as_str(), + Some(RECOVERY_PHRASE) + ); +} + +#[tokio::test] +async fn import_recovery_phrase_rejects_unexpected_address_before_persisting() { + let (_temp_dir, app) = + test_app_with_output(OutputMode::Quiet, InvocationOverrides::default()).await; + + let err = import_recovery_phrase_with_password( + &app, + Some("restored".to_string()), + RECOVERY_PHRASE, + PASSWORD, + Some("0x1111111111111111111111111111111111111111"), + ) + .await + .expect_err("reject unexpected derived address"); + + match err { + Error::RecoveryPhraseAddressMismatch { expected, derived } => { + assert_eq!(expected, "0x1111111111111111111111111111111111111111"); + assert_ne!(derived, expected); + } + other => panic!("expected address mismatch, got {other:?}"), + } + assert!(app.keystore_store.get().await.wallets.is_empty()); +} + +#[tokio::test] +async fn duplicate_recovery_phrase_import_fails_before_password_validation() { + let (_temp_dir, app) = + test_app_with_output(OutputMode::Quiet, InvocationOverrides::default()).await; + let address = seed_encrypted_wallet(&app, "alice", PASSWORD).await; + + let err = import_recovery_phrase_with_password( + &app, + Some("duplicate".to_string()), + RECOVERY_PHRASE, + " \t ", + Some(&address), + ) + .await + .expect_err("reject duplicate address before validating password"); + + assert!(matches!( + err, + Error::WalletAddressAlreadyExists { address: duplicate } if duplicate == address + )); +} + +#[test] +fn recovery_phrase_import_warning_includes_derived_address_and_non_hd_warning() { + let warning = recovery_phrase_import_warning("0x9a5ad45307715c47527a232b6c65978349c2411c"); + + assert!(warning.contains("0x9a5ad45307715c47527a232b6c65978349c2411c")); + assert!(warning.contains("direct BIP39 encoding of the raw EVM private key")); + assert!(warning.contains("not a MetaMask or HD-wallet seed phrase")); + assert!(warning.contains("Press Ctrl-C to cancel")); +} + +async fn seed_encrypted_wallet(app: &BeamApp, name: &str, password: &str) -> String { + let private_key = private_key_bytes(); + let encrypted_private_key = + encrypt_private_key(&private_key, password).expect("encrypt private key"); + let address = format!( + "{:#x}", + wallet_address(&private_key).expect("derive wallet address") + ); + + app.keystore_store + .set(KeyStore { + wallets: vec![StoredWallet { + address: address.clone(), + encrypted_key: encrypted_private_key.encrypted_key, + name: name.to_string(), + salt: encrypted_private_key.salt, + kdf: encrypted_private_key.kdf, + }], + }) + .await + .expect("persist keystore"); + + let default_wallet = name.to_string(); + app.config_store + .update(move |config| config.default_wallet = Some(default_wallet.clone())) + .await + .expect("persist default wallet"); + + address +} + +fn private_key_bytes() -> Vec { + hex::decode(PRIVATE_KEY).expect("decode private key") +} diff --git a/pkg/json-store/src/lib.rs b/pkg/json-store/src/lib.rs index dfce8ab..7504ee0 100644 --- a/pkg/json-store/src/lib.rs +++ b/pkg/json-store/src/lib.rs @@ -39,14 +39,19 @@ //! } //! ``` +use std::ffi::OsString; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; +use std::process; use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; use tokio::fs; #[cfg(unix)] +use tokio::fs::OpenOptions; use tokio::io::AsyncWriteExt; use tokio::sync::RwLock; use tracing::{debug, info, warn}; @@ -198,7 +203,12 @@ where data.clone() } - /// Updates the state using a closure and persists the changes atomically + /// Updates the state using a closure and persists the changes atomically. + /// + /// The updated value is written and renamed into place before the + /// in-memory state is swapped. If persistence fails, callers receive the + /// error and subsequent reads from the same store instance continue to see + /// the last committed value. /// /// # Arguments /// * `update_fn` - A closure that takes a mutable reference to the state @@ -209,15 +219,20 @@ where where F: FnOnce(&mut T) + Send, { - { - let mut data = self.data.write().await; - update_fn(&mut *data); - } - - self.persist().await + let mut data = self.data.write().await; + let mut new_state = data.clone(); + update_fn(&mut new_state); + self.persist_state(&new_state).await?; + *data = new_state; + Ok(()) } - /// Replaces the entire state with a new value and persists it atomically + /// Replaces the entire state with a new value and persists it atomically. + /// + /// The replacement becomes visible through [`Self::get`] only after the + /// JSON file write and atomic rename succeed. This gives callers + /// transaction-like rollback semantics for a single store instance when the + /// filesystem rejects the write. /// /// # Arguments /// * `new_state` - The new state to replace the current one @@ -225,12 +240,36 @@ where /// # Returns /// Result indicating success or failure of the set operation pub async fn set(&self, new_state: T) -> Result<(), JsonStoreError> { - { - let mut data = self.data.write().await; - *data = new_state; - } + let mut data = self.data.write().await; + self.persist_state(&new_state).await?; + *data = new_state; + Ok(()) + } - self.persist().await + /// Replace state, or recover to a caller-supplied state if persistence fails. + /// + /// Higher-level stores use this when a failed final commit must abandon a + /// prepared in-memory attempt rather than exposing stale pending state. The + /// recovery state is also persisted when the filesystem permits it. If both + /// writes fail, the same store instance still swaps to `recovery_state` so + /// subsequent in-process reads observe the caller's rollback decision. + pub async fn set_with_recovery_on_error( + &self, + new_state: T, + recovery_state: T, + ) -> Result<(), JsonStoreError> { + let mut data = self.data.write().await; + match self.persist_state(&new_state).await { + Ok(()) => { + *data = new_state; + Ok(()) + } + Err(error) => { + let _ = self.persist_state(&recovery_state).await; + *data = recovery_state; + Err(error) + } + } } /// Persists the current state to the JSON file atomically @@ -238,19 +277,21 @@ where /// This method writes to a temporary file first, then atomically moves it /// to the target location to ensure consistency. async fn persist(&self) -> Result<(), JsonStoreError> { - let data = self.data.read().await; + let data = self.data.read().await.clone(); + self.persist_state(&data).await + } + async fn persist_state(&self, data: &T) -> Result<(), JsonStoreError> { // Serialize the data - let json_content = serde_json::to_string_pretty(&*data)?; - - // Create a temporary file in the same directory as the target file - let temp_path = self.file_path.with_extension("tmp"); + let json_content = serde_json::to_string_pretty(data)?; - // Write to temporary file - write_json_file(&temp_path, &json_content, self.file_access).await?; + let temp_path = + write_json_temp_file(&self.file_path, &json_content, self.file_access).await?; - // Atomically move the temporary file to the target location - fs::rename(&temp_path, &self.file_path).await?; + if let Err(error) = fs::rename(&temp_path, &self.file_path).await { + let _ = fs::remove_file(&temp_path).await; + return Err(error.into()); + } ensure_file_access(&self.file_path, self.file_access).await?; debug!("Successfully persisted state to: {:?}", self.file_path); @@ -276,33 +317,81 @@ where } } -async fn write_json_file( - path: &Path, +static TEMP_FILE_COUNTER: AtomicU64 = AtomicU64::new(0); + +async fn write_json_temp_file( + target_path: &Path, content: &str, file_access: FileAccess, -) -> Result<(), std::io::Error> { - if matches!(file_access, FileAccess::OwnerOnly) { - return write_owner_only_json_file(path, content).await; +) -> Result { + for _ in 0..16 { + let temp_path = unique_temp_path(target_path)?; + let result = if matches!(file_access, FileAccess::OwnerOnly) { + write_owner_only_json_file(&temp_path, content).await + } else { + write_shared_json_file(&temp_path, content).await + }; + match result { + Ok(()) => return Ok(temp_path), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(error) => { + let _ = fs::remove_file(&temp_path).await; + return Err(error); + } + } } - fs::write(path, content).await + Err(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "could not create a unique json-store temp file", + )) +} + +fn unique_temp_path(target_path: &Path) -> Result { + let parent = target_path.parent().unwrap_or_else(|| Path::new(".")); + let file_name = target_path.file_name().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "missing json-store file name", + ) + })?; + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |duration| duration.as_nanos()); + let counter = TEMP_FILE_COUNTER.fetch_add(1, Ordering::Relaxed); + let mut temp_name = OsString::from("."); + temp_name.push(file_name); + temp_name.push(format!(".tmp-{}-{nanos}-{counter}", process::id())); + + Ok(parent.join(temp_name)) } #[cfg(unix)] async fn write_owner_only_json_file(path: &Path, content: &str) -> Result<(), std::io::Error> { - let mut options = fs::OpenOptions::new(); - options.create(true).truncate(true).write(true).mode(0o600); + let mut options = OpenOptions::new(); + options.create_new(true).write(true).mode(0o600); - let mut file = options.open(path).await?; - file.write_all(content.as_bytes()).await?; - file.flush().await?; - drop(file); + { + let mut file = options.open(path).await?; + file.write_all(content.as_bytes()).await?; + file.flush().await?; + } ensure_file_access(path, FileAccess::OwnerOnly).await } #[cfg(not(unix))] async fn write_owner_only_json_file(path: &Path, content: &str) -> Result<(), std::io::Error> { - fs::write(path, content).await + write_shared_json_file(path, content).await +} + +async fn write_shared_json_file(path: &Path, content: &str) -> Result<(), std::io::Error> { + let mut file = fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(path) + .await?; + file.write_all(content.as_bytes()).await?; + file.flush().await } async fn ensure_file_access(path: &Path, file_access: FileAccess) -> Result<(), std::io::Error> { diff --git a/pkg/json-store/src/tests.rs b/pkg/json-store/src/tests.rs index 7eeb217..13f6030 100644 --- a/pkg/json-store/src/tests.rs +++ b/pkg/json-store/src/tests.rs @@ -3,6 +3,7 @@ use super::*; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; +use serde::ser::{Error as SerializeError, SerializeStruct}; use serde::{Deserialize, Serialize}; use tempdir::TempDir; @@ -13,6 +14,28 @@ struct TestState { active: bool, } +#[derive(Debug, Clone, Deserialize, Default, PartialEq)] +struct SometimesFailingState { + counter: u64, + fail_serialize: bool, +} + +impl Serialize for SometimesFailingState { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + if self.fail_serialize { + return Err(S::Error::custom("forced serialization failure")); + } + + let mut state = serializer.serialize_struct("SometimesFailingState", 2)?; + state.serialize_field("counter", &self.counter)?; + state.serialize_field("fail_serialize", &self.fail_serialize)?; + state.end() + } +} + #[tokio::test] async fn test_new_store_creates_default_state() { let temp_dir = TempDir::new("json_kv_store_test").unwrap(); @@ -65,6 +88,52 @@ async fn test_set_and_persist() { assert_eq!(retrieved_state, new_state); } +#[tokio::test] +async fn set_persist_failure_does_not_change_in_memory_state() { + let temp_dir = TempDir::new("json_kv_store_test").unwrap(); + let store = JsonStore::::new(temp_dir.path(), "set_failure.json") + .await + .unwrap(); + let committed = SometimesFailingState { + counter: 1, + fail_serialize: false, + }; + store.set(committed.clone()).await.unwrap(); + + let result = store + .set(SometimesFailingState { + counter: 2, + fail_serialize: true, + }) + .await; + + assert!(matches!(result, Err(JsonStoreError::Serialization(_)))); + assert_eq!(store.get().await, committed); +} + +#[tokio::test] +async fn update_persist_failure_does_not_change_in_memory_state() { + let temp_dir = TempDir::new("json_kv_store_test").unwrap(); + let store = JsonStore::::new(temp_dir.path(), "update_failure.json") + .await + .unwrap(); + let committed = SometimesFailingState { + counter: 1, + fail_serialize: false, + }; + store.set(committed.clone()).await.unwrap(); + + let result = store + .update(|state| { + state.counter = 2; + state.fail_serialize = true; + }) + .await; + + assert!(matches!(result, Err(JsonStoreError::Serialization(_)))); + assert_eq!(store.get().await, committed); +} + #[tokio::test] async fn test_persistence_across_instances() { let temp_dir = TempDir::new("json_kv_store_test").unwrap(); @@ -201,6 +270,11 @@ async fn test_owner_only_access_restricts_existing_and_persisted_files() { 0o600 ); + let fixed_temp_path = store.file_path.with_extension("tmp"); + fs::write(&fixed_temp_path, "preexisting fixed temp path") + .await + .unwrap(); + store .update(|state| { state.counter = 1; @@ -212,4 +286,8 @@ async fn test_owner_only_access_restricts_existing_and_persisted_files() { std::fs::metadata(&file_path).unwrap().permissions().mode() & 0o777, 0o600 ); + assert_eq!( + fs::read_to_string(fixed_temp_path).await.unwrap(), + "preexisting fixed temp path" + ); } diff --git a/pkg/workspace-hack/Cargo.toml b/pkg/workspace-hack/Cargo.toml index 06144a6..08a07b3 100644 --- a/pkg/workspace-hack/Cargo.toml +++ b/pkg/workspace-hack/Cargo.toml @@ -11,6 +11,7 @@ default = [] ### BEGIN HAKARI SECTION [dependencies] actix-router = { version = "0.5", default-features = false, features = ["http", "unicode"] } +aead = { version = "0.5", features = ["alloc", "getrandom"] } ahash = { version = "0.8", default-features = false, features = ["runtime-rng"] } aho-corasick = { version = "1" } allocator-api2 = { version = "0.2" } @@ -38,6 +39,7 @@ ark-serialize = { version = "0.5", default-features = false, features = ["derive ark-std = { version = "0.5", default-features = false, features = ["std"] } arrayvec = { version = "0.7", features = ["serde"] } async-compression = { version = "0.4", default-features = false, features = ["brotli", "gzip", "tokio", "zlib", "zstd"] } +axum = { version = "0.7", features = ["macros"] } base64 = { version = "0.13", features = ["alloc"] } bitflags = { version = "2", default-features = false, features = ["serde", "std"] } bitvec = { version = "1", features = ["serde"] } @@ -61,6 +63,7 @@ crypto-common = { version = "0.1", default-features = false, features = ["getran curve25519-dalek = { version = "4", default-features = false, features = ["alloc", "digest", "precomputed-tables", "zeroize"] } dashmap = { version = "6", default-features = false, features = ["inline"] } data-encoding = { version = "2" } +der = { version = "0.7", default-features = false, features = ["oid", "pem", "std"] } derive_more = { version = "2", features = ["full"] } digest = { version = "0.10", features = ["mac", "oid", "std"] } ecdsa = { version = "0.16", default-features = false, features = ["pem", "serde", "signing", "std", "verifying"] } @@ -122,6 +125,7 @@ num-traits = { version = "0.2", features = ["i128", "libm"] } num_enum = { version = "0.7" } nybbles = { version = "0.4", default-features = false, features = ["rlp", "serde", "std"] } once_cell = { version = "1", features = ["critical-section"] } +p256 = { version = "0.13", features = ["ecdh"] } parity-scale-codec = { version = "3", features = ["bytes", "derive", "max-encoded-len"] } parking_lot = { version = "0.12", features = ["arc_lock", "send_guard", "serde"] } percent-encoding = { version = "2" } @@ -134,7 +138,7 @@ proc-macro2 = { version = "1", features = ["span-locations"] } prost = { version = "0.13", features = ["prost-derive"] } rand-274715c4dabd11b0 = { package = "rand", version = "0.9", features = ["serde"] } rand-c38e5c1d305a1b54 = { package = "rand", version = "0.8", features = ["serde", "small_rng"] } -rand_chacha-274715c4dabd11b0 = { package = "rand_chacha", version = "0.9", default-features = false, features = ["std"] } +rand_chacha-274715c4dabd11b0 = { package = "rand_chacha", version = "0.9" } rand_chacha-468e82937335b1c9 = { package = "rand_chacha", version = "0.3" } rand_core-274715c4dabd11b0 = { package = "rand_core", version = "0.9", default-features = false, features = ["os_rng", "serde", "std"] } rand_core-3b31131e45eafb45 = { package = "rand_core", version = "0.6", default-features = false, features = ["std"] } @@ -155,7 +159,7 @@ sec1 = { version = "0.7", features = ["pem", "serde", "std", "subtle"] } semver = { version = "1", features = ["serde"] } serde = { version = "1", features = ["alloc", "derive", "rc"] } serde_core = { version = "1", features = ["alloc", "rc"] } -serde_json = { version = "1", features = ["alloc", "raw_value", "unbounded_depth"] } +serde_json = { version = "1", features = ["alloc", "float_roundtrip", "raw_value", "unbounded_depth"] } serde_spanned = { version = "1", default-features = false, features = ["serde", "std"] } serde_with = { version = "3", features = ["base64"] } sha1 = { version = "0.10", features = ["oid"] } @@ -167,6 +171,7 @@ smallvec = { version = "1", default-features = false, features = ["const_new", " socket2-3b31131e45eafb45 = { package = "socket2", version = "0.6", default-features = false, features = ["all"] } socket2-d8f496e17d97b5cb = { package = "socket2", version = "0.5", default-features = false, features = ["all"] } spin = { version = "0.9", default-features = false, features = ["once", "rwlock", "spin_mutex", "std"] } +spki = { version = "0.7", default-features = false, features = ["pem", "std"] } strum = { version = "0.27", features = ["derive"] } subtle = { version = "2" } sync_wrapper = { version = "1", default-features = false, features = ["futures"] } @@ -189,6 +194,7 @@ unicode-normalization = { version = "0.1" } url = { version = "2", features = ["serde"] } uuid = { version = "1", features = ["serde", "v4"] } winnow = { version = "0.7" } +x25519-dalek = { version = "2", features = ["static_secrets"] } zeroize = { version = "1", features = ["zeroize_derive"] } zstd = { version = "0.13", features = ["experimental"] } zstd-safe = { version = "7", default-features = false, features = ["arrays", "experimental", "legacy", "std", "zdict_builder"] } @@ -196,6 +202,7 @@ zstd-sys = { version = "2", features = ["experimental", "std"] } [build-dependencies] actix-router = { version = "0.5", default-features = false, features = ["http", "unicode"] } +aead = { version = "0.5", features = ["alloc", "getrandom"] } ahash = { version = "0.8", default-features = false, features = ["runtime-rng"] } aho-corasick = { version = "1" } allocator-api2 = { version = "0.2" } @@ -226,6 +233,7 @@ ark-serialize = { version = "0.5", default-features = false, features = ["derive ark-std = { version = "0.5", default-features = false, features = ["std"] } arrayvec = { version = "0.7", features = ["serde"] } async-compression = { version = "0.4", default-features = false, features = ["brotli", "gzip", "tokio", "zlib", "zstd"] } +axum = { version = "0.7", features = ["macros"] } base64 = { version = "0.13", features = ["alloc"] } bindgen = { version = "0.71" } bitflags = { version = "2", default-features = false, features = ["serde", "std"] } @@ -254,6 +262,7 @@ darling = { version = "0.21", features = ["serde"] } darling_core = { version = "0.21", default-features = false, features = ["serde", "suggestions"] } dashmap = { version = "6", default-features = false, features = ["inline"] } data-encoding = { version = "2" } +der = { version = "0.7", default-features = false, features = ["oid", "pem", "std"] } derive_more = { version = "2", features = ["full"] } derive_more-impl = { version = "2", features = ["add", "add_assign", "as_ref", "constructor", "debug", "deref", "deref_mut", "display", "eq", "error", "from", "from_str", "index", "index_mut", "into", "into_iterator", "is_variant", "mul", "mul_assign", "not", "sum", "try_from", "try_into", "try_unwrap", "unwrap"] } digest = { version = "0.10", features = ["mac", "oid", "std"] } @@ -317,6 +326,7 @@ num_enum = { version = "0.7" } num_enum_derive = { version = "0.7", default-features = false, features = ["std"] } nybbles = { version = "0.4", default-features = false, features = ["rlp", "serde", "std"] } once_cell = { version = "1", features = ["critical-section"] } +p256 = { version = "0.13", features = ["ecdh"] } parity-scale-codec = { version = "3", features = ["bytes", "derive", "max-encoded-len"] } parking_lot = { version = "0.12", features = ["arc_lock", "send_guard", "serde"] } percent-encoding = { version = "2" } @@ -331,7 +341,7 @@ prost = { version = "0.13", features = ["prost-derive"] } quote = { version = "1" } rand-274715c4dabd11b0 = { package = "rand", version = "0.9", features = ["serde"] } rand-c38e5c1d305a1b54 = { package = "rand", version = "0.8", features = ["serde", "small_rng"] } -rand_chacha-274715c4dabd11b0 = { package = "rand_chacha", version = "0.9", default-features = false, features = ["std"] } +rand_chacha-274715c4dabd11b0 = { package = "rand_chacha", version = "0.9" } rand_chacha-468e82937335b1c9 = { package = "rand_chacha", version = "0.3" } rand_core-274715c4dabd11b0 = { package = "rand_core", version = "0.9", default-features = false, features = ["os_rng", "serde", "std"] } rand_core-3b31131e45eafb45 = { package = "rand_core", version = "0.6", default-features = false, features = ["std"] } @@ -352,7 +362,7 @@ sec1 = { version = "0.7", features = ["pem", "serde", "std", "subtle"] } semver = { version = "1", features = ["serde"] } serde = { version = "1", features = ["alloc", "derive", "rc"] } serde_core = { version = "1", features = ["alloc", "rc"] } -serde_json = { version = "1", features = ["alloc", "raw_value", "unbounded_depth"] } +serde_json = { version = "1", features = ["alloc", "float_roundtrip", "raw_value", "unbounded_depth"] } serde_spanned = { version = "1", default-features = false, features = ["serde", "std"] } serde_with = { version = "3", features = ["base64"] } sha1 = { version = "0.10", features = ["oid"] } @@ -364,6 +374,7 @@ smallvec = { version = "1", default-features = false, features = ["const_new", " socket2-3b31131e45eafb45 = { package = "socket2", version = "0.6", default-features = false, features = ["all"] } socket2-d8f496e17d97b5cb = { package = "socket2", version = "0.5", default-features = false, features = ["all"] } spin = { version = "0.9", default-features = false, features = ["once", "rwlock", "spin_mutex", "std"] } +spki = { version = "0.7", default-features = false, features = ["pem", "std"] } strum = { version = "0.27", features = ["derive"] } subtle = { version = "2" } syn-dff4ba8e3ae991db = { package = "syn", version = "1", features = ["extra-traits", "full", "visit"] } @@ -388,6 +399,7 @@ unicode-normalization = { version = "0.1" } url = { version = "2", features = ["serde"] } uuid = { version = "1", features = ["serde", "v4"] } winnow = { version = "0.7" } +x25519-dalek = { version = "2", features = ["static_secrets"] } zeroize = { version = "1", features = ["zeroize_derive"] } zstd = { version = "0.13", features = ["experimental"] } zstd-safe = { version = "7", default-features = false, features = ["arrays", "experimental", "legacy", "std", "zdict_builder"] } diff --git a/pkg/xtask/src/lint/steps/checks.rs b/pkg/xtask/src/lint/steps/checks.rs index c28c17a..b12c623 100644 --- a/pkg/xtask/src/lint/steps/checks.rs +++ b/pkg/xtask/src/lint/steps/checks.rs @@ -1,3 +1,4 @@ +// lint-long-file-override allow-max-lines=240 use std::io::ErrorKind; use std::path::Path; use std::time::Instant; diff --git a/pkg/xtask/src/test/mod.rs b/pkg/xtask/src/test/mod.rs index 71fc0b2..0ef6eec 100644 --- a/pkg/xtask/src/test/mod.rs +++ b/pkg/xtask/src/test/mod.rs @@ -15,7 +15,7 @@ use crate::git::collect_changed_files; use crate::test::changes::{ChangedCrates, determine_changed_crates, sorted_list}; use crate::test::graph::DependencyGraph; use crate::test::metadata::{Metadata, load_metadata}; -use crate::test::workspace::{CompiledWorkspace, compile_workspace_tests}; +use crate::test::workspace::{CompiledWorkspace, compile_package_tests}; fn prepare_execution_order( graph: &DependencyGraph, @@ -147,8 +147,8 @@ pub fn run_test(_args: TestArgs) -> Result<()> { return Ok(()); }; - println!("Building workspace tests with `cargo test --workspace --no-run`..."); - let compiled = compile_workspace_tests(&repo_root, &metadata)?; + println!("Building targeted tests with `cargo test --no-run -p ...`..."); + let compiled = compile_package_tests(&repo_root, &metadata, &execution_order)?; let failed_crates = run_execution_order(&execution_order, &metadata, &compiled)?; diff --git a/pkg/xtask/src/test/workspace.rs b/pkg/xtask/src/test/workspace.rs index cb99886..6e943a2 100644 --- a/pkg/xtask/src/test/workspace.rs +++ b/pkg/xtask/src/test/workspace.rs @@ -58,16 +58,26 @@ struct CargoProfile { test: bool, } -pub fn compile_workspace_tests(repo_root: &Path, metadata: &Metadata) -> Result { - let output = Command::new("cargo") +pub fn compile_package_tests( + repo_root: &Path, + metadata: &Metadata, + package_names: &[String], +) -> Result { + let mut command = Command::new("cargo"); + command .arg("test") - .arg("--workspace") .arg("--message-format=json-render-diagnostics") .arg("--no-run") .arg("--quiet") - .current_dir(repo_root) + .current_dir(repo_root); + + for package_name in package_names { + command.arg("--package").arg(package_name); + } + + let output = command .output() - .context("spawn cargo test --workspace --no-run")?; + .with_context(|| format!("spawn {}", package_test_command_label(package_names)))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr).to_string(); @@ -154,3 +164,13 @@ pub fn compile_workspace_tests(repo_root: &Path, metadata: &Metadata) -> Result< fn sanitize_bin_name(name: &str) -> String { name.replace('-', "_") } + +fn package_test_command_label(package_names: &[String]) -> String { + let mut args = vec!["cargo test --no-run".to_owned()]; + + for package_name in package_names { + args.push(format!("--package {package_name}")); + } + + args.join(" ") +} diff --git a/scripts/prepare-beam-release-pr.sh b/scripts/prepare-beam-release-pr.sh index 0dba887..cf52811 100755 --- a/scripts/prepare-beam-release-pr.sh +++ b/scripts/prepare-beam-release-pr.sh @@ -2,6 +2,7 @@ set -euo pipefail readonly BEAM_MANIFEST="pkg/beam-cli/Cargo.toml" +readonly BEAM_SITE_VERSION_FILE="app/packages/beam-site/src/lib/beamVersion.ts" readonly PAYY_REPO_URL="${PAYY_REPO_URL:-https://github.com/polybase/payy.git}" current_version() { @@ -133,6 +134,12 @@ update_manifest_version() { perl -0pi -e 's/^version = "[^"]+"/version = "$ENV{BEAM_NEXT_VERSION}"/m' "$BEAM_MANIFEST" } +update_site_version() { + local version="$1" + + BEAM_NEXT_VERSION="$version" perl -0pi -e "s/^const FALLBACK_BEAM_VERSION = '[^']+'/const FALLBACK_BEAM_VERSION = '\$ENV{BEAM_NEXT_VERSION}'/m" "$BEAM_SITE_VERSION_FILE" +} + create_or_update_pr() { local version="$1" local branch="beam/release-v${version}" @@ -154,14 +161,15 @@ EOF git checkout -B "$branch" BEAM_NEXT_VERSION="$version" update_manifest_version "$version" + update_site_version "$version" cargo update -p beam-cli - if git diff --quiet -- "$BEAM_MANIFEST" Cargo.lock; then + if git diff --quiet -- "$BEAM_MANIFEST" Cargo.lock "$BEAM_SITE_VERSION_FILE"; then echo "Beam release ${version} produced no manifest or lockfile changes." exit 0 fi - git add "$BEAM_MANIFEST" Cargo.lock + git add "$BEAM_MANIFEST" Cargo.lock "$BEAM_SITE_VERSION_FILE" git commit -m "$title" -m "$body" git fetch origin "refs/heads/${branch}:refs/remotes/origin/${branch}" || true git push --force-with-lease origin "$branch"