Skip to content

Commit 4ac9231

Browse files
authored
Merge pull request #441 from Baskarayelu/feature/dispute-414-418-model
feat(dispute): add per-pair dispute flag with view, event, and boundary tests
2 parents 735b10e + a260399 commit 4ac9231

1 file changed

Lines changed: 173 additions & 1 deletion

File tree

src/lib.rs

Lines changed: 173 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,12 @@ pub enum DataKey {
211211
/// seconds have elapsed since `PairLastRouteAt`. Capped at
212212
/// `MAX_COOLDOWN_SECS` (30 days). Defaults to `0` (disabled).
213213
PairCooldown(Symbol, Symbol),
214+
/// `true` when `(source, destination)` has an active dispute flag
215+
/// (keyed per-pair, `bool`, persistent). Set by `flag_pair_dispute`
216+
/// and cleared by `resolve_pair_dispute` or `unregister_pair`. While
217+
/// `true`, `quote_route` and `compute_route_fee` reject the pair with
218+
/// `PairDisputed`. Defaults to `false`.
219+
PairDisputed(Symbol, Symbol),
214220
/// Optional absolute per-route fee ceiling (singleton, `i128`,
215221
/// persistent). When set, the effective fee is `min(bps_fee, cap)`.
216222
/// Absent ↔ `None` (only the relative `MAX_FEE_BPS` bound applies).
@@ -320,6 +326,9 @@ pub enum RouterError {
320326
/// route free. Use [`StableRouteRouter::clear_max_fee_absolute`] to
321327
/// remove the cap entirely.
322328
ZeroFeeCap = 21,
329+
/// `quote_route` or `compute_route_fee` was called for a pair with an
330+
/// active dispute flag (see [`StableRouteRouter::flag_pair_dispute`]).
331+
PairDisputed = 22,
323332
}
324333

325334
/// StableRoute router contract — placeholder for routing logic.
@@ -608,6 +617,46 @@ impl StableRouteRouter {
608617
);
609618
}
610619

620+
/// Admin flags `(source, destination)` as disputed. While disputed,
621+
/// [`Self::quote_route`] and [`Self::compute_route_fee`] reject the
622+
/// pair with [`RouterError::PairDisputed`] until
623+
/// [`Self::resolve_pair_dispute`] clears the flag. Requires the pair
624+
/// to already be registered.
625+
///
626+
/// Idempotent on the event: flagging an already-disputed pair
627+
/// re-asserts the flag but does not re-emit `disp_set`.
628+
pub fn flag_pair_dispute(env: Env, source: Symbol, destination: Symbol) {
629+
Self::require_admin(&env);
630+
Self::require_pair_registered(&env, &source, &destination);
631+
let already_disputed = Self::read_pair_disputed(&env, &source, &destination);
632+
env.storage().persistent().set(
633+
&DataKey::PairDisputed(source.clone(), destination.clone()),
634+
&true,
635+
);
636+
if !already_disputed {
637+
env.events()
638+
.publish((symbol_short!("disp_set"),), (source, destination, true));
639+
}
640+
}
641+
642+
/// Admin clears a dispute flag, restoring normal routing and quoting
643+
/// for the pair.
644+
///
645+
/// Idempotent: resolving a pair with no active dispute is a clean
646+
/// no-op that does not emit `disp_set`.
647+
pub fn resolve_pair_dispute(env: Env, source: Symbol, destination: Symbol) {
648+
Self::require_admin(&env);
649+
let was_disputed = Self::read_pair_disputed(&env, &source, &destination);
650+
env.storage().persistent().set(
651+
&DataKey::PairDisputed(source.clone(), destination.clone()),
652+
&false,
653+
);
654+
if was_disputed {
655+
env.events()
656+
.publish((symbol_short!("disp_set"),), (source, destination, false));
657+
}
658+
}
659+
611660
/// Configure an absolute upper bound on the fee charged for any route.
612661
///
613662
/// Admin-gated. The computed fee is clamped to this value after the
@@ -1089,6 +1138,14 @@ impl StableRouteRouter {
10891138
Self::read_pair_registered(&env, &source, &destination)
10901139
}
10911140

