From 4538f13d1af267e65f02d53cc44ecabd902cf21f Mon Sep 17 00:00:00 2001 From: Michael Zaikin Date: Tue, 11 Jun 2024 15:52:57 +0100 Subject: [PATCH] EVM: add FA deposit structure and helper methods --- etherlink/CHANGES_KERNEL.md | 4 +- etherlink/kernel_evm/Cargo.lock | 23 ++ etherlink/kernel_evm/Cargo.toml | 1 + etherlink/kernel_evm/evm_execution/Cargo.toml | 1 + .../evm_execution/src/fa_bridge/deposit.rs | 379 ++++++++++++++++++ .../evm_execution/src/fa_bridge/error.rs | 34 ++ .../evm_execution/src/fa_bridge/mod.rs | 2 + .../kernel_evm/evm_execution/src/utilities.rs | 17 +- 8 files changed, 459 insertions(+), 2 deletions(-) create mode 100644 etherlink/kernel_evm/evm_execution/src/fa_bridge/deposit.rs create mode 100644 etherlink/kernel_evm/evm_execution/src/fa_bridge/error.rs diff --git a/etherlink/CHANGES_KERNEL.md b/etherlink/CHANGES_KERNEL.md index 1ed54657a623..360765367c47 100644 --- a/etherlink/CHANGES_KERNEL.md +++ b/etherlink/CHANGES_KERNEL.md @@ -10,6 +10,9 @@ ## Internal +- Add FA deposit structure and helper methods for its parsing and formatting. (!13720) +- Add ticket table to account for FA deposits. (!12072) + ## Version ec7c3b349624896b269e179384d0a45cf39e1145 ### Features @@ -132,7 +135,6 @@ kernel. (!12046) - Da fee is sent to sequencer pool address. (!12113) - Gas price adjusts itself to handle congestion. (!12167) -- Add ticket table to account for FA deposits. (!12072) ### Bug fixes diff --git a/etherlink/kernel_evm/Cargo.lock b/etherlink/kernel_evm/Cargo.lock index 0a65e299b6df..1512081eab0a 100644 --- a/etherlink/kernel_evm/Cargo.lock +++ b/etherlink/kernel_evm/Cargo.lock @@ -338,6 +338,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "digest" version = "0.9.0" @@ -578,6 +584,7 @@ dependencies = [ "libsecp256k1", "num-bigint 0.3.3", "num-traits", + "pretty_assertions", "primitive-types", "proptest", "rand 0.8.5", @@ -1321,6 +1328,16 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" +[[package]] +name = "pretty_assertions" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af7cee1a6c8a5b9208b3cb1061f10c0cb689087b3d8ce85fb9d2dd7a29b6ba66" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "primitive-types" version = "0.12.1" @@ -2769,6 +2786,12 @@ dependencies = [ "rustix 0.38.28", ] +[[package]] +name = "yansi" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec" + [[package]] name = "zeroize" version = "1.6.0" diff --git a/etherlink/kernel_evm/Cargo.toml b/etherlink/kernel_evm/Cargo.toml index dabc09189fc8..ebb73676e952 100644 --- a/etherlink/kernel_evm/Cargo.toml +++ b/etherlink/kernel_evm/Cargo.toml @@ -72,3 +72,4 @@ tezos-smart-rollup-storage = { path = "../../src/kernel_sdk/storage" } # property based testing rand = { version = "0.8" } proptest = { version = "1.0" } +pretty_assertions = { version = "1.4.0" } diff --git a/etherlink/kernel_evm/evm_execution/Cargo.toml b/etherlink/kernel_evm/evm_execution/Cargo.toml index 12747925c852..677cdf348e64 100644 --- a/etherlink/kernel_evm/evm_execution/Cargo.toml +++ b/etherlink/kernel_evm/evm_execution/Cargo.toml @@ -54,6 +54,7 @@ proptest = { workspace = true, optional = true } [dev-dependencies] tezos-smart-rollup-mock.workspace = true +pretty_assertions.workspace = true [features] default = ["evm_execution"] diff --git a/etherlink/kernel_evm/evm_execution/src/fa_bridge/deposit.rs b/etherlink/kernel_evm/evm_execution/src/fa_bridge/deposit.rs new file mode 100644 index 000000000000..f74128616fc1 --- /dev/null +++ b/etherlink/kernel_evm/evm_execution/src/fa_bridge/deposit.rs @@ -0,0 +1,379 @@ +// SPDX-FileCopyrightText: 2023 PK Lab +// +// SPDX-License-Identifier: MIT + +//! FA token deposit. +//! +//! Represents a ticket transfer from L1 to L2 that has: +//! * Arbitrary ticketer (excluding whitelisted native token) +//! * Standard ticket content (FA2.1 compatible) +//! * Additional routing info parameter (raw bytes) +//! +//! It has several implicit constraints: +//! * Total token supply must fit into U256 +//! (losses are possible otherwise) +//! * Routing info must contain valid receiver address, user has access to +//! (otherwise funds are forever lost) +//! +//! User can optionally specify an address of a valid existing proxy contract, +//! which is typically an ERC wrapper contract. Any runtime errors related +//! to that contract will be handled and user will still be able to withdraw +//! funds. +//! +//! Given the permissionless nature of the bridge (anyone can bridge any token), +//! these constraints can only be checked on the client side. +//! +//! A special deposit event is emitted upon the successful transfer +//! (regardless of the inner proxy contract call result) and can be used for +//! indexing. + +use primitive_types::{H160, H256, U256}; +use rlp::{Encodable, RlpDecodable, RlpEncodable}; +use sha3::{Digest, Keccak256}; +use tezos_data_encoding::enc::BinWriter; +use tezos_ethereum::Log; +use tezos_smart_rollup_encoding::michelson::{ticket::FA2_1Ticket, MichelsonBytes}; + +use crate::utilities::{bigint_to_u256, keccak256_hash}; + +use super::error::FaBridgeError; + +/// Keccak256 of deposit(address,uint256,uint256), first 4 bytes +/// This is function selector: https://docs.soliditylang.org/en/latest/abi-spec.html#function-selector +pub const DEPOSIT_METHOD_ID: &[u8; 4] = b"\x0e\xfe\x6a\x8b"; + +/// Keccak256 of Deposit(uint256,address,address,uint256,uint256,uint256) +/// This is main topic (non-anonymous event): https://docs.soliditylang.org/en/latest/abi-spec.html#events +pub const DEPOSIT_EVENT_TOPIC: &[u8; 32] = b"\ + \x7e\xe7\xa1\xde\x9c\x18\xce\x69\x5c\x95\xb8\xb1\x9f\xbd\xf2\x6c\ + \xce\x35\x44\xe3\xca\x9e\x08\xc9\xf4\x87\x77\x67\x83\xd7\x59\x9f"; + +/// All arguments in ABI encoding are padded to 32 bytes +/// https://docs.soliditylang.org/en/develop/abi-spec.html#formal-specification-of-the-encoding +const ABI_H160_LEFT_PADDING: [u8; 12] = [0u8; 12]; +const ABI_U32_LEFT_PADDING: [u8; 28] = [0u8; 28]; + +/// Overapproximation for the typical FA ticket payload (ticketer address and content) +const TICKET_PAYLOAD_SIZE_HINT: usize = 200; + +/// Deposit structure parsed from the inbox message +#[derive(Debug, PartialEq, Clone, RlpEncodable, RlpDecodable)] +pub struct FaDeposit { + /// Original ticket transfer amount + pub amount: U256, + /// Final deposit receiver address on L2 + pub receiver: H160, + /// Optional proxy contract address on L2 (ERC wrapper) + pub proxy: Option, + /// Digest of the pair (ticketer address + ticket content) + pub ticket_hash: H256, + /// Inbox level containing the original deposit message + pub inbox_level: u32, + /// Inbox message id (can be used for tracking and as nonce) + pub inbox_msg_id: u32, +} + +impl FaDeposit { + /// Tries to parse FA deposit given encoded parameters + pub fn try_parse( + ticket: FA2_1Ticket, + routing_info: MichelsonBytes, + inbox_level: u32, + inbox_msg_id: u32, + ) -> Result { + let amount = bigint_to_u256(ticket.amount())?; + let (receiver, proxy) = parse_routing_info(routing_info)?; + let ticket_hash = ticket_hash(&ticket)?; + + Ok(FaDeposit { + amount, + receiver, + proxy, + ticket_hash, + inbox_level, + inbox_msg_id, + }) + } + + /// Returns calldata for the proxy (ERC wrapper) contract. + /// + /// Signature: deposit(address,uint256,uint256) + pub fn calldata(&self) -> Vec { + let mut call_data = Vec::with_capacity(100); + call_data.extend_from_slice(DEPOSIT_METHOD_ID); + + call_data.extend_from_slice(&ABI_H160_LEFT_PADDING); + call_data.extend_from_slice(&self.receiver.0); + debug_assert!((call_data.len() - 4) % 32 == 0); + + call_data.extend_from_slice(&Into::<[u8; 32]>::into(self.amount)); + debug_assert!((call_data.len() - 4) % 32 == 0); + + call_data.extend_from_slice(self.ticket_hash.as_bytes()); + debug_assert!((call_data.len() - 4) % 32 == 0); + + call_data + } + + /// Returns log structure for an implicit deposit event. + /// + /// This event is added to the outer transaction receipt, + /// so that we can index successful deposits and update status. + /// Ticket owner can be either proxy contract or receiver + /// (if proxy is not specified or proxy call failed). + /// + /// Signature: Deposit(uint256,address,address,uint256,uint256,uint256) + pub fn event_log(&self, ticket_owner: &H160) -> Log { + let mut data = Vec::with_capacity(5 * 32); + + data.extend_from_slice(&ABI_H160_LEFT_PADDING); + data.extend_from_slice(&ticket_owner.0); + debug_assert!(data.len() % 32 == 0); + + data.extend_from_slice(&ABI_H160_LEFT_PADDING); + data.extend_from_slice(&self.receiver.0); + debug_assert!(data.len() % 32 == 0); + + data.extend_from_slice(&Into::<[u8; 32]>::into(self.amount)); + debug_assert!(data.len() % 32 == 0); + + data.extend_from_slice(&ABI_U32_LEFT_PADDING); + data.extend_from_slice(&self.inbox_level.to_be_bytes()); + debug_assert!(data.len() % 32 == 0); + + data.extend_from_slice(&ABI_U32_LEFT_PADDING); + data.extend_from_slice(&self.inbox_msg_id.to_be_bytes()); + debug_assert!(data.len() % 32 == 0); + + Log { + // Emitted by the "system" contract + address: H160::zero(), + // Event ID (non-anonymous) and indexed fields + topics: vec![H256(*DEPOSIT_EVENT_TOPIC), self.ticket_hash], + // Non-indexed fields + data, + } + } + + /// Returns unique deposit digest that can be used as hash for the + /// pseudo transaction. + pub fn hash(&self, seed: &[u8]) -> H256 { + let mut hasher = Keccak256::new(); + hasher.update(&self.rlp_bytes()); + hasher.update(seed); + H256(hasher.finalize().into()) + } + + /// Formats FA deposit structure for logging purposes. + pub fn display(&self) -> String { + format!( + "FA deposit {} of {} for {} via {:?}", + self.amount, self.ticket_hash, self.receiver, self.proxy + ) + } +} + +/// Split routing info (raw bytes passed along with the ticket) into receiver and optional proxy addresses. +fn parse_routing_info( + routing_info: MichelsonBytes, +) -> Result<(H160, Option), FaBridgeError> { + if routing_info.0.len() == 20 { + Ok((H160::from_slice(&routing_info.0), None)) + } else if routing_info.0.len() == 40 { + Ok(( + H160::from_slice(&routing_info.0[..20]), + Some(H160::from_slice(&routing_info.0[20..])), + )) + } else { + Err(FaBridgeError::InvalidRoutingInfo("invalid length")) + } +} + +/// Calculate unique ticket hash out of the ticket identifier (ticketer address and content). +/// +/// Computed as Keccak256(ticketer || content) where +/// * ticketer: contract is in its forged form [ 0x01 | 20 bytes | 0x00 ] +/// * content: Micheline expression is in its forged form, legacy optimized mode +/// +/// Solidity equivalent: uint256(keccak256(abi.encodePacked(ticketer, content))); +pub fn ticket_hash(ticket: &FA2_1Ticket) -> Result { + let mut payload = Vec::with_capacity(TICKET_PAYLOAD_SIZE_HINT); + ticket.creator().0.bin_write(&mut payload)?; + ticket.contents().bin_write(&mut payload)?; + Ok(keccak256_hash(&payload)) +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use num_bigint::BigInt; + use sha3::{Digest, Keccak256}; + use tezos_crypto_rs::hash::ContractKt1Hash; + use tezos_smart_rollup_encoding::{ + contract::Contract, + michelson::{MichelsonNat, MichelsonOption, MichelsonPair}, + }; + + use super::*; + + fn create_fa_ticket( + ticketer: &str, + token_id: u64, + metadata: &[u8], + amount: BigInt, + ) -> FA2_1Ticket { + let creator = + Contract::Originated(ContractKt1Hash::from_base58_check(ticketer).unwrap()); + let contents = MichelsonPair( + MichelsonNat::new(BigInt::from(token_id).into()).unwrap(), + MichelsonOption(Some(MichelsonBytes(metadata.to_vec()))), + ); + FA2_1Ticket::new(creator, contents, amount).unwrap() + } + + #[test] + fn fa_deposit_parsing_success() { + let ticket = + create_fa_ticket("KT18amZmM5W7qDWVt2pH6uj7sCEd3kbzLrHT", 1, &[0u8], 2.into()); + let routing_info = MichelsonBytes([[1u8; 20], [0u8; 20]].concat().to_vec()); + let deposit = + FaDeposit::try_parse(ticket, routing_info, 1, 0).expect("Failed to parse"); + + pretty_assertions::assert_eq!( + deposit, + FaDeposit { + amount: 2.into(), + proxy: Some(H160([0u8; 20])), + receiver: H160([1u8; 20]), + inbox_level: 1, + inbox_msg_id: 0, + ticket_hash: H256::from_str( + "e0027297584c9e4162c872e072f1cc75b527023f9c0eda44ad4c732762b0b897" + ) + .unwrap(), + } + ) + } + + #[test] + fn fa_deposit_parsing_error_amount_overflow() { + let ticket = create_fa_ticket( + "KT1TxqZ8QtKvLu3V3JH7Gx58n7Co8pgtpQU5", + 1, + &[0u8], + BigInt::from_bytes_be(num_bigint::Sign::Plus, &[1u8; 64]), + ); + let routing_info = MichelsonBytes([0u8; 40].to_vec()); + + let res = FaDeposit::try_parse(ticket, routing_info, 1, 0); + + match res { + Err(FaBridgeError::PrimitiveType(_)) => (), + _ => panic!("Expected overflow error"), + } + } + + #[test] + fn fa_deposit_parsing_error_invalid_routing() { + let ticket = + create_fa_ticket("KT1TxqZ8QtKvLu3V3JH7Gx58n7Co8pgtpQU5", 1, &[0u8], 1.into()); + let routing_info = MichelsonBytes([0u8; 10].to_vec()); + + let res = FaDeposit::try_parse(ticket, routing_info, 1, 0); + + match res { + Err(FaBridgeError::InvalidRoutingInfo(_)) => (), + _ => panic!("Expected routing error"), + } + } + + #[test] + fn fa_deposit_routing_does_not_contain_proxy() { + let ticket = + create_fa_ticket("KT1TxqZ8QtKvLu3V3JH7Gx58n7Co8pgtpQU5", 1, &[0u8], 1.into()); + let routing_info = MichelsonBytes([0u8; 20].to_vec()); + + let res = FaDeposit::try_parse(ticket, routing_info, 1, 0).unwrap(); + assert_eq!(res.receiver, H160::zero()); + assert!(res.proxy.is_none()); + } + + #[test] + fn fa_deposit_erc_calldata_consistent() { + // Use this data to ensure consistency with the ERC wrapper contract + let deposit = FaDeposit { + amount: 1.into(), + proxy: Some(H160([2u8; 20])), + inbox_level: 3, + inbox_msg_id: 0, + receiver: H160([4u8; 20]), + ticket_hash: H256::from_str( + "12fb6647075cb9289e40af5560ce27a462ec2e49046b98298cdb41c9f128fb89", + ) + .unwrap(), + }; + + assert_eq!( + DEPOSIT_METHOD_ID.to_vec(), + Keccak256::digest(b"deposit(address,uint256,uint256)").to_vec()[..4] + ); + + pretty_assertions::assert_eq!( + hex::encode(deposit.calldata()), + "0efe6a8b\ + 0000000000000000000000000404040404040404040404040404040404040404\ + 0000000000000000000000000000000000000000000000000000000000000001\ + 12fb6647075cb9289e40af5560ce27a462ec2e49046b98298cdb41c9f128fb89" + ); + } + + #[test] + fn fa_deposit_event_log_consistent() { + // Use this data to ensure consistency with the ERC wrapper contract + let deposit = FaDeposit { + amount: 1.into(), + proxy: Some(H160([2u8; 20])), + inbox_level: 3, + inbox_msg_id: 43775, + receiver: H160([4u8; 20]), + ticket_hash: H256::from_str( + "0000000000000000000000000000000000000000000000000000000000000000", + ) + .unwrap(), + }; + let event_log = deposit.event_log(&deposit.proxy.unwrap()); + + pretty_assertions::assert_eq!( + hex::encode(&event_log.data), + "0000000000000000000000000202020202020202020202020202020202020202\ + 0000000000000000000000000404040404040404040404040404040404040404\ + 0000000000000000000000000000000000000000000000000000000000000001\ + 0000000000000000000000000000000000000000000000000000000000000003\ + 000000000000000000000000000000000000000000000000000000000000aaff" + ); + + assert_eq!( + DEPOSIT_EVENT_TOPIC.to_vec(), + Keccak256::digest( + b"Deposit(uint256,address,address,uint256,uint256,uint256)" + ) + .to_vec() + ); + assert!(event_log.address.is_zero()); + } + + #[test] + fn ticket_payload_size_overapproximation() { + let ticket = create_fa_ticket( + "KT1TxqZ8QtKvLu3V3JH7Gx58n7Co8pgtpQU5", + 1000000, + b"{\"contract_address\":\"KT1TxqZ8QtKvLu3V3JH7Gx58n7Co8pgtpQU5\",\"token_type\":\"FA2.0\",\"token_id\":\"1000000\",\"decimals\":\"12\",\"symbol\":\"USDQ\"}", + BigInt::from_bytes_be(num_bigint::Sign::Plus, &[1u8; 64]), + ); + let mut payload = Vec::new(); + ticket.creator().0.bin_write(&mut payload).unwrap(); + ticket.contents().bin_write(&mut payload).unwrap(); + assert!(payload.len() < TICKET_PAYLOAD_SIZE_HINT); + } +} diff --git a/etherlink/kernel_evm/evm_execution/src/fa_bridge/error.rs b/etherlink/kernel_evm/evm_execution/src/fa_bridge/error.rs new file mode 100644 index 000000000000..9819872bb3a8 --- /dev/null +++ b/etherlink/kernel_evm/evm_execution/src/fa_bridge/error.rs @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: 2023 PK Lab +// +// SPDX-License-Identifier: MIT + +//! FA bridge specific errors. + +use tezos_data_encoding::enc::BinError; + +#[derive(Debug, thiserror::Error)] +pub enum FaBridgeError { + #[error("Binary codec error: {0}")] + BinaryCodec(#[from] BinError), + + #[error("Primitive type error: {0:?}")] + PrimitiveType(primitive_types::Error), + + #[error("Invalid routing info")] + InvalidRoutingInfo(&'static str), + + #[error("Ticket parsing error: {0}")] + TicketParseError(&'static str), + + #[error("Entrypoint pasing error")] + EntrypointParseError, + + #[error("Abi decode error: {0}")] + AbiDecodeError(&'static str), +} + +impl From for FaBridgeError { + fn from(value: primitive_types::Error) -> Self { + Self::PrimitiveType(value) + } +} diff --git a/etherlink/kernel_evm/evm_execution/src/fa_bridge/mod.rs b/etherlink/kernel_evm/evm_execution/src/fa_bridge/mod.rs index 2bd058f32b77..0baa0e6081d9 100644 --- a/etherlink/kernel_evm/evm_execution/src/fa_bridge/mod.rs +++ b/etherlink/kernel_evm/evm_execution/src/fa_bridge/mod.rs @@ -31,4 +31,6 @@ //! using the transactional Eth account storage, so that they are discarded //! in case of a revert/failure. +pub mod deposit; +pub mod error; pub mod ticket_table; diff --git a/etherlink/kernel_evm/evm_execution/src/utilities.rs b/etherlink/kernel_evm/evm_execution/src/utilities.rs index 7e5fadd3c32c..3c14ecc4955a 100644 --- a/etherlink/kernel_evm/evm_execution/src/utilities.rs +++ b/etherlink/kernel_evm/evm_execution/src/utilities.rs @@ -6,7 +6,8 @@ use core::cmp::min; use alloc::vec::Vec; -use primitive_types::{H160, H256}; +use num_bigint::BigInt; +use primitive_types::{H160, H256, U256}; use sha3::{Digest, Keccak256}; /// Get an array from the data, if data does not contain `start` to `len` bytes, add right padding with @@ -55,3 +56,17 @@ pub fn create_address_legacy(caller: &H160, nonce: &u64) -> H160 { stream.append(nonce); H256::from_slice(Keccak256::digest(&stream.out()).as_slice()).into() } + +/// Compute Keccak 256 for some bytes +pub fn keccak256_hash(bytes: &[u8]) -> H256 { + H256(Keccak256::digest(bytes).into()) +} + +/// Try to cast BigInt to U256 +pub fn bigint_to_u256(value: &BigInt) -> Result { + let (_, bytes) = value.to_bytes_le(); + if bytes.len() > 32 { + return Err(primitive_types::Error::Overflow); + } + Ok(U256::from_little_endian(&bytes)) +} -- GitLab