From 7d172e1108a5129003e5e0583e14c97d34e7e07c Mon Sep 17 00:00:00 2001 From: Daniel Sharifi Date: Fri, 20 Feb 2026 09:55:26 +0100 Subject: [PATCH 01/27] . --- 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 02/27] 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 03/27] 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 04/27] 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 05/27] 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 06/27] 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", From a41207771ef57c3794470d13abc11a3806e80316 Mon Sep 17 00:00:00 2001 From: Daniel Sharifi Date: Fri, 20 Feb 2026 11:48:11 +0100 Subject: [PATCH 07/27] just bounded vec --- Cargo.lock | 1 - crates/contract/src/lib.rs | 156 ++---------------- crates/contract/src/primitives/signature.rs | 90 ++++++++-- 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 | 28 ++-- crates/devnet/Cargo.toml | 1 - crates/devnet/src/contracts.rs | 16 +- crates/devnet/src/types.rs | 2 +- crates/node/src/keyshare/permanent.rs | 1 + .../src/providers/verify_foreign_tx/sign.rs | 7 +- crates/node/src/tests.rs | 17 +- 13 files changed, 118 insertions(+), 323 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 447b4bdef..eece587fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5313,7 +5313,6 @@ version = "3.5.0" dependencies = [ "anyhow", "borsh", - "bounded-collections", "bs58 0.5.1", "clap", "contract-interface", diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index 9bf9d1ef5..30a448d0e 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -125,20 +125,8 @@ 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, 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, - ), - } - } -} +#[derive(Debug, Default, BorshSerialize, BorshDeserialize)] +struct StaleData {} #[near(serializers=[borsh])] #[derive(Debug)] @@ -717,18 +705,7 @@ impl MpcContract { env::promise_yield_resume(&data_id, serde_json::to_vec(&response).unwrap()); Ok(()) } else { - // 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()) - } + Err(InvalidParameters::RequestNotFound.into()) } } @@ -1521,7 +1498,7 @@ impl MpcContract { Keyset::new(EpochId::new(0), Vec::new()), parameters, )), - pending_signature_requests: LookupMap::new(StorageKey::PendingSignatureRequestsV3), + pending_signature_requests: LookupMap::new(StorageKey::PendingSignatureRequestsV2), pending_ckd_requests: LookupMap::new(StorageKey::PendingCKDRequests), pending_verify_foreign_tx_requests: LookupMap::new( StorageKey::PendingVerifyForeignTxRequests, @@ -1533,7 +1510,7 @@ impl MpcContract { tee_state, accept_requests: true, node_migrations: NodeMigrations::default(), - stale_data: StaleData::new(), + stale_data: Default::default(), metrics: Default::default(), }) } @@ -1583,7 +1560,7 @@ impl MpcContract { protocol_state: ProtocolContractState::Running(RunningContractState::new( domains, keyset, parameters, )), - pending_signature_requests: LookupMap::new(StorageKey::PendingSignatureRequestsV3), + pending_signature_requests: LookupMap::new(StorageKey::PendingSignatureRequestsV2), pending_ckd_requests: LookupMap::new(StorageKey::PendingCKDRequests), pending_verify_foreign_tx_requests: LookupMap::new( StorageKey::PendingVerifyForeignTxRequests, @@ -1594,7 +1571,7 @@ impl MpcContract { tee_state, accept_requests: true, node_migrations: NodeMigrations::default(), - stale_data: StaleData::new(), + stale_data: Default::default(), metrics: Default::default(), }) } @@ -1654,16 +1631,7 @@ impl MpcContract { } pub fn get_pending_request(&self, request: &SignatureRequest) -> Option { - 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() - }) + self.pending_signature_requests.get(request).cloned() } pub fn get_pending_ckd_request(&self, request: &CKDRequest) -> Option { @@ -1708,16 +1676,7 @@ impl MpcContract { match signature { Ok(signature) => PromiseOrValue::Value(signature), Err(_) => { - 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); - } - + self.pending_signature_requests.remove(&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(), @@ -2091,7 +2050,6 @@ 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, @@ -2111,6 +2069,7 @@ mod tests { }; use mpc_primitives::hash::{Hash32, Image}; use near_sdk::{test_utils::VMContextBuilder, testing_env, NearToken, VMContext}; + use bounded_collections::NonEmptyBTreeSet; use primitives::key_state::{AttemptId, KeyForDomain}; use rand::seq::SliceRandom; use rand::SeedableRng; @@ -2403,97 +2362,6 @@ 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) = @@ -3117,7 +2985,7 @@ mod tests { pub fn new_from_protocol_state(protocol_state: ProtocolContractState) -> Self { MpcContract { protocol_state, - pending_signature_requests: LookupMap::new(StorageKey::PendingSignatureRequestsV3), + pending_signature_requests: LookupMap::new(StorageKey::PendingSignatureRequestsV2), pending_ckd_requests: LookupMap::new(StorageKey::PendingCKDRequests), pending_verify_foreign_tx_requests: LookupMap::new( StorageKey::PendingVerifyForeignTxRequests, @@ -3129,7 +2997,7 @@ mod tests { config: Default::default(), tee_state: Default::default(), node_migrations: Default::default(), - stale_data: StaleData::new(), + stale_data: Default::default(), metrics: Default::default(), } } diff --git a/crates/contract/src/primitives/signature.rs b/crates/contract/src/primitives/signature.rs index 269c096f3..1e588d9b4 100644 --- a/crates/contract/src/primitives/signature.rs +++ b/crates/contract/src/primitives/signature.rs @@ -1,16 +1,11 @@ use crate::crypto_shared; use crate::errors::{Error, InvalidParameters}; use crate::DomainId; -use bounded_collections::{hex_serde, BoundedVec}; use crypto_shared::derive_tweak; 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]); @@ -31,47 +26,106 @@ impl Tweak { #[near(serializers=[borsh, json])] pub enum Payload { Ecdsa( - #[serde(with = "hex_serde")] #[cfg_attr( all(feature = "abi", not(target_arch = "wasm32")), - schemars( - with = "hex_serde::HexString" - ) + schemars(with = "[u8; 32]"), + borsh(schema(with_funcs( + declaration = "<[u8; 32] as ::borsh::BorshSchema>::declaration", + definitions = "<[u8; 32] as ::borsh::BorshSchema>::add_definitions_recursively" + ),)) )] - BoundedVec, + Bytes<32, 32>, ), Eddsa( - #[serde(with = "hex_serde")] #[cfg_attr( all(feature = "abi", not(target_arch = "wasm32")), - schemars( - with = "hex_serde::HexString" - ) + schemars(with = "Vec"), + borsh(schema(with_funcs( + declaration = " as ::borsh::BorshSchema>::declaration", + definitions = " as ::borsh::BorshSchema>::add_definitions_recursively" + ),)) )] - BoundedVec, + Bytes<32, 1232>, ), } impl Payload { pub fn from_legacy_ecdsa(bytes: [u8; 32]) -> Self { - Payload::Ecdsa(bytes.into()) + Payload::Ecdsa(Bytes::new(bytes.to_vec()).unwrap()) } pub fn as_ecdsa(&self) -> Option<&[u8; 32]> { match self { - Payload::Ecdsa(bytes) => Some(bytes.as_ref()), + Payload::Ecdsa(bytes) => Some(bytes.as_fixed_bytes()), _ => None, } } pub fn as_eddsa(&self) -> Option<&[u8]> { match self { - Payload::Eddsa(bytes) => Some(bytes.as_slice()), + Payload::Eddsa(bytes) => Some(bytes.as_bytes()), _ => None, } } } +/// 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()); + } + 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 near_sdk::serde::Serialize + for Bytes +{ + fn serialize(&self, serializer: S) -> Result + where + S: near_sdk::serde::Serializer, + { + hex::encode(&self.0).serialize(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 = 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) + } +} + /// The index into calling the YieldResume feature of NEAR. This will allow to resume /// a yield call after the contract has been called back via this index. #[derive(Debug, Clone)] diff --git a/crates/contract/src/storage_keys.rs b/crates/contract/src/storage_keys.rs index 74b92cb8d..072f0e4fc 100644 --- a/crates/contract/src/storage_keys.rs +++ b/crates/contract/src/storage_keys.rs @@ -19,5 +19,4 @@ 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 c26525ced..7310dbb0a 100644 --- a/crates/contract/src/v3_4_1_state.rs +++ b/crates/contract/src/v3_4_1_state.rs @@ -13,7 +13,6 @@ use near_sdk::{env, near, store::LookupMap}; use std::collections::{BTreeMap, BTreeSet, HashSet}; use crate::{ - errors::{Error, InvalidParameters}, node_migrations::NodeMigrations, primitives::{ ckd::CKDRequest, @@ -22,19 +21,15 @@ use crate::{ AttemptId, AuthenticatedAccountId, AuthenticatedParticipantId, EpochId, KeyForDomain, Keyset, }, - signature::{Tweak, YieldIndex}, + signature::{SignatureRequest, YieldIndex}, thresholds::ThresholdParameters, votes::ThresholdParametersVotes, }, tee::tee_state::TeeState, update::ProposedUpdates, - Config, ForeignChainPolicyVotes, StorageKey, + Config, ForeignChainPolicyVotes, StaleData, 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, @@ -47,7 +42,7 @@ pub struct MpcContract { tee_state: TeeState, accept_requests: bool, node_migrations: NodeMigrations, - stale_data: OldStaleData, + stale_data: StaleData, } impl From for crate::MpcContract { @@ -60,7 +55,7 @@ impl From for crate::MpcContract { Self { protocol_state, - pending_signature_requests: LookupMap::new(StorageKey::PendingSignatureRequestsV3), + pending_signature_requests: value.pending_signature_requests, pending_ckd_requests: value.pending_ckd_requests, pending_verify_foreign_tx_requests: LookupMap::new( StorageKey::PendingVerifyForeignTxRequests, @@ -72,9 +67,7 @@ impl From for crate::MpcContract { tee_state: value.tee_state, accept_requests: value.accept_requests, node_migrations: value.node_migrations, - stale_data: crate::StaleData { - pending_signature_requests_pre_upgrade: value.pending_signature_requests, - }, + stale_data: crate::StaleData {}, metrics: Default::default(), } } @@ -248,102 +241,3 @@ 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 d1421642d..7e43f633a 100644 --- a/crates/contract/tests/sandbox/utils/sign_utils.rs +++ b/crates/contract/tests/sandbox/utils/sign_utils.rs @@ -4,7 +4,6 @@ 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, @@ -26,7 +25,7 @@ use mpc_contract::{ primitives::{ ckd::{CKDRequest, CKDRequestArgs}, domain::DomainId, - signature::{Payload, SignRequestArgs, SignatureRequest, YieldIndex}, + signature::{Bytes, Payload, SignRequestArgs, SignatureRequest, YieldIndex}, }, }; use near_account_id::AccountId; @@ -529,7 +528,7 @@ fn create_response_ed25519( .try_into() .unwrap(); - let bytes = BoundedVec::from(payload); + let bytes = Bytes::new(payload.into()).unwrap(); 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 733d68f7a..0da5a1fb9 100644 --- a/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap +++ b/crates/contract/tests/snapshots/abi__abi_has_not_changed.snap @@ -2037,18 +2037,6 @@ 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", @@ -2626,7 +2614,14 @@ expression: abi ], "properties": { "Ecdsa": { - "$ref": "#/definitions/HexString_Min32_Max32" + "type": "array", + "items": { + "type": "integer", + "format": "uint8", + "minimum": 0.0 + }, + "maxItems": 32, + "minItems": 32 } }, "additionalProperties": false @@ -2638,7 +2633,12 @@ expression: abi ], "properties": { "Eddsa": { - "$ref": "#/definitions/HexString_Min32_Max1232" + "type": "array", + "items": { + "type": "integer", + "format": "uint8", + "minimum": 0.0 + } } }, "additionalProperties": false diff --git a/crates/devnet/Cargo.toml b/crates/devnet/Cargo.toml index ea6cf716e..24dbc5877 100644 --- a/crates/devnet/Cargo.toml +++ b/crates/devnet/Cargo.toml @@ -7,7 +7,6 @@ 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 968d744a4..9e11b6537 100644 --- a/crates/devnet/src/contracts.rs +++ b/crates/devnet/src/contracts.rs @@ -1,11 +1,10 @@ use std::collections::BTreeMap; -use bounded_collections::BoundedVec; use contract_interface::method_names; use mpc_contract::primitives::{ ckd::CKDRequestArgs, domain::{DomainConfig, SignatureScheme}, - signature::{Payload, SignRequestArgs}, + signature::{Bytes, Payload, SignRequestArgs}, }; use near_account_id::AccountId; use near_primitives::action::Action; @@ -166,20 +165,13 @@ struct ParallelSignArgsV2 { fn make_payload(scheme: SignatureScheme) -> Payload { match scheme { SignatureScheme::Secp256k1 | SignatureScheme::V2Secp256k1 => { - Payload::Ecdsa(rand::random::<[u8; 32]>().into()) + Payload::Ecdsa(Bytes::new(rand::random::<[u8; 32]>().to_vec()).unwrap()) } SignatureScheme::Ed25519 => { - const LOWER: usize = 32; - const UPPER: usize = 1232; - let len = rand::random_range(LOWER..=UPPER); - + let len = rand::random_range(32..=1232); let mut payload = vec![0; len]; rand::rng().fill_bytes(&mut payload); - - let bounded_payload: BoundedVec = - BoundedVec::::try_from(payload).unwrap(); - - Payload::Eddsa(bounded_payload) + Payload::Eddsa(Bytes::new(payload).unwrap()) } SignatureScheme::Bls12381 => { unreachable!("make_payload should not be called with `Bls12381` scheme") diff --git a/crates/devnet/src/types.rs b/crates/devnet/src/types.rs index 260bede4b..fb986b3df 100644 --- a/crates/devnet/src/types.rs +++ b/crates/devnet/src/types.rs @@ -182,7 +182,7 @@ pub async fn load_config() -> ParsedConfig { } /// 1. We need to serialize the keys with [`bs58`] encoding, to maintain -/// backwards compatibility with the binary version previous to https://github.com/near/mpc/issues/880 +/// backwards compatibility with the binary version previous to /// which removes [`near_crypto`] representation of keys on the node in favor of the [`ed25519_dalek`] crate. /// /// 2. [`serde_yaml`] serialization will fail as [`SigningKey`] and [`VerifyingKey`] serialize into bytes, and [`serde_yaml`] does diff --git a/crates/node/src/keyshare/permanent.rs b/crates/node/src/keyshare/permanent.rs index 47ff3cca9..a1b4eb6f3 100644 --- a/crates/node/src/keyshare/permanent.rs +++ b/crates/node/src/keyshare/permanent.rs @@ -90,6 +90,7 @@ impl PermanentKeyStorage { Ok(data.map(|data| serde_json::from_slice(&data)).transpose()?) } + #[allow(rustdoc::private_intra_doc_links)] /// Attempts to store the given [`PermanentKeyshareData`] in persistent storage, /// replacing existing data only if the new keyset is considered valid. /// diff --git a/crates/node/src/providers/verify_foreign_tx/sign.rs b/crates/node/src/providers/verify_foreign_tx/sign.rs index 04f605f2c..a4d46b8d6 100644 --- a/crates/node/src/providers/verify_foreign_tx/sign.rs +++ b/crates/node/src/providers/verify_foreign_tx/sign.rs @@ -20,9 +20,8 @@ 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::{Payload, Tweak}; +use mpc_contract::primitives::signature::{Bytes, Payload, Tweak}; use near_indexer_primitives::CryptoHash; use tokio::time::{timeout, Duration}; @@ -33,8 +32,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: BoundedVec = payload_hash.into(); - + let payload_bytes = + Bytes::new(payload_hash.to_vec()).map_err(|err| anyhow::format_err!("{err}"))?; Ok(SignatureRequest { id: request.id, receipt_id: request.receipt_id, diff --git a/crates/node/src/tests.rs b/crates/node/src/tests.rs index 8a94b1e29..fc962cb94 100644 --- a/crates/node/src/tests.rs +++ b/crates/node/src/tests.rs @@ -32,9 +32,8 @@ 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::Payload; +use mpc_contract::primitives::signature::{Bytes, Payload}; use near_account_id::AccountId; use near_indexer_primitives::types::Finality; use near_indexer_primitives::CryptoHash; @@ -275,21 +274,13 @@ 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(payload.into()) + Payload::Ecdsa(Bytes::new(payload.to_vec()).unwrap()) } SignatureScheme::Ed25519 => { - const LOWER: usize = 32; - const UPPER: usize = 1232; - let len = rand::thread_rng().gen_range(LOWER..=UPPER); - + let len = rand::thread_rng().gen_range(32..1232); let mut payload = vec![0; len]; rand::thread_rng().fill_bytes(payload.as_mut()); - - let bounded_payload: BoundedVec = - BoundedVec::::try_from(payload).unwrap(); - - Payload::Eddsa(bounded_payload) + Payload::Eddsa(Bytes::new(payload.to_vec()).unwrap()) } SignatureScheme::Bls12381 => unreachable!(), }; From f3fff6ff71d76855b5a81534281f1bdaf8fdad17 Mon Sep 17 00:00:00 2001 From: Daniel Sharifi Date: Fri, 20 Feb 2026 11:51:16 +0100 Subject: [PATCH 08/27] cargo fmt --- crates/contract/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/contract/src/lib.rs b/crates/contract/src/lib.rs index 30a448d0e..d30cbf476 100644 --- a/crates/contract/src/lib.rs +++ b/crates/contract/src/lib.rs @@ -2050,6 +2050,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 +2070,6 @@ mod tests { }; use mpc_primitives::hash::{Hash32, Image}; use near_sdk::{test_utils::VMContextBuilder, testing_env, NearToken, VMContext}; - use bounded_collections::NonEmptyBTreeSet; use primitives::key_state::{AttemptId, KeyForDomain}; use rand::seq::SliceRandom; use rand::SeedableRng; From 4f8ef1f41f0271214dc75a7e6fc8789093ca607e Mon Sep 17 00:00:00 2001 From: Daniel Sharifi Date: Fri, 20 Feb 2026 11:56:49 +0100 Subject: [PATCH 09/27] fix doc tests --- crates/bounded-collections/src/bounded_vec.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/bounded-collections/src/bounded_vec.rs b/crates/bounded-collections/src/bounded_vec.rs index 6c9c4ca9d..f269ef0a5 100644 --- a/crates/bounded-collections/src/bounded_vec.rs +++ b/crates/bounded-collections/src/bounded_vec.rs @@ -76,7 +76,7 @@ pub mod witnesses { } impl BoundedVec> { - /// Creates new BoundedVec or returns error if items count is out of bounds + /// Creates new [`BoundedVec`] or returns error if items count is out of bounds /// /// # Parameters /// @@ -109,7 +109,7 @@ impl BoundedVec> { } } - /// Returns the first element of the vector, or `None` if it is empty + /// Returns the first element of the vector, or [`None`] if it is empty /// /// # Example /// ``` @@ -139,7 +139,7 @@ impl BoundedVec> { self.inner.is_empty() } - /// Returns the last element of the vector, or `None` if it is empty + /// Returns the last element of the vector, or [`None`] if it is empty /// /// # Example /// ``` @@ -157,7 +157,7 @@ impl BoundedVec> { /// Methods which works for all witnesses impl BoundedVec { - /// Returns a reference to underlying `Vec` + /// Returns a reference to underlying [`Vec`] /// /// # Example /// ``` @@ -171,7 +171,7 @@ impl BoundedVec { &self.inner } - /// Returns an underlying `Vec` + /// Returns an underlying [`Vec`] /// /// # Example /// ``` @@ -595,9 +595,9 @@ impl AsRef<[T; N]> for BoundedVec> to Vec +/// [`Option>`] to [`Vec`] pub trait OptBoundedVecToVec { - /// Option> to Vec + /// [`Option>`] to [`Vec`] fn to_vec(self) -> Vec; } From c8dfdfeb6d4f73c5f452bd8b2557e86a5dc502bf Mon Sep 17 00:00:00 2001 From: Daniel Sharifi Date: Fri, 20 Feb 2026 15:01:22 +0100 Subject: [PATCH 10/27] rename into_vec --- crates/bounded-collections/src/bounded_vec.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/bounded-collections/src/bounded_vec.rs b/crates/bounded-collections/src/bounded_vec.rs index f269ef0a5..d84d79f96 100644 --- a/crates/bounded-collections/src/bounded_vec.rs +++ b/crates/bounded-collections/src/bounded_vec.rs @@ -179,9 +179,9 @@ impl BoundedVec { /// use std::convert::TryInto; /// /// let data: BoundedVec<_, 2, 8> = vec![1u8, 2].try_into().unwrap(); - /// assert_eq!(data.to_vec(), vec![1u8,2]); + /// assert_eq!(data.into_vec(), vec![1u8,2]); /// ``` - pub fn to_vec(self) -> Vec { + pub fn into_vec(self) -> Vec { self.inner } From fe3fb48273616ec195c795312c80422f8a8b98ab Mon Sep 17 00:00:00 2001 From: Daniel Sharifi Date: Fri, 20 Feb 2026 15:03:46 +0100 Subject: [PATCH 11/27] remove as_vec, we have as_slice --- crates/bounded-collections/src/bounded_vec.rs | 24 +------------------ 1 file changed, 1 insertion(+), 23 deletions(-) diff --git a/crates/bounded-collections/src/bounded_vec.rs b/crates/bounded-collections/src/bounded_vec.rs index d84d79f96..0f5d78eaf 100644 --- a/crates/bounded-collections/src/bounded_vec.rs +++ b/crates/bounded-collections/src/bounded_vec.rs @@ -157,20 +157,6 @@ impl BoundedVec> { /// 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 @@ -922,14 +908,6 @@ mod tests { 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 @@ -1138,7 +1116,7 @@ mod serde_tests { // When let result = serde_json::from_str::>(json).unwrap(); // Then - assert_eq!(result.as_vec(), &vec![1, 2]); + assert_eq!(result.as_slice(), &[1, 2]); } #[test] From ed0dd2709b2c27d9f03a34d9095465b1cedebbeb Mon Sep 17 00:00:00 2001 From: Daniel Sharifi Date: Fri, 20 Feb 2026 15:20:15 +0100 Subject: [PATCH 12/27] remove opt function, and impl mapping for all witnesses --- crates/bounded-collections/src/bounded_vec.rs | 68 ++++++------------- 1 file changed, 22 insertions(+), 46 deletions(-) diff --git a/crates/bounded-collections/src/bounded_vec.rs b/crates/bounded-collections/src/bounded_vec.rs index 0f5d78eaf..1fc79ebf8 100644 --- a/crates/bounded-collections/src/bounded_vec.rs +++ b/crates/bounded-collections/src/bounded_vec.rs @@ -292,6 +292,24 @@ impl BoundedVec (&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> { + BoundedVec { + inner: self.inner.into_iter().enumerate().collect(), + witness: self.witness, + } + } +} + +impl BoundedVec +where + Witness: Copy, +{ /// Create a new `BoundedVec` by consuming `self` and mapping each element. /// /// This is useful as it keeps the knowledge that the length is >= L, <= U, @@ -305,7 +323,7 @@ impl BoundedVec(self, map_fn: F) -> BoundedVec> + pub fn mapped(self, map_fn: F) -> BoundedVec where F: FnMut(T) -> N, { @@ -328,7 +346,7 @@ impl BoundedVec(&self, map_fn: F) -> BoundedVec> + pub fn mapped_ref(&self, map_fn: F) -> BoundedVec where F: FnMut(&T) -> N, { @@ -363,10 +381,7 @@ impl BoundedVec, _> = data.try_mapped(|x| Err("failed")); /// assert_eq!(data, Err("failed")); /// ``` - pub fn try_mapped( - self, - mut map_fn: F, - ) -> Result>, E> + pub fn try_mapped(self, mut map_fn: F) -> Result, E> where F: FnMut(T) -> Result, { @@ -401,10 +416,7 @@ impl BoundedVec, _> = data.try_mapped_ref(|x| Err("failed")); /// assert_eq!(data, Err("failed")); /// ``` - pub fn try_mapped_ref( - &self, - mut map_fn: F, - ) -> Result>, E> + pub fn try_mapped_ref(&self, mut map_fn: F) -> Result, E> where F: FnMut(&T) -> Result, { @@ -419,42 +431,6 @@ impl BoundedVec (&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> { - BoundedVec { - inner: self.inner.into_iter().enumerate().collect(), - witness: self.witness, - } - } - - /// 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 From 1738eca867681d512963036db14af121f4bbbf12 Mon Sep 17 00:00:00 2001 From: Daniel Sharifi Date: Fri, 20 Feb 2026 16:05:13 +0100 Subject: [PATCH 13/27] created the sdk crate with builder --- Cargo.lock | 11 ++++ Cargo.toml | 2 + crates/near-mpc-sdk/Cargo.toml | 17 ++++++ crates/near-mpc-sdk/src/lib.rs | 4 ++ crates/near-mpc-sdk/src/sign.rs | 98 +++++++++++++++++++++++++++++++++ 5 files changed, 132 insertions(+) create mode 100644 crates/near-mpc-sdk/Cargo.toml create mode 100644 crates/near-mpc-sdk/src/lib.rs create mode 100644 crates/near-mpc-sdk/src/sign.rs diff --git a/Cargo.lock b/Cargo.lock index c616ec0c8..505376ea4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6243,6 +6243,17 @@ dependencies = [ "serde_json", ] +[[package]] +name = "near-mpc-sdk" +version = "3.5.1" +dependencies = [ + "bounded-collections", + "contract-interface", + "derive_more 2.1.1", + "serde", + "serde_json", +] + [[package]] name = "near-network" version = "2.10.6" diff --git a/Cargo.toml b/Cargo.toml index 2153e8c2e..485fc458f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ members = [ "crates/foreign-chain-rpc-interfaces", "crates/include-measurements", "crates/mpc-attestation", + "crates/near-mpc-sdk", "crates/node", "crates/node-types", "crates/primitives", @@ -44,6 +45,7 @@ mpc-contract = { path = "crates/contract", features = ["dev-utils"] } mpc-node = { path = "crates/node" } mpc-primitives = { path = "crates/primitives", features = ["abi"] } mpc-tls = { path = "crates/tls" } +near-mpc-sdk = { path = "crates/near-mpc-sdk" } node-types = { path = "crates/node-types" } tee-authority = { path = "crates/tee-authority" } test-utils = { path = "crates/test-utils" } diff --git a/crates/near-mpc-sdk/Cargo.toml b/crates/near-mpc-sdk/Cargo.toml new file mode 100644 index 000000000..3c5d66624 --- /dev/null +++ b/crates/near-mpc-sdk/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "near-mpc-sdk" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +bounded-collections = { workspace = true } +derive_more = { workspace = true } +contract-interface = { workspace = true } +serde = { workspace = true } + +[dev-dependencies] +serde_json = { workspace = true } + +[lints] +workspace = true diff --git a/crates/near-mpc-sdk/src/lib.rs b/crates/near-mpc-sdk/src/lib.rs new file mode 100644 index 000000000..b61f78ce0 --- /dev/null +++ b/crates/near-mpc-sdk/src/lib.rs @@ -0,0 +1,4 @@ +pub use bounded_collections; +pub use contract_interface; + +pub mod sign; diff --git a/crates/near-mpc-sdk/src/sign.rs b/crates/near-mpc-sdk/src/sign.rs new file mode 100644 index 000000000..c2b27fbd3 --- /dev/null +++ b/crates/near-mpc-sdk/src/sign.rs @@ -0,0 +1,98 @@ +use bounded_collections::{BoundedVec, hex_serde}; +use contract_interface::types::DomainId; +use serde::{Deserialize, Serialize}; + +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, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct SignRequestArgs { + pub path: String, + // Either one of the following two must be present. + #[serde(rename = "payload_v2")] + pub payload: Payload, + // Either one of the following two must be present. + pub domain_id: DomainId, +} + +#[derive( + Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, derive_more::From, +)] +pub enum Payload { + Ecdsa( + #[serde(with = "hex_serde")] + BoundedVec, + ), + Eddsa( + #[serde(with = "hex_serde")] + BoundedVec, + ), +} + +#[derive(Debug, Clone)] +struct NotSet; + +#[derive(Debug, Clone)] +pub struct SignRequestBuilder { + path: Path, + payload: Payload, + domain_id: DomainId, +} + +impl SignRequestBuilder { + pub fn new() -> Self { + Self { + path: NotSet, + payload: NotSet, + domain_id: NotSet, + } + } + + pub fn with_path(self, path: String) -> SignRequestBuilder { + SignRequestBuilder { + path, + payload: NotSet, + domain_id: NotSet, + } + } +} + +impl SignRequestBuilder { + pub fn with_payload( + self, + payload: impl Into, + ) -> SignRequestBuilder { + SignRequestBuilder { + path: self.path, + payload: payload.into(), + domain_id: NotSet, + } + } +} + +impl SignRequestBuilder { + pub fn with_domain_id( + self, + domain_id: impl Into, + ) -> SignRequestBuilder { + SignRequestBuilder { + path: self.path, + payload: self.payload, + domain_id: domain_id.into(), + } + } +} + +impl SignRequestBuilder { + pub fn build(self) -> SignRequestArgs { + SignRequestArgs { + path: self.path, + payload: self.payload, + domain_id: self.domain_id, + } + } +} + +#[cfg(test)] +mod test {} From 74f89d036433886a6529ec3086412265f7e4e560 Mon Sep 17 00:00:00 2001 From: Daniel Sharifi Date: Fri, 20 Feb 2026 16:22:48 +0100 Subject: [PATCH 14/27] added signing module --- crates/near-mpc-sdk/src/sign.rs | 85 ++++++++++++++++++++++++++++++++- 1 file changed, 83 insertions(+), 2 deletions(-) diff --git a/crates/near-mpc-sdk/src/sign.rs b/crates/near-mpc-sdk/src/sign.rs index c2b27fbd3..60d2a78da 100644 --- a/crates/near-mpc-sdk/src/sign.rs +++ b/crates/near-mpc-sdk/src/sign.rs @@ -17,7 +17,17 @@ pub struct SignRequestArgs { } #[derive( - Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, derive_more::From, + Debug, + Clone, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + Serialize, + Deserialize, + // TOOD: maybe remove the from implementation to be explicit on payload type + derive_more::From, )] pub enum Payload { Ecdsa( @@ -95,4 +105,75 @@ impl SignRequestBuilder { } #[cfg(test)] -mod test {} +mod test { + use super::*; + + #[test] + fn builder_builds_as_expected() { + // given + let path = "test_path".to_string(); + let payload: BoundedVec = BoundedVec::from([1_u8; 32]); + let domain_id = DomainId(2); + + // when + let built_sign_request_args = SignRequestBuilder::new() + .with_path(path.clone()) + .with_payload(payload.clone()) + .with_domain_id(domain_id.clone()) + .build(); + + // then + let expected = SignRequestArgs { + path, + payload: payload.into(), + domain_id, + }; + + assert_eq!(built_sign_request_args, expected); + } + + #[test] + fn with_path_sets_expected_value() { + // given + let path = "test_path".to_string(); + + // when + let builder = SignRequestBuilder::new().with_path(path.clone()); + + // then + assert_eq!(builder.path, path); + } + + #[test] + fn with_payload_sets_expected_value() { + // given + let path = "test_path".to_string(); + let payload: BoundedVec = BoundedVec::from([1_u8; 32]); + + let builder = SignRequestBuilder::new().with_path(path); + + // when + let builder = builder.with_payload(payload.clone()); + + // then + assert_eq!(builder.payload, Payload::Ecdsa(payload)); + } + + #[test] + fn with_domain_id_sets_expected_value() { + // given + let path = "test_path".to_string(); + let payload: BoundedVec = BoundedVec::from([1_u8; 32]); + let domain_id = 420; + + let builder = SignRequestBuilder::new() + .with_path(path) + .with_payload(payload); + + // when + let builder = builder.with_domain_id(domain_id); + + // then + assert_eq!(builder.domain_id, DomainId::from(domain_id)); + } +} From 211c10cc41c5e0b64691088924f31f111d198260 Mon Sep 17 00:00:00 2001 From: Daniel Sharifi Date: Fri, 20 Feb 2026 16:32:24 +0100 Subject: [PATCH 15/27] added sign for the method name --- crates/near-mpc-sdk/src/sign.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/near-mpc-sdk/src/sign.rs b/crates/near-mpc-sdk/src/sign.rs index 60d2a78da..ad0dcf72e 100644 --- a/crates/near-mpc-sdk/src/sign.rs +++ b/crates/near-mpc-sdk/src/sign.rs @@ -6,6 +6,8 @@ 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; +pub use contract_interface::method_names::SIGN as SIGN_METHOD_NAME; + #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub struct SignRequestArgs { pub path: String, From 3a90eaec1976a59c4f41c2386c852fa409dd175c Mon Sep 17 00:00:00 2001 From: Daniel Sharifi Date: Fri, 20 Feb 2026 16:49:25 +0100 Subject: [PATCH 16/27] make NotSet public --- crates/near-mpc-sdk/src/sign.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/near-mpc-sdk/src/sign.rs b/crates/near-mpc-sdk/src/sign.rs index ad0dcf72e..f8d01430d 100644 --- a/crates/near-mpc-sdk/src/sign.rs +++ b/crates/near-mpc-sdk/src/sign.rs @@ -43,7 +43,7 @@ pub enum Payload { } #[derive(Debug, Clone)] -struct NotSet; +pub struct NotSet; #[derive(Debug, Clone)] pub struct SignRequestBuilder { @@ -52,6 +52,12 @@ pub struct SignRequestBuilder { domain_id: DomainId, } +impl Default for SignRequestBuilder { + fn default() -> Self { + Self::new() + } +} + impl SignRequestBuilder { pub fn new() -> Self { Self { From c82139a0085d0e3a523a957168c44ed25bd86d43 Mon Sep 17 00:00:00 2001 From: Daniel Sharifi Date: Fri, 20 Feb 2026 16:50:58 +0100 Subject: [PATCH 17/27] use sdk builder in sign_utils sandbox test --- Cargo.lock | 1 + crates/contract/Cargo.toml | 1 + .../tests/sandbox/utils/sign_utils.rs | 28 ++++++++++--------- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 505376ea4..4e3c7921a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5288,6 +5288,7 @@ dependencies = [ "mpc-primitives", "near-abi", "near-account-id 2.5.0", + "near-mpc-sdk", "near-sdk", "near-workspaces", "rand 0.8.5", diff --git a/crates/contract/Cargo.toml b/crates/contract/Cargo.toml index 8cd225251..1ab3ad68a 100644 --- a/crates/contract/Cargo.toml +++ b/crates/contract/Cargo.toml @@ -93,6 +93,7 @@ futures = { workspace = true } insta = { workspace = true } mpc-contract = { workspace = true, features = ["test-utils"] } near-abi = { workspace = true } +near-mpc-sdk = { workspace = true } near-workspaces = { workspace = true } rand = { workspace = true } rand_core = { workspace = true } diff --git a/crates/contract/tests/sandbox/utils/sign_utils.rs b/crates/contract/tests/sandbox/utils/sign_utils.rs index 7e43f633a..f43b522f3 100644 --- a/crates/contract/tests/sandbox/utils/sign_utils.rs +++ b/crates/contract/tests/sandbox/utils/sign_utils.rs @@ -25,10 +25,12 @@ use mpc_contract::{ primitives::{ ckd::{CKDRequest, CKDRequestArgs}, domain::DomainId, - signature::{Bytes, Payload, SignRequestArgs, SignatureRequest, YieldIndex}, + signature::{Bytes, Payload, SignatureRequest, YieldIndex}, }, }; use near_account_id::AccountId; +use near_mpc_sdk::bounded_collections::BoundedVec; +use near_mpc_sdk::sign::{SignRequestArgs, SignRequestBuilder}; use near_workspaces::{ network::Sandbox, operations::TransactionStatus, types::NearToken, Account, Contract, Worker, }; @@ -570,12 +572,13 @@ fn gen_ed25519_sign_test( let path: String = rng.gen::().to_string(); let (payload, request, response) = create_response_ed25519(domain_id, predecessor_id, &msg, &path, sk); - let args = SignRequestArgs { - payload_v2: Some(payload.clone()), - path, - domain_id: Some(domain_id), - ..Default::default() - }; + let args = SignRequestBuilder::new() + .with_path(path) + .with_payload( + BoundedVec::::from_vec(payload.as_eddsa().unwrap().to_vec()).unwrap(), + ) + .with_domain_id(domain_id.0) + .build(); SignRequestTest { response: SignResponseArgs { request, response }, args, @@ -592,12 +595,11 @@ pub fn gen_secp_256k1_sign_test( let path: String = rng.gen::().to_string(); let (payload, request, response) = create_response_secp256k1(domain_id, predecessor_id, &msg, &path, sk); - let args = SignRequestArgs { - payload_v2: Some(payload.clone()), - path, - domain_id: Some(domain_id), - ..Default::default() - }; + let args = SignRequestBuilder::new() + .with_path(path) + .with_payload(BoundedVec::::from(*payload.as_ecdsa().unwrap())) + .with_domain_id(domain_id.0) + .build(); SignRequestTest { response: SignResponseArgs { request, response }, args, From 33e5c4fde9af746e8688aa0e0ae92892a358f8a2 Mon Sep 17 00:00:00 2001 From: Daniel Sharifi Date: Fri, 20 Feb 2026 17:38:12 +0100 Subject: [PATCH 18/27] integrate the sdk into the contract sandbox tests --- crates/contract-interface/src/lib.rs | 4 +- .../src/types/primitives.rs | 4 +- .../sandbox/upgrade_to_current_contract.rs | 6 +-- .../tests/sandbox/utils/sign_utils.rs | 40 +++++++++++-------- crates/near-mpc-sdk/src/sign.rs | 5 +++ 5 files changed, 36 insertions(+), 23 deletions(-) diff --git a/crates/contract-interface/src/lib.rs b/crates/contract-interface/src/lib.rs index b04e50f89..e9a5ad743 100644 --- a/crates/contract-interface/src/lib.rs +++ b/crates/contract-interface/src/lib.rs @@ -15,8 +15,8 @@ pub mod types { pub use metrics::Metrics; pub use primitives::{ - AccountId, CkdAppId, DomainId, K256AffinePoint, K256Scalar, K256Signature, - SignatureResponse, Tweak, + AccountId, CkdAppId, DomainId, Ed25519Signature, K256AffinePoint, K256Scalar, + K256Signature, SignatureResponse, Tweak, }; pub use state::{ AddDomainsVotes, AttemptId, AuthenticatedAccountId, AuthenticatedParticipantId, diff --git a/crates/contract-interface/src/types/primitives.rs b/crates/contract-interface/src/types/primitives.rs index 60cca5776..7fdce8871 100644 --- a/crates/contract-interface/src/types/primitives.rs +++ b/crates/contract-interface/src/types/primitives.rs @@ -102,7 +102,9 @@ pub enum SignatureResponse { } #[serde_as] -#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)] +#[derive( + Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize, derive_more::From, +)] #[cfg_attr( all(feature = "abi", not(target_arch = "wasm32")), derive(schemars::JsonSchema) diff --git a/crates/contract/tests/sandbox/upgrade_to_current_contract.rs b/crates/contract/tests/sandbox/upgrade_to_current_contract.rs index c37b1ec01..ffded7a82 100644 --- a/crates/contract/tests/sandbox/upgrade_to_current_contract.rs +++ b/crates/contract/tests/sandbox/upgrade_to_current_contract.rs @@ -15,7 +15,6 @@ use contract_interface::method_names; use contract_interface::types::{self as dtos, ProtocolContractState}; use mpc_contract::{ crypto_shared::CKDResponse, - crypto_shared::SignatureResponse, primitives::{ domain::{DomainConfig, DomainPurpose, SignatureScheme}, key_state::{EpochId, Keyset}, @@ -24,6 +23,7 @@ use mpc_contract::{ }, }; use near_account_id::AccountId; +use near_mpc_sdk::sign::SignatureRequestResponse; use near_workspaces::{network::Sandbox, Account, Contract, Worker}; use rand_core::OsRng; use rstest::rstest; @@ -287,7 +287,7 @@ async fn upgrade_preserves_state_and_requests( .unwrap(); let execution = pending.transaction.await.unwrap().into_result().unwrap(); - let returned: SignatureResponse = execution.json().unwrap(); + let returned: SignatureRequestResponse = execution.json().unwrap(); assert_eq!( returned, pending.response.response, @@ -434,7 +434,7 @@ async fn upgrade_allows_new_request_types( .unwrap(); let execution = pending.transaction.await.unwrap().into_result().unwrap(); - let returned: SignatureResponse = execution.json().unwrap(); + let returned: SignatureRequestResponse = execution.json().unwrap(); assert_eq!( returned, pending.response.response, diff --git a/crates/contract/tests/sandbox/utils/sign_utils.rs b/crates/contract/tests/sandbox/utils/sign_utils.rs index f43b522f3..2dc60211e 100644 --- a/crates/contract/tests/sandbox/utils/sign_utils.rs +++ b/crates/contract/tests/sandbox/utils/sign_utils.rs @@ -17,9 +17,8 @@ use k256::{ }; use mpc_contract::{ crypto_shared::{ - derive_key_secp256k1, derive_tweak, ed25519_types, k256_types, - k256_types::SerializableAffinePoint, kdf::check_ec_signature, kdf::derive_app_id, - CKDResponse, SerializableScalar, SignatureResponse, + derive_key_secp256k1, derive_tweak, kdf::check_ec_signature, kdf::derive_app_id, + CKDResponse, }, errors, primitives::{ @@ -29,8 +28,11 @@ use mpc_contract::{ }, }; use near_account_id::AccountId; -use near_mpc_sdk::bounded_collections::BoundedVec; -use near_mpc_sdk::sign::{SignRequestArgs, SignRequestBuilder}; +use near_mpc_sdk::sign::{Ed25519Signature, K256AffinePoint, K256Scalar, K256Signature}; +use near_mpc_sdk::{ + bounded_collections::BoundedVec, + sign::{SignRequestArgs, SignRequestBuilder, SignatureRequestResponse}, +}; use near_workspaces::{ network::Sandbox, operations::TransactionStatus, types::NearToken, Account, Contract, Worker, }; @@ -158,7 +160,7 @@ impl SignRequestTest { let execution = status.await?; dbg!(&execution); let execution = execution.into_result()?; - let returned_resp: SignatureResponse = execution.json()?; + let returned_resp: SignatureRequestResponse = execution.json()?; assert_eq!( returned_resp, self.response.response, "Returned signature request does not match" @@ -178,7 +180,7 @@ impl SignRequestTest { #[derive(Debug, Serialize)] pub struct SignResponseArgs { pub request: SignatureRequest, - pub response: SignatureResponse, + pub response: SignatureRequestResponse, } impl SignResponseArgs { @@ -460,7 +462,7 @@ fn create_response_secp256k1( msg: &str, path: &str, signing_key: &ts_ecdsa::KeygenOutput, -) -> (Payload, SignatureRequest, SignatureResponse) { +) -> (Payload, SignatureRequest, SignatureRequestResponse) { let (digest, payload) = process_message(msg); let pk = signing_key.public_key; let tweak = derive_tweak(predecessor_id, path); @@ -480,7 +482,6 @@ fn create_response_secp256k1( let respond_req = SignatureRequest::new(domain_id, payload.clone(), predecessor_id, path); let big_r = AffinePoint::decompress(&r_bytes, k256::elliptic_curve::subtle::Choice::from(0)).unwrap(); - let s: k256::Scalar = *s.as_ref(); let recovery_id = if check_ec_signature(&derived_pk, &big_r, &s, payload.as_ecdsa().unwrap(), 0) .is_ok() @@ -492,11 +493,16 @@ fn create_response_secp256k1( panic!("unable to use recovery id of 0 or 1"); }; - let respond_resp = SignatureResponse::Secp256k1(k256_types::Signature { - big_r: SerializableAffinePoint { - affine_point: big_r, + use k256::elliptic_curve::sec1::ToEncodedPoint; + let encoded_point = big_r.to_encoded_point(true); + + let respond_resp = SignatureRequestResponse::Secp256k1(K256Signature { + big_r: K256AffinePoint { + affine_point: encoded_point.as_bytes().try_into().unwrap(), + }, + s: K256Scalar { + scalar: s.to_bytes().into(), }, - s: SerializableScalar { scalar: s }, recovery_id, }); @@ -509,7 +515,7 @@ fn create_response_ed25519( msg: &str, path: &str, signing_key: &eddsa::KeygenOutput, -) -> (Payload, SignatureRequest, SignatureResponse) { +) -> (Payload, SignatureRequest, SignatureRequestResponse) { let tweak = derive_tweak(predecessor_id, path); let derived_signing_key = derive_secret_key_ed25519(signing_key, &tweak); @@ -523,7 +529,7 @@ fn create_response_ed25519( frost_ed25519::SigningKey::from_scalar(derived_signing_key.private_share.to_scalar()) .unwrap(); - let signature = derived_signing_key + let signature: [u8; 64] = derived_signing_key .sign(OsRng, &payload) .serialize() .unwrap() @@ -535,8 +541,8 @@ fn create_response_ed25519( let respond_req = SignatureRequest::new(domain_id, payload.clone(), predecessor_id, path); - let signature_response = SignatureResponse::Ed25519 { - signature: ed25519_types::Signature::new(signature), + let signature_response = SignatureRequestResponse::Ed25519 { + signature: Ed25519Signature::from(signature), }; (payload, respond_req, signature_response) diff --git a/crates/near-mpc-sdk/src/sign.rs b/crates/near-mpc-sdk/src/sign.rs index f8d01430d..8c9ddfb86 100644 --- a/crates/near-mpc-sdk/src/sign.rs +++ b/crates/near-mpc-sdk/src/sign.rs @@ -7,6 +7,11 @@ const EDDSA_PAYLOAD_SIZE_LOWER_BOUND_BYTES: usize = 32; const EDDSA_PAYLOAD_SIZE_UPPER_BOUND_BYTES: usize = 1232; pub use contract_interface::method_names::SIGN as SIGN_METHOD_NAME; +// response types +pub use contract_interface::types::{ + Ed25519Signature, K256AffinePoint, K256Scalar, K256Signature, + SignatureResponse as SignatureRequestResponse, +}; #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub struct SignRequestArgs { From a7f451e11a776811e41f24ad0fbdfe4bd9521671 Mon Sep 17 00:00:00 2001 From: Daniel Sharifi Date: Fri, 20 Feb 2026 17:46:47 +0100 Subject: [PATCH 19/27] sort/shear --- Cargo.lock | 1 - crates/near-mpc-sdk/Cargo.toml | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4e3c7921a..a7ce3b35e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6252,7 +6252,6 @@ dependencies = [ "contract-interface", "derive_more 2.1.1", "serde", - "serde_json", ] [[package]] diff --git a/crates/near-mpc-sdk/Cargo.toml b/crates/near-mpc-sdk/Cargo.toml index 3c5d66624..17b58a131 100644 --- a/crates/near-mpc-sdk/Cargo.toml +++ b/crates/near-mpc-sdk/Cargo.toml @@ -6,12 +6,11 @@ license.workspace = true [dependencies] bounded-collections = { workspace = true } -derive_more = { workspace = true } contract-interface = { workspace = true } +derive_more = { workspace = true } serde = { workspace = true } [dev-dependencies] -serde_json = { workspace = true } [lints] workspace = true From e0474c54173869a3a10d2653a911767706258674 Mon Sep 17 00:00:00 2001 From: Daniel Sharifi Date: Fri, 20 Feb 2026 17:47:27 +0100 Subject: [PATCH 20/27] remove clone for type with copy --- crates/near-mpc-sdk/src/sign.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/near-mpc-sdk/src/sign.rs b/crates/near-mpc-sdk/src/sign.rs index 8c9ddfb86..3e21ec038 100644 --- a/crates/near-mpc-sdk/src/sign.rs +++ b/crates/near-mpc-sdk/src/sign.rs @@ -132,7 +132,7 @@ mod test { let built_sign_request_args = SignRequestBuilder::new() .with_path(path.clone()) .with_payload(payload.clone()) - .with_domain_id(domain_id.clone()) + .with_domain_id(domain_id) .build(); // then From 7b44c5c559e12069ace745c1769a904c317c1e4a Mon Sep 17 00:00:00 2001 From: Daniel Sharifi Date: Fri, 20 Feb 2026 17:52:16 +0100 Subject: [PATCH 21/27] remove stale copy pasta comments --- crates/near-mpc-sdk/src/sign.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/crates/near-mpc-sdk/src/sign.rs b/crates/near-mpc-sdk/src/sign.rs index 3e21ec038..b2942e261 100644 --- a/crates/near-mpc-sdk/src/sign.rs +++ b/crates/near-mpc-sdk/src/sign.rs @@ -16,10 +16,8 @@ pub use contract_interface::types::{ #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub struct SignRequestArgs { pub path: String, - // Either one of the following two must be present. #[serde(rename = "payload_v2")] pub payload: Payload, - // Either one of the following two must be present. pub domain_id: DomainId, } From f73856e401cfb219889d8271b77fd8f0b43ff0c2 Mon Sep 17 00:00:00 2001 From: Daniel Sharifi Date: Fri, 20 Feb 2026 18:00:24 +0100 Subject: [PATCH 22/27] remove from implementation --- Cargo.lock | 1 - .../tests/sandbox/utils/sign_utils.rs | 16 ++++++------- crates/near-mpc-sdk/Cargo.toml | 1 - crates/near-mpc-sdk/src/sign.rs | 24 +++++-------------- 4 files changed, 14 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a7ce3b35e..5e9f1e0e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6250,7 +6250,6 @@ version = "3.5.1" dependencies = [ "bounded-collections", "contract-interface", - "derive_more 2.1.1", "serde", ] diff --git a/crates/contract/tests/sandbox/utils/sign_utils.rs b/crates/contract/tests/sandbox/utils/sign_utils.rs index 2dc60211e..658ce49a9 100644 --- a/crates/contract/tests/sandbox/utils/sign_utils.rs +++ b/crates/contract/tests/sandbox/utils/sign_utils.rs @@ -29,10 +29,7 @@ use mpc_contract::{ }; use near_account_id::AccountId; use near_mpc_sdk::sign::{Ed25519Signature, K256AffinePoint, K256Scalar, K256Signature}; -use near_mpc_sdk::{ - bounded_collections::BoundedVec, - sign::{SignRequestArgs, SignRequestBuilder, SignatureRequestResponse}, -}; +use near_mpc_sdk::sign::{SignRequestArgs, SignRequestBuilder, SignatureRequestResponse}; use near_workspaces::{ network::Sandbox, operations::TransactionStatus, types::NearToken, Account, Contract, Worker, }; @@ -580,9 +577,9 @@ fn gen_ed25519_sign_test( create_response_ed25519(domain_id, predecessor_id, &msg, &path, sk); let args = SignRequestBuilder::new() .with_path(path) - .with_payload( - BoundedVec::::from_vec(payload.as_eddsa().unwrap().to_vec()).unwrap(), - ) + .with_payload(near_mpc_sdk::sign::Payload::Eddsa( + payload.as_eddsa().unwrap().to_vec().try_into().unwrap(), + )) .with_domain_id(domain_id.0) .build(); SignRequestTest { @@ -601,9 +598,12 @@ pub fn gen_secp_256k1_sign_test( let path: String = rng.gen::().to_string(); let (payload, request, response) = create_response_secp256k1(domain_id, predecessor_id, &msg, &path, sk); + + let payload_bytes: [u8; 32] = *payload.as_ecdsa().unwrap(); + let args = SignRequestBuilder::new() .with_path(path) - .with_payload(BoundedVec::::from(*payload.as_ecdsa().unwrap())) + .with_payload(near_mpc_sdk::sign::Payload::Ecdsa(payload_bytes.into())) .with_domain_id(domain_id.0) .build(); SignRequestTest { diff --git a/crates/near-mpc-sdk/Cargo.toml b/crates/near-mpc-sdk/Cargo.toml index 17b58a131..924d44bf4 100644 --- a/crates/near-mpc-sdk/Cargo.toml +++ b/crates/near-mpc-sdk/Cargo.toml @@ -7,7 +7,6 @@ license.workspace = true [dependencies] bounded-collections = { workspace = true } contract-interface = { workspace = true } -derive_more = { workspace = true } serde = { workspace = true } [dev-dependencies] diff --git a/crates/near-mpc-sdk/src/sign.rs b/crates/near-mpc-sdk/src/sign.rs index b2942e261..a49897503 100644 --- a/crates/near-mpc-sdk/src/sign.rs +++ b/crates/near-mpc-sdk/src/sign.rs @@ -21,19 +21,7 @@ pub struct SignRequestArgs { pub domain_id: DomainId, } -#[derive( - Debug, - Clone, - PartialEq, - Eq, - PartialOrd, - Ord, - Hash, - Serialize, - Deserialize, - // TOOD: maybe remove the from implementation to be explicit on payload type - derive_more::From, -)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub enum Payload { Ecdsa( #[serde(with = "hex_serde")] @@ -123,7 +111,7 @@ mod test { fn builder_builds_as_expected() { // given let path = "test_path".to_string(); - let payload: BoundedVec = BoundedVec::from([1_u8; 32]); + let payload = Payload::Ecdsa(BoundedVec::from([1_u8; 32])); let domain_id = DomainId(2); // when @@ -136,7 +124,7 @@ mod test { // then let expected = SignRequestArgs { path, - payload: payload.into(), + payload, domain_id, }; @@ -159,7 +147,7 @@ mod test { fn with_payload_sets_expected_value() { // given let path = "test_path".to_string(); - let payload: BoundedVec = BoundedVec::from([1_u8; 32]); + let payload = Payload::Ecdsa(BoundedVec::from([1_u8; 32])); let builder = SignRequestBuilder::new().with_path(path); @@ -167,14 +155,14 @@ mod test { let builder = builder.with_payload(payload.clone()); // then - assert_eq!(builder.payload, Payload::Ecdsa(payload)); + assert_eq!(builder.payload, payload); } #[test] fn with_domain_id_sets_expected_value() { // given let path = "test_path".to_string(); - let payload: BoundedVec = BoundedVec::from([1_u8; 32]); + let payload = Payload::Ecdsa(BoundedVec::from([1_u8; 32])); let domain_id = 420; let builder = SignRequestBuilder::new() From d588938c1f850be4005334e3d831593fb8a0940a Mon Sep 17 00:00:00 2001 From: Daniel Sharifi Date: Mon, 23 Feb 2026 10:35:24 +0100 Subject: [PATCH 23/27] move SignRequestArgs to interface crate --- crates/contract-interface/src/lib.rs | 2 ++ crates/contract-interface/src/types/sign.rs | 28 ++++++++++++++++++ crates/near-mpc-sdk/src/sign.rs | 32 ++++----------------- 3 files changed, 35 insertions(+), 27 deletions(-) create mode 100644 crates/contract-interface/src/types/sign.rs diff --git a/crates/contract-interface/src/lib.rs b/crates/contract-interface/src/lib.rs index e9a5ad743..9d34e800c 100644 --- a/crates/contract-interface/src/lib.rs +++ b/crates/contract-interface/src/lib.rs @@ -18,6 +18,7 @@ pub mod types { AccountId, CkdAppId, DomainId, Ed25519Signature, K256AffinePoint, K256Scalar, K256Signature, SignatureResponse, Tweak, }; + pub use sign::*; pub use state::{ AddDomainsVotes, AttemptId, AuthenticatedAccountId, AuthenticatedParticipantId, DomainConfig, DomainPurpose, DomainRegistry, EpochId, InitializingContractState, KeyEvent, @@ -34,6 +35,7 @@ pub mod types { mod metrics; mod participants; mod primitives; + mod sign; mod state; mod updates; } diff --git a/crates/contract-interface/src/types/sign.rs b/crates/contract-interface/src/types/sign.rs new file mode 100644 index 000000000..66d297df8 --- /dev/null +++ b/crates/contract-interface/src/types/sign.rs @@ -0,0 +1,28 @@ +use bounded_collections::{BoundedVec, hex_serde}; +use serde::{Deserialize, Serialize}; + +use crate::types::DomainId; + +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, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct SignRequestArgs { + pub path: String, + #[serde(rename = "payload_v2")] + pub payload: Payload, + pub domain_id: DomainId, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub enum Payload { + Ecdsa( + #[serde(with = "hex_serde")] + BoundedVec, + ), + Eddsa( + #[serde(with = "hex_serde")] + BoundedVec, + ), +} diff --git a/crates/near-mpc-sdk/src/sign.rs b/crates/near-mpc-sdk/src/sign.rs index a49897503..ae806b784 100644 --- a/crates/near-mpc-sdk/src/sign.rs +++ b/crates/near-mpc-sdk/src/sign.rs @@ -1,37 +1,13 @@ -use bounded_collections::{BoundedVec, hex_serde}; -use contract_interface::types::DomainId; -use serde::{Deserialize, Serialize}; - -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; - pub use contract_interface::method_names::SIGN as SIGN_METHOD_NAME; +use contract_interface::types::DomainId; // response types pub use contract_interface::types::{ Ed25519Signature, K256AffinePoint, K256Scalar, K256Signature, SignatureResponse as SignatureRequestResponse, }; -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] -pub struct SignRequestArgs { - pub path: String, - #[serde(rename = "payload_v2")] - pub payload: Payload, - pub domain_id: DomainId, -} - -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] -pub enum Payload { - Ecdsa( - #[serde(with = "hex_serde")] - BoundedVec, - ), - Eddsa( - #[serde(with = "hex_serde")] - BoundedVec, - ), -} +// raw request arg type +pub use contract_interface::types::{Payload, SignRequestArgs}; #[derive(Debug, Clone)] pub struct NotSet; @@ -105,6 +81,8 @@ impl SignRequestBuilder { #[cfg(test)] mod test { + use bounded_collections::BoundedVec; + use super::*; #[test] From ae84e1148110f5c397b6f98294bed5615934115e Mon Sep 17 00:00:00 2001 From: Daniel Sharifi Date: Mon, 23 Feb 2026 11:04:31 +0100 Subject: [PATCH 24/27] remove serde unused dep --- Cargo.lock | 1 - crates/near-mpc-sdk/Cargo.toml | 1 - 2 files changed, 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5e9f1e0e5..3739169c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6250,7 +6250,6 @@ version = "3.5.1" dependencies = [ "bounded-collections", "contract-interface", - "serde", ] [[package]] diff --git a/crates/near-mpc-sdk/Cargo.toml b/crates/near-mpc-sdk/Cargo.toml index 924d44bf4..2c4b6b2e1 100644 --- a/crates/near-mpc-sdk/Cargo.toml +++ b/crates/near-mpc-sdk/Cargo.toml @@ -7,7 +7,6 @@ license.workspace = true [dependencies] bounded-collections = { workspace = true } contract-interface = { workspace = true } -serde = { workspace = true } [dev-dependencies] From 01d9eedd0183d97af2cba0401f78a0d3432f2a4f Mon Sep 17 00:00:00 2001 From: Daniel Sharifi Date: Mon, 23 Feb 2026 16:08:54 +0100 Subject: [PATCH 25/27] move import to top --- crates/contract/tests/sandbox/utils/sign_utils.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/contract/tests/sandbox/utils/sign_utils.rs b/crates/contract/tests/sandbox/utils/sign_utils.rs index 658ce49a9..687a55db1 100644 --- a/crates/contract/tests/sandbox/utils/sign_utils.rs +++ b/crates/contract/tests/sandbox/utils/sign_utils.rs @@ -11,6 +11,7 @@ use contract_interface::method_names::{ use contract_interface::types::{self as dtos}; use digest::{Digest, FixedOutput}; use ecdsa::signature::Verifier as _; +use k256::elliptic_curve::sec1::ToEncodedPoint as _; use k256::{ elliptic_curve::{point::DecompressPoint as _, Field as _, Group as _}, AffinePoint, FieldBytes, Secp256k1, @@ -490,7 +491,6 @@ fn create_response_secp256k1( panic!("unable to use recovery id of 0 or 1"); }; - use k256::elliptic_curve::sec1::ToEncodedPoint; let encoded_point = big_r.to_encoded_point(true); let respond_resp = SignatureRequestResponse::Secp256k1(K256Signature { From 1a891e6babea0f80a2870e544dfe84c3a750bc2c Mon Sep 17 00:00:00 2001 From: Daniel Sharifi Date: Mon, 23 Feb 2026 16:12:04 +0100 Subject: [PATCH 26/27] rename to payload_v2 --- crates/contract-interface/src/types/sign.rs | 3 +-- crates/near-mpc-sdk/src/sign.rs | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/contract-interface/src/types/sign.rs b/crates/contract-interface/src/types/sign.rs index 66d297df8..d1a41f543 100644 --- a/crates/contract-interface/src/types/sign.rs +++ b/crates/contract-interface/src/types/sign.rs @@ -10,8 +10,7 @@ const EDDSA_PAYLOAD_SIZE_UPPER_BOUND_BYTES: usize = 1232; #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub struct SignRequestArgs { pub path: String, - #[serde(rename = "payload_v2")] - pub payload: Payload, + pub payload_v2: Payload, pub domain_id: DomainId, } diff --git a/crates/near-mpc-sdk/src/sign.rs b/crates/near-mpc-sdk/src/sign.rs index ae806b784..4879e8dd5 100644 --- a/crates/near-mpc-sdk/src/sign.rs +++ b/crates/near-mpc-sdk/src/sign.rs @@ -73,7 +73,7 @@ impl SignRequestBuilder { pub fn build(self) -> SignRequestArgs { SignRequestArgs { path: self.path, - payload: self.payload, + payload_v2: self.payload, domain_id: self.domain_id, } } @@ -102,7 +102,7 @@ mod test { // then let expected = SignRequestArgs { path, - payload, + payload_v2: payload, domain_id, }; From cba63265917988ba821ee9c6d97dfa63179f0dac Mon Sep 17 00:00:00 2001 From: Daniel Sharifi Date: Mon, 23 Feb 2026 16:15:54 +0100 Subject: [PATCH 27/27] add comment to the bounds for payload sizes --- crates/contract-interface/src/types/sign.rs | 10 +++++++--- crates/contract/src/primitives/signature.rs | 8 ++++++-- crates/devnet/src/contracts.rs | 9 +++++++-- crates/node/src/tests.rs | 7 +++++-- 4 files changed, 25 insertions(+), 9 deletions(-) diff --git a/crates/contract-interface/src/types/sign.rs b/crates/contract-interface/src/types/sign.rs index d1a41f543..a35fb9c7a 100644 --- a/crates/contract-interface/src/types/sign.rs +++ b/crates/contract-interface/src/types/sign.rs @@ -3,9 +3,13 @@ use serde::{Deserialize, Serialize}; use crate::types::DomainId; -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; +pub const ECDSA_PAYLOAD_SIZE_BYTES: usize = 32; + +pub const EDDSA_PAYLOAD_SIZE_LOWER_BOUND_BYTES: usize = 32; +// Transaction signatures for Solana is over the whole transaction payload, +// not the transaction hash. The max size for a solana transaction is 1232 bytes, +// to fit in a single UDP packet, hence the 1232 byte upper bounds. +pub const EDDSA_PAYLOAD_SIZE_UPPER_BOUND_BYTES: usize = 1232; #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub struct SignRequestArgs { diff --git a/crates/contract/src/primitives/signature.rs b/crates/contract/src/primitives/signature.rs index 1e588d9b4..11c461add 100644 --- a/crates/contract/src/primitives/signature.rs +++ b/crates/contract/src/primitives/signature.rs @@ -1,6 +1,10 @@ use crate::crypto_shared; use crate::errors::{Error, InvalidParameters}; use crate::DomainId; +use contract_interface::types::{ + ECDSA_PAYLOAD_SIZE_BYTES, EDDSA_PAYLOAD_SIZE_LOWER_BOUND_BYTES, + EDDSA_PAYLOAD_SIZE_UPPER_BOUND_BYTES, +}; use crypto_shared::derive_tweak; use near_account_id::AccountId; use near_sdk::{near, CryptoHash}; @@ -34,7 +38,7 @@ pub enum Payload { definitions = "<[u8; 32] as ::borsh::BorshSchema>::add_definitions_recursively" ),)) )] - Bytes<32, 32>, + Bytes, ), Eddsa( #[cfg_attr( @@ -45,7 +49,7 @@ pub enum Payload { definitions = " as ::borsh::BorshSchema>::add_definitions_recursively" ),)) )] - Bytes<32, 1232>, + Bytes, ), } diff --git a/crates/devnet/src/contracts.rs b/crates/devnet/src/contracts.rs index 9e11b6537..509de749e 100644 --- a/crates/devnet/src/contracts.rs +++ b/crates/devnet/src/contracts.rs @@ -1,6 +1,9 @@ use std::collections::BTreeMap; -use contract_interface::method_names; +use contract_interface::{ + method_names, + types::{EDDSA_PAYLOAD_SIZE_LOWER_BOUND_BYTES, EDDSA_PAYLOAD_SIZE_UPPER_BOUND_BYTES}, +}; use mpc_contract::primitives::{ ckd::CKDRequestArgs, domain::{DomainConfig, SignatureScheme}, @@ -168,7 +171,9 @@ fn make_payload(scheme: SignatureScheme) -> Payload { Payload::Ecdsa(Bytes::new(rand::random::<[u8; 32]>().to_vec()).unwrap()) } SignatureScheme::Ed25519 => { - let len = rand::random_range(32..=1232); + let len = rand::random_range( + EDDSA_PAYLOAD_SIZE_LOWER_BOUND_BYTES..=EDDSA_PAYLOAD_SIZE_UPPER_BOUND_BYTES, + ); let mut payload = vec![0; len]; rand::rng().fill_bytes(&mut payload); Payload::Eddsa(Bytes::new(payload).unwrap()) diff --git a/crates/node/src/tests.rs b/crates/node/src/tests.rs index fc962cb94..a839c0a1b 100644 --- a/crates/node/src/tests.rs +++ b/crates/node/src/tests.rs @@ -1,7 +1,8 @@ use aes_gcm::{Aes256Gcm, KeyInit}; use contract_interface::types::{ BitcoinExtractor, BitcoinRpcRequest, ForeignChainRpcRequest, - VerifyForeignTransactionRequestArgs, + VerifyForeignTransactionRequestArgs, EDDSA_PAYLOAD_SIZE_LOWER_BOUND_BYTES, + EDDSA_PAYLOAD_SIZE_UPPER_BOUND_BYTES, }; use mpc_contract::primitives::key_state::Keyset; use mpc_contract::state::ProtocolContractState; @@ -277,7 +278,9 @@ pub async fn request_signature_and_await_response( Payload::Ecdsa(Bytes::new(payload.to_vec()).unwrap()) } SignatureScheme::Ed25519 => { - let len = rand::thread_rng().gen_range(32..1232); + let len = rand::thread_rng().gen_range( + EDDSA_PAYLOAD_SIZE_LOWER_BOUND_BYTES..EDDSA_PAYLOAD_SIZE_UPPER_BOUND_BYTES, + ); let mut payload = vec![0; len]; rand::thread_rng().fill_bytes(payload.as_mut()); Payload::Eddsa(Bytes::new(payload.to_vec()).unwrap())