From 7d172e1108a5129003e5e0583e14c97d34e7e07c Mon Sep 17 00:00:00 2001 From: Daniel Sharifi Date: Fri, 20 Feb 2026 09:55:26 +0100 Subject: [PATCH 1/6] . --- Cargo.lock | 35 +- Cargo.toml | 4 +- .../Cargo.toml | 2 +- crates/bounded-collections/src/bounded_vec.rs | 1215 +++++++++++++++++ .../src/btreemap.rs | 0 .../src/btreeset.rs | 0 crates/bounded-collections/src/lib.rs | 10 + crates/contract-interface/Cargo.toml | 4 +- .../src/types/foreign_chain.rs | 2 +- crates/contract/Cargo.toml | 4 +- crates/contract/src/lib.rs | 156 ++- crates/contract/src/primitives/signature.rs | 150 +- crates/contract/src/storage_keys.rs | 1 + crates/contract/src/v3_4_1_state.rs | 116 +- .../tests/sandbox/utils/sign_utils.rs | 5 +- .../snapshots/abi__abi_has_not_changed.snap | 36 +- crates/devnet/Cargo.toml | 1 + crates/devnet/src/contracts.rs | 16 +- crates/near-mpc-sdk/src/sign.rs | 1 + crates/node/Cargo.toml | 2 +- crates/node/src/config/foreign_chains.rs | 2 +- .../config/foreign_chains/abstract_chain.rs | 2 +- .../node/src/config/foreign_chains/bitcoin.rs | 2 +- .../src/config/foreign_chains/ethereum.rs | 2 +- .../node/src/config/foreign_chains/solana.rs | 2 +- .../src/config/foreign_chains/starknet.rs | 2 +- .../src/providers/verify_foreign_tx/sign.rs | 11 +- crates/node/src/tests.rs | 17 +- crates/node/src/tests/foreign_chain_policy.rs | 2 +- crates/non-empty-collections/src/lib.rs | 5 - 30 files changed, 1640 insertions(+), 167 deletions(-) rename crates/{non-empty-collections => bounded-collections}/Cargo.toml (94%) create mode 100644 crates/bounded-collections/src/bounded_vec.rs rename crates/{non-empty-collections => bounded-collections}/src/btreemap.rs (100%) rename crates/{non-empty-collections => bounded-collections}/src/btreeset.rs (100%) create mode 100644 crates/bounded-collections/src/lib.rs create mode 100644 crates/near-mpc-sdk/src/sign.rs delete mode 100644 crates/non-empty-collections/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 58bec8016..08a794274 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1422,6 +1422,20 @@ dependencies = [ "syn 2.0.115", ] +[[package]] +name = "bounded-collections" +version = "3.5.0" +dependencies = [ + "assert_matches", + "borsh", + "derive_more 2.1.1", + "rstest", + "schemars 0.8.22", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "bs58" version = "0.4.0" @@ -1905,12 +1919,12 @@ name = "contract-interface" version = "3.5.0" dependencies = [ "borsh", + "bounded-collections", "bs58 0.5.1", "derive_more 2.1.1", "hex", "insta", "near-sdk", - "non-empty-collections", "rstest", "schemars 0.8.22", "serde", @@ -5254,6 +5268,7 @@ dependencies = [ "anyhow", "assert_matches", "borsh", + "bounded-collections", "cargo-near-build", "contract-history", "contract-interface", @@ -5274,7 +5289,6 @@ dependencies = [ "near-account-id 2.5.0", "near-sdk", "near-workspaces", - "non-empty-collections", "rand 0.8.5", "rand_core 0.6.4", "rstest", @@ -5298,6 +5312,7 @@ version = "3.5.0" dependencies = [ "anyhow", "borsh", + "bounded-collections", "bs58 0.5.1", "clap", "contract-interface", @@ -5335,6 +5350,7 @@ dependencies = [ "backon", "base64 0.22.1", "borsh", + "bounded-collections", "bs58 0.5.1", "built", "bytes", @@ -5369,7 +5385,6 @@ dependencies = [ "near-sdk", "near-time 2.10.6", "node-types", - "non-empty-collections", "num_enum", "pprof", "prometheus", @@ -7227,20 +7242,6 @@ dependencies = [ "minimal-lexical", ] -[[package]] -name = "non-empty-collections" -version = "3.5.0" -dependencies = [ - "assert_matches", - "borsh", - "derive_more 2.1.1", - "rstest", - "schemars 0.8.22", - "serde", - "serde_json", - "thiserror 2.0.18", -] - [[package]] name = "ntapi" version = "0.3.7" diff --git a/Cargo.toml b/Cargo.toml index 57709a4a3..94270bd7e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ exclude = ["libs/nearcore"] members = [ "crates/attestation", "crates/backup-cli", + "crates/bounded-collections", "crates/ckd-example-cli", "crates/contract", "crates/contract-history", @@ -15,7 +16,6 @@ members = [ "crates/mpc-attestation", "crates/node", "crates/node-types", - "crates/non-empty-collections", "crates/primitives", "crates/tee-authority", "crates/test-migration-contract", @@ -33,6 +33,7 @@ license = "MIT" [workspace.dependencies] #workspace members attestation = { path = "crates/attestation" } +bounded-collections = { path = "crates/bounded-collections" } contract-history = { path = "crates/contract-history" } contract-interface = { path = "crates/contract-interface" } foreign-chain-inspector = { path = "crates/foreign-chain-inspector" } @@ -44,7 +45,6 @@ mpc-node = { path = "crates/node" } mpc-primitives = { path = "crates/primitives", features = ["abi"] } mpc-tls = { path = "crates/tls" } node-types = { path = "crates/node-types" } -non-empty-collections = { path = "crates/non-empty-collections" } tee-authority = { path = "crates/tee-authority" } test-utils = { path = "crates/test-utils" } utilities = { path = "crates/utilities" } diff --git a/crates/non-empty-collections/Cargo.toml b/crates/bounded-collections/Cargo.toml similarity index 94% rename from crates/non-empty-collections/Cargo.toml rename to crates/bounded-collections/Cargo.toml index e759b1d15..cd8ffe290 100644 --- a/crates/non-empty-collections/Cargo.toml +++ b/crates/bounded-collections/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "non-empty-collections" +name = "bounded-collections" version.workspace = true edition.workspace = true license.workspace = true diff --git a/crates/bounded-collections/src/bounded_vec.rs b/crates/bounded-collections/src/bounded_vec.rs new file mode 100644 index 000000000..8b9a41e97 --- /dev/null +++ b/crates/bounded-collections/src/bounded_vec.rs @@ -0,0 +1,1215 @@ +use std::{ + convert::{TryFrom, TryInto}, + slice::{Iter, IterMut}, + vec, +}; + +use thiserror::Error; + +/// Non-empty Vec bounded with minimal (L - lower bound) and maximal (U - upper bound) items quantity. +/// +/// # Type Parameters +/// +/// * `W` - witness type to prove vector ranges and shape of interface accordingly +#[derive(PartialEq, Eq, Debug, Clone, Hash, PartialOrd, Ord)] +pub struct BoundedVec> { + inner: Vec, + witness: W, +} + +/// BoundedVec errors +#[derive(Error, PartialEq, Eq, Debug, Clone)] +pub enum BoundedVecOutOfBounds { + /// Items quantity is less than L (lower bound) + #[error("Lower bound violation: got {got} (expected >= {lower_bound})")] + LowerBoundError { + /// L (lower bound) + lower_bound: usize, + /// provided value + got: usize, + }, + /// Items quantity is more than U (upper bound) + #[error("Upper bound violation: got {got} (expected <= {upper_bound})")] + UpperBoundError { + /// U (upper bound) + upper_bound: usize, + /// provided value + got: usize, + }, +} + +/// Module for type witnesses used to prove vector bounds at compile time +pub mod witnesses { + /// Compile-time proof of valid bounds. Must be constructed with same bounds to instantiate `BoundedVec`. + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] + pub struct NonEmpty(()); + + /// Possibly empty vector with upper bound. + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] + pub struct Empty(()); + + /// Type a compile-time proof of valid bounds + pub const fn non_empty() -> NonEmpty { + const { + if L == 0 { + panic!("L must be greater than 0") + } + if L > U { + panic!("L must be less than or equal to U") + } + + NonEmpty::(()) + } + } + + /// Type a compile-time proof for possibly empty vector with upper bound + pub const fn empty() -> Empty { + const { Empty::(()) } + } +} + +impl BoundedVec> { + /// Creates new BoundedVec or returns error if items count is out of bounds + /// + /// # Parameters + /// + /// * `items` - vector of items within bounds + /// + /// # Errors + /// + /// * `UpperBoundError` - if `items` len is more than U (upper bound) + /// + /// # Example + /// ``` + /// use bounded_collections::BoundedVec; + /// use bounded_collections::witnesses; + /// let data: BoundedVec<_, 0, 8, witnesses::Empty<8>> = + /// BoundedVec::<_, 0, 8, witnesses::Empty<8>>::from_vec(vec![1u8, 2]).unwrap(); + /// ``` + pub fn from_vec(items: Vec) -> Result { + let witness = witnesses::empty::(); + let len = items.len(); + if len > U { + Err(BoundedVecOutOfBounds::UpperBoundError { + upper_bound: U, + got: len, + }) + } else { + Ok(BoundedVec { + inner: items, + witness, + }) + } + } + + /// Returns the first element of the vector, or `None` if it is empty + /// + /// # Example + /// ``` + /// use bounded_collections::BoundedVec; + /// use bounded_collections::witnesses; + /// use std::convert::TryInto; + /// + /// let data: BoundedVec> = vec![1u8, 2].try_into().unwrap(); + /// assert_eq!(data.first(), Some(&1u8)); + /// ``` + pub fn first(&self) -> Option<&T> { + self.inner.first() + } + + /// Returns `true` if the vector contains no elements + /// + /// # Example + /// ``` + /// use bounded_collections::BoundedVec; + /// use bounded_collections::witnesses; + /// use std::convert::TryInto; + /// + /// let data: BoundedVec> = vec![1u8, 2].try_into().unwrap(); + /// assert_eq!(data.is_empty(), false); + /// ``` + pub fn is_empty(&self) -> bool { + self.inner.is_empty() + } + + /// Returns the last element of the vector, or `None` if it is empty + /// + /// # Example + /// ``` + /// use bounded_collections::BoundedVec; + /// use bounded_collections::witnesses; + /// use std::convert::TryInto; + /// + /// let data: BoundedVec> = vec![1u8, 2].try_into().unwrap(); + /// assert_eq!(data.last(), Some(&2u8)); + /// ``` + pub fn last(&self) -> Option<&T> { + self.inner.last() + } +} + +/// Methods which works for all witnesses +impl BoundedVec { + /// Returns a reference to underlying `Vec` + /// + /// # Example + /// ``` + /// use bounded_collections::BoundedVec; + /// use std::convert::TryInto; + /// + /// let data: BoundedVec<_, 2, 8> = vec![1u8, 2].try_into().unwrap(); + /// assert_eq!(data.as_vec(), &vec![1u8,2]); + /// ``` + pub fn as_vec(&self) -> &Vec { + &self.inner + } + + /// Returns an underlying `Vec` + /// + /// # Example + /// ``` + /// use bounded_collections::BoundedVec; + /// use std::convert::TryInto; + /// + /// let data: BoundedVec<_, 2, 8> = vec![1u8, 2].try_into().unwrap(); + /// assert_eq!(data.to_vec(), vec![1u8,2]); + /// ``` + pub fn to_vec(self) -> Vec { + self.inner + } + + /// Extracts a slice containing the entire vector. + /// + /// # Example + /// ``` + /// use bounded_collections::BoundedVec; + /// use std::convert::TryInto; + /// + /// let data: BoundedVec<_, 2, 8> = vec![1u8, 2].try_into().unwrap(); + /// assert_eq!(data.as_slice(), &[1u8,2]); + /// ``` + pub fn as_slice(&self) -> &[T] { + self.inner.as_slice() + } + + /// Returns a reference for an element at index or `None` if out of bounds + /// + /// # Example + /// + /// ``` + /// use bounded_collections::BoundedVec; + /// let data: BoundedVec = [1u8,2].into(); + /// let elem = *data.get(1).unwrap(); + /// assert_eq!(elem, 2); + /// ``` + pub fn get(&self, index: usize) -> Option<&T> { + self.inner.get(index) + } + + /// Returns the number of elements in the vector + /// + /// # Example + /// ``` + /// use bounded_collections::BoundedVec; + /// use std::convert::TryInto; + /// + /// let data: BoundedVec = vec![1u8,2].try_into().unwrap(); + /// assert_eq!(data.len(), 2); + /// ``` + pub fn len(&self) -> usize { + self.inner.len() + } + + /// Returns an iterator + pub fn iter(&self) -> Iter { + self.inner.iter() + } + + /// Returns an iterator that allows to modify each value + pub fn iter_mut(&mut self) -> IterMut { + self.inner.iter_mut() + } +} + +impl BoundedVec> { + /// Creates new BoundedVec or returns error if items count is out of bounds + /// + /// # Parameters + /// + /// * `items` - vector of items within bounds + /// + /// # Errors + /// + /// * `LowerBoundError` - if `items` len is less than L (lower bound) + /// * `UpperBoundError` - if `items` len is more than U (upper bound) + /// + /// # Example + /// ``` + /// use bounded_collections::BoundedVec; + /// use bounded_collections::witnesses; + /// let data: BoundedVec<_, 2, 8, witnesses::NonEmpty<2, 8>> = + /// BoundedVec::<_, 2, 8, witnesses::NonEmpty<2, 8>>::from_vec(vec![1u8, 2]).unwrap(); + /// ``` + pub fn from_vec(items: Vec) -> Result { + let witness = witnesses::non_empty::(); + let len = items.len(); + if len < L { + Err(BoundedVecOutOfBounds::LowerBoundError { + lower_bound: L, + got: len, + }) + } else if len > U { + Err(BoundedVecOutOfBounds::UpperBoundError { + upper_bound: U, + got: len, + }) + } else { + Ok(BoundedVec { + inner: items, + witness, + }) + } + } + + /// Returns the first element of non-empty Vec + /// + /// # Example + /// ``` + /// use bounded_collections::BoundedVec; + /// use std::convert::TryInto; + /// + /// let data: BoundedVec<_, 2, 8> = vec![1u8, 2].try_into().unwrap(); + /// assert_eq!(*data.first(), 1); + /// ``` + pub fn first(&self) -> &T { + self.inner.first().unwrap() + } + + /// Returns the last element of non-empty Vec + /// + /// # Example + /// ``` + /// use bounded_collections::BoundedVec; + /// use std::convert::TryInto; + /// + /// let data: BoundedVec<_, 2, 8> = vec![1u8, 2].try_into().unwrap(); + /// assert_eq!(*data.last(), 2); + /// ``` + pub fn last(&self) -> &T { + self.inner.last().unwrap() + } + + /// Create a new `BoundedVec` by consuming `self` and mapping each element. + /// + /// This is useful as it keeps the knowledge that the length is >= U, <= L, + /// even through the old `BoundedVec` is consumed and turned into an iterator. + /// + /// # Example + /// + /// ``` + /// use bounded_collections::BoundedVec; + /// let data: BoundedVec = [1u8,2].into(); + /// let data = data.mapped(|x|x*2); + /// assert_eq!(data, [2u8,4].into()); + /// ``` + pub fn mapped(self, map_fn: F) -> BoundedVec> + where + F: FnMut(T) -> N, + { + BoundedVec { + inner: self.inner.into_iter().map(map_fn).collect::>(), + witness: self.witness, + } + } + + /// Create a new `BoundedVec` by mapping references to the elements of self + /// + /// This is useful as it keeps the knowledge that the length is >= U, <= L, + /// will still hold for new `BoundedVec` + /// + /// # Example + /// + /// ``` + /// use bounded_collections::BoundedVec; + /// let data: BoundedVec = [1u8,2].into(); + /// let data = data.mapped_ref(|x|x*2); + /// assert_eq!(data, [2u8,4].into()); + /// ``` + pub fn mapped_ref(&self, map_fn: F) -> BoundedVec> + where + F: FnMut(&T) -> N, + { + BoundedVec { + inner: self.inner.iter().map(map_fn).collect::>(), + witness: self.witness, + } + } + + /// Create a new `BoundedVec` by consuming `self` and mapping each element + /// to a `Result`. + /// + /// This is useful as it keeps the knowledge that the length is preserved + /// even through the old `BoundedVec` is consumed and turned into an iterator. + /// + /// As this method consumes self, returning an error means that this + /// vec is dropped. I.e. this method behaves roughly like using a + /// chain of `into_iter()`, `map`, `collect::,E>>` and + /// then converting the `Vec` back to a `Vec1`. + /// + /// + /// # Errors + /// + /// Once any call to `map_fn` returns a error that error is directly + /// returned by this method. + /// + /// # Example + /// + /// ``` + /// use bounded_collections::BoundedVec; + /// let data: BoundedVec = [1u8,2].into(); + /// let data: Result, _> = data.try_mapped(|x| Err("failed")); + /// assert_eq!(data, Err("failed")); + /// ``` + pub fn try_mapped( + self, + map_fn: F, + ) -> Result>, E> + where + F: FnMut(T) -> Result, + { + let mut map_fn = map_fn; + let mut out = Vec::with_capacity(self.len()); + for element in self.inner.into_iter() { + out.push(map_fn(element)?); + } + + Ok(BoundedVec::>::from_vec(out).unwrap()) + } + + /// Create a new `BoundedVec` by mapping references of `self` elements + /// to a `Result`. + /// + /// This is useful as it keeps the knowledge that the length is preserved + /// even through the old `BoundedVec` is consumed and turned into an iterator. + /// + /// # Errors + /// + /// Once any call to `map_fn` returns a error that error is directly + /// returned by this method. + /// + /// # Example + /// + /// ``` + /// use bounded_collections::BoundedVec; + /// let data: BoundedVec = [1u8,2].into(); + /// let data: Result, _> = data.try_mapped_ref(|x| Err("failed")); + /// assert_eq!(data, Err("failed")); + /// ``` + pub fn try_mapped_ref( + &self, + map_fn: F, + ) -> Result>, E> + where + F: FnMut(&T) -> Result, + { + let mut map_fn = map_fn; + let mut out = Vec::with_capacity(self.len()); + for element in self.inner.iter() { + out.push(map_fn(element)?); + } + + Ok(BoundedVec::>::from_vec(out).unwrap()) + } + + /// Returns the last and all the rest of the elements + pub fn split_last(&self) -> (&T, &[T]) { + self.inner.split_last().unwrap() + } + + /// Return a new BoundedVec with indices included + pub fn enumerated(self) -> BoundedVec<(usize, T), L, U, witnesses::NonEmpty> { + self.inner + .into_iter() + .enumerate() + .collect::>() + .try_into() + .unwrap() + } + + /// Return a Some(BoundedVec) or None if `v` is empty + /// # Example + /// ``` + /// use bounded_collections::BoundedVec; + /// use bounded_collections::OptBoundedVecToVec; + /// + /// let opt_bv_none = BoundedVec::::opt_empty_vec(vec![]).unwrap(); + /// assert!(opt_bv_none.is_none()); + /// assert_eq!(opt_bv_none.to_vec(), Vec::::new()); + /// let opt_bv_some = BoundedVec::::opt_empty_vec(vec![0u8, 2]).unwrap(); + /// assert!(opt_bv_some.is_some()); + /// assert_eq!(opt_bv_some.to_vec(), vec![0u8, 2]); + /// ``` + pub fn opt_empty_vec( + v: Vec, + ) -> Result>>, BoundedVecOutOfBounds> { + if v.is_empty() { + Ok(None) + } else { + Ok(Some(Self::from_vec(v)?)) + } + } +} + +/// A non-empty Vec with no effective upper-bound on its length +pub type NonEmptyVec = BoundedVec>; + +/// Possibly empty Vec with upper-bound on its length +pub type EmptyBoundedVec = BoundedVec>; + +/// Non-empty Vec with bounded length +pub type NonEmptyBoundedVec = + BoundedVec>; + +impl TryFrom> + for BoundedVec> +{ + type Error = BoundedVecOutOfBounds; + + fn try_from(value: Vec) -> Result { + Self::from_vec(value) + } +} + +impl TryFrom> for BoundedVec> { + type Error = BoundedVecOutOfBounds; + + fn try_from(value: Vec) -> Result { + Self::from_vec(value) + } +} + +// when feature(const_evaluatable_checked) is stable cover all array sizes (L..=U) +impl From<[T; L]> + for BoundedVec> +{ + fn from(arr: [T; L]) -> Self { + BoundedVec { + inner: arr.into(), + witness: witnesses::non_empty(), + } + } +} + +impl From>> + for Vec +{ + fn from(v: BoundedVec>) -> Self { + v.inner + } +} + +impl From>> for Vec { + fn from(v: BoundedVec>) -> Self { + v.inner + } +} + +impl IntoIterator for BoundedVec { + type Item = T; + type IntoIter = vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.inner.into_iter() + } +} + +impl<'a, T, const L: usize, const U: usize, W> IntoIterator for &'a BoundedVec { + type Item = &'a T; + type IntoIter = core::slice::Iter<'a, T>; + + fn into_iter(self) -> Self::IntoIter { + self.inner.iter() + } +} + +impl<'a, T, const L: usize, const U: usize, W> IntoIterator for &'a mut BoundedVec { + type Item = &'a mut T; + type IntoIter = core::slice::IterMut<'a, T>; + + fn into_iter(self) -> Self::IntoIter { + self.inner.iter_mut() + } +} + +impl AsRef> for BoundedVec { + fn as_ref(&self) -> &Vec { + &self.inner + } +} + +impl AsRef<[T]> for BoundedVec { + fn as_ref(&self) -> &[T] { + self.inner.as_ref() + } +} + +/// `AsRef<[T; N]>` is only available when `L == U == N`, i.e. the vector has +/// a fixed length known at compile time. +/// +/// ``` +/// use bounded_collections::BoundedVec; +/// let data: BoundedVec = [1u8, 2, 3].into(); +/// let arr: &[u8; 3] = data.as_ref(); +/// assert_eq!(arr, &[1, 2, 3]); +/// ``` +/// +/// Does not compile when L != U (variable-length vec): +/// ```compile_fail,E0277 +/// use bounded_collections::BoundedVec; +/// let data: BoundedVec = vec![1u8, 2].try_into().unwrap(); +/// let _: &[u8; 2] = data.as_ref(); +/// ``` +/// +/// Does not compile when N differs from L and U: +/// ```compile_fail,E0277 +/// use bounded_collections::BoundedVec; +/// let data: BoundedVec = [1u8, 2, 3].into(); +/// let _: &[u8; 4] = data.as_ref(); +/// ``` +impl AsRef<[T; N]> for BoundedVec> { + fn as_ref(&self) -> &[T; N] { + self.inner.as_slice().try_into().expect( + "When L == U == N, the length is guaranteed to be exactly N, so the conversion to a fixed-size array is infallible", + ) + } +} + +/// Option> to Vec +pub trait OptBoundedVecToVec { + /// Option> to Vec + fn to_vec(self) -> Vec; +} + +impl OptBoundedVecToVec + for Option>> +{ + fn to_vec(self) -> Vec { + self.map(|bv| bv.into()).unwrap_or_default() + } +} + +mod borsh_impl { + use super::*; + use borsh::{BorshDeserialize, BorshSerialize}; + + impl BorshSerialize + for BoundedVec + { + fn serialize(&self, writer: &mut Writer) -> std::io::Result<()> { + self.inner.serialize(writer) + } + } + + impl BorshDeserialize + for BoundedVec> + { + fn deserialize_reader(reader: &mut R) -> std::io::Result { + let inner = Vec::::deserialize_reader(reader)?; + Self::from_vec(inner) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string())) + } + } + + impl BorshDeserialize + for BoundedVec> + { + fn deserialize_reader(reader: &mut R) -> std::io::Result { + let inner = Vec::::deserialize_reader(reader)?; + Self::from_vec(inner) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string())) + } + } + + #[cfg(feature = "abi")] + mod schema { + use super::*; + use borsh::BorshSchema; + use borsh::schema::{Declaration, Definition, add_definition}; + use std::collections::BTreeMap; + + impl BorshSchema for BoundedVec { + fn declaration() -> Declaration { + format!("BoundedVec<{}, {}, {}>", T::declaration(), L, U) + } + + fn add_definitions_recursively(definitions: &mut BTreeMap) { + let definition = Definition::Sequence { + length_width: Definition::DEFAULT_LENGTH_WIDTH, + length_range: (L as u64)..=(U as u64), + elements: T::declaration(), + }; + add_definition(Self::declaration(), definition, definitions); + T::add_definitions_recursively(definitions); + } + } + } +} + +mod serde_impl { + use super::*; + use serde::{Deserialize, Serialize}; + + // direct impl to unify serde in one place instead of doing attribute on declaration and deserialize here + impl Serialize for BoundedVec { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.inner.serialize(serializer) + } + } + + impl<'de, T: Deserialize<'de>, const L: usize, const U: usize> Deserialize<'de> + for BoundedVec + { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let inner = Vec::::deserialize(deserializer)?; + BoundedVec::::from_vec(inner).map_err(serde::de::Error::custom) + } + } + + impl<'de, T: Deserialize<'de>, const U: usize> Deserialize<'de> for EmptyBoundedVec { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let inner = Vec::::deserialize(deserializer)?; + EmptyBoundedVec::from_vec(inner).map_err(serde::de::Error::custom) + } + } + + #[cfg(all(feature = "abi", not(target_arch = "wasm32")))] + mod schema { + use super::*; + use schemars::JsonSchema; + + impl JsonSchema for BoundedVec { + fn schema_name() -> String { + format!("BoundedVec_{}_Min{}_Max{}", T::schema_name(), L, U) + } + + fn json_schema( + generator: &mut schemars::r#gen::SchemaGenerator, + ) -> schemars::schema::Schema { + let mut schema = >::json_schema(generator); + if let schemars::schema::Schema::Object(ref mut obj) = schema { + if let Some(ref mut array) = obj.array { + array.min_items = Some(L as u32); + array.max_items = Some(U as u32); + } + } + schema + } + } + } +} + +#[cfg(test)] +mod tests { + use assert_matches::assert_matches; + use core::convert::TryInto; + + use super::*; + + #[test] + fn from_vec_succeeds_within_bounds() { + // Given + let items = vec![1u8, 2]; + // When + let result = BoundedVec::::from_vec(items); + // Then + assert_matches!(result, Ok(_)); + } + + #[test] + fn from_vec_fails_below_lower_bound() { + // Given + let empty: Vec = vec![]; + // When + let result = BoundedVec::::from_vec(empty); + // Then + assert_eq!( + result.unwrap_err(), + BoundedVecOutOfBounds::LowerBoundError { + lower_bound: 2, + got: 0, + } + ); + } + + #[test] + fn from_vec_fails_when_length_less_than_lower_bound() { + // Given + let items = vec![1u8, 2]; + // When + let result = BoundedVec::::from_vec(items); + // Then + assert_eq!( + result.unwrap_err(), + BoundedVecOutOfBounds::LowerBoundError { + lower_bound: 3, + got: 2, + } + ); + } + + #[test] + fn from_vec_fails_above_upper_bound() { + // Given + let items = vec![1u8, 2, 3]; + // When + let result = BoundedVec::::from_vec(items); + // Then + assert_eq!( + result.unwrap_err(), + BoundedVecOutOfBounds::UpperBoundError { + upper_bound: 2, + got: 3, + } + ); + } + + #[test] + fn empty_bounded_from_vec_succeeds_within_bounds() { + // Given + let items = vec![1u8, 2]; + // When + let result = EmptyBoundedVec::::from_vec(items); + // Then + assert_matches!(result, Ok(_)); + } + + #[test] + fn empty_bounded_from_vec_accepts_empty() { + // Given + let items: Vec = vec![]; + // When + let result = EmptyBoundedVec::::from_vec(items); + // Then + assert_eq!( + result, + Ok(BoundedVec { + inner: vec![], + witness: witnesses::empty() + }) + ); + } + + #[test] + fn empty_bounded_from_vec_fails_above_upper_bound() { + // Given + let items = vec![1u8, 2, 3]; + // When + let result = EmptyBoundedVec::::from_vec(items); + // Then + assert_eq!( + result.unwrap_err(), + BoundedVecOutOfBounds::UpperBoundError { + upper_bound: 2, + got: 3, + } + ); + } + + #[test] + fn is_empty_returns_false_for_non_empty_vec() { + // Given + let data: EmptyBoundedVec<_, 8> = vec![1u8, 2].try_into().unwrap(); + // When / Then + assert!(!data.is_empty()); + } + + #[test] + fn as_vec_returns_reference_to_inner() { + // Given + let data: BoundedVec<_, 2, 8> = vec![1u8, 2].try_into().unwrap(); + // When / Then + assert_eq!(data.as_vec(), &vec![1u8, 2]); + } + + #[test] + fn as_slice_returns_slice_of_elements() { + // Given + let data: BoundedVec<_, 2, 8> = vec![1u8, 2].try_into().unwrap(); + // When / Then + assert_eq!(data.as_slice(), &[1u8, 2]); + } + + #[test] + fn len_returns_element_count() { + // Given + let data: BoundedVec<_, 2, 8> = vec![1u8, 2].try_into().unwrap(); + // When / Then + assert_eq!(data.len(), 2); + } + + #[test] + fn first_returns_first_element() { + // Given + let data: BoundedVec<_, 2, 8> = vec![1u8, 2].try_into().unwrap(); + // When / Then + assert_eq!(data.first(), &1u8); + } + + #[test] + fn last_returns_last_element() { + // Given + let data: BoundedVec<_, 2, 8> = vec![1u8, 2].try_into().unwrap(); + // When / Then + assert_eq!(data.last(), &2u8); + } + + #[test] + fn empty_bounded_first_returns_some_when_non_empty() { + // Given + let data: EmptyBoundedVec<_, 8> = vec![1u8, 2].try_into().unwrap(); + // When / Then + assert_eq!(data.first(), Some(&1u8)); + } + + #[test] + fn empty_bounded_last_returns_some_when_non_empty() { + // Given + let data: EmptyBoundedVec<_, 8> = vec![1u8, 2].try_into().unwrap(); + // When / Then + assert_eq!(data.last(), Some(&2u8)); + } + + #[test] + fn mapped_applies_function_to_all_elements() { + // Given + let data: BoundedVec = [1u8, 2].into(); + // When + let result = data.mapped(|x| x * 2); + // Then + assert_eq!(result, [2u8, 4].into()); + } + + #[test] + fn mapped_ref_applies_function_to_all_elements() { + // Given + let data: BoundedVec = [1u8, 2].into(); + // When + let result = data.mapped_ref(|x| x * 2); + // Then + assert_eq!(result, [2u8, 4].into()); + } + + #[test] + fn get_returns_element_at_valid_index() { + // Given + let data: BoundedVec<_, 2, 8> = vec![1u8, 2].try_into().unwrap(); + // When + let elem = data.get(1); + // Then + assert_eq!(elem, Some(&2u8)); + } + + #[test] + fn get_returns_none_for_out_of_bounds_index() { + // Given + let data: BoundedVec<_, 2, 8> = vec![1u8, 2].try_into().unwrap(); + // When + let elem = data.get(3); + // Then + assert_eq!(elem, None); + } + + #[test] + fn try_mapped_succeeds_when_all_elements_succeed() { + // Given + let data: BoundedVec = [1u8, 2].into(); + // When + let result = data.try_mapped(|x| 100u8.checked_div(x).ok_or("error")); + // Then + assert_eq!(result, Ok([100u8, 50].into())); + } + + #[test] + fn try_mapped_fails_when_any_element_fails() { + // Given + let data: BoundedVec = [0u8, 2].into(); + // When + let result = data.try_mapped(|x| 100u8.checked_div(x).ok_or("error")); + // Then + assert_eq!(result, Err("error")); + } + + #[test] + fn try_mapped_ref_succeeds_when_all_elements_succeed() { + // Given + let data: BoundedVec = [1u8, 2].into(); + // When + let result = data.try_mapped_ref(|x| 100u8.checked_div(*x).ok_or("error")); + // Then + assert_eq!(result, Ok([100u8, 50].into())); + } + + #[test] + fn try_mapped_ref_fails_when_any_element_fails() { + // Given + let data: BoundedVec = [0u8, 2].into(); + // When + let result = data.try_mapped_ref(|x| 100u8.checked_div(*x).ok_or("error")); + // Then + assert_eq!(result, Err("error")); + } + + #[test] + fn split_last_returns_last_and_rest() { + // Given + let data: BoundedVec<_, 2, 8> = vec![1u8, 2].try_into().unwrap(); + // When + let (last, rest) = data.split_last(); + // Then + assert_eq!(last, &2u8); + assert_eq!(rest, &[1u8]); + } + + #[test] + fn split_last_on_single_element_returns_empty_rest() { + // Given + let data: BoundedVec<_, 1, 8> = vec![1u8].try_into().unwrap(); + // When + let (last, rest) = data.split_last(); + // Then + assert_eq!(last, &1u8); + assert!(rest.is_empty()); + } + + #[test] + fn enumerated_pairs_elements_with_indices() { + // Given + let data: BoundedVec<_, 2, 8> = vec![1u8, 2].try_into().unwrap(); + // When + let result = data.enumerated(); + // Then + let expected: BoundedVec<_, 2, 8> = vec![(0, 1u8), (1, 2)].try_into().unwrap(); + assert_eq!(result, expected); + } + + #[test] + fn into_iter_yields_owned_elements() { + // Given + let vec = vec![1u8, 2]; + let data: BoundedVec<_, 2, 8> = vec.clone().try_into().unwrap(); + // When + let collected: Vec = data.into_iter().collect(); + // Then + assert_eq!(collected, vec); + } + + #[test] + fn iter_yields_references() { + // Given + let vec = vec![1u8, 2]; + let data: BoundedVec<_, 2, 8> = vec.clone().try_into().unwrap(); + // When + let collected: Vec<&u8> = data.iter().collect(); + // Then + assert_eq!(collected, vec.iter().collect::>()); + } + + #[test] + fn iter_mut_yields_mutable_references() { + // Given + let mut vec = vec![1u8, 2]; + let mut data: BoundedVec<_, 2, 8> = vec.clone().try_into().unwrap(); + // When + let collected: Vec<&mut u8> = data.iter_mut().collect(); + // Then + assert_eq!(collected, vec.iter_mut().collect::>()); + } +} + +#[cfg(test)] +mod serde_tests { + use assert_matches::assert_matches; + + use super::*; + + #[test] + fn deserialize_non_empty_vec_succeeds() { + // Given + let json = "[1, 2]"; + // When + let result = serde_json::from_str::>(json).unwrap(); + // Then + assert_eq!(result.as_vec(), &vec![1, 2]); + } + + #[test] + fn deserialize_non_empty_vec_rejects_empty_array() { + // Given + let json = "[]"; + // When + let result = serde_json::from_str::>(json); + // Then + assert_matches!(result, Err(_)); + } + + #[test] + fn deserialize_empty_bounded_vec_accepts_empty_array() { + // Given + let json = "[]"; + // When + let result = serde_json::from_str::>(json); + // Then + assert_matches!(result, Ok(_)); + } +} + +#[cfg(test)] +mod borsh_tests { + use super::*; + use borsh::BorshDeserialize; + + #[test] + fn borsh_roundtrip_preserves_non_empty_vec() { + // Given + let original: BoundedVec = vec![1u8, 2, 3].try_into().unwrap(); + // When + let bytes = borsh::to_vec(&original).unwrap(); + let deserialized: BoundedVec = BorshDeserialize::try_from_slice(&bytes).unwrap(); + // Then + assert_eq!(deserialized, original); + } + + #[test] + fn borsh_roundtrip_preserves_empty_bounded_vec() { + // Given + let original: EmptyBoundedVec = vec![1u8, 2].try_into().unwrap(); + // When + let bytes = borsh::to_vec(&original).unwrap(); + let deserialized: EmptyBoundedVec = + BorshDeserialize::try_from_slice(&bytes).unwrap(); + // Then + assert_eq!(deserialized, original); + } + + #[test] + fn borsh_deserialize_rejects_too_few_elements() { + // Given + let empty_bytes = borsh::to_vec(&Vec::::new()).unwrap(); + // When + let result: Result, _> = + BorshDeserialize::try_from_slice(&empty_bytes); + // Then + assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::InvalidData); + } + + #[test] + fn borsh_deserialize_rejects_too_many_elements() { + // Given + let too_many_bytes = borsh::to_vec(&vec![1u8, 2, 3, 4, 5]).unwrap(); + // When + let result: Result, _> = + BorshDeserialize::try_from_slice(&too_many_bytes); + // Then + assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::InvalidData); + } + + #[test] + fn borsh_deserialize_empty_bounded_rejects_too_many() { + // Given + let too_many_bytes = borsh::to_vec(&vec![1u8, 2, 3]).unwrap(); + // When + let result: Result, _> = + BorshDeserialize::try_from_slice(&too_many_bytes); + // Then + assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::InvalidData); + } + + #[test] + fn borsh_deserialize_empty_bounded_accepts_empty() { + // Given + let empty_bytes = borsh::to_vec(&Vec::::new()).unwrap(); + // When + let result: EmptyBoundedVec = + BorshDeserialize::try_from_slice(&empty_bytes).unwrap(); + // Then + assert!(result.is_empty()); + } +} + +#[cfg(all(test, feature = "abi"))] +mod borsh_schema_tests { + use super::*; + use borsh::{ + BorshSchema, + schema::{BorshSchemaContainer, Definition}, + }; + + #[test] + fn schema_declaration_includes_bounds() { + // Given / When + let decl = BoundedVec::::declaration(); + // Then + assert_eq!(decl, "BoundedVec"); + } + + #[test] + fn schema_declaration_empty_bounded_starts_at_zero() { + // Given / When + let decl = EmptyBoundedVec::::declaration(); + // Then + assert_eq!(decl, "BoundedVec"); + } + + #[test] + fn schema_encodes_length_range() { + // Given + let schema = BorshSchemaContainer::for_type::>(); + // When + let def = schema.get_definition("BoundedVec").unwrap(); + // Then + match def { + Definition::Sequence { + length_width, + length_range, + elements, + } => { + assert_eq!(*length_width, Definition::DEFAULT_LENGTH_WIDTH); + assert_eq!(*length_range, 2..=8); + assert_eq!(elements, "u8"); + } + other => panic!("expected Sequence, got {:?}", other), + } + } + + #[test] + fn schema_empty_bounded_range_starts_at_zero() { + // Given + let schema = BorshSchemaContainer::for_type::>(); + // When + let def = schema.get_definition("BoundedVec").unwrap(); + // Then + match def { + Definition::Sequence { length_range, .. } => { + assert_eq!(*length_range, 0..=4); + } + other => panic!("expected Sequence, got {:?}", other), + } + } + + #[test] + fn schema_validates_successfully() { + // Given + let schema = BorshSchemaContainer::for_type::>(); + // When / Then + assert_eq!(Ok(()), schema.validate()); + } +} diff --git a/crates/non-empty-collections/src/btreemap.rs b/crates/bounded-collections/src/btreemap.rs similarity index 100% rename from crates/non-empty-collections/src/btreemap.rs rename to crates/bounded-collections/src/btreemap.rs diff --git a/crates/non-empty-collections/src/btreeset.rs b/crates/bounded-collections/src/btreeset.rs similarity index 100% rename from crates/non-empty-collections/src/btreeset.rs rename to crates/bounded-collections/src/btreeset.rs diff --git a/crates/bounded-collections/src/lib.rs b/crates/bounded-collections/src/lib.rs new file mode 100644 index 000000000..95bf4818e --- /dev/null +++ b/crates/bounded-collections/src/lib.rs @@ -0,0 +1,10 @@ +mod bounded_vec; +mod btreemap; +mod btreeset; + +pub use bounded_vec::{ + BoundedVec, BoundedVecOutOfBounds, EmptyBoundedVec, NonEmptyBoundedVec, NonEmptyVec, + OptBoundedVecToVec, witnesses, +}; +pub use btreemap::{EmptyMapError, NonEmptyBTreeMap}; +pub use btreeset::{EmptySetError, NonEmptyBTreeSet}; diff --git a/crates/contract-interface/Cargo.toml b/crates/contract-interface/Cargo.toml index c89c9cee0..14459ca5e 100644 --- a/crates/contract-interface/Cargo.toml +++ b/crates/contract-interface/Cargo.toml @@ -5,13 +5,13 @@ license = { workspace = true } edition = { workspace = true } [features] -abi = ["borsh/unstable__schema", "schemars", "non-empty-collections/abi"] +abi = ["borsh/unstable__schema", "schemars", "bounded-collections/abi"] [dependencies] borsh = { workspace = true } +bounded-collections = { workspace = true } bs58 = { workspace = true } derive_more = { workspace = true } -non-empty-collections = { workspace = true } serde = { workspace = true } serde_with = { workspace = true } sha2 = { workspace = true } diff --git a/crates/contract-interface/src/types/foreign_chain.rs b/crates/contract-interface/src/types/foreign_chain.rs index 48fcecf7f..859a6c773 100644 --- a/crates/contract-interface/src/types/foreign_chain.rs +++ b/crates/contract-interface/src/types/foreign_chain.rs @@ -1,5 +1,5 @@ use borsh::{BorshDeserialize, BorshSerialize}; -use non_empty_collections::NonEmptyBTreeSet; +use bounded_collections::NonEmptyBTreeSet; use serde::{Deserialize, Serialize}; use serde_with::{hex::Hex, serde_as}; use sha2::Digest; diff --git a/crates/contract/Cargo.toml b/crates/contract/Cargo.toml index b55d201b2..d4f78f8ef 100644 --- a/crates/contract/Cargo.toml +++ b/crates/contract/Cargo.toml @@ -51,10 +51,11 @@ __abi-generate = ["near-sdk/__abi-generate"] anyhow = { workspace = true } assert_matches = { workspace = true } borsh = { workspace = true } +bounded-collections = { workspace = true } contract-interface = { workspace = true } curve25519-dalek = { workspace = true } derive_more = { workspace = true, features = ["from", "deref"] } -hex = { workspace = true, features = [] } +hex = { workspace = true } k256 = { workspace = true, features = [ "sha256", "ecdsa", @@ -70,7 +71,6 @@ near-account-id = { workspace = true, features = [ "serde", ] } near-sdk = { workspace = true } -non-empty-collections = { workspace = true } rand = { workspace = true, optional = true } schemars = { workspace = true } serde = { workspace = true } diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index 2375fc764..9bf9d1ef5 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -125,8 +125,20 @@ pub struct MpcContract { /// 2. The main contract state becomes usable immediately. /// 3. "Lazy cleanup" methods (like `post_upgrade_cleanup`) are then called in subsequent, /// separate transactions to gradually deallocate this storage. -#[derive(Debug, Default, BorshSerialize, BorshDeserialize)] -struct StaleData {} +#[derive(Debug, BorshSerialize, BorshDeserialize)] +struct StaleData { + pending_signature_requests_pre_upgrade: LookupMap, +} + +impl StaleData { + fn new() -> Self { + Self { + pending_signature_requests_pre_upgrade: LookupMap::new( + StorageKey::PendingSignatureRequestsV2, + ), + } + } +} #[near(serializers=[borsh])] #[derive(Debug)] @@ -705,7 +717,18 @@ impl MpcContract { env::promise_yield_resume(&data_id, serde_json::to_vec(&response).unwrap()); Ok(()) } else { - Err(InvalidParameters::RequestNotFound.into()) + // Fall back to the pre-upgrade map for in-flight requests from before the migration. + let old_request = v3_4_1_state::SignatureRequest::from(&request); + if let Some(YieldIndex { data_id }) = self + .stale_data + .pending_signature_requests_pre_upgrade + .remove(&old_request) + { + env::promise_yield_resume(&data_id, serde_json::to_vec(&response).unwrap()); + Ok(()) + } else { + Err(InvalidParameters::RequestNotFound.into()) + } } } @@ -1498,7 +1521,7 @@ impl MpcContract { Keyset::new(EpochId::new(0), Vec::new()), parameters, )), - pending_signature_requests: LookupMap::new(StorageKey::PendingSignatureRequestsV2), + pending_signature_requests: LookupMap::new(StorageKey::PendingSignatureRequestsV3), pending_ckd_requests: LookupMap::new(StorageKey::PendingCKDRequests), pending_verify_foreign_tx_requests: LookupMap::new( StorageKey::PendingVerifyForeignTxRequests, @@ -1510,7 +1533,7 @@ impl MpcContract { tee_state, accept_requests: true, node_migrations: NodeMigrations::default(), - stale_data: Default::default(), + stale_data: StaleData::new(), metrics: Default::default(), }) } @@ -1560,7 +1583,7 @@ impl MpcContract { protocol_state: ProtocolContractState::Running(RunningContractState::new( domains, keyset, parameters, )), - pending_signature_requests: LookupMap::new(StorageKey::PendingSignatureRequestsV2), + pending_signature_requests: LookupMap::new(StorageKey::PendingSignatureRequestsV3), pending_ckd_requests: LookupMap::new(StorageKey::PendingCKDRequests), pending_verify_foreign_tx_requests: LookupMap::new( StorageKey::PendingVerifyForeignTxRequests, @@ -1571,7 +1594,7 @@ impl MpcContract { tee_state, accept_requests: true, node_migrations: NodeMigrations::default(), - stale_data: Default::default(), + stale_data: StaleData::new(), metrics: Default::default(), }) } @@ -1631,7 +1654,16 @@ impl MpcContract { } pub fn get_pending_request(&self, request: &SignatureRequest) -> Option { - self.pending_signature_requests.get(request).cloned() + self.pending_signature_requests + .get(request) + .cloned() + .or_else(|| { + let old_request = v3_4_1_state::SignatureRequest::from(request); + self.stale_data + .pending_signature_requests_pre_upgrade + .get(&old_request) + .cloned() + }) } pub fn get_pending_ckd_request(&self, request: &CKDRequest) -> Option { @@ -1676,7 +1708,16 @@ impl MpcContract { match signature { Ok(signature) => PromiseOrValue::Value(signature), Err(_) => { - self.pending_signature_requests.remove(&request); + let removed = self.pending_signature_requests.remove(&request).is_some(); + + // Fallback, the request must have been made pre-upgrade + if !removed { + let old_request = v3_4_1_state::SignatureRequest::from(&request); + self.stale_data + .pending_signature_requests_pre_upgrade + .remove(&old_request); + } + let fail_on_timeout_gas = Gas::from_tgas(self.config.fail_on_timeout_tera_gas); let promise = Promise::new(env::current_account_id()).function_call( method_names::FAIL_ON_TIMEOUT.to_string(), @@ -2050,6 +2091,7 @@ mod tests { }; use crate::tee::tee_state::NodeId; use assert_matches::assert_matches; + use bounded_collections::NonEmptyBTreeSet; use contract_interface::types::{ BitcoinExtractedValue, BitcoinExtractor, BitcoinRpcRequest, ExtractedValue, ForeignTxSignPayloadV1, @@ -2069,7 +2111,6 @@ mod tests { }; use mpc_primitives::hash::{Hash32, Image}; use near_sdk::{test_utils::VMContextBuilder, testing_env, NearToken, VMContext}; - use non_empty_collections::NonEmptyBTreeSet; use primitives::key_state::{AttemptId, KeyForDomain}; use rand::seq::SliceRandom; use rand::SeedableRng; @@ -2362,6 +2403,97 @@ mod tests { assert!(contract.get_pending_request(&signature_request).is_none()); } + #[test] + fn test_signature_timeout__removes_from_pre_upgrade_stale_map() { + // given + let (context, mut contract, _) = basic_setup(SignatureScheme::Secp256k1, &mut OsRng); + let signature_request = SignatureRequest::new( + DomainId::default(), + Payload::from_legacy_ecdsa([0u8; 32]), + &context.predecessor_account_id, + "m/44'\''/60'\''/0'\''/0/0", + ); + let old_request = v3_4_1_state::SignatureRequest::from(&signature_request); + contract + .stale_data + .pending_signature_requests_pre_upgrade + .insert( + old_request.clone(), + YieldIndex { + data_id: CryptoHash::default(), + }, + ); + assert_matches!(contract.get_pending_request(&signature_request), Some(_)); + + // when + let result = contract.return_signature_and_clean_state_on_success( + signature_request.clone(), + Err(PromiseError::Failed), + ); + + // then + assert!(matches!(result, PromiseOrValue::Promise(_))); + assert_matches!(contract.get_pending_request(&signature_request), None); + assert_matches!( + contract + .stale_data + .pending_signature_requests_pre_upgrade + .get(&old_request), + None + ); + } + + #[test] + fn test_signature_timeout__request_in_neither_map_still_schedules_fail() { + // given + let (context, mut contract, _) = basic_setup(SignatureScheme::Secp256k1, &mut OsRng); + let signature_request = SignatureRequest::new( + DomainId::default(), + Payload::from_legacy_ecdsa([0u8; 32]), + &context.predecessor_account_id, + "m/44'\''/60'\''/0'\''/0/0", + ); + assert_matches!(contract.get_pending_request(&signature_request), None); + + // when + let result = contract.return_signature_and_clean_state_on_success( + signature_request, + Err(PromiseError::Failed), + ); + + // then + assert!(matches!(result, PromiseOrValue::Promise(_))); + } + + #[test] + fn test_signature_success__returns_value_directly() { + // given + let (context, mut contract, _) = basic_setup(SignatureScheme::Secp256k1, &mut OsRng); + let signature_request = SignatureRequest::new( + DomainId::default(), + Payload::from_legacy_ecdsa([0u8; 32]), + &context.predecessor_account_id, + "m/44'\''/60'\''/0'\''/0/0", + ); + let signature_response = SignatureResponse::Secp256k1(k256_types::Signature::new( + AffinePoint::IDENTITY, + k256::Scalar::ONE, + 0, + )); + + // when + let result = contract.return_signature_and_clean_state_on_success( + signature_request, + Ok(signature_response.clone()), + ); + + // then + match result { + PromiseOrValue::Value(resp) => assert_eq!(resp, signature_response), + PromiseOrValue::Promise(_) => panic!("Expected Value, got Promise"), + } + } + #[test] fn respond_ckd__should_succeed_when_response_is_valid_and_request_exists() { let (context, mut contract, _secret_key) = @@ -2985,7 +3117,7 @@ mod tests { pub fn new_from_protocol_state(protocol_state: ProtocolContractState) -> Self { MpcContract { protocol_state, - pending_signature_requests: LookupMap::new(StorageKey::PendingSignatureRequestsV2), + pending_signature_requests: LookupMap::new(StorageKey::PendingSignatureRequestsV3), pending_ckd_requests: LookupMap::new(StorageKey::PendingCKDRequests), pending_verify_foreign_tx_requests: LookupMap::new( StorageKey::PendingVerifyForeignTxRequests, @@ -2997,7 +3129,7 @@ mod tests { config: Default::default(), tee_state: Default::default(), node_migrations: Default::default(), - stale_data: Default::default(), + stale_data: StaleData::new(), metrics: Default::default(), } } diff --git a/crates/contract/src/primitives/signature.rs b/crates/contract/src/primitives/signature.rs index 1e588d9b4..9e62070e8 100644 --- a/crates/contract/src/primitives/signature.rs +++ b/crates/contract/src/primitives/signature.rs @@ -1,6 +1,7 @@ use crate::crypto_shared; use crate::errors::{Error, InvalidParameters}; use crate::DomainId; +use bounded_collections::BoundedVec; use crypto_shared::derive_tweak; use near_account_id::AccountId; use near_sdk::{near, CryptoHash}; @@ -23,106 +24,89 @@ impl Tweak { /// A signature payload; the right payload must be passed in for the curve. /// The json encoding for this payload converts the bytes to hex string. #[derive(Debug, Clone, Eq, Ord, PartialEq, PartialOrd)] -#[near(serializers=[borsh, json])] +#[cfg_attr( + all(feature = "abi", not(target_arch = "wasm32")), + derive(schemars::JsonSchema) +)] +#[near(serializers=[borsh])] pub enum Payload { - Ecdsa( - #[cfg_attr( - all(feature = "abi", not(target_arch = "wasm32")), - schemars(with = "[u8; 32]"), - borsh(schema(with_funcs( - declaration = "<[u8; 32] as ::borsh::BorshSchema>::declaration", - definitions = "<[u8; 32] as ::borsh::BorshSchema>::add_definitions_recursively" - ),)) - )] - Bytes<32, 32>, - ), - Eddsa( - #[cfg_attr( - all(feature = "abi", not(target_arch = "wasm32")), - schemars(with = "Vec"), - borsh(schema(with_funcs( - declaration = " as ::borsh::BorshSchema>::declaration", - definitions = " as ::borsh::BorshSchema>::add_definitions_recursively" - ),)) - )] - Bytes<32, 1232>, - ), + Ecdsa(BoundedVec), + Eddsa(BoundedVec), } -impl Payload { - pub fn from_legacy_ecdsa(bytes: [u8; 32]) -> Self { - Payload::Ecdsa(Bytes::new(bytes.to_vec()).unwrap()) - } - - pub fn as_ecdsa(&self) -> Option<&[u8; 32]> { - match self { - Payload::Ecdsa(bytes) => Some(bytes.as_fixed_bytes()), - _ => None, +/// Custom JSON serialization preserving the hex-encoded string format +/// used by the original `Bytes` type, so the on-chain API stays backwards-compatible. +impl near_sdk::serde::Serialize for Payload { + fn serialize(&self, serializer: S) -> Result + where + S: near_sdk::serde::Serializer, + { + #[derive(near_sdk::serde::Serialize)] + #[serde(crate = "near_sdk::serde")] + enum Helper<'a> { + Ecdsa(&'a str), + Eddsa(&'a str), } - } - - pub fn as_eddsa(&self) -> Option<&[u8]> { match self { - Payload::Eddsa(bytes) => Some(bytes.as_bytes()), - _ => None, + Payload::Ecdsa(bytes) => { + let hex = hex::encode(bytes.as_slice()); + Helper::Ecdsa(&hex).serialize(serializer) + } + Payload::Eddsa(bytes) => { + let hex = hex::encode(bytes.as_slice()); + Helper::Eddsa(&hex).serialize(serializer) + } } } } -/// A byte array with a statically encoded minimum and maximum length. -/// The `new` function as well as json deserialization checks that the length is within bounds. -/// The borsh deserialization does not perform such checks, as the borsh serialization is only -/// used for internal contract storage. -#[derive(Clone, Eq, Ord, PartialEq, PartialOrd)] -#[near(serializers=[borsh])] -pub struct Bytes(Vec); - -impl Bytes { - pub fn new(bytes: Vec) -> Result { - if bytes.len() < MIN_LEN || bytes.len() > MAX_LEN { - return Err(InvalidParameters::MalformedPayload.into()); +impl<'de> near_sdk::serde::Deserialize<'de> for Payload { + fn deserialize(deserializer: D) -> Result + where + D: near_sdk::serde::Deserializer<'de>, + { + #[derive(near_sdk::serde::Deserialize)] + #[serde(crate = "near_sdk::serde")] + enum Helper { + Ecdsa(String), + Eddsa(String), + } + match Helper::deserialize(deserializer)? { + Helper::Ecdsa(hex) => { + let bytes = hex::decode(&hex).map_err(near_sdk::serde::de::Error::custom)?; + let bounded: BoundedVec = bytes + .try_into() + .map_err(near_sdk::serde::de::Error::custom)?; + Ok(Payload::Ecdsa(bounded)) + } + Helper::Eddsa(hex) => { + let bytes = hex::decode(&hex).map_err(near_sdk::serde::de::Error::custom)?; + let bounded: BoundedVec = bytes + .try_into() + .map_err(near_sdk::serde::de::Error::custom)?; + Ok(Payload::Eddsa(bounded)) + } } - Ok(Self(bytes)) - } - - pub fn as_bytes(&self) -> &[u8] { - &self.0 - } -} - -impl Bytes { - pub fn as_fixed_bytes(&self) -> &[u8; N] { - self.0.as_slice().try_into().unwrap() } } -impl Debug for Bytes { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_tuple("Bytes").field(&hex::encode(&self.0)).finish() +impl Payload { + pub fn from_legacy_ecdsa(bytes: [u8; 32]) -> Self { + Payload::Ecdsa(bytes.into()) } -} -impl near_sdk::serde::Serialize - for Bytes -{ - fn serialize(&self, serializer: S) -> Result - where - S: near_sdk::serde::Serializer, - { - hex::encode(&self.0).serialize(serializer) + pub fn as_ecdsa(&self) -> Option<&[u8; 32]> { + match self { + Payload::Ecdsa(bytes) => Some(bytes.as_ref()), + _ => None, + } } -} -impl<'de, const MIN_LEN: usize, const MAX_LEN: usize> near_sdk::serde::Deserialize<'de> - for Bytes -{ - fn deserialize(deserializer: D) -> Result - where - D: near_sdk::serde::Deserializer<'de>, - { - let s = String::deserialize(deserializer)?; - let bytes = hex::decode(&s).map_err(near_sdk::serde::de::Error::custom)?; - Self::new(bytes).map_err(near_sdk::serde::de::Error::custom) + pub fn as_eddsa(&self) -> Option<&[u8]> { + match self { + Payload::Eddsa(bytes) => Some(bytes.as_slice()), + _ => None, + } } } diff --git a/crates/contract/src/storage_keys.rs b/crates/contract/src/storage_keys.rs index 072f0e4fc..74b92cb8d 100644 --- a/crates/contract/src/storage_keys.rs +++ b/crates/contract/src/storage_keys.rs @@ -19,4 +19,5 @@ pub enum StorageKey { NodeMigrations, ForeignChainPolicyVotes, PendingVerifyForeignTxRequests, + PendingSignatureRequestsV3, } diff --git a/crates/contract/src/v3_4_1_state.rs b/crates/contract/src/v3_4_1_state.rs index 7310dbb0a..c26525ced 100644 --- a/crates/contract/src/v3_4_1_state.rs +++ b/crates/contract/src/v3_4_1_state.rs @@ -13,6 +13,7 @@ use near_sdk::{env, near, store::LookupMap}; use std::collections::{BTreeMap, BTreeSet, HashSet}; use crate::{ + errors::{Error, InvalidParameters}, node_migrations::NodeMigrations, primitives::{ ckd::CKDRequest, @@ -21,15 +22,19 @@ use crate::{ AttemptId, AuthenticatedAccountId, AuthenticatedParticipantId, EpochId, KeyForDomain, Keyset, }, - signature::{SignatureRequest, YieldIndex}, + signature::{Tweak, YieldIndex}, thresholds::ThresholdParameters, votes::ThresholdParametersVotes, }, tee::tee_state::TeeState, update::ProposedUpdates, - Config, ForeignChainPolicyVotes, StaleData, StorageKey, + Config, ForeignChainPolicyVotes, StorageKey, }; +/// Old `StaleData` was an empty struct — must match the on-chain borsh layout exactly. +#[derive(Debug, Default, BorshSerialize, BorshDeserialize)] +struct OldStaleData {} + #[derive(Debug, BorshSerialize, BorshDeserialize)] pub struct MpcContract { protocol_state: ProtocolContractState, @@ -42,7 +47,7 @@ pub struct MpcContract { tee_state: TeeState, accept_requests: bool, node_migrations: NodeMigrations, - stale_data: StaleData, + stale_data: OldStaleData, } impl From for crate::MpcContract { @@ -55,7 +60,7 @@ impl From for crate::MpcContract { Self { protocol_state, - pending_signature_requests: value.pending_signature_requests, + pending_signature_requests: LookupMap::new(StorageKey::PendingSignatureRequestsV3), pending_ckd_requests: value.pending_ckd_requests, pending_verify_foreign_tx_requests: LookupMap::new( StorageKey::PendingVerifyForeignTxRequests, @@ -67,7 +72,9 @@ impl From for crate::MpcContract { tee_state: value.tee_state, accept_requests: value.accept_requests, node_migrations: value.node_migrations, - stale_data: crate::StaleData {}, + stale_data: crate::StaleData { + pending_signature_requests_pre_upgrade: value.pending_signature_requests, + }, metrics: Default::default(), } } @@ -241,3 +248,102 @@ impl DomainConfig { } } } + +#[derive(Debug, Clone, Eq, Ord, PartialEq, PartialOrd, BorshSerialize, BorshDeserialize)] +pub struct SignatureRequest { + pub tweak: Tweak, + pub payload: Payload, + pub domain_id: DomainId, +} + +/// A signature payload; the right payload must be passed in for the curve. +/// The json encoding for this payload converts the bytes to hex string. +#[derive(Debug, Clone, Eq, Ord, PartialEq, PartialOrd)] +#[near(serializers=[borsh, json])] +pub enum Payload { + Ecdsa( + #[cfg_attr( + all(feature = "abi", not(target_arch = "wasm32")), + schemars(with = "[u8; 32]"), + borsh(schema(with_funcs( + declaration = "<[u8; 32] as ::borsh::BorshSchema>::declaration", + definitions = "<[u8; 32] as ::borsh::BorshSchema>::add_definitions_recursively" + ),)) + )] + Bytes<32, 32>, + ), + Eddsa( + #[cfg_attr( + all(feature = "abi", not(target_arch = "wasm32")), + schemars(with = "Vec"), + borsh(schema(with_funcs( + declaration = " as ::borsh::BorshSchema>::declaration", + definitions = " as ::borsh::BorshSchema>::add_definitions_recursively" + ),)) + )] + Bytes<32, 1232>, + ), +} + +impl Bytes { + pub fn new(bytes: Vec) -> Result { + if bytes.len() < MIN_LEN || bytes.len() > MAX_LEN { + return Err(InvalidParameters::MalformedPayload.into()); + } + Ok(Self(bytes)) + } + + pub fn as_bytes(&self) -> &[u8] { + &self.0 + } +} + +/// A byte array with a statically encoded minimum and maximum length. +/// The `new` function as well as json deserialization checks that the length is within bounds. +/// The borsh deserialization does not perform such checks, as the borsh serialization is only +/// used for internal contract storage. +#[derive(Debug, Clone, Eq, Ord, PartialEq, PartialOrd)] +#[near(serializers=[borsh])] +pub struct Bytes(Vec); + +impl near_sdk::serde::Serialize + for Bytes +{ + fn serialize(&self, serializer: S) -> Result + where + S: near_sdk::serde::Serializer, + { + near_sdk::serde::Serialize::serialize(&hex::encode(&self.0), serializer) + } +} + +impl<'de, const MIN_LEN: usize, const MAX_LEN: usize> near_sdk::serde::Deserialize<'de> + for Bytes +{ + fn deserialize(deserializer: D) -> Result + where + D: near_sdk::serde::Deserializer<'de>, + { + let s = ::deserialize(deserializer)?; + let bytes = hex::decode(&s).map_err(near_sdk::serde::de::Error::custom)?; + Self::new(bytes).map_err(near_sdk::serde::de::Error::custom) + } +} + +impl From<&crate::primitives::signature::SignatureRequest> for SignatureRequest { + fn from(request: &crate::primitives::signature::SignatureRequest) -> Self { + let payload = match &request.payload { + crate::primitives::signature::Payload::Ecdsa(bytes) => { + Payload::Ecdsa(Bytes(bytes.as_slice().to_vec())) + } + crate::primitives::signature::Payload::Eddsa(bytes) => { + Payload::Eddsa(Bytes(bytes.as_slice().to_vec())) + } + }; + SignatureRequest { + tweak: request.tweak.clone(), + payload, + domain_id: request.domain_id, + } + } +} diff --git a/crates/contract/tests/sandbox/utils/sign_utils.rs b/crates/contract/tests/sandbox/utils/sign_utils.rs index 7e43f633a..d1421642d 100644 --- a/crates/contract/tests/sandbox/utils/sign_utils.rs +++ b/crates/contract/tests/sandbox/utils/sign_utils.rs @@ -4,6 +4,7 @@ use super::shared_key_utils::{ derive_secret_key_ed25519, derive_secret_key_secp256k1, generate_random_app_public_key, DomainKey, SharedSecretKey, }; +use bounded_collections::BoundedVec; use contract_interface::method_names::{ GET_PENDING_CKD_REQUEST, GET_PENDING_REQUEST, REQUEST_APP_PRIVATE_KEY, RESPOND, RESPOND_CKD, SIGN, @@ -25,7 +26,7 @@ use mpc_contract::{ primitives::{ ckd::{CKDRequest, CKDRequestArgs}, domain::DomainId, - signature::{Bytes, Payload, SignRequestArgs, SignatureRequest, YieldIndex}, + signature::{Payload, SignRequestArgs, SignatureRequest, YieldIndex}, }, }; use near_account_id::AccountId; @@ -528,7 +529,7 @@ fn create_response_ed25519( .try_into() .unwrap(); - let bytes = Bytes::new(payload.into()).unwrap(); + let bytes = BoundedVec::from(payload); let payload = Payload::Eddsa(bytes); let respond_req = SignatureRequest::new(domain_id, payload.clone(), predecessor_id, path); diff --git a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap index 0da5a1fb9..1d9382f50 100644 --- a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap +++ b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap @@ -1460,6 +1460,26 @@ expression: abi "Bls12381G2PublicKey": { "type": "string" }, + "BoundedVec_uint8_Min32_Max1232": { + "type": "array", + "items": { + "type": "integer", + "format": "uint8", + "minimum": 0.0 + }, + "maxItems": 1232, + "minItems": 32 + }, + "BoundedVec_uint8_Min32_Max32": { + "type": "array", + "items": { + "type": "integer", + "format": "uint8", + "minimum": 0.0 + }, + "maxItems": 32, + "minItems": 32 + }, "CKDRequest": { "type": "object", "required": [ @@ -2614,14 +2634,7 @@ expression: abi ], "properties": { "Ecdsa": { - "type": "array", - "items": { - "type": "integer", - "format": "uint8", - "minimum": 0.0 - }, - "maxItems": 32, - "minItems": 32 + "$ref": "#/definitions/BoundedVec_uint8_Min32_Max32" } }, "additionalProperties": false @@ -2633,12 +2646,7 @@ expression: abi ], "properties": { "Eddsa": { - "type": "array", - "items": { - "type": "integer", - "format": "uint8", - "minimum": 0.0 - } + "$ref": "#/definitions/BoundedVec_uint8_Min32_Max1232" } }, "additionalProperties": false diff --git a/crates/devnet/Cargo.toml b/crates/devnet/Cargo.toml index 24dbc5877..ea6cf716e 100644 --- a/crates/devnet/Cargo.toml +++ b/crates/devnet/Cargo.toml @@ -7,6 +7,7 @@ edition = "2021" [dependencies] anyhow = { workspace = true } borsh = { workspace = true } +bounded-collections = { workspace = true } bs58 = { workspace = true } clap = { workspace = true } contract-interface = { workspace = true } diff --git a/crates/devnet/src/contracts.rs b/crates/devnet/src/contracts.rs index 9e11b6537..968d744a4 100644 --- a/crates/devnet/src/contracts.rs +++ b/crates/devnet/src/contracts.rs @@ -1,10 +1,11 @@ use std::collections::BTreeMap; +use bounded_collections::BoundedVec; use contract_interface::method_names; use mpc_contract::primitives::{ ckd::CKDRequestArgs, domain::{DomainConfig, SignatureScheme}, - signature::{Bytes, Payload, SignRequestArgs}, + signature::{Payload, SignRequestArgs}, }; use near_account_id::AccountId; use near_primitives::action::Action; @@ -165,13 +166,20 @@ struct ParallelSignArgsV2 { fn make_payload(scheme: SignatureScheme) -> Payload { match scheme { SignatureScheme::Secp256k1 | SignatureScheme::V2Secp256k1 => { - Payload::Ecdsa(Bytes::new(rand::random::<[u8; 32]>().to_vec()).unwrap()) + Payload::Ecdsa(rand::random::<[u8; 32]>().into()) } SignatureScheme::Ed25519 => { - let len = rand::random_range(32..=1232); + const LOWER: usize = 32; + const UPPER: usize = 1232; + let len = rand::random_range(LOWER..=UPPER); + let mut payload = vec![0; len]; rand::rng().fill_bytes(&mut payload); - Payload::Eddsa(Bytes::new(payload).unwrap()) + + let bounded_payload: BoundedVec = + BoundedVec::::try_from(payload).unwrap(); + + Payload::Eddsa(bounded_payload) } SignatureScheme::Bls12381 => { unreachable!("make_payload should not be called with `Bls12381` scheme") diff --git a/crates/near-mpc-sdk/src/sign.rs b/crates/near-mpc-sdk/src/sign.rs new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/crates/near-mpc-sdk/src/sign.rs @@ -0,0 +1 @@ + diff --git a/crates/node/Cargo.toml b/crates/node/Cargo.toml index 960c2b302..5b70d1291 100644 --- a/crates/node/Cargo.toml +++ b/crates/node/Cargo.toml @@ -16,6 +16,7 @@ axum = { workspace = true } backon = { workspace = true } base64 = { workspace = true } borsh = { workspace = true } +bounded-collections = { workspace = true } bs58 = { workspace = true } bytes = { workspace = true } clap = { workspace = true } @@ -48,7 +49,6 @@ near-o11y = { workspace = true } near-sdk = { workspace = true } near-time = { workspace = true } node-types = { workspace = true } -non-empty-collections = { workspace = true } num_enum = { workspace = true } pprof = { workspace = true } prometheus = { workspace = true } diff --git a/crates/node/src/config/foreign_chains.rs b/crates/node/src/config/foreign_chains.rs index fdfe87d5e..c4f1b44b6 100644 --- a/crates/node/src/config/foreign_chains.rs +++ b/crates/node/src/config/foreign_chains.rs @@ -2,8 +2,8 @@ use std::collections::BTreeSet; use std::{borrow::Cow, collections::BTreeMap}; use anyhow::Context; +use bounded_collections::{NonEmptyBTreeMap, NonEmptyBTreeSet}; use contract_interface::types as dtos; -use non_empty_collections::{NonEmptyBTreeMap, NonEmptyBTreeSet}; use serde::{Deserialize, Serialize}; mod abstract_chain; diff --git a/crates/node/src/config/foreign_chains/abstract_chain.rs b/crates/node/src/config/foreign_chains/abstract_chain.rs index 7801c23e4..c3d3b5b37 100644 --- a/crates/node/src/config/foreign_chains/abstract_chain.rs +++ b/crates/node/src/config/foreign_chains/abstract_chain.rs @@ -1,6 +1,6 @@ use std::borrow::Cow; -use non_empty_collections::NonEmptyBTreeMap; +use bounded_collections::NonEmptyBTreeMap; use serde::{Deserialize, Serialize}; use crate::config::foreign_chains::auth; diff --git a/crates/node/src/config/foreign_chains/bitcoin.rs b/crates/node/src/config/foreign_chains/bitcoin.rs index aee4688cb..f19109249 100644 --- a/crates/node/src/config/foreign_chains/bitcoin.rs +++ b/crates/node/src/config/foreign_chains/bitcoin.rs @@ -1,6 +1,6 @@ use std::borrow::Cow; -use non_empty_collections::NonEmptyBTreeMap; +use bounded_collections::NonEmptyBTreeMap; use serde::{Deserialize, Serialize}; use crate::config::foreign_chains::auth; diff --git a/crates/node/src/config/foreign_chains/ethereum.rs b/crates/node/src/config/foreign_chains/ethereum.rs index ed5a8a87e..134ea14c6 100644 --- a/crates/node/src/config/foreign_chains/ethereum.rs +++ b/crates/node/src/config/foreign_chains/ethereum.rs @@ -1,6 +1,6 @@ use std::borrow::Cow; -use non_empty_collections::NonEmptyBTreeMap; +use bounded_collections::NonEmptyBTreeMap; use serde::{Deserialize, Serialize}; use crate::config::foreign_chains::auth; diff --git a/crates/node/src/config/foreign_chains/solana.rs b/crates/node/src/config/foreign_chains/solana.rs index 4a1b3590c..be5ae127d 100644 --- a/crates/node/src/config/foreign_chains/solana.rs +++ b/crates/node/src/config/foreign_chains/solana.rs @@ -1,6 +1,6 @@ use std::borrow::Cow; -use non_empty_collections::NonEmptyBTreeMap; +use bounded_collections::NonEmptyBTreeMap; use serde::{Deserialize, Serialize}; use crate::config::foreign_chains::auth; diff --git a/crates/node/src/config/foreign_chains/starknet.rs b/crates/node/src/config/foreign_chains/starknet.rs index 4597c28a5..02f2b4ec9 100644 --- a/crates/node/src/config/foreign_chains/starknet.rs +++ b/crates/node/src/config/foreign_chains/starknet.rs @@ -1,6 +1,6 @@ use std::borrow::Cow; -use non_empty_collections::NonEmptyBTreeMap; +use bounded_collections::NonEmptyBTreeMap; use serde::{Deserialize, Serialize}; use crate::config::foreign_chains::auth; diff --git a/crates/node/src/providers/verify_foreign_tx/sign.rs b/crates/node/src/providers/verify_foreign_tx/sign.rs index 1fa861f6f..04f605f2c 100644 --- a/crates/node/src/providers/verify_foreign_tx/sign.rs +++ b/crates/node/src/providers/verify_foreign_tx/sign.rs @@ -20,8 +20,9 @@ use crate::{ network::NetworkTaskChannel, primitives::UniqueId, providers::verify_foreign_tx::VerifyForeignTxProvider, types::SignatureId, }; +use bounded_collections::BoundedVec; use contract_interface::types as dtos; -use mpc_contract::primitives::signature::{Bytes, Payload, Tweak}; +use mpc_contract::primitives::signature::{Payload, Tweak}; use near_indexer_primitives::CryptoHash; use tokio::time::{timeout, Duration}; @@ -32,8 +33,8 @@ fn build_signature_request( foreign_tx_payload: &dtos::ForeignTxSignPayload, ) -> anyhow::Result { let payload_hash: [u8; 32] = foreign_tx_payload.compute_msg_hash()?.into(); - let payload_bytes = - Bytes::new(payload_hash.to_vec()).map_err(|err| anyhow::format_err!("{err}"))?; + let payload_bytes: BoundedVec = payload_hash.into(); + Ok(SignatureRequest { id: request.id, receipt_id: request.receipt_id, @@ -360,7 +361,7 @@ mod tests { }; use crate::indexer::MockReadForeignChainPolicy; use assert_matches::assert_matches; - use non_empty_collections::NonEmptyBTreeSet; + use bounded_collections::NonEmptyBTreeSet; use std::collections::BTreeMap; fn bitcoin_request() -> dtos::ForeignChainRpcRequest { @@ -372,7 +373,7 @@ mod tests { } fn bitcoin_foreign_chains_config() -> ForeignChainsConfig { - let providers = non_empty_collections::NonEmptyBTreeMap::new( + let providers = bounded_collections::NonEmptyBTreeMap::new( "public".to_string(), BitcoinProviderConfig { rpc_url: "https://blockstream.info/api".to_string(), diff --git a/crates/node/src/tests.rs b/crates/node/src/tests.rs index fc962cb94..8a94b1e29 100644 --- a/crates/node/src/tests.rs +++ b/crates/node/src/tests.rs @@ -32,8 +32,9 @@ use crate::tests::common::MockTransactionSender; use crate::tracking::{self, start_root_task, AutoAbortTask}; use crate::web::{start_web_server, static_web_data}; use assert_matches::assert_matches; +use bounded_collections::BoundedVec; use mpc_contract::primitives::domain::{DomainConfig, SignatureScheme}; -use mpc_contract::primitives::signature::{Bytes, Payload}; +use mpc_contract::primitives::signature::Payload; use near_account_id::AccountId; use near_indexer_primitives::types::Finality; use near_indexer_primitives::CryptoHash; @@ -274,13 +275,21 @@ pub async fn request_signature_and_await_response( SignatureScheme::Secp256k1 | SignatureScheme::V2Secp256k1 => { let mut payload = [0; 32]; rand::thread_rng().fill_bytes(payload.as_mut()); - Payload::Ecdsa(Bytes::new(payload.to_vec()).unwrap()) + + Payload::Ecdsa(payload.into()) } SignatureScheme::Ed25519 => { - let len = rand::thread_rng().gen_range(32..1232); + const LOWER: usize = 32; + const UPPER: usize = 1232; + let len = rand::thread_rng().gen_range(LOWER..=UPPER); + let mut payload = vec![0; len]; rand::thread_rng().fill_bytes(payload.as_mut()); - Payload::Eddsa(Bytes::new(payload.to_vec()).unwrap()) + + let bounded_payload: BoundedVec = + BoundedVec::::try_from(payload).unwrap(); + + Payload::Eddsa(bounded_payload) } SignatureScheme::Bls12381 => unreachable!(), }; diff --git a/crates/node/src/tests/foreign_chain_policy.rs b/crates/node/src/tests/foreign_chain_policy.rs index cc1f85893..142385c72 100644 --- a/crates/node/src/tests/foreign_chain_policy.rs +++ b/crates/node/src/tests/foreign_chain_policy.rs @@ -26,7 +26,7 @@ async fn foreign_chain_policy_auto_vote_on_startup__should_apply_local_policy() DEFAULT_BLOCK_TIME, ); - let providers = non_empty_collections::NonEmptyBTreeMap::new( + let providers = bounded_collections::NonEmptyBTreeMap::new( "public".to_string(), SolanaProviderConfig { rpc_url: "https://rpc.public.example.com".to_string(), diff --git a/crates/non-empty-collections/src/lib.rs b/crates/non-empty-collections/src/lib.rs deleted file mode 100644 index cfc79c68e..000000000 --- a/crates/non-empty-collections/src/lib.rs +++ /dev/null @@ -1,5 +0,0 @@ -mod btreemap; -mod btreeset; - -pub use btreemap::{EmptyMapError, NonEmptyBTreeMap}; -pub use btreeset::{EmptySetError, NonEmptyBTreeSet}; From 02fcf18f5ea17df0f9ad872a86bc2c17776cd3b0 Mon Sep 17 00:00:00 2001 From: Daniel Sharifi Date: Fri, 20 Feb 2026 10:17:36 +0100 Subject: [PATCH 2/6] revise on LLM reviews --- crates/bounded-collections/src/bounded_vec.rs | 99 ++++++++++--------- 1 file changed, 55 insertions(+), 44 deletions(-) diff --git a/crates/bounded-collections/src/bounded_vec.rs b/crates/bounded-collections/src/bounded_vec.rs index 8b9a41e97..b77cb9dd1 100644 --- a/crates/bounded-collections/src/bounded_vec.rs +++ b/crates/bounded-collections/src/bounded_vec.rs @@ -6,7 +6,10 @@ use std::{ use thiserror::Error; -/// Non-empty Vec bounded with minimal (L - lower bound) and maximal (U - upper bound) items quantity. +/// Vec bounded with minimal (L - lower bound) and maximal (U - upper bound) items quantity. +/// +/// By default the witness type is [`witnesses::NonEmpty`], which requires `L > 0`. +/// For a possibly-empty bounded vector (where `L = 0`), use [`EmptyBoundedVec`] instead. /// /// # Type Parameters /// @@ -42,11 +45,15 @@ pub enum BoundedVecOutOfBounds { pub mod witnesses { /// Compile-time proof of valid bounds. Must be constructed with same bounds to instantiate `BoundedVec`. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] - pub struct NonEmpty(()); + pub struct NonEmpty( + (), // private field to prevent direct construction. + ); /// Possibly empty vector with upper bound. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] - pub struct Empty(()); + pub struct PossiblyEmpty( + (), // private field to prevent direct construction. + ); /// Type a compile-time proof of valid bounds pub const fn non_empty() -> NonEmpty { @@ -63,12 +70,12 @@ pub mod witnesses { } /// Type a compile-time proof for possibly empty vector with upper bound - pub const fn empty() -> Empty { - const { Empty::(()) } + pub const fn possibly_empty() -> PossiblyEmpty { + const { PossiblyEmpty::(()) } } } -impl BoundedVec> { +impl BoundedVec> { /// Creates new BoundedVec or returns error if items count is out of bounds /// /// # Parameters @@ -83,11 +90,11 @@ impl BoundedVec> { /// ``` /// use bounded_collections::BoundedVec; /// use bounded_collections::witnesses; - /// let data: BoundedVec<_, 0, 8, witnesses::Empty<8>> = - /// BoundedVec::<_, 0, 8, witnesses::Empty<8>>::from_vec(vec![1u8, 2]).unwrap(); + /// let data: BoundedVec<_, 0, 8, witnesses::PossiblyEmpty<8>> = + /// BoundedVec::<_, 0, 8, witnesses::PossiblyEmpty<8>>::from_vec(vec![1u8, 2]).unwrap(); /// ``` pub fn from_vec(items: Vec) -> Result { - let witness = witnesses::empty::(); + let witness = witnesses::possibly_empty::(); let len = items.len(); if len > U { Err(BoundedVecOutOfBounds::UpperBoundError { @@ -110,7 +117,7 @@ impl BoundedVec> { /// use bounded_collections::witnesses; /// use std::convert::TryInto; /// - /// let data: BoundedVec> = vec![1u8, 2].try_into().unwrap(); + /// let data: BoundedVec> = vec![1u8, 2].try_into().unwrap(); /// assert_eq!(data.first(), Some(&1u8)); /// ``` pub fn first(&self) -> Option<&T> { @@ -125,7 +132,7 @@ impl BoundedVec> { /// use bounded_collections::witnesses; /// use std::convert::TryInto; /// - /// let data: BoundedVec> = vec![1u8, 2].try_into().unwrap(); + /// let data: BoundedVec> = vec![1u8, 2].try_into().unwrap(); /// assert_eq!(data.is_empty(), false); /// ``` pub fn is_empty(&self) -> bool { @@ -140,7 +147,7 @@ impl BoundedVec> { /// use bounded_collections::witnesses; /// use std::convert::TryInto; /// - /// let data: BoundedVec> = vec![1u8, 2].try_into().unwrap(); + /// let data: BoundedVec> = vec![1u8, 2].try_into().unwrap(); /// assert_eq!(data.last(), Some(&2u8)); /// ``` pub fn last(&self) -> Option<&T> { @@ -301,7 +308,7 @@ impl BoundedVec= U, <= L, + /// This is useful as it keeps the knowledge that the length is >= L, <= U, /// even through the old `BoundedVec` is consumed and turned into an iterator. /// /// # Example @@ -324,7 +331,7 @@ impl BoundedVec= U, <= L, + /// This is useful as it keeps the knowledge that the length is >= L, <= U, /// will still hold for new `BoundedVec` /// /// # Example @@ -372,18 +379,21 @@ impl BoundedVec( self, - map_fn: F, + mut map_fn: F, ) -> Result>, E> where F: FnMut(T) -> Result, { - let mut map_fn = map_fn; - let mut out = Vec::with_capacity(self.len()); - for element in self.inner.into_iter() { - out.push(map_fn(element)?); - } + let out = self + .inner + .into_iter() + .map(&mut map_fn) + .collect::, E>>()?; - Ok(BoundedVec::>::from_vec(out).unwrap()) + Ok(BoundedVec { + inner: out, + witness: self.witness, + }) } /// Create a new `BoundedVec` by mapping references of `self` elements @@ -407,18 +417,21 @@ impl BoundedVec( &self, - map_fn: F, + mut map_fn: F, ) -> Result>, E> where F: FnMut(&T) -> Result, { - let mut map_fn = map_fn; - let mut out = Vec::with_capacity(self.len()); - for element in self.inner.iter() { - out.push(map_fn(element)?); - } - - Ok(BoundedVec::>::from_vec(out).unwrap()) + let out = self + .inner + .iter() + .map(&mut map_fn) + .collect::, E>>()?; + + Ok(BoundedVec { + inner: out, + witness: self.witness, + }) } /// Returns the last and all the rest of the elements @@ -428,12 +441,10 @@ impl BoundedVec BoundedVec<(usize, T), L, U, witnesses::NonEmpty> { - self.inner - .into_iter() - .enumerate() - .collect::>() - .try_into() - .unwrap() + BoundedVec { + inner: self.inner.into_iter().enumerate().collect(), + witness: self.witness, + } } /// Return a Some(BoundedVec) or None if `v` is empty @@ -464,7 +475,7 @@ impl BoundedVec = BoundedVec>; /// Possibly empty Vec with upper-bound on its length -pub type EmptyBoundedVec = BoundedVec>; +pub type EmptyBoundedVec = BoundedVec>; /// Non-empty Vec with bounded length pub type NonEmptyBoundedVec = @@ -480,7 +491,7 @@ impl TryFrom> } } -impl TryFrom> for BoundedVec> { +impl TryFrom> for BoundedVec> { type Error = BoundedVecOutOfBounds; fn try_from(value: Vec) -> Result { @@ -508,8 +519,8 @@ impl From From>> for Vec { - fn from(v: BoundedVec>) -> Self { +impl From>> for Vec { + fn from(v: BoundedVec>) -> Self { v.inner } } @@ -549,7 +560,7 @@ impl AsRef> for BoundedVec AsRef<[T]> for BoundedVec { fn as_ref(&self) -> &[T] { - self.inner.as_ref() + self.inner.as_slice() } } @@ -621,7 +632,7 @@ mod borsh_impl { } impl BorshDeserialize - for BoundedVec> + for BoundedVec> { fn deserialize_reader(reader: &mut R) -> std::io::Result { let inner = Vec::::deserialize_reader(reader)?; @@ -707,8 +718,8 @@ mod serde_impl { let mut schema = >::json_schema(generator); if let schemars::schema::Schema::Object(ref mut obj) = schema { if let Some(ref mut array) = obj.array { - array.min_items = Some(L as u32); - array.max_items = Some(U as u32); + array.min_items = u32::try_from(L).ok(); + array.max_items = u32::try_from(U).ok(); } } schema @@ -803,7 +814,7 @@ mod tests { result, Ok(BoundedVec { inner: vec![], - witness: witnesses::empty() + witness: witnesses::possibly_empty() }) ); } From fdaa925db3ba20831ccf67f2727bda34571e1e0b Mon Sep 17 00:00:00 2001 From: Daniel Sharifi Date: Fri, 20 Feb 2026 10:46:02 +0100 Subject: [PATCH 3/6] create separate module for with hex --- Cargo.lock | 1 + crates/bounded-collections/Cargo.toml | 1 + crates/bounded-collections/src/bounded_vec.rs | 120 ++++++++++++++++++ crates/bounded-collections/src/lib.rs | 2 +- crates/contract/src/primitives/signature.rs | 82 +++--------- .../snapshots/abi__abi_has_not_changed.snap | 36 ++---- 6 files changed, 155 insertions(+), 87 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 08a794274..447b4bdef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1429,6 +1429,7 @@ dependencies = [ "assert_matches", "borsh", "derive_more 2.1.1", + "hex", "rstest", "schemars 0.8.22", "serde", diff --git a/crates/bounded-collections/Cargo.toml b/crates/bounded-collections/Cargo.toml index cd8ffe290..7278f05c6 100644 --- a/crates/bounded-collections/Cargo.toml +++ b/crates/bounded-collections/Cargo.toml @@ -10,6 +10,7 @@ abi = ["borsh/unstable__schema", "schemars"] [dependencies] borsh = { workspace = true } derive_more = { workspace = true } +hex = { workspace = true } serde = { workspace = true } thiserror = { workspace = true } diff --git a/crates/bounded-collections/src/bounded_vec.rs b/crates/bounded-collections/src/bounded_vec.rs index b77cb9dd1..6c9c4ca9d 100644 --- a/crates/bounded-collections/src/bounded_vec.rs +++ b/crates/bounded-collections/src/bounded_vec.rs @@ -728,6 +728,85 @@ mod serde_impl { } } +/// Serde helper for serializing/deserializing `BoundedVec` as a hex string. +/// +/// Use with `#[serde(with = "bounded_collections::hex_serde")]` on fields +/// whose type is `BoundedVec`. +/// +/// When the `abi` feature is enabled, pair with +/// `#[schemars(with = "bounded_collections::hex_serde::HexString")]` +/// to generate a string schema with hex length constraints. +/// +/// # Example +/// ```ignore +/// use bounded_collections::BoundedVec; +/// +/// #[derive(serde::Serialize, serde::Deserialize)] +/// #[cfg_attr(feature = "abi", derive(schemars::JsonSchema))] +/// struct MyStruct { +/// #[serde(with = "bounded_collections::hex_serde")] +/// #[cfg_attr(feature = "abi", schemars(with = "bounded_collections::hex_serde::HexString<1, 64>"))] +/// data: BoundedVec, +/// } +/// ``` +pub mod hex_serde { + use super::*; + use serde::Deserialize; + + #[cfg(all(feature = "abi", not(target_arch = "wasm32")))] + const HEX_PATTERN: &str = "^[0-9a-fA-F]*$"; + + pub fn serialize( + value: &BoundedVec, + serializer: S, + ) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&hex::encode(value.as_slice())) + } + + pub fn deserialize<'de, D, const L: usize, const U: usize>( + deserializer: D, + ) -> Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let hex_str = String::deserialize(deserializer)?; + let bytes: Vec = hex::decode(&hex_str).map_err(serde::de::Error::custom)?; + bytes.try_into().map_err(serde::de::Error::custom) + } + + /// Marker type for JSON schema generation of hex-encoded `BoundedVec`. + /// + /// Use with `#[schemars(with = "bounded_collections::hex_serde::HexString")]` + /// alongside `#[serde(with = "bounded_collections::hex_serde")]`. + #[cfg(all(feature = "abi", not(target_arch = "wasm32")))] + pub struct HexString; + + #[cfg(all(feature = "abi", not(target_arch = "wasm32")))] + impl schemars::JsonSchema for HexString { + fn schema_name() -> String { + format!("HexString_Min{}_Max{}", L, U) + } + + fn json_schema( + _generator: &mut schemars::r#gen::SchemaGenerator, + ) -> schemars::schema::Schema { + schemars::schema::SchemaObject { + instance_type: Some(schemars::schema::InstanceType::String.into()), + string: Some(Box::new(schemars::schema::StringValidation { + min_length: Some((L * 2) as u32), + max_length: Some((U * 2) as u32), + pattern: Some(HEX_PATTERN.to_string()), + })), + ..Default::default() + } + .into() + } + } +} + #[cfg(test)] mod tests { use assert_matches::assert_matches; @@ -1083,6 +1162,47 @@ mod serde_tests { } } +#[cfg(test)] +mod hex_serde_tests { + use assert_matches::assert_matches; + + use super::*; + + #[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq)] + struct Wrapper { + #[serde(with = "hex_serde")] + data: BoundedVec, + } + + #[test] + fn roundtrip() { + let original = Wrapper { + data: vec![0xAB, 0xCD, 0xEF].try_into().unwrap(), + }; + let json = serde_json::to_string(&original).unwrap(); + assert_eq!(json, r#"{"data":"abcdef"}"#); + let deserialized: Wrapper = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized, original); + } + + #[test] + fn rejects_invalid_hex() { + let json = r#"{"data":"zzzz"}"#; + assert_matches!(serde_json::from_str::(json), Err(_)); + } + + #[test] + fn rejects_out_of_bounds() { + // 1 byte is below lower bound of 2 + let json = r#"{"data":"ab"}"#; + assert_matches!(serde_json::from_str::(json), Err(_)); + + // 5 bytes exceeds upper bound of 4 + let json = r#"{"data":"abcdef0102"}"#; + assert_matches!(serde_json::from_str::(json), Err(_)); + } +} + #[cfg(test)] mod borsh_tests { use super::*; diff --git a/crates/bounded-collections/src/lib.rs b/crates/bounded-collections/src/lib.rs index 95bf4818e..96ffed446 100644 --- a/crates/bounded-collections/src/lib.rs +++ b/crates/bounded-collections/src/lib.rs @@ -4,7 +4,7 @@ mod btreeset; pub use bounded_vec::{ BoundedVec, BoundedVecOutOfBounds, EmptyBoundedVec, NonEmptyBoundedVec, NonEmptyVec, - OptBoundedVecToVec, witnesses, + OptBoundedVecToVec, hex_serde, witnesses, }; pub use btreemap::{EmptyMapError, NonEmptyBTreeMap}; pub use btreeset::{EmptySetError, NonEmptyBTreeSet}; diff --git a/crates/contract/src/primitives/signature.rs b/crates/contract/src/primitives/signature.rs index 9e62070e8..9da85147e 100644 --- a/crates/contract/src/primitives/signature.rs +++ b/crates/contract/src/primitives/signature.rs @@ -1,7 +1,7 @@ use crate::crypto_shared; use crate::errors::{Error, InvalidParameters}; use crate::DomainId; -use bounded_collections::BoundedVec; +use bounded_collections::{hex_serde, BoundedVec}; use crypto_shared::derive_tweak; use near_account_id::AccountId; use near_sdk::{near, CryptoHash}; @@ -24,70 +24,24 @@ impl Tweak { /// A signature payload; the right payload must be passed in for the curve. /// The json encoding for this payload converts the bytes to hex string. #[derive(Debug, Clone, Eq, Ord, PartialEq, PartialOrd)] -#[cfg_attr( - all(feature = "abi", not(target_arch = "wasm32")), - derive(schemars::JsonSchema) -)] -#[near(serializers=[borsh])] +#[near(serializers=[borsh, json])] pub enum Payload { - Ecdsa(BoundedVec), - Eddsa(BoundedVec), -} - -/// Custom JSON serialization preserving the hex-encoded string format -/// used by the original `Bytes` type, so the on-chain API stays backwards-compatible. -impl near_sdk::serde::Serialize for Payload { - fn serialize(&self, serializer: S) -> Result - where - S: near_sdk::serde::Serializer, - { - #[derive(near_sdk::serde::Serialize)] - #[serde(crate = "near_sdk::serde")] - enum Helper<'a> { - Ecdsa(&'a str), - Eddsa(&'a str), - } - match self { - Payload::Ecdsa(bytes) => { - let hex = hex::encode(bytes.as_slice()); - Helper::Ecdsa(&hex).serialize(serializer) - } - Payload::Eddsa(bytes) => { - let hex = hex::encode(bytes.as_slice()); - Helper::Eddsa(&hex).serialize(serializer) - } - } - } -} - -impl<'de> near_sdk::serde::Deserialize<'de> for Payload { - fn deserialize(deserializer: D) -> Result - where - D: near_sdk::serde::Deserializer<'de>, - { - #[derive(near_sdk::serde::Deserialize)] - #[serde(crate = "near_sdk::serde")] - enum Helper { - Ecdsa(String), - Eddsa(String), - } - match Helper::deserialize(deserializer)? { - Helper::Ecdsa(hex) => { - let bytes = hex::decode(&hex).map_err(near_sdk::serde::de::Error::custom)?; - let bounded: BoundedVec = bytes - .try_into() - .map_err(near_sdk::serde::de::Error::custom)?; - Ok(Payload::Ecdsa(bounded)) - } - Helper::Eddsa(hex) => { - let bytes = hex::decode(&hex).map_err(near_sdk::serde::de::Error::custom)?; - let bounded: BoundedVec = bytes - .try_into() - .map_err(near_sdk::serde::de::Error::custom)?; - Ok(Payload::Eddsa(bounded)) - } - } - } + Ecdsa( + #[serde(with = "hex_serde")] + #[cfg_attr( + all(feature = "abi", not(target_arch = "wasm32")), + schemars(with = "hex_serde::HexString<32, 32>") + )] + BoundedVec, + ), + Eddsa( + #[serde(with = "hex_serde")] + #[cfg_attr( + all(feature = "abi", not(target_arch = "wasm32")), + schemars(with = "hex_serde::HexString<32, 1232>") + )] + BoundedVec, + ), } impl Payload { diff --git a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap index 1d9382f50..733d68f7a 100644 --- a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap +++ b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap @@ -1460,26 +1460,6 @@ expression: abi "Bls12381G2PublicKey": { "type": "string" }, - "BoundedVec_uint8_Min32_Max1232": { - "type": "array", - "items": { - "type": "integer", - "format": "uint8", - "minimum": 0.0 - }, - "maxItems": 1232, - "minItems": 32 - }, - "BoundedVec_uint8_Min32_Max32": { - "type": "array", - "items": { - "type": "integer", - "format": "uint8", - "minimum": 0.0 - }, - "maxItems": 32, - "minItems": 32 - }, "CKDRequest": { "type": "object", "required": [ @@ -2057,6 +2037,18 @@ expression: abi "type": "string", "pattern": "^(?:[0-9A-Fa-f]{2})*$" }, + "HexString_Min32_Max1232": { + "type": "string", + "maxLength": 2464, + "minLength": 64, + "pattern": "^[0-9a-fA-F]*$" + }, + "HexString_Min32_Max32": { + "type": "string", + "maxLength": 64, + "minLength": 64, + "pattern": "^[0-9a-fA-F]*$" + }, "InitConfig": { "description": "The initial configuration parameters for when initializing the contract. All fields are optional, as the contract can fill in defaults for any missing fields.", "type": "object", @@ -2634,7 +2626,7 @@ expression: abi ], "properties": { "Ecdsa": { - "$ref": "#/definitions/BoundedVec_uint8_Min32_Max32" + "$ref": "#/definitions/HexString_Min32_Max32" } }, "additionalProperties": false @@ -2646,7 +2638,7 @@ expression: abi ], "properties": { "Eddsa": { - "$ref": "#/definitions/BoundedVec_uint8_Min32_Max1232" + "$ref": "#/definitions/HexString_Min32_Max1232" } }, "additionalProperties": false From 633b06efa804210e3b6d3625df1cfd5f69503989 Mon Sep 17 00:00:00 2001 From: Daniel Sharifi Date: Fri, 20 Feb 2026 10:51:03 +0100 Subject: [PATCH 4/6] move bounds to conts --- crates/contract/src/primitives/signature.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/crates/contract/src/primitives/signature.rs b/crates/contract/src/primitives/signature.rs index 9da85147e..269c096f3 100644 --- a/crates/contract/src/primitives/signature.rs +++ b/crates/contract/src/primitives/signature.rs @@ -7,6 +7,10 @@ use near_account_id::AccountId; use near_sdk::{near, CryptoHash}; use std::fmt::Debug; +const ECDSA_PAYLOAD_SIZE_BYTES: usize = 32; +const EDDSA_PAYLOAD_SIZE_LOWER_BOUND_BYTES: usize = 32; +const EDDSA_PAYLOAD_SIZE_UPPER_BOUND_BYTES: usize = 1232; + #[derive(Debug, Clone, Eq, Ord, PartialEq, PartialOrd)] #[near(serializers=[borsh, json])] pub struct Tweak([u8; 32]); @@ -30,17 +34,21 @@ pub enum Payload { #[serde(with = "hex_serde")] #[cfg_attr( all(feature = "abi", not(target_arch = "wasm32")), - schemars(with = "hex_serde::HexString<32, 32>") + schemars( + with = "hex_serde::HexString" + ) )] - BoundedVec, + BoundedVec, ), Eddsa( #[serde(with = "hex_serde")] #[cfg_attr( all(feature = "abi", not(target_arch = "wasm32")), - schemars(with = "hex_serde::HexString<32, 1232>") + schemars( + with = "hex_serde::HexString" + ) )] - BoundedVec, + BoundedVec, ), } From 9e78903ff6db74cd2fb64291a13f56f33509a45b Mon Sep 17 00:00:00 2001 From: Daniel Sharifi <40335219+DSharifi@users.noreply.github.com> Date: Fri, 20 Feb 2026 11:14:27 +0100 Subject: [PATCH 5/6] Delete crates/near-mpc-sdk/src/sign.rs --- crates/near-mpc-sdk/src/sign.rs | 1 - 1 file changed, 1 deletion(-) delete mode 100644 crates/near-mpc-sdk/src/sign.rs diff --git a/crates/near-mpc-sdk/src/sign.rs b/crates/near-mpc-sdk/src/sign.rs deleted file mode 100644 index 8b1378917..000000000 --- a/crates/near-mpc-sdk/src/sign.rs +++ /dev/null @@ -1 +0,0 @@ - From 338609a1c309dbd94bb58c59fe36f2fd53866079 Mon Sep 17 00:00:00 2001 From: Daniel Sharifi <40335219+DSharifi@users.noreply.github.com> Date: Fri, 20 Feb 2026 11:24:03 +0100 Subject: [PATCH 6/6] Update crates/contract/Cargo.toml --- crates/contract/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/contract/Cargo.toml b/crates/contract/Cargo.toml index d4f78f8ef..8cd225251 100644 --- a/crates/contract/Cargo.toml +++ b/crates/contract/Cargo.toml @@ -55,7 +55,7 @@ bounded-collections = { workspace = true } contract-interface = { workspace = true } curve25519-dalek = { workspace = true } derive_more = { workspace = true, features = ["from", "deref"] } -hex = { workspace = true } +hex = { workspace = true, features = [] } k256 = { workspace = true, features = [ "sha256", "ecdsa",