mirror of
https://github.com/serai-dex/serai.git
synced 2025-12-12 22:19:26 +00:00
Compare commits
6 Commits
2aee21e507
...
6a520a7412
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a520a7412 | ||
|
|
b2ec58a445 | ||
|
|
8e800885fb | ||
|
|
2a427382f1 | ||
|
|
ce1689b325 | ||
|
|
0b61a75afc |
22
.github/workflows/lint.yml
vendored
22
.github/workflows/lint.yml
vendored
@@ -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
|
||||
|
||||
1
.github/workflows/tests.yml
vendored
1
.github/workflows/tests.yml
vendored
@@ -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
19
Cargo.lock
generated
@@ -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]]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
69
networks/ethereum/schnorr/src/tests/public_key.rs
Normal file
69
networks/ethereum/schnorr/src/tests/public_key.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
33
networks/ethereum/schnorr/src/tests/signature.rs
Normal file
33
networks/ethereum/schnorr/src/tests/signature.rs
Normal 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());
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
210
processor/ethereum/router/src/tests/mod.rs
Normal file
210
processor/ethereum/router/src/tests/mod.rs
Normal 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")
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())?;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
28
processor/ethereum/test-primitives/Cargo.toml
Normal file
28
processor/ethereum/test-primitives/Cargo.toml
Normal 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 }
|
||||
15
processor/ethereum/test-primitives/LICENSE
Normal file
15
processor/ethereum/test-primitives/LICENSE
Normal 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/>.
|
||||
5
processor/ethereum/test-primitives/README.md
Normal file
5
processor/ethereum/test-primitives/README.md
Normal 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.
|
||||
117
processor/ethereum/test-primitives/src/lib.rs
Normal file
117
processor/ethereum/test-primitives/src/lib.rs
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user