Repository: chainflip-io/chainflip-backend
Pull request #5836: feat: limit order auto expire
Author: login: j4m1ef0rd
Labels: name: State Chain
Created: 2025-04-29T04:24:28Z | Merged: 2025-05-19T07:23:57Z

# Pull Request

Closes: PRO-1734

## Checklist

- [x] I am confident that the code works.
- [x] I have written sufficient tests.
- [ ] I have updated documentation where appropriate.

## Summary

- Added `dispatch_at` field to the `set_limit_order` and `update_limit_order` extrinsics.
- Added an `expire_at` field to `set_limit_order`, Allowing an LP to automatically schedule a cancel order at the desired block.
- Changed the storage item for scheduling limit order updates to a struct.
- Added a migration to the new struct.
- Refactored how the limit order updates are scheduled and dispatched.
- Changed the `schedule_limit_order_update` Call to use the new struct instead of a call.

TODO: Update docs



Diff:
diff --git a/api/bin/chainflip-lp-api/src/main.rs b/api/bin/chainflip-lp-api/src/main.rs
index 3c7f274385d..1587a6fbc18 100644
--- a/api/bin/chainflip-lp-api/src/main.rs
+++ b/api/bin/chainflip-lp-api/src/main.rs
@@ -242,6 +242,7 @@ impl LpRpcApiServer for RpcServerImpl {
 		sell_amount: NumberOrHex,
 		dispatch_at: Option<BlockNumber>,
 		wait_for: Option<WaitFor>,
+		close_order_at: Option<BlockNumber>,
 	) -> RpcResult<ApiWaitForResult<Vec<LimitOrder>>> {
 		Ok(self
 			.api
@@ -255,6 +256,7 @@ impl LpRpcApiServer for RpcServerImpl {
 				try_parse_number_or_hex(sell_amount)?,
 				dispatch_at,
 				wait_for.unwrap_or_default(),
+				close_order_at,
 			)
 			.await?)
 	}
diff --git a/api/cf-rpc-apis/src/lp.rs b/api/cf-rpc-apis/src/lp.rs
index 2fd86aaec20..1c991797182 100644
--- a/api/cf-rpc-apis/src/lp.rs
+++ b/api/cf-rpc-apis/src/lp.rs
@@ -121,6 +121,7 @@ pub trait LpRpcApi {
 		sell_amount: NumberOrHex,
 		dispatch_at: Option<BlockNumber>,
 		wait_for: Option<WaitFor>,
+		close_order_at: Option<BlockNumber>,
 	) -> RpcResult<ApiWaitForResult<Vec<LimitOrder>>>;
 
 	#[method(name = "free_balances", aliases = ["lp_asset_balances"])]
diff --git a/api/lib/src/lp.rs b/api/lib/src/lp.rs
index 1b17ac57a99..1ad7aa9cb9d 100644
--- a/api/lib/src/lp.rs
+++ b/api/lib/src/lp.rs
@@ -15,7 +15,7 @@
 // SPDX-License-Identifier: Apache-2.0
 
 use super::SimpleSubmissionApi;
-use anyhow::{bail, Result};
+use anyhow::{anyhow, bail, Result};
 use async_trait::async_trait;
 pub use cf_amm::{
 	common::{PoolPairsMap, Side},
@@ -200,9 +200,7 @@ pub trait LpApi: SignedExtrinsicApi + Sized + Send + Sync + 'static {
 						}),
 						_ => None,
 					})
-					.ok_or_else(|| {
-						anyhow::anyhow!("No LiquidityDepositAddressReady event was found")
-					})?;
+					.ok_or_else(|| anyhow!("No LiquidityDepositAddressReady event was found"))?;
 
 				ApiWaitForResult::TxDetails { tx_hash, response: encoded_address }
 			},
@@ -245,9 +243,7 @@ pub trait LpApi: SignedExtrinsicApi + Sized + Send + Sync + 'static {
 						) => Some(egress_id),
 						_ => None,
 					})
-					.ok_or_else(|| {
-						anyhow::anyhow!("No WithdrawalEgressScheduled event was found")
-					})?;
+					.ok_or_else(|| anyhow!("No WithdrawalEgressScheduled event was found"))?;
 
 				ApiWaitForResult::TxDetails { tx_hash, response: egress_id }
 			},
@@ -338,19 +334,22 @@ pub trait LpApi: SignedExtrinsicApi + Sized + Send + Sync + 'static {
 		dispatch_at: Option<BlockNumber>,
 		wait_for: WaitFor,
 	) -> Result<ApiWaitForResult<Vec<LimitOrder>>> {
-		self.scheduled_or_immediate(
-			pallet_cf_pools::Call::update_limit_order {
-				base_asset,
-				quote_asset,
-				side,
-				id,
-				option_tick,
-				amount_change,
-			},
-			dispatch_at,
-			wait_for,
-		)
-		.await
+		Ok(into_api_wait_for_result(
+			self.submit_signed_extrinsic_wait_for(
+				pallet_cf_pools::Call::update_limit_order {
+					base_asset,
+					quote_asset,
+					side,
+					id,
+					option_tick,
+					amount_change,
+					dispatch_at,
+				},
+				wait_for,
+			)
+			.await?,
+			collect_limit_order_returns,
+		))
 	}
 
 	async fn set_limit_order(
@@ -363,41 +362,23 @@ pub trait LpApi: SignedExtrinsicApi + Sized + Send + Sync + 'static {
 		sell_amount: AssetAmount,
 		dispatch_at: Option<BlockNumber>,
 		wait_for: WaitFor,
-	) -> Result<ApiWaitForResult<Vec<LimitOrder>>> {
-		self.scheduled_or_immediate(
-			pallet_cf_pools::Call::set_limit_order {
-				base_asset,
-				quote_asset,
-				side,
-				id,
-				option_tick,
-				sell_amount,
-			},
-			dispatch_at,
-			wait_for,
-		)
-		.await
-	}
-
-	async fn scheduled_or_immediate(
-		&self,
-		call: pallet_cf_pools::Call<state_chain_runtime::Runtime>,
-		dispatch_at: Option<BlockNumber>,
-		wait_for: WaitFor,
+		close_order_at: Option<BlockNumber>,
 	) -> Result<ApiWaitForResult<Vec<LimitOrder>>> {
 		Ok(into_api_wait_for_result(
-			if let Some(dispatch_at) = dispatch_at {
-				self.submit_signed_extrinsic_wait_for(
-					pallet_cf_pools::Call::schedule_limit_order_update {
-						call: Box::new(call),
-						dispatch_at,
-					},
-					wait_for,
-				)
-				.await?
-			} else {
-				self.submit_signed_extrinsic_wait_for(call, wait_for).await?
-			},
+			self.submit_signed_extrinsic_wait_for(
+				pallet_cf_pools::Call::set_limit_order {
+					base_asset,
+					quote_asset,
+					side,
+					id,
+					option_tick,
+					sell_amount,
+					close_order_at,
+					dispatch_at,
+				},
+				wait_for,
+			)
+			.await?,
 			collect_limit_order_returns,
 		))
 	}
@@ -463,7 +444,7 @@ pub trait LpApi: SignedExtrinsicApi + Sized + Send + Sync + 'static {
 						) => Some(swap_request_id),
 						_ => None,
 					})
-					.ok_or_else(|| anyhow::anyhow!("No SwapRequested event was found"))?;
+					.ok_or_else(|| anyhow!("No SwapRequested event was found"))?;
 
 				ApiWaitForResult::TxDetails { tx_hash, response: swap_request_id }
 			},
diff --git a/bouncer/shared/limit_order.ts b/bouncer/shared/limit_order.ts
index e4e8f9677ef..6409531ee69 100644
--- a/bouncer/shared/limit_order.ts
+++ b/bouncer/shared/limit_order.ts
@@ -29,7 +29,7 @@ export async function limitOrder(
   }).event;
   await lpMutex.runExclusive(async () => {
     await chainflip.tx.liquidityPools
-      .setLimitOrder(ccy.toLowerCase(), 'usdc', 'sell', orderId, tick, fineAmount)
+      .setLimitOrder(ccy.toLowerCase(), 'usdc', 'sell', orderId, tick, fineAmount, null, null)
       .signAndSend(lp, { nonce: -1 }, handleSubstrateError(chainflip));
   });
   await orderCreatedEvent;
diff --git a/state-chain/amm/src/range_orders.rs b/state-chain/amm/src/range_orders.rs
index 64a094e1dcc..d5336031307 100644
--- a/state-chain/amm/src/range_orders.rs
+++ b/state-chain/amm/src/range_orders.rs
@@ -390,7 +390,7 @@ pub enum MintError<E> {
 	/// The ratio of assets added to the position must match the required ratio of assets for the
 	/// given tick range and current price of the pool, but there are no amounts between the
 	/// specified maximum and minimum that could match that ratio
-	AssetRatioUnachieveable,
+	AssetRatioUnachievable,
 	/// Callback failed
 	CallbackFailed(E),
 }
@@ -409,7 +409,7 @@ pub enum BurnError {
 	/// The ratio of assets removed from the position must match the ratio of assets in the
 	/// position, so that the ratio of assets in the position is maintained, but there are no
 	/// amounts between the specified maximum and minimum that could match that ratio
-	AssetRatioUnachieveable,
+	AssetRatioUnachievable,
 }
 
 #[derive(Debug)]
@@ -578,7 +578,7 @@ impl<LiquidityProvider: Clone + Ord> PoolState<LiquidityProvider> {
 
 		let minted_liquidity = self
 			.size_as_liquidity(lower_tick, upper_tick, size)
-			.ok_or(PositionError::Other(MintError::AssetRatioUnachieveable))
+			.ok_or(PositionError::Other(MintError::AssetRatioUnachievable))
 			.and_then(|liquidity| {
 				if liquidity >
 					MAX_TICK_GROSS_LIQUIDITY -
@@ -688,7 +688,7 @@ impl<LiquidityProvider: Clone + Ord> PoolState<LiquidityProvider> {
 
 			let burnt_liquidity = self
 				.size_as_liquidity(lower_tick, upper_tick, size)
-				.ok_or(PositionError::Other(BurnError::AssetRatioUnachieveable))
+				.ok_or(PositionError::Other(BurnError::AssetRatioUnachievable))
 				.map(|liquidity| core::cmp::min(position.liquidity, liquidity))?;
 
 			let mut lower_delta = self.liquidity_map.get(&lower_tick).unwrap().clone();
diff --git a/state-chain/cf-integration-tests/src/swapping.rs b/state-chain/cf-integration-tests/src/swapping.rs
index b6b3fcdefc7..1918c4b300a 100644
--- a/state-chain/cf-integration-tests/src/swapping.rs
+++ b/state-chain/cf-integration-tests/src/swapping.rs
@@ -325,6 +325,8 @@ fn set_limit_order(
 		id,
 		tick,
 		sell_amount,
+		None, // Dispatch now
+		None, // No expiration
 	));
 	let new_sell_balance =
 		pallet_cf_asset_balances::FreeBalances::<Runtime>::get(account_id, sell_asset);
diff --git a/state-chain/custom-rpc/src/lp.rs b/state-chain/custom-rpc/src/lp.rs
index e7805747690..ba62e0fa185 100644
--- a/state-chain/custom-rpc/src/lp.rs
+++ b/state-chain/custom-rpc/src/lp.rs
@@ -389,19 +389,26 @@ where
 		dispatch_at: Option<BlockNumber>,
 		wait_for: Option<WaitFor>,
 	) -> RpcResult<ApiWaitForResult<Vec<LimitOrder>>> {
-		self.scheduled_or_immediate(
-			pallet_cf_pools::Call::update_limit_order {
-				base_asset,
-				quote_asset,
-				side,
-				id: id.try_into()?,
-				option_tick: tick,
-				amount_change: amount_change.try_map(try_parse_number_or_hex)?,
-			},
-			dispatch_at,
-			wait_for.unwrap_or_default(),
+		Ok(into_api_wait_for_dynamic_result(
+			self.signed_pool_client
+				.submit_wait_for_result_dynamic(
+					RuntimeCall::from(pallet_cf_pools::Call::update_limit_order {
+						base_asset,
+						quote_asset,
+						side,
+						id: id.try_into()?,
+						option_tick: tick,
+						amount_change: amount_change.try_map(try_parse_number_or_hex)?,
+						dispatch_at,
+					}),
+					wait_for.unwrap_or_default(),
+					false,
+				)
+				.await
+				.map_err(CfApiError::from)?,
+			filter_limit_orders,
 		)
-		.await
+		.map_err(CfApiError::from)?)
 	}
 
 	async fn set_limit_order(
@@ -414,20 +421,29 @@ where
 		sell_amount: NumberOrHex,
 		dispatch_at: Option<BlockNumber>,
 		wait_for: Option<WaitFor>,
+		close_order_at: Option<BlockNumber>,
 	) -> RpcResult<ApiWaitForResult<Vec<LimitOrder>>> {
-		self.scheduled_or_immediate(
-			pallet_cf_pools::Call::set_limit_order {
-				base_asset,
-				quote_asset,
-				side,
-				id: id.try_into()?,
-				option_tick: tick,
-				sell_amount: try_parse_number_or_hex(sell_amount)?,
-			},
-			dispatch_at,
-			wait_for.unwrap_or_default(),
+		Ok(into_api_wait_for_dynamic_result(
+			self.signed_pool_client
+				.submit_wait_for_result_dynamic(
+					RuntimeCall::from(pallet_cf_pools::Call::set_limit_order {
+						base_asset,
+						quote_asset,
+						side,
+						id: id.try_into()?,
+						option_tick: tick,
+						sell_amount: try_parse_number_or_hex(sell_amount)?,
+						close_order_at,
+						dispatch_at,
+					}),
+					wait_for.unwrap_or_default(),
+					false,
+				)
+				.await
+				.map_err(CfApiError::from)?,
+			filter_limit_orders,
 		)
-		.await
+		.map_err(CfApiError::from)?)
 	}
 
 	async fn free_balances(&self) -> RpcResult<AssetMap<U256>> {
@@ -672,36 +688,6 @@ where
 		.map_err(CfApiError::from)?)
 	}
 
-	async fn scheduled_or_immediate(
-		&self,
-		call: pallet_cf_pools::Call<state_chain_runtime::Runtime>,
-		dispatch_at: Option<BlockNumber>,
-		wait_for: WaitFor,
-	) -> RpcResult<ApiWaitForResult<Vec<LimitOrder>>> {
-		Ok(into_api_wait_for_dynamic_result(
-			if let Some(dispatch_at) = dispatch_at {
-				self.signed_pool_client
-					.submit_wait_for_result_dynamic(
-						RuntimeCall::from(pallet_cf_pools::Call::schedule_limit_order_update {
-							call: Box::new(call),
-							dispatch_at,
-						}),
-						wait_for,
-						false,
-					)
-					.await
-					.map_err(CfApiError::from)?
-			} else {
-				self.signed_pool_client
-					.submit_wait_for_result_dynamic(RuntimeCall::from(call), wait_for, false)
-					.await
-					.map_err(CfApiError::from)?
-			},
-			filter_limit_orders,
-		)
-		.map_err(CfApiError::from)?)
-	}
-
 	pub async fn get_open_swap_channels_for_chain<CH: Chain>(
 		&self,
 		block_hash: Option<Hash>,
diff --git a/state-chain/pallets/cf-pools/src/benchmarking.rs b/state-chain/pallets/cf-pools/src/benchmarking.rs
index eb3a6a1c7ea..340d425f9cc 100644
--- a/state-chain/pallets/cf-pools/src/benchmarking.rs
+++ b/state-chain/pallets/cf-pools/src/benchmarking.rs
@@ -74,6 +74,8 @@ mod benchmarks {
 				i,
 				Some(0),
 				1_000,
+				None,
+				None,
 			));
 		}
 	}
@@ -179,6 +181,7 @@ mod benchmarks {
 			0,
 			Some(100),
 			IncreaseOrDecrease::Increase(1_000_000),
+			None,
 		);
 	}
 
@@ -206,6 +209,8 @@ mod benchmarks {
 			0,
 			Some(100),
 			1_000,
+			None,
+			Some(BlockNumberFor::<T>::from(100u32)),
 		);
 	}
 
@@ -229,6 +234,8 @@ mod benchmarks {
 			0,
 			Some(0),
 			10_000,
+			None,
+			None,
 		));
 		assert_ok!(Pallet::<T>::set_limit_order(
 			RawOrigin::Signed(caller.clone()).into(),
@@ -238,6 +245,8 @@ mod benchmarks {
 			1,
 			Some(0),
 			10_000,
+			None,
+			None,
 		));
 		assert_ok!(Pallet::<T>::swap_single_leg(STABLE_ASSET, Asset::Eth, 1_000));
 		let fee = 1_000;
@@ -262,35 +271,6 @@ mod benchmarks {
 		}
 	}
 
-	#[benchmark]
-	fn schedule_limit_order_update() {
-		let caller = new_lp_account::<T>();
-		assert_ok!(Pallet::<T>::new_pool(
-			T::EnsureGovernance::try_successful_origin().unwrap(),
-			Asset::Eth,
-			Asset::Usdc,
-			0,
-			price_at_tick(0).unwrap()
-		));
-		T::LpBalance::credit_account(&caller, Asset::Eth, 1_000_000);
-		T::LpBalance::credit_account(&caller, Asset::Usdc, 1_000_000);
-		#[extrinsic_call]
-		schedule_limit_order_update(
-			RawOrigin::Signed(caller.clone()),
-			Box::new(Call::<T>::set_limit_order {
-				base_asset: Asset::Eth,
-				quote_asset: Asset::Usdc,
-				side: Side::Sell,
-				id: 0,
-				option_tick: Some(0),
-				sell_amount: 100,
-			}),
-			BlockNumberFor::<T>::from(5u32),
-		);
-
-		assert!(!ScheduledLimitOrderUpdates::<T>::get(BlockNumberFor::<T>::from(5u32)).is_empty());
-	}
-
 	#[benchmark]
 	fn set_maximum_price_impact(n: Linear<1, 6>) {
 		const LIMIT: u32 = 1_000;
diff --git a/state-chain/pallets/cf-pools/src/lib.rs b/state-chain/pallets/cf-pools/src/lib.rs
index f3b035b6b1a..25ae02f8990 100644
--- a/state-chain/pallets/cf-pools/src/lib.rs
+++ b/state-chain/pallets/cf-pools/src/lib.rs
@@ -29,22 +29,24 @@ use cf_traits::{
 	impl_pallet_safe_mode, AccountRoleRegistry, BalanceApi, Chainflip, LpOrdersWeightsProvider,
 	PoolApi, SwapRequestHandler, SwappingApi,
 };
+use sp_runtime::Saturating;
 
 use cf_traits::LpRegistration;
 pub use cf_traits::{IncreaseOrDecrease, OrderId};
 use core::ops::Range;
 use frame_support::{
-	dispatch::GetDispatchInfo,
 	pallet_prelude::*,
-	storage::with_storage_layer,
-	traits::{OnKilledAccount, OriginTrait, StorageVersion, UnfilteredDispatchable},
+	traits::{OnKilledAccount, OriginTrait, StorageVersion},
 	transactional,
 };
-use frame_system::{pallet_prelude::OriginFor, WeightInfo as SystemWeightInfo};
+use frame_system::{
+	pallet_prelude::{BlockNumberFor, OriginFor},
+	WeightInfo as SystemWeightInfo,
+};
 pub use pallet::*;
 use serde::{Deserialize, Serialize};
 use sp_arithmetic::traits::{SaturatedConversion, Zero};
-use sp_std::{boxed::Box, vec::Vec};
+use sp_std::vec::Vec;
 
 mod benchmarking;
 pub mod migrations;
@@ -67,6 +69,9 @@ impl<const N: u128> Get<SweepingThresholds> for StablecoinDefaults<N> {
 	}
 }
 
+// Limit on how far in the future an LP can schedule a limit order update/close.
+const SCHEDULE_UPDATE_LIMIT_BLOCKS: u32 = 3600; // 6 hours
+
 pub const MAX_ORDERS_DELETE: u32 = 100;
 #[derive(
 	serde::Serialize,
@@ -179,12 +184,150 @@ impl<T> AskBidMap<T> {
 	}
 }
 
+#[derive(Clone, Encode, DebugNoBound, Decode, TypeInfo, PartialEq, Eq)]
+#[scale_info(skip_type_params(T))]
+struct LimitOrderUpdate<T: Config> {
+	pub lp: T::AccountId,
+	pub base_asset: any::Asset,
+	pub quote_asset: any::Asset,
+	pub side: Side,
+	pub id: OrderId,
+	pub details: LimitOrderUpdateDetails<BlockNumberFor<T>>,
+}
+
+#[derive(Clone, Encode, DebugNoBound, Decode, TypeInfo, PartialEq, Eq)]
+enum LimitOrderUpdateDetails<BlockNumber: sp_std::fmt::Debug> {
+	Update { option_tick: Option<Tick>, amount_change: IncreaseOrDecrease<AssetAmount> },
+	Set { option_tick: Option<Tick>, sell_amount: AssetAmount, close_order_at: Option<BlockNumber> },
+	Close,
+}
+
+impl<T: Config> LimitOrderUpdate<T> {
+	pub fn dispatch(self) -> (Weight, DispatchResult) {
+		let (weight, result) = match self.details {
+			LimitOrderUpdateDetails::Set { option_tick, sell_amount, close_order_at } => {
+				let result = Pallet::<T>::set_limit_order(
+					OriginTrait::signed(self.lp.clone()),
+					self.base_asset,
+					self.quote_asset,
+					self.side,
+					self.id,
+					option_tick,
+					sell_amount,
+					None, // Dispatch now
+					close_order_at,
+				);
+				(T::WeightInfo::set_limit_order(), result)
+			},
+			LimitOrderUpdateDetails::Update { option_tick, amount_change } => {
+				let result = Pallet::<T>::update_limit_order(
+					OriginTrait::signed(self.lp.clone()),
+					self.base_asset,
+					self.quote_asset,
+					self.side,
+					self.id,
+					option_tick,
+					amount_change,
+					None, // Dispatch now
+				);
+				(T::WeightInfo::update_limit_order(), result)
+			},
+			LimitOrderUpdateDetails::Close => {
+				let result = Pallet::<T>::update_limit_order(
+					OriginTrait::signed(self.lp.clone()),
+					self.base_asset,
+					self.quote_asset,
+					self.side,
+					self.id,
+					None,
+					IncreaseOrDecrease::Decrease(AssetAmount::MAX),
+					None, // Dispatch now
+				);
+				let result = if let Err(err) = result {
+					if err == Error::<T>::OrderDoesNotExist.into() {
+						// Ignore the error if the order doesn't exist, as this is expected.
+						Ok(())
+					} else {
+						Err(err)
+					}
+				} else {
+					result
+				};
+				(T::WeightInfo::update_limit_order(), result)
+			},
+		};
+		match result {
+			Ok(()) => {
+				Pallet::<T>::deposit_event(Event::<T>::ScheduledLimitOrderUpdateDispatchSuccess {
+					lp: self.lp,
+					order_id: self.id,
+				});
+			},
+			Err(err) => {
+				Pallet::<T>::deposit_event(Event::<T>::ScheduledLimitOrderUpdateDispatchFailure {
+					lp: self.lp,
+					order_id: self.id,
+					error: err,
+				});
+			},
+		}
+		(weight, result)
+	}
+
+	pub fn schedule(self, dispatch_at: BlockNumberFor<T>) {
+		Pallet::<T>::deposit_event(Event::<T>::LimitOrderSetOrUpdateScheduled {
+			lp: self.lp.clone(),
+			order_id: self.id,
+			dispatch_at,
+		});
+		ScheduledLimitOrderUpdates::<T>::append(dispatch_at, self);
+	}
+
+	pub fn schedule_or_dispatch(self, dispatch_at: Option<BlockNumberFor<T>>) -> DispatchResult {
+		if let Some(dispatch_at) = dispatch_at {
+			let current_block_number = frame_system::Pallet::<T>::block_number();
+			ensure!(
+				dispatch_at >= current_block_number &&
+					dispatch_at <=
+						current_block_number.saturating_add(BlockNumberFor::<T>::from(
+							SCHEDULE_UPDATE_LIMIT_BLOCKS
+						)),
+				Error::<T>::InvalidDispatchAt
+			);
+
+			if let LimitOrderUpdateDetails::Set { close_order_at: Some(close_order_at), .. } =
+				self.details
+			{
+				ensure!(
+					close_order_at > dispatch_at &&
+						close_order_at <=
+							current_block_number.saturating_add(BlockNumberFor::<T>::from(
+								SCHEDULE_UPDATE_LIMIT_BLOCKS
+							)),
+					Error::<T>::InvalidCloseOrderAt
+				);
+			}
+
+			if current_block_number == dispatch_at {
+				let (_weight, result) = self.dispatch();
+				result
+			} else {
+				self.schedule(dispatch_at);
+				Ok(())
+			}
+		} else {
+			let (_weight, result) = self.dispatch();
+			result
+		}
+	}
+}
+
 #[derive(Clone, RuntimeDebugNoBound, PartialEq, Eq, Encode, Decode, TypeInfo, MaxEncodedLen)]
 pub enum PalletConfigUpdate {
 	LimitOrderAutoSweepingThreshold { asset: Asset, amount: AssetAmount },
 }
 
-pub const PALLET_VERSION: StorageVersion = StorageVersion::new(6);
+pub const PALLET_VERSION: StorageVersion = StorageVersion::new(7);
 
 #[frame_support::pallet]
 pub mod pallet {
@@ -192,19 +335,10 @@ pub mod pallet {
 		math::Tick,
 		range_orders::{self, Liquidity},
 	};
-	use frame_system::pallet_prelude::BlockNumberFor;
 	use sp_std::collections::btree_map::BTreeMap;
 
 	use super::*;
 
-	#[derive(Clone, Debug, Encode, Decode, TypeInfo)]
-	#[scale_info(skip_type_params(T))]
-	pub struct LimitOrderUpdate<T: Config> {
-		pub lp: T::AccountId,
-		pub id: OrderId,
-		pub call: Call<T>,
-	}
-
 	#[derive(Clone, DebugNoBound, Encode, Decode, TypeInfo, PartialEq)]
 	#[scale_info(skip_type_params(T))]
 	pub struct Pool<T: Config> {
@@ -332,26 +466,8 @@ pub mod pallet {
 
 			Self::auto_sweep_limit_orders();
 
-			for LimitOrderUpdate { ref lp, id, call } in
-				ScheduledLimitOrderUpdates::<T>::take(current_block)
-			{
-				let call_weight = call.get_dispatch_info().weight;
-				let _result = with_storage_layer(move || {
-					call.dispatch_bypass_filter(OriginTrait::signed(lp.clone()))
-				})
-				.map(|_| {
-					Self::deposit_event(Event::<T>::ScheduledLimitOrderUpdateDispatchSuccess {
-						lp: lp.clone(),
-						order_id: id,
-					});
-				})
-				.map_err(|err| {
-					Self::deposit_event(Event::<T>::ScheduledLimitOrderUpdateDispatchFailure {
-						lp: lp.clone(),
-						order_id: id,
-						error: err.error,
-					});
-				});
+			for update in ScheduledLimitOrderUpdates::<T>::take(current_block) {
+				let (call_weight, _result) = update.dispatch();
 				weight_used.saturating_accrue(call_weight);
 			}
 			weight_used
@@ -390,15 +506,19 @@ pub mod pallet {
 		OutputOverflow,
 		/// There are no amounts between the specified maximum and minimum that match the required
 		/// ratio of assets
-		AssetRatioUnachieveable,
+		AssetRatioUnachievable,
 		/// Updating Limit Orders is disabled.
 		UpdatingLimitOrdersDisabled,
 		/// Updating Range Orders is disabled.
 		UpdatingRangeOrdersDisabled,
 		/// Unsupported call.
 		UnsupportedCall,
-		/// The update can't be scheduled because it has expired (dispatch_at is in the past).
-		LimitOrderUpdateExpired,
+		/// The update can't be scheduled because the given dispatch_at block is in the past or too
+		/// far in the future (3600 blocks).
+		InvalidDispatchAt,
+		/// The given close_order_at is in the past, not larger than dispatch_at or too far in the
+		/// future (3600 blocks).
+		InvalidCloseOrderAt,
 		/// The range order size is invalid.
 		InvalidSize,
 	}
@@ -676,6 +796,7 @@ pub mod pallet {
 		/// for buy and sell limit orders i.e. those in different directions are independent.
 		/// Therefore you may have two limit orders with the same order_id in the same pool, one to
 		/// buy Eth and one to sell Eth for example.
+		/// `dispatch_at` specifies the block at which to schedule the update.
 		#[pallet::call_index(5)]
 		#[pallet::weight(T::WeightInfo::update_limit_order())]
 		pub fn update_limit_order(
@@ -686,6 +807,7 @@ pub mod pallet {
 			id: OrderId,
 			option_tick: Option<Tick>,
 			amount_change: IncreaseOrDecrease<AssetAmount>,
+			dispatch_at: Option<BlockNumberFor<T>>,
 		) -> DispatchResult {
 			ensure!(
 				T::SafeMode::get().limit_order_update_enabled,
@@ -697,15 +819,27 @@ pub mod pallet {
 				[base_asset, quote_asset],
 			)?;
 
-			Self::inner_update_limit_order(
-				&lp,
-				base_asset,
-				quote_asset,
-				side,
-				id,
-				option_tick,
-				amount_change,
-			)
+			if let Some(dispatch_at) = dispatch_at {
+				LimitOrderUpdate::<T> {
+					lp: lp.clone(),
+					base_asset,
+					quote_asset,
+					side,
+					id,
+					details: LimitOrderUpdateDetails::Update { option_tick, amount_change },
+				}
+				.schedule_or_dispatch(Some(dispatch_at))
+			} else {
+				Self::inner_update_limit_order(
+					&lp,
+					base_asset,
+					quote_asset,
+					side,
+					id,
+					option_tick,
+					amount_change,
+				)
+			}
 		}
 
 		/// Optionally move the order to a different tick and then set its amount of liquidity. The
@@ -716,6 +850,7 @@ pub mod pallet {
 		/// limit orders i.e. those in different directions are independent. Therefore you may have
 		/// two limit orders with the same order_id in the same pool, one to buy Eth and one to sell
 		/// Eth for example.
+		/// `dispatch_at` specifies the block at which to schedule the update.
 		#[pallet::call_index(6)]
 		#[pallet::weight(T::WeightInfo::set_limit_order())]
 		pub fn set_limit_order(
@@ -726,26 +861,67 @@ pub mod pallet {
 			id: OrderId,
 			option_tick: Option<Tick>,
 			sell_amount: AssetAmount,
+			dispatch_at: Option<BlockNumberFor<T>>,
+			close_order_at: Option<BlockNumberFor<T>>,
 		) -> DispatchResult {
 			ensure!(
 				T::SafeMode::get().limit_order_update_enabled,
 				Error::<T>::UpdatingLimitOrdersDisabled
 			);
-			let lp = T::AccountRoleRegistry::ensure_liquidity_provider(origin)?;
+			let lp = T::AccountRoleRegistry::ensure_liquidity_provider(origin.clone())?;
 			T::LpRegistrationApi::ensure_has_refund_address_for_assets(
 				&lp,
 				[base_asset, quote_asset],
 			)?;
 
-			Self::inner_set_limit_order(
-				&lp,
-				base_asset,
-				quote_asset,
-				side,
-				id,
-				option_tick,
-				sell_amount,
-			)
+			if let Some(dispatch_at) = dispatch_at {
+				LimitOrderUpdate::<T> {
+					lp: lp.clone(),
+					base_asset,
+					quote_asset,
+					side,
+					id,
+					details: LimitOrderUpdateDetails::Set {
+						option_tick,
+						sell_amount,
+						close_order_at,
+					},
+				}
+				.schedule_or_dispatch(Some(dispatch_at))?;
+			} else {
+				Self::inner_set_limit_order(
+					&lp,
+					base_asset,
+					quote_asset,
+					side,
+					id,
+					option_tick,
+					sell_amount,
+				)?;
+
+				if let Some(close_order_at) = close_order_at {
+					let current_block_number = frame_system::Pallet::<T>::block_number();
+					ensure!(
+						close_order_at > current_block_number &&
+							close_order_at <=
+								current_block_number.saturating_add(
+									BlockNumberFor::<T>::from(SCHEDULE_UPDATE_LIMIT_BLOCKS)
+								),
+						Error::<T>::InvalidCloseOrderAt
+					);
+
+					LimitOrderUpdate::<T> {
+						lp: lp.clone(),
+						base_asset,
+						quote_asset,
+						side,
+						id,
+						details: LimitOrderUpdateDetails::Close,
+					}
+					.schedule(close_order_at);
+				}
+			}
+			Ok(())
 		}
 
 		/// Sets the Liquidity Pool fees. Also collect earned fees and bought amount for
@@ -780,50 +956,6 @@ pub mod pallet {
 			Ok(())
 		}
 
-		/// Schedules a limit order update to be executed at a later block.
-		///
-		/// The update is defined by the passed call, which can be one either `set_limit_order` or
-		/// `update_limit_order` extrinsic at a later block. The call is executed at the specified
-		/// block number, and the validity of the order is checked at the block number it enters
-		/// the state-chain.
-		///
-		/// `dispatch_at` specifies the block at which to schedule the update.
-		#[allow(clippy::useless_conversion)]
-		#[pallet::call_index(8)]
-		#[pallet::weight(T::WeightInfo::schedule_limit_order_update())]
-		pub fn schedule_limit_order_update(
-			origin: OriginFor<T>,
-			call: Box<Call<T>>,
-			dispatch_at: BlockNumberFor<T>,
-		) -> DispatchResultWithPostInfo {
-			let lp = T::AccountRoleRegistry::ensure_liquidity_provider(origin)?;
-			let current_block_number = frame_system::Pallet::<T>::block_number();
-			ensure!(dispatch_at >= current_block_number, Error::<T>::LimitOrderUpdateExpired);
-
-			let schedule_or_dispatch = |call: Call<T>, id: OrderId| {
-				if current_block_number == dispatch_at {
-					call.dispatch_bypass_filter(OriginTrait::signed(lp))
-				} else {
-					ScheduledLimitOrderUpdates::<T>::append(
-						dispatch_at,
-						LimitOrderUpdate { lp: lp.clone(), id, call },
-					);
-					Self::deposit_event(Event::<T>::LimitOrderSetOrUpdateScheduled {
-						lp,
-						order_id: id,
-						dispatch_at,
-					});
-					Ok(().into())
-				}
-			};
-
-			match *call {
-				Call::update_limit_order { id, .. } => schedule_or_dispatch(*call, id),
-				Call::set_limit_order { id, .. } => schedule_or_dispatch(*call, id),
-				_ => Err(Error::<T>::UnsupportedCall)?,
-			}
-		}
-
 		/// Sets per-pool limits (in number of ticks) that determine the allowed change in price of
 		/// the bought asset during a swap.
 		///
@@ -876,7 +1008,7 @@ pub mod pallet {
 							match pool.limit_orders_cache[side.to_sold_pair()]
 								.get(lp)
 								.and_then(|limit_orders| limit_orders.get(&id))
-								.cloned()
+								.copied()
 							{
 								None => {
 									Self::deposit_event(Event::<T>::OrderDeletionFailed { order });
@@ -890,8 +1022,8 @@ pub mod pallet {
 										side,
 										id,
 										previous_tick,
-										crate::pallet::IncreaseOrDecrease::Decrease(Amount::MAX),
-										crate::NoOpStatus::Allow,
+										IncreaseOrDecrease::Decrease(Amount::MAX),
+										NoOpStatus::Allow,
 									)?;
 									Ok(())
 								},
@@ -901,17 +1033,16 @@ pub mod pallet {
 					CloseOrder::Range { base_asset, quote_asset, id } => {
 						let asset_pair = AssetPair::try_new::<T>(base_asset, quote_asset)?;
 						Self::try_mutate_pool(asset_pair, |asset_pair, pool| {
-							match (pool
+							match pool
 								.range_orders_cache
 								.get(lp)
 								.and_then(|range_orders| range_orders.get(&id))
-								.cloned(),)
 							{
-								(None,) => {
+								None => {
 									Self::deposit_event(Event::<T>::OrderDeletionFailed { order });
 									Ok::<(), DispatchError>(())
 								},
-								(Some(previous_tick_range),) => {
+								Some(previous_tick_range) => {
 									Self::inner_update_range_order(
 										pool,
 										lp,
@@ -1090,7 +1221,7 @@ impl<T: Config> PoolApi for Pallet<T> {
 					cache.get(account).cloned().map(|orders| (asset, orders))
 				}) {
 				for (id, tick) in orders {
-					Self::cancel_limit_order(
+					<Self as PoolApi>::cancel_limit_order(
 						account,
 						asset_pair.assets().base,
 						asset_pair.assets().quote,
@@ -1425,7 +1556,7 @@ impl<T: Config> Pallet<T> {
 				pool.limit_orders_cache[side.to_sold_pair()]
 					.get(lp)
 					.and_then(|limit_orders| limit_orders.get(&id))
-					.cloned(),
+					.copied(),
 				option_tick,
 			) {
 				(None, None) => Err(Error::<T>::UnspecifiedOrderPrice),
@@ -1477,7 +1608,7 @@ impl<T: Config> Pallet<T> {
 				pool.limit_orders_cache[side.to_sold_pair()]
 					.get(lp)
 					.and_then(|limit_orders| limit_orders.get(&id))
-					.cloned(),
+					.copied(),
 				option_tick,
 			) {
 				(None, None) => Err(Error::<T>::UnspecifiedOrderPrice),
@@ -1680,8 +1811,8 @@ impl<T: Config> Pallet<T> {
 								range_orders::MintError::MaximumGrossLiquidity,
 							) => Error::<T>::MaximumGrossLiquidity.into(),
 							range_orders::PositionError::Other(
-								cf_amm::range_orders::MintError::AssetRatioUnachieveable,
-							) => Error::<T>::AssetRatioUnachieveable.into(),
+								cf_amm::range_orders::MintError::AssetRatioUnachievable,
+							) => Error::<T>::AssetRatioUnachievable.into(),
 						}),
 					}?;
 
@@ -1708,8 +1839,8 @@ impl<T: Config> Pallet<T> {
 								Error::<T>::OrderDoesNotExist
 							},
 						range_orders::PositionError::Other(e) => match e {
-							range_orders::BurnError::AssetRatioUnachieveable =>
-								Error::<T>::AssetRatioUnachieveable,
+							range_orders::BurnError::AssetRatioUnachievable =>
+								Error::<T>::AssetRatioUnachievable,
 						},
 					}),
 				}?;
@@ -1906,8 +2037,8 @@ impl<T: Config> Pallet<T> {
 		let pool_state =
 			Pools::<T>::get(asset_pair).ok_or(Error::<T>::PoolDoesNotExist)?.pool_state;
 
-		// TODO: Need to change limit order pool implmentation so Amount::MAX is guaranteed to drain
-		// pool (so the calculated amounts here are guaranteed to reflect the accurate
+		// TODO: Need to change limit order pool implementation so Amount::MAX is guaranteed to
+		// drain pool (so the calculated amounts here are guaranteed to reflect the accurate
 		// maximum_bough_amounts)
 
 		Ok(PoolOrderbook {
diff --git a/state-chain/pallets/cf-pools/src/migrations.rs b/state-chain/pallets/cf-pools/src/migrations.rs
index 74d6bf75860..0c2a97ff56b 100644
--- a/state-chain/pallets/cf-pools/src/migrations.rs
+++ b/state-chain/pallets/cf-pools/src/migrations.rs
@@ -16,5 +16,17 @@
 
 use crate::Pallet;
 use cf_runtime_utilities::PlaceholderMigration;
+use frame_support::migrations::VersionedMigration;
 
-pub type PalletMigration<T> = (PlaceholderMigration<6, Pallet<T>>,);
+mod scheduled_limit_order_updates;
+
+pub type PalletMigration<T> = (
+	VersionedMigration<
+		6,
+		7,
+		scheduled_limit_order_updates::Migration<T>,
+		Pallet<T>,
+		<T as frame_system::Config>::DbWeight,
+	>,
+	PlaceholderMigration<7, Pallet<T>>,
+);
diff --git a/state-chain/pallets/cf-pools/src/migrations/scheduled_limit_order_updates.rs b/state-chain/pallets/cf-pools/src/migrations/scheduled_limit_order_updates.rs
new file mode 100644
index 00000000000..e7cf94399a0
--- /dev/null
+++ b/state-chain/pallets/cf-pools/src/migrations/scheduled_limit_order_updates.rs
@@ -0,0 +1,198 @@
+// Copyright 2025 Chainflip Labs GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+use frame_support::traits::UncheckedOnRuntimeUpgrade;
+
+use crate::Config;
+
+use crate::*;
+use frame_support::pallet_prelude::Weight;
+#[cfg(feature = "try-runtime")]
+use sp_runtime::DispatchError;
+
+use codec::{Decode, Encode};
+
+pub mod old {
+	use super::*;
+
+	#[derive(Clone, DebugNoBound, PartialEq, Eq, Encode, Decode, TypeInfo)]
+	pub(super) struct LimitOrderUpdate<T: Config> {
+		pub lp: T::AccountId,
+		pub id: OrderId,
+		pub call: LimitOrderCall,
+	}
+
+	#[derive(Clone, DebugNoBound, PartialEq, Eq, Encode, Decode, TypeInfo)]
+	#[repr(u8)]
+	#[allow(clippy::unnecessary_cast)]
+	// This enum is laid out in the same way as the old call, so it will decode exactly the same.
+	// The index of the variants is set to match the call_index of the old call.
+	pub(super) enum LimitOrderCall {
+		UpdateLimitOrder {
+			base_asset: any::Asset,
+			quote_asset: any::Asset,
+			side: Side,
+			id: OrderId,
+			option_tick: Option<Tick>,
+			amount_change: IncreaseOrDecrease<AssetAmount>,
+		} = 5_u8,
+		SetLimitOrder {
+			base_asset: Asset,
+			quote_asset: Asset,
+			side: Side,
+			id: u64,
+			option_tick: Option<Tick>,
+			sell_amount: AssetAmount,
+		} = 6_u8,
+	}
+
+	// Migrating this storage item. It was previously storing the call directly in
+	// `LimitOrderUpdate`, but now it is a struct.
+	#[frame_support::storage_alias]
+	pub type ScheduledLimitOrderUpdates<T: Config> = StorageMap<
+		Pallet<T>,
+		Twox64Concat,
+		BlockNumberFor<T>,
+		Vec<LimitOrderUpdate<T>>,
+		ValueQuery,
+	>;
+}
+
+pub struct Migration<T: Config>(PhantomData<T>);
+
+// Migrating from Call -> Struct
+fn migrate_update<T: Config>(update: old::LimitOrderUpdate<T>) -> LimitOrderUpdate<T> {
+	match update.call {
+		old::LimitOrderCall::SetLimitOrder {
+			base_asset,
+			quote_asset,
+			side,
+			id,
+			option_tick,
+			sell_amount,
+		} => LimitOrderUpdate {
+			lp: update.lp,
+			id,
+			base_asset,
+			quote_asset,
+			side,
+			details: LimitOrderUpdateDetails::Set {
+				option_tick,
+				sell_amount,
+				// Setting the close_order_at to None by default because it did not exist before.
+				close_order_at: None,
+			},
+		},
+		old::LimitOrderCall::UpdateLimitOrder {
+			base_asset,
+			quote_asset,
+			side,
+			id,
+			option_tick,
+			amount_change,
+		} => LimitOrderUpdate {
+			lp: update.lp,
+			id,
+			base_asset,
+			quote_asset,
+			side,
+			details: LimitOrderUpdateDetails::Update { option_tick, amount_change },
+		},
+	}
+}
+
+impl<T: Config> UncheckedOnRuntimeUpgrade for Migration<T> {
+	#[cfg(feature = "try-runtime")]
+	fn pre_upgrade() -> Result<Vec<u8>, DispatchError> {
+		let update_count = old::ScheduledLimitOrderUpdates::<T>::iter()
+			.map(|(_, updates)| updates.len() as u64)
+			.sum::<u64>();
+		Ok(update_count.encode())
+	}
+
+	fn on_runtime_upgrade() -> Weight {
+		crate::ScheduledLimitOrderUpdates::<T>::translate_values::<Vec<old::LimitOrderUpdate<T>>, _>(
+			|old_updates| {
+				Some(old_updates.into_iter().map(|update| migrate_update(update)).collect())
+			},
+		);
+
+		Weight::zero()
+	}
+
+	#[cfg(feature = "try-runtime")]
+	fn post_upgrade(state: Vec<u8>) -> Result<(), DispatchError> {
+		let pre_update_count = <u64>::decode(&mut state.as_slice())
+			.map_err(|_| DispatchError::from("Failed to decode state"))?;
+
+		let post_update_count = crate::ScheduledLimitOrderUpdates::<T>::iter()
+			.map(|(_, updates)| updates.len() as u64)
+			.sum::<u64>();
+
+		assert_eq!(pre_update_count, post_update_count);
+		Ok(())
+	}
+}
+
+#[test]
+fn test_migrate_update() {
+	use crate::mock::*;
+
+	// How the storage item used to look, storing the call directly
+	#[derive(Clone, DebugNoBound, PartialEq, Eq, Encode, Decode, TypeInfo)]
+	struct OldStorage<T: Config> {
+		pub lp: T::AccountId,
+		pub id: OrderId,
+		pub call: crate::Call<T>,
+	}
+
+	new_test_ext().execute_with(|| {
+		// Create a call
+		// Note: The update_limit_order call has changed, but it should still decode into the old
+		// struct because we have only added fields
+		let call = crate::Call::<Test>::update_limit_order {
+			base_asset: Asset::Flip,
+			quote_asset: Asset::Eth,
+			side: Side::Buy,
+			id: 123,
+			option_tick: None,
+			amount_change: IncreaseOrDecrease::Increase(100),
+			dispatch_at: None,
+		};
+		// Encode the call in the old storage format
+		let encoded_storage = OldStorage { lp: 69, id: 123, call: call.clone() }.encode();
+
+		// Decode it into the new storage struct
+		let decoded_storage =
+			old::LimitOrderUpdate::<Test>::decode(&mut &encoded_storage[..]).unwrap();
+
+		// Migrate the data and check its the same
+		let migrated_update = migrate_update(decoded_storage);
+		assert_eq!(
+			migrated_update,
+			LimitOrderUpdate {
+				lp: 69,
+				id: 123,
+				base_asset: Asset::Flip,
+				quote_asset: Asset::Eth,
+				side: Side::Buy,
+				details: LimitOrderUpdateDetails::Update {
+					option_tick: None,
+					amount_change: IncreaseOrDecrease::Increase(100),
+				},
+			}
+		);
+	});
+}
diff --git a/state-chain/pallets/cf-pools/src/tests.rs b/state-chain/pallets/cf-pools/src/tests.rs
index 694a9b7499e..ca9c3bdc5d0 100644
--- a/state-chain/pallets/cf-pools/src/tests.rs
+++ b/state-chain/pallets/cf-pools/src/tests.rs
@@ -24,7 +24,7 @@ use cf_test_utilities::{
 	assert_events_eq, assert_events_match, assert_matching_event_count, last_event,
 };
 use cf_traits::{mocks::balance_api::MockBalance, BalanceApi, PoolApi, SwappingApi};
-use frame_support::{assert_noop, assert_ok, traits::Hooks};
+use frame_support::{assert_noop, assert_ok};
 use sp_core::bounded_vec;
 use sp_runtime::BoundedVec;
 
@@ -166,6 +166,8 @@ fn pallet_limit_order_is_in_sync_with_pool() {
 			0,
 			Some(0),
 			100,
+			None,
+			None,
 		));
 		assert_ok!(LiquidityPools::set_limit_order(
 			RuntimeOrigin::signed(BOB),
@@ -175,6 +177,8 @@ fn pallet_limit_order_is_in_sync_with_pool() {
 			0,
 			Some(tick),
 			100_000,
+			None,
+			None,
 		));
 		assert_ok!(LiquidityPools::set_limit_order(
 			RuntimeOrigin::signed(BOB),
@@ -184,6 +188,8 @@ fn pallet_limit_order_is_in_sync_with_pool() {
 			1,
 			Some(tick),
 			10_000,
+			None,
+			None,
 		));
 		assert_eq!(
 			LiquidityPools::pool_orders(Asset::Eth, STABLE_ASSET, Some(ALICE), false),
@@ -371,93 +377,126 @@ fn update_pool_liquidity_fee_collects_fees_for_range_order() {
 }
 
 #[test]
-fn can_execute_scheduled_limit_order() {
-	new_test_ext().execute_with(|| {
-		let order_id = 0;
-		assert_ok!(LiquidityPools::new_pool(
-			RuntimeOrigin::root(),
-			Asset::Flip,
-			STABLE_ASSET,
-			400_000u32,
-			price_at_tick(0).unwrap(),
-		));
-
+fn can_execute_scheduled_limit_order_updates() {
+	fn test_scheduled_limit_order_update(
+		update: impl Fn(OrderId, u64, AssetAmount) -> DispatchResult,
+	) {
+		const DISPATCH_AT: u64 = 6;
+		const ORDER_ID: u64 = 0;
 		const AMOUNT: AssetAmount = 55;
 
-		MockBalance::credit_account(&ALICE, STABLE_ASSET, AMOUNT);
-		assert_ok!(LiquidityPools::schedule_limit_order_update(
-			RuntimeOrigin::signed(ALICE),
-			Box::new(pallet_cf_pools::Call::<Test>::set_limit_order {
-				base_asset: Asset::Flip,
-				quote_asset: STABLE_ASSET,
-				side: Side::Buy,
-				id: order_id,
-				option_tick: Some(100),
-				sell_amount: AMOUNT,
-			}),
-			6
-		));
-		assert_eq!(
-			last_event::<Test>(),
-			RuntimeEvent::LiquidityPools(Event::LimitOrderSetOrUpdateScheduled {
-				lp: ALICE,
-				order_id,
-				dispatch_at: 6,
+		new_test_ext()
+			.execute_with(|| {
+				assert_ok!(LiquidityPools::new_pool(
+					RuntimeOrigin::root(),
+					Asset::Flip,
+					STABLE_ASSET,
+					400_000u32,
+					price_at_tick(0).unwrap(),
+				));
+
+				MockBalance::credit_account(&ALICE, STABLE_ASSET, AMOUNT);
+				assert_ok!(update(ORDER_ID, DISPATCH_AT, AMOUNT));
+				assert_eq!(
+					last_event::<Test>(),
+					RuntimeEvent::LiquidityPools(Event::LimitOrderSetOrUpdateScheduled {
+						lp: ALICE,
+						order_id: ORDER_ID,
+						dispatch_at: DISPATCH_AT,
+					})
+				);
+				assert!(!ScheduledLimitOrderUpdates::<Test>::get(DISPATCH_AT).is_empty());
 			})
-		);
-		assert!(!ScheduledLimitOrderUpdates::<Test>::get(6).is_empty());
-		LiquidityPools::on_initialize(6);
-		assert!(
-			ScheduledLimitOrderUpdates::<Test>::get(6).is_empty(),
-			"Should be empty, but is {:?}",
-			ScheduledLimitOrderUpdates::<Test>::get(6)
-		);
-		assert_eq!(
-			last_event::<Test>(),
-			RuntimeEvent::LiquidityPools(Event::ScheduledLimitOrderUpdateDispatchSuccess {
-				lp: ALICE,
+			.then_process_blocks_until_block(DISPATCH_AT)
+			.then_execute_with(|_| {
+				assert!(ScheduledLimitOrderUpdates::<Test>::get(DISPATCH_AT).is_empty());
+				assert_eq!(
+					last_event::<Test>(),
+					RuntimeEvent::LiquidityPools(Event::ScheduledLimitOrderUpdateDispatchSuccess {
+						lp: ALICE,
+						order_id: ORDER_ID,
+					})
+				);
+			});
+	}
+
+	test_scheduled_limit_order_update(
+		|order_id: OrderId, dispatch_at: u64, amount: AssetAmount| {
+			LiquidityPools::set_limit_order(
+				RuntimeOrigin::signed(ALICE),
+				Asset::Flip,
+				STABLE_ASSET,
+				Side::Buy,
 				order_id,
-			})
-		);
-	});
+				Some(100),
+				amount,
+				Some(dispatch_at),
+				None,
+			)
+		},
+	);
+
+	test_scheduled_limit_order_update(
+		|order_id: OrderId, dispatch_at: u64, amount: AssetAmount| {
+			LiquidityPools::update_limit_order(
+				RuntimeOrigin::signed(ALICE),
+				Asset::Flip,
+				STABLE_ASSET,
+				Side::Buy,
+				order_id,
+				Some(100),
+				IncreaseOrDecrease::Increase(amount),
+				Some(dispatch_at),
+			)
+		},
+	);
 }
 
 #[test]
-fn schedule_rejects_unsupported_calls() {
-	new_test_ext().execute_with(|| {
+fn test_dispatch_at_validation() {
+	const CURRENT_BLOCK: u64 = 10;
+	new_test_ext().then_execute_at_block(10u32, |_| {
 		assert_noop!(
-			LiquidityPools::schedule_limit_order_update(
+			LiquidityPools::update_limit_order(
 				RuntimeOrigin::signed(ALICE),
-				Box::new(pallet_cf_pools::Call::<Test>::set_pool_fees {
-					base_asset: Asset::Eth,
-					quote_asset: STABLE_ASSET,
-					fee_hundredth_pips: 0,
-				}),
-				6
+				Asset::Flip,
+				STABLE_ASSET,
+				Side::Buy,
+				0,
+				Some(0),
+				IncreaseOrDecrease::Decrease(55),
+				// 1 block in the past
+				Some(CURRENT_BLOCK - 1)
 			),
-			Error::<Test>::UnsupportedCall
+			Error::<Test>::InvalidDispatchAt
 		);
-	});
-}
 
-#[test]
-fn cant_schedule_in_the_past() {
-	new_test_ext().then_execute_at_block(10u32, |_| {
 		assert_noop!(
-			LiquidityPools::schedule_limit_order_update(
+			LiquidityPools::update_limit_order(
 				RuntimeOrigin::signed(ALICE),
-				Box::new(pallet_cf_pools::Call::<Test>::set_limit_order {
-					base_asset: Asset::Flip,
-					quote_asset: STABLE_ASSET,
-					side: Side::Buy,
-					id: 0,
-					option_tick: Some(0),
-					sell_amount: 55,
-				}),
-				9
+				Asset::Flip,
+				STABLE_ASSET,
+				Side::Buy,
+				0,
+				Some(0),
+				IncreaseOrDecrease::Decrease(55),
+				// Too far in the future
+				Some(CURRENT_BLOCK + (SCHEDULE_UPDATE_LIMIT_BLOCKS as u64) + 1)
 			),
-			Error::<Test>::LimitOrderUpdateExpired
+			Error::<Test>::InvalidDispatchAt
 		);
+
+		assert_ok!(LiquidityPools::update_limit_order(
+			RuntimeOrigin::signed(ALICE),
+			Asset::Flip,
+			STABLE_ASSET,
+			Side::Buy,
+			0,
+			Some(0),
+			IncreaseOrDecrease::Decrease(55),
+			// Valid dispatch at
+			Some(CURRENT_BLOCK + (SCHEDULE_UPDATE_LIMIT_BLOCKS as u64))
+		));
 	});
 }
 
@@ -523,6 +562,8 @@ fn can_get_all_pool_orders() {
 			4,
 			Some(100),
 			500_000,
+			None,
+			None,
 		));
 		assert_ok!(LiquidityPools::set_limit_order(
 			RuntimeOrigin::signed(ALICE),
@@ -532,6 +573,8 @@ fn can_get_all_pool_orders() {
 			5,
 			Some(1000),
 			600_000,
+			None,
+			None,
 		));
 		assert_ok!(LiquidityPools::set_limit_order(
 			RuntimeOrigin::signed(ALICE),
@@ -541,6 +584,8 @@ fn can_get_all_pool_orders() {
 			6,
 			Some(100),
 			700_000,
+			None,
+			None,
 		));
 		assert_ok!(LiquidityPools::set_limit_order(
 			RuntimeOrigin::signed(ALICE),
@@ -550,6 +595,8 @@ fn can_get_all_pool_orders() {
 			7,
 			Some(1000),
 			800_000,
+			None,
+			None,
 		));
 
 		assert_eq!(
@@ -887,7 +934,9 @@ fn test_cancel_orders_batch() {
 			Side::Sell,
 			0,
 			Some(TICK),
-			5_000
+			5_000,
+			None,
+			None,
 		));
 		assert_ok!(LiquidityPools::set_limit_order(
 			RuntimeOrigin::signed(ALICE),
@@ -896,7 +945,9 @@ fn test_cancel_orders_batch() {
 			Side::Sell,
 			1,
 			Some(TICK + POOL_FEE_BPS as Tick),
-			15_000
+			15_000,
+			None,
+			None,
 		));
 
 		assert_eq!(
@@ -1032,7 +1083,9 @@ fn auto_sweeping() {
 					Side::Sell,
 					1,
 					Some(100),
-					amount
+					amount,
+					None,
+					None,
 				));
 			}
 
@@ -1067,7 +1120,9 @@ fn auto_sweeping() {
 				Side::Buy,
 				1,
 				Some(0),
-				5_000
+				5_000,
+				None,
+				None,
 			));
 
 			// Note: increase due to implicit sweeping in `set_limit_order`
@@ -1121,7 +1176,9 @@ fn cancel_all_limit_orders_for_account() {
 				side,
 				ORDER_ID,
 				Some(100),
-				AMOUNT
+				AMOUNT,
+				None,
+				None,
 			));
 		}
 
@@ -1262,7 +1319,9 @@ fn test_sweeping_when_updating_limit_order() {
 				Side::Sell,
 				1,
 				Some(0),
-				amount
+				amount,
+				None,
+				None,
 			));
 			MockBalance::credit_account(&lp, STABLE_ASSET, amount);
 			assert_ok!(LiquidityPools::set_limit_order(
@@ -1272,7 +1331,9 @@ fn test_sweeping_when_updating_limit_order() {
 				Side::Buy,
 				2,
 				Some(0),
-				amount
+				amount,
+				None,
+				None,
 			));
 		}
 		assert_eq!(get_balance(&ALICE), (0, 0));
@@ -1297,7 +1358,9 @@ fn test_sweeping_when_updating_limit_order() {
 			Side::Sell,
 			1,
 			None,
-			10_000
+			10_000,
+			None,
+			None,
 		));
 		assert_eq!(get_balance(&ALICE), (0, 5000));
 
@@ -1313,7 +1376,9 @@ fn test_sweeping_when_updating_limit_order() {
 			Side::Sell,
 			1,
 			None,
-			1000
+			1000,
+			None,
+			None,
 		));
 		assert_eq!(get_balance(&BOB), (9000, 5000));
 	});
@@ -1402,3 +1467,153 @@ fn test_sweeping_when_updating_range_order() {
 		assert_eq!(HistoricalEarnedFees::<Test>::get(BOB, STABLE_ASSET), EXPECTED_FEES);
 	});
 }
+
+#[test]
+fn test_limit_order_auto_close() {
+	const ASSET: Asset = Asset::Flip;
+	const CLOSE_ORDER_AT: u64 = 10;
+	const DISPATCH_AT: u64 = 5;
+	const AMOUNT: AssetAmount = 10_000;
+	const ORDER_ID: u64 = 1;
+
+	new_test_ext()
+		.execute_with(|| {
+			assert_ok!(LiquidityPools::new_pool(
+				RuntimeOrigin::root(),
+				ASSET,
+				STABLE_ASSET,
+				0,
+				price_at_tick(0).unwrap(),
+			));
+
+			MockBalance::credit_account(&ALICE, ASSET, AMOUNT);
+
+			// Make sure that a close order at block that is in the past is not accepted
+			assert_noop!(
+				LiquidityPools::set_limit_order(
+					RuntimeOrigin::signed(ALICE),
+					ASSET,
+					STABLE_ASSET,
+					Side::Sell,
+					ORDER_ID,
+					Some(5),
+					AMOUNT,
+					None,
+					Some(0), // Block 0 will always be in the past
+				),
+				Error::<Test>::InvalidCloseOrderAt
+			);
+
+			// Test close order block validation
+			assert_noop!(
+				LiquidityPools::set_limit_order(
+					RuntimeOrigin::signed(ALICE),
+					ASSET,
+					STABLE_ASSET,
+					Side::Sell,
+					ORDER_ID,
+					Some(5),
+					AMOUNT,
+					// Schedule the call for the same block as the close order, so it
+					// should be rejected
+					Some(CLOSE_ORDER_AT),
+					Some(CLOSE_ORDER_AT),
+				),
+				Error::<Test>::InvalidCloseOrderAt
+			);
+			assert_noop!(
+				LiquidityPools::set_limit_order(
+					RuntimeOrigin::signed(ALICE),
+					ASSET,
+					STABLE_ASSET,
+					Side::Sell,
+					ORDER_ID,
+					Some(5),
+					AMOUNT,
+					// Schedule the call for after the close order, so it should be rejected
+					Some(CLOSE_ORDER_AT + 1),
+					Some(CLOSE_ORDER_AT),
+				),
+				Error::<Test>::InvalidCloseOrderAt
+			);
+			assert_noop!(
+				LiquidityPools::set_limit_order(
+					RuntimeOrigin::signed(ALICE),
+					ASSET,
+					STABLE_ASSET,
+					Side::Sell,
+					ORDER_ID,
+					Some(5),
+					AMOUNT,
+					Some(DISPATCH_AT),
+					// Schedule the close order for too far in the future
+					Some((SCHEDULE_UPDATE_LIMIT_BLOCKS as u64) + 2),
+				),
+				Error::<Test>::InvalidCloseOrderAt
+			);
+
+			// Set a limit order with an expiry block in the future
+			assert_ok!(LiquidityPools::set_limit_order(
+				RuntimeOrigin::signed(ALICE),
+				ASSET,
+				STABLE_ASSET,
+				Side::Sell,
+				ORDER_ID,
+				Some(5),
+				AMOUNT,
+				Some(DISPATCH_AT),
+				Some(CLOSE_ORDER_AT),
+			));
+
+			// Check that the event for scheduling the opening of the order is emitted
+			assert_events_match!(
+				Test,
+				RuntimeEvent::LiquidityPools(Event::LimitOrderSetOrUpdateScheduled {
+					lp: ALICE,
+					order_id: ORDER_ID,
+					dispatch_at: DISPATCH_AT,
+				}) => ()
+			);
+		})
+		.then_process_blocks_until_block(DISPATCH_AT)
+		.then_execute_with(|_| {
+			// Check that the event for scheduling the expire order is emitted
+			assert_events_match!(
+				Test,
+				RuntimeEvent::LiquidityPools(Event::LimitOrderSetOrUpdateScheduled {
+					lp: ALICE,
+					order_id: ORDER_ID,
+					dispatch_at: CLOSE_ORDER_AT,
+				}) => ()
+			);
+
+			// The order should be present in the pool
+			assert_eq!(
+				LiquidityPools::pool_orders(ASSET, STABLE_ASSET, Some(ALICE), false)
+					.unwrap()
+					.limit_orders
+					.asks
+					.len(),
+				1
+			);
+		})
+		.then_process_blocks_until_block(CLOSE_ORDER_AT)
+		.then_execute_with(|_| {
+			// The order should be removed after the expiry block
+			assert_events_match!(
+				Test,
+				RuntimeEvent::LiquidityPools(Event::ScheduledLimitOrderUpdateDispatchSuccess {
+					lp: ALICE,
+					order_id: ORDER_ID,
+				}) => ()
+			);
+			assert_eq!(
+				LiquidityPools::pool_orders(ASSET, STABLE_ASSET, Some(ALICE), false)
+					.unwrap()
+					.limit_orders
+					.asks
+					.len(),
+				0
+			);
+		});
+}
diff --git a/state-chain/pallets/cf-pools/src/weights.rs b/state-chain/pallets/cf-pools/src/weights.rs
index e3cd403256b..9ee137de7d2 100644
--- a/state-chain/pallets/cf-pools/src/weights.rs
+++ b/state-chain/pallets/cf-pools/src/weights.rs
@@ -54,7 +54,6 @@ pub trait WeightInfo {
 	fn update_limit_order() -> Weight;
 	fn set_limit_order() -> Weight;
 	fn set_pool_fees() -> Weight;
-	fn schedule_limit_order_update() -> Weight;
 	fn set_maximum_price_impact(n: u32, ) -> Weight;
 	fn cancel_orders_batch(n: u32, ) -> Weight;
 }
@@ -172,19 +171,6 @@ impl<T: frame_system::Config> WeightInfo for PalletWeight<T> {
 			.saturating_add(T::DbWeight::get().reads(5_u64))
 			.saturating_add(T::DbWeight::get().writes(4_u64))
 	}
-	/// Storage: `AccountRoles::AccountRoles` (r:1 w:0)
-	/// Proof: `AccountRoles::AccountRoles` (`max_values`: None, `max_size`: None, mode: `Measured`)
-	/// Storage: `LiquidityPools::ScheduledLimitOrderUpdates` (r:1 w:1)
-	/// Proof: `LiquidityPools::ScheduledLimitOrderUpdates` (`max_values`: None, `max_size`: None, mode: `Measured`)
-	fn schedule_limit_order_update() -> Weight {
-		// Proof Size summary in bytes:
-		//  Measured:  `579`
-		//  Estimated: `4044`
-		// Minimum execution time: 14_000_000 picoseconds.
-		Weight::from_parts(15_000_000, 4044)
-			.saturating_add(T::DbWeight::get().reads(2_u64))
-			.saturating_add(T::DbWeight::get().writes(1_u64))
-	}
 	/// Storage: `LiquidityPools::MaximumPriceImpact` (r:0 w:6)
 	/// Proof: `LiquidityPools::MaximumPriceImpact` (`max_values`: None, `max_size`: None, mode: `Measured`)
 	/// The range of component `n` is `[1, 6]`.
@@ -335,19 +321,6 @@ impl WeightInfo for () {
 			.saturating_add(ParityDbWeight::get().reads(5_u64))
 			.saturating_add(ParityDbWeight::get().writes(4_u64))
 	}
-	/// Storage: `AccountRoles::AccountRoles` (r:1 w:0)
-	/// Proof: `AccountRoles::AccountRoles` (`max_values`: None, `max_size`: None, mode: `Measured`)
-	/// Storage: `LiquidityPools::ScheduledLimitOrderUpdates` (r:1 w:1)
-	/// Proof: `LiquidityPools::ScheduledLimitOrderUpdates` (`max_values`: None, `max_size`: None, mode: `Measured`)
-	fn schedule_limit_order_update() -> Weight {
-		// Proof Size summary in bytes:
-		//  Measured:  `579`
-		//  Estimated: `4044`
-		// Minimum execution time: 14_000_000 picoseconds.
-		Weight::from_parts(15_000_000, 4044)
-			.saturating_add(ParityDbWeight::get().reads(2_u64))
-			.saturating_add(ParityDbWeight::get().writes(1_u64))
-	}
 	/// Storage: `LiquidityPools::MaximumPriceImpact` (r:0 w:6)
 	/// Proof: `LiquidityPools::MaximumPriceImpact` (`max_values`: None, `max_size`: None, mode: `Measured`)
 	/// The range of component `n` is `[1, 6]`.


Reviews:
user: login: msgmaxim
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T03:47:08Z
user: login: dandanlen
body: Looking good, some minor comments. 
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-30T09:44:43Z
user: login: j4m1ef0rd
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T04:23:54Z
user: login: j4m1ef0rd
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T05:49:44Z
user: login: dandanlen
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-05T13:47:16Z
user: login: msgmaxim
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T05:27:38Z
user: login: msgmaxim
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T05:44:15Z
user: login: j4m1ef0rd
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T05:45:01Z
user: login: j4m1ef0rd
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T04:00:20Z
user: login: dandanlen
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-12T11:01:00Z
user: login: dandanlen
body: I agree using a struct here is much nicer 👌 
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-12T11:23:16Z
user: login: dandanlen
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-12T11:26:08Z
user: login: dandanlen
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-12T11:34:38Z
user: login: msgmaxim
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T02:34:42Z
user: login: dandanlen
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-14T07:55:06Z

Review comments:
diff_hunk: @@ -241,6 +241,7 @@ impl LpRpcApiServer for RpcServerImpl {
 		tick: Option<Tick>,
 		sell_amount: NumberOrHex,
 		dispatch_at: Option<BlockNumber>,
+		expire_at: Option<BlockNumber>,
path: api/bin/chainflip-lp-api/src/main.rs
user: login: msgmaxim
body: I believe this will be a breaking change if someone is using positional arguments to call this. We should either make this the last parameter or add a v2 method. (Personally I think we might want to reserve a v2 for when we need a bigger change.)
created_at: 2025-04-30T01:25:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -241,6 +241,7 @@ impl LpRpcApiServer for RpcServerImpl {
 		tick: Option<Tick>,
path: api/bin/chainflip-lp-api/src/main.rs
user: login: msgmaxim
body: I think we want to add the same parameter to `update_limit_order` too. Both of the methods are sometimes used interchangeably.
created_at: 2025-04-30T01:29:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -986,6 +997,52 @@ pub mod pallet {
 
 			Ok(())
 		}
+
+		/// Cancels a limit order.
+		/// Main difference with `update_limit_order` is that this will not error if the order does
+		/// not exist. This is used for auto-expiring orders that may of already been swept and
+		/// removed, without spamming failure events.
+		#[pallet::call_index(12)]
+		#[pallet::weight(T::WeightInfo::cancel_limit_order())]
+		pub fn cancel_limit_order(
+			origin: OriginFor<T>,
+			base_asset: any::Asset,
+			quote_asset: any::Asset,
+			side: Side,
+			id: OrderId,
+		) -> DispatchResult {
+			let lp = &T::AccountRoleRegistry::ensure_liquidity_provider(origin)?;
+			ensure!(
+				T::SafeMode::get().limit_order_update_enabled,
+				Error::<T>::UpdatingLimitOrdersDisabled
+			);
+
+			let asset_pair = AssetPair::try_new::<T>(base_asset, quote_asset)?;
+			Self::try_mutate_pool(asset_pair, |asset_pair, pool| {
+				match pool.limit_orders_cache[side.to_sold_pair()]
+					.get(lp)
+					.and_then(|limit_orders| limit_orders.get(&id))
+					.cloned()
path: state-chain/pallets/cf-pools/src/lib.rs
user: login: msgmaxim
body: Can't this just be `copied` since it is just a Tick?
created_at: 2025-04-30T01:37:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -986,6 +997,52 @@ pub mod pallet {
 
 			Ok(())
 		}
+
+		/// Cancels a limit order.
+		/// Main difference with `update_limit_order` is that this will not error if the order does
+		/// not exist. This is used for auto-expiring orders that may of already been swept and
+		/// removed, without spamming failure events.
+		#[pallet::call_index(12)]
+		#[pallet::weight(T::WeightInfo::cancel_limit_order())]
+		pub fn cancel_limit_order(
+			origin: OriginFor<T>,
+			base_asset: any::Asset,
+			quote_asset: any::Asset,
+			side: Side,
+			id: OrderId,
+		) -> DispatchResult {
+			let lp = &T::AccountRoleRegistry::ensure_liquidity_provider(origin)?;
+			ensure!(
+				T::SafeMode::get().limit_order_update_enabled,
+				Error::<T>::UpdatingLimitOrdersDisabled
+			);
+
+			let asset_pair = AssetPair::try_new::<T>(base_asset, quote_asset)?;
+			Self::try_mutate_pool(asset_pair, |asset_pair, pool| {
+				match pool.limit_orders_cache[side.to_sold_pair()]
+					.get(lp)
+					.and_then(|limit_orders| limit_orders.get(&id))
+					.cloned()
+				{
+					None => Ok::<(), DispatchError>(()),
+					Some(previous_tick) => {
path: state-chain/pallets/cf-pools/src/lib.rs
user: login: msgmaxim
body: Since there is no `new_tick` this should probably be called something other than `previous_tick`. Just `tick` is probably fine.
created_at: 2025-04-30T01:38:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -986,6 +997,52 @@ pub mod pallet {
 
 			Ok(())
 		}
+
+		/// Cancels a limit order.
+		/// Main difference with `update_limit_order` is that this will not error if the order does
+		/// not exist. This is used for auto-expiring orders that may of already been swept and
+		/// removed, without spamming failure events.
+		#[pallet::call_index(12)]
+		#[pallet::weight(T::WeightInfo::cancel_limit_order())]
+		pub fn cancel_limit_order(
+			origin: OriginFor<T>,
+			base_asset: any::Asset,
+			quote_asset: any::Asset,
+			side: Side,
+			id: OrderId,
+		) -> DispatchResult {
+			let lp = &T::AccountRoleRegistry::ensure_liquidity_provider(origin)?;
+			ensure!(
+				T::SafeMode::get().limit_order_update_enabled,
+				Error::<T>::UpdatingLimitOrdersDisabled
+			);
+
+			let asset_pair = AssetPair::try_new::<T>(base_asset, quote_asset)?;
+			Self::try_mutate_pool(asset_pair, |asset_pair, pool| {
+				match pool.limit_orders_cache[side.to_sold_pair()]
+					.get(lp)
+					.and_then(|limit_orders| limit_orders.get(&id))
+					.cloned()
+				{
+					None => Ok::<(), DispatchError>(()),
+					Some(previous_tick) => {
+						Self::inner_update_limit_order_at_tick(
+							pool,
+							lp,
+							asset_pair,
+							side,
+							id,
+							previous_tick,
+							crate::pallet::IncreaseOrDecrease::Decrease(Amount::MAX),
path: state-chain/pallets/cf-pools/src/lib.rs
user: login: msgmaxim
body: Nit, but `IncreaseOrDecrease` and `NoOpStatus` should already be in scope, so you don't need the full path.
created_at: 2025-04-30T03:42:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1402,3 +1420,156 @@ fn test_sweeping_when_updating_range_order() {
 		assert_eq!(HistoricalEarnedFees::<Test>::get(BOB, STABLE_ASSET), EXPECTED_FEES);
 	});
 }
+
+#[test]
+fn test_limit_order_expire_at() {
+	const ASSET: Asset = Asset::Flip;
+	const EXPIRE_AT: u64 = 10;
+	const AMOUNT: AssetAmount = 10_000;
+	const ORDER_ID: u64 = 1;
+
+	new_test_ext()
+		.execute_with(|| {
+			assert_ok!(LiquidityPools::new_pool(
+				RuntimeOrigin::root(),
+				ASSET,
+				STABLE_ASSET,
+				0,
+				price_at_tick(0).unwrap(),
+			));
+
+			MockBalance::credit_account(&ALICE, ASSET, AMOUNT);
+
+			// Make sure that a expiry block that is in the past is not accepted
+			assert_noop!(
+				LiquidityPools::set_limit_order(
+					RuntimeOrigin::signed(ALICE),
+					ASSET,
+					STABLE_ASSET,
+					Side::Sell,
+					ORDER_ID,
+					Some(5),
+					AMOUNT,
+					Some(0), // Block 0 will always be in the past
+				),
+				Error::<Test>::LimitOrderUpdateExpired
+			);
+
+			// Set a limit order with an expiry block in the future
+			assert_ok!(LiquidityPools::set_limit_order(
+				RuntimeOrigin::signed(ALICE),
+				ASSET,
+				STABLE_ASSET,
+				Side::Sell,
+				ORDER_ID,
+				Some(5),
+				AMOUNT,
+				Some(EXPIRE_AT),
+			));
+
+			// Check that the event for scheduling the expire order is emitted
+			assert_events_match!(
+				Test,
+				RuntimeEvent::LiquidityPools(Event::LimitOrderSetOrUpdateScheduled {
+					lp: ALICE,
+					order_id: ORDER_ID,
+					dispatch_at: EXPIRE_AT,
+				}) => ()
+			);
+
+			// The order should be present in the pool
+			assert_eq!(
+				LiquidityPools::pool_orders(ASSET, STABLE_ASSET, Some(ALICE), false)
+					.unwrap()
+					.limit_orders
+					.asks
+					.len(),
+				1
+			);
+		})
+		.then_execute_at_block(EXPIRE_AT, |_| {
path: state-chain/pallets/cf-pools/src/tests.rs
user: login: msgmaxim
body: Probably better to use `then_process_blocks_until_block` since it is closer to what happens in practice (i.e. we don't skip blocks).
created_at: 2025-04-30T03:45:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -757,7 +759,15 @@ pub mod pallet {
 				id,
 				option_tick,
 				sell_amount,
-			)
+			)?;
+
+			if let Some(expire_at) = expire_at {
+				let call = Call::cancel_limit_order { base_asset, quote_asset, side, id };
+				Self::schedule_limit_order_update(origin, Box::new(call), expire_at)
path: state-chain/pallets/cf-pools/src/lib.rs
user: login: dandanlen
body: This function checks if the expiry_at block is greater or equal than the current block. But for this particular case, it doesn't make much sense: If expire_at is equal to the current block, we would create the order and then immediately remove it. 

We should add a check to ensure that expire_at is greater than the current block. 
created_at: 2025-04-30T09:27:51Z
author_association: COLLABORATOR
diff_hunk: @@ -986,6 +997,52 @@ pub mod pallet {
 
 			Ok(())
 		}
+
+		/// Cancels a limit order.
+		/// Main difference with `update_limit_order` is that this will not error if the order does
+		/// not exist. This is used for auto-expiring orders that may of already been swept and
+		/// removed, without spamming failure events.
+		#[pallet::call_index(12)]
+		#[pallet::weight(T::WeightInfo::cancel_limit_order())]
+		pub fn cancel_limit_order(
path: state-chain/pallets/cf-pools/src/lib.rs
user: login: dandanlen
body: If this is the same as update_limit_order, why not just implement it via update_limit_order, ignoring the result? 
created_at: 2025-04-30T09:36:39Z
author_association: COLLABORATOR
diff_hunk: @@ -362,8 +358,14 @@ pub trait LpApi: SignedExtrinsicApi + Sized + Send + Sync + 'static {
 		option_tick: Option<Tick>,
 		sell_amount: AssetAmount,
 		dispatch_at: Option<BlockNumber>,
+		expire_at: Option<BlockNumber>,
 		wait_for: WaitFor,
 	) -> Result<ApiWaitForResult<Vec<LimitOrder>>> {
+		if let (Some(dispatch_at), Some(expire_at)) = (dispatch_at, expire_at) {
+			if dispatch_at >= expire_at {
path: api/lib/src/lp.rs
user: login: dandanlen
body: We could enforce this on the protocol to avoid any weird edge cases. 
created_at: 2025-04-30T09:40:58Z
author_association: COLLABORATOR
diff_hunk: @@ -757,7 +759,15 @@ pub mod pallet {
 				id,
 				option_tick,
 				sell_amount,
-			)
+			)?;
+
+			if let Some(expire_at) = expire_at {
+				let call = Call::cancel_limit_order { base_asset, quote_asset, side, id };
+				Self::schedule_limit_order_update(origin, Box::new(call), expire_at)
path: state-chain/pallets/cf-pools/src/lib.rs
user: login: dandanlen
body: Btw. I might make sense to break `schedule_limit_order_update` out into a pallet method instead of calling the extrinsic directly, otherwise we're duplicating things like the origin check. 
created_at: 2025-04-30T09:41:08Z
author_association: COLLABORATOR
diff_hunk: @@ -413,8 +413,14 @@ where
 		tick: Option<Tick>,
 		sell_amount: NumberOrHex,
 		dispatch_at: Option<BlockNumber>,
+		expire_at: Option<BlockNumber>,
 		wait_for: Option<WaitFor>,
 	) -> RpcResult<ApiWaitForResult<Vec<LimitOrder>>> {
+		if let (Some(dispatch_at), Some(expire_at)) = (dispatch_at, expire_at) {
+			if dispatch_at >= expire_at {
+				Err(anyhow!("Invalid expire_at, must be larger than dispatch_at"))?;
+			}
+		};
path: state-chain/custom-rpc/src/lp.rs
user: login: dandanlen
body: Putting this check in the runtime would also avoid having to duplicated it here.. 
created_at: 2025-04-30T09:44:03Z
author_association: COLLABORATOR
diff_hunk: @@ -241,6 +241,7 @@ impl LpRpcApiServer for RpcServerImpl {
 		tick: Option<Tick>,
path: api/bin/chainflip-lp-api/src/main.rs
user: login: j4m1ef0rd
body: I didnt want to add it to the `update` because the user might expect it to update the expire block. This would involve looking up the existing cancel order and removing it before adding the new one. Not simple or cheap to do, so I avoided it for now.
created_at: 2025-05-01T04:23:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -986,6 +997,52 @@ pub mod pallet {
 
 			Ok(())
 		}
+
+		/// Cancels a limit order.
+		/// Main difference with `update_limit_order` is that this will not error if the order does
+		/// not exist. This is used for auto-expiring orders that may of already been swept and
+		/// removed, without spamming failure events.
+		#[pallet::call_index(12)]
+		#[pallet::weight(T::WeightInfo::cancel_limit_order())]
+		pub fn cancel_limit_order(
path: state-chain/pallets/cf-pools/src/lib.rs
user: login: j4m1ef0rd
body: Because the `on_initialize` does not see the difference between a auto-cancel call and a scheduled update, so it will deposit the `ScheduledLimitOrderUpdateDispatchFailure`event. Having a seperate call was the easiest solution.
created_at: 2025-05-01T05:49:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -986,6 +997,52 @@ pub mod pallet {
 
 			Ok(())
 		}
+
+		/// Cancels a limit order.
+		/// Main difference with `update_limit_order` is that this will not error if the order does
+		/// not exist. This is used for auto-expiring orders that may of already been swept and
+		/// removed, without spamming failure events.
+		#[pallet::call_index(12)]
+		#[pallet::weight(T::WeightInfo::cancel_limit_order())]
+		pub fn cancel_limit_order(
path: state-chain/pallets/cf-pools/src/lib.rs
user: login: dandanlen
body: Yeah I get that. I don't see having a few extra instances of this event as a big downside really - might as well have the visibility of this. Not a strong opinion though, it's fine as it is. 
created_at: 2025-05-05T13:47:16Z
author_association: COLLABORATOR
diff_hunk: @@ -837,12 +931,17 @@ pub mod pallet {
 		#[pallet::weight(T::WeightInfo::schedule_limit_order_update())]
 		pub fn schedule_limit_order_update(
 			origin: OriginFor<T>,
-			call: Box<Call<T>>,
-			dispatch_at: BlockNumberFor<T>,
-		) -> DispatchResultWithPostInfo {
+			base_asset: any::Asset,
+			quote_asset: any::Asset,
+			side: Side,
+			id: OrderId,
+			details: LimitOrderUpdateDetails<BlockNumberFor<T>>,
+			dispatch_at: Option<BlockNumberFor<T>>,
+		) -> DispatchResult {
 			let lp = T::AccountRoleRegistry::ensure_liquidity_provider(origin)?;
 
-			Self::schedule_or_dispatch_limit_order_update(lp, call, dispatch_at)
+			LimitOrderUpdate::<T> { lp: lp.clone(), base_asset, quote_asset, side, id, details }
+				.schedule_or_dispatch(dispatch_at)
path: state-chain/pallets/cf-pools/src/lib.rs
user: login: msgmaxim
body: If this is the only place where `schedule_or_dispatch` is called I think it might make sense to inline it? (If we had more code in `schedule_limit_order_update`, it would probably be justified to split it up, but now it looks like there isn't much benefit to having a separate function).
created_at: 2025-05-08T05:11:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -837,12 +931,17 @@ pub mod pallet {
 		#[pallet::weight(T::WeightInfo::schedule_limit_order_update())]
 		pub fn schedule_limit_order_update(
 			origin: OriginFor<T>,
-			call: Box<Call<T>>,
-			dispatch_at: BlockNumberFor<T>,
-		) -> DispatchResultWithPostInfo {
+			base_asset: any::Asset,
+			quote_asset: any::Asset,
+			side: Side,
+			id: OrderId,
+			details: LimitOrderUpdateDetails<BlockNumberFor<T>>,
path: state-chain/pallets/cf-pools/src/lib.rs
user: login: msgmaxim
body: Is the assumption here that `schedule_limit_order_update` isn't documented, and thus breaking compatibility isn'ta problem since it is only used internally in lp-api (which you also updated)?
created_at: 2025-05-08T05:18:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,195 @@
+// Copyright 2025 Chainflip Labs GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+use frame_support::traits::UncheckedOnRuntimeUpgrade;
+
+use crate::Config;
+
+use crate::*;
+use frame_support::pallet_prelude::Weight;
+#[cfg(feature = "try-runtime")]
+use sp_runtime::DispatchError;
+
+use codec::{Decode, Encode};
+
+pub mod old {
+	use super::*;
+
+	#[derive(Clone, DebugNoBound, PartialEq, Eq, Encode, Decode, TypeInfo)]
+	pub(super) struct LimitOrderUpdate<T: Config> {
+		pub lp: T::AccountId,
+		pub id: OrderId,
+		pub call: LimitOrderCall,
+	}
+
+	#[derive(Clone, DebugNoBound, PartialEq, Eq, Encode, Decode, TypeInfo)]
+	#[repr(u8)]
+	#[allow(clippy::unnecessary_cast)]
+	// This enum is laid out in the same way as the old call, so it will decode exactly the same.
+	// The index of the variants is set to match the call_index of the old call.
+	pub(super) enum LimitOrderCall {
+		UpdateLimitOrder {
+			base_asset: any::Asset,
+			quote_asset: any::Asset,
+			side: Side,
+			id: OrderId,
+			option_tick: Option<Tick>,
+			amount_change: IncreaseOrDecrease<AssetAmount>,
+		} = 5_u8,
+		SetLimitOrder {
+			base_asset: Asset,
+			quote_asset: Asset,
+			side: Side,
+			id: u64,
+			option_tick: Option<Tick>,
+			sell_amount: AssetAmount,
+		} = 6_u8,
+	}
+
+	// Migrating this storage item. It was previously storing the call directly in
+	// `LimitOrderUpdate`, but now it is a struct.
+	#[frame_support::storage_alias]
+	pub type ScheduledLimitOrderUpdates<T: Config> = StorageMap<
+		Pallet<T>,
+		Twox64Concat,
+		BlockNumberFor<T>,
+		Vec<LimitOrderUpdate<T>>,
+		ValueQuery,
+	>;
+}
+
+pub struct Migration<T: Config>(PhantomData<T>);
+
+// Migrating from Call -> Struct
+fn migrate_update<T: Config>(update: old::LimitOrderUpdate<T>) -> LimitOrderUpdate<T> {
+	match update.call {
+		old::LimitOrderCall::SetLimitOrder {
+			base_asset,
+			quote_asset,
+			side,
+			id,
+			option_tick,
+			sell_amount,
+		} => LimitOrderUpdate {
+			lp: update.lp,
+			id,
+			base_asset,
+			quote_asset,
+			side,
+			details: LimitOrderUpdateDetails::Set {
+				option_tick,
+				sell_amount,
+				// Setting the close_order_at to None by default because it did not exist before.
+				close_order_at: None,
+			},
+		},
+		old::LimitOrderCall::UpdateLimitOrder {
+			base_asset,
+			quote_asset,
+			side,
+			id,
+			option_tick,
+			amount_change,
+		} => LimitOrderUpdate {
+			lp: update.lp,
+			id,
+			base_asset,
+			quote_asset,
+			side,
+			details: LimitOrderUpdateDetails::Update { option_tick, amount_change },
+		},
+	}
+}
+
+impl<T: Config> UncheckedOnRuntimeUpgrade for Migration<T> {
+	#[cfg(feature = "try-runtime")]
+	fn pre_upgrade() -> Result<Vec<u8>, DispatchError> {
+		let update_count = old::ScheduledLimitOrderUpdates::<T>::iter()
+			.map(|(_, updates)| updates.len() as u64)
+			.sum::<u64>();
+		Ok(update_count.encode())
+	}
+
+	fn on_runtime_upgrade() -> Weight {
+		crate::ScheduledLimitOrderUpdates::<T>::translate_values::<Vec<old::LimitOrderUpdate<T>>, _>(
+			|old_updates| {
+				Some(old_updates.into_iter().map(|update| migrate_update(update)).collect())
+			},
+		);
+
+		Weight::zero()
+	}
+
+	#[cfg(feature = "try-runtime")]
+	fn post_upgrade(state: Vec<u8>) -> Result<(), DispatchError> {
+		let pre_update_count = <u64>::decode(&mut state.as_slice())
+			.map_err(|_| DispatchError::from("Failed to decode state"))?;
+
+		let post_update_count = crate::ScheduledLimitOrderUpdates::<T>::iter()
+			.map(|(_, updates)| updates.len() as u64)
+			.sum::<u64>();
+
+		assert_eq!(pre_update_count, post_update_count);
+		Ok(())
+	}
+}
+
+#[test]
+fn test_migrate_update() {
+	use crate::mock::*;
+
+	// How the storage item used to look, storing the call directly
+	#[derive(Clone, DebugNoBound, PartialEq, Eq, Encode, Decode, TypeInfo)]
+	struct OldStorage<T: Config> {
+		pub lp: T::AccountId,
+		pub id: OrderId,
+		pub call: crate::Call<T>,
+	}
+
+	new_test_ext().execute_with(|| {
+		// Create a call (The update_limit_order has not changed, so we can use it for the test)
+		let call = crate::Call::<Test>::update_limit_order {
path: state-chain/pallets/cf-pools/src/migrations/scheduled_limit_order_updates.rs
user: login: msgmaxim
body: might be worth testing `set_limit_order` order too? (Just to show that we can decode it correctly too)
created_at: 2025-05-08T05:26:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -837,12 +931,17 @@ pub mod pallet {
 		#[pallet::weight(T::WeightInfo::schedule_limit_order_update())]
 		pub fn schedule_limit_order_update(
 			origin: OriginFor<T>,
-			call: Box<Call<T>>,
-			dispatch_at: BlockNumberFor<T>,
-		) -> DispatchResultWithPostInfo {
+			base_asset: any::Asset,
+			quote_asset: any::Asset,
+			side: Side,
+			id: OrderId,
+			details: LimitOrderUpdateDetails<BlockNumberFor<T>>,
path: state-chain/pallets/cf-pools/src/lib.rs
user: login: msgmaxim
body: Also (as discussed), if `schedule_limit_order_update` isn't used externally, maybe we could just remove it and instead add distpatch_at field to `set_limit_order` and `update_limit_order` calls directly (as long is it is done in a backwards compatible way)? Wdyt @dandanlen?
created_at: 2025-05-08T05:44:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,195 @@
+// Copyright 2025 Chainflip Labs GmbH
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+// SPDX-License-Identifier: Apache-2.0
+use frame_support::traits::UncheckedOnRuntimeUpgrade;
+
+use crate::Config;
+
+use crate::*;
+use frame_support::pallet_prelude::Weight;
+#[cfg(feature = "try-runtime")]
+use sp_runtime::DispatchError;
+
+use codec::{Decode, Encode};
+
+pub mod old {
+	use super::*;
+
+	#[derive(Clone, DebugNoBound, PartialEq, Eq, Encode, Decode, TypeInfo)]
+	pub(super) struct LimitOrderUpdate<T: Config> {
+		pub lp: T::AccountId,
+		pub id: OrderId,
+		pub call: LimitOrderCall,
+	}
+
+	#[derive(Clone, DebugNoBound, PartialEq, Eq, Encode, Decode, TypeInfo)]
+	#[repr(u8)]
+	#[allow(clippy::unnecessary_cast)]
+	// This enum is laid out in the same way as the old call, so it will decode exactly the same.
+	// The index of the variants is set to match the call_index of the old call.
+	pub(super) enum LimitOrderCall {
+		UpdateLimitOrder {
+			base_asset: any::Asset,
+			quote_asset: any::Asset,
+			side: Side,
+			id: OrderId,
+			option_tick: Option<Tick>,
+			amount_change: IncreaseOrDecrease<AssetAmount>,
+		} = 5_u8,
+		SetLimitOrder {
+			base_asset: Asset,
+			quote_asset: Asset,
+			side: Side,
+			id: u64,
+			option_tick: Option<Tick>,
+			sell_amount: AssetAmount,
+		} = 6_u8,
+	}
+
+	// Migrating this storage item. It was previously storing the call directly in
+	// `LimitOrderUpdate`, but now it is a struct.
+	#[frame_support::storage_alias]
+	pub type ScheduledLimitOrderUpdates<T: Config> = StorageMap<
+		Pallet<T>,
+		Twox64Concat,
+		BlockNumberFor<T>,
+		Vec<LimitOrderUpdate<T>>,
+		ValueQuery,
+	>;
+}
+
+pub struct Migration<T: Config>(PhantomData<T>);
+
+// Migrating from Call -> Struct
+fn migrate_update<T: Config>(update: old::LimitOrderUpdate<T>) -> LimitOrderUpdate<T> {
+	match update.call {
+		old::LimitOrderCall::SetLimitOrder {
+			base_asset,
+			quote_asset,
+			side,
+			id,
+			option_tick,
+			sell_amount,
+		} => LimitOrderUpdate {
+			lp: update.lp,
+			id,
+			base_asset,
+			quote_asset,
+			side,
+			details: LimitOrderUpdateDetails::Set {
+				option_tick,
+				sell_amount,
+				// Setting the close_order_at to None by default because it did not exist before.
+				close_order_at: None,
+			},
+		},
+		old::LimitOrderCall::UpdateLimitOrder {
+			base_asset,
+			quote_asset,
+			side,
+			id,
+			option_tick,
+			amount_change,
+		} => LimitOrderUpdate {
+			lp: update.lp,
+			id,
+			base_asset,
+			quote_asset,
+			side,
+			details: LimitOrderUpdateDetails::Update { option_tick, amount_change },
+		},
+	}
+}
+
+impl<T: Config> UncheckedOnRuntimeUpgrade for Migration<T> {
+	#[cfg(feature = "try-runtime")]
+	fn pre_upgrade() -> Result<Vec<u8>, DispatchError> {
+		let update_count = old::ScheduledLimitOrderUpdates::<T>::iter()
+			.map(|(_, updates)| updates.len() as u64)
+			.sum::<u64>();
+		Ok(update_count.encode())
+	}
+
+	fn on_runtime_upgrade() -> Weight {
+		crate::ScheduledLimitOrderUpdates::<T>::translate_values::<Vec<old::LimitOrderUpdate<T>>, _>(
+			|old_updates| {
+				Some(old_updates.into_iter().map(|update| migrate_update(update)).collect())
+			},
+		);
+
+		Weight::zero()
+	}
+
+	#[cfg(feature = "try-runtime")]
+	fn post_upgrade(state: Vec<u8>) -> Result<(), DispatchError> {
+		let pre_update_count = <u64>::decode(&mut state.as_slice())
+			.map_err(|_| DispatchError::from("Failed to decode state"))?;
+
+		let post_update_count = crate::ScheduledLimitOrderUpdates::<T>::iter()
+			.map(|(_, updates)| updates.len() as u64)
+			.sum::<u64>();
+
+		assert_eq!(pre_update_count, post_update_count);
+		Ok(())
+	}
+}
+
+#[test]
+fn test_migrate_update() {
+	use crate::mock::*;
+
+	// How the storage item used to look, storing the call directly
+	#[derive(Clone, DebugNoBound, PartialEq, Eq, Encode, Decode, TypeInfo)]
+	struct OldStorage<T: Config> {
+		pub lp: T::AccountId,
+		pub id: OrderId,
+		pub call: crate::Call<T>,
+	}
+
+	new_test_ext().execute_with(|| {
+		// Create a call (The update_limit_order has not changed, so we can use it for the test)
+		let call = crate::Call::<Test>::update_limit_order {
path: state-chain/pallets/cf-pools/src/migrations/scheduled_limit_order_updates.rs
user: login: j4m1ef0rd
body: As discussed, Not easy to do because I added a field to `set_limit_order`.
created_at: 2025-05-08T05:45:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -179,32 +180,144 @@ impl<T> AskBidMap<T> {
 	}
 }
 
+#[derive(Clone, Encode, DebugNoBound, Decode, TypeInfo, PartialEq, Eq)]
+#[scale_info(skip_type_params(T))]
+pub struct LimitOrderUpdate<T: Config> {
+	pub lp: T::AccountId,
+	pub base_asset: any::Asset,
+	pub quote_asset: any::Asset,
+	pub side: Side,
+	pub id: OrderId,
+	pub details: LimitOrderUpdateDetails<BlockNumberFor<T>>,
+}
+
+#[derive(Clone, Encode, DebugNoBound, Decode, TypeInfo, PartialEq, Eq)]
+pub enum LimitOrderUpdateDetails<BlockNumber: sp_std::fmt::Debug> {
+	Update { option_tick: Option<Tick>, amount_change: IncreaseOrDecrease<AssetAmount> },
+	Set { option_tick: Option<Tick>, sell_amount: AssetAmount, close_order_at: Option<BlockNumber> },
+	Close,
+}
+
+impl<T: Config> LimitOrderUpdate<T> {
+	pub fn dispatch(self) -> (Weight, DispatchResult) {
+		let (weight, result) = match self.details {
+			LimitOrderUpdateDetails::Set { option_tick, sell_amount, close_order_at } => {
+				let result = Pallet::<T>::set_limit_order(
+					OriginTrait::signed(self.lp.clone()),
+					self.base_asset,
+					self.quote_asset,
+					self.side,
+					self.id,
+					option_tick,
+					sell_amount,
+					close_order_at,
+				);
+				(T::WeightInfo::set_limit_order(), result)
+			},
+			LimitOrderUpdateDetails::Update { option_tick, amount_change } => {
+				let result = Pallet::<T>::update_limit_order(
+					OriginTrait::signed(self.lp.clone()),
+					self.base_asset,
+					self.quote_asset,
+					self.side,
+					self.id,
+					option_tick,
+					amount_change,
+				);
+				(T::WeightInfo::update_limit_order(), result)
+			},
+			LimitOrderUpdateDetails::Close => {
+				let result = Pallet::<T>::update_limit_order(
+					OriginTrait::signed(self.lp.clone()),
+					self.base_asset,
+					self.quote_asset,
+					self.side,
+					self.id,
+					None,
+					IncreaseOrDecrease::Decrease(AssetAmount::MAX),
+				);
+				let result = if let Err(err) = result {
+					if err == Error::<T>::OrderDoesNotExist.into() {
+						// Ignore the error if the order doesn't exist, as this is expected.
+						Ok(())
+					} else {
+						Err(err)
+					}
+				} else {
+					result
+				};
+				(T::WeightInfo::update_limit_order(), result)
+			},
+		};
+		match result {
+			Ok(()) => {
+				Pallet::<T>::deposit_event(Event::<T>::ScheduledLimitOrderUpdateDispatchSuccess {
+					lp: self.lp,
+					order_id: self.id,
+				});
+			},
+			Err(err) => {
+				Pallet::<T>::deposit_event(Event::<T>::ScheduledLimitOrderUpdateDispatchFailure {
+					lp: self.lp,
+					order_id: self.id,
+					error: err,
+				});
+			},
+		}
+		(weight, result)
+	}
+
+	pub fn schedule(self, dispatch_at: BlockNumberFor<T>) {
+		Pallet::<T>::deposit_event(Event::<T>::LimitOrderSetOrUpdateScheduled {
+			lp: self.lp.clone(),
+			order_id: self.id,
+			dispatch_at,
+		});
+		ScheduledLimitOrderUpdates::<T>::append(dispatch_at, self);
+	}
+
+	pub fn schedule_or_dispatch(self, dispatch_at: Option<BlockNumberFor<T>>) -> DispatchResult {
+		if let Some(dispatch_at) = dispatch_at {
+			let current_block_number = frame_system::Pallet::<T>::block_number();
+			ensure!(dispatch_at >= current_block_number, Error::<T>::LimitOrderUpdateExpired);
path: state-chain/pallets/cf-pools/src/lib.rs
user: login: j4m1ef0rd
body: I do wonder if we should put a cap on how far in the future someone can schedule an update for. Because they could potentially schedule a lot of junk updates for a million years in the future just to fill up space on the chain.
Would also prevent accidental scheduled updates, like putting an extra 0 on the dispatch block and now it is scheduled for 10 years in the future with no way to clean it up.
I'm unable to see if there is any ridiculous updates scheduled on mainnet because its in a storage map.
@dandanlen @msgmaxim 
created_at: 2025-05-12T04:00:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -179,32 +180,144 @@ impl<T> AskBidMap<T> {
 	}
 }
 
+#[derive(Clone, Encode, DebugNoBound, Decode, TypeInfo, PartialEq, Eq)]
+#[scale_info(skip_type_params(T))]
+pub struct LimitOrderUpdate<T: Config> {
+	pub lp: T::AccountId,
+	pub base_asset: any::Asset,
+	pub quote_asset: any::Asset,
+	pub side: Side,
+	pub id: OrderId,
+	pub details: LimitOrderUpdateDetails<BlockNumberFor<T>>,
+}
+
+#[derive(Clone, Encode, DebugNoBound, Decode, TypeInfo, PartialEq, Eq)]
+pub enum LimitOrderUpdateDetails<BlockNumber: sp_std::fmt::Debug> {
+	Update { option_tick: Option<Tick>, amount_change: IncreaseOrDecrease<AssetAmount> },
+	Set { option_tick: Option<Tick>, sell_amount: AssetAmount, close_order_at: Option<BlockNumber> },
+	Close,
+}
+
+impl<T: Config> LimitOrderUpdate<T> {
+	pub fn dispatch(self) -> (Weight, DispatchResult) {
+		let (weight, result) = match self.details {
+			LimitOrderUpdateDetails::Set { option_tick, sell_amount, close_order_at } => {
+				let result = Pallet::<T>::set_limit_order(
+					OriginTrait::signed(self.lp.clone()),
+					self.base_asset,
+					self.quote_asset,
+					self.side,
+					self.id,
+					option_tick,
+					sell_amount,
+					close_order_at,
+				);
+				(T::WeightInfo::set_limit_order(), result)
+			},
+			LimitOrderUpdateDetails::Update { option_tick, amount_change } => {
+				let result = Pallet::<T>::update_limit_order(
+					OriginTrait::signed(self.lp.clone()),
+					self.base_asset,
+					self.quote_asset,
+					self.side,
+					self.id,
+					option_tick,
+					amount_change,
+				);
+				(T::WeightInfo::update_limit_order(), result)
+			},
+			LimitOrderUpdateDetails::Close => {
+				let result = Pallet::<T>::update_limit_order(
+					OriginTrait::signed(self.lp.clone()),
+					self.base_asset,
+					self.quote_asset,
+					self.side,
+					self.id,
+					None,
+					IncreaseOrDecrease::Decrease(AssetAmount::MAX),
+				);
+				let result = if let Err(err) = result {
+					if err == Error::<T>::OrderDoesNotExist.into() {
+						// Ignore the error if the order doesn't exist, as this is expected.
+						Ok(())
+					} else {
+						Err(err)
+					}
+				} else {
+					result
+				};
+				(T::WeightInfo::update_limit_order(), result)
+			},
+		};
+		match result {
+			Ok(()) => {
+				Pallet::<T>::deposit_event(Event::<T>::ScheduledLimitOrderUpdateDispatchSuccess {
+					lp: self.lp,
+					order_id: self.id,
+				});
+			},
+			Err(err) => {
+				Pallet::<T>::deposit_event(Event::<T>::ScheduledLimitOrderUpdateDispatchFailure {
+					lp: self.lp,
+					order_id: self.id,
+					error: err,
+				});
+			},
+		}
+		(weight, result)
+	}
+
+	pub fn schedule(self, dispatch_at: BlockNumberFor<T>) {
+		Pallet::<T>::deposit_event(Event::<T>::LimitOrderSetOrUpdateScheduled {
+			lp: self.lp.clone(),
+			order_id: self.id,
+			dispatch_at,
+		});
+		ScheduledLimitOrderUpdates::<T>::append(dispatch_at, self);
+	}
+
+	pub fn schedule_or_dispatch(self, dispatch_at: Option<BlockNumberFor<T>>) -> DispatchResult {
+		if let Some(dispatch_at) = dispatch_at {
+			let current_block_number = frame_system::Pallet::<T>::block_number();
+			ensure!(dispatch_at >= current_block_number, Error::<T>::LimitOrderUpdateExpired);
path: state-chain/pallets/cf-pools/src/lib.rs
user: login: dandanlen
body: There are currently no scheduled updated on mainnet, but I agree it makes sense to limit the duration. I don't think it needs to be configurable, I doubt it's something that needs major fine-tuning, so a constant should be enough. What order of magnitude seems reasonable? 6 hours? 
created_at: 2025-05-12T11:01:00Z
author_association: COLLABORATOR
diff_hunk: @@ -837,12 +931,17 @@ pub mod pallet {
 		#[pallet::weight(T::WeightInfo::schedule_limit_order_update())]
 		pub fn schedule_limit_order_update(
 			origin: OriginFor<T>,
-			call: Box<Call<T>>,
-			dispatch_at: BlockNumberFor<T>,
-		) -> DispatchResultWithPostInfo {
+			base_asset: any::Asset,
+			quote_asset: any::Asset,
+			side: Side,
+			id: OrderId,
+			details: LimitOrderUpdateDetails<BlockNumberFor<T>>,
+			dispatch_at: Option<BlockNumberFor<T>>,
+		) -> DispatchResult {
 			let lp = T::AccountRoleRegistry::ensure_liquidity_provider(origin)?;
 
-			Self::schedule_or_dispatch_limit_order_update(lp, call, dispatch_at)
+			LimitOrderUpdate::<T> { lp: lp.clone(), base_asset, quote_asset, side, id, details }
+				.schedule_or_dispatch(dispatch_at)
path: state-chain/pallets/cf-pools/src/lib.rs
user: login: dandanlen
body: Yes I think I agree. 
created_at: 2025-05-12T11:16:37Z
author_association: COLLABORATOR
diff_hunk: @@ -837,12 +931,17 @@ pub mod pallet {
 		#[pallet::weight(T::WeightInfo::schedule_limit_order_update())]
 		pub fn schedule_limit_order_update(
 			origin: OriginFor<T>,
-			call: Box<Call<T>>,
-			dispatch_at: BlockNumberFor<T>,
-		) -> DispatchResultWithPostInfo {
+			base_asset: any::Asset,
+			quote_asset: any::Asset,
+			side: Side,
+			id: OrderId,
+			details: LimitOrderUpdateDetails<BlockNumberFor<T>>,
+			dispatch_at: Option<BlockNumberFor<T>>,
path: state-chain/pallets/cf-pools/src/lib.rs
user: login: dandanlen
body: Why is this optional? 
created_at: 2025-05-12T11:16:41Z
author_association: COLLABORATOR
diff_hunk: @@ -837,12 +931,17 @@ pub mod pallet {
 		#[pallet::weight(T::WeightInfo::schedule_limit_order_update())]
 		pub fn schedule_limit_order_update(
 			origin: OriginFor<T>,
-			call: Box<Call<T>>,
-			dispatch_at: BlockNumberFor<T>,
-		) -> DispatchResultWithPostInfo {
+			base_asset: any::Asset,
+			quote_asset: any::Asset,
+			side: Side,
+			id: OrderId,
+			details: LimitOrderUpdateDetails<BlockNumberFor<T>>,
path: state-chain/pallets/cf-pools/src/lib.rs
user: login: dandanlen
body: Any addition to the call parameters will break the SCALE encoding. The only way to make it fully backwards-compatible would be to add a new extrinsic and deprecate the old one. If we assume that everyone uses the LP API then we can assume it's a non-breaking change - probably a fair assumption? 

I do agree that adding a single param to the existing calls is probably better that a separate call (if nothing else, it makes the feature more visible).
created_at: 2025-05-12T11:20:35Z
author_association: COLLABORATOR
diff_hunk: @@ -847,32 +864,47 @@ pub mod pallet {
 				[base_asset, quote_asset],
 			)?;
 
-			Self::inner_set_limit_order(
-				&lp,
-				base_asset,
-				quote_asset,
-				side,
-				id,
-				option_tick,
-				sell_amount,
-			)?;
-
-			if let Some(close_order_at) = close_order_at {
-				if close_order_at <= frame_system::Pallet::<T>::block_number() {
-					return Err(Error::<T>::InvalidCloseOrderAt.into());
-				}
-
+			if let Some(dispatch_at) = dispatch_at {
 				LimitOrderUpdate::<T> {
 					lp: lp.clone(),
 					base_asset,
 					quote_asset,
 					side,
 					id,
-					details: LimitOrderUpdateDetails::Close,
+					details: LimitOrderUpdateDetails::Set {
+						option_tick,
+						sell_amount,
+						close_order_at,
+					},
 				}
-				.schedule(close_order_at);
-			}
+				.schedule_or_dispatch(Some(dispatch_at))?;
+			} else {
+				Self::inner_set_limit_order(
+					&lp,
+					base_asset,
+					quote_asset,
+					side,
+					id,
+					option_tick,
+					sell_amount,
+				)?;
+
+				if let Some(close_order_at) = close_order_at {
+					if close_order_at <= frame_system::Pallet::<T>::block_number() {
+						return Err(Error::<T>::InvalidCloseOrderAt.into());
+					}
path: state-chain/pallets/cf-pools/src/lib.rs
user: login: dandanlen
body: nit: `ensure` is a bit more idiomatic. 
created_at: 2025-05-12T11:26:08Z
author_association: COLLABORATOR
diff_hunk: @@ -464,9 +465,25 @@ fn cant_schedule_in_the_past() {
 				0,
 				Some(0),
 				IncreaseOrDecrease::Decrease(55),
-				Some(9) // 1 block in the past
+				// 1 block in the past
+				Some(CURRENT_BLOCK - 1)
 			),
-			Error::<Test>::LimitOrderUpdateExpired
+			Error::<Test>::InvalidDispatchAt
+		);
+
+		assert_noop!(
path: state-chain/pallets/cf-pools/src/tests.rs
user: login: msgmaxim
body: Would be nice to test that for `CURRENT_BLOCK + SCHEDULE_UPDATE_LIMIT_BLOCKS` this does work.
created_at: 2025-05-14T02:34:41Z
author_association: CONTRIBUTOR

PR comments:
user: login: coderabbitai[bot]
created_at: 2025-04-29T04:24:33Z
body: <!-- This is an auto-generated comment: summarize by coderabbit.ai -->
<!-- This is an auto-generated comment: skip review by coderabbit.ai -->

> [!IMPORTANT]
> ## Review skipped
> 
> Auto reviews are disabled on this repository.
> 
> Please check the settings in the CodeRabbit UI or the `.coderabbit.yaml` file in this repository. To trigger a single review, invoke the `@coderabbitai review` command.
> 
> You can disable this status message by setting the `reviews.review_status` to `false` in the CodeRabbit configuration file.

<!-- end of auto-generated comment: skip review by coderabbit.ai -->
<!-- tips_start -->

---

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

<details>
<summary>❤️ Share</summary>

- [X](https://twitter.com/intent/tweet?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A&url=https%3A//coderabbit.ai)
- [Mastodon](https://mastodon.social/share?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A%20https%3A%2F%2Fcoderabbit.ai)
- [Reddit](https://www.reddit.com/submit?title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&text=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code.%20Check%20it%20out%3A%20https%3A//coderabbit.ai)
- [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fcoderabbit.ai&mini=true&title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&summary=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code)

</details>

<details>
<summary>🪧 Tips</summary>

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=chainflip-io/chainflip-backend&utm_content=5836):

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
  - `I pushed a fix in commit <commit_id>, please review it.`
  - `Explain this complex logic.`
  - `Open a follow-up GitHub issue for this discussion.`
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
  - `@coderabbitai explain this code block.`
  -	`@coderabbitai modularize this function.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
  - `@coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.`
  - `@coderabbitai read src/utils.ts and explain its main purpose.`
  - `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`
  - `@coderabbitai help me debug CodeRabbit configuration file.`

### Support

Need help? Create a ticket on our [support page](https://www.coderabbit.ai/contact-us/support) for assistance with any issues or questions.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

### CodeRabbit Commands (Invoked using PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai full review` to do a full review from scratch and review all the files again.
- `@coderabbitai summary` to regenerate the summary of the PR.
- `@coderabbitai generate docstrings` to [generate docstrings](https://docs.coderabbit.ai/finishing-touches/docstrings) for this PR.
- `@coderabbitai generate sequence diagram` to generate a sequence diagram of the changes in this PR.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai configuration` to show the current CodeRabbit configuration for the repository.
- `@coderabbitai help` to get help.

### Other keywords and placeholders

- Add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.
- Add `@coderabbitai summary` to generate the high-level summary at a specific location in the PR description.
- Add `@coderabbitai` anywhere in the PR title to generate the title automatically.

### CodeRabbit Configuration File (`.coderabbit.yaml`)

- You can programmatically configure CodeRabbit by adding a `.coderabbit.yaml` file to the root of your repository.
- Please see the [configuration documentation](https://docs.coderabbit.ai/guides/configure-coderabbit) for more information.
- If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: `# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json`

### Documentation and Community

- Visit our [Documentation](https://docs.coderabbit.ai) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](http://discord.gg/coderabbit) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->
author_association: NONE
user: login: codecov[bot]
created_at: 2025-04-29T04:36:24Z
body: ## [Codecov](https://app.codecov.io/gh/chainflip-io/chainflip-backend/pull/5836?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=chainflip-io) Report
Attention: Patch coverage is `77.86624%` with `139 lines` in your changes missing coverage. Please review.
| [Files with missing lines](https://app.codecov.io/gh/chainflip-io/chainflip-backend/pull/5836?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=chainflip-io) | Patch % | Lines |
|---|---|---|
| [...ls/src/migrations/scheduled\_limit\_order\_updates.rs](https://app.codecov.io/gh/chainflip-io/chainflip-backend/pull/5836?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=chainflip-io#diff-c3RhdGUtY2hhaW4vcGFsbGV0cy9jZi1wb29scy9zcmMvbWlncmF0aW9ucy9zY2hlZHVsZWRfbGltaXRfb3JkZXJfdXBkYXRlcy5ycw==) | 56% | [44 Missing :warning: ](https://app.codecov.io/gh/chainflip-io/chainflip-backend/pull/5836?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=chainflip-io) |
| [state-chain/custom-rpc/src/lp.rs](https://app.codecov.io/gh/chainflip-io/chainflip-backend/pull/5836?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=chainflip-io#diff-c3RhdGUtY2hhaW4vY3VzdG9tLXJwYy9zcmMvbHAucnM=) | 0% | [35 Missing :warning: ](https://app.codecov.io/gh/chainflip-io/chainflip-backend/pull/5836?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=chainflip-io) |
| [api/lib/src/lp.rs](https://app.codecov.io/gh/chainflip-io/chainflip-backend/pull/5836?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=chainflip-io#diff-YXBpL2xpYi9zcmMvbHAucnM=) | 0% | [30 Missing :warning: ](https://app.codecov.io/gh/chainflip-io/chainflip-backend/pull/5836?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=chainflip-io) |
| [state-chain/pallets/cf-pools/src/lib.rs](https://app.codecov.io/gh/chainflip-io/chainflip-backend/pull/5836?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=chainflip-io#diff-c3RhdGUtY2hhaW4vcGFsbGV0cy9jZi1wb29scy9zcmMvbGliLnJz) | 84% | [24 Missing and 6 partials :warning: ](https://app.codecov.io/gh/chainflip-io/chainflip-backend/pull/5836?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=chainflip-io) |

:loudspeaker: Thoughts on this report? [Let us know!](https://github.com/codecov/feedback/issues/255)
author_association: NONE
user: login: CumpsD
created_at: 2025-04-29T08:03:38Z
body: This is a nice addition, simplifies LP code :)
author_association: NONE
user: login: dandanlen
created_at: 2025-04-30T09:15:25Z
body: > I think we want to add the same parameter to `update_limit_order` too. Both of the methods are sometimes used interchangeably.

I was going to suggest something similar to @msgmaxim but I also think that the semantics are a little ambiguous. I would suggest renaming `expire_at` to `expire_order_at` or `close_order_at` and then using that throughout. Using simply `expire_at` could mean that the *update* expires, ie. if you increase the size of an existing order, then it resets back to where it was when the update expires. (Makes little sense to do this, I know, but IMO better to be clear)
author_association: COLLABORATOR
user: login: j4m1ef0rd
created_at: 2025-05-06T05:04:51Z
body: Realised that adding a field to the `set_limit_order` call would probably need a migration since we store the call on chain for schedule queue. Dropped it back into draft while work a refactor to decouple the call from the storage item and write a migration.
@dandanlen 
author_association: CONTRIBUTOR
user: login: dandanlen
created_at: 2025-05-06T05:31:52Z
body: > Realised that adding a field to the `set_limit_order` call would probably need a migration since we store the call on chain for schedule queue. Dropped it back into draft while work a refactor to decouple the call from the storage item and write a migration.
> 
> @dandanlen 

Would it be easier to just add a new call and deprecate the old one? 
author_association: COLLABORATOR
user: login: j4m1ef0rd
created_at: 2025-05-08T04:47:32Z
body: I have done a large refactor to address a few issues:
- (main issue) Decoupled the storage item from the Call.
- No need to check that the type of call is correct when storing/using it.
- Deduplicate the dispatching and scheduling of order updates.
- No need for the `cancel_limit_order` call and we still keep the feature of not depositing an event when an automatic cancel does not work because the order was already closed.
- Add a migration for the storage item. A lot easier now that it's a struct.
- Deduplicate the schedule or dispatch logic from the api's.

> Would it be easier to just add a new call and deprecate the old one?

That would still leave the main issue. It would work at not needing a migration, but I think it's much nicer to just use a struct. It's more standard and less prone to mistakes, like I almost made in this PR.
author_association: CONTRIBUTOR
user: login: dandanlen
created_at: 2025-05-12T11:36:45Z
body: LGTM but can we please add a limit as discussed [here](https://github.com/chainflip-io/chainflip-backend/pull/5836#discussion_r2084428951). Can be in this PR or in a follow-up. 

We also need to update documentation. 
author_association: COLLABORATOR