Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,7 @@ Get current fee pricing for a token on a chain.
|-------|-------------|
| `chainId` | Chain the fee data applies to |
| `token` | Token descriptor (`address`, `decimals`) |
| `rate` | Tokens per 1 unit of native currency (e.g. USDC/ETH ≈ 2000.5); always `1.0` for native |
| `rate` | Tokens per 1 unit of native currency (e.g. USDC/ETH ≈ 2000.5), rounded to 6 decimal places; always `1.0` for native |
| `gasPrice` | Current gas price in wei (decimal string) |
| `maxFeePerGas` | EIP-1559 max fee per gas in wei (decimal string, omitted on pre-EIP-1559 chains) |
| `maxPriorityFeePerGas` | EIP-1559 max priority fee per gas in wei (decimal string, omitted on pre-EIP-1559 chains) |
Expand Down
2 changes: 1 addition & 1 deletion docs/RELAYER_GET_EXCHANGE_RATE_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ When an error occurs, the result array can contain an error object:

### quote.rate

The exchange rate represents how much of the specified token is needed per unit of gas. For example:
The exchange rate represents how much of the specified token is needed per unit of gas. RelayX rounds this numeric value to 6 decimal places before returning it. For example:
- A rate of `0.001` for ETH means 0.001 ETH per gas unit
- A rate of `0.0032` for USDC means 0.0032 USDC per gas unit

Expand Down
81 changes: 71 additions & 10 deletions src/methods/fee_data.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use alloy::{
primitives::{Address, Bytes},
primitives::{Address, Bytes, U256},
providers::{Provider, ProviderBuilder},
rpc::types::TransactionRequest,
};
Expand All @@ -18,6 +18,49 @@ use crate::{
Config, FeeDataParams, FeeDataResponse, GasFees, TokenDetails,
};

const RATE_DECIMALS: usize = 6;

fn pow10_u256(exp: u32) -> Option<U256> {
let mut result = U256::from(1u8);
for _ in 0..exp {
result = result.checked_mul(U256::from(10u8))?;
}
Some(result)
}

fn scaled_u256_to_f64(value: U256, decimals: usize) -> Option<f64> {
let digits = value.to_string();
let decimal = if decimals == 0 {
digits
} else if digits.len() > decimals {
let split = digits.len() - decimals;
format!("{}.{}", &digits[..split], &digits[split..])
} else {
format!("0.{digits:0>width$}", width = decimals)
};

decimal.parse().ok()
}

fn rounded_rate_from_oracle_answers(
native_px: i128,
native_dec: u8,
token_px: i128,
token_dec: u8,
) -> Option<f64> {
if native_px <= 0 || token_px <= 0 {
return None;
}

// Keep the ratio exact in integer space, then round once to the public 6-decimal API.
let numerator = U256::from(native_px as u128)
.checked_mul(pow10_u256(token_dec as u32 + RATE_DECIMALS as u32)?)?;
let denominator = U256::from(token_px as u128).checked_mul(pow10_u256(native_dec as u32)?)?;
let rounded_scaled = numerator.checked_add(denominator / U256::from(2u8))? / denominator;

scaled_u256_to_f64(rounded_scaled, RATE_DECIMALS)
}

/// Build a spec-compliant relayer_getFeeData response.
pub async fn build_fee_data_response(
cfg: &Config,
Expand Down Expand Up @@ -141,16 +184,11 @@ pub async fn build_fee_data_response(
let native_px = read_latest_answer(&rpc_url, &native_feed_addr).await;
let token_px = read_latest_answer(&rpc_url, &token_feed_addr).await;

let (native_usd, token_usd) = match (native_px, token_px) {
(Some(n), Some(t)) if n > 0 && t > 0 => (
n as f64 / 10f64.powi(native_dec as i32),
t as f64 / 10f64.powi(token_dec as i32),
),
let rate = match (native_px, token_px) {
(Some(n), Some(t)) => rounded_rate_from_oracle_answers(n, native_dec, t, token_dec),
_ => return Err(unsupported_payment_token_error()),
};

// rate = tokens per 1 native = native_usd / token_usd
let rate = native_usd / token_usd;
}
.ok_or_else(unsupported_payment_token_error)?;

async fn read_erc20_decimals(rpc_url: &str, token: &str) -> Option<u8> {
let sel: [u8; 4] = [0x31, 0x3c, 0xe5, 0x67];
Expand Down Expand Up @@ -181,3 +219,26 @@ pub async fn build_fee_data_response(
context: None,
})
}

#[cfg(test)]
mod tests {
use super::rounded_rate_from_oracle_answers;

#[test]
fn rounds_rate_half_up_to_six_decimals() {
let rate = rounded_rate_from_oracle_answers(123_456_750, 8, 100_000_000, 8).unwrap();
assert!((rate - 1.234568).abs() < 1e-12);
}

#[test]
fn rounds_recurring_decimal_rate_deterministically() {
let rate = rounded_rate_from_oracle_answers(200_000_000, 8, 300_000_000, 8).unwrap();
assert!((rate - 0.666667).abs() < 1e-12);
}

#[test]
fn rejects_non_positive_oracle_answers() {
assert!(rounded_rate_from_oracle_answers(0, 8, 1, 8).is_none());
assert!(rounded_rate_from_oracle_answers(1, 8, -1, 8).is_none());
}
}
2 changes: 1 addition & 1 deletion src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ pub struct FeeDataResponse {
pub chain_id: String,
pub token: TokenDetails,
/// Tokens per 1 unit of native currency (e.g., USDC/ETH = 2000.5).
/// For native token payments this is always 1.0.
/// Rounded to 6 decimal places; for native token payments this is always 1.0.
pub rate: f64,
/// Minimum fee denominated in token units (human-readable), if applicable.
#[serde(rename = "minFee", skip_serializing_if = "Option::is_none")]
Expand Down
Loading