From bc0a10fb31bb4a0bfa76edd72c0fc3bc3ebe94f8 Mon Sep 17 00:00:00 2001 From: o-az Date: Thu, 16 Apr 2026 11:37:35 -0700 Subject: [PATCH] feat(wallet): new flags `--expiry` `--limit` `--token` --- crates/tempo-wallet/src/app.rs | 15 ++- crates/tempo-wallet/src/args.rs | 26 ++++- crates/tempo-wallet/src/commands/login.rs | 108 +++++++++++++++++--- crates/tempo-wallet/src/commands/refresh.rs | 9 +- crates/tempo-wallet/tests/remote_flows.rs | 46 ++++++++- 5 files changed, 180 insertions(+), 24 deletions(-) diff --git a/crates/tempo-wallet/src/app.rs b/crates/tempo-wallet/src/app.rs index 38fb104c..997869cc 100644 --- a/crates/tempo-wallet/src/app.rs +++ b/crates/tempo-wallet/src/app.rs @@ -25,8 +25,17 @@ 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 { no_browser } => login::run(&ctx, no_browser).await, - Commands::Refresh => refresh::run(&ctx).await, + Commands::Login { + no_browser, + limit, + expiry, + token, + } => login::run(&ctx, no_browser, limit, expiry, token).await, + Commands::Refresh { + limit, + expiry, + token, + } => refresh::run(&ctx, limit, expiry, token).await, Commands::Logout { yes } => logout::run(&ctx, yes), Commands::Completions { shell } => completions::run(&ctx, shell), Commands::Fund { @@ -67,7 +76,7 @@ pub(crate) async fn run(mut cli: Cli) -> Result<(), TempoError> { const fn command_name(command: &Commands) -> &'static str { match command { Commands::Login { .. } => "login", - Commands::Refresh => "refresh", + Commands::Refresh { .. } => "refresh", Commands::Logout { .. } => "logout", Commands::Completions { .. } => "completions", Commands::Fund { .. } => "fund", diff --git a/crates/tempo-wallet/src/args.rs b/crates/tempo-wallet/src/args.rs index e7438462..d0003d82 100644 --- a/crates/tempo-wallet/src/args.rs +++ b/crates/tempo-wallet/src/args.rs @@ -35,10 +35,34 @@ pub(crate) enum Commands { /// Do not attempt to open a browser #[arg(long)] no_browser: bool, + + /// Token spend limit in human units (default: 1000) + #[arg(long)] + limit: Option, + + /// Access-key expiry in seconds (default: 3600) + #[arg(long)] + expiry: Option, + + /// TIP-20 token address for the spend limit (defaults to the selected network token) + #[arg(long)] + token: Option, }, /// Refresh your access key without logging out #[command(display_order = 2)] - Refresh, + Refresh { + /// Token spend limit in human units (default: 1000) + #[arg(long)] + limit: Option, + + /// Access-key expiry in seconds (default: 3600) + #[arg(long)] + expiry: Option, + + /// TIP-20 token address for the spend limit (defaults to the selected network token) + #[arg(long)] + token: Option, + }, /// Log out and disconnect your wallet #[command(display_order = 3)] Logout { diff --git a/crates/tempo-wallet/src/commands/login.rs b/crates/tempo-wallet/src/commands/login.rs index a5540821..26409cde 100644 --- a/crates/tempo-wallet/src/commands/login.rs +++ b/crates/tempo-wallet/src/commands/login.rs @@ -25,16 +25,35 @@ use tempo_common::{ const CALLBACK_TIMEOUT_SECS: u64 = 900; // 15 minutes const POLL_INTERVAL_SECS: u64 = 2; - -pub(crate) async fn run(ctx: &Context, no_browser: bool) -> Result<(), TempoError> { - run_impl(ctx, false, no_browser).await +const DEFAULT_ACCESS_KEY_LIMIT: u64 = 1_000; + +pub(crate) async fn run( + ctx: &Context, + no_browser: bool, + limit: Option, + expiry: Option, + token: Option, +) -> Result<(), TempoError> { + run_impl(ctx, false, no_browser, limit, expiry, token).await } -pub(crate) async fn run_with_reauth(ctx: &Context) -> Result<(), TempoError> { - run_impl(ctx, true, false).await +pub(crate) async fn run_with_reauth( + ctx: &Context, + limit: Option, + expiry: Option, + token: Option, +) -> Result<(), TempoError> { + run_impl(ctx, true, false, limit, expiry, token).await } -async fn run_impl(ctx: &Context, force_reauth: bool, no_browser: bool) -> Result<(), TempoError> { +async fn run_impl( + ctx: &Context, + force_reauth: bool, + no_browser: bool, + limit: Option, + expiry: Option, + token: Option, +) -> Result<(), TempoError> { ctx.track_event(analytics::LOGIN_STARTED); let already_logged_in = ctx.keys.has_key_for_network(ctx.network); @@ -57,7 +76,7 @@ async fn run_impl(ctx: &Context, force_reauth: bool, no_browser: bool) -> Result } if !already_logged_in || needs_reauth { - let result = do_login(ctx, no_browser).await; + let result = do_login(ctx, no_browser, limit, expiry, token.as_deref()).await; if let Some(ref a) = ctx.analytics { track_login_result(a, &result); @@ -198,7 +217,13 @@ fn track_login_result(a: &tempo_common::analytics::Analytics, result: &Result<() } } -async fn do_login(ctx: &Context, no_browser: bool) -> Result<(), TempoError> { +async fn do_login( + ctx: &Context, + no_browser: bool, + limit: Option, + expiry: Option, + token: Option<&str>, +) -> Result<(), TempoError> { let auth_server_url = std::env::var("TEMPO_AUTH_URL").unwrap_or_else(|_| ctx.network.auth_url().to_string()); @@ -220,7 +245,19 @@ async fn do_login(ctx: &Context, no_browser: bool) -> Result<(), TempoError> { let client = reqwest::Client::builder() .build() .map_err(NetworkError::Reqwest)?; - let code = create_device_code(&client, &auth_base_url, &pub_key, &code_challenge).await?; + let code = create_device_code( + &client, + &auth_base_url, + CreateDeviceCodeRequest { + pub_key: &pub_key, + code_challenge: &code_challenge, + limit, + expiry, + token, + network: ctx.network, + }, + ) + .await?; let mut auth_url = parsed_url; auth_url.query_pairs_mut().append_pair("code", &code); @@ -433,20 +470,59 @@ struct PollResponse { error: Option, } +struct CreateDeviceCodeRequest<'a> { + pub_key: &'a str, + code_challenge: &'a str, + limit: Option, + expiry: Option, + token: Option<&'a str>, + network: NetworkId, +} + async fn create_device_code( client: &reqwest::Client, base_url: &str, - pub_key: &str, - code_challenge: &str, + request: CreateDeviceCodeRequest<'_>, ) -> Result { let url = format!("{base_url}/cli-auth/device-code"); + let mut body = serde_json::json!({ + "pub_key": request.pub_key, + "key_type": "secp256k1", + "code_challenge": request.code_challenge, + "chain_id": request.network.chain_id(), + }); + if let Some(expiry_secs) = request.expiry { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock before epoch") + .as_secs(); + body["expiry"] = serde_json::json!(now + expiry_secs); + } + if let Some(limit) = request + .limit + .or(request.token.map(|_| DEFAULT_ACCESS_KEY_LIMIT)) + { + let default_token = request.network.token(); + let token_address = match request.token { + Some(value) => value + .parse::
() + .map_err(|_| ConfigError::InvalidAddress { + context: "access-key token", + value: value.to_string(), + })?, + None => default_token.address, + }; + let decimals = default_token.decimals as u32; + let raw_amount = (limit as u128) * 10u128.pow(decimals); + let hex_limit = format!("0x{raw_amount:x}"); + body["limits"] = serde_json::json!([{ + "token": format!("{token_address:#x}"), + "limit": hex_limit, + }]); + } let resp = client .post(&url) - .json(&serde_json::json!({ - "pub_key": pub_key, - "key_type": "secp256k1", - "code_challenge": code_challenge, - })) + .json(&body) .send() .await .map_err(NetworkError::Reqwest)?; diff --git a/crates/tempo-wallet/src/commands/refresh.rs b/crates/tempo-wallet/src/commands/refresh.rs index 40e6782b..5c36ae0b 100644 --- a/crates/tempo-wallet/src/commands/refresh.rs +++ b/crates/tempo-wallet/src/commands/refresh.rs @@ -4,6 +4,11 @@ use tempo_common::{cli::context::Context, error::TempoError}; use crate::commands::login; -pub(crate) async fn run(ctx: &Context) -> Result<(), TempoError> { - login::run_with_reauth(ctx).await +pub(crate) async fn run( + ctx: &Context, + limit: Option, + expiry: Option, + token: Option, +) -> Result<(), TempoError> { + login::run_with_reauth(ctx, limit, expiry, token).await } diff --git a/crates/tempo-wallet/tests/remote_flows.rs b/crates/tempo-wallet/tests/remote_flows.rs index a88a5994..0bd89658 100644 --- a/crates/tempo-wallet/tests/remote_flows.rs +++ b/crates/tempo-wallet/tests/remote_flows.rs @@ -18,6 +18,7 @@ const BALANCE_OF_SELECTOR: &str = "70a08231"; struct MockLoginServer { base_url: String, poll_count: Arc>, + last_device_code_request: Arc>>, shutdown_tx: Option>, _handle: tokio::task::JoinHandle<()>, } @@ -32,13 +33,19 @@ impl MockLoginServer { let device_code = code.to_string(); let poll_code = code.to_string(); let poll_state = poll_count.clone(); + let last_device_code_request = Arc::new(Mutex::new(None)); + let captured_device_code_request = last_device_code_request.clone(); let app = Router::new() .route( "/cli-auth/device-code", - post(move || { + post(move |Json(body): Json| { let code = device_code.clone(); - async move { Json(json!({ "code": code })) } + let captured_device_code_request = captured_device_code_request.clone(); + async move { + *captured_device_code_request.lock().unwrap() = Some(body); + Json(json!({ "code": code })) + } }), ) .route( @@ -80,6 +87,7 @@ impl MockLoginServer { Self { base_url, poll_count, + last_device_code_request, shutdown_tx: Some(shutdown_tx), _handle: handle, } @@ -444,6 +452,40 @@ async fn login_default_flow_keeps_local_copy_and_does_not_print_remote_handoff_t assert_eq!(*login.poll_count.lock().unwrap(), 2); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn login_with_token_uses_requested_spend_limit_token() { + let login = MockLoginServer::start_authorized("ANMGE375").await; + let rpc = BalanceSequenceRpcServer::start(vec!["0"]).await; + let temp = build_login_temp(&rpc.base_url); + let custom_token = "0x1111111111111111111111111111111111111111"; + + let output = test_command(&temp) + .env("TEMPO_AUTH_URL", login.auth_url()) + .args([ + "-n", + "tempo-moderato", + "login", + "--no-browser", + "--limit", + "42", + "--token", + custom_token, + ]) + .output() + .unwrap(); + + assert!(output.status.success(), "login should succeed: {output:?}"); + + let request = login + .last_device_code_request + .lock() + .unwrap() + .clone() + .expect("device-code request should be captured"); + assert_eq!(request["limits"][0]["token"], custom_token); + assert_eq!(request["limits"][0]["limit"], "0x280de80"); +} + #[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;