diff --git a/.changelog/remote-wallet-no-browser.md b/.changelog/remote-wallet-no-browser.md new file mode 100644 index 00000000..9047aec4 --- /dev/null +++ b/.changelog/remote-wallet-no-browser.md @@ -0,0 +1,5 @@ +--- +tempo-wallet: patch +--- + +Add a remote-host-friendly `--no-browser` path for `tempo wallet login` and `tempo wallet fund`, including CLI guidance that agents can relay to a user approving wallet actions from another device. diff --git a/README.md b/README.md index 4487f86e..a807c67b 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,8 @@ Tempo Wallet is a CLI that lets you create a wallet, manage keys, and make HTTP 3. The browser authorizes a session key for the CLI and redirects back. 4. The CLI stores the authorized key locally. All subsequent signing happens locally — no browser needed. +If the agent is running on a remote host while you are using a different device, use `tempo wallet login --no-browser` instead. The CLI will print the auth URL and verification code for you to open on your device, and you may need to return to your CLI or agent session after passkey creation or after funding. A second authorization round may still be needed before the host is ready. + ## Goals 1. **Zero-config payments**: `tempo request ` handles the full 402 flow — challenge, sign, pay, retry — in a single command. @@ -60,11 +62,17 @@ npx skills@latest add tempoxyz/wallet --global # Log in with your passkey (opens browser) tempo wallet login +# Remote-host login when the human is on another device +tempo wallet login --no-browser + # Check your wallet tempo wallet whoami # Fund your wallet tempo wallet fund + +# Remote-host funding when the human is on another device +tempo wallet fund --no-browser ``` ### One-Shot Payment (Charge) diff --git a/SKILL.md b/SKILL.md index a00b93ad..270dd217 100644 --- a/SKILL.md +++ b/SKILL.md @@ -16,7 +16,9 @@ Run these commands in order. Do not skip steps. Do not search for other install **Step 1 — Install:** `curl -fsSL https://tempo.xyz/install | bash` -**Step 2 — Login:** `"$HOME/.tempo/bin/tempo" wallet login` — requires user browser/passkey action. Prompt user, wait for confirmation, then continue. Do not loop login attempts without user confirmation. When run by agents, use a long command timeout (at least 16 minutes). +**Step 2 — Login:** `"$HOME/.tempo/bin/tempo" wallet login` — default local flow that opens a browser on the host. Prompt user, wait for confirmation, then continue. Do not loop login attempts without user confirmation. When run by agents, use a long command timeout (at least 16 minutes). + +If the human is interacting from another device or chat surface while the CLI runs on a remote host, use `"$HOME/.tempo/bin/tempo" wallet login --no-browser` instead. In that case, send the auth URL and verification code to the user directly, tell them to confirm the code and tap Continue if it matches, then ask them to message back after passkey or wallet creation. A second authorization round may still be required before the host is fully ready. **Step 3 — Confirm readiness:** `"$HOME/.tempo/bin/tempo" wallet -t whoami` @@ -32,6 +34,8 @@ Provide: - Installation location and version (`$HOME/.tempo/bin/tempo --version`). - Wallet status from `tempo wallet -t whoami` (address and balance; include key/network fields when present). - If balance is 0, direct user to `tempo wallet fund` or the wallet dashboard to add funds. +- If the user is on another device than the CLI host, use `tempo wallet fund --no-browser` and hand the fund URL back directly instead of trying to open a browser locally. +- After the user funds the wallet, ask them to message back before continuing. - 2-3 simple starter prompts tailored to currently available services. To generate starter prompts, list available services and pick useful beginner examples: diff --git a/crates/tempo-wallet/README.md b/crates/tempo-wallet/README.md index 3cc6177f..45460665 100644 --- a/crates/tempo-wallet/README.md +++ b/crates/tempo-wallet/README.md @@ -7,11 +7,13 @@ Wallet identity and custody extension for the Tempo CLI. Manages authentication, | Command | Description | |---------|-------------| | `tempo wallet login` | Connect via browser passkey authentication | +| `tempo wallet login --no-browser` | Remote-host login when the human is on another device | | `tempo wallet refresh` | Renew your access key without logging out | | `tempo wallet logout` | Disconnect your wallet | | `tempo wallet whoami` | Show wallet address, balances, keys, and readiness | | `tempo wallet keys` | List keys with balance and spending limit details | | `tempo wallet fund` | Fund your wallet (opens browser) | +| `tempo wallet fund --no-browser` | Remote-host funding when the human is on another device | | `tempo wallet sessions list` | List payment sessions | | `tempo wallet sessions close` | Close by origin or channel ID, or batch close/finalize/orphaned | | `tempo wallet sessions sync` | Reconcile local sessions against on-chain state | @@ -27,10 +29,21 @@ curl -fsSL https://tempo.xyz/install | bash # Connect your wallet tempo wallet login +# Remote-host login +tempo wallet login --no-browser + # Check status tempo wallet whoami + +# Fund your wallet +tempo wallet fund + +# Remote-host funding +tempo wallet fund --no-browser ``` +If you use the remote-host funding path, return to your CLI or agent session once funding is complete so the workflow can continue. + ## License Dual-licensed under [Apache 2.0](../../LICENSE-APACHE) and [MIT](../../LICENSE-MIT). diff --git a/crates/tempo-wallet/src/app.rs b/crates/tempo-wallet/src/app.rs index 3da79c92..38fb104c 100644 --- a/crates/tempo-wallet/src/app.rs +++ b/crates/tempo-wallet/src/app.rs @@ -25,11 +25,14 @@ pub(crate) async fn run(mut cli: Cli) -> Result<(), TempoError> { |ctx| async move { let cmd_name = command_name(&command); let result = match command { - Commands::Login => login::run(&ctx).await, + Commands::Login { no_browser } => login::run(&ctx, no_browser).await, Commands::Refresh => refresh::run(&ctx).await, Commands::Logout { yes } => logout::run(&ctx, yes), Commands::Completions { shell } => completions::run(&ctx, shell), - Commands::Fund { address } => fund::run(&ctx, address).await, + Commands::Fund { + address, + no_browser, + } => fund::run(&ctx, address, no_browser).await, Commands::Whoami => whoami::run(&ctx).await, Commands::Keys => keys::run(&ctx).await, Commands::Sessions { command } => { @@ -63,7 +66,7 @@ pub(crate) async fn run(mut cli: Cli) -> Result<(), TempoError> { /// Derive a short analytics-friendly name from a parsed command. const fn command_name(command: &Commands) -> &'static str { match command { - Commands::Login => "login", + Commands::Login { .. } => "login", Commands::Refresh => "refresh", Commands::Logout { .. } => "logout", Commands::Completions { .. } => "completions", diff --git a/crates/tempo-wallet/src/args.rs b/crates/tempo-wallet/src/args.rs index 14c3dd0b..e7438462 100644 --- a/crates/tempo-wallet/src/args.rs +++ b/crates/tempo-wallet/src/args.rs @@ -31,7 +31,11 @@ pub(crate) struct Cli { pub(crate) enum Commands { /// Sign up or log in to your Tempo wallet #[command(display_order = 1)] - Login, + Login { + /// Do not attempt to open a browser + #[arg(long)] + no_browser: bool, + }, /// Refresh your access key without logging out #[command(display_order = 2)] Refresh, @@ -74,6 +78,9 @@ Examples: /// Wallet address to fund (defaults to current wallet) #[arg(long)] address: Option, + /// Do not attempt to open a browser + #[arg(long)] + no_browser: bool, }, /// Manage payment sessions #[command(display_order = 8, name = "sessions")] diff --git a/crates/tempo-wallet/src/commands/auth.rs b/crates/tempo-wallet/src/commands/auth.rs index 23bc530b..542e21be 100644 --- a/crates/tempo-wallet/src/commands/auth.rs +++ b/crates/tempo-wallet/src/commands/auth.rs @@ -1,10 +1,69 @@ //! Shared authentication and browser utilities. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum BrowserLaunchStatus { + Opened, + Skipped, + Failed, +} + /// Attempt to open a URL in the default browser. /// Prints a fallback message if it fails. -pub(crate) fn try_open_browser(url: &str) { - if let Err(e) = webbrowser::open(url) { - eprintln!("Failed to open browser: {e}"); - eprintln!("Open this URL manually: {url}"); +fn try_open_browser_with( + url: &str, + no_browser: bool, + opener: impl FnOnce(&str) -> Result<(), String>, +) -> BrowserLaunchStatus { + if no_browser { + return BrowserLaunchStatus::Skipped; + } + match opener(url) { + Ok(_) => BrowserLaunchStatus::Opened, + Err(err) => { + eprintln!("Failed to open browser: {err}"); + eprintln!("Open this URL manually: {url}"); + BrowserLaunchStatus::Failed + } + } +} + +pub(crate) fn try_open_browser(url: &str, no_browser: bool) -> BrowserLaunchStatus { + try_open_browser_with(url, no_browser, |url| { + webbrowser::open(url).map_err(|err| err.to_string()) + }) +} + +#[cfg(test)] +mod tests { + use super::{try_open_browser_with, BrowserLaunchStatus}; + use std::{cell::RefCell, rc::Rc}; + + #[test] + fn browser_launch_is_skipped_when_no_browser_is_true() { + let status = try_open_browser_with( + "https://wallet.tempo.xyz/cli-auth?code=ANMGE375", + true, + |_url| Err("should not be called".to_string()), + ); + assert_eq!(status, BrowserLaunchStatus::Skipped); + } + + #[test] + fn browser_launch_attempts_open_when_no_browser_is_false() { + let seen_url = Rc::new(RefCell::new(None::)); + let captured_url = Rc::clone(&seen_url); + let status = try_open_browser_with( + "https://wallet.tempo.xyz/cli-auth?code=ANMGE375", + false, + |url| { + *captured_url.borrow_mut() = Some(url.to_string()); + Err("synthetic open failure".to_string()) + }, + ); + assert_eq!(status, BrowserLaunchStatus::Failed); + assert_eq!( + seen_url.borrow().as_deref(), + Some("https://wallet.tempo.xyz/cli-auth?code=ANMGE375") + ); } } diff --git a/crates/tempo-wallet/src/commands/fund/mod.rs b/crates/tempo-wallet/src/commands/fund/mod.rs index f9013560..9d5b638b 100644 --- a/crates/tempo-wallet/src/commands/fund/mod.rs +++ b/crates/tempo-wallet/src/commands/fund/mod.rs @@ -26,15 +26,23 @@ const CALLBACK_TIMEOUT_SECS: u64 = 900; // Entry point // --------------------------------------------------------------------------- -pub(crate) async fn run(ctx: &Context, address: Option) -> Result<(), TempoError> { - let method = "browser"; +pub(crate) async fn run( + ctx: &Context, + address: Option, + no_browser: bool, +) -> Result<(), TempoError> { + let method = fund_method(no_browser); track_fund_start(ctx, method); - let result = run_inner(ctx, address).await; + let result = run_inner(ctx, address, no_browser).await; track_fund_result(ctx, method, &result); result } -async fn run_inner(ctx: &Context, address: Option) -> Result<(), TempoError> { +async fn run_inner( + ctx: &Context, + address: Option, + no_browser: bool, +) -> Result<(), TempoError> { let wallet_address = resolve_address(address, &ctx.keys)?; let before = query_all_balances(&ctx.config, ctx.network, &wallet_address).await; @@ -48,14 +56,19 @@ async fn run_inner(ctx: &Context, address: Option) -> Result<(), TempoEr })?; let base_url = parsed_url.origin().ascii_serialization(); let fund_url = format!("{base_url}/?action=fund"); + let show_status = no_browser || ctx.output_format == OutputFormat::Text; - if ctx.output_format == OutputFormat::Text { + if show_status { eprintln!("Fund URL: {fund_url}"); } - super::auth::try_open_browser(&fund_url); + super::auth::try_open_browser(&fund_url, no_browser); - if ctx.output_format == OutputFormat::Text { + if no_browser { + show_remote_fund_prompt(&fund_url); + } + + if show_status { eprintln!("Waiting for funding..."); } @@ -65,7 +78,7 @@ async fn run_inner(ctx: &Context, address: Option) -> Result<(), TempoEr loop { if start.elapsed() >= timeout { - if ctx.output_format == OutputFormat::Text { + if show_status { eprintln!( "Timed out waiting for funding after {} minutes.", CALLBACK_TIMEOUT_SECS / 60 @@ -79,7 +92,7 @@ async fn run_inner(ctx: &Context, address: Option) -> Result<(), TempoEr let current = query_all_balances(&ctx.config, ctx.network, &wallet_address).await; if has_balance_changed(&before, ¤t) { - if ctx.output_format == OutputFormat::Text { + if show_status { eprintln!("\nFunding received!"); render_balance_diff(&before, ¤t); } @@ -133,6 +146,19 @@ fn render_balance_diff(before: &[TokenBalance], after: &[TokenBalance]) { } } +fn fund_method(no_browser: bool) -> &'static str { + if no_browser { + "manual" + } else { + "browser" + } +} + +fn show_remote_fund_prompt(fund_url: &str) { + eprintln!("Open this link on your device: {fund_url}"); + eprintln!("After funding is complete, return here to continue."); +} + // --------------------------------------------------------------------------- // Analytics // --------------------------------------------------------------------------- @@ -170,3 +196,14 @@ fn track_fund_result(ctx: &Context, method: &str, result: &Result<(), TempoError } } } + +#[cfg(test)] +mod tests { + use super::fund_method; + + #[test] + fn fund_method_uses_manual_only_when_no_browser_is_true() { + assert_eq!(fund_method(true), "manual"); + assert_eq!(fund_method(false), "browser"); + } +} diff --git a/crates/tempo-wallet/src/commands/login.rs b/crates/tempo-wallet/src/commands/login.rs index 9026ecb7..a5540821 100644 --- a/crates/tempo-wallet/src/commands/login.rs +++ b/crates/tempo-wallet/src/commands/login.rs @@ -11,7 +11,10 @@ use url::Url; use zeroize::Zeroizing; use super::whoami::show_whoami; -use crate::analytics::{self, CallbackReceivedPayload, LoginFailurePayload, WalletCreatedPayload}; +use crate::{ + analytics::{self, CallbackReceivedPayload, LoginFailurePayload, WalletCreatedPayload}, + commands::auth::BrowserLaunchStatus, +}; use tempo_common::{ cli::{context::Context, output::OutputFormat}, error::{ConfigError, InputError, KeyError, NetworkError, TempoError}, @@ -23,15 +26,15 @@ use tempo_common::{ const CALLBACK_TIMEOUT_SECS: u64 = 900; // 15 minutes const POLL_INTERVAL_SECS: u64 = 2; -pub(crate) async fn run(ctx: &Context) -> Result<(), TempoError> { - run_impl(ctx, false).await +pub(crate) async fn run(ctx: &Context, no_browser: bool) -> Result<(), TempoError> { + run_impl(ctx, false, no_browser).await } pub(crate) async fn run_with_reauth(ctx: &Context) -> Result<(), TempoError> { - run_impl(ctx, true).await + run_impl(ctx, true, false).await } -async fn run_impl(ctx: &Context, force_reauth: bool) -> Result<(), TempoError> { +async fn run_impl(ctx: &Context, force_reauth: bool, no_browser: bool) -> Result<(), TempoError> { ctx.track_event(analytics::LOGIN_STARTED); let already_logged_in = ctx.keys.has_key_for_network(ctx.network); @@ -54,7 +57,7 @@ async fn run_impl(ctx: &Context, force_reauth: bool) -> Result<(), TempoError> { } if !already_logged_in || needs_reauth { - let result = do_login(ctx).await; + let result = do_login(ctx, no_browser).await; if let Some(ref a) = ctx.analytics { track_login_result(a, &result); @@ -195,7 +198,7 @@ fn track_login_result(a: &tempo_common::analytics::Analytics, result: &Result<() } } -async fn do_login(ctx: &Context) -> Result<(), TempoError> { +async fn do_login(ctx: &Context, no_browser: bool) -> Result<(), TempoError> { let auth_server_url = std::env::var("TEMPO_AUTH_URL").unwrap_or_else(|_| ctx.network.auth_url().to_string()); @@ -229,13 +232,17 @@ async fn do_login(ctx: &Context) -> Result<(), TempoError> { // Always attempt browser open, even in machine output modes. // Some agents run login with non-text output (`-t`/JSON) and still need // the browser flow to start. - super::auth::try_open_browser(&url_str); + let browser_launch_status = super::auth::try_open_browser(&url_str, no_browser); - if ctx.output_format == OutputFormat::Text { + if no_browser { + show_remote_login_prompt(&url_str, &code); + } else if ctx.output_format == OutputFormat::Text { show_login_prompt(&code); } - ctx.track_event(analytics::CALLBACK_WINDOW_OPENED); + if should_track_callback_window(browser_launch_status) { + ctx.track_event(analytics::CALLBACK_WINDOW_OPENED); + } let callback = poll_until_authorized(&client, &auth_base_url, &code, &code_verifier).await?; @@ -264,16 +271,52 @@ async fn do_login(ctx: &Context) -> Result<(), TempoError> { /// Display the verification code and wait prompt for authentication. fn show_login_prompt(code: &str) { - let display_code = if code.len() == 8 { - format!("{}-{}", &code[..4], &code[4..]) - } else { - code.to_string() - }; + let display_code = format_verification_code(code); eprintln!("Verification code: {}", display_code.bold()); eprintln!(); eprintln!("Waiting for authentication..."); } +/// Display the remote-host handoff prompt for a user who is chatting from another device. +fn show_remote_login_prompt(auth_url: &str, code: &str) { + let prompt = remote_login_prompt(auth_url, code); + eprintln!("{}", prompt.auth_url_line); + eprintln!("Verification code: {}", prompt.verification_code.bold()); + eprintln!("{}", prompt.continue_line); + eprintln!("{}", prompt.return_line); + eprintln!(); + eprintln!("Waiting for authentication..."); +} + +struct RemoteLoginPrompt { + auth_url_line: String, + verification_code: String, + continue_line: &'static str, + return_line: &'static str, +} + +fn remote_login_prompt(auth_url: &str, code: &str) -> RemoteLoginPrompt { + RemoteLoginPrompt { + auth_url_line: format!("Open this link on your device: {auth_url}"), + verification_code: format_verification_code(code), + continue_line: "If the wallet page shows that same code, tap Continue.", + return_line: + "After passkey or wallet creation, return here. If needed, one more authorization link may still be required before this host is ready.", + } +} + +fn format_verification_code(code: &str) -> String { + if code.len() == 8 { + format!("{}-{}", &code[..4], &code[4..]) + } else { + code.to_string() + } +} + +fn should_track_callback_window(status: BrowserLaunchStatus) -> bool { + matches!(status, BrowserLaunchStatus::Opened) +} + struct AuthCallback { account_address: String, key_authorization: Option, @@ -492,6 +535,7 @@ fn generate_pkce_pair() -> Result<(String, String), TempoError> { #[cfg(test)] mod tests { use super::*; + use crate::commands::auth::BrowserLaunchStatus; #[test] fn test_pkce_pair_lengths() { @@ -530,4 +574,32 @@ mod tests { let (v2, _) = generate_pkce_pair().expect("pkce generation should succeed"); assert_ne!(v1, v2); } + + #[test] + fn callback_window_is_only_tracked_when_browser_launch_opens() { + assert!(should_track_callback_window(BrowserLaunchStatus::Opened)); + assert!(!should_track_callback_window(BrowserLaunchStatus::Skipped)); + assert!(!should_track_callback_window(BrowserLaunchStatus::Failed)); + } + + #[test] + fn remote_login_prompt_covers_required_remote_handoff_steps() { + let prompt = remote_login_prompt( + "https://wallet.tempo.xyz/cli-auth?code=ANMGE375", + "ANMGE375", + ); + + assert_eq!( + prompt.auth_url_line, + "Open this link on your device: https://wallet.tempo.xyz/cli-auth?code=ANMGE375" + ); + assert_eq!(prompt.verification_code, "ANMG-E375"); + assert_eq!( + prompt.continue_line, + "If the wallet page shows that same code, tap Continue." + ); + assert!(prompt.return_line.contains("return here")); + assert!(prompt.return_line.contains("one more authorization link")); + assert!(prompt.return_line.contains("this host is ready")); + } } diff --git a/crates/tempo-wallet/tests/remote_flows.rs b/crates/tempo-wallet/tests/remote_flows.rs new file mode 100644 index 00000000..a88a5994 --- /dev/null +++ b/crates/tempo-wallet/tests/remote_flows.rs @@ -0,0 +1,533 @@ +mod common; + +use std::{ + collections::VecDeque, + sync::{Arc, Mutex}, +}; + +use axum::{routing::post, Json, Router}; +use serde_json::json; +use tempo_test::{mock_rpc_response, TestConfigBuilder, MODERATO_DIRECT_KEYS_TOML}; + +use common::test_command; + +const AUTHORIZED_WALLET_ADDRESS: &str = "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266"; +const MODERATO_TOKEN_ADDRESS: &str = "0x20c0000000000000000000000000000000000000"; +const BALANCE_OF_SELECTOR: &str = "70a08231"; + +struct MockLoginServer { + base_url: String, + poll_count: Arc>, + shutdown_tx: Option>, + _handle: tokio::task::JoinHandle<()>, +} + +impl MockLoginServer { + async fn start_authorized(code: &str) -> Self { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let base_url = format!("http://{}:{}", addr.ip(), addr.port()); + let poll_count = Arc::new(Mutex::new(0u32)); + + let device_code = code.to_string(); + let poll_code = code.to_string(); + let poll_state = poll_count.clone(); + + let app = Router::new() + .route( + "/cli-auth/device-code", + post(move || { + let code = device_code.clone(); + async move { Json(json!({ "code": code })) } + }), + ) + .route( + "/cli-auth/poll/{code}", + post( + move |axum::extract::Path(path_code): axum::extract::Path| { + let expected = poll_code.clone(); + let poll_state = poll_state.clone(); + async move { + assert_eq!(path_code, expected, "unexpected poll code"); + let mut count = poll_state.lock().unwrap(); + let response = if *count == 0 { + *count += 1; + json!({ "status": "pending" }) + } else { + *count += 1; + json!({ + "status": "authorized", + "account_address": AUTHORIZED_WALLET_ADDRESS, + "key_authorization": null + }) + }; + Json(response) + } + }, + ), + ); + + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + let handle = tokio::spawn(async move { + axum::serve(listener, app) + .with_graceful_shutdown(async { + let _ = shutdown_rx.await; + }) + .await + .unwrap(); + }); + + Self { + base_url, + poll_count, + shutdown_tx: Some(shutdown_tx), + _handle: handle, + } + } + + fn auth_url(&self) -> String { + format!("{}/cli-auth", self.base_url) + } +} + +impl Drop for MockLoginServer { + fn drop(&mut self) { + if let Some(tx) = self.shutdown_tx.take() { + let _ = tx.send(()); + } + } +} + +struct BalanceSequenceRpcServer { + base_url: String, + balances: Arc>>, + last_value: Arc>, + shutdown_tx: Option>, + _handle: tokio::task::JoinHandle<()>, +} + +impl BalanceSequenceRpcServer { + async fn start(raw_balances: Vec<&str>) -> Self { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let base_url = format!("http://{}:{}", addr.ip(), addr.port()); + + let balances = Arc::new(Mutex::new( + raw_balances + .into_iter() + .map(std::string::ToString::to_string) + .collect(), + )); + let last_value = Arc::new(Mutex::new(String::from("0"))); + + let shared_balances = balances.clone(); + let shared_last_value = last_value.clone(); + + let app = Router::new().route( + "/", + post(move |Json(body): Json| { + let shared_balances = shared_balances.clone(); + let shared_last_value = shared_last_value.clone(); + async move { + if let Some(batch) = body.as_array() { + let response = serde_json::Value::Array( + batch + .iter() + .map(|req| { + handle_rpc_request(req, &shared_balances, &shared_last_value) + }) + .collect(), + ); + Json(response) + } else { + Json(handle_rpc_request( + &body, + &shared_balances, + &shared_last_value, + )) + } + } + }), + ); + + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + let handle = tokio::spawn(async move { + axum::serve(listener, app) + .with_graceful_shutdown(async { + let _ = shutdown_rx.await; + }) + .await + .unwrap(); + }); + + Self { + base_url, + balances, + last_value, + shutdown_tx: Some(shutdown_tx), + _handle: handle, + } + } +} + +impl Drop for BalanceSequenceRpcServer { + fn drop(&mut self) { + if let Some(tx) = self.shutdown_tx.take() { + let _ = tx.send(()); + } + } +} + +fn handle_rpc_request( + req: &serde_json::Value, + balances: &Arc>>, + last_value: &Arc>, +) -> serde_json::Value { + if is_fund_balance_request(req) { + let raw = next_balance(balances, last_value); + let encoded = encode_raw_balance(&raw); + return json!({ + "jsonrpc": "2.0", + "id": req["id"].clone(), + "result": encoded, + }); + } + + mock_rpc_response(req, 42431) +} + +fn assert_remote_login_handoff(stderr: &str) { + assert!(stderr.contains("Auth URL:"), "{stderr}"); + assert!(stderr.contains("Verification code:"), "{stderr}"); + assert!(stderr.contains("Open this link on your device"), "{stderr}"); + assert!( + stderr.contains("If the wallet page shows that same code"), + "{stderr}" + ); + assert!(stderr.contains("tap Continue"), "{stderr}"); + assert!( + stderr.contains("After passkey or wallet creation, return here"), + "{stderr}" + ); + assert!(stderr.contains("one more authorization link"), "{stderr}"); +} + +fn assert_remote_fund_handoff(stderr: &str) { + assert!(stderr.contains("Fund URL:"), "{stderr}"); + assert!(stderr.contains("Open this link on your device"), "{stderr}"); + assert!(stderr.contains("After funding is complete"), "{stderr}"); +} + +fn is_fund_balance_request(req: &serde_json::Value) -> bool { + if req["method"].as_str() != Some("eth_call") { + return false; + } + + let Some(params) = req["params"].as_array() else { + return false; + }; + let Some(call) = params.first().and_then(serde_json::Value::as_object) else { + return false; + }; + let Some(to) = call.get("to").and_then(serde_json::Value::as_str) else { + return false; + }; + let Some(data) = call + .get("data") + .or_else(|| call.get("input")) + .and_then(serde_json::Value::as_str) + else { + return false; + }; + + normalized_hex(to) == normalized_hex(MODERATO_TOKEN_ADDRESS) + && data.eq_ignore_ascii_case(&balance_of_call_data(AUTHORIZED_WALLET_ADDRESS)) +} + +fn next_balance( + balances: &Arc>>, + last_value: &Arc>, +) -> String { + let next = { + let mut queue = balances.lock().unwrap(); + queue.pop_front() + }; + + match next { + Some(raw) => { + *last_value.lock().unwrap() = raw.clone(); + raw + } + None => last_value.lock().unwrap().clone(), + } +} + +fn encode_raw_balance(raw: &str) -> String { + let value = raw.parse::().unwrap(); + let bytes = alloy::primitives::U256::from(value).to_be_bytes::<32>(); + format!("0x{}", hex::encode(bytes)) +} + +fn normalized_hex(value: &str) -> String { + value.trim_start_matches("0x").to_ascii_lowercase() +} + +fn balance_of_call_data(account: &str) -> String { + format!("0x{BALANCE_OF_SELECTOR}{:0>64}", normalized_hex(account)) +} + +fn moderato_config_toml(rpc_url: &str) -> String { + format!("[rpc]\n\"tempo-moderato\" = \"{rpc_url}\"\n") +} + +fn build_login_temp(rpc_url: &str) -> tempfile::TempDir { + TestConfigBuilder::new() + .with_config_toml(moderato_config_toml(rpc_url)) + .build() +} + +fn build_fund_temp(rpc_url: &str) -> tempfile::TempDir { + TestConfigBuilder::new() + .with_config_toml(moderato_config_toml(rpc_url)) + .with_keys_toml(MODERATO_DIRECT_KEYS_TOML) + .build() +} + +#[test] +fn unrelated_eth_call_uses_default_rpc_response_and_does_not_advance_balance_sequence() { + let balances = Arc::new(Mutex::new(VecDeque::from([ + String::from("0"), + String::from("1000000"), + ]))); + let last_value = Arc::new(Mutex::new(String::from("0"))); + let request = json!({ + "jsonrpc": "2.0", + "id": 7, + "method": "eth_call", + "params": [ + { + "to": "0xe1c4d3dce17bc111181ddf716f75bae49e61a336", + "data": "0x12345678" + }, + "latest" + ] + }); + + let response = handle_rpc_request(&request, &balances, &last_value); + + assert_eq!(response, mock_rpc_response(&request, 42431)); + assert_eq!( + balances.lock().unwrap().iter().cloned().collect::>(), + vec![String::from("0"), String::from("1000000")] + ); + assert_eq!(last_value.lock().unwrap().as_str(), "0"); +} + +#[test] +fn matching_balance_request_advances_sequence_and_repeats_last_value() { + let balances = Arc::new(Mutex::new(VecDeque::from([ + String::from("0"), + String::from("1000000"), + ]))); + let last_value = Arc::new(Mutex::new(String::from("0"))); + let request = json!({ + "jsonrpc": "2.0", + "id": 8, + "method": "eth_call", + "params": [ + { + "to": MODERATO_TOKEN_ADDRESS, + "input": balance_of_call_data(AUTHORIZED_WALLET_ADDRESS) + }, + "latest" + ] + }); + + let first = handle_rpc_request(&request, &balances, &last_value); + let second = handle_rpc_request(&request, &balances, &last_value); + let third = handle_rpc_request(&request, &balances, &last_value); + + assert_eq!(first["result"], encode_raw_balance("0")); + assert_eq!(second["result"], encode_raw_balance("1000000")); + assert_eq!(third["result"], encode_raw_balance("1000000")); + assert!(balances.lock().unwrap().is_empty()); + assert_eq!(last_value.lock().unwrap().as_str(), "1000000"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn login_no_browser_prints_remote_safe_handoff_copy_and_completes() { + let login = MockLoginServer::start_authorized("ANMGE375").await; + let rpc = BalanceSequenceRpcServer::start(vec!["0"]).await; + let temp = build_login_temp(&rpc.base_url); + + let output = test_command(&temp) + .env("TEMPO_AUTH_URL", login.auth_url()) + .args(["-n", "tempo-moderato", "login", "--no-browser"]) + .output() + .unwrap(); + + assert!(output.status.success(), "login should succeed: {output:?}"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_remote_login_handoff(&stderr); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Wallet"), "{stdout}"); + assert_eq!(*login.poll_count.lock().unwrap(), 2); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn login_no_browser_json_keeps_structured_stdout_and_prints_remote_handoff() { + let login = MockLoginServer::start_authorized("ANMGE375").await; + let rpc = BalanceSequenceRpcServer::start(vec!["0"]).await; + let temp = build_login_temp(&rpc.base_url); + + let output = test_command(&temp) + .env("TEMPO_AUTH_URL", login.auth_url()) + .args(["-j", "-n", "tempo-moderato", "login", "--no-browser"]) + .output() + .unwrap(); + + assert!(output.status.success(), "login should succeed: {output:?}"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_remote_login_handoff(&stderr); + + let stdout: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(stdout["ready"], true, "{stdout}"); + assert_eq!(*login.poll_count.lock().unwrap(), 2); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn login_no_browser_toon_keeps_structured_stdout_and_prints_remote_handoff() { + let login = MockLoginServer::start_authorized("ANMGE375").await; + let rpc = BalanceSequenceRpcServer::start(vec!["0"]).await; + let temp = build_login_temp(&rpc.base_url); + + let output = test_command(&temp) + .env("TEMPO_AUTH_URL", login.auth_url()) + .args(["-t", "-n", "tempo-moderato", "login", "--no-browser"]) + .output() + .unwrap(); + + assert!(output.status.success(), "login should succeed: {output:?}"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_remote_login_handoff(&stderr); + + let stdout: serde_json::Value = + toon_format::decode_default(String::from_utf8_lossy(&output.stdout).trim()).unwrap(); + assert_eq!(stdout["ready"], true, "{stdout}"); + assert_eq!(*login.poll_count.lock().unwrap(), 2); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn login_default_flow_keeps_local_copy_and_does_not_print_remote_handoff_text() { + let login = MockLoginServer::start_authorized("ANMGE375").await; + let rpc = BalanceSequenceRpcServer::start(vec!["0"]).await; + let temp = build_login_temp(&rpc.base_url); + + let output = test_command(&temp) + .env("TEMPO_AUTH_URL", login.auth_url()) + .args(["-n", "tempo-moderato", "login"]) + .output() + .unwrap(); + + assert!(output.status.success(), "login should succeed: {output:?}"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("Auth URL:"), "{stderr}"); + assert!(stderr.contains("Verification code:"), "{stderr}"); + assert!( + !stderr.contains("Open this link on your device"), + "unexpected remote-safe handoff text: {stderr}" + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Wallet"), "{stdout}"); + assert_eq!(*login.poll_count.lock().unwrap(), 2); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fund_no_browser_prints_remote_safe_handoff_copy_and_detects_balance_change() { + let rpc = BalanceSequenceRpcServer::start(vec!["0", "1000000"]).await; + let temp = build_fund_temp(&rpc.base_url); + + let output = test_command(&temp) + .env( + "TEMPO_AUTH_URL", + "https://wallet.moderato.tempo.xyz/cli-auth", + ) + .args(["-n", "tempo-moderato", "fund", "--no-browser"]) + .output() + .unwrap(); + + assert!(output.status.success(), "fund should succeed: {output:?}"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_remote_fund_handoff(&stderr); + assert!(rpc.balances.lock().unwrap().is_empty()); + assert_eq!(rpc.last_value.lock().unwrap().as_str(), "1000000"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fund_no_browser_json_prints_remote_handoff() { + let rpc = BalanceSequenceRpcServer::start(vec!["0", "1000000"]).await; + let temp = build_fund_temp(&rpc.base_url); + + let output = test_command(&temp) + .env( + "TEMPO_AUTH_URL", + "https://wallet.moderato.tempo.xyz/cli-auth", + ) + .args(["-j", "-n", "tempo-moderato", "fund", "--no-browser"]) + .output() + .unwrap(); + + assert!(output.status.success(), "fund should succeed: {output:?}"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_remote_fund_handoff(&stderr); + assert!(rpc.balances.lock().unwrap().is_empty()); + assert_eq!(rpc.last_value.lock().unwrap().as_str(), "1000000"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fund_no_browser_toon_prints_remote_handoff() { + let rpc = BalanceSequenceRpcServer::start(vec!["0", "1000000"]).await; + let temp = build_fund_temp(&rpc.base_url); + + let output = test_command(&temp) + .env( + "TEMPO_AUTH_URL", + "https://wallet.moderato.tempo.xyz/cli-auth", + ) + .args(["-t", "-n", "tempo-moderato", "fund", "--no-browser"]) + .output() + .unwrap(); + + assert!(output.status.success(), "fund should succeed: {output:?}"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert_remote_fund_handoff(&stderr); + assert!(rpc.balances.lock().unwrap().is_empty()); + assert_eq!(rpc.last_value.lock().unwrap().as_str(), "1000000"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fund_default_flow_keeps_local_copy_and_does_not_print_remote_handoff_text() { + let rpc = BalanceSequenceRpcServer::start(vec!["0", "1000000"]).await; + let temp = build_fund_temp(&rpc.base_url); + + let output = test_command(&temp) + .env( + "TEMPO_AUTH_URL", + "https://wallet.moderato.tempo.xyz/cli-auth", + ) + .args(["-n", "tempo-moderato", "fund"]) + .output() + .unwrap(); + + assert!(output.status.success(), "fund should succeed: {output:?}"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("Fund URL:"), "{stderr}"); + assert!( + !stderr.contains("Open this link on your device"), + "unexpected remote-safe handoff text: {stderr}" + ); + assert!(rpc.balances.lock().unwrap().is_empty()); + assert_eq!(rpc.last_value.lock().unwrap().as_str(), "1000000"); +}