Skip to content

Commit

Permalink
SIG: Allow eth to send to non-eth with regular signature (#773)
Browse files Browse the repository at this point in the history
  • Loading branch information
aakoshh authored Mar 20, 2024
1 parent ae750fe commit 476b472
Show file tree
Hide file tree
Showing 4 changed files with 234 additions and 6 deletions.
134 changes: 134 additions & 0 deletions fendermint/rpc/examples/transfer.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// Copyright 2022-2024 Protocol Labs
// SPDX-License-Identifier: Apache-2.0, MIT

//! Example of using the RPC library to send tokens from an f410 account to an f1 account.
//!
//! The example assumes that Tendermint and Fendermint have been started
//! and are running locally.
//!
//! # Usage
//! ```text
//! cargo run -p fendermint_rpc --release --example transfer -- --secret-key test-network/keys/eric.sk --verbose
//! ```
use std::path::PathBuf;

use anyhow::{anyhow, Context};
use clap::Parser;
use fendermint_rpc::query::QueryClient;
use fendermint_vm_actor_interface::eam::EthAddress;
use fendermint_vm_message::query::FvmQueryHeight;
use fvm_shared::address::Address;
use fvm_shared::chainid::ChainID;
use lazy_static::lazy_static;
use tendermint_rpc::Url;
use tracing::Level;

use fvm_shared::econ::TokenAmount;

use fendermint_rpc::client::FendermintClient;
use fendermint_rpc::message::{GasParams, SignedMessageFactory};
use fendermint_rpc::tx::{TxClient, TxCommit};

lazy_static! {
/// Default gas params based on the testkit.
static ref GAS_PARAMS: GasParams = GasParams {
gas_limit: 10_000_000_000,
gas_fee_cap: TokenAmount::default(),
gas_premium: TokenAmount::default(),
};
}

#[derive(Parser, Debug)]
pub struct Options {
/// The URL of the Tendermint node's RPC endpoint.
#[arg(
long,
short,
default_value = "http://127.0.0.1:26657",
env = "TENDERMINT_RPC_URL"
)]
pub url: Url,

/// Enable DEBUG logs.
#[arg(long, short)]
pub verbose: bool,

/// Path to the secret key to deploy with, expected to be in Base64 format,
/// and that it has a corresponding f410 account in genesis.
#[arg(long, short)]
pub secret_key: PathBuf,
}

impl Options {
pub fn log_level(&self) -> Level {
if self.verbose {
Level::DEBUG
} else {
Level::INFO
}
}
}

/// See the module docs for how to run.
#[tokio::main]
async fn main() {
let opts: Options = Options::parse();

tracing_subscriber::fmt()
.with_max_level(opts.log_level())
.init();

let client = FendermintClient::new_http(opts.url, None).expect("error creating client");

let sk =
SignedMessageFactory::read_secret_key(&opts.secret_key).expect("error reading secret key");

let pk = sk.public_key();

let f1_addr = Address::new_secp256k1(&pk.serialize()).expect("valid public key");
let f410_addr = Address::from(EthAddress::from(pk));

// Query the account nonce from the state, so it doesn't need to be passed as an arg.
let sn = sequence(&client, &f410_addr)
.await
.expect("error getting sequence");

// Query the chain ID, so it doesn't need to be passed as an arg.
let chain_id = client
.state_params(FvmQueryHeight::default())
.await
.expect("error getting state params")
.value
.chain_id;

let mf = SignedMessageFactory::new(sk, f410_addr, sn, ChainID::from(chain_id));

let mut client = client.bind(mf);

let res = TxClient::<TxCommit>::transfer(
&mut client,
f1_addr,
TokenAmount::from_whole(1),
GAS_PARAMS.clone(),
)
.await
.expect("transfer failed");

assert!(res.response.check_tx.code.is_ok(), "check is ok");
assert!(res.response.deliver_tx.code.is_ok(), "deliver is ok");
assert!(res.return_data.is_some());
}

/// Get the next sequence number (nonce) of an account.
async fn sequence(client: &impl QueryClient, addr: &Address) -> anyhow::Result<u64> {
let state = client
.actor_state(&addr, FvmQueryHeight::default())
.await
.context("failed to get actor state")?;

match state.value {
Some((_id, state)) => Ok(state.sequence),
None => Err(anyhow!("cannot find actor {addr}")),
}
}
11 changes: 11 additions & 0 deletions fendermint/testing/smoke-test/Makefile.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,12 @@ EOF
clear = true
dependencies = [
"simplecoin-example",
"transfer-example",
"ethers-example",
"query-blockhash-example",
]


