diff --git a/crates/hot-mdbx/src/cursor.rs b/crates/hot-mdbx/src/cursor.rs index ecb1a42..4157406 100644 --- a/crates/hot-mdbx/src/cursor.rs +++ b/crates/hot-mdbx/src/cursor.rs @@ -185,20 +185,48 @@ impl Cursor<'_, K> { } } +/// A key-value pair of borrowed byte slices from the cursor. +type RawCowKv<'a> = (Cow<'a, [u8]>, Cow<'a, [u8]>); + +/// Result of splitting a [`Cow`] byte slice into two halves. +type CowSplit<'a> = Result<(Cow<'a, [u8]>, Cow<'a, [u8]>), MdbxError>; + +/// Splits a DUPSORT `(key1, dup_value)` pair into `(key1, key2, value)`. +/// +/// In DUPSORT tables MDBX stores `key2 || actual_value` as the dup value. +/// This helper extracts `key2_size` from the [`FixedSizeInfo`], splits the +/// value, and reassembles the triple. +fn split_dup_kv( + fsi: FixedSizeInfo, + kv: Option>, +) -> Result>, MdbxError> { + let Some((k1, v)) = kv else { return Ok(None) }; + let key2_size = fsi.key2_size().ok_or(MdbxError::UnknownFixedSize)?; + let (k2, val) = split_cow_at(v, key2_size)?; + Ok(Some((k1, k2, val))) +} + /// Splits a [`Cow`] slice at the given index, preserving borrowed status. /// /// When the input is `Cow::Borrowed`, both outputs will be `Cow::Borrowed` /// referencing subslices of the original data. When the input is `Cow::Owned`, /// both outputs will be `Cow::Owned` with newly allocated vectors. +/// +/// Returns [`MdbxError::DupFixedErr`] if `at` exceeds the slice length. #[inline] -fn split_cow_at(cow: Cow<'_, [u8]>, at: usize) -> (Cow<'_, [u8]>, Cow<'_, [u8]>) { - match cow { +fn split_cow_at(cow: Cow<'_, [u8]>, at: usize) -> CowSplit<'_> { + debug_assert!( + at <= cow.len(), + "'at' ({at}) must not be greater than slice length ({})", + cow.len() + ); + Ok(match cow { Cow::Borrowed(slice) => (Cow::Borrowed(&slice[..at]), Cow::Borrowed(&slice[at..])), Cow::Owned(mut vec) => { let right = vec.split_off(at); (Cow::Owned(vec), Cow::Owned(right)) } - } + }) } impl DualKeyTraverse for Cursor<'_, K> @@ -209,72 +237,28 @@ where if !self.fsi.is_dupsort() { return Err(MdbxError::NotDupSort); } - - match self.inner.first::, Cow<'_, [u8]>>()? { - Some((k1, v)) => { - // For DUPSORT, the value contains key2 || actual_value. - let Some(key2_size) = self.fsi.key2_size() else { - return Err(MdbxError::UnknownFixedSize); - }; - let (k2, val) = split_cow_at(v, key2_size); - Ok(Some((k1, k2, val))) - } - None => Ok(None), - } + split_dup_kv(self.fsi, self.inner.first()?) } fn last<'a>(&'a mut self) -> Result>, MdbxError> { if !self.fsi.is_dupsort() { return Err(MdbxError::NotDupSort); } - - match self.inner.last::, Cow<'_, [u8]>>()? { - Some((k1, v)) => { - // For DUPSORT, the value contains key2 || actual_value. - let Some(key2_size) = self.fsi.key2_size() else { - return Err(MdbxError::UnknownFixedSize); - }; - let (k2, val) = split_cow_at(v, key2_size); - Ok(Some((k1, k2, val))) - } - None => Ok(None), - } + split_dup_kv(self.fsi, self.inner.last()?) } fn read_next<'a>(&'a mut self) -> Result>, MdbxError> { if !self.fsi.is_dupsort() { return Err(MdbxError::NotDupSort); } - - match self.inner.next::, Cow<'_, [u8]>>()? { - Some((k1, v)) => { - // For DUPSORT, the value contains key2 || actual_value. - let Some(key2_size) = self.fsi.key2_size() else { - return Err(MdbxError::UnknownFixedSize); - }; - let (k2, val) = split_cow_at(v, key2_size); - Ok(Some((k1, k2, val))) - } - None => Ok(None), - } + split_dup_kv(self.fsi, self.inner.next()?) } fn read_prev<'a>(&'a mut self) -> Result>, MdbxError> { if !self.fsi.is_dupsort() { return Err(MdbxError::NotDupSort); } - - match self.inner.prev::, Cow<'_, [u8]>>()? { - Some((k1, v)) => { - // For DUPSORT, the value contains key2 || actual_value. - let Some(key2_size) = self.fsi.key2_size() else { - return Err(MdbxError::UnknownFixedSize); - }; - let (k2, val) = split_cow_at(v, key2_size); - Ok(Some((k1, k2, val))) - } - None => Ok(None), - } + split_dup_kv(self.fsi, self.inner.prev()?) } fn exact_dual<'a>( @@ -286,18 +270,34 @@ where return Err(MdbxError::NotDupSort); } - // For DUPSORT tables, we use get_both which finds exact (key1, key2) match. - // The "value" in MDBX DUPSORT is key2 || actual_value, so we return that. - // Prepare key2 (may need padding for DUP_FIXED) + // For DUPSORT tables, the stored data is key2 || actual_value. + // We use get_both_range (first value >= search) rather than get_both + // (exact match), because the search value is just key2 (zero-padded + // for DUP_FIXED) and an exact match would only succeed when + // actual_value is empty/zero. let key2_prepared = if let Some(total_size) = self.fsi.total_size() { - // Copy key2 to scratch buffer and zero-pad to total fixed size self.buf[..key2.len()].copy_from_slice(key2); self.buf[key2.len()..total_size].fill(0); &self.buf[..total_size] } else { key2 }; - self.inner.get_both(key1, key2_prepared).map_err(MdbxError::from) + + let found = self + .inner + .get_both_range::>(key1, key2_prepared) + .map_err(MdbxError::from)?; + + // get_both_range returns the first dup value >= search. The dup + // value is key2 || actual_value, so verify the key2 prefix matches + // and strip it before returning. + match found { + Some(v) if v.starts_with(key2) => { + let (_, val) = split_cow_at(v, key2.len())?; + Ok(Some(val)) + } + _ => Ok(None), + } } fn next_dual_above<'a>( @@ -319,7 +319,7 @@ where // If found_k1 > search_key1, we have our answer (first entry in next key1) if found_k1.as_ref() > key1 { - let (k2, val) = split_cow_at(v, key2_size); + let (k2, val) = split_cow_at(v, key2_size)?; return Ok(Some((found_k1, k2, val))); } @@ -336,7 +336,7 @@ where match self.inner.get_both_range::>(key1, key2_prepared)? { Some(v) => { - let (k2, val) = split_cow_at(v, key2_size); + let (k2, val) = split_cow_at(v, key2_size)?; // key1 must be owned here since we're returning a reference to the input Ok(Some((Cow::Owned(key1.to_vec()), k2, val))) } @@ -344,7 +344,7 @@ where // No entry with key2 >= search_key2 in this key1, try next key1 match self.inner.next_nodup::, Cow<'_, [u8]>>()? { Some((k1, v)) => { - let (k2, val) = split_cow_at(v, key2_size); + let (k2, val) = split_cow_at(v, key2_size)?; Ok(Some((k1, k2, val))) } None => Ok(None), @@ -354,48 +354,17 @@ where } fn next_k1<'a>(&'a mut self) -> Result>, MdbxError> { - // Move to the next distinct key1 (skip remaining duplicates for current key1) - if self.fsi.is_dupsort() { - match self.inner.next_nodup::, Cow<'_, [u8]>>()? { - Some((k1, v)) => { - // For DUPSORT, the value contains key2 || actual_value. - // Split using the known key2 size. - let Some(key2_size) = self.fsi.key2_size() else { - return Err(MdbxError::UnknownFixedSize); - }; - let (k2, val) = split_cow_at(v, key2_size); - Ok(Some((k1, k2, val))) - } - None => Ok(None), - } - } else { - // Not a DUPSORT table - just get next entry - match self.inner.next()? { - Some((k, v)) => Ok(Some((k, Cow::Borrowed(&[] as &[u8]), v))), - None => Ok(None), - } + if !self.fsi.is_dupsort() { + return Err(MdbxError::NotDupSort); } + split_dup_kv(self.fsi, self.inner.next_nodup()?) } fn next_k2<'a>(&'a mut self) -> Result>, MdbxError> { - // Move to the next duplicate (same key1, next key2) - if self.fsi.is_dupsort() { - match self.inner.next_dup::, Cow<'_, [u8]>>()? { - Some((k1, v)) => { - // For DUPSORT, the value contains key2 || actual_value. - // Split using the known key2 size. - let Some(key2_size) = self.fsi.key2_size() else { - return Err(MdbxError::UnknownFixedSize); - }; - let (k2, val) = split_cow_at(v, key2_size); - Ok(Some((k1, k2, val))) - } - None => Ok(None), - } - } else { - // Not a DUPSORT table - no concept of "next duplicate" - Ok(None) + if !self.fsi.is_dupsort() { + return Err(MdbxError::NotDupSort); } + split_dup_kv(self.fsi, self.inner.next_dup()?) } fn last_of_k1<'a>(&'a mut self, key1: &[u8]) -> Result>, MdbxError> { @@ -417,7 +386,7 @@ where let Some(key2_size) = self.fsi.key2_size() else { return Err(MdbxError::UnknownFixedSize); }; - let (k2, val) = split_cow_at(v, key2_size); + let (k2, val) = split_cow_at(v, key2_size)?; // key1 must be owned here since we're returning a reference to the input Ok(Some((Cow::Owned(key1.to_vec()), k2, val))) @@ -427,38 +396,14 @@ where if !self.fsi.is_dupsort() { return Err(MdbxError::NotDupSort); } - - // prev_nodup positions at the last data item of the previous key - match self.inner.prev_nodup::, Cow<'_, [u8]>>()? { - Some((k1, v)) => { - // For DUPSORT, prev_nodup already positions at the last duplicate - // of the previous key. Split the value. - let Some(key2_size) = self.fsi.key2_size() else { - return Err(MdbxError::UnknownFixedSize); - }; - let (k2, val) = split_cow_at(v, key2_size); - Ok(Some((k1, k2, val))) - } - None => Ok(None), - } + split_dup_kv(self.fsi, self.inner.prev_nodup()?) } fn previous_k2<'a>(&'a mut self) -> Result>, MdbxError> { if !self.fsi.is_dupsort() { return Err(MdbxError::NotDupSort); } - - // prev_dup positions at the previous duplicate of the current key - match self.inner.prev_dup::, Cow<'_, [u8]>>()? { - Some((k1, v)) => { - let Some(key2_size) = self.fsi.key2_size() else { - return Err(MdbxError::UnknownFixedSize); - }; - let (k2, val) = split_cow_at(v, key2_size); - Ok(Some((k1, k2, val))) - } - None => Ok(None), - } + split_dup_kv(self.fsi, self.inner.prev_dup()?) } fn iter_items( @@ -513,16 +458,12 @@ impl<'a, K: TransactionKind> Iterator for MdbxDualKeyItemIter<'_, 'a, K> { match self.iter_dup.borrow_next() { Ok(Some(item)) => { let result = match item { - DupItem::NewKey(k1, v) => { - let (k2, val) = split_cow_at_owned(v, self.key2_size); - DualKeyItem::NewK1(Cow::Owned(k1.into_owned()), k2, val) - } - DupItem::SameKey(v) => { - let (k2, val) = split_cow_at_owned(v, self.key2_size); - DualKeyItem::SameK1(k2, val) - } + DupItem::NewKey(k1, v) => split_cow_at_owned(v, self.key2_size) + .map(|(k2, val)| DualKeyItem::NewK1(Cow::Owned(k1.into_owned()), k2, val)), + DupItem::SameKey(v) => split_cow_at_owned(v, self.key2_size) + .map(|(k2, val)| DualKeyItem::SameK1(k2, val)), }; - Some(Ok(result)) + Some(result) } Ok(None) => None, Err(e) => Some(Err(MdbxError::from(e))), @@ -531,11 +472,16 @@ impl<'a, K: TransactionKind> Iterator for MdbxDualKeyItemIter<'_, 'a, K> { } /// Splits a [`Cow`] at the given index and returns owned [`Cow`]s. +/// +/// Returns [`MdbxError::DupFixedErr`] if `at` exceeds the slice length. #[inline] -fn split_cow_at_owned(cow: Cow<'_, [u8]>, at: usize) -> (Cow<'static, [u8]>, Cow<'static, [u8]>) { +fn split_cow_at_owned(cow: Cow<'_, [u8]>, at: usize) -> CowSplit<'static> { + if at > cow.len() { + return Err(MdbxError::DupFixedErr { expected: at, found: cow.len() }); + } let mut vec = cow.into_owned(); let right = vec.split_off(at); - (Cow::Owned(vec), Cow::Owned(right)) + Ok((Cow::Owned(vec), Cow::Owned(right))) } impl DualKeyTraverseMut for Cursor<'_, K> { diff --git a/crates/hot-mdbx/src/test_utils.rs b/crates/hot-mdbx/src/test_utils.rs index 6444117..18fd0ba 100644 --- a/crates/hot-mdbx/src/test_utils.rs +++ b/crates/hot-mdbx/src/test_utils.rs @@ -64,9 +64,13 @@ mod tests { }; use serial_test::serial; use signet_hot::{ + KeySer, MAX_KEY_SIZE, ValSer, conformance::{conformance, test_unwind_conformance}, db::UnsafeDbWrite, - model::{DualTableTraverse, HotKv, HotKvRead, HotKvWrite, TableTraverse, TableTraverseMut}, + model::{ + DualKeyTraverse, DualTableTraverse, HotKv, HotKvRead, HotKvWrite, TableTraverse, + TableTraverseMut, + }, tables, }; use signet_libmdbx::{Ro, Rw}; @@ -876,6 +880,107 @@ mod tests { } } + /// Tests raw `DualKeyTraverse::exact_dual` on the MDBX cursor directly, + /// bypassing the typed `DualTableTraverse` layer (which uses + /// `next_dual_above` internally and would not have caught the get_both + /// bug). + #[test] + fn test_raw_exact_dual() { + run_test(test_raw_exact_dual_inner) + } + + fn test_raw_exact_dual_inner(db: &DatabaseEnv) { + let addr = Address::repeat_byte(0xAA); + let slot = U256::from(0x42); + let value = U256::from(0xDEAD); + + { + let writer: Tx = db.writer().unwrap(); + writer.queue_put_dual::(&addr, &slot, &value).unwrap(); + writer.raw_commit().unwrap(); + } + + let reader: Tx = db.reader().unwrap(); + let mut cursor = reader.new_cursor::().unwrap(); + + // Serialize keys to raw bytes, matching what the typed layer does. + let mut k1_buf = [0u8; MAX_KEY_SIZE]; + let k1_bytes = addr.encode_key(&mut k1_buf); + let mut k2_buf = [0u8; MAX_KEY_SIZE]; + let k2_bytes = slot.encode_key(&mut k2_buf); + + // Call raw DualKeyTraverse::exact_dual — this previously used + // get_both (exact data match) which could only find entries with + // empty/zero values. + let raw_val = + DualKeyTraverse::exact_dual(&mut cursor, k1_bytes, k2_bytes).unwrap().unwrap(); + + let decoded = U256::decode_value(&raw_val).unwrap(); + assert_eq!(decoded, value); + + // Non-existent key2 must return None. + let missing_slot = U256::from(0x9999); + let mut missing_buf = [0u8; MAX_KEY_SIZE]; + let missing_bytes = missing_slot.encode_key(&mut missing_buf); + let missing = DualKeyTraverse::exact_dual(&mut cursor, k1_bytes, missing_bytes).unwrap(); + assert!(missing.is_none()); + + // Non-existent key1 must return None. + let missing_addr = Address::repeat_byte(0xFF); + let mut missing_k1_buf = [0u8; MAX_KEY_SIZE]; + let missing_k1 = missing_addr.encode_key(&mut missing_k1_buf); + let missing = DualKeyTraverse::exact_dual(&mut cursor, missing_k1, k2_bytes).unwrap(); + assert!(missing.is_none()); + } + + /// `queue_raw_put_dual` must reject non-DUPSORT tables rather than + /// silently storing key2||value as the value. + #[test] + fn test_raw_put_dual_rejects_non_dupsort() { + run_test(test_raw_put_dual_rejects_non_dupsort_inner) + } + + fn test_raw_put_dual_rejects_non_dupsort_inner(db: &DatabaseEnv) { + let writer: Tx = db.writer().unwrap(); + + // TestTable is a SingleKey (non-DUPSORT) table. Calling + // queue_raw_put_dual on it should return an error. + let result = writer.queue_raw_put_dual(TestTable::NAME, &[0x01; 8], &[0x02; 8], &[0x03; 8]); + + assert!(result.is_err(), "queue_raw_put_dual on non-DUPSORT table should error"); + assert!( + matches!(result, Err(MdbxError::NotDupSort)), + "expected NotDupSort, got: {result:?}" + ); + } + + /// `queue_raw_delete_dual` must reject non-DUPSORT tables rather than + /// using key2 as a meaningless value filter. + #[test] + fn test_raw_delete_dual_rejects_non_dupsort() { + run_test(test_raw_delete_dual_rejects_non_dupsort_inner) + } + + fn test_raw_delete_dual_rejects_non_dupsort_inner(db: &DatabaseEnv) { + // First, write a value to the non-DUPSORT table so that a + // deletion attempt has something to operate on. + { + let writer: Tx = db.writer().unwrap(); + writer.queue_raw_put(TestTable::NAME, &[0x01; 8], &[0xFF; 8]).unwrap(); + writer.raw_commit().unwrap(); + } + + let writer: Tx = db.writer().unwrap(); + + let result = writer.queue_raw_delete_dual(TestTable::NAME, &[0x01; 8], &[0x02; 8]); + + assert!(result.is_err(), "queue_raw_delete_dual on non-DUPSORT table should error"); + assert!( + matches!(result, Err(MdbxError::NotDupSort)), + "expected NotDupSort, got: {result:?}" + ); + } + #[test] fn test_dual_table_traverse_empty_results() { run_test(test_dual_table_traverse_empty_results_inner) diff --git a/crates/hot-mdbx/src/tx.rs b/crates/hot-mdbx/src/tx.rs index d477fb4..759acf0 100644 --- a/crates/hot-mdbx/src/tx.rs +++ b/crates/hot-mdbx/src/tx.rs @@ -6,7 +6,7 @@ use signet_hot::{ model::{DualTableTraverse, HotKvRead, HotKvWrite}, tables::{DualKey, SingleKey, Table}, }; -use signet_libmdbx::{Rw, RwSync, TransactionKind, WriteFlags, tx::WriteMarker}; +use signet_libmdbx::{Database, Rw, RwSync, TransactionKind, WriteFlags, tx::WriteMarker}; use std::borrow::Cow; const TX_BUFFER_SIZE: usize = MAX_KEY_SIZE + MAX_FIXED_VAL_SIZE; @@ -96,6 +96,37 @@ impl Tx { } impl Tx { + /// Deletes an existing DUPSORT entry matching `(key1, key2)` if one + /// exists. Uses `get_both_range` to find the first dup value whose key2 + /// prefix matches, then deletes it. + fn delete_dup_entry( + &self, + db: Database, + fsi: FixedSizeInfo, + key1: &[u8], + key2: &[u8], + ) -> Result<(), MdbxError> { + let mut search_buf = [0u8; TX_BUFFER_SIZE]; + let search_val = if let Some(total_size) = fsi.total_size() { + search_buf[..key2.len()].copy_from_slice(key2); + search_buf[key2.len()..total_size].fill(0); + &search_buf[..total_size] + } else { + key2 + }; + + let mut cursor = self.inner.cursor(db).map_err(MdbxError::Mdbx)?; + + if let Some(found_val) = + cursor.get_both_range::>(key1, search_val).map_err(MdbxError::from)? + && found_val.starts_with(key2) + { + cursor.del().map_err(MdbxError::Mdbx)?; + } + + Ok(()) + } + /// Stores FixedSizeInfo in the metadata table. fn store_fsi(&self, table: &'static str, fsi: FixedSizeInfo) -> Result<(), MdbxError> { let db = self.inner.open_db(None)?; @@ -111,9 +142,12 @@ impl Tx { } fn fsi_name_to_key(name: &'static str) -> B256 { + assert!( + name.len() <= 32, + "table name exceeds 32 bytes and would be truncated in the FSI metadata key: {name}" + ); let mut key = B256::ZERO; - let to_copy = core::cmp::min(32, name.len()); - key[..to_copy].copy_from_slice(&name.as_bytes()[..to_copy]); + key[..name.len()].copy_from_slice(name.as_bytes()); key } @@ -190,43 +224,18 @@ macro_rules! impl_hot_kv_write { let db = self.inner.open_db(Some(table))?; let fsi = self.get_fsi(table)?; - // For DUPSORT tables, we must delete any existing entry with the same - // (key1, key2) before inserting, because MDBX stores key2 as part of - // the value (key2||actual_value). Without deletion, putting a new value - // for the same key2 creates a duplicate entry instead of replacing. - if fsi.is_dupsort() { - // Prepare search value (key2, optionally padded for DUP_FIXED) - let mut search_buf = [0u8; TX_BUFFER_SIZE]; - let search_val = if let Some(ts) = fsi.total_size() { - search_buf[..key2.len()].copy_from_slice(key2); - search_buf[key2.len()..ts].fill(0); - &search_buf[..ts] - } else { - key2 - }; - - // get_both_range finds entry where key=key1 and value >= search_val - // If found and the key2 portion matches, delete it - let mut cursor = self.inner.cursor(db).map_err(MdbxError::Mdbx)?; - - if let Some(found_val) = cursor - .get_both_range::>(key1, search_val) - .map_err(MdbxError::from)? - && found_val.starts_with(key2) - // Check if found value starts with our key2 - { - cursor.del().map_err(MdbxError::Mdbx)?; - } + if !fsi.is_dupsort() { + return Err(MdbxError::NotDupSort); } - // For DUPSORT tables, the "value" is key2 concatenated with the actual - // value. - // If the value is fixed size, we can write directly into our scratch - // buffer. Otherwise, we need to allocate - // - // NB: DUPSORT and RESERVE are incompatible :( + // Delete any existing entry with the same (key1, key2) + // before inserting, because MDBX stores key2 as part of + // the value (key2||actual_value). Without deletion, putting + // a new value for the same key2 creates a duplicate entry + // instead of replacing. + self.delete_dup_entry(db, fsi, key1, key2)?; + if key2.len() + value.len() > TX_BUFFER_SIZE { - // Allocate a buffer for the combined value let mut combined = Vec::with_capacity(key2.len() + value.len()); combined.extend_from_slice(key2); combined.extend_from_slice(value); @@ -236,7 +245,6 @@ macro_rules! impl_hot_kv_write { .map_err(MdbxError::Mdbx); } - // Use the scratch buffer let mut buffer = [0u8; TX_BUFFER_SIZE]; let buf = &mut buffer[..key2.len() + value.len()]; buf[..key2.len()].copy_from_slice(key2); @@ -259,39 +267,11 @@ macro_rules! impl_hot_kv_write { let db = self.inner.open_db(Some(table))?; let fsi = self.get_fsi(table)?; - // For DUPSORT tables, the "value" is key2 || actual_value. - // For DUP_FIXED tables, we cannot use del() with a partial value - // because MDBX requires an exact match. We must use a cursor to - // find and delete the entry. - if fsi.is_dupsort() { - // Prepare search value (key2, optionally padded for DUP_FIXED) - let mut search_buf = [0u8; TX_BUFFER_SIZE]; - let search_val = if let Some(ts) = fsi.total_size() { - search_buf[..key2.len()].copy_from_slice(key2); - search_buf[key2.len()..ts].fill(0); - &search_buf[..ts] - } else { - key2 - }; - - // Use cursor to find and delete the entry - let mut cursor = self.inner.cursor(db).map_err(MdbxError::Mdbx)?; - - // get_both_range finds entry where key=key1 and value >= search_val - // If found and the key2 portion matches, delete it - if let Some(found_val) = cursor - .get_both_range::>(key1, search_val) - .map_err(MdbxError::from)? - && found_val.starts_with(key2) - { - cursor.del().map_err(MdbxError::Mdbx)?; - } - - Ok(()) - } else { - // Non-DUPSORT table - just delete by key1 - self.inner.del(db, key1, Some(key2)).map(drop).map_err(MdbxError::Mdbx) + if !fsi.is_dupsort() { + return Err(MdbxError::NotDupSort); } + + self.delete_dup_entry(db, fsi, key1, key2) } fn queue_raw_clear(&self, table: &'static str) -> Result<(), Self::Error> {