diff --git a/crates/e2e/src/lib.rs b/crates/e2e/src/lib.rs index 0e3b32a563..21f24977af 100644 --- a/crates/e2e/src/lib.rs +++ b/crates/e2e/src/lib.rs @@ -79,12 +79,58 @@ pub use subxt_client::{ }; pub use subxt_signer::{ self, - sr25519::{ - self, - Keypair, - dev::*, - }, }; + +/// Native sr25519 keypair type re-exported for convenience. +pub type Sr25519Keypair = subxt_signer::sr25519::Keypair; + +/// Native Ethereum keypair type re-exported for convenience. +pub type EthKeypair = eth::EthKeypair; + +/// Ethereum keypair types for use with pallet-revive. +/// +/// Using Ethereum keypairs is the recommended approach for interacting with +/// pallet-revive contracts. Unlike Substrate keypairs, Ethereum keypairs: +/// - Don't require explicit account mapping +/// - Have perfect address roundtrip (H160 → AccountId32 → H160) +/// - Work seamlessly with MetaMask and other Ethereum wallets +/// +/// # Example +/// ```ignore +/// use ink_e2e::eth::{self, dev::alith}; +/// +/// let keypair = alith(); +/// let address = keypair.address(); // Native H160 Ethereum address +/// ``` +pub mod eth { + pub use subxt_signer::eth::{ + Keypair as EthKeypair, + PublicKey as EthPublicKey, + Signature as EthSignature, + dev, + }; + + // Re-export common dev accounts at module level for convenience + pub use subxt_signer::eth::dev::{ + alith, + baltathar, + charleth, + dorothy, + ethan, + faith, + }; +} + +/// Re-export sr25519 signer types and dev accounts for callers that still need +/// direct access to the raw Substrate keys. +pub mod sr25519 { + pub use subxt_signer::sr25519::{ + Keypair, + PublicKey, + Signature, + dev, + }; +} pub use tokio; pub use tracing; pub use tracing_subscriber; @@ -105,6 +151,7 @@ use std::{ cell::RefCell, sync::Once, }; +use crate::sr25519::dev as sr25519_dev; use xts::ReviveApi; pub use subxt::PolkadotConfig; @@ -122,6 +169,130 @@ thread_local! { pub static LOG_PREFIX: RefCell = RefCell::new(String::from("no prefix set")); } +/// Unified keypair type that can represent either a Substrate sr25519 keypair +/// or an Ethereum ECDSA keypair. This lets e2e tests choose which signing scheme +/// to use while keeping the high-level API stable. +#[derive(Clone)] +pub enum Keypair { + Sr25519(Sr25519Keypair), + Eth(EthKeypair), +} + +impl Keypair { + /// Returns the AccountId32 bytes for this keypair. + /// - sr25519: raw public key bytes + /// - eth: fallback format `[H160][0xEE;12]` + pub fn account_id_bytes(&self) -> [u8; 32] { + match self { + Keypair::Sr25519(kp) => kp.public_key().0, + Keypair::Eth(kp) => { + let eth_address = kp.public_key().to_account_id(); + let mut account_bytes = [0xEE_u8; 32]; + account_bytes[..20].copy_from_slice(ð_address.0); + account_bytes + } + } + } + + pub fn is_eth(&self) -> bool { + matches!(self, Keypair::Eth(_)) + } + + pub fn as_sr25519(&self) -> Option<&Sr25519Keypair> { + match self { + Keypair::Sr25519(kp) => Some(kp), + _ => None, + } + } + + pub fn as_eth(&self) -> Option<&EthKeypair> { + match self { + Keypair::Eth(kp) => Some(kp), + _ => None, + } + } +} + +impl From for Keypair { + fn from(value: Sr25519Keypair) -> Self { + Keypair::Sr25519(value) + } +} + +impl From for Keypair { + fn from(value: EthKeypair) -> Self { + Keypair::Eth(value) + } +} + +/// Sr25519 dev accounts (Substrate keyring), wrapped into the unified `Keypair`. +pub fn alice() -> Keypair { + Keypair::from(sr25519_dev::alice()) +} +pub fn bob() -> Keypair { + Keypair::from(sr25519_dev::bob()) +} +pub fn charlie() -> Keypair { + Keypair::from(sr25519_dev::charlie()) +} +pub fn dave() -> Keypair { + Keypair::from(sr25519_dev::dave()) +} +pub fn eve() -> Keypair { + Keypair::from(sr25519_dev::eve()) +} +pub fn ferdie() -> Keypair { + Keypair::from(sr25519_dev::ferdie()) +} +pub fn one() -> Keypair { + Keypair::from(sr25519_dev::one()) +} +pub fn two() -> Keypair { + Keypair::from(sr25519_dev::two()) +} + +/// Ethereum dev accounts wrapped into the unified `Keypair`. +pub fn alith() -> Keypair { + Keypair::from(eth::dev::alith()) +} +pub fn baltathar() -> Keypair { + Keypair::from(eth::dev::baltathar()) +} +pub fn charleth() -> Keypair { + Keypair::from(eth::dev::charleth()) +} +pub fn dorothy() -> Keypair { + Keypair::from(eth::dev::dorothy()) +} +pub fn ethan() -> Keypair { + Keypair::from(eth::dev::ethan()) +} +pub fn faith() -> Keypair { + Keypair::from(eth::dev::faith()) +} + +/// Backwards-compatible dev module to mirror the old `subxt_signer::sr25519::dev` API +/// while returning the unified `Keypair` wrapper. Includes both sr25519 and Ethereum +/// dev accounts. +pub mod dev { + pub use crate::{ + alice, + alith, + baltathar, + bob, + charleth, + charlie, + dave, + dorothy, + ethan, + eve, + faith, + ferdie, + one, + two, + }; +} + /// Returns the name of the test which is currently executed. pub fn log_prefix() -> String { LOG_PREFIX.with(|log_prefix| log_prefix.borrow().clone()) @@ -178,7 +349,7 @@ pub fn address_from_keypair + AsRef<[u8]>>( /// Transforms a `Keypair` into an account id. pub fn keypair_to_account>(keypair: &Keypair) -> AccountId { - AccountId::from(keypair.public_key().0) + AccountId::from(keypair.account_id_bytes()) } /// Creates a call builder for `Contract`, based on an account id. @@ -215,7 +386,7 @@ pub trait IntoAddress { impl IntoAddress for Keypair { fn address(&self) -> Address { - AccountIdMapper::to_address(&self.public_key().0) + AccountIdMapper::to_address(&self.account_id_bytes()) } } @@ -225,3 +396,91 @@ impl IntoAddress for ink_primitives::AccountId { AccountIdMapper::to_address(&bytes) } } + +impl IntoAddress for eth::EthKeypair { + /// Returns the native Ethereum H160 address for this keypair. + /// + /// This is derived using the standard Ethereum method: + /// `keccak256(uncompressed_pubkey[1..65])[12..32]` + /// + /// Unlike Substrate keypairs, this address has a perfect roundtrip: + /// - H160 → AccountId32 (fallback with 0xEE suffix) → H160 (strips suffix) + fn address(&self) -> Address { + // eth::PublicKey::to_account_id() returns AccountId20 which is the H160 + // derived via keccak256(pubkey[1..65])[12..32] + let account_id_20 = self.public_key().to_account_id(); + Address::from(account_id_20.0) + } +} + +/// Trait for keypairs that can be used to sign transactions in e2e tests. +/// +/// This trait abstracts over both Sr25519 (Substrate) and ECDSA (Ethereum) keypairs, +/// allowing the e2e testing framework to work seamlessly with either. +/// +/// # Implementors +/// +/// - [`Keypair`] (Sr25519): Traditional Substrate keypairs from `subxt_signer::sr25519` +/// - [`eth::EthKeypair`] (ECDSA): Ethereum keypairs from `subxt_signer::eth` +/// +/// # Example +/// +/// ```ignore +/// use ink_e2e::{Signer, alice, eth::alith}; +/// +/// // Both Sr25519 and Ethereum keypairs can be used +/// let sr25519_account: [u8; 32] = alice().account_id(); +/// let eth_account: [u8; 32] = alith().account_id(); +/// ``` +pub trait Signer: Send + Sync { + /// Returns the 32-byte account ID for this keypair. + /// + /// For Sr25519 keypairs, this is the raw public key. + /// For Ethereum keypairs, this is the fallback format: `[H160][0xEE; 12]`. + fn account_id(&self) -> [u8; 32]; +} + +impl subxt::tx::Signer for Keypair +where + C: subxt::Config, + C::AccountId: From<[u8; 32]>, + C::Signature: From + From, +{ + fn account_id(&self) -> C::AccountId { + C::AccountId::from(self.account_id_bytes()) + } + + fn sign(&self, payload: &[u8]) -> C::Signature { + match self { + Keypair::Sr25519(kp) => kp.sign(payload).into(), + Keypair::Eth(kp) => kp.sign(payload).into(), + } + } +} + +impl Signer for Keypair { + fn account_id(&self) -> [u8; 32] { + self.account_id_bytes() + } +} + +impl Signer for Sr25519Keypair { + fn account_id(&self) -> [u8; 32] { + self.public_key().0 + } +} + +impl Signer for eth::EthKeypair { + /// Returns the fallback AccountId32 format for Ethereum keypairs. + /// + /// Format: `[H160 (20 bytes)][0xEE repeated 12 times]` + /// + /// This format is automatically recognized as "Ethereum-derived" by pallet-revive, + /// which means no explicit account mapping is required. + fn account_id(&self) -> [u8; 32] { + let eth_address = self.public_key().to_account_id(); + let mut account_bytes = [0xEE_u8; 32]; + account_bytes[..20].copy_from_slice(ð_address.0); + account_bytes + } +} diff --git a/crates/e2e/src/subxt_client.rs b/crates/e2e/src/subxt_client.rs index 5f21e5201e..79f34f2f39 100644 --- a/crates/e2e/src/subxt_client.rs +++ b/crates/e2e/src/subxt_client.rs @@ -25,6 +25,7 @@ use super::{ CreateBuilderPartial, constructor_exec_input, }, + eth, events::{ CodeStoredEvent, EventWithTopics, @@ -121,10 +122,8 @@ where impl Client where C: subxt::Config, - C::AccountId: - From + scale::Codec + serde::de::DeserializeOwned + Debug, - C::Address: From, - C::Signature: From, + C::AccountId: From<[u8; 32]> + scale::Codec + serde::de::DeserializeOwned + Debug, + C::Signature: From + From, >::Params: From< as ExtrinsicParams>::Params>, E: Environment, @@ -342,11 +341,10 @@ where + Sync + core::fmt::Display + scale::Codec - + From + + From<[u8; 32]> + serde::de::DeserializeOwned, - C::Address: From, - C::Signature: From, C::Address: Send + Sync, + C::Signature: From + From, >::Params: From< as ExtrinsicParams>::Params>, E: Environment, @@ -374,9 +372,11 @@ where ::generate_with_phrase(None); let phrase = subxt_signer::bip39::Mnemonic::parse(phrase).expect("valid phrase expected"); - let keypair = Keypair::from_phrase(&phrase, None).expect("valid phrase expected"); + let sr_kp = + sr25519::Keypair::from_phrase(&phrase, None).expect("valid phrase expected"); + let keypair = Keypair::from(sr_kp); let account_id = >::account_id(&keypair); - let origin_account_id = origin.public_key().to_account_id(); + let origin_account_id: C::AccountId = >::account_id(origin); self.api .transfer_allow_death(origin, account_id.clone(), amount) @@ -495,11 +495,9 @@ where + Sync + core::fmt::Display + scale::Codec - + From + From<[u8; 32]> + serde::de::DeserializeOwned, - C::Address: From, - C::Signature: From, + C::Signature: From + From, C::Address: Send + Sync, >::Params: From< as ExtrinsicParams>::Params>, @@ -880,6 +878,12 @@ where &mut self, caller: &Keypair, ) -> Result, Self::Error> { + if caller.is_eth() { + // Ethereum signers are already recognized via fallback format + // and don't need explicit mapping. + return Ok(None); + } + let addr = self.derive_keypair_address(caller); if self.fetch_original_account(&addr).await?.is_some() { return Ok(None); @@ -955,10 +959,9 @@ where + Sync + core::fmt::Display + scale::Codec - + From + + From<[u8; 32]> + serde::de::DeserializeOwned, - C::Address: From, - C::Signature: From, + C::Signature: From + From, C::Address: Send + Sync, E: Environment, E::AccountId: Debug + Send + Sync, @@ -979,11 +982,9 @@ where + Sync + core::fmt::Display + scale::Codec - + From + From<[u8; 32]> + serde::de::DeserializeOwned, - C::Address: From, - C::Signature: From, + C::Signature: From + From, C::Address: Send + Sync, >::Params: From< as ExtrinsicParams>::Params>, diff --git a/crates/e2e/src/xts.rs b/crates/e2e/src/xts.rs index be0a6051f3..723d6baf40 100644 --- a/crates/e2e/src/xts.rs +++ b/crates/e2e/src/xts.rs @@ -14,6 +14,7 @@ use super::{ Keypair, + eth, log_info, sr25519, }; @@ -221,9 +222,9 @@ pub struct ReviveApi { impl ReviveApi where C: subxt::Config, - C::AccountId: From + serde::de::DeserializeOwned + scale::Codec, - C::Address: From, - C::Signature: From, + C::AccountId: From<[u8; 32]> + serde::de::DeserializeOwned + scale::Codec, + C::Address: From, + C::Signature: From + From, >::Params: From< as ExtrinsicParams>::Params>, E: Environment, diff --git a/crates/sandbox/src/client.rs b/crates/sandbox/src/client.rs index 3fd8c71f71..6aac64fa7b 100644 --- a/crates/sandbox/src/client.rs +++ b/crates/sandbox/src/client.rs @@ -56,20 +56,31 @@ use ink_e2e::{ CreateBuilderPartial, E2EBackend, InstantiateDryRunResult, + Keypair, UploadResult, + alice, + alith, + baltathar, + bob, + charleth, + charlie, constructor_exec_input, + dave, + dorothy, + ethan, + eve, + faith, + ferdie, keypair_to_account, log_error, + one, salt, subxt::{ self, dynamic::Value, tx::Payload, }, - subxt_signer::sr25519::{ - Keypair, - dev, - }, + two, }; use ink_env::{ Environment, @@ -101,6 +112,7 @@ use std::{ marker::PhantomData, path::PathBuf, }; +use crate::subxt_signer::sr25519; type BalanceOf = ::Balance; type ContractsBalanceOf = @@ -146,16 +158,22 @@ where const TOKENS: u128 = 1_000_000_000_000_000; let accounts = [ - dev::alice(), - dev::bob(), - dev::charlie(), - dev::dave(), - dev::eve(), - dev::ferdie(), - dev::one(), - dev::two(), + alice(), + bob(), + charlie(), + dave(), + eve(), + ferdie(), + one(), + two(), + alith(), + baltathar(), + charleth(), + dorothy(), + ethan(), + faith(), ] - .map(|kp| kp.public_key().0) + .map(|kp| kp.account_id_bytes()) .map(From::from); for account in accounts.iter() { sandbox @@ -193,7 +211,9 @@ where .mint_into(&pair.public().0.into(), amount) .expect("Failed to mint tokens"); - Keypair::from_secret_key(seed).expect("Failed to create keypair") + let kp = + sr25519::Keypair::from_secret_key(seed).expect("Failed to create keypair"); + Keypair::from(kp) } async fn free_balance( @@ -699,6 +719,11 @@ where &mut self, caller: &Keypair, ) -> Result, Self::Error> { + if caller.is_eth() { + // Fallback AccountId32 format makes Ethereum keys auto-mapped. + return Ok(None); + } + let caller_account: AccountIdFor = keypair_to_account(caller); let origin = S::convert_account_to_origin(caller_account); diff --git a/crates/sandbox/src/lib.rs b/crates/sandbox/src/lib.rs index 56a7ca4a56..dd1cf995d3 100644 --- a/crates/sandbox/src/lib.rs +++ b/crates/sandbox/src/lib.rs @@ -49,6 +49,8 @@ pub use { }, }, frame_system, + // Re-export subxt_signer for Ethereum dev accounts in genesis + ink_e2e::subxt_signer, ink_precompiles, pallet_assets, pallet_assets_precompiles, @@ -278,6 +280,27 @@ impl IntoAccountId for &ink_primitives::AccountId { impl IntoAccountId for &ink_e2e::Keypair { fn into_account_id(self) -> AccountId32 { - AccountId32::from(self.public_key().0) + AccountId32::from(self.account_id_bytes()) + } +} + +impl IntoAccountId for &ink_e2e::eth::EthKeypair { + /// Converts an Ethereum keypair to an AccountId32 using the fallback format. + /// + /// The fallback format is: `[H160 (20 bytes)][0xEE repeated 12 times]` + /// + /// This format is automatically recognized as "Ethereum-derived" by pallet-revive's + /// `is_eth_derived()` function, which means: + /// - No explicit account mapping is required + /// - The address roundtrip is lossless: H160 → AccountId32 → H160 + fn into_account_id(self) -> AccountId32 { + // Get the native Ethereum H160 address + let eth_address = self.public_key().to_account_id(); + + // Create fallback AccountId32: [H160][0xEE; 12] + let mut account_bytes = [0xEE_u8; 32]; + account_bytes[..20].copy_from_slice(ð_address.0); + + AccountId32::from(account_bytes) } } diff --git a/crates/sandbox/src/macros.rs b/crates/sandbox/src/macros.rs index 4e82559eee..41737bed12 100644 --- a/crates/sandbox/src/macros.rs +++ b/crates/sandbox/src/macros.rs @@ -399,16 +399,36 @@ mod construct_runtime { pub const INITIAL_BALANCE: u128 = 1_000_000_000_000_000; pub const DEFAULT_ACCOUNT: AccountId32 = AccountId32::new([1u8; 32]); + /// Convert an Ethereum dev keypair to an AccountId32 using the fallback format. + /// Format: [H160 (20 bytes)][0xEE repeated 12 times] + fn eth_dev_account(keypair: &$crate::subxt_signer::eth::Keypair) -> AccountId32 { + let eth_address = keypair.public_key().to_account_id(); + let mut account_bytes = [0xEE_u8; 32]; + account_bytes[..20].copy_from_slice(ð_address.0); + AccountId32::from(account_bytes) + } + pub struct $sandbox { ext: $crate::TestExternalities, } impl ::std::default::Default for $sandbox { fn default() -> Self { - let ext = $crate::macros::BlockBuilder::<$runtime>::new_ext(vec![( - DEFAULT_ACCOUNT, - INITIAL_BALANCE, - )]); + use $crate::subxt_signer::eth::dev::{alith, baltathar, charleth, dorothy, ethan, faith}; + + // Fund both the default account and Ethereum dev accounts + let balances = vec![ + (DEFAULT_ACCOUNT, INITIAL_BALANCE), + // Ethereum dev accounts (fallback format: [H160][0xEE; 12]) + (eth_dev_account(&alith()), INITIAL_BALANCE), + (eth_dev_account(&baltathar()), INITIAL_BALANCE), + (eth_dev_account(&charleth()), INITIAL_BALANCE), + (eth_dev_account(&dorothy()), INITIAL_BALANCE), + (eth_dev_account(ðan()), INITIAL_BALANCE), + (eth_dev_account(&faith()), INITIAL_BALANCE), + ]; + + let ext = $crate::macros::BlockBuilder::<$runtime>::new_ext(balances); Self { ext } } } diff --git a/integration-tests/public/assets-precompile-eth/Cargo.toml b/integration-tests/public/assets-precompile-eth/Cargo.toml new file mode 100644 index 0000000000..eb03c68d1b --- /dev/null +++ b/integration-tests/public/assets-precompile-eth/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "assets_precompile_eth" +version = "6.0.0-beta.1" +authors = ["Use Ink "] +edition = "2024" +publish = false + +[dependencies] +ink = { path = "../../../crates/ink", default-features = false } +ink_precompiles = { path = "../../../crates/precompiles", default-features = false } + +[dev-dependencies] +ink_e2e = { path = "../../../crates/e2e" } +ink_sandbox = { path = "../../../crates/sandbox" } +hex = "0.4" + +[lib] +path = "lib.rs" + +[features] +default = ["std"] +std = [ + "ink/std", + "ink_precompiles/std", +] +ink-as-dependency = [] +e2e-tests = [] + +[package.metadata.ink-lang] +abi = "ink" + +[lints.rust.unexpected_cfgs] +level = "warn" +check-cfg = [ + 'cfg(ink_abi, values("ink", "sol", "all"))' +] diff --git a/integration-tests/public/assets-precompile-eth/README.md b/integration-tests/public/assets-precompile-eth/README.md new file mode 100644 index 0000000000..83081cd439 --- /dev/null +++ b/integration-tests/public/assets-precompile-eth/README.md @@ -0,0 +1,91 @@ +# Assets Precompile Integration Test (Ethereum-First Approach) + +This integration test demonstrates the **recommended approach** for interacting with pallet-revive contracts using Ethereum addresses as the default. + +## Why Ethereum Addresses? + +When using Substrate addresses (AccountId32) with pallet-revive, you encounter the "fallback account trap": + +``` +Substrate AccountId32 (Alice) + │ + ▼ keccak_256()[12..] +Derived H160 (0xabcd...) + │ + ▼ Contract stores this address + │ + ▼ Later: Contract needs to send tokens back + │ + ▼ to_account_id(0xabcd...) lookup + │ + ├─ IF MAPPED: Returns original AccountId32 ✓ + │ + └─ IF NOT MAPPED: Returns FALLBACK account ✗ + (0xabcd...EEEEEEEEEEEEEEEEEEEE) + Tokens are effectively lost! +``` + +## The Ethereum-First Solution + +By using Ethereum keypairs (ECDSA/secp256k1), the address roundtrip is **lossless**: + +``` +Ethereum H160 (0x1234...) + │ + ▼ to_account_id() +Fallback AccountId32 (0x1234...EEEEEEEEEEEEEEEEEEEE) + │ + ▼ to_address() checks is_eth_derived() + │ + ▼ TRUE → strips 0xEE suffix + │ +Original H160 (0x1234...) ✓ +``` + +## Key Differences from Standard Approach + +| Aspect | Substrate Approach | Ethereum Approach | +|--------|-------------------|-------------------| +| Keypair type | Sr25519 (`alice()`, `bob()`) | ECDSA (`alith()`, `baltathar()`) | +| Address type | Derived H160 | Native H160 | +| Mapping required | Yes (`map_account()`) | **No!** | +| Address roundtrip | Lossy without mapping | Lossless | +| MetaMask compatible | No | Yes | + +## Usage + +```rust +use ink_e2e::eth::{alith, baltathar}; + +// Use Ethereum keypairs +let alice = alith(); +let bob = baltathar(); + +// Get native H160 addresses +let alice_address = alice.address(); +let bob_address = bob.address(); + +// No mapping needed - just use them directly with contracts! +``` + +## Available Dev Accounts + +The following Ethereum dev accounts are available via `ink_e2e::eth::dev`: + +- `alith()` - Primary test account +- `baltathar()` - Secondary test account +- `charleth()` - Third test account +- `dorothy()` - Fourth test account +- `ethan()` - Fifth test account +- `faith()` - Sixth test account +- ... and more + +## Running the Tests + +All calls are signed with the Ethereum dev accounts via the new +`MultiSignature::Eth` support, so you don't need any Substrate keyring +accounts for this suite. + +```bash +cargo test -p assets_precompile_eth --features e2e-tests +``` diff --git a/integration-tests/public/assets-precompile-eth/lib.rs b/integration-tests/public/assets-precompile-eth/lib.rs new file mode 100644 index 0000000000..3c7f2b5165 --- /dev/null +++ b/integration-tests/public/assets-precompile-eth/lib.rs @@ -0,0 +1,599 @@ +//! # Assets Precompile Integration Test (Ethereum-First Approach) +//! +//! This integration test demonstrates the recommended approach for interacting with +//! pallet-revive contracts using **Ethereum addresses as the default**. +//! +//! ## Why Ethereum Addresses? +//! +//! When using Substrate addresses (AccountId32) with pallet-revive, you encounter +//! the "fallback account trap": +//! +//! 1. Substrate AccountId32 → H160 conversion uses `keccak256(AccountId32)[12..]` +//! 2. When converting back (H160 → AccountId32), if the account isn't explicitly mapped, +//! tokens go to a "fallback account" (`H160 + 0xEE padding`) +//! 3. This fallback account is NOT the original Substrate account! +//! +//! ## The Ethereum-First Solution +//! +//! By using Ethereum addresses (H160) directly: +//! +//! 1. The H160 address is the native Ethereum address +//! 2. AccountId32 uses the fallback format: `[H160][0xEE; 12]` +//! 3. `is_eth_derived()` returns TRUE → automatically "mapped" +//! 4. Perfect roundtrip: H160 → AccountId32 → H160 +//! +//! **No `map_account()` calls needed!** +//! +//! ## Note on Test Infrastructure +//! +//! The tests sign transactions with the Ethereum dev accounts via the +//! `MultiSignature::Eth` variant, so both the origin and the contract +//! parameters use native Ethereum addresses. + +#![cfg_attr(not(feature = "std"), no_std, no_main)] + +use ink::{ + H160, + U256, + prelude::string::ToString, +}; +pub use ink_precompiles::erc20::{ + AssetId, + erc20, +}; + +#[ink::contract] +mod asset_hub_precompile_eth { + use super::*; + use ink::prelude::string::String; + use ink_precompiles::erc20::{ + Erc20, + Erc20Ref, + }; + + #[ink(storage)] + pub struct AssetHubPrecompileEth { + asset_id: AssetId, + /// The owner of this contract. Only the owner can call transfer, approve, and + /// transfer_from. This is necessary because the contract holds tokens + /// and without access control, anyone could transfer tokens that the + /// contract holds, which would be a security issue. + owner: H160, + precompile: Erc20Ref, + } + + impl AssetHubPrecompileEth { + /// Creates a new contract instance for a specific asset ID. + #[ink(constructor, payable)] + pub fn new(asset_id: AssetId) -> Self { + Self { + asset_id, + owner: Self::env().caller(), + precompile: erc20(TRUST_BACKED_ASSETS_PRECOMPILE_INDEX, asset_id), + } + } + + /// Returns the asset ID this contract is configured for. + #[ink(message)] + pub fn asset_id(&self) -> AssetId { + self.asset_id + } + + /// Returns the owner of this contract. + #[ink(message)] + pub fn owner(&self) -> H160 { + self.owner + } + + /// Ensures only the owner can call this function. + fn ensure_owner(&self) -> Result<(), String> { + if self.env().caller() != self.owner { + return Err("Only owner can call this function".to_string()); + } + Ok(()) + } + + /// Gets the total supply by calling the precompile. + #[ink(message)] + pub fn total_supply(&self) -> U256 { + self.precompile.totalSupply() + } + + /// Gets the balance of an account. + #[ink(message)] + pub fn balance_of(&self, account: Address) -> U256 { + self.precompile.balanceOf(account) + } + + /// Transfers tokens to another account. + #[ink(message)] + pub fn transfer(&mut self, to: Address, value: U256) -> Result { + self.ensure_owner()?; + if !self.precompile.transfer(to, value) { + return Err("Transfer failed".to_string()); + } + self.env().emit_event(Transfer { + from: self.env().address(), + to, + value, + }); + Ok(true) + } + + /// Approves a spender. + #[ink(message)] + pub fn approve(&mut self, spender: Address, value: U256) -> Result { + self.ensure_owner()?; + if !self.precompile.approve(spender, value) { + return Err("Approval failed".to_string()); + } + self.env().emit_event(Approval { + owner: self.env().address(), + spender, + value, + }); + Ok(true) + } + + /// Gets the allowance for a spender. + #[ink(message)] + pub fn allowance(&self, owner: Address, spender: Address) -> U256 { + self.precompile.allowance(owner, spender) + } + + /// Transfers tokens from one account to another using allowance. + #[ink(message)] + pub fn transfer_from( + &mut self, + from: Address, + to: Address, + value: U256, + ) -> Result { + self.ensure_owner()?; + if !self.precompile.transferFrom(from, to, value) { + return Err("Transfer failed".to_string()); + } + self.env().emit_event(Transfer { from, to, value }); + Ok(true) + } + } + + /// Event emitted when allowance by `owner` to `spender` changes. + #[derive(Debug, PartialEq)] + #[ink::event] + pub struct Approval { + #[ink(topic)] + pub owner: Address, + #[ink(topic)] + pub spender: Address, + pub value: U256, + } + + /// Event emitted when transfer of tokens occurs. + #[derive(Debug, PartialEq)] + #[ink::event] + pub struct Transfer { + #[ink(topic)] + pub from: Address, + #[ink(topic)] + pub to: Address, + pub value: U256, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn contract_stores_asset_id() { + use asset_hub_precompile_eth::AssetHubPrecompileEth; + + let contract = AssetHubPrecompileEth::new(1337); + + assert_eq!(contract.asset_id(), 1337); + } + + #[test] + fn contract_stores_owner() { + use asset_hub_precompile_eth::AssetHubPrecompileEth; + + let contract = AssetHubPrecompileEth::new(1337); + + assert_eq!(contract.asset_id(), 1337); + // Note: In unit tests, the caller is always the zero address + assert_eq!(contract.owner(), H160::from([0u8; 20])); + } +} + +/// End-to-end tests demonstrating the Ethereum-first approach. +/// +/// ## Key Concepts Demonstrated +/// +/// 1. **Ethereum addresses for token recipients**: When sending tokens to users, we use +/// Ethereum H160 addresses directly. This ensures the tokens go to accounts that can +/// be controlled via MetaMask. +/// +/// 2. **Fallback account format**: Ethereum addresses are stored as AccountId32 using the +/// fallback format: `[H160][0xEE; 12]`. This format is automatically recognized as +/// "Ethereum-derived" by pallet-revive. +/// +/// 3. **No mapping required**: Unlike Substrate addresses, Ethereum addresses don't need +/// explicit mapping. The `is_eth_derived()` check passes automatically. +/// +/// ## Test Infrastructure Note +/// +/// Transactions are signed with Ethereum dev accounts using `MultiSignature::Eth`, +/// so both the origin and all contract parameters are native Ethereum addresses. +#[cfg(all(test, feature = "e2e-tests"))] +mod e2e_tests { + use super::*; + use crate::asset_hub_precompile_eth::{ + Approval, + AssetHubPrecompileEth, + AssetHubPrecompileEthRef, + Transfer, + }; + use ink_e2e::{ + ContractsBackend, + IntoAddress, + alith, + baltathar, + charleth, + }; + use ink_sandbox::{ + DefaultSandbox, + E2EError, + SandboxClient, + api::prelude::AssetsAPI, + assert_last_event, + assert_ok, + }; + + type E2EResult = std::result::Result; + + #[ink_sandbox::test(backend(runtime_only( + sandbox = DefaultSandbox, + client = SandboxClient + )))] + async fn deployment_works(mut client: Client) -> E2EResult<()> { + let asset_id: u32 = 1; + let mut constructor = AssetHubPrecompileEthRef::new(asset_id); + + let contract = client + .instantiate("assets_precompile_eth", &alith(), &mut constructor) + .value(1_000_000_000_000u128) + .submit() + .await?; + + let contract_call = contract.call_builder::(); + let result = client + .call(&alith(), &contract_call.asset_id()) + .dry_run() + .await?; + + assert_eq!(result.return_value(), asset_id); + + Ok(()) + } + + #[ink_sandbox::test(backend(runtime_only( + sandbox = DefaultSandbox, + client = SandboxClient + )))] + async fn total_supply_works(mut client: Client) -> E2EResult<()> { + let asset_id: u32 = 1; + + // Create and mint using Ethereum keypairs (works with IntoAccountId) + client.sandbox().create(&asset_id, &alith(), 1u128)?; + AssetsAPI::mint_into(client.sandbox(), &asset_id, &alith(), 1000u128)?; + + let contract = client + .instantiate( + "assets_precompile_eth", + &alith(), + &mut AssetHubPrecompileEthRef::new(asset_id), + ) + .submit() + .await?; + + let contract_call = contract.call_builder::(); + let result = client + .call(&alith(), &contract_call.total_supply()) + .submit() + .await?; + + assert_eq!(result.return_value(), U256::from(1000)); + Ok(()) + } + + /// This test demonstrates using Ethereum addresses for querying balances. + /// + /// Key point: We use `alith().address()` and `baltathar().address()` to get + /// the native Ethereum H160 addresses, which work correctly with the precompile. + #[ink_sandbox::test(backend(runtime_only( + sandbox = DefaultSandbox, + client = SandboxClient + )))] + async fn balance_of_with_eth_addresses( + mut client: Client, + ) -> E2EResult<()> { + let asset_id: u32 = 1; + + // Create asset with alith as admin + client.sandbox().create(&asset_id, &alith(), 1u128)?; + + // Mint to Ethereum addresses (uses IntoAccountId for EthKeypair) + AssetsAPI::mint_into(client.sandbox(), &asset_id, &alith(), 1000u128)?; + AssetsAPI::mint_into(client.sandbox(), &asset_id, &baltathar(), 500u128)?; + + let contract = client + .instantiate( + "assets_precompile_eth", + &alith(), + &mut AssetHubPrecompileEthRef::new(asset_id), + ) + .submit() + .await?; + + let contract_call = contract.call_builder::(); + + // Query balance using Ethereum addresses directly + // This is the key point: we use alith().address() which returns the native H160 + let alith_balance = client + .call(&alith(), &contract_call.balance_of(alith().address())) + .dry_run() + .await?; + assert_eq!(alith_balance.return_value(), U256::from(1000)); + + let baltathar_balance = client + .call(&alith(), &contract_call.balance_of(baltathar().address())) + .dry_run() + .await?; + assert_eq!(baltathar_balance.return_value(), U256::from(500)); + + Ok(()) + } + + /// Transfer test using Ethereum addresses for recipients. + /// + /// This demonstrates that tokens sent to Ethereum addresses can be + /// correctly tracked without explicit account mapping. + #[ink_sandbox::test(backend(runtime_only( + sandbox = DefaultSandbox, + client = SandboxClient + )))] + async fn transfer_to_eth_address( + mut client: Client, + ) -> E2EResult<()> { + let asset_id: u32 = 1; + + client.sandbox().create(&asset_id, &alith(), 1u128)?; + + let contract = client + .instantiate( + "assets_precompile_eth", + &alith(), + &mut AssetHubPrecompileEthRef::new(asset_id), + ) + .submit() + .await?; + + // Mint tokens to the contract + AssetsAPI::mint_into( + client.sandbox(), + &asset_id, + &contract.account_id, + 100_000u128, + )?; + + let mut contract_call = contract.call_builder::(); + + // Transfer to an Ethereum address (baltathar) + // The key point: baltathar().address() is a native H160 that will work + // correctly without mapping + let baltathar_addr = baltathar().address(); + let transfer_amount = U256::from(1_000); + + let result = client + .call( + &alith(), + &contract_call.transfer(baltathar_addr, transfer_amount), + ) + .submit() + .await?; + assert_ok!(result); + assert_last_event!( + &mut client, + Transfer { + from: contract.addr, + to: baltathar_addr, + value: transfer_amount + } + ); + + // Verify balances using sandbox (which uses IntoAccountId for EthKeypair) + let contract_balance = + client.sandbox().balance_of(&asset_id, &contract.account_id); + let baltathar_balance = client.sandbox().balance_of(&asset_id, &baltathar()); + assert_eq!(contract_balance, 99_000u128); + assert_eq!(baltathar_balance, 1_000u128); + + Ok(()) + } + + #[ink_sandbox::test(backend(runtime_only( + sandbox = DefaultSandbox, + client = SandboxClient + )))] + async fn approve_with_eth_address( + mut client: Client, + ) -> E2EResult<()> { + let asset_id: u32 = 1; + + client.sandbox().create(&asset_id, &alith(), 1u128)?; + + let contract = client + .instantiate( + "assets_precompile_eth", + &alith(), + &mut AssetHubPrecompileEthRef::new(asset_id), + ) + .value(100_000) + .submit() + .await?; + + AssetsAPI::mint_into( + client.sandbox(), + &asset_id, + &contract.account_id, + 100_000u128, + )?; + + // Check initial allowance is 0 + let baltathar_allowance_before = + client + .sandbox() + .allowance(&asset_id, &contract.account_id, &baltathar()); + assert_eq!(baltathar_allowance_before, 0u128); + + let mut contract_call = contract.call_builder::(); + + // Approve baltathar (using Ethereum address) + let baltathar_addr = baltathar().address(); + let approve_amount = U256::from(200); + + let result = client + .call( + &alith(), + &contract_call.approve(baltathar_addr, approve_amount), + ) + .submit() + .await?; + assert_ok!(result); + assert_last_event!( + &mut client, + Approval { + owner: contract.addr, + spender: baltathar_addr, + value: approve_amount, + } + ); + + // Verify allowance using sandbox + let baltathar_allowance = + client + .sandbox() + .allowance(&asset_id, &contract.account_id, &baltathar()); + assert_eq!(baltathar_allowance, 200u128); + + Ok(()) + } + + #[ink_sandbox::test(backend(runtime_only( + sandbox = DefaultSandbox, + client = SandboxClient + )))] + async fn allowance_query_with_eth_addresses( + mut client: Client, + ) -> E2EResult<()> { + let asset_id: u32 = 1; + + client.sandbox().create(&asset_id, &alith(), 1u128)?; + + let contract = client + .instantiate( + "assets_precompile_eth", + &alith(), + &mut AssetHubPrecompileEthRef::new(asset_id), + ) + .submit() + .await?; + + let contract_call = contract.call_builder::(); + AssetsAPI::mint_into(client.sandbox(), &asset_id, &alith(), 100_000u128)?; + + // Query allowance using Ethereum addresses + let allowance_call = + &contract_call.allowance(alith().address(), baltathar().address()); + let result = client.call(&alith(), allowance_call).dry_run().await?; + assert_eq!(result.return_value(), U256::from(0)); + + // Approve using sandbox (which uses IntoAccountId for EthKeypair) + client + .sandbox() + .approve(&asset_id, &alith(), &baltathar(), 300u128)?; + + let result = client.call(&alith(), allowance_call).dry_run().await?; + assert_eq!(result.return_value(), U256::from(300)); + + Ok(()) + } + + #[ink_sandbox::test(backend(runtime_only( + sandbox = DefaultSandbox, + client = SandboxClient + )))] + async fn transfer_from_with_eth_addresses( + mut client: Client, + ) -> E2EResult<()> { + let asset_id: u32 = 1; + + client.sandbox().create(&asset_id, &alith(), 1u128)?; + + let contract = client + .instantiate( + "assets_precompile_eth", + &alith(), + &mut AssetHubPrecompileEthRef::new(asset_id), + ) + .submit() + .await?; + + // Mint to charleth and approve the contract to spend + AssetsAPI::mint_into(client.sandbox(), &asset_id, &charleth(), 100_000u128)?; + client.sandbox().approve( + &asset_id, + &charleth(), + &contract.account_id, + 50_000u128, + )?; + + let mut contract_call = contract.call_builder::(); + + // Transfer from charleth to baltathar (both Ethereum addresses) + let charleth_addr = charleth().address(); + let baltathar_addr = baltathar().address(); + let transfer_amount = U256::from(1_500); + + let result = client + .call( + &alith(), + &contract_call.transfer_from(charleth_addr, baltathar_addr, transfer_amount), + ) + .submit() + .await?; + assert_ok!(result); + assert_last_event!( + &mut client, + Transfer { + from: charleth_addr, + to: baltathar_addr, + value: transfer_amount, + } + ); + + // Verify balances using sandbox + let charleth_balance = client.sandbox().balance_of(&asset_id, &charleth()); + let baltathar_balance = client.sandbox().balance_of(&asset_id, &baltathar()); + let contract_allowance = + client + .sandbox() + .allowance(&asset_id, &charleth(), &contract.account_id); + assert_eq!(charleth_balance, 98_500u128); + assert_eq!(baltathar_balance, 1_500u128); + assert_eq!(contract_allowance, 48_500u128); + + Ok(()) + } +}