Work on testing the Router

This commit is contained in:
Luke Parker
2024-10-31 02:23:59 -04:00
parent b2ec58a445
commit 6a520a7412
13 changed files with 505 additions and 23 deletions

View File

@@ -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" }

View File

@@ -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

View File

@@ -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<u8> {
("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<PublicKey, RpcError<TransportErrorKind>> {
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<u64, RpcError<TransportErrorKind>> {
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<Address, RpcError<TransportErrorKind>> {
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)
}
}

View File

@@ -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<RootProvider<SimpleRequest>>, 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::<crate::InInstructionEvent>().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")
}