Skip to content
Merged
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
174 changes: 173 additions & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,12 @@ pub enum DataKey {
/// seconds have elapsed since `PairLastRouteAt`. Capped at
/// `MAX_COOLDOWN_SECS` (30 days). Defaults to `0` (disabled).
PairCooldown(Symbol, Symbol),
/// `true` when `(source, destination)` has an active dispute flag
/// (keyed per-pair, `bool`, persistent). Set by `flag_pair_dispute`
/// and cleared by `resolve_pair_dispute` or `unregister_pair`. While
/// `true`, `quote_route` and `compute_route_fee` reject the pair with
/// `PairDisputed`. Defaults to `false`.
PairDisputed(Symbol, Symbol),
/// Optional absolute per-route fee ceiling (singleton, `i128`,
/// persistent). When set, the effective fee is `min(bps_fee, cap)`.
/// Absent ↔ `None` (only the relative `MAX_FEE_BPS` bound applies).
Expand Down Expand Up @@ -314,6 +320,9 @@ pub enum RouterError {
/// route free. Use [`StableRouteRouter::clear_max_fee_absolute`] to
/// remove the cap entirely.
ZeroFeeCap = 21,
/// `quote_route` or `compute_route_fee` was called for a pair with an
/// active dispute flag (see [`StableRouteRouter::flag_pair_dispute`]).
PairDisputed = 22,
}

/// StableRoute router contract — placeholder for routing logic.
Expand Down Expand Up @@ -624,6 +633,46 @@ impl StableRouteRouter {
);
}

/// Admin flags `(source, destination)` as disputed. While disputed,
/// [`Self::quote_route`] and [`Self::compute_route_fee`] reject the
/// pair with [`RouterError::PairDisputed`] until
/// [`Self::resolve_pair_dispute`] clears the flag. Requires the pair
/// to already be registered.
///
/// Idempotent on the event: flagging an already-disputed pair
/// re-asserts the flag but does not re-emit `disp_set`.
pub fn flag_pair_dispute(env: Env, source: Symbol, destination: Symbol) {
Self::require_admin(&env);
Self::require_pair_registered(&env, &source, &destination);
let already_disputed = Self::read_pair_disputed(&env, &source, &destination);
env.storage().persistent().set(
&DataKey::PairDisputed(source.clone(), destination.clone()),
&true,
);
if !already_disputed {
env.events()
.publish((symbol_short!("disp_set"),), (source, destination, true));
}
}

/// Admin clears a dispute flag, restoring normal routing and quoting
/// for the pair.
///
/// Idempotent: resolving a pair with no active dispute is a clean
/// no-op that does not emit `disp_set`.
pub fn resolve_pair_dispute(env: Env, source: Symbol, destination: Symbol) {
Self::require_admin(&env);
let was_disputed = Self::read_pair_disputed(&env, &source, &destination);
env.storage().persistent().set(
&DataKey::PairDisputed(source.clone(), destination.clone()),
&false,
);
if was_disputed {
env.events()
.publish((symbol_short!("disp_set"),), (source, destination, false));
}
}

/// Configure an absolute upper bound on the fee charged for any route.
///
/// Admin-gated. The computed fee is clamped to this value after the
Expand Down Expand Up @@ -1097,6 +1146,14 @@ impl StableRouteRouter {
Self::read_pair_registered(&env, &source, &destination)
}

/// Returns `true` if `(source, destination)` currently has an active
/// dispute flag set via [`Self::flag_pair_dispute`].
///
/// Does not mutate storage. Defaults to `false`.
pub fn is_pair_disputed(env: Env, source: Symbol, destination: Symbol) -> bool {
Self::read_pair_disputed(&env, &source, &destination)
}

