From d5058f86828dba9c60edf6481bebecf879fdaa52 Mon Sep 17 00:00:00 2001 From: Ekleog-Polygon Date: Thu, 4 Jun 2026 13:10:26 +0200 Subject: [PATCH] feat(settlement): persist terminal result metadata (#1549) Persist terminal settlement job results with the finalized wallet, nonce, and attempt number. Record attempt-level terminal outcomes and allow client errors to be replaced by stronger final evidence. Fixes https://github.com/agglayer/agglayer/issues/1317 --- .../src/settlement_service.rs | 23 +- .../src/settlement_task.rs | 513 +++++++++++++++--- .../interfaces/writer/settlement_writer.rs | 16 +- .../src/stores/state/settlement/mod.rs | 36 +- .../src/stores/state/tests/settlement.rs | 140 +++-- .../src/tests/mocks/state_store.rs | 2 +- .../types/generated/agglayer.storage.v0.rs | 15 +- .../types/settlement/compat/client_error.rs | 4 + .../src/types/settlement/compat/tx_result.rs | 43 +- ...esult__tests__codec_encoding_snapshot.snap | 3 +- .../agglayer-storage/src/types/testutils.rs | 14 +- crates/agglayer-types/src/settlement.rs | 34 +- proto/agglayer/storage/v0/settlement.proto | 14 +- 13 files changed, 686 insertions(+), 171 deletions(-) diff --git a/crates/agglayer-settlement-service/src/settlement_service.rs b/crates/agglayer-settlement-service/src/settlement_service.rs index bced3fcfe..9931f7665 100644 --- a/crates/agglayer-settlement-service/src/settlement_service.rs +++ b/crates/agglayer-settlement-service/src/settlement_service.rs @@ -297,8 +297,8 @@ mod tests { use agglayer_storage::tests::mocks::MockStateStore; use agglayer_types::{ - ContractCallOutcome, ContractCallResult, Digest, SettlementJobId, SettlementJobResult, - SettlementTxHash, B256, + ContractCallOutcome, ContractCallResult, Digest, Nonce, SettlementAttemptNumber, + SettlementJobId, SettlementJobResult, SettlementTxHash, B256, }; use alloy::providers::ProviderBuilder; @@ -331,13 +331,18 @@ mod tests { } fn mk_result(seed: u8, outcome: ContractCallOutcome) -> SettlementJobResult { - SettlementJobResult::ContractCall(ContractCallResult { - outcome, - metadata: vec![seed, seed.wrapping_add(1)].into(), - block_hash: B256::from([seed; 32]), - block_number: seed as u64, - tx_hash: SettlementTxHash::new(Digest::from([seed.wrapping_add(2); 32])), - }) + SettlementJobResult { + wallet: agglayer_types::Address::from([seed.wrapping_add(3); 20]), + nonce: Nonce(seed as u64 + 200), + attempt_number: SettlementAttemptNumber(seed as u64 + 300), + contract_call_result: ContractCallResult { + outcome, + metadata: vec![seed, seed.wrapping_add(1)].into(), + block_hash: B256::from([seed; 32]), + block_number: seed as u64, + tx_hash: SettlementTxHash::new(Digest::from([seed.wrapping_add(2); 32])), + }, + } } #[tokio::test] diff --git a/crates/agglayer-settlement-service/src/settlement_task.rs b/crates/agglayer-settlement-service/src/settlement_task.rs index aa37c169b..29a9a51dd 100644 --- a/crates/agglayer-settlement-service/src/settlement_task.rs +++ b/crates/agglayer-settlement-service/src/settlement_task.rs @@ -286,16 +286,10 @@ impl Vec<(Address, Nonce, SettlementAttemptNumber)> { + self.attempts + .iter() + .flat_map(|(&(wallet, nonce), attempts_for_nonce)| { + attempts_for_nonce + .keys() + .copied() + .map(move |attempt_number| (wallet, nonce, attempt_number)) + }) + .collect() + } + fn is_any_attempt_pending_for_nonce(&self, wallet: Address, nonce: Nonce) -> bool { self.attempts .get(&(wallet, nonce)) @@ -495,6 +512,27 @@ impl Vec { + self.attempts + .get(&(wallet, nonce)) + .map(|attempts_for_nonce| attempts_for_nonce.keys().copied().collect()) + .unwrap_or_default() + } + + fn attempt_key_for_attempt_number( + &self, + attempt_number: SettlementAttemptNumber, + ) -> Option<(Address, Nonce)> { + self.attempts + .iter() + .find(|(_, attempts_for_nonce)| attempts_for_nonce.contains_key(&attempt_number)) + .map(|(key, _)| *key) + } + fn is_wallet_privkey_known(&self, _wallet: Address) -> bool { // TODO: tie with the configuration todo!() @@ -745,69 +783,416 @@ impl, + ) -> usize { + let mut recorded_attempt_count = 0; + + for attempt_number in self.attempt_numbers_for_nonce(wallet, nonce) { + if Some(attempt_number) == excluded_attempt_number { + continue; + } + + self.record_attempt_result_to_db( + attempt_number, + SettlementAttemptResult::ClientError(ClientError::nonce_already_used( + wallet.into(), + nonce, + tx_hash, + )), + ); + recorded_attempt_count += 1; + } + + recorded_attempt_count } - async fn write_job_revert_to_db(&self, _result: &ContractCallResult) { - // TODO: Record a settlement job as reverted to db, with the given result. All - // attempts should already have been marked as nonce already used or revert, so - // no need to update them, but maybe run a sanity-check. - // XREF: https://github.com/agglayer/agglayer/issues/1317 - todo!() + async fn write_job_result_to_db( + &mut self, + wallet: Address, + nonce: Nonce, + attempt_number: SettlementAttemptNumber, + tx_result: ContractCallResult, + ) -> SettlementJobResult { + // TODO: Handle interrupted completion writes in the resumption path. + // Attempt results are persisted before the terminal job result below; if + // the process stops in between, loading the pending job must resume these + // writes before considering any new settlement submission. + self.record_attempt_result_to_db( + attempt_number, + SettlementAttemptResult::ContractCall(tx_result.clone()), + ); + + if tx_result.outcome == ContractCallOutcome::Success { + self.record_nonce_already_used_attempts_to_db( + wallet, + nonce, + tx_result.tx_hash, + Some(attempt_number), + ); + + for (attempt_wallet, attempt_nonce, other_attempt_number) in self.all_attempt_keys() { + if attempt_wallet == wallet && attempt_nonce == nonce { + continue; + } + + self.record_attempt_result_to_db( + other_attempt_number, + SettlementAttemptResult::ClientError( + ClientError::settlement_succeeded_elsewhere(tx_result.tx_hash), + ), + ); + } + } + + let job_result = SettlementJobResult { + wallet: wallet.into(), + nonce, + attempt_number, + contract_call_result: tx_result, + }; + + self.store + .insert_settlement_job_result(&self.id, &job_result) + .unwrap_or_else(|error| { + panic!( + "Failed to write settlement job result for job {}: {error:?}", + self.id + ) + }); + + job_result + } + + fn record_attempt_result_to_db( + &mut self, + attempt_number: SettlementAttemptNumber, + result: SettlementAttemptResult, + ) { + let Some((wallet, nonce)) = self.attempt_key_for_attempt_number(attempt_number) else { + panic!( + "Settlement task {} tried to record a result for unknown attempt {}", + self.id, attempt_number + ); + }; + + let active_attempt = self + .attempts + .get_mut(&(wallet, nonce)) + .and_then(|attempts_for_nonce| attempts_for_nonce.get_mut(&attempt_number)) + .expect("attempt existence was checked before storage write"); + + if let Some(current_result) = active_attempt.result.as_ref() { + if current_result == &result { + return; + } + + if !current_result.can_be_replaced_by(&result) { + panic!( + "Settlement task {} tried to replace conflicting result for attempt {}", + self.id, attempt_number + ); + } + } + + self.store + .record_settlement_attempt_result(&self.id, attempt_number.0, &result) + .unwrap_or_else(|error| { + panic!( + "Failed to write settlement attempt result for job {} attempt {}: {error:?}", + self.id, attempt_number + ) + }); + + active_attempt.result = Some(result); } } #[cfg(test)] mod tests { + use std::collections::BTreeMap; + + use agglayer_storage::tests::mocks::MockStateStore; + use agglayer_types::{ + ClientError, ClientErrorType, ContractCallOutcome, Digest, SettlementAttemptResult, B256, + U256, + }; + use alloy::providers::ProviderBuilder; + use tokio::sync::mpsc; + use super::*; + fn mk_provider() -> impl Provider + 'static { + ProviderBuilder::new().connect_http( + "http://127.0.0.1:0" + .parse() + .expect("test provider URL should parse"), + ) + } + + fn mk_control() -> TaskControl { + let (admin_sender, admin_receiver) = mpsc::channel(1); + let (_handle, control) = + TaskControlHandle::new(&CancellationToken::new(), admin_sender, admin_receiver); + control + } + + fn mk_job() -> SettlementJob { + SettlementJob { + contract_address: agglayer_types::Address::from([1; 20]), + calldata: vec![2, 3].into(), + eth_value: U256::from(0), + gas_limit: 100_000, + } + } + + fn mk_tx_hash(seed: u8) -> SettlementTxHash { + SettlementTxHash::new(Digest::from([seed; 32])) + } + + fn mk_contract_call_result(seed: u8, outcome: ContractCallOutcome) -> ContractCallResult { + ContractCallResult { + outcome, + metadata: vec![seed, seed.wrapping_add(1)].into(), + block_hash: B256::from([seed.wrapping_add(2); 32]), + block_number: seed as u64, + tx_hash: mk_tx_hash(seed.wrapping_add(3)), + } + } + + fn mk_attempt( + wallet: Address, + nonce: Nonce, + hash: SettlementTxHash, + result: Option, + ) -> ActiveSettlementAttempt { + ActiveSettlementAttempt { + attempt: SettlementAttempt { + sender_wallet: wallet.into(), + nonce, + hash, + submission_time: SystemTime::UNIX_EPOCH, + }, + result, + } + } + + fn mk_task( + store: Arc, + attempts: BTreeMap< + (Address, Nonce), + BTreeMap, + >, + ) -> SettlementTask { + SettlementTask { + id: SettlementJobId::from(ulid::Ulid::from(1_u128)), + job: mk_job(), + tx_config: Arc::new(SettlementTransactionConfig::default()), + provider: Arc::new(mk_provider()), + store, + control: mk_control(), + attempts, + } + } + + #[tokio::test] + async fn write_job_result_records_success_and_marks_other_attempts() { + let wallet = Address::from([1; 20]); + let other_wallet = Address::from([2; 20]); + let nonce = Nonce(7); + let other_nonce = Nonce(8); + let attempt_number = SettlementAttemptNumber(1); + let sibling_attempt_number = SettlementAttemptNumber(2); + let other_attempt_number = SettlementAttemptNumber(3); + let tx_result = mk_contract_call_result(10, ContractCallOutcome::Success); + let expected_wallet: agglayer_types::Address = wallet.into(); + let expected_tx_result = tx_result.clone(); + + let mut attempts = BTreeMap::new(); + attempts.insert( + (wallet, nonce), + BTreeMap::from([ + ( + attempt_number, + mk_attempt(wallet, nonce, tx_result.tx_hash, None), + ), + ( + sibling_attempt_number, + mk_attempt(wallet, nonce, mk_tx_hash(20), None), + ), + ]), + ); + attempts.insert( + (other_wallet, other_nonce), + BTreeMap::from([( + other_attempt_number, + mk_attempt(other_wallet, other_nonce, mk_tx_hash(30), None), + )]), + ); + + let mut store = MockStateStore::new(); + store + .expect_record_settlement_attempt_result() + .times(3) + .returning(|_, _, _| Ok(())); + store + .expect_insert_settlement_job_result() + .once() + .withf(move |_, result| { + result.wallet == expected_wallet + && result.nonce == nonce + && result.attempt_number == attempt_number + && result.contract_call_result == expected_tx_result + }) + .returning(|_, _| Ok(())); + + let mut task = mk_task(Arc::new(store), attempts); + + let job_result = task + .write_job_result_to_db(wallet, nonce, attempt_number, tx_result.clone()) + .await; + + assert_eq!(job_result.contract_call_result, tx_result); + assert!(matches!( + task.attempts[&(wallet, nonce)][&attempt_number] + .result + .as_ref(), + Some(SettlementAttemptResult::ContractCall(_)) + )); + assert!(matches!( + task.attempts[&(wallet, nonce)][&sibling_attempt_number] + .result + .as_ref(), + Some(SettlementAttemptResult::ClientError(ClientError { + kind: ClientErrorType::NonceAlreadyUsed, + .. + })) + )); + assert!(matches!( + task.attempts[&(other_wallet, other_nonce)][&other_attempt_number] + .result + .as_ref(), + Some(SettlementAttemptResult::ClientError(ClientError { + kind: ClientErrorType::SettlementSucceededElsewhere, + .. + })) + )); + } + + #[tokio::test] + async fn write_nonce_revert_replaces_previous_client_error_for_finalized_attempt() { + let wallet = Address::from([3; 20]); + let nonce = Nonce(9); + let attempt_number = SettlementAttemptNumber(1); + let sibling_attempt_number = SettlementAttemptNumber(2); + let tx_result = mk_contract_call_result(40, ContractCallOutcome::Revert); + + let attempts = BTreeMap::from([( + (wallet, nonce), + BTreeMap::from([ + ( + attempt_number, + mk_attempt( + wallet, + nonce, + tx_result.tx_hash, + Some(SettlementAttemptResult::ClientError(ClientError { + kind: ClientErrorType::Unknown, + message: "submission failed".to_string(), + })), + ), + ), + ( + sibling_attempt_number, + mk_attempt(wallet, nonce, mk_tx_hash(50), None), + ), + ]), + )]); + + let mut store = MockStateStore::new(); + store + .expect_record_settlement_attempt_result() + .times(2) + .returning(|_, _, _| Ok(())); + + let mut task = mk_task(Arc::new(store), attempts); + + task.write_nonce_revert_to_db(wallet, nonce, attempt_number, tx_result.clone()) + .await; + + assert_eq!( + task.attempts[&(wallet, nonce)][&attempt_number] + .result + .as_ref(), + Some(&SettlementAttemptResult::ContractCall(tx_result)) + ); + assert!(matches!( + task.attempts[&(wallet, nonce)][&sibling_attempt_number] + .result + .as_ref(), + Some(SettlementAttemptResult::ClientError(ClientError { + kind: ClientErrorType::NonceAlreadyUsed, + .. + })) + )); + } #[test] fn required_settlement_head_number_is_inclusive_of_receipt_block() { // Confirmations count the receipt block itself, and saturate rather than diff --git a/crates/agglayer-storage/src/stores/interfaces/writer/settlement_writer.rs b/crates/agglayer-storage/src/stores/interfaces/writer/settlement_writer.rs index 13849d5c3..4a741f784 100644 --- a/crates/agglayer-storage/src/stores/interfaces/writer/settlement_writer.rs +++ b/crates/agglayer-storage/src/stores/interfaces/writer/settlement_writer.rs @@ -6,8 +6,9 @@ use crate::error::Error; /// Write access to settlement-related records stored in RocksDB. /// -/// All write operations in this trait are insert-only: implementations must -/// reject attempts to overwrite an existing key. +/// Settlement job and attempt writes are insert-only. Settlement attempt +/// results may be upgraded by `record_settlement_attempt_result` when stronger +/// final evidence supersedes a previous client-side error. pub trait SettlementWriter: Send + Sync { /// Inserts a settlement job under `settlement_job_id`. /// @@ -42,12 +43,13 @@ pub trait SettlementWriter: Send + Sync { settlement_attempt: &SettlementAttempt, ) -> Result<(), Error>; - /// Inserts a settlement attempt result under - /// `(settlement_job_id, attempt_sequence_number)`. + /// Records a settlement attempt result under `(settlement_job_id, + /// attempt_sequence_number)`. /// - /// This is an insert-only operation and must fail if that composite key - /// already exists. The corresponding settlement attempt must already exist. - fn insert_settlement_attempt_result( + /// This inserts missing results, accepts idempotent re-recording, and + /// allows a previous client error to be replaced by stronger final + /// nonce/on-chain evidence. Other conflicting updates must fail. + fn record_settlement_attempt_result( &self, settlement_job_id: &SettlementJobId, attempt_sequence_number: u64, diff --git a/crates/agglayer-storage/src/stores/state/settlement/mod.rs b/crates/agglayer-storage/src/stores/state/settlement/mod.rs index 3a962a6ab..8915ab411 100644 --- a/crates/agglayer-storage/src/stores/state/settlement/mod.rs +++ b/crates/agglayer-storage/src/stores/state/settlement/mod.rs @@ -225,13 +225,13 @@ impl SettlementWriter for StateStore { }) } - fn insert_settlement_attempt_result( + fn record_settlement_attempt_result( &self, settlement_job_id: &SettlementJobId, attempt_sequence_number: u64, tx_result: &SettlementAttemptResult, ) -> Result<(), Error> { - let tx_result: v0::SettlementAttemptResult = tx_result.into(); + let proto_tx_result: v0::SettlementAttemptResult = tx_result.into(); self.with_settlement_write_lock(settlement_job_id, || { let key = SettlementAttemptKey { @@ -239,17 +239,6 @@ impl SettlementWriter for StateStore { attempt_sequence_number, }; - if self - .db - .get::(&key)? - .is_some() - { - return Err(Error::UnprocessedAction(format!( - "Settlement attempt result already exists for job {settlement_job_id} and \ - attempt sequence number {attempt_sequence_number}" - ))); - } - if self.db.get::(&key)?.is_none() { return Err(Error::UnprocessedAction(format!( "Settlement attempt does not exist for job {settlement_job_id} and attempt \ @@ -257,9 +246,28 @@ impl SettlementWriter for StateStore { ))); } + if let Some(stored_result) = self + .db + .get::(&key)? + .map(SettlementAttemptResult::try_from) + .transpose()? + { + if stored_result == *tx_result { + return Ok(()); + } + + if !stored_result.can_be_replaced_by(tx_result) { + return Err(Error::UnprocessedAction(format!( + "Cannot replace existing settlement attempt result {stored_result:?} with \ + new settlement attempt result {tx_result:?} for job {settlement_job_id} \ + and attempt sequence number {attempt_sequence_number}", + ))); + } + } + Ok(self .db - .put::(&key, &tx_result)?) + .put::(&key, &proto_tx_result)?) }) } } diff --git a/crates/agglayer-storage/src/stores/state/tests/settlement.rs b/crates/agglayer-storage/src/stores/state/tests/settlement.rs index d31d64935..3c5de8235 100644 --- a/crates/agglayer-storage/src/stores/state/tests/settlement.rs +++ b/crates/agglayer-storage/src/stores/state/tests/settlement.rs @@ -4,8 +4,8 @@ use std::{ }; use agglayer_types::{ - Address, Digest, Nonce, SettlementAttempt, SettlementJob, SettlementJobId, SettlementTxHash, - U256, + Address, ClientError, ClientErrorType, Digest, Nonce, SettlementAttempt, + SettlementAttemptResult, SettlementJob, SettlementJobId, SettlementTxHash, U256, }; use crate::{ @@ -129,7 +129,7 @@ fn insert_settlement_attempt_without_job_fails() { } #[test] -fn insert_settlement_attempt_result_succeeds_once() { +fn record_settlement_attempt_result_succeeds_once() { let (_tmp, _db, store) = setup_store(); let job_id = mk_job_id(5); store @@ -139,7 +139,7 @@ fn insert_settlement_attempt_result_succeeds_once() { .insert_settlement_attempt(&job_id, 1, &mk_settlement_attempt(1)) .expect("attempt insert must succeed"); assert!(store - .insert_settlement_attempt_result( + .record_settlement_attempt_result( &job_id, 1, &v0::SettlementAttemptResult::contract_call_success_for_test(1) @@ -150,15 +150,12 @@ fn insert_settlement_attempt_result_succeeds_once() { } #[test] -fn insert_settlement_attempt_result_duplicate_fails() { +fn record_settlement_attempt_result_idempotent_record_succeeds() { let (_tmp, db, store) = setup_store(); let job_id = mk_job_id(6); let first = v0::SettlementAttemptResult::contract_call_success_for_test(1) .try_into() .expect("test tx result helper should be decodable"); - let second = v0::SettlementAttemptResult::contract_call_success_for_test(2) - .try_into() - .expect("test tx result helper should be decodable"); store .insert_settlement_job(&job_id, &mk_settlement_job(6)) .expect("job insert must succeed"); @@ -166,10 +163,11 @@ fn insert_settlement_attempt_result_duplicate_fails() { .insert_settlement_attempt(&job_id, 1, &mk_settlement_attempt(1)) .expect("attempt insert must succeed"); store - .insert_settlement_attempt_result(&job_id, 1, &first) + .record_settlement_attempt_result(&job_id, 1, &first) .expect("first insert must succeed"); - let res = store.insert_settlement_attempt_result(&job_id, 1, &second); - assert!(matches!(res, Err(Error::UnprocessedAction(_)))); + store + .record_settlement_attempt_result(&job_id, 1, &first) + .expect("idempotent record must succeed"); assert_eq!( db.get::(&SettlementAttemptKey { settlement_job_id: job_id, @@ -181,14 +179,14 @@ fn insert_settlement_attempt_result_duplicate_fails() { } #[test] -fn insert_settlement_attempt_result_without_attempt_fails() { +fn record_settlement_attempt_result_without_attempt_fails() { let (_tmp, _db, store) = setup_store(); let job_id = mk_job_id(405); store .insert_settlement_job(&job_id, &mk_settlement_job(42)) .expect("job insert must succeed"); - let res = store.insert_settlement_attempt_result( + let res = store.record_settlement_attempt_result( &job_id, 1, &v0::SettlementAttemptResult::contract_call_success_for_test(1) @@ -198,6 +196,77 @@ fn insert_settlement_attempt_result_without_attempt_fails() { assert!(matches!(res, Err(Error::UnprocessedAction(_)))); } +#[test] +fn record_settlement_attempt_result_replaces_client_error_with_contract_call() { + let (_tmp, db, store) = setup_store(); + let job_id = mk_job_id(24); + let attempt = mk_settlement_attempt(1); + let client_error = SettlementAttemptResult::ClientError(ClientError { + kind: ClientErrorType::Unknown, + message: "submit failed".to_string(), + }); + let contract_call_result = v0::SettlementAttemptResult::contract_call_success_for_test(24) + .try_into() + .expect("test tx result helper should be decodable"); + + store + .insert_settlement_job(&job_id, &mk_settlement_job(24)) + .expect("job insert must succeed"); + store + .insert_settlement_attempt(&job_id, 1, &attempt) + .expect("attempt insert must succeed"); + store + .record_settlement_attempt_result(&job_id, 1, &client_error) + .expect("client error insert must succeed"); + store + .record_settlement_attempt_result(&job_id, 1, &contract_call_result) + .expect("contract call should replace client error"); + + assert_eq!( + db.get::(&SettlementAttemptKey { + settlement_job_id: job_id, + attempt_sequence_number: 1, + }) + .expect("attempt result read must succeed"), + Some((&contract_call_result).into()) + ); +} + +#[test] +fn record_settlement_attempt_result_rejects_conflicting_contract_calls() { + let (_tmp, db, store) = setup_store(); + let job_id = mk_job_id(25); + let attempt = mk_settlement_attempt(1); + let first = v0::SettlementAttemptResult::contract_call_success_for_test(25) + .try_into() + .expect("test tx result helper should be decodable"); + let second = v0::SettlementAttemptResult::contract_call_success_for_test(26) + .try_into() + .expect("test tx result helper should be decodable"); + + store + .insert_settlement_job(&job_id, &mk_settlement_job(25)) + .expect("job insert must succeed"); + store + .insert_settlement_attempt(&job_id, 1, &attempt) + .expect("attempt insert must succeed"); + store + .record_settlement_attempt_result(&job_id, 1, &first) + .expect("first result insert must succeed"); + + let res = store.record_settlement_attempt_result(&job_id, 1, &second); + assert!(matches!(res, Err(Error::UnprocessedAction(_)))); + + assert_eq!( + db.get::(&SettlementAttemptKey { + settlement_job_id: job_id, + attempt_sequence_number: 1, + }) + .expect("attempt result read must succeed"), + Some((&first).into()) + ); +} + #[test] fn insert_settlement_attempt_indexes_by_wallet_and_nonce() { let (_tmp, db, store) = setup_store(); @@ -405,10 +474,10 @@ fn list_settlement_attempt_results_returns_all_results_for_job() { .insert_settlement_attempt(&job_id, 2, &second_attempt) .expect("second attempt insert must succeed"); store - .insert_settlement_attempt_result(&job_id, 1, &first_result) + .record_settlement_attempt_result(&job_id, 1, &first_result) .expect("first result insert must succeed"); store - .insert_settlement_attempt_result(&job_id, 2, &second_result) + .record_settlement_attempt_result(&job_id, 2, &second_result) .expect("second result insert must succeed"); assert_eq!( @@ -447,10 +516,10 @@ fn list_settlement_attempt_results_does_not_return_results_from_other_jobs() { .insert_settlement_attempt(&job_id, 2, &mk_settlement_attempt(2)) .expect("second attempt insert must succeed"); store - .insert_settlement_attempt_result(&job_id, 1, &first_result) + .record_settlement_attempt_result(&job_id, 1, &first_result) .expect("first result insert must succeed"); store - .insert_settlement_attempt_result( + .record_settlement_attempt_result( &other_job_id, 1, &v0::SettlementAttemptResult::contract_call_success_for_test(10) @@ -459,7 +528,7 @@ fn list_settlement_attempt_results_does_not_return_results_from_other_jobs() { ) .expect("other job result insert must succeed"); store - .insert_settlement_attempt_result(&job_id, 2, &second_result) + .record_settlement_attempt_result(&job_id, 2, &second_result) .expect("second result insert must succeed"); assert_eq!( @@ -518,7 +587,7 @@ fn job_attempt_result_can_be_read_back_together() { .insert_settlement_attempt(&job_id, 5, &attempt) .expect("insert must succeed"); store - .insert_settlement_attempt_result(&job_id, 5, &attempt_result) + .record_settlement_attempt_result(&job_id, 5, &attempt_result) .expect("insert must succeed"); store .insert_settlement_job_result(&job_id, &job_result) @@ -576,36 +645,3 @@ fn result_absent_does_not_imply_attempt_absent() { None ); } - -#[test] -fn duplicate_insert_preserves_original_value() { - let (_tmp, db, store) = setup_store(); - let job_id = mk_job_id(23); - let first = v0::SettlementAttemptResult::contract_call_success_for_test(1) - .try_into() - .expect("test tx result helper should be decodable"); - let second = v0::SettlementAttemptResult::contract_call_success_for_test(2) - .try_into() - .expect("test tx result helper should be decodable"); - store - .insert_settlement_job(&job_id, &mk_settlement_job(23)) - .expect("job insert must succeed"); - store - .insert_settlement_attempt(&job_id, 9, &mk_settlement_attempt(9)) - .expect("attempt insert must succeed"); - - store - .insert_settlement_attempt_result(&job_id, 9, &first) - .expect("first insert must succeed"); - let res = store.insert_settlement_attempt_result(&job_id, 9, &second); - assert!(matches!(res, Err(Error::UnprocessedAction(_)))); - - assert_eq!( - db.get::(&SettlementAttemptKey { - settlement_job_id: job_id, - attempt_sequence_number: 9, - }) - .expect("attempt result read must succeed"), - Some((&first).into()) - ); -} diff --git a/crates/agglayer-storage/src/tests/mocks/state_store.rs b/crates/agglayer-storage/src/tests/mocks/state_store.rs index e3d41de18..8351d84b8 100644 --- a/crates/agglayer-storage/src/tests/mocks/state_store.rs +++ b/crates/agglayer-storage/src/tests/mocks/state_store.rs @@ -161,7 +161,7 @@ mock! { settlement_attempt: &SettlementAttempt, ) -> Result<(), Error>; - fn insert_settlement_attempt_result( + fn record_settlement_attempt_result( &self, settlement_job_id: &SettlementJobId, attempt_sequence_number: u64, diff --git a/crates/agglayer-storage/src/types/generated/agglayer.storage.v0.rs b/crates/agglayer-storage/src/types/generated/agglayer.storage.v0.rs index 4da108ebe..4dc048f85 100644 --- a/crates/agglayer-storage/src/types/generated/agglayer.storage.v0.rs +++ b/crates/agglayer-storage/src/types/generated/agglayer.storage.v0.rs @@ -843,8 +843,17 @@ pub mod settlement_attempt_result { /// Terminal result of a settlement job. #[derive(Clone, PartialEq, ::prost::Message)] pub struct SettlementJobResult { - /// Result of the final on-chain contract call for the job. + /// Sender wallet of the finalized settlement transaction. #[prost(message, optional, tag="1")] + pub wallet: ::core::option::Option
, + /// Nonce of the finalized settlement transaction. + #[prost(message, optional, tag="2")] + pub nonce: ::core::option::Option, + /// Sequence number of the finalized settlement attempt. + #[prost(message, optional, tag="3")] + pub attempt_number: ::core::option::Option, + /// Result of the final on-chain contract call for the job. + #[prost(message, optional, tag="4")] pub contract_call_result: ::core::option::Option, } /// Error encountered while attempting to submit the transaction, that didn't lead to an on-chain result. @@ -922,6 +931,8 @@ pub enum ClientErrorType { Unspecified = 0, /// Nonce already used and finalized in other tx. NonceAlreadyUsed = 1, + /// Settlement succeeded in another tx. + SettlementSucceededElsewhere = 2, } impl ClientErrorType { /// String value of the enum field names used in the ProtoBuf definition. @@ -932,6 +943,7 @@ impl ClientErrorType { match self { Self::Unspecified => "CLIENT_ERROR_TYPE_UNSPECIFIED", Self::NonceAlreadyUsed => "CLIENT_ERROR_TYPE_NONCE_ALREADY_USED", + Self::SettlementSucceededElsewhere => "CLIENT_ERROR_TYPE_SETTLEMENT_SUCCEEDED_ELSEWHERE", } } /// Creates an enum from field names used in the ProtoBuf definition. @@ -939,6 +951,7 @@ impl ClientErrorType { match value { "CLIENT_ERROR_TYPE_UNSPECIFIED" => Some(Self::Unspecified), "CLIENT_ERROR_TYPE_NONCE_ALREADY_USED" => Some(Self::NonceAlreadyUsed), + "CLIENT_ERROR_TYPE_SETTLEMENT_SUCCEEDED_ELSEWHERE" => Some(Self::SettlementSucceededElsewhere), _ => None, } } diff --git a/crates/agglayer-storage/src/types/settlement/compat/client_error.rs b/crates/agglayer-storage/src/types/settlement/compat/client_error.rs index 38540d0a9..994e30538 100644 --- a/crates/agglayer-storage/src/types/settlement/compat/client_error.rs +++ b/crates/agglayer-storage/src/types/settlement/compat/client_error.rs @@ -8,6 +8,7 @@ impl From<&ClientErrorType> for v0::ClientErrorType { match value { ClientErrorType::Unknown => Self::Unspecified, ClientErrorType::NonceAlreadyUsed => Self::NonceAlreadyUsed, + ClientErrorType::SettlementSucceededElsewhere => Self::SettlementSucceededElsewhere, } } } @@ -17,6 +18,9 @@ impl From for ClientErrorType { match value { v0::ClientErrorType::Unspecified => ClientErrorType::Unknown, v0::ClientErrorType::NonceAlreadyUsed => ClientErrorType::NonceAlreadyUsed, + v0::ClientErrorType::SettlementSucceededElsewhere => { + ClientErrorType::SettlementSucceededElsewhere + } } } } diff --git a/crates/agglayer-storage/src/types/settlement/compat/tx_result.rs b/crates/agglayer-storage/src/types/settlement/compat/tx_result.rs index e7fd905b0..1d3596f78 100644 --- a/crates/agglayer-storage/src/types/settlement/compat/tx_result.rs +++ b/crates/agglayer-storage/src/types/settlement/compat/tx_result.rs @@ -50,10 +50,11 @@ impl TryFrom for SettlementAttemptResult { impl From<&SettlementJobResult> for v0::SettlementJobResult { fn from(value: &SettlementJobResult) -> Self { - match value { - SettlementJobResult::ContractCall(contract_call_result) => Self { - contract_call_result: Some(contract_call_result.into()), - }, + Self { + wallet: Some(value.wallet.into()), + nonce: Some(value.nonce.into()), + attempt_number: Some(value.attempt_number.into()), + contract_call_result: Some((&value.contract_call_result).into()), } } } @@ -62,19 +63,24 @@ impl TryFrom for SettlementJobResult { type Error = Error; fn try_from(value: v0::SettlementJobResult) -> Result { - Ok(SettlementJobResult::ContractCall( - value - .contract_call_result - .ok_or_else(|| Error::missing_field("contract_call_result"))? - .try_into()?, - )) + Ok(SettlementJobResult { + wallet: required_field!(value, wallet => try_into::), + nonce: required_field!(value, nonce => into::), + attempt_number: required_field!(value, attempt_number => + into:: + ), + contract_call_result: required_field!(value, contract_call_result => + try_into:: + ), + }) } } #[cfg(test)] mod tests { use agglayer_types::{ - ClientError, ContractCallOutcome, ContractCallResult, Digest, Nonce, SettlementTxHash, B256, + ClientError, ContractCallOutcome, ContractCallResult, Digest, Nonce, + SettlementAttemptNumber, SettlementTxHash, B256, }; use super::*; @@ -133,6 +139,9 @@ mod tests { #[test] fn missing_contract_call_fails_for_job_result() { let proto = v0::SettlementJobResult { + wallet: Some(agglayer_types::Address::from([1_u8; 20]).into()), + nonce: Some(Nonce(7).into()), + attempt_number: Some(SettlementAttemptNumber(8).into()), contract_call_result: None, }; @@ -142,14 +151,16 @@ mod tests { #[test] fn job_result_round_trip_contract_call() { - let job_result = SettlementJobResult::ContractCall(sample_contract_call_result()); + let job_result = SettlementJobResult { + wallet: agglayer_types::Address::from([9_u8; 20]), + nonce: Nonce(7), + attempt_number: SettlementAttemptNumber(8), + contract_call_result: sample_contract_call_result(), + }; let proto: v0::SettlementJobResult = (&job_result).into(); let decoded = SettlementJobResult::try_from(proto).unwrap(); - assert_eq!( - decoded, - SettlementJobResult::ContractCall(sample_contract_call_result()) - ); + assert_eq!(decoded, job_result); } } diff --git a/crates/agglayer-storage/src/types/settlement/snapshots/agglayer_storage__types__settlement__job_result__tests__codec_encoding_snapshot.snap b/crates/agglayer-storage/src/types/settlement/snapshots/agglayer_storage__types__settlement__job_result__tests__codec_encoding_snapshot.snap index 63263b1f0..3b1224751 100644 --- a/crates/agglayer-storage/src/types/settlement/snapshots/agglayer_storage__types__settlement__job_result__tests__codec_encoding_snapshot.snap +++ b/crates/agglayer-storage/src/types/settlement/snapshots/agglayer_storage__types__settlement__job_result__tests__codec_encoding_snapshot.snap @@ -1,6 +1,7 @@ --- source: crates/agglayer-storage/src/types/settlement/job_result.rs +assertion_line: 52 expression: encoded_hex snapshot_kind: text --- -0a54080112040a0217181a220a2017171717171717171717171717171717171717171717171717171717171717172202087b2a220a201919191919191919191919191919191919191919191919191919191919191919 +0a160a141a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a120308df011a0308c3022254080112040a0217181a220a2017171717171717171717171717171717171717171717171717171717171717172202087b2a220a201919191919191919191919191919191919191919191919191919191919191919 diff --git a/crates/agglayer-storage/src/types/testutils.rs b/crates/agglayer-storage/src/types/testutils.rs index f1181c263..d9b9d6288 100644 --- a/crates/agglayer-storage/src/types/testutils.rs +++ b/crates/agglayer-storage/src/types/testutils.rs @@ -1,6 +1,7 @@ use super::generated::agglayer::storage::v0::{ - settlement_attempt_result, BlockHash, BlockNumber, ContractCallMetadata, ContractCallOutcome, - ContractCallResult, SettlementAttemptResult, SettlementJobResult, TxHash, + settlement_attempt_result, Address, AttemptSequenceNumber, BlockHash, BlockNumber, + ContractCallMetadata, ContractCallOutcome, ContractCallResult, Nonce, SettlementAttemptResult, + SettlementJobResult, TxHash, }; fn contract_call_success_for_test(seed: u8) -> ContractCallResult { @@ -34,6 +35,15 @@ impl SettlementAttemptResult { impl SettlementJobResult { pub fn contract_call_success_for_test(seed: u8) -> Self { Self { + wallet: Some(Address { + address: vec![seed.wrapping_add(3); 20].into(), + }), + nonce: Some(Nonce { + nonce: seed as u64 + 200, + }), + attempt_number: Some(AttemptSequenceNumber { + number: seed as u64 + 300, + }), contract_call_result: Some(contract_call_success_for_test(seed)), } } diff --git a/crates/agglayer-types/src/settlement.rs b/crates/agglayer-types/src/settlement.rs index 7f6704921..d48a7249c 100644 --- a/crates/agglayer-types/src/settlement.rs +++ b/crates/agglayer-types/src/settlement.rs @@ -82,8 +82,11 @@ pub struct SettlementJob { } #[derive(Clone, Debug, Eq, PartialEq)] -pub enum SettlementJobResult { - ContractCall(ContractCallResult), +pub struct SettlementJobResult { + pub wallet: Address, + pub nonce: Nonce, + pub attempt_number: SettlementAttemptNumber, + pub contract_call_result: ContractCallResult, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -98,10 +101,11 @@ pub struct ClientError { pub message: String, } -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ClientErrorType { Unknown, NonceAlreadyUsed, + SettlementSucceededElsewhere, } impl ClientError { @@ -120,6 +124,30 @@ impl ClientError { message: "Timeout waiting for inclusion on L1".to_string(), } } + + pub fn settlement_succeeded_elsewhere(tx_hash: SettlementTxHash) -> Self { + Self { + kind: ClientErrorType::SettlementSucceededElsewhere, + message: format!("Settlement succeeded in transaction {tx_hash}"), + } + } +} + +impl SettlementAttemptResult { + pub fn can_be_replaced_by(&self, replacement: &Self) -> bool { + match (self, replacement) { + (Self::ClientError(_), Self::ContractCall(_)) => true, + (Self::ClientError(existing_error), Self::ClientError(replacement_error)) => { + existing_error.kind == ClientErrorType::Unknown + && matches!( + replacement_error.kind, + ClientErrorType::NonceAlreadyUsed + | ClientErrorType::SettlementSucceededElsewhere + ) + } + _ => false, + } + } } #[derive(Clone, Debug, Eq, PartialEq)] diff --git a/proto/agglayer/storage/v0/settlement.proto b/proto/agglayer/storage/v0/settlement.proto index 1a5714eba..b9b7f7238 100644 --- a/proto/agglayer/storage/v0/settlement.proto +++ b/proto/agglayer/storage/v0/settlement.proto @@ -37,8 +37,17 @@ message SettlementAttemptResult { // Terminal result of a settlement job. message SettlementJobResult { + // Sender wallet of the finalized settlement transaction. + Address wallet = 1; + + // Nonce of the finalized settlement transaction. + Nonce nonce = 2; + + // Sequence number of the finalized settlement attempt. + AttemptSequenceNumber attempt_number = 3; + // Result of the final on-chain contract call for the job. - ContractCallResult contract_call_result = 1; + ContractCallResult contract_call_result = 4; } // Error encountered while attempting to submit the transaction, that didn't lead to an on-chain result. @@ -57,6 +66,9 @@ enum ClientErrorType { // Nonce already used and finalized in other tx. CLIENT_ERROR_TYPE_NONCE_ALREADY_USED = 1; + + // Settlement succeeded in another tx. + CLIENT_ERROR_TYPE_SETTLEMENT_SUCCEEDED_ELSEWHERE = 2; } // Result for a successfully-executed contract call.