From 83e5bcbc2c0b2ef43421fb2127529781b22b9d83 Mon Sep 17 00:00:00 2001 From: Andrew Kirillov <20803092+akirillo@users.noreply.github.com> Date: Mon, 3 Feb 2025 13:04:55 -0800 Subject: [PATCH] auth-server: handle_external_match: mutate calldata for gas sponsorship (#96) --- .gitignore | 2 + Cargo.lock | 5 + auth/auth-server-api/src/lib.rs | 9 + auth/auth-server/Cargo.toml | 2 + auth/auth-server/src/error.rs | 18 ++ auth/auth-server/src/main.rs | 19 +- .../src/server/handle_external_match.rs | 170 +++++++++++++++++- auth/auth-server/src/server/helpers.rs | 53 +++++- auth/auth-server/src/server/mod.rs | 18 +- auth/auth-server/src/telemetry/helpers.rs | 58 ++++-- auth/auth-server/src/telemetry/sources/mod.rs | 5 +- 11 files changed, 335 insertions(+), 24 deletions(-) diff --git a/.gitignore b/.gitignore index 46eae21..fc1d068 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,5 @@ target/ .vscode/ /.gitattributes + +.env diff --git a/Cargo.lock b/Cargo.lock index 619e5cc..6c5f235 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -725,6 +725,7 @@ name = "auth-server" version = "0.1.0" dependencies = [ "aes-gcm", + "alloy-primitives", "alloy-sol-types", "arbitrum-client", "auth-server-api", @@ -755,6 +756,7 @@ dependencies = [ "reqwest 0.11.27", "serde", "serde_json", + "serde_urlencoded", "thiserror 1.0.69", "tokio", "tokio-postgres", @@ -3704,6 +3706,9 @@ name = "hex" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +dependencies = [ + "serde", +] [[package]] name = "hex-literal" diff --git a/auth/auth-server-api/src/lib.rs b/auth/auth-server-api/src/lib.rs index fcffe20..d002856 100644 --- a/auth/auth-server-api/src/lib.rs +++ b/auth/auth-server-api/src/lib.rs @@ -37,3 +37,12 @@ pub struct CreateApiKeyRequest { /// A description of the API key's purpose pub description: String, } + +/// The query parameters accepted by the external quote assembly endpoint +#[derive(Debug, Serialize, Deserialize)] +pub struct ExternalQuoteAssemblyQueryParams { + /// Whether to use gas sponsorship for the assembled quote + pub use_gas_sponsorship: Option, + /// The address to refund gas to + pub refund_address: Option, +} diff --git a/auth/auth-server/Cargo.toml b/auth/auth-server/Cargo.toml index 5129912..beea862 100644 --- a/auth/auth-server/Cargo.toml +++ b/auth/auth-server/Cargo.toml @@ -24,6 +24,7 @@ native-tls = "0.2" # === Cryptography === # aes-gcm = "0.10.1" alloy-sol-types = "=0.7.7" +alloy-primitives = { version = "=0.7.7", features = ["serde", "k256"] } ethers = "2" rand = "0.8.5" @@ -48,6 +49,7 @@ futures-util = "0.3" metrics = "=0.22.3" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +serde_urlencoded = "0.7" thiserror = "1.0" tracing = "0.1" uuid = { version = "1.0", features = ["serde", "v4"] } diff --git a/auth/auth-server/src/error.rs b/auth/auth-server/src/error.rs index 8cb709e..3003bcb 100644 --- a/auth/auth-server/src/error.rs +++ b/auth/auth-server/src/error.rs @@ -31,6 +31,12 @@ pub enum AuthServerError { /// An error executing an HTTP request #[error("Http: {0}")] Http(String), + /// An error mutating calldata for gas sponsorship + #[error("Calldata mutation error: {0}")] + CalldataMutation(String), + /// An error signing a message + #[error("Signing error: {0}")] + Signing(String), /// A miscellaneous error #[error("Error: {0}")] Custom(String), @@ -72,6 +78,18 @@ impl AuthServerError { pub fn unauthorized(msg: T) -> Self { Self::Unauthorized(msg.to_string()) } + + /// Create a new calldata mutation error + #[allow(clippy::needless_pass_by_value)] + pub fn calldata_mutation(msg: T) -> Self { + Self::CalldataMutation(msg.to_string()) + } + + /// Create a new signing error + #[allow(clippy::needless_pass_by_value)] + pub fn signing(msg: T) -> Self { + Self::Signing(msg.to_string()) + } } impl warp::reject::Reject for AuthServerError {} diff --git a/auth/auth-server/src/main.rs b/auth/auth-server/src/main.rs index c5925d3..46d3c8e 100644 --- a/auth/auth-server/src/main.rs +++ b/auth/auth-server/src/main.rs @@ -20,7 +20,7 @@ pub(crate) mod schema; mod server; mod telemetry; -use auth_server_api::API_KEYS_PATH; +use auth_server_api::{ExternalQuoteAssemblyQueryParams, API_KEYS_PATH}; use clap::Parser; use ethers::signers::LocalWallet; use renegade_arbitrum_client::{ @@ -99,12 +99,22 @@ pub struct Cli { #[clap(short, long, env = "RPC_URL")] rpc_url: String, /// The address of the darkpool contract - #[clap(short = 'a', long, env = "DARKPOOL_ADDRESS")] + #[clap(short, long, env = "DARKPOOL_ADDRESS")] darkpool_address: String, /// The URL of the price reporter #[arg(long, env = "PRICE_REPORTER_URL")] pub price_reporter_url: String, + // ------------------- + // | Gas Sponsorship | + // ------------------- + /// The address of the gas sponsor contract + #[clap(long, env = "GAS_SPONSOR_ADDRESS")] + gas_sponsor_address: String, + /// The auth private key used for gas sponsorship, encoded as a hex string + #[clap(long, env = "GAS_SPONSOR_AUTH_KEY")] + gas_sponsor_auth_key: String, + // ------------- // | Telemetry | // ------------- @@ -267,9 +277,10 @@ async fn main() { .and(warp::path::full()) .and(warp::header::headers_cloned()) .and(warp::body::bytes()) + .and(warp::query::()) .and(with_server(server.clone())) - .and_then(|path, headers, body, server: Arc| async move { - server.handle_external_quote_assembly_request(path, headers, body).await + .and_then(|path, headers, body, query_params, server: Arc| async move { + server.handle_external_quote_assembly_request(path, headers, body, query_params).await }); let atomic_match_path = warp::path("v0") diff --git a/auth/auth-server/src/server/handle_external_match.rs b/auth/auth-server/src/server/handle_external_match.rs index a809c7e..f6c8524 100644 --- a/auth/auth-server/src/server/handle_external_match.rs +++ b/auth/auth-server/src/server/handle_external_match.rs @@ -3,8 +3,15 @@ //! At a high level the server must first authenticate the request, then forward //! it to the relayer with admin authentication +use alloy_primitives::Address; +use alloy_sol_types::{sol, SolCall}; +use auth_server_api::ExternalQuoteAssemblyQueryParams; use bytes::Bytes; -use http::Method; +use http::header::CONTENT_LENGTH; +use http::{Method, Response}; +use renegade_arbitrum_client::abi::{ + processAtomicMatchSettleCall, processAtomicMatchSettleWithReceiverCall, +}; use tracing::{info, instrument, warn}; use warp::{reject::Rejection, reply::Reply}; @@ -15,6 +22,7 @@ use renegade_api::http::external_match::{ use renegade_circuit_types::fixed_point::FixedPoint; use renegade_common::types::{token::Token, TimestampedPrice}; +use super::helpers::{gen_signed_sponsorship_nonce, get_selector}; use super::Server; use crate::error::AuthServerError; use crate::telemetry::helpers::calculate_implied_price; @@ -28,6 +36,28 @@ use crate::telemetry::{ }, }; +// ------------- +// | Constants | +// ------------- + +/// The gas estimation to use if fetching a gas estimation fails +/// From https://github.com/renegade-fi/renegade/blob/main/workers/api-server/src/http/external_match.rs/#L62 +pub const DEFAULT_GAS_ESTIMATION: u64 = 4_000_000; // 4m + +// ------- +// | ABI | +// ------- + +// The ABI for gas sponsorship functions +sol! { + function sponsorAtomicMatchSettle(bytes memory internal_party_match_payload, bytes memory valid_match_settle_atomic_statement, bytes memory match_proofs, bytes memory match_linking_proofs, address memory refund_address, uint256 memory nonce, bytes memory signature) external payable; + function sponsorAtomicMatchSettleWithReceiver(address receiver, bytes memory internal_party_match_payload, bytes memory valid_match_settle_atomic_statement, bytes memory match_proofs, bytes memory match_linking_proofs, address memory refund_address, uint256 memory nonce, bytes memory signature) external payable; +} + +// --------------- +// | Server Impl | +// --------------- + /// Handle a proxied request impl Server { /// Handle an external quote request @@ -64,15 +94,39 @@ impl Server { path: warp::path::FullPath, headers: warp::hyper::HeaderMap, body: Bytes, + query_params: ExternalQuoteAssemblyQueryParams, ) -> Result { + // Serialize the path + query params for auth + let query_str = serde_urlencoded::to_string(&query_params).unwrap(); + let auth_path = if query_str.is_empty() { + path.as_str().to_string() + } else { + format!("{}?{}", path.as_str(), query_str) + }; + // Authorize the request - let key_desc = self.authorize_request(path.as_str(), &headers, &body).await?; + let key_desc = self.authorize_request(&auth_path, &headers, &body).await?; self.check_bundle_rate_limit(key_desc.clone()).await?; // Send the request to the relayer - let resp = + let mut resp = self.send_admin_request(Method::POST, path.as_str(), headers, body.clone()).await?; + if query_params.use_gas_sponsorship.unwrap_or(false) { + // If gas sponsorship is requested, mutate the calldata in the response + // to invoke the gas sponsor contract + + info!("Redirecting match bundle through gas sponsor"); + let refund_address = query_params + .refund_address + .map(|s| s.parse()) + .transpose() + .map_err(AuthServerError::serde)? + .unwrap_or(Address::ZERO); + + self.mutate_response_for_gas_sponsorship(&mut resp, refund_address)?; + } + let resp_clone = resp.body().to_vec(); let server_clone = self.clone(); tokio::spawn(async move { @@ -330,4 +384,114 @@ impl Server { Ok(()) } + + /// Mutate a quote assembly response to invoke gas sponsorship + fn mutate_response_for_gas_sponsorship( + &self, + resp: &mut Response, + refund_address: Address, + ) -> Result<(), AuthServerError> { + let mut external_match_resp: ExternalMatchResponse = + serde_json::from_slice(resp.body()).map_err(AuthServerError::serde)?; + + let gas_sponsor_calldata = + self.generate_gas_sponsor_calldata(&external_match_resp, refund_address)?.into(); + + external_match_resp.match_bundle.settlement_tx.set_to(self.gas_sponsor_address); + external_match_resp.match_bundle.settlement_tx.set_data(gas_sponsor_calldata); + + let body = + Bytes::from(serde_json::to_vec(&external_match_resp).map_err(AuthServerError::serde)?); + + resp.headers_mut().insert(CONTENT_LENGTH, body.len().into()); + *resp.body_mut() = body; + + Ok(()) + } + + /// Generate the calldata for sponsoring the given match via the gas sponsor + fn generate_gas_sponsor_calldata( + &self, + external_match_resp: &ExternalMatchResponse, + refund_address: Address, + ) -> Result { + let calldata = external_match_resp + .match_bundle + .settlement_tx + .data() + .ok_or(AuthServerError::calldata_mutation("expected calldata"))?; + + let selector = get_selector(calldata)?; + + let gas_sponsor_calldata = match selector { + processAtomicMatchSettleCall::SELECTOR => { + self.sponsor_atomic_match_settle_call(calldata, refund_address) + }, + processAtomicMatchSettleWithReceiverCall::SELECTOR => { + self.sponsor_atomic_match_settle_with_receiver_call(calldata, refund_address) + }, + _ => { + return Err(AuthServerError::calldata_mutation("invalid selector")); + }, + }?; + + Ok(gas_sponsor_calldata) + } + + /// Create a `sponsorAtomicMatchSettle` call from `processAtomicMatchSettle` + /// calldata + fn sponsor_atomic_match_settle_call( + &self, + calldata: &[u8], + refund_address: Address, + ) -> Result { + let call = processAtomicMatchSettleCall::abi_decode( + calldata, true, // validate + ) + .map_err(AuthServerError::calldata_mutation)?; + + let (nonce, signature) = + gen_signed_sponsorship_nonce(refund_address, &self.gas_sponsor_auth_key)?; + + let sponsored_call = sponsorAtomicMatchSettleCall { + internal_party_match_payload: call.internal_party_match_payload, + valid_match_settle_atomic_statement: call.valid_match_settle_atomic_statement, + match_proofs: call.match_proofs, + match_linking_proofs: call.match_linking_proofs, + refund_address, + nonce, + signature, + }; + + Ok(sponsored_call.abi_encode().into()) + } + + /// Create a `sponsorAtomicMatchSettleWithReceiver` call from + /// `processAtomicMatchSettleWithReceiver` calldata + fn sponsor_atomic_match_settle_with_receiver_call( + &self, + calldata: &[u8], + refund_address: Address, + ) -> Result { + let call = processAtomicMatchSettleWithReceiverCall::abi_decode( + calldata, true, // validate + ) + .map_err(AuthServerError::calldata_mutation)?; + + let (nonce, signature) = + gen_signed_sponsorship_nonce(refund_address, &self.gas_sponsor_auth_key)?; + + let sponsored_call = sponsorAtomicMatchSettleWithReceiverCall { + receiver: call.receiver, + internal_party_match_payload: call.internal_party_match_payload, + valid_match_settle_atomic_statement: call.valid_match_settle_atomic_statement, + match_proofs: call.match_proofs, + match_linking_proofs: call.match_linking_proofs, + refund_address, + nonce, + signature, + }; + + Ok(sponsored_call.abi_encode().into()) + } } diff --git a/auth/auth-server/src/server/helpers.rs b/auth/auth-server/src/server/helpers.rs index 5458e18..7274fa4 100644 --- a/auth/auth-server/src/server/helpers.rs +++ b/auth/auth-server/src/server/helpers.rs @@ -4,8 +4,11 @@ use aes_gcm::{ aead::{Aead, KeyInit}, AeadCore, Aes128Gcm, }; +use alloy_primitives::{Address, Bytes, Parity, Signature, U256}; use base64::{engine::general_purpose, Engine as _}; -use rand::thread_rng; +use contracts_common::constants::NUM_BYTES_SIGNATURE; +use ethers::{core::k256::ecdsa::SigningKey, utils::keccak256}; +use rand::{thread_rng, Rng}; use serde_json::json; use warp::reply::Reply; @@ -50,6 +53,54 @@ pub fn aes_decrypt(value: &str, key: &[u8]) -> Result { Ok(plaintext) } +/// Generate a random nonce for gas sponsorship, signing it and the provided +/// refund address +pub fn gen_signed_sponsorship_nonce( + refund_address: Address, + gas_sponsor_auth_key: &SigningKey, +) -> Result<(U256, Bytes), AuthServerError> { + // Generate a random sponsorship nonce + let mut nonce_bytes = [0u8; U256::BYTES]; + thread_rng().fill(&mut nonce_bytes); + + // Generate a signature over the nonce + refund address using the gas sponsor + // key + let mut message = [0_u8; U256::BYTES + Address::len_bytes()]; + message[..U256::BYTES].copy_from_slice(&nonce_bytes); + message[U256::BYTES..].copy_from_slice(refund_address.as_ref()); + + let signature = sign_message(&message, gas_sponsor_auth_key)?.into(); + let nonce = U256::from_be_bytes(nonce_bytes); + + Ok((nonce, signature)) +} + +/// Sign a message using a secp256k1 key, serializing the signature to bytes +pub fn sign_message( + message: &[u8], + key: &SigningKey, +) -> Result<[u8; NUM_BYTES_SIGNATURE], AuthServerError> { + let message_hash = keccak256(message); + let (k256_sig, recid) = + key.sign_prehash_recoverable(&message_hash).map_err(AuthServerError::signing)?; + + let parity = Parity::Eip155(recid.to_byte() as u64); + + let signature = + Signature::from_signature_and_parity(k256_sig, parity).map_err(AuthServerError::signing)?; + + Ok(signature.as_bytes()) +} + +/// Get the function selector from calldata +pub fn get_selector(calldata: &[u8]) -> Result<[u8; 4], AuthServerError> { + calldata + .get(0..4) + .ok_or(AuthServerError::serde("expected selector"))? + .try_into() + .map_err(AuthServerError::serde) +} + #[cfg(test)] mod tests { use renegade_common::types::wallet::keychain::HmacKey; diff --git a/auth/auth-server/src/server/mod.rs b/auth/auth-server/src/server/mod.rs index c8ed54b..0ee2b66 100644 --- a/auth/auth-server/src/server/mod.rs +++ b/auth/auth-server/src/server/mod.rs @@ -2,7 +2,7 @@ //! //! The server is a dependency injection container for the authentication server mod api_auth; -mod handle_external_match; +pub(crate) mod handle_external_match; mod handle_key_management; mod helpers; mod queries; @@ -28,6 +28,7 @@ use diesel_async::{ pooled_connection::{AsyncDieselConnectionManager, ManagerConfig}, AsyncPgConnection, }; +use ethers::{abi::Address, core::k256::ecdsa::SigningKey, utils::hex}; use http::{HeaderMap, Method, Response}; use native_tls::TlsConnector; use postgres_native_tls::MakeTlsConnector; @@ -77,6 +78,10 @@ pub struct Server { pub quote_metrics: Option>, /// Rate at which to sample metrics (0.0 to 1.0) pub metrics_sampling_rate: f64, + /// The address of the gas sponsor address + pub gas_sponsor_address: Address, + /// The auth key for the gas sponsor + pub gas_sponsor_auth_key: SigningKey, } impl Server { @@ -111,6 +116,15 @@ impl Server { None }; + let gas_sponsor_address_bytes = + hex::decode(&args.gas_sponsor_address).map_err(AuthServerError::setup)?; + let gas_sponsor_address = Address::from_slice(&gas_sponsor_address_bytes); + + let gas_sponsor_auth_key_bytes = + hex::decode(&args.gas_sponsor_auth_key).map_err(AuthServerError::setup)?; + let gas_sponsor_auth_key = + SigningKey::from_slice(&gas_sponsor_auth_key_bytes).map_err(AuthServerError::setup)?; + Ok(Self { db_pool: Arc::new(db_pool), relayer_url: args.relayer_url, @@ -125,6 +139,8 @@ impl Server { metrics_sampling_rate: args .metrics_sampling_rate .unwrap_or(1.0 /* default no sampling */), + gas_sponsor_address, + gas_sponsor_auth_key, }) } diff --git a/auth/auth-server/src/telemetry/helpers.rs b/auth/auth-server/src/telemetry/helpers.rs index 1c6e020..5433e8d 100644 --- a/auth/auth-server/src/telemetry/helpers.rs +++ b/auth/auth-server/src/telemetry/helpers.rs @@ -15,12 +15,15 @@ use renegade_arbitrum_client::{ }; use renegade_circuit_types::{fixed_point::FixedPoint, order::OrderSide, wallet::Nullifier}; use renegade_common::types::token::Token; -use renegade_constants::Scalar; -use renegade_util::hex::biguint_to_hex_addr; +use renegade_constants::{Scalar, NATIVE_ASSET_ADDRESS, NATIVE_ASSET_WRAPPER_TICKER}; +use renegade_util::hex::{biguint_from_hex_string, biguint_to_hex_addr}; use tracing::{info, warn}; use crate::{ error::AuthServerError, + server::handle_external_match::{ + sponsorAtomicMatchSettleCall, sponsorAtomicMatchSettleWithReceiverCall, + }, telemetry::labels::{ ASSET_METRIC_TAG, BASE_ASSET_METRIC_TAG, EXTERNAL_MATCH_BASE_VOLUME, EXTERNAL_MATCH_FILL_RATIO, EXTERNAL_MATCH_QUOTE_VOLUME, EXTERNAL_MATCH_SETTLED_BASE_VOLUME, @@ -72,7 +75,14 @@ pub(crate) fn calculate_implied_price( OrderSide::Sell => (&match_bundle.send, &match_bundle.receive), }; - let base_token = Token::from_addr(&base.mint); + let trades_native_asset = + biguint_from_hex_string(&base.mint) == biguint_from_hex_string(NATIVE_ASSET_ADDRESS); + let base_token = if trades_native_asset { + Token::from_ticker(NATIVE_ASSET_WRAPPER_TICKER) + } else { + Token::from_addr(&base.mint) + }; + let quote_token = Token::from_addr("e.mint); let base_decimals = base_token.get_decimals().ok_or_else(|| { @@ -296,17 +306,41 @@ fn extract_nullifier_from_match_bundle( let tx_data = match_bundle .settlement_tx .data() - .ok_or_else(|| AuthServerError::Serde("No data in settlement tx".to_string()))?; + .ok_or(AuthServerError::serde("No data in settlement tx"))?; + + let selector: [u8; 4] = tx_data + .as_ref() + .get(0..4) + .ok_or(AuthServerError::serde("expected selector"))? + .try_into() + .unwrap(); // Retrieve serialized match payload from the transaction data - let serialized_match_payload = - if let Ok(decoded) = processAtomicMatchSettleCall::abi_decode(tx_data, false) { - decoded.internal_party_match_payload - } else { - let decoded = processAtomicMatchSettleWithReceiverCall::abi_decode(tx_data, false) - .map_err(AuthServerError::serde)?; - decoded.internal_party_match_payload - }; + let serialized_match_payload = match selector { + processAtomicMatchSettleCall::SELECTOR => { + processAtomicMatchSettleCall::abi_decode(tx_data, false) + .map_err(AuthServerError::serde)? + .internal_party_match_payload + }, + processAtomicMatchSettleWithReceiverCall::SELECTOR => { + processAtomicMatchSettleWithReceiverCall::abi_decode(tx_data, false) + .map_err(AuthServerError::serde)? + .internal_party_match_payload + }, + sponsorAtomicMatchSettleCall::SELECTOR => { + sponsorAtomicMatchSettleCall::abi_decode(tx_data, false) + .map_err(AuthServerError::serde)? + .internal_party_match_payload + }, + sponsorAtomicMatchSettleWithReceiverCall::SELECTOR => { + sponsorAtomicMatchSettleWithReceiverCall::abi_decode(tx_data, false) + .map_err(AuthServerError::serde)? + .internal_party_match_payload + }, + _ => { + return Err(AuthServerError::serde("Invalid selector for settlement tx")); + }, + }; // Extract nullifier from the payload let match_payload = deserialize_calldata::(&serialized_match_payload) diff --git a/auth/auth-server/src/telemetry/sources/mod.rs b/auth/auth-server/src/telemetry/sources/mod.rs index c36b5a6..0b4c7a7 100644 --- a/auth/auth-server/src/telemetry/sources/mod.rs +++ b/auth/auth-server/src/telemetry/sources/mod.rs @@ -7,9 +7,8 @@ use renegade_api::http::external_match::AtomicMatchApiBundle; use renegade_circuit_types::{order::OrderSide, Amount}; use renegade_common::types::token::Token; -/// The gas estimation to use if fetching a gas estimation fails -/// From https://github.com/renegade-fi/renegade/blob/main/workers/api-server/src/http/external_match.rs/#L62 -const DEFAULT_GAS_ESTIMATION: u64 = 4_000_000; // 4m +use crate::server::handle_external_match::DEFAULT_GAS_ESTIMATION; + /// The name of our quote source const RENEGADE_SOURCE_NAME: &str = "renegade";