diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index 4151f4766..7acadc911 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -77,10 +77,10 @@ jobs: - name: Run Audit (FastAuth) working-directory: integration-tests/fastauth run: | - cargo audit --ignore RUSTSEC-2020-0071 --ignore RUSTSEC-2023-0052 --ignore RUSTSEC-2022-0093 --ignore RUSTSEC-2023-0071 --ignore RUSTSEC-2024-0019 --ignore RUSTSEC-2024-0344 + cargo audit --ignore RUSTSEC-2020-0071 --ignore RUSTSEC-2023-0052 --ignore RUSTSEC-2022-0093 --ignore RUSTSEC-2023-0071 --ignore RUSTSEC-2024-0019 --ignore RUSTSEC-2024-0344 --ignore RUSTSEC-2024-0357 - name: Run Audit (Chain Signatures) # even if previous audit step fails, run this audit step to ensure all crates are audited if: always() working-directory: integration-tests/chain-signatures run: | - cargo audit --ignore RUSTSEC-2020-0071 --ignore RUSTSEC-2023-0052 --ignore RUSTSEC-2022-0093 --ignore RUSTSEC-2023-0071 --ignore RUSTSEC-2024-0019 --ignore RUSTSEC-2024-0344 --ignore RUSTSEC-2022-0093 --ignore RUSTSEC-2024-0346 --ignore RUSTSEC-2024-0347 + cargo audit --ignore RUSTSEC-2020-0071 --ignore RUSTSEC-2023-0052 --ignore RUSTSEC-2022-0093 --ignore RUSTSEC-2023-0071 --ignore RUSTSEC-2024-0019 --ignore RUSTSEC-2024-0344 --ignore RUSTSEC-2022-0093 --ignore RUSTSEC-2024-0346 --ignore RUSTSEC-2024-0347 --ignore RUSTSEC-2024-0357 diff --git a/chain-signatures/Cargo.lock b/chain-signatures/Cargo.lock index 74c78e429..6699aee23 100644 --- a/chain-signatures/Cargo.lock +++ b/chain-signatures/Cargo.lock @@ -1753,6 +1753,7 @@ dependencies = [ "near-sdk", "serde", "serde_json", + "sha3", "subtle", ] @@ -3762,6 +3763,7 @@ dependencies = [ "serde", "serde_json", "signature 2.2.0", + "thiserror", "tokio", ] diff --git a/chain-signatures/README.md b/chain-signatures/README.md new file mode 100644 index 000000000..da9562239 --- /dev/null +++ b/chain-signatures/README.md @@ -0,0 +1,55 @@ +# Chain Signatures API + +## `sign()` +This is the main function of the contract API. It is used to sign a request with the MPC service. +```rust +pub fn sign(&mut self, request: SignRequest) -> Result +``` +Arguments and return type: +```rust +pub struct SignRequest { + pub payload: [u8; 32], + pub path: String, + pub key_version: u32, +} + +pub struct SignResult { + pub big_r: String, + pub s: String, +} +``` +- `key_version` must be less than or equal to the value at `latest_key_version`. +- `path` is a derivation path for the key that will be used to sign the payload. +- To avoid overloading the network with too many requests, we ask for a small deposit for each signature request. The fee changes based on how busy the network is. + +## `public_key()` +This is the root public key combined from all the public keys of the participants. +```rust +pub fn public_key(&self) -> Result +``` + +## `derived_public_key()` +This is the derived public key of the caller given path and predecessor. If the predecessor is not provided, it will be the caller of the contract. +```rust +pub fn derived_public_key( + &self, + path: String, + predecessor: Option, + ) -> Result +``` + +## `latest_key_version()` +Key versions refer new versions of the root key that we may choose to generate on cohort changes. Older key versions will always work but newer key versions were never held by older signers. Newer key versions may also add new security features, like only existing within a secure enclave. Currently only 0 is a valid key version. +```rust +pub const fn latest_key_version(&self) -> u32 +``` + +For more details check `User contract API` impl block in the [chain-signatures/contracts/src/lib.rs](./chain-signatures/contracts/src/lib.rs) file. + +# Environments +Currently, we have 3 environments: +1. Mainnet: `v1.multichain-mpc.near` // TODO: set when available +2. Testnet: `v2.multichain-mpc.testnet` +3. Dev: `v5.multichain-mpc-dev.testnet` + +Contracts can be changed from v1 to v2, etc. Older contracts should continue functioning. \ No newline at end of file diff --git a/chain-signatures/contract/Cargo.toml b/chain-signatures/contract/Cargo.toml index 050e7ed96..e056e2c8e 100644 --- a/chain-signatures/contract/Cargo.toml +++ b/chain-signatures/contract/Cargo.toml @@ -15,6 +15,7 @@ schemars = "0.8" k256 = { version = "0.13.1", features = ["sha256", "ecdsa", "serde", "arithmetic", "expose-field"] } crypto-shared = { path = "../crypto-shared" } near-gas = { version = "0.2.5", features = ["serde", "borsh", "schemars"] } +thiserror = "1" [dev-dependencies] anyhow = "1" diff --git a/chain-signatures/contract/src/errors.rs b/chain-signatures/contract/src/errors.rs new file mode 100644 index 000000000..09a4d773c --- /dev/null +++ b/chain-signatures/contract/src/errors.rs @@ -0,0 +1,93 @@ +use near_sdk::Gas; + +#[derive(Debug, thiserror::Error)] +pub enum SignError { + #[error("Signature request has timed out.")] + Timeout, + #[error("Signature request has already been submitted. Please try again later.")] + PayloadCollision, + #[error("Malformed payload: {0}")] + MalformedPayload(String), + #[error( + "This key version is not supported. Call latest_key_version() to get the latest supported version." + )] + UnsupportedKeyVersion, + #[error("Attached deposit is lower than required. Attached: {0}, Required: {1}.")] + InsufficientDeposit(u128, u128), + #[error("Provided gas is lower than required. Provided: {0}, required {1}.")] + InsufficientGas(Gas, Gas), + #[error("Too many pending requests. Please try again later.")] + RequestLimitExceeded, + #[error("This sign request has timed out, was completed, or never existed.")] + RequestNotFound, +} + +#[derive(Debug, thiserror::Error)] +pub enum RespondError { + #[error("This sign request has timed out, was completed, or never existed.")] + RequestNotFound, + #[error("The provided signature is invalid.")] + InvalidSignature, + #[error("The protocol is not Running.")] + ProtocolNotInRunningState, +} + +#[derive(Debug, thiserror::Error)] +pub enum JoinError { + #[error("The protocol is not Running.")] + ProtocolStateNotRunning, +} + +#[derive(Debug, thiserror::Error)] +pub enum PublicKeyError { + #[error("Protocol state is not running or resharing.")] + ProtocolStateNotRunningOrResharing, + #[error("Derived key conversion failed.")] + DerivedKeyConversionFailed, +} + +#[derive(Debug, thiserror::Error)] +pub enum InitError { + #[error("Threshold cannot be greater than the number of candidates")] + ThresholdTooHigh, +} + +#[derive(Debug, thiserror::Error)] +pub enum VoteError { + #[error("Voting account is not in the participant set.")] + VoterNotParticipant, + #[error("Account to be kicked is not in the participant set.")] + KickNotParticipant, + #[error("Account to join is not in the candidate set.")] + JoinNotCandidate, + #[error("Account to join is already in the participant set.")] + JoinAlreadyParticipant, + #[error("Mismatched epoch.")] + EpochMismatch, + #[error("Number of participants cannot go below threshold.")] + ParticipantsBelowThreshold, + #[error("Unexpected protocol state: {0}")] + UnexpectedProtocolState(String), +} + +#[derive(Debug, thiserror::Error)] +pub enum MpcContractError { + #[error("sign fn error: {0}")] + SignError(SignError), + #[error("respond fn error: {0}")] + RespondError(RespondError), + #[error("vote_* fn error: {0}")] + VoteError(VoteError), + #[error("init fn error: {0}")] + InitError(InitError), + #[error("join fn error: {0}")] + JoinError(JoinError), + #[error("public_key fn error: {0}")] + PublicKeyError(PublicKeyError), +} + +impl near_sdk::FunctionError for MpcContractError { + fn panic(&self) -> ! { + crate::env::panic_str(&self.to_string()) + } +} diff --git a/chain-signatures/contract/src/lib.rs b/chain-signatures/contract/src/lib.rs index 69a647fb1..21e1cffe7 100644 --- a/chain-signatures/contract/src/lib.rs +++ b/chain-signatures/contract/src/lib.rs @@ -1,8 +1,9 @@ +pub mod errors; pub mod primitives; use crypto_shared::{ derive_epsilon, derive_key, kdf::check_ec_signature, near_public_key_to_affine_point, - types::SignatureResponse, ScalarExt as _, SerializableScalar, + types::SignatureResponse, ScalarExt as _, }; use k256::Scalar; use near_sdk::borsh::{self, BorshDeserialize, BorshSerialize}; @@ -10,14 +11,17 @@ use near_sdk::collections::LookupMap; use near_sdk::serde::{Deserialize, Serialize}; use near_sdk::{ - env, log, near_bindgen, AccountId, BorshStorageKey, CryptoHash, Gas, GasWeight, NearToken, - PromiseError, PublicKey, + env, log, near_bindgen, AccountId, CryptoHash, Gas, GasWeight, NearToken, PromiseError, + PublicKey, }; +use errors::{ + InitError, JoinError, MpcContractError, PublicKeyError, RespondError, SignError, VoteError, +}; use k256::elliptic_curve::sec1::ToEncodedPoint; use primitives::{ CandidateInfo, Candidates, ParticipantInfo, Participants, PkVotes, SignRequest, - SignaturePromiseError, SignatureResult, Votes, + SignaturePromiseError, SignatureRequest, SignatureResult, StorageKey, Votes, YieldIndex, }; use std::collections::{BTreeMap, HashSet}; @@ -69,12 +73,6 @@ pub enum ProtocolContractState { Resharing(ResharingContractState), } -#[derive(BorshSerialize, BorshDeserialize, BorshStorageKey, Hash, Clone, Debug, PartialEq, Eq)] -#[borsh(crate = "near_sdk::borsh")] -pub enum StorageKey { - PendingRequests, -} - #[near_bindgen] #[derive(BorshDeserialize, BorshSerialize, Debug)] pub enum VersionedMpcContract { @@ -87,35 +85,6 @@ impl Default for VersionedMpcContract { } } -/// 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(BorshDeserialize, BorshSerialize, Serialize, Deserialize, Debug, Clone)] -#[borsh(crate = "near_sdk::borsh")] -pub struct YieldIndex { - data_id: CryptoHash, -} - -#[derive(BorshDeserialize, BorshSerialize, Serialize, Deserialize, Debug, Clone)] -#[borsh(crate = "near_sdk::borsh")] -pub struct SignatureRequest { - pub epsilon: SerializableScalar, - pub payload_hash: SerializableScalar, -} - -impl SignatureRequest { - pub fn new(payload_hash: Scalar, predecessor_id: &AccountId, path: &str) -> Self { - let epsilon = derive_epsilon(predecessor_id, path); - let epsilon = SerializableScalar { scalar: epsilon }; - let payload_hash = SerializableScalar { - scalar: payload_hash, - }; - SignatureRequest { - epsilon, - payload_hash, - } - } -} - #[derive(BorshDeserialize, BorshSerialize, Debug)] pub struct MpcContract { protocol_state: ProtocolContractState, @@ -134,11 +103,12 @@ impl MpcContract { } } - fn remove_request(&mut self, request: SignatureRequest) { + fn remove_request(&mut self, request: SignatureRequest) -> Result<(), MpcContractError> { if self.pending_requests.remove(&request).is_some() { self.request_counter -= 1; + Ok(()) } else { - env::panic_str("yield resume requests do not contain this request.") + Err(MpcContractError::SignError(SignError::RequestNotFound)) } } @@ -158,13 +128,14 @@ impl MpcContract { // User contract API #[near_bindgen] impl VersionedMpcContract { - #[allow(unused_variables)] /// `key_version` must be less than or equal to the value at `latest_key_version` /// To avoid overloading the network with too many requests, /// we ask for a small deposit for each signature request. /// The fee changes based on how busy the network is. + #[allow(unused_variables)] + #[handle_result] #[payable] - pub fn sign(&mut self, request: SignRequest) { + pub fn sign(&mut self, request: SignRequest) -> Result { let SignRequest { payload, path, @@ -173,119 +144,102 @@ impl VersionedMpcContract { let latest_key_version: u32 = self.latest_key_version(); // It's important we fail here because the MPC nodes will fail in an identical way. // This allows users to get the error message - let payload = Scalar::from_bytes(payload).expect("Payload hash is bad"); - assert!( - key_version <= latest_key_version, - "This version of the signer contract doesn't support versions greater than {}", - latest_key_version, - ); + let payload = Scalar::from_bytes(payload).ok_or(MpcContractError::SignError( + SignError::MalformedPayload("Payload hash cannot be convereted to Scalar".to_string()), + ))?; + if key_version > latest_key_version { + return Err(MpcContractError::SignError( + SignError::UnsupportedKeyVersion, + )); + } // Check deposit let deposit = env::attached_deposit(); let required_deposit = self.signature_deposit(); if deposit.as_yoctonear() < required_deposit { - env::panic_str(&format!( - "Attached deposit is {}, required deposit is {}", - deposit, required_deposit - )); + return Err(MpcContractError::SignError(SignError::InsufficientDeposit( + deposit.as_yoctonear(), + required_deposit, + ))); } // Make sure sign call will not run out of gas doing recursive calls because the payload will never be removed - assert!( - env::prepaid_gas() >= GAS_FOR_SIGN_CALL, - "Insufficient gas provided. Provided: {} Required: {}", - env::prepaid_gas(), - GAS_FOR_SIGN_CALL - ); + if env::prepaid_gas() < GAS_FOR_SIGN_CALL { + return Err(MpcContractError::SignError(SignError::InsufficientGas( + env::prepaid_gas(), + GAS_FOR_SIGN_CALL, + ))); + } match self { Self::V0(mpc_contract) => { if mpc_contract.request_counter > 8 { - env::panic_str("Too many pending requests. Please, try again later."); + return Err(MpcContractError::SignError(SignError::RequestLimitExceeded)); } } } let predecessor = env::predecessor_account_id(); let request = SignatureRequest::new(payload, &predecessor, &path); if !self.request_already_exists(&request) { - match self { - Self::V0(mpc_contract) => { - let yield_promise = env::promise_yield_create( - "clear_state_on_finish", - &serde_json::to_vec(&(&request,)).unwrap(), - CLEAR_STATE_ON_FINISH_CALL_GAS, - GasWeight(0), - DATA_ID_REGISTER, - ); - - // Store the request in the contract's local state - let data_id: CryptoHash = env::read_register(DATA_ID_REGISTER) - .expect("read_register failed") - .try_into() - .expect("conversion to CryptoHash failed"); - mpc_contract.add_request(&request, data_id); - - log!( - "sign: predecessor={predecessor}, payload={payload:?}, path={path:?}, key_version={key_version}, data_id={data_id:?}", - ); - - env::log_str( - &serde_json::to_string(&near_sdk::env::random_seed_array()).unwrap(), - ); - - // NOTE: there's another promise after the clear_state_on_finish to avoid any errors - // that would rollback the state. - let final_yield_promise = env::promise_then( - yield_promise, - env::current_account_id(), - "return_signature_on_finish", - &[], - NearToken::from_near(0), - RETURN_SIGNATURE_ON_FINISH_CALL_GAS, - ); - // The return value for this function call will be the value - // returned by the `sign_on_finish` callback. - env::promise_return(final_yield_promise); - } - } + log!( + "sign: predecessor={predecessor}, payload={payload:?}, path={path:?}, key_version={key_version}", + ); + env::log_str(&serde_json::to_string(&near_sdk::env::random_seed_array()).unwrap()); + Ok(Self::ext(env::current_account_id()).sign_helper(request)) } else { - env::panic_str("Signature for this payload already requested") + Err(MpcContractError::SignError(SignError::PayloadCollision)) } } - #[private] - pub fn return_signature_on_finish( - &mut self, - #[callback_unwrap] signature: SignatureResult, - ) -> SignatureResponse { - match self { - Self::V0(_) => match signature { - SignatureResult::Ok(signature) => signature, - SignatureResult::Err(_) => { - env::panic_str("Signature has timed out"); - } - }, + /// This is the root public key combined from all the public keys of the participants. + #[handle_result] + pub fn public_key(&self) -> Result { + match self.state() { + ProtocolContractState::Running(state) => Ok(state.public_key.clone()), + ProtocolContractState::Resharing(state) => Ok(state.public_key.clone()), + _ => Err(MpcContractError::PublicKeyError( + PublicKeyError::ProtocolStateNotRunningOrResharing, + )), } } - #[private] - pub fn clear_state_on_finish( - &mut self, - request: SignatureRequest, - #[callback_result] signature: Result, - ) -> SignatureResult { - match self { - Self::V0(mpc_contract) => { - // Clean up the local state - mpc_contract.remove_request(request); + /// This is the derived public key of the caller given path and predecessor + /// if predecessor is not provided, it will be the caller of the contract + #[handle_result] + pub fn derived_public_key( + &self, + path: String, + predecessor: Option, + ) -> Result { + let predecessor = predecessor.unwrap_or_else(env::predecessor_account_id); + let epsilon = derive_epsilon(&predecessor, &path); + let derived_public_key = + derive_key(near_public_key_to_affine_point(self.public_key()?), epsilon); + let encoded_point = derived_public_key.to_encoded_point(false); + let slice: &[u8] = &encoded_point.as_bytes()[1..65]; + let mut data: Vec = vec![near_sdk::CurveType::SECP256K1 as u8]; + data.extend(slice.to_vec()); + PublicKey::try_from(data).map_err(|_| { + MpcContractError::PublicKeyError(PublicKeyError::DerivedKeyConversionFailed) + }) + } - match signature { - Ok(signature) => SignatureResult::Ok(signature), - Err(_) => SignatureResult::Err(SignaturePromiseError::Failed), - } - } - } + /// Key versions refer new versions of the root key that we may choose to generate on cohort changes + /// Older key versions will always work but newer key versions were never held by older signers + /// Newer key versions may also add new security features, like only existing within a secure enclave + /// Currently only 0 is a valid key version + pub const fn latest_key_version(&self) -> u32 { + 0 } +} - pub fn respond(&mut self, request: SignatureRequest, response: SignatureResponse) { +// Node API +#[near_bindgen] +impl VersionedMpcContract { + #[handle_result] + pub fn respond( + &mut self, + request: SignatureRequest, + response: SignatureResponse, + ) -> Result<(), MpcContractError> { let protocol_state = self.mutable_state(); if let ProtocolContractState::Running(_) = protocol_state { @@ -299,10 +253,9 @@ impl VersionedMpcContract { ); // generate the expected public key - let expected_public_key = derive_key( - near_public_key_to_affine_point(self.public_key()), - request.epsilon.scalar, - ); + let pk = self.public_key()?; + let expected_public_key = + derive_key(near_public_key_to_affine_point(pk), request.epsilon.scalar); // Check the signature is correct if check_ec_signature( @@ -314,7 +267,9 @@ impl VersionedMpcContract { ) .is_err() { - env::panic_str("Signature could not be verified"); + return Err(MpcContractError::RespondError( + RespondError::InvalidSignature, + )); } match self { @@ -326,60 +281,28 @@ impl VersionedMpcContract { &data_id, &serde_json::to_vec(&response).unwrap(), ); + Ok(()) } else { - env::panic_str( - "this sign request was removed from pending requests: timed out or completed.", - ) + Err(MpcContractError::RespondError( + RespondError::RequestNotFound, + )) } } } } else { - env::panic_str("protocol is not in a running state"); + Err(MpcContractError::RespondError( + RespondError::ProtocolNotInRunningState, + )) } } - /// This is the root public key combined from all the public keys of the participants. - pub fn public_key(&self) -> PublicKey { - match self.state() { - ProtocolContractState::Running(state) => state.public_key.clone(), - ProtocolContractState::Resharing(state) => state.public_key.clone(), - _ => env::panic_str("public key not available (protocol is not running or resharing)"), - } - } - - /// This is the derived public key of the caller given path and predecessor - /// if predecessor is not provided, it will be the caller of the contract - pub fn derived_public_key(&self, path: String, predecessor: Option) -> PublicKey { - let predecessor = predecessor.unwrap_or_else(env::predecessor_account_id); - let epsilon = derive_epsilon(&predecessor, &path); - let derived_public_key = - derive_key(near_public_key_to_affine_point(self.public_key()), epsilon); - let encoded_point = derived_public_key.to_encoded_point(false); - let slice: &[u8] = &encoded_point.as_bytes()[1..65]; - let mut data: Vec = vec![near_sdk::CurveType::SECP256K1 as u8]; - data.extend(slice.to_vec()); - PublicKey::try_from(data).unwrap() - } - - /// Key versions refer new versions of the root key that we may choose to generate on cohort changes - /// Older key versions will always work but newer key versions were never held by older signers - /// Newer key versions may also add new security features, like only existing within a secure enclave - /// Currently only 0 is a valid key version - pub const fn latest_key_version(&self) -> u32 { - 0 - } - - // contract version - pub fn version(&self) -> String { - env!("CARGO_PKG_VERSION").to_string() - } - + #[handle_result] pub fn join( &mut self, url: String, cipher_pk: primitives::hpke::PublicKey, sign_pk: PublicKey, - ) { + ) -> Result<(), MpcContractError> { log!( "join: signer={}, url={}, cipher_pk={:?}, sign_pk={:?}", env::signer_account_id(), @@ -396,7 +319,9 @@ impl VersionedMpcContract { }) => { let signer_account_id = env::signer_account_id(); if participants.contains_key(&signer_account_id) { - env::panic_str("this participant is already in the participant set"); + return Err(MpcContractError::VoteError( + VoteError::JoinAlreadyParticipant, + )); } candidates.insert( signer_account_id.clone(), @@ -407,12 +332,16 @@ impl VersionedMpcContract { sign_pk, }, ); + Ok(()) } - _ => env::panic_str("protocol state can't accept new participants right now"), + _ => Err(MpcContractError::JoinError( + JoinError::ProtocolStateNotRunning, + )), } } - pub fn vote_join(&mut self, candidate_account_id: AccountId) -> bool { + #[handle_result] + pub fn vote_join(&mut self, candidate_account_id: AccountId) -> Result { log!( "vote_join: signer={}, candidate_account_id={}", env::signer_account_id(), @@ -431,11 +360,11 @@ impl VersionedMpcContract { }) => { let signer_account_id = env::signer_account_id(); if !participants.contains_key(&signer_account_id) { - env::panic_str("calling account is not in the participant set"); + return Err(MpcContractError::VoteError(VoteError::VoterNotParticipant)); } let candidate_info = candidates .get(&candidate_account_id) - .unwrap_or_else(|| env::panic_str("candidate is not registered")); + .ok_or(MpcContractError::VoteError(VoteError::JoinNotCandidate))?; let voted = join_votes.entry(candidate_account_id.clone()); voted.insert(signer_account_id); if voted.len() >= *threshold { @@ -450,16 +379,19 @@ impl VersionedMpcContract { public_key: public_key.clone(), finished_votes: HashSet::new(), }); - true + Ok(true) } else { - false + Ok(false) } } - _ => env::panic_str("protocol state can't accept new participants right now"), + _ => Err(MpcContractError::VoteError( + VoteError::UnexpectedProtocolState("running".to_string()), + )), } } - pub fn vote_leave(&mut self, kick: AccountId) -> bool { + #[handle_result] + pub fn vote_leave(&mut self, kick: AccountId) -> Result { log!( "vote_leave: signer={}, kick={}", env::signer_account_id(), @@ -477,13 +409,15 @@ impl VersionedMpcContract { }) => { let signer_account_id = env::signer_account_id(); if !participants.contains_key(&signer_account_id) { - env::panic_str("calling account is not in the participant set"); + return Err(MpcContractError::VoteError(VoteError::VoterNotParticipant)); } if !participants.contains_key(&kick) { - env::panic_str("account to leave is not in the participant set"); + return Err(MpcContractError::VoteError(VoteError::KickNotParticipant)); } if participants.len() <= *threshold { - env::panic_str("the number of participants can not go below the threshold"); + return Err(MpcContractError::VoteError( + VoteError::ParticipantsBelowThreshold, + )); } let voted = leave_votes.entry(kick.clone()); voted.insert(signer_account_id); @@ -498,16 +432,19 @@ impl VersionedMpcContract { public_key: public_key.clone(), finished_votes: HashSet::new(), }); - true + Ok(true) } else { - false + Ok(false) } } - _ => env::panic_str("protocol state can't kick participants right now"), + _ => Err(MpcContractError::VoteError( + VoteError::UnexpectedProtocolState("running".to_string()), + )), } } - pub fn vote_pk(&mut self, public_key: PublicKey) -> bool { + #[handle_result] + pub fn vote_pk(&mut self, public_key: PublicKey) -> Result { log!( "vote_pk: signer={}, public_key={:?}", env::signer_account_id(), @@ -522,7 +459,7 @@ impl VersionedMpcContract { }) => { let signer_account_id = env::signer_account_id(); if !candidates.contains_key(&signer_account_id) { - env::panic_str("calling account is not in the participant set"); + return Err(MpcContractError::VoteError(VoteError::VoterNotParticipant)); } let voted = pk_votes.entry(public_key.clone()); voted.insert(signer_account_id); @@ -536,18 +473,23 @@ impl VersionedMpcContract { join_votes: Votes::new(), leave_votes: Votes::new(), }); - true + Ok(true) } else { - false + Ok(false) } } - ProtocolContractState::Running(state) if state.public_key == public_key => true, - ProtocolContractState::Resharing(state) if state.public_key == public_key => true, - _ => env::panic_str("can't change public key anymore"), + ProtocolContractState::Running(state) if state.public_key == public_key => Ok(true), + ProtocolContractState::Resharing(state) if state.public_key == public_key => Ok(true), + _ => Err(MpcContractError::VoteError( + VoteError::UnexpectedProtocolState( + "initializing or running/resharing with the same public key".to_string(), + ), + )), } } - pub fn vote_reshared(&mut self, epoch: u64) -> bool { + #[handle_result] + pub fn vote_reshared(&mut self, epoch: u64) -> Result { log!( "vote_reshared: signer={}, epoch={}", env::signer_account_id(), @@ -564,11 +506,11 @@ impl VersionedMpcContract { finished_votes, }) => { if *old_epoch + 1 != epoch { - env::panic_str("mismatched epochs"); + return Err(MpcContractError::VoteError(VoteError::EpochMismatch)); } let signer_account_id = env::signer_account_id(); if !old_participants.contains_key(&signer_account_id) { - env::panic_str("calling account is not in the old participant set"); + return Err(MpcContractError::VoteError(VoteError::VoterNotParticipant)); } finished_votes.insert(signer_account_id); if finished_votes.len() >= *threshold { @@ -581,19 +523,23 @@ impl VersionedMpcContract { join_votes: Votes::new(), leave_votes: Votes::new(), }); - true + Ok(true) } else { - false + Ok(false) } } ProtocolContractState::Running(state) => { if state.epoch == epoch { - true + Ok(true) } else { - env::panic_str("protocol is not resharing right now") + Err(MpcContractError::VoteError( + VoteError::UnexpectedProtocolState("resharing".to_string()), + )) } } - _ => env::panic_str("protocol is not resharing right now"), + _ => Err(MpcContractError::VoteError( + VoteError::UnexpectedProtocolState("resharing".to_string()), + )), } } } @@ -601,8 +547,12 @@ impl VersionedMpcContract { // Contract developer helper API #[near_bindgen] impl VersionedMpcContract { + #[handle_result] #[init] - pub fn init(threshold: usize, candidates: BTreeMap) -> Self { + pub fn init( + threshold: usize, + candidates: BTreeMap, + ) -> Result { log!( "init: signer={}, threshold={}, candidates={}", env::signer_account_id(), @@ -611,21 +561,22 @@ impl VersionedMpcContract { ); if threshold > candidates.len() { - env::panic_str("threshold cannot be greater than the number of candidates"); + return Err(MpcContractError::InitError(InitError::ThresholdTooHigh)); } - Self::V0(MpcContract::init(threshold, candidates)) + Ok(Self::V0(MpcContract::init(threshold, candidates))) } // This function can be used to transfer the MPC network to a new contract. #[private] #[init(ignore_state)] + #[handle_result] pub fn init_running( epoch: u64, participants: BTreeMap, threshold: usize, public_key: PublicKey, - ) -> Self { + ) -> Result { log!( "init_running: signer={}, epoch={}, participants={}, threshold={}, public_key={:?}", env::signer_account_id(), @@ -636,10 +587,10 @@ impl VersionedMpcContract { ); if threshold > participants.len() { - env::panic_str("threshold cannot be greater than the number of participants"); + return Err(MpcContractError::InitError(InitError::ThresholdTooHigh)); } - Self::V0(MpcContract { + Ok(Self::V0(MpcContract { protocol_state: ProtocolContractState::Running(RunningContractState { epoch, participants: Participants { participants }, @@ -651,7 +602,7 @@ impl VersionedMpcContract { }), pending_requests: LookupMap::new(StorageKey::PendingRequests), request_counter: 0, - }) + })) } pub fn state(&self) -> &ProtocolContractState { @@ -660,6 +611,81 @@ impl VersionedMpcContract { } } + // contract version + pub fn version(&self) -> String { + env!("CARGO_PKG_VERSION").to_string() + } + + #[private] + pub fn sign_helper(&mut self, request: SignatureRequest) { + match self { + Self::V0(mpc_contract) => { + let yield_promise = env::promise_yield_create( + "clear_state_on_finish", + &serde_json::to_vec(&(&request,)).unwrap(), + CLEAR_STATE_ON_FINISH_CALL_GAS, + GasWeight(0), + DATA_ID_REGISTER, + ); + + // Store the request in the contract's local state + let data_id: CryptoHash = env::read_register(DATA_ID_REGISTER) + .expect("read_register failed") + .try_into() + .expect("conversion to CryptoHash failed"); + + mpc_contract.add_request(&request, data_id); + + // NOTE: there's another promise after the clear_state_on_finish to avoid any errors + // that would rollback the state. + let final_yield_promise = env::promise_then( + yield_promise, + env::current_account_id(), + "return_signature_on_finish", + &[], + NearToken::from_near(0), + RETURN_SIGNATURE_ON_FINISH_CALL_GAS, + ); + // The return value for this function call will be the value + // returned by the `sign_on_finish` callback. + env::promise_return(final_yield_promise); + } + } + } + + #[private] + #[handle_result] + pub fn return_signature_on_finish( + &mut self, + #[callback_unwrap] signature: SignatureResult, + ) -> Result { + match self { + Self::V0(_) => match signature { + SignatureResult::Ok(signature) => Ok(signature), + SignatureResult::Err(_) => Err(MpcContractError::SignError(SignError::Timeout)), + }, + } + } + + #[private] + #[handle_result] + pub fn clear_state_on_finish( + &mut self, + request: SignatureRequest, + #[callback_result] signature: Result, + ) -> Result, MpcContractError> { + match self { + Self::V0(mpc_contract) => { + // Clean up the local state + mpc_contract.remove_request(request)?; + match signature { + Ok(signature) => Ok(SignatureResult::Ok(signature)), + Err(_) => Ok(SignatureResult::Err(SignaturePromiseError::Failed)), + } + } + } + } + #[private] #[init(ignore_state)] pub fn clean(keys: Vec) -> Self { diff --git a/chain-signatures/contract/src/primitives.rs b/chain-signatures/contract/src/primitives.rs index 354b2143e..1e5c066d6 100644 --- a/chain-signatures/contract/src/primitives.rs +++ b/chain-signatures/contract/src/primitives.rs @@ -1,12 +1,49 @@ +use crypto_shared::{derive_epsilon, SerializableScalar}; +use k256::Scalar; use near_sdk::borsh::{self, BorshDeserialize, BorshSerialize}; use near_sdk::serde::{Deserialize, Serialize}; -use near_sdk::{AccountId, PublicKey}; +use near_sdk::{AccountId, BorshStorageKey, CryptoHash, PublicKey}; use std::collections::{BTreeMap, HashSet}; pub mod hpke { pub type PublicKey = [u8; 32]; } +#[derive(BorshSerialize, BorshDeserialize, BorshStorageKey, Hash, Clone, Debug, PartialEq, Eq)] +#[borsh(crate = "near_sdk::borsh")] +pub enum StorageKey { + PendingRequests, +} + +/// 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(BorshDeserialize, BorshSerialize, Serialize, Deserialize, Debug, Clone)] +#[borsh(crate = "near_sdk::borsh")] +pub struct YieldIndex { + pub data_id: CryptoHash, +} + +#[derive(BorshDeserialize, BorshSerialize, Serialize, Deserialize, Debug, Clone)] +#[borsh(crate = "near_sdk::borsh")] +pub struct SignatureRequest { + pub epsilon: SerializableScalar, + pub payload_hash: SerializableScalar, +} + +impl SignatureRequest { + pub fn new(payload_hash: Scalar, predecessor_id: &AccountId, path: &str) -> Self { + let epsilon = derive_epsilon(predecessor_id, path); + let epsilon = SerializableScalar { scalar: epsilon }; + let payload_hash = SerializableScalar { + scalar: payload_hash, + }; + SignatureRequest { + epsilon, + payload_hash, + } + } +} + #[derive( Serialize, Deserialize, diff --git a/chain-signatures/contract/tests/tests.rs b/chain-signatures/contract/tests/tests.rs index 49a10fe0e..0ee6122d5 100644 --- a/chain-signatures/contract/tests/tests.rs +++ b/chain-signatures/contract/tests/tests.rs @@ -8,8 +8,8 @@ use k256::elliptic_curve::ops::Reduce; use k256::elliptic_curve::point::DecompressPoint; use k256::elliptic_curve::sec1::ToEncodedPoint; use k256::{AffinePoint, FieldBytes, Scalar, Secp256k1}; -use mpc_contract::primitives::{CandidateInfo, ParticipantInfo, SignRequest}; -use mpc_contract::SignatureRequest; +use mpc_contract::errors; +use mpc_contract::primitives::{CandidateInfo, ParticipantInfo, SignRequest, SignatureRequest}; use near_sdk::NearToken; use near_workspaces::network::Sandbox; use near_workspaces::{AccountId, Contract, Worker}; @@ -252,7 +252,9 @@ async fn test_contract_sign_request() -> anyhow::Result<()> { let err = sign_and_validate(&request, None, &contract) .await .expect_err("should have failed with timeout"); - assert!(err.to_string().contains("Signature has timed out")); + assert!(err + .to_string() + .contains(&errors::MpcContractError::SignError(errors::SignError::Timeout).to_string())); Ok(()) } @@ -295,19 +297,16 @@ async fn test_contract_sign_request_deposits() -> anyhow::Result<()> { .transact() .await?; dbg!(&respond); - assert!(respond - .into_result() - .unwrap_err() - .to_string() - .contains("timed out or completed")); + assert!(respond.into_result().unwrap_err().to_string().contains( + &errors::MpcContractError::RespondError(errors::RespondError::RequestNotFound).to_string() + )); let execution = status.await?; dbg!(&execution); - assert!(execution - .into_result() - .unwrap_err() - .to_string() - .contains("required deposit is 1")); + assert!(execution.into_result().unwrap_err().to_string().contains( + &errors::MpcContractError::SignError(errors::SignError::InsufficientDeposit(0, 1)) + .to_string() + )); Ok(()) } diff --git a/chain-signatures/node/src/protocol/presignature.rs b/chain-signatures/node/src/protocol/presignature.rs index cb51b8ba1..ec2e00ed5 100644 --- a/chain-signatures/node/src/protocol/presignature.rs +++ b/chain-signatures/node/src/protocol/presignature.rs @@ -516,7 +516,7 @@ impl PresignatureManager { }); if !errors.is_empty() { - tracing::warn!(?errors, "faled to generate some presignatures"); + tracing::warn!(?errors, "failed to generate some presignatures"); } messages diff --git a/chain-signatures/node/src/protocol/signature.rs b/chain-signatures/node/src/protocol/signature.rs index c0f09df1b..a5ad25a29 100644 --- a/chain-signatures/node/src/protocol/signature.rs +++ b/chain-signatures/node/src/protocol/signature.rs @@ -12,7 +12,7 @@ use chrono::Utc; use crypto_shared::SerializableScalar; use crypto_shared::{derive_key, PublicKey}; use k256::{Scalar, Secp256k1}; -use mpc_contract::SignatureRequest; +use mpc_contract::primitives::SignatureRequest; use rand::rngs::StdRng; use rand::seq::{IteratorRandom, SliceRandom}; use rand::SeedableRng; diff --git a/integration-tests/chain-signatures/Cargo.lock b/integration-tests/chain-signatures/Cargo.lock index be47ad50c..f07ddc593 100644 --- a/integration-tests/chain-signatures/Cargo.lock +++ b/integration-tests/chain-signatures/Cargo.lock @@ -4248,6 +4248,7 @@ dependencies = [ "schemars", "serde", "serde_json", + "thiserror", ] [[package]] diff --git a/integration-tests/chain-signatures/tests/actions/mod.rs b/integration-tests/chain-signatures/tests/actions/mod.rs index 8087e1274..00f0db451 100644 --- a/integration-tests/chain-signatures/tests/actions/mod.rs +++ b/integration-tests/chain-signatures/tests/actions/mod.rs @@ -13,9 +13,10 @@ use k256::elliptic_curve::point::AffineCoordinates; use k256::elliptic_curve::sec1::FromEncodedPoint; use k256::elliptic_curve::ProjectivePoint; use k256::{AffinePoint, EncodedPoint, Scalar, Secp256k1}; +use mpc_contract::errors; use mpc_contract::primitives::SignRequest; +use mpc_contract::primitives::SignatureRequest; use mpc_contract::RunningContractState; -use mpc_contract::SignatureRequest; use mpc_recovery_node::kdf::into_eth_sig; use near_crypto::InMemorySigner; use near_jsonrpc_client::methods::broadcast_tx_async::RpcBroadcastTxAsyncRequest; @@ -111,10 +112,9 @@ pub async fn single_signature_rogue_responder( let err = wait_for::rogue_message_responded(ctx, rogue_hash).await?; - assert_eq!( - err, - "Smart contract panicked: Signature could not be verified".to_string() - ); + assert!(err.contains( + &errors::MpcContractError::RespondError(errors::RespondError::InvalidSignature).to_string() + )); let signature = wait_for::signature_responded(ctx, tx_hash).await?; diff --git a/integration-tests/chain-signatures/tests/cases/mod.rs b/integration-tests/chain-signatures/tests/cases/mod.rs index 917c6cae0..da8a098a5 100644 --- a/integration-tests/chain-signatures/tests/cases/mod.rs +++ b/integration-tests/chain-signatures/tests/cases/mod.rs @@ -32,7 +32,7 @@ async fn test_multichain_reshare() -> anyhow::Result<()> { let new_state = wait_for::running_mpc(&ctx, None).await?; wait_for::has_at_least_triples(&ctx, 2).await?; wait_for::has_at_least_presignatures(&ctx, 2).await?; - actions::single_signature_production(&ctx, &new_state).await + actions::single_payload_signature_production(&ctx, &new_state).await }) }) .await @@ -315,7 +315,7 @@ async fn test_multichain_reshare_with_lake_congestion() -> anyhow::Result<()> { let new_state = wait_for::running_mpc(&ctx, None).await?; wait_for::has_at_least_triples(&ctx, 2).await?; wait_for::has_at_least_presignatures(&ctx, 2).await?; - actions::single_signature_production(&ctx, &new_state).await + actions::single_payload_signature_production(&ctx, &new_state).await }) }) .await