/// Returns the configured routing fee for the pair, expressed in basis
/// points.
///
Expand Down Expand Up @@ -1125,6 +1182,9 @@ impl StableRouteRouter {
panic_with_error!(&env, RouterError::AmountMustBePositive);
}
Self::require_pair_registered(&env, &source, &destination);
if Self::read_pair_disputed(&env, &source, &destination) {
panic_with_error!(&env, RouterError::PairDisputed);
}
if matches!(
env.storage()
.persistent()
Expand Down Expand Up @@ -1211,6 +1271,9 @@ impl StableRouteRouter {
if !Self::read_pair_registered(&env, &source, &destination) {
Self::route_abort(&env, RouterError::PairNotRegistered);
}
if Self::read_pair_disputed(&env, &source, &destination) {
Self::route_abort(&env, RouterError::PairDisputed);
}
let min_amount = Self::read_pair_min(&env, &source, &destination);
if amount < min_amount {
Self::route_abort(&env, RouterError::AmountBelowMin);
Expand Down Expand Up @@ -1510,6 +1573,18 @@ impl StableRouteRouter {
.unwrap_or(false)
}

/// Read whether `(source, destination)` currently has an active
/// dispute flag. Single source of truth for the
/// [`DataKey::PairDisputed`] sentinel value; shared by `quote_route`
/// and `compute_route_fee` instead of each inlining its own storage
/// read.
fn read_pair_disputed(env: &Env, source: &Symbol, destination: &Symbol) -> bool {
env.storage()
.persistent()
.get(&DataKey::PairDisputed(source.clone(), destination.clone()))
.unwrap_or(false)
}

/// Read the per-pair fee in basis points from persistent storage.
///
/// Returns `0` (free) when the slot is absent — the documented
Expand Down Expand Up @@ -1619,7 +1694,8 @@ impl StableRouteRouter {
storage.remove(&DataKey::PairMinAmount(source.clone(), destination.clone()));
storage.remove(&DataKey::PairMaxAmount(source.clone(), destination.clone()));
storage.remove(&DataKey::PairLiquidity(source.clone(), destination.clone()));
storage.remove(&DataKey::PairCooldown(source, destination));
storage.remove(&DataKey::PairCooldown(source.clone(), destination.clone()));
storage.remove(&DataKey::PairDisputed(source, destination));
}

/// Remove a registered pair from the router.
Expand Down Expand Up @@ -2262,6 +2338,102 @@ mod test {
client.register_pair(&symbol_short!("USDC"), &symbol_short!("USDC"));
}

// --- dispute flag ---

#[test]
fn test_is_pair_disputed_defaults_to_false() {
let env = Env::default();
let (client, _admin) = setup_initialized(&env);
let (s, d) = (symbol_short!("USDC"), symbol_short!("EURC"));
client.register_pair(&s, &d);
assert!(!client.is_pair_disputed(&s, &d));
}

#[test]
fn test_flag_pair_dispute_blocks_quote_and_compute() {
let env = Env::default();
let (client, _admin) = setup_initialized(&env);
let (s, d) = (symbol_short!("USDC"), symbol_short!("EURC"));
client.register_pair(&s, &d);
client.flag_pair_dispute(&s, &d);
assert!(client.is_pair_disputed(&s, &d));

let quote_result = client.try_quote_route(&s, &d, &1_000i128);
assert!(quote_result.is_err(), "disputed pair must reject quote_route");

let compute_result = client.try_compute_route_fee(&s, &d, &1_000i128);
assert!(
compute_result.is_err(),
"disputed pair must reject compute_route_fee"
);
}

#[test]
fn test_resolve_pair_dispute_restores_routing() {
let env = Env::default();
let (client, _admin) = setup_initialized(&env);
let (s, d) = (symbol_short!("USDC"), symbol_short!("EURC"));
client.register_pair(&s, &d);
client.flag_pair_dispute(&s, &d);
client.resolve_pair_dispute(&s, &d);
assert!(!client.is_pair_disputed(&s, &d));
// Routing works again once resolved.
client.quote_route(&s, &d, &1_000i128);
}

#[test]
#[should_panic(expected = "Error(Contract, #5)")]
fn test_flag_pair_dispute_requires_registered_pair() {
let env = Env::default();
let (client, _admin) = setup_initialized(&env);
client.flag_pair_dispute(&symbol_short!("USDC"), &symbol_short!("EURC"));
}

#[test]
fn test_flag_pair_dispute_does_not_duplicate_event_on_reflag() {
let env = Env::default();
let (client, _admin) = setup_initialized(&env);
let (s, d) = (symbol_short!("USDC"), symbol_short!("EURC"));
client.register_pair(&s, &d);
client.flag_pair_dispute(&s, &d);
client.flag_pair_dispute(&s, &d);
assert_eq!(
event_payloads(&env, symbol_short!("disp_set")).len(),
1,
"re-flagging an already-disputed pair must not emit a second disp_set event"
);
}

#[test]
fn test_resolve_pair_dispute_on_undisputed_pair_is_noop_without_event() {
let env = Env::default();
let (client, _admin) = setup_initialized(&env);
let (s, d) = (symbol_short!("USDC"), symbol_short!("EURC"));
client.register_pair(&s, &d);
client.resolve_pair_dispute(&s, &d);
assert!(!client.is_pair_disputed(&s, &d));
assert_eq!(
event_payloads(&env, symbol_short!("disp_set")).len(),
0,
"resolving a pair with no active dispute must not emit disp_set"
);
}

#[test]
fn test_unregister_pair_clears_dispute_flag() {
let env = Env::default();
let (client, _admin) = setup_initialized(&env);
let (s, d) = (symbol_short!("USDC"), symbol_short!("EURC"));
client.register_pair(&s, &d);
client.flag_pair_dispute(&s, &d);
client.unregister_pair(&s, &d);
client.register_pair(&s, &d);
assert!(
!client.is_pair_disputed(&s, &d),
"re-registering after unregister must not inherit a stale dispute flag"
);
}

#[test]
fn test_is_pair_registered_defaults_to_false() {
let env = Env::default();
Expand Down
Loading