diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d207e9cd..3adc3ac5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -53,6 +53,7 @@ jobs: -p serai-processor-bin \ -p serai-bitcoin-processor \ -p serai-processor-ethereum-primitives \ + -p serai-processor-ethereum-test-primitives \ -p serai-processor-ethereum-deployer \ -p serai-processor-ethereum-router \ -p serai-processor-ethereum-erc20 \ diff --git a/Cargo.lock b/Cargo.lock index fd9838f2..0550b05e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8374,6 +8374,19 @@ dependencies = [ "tokio", ] +[[package]] +name = "serai-ethereum-test-primitives" +version = "0.1.0" +dependencies = [ + "alloy-consensus", + "alloy-core", + "alloy-provider", + "alloy-rpc-types-eth", + "alloy-simple-request-transport", + "k256", + "serai-processor-ethereum-primitives", +] + [[package]] name = "serai-full-stack-tests" version = "0.1.0" @@ -8706,7 +8719,9 @@ version = "0.1.0" dependencies = [ "alloy-consensus", "alloy-core", + "alloy-node-bindings", "alloy-provider", + "alloy-rpc-client", "alloy-rpc-types-eth", "alloy-simple-request-transport", "alloy-sol-macro-expander", @@ -8716,12 +8731,16 @@ dependencies = [ "build-solidity-contracts", "ethereum-schnorr-contract", "group", + "k256", + "rand_core", "serai-client", + "serai-ethereum-test-primitives", "serai-processor-ethereum-deployer", "serai-processor-ethereum-erc20", "serai-processor-ethereum-primitives", "syn 2.0.77", "syn-solidity", + "tokio", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index facd5a6a..16c12262 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -88,6 +88,7 @@ members = [ "processor/bin", "processor/bitcoin", "processor/ethereum/primitives", + "processor/ethereum/test-primitives", "processor/ethereum/deployer", "processor/ethereum/router", "processor/ethereum/erc20", diff --git a/deny.toml b/deny.toml index d09fc8eb..51bffbc0 100644 --- a/deny.toml +++ b/deny.toml @@ -60,6 +60,7 @@ exceptions = [ { allow = ["AGPL-3.0"], name = "serai-bitcoin-processor" }, { allow = ["AGPL-3.0"], name = "serai-processor-ethereum-primitives" }, + { allow = ["AGPL-3.0"], name = "serai-processor-ethereum-test-primitives" }, { allow = ["AGPL-3.0"], name = "serai-processor-ethereum-deployer" }, { allow = ["AGPL-3.0"], name = "serai-processor-ethereum-router" }, { allow = ["AGPL-3.0"], name = "serai-processor-ethereum-erc20" }, diff --git a/processor/ethereum/deployer/src/lib.rs b/processor/ethereum/deployer/src/lib.rs index 50180236..2293de47 100644 --- a/processor/ethereum/deployer/src/lib.rs +++ b/processor/ethereum/deployer/src/lib.rs @@ -59,12 +59,27 @@ impl Deployer { } /// Obtain the deterministic address for this contract. - pub(crate) fn address() -> Address { + pub fn address() -> Address { let deployer_deployer = Self::deployment_tx().recover_signer().expect("deployment_tx didn't have a valid signature"); Address::create(&deployer_deployer, 0) } + /// Obtain the unsigned transaction to deploy a contract. + /// + /// This will not have its `nonce`, `gas_price`, nor `gas_limit` filled out. + pub fn deploy_tx(init_code: Vec) -> TxLegacy { + TxLegacy { + chain_id: None, + nonce: 0, + gas_price: 0, + gas_limit: 0, + to: TxKind::Call(Self::address()), + value: U256::ZERO, + input: abi::Deployer::deployCall::new((init_code.into(),)).abi_encode().into(), + } + } + /// Construct a new view of the Deployer. /// /// This will return `None` if the Deployer has yet to be deployed on-chain. diff --git a/processor/ethereum/router/Cargo.toml b/processor/ethereum/router/Cargo.toml index d21a26d9..132a9fa4 100644 --- a/processor/ethereum/router/Cargo.toml +++ b/processor/ethereum/router/Cargo.toml @@ -20,10 +20,10 @@ workspace = true group = { version = "0.13", default-features = false } alloy-core = { version = "0.8", default-features = false } -alloy-consensus = { version = "0.3", default-features = false } - alloy-sol-types = { version = "0.8", default-features = false } +alloy-consensus = { version = "0.3", default-features = false } + alloy-rpc-types-eth = { version = "0.3", default-features = false } alloy-transport = { version = "0.3", default-features = false } alloy-simple-request-transport = { path = "../../../networks/ethereum/alloy-simple-request-transport", default-features = false } @@ -45,3 +45,15 @@ syn = { version = "2", default-features = false, features = ["proc-macro"] } syn-solidity = { version = "0.8", default-features = false } alloy-sol-macro-input = { version = "0.8", default-features = false } alloy-sol-macro-expander = { version = "0.8", default-features = false } + +[dev-dependencies] +rand_core = { version = "0.6", default-features = false, features = ["std"] } + +k256 = { version = "0.13", default-features = false, features = ["std"] } + +alloy-rpc-client = { version = "0.3", default-features = false } +alloy-node-bindings = { version = "0.3", default-features = false } + +tokio = { version = "1.0", default-features = false, features = ["rt-multi-thread", "macros"] } + +ethereum-test-primitives = { package = "serai-ethereum-test-primitives", path = "../test-primitives" } diff --git a/processor/ethereum/router/contracts/Router.sol b/processor/ethereum/router/contracts/Router.sol index 2fd0f2e4..8607e732 100644 --- a/processor/ethereum/router/contracts/Router.sol +++ b/processor/ethereum/router/contracts/Router.sol @@ -1,6 +1,8 @@ // SPDX-License-Identifier: AGPL-3.0-only pragma solidity ^0.8.26; +// TODO: MIT licensed interface + import "IERC20.sol"; import "Schnorr.sol"; @@ -34,8 +36,11 @@ contract Router { */ uint256 private _smartContractNonce; - /// @dev A nonce incremented upon an action to prevent replays/out-of-order execution - uint256 private _nonce; + /** + * @dev The nonce to verify the next signature with, incremented upon an action to prevent + * replays/out-of-order execution + */ + uint256 private _nextNonce; /** * @dev The current public key for Serai's Ethereum validator set, in the form the Schnorr library @@ -124,7 +129,7 @@ contract Router { /** * @dev Updates the Serai key at the end of the current function. Executing at the end of the * current function allows verifying a signature with the current key. This does not update - * `_nonce` + * `_nextNonce` */ /// @param nonceUpdatedWith The nonce used to update the key /// @param newSeraiKey The key updated to @@ -145,7 +150,7 @@ contract Router { _smartContractNonce = 1; // We consumed nonce 0 when setting the initial Serai key - _nonce = 1; + _nextNonce = 1; // We haven't escaped to any address yet _escapedTo = address(0); @@ -163,18 +168,19 @@ contract Router { if (!Schnorr.verify(_seraiKey, message, signature.c, signature.s)) { revert InvalidSignature(); } - // Increment the nonce + // Set the next nonce unchecked { - _nonce++; + _nextNonce++; } } /// @notice Update the key representing Serai's Ethereum validators + /// @dev This assumes the key is correct. No checks on it are performed /// @param newSeraiKey The key to update to /// @param signature The signature by the current key authorizing this update function updateSeraiKey(bytes32 newSeraiKey, Signature calldata signature) external - updateSeraiKeyAtEndOfFn(_nonce, newSeraiKey) + updateSeraiKeyAtEndOfFn(_nextNonce, newSeraiKey) { /* This DST needs a length prefix as well to prevent DSTs potentially being substrings of each @@ -188,7 +194,7 @@ contract Router { This uses encodePacked as all items present here are of fixed length. */ - bytes32 message = keccak256(abi.encodePacked("updateSeraiKey", _nonce, newSeraiKey)); + bytes32 message = keccak256(abi.encodePacked("updateSeraiKey", _nextNonce, newSeraiKey)); verifySignature(message, signature); } @@ -366,11 +372,11 @@ contract Router { // Verify the signature // This uses `encode`, not `encodePacked`, as `outs` is of variable length // TODO: Use a custom encode in verifySignature here with assembly (benchmarking before/after) - bytes32 message = keccak256(abi.encode("execute", _nonce, coin, fee, outs)); + bytes32 message = keccak256(abi.encode("execute", _nextNonce, coin, fee, outs)); verifySignature(message, signature); - // _nonce: Also include a bit mask here - emit Executed(_nonce, message); + // TODO: Also include a bit mask here + emit Executed(_nextNonce, message); /* Since we don't have a re-entrancy guard, it is possible for instructions from later batches to @@ -449,7 +455,7 @@ contract Router { } // Verify the signature - bytes32 message = keccak256(abi.encodePacked("escapeHatch", _nonce, escapeTo)); + bytes32 message = keccak256(abi.encodePacked("escapeHatch", _nextNonce, escapeTo)); verifySignature(message, signature); _escapedTo = escapeTo; @@ -477,8 +483,8 @@ contract Router { /// @notice Fetch the next nonce to use by an action published to this contract /// return The next nonce to use by an action published to this contract - function nonce() external view returns (uint256) { - return _nonce; + function nextNonce() external view returns (uint256) { + return _nextNonce; } /// @notice Fetch the current key for Serai's Ethereum validator set diff --git a/processor/ethereum/router/src/lib.rs b/processor/ethereum/router/src/lib.rs index e0a53ac6..eeee70e7 100644 --- a/processor/ethereum/router/src/lib.rs +++ b/processor/ethereum/router/src/lib.rs @@ -7,11 +7,11 @@ use std::{sync::Arc, io, collections::HashSet}; use group::ff::PrimeField; use alloy_core::primitives::{hex::FromHex, Address, U256, Bytes, TxKind}; -use alloy_consensus::TxLegacy; - use alloy_sol_types::{SolValue, SolConstructor, SolCall, SolEvent}; -use alloy_rpc_types_eth::Filter; +use alloy_consensus::TxLegacy; + +use alloy_rpc_types_eth::{TransactionRequest, TransactionInput, BlockId, Filter}; use alloy_transport::{TransportErrorKind, RpcError}; use alloy_simple_request_transport::SimpleRequest; use alloy_provider::{Provider, RootProvider}; @@ -37,6 +37,9 @@ use abi::{ Executed as ExecutedEvent, }; +#[cfg(test)] +mod tests; + impl From<&Signature> for abi::Signature { fn from(signature: &Signature) -> Self { Self { @@ -270,6 +273,15 @@ impl Router { bytecode } + /// Obtain the transaction to deploy this contract. + /// + /// This transaction assumes the `Deployer` has already been deployed. + pub fn deployment_tx(initial_serai_key: &PublicKey) -> TxLegacy { + let mut tx = Deployer::deploy_tx(Self::init_code(initial_serai_key)); + tx.gas_limit = 883654 * 120 / 100; + tx + } + /// Create a new view of the Router. /// /// This performs an on-chain lookup for the first deployed Router constructed with this public @@ -303,20 +315,20 @@ impl Router { /// Construct a transaction to update the key representing Serai. pub fn update_serai_key(&self, public_key: &PublicKey, sig: &Signature) -> TxLegacy { - // TODO: Set a more accurate gas TxLegacy { to: TxKind::Call(self.1), input: abi::updateSeraiKeyCall::new((public_key.eth_repr().into(), sig.into())) .abi_encode() .into(), - gas_limit: 100_000, + gas_limit: 40748 * 120 / 100, ..Default::default() } } /// Get the message to be signed in order to execute a series of `OutInstruction`s. pub fn execute_message(nonce: u64, coin: Coin, fee: U256, outs: OutInstructions) -> Vec { - ("execute", U256::try_from(nonce).unwrap(), coin.address(), fee, outs.0).abi_encode() + ("execute".to_string(), U256::try_from(nonce).unwrap(), coin.address(), fee, outs.0) + .abi_encode_sequence() } /// Construct a transaction to execute a batch of `OutInstruction`s. @@ -539,4 +551,44 @@ impl Router { Ok(res) } + + /// Fetch the current key for Serai's Ethereum validators + pub async fn key(&self, block: BlockId) -> Result> { + let call = TransactionRequest::default() + .to(self.1) + .input(TransactionInput::new(abi::seraiKeyCall::new(()).abi_encode().into())); + let bytes = self.0.call(&call).block(block).await?; + let res = abi::seraiKeyCall::abi_decode_returns(&bytes, true) + .map_err(|e| TransportErrorKind::Custom(format!("filtered to decode key: {e:?}").into()))?; + Ok( + PublicKey::from_eth_repr(res._0.into()).ok_or_else(|| { + TransportErrorKind::Custom("invalid key set on router".to_string().into()) + })?, + ) + } + + /// Fetch the nonce of the next action to execute + pub async fn next_nonce(&self, block: BlockId) -> Result> { + let call = TransactionRequest::default() + .to(self.1) + .input(TransactionInput::new(abi::nextNonceCall::new(()).abi_encode().into())); + let bytes = self.0.call(&call).block(block).await?; + let res = abi::nextNonceCall::abi_decode_returns(&bytes, true) + .map_err(|e| TransportErrorKind::Custom(format!("filtered to decode nonce: {e:?}").into()))?; + Ok(u64::try_from(res._0).map_err(|_| { + TransportErrorKind::Custom("nonce returned exceeded 2**64".to_string().into()) + })?) + } + + /// Fetch the address the escape hatch was set to + pub async fn escaped_to(&self, block: BlockId) -> Result> { + let call = TransactionRequest::default() + .to(self.1) + .input(TransactionInput::new(abi::escapedToCall::new(()).abi_encode().into())); + let bytes = self.0.call(&call).block(block).await?; + let res = abi::escapedToCall::abi_decode_returns(&bytes, true).map_err(|e| { + TransportErrorKind::Custom(format!("filtered to decode the address escaped to: {e:?}").into()) + })?; + Ok(res._0) + } } diff --git a/processor/ethereum/router/src/tests/mod.rs b/processor/ethereum/router/src/tests/mod.rs new file mode 100644 index 00000000..317003e8 --- /dev/null +++ b/processor/ethereum/router/src/tests/mod.rs @@ -0,0 +1,210 @@ +use std::{sync::Arc, collections::HashSet}; + +use rand_core::{RngCore, OsRng}; + +use group::ff::Field; +use k256::{Scalar, ProjectivePoint}; + +use alloy_core::primitives::{Address, U256, TxKind}; +use alloy_sol_types::SolCall; + +use alloy_consensus::TxLegacy; + +use alloy_rpc_types_eth::BlockNumberOrTag; +use alloy_simple_request_transport::SimpleRequest; +use alloy_rpc_client::ClientBuilder; +use alloy_provider::RootProvider; + +use alloy_node_bindings::{Anvil, AnvilInstance}; + +use ethereum_schnorr::{PublicKey, Signature}; +use ethereum_deployer::Deployer; + +use crate::{Coin, OutInstructions, Router}; + +pub(crate) fn test_key() -> (Scalar, PublicKey) { + loop { + let key = Scalar::random(&mut OsRng); + let point = ProjectivePoint::GENERATOR * key; + if let Some(public_key) = PublicKey::new(point) { + return (key, public_key); + } + } +} + +async fn setup_test( +) -> (AnvilInstance, Arc>, Router, (Scalar, PublicKey)) { + let anvil = Anvil::new().spawn(); + + let provider = Arc::new(RootProvider::new( + ClientBuilder::default().transport(SimpleRequest::new(anvil.endpoint()), true), + )); + + let (private_key, public_key) = test_key(); + assert!(Router::new(provider.clone(), &public_key).await.unwrap().is_none()); + + // Deploy the Deployer + let receipt = ethereum_test_primitives::publish_tx(&provider, Deployer::deployment_tx()).await; + assert!(receipt.status()); + + // Get the TX to deploy the Router + let mut tx = Router::deployment_tx(&public_key); + // Set a gas price (100 gwei) + tx.gas_price = 100_000_000_000u128; + // Sign it + let tx = ethereum_primitives::deterministically_sign(&tx); + // Publish it + let receipt = ethereum_test_primitives::publish_tx(&provider, tx).await; + assert!(receipt.status()); + println!("Router deployment used {} gas:", receipt.gas_used); + + let router = Router::new(provider.clone(), &public_key).await.unwrap().unwrap(); + + (anvil, provider, router, (private_key, public_key)) +} + +#[tokio::test] +async fn test_constructor() { + let (_anvil, _provider, router, key) = setup_test().await; + assert_eq!(router.key(BlockNumberOrTag::Latest.into()).await.unwrap(), key.1); + assert_eq!(router.next_nonce(BlockNumberOrTag::Latest.into()).await.unwrap(), 1); + assert_eq!( + router.escaped_to(BlockNumberOrTag::Latest.into()).await.unwrap(), + Address::from([0; 20]) + ); +} + +#[tokio::test] +async fn test_update_serai_key() { + let (_anvil, provider, router, key) = setup_test().await; + + let update_to = test_key().1; + let msg = Router::update_serai_key_message(1, &update_to); + + let nonce = Scalar::random(&mut OsRng); + let c = Signature::challenge(ProjectivePoint::GENERATOR * nonce, &key.1, &msg); + let s = nonce + (c * key.0); + + let sig = Signature::new(c, s).unwrap(); + + let mut tx = router.update_serai_key(&update_to, &sig); + tx.gas_price = 100_000_000_000u128; + let tx = ethereum_primitives::deterministically_sign(&tx); + let receipt = ethereum_test_primitives::publish_tx(&provider, tx).await; + assert!(receipt.status()); + println!("update_serai_key used {} gas:", receipt.gas_used); + + assert_eq!(router.key(receipt.block_hash.unwrap().into()).await.unwrap(), update_to); + assert_eq!(router.next_nonce(receipt.block_hash.unwrap().into()).await.unwrap(), 2); +} + +#[tokio::test] +async fn test_eth_in_instruction() { + let (_anvil, provider, router, _key) = setup_test().await; + + let amount = U256::try_from(OsRng.next_u64()).unwrap(); + let mut in_instruction = vec![0; usize::try_from(OsRng.next_u64() % 256).unwrap()]; + OsRng.fill_bytes(&mut in_instruction); + + let tx = TxLegacy { + chain_id: None, + nonce: 0, + // 100 gwei + gas_price: 100_000_000_000u128, + gas_limit: 1_000_000u128, + to: TxKind::Call(router.address()), + value: amount, + input: crate::abi::inInstructionCall::new(( + [0; 20].into(), + amount, + in_instruction.clone().into(), + )) + .abi_encode() + .into(), + }; + let tx = ethereum_primitives::deterministically_sign(&tx); + let signer = tx.recover_signer().unwrap(); + + let receipt = ethereum_test_primitives::publish_tx(&provider, tx).await; + assert!(receipt.status()); + + assert_eq!(receipt.inner.logs().len(), 1); + let parsed_log = + receipt.inner.logs()[0].log_decode::().unwrap().inner.data; + assert_eq!(parsed_log.from, signer); + assert_eq!(parsed_log.coin, Address::from([0; 20])); + assert_eq!(parsed_log.amount, amount); + assert_eq!(parsed_log.instruction.as_ref(), &in_instruction); + + let parsed_in_instructions = + router.in_instructions(receipt.block_number.unwrap(), &HashSet::new()).await.unwrap(); + assert_eq!(parsed_in_instructions.len(), 1); + assert_eq!( + parsed_in_instructions[0].id, + (<[u8; 32]>::from(receipt.block_hash.unwrap()), receipt.inner.logs()[0].log_index.unwrap()) + ); + assert_eq!(parsed_in_instructions[0].from, signer); + assert_eq!(parsed_in_instructions[0].coin, Coin::Ether); + assert_eq!(parsed_in_instructions[0].amount, amount); + assert_eq!(parsed_in_instructions[0].data, in_instruction); +} + +#[tokio::test] +async fn test_erc20_in_instruction() { + todo!("TODO") +} + +async fn publish_outs(key: (Scalar, PublicKey), nonce: u64, coin: Coin, fee: U256, outs: OutInstructions) -> TransactionReceipt { + let msg = Router::execute_message(nonce, coin, fee, instructions.clone()); + + let nonce = Scalar::random(&mut OsRng); + let c = Signature::challenge(ProjectivePoint::GENERATOR * nonce, &key.1, &msg); + let s = nonce + (c * key.0); + + let sig = Signature::new(c, s).unwrap(); + + let mut tx = router.execute(coin, fee, instructions, &sig); + tx.gas_price = 100_000_000_000u128; + let tx = ethereum_primitives::deterministically_sign(&tx); + ethereum_test_primitives::publish_tx(&provider, tx).await +} + +#[tokio::test] +async fn test_eth_address_out_instruction() { + let (_anvil, provider, router, key) = setup_test().await; + + let mut amount = U256::try_from(OsRng.next_u64()).unwrap(); + let mut fee = U256::try_from(OsRng.next_u64()).unwrap(); + if fee > amount { + core::mem::swap(&mut amount, &mut fee); + } + assert!(amount >= fee); + ethereum_test_primitives::fund_account(&provider, router.address(), amount).await; + + let instructions = OutInstructions::from([].as_slice()); + let receipt = publish_outs(key, 1, Coin::Ether, fee, instructions); + assert!(receipt.status()); + println!("empty execute used {} gas:", receipt.gas_used); + + assert_eq!(router.next_nonce(receipt.block_hash.unwrap().into()).await.unwrap(), 2); +} + +#[tokio::test] +async fn test_erc20_address_out_instruction() { + todo!("TODO") +} + +#[tokio::test] +async fn test_eth_code_out_instruction() { + todo!("TODO") +} + +#[tokio::test] +async fn test_erc20_code_out_instruction() { + todo!("TODO") +} + +#[tokio::test] +async fn test_escape_hatch() { + todo!("TODO") +} diff --git a/processor/ethereum/test-primitives/Cargo.toml b/processor/ethereum/test-primitives/Cargo.toml new file mode 100644 index 00000000..54bc6850 --- /dev/null +++ b/processor/ethereum/test-primitives/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "serai-ethereum-test-primitives" +version = "0.1.0" +description = "Test primitives for Ethereum" +license = "AGPL-3.0-only" +repository = "https://github.com/serai-dex/serai/tree/develop/processor/ethereum/test-primitives" +authors = ["Luke Parker "] +edition = "2021" +publish = false + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] + +[lints] +workspace = true + +[dependencies] +k256 = { version = "0.13", default-features = false, features = ["std"] } + +alloy-core = { version = "0.8", default-features = false } +alloy-consensus = { version = "0.3", default-features = false, features = ["std"] } + +alloy-rpc-types-eth = { version = "0.3", default-features = false } +alloy-simple-request-transport = { path = "../../../networks/ethereum/alloy-simple-request-transport", default-features = false } +alloy-provider = { version = "0.3", default-features = false } + +ethereum-primitives = { package = "serai-processor-ethereum-primitives", path = "../primitives", default-features = false } diff --git a/processor/ethereum/test-primitives/LICENSE b/processor/ethereum/test-primitives/LICENSE new file mode 100644 index 00000000..41d5a261 --- /dev/null +++ b/processor/ethereum/test-primitives/LICENSE @@ -0,0 +1,15 @@ +AGPL-3.0-only license + +Copyright (c) 2022-2024 Luke Parker + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License Version 3 as +published by the Free Software Foundation. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . diff --git a/processor/ethereum/test-primitives/README.md b/processor/ethereum/test-primitives/README.md new file mode 100644 index 00000000..efb4d0a4 --- /dev/null +++ b/processor/ethereum/test-primitives/README.md @@ -0,0 +1,5 @@ +# Ethereum Router + +The [Router contract](./contracts/Router.sol) is extensively documented to ensure clarity and +understanding of the design decisions made. Please refer to it for understanding of why/what this +is. diff --git a/processor/ethereum/test-primitives/src/lib.rs b/processor/ethereum/test-primitives/src/lib.rs new file mode 100644 index 00000000..b91ba97f --- /dev/null +++ b/processor/ethereum/test-primitives/src/lib.rs @@ -0,0 +1,117 @@ +#![cfg_attr(docsrs, feature(doc_auto_cfg))] +#![doc = include_str!("../README.md")] +#![deny(missing_docs)] + +use k256::{elliptic_curve::sec1::ToEncodedPoint, ProjectivePoint}; + +use alloy_core::{ + primitives::{Address, U256, Bytes, Signature, TxKind}, + hex::FromHex, +}; +use alloy_consensus::{SignableTransaction, TxLegacy, Signed}; + +use alloy_rpc_types_eth::TransactionReceipt; +use alloy_simple_request_transport::SimpleRequest; +use alloy_provider::{Provider, RootProvider}; + +use ethereum_primitives::{keccak256, deterministically_sign}; + +fn address(point: &ProjectivePoint) -> [u8; 20] { + let encoded_point = point.to_encoded_point(false); + // 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() +} + +/// Fund an account. +pub async fn fund_account(provider: &RootProvider, address: Address, value: U256) { + let _: () = provider + .raw_request("anvil_setBalance".into(), [address.to_string(), value.to_string()]) + .await + .unwrap(); +} + +/// Publish an already-signed transaction. +pub async fn publish_tx( + provider: &RootProvider, + tx: Signed, +) -> TransactionReceipt { + // Fund the sender's address + fund_account( + provider, + tx.recover_signer().unwrap(), + (U256::from(tx.tx().gas_limit) * U256::from(tx.tx().gas_price)) + tx.tx().value, + ) + .await; + + let (tx, sig, _) = tx.into_parts(); + let mut bytes = vec![]; + tx.encode_with_signature_fields(&sig, &mut bytes); + let pending_tx = provider.send_raw_transaction(&bytes).await.unwrap(); + pending_tx.get_receipt().await.unwrap() +} + +/// Deploy a contract. +/// +/// The contract deployment will be done by a random account. +pub async fn deploy_contract( + provider: &RootProvider, + file_path: &str, + constructor_arguments: &[u8], +) -> Address { + let hex_bin_buf = std::fs::read_to_string(file_path).unwrap(); + let hex_bin = + if let Some(stripped) = hex_bin_buf.strip_prefix("0x") { stripped } else { &hex_bin_buf }; + let mut bin = Vec::::from(Bytes::from_hex(hex_bin).unwrap()); + bin.extend(constructor_arguments); + + let deployment_tx = TxLegacy { + chain_id: None, + nonce: 0, + // 100 gwei + gas_price: 100_000_000_000u128, + gas_limit: 1_000_000, + to: TxKind::Create, + value: U256::ZERO, + input: bin.into(), + }; + + let deployment_tx = deterministically_sign(&deployment_tx); + + let receipt = publish_tx(provider, deployment_tx).await; + assert!(receipt.status()); + + receipt.contract_address.unwrap() +} + +/// Sign and send a transaction from the specified wallet. +/// +/// This assumes the wallet is funded. +pub async fn send( + provider: &RootProvider, + wallet: &k256::ecdsa::SigningKey, + mut tx: TxLegacy, +) -> TransactionReceipt { + let verifying_key = *wallet.verifying_key().as_affine(); + let address = Address::from(address(&verifying_key.into())); + + // https://github.com/alloy-rs/alloy/issues/539 + // let chain_id = provider.get_chain_id().await.unwrap(); + // tx.chain_id = Some(chain_id); + tx.chain_id = None; + tx.nonce = provider.get_transaction_count(address).await.unwrap(); + // 100 gwei + tx.gas_price = 100_000_000_000u128; + + let sig = wallet.sign_prehash_recoverable(tx.signature_hash().as_ref()).unwrap(); + assert_eq!(address, tx.clone().into_signed(sig.into()).recover_signer().unwrap()); + assert!( + provider.get_balance(address).await.unwrap() > + ((U256::from(tx.gas_price) * U256::from(tx.gas_limit)) + tx.value) + ); + + let mut bytes = vec![]; + tx.encode_with_signature_fields(&Signature::from(sig), &mut bytes); + let pending_tx = provider.send_raw_transaction(&bytes).await.unwrap(); + pending_tx.get_receipt().await.unwrap() +}