diff --git a/Cargo.lock b/Cargo.lock index f28f1aeb..7d944f90 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1029,9 +1029,9 @@ checksum = "5827cebf4670468b8772dd191856768aedcb1b0278a04f989f7766351917b9dc" [[package]] name = "counter" -version = "0.5.6" +version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48388d8711a360319610960332b6a6f9fc2b5a63bba9fd10f1b7aa50677d956f" +checksum = "2d458e66999348f56fd3ffcfbb7f7951542075ca8359687c703de6500c1ddccd" dependencies = [ "num-traits", ] diff --git a/TODO.md b/TODO.md index 6e567169..ebe0ddf0 100644 --- a/TODO.md +++ b/TODO.md @@ -181,19 +181,22 @@ These are roughly in order of completition - [x] change stats to using the database - [x] emit user stat on retry - [ ] include if archive query or not in the stats -- [ ] ability to generate a key from a web endpoint - this is already partially done, but we need to double check it works. preferrably with tests -- [ ] ability to domain lock or ip lock said key +- [-] ability to domain lock or ip lock said key - the code to check the database and use these entries already exists, but users don't have a way to set them -- [ ] view stats about key - - [ ] display requests per second per api key (only with authentication!) - - [ ] display concurrent requests per api key (only with authentication!) - - [ ] display distribution of methods per api key (eth_call, eth_getLogs, etc.) (only with authentication!) - - [ ] display logged reverts on an endpoint that requires authentication -- [ ] sign in - - i think this is done, but double check -- [ ] sign out - - i think this is done, but double check +- [-] new endpoints for users (not totally sure about the exact paths, but these features are all needed): + - [ ] ability to generate a key from a web endpoint + - [x] sign in + - [x] sign out + - [-] GET profile endpoint + - [-] POST profile endpoint + - [-] GET stats endpoint + - [ ] display requests per second per api key (only with authentication!) + - [ ] display concurrent requests per api key (only with authentication!) + - [ ] display distribution of methods per api key (eth_call, eth_getLogs, etc.) (only with authentication!) + - [ ] POST key endpoint + - allow setting things such as private relay, revert logging, ip/origin/etc checks + - [ ] GET logged reverts on an endpoint that requires authentication. - [ ] endpoint for creating/modifying api keys and their advanced security features - [ ] WARN http_request:request: web3_proxy::block_number: could not get block from params err=unexpected params length id=01GF4HTRKM4JV6NX52XSF9AYMW method=POST authorized_request=User(Some(SqlxMySqlPoolConnection), AuthorizedKey { ip: 10.11.12.15, origin: None, user_key_id: 4, log_revert_chance: 0.0000 }) - ERROR http_request:request:try_send_all_upstream_servers: web3_proxy::rpcs::request: bad response! err=JsonRpcClientError(JsonRpcError(JsonRpcError { code: -32000, message: "INTERNAL_ERROR: existing tx with same hash", data: None })) method=eth_sendRawTransaction rpc=local_erigon_alpha_archive id=01GF4HV03Y4ZNKQV8DW5NDQ5CG method=POST authorized_request=User(Some(SqlxMySqlPoolConnection), AuthorizedKey { ip: 10.11.12.15, origin: None, user_key_id: 4, log_revert_chance: 0.0000 }) self=Web3Connections { conns: {"local_erigon_alpha_archive_ws": Web3Connection { name: "local_erigon_alpha_archive_ws", blocks: "all", .. }, "local_geth_ws": Web3Connection { name: "local_geth_ws", blocks: 64, .. }, "local_erigon_alpha_archive": Web3Connection { name: "local_erigon_alpha_archive", blocks: "all", .. }}, .. } authorized_request=Some(User(Some(SqlxMySqlPoolConnection), AuthorizedKey { ip: 10.11.12.15, origin: None, user_key_id: 4, log_revert_chance: 0.0000 })) request=JsonRpcRequest { id: RawValue(39), method: "eth_sendRawTransaction", .. } request_metadata=Some(RequestMetadata { datetime: 2022-10-11T22:14:57.406829095Z, period_seconds: 60, request_bytes: 633, backend_requests: 0, no_servers: 0, error_response: false, response_bytes: 0, response_millis: 0 }) block_needed=None @@ -231,6 +234,8 @@ These are roughly in order of completition These are not yet ordered. +- [ ] GET balance endpoint +- [ ] POST balance endpoint - [ ] eth_1 | 2022-10-11T22:14:57.408114Z ERROR http_request:request:try_send_all_upstream_servers: web3_proxy::rpcs::request: bad response! err=JsonRpcClientError(JsonRpcError(JsonRpcError { code: -32000, message: "INTERNAL_ERROR: existing tx with same hash", data: None })) method=eth_sendRawTransaction rpc=local_erigon_alpha_archive id=01GF4HV03Y4ZNKQV8DW5NDQ5CG method=POST authorized_request=User(Some(SqlxMySqlPoolConnection), AuthorizedKey { ip: 10.11.12.15, origin: None, user_key_id: 4, log_revert_chance: 0.0000 }) self=Web3Connections { conns: {"local_erigon_alpha_archive_ws": Web3Connection { name: "local_erigon_alpha_archive_ws", blocks: "all", .. }, "local_geth_ws": Web3Connection { name: "local_geth_ws", blocks: 64, .. }, "local_erigon_alpha_archive": Web3Connection { name: "local_erigon_alpha_archive", blocks: "all", .. }}, .. } authorized_request=Some(User(Some(SqlxMySqlPoolConnection), AuthorizedKey { ip: 10.11.12.15, origin: None, user_key_id: 4, log_revert_chance: 0.0000 })) request=JsonRpcRequest { id: RawValue(39), method: "eth_sendRawTransaction", .. } request_metadata=Some(RequestMetadata { datetime: 2022-10-11T22:14:57.406829095Z, period_seconds: 60, request_bytes: 633, backend_requests: 0, no_servers: 0, error_response: false, response_bytes: 0, response_millis: 0 }) block_needed=None - eth_sendRawTransaction should accept "INTERNAL_ERROR: existing tx with same hash" as a successful response. we just want to be sure that the server has our tx and in this case, it does. - [ ] EIP1271 for siwe @@ -277,29 +282,6 @@ These are not yet ordered. - [ ] if we subscribe to a server that is syncing, it gives us null block_data_limit. when it catches up, we don't ever send queries to it. we need to recheck block_data_limit - [ ] add a "failover" tier that is only used if balanced_rpcs has "no servers synced" - use this tier (and private tier) to check timestamp on latest block. if we are behind that by more than a few seconds, something is wrong - -new endpoints for users (not totally sure about the exact paths, but these features are all needed): -- [x] GET /u/:api_key - - proxies to web3 websocket -- [x] POST /u/:api_key - - proxies to web3 -- [ ] GET /user/login/$address - - returns a JSON string for the user to sign -- [ ] POST /user/login/$address - - returns a JSON string including the api key - - sets session cookie -- [ ] GET /user/$address - - checks for api key in session cookie or header - - returns a JSON string including user stats - - balance in USD - - deposits history (currency, amounts, transaction id) - - number of requests used (so we can calculate average spending over a month, burn rate for a user etc, something like "Your balance will be depleted in xx days) - - the email address of a user if he opted in to get contacted via email - - all the success/retry/fail counts and latencies (but that may better come from somewhere else) -- [ ] POST /user/$address - - opt-in link email address - - checks for api key in session cookie or header - - allows modifying user settings - [ ] sometimes when fetching a txid through the proxy it fails, but fetching from the backends works fine - check flashprofits logs for examples - [ ] relevant erigon changelogs: add pendingTransactionWithBody subscription method (#5675) diff --git a/web3_proxy/Cargo.toml b/web3_proxy/Cargo.toml index 577e779e..99cb4cbd 100644 --- a/web3_proxy/Cargo.toml +++ b/web3_proxy/Cargo.toml @@ -27,7 +27,7 @@ axum-client-ip = "0.2.0" axum-macros = "0.2.3" # TODO: import chrono from sea-orm so we always have the same version chrono = "0.4.22" -counter = "0.5.6" +counter = "0.5.7" derive_more = "0.99.17" dotenv = "0.15.0" ethers = { version = "0.17.0", features = ["rustls", "ws"] } diff --git a/web3_proxy/src/app.rs b/web3_proxy/src/app.rs index e27828f2..e6760cd8 100644 --- a/web3_proxy/src/app.rs +++ b/web3_proxy/src/app.rs @@ -178,10 +178,12 @@ impl Web3ProxyApp { Pin>>>, )> { // safety checks on the config - assert!( - top_config.app.redirect_user_url.contains("{{user_id}}"), - "redirect user url must contain \"{{user_id}}\"" - ); + if let Some(redirect) = &top_config.app.redirect_user_url { + assert!( + redirect.contains("{{user_id}}"), + "redirect_user_url user url must contain \"{{user_id}}\"" + ); + } // setup metrics let app_metrics = Default::default(); diff --git a/web3_proxy/src/bin/web3_proxy.rs b/web3_proxy/src/bin/web3_proxy.rs index 28ca7377..4c8fe4cd 100644 --- a/web3_proxy/src/bin/web3_proxy.rs +++ b/web3_proxy/src/bin/web3_proxy.rs @@ -78,7 +78,7 @@ fn run( let prometheus_handle = tokio::spawn(metrics_frontend::serve(app, app_prometheus_port)); // if everything is working, these should both run forever - // TODO: join these instead and use shutdown handler properly + // TODO: join these instead and use shutdown handler properly. probably use tokio's ctrl+c helper tokio::select! { x = app_handle => { match x { @@ -220,8 +220,8 @@ mod tests { min_synced_rpcs: 1, frontend_rate_limit_per_minute: 1_000_000, response_cache_max_bytes: 10_usize.pow(7), - redirect_public_url: "example.com/".to_string(), - redirect_user_url: "example.com/{{user_id}}".to_string(), + redirect_public_url: Some("example.com/".to_string()), + redirect_user_url: Some("example.com/{{user_id}}".to_string()), ..Default::default() }, balanced_rpcs: HashMap::from([ diff --git a/web3_proxy/src/config.rs b/web3_proxy/src/config.rs index a2ac0e70..dc8995a9 100644 --- a/web3_proxy/src/config.rs +++ b/web3_proxy/src/config.rs @@ -91,9 +91,9 @@ pub struct AppConfig { #[serde(default = "default_response_cache_max_bytes")] pub response_cache_max_bytes: usize, /// the stats page url for an anonymous user. - pub redirect_public_url: String, - /// the stats page url for a logged in user. it must contain "{user_id}" - pub redirect_user_url: String, + pub redirect_public_url: Option, + /// the stats page url for a logged in user. if set, must contain "{user_id}" + pub redirect_user_url: Option, } /// This might cause a thundering herd! diff --git a/web3_proxy/src/frontend/authorization.rs b/web3_proxy/src/frontend/authorization.rs index 2093beee..be0d38b0 100644 --- a/web3_proxy/src/frontend/authorization.rs +++ b/web3_proxy/src/frontend/authorization.rs @@ -1,3 +1,5 @@ +//! Utilities for authorization of logged in and anonymous users. + use super::errors::FrontendErrorResponse; use crate::app::{UserKeyData, Web3ProxyApp}; use crate::jsonrpc::JsonRpcRequest; @@ -21,6 +23,7 @@ use ulid::Ulid; use uuid::Uuid; /// This lets us use UUID and ULID while we transition to only ULIDs +/// TODO: include the key's description. #[derive(Copy, Clone, Debug, Eq, PartialEq, serde::Serialize)] pub enum UserKey { Ulid(Ulid), diff --git a/web3_proxy/src/frontend/errors.rs b/web3_proxy/src/frontend/errors.rs index 2ac5e832..94023e4f 100644 --- a/web3_proxy/src/frontend/errors.rs +++ b/web3_proxy/src/frontend/errors.rs @@ -1,3 +1,5 @@ +//! Utlities for logging errors for admins and displaying errors to users. + use crate::{app::UserKeyData, jsonrpc::JsonRpcForwardedResponse}; use axum::{ http::StatusCode, diff --git a/web3_proxy/src/frontend/mod.rs b/web3_proxy/src/frontend/mod.rs index b4748bee..17c75a44 100644 --- a/web3_proxy/src/frontend/mod.rs +++ b/web3_proxy/src/frontend/mod.rs @@ -1,9 +1,12 @@ +//! `frontend` contains HTTP and websocket endpoints for use by users and admins. + pub mod authorization; mod errors; -mod rpc_proxy_http; -mod rpc_proxy_ws; -mod status; -mod users; +// TODO: these are only public so docs are generated. What's a better way to do this? +pub mod rpc_proxy_http; +pub mod rpc_proxy_ws; +pub mod status; +pub mod users; use crate::app::Web3ProxyApp; use axum::{ @@ -23,7 +26,7 @@ use tower_http::trace::TraceLayer; use tower_request_id::{RequestId, RequestIdLayer}; use tracing::{error_span, info}; -/// http and websocket frontend for customers +/// Start the frontend server. pub async fn serve(port: u16, proxy_app: Arc) -> anyhow::Result<()> { // create a tracing span for each request with a random request id and the method // GET: websocket or static pages @@ -48,7 +51,7 @@ pub async fn serve(port: u16, proxy_app: Arc) -> anyhow::Result<() // build our axum Router let app = Router::new() - // routes should be order most to least common + // routes should be ordered most to least common .route("/rpc", post(rpc_proxy_http::proxy_web3_rpc)) .route("/rpc", get(rpc_proxy_ws::websocket_handler)) .route( @@ -60,17 +63,21 @@ pub async fn serve(port: u16, proxy_app: Arc) -> anyhow::Result<() get(rpc_proxy_ws::websocket_handler_with_key), ) .route("/health", get(status::health)) + .route("/user/login/:user_address", get(users::user_login_get)) + .route( + "/user/login/:user_address/:message_eip", + get(users::user_login_get), + ) + .route("/user/login", post(users::user_login_post)) + .route("/user/balance", get(users::user_balance_get)) + .route("/user/balance/:txid", post(users::user_balance_post)) + .route("/user/profile", get(users::user_profile_get)) + .route("/user/profile", post(users::user_profile_post)) + .route("/user/stats", get(users::user_stats_get)) + .route("/user/logout", post(users::user_logout_post)) .route("/status", get(status::status)) // TODO: make this optional or remove it since it is available on another port .route("/prometheus", get(status::prometheus)) - .route("/user/login/:user_address", get(users::get_login)) - .route( - "/user/login/:user_address/:message_eip", - get(users::get_login), - ) - .route("/user/login", post(users::post_login)) - .route("/user", post(users::post_user)) - .route("/user/logout", get(users::get_logout)) // layers are ordered bottom up // the last layer is first for requests and last for responses // Mark the `Authorization` request header as sensitive so it doesn't show in logs @@ -112,8 +119,9 @@ pub async fn serve(port: u16, proxy_app: Arc) -> anyhow::Result<() } /// Tokio signal handler that will wait for a user to press CTRL+C. -/// We use this in our hyper `Server` method `with_graceful_shutdown`. +/// Used in our hyper `Server` method `with_graceful_shutdown`. async fn signal_shutdown() { + // TODO: take a shutdown_receiver and select on ctrl_c and it info!("ctrl-c to quit"); tokio::signal::ctrl_c() .await diff --git a/web3_proxy/src/frontend/rpc_proxy_http.rs b/web3_proxy/src/frontend/rpc_proxy_http.rs index 10d0331b..e8b322ba 100644 --- a/web3_proxy/src/frontend/rpc_proxy_http.rs +++ b/web3_proxy/src/frontend/rpc_proxy_http.rs @@ -1,3 +1,5 @@ +//! Take a user's HTTP JSON-RPC requests and either respond from local data or proxy the request to a backend rpc server. + use super::authorization::{bearer_is_authorized, ip_is_authorized, key_is_authorized}; use super::errors::FrontendResult; use crate::{app::Web3ProxyApp, jsonrpc::JsonRpcRequestEnum}; @@ -10,6 +12,9 @@ use axum_client_ip::ClientIp; use std::sync::Arc; use tracing::{error_span, Instrument}; +/// POST /rpc -- Public entrypoint for HTTP JSON-RPC requests. Web3 wallets use this. +/// Defaults to rate limiting by IP address, but can also read the Authorization header for a bearer token. +/// If possible, please use a WebSocket instead. pub async fn proxy_web3_rpc( Extension(app): Extension>, bearer: Option>>, @@ -40,6 +45,7 @@ pub async fn proxy_web3_rpc( let authorized_request = Arc::new(authorized_request); + // TODO: spawn earlier? let f = tokio::spawn(async move { app.proxy_web3_rpc(authorized_request, payload) .instrument(request_span) @@ -51,6 +57,10 @@ pub async fn proxy_web3_rpc( Ok(Json(&response).into_response()) } +/// Authenticated entrypoint for HTTP JSON-RPC requests. Web3 wallets use this. +/// Rate limit and billing based on the api key in the url. +/// Can optionally authorized based on origin, referer, or user agent. +/// If possible, please use a WebSocket instead. pub async fn proxy_web3_rpc_with_key( Extension(app): Extension>, ClientIp(ip): ClientIp, @@ -64,7 +74,6 @@ pub async fn proxy_web3_rpc_with_key( let request_span = error_span!("request", %ip, ?referer, ?user_agent); - // TODO: this should probably return the user_key_id instead? or maybe both? let (authorized_request, _semaphore) = key_is_authorized( &app, user_key, @@ -80,6 +89,8 @@ pub async fn proxy_web3_rpc_with_key( let authorized_request = Arc::new(authorized_request); + // the request can take a while, so we spawn so that we can start serving another request + // TODO: spawn even earlier? let f = tokio::spawn(async move { app.proxy_web3_rpc(authorized_request, payload) .instrument(request_span) diff --git a/web3_proxy/src/frontend/rpc_proxy_ws.rs b/web3_proxy/src/frontend/rpc_proxy_ws.rs index 7dcea27a..024b18b1 100644 --- a/web3_proxy/src/frontend/rpc_proxy_ws.rs +++ b/web3_proxy/src/frontend/rpc_proxy_ws.rs @@ -1,3 +1,7 @@ +//! Take a user's WebSocket JSON-RPC requests and either respond from local data or proxy the request to a backend rpc server. +//! +//! WebSockets are the preferred method of receiving requests, but not all clients have good support. + use super::authorization::{ bearer_is_authorized, ip_is_authorized, key_is_authorized, AuthorizedRequest, }; @@ -28,6 +32,8 @@ use crate::{ jsonrpc::{JsonRpcForwardedResponse, JsonRpcForwardedResponseEnum, JsonRpcRequest}, }; +/// Public entrypoint for WebSocket JSON-RPC requests. +/// Defaults to rate limiting by IP address, but can also read the Authorization header for a bearer token. #[debug_handler] pub async fn websocket_handler( bearer: Option>>, @@ -66,12 +72,23 @@ pub async fn websocket_handler( }) .into_response()), None => { - // this is not a websocket. redirect to a friendly page - Ok(Redirect::to(&app.config.redirect_public_url).into_response()) + if let Some(redirect) = &app.config.redirect_public_url { + // this is not a websocket. redirect to a friendly page + Ok(Redirect::to(redirect).into_response()) + } else { + // TODO: do not use an anyhow error. send the user a 400 + Err( + anyhow::anyhow!("redirect_public_url not set. only websockets work here") + .into(), + ) + } } } } +/// Authenticated entrypoint for WebSocket JSON-RPC requests. Web3 wallets use this. +/// Rate limit and billing based on the api key in the url. +/// Can optionally authorized based on origin, referer, or user agent. #[debug_handler] pub async fn websocket_handler_with_key( Extension(app): Extension>, @@ -107,20 +124,25 @@ pub async fn websocket_handler_with_key( proxy_web3_socket(app, authorized_request, socket).instrument(request_span) })), None => { - // TODO: store this on the app and use register_template? - let reg = Handlebars::new(); + if let Some(redirect) = &app.config.redirect_user_url { + // TODO: store this on the app and use register_template? + let reg = Handlebars::new(); - // TODO: show the user's address, not their id (remember to update the checks for {{user_id}}} in app.rs) - // TODO: query to get the user's address. expose that instead of user_id - let user_url = reg - .render_template( - &app.config.redirect_user_url, - &json!({ "authorized_request": authorized_request }), - ) - .expect("templating should always work"); + // TODO: show the user's address, not their id (remember to update the checks for {{user_id}}} in app.rs) + // TODO: query to get the user's address. expose that instead of user_id + let user_url = reg + .render_template( + redirect, + &json!({ "authorized_request": authorized_request }), + ) + .expect("templating should always work"); - // this is not a websocket. redirect to a page for this user - Ok(Redirect::to(&user_url).into_response()) + // this is not a websocket. redirect to a page for this user + Ok(Redirect::to(&user_url).into_response()) + } else { + // TODO: do not use an anyhow error. send the user a 400 + Err(anyhow::anyhow!("redirect_user_url not set. only websockets work here").into()) + } } } } diff --git a/web3_proxy/src/frontend/status.rs b/web3_proxy/src/frontend/status.rs index dc3f1c09..92814a84 100644 --- a/web3_proxy/src/frontend/status.rs +++ b/web3_proxy/src/frontend/status.rs @@ -1,10 +1,15 @@ +//! Used by admins for health checks and inspecting global statistics. +//! +//! For ease of development, users can currently access these endponts. +//! They will eventually move to another port. + use crate::app::Web3ProxyApp; use axum::{http::StatusCode, response::IntoResponse, Extension, Json}; use moka::future::ConcurrentCacheExt; use serde_json::json; use std::sync::Arc; -/// Health check page for load balancers to use +/// Health check page for load balancers to use. pub async fn health(Extension(app): Extension>) -> impl IntoResponse { // TODO: also check that the head block is not too old if app.balanced_rpcs.synced() { @@ -14,13 +19,15 @@ pub async fn health(Extension(app): Extension>) -> impl IntoRe } } -/// Prometheus metrics +/// Prometheus metrics. +/// /// TODO: when done debugging, remove this and only allow access on a different port pub async fn prometheus(Extension(app): Extension>) -> impl IntoResponse { app.prometheus_metrics() } -/// Very basic status page +/// Very basic status page. +/// /// TODO: replace this with proper stats and monitoring pub async fn status(Extension(app): Extension>) -> impl IntoResponse { app.pending_transactions.sync(); diff --git a/web3_proxy/src/frontend/users.rs b/web3_proxy/src/frontend/users.rs index f58269e3..1b29fff0 100644 --- a/web3_proxy/src/frontend/users.rs +++ b/web3_proxy/src/frontend/users.rs @@ -1,11 +1,4 @@ -// So the API needs to show for any given user: -// - show balance in USD -// - show deposits history (currency, amounts, transaction id) -// - show number of requests used (so we can calculate average spending over a month, burn rate for a user etc, something like "Your balance will be depleted in xx days) -// - the email address of a user if he opted in to get contacted via email -// - all the monitoring and stats but that will come from someplace else if I understand corectly? -// I wonder how we handle payment -// probably have to do manual withdrawals +//! Handle registration, logins, and managing account data. use super::authorization::{login_is_authorized, UserKey}; use super::errors::FrontendResult; @@ -32,9 +25,20 @@ use std::sync::Arc; use time::{Duration, OffsetDateTime}; use ulid::Ulid; -// TODO: how do we customize axum's error response? I think we probably want an enum that implements IntoResponse instead +/// `GET /user/login/:user_address` or `GET /user/login/:user_address/:message_eip` -- Start the "Sign In with Ethereum" (siwe) login flow. +/// +/// `message_eip`s accepted: +/// - eip191_bytes +/// - eip191_hash +/// - eip4361 (default) +/// +/// Coming soon: eip1271 +/// +/// This is the initial entrypoint for logging in. Take the response from this endpoint and give it to your user's wallet for singing. POST the response to `/user/login`. +/// +/// Rate limited by IP address. #[debug_handler] -pub async fn get_login( +pub async fn user_login_get( Extension(app): Extension>, ClientIp(ip): ClientIp, // TODO: what does axum's error handling look like if the path fails to parse? @@ -69,6 +73,7 @@ pub async fn get_login( // TODO: get most of these from the app config let message = Message { + // TODO: should domain be llamanodes, or llamarpc? domain: "staging.llamanodes.com".parse().unwrap(), address: user_address.to_fixed_bytes(), statement: Some("🦙🦙🦙🦙🦙".to_string()), @@ -95,6 +100,7 @@ pub async fn get_login( .await?; // there are multiple ways to sign messages and not all wallets support them + // TODO: default message eip from config? let message_eip = params .remove("message_eip") .unwrap_or_else(|| "eip4361".to_string()); @@ -112,35 +118,41 @@ pub async fn get_login( Ok(message.into_response()) } -/// Query params to our `post_login` handler. +/// Query params for our `post_login` handler. #[derive(Debug, Deserialize)] pub struct PostLoginQuery { - invite_code: Option, + /// While we are in alpha/beta, we require users to supply an invite code. + /// The invite code (if any) is set in the application's config. + /// This may eventually provide some sort of referral bonus. + pub invite_code: Option, } /// JSON body to our `post_login` handler. -/// TODO: this should be an enum with the different login methods having different structs +/// Currently only siwe logins that send an address, msg, and sig are allowed. #[derive(Deserialize)] pub struct PostLogin { - address: Address, - msg: String, - sig: Bytes, + pub address: Address, + pub msg: String, + pub sig: Bytes, // TODO: do we care about these? we should probably check the version is something we expect // version: String, // signer: String, } -/// TODO: what information should we return? +/// Successful logins receive a bearer_token and all of the user's api keys. #[derive(Serialize)] pub struct PostLoginResponse { + /// Used for authenticating additonal requests. bearer_token: Ulid, + /// Used for authenticating with the RPC endpoints. api_keys: Vec, } -/// Post to the user endpoint to register or login. -/// It is recommended to save the returned bearer this in a cookie and send bac +/// `POST /user/login` - Register or login by posting a signed "siwe" message. +/// It is recommended to save the returned bearer token in a cookie. +/// The bearer token can be used to authenticate other requests, such as getting the user's stats or modifying the user's profile. #[debug_handler] -pub async fn post_login( +pub async fn user_login_post( Extension(app): Extension>, ClientIp(ip): ClientIp, Json(payload): Json, @@ -266,9 +278,9 @@ pub async fn post_login( Ok(response) } -/// Log out the user connected to the given Authentication header. +/// `POST /user/logout` - Forget the bearer token in the `Authentication` header. #[debug_handler] -pub async fn get_logout( +pub async fn user_logout_post( Extension(app): Extension>, TypedHeader(Authorization(bearer)): TypedHeader>, ) -> FrontendResult { @@ -293,9 +305,9 @@ pub struct PostUser { // TODO: make them sign this JSON? cookie in session id is hard because its on a different domain } +/// `POST /user/profile` -- modify the account connected to the bearer token in the `Authentication` header. #[debug_handler] -/// post to the user endpoint to modify your existing account -pub async fn post_user( +pub async fn user_profile_post( TypedHeader(Authorization(bearer_token)): TypedHeader>, ClientIp(ip): ClientIp, Extension(app): Extension>, @@ -330,17 +342,101 @@ pub async fn post_user( todo!("finish post_user"); } +/// `GET /user/balance` -- Use a bearer token to get the user's balance and spend. +/// +/// - show balance in USD +/// - show deposits history (currency, amounts, transaction id) +/// +/// TODO: one key per request? maybe /user/balance/:api_key? +/// TODO: this will change as we add better support for secondary users. +#[debug_handler] +pub async fn user_balance_get( + TypedHeader(Authorization(bearer_token)): TypedHeader>, + Extension(app): Extension>, +) -> FrontendResult { + todo!("user_balance_get"); +} + +/// `POST /user/balance` -- Manually process a confirmed txid to update a user's balance. +/// +/// We will subscribe to events to watch for any user deposits, but sometimes events can be missed. +/// +/// TODO: rate limit by user +/// TODO: one key per request? maybe /user/balance/:api_key? +/// TODO: this will change as we add better support for secondary users. +#[debug_handler] +pub async fn user_balance_post( + TypedHeader(Authorization(bearer_token)): TypedHeader>, + Extension(app): Extension>, +) -> FrontendResult { + todo!("user_balance_post"); +} + +/// `GET /user/keys` -- Use a bearer token to get the user's api keys and their settings. +/// +/// TODO: one key per request? maybe /user/keys/:api_key? +#[debug_handler] +pub async fn user_keys_get( + TypedHeader(Authorization(bearer_token)): TypedHeader>, + Extension(app): Extension>, +) -> FrontendResult { + todo!("user_keys_get"); +} + +/// `POST /user/keys` -- Use a bearer token to create a new key or modify an existing key. +/// +/// TODO: read json from the request body +/// TODO: one key per request? maybe /user/keys/:api_key? +#[debug_handler] +pub async fn user_keys_post( + TypedHeader(Authorization(bearer_token)): TypedHeader>, + Extension(app): Extension>, +) -> FrontendResult { + todo!("user_keys_post"); +} + +/// `GET /user/profile` -- Use a bearer token to get the user's profile. +/// +/// - the email address of a user if they opted in to get contacted via email +/// +/// TODO: this will change as we add better support for secondary users. +#[debug_handler] +pub async fn user_profile_get( + TypedHeader(Authorization(bearer_token)): TypedHeader>, + Extension(app): Extension>, +) -> FrontendResult { + todo!("get_user_profile"); +} + +/// `GET /user/stats` -- Use a bearer token to get the user's key stats such as bandwidth used and methods requested. +/// +/// - show number of requests used (so we can calculate average spending over a month, burn rate for a user etc, something like "Your balance will be depleted in xx days) +/// +/// TODO: one key per request? maybe /user/stats/:api_key? +/// TODO: this will change as we add better support for secondary users. +#[debug_handler] +pub async fn user_stats_get( + TypedHeader(Authorization(bearer_token)): TypedHeader>, + Extension(app): Extension>, +) -> FrontendResult { + todo!("get_user_stats"); +} + +/// `GET /user/profile` -- Use a bearer token to get the user's profile such as their optional email address. +/// Handle authorization for a given address and bearer token. // TODO: what roles should exist? enum ProtectedAction { PostUser, } impl ProtectedAction { + /// Verify that the given bearer token and address are allowed to take the specified action. async fn verify( self, app: &Web3ProxyApp, // TODO: i don't think we want Bearer here. we want user_key and a helper for bearer -> user_key bearer: Bearer, + // TODO: what about secondary addresses? maybe an enum for primary or secondary? primary_address: &Address, ) -> anyhow::Result { // get the attached address from redis for the given auth_token.