use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::error::ClientError;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OracleSource {
Binance,
Okx,
Bybit,
Coinbase,
Kraken,
Kucoin,
Gate,
Mexc,
Bitget,
MtfSpot,
}
impl OracleSource {
#[must_use]
pub const fn all() -> [Self; 10] {
[
Self::Binance,
Self::Okx,
Self::Bybit,
Self::Coinbase,
Self::Kraken,
Self::Kucoin,
Self::Gate,
Self::Mexc,
Self::Bitget,
Self::MtfSpot,
]
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Action {
PerpRegisterAsset {
asset_name: String,
asset_symbol: String,
decimals: u8,
},
PerpSetOracle {
asset_name: String,
oracle_sources: Vec<OracleSource>,
},
PerpSetLeverage {
asset_name: String,
max_leverage: u8,
},
PerpSetFees {
asset_name: String,
taker_fee_bps: i16,
maker_fee_bps: i16,
deployer_fee_bps: u16,
},
PerpSetMinOrderSize {
asset_name: String,
min_order_size: u128,
},
PerpSetFundingParams {
asset_name: String,
},
PerpRegisterMarket {
asset_name: String,
},
PerpActivateMarket {
asset_name: String,
},
SpotRegisterPair {
base_asset_id: u32,
quote_asset_id: u32,
name: String,
},
SpotSetFees {
name: String,
taker_fee_bps: i16,
maker_fee_bps: i16,
},
SpotSetMinNotional {
name: String,
min_notional_cents: u128,
},
SpotActivatePair {
name: String,
},
}
impl Action {
#[must_use]
pub fn to_json(&self) -> Value {
match self {
Self::PerpRegisterAsset {
asset_name,
asset_symbol,
decimals,
} => json!({
"type": "perp_register_asset",
"asset_name": asset_name,
"asset_symbol": asset_symbol,
"decimals": decimals,
}),
Self::PerpSetOracle {
asset_name,
oracle_sources,
} => json!({
"type": "perp_set_oracle",
"asset_name": asset_name,
"oracle_sources": oracle_sources,
}),
Self::PerpSetLeverage {
asset_name,
max_leverage,
} => json!({
"type": "perp_set_leverage",
"asset_name": asset_name,
"max_leverage": max_leverage,
}),
Self::PerpSetFees {
asset_name,
taker_fee_bps,
maker_fee_bps,
deployer_fee_bps,
} => json!({
"type": "perp_set_fees",
"asset_name": asset_name,
"taker_fee_bps": taker_fee_bps,
"maker_fee_bps": maker_fee_bps,
"deployer_fee_bps": deployer_fee_bps,
}),
Self::PerpSetMinOrderSize {
asset_name,
min_order_size,
} => json!({
"type": "perp_set_min_order_size",
"asset_name": asset_name,
"min_order_size": min_order_size,
}),
Self::PerpSetFundingParams { asset_name } => json!({
"type": "perp_set_funding_params",
"asset_name": asset_name,
}),
Self::PerpRegisterMarket { asset_name } => json!({
"type": "perp_register_market",
"asset_name": asset_name,
}),
Self::PerpActivateMarket { asset_name } => json!({
"type": "perp_activate_market",
"asset_name": asset_name,
}),
Self::SpotRegisterPair {
base_asset_id,
quote_asset_id,
name,
} => json!({
"type": "spot_register_pair",
"base_asset_id": base_asset_id,
"quote_asset_id": quote_asset_id,
"name": name,
}),
Self::SpotSetFees {
name,
taker_fee_bps,
maker_fee_bps,
} => json!({
"type": "spot_set_fees",
"name": name,
"taker_fee_bps": taker_fee_bps,
"maker_fee_bps": maker_fee_bps,
}),
Self::SpotSetMinNotional {
name,
min_notional_cents,
} => json!({
"type": "spot_set_min_notional",
"name": name,
"min_notional_cents": min_notional_cents,
}),
Self::SpotActivatePair { name } => json!({
"type": "spot_activate_pair",
"name": name,
}),
}
}
#[must_use]
pub fn type_id(&self) -> &'static str {
match self {
Self::PerpRegisterAsset { .. } => "perp_register_asset",
Self::PerpSetOracle { .. } => "perp_set_oracle",
Self::PerpSetLeverage { .. } => "perp_set_leverage",
Self::PerpSetFees { .. } => "perp_set_fees",
Self::PerpSetMinOrderSize { .. } => "perp_set_min_order_size",
Self::PerpSetFundingParams { .. } => "perp_set_funding_params",
Self::PerpRegisterMarket { .. } => "perp_register_market",
Self::PerpActivateMarket { .. } => "perp_activate_market",
Self::SpotRegisterPair { .. } => "spot_register_pair",
Self::SpotSetFees { .. } => "spot_set_fees",
Self::SpotSetMinNotional { .. } => "spot_set_min_notional",
Self::SpotActivatePair { .. } => "spot_activate_pair",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PerpDeployBuilder {
pub asset_name: String,
pub asset_symbol: String,
pub decimals: u8,
pub oracle_sources: Vec<OracleSource>,
pub max_leverage: u8,
pub taker_fee_bps: i16,
pub maker_fee_bps: i16,
pub min_order_size: u128,
pub deployer_fee_bps: u16,
}
impl PerpDeployBuilder {
#[allow(clippy::too_many_arguments)]
pub fn new(
asset_name: impl Into<String>,
asset_symbol: impl Into<String>,
decimals: u8,
oracle_sources: Vec<OracleSource>,
max_leverage: u8,
taker_fee_bps: i16,
maker_fee_bps: i16,
min_order_size: u128,
deployer_fee_bps: u16,
) -> Result<Self, ClientError> {
let me = Self {
asset_name: asset_name.into(),
asset_symbol: asset_symbol.into(),
decimals,
oracle_sources,
max_leverage,
taker_fee_bps,
maker_fee_bps,
min_order_size,
deployer_fee_bps,
};
me.validate()?;
Ok(me)
}
#[must_use]
pub fn with_asset_name(mut self, name: impl Into<String>) -> Self {
self.asset_name = name.into();
self
}
#[must_use]
pub fn with_asset_symbol(mut self, symbol: impl Into<String>) -> Self {
self.asset_symbol = symbol.into();
self
}
#[must_use]
pub fn with_max_leverage(mut self, lev: u8) -> Self {
self.max_leverage = lev;
self
}
#[must_use]
pub fn with_taker_fee_bps(mut self, bps: i16) -> Self {
self.taker_fee_bps = bps;
self
}
#[must_use]
pub fn with_maker_fee_bps(mut self, bps: i16) -> Self {
self.maker_fee_bps = bps;
self
}
#[must_use]
pub fn with_min_order_size(mut self, sz: u128) -> Self {
self.min_order_size = sz;
self
}
#[must_use]
pub fn with_deployer_fee_bps(mut self, bps: u16) -> Self {
self.deployer_fee_bps = bps;
self
}
#[must_use]
pub fn with_oracle_sources(mut self, sources: Vec<OracleSource>) -> Self {
self.oracle_sources = sources;
self
}
pub fn validate(&self) -> Result<(), ClientError> {
if self.asset_name.is_empty() {
return Err(ClientError::Validation("asset_name is empty".into()));
}
if self.asset_symbol.is_empty() {
return Err(ClientError::Validation("asset_symbol is empty".into()));
}
if self.decimals > 18 {
return Err(ClientError::Validation(format!(
"decimals {} exceeds max 18",
self.decimals
)));
}
if self.max_leverage == 0 || self.max_leverage > 50 {
return Err(ClientError::Validation(format!(
"max_leverage {} out of range [1, 50]",
self.max_leverage
)));
}
if self.taker_fee_bps < 0 || self.taker_fee_bps > 100 {
return Err(ClientError::Validation(format!(
"taker_fee_bps {} out of range [0, 100] (bps×10)",
self.taker_fee_bps
)));
}
if self.maker_fee_bps > 100 || self.maker_fee_bps < -20 {
return Err(ClientError::Validation(format!(
"maker_fee_bps {} out of range [-20, 100] (bps×10)",
self.maker_fee_bps
)));
}
if self.deployer_fee_bps > 50 {
return Err(ClientError::Validation(format!(
"deployer_fee_bps {} exceeds 5 bps cap (50 in bps×10)",
self.deployer_fee_bps
)));
}
if self.oracle_sources.is_empty() {
return Err(ClientError::Validation(
"oracle_sources cannot be empty".into(),
));
}
let mut sorted = self.oracle_sources.clone();
sorted.sort();
for w in sorted.windows(2) {
if w[0] == w[1] {
return Err(ClientError::Validation(format!(
"duplicate oracle source: {:?}",
w[0]
)));
}
}
Ok(())
}
#[must_use]
pub fn build_register_asset(&self) -> Action {
Action::PerpRegisterAsset {
asset_name: self.asset_name.clone(),
asset_symbol: self.asset_symbol.clone(),
decimals: self.decimals,
}
}
#[must_use]
pub fn build_set_oracle(&self) -> Action {
let mut sources = self.oracle_sources.clone();
sources.sort();
Action::PerpSetOracle {
asset_name: self.asset_name.clone(),
oracle_sources: sources,
}
}
#[must_use]
pub fn build_set_leverage(&self) -> Action {
Action::PerpSetLeverage {
asset_name: self.asset_name.clone(),
max_leverage: self.max_leverage,
}
}
#[must_use]
pub fn build_set_fees(&self) -> Action {
Action::PerpSetFees {
asset_name: self.asset_name.clone(),
taker_fee_bps: self.taker_fee_bps,
maker_fee_bps: self.maker_fee_bps,
deployer_fee_bps: self.deployer_fee_bps,
}
}
#[must_use]
pub fn build_set_min_order_size(&self) -> Action {
Action::PerpSetMinOrderSize {
asset_name: self.asset_name.clone(),
min_order_size: self.min_order_size,
}
}
#[must_use]
pub fn build_set_funding_params(&self) -> Action {
Action::PerpSetFundingParams {
asset_name: self.asset_name.clone(),
}
}
#[must_use]
pub fn build_register_market(&self) -> Action {
Action::PerpRegisterMarket {
asset_name: self.asset_name.clone(),
}
}
#[must_use]
pub fn build_activate_market(&self) -> Action {
Action::PerpActivateMarket {
asset_name: self.asset_name.clone(),
}
}
#[must_use]
pub fn deploy_sequence(&self) -> Vec<Action> {
vec![
self.build_register_asset(),
self.build_set_oracle(),
self.build_set_leverage(),
self.build_set_fees(),
self.build_set_min_order_size(),
self.build_set_funding_params(),
self.build_register_market(),
self.build_activate_market(),
]
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SpotDeployBuilder {
pub base_asset_id: u32,
pub quote_asset_id: u32,
pub name: String,
pub taker_fee_bps: i16,
pub maker_fee_bps: i16,
pub min_notional_cents: u128,
}
impl SpotDeployBuilder {
pub fn new(
base_asset_id: u32,
quote_asset_id: u32,
name: impl Into<String>,
taker_fee_bps: i16,
maker_fee_bps: i16,
min_notional_cents: u128,
) -> Result<Self, ClientError> {
let me = Self {
base_asset_id,
quote_asset_id,
name: name.into(),
taker_fee_bps,
maker_fee_bps,
min_notional_cents,
};
me.validate()?;
Ok(me)
}
pub fn validate(&self) -> Result<(), ClientError> {
if self.name.is_empty() {
return Err(ClientError::Validation("name is empty".into()));
}
if self.base_asset_id == self.quote_asset_id {
return Err(ClientError::Validation(format!(
"base ({}) == quote ({}) — degenerate pair",
self.base_asset_id, self.quote_asset_id
)));
}
if self.taker_fee_bps < 0 || self.taker_fee_bps > 100 {
return Err(ClientError::Validation(format!(
"taker_fee_bps {} out of range [0, 100] (bps×10)",
self.taker_fee_bps
)));
}
if self.maker_fee_bps > 100 || self.maker_fee_bps < -20 {
return Err(ClientError::Validation(format!(
"maker_fee_bps {} out of range [-20, 100] (bps×10)",
self.maker_fee_bps
)));
}
Ok(())
}
#[must_use]
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = name.into();
self
}
#[must_use]
pub fn with_base_asset_id(mut self, id: u32) -> Self {
self.base_asset_id = id;
self
}
#[must_use]
pub fn with_quote_asset_id(mut self, id: u32) -> Self {
self.quote_asset_id = id;
self
}
#[must_use]
pub fn with_taker_fee_bps(mut self, bps: i16) -> Self {
self.taker_fee_bps = bps;
self
}
#[must_use]
pub fn with_maker_fee_bps(mut self, bps: i16) -> Self {
self.maker_fee_bps = bps;
self
}
#[must_use]
pub fn with_min_notional_cents(mut self, n: u128) -> Self {
self.min_notional_cents = n;
self
}
#[must_use]
pub fn build_register_pair(&self) -> Action {
Action::SpotRegisterPair {
base_asset_id: self.base_asset_id,
quote_asset_id: self.quote_asset_id,
name: self.name.clone(),
}
}
#[must_use]
pub fn build_set_fees(&self) -> Action {
Action::SpotSetFees {
name: self.name.clone(),
taker_fee_bps: self.taker_fee_bps,
maker_fee_bps: self.maker_fee_bps,
}
}
#[must_use]
pub fn build_set_min_notional(&self) -> Action {
Action::SpotSetMinNotional {
name: self.name.clone(),
min_notional_cents: self.min_notional_cents,
}
}
#[must_use]
pub fn build_activate_pair(&self) -> Action {
Action::SpotActivatePair {
name: self.name.clone(),
}
}
#[must_use]
pub fn deploy_sequence(&self) -> Vec<Action> {
vec![
self.build_register_pair(),
self.build_set_fees(),
self.build_set_min_notional(),
self.build_activate_pair(),
]
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ok_perp() -> PerpDeployBuilder {
PerpDeployBuilder::new(
"FOO-PERP",
"FOO",
8,
vec![OracleSource::Binance, OracleSource::Okx],
10,
45,
15,
1_000,
5,
)
.unwrap()
}
#[test]
fn perp_builder_validates_leverage_range() {
let e = PerpDeployBuilder::new(
"X-PERP",
"X",
8,
vec![OracleSource::Binance],
51, 10,
10,
10,
5,
)
.unwrap_err();
assert!(matches!(e, ClientError::Validation(_)));
}
#[test]
fn perp_builder_validates_taker_fee_cap() {
let e = PerpDeployBuilder::new(
"X-PERP",
"X",
8,
vec![OracleSource::Binance],
10,
101, 10,
10,
5,
)
.unwrap_err();
assert!(matches!(e, ClientError::Validation(_)));
}
#[test]
fn perp_builder_validates_deployer_cap() {
let e = PerpDeployBuilder::new(
"X-PERP",
"X",
8,
vec![OracleSource::Binance],
10,
10,
10,
10,
60, )
.unwrap_err();
assert!(matches!(e, ClientError::Validation(_)));
}
#[test]
fn perp_builder_rejects_empty_oracle_sources() {
let e = PerpDeployBuilder::new("X-PERP", "X", 8, vec![], 10, 10, 10, 10, 5).unwrap_err();
assert!(matches!(e, ClientError::Validation(_)));
}
#[test]
fn perp_builder_rejects_duplicate_oracle_sources() {
let e = PerpDeployBuilder::new(
"X-PERP",
"X",
8,
vec![OracleSource::Binance, OracleSource::Binance],
10,
10,
10,
10,
5,
)
.unwrap_err();
assert!(matches!(e, ClientError::Validation(_)));
}
#[test]
fn perp_builder_allows_negative_maker_rebate() {
let b = PerpDeployBuilder::new(
"X-PERP",
"X",
8,
vec![OracleSource::Binance],
10,
10,
-10, 10,
5,
);
assert!(b.is_ok());
}
#[test]
fn perp_deploy_sequence_has_eight_actions_in_order() {
let b = ok_perp();
let seq = b.deploy_sequence();
assert_eq!(seq.len(), 8);
assert_eq!(
seq.iter().map(Action::type_id).collect::<Vec<_>>(),
vec![
"perp_register_asset",
"perp_set_oracle",
"perp_set_leverage",
"perp_set_fees",
"perp_set_min_order_size",
"perp_set_funding_params",
"perp_register_market",
"perp_activate_market",
]
);
}
#[test]
fn perp_action_json_uses_snake_case_and_plain_integers() {
let b = ok_perp();
let json = b.build_set_fees().to_json();
assert!(json.get("taker_fee_bps").is_some());
assert!(json.get("maker_fee_bps").is_some());
assert!(json.get("deployer_fee_bps").is_some());
assert!(json["taker_fee_bps"].is_number());
assert!(json["maker_fee_bps"].is_number());
}
#[test]
fn perp_set_oracle_sorts_sources_deterministically() {
let b = PerpDeployBuilder::new(
"X-PERP",
"X",
8,
vec![
OracleSource::Okx,
OracleSource::Binance,
OracleSource::Coinbase,
],
10,
10,
10,
10,
5,
)
.unwrap();
let a = b.build_set_oracle();
let b2 = b.build_set_oracle();
assert_eq!(a, b2, "deterministic output");
let j = a.to_json();
let sources: Vec<String> = j["oracle_sources"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect();
assert_eq!(sources, vec!["binance", "okx", "coinbase"]);
}
#[test]
fn perp_with_chaining_preserves_other_fields() {
let b = ok_perp().with_asset_name("BAR-PERP").with_max_leverage(20);
assert_eq!(b.asset_name, "BAR-PERP");
assert_eq!(b.max_leverage, 20);
assert_eq!(b.asset_symbol, "FOO"); }
#[test]
fn spot_builder_rejects_same_base_quote() {
let e = SpotDeployBuilder::new(1, 1, "X-X", 10, 10, 1000).unwrap_err();
assert!(matches!(e, ClientError::Validation(_)));
}
#[test]
fn spot_deploy_sequence_has_four_actions() {
let b = SpotDeployBuilder::new(2, 0, "ETH/USDC", 10, -5, 1_000).unwrap();
let seq = b.deploy_sequence();
assert_eq!(seq.len(), 4);
assert_eq!(
seq.iter().map(Action::type_id).collect::<Vec<_>>(),
vec![
"spot_register_pair",
"spot_set_fees",
"spot_set_min_notional",
"spot_activate_pair",
]
);
}
#[test]
fn action_type_id_matches_json_type_field() {
let a = Action::PerpRegisterAsset {
asset_name: "FOO".into(),
asset_symbol: "F".into(),
decimals: 8,
};
assert_eq!(a.type_id(), "perp_register_asset");
assert_eq!(a.to_json()["type"], "perp_register_asset");
}
#[test]
fn oracle_source_all_returns_ten_sources() {
assert_eq!(OracleSource::all().len(), 10);
let mut sorted = OracleSource::all().to_vec();
sorted.sort();
for w in sorted.windows(2) {
assert_ne!(w[0], w[1]);
}
}
}