6 Commits

Author SHA1 Message Date
Luke Parker
6a520a7412 Work on testing the Router 2024-10-31 02:23:59 -04:00
Luke Parker
b2ec58a445 Update serai-ethereum-processor to compile 2024-10-30 21:48:40 -04:00
Luke Parker
8e800885fb Simplify deterministic signing process in serai-processor-ethereum-primitives
This should be easier to specify/do an alternative implementation of.
2024-10-30 21:36:31 -04:00
Luke Parker
2a427382f1 Natspec, slither Deployer, Router 2024-10-30 21:35:43 -04:00
Luke Parker
ce1689b325 Expand tests for ethereum-schnorr-contract 2024-10-28 18:08:31 -04:00
Luke Parker
0b61a75afc Add lint against string slicing
These are tricky as it panics if the slice doesn't hit a UTF-8 codepoint
boundary.
2024-10-02 21:58:48 -04:00
31 changed files with 1198 additions and 285 deletions

View File

@@ -90,3 +90,25 @@ jobs:
run: |
cargo install cargo-machete
cargo machete
slither:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3df4ab11eba7bda6032a0b82a6bb43b11571feac
- name: Slither
run: |
python3 -m pip install solc-select
solc-select install 0.8.26
solc-select use 0.8.26
python3 -m pip install slither-analyzer
slither --include-paths ./networks/ethereum/schnorr/contracts/Schnorr.sol
slither --include-paths ./networks/ethereum/schnorr/contracts ./networks/ethereum/schnorr/contracts/tests/Schnorr.sol
slither processor/ethereum/deployer/contracts/Deployer.sol
slither processor/ethereum/erc20/contracts/IERC20.sol
cp networks/ethereum/schnorr/contracts/Schnorr.sol processor/ethereum/router/contracts/
cp processor/ethereum/erc20/contracts/IERC20.sol processor/ethereum/router/contracts/
cd processor/ethereum/router/contracts
slither Router.sol

View File

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

19
Cargo.lock generated
View File

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

View File

@@ -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",
@@ -245,6 +246,7 @@ range_plus_one = "deny"
redundant_closure_for_method_calls = "deny"
redundant_else = "deny"
string_add_assign = "deny"
string_slice = "deny"
unchecked_duration_subtraction = "deny"
uninlined_format_args = "deny"
unnecessary_box_returns = "deny"

View File

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

View File

