Skip to content

Commit

Permalink
Make class and token data forwardable and queryable (#43)
Browse files Browse the repository at this point in the history
* Added handling class data nad token data

* fmt/clippy

* Apply suggestions from code review

Co-authored-by: ekez <30676292+0xekez@users.noreply.github.com>

* zeke review changes

* added memo

* Update contracts/cw-ics721-bridge/src/contract.rs

Co-authored-by: ekez <30676292+0xekez@users.noreply.github.com>

* zeke comments fixes

* moved tests to testing folder

* Add test for metadata forwarding.

Co-authored-by: ekez <30676292+0xekez@users.noreply.github.com>
Co-authored-by: ekez <zekemedley@gmail.com>
  • Loading branch information
3 people committed Jan 3, 2023
1 parent 84742db commit f2ac26d
Show file tree
Hide file tree
Showing 13 changed files with 561 additions and 243 deletions.
280 changes: 115 additions & 165 deletions contracts/cw-ics721-bridge/src/contract.rs

Large diffs are not rendered by default.

10 changes: 8 additions & 2 deletions contracts/cw-ics721-bridge/src/ibc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ use crate::{
ibc_helpers::{ack_fail, ack_success, try_get_ack_error, validate_order_and_version},
ibc_packet_receive::receive_ibc_packet,
state::{
CLASS_ID_TO_NFT_CONTRACT, INCOMING_CLASS_TOKEN_TO_CHANNEL, NFT_CONTRACT_TO_CLASS_ID,
OUTGOING_CLASS_TOKEN_TO_CHANNEL, PROXY,
CLASS_ID_TO_NFT_CONTRACT, CLASS_TOKEN_ID_TO_TOKEN_METADATA,
INCOMING_CLASS_TOKEN_TO_CHANNEL, NFT_CONTRACT_TO_CLASS_ID, OUTGOING_CLASS_TOKEN_TO_CHANNEL,
PROXY,
},
token_types::{ClassId, TokenId},
ContractError,
Expand Down Expand Up @@ -63,6 +64,8 @@ pub struct NonFungibleTokenPacketData {
/// The address that should receive the tokens on the receiving
/// chain.
pub receiver: String,
/// Memo to add custom string to the msg
pub memo: Option<String>,
}

#[cfg_attr(not(feature = "library"), entry_point)]
Expand Down Expand Up @@ -163,6 +166,9 @@ pub fn ibc_packet_ack(
if returning_to_source {
// This token's journey is complete, for now.
INCOMING_CLASS_TOKEN_TO_CHANNEL.remove(deps.storage, key);
CLASS_TOKEN_ID_TO_TOKEN_METADATA
.remove(deps.storage, (msg.class_id.clone(), token.clone()));

messages.push(WasmMsg::Execute {
contract_addr: nft_contract.to_string(),
msg: to_binary(&cw721::Cw721ExecuteMsg::Burn {
Expand Down
109 changes: 58 additions & 51 deletions contracts/cw-ics721-bridge/src/ibc_packet_receive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,56 +51,6 @@ pub(crate) fn receive_ibc_packet(
let data: NonFungibleTokenPacketData = from_binary(&packet.data)?;
data.validate()?;

// below is a functional implementation of this imperative psudocode:
//
// ```
// 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`
//
// this property is made use of in the `VoucherRedemption` and
// `VoucherCreation` types which aggregate redemption and creation
// actions.
//
// notably:
//
// 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.

let local_class_id = try_pop_source_prefix(&packet.src, &data.class_id);
let receiver = deps.api.addr_validate(&data.receiver)?;
let token_count = data.token_ids.len();
Expand Down Expand Up @@ -160,7 +110,13 @@ pub(crate) fn receive_ibc_packet(
)
.into_submessage(env.contract.address, receiver)?;

Ok(IbcReceiveResponse::default()
let response = if let Some(memo) = data.memo {
IbcReceiveResponse::default().add_attribute("memo", memo)
} else {
IbcReceiveResponse::default()
};

Ok(response
.add_submessage(submessage)
.add_attribute("method", "receive_ibc_packet")
.add_attribute("class_id", data.class_id)
Expand All @@ -178,6 +134,57 @@ impl ActionAggregator {
}
}

// the ics-721 rx logic is a functional implementation of this
// imperative psudocode:
//
// ```
// 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.
pub fn add_action(mut self, action: Action) -> Self {
match action {
Action::Redemption { class_id, token_id } => {
Expand Down
4 changes: 1 addition & 3 deletions contracts/cw-ics721-bridge/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ pub mod state;
pub mod token_types;

#[cfg(test)]
mod ibc_tests;
#[cfg(test)]
mod integration_tests;
pub mod testing;

pub use crate::error::ContractError;
13 changes: 9 additions & 4 deletions contracts/cw-ics721-bridge/src/msg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ pub struct IbcOutgoingMsg {
pub channel_id: String,
/// Timeout for the IBC message.
pub timeout: IbcTimeout,
/// Memo to add custom string to the msg
pub memo: Option<String>,
}

#[cw_serde]
Expand All @@ -98,7 +100,7 @@ pub enum QueryMsg {
/// Gets the classID this contract has stored for a given NFT
/// contract. If there is no class ID for the provided contract,
/// returns None.
#[returns(Option<ClassId>)]
#[returns(Option<crate::token_types::ClassId>)]
ClassId { contract: String },

/// Gets the NFT contract associated wtih the provided class
Expand All @@ -109,9 +111,12 @@ pub enum QueryMsg {

/// Gets the class level metadata URI for the provided
/// class_id. If there is no metadata, returns None. Returns
/// `Option<String>`.
#[returns(Option<String>)]
Metadata { class_id: String },
/// `Option<Class>`.
#[returns(Option<crate::token_types::Class>)]
ClassMetadata { class_id: String },

#[returns(Option<crate::token_types::Token>)]
TokenMetadata { class_id: String, token_id: String },

/// Gets the owner of the NFT identified by CLASS_ID and
/// TOKEN_ID. Errors if no such NFT exists. Returns
Expand Down
10 changes: 6 additions & 4 deletions contracts/cw-ics721-bridge/src/state.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
use cosmwasm_std::{Addr, Empty};
use cosmwasm_std::{Addr, Binary, Empty};
use cw_pause_once::PauseOrchestrator;
use cw_storage_plus::{Item, Map};
use serde::Deserialize;

use crate::token_types::{ClassId, TokenId};
use crate::token_types::{Class, ClassId, TokenId};

/// The code ID we will use for instantiating new cw721s.
pub const CW721_CODE_ID: Item<u64> = Item::new("a");
Expand All @@ -18,15 +18,17 @@ pub const CLASS_ID_TO_NFT_CONTRACT: Map<ClassId, Addr> = Map::new("e");
/// Maps cw721 contracts to the classID they were instantiated for.
pub const NFT_CONTRACT_TO_CLASS_ID: Map<Addr, ClassId> = Map::new("f");

/// Maps between classIDs and classUris. We need to keep this state
/// Maps between classIDs and classs. We need to keep this state
/// ourselves as cw721 contracts do not have class-level metadata.
pub const CLASS_ID_TO_CLASS_URI: Map<ClassId, Option<String>> = Map::new("g");
pub const CLASS_ID_TO_CLASS: Map<ClassId, Class> = Map::new("g");

/// Maps (class ID, token ID) -> local channel ID. Used to determine
/// the local channel that NFTs have been sent out on.
pub const OUTGOING_CLASS_TOKEN_TO_CHANNEL: Map<(ClassId, TokenId), String> = Map::new("h");
/// Same as above, but for NFTs arriving at this contract.
pub const INCOMING_CLASS_TOKEN_TO_CHANNEL: Map<(ClassId, TokenId), String> = Map::new("i");
/// metadata of a token (class id, token id) -> metadata
pub const CLASS_TOKEN_ID_TO_TOKEN_METADATA: Map<(ClassId, TokenId), Option<Binary>> = Map::new("j");

#[derive(Deserialize)]
pub struct UniversalNftInfoResponse {
Expand Down
114 changes: 114 additions & 0 deletions contracts/cw-ics721-bridge/src/testing/contract.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
use cosmwasm_std::{
testing::{mock_dependencies, mock_info, MockQuerier},
to_binary, ContractResult, CosmosMsg, Empty, IbcMsg, IbcTimeout, QuerierResult, SubMsg,
Timestamp, WasmQuery,
};
use cw721::NftInfoResponse;

use crate::{
contract::receive_nft,
ibc::NonFungibleTokenPacketData,
msg::IbcOutgoingMsg,
state::CLASS_ID_TO_CLASS,
token_types::{ClassId, TokenId},
};

const NFT_ADDR: &str = "nft";

fn nft_info_response_mock_querier(query: &WasmQuery) -> QuerierResult {
match query {
cosmwasm_std::WasmQuery::Smart {
contract_addr,
msg: _,
} => {
if *contract_addr == NFT_ADDR {
QuerierResult::Ok(ContractResult::Ok(
to_binary(&NftInfoResponse::<Option<Empty>> {
token_uri: Some("https://moonphase.is/image.svg".to_string()),
extension: None,
})
.unwrap(),
))
} else {
unimplemented!()
}
}
cosmwasm_std::WasmQuery::Raw {
contract_addr: _,
key: _,
} => unimplemented!(),
cosmwasm_std::WasmQuery::ContractInfo { contract_addr: _ } => unimplemented!(),
_ => unimplemented!(),
}
}

#[test]
fn test_receive_nft() {
let mut querier = MockQuerier::default();
querier.update_wasm(nft_info_response_mock_querier);

let mut deps = mock_dependencies();
deps.querier = querier;

let info = mock_info(NFT_ADDR, &[]);
let token_id = TokenId::new("1");
let sender = "ekez".to_string();
let msg = to_binary(&IbcOutgoingMsg {
receiver: "callum".to_string(),
channel_id: "channel-1".to_string(),
timeout: IbcTimeout::with_timestamp(Timestamp::from_seconds(42)),
memo: None,
})
.unwrap();

let res = receive_nft(deps.as_mut(), info, token_id.clone(), sender.clone(), msg).unwrap();
assert_eq!(res.messages.len(), 1);

assert_eq!(
res.messages[0],
SubMsg::new(CosmosMsg::Ibc(IbcMsg::SendPacket {
channel_id: "channel-1".to_string(),
timeout: IbcTimeout::with_timestamp(Timestamp::from_seconds(42)),
data: to_binary(&NonFungibleTokenPacketData {
class_id: ClassId::new(NFT_ADDR),
class_uri: None,
class_data: None,
token_data: None,
token_ids: vec![token_id],
token_uris: Some(vec!["https://moonphase.is/image.svg".to_string()]),
sender,
receiver: "callum".to_string(),
memo: None,
})
.unwrap()
}))
)
}

#[test]
fn test_receive_sets_uri() {
let mut querier = MockQuerier::default();
querier.update_wasm(nft_info_response_mock_querier);

let mut deps = mock_dependencies();
deps.querier = querier;

let info = mock_info(NFT_ADDR, &[]);
let token_id = TokenId::new("1");
let sender = "ekez".to_string();
let msg = to_binary(&IbcOutgoingMsg {
receiver: "ekez".to_string(),
channel_id: "channel-1".to_string(),
timeout: IbcTimeout::with_timestamp(Timestamp::from_nanos(42)),
memo: None,
})
.unwrap();

receive_nft(deps.as_mut(), info, token_id, sender, msg).unwrap();

let class = CLASS_ID_TO_CLASS
.load(deps.as_ref().storage, ClassId::new(NFT_ADDR))
.unwrap();
assert_eq!(class.uri, None);
assert_eq!(class.data, None);
}
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ fn build_ics_packet(
token_uris: token_uris.map(|t| t.into_iter().map(|s| s.to_string()).collect()),
sender: sender.to_string(),
receiver: receiver.to_string(),
memo: None,
}
}

Expand Down Expand Up @@ -406,7 +407,10 @@ fn test_ibc_channel_connect_invalid_version_counterparty() {

#[test]
fn test_ibc_packet_receive_invalid_packet_data() {
let data = to_binary(&QueryMsg::Metadata {
// the actual message used here is unimportant. this just
// constructs a valud JSON blob that is not a valid ICS-721
// packet.
let data = to_binary(&QueryMsg::ClassMetadata {
class_id: "foobar".to_string(),
})
.unwrap();
Expand Down Expand Up @@ -475,15 +479,17 @@ fn test_packet_json() {
);
// Example message generated from the SDK
// TODO: test with non-null tokenData and classData.
let expected = r#"{"classId":"stars1zedxv25ah8fksmg2lzrndrpkvsjqgk4zt5ff7n","classUri":"https://metadata-url.com/my-metadata","classData":null,"tokenIds":["1","2","3"],"tokenUris":["https://metadata-url.com/my-metadata1","https://metadata-url.com/my-metadata2","https://metadata-url.com/my-metadata3"],"tokenData":null,"sender":"stars1zedxv25ah8fksmg2lzrndrpkvsjqgk4zt5ff7n","receiver":"wasm1fucynrfkrt684pm8jrt8la5h2csvs5cnldcgqc"}"#;
let expected = r#"{"classId":"stars1zedxv25ah8fksmg2lzrndrpkvsjqgk4zt5ff7n","classUri":"https://metadata-url.com/my-metadata","classData":null,"tokenIds":["1","2","3"],"tokenUris":["https://metadata-url.com/my-metadata1","https://metadata-url.com/my-metadata2","https://metadata-url.com/my-metadata3"],"tokenData":null,"sender":"stars1zedxv25ah8fksmg2lzrndrpkvsjqgk4zt5ff7n","receiver":"wasm1fucynrfkrt684pm8jrt8la5h2csvs5cnldcgqc","memo":null}"#;

let encdoded = String::from_utf8(to_vec(&packet).unwrap()).unwrap();
assert_eq!(expected, encdoded.as_str());
}

#[test]
fn test_no_receive_when_paused() {
let data = to_binary(&QueryMsg::Metadata {
// Valid JSON, invalid ICS-721 packet. Tests that we check for
// pause status before attempting validation.
let data = to_binary(&QueryMsg::ClassMetadata {
class_id: "foobar".to_string(),
})
.unwrap();
Expand Down
Loading

0 comments on commit f2ac26d

Please sign in to comment.