diff --git a/CHANGELOG.md b/CHANGELOG.md index ca27953caa4..10a2cfa1d4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `ink_revive_types` (and remove `pallet-revive` dependency from `ink_e2e`) - [#2657](https://github.com/use-ink/ink/pull/2657) - non-allocating Solidity ABI encoder - [#2655](https://github.com/use-ink/ink/pull/2655) - Implement XCM precompile, stabilize XCM API - [#2687](https://github.com/use-ink/ink/pull/2687) +- Add `ink_precompiles` crate with ERC-20 assets precompile interface - [#2686](https://github.com/use-ink/ink/pull/2686) ### Changed - Marks the `pallet-revive` host function `account_id` stable - [#2578](https://github.com/use-ink/ink/pull/2578) diff --git a/Cargo.lock b/Cargo.lock index a2ba1d89af0..496c9837924 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3815,6 +3815,13 @@ dependencies = [ "serde", ] +[[package]] +name = "ink_precompiles" +version = "6.0.0-beta" +dependencies = [ + "ink", +] + [[package]] name = "ink_prelude" version = "6.0.0-beta" @@ -3908,9 +3915,12 @@ dependencies = [ "ink_e2e", "ink_e2e_macro", "ink_env", + "ink_precompiles", "ink_primitives 6.0.0-beta", "ink_revive_types", "jsonrpsee", + "pallet-assets", + "pallet-assets-precompiles", "pallet-balances", "pallet-revive", "pallet-timestamp", @@ -4760,6 +4770,33 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "pallet-assets" +version = "29.1.0" +source = "git+https://github.com/use-ink/polkadot-sdk.git?rev=cbab8ed4be1941420dd25dc81102fb79d8e2a7f0#cbab8ed4be1941420dd25dc81102fb79d8e2a7f0" +dependencies = [ + "frame-benchmarking", + "frame-support 28.0.0", + "frame-system", + "impl-trait-for-tuples", + "log", + "parity-scale-codec", + "scale-info", + "sp-core 28.0.0", + "sp-runtime 31.0.1", +] + +[[package]] +name = "pallet-assets-precompiles" +version = "0.1.0" +source = "git+https://github.com/use-ink/polkadot-sdk.git?rev=cbab8ed4be1941420dd25dc81102fb79d8e2a7f0#cbab8ed4be1941420dd25dc81102fb79d8e2a7f0" +dependencies = [ + "ethereum-standards", + "frame-support 28.0.0", + "pallet-assets", + "pallet-revive", +] + [[package]] name = "pallet-balances" version = "28.0.0" diff --git a/Cargo.toml b/Cargo.toml index c77ec29573a..669ef88c8e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "crates/ink/ir", "crates/ink/macro", "crates/metadata", + "crates/precompiles", "crates/revive-types", "crates/prelude", "crates/primitives", @@ -115,6 +116,7 @@ ink_env = { version = "=6.0.0-beta", path = "crates/env", default-features = fal ink_ir = { version = "=6.0.0-beta", path = "crates/ink/ir", default-features = false } ink_macro = { version = "=6.0.0-beta", path = "crates/ink/macro", default-features = false } ink_metadata = { version = "=6.0.0-beta", path = "crates/metadata", default-features = false } +ink_precompiles = { version = "=6.0.0-beta", path = "crates/precompiles", default-features = false } ink_prelude = { version = "=6.0.0-beta", path = "crates/prelude", default-features = false } ink_primitives = { version = "=6.0.0-beta", path = "crates/primitives", default-features = false } ink_revive_types = { version = "=6.0.0-beta", path = "crates/revive-types", default-features = false } diff --git a/crates/e2e/src/contract_results.rs b/crates/e2e/src/contract_results.rs index 8a070b1a74b..bf2d0c1f8b7 100644 --- a/crates/e2e/src/contract_results.rs +++ b/crates/e2e/src/contract_results.rs @@ -259,6 +259,31 @@ impl CallResult { pub fn return_data(&self) -> &[u8] { &self.dry_run.exec_return_value().data } + + /// Returns the error from nested contract calls (e.g., precompile errors) + /// if available in the trace, otherwise returns the raw error data. + pub fn extract_error(&self) -> Option { + if !self.dry_run.did_revert() { + return None; + } + + // Check trace for error information + if let Some(trace) = &self.trace { + // // Check nested calls first (more specific errors) + for call in &trace.calls { + if let Some(error) = &call.error { + return Some(error.clone()); + } + } + + // Then check top-level error + if let Some(error) = &trace.error { + return Some(error.clone()); + } + } + // Fallback to raw data + Some(format!("{:?}", self.return_data())) + } } // TODO(#xxx) Improve the `Debug` implementation. diff --git a/crates/e2e/src/lib.rs b/crates/e2e/src/lib.rs index 534ef5ca2e5..0e3b32a5635 100644 --- a/crates/e2e/src/lib.rs +++ b/crates/e2e/src/lib.rs @@ -206,3 +206,22 @@ where { <::Type as FromAddr>::from_addr(acc_id) } + +/// Extension trait for converting various types to Address (H160). +pub trait IntoAddress { + /// Convert to an Address (H160). + fn address(&self) -> Address; +} + +impl IntoAddress for Keypair { + fn address(&self) -> Address { + AccountIdMapper::to_address(&self.public_key().0) + } +} + +impl IntoAddress for ink_primitives::AccountId { + fn address(&self) -> Address { + let bytes = *AsRef::<[u8; 32]>::as_ref(self); + AccountIdMapper::to_address(&bytes) + } +} diff --git a/crates/ink/macro/src/contract_ref.rs b/crates/ink/macro/src/contract_ref.rs index 56265a40aaa..3e3bd6f04c6 100644 --- a/crates/ink/macro/src/contract_ref.rs +++ b/crates/ink/macro/src/contract_ref.rs @@ -68,7 +68,7 @@ pub fn analyze_or_err( #trait_def_impl // Type alias for contract ref. - type #contract_ref_name = + pub type #contract_ref_name = <<::ink::reflect::TraitDefinitionRegistry<#env> as #trait_name> ::__ink_TraitInfo as ::ink::codegen::TraitCallForwarder>::Forwarder<#abi_ty>; )) diff --git a/crates/precompiles/Cargo.toml b/crates/precompiles/Cargo.toml new file mode 100644 index 00000000000..5f9b5f83470 --- /dev/null +++ b/crates/precompiles/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "ink_precompiles" +version.workspace = true +authors = ["Use Ink "] +edition.workspace = true +license.workspace = true +description = "[ink!] Precompile interfaces for pallet-revive smart contracts." +repository.workspace = true +homepage.workspace = true +keywords.workspace = true +categories.workspace = true + +[dependencies] +ink = { workspace = true, default-features = false } + +[features] +default = ["std"] +std = [ + "ink/std", +] + diff --git a/crates/precompiles/src/erc20.rs b/crates/precompiles/src/erc20.rs new file mode 100644 index 00000000000..bd6a729103c --- /dev/null +++ b/crates/precompiles/src/erc20.rs @@ -0,0 +1,192 @@ +// 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. + +//! ERC-20 precompile interface for `pallet-assets`. +//! +//! This module provides the standard ERC-20 token interface for interacting with +//! assets managed by `pallet-assets` through the precompile mechanism. +//! +//! # References +//! +//! - [Polkadot SDK Assets Precompile](https://github.com/paritytech/polkadot-sdk/blob/master/substrate/frame/assets/src/precompiles.rs) +//! - [ERC-20 Token Standard](https://eips.ethereum.org/EIPS/eip-20) + +use ink::{ + Address, + U256, +}; + +/// ERC-20 Assets precompile index. +pub const PRECOMPILE_INDEX: u16 = 0x0120; + +/// Type alias for asset IDs. +pub type AssetId = u32; + +/// Defines the ERC-20 interface of the Asset Hub precompile. +#[ink::contract_ref(abi = "sol")] +pub trait Erc20 { + /// Returns the total supply of tokens. + /// + /// # Solidity Signature + /// + /// ```solidity + /// function totalSupply() external view returns (uint256); + /// ``` + #[ink(message)] + #[allow(non_snake_case)] + fn totalSupply(&self) -> U256; + + /// Returns the balance of an account. + /// + /// # Arguments + /// * `account` - The address to query the balance of + /// + /// # Solidity Signature + /// + /// ```solidity + /// function balanceOf(address account) external view returns (uint256); + /// ``` + #[ink(message)] + #[allow(non_snake_case)] + fn balanceOf(&self, account: Address) -> U256; + + /// Transfers tokens to another account. + /// + /// # Arguments + /// * `to` - The recipient address + /// * `value` - The amount of tokens to transfer + /// + /// # Returns + /// + /// Returns `true` if the transfer was successful. + /// + /// # Solidity Signature + /// + /// ```solidity + /// function transfer(address to, uint256 value) external returns (bool); + /// ``` + #[ink(message)] + fn transfer(&mut self, to: Address, value: U256) -> bool; + + /// Returns the allowance for a spender on behalf of an owner. + /// + /// This shows how many tokens `spender` is allowed to spend on behalf of `owner`. + /// + /// # Arguments + /// * `owner` - The token owner's address + /// * `spender` - The spender's address + /// + /// # Solidity Signature + /// + /// ```solidity + /// function allowance(address owner, address spender) external view returns (uint256); + /// ``` + #[ink(message)] + fn allowance(&self, owner: Address, spender: Address) -> U256; + + /// Approves a spender to spend tokens on behalf of the caller. + /// + /// # Arguments + /// * `spender` - The address authorized to spend tokens + /// * `value` - The maximum amount the spender can spend + /// + /// # Returns + /// + /// Returns `true` if the approval was successful. + /// + /// # Solidity Signature + /// + /// ```solidity + /// function approve(address spender, uint256 value) external returns (bool); + /// ``` + #[ink(message)] + fn approve(&mut self, spender: Address, value: U256) -> bool; + + /// Transfers tokens from one account to another using allowance. + /// + /// The caller must have sufficient allowance from the `from` account. + /// + /// # Arguments + /// * `from` - The address to transfer tokens from + /// * `to` - The recipient address + /// * `value` - The amount of tokens to transfer + /// + /// # Returns + /// + /// Returns `true` if the transfer was successful. + /// + /// # Solidity Signature + /// + /// ```solidity + /// function transferFrom(address from, address to, uint256 value) external returns (bool); + /// ``` + #[ink(message)] + #[allow(non_snake_case)] + fn transferFrom(&mut self, from: Address, to: Address, value: U256) -> bool; +} + +/// Creates a new ERC-20 precompile reference for the given asset ID. +/// +/// # Arguments +/// * `asset_id` - The ID of the asset to interact with +/// +/// # Returns +/// +/// Returns an `Erc20Ref` that can be used to call precompile methods. +/// +/// # Example +/// +/// ```rust,ignore +/// use ink_precompiles::erc20::erc20; +/// +/// let asset_id = 1; +/// let erc20_ref = erc20(asset_id); +/// let balance = erc20_ref.balanceOf(account); +/// ``` +pub fn erc20(asset_id: AssetId) -> Erc20Ref { + let address = crate::prefixed_address(PRECOMPILE_INDEX, asset_id); + address.into() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn erc20_precompile_address_format() { + // ERC20 Assets precompile for asset ID 1 should be at the correct address + let expected = [ + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x01, 0x20, 0x00, 0x00, + ]; + + let address = crate::prefixed_address(PRECOMPILE_INDEX, 1); + let address_bytes: [u8; 20] = address.into(); + + assert_eq!(address_bytes, expected); + } + + #[test] + fn erc20_precompile_address_for_multiple_assets() { + // Test asset ID 42 + let address_42 = crate::prefixed_address(PRECOMPILE_INDEX, 42); + let bytes_42: [u8; 20] = address_42.into(); + + // First 4 bytes should be asset ID (42 = 0x0000002a) + assert_eq!(&bytes_42[0..4], &[0x00, 0x00, 0x00, 0x2a]); + + // Bytes 16-19 should be precompile index (0x0120) + assert_eq!(&bytes_42[16..20], &[0x01, 0x20, 0x00, 0x00]); + } +} diff --git a/crates/precompiles/src/lib.rs b/crates/precompiles/src/lib.rs new file mode 100644 index 00000000000..dc9af353829 --- /dev/null +++ b/crates/precompiles/src/lib.rs @@ -0,0 +1,88 @@ +// 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. + +#![doc( + html_logo_url = "https://use.ink/img/crate-docs/logo.png", + html_favicon_url = "https://use.ink/crate-docs/favicon.png" +)] +#![cfg_attr(not(feature = "std"), no_std)] + +pub mod erc20; + +/// Calculates the address of a precompile at index `n`. +/// +/// This creates an address with the precompile index encoded in bytes 16-19 +/// as big-endian: `0x0000000000000000000000000000000000[nn]0000`. +/// +/// # Arguments +/// * `n` - The precompile index (e.g., `0x0120` for ERC20 Assets precompile) +#[inline] +pub fn fixed_address(n: u16) -> ink::Address { + let shifted = (n as u32) << 16; + + let suffix = shifted.to_be_bytes(); + let mut address = [0u8; 20]; + let mut i = 16; + while i < address.len() { + address[i] = suffix[i - 16]; + i = i + 1; + } + ink::Address::from(address) +} + +/// Calculates the address of a precompile at index `n` with an additional prefix. +/// +/// This is used for precompiles that encode additional information in the address, +/// such as the ERC20 Assets precompile which encodes the asset ID in bytes 0-3. +/// +/// The resulting address format is: `[prefix][...00000000000000][nn]0000` +/// where `prefix` occupies bytes 0-3 and `nn` is the precompile index in bytes 16-19. +/// +/// # Arguments +/// * `n` - The precompile index (e.g., `0x0120` for ERC20 Assets precompile) +/// * `prefix` - A 32-bit value to encode in the first 4 bytes (e.g., asset ID) +#[inline] +pub fn prefixed_address(n: u16, prefix: u32) -> ink::Address { + let address = fixed_address(n); + let mut address_bytes: [u8; 20] = address.into(); + address_bytes[..4].copy_from_slice(&prefix.to_be_bytes()); + ink::Address::from(address_bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fixed_address_works() { + let expected = [ + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x64, 0x00, 0x00, + ]; + let result = fixed_address(100); + let address_bytes: [u8; 20] = result.into(); + assert_eq!(address_bytes, expected); + } + + #[test] + fn prefixed_address_works() { + let expected = [ + 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x65, 0x00, 0x00, + ]; + let result = prefixed_address(101, u32::MAX); + let address_bytes: [u8; 20] = result.into(); + assert_eq!(address_bytes, expected); + } +} diff --git a/crates/sandbox/Cargo.toml b/crates/sandbox/Cargo.toml index 84880293460..02152e3863d 100644 --- a/crates/sandbox/Cargo.toml +++ b/crates/sandbox/Cargo.toml @@ -14,6 +14,8 @@ sha3 = "0.10.8" frame-metadata = { workspace = true } frame-system = { git = "https://github.com/use-ink/polkadot-sdk.git", rev = "cbab8ed4be1941420dd25dc81102fb79d8e2a7f0", default-features = false } frame-support = { git = "https://github.com/use-ink/polkadot-sdk.git", rev = "cbab8ed4be1941420dd25dc81102fb79d8e2a7f0", default-features = false } +pallet-assets = { git = "https://github.com/use-ink/polkadot-sdk.git", rev = "cbab8ed4be1941420dd25dc81102fb79d8e2a7f0", default-features = false } +pallet-assets-precompiles = { git = "https://github.com/use-ink/polkadot-sdk.git", rev = "cbab8ed4be1941420dd25dc81102fb79d8e2a7f0", default-features = false } pallet-balances = { git = "https://github.com/use-ink/polkadot-sdk.git", rev = "cbab8ed4be1941420dd25dc81102fb79d8e2a7f0", default-features = false } pallet-transaction-payment = { git = "https://github.com/use-ink/polkadot-sdk.git", rev = "cbab8ed4be1941420dd25dc81102fb79d8e2a7f0", default-features = false } pallet-revive = { git = "https://github.com/use-ink/polkadot-sdk.git", rev = "cbab8ed4be1941420dd25dc81102fb79d8e2a7f0", default-features = false } @@ -26,6 +28,7 @@ sp-io = { git = "https://github.com/use-ink/polkadot-sdk.git", rev = "cbab8ed4b ink_primitives = { workspace = true } ink_revive_types = { workspace = true } +ink_precompiles = { workspace = true } ink_env = { workspace = true, default-features = true } ink_e2e = { path = "../e2e" } ink_e2e_macro = { workspace = true, default-features = true } @@ -47,6 +50,8 @@ std = [ "frame-metadata/std", "ink_primitives/std", "ink_revive_types/std", + "pallet-assets/std", + "pallet-assets-precompiles/std", "pallet-balances/std", "pallet-transaction-payment/std", "pallet-revive/std", @@ -57,4 +62,4 @@ std = [ "sp-externalities/std", "sp-io/std", "ink_e2e_macro/std", -] +] \ No newline at end of file diff --git a/crates/sandbox/src/api.rs b/crates/sandbox/src/api.rs index 190d9ad8f87..5ce3d230471 100644 --- a/crates/sandbox/src/api.rs +++ b/crates/sandbox/src/api.rs @@ -1,3 +1,4 @@ +pub mod assets_api; pub mod balance_api; pub mod revive_api; pub mod system_api; @@ -5,6 +6,7 @@ pub mod timestamp_api; pub mod prelude { pub use super::{ + assets_api::AssetsAPI, balance_api::BalanceAPI, revive_api::ContractAPI, system_api::SystemAPI, diff --git a/crates/sandbox/src/api/assets_api.rs b/crates/sandbox/src/api/assets_api.rs new file mode 100644 index 00000000000..d198d89833a --- /dev/null +++ b/crates/sandbox/src/api/assets_api.rs @@ -0,0 +1,513 @@ +use crate::{ + AccountIdFor, + IntoAccountId, + Sandbox, +}; +use frame_support::{ + pallet_prelude::DispatchError, + traits::fungibles::{ + Create, + Inspect, + Mutate, + approvals::{ + Inspect as _, + Mutate as _, + }, + metadata::{ + Inspect as MetadataInspect, + Mutate as _, + }, + }, +}; + +type AssetIdOf = >::AssetId; +type AssetBalanceOf = >::Balance; + +/// Assets API for the sandbox. +/// +/// Provides methods to create, mint, and manage assets in `pallet-assets`. +pub trait AssetsAPI +where + T: Sandbox, + T::Runtime: pallet_assets::Config, + I: 'static, +{ + /// Creates `value` amount of tokens and assigns them to `account`, increasing the + /// total supply. + /// + /// # Arguments + /// * `id` - ID of the new asset to be created. + /// * `owner` - The owner of the created asset (accepts any type convertible to + /// AccountId). + /// * `min_balance` - The asset amount one account need at least. + fn create( + &mut self, + id: &AssetIdOf, + owner: impl IntoAccountId>, + min_balance: AssetBalanceOf, + ) -> Result<(), DispatchError>; + + /// Sets the metadata for an existing fungible asset. + /// + /// # Arguments + /// * `asset` - ID of the asset. + /// * `owner` - The owner of the asset (accepts any type convertible to AccountId). + /// * `name` - Token name. + /// * `symbol` - Token symbol. + /// * `decimals` - Token decimals. + fn set_metadata( + &mut self, + asset: &AssetIdOf, + owner: impl IntoAccountId>, + name: Vec, + symbol: Vec, + decimals: u8, + ) -> Result<(), DispatchError>; + + /// Returns the metadata for an asset. + /// + /// # Arguments + /// * `asset` - ID of the asset. + /// + /// # Returns + /// A tuple of (name, symbol, decimals). + fn metadata(&mut self, asset: &AssetIdOf) -> (Vec, Vec, u8); + + /// Approves `spender` to spend `value` amount of tokens on behalf of the caller. + /// + /// Successive calls of this method overwrite previous values. + /// + /// # Arguments + /// * `asset` - ID of the asset. + /// * `owner` - The account that owns the tokens (accepts any type convertible to + /// AccountId). + /// * `delegate` - The account that is allowed to spend the tokens (accepts any type + /// convertible to AccountId). + /// * `amount` - The number of tokens to approve. + fn approve( + &mut self, + asset: &AssetIdOf, + owner: impl IntoAccountId>, + delegate: impl IntoAccountId>, + amount: AssetBalanceOf, + ) -> Result<(), DispatchError>; + + /// Creates `value` amount of tokens and assigns them to `account`, increasing the + /// total supply. + /// + /// # Arguments + /// * `asset` - ID of the asset. + /// * `account` - The account to be credited with the created tokens (accepts any type + /// convertible to AccountId). + /// * `value` - The number of tokens to mint. + fn mint_into( + &mut self, + asset: &AssetIdOf, + account: impl IntoAccountId>, + value: AssetBalanceOf, + ) -> Result, DispatchError>; + + /// Transfer `amount` of tokens from `origin` to `dest`. + /// + /// # Arguments + /// * `asset` - ID of the asset. + /// * `source` - The account from which tokens are transferred (accepts any type + /// convertible to AccountId). + /// * `dest` - The account to which tokens are transferred (accepts any type + /// convertible to AccountId). + /// * `amount` - The number of tokens to transfer. + fn transfer( + &mut self, + asset: &AssetIdOf, + source: impl IntoAccountId>, + dest: impl IntoAccountId>, + amount: AssetBalanceOf, + ) -> Result<(), DispatchError>; + + /// Returns the account balance for the specified `owner`. + /// + /// # Arguments + /// * `owner` - The account whose balance is being queried (accepts any type + /// convertible to AccountId). + fn balance_of( + &mut self, + asset: &AssetIdOf, + owner: impl IntoAccountId>, + ) -> AssetBalanceOf; + + /// Returns the total supply of the `asset`. + /// + /// # Arguments + /// * `asset` - ID of the asset. + fn total_supply( + &mut self, + asset: &AssetIdOf, + ) -> AssetBalanceOf; + + /// Returns the allowance for a `spender` approved by an `owner`. + /// + /// # Arguments + /// * `asset` - ID of the asset. + /// * `owner` - The account that owns the tokens (accepts any type convertible to + /// AccountId). + /// * `delegate` - The account that is allowed to spend the tokens (accepts any type + /// convertible to AccountId). + fn allowance( + &mut self, + asset: &AssetIdOf, + owner: impl IntoAccountId>, + delegate: impl IntoAccountId>, + ) -> AssetBalanceOf; + + /// Check if the asset exists. + /// + /// # Arguments + /// * `asset` - ID of the asset. + fn asset_exists(&mut self, asset: &AssetIdOf) -> bool; +} + +impl AssetsAPI for T +where + T: Sandbox, + T::Runtime: pallet_assets::Config, + I: 'static, +{ + fn create( + &mut self, + id: &AssetIdOf, + owner: impl IntoAccountId>, + min_balance: AssetBalanceOf, + ) -> Result<(), DispatchError> { + let owner = owner.into_account_id(); + self.execute_with(|| { + as Create< + AccountIdFor, + >>::create(id.clone(), owner, true, min_balance) + }) + } + + fn set_metadata( + &mut self, + asset: &AssetIdOf, + owner: impl IntoAccountId>, + name: Vec, + symbol: Vec, + decimals: u8, + ) -> Result<(), DispatchError> { + let owner = owner.into_account_id(); + self.execute_with(|| { + pallet_assets::Pallet::::set( + asset.clone().into(), + &owner, + name, + symbol, + decimals, + ) + }) + } + + fn metadata(&mut self, asset: &AssetIdOf) -> (Vec, Vec, u8) { + self.execute_with(|| { + let name = as MetadataInspect< + AccountIdFor, + >>::name(asset.clone()); + let symbol = as MetadataInspect< + AccountIdFor, + >>::symbol(asset.clone()); + let decimals = as MetadataInspect< + AccountIdFor, + >>::decimals(asset.clone()); + (name, symbol, decimals) + }) + } + + fn mint_into( + &mut self, + asset: &AssetIdOf, + account: impl IntoAccountId>, + value: AssetBalanceOf, + ) -> Result, DispatchError> { + let account = account.into_account_id(); + self.execute_with(|| { + pallet_assets::Pallet::::mint_into( + asset.clone(), + &account, + value, + ) + }) + } + + fn transfer( + &mut self, + asset: &AssetIdOf, + source: impl IntoAccountId>, + dest: impl IntoAccountId>, + amount: AssetBalanceOf, + ) -> Result<(), DispatchError> { + let source = source.into_account_id(); + let dest = dest.into_account_id(); + self.execute_with(|| { + as Mutate>>::transfer( + asset.clone(), + &source, + &dest, + amount, + frame_support::traits::tokens::Preservation::Preserve, + ).map(|_| ()) + }) + } + + fn approve( + &mut self, + asset: &AssetIdOf, + owner: impl IntoAccountId>, + delegate: impl IntoAccountId>, + amount: AssetBalanceOf, + ) -> Result<(), DispatchError> { + let owner = owner.into_account_id(); + let delegate = delegate.into_account_id(); + self.execute_with(|| { + pallet_assets::Pallet::::approve( + asset.clone(), + &owner, + &delegate, + amount, + ) + }) + } + + fn balance_of( + &mut self, + asset: &AssetIdOf, + owner: impl IntoAccountId>, + ) -> AssetBalanceOf { + let owner = owner.into_account_id(); + self.execute_with(|| { + pallet_assets::Pallet::::balance(asset.clone(), &owner) + }) + } + + fn total_supply( + &mut self, + asset: &AssetIdOf, + ) -> AssetBalanceOf { + self.execute_with(|| { + pallet_assets::Pallet::::total_supply(asset.clone()) + }) + } + + fn allowance( + &mut self, + asset: &AssetIdOf, + owner: impl IntoAccountId>, + delegate: impl IntoAccountId>, + ) -> AssetBalanceOf { + let owner = owner.into_account_id(); + let delegate = delegate.into_account_id(); + self.execute_with(|| { + pallet_assets::Pallet::::allowance( + asset.clone(), + &owner, + &delegate, + ) + }) + } + + fn asset_exists(&mut self, asset: &AssetIdOf) -> bool { + self.execute_with(|| { + pallet_assets::Pallet::::asset_exists(asset.clone()) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::DefaultSandbox; + + #[test] + fn create_works() { + let mut sandbox = DefaultSandbox::default(); + let admin = DefaultSandbox::default_actor(); + let asset_id = 1u32; + let min_balance = 1u128; + + let result = sandbox.create(&asset_id, &admin, min_balance); + + assert!(result.is_ok()); + assert!(sandbox.asset_exists(&asset_id)); + } + + #[test] + fn set_metadata_works() { + let mut sandbox = DefaultSandbox::default(); + let admin = DefaultSandbox::default_actor(); + let asset_id = 1u32; + + sandbox.create(&asset_id, &admin, 1u128).unwrap(); + + let name = b"Test Token".to_vec(); + let symbol = b"TEST".to_vec(); + let decimals = 18u8; + + let result = sandbox.set_metadata(&asset_id, &admin, name, symbol, decimals); + + assert!(result.is_ok()); + } + + #[test] + fn metadata_works() { + let mut sandbox = DefaultSandbox::default(); + let admin = DefaultSandbox::default_actor(); + let asset_id = 1u32; + + sandbox.create(&asset_id, &admin, 1u128).unwrap(); + + let name = b"Test Token".to_vec(); + let symbol = b"TEST".to_vec(); + let decimals = 18u8; + + sandbox + .set_metadata(&asset_id, &admin, name.clone(), symbol.clone(), decimals) + .unwrap(); + + let (retrieved_name, retrieved_symbol, retrieved_decimals) = + sandbox.metadata(&asset_id); + + assert_eq!(retrieved_name, name); + assert_eq!(retrieved_symbol, symbol); + assert_eq!(retrieved_decimals, decimals); + } + + #[test] + fn approve_works() { + let mut sandbox = DefaultSandbox::default(); + let admin = DefaultSandbox::default_actor(); + let spender = ink_e2e::bob().into_account_id(); + let asset_id = 1u32; + + sandbox.create(&asset_id, &admin, 1u128).unwrap(); + sandbox.mint_into(&asset_id, &admin, 1000u128).unwrap(); + + let allowance_before = sandbox.allowance(&asset_id, &admin, &spender); + assert_eq!(allowance_before, 0); + + let result = sandbox.approve(&asset_id, &admin, &spender, 500u128); + + assert!(result.is_ok()); + + let allowance_after = sandbox.allowance(&asset_id, &admin, &spender); + assert_eq!(allowance_after, 500); + } + + #[test] + fn mint_into_works() { + let mut sandbox = DefaultSandbox::default(); + let admin = DefaultSandbox::default_actor(); + let asset_id = 1u32; + + sandbox.create(&asset_id, &admin, 1u128).unwrap(); + + let balance_before = sandbox.balance_of(&asset_id, &admin); + assert_eq!(balance_before, 0); + + sandbox.mint_into(&asset_id, &admin, 100u128).unwrap(); + + let balance_after = sandbox.balance_of(&asset_id, &admin); + assert_eq!(balance_after, 100); + } + + #[test] + fn transfer_works() { + let mut sandbox = DefaultSandbox::default(); + let admin = DefaultSandbox::default_actor(); + let recipient = ink_e2e::bob().into_account_id(); + let asset_id = 1u32; + + sandbox.create(&asset_id, &admin, 1u128).unwrap(); + sandbox.mint_into(&asset_id, &admin, 1000u128).unwrap(); + + let admin_balance_before = sandbox.balance_of(&asset_id, &admin); + let recipient_balance_before = sandbox.balance_of(&asset_id, &recipient); + + assert_eq!(admin_balance_before, 1000); + assert_eq!(recipient_balance_before, 0); + + let result = sandbox.transfer(&asset_id, &admin, &recipient, 300u128); + + assert!(result.is_ok()); + + let admin_balance_after = sandbox.balance_of(&asset_id, &admin); + let recipient_balance_after = sandbox.balance_of(&asset_id, &recipient); + + assert_eq!(admin_balance_after, 700); + assert_eq!(recipient_balance_after, 300); + } + + #[test] + fn balance_of_works() { + let mut sandbox = DefaultSandbox::default(); + let admin = DefaultSandbox::default_actor(); + let asset_id = 1u32; + + sandbox.create(&asset_id, &admin, 1u128).unwrap(); + + let balance = sandbox.balance_of(&asset_id, &admin); + assert_eq!(balance, 0); + + sandbox.mint_into(&asset_id, &admin, 500u128).unwrap(); + + let balance = sandbox.balance_of(&asset_id, &admin); + assert_eq!(balance, 500); + } + + #[test] + fn total_supply_works() { + let mut sandbox = DefaultSandbox::default(); + let admin = DefaultSandbox::default_actor(); + let asset_id = 1u32; + + sandbox.create(&asset_id, &admin, 1u128).unwrap(); + + let supply_before = sandbox.total_supply(&asset_id); + assert_eq!(supply_before, 0); + + sandbox.mint_into(&asset_id, &admin, 1000u128).unwrap(); + + let supply_after = sandbox.total_supply(&asset_id); + assert_eq!(supply_after, 1000); + } + + #[test] + fn allowance_works() { + let mut sandbox = DefaultSandbox::default(); + let admin = DefaultSandbox::default_actor(); + let spender = ink_e2e::bob().into_account_id(); + let asset_id = 1u32; + + sandbox.create(&asset_id, &admin, 1u128).unwrap(); + + let allowance = sandbox.allowance(&asset_id, &admin, &spender); + assert_eq!(allowance, 0); + + sandbox + .approve(&asset_id, &admin, &spender, 250u128) + .unwrap(); + + let allowance = sandbox.allowance(&asset_id, &admin, &spender); + assert_eq!(allowance, 250); + } + + #[test] + fn asset_exists_works() { + let mut sandbox = DefaultSandbox::default(); + let admin = DefaultSandbox::default_actor(); + let asset_id = 1u32; + + assert!(!sandbox.asset_exists(&asset_id)); + + sandbox.create(&asset_id, &admin, 1u128).unwrap(); + + assert!(sandbox.asset_exists(&asset_id)); + } +} diff --git a/crates/sandbox/src/api/revive_api.rs b/crates/sandbox/src/api/revive_api.rs index 8e84b86143d..37b08500964 100644 --- a/crates/sandbox/src/api/revive_api.rs +++ b/crates/sandbox/src/api/revive_api.rs @@ -55,7 +55,10 @@ pub trait ContractAPI { /// * `gas_limit` - The gas limit for the contract call. /// * `storage_deposit_limit` - The storage deposit limit for the contract call. #[allow(clippy::type_complexity, clippy::too_many_arguments)] - fn map_account(&mut self, account: OriginFor) -> Result<(), DispatchError>; + fn map_account( + &mut self, + account: impl crate::IntoAccountId>, + ) -> Result<(), DispatchError>; /// `pallet-revive` uses a dedicated "pallet" account for tracking /// storage deposits. The static account is returned by the @@ -166,9 +169,11 @@ where fn map_account( &mut self, - account_id: OriginFor, + account: impl crate::IntoAccountId>, ) -> Result<(), DispatchError> { - self.execute_with(|| pallet_revive::Pallet::::map_account(account_id)) + let account_id = account.into_account_id(); + let origin = Self::convert_account_to_origin(account_id); + self.execute_with(|| pallet_revive::Pallet::::map_account(origin)) } /// `pallet-revive` uses a dedicated "pallet" account for tracking @@ -374,9 +379,9 @@ mod tests { warm_up::(&mut sandbox); - let origin = - DefaultSandbox::convert_account_to_origin(DefaultSandbox::default_actor()); - sandbox.map_account(origin.clone()).expect("cannot map"); + let default_actor = DefaultSandbox::default_actor(); + sandbox.map_account(&default_actor).expect("cannot map"); + let origin = DefaultSandbox::convert_account_to_origin(default_actor); let result = sandbox.deploy_contract( contract_binary.clone(), 0, @@ -411,9 +416,9 @@ mod tests { let contract_binary = compile_module("dummy"); warm_up::(&mut sandbox); - let origin = - DefaultSandbox::convert_account_to_origin(DefaultSandbox::default_actor()); - sandbox.map_account(origin.clone()).expect("unable to map"); + let default_actor = DefaultSandbox::default_actor(); + sandbox.map_account(&default_actor).expect("unable to map"); + let origin = DefaultSandbox::convert_account_to_origin(default_actor); let result = sandbox.deploy_contract( contract_binary, 0, diff --git a/crates/sandbox/src/api/system_api.rs b/crates/sandbox/src/api/system_api.rs index ac333fab8fd..80a7cdf53a4 100644 --- a/crates/sandbox/src/api/system_api.rs +++ b/crates/sandbox/src/api/system_api.rs @@ -143,7 +143,7 @@ mod tests { let initial_balance = sandbox.free_balance(&actor); sandbox.dry_run(|sandbox| { - sandbox.mint_into(&actor, 100).unwrap(); + crate::api::balance_api::BalanceAPI::mint_into(sandbox, &actor, 100).unwrap(); assert_eq!(sandbox.free_balance(&actor), initial_balance + 100); }); diff --git a/crates/sandbox/src/client.rs b/crates/sandbox/src/client.rs index 48e2b298a59..3fd8c71f71a 100644 --- a/crates/sandbox/src/client.rs +++ b/crates/sandbox/src/client.rs @@ -14,6 +14,7 @@ use crate::{ AccountIdFor, + EventRecordOf, RuntimeCall, Sandbox, api::prelude::*, @@ -133,6 +134,14 @@ where } } + /// Get a mutable reference to the underlying sandbox. + /// + /// This allows direct access to all sandbox methods and traits, making it easy to + /// interact with runtime pallets like `pallet-assets`: + pub fn sandbox(&mut self) -> &mut S { + &mut self.sandbox + } + fn fund_accounts(sandbox: &mut S) { const TOKENS: u128 = 1_000_000_000_000_000; @@ -171,7 +180,7 @@ where type AccountId = AccountId; type Balance = BalanceOf; type Error = SandboxErr; - type EventLog = (); + type EventLog = Vec>; async fn create_and_fund_account( &mut self, @@ -226,7 +235,8 @@ where RuntimeCall::::decode(&mut encoded_call.as_slice()) .expect("Failed to decode runtime call"); - // Execute the call. + // Execute the call and record events emitted during this operation. + let start = self.sandbox.events().len(); self.sandbox .runtime_call( decoded_call, @@ -235,8 +245,9 @@ where .map_err(|err| { SandboxErr::new(format!("runtime_call: execution error {:?}", err.error)) })?; - - Ok(()) + let all = self.sandbox.events(); + let events = all[start..].to_vec(); + Ok(events) } async fn transfer_allow_death( @@ -334,6 +345,8 @@ where let mut tracer = self.sandbox.evm_tracer(tracer_type); let mut code_hash: Option = None; + // Record events emitted during instantiation + let start = self.sandbox.events().len(); let result = pallet_revive::tracing::trace(tracer.as_tracing(), || { code_hash = Some(H256(ink_e2e::code_hash(&code[..]))); self.sandbox.deploy_contract( @@ -371,10 +384,13 @@ where .as_ref() .to_owned(); + let all = self.sandbox.events(); + let events = all[start..].to_vec(); + Ok(BareInstantiationResult { addr: addr_raw, account_id: account_id.into(), - events: (), // todo: https://github.com/Cardinal-Cryptography/drink/issues/32 + events, trace, code_hash: code_hash.expect("code_hash must have been calculated"), }) @@ -476,7 +492,8 @@ where storage_deposit_limit: Option, ) -> Result, Self::Error> { let code = self.contracts.load_code(contract_name); - + // Record events emitted during upload + let start = self.sandbox.events().len(); let result = match self.sandbox.upload_contract( code, caller_to_origin::(caller), @@ -485,17 +502,19 @@ where Ok(result) => result, Err(err) => { log_error(&format!("upload failed: {err:?}")); - return Err(SandboxErr::new(format!("bare_upload: {err:?}"))) + return Err(SandboxErr::new(format!("bare_upload: {err:?}"))); } }; + let all = self.sandbox.events(); + let events = all[start..].to_vec(); Ok(UploadResult { code_hash: result.code_hash, dry_run: Ok(CodeUploadReturnValue { code_hash: result.code_hash, deposit: result.deposit, }), - events: (), + events, }) } @@ -553,6 +572,8 @@ where storage_deposit_limit: E::Balance, signer: &Keypair, ) -> Result<(Self::EventLog, Option), Self::Error> { + // Record events emitted during the contract call + let start = self.sandbox.events().len(); // todo let tracer_type = TracerType::CallTracer(Some(CallTracerConfig::default())); let mut tracer = self.sandbox.evm_tracer(tracer_type); @@ -578,7 +599,9 @@ where _ => None, }; - Ok(((), trace)) + let all = self.sandbox.events(); + let events = all[start..].to_vec(); + Ok((events, trace)) } /// Important: For an uncomplicated UX of the E2E testing environment we @@ -676,12 +699,11 @@ where &mut self, caller: &Keypair, ) -> Result, Self::Error> { - let caller = keypair_to_account(caller); - let origin = RawOrigin::Signed(caller); - let origin = OriginFor::::from(origin); + let caller_account: AccountIdFor = keypair_to_account(caller); + let origin = S::convert_account_to_origin(caller_account); self.sandbox - .map_account(origin) + .execute_with(|| pallet_revive::Pallet::::map_account(origin)) .map_err(|err| { SandboxErr::new(format!("map_account: execution error {err:?}")) }) @@ -696,6 +718,86 @@ where } } +impl Client +where + S::Runtime: pallet_revive::Config, + ::RuntimeEvent: + TryInto>, +{ + pub fn contract_events(&mut self) -> Vec> + where + S::Runtime: pallet_revive::Config, + ::RuntimeEvent: + TryInto>, + { + self.sandbox + .events() + .iter() + .filter_map(|event_record| { + if let Ok(pallet_event) = &event_record.event.clone().try_into() { + match pallet_event { + pallet_revive::Event::::ContractEmitted { + data, + .. + } => Some(data.clone()), + _ => None, + } + } else { + None + } + }) + .collect::>>() + } + + /// Returns the last contract event that was emitted, if any. + pub fn last_contract_event(&mut self) -> Option> + where + S::Runtime: pallet_revive::Config, + ::RuntimeEvent: + TryInto>, + { + self.contract_events().last().cloned() + } +} + +/// Helper function for the `assert_last_contract_event!` macro. +/// +/// # Parameters: +/// - `client` - The client for interacting with the sandbox. +/// - `event` - The expected event. +#[track_caller] +pub fn assert_last_contract_event_inner( + client: &mut Client, + event: E, +) where + S: Sandbox, + S::Runtime: pallet_revive::Config, + ::RuntimeEvent: + TryInto>, + E: Decode + scale::Encode + core::fmt::Debug + std::cmp::PartialEq, +{ + let expected_event = event; + let last_event = client + .last_contract_event() + .unwrap_or_else(|| panic!("contract events expected but none were emitted yet")); + + let decoded_event = E::decode(&mut &last_event[..]).unwrap_or_else(|error| { + panic!( + "failed to decode last contract event as {}: bytes={:?}, error={:?}", + core::any::type_name::(), + last_event, + error + ); + }); + + if decoded_event != expected_event { + panic!( + "expected contract event {:?} but found {:?}", + expected_event, decoded_event + ); + } +} + impl< AccountId: Clone + Send + Sync + From<[u8; 32]> + AsRef<[u8; 32]>, Config: Sandbox, @@ -726,7 +828,7 @@ where AccountIdFor: From<[u8; 32]> + AsRef<[u8; 32]>, { type Error = SandboxErr; - type EventLog = (); + type EventLog = Vec>; } /// Exposes preset sandbox configurations to be used in tests. diff --git a/crates/sandbox/src/error.rs b/crates/sandbox/src/error.rs index 0f62dd28abb..314ba570c2f 100644 --- a/crates/sandbox/src/error.rs +++ b/crates/sandbox/src/error.rs @@ -39,3 +39,31 @@ impl fmt::Display for SandboxErr { write!(f, "SandboxErr: {}", self.msg) } } + +/// Unified error type for sandbox E2E testing. +/// +/// This error type allows seamless error propagation with the `?` operator +/// across sandbox APIs (which return `DispatchError`) and contract calls +/// (which return `SandboxErr`). +#[derive(Debug, thiserror::Error)] +pub enum E2EError { + /// Error from FRAME dispatch (e.g., pallet extrinsic failures). + /// + /// Returned by sandbox APIs like `create()`, `mint_into()`, `map_account()`, etc. + /// when the underlying FRAME pallet operation fails. + #[error("Dispatch error: {0:?}")] + Dispatch(frame_support::sp_runtime::DispatchError), + + /// Error from sandbox operations. + /// + /// Returned by contract instantiation and call operations when they fail + /// at the sandbox client level. + #[error("Sandbox error: {0}")] + Sandbox(#[from] SandboxErr), +} + +impl From for E2EError { + fn from(err: frame_support::sp_runtime::DispatchError) -> Self { + E2EError::Dispatch(err) + } +} diff --git a/crates/sandbox/src/lib.rs b/crates/sandbox/src/lib.rs index 65e33383c63..56a7ca4a569 100644 --- a/crates/sandbox/src/lib.rs +++ b/crates/sandbox/src/lib.rs @@ -2,7 +2,7 @@ use core::any::Any; pub mod api; pub mod client; -mod error; +pub mod error; pub mod macros; pub use frame_metadata::RuntimeMetadataPrefixed; @@ -27,8 +27,10 @@ use ink_revive_types::{ }, }; pub use macros::{ + AssetIdForTrustBackedAssets, BlockBuilder, DefaultSandbox, + TrustBackedAssetsInstance, }; use pallet_revive::{ ContractResult, @@ -47,11 +49,15 @@ pub use { }, }, frame_system, + ink_precompiles, + pallet_assets, + pallet_assets_precompiles, pallet_balances, pallet_revive, pallet_timestamp, pallet_transaction_payment, paste, + scale, sp_core::crypto::Ss58Codec, sp_externalities::{ self, @@ -64,6 +70,7 @@ pub use client::{ Client as SandboxClient, preset, }; +pub use error::E2EError; pub use ink_e2e_macro::test; /// A snapshot of the storage. @@ -248,3 +255,29 @@ pub fn to_revive_storage_deposit( } } } + +/// Trait for types that can be converted into a runtime AccountId. +/// +/// This allows sandbox 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/sandbox/src/macros.rs b/crates/sandbox/src/macros.rs index bb1cb910694..bbfe6bffb17 100644 --- a/crates/sandbox/src/macros.rs +++ b/crates/sandbox/src/macros.rs @@ -13,6 +13,134 @@ 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 sandbox +/// matches the provided expected event. +/// +/// # Parameters +/// - `client` - Mutable reference to the sandbox 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); @@ -127,6 +255,7 @@ mod construct_runtime { use $crate::pallet_transaction_payment::{FungibleAdapter}; use $crate::Snapshot; + use $crate::ink_precompiles::erc20; pub type Balance = u128; @@ -136,6 +265,7 @@ mod construct_runtime { System: $crate::frame_system, Balances: $crate::pallet_balances, Timestamp: $crate::pallet_timestamp, + Assets: $crate::pallet_assets::, Revive: $crate::pallet_revive, TransactionPayment: $crate::pallet_transaction_payment, $( @@ -176,6 +306,32 @@ mod construct_runtime { type WeightInfo = (); } + // Configure pallet-assets (Instance1 for Trust Backed Assets) + pub type TrustBackedAssetsInstance = $crate::pallet_assets::Instance1; + pub type AssetIdForTrustBackedAssets = u32; + + impl $crate::pallet_assets::Config for $runtime { + type RuntimeEvent = RuntimeEvent; + type Balance = Balance; + type AssetId = AssetIdForTrustBackedAssets; + type AssetIdParameter = $crate::scale::Compact; + type Currency = Balances; + type CreateOrigin = $crate::frame_support::traits::AsEnsureOriginWithArg<$crate::frame_system::EnsureSigned>; + type ForceOrigin = $crate::frame_system::EnsureRoot; + type AssetDeposit = ConstU128<1>; + type AssetAccountDeposit = ConstU128<1>; + type MetadataDepositBase = ConstU128<1>; + type MetadataDepositPerByte = ConstU128<1>; + type ApprovalDeposit = ConstU128<1>; + type StringLimit = ConstU32<50>; + type Freezer = (); + type Holder = (); + type Extra = (); + type WeightInfo = (); + type CallbackHandle = $crate::pallet_assets::AutoIncAssetId<$runtime, TrustBackedAssetsInstance>; + type RemoveItemsLimit = ConstU32<1000>; + } + impl $crate::pallet_transaction_payment::Config for $runtime { type RuntimeEvent = RuntimeEvent; type OnChargeTransaction = FungibleAdapter; @@ -231,10 +387,7 @@ mod construct_runtime { type InstantiateOrigin = $crate::frame_system::EnsureSigned; type FindAuthor = (); type Precompiles = ( - // todo - //ERC20, TrustBackedAssetsInstance>, - //ERC20, PoolAssetsInstance>, - //XcmPrecompile, + $crate::pallet_assets_precompiles::ERC20, TrustBackedAssetsInstance>, ); type AllowEVMBytecode = ConstBool; type FeeInfo = (); @@ -339,8 +492,9 @@ mod construct_runtime { // Export runtime type itself, pallets and useful types from the auxiliary module pub use construct_runtime::{ - $sandbox, $runtime, Balances, Revive, PalletInfo, RuntimeCall, RuntimeEvent, RuntimeHoldReason, - RuntimeOrigin, System, Timestamp, + $sandbox, $runtime, Assets, AssetIdForTrustBackedAssets, Balances, Revive, PalletInfo, + RuntimeCall, RuntimeEvent, RuntimeHoldReason, RuntimeOrigin, System, Timestamp, + TrustBackedAssetsInstance, }; }; } diff --git a/integration-tests/public/assets-precompile/Cargo.toml b/integration-tests/public/assets-precompile/Cargo.toml new file mode 100644 index 00000000000..012fa9be2ab --- /dev/null +++ b/integration-tests/public/assets-precompile/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "assets_precompile" +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" } +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/README.md b/integration-tests/public/assets-precompile/README.md new file mode 100644 index 00000000000..b04a9779e66 --- /dev/null +++ b/integration-tests/public/assets-precompile/README.md @@ -0,0 +1,12 @@ +# Assets Precompile Integration + +This contract demonstrates how to interact with ERC-20 asset precompiles in ink! using the `ink_precompiles` crate. + +## Overview + +This example shows how to: +- Use the `ink_precompiles` crate for precompile interfaces +- Test precompile interactions using the runtime-only e2e test framework +- Work with `pallet-assets` through the ERC-20 precompile +- Set up accounts and assets for e2e tests +- Debug precompile errors with `extract_error()` diff --git a/integration-tests/public/assets-precompile/lib.rs b/integration-tests/public/assets-precompile/lib.rs new file mode 100644 index 00000000000..3b1d7d81283 --- /dev/null +++ b/integration-tests/public/assets-precompile/lib.rs @@ -0,0 +1,532 @@ +#![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(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::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::{ + ContractsBackend, + IntoAddress, + alice, + bob, + }; + use ink_sandbox::{ + DefaultSandbox, + E2EError, + SandboxClient, + api::prelude::{ + AssetsAPI, + ContractAPI, + }, + assert_last_event, + assert_noop, + 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 = AssetHubPrecompileRef::new(asset_id); + + let contract = client + .instantiate("assets_precompile", &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_sandbox::test(backend(runtime_only( + sandbox = DefaultSandbox, + client = SandboxClient + )))] + async fn total_supply_works(mut client: Client) -> E2EResult<()> { + let asset_id: u32 = 1; + let admin = alice(); + + client.sandbox().create(&asset_id, &admin, 1u128)?; + client.sandbox().mint_into(&asset_id, &admin, 1000u128)?; + + let contract = client + .instantiate( + "assets_precompile", + &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_sandbox::test(backend(runtime_only( + sandbox = DefaultSandbox, + client = SandboxClient + )))] + async fn balance_of_works(mut client: Client) -> E2EResult<()> { + let asset_id: u32 = 1; + let alice = alice(); + let bob = bob(); + + client.sandbox().create(&asset_id, &alice, 1u128)?; + client.sandbox().mint_into(&asset_id, &alice, 1000u128)?; + client.sandbox().mint_into(&asset_id, &bob, 500u128)?; + + let contract = client + .instantiate( + "assets_precompile", + &alice, + &mut AssetHubPrecompileRef::new(asset_id), + ) + .submit() + .await?; + + // Map bob's account otherwise it fails. + client.sandbox().map_account(&bob)?; + + 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_sandbox::test(backend(runtime_only( + sandbox = DefaultSandbox, + client = SandboxClient + )))] + async fn transfer_works(mut client: Client) -> E2EResult<()> { + let asset_id: u32 = 1; + let alice = alice(); + let bob = bob(); + + client.sandbox().create(&asset_id, &alice, 1u128)?; + + let contract = client + .instantiate( + "assets_precompile", + &alice, + &mut AssetHubPrecompileRef::new(asset_id), + ) + .submit() + .await?; + + client + .sandbox() + .mint_into(&asset_id, &contract.account_id, 100_000u128)?; + client.sandbox().map_account(&bob)?; + + 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!( + &mut client, + Transfer { + from: contract.addr, + to: bob_address, + value: transfer_amount + } + ); + + let contract_balance = + client.sandbox().balance_of(&asset_id, &contract.account_id); + let bob_balance = client.sandbox().balance_of(&asset_id, &bob); + assert_eq!(contract_balance, 99_000u128); + assert_eq!(bob_balance, 1_000u128); + + let result = client + .call( + &alice, + &contract_call.transfer(bob_address, U256::from(1_000_000)), + ) + .submit() + .await?; + assert_noop!(result, "BalanceLow"); + + Ok(()) + } + + #[ink_sandbox::test(backend(runtime_only( + sandbox = DefaultSandbox, + client = SandboxClient + )))] + async fn approve_works(mut client: Client) -> E2EResult<()> { + let asset_id: u32 = 1; + let alice = alice(); + let bob = bob(); + + client.sandbox().create(&asset_id, &alice, 1u128)?; + + let contract = client + .instantiate("assets_precompile", &alice, &mut AssetHubPrecompileRef::new(asset_id)) + // Contract needs native balance for approval deposit. + .value(100_000) + .submit() + .await?; + + client + .sandbox() + .mint_into(&asset_id, &contract.account_id, 100_000u128)?; + client.sandbox().map_account(&bob)?; + let bob_allowance_before = + client + .sandbox() + .allowance(&asset_id, &contract.account_id, &bob); + assert_eq!(bob_allowance_before, 0u128); // Bob's allowance is 0 + + 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!( + &mut client, + Approval { + owner: contract.addr, + spender: bob_address, + value: approve_amount, + } + ); + + let bob_allowance = + client + .sandbox() + .allowance(&asset_id, &contract.account_id, &bob); + assert_eq!(bob_allowance, 200u128); + + Ok(()) + } + + #[ink_sandbox::test(backend(runtime_only( + sandbox = DefaultSandbox, + client = SandboxClient + )))] + async fn allowance_works(mut client: Client) -> E2EResult<()> { + let asset_id: u32 = 1; + let alice = alice(); + let bob = bob(); + + client.sandbox().create(&asset_id, &alice, 1u128)?; + + let contract = client + .instantiate( + "assets_precompile", + &alice, + &mut AssetHubPrecompileRef::new(asset_id), + ) + .submit() + .await?; + + let contract_call = contract.call_builder::(); + client.sandbox().mint_into(&asset_id, &alice, 100_000u128)?; + client.sandbox().map_account(&bob)?; + + 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.sandbox().approve(&asset_id, &alice, &bob, 300u128)?; + + let result = client.call(&alice, 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_works( + mut client: Client, + ) -> E2EResult<()> { + let asset_id: u32 = 1; + let alice = alice(); + let bob = bob(); + + client.sandbox().create(&asset_id, &alice, 1u128)?; + + let contract = client + .instantiate( + "assets_precompile", + &alice, + &mut AssetHubPrecompileRef::new(asset_id), + ) + .submit() + .await?; + + client.sandbox().mint_into(&asset_id, &alice, 100_000u128)?; + // Approve alice to spend contract's tokens + client + .sandbox() + .approve(&asset_id, &alice, &contract.account_id, 50_000u128)?; + client.sandbox().map_account(&bob)?; + + 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!( + &mut client, + Transfer { + from: alice_address, + to: bob_address, + value: transfer_amount, + } + ); + + let alice_balance = client.sandbox().balance_of(&asset_id, &alice); + let bob_balance = client.sandbox().balance_of(&asset_id, &bob); + let contract_allowance = + client + .sandbox() + .allowance(&asset_id, &alice, &contract.account_id); + 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/runtime-call-contract/Cargo.toml b/integration-tests/public/runtime-call-contract/Cargo.toml index a355c0ac849..08616179bb0 100644 --- a/integration-tests/public/runtime-call-contract/Cargo.toml +++ b/integration-tests/public/runtime-call-contract/Cargo.toml @@ -51,6 +51,7 @@ std = [ "flipper-traits/std", ] ink-as-dependency = [] +e2e-tests = [] [package.metadata.ink-lang] abi = "ink" diff --git a/integration-tests/solidity-abi/sol-cross-contract/e2e_tests.rs b/integration-tests/solidity-abi/sol-cross-contract/e2e_tests.rs index 23953813e81..3a35774bfd8 100644 --- a/integration-tests/solidity-abi/sol-cross-contract/e2e_tests.rs +++ b/integration-tests/solidity-abi/sol-cross-contract/e2e_tests.rs @@ -3,7 +3,10 @@ use ink_e2e::ContractsRegistry; use ink_sandbox::{ DefaultSandbox, Sandbox, - api::prelude::*, + api::prelude::{ + BalanceAPI, + ContractAPI, + }, }; use ink::{ @@ -30,7 +33,9 @@ fn call_sol_encoded_message() { .mint_into(&caller.public_key().0.into(), 1_000_000_000_000_000u128) .unwrap_or_else(|_| panic!("Failed to mint tokens")); - sandbox.map_account(origin.clone()).expect("unable to map"); + sandbox + .map_account(&DefaultSandbox::default_actor()) + .expect("unable to map"); // upload other contract (callee) let constructor = other_contract_sol::OtherContractRef::new(false); diff --git a/integration-tests/solidity-abi/sol-encoding/e2e_tests.rs b/integration-tests/solidity-abi/sol-encoding/e2e_tests.rs index 1b9187cb305..694be765f68 100644 --- a/integration-tests/solidity-abi/sol-encoding/e2e_tests.rs +++ b/integration-tests/solidity-abi/sol-encoding/e2e_tests.rs @@ -9,7 +9,7 @@ use ink_revive_types::ExecReturnValue; use ink_sandbox::{ DefaultSandbox, Sandbox, - api::prelude::*, + api::prelude::{BalanceAPI, ContractAPI}, frame_system::pallet_prelude::OriginFor, }; @@ -29,7 +29,7 @@ fn call_solidity_encoded_message() { .mint_into(&caller.public_key().0.into(), 1_000_000_000_000_000u128) .unwrap_or_else(|_| panic!("Failed to mint tokens")); - sandbox.map_account(origin.clone()).expect("unable to map"); + sandbox.map_account(&DefaultSandbox::default_actor()).expect("unable to map"); let constructor = SolEncodingRef::new(false); let params = constructor