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
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 SO4 Markets

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ KEEPER_SECRET_KEY=<S...-strkey seed>
KEEPER_ACCOUNT_ID=<G...-public key>
KEEPER_INDEX=0
MIN_KEEPER_BALANCE_XLM=10
# Optional inclusion fees (stroops); defaults match historical hardcoded values
SET_PRICES_TX_FEE=1000000
KEEPER_TX_FEE=2000000

# API configuration
BIND_ADDR=0.0.0.0:8080
Expand Down
1 change: 1 addition & 0 deletions oracle/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ name = "oracle"
version = "0.1.0"
edition = "2021"
authors = ["0xsteins"]
license = "MIT"

[lints]
workspace = true
Expand Down
51 changes: 34 additions & 17 deletions oracle/src/binance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ pub const FLOAT_PRECISION: i128 = 1_000_000_000_000_000_000_000_000_000_000;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BinancePriceError {
NetworkError(String),
HttpError(u16),
HttpError { status: u16, body: String },
JsonError(String),
PriceParseError(String),
}
Expand All @@ -15,7 +15,13 @@ impl std::fmt::Display for BinancePriceError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NetworkError(error) => write!(f, "Binance network error: {error}"),
Self::HttpError(status) => write!(f, "Binance returned HTTP {status}"),
Self::HttpError { status, body } => {
if body.is_empty() {
write!(f, "Binance returned HTTP {status}")
} else {
write!(f, "Binance returned HTTP {status}: {body}")
}
}
Self::JsonError(error) => write!(f, "invalid Binance response: {error}"),
Self::PriceParseError(error) => write!(f, "invalid Binance price: {error}"),
}
Expand Down Expand Up @@ -77,7 +83,10 @@ pub fn parse_ticker_http_response(
symbols: &[String],
) -> Result<Vec<(String, i128)>, BinancePriceError> {
if status_code != 200 {
return Err(BinancePriceError::HttpError(status_code));
return Err(BinancePriceError::HttpError {
status: status_code,
body: crate::http::truncate_error_body(body),
});
}
parse_ticker_response_body(body, symbols)
}
Expand All @@ -94,13 +103,17 @@ pub fn parse_ticker_http_result(
// query through reqwest instead.
#[cfg(test)]
fn build_spot_price_url(symbols: &[String]) -> String {
build_spot_price_url_for(BINANCE_TICKER_PRICE_URL, symbols)
}

fn build_spot_price_url_for(base_url: &str, symbols: &[String]) -> String {
if symbols.len() == 1 {
format!("{}?symbol={}", BINANCE_TICKER_PRICE_URL, symbols[0])
format!("{}?symbol={}", base_url, symbols[0])
} else {
format!(
"{}?symbols={}",
BINANCE_TICKER_PRICE_URL,
percent_encode_query_value(&serde_json::to_string(symbols).unwrap_or_default())
base_url,
serde_json::to_string(symbols).unwrap()
)
}
}
Expand Down Expand Up @@ -136,14 +149,9 @@ pub(crate) async fn fetch_spot_prices_with_url(
base_url: &str,
symbols: &[String],
) -> Result<Vec<(String, i128)>, BinancePriceError> {
let mut req = crate::http::client().get(base_url);
if symbols.len() == 1 {
req = req.query(&[("symbol", &symbols[0])]);
} else {
let symbols_json = serde_json::to_string(symbols).unwrap_or_default();
req = req.query(&[("symbols", &symbols_json)]);
}
let response = req
let url = build_spot_price_url_for(base_url, symbols);
let response = crate::http::client()
.get(&url)
.send()
.await
.map_err(|err| BinancePriceError::NetworkError(err.to_string()))?;
Expand Down Expand Up @@ -364,7 +372,10 @@ mod tests {
fn parse_ticker_http_response_non_200_returns_error() {
let symbols = vec!["BTCUSDT".to_string()];
let err = parse_ticker_http_response(503, "[]", &symbols).unwrap_err();
assert_eq!(err, BinancePriceError::HttpError(503));
assert!(matches!(
err,
BinancePriceError::HttpError { status: 503, .. }
));
}

#[test]
Expand Down Expand Up @@ -438,7 +449,10 @@ mod tests {
let err = super::fetch_spot_prices_with_url(&server.uri(), &symbols)
.await
.unwrap_err();
assert_eq!(err, BinancePriceError::HttpError(404));
assert!(matches!(
err,
BinancePriceError::HttpError { status: 404, .. }
));
}

#[tokio::test]
Expand All @@ -457,7 +471,10 @@ mod tests {
let err = super::fetch_spot_prices_with_url(&server.uri(), &symbols)
.await
.unwrap_err();
assert_eq!(err, BinancePriceError::HttpError(500));
assert!(matches!(
err,
BinancePriceError::HttpError { status: 500, .. }
));
}

#[test]
Expand Down
30 changes: 24 additions & 6 deletions oracle/src/coinbase.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ pub const COINBASE_EXCHANGE_RATES_URL: &str =
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CoinbasePriceError {
NetworkError(String),
HttpError(u16),
HttpError { status: u16, body: String },
JsonError(String),
PriceParseError(String),
MissingUsdRate,
Expand All @@ -16,7 +16,13 @@ impl std::fmt::Display for CoinbasePriceError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NetworkError(error) => write!(f, "Coinbase network error: {error}"),
Self::HttpError(status) => write!(f, "Coinbase returned HTTP {status}"),
Self::HttpError { status, body } => {
if body.is_empty() {
write!(f, "Coinbase returned HTTP {status}")
} else {
write!(f, "Coinbase returned HTTP {status}: {body}")
}
}
Self::JsonError(error) => write!(f, "invalid Coinbase response: {error}"),
Self::PriceParseError(error) => write!(f, "invalid Coinbase price: {error}"),
Self::MissingUsdRate => f.write_str("Coinbase response has no USD rate"),
Expand Down Expand Up @@ -72,7 +78,10 @@ pub fn parse_coinbase_http_response(
body: &str,
) -> Result<i128, CoinbasePriceError> {
if status_code != 200 {
return Err(CoinbasePriceError::HttpError(status_code));
return Err(CoinbasePriceError::HttpError {
status: status_code,
body: crate::http::truncate_error_body(body),
});
}
parse_coinbase_response_body(body)
}
Expand Down Expand Up @@ -197,7 +206,10 @@ mod tests {
#[test]
fn test_parse_coinbase_http_response_non_200() {
let err = parse_coinbase_http_response(404, "{}").unwrap_err();
assert_eq!(err, CoinbasePriceError::HttpError(404));
assert!(matches!(
err,
CoinbasePriceError::HttpError { status: 404, .. }
));
}

#[test]
Expand Down Expand Up @@ -421,7 +433,10 @@ mod tests {
let err = super::fetch_spot_price_with_url(&base_url, "BTC")
.await
.unwrap_err();
assert_eq!(err, CoinbasePriceError::HttpError(404));
assert!(matches!(
err,
CoinbasePriceError::HttpError { status: 404, .. }
));
}

#[tokio::test]
Expand All @@ -440,6 +455,9 @@ mod tests {
let err = super::fetch_spot_price_with_url(&base_url, "BTC")
.await
.unwrap_err();
assert_eq!(err, CoinbasePriceError::HttpError(500));
assert!(matches!(
err,
CoinbasePriceError::HttpError { status: 500, .. }
));
}
}
48 changes: 47 additions & 1 deletion oracle/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ pub const DEFAULT_TESTNET_HORIZON_URL: &str = "https://horizon-testnet.stellar.o
pub const DEFAULT_MAINNET_HORIZON_URL: &str = "https://horizon.stellar.org";
pub const DEFAULT_PRICE_LOOP_MS: u64 = 1_000;
pub const DEFAULT_KEEPER_LOOP_MS: u64 = 1_500;
/// Default inclusion fee (stroops) for `set_prices` transactions.
pub const DEFAULT_SET_PRICES_TX_FEE: u32 = 1_000_000;
/// Default inclusion fee (stroops) for keeper handler transactions.
pub const DEFAULT_KEEPER_TX_FEE: u32 = 2_000_000;

/// Oracle-specific view of a token feed config.
/// Re-exports fields from `TokenConfig` for backward compatibility with
Expand Down Expand Up @@ -80,6 +84,10 @@ pub struct Config {
/// API key used to authenticate requests to the production Hermes endpoint.
pub pyth_api_key: Option<SecretString>,
pub min_keeper_balance_xlm: f64,
/// Inclusion fee (stroops) for oracle `set_prices` transactions.
pub set_prices_tx_fee: u32,
/// Inclusion fee (stroops) for keeper handler execute/freeze transactions.
pub keeper_tx_fee: u32,
pub price_loop_interval: Duration,
pub keeper_loop_interval: Duration,
pub price_feed: PriceFeedConfig,
Expand Down Expand Up @@ -193,7 +201,7 @@ impl Config {
};

let price_feed = collect_or_default!(
load_price_feed_config(lookup("PRICE_FEED_CONFIG").as_deref()),
load_price_feed_config(lookup("PRICE_FEED_CONFIG").as_deref()).map_err(EnvError::from),
PriceFeedConfig { tokens: vec![] }
);

Expand Down Expand Up @@ -273,6 +281,42 @@ impl Config {
DEFAULT_MIN_KEEPER_BALANCE_XLM
);

let set_prices_tx_fee: u32 = collect_or_default!(
(|| {
let fee: u32 = parse_or_default(
&mut lookup,
"SET_PRICES_TX_FEE",
&DEFAULT_SET_PRICES_TX_FEE.to_string(),
)?;
if fee == 0 {
return Err(EnvError::InvalidVar {
var: "SET_PRICES_TX_FEE",
reason: "must be greater than 0".to_string(),
});
}
Ok(fee)
})(),
DEFAULT_SET_PRICES_TX_FEE
);

let keeper_tx_fee: u32 = collect_or_default!(
(|| {
let fee: u32 = parse_or_default(
&mut lookup,
"KEEPER_TX_FEE",
&DEFAULT_KEEPER_TX_FEE.to_string(),
)?;
if fee == 0 {
return Err(EnvError::InvalidVar {
var: "KEEPER_TX_FEE",
reason: "must be greater than 0".to_string(),
});
}
Ok(fee)
})(),
DEFAULT_KEEPER_TX_FEE
);

let price_loop_interval = collect_or_default!(
(|| {
let ms: u64 = parse_or_default(
Expand Down Expand Up @@ -333,6 +377,8 @@ impl Config {
admin_api_token,
pyth_api_key,
min_keeper_balance_xlm,
set_prices_tx_fee,
keeper_tx_fee,
price_loop_interval,
keeper_loop_interval,
price_feed,
Expand Down
12 changes: 12 additions & 0 deletions oracle/src/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@
use std::sync::OnceLock;
use std::time::Duration;

/// Max chars of an HTTP error body retained in error variants / logs.
pub const HTTP_ERROR_BODY_MAX_CHARS: usize = 512;

/// Truncate a response body for inclusion in error types.
pub fn truncate_error_body(body: &str) -> String {
let trimmed = body.trim();
if trimmed.chars().count() <= HTTP_ERROR_BODY_MAX_CHARS {
return trimmed.to_string();
}
trimmed.chars().take(HTTP_ERROR_BODY_MAX_CHARS).collect()
}

/// Returns the process-wide shared HTTP client, initializing it on first use (resolves #355).
pub fn client() -> &'static reqwest::Client {
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
Expand Down
Loading