From 63521f6a965107590d5104ae16264169fd59d3e2 Mon Sep 17 00:00:00 2001 From: noot <36753753+noot@users.noreply.github.com> Date: Sun, 24 Mar 2024 09:00:54 -0400 Subject: [PATCH] implement Router.sol and associated functions (#92) * start Router contract * use calldata for function args * var name changes * start testing router contract * test with and without abi.encode * cleanup * why tf isn't tests/utils working * cleanup tests * remove unused files * wip * fix router contract and tests, add set/update public keys funcs * impl some Froms * make execute non-reentrant * cleanup * update Router to use ReentrancyGuard * update contract to use errors, use bitfield in Executed event, minor other fixes * wip * fix build issues from merge, tests ok * Router.sol cleanup * cleanup, uncomment stuff * bump ethers.rs version to latest * make contract functions take generic middleware * update build script to assert no compiler errors * hardcode pubkey parity into contract, update tests * Polish coins/ethereum in various ways --------- Co-authored-by: Luke Parker --- .github/actions/build-dependencies/action.yml | 4 +- coins/ethereum/.gitignore | 6 +- coins/ethereum/Cargo.toml | 3 + coins/ethereum/build.rs | 35 ++++- coins/ethereum/contracts/Router.sol | 90 ++++++++++++ coins/ethereum/contracts/Schnorr.sol | 29 ++-- coins/ethereum/src/abi/mod.rs | 6 + coins/ethereum/src/contract.rs | 36 ----- coins/ethereum/src/crypto.rs | 124 +++++++--------- coins/ethereum/src/lib.rs | 16 ++- coins/ethereum/src/router.rs | 30 ++++ coins/ethereum/src/schnorr.rs | 34 +++++ coins/ethereum/src/tests/crypto.rs | 132 ++++++++++++++++++ coins/ethereum/src/tests/mod.rs | 92 ++++++++++++ coins/ethereum/src/tests/router.rs | 109 +++++++++++++++ coins/ethereum/src/tests/schnorr.rs | 67 +++++++++ coins/ethereum/tests/contract.rs | 128 ----------------- coins/ethereum/tests/crypto.rs | 87 ------------ coins/ethereum/tests/mod.rs | 2 - spec/Getting Started.md | 8 +- 20 files changed, 690 insertions(+), 348 deletions(-) create mode 100644 coins/ethereum/contracts/Router.sol create mode 100644 coins/ethereum/src/abi/mod.rs delete mode 100644 coins/ethereum/src/contract.rs create mode 100644 coins/ethereum/src/router.rs create mode 100644 coins/ethereum/src/schnorr.rs create mode 100644 coins/ethereum/src/tests/crypto.rs create mode 100644 coins/ethereum/src/tests/mod.rs create mode 100644 coins/ethereum/src/tests/router.rs create mode 100644 coins/ethereum/src/tests/schnorr.rs delete mode 100644 coins/ethereum/tests/contract.rs delete mode 100644 coins/ethereum/tests/crypto.rs delete mode 100644 coins/ethereum/tests/mod.rs diff --git a/.github/actions/build-dependencies/action.yml b/.github/actions/build-dependencies/action.yml index 2a8e8ed8..5994b723 100644 --- a/.github/actions/build-dependencies/action.yml +++ b/.github/actions/build-dependencies/action.yml @@ -42,8 +42,8 @@ runs: shell: bash run: | cargo install svm-rs - svm install 0.8.16 - svm use 0.8.16 + svm install 0.8.25 + svm use 0.8.25 # - name: Cache Rust # uses: Swatinem/rust-cache@a95ba195448af2da9b00fb742d14ffaaf3c21f43 diff --git a/coins/ethereum/.gitignore b/coins/ethereum/.gitignore index 6ff35861..46365e03 100644 --- a/coins/ethereum/.gitignore +++ b/coins/ethereum/.gitignore @@ -1,3 +1,7 @@ -# solidity build outputs +# Solidity build outputs cache artifacts + +# Auto-generated ABI files +src/abi/schnorr.rs +src/abi/router.rs diff --git a/coins/ethereum/Cargo.toml b/coins/ethereum/Cargo.toml index 1d1c6dbb..bc60d3a4 100644 --- a/coins/ethereum/Cargo.toml +++ b/coins/ethereum/Cargo.toml @@ -30,6 +30,9 @@ ethers-core = { version = "2", default-features = false } ethers-providers = { version = "2", default-features = false } ethers-contract = { version = "2", default-features = false, features = ["abigen", "providers"] } +[build-dependencies] +ethers-contract = { version = "2", default-features = false, features = ["abigen", "providers"] } + [dev-dependencies] rand_core = { version = "0.6", default-features = false, features = ["std"] } diff --git a/coins/ethereum/build.rs b/coins/ethereum/build.rs index 2166f6ad..3590b12f 100644 --- a/coins/ethereum/build.rs +++ b/coins/ethereum/build.rs @@ -1,6 +1,20 @@ +use std::process::Command; + +use ethers_contract::Abigen; + fn main() { - println!("cargo:rerun-if-changed=contracts"); - println!("cargo:rerun-if-changed=artifacts"); + println!("cargo:rerun-if-changed=contracts/*"); + println!("cargo:rerun-if-changed=artifacts/*"); + + for line in String::from_utf8(Command::new("solc").args(["--version"]).output().unwrap().stdout) + .unwrap() + .lines() + { + if let Some(version) = line.strip_prefix("Version: ") { + let version = version.split('+').next().unwrap(); + assert_eq!(version, "0.8.25"); + } + } #[rustfmt::skip] let args = [ @@ -8,8 +22,21 @@ fn main() { "-o", "./artifacts", "--overwrite", "--bin", "--abi", "--optimize", - "./contracts/Schnorr.sol" + "./contracts/Schnorr.sol", "./contracts/Router.sol", ]; + assert!(Command::new("solc").args(args).status().unwrap().success()); - assert!(std::process::Command::new("solc").args(args).status().unwrap().success()); + Abigen::new("Schnorr", "./artifacts/Schnorr.abi") + .unwrap() + .generate() + .unwrap() + .write_to_file("./src/abi/schnorr.rs") + .unwrap(); + + Abigen::new("Router", "./artifacts/Router.abi") + .unwrap() + .generate() + .unwrap() + .write_to_file("./src/abi/router.rs") + .unwrap(); } diff --git a/coins/ethereum/contracts/Router.sol b/coins/ethereum/contracts/Router.sol new file mode 100644 index 00000000..25775ec5 --- /dev/null +++ b/coins/ethereum/contracts/Router.sol @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: AGPLv3 +pragma solidity ^0.8.0; + +import "./Schnorr.sol"; + +contract Router is Schnorr { + // Contract initializer + // TODO: Replace with a MuSig of the genesis validators + address public initializer; + + // Nonce is incremented for each batch of transactions executed + uint256 public nonce; + + // fixed parity for the public keys used in this contract + uint8 constant public KEY_PARITY = 27; + + // current public key's x-coordinate + // note: this key must always use the fixed parity defined above + bytes32 public seraiKey; + + struct OutInstruction { + address to; + uint256 value; + bytes data; + } + + struct Signature { + bytes32 c; + bytes32 s; + } + + // success is a uint256 representing a bitfield of transaction successes + event Executed(uint256 nonce, bytes32 batch, uint256 success); + + // error types + error NotInitializer(); + error AlreadyInitialized(); + error InvalidKey(); + error TooManyTransactions(); + + constructor() { + initializer = msg.sender; + } + + // initSeraiKey can be called by the contract initializer to set the first + // public key, only if the public key has yet to be set. + function initSeraiKey(bytes32 _seraiKey) external { + if (msg.sender != initializer) revert NotInitializer(); + if (seraiKey != 0) revert AlreadyInitialized(); + if (_seraiKey == bytes32(0)) revert InvalidKey(); + seraiKey = _seraiKey; + } + + // updateSeraiKey validates the given Schnorr signature against the current public key, + // and if successful, updates the contract's public key to the given one. + function updateSeraiKey( + bytes32 _seraiKey, + Signature memory sig + ) public { + if (_seraiKey == bytes32(0)) revert InvalidKey(); + bytes32 message = keccak256(abi.encodePacked("updateSeraiKey", _seraiKey)); + if (!verify(KEY_PARITY, seraiKey, message, sig.c, sig.s)) revert InvalidSignature(); + seraiKey = _seraiKey; + } + + // execute accepts a list of transactions to execute as well as a Schnorr signature. + // if signature verification passes, the given transactions are executed. + // if signature verification fails, this function will revert. + function execute( + OutInstruction[] calldata transactions, + Signature memory sig + ) public { + if (transactions.length > 256) revert TooManyTransactions(); + + bytes32 message = keccak256(abi.encode("execute", nonce, transactions)); + // This prevents re-entrancy from causing double spends yet does allow + // out-of-order execution via re-entrancy + nonce++; + if (!verify(KEY_PARITY, seraiKey, message, sig.c, sig.s)) revert InvalidSignature(); + + uint256 successes; + for(uint256 i = 0; i < transactions.length; i++) { + (bool success, ) = transactions[i].to.call{value: transactions[i].value, gas: 200_000}(transactions[i].data); + assembly { + successes := or(successes, shl(i, success)) + } + } + emit Executed(nonce, message, successes); + } +} diff --git a/coins/ethereum/contracts/Schnorr.sol b/coins/ethereum/contracts/Schnorr.sol index 3f0196b2..47263e66 100644 --- a/coins/ethereum/contracts/Schnorr.sol +++ b/coins/ethereum/contracts/Schnorr.sol @@ -1,4 +1,4 @@ -//SPDX-License-Identifier: AGPLv3 +// SPDX-License-Identifier: AGPLv3 pragma solidity ^0.8.0; // see https://github.com/noot/schnorr-verify for implementation details @@ -7,29 +7,32 @@ contract Schnorr { uint256 constant public Q = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141; + error InvalidSOrA(); + error InvalidSignature(); + // parity := public key y-coord parity (27 or 28) // px := public key x-coord - // message := 32-byte message + // message := 32-byte hash of the message + // c := schnorr signature challenge // s := schnorr signature - // e := schnorr signature challenge function verify( uint8 parity, bytes32 px, bytes32 message, - bytes32 s, - bytes32 e + bytes32 c, + bytes32 s ) public view returns (bool) { // ecrecover = (m, v, r, s); - bytes32 sp = bytes32(Q - mulmod(uint256(s), uint256(px), Q)); - bytes32 ep = bytes32(Q - mulmod(uint256(e), uint256(px), Q)); + bytes32 sa = bytes32(Q - mulmod(uint256(s), uint256(px), Q)); + bytes32 ca = bytes32(Q - mulmod(uint256(c), uint256(px), Q)); - require(sp != 0); + if (sa == 0) revert InvalidSOrA(); // the ecrecover precompile implementation checks that the `r` and `s` - // inputs are non-zero (in this case, `px` and `ep`), thus we don't need to - // check if they're zero.will make me - address R = ecrecover(sp, parity, px, ep); - require(R != address(0), "ecrecover failed"); - return e == keccak256( + // inputs are non-zero (in this case, `px` and `ca`), thus we don't need to + // check if they're zero. + address R = ecrecover(sa, parity, px, ca); + if (R == address(0)) revert InvalidSignature(); + return c == keccak256( abi.encodePacked(R, uint8(parity), px, block.chainid, message) ); } diff --git a/coins/ethereum/src/abi/mod.rs b/coins/ethereum/src/abi/mod.rs new file mode 100644 index 00000000..2d7dd47c --- /dev/null +++ b/coins/ethereum/src/abi/mod.rs @@ -0,0 +1,6 @@ +#[rustfmt::skip] +#[allow(clippy::all)] +pub(crate) mod schnorr; +#[rustfmt::skip] +#[allow(clippy::all)] +pub(crate) mod router; diff --git a/coins/ethereum/src/contract.rs b/coins/ethereum/src/contract.rs deleted file mode 100644 index 80093b08..00000000 --- a/coins/ethereum/src/contract.rs +++ /dev/null @@ -1,36 +0,0 @@ -use thiserror::Error; -use eyre::{eyre, Result}; - -use ethers_providers::{Provider, Http}; -use ethers_contract::abigen; - -use crate::crypto::ProcessedSignature; - -#[derive(Error, Debug)] -pub enum EthereumError { - #[error("failed to verify Schnorr signature")] - VerificationError, -} - -abigen!(Schnorr, "./artifacts/Schnorr.abi"); - -pub async fn call_verify( - contract: &Schnorr>, - params: &ProcessedSignature, -) -> Result<()> { - if contract - .verify( - params.parity + 27, - params.px.to_bytes().into(), - params.message, - params.s.to_bytes().into(), - params.e.to_bytes().into(), - ) - .call() - .await? - { - Ok(()) - } else { - Err(eyre!(EthereumError::VerificationError)) - } -} diff --git a/coins/ethereum/src/crypto.rs b/coins/ethereum/src/crypto.rs index 3e9d50fa..5f681cfa 100644 --- a/coins/ethereum/src/crypto.rs +++ b/coins/ethereum/src/crypto.rs @@ -1,50 +1,54 @@ use sha3::{Digest, Keccak256}; -use group::Group; +use group::ff::PrimeField; use k256::{ elliptic_curve::{ - bigint::ArrayEncoding, ops::Reduce, point::DecompressPoint, sec1::ToEncodedPoint, + bigint::ArrayEncoding, ops::Reduce, point::AffineCoordinates, sec1::ToEncodedPoint, }, - AffinePoint, ProjectivePoint, Scalar, U256, + ProjectivePoint, Scalar, U256, }; -use frost::{algorithm::Hram, curve::Secp256k1}; +use frost::{ + algorithm::{Hram, SchnorrSignature}, + curve::Secp256k1, +}; -pub fn keccak256(data: &[u8]) -> [u8; 32] { +pub(crate) fn keccak256(data: &[u8]) -> [u8; 32] { Keccak256::digest(data).into() } -pub fn hash_to_scalar(data: &[u8]) -> Scalar { - Scalar::reduce(U256::from_be_slice(&keccak256(data))) -} - -pub fn address(point: &ProjectivePoint) -> [u8; 20] { +pub(crate) fn address(point: &ProjectivePoint) -> [u8; 20] { let encoded_point = point.to_encoded_point(false); - keccak256(&encoded_point.as_ref()[1 .. 65])[12 .. 32].try_into().unwrap() + // Last 20 bytes of the hash of the concatenated x and y coordinates + // We obtain the concatenated x and y coordinates via the uncompressed encoding of the point + keccak256(&encoded_point.as_ref()[1 .. 65])[12 ..].try_into().unwrap() } -pub fn ecrecover(message: Scalar, v: u8, r: Scalar, s: Scalar) -> Option<[u8; 20]> { - if r.is_zero().into() || s.is_zero().into() { - return None; - } +#[allow(non_snake_case)] +pub struct PublicKey { + pub A: ProjectivePoint, + pub px: Scalar, + pub parity: u8, +} +impl PublicKey { #[allow(non_snake_case)] - let R = AffinePoint::decompress(&r.to_bytes(), v.into()); - #[allow(non_snake_case)] - if let Some(R) = Option::::from(R) { - #[allow(non_snake_case)] - let R = ProjectivePoint::from(R); - - let r = r.invert().unwrap(); - let u1 = ProjectivePoint::GENERATOR * (-message * r); - let u2 = R * (s * r); - let key: ProjectivePoint = u1 + u2; - if !bool::from(key.is_identity()) { - return Some(address(&key)); + pub fn new(A: ProjectivePoint) -> Option { + let affine = A.to_affine(); + let parity = u8::from(bool::from(affine.y_is_odd())) + 27; + if parity != 27 { + None?; } - } - None + let x_coord = affine.x(); + let x_coord_scalar = >::reduce_bytes(&x_coord); + // Return None if a reduction would occur + if x_coord_scalar.to_repr() != x_coord { + None?; + } + + Some(PublicKey { A, px: x_coord_scalar, parity }) + } } #[derive(Clone, Default)] @@ -55,53 +59,33 @@ impl Hram for EthereumHram { let a_encoded_point = A.to_encoded_point(true); let mut a_encoded = a_encoded_point.as_ref().to_owned(); a_encoded[0] += 25; // Ethereum uses 27/28 for point parity + assert!((a_encoded[0] == 27) || (a_encoded[0] == 28)); let mut data = address(R).to_vec(); data.append(&mut a_encoded); - data.append(&mut m.to_vec()); + data.extend(m); Scalar::reduce(U256::from_be_slice(&keccak256(&data))) } } -pub struct ProcessedSignature { - pub s: Scalar, - pub px: Scalar, - pub parity: u8, - pub message: [u8; 32], - pub e: Scalar, +pub struct Signature { + pub(crate) c: Scalar, + pub(crate) s: Scalar, } - -#[allow(non_snake_case)] -pub fn preprocess_signature_for_ecrecover( - m: [u8; 32], - R: &ProjectivePoint, - s: Scalar, - A: &ProjectivePoint, - chain_id: U256, -) -> (Scalar, Scalar) { - let processed_sig = process_signature_for_contract(m, R, s, A, chain_id); - let sr = processed_sig.s.mul(&processed_sig.px).negate(); - let er = processed_sig.e.mul(&processed_sig.px).negate(); - (sr, er) -} - -#[allow(non_snake_case)] -pub fn process_signature_for_contract( - m: [u8; 32], - R: &ProjectivePoint, - s: Scalar, - A: &ProjectivePoint, - chain_id: U256, -) -> ProcessedSignature { - let encoded_pk = A.to_encoded_point(true); - let px = &encoded_pk.as_ref()[1 .. 33]; - let px_scalar = Scalar::reduce(U256::from_be_slice(px)); - let e = EthereumHram::hram(R, A, &[chain_id.to_be_byte_array().as_slice(), &m].concat()); - ProcessedSignature { - s, - px: px_scalar, - parity: &encoded_pk.as_ref()[0] - 2, - #[allow(non_snake_case)] - message: m, - e, +impl Signature { + pub fn new( + public_key: &PublicKey, + chain_id: U256, + m: &[u8], + signature: SchnorrSignature, + ) -> Option { + let c = EthereumHram::hram( + &signature.R, + &public_key.A, + &[chain_id.to_be_byte_array().as_slice(), &keccak256(m)].concat(), + ); + if !signature.verify(public_key.A, c) { + None?; + } + Some(Signature { c, s: signature.s }) } } diff --git a/coins/ethereum/src/lib.rs b/coins/ethereum/src/lib.rs index 75a58525..505de38e 100644 --- a/coins/ethereum/src/lib.rs +++ b/coins/ethereum/src/lib.rs @@ -1,2 +1,16 @@ -pub mod contract; +use thiserror::Error; + pub mod crypto; + +pub(crate) mod abi; +pub mod schnorr; +pub mod router; + +#[cfg(test)] +mod tests; + +#[derive(Error, Debug)] +pub enum Error { + #[error("failed to verify Schnorr signature")] + InvalidSignature, +} diff --git a/coins/ethereum/src/router.rs b/coins/ethereum/src/router.rs new file mode 100644 index 00000000..3696fd9b --- /dev/null +++ b/coins/ethereum/src/router.rs @@ -0,0 +1,30 @@ +pub use crate::abi::router::*; + +/* +use crate::crypto::{ProcessedSignature, PublicKey}; +use ethers::{contract::ContractFactory, prelude::*, solc::artifacts::contract::ContractBytecode}; +use eyre::Result; +use std::{convert::From, fs::File, sync::Arc}; + +pub async fn router_update_public_key( + contract: &Router, + public_key: &PublicKey, + signature: &ProcessedSignature, +) -> std::result::Result, eyre::ErrReport> { + let tx = contract.update_public_key(public_key.px.to_bytes().into(), signature.into()); + let pending_tx = tx.send().await?; + let receipt = pending_tx.await?; + Ok(receipt) +} + +pub async fn router_execute( + contract: &Router, + txs: Vec, + signature: &ProcessedSignature, +) -> std::result::Result, eyre::ErrReport> { + let tx = contract.execute(txs, signature.into()).send(); + let pending_tx = tx.send().await?; + let receipt = pending_tx.await?; + Ok(receipt) +} +*/ diff --git a/coins/ethereum/src/schnorr.rs b/coins/ethereum/src/schnorr.rs new file mode 100644 index 00000000..0e4495ec --- /dev/null +++ b/coins/ethereum/src/schnorr.rs @@ -0,0 +1,34 @@ +use eyre::{eyre, Result}; + +use group::ff::PrimeField; + +use ethers_providers::{Provider, Http}; + +use crate::{ + Error, + crypto::{keccak256, PublicKey, Signature}, +}; +pub use crate::abi::schnorr::*; + +pub async fn call_verify( + contract: &Schnorr>, + public_key: &PublicKey, + message: &[u8], + signature: &Signature, +) -> Result<()> { + if contract + .verify( + public_key.parity, + public_key.px.to_repr().into(), + keccak256(message), + signature.c.to_repr().into(), + signature.s.to_repr().into(), + ) + .call() + .await? + { + Ok(()) + } else { + Err(eyre!(Error::InvalidSignature)) + } +} diff --git a/coins/ethereum/src/tests/crypto.rs b/coins/ethereum/src/tests/crypto.rs new file mode 100644 index 00000000..6dced933 --- /dev/null +++ b/coins/ethereum/src/tests/crypto.rs @@ -0,0 +1,132 @@ +use rand_core::OsRng; + +use sha2::Sha256; +use sha3::{Digest, Keccak256}; + +use group::Group; +use k256::{ + ecdsa::{hazmat::SignPrimitive, signature::DigestVerifier, SigningKey, VerifyingKey}, + elliptic_curve::{bigint::ArrayEncoding, ops::Reduce, point::DecompressPoint}, + U256, Scalar, AffinePoint, ProjectivePoint, +}; + +use frost::{ + curve::Secp256k1, + algorithm::{Hram, IetfSchnorr}, + tests::{algorithm_machines, sign}, +}; + +use crate::{crypto::*, tests::key_gen}; + +pub fn hash_to_scalar(data: &[u8]) -> Scalar { + Scalar::reduce(U256::from_be_slice(&keccak256(data))) +} + +pub(crate) fn ecrecover(message: Scalar, v: u8, r: Scalar, s: Scalar) -> Option<[u8; 20]> { + if r.is_zero().into() || s.is_zero().into() || !((v == 27) || (v == 28)) { + return None; + } + + #[allow(non_snake_case)] + let R = AffinePoint::decompress(&r.to_bytes(), (v - 27).into()); + #[allow(non_snake_case)] + if let Some(R) = Option::::from(R) { + #[allow(non_snake_case)] + let R = ProjectivePoint::from(R); + + let r = r.invert().unwrap(); + let u1 = ProjectivePoint::GENERATOR * (-message * r); + let u2 = R * (s * r); + let key: ProjectivePoint = u1 + u2; + if !bool::from(key.is_identity()) { + return Some(address(&key)); + } + } + + None +} + +#[test] +fn test_ecrecover() { + let private = SigningKey::random(&mut OsRng); + let public = VerifyingKey::from(&private); + + // Sign the signature + const MESSAGE: &[u8] = b"Hello, World!"; + let (sig, recovery_id) = private + .as_nonzero_scalar() + .try_sign_prehashed_rfc6979::(&Keccak256::digest(MESSAGE), b"") + .unwrap(); + + // Sanity check the signature verifies + #[allow(clippy::unit_cmp)] // Intended to assert this wasn't changed to Result + { + assert_eq!(public.verify_digest(Keccak256::new_with_prefix(MESSAGE), &sig).unwrap(), ()); + } + + // Perform the ecrecover + assert_eq!( + ecrecover( + hash_to_scalar(MESSAGE), + u8::from(recovery_id.unwrap().is_y_odd()) + 27, + *sig.r(), + *sig.s() + ) + .unwrap(), + address(&ProjectivePoint::from(public.as_affine())) + ); +} + +// Run the sign test with the EthereumHram +#[test] +fn test_signing() { + let (keys, _) = key_gen(); + + const MESSAGE: &[u8] = b"Hello, World!"; + + let algo = IetfSchnorr::::ietf(); + let _sig = + sign(&mut OsRng, &algo, keys.clone(), algorithm_machines(&mut OsRng, &algo, &keys), MESSAGE); +} + +#[allow(non_snake_case)] +pub fn preprocess_signature_for_ecrecover( + R: ProjectivePoint, + public_key: &PublicKey, + chain_id: U256, + m: &[u8], + s: Scalar, +) -> (u8, Scalar, Scalar) { + let c = EthereumHram::hram( + &R, + &public_key.A, + &[chain_id.to_be_byte_array().as_slice(), &keccak256(m)].concat(), + ); + let sa = -(s * public_key.px); + let ca = -(c * public_key.px); + (public_key.parity, sa, ca) +} + +#[test] +fn test_ecrecover_hack() { + let (keys, public_key) = key_gen(); + + const MESSAGE: &[u8] = b"Hello, World!"; + let hashed_message = keccak256(MESSAGE); + let chain_id = U256::ONE; + let full_message = &[chain_id.to_be_byte_array().as_slice(), &hashed_message].concat(); + + let algo = IetfSchnorr::::ietf(); + let sig = sign( + &mut OsRng, + &algo, + keys.clone(), + algorithm_machines(&mut OsRng, &algo, &keys), + full_message, + ); + + let (parity, sa, ca) = + preprocess_signature_for_ecrecover(sig.R, &public_key, chain_id, MESSAGE, sig.s); + let q = ecrecover(sa, parity, public_key.px, ca).unwrap(); + assert_eq!(q, address(&sig.R)); +} diff --git a/coins/ethereum/src/tests/mod.rs b/coins/ethereum/src/tests/mod.rs new file mode 100644 index 00000000..c468cfb6 --- /dev/null +++ b/coins/ethereum/src/tests/mod.rs @@ -0,0 +1,92 @@ +use std::{sync::Arc, time::Duration, fs::File, collections::HashMap}; + +use rand_core::OsRng; + +use group::ff::PrimeField; +use k256::{Scalar, ProjectivePoint}; +use frost::{curve::Secp256k1, Participant, ThresholdKeys, tests::key_gen as frost_key_gen}; + +use ethers_core::{ + types::{H160, Signature as EthersSignature}, + abi::Abi, +}; +use ethers_contract::ContractFactory; +use ethers_providers::{Middleware, Provider, Http}; + +use crate::crypto::PublicKey; + +mod crypto; +mod schnorr; +mod router; + +pub fn key_gen() -> (HashMap>, PublicKey) { + let mut keys = frost_key_gen::<_, Secp256k1>(&mut OsRng); + let mut group_key = keys[&Participant::new(1).unwrap()].group_key(); + + let mut offset = Scalar::ZERO; + while PublicKey::new(group_key).is_none() { + offset += Scalar::ONE; + group_key += ProjectivePoint::GENERATOR; + } + for keys in keys.values_mut() { + *keys = keys.offset(offset); + } + let public_key = PublicKey::new(group_key).unwrap(); + + (keys, public_key) +} + +// TODO: Replace with a contract deployment from an unknown account, so the environment solely has +// to fund the deployer, not create/pass a wallet +// TODO: Deterministic deployments across chains +pub async fn deploy_contract( + chain_id: u32, + client: Arc>, + wallet: &k256::ecdsa::SigningKey, + name: &str, +) -> eyre::Result { + let abi: Abi = + serde_json::from_reader(File::open(format!("./artifacts/{name}.abi")).unwrap()).unwrap(); + + let hex_bin_buf = std::fs::read_to_string(format!("./artifacts/{name}.bin")).unwrap(); + let hex_bin = + if let Some(stripped) = hex_bin_buf.strip_prefix("0x") { stripped } else { &hex_bin_buf }; + let bin = hex::decode(hex_bin).unwrap(); + let factory = ContractFactory::new(abi, bin.into(), client.clone()); + + let mut deployment_tx = factory.deploy(())?.tx; + deployment_tx.set_chain_id(chain_id); + deployment_tx.set_gas(1_000_000); + let (max_fee_per_gas, max_priority_fee_per_gas) = client.estimate_eip1559_fees(None).await?; + deployment_tx.as_eip1559_mut().unwrap().max_fee_per_gas = Some(max_fee_per_gas); + deployment_tx.as_eip1559_mut().unwrap().max_priority_fee_per_gas = Some(max_priority_fee_per_gas); + + let sig_hash = deployment_tx.sighash(); + let (sig, rid) = wallet.sign_prehash_recoverable(sig_hash.as_ref()).unwrap(); + + // EIP-155 v + let mut v = u64::from(rid.to_byte()); + assert!((v == 0) || (v == 1)); + v += u64::from((chain_id * 2) + 35); + + let r = sig.r().to_repr(); + let r_ref: &[u8] = r.as_ref(); + let s = sig.s().to_repr(); + let s_ref: &[u8] = s.as_ref(); + let deployment_tx = + deployment_tx.rlp_signed(&EthersSignature { r: r_ref.into(), s: s_ref.into(), v }); + + let pending_tx = client.send_raw_transaction(deployment_tx).await?; + + let mut receipt; + while { + receipt = client.get_transaction_receipt(pending_tx.tx_hash()).await?; + receipt.is_none() + } { + tokio::time::sleep(Duration::from_secs(6)).await; + } + let receipt = receipt.unwrap(); + assert!(receipt.status == Some(1.into())); + + Ok(receipt.contract_address.unwrap()) +} diff --git a/coins/ethereum/src/tests/router.rs b/coins/ethereum/src/tests/router.rs new file mode 100644 index 00000000..c9be93be --- /dev/null +++ b/coins/ethereum/src/tests/router.rs @@ -0,0 +1,109 @@ +use std::{convert::TryFrom, sync::Arc, collections::HashMap}; + +use rand_core::OsRng; + +use group::ff::PrimeField; +use frost::{ + curve::Secp256k1, + Participant, ThresholdKeys, + algorithm::IetfSchnorr, + tests::{algorithm_machines, sign}, +}; + +use ethers_core::{ + types::{H160, U256, Bytes}, + abi::AbiEncode, + utils::{Anvil, AnvilInstance}, +}; +use ethers_providers::{Middleware, Provider, Http}; + +use crate::{ + crypto::{keccak256, PublicKey, EthereumHram, Signature}, + router::{self, *}, + tests::{key_gen, deploy_contract}, +}; + +async fn setup_test() -> ( + u32, + AnvilInstance, + Router>, + HashMap>, + PublicKey, +) { + let anvil = Anvil::new().spawn(); + + let provider = Provider::::try_from(anvil.endpoint()).unwrap(); + let chain_id = provider.get_chainid().await.unwrap().as_u32(); + let wallet = anvil.keys()[0].clone().into(); + let client = Arc::new(provider); + + let contract_address = + deploy_contract(chain_id, client.clone(), &wallet, "Router").await.unwrap(); + let contract = Router::new(contract_address, client.clone()); + + let (keys, public_key) = key_gen(); + + // Set the key to the threshold keys + let tx = contract.init_serai_key(public_key.px.to_repr().into()).gas(100_000); + let pending_tx = tx.send().await.unwrap(); + let receipt = pending_tx.await.unwrap().unwrap(); + assert!(receipt.status == Some(1.into())); + + (chain_id, anvil, contract, keys, public_key) +} + +#[tokio::test] +async fn test_deploy_contract() { + setup_test().await; +} + +pub fn hash_and_sign( + keys: &HashMap>, + public_key: &PublicKey, + chain_id: U256, + message: &[u8], +) -> Signature { + let hashed_message = keccak256(message); + + let mut chain_id_bytes = [0; 32]; + chain_id.to_big_endian(&mut chain_id_bytes); + let full_message = &[chain_id_bytes.as_slice(), &hashed_message].concat(); + + let algo = IetfSchnorr::::ietf(); + let sig = sign( + &mut OsRng, + &algo, + keys.clone(), + algorithm_machines(&mut OsRng, &algo, keys), + full_message, + ); + + Signature::new(public_key, k256::U256::from_words(chain_id.0), message, sig).unwrap() +} + +#[tokio::test] +async fn test_router_execute() { + let (chain_id, _anvil, contract, keys, public_key) = setup_test().await; + + let to = H160([0u8; 20]); + let value = U256([0u64; 4]); + let data = Bytes::from([0]); + let tx = OutInstruction { to, value, data: data.clone() }; + + let nonce_call = contract.nonce(); + let nonce = nonce_call.call().await.unwrap(); + + let encoded = + ("execute".to_string(), nonce, vec![router::OutInstruction { to, value, data }]).encode(); + let sig = hash_and_sign(&keys, &public_key, chain_id.into(), &encoded); + + let tx = contract + .execute(vec![tx], router::Signature { c: sig.c.to_repr().into(), s: sig.s.to_repr().into() }) + .gas(300_000); + let pending_tx = tx.send().await.unwrap(); + let receipt = dbg!(pending_tx.await.unwrap().unwrap()); + assert!(receipt.status == Some(1.into())); + + println!("gas used: {:?}", receipt.cumulative_gas_used); + println!("logs: {:?}", receipt.logs); +} diff --git a/coins/ethereum/src/tests/schnorr.rs b/coins/ethereum/src/tests/schnorr.rs new file mode 100644 index 00000000..9525e4d6 --- /dev/null +++ b/coins/ethereum/src/tests/schnorr.rs @@ -0,0 +1,67 @@ +use std::{convert::TryFrom, sync::Arc}; + +use rand_core::OsRng; + +use ::k256::{elliptic_curve::bigint::ArrayEncoding, U256, Scalar}; + +use ethers_core::utils::{keccak256, Anvil, AnvilInstance}; +use ethers_providers::{Middleware, Provider, Http}; + +use frost::{ + curve::Secp256k1, + algorithm::IetfSchnorr, + tests::{algorithm_machines, sign}, +}; + +use crate::{ + crypto::*, + schnorr::*, + tests::{key_gen, deploy_contract}, +}; + +async fn setup_test() -> (u32, AnvilInstance, Schnorr>) { + let anvil = Anvil::new().spawn(); + + let provider = Provider::::try_from(anvil.endpoint()).unwrap(); + let chain_id = provider.get_chainid().await.unwrap().as_u32(); + let wallet = anvil.keys()[0].clone().into(); + let client = Arc::new(provider); + + let contract_address = + deploy_contract(chain_id, client.clone(), &wallet, "Schnorr").await.unwrap(); + let contract = Schnorr::new(contract_address, client.clone()); + (chain_id, anvil, contract) +} + +#[tokio::test] +async fn test_deploy_contract() { + setup_test().await; +} + +#[tokio::test] +async fn test_ecrecover_hack() { + let (chain_id, _anvil, contract) = setup_test().await; + let chain_id = U256::from(chain_id); + + let (keys, public_key) = key_gen(); + + const MESSAGE: &[u8] = b"Hello, World!"; + let hashed_message = keccak256(MESSAGE); + let full_message = &[chain_id.to_be_byte_array().as_slice(), &hashed_message].concat(); + + let algo = IetfSchnorr::::ietf(); + let sig = sign( + &mut OsRng, + &algo, + keys.clone(), + algorithm_machines(&mut OsRng, &algo, &keys), + full_message, + ); + let sig = Signature::new(&public_key, chain_id, MESSAGE, sig).unwrap(); + + call_verify(&contract, &public_key, MESSAGE, &sig).await.unwrap(); + // Test an invalid signature fails + let mut sig = sig; + sig.s += Scalar::ONE; + assert!(call_verify(&contract, &public_key, MESSAGE, &sig).await.is_err()); +} diff --git a/coins/ethereum/tests/contract.rs b/coins/ethereum/tests/contract.rs deleted file mode 100644 index 37875819..00000000 --- a/coins/ethereum/tests/contract.rs +++ /dev/null @@ -1,128 +0,0 @@ -use std::{convert::TryFrom, sync::Arc, time::Duration, fs::File}; - -use rand_core::OsRng; - -use ::k256::{ - elliptic_curve::{bigint::ArrayEncoding, PrimeField}, - U256, -}; - -use ethers_core::{ - types::Signature, - abi::Abi, - utils::{keccak256, Anvil, AnvilInstance}, -}; -use ethers_contract::ContractFactory; -use ethers_providers::{Middleware, Provider, Http}; - -use frost::{ - curve::Secp256k1, - Participant, - algorithm::IetfSchnorr, - tests::{key_gen, algorithm_machines, sign}, -}; - -use ethereum_serai::{ - crypto, - contract::{Schnorr, call_verify}, -}; - -// TODO: Replace with a contract deployment from an unknown account, so the environment solely has -// to fund the deployer, not create/pass a wallet -pub async fn deploy_schnorr_verifier_contract( - chain_id: u32, - client: Arc>, - wallet: &k256::ecdsa::SigningKey, -) -> eyre::Result>> { - let abi: Abi = serde_json::from_reader(File::open("./artifacts/Schnorr.abi").unwrap()).unwrap(); - - let hex_bin_buf = std::fs::read_to_string("./artifacts/Schnorr.bin").unwrap(); - let hex_bin = - if let Some(stripped) = hex_bin_buf.strip_prefix("0x") { stripped } else { &hex_bin_buf }; - let bin = hex::decode(hex_bin).unwrap(); - let factory = ContractFactory::new(abi, bin.into(), client.clone()); - - let mut deployment_tx = factory.deploy(())?.tx; - deployment_tx.set_chain_id(chain_id); - deployment_tx.set_gas(500_000); - let (max_fee_per_gas, max_priority_fee_per_gas) = client.estimate_eip1559_fees(None).await?; - deployment_tx.as_eip1559_mut().unwrap().max_fee_per_gas = Some(max_fee_per_gas); - deployment_tx.as_eip1559_mut().unwrap().max_priority_fee_per_gas = Some(max_priority_fee_per_gas); - - let sig_hash = deployment_tx.sighash(); - let (sig, rid) = wallet.sign_prehash_recoverable(sig_hash.as_ref()).unwrap(); - - // EIP-155 v - let mut v = u64::from(rid.to_byte()); - assert!((v == 0) || (v == 1)); - v += u64::from((chain_id * 2) + 35); - - let r = sig.r().to_repr(); - let r_ref: &[u8] = r.as_ref(); - let s = sig.s().to_repr(); - let s_ref: &[u8] = s.as_ref(); - let deployment_tx = deployment_tx.rlp_signed(&Signature { r: r_ref.into(), s: s_ref.into(), v }); - - let pending_tx = client.send_raw_transaction(deployment_tx).await?; - - let mut receipt; - while { - receipt = client.get_transaction_receipt(pending_tx.tx_hash()).await?; - receipt.is_none() - } { - tokio::time::sleep(Duration::from_secs(6)).await; - } - let receipt = receipt.unwrap(); - assert!(receipt.status == Some(1.into())); - - let contract = Schnorr::new(receipt.contract_address.unwrap(), client.clone()); - Ok(contract) -} - -async fn deploy_test_contract() -> (u32, AnvilInstance, Schnorr>) { - let anvil = Anvil::new().spawn(); - - let provider = - Provider::::try_from(anvil.endpoint()).unwrap().interval(Duration::from_millis(10u64)); - let chain_id = provider.get_chainid().await.unwrap().as_u32(); - let wallet = anvil.keys()[0].clone().into(); - let client = Arc::new(provider); - - (chain_id, anvil, deploy_schnorr_verifier_contract(chain_id, client, &wallet).await.unwrap()) -} - -#[tokio::test] -async fn test_deploy_contract() { - deploy_test_contract().await; -} - -#[tokio::test] -async fn test_ecrecover_hack() { - let (chain_id, _anvil, contract) = deploy_test_contract().await; - let chain_id = U256::from(chain_id); - - let keys = key_gen::<_, Secp256k1>(&mut OsRng); - let group_key = keys[&Participant::new(1).unwrap()].group_key(); - - const MESSAGE: &[u8] = b"Hello, World!"; - let hashed_message = keccak256(MESSAGE); - - let full_message = &[chain_id.to_be_byte_array().as_slice(), &hashed_message].concat(); - - let algo = IetfSchnorr::::ietf(); - let sig = sign( - &mut OsRng, - &algo, - keys.clone(), - algorithm_machines(&mut OsRng, &algo, &keys), - full_message, - ); - let mut processed_sig = - crypto::process_signature_for_contract(hashed_message, &sig.R, sig.s, &group_key, chain_id); - - call_verify(&contract, &processed_sig).await.unwrap(); - - // test invalid signature fails - processed_sig.message[0] = 0; - assert!(call_verify(&contract, &processed_sig).await.is_err()); -} diff --git a/coins/ethereum/tests/crypto.rs b/coins/ethereum/tests/crypto.rs deleted file mode 100644 index f1ab08b0..00000000 --- a/coins/ethereum/tests/crypto.rs +++ /dev/null @@ -1,87 +0,0 @@ -use k256::{ - elliptic_curve::{bigint::ArrayEncoding, ops::Reduce, sec1::ToEncodedPoint}, - ProjectivePoint, Scalar, U256, -}; -use frost::{curve::Secp256k1, Participant}; - -use ethereum_serai::crypto::*; - -#[test] -fn test_ecrecover() { - use rand_core::OsRng; - use sha2::Sha256; - use sha3::{Digest, Keccak256}; - use k256::ecdsa::{hazmat::SignPrimitive, signature::DigestVerifier, SigningKey, VerifyingKey}; - - let private = SigningKey::random(&mut OsRng); - let public = VerifyingKey::from(&private); - - const MESSAGE: &[u8] = b"Hello, World!"; - let (sig, recovery_id) = private - .as_nonzero_scalar() - .try_sign_prehashed_rfc6979::(&Keccak256::digest(MESSAGE), b"") - .unwrap(); - #[allow(clippy::unit_cmp)] // Intended to assert this wasn't changed to Result - { - assert_eq!(public.verify_digest(Keccak256::new_with_prefix(MESSAGE), &sig).unwrap(), ()); - } - - assert_eq!( - ecrecover(hash_to_scalar(MESSAGE), recovery_id.unwrap().is_y_odd().into(), *sig.r(), *sig.s()) - .unwrap(), - address(&ProjectivePoint::from(public.as_affine())) - ); -} - -#[test] -fn test_signing() { - use frost::{ - algorithm::IetfSchnorr, - tests::{algorithm_machines, key_gen, sign}, - }; - use rand_core::OsRng; - - let keys = key_gen::<_, Secp256k1>(&mut OsRng); - let _group_key = keys[&Participant::new(1).unwrap()].group_key(); - - const MESSAGE: &[u8] = b"Hello, World!"; - - let algo = IetfSchnorr::::ietf(); - let _sig = - sign(&mut OsRng, &algo, keys.clone(), algorithm_machines(&mut OsRng, &algo, &keys), MESSAGE); -} - -#[test] -fn test_ecrecover_hack() { - use frost::{ - algorithm::IetfSchnorr, - tests::{algorithm_machines, key_gen, sign}, - }; - use rand_core::OsRng; - - let keys = key_gen::<_, Secp256k1>(&mut OsRng); - let group_key = keys[&Participant::new(1).unwrap()].group_key(); - let group_key_encoded = group_key.to_encoded_point(true); - let group_key_compressed = group_key_encoded.as_ref(); - let group_key_x = Scalar::reduce(U256::from_be_slice(&group_key_compressed[1 .. 33])); - - const MESSAGE: &[u8] = b"Hello, World!"; - let hashed_message = keccak256(MESSAGE); - let chain_id = U256::ONE; - - let full_message = &[chain_id.to_be_byte_array().as_slice(), &hashed_message].concat(); - - let algo = IetfSchnorr::::ietf(); - let sig = sign( - &mut OsRng, - &algo, - keys.clone(), - algorithm_machines(&mut OsRng, &algo, &keys), - full_message, - ); - - let (sr, er) = - preprocess_signature_for_ecrecover(hashed_message, &sig.R, sig.s, &group_key, chain_id); - let q = ecrecover(sr, group_key_compressed[0] - 2, group_key_x, er).unwrap(); - assert_eq!(q, address(&sig.R)); -} diff --git a/coins/ethereum/tests/mod.rs b/coins/ethereum/tests/mod.rs deleted file mode 100644 index 257fb61f..00000000 --- a/coins/ethereum/tests/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -mod contract; -mod crypto; diff --git a/spec/Getting Started.md b/spec/Getting Started.md index 0034d69d..c2530b2a 100644 --- a/spec/Getting Started.md +++ b/spec/Getting Started.md @@ -36,16 +36,16 @@ rustup target add wasm32-unknown-unknown --toolchain nightly ``` cargo install svm-rs -svm install 0.8.16 -svm use 0.8.16 +svm install 0.8.25 +svm use 0.8.25 ``` ### Install Solidity Compiler Version Manager ``` cargo install svm-rs -svm install 0.8.16 -svm use 0.8.16 +svm install 0.8.25 +svm use 0.8.25 ``` ### Install foundry (for tests)