1141+
/// Returns `true` if `(source, destination)` currently has an active
1142+
/// dispute flag set via [`Self::flag_pair_dispute`].
1143+
///
1144+
/// Does not mutate storage. Defaults to `false`.
1145+
pub fn is_pair_disputed(env: Env, source: Symbol, destination: Symbol) -> bool {
1146+
Self::read_pair_disputed(&env, &source, &destination)
1147+
}
1148+
10921149
/// Returns the configured routing fee for the pair, expressed in basis
10931150
/// points.
10941151
///
@@ -1117,6 +1174,9 @@ impl StableRouteRouter {
11171174
panic_with_error!(&env, RouterError::AmountMustBePositive);
11181175
}
11191176
Self::require_pair_registered(&env, &source, &destination);
1177+
if Self::read_pair_disputed(&env, &source, &destination) {
1178+
panic_with_error!(&env, RouterError::PairDisputed);
1179+
}
11201180
if matches!(
11211181
env.storage()
11221182
.persistent()
@@ -1203,6 +1263,9 @@ impl StableRouteRouter {
12031263
if !Self::read_pair_registered(&env, &source, &destination) {
12041264
Self::route_abort(&env, RouterError::PairNotRegistered);
12051265
}
1266+
if Self::read_pair_disputed(&env, &source, &destination) {
1267+
Self::route_abort(&env, RouterError::PairDisputed);
1268+
}
12061269
let min_amount = Self::read_pair_min(&env, &source, &destination);
12071270
if amount < min_amount {
12081271
Self::route_abort(&env, RouterError::AmountBelowMin);
@@ -1563,6 +1626,18 @@ impl StableRouteRouter {
15631626
.unwrap_or(false)
15641627
}
15651628

1629+
/// Read whether `(source, destination)` currently has an active
1630+
/// dispute flag. Single source of truth for the
1631+
/// [`DataKey::PairDisputed`] sentinel value; shared by `quote_route`
1632+
/// and `compute_route_fee` instead of each inlining its own storage
1633+
/// read.
1634+
fn read_pair_disputed(env: &Env, source: &Symbol, destination: &Symbol) -> bool {
1635+
env.storage()
1636+
.persistent()
1637+
.get(&DataKey::PairDisputed(source.clone(), destination.clone()))
1638+
.unwrap_or(false)
1639+
}
1640+
15661641
/// Read the per-pair fee in basis points from persistent storage.
15671642
///
15681643
/// Returns `0` (free) when the slot is absent — the documented
@@ -1672,7 +1747,8 @@ impl StableRouteRouter {
16721747
storage.remove(&DataKey::PairMinAmount(source.clone(), destination.clone()));
16731748
storage.remove(&DataKey::PairMaxAmount(source.clone(), destination.clone()));
16741749
storage.remove(&DataKey::PairLiquidity(source.clone(), destination.clone()));
1675-
storage.remove(&DataKey::PairCooldown(source, destination));
1750+
storage.remove(&DataKey::PairCooldown(source.clone(), destination.clone()));
1751+
storage.remove(&DataKey::PairDisputed(source, destination));
16761752
}
16771753

16781754
/// Remove a registered pair from the router.
@@ -2315,6 +2391,102 @@ mod test {
23152391
client.register_pair(&symbol_short!("USDC"), &symbol_short!("USDC"));
23162392
}
23172393

2394+
// --- dispute flag ---
2395+
2396+
#[test]
2397+
fn test_is_pair_disputed_defaults_to_false() {
2398+
let env = Env::default();
2399+
let (client, _admin) = setup_initialized(&env);
2400+
let (s, d) = (symbol_short!("USDC"), symbol_short!("EURC"));
2401+
client.register_pair(&s, &d);
2402+
assert!(!client.is_pair_disputed(&s, &d));
2403+
}
2404+
2405+
#[test]
2406+
fn test_flag_pair_dispute_blocks_quote_and_compute() {
2407+
let env = Env::default();
2408+
let (client, _admin) = setup_initialized(&env);
2409+
let (s, d) = (symbol_short!("USDC"), symbol_short!("EURC"));
2410+
client.register_pair(&s, &d);
2411+
client.flag_pair_dispute(&s, &d);
2412+
assert!(client.is_pair_disputed(&s, &d));
2413+
2414+
let quote_result = client.try_quote_route(&s, &d, &1_000i128);
2415+
assert!(quote_result.is_err(), "disputed pair must reject quote_route");
2416+
2417+
let compute_result = client.try_compute_route_fee(&s, &d, &1_000i128);
2418+
assert!(
2419+
compute_result.is_err(),
2420+
"disputed pair must reject compute_route_fee"
2421+
);
2422+
}
2423+
2424+
#[test]
2425+
fn test_resolve_pair_dispute_restores_routing() {
2426+
let env = Env::default();
2427+
let (client, _admin) = setup_initialized(&env);
2428+
let (s, d) = (symbol_short!("USDC"), symbol_short!("EURC"));
2429+
client.register_pair(&s, &d);
2430+
client.flag_pair_dispute(&s, &d);
2431+
client.resolve_pair_dispute(&s, &d);
2432+
assert!(!client.is_pair_disputed(&s, &d));
2433+
// Routing works again once resolved.
2434+
client.quote_route(&s, &d, &1_000i128);
2435+
}
2436+
2437+
#[test]
2438+
#[should_panic(expected = "Error(Contract, #5)")]
2439+
fn test_flag_pair_dispute_requires_registered_pair() {
2440+
let env = Env::default();
2441+
let (client, _admin) = setup_initialized(&env);
2442+
client.flag_pair_dispute(&symbol_short!("USDC"), &symbol_short!("EURC"));
2443+
}
2444+
2445+
#[test]
2446+
fn test_flag_pair_dispute_does_not_duplicate_event_on_reflag() {
2447+
let env = Env::default();
2448+
let (client, _admin) = setup_initialized(&env);
2449+
let (s, d) = (symbol_short!("USDC"), symbol_short!("EURC"));
2450+
client.register_pair(&s, &d);
2451+
client.flag_pair_dispute(&s, &d);
2452+
client.flag_pair_dispute(&s, &d);
2453+
assert_eq!(
2454+
event_payloads(&env, symbol_short!("disp_set")).len(),
2455+
1,
2456+
"re-flagging an already-disputed pair must not emit a second disp_set event"
2457+
);
2458+
}
2459+
2460+
#[test]
2461+
fn test_resolve_pair_dispute_on_undisputed_pair_is_noop_without_event() {
2462+
let env = Env::default();
2463+
let (client, _admin) = setup_initialized(&env);
2464+
let (s, d) = (symbol_short!("USDC"), symbol_short!("EURC"));
2465+
client.register_pair(&s, &d);
2466+
client.resolve_pair_dispute(&s, &d);
2467+
assert!(!client.is_pair_disputed(&s, &d));
2468+
assert_eq!(
2469+
event_payloads(&env, symbol_short!("disp_set")).len(),
2470+
0,
2471+
"resolving a pair with no active dispute must not emit disp_set"
2472+
);
2473+
}
2474+
2475+
#[test]
2476+
fn test_unregister_pair_clears_dispute_flag() {
2477+
let env = Env::default();
2478+
let (client, _admin) = setup_initialized(&env);
2479+
let (s, d) = (symbol_short!("USDC"), symbol_short!("EURC"));
2480+
client.register_pair(&s, &d);
2481+
client.flag_pair_dispute(&s, &d);
2482+
client.unregister_pair(&s, &d);
2483+
client.register_pair(&s, &d);
2484+
assert!(
2485+
!client.is_pair_disputed(&s, &d),
2486+
"re-registering after unregister must not inherit a stale dispute flag"
2487+
);
2488+
}
2489+
23182490
#[test]
23192491
fn test_is_pair_registered_defaults_to_false() {
23202492
let env = Env::default();

0 commit comments

Comments
 (0)