[tasks.simplecoin-example]
script = """
cd ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/fendermint
Expand All @@ -43,6 +45,15 @@ cargo run -p fendermint_rpc --example simplecoin -- \
"""


[tasks.transfer-example]
script = """
cd ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/fendermint
cargo run -p fendermint_rpc --example transfer -- \
--secret-key testing/smoke-test/test-data/keys/eric.sk \
${VERBOSITY}
"""


[tasks.ethers-example]
script = """
cd ${CARGO_MAKE_WORKSPACE_WORKING_DIRECTORY}/fendermint
Expand Down
6 changes: 4 additions & 2 deletions fendermint/vm/interpreter/src/signed.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Copyright 2022-2024 Protocol Labs
// SPDX-License-Identifier: Apache-2.0, MIT
use anyhow::anyhow;
use anyhow::{anyhow, Context};
use async_trait::async_trait;

use fendermint_vm_core::chainid::HasChainID;
Expand Down Expand Up @@ -148,7 +148,9 @@ where
Ok((state, Err(InvalidSignature(s))))
}
Ok(()) => {
let domain_hash = msg.domain_hash(&chain_id)?;
let domain_hash = msg
.domain_hash(&chain_id)
.context("failed to compute domain hash")?;
let (state, ret) = self.inner.deliver(state, msg.into_message()).await?;
let ret = SignedMessageApplyRet {
fvm: ret,
Expand Down
89 changes: 85 additions & 4 deletions fendermint/vm/message/src/signed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ use cid::multihash::MultihashDigest;
use cid::Cid;
use ethers_core::types as et;
use ethers_core::types::transaction::eip2718::TypedTransaction;
use fendermint_crypto::SecretKey;
use fendermint_crypto::{PublicKey, SecretKey};
use fendermint_vm_actor_interface::eam::EthAddress;
use fendermint_vm_actor_interface::{eam, evm};
use fvm_ipld_encoding::tuple::{Deserialize_tuple, Serialize_tuple};
use fvm_shared::address::{Address, Payload};
use fvm_shared::chainid::ChainID;
use fvm_shared::crypto::signature::ops::recover_secp_public_key;
use fvm_shared::crypto::signature::{Signature, SignatureType, SECP_SIG_LEN};
use fvm_shared::message::Message;

Expand All @@ -24,6 +26,9 @@ enum Signable {
Ethereum((et::H256, et::H160)),
/// Bytes to be passed to the FVM Signature for hashing or verification.
Regular(Vec<u8>),
/// Same signature as `Regular` but using an Ethereum account hash as sender.
/// This is used if the recipient of the message is not an Ethereum account.
RegularFromEth((Vec<u8>, et::H160)),
}

#[derive(Error, Debug)]
Expand All @@ -32,7 +37,7 @@ pub enum SignedMessageError {
Ipld(#[from] fvm_ipld_encoding::Error),
#[error("invalid signature: {0}")]
InvalidSignature(String),
#[error("message cannot be converted to ethereum")]
#[error("message cannot be converted to ethereum: {0}")]
Ethereum(#[from] anyhow::Error),
}

Expand Down Expand Up @@ -77,6 +82,7 @@ impl SignedMessage {
let signature = match Self::signable(&message, chain_id)? {
Signable::Ethereum((hash, _)) => sign_eth(sk, hash),
Signable::Regular(data) => sign_regular(sk, &data),
Signable::RegularFromEth((data, _)) => sign_regular(sk, &data),
};
Ok(Self { message, signature })
}
Expand Down Expand Up @@ -104,14 +110,22 @@ impl SignedMessage {
// so at least for now they are easy to tell apart: any `f410` address is coming from Ethereum API and must have
// been signed according to the Ethereum scheme, and it could not have been signed by an `f1` address, it doesn't
// work with regular accounts.
//
// We detect the case where the recipient is not an ethereum address. If that is the case then use regular signing rules,
// which should allow messages from ethereum accounts to go to any other type of account, e.g. custom Wasm actors.
match maybe_eth_address(&message.from) {
Some(addr) => {
Some(addr) if is_eth_addr_compat(&message.to) => {
let tx: TypedTransaction = from_fvm::to_eth_transaction_request(message, chain_id)
.map_err(SignedMessageError::Ethereum)?
.into();

Ok(Signable::Ethereum((tx.sighash(), addr)))
}
Some(addr) => {
let mut data = Self::cid(message)?.to_bytes();
data.extend(chain_id_bytes(chain_id).iter());
Ok(Signable::RegularFromEth((data, addr)))
}
None => {
let mut data = Self::cid(message)?.to_bytes();
data.extend(chain_id_bytes(chain_id).iter());
Expand Down Expand Up @@ -149,6 +163,18 @@ impl SignedMessage {
.verify(&data, &message.from)
.map_err(SignedMessageError::InvalidSignature)
}
Signable::RegularFromEth((data, from)) => {
let rec = recover_secp256k1(signature, &data)
.map_err(SignedMessageError::InvalidSignature)?;

let rec_addr = EthAddress::from(rec);

if rec_addr.0 == from.0 {
Ok(())
} else {
Err(SignedMessageError::InvalidSignature("the Ethereum delegated address did not match the one recovered from the signature".to_string()))
}
}
}
}

Expand All @@ -157,7 +183,7 @@ impl SignedMessage {
&self,
chain_id: &ChainID,
) -> Result<Option<DomainHash>, SignedMessageError> {
if maybe_eth_address(&self.message.from).is_some() {
if is_eth_addr_deleg(&self.message.from) && is_eth_addr_compat(&self.message.to) {
let tx: TypedTransaction =
from_fvm::to_eth_transaction_request(self.message(), chain_id)
.map_err(SignedMessageError::Ethereum)?
Expand Down Expand Up @@ -245,6 +271,16 @@ fn maybe_eth_address(addr: &Address) -> Option<et::H160> {
}
}

/// Check if the address can be converted to an Ethereum one.
fn is_eth_addr_compat(addr: &Address) -> bool {
from_fvm::to_eth_address(addr).is_ok()
}

/// Check if the address is an Ethereum delegated one.
fn is_eth_addr_deleg(addr: &Address) -> bool {
maybe_eth_address(addr).is_some()
}

/// Verify that the method ID and the recipient are one of the allowed combination,
/// which for example is set by [from_eth::to_fvm_message].
///
Expand Down Expand Up @@ -282,6 +318,36 @@ pub fn sign_secp256k1(sk: &SecretKey, hash: &[u8; 32]) -> Signature {
}
}

/// Recover the public key from a Secp256k1
///
/// Based on how `Signature` does it, but without the final address hashing.
fn recover_secp256k1(signature: &Signature, data: &[u8]) -> Result<PublicKey, String> {
let signature = &signature.bytes;

if signature.len() != SECP_SIG_LEN {
return Err(format!(
"Invalid Secp256k1 signature length. Was {}, must be 65",
signature.len()
));
}

// blake2b 256 hash
let hash = blake2b_simd::Params::new()
.hash_length(32)
.to_state()
.update(data)
.finalize();

let mut sig = [0u8; SECP_SIG_LEN];
sig[..].copy_from_slice(signature);

let rec_key =
recover_secp_public_key(hash.as_bytes().try_into().expect("fixed array size"), &sig)
.map_err(|e| e.to_string())?;

Ok(rec_key)
}

/// Signed message with an invalid random signature.
#[cfg(feature = "arb")]
mod arb {
Expand Down Expand Up @@ -385,4 +451,19 @@ mod tests {
}
Ok(())
}

/// Check that we can send from an ethereum account to a non-ethereum one and sign it.
#[quickcheck]
fn eth_to_non_eth_sign_and_verify(msg: EthMessage, chain_id: u64, from: KeyPair, to: KeyPair) {
let chain_id = ChainID::from(chain_id);
let mut msg = msg.0;

msg.from = Address::from(EthAddress::from(from.pk));
msg.to = Address::new_secp256k1(&to.pk.serialize()).expect("f1 address");

let signed =
SignedMessage::new_secp256k1(msg, &from.sk, &chain_id).expect("message can be signed");

signed.verify(&chain_id).expect("signature should be valid")
}
}

0 comments on commit 476b472

Please sign in to comment.