diff --git a/CHANGELOG.md b/CHANGELOG.md index 9dca070c0e..ed489ae5d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Implements the API for the `pallet-revive` host functions `chain_id`, `balance_of`, `base_fee`, `origin`, `code_size`, `block_hash`, `block_author` - [#2719](https://github.com/use-ink/ink/pull/2719) - Implement `From` for "ink-as-dependency" contract refs - [#2728](https://github.com/use-ink/ink/pull/2728) +- Added apis to e2e crate to interact with pallet assets, together with a contract in integration tests using the assets precompile and node as backend. - [#2709](https://github.com/use-ink/ink/pull/2709) ### Changed - Rename `ink_sandbox` crate to `ink_runtime`, `Sandbox` trait to `RuntimeEnv`, and `SandboxClient` to `RuntimeClient` for improved clarity. Also simplifies syntax for e2e tests, both runtime and node e2e tests. diff --git a/crates/e2e/src/lib.rs b/crates/e2e/src/lib.rs index 0e3b32a563..75daf6f443 100644 --- a/crates/e2e/src/lib.rs +++ b/crates/e2e/src/lib.rs @@ -27,6 +27,7 @@ mod contract_build; mod contract_results; mod error; pub mod events; +mod macros; mod node_proc; mod subxt_client; mod xts; @@ -63,6 +64,10 @@ pub use contract_results::{ }; pub use ink_e2e_macro::test; pub use ink_revive_types::evm::CallTrace; +pub use macros::{ + ContractEventReader, + assert_last_event_internal, +}; pub use node_proc::{ TestNodeProcess, TestNodeProcessBuilder, @@ -100,6 +105,7 @@ use ink_primitives::{ H256, types::AccountIdMapper, }; +use sp_core::crypto::AccountId32; pub use sp_weights::Weight; use std::{ cell::RefCell, @@ -137,50 +143,6 @@ pub fn log_error(msg: &str) { tracing::error!("[{}] {}", log_prefix(), msg); } -/// Get an ink! [`ink_primitives::AccountId`] for a given keyring account. -pub fn account_id(account: Sr25519Keyring) -> ink_primitives::AccountId { - ink_primitives::AccountId::try_from(account.to_account_id().as_ref()) - .expect("account keyring has a valid account id") -} - -/// Returns the [`ink::Address`] for a given keyring account. -/// -/// # Developer Note -/// -/// We take the `AccountId` and return only the first twenty bytes, this -/// is what `pallet-revive` does as well. -pub fn address(account: Sr25519Keyring) -> Address { - AccountIdMapper::to_address(account.to_account_id().as_ref()) -} - -/// Returns the [`ink::Address`] for a given account id. -/// -/// # Developer Note -/// -/// We take the `AccountId` and return only the first twenty bytes, this -/// is what `pallet-revive` does as well. -pub fn address_from_account_id>(account_id: AccountId) -> Address { - AccountIdMapper::to_address(account_id.as_ref()) -} - -/// Returns the [`ink::Address`] for a given `Keypair`. -/// -/// # Developer Note -/// -/// We take the `AccountId` and return only the first twenty bytes, this -/// is what `pallet-revive` does as well. -pub fn address_from_keypair + AsRef<[u8]>>( - keypair: &Keypair, -) -> Address { - let account_id: AccountId = keypair_to_account(keypair); - address_from_account_id(account_id) -} - -/// Transforms a `Keypair` into an account id. -pub fn keypair_to_account>(keypair: &Keypair) -> AccountId { - AccountId::from(keypair.public_key().0) -} - /// Creates a call builder for `Contract`, based on an account id. pub fn create_call_builder( acc_id: Address, @@ -207,9 +169,57 @@ where <::Type as FromAddr>::from_addr(acc_id) } -/// Extension trait for converting various types to Address (H160). +/// Trait for converting various types into an `AccountId`. +/// +/// This enables generic functions to accept multiple account representations +/// (e.g., `Keypair`, `AccountId32`, `ink_primitives::AccountId`) without +/// requiring callers to perform manual conversions. +/// +/// Implementations extract the underlying 32-byte public key and convert it +/// to the target `AccountId` type. +pub trait IntoAccountId { + /// Converts this type into the target account ID. + fn into_account_id(self) -> TargetAccountId; +} + +impl IntoAccountId for AccountId32 { + fn into_account_id(self) -> AccountId32 { + self + } +} + +impl IntoAccountId for &AccountId32 { + fn into_account_id(self) -> AccountId32 { + self.clone() + } +} + +impl IntoAccountId for &ink_primitives::AccountId +where + AccountId: From<[u8; 32]>, +{ + fn into_account_id(self) -> AccountId { + AccountId::from(*AsRef::<[u8; 32]>::as_ref(self)) + } +} + +impl IntoAccountId for &Keypair +where + AccountId: From<[u8; 32]>, +{ + fn into_account_id(self) -> AccountId { + AccountId::from(self.public_key().0) + } +} + +/// Trait for converting various types to an EVM-compatible `Address` (H160). +/// +/// The conversion uses [`AccountIdMapper::to_address`] which applies different +/// strategies based on the account type: +/// - Ethereum-derived accounts (last 12 bytes are `0xEE`): extracts the first 20 bytes +/// - Sr25519-derived accounts: computes keccak256 hash and takes the last 20 bytes pub trait IntoAddress { - /// Convert to an Address (H160). + /// Converts this type to an EVM-compatible address. fn address(&self) -> Address; } @@ -225,3 +235,9 @@ impl IntoAddress for ink_primitives::AccountId { AccountIdMapper::to_address(&bytes) } } + +impl IntoAddress for AccountId32 { + fn address(&self) -> Address { + AccountIdMapper::to_address(self.as_ref()) + } +} diff --git a/crates/e2e/src/macros.rs b/crates/e2e/src/macros.rs new file mode 100644 index 0000000000..59ef0811c9 --- /dev/null +++ b/crates/e2e/src/macros.rs @@ -0,0 +1,208 @@ +// Copyright (C) Use Ink (UK) Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Assertion helpers for ink! E2E tests. +//! +//! These macros provide convenient assertions similar to FRAME's testing macros, +//! adapted for contract call results. + +/// Assert that a contract call succeeded without reverting. +/// +/// This macro follows FRAME's `assert_ok!` convention for consistency across +/// the Polkadot ecosystem. It verifies that a contract call completed successfully +/// and did not revert. +/// +/// # Variants +/// +/// - `assert_ok!(result)` - Assert the call didn't revert +/// - `assert_ok!(result, expected)` - Assert the call didn't revert AND the return value +/// equals `expected` +/// +/// # Examples +/// +/// ```ignore +/// // Just assert success +/// let result = client.call(&alice, &contract_call.transfer(bob, amount)) +/// .submit() +/// .await?; +/// assert_ok!(result); +/// +/// // Assert success and check return value +/// let result = client.call(&alice, &contract_call.balance_of(bob)) +/// .dry_run() +/// .await?; +/// assert_ok!(result, expected_balance); +/// ``` +#[macro_export] +macro_rules! assert_ok { + ($result:expr $(,)?) => {{ + let result = $result; + if result.dry_run.did_revert() { + panic!( + "Expected call to succeed but it reverted.\nError: {:?}", + result.extract_error() + ); + } + result + }}; + ($result:expr, $expected:expr $(,)?) => {{ + let result = $result; + if result.dry_run.did_revert() { + panic!( + "Expected call to succeed but it reverted.\nError: {:?}", + result.extract_error() + ); + } + assert_eq!(result.return_value(), $expected, "Return value mismatch"); + result + }}; +} + +/// Assert that a contract call reverted with a specific error. +/// +/// This macro follows FRAME's `assert_noop!` convention, which stands for +/// "assert no operation" - meaning the call should fail without changing state. +/// Since reverted contract calls don't mutate state, this verifies the call +/// reverted with the expected error message. +/// +/// # Variants +/// +/// - `assert_noop!(result, expected_error)` - Assert the call reverted with an error +/// containing `expected_error` +/// +/// # Examples +/// +/// ```ignore +/// let result = client.call(&alice, &contract_call.transfer(bob, huge_amount)) +/// .submit() +/// .await?; +/// assert_noop!(result, "BalanceLow"); +/// ``` +#[macro_export] +macro_rules! assert_noop { + ($result:expr, $expected_error:expr $(,)?) => {{ + let result = $result; + if !result.dry_run.did_revert() { + panic!( + "Expected call to revert with '{}' but it succeeded.\nReturn value: {:?}", + $expected_error, + result.return_data() + ); + } + + let actual_error = result.extract_error(); + if actual_error != Some($expected_error.to_string()) { + panic!( + "Expected error '{}' but got {:?}", + $expected_error, actual_error + ); + } + result + }}; +} + +/// Assert that the last event from a contract call matches the expected event. +/// +/// This macro extracts events from the contract result and compares the last +/// emitted event with the expected event structure by comparing encoded bytes. +/// +/// # Examples +/// +/// ```ignore +/// let result = client.call(&alice, &contract_call.transfer(bob_address, amount)) +/// .submit() +/// .await?; +/// +/// assert_last_event!( +/// &result, +/// Transfer { +/// from: contract.addr, +/// to: bob_address, +/// value: amount +/// } +/// ); +/// ``` +#[macro_export] +macro_rules! assert_last_event { + ($result:expr, $expected_event:expr) => {{ $crate::assert_last_event_internal($result, $expected_event) }}; +} + +use crate::CallResult; +use ink_env::Environment; +use scale::{ + Decode, + Encode, +}; +use subxt::{ + blocks::ExtrinsicEvents, + config::HashFor, +}; + +/// A trait for types that can expose the last contract-emitted event for assertions. +#[allow(dead_code)] +pub trait ContractEventReader { + fn fetch_last_contract_event(self) -> Result, String>; +} + +impl<'a, E, V, C, Abi> ContractEventReader + for &'a CallResult, Abi> +where + E: Environment, + C: subxt::Config, + HashFor: Into, +{ + fn fetch_last_contract_event(self) -> Result, String> { + let events = self + .contract_emitted_events() + .map_err(|err| format!("failed to get contract events: {err:?}"))?; + + let last_event = events + .last() + .ok_or_else(|| "no contract events were emitted".to_string())?; + + Ok(last_event.event.data.clone()) + } +} + +/// Shared implementation that decodes the last contract event and compares it against the +/// expected value. +#[allow(dead_code)] +pub fn assert_last_event_internal(reader: R, expected_event: E) +where + R: ContractEventReader, + E: Decode + Encode + core::fmt::Debug, +{ + let last_event_data = reader + .fetch_last_contract_event() + .unwrap_or_else(|err| panic!("Contract event assertion failed: {err}")); + + let expected_bytes = expected_event.encode(); + + if expected_bytes != last_event_data { + let decoded_event = + E::decode(&mut &last_event_data[..]).unwrap_or_else(|error| { + panic!( + "failed to decode last contract event as {}: bytes={:?}, error={:?}", + core::any::type_name::(), + last_event_data, + error + ); + }); + + panic!( + "event mismatch!\nExpected: {:?}\nActual: {:?}", + expected_event, decoded_event + ); + } +} diff --git a/crates/e2e/src/subxt_client.rs b/crates/e2e/src/subxt_client.rs index 5f21e5201e..8d567531a4 100644 --- a/crates/e2e/src/subxt_client.rs +++ b/crates/e2e/src/subxt_client.rs @@ -36,6 +36,7 @@ use super::{ use crate::{ ContractsBackend, E2EBackend, + IntoAccountId, backend::{ BuilderClient, ChainBackend, @@ -113,7 +114,7 @@ where C: subxt::Config, E: Environment, { - api: ReviveApi, + pub(crate) api: ReviveApi, contracts: ContractsRegistry, url: String, } @@ -263,6 +264,11 @@ where &self.url } + /// Returns a reference to the subxt client for making RPC calls and storage queries. + pub fn subxt_client(&self) -> &subxt::OnlineClient { + &self.api.client + } + /// Derives the Ethereum address from a keypair. // copied from `pallet-revive` fn derive_keypair_address(&self, signer: &Keypair) -> H160 { @@ -323,6 +329,232 @@ where } } } + + /// Creates a new asset. + /// + /// # Arguments + /// * `asset_id` - ID of the new asset to be created. + /// * `admin` - The admin/owner of the created asset. + /// * `min_balance` - The minimum balance required for accounts. + pub async fn create( + &mut self, + asset_id: &u32, + admin: &Keypair, + min_balance: u128, + ) -> Result<(), Error> + where + C::AccountId: From<[u8; 32]>, + { + let admin_public_key: [u8; 32] = admin.public_key().0; + let admin_account_id: C::AccountId = C::AccountId::from(admin_public_key); + + let (tx_events, trace) = self + .api + .assets_create(admin, *asset_id, admin_account_id, min_balance) + .await; + + for evt in tx_events.iter() { + let evt = evt.unwrap_or_else(|err| { + panic!("unable to unwrap event: {err:?}"); + }); + + if is_extrinsic_failed_event(&evt) { + let metadata = self.api.client.metadata(); + let dispatch_error = + DispatchError::decode_from(evt.field_bytes(), metadata) + .map_err(|e| Error::Decoding(e.to_string()))?; + return Err(Error::CallExtrinsic(dispatch_error, trace)); + } + } + + Ok(()) + } + + /// Mints tokens into an account. + /// + /// Accepts both `&Keypair` and `&ink::primitives::AccountId` types (e.g., for + /// contract accounts). + /// + /// # Arguments + /// * `asset_id` - ID of the asset. + /// * `owner` - The account that created/administers the asset and signs the mint + /// extrinsic. + /// * `beneficiary` - The account to receive the minted tokens (Keypair or AccountId). + /// * `amount` - The number of tokens to mint. + pub async fn mint_into( + &mut self, + asset_id: &u32, + owner: &Keypair, + beneficiary: impl IntoAccountId, + amount: u128, + ) -> Result<(), Error> { + let beneficiary_account_id = beneficiary.into_account_id(); + self.mint_into_impl(*asset_id, owner, beneficiary_account_id, amount) + .await + } + + /// Internal implementation for minting tokens. + async fn mint_into_impl( + &mut self, + asset_id: u32, + owner: &Keypair, + beneficiary_account_id: C::AccountId, + amount: u128, + ) -> Result<(), Error> { + let (tx_events, trace) = self + .api + .assets_mint(owner, asset_id, beneficiary_account_id, amount) + .await; + + for evt in tx_events.iter() { + let evt = evt.unwrap_or_else(|err| { + panic!("unable to unwrap event: {err:?}"); + }); + + if is_extrinsic_failed_event(&evt) { + let metadata = self.api.client.metadata(); + let dispatch_error = + DispatchError::decode_from(evt.field_bytes(), metadata) + .map_err(|e| Error::Decoding(e.to_string()))?; + return Err(Error::CallExtrinsic(dispatch_error, trace)); + } + } + + Ok(()) + } + + /// Returns the balance of an account for a specific asset. + /// + /// Accepts both `&Keypair` and `&ink::primitives::AccountId` types (e.g., for + /// contract accounts). + /// + /// # Arguments + /// * `asset_id` - ID of the asset. + /// * `owner` - The account whose balance is being queried (Keypair or AccountId). + pub async fn balance_of( + &mut self, + asset_id: &u32, + owner: impl IntoAccountId, + ) -> u128 + where + C::AccountId: scale::Encode + AsRef<[u8]>, + { + let owner_account_id = owner.into_account_id(); + self.api + .balance_of_asset(*asset_id, &owner_account_id) + .await + } + + /// Approves a delegate to spend tokens on behalf of the owner. + /// + /// Accepts both `&Keypair` and `&ink::primitives::AccountId` types for the delegate + /// parameter. + /// + /// # Arguments + /// * `asset_id` - ID of the asset. + /// * `owner` - The keypair that owns the tokens. + /// * `delegate` - The account allowed to spend the tokens (Keypair or AccountId). + /// * `amount` - The number of tokens to approve. + pub async fn approve( + &mut self, + asset_id: &u32, + owner: &Keypair, + delegate: impl IntoAccountId, + amount: u128, + ) -> Result<(), Error> { + let delegate_account_id = delegate.into_account_id(); + + let (tx_events, trace) = self + .api + .assets_approve_transfer(owner, *asset_id, delegate_account_id, amount) + .await; + + for evt in tx_events.iter() { + let evt = evt.unwrap_or_else(|err| { + panic!("unable to unwrap event: {err:?}"); + }); + + if is_extrinsic_failed_event(&evt) { + let metadata = self.api.client.metadata(); + let dispatch_error = + DispatchError::decode_from(evt.field_bytes(), metadata) + .map_err(|e| Error::Decoding(e.to_string()))?; + return Err(Error::CallExtrinsic(dispatch_error, trace)); + } + } + + Ok(()) + } + + /// Returns the allowance for a delegate approved by an owner. + /// + /// Accepts both `&Keypair` and `&ink::primitives::AccountId` types for both owner and + /// delegate parameters. + /// + /// # Arguments + /// * `asset_id` - ID of the asset. + /// * `owner` - The account that owns the tokens (Keypair or AccountId). + /// * `delegate` - The account allowed to spend the tokens (Keypair or AccountId). + pub async fn allowance( + &mut self, + asset_id: &u32, + owner: impl IntoAccountId, + delegate: impl IntoAccountId, + ) -> u128 + where + C::AccountId: scale::Encode + AsRef<[u8]>, + { + let owner_account_id = owner.into_account_id(); + let delegate_account_id = delegate.into_account_id(); + + self.api + .allowance_asset(*asset_id, &owner_account_id, &delegate_account_id) + .await + } + + /// Transfers tokens from one account to another. + /// + /// # Arguments + /// * `asset_id` - ID of the asset. + /// * `origin` - The account transferring the tokens. + /// * `dest` - The account receiving the tokens. + /// * `amount` - The number of tokens to transfer. + pub async fn transfer( + &mut self, + asset_id: &u32, + origin: &Keypair, + dest: &E::AccountId, + amount: u128, + ) -> Result<(), Error> + where + E::AccountId: AsRef<[u8]>, + C::AccountId: From<[u8; 32]>, + { + let dest_account_id: C::AccountId = C::AccountId::from( + <[u8; 32]>::try_from(dest.as_ref()).expect("AccountId is 32 bytes"), + ); + + let (tx_events, trace) = self + .api + .assets_transfer(origin, *asset_id, dest_account_id, amount) + .await; + + for evt in tx_events.iter() { + let evt = evt.unwrap_or_else(|err| { + panic!("unable to unwrap event: {err:?}"); + }); + + if is_extrinsic_failed_event(&evt) { + let metadata = self.api.client.metadata(); + let dispatch_error = + DispatchError::decode_from(evt.field_bytes(), metadata) + .map_err(|e| Error::Decoding(e.to_string()))?; + return Err(Error::CallExtrinsic(dispatch_error, trace)); + } + } + + Ok(()) + } } /// Returns the fallback accountfor an `H160`. @@ -787,14 +1019,26 @@ where let dispatch_error = DispatchError::decode_from(evt.field_bytes(), metadata) .map_err(|e| Error::Decoding(e.to_string()))?; - log_error(&format!( - "Attempt to stringify returned data: {:?}", - String::from_utf8_lossy(&trace.clone().unwrap().output.0[..]) - )); - log_error(&format!( - "extrinsic for `raw_call` failed: {dispatch_error} {trace:?}" - )); - return Err(Error::CallExtrinsic(dispatch_error, trace)) + + // Check if this is a ContractReverted error from the Revive pallet + // Contract reverts should not be treated as errors, as they need to be + // captured in the CallResult for proper testing with assert_noop! + let error_string = format!("{dispatch_error}"); + let is_contract_revert = error_string.contains("Revive") + && error_string.contains("ContractReverted"); + + if !is_contract_revert { + log_error(&format!( + "Attempt to stringify returned data: {:?}", + String::from_utf8_lossy(&trace.clone().unwrap().output.0[..]) + )); + log_error(&format!( + "extrinsic for `raw_call` failed: {dispatch_error} {trace:?}" + )); + return Err(Error::CallExtrinsic(dispatch_error, trace)) + } + // If it's a contract revert, continue and let the trace be captured in + // CallResult } } @@ -1001,7 +1245,7 @@ where /// Returns `Err` if: /// - The value is not a [`Value::Composite`] with [`Composite::Named`] fields /// - The value does not contain a field with the given name. -fn get_composite_field_value<'a, T>( +pub(crate) fn get_composite_field_value<'a, T>( value: &'a Value, field_name: &str, ) -> Result<&'a Value, Error> { @@ -1021,7 +1265,9 @@ fn get_composite_field_value<'a, T>( } /// Returns true if the give event is System::Extrinsic failed. -fn is_extrinsic_failed_event(event: &EventDetails) -> bool { +pub(crate) fn is_extrinsic_failed_event( + event: &EventDetails, +) -> bool { event.pallet_name() == "System" && event.variant_name() == "ExtrinsicFailed" } diff --git a/crates/e2e/src/xts.rs b/crates/e2e/src/xts.rs index be0a6051f3..8ec8948d4b 100644 --- a/crates/e2e/src/xts.rs +++ b/crates/e2e/src/xts.rs @@ -147,6 +147,50 @@ pub struct Transfer { value: E::Balance, } +/// A raw call to `pallet-assets`'s `create`. +#[derive(Debug, scale::Decode, scale::Encode, scale_encode::EncodeAsType)] +#[encode_as_type(trait_bounds = "", crate_path = "subxt::ext::scale_encode")] +pub struct AssetsCreate { + #[codec(compact)] + id: u32, + admin: subxt::utils::Static, + #[codec(compact)] + min_balance: u128, +} + +/// A raw call to `pallet-assets`'s `mint`. +#[derive(Debug, scale::Decode, scale::Encode, scale_encode::EncodeAsType)] +#[encode_as_type(trait_bounds = "", crate_path = "subxt::ext::scale_encode")] +pub struct AssetsMint { + #[codec(compact)] + id: u32, + beneficiary: subxt::utils::Static, + #[codec(compact)] + amount: u128, +} + +/// A raw call to `pallet-assets`'s `approve_transfer`. +#[derive(Debug, scale::Decode, scale::Encode, scale_encode::EncodeAsType)] +#[encode_as_type(trait_bounds = "", crate_path = "subxt::ext::scale_encode")] +pub struct AssetsApproveTransfer { + #[codec(compact)] + id: u32, + delegate: subxt::utils::Static, + #[codec(compact)] + amount: u128, +} + +/// A raw call to `pallet-assets`'s `transfer`. +#[derive(Debug, scale::Decode, scale::Encode, scale_encode::EncodeAsType)] +#[encode_as_type(trait_bounds = "", crate_path = "subxt::ext::scale_encode")] +pub struct AssetsTransfer { + #[codec(compact)] + id: u32, + target: subxt::utils::Static, + #[codec(compact)] + amount: u128, +} + /// A raw call to `pallet-revive`'s `remove_code`. /// /// See . @@ -265,6 +309,94 @@ where Ok(()) } + /// Calls `pallet-assets`'s `create` extrinsic. + pub async fn assets_create( + &self, + origin: &Keypair, + id: u32, + admin: C::AccountId, + min_balance: u128, + ) -> (ExtrinsicEvents, Option) { + let call = subxt::tx::DefaultPayload::new( + "Assets", + "create", + AssetsCreate:: { + id, + admin: subxt::utils::Static(admin.into()), + min_balance, + }, + ) + .unvalidated(); + + self.submit_extrinsic(&call, origin).await + } + + /// Calls `pallet-assets`'s `mint` extrinsic. + pub async fn assets_mint( + &self, + origin: &Keypair, + id: u32, + beneficiary: C::AccountId, + amount: u128, + ) -> (ExtrinsicEvents, Option) { + let call = subxt::tx::DefaultPayload::new( + "Assets", + "mint", + AssetsMint:: { + id, + beneficiary: subxt::utils::Static(beneficiary.into()), + amount, + }, + ) + .unvalidated(); + + self.submit_extrinsic(&call, origin).await + } + + /// Calls `pallet-assets`'s `approve_transfer` extrinsic. + pub async fn assets_approve_transfer( + &self, + origin: &Keypair, + id: u32, + delegate: C::AccountId, + amount: u128, + ) -> (ExtrinsicEvents, Option) { + let call = subxt::tx::DefaultPayload::new( + "Assets", + "approve_transfer", + AssetsApproveTransfer:: { + id, + delegate: subxt::utils::Static(delegate.into()), + amount, + }, + ) + .unvalidated(); + + self.submit_extrinsic(&call, origin).await + } + + /// Calls `pallet-assets`'s `transfer` extrinsic. + pub async fn assets_transfer( + &self, + origin: &Keypair, + id: u32, + target: C::AccountId, + amount: u128, + ) -> (ExtrinsicEvents, Option) { + let call = subxt::tx::DefaultPayload::new( + "Assets", + "transfer", + AssetsTransfer:: { + id, + target: subxt::utils::Static(target.into()), + amount, + }, + ) + .unvalidated(); + + self.submit_extrinsic(&call, origin).await + } + /// Dry runs the instantiation of the given `code`. pub async fn instantiate_with_code_dry_run( &self, @@ -749,4 +881,118 @@ where let call = subxt::dynamic::tx(pallet_name, call_name, call_data); self.submit_extrinsic(&call, signer).await } + + /// Queries the balance of an account for a specific asset. + /// + /// # Arguments + /// * `asset_id` - ID of the asset. + /// * `owner` - The account whose balance is being queried. + pub async fn balance_of_asset(&self, asset_id: u32, owner: &C::AccountId) -> u128 + where + C::AccountId: scale::Encode + AsRef<[u8]>, + { + use subxt::dynamic::Value; + + // Query storage for the balance + let storage_address = subxt::dynamic::storage( + "Assets", + "Account", + vec![Value::u128(asset_id as u128), Value::from_bytes(owner)], + ); + + let best_block = self.best_block().await; + + let account_value = self + .client + .storage() + .at(best_block) + .fetch(&storage_address) + .await + .unwrap_or_else(|err| { + panic!("unable to fetch asset balance: {err:?}"); + }); + + // If account doesn't exist, return 0 + let account_value = match account_value { + Some(val) => val, + None => return 0, + }; + + let account = account_value.to_value().unwrap_or_else(|err| { + panic!("unable to decode asset account info: {err:?}"); + }); + + // The pallet-assets Account storage is: AssetAccount { balance, status, reason, + // ... } Extract the balance field + let balance_value = + match crate::subxt_client::get_composite_field_value(&account, "balance") { + Ok(val) => val, + Err(_) => return 0, + }; + + let balance = balance_value.as_u128().unwrap_or(0); + balance + } + + /// Queries the allowance for a delegate approved by an owner. + /// + /// # Arguments + /// * `asset_id` - ID of the asset. + /// * `owner` - The account that owns the tokens. + /// * `delegate` - The account that is allowed to spend the tokens. + pub async fn allowance_asset( + &self, + asset_id: u32, + owner: &C::AccountId, + delegate: &C::AccountId, + ) -> u128 + where + C::AccountId: scale::Encode + AsRef<[u8]>, + { + use subxt::dynamic::Value; + + // Query storage for the allowance + let allowance_query = subxt::dynamic::storage( + "Assets", + "Approvals", + vec![ + Value::u128(asset_id as u128), + Value::from_bytes(owner), + Value::from_bytes(delegate), + ], + ); + + let best_block = self.best_block().await; + + let approval_value = self + .client + .storage() + .at(best_block) + .fetch(&allowance_query) + .await + .unwrap_or_else(|err| { + panic!("unable to fetch asset allowance: {err:?}"); + }); + + // If approval doesn't exist, return 0 + let approval_value = match approval_value { + Some(val) => val, + None => return 0, + }; + + let approval = approval_value.to_value().unwrap_or_else(|err| { + panic!("unable to decode asset approval info: {err:?}"); + }); + + // The pallet-assets Approval storage is: Approval { amount, deposit } + // Extract the amount field + let amount_value = + match crate::subxt_client::get_composite_field_value(&approval, "amount") { + Ok(val) => val, + Err(_) => return 0, + }; + + let amount = amount_value.as_u128().unwrap_or(0); + amount + } } diff --git a/crates/runtime/Cargo.toml b/crates/runtime/Cargo.toml index f72fcc644d..bf9b7052b0 100644 --- a/crates/runtime/Cargo.toml +++ b/crates/runtime/Cargo.toml @@ -62,4 +62,4 @@ std = [ "sp-externalities/std", "sp-io/std", "ink_e2e_macro/std", -] +] \ No newline at end of file diff --git a/crates/runtime/src/client.rs b/crates/runtime/src/client.rs index c63d0a7f23..1e1e395151 100644 --- a/crates/runtime/src/client.rs +++ b/crates/runtime/src/client.rs @@ -49,6 +49,7 @@ use ink_e2e::{ CallBuilderFinal, CallDryRunResult, ChainBackend, + ContractEventReader, ContractExecResultFor, ContractResult, ContractsBackend, @@ -56,9 +57,9 @@ use ink_e2e::{ CreateBuilderPartial, E2EBackend, InstantiateDryRunResult, + IntoAccountId, UploadResult, constructor_exec_input, - keypair_to_account, log_error, salt, subxt::{ @@ -241,7 +242,7 @@ where self.runtime .runtime_call( decoded_call, - R::convert_account_to_origin(keypair_to_account(origin)), + R::convert_account_to_origin(origin.into_account_id()), ) .map_err(|err| { RuntimeErr::new(format!("runtime_call: execution error {:?}", err.error)) @@ -257,7 +258,7 @@ where dest: Self::AccountId, value: Self::Balance, ) -> Result<(), Self::Error> { - let caller = keypair_to_account(origin); + let caller = origin.into_account_id(); let origin = RawOrigin::Signed(caller); let origin = OriginFor::::from(origin); @@ -700,7 +701,7 @@ where &mut self, caller: &Keypair, ) -> Result, Self::Error> { - let caller_account: AccountIdFor = keypair_to_account(caller); + let caller_account: AccountIdFor = caller.into_account_id(); let origin = R::convert_account_to_origin(caller_account); self.runtime @@ -725,12 +726,7 @@ where ::RuntimeEvent: TryInto>, { - pub fn contract_events(&mut self) -> Vec> - where - R::Runtime: pallet_revive::Config, - ::RuntimeEvent: - TryInto>, - { + pub fn contract_events(&mut self) -> Vec> { self.runtime .events() .iter() @@ -751,16 +747,24 @@ where } /// Returns the last contract event that was emitted, if any. - pub fn last_contract_event(&mut self) -> Option> - where - R::Runtime: pallet_revive::Config, - ::RuntimeEvent: - TryInto>, - { + pub fn last_contract_event(&mut self) -> Option> { self.contract_events().last().cloned() } } +impl<'a, AccountId, R> ContractEventReader for &'a mut Client +where + R: RuntimeEnv, + R::Runtime: pallet_revive::Config, + ::RuntimeEvent: + TryInto>, +{ + fn fetch_last_contract_event(self) -> Result, String> { + self.last_contract_event() + .ok_or_else(|| "no contract events were emitted".to_string()) + } +} + /// Helper function for the `assert_last_contract_event!` macro. /// /// # Parameters: @@ -962,7 +966,7 @@ where R::Runtime: pallet_balances::Config + pallet_revive::Config, AccountIdFor: From<[u8; 32]> + AsRef<[u8; 32]>, { - let caller = keypair_to_account(caller); + let caller = caller.into_account_id(); let origin = RawOrigin::Signed(caller); OriginFor::::from(origin) } diff --git a/crates/runtime/src/lib.rs b/crates/runtime/src/lib.rs index 9abc3ae43d..631e3457a4 100644 --- a/crates/runtime/src/lib.rs +++ b/crates/runtime/src/lib.rs @@ -73,6 +73,15 @@ pub use client::{ pub use error::E2EError; pub use ink_e2e_macro::test; +// Re-export conversion traits and assertion macros from ink_e2e for unified API +pub use ink_e2e::{ + IntoAccountId, + IntoAddress, + assert_last_event, + assert_noop, + assert_ok, +}; + /// A snapshot of the storage. #[derive(Clone, Debug)] pub struct Snapshot { @@ -255,29 +264,3 @@ pub fn to_revive_storage_deposit( } } } - -/// Trait for types that can be converted into a runtime AccountId. -/// -/// This allows runtime APIs to accept various account types without requiring manual -/// conversion. -pub trait IntoAccountId { - fn into_account_id(self) -> AccountId; -} - -impl IntoAccountId for &AccountId32 { - fn into_account_id(self) -> AccountId32 { - self.clone() - } -} - -impl IntoAccountId for &ink_primitives::AccountId { - fn into_account_id(self) -> AccountId32 { - AccountId32::from(*AsRef::<[u8; 32]>::as_ref(self)) - } -} - -impl IntoAccountId for &ink_e2e::Keypair { - fn into_account_id(self) -> AccountId32 { - AccountId32::from(self.public_key().0) - } -} diff --git a/crates/runtime/src/macros.rs b/crates/runtime/src/macros.rs index df0714a260..70ed03af1f 100644 --- a/crates/runtime/src/macros.rs +++ b/crates/runtime/src/macros.rs @@ -13,134 +13,6 @@ use frame_support::{ use frame_system::pallet_prelude::BlockNumberFor; use sp_io::TestExternalities; -/// Asserts that a contract call succeeded without reverting. -/// -/// This macro follows FRAME's `assert_ok!` convention for consistency across -/// the Polkadot ecosystem. It verifies that a contract call completed successfully -/// and did not revert. If the call reverted, the macro panics with a detailed -/// error message extracted from the call trace. -/// -/// # Behavior -/// -/// - Takes a `CallResult` as input -/// - Checks if `dry_run.did_revert()` is `false` -/// - Panics with error details if the call reverted -/// - Returns the `CallResult` for further inspection if successful -/// -/// # Examples -/// -/// ```ignore -/// let result = client.call(&alice, &transfer).submit().await?; -/// assert_ok!(result); -/// ``` -#[macro_export] -macro_rules! assert_ok { - ($result:expr) => {{ - let result = $result; - if result.dry_run.did_revert() { - panic!( - "Expected call to succeed but it reverted.\nError: {:?}", - result.extract_error() - ); - } - result - }}; - ($result:expr, $($msg:tt)+) => {{ - let result = $result; - if result.dry_run.did_revert() { - panic!( - "{}\nExpected call to succeed but it reverted.\nError: {:?}", - format_args!($($msg)+), - result.extract_error() - ); - } - result - }}; -} - -/// Asserts that a contract call reverted with a specific error. -/// -/// This macro follows FRAME's `assert_noop!` convention, which stands for -/// "assert no operation" - meaning the call should fail without changing state. -/// It verifies that a contract call reverted and that the revert reason matches -/// the expected error string. -/// -/// # Behavior -/// -/// - Takes a `CallResult` and an expected error string as input -/// - Checks if `dry_run.did_revert()` is `true` -/// - Panics if the call succeeded (did not revert) -/// - Extracts the error from the call trace using `extract_error()` -/// - Panics if the actual error doesn't match the expected error -/// - Returns the `CallResult` if both checks pass -/// -/// # Examples -/// -/// ```ignore -/// let result = client.call(&alice, &insufficient_transfer).submit().await?; -/// assert_noop!(result, "BalanceLow"); -/// ``` -#[macro_export] -macro_rules! assert_noop { - ($result:expr, $expected_error:expr) => {{ - let result = $result; - if !result.dry_run.did_revert() { - panic!( - "Expected call to revert with '{}' but it succeeded", - $expected_error - ); - } - - let actual_error = result.extract_error(); - if actual_error != Some($expected_error.to_string()) { - panic!( - "Expected error '{}' but got {:?}", - $expected_error, - actual_error - ); - } - - result - }}; - ($result:expr, $expected_error:expr, $($msg:tt)+) => {{ - let result = $result; - if !result.dry_run.did_revert() { - panic!( - "{}\nExpected call to revert with '{}' but it succeeded", - format_args!($($msg)+), - $expected_error - ); - } - - let actual_error = result.extract_error(); - if actual_error != Some($expected_error.to_string()) { - panic!( - "{}\nExpected error '{}' but got {:?}", - format_args!($($msg)+), - $expected_error, - actual_error - ); - } - - result - }}; -} - -/// Asserts that the latest contract event matches an expected event. -/// -/// This macro verifies that the last emitted contract event from the runtime -/// matches the provided expected event. -/// -/// # Parameters -/// - `client` - Mutable reference to the runtime client -/// - `event` - The expected event -#[macro_export] -macro_rules! assert_last_event { - ($client:expr, $event:expr $(,)?) => { - $crate::client::assert_last_contract_event_inner($client, $event) - }; -} - /// A helper struct for initializing and finalizing blocks. pub struct BlockBuilder(std::marker::PhantomData); diff --git a/integration-tests/internal/misc-evm-getters-hostfns/lib.rs b/integration-tests/internal/misc-evm-getters-hostfns/lib.rs index 1e6b82ab33..a7e1b5bc5c 100644 --- a/integration-tests/internal/misc-evm-getters-hostfns/lib.rs +++ b/integration-tests/internal/misc-evm-getters-hostfns/lib.rs @@ -67,7 +67,7 @@ mod misc_evm_getters_hostfns { use super::*; use ink_e2e::{ ContractsBackend, - address_from_keypair, + IntoAddress, }; type E2EResult = std::result::Result>; @@ -181,7 +181,7 @@ mod misc_evm_getters_hostfns { assert_eq!( call_res.return_value(), - address_from_keypair::(&ink_e2e::alice()) + ink_e2e::alice().address() ); Ok(()) diff --git a/integration-tests/public/assets-precompile-node/Cargo.toml b/integration-tests/public/assets-precompile-node/Cargo.toml new file mode 100644 index 0000000000..46eea07d3e --- /dev/null +++ b/integration-tests/public/assets-precompile-node/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "assets_precompile_node" +version = "6.0.0-alpha.4" +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" } +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-node/README.md b/integration-tests/public/assets-precompile-node/README.md new file mode 100644 index 0000000000..5da767b7e3 --- /dev/null +++ b/integration-tests/public/assets-precompile-node/README.md @@ -0,0 +1,27 @@ +# Assets Precompile Integration (Node Backend) + +This contract demonstrates how to interact with ERC-20 asset precompiles in ink! using the `ink_precompiles` crate, tested with a full node backend. + +## Overview + +This example shows how to: +- Use the `ink_precompiles` crate for precompile interfaces +- Test precompile interactions using the **node backend** e2e test framework +- Work with `pallet-assets` through the ERC-20 precompile +- Set up accounts and assets for e2e tests with a real node +- Debug precompile errors with `extract_error()` + +## Performance Note + +These tests use the `node` backend, which spawns a fresh `ink-node` for each test. +This provides perfect test isolation but is slower than runtime-only tests. + +**Expected runtime**: ~5-7 seconds per test (~40 seconds total) + +### Speed Up During Development + +Run against a persistent node: +```bash +ink-node --dev --rpc-port 9944 +CONTRACTS_NODE_URL=ws://127.0.0.1:9944 cargo test --package assets_precompile_node --features e2e-tests +``` diff --git a/integration-tests/public/assets-precompile-node/lib.rs b/integration-tests/public/assets-precompile-node/lib.rs new file mode 100644 index 0000000000..be863a4433 --- /dev/null +++ b/integration-tests/public/assets-precompile-node/lib.rs @@ -0,0 +1,512 @@ +#![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 { + use super::*; + use ink::prelude::string::String; + use ink_precompiles::erc20::{ + Erc20, + Erc20Ref, + }; + + #[ink(storage)] + pub struct AssetHubPrecompile { + 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 AssetHubPrecompile { + /// 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(0x0120, 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)] + #[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)] + #[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::AssetHubPrecompile; + + let contract = AssetHubPrecompile::new(1337); + + assert_eq!(contract.asset_id(), 1337); + } + + #[test] + fn contract_stores_owner() { + use asset_hub_precompile::AssetHubPrecompile; + + let contract = AssetHubPrecompile::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])); + } +} + +#[cfg(all(test, feature = "e2e-tests"))] +mod e2e_tests { + use super::*; + use crate::asset_hub_precompile::{ + Approval, + AssetHubPrecompile, + AssetHubPrecompileRef, + Transfer, + }; + use ink_e2e::{ + BuilderClient, + ContractsBackend, + IntoAddress, + PolkadotConfig, + alice, + assert_last_event, + assert_noop, + assert_ok, + bob, + }; + + type E2EResult = Result>; + + #[allow(dead_code)] + type Client = ink_e2e::Client; + + #[ink_e2e::test] + async fn deployment_works(mut client: Client) -> E2EResult<()> { + let asset_id: u32 = 1; + let mut constructor = AssetHubPrecompileRef::new(asset_id); + + let contract = client + .instantiate("assets_precompile_node", &alice(), &mut constructor) + .value(1_000_000_000_000u128) // Transfer native tokens to contract + .submit() + .await?; + + let contract_call = contract.call_builder::(); + let result = client + .call(&alice(), &contract_call.asset_id()) + .dry_run() + .await?; + + assert_eq!(result.return_value(), asset_id); + + Ok(()) + } + + #[ink_e2e::test] + async fn total_supply_works(mut client: Client) -> E2EResult<()> { + let asset_id: u32 = 1; + let admin = alice(); + + client.create(&asset_id, &admin, 1u128).await?; + client + .mint_into(&asset_id, &admin, &admin, 1000u128) + .await?; + + let contract = client + .instantiate( + "assets_precompile_node", + &admin, + &mut AssetHubPrecompileRef::new(asset_id), + ) + .submit() + .await?; + + let contract_call = contract.call_builder::(); + let result = client + .call(&admin, &contract_call.total_supply()) + .submit() + .await?; + + assert_eq!(result.return_value(), U256::from(1000)); + Ok(()) + } + + #[ink_e2e::test] + async fn balance_of_works(mut client: Client) -> E2EResult<()> { + let asset_id: u32 = 1; + let alice = alice(); + let bob = bob(); + + client.create(&asset_id, &alice, 1u128).await?; + client + .mint_into(&asset_id, &alice, &alice, 1000u128) + .await?; + client.mint_into(&asset_id, &alice, &bob, 500u128).await?; + + let contract = client + .instantiate( + "assets_precompile_node", + &alice, + &mut AssetHubPrecompileRef::new(asset_id), + ) + .submit() + .await?; + + // Map bob's account otherwise it fails. + client.map_account(&bob).await?; + + let contract_call = contract.call_builder::(); + let alice_balance = client + .call(&alice, &contract_call.balance_of(alice.address())) + .dry_run() + .await?; + assert_eq!(alice_balance.return_value(), U256::from(1000)); + let bob_balance = client + .call(&alice, &contract_call.balance_of(bob.address())) + .dry_run() + .await?; + assert_eq!(bob_balance.return_value(), U256::from(500)); + + Ok(()) + } + + #[ink_e2e::test] + async fn transfer_works(mut client: Client) -> E2EResult<()> { + let asset_id: u32 = 1; + let alice = alice(); + let bob = bob(); + + client.create(&asset_id, &alice, 1u128).await?; + + let contract = client + .instantiate( + "assets_precompile_node", + &alice, + &mut AssetHubPrecompileRef::new(asset_id), + ) + .submit() + .await?; + + // Mint tokens directly to the contract + client + .mint_into(&asset_id, &alice, &contract.account_id, 100_000u128) + .await?; + client.map_account(&bob).await?; + + let mut contract_call = contract.call_builder::(); + let bob_address = bob.address(); + let transfer_amount = U256::from(1_000); + + let result = client + .call( + &alice, + &contract_call.transfer(bob_address, transfer_amount), + ) + .submit() + .await?; + assert_ok!(&result); + assert_last_event!( + &result, + Transfer { + from: contract.addr, + to: bob_address, + value: transfer_amount + } + ); + + let contract_balance = client.balance_of(&asset_id, &contract.account_id).await; + let bob_balance = client.balance_of(&asset_id, &bob).await; + assert_eq!(contract_balance, 99_000u128); + assert_eq!(bob_balance, 1_000u128); + + // Attempt to transfer more than the contract's balance, should fail + let result = client + .call( + &alice, + &contract_call.transfer(bob_address, U256::from(1_000_000)), + ) + .submit() + .await?; + assert_noop!(result, "BalanceLow"); + Ok(()) + } + + #[ink_e2e::test] + async fn approve_works(mut client: Client) -> E2EResult<()> { + let asset_id: u32 = 1; + let alice = alice(); + let bob = bob(); + + client.create(&asset_id, &alice, 1u128).await?; + + let contract = client + .instantiate("assets_precompile_node", &alice, &mut AssetHubPrecompileRef::new(asset_id)) + // Contract needs native balance for approval deposit (100_000_000). + .value(1_000_000_000) + .submit() + .await?; + + client + .mint_into(&asset_id, &alice, &contract.account_id, 100_000u128) + .await?; + client.map_account(&bob).await?; + + let bob_allowance_before = client + .allowance(&asset_id, &contract.account_id, &bob) + .await; + assert_eq!(bob_allowance_before, 0u128); + + let mut contract_call = contract.call_builder::(); + let bob_address = bob.address(); + let approve_amount = U256::from(200); + + let result = client + .call(&alice, &contract_call.approve(bob_address, approve_amount)) + .submit() + .await?; + assert_ok!(&result); + assert_last_event!( + &result, + Approval { + owner: contract.addr, + spender: bob_address, + value: approve_amount, + } + ); + + let bob_allowance = client + .allowance(&asset_id, &contract.account_id, &bob) + .await; + assert_eq!(bob_allowance, 200u128); + + Ok(()) + } + + #[ink_e2e::test] + async fn allowance_works(mut client: Client) -> E2EResult<()> { + let asset_id: u32 = 1; + let alice = alice(); + let bob = bob(); + + client.create(&asset_id, &alice, 1u128).await?; + + let contract = client + .instantiate( + "assets_precompile_node", + &alice, + &mut AssetHubPrecompileRef::new(asset_id), + ) + .submit() + .await?; + + let contract_call = contract.call_builder::(); + client + .mint_into(&asset_id, &alice, &alice, 100_000u128) + .await?; + client.map_account(&bob).await?; + + let allowance_call = &contract_call.allowance(alice.address(), bob.address()); + let result = client.call(&alice, allowance_call).dry_run().await?; + assert_eq!(result.return_value(), U256::from(0)); + + // Approve bob to spend alice's tokens + client.approve(&asset_id, &alice, &bob, 300u128).await?; + + let result = client.call(&alice, allowance_call).dry_run().await?; + assert_eq!(result.return_value(), U256::from(300)); + + Ok(()) + } + + #[ink_e2e::test] + async fn transfer_from_works(mut client: Client) -> E2EResult<()> { + let asset_id: u32 = 1; + let alice = alice(); + let bob = bob(); + + client.create(&asset_id, &alice, 1u128).await?; + + let contract = client + .instantiate( + "assets_precompile_node", + &alice, + &mut AssetHubPrecompileRef::new(asset_id), + ) + .submit() + .await?; + + // Use contract.account_id directly like in sandbox + client + .mint_into(&asset_id, &alice, &alice, 100_000u128) + .await?; + // Approve contract to spend alice's tokens + client + .approve(&asset_id, &alice, &contract.account_id, 50_000u128) + .await?; + client.map_account(&bob).await?; + + let mut contract_call = contract.call_builder::(); + let alice_address = alice.address(); + let bob_address = bob.address(); + let transfer_amount = U256::from(1_500); + let result = client + .call( + &alice, + &contract_call.transfer_from(alice_address, bob_address, transfer_amount), + ) + .submit() + .await?; + assert_ok!(&result); + assert_last_event!( + &result, + Transfer { + from: alice_address, + to: bob_address, + value: transfer_amount, + } + ); + + let alice_balance = client.balance_of(&asset_id, &alice).await; + let bob_balance = client.balance_of(&asset_id, &bob).await; + let contract_allowance = client + .allowance(&asset_id, &alice, &contract.account_id) + .await; + assert_eq!(alice_balance, 98_500u128); + assert_eq!(bob_balance, 1_500u128); + assert_eq!(contract_allowance, 48_500u128); + + let result = client + .call( + &alice, + &contract_call.transfer_from( + alice_address, + bob_address, + U256::from(1_000_000), + ), + ) + .submit() + .await?; + assert_noop!(result, "Unapproved"); + Ok(()) + } +} diff --git a/integration-tests/public/contract-transfer/lib.rs b/integration-tests/public/contract-transfer/lib.rs index bafdbbc25d..4a8176742a 100644 --- a/integration-tests/public/contract-transfer/lib.rs +++ b/integration-tests/public/contract-transfer/lib.rs @@ -185,6 +185,7 @@ pub mod give_me { use ink_e2e::{ ChainBackend, ContractsBackend, + IntoAddress, }; type E2EResult = std::result::Result>; @@ -266,7 +267,7 @@ pub mod give_me { assert_eq!(outgoing_trace.from, contract_addr); assert_eq!( outgoing_trace.to, - ink_e2e::address_from_keypair::(&ink_e2e::eve()) + ink_e2e::eve().address() ); let balance_after: Balance = client diff --git a/integration-tests/public/erc20/lib.rs b/integration-tests/public/erc20/lib.rs index fd41432384..07a2cc7315 100644 --- a/integration-tests/public/erc20/lib.rs +++ b/integration-tests/public/erc20/lib.rs @@ -531,7 +531,7 @@ mod erc20 { #[cfg(all(test, feature = "e2e-tests"))] mod e2e_tests { use super::*; - use ink_e2e::ContractsBackend; + use ink_e2e::{ContractsBackend, IntoAddress}; type E2EResult = std::result::Result>; @@ -554,9 +554,7 @@ mod erc20 { .dry_run() .await?; - let bob_account = ink_e2e::address::( - ink_e2e::Sr25519Keyring::Bob, - ); + let bob_account = ink_e2e::bob().address(); let transfer_to_bob = U256::from(500_000_000); let transfer = call_builder.transfer(bob_account, transfer_to_bob); let _transfer_res = client @@ -596,12 +594,8 @@ mod erc20 { // when - let bob_account = ink_e2e::address::( - ink_e2e::Sr25519Keyring::Bob, - ); - let charlie_account = ink_e2e::address::( - ink_e2e::Sr25519Keyring::Charlie, - ); + let bob_account = ink_e2e::bob().address(); + let charlie_account = ink_e2e::charlie().address(); let amount = U256::from(500_000_000); // tx