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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions crates/tempo-wallet/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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",
Expand Down
26 changes: 25 additions & 1 deletion crates/tempo-wallet/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u64>,

/// Access-key expiry in seconds (default: 3600)
#[arg(long)]
expiry: Option<u64>,

/// TIP-20 token address for the spend limit (defaults to the selected network token)
#[arg(long)]
token: Option<String>,
},
/// 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<u64>,

/// Access-key expiry in seconds (default: 3600)
#[arg(long)]
expiry: Option<u64>,

/// TIP-20 token address for the spend limit (defaults to the selected network token)
#[arg(long)]
token: Option<String>,
},
/// Log out and disconnect your wallet
#[command(display_order = 3)]
Logout {
Expand Down
108 changes: 92 additions & 16 deletions crates/tempo-wallet/src/commands/login.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u64>,
expiry: Option<u64>,
token: Option<String>,
) -> 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<u64>,
expiry: Option<u64>,
token: Option<String>,
) -> 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<u64>,
expiry: Option<u64>,
token: Option<String>,
) -> Result<(), TempoError> {
ctx.track_event(analytics::LOGIN_STARTED);

let already_logged_in = ctx.keys.has_key_for_network(ctx.network);
Expand All @@ -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);
Expand Down Expand Up @@ -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<u64>,
expiry: Option<u64>,
token: Option<&str>,
) -> Result<(), TempoError> {
let auth_server_url =
std::env::var("TEMPO_AUTH_URL").unwrap_or_else(|_| ctx.network.auth_url().to_string());

Expand All @@ -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);
Expand Down Expand Up @@ -433,20 +470,59 @@ struct PollResponse {
error: Option<String>,
}

struct CreateDeviceCodeRequest<'a> {
pub_key: &'a str,
code_challenge: &'a str,
limit: Option<u64>,
expiry: Option<u64>,
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<String, TempoError> {
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::<Address>()
.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)?;
Expand Down
9 changes: 7 additions & 2 deletions crates/tempo-wallet/src/commands/refresh.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u64>,
expiry: Option<u64>,
token: Option<String>,
) -> Result<(), TempoError> {
login::run_with_reauth(ctx, limit, expiry, token).await
}
46 changes: 44 additions & 2 deletions crates/tempo-wallet/tests/remote_flows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const BALANCE_OF_SELECTOR: &str = "70a08231";
struct MockLoginServer {
base_url: String,
poll_count: Arc<Mutex<u32>>,
last_device_code_request: Arc<Mutex<Option<serde_json::Value>>>,
shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
_handle: tokio::task::JoinHandle<()>,
}
Expand All @@ -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<serde_json::Value>| {
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(
Expand Down Expand Up @@ -80,6 +87,7 @@ impl MockLoginServer {
Self {
base_url,
poll_count,
last_device_code_request,
shutdown_tx: Some(shutdown_tx),
_handle: handle,
}
Expand Down Expand Up @@ -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;
Expand Down
Loading