Skip to content

Commit a260399

Browse files
committed
feat(dispute): add per-pair dispute flag with view, event, and boundary tests
Adds a minimal dispute-flagging model: flag_pair_dispute/resolve_pair_dispute (admin-gated), is_pair_disputed (read-only view), a disp_set event guarded against duplicate emission, and a shared read_pair_disputed helper used by both quote_route and compute_route_fee instead of inlining the check twice. Closes #414 Closes #415 Closes #416 Closes #417
1 parent 3eaff98 commit a260399

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
@@ -205,6 +205,12 @@ pub enum DataKey {
205205
/// seconds have elapsed since `PairLastRouteAt`. Capped at
206206
/// `MAX_COOLDOWN_SECS` (30 days). Defaults to `0` (disabled).
207207
PairCooldown(Symbol, Symbol),
208+
/// `true` when `(source, destination)` has an active dispute flag
209+
/// (keyed per-pair, `bool`, persistent). Set by `flag_pair_dispute`
210+
/// and cleared by `resolve_pair_dispute` or `unregister_pair`. While
211+
/// `true`, `quote_route` and `compute_route_fee` reject the pair with
212+
/// `PairDisputed`. Defaults to `false`.
213+
PairDisputed(Symbol, Symbol),
208214
/// Optional absolute per-route fee ceiling (singleton, `i128`,
209215
/// persistent). When set, the effective fee is `min(bps_fee, cap)`.
210216
/// Absent ↔ `None` (only the relative `MAX_FEE_BPS` bound applies).
@@ -314,6 +320,9 @@ pub enum RouterError {
314320
/// route free. Use [`StableRouteRouter::clear_max_fee_absolute`] to
315321
/// remove the cap entirely.
316322
ZeroFeeCap = 21,
323+
/// `quote_route` or `compute_route_fee` was called for a pair with an
324+
/// active dispute flag (see [`StableRouteRouter::flag_pair_dispute`]).
325+
PairDisputed = 22,
317326
}
318327

319328
/// StableRoute router contract — placeholder for routing logic.
@@ -624,6 +633,46 @@ impl StableRouteRouter {
624633
);
625634
}
626635

636+
/// Admin flags `(source, destination)` as disputed. While disputed,
637+
/// [`Self::quote_route`] and [`Self::compute_route_fee`] reject the
638+
/// pair with [`RouterError::PairDisputed`] until
639+
/// [`Self::resolve_pair_dispute`] clears the flag. Requires the pair
640+
/// to already be registered.
641+
///
642+
/// Idempotent on the event: flagging an already-disputed pair
643+
/// re-asserts the flag but does not re-emit `disp_set`.
644+
pub fn flag_pair_dispute(env: Env, source: Symbol, destination: Symbol) {
645+
Self::require_admin(&env);
646+
Self::require_pair_registered(&env, &source, &destination);
647+
let already_disputed = Self::read_pair_disputed(&env, &source, &destination);
648+
env.storage().persistent().set(
649+
&DataKey::PairDisputed(source.clone(), destination.clone()),
650+
&true,
651+
);
652+
if !already_disputed {
653+
env.events()
654+
.publish((symbol_short!("disp_set"),), (source, destination, true));
655+
}
656+
}
657+
658+
/// Admin clears a dispute flag, restoring normal routing and quoting
659+
/// for the pair.
660+
///
661+
/// Idempotent: resolving a pair with no active dispute is a clean
662+
/// no-op that does not emit `disp_set`.
663+
pub fn resolve_pair_dispute(env: Env, source: Symbol, destination: Symbol) {
664+
Self::require_admin(&env);
665+
let was_disputed = Self::read_pair_disputed(&env, &source, &destination);
666+
env.storage().persistent().set(
667+
&DataKey::PairDisputed(source.clone(), destination.clone()),
668+
&false,
669+
);
670+
if was_disputed {
671+
env.events()
672+
.publish((symbol_short!("disp_set"),), (source, destination, false));
673+
}
674+
}
675+
627676
/// Configure an absolute upper bound on the fee charged for any route.
628677
///
629678
/// Admin-gated. The computed fee is clamped to this value after the
@@ -1097,6 +1146,14 @@ impl StableRouteRouter {
10971146
Self::read_pair_registered(&env, &source, &destination)
10981147
}
10991148

1149+
/// Returns `true` if `(source, destination)` currently has an active
1150+
/// dispute flag set via [`Self::flag_pair_dispute`].
1151+
///
1152+
/// Does not mutate storage. Defaults to `false`.
1153+
pub fn is_pair_disputed(env: Env, source: Symbol, destination: Symbol) -> bool {
1154+
Self::read_pair_disputed(&env, &source, &destination)
1155+
}
1156+
11001157
/// Returns the configured routing fee for the pair, expressed in basis
11011158
/// points.
11021159
///
@@ -1125,6 +1182,9 @@ impl StableRouteRouter {
11251182
panic_with_error!(&env, RouterError::AmountMustBePositive);
11261183
}
11271184
Self::require_pair_registered(&env, &source, &destination);
1185+
if Self::read_pair_disputed(&env, &source, &destination) {
1186+
panic_with_error!(&env, RouterError::PairDisputed);
1187+
}
11281188
if matches!(
11291189
env.storage()
11301190
.persistent()
@@ -1211,6 +1271,9 @@ impl StableRouteRouter {
12111271
if !Self::read_pair_registered(&env, &source, &destination) {
12121272
Self::route_abort(&env, RouterError::PairNotRegistered);
12131273
}
1274+
if Self::read_pair_disputed(&env, &source, &destination) {
1275+
Self::route_abort(&env, RouterError::PairDisputed);
1276+
}
12141277
let min_amount = Self::read_pair_min(&env, &source, &destination);
12151278
if amount < min_amount {
12161279
Self::route_abort(&env, RouterError::AmountBelowMin);
@@ -1510,6 +1573,18 @@ impl StableRouteRouter {
15101573
.unwrap_or(false)
15111574
}
15121575

1576+
/// Read whether `(source, destination)` currently has an active
1577+
/// dispute flag. Single source of truth for the
1578+
/// [`DataKey::PairDisputed`] sentinel value; shared by `quote_route`
1579+
/// and `compute_route_fee` instead of each inlining its own storage
1580+
/// read.
1581+
fn read_pair_disputed(env: &Env, source: &Symbol, destination: &Symbol) -> bool {
1582+
env.storage()
1583+
.persistent()
1584+
.get(&DataKey::PairDisputed(source.clone(), destination.clone()))
1585+
.unwrap_or(false)
1586+
}
1587+
15131588
/// Read the per-pair fee in basis points from persistent storage.
15141589
///
15151590
/// Returns `0` (free) when the slot is absent — the documented
@@ -1619,7 +1694,8 @@ impl StableRouteRouter {
16191694
storage.remove(&DataKey::PairMinAmount(source.clone(), destination.clone()));
16201695
storage.remove(&DataKey::PairMaxAmount(source.clone(), destination.clone()));
16211696
storage.remove(&DataKey::PairLiquidity(source.clone(), destination.clone()));
1622-
storage.remove(&DataKey::PairCooldown(source, destination));
1697+
storage.remove(&DataKey::PairCooldown(source.clone(), destination.clone()));
1698+
storage.remove(&DataKey::PairDisputed(source, destination));
16231699
}
16241700

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

2341+
// --- dispute flag ---
2342+
2343+
#[test]
2344+
fn test_is_pair_disputed_defaults_to_false() {
2345+
let env = Env::default();
2346+
let (client, _admin) = setup_initialized(&env);
2347+
let (s, d) = (symbol_short!("USDC"), symbol_short!("EURC"));
2348+
client.register_pair(&s, &d);
2349+
assert!(!client.is_pair_disputed(&s, &d));
2350+
}
2351+
2352+
#[test]
2353+
fn test_flag_pair_dispute_blocks_quote_and_compute() {
2354+
let env = Env::default();
2355+
let (client, _admin) = setup_initialized(&env);
2356+
let (s, d) = (symbol_short!("USDC"), symbol_short!("EURC"));
2357+
client.register_pair(&s, &d);
2358+
client.flag_pair_dispute(&s, &d);
2359+
assert!(client.is_pair_disputed(&s, &d));
2360+
2361+
let quote_result = client.try_quote_route(&s, &d, &1_000i128);
2362+
assert!(quote_result.is_err(), "disputed pair must reject quote_route");
2363+
2364+
let compute_result = client.try_compute_route_fee(&s, &d, &1_000i128);
2365+
assert!(
2366+
compute_result.is_err(),
2367+
"disputed pair must reject compute_route_fee"
2368+
);
2369+
}
2370+
2371+
#[test]
2372+
fn test_resolve_pair_dispute_restores_routing() {
2373+
let env = Env::default();
2374+
let (client, _admin) = setup_initialized(&env);
2375+
let (s, d) = (symbol_short!("USDC"), symbol_short!("EURC"));
2376+
client.register_pair(&s, &d);
2377+
client.flag_pair_dispute(&s, &d);
2378+
client.resolve_pair_dispute(&s, &d);
2379+
assert!(!client.is_pair_disputed(&s, &d));
2380+
// Routing works again once resolved.
2381+
client.quote_route(&s, &d, &1_000i128);
2382+
}
2383+
2384+
#[test]
2385+
#[should_panic(expected = "Error(Contract, #5)")]
2386+
fn test_flag_pair_dispute_requires_registered_pair() {
2387+
let env = Env::default();
2388+
let (client, _admin) = setup_initialized(&env);
2389+
client.flag_pair_dispute(&symbol_short!("USDC"), &symbol_short!("EURC"));
2390+
}
2391+
2392+
#[test]
2393+
fn test_flag_pair_dispute_does_not_duplicate_event_on_reflag() {
2394+
let env = Env::default();
2395+
let (client, _admin) = setup_initialized(&env);
2396+
let (s, d) = (symbol_short!("USDC"), symbol_short!("EURC"));
2397+
client.register_pair(&s, &d);
2398+
client.flag_pair_dispute(&s, &d);
2399+
client.flag_pair_dispute(&s, &d);
2400+
assert_eq!(
2401+
event_payloads(&env, symbol_short!("disp_set")).len(),
2402+
1,
2403+
"re-flagging an already-disputed pair must not emit a second disp_set event"
2404+
);
2405+
}
2406+
2407+
#[test]
2408+
fn test_resolve_pair_dispute_on_undisputed_pair_is_noop_without_event() {
2409+
let env = Env::default();
2410+
let (client, _admin) = setup_initialized(&env);
2411+
let (s, d) = (symbol_short!("USDC"), symbol_short!("EURC"));
2412+
client.register_pair(&s, &d);
2413+
client.resolve_pair_dispute(&s, &d);
2414+
assert!(!client.is_pair_disputed(&s, &d));
2415+
assert_eq!(
2416+
event_payloads(&env, symbol_short!("disp_set")).len(),
2417+
0,
2418+
"resolving a pair with no active dispute must not emit disp_set"
2419+
);
2420+
}
2421+
2422+
#[test]
2423+
fn test_unregister_pair_clears_dispute_flag() {
2424+
let env = Env::default();
2425+
let (client, _admin) = setup_initialized(&env);
2426+
let (s, d) = (symbol_short!("USDC"), symbol_short!("EURC"));
2427+
client.register_pair(&s, &d);
2428+
client.flag_pair_dispute(&s, &d);
2429+
client.unregister_pair(&s, &d);
2430+
client.register_pair(&s, &d);
2431+
assert!(
2432+
!client.is_pair_disputed(&s, &d),
2433+
"re-registering after unregister must not inherit a stale dispute flag"
2434+
);
2435+
}
2436+
22652437
#[test]
22662438
fn test_is_pair_registered_defaults_to_false() {
22672439
let env = Env::default();

0 commit comments

Comments
 (0)