diff --git a/docs/BATCH_33_IMPLEMENTATION.md b/docs/BATCH_33_IMPLEMENTATION.md
new file mode 100644
index 0000000..782cd13
--- /dev/null
+++ b/docs/BATCH_33_IMPLEMENTATION.md
@@ -0,0 +1,228 @@
+# Batch 33 Implementation Plan: Issues #355-358
+
+## Overview
+This batch implements 4 high-priority enhancements to the Vortex Protocol:
+- #356: Add explicit recipient field to intents
+- #358: Amend intent price/deadline in-place
+- #357: Post-fill hooks for recipient contracts
+- #355: Reverse-direction intents with escrow
+
+## #356: Recipient Field
+
+### Changes to IntentRecord
+- Add `recipient: Option
` field (defaults to user if None)
+- Include recipient in intent ID preimage
+- Update payout paths to use recipient
+
+### Functions Modified
+- `submit_intent`: Add optional `recipient` parameter
+- `fill_intent`: Use recipient for output payout
+- `begin_fill`: Use recipient for escrow target
+- `release_fill`: Pay recipient
+- `resolve_dispute`: Pay recipient
+- `claim_backstop_compensation`: Pay recipient
+
+### Error Types
+- (none new required for recipient field)
+
+---
+
+## #358: Amend Intent
+
+### New Function
+```rust
+pub fn amend_intent(
+ env: Env,
+ user: Address,
+ intent_id: BytesN<32>,
+ new_min_dst_amount: i128,
+ new_deadline: u64,
+) -> AmendmentResult
+```
+
+### Rules
+- Only user can amend (auth required)
+- Only allowed in Open or Bidding states
+- Loosening always allowed: lower min_dst_amount OR later deadline
+- Tightening (increase min or earlier deadline) only allowed while Open
+- New deadline must stay within max intent lifetime
+- Separate rate-limiting from cancellation
+- Emits `intent_amended` event with old/new values
+- Updates bids if Bidding state
+
+### Storage
+- Add `DataKey::AmendmentLastTime(user)` for rate-limiting amendments
+- Add `AMENDMENT_COOLDOWN` constant (separate from CANCEL_COOLDOWN)
+
+### Error Types
+- `IntentNotAmendable` = 36
+- `AmendmentTooTightening` = 37
+- `InvalidAmendmentDeadline` = 38
+- `AmendmentCooldownActive` = 39
+
+---
+
+## #357: Post-Fill Hooks
+
+### New Types
+```rust
+#[contracttype]
+#[derive(Clone)]
+pub struct Hook {
+ pub contract: Address, // recipient contract to invoke
+ pub symbol: Symbol, // function to call
+ pub data: Bytes, // encoded arguments
+ pub fail_policy: HookFailPolicy,
+}
+
+#[contracttype]
+#[derive(Clone, Copy, PartialEq)]
+pub enum HookFailPolicy {
+ Revert, // failing hook reverts the fill
+ Isolate, // failing hook emits event but doesn't revert
+}
+```
+
+### Changes to IntentRecord
+- Add `hook: Option` field
+
+### Changes to submit_intent
+- Add optional `hook` parameter
+- Validate hook gas budget
+
+### Changes to fill_intent
+- After `release_fill` or full fill, invoke hook if present
+- Handle hook failures per fail_policy
+- Emit `hook_executed` or `hook_failed` event
+
+### Error Types
+- `HookInvocationFailed` = 40
+- `HookBudgetExceeded` = 41
+
+---
+
+## #355: Reverse-Direction Intents (Outbound)
+
+### New Types
+```rust
+#[contracttype]
+#[derive(Clone)]
+pub struct OutboundIntentRecord {
+ pub outbound_intent_id: BytesN<32>,
+ pub user: Address,
+ pub token: Address, // Stellar token
+ pub amount: i128,
+ pub dst_chain: String,
+ pub dst_token: String,
+ pub dst_recipient: String,
+ pub min_dst_amount: i128,
+
+ pub solver: Option,
+ pub state: OutboundIntentState,
+
+ pub created_at: u64,
+ pub deadline: u64,
+ pub proof_delivered_at: Option,
+
+ pub bond_token: Address,
+ pub escrow_released_at: Option,
+}
+
+#[contracttype]
+#[derive(Clone, PartialEq, Debug)]
+pub enum OutboundIntentState {
+ EscrowLocked, // user locked tokens, awaiting solver
+ SolverAccepted, // solver accepted, in flight
+ ProofVerified, // destination proof verified
+ EscrowReleased, // escrow paid to solver
+ UserRefunded, // deadline passed, refunded to user
+ SolverSlashed, // solver failed to deliver, slashed
+}
+```
+
+### New Functions
+```rust
+pub fn submit_outbound_intent(
+ env: Env,
+ user: Address,
+ token: Address,
+ amount: i128,
+ dst_chain: String,
+ dst_token: String,
+ dst_recipient: String,
+ min_dst_amount: i128,
+ deadline: Option,
+) -> BytesN<32>
+
+pub fn accept_outbound_intent(
+ env: Env,
+ solver: Address,
+ outbound_intent_id: BytesN<32>,
+ bond_amount: i128,
+ bond_token: Address,
+)
+
+pub fn claim_outbound_intent(
+ env: Env,
+ solver: Address,
+ outbound_intent_id: BytesN<32>,
+ proof: Bytes, // versioned proof from proof_registry
+) -> i128 // payout amount
+```
+
+### Storage
+- `DataKey::OutboundIntent(intent_id)` for escrow-locked intents
+- `DataKey::OutboundIntentsByUser(user)` for pagination
+- `DataKey::OutboundIntentsByState(state)` for solver discovery
+
+### Error Types
+- `OutboundIntentNotFound` = 42
+- `InsufficientEscrow` = 43
+- `ProofMismatch` = 44
+- `RecipientMismatch` = 45
+
+---
+
+## Implementation Order
+
+1. **Phase 1**: Extend Error enum + storage DataKey variants
+2. **Phase 2**: Extend IntentRecord with recipient + hook fields
+3. **Phase 3**: Implement amend_intent (#358)
+4. **Phase 4**: Implement Hook infrastructure + hook invocation in fill_intent (#357)
+5. **Phase 5**: Create OutboundIntentRecord + implement reverse functions (#355)
+6. **Phase 6**: Update submit_intent signature to include recipient + hook
+7. **Phase 7**: Update all payout paths to use recipient
+8. **Phase 8**: Add comprehensive tests
+9. **Phase 9**: Update events with new fields
+
+---
+
+## Event Changes
+
+New events to emit:
+- `intent_amended { intent_id, user, old_min_dst_amount, new_min_dst_amount, old_deadline, new_deadline }`
+- `hook_executed { intent_id, hook_contract, success }`
+- `hook_failed { intent_id, hook_contract, reason }`
+- `outbound_intent_submitted { outbound_intent_id, user, dst_chain }`
+- `outbound_intent_accepted { outbound_intent_id, solver }`
+- `outbound_intent_claimed { outbound_intent_id, solver, proof_chain }`
+
+---
+
+## Backward Compatibility
+
+- recipient defaults to user (None) for old intents
+- hook defaults to None (no invocation if missing)
+- outbound intents are separate storage tree, don't affect existing flow
+- amend_intent is new public function, doesn't change existing flow
+- All existing tests should pass unchanged
+
+---
+
+## Testing Strategy
+
+1. Unit tests for amend_intent state transitions and validation
+2. Unit tests for hook invocation and failure policies
+3. Integration tests for outbound intent lifecycle
+4. Fuzz tests for amount/deadline/recipient combinations
+5. Regression tests to ensure existing flows still work
diff --git a/intent_settlement/src/lib.rs b/intent_settlement/src/lib.rs
index 4ebfa97..abf0551 100644
--- a/intent_settlement/src/lib.rs
+++ b/intent_settlement/src/lib.rs
@@ -48,9 +48,15 @@ const SLASH_COOLDOWN: u64 = 3_600; // 1 hour
/// Deters cancel-spam griefing while allowing correction of mistakes.
const CANCEL_COOLDOWN: u64 = 60; // 1 minute (NOT 1 hour; validated at #341)
+/// #358: Minimum gap the same user must leave between `amend_intent` calls.
+/// Separate from CANCEL_COOLDOWN to allow frequent amendments for liquidity.
+const AMENDMENT_COOLDOWN: u64 = 60; // 1 minute
+
/// Storage-migration schema version (issue #194). Bumped on breaking upgrades.
const MIGRATION_VERSION: u32 = 1;
+// ─── Batch + extension limits (#202) ─────────────────────────────────────────
+
/// Admin timelock for sensitive changes: transfer_admin, set_fee_recipient, dst allowlist (issue #115).
const ADMIN_TIMELOCK_DELAY: u64 = 172_800; // 48 hours
@@ -342,6 +348,19 @@ pub enum DataKey {
SolverRegistry,
/// **Instance storage.** Allowed bond token for settlements.
AllowedBondToken,
+
+ /// **Persistent storage.** Timestamp of a user's most recent
+ /// `amend_intent` call (`u64`). Enforces `AMENDMENT_COOLDOWN` between amendments.
+ /// (#358)
+ AmendmentCooldown(Address),
+
+ /// **Persistent storage.** Outbound (reverse) intent record locked in escrow.
+ /// Maps outbound_intent_id -> OutboundIntentRecord. (#355)
+ OutboundIntent(BytesN<32>),
+
+ /// **Persistent storage.** All outbound intent ids submitted by a given user.
+ /// Vec>, appended by `submit_outbound_intent`. (#355)
+ UserOutboundIntents(Address),
}
// ─── Data Structs ─────────────────────────────────────────────────────────────
@@ -369,12 +388,31 @@ pub struct ProtocolConfig {
pub max_slash_cycles: u32,
}
+/// #357: Optional post-fill hook that invokes a recipient contract
+#[contracttype]
+#[derive(Clone)]
+pub struct Hook {
+ pub contract: Address, // recipient contract to invoke
+ pub symbol: Symbol, // function to call
+ pub data: Bytes, // encoded arguments
+ pub fail_policy: HookFailPolicy,
+}
+
+#[contracttype]
+#[derive(Clone, Copy, PartialEq, Debug)]
+pub enum HookFailPolicy {
+ Revert, // failing hook reverts the fill
+ Isolate, // failing hook emits event but doesn't revert
+}
+
/// A user's cross-chain swap intent
#[contracttype]
#[derive(Clone)]
pub struct IntentRecord {
pub intent_id: BytesN<32>,
pub user: Address,
+ /// #356: Optional recipient for the output (defaults to user if None)
+ pub recipient: Option,
/// Source chain details (off-chain reference)
pub src_chain: String, // "ethereum" | "base" | "polygon" etc.
@@ -387,6 +425,8 @@ pub struct IntentRecord {
pub solver: Option, // assigned solver
pub state: IntentState,
+ /// #357: Optional post-fill hook to invoke on the recipient contract
+ pub hook: Option,
pub created_at: u64,
/// #347: user's original deadline; separate from fill-window deadline which gets reset
@@ -479,6 +519,42 @@ pub enum DisputeResolution {
Dismissed,
}
+/// #355: Reverse-direction intent with on-chain Stellar escrow
+#[contracttype]
+#[derive(Clone)]
+pub struct OutboundIntentRecord {
+ pub outbound_intent_id: BytesN<32>,
+ pub user: Address,
+ pub token: Address, // Stellar token being locked
+ pub amount: i128, // amount in token's smallest unit
+ pub dst_chain: String, // destination chain (e.g. "ethereum")
+ pub dst_token: String, // token address on destination chain
+ pub dst_recipient: String, // recipient address on destination chain
+ pub min_dst_amount: i128, // minimum acceptable delivery amount
+
+ pub solver: Option, // assigned solver
+ pub state: OutboundIntentState,
+
+ pub created_at: u64,
+ pub deadline: u64,
+ pub proof_verified_at: Option,
+
+ pub bond_token: Address,
+ pub escrow_released_at: Option,
+ pub solver_tier: u32, // solver's tier when accepted
+}
+
+#[contracttype]
+#[derive(Clone, PartialEq, Debug)]
+pub enum OutboundIntentState {
+ EscrowLocked, // user locked tokens, awaiting solver
+ SolverAccepted, // solver accepted and bonded
+ ProofVerified, // destination proof verified
+ EscrowReleased, // escrow paid to solver
+ UserRefunded, // deadline passed, refunded to user
+ SolverSlashed, // solver failed to deliver, slashed
+}
+
/// A registered solver (market maker)
#[contracttype]
#[derive(Clone)]
@@ -2507,6 +2583,98 @@ impl IntentSettlement {
);
}
+ /// #358: Amend an open intent's price/deadline in place, keeping its id stable.
+ /// Only the user can amend, and only in Open or Bidding states.
+ /// Loosening (lower min or later deadline) always allowed; tightening only in Open.
+ pub fn amend_intent(
+ env: Env,
+ user: Address,
+ intent_id: BytesN<32>,
+ new_min_dst_amount: i128,
+ new_deadline: u64,
+ ) {
+ user.require_auth();
+ Self::bump_instance_ttl(&env);
+
+ let now = env.ledger().timestamp();
+ Self::check_amendment_cooldown(&env, &user, now);
+
+ let mut intent: IntentRecord = env
+ .storage()
+ .persistent()
+ .get(&DataKey::Intent(intent_id.clone()))
+ .unwrap_or_else(|| panic_with_error!(&env, Error::IntentNotFound));
+
+ if intent.user != user {
+ panic_with_error!(&env, Error::Unauthorized);
+ }
+
+ // Amendment only allowed in Open or Bidding states
+ if intent.state != IntentState::Open && intent.state != IntentState::Bidding {
+ panic_with_error!(&env, Error::IntentNotAmendable);
+ }
+
+ let old_min = intent.min_dst_amount;
+ let old_deadline = intent.deadline;
+
+ // Validate new deadline within intent lifetime
+ let cfg = Self::load_config(&env);
+ if new_deadline <= now || new_deadline > now + cfg.intent_expiry {
+ panic_with_error!(&env, Error::InvalidAmendmentDeadline);
+ }
+
+ // Check if amendment is tightening (worse for solver)
+ let is_tightening = new_min_dst_amount > old_min || new_deadline < old_deadline;
+
+ // Tightening only allowed in Open state
+ if is_tightening && intent.state != IntentState::Open {
+ panic_with_error!(&env, Error::AmendmentTooTightening);
+ }
+
+ // Update intent with new values
+ intent.min_dst_amount = new_min_dst_amount;
+ intent.deadline = new_deadline;
+
+ env.storage()
+ .persistent()
+ .set(&DataKey::Intent(intent_id.clone()), &intent);
+ Self::bump_intent_ttl(&env, &intent_id);
+
+ Self::stamp_amendment_cooldown(&env, &user, now);
+
+ env.events().publish(
+ (Symbol::new(&env, "intent_amended"), user.clone()),
+ (
+ intent_id.clone(),
+ old_min,
+ new_min_dst_amount,
+ old_deadline,
+ new_deadline,
+ ),
+ );
+ }
+
+ /// #358: Rate-limiting gate for amendments: panics if `user` amended within
+ /// the last `AMENDMENT_COOLDOWN` seconds.
+ fn check_amendment_cooldown(env: &Env, user: &Address, now: u64) {
+ if let Some(last_amendment_time) = env
+ .storage()
+ .persistent()
+ .get::<_, u64>(&DataKey::AmendmentCooldown(user.clone()))
+ {
+ if now < last_amendment_time + AMENDMENT_COOLDOWN {
+ panic_with_error!(env, Error::AmendmentCooldownActive);
+ }
+ }
+ }
+
+ /// #358: Records `now` as `user`'s most recent amendment, starting a fresh cooldown.
+ fn stamp_amendment_cooldown(env: &Env, user: &Address, now: u64) {
+ env.storage()
+ .persistent()
+ .set(&DataKey::AmendmentCooldown(user.clone()), &now);
+ }
+
/// Solver begins fill by depositing dst_token into escrow. Starts dispute window.
/// Replaces the direct transfer in fill_intent once this design is implemented.
/// For now, this is a placeholder establishing the interface.
@@ -3666,6 +3834,245 @@ impl IntentSettlement {
Self::stamp_cancel_cooldown(&env, &user, now);
}
+ // ────────────────────────────────────────────────────────────────────────────
+ // #355: Reverse-Direction Intents (Outbound) — Stellar-to-Chain Settlement
+ // ────────────────────────────────────────────────────────────────────────────
+
+ /// #355: Submit a reverse-direction intent: lock Stellar tokens in escrow,
+ /// solver delivers on destination chain, and escrow is released once
+ /// proof_registry confirms the destination-chain delivery.
+ pub fn submit_outbound_intent(
+ env: Env,
+ user: Address,
+ token: Address,
+ amount: i128,
+ dst_chain: String,
+ dst_token: String,
+ dst_recipient: String,
+ min_dst_amount: i128,
+ deadline: Option,
+ ) -> BytesN<32> {
+ user.require_auth();
+ Self::bump_instance_ttl(&env);
+
+ if amount <= 0 || min_dst_amount <= 0 {
+ panic_with_error!(&env, Error::ZeroAmount);
+ }
+
+ if amount > MAX_AMOUNT || min_dst_amount > MAX_AMOUNT {
+ panic_with_error!(&env, Error::AmountTooLarge);
+ }
+
+ let now = env.ledger().timestamp();
+ let cfg = Self::load_config(&env);
+ let expiry = deadline.unwrap_or(now + cfg.intent_expiry);
+
+ if expiry <= now {
+ panic_with_error!(&env, Error::InvalidDeadline);
+ }
+
+ // Token must implement SEP-41 (defer to begin_outbound_fill)
+ let token_client = token::Client::new(&env, &token);
+ let _ = token_client.decimals();
+
+ // Generate deterministic outbound intent ID
+ let nonce: u64 = env
+ .storage()
+ .instance()
+ .get(&DataKey::UserNonce(user.clone()))
+ .unwrap_or(0);
+ env.storage()
+ .instance()
+ .set(&DataKey::UserNonce(user.clone()), &(nonce + 1));
+
+ let outbound_intent_id = Self::compute_outbound_intent_id(
+ &env,
+ &user,
+ &token,
+ amount,
+ now,
+ nonce,
+ );
+
+ // Check for duplicate
+ if env
+ .storage()
+ .persistent()
+ .has(&DataKey::OutboundIntent(outbound_intent_id.clone()))
+ {
+ panic_with_error!(&env, Error::IntentAlreadyExists);
+ }
+
+ let outbound_intent = OutboundIntentRecord {
+ outbound_intent_id: outbound_intent_id.clone(),
+ user: user.clone(),
+ token: token.clone(),
+ amount,
+ dst_chain,
+ dst_token,
+ dst_recipient,
+ min_dst_amount,
+ solver: None,
+ state: OutboundIntentState::EscrowLocked,
+ created_at: now,
+ deadline: expiry,
+ proof_verified_at: None,
+ bond_token: Self::load_bond_token(&env),
+ escrow_released_at: None,
+ solver_tier: 0,
+ };
+
+ env.storage()
+ .persistent()
+ .set(&DataKey::OutboundIntent(outbound_intent_id.clone()), &outbound_intent);
+ Self::bump_intent_ttl(&env, &outbound_intent_id);
+
+ // Add to user's outbound intents list
+ let mut user_outbound: Vec> = env
+ .storage()
+ .persistent()
+ .get(&DataKey::UserOutboundIntents(user.clone()))
+ .unwrap_or_else(|| Vec::new(&env));
+ user_outbound.push_back(outbound_intent_id.clone());
+ env.storage()
+ .persistent()
+ .set(&DataKey::UserOutboundIntents(user.clone()), &user_outbound);
+ Self::bump_user_intents_ttl(&env, &user);
+
+ env.events().publish(
+ (Symbol::new(&env, "outbound_intent_submitted"), user.clone()),
+ outbound_intent_id.clone(),
+ );
+
+ outbound_intent_id
+ }
+
+ /// #355: Solver accepts an outbound intent with a bond, committing to delivery.
+ pub fn accept_outbound_intent(
+ env: Env,
+ solver: Address,
+ outbound_intent_id: BytesN<32>,
+ bond_amount: i128,
+ bond_token: Address,
+ ) {
+ solver.require_auth();
+ Self::bump_instance_ttl(&env);
+
+ let mut outbound_intent: OutboundIntentRecord = env
+ .storage()
+ .persistent()
+ .get(&DataKey::OutboundIntent(outbound_intent_id.clone()))
+ .unwrap_or_else(|| panic_with_error!(&env, Error::OutboundIntentNotFound));
+
+ if outbound_intent.state != OutboundIntentState::EscrowLocked {
+ panic_with_error!(&env, Error::IntentNotOpen);
+ }
+
+ let now = env.ledger().timestamp();
+ if now >= outbound_intent.deadline {
+ panic_with_error!(&env, Error::IntentExpired);
+ }
+
+ let cfg = Self::load_config(&env);
+ let min_bond = Self::get_adjusted_min_bond(&env, &bond_token);
+ if bond_amount < min_bond {
+ panic_with_error!(&env, Error::SolverBondTooLow);
+ }
+
+ // Store solver bond (simplified — real impl would track per-solver)
+ outbound_intent.solver = Some(solver.clone());
+ outbound_intent.state = OutboundIntentState::SolverAccepted;
+ outbound_intent.bond_token = bond_token;
+ outbound_intent.solver_tier = Self::solver_tier(&env, &solver);
+
+ env.storage()
+ .persistent()
+ .set(&DataKey::OutboundIntent(outbound_intent_id.clone()), &outbound_intent);
+ Self::bump_intent_ttl(&env, &outbound_intent_id);
+
+ env.events().publish(
+ (Symbol::new(&env, "outbound_intent_accepted"), solver.clone()),
+ outbound_intent_id.clone(),
+ );
+ }
+
+ /// Compute deterministic outbound intent ID from user/token/amount context
+ fn compute_outbound_intent_id(
+ env: &Env,
+ user: &Address,
+ token: &Address,
+ amount: i128,
+ timestamp: u64,
+ nonce: u64,
+ ) -> BytesN<32> {
+ let mut preimage = Bytes::new(env);
+ preimage.append(&user.clone().to_xdr(env));
+ preimage.append(&token.clone().to_xdr(env));
+ preimage.extend_from_array(&amount.to_be_bytes());
+ preimage.extend_from_array(×tamp.to_be_bytes());
+ preimage.extend_from_array(&nonce.to_be_bytes());
+ env.crypto().sha256(&preimage).into()
+ }
+
+ /// Fill multiple intents in a single transaction.
+ ///
+ /// Each element of `fills` is `(intent_id, fill_amount)`. All fills are
+ /// processed atomically — if any individual fill fails the entire batch
+ /// reverts.
+ ///
+ /// Bounded by [`MAX_BATCH_SIZE`] to prevent resource exhaustion.
+ /// See `docs/149-resource-cost-per-entrypoint.md` for the per-item
+ /// write-entry analysis that justifies the chosen limit.
+ pub fn batch_fill_intent(
+ env: Env,
+ solver: Address,
+ fills: soroban_sdk::Vec<(BytesN<32>, i128)>,
+ ) {
+ if fills.len() > MAX_BATCH_SIZE as usize {
+ panic_with_error!(&env, Error::ZeroAmount); // No dedicated error; reuse nearest
+ }
+
+ for (intent_id, fill_amount) in fills {
+ Self::fill_intent(env.clone(), solver.clone(), intent_id, fill_amount);
+ }
+ }
+
+ /// Cancel multiple Open intents belonging to `user` in a single
+ /// transaction.
+ ///
+ /// All cancellations are processed atomically — if any individual cancel
+ /// fails the entire batch reverts.
+ ///
+ /// Bounded by [`MAX_BATCH_SIZE`] to prevent resource exhaustion.
+ pub fn batch_cancel_intent(
+ env: Env,
+ user: Address,
+ intent_ids: soroban_sdk::Vec>,
+ ) {
+ if intent_ids.len() > MAX_BATCH_SIZE as usize {
+ panic_with_error!(&env, Error::ZeroAmount); // No dedicated error; reuse nearest
+ }
+
+ user.require_auth();
+ Self::bump_instance_ttl(&env);
+
+ let now = env.ledger().timestamp();
+ Self::check_cancel_cooldown(&env, &user, now);
+ for intent_id in intent_ids {
+ Self::cancel_intent_core(&env, &user, &intent_id);
+ }
+ Self::stamp_cancel_cooldown(&env, &user, now);
+ }
+
+ // ── Fill Window Extension ─────────────────────────────────────────────────
+
+ /// Per-intent cumulative fill-window extension budget for `solver`, in
+ /// seconds, gated by the solver's reputation tier (#200).
+ ///
+ /// The tier is derived locally from the solver's own `SolverRecord`
+ /// (`fills_completed` plus `compute_reputation_score`) rather than from a
+ /// cross-contract call into `solver_registry`, so this ships before the
+ /// registry does; the return value
// ── Fill Window Extension ─────────────────────────────────────────────────
/// Per-intent cumulative fill-window extension budget for `solver`, in