From 0c8c012aedca15556877e1a7602a6ce73e378d2d Mon Sep 17 00:00:00 2001 From: favvy994 Date: Sun, 26 Jul 2026 23:43:09 +0100 Subject: [PATCH] feat: add partial fill support for src_amount Closes #50 - Add PartiallyFilled state to IntentState - Add total_filled field to IntentRecord to track cumulative fills - fill_intent now accepts any positive amount; intent transitions to PartiallyFilled when total_filled < min_dst_amount, re-opening it so another solver can claim and deliver the remainder - Intent transitions to Filled once total_filled >= min_dst_amount - Protocol fee applied per individual fill for consistent accounting - slash_solver preserves PartiallyFilled state when re-opening an intent that already has partial progress - expire_intent and cancel_intent now also apply to PartiallyFilled intents - Replace fill_below_minimum_fails test (no longer valid) with fill_zero_amount_fails; add three partial-fill lifecycle tests --- intent_settlement/src/lib.rs | 97 +++++++++++++++++++++---------- intent_settlement/src/test.rs | 104 ++++++++++++++++++++++++++++++++-- 2 files changed, 167 insertions(+), 34 deletions(-) diff --git a/intent_settlement/src/lib.rs b/intent_settlement/src/lib.rs index 5d6afa3..31ef2a6 100644 --- a/intent_settlement/src/lib.rs +++ b/intent_settlement/src/lib.rs @@ -68,7 +68,7 @@ pub struct IntentRecord { /// Destination (always Stellar) pub dst_token: Address, // SAC/SEP-41 token on Stellar - pub min_dst_amount: i128, // minimum acceptable output + pub min_dst_amount: i128, // minimum acceptable output per fill (floor per partial) pub solver: Option
, // assigned solver pub state: IntentState, @@ -76,18 +76,29 @@ pub struct IntentRecord { pub created_at: u64, pub deadline: u64, pub filled_at: Option, - pub fill_amount: Option, // actual amount received + pub fill_amount: Option, // cumulative dst tokens received across all fills + + /// Cumulative dst tokens delivered so far; intent completes when this + /// reaches or exceeds `min_dst_amount * num_fills_needed`, but in the + /// partial-fill model the intent is fully settled once the solver + /// delivering a fill brings `total_filled` to at least `min_dst_amount`. + /// + /// More precisely: each individual partial fill must be > 0, and the + /// intent transitions to `Filled` as soon as `total_filled` satisfies + /// the user's `min_dst_amount` requirement. + pub total_filled: i128, } #[contracttype] -#[derive(Clone, PartialEq)] +#[derive(Clone, PartialEq, Debug)] pub enum IntentState { - Open, // awaiting solver - Accepted, // solver claimed it - Filled, // user received output - Cancelled, // user cancelled before fill - Expired, // deadline passed, no fill - Slashed, // solver failed to fill after accepting + Open, // awaiting solver + Accepted, // solver claimed it + PartiallyFilled, // one or more partial fills delivered; still open for more + Filled, // user received total output >= min_dst_amount + Cancelled, // user cancelled before fill + Expired, // deadline passed, no fill + Slashed, // solver failed to fill after accepting } /// A registered solver (market maker) @@ -483,6 +494,7 @@ impl IntentSettlement { deadline: expiry, filled_at: None, fill_amount: None, + total_filled: 0, }; env.storage() @@ -539,7 +551,7 @@ impl IntentSettlement { panic_with_error!(&env, Error::IntentExpired); } - if intent.state != IntentState::Open { + if intent.state != IntentState::Open && intent.state != IntentState::PartiallyFilled { panic_with_error!(&env, Error::IntentNotOpen); } @@ -564,8 +576,16 @@ impl IntentSettlement { ); } - /// Solver fills the intent by sending dst_token to the user - /// The solver provides cross-chain proof (stored off-chain; on-chain we trust solver's bond) + /// Solver fills the intent by sending dst_token to the user. + /// + /// Partial fills are supported: `fill_amount` must be > 0 but may be less + /// than `min_dst_amount`. The intent transitions to `PartiallyFilled` after + /// each sub-fill and is re-opened so another solver (or the same one) can + /// accept and deliver the remainder. Once the cumulative `total_filled` + /// reaches or exceeds `min_dst_amount` the intent transitions to `Filled`. + /// + /// The protocol fee is taken on each individual fill so the fee accounting + /// stays consistent regardless of how many fills it takes. pub fn fill_intent(env: Env, solver: Address, intent_id: BytesN<32>, fill_amount: i128) { solver.require_auth(); Self::require_not_paused(&env); @@ -592,18 +612,15 @@ impl IntentSettlement { panic_with_error!(&env, Error::Unauthorized); } - if fill_amount < intent.min_dst_amount { - panic_with_error!(&env, Error::InsufficientOutput); + if fill_amount <= 0 { + panic_with_error!(&env, Error::ZeroAmount); } - // Solver delivers the full requested output to the user. + // Deliver this fill's tokens to the user. let dst_client = token::Client::new(&env, &intent.dst_token); dst_client.transfer(&solver, &intent.user, &fill_amount); - // Solver also pays the protocol fee (priced into their quote). Taking the - // fee from the solver — rather than clawing it back from the user — keeps - // the user's received amount at or above `min_dst_amount`, and keeps every - // token transfer authorized by the solver who signed this call. + // Solver also pays the protocol fee on each fill. let fee = fill_amount * PROTOCOL_FEE_BPS / 10_000; if fee > 0 { let fee_recipient: Address = env @@ -614,19 +631,37 @@ impl IntentSettlement { dst_client.transfer(&solver, &fee_recipient, &fee); } - intent.state = IntentState::Filled; - intent.filled_at = Some(now); - intent.fill_amount = Some(fill_amount); + // Accumulate the fill. + intent.total_filled += fill_amount; + let cumulative = intent.total_filled; + + // Update fill_amount to reflect the running total for backward-compatible reads. + intent.fill_amount = Some(cumulative); - // Update solver stats + // Update solver stats for this partial fill. let mut solver_record: SolverRecord = env .storage() .persistent() .get(&DataKey::Solver(solver.clone())) .unwrap(); - solver_record.fills_completed += 1; solver_record.total_volume += fill_amount; - solver_record.active_intents = solver_record.active_intents.saturating_sub(1); + + if cumulative >= intent.min_dst_amount { + // Intent is fully satisfied — close it out. + intent.state = IntentState::Filled; + intent.filled_at = Some(now); + solver_record.fills_completed += 1; + solver_record.active_intents = solver_record.active_intents.saturating_sub(1); + } else { + // Partial fill: re-open so another solver (or the same) can claim the + // remaining amount. Reset solver assignment and deadline back to the + // full intent expiry window so the rest of the intent can be picked up. + intent.state = IntentState::PartiallyFilled; + intent.solver = None; + intent.deadline = now + INTENT_EXPIRY; + solver_record.active_intents = solver_record.active_intents.saturating_sub(1); + } + env.storage() .persistent() .set(&DataKey::Solver(solver.clone()), &solver_record); @@ -672,7 +707,7 @@ impl IntentSettlement { panic_with_error!(&env, Error::CannotCancelAccepted); } - if intent.state != IntentState::Open { + if intent.state != IntentState::Open && intent.state != IntentState::PartiallyFilled { panic_with_error!(&env, Error::IntentNotOpen); } @@ -725,8 +760,12 @@ impl IntentSettlement { solver_record.is_active = false; } - // Re-open the intent - intent.state = IntentState::Open; + // Re-open the intent, preserving partial-fill progress if any. + intent.state = if intent.total_filled > 0 { + IntentState::PartiallyFilled + } else { + IntentState::Open + }; intent.solver = None; intent.deadline = now + INTENT_EXPIRY; @@ -774,7 +813,7 @@ impl IntentSettlement { .get(&DataKey::Intent(intent_id.clone())) .unwrap_or_else(|| panic_with_error!(&env, Error::IntentNotFound)); - if intent.state != IntentState::Open { + if intent.state != IntentState::Open && intent.state != IntentState::PartiallyFilled { panic_with_error!(&env, Error::IntentNotOpen); } diff --git a/intent_settlement/src/test.rs b/intent_settlement/src/test.rs index 74e0521..dec9abd 100644 --- a/intent_settlement/src/test.rs +++ b/intent_settlement/src/test.rs @@ -724,16 +724,14 @@ fn cannot_accept_already_accepted_intent() { // ─── Fill guards ──────────────────────────────────────────────────────────────── #[test] -fn fill_below_minimum_fails() { +fn fill_zero_amount_fails() { let ctx = setup(); ctx.register_solver(); let id = ctx.submit(); ctx.client().accept_intent(&ctx.solver, &id); - let res = ctx - .client() - .try_fill_intent(&ctx.solver, &id, &(MIN_DST - 1)); - assert_eq!(res, Err(Ok(Error::InsufficientOutput.into()))); + let res = ctx.client().try_fill_intent(&ctx.solver, &id, &0); + assert_eq!(res, Err(Ok(Error::ZeroAmount.into()))); } #[test] @@ -994,3 +992,99 @@ fn get_bond_token_returns_configured_token() { let ctx = setup(); assert_eq!(ctx.client().get_bond_token(), Some(ctx.bond_token.clone())); } + +// ─── Partial fills ─────────────────────────────────────────────────────────────── + +#[test] +fn two_partial_fills_complete_intent() { + let ctx = setup(); + let c = ctx.client(); + ctx.register_solver(); + + // Submit with MIN_DST as the full target. + let id = ctx.submit(); + + // First partial fill: half of MIN_DST. + let half = MIN_DST / 2; + let fee1 = half * 5 / 10_000; + ctx.dst_admin().mint(&ctx.solver, &(half + fee1)); + c.accept_intent(&ctx.solver, &id); + c.fill_intent(&ctx.solver, &id, &half); + + // Intent should now be PartiallyFilled and re-opened (solver reset). + let intent = c.get_intent(&id).unwrap(); + assert_eq!(intent.state, IntentState::PartiallyFilled); + assert_eq!(intent.total_filled, half); + assert!(intent.solver.is_none()); + + // User already received the first half. + assert_eq!(ctx.dst().balance(&ctx.user), half); + + // Second fill: the remainder — brings total to MIN_DST. + let remainder = MIN_DST - half; + let fee2 = remainder * 5 / 10_000; + ctx.dst_admin().mint(&ctx.solver, &(remainder + fee2)); + c.accept_intent(&ctx.solver, &id); + c.fill_intent(&ctx.solver, &id, &remainder); + + let intent = c.get_intent(&id).unwrap(); + assert_eq!(intent.state, IntentState::Filled); + assert_eq!(intent.total_filled, MIN_DST); + assert_eq!(intent.fill_amount, Some(MIN_DST)); + + // User has received the full MIN_DST across both fills. + assert_eq!(ctx.dst().balance(&ctx.user), MIN_DST); + + // Solver credited fills_completed once (on the completing fill). + let solver = c.get_solver(&ctx.solver).unwrap(); + assert_eq!(solver.fills_completed, 1); + assert_eq!(solver.total_volume, MIN_DST); +} + +#[test] +fn partial_fill_left_incomplete_past_deadline_can_be_expired() { + let ctx = setup(); + let c = ctx.client(); + ctx.register_solver(); + + let id = ctx.submit(); + + // Deliver a partial fill (less than MIN_DST). + let partial = MIN_DST / 3; + let fee = partial * 5 / 10_000; + ctx.dst_admin().mint(&ctx.solver, &(partial + fee)); + c.accept_intent(&ctx.solver, &id); + c.fill_intent(&ctx.solver, &id, &partial); + + // Intent is PartiallyFilled and re-opened with a fresh INTENT_EXPIRY deadline. + assert_eq!( + c.get_intent(&id).unwrap().state, + IntentState::PartiallyFilled + ); + + // Let the new deadline expire without anyone picking up the remainder. + ctx.pass_time(INTENT_EXPIRY + 1); + + // expire_intent works on PartiallyFilled intents past their deadline. + c.expire_intent(&id); + assert_eq!(c.get_intent(&id).unwrap().state, IntentState::Expired); +} + +#[test] +fn single_fill_at_or_above_minimum_completes_immediately() { + let ctx = setup(); + let c = ctx.client(); + ctx.register_solver(); + + let id = ctx.submit(); + + // A single fill that exactly meets min_dst_amount. + let fee = MIN_DST * 5 / 10_000; + ctx.dst_admin().mint(&ctx.solver, &(MIN_DST + fee)); + c.accept_intent(&ctx.solver, &id); + c.fill_intent(&ctx.solver, &id, &MIN_DST); + + let intent = c.get_intent(&id).unwrap(); + assert_eq!(intent.state, IntentState::Filled); + assert_eq!(intent.total_filled, MIN_DST); +}