From 78edf3883b3472798f0f82d28b1ba1e9773e0fb7 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Tue, 14 Apr 2026 15:49:09 +0000 Subject: [PATCH] feat: API gating signer + signed context injection for take-orders Introduces a gating signer that produces SignedContextV1 entries for every candidate order in /v1/swap/calldata and /v1/swap/quote. The signed payload is [recipient, orderHash, expiry, id] where id is keccak256(api_key_id), retained for off-chain attribution. The signer key is loaded from env (ST0X_GATING_SIGNER_KEY, required at startup) and never written to disk; TTL is `gating_signature_ttl_seconds` in config. Architecture: - New `signing` module: EIP-191 signer adapted from st0x-oracle-server, plus `GatingState` held in rocket-managed state. - New `raindex::gating_injector::ApiGatingInjector` implements the upstream `SignedContextInjector` trait: per-order contexts_for() signs `[taker, keccak256(abi.encode(order)), expiry, id]` and returns a single SignedContextV1 appended after any oracle-discovered contexts. - `SwapDataSource` trait grows `counterparty` + `injector` params. `RaindexSwapDataSource` forwards to `get_take_orders_calldata_with_injector`. - `SwapQuoteRequest` gains an optional `taker` field (backward compat; gated orders need it, others don't). **Merge order**: this PR depends on rainlanguage/raindex#2547. The submodule is pinned to that branch's head; after #2547 merges, rebase this branch onto main and bump `lib/rain.orderbook` to the new main head. --- .env.example | 7 + Cargo.lock | 16 +-- config/dev.toml | 1 + config/rest-api.toml | 1 + dev-config.toml | 1 + lib/rain.orderbook | 2 +- src/config.rs | 80 +++++++++++- src/main.rs | 30 +++++ src/raindex/gating_injector.rs | 126 ++++++++++++++++++ src/raindex/mod.rs | 2 + src/routes/order/mod.rs | 2 +- src/routes/swap/calldata.rs | 26 ++-- src/routes/swap/mod.rs | 35 +++-- src/routes/swap/quote.rs | 47 +++++-- src/routes/tokens.rs | 4 +- src/signing.rs | 225 +++++++++++++++++++++++++++++++++ src/test_helpers.rs | 28 +++- src/types/swap.rs | 10 ++ 18 files changed, 590 insertions(+), 53 deletions(-) create mode 100644 src/raindex/gating_injector.rs create mode 100644 src/signing.rs diff --git a/.env.example b/.env.example index d9c0f7e..bb7ce31 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,8 @@ RUST_LOG=st0x_rest_api=info,rocket=warn,warn + +# EOA private key (hex, with or without 0x prefix) used by the API to sign +# gating SignedContextV1 entries for API-reserved orders (e.g. the +# `gated-pyth` registry strategy). Required at startup; the server exits +# if missing or empty. Keep this secret — it authorises taker access to +# gated liquidity pots. +ST0X_GATING_SIGNER_KEY= diff --git a/Cargo.lock b/Cargo.lock index 01cdca2..d8cfbb1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6491,14 +6491,11 @@ dependencies = [ "alloy", "eyre", "foundry-evm", - "once_cell", "rain-error-decoding 0.1.0 (git+https://github.com/rainlanguage/rain.error?rev=3d2ed70fb2f7c6156706846e10f163d1e493a8d3)", "rain_interpreter_bindings", - "reqwest 0.11.27", "revm 24.0.1", "revm 25.0.0", "serde", - "serde_json", "thiserror 1.0.69", "wasm-bindgen-utils 0.0.10 (registry+https://github.com/rust-lang/crates.io-index)", ] @@ -6612,14 +6609,6 @@ name = "rain_interpreter_dispair" version = "0.1.0" dependencies = [ "alloy", - "alloy-ethers-typecast 0.2.0 (git+https://github.com/rainlanguage/alloy-ethers-typecast?rev=bcc3a04394aefe191fef4ae8e6e94381a419c99a)", - "rain_interpreter_bindings", - "serde", - "serde_json", - "thiserror 1.0.69", - "tokio", - "tracing", - "tracing-subscriber 0.3.19", ] [[package]] @@ -6630,8 +6619,6 @@ dependencies = [ "alloy-ethers-typecast 0.2.0 (git+https://github.com/rainlanguage/alloy-ethers-typecast?rev=bcc3a04394aefe191fef4ae8e6e94381a419c99a)", "rain_interpreter_bindings", "rain_interpreter_dispair", - "serde", - "serde_json", "thiserror 1.0.69", "tokio", ] @@ -6765,12 +6752,15 @@ dependencies = [ "alloy", "alloy-ethers-typecast 0.2.0 (git+https://github.com/rainlanguage/alloy-ethers-typecast?rev=bcc3a04394aefe191fef4ae8e6e94381a419c99a)", "anyhow", + "async-trait", "clap", + "futures", "getrandom 0.2.16", "once_cell", "rain-error-decoding 0.1.0 (git+https://github.com/rainlanguage/rain.error?rev=3d2ed70fb2f7c6156706846e10f163d1e493a8d3)", "rain-interpreter-eval", "rain-math-float", + "rain-metadata 0.0.2-alpha.6", "rain_orderbook_bindings", "rain_orderbook_subgraph_client", "reqwest 0.12.20", diff --git a/config/dev.toml b/config/dev.toml index edf14f7..a52873b 100644 --- a/config/dev.toml +++ b/config/dev.toml @@ -5,3 +5,4 @@ rate_limit_global_rpm = 600 rate_limit_per_key_rpm = 60 docs_dir = "./docs/book" local_db_path = "data/raindex.db" +gating_signature_ttl_seconds = 60 diff --git a/config/rest-api.toml b/config/rest-api.toml index 0c67a0d..14e8783 100644 --- a/config/rest-api.toml +++ b/config/rest-api.toml @@ -5,3 +5,4 @@ rate_limit_global_rpm = 600 rate_limit_per_key_rpm = 60 docs_dir = "/var/lib/st0x-docs" local_db_path = "/mnt/data/st0x-rest-api/raindex.db" +gating_signature_ttl_seconds = 60 diff --git a/dev-config.toml b/dev-config.toml index 6dd1674..ca778d2 100644 --- a/dev-config.toml +++ b/dev-config.toml @@ -4,3 +4,4 @@ registry_url = "https://raw.githubusercontent.com/rainlanguage/rain.strategies/a rate_limit_global_rpm = 1000 rate_limit_per_key_rpm = 100 docs_dir = "docs/book" +gating_signature_ttl_seconds = 60 diff --git a/lib/rain.orderbook b/lib/rain.orderbook index 5725312..422030b 160000 --- a/lib/rain.orderbook +++ b/lib/rain.orderbook @@ -1 +1 @@ -Subproject commit 57253129e47b1c7f744a514c131a638bb0d7607a +Subproject commit 422030b3a90a0e4cddf61d5fba5d5e1f3f9182da diff --git a/src/config.rs b/src/config.rs index fc3f127..2d8e805 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,7 +1,7 @@ use serde::Deserialize; use std::path::Path; -#[derive(Deserialize)] +#[derive(Debug, Deserialize)] pub struct Config { pub log_dir: String, pub database_url: String, @@ -10,12 +10,88 @@ pub struct Config { pub rate_limit_per_key_rpm: u64, pub docs_dir: String, pub local_db_path: String, + /// TTL (seconds) for gating signatures produced by the API. Each swap + /// calldata response embeds a signed context whose `expiry` field is + /// `now() + this`. Keep short enough that a captured signature is not + /// useful for long. The gating signer private key is read from the + /// `ST0X_GATING_SIGNER_KEY` env var and never appears in this file. + pub gating_signature_ttl_seconds: u64, } +/// Upper bound on `gating_signature_ttl_seconds`. A captured signature is +/// replayable until `expiry`, so the window is a security control. +const GATING_SIGNATURE_TTL_MAX_SECONDS: u64 = 300; + impl Config { pub fn load(path: &Path) -> Result { let contents = std::fs::read_to_string(path).map_err(|e| format!("failed to read config: {e}"))?; - toml::from_str(&contents).map_err(|e| format!("failed to parse config: {e}")) + let cfg: Self = + toml::from_str(&contents).map_err(|e| format!("failed to parse config: {e}"))?; + if cfg.gating_signature_ttl_seconds == 0 { + return Err("gating_signature_ttl_seconds must be > 0".into()); + } + if cfg.gating_signature_ttl_seconds > GATING_SIGNATURE_TTL_MAX_SECONDS { + return Err(format!( + "gating_signature_ttl_seconds must be <= {GATING_SIGNATURE_TTL_MAX_SECONDS}" + )); + } + Ok(cfg) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use std::path::PathBuf; + + struct TempCfg(PathBuf); + impl Drop for TempCfg { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.0); + } + } + + fn write_config(body: &str) -> TempCfg { + let path = std::env::temp_dir().join(format!("st0x-cfg-{}.toml", uuid::Uuid::new_v4())); + let mut f = std::fs::File::create(&path).unwrap(); + f.write_all(body.as_bytes()).unwrap(); + TempCfg(path) + } + + fn base_config(ttl: &str) -> String { + format!( + r#"log_dir = "/tmp/l" +database_url = "sqlite::memory:" +registry_url = "https://example.com/r" +rate_limit_global_rpm = 10 +rate_limit_per_key_rpm = 1 +docs_dir = "/tmp/d" +local_db_path = "/tmp/db" +gating_signature_ttl_seconds = {ttl} +"# + ) + } + + #[test] + fn rejects_zero_ttl() { + let f = write_config(&base_config("0")); + let err = Config::load(&f.0).unwrap_err(); + assert!(err.contains("must be > 0"), "got: {err}"); + } + + #[test] + fn rejects_ttl_above_max() { + let f = write_config(&base_config("301")); + let err = Config::load(&f.0).unwrap_err(); + assert!(err.contains("must be <= 300"), "got: {err}"); + } + + #[test] + fn accepts_valid_ttl() { + let f = write_config(&base_config("60")); + let cfg = Config::load(&f.0).expect("valid config"); + assert_eq!(cfg.gating_signature_ttl_seconds, 60); } } diff --git a/src/main.rs b/src/main.rs index 082822e..2de2453 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,6 +12,7 @@ mod error; mod fairings; mod raindex; mod routes; +mod signing; mod telemetry; mod types; @@ -121,6 +122,7 @@ pub(crate) fn rocket( pool: db::DbPool, rate_limiter: fairings::RateLimiter, raindex_config: raindex::SharedRaindexProvider, + gating: signing::GatingState, docs_dir: String, direct_trades_fetcher: Option, ) -> Result, StartupError> { @@ -148,6 +150,7 @@ pub(crate) fn rocket( .manage(orders_by_token_cache) .manage(orders_by_owner_cache) .manage(direct_trades_fetcher) + .manage(gating) .mount("/", routes::health::routes()) .mount("/v1/tokens", routes::tokens::routes()) .mount("/v1/swap", routes::swap::routes()) @@ -333,10 +336,37 @@ async fn main() { } tracing::info!(docs_dir = %cfg.docs_dir, "serving documentation at /docs"); + let gating_key = match std::env::var("ST0X_GATING_SIGNER_KEY") { + Ok(k) if !k.is_empty() => k, + _ => { + tracing::error!( + "ST0X_GATING_SIGNER_KEY env var is required but missing or empty" + ); + drop(log_guard); + std::process::exit(1); + } + }; + let gating_signer = match signing::GatingSigner::from_hex_key(&gating_key) { + Ok(s) => { + tracing::info!(signer = %s.address(), "gating signer loaded"); + s + } + Err(e) => { + tracing::error!(error = %e, "failed to parse ST0X_GATING_SIGNER_KEY"); + drop(log_guard); + std::process::exit(1); + } + }; + let gating_state = signing::GatingState { + signer: gating_signer, + ttl_seconds: cfg.gating_signature_ttl_seconds, + }; + let rocket = match rocket( pool, rate_limiter, shared_raindex, + gating_state, cfg.docs_dir, direct_trades_fetcher, ) { diff --git a/src/raindex/gating_injector.rs b/src/raindex/gating_injector.rs new file mode 100644 index 0000000..9a724c0 --- /dev/null +++ b/src/raindex/gating_injector.rs @@ -0,0 +1,126 @@ +//! Adapter that implements `rain_orderbook_common::take_orders::SignedContextInjector` +//! by producing a single gating `SignedContextV1` per order, signed by the +//! API's gating key. +//! +//! The injector is constructed per request: the authenticated key id defines +//! the attribution `id`, and the TTL config defines the `expiry`. The +//! counterparty (taker) comes from rain.orderbook's candidate-building loop +//! as the on-chain take-order counterparty. + +use crate::auth::AuthenticatedKey; +use crate::signing::{compute_attribution_id, GatingSigner, GatingState, SignedGatingContext}; +use alloy::primitives::{keccak256, Address, Bytes, B256}; +use alloy::sol_types::SolValue; +use async_trait::async_trait; +use rain_orderbook_bindings::IRaindexV6::{OrderV4, SignedContextV1}; +use rain_orderbook_common::take_orders::SignedContextInjector; + +pub struct ApiGatingInjector<'a> { + pub signer: &'a GatingSigner, + pub expiry: u64, + pub attribution_id: B256, +} + +impl<'a> ApiGatingInjector<'a> { + /// Build the request-scoped injector from rocket-managed gating state and + /// the authenticated key id. Encapsulates expiry computation and + /// `id = keccak256(key_id)` derivation so route handlers don't repeat it. + pub fn for_request(state: &'a GatingState, key: &AuthenticatedKey) -> Self { + Self { + signer: &state.signer, + expiry: state.expiry_from_now(), + attribution_id: compute_attribution_id(&key.key_id), + } + } +} + +#[async_trait] +impl SignedContextInjector for ApiGatingInjector<'_> { + async fn contexts_for( + &self, + order: &OrderV4, + _input_io_index: u32, + _output_io_index: u32, + counterparty: Address, + ) -> Vec { + let order_hash = keccak256(order.abi_encode()); + match self + .signer + .sign_gating_context(counterparty, order_hash, self.expiry, self.attribution_id) + .await + { + Ok(signed) => vec![to_signed_context_v1(signed)], + Err(e) => { + tracing::error!( + error = %e, + %counterparty, + %order_hash, + attribution_id = %self.attribution_id, + "gating signer failed to sign context; order will fail on-chain gating" + ); + vec![] + } + } + } +} + +fn to_signed_context_v1(signed: SignedGatingContext) -> SignedContextV1 { + SignedContextV1 { + signer: signed.signer, + context: signed.context, + signature: Bytes::from(signed.signature), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::signing::compute_attribution_id; + use alloy::primitives::U256; + use rain_orderbook_bindings::IRaindexV6::{EvaluableV4, IOV2}; + + const TEST_KEY: &str = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + + fn dummy_order() -> OrderV4 { + OrderV4 { + owner: Address::from([1u8; 20]), + nonce: U256::from(1).into(), + evaluable: EvaluableV4 { + interpreter: Address::from([2u8; 20]), + store: Address::from([3u8; 20]), + bytecode: Bytes::from(vec![0x01, 0x02]), + }, + validInputs: vec![IOV2 { + token: Address::from([4u8; 20]), + vaultId: U256::from(100).into(), + }], + validOutputs: vec![IOV2 { + token: Address::from([5u8; 20]), + vaultId: U256::from(200).into(), + }], + } + } + + #[tokio::test] + async fn test_produces_single_signed_context_with_derived_hash() { + let signer = GatingSigner::from_hex_key(TEST_KEY).unwrap(); + let injector = ApiGatingInjector { + signer: &signer, + expiry: 1_700_000_000, + attribution_id: compute_attribution_id("my-key-id"), + }; + let order = dummy_order(); + let counterparty = Address::from([7u8; 20]); + + let contexts = injector.contexts_for(&order, 0, 0, counterparty).await; + assert_eq!(contexts.len(), 1); + let ctx = &contexts[0]; + assert_eq!(ctx.signer, signer.address()); + assert_eq!(ctx.context.len(), 4); + assert_eq!(ctx.signature.len(), 65); + + // Row 1 must be the canonical order hash. + let expected_hash = keccak256(order.abi_encode()); + assert_eq!(ctx.context[1], expected_hash); + } +} diff --git a/src/raindex/mod.rs b/src/raindex/mod.rs index 14b908d..815c94e 100644 --- a/src/raindex/mod.rs +++ b/src/raindex/mod.rs @@ -1,4 +1,6 @@ pub(crate) mod config; +pub(crate) mod gating_injector; pub(crate) use config::RaindexProvider; +pub(crate) use gating_injector::ApiGatingInjector; pub(crate) type SharedRaindexProvider = tokio::sync::RwLock; diff --git a/src/routes/order/mod.rs b/src/routes/order/mod.rs index 55b1ad0..0cbb859 100644 --- a/src/routes/order/mod.rs +++ b/src/routes/order/mod.rs @@ -98,7 +98,7 @@ pub(crate) mod test_fixtures { pub fn stub_raindex_client() -> serde_json::Value { json!({ "orderbook_yaml": { - "documents": ["version: 4\nnetworks:\n base:\n rpcs:\n - https://mainnet.base.org\n chain-id: 8453\n currency: ETH\nsubgraphs:\n base: https://example.com/sg\norderbooks:\n base:\n address: 0xd2938e7c9fe3597f78832ce780feb61945c377d7\n network: base\n subgraph: base\n deployment-block: 0\ndeployers:\n base:\n address: 0xC1A14cE2fd58A3A2f99deCb8eDd866204eE07f8D\n network: base\n"], + "documents": ["version: 5\nnetworks:\n base:\n rpcs:\n - https://mainnet.base.org\n chain-id: 8453\n currency: ETH\nsubgraphs:\n base: https://example.com/sg\norderbooks:\n base:\n address: 0xd2938e7c9fe3597f78832ce780feb61945c377d7\n network: base\n subgraph: base\n deployment-block: 0\ndeployers:\n base:\n address: 0xC1A14cE2fd58A3A2f99deCb8eDd866204eE07f8D\n network: base\n"], "profile": "strict" } }) diff --git a/src/routes/swap/calldata.rs b/src/routes/swap/calldata.rs index cee7d06..8819f95 100644 --- a/src/routes/swap/calldata.rs +++ b/src/routes/swap/calldata.rs @@ -2,6 +2,8 @@ use super::{RaindexSwapDataSource, SwapDataSource}; use crate::auth::AuthenticatedKey; use crate::error::{ApiError, ApiErrorResponse}; use crate::fairings::{GlobalRateLimit, TracingSpan}; +use crate::raindex::ApiGatingInjector; +use crate::signing::GatingState; use crate::types::swap::{SwapCalldataRequest, SwapCalldataResponse}; use rain_orderbook_common::raindex_client::take_orders::TakeOrdersRequest; use rain_orderbook_common::take_orders::TakeOrdersMode; @@ -27,19 +29,21 @@ use tracing::Instrument; #[post("/calldata", data = "")] pub async fn post_swap_calldata( _global: GlobalRateLimit, - _key: AuthenticatedKey, + key: AuthenticatedKey, shared_raindex: &State, + gating: &State, span: TracingSpan, request: Json, ) -> Result, ApiError> { let req = request.into_inner(); + let injector = ApiGatingInjector::for_request(gating, &key); async move { tracing::info!(body = ?req, "request received"); let raindex = shared_raindex.read().await; let ds = RaindexSwapDataSource { client: raindex.client(), }; - let response = process_swap_calldata(&ds, req).await?; + let response = process_swap_calldata(&ds, req, &injector).await?; Ok(Json(response)) } .instrument(span.0) @@ -49,6 +53,7 @@ pub async fn post_swap_calldata( async fn process_swap_calldata( ds: &dyn SwapDataSource, req: SwapCalldataRequest, + injector: &dyn rain_orderbook_common::take_orders::SignedContextInjector, ) -> Result { let take_req = TakeOrdersRequest { taker: req.taker.to_string(), @@ -60,7 +65,7 @@ async fn process_swap_calldata( price_cap: req.maximum_io_ratio, }; - ds.get_calldata(take_req).await + ds.get_calldata(take_req, injector).await } #[cfg(test)] @@ -70,6 +75,7 @@ mod tests { use crate::test_helpers::TestClientBuilder; use crate::types::common::Approval; use alloy::primitives::{address, Address, Bytes, U256}; + use rain_orderbook_common::take_orders::NoopInjector; use rocket::http::{ContentType, Status}; const USDC: Address = address!("833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"); @@ -120,7 +126,7 @@ mod tests { candidates: vec![], calldata_result: Ok(ready_response()), }; - let result = process_swap_calldata(&ds, calldata_request("100", "2.5")) + let result = process_swap_calldata(&ds, calldata_request("100", "2.5"), &NoopInjector) .await .unwrap(); @@ -138,7 +144,7 @@ mod tests { candidates: vec![], calldata_result: Ok(approval_response()), }; - let result = process_swap_calldata(&ds, calldata_request("100", "2.5")) + let result = process_swap_calldata(&ds, calldata_request("100", "2.5"), &NoopInjector) .await .unwrap(); @@ -158,7 +164,8 @@ mod tests { "no liquidity found for this pair".into(), )), }; - let result = process_swap_calldata(&ds, calldata_request("100", "2.5")).await; + let result = + process_swap_calldata(&ds, calldata_request("100", "2.5"), &NoopInjector).await; assert!(matches!(result, Err(ApiError::NotFound(msg)) if msg.contains("no liquidity"))); } @@ -169,7 +176,9 @@ mod tests { candidates: vec![], calldata_result: Err(ApiError::BadRequest("invalid parameters".into())), }; - let result = process_swap_calldata(&ds, calldata_request("not-a-number", "2.5")).await; + let result = + process_swap_calldata(&ds, calldata_request("not-a-number", "2.5"), &NoopInjector) + .await; assert!(matches!(result, Err(ApiError::BadRequest(_)))); } @@ -180,7 +189,8 @@ mod tests { candidates: vec![], calldata_result: Err(ApiError::Internal("failed to generate calldata".into())), }; - let result = process_swap_calldata(&ds, calldata_request("100", "2.5")).await; + let result = + process_swap_calldata(&ds, calldata_request("100", "2.5"), &NoopInjector).await; assert!(matches!(result, Err(ApiError::Internal(_)))); } diff --git a/src/routes/swap/mod.rs b/src/routes/swap/mod.rs index ad25933..e3ddc23 100644 --- a/src/routes/swap/mod.rs +++ b/src/routes/swap/mod.rs @@ -12,7 +12,7 @@ use rain_orderbook_common::raindex_client::take_orders::TakeOrdersRequest; use rain_orderbook_common::raindex_client::RaindexClient; use rain_orderbook_common::raindex_client::RaindexError; use rain_orderbook_common::take_orders::{ - build_take_order_candidates_for_pair, TakeOrderCandidate, + build_take_order_candidates_for_pair, SignedContextInjector, TakeOrderCandidate, }; use rocket::Route; @@ -29,11 +29,14 @@ pub(crate) trait SwapDataSource: Send + Sync { orders: &[RaindexOrder], input_token: Address, output_token: Address, + counterparty: Address, + injector: &dyn SignedContextInjector, ) -> Result, ApiError>; async fn get_calldata( &self, request: TakeOrdersRequest, + injector: &dyn SignedContextInjector, ) -> Result; } @@ -72,22 +75,33 @@ impl<'a> SwapDataSource for RaindexSwapDataSource<'a> { orders: &[RaindexOrder], input_token: Address, output_token: Address, + counterparty: Address, + injector: &dyn SignedContextInjector, ) -> Result, ApiError> { - build_take_order_candidates_for_pair(orders, input_token, output_token, None, None) - .await - .map_err(|e| { - tracing::error!(error = %e, "failed to build order candidates"); - ApiError::Internal("failed to build order candidates".into()) - }) + build_take_order_candidates_for_pair( + orders, + input_token, + output_token, + None, + None, + counterparty, + injector, + ) + .await + .map_err(|e| { + tracing::error!(error = %e, "failed to build order candidates"); + ApiError::Internal("failed to build order candidates".into()) + }) } async fn get_calldata( &self, request: TakeOrdersRequest, + injector: &dyn SignedContextInjector, ) -> Result { let result = self .client - .get_take_orders_calldata(request) + .get_take_orders_calldata_with_injector(request, injector) .await .map_err(map_raindex_error)?; @@ -167,7 +181,7 @@ pub(crate) mod test_fixtures { use async_trait::async_trait; use rain_orderbook_common::raindex_client::orders::RaindexOrder; use rain_orderbook_common::raindex_client::take_orders::TakeOrdersRequest; - use rain_orderbook_common::take_orders::TakeOrderCandidate; + use rain_orderbook_common::take_orders::{SignedContextInjector, TakeOrderCandidate}; pub struct MockSwapDataSource { pub orders: Result, ApiError>, @@ -193,6 +207,8 @@ pub(crate) mod test_fixtures { _orders: &[RaindexOrder], _input_token: Address, _output_token: Address, + _counterparty: Address, + _injector: &dyn SignedContextInjector, ) -> Result, ApiError> { Ok(self.candidates.clone()) } @@ -200,6 +216,7 @@ pub(crate) mod test_fixtures { async fn get_calldata( &self, _request: TakeOrdersRequest, + _injector: &dyn SignedContextInjector, ) -> Result { self.calldata_result.clone() } diff --git a/src/routes/swap/quote.rs b/src/routes/swap/quote.rs index b37c3c9..357f3cf 100644 --- a/src/routes/swap/quote.rs +++ b/src/routes/swap/quote.rs @@ -2,9 +2,12 @@ use super::{RaindexSwapDataSource, SwapDataSource}; use crate::auth::AuthenticatedKey; use crate::error::{ApiError, ApiErrorResponse}; use crate::fairings::{GlobalRateLimit, TracingSpan}; +use crate::raindex::ApiGatingInjector; +use crate::signing::GatingState; use crate::types::swap::{SwapQuoteRequest, SwapQuoteResponse}; +use alloy::primitives::Address; use rain_math_float::Float; -use rain_orderbook_common::take_orders::simulate_buy_over_candidates; +use rain_orderbook_common::take_orders::{simulate_buy_over_candidates, SignedContextInjector}; use rocket::serde::json::Json; use rocket::State; use std::ops::Div; @@ -28,19 +31,21 @@ use tracing::Instrument; #[post("/quote", data = "")] pub async fn post_swap_quote( _global: GlobalRateLimit, - _key: AuthenticatedKey, + key: AuthenticatedKey, shared_raindex: &State, + gating: &State, span: TracingSpan, request: Json, ) -> Result, ApiError> { let req = request.into_inner(); + let injector = ApiGatingInjector::for_request(gating, &key); async move { tracing::info!(body = ?req, "request received"); let raindex = shared_raindex.read().await; let ds = RaindexSwapDataSource { client: raindex.client(), }; - let response = process_swap_quote(&ds, req).await?; + let response = process_swap_quote(&ds, req, &injector).await?; Ok(Json(response)) } .instrument(span.0) @@ -50,6 +55,7 @@ pub async fn post_swap_quote( async fn process_swap_quote( ds: &dyn SwapDataSource, req: SwapQuoteRequest, + injector: &dyn SignedContextInjector, ) -> Result { let orders = ds .get_orders_for_pair(req.input_token, req.output_token) @@ -61,8 +67,15 @@ async fn process_swap_quote( )); } + let counterparty = req.taker.unwrap_or(Address::ZERO); let candidates = ds - .build_candidates_for_pair(&orders, req.input_token, req.output_token) + .build_candidates_for_pair( + &orders, + req.input_token, + req.output_token, + counterparty, + injector, + ) .await?; if candidates.is_empty() { @@ -124,6 +137,7 @@ mod tests { use crate::routes::swap::test_fixtures::MockSwapDataSource; use crate::test_helpers::{mock_candidate, mock_order, TestClientBuilder}; use alloy::primitives::address; + use rain_orderbook_common::take_orders::NoopInjector; use rocket::http::{ContentType, Status}; const USDC: alloy::primitives::Address = address!("833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"); @@ -134,6 +148,7 @@ mod tests { input_token: USDC, output_token: WETH, output_amount: output_amount.to_string(), + taker: None, } } @@ -144,7 +159,9 @@ mod tests { candidates: vec![mock_candidate("1000", "1.5")], calldata_result: Err(ApiError::Internal("unused".into())), }; - let result = process_swap_quote(&ds, quote_request("100")).await.unwrap(); + let result = process_swap_quote(&ds, quote_request("100"), &NoopInjector) + .await + .unwrap(); assert_eq!(result.input_token, USDC); assert_eq!(result.output_token, WETH); @@ -161,7 +178,9 @@ mod tests { candidates: vec![mock_candidate("50", "2"), mock_candidate("50", "3")], calldata_result: Err(ApiError::Internal("unused".into())), }; - let result = process_swap_quote(&ds, quote_request("100")).await.unwrap(); + let result = process_swap_quote(&ds, quote_request("100"), &NoopInjector) + .await + .unwrap(); assert_eq!(result.output_amount, "100"); assert_eq!(result.estimated_output, "100"); @@ -176,7 +195,9 @@ mod tests { candidates: vec![mock_candidate("30", "2")], calldata_result: Err(ApiError::Internal("unused".into())), }; - let result = process_swap_quote(&ds, quote_request("100")).await.unwrap(); + let result = process_swap_quote(&ds, quote_request("100"), &NoopInjector) + .await + .unwrap(); assert_eq!(result.output_amount, "100"); assert_eq!(result.estimated_output, "30"); @@ -194,7 +215,9 @@ mod tests { ], calldata_result: Err(ApiError::Internal("unused".into())), }; - let result = process_swap_quote(&ds, quote_request("10")).await.unwrap(); + let result = process_swap_quote(&ds, quote_request("10"), &NoopInjector) + .await + .unwrap(); assert_eq!(result.estimated_io_ratio, "1.5"); assert_eq!(result.estimated_input, "15"); @@ -207,7 +230,7 @@ mod tests { candidates: vec![], calldata_result: Err(ApiError::Internal("unused".into())), }; - let result = process_swap_quote(&ds, quote_request("100")).await; + let result = process_swap_quote(&ds, quote_request("100"), &NoopInjector).await; assert!(matches!(result, Err(ApiError::NotFound(msg)) if msg.contains("no liquidity"))); } @@ -218,7 +241,7 @@ mod tests { candidates: vec![], calldata_result: Err(ApiError::Internal("unused".into())), }; - let result = process_swap_quote(&ds, quote_request("100")).await; + let result = process_swap_quote(&ds, quote_request("100"), &NoopInjector).await; assert!(matches!(result, Err(ApiError::NotFound(msg)) if msg.contains("no valid quotes"))); } @@ -229,7 +252,7 @@ mod tests { candidates: vec![mock_candidate("1000", "1.5")], calldata_result: Err(ApiError::Internal("unused".into())), }; - let result = process_swap_quote(&ds, quote_request("not-a-number")).await; + let result = process_swap_quote(&ds, quote_request("not-a-number"), &NoopInjector).await; assert!(matches!(result, Err(ApiError::BadRequest(_)))); } @@ -240,7 +263,7 @@ mod tests { candidates: vec![], calldata_result: Err(ApiError::Internal("unused".into())), }; - let result = process_swap_quote(&ds, quote_request("100")).await; + let result = process_swap_quote(&ds, quote_request("100"), &NoopInjector).await; assert!(matches!(result, Err(ApiError::Internal(_)))); } diff --git a/src/routes/tokens.rs b/src/routes/tokens.rs index 6243d0d..ab18c3a 100644 --- a/src/routes/tokens.rs +++ b/src/routes/tokens.rs @@ -122,7 +122,7 @@ mod tests { #[rocket::async_test] async fn test_get_tokens_returns_multiple_tokens() { - let settings = r#"version: 4 + let settings = r#"version: 5 networks: base: rpcs: @@ -180,7 +180,7 @@ tokens: #[rocket::async_test] async fn test_get_tokens_adds_name_and_isin_from_remote_tokens() { - let settings = r#"version: 4 + let settings = r#"version: 5 networks: base: rpcs: diff --git a/src/signing.rs b/src/signing.rs new file mode 100644 index 0000000..b2bce82 --- /dev/null +++ b/src/signing.rs @@ -0,0 +1,225 @@ +//! EIP-191 signer used to produce gating `SignedContextV1` entries for +//! take-order flows against API-gated strategies. +//! +//! The signed context carries `[recipient, orderHash, expiry, id]` as four +//! bytes32 values. Strategies on the gated-pyth registry entry verify: +//! - signer == `api-signer` binding, +//! - recipient == `order-counterparty()`, +//! - orderHash == `order-hash()`, +//! - expiry >= `now()`. +//! +//! The `id` (keccak256 of the authenticated API key id) is signed but not +//! asserted on-chain; it is retained purely for off-chain attribution via +//! event indexing. + +use alloy::primitives::{keccak256, Address, FixedBytes, B256, U256}; +use alloy::signers::local::PrivateKeySigner; +use alloy::signers::Signer as AlloySigner; + +/// Holds the gating signer key and knows how to produce signed contexts. +/// +/// Does **not** derive `Debug`: the inner `PrivateKeySigner` would print +/// the secret through `{:?}`. A manual impl redacts the key material and +/// only surfaces the public address, so any accidental log or panic +/// formatting of a struct containing a `GatingSigner` stays safe. +pub struct GatingSigner { + inner: PrivateKeySigner, +} + +impl std::fmt::Debug for GatingSigner { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("GatingSigner") + .field("address", &self.inner.address()) + .field("key", &"") + .finish() + } +} + +#[derive(Debug, thiserror::Error)] +pub enum GatingSignerError { + #[error("invalid gating signer private key: {0}")] + InvalidKey(String), + #[error("signing failed: {0}")] + SignFailed(String), +} + +impl GatingSigner { + /// Parse a hex private key (with or without `0x` prefix). + pub fn from_hex_key(private_key: &str) -> Result { + let key = private_key.strip_prefix("0x").unwrap_or(private_key); + let inner: PrivateKeySigner = + key.parse() + .map_err(|e: alloy::signers::local::LocalSignerError| { + GatingSignerError::InvalidKey(e.to_string()) + })?; + Ok(Self { inner }) + } + + pub fn address(&self) -> Address { + self.inner.address() + } + + /// Sign `[recipient, orderHash, expiry, id]` as a four-entry signed + /// context. The signature is EIP-191 over `keccak256(abi.encodePacked(context))`, + /// matching `LibContext.build` in the orderbook contract. + pub async fn sign_gating_context( + &self, + recipient: Address, + order_hash: B256, + expiry: u64, + id: B256, + ) -> Result { + let context: [FixedBytes<32>; 4] = [ + address_to_b256(recipient), + order_hash, + u64_to_b256(expiry), + id, + ]; + + let mut packed = Vec::with_capacity(context.len() * 32); + for word in &context { + packed.extend_from_slice(word.as_slice()); + } + let hash = keccak256(&packed); + let signature = self + .inner + .sign_message(hash.as_slice()) + .await + .map_err(|e| GatingSignerError::SignFailed(e.to_string()))?; + + Ok(SignedGatingContext { + signer: self.address(), + context: context.to_vec(), + signature: signature.as_bytes().to_vec(), + }) + } +} + +#[derive(Debug, Clone)] +pub struct SignedGatingContext { + pub signer: Address, + pub context: Vec>, + pub signature: Vec, +} + +/// Compute the per-key id embedded in gating signatures: `keccak256(key_id)`. +pub fn compute_attribution_id(key_id: &str) -> B256 { + keccak256(key_id.as_bytes()) +} + +/// Bundle of runtime gating config held in rocket-managed state. +pub struct GatingState { + pub signer: GatingSigner, + pub ttl_seconds: u64, +} + +impl GatingState { + pub fn expiry_from_now(&self) -> u64 { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + now.saturating_add(self.ttl_seconds) + } +} + +fn address_to_b256(addr: Address) -> B256 { + let mut out = [0u8; 32]; + out[12..].copy_from_slice(addr.as_slice()); + B256::from(out) +} + +fn u64_to_b256(n: u64) -> B256 { + B256::from(U256::from(n)) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Hardhat account #0 — test only. + const TEST_KEY: &str = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + const HARDHAT_0: &str = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; + + #[test] + fn test_address_derivation() { + let signer = GatingSigner::from_hex_key(TEST_KEY).unwrap(); + assert_eq!(signer.address(), HARDHAT_0.parse::
().unwrap()); + } + + #[test] + fn test_key_with_0x_prefix() { + let signer = GatingSigner::from_hex_key(&format!("0x{TEST_KEY}")).unwrap(); + assert_eq!(signer.address(), HARDHAT_0.parse::
().unwrap()); + } + + #[test] + fn test_invalid_key() { + let err = GatingSigner::from_hex_key("not-hex").unwrap_err(); + assert!(matches!(err, GatingSignerError::InvalidKey(_))); + } + + #[tokio::test] + async fn test_sign_is_deterministic() { + let signer = GatingSigner::from_hex_key(TEST_KEY).unwrap(); + let recipient = "0x1111111111111111111111111111111111111111" + .parse::
() + .unwrap(); + let order_hash = B256::from([0x22u8; 32]); + let id = compute_attribution_id("test-key-id"); + + let a = signer + .sign_gating_context(recipient, order_hash, 1_700_000_000, id) + .await + .unwrap(); + let b = signer + .sign_gating_context(recipient, order_hash, 1_700_000_000, id) + .await + .unwrap(); + + assert_eq!(a.signature, b.signature); + assert_eq!(a.signer, b.signer); + assert_eq!(a.context, b.context); + assert_eq!(a.signature.len(), 65); + assert_eq!(a.context.len(), 4); + } + + #[tokio::test] + async fn test_different_inputs_produce_different_sigs() { + let signer = GatingSigner::from_hex_key(TEST_KEY).unwrap(); + let recipient = "0x1111111111111111111111111111111111111111" + .parse::
() + .unwrap(); + let order_hash_a = B256::from([0x22u8; 32]); + let order_hash_b = B256::from([0x33u8; 32]); + let id = compute_attribution_id("test-key-id"); + + let a = signer + .sign_gating_context(recipient, order_hash_a, 1_700_000_000, id) + .await + .unwrap(); + let b = signer + .sign_gating_context(recipient, order_hash_b, 1_700_000_000, id) + .await + .unwrap(); + assert_ne!(a.signature, b.signature); + } + + #[test] + fn test_attribution_id_is_keccak_of_key_id() { + let id = compute_attribution_id("abc123"); + let expected = keccak256(b"abc123"); + assert_eq!(id, expected); + } + + #[test] + fn test_address_to_b256_is_right_aligned() { + let addr: Address = "0x1111111111111111111111111111111111111111" + .parse() + .unwrap(); + let b = address_to_b256(addr); + // first 12 bytes zero, last 20 bytes = addr + assert_eq!(&b.as_slice()[..12], &[0u8; 12]); + assert_eq!(&b.as_slice()[12..], addr.as_slice()); + } +} diff --git a/src/test_helpers.rs b/src/test_helpers.rs index 0c80d77..467389d 100644 --- a/src/test_helpers.rs +++ b/src/test_helpers.rs @@ -1,7 +1,7 @@ use alloy::primitives::{Address, U256}; use base64::Engine; use rain_math_float::Float; -use rain_orderbook_bindings::IOrderBookV6::{EvaluableV4, OrderV4, IOV2}; +use rain_orderbook_bindings::IRaindexV6::{EvaluableV4, OrderV4, IOV2}; use rain_orderbook_common::raindex_client::orders::RaindexOrder; use rain_orderbook_common::take_orders::TakeOrderCandidate; use rocket::local::asynchronous::Client; @@ -57,8 +57,25 @@ impl TestClientBuilder { let shared_raindex = tokio::sync::RwLock::new(raindex_config); let docs_dir = std::env::temp_dir().to_string_lossy().into_owned(); - let rocket = crate::rocket(pool, self.rate_limiter, shared_raindex, docs_dir, None) - .expect("valid rocket instance"); + + // Deterministic hardhat account #0 — test-only signer. + const TEST_GATING_KEY: &str = + "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + let gating_state = crate::signing::GatingState { + signer: crate::signing::GatingSigner::from_hex_key(TEST_GATING_KEY) + .expect("parse test gating key"), + ttl_seconds: 60, + }; + + let rocket = crate::rocket( + pool, + self.rate_limiter, + shared_raindex, + gating_state, + docs_dir, + None, + ) + .expect("valid rocket instance"); Client::tracked(rocket).await.expect("valid client") } @@ -72,7 +89,7 @@ pub(crate) async fn mock_raindex_config() -> crate::raindex::RaindexProvider { } pub(crate) async fn mock_raindex_registry_url() -> String { - let settings = r#"version: 4 + let settings = r#"version: 5 networks: base: rpcs: @@ -207,7 +224,7 @@ pub(crate) fn basic_auth_header(key_id: &str, secret: &str) -> String { fn stub_raindex_client() -> serde_json::Value { json!({ "orderbook_yaml": { - "documents": ["version: 4\nnetworks:\n base:\n rpcs:\n - https://mainnet.base.org\n chain-id: 8453\n currency: ETH\nsubgraphs:\n base: https://example.com/sg\norderbooks:\n base:\n address: 0xd2938e7c9fe3597f78832ce780feb61945c377d7\n network: base\n subgraph: base\n deployment-block: 0\ndeployers:\n base:\n address: 0xC1A14cE2fd58A3A2f99deCb8eDd866204eE07f8D\n network: base\n"], + "documents": ["version: 5\nnetworks:\n base:\n rpcs:\n - https://mainnet.base.org\n chain-id: 8453\n currency: ETH\nsubgraphs:\n base: https://example.com/sg\norderbooks:\n base:\n address: 0xd2938e7c9fe3597f78832ce780feb61945c377d7\n network: base\n subgraph: base\n deployment-block: 0\ndeployers:\n base:\n address: 0xC1A14cE2fd58A3A2f99deCb8eDd866204eE07f8D\n network: base\n"], "profile": "strict" } }) @@ -310,5 +327,6 @@ pub(crate) fn mock_candidate(max_output: &str, ratio: &str) -> TakeOrderCandidat output_io_index: 0, max_output: Float::parse(max_output.to_string()).unwrap(), ratio: Float::parse(ratio.to_string()).unwrap(), + signed_context: vec![], } } diff --git a/src/types/swap.rs b/src/types/swap.rs index 9f83f2e..4e969c0 100644 --- a/src/types/swap.rs +++ b/src/types/swap.rs @@ -12,6 +12,16 @@ pub struct SwapQuoteRequest { pub output_token: Address, #[schema(example = "0.5")] pub output_amount: String, + /// Taker address used for pricing. For API-gated orders this must match + /// the address that will eventually call `takeOrders4`; otherwise the + /// gating signature produced for the quote won't validate at take time. + /// Omit for non-gated orders. + #[schema( + value_type = Option, + example = "0x1234567890abcdef1234567890abcdef12345678" + )] + #[serde(default)] + pub taker: Option
, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]