@@ -1,7 +1,18 @@
// SPDX-License-Identifier: AGPL-3.0-only
pragma solidity ^0.8.26;
// See https://github.com/noot/schnorr-verify for implementation details
/// @title A library for verifying Schnorr signatures
/// @author Luke Parker <lukeparker@serai.exchange>
/// @author Elizabeth Binks <elizabethjbinks@gmail.com>
/// @notice Verifies a Schnorr signature for a specified public key
/**
* @dev This contract is not complete (in the cryptographic sense). Only a subset of potential
* public keys are representable here.
*
* See https://github.com/serai-dex/serai/blob/next/networks/ethereum/schnorr/src/tests/premise.rs
* for implementation details
*/
// TODO: Pin above link to a specific branch/commit once `next` is merged into `develop`
library Schnorr {
// secp256k1 group order
uint256 private constant Q = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141;
@@ -11,31 +22,43 @@ library Schnorr {
// 2) An x-coordinate < Q
uint8 private constant KEY_PARITY = 27;
// px := public key x-coordinate, where the public key has an even y-coordinate
// message := the message signed
// c := Schnorr signature challenge
// s := Schnorr signature solution
function verify(bytes32 px, bytes32 message, bytes32 c, bytes32 s) internal pure returns (bool) {
/// @notice Verifies a Schnorr signature for the specified public key
/**
* @dev The y-coordinate of the public key is assumed to be even. The x-coordinate of the public
* key is assumed to be less than the order of secp256k1.
*
* The challenge is calculated as `keccak256(abi.encodePacked(address(R), publicKey, message))`
* where `R` is the commitment to the Schnorr signature's nonce.
*/
/// @param publicKey The x-coordinate of the public key
/// @param message The (hash of the) message signed
/// @param c The challenge for the Schnorr signature
/// @param s The response to the challenge for the Schnorr signature
/// @return If the signature is valid
function verify(bytes32 publicKey, bytes32 message, bytes32 c, bytes32 s)
internal
pure
returns (bool)
{
// ecrecover = (m, v, r, s) -> key
// We instead pass the following to obtain the nonce (not the key)
// Then we hash it and verify it matches the challenge
bytes32 sa = bytes32(Q - mulmod(uint256(s), uint256(px), Q));
bytes32 ca = bytes32(Q - mulmod(uint256(c), uint256(px), Q));
// We instead pass the following to recover the Schnorr signature's nonce (not a public key)
bytes32 sa = bytes32(Q - mulmod(uint256(s), uint256(publicKey), Q));
bytes32 ca = bytes32(Q - mulmod(uint256(c), uint256(publicKey), Q));
/*
The ecrecover precompile checks `r` and `s` (`px` and `ca`) are non-zero,
banning the two keys with zero for their x-coordinate and zero challenge.
Each has negligible probability of occuring (assuming zero x-coordinates
are even on-curve in the first place).
The ecrecover precompile checks `r` and `s` (`publicKey` and `ca`) are non-zero, banning the
two keys with zero for their x-coordinate and zero challenges. Each already only had a
negligible probability of occuring (assuming zero x-coordinates are even on-curve in the first
place).
`sa` is not checked to be non-zero yet it does not need to be. The inverse
of it is never taken.
`sa` is not checked to be non-zero yet it does not need to be. The inverse of it is never
taken.
*/
address R = ecrecover(sa, KEY_PARITY, px, ca);
address R = ecrecover(sa, KEY_PARITY, publicKey, ca);
// The ecrecover failed
if (R == address(0)) return false;
// Check the signature is correct by rebuilding the challenge
return c == keccak256(abi.encodePacked(R, px, message));
return c == keccak256(abi.encodePacked(R, publicKey, message));
}
}

View File

@@ -3,12 +3,28 @@ pragma solidity ^0.8.26;
import "../Schnorr.sol";
/// @title A thin wrapper around the library for verifying Schnorr signatures to test it with
/// @author Luke Parker <lukeparker5132@gmail.com>
/// @author Elizabeth Binks <elizabethjbinks@gmail.com>
contract TestSchnorr {
function verify(bytes32 public_key, bytes calldata message, bytes32 c, bytes32 s)
/// @notice Verifies a Schnorr signature for the specified public key
/**
* @dev The y-coordinate of the public key is assumed to be even. The x-coordinate of the public
* key is assumed to be less than the order of secp256k1.
*
* The challenge is calculated as `keccak256(abi.encodePacked(address(R), publicKey, message))`
* where `R` is the commitment to the Schnorr signature's nonce.
*/
/// @param publicKey The x-coordinate of the public key
/// @param message The (hash of the) message signed
/// @param c The challenge for the Schnorr signature
/// @param s The response to the challenge for the Schnorr signature
/// @return If the signature is valid
function verify(bytes32 publicKey, bytes calldata message, bytes32 c, bytes32 s)
external
pure
returns (bool)
{
return Schnorr.verify(public_key, keccak256(message), c, s);
return Schnorr.verify(publicKey, keccak256(message), c, s);
}
}

View File

@@ -31,8 +31,7 @@ impl PublicKey {
let x_coordinate = affine.x();
// Return None if the x-coordinate isn't mutual to both fields
// While reductions shouldn't be an issue, it's one less headache/concern to have
// The trivial amount of public keys this makes non-representable aren't a concern
// The trivial amount of public keys this makes non-representable aren't considered a concern
if <Scalar as Reduce<KU256>>::reduce_bytes(&x_coordinate).to_repr() != x_coordinate {
None?;
}

View File

@@ -20,11 +20,17 @@ pub struct Signature {
impl Signature {
/// Construct a new `Signature`.
#[must_use]
pub fn new(c: Scalar, s: Scalar) -> Signature {
Signature { c, s }
pub fn new(c: Scalar, s: Scalar) -> Option<Signature> {
if bool::from(c.is_zero()) {
None?;
}
Some(Signature { c, s })
}
/// The challenge for a signature.
///
/// With negligible probability, this MAY return 0 which will create an invalid/unverifiable
/// signature.
#[must_use]
pub fn challenge(R: ProjectivePoint, key: &PublicKey, message: &[u8]) -> Scalar {
// H(R || A || m)

View File

@@ -17,6 +17,9 @@ use alloy_node_bindings::{Anvil, AnvilInstance};
use crate::{PublicKey, Signature};
mod public_key;
pub(crate) use public_key::test_key;
mod signature;
mod premise;
#[expect(warnings)]
@@ -88,12 +91,7 @@ async fn test_verify() {
let (_anvil, provider, address) = setup_test().await;
for _ in 0 .. 100 {
let (key, public_key) = loop {
let key = Scalar::random(&mut OsRng);
if let Some(public_key) = PublicKey::new(ProjectivePoint::GENERATOR * key) {
break (key, public_key);
}
};
let (key, public_key) = test_key();
let nonce = Scalar::random(&mut OsRng);
let mut message = vec![0; 1 + usize::try_from(OsRng.next_u32() % 256).unwrap()];
@@ -102,11 +100,37 @@ async fn test_verify() {
let c = Signature::challenge(ProjectivePoint::GENERATOR * nonce, &public_key, &message);
let s = nonce + (c * key);
let sig = Signature::new(c, s);
let sig = Signature::new(c, s).unwrap();
assert!(sig.verify(&public_key, &message));
assert!(call_verify(&provider, address, &public_key, &message, &sig).await);
// Test setting `s = 0` doesn't pass verification
{
let zero_s = Signature::new(c, Scalar::ZERO).unwrap();
assert!(!zero_s.verify(&public_key, &message));
assert!(!call_verify(&provider, address, &public_key, &message, &zero_s).await);
}
// Mutate the message and make sure the signature now fails to verify
message[0] = message[0].wrapping_add(1);
assert!(!call_verify(&provider, address, &public_key, &message, &sig).await);
{
let mut message = message.clone();
message[0] = message[0].wrapping_add(1);
assert!(!sig.verify(&public_key, &message));
assert!(!call_verify(&provider, address, &public_key, &message, &sig).await);
}
// Mutate c and make sure the signature now fails to verify
{
let mutated_c = Signature::new(c + Scalar::ONE, s).unwrap();
assert!(!mutated_c.verify(&public_key, &message));
assert!(!call_verify(&provider, address, &public_key, &message, &mutated_c).await);
}
// Mutate s and make sure the signature now fails to verify
{
let mutated_s = Signature::new(c, s + Scalar::ONE).unwrap();
assert!(!mutated_s.verify(&public_key, &message));
assert!(!call_verify(&provider, address, &public_key, &message, &mutated_s).await);
}
}
}

View File

@@ -12,7 +12,7 @@ use k256::{
use alloy_core::primitives::Address;
use crate::{PublicKey, Signature};
use crate::{Signature, tests::test_key};
// The ecrecover opcode, yet with if the y is odd replacing v
fn ecrecover(message: Scalar, odd_y: bool, r: Scalar, s: Scalar) -> Option<[u8; 20]> {
@@ -64,12 +64,7 @@ fn test_ecrecover() {
// of efficiently verifying Schnorr signatures in an Ethereum contract
#[test]
fn nonce_recovery_via_ecrecover() {
let (key, public_key) = loop {
let key = Scalar::random(&mut OsRng);
if let Some(public_key) = PublicKey::new(ProjectivePoint::GENERATOR * key) {
break (key, public_key);
}
};
let (key, public_key) = test_key();
let nonce = Scalar::random(&mut OsRng);
let R = ProjectivePoint::GENERATOR * nonce;
@@ -81,26 +76,28 @@ fn nonce_recovery_via_ecrecover() {
let s = nonce + (c * key);
/*
An ECDSA signature is `(r, s)` with `s = (H(m) + rx) / k`, where:
- `m` is the message
An ECDSA signature is `(r, s)` with `s = (m + (r * x)) / k`, where:
- `m` is the hash of the message
- `r` is the x-coordinate of the nonce, reduced into a scalar
- `x` is the private key
- `k` is the nonce
We fix the recovery ID to be for the even key with an x-coordinate < the order. Accordingly,
`kG = Point::from(Even, r)`. This enables recovering the public key via
`((s Point::from(Even, r)) - H(m)G) / r`.
`k * G = Point::from(Even, r)`. This enables recovering the public key via
`((s * Point::from(Even, r)) - (m * G)) / r`.
We want to calculate `R` from `(c, s)` where `s = r + cx`. That means we need to calculate
`sG - cX`.
`(s * G) - (c * X)`.
We can calculate `sG - cX` with `((s Point::from(Even, r)) - H(m)G) / r` if:
- Latter `r` = `X.x`
- Latter `s` = `c`
- `H(m)` = former `s`
This gets us to `(cX - sG) / X.x`. If we additionally scale the latter's `s, H(m)` values (the
former's `c, s` values) by `X.x`, we get `cX - sG`. This just requires negating each to achieve
`sG - cX`.
We can calculate `(s * G) - (c * X)` with `((s * Point::from(Even, r)) - (m * G)) / r` if:
- ECDSA `r` = `X.x`, the x-coordinate of the Schnorr public key
- ECDSA `s` = `c`, the Schnorr signature's challenge
- ECDSA `m` = Schnorr `s`
This gets us to `((c * X) - (s * G)) / X.x`. If we additionally scale the ECDSA `s, m` values
(the Schnorr `c, s` values) by `X.x`, we get `(c * X) - (s * G)`. This just requires negating
to achieve `(s * G) - (c * X)`.
With `R`, we can recalculate and compare the challenges to confirm the signature is valid.
*/
let x_scalar = <Scalar as Reduce<U256>>::reduce_bytes(&public_key.point().to_affine().x());
let sa = -(s * x_scalar);

View File

@@ -0,0 +1,69 @@
use rand_core::OsRng;
use subtle::Choice;
use group::ff::{Field, PrimeField};
use k256::{
elliptic_curve::{
FieldBytesEncoding,
ops::Reduce,
point::{AffineCoordinates, DecompressPoint},
},
AffinePoint, ProjectivePoint, Scalar, U256 as KU256,
};
use crate::PublicKey;
// Generates a key usable within tests
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) {
// While here, test `PublicKey::point` and its serialization functions
assert_eq!(point, public_key.point());
assert_eq!(PublicKey::from_eth_repr(public_key.eth_repr()).unwrap(), public_key);
return (key, public_key);
}
}
}
#[test]
fn test_odd_key() {
// We generate a valid key to ensure there's not some distinct reason this key is invalid
let (_, key) = test_key();
// We then take its point and negate it so its y-coordinate is odd
let odd = -key.point();
assert!(PublicKey::new(odd).is_none());
}
#[test]
fn test_non_mutual_key() {
let mut x_coordinate = KU256::from(-(Scalar::ONE)).saturating_add(&KU256::ONE);
let y_is_odd = Choice::from(0);
let non_mutual = loop {
if let Some(point) = Option::<AffinePoint>::from(AffinePoint::decompress(
&FieldBytesEncoding::encode_field_bytes(&x_coordinate),
y_is_odd,
)) {
break point;
}
x_coordinate = x_coordinate.saturating_add(&KU256::ONE);
};
let x_coordinate = non_mutual.x();
assert!(<Scalar as Reduce<KU256>>::reduce_bytes(&x_coordinate).to_repr() != x_coordinate);
// Even point whose x-coordinate isn't mutual to both fields (making it non-zero)
assert!(PublicKey::new(non_mutual.into()).is_none());
}
#[test]
fn test_zero_key() {
let y_is_odd = Choice::from(0);
if let Some(A_affine) =
Option::<AffinePoint>::from(AffinePoint::decompress(&[0; 32].into(), y_is_odd))
{
let A = ProjectivePoint::from(A_affine);
assert!(PublicKey::new(A).is_none());
}
}

View File

@@ -0,0 +1,33 @@
use rand_core::OsRng;
use group::ff::Field;
use k256::Scalar;
use crate::Signature;
#[test]
fn test_zero_challenge() {
assert!(Signature::new(Scalar::ZERO, Scalar::random(&mut OsRng)).is_none());
}
#[test]
fn test_signature_serialization() {
let c = Scalar::random(&mut OsRng);
let s = Scalar::random(&mut OsRng);
let sig = Signature::new(c, s).unwrap();
assert_eq!(sig.c(), c);
assert_eq!(sig.s(), s);
let sig_bytes = sig.to_bytes();
assert_eq!(Signature::from_bytes(sig_bytes).unwrap(), sig);
{
let mut sig_written_bytes = vec![];
sig.write(&mut sig_written_bytes).unwrap();
assert_eq!(sig_bytes.as_slice(), &sig_written_bytes);
}
let mut sig_read_slice = sig_bytes.as_slice();
assert_eq!(Signature::read(&mut sig_read_slice).unwrap(), sig);
assert!(sig_read_slice.is_empty());
}

View File

@@ -296,7 +296,7 @@ pub async fn main_loop<
is equally valid unless we want to start introspecting (and should be our only
Batch anyways).
*/
burns.drain(..).collect(),
std::mem::take(&mut burns),
key_to_activate,
);
}

View File

@@ -2,7 +2,7 @@
pragma solidity ^0.8.26;
/*
The expected deployment process of the Router is as follows:
The expected deployment process of Serai's Router is as follows:
1) A transaction deploying Deployer is made. Then, a deterministic signature is
created such that an account with an unknown private key is the creator of
@@ -32,35 +32,57 @@ pragma solidity ^0.8.26;
with Serai verifying the published result. This would introduce a DoS risk in
the council not publishing the correct key/not publishing any key.
This design does not work with designs expecting initialization (which may require re-deploying
the same code until the initialization successfully goes through, without being sniped).
This design does not work (well) with contracts expecting initialization due
to only allowing deploying init code once (which assumes contracts are
distinct via their constructors). Such designs are unused by Serai so that is
accepted.
*/
/// @title Deployer of contracts for the Serai network
/// @author Luke Parker <lukeparker@serai.exchange>
contract Deployer {
/// @return The deployment for some (hashed) init code
mapping(bytes32 => address) public deployments;
/// @notice Raised if the provided init code was already prior deployed
error PriorDeployed();
/// @notice Raised if the deployment fails
error DeploymentFailed();
function deploy(bytes memory init_code) external {
/// @notice Deploy the specified init code with `CREATE`
/// @dev This init code is expected to be unique and not prior deployed
/// @param initCode The init code to pass to `CREATE`
function deploy(bytes memory initCode) external {
// Deploy the contract
address created_contract;
address createdContract;
// slither-disable-next-line assembly
assembly {
created_contract := create(0, add(init_code, 0x20), mload(init_code))
createdContract := create(0, add(initCode, 0x20), mload(initCode))
}
if (created_contract == address(0)) {
if (createdContract == address(0)) {
revert DeploymentFailed();
}
bytes32 init_code_hash = keccak256(init_code);
bytes32 initCodeHash = keccak256(initCode);
// Check this wasn't prior deployed
// We check this *after* deploymeing (in violation of CEI) to handle re-entrancy related bugs
if (deployments[init_code_hash] != address(0)) {
/*
Check this wasn't prior deployed.
This is a post-check, not a pre-check (in violation of the CEI pattern). If we used a
pre-check, a deployed contract could re-enter the Deployer to deploy the same contract
multiple times due to the inner call updating state and then the outer call overwriting it.
The post-check causes the outer call to error once the inner call updates state.
This does mean contract deployment may fail if deployment causes arbitrary execution which
maliciously nests deployment of the being-deployed contract. Such an inner call won't fail,
yet the outer call would. The usage of a re-entrancy guard would call the inner call to fail
while the outer call succeeds. This is considered so edge-case it isn't worth handling.
*/
if (deployments[initCodeHash] != address(0)) {
revert PriorDeployed();
}
// Write the deployment to storage
deployments[init_code_hash] = created_contract;
deployments[initCodeHash] = createdContract;
}
}

View File

@@ -26,8 +26,8 @@ mod abi {
/// The Deployer contract for the Serai Router contract.
///
/// This Deployer has a deterministic address, letting it be immediately identified on any
/// compatible chain. It then supports retrieving the Router contract's address (which isn't
/// This Deployer has a deterministic address, letting it be immediately identified on any instance
/// of the EVM. It then supports retrieving the deployed contracts addresses (which aren't
/// deterministic) using a single call.
#[derive(Clone, Debug)]
pub struct Deployer(Arc<RootProvider<SimpleRequest>>);
@@ -59,13 +59,30 @@ 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<u8>) -> 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.
pub async fn new(
provider: Arc<RootProvider<SimpleRequest>>,
) -> Result<Option<Self>, RpcError<TransportErrorKind>> {

View File

@@ -3,7 +3,7 @@
#![deny(missing_docs)]
use group::ff::PrimeField;
use k256::{elliptic_curve::ops::Reduce, U256, Scalar};
use k256::Scalar;
use alloy_core::primitives::{Parity, Signature};
use alloy_consensus::{SignableTransaction, Signed, TxLegacy};
@@ -15,20 +15,21 @@ pub fn keccak256(data: impl AsRef<[u8]>) -> [u8; 32] {
/// Deterministically sign a transaction.
///
/// This function panics if passed a transaction with a non-None chain ID.
/// This signs a transaction via setting `r = 1, s = 1`, and incrementing `r` until a signer is
/// recoverable from the signature for this transaction. The purpose of this is to be able to send
/// a transaction from a known account which no one knows the private key for.
///
/// This function panics if passed a transaction with a non-None chain ID. This is because the
/// signer for this transaction is only singular across any/all EVM instances if it isn't binding
/// to an instance.
pub fn deterministically_sign(tx: &TxLegacy) -> Signed<TxLegacy> {
pub fn hash_to_scalar(data: impl AsRef<[u8]>) -> Scalar {
<Scalar as Reduce<U256>>::reduce_bytes(&keccak256(data).into())
}
assert!(
tx.chain_id.is_none(),
"chain ID was Some when deterministically signing a TX (causing a non-deterministic signer)"
"chain ID was Some when deterministically signing a TX (causing a non-singular signer)"
);
let sig_hash = tx.signature_hash().0;
let mut r = hash_to_scalar([sig_hash.as_slice(), b"r"].concat());
let mut s = hash_to_scalar([sig_hash.as_slice(), b"s"].concat());
let mut r = Scalar::ONE;
let s = Scalar::ONE;
loop {
// Create the signature
let r_bytes: [u8; 32] = r.to_repr().into();
@@ -42,8 +43,6 @@ pub fn deterministically_sign(tx: &TxLegacy) -> Signed<TxLegacy> {
return tx;
}
// Re-hash until valid
r = hash_to_scalar(r_bytes);
s = hash_to_scalar(s_bytes);
r += Scalar::ONE;
}
}

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 +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.

View File

@@ -1,240 +1,503 @@
// SPDX-License-Identifier: AGPL-3.0-only
pragma solidity ^0.8.26;
// TODO: MIT licensed interface
import "IERC20.sol";
import "Schnorr.sol";
// _ is used as a prefix for internal functions and smart-contract-scoped variables
contract Router {
// Nonce is incremented for each command executed, preventing replays
uint256 private _nonce;
/*
The Router directly performs low-level calls in order to fine-tune the gas settings. Since this
contract is meant to relay an entire batch of transactions, the ability to exactly meter
individual transactions is critical.
// The nonce which will be used for the smart contracts we deploy, enabling
// predicting their addresses
We don't check the return values as we don't care if the calls succeeded. We solely care we made
them. If someone configures an external contract in a way which borks, we epxlicitly define that
as their fault and out-of-scope to this contract.
If an actual invariant within Serai exists, an escape hatch exists to move to a new contract. Any
improperly handled actions can be re-signed and re-executed at that point in time.
*/
// slither-disable-start low-level-calls,unchecked-lowlevel
/// @title Serai Router
/// @author Luke Parker <lukeparker@serai.exchange>
/// @notice Intakes coins for the Serai network and handles relaying batches of transfers out
contract Router {
/**
* @dev The next nonce used to determine the address of contracts deployed with CREATE. This is
* used to predict the addresses of deployed contracts ahead of time.
*/
/*
We don't expose a getter for this as it shouldn't be expected to have any specific value at a
given moment in time. If someone wants to know the address of their deployed contract, they can
have it emit an event and verify the emitting contract is the expected one.
*/
uint256 private _smartContractNonce;
// The current public key, defined as per the Schnorr library
/**
* @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
* expects
*/
bytes32 private _seraiKey;
/// @dev The address escaped to
address private _escapedTo;
/// @title The type of destination
/// @dev A destination is either an address or a blob of code to deploy and call
enum DestinationType {
Address,
Code
}
struct AddressDestination {
address destination;
}
/// @title A code destination
/**
* @dev If transferring an ERC20 to this destination, it will be transferred to the address the
* code will be deployed to. If transferring ETH, it will be transferred with the deployment of
* the code. `code` is deployed with CREATE (calling its constructor). The entire deployment
* (and associated sandboxing) must consume less than `gasLimit` units of gas or it will revert.
*/
struct CodeDestination {
uint32 gas_limit;
uint32 gasLimit;
bytes code;
}
/// @title An instruction to transfer coins out
/// @dev Specifies a destination and amount but not the coin as that's assumed to be contextual
struct OutInstruction {
DestinationType destinationType;
bytes destination;
uint256 value;
uint256 amount;
}
/// @title A signature
/// @dev Thin wrapper around `c, s` to simplify the API
struct Signature {
bytes32 c;
bytes32 s;
}
/// @notice Emitted when the key for Serai's Ethereum validators is updated
/// @param nonce The nonce consumed to update this key
/// @param key The key updated to
event SeraiKeyUpdated(uint256 indexed nonce, bytes32 indexed key);
/// @notice Emitted when an InInstruction occurs
/// @param from The address which called `inInstruction` and caused this event to be emitted
/// @param coin The coin transferred in
/// @param amount The amount of the coin transferred in
/// @param instruction The Shorthand-encoded InInstruction for Serai to decode and handle
event InInstruction(
address indexed from, address indexed coin, uint256 amount, bytes instruction
);
event Executed(uint256 indexed nonce, bytes32 indexed message_hash);
/// @notice Emitted when a batch of `OutInstruction`s occurs
/// @param nonce The nonce consumed to execute this batch of transactions
/// @param messageHash The hash of the message signed for the executed batch
event Executed(uint256 indexed nonce, bytes32 indexed messageHash);
/// @notice Emitted when `escapeHatch` is invoked
/// @param escapeTo The address to escape to
event EscapeHatch(address indexed escapeTo);
/// @notice Emitted when coins escape through the escape hatch
/// @param coin The coin which escaped
event Escaped(address indexed coin);
/// @notice The contract has had its escape hatch invoked and won't accept further actions
error EscapeHatchInvoked();
/// @notice The signature was invalid
error InvalidSignature();
error InvalidAmount();
error FailedTransfer();
/// @notice The amount specified didn't match `msg.value`
error AmountMismatchesMsgValue();
/// @notice The call to an ERC20's `transferFrom` failed
error TransferFromFailed();
// Update the Serai key at the end of the current function.
modifier _updateSeraiKeyAtEndOfFn(uint256 nonceUpdatedWith, bytes32 newSeraiKey) {
// Run the function itself.
/// @notice An invalid address to escape to was specified.
error InvalidEscapeAddress();
/// @notice Escaping when escape hatch wasn't invoked.
error EscapeHatchNotInvoked();
/**
* @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
* `_nextNonce`
*/
/// @param nonceUpdatedWith The nonce used to update the key
/// @param newSeraiKey The key updated to
modifier updateSeraiKeyAtEndOfFn(uint256 nonceUpdatedWith, bytes32 newSeraiKey) {
// Run the function itself
_;
// Update the key.
// Update the key
_seraiKey = newSeraiKey;
emit SeraiKeyUpdated(nonceUpdatedWith, newSeraiKey);
}
constructor(bytes32 initialSeraiKey) _updateSeraiKeyAtEndOfFn(0, initialSeraiKey) {
// We consumed nonce 0 when setting the initial Serai key
_nonce = 1;
/// @notice The constructor for the relayer
/// @param initialSeraiKey The initial key for Serai's Ethereum validators
constructor(bytes32 initialSeraiKey) updateSeraiKeyAtEndOfFn(0, initialSeraiKey) {
// Nonces are incremented by 1 upon account creation, prior to any code execution, per EIP-161
// This is incompatible with any networks which don't have their nonces start at 0
_smartContractNonce = 1;
// We consumed nonce 0 when setting the initial Serai key
_nextNonce = 1;
// We haven't escaped to any address yet
_escapedTo = address(0);
}
// updateSeraiKey validates the given Schnorr signature against the current public key, and if
// successful, updates the contract's public key to the one specified.
function updateSeraiKey(bytes32 newSeraiKey, Signature calldata signature)
external
_updateSeraiKeyAtEndOfFn(_nonce, newSeraiKey)
{
// This DST needs a length prefix as well to prevent DSTs potentially being substrings of each
// other, yet this fine for our very well-defined, limited use
bytes32 message =
keccak256(abi.encodePacked("updateSeraiKey", block.chainid, _nonce, newSeraiKey));
_nonce++;
/// @dev Verify a signature
/// @param message The message to pass to the Schnorr contract
/// @param signature The signature by the current key for this message
function verifySignature(bytes32 message, Signature calldata signature) private {
// If the escape hatch was triggered, reject further signatures
if (_escapedTo != address(0)) {
revert EscapeHatchInvoked();
}
// Verify the signature
if (!Schnorr.verify(_seraiKey, message, signature.c, signature.s)) {
revert InvalidSignature();
}
// Set the next nonce
unchecked {
_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(_nextNonce, newSeraiKey)
{
/*
This DST needs a length prefix as well to prevent DSTs potentially being substrings of each
other, yet this is fine for our well-defined, extremely-limited use.
We don't encode the chain ID as Serai generates independent keys for each integration. If
Ethereum L2s are integrated, and they reuse the Ethereum validator set, we would use the
existing Serai key yet we'd apply an off-chain derivation scheme to bind it to specific
networks. This also lets Serai identify EVMs per however it wants, solving the edge case where
two instances of the EVM share a chain ID for whatever horrific reason.
This uses encodePacked as all items present here are of fixed length.
*/
bytes32 message = keccak256(abi.encodePacked("updateSeraiKey", _nextNonce, newSeraiKey));
verifySignature(message, signature);
}
/// @notice Transfer coins into Serai with an instruction
/// @param coin The coin to transfer in (address(0) if Ether)
/// @param amount The amount to transfer in (msg.value if Ether)
/**
* @param instruction The Shorthand-encoded InInstruction for Serai to associate with this
* transfer in
*/
// Re-entrancy doesn't bork this function
// slither-disable-next-line reentrancy-events
function inInstruction(address coin, uint256 amount, bytes memory instruction) external payable {
// Check the transfer
if (coin == address(0)) {
if (amount != msg.value) revert InvalidAmount();
if (amount != msg.value) revert AmountMismatchesMsgValue();
} else {
(bool success, bytes memory res) = address(coin).call(
abi.encodeWithSelector(IERC20.transferFrom.selector, msg.sender, address(this), amount)
);
// Require there was nothing returned, which is done by some non-standard tokens, or that the
// ERC20 contract did in fact return true
/*
Require there was nothing returned, which is done by some non-standard tokens, or that the
ERC20 contract did in fact return true
*/
bool nonStandardResOrTrue = (res.length == 0) || abi.decode(res, (bool));
if (!(success && nonStandardResOrTrue)) revert FailedTransfer();
if (!(success && nonStandardResOrTrue)) revert TransferFromFailed();
}
/*
Due to fee-on-transfer tokens, emitting the amount directly is frowned upon. The amount
instructed to be transferred may not actually be the amount transferred.
Due to fee-on-transfer tokens, emitting the amount directly is frowned upon. The amount
instructed to be transferred may not actually be the amount transferred.
If we add nonReentrant to every single function which can effect the balance, we can check the
amount exactly matches. This prevents transfers of less value than expected occurring, at
least, not without an additional transfer to top up the difference (which isn't routed through
this contract and accordingly isn't trying to artificially create events from this contract).
If we add nonReentrant to every single function which can effect the balance, we can check the
amount exactly matches. This prevents transfers of less value than expected occurring, at
least, not without an additional transfer to top up the difference (which isn't routed through
this contract and accordingly isn't trying to artificially create events from this contract).
If we don't add nonReentrant, a transfer can be started, and then a new transfer for the
difference can follow it up (again and again until a rounding error is reached). This contract
would believe all transfers were done in full, despite each only being done in part (except
for the last one).
If we don't add nonReentrant, a transfer can be started, and then a new transfer for the
difference can follow it up (again and again until a rounding error is reached). This contract
would believe all transfers were done in full, despite each only being done in part (except
for the last one).
Given fee-on-transfer tokens aren't intended to be supported, the only token actively planned
to be supported is Dai and it doesn't have any fee-on-transfer logic, and how fee-on-transfer
tokens aren't even able to be supported at this time by the larger Serai network, we simply
classify this entire class of tokens as non-standard implementations which induce undefined
behavior.
Given fee-on-transfer tokens aren't intended to be supported, the only token actively planned
to be supported is Dai and it doesn't have any fee-on-transfer logic, and how fee-on-transfer
tokens aren't even able to be supported at this time by the larger Serai network, we simply
classify this entire class of tokens as non-standard implementations which induce undefined
behavior.
It is the Serai network's role not to add support for any non-standard implementations.
It is the Serai network's role not to add support for any non-standard implementations.
*/
emit InInstruction(msg.sender, coin, amount, instruction);
}
/*
We on purposely do not check if these calls succeed. A call either succeeded, and there's no
problem, or the call failed due to:
A) An insolvency
B) A malicious receiver
C) A non-standard token
A is an invariant, B should be dropped, C is something out of the control of this contract.
It is again the Serai's network role to not add support for any non-standard tokens,
*/
/// @dev Perform an ERC20 transfer out
/// @param to The address to transfer the coins to
/// @param coin The coin to transfer
/// @param amount The amount of the coin to transfer
function erc20TransferOut(address to, address coin, uint256 amount) private {
/*
The ERC20s integrated are presumed to have a constant gas cost, meaning this can only be
insufficient:
// Perform an ERC20 transfer out
function _erc20TransferOut(address to, address coin, uint256 value) private {
coin.call{ gas: 100_000 }(abi.encodeWithSelector(IERC20.transfer.selector, msg.sender, value));
}
A) An integrated ERC20 uses more gas than this limit (presumed not to be the case)
B) An integrated ERC20 is upgraded (integrated ERC20s are presumed to not be upgradeable)
C) This has a variable gas cost and the user set a hook on receive which caused this (in
which case, we accept dropping this)
D) The user was blacklisted (in which case, we again accept dropping this)
E) Other extreme edge cases, for which such tokens are assumed to not be integrated
F) Ethereum opcodes are repriced in a sufficiently breaking fashion
// Perform an ETH/ERC20 transfer out
function _transferOut(address to, address coin, uint256 value) private {
if (coin == address(0)) {
// Enough gas to service the transfer and a minimal amount of logic
to.call{ value: value, gas: 5_000 }("");
} else {
_erc20TransferOut(to, coin, value);
This should be in such excess of the gas requirements of integrated tokens we'll survive
repricing, so long as the repricing doesn't revolutionize EVM gas costs as we know it. In such
a case, Serai would have to migrate to a new smart contract using `escapeHatch`.
*/
uint256 _gas = 100_000;
bytes memory _calldata = abi.encodeWithSelector(IERC20.transfer.selector, to, amount);
bool _success;
// slither-disable-next-line assembly
assembly {
/*
`coin` is trusted so we can accept the risk of a return bomb here, yet we won't check the
return value anyways so there's no need to spend the gas decoding it. We assume failures
are the fault of the recipient, not us, the sender. We don't want to have such errors block
the queue of transfers to make.
If there ever was some invariant broken, off-chain actions is presumed to occur to move to a
new smart contract with whatever necessary changes made/response occurring.
*/
_success :=
call(
_gas,
coin,
// Ether value
0,
// calldata
add(_calldata, 0x20),
mload(_calldata),
// return data
0,
0
)
}
}
/*
Serai supports arbitrary calls out via deploying smart contracts (with user-specified code),
letting them execute whatever calls they're coded for. Since we can't meter CREATE, we call
CREATE from this function which we call not internally, but with CALL (which we can meter).
*/
function arbitaryCallOut(bytes memory code) external payable {
/// @dev Perform an ETH/ERC20 transfer out
/// @param to The address to transfer the coins to
/// @param coin The coin to transfer (address(0) if Ether)
/// @param amount The amount of the coin to transfer
function transferOut(address to, address coin, uint256 amount) private {
if (coin == address(0)) {
// Enough gas to service the transfer and a minimal amount of logic
uint256 _gas = 5_000;
// This uses assembly to prevent return bombs
bool _success;
// slither-disable-next-line assembly
assembly {
_success :=
call(
_gas,
to,
amount,
// calldata
0,
0,
// return data
0,
0
)
}
} else {
erc20TransferOut(to, coin, amount);
}
}
/// @notice Execute some arbitrary code within a secure sandbox
/**
* @dev This performs sandboxing by deploying this code with `CREATE`. This is an external
* function as we can't meter `CREATE`/internal functions. We work around this by calling this
* function with `CALL` (which we can meter). This does forward `msg.value` to the newly
* deployed contract.
*/
/// @param code The code to execute
function executeArbitraryCode(bytes memory code) external payable {
// Because we're creating a contract, increment our nonce
_smartContractNonce += 1;
uint256 msg_value = msg.value;
uint256 msgValue = msg.value;
address contractAddress;
// We need to use assembly here because Solidity doesn't expose CREATE
// slither-disable-next-line assembly
assembly {
contractAddress := create(msg_value, add(code, 0x20), mload(code))
contractAddress := create(msgValue, add(code, 0x20), mload(code))
}
}
// Execute a list of transactions if they were signed by the current key with the current nonce
/// @notice Execute a batch of `OutInstruction`s
/**
* @dev All `OutInstruction`s in a batch are only for a single coin to simplify handling of the
* fee
*/
/// @param coin The coin all of these `OutInstruction`s are for
/// @param fee The fee to pay (in coin) to the caller for their relaying of this batch
/// @param outs The `OutInstruction`s to act on
/// @param signature The signature by the current key for Serai's Ethereum validators
// Each individual call is explicitly metered to ensure there isn't a DoS here
// slither-disable-next-line calls-loop
function execute(
address coin,
uint256 fee,
OutInstruction[] calldata transactions,
OutInstruction[] calldata outs,
Signature calldata signature
) external {
// Verify the signature
// We hash the message here as we need the message's hash for the Executed event
// Since we're already going to hash it, hashing it prior to verifying the signature reduces the
// amount of words hashed by its challenge function (reducing our gas costs)
bytes32 message =
keccak256(abi.encode("execute", block.chainid, _nonce, coin, fee, transactions));
if (!Schnorr.verify(_seraiKey, message, signature.c, signature.s)) {
revert InvalidSignature();
}
// 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", _nextNonce, coin, fee, outs));
verifySignature(message, signature);
// Since the signature was verified, perform execution
emit Executed(_nonce, message);
// While this is sufficient to prevent replays, it's still technically possible for instructions
// from later batches to be executed before these instructions upon re-entrancy
_nonce++;
// TODO: Also include a bit mask here
emit Executed(_nextNonce, message);
for (uint256 i = 0; i < transactions.length; i++) {
/*
Since we don't have a re-entrancy guard, it is possible for instructions from later batches to
be executed before these instructions. This is deemed fine. We only require later batches be
relayed after earlier batches in order to form backpressure. This means if a batch has a fee
which isn't worth relaying the batch for, so long as later batches are sufficiently
worthwhile, every batch will be relayed.
*/
// slither-disable-next-line reentrancy-events
for (uint256 i = 0; i < outs.length; i++) {
// If the destination is an address, we perform a direct transfer
if (transactions[i].destinationType == DestinationType.Address) {
// This may cause a panic and the contract to become stuck if the destination isn't actually
// 20 bytes. Serai is trusted to not pass a malformed destination
(AddressDestination memory destination) =
abi.decode(transactions[i].destination, (AddressDestination));
_transferOut(destination.destination, coin, transactions[i].value);
if (outs[i].destinationType == DestinationType.Address) {
/*
This may cause a revert if the destination isn't actually a valid address. Serai is
trusted to not pass a malformed destination, yet if it ever did, it could simply re-sign a
corrected batch using this nonce.
*/
address destination = abi.decode(outs[i].destination, (address));
transferOut(destination, coin, outs[i].amount);
} else {
// Prepare for the transfer
uint256 eth_value = 0;
// Prepare the transfer
uint256 ethValue = 0;
if (coin == address(0)) {
// If it's ETH, we transfer the value with the call
eth_value = transactions[i].value;
// If it's ETH, we transfer the amount with the call
ethValue = outs[i].amount;
} else {
// If it's an ERC20, we calculate the hash of the will-be contract and transfer to it
// before deployment. This avoids needing to deploy, then call again, offering a few
// optimizations
address nextAddress =
address(uint160(uint256(keccak256(abi.encode(address(this), _smartContractNonce)))));
_erc20TransferOut(nextAddress, coin, transactions[i].value);
/*
If it's an ERC20, we calculate the address of the will-be contract and transfer to it
before deployment. This avoids needing to deploy the contract, then call transfer, then
call the contract again
*/
address nextAddress = address(
uint160(uint256(keccak256(abi.encodePacked(address(this), _smartContractNonce))))
);
erc20TransferOut(nextAddress, coin, outs[i].amount);
}
// Perform the deployment with the defined gas budget
(CodeDestination memory destination) =
abi.decode(transactions[i].destination, (CodeDestination));
address(this).call{ gas: destination.gas_limit, value: eth_value }(
abi.encodeWithSelector(Router.arbitaryCallOut.selector, destination.code)
(CodeDestination memory destination) = abi.decode(outs[i].destination, (CodeDestination));
/*
Perform the deployment with the defined gas budget.
We don't care if the following call fails as we don't want to block/retry if it does.
Failures are considered the recipient's fault. We explicitly do not want the surface
area/inefficiency of caching these for later attempted retires.
We don't have to worry about a return bomb here as this is our own function which doesn't
return any data.
*/
address(this).call{ gas: destination.gasLimit, value: ethValue }(
abi.encodeWithSelector(Router.executeArbitraryCode.selector, destination.code)
);
}
}
// Transfer to the caller the fee
_transferOut(msg.sender, coin, fee);
// Transfer the fee to the relayer
transferOut(msg.sender, coin, fee);
}
function nonce() external view returns (uint256) {
return _nonce;
/// @notice Escapes to a new smart contract
/// @dev This should be used upon an invariant being reached or new functionality being needed
/// @param escapeTo The address to escape to
/// @param signature The signature by the current key for Serai's Ethereum validators
function escapeHatch(address escapeTo, Signature calldata signature) external {
if (escapeTo == address(0)) {
revert InvalidEscapeAddress();
}
/*
We want to define the escape hatch so coins here now, and latently received, can be forwarded.
If the last Serai key set could update the escape hatch, they could siphon off latently
received coins without penalty (if they update the escape hatch after unstaking).
*/
if (_escapedTo != address(0)) {
revert EscapeHatchInvoked();
}
// Verify the signature
bytes32 message = keccak256(abi.encodePacked("escapeHatch", _nextNonce, escapeTo));
verifySignature(message, signature);
_escapedTo = escapeTo;
emit EscapeHatch(escapeTo);
}
function smartContractNonce() external view returns (uint256) {
return _smartContractNonce;
/// @notice Escape coins after the escape hatch has been invoked
/// @param coin The coin to escape
function escape(address coin) external {
if (_escapedTo == address(0)) {
revert EscapeHatchNotInvoked();
}
emit Escaped(coin);
// Fetch the amount to escape
uint256 amount = address(this).balance;
if (coin != address(0)) {
amount = IERC20(coin).balanceOf(address(this));
}
// Perform the transfer
transferOut(_escapedTo, coin, amount);
}
/// @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 nextNonce() external view returns (uint256) {
return _nextNonce;
}
/// @notice Fetch the current key for Serai's Ethereum validator set
/// @return The current key for Serai's Ethereum validator set
function seraiKey() external view returns (bytes32) {
return _seraiKey;
}
/// @notice Fetch the address escaped to
/// @return The address which was escaped to (address(0) if the escape hatch hasn't been invoked)
function escapedTo() external view returns (address) {
return _escapedTo;
}
}
// slither-disable-end low-level-calls,unchecked-lowlevel

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 {
@@ -168,20 +171,19 @@ impl From<&[(SeraiAddress, U256)]> for OutInstructions {
.map(|(address, amount)| {
#[allow(non_snake_case)]
let (destinationType, destination) = match address {
SeraiAddress::Address(address) => (
abi::DestinationType::Address,
(abi::AddressDestination { destination: Address::from(address) }).abi_encode(),
),
SeraiAddress::Address(address) => {
(abi::DestinationType::Address, (Address::from(address)).abi_encode())
}
SeraiAddress::Contract(contract) => (
abi::DestinationType::Code,
(abi::CodeDestination {
gas_limit: contract.gas_limit(),
gasLimit: contract.gas_limit(),
code: contract.code().to_vec().into(),
})
.abi_encode(),
),
};
abi::OutInstruction { destinationType, destination: destination.into(), value: *amount }
abi::OutInstruction { destinationType, destination: destination.into(), amount: *amount }
})
.collect(),
)
@@ -271,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
@@ -297,38 +308,27 @@ impl Router {
}
/// Get the message to be signed in order to update the key for Serai.
pub fn update_serai_key_message(chain_id: U256, nonce: u64, key: &PublicKey) -> Vec<u8> {
(
"updateSeraiKey",
chain_id,
U256::try_from(nonce).expect("couldn't convert u64 to u256"),
key.eth_repr(),
)
pub fn update_serai_key_message(nonce: u64, key: &PublicKey) -> Vec<u8> {
("updateSeraiKey", U256::try_from(nonce).expect("couldn't convert u64 to u256"), key.eth_repr())
.abi_encode_packed()
}
/// 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(
chain_id: U256,
nonce: u64,
coin: Coin,
fee: U256,
outs: OutInstructions,
) -> Vec<u8> {
("execute", chain_id, U256::try_from(nonce).unwrap(), coin.address(), fee, outs.0).abi_encode()
pub fn execute_message(nonce: u64, coin: Coin, fee: U256, outs: OutInstructions) -> Vec<u8> {
("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.
@@ -542,7 +542,7 @@ impl Router {
nonce: log.nonce.try_into().map_err(|e| {
TransportErrorKind::Custom(format!("filtered to convert nonce to u64: {e:?}").into())
})?,
message_hash: log.message_hash.into(),
message_hash: log.messageHash.into(),
});
}
}
@@ -551,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")
}

View File

@@ -8,10 +8,9 @@ static ALLOCATOR: zalloc::ZeroizingAlloc<std::alloc::System> =
use std::sync::Arc;
use alloy_core::primitives::U256;
use alloy_simple_request_transport::SimpleRequest;
use alloy_rpc_client::ClientBuilder;
use alloy_provider::{Provider, RootProvider};
use alloy_provider::RootProvider;
use serai_client::validator_sets::primitives::Session;
@@ -63,20 +62,10 @@ async fn main() {
ClientBuilder::default().transport(SimpleRequest::new(bin::url()), true),
));
let chain_id = loop {
match provider.get_chain_id().await {
Ok(chain_id) => break U256::try_from(chain_id).unwrap(),
Err(e) => {
log::error!("couldn't connect to the Ethereum node for the chain ID: {e:?}");
tokio::time::sleep(core::time::Duration::from_secs(5)).await;
}
}
};
bin::main_loop::<SetInitialKey, _, KeyGenParams, _>(
db.clone(),
Rpc { db: db.clone(), provider: provider.clone() },
Scheduler::<bin::Db>::new(SmartContract { chain_id }),
Scheduler::<bin::Db>::new(SmartContract),
TransactionPublisher::new(db, provider, {
let relayer_hostname = env::var("ETHEREUM_RELAYER_HOSTNAME")
.expect("ethereum relayer hostname wasn't specified")

View File

@@ -140,7 +140,7 @@ impl SignatureMachine<Transaction> for ActionSignatureMachine {
self.machine.complete(shares).map(|signature| {
let s = signature.s;
let c = Signature::challenge(signature.R, &self.key, &self.action.message());
Transaction(self.action, Signature::new(c, s))
Transaction(self.action, Signature::new(c, s).unwrap())
})
}
}

View File

@@ -17,8 +17,8 @@ use crate::{output::OutputId, machine::ClonableTransctionMachine};
#[derive(Clone, PartialEq, Debug)]
pub(crate) enum Action {
SetKey { chain_id: U256, nonce: u64, key: PublicKey },
Batch { chain_id: U256, nonce: u64, coin: Coin, fee: U256, outs: Vec<(Address, U256)> },
SetKey { nonce: u64, key: PublicKey },
Batch { nonce: u64, coin: Coin, fee: U256, outs: Vec<(Address, U256)> },
}
#[derive(Clone, PartialEq, Eq, Debug)]
@@ -33,24 +33,16 @@ impl Action {
pub(crate) fn message(&self) -> Vec<u8> {
match self {
Action::SetKey { chain_id, nonce, key } => {
Router::update_serai_key_message(*chain_id, *nonce, key)
Action::SetKey { nonce, key } => Router::update_serai_key_message(*nonce, key),
Action::Batch { nonce, coin, fee, outs } => {
Router::execute_message(*nonce, *coin, *fee, OutInstructions::from(outs.as_ref()))
}
Action::Batch { chain_id, nonce, coin, fee, outs } => Router::execute_message(
*chain_id,
*nonce,
*coin,
*fee,
OutInstructions::from(outs.as_ref()),
),
}
}
pub(crate) fn eventuality(&self) -> Eventuality {
Eventuality(match self {
Self::SetKey { chain_id: _, nonce, key } => {
Executed::SetKey { nonce: *nonce, key: key.eth_repr() }
}
Self::SetKey { nonce, key } => Executed::SetKey { nonce: *nonce, key: key.eth_repr() },
Self::Batch { nonce, .. } => {
Executed::Batch { nonce: *nonce, message_hash: keccak256(self.message()) }
}
@@ -85,10 +77,6 @@ impl SignableTransaction for Action {
Err(io::Error::other("unrecognized Action type"))?;
}
let mut chain_id = [0; 32];
reader.read_exact(&mut chain_id)?;
let chain_id = U256::from_le_bytes(chain_id);
let mut nonce = [0; 8];
reader.read_exact(&mut nonce)?;
let nonce = u64::from_le_bytes(nonce);
@@ -100,7 +88,7 @@ impl SignableTransaction for Action {
let key =
PublicKey::from_eth_repr(key).ok_or_else(|| io::Error::other("invalid key in Action"))?;
Action::SetKey { chain_id, nonce, key }
Action::SetKey { nonce, key }
}
1 => {
let coin = Coin::read(reader)?;
@@ -123,22 +111,20 @@ impl SignableTransaction for Action {
outs.push((address, amount));
}
Action::Batch { chain_id, nonce, coin, fee, outs }
Action::Batch { nonce, coin, fee, outs }
}
_ => unreachable!(),
})
}
fn write(&self, writer: &mut impl io::Write) -> io::Result<()> {
match self {
Self::SetKey { chain_id, nonce, key } => {
Self::SetKey { nonce, key } => {
writer.write_all(&[0])?;
writer.write_all(&chain_id.as_le_bytes())?;
writer.write_all(&nonce.to_le_bytes())?;
writer.write_all(&key.eth_repr())
}
Self::Batch { chain_id, nonce, coin, fee, outs } => {
Self::Batch { nonce, coin, fee, outs } => {
writer.write_all(&[1])?;
writer.write_all(&chain_id.as_le_bytes())?;
writer.write_all(&nonce.to_le_bytes())?;
coin.write(writer)?;
writer.write_all(&fee.as_le_bytes())?;

View File

@@ -88,8 +88,8 @@ impl<D: Db> signers::TransactionPublisher<Transaction> for TransactionPublisher<
let nonce = tx.0.nonce();
// Convert from an Action (an internal representation of a signable event) to a TxLegacy
let tx = match tx.0 {
Action::SetKey { chain_id: _, nonce: _, key } => router.update_serai_key(&key, &tx.1),
Action::Batch { chain_id: _, nonce: _, coin, fee, outs } => {
Action::SetKey { nonce: _, key } => router.update_serai_key(&key, &tx.1),
Action::Batch { nonce: _, coin, fee, outs } => {
router.execute(coin, fee, OutInstructions::from(outs.as_ref()), &tx.1)
}
};

View File

@@ -36,9 +36,7 @@ fn balance_to_ethereum_amount(balance: Balance) -> U256 {
}
#[derive(Clone)]
pub(crate) struct SmartContract {
pub(crate) chain_id: U256,
}
pub(crate) struct SmartContract;
impl<D: Db> smart_contract_scheduler::SmartContract<Rpc<D>> for SmartContract {
type SignableTransaction = Action;
@@ -48,11 +46,8 @@ impl<D: Db> smart_contract_scheduler::SmartContract<Rpc<D>> for SmartContract {
_retiring_key: KeyFor<Rpc<D>>,
new_key: KeyFor<Rpc<D>>,
) -> (Self::SignableTransaction, EventualityFor<Rpc<D>>) {
let action = Action::SetKey {
chain_id: self.chain_id,
nonce,
key: PublicKey::new(new_key).expect("rotating to an invald key"),
};
let action =
Action::SetKey { nonce, key: PublicKey::new(new_key).expect("rotating to an invald key") };
(action.clone(), action.eventuality())
}
@@ -138,7 +133,6 @@ impl<D: Db> smart_contract_scheduler::SmartContract<Rpc<D>> for SmartContract {
}
res.push(Action::Batch {
chain_id: self.chain_id,
nonce,
coin: coin_to_ethereum_coin(coin),
fee: U256::try_from(total_gas).unwrap() * fee_per_gas,

View File

@@ -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 <lukeparker5132@gmail.com>"]
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 }

View File

@@ -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 <http://www.gnu.org/licenses/>.

View File

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

View File

@@ -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<SimpleRequest>, 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<SimpleRequest>,
tx: Signed<TxLegacy>,
) -> 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<SimpleRequest>,
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::<u8>::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<SimpleRequest>,
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()
}