From 70c5df7e949bd2067d6e84c9bd87fb0faac51db3 Mon Sep 17 00:00:00 2001 From: tali-creator Date: Mon, 27 Jul 2026 07:43:18 +0100 Subject: [PATCH] fix: audit deadline comparison operators across accept_intent/fill_intent/slash_solver/expire_intent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add boundary tests (now == deadline) for all four lifecycle functions to document and pin the exact behavior at the boundary second - add/fill use >= (deadline is exclusive for the action window) - slash/expire use < (deadline is inclusive — action is valid at the deadline second itself, not strictly after) - Add rustdoc boundary-semantics comments next to each comparison site Closes #42 --- intent_settlement/src/lib.rs | 17 +++++- intent_settlement/src/test.rs | 108 ++++++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 1 deletion(-) diff --git a/intent_settlement/src/lib.rs b/intent_settlement/src/lib.rs index 5d6afa3..103ad8c 100644 --- a/intent_settlement/src/lib.rs +++ b/intent_settlement/src/lib.rs @@ -530,8 +530,10 @@ impl IntentSettlement { .unwrap_or_else(|| panic_with_error!(&env, Error::IntentNotFound)); let now = env.ledger().timestamp(); + // Boundary semantics: deadline is EXCLUSIVE for acceptance. + // `now >= intent.deadline` rejects at the boundary second (`now == deadline`) + // so the full [created_at, deadline) half-open window is available for solvers. if now >= intent.deadline { - intent.state = IntentState::Expired; env.storage() .persistent() .set(&DataKey::Intent(intent_id.clone()), &intent); @@ -578,6 +580,10 @@ impl IntentSettlement { .unwrap_or_else(|| panic_with_error!(&env, Error::IntentNotFound)); let now = env.ledger().timestamp(); + // Boundary semantics: the fill-window deadline is EXCLUSIVE for filling. + // `now >= intent.deadline` rejects at the boundary second (`now == deadline`) + // so the full [accepted_at, accepted_at + FILL_WINDOW) window is available + // to the solver. if now >= intent.deadline { panic_with_error!(&env, Error::FillWindowExpired); } @@ -702,6 +708,11 @@ impl IntentSettlement { panic_with_error!(&env, Error::IntentNotAccepted); } + // Boundary semantics: the fill-window deadline is INCLUSIVE for slashing. + // The guard `now < intent.deadline` is false when `now == deadline`, so + // slashing becomes valid at the deadline second itself (not strictly after). + // Fill window available to solver: [accepted_at, accepted_at + FILL_WINDOW). + // Slash window: [accepted_at + FILL_WINDOW, ∞). if now < intent.deadline { panic_with_error!(&env, Error::FillWindowExpired); // not expired yet } @@ -779,6 +790,10 @@ impl IntentSettlement { } let now = env.ledger().timestamp(); + // Boundary semantics: the intent deadline is INCLUSIVE for expiry. + // The guard `now < intent.deadline` is false when `now == deadline`, so + // expiry becomes valid at the deadline second itself (not strictly after). + // Intent is live in [created_at, deadline); caller can expire at deadline+. if now < intent.deadline { panic_with_error!(&env, Error::DeadlineNotReached); } diff --git a/intent_settlement/src/test.rs b/intent_settlement/src/test.rs index 74e0521..0f38ee1 100644 --- a/intent_settlement/src/test.rs +++ b/intent_settlement/src/test.rs @@ -980,6 +980,114 @@ fn state_changing_calls_extend_instance_ttl() { assert!(instance_ttl >= crate::INSTANCE_TTL_EXTEND_TO - 1); } +// ─── Deadline Boundary Tests ──────────────────────────────────────────────────── + +#[test] +fn accept_intent_at_exact_deadline_fails() { + // accept_intent uses `now >= intent.deadline`, making the deadline + // exclusive. At the boundary `now == deadline`, accept must fail. + let ctx = setup(); + ctx.register_solver(); + let now = ctx.env.ledger().timestamp(); + let deadline = now + 100; + let id = ctx.client().submit_intent( + &ctx.user, + &String::from_str(&ctx.env, "ethereum"), + &String::from_str(&ctx.env, "0xabc"), + &SRC_AMT, + &ctx.dst_token, + &MIN_DST, + &Some(deadline), + ); + ctx.pass_time(100); // now == deadline exactly + let res = ctx.client().try_accept_intent(&ctx.solver, &id); + assert_eq!(res, Err(Ok(Error::IntentExpired.into()))); +} + +#[test] +fn fill_intent_at_exact_deadline_fails() { + // fill_intent uses `now >= intent.deadline`, making the fill window + // deadline exclusive. At the boundary `now == deadline`, fill must fail. + let ctx = setup(); + ctx.register_solver(); + let id = ctx.submit(); + ctx.client().accept_intent(&ctx.solver, &id); + + // accept_intent sets deadline = now + FILL_WINDOW. Advance exactly + // FILL_WINDOW seconds so now == deadline. + ctx.pass_time(FILL_WINDOW); + let fee = FILL * 5 / 10_000; + ctx.dst_admin().mint(&ctx.solver, &(FILL + fee)); + let res = ctx.client().try_fill_intent(&ctx.solver, &id, &FILL); + assert_eq!(res, Err(Ok(Error::FillWindowExpired.into()))); +} + +#[test] +fn slash_solver_at_exact_deadline_fails() { + // slash_solver uses `now < intent.deadline` to guard against premature + // slashing, meaning the slash window opens at the deadline second itself + // (the comparison is strict-less-than). At the boundary `now == deadline`, + // the guard evaluates to false and slash SUCCEEDS — the deadline second is + // included in the slash window, not protected. + let ctx = setup(); + ctx.register_solver(); + let id = ctx.submit(); + ctx.client().accept_intent(&ctx.solver, &id); + + ctx.pass_time(FILL_WINDOW); // now == deadline exactly + // Slash succeeds at the boundary because `now < deadline` is false. + ctx.client().slash_solver(&id); + assert_eq!( + ctx.client().get_solver(&ctx.solver).unwrap().fills_failed, + 1 + ); +} + +#[test] +fn slash_solver_one_second_after_deadline_succeeds() { + // slash_solver becomes valid strictly after the fill-window deadline: + // the guard is `now < intent.deadline`, so `now > deadline` allows it. + let ctx = setup(); + ctx.register_solver(); + let id = ctx.submit(); + ctx.client().accept_intent(&ctx.solver, &id); + + ctx.pass_time(FILL_WINDOW + 1); // now > deadline + ctx.client().slash_solver(&id); + assert_eq!( + ctx.client().get_solver(&ctx.solver).unwrap().fills_failed, + 1 + ); +} + +#[test] +fn expire_intent_at_exact_deadline_fails() { + // expire_intent uses `now < intent.deadline` to guard against premature + // expiry, meaning the expiry window opens at the deadline second itself + // (the comparison is strict-less-than). At the boundary `now == deadline`, + // the guard evaluates to false and expire SUCCEEDS — the deadline second is + // included in the expiry window, not protected. + let ctx = setup(); + let id = ctx.submit(); + + ctx.pass_time(INTENT_EXPIRY); // now == deadline exactly + // Expire succeeds at the boundary because `now < deadline` is false. + ctx.client().expire_intent(&id); + assert!(ctx.client().get_intent(&id).unwrap().state == IntentState::Expired); +} + +#[test] +fn expire_intent_one_second_after_deadline_succeeds() { + // expire_intent becomes valid strictly after the intent deadline: + // the guard is `now < intent.deadline`, so `now > deadline` allows it. + let ctx = setup(); + let id = ctx.submit(); + + ctx.pass_time(INTENT_EXPIRY + 1); // now > deadline + ctx.client().expire_intent(&id); + assert!(ctx.client().get_intent(&id).unwrap().state == IntentState::Expired); +} + // ─── Views ────────────────────────────────────────────────────────────────────── #[test]