diff --git a/Cargo.lock b/Cargo.lock index 2d1f648f..2f471560 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -27,9 +27,9 @@ checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" [[package]] name = "base64" -version = "0.21.5" +version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35636a1494ede3b646cc98f74f8e62c773a38a659ebc777a2cf26b9b74171df9" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" [[package]] name = "base64ct" @@ -63,9 +63,9 @@ dependencies = [ [[package]] name = "bnum" -version = "0.8.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab9008b6bb9fc80b5277f2fe481c09e828743d9151203e804583eb4c9e15b31d" +checksum = "56953345e39537a3e18bdaeba4cb0c58a78c1f61f361dc0fa7c5c7340ae87c5f" [[package]] name = "byteorder" @@ -93,9 +93,9 @@ checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" [[package]] name = "cosmwasm-crypto" -version = "1.5.0" +version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8bb3c77c3b7ce472056968c745eb501c440fbc07be5004eba02782c35bfbbe3" +checksum = "9934c79e58d9676edfd592557dee765d2a6ef54c09d5aa2edb06156b00148966" dependencies = [ "digest 0.10.7", "ecdsa", @@ -107,18 +107,18 @@ dependencies = [ [[package]] name = "cosmwasm-derive" -version = "1.5.0" +version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fea73e9162e6efde00018d55ed0061e93a108b5d6ec4548b4f8ce3c706249687" +checksum = "bc5e72e330bd3bdab11c52b5ecbdeb6a8697a004c57964caeb5d876f0b088b3c" dependencies = [ "syn 1.0.109", ] [[package]] name = "cosmwasm-schema" -version = "1.5.0" +version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0df41ea55f2946b6b43579659eec048cc2f66e8c8e2e3652fc5e5e476f673856" +checksum = "ac3e3a2136e2a60e8b6582f5dffca5d1a683ed77bf38537d330bc1dfccd69010" dependencies = [ "cosmwasm-schema-derive", "schemars", @@ -129,9 +129,9 @@ dependencies = [ [[package]] name = "cosmwasm-schema-derive" -version = "1.5.0" +version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43609e92ce1b9368aa951b334dd354a2d0dd4d484931a5f83ae10e12a26c8ba9" +checksum = "f5d803bea6bd9ed61bd1ee0b4a2eb09ee20dbb539cc6e0b8795614d20952ebb1" dependencies = [ "proc-macro2", "quote", @@ -140,9 +140,9 @@ dependencies = [ [[package]] name = "cosmwasm-std" -version = "1.5.0" +version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04d6864742e3a7662d024b51a94ea81c9af21db6faea2f9a6d2232bb97c6e53e" +checksum = "ef8666e572a3a2519010dde88c04d16e9339ae751b56b2bb35081fe3f7d6be74" dependencies = [ "base64", "bech32", @@ -162,9 +162,9 @@ dependencies = [ [[package]] name = "cosmwasm-storage" -version = "1.5.0" +version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd2b4ae72a03e8f56c85df59d172d51d2d7dc9cec6e2bc811e3fb60c588032a4" +checksum = "66de2ab9db04757bcedef2b5984fbe536903ada4a8a9766717a4a71197ef34f6" dependencies = [ "cosmwasm-std", "serde", @@ -172,9 +172,9 @@ dependencies = [ [[package]] name = "cpufeatures" -version = "0.2.11" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce420fe07aecd3e67c5f910618fe65e94158f6dcc0adf44e00d69ce2bdfe0fd0" +checksum = "53fe5e26ff1b7aef8bca9c6080520cfb8d9333c7568e1829cef191a9723e5504" dependencies = [ "libc", ] @@ -234,14 +234,14 @@ dependencies = [ [[package]] name = "cw-ics721-incoming-proxy" version = "0.1.0" -source = "git+https://github.com/arkprotocol/cw-ics721-proxy.git?tag=v0.1.0#cc1c156b1c0d2941c62b4291444674baf1ec855b" +source = "git+https://github.com/arkprotocol/cw-ics721-proxy.git?tag=v0.1.0#58ac2ad8dcf70751975758d8b8925f5b009ddaa2" dependencies = [ "cosmwasm-schema", "cosmwasm-std", "cw-ics721-incoming-proxy-derive", "cw-paginate-storage", "cw-storage-plus 1.2.0", - "ics721-types 0.1.0 (git+https://github.com/arkprotocol/ark-cw-ics721?branch=incoming_proxy)", + "ics721-types 0.1.0 (git+https://github.com/public-awesome/cw-ics721?tag=v0.1.5)", "schemars", "serde", "thiserror", @@ -250,7 +250,7 @@ dependencies = [ [[package]] name = "cw-ics721-incoming-proxy-base" version = "0.1.0" -source = "git+https://github.com/arkprotocol/cw-ics721-proxy.git?tag=v0.1.0#cc1c156b1c0d2941c62b4291444674baf1ec855b" +source = "git+https://github.com/arkprotocol/cw-ics721-proxy.git?tag=v0.1.0#58ac2ad8dcf70751975758d8b8925f5b009ddaa2" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -261,7 +261,7 @@ dependencies = [ "cw-storage-plus 1.2.0", "cw2 1.1.2", "cw721 0.18.0 (registry+https://github.com/rust-lang/crates.io-index)", - "ics721-types 0.1.0 (git+https://github.com/arkprotocol/ark-cw-ics721?branch=incoming_proxy)", + "ics721-types 0.1.0 (git+https://github.com/public-awesome/cw-ics721?tag=v0.1.5)", "serde", "thiserror", ] @@ -269,11 +269,11 @@ dependencies = [ [[package]] name = "cw-ics721-incoming-proxy-derive" version = "0.1.0" -source = "git+https://github.com/arkprotocol/cw-ics721-proxy.git?tag=v0.1.0#cc1c156b1c0d2941c62b4291444674baf1ec855b" +source = "git+https://github.com/arkprotocol/cw-ics721-proxy.git?tag=v0.1.0#58ac2ad8dcf70751975758d8b8925f5b009ddaa2" dependencies = [ "cosmwasm-schema", "cosmwasm-std", - "ics721-types 0.1.0 (git+https://github.com/arkprotocol/ark-cw-ics721?branch=incoming_proxy)", + "ics721-types 0.1.0 (git+https://github.com/public-awesome/cw-ics721?tag=v0.1.5)", "proc-macro2", "quote", "syn 1.0.109", @@ -282,7 +282,7 @@ dependencies = [ [[package]] name = "cw-ics721-outgoing-proxy" version = "0.1.0" -source = "git+https://github.com/arkprotocol/cw-ics721-proxy.git?tag=v0.1.0#cc1c156b1c0d2941c62b4291444674baf1ec855b" +source = "git+https://github.com/arkprotocol/cw-ics721-proxy.git?tag=v0.1.0#58ac2ad8dcf70751975758d8b8925f5b009ddaa2" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -290,14 +290,14 @@ dependencies = [ "cw-storage-plus 1.2.0", "cw721 0.18.0 (registry+https://github.com/rust-lang/crates.io-index)", "cw721-base 0.18.0 (registry+https://github.com/rust-lang/crates.io-index)", - "ics721-types 0.1.0 (git+https://github.com/arkprotocol/ark-cw-ics721?branch=incoming_proxy)", + "ics721-types 0.1.0 (git+https://github.com/public-awesome/cw-ics721?tag=v0.1.5)", "thiserror", ] [[package]] name = "cw-ics721-outgoing-proxy-derive" version = "0.1.0" -source = "git+https://github.com/arkprotocol/cw-ics721-proxy.git?tag=v0.1.0#cc1c156b1c0d2941c62b4291444674baf1ec855b" +source = "git+https://github.com/arkprotocol/cw-ics721-proxy.git?tag=v0.1.0#58ac2ad8dcf70751975758d8b8925f5b009ddaa2" dependencies = [ "cw721 0.18.0 (registry+https://github.com/rust-lang/crates.io-index)", "proc-macro2", @@ -308,7 +308,7 @@ dependencies = [ [[package]] name = "cw-ics721-outgoing-proxy-rate-limit" version = "0.1.0" -source = "git+https://github.com/arkprotocol/cw-ics721-proxy.git?tag=v0.1.0#cc1c156b1c0d2941c62b4291444674baf1ec855b" +source = "git+https://github.com/arkprotocol/cw-ics721-proxy.git?tag=v0.1.0#58ac2ad8dcf70751975758d8b8925f5b009ddaa2" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -319,7 +319,7 @@ dependencies = [ "cw2 1.1.2", "cw721 0.18.0 (registry+https://github.com/rust-lang/crates.io-index)", "cw721-base 0.18.0 (registry+https://github.com/rust-lang/crates.io-index)", - "ics721-types 0.1.0 (git+https://github.com/arkprotocol/ark-cw-ics721?branch=incoming_proxy)", + "ics721-types 0.1.0 (git+https://github.com/public-awesome/cw-ics721?tag=v0.1.5)", "serde", "thiserror", ] @@ -336,7 +336,7 @@ dependencies = [ "cw-storage-plus 1.2.0", "cw-utils 1.0.3", "derivative", - "itertools 0.12.0", + "itertools 0.12.1", "prost", "schemars", "serde", @@ -373,10 +373,9 @@ dependencies = [ [[package]] name = "cw-paginate-storage" version = "2.4.0" -source = "git+https://github.com/DA0-DA0/dao-contracts.git#3ead037e9366d922c862614974b16b4dc732b329" +source = "git+https://github.com/DA0-DA0/dao-contracts.git#43eccded799ea63ea19a81ed64054848e689ff73" dependencies = [ "cosmwasm-std", - "cosmwasm-storage", "cw-storage-plus 1.2.0", "serde", ] @@ -395,7 +394,7 @@ dependencies = [ [[package]] name = "cw-rate-limiter" version = "0.1.0" -source = "git+https://github.com/arkprotocol/cw-ics721-proxy.git?tag=v0.1.0#cc1c156b1c0d2941c62b4291444674baf1ec855b" +source = "git+https://github.com/arkprotocol/cw-ics721-proxy.git?tag=v0.1.0#58ac2ad8dcf70751975758d8b8925f5b009ddaa2" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -514,7 +513,7 @@ dependencies = [ [[package]] name = "cw721" version = "0.18.0" -source = "git+https://github.com/CosmWasm/cw-nfts?branch=main#cc27e27d54e237709ebdcd478568126e109a8845" +source = "git+https://github.com/CosmWasm/cw-nfts?branch=main#e63a7bbb620e6ea39224bb5580967477066cb7ae" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -562,7 +561,7 @@ dependencies = [ [[package]] name = "cw721-base" version = "0.18.0" -source = "git+https://github.com/CosmWasm/cw-nfts?branch=main#cc27e27d54e237709ebdcd478568126e109a8845" +source = "git+https://github.com/CosmWasm/cw-nfts?branch=main#e63a7bbb620e6ea39224bb5580967477066cb7ae" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -729,9 +728,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.11" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe9006bed769170c11f845cf00c7c1e9092aeb3f268e007c3e760ac68008070f" +checksum = "190092ea657667030ac6a35e305e62fc4dd69fd98ac98631e5d3a2b1575a12b5" dependencies = [ "cfg-if", "libc", @@ -841,7 +840,7 @@ dependencies = [ [[package]] name = "ics721-types" version = "0.1.0" -source = "git+https://github.com/arkprotocol/ark-cw-ics721?branch=incoming_proxy#1fadd3b4869b7cdcbc54128d34ad86b2fdfed0b2" +source = "git+https://github.com/public-awesome/cw-ics721?tag=v0.1.5#f1dfefc71c3ace567a5b79e98100ee17d9cfcc5d" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -872,9 +871,9 @@ dependencies = [ [[package]] name = "itertools" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25db6b064527c5d482d0423354fcd07a89a2dfe07b67892e62411946db7f07b0" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" dependencies = [ "either", ] @@ -887,9 +886,9 @@ checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c" [[package]] name = "k256" -version = "0.13.2" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f01b677d82ef7a676aa37e099defd83a28e15687112cafdd112d60236b6115b" +checksum = "cadb76004ed8e97623117f3df85b17aaa6626ab0b0831e6573f104df16cd1bcc" dependencies = [ "cfg-if", "ecdsa", @@ -901,9 +900,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.151" +version = "0.2.153" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "302d7ab3130588088d277783b1e2d2e10c9e9e4a16dd9050e6ec93fb3e7048f4" +checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" [[package]] name = "once_cell" @@ -935,9 +934,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.74" +version = "1.0.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2de98502f212cfcea8d0bb305bd0f49d7ebdd75b64ba0a68f937d888f4e0d6db" +checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae" dependencies = [ "unicode-ident", ] @@ -962,7 +961,7 @@ dependencies = [ "itertools 0.11.0", "proc-macro2", "quote", - "syn 2.0.46", + "syn 2.0.48", ] [[package]] @@ -1051,31 +1050,31 @@ checksum = "b97ed7a9823b74f99c7742f5336af7be5ecd3eeafcb1507d1fa93347b1d589b0" [[package]] name = "serde" -version = "1.0.194" +version = "1.0.196" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b114498256798c94a0689e1a15fec6005dee8ac1f41de56404b67afc2a4b773" +checksum = "870026e60fa08c69f064aa766c10f10b1d62db9ccd4d0abb206472bee0ce3b32" dependencies = [ "serde_derive", ] [[package]] name = "serde-json-wasm" -version = "0.5.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16a62a1fad1e1828b24acac8f2b468971dade7b8c3c2e672bcadefefb1f8c137" +checksum = "9e9213a07d53faa0b8dd81e767a54a8188a242fdb9be99ab75ec576a774bfdd7" dependencies = [ "serde", ] [[package]] name = "serde_derive" -version = "1.0.194" +version = "1.0.196" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3385e45322e8f9931410f01b3031ec534c3947d0e94c18049af4d9f9907d4e0" +checksum = "33c85360c95e7d137454dc81d9a4ed2b8efd8fbe19cee57357b32b9771fccb67" dependencies = [ "proc-macro2", "quote", - "syn 2.0.46", + "syn 2.0.48", ] [[package]] @@ -1091,9 +1090,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.110" +version = "1.0.113" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fbd975230bada99c8bb618e0c365c2eefa219158d5c6c29610fd09ff1833257" +checksum = "69801b70b1c3dac963ecb03a364ba0ceda9cf60c71cfe475e99864759c8b8a79" dependencies = [ "itoa", "ryu", @@ -1142,9 +1141,9 @@ dependencies = [ [[package]] name = "sg721" -version = "3.3.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65af43465c79bc5a2af7b41a526876cd757a6ba2a9051bbeb2f95133285f89dc" +checksum = "8f59f52a646afc7e20dd55a873df667c6c995deb7495c6cf9b0f3d8f340dd227" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -1157,9 +1156,9 @@ dependencies = [ [[package]] name = "sg721-base" -version = "3.3.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcaffbe4a8d278a54c3ac4dcce30aa971cd55bce8184b43b6344a45cff9eea48" +checksum = "0e903e3e9bd2f8641d03a7ef3e9e40a7188f655d9e1cdfd220ba7c01e8d0b35b" dependencies = [ "cosmwasm-schema", "cosmwasm-std", @@ -1245,9 +1244,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.46" +version = "2.0.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89456b690ff72fddcecf231caedbe615c59480c93358a93dfae7fc29e3ebbf0e" +checksum = "0f3531638e407dfc0814761abb7c00a5b54992b849452a0646b7f65c9f770f3f" dependencies = [ "proc-macro2", "quote", @@ -1271,7 +1270,7 @@ checksum = "fa0faa943b50f3db30a20aa7e265dbc66076993efed8463e8de414e5d06d3471" dependencies = [ "proc-macro2", "quote", - "syn 2.0.46", + "syn 2.0.48", ] [[package]] @@ -1297,9 +1296,9 @@ checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" [[package]] name = "unicode-bidi" -version = "0.3.14" +version = "0.3.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f2528f27a9eb2b21e69c95319b30bd0efd85d09c379741b0f78ea1d86be2416" +checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75" [[package]] name = "unicode-ident" diff --git a/contracts/cw721-tester/src/lib.rs b/contracts/cw721-tester/src/lib.rs index ed686b1e..d7b74e5a 100644 --- a/contracts/cw721-tester/src/lib.rs +++ b/contracts/cw721-tester/src/lib.rs @@ -1,7 +1,7 @@ use cosmwasm_schema::cw_serde; #[cfg(not(feature = "library"))] use cosmwasm_std::entry_point; -use cosmwasm_std::{Addr, Binary, Deps, DepsMut, Empty, Env, MessageInfo, Response, StdResult}; +use cosmwasm_std::{Binary, Deps, DepsMut, Empty, Env, MessageInfo, Response, StdResult}; use cw2::set_contract_version; use cw721_base::{msg, ContractError, Extension}; use cw_storage_plus::Item; @@ -14,13 +14,13 @@ pub struct InstantiateMsg { pub name: String, pub symbol: String, pub minter: String, - /// An address which will be unable to transfer NFTs away from - /// themselves (they are a black hole). If this address attempts a - /// `TransferNft` message it will fail with an out-of-gas error. - pub target: String, + /// An address which will be unable receive NFT on `TransferNft` message + /// If `TransferNft` message attempts sending to banned recipient + /// it will fail with an out-of-gas error. + pub banned_recipient: String, } -const TARGET: Item = Item::new("target"); +const BANNED_RECIPIENT: Item = Item::new("banned_recipient"); const CONTRACT_NAME: &str = "crates.io:cw721-gas-tester"; const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -39,11 +39,11 @@ pub fn instantiate( msg::InstantiateMsg { name: msg.name, symbol: msg.symbol, - minter: msg.minter, + minter: Some(msg.minter), withdraw_address: None, }, )?; - TARGET.save(deps.storage, &deps.api.addr_validate(&msg.target)?)?; + BANNED_RECIPIENT.save(deps.storage, &msg.banned_recipient)?; set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; Ok(response) @@ -56,13 +56,17 @@ pub fn execute( info: MessageInfo, msg: ExecuteMsg, ) -> Result { - if matches!(msg, ExecuteMsg::TransferNft { .. }) && info.sender == TARGET.load(deps.storage)? { - // loop here causes the relayer to hang while it tries to - // simulate the TX. - panic!("gotem") - // loop {} - } else { - cw721_base::entry::execute(deps, env, info, msg) + match msg.clone() { + ExecuteMsg::TransferNft { recipient, .. } => { + if recipient == BANNED_RECIPIENT.load(deps.storage)? { + // loop here causes the relayer to hang while it tries to + // simulate the TX. + panic!("gotem") + // loop {} + } + cw721_base::entry::execute(deps, env, info, msg) + } + _ => cw721_base::entry::execute(deps, env, info, msg), } } diff --git a/contracts/sg-ics721/src/execute.rs b/contracts/sg-ics721/src/execute.rs index a937700d..2bcfba51 100644 --- a/contracts/sg-ics721/src/execute.rs +++ b/contracts/sg-ics721/src/execute.rs @@ -4,7 +4,7 @@ use ics721_types::token_types::Class; use sg721_base::msg::{CollectionInfoResponse, QueryMsg}; -use crate::state::{SgCollectionData, SgIcs721Contract}; +use crate::state::{SgCollectionData, SgIcs721Contract, STARGAZE_ICON_PLACEHOLDER}; impl Ics721Execute for SgIcs721Contract { type ClassData = SgCollectionData; @@ -48,7 +48,8 @@ impl Ics721Execute for SgIcs721Contract { // therefore, we use ics721 creator as owner creator: ics721_contract_info.creator, description: "".to_string(), - image: "https://arkprotocol.io".to_string(), + // use Stargaze icon as placeholder + image: STARGAZE_ICON_PLACEHOLDER.to_string(), external_link: None, explicit_content: None, start_trading_time: None, diff --git a/contracts/sg-ics721/src/state.rs b/contracts/sg-ics721/src/state.rs index 3c8b32cd..e2f7c731 100644 --- a/contracts/sg-ics721/src/state.rs +++ b/contracts/sg-ics721/src/state.rs @@ -2,6 +2,9 @@ use cosmwasm_schema::cw_serde; use cosmwasm_std::ContractInfoResponse; use sg721_base::msg::CollectionInfoResponse; +pub const STARGAZE_ICON_PLACEHOLDER: &str = + "ipfs://bafkreie5vwrm5zts4wiq6ebtopmztgl5qzyl4uszyllgwpaizyc5w2uycm"; + /// Collection data provided by the (source) cw721 contract. This is pass as optional class data during interchain transfer to target chain. /// ICS721 on target chain is free to use this data or not. Lik in case of `sg721-base` it uses owner for defining creator in collection info. #[cw_serde] diff --git a/contracts/sg-ics721/src/testing/integration_tests.rs b/contracts/sg-ics721/src/testing/integration_tests.rs index 90cb9fa8..f5af98a7 100644 --- a/contracts/sg-ics721/src/testing/integration_tests.rs +++ b/contracts/sg-ics721/src/testing/integration_tests.rs @@ -30,7 +30,10 @@ use sg721::InstantiateMsg as Sg721InstantiateMsg; use sg721_base::msg::{CollectionInfoResponse, QueryMsg as Sg721QueryMsg}; use sha2::{digest::Update, Digest, Sha256}; -use crate::{state::SgCollectionData, ContractError, SgIcs721Contract}; +use crate::{ + state::{SgCollectionData, STARGAZE_ICON_PLACEHOLDER}, + ContractError, SgIcs721Contract, +}; const ICS721_CREATOR: &str = "ics721-creator"; const CONTRACT_NAME: &str = "crates.io:sg-ics721"; @@ -338,6 +341,9 @@ impl Test { false => None, }; + let admin = admin_and_pauser + .clone() + .map(|p| app.api().addr_make(&p).to_string()); let ics721 = app .instantiate_contract( ics721_id, @@ -346,9 +352,8 @@ impl Test { cw721_base_code_id: source_cw721_id, incoming_proxy, outgoing_proxy, - pauser: admin_and_pauser - .clone() - .map(|p| app.api().addr_make(&p).to_string()), + pauser: admin.clone(), + cw721_admin: admin, }, &[], "sg-ics721", @@ -369,7 +374,7 @@ impl Test { collection_info: sg721::CollectionInfo { creator: source_cw721_owner.to_string(), description: "".to_string(), - image: "https://arkprotocol.io".to_string(), + image: STARGAZE_ICON_PLACEHOLDER.to_string(), external_link: None, explicit_content: None, start_trading_time: None, @@ -452,6 +457,13 @@ impl Test { .unwrap() } + fn query_cw721_admin(&mut self) -> Option { + self.app + .wrap() + .query_wasm_smart(self.ics721.clone(), &QueryMsg::Cw721Admin {}) + .unwrap() + } + fn query_nft_contracts(&mut self) -> Vec<(String, Addr)> { self.app .wrap() @@ -719,7 +731,7 @@ fn test_do_instantiate_and_mint() { // creator of ics721 contract is also creator of collection, since no owner in ClassData provided creator: test.app.api().addr_make(ICS721_CREATOR).to_string(), description: "".to_string(), - image: "https://arkprotocol.io".to_string(), + image: STARGAZE_ICON_PLACEHOLDER.to_string(), external_link: None, explicit_content: None, start_trading_time: None, @@ -895,7 +907,7 @@ fn test_do_instantiate_and_mint() { // creator based on owner from collection in soure chain creator: test.app.api().addr_make(ICS721_CREATOR).to_string(), description: "".to_string(), - image: "https://arkprotocol.io".to_string(), + image: STARGAZE_ICON_PLACEHOLDER.to_string(), external_link: None, explicit_content: None, start_trading_time: None, @@ -1069,7 +1081,7 @@ fn test_do_instantiate_and_mint() { // creator of ics721 contract is creator of nft contract, since no owner in ClassData provided creator: test.app.api().addr_make(ICS721_CREATOR).to_string(), description: "".to_string(), - image: "https://arkprotocol.io".to_string(), + image: STARGAZE_ICON_PLACEHOLDER.to_string(), external_link: None, explicit_content: None, start_trading_time: None, @@ -1247,7 +1259,7 @@ fn test_do_instantiate_and_mint() { // creator of ics721 contract is creator of nft contract, since no owner in ClassData provided creator: test.app.api().addr_make(ICS721_CREATOR).to_string(), description: "".to_string(), - image: "https://arkprotocol.io".to_string(), + image: STARGAZE_ICON_PLACEHOLDER.to_string(), external_link: None, explicit_content: None, start_trading_time: None, @@ -1425,7 +1437,7 @@ fn test_do_instantiate_and_mint() { // creator of ics721 contract is creator of nft contract, since no owner in ClassData provided creator: test.app.api().addr_make(ICS721_CREATOR).to_string(), description: "".to_string(), - image: "https://arkprotocol.io".to_string(), + image: STARGAZE_ICON_PLACEHOLDER.to_string(), external_link: None, explicit_content: None, start_trading_time: None, @@ -1657,7 +1669,7 @@ fn test_do_instantiate_and_mint_2_different_collections() { // creator of ics721 contract is also creator of collection, since no owner in ClassData provided creator: test.app.api().addr_make(ICS721_CREATOR).to_string(), description: "".to_string(), - image: "https://arkprotocol.io".to_string(), + image: STARGAZE_ICON_PLACEHOLDER.to_string(), external_link: None, explicit_content: None, start_trading_time: None, @@ -1670,7 +1682,7 @@ fn test_do_instantiate_and_mint_2_different_collections() { // creator of ics721 contract is also creator of collection, since no owner in ClassData provided creator: test.app.api().addr_make(ICS721_CREATOR).to_string(), description: "".to_string(), - image: "https://arkprotocol.io".to_string(), + image: STARGAZE_ICON_PLACEHOLDER.to_string(), external_link: None, explicit_content: None, start_trading_time: None, @@ -1917,7 +1929,7 @@ fn test_do_instantiate_and_mint_no_instantiate() { CollectionInfoResponse { creator: test.app.api().addr_make(ICS721_CREATOR).to_string(), description: "".to_string(), - image: "https://arkprotocol.io".to_string(), + image: STARGAZE_ICON_PLACEHOLDER.to_string(), external_link: None, explicit_content: None, start_trading_time: None, @@ -2099,7 +2111,7 @@ fn test_proxy_authorized() { .addr_make(COLLECTION_OWNER_SOURCE_CHAIN) .to_string(), description: "".to_string(), - image: "https://arkprotocol.io".to_string(), + image: STARGAZE_ICON_PLACEHOLDER.to_string(), external_link: None, explicit_content: None, start_trading_time: None, @@ -2225,7 +2237,7 @@ fn test_receive_nft() { collection_info: Some(CollectionInfoResponse { creator: test.ics721.to_string(), description: "".to_string(), - image: "https://arkprotocol.io".to_string(), + image: STARGAZE_ICON_PLACEHOLDER.to_string(), external_link: None, explicit_content: None, start_trading_time: None, @@ -2337,6 +2349,7 @@ fn test_pause() { incoming_proxy: None, outgoing_proxy: None, cw721_base_code_id: None, + cw721_admin: None, }) .unwrap(), } @@ -2378,9 +2391,10 @@ fn test_migration() { assert_eq!(cw721_code_id, test.source_cw721_id); // migrate changes + let admin = test.app.api().addr_make(ICS721_ADMIN_AND_PAUSER); test.app .execute( - test.app.api().addr_make(ICS721_ADMIN_AND_PAUSER), + admin.clone(), WasmMsg::Migrate { contract_addr: test.ics721.to_string(), new_code_id: test.ics721_id, @@ -2389,6 +2403,7 @@ fn test_migration() { incoming_proxy: None, outgoing_proxy: None, cw721_base_code_id: Some(12345678), + cw721_admin: Some(admin.to_string()), }) .unwrap(), } @@ -2402,6 +2417,7 @@ fn test_migration() { assert!(proxy.is_none()); let cw721_code_id = test.query_cw721_id(); assert_eq!(cw721_code_id, 12345678); + assert_eq!(test.query_cw721_admin(), Some(admin),); // migrate without changing code id test.app @@ -2415,6 +2431,7 @@ fn test_migration() { incoming_proxy: None, outgoing_proxy: None, cw721_base_code_id: None, + cw721_admin: Some("".to_string()), }) .unwrap(), } @@ -2428,4 +2445,5 @@ fn test_migration() { assert!(proxy.is_none()); let cw721_code_id = test.query_cw721_id(); assert_eq!(cw721_code_id, 12345678); + assert_eq!(test.query_cw721_admin(), None,); } diff --git a/justfile b/justfile index cdb51921..d8674d1c 100644 --- a/justfile +++ b/justfile @@ -1,7 +1,7 @@ set dotenv-load platform := if arch() =~ "aarch64" {"linux/arm64"} else {"linux/amd64"} -image := if arch() =~ "aarch64" {"cosmwasm/workspace-optimizer-arm64:0.14.0"} else {"cosmwasm/workspace-optimizer:0.14.0"} +image := if arch() =~ "aarch64" {"cosmwasm/workspace-optimizer-arm64:0.15.0"} else {"cosmwasm/workspace-optimizer:0.15.0"} alias log := optimize-watch diff --git a/packages/ics721/schema/ics721.json b/packages/ics721/schema/ics721.json index 863f9db2..56202f8d 100644 --- a/packages/ics721/schema/ics721.json +++ b/packages/ics721/schema/ics721.json @@ -10,6 +10,13 @@ "cw721_base_code_id" ], "properties": { + "cw721_admin": { + "description": "The admin address for instantiating new cw721 contracts. In case of None, contract is immutable.", + "type": [ + "string", + "null" + ] + }, "cw721_base_code_id": { "description": "Code ID of cw721-ics contract. A new cw721-ics will be instantiated for each new IBCd NFT classID.\n\nNOTE: this _must_ correspond to the cw721-base contract. Using a regular cw721 may cause the ICS 721 interface implemented by this contract to stop working, and IBCd away NFTs to be unreturnable as cw721 does not have a mint method in the spec.", "type": "integer", @@ -169,23 +176,66 @@ "additionalProperties": false }, { + "description": "Admin msg in case something goes wrong. As a minimum it clean up states (incoming channel and token metadata), and burn NFT if exists.", "type": "object", "required": [ - "receive_proxy_nft" + "admin_clean_and_burn_nft" ], "properties": { - "receive_proxy_nft": { + "admin_clean_and_burn_nft": { "type": "object", "required": [ - "eyeball", - "msg" + "class_id", + "collection", + "owner", + "token_id" ], "properties": { - "eyeball": { + "class_id": { + "type": "string" + }, + "collection": { + "type": "string" + }, + "owner": { "type": "string" }, - "msg": { - "$ref": "#/definitions/Cw721ReceiveMsg" + "token_id": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Admin msg in case something goes wrong. As a minimum it clean up state (outgoing channel), and transfer NFT if exists. - transfer NFT if exists", + "type": "object", + "required": [ + "admin_clean_and_unescrow_nft" + ], + "properties": { + "admin_clean_and_unescrow_nft": { + "type": "object", + "required": [ + "class_id", + "collection", + "recipient", + "token_id" + ], + "properties": { + "class_id": { + "type": "string" + }, + "collection": { + "type": "string" + }, + "recipient": { + "type": "string" + }, + "token_id": { + "type": "string" } }, "additionalProperties": false @@ -263,6 +313,68 @@ }, "additionalProperties": false }, + { + "description": "Redeem all entries in outgoing channel.", + "type": "object", + "required": [ + "redeem_outgoing_channel_entries" + ], + "properties": { + "redeem_outgoing_channel_entries": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "$ref": "#/definitions/ClassId" + }, + { + "$ref": "#/definitions/TokenId" + } + ], + "maxItems": 2, + "minItems": 2 + } + } + }, + "additionalProperties": false + }, + { + "description": "Save all entries in incoming channel.", + "type": "object", + "required": [ + "add_incoming_channel_entries" + ], + "properties": { + "add_incoming_channel_entries": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "array", + "items": [ + { + "$ref": "#/definitions/ClassId" + }, + { + "$ref": "#/definitions/TokenId" + } + ], + "maxItems": 2, + "minItems": 2 + }, + { + "type": "string" + } + ], + "maxItems": 2, + "minItems": 2 + } + } + }, + "additionalProperties": false + }, { "description": "Mints a NFT of collection class_id for receiver with the provided id and metadata. Only callable by this contract.", "type": "object", @@ -921,6 +1033,20 @@ }, "additionalProperties": false }, + { + "description": "Gets the admin address for instantiating new cw721 contracts. In case of None, contract is immutable.", + "type": "object", + "required": [ + "cw721_admin" + ], + "properties": { + "cw721_admin": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, { "description": "Gets a list of classID as key (from NonFungibleTokenPacketData) and cw721 contract as value (instantiated for that classID).", "type": "object", @@ -1129,6 +1255,31 @@ } } }, + "cw721_admin": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Nullable_Nullable_Addr", + "anyOf": [ + { + "anyOf": [ + { + "$ref": "#/definitions/Addr" + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ], + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + } + } + }, "cw721_code_id": { "$schema": "http://json-schema.org/draft-07/schema#", "title": "uint64", diff --git a/packages/ics721/schema/raw/execute.json b/packages/ics721/schema/raw/execute.json index 6e99d45c..652ac4a3 100644 --- a/packages/ics721/schema/raw/execute.json +++ b/packages/ics721/schema/raw/execute.json @@ -43,23 +43,66 @@ "additionalProperties": false }, { + "description": "Admin msg in case something goes wrong. As a minimum it clean up states (incoming channel and token metadata), and burn NFT if exists.", "type": "object", "required": [ - "receive_proxy_nft" + "admin_clean_and_burn_nft" ], "properties": { - "receive_proxy_nft": { + "admin_clean_and_burn_nft": { "type": "object", "required": [ - "eyeball", - "msg" + "class_id", + "collection", + "owner", + "token_id" ], "properties": { - "eyeball": { + "class_id": { "type": "string" }, - "msg": { - "$ref": "#/definitions/Cw721ReceiveMsg" + "collection": { + "type": "string" + }, + "owner": { + "type": "string" + }, + "token_id": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + { + "description": "Admin msg in case something goes wrong. As a minimum it clean up state (outgoing channel), and transfer NFT if exists. - transfer NFT if exists", + "type": "object", + "required": [ + "admin_clean_and_unescrow_nft" + ], + "properties": { + "admin_clean_and_unescrow_nft": { + "type": "object", + "required": [ + "class_id", + "collection", + "recipient", + "token_id" + ], + "properties": { + "class_id": { + "type": "string" + }, + "collection": { + "type": "string" + }, + "recipient": { + "type": "string" + }, + "token_id": { + "type": "string" } }, "additionalProperties": false @@ -137,6 +180,68 @@ }, "additionalProperties": false }, + { + "description": "Redeem all entries in outgoing channel.", + "type": "object", + "required": [ + "redeem_outgoing_channel_entries" + ], + "properties": { + "redeem_outgoing_channel_entries": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "$ref": "#/definitions/ClassId" + }, + { + "$ref": "#/definitions/TokenId" + } + ], + "maxItems": 2, + "minItems": 2 + } + } + }, + "additionalProperties": false + }, + { + "description": "Save all entries in incoming channel.", + "type": "object", + "required": [ + "add_incoming_channel_entries" + ], + "properties": { + "add_incoming_channel_entries": { + "type": "array", + "items": { + "type": "array", + "items": [ + { + "type": "array", + "items": [ + { + "$ref": "#/definitions/ClassId" + }, + { + "$ref": "#/definitions/TokenId" + } + ], + "maxItems": 2, + "minItems": 2 + }, + { + "type": "string" + } + ], + "maxItems": 2, + "minItems": 2 + } + } + }, + "additionalProperties": false + }, { "description": "Mints a NFT of collection class_id for receiver with the provided id and metadata. Only callable by this contract.", "type": "object", diff --git a/packages/ics721/schema/raw/instantiate.json b/packages/ics721/schema/raw/instantiate.json index f431f451..52fd2a18 100644 --- a/packages/ics721/schema/raw/instantiate.json +++ b/packages/ics721/schema/raw/instantiate.json @@ -6,6 +6,13 @@ "cw721_base_code_id" ], "properties": { + "cw721_admin": { + "description": "The admin address for instantiating new cw721 contracts. In case of None, contract is immutable.", + "type": [ + "string", + "null" + ] + }, "cw721_base_code_id": { "description": "Code ID of cw721-ics contract. A new cw721-ics will be instantiated for each new IBCd NFT classID.\n\nNOTE: this _must_ correspond to the cw721-base contract. Using a regular cw721 may cause the ICS 721 interface implemented by this contract to stop working, and IBCd away NFTs to be unreturnable as cw721 does not have a mint method in the spec.", "type": "integer", diff --git a/packages/ics721/schema/raw/query.json b/packages/ics721/schema/raw/query.json index 9a6d14f2..0bcdce7e 100644 --- a/packages/ics721/schema/raw/query.json +++ b/packages/ics721/schema/raw/query.json @@ -189,6 +189,20 @@ }, "additionalProperties": false }, + { + "description": "Gets the admin address for instantiating new cw721 contracts. In case of None, contract is immutable.", + "type": "object", + "required": [ + "cw721_admin" + ], + "properties": { + "cw721_admin": { + "type": "object", + "additionalProperties": false + } + }, + "additionalProperties": false + }, { "description": "Gets a list of classID as key (from NonFungibleTokenPacketData) and cw721 contract as value (instantiated for that classID).", "type": "object", diff --git a/packages/ics721/schema/raw/response_to_cw721_admin.json b/packages/ics721/schema/raw/response_to_cw721_admin.json new file mode 100644 index 00000000..bbbfb8ea --- /dev/null +++ b/packages/ics721/schema/raw/response_to_cw721_admin.json @@ -0,0 +1,25 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Nullable_Nullable_Addr", + "anyOf": [ + { + "anyOf": [ + { + "$ref": "#/definitions/Addr" + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ], + "definitions": { + "Addr": { + "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.", + "type": "string" + } + } +} diff --git a/packages/ics721/src/error.rs b/packages/ics721/src/error.rs index 5f7d04e0..955b8fe6 100644 --- a/packages/ics721/src/error.rs +++ b/packages/ics721/src/error.rs @@ -27,6 +27,13 @@ pub enum ContractError { #[error("NFT not escrowed by ICS721! Owner: {0}")] NotEscrowedByIcs721(String), + #[error("{recipient} not owner of NFT {token_id}! Owner: {owner}")] + NotOwnerOfNft { + recipient: String, + owner: String, + token_id: String, + }, + #[error("only unordered channels are supported")] OrderedChannel {}, @@ -48,6 +55,17 @@ pub enum ContractError { #[error("Transfer Doesn't contain any action, no redemption or creation")] InvalidTransferNoAction, - #[error("Couldn't find nft contract for this class id: {0}")] + #[error("Couldn't find nft contract for class id: {0}")] NoNftContractForClassId(String), + + #[error("Unknown nft contract: {child_collection}, Class Id: {class_id}, Token ID: {token_id} => NFT contract: {cw721_addr}")] + NoNftContractMatch { + child_collection: String, + class_id: String, + token_id: String, + cw721_addr: String, + }, + + #[error("Couldn't find class id for nft contract: {0}")] + NoClassIdForNftContract(String), } diff --git a/packages/ics721/src/execute.rs b/packages/ics721/src/execute.rs index 9eb345ac..cb84861c 100644 --- a/packages/ics721/src/execute.rs +++ b/packages/ics721/src/execute.rs @@ -2,8 +2,9 @@ use std::fmt::Debug; use cosmwasm_std::{ from_json, to_json_binary, Addr, Binary, ContractInfoResponse, Deps, DepsMut, Empty, Env, - IbcMsg, MessageInfo, Response, StdResult, SubMsg, WasmMsg, + IbcMsg, MessageInfo, Order, Response, StdResult, SubMsg, WasmMsg, }; +use cw_storage_plus::Map; use ics721_types::{ ibc_types::{IbcOutgoingMsg, IbcOutgoingProxyMsg, NonFungibleTokenPacketData}, token_types::{Class, ClassId, Token, TokenId}, @@ -18,9 +19,14 @@ use crate::{ INSTANTIATE_OUTGOING_PROXY_REPLY_ID, }, msg::{CallbackMsg, ExecuteMsg, InstantiateMsg, MigrateMsg}, + query::{ + load_class_id_for_nft_contract, load_nft_contract_for_class_id, + query_nft_contract_for_class_id, query_nft_contracts, + }, state::{ - CollectionData, UniversalAllNftInfoResponse, CLASS_ID_TO_CLASS, CLASS_ID_TO_NFT_CONTRACT, - CW721_CODE_ID, INCOMING_PROXY, NFT_CONTRACT_TO_CLASS_ID, OUTGOING_CLASS_TOKEN_TO_CHANNEL, + ClassIdInfo, CollectionData, UniversalAllNftInfoResponse, ADMIN_USED_FOR_CW721, + CLASS_ID_AND_NFT_CONTRACT_INFO, CLASS_ID_TO_CLASS, CW721_CODE_ID, + INCOMING_CLASS_TOKEN_TO_CHANNEL, INCOMING_PROXY, OUTGOING_CLASS_TOKEN_TO_CHANNEL, OUTGOING_PROXY, PO, TOKEN_METADATA, }, token_types::{VoucherCreation, VoucherRedemption}, @@ -62,10 +68,23 @@ where )); } + ADMIN_USED_FOR_CW721.save( + deps.storage, + &msg.cw721_admin + .as_ref() + .map(|h| deps.api.addr_validate(h)) + .transpose()?, + )?; + Ok(Response::default() .add_submessages(proxies_instantiate) .add_attribute("method", "instantiate") - .add_attribute("cw721_code_id", msg.cw721_base_code_id.to_string())) + .add_attribute("cw721_code_id", msg.cw721_base_code_id.to_string()) + .add_attribute( + "cw721_admin", + msg.cw721_admin + .map_or_else(|| "immutable".to_string(), |or| or), + )) } fn execute( @@ -84,7 +103,187 @@ where }) => self.execute_receive_nft(deps, env, info, token_id, sender, msg), ExecuteMsg::Pause {} => self.execute_pause(deps, info), ExecuteMsg::Callback(msg) => self.execute_callback(deps, env, info, msg), + ExecuteMsg::AdminCleanAndBurnNft { + owner, + token_id, + class_id, + collection, + } => self.execute_admin_clean_and_burn_nft( + deps, env, info, owner, token_id, class_id, collection, + ), + ExecuteMsg::AdminCleanAndUnescrowNft { + recipient, + token_id, + class_id, + collection, + } => self.execute_admin_clean_and_unescrow_nft( + deps, env, info, recipient, token_id, class_id, collection, + ), + } + } + + #[allow(clippy::too_many_arguments)] + fn execute_admin_clean_and_burn_nft( + &self, + deps: DepsMut, + env: Env, + info: MessageInfo, + owner: String, + token_id: String, + child_class_id: String, + child_collection: String, + ) -> Result, ContractError> { + deps.api.addr_validate(&owner)?; + // only admin can call this method + let ContractInfoResponse { admin, .. } = deps + .querier + .query_wasm_contract_info(env.contract.address.to_string())?; + if admin.is_some() && info.sender != admin.unwrap() { + return Err(ContractError::Unauthorized {}); } + + // check given child class id and child collection is the same as stored in the contract + let token_id = TokenId::new(token_id); + let child_class_id = ClassId::new(child_class_id); + let child_collection = deps.api.addr_validate(&child_collection)?; + match query_nft_contract_for_class_id(deps.storage, child_class_id.to_string())? { + Some(cw721_addr) => { + if cw721_addr != child_collection { + return Err(ContractError::NoNftContractMatch { + child_collection: child_collection.to_string(), + class_id: child_class_id.to_string(), + token_id: token_id.into(), + cw721_addr: cw721_addr.to_string(), + }); + } + } + None => { + return Err(ContractError::NoNftContractForClassId( + child_class_id.to_string(), + )) + } + } + + // remove incoming channel entry and metadata + INCOMING_CLASS_TOKEN_TO_CHANNEL + .remove(deps.storage, (child_class_id.clone(), token_id.clone())); + TOKEN_METADATA.remove(deps.storage, (child_class_id.clone(), token_id.clone())); + + // check NFT on child collection owned by recipient + let maybe_nft_info: Option = deps + .querier + .query_wasm_smart( + child_collection.clone(), + &cw721::Cw721QueryMsg::AllNftInfo { + token_id: token_id.clone().into(), + include_expired: None, + }, + ) + .ok(); + + let mut response = + Response::default().add_attribute("method", "execute_admin_clean_and_burn_nft"); + if let Some(UniversalAllNftInfoResponse { access, .. }) = maybe_nft_info { + if access.owner != owner { + return Err(ContractError::NotOwnerOfNft { + recipient: owner.to_string(), + token_id: token_id.clone().into(), + owner: access.owner.to_string(), + }); + } + // burn child NFT + // note: this requires approval from recipient, or recipient burns it himself + let burn_msg = WasmMsg::Execute { + contract_addr: child_collection.to_string(), + msg: to_json_binary(&cw721::Cw721ExecuteMsg::Burn { + token_id: token_id.clone().into(), + })?, + funds: vec![], + }; + response = response.add_message(burn_msg); + } + + Ok(response) + } + + #[allow(clippy::too_many_arguments)] + fn execute_admin_clean_and_unescrow_nft( + &self, + deps: DepsMut, + env: Env, + info: MessageInfo, + recipient: String, + token_id: String, + home_class_id: String, + home_collection: String, + ) -> Result, ContractError> { + deps.api.addr_validate(&recipient)?; + // only admin can call this method + let ContractInfoResponse { admin, .. } = deps + .querier + .query_wasm_contract_info(env.contract.address.to_string())?; + if admin.is_some() && info.sender != admin.unwrap() { + return Err(ContractError::Unauthorized {}); + } + + // check given home class id and home collection is the same as stored in the contract + let home_class_id = ClassId::new(home_class_id); + let home_collection = deps.api.addr_validate(&home_collection)?; + match query_nft_contract_for_class_id(deps.storage, home_class_id.to_string())? { + Some(cw721_addr) => { + if cw721_addr != home_collection { + return Err(ContractError::NoNftContractMatch { + child_collection: home_collection.to_string(), + class_id: home_class_id.to_string(), + token_id, + cw721_addr: cw721_addr.to_string(), + }); + } + } + None => { + return Err(ContractError::NoNftContractForClassId( + home_class_id.to_string(), + )) + } + } + + // remove outgoing channel entry + let token_id = TokenId::new(token_id); + OUTGOING_CLASS_TOKEN_TO_CHANNEL + .remove(deps.storage, (home_class_id.clone(), token_id.clone())); + + // check NFT on home collection owned by ics721 contract + let maybe_nft_info: Option = deps + .querier + .query_wasm_smart( + home_collection.clone(), + &cw721::Cw721QueryMsg::AllNftInfo { + token_id: token_id.clone().into(), + include_expired: None, + }, + ) + .ok(); + + let mut response = + Response::default().add_attribute("method", "execute_admin_clean_and_unescrow_nft"); + if let Some(UniversalAllNftInfoResponse { access, .. }) = maybe_nft_info { + if access.owner != env.contract.address { + return Err(ContractError::NotEscrowedByIcs721(access.owner.to_string())); + } + // transfer NFT + let transfer_msg = WasmMsg::Execute { + contract_addr: home_collection.to_string(), + msg: to_json_binary(&cw721::Cw721ExecuteMsg::TransferNft { + recipient: recipient.to_string(), + token_id: token_id.clone().into(), + })?, + funds: vec![], + }; + + response = response.add_message(transfer_msg); + } + + Ok(response) } /// ICS721 may receive an NFT from 2 sources: @@ -119,30 +318,23 @@ where } from_json::(msg.clone()) .ok() - .map(|msg| { - let mut info = info; - match deps.api.addr_validate(&msg.collection) { - Ok(collection_addr) => { - // set collection address as (initial) sender - info.sender = collection_addr; - self.receive_nft( - deps, - env, - info, - TokenId::new(token_id), - nft_owner, - msg.msg, - ) - } - Err(err) => Err(ContractError::Std(err)), - } + .map(|msg| match deps.api.addr_validate(&msg.collection) { + Ok(nft_contract) => self.receive_nft( + deps, + env, + &nft_contract, + TokenId::new(token_id), + nft_owner, + msg.msg, + ), + Err(err) => Err(ContractError::Std(err)), }) } None => from_json::(msg.clone()).ok().map(|_| { self.receive_nft( deps, env, - info, + &info.sender, TokenId::new(token_id), nft_owner, msg.clone(), @@ -158,7 +350,7 @@ where &self, deps: DepsMut, env: Env, - info: MessageInfo, + nft_contract: &Addr, token_id: TokenId, nft_owner: String, msg: Binary, @@ -166,15 +358,15 @@ where let nft_owner = deps.api.addr_validate(&nft_owner)?; let msg: IbcOutgoingMsg = from_json(msg)?; - let class = match NFT_CONTRACT_TO_CLASS_ID.may_load(deps.storage, info.sender.clone())? { + let class = match load_class_id_for_nft_contract(deps.as_ref().storage, nft_contract)? { Some(class_id) => CLASS_ID_TO_CLASS.load(deps.storage, class_id)?, // No class ID being present means that this is a local NFT // that has never been sent out of this contract. None => { - let class_data = self.get_class_data(&deps, &info.sender)?; + let class_data = self.get_class_data(&deps, nft_contract)?; let data = class_data.as_ref().map(to_json_binary).transpose()?; let class = Class { - id: ClassId::new(info.sender.to_string()), + id: ClassId::new(nft_contract.to_string()), // There is no collection-level uri nor data in the // cw721 specification so we set those values to // `None` for local, cw721 NFTs. @@ -182,8 +374,11 @@ where data, }; - NFT_CONTRACT_TO_CLASS_ID.save(deps.storage, info.sender.clone(), &class.id)?; - CLASS_ID_TO_NFT_CONTRACT.save(deps.storage, class.id.clone(), &info.sender)?; + let class_id_info = ClassIdInfo { + class_id: class.id.clone(), + address: nft_contract.clone(), + }; + CLASS_ID_AND_NFT_CONTRACT_INFO.save(deps.storage, &class.id, &class_id_info)?; // Merging and usage of this PR may change that: // @@ -194,7 +389,7 @@ where // make sure NFT is escrowed by ics721 let UniversalAllNftInfoResponse { access, info } = deps.querier.query_wasm_smart( - info.sender, + nft_contract, &cw721::Cw721QueryMsg::AllNftInfo { token_id: token_id.clone().into(), include_expired: None, @@ -281,7 +476,12 @@ where tokens, receiver, } => self.callback_mint(deps, class_id, tokens, receiver), - + CallbackMsg::RedeemOutgoingChannelEntries(entries) => { + self.callback_redeem_outgoing_channel_entries(deps, entries) + } + CallbackMsg::AddIncomingChannelEntries(entries) => { + self.callback_save_incoming_channel_entries(deps, entries) + } CallbackMsg::Conjunction { operands } => { Ok(Response::default().add_messages(operands)) } @@ -300,8 +500,40 @@ where create: VoucherCreation, ) -> Result, ContractError> { let VoucherCreation { class, tokens } = create; - let instantiate = if CLASS_ID_TO_NFT_CONTRACT.has(deps.storage, class.id.clone()) { - vec![] + + // Store mapping from classID to classURI. Notably, we don't check + // if this has already been set. If a new NFT belonging to a class + // ID we have already seen comes in with new metadata, we assume + // that the metadata has been updated on the source chain and + // update it for the class ID locally as well. + CLASS_ID_TO_CLASS.save(deps.storage, class.id.clone(), &class)?; + + let mint = WasmMsg::Execute { + contract_addr: env.contract.address.to_string(), + msg: to_json_binary(&ExecuteMsg::Callback(CallbackMsg::Mint { + class_id: class.id.clone(), + receiver, + tokens, + }))?, + funds: vec![], + }; + + let instantiate = self.create_instantiate_msg(deps, &env, class.clone())?; + + Ok(Response::::default() + .add_attribute("method", "callback_create_vouchers") + .add_submessages(instantiate) + .add_message(mint)) + } + + fn create_instantiate_msg( + &self, + deps: DepsMut, + env: &Env, + class: Class, + ) -> Result>, ContractError> { + if CLASS_ID_AND_NFT_CONTRACT_INFO.has(deps.storage, class.id.to_string().as_str()) { + Ok(vec![]) } else { let class_id = ClassId::new(class.id.clone()); let cw721_code_id = CW721_CODE_ID.load(deps.storage)?; @@ -312,7 +544,7 @@ where hasher.update(class_id.as_bytes()); let salt = hasher.finalize().to_vec(); - let cw721_addr = get_instantiate2_address( + let nft_contract = get_instantiate2_address( deps.as_ref(), env.contract.address.as_str(), &salt, @@ -320,14 +552,20 @@ where )?; // Save classId <-> contract mappings. - CLASS_ID_TO_NFT_CONTRACT.save(deps.storage, class_id.clone(), &cw721_addr)?; - NFT_CONTRACT_TO_CLASS_ID.save(deps.storage, cw721_addr, &class_id)?; - + let class_id_info = ClassIdInfo { + class_id: class_id.clone(), + address: nft_contract.clone(), + }; + CLASS_ID_AND_NFT_CONTRACT_INFO.save(deps.storage, &class.id, &class_id_info)?; + + let admin = ADMIN_USED_FOR_CW721 + .load(deps.storage)? + .map(|a| a.to_string()); let message = SubMsg::::reply_on_success( WasmMsg::Instantiate2 { - admin: None, + admin, code_id: cw721_code_id, - msg: self.init_msg(deps.as_ref(), &env, &class)?, + msg: self.init_msg(deps.as_ref(), env, &class)?, funds: vec![], // Attempting to fit the class ID in the label field // can make this field too long which causes data @@ -337,30 +575,8 @@ where }, INSTANTIATE_CW721_REPLY_ID, ); - vec![message] - }; - - // Store mapping from classID to classURI. Notably, we don't check - // if this has already been set. If a new NFT belonging to a class - // ID we have already seen comes in with new metadata, we assume - // that the metadata has been updated on the source chain and - // update it for the class ID locally as well. - CLASS_ID_TO_CLASS.save(deps.storage, class.id.clone(), &class)?; - - let mint = WasmMsg::Execute { - contract_addr: env.contract.address.into_string(), - msg: to_json_binary(&ExecuteMsg::Callback(CallbackMsg::Mint { - class_id: class.id, - receiver, - tokens, - }))?, - funds: vec![], - }; - - Ok(Response::::default() - .add_attribute("method", "callback_create_vouchers") - .add_submessages(instantiate) - .add_message(mint)) + Ok(vec![message]) + } } /// Default implementation using `cw721_base::msg::InstantiateMsg` @@ -374,7 +590,7 @@ where let mut instantiate_msg = cw721_base::msg::InstantiateMsg { name: class.id.clone().into(), symbol: class.id.clone().into(), - minter: env.contract.address.to_string(), + minter: Some(env.contract.address.to_string()), withdraw_address: Some(creator), }; @@ -400,7 +616,7 @@ where redeem: VoucherRedemption, ) -> Result, ContractError> { let VoucherRedemption { class, token_ids } = redeem; - let nft_contract = CLASS_ID_TO_NFT_CONTRACT.load(deps.storage, class.id)?; + let nft_contract = load_nft_contract_for_class_id(deps.storage, class.id.to_string())?; let receiver = deps.api.addr_validate(&receiver)?; Ok(Response::default() .add_attribute("method", "callback_redeem_vouchers") @@ -429,7 +645,8 @@ where receiver: String, ) -> Result, ContractError> { let receiver = deps.api.addr_validate(&receiver)?; - let cw721_addr = CLASS_ID_TO_NFT_CONTRACT.load(deps.storage, class_id.clone())?; + let nft_contract = + load_nft_contract_for_class_id(deps.as_ref().storage, class_id.to_string())?; let mint = tokens .into_iter() @@ -447,7 +664,7 @@ where extension: Empty::default(), }; Ok(WasmMsg::Execute { - contract_addr: cw721_addr.to_string(), + contract_addr: nft_contract.to_string(), msg: to_json_binary(&msg)?, funds: vec![], }) @@ -459,6 +676,28 @@ where .add_messages(mint)) } + fn callback_redeem_outgoing_channel_entries( + &self, + deps: DepsMut, + entries: Vec<(ClassId, TokenId)>, + ) -> Result, ContractError> { + for (class_id, token_id) in entries { + OUTGOING_CLASS_TOKEN_TO_CHANNEL.remove(deps.storage, (class_id, token_id)); + } + Ok(Response::default().add_attribute("method", "callback_redeem_outgoing_channel_entries")) + } + + fn callback_save_incoming_channel_entries( + &self, + deps: DepsMut, + entries: Vec<((ClassId, TokenId), String)>, + ) -> Result, ContractError> { + for (key, channel) in entries { + INCOMING_CLASS_TOKEN_TO_CHANNEL.save(deps.storage, key, &channel)?; + } + Ok(Response::default().add_attribute("method", "callback_redeem_outgoing_channel_entries")) + } + fn migrate( &self, deps: DepsMut, @@ -471,6 +710,7 @@ where incoming_proxy, outgoing_proxy, cw721_base_code_id, + cw721_admin, } => { // disables incoming proxy if none is provided! INCOMING_PROXY.save( @@ -492,7 +732,16 @@ where if let Some(cw721_base_code_id) = cw721_base_code_id { CW721_CODE_ID.save(deps.storage, &cw721_base_code_id)?; } - Ok(Response::default() + if let Some(cw721_admin) = cw721_admin.clone() { + if cw721_admin.is_empty() { + ADMIN_USED_FOR_CW721.save(deps.storage, &None)?; + } else { + ADMIN_USED_FOR_CW721 + .save(deps.storage, &Some(deps.api.addr_validate(&cw721_admin)?))?; + } + } + + let response = Response::default() .add_attribute("method", "migrate") .add_attribute("pauser", pauser.map_or_else(|| "none".to_string(), |or| or)) .add_attribute( @@ -506,8 +755,69 @@ where .add_attribute( "cw721_base_code_id", cw721_base_code_id.map_or_else(|| "none".to_string(), |or| or.to_string()), - )) + ) + .add_attribute( + "cw721_admin", + cw721_admin.map_or_else( + || "none".to_string(), + |or| { + if or.is_empty() { + "immutable".to_string() + } else { + or + } + }, + ), + ); + + self.migrate_legacy(deps, response) + } + } + } + + // TODO once migrated: + // - this complete block can be deleted + // - legacy map 'e' and 'f' can be deleted + fn migrate_legacy( + &self, + deps: DepsMut, + response: Response, + ) -> Result, ContractError> { + // we migrate only in case CLASS_ID_AND_NFT_CONTRACT_INFO is not populated yet + let is_empty = query_nft_contracts(deps.as_ref(), None, None) + .map(|nft_contracts| nft_contracts.is_empty())?; + if is_empty { + // - get legacy map and migrate it to new indexed map + let legacy_nft_contract_to_class_id: Map = Map::new("f"); + match cw_paginate_storage::paginate_map( + deps.as_ref(), + &legacy_nft_contract_to_class_id, + None, + None, + Order::Ascending, + ) { + Ok(nft_contract_and_class_id) => { + let response = response.add_attribute( + "migrated nft contracts", + nft_contract_and_class_id.len().to_string(), + ); + for (nft_contract, class_id) in nft_contract_and_class_id { + let class_id_info = ClassIdInfo { + class_id: class_id.clone(), + address: nft_contract.clone(), + }; + CLASS_ID_AND_NFT_CONTRACT_INFO.save( + deps.storage, + &class_id, + &class_id_info, + )?; + } + Ok(response) + } + Err(err) => Err(ContractError::Std(err)), } + } else { + Ok(response) } } } diff --git a/packages/ics721/src/ibc.rs b/packages/ics721/src/ibc.rs index 0b6b6b8b..5f7cdaf8 100644 --- a/packages/ics721/src/ibc.rs +++ b/packages/ics721/src/ibc.rs @@ -12,9 +12,10 @@ use crate::{ helpers::ack_callback_msg, ibc_helpers::{ack_fail, ack_success, try_get_ack_error, validate_order_and_version}, ibc_packet_receive::receive_ibc_packet, + query::{load_class_id_for_nft_contract, load_nft_contract_for_class_id}, state::{ - CLASS_ID_TO_NFT_CONTRACT, INCOMING_CLASS_TOKEN_TO_CHANNEL, INCOMING_PROXY, - NFT_CONTRACT_TO_CLASS_ID, OUTGOING_CLASS_TOKEN_TO_CHANNEL, OUTGOING_PROXY, TOKEN_METADATA, + INCOMING_CLASS_TOKEN_TO_CHANNEL, INCOMING_PROXY, OUTGOING_CLASS_TOKEN_TO_CHANNEL, + OUTGOING_PROXY, TOKEN_METADATA, }, ContractError, }; @@ -121,7 +122,8 @@ where } else { let msg: NonFungibleTokenPacketData = from_json(&ack.original_packet.data)?; - let nft_contract = CLASS_ID_TO_NFT_CONTRACT.load(deps.storage, msg.class_id.clone())?; + let nft_contract = + load_nft_contract_for_class_id(deps.storage, msg.class_id.to_string())?; // Burn all of the tokens being transfered out that were // previously transfered in on this channel. let burn_notices = msg.token_ids.iter().cloned().try_fold( @@ -188,7 +190,8 @@ where error: &str, ) -> Result { let message: NonFungibleTokenPacketData = from_json(&packet.data)?; - let nft_contract = CLASS_ID_TO_NFT_CONTRACT.load(deps.storage, message.class_id.clone())?; + let nft_contract = + load_nft_contract_for_class_id(deps.storage, message.class_id.to_string())?; let sender = deps.api.addr_validate(&message.sender)?; let messages = message @@ -241,15 +244,18 @@ where // `ACK_AND_DO_NOTHING`. let res = parse_reply_instantiate_data(reply)?; - let cw721_addr = deps.api.addr_validate(&res.contract_address)?; + let nft_contract = deps.api.addr_validate(&res.contract_address)?; // cw721 addr has already been stored, here just check whether it exists, otherwise a NotFound error is thrown - let class_id = NFT_CONTRACT_TO_CLASS_ID.load(deps.storage, cw721_addr.clone())?; - - Ok(Response::default() - .add_attribute("method", "instantiate_cw721_reply") - .add_attribute("class_id", class_id) - .add_attribute("cw721_addr", cw721_addr)) + match load_class_id_for_nft_contract(deps.storage, &nft_contract)? { + Some(class_id) => Ok(Response::default() + .add_attribute("method", "instantiate_cw721_reply") + .add_attribute("class_id", class_id) + .add_attribute("cw721_addr", nft_contract)), + None => Err(ContractError::NoClassIdForNftContract( + nft_contract.to_string(), + )), + } } INSTANTIATE_OUTGOING_PROXY_REPLY_ID => { let res = parse_reply_instantiate_data(reply)?; diff --git a/packages/ics721/src/ibc_packet_receive.rs b/packages/ics721/src/ibc_packet_receive.rs index 02951694..5aff2200 100644 --- a/packages/ics721/src/ibc_packet_receive.rs +++ b/packages/ics721/src/ibc_packet_receive.rs @@ -1,6 +1,6 @@ use cosmwasm_std::{ - from_json, to_json_binary, Addr, Binary, DepsMut, Empty, Env, IbcPacket, IbcReceiveResponse, - StdResult, SubMsg, WasmMsg, + from_json, to_json_binary, Addr, Binary, Deps, DepsMut, Empty, Env, IbcPacket, + IbcReceiveResponse, StdResult, SubMsg, WasmMsg, }; use sha2::{Digest, Sha256}; use zip_optional::Zippable; @@ -13,10 +13,8 @@ use crate::{ ibc::ACK_AND_DO_NOTHING_REPLY_ID, ibc_helpers::{get_endpoint_prefix, try_pop_source_prefix}, msg::{CallbackMsg, ExecuteMsg}, - state::{ - CLASS_ID_TO_NFT_CONTRACT, CW721_CODE_ID, INCOMING_CLASS_TOKEN_TO_CHANNEL, - OUTGOING_CLASS_TOKEN_TO_CHANNEL, PO, - }, + query::load_nft_contract_for_class_id, + state::{CW721_CODE_ID, OUTGOING_CLASS_TOKEN_TO_CHANNEL, PO}, token_types::{VoucherCreation, VoucherRedemption}, ContractError, }; @@ -25,34 +23,6 @@ use ics721_types::{ token_types::{Class, ClassId, Token, TokenId}, }; -/// Every incoming token has some associated action. -enum Action { - /// Debt-voucher redemption. - Redemption { - class_id: ClassId, - token_id: TokenId, - }, - /// Debt-voucher creation. - Creation { class_id: ClassId, token: Token }, -} - -/// Internal type for aggregating actions. Actions can be added via -/// `add_action`. Once aggregation has completed, a -/// `HandlePacketReceive` submessage can be created via the -/// `into_submessage` method. -/// -/// Unlike `class_id`, class data and uri will always be the same -/// across one transfer so we store only one copy at the top level and -/// initialize it at creation time. -#[derive(Default)] -struct ActionAggregator { - class_uri: Option, - class_data: Option, - - redemption: Option, - creation: Option, -} - pub(crate) fn receive_ibc_packet( deps: DepsMut, env: Env, @@ -62,25 +32,83 @@ pub(crate) fn receive_ibc_packet( let data: NonFungibleTokenPacketData = from_json(&packet.data)?; data.validate()?; - let cloned_data = data.clone(); - let receiver = deps.api.addr_validate(&data.receiver)?; - let token_count = data.token_ids.len(); - // Check if NFT is local if not get the local class id let maybe_local_class_id = try_pop_source_prefix(&packet.src, &data.class_id); let callback = get_receive_callback(&data); + let local_class_id = if let Some(local_class_id) = maybe_local_class_id { + ClassId::new(local_class_id) + } else { + let local_prefix = get_endpoint_prefix(&packet.dest); + ClassId::new(format!("{}{}", local_prefix, data.class_id)) + }; - let action_aggregator = data + // sub message holds 2 to 4 messages: + // - one message for voucher creation or redemption, another message for updating incoming or outgoing channel + let (is_redemption, voucher_and_channel_messages) = create_voucher_and_channel_messages( + deps.as_ref(), + env.clone(), + data.clone(), + maybe_local_class_id, + local_class_id.clone(), + packet.clone(), + )?; + // - one optional incoming proxy message + let incoming_proxy_msg = + get_incoming_proxy_msg(deps.as_ref().storage, packet.clone(), data.clone())?; + // - one optional callback message + let callback_msg = create_callback_msg( + deps.as_ref(), + &env, + &data, + is_redemption, + callback, + local_class_id, + )?; + + let submessage = into_submessage( + env.contract.address, + voucher_and_channel_messages.0, + voucher_and_channel_messages.1, + callback_msg, + incoming_proxy_msg, + )?; + + let response = if let Some(memo) = data.memo { + IbcReceiveResponse::default().add_attribute("ics721_memo", memo) + } else { + IbcReceiveResponse::default() + }; + + Ok(response + .add_submessage(submessage) + .add_attribute("method", "receive_ibc_packet") + .add_attribute("class_id", data.class_id) + .add_attribute("local_channel", packet.dest.channel_id) + .add_attribute("counterparty_channel", packet.src.channel_id)) +} + +fn create_voucher_and_channel_messages( + deps: Deps, + env: Env, + data: NonFungibleTokenPacketData, + maybe_local_class_id: Option<&str>, + local_class_id: ClassId, + packet: IbcPacket, +) -> Result<(bool, (WasmMsg, WasmMsg)), ContractError> { + let token_count = data.token_ids.len(); + let redemption_or_create = data .token_ids .into_iter() .zip_optional(data.token_uris) .zip_optional(data.token_data) .try_fold( - Vec::::with_capacity(token_count), - |mut messages, ((token_id, token_uri), token_data)| -> StdResult<_> { + ( + Vec::::with_capacity(token_count), + Vec::::with_capacity(token_count), + ), + |mut redemption_or_create, ((token_id, token_uri), token_data)| -> StdResult<_> { // If class is not local, its something new - if let Some(local_class_id) = maybe_local_class_id { - let local_class_id = ClassId::new(local_class_id); + if maybe_local_class_id.is_some() { let key: (ClassId, TokenId) = (local_class_id.clone(), token_id.clone()); let outgoing_channel = OUTGOING_CLASS_TOKEN_TO_CHANNEL.may_load(deps.storage, key.clone())?; @@ -94,57 +122,116 @@ pub(crate) fn receive_ibc_packet( // We previously sent this NFT out on this // channel. Unlock the local version for the // receiver. - OUTGOING_CLASS_TOKEN_TO_CHANNEL.remove(deps.storage, key); - messages.push(Action::Redemption { - token_id, - class_id: local_class_id, - }); - return Ok(messages); + redemption_or_create.0.push(token_id); + return Ok(redemption_or_create); } } // It's not something we've sent out before => make a // new NFT. - let local_prefix = get_endpoint_prefix(&packet.dest); - let local_class_id = ClassId::new(format!("{}{}", local_prefix, data.class_id)); - - INCOMING_CLASS_TOKEN_TO_CHANNEL.save( - deps.storage, - (local_class_id.clone(), token_id.clone()), - &packet.dest.channel_id, - )?; - messages.push(Action::Creation { - class_id: local_class_id, - token: Token { - id: token_id, - uri: token_uri, - data: token_data, - }, + redemption_or_create.1.push(Token { + id: token_id, + uri: token_uri, + data: token_data, }); - Ok(messages) + Ok(redemption_or_create) }, - )? - .into_iter() - .fold( - ActionAggregator::new(data.class_uri, data.class_data), - ActionAggregator::add_action, - ); - - // All token ids in the transfer must be either a redeption or creation - // they can't be both, if they are both something is wrong. - if action_aggregator.redemption.is_some() && action_aggregator.creation.is_some() { + )?; + let is_redemption = if !redemption_or_create.0.is_empty() && !redemption_or_create.1.is_empty() + { + // All token ids in the transfer must be either a redeption or creation + // they can't be both, if they are both something is wrong. return Err(ContractError::InvalidTransferBothActions); - } + } else if !redemption_or_create.0.is_empty() { + true + } else if !redemption_or_create.1.is_empty() { + false + } else { + // This should never happen, as we must have at least 1 of the above actions + return Err(ContractError::InvalidTransferNoAction); + }; + + let receiver = deps.api.addr_validate(&data.receiver)?; + let voucher_and_channel_messages = match is_redemption { + true => { + let redemption = VoucherRedemption { + class: Class { + id: local_class_id.clone(), + uri: data.class_uri.clone(), + data: data.class_data.clone(), + }, + token_ids: redemption_or_create.0, + }; + let redeem_outgoing_channels: Vec<(ClassId, TokenId)> = redemption + .token_ids + .clone() + .into_iter() + .map(|token_id| (local_class_id.clone(), token_id)) + .collect(); + let redeem_outgoing_channels_msg = WasmMsg::Execute { + contract_addr: env.contract.address.to_string(), + msg: to_json_binary(&ExecuteMsg::Callback( + CallbackMsg::RedeemOutgoingChannelEntries(redeem_outgoing_channels), + ))?, + funds: vec![], + }; + ( + redemption.into_wasm_msg(env.contract.address.clone(), receiver.to_string())?, + redeem_outgoing_channels_msg, + ) + } + false => { + let creation = VoucherCreation { + class: Class { + id: local_class_id.clone(), + uri: data.class_uri.clone(), + data: data.class_data.clone(), + }, + tokens: redemption_or_create.1, + }; + let add_incoming_channels: Vec<((ClassId, TokenId), String)> = creation + .tokens + .clone() + .into_iter() + .map(|token| { + ( + (local_class_id.clone(), token.id), + packet.dest.channel_id.clone(), + ) + }) + .collect(); + let add_incoming_channels_msg = WasmMsg::Execute { + contract_addr: env.contract.address.to_string(), + msg: to_json_binary(&ExecuteMsg::Callback( + CallbackMsg::AddIncomingChannelEntries(add_incoming_channels), + ))?, + funds: vec![], + }; + ( + creation.into_wasm_msg(env.contract.address.clone(), receiver.to_string())?, + add_incoming_channels_msg, + ) + } + }; - // if there is a callback, generate the callback message - let callback_msg = if let Some((receive_callback_data, receive_callback_addr)) = callback { + Ok((is_redemption, voucher_and_channel_messages)) +} + +fn create_callback_msg( + deps: Deps, + env: &Env, + data: &NonFungibleTokenPacketData, + is_redemption: bool, + callback: Option<(Binary, Option)>, + local_class_id: ClassId, +) -> Result, ContractError> { + if let Some((receive_callback_data, receive_callback_addr)) = callback { // callback require the nft contract, get it using the class id from the action - let nft_contract = if let Some(voucher) = action_aggregator.redemption.clone() { + let nft_contract = if is_redemption { // If its a redemption, it means we already have the contract address in storage - CLASS_ID_TO_NFT_CONTRACT - .load(deps.storage, voucher.class.id.clone()) - .map_err(|_| ContractError::NoNftContractForClassId(voucher.class.id.to_string())) - } else if let Some(voucher) = action_aggregator.creation.clone() { + load_nft_contract_for_class_id(deps.storage, local_class_id.to_string()) + .map_err(|_| ContractError::NoNftContractForClassId(local_class_id.to_string())) + } else { // If its a creation action, we can use the instantiate2 function to get the nft contract // we don't care of the contract is instantiated yet or not, as later submessage will instantiate it if its not. // The reason we use instantiate2 here is because we don't know if it was already instantiated or not. @@ -154,190 +241,49 @@ pub(crate) fn receive_ibc_packet( // - using class id as salt for instantiating nft contract guarantees a) predictable address and b) uniqueness // for this salt must be of length 32 bytes, so we use sha256 to hash class id let mut hasher = Sha256::new(); - hasher.update(voucher.class.id.as_bytes()); + hasher.update(local_class_id.as_bytes()); let salt = hasher.finalize().to_vec(); - get_instantiate2_address( - deps.as_ref(), - env.contract.address.as_str(), - &salt, - cw721_code_id, - ) - } else { - // This should never happen, as we must have at least 1 of the above actions - Err(ContractError::InvalidTransferNoAction) + get_instantiate2_address(deps, env.contract.address.as_str(), &salt, cw721_code_id) }?; - generate_receive_callback_msg( - deps.as_ref(), - &cloned_data, + Ok(generate_receive_callback_msg( + deps, + data, receive_callback_data, receive_callback_addr, nft_contract.to_string(), - ) - } else { - None - }; - - let incoming_proxy_msg = - get_incoming_proxy_msg(deps.storage, packet.clone(), cloned_data.clone())?; - let submessage = action_aggregator.into_submessage( - env.contract.address, - receiver, - callback_msg, - incoming_proxy_msg, - )?; - - let response = if let Some(memo) = data.memo { - IbcReceiveResponse::default().add_attribute("ics721_memo", memo) + )) } else { - IbcReceiveResponse::default() - }; - - Ok(response - .add_submessage(submessage) - .add_attribute("method", "receive_ibc_packet") - .add_attribute("class_id", data.class_id) - .add_attribute("local_channel", packet.dest.channel_id) - .add_attribute("counterparty_channel", packet.src.channel_id)) + Ok(None) + } } -impl ActionAggregator { - pub fn new(class_uri: Option, class_data: Option) -> Self { - Self { - class_uri, - class_data, - redemption: None, - creation: None, - } +pub fn into_submessage( + contract: Addr, + voucher_message: WasmMsg, + channel_message: WasmMsg, + callback_msg: Option, + incoming_proxy_msg: Option, +) -> StdResult> { + let mut operands = Vec::with_capacity(4); // 4 is the max number of submessages we can have + if let Some(incoming_proxy_msg) = incoming_proxy_msg { + operands.push(incoming_proxy_msg) } - // the ics-721 rx logic is a functional implementation of this - // imperative pseudocode: - // - // ``` - // def select_actions(class_id, token, ibc_channel): - // (local_class_id, could_be_local) = pop_src_prefix(class_id) - // actions = [] - // - // for token in tokens: - // if could_be_local: - // returning_to_source = outgoing_tokens.has(token) - // if returning_to_source: - // outgoing_tokens.remove(token) - // actions.push(redeem_voucher, token, local_class_id) - // continue - // incoming_tokens.save(token) - // prefixed_class_id = prefix(class_id, ibc_channel) - // actions.push(create_voucher, token, prefixed_class_id) - // - // return actions - // ``` - // - // as `class_id` is fixed: - // - // 1. all `create_voucher` actions will have class id - // `prefixed_class_id` - // 2. all `redeem_voucher` actions will have class id - // `local_class_id` - // - // in other words: - // - // 1. `create_voucher` actions will all have the same `class_id` - // 2. `redeem_voucher` actions will all have the same `class_id` - // - // we make use of these properties here in that we only store one - // copy of class information per voucher action. - // - // --- - // - // tangental but nonetheless important aside: - // - // 3. not all create and redeem actions will have the same - // `class_id`. - // - // by counterexample: two identical tokens are sent by a malicious - // counterparty, the first removes the token from the - // outgoing_tokens map, the second then creates a create_voucher - // action. - // - // see `TestDoubleSendInSingleMessage` in `/e2e/adversarial_test.go` - // for a test demonstrating this. - // - // Having both redemption and creation action in the same transfer - // tells us its a malicious act that we should reject. - pub fn add_action(mut self, action: Action) -> Self { - match action { - Action::Redemption { class_id, token_id } => { - self.redemption = match self.redemption { - Some(mut r) => { - r.token_ids.push(token_id); - Some(r) - } - None => Some(VoucherRedemption { - class: Class { - id: class_id, - uri: self.class_uri.clone(), - data: self.class_data.clone(), - }, - token_ids: vec![token_id], - }), - } - } - Action::Creation { class_id, token } => { - self.creation = match self.creation { - Some(mut c) => { - c.tokens.push(token); - Some(c) - } - None => Some(VoucherCreation { - class: Class { - id: class_id, - uri: self.class_uri.clone(), - data: self.class_data.clone(), - }, - tokens: vec![token], - }), - } - } - }; - self - } + operands.push(voucher_message); - pub fn into_submessage( - self, - contract: Addr, - receiver: Addr, - callback_msg: Option, - incoming_proxy_msg: Option, - ) -> StdResult> { - let mut m = Vec::with_capacity(3); // 3 is the max number of submessages we can have - if let Some(incoming_proxy_msg) = incoming_proxy_msg { - m.push(incoming_proxy_msg) - } + if let Some(callback_msg) = callback_msg { + operands.push(callback_msg) + } - // we can only have redeem or create, not both - if let Some(redeem) = self.redemption { - m.push(redeem.into_wasm_msg(contract.clone(), receiver.to_string())?) - } - if let Some(create) = self.creation { - m.push(create.into_wasm_msg(contract.clone(), receiver.into_string())?) - } + // once all other submessages are done, we can update incoming or outgoing channel + operands.push(channel_message); - if let Some(callback_msg) = callback_msg { - m.push(callback_msg) - } - let message = if m.len() == 1 { - m[0].clone() - } else { - WasmMsg::Execute { - contract_addr: contract.into_string(), - msg: to_json_binary(&ExecuteMsg::Callback(CallbackMsg::Conjunction { - operands: m, - }))?, - funds: vec![], - } - }; - Ok(SubMsg::reply_always(message, ACK_AND_DO_NOTHING_REPLY_ID)) - } + let message = WasmMsg::Execute { + contract_addr: contract.into_string(), + msg: to_json_binary(&ExecuteMsg::Callback(CallbackMsg::Conjunction { operands }))?, + funds: vec![], + }; + Ok(SubMsg::reply_always(message, ACK_AND_DO_NOTHING_REPLY_ID)) } diff --git a/packages/ics721/src/msg.rs b/packages/ics721/src/msg.rs index f5a9401f..ccc9ab75 100644 --- a/packages/ics721/src/msg.rs +++ b/packages/ics721/src/msg.rs @@ -29,6 +29,8 @@ pub struct InstantiateMsg { /// right to do so again. A new pauser may be later nominated by /// the CosmWasm level admin via a migration. pub pauser: Option, + /// The admin address for instantiating new cw721 contracts. In case of None, contract is immutable. + pub cw721_admin: Option, } #[cw_serde] @@ -44,6 +46,25 @@ pub enum ExecuteMsg { /// Mesages used internally by the contract. These may only be /// called by the contract itself. Callback(CallbackMsg), + + /// Admin msg in case something goes wrong. + /// As a minimum it clean up states (incoming channel and token metadata), and burn NFT if exists. + AdminCleanAndBurnNft { + owner: String, + token_id: String, + class_id: String, + collection: String, + }, + + /// Admin msg in case something goes wrong. + /// As a minimum it clean up state (outgoing channel), and transfer NFT if exists. + /// - transfer NFT if exists + AdminCleanAndUnescrowNft { + recipient: String, + token_id: String, + class_id: String, + collection: String, + }, } #[cw_serde] @@ -61,6 +82,10 @@ pub enum CallbackMsg { /// Information about the vouchers been redeemed. redeem: VoucherRedemption, }, + /// Redeem all entries in outgoing channel. + RedeemOutgoingChannelEntries(Vec<(ClassId, TokenId)>), + /// Save all entries in incoming channel. + AddIncomingChannelEntries(Vec<((ClassId, TokenId), String)>), /// Mints a NFT of collection class_id for receiver with the /// provided id and metadata. Only callable by this contract. Mint { @@ -133,6 +158,10 @@ pub enum QueryMsg { #[returns(u64)] Cw721CodeId {}, + /// Gets the admin address for instantiating new cw721 contracts. In case of None, contract is immutable. + #[returns(Option>)] + Cw721Admin {}, + /// Gets a list of classID as key (from /// NonFungibleTokenPacketData) and cw721 contract as value /// (instantiated for that classID). @@ -181,5 +210,7 @@ pub enum MigrateMsg { /// this contract to stop working, and IBCd away NFTs to be /// unreturnable as cw721 does not have a mint method in the spec. cw721_base_code_id: Option, + /// The admin address for instantiating new cw721 contracts. In case of "", contract is immutable. + cw721_admin: Option, }, } diff --git a/packages/ics721/src/query.rs b/packages/ics721/src/query.rs index 37c43e16..b4a55de6 100644 --- a/packages/ics721/src/query.rs +++ b/packages/ics721/src/query.rs @@ -1,11 +1,11 @@ -use cosmwasm_std::{to_json_binary, Addr, Binary, Deps, Env, Order, StdResult}; -use cw_storage_plus::Map; +use cosmwasm_std::{to_json_binary, Addr, Binary, Deps, Env, Order, StdError, StdResult, Storage}; +use cw_storage_plus::{Bound, Map}; use crate::{ msg::QueryMsg, state::{ - UniversalAllNftInfoResponse, CLASS_ID_TO_CLASS, CLASS_ID_TO_NFT_CONTRACT, CW721_CODE_ID, - INCOMING_CLASS_TOKEN_TO_CHANNEL, INCOMING_PROXY, NFT_CONTRACT_TO_CLASS_ID, + UniversalAllNftInfoResponse, ADMIN_USED_FOR_CW721, CLASS_ID_AND_NFT_CONTRACT_INFO, + CLASS_ID_TO_CLASS, CW721_CODE_ID, INCOMING_CLASS_TOKEN_TO_CHANNEL, INCOMING_PROXY, OUTGOING_CLASS_TOKEN_TO_CHANNEL, OUTGOING_PROXY, PO, TOKEN_METADATA, }, }; @@ -15,27 +15,28 @@ pub trait Ics721Query { fn query(&self, deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { match msg { QueryMsg::ClassId { contract } => { - to_json_binary(&self.query_class_id_for_nft_contract(deps, contract)?) + to_json_binary(&query_class_id_for_nft_contract(deps, contract)?) } QueryMsg::NftContract { class_id } => { - to_json_binary(&self.query_nft_contract_for_class_id(deps, class_id)?) + to_json_binary(&query_nft_contract_for_class_id(deps.storage, class_id)?) } QueryMsg::ClassMetadata { class_id } => { - to_json_binary(&self.query_class_metadata(deps, class_id)?) + to_json_binary(&query_class_metadata(deps, class_id)?) } QueryMsg::TokenMetadata { class_id, token_id } => { - to_json_binary(&self.query_token_metadata(deps, class_id, token_id)?) + to_json_binary(&query_token_metadata(deps, class_id, token_id)?) } QueryMsg::Owner { class_id, token_id } => { - to_json_binary(&self.query_owner(deps, class_id, token_id)?) + to_json_binary(&query_owner(deps, class_id, token_id)?) } QueryMsg::Pauser {} => to_json_binary(&PO.query_pauser(deps.storage)?), QueryMsg::Paused {} => to_json_binary(&PO.query_paused(deps.storage)?), QueryMsg::OutgoingProxy {} => to_json_binary(&OUTGOING_PROXY.load(deps.storage)?), QueryMsg::IncomingProxy {} => to_json_binary(&INCOMING_PROXY.load(deps.storage)?), - QueryMsg::Cw721CodeId {} => to_json_binary(&self.query_cw721_code_id(deps)?), + QueryMsg::Cw721CodeId {} => to_json_binary(&query_cw721_code_id(deps)?), + QueryMsg::Cw721Admin {} => to_json_binary(&ADMIN_USED_FOR_CW721.load(deps.storage)?), QueryMsg::NftContracts { start_after, limit } => { - to_json_binary(&self.query_nft_contracts(deps, start_after, limit)?) + to_json_binary(&query_nft_contracts(deps, start_after, limit)?) } QueryMsg::OutgoingChannels { start_after, limit } => to_json_binary(&query_channels( deps, @@ -51,97 +52,120 @@ pub trait Ics721Query { )?), } } +} - fn query_class_id_for_nft_contract( - &self, - deps: Deps, - contract: String, - ) -> StdResult> { - let contract = deps.api.addr_validate(&contract)?; - NFT_CONTRACT_TO_CLASS_ID.may_load(deps.storage, contract) - } +pub fn query_class_id_for_nft_contract(deps: Deps, contract: String) -> StdResult> { + let contract = deps.api.addr_validate(&contract)?; + load_class_id_for_nft_contract(deps.storage, &contract) +} - fn query_nft_contract_for_class_id( - &self, - deps: Deps, - class_id: String, - ) -> StdResult> { - CLASS_ID_TO_NFT_CONTRACT.may_load(deps.storage, ClassId::new(class_id)) - } +pub fn load_class_id_for_nft_contract( + storage: &dyn Storage, + contract: &Addr, +) -> StdResult> { + CLASS_ID_AND_NFT_CONTRACT_INFO + .idx + .address + .item(storage, contract.clone()) + .map(|e| e.map(|(_, c)| c.class_id)) +} - fn query_class_metadata(&self, deps: Deps, class_id: String) -> StdResult> { - CLASS_ID_TO_CLASS.may_load(deps.storage, ClassId::new(class_id)) - } +pub fn query_nft_contract_for_class_id( + storage: &dyn Storage, + class_id: String, +) -> StdResult> { + // Convert the class_id string to ClassId type if necessary + let class_id_key = ClassId::new(class_id); - fn query_token_metadata( - &self, - deps: Deps, - class_id: String, - token_id: String, - ) -> StdResult> { - let token_id = TokenId::new(token_id); - let class_id = ClassId::new(class_id); + // Query the IndexedMap using the class_id index + CLASS_ID_AND_NFT_CONTRACT_INFO + .idx + .class_id + .item(storage, class_id_key) + .map(|e| e.map(|(_, v)| v.address)) +} - let Some(token_metadata) = - TOKEN_METADATA.may_load(deps.storage, (class_id.clone(), token_id.clone()))? - else { - // Token metadata is set unconditionaly on mint. If we have no - // metadata entry, we have no entry for this token at all. - return Ok(None); - }; - let Some(token_contract) = CLASS_ID_TO_NFT_CONTRACT.may_load(deps.storage, class_id)? - else { - debug_assert!(false, "token_metadata != None => token_contract != None"); - return Ok(None); - }; - let UniversalAllNftInfoResponse { info, .. } = deps.querier.query_wasm_smart( - token_contract, - &cw721::Cw721QueryMsg::AllNftInfo { - token_id: token_id.clone().into(), - include_expired: None, - }, - )?; - Ok(Some(Token { - id: token_id, - uri: info.token_uri, - data: token_metadata, - })) - } +pub fn load_nft_contract_for_class_id(storage: &dyn Storage, class_id: String) -> StdResult { + query_nft_contract_for_class_id(storage, class_id.clone())?.map_or_else( + || { + Err(StdError::NotFound { + kind: format!("NFT contract not found for class id {}", class_id), + }) + }, + Ok, + ) +} - fn query_owner( - &self, - deps: Deps, - class_id: String, - token_id: String, - ) -> StdResult { - let class_uri = CLASS_ID_TO_NFT_CONTRACT.load(deps.storage, ClassId::new(class_id))?; - let resp: cw721::OwnerOfResponse = deps.querier.query_wasm_smart( - class_uri, - &cw721::Cw721QueryMsg::OwnerOf { - token_id, - include_expired: None, - }, - )?; - Ok(resp) - } +pub fn query_class_metadata(deps: Deps, class_id: String) -> StdResult> { + CLASS_ID_TO_CLASS.may_load(deps.storage, ClassId::new(class_id)) +} - fn query_cw721_code_id(&self, deps: Deps) -> StdResult { - CW721_CODE_ID.load(deps.storage) - } +pub fn query_token_metadata( + deps: Deps, + class_id: String, + token_id: String, +) -> StdResult> { + let token_id = TokenId::new(token_id); + let class_id = ClassId::new(class_id); - fn query_nft_contracts( - &self, - deps: Deps, - start_after: Option, - limit: Option, - ) -> StdResult> { - cw_paginate_storage::paginate_map( - deps, - &CLASS_ID_TO_NFT_CONTRACT, - start_after, - limit, - Order::Ascending, - ) + let Some(token_metadata) = + TOKEN_METADATA.may_load(deps.storage, (class_id.clone(), token_id.clone()))? + else { + // Token metadata is set unconditionaly on mint. If we have no + // metadata entry, we have no entry for this token at all. + return Ok(None); + }; + let Some(nft_contract) = query_nft_contract_for_class_id(deps.storage, class_id.to_string())? + else { + debug_assert!(false, "token_metadata != None => token_contract != None"); + return Ok(None); + }; + let UniversalAllNftInfoResponse { info, .. } = deps.querier.query_wasm_smart( + nft_contract, + &cw721::Cw721QueryMsg::AllNftInfo { + token_id: token_id.clone().into(), + include_expired: None, + }, + )?; + Ok(Some(Token { + id: token_id, + uri: info.token_uri, + data: token_metadata, + })) +} + +pub fn query_owner( + deps: Deps, + class_id: String, + token_id: String, +) -> StdResult { + let nft_contract = load_nft_contract_for_class_id(deps.storage, class_id)?; + let resp: cw721::OwnerOfResponse = deps.querier.query_wasm_smart( + nft_contract, + &cw721::Cw721QueryMsg::OwnerOf { + token_id, + include_expired: None, + }, + )?; + Ok(resp) +} + +pub fn query_cw721_code_id(deps: Deps) -> StdResult { + CW721_CODE_ID.load(deps.storage) +} + +pub fn query_nft_contracts( + deps: Deps, + start_after: Option, + limit: Option, +) -> StdResult> { + let start = start_after.map(|s| Bound::ExclusiveRaw(s.to_string().into())); + let all = CLASS_ID_AND_NFT_CONTRACT_INFO + .range(deps.storage, start, None, Order::Ascending) + .map(|item| item.map(|(k, v)| (k, v.address))); + match limit { + Some(limit) => all.take(limit as usize).collect(), + None => all.collect(), } } diff --git a/packages/ics721/src/state.rs b/packages/ics721/src/state.rs index bc75a832..8d297d0e 100644 --- a/packages/ics721/src/state.rs +++ b/packages/ics721/src/state.rs @@ -1,7 +1,7 @@ -use cosmwasm_schema::schemars::JsonSchema; +use cosmwasm_schema::{cw_serde, schemars::JsonSchema}; use cosmwasm_std::{Addr, Binary, ContractInfoResponse, Empty}; use cw_pause_once::PauseOrchestrator; -use cw_storage_plus::{Item, Map}; +use cw_storage_plus::{Index, IndexList, IndexedMap, Item, Map, UniqueIndex}; use serde::{Deserialize, Serialize}; use ics721_types::token_types::{Class, ClassId, TokenId}; @@ -19,9 +19,15 @@ pub const PO: PauseOrchestrator = PauseOrchestrator::new("c", "d"); /// Maps classID (from NonFungibleTokenPacketData) to the cw721 /// contract we have instantiated for that classID. -pub const CLASS_ID_TO_NFT_CONTRACT: Map = Map::new("e"); -/// Maps cw721 contracts to the classID they were instantiated for. -pub const NFT_CONTRACT_TO_CLASS_ID: Map = Map::new("f"); +/// NOTE: legacy stores with keys `e` and `f` are no longer used. +pub const CLASS_ID_AND_NFT_CONTRACT_INFO: IndexedMap<&str, ClassIdInfo, ClassIdInfoIndexes> = + IndexedMap::new( + "m", + ClassIdInfoIndexes { + class_id: UniqueIndex::new(|d| d.class_id.clone(), "class_id_info__class_id"), + address: UniqueIndex::new(|d| d.address.clone(), "class_id_info__address"), + }, + ); /// Maps between classIDs and classs. We need to keep this state /// ourselves as cw721 contracts do not have class-level metadata. @@ -38,6 +44,8 @@ pub const INCOMING_CLASS_TOKEN_TO_CHANNEL: Map<(ClassId, TokenId), String> = Map /// is `None`) is stored in this map. When the token is returned to /// it's source chain, the metadata is removed from the map. pub const TOKEN_METADATA: Map<(ClassId, TokenId), Option> = Map::new("j"); +/// The admin address for instantiating new cw721 contracts. In case of None, contract is immutable. +pub const ADMIN_USED_FOR_CW721: Item> = Item::new("l"); #[derive(Deserialize)] pub struct UniversalAllNftInfoResponse { @@ -55,9 +63,9 @@ pub struct UniversalNftInfoResponse { } /// Collection data send by ICS721 on source chain. It is an optional class data for interchain transfer to target chain. -/// ICS721 on target chain is free to use this data or not. Lik in case of `sg721-base` it uses owner for defining creator in collection info. +/// ICS721 on target chain is free to use this data or not. Like in case of `sg721-base` it uses owner for defining creator in collection info. /// `ics721-base` uses name and symbol for instantiating new cw721 contract. -// NB: Please not cw_serde includes `deny_unknown_fields`: https://github.com/CosmWasm/cosmwasm/blob/v1.5.0/packages/schema-derive/src/cw_serde.rs +// NB: Please note cw_serde includes `deny_unknown_fields`: https://github.com/CosmWasm/cosmwasm/blob/v1.5.0/packages/schema-derive/src/cw_serde.rs // For incoming data, parsing needs to be more lenient/less strict, so we use `serde` directly. #[derive(Serialize, Deserialize, JsonSchema, Clone, Debug, PartialEq)] #[allow(clippy::derive_partial_eq_without_eq)] @@ -80,6 +88,27 @@ pub struct UniversalOwnerOfResponse { pub approvals: Vec, } +/// ClassIdInfo is used to store associated ClassId for given collection/cw721 address. +#[cw_serde] +pub struct ClassIdInfo { + /// Associated class_id for a given collection/CW721 address. + pub class_id: ClassId, + /// Associated collection/CW721 address for a given class_id. + pub address: Addr, +} + +pub struct ClassIdInfoIndexes<'a> { + pub class_id: UniqueIndex<'a, ClassId, ClassIdInfo>, + pub address: UniqueIndex<'a, Addr, ClassIdInfo>, +} + +impl<'a> IndexList for ClassIdInfoIndexes<'a> { + fn get_indexes(&'_ self) -> Box> + '_> { + let v: Vec<&dyn Index> = vec![&self.class_id, &self.address]; + Box::new(v.into_iter()) + } +} + #[cfg(test)] mod tests { use cosmwasm_std::{from_json, to_json_binary, Coin, Empty}; diff --git a/packages/ics721/src/testing/contract.rs b/packages/ics721/src/testing/contract.rs index 7d5acb48..409fe612 100644 --- a/packages/ics721/src/testing/contract.rs +++ b/packages/ics721/src/testing/contract.rs @@ -9,14 +9,18 @@ use cw721::{AllNftInfoResponse, NftInfoResponse, NumTokensResponse}; use cw721_base::QueryMsg; use cw_cii::ContractInstantiateInfo; use cw_ownable::Ownership; +use cw_storage_plus::Map; use crate::{ execute::Ics721Execute, ibc::{Ics721Ibc, INSTANTIATE_INCOMING_PROXY_REPLY_ID, INSTANTIATE_OUTGOING_PROXY_REPLY_ID}, - msg::InstantiateMsg, - query::Ics721Query, + msg::{InstantiateMsg, MigrateMsg}, + query::{ + query_class_id_for_nft_contract, query_nft_contract_for_class_id, query_nft_contracts, + Ics721Query, + }, state::{ - CollectionData, CLASS_ID_TO_CLASS, CW721_CODE_ID, INCOMING_PROXY, + CollectionData, ADMIN_USED_FOR_CW721, CLASS_ID_TO_CLASS, CW721_CODE_ID, INCOMING_PROXY, OUTGOING_CLASS_TOKEN_TO_CHANNEL, OUTGOING_PROXY, PO, }, utils::get_collection_data, @@ -26,7 +30,10 @@ use ics721_types::{ token_types::{ClassId, TokenId}, }; -const NFT_ADDR: &str = "nft"; +const NFT_CONTRACT_1: &str = "nft1"; +const NFT_CONTRACT_2: &str = "nft2"; +const CLASS_ID_1: &str = "some/class/id1"; +const CLASS_ID_2: &str = "some/class/id2"; const OWNER_ADDR: &str = "owner"; const ADMIN_ADDR: &str = "admin"; const PAUSER_ADDR: &str = "pauser"; @@ -206,7 +213,7 @@ fn test_receive_nft() { deps.querier = querier; let env = mock_env(); - let info = mock_info(NFT_ADDR, &[]); + let info = mock_info(NFT_CONTRACT_1, &[]); let token_id = "1"; let sender = "ekez".to_string(); let msg = to_json_binary(&IbcOutgoingMsg { @@ -221,7 +228,7 @@ fn test_receive_nft() { .receive_nft( deps.as_mut(), env, - info, + &info.sender, TokenId::new(token_id), sender.clone(), msg, @@ -236,7 +243,7 @@ fn test_receive_nft() { channel_id: channel_id.clone(), timeout: IbcTimeout::with_timestamp(Timestamp::from_seconds(42)), data: to_json_binary(&NonFungibleTokenPacketData { - class_id: ClassId::new(NFT_ADDR), + class_id: ClassId::new(NFT_CONTRACT_1), class_uri: None, class_data: Some( to_json_binary(&CollectionData { @@ -264,7 +271,7 @@ fn test_receive_nft() { .keys(deps.as_mut().storage, None, None, Order::Ascending) .collect::>>() .unwrap(); - assert_eq!(keys, [(NFT_ADDR.to_string(), token_id.to_string())]); + assert_eq!(keys, [(NFT_CONTRACT_1.to_string(), token_id.to_string())]); // check channel let key = ( @@ -287,7 +294,7 @@ fn test_receive_nft() { deps.querier = querier; let env = mock_env(); - let info = mock_info(NFT_ADDR, &[]); + let info = mock_info(NFT_CONTRACT_1, &[]); let token_id = "1"; let sender = "ekez".to_string(); let msg = to_json_binary(&IbcOutgoingMsg { @@ -302,7 +309,7 @@ fn test_receive_nft() { .receive_nft( deps.as_mut(), env, - info, + &info.sender, TokenId::new(token_id), sender.clone(), msg, @@ -317,7 +324,7 @@ fn test_receive_nft() { channel_id: channel_id.clone(), timeout: IbcTimeout::with_timestamp(Timestamp::from_seconds(42)), data: to_json_binary(&NonFungibleTokenPacketData { - class_id: ClassId::new(NFT_ADDR), + class_id: ClassId::new(NFT_CONTRACT_1), class_uri: None, class_data: Some( to_json_binary(&CollectionData { @@ -345,7 +352,7 @@ fn test_receive_nft() { .keys(deps.as_mut().storage, None, None, Order::Ascending) .collect::>>() .unwrap(); - assert_eq!(keys, [(NFT_ADDR.to_string(), token_id.to_string())]); + assert_eq!(keys, [(NFT_CONTRACT_1.to_string(), token_id.to_string())]); // check channel let key = ( @@ -368,7 +375,7 @@ fn test_receive_nft() { deps.querier = querier; let env = mock_env(); - let info = mock_info(NFT_ADDR, &[]); + let info = mock_info(NFT_CONTRACT_1, &[]); let token_id = "1"; let sender = "ekez".to_string(); let msg = to_json_binary(&IbcOutgoingMsg { @@ -383,7 +390,7 @@ fn test_receive_nft() { .receive_nft( deps.as_mut(), env, - info, + &info.sender, TokenId::new(token_id), sender.clone(), msg, @@ -398,7 +405,7 @@ fn test_receive_nft() { channel_id: channel_id.clone(), timeout: IbcTimeout::with_timestamp(Timestamp::from_seconds(42)), data: to_json_binary(&NonFungibleTokenPacketData { - class_id: ClassId::new(NFT_ADDR), + class_id: ClassId::new(NFT_CONTRACT_1), class_uri: None, class_data: None, token_data: None, @@ -417,7 +424,7 @@ fn test_receive_nft() { .keys(deps.as_mut().storage, None, None, Order::Ascending) .collect::>>() .unwrap(); - assert_eq!(keys, [(NFT_ADDR.to_string(), token_id.to_string())]); + assert_eq!(keys, [(NFT_CONTRACT_1.to_string(), token_id.to_string())]); // check channel let key = ( @@ -442,7 +449,7 @@ fn test_receive_sets_uri() { deps.querier = querier; let env = mock_env(); - let info = mock_info(NFT_ADDR, &[]); + let info = mock_info(NFT_CONTRACT_1, &[]); let token_id = TokenId::new("1"); let sender = "ekez".to_string(); let msg = to_json_binary(&IbcOutgoingMsg { @@ -454,11 +461,11 @@ fn test_receive_sets_uri() { .unwrap(); Ics721Contract {} - .receive_nft(deps.as_mut(), env, info, token_id, sender, msg) + .receive_nft(deps.as_mut(), env, &info.sender, token_id, sender, msg) .unwrap(); let class = CLASS_ID_TO_CLASS - .load(deps.as_ref().storage, ClassId::new(NFT_ADDR)) + .load(deps.as_ref().storage, ClassId::new(NFT_CONTRACT_1)) .unwrap(); assert_eq!(class.uri, None); let expected_contract_info: cosmwasm_std::ContractInfoResponse = from_json( @@ -487,6 +494,19 @@ fn test_receive_sets_uri() { ); } +fn instantiate_msg( + incoming_proxy: Option, + outgoing_proxy: Option, +) -> InstantiateMsg { + InstantiateMsg { + cw721_base_code_id: 0, + incoming_proxy, + outgoing_proxy, + pauser: Some(PAUSER_ADDR.to_string()), + cw721_admin: Some(ADMIN_ADDR.to_string()), + } +} + #[test] fn test_instantiate() { let mut deps = mock_dependencies(); @@ -508,12 +528,10 @@ fn test_instantiate() { }), label: "outgoing".to_string(), }; - let msg = InstantiateMsg { - cw721_base_code_id: 0, - incoming_proxy: Some(incoming_proxy_init_msg.clone()), - outgoing_proxy: Some(outgoing_proxy_init_msg.clone()), - pauser: Some(PAUSER_ADDR.to_string()), - }; + let msg = instantiate_msg( + Some(incoming_proxy_init_msg.clone()), + Some(outgoing_proxy_init_msg.clone()), + ); let response = Ics721Contract {} .instantiate(deps.as_mut(), env.clone(), info, msg.clone()) .unwrap(); @@ -531,7 +549,8 @@ fn test_instantiate() { INSTANTIATE_OUTGOING_PROXY_REPLY_ID, )) .add_attribute("method", "instantiate") - .add_attribute("cw721_code_id", msg.cw721_base_code_id.to_string()); + .add_attribute("cw721_code_id", msg.cw721_base_code_id.to_string()) + .add_attribute("cw721_admin", ADMIN_ADDR); assert_eq!(response, expected_response); assert_eq!(CW721_CODE_ID.load(&deps.storage).unwrap(), 0); // incoming and outgoing proxy initially set to None and set later in sub msg @@ -542,4 +561,100 @@ fn test_instantiate() { Some(Addr::unchecked(PAUSER_ADDR)) ); assert!(!PO.paused.load(&deps.storage).unwrap()); + assert_eq!( + ADMIN_USED_FOR_CW721.load(&deps.storage).unwrap(), + Some(Addr::unchecked(ADMIN_ADDR.to_string())) + ); +} + +#[test] +fn test_migrate() { + let mut deps = mock_dependencies(); + let env = mock_env(); + let info = mock_info(OWNER_ADDR, &[]); + let msg = instantiate_msg(None, None); + Ics721Contract {} + .instantiate(deps.as_mut(), env.clone(), info, msg.clone()) + .unwrap(); + let msg = MigrateMsg::WithUpdate { + pauser: Some("some_other_pauser".to_string()), + outgoing_proxy: Some("outgoing".to_string()), + incoming_proxy: Some("incoming".to_string()), + cw721_base_code_id: Some(1), + cw721_admin: Some("some_other_admin".to_string()), + }; + + // before migrate, populate legacy + let class_id_to_nft_contract: Map = Map::new("e"); + class_id_to_nft_contract + .save( + deps.as_mut().storage, + ClassId::new(CLASS_ID_1), + &Addr::unchecked(NFT_CONTRACT_1), + ) + .unwrap(); + class_id_to_nft_contract + .save( + deps.as_mut().storage, + ClassId::new(CLASS_ID_2), + &Addr::unchecked(NFT_CONTRACT_2), + ) + .unwrap(); + let nft_contract_to_class_id: Map = Map::new("f"); + nft_contract_to_class_id + .save( + deps.as_mut().storage, + Addr::unchecked(NFT_CONTRACT_1), + &ClassId::new(CLASS_ID_1), + ) + .unwrap(); + nft_contract_to_class_id + .save( + deps.as_mut().storage, + Addr::unchecked(NFT_CONTRACT_2), + &ClassId::new(CLASS_ID_2), + ) + .unwrap(); + + // migrate + Ics721Contract {} + .migrate(deps.as_mut(), env.clone(), msg) + .unwrap(); + + assert_eq!( + PO.pauser.load(&deps.storage).unwrap(), + Some(Addr::unchecked("some_other_pauser")) + ); + assert_eq!( + OUTGOING_PROXY.load(&deps.storage).unwrap(), + Some(Addr::unchecked("outgoing")) + ); + assert_eq!( + INCOMING_PROXY.load(&deps.storage).unwrap(), + Some(Addr::unchecked("incoming")) + ); + assert_eq!(CW721_CODE_ID.load(&deps.storage).unwrap(), 1); + assert_eq!( + ADMIN_USED_FOR_CW721.load(&deps.storage).unwrap(), + Some(Addr::unchecked("some_other_admin")) + ); + let nft_contract_and_class_id_list = query_nft_contracts(deps.as_ref(), None, None).unwrap(); + assert_eq!(nft_contract_and_class_id_list.len(), 2); + assert_eq!(nft_contract_and_class_id_list[0].0, CLASS_ID_1); + assert_eq!(nft_contract_and_class_id_list[0].1, NFT_CONTRACT_1); + assert_eq!(nft_contract_and_class_id_list[1].0, CLASS_ID_2); + assert_eq!(nft_contract_and_class_id_list[1].1, NFT_CONTRACT_2); + // test query and indexers for class id and addr are working + let nft_contract_1 = + query_nft_contract_for_class_id(&deps.storage, CLASS_ID_1.to_string()).unwrap(); + assert_eq!(nft_contract_1, Some(Addr::unchecked(NFT_CONTRACT_1))); + let nft_contract_2 = + query_nft_contract_for_class_id(&deps.storage, CLASS_ID_2.to_string()).unwrap(); + assert_eq!(nft_contract_2, Some(Addr::unchecked(NFT_CONTRACT_2))); + let class_id_1 = + query_class_id_for_nft_contract(deps.as_ref(), NFT_CONTRACT_1.to_string()).unwrap(); + assert_eq!(class_id_1, Some(ClassId::new(CLASS_ID_1))); + let class_id_2 = + query_class_id_for_nft_contract(deps.as_ref(), NFT_CONTRACT_2.to_string()).unwrap(); + assert_eq!(class_id_2, Some(ClassId::new(CLASS_ID_2))); } diff --git a/packages/ics721/src/testing/ibc_tests.rs b/packages/ics721/src/testing/ibc_tests.rs index 39ced734..09bc90f1 100644 --- a/packages/ics721/src/testing/ibc_tests.rs +++ b/packages/ics721/src/testing/ibc_tests.rs @@ -1,11 +1,13 @@ +use core::panic; + use cosmwasm_schema::cw_serde; use cosmwasm_std::{ attr, from_json, testing::{mock_dependencies, mock_env, mock_info}, to_json_binary, to_json_vec, Addr, Attribute, Binary, DepsMut, Empty, Env, IbcAcknowledgement, IbcChannel, IbcChannelConnectMsg, IbcChannelOpenMsg, IbcEndpoint, IbcOrder, IbcPacket, - IbcPacketReceiveMsg, IbcTimeout, Order, Reply, Response, StdResult, SubMsgResponse, - SubMsgResult, Timestamp, WasmMsg, + IbcPacketReceiveMsg, IbcTimeout, Reply, Response, StdResult, SubMsgResponse, SubMsgResult, + Timestamp, WasmMsg, }; use crate::{ @@ -14,14 +16,14 @@ use crate::{ ibc_helpers::{ack_fail, ack_success, try_get_ack_error}, msg::{CallbackMsg, ExecuteMsg, InstantiateMsg, QueryMsg}, query::Ics721Query, - state::{CollectionData, INCOMING_CLASS_TOKEN_TO_CHANNEL, NFT_CONTRACT_TO_CLASS_ID, PO}, + state::{ClassIdInfo, CollectionData, CLASS_ID_AND_NFT_CONTRACT_INFO, PO}, utils::get_collection_data, ContractError, }; use ics721_types::{ ibc_types::NonFungibleTokenPacketData, token_types::{ClassId, TokenId}, - types::Ics721Callbacks, + types::{Ics721Callbacks, ReceiverExecuteMsg}, }; const CONTRACT_PORT: &str = "wasm.address1"; @@ -108,6 +110,7 @@ fn do_instantiate(deps: DepsMut, env: Env, sender: &str) -> StdResult incoming_proxy: None, outgoing_proxy: None, pauser: None, + cw721_admin: None, }; Ics721Contract::default().instantiate(deps, env, mock_info(sender, &[]), msg) } @@ -157,8 +160,12 @@ fn test_reply_cw721() { // save the class_id and cw721_addr, since reply assumes it has been stored before let cw721_addr = Addr::unchecked("cosmos2contract"); let class_id = ClassId::new("wasm.address1/channel-10/address2"); - NFT_CONTRACT_TO_CLASS_ID - .save(deps.as_mut().storage, cw721_addr, &class_id) + let class_id_info = ClassIdInfo { + class_id: class_id.clone(), + address: cw721_addr.clone(), + }; + CLASS_ID_AND_NFT_CONTRACT_INFO + .save(deps.as_mut().storage, &class_id, &class_id_info) .unwrap(); let res = Ics721Contract::default() @@ -446,32 +453,61 @@ fn test_ibc_packet_receive() { let mut deps = mock_dependencies(); let env = mock_env(); PO.set_pauser(&mut deps.storage, &deps.api, None).unwrap(); - Ics721Contract::default() + let response = Ics721Contract::default() .ibc_packet_receive(deps.as_mut(), env, packet) .unwrap(); + // assert there is only one message + assert_eq!(response.messages.len(), 1); - // check incoming classID and tokenID - let keys = INCOMING_CLASS_TOKEN_TO_CHANNEL - .keys(deps.as_mut().storage, None, None, Order::Ascending) - .collect::>>() - .unwrap(); - let class_id = format!( - "{}/{}/{}", - ibc_packet.dest.port_id, ibc_packet.dest.channel_id, "id" - ); - assert_eq!(keys, [(class_id, "1".to_string())]); - - // check channel - let key = ( - ClassId::new(keys[0].clone().0), - TokenId::new(keys[0].clone().1), - ); - assert_eq!( - INCOMING_CLASS_TOKEN_TO_CHANNEL - .load(deps.as_mut().storage, key) - .unwrap(), - ibc_packet.dest.channel_id, - ) + let conjunction_msg = match response.messages[0].msg.clone() { + cosmwasm_std::CosmosMsg::Wasm(WasmMsg::Execute { msg, .. }) => { + match from_json::(msg.clone()).unwrap() { + ExecuteMsg::Callback(callback_msg) => match callback_msg { + CallbackMsg::Conjunction { operands } => Some(operands), + _ => panic!("unexpected callback msg"), + }, + _ => panic!("unexpected execute msg"), + } + } + _ => panic!("unexpected cosmos msg"), + }; + assert!(conjunction_msg.is_some()); + + let operands = conjunction_msg.unwrap(); + assert_eq!(operands.len(), 2); + + let add_incoming_msg = operands[1].clone(); + match add_incoming_msg { + WasmMsg::Execute { msg, .. } => { + match from_json::(msg).ok() { + Some(msg) => match msg { + ExecuteMsg::Callback(msg) => match msg { + CallbackMsg::AddIncomingChannelEntries(class_token_to_channel_list) => { + let class_token_to_channel_list = class_token_to_channel_list + .into_iter() + .map(|((class, token), channel)| { + ((class.to_string(), token.into()), channel) + }) + .collect::>(); + // assert there is only one class token to channel entry + let class_id = format!( + "{}/{}/{}", + ibc_packet.dest.port_id, ibc_packet.dest.channel_id, "id" + ); + assert_eq!( + class_token_to_channel_list, + [((class_id, "1".to_string()), ibc_packet.dest.channel_id,)] + ); + } + _ => panic!("unexpected callback msg"), + }, + _ => panic!("unexpected execute msg"), + }, + _ => panic!("no callback msg"), + } + } + _ => panic!("unexpected wasm msg"), + } } #[test] @@ -704,20 +740,21 @@ fn test_different_memo_ignored() { .ibc_packet_receive(deps.as_mut(), env, packet) .unwrap(); - let memo_callback_msg = match res.messages[0].msg.clone() { - cosmwasm_std::CosmosMsg::Wasm(WasmMsg::Execute { msg, .. }) => { - match from_json::(msg).unwrap() { - ExecuteMsg::Callback(callback_msg) => match callback_msg { - CallbackMsg::Conjunction { operands } => Some(operands), - _ => Some(vec![]), - }, - _ => None, - } + if let cosmwasm_std::CosmosMsg::Wasm(WasmMsg::Execute { msg, .. }) = res.messages[0].msg.clone() + { + if let ExecuteMsg::Callback(CallbackMsg::Conjunction { operands }) = + from_json::(msg).unwrap() + { + // check each operand and make sure there is no memo callback + operands.into_iter().for_each(|operand| { + if let WasmMsg::Execute { msg, .. } = operand { + if let Ok(msg) = from_json::(msg) { + panic!("unexpected callback message: {:?}", msg) + } + } + }) } - _ => None, }; - assert!(memo_callback_msg.is_some()); - assert!(memo_callback_msg.unwrap().is_empty()); } #[test] diff --git a/packages/ics721/src/testing/integration_tests.rs b/packages/ics721/src/testing/integration_tests.rs index f6814f2c..cf3dcf30 100644 --- a/packages/ics721/src/testing/integration_tests.rs +++ b/packages/ics721/src/testing/integration_tests.rs @@ -21,7 +21,7 @@ use crate::{ ibc::Ics721Ibc, msg::{CallbackMsg, ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg}, query::Ics721Query, - state::CollectionData, + state::{CollectionData, UniversalAllNftInfoResponse}, token_types::VoucherCreation, ContractError, }; @@ -33,6 +33,7 @@ use ics721_types::{ use super::contract::Ics721Contract; const ICS721_CREATOR: &str = "ics721-creator"; +const ICS721_ADMIN: &str = "ics721-admin"; const CONTRACT_NAME: &str = "crates.io:ics721-base"; const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -340,6 +341,9 @@ impl Test { false => None, }; + let admin = admin_and_pauser + .clone() + .map(|p| app.api().addr_make(&p).to_string()); let ics721 = app .instantiate_contract( ics721_id, @@ -348,13 +352,12 @@ impl Test { cw721_base_code_id: source_cw721_id, incoming_proxy, outgoing_proxy, - pauser: admin_and_pauser - .clone() - .map(|p| app.api().addr_make(&p).to_string()), + pauser: admin.clone(), + cw721_admin: admin.clone(), }, &[], "ics721-base", - admin_and_pauser.map(|p| app.api().addr_make(&p).to_string()), + admin.clone(), ) .unwrap(); @@ -367,7 +370,7 @@ impl Test { &Cw721InstantiateMsg { name: "name".to_string(), symbol: "symbol".to_string(), - minter: source_cw721_owner.to_string(), + minter: Some(source_cw721_owner.to_string()), withdraw_address: None, }, &[], @@ -461,6 +464,13 @@ impl Test { .unwrap() } + fn query_cw721_admin(&mut self) -> Option { + self.app + .wrap() + .query_wasm_smart(self.ics721.clone(), &QueryMsg::Cw721Admin {}) + .unwrap() + } + fn query_nft_contracts(&mut self) -> Vec<(String, Addr)> { self.app .wrap() @@ -500,6 +510,19 @@ impl Test { .unwrap() } + fn query_cw721_all_nft_info(&mut self, token_id: String) -> UniversalAllNftInfoResponse { + self.app + .wrap() + .query_wasm_smart( + self.source_cw721.clone(), + &cw721_base::msg::QueryMsg::::AllNftInfo { + token_id, + include_expired: None, + }, + ) + .unwrap() + } + fn execute_cw721_mint(&mut self, owner: Addr) -> Result { self.nfts_minted += 1; @@ -570,7 +593,14 @@ fn outgoing_proxy_contract() -> Box> { #[test] fn test_instantiate() { - let mut test = Test::new(true, true, None, None, cw721_base_contract(), true); + let mut test = Test::new( + true, + true, + None, + Some(ICS721_ADMIN.to_string()), + cw721_base_contract(), + true, + ); // check stores are properly initialized let cw721_id = test.query_cw721_id(); @@ -585,6 +615,8 @@ fn test_instantiate() { assert!(outgoing_proxy.is_some()); let incoming_proxy = test.query_incoming_proxy(); assert!(incoming_proxy.is_some()); + let cw721_admin = test.query_cw721_admin(); + assert_eq!(cw721_admin, Some(test.app.api().addr_make(ICS721_ADMIN))); } #[test] @@ -1768,11 +1800,12 @@ fn test_proxy_authorized() { &cw721_base::InstantiateMsg { name: "token".to_string(), symbol: "nonfungible".to_string(), - minter: test - .app - .api() - .addr_make(COLLECTION_OWNER_SOURCE_CHAIN) - .to_string(), + minter: Some( + test.app + .api() + .addr_make(COLLECTION_OWNER_SOURCE_CHAIN) + .to_string(), + ), withdraw_address: None, }, &[], @@ -1973,6 +2006,151 @@ fn test_receive_nft() { } } +#[test] +fn test_admin_clean_and_unescrow_nft() { + // test case: receive nft from cw721-base + { + let mut test = Test::new( + false, + false, + None, + Some(ICS721_ADMIN_AND_PAUSER.to_string()), + cw721_base_contract(), + true, + ); + // simplify: mint and escrowed/owned by ics721, as a precondition for receive nft + let token_id_escrowed_by_ics721 = test.execute_cw721_mint(test.ics721.clone()).unwrap(); + let recipient = test.app.api().addr_make("recipient"); + let token_id_from_owner = test.execute_cw721_mint(recipient.clone()).unwrap(); + let channel = "channel-0".to_string(); + test.app + .execute_contract( + test.source_cw721.clone(), + test.ics721.clone(), + &ExecuteMsg::ReceiveNft(cw721::Cw721ReceiveMsg { + sender: test.source_cw721_owner.to_string(), + token_id: token_id_escrowed_by_ics721.clone(), + msg: to_json_binary(&IbcOutgoingMsg { + receiver: NFT_OWNER_TARGET_CHAIN.to_string(), // nft owner for other chain, on this chain ics721 is owner + channel_id: channel.clone(), + timeout: IbcTimeout::with_block(IbcTimeoutBlock { + revision: 0, + height: 10, + }), + memo: None, + }) + .unwrap(), + }), + &[], + ) + .unwrap(); + // check outgoing channel entry + let outgoing_channel = test.query_outgoing_channels(); + assert_eq!(outgoing_channel.len(), 1); + let class_id = ClassId::new(test.source_cw721.to_string()); + assert_eq!( + outgoing_channel, + vec![( + (class_id.to_string(), token_id_escrowed_by_ics721.clone()), + channel.clone() + )] + ); + // assert nft is escrowed + let UniversalAllNftInfoResponse { access, .. } = + test.query_cw721_all_nft_info(token_id_escrowed_by_ics721.clone()); + assert_eq!(access.owner, test.ics721.to_string()); + + // non admin can't call + let non_admin = test.app.api().addr_make("not_admin"); + let admin = test.app.api().addr_make(ICS721_ADMIN_AND_PAUSER); + let clean_and_burn_msg = ExecuteMsg::AdminCleanAndBurnNft { + owner: recipient.to_string(), + token_id: token_id_escrowed_by_ics721.clone(), + class_id: class_id.to_string(), + collection: test.source_cw721.to_string(), + }; + let err: ContractError = test + .app + .execute_contract( + non_admin.clone(), + test.ics721.clone(), + &clean_and_burn_msg, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!(err, ContractError::Unauthorized {}); + + let clean_and_unescrow_msg = ExecuteMsg::AdminCleanAndUnescrowNft { + recipient: recipient.to_string(), + token_id: token_id_from_owner.clone(), // not escrowed by ics721 + class_id: class_id.to_string(), + collection: test.source_cw721.to_string(), + }; + let err: ContractError = test + .app + .execute_contract( + admin.clone(), + test.ics721.clone(), + &clean_and_unescrow_msg, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + ContractError::NotEscrowedByIcs721(recipient.to_string()) + ); + + // unknown class id + let clean_and_unescrow_msg = ExecuteMsg::AdminCleanAndUnescrowNft { + recipient: recipient.to_string(), + token_id: token_id_escrowed_by_ics721.to_string(), + class_id: "unknown".to_string(), + collection: test.source_cw721.to_string(), + }; + let err: ContractError = test + .app + .execute_contract( + admin.clone(), + test.ics721.clone(), + &clean_and_unescrow_msg, + &[], + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + ContractError::NoNftContractForClassId("unknown".to_string()) + ); + + let clean_and_unescrow_msg = ExecuteMsg::AdminCleanAndUnescrowNft { + recipient: recipient.to_string(), + token_id: token_id_escrowed_by_ics721.clone(), + class_id: class_id.to_string(), + collection: test.source_cw721.to_string(), + }; + test.app + .execute_contract( + admin.clone(), + test.ics721.clone(), + &clean_and_unescrow_msg, + &[], + ) + .unwrap(); + // asert outgoing channel entry is removed + let outgoing_channel = test.query_outgoing_channels(); + assert_eq!(outgoing_channel.len(), 0); + // check nft is unescrowed + let UniversalAllNftInfoResponse { access, .. } = + test.query_cw721_all_nft_info(token_id_escrowed_by_ics721.clone()); + assert_eq!(access.owner, recipient.to_string()); + } +} + /// In case proxy for ICS721 is defined, ICS721 only accepts receival from proxy - not from nft contract! #[test] fn test_no_receive_with_proxy() { @@ -2072,6 +2250,7 @@ fn test_pause() { incoming_proxy: None, outgoing_proxy: None, cw721_base_code_id: None, + cw721_admin: None, }) .unwrap(), } @@ -2114,9 +2293,10 @@ fn test_migration() { assert_eq!(cw721_code_id, test.source_cw721_id); // migrate changes + let admin = test.app.api().addr_make(ICS721_ADMIN_AND_PAUSER); test.app .execute( - test.app.api().addr_make(ICS721_ADMIN_AND_PAUSER), + admin.clone(), WasmMsg::Migrate { contract_addr: test.ics721.to_string(), new_code_id: test.ics721_id, @@ -2125,6 +2305,7 @@ fn test_migration() { incoming_proxy: None, outgoing_proxy: None, cw721_base_code_id: Some(12345678), + cw721_admin: Some(admin.to_string()), }) .unwrap(), } @@ -2138,6 +2319,7 @@ fn test_migration() { assert!(proxy.is_none()); let cw721_code_id = test.query_cw721_id(); assert_eq!(cw721_code_id, 12345678); + assert_eq!(test.query_cw721_admin(), Some(admin),); // migrate without changing code id test.app @@ -2151,6 +2333,7 @@ fn test_migration() { incoming_proxy: None, outgoing_proxy: None, cw721_base_code_id: None, + cw721_admin: Some("".to_string()), }) .unwrap(), } @@ -2164,4 +2347,5 @@ fn test_migration() { assert!(proxy.is_none()); let cw721_code_id = test.query_cw721_id(); assert_eq!(cw721_code_id, 12345678); + assert_eq!(test.query_cw721_admin(), None,); } diff --git a/ts-relayer-tests/src/cw721-utils.ts b/ts-relayer-tests/src/cw721-utils.ts index 7a78d972..ce447f00 100644 --- a/ts-relayer-tests/src/cw721-utils.ts +++ b/ts-relayer-tests/src/cw721-utils.ts @@ -48,6 +48,29 @@ export function sendNft( ); } +export function approve( + client: CosmWasmSigner, + cw721Contract: string, + spender: string, + token_id: string +) { + // msg to be executed on cw721 contract + const msg = { + approve: { + token_id, + spender, + }, + }; + return client.sign.execute( + client.senderAddress, + cw721Contract, + msg, + "auto", // fee + undefined, // no memo + undefined // no funds + ); +} + // ######### query export function allTokens( client: CosmWasmSigner, @@ -104,3 +127,13 @@ export function ownerOf( }; return client.sign.queryContractSmart(cw721Contract, msg); } + +export function numTokens( + client: CosmWasmSigner, + cw721Contract: string +): Promise<{ count: number }> { + const msg = { + num_tokens: {}, + }; + return client.sign.queryContractSmart(cw721Contract, msg); +} diff --git a/ts-relayer-tests/src/ics721-utils.ts b/ts-relayer-tests/src/ics721-utils.ts index 398eefac..e8142554 100644 --- a/ts-relayer-tests/src/ics721-utils.ts +++ b/ts-relayer-tests/src/ics721-utils.ts @@ -21,3 +21,104 @@ export function migrate( undefined ); } + +export function migrateIncomingProxy( + client: CosmWasmSigner, + contractAddress: string, + codeId: number, + channels?: string[], + origin?: string +) { + const msg = { + with_update: { origin, channels }, + }; + return client.sign.migrate( + client.senderAddress, + contractAddress, + codeId, + msg, + "auto", + undefined + ); +} + +export function adminCleanAndUnescrowNft( + client: CosmWasmSigner, + contractAddress: string, + recipient: string, + token_id: string, + class_id: string, + collection: string +) { + const msg = { + admin_clean_and_unescrow_nft: { + recipient, + token_id, + class_id, + collection, + }, + }; + return client.sign.execute( + client.senderAddress, + contractAddress, + msg, + "auto", + undefined + ); +} + +export function adminCleanAndBurnNft( + client: CosmWasmSigner, + contractAddress: string, + owner: string, + token_id: string, + class_id: string, + collection: string +) { + const msg = { + admin_clean_and_burn_nft: { + owner, + token_id, + class_id, + collection, + }, + }; + return client.sign.execute( + client.senderAddress, + contractAddress, + msg, + "auto", + undefined + ); +} + +// ######### query +export function nftContracts( + client: CosmWasmSigner, + contractAddress: string +): Promise<[string, string][]> { + const msg = { + nft_contracts: {}, + }; + return client.sign.queryContractSmart(contractAddress, msg); +} + +export function outgoingChannels( + client: CosmWasmSigner, + contractAddress: string +): Promise<[[string, string], string][]> { + const msg = { + outgoing_channels: {}, + }; + return client.sign.queryContractSmart(contractAddress, msg); +} + +export function incomingChannels( + client: CosmWasmSigner, + contractAddress: string +): Promise<[[string, string], string][]> { + const msg = { + incoming_channels: {}, + }; + return client.sign.queryContractSmart(contractAddress, msg); +} diff --git a/ts-relayer-tests/src/ics721.spec.ts b/ts-relayer-tests/src/ics721.spec.ts index 530de734..746ff153 100644 --- a/ts-relayer-tests/src/ics721.spec.ts +++ b/ts-relayer-tests/src/ics721.spec.ts @@ -1,10 +1,19 @@ import { CosmWasmSigner } from "@confio/relayer"; +import { fromUtf8 } from "@cosmjs/encoding"; import anyTest, { ExecutionContext, TestFn } from "ava"; import { Order } from "cosmjs-types/ibc/core/channel/v1/channel"; import { instantiateContract } from "./controller"; -import { mint, ownerOf, sendNft } from "./cw721-utils"; -import { migrate } from "./ics721-utils"; +import { allTokens, approve, mint, ownerOf, sendNft } from "./cw721-utils"; +import { + adminCleanAndBurnNft, + adminCleanAndUnescrowNft, + incomingChannels, + migrate, + migrateIncomingProxy, + nftContracts, + outgoingChannels, +} from "./ics721-utils"; import { assertAckErrors, assertAckSuccess, @@ -28,14 +37,17 @@ interface TestContext { wasmCw721: string; wasmIcs721: string; + wasmCw721IncomingProxyId: number; wasmCw721IncomingProxy: string; + wasmCw721OutgoingProxy: string; osmoCw721: string; osmoIcs721: string; + osmoCw721IncomingProxy: string; osmoCw721OutgoingProxy: string; channel: ChannelAndLinkInfo; - + onlyOsmoIncomingChannel: ChannelAndLinkInfo; // this channel is WLed only in incoming proxy on osmo side otherChannel: ChannelAndLinkInfo; } @@ -70,6 +82,10 @@ const standardSetup = async (t: ExecutionContext) => { path: WASM_FILE_CW721_INCOMING_PROXY, instantiateMsg: undefined, }, + cw721OutgoingProxy: { + path: WASM_FILE_CW721_OUTGOING_PROXY, + instantiateMsg: undefined, + }, ics721: { path: WASM_FILE_CW_ICS721_ICS721, instantiateMsg: undefined, @@ -84,6 +100,10 @@ const standardSetup = async (t: ExecutionContext) => { minter: osmoClient.senderAddress, }, }, + cw721IncomingProxy: { + path: WASM_FILE_CW721_INCOMING_PROXY, + instantiateMsg: undefined, + }, cw721OutgoingProxy: { path: WASM_FILE_CW721_OUTGOING_PROXY, instantiateMsg: undefined, @@ -106,10 +126,15 @@ const standardSetup = async (t: ExecutionContext) => { const wasmCw721IncomingProxyId = info.wasmContractInfos.cw721IncomingProxy.codeId; + t.context.wasmCw721IncomingProxyId = wasmCw721IncomingProxyId; + const osmoCw721IncomingProxyId = + info.osmoContractInfos.cw721IncomingProxy.codeId; const wasmIcs721Id = info.wasmContractInfos.ics721.codeId; const osmoIcs721Id = info.osmoContractInfos.ics721.codeId; + const wasmCw721OutgoingProxyId = + info.wasmContractInfos.cw721OutgoingProxy.codeId; const osmoCw721OutgoingProxyId = info.osmoContractInfos.cw721OutgoingProxy.codeId; @@ -147,11 +172,17 @@ const standardSetup = async (t: ExecutionContext) => { Order.ORDER_UNORDERED, "ics721-1" ); - t.log(`- channel: ${JSON.stringify(channelInfo, bigIntReplacer, 2)}`); + t.log( + `- channel for incoming proxy on both chains: ${JSON.stringify( + channelInfo.channel, + bigIntReplacer, + 2 + )}` + ); t.context.channel = channelInfo; t.log( - `instantiating wasm cw721-incoming-proxy (${wasmCw721IncomingProxyId}) for channel ${channelInfo.channel.src.channelId}` + `instantiating wasm cw721-incoming-proxy (${wasmCw721IncomingProxyId}) with channel ${channelInfo.channel.src.channelId}` ); const { contractAddress: wasmCw721IncomingProxy } = await instantiateContract( wasmClient, @@ -166,11 +197,61 @@ const standardSetup = async (t: ExecutionContext) => { t.context.wasmCw721IncomingProxy = wasmCw721IncomingProxy; t.log( - `migrate ${wasmIcs721} contract to use incoming proxy ${wasmCw721IncomingProxy}` + `migrate ${wasmIcs721} contract with incoming proxy ${wasmCw721IncomingProxy}` ); await migrate(wasmClient, wasmIcs721, wasmIcs721Id, wasmCw721IncomingProxy); + const onlyOsmoIncomingChannelInfo = await createIbcConnectionAndChannel( + wasmClient, + osmoClient, + wasmIcs721, + osmoIcs721, + Order.ORDER_UNORDERED, + "ics721-1" + ); + t.log( + `- channel for incoming proxy only on wasm chain: ${JSON.stringify( + onlyOsmoIncomingChannelInfo.channel, + bigIntReplacer, + 2 + )}` + ); + t.context.onlyOsmoIncomingChannel = onlyOsmoIncomingChannelInfo; + + t.log( + `instantiating osmo cw721-incoming-proxy (${osmoCw721IncomingProxyId}) with channel ${channelInfo.channel.dest.channelId}and ${onlyOsmoIncomingChannelInfo.channel.dest.channelId}` + ); + const { contractAddress: osmoCw721IncomingProxy } = await instantiateContract( + osmoClient, + osmoCw721IncomingProxyId, + { + origin: osmoIcs721, + channels: [ + channelInfo.channel.dest.channelId, + onlyOsmoIncomingChannelInfo.channel.dest.channelId, + ], + }, + "label incoming proxy" + ); + t.log(`- osmo cw721-incoming-proxy address: ${osmoCw721IncomingProxy}`); + t.context.osmoCw721IncomingProxy = osmoCw721IncomingProxy; + const per_block = 10; // use high rate limit to avoid test failures + t.log( + `instantiating wasm cw721-outgoing-proxy (${wasmCw721OutgoingProxyId}) with ${per_block} per blocks rate limit` + ); + const { contractAddress: wasmCw721OutgoingProxy } = await instantiateContract( + wasmClient, + wasmCw721OutgoingProxyId, + { + origin: wasmIcs721, + rate_limit: { per_block }, + }, + "label outgoing proxy" + ); + t.log(`- wasm cw721-outgoing-proxy address: ${wasmCw721OutgoingProxy}`); + t.context.wasmCw721OutgoingProxy = wasmCw721OutgoingProxy; + t.log( `instantiating osmo cw721-outgoing-proxy (${osmoCw721OutgoingProxyId}) with ${per_block} per blocks rate limit` ); @@ -187,13 +268,24 @@ const standardSetup = async (t: ExecutionContext) => { t.context.osmoCw721OutgoingProxy = osmoCw721OutgoingProxy; t.log( - `migrate ${osmoIcs721} contract to use outgoing proxy ${osmoCw721OutgoingProxy}` + `migrate ${wasmIcs721} contract with incoming (${wasmCw721IncomingProxy}) and outgoing proxy (${wasmCw721OutgoingProxy})` + ); + await migrate( + wasmClient, + wasmIcs721, + wasmIcs721Id, + wasmCw721IncomingProxy, + wasmCw721OutgoingProxy + ); + + t.log( + `migrate ${osmoIcs721} contract with incoming (${osmoCw721IncomingProxy}) and outgoing proxy (${osmoCw721OutgoingProxy})` ); await migrate( osmoClient, osmoIcs721, osmoIcs721Id, - undefined, + osmoCw721IncomingProxy, osmoCw721OutgoingProxy ); @@ -208,6 +300,13 @@ const standardSetup = async (t: ExecutionContext) => { Order.ORDER_UNORDERED, "ics721-1" ); + t.log( + `- other channel not WLed for incoming proxy: ${JSON.stringify( + otherChannelInfo.channel, + bigIntReplacer, + 2 + )}` + ); t.context.otherChannel = otherChannelInfo; t.pass(); @@ -221,20 +320,27 @@ test.serial("transfer NFT: wasmd -> osmo", async (t) => { wasmAddr, wasmCw721, wasmIcs721, + wasmCw721IncomingProxyId, + wasmCw721IncomingProxy, + wasmCw721OutgoingProxy, osmoClient, osmoAddr, osmoIcs721, channel, + otherChannel, + onlyOsmoIncomingChannel, } = t.context; - t.log(JSON.stringify(wasmClient, undefined, 2)); - const tokenId = "1"; + let tokenId = "1"; await mint(wasmClient, wasmCw721, tokenId, wasmAddr, undefined); // assert token is minted let tokenOwner = await ownerOf(wasmClient, wasmCw721, tokenId); t.is(wasmAddr, tokenOwner.owner); - const ibcMsg = { + // ==== happy path: transfer NFT to osmo chain and back to wasm chain ==== + // test transfer NFT to osmo chain + t.log(`transfering to osmo chain via ${channel.channel.src.channelId}`); + let ibcMsg = { receiver: osmoAddr, channel_id: channel.channel.src.channelId, timeout: { @@ -244,162 +350,594 @@ test.serial("transfer NFT: wasmd -> osmo", async (t) => { }, }, }; + let transferResponse = await sendNft( + wasmClient, + wasmCw721, + wasmCw721OutgoingProxy, + ibcMsg, + tokenId + ); + t.truthy(transferResponse); - t.log(`transfering to osmo chain via ${channel.channel.src.channelId}`); + // Relay and verify we got a success + t.log("relaying packets"); + let info = await channel.link.relayAll(); + assertAckSuccess(info.acksFromA); - const transferResponse = await sendNft( + // assert NFT on chain A is locked/owned by ICS contract + tokenOwner = await ownerOf(wasmClient, wasmCw721, tokenId); + t.is(wasmIcs721, tokenOwner.owner); + // assert NFT minted on chain B + let osmoClassId = `${channel.channel.dest.portId}/${channel.channel.dest.channelId}/${t.context.wasmCw721}`; + let osmoCw721 = await osmoClient.sign.queryContractSmart(osmoIcs721, { + nft_contract: { class_id: osmoClassId }, + }); + let allNFTs = await allTokens(osmoClient, osmoCw721); + t.true(allNFTs.tokens.length === 1); + // assert NFT on chain B is owned by osmoAddr + tokenOwner = await ownerOf(osmoClient, osmoCw721, tokenId); + t.is(osmoAddr, tokenOwner.owner); + + // test back transfer NFT to wasm chain + t.log(`transfering back to wasm chain via ${channel.channel.dest.channelId}`); + transferResponse = await sendNft( + osmoClient, + osmoCw721, + t.context.osmoCw721OutgoingProxy, + { + receiver: wasmAddr, + channel_id: channel.channel.dest.channelId, + timeout: { + block: { + revision: 1, + height: 90000, + }, + }, + }, + tokenId + ); + t.truthy(transferResponse); + t.log("relaying packets"); + + // Verify we got a success + info = await channel.link.relayAll(); + assertAckSuccess(info.acksFromA); + + // assert NFT burned on chain B + allNFTs = await allTokens(osmoClient, osmoCw721); + t.true(allNFTs.tokens.length === 0); + // assert NFT on chain A is returned to owner + tokenOwner = await ownerOf(wasmClient, wasmCw721, tokenId); + t.is(wasmAddr, tokenOwner.owner); + + // ==== test transfer NFT to osmo chain via unknown, not WLed channel by incoming proxy ==== + // test rejected NFT transfer due to unknown channel by incoming proxy + tokenId = "2"; + await mint(wasmClient, wasmCw721, tokenId, wasmAddr, undefined); + // assert token is minted + tokenOwner = await ownerOf(wasmClient, wasmCw721, tokenId); + t.is(wasmAddr, tokenOwner.owner); + + t.log( + `transfering to osmo chain via unknown ${otherChannel.channel.src.channelId}` + ); + const beforeWasmOutgoingClassTokenToChannelList = await outgoingChannels( + wasmClient, + wasmIcs721 + ); + const beforeWasmIncomingClassTokenToChannelList = await incomingChannels( + wasmClient, + wasmIcs721 + ); + const beforeWasmNftContractsToClassIdList = await nftContracts( + wasmClient, + wasmIcs721 + ); + const beforeOsmoOutgoingClassTokenToChannelList = await outgoingChannels( + osmoClient, + osmoIcs721 + ); + const beforeOsmoIncomingClassTokenToChannelList = await incomingChannels( + osmoClient, + osmoIcs721 + ); + const beforeOsmoNftContractsToClassIdList = await nftContracts( + osmoClient, + osmoIcs721 + ); + + ibcMsg = { + receiver: osmoAddr, + channel_id: otherChannel.channel.src.channelId, + timeout: { + block: { + revision: 1, + height: 90000, + }, + }, + }; + transferResponse = await sendNft( wasmClient, wasmCw721, - wasmIcs721, + wasmCw721OutgoingProxy, ibcMsg, tokenId ); t.truthy(transferResponse); + // Relay and verify we got an error t.log("relaying packets"); + info = await otherChannel.link.relayAll(); + assertAckErrors(info.acksFromA); + // assert no change before and after relay + const afterWasmOutgoingClassTokenToChannelList = await outgoingChannels( + wasmClient, + wasmIcs721 + ); + const afterWasmIncomingClassTokenToChannelList = await incomingChannels( + wasmClient, + wasmIcs721 + ); + const afterWasmNftContractsToClassIdList = await nftContracts( + wasmClient, + wasmIcs721 + ); + t.deepEqual( + beforeWasmOutgoingClassTokenToChannelList, + afterWasmOutgoingClassTokenToChannelList, + `outgoing channels must be unchanged: +- wasm before: ${JSON.stringify(beforeWasmOutgoingClassTokenToChannelList)} +- wasm after: ${JSON.stringify(afterWasmOutgoingClassTokenToChannelList)}` + ); + t.deepEqual( + beforeWasmIncomingClassTokenToChannelList, + afterWasmIncomingClassTokenToChannelList, + `incoming channels must be unchanged: +- wasm before: ${JSON.stringify(beforeWasmIncomingClassTokenToChannelList)} +- wasm after: ${JSON.stringify(afterWasmIncomingClassTokenToChannelList)}` + ); + t.deepEqual( + beforeWasmNftContractsToClassIdList, + afterWasmNftContractsToClassIdList, + `nft contracts must be unchanged: +- wasm before: ${JSON.stringify(beforeWasmNftContractsToClassIdList)} +- wasm after: ${JSON.stringify(afterWasmNftContractsToClassIdList)}` + ); + const afterOsmoOutgoingClassTokenToChannelList = await outgoingChannels( + osmoClient, + osmoIcs721 + ); + const afterOsmoIncomingClassTokenToChannelList = await incomingChannels( + osmoClient, + osmoIcs721 + ); + const afterOsmoNftContractsToClassIdList = await nftContracts( + osmoClient, + osmoIcs721 + ); + t.deepEqual( + beforeOsmoOutgoingClassTokenToChannelList, + afterOsmoOutgoingClassTokenToChannelList, + `outgoing channels must be unchanged: +- osmo before: ${JSON.stringify(beforeOsmoOutgoingClassTokenToChannelList)} +- osmo after: ${JSON.stringify(afterOsmoOutgoingClassTokenToChannelList)}` + ); + t.deepEqual( + beforeOsmoIncomingClassTokenToChannelList, + afterOsmoIncomingClassTokenToChannelList, + `incoming channels must be unchanged: +- osmo before: ${JSON.stringify(beforeOsmoIncomingClassTokenToChannelList)} +- osmo after: ${JSON.stringify(afterOsmoIncomingClassTokenToChannelList)}` + ); + t.deepEqual( + beforeOsmoNftContractsToClassIdList, + afterOsmoNftContractsToClassIdList, + `nft contracts must be unchanged: +- osmo before: ${JSON.stringify(beforeOsmoNftContractsToClassIdList)} +- osmo after: ${JSON.stringify(afterOsmoNftContractsToClassIdList)}` + ); - const info = await channel.link.relayAll(); + // assert NFT on chain A is returned to owner + tokenOwner = await ownerOf(wasmClient, wasmCw721, tokenId); + t.is(wasmAddr, tokenOwner.owner); - // Verify we got a success - assertAckSuccess(info.acksFromB); + // ==== test transfer NFT to osmo chain via channel WLed ONLY on osmo incoming proxy and back to wasm chain ==== + tokenId = "3"; + await mint(wasmClient, wasmCw721, tokenId, wasmAddr, undefined); + // assert token is minted + tokenOwner = await ownerOf(wasmClient, wasmCw721, tokenId); + t.is(wasmAddr, tokenOwner.owner); + // test transfer NFT to osmo chain + t.log( + `transfering to osmo chain via ${onlyOsmoIncomingChannel.channel.src.channelId}` + ); + ibcMsg = { + receiver: osmoAddr, + channel_id: onlyOsmoIncomingChannel.channel.src.channelId, + timeout: { + block: { + revision: 1, + height: 90000, + }, + }, + }; + transferResponse = await sendNft( + wasmClient, + wasmCw721, + wasmCw721OutgoingProxy, + ibcMsg, + tokenId + ); + t.truthy(transferResponse); + + // Relay and verify we got a success + t.log("relaying packets"); + info = await onlyOsmoIncomingChannel.link.relayAll(); + assertAckSuccess(info.acksFromA); + + // assert 1 entry for outgoing channels + let wasmOutgoingClassTokenToChannelList = await outgoingChannels( + wasmClient, + wasmIcs721 + ); + t.log( + `- outgoing channels: ${JSON.stringify( + wasmOutgoingClassTokenToChannelList + )}` + ); + t.true( + wasmOutgoingClassTokenToChannelList.length === 1, + `outgoing channels must have one entry: ${JSON.stringify( + wasmOutgoingClassTokenToChannelList + )}` + ); + + // assert NFT minted on chain B + osmoClassId = `${onlyOsmoIncomingChannel.channel.dest.portId}/${onlyOsmoIncomingChannel.channel.dest.channelId}/${t.context.wasmCw721}`; + osmoCw721 = await osmoClient.sign.queryContractSmart(osmoIcs721, { + nft_contract: { class_id: osmoClassId }, + }); + allNFTs = await allTokens(osmoClient, osmoCw721); + t.true(allNFTs.tokens.length === 1); + // assert NFT on chain B is owned by osmoAddr + tokenOwner = await ownerOf(osmoClient, osmoCw721, tokenId); + t.is(osmoAddr, tokenOwner.owner); // assert NFT on chain A is locked/owned by ICS contract tokenOwner = await ownerOf(wasmClient, wasmCw721, tokenId); t.is(wasmIcs721, tokenOwner.owner); - - const osmoClassId = `${t.context.channel.channel.dest.portId}/${t.context.channel.channel.dest.channelId}/${t.context.wasmCw721}`; - const osmoCw721 = await osmoClient.sign.queryContractSmart(osmoIcs721, { + // assert NFT on chain B is owned by osmoAddr + osmoClassId = `${onlyOsmoIncomingChannel.channel.dest.portId}/${onlyOsmoIncomingChannel.channel.dest.channelId}/${t.context.wasmCw721}`; + osmoCw721 = await osmoClient.sign.queryContractSmart(osmoIcs721, { nft_contract: { class_id: osmoClassId }, }); - tokenOwner = await ownerOf(osmoClient, osmoCw721, tokenId); t.is(osmoAddr, tokenOwner.owner); -}); -test.serial( - "transfer NFT with osmo outgoing and wasm incoming proxy", - async (t) => { - await standardSetup(t); - - const { - wasmClient, - wasmAddr, - wasmIcs721, - osmoClient, - osmoAddr, - osmoCw721, - osmoIcs721, - osmoCw721OutgoingProxy, - channel, - otherChannel, - } = t.context; - - // test 1: transfer via outgoing proxy and using WLed channel by incoming proxy - let tokenId = "1"; - t.log(`transferring NFT #${tokenId} from osmo to wasmd chain`); - await mint(osmoClient, osmoCw721, tokenId, osmoAddr, undefined); - // assert token is minted - let tokenOwner = await ownerOf(osmoClient, osmoCw721, tokenId); - t.is(osmoAddr, tokenOwner.owner); - - let ibcMsg = { + // test back transfer NFT to wasm chain, where onlyOsmoIncomingChannel is not WLed on wasm chain + t.log( + `transfering back to wasm chain via unknown ${onlyOsmoIncomingChannel.channel.dest.channelId}` + ); + transferResponse = await sendNft( + osmoClient, + osmoCw721, + t.context.osmoCw721OutgoingProxy, + { receiver: wasmAddr, - channel_id: channel.channel.dest.channelId, + channel_id: onlyOsmoIncomingChannel.channel.dest.channelId, timeout: { block: { revision: 1, height: 90000, }, }, - }; - - t.log( - `transfering to wasm chain via ${channel.channel.dest.channelId} and outgoing proxy ${osmoCw721OutgoingProxy}` - ); - - let transferResponse = await sendNft( - osmoClient, - osmoCw721, - osmoCw721OutgoingProxy, - ibcMsg, - tokenId - ); - t.truthy(transferResponse); - - t.log("relaying packets"); - - let info = await channel.link.relayAll(); - - // Verify we got a success - assertAckSuccess(info.acksFromA); - - // assert NFT on chain A is locked/owned by ICS contract - tokenOwner = await ownerOf(osmoClient, osmoCw721, tokenId); - t.is(osmoIcs721, tokenOwner.owner); - t.log(`NFT #${tokenId} locked by ICS721 contract`); - - const wasmClassId = `${t.context.channel.channel.src.portId}/${t.context.channel.channel.src.channelId}/${t.context.osmoCw721}`; - const wasmCw721 = await wasmClient.sign.queryContractSmart(wasmIcs721, { - nft_contract: { class_id: wasmClassId }, - }); - - tokenOwner = await ownerOf(wasmClient, wasmCw721, tokenId); - t.is(wasmAddr, tokenOwner.owner); - t.log(`NFT #${tokenId} transferred to ${wasmAddr}`); - - // test 2: transfer via outgoing proxy and using unknown channel by incoming proxy - tokenId = "2"; - t.log(`transferring NFT #${tokenId} from osmo to wasmd chain`); - await mint(osmoClient, osmoCw721, tokenId, osmoAddr, undefined); - // assert token is minted - tokenOwner = await ownerOf(osmoClient, osmoCw721, tokenId); - t.is(osmoAddr, tokenOwner.owner); - - ibcMsg = { + }, + tokenId + ); + t.truthy(transferResponse); + // before relay NFT escrowed by ICS721 + tokenOwner = await ownerOf(osmoClient, osmoCw721, tokenId); + t.is(osmoIcs721, tokenOwner.owner); + + // Relay and verify we got an error + t.log("relaying packets"); + info = await onlyOsmoIncomingChannel.link.relayAll(); + for (const ack of info.acksFromB) { + const parsed = JSON.parse(fromUtf8(ack.acknowledgement)); + t.log(`- ack: ${JSON.stringify(parsed)}`); + } + assertAckErrors(info.acksFromB); + + // assert after failed relay, NFT on chain B is returned to owner + allNFTs = await allTokens(osmoClient, osmoCw721); + t.true(allNFTs.tokens.length === 1); + // assert NFT is returned to sender on osmo chain + tokenOwner = await ownerOf(osmoClient, osmoCw721, tokenId); + t.is(osmoAddr, tokenOwner.owner); + + // ==== WL channel on wasm chain and test back transfer again ==== + t.log( + `migrate ${wasmCw721IncomingProxy} contract and add channel ${onlyOsmoIncomingChannel.channel.src.channelId}` + ); + await migrateIncomingProxy( + wasmClient, + wasmCw721IncomingProxy, + wasmCw721IncomingProxyId, + [ + channel.channel.src.channelId, + onlyOsmoIncomingChannel.channel.src.channelId, + ] + ); + + // test back transfer NFT to wasm chain, where onlyOsmoIncomingChannel is not WLed on wasm chain + t.log( + `transfering back to wasm chain via WLed ${onlyOsmoIncomingChannel.channel.dest.channelId}` + ); + transferResponse = await sendNft( + osmoClient, + osmoCw721, + t.context.osmoCw721OutgoingProxy, + { receiver: wasmAddr, - channel_id: otherChannel.channel.dest.channelId, + channel_id: onlyOsmoIncomingChannel.channel.dest.channelId, timeout: { block: { revision: 1, height: 90000, }, }, - }; + }, + tokenId + ); + t.truthy(transferResponse); + // before relay NFT escrowed by ICS721 + tokenOwner = await ownerOf(osmoClient, osmoCw721, tokenId); + t.is(osmoIcs721, tokenOwner.owner); + + allNFTs = await allTokens(osmoClient, osmoCw721); + t.log(`- all tokens: ${JSON.stringify(allNFTs)}`); + + // query nft contracts + let nftContractsToClassIdList = await nftContracts(wasmClient, wasmIcs721); + t.log(`- nft contracts: ${JSON.stringify(nftContractsToClassIdList)}`); + t.true( + nftContractsToClassIdList.length === 1, + `nft contracts must have exactly one entry: ${JSON.stringify( + nftContractsToClassIdList + )}` + ); + + // Relay and verify success + t.log("relaying packets"); + info = await onlyOsmoIncomingChannel.link.relayAll(); + for (const ack of info.acksFromB) { + const parsed = JSON.parse(fromUtf8(ack.acknowledgement)); + t.log(`- ack: ${JSON.stringify(parsed)}`); + } + assertAckSuccess(info.acksFromB); + + // assert outgoing channels is empty + wasmOutgoingClassTokenToChannelList = await outgoingChannels( + wasmClient, + wasmIcs721 + ); + t.true( + wasmOutgoingClassTokenToChannelList.length === 0, + `outgoing channels not empty: ${JSON.stringify( + wasmOutgoingClassTokenToChannelList + )}` + ); - t.log( - `transfering to wasm chain via ${otherChannel.channel.dest.channelId}` - ); + // assert after success relay, NFT on chain B is burned + allNFTs = await allTokens(osmoClient, osmoCw721); + t.log(`- all tokens: ${JSON.stringify(allNFTs)}`); + t.true(allNFTs.tokens.length === 0); + // assert list is unchanged + nftContractsToClassIdList = await nftContracts(wasmClient, wasmIcs721); + t.log(`- nft contracts: ${JSON.stringify(nftContractsToClassIdList)}`); + t.true( + nftContractsToClassIdList.length === 1, + `nft contracts must have exactly one entry: ${JSON.stringify( + nftContractsToClassIdList + )}` + ); + // assert NFT is returned to sender on wasm chain + tokenOwner = await ownerOf(wasmClient, wasmCw721, tokenId); + t.is(wasmAddr, tokenOwner.owner); +}); - transferResponse = await sendNft( - osmoClient, - osmoCw721, - osmoCw721OutgoingProxy, - ibcMsg, - tokenId - ); - t.truthy(transferResponse); +test.serial("admin unescrow and burn NFT: wasmd -> osmo", async (t) => { + await standardSetup(t); - t.log("relaying packets"); + const { + wasmClient, + wasmAddr, + wasmCw721, + wasmIcs721, + wasmCw721OutgoingProxy, + osmoClient, + osmoAddr, + osmoIcs721, + channel, + } = t.context; - info = await otherChannel.link.relayAll(); + const tokenId = "1"; + await mint(wasmClient, wasmCw721, tokenId, wasmAddr, undefined); + // assert token is minted + let tokenOwner = await ownerOf(wasmClient, wasmCw721, tokenId); + t.is(wasmAddr, tokenOwner.owner); - // Verify we got an error - assertAckErrors(info.acksFromA); + // ==== happy path: transfer NFT to osmo chain ==== + // test transfer NFT to osmo chain + t.log(`transfering to osmo chain via ${channel.channel.src.channelId}`); + const ibcMsg = { + receiver: osmoAddr, + channel_id: channel.channel.src.channelId, + timeout: { + block: { + revision: 1, + height: 90000, + }, + }, + }; + const transferResponse = await sendNft( + wasmClient, + wasmCw721, + wasmCw721OutgoingProxy, + ibcMsg, + tokenId + ); + t.truthy(transferResponse); - // assert NFT on chain A is returned to owner - tokenOwner = await ownerOf(osmoClient, osmoCw721, tokenId); - t.is(osmoClient.senderAddress, tokenOwner.owner); - t.log(`NFT #${tokenId} returned to owner`); - } -); + // Relay and verify we got a success + t.log("relaying packets"); + const info = await channel.link.relayAll(); + assertAckSuccess(info.acksFromA); + + // assert NFT on chain A is locked/owned by ICS contract + tokenOwner = await ownerOf(wasmClient, wasmCw721, tokenId); + t.is(wasmIcs721, tokenOwner.owner); + // assert NFT minted on chain B + const osmoClassId = `${channel.channel.dest.portId}/${channel.channel.dest.channelId}/${t.context.wasmCw721}`; + const osmoCw721 = await osmoClient.sign.queryContractSmart(osmoIcs721, { + nft_contract: { class_id: osmoClassId }, + }); + let allNFTs = await allTokens(osmoClient, osmoCw721); + t.is(allNFTs.tokens.length, 1, `all tokens: ${JSON.stringify(allNFTs)}`); + // assert NFT on chain B is owned by osmoAddr + tokenOwner = await ownerOf(osmoClient, osmoCw721, tokenId); + t.is(osmoAddr, tokenOwner.owner); + + const beforeWasmOutgoingClassTokenToChannelList = await outgoingChannels( + wasmClient, + wasmIcs721 + ); + // there should be one outgoing channel entry + t.deepEqual( + beforeWasmOutgoingClassTokenToChannelList, + [[[wasmCw721, tokenId], channel.channel.src.channelId]], + `wasm outgoing channels before: +- before: ${JSON.stringify(beforeWasmOutgoingClassTokenToChannelList)}` + ); + // no incoming channel entry + const beforeWasmIncomingClassTokenToChannelList = await incomingChannels( + wasmClient, + wasmIcs721 + ); + t.deepEqual( + beforeWasmIncomingClassTokenToChannelList, + [], + `wasm incoming channels before: +- before: ${JSON.stringify(beforeWasmIncomingClassTokenToChannelList)}` + ); + // one nft contract entry + const beforeWasmNftContractsToClassIdList = await nftContracts( + wasmClient, + wasmIcs721 + ); + t.deepEqual( + beforeWasmNftContractsToClassIdList, + [[wasmCw721, wasmCw721]], + `wasm nft contracts before: +- before: ${JSON.stringify(beforeWasmNftContractsToClassIdList)}` + ); + + // no outgoing channel entry + const beforeOsmoOutgoingClassTokenToChannelList = await outgoingChannels( + osmoClient, + osmoIcs721 + ); + t.deepEqual( + beforeOsmoOutgoingClassTokenToChannelList, + [], + `osmo outgoing channels before: +- before: ${JSON.stringify(beforeOsmoOutgoingClassTokenToChannelList)}` + ); + // there should be one incoming channel entry + const beforeOsmoIncomingClassTokenToChannelList = await incomingChannels( + osmoClient, + osmoIcs721 + ); + t.deepEqual( + beforeOsmoIncomingClassTokenToChannelList, + [[[osmoClassId, tokenId], channel.channel.dest.channelId]], + `osmo incoming channels before: +- before: ${JSON.stringify(beforeOsmoIncomingClassTokenToChannelList)}` + ); + // one nft contract entry + const beforeOsmoNftContractsToClassIdList = await nftContracts( + osmoClient, + osmoIcs721 + ); + t.deepEqual( + beforeOsmoNftContractsToClassIdList, + [[osmoClassId, osmoCw721]], + `osmo incoming channels before: +- before: ${JSON.stringify(beforeOsmoNftContractsToClassIdList)}` + ); + + // ==== test unescrow NFT on wasm chain ==== + t.log(`unescrow NFT on wasm chain`); + await adminCleanAndUnescrowNft( + wasmClient, + wasmIcs721, + wasmAddr, + tokenId, + wasmCw721, + wasmCw721 + ); + // there should be no outgoing channel entry + const afterWasmOutgoingClassTokenToChannelList = await outgoingChannels( + wasmClient, + wasmIcs721 + ); + t.deepEqual( + afterWasmOutgoingClassTokenToChannelList, + [], + `wasm outgoing channels after: +- after: ${JSON.stringify(afterWasmOutgoingClassTokenToChannelList)}` + ); + // assert NFT on chain A is owned by wasmAddr + tokenOwner = await ownerOf(wasmClient, wasmCw721, tokenId); + t.is(wasmAddr, tokenOwner.owner); + + // ==== test burn NFT on osmo chain ==== + // we need to approve the contract to burn the NFT + t.log(`approve NFT on osmo chain`); + const response = await approve(osmoClient, osmoCw721, osmoIcs721, tokenId); + t.log(`- response: ${JSON.stringify(response, bigIntReplacer, 2)}`); + t.log(`burn NFT on osmo chain`); + await adminCleanAndBurnNft( + osmoClient, + osmoIcs721, + osmoAddr, + tokenId, + osmoClassId, + osmoCw721 + ); + t.log(`- response: ${JSON.stringify(response, bigIntReplacer, 2)}`); + allNFTs = await allTokens(osmoClient, osmoCw721); + t.is(allNFTs.tokens.length, 0); + // there should be no incoming channel entry + const afterOsmoIncomingClassTokenToChannelList = await incomingChannels( + osmoClient, + osmoIcs721 + ); + t.deepEqual( + afterOsmoIncomingClassTokenToChannelList, + [], + `osmo incoming channels after: +- after: ${JSON.stringify(afterOsmoIncomingClassTokenToChannelList)}` + ); +}); test.serial("malicious NFT", async (t) => { await standardSetup(t); - const { wasmClient, wasmAddr, wasmIcs721, + wasmCw721OutgoingProxy, osmoClient, osmoAddr, osmoIcs721, @@ -408,6 +946,7 @@ test.serial("malicious NFT", async (t) => { } = t.context; const tokenId = "1"; + // instantiate malicious cw721 contract const res = await uploadAndInstantiate(wasmClient, { cw721_gas_tester: { path: MALICIOUS_CW721, @@ -415,15 +954,15 @@ test.serial("malicious NFT", async (t) => { name: "evil", symbol: "evil", minter: wasmClient.senderAddress, - target: wasmIcs721, // panic every time the ICS721 contract tries to return a NFT. + banned_recipient: "banned_recipient", // panic every time, on back transfer, when ICS721 tries to transfer/unescrow NFT to this address }, }, }); - const cw721 = res.cw721_gas_tester.address as string; + // ==== test malicious NFT transfer to osmo chain ==== await mint(wasmClient, cw721, tokenId, wasmAddr, undefined); - + t.log("transferring to osmo chain"); let ibcMsg = { receiver: osmoAddr, channel_id: channel.channel.src.channelId, @@ -434,33 +973,34 @@ test.serial("malicious NFT", async (t) => { }, }, }; - - t.log("transferring to osmo chain"); - let transferResponse = await sendNft( wasmClient, cw721, - wasmIcs721, + wasmCw721OutgoingProxy, ibcMsg, tokenId ); t.truthy(transferResponse); t.log("relaying packets"); - let info = await channel.link.relayAll(); - assertAckSuccess(info.acksFromB); - t.log("transferring back to wasm chain"); - + // assert NFT on chain A is locked/owned by ICS contract + let tokenOwner = await ownerOf(wasmClient, cw721, tokenId); + t.is(wasmIcs721, tokenOwner.owner); + // assert NFT on chain B is owned by osmoAddr const osmoClassId = `${t.context.channel.channel.dest.portId}/${t.context.channel.channel.dest.channelId}/${cw721}`; const osmoCw721 = await osmoClient.sign.queryContractSmart(osmoIcs721, { nft_contract: { class_id: osmoClassId }, }); + tokenOwner = await ownerOf(osmoClient, osmoCw721, tokenId); + t.is(osmoAddr, tokenOwner.owner); + // ==== test malicious NFT back transfer to banned recipient on wasm chain ==== + t.log("transferring back to wasm chain to banned recipient"); ibcMsg = { - receiver: wasmAddr, + receiver: "banned_recipient", channel_id: channel.channel.dest.channelId, timeout: { block: { @@ -469,7 +1009,6 @@ test.serial("malicious NFT", async (t) => { }, }, }; - transferResponse = await sendNft( osmoClient, osmoCw721, @@ -478,13 +1017,51 @@ test.serial("malicious NFT", async (t) => { tokenId ); t.truthy(transferResponse); + // before relay NFT escrowed by ICS721 + tokenOwner = await ownerOf(osmoClient, osmoCw721, tokenId); + t.is(osmoIcs721, tokenOwner.owner); t.log("relaying packets"); - - const pending = await channel.link.getPendingPackets("B"); + let pending = await channel.link.getPendingPackets("B"); t.is(pending.length, 1); - // Despite the transfer panicking, a fail ack should be returned. info = await channel.link.relayAll(); assertAckErrors(info.acksFromA); + // assert after failed relay, NFT on chain B is returned to owner + tokenOwner = await ownerOf(osmoClient, osmoCw721, tokenId); + t.is(osmoAddr, tokenOwner.owner); + t.log(`NFT #${tokenId} returned to owner`); + + // ==== test malicious NFT transfer to regular recipient wasm chain ==== + t.log("transferring back to wasm chain to recipient", wasmAddr); + ibcMsg = { + receiver: wasmAddr, + channel_id: channel.channel.dest.channelId, + timeout: { + block: { + revision: 1, + height: 90000, + }, + }, + }; + + transferResponse = await sendNft( + osmoClient, + osmoCw721, + osmoCw721OutgoingProxy, + ibcMsg, + tokenId + ); + t.truthy(transferResponse); + + // Relay and verify we got a success + t.log("relaying packets"); + pending = await channel.link.getPendingPackets("B"); + t.is(pending.length, 1); + info = await channel.link.relayAll(); + assertAckSuccess(info.acksFromB); + + // assert NFT on chain A is returned to owner + tokenOwner = await ownerOf(wasmClient, cw721, tokenId); + t.is(wasmAddr, tokenOwner.owner); }); diff --git a/ts-relayer-tests/src/utils.ts b/ts-relayer-tests/src/utils.ts index d965be95..06ba5696 100644 --- a/ts-relayer-tests/src/utils.ts +++ b/ts-relayer-tests/src/utils.ts @@ -232,10 +232,12 @@ export function assertAckErrors(acks: AckWithMetadata[]) { for (const ack of acks) { const parsed = JSON.parse(fromUtf8(ack.acknowledgement)); if (parsed.result) { - throw new Error(`Ack result unexpectedly set`); + throw new Error(`Ack result unexpectedly set: ${JSON.stringify(parsed)}`); } if (!parsed.error) { - throw new Error(`Ack error unexpectedly empty`); + throw new Error( + `Ack error unexpectedly empty: ${JSON.stringify(parsed)}` + ); } } }