-
Notifications
You must be signed in to change notification settings - Fork 2
feat: API gating signer + signed context injection for take-orders #78
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: 2026-04-25-fix-ci
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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= |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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<direct_trades::DirectTradesFetcher>, | ||||||||||||||||
| ) -> Result<rocket::Rocket<rocket::Build>, 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") { | ||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This introduces a new required env var, but Lines 14 to 20 in 54023a8
.env.example will miss ST0X_GATING_SIGNER_KEY and the server now exits at startup.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added to .env.example in 5627e0c with a comment explaining the purpose + failure mode. Thanks for the catch. |
||||||||||||||||
| 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, | ||||||||||||||||
| ) { | ||||||||||||||||
|
|
||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<SignedContextV1> { | ||
| 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); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<RaindexProvider>; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ST0X_GATING_SIGNER_KEYis now required at startup, but .env.example was not updated to document it. That will break fresh setup/deploy flows that rely on .env.example, and it violates the repo rule to use .env.example for documenting env vars.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same fix — .env.example updated in 5627e0c.