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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
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=
16 changes: 3 additions & 13 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions config/dev.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions config/rest-api.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions dev-config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion lib/rain.orderbook
80 changes: 78 additions & 2 deletions src/config.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ST0X_GATING_SIGNER_KEY is 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.

Copy link
Copy Markdown
Contributor Author

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.

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<Self, String> {
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);
}
}
30 changes: 30 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ mod error;
mod fairings;
mod raindex;
mod routes;
mod signing;
mod telemetry;
mod types;

Expand Down Expand Up @@ -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> {
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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") {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This introduces a new required env var, but .env.example was not updated to document it. Our repo rules explicitly require using .env.example for env-var documentation:

st0x.rest.api/AGENTS.md

Lines 14 to 20 in 54023a8

## Code Rules
- Never use `expect` or `unwrap` in production code; handle errors gracefully or exit with a message
- Every route handler must log appropriately using tracing (request received, errors, key decisions)
- All async route handlers must use `TracingSpan` and `.instrument(span.0)` for span propagation
- All API errors must go through the `ApiError` enum, never return raw status codes
- Keep OpenAPI annotations (`#[utoipa::path(...)]`) in sync when adding or modifying routes
- Do not commit `.env` or secrets; use `.env.example` for documenting env vars
. As written, anyone deploying or bootstrapping from .env.example will miss ST0X_GATING_SIGNER_KEY and the server now exits at startup.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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,
) {
Expand Down
126 changes: 126 additions & 0 deletions src/raindex/gating_injector.rs
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);
}
}
2 changes: 2 additions & 0 deletions src/raindex/mod.rs
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>;
2 changes: 1 addition & 1 deletion src/routes/order/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
})
Expand Down
Loading
Loading