From 2986da7d06026644f29872be873f14d8c2568828 Mon Sep 17 00:00:00 2001 From: Ekleog-Polygon Date: Fri, 5 Jun 2026 12:52:41 +0200 Subject: [PATCH] fix(settlement): load persisted settlement attempts (#1552) Handle the settlement-attempt reload path that was forgotten in #1472 so #1312 is properly completed. This hydrates stored attempts and matching results when rebuilding a settlement task from storage. It also rejects duplicate attempt entries or orphaned result entries so recovery does not continue with an inconsistent settlement history. Tests cover successful hydration and orphaned result rejection. --- .../src/settlement_task.rs | 192 +++++++++++++++++- 1 file changed, 181 insertions(+), 11 deletions(-) diff --git a/crates/agglayer-settlement-service/src/settlement_task.rs b/crates/agglayer-settlement-service/src/settlement_task.rs index 29a9a51dd..a60943c69 100644 --- a/crates/agglayer-settlement-service/src/settlement_task.rs +++ b/crates/agglayer-settlement-service/src/settlement_task.rs @@ -765,9 +765,51 @@ impl eyre::Result<()> { - // TODO: Load all the settlement attempts related to self into self - // XREF: https://github.com/agglayer/agglayer/issues/1312 - todo!() + let mut results_by_attempt_number = BTreeMap::new(); + for (attempt_number, result) in self.store.list_settlement_attempt_results(&self.id)? { + let attempt_number = SettlementAttemptNumber(attempt_number); + if results_by_attempt_number + .insert(attempt_number, result) + .is_some() + { + eyre::bail!( + "Duplicate settlement attempt result {attempt_number} for job {}", + self.id, + ); + } + } + + let mut loaded_attempt_numbers = BTreeSet::new(); + let mut loaded_attempts: BTreeMap< + (Address, Nonce), + BTreeMap, + > = BTreeMap::new(); + for (attempt_number, attempt) in self.store.list_settlement_attempts(&self.id)? { + let attempt_number = SettlementAttemptNumber(attempt_number); + if !loaded_attempt_numbers.insert(attempt_number) { + eyre::bail!( + "Duplicate settlement attempt {attempt_number} for job {}", + self.id, + ); + } + + let result = results_by_attempt_number.remove(&attempt_number); + loaded_attempts + .entry((attempt.sender_wallet.into_alloy(), attempt.nonce)) + .or_default() + .insert(attempt_number, ActiveSettlementAttempt { attempt, result }); + } + + if let Some((attempt_number, _)) = results_by_attempt_number.first_key_value() { + eyre::bail!( + "Settlement attempt result {attempt_number} exists for job {} without a recorded \ + settlement attempt", + self.id, + ); + } + + self.attempts = loaded_attempts; + Ok(()) } async fn save_attempt_to_db( @@ -967,7 +1009,7 @@ impl SettlementJobId { + SettlementJobId::from(ulid::Ulid::from(seed)) + } + fn mk_control() -> TaskControl { let (admin_sender, admin_receiver) = mpsc::channel(1); let (_handle, control) = @@ -1017,7 +1063,7 @@ mod tests { } } - fn mk_attempt( + fn mk_active_attempt( wallet: Address, nonce: Nonce, hash: SettlementTxHash, @@ -1034,15 +1080,42 @@ mod tests { } } + fn mk_stored_attempt(seed: u8, sender_wallet: Address, nonce: Nonce) -> SettlementAttempt { + SettlementAttempt { + sender_wallet: sender_wallet.into(), + nonce, + hash: mk_tx_hash(seed), + submission_time: SystemTime::UNIX_EPOCH + Duration::from_secs(seed.into()), + } + } + + fn mk_client_error(seed: u8) -> SettlementAttemptResult { + SettlementAttemptResult::ClientError(ClientError { + kind: ClientErrorType::Unknown, + message: format!("client error {seed}"), + }) + } + fn mk_task( store: Arc, attempts: BTreeMap< (Address, Nonce), BTreeMap, >, + ) -> SettlementTask { + mk_task_with_id(mk_job_id(1), store, attempts) + } + + fn mk_task_with_id( + job_id: SettlementJobId, + store: Arc, + attempts: BTreeMap< + (Address, Nonce), + BTreeMap, + >, ) -> SettlementTask { SettlementTask { - id: SettlementJobId::from(ulid::Ulid::from(1_u128)), + id: job_id, job: mk_job(), tx_config: Arc::new(SettlementTransactionConfig::default()), provider: Arc::new(mk_provider()), @@ -1071,11 +1144,11 @@ mod tests { BTreeMap::from([ ( attempt_number, - mk_attempt(wallet, nonce, tx_result.tx_hash, None), + mk_active_attempt(wallet, nonce, tx_result.tx_hash, None), ), ( sibling_attempt_number, - mk_attempt(wallet, nonce, mk_tx_hash(20), None), + mk_active_attempt(wallet, nonce, mk_tx_hash(20), None), ), ]), ); @@ -1083,7 +1156,7 @@ mod tests { (other_wallet, other_nonce), BTreeMap::from([( other_attempt_number, - mk_attempt(other_wallet, other_nonce, mk_tx_hash(30), None), + mk_active_attempt(other_wallet, other_nonce, mk_tx_hash(30), None), )]), ); @@ -1149,7 +1222,7 @@ mod tests { BTreeMap::from([ ( attempt_number, - mk_attempt( + mk_active_attempt( wallet, nonce, tx_result.tx_hash, @@ -1161,7 +1234,7 @@ mod tests { ), ( sibling_attempt_number, - mk_attempt(wallet, nonce, mk_tx_hash(50), None), + mk_active_attempt(wallet, nonce, mk_tx_hash(50), None), ), ]), )]); @@ -1193,6 +1266,7 @@ mod tests { })) )); } + #[test] fn required_settlement_head_number_is_inclusive_of_receipt_block() { // Confirmations count the receipt block itself, and saturate rather than @@ -1209,4 +1283,100 @@ mod tests { ); } } + + #[tokio::test] + async fn load_settlement_attempts_from_db_hydrates_attempts_and_results() { + let job_id = mk_job_id(1); + let wallet = Address::repeat_byte(2); + let other_wallet = Address::repeat_byte(3); + let nonce = Nonce(7); + let other_nonce = Nonce(8); + let pending_attempt = mk_stored_attempt(1, wallet, nonce); + let completed_attempt = mk_stored_attempt(2, wallet, nonce); + let other_attempt = mk_stored_attempt(3, other_wallet, other_nonce); + let completed_result = mk_client_error(4); + + let attempts_for_store = vec![ + (1, pending_attempt.clone()), + (2, completed_attempt.clone()), + (3, other_attempt.clone()), + ]; + let completed_result_for_store = completed_result.clone(); + let mut store = MockStateStore::new(); + let expected_job_id = job_id; + store + .expect_list_settlement_attempt_results() + .once() + .withf(move |requested_job_id| requested_job_id == &expected_job_id) + .return_once(move |_| Ok(vec![(2, completed_result_for_store)])); + let expected_job_id = job_id; + store + .expect_list_settlement_attempts() + .once() + .withf(move |requested_job_id| requested_job_id == &expected_job_id) + .return_once(move |_| Ok(attempts_for_store)); + + let mut task = mk_task_with_id(job_id, Arc::new(store), BTreeMap::new()); + + task.load_settlement_attempts_from_db() + .await + .expect("stored attempts should hydrate"); + + let attempts_for_nonce = task + .attempts + .get(&(wallet, nonce)) + .expect("wallet nonce should be loaded"); + assert_eq!(attempts_for_nonce.len(), 2); + let loaded_pending = attempts_for_nonce + .get(&SettlementAttemptNumber(1)) + .expect("pending attempt should be loaded"); + assert_eq!(loaded_pending.attempt, pending_attempt); + assert_eq!(loaded_pending.result, None); + let loaded_completed = attempts_for_nonce + .get(&SettlementAttemptNumber(2)) + .expect("completed attempt should be loaded"); + assert_eq!(loaded_completed.attempt, completed_attempt); + assert_eq!(loaded_completed.result.as_ref(), Some(&completed_result)); + + let attempts_for_other_nonce = task + .attempts + .get(&(other_wallet, other_nonce)) + .expect("other wallet nonce should be loaded"); + let loaded_other = attempts_for_other_nonce + .get(&SettlementAttemptNumber(3)) + .expect("other attempt should be loaded"); + assert_eq!(loaded_other.attempt, other_attempt); + assert_eq!(loaded_other.result, None); + } + + #[tokio::test] + async fn load_settlement_attempts_from_db_rejects_result_without_attempt() { + let job_id = mk_job_id(2); + let result = mk_client_error(5); + let mut store = MockStateStore::new(); + let expected_job_id = job_id; + store + .expect_list_settlement_attempt_results() + .once() + .withf(move |requested_job_id| requested_job_id == &expected_job_id) + .return_once(move |_| Ok(vec![(7, result)])); + let expected_job_id = job_id; + store + .expect_list_settlement_attempts() + .once() + .withf(move |requested_job_id| requested_job_id == &expected_job_id) + .return_once(|_| Ok(Vec::new())); + + let mut task = mk_task_with_id(job_id, Arc::new(store), BTreeMap::new()); + + let error = task + .load_settlement_attempts_from_db() + .await + .expect_err("orphaned attempt result should fail hydration"); + + assert!(error + .to_string() + .contains("without a recorded settlement attempt")); + assert!(task.attempts.is_empty()); + } }