From c580365654990b778dbcda4042045a46368f8074 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Wo=C5=BAniak?= Date: Thu, 17 Oct 2024 18:15:24 +0200 Subject: [PATCH] feat: Add auto deserialization of reply data (#445) --- .../src/contract/communication/reply.rs | 75 ++++- sylvia-derive/src/parser/attributes/data.rs | 48 ++++ sylvia-derive/src/parser/attributes/mod.rs | 10 + sylvia-derive/src/types/msg_field.rs | 4 + sylvia/tests/reply_data.rs | 270 ++++++++++++++++++ sylvia/tests/{reply.rs => reply_dispatch.rs} | 4 +- sylvia/tests/reply_generation.rs | 2 +- .../ui/attributes/data/missing_attribute.rs | 33 +++ .../attributes/data/missing_attribute.stderr | 9 + sylvia/tests/ui/method_signature/reply.rs | 8 +- 10 files changed, 455 insertions(+), 8 deletions(-) create mode 100644 sylvia-derive/src/parser/attributes/data.rs create mode 100644 sylvia/tests/reply_data.rs rename sylvia/tests/{reply.rs => reply_dispatch.rs} (99%) create mode 100644 sylvia/tests/ui/attributes/data/missing_attribute.rs create mode 100644 sylvia/tests/ui/attributes/data/missing_attribute.stderr diff --git a/sylvia-derive/src/contract/communication/reply.rs b/sylvia-derive/src/contract/communication/reply.rs index 0a528200..b2e8b1a0 100644 --- a/sylvia-derive/src/contract/communication/reply.rs +++ b/sylvia-derive/src/contract/communication/reply.rs @@ -6,7 +6,7 @@ use syn::{parse_quote, GenericParam, Ident, ItemImpl, Type}; use crate::crate_module; use crate::parser::attributes::msg::ReplyOn; -use crate::parser::{MsgType, SylviaAttribute}; +use crate::parser::{MsgType, ParsedSylviaAttributes, SylviaAttribute}; use crate::types::msg_field::MsgField; use crate::types::msg_variant::{MsgVariant, MsgVariants}; use crate::utils::emit_turbofish; @@ -190,12 +190,15 @@ struct ReplyData<'a> { pub handler_id: &'a Ident, /// Methods handling the reply id for the associated reply on. pub handlers: Vec<(&'a Ident, ReplyOn)>, + /// Data parameter associated with the handlers. + pub data: Option<&'a MsgField<'a>>, /// Payload parameters associated with the handlers. pub payload: Vec<&'a MsgField<'a>>, } impl<'a> ReplyData<'a> { pub fn new(reply_id: Ident, variant: &'a MsgVariant<'a>, handler_id: &'a Ident) -> Self { + let data = variant.fields().first(); // Skip the first field reserved for the `data`. let payload = variant.fields().iter().skip(1).collect::>(); let method_name = variant.function_name(); @@ -205,6 +208,7 @@ impl<'a> ReplyData<'a> { reply_id, handler_id, handlers: vec![(method_name, reply_on)], + data, payload, } } @@ -372,12 +376,14 @@ impl<'a> ReplyData<'a> { Some((method_name, reply_on)) if reply_on == &ReplyOn::Success => { let payload_values = self.payload.iter().map(|field| field.name()); let payload_deserialization = self.payload.emit_payload_deserialization(); + let data_deserialization = self.data.map(DataField::emit_data_deserialization); quote! { #sylvia ::cw_std::SubMsgResult::Ok(sub_msg_resp) => { #[allow(deprecated)] let #sylvia ::cw_std::SubMsgResponse { events, data, msg_responses} = sub_msg_resp; #payload_deserialization + #data_deserialization #contract_turbofish ::new(). #method_name ((deps, env, gas_used, events, msg_responses).into(), data, #(#payload_values),* ) } @@ -475,6 +481,73 @@ impl<'a> ReplyVariant<'a> for MsgVariant<'a> { } } +pub trait DataField { + fn emit_data_deserialization(&self) -> TokenStream; +} + +impl DataField for MsgField<'_> { + fn emit_data_deserialization(&self) -> TokenStream { + let sylvia = crate_module(); + let data = ParsedSylviaAttributes::new(self.attrs().iter()).data; + let is_data_attr = self + .attrs() + .iter() + .any(|attr| SylviaAttribute::new(attr) == Some(SylviaAttribute::Data)); + let missing_data_err = "Missing reply data field."; + let invalid_reply_data_err = quote! { + format! {"Invalid reply data: {}\nSerde error while deserializing {}", data, err} + }; + let data_deserialization = quote! { + let deserialized_data = + #sylvia ::cw_utils::parse_execute_response_data(data.as_slice()) + .map_err(|err| #sylvia ::cw_std::StdError::generic_err( + format!("Failed deserializing protobuf data: {}", err) + ))?; + let deserialized_data = match deserialized_data.data { + Some(data) => #sylvia ::cw_std::from_json(&data).map_err(|err| #sylvia ::cw_std::StdError::generic_err( #invalid_reply_data_err ))?, + None => return Err(Into::into( #sylvia ::cw_std::StdError::generic_err( #missing_data_err ))), + }; + }; + + match data { + Some(data) if data.raw && data.opt => quote! {}, + Some(data) if data.raw => quote! { + let data = match data { + Some(data) => data, + None => return Err(Into::into( #sylvia ::cw_std::StdError::generic_err( #missing_data_err ))), + }; + }, + Some(data) if data.opt => quote! { + let data = match data { + Some(data) => { + #data_deserialization + + Some(deserialized_data) + }, + None => None, + }; + }, + None if is_data_attr => quote! { + let data = match data { + Some(data) => { + #data_deserialization + + deserialized_data + }, + None => return Err(Into::into( #sylvia ::cw_std::StdError::generic_err( #missing_data_err ))), + }; + }, + _ => { + emit_error!(self.name().span(), "Invalid data usage."; + note = "Reply data should be marked with #[sv::data] attribute."; + note = "Remove this parameter or mark it with #[sv::data] attribute." + ); + quote! {} + } + } + } +} + pub trait PayloadFields { fn emit_payload_deserialization(&self) -> TokenStream; fn emit_payload_serialization(&self) -> TokenStream; diff --git a/sylvia-derive/src/parser/attributes/data.rs b/sylvia-derive/src/parser/attributes/data.rs new file mode 100644 index 00000000..b88fa8bb --- /dev/null +++ b/sylvia-derive/src/parser/attributes/data.rs @@ -0,0 +1,48 @@ +use proc_macro_error::emit_error; +use syn::parse::{Parse, ParseStream, Parser}; +use syn::{Error, Ident, MetaList, Result, Token}; + +/// Type wrapping data parsed from `sv::data` attribute. +#[derive(Default, Debug)] +pub struct DataFieldParams { + pub raw: bool, + pub opt: bool, +} + +impl DataFieldParams { + pub fn new(attr: &MetaList) -> Result { + DataFieldParams::parse + .parse2(attr.tokens.clone()) + .map_err(|err| { + emit_error!(err.span(), err); + err + }) + } +} + +impl Parse for DataFieldParams { + fn parse(input: ParseStream) -> Result { + let mut data = Self::default(); + + while !input.is_empty() { + let option: Ident = input.parse()?; + match option.to_string().as_str() { + "raw" => data.raw = true, + "opt" => data.opt = true, + _ => { + return Err(Error::new( + option.span(), + "Invalid data parameter.\n + = note: Expected one of [`raw`, `opt`] comma separated.\n", + )) + } + } + if !input.peek(Token![,]) { + break; + } + let _: Token![,] = input.parse()?; + } + + Ok(data) + } +} diff --git a/sylvia-derive/src/parser/attributes/mod.rs b/sylvia-derive/src/parser/attributes/mod.rs index 2134e818..215bda73 100644 --- a/sylvia-derive/src/parser/attributes/mod.rs +++ b/sylvia-derive/src/parser/attributes/mod.rs @@ -1,6 +1,7 @@ //! Module defining parsing of Sylvia attributes. //! Every Sylvia attribute should be prefixed with `sv::` +use data::DataFieldParams; use features::SylviaFeatures; use proc_macro_error::emit_error; use syn::spanned::Spanned; @@ -8,6 +9,7 @@ use syn::{Attribute, MetaList, PathSegment}; pub mod attr; pub mod custom; +pub mod data; pub mod error; pub mod features; pub mod messages; @@ -33,6 +35,7 @@ pub enum SylviaAttribute { VariantAttrs, MsgAttrs, Payload, + Data, Features, } @@ -56,6 +59,7 @@ impl SylviaAttribute { "attr" => Some(Self::VariantAttrs), "msg_attr" => Some(Self::MsgAttrs), "payload" => Some(Self::Payload), + "data" => Some(Self::Data), "features" => Some(Self::Features), _ => None, } @@ -74,6 +78,7 @@ pub struct ParsedSylviaAttributes { pub variant_attrs_forward: Vec, pub msg_attrs_forward: Vec, pub sv_features: SylviaFeatures, + pub data: Option, } impl ParsedSylviaAttributes { @@ -172,6 +177,11 @@ impl ParsedSylviaAttributes { note = attr.span() => "The `sv::payload` should be used as a prefix for `Binary` payload."; ); } + SylviaAttribute::Data => { + if let Ok(data) = DataFieldParams::new(attr) { + self.data = Some(data); + } + } SylviaAttribute::Features => { if let Ok(features) = SylviaFeatures::new(attr) { self.sv_features = features; diff --git a/sylvia-derive/src/types/msg_field.rs b/sylvia-derive/src/types/msg_field.rs index 2834ed71..46bfffc4 100644 --- a/sylvia-derive/src/types/msg_field.rs +++ b/sylvia-derive/src/types/msg_field.rs @@ -121,6 +121,10 @@ impl<'a> MsgField<'a> { self.ty } + pub fn attrs(&self) -> &'a Vec { + self.attrs + } + pub fn contains_attribute(&self, sv_attr: SylviaAttribute) -> bool { self.attrs .iter() diff --git a/sylvia/tests/reply_data.rs b/sylvia/tests/reply_data.rs new file mode 100644 index 00000000..3a493844 --- /dev/null +++ b/sylvia/tests/reply_data.rs @@ -0,0 +1,270 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::to_json_binary; +use cw_storage_plus::Item; +use cw_utils::{parse_instantiate_response_data, ParseReplyError}; +use noop_contract::sv::{Executor, NoopContractInstantiateBuilder}; +use sv::SubMsgMethods; +use sylvia::builder::instantiate::InstantiateBuilder; +use sylvia::cw_std::{Addr, Binary, Response, StdError, SubMsg}; +use sylvia::types::{ExecCtx, InstantiateCtx, Remote, ReplyCtx}; +use sylvia::{contract, entry_points}; +use thiserror::Error; + +#[allow(dead_code)] +mod noop_contract { + use cosmwasm_std::{Binary, StdResult}; + use sylvia::types::{ExecCtx, InstantiateCtx}; + use sylvia::{contract, entry_points}; + + use sylvia::cw_std::Response; + + pub struct NoopContract; + + #[entry_points] + #[contract] + impl NoopContract { + pub const fn new() -> Self { + Self + } + + #[sv::msg(instantiate)] + fn instantiate(&self, _ctx: InstantiateCtx) -> StdResult { + Ok(Response::new()) + } + + #[sv::msg(exec)] + fn noop(&self, _ctx: ExecCtx, data: Option) -> StdResult { + let resp = match data { + Some(data) => Response::new().set_data(data), + None => Response::new(), + }; + + Ok(resp) + } + } +} + +#[cw_serde] +pub struct InstantiatePayload { + pub sender: Addr, +} + +#[derive(Error, Debug, PartialEq)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + + #[error("{0}")] + ParseReply(#[from] ParseReplyError), +} + +pub struct Contract { + remote: Item>, +} + +#[entry_points] +#[contract] +#[sv::error(ContractError)] +#[sv::features(replies)] +impl Contract { + pub fn new() -> Self { + Self { + remote: Item::new("remote"), + } + } + + #[sv::msg(instantiate)] + pub fn instantiate( + &self, + ctx: InstantiateCtx, + remote_code_id: u64, + ) -> Result { + // Custom type can be used as a payload. + let payload = InstantiatePayload { + sender: ctx.info.sender, + }; + let sub_msg = InstantiateBuilder::noop_contract(remote_code_id)? + .with_label("noop") + .build() + .remote_instantiated(to_json_binary(&payload)?)?; + // TODO: Blocked by https://github.com/CosmWasm/cw-multi-test/pull/216. Uncomment when new + // MultiTest version is released. + // Payload is not currently forwarded in the MultiTest. + // .remote_instantiated(payload)?; + + Ok(Response::new().add_submessage(sub_msg)) + } + + #[sv::msg(exec)] + fn send_message_expecting_data( + &self, + ctx: ExecCtx, + data: Option, + reply_id: u64, + ) -> Result { + let msg = self + .remote + .load(ctx.deps.storage)? + .executor() + .noop(data)? + .build(); + let submsg = SubMsg::reply_on_success(msg, reply_id); + + Ok(Response::new().add_submessage(submsg)) + } + + #[sv::msg(reply, reply_on=success)] + fn remote_instantiated( + &self, + ctx: ReplyCtx, + #[sv::data(raw, opt)] data: Option, + // TODO: Blocked by https://github.com/CosmWasm/cw-multi-test/pull/216. Uncomment when new + // MultiTest version is released. + // Payload is not currently forwarded in the MultiTest. + // _instantiate_payload: InstantiatePayload, + #[sv::payload] _payload: Binary, + ) -> Result { + let init_data = parse_instantiate_response_data(&data.unwrap())?; + let remote_addr = Addr::unchecked(init_data.contract_address); + + self.remote + .save(ctx.deps.storage, &Remote::new(remote_addr))?; + + Ok(Response::new()) + } + + #[sv::msg(reply, reply_on=success)] + fn data_raw_opt( + &self, + _ctx: ReplyCtx, + #[sv::data(raw, opt)] _data: Option, + #[sv::payload] _payload: Binary, + ) -> Result { + Ok(Response::new()) + } + + #[sv::msg(reply, reply_on=success)] + fn data_raw( + &self, + _ctx: ReplyCtx, + #[sv::data(raw)] _data: Binary, + #[sv::payload] _payload: Binary, + ) -> Result { + Ok(Response::new()) + } + + #[sv::msg(reply, reply_on=success)] + fn data_opt( + &self, + _ctx: ReplyCtx, + #[sv::data(opt)] _data: Option, + #[sv::payload] _payload: Binary, + ) -> Result { + Ok(Response::new()) + } + + #[sv::msg(reply, reply_on=success)] + fn data( + &self, + _ctx: ReplyCtx, + #[sv::data] _data: String, + #[sv::payload] _payload: Binary, + ) -> Result { + Ok(Response::new()) + } +} + +mod tests { + use crate::noop_contract::sv::mt::CodeId as NoopCodeId; + use crate::sv::mt::{CodeId, ContractProxy}; + use crate::sv::{DATA_OPT_REPLY_ID, DATA_RAW_OPT_REPLY_ID, DATA_RAW_REPLY_ID, DATA_REPLY_ID}; + + use cosmwasm_std::{to_json_binary, Binary, StdError}; + use sylvia::cw_multi_test::IntoBech32; + use sylvia::multitest::App; + + #[test] + fn dispatch_replies() { + let app = App::default(); + let code_id = CodeId::store_code(&app); + let noop_code_id = NoopCodeId::store_code(&app); + + let owner = "owner".into_bech32(); + let data = Some(to_json_binary(&String::from("some_data")).unwrap()); + let invalid_data = Some(Binary::from("InvalidData".as_bytes())); + + // Trigger remote instantiation reply + let contract = code_id + .instantiate(noop_code_id.code_id()) + .with_label("Contract") + .call(&owner) + .unwrap(); + + // Should forward `data` in every case + contract + .send_message_expecting_data(None, DATA_RAW_OPT_REPLY_ID) + .call(&owner) + .unwrap(); + + contract + .send_message_expecting_data(data.clone(), DATA_RAW_OPT_REPLY_ID) + .call(&owner) + .unwrap(); + + // Should forward `data` if `Some` and return error if `None` + let err = contract + .send_message_expecting_data(None, DATA_RAW_REPLY_ID) + .call(&owner) + .unwrap_err(); + assert_eq!( + err, + StdError::generic_err("Missing reply data field.").into() + ); + + contract + .send_message_expecting_data(data.clone(), DATA_RAW_REPLY_ID) + .call(&owner) + .unwrap(); + + // Should forward deserialized `data` if `Some` or None and return error if deserialization fails + contract + .send_message_expecting_data(None, DATA_OPT_REPLY_ID) + .call(&owner) + .unwrap(); + + let err = contract + .send_message_expecting_data(invalid_data.clone(), DATA_OPT_REPLY_ID) + .call(&owner) + .unwrap_err(); + assert_eq!( + err, + StdError::generic_err("Invalid reply data: SW52YWxpZERhdGE=\nSerde error while deserializing Error parsing into type alloc::string::String: Invalid type").into() + ); + + contract + .send_message_expecting_data(data.clone(), DATA_OPT_REPLY_ID) + .call(&owner) + .unwrap(); + + // Should forward deserialized `data` if `Some` and return error if `None` or if deserialization fails + let err = contract + .send_message_expecting_data(None, DATA_REPLY_ID) + .call(&owner) + .unwrap_err(); + assert_eq!( + err, + StdError::generic_err("Missing reply data field.").into() + ); + + let err = contract + .send_message_expecting_data(invalid_data, DATA_REPLY_ID) + .call(&owner) + .unwrap_err(); + assert_eq!(err, StdError::generic_err("Invalid reply data: SW52YWxpZERhdGE=\nSerde error while deserializing Error parsing into type alloc::string::String: Invalid type").into()); + + contract + .send_message_expecting_data(data, DATA_REPLY_ID) + .call(&owner) + .unwrap(); + } +} diff --git a/sylvia/tests/reply.rs b/sylvia/tests/reply_dispatch.rs similarity index 99% rename from sylvia/tests/reply.rs rename to sylvia/tests/reply_dispatch.rs index ed478151..23569c5c 100644 --- a/sylvia/tests/reply.rs +++ b/sylvia/tests/reply_dispatch.rs @@ -214,7 +214,7 @@ where fn remote_instantiated( &self, ctx: ReplyCtx, - data: Option, + #[sv::data(raw, opt)] data: Option, // TODO: Blocked by https://github.com/CosmWasm/cw-multi-test/pull/216. Uncomment when new // MultiTest version is released. // Payload is not currently forwarded in the MultiTest. @@ -236,7 +236,7 @@ where fn success( &self, ctx: ReplyCtx, - _data: Option, + #[sv::data(raw, opt)] _data: Option, #[sv::payload] _payload: Binary, ) -> Result, ContractError> { self.last_reply.save(ctx.deps.storage, &SUCCESS_REPLY_ID)?; diff --git a/sylvia/tests/reply_generation.rs b/sylvia/tests/reply_generation.rs index a4f471ca..afd65ad2 100644 --- a/sylvia/tests/reply_generation.rs +++ b/sylvia/tests/reply_generation.rs @@ -45,7 +45,7 @@ impl Contract { fn reply_on( &self, _ctx: ReplyCtx, - _data: Option, + #[sv::data(raw, opt)] _data: Option, #[sv::payload] _payload: Binary, ) -> StdResult { Ok(Response::new()) diff --git a/sylvia/tests/ui/attributes/data/missing_attribute.rs b/sylvia/tests/ui/attributes/data/missing_attribute.rs new file mode 100644 index 00000000..bed0fd3d --- /dev/null +++ b/sylvia/tests/ui/attributes/data/missing_attribute.rs @@ -0,0 +1,33 @@ +#![allow(unused_imports)] + +use sylvia::contract; +use sylvia::cw_std::{Reply, Response, StdResult}; +use sylvia::types::{InstantiateCtx, ReplyCtx}; + +pub struct Contract; + +#[contract] +#[sv::features(replies)] +impl Contract { + pub fn new() -> Self { + Self + } + + #[sv::msg(instantiate)] + pub fn instantiate(&self, _ctx: InstantiateCtx) -> StdResult { + Ok(Response::new()) + } + + #[sv::msg(reply, reply_on=success)] + fn reply( + &self, + _ctx: ReplyCtx, + // If the `data` attribute is missing, the data field should be omitted. + _data: Option, + param: String, + ) -> StdResult { + Ok(Response::new()) + } +} + +fn main() {} diff --git a/sylvia/tests/ui/attributes/data/missing_attribute.stderr b/sylvia/tests/ui/attributes/data/missing_attribute.stderr new file mode 100644 index 00000000..64010df5 --- /dev/null +++ b/sylvia/tests/ui/attributes/data/missing_attribute.stderr @@ -0,0 +1,9 @@ +error: Invalid data usage. + + = note: Reply data should be marked with #[sv::data] attribute. + = note: Remove this parameter or mark it with #[sv::data] attribute. + + --> tests/ui/attributes/data/missing_attribute.rs:26:9 + | +26 | _data: Option, + | ^^^^^ diff --git a/sylvia/tests/ui/method_signature/reply.rs b/sylvia/tests/ui/method_signature/reply.rs index b6775cea..4cd6ea88 100644 --- a/sylvia/tests/ui/method_signature/reply.rs +++ b/sylvia/tests/ui/method_signature/reply.rs @@ -23,7 +23,7 @@ pub mod mismatched_params { fn first_reply( &self, _ctx: ReplyCtx, - _data: Option, + #[sv::data(opt, raw)] _data: Option, param: String, ) -> StdResult { Ok(Response::new()) @@ -33,7 +33,7 @@ pub mod mismatched_params { fn second_reply( &self, _ctx: ReplyCtx, - _data: Option, + #[sv::data(opt, raw)] _data: Option, param: u32, ) -> StdResult { Ok(Response::new()) @@ -62,7 +62,7 @@ pub mod mismatched_param_arity { fn first_reply( &self, _ctx: ReplyCtx, - _data: Option, + #[sv::data(opt, raw)] _data: Option, param: String, ) -> StdResult { Ok(Response::new()) @@ -72,7 +72,7 @@ pub mod mismatched_param_arity { fn second_reply( &self, _ctx: ReplyCtx, - _data: Option, + #[sv::data(opt, raw)] _data: Option, param: String, param: u32, ) -> StdResult {