diff --git a/Cargo.lock b/Cargo.lock index 312631fb6..3e9d6ba04 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -885,14 +885,17 @@ dependencies = [ name = "axelar-wasm-std-derive" version = "1.0.0" dependencies = [ + "assert_ok", "axelar-wasm-std", "cosmwasm-std", + "cw2", "error-stack", "heck 0.5.0", "itertools 0.11.0", "proc-macro2 1.0.92", "quote 1.0.38", "report", + "semver 1.0.23", "serde", "serde_json", "syn 2.0.96", @@ -4221,7 +4224,7 @@ dependencies = [ [[package]] name = "interchain-token-service" -version = "1.0.0" +version = "1.1.0" dependencies = [ "alloy-primitives", "alloy-sol-types", diff --git a/Cargo.toml b/Cargo.toml index ff1acbf2b..078c73c8f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,7 +50,7 @@ goldie = { version = "0.5" } heck = "0.5.0" hex = "0.4.3" integration-tests = { version = "^1.0.0", path = "integration-tests" } -interchain-token-service = { version = "^1.0.0", path = "contracts/interchain-token-service" } +interchain-token-service = { version = "^1.1.0", path = "contracts/interchain-token-service" } into-inner-derive = { version = "^1.0.0", path = "packages/into-inner-derive" } itertools = "0.11.0" k256 = { version = "0.13.1", features = ["ecdsa"] } diff --git a/ampd/src/handlers/multisig.rs b/ampd/src/handlers/multisig.rs index 9796bc704..14908d81b 100644 --- a/ampd/src/handlers/multisig.rs +++ b/ampd/src/handlers/multisig.rs @@ -138,8 +138,8 @@ where match pub_keys.get(&self.verifier) { Some(pub_key) => { let key_type = match pub_key { - PublicKey::Secp256k1(_) => tofnd::Algorithm::Ed25519, - PublicKey::Ed25519(_) => tofnd::Algorithm::Ecdsa, + PublicKey::Secp256k1(_) => tofnd::Algorithm::Ecdsa, + PublicKey::Ed25519(_) => tofnd::Algorithm::Ed25519, }; let signature = self diff --git a/ampd/src/handlers/stacks_verify_msg.rs b/ampd/src/handlers/stacks_verify_msg.rs index 1d4b5487f..b78d66a8d 100644 --- a/ampd/src/handlers/stacks_verify_msg.rs +++ b/ampd/src/handlers/stacks_verify_msg.rs @@ -181,7 +181,7 @@ mod tests { use cosmrs::tx::Msg; use cosmwasm_std; use error_stack::Result; - use ethers_core::types::{H160, U64}; + use ethers_core::types::H160; use tokio::sync::watch; use tokio::test as async_test; use voting_verifier::events::{PollMetadata, PollStarted, TxEventConfirmation}; @@ -190,7 +190,7 @@ mod tests { use crate::event_processor::EventHandler; use crate::handlers::tests::{into_structured_event, participants}; use crate::stacks::http_client::{Block, Client}; - use crate::types::{EVMAddress, Hash, TMAddress}; + use crate::types::{Hash, TMAddress}; use crate::PREFIX; #[test] diff --git a/contracts/interchain-token-service/Cargo.toml b/contracts/interchain-token-service/Cargo.toml index 1ac801171..a9d5a4d56 100644 --- a/contracts/interchain-token-service/Cargo.toml +++ b/contracts/interchain-token-service/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "interchain-token-service" -version = "1.0.0" +version = "1.1.0" rust-version = { workspace = true } edition = { workspace = true } diff --git a/contracts/interchain-token-service/src/contract/execute/interceptors.rs b/contracts/interchain-token-service/src/contract/execute/interceptors.rs index 6dc318038..01ba8f65e 100644 --- a/contracts/interchain-token-service/src/contract/execute/interceptors.rs +++ b/contracts/interchain-token-service/src/contract/execute/interceptors.rs @@ -299,13 +299,22 @@ pub fn register_custom_token( register_token.token_address.clone(), ) .change_context(Error::State)?; - ensure!( - existing_token.is_none(), - Error::TokenAlreadyRegistered(register_token.token_address) - ); - state::save_custom_token_metadata(storage, source_chain, register_token) - .change_context(Error::State) + if let Some(existing_token) = existing_token { + ensure!( + existing_token.decimals == register_token.decimals, + Error::TokenDecimalsMismatch { + token_address: register_token.token_address, + existing_decimals: existing_token.decimals, + new_decimals: register_token.decimals + } + ); + } else { + state::save_custom_token_metadata(storage, source_chain, register_token) + .change_context(Error::State)?; + } + + Ok(()) } #[cfg(test)] @@ -313,14 +322,63 @@ mod test { use assert_ok::assert_ok; use axelar_wasm_std::assert_err_contains; use cosmwasm_std::testing::MockStorage; - use cosmwasm_std::Uint256; + use cosmwasm_std::{HexBinary, Uint256}; use router_api::ChainNameRaw; - use super::Error; + use super::{register_custom_token, Error}; use crate::contract::execute::interceptors; use crate::msg::TruncationConfig; use crate::state::{self, TokenDeploymentType}; - use crate::{msg, DeployInterchainToken, InterchainTransfer, TokenInstance}; + use crate::{ + msg, DeployInterchainToken, InterchainTransfer, RegisterTokenMetadata, TokenInstance, + }; + + #[test] + fn register_custom_token_allows_reregistration() { + let mut storage = MockStorage::new(); + let source_chain = ChainNameRaw::try_from("source-chain").unwrap(); + let register_token_msg = RegisterTokenMetadata { + decimals: 6, + token_address: HexBinary::from([0; 32]).try_into().unwrap(), + }; + assert_ok!(register_custom_token( + &mut storage, + source_chain.clone(), + register_token_msg.clone() + )); + assert_ok!(register_custom_token( + &mut storage, + source_chain, + register_token_msg + )); + } + + #[test] + fn register_custom_token_errors_on_decimals_mismatch() { + let mut storage = MockStorage::new(); + let source_chain = ChainNameRaw::try_from("source-chain").unwrap(); + let register_token_msg = RegisterTokenMetadata { + decimals: 6, + token_address: HexBinary::from([0; 32]).try_into().unwrap(), + }; + assert_ok!(register_custom_token( + &mut storage, + source_chain.clone(), + register_token_msg.clone() + )); + assert_err_contains!( + register_custom_token( + &mut storage, + source_chain, + RegisterTokenMetadata { + decimals: 12, + ..register_token_msg + } + ), + Error, + Error::TokenDecimalsMismatch { .. } + ); + } #[test] fn apply_scaling_factor_to_amount_when_source_decimals_are_bigger() { diff --git a/contracts/interchain-token-service/src/contract/execute/mod.rs b/contracts/interchain-token-service/src/contract/execute/mod.rs index 6e12f54d3..fa8837365 100644 --- a/contracts/interchain-token-service/src/contract/execute/mod.rs +++ b/contracts/interchain-token-service/src/contract/execute/mod.rs @@ -79,8 +79,12 @@ pub enum Error { }, #[error("token not registered {0}")] TokenNotRegistered(nonempty::HexBinary), - #[error("token already registered {0}")] - TokenAlreadyRegistered(nonempty::HexBinary), + #[error("attempted to register token {token_address} with {new_decimals} but already registered with {existing_decimals} decimals")] + TokenDecimalsMismatch { + token_address: nonempty::HexBinary, + existing_decimals: u8, + new_decimals: u8, + }, #[error("failed to query axelarnet gateway for chain name")] FailedToQueryAxelarnetGateway, } diff --git a/contracts/router/src/contract.rs b/contracts/router/src/contract.rs index 934773666..22f10e0c8 100644 --- a/contracts/router/src/contract.rs +++ b/contracts/router/src/contract.rs @@ -1,11 +1,10 @@ -use axelar_wasm_std::{address, killswitch, permission_control, FnExt}; +use axelar_wasm_std::{address, killswitch, migrate_from_version, permission_control, FnExt}; #[cfg(not(feature = "library"))] use cosmwasm_std::entry_point; use cosmwasm_std::{ to_json_binary, Addr, Binary, Deps, DepsMut, Env, MessageInfo, Response, Storage, }; use router_api::error::Error; -use semver::{Version, VersionReq}; use crate::contract::migrations::v1_1_1; use crate::events::RouterInstantiated; @@ -21,21 +20,13 @@ pub const CONTRACT_NAME: &str = env!("CARGO_PKG_NAME"); pub const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); #[cfg_attr(not(feature = "library"), entry_point)] +#[migrate_from_version("1.1")] pub fn migrate( deps: DepsMut, _env: Env, msg: MigrateMsg, ) -> Result { - let old_version = Version::parse(&cw2::get_contract_version(deps.storage)?.version)?; - let version_requirement = VersionReq::parse(">= 1.1.0, < 1.2.0")?; - assert!(version_requirement.matches(&old_version)); - v1_1_1::migrate(deps.storage, msg.chains_to_remove)?; - - // this needs to be the last thing to do during migration, - // because previous migration steps should check the old version - cw2::set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; - Ok(Response::default()) } diff --git a/packages/axelar-wasm-std-derive/Cargo.toml b/packages/axelar-wasm-std-derive/Cargo.toml index b597c5d8a..0bbf3248b 100644 --- a/packages/axelar-wasm-std-derive/Cargo.toml +++ b/packages/axelar-wasm-std-derive/Cargo.toml @@ -16,11 +16,14 @@ itertools = { workspace = true } proc-macro2 = { workspace = true } quote = { workspace = true } report = { workspace = true } +semver = { workspace = true } syn = { workspace = true } thiserror = { workspace = true } [dev-dependencies] -axelar-wasm-std = { workspace = true } +assert_ok = { workspace = true } +axelar-wasm-std = { workspace = true, features = ["derive"] } +cw2 = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/packages/axelar-wasm-std-derive/src/lib.rs b/packages/axelar-wasm-std-derive/src/lib.rs index 92a69a7f9..82fce2a32 100644 --- a/packages/axelar-wasm-std-derive/src/lib.rs +++ b/packages/axelar-wasm-std-derive/src/lib.rs @@ -5,6 +5,7 @@ use itertools::Itertools; use proc_macro::TokenStream; use proc_macro2::{Ident, Span, TokenStream as TokenStream2}; use quote::quote; +use syn::spanned::Spanned; use syn::{DeriveInput, FieldsNamed, Generics, ItemEnum, Variant}; #[proc_macro_derive(IntoContractError)] @@ -282,3 +283,165 @@ fn match_unit_variant(event_enum: &Ident, variant_name: &Ident) -> TokenStream2 #event_enum::#variant_name => cosmwasm_std::Event::new(#event_name) } } + +/// Attribute macro for handling contract version migrations. Must be applied to the `migrate` contract entry point. +/// Checks if migrating from the current version is supported and sets the new version. The base version must be a valid semver without patch, pre, or build. +/// +/// # Example +/// ``` +/// use cosmwasm_std::{ DepsMut, Env, Response, Empty}; +/// use axelar_wasm_std_derive::migrate_from_version; +/// +/// #[migrate_from_version("1.1")] +/// pub fn migrate( +/// deps: DepsMut, +/// _env: Env, +/// _msg: Empty, +/// ) -> Result { +/// // migration logic +/// Ok(Response::default()) +/// } +/// ``` +/// +/// ```compile_fail +/// # use cosmwasm_std::{ DepsMut, Env, Response, Empty}; +/// # use axelar_wasm_std_derive::migrate_from_version; +/// +/// # #[migrate_from_version("1.1")] // compilation error because the macro is not applied to a function `migrate` +/// # pub fn execute( +/// # deps: DepsMut, +/// # _env: Env, +/// # _msg: Empty, +/// # ) -> Result { +/// # Ok(Response::default()) +/// # } +/// ``` +/// +/// ```compile_fail +/// # use cosmwasm_std::{ Deps, Env, Response, Empty}; +/// # use axelar_wasm_std_derive::migrate_from_version; +/// +/// # #[migrate_from_version("1.1")] // compilation error because it cannot parse a `DepsMut` parameter +/// # pub fn migrate( +/// # deps: Deps, +/// # _env: Env, +/// # _msg: Empty, +/// # ) -> Result { +/// # Ok(Response::default()) +/// # } +/// ``` +/// +/// ```compile_fail +/// # use cosmwasm_std::{ DepsMut, Env, Response, Empty}; +/// # use axelar_wasm_std_derive::migrate_from_version; +/// +/// # #[migrate_from_version("~1.1.0")] // compilation error because the base version is not formatted correctly +/// # pub fn migrate( +/// # deps: DepsMut, +/// # _env: Env, +/// # _msg: Empty, +/// # ) -> Result { +/// # Ok(Response::default()) +/// # } +/// ``` +/// +#[proc_macro_attribute] +pub fn migrate_from_version(input: TokenStream, item: TokenStream) -> TokenStream { + let base_version_req = syn::parse_macro_input!(input as syn::LitStr); + let annotated_fn = syn::parse_macro_input!(item as syn::ItemFn); + + try_migrate_from_version(base_version_req, annotated_fn) + .unwrap_or_else(syn::Error::into_compile_error) + .into() +} + +fn try_migrate_from_version( + base_version: syn::LitStr, + annotated_fn: syn::ItemFn, +) -> syn::Result { + let fn_name = &annotated_fn.sig.ident; + let fn_inputs = &annotated_fn.sig.inputs; + let fn_output = &annotated_fn.sig.output; + let fn_block = &annotated_fn.block; + + let base_semver_req = base_semver_req(&base_version)?; + let deps = validate_migrate_signature(&annotated_fn.sig)?; + + let gen = quote! { + pub fn #fn_name(#fn_inputs) #fn_output { + let pkg_name = env!("CARGO_PKG_NAME"); + let pkg_version = env!("CARGO_PKG_VERSION"); + + let contract_version = cw2::get_contract_version(#deps.storage)?; + assert_eq!(contract_version.contract, pkg_name, "contract name mismatch: actual {}, expected {}", contract_version.contract, pkg_name); + + let curr_version = semver::Version::parse(&contract_version.version)?; + let version_requirement = semver::VersionReq::parse(#base_semver_req)?; + assert!(version_requirement.matches(&curr_version), "base version {} does not match {} version requirement", curr_version, #base_semver_req); + + cw2::set_contract_version(#deps.storage, pkg_name, pkg_version)?; + + #fn_block + } + }; + + Ok(gen) +} + +fn base_semver_req(base_version: &syn::LitStr) -> syn::Result { + let base_semver = semver::Version::parse(&format!("{}.0", base_version.value())) + .map_err(|_| syn::Error::new(base_version.span(), "base version format must be semver without patch, pre, or build. Example: '1.2'")) + .and_then(|version| { + if version.patch == 0 && version.pre.is_empty() && version.build.is_empty() { + Ok(version) + } else { + Err(syn::Error::new(base_version.span(), "base version format must be semver without patch, pre, or build. Example: '1.2'")) + } + })?; + + Ok(format!("~{}.{}.0", base_semver.major, base_semver.minor)) +} + +fn validate_migrate_signature(sig: &syn::Signature) -> syn::Result { + if sig.ident != "migrate" + || sig.inputs.len() != 3 + || !matches!(sig.output, syn::ReturnType::Type(_, _)) + { + return Err(syn::Error::new( + sig.ident.span(), + "invalid function signature for 'migrate' entry point", + )); + } + + validate_migrate_param(&sig.inputs[1], "Env")?; + validate_migrate_param(&sig.inputs[0], "DepsMut") +} + +fn validate_migrate_param(param: &syn::FnArg, expected_type: &str) -> syn::Result { + let (ty, pat) = match param { + syn::FnArg::Typed(syn::PatType { ty, pat, .. }) => (ty, pat), + _ => { + return Err(syn::Error::new( + param.span(), + format!( + "parameter for 'migrate' entry point expected to be of type {}", + expected_type + ), + )); + } + }; + match (&**ty, &**pat) { + (syn::Type::Path(syn::TypePath { path, .. }), syn::Pat::Ident(pat_ident)) + if path.is_ident(expected_type) => + { + Ok(pat_ident.ident.clone()) + } + _ => Err(syn::Error::new( + ty.span(), + format!( + "parameter for 'migrate' entry point expected to be of type {}", + expected_type + ), + )), + } +} diff --git a/packages/axelar-wasm-std-derive/tests/derive.rs b/packages/axelar-wasm-std-derive/tests/derive.rs index e5e17f8f6..bd6a955da 100644 --- a/packages/axelar-wasm-std-derive/tests/derive.rs +++ b/packages/axelar-wasm-std-derive/tests/derive.rs @@ -1,5 +1,8 @@ +use assert_ok::assert_ok; use axelar_wasm_std::error::ContractError; -use axelar_wasm_std::IntoContractError; +use axelar_wasm_std::{migrate_from_version, IntoContractError}; +use cosmwasm_std::testing::{mock_dependencies, mock_env}; +use cosmwasm_std::{DepsMut, Empty, Env, Response}; use thiserror::Error; #[derive(Error, Debug, IntoContractError)] @@ -12,3 +15,55 @@ enum TestError { fn can_convert_error() { _ = ContractError::from(TestError::Something); } + +#[migrate_from_version("999.1")] +pub fn migrate( + deps: DepsMut, + _env: Env, + _msg: Empty, +) -> Result { + // migration logic + deps.storage.set(b"key", b"migrated value"); + Ok(Response::default()) +} + +#[test] +fn should_handle_version_migration() { + let mut deps = mock_dependencies(); + + let base_contract = env!("CARGO_PKG_NAME"); + let base_version = "999.1.1"; + cw2::set_contract_version(deps.as_mut().storage, base_contract, base_version).unwrap(); + deps.as_mut().storage.set(b"key", b"original value"); + + migrate(deps.as_mut(), mock_env(), Empty {}).unwrap(); + + let contract_version = assert_ok!(cw2::get_contract_version(deps.as_ref().storage)); + assert_eq!(contract_version.contract, base_contract); + assert_eq!(contract_version.version, env!("CARGO_PKG_VERSION")); + + let migrated_value = deps.as_ref().storage.get(b"key").unwrap(); + assert_eq!(migrated_value, b"migrated value") +} + +#[test] +#[should_panic(expected = "base version 999.2.1 does not match ~999.1.0 version requirement")] +fn should_fail_version_migration_if_not_supported() { + let mut deps = mock_dependencies(); + let base_contract = env!("CARGO_PKG_NAME"); + let base_version = "999.2.1"; + cw2::set_contract_version(deps.as_mut().storage, base_contract, base_version).unwrap(); + + migrate(deps.as_mut(), mock_env(), Empty {}).unwrap(); +} + +#[test] +#[should_panic(expected = "contract name mismatch: actual wrong-base-contract, expected ")] +fn should_fail_version_migration_using_wrong_contract() { + let mut deps = mock_dependencies(); + let base_contract = "wrong-base-contract"; + let base_version = "999.1.1"; + cw2::set_contract_version(deps.as_mut().storage, base_contract, base_version).unwrap(); + + migrate(deps.as_mut(), mock_env(), Empty {}).unwrap(); +}