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();