diff --git a/intent_settlement/src/lib.rs b/intent_settlement/src/lib.rs index 733c4ac..ca84461 100644 --- a/intent_settlement/src/lib.rs +++ b/intent_settlement/src/lib.rs @@ -75,7 +75,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, @@ -83,18 +83,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) @@ -693,6 +704,7 @@ impl IntentSettlement { deadline: expiry, filled_at: None, fill_amount: None, + total_filled: 0, }; env.storage() @@ -749,7 +761,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); } @@ -774,8 +786,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); @@ -802,10 +822,16 @@ 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); } + // 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 on each fill. + let fee = fill_amount * PROTOCOL_FEE_BPS / 10_000; // ── Effects first (CEI) ────────────────────────────────────────────── // Mark the intent Filled and write every state change to storage // *before* any external token transfer executes. A hostile SEP-41 @@ -837,19 +863,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); @@ -914,7 +958,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); } @@ -969,8 +1013,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; @@ -1021,7 +1069,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 fa97a6b..0227f7a 100644 --- a/intent_settlement/src/test.rs +++ b/intent_settlement/src/test.rs @@ -848,16 +848,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] @@ -1231,6 +1229,85 @@ fn get_bond_token_returns_configured_token() { 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() { // ─── #29: slash_solver ordering ───────────────────────────────────────────────── /// Calling slash_solver twice on the same intent_id must fail on the second @@ -1710,6 +1787,17 @@ fn unpause_restores_solver_bond_management() { 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); c.pause(); c.unpause();