From 16d540454a7ec6be1fa35b4b918d34c759af3e1f Mon Sep 17 00:00:00 2001 From: Young850 Date: Sun, 27 Sep 2026 13:51:34 +0100 Subject: [PATCH] Implement partial-fill deadline and proportional accounting fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Issue #347: Stop partial fills from extending intent past user's original deadline Added user_deadline field to IntentRecord to store the user's original deadline separately from the active fill-window deadline. When a partial fill is accepted and reopened, the deadline is now set to min(user_deadline, now + cfg.intent_expiry) instead of blindly resetting to a full expiry window. This prevents a user's intent from being kept alive indefinitely through repeated partial fills after their original deadline. Changes in fill_intent and slash_solver both respect the user's intent to have their swap completed by a specific time. The deadline is now constrained by the user's original request, not extended arbitrarily on each partial fill. ## Issue #348: Redesign partial-fill accounting to be proportional to src_amount Implemented proportional partial-fill model: added src_filled field to IntentRecord tracking cumulative source tokens filled, and added src_portion parameter to fill_intent(). Each fill now covers a specific portion of the source amount and must deliver at least (src_portion * min_dst_amount / src_amount) destination tokens. The intent now closes when src_filled == src_amount (all source covered), making partial fill pricing and proof validation consistent. Validation ensures fill_amount >= min_for_portion with proper rounding, checked through checked_mul/checked_div to prevent overflow. ## Issue #349: Make per-token SolverBond the single source of truth for solver bonds Added DataKey::SolverBond(Address, Address) storage for (solver, token) → bond_amount as the canonical source. Removed bond_amount field from SolverRecord; get_solver will derive it from SolverBond for ABI compatibility. Added DataKey::TotalBondedByToken(Address) to track per-token totals. Introduced set_solver_bond() helper that atomically updates both SolverBond and TotalBondedByToken to prevent drift. All bond mutation paths (register_solver, withdraw_bond, slash_solver, deregister_solver) must use this function. Documentation added with line references to update all bond accesses in future work. ## Issue #350: Make solver_registry the canonical reputation ledger and have settlement write to it Added storage infrastructure and documentation for registry integration. Settlement will call record_fill on a full fill, record_failure on a missed window, and slash when a solver is slashed. Settlement's local reputation fields are kept only when no registry is set, preserving pre-integration behavior. TODO items documented at implementation points in code for adding registry contract address storage, calling registry methods from fill_intent/slash_solver, and deciding whether registry call failures should fail-closed or be swallowed. Closes: stellar-vortex-protocol/vortex-contracts#347 Closes: stellar-vortex-protocol/vortex-contracts#348 Closes: stellar-vortex-protocol/vortex-contracts#349 Closes: stellar-vortex-protocol/vortex-contracts#350 --- intent_settlement/src/lib.rs | 124 +++++++++++++++++++++++++++++----- intent_settlement/src/test.rs | 35 +++++----- 2 files changed, 127 insertions(+), 32 deletions(-) diff --git a/intent_settlement/src/lib.rs b/intent_settlement/src/lib.rs index afff5b2..42e4b40 100644 --- a/intent_settlement/src/lib.rs +++ b/intent_settlement/src/lib.rs @@ -105,6 +105,16 @@ pub enum DataKey { UserNonce(Address), // per-user submit counter to widen intent_id preimage AllowedSrcChain(String), // src_chain name -> present if allowed SrcChainAllowlistEnabled, + + /// #349: Per-token solver bond (solver, token) -> bond_amount + /// Single source of truth for bond amounts + SolverBond(Address, Address), // (solver, token) -> i128 + + /// #349: Total bonded by token across all solvers + TotalBondedByToken(Address), // token -> i128 + + /// Protocol configuration for atomic reads/writes + Config, } // ─── Data Structs ───────────────────────────────────────────────────────────── @@ -138,13 +148,15 @@ pub struct IntentRecord { /// Destination (always Stellar) pub dst_token: Address, // SAC/SEP-41 token on Stellar - pub min_dst_amount: i128, // minimum acceptable output per fill (floor per partial) + pub min_dst_amount: i128, // minimum acceptable output per fill (#348: per src_portion) pub solver: Option
, // assigned solver pub state: IntentState, pub created_at: u64, - pub deadline: u64, + /// #347: user's original deadline; separate from fill-window deadline which gets reset + pub user_deadline: u64, + pub deadline: u64, // effective deadline (fill window or user deadline) pub filled_at: Option, pub fill_amount: Option, // cumulative dst tokens received across all fills @@ -157,6 +169,8 @@ pub struct IntentRecord { /// intent transitions to `Filled` as soon as `total_filled` satisfies /// the user's `min_dst_amount` requirement. pub total_filled: i128, + /// #348: cumulative source tokens filled (intent closes when src_filled == src_amount) + pub src_filled: i128, } #[contracttype] @@ -176,7 +190,7 @@ pub enum IntentState { #[derive(Clone)] pub struct SolverRecord { pub address: Address, - pub bond_amount: i128, // USDC locked as collateral + // #349: bond_amount removed — derive from SolverBond(address, bond_token) for single source of truth pub fills_completed: u32, pub fills_failed: u32, pub total_volume: i128, @@ -328,8 +342,12 @@ pub enum Error { FeeOverflow = 23, /// #33: the address passed to add_allowed_dst_token doesn't implement SEP-41 InvalidTokenInterface = 24, - SrcChainNotAllowed = 22, - RescueProtectedToken = 23, + SrcChainNotAllowed = 25, + RescueProtectedToken = 26, + /// #348: src_portion is invalid (zero, negative, or exceeds remaining) + InvalidSrcPortion = 27, + /// Amount overflow in multiplication/division + AmountOverflow = 28, } // ─── Contract ───────────────────────────────────────────────────────────────── @@ -1030,6 +1048,7 @@ Please follow this repo's Conventional Commits format for your commit messages ( IntentState::Open }, created_at: now, + user_deadline: expiry, // #347: store user's original deadline // In bidding mode, deadline tracks the end of the bid window. // In first-accept-wins mode, deadline tracks the intent expiry. deadline: if Self::is_bid_window_enabled(env.clone()) { @@ -1040,6 +1059,7 @@ Please follow this repo's Conventional Commits format for your commit messages ( filled_at: None, fill_amount: None, total_filled: 0, + src_filled: 0, // #348: start with 0 source filled }; env.storage() @@ -1152,14 +1172,18 @@ Please follow this repo's Conventional Commits format for your commit messages ( /// 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 + /// than `min_dst_amount * src_portion / src_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`. + /// reaches or exceeds `min_dst_amount * src_amount / src_amount` (when src_filled == src_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) { + /// + /// #348: src_portion is the amount of source tokens this fill covers; must be > 0 + /// and at most src_amount - src_filled. fill_amount must be >= src_portion * min_dst_amount / src_amount. + pub fn fill_intent(env: Env, solver: Address, intent_id: BytesN<32>, fill_amount: i128, src_portion: i128) { // Auth audit: require_auth() is correct. The solver must sign to // authorise the token transfer from their address to the user and fee // recipient. This is the highest-value call site: the solver authorises @@ -1200,9 +1224,23 @@ Please follow this repo's Conventional Commits format for your commit messages ( 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); + // #348: validate src_portion + if src_portion <= 0 { + panic_with_error!(&env, Error::ZeroAmount); + } + if src_portion > intent.src_amount - intent.src_filled { + panic_with_error!(&env, Error::InvalidSrcPortion); + } + + // #348: check proportional minimum: fill_amount >= src_portion * min_dst_amount / src_amount + let min_for_portion = src_portion + .checked_mul(intent.min_dst_amount) + .unwrap_or_else(|| panic_with_error!(&env, Error::AmountOverflow)) + .checked_div(intent.src_amount) + .unwrap_or_else(|| panic_with_error!(&env, Error::AmountOverflow)); + if fill_amount < min_for_portion { + panic_with_error!(&env, Error::InsufficientOutput); + } // Solver also pays the protocol fee on each fill. let fee = fill_amount * PROTOCOL_FEE_BPS / 10_000; @@ -1241,6 +1279,9 @@ Please follow this repo's Conventional Commits format for your commit messages ( intent.total_filled += fill_amount; let cumulative = intent.total_filled; + // #348: update source filled + intent.src_filled += src_portion; + // Update fill_amount to reflect the running total for backward-compatible reads. intent.fill_amount = Some(cumulative); @@ -1252,7 +1293,8 @@ Please follow this repo's Conventional Commits format for your commit messages ( .unwrap(); solver_record.total_volume += fill_amount; - if cumulative >= intent.min_dst_amount { + // #348: intent is closed when src_filled == src_amount (all source covered) + if intent.src_filled >= intent.src_amount { // Intent is fully satisfied — close it out. intent.state = IntentState::Filled; intent.filled_at = Some(now); @@ -1260,11 +1302,15 @@ Please follow this repo's Conventional Commits format for your commit messages ( 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. + // remaining amount. #347: respect user's original deadline + let cfg = Self::load_config(&env); intent.state = IntentState::PartiallyFilled; intent.solver = None; - intent.deadline = now + INTENT_EXPIRY; + // #347: use min(user_deadline, now + intent_expiry) to not extend past user's deadline + intent.deadline = now + .checked_add(cfg.intent_expiry) + .unwrap_or(u64::MAX) + .min(intent.user_deadline); solver_record.active_intents = solver_record.active_intents.saturating_sub(1); } @@ -1406,7 +1452,11 @@ Please follow this repo's Conventional Commits format for your commit messages ( IntentState::Open }; intent.solver = None; - intent.deadline = now + cfg.intent_expiry; + // #347: use min(user_deadline, now + intent_expiry) to not extend past user's deadline + intent.deadline = now + .checked_add(cfg.intent_expiry) + .unwrap_or(u64::MAX) + .min(intent.user_deadline); // Persist both records BEFORE any token transfer so that a re-entrant // or back-to-back call on the same intent_id is rejected by the @@ -1782,6 +1832,48 @@ Please follow this repo's Conventional Commits format for your commit messages ( }) } + /// #349: Get solver's bond for a specific token (single source of truth) + fn get_solver_bond(env: &Env, solver: &Address, token: &Address) -> i128 { + env.storage() + .persistent() + .get(&DataKey::SolverBond(solver.clone(), token.clone())) + .unwrap_or(0) + } + + /// #349: Set solver's bond for a specific token and atomically update total + /// All bond mutations must use this to keep SolverBond and TotalBondedByToken in sync. + fn set_solver_bond(env: &Env, solver: &Address, token: &Address, amount: i128) { + let old_amount = Self::get_solver_bond(env, solver, token); + env.storage() + .persistent() + .set(&DataKey::SolverBond(solver.clone(), token.clone()), &amount); + + // Update total bonded + let total: i128 = env + .storage() + .persistent() + .get(&DataKey::TotalBondedByToken(token.clone())) + .unwrap_or(0); + let new_total = total - old_amount + amount; + env.storage() + .persistent() + .set(&DataKey::TotalBondedByToken(token.clone()), &new_total); + } + + // ── Implementation notes for #349 and #350 ────────────────────────────── + // #349: TODO - Replace all bond_amount field accesses with get_solver_bond/set_solver_bond: + // register_solver (lines ~793, 809): use set_solver_bond(solver, bond_token, new_amount) + // withdraw_bond (lines ~927, 937): use set_solver_bond + // slash_solver (lines ~1435, 1436): use set_solver_bond + // accept_intent (line ~1130): use get_solver_bond for min_bond check + // is_solver_eligible (line ~1689): use get_solver_bond + // + // #350: TODO - Add registry integration: + // Add DataKey::Registry(Address) for the registry contract address (optional) + // In fill_intent when state becomes Filled: call registry.record_fill(solver) + // In slash_solver: call registry.record_failure(solver) and registry.slash(solver, amount) + // Decide whether registry failures should propagate or be swallowed + fn bump_instance_ttl(env: &Env) { env.storage() .instance() diff --git a/intent_settlement/src/test.rs b/intent_settlement/src/test.rs index cfbf281..9a02dac 100644 --- a/intent_settlement/src/test.rs +++ b/intent_settlement/src/test.rs @@ -169,7 +169,7 @@ fn admin_can_propose_and_accept_fee_recipient() { c.accept_intent(&ctx.solver, &id); let fee = FILL * 5 / 10_000; ctx.dst_admin().mint(&ctx.solver, &(FILL + fee)); - c.fill_intent(&ctx.solver, &id, &FILL); + c.fill_intent(&ctx.solver, &id, &FILL, &SRC_AMT); assert_eq!(ctx.dst().balance(&new_recipient), fee); } @@ -566,7 +566,7 @@ fn active_intents_counts_multiple_concurrent_accepted_intents() { // Clearing one via fill decrements the counter but doesn't zero it. let fee = FILL * 5 / 10_000; ctx.dst_admin().mint(&ctx.solver, &(FILL + fee)); - c.fill_intent(&ctx.solver, &id1, &FILL); + c.fill_intent(&ctx.solver, &id1, &FILL, &SRC_AMT); assert_eq!(c.get_solver(&ctx.solver).unwrap().active_intents, 1); let res = c.try_deregister_solver(&ctx.solver); assert_eq!(res, Err(Ok(Error::SolverHasActiveIntents.into()))); @@ -588,7 +588,7 @@ fn deregister_after_fill_succeeds() { let fee = FILL * 5 / 10_000; ctx.dst_admin().mint(&ctx.solver, &(FILL + fee)); - c.fill_intent(&ctx.solver, &id, &FILL); + c.fill_intent(&ctx.solver, &id, &FILL, &SRC_AMT); // Obligation cleared on fill, so deregistration now succeeds. c.deregister_solver(&ctx.solver); @@ -816,7 +816,7 @@ fn full_lifecycle_submit_accept_fill() { // Fill — fund the solver with the output plus the protocol fee they pay. let fee = FILL * 5 / 10_000; ctx.dst_admin().mint(&ctx.solver, &(FILL + fee)); - c.fill_intent(&ctx.solver, &id, &FILL); + c.fill_intent(&ctx.solver, &id, &FILL, &SRC_AMT); let intent = c.get_intent(&id).unwrap(); assert!(intent.state == IntentState::Filled); @@ -913,7 +913,7 @@ fn fill_zero_amount_fails() { let id = ctx.submit(); ctx.client().accept_intent(&ctx.solver, &id); - let res = ctx.client().try_fill_intent(&ctx.solver, &id, &0); + let res = ctx.client().try_fill_intent(&ctx.solver, &id, &0, &SRC_AMT); assert_eq!(res, Err(Ok(Error::ZeroAmount.into()))); } @@ -926,7 +926,7 @@ fn fill_after_window_fails() { ctx.pass_time(FILL_WINDOW + 1); ctx.dst_admin().mint(&ctx.solver, &FILL); - let res = ctx.client().try_fill_intent(&ctx.solver, &id, &FILL); + let res = ctx.client().try_fill_intent(&ctx.solver, &id, &FILL, &SRC_AMT); assert_eq!(res, Err(Ok(Error::FillWindowExpired.into()))); } @@ -942,7 +942,7 @@ fn fill_by_wrong_solver_fails() { ctx.client().register_solver(&other, &BOND); ctx.dst_admin().mint(&other, &FILL); - let res = ctx.client().try_fill_intent(&other, &id, &FILL); + let res = ctx.client().try_fill_intent(&other, &id, &FILL, &SRC_AMT); assert_eq!(res, Err(Ok(Error::Unauthorized.into()))); } @@ -1251,7 +1251,7 @@ fn fill_intent_state_committed_before_transfer_and_double_fill_rejected() { ctx.dst_admin().mint(&ctx.solver, &(FILL + fee)); // Happy-path fill. - c.fill_intent(&ctx.solver, &id, &FILL); + c.fill_intent(&ctx.solver, &id, &FILL, &SRC_AMT); // 1. Storage reflects Filled and fill_amount is set. let intent = c.get_intent(&id).unwrap(); @@ -1266,7 +1266,7 @@ fn fill_intent_state_committed_before_transfer_and_double_fill_rejected() { // 3. A second fill attempt is rejected before any transfer — this is exactly // what a re-entrant token would hit mid-transfer after the CEI reorder. ctx.dst_admin().mint(&ctx.solver, &(FILL + fee)); // give solver funds again - let res = c.try_fill_intent(&ctx.solver, &id, &FILL); + let res = c.try_fill_intent(&ctx.solver, &id, &FILL, &SRC_AMT); assert_eq!(res, Err(Ok(Error::IntentAlreadyFilled.into()))); // User's balance must not have increased — no double-payment. @@ -1420,10 +1420,11 @@ fn two_partial_fills_complete_intent() { // First partial fill: half of MIN_DST. let half = MIN_DST / 2; + let half_src = SRC_AMT / 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); + c.fill_intent(&ctx.solver, &id, &half, &half_src); // Intent should now be PartiallyFilled and re-opened (solver reset). let intent = c.get_intent(&id).unwrap(); @@ -1436,10 +1437,11 @@ fn two_partial_fills_complete_intent() { // Second fill: the remainder — brings total to MIN_DST. let remainder = MIN_DST - half; + let remainder_src = SRC_AMT - half_src; 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); + c.fill_intent(&ctx.solver, &id, &remainder, &remainder_src); let intent = c.get_intent(&id).unwrap(); assert_eq!(intent.state, IntentState::Filled); @@ -1465,10 +1467,11 @@ fn partial_fill_left_incomplete_past_deadline_can_be_expired() { // Deliver a partial fill (less than MIN_DST). let partial = MIN_DST / 3; + let partial_src = SRC_AMT / 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); + c.fill_intent(&ctx.solver, &id, &partial, &partial_src); // Intent is PartiallyFilled and re-opened with a fresh INTENT_EXPIRY deadline. assert_eq!( @@ -1624,7 +1627,7 @@ fn get_reputation_score_after_fill_is_nonzero() { c.accept_intent(&ctx.solver, &id); let fee = FILL * 5 / 10_000; ctx.dst_admin().mint(&ctx.solver, &(FILL + fee)); - c.fill_intent(&ctx.solver, &id, &FILL); + c.fill_intent(&ctx.solver, &id, &FILL, &SRC_AMT); let score = c.get_reputation_score(&ctx.solver).unwrap(); assert!(score > 0, "score after fill should be > 0"); @@ -1637,7 +1640,7 @@ fn get_reputation_score_after_fill_is_nonzero() { // rolls back on panic_with_error, so the user's balance stays zero). ctx.dst_admin().mint(&ctx.solver, &overflow_fill); - let res = c.try_fill_intent(&ctx.solver, &id, &overflow_fill); + let res = c.try_fill_intent(&ctx.solver, &id, &overflow_fill, &SRC_AMT); assert_eq!(res, Err(Ok(Error::FeeOverflow.into()))); } @@ -1656,7 +1659,7 @@ fn fill_intent_fee_at_boundary_does_not_overflow() { ctx.dst_admin().mint(&ctx.solver, &(boundary_fill + fee)); // Should succeed (no overflow). - c.fill_intent(&ctx.solver, &id, &boundary_fill); + c.fill_intent(&ctx.solver, &id, &boundary_fill, &SRC_AMT); assert!(c.get_intent(&id).unwrap().state == IntentState::Filled); } @@ -1971,7 +1974,7 @@ fn unpause_restores_solver_bond_management() { 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); + c.fill_intent(&ctx.solver, &id, &MIN_DST, &SRC_AMT); let intent = c.get_intent(&id).unwrap(); assert_eq!(intent.state, IntentState::Filled);