diff --git a/CHANGELOG.md b/CHANGELOG.md index 6686c0904..2e017edb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added Distance ADO [(#570)](https://github.com/andromedaprotocol/andromeda-core/pull/570) - Rates: Handle cross-chain recipients [(#671)](https://github.com/andromedaprotocol/andromeda-core/pull/671) - Permissions: Permissioned Actors in AndromedaQuery [(#717)](https://github.com/andromedaprotocol/andromeda-core/pull/717) +- Added Schema and Form ADOs [(#591)](https://github.com/andromedaprotocol/andromeda-core/pull/591) ### Changed - Rates: Limit rates recipient to only one address [(#669)](https://github.com/andromedaprotocol/andromeda-core/pull/669) diff --git a/Cargo.lock b/Cargo.lock index 98d91977b..94241575b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -535,6 +535,27 @@ dependencies = [ "cw4", ] +[[package]] +name = "andromeda-form" +version = "0.1.0-beta" +dependencies = [ + "andromeda-data-storage", + "andromeda-modules", + "andromeda-std", + "andromeda-testing", + "cosmwasm-schema 1.5.8", + "cosmwasm-std 1.5.8", + "cw-json", + "cw-multi-test", + "cw-orch", + "cw-storage-plus 1.2.0", + "cw-utils 1.0.3", + "cw20 1.1.2", + "serde", + "serde_json", + "test-case", +] + [[package]] name = "andromeda-fungible-tokens" version = "1.0.0" @@ -812,6 +833,24 @@ dependencies = [ "cw20 1.1.2", ] +[[package]] +name = "andromeda-schema" +version = "0.1.0-beta" +dependencies = [ + "andromeda-app", + "andromeda-modules", + "andromeda-std", + "andromeda-testing", + "cosmwasm-schema 1.5.8", + "cosmwasm-std 1.5.8", + "cw-json", + "cw-multi-test", + "cw-orch", + "cw-storage-plus 1.2.0", + "serde_json", + "test-case", +] + [[package]] name = "andromeda-set-amount-splitter" version = "1.2.0-b.1" diff --git a/Cargo.toml b/Cargo.toml index fa8dfc986..605cdb625 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,3 +67,5 @@ cw-multi-test = { version = "1.0.0", features = ["cosmwasm_1_2"] } serde = { version = "1.0.215" } test-case = { version = "3.3.1" } cw-orch = "=0.24.1" +jsonschema-valid = { version = "0.5.2"} +serde_json = { version = "1.0.128" } diff --git a/contracts/data-storage/andromeda-form/.cargo/config b/contracts/data-storage/andromeda-form/.cargo/config new file mode 100644 index 000000000..336b618a1 --- /dev/null +++ b/contracts/data-storage/andromeda-form/.cargo/config @@ -0,0 +1,4 @@ +[alias] +wasm = "build --release --target wasm32-unknown-unknown" +unit-test = "test --lib" +schema = "run --example schema" diff --git a/contracts/data-storage/andromeda-form/Cargo.toml b/contracts/data-storage/andromeda-form/Cargo.toml new file mode 100644 index 000000000..08890859f --- /dev/null +++ b/contracts/data-storage/andromeda-form/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "andromeda-form" +version = "0.1.0-beta" +authors = ["Mitar Djakovic "] +edition = "2021" +rust-version = "1.75.0" + +exclude = [ + # Those files are rust-optimizer artifacts. You might want to commit them for convenience but they should not be part of the source code publication. + "contract.wasm", + "hash.txt", +] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +# for more explicit tests, cargo test --features=backtraces +backtraces = ["cosmwasm-std/backtraces"] +# use library feature to disable all instantiate/execute/query exports +library = [] +testing = ["cw-multi-test", "andromeda-testing"] + +[dependencies] +cosmwasm-std = { workspace = true } +cosmwasm-schema = { workspace = true } +cw-storage-plus = { workspace = true } +cw-utils = { workspace = true } +cw20 = { workspace = true } +cw-json = { git = "https://github.com/SlayerAnsh/cw-json.git" } +serde_json = { workspace = true } +serde = { workspace = true } +test-case = { workspace = true } + +andromeda-std = { workspace = true, features = ["rates"] } +andromeda-data-storage = { workspace = true } +andromeda-modules = { workspace = true } + + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +cw-orch = { workspace = true } +cw-multi-test = { workspace = true, optional = true } +andromeda-testing = { workspace = true, optional = true } diff --git a/contracts/data-storage/andromeda-form/examples/schema.rs b/contracts/data-storage/andromeda-form/examples/schema.rs new file mode 100644 index 000000000..cb224a660 --- /dev/null +++ b/contracts/data-storage/andromeda-form/examples/schema.rs @@ -0,0 +1,10 @@ +use andromeda_data_storage::form::{ExecuteMsg, InstantiateMsg, QueryMsg}; +use cosmwasm_schema::write_api; +fn main() { + write_api! { + instantiate: InstantiateMsg, + query: QueryMsg, + execute: ExecuteMsg, + + } +} diff --git a/contracts/data-storage/andromeda-form/src/contract.rs b/contracts/data-storage/andromeda-form/src/contract.rs new file mode 100644 index 000000000..8cb19e055 --- /dev/null +++ b/contracts/data-storage/andromeda-form/src/contract.rs @@ -0,0 +1,180 @@ +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{ + ensure, Binary, Deps, DepsMut, Env, Event, MessageInfo, Reply, Response, StdError, Uint64, +}; + +use andromeda_data_storage::form::{ExecuteMsg, InstantiateMsg, QueryMsg}; +use andromeda_std::{ + ado_base::{ + permissioning::{LocalPermission, Permission}, + InstantiateMsg as BaseInstantiateMsg, MigrateMsg, + }, + ado_contract::ADOContract, + common::{ + context::ExecuteContext, encode_binary, expiration::get_and_validate_start_time, + Milliseconds, + }, + error::ContractError, +}; + +use crate::execute::{handle_execute, milliseconds_from_expiration}; +use crate::query::{ + get_all_submissions, get_form_status, get_schema, get_submission, get_submission_ids, +}; +use crate::state::{Config, CONFIG, SCHEMA_ADO_ADDRESS, SUBMISSION_ID}; + +// version info for migration info +const CONTRACT_NAME: &str = "crates.io:andromeda-form"; +const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +pub const SUBMIT_FORM_ACTION: &str = "submit_form"; + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: InstantiateMsg, +) -> Result { + let resp = ADOContract::default().instantiate( + deps.storage, + env.clone(), + deps.api, + &deps.querier, + info, + BaseInstantiateMsg { + ado_type: CONTRACT_NAME.to_string(), + ado_version: CONTRACT_VERSION.to_string(), + kernel_address: msg.kernel_address, + owner: msg.owner, + }, + )?; + + let schema_ado_address = msg.schema_ado_address; + schema_ado_address.validate(deps.api)?; + + SCHEMA_ADO_ADDRESS.save(deps.storage, &schema_ado_address)?; + SUBMISSION_ID.save(deps.storage, &Uint64::zero())?; + + let start_time = match msg.form_config.start_time { + Some(start_time) => Some(milliseconds_from_expiration( + get_and_validate_start_time(&env.clone(), Some(start_time))?.0, + )?), + None => None, + }; + let end_time = match msg.form_config.end_time { + Some(end_time) => { + let time_res = get_and_validate_start_time(&env.clone(), Some(end_time)); + if time_res.is_ok() { + Some(milliseconds_from_expiration(time_res.unwrap().0)?) + } else { + let current_time = Milliseconds::from_nanos(env.block.time.nanos()).milliseconds(); + let current_height = env.block.height; + return Err(ContractError::CustomError { + msg: format!( + "End time in the past. current_time {:?}, current_block {:?}", + current_time, current_height + ), + }); + } + } + None => None, + }; + + if let (Some(start_time), Some(end_time)) = (start_time, end_time) { + ensure!( + end_time.gt(&start_time), + ContractError::StartTimeAfterEndTime {} + ); + } + + let allow_multiple_submissions = msg.form_config.allow_multiple_submissions; + let allow_edit_submission = msg.form_config.allow_edit_submission; + + let config = Config { + start_time, + end_time, + allow_multiple_submissions, + allow_edit_submission, + }; + + CONFIG.save(deps.storage, &config)?; + + if let Some(authorized_addresses_for_submission) = msg.authorized_addresses_for_submission { + if !authorized_addresses_for_submission.is_empty() { + ADOContract::default().permission_action(SUBMIT_FORM_ACTION, deps.storage)?; + } + + for address in authorized_addresses_for_submission { + let addr = address.get_raw_address(&deps.as_ref())?; + ADOContract::set_permission( + deps.storage, + SUBMIT_FORM_ACTION, + addr, + Permission::Local(LocalPermission::Whitelisted(None)), + )?; + } + } + + let mut response = resp.add_event(Event::new("form_instantiated")); + + if let Some(custom_key) = msg.custom_key_for_notifications { + response = response.add_event( + cosmwasm_std::Event::new("custom_key") + .add_attribute("custom_key", custom_key) + .add_attribute("notification_service", "Telegram"), + ); + } + + Ok(response) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + let ctx = ExecuteContext::new(deps, info, env); + match msg { + ExecuteMsg::AMPReceive(pkt) => { + ADOContract::default().execute_amp_receive(ctx, pkt, handle_execute) + } + _ => handle_execute(ctx, msg), + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> Result { + match msg { + QueryMsg::GetSchema {} => encode_binary(&get_schema(deps)?), + QueryMsg::GetAllSubmissions {} => encode_binary(&get_all_submissions(deps.storage)?), + QueryMsg::GetSubmission { + submission_id, + wallet_address, + } => encode_binary(&get_submission(deps, submission_id, wallet_address)?), + QueryMsg::GetSubmissionIds { wallet_address } => { + encode_binary(&get_submission_ids(deps, wallet_address)?) + } + QueryMsg::GetFormStatus {} => encode_binary(&get_form_status(deps.storage, env)?), + _ => ADOContract::default().query(deps, env, msg), + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn migrate(deps: DepsMut, _env: Env, _msg: MigrateMsg) -> Result { + ADOContract::default().migrate(deps, CONTRACT_NAME, CONTRACT_VERSION) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn reply(_deps: DepsMut, _env: Env, msg: Reply) -> Result { + if msg.result.is_err() { + return Err(ContractError::Std(StdError::generic_err( + msg.result.unwrap_err(), + ))); + } + + Ok(Response::default()) +} diff --git a/contracts/data-storage/andromeda-form/src/execute.rs b/contracts/data-storage/andromeda-form/src/execute.rs new file mode 100644 index 000000000..8fd7d1055 --- /dev/null +++ b/contracts/data-storage/andromeda-form/src/execute.rs @@ -0,0 +1,436 @@ +use andromeda_data_storage::form::{ExecuteMsg, SubmissionInfo}; +use andromeda_modules::schema::{QueryMsg as SchemaQueryMsg, ValidateDataResponse}; +use andromeda_std::{ + ado_contract::ADOContract, + amp::AndrAddr, + common::{actions::call_action, context::ExecuteContext, encode_binary, Milliseconds}, + error::ContractError, +}; +use cosmwasm_std::{ensure, Env, QueryRequest, Response, Uint64, WasmQuery}; +use cw_utils::{nonpayable, Expiration}; + +use crate::{ + contract::SUBMIT_FORM_ACTION, + state::{submissions, Config, CONFIG, SCHEMA_ADO_ADDRESS, SUBMISSION_ID}, +}; + +const MAX_LIMIT: u64 = 100u64; + +pub fn handle_execute(mut ctx: ExecuteContext, msg: ExecuteMsg) -> Result { + let action_response = call_action( + &mut ctx.deps, + &ctx.info, + &ctx.env, + &ctx.amp_ctx, + msg.as_ref(), + )?; + + let res = match msg { + ExecuteMsg::SubmitForm { data } => execute_submit_form(ctx, data), + ExecuteMsg::DeleteSubmission { + submission_id, + wallet_address, + } => execute_delete_submission(ctx, submission_id, wallet_address), + ExecuteMsg::EditSubmission { + submission_id, + wallet_address, + data, + } => execute_edit_submission(ctx, submission_id, wallet_address, data), + ExecuteMsg::OpenForm {} => execute_open_form(ctx), + ExecuteMsg::CloseForm {} => execute_close_form(ctx), + _ => ADOContract::default().execute(ctx, msg), + }?; + + Ok(res + .add_submessages(action_response.messages) + .add_attributes(action_response.attributes) + .add_events(action_response.events)) +} + +pub fn execute_submit_form( + mut ctx: ExecuteContext, + data: String, +) -> Result { + nonpayable(&ctx.info)?; + let sender = ctx.info.sender; + ADOContract::default().is_permissioned( + ctx.deps.branch(), + ctx.env.clone(), + SUBMIT_FORM_ACTION, + sender.clone(), + )?; + + let config = CONFIG.load(ctx.deps.storage)?; + validate_form_is_opened(ctx.env, config.clone())?; + + let schema_ado_address = SCHEMA_ADO_ADDRESS.load(ctx.deps.storage)?; + let data_to_validate = data.clone(); + let validate_res: ValidateDataResponse = + ctx.deps + .querier + .query(&QueryRequest::Wasm(WasmQuery::Smart { + contract_addr: schema_ado_address + .get_raw_address(&ctx.deps.as_ref())? + .into_string(), + msg: encode_binary(&SchemaQueryMsg::ValidateData { + data: data_to_validate, + })?, + }))?; + + let submission_id = SUBMISSION_ID.load(ctx.deps.storage)?; + let new_id = submission_id.checked_add(Uint64::one())?; + + match validate_res { + ValidateDataResponse::Valid => { + let allow_multiple_submissions = config.allow_multiple_submissions; + match allow_multiple_submissions { + true => { + submissions().save( + ctx.deps.storage, + &(new_id.u64(), sender.clone()), + &SubmissionInfo { + submission_id: new_id.u64(), + wallet_address: sender.clone(), + data, + }, + )?; + SUBMISSION_ID.save(ctx.deps.storage, &new_id)?; + } + false => { + let submissions_by_address: Vec = submissions() + .idx + .wallet_address + .prefix(sender.clone()) + .range(ctx.deps.storage, None, None, cosmwasm_std::Order::Ascending) + .take(MAX_LIMIT as usize) + .map(|r| r.unwrap().1) + .collect(); + + if submissions_by_address.is_empty() { + submissions().save( + ctx.deps.storage, + &(new_id.u64(), sender.clone()), + &SubmissionInfo { + submission_id: new_id.u64(), + wallet_address: sender.clone(), + data, + }, + )?; + SUBMISSION_ID.save(ctx.deps.storage, &new_id)?; + } else { + return Err(ContractError::CustomError { + msg: "Multiple submissions are not allowed".to_string(), + }); + } + } + } + } + ValidateDataResponse::Invalid { msg } => return Err(ContractError::CustomError { msg }), + } + + let response = Response::new() + .add_attribute("method", "submit_form") + .add_attribute("submission_id", new_id) + .add_attribute("sender", sender.clone()); + + Ok(response) +} + +pub fn execute_delete_submission( + ctx: ExecuteContext, + submission_id: u64, + wallet_address: AndrAddr, +) -> Result { + nonpayable(&ctx.info)?; + let sender = ctx.info.sender; + ensure!( + ADOContract::default().is_owner_or_operator(ctx.deps.storage, sender.as_ref())?, + ContractError::Unauthorized {} + ); + + let address = wallet_address.get_raw_address(&ctx.deps.as_ref())?; + submissions() + .load(ctx.deps.storage, &(submission_id, address.clone())) + .map_err(|_| ContractError::CustomError { + msg: format!( + "Submission does not exist - Submission_id {:?}, Wallet_address {:?}", + submission_id, wallet_address + ), + })?; + submissions().remove(ctx.deps.storage, &(submission_id, address.clone()))?; + + let response = Response::new() + .add_attribute("method", "delete_submission") + .add_attribute("submission_id", Uint64::from(submission_id)) + .add_attribute("sender", sender); + + Ok(response) +} + +pub fn execute_edit_submission( + ctx: ExecuteContext, + submission_id: u64, + wallet_address: AndrAddr, + data: String, +) -> Result { + nonpayable(&ctx.info)?; + let sender = ctx.info.sender; + + let config = CONFIG.load(ctx.deps.storage)?; + let allow_edit_submission = config.allow_edit_submission; + ensure!( + allow_edit_submission, + ContractError::CustomError { + msg: "Edit submission is not allowed".to_string(), + } + ); + // validate if the Form is opened + validate_form_is_opened(ctx.env, config.clone())?; + + let schema_ado_address = SCHEMA_ADO_ADDRESS.load(ctx.deps.storage)?; + let data_to_validate = data.clone(); + let validate_res: ValidateDataResponse = + ctx.deps + .querier + .query(&QueryRequest::Wasm(WasmQuery::Smart { + contract_addr: schema_ado_address + .get_raw_address(&ctx.deps.as_ref())? + .into_string(), + msg: encode_binary(&SchemaQueryMsg::ValidateData { + data: data_to_validate, + })?, + }))?; + + let wallet_address = wallet_address.get_raw_address(&ctx.deps.as_ref())?; + + if submissions() + .may_load(ctx.deps.storage, &(submission_id, wallet_address.clone()))? + .is_some() + { + ensure!( + wallet_address.clone() == sender.clone(), + ContractError::Unauthorized {} + ); + + match validate_res { + ValidateDataResponse::Valid => { + submissions().save( + ctx.deps.storage, + &(submission_id, wallet_address), + &SubmissionInfo { + submission_id, + wallet_address: sender.clone(), + data: data.clone(), + }, + )?; + } + ValidateDataResponse::Invalid { msg } => { + return Err(ContractError::CustomError { msg }) + } + } + } else { + return Err(ContractError::CustomError { + msg: format!( + "Submission is not existed - Submission_id {:?}, Wallet_address {:?}", + submission_id, wallet_address + ), + }); + } + + let response = Response::new() + .add_attribute("method", "edit_submission") + .add_attribute("submission_id", Uint64::from(submission_id)) + .add_attribute("sender", sender); + + Ok(response) +} + +pub fn execute_open_form(ctx: ExecuteContext) -> Result { + nonpayable(&ctx.info)?; + let sender = ctx.info.sender; + ensure!( + ADOContract::default().is_owner_or_operator(ctx.deps.storage, sender.as_ref())?, + ContractError::Unauthorized {} + ); + + let mut config = CONFIG.load(ctx.deps.storage)?; + + let current_time = Milliseconds::from_nanos(ctx.env.block.time.nanos()); + let start_time = current_time.plus_milliseconds(Milliseconds(1)); + + let saved_start_time = config.start_time; + let saved_end_time = config.end_time; + match saved_start_time { + // If a start time is already configured: + Some(saved_start_time) => match saved_end_time { + // If both start time and end time are configured: + Some(saved_end_time) => { + // If the start time is in the future, update the start time to the current start_time value. + if saved_start_time.gt(&start_time) { + config.start_time = Some(start_time); + CONFIG.save(ctx.deps.storage, &config)?; + } + // If the form is still open (end time is in the future), return an error as the form is already open. + else if saved_end_time.gt(&start_time) { + return Err(ContractError::CustomError { + msg: format!("Already opened. Opened time {:?}", saved_start_time), + }); + } + // Otherwise, the form was closed. Update the start time to reopen the form and clear the end time. + else { + config.start_time = Some(start_time); + config.end_time = None; + CONFIG.save(ctx.deps.storage, &config)?; + } + } + + // If only the start time is configured (no end time): + None => { + // Update the start time if the saved start time is in the future. + if saved_start_time.gt(&start_time) { + config.start_time = Some(start_time); + CONFIG.save(ctx.deps.storage, &config)?; + } + // Otherwise, the form is already open, return an error. + else { + return Err(ContractError::CustomError { + msg: format!("Already opened. Opened time {:?}", saved_start_time), + }); + } + } + }, + // If no start time is configured: + None => { + // Set the start time to the current start_time value. + config.start_time = Some(start_time); + CONFIG.save(ctx.deps.storage, &config)?; + + // If an end time exists and is in the past, clear it to reopen the form. + if let Some(saved_end_time) = saved_end_time { + if start_time.gt(&saved_end_time) { + config.end_time = None; + CONFIG.save(ctx.deps.storage, &config)?; + } + } + } + } + + let response = Response::new() + .add_attribute("method", "open_form") + .add_attribute("sender", sender); + + Ok(response) +} + +pub fn execute_close_form(ctx: ExecuteContext) -> Result { + nonpayable(&ctx.info)?; + let sender = ctx.info.sender; + ensure!( + ADOContract::default().is_owner_or_operator(ctx.deps.storage, sender.as_ref())?, + ContractError::Unauthorized {} + ); + + let current_time = Milliseconds::from_nanos(ctx.env.block.time.nanos()); + let end_time = current_time.plus_milliseconds(Milliseconds(1)); + + let mut config = CONFIG.load(ctx.deps.storage)?; + let saved_start_time = config.start_time; + let saved_end_time = config.end_time; + match saved_end_time { + // If an end time is configured: + Some(saved_end_time) => match saved_start_time { + // If both start time and end time are configured: + Some(saved_start_time) => { + // If the form start time is in the future, return an error indicating the form isn't open yet. + if saved_start_time.gt(&end_time) { + return Err(ContractError::CustomError { + msg: format!("Not opened yet. Will be opened at {:?}", saved_start_time), + }); + } + // If the form is still open (end time is in the future), update the end time to the current end_time value. + else if saved_end_time.gt(&end_time) { + config.end_time = Some(end_time); + CONFIG.save(ctx.deps.storage, &config)?; + } + // Otherwise, the form has already been closed. Return an error. + else { + return Err(ContractError::CustomError { + msg: format!("Already closed. Closed at {:?}", saved_end_time), + }); + } + } + // If no start time is configured: + None => { + // Return an error indicating the form has not been opened yet. + return Err(ContractError::CustomError { + msg: "Not opened yet".to_string(), + }); + } + }, + // If no end time is configured: + None => match saved_start_time { + // If the start time exists: + Some(saved_start_time) => { + // If the start time is in the future, return an error indicating the form isn't open yet. + if saved_start_time.gt(&end_time) { + return Err(ContractError::CustomError { + msg: format!("Not opened yet. Will be opened at {:?}", saved_start_time), + }); + } + // Otherwise, set the end time to the current end_time value to close the form. + else { + config.end_time = Some(end_time); + CONFIG.save(ctx.deps.storage, &config)?; + } + } + // If no start time exists: + None => { + // Return an error indicating the form has not been opened yet. + return Err(ContractError::CustomError { + msg: "Not opened yet".to_string(), + }); + } + }, + } + + let response = Response::new() + .add_attribute("method", "close_form") + .add_attribute("sender", sender); + + Ok(response) +} + +pub fn milliseconds_from_expiration(expiration: Expiration) -> Result { + match expiration { + Expiration::AtTime(time) => Ok(Milliseconds::from_nanos(time.nanos())), + _ => Err(ContractError::CustomError { + msg: "Not supported expiration enum".to_string(), + }), + } +} + +pub fn validate_form_is_opened(env: Env, config: Config) -> Result<(), ContractError> { + let current_time = Milliseconds::from_nanos(env.block.time.nanos()); + let saved_start_time = config.start_time; + let saved_end_time = config.end_time; + match saved_start_time { + Some(saved_start_time) => { + if saved_start_time.gt(¤t_time) { + return Err(ContractError::CustomError { + msg: format!("Not opened yet. Will be opened at {:?}", saved_start_time), + }); + } + if let Some(saved_end_time) = saved_end_time { + if current_time.gt(&saved_end_time) { + return Err(ContractError::CustomError { + msg: format!("Already closed. Closed at {:?}", saved_end_time), + }); + } + } + Ok(()) + } + None => Err(ContractError::CustomError { + msg: "Not opened yet. Start time is not set".to_string(), + }), + } +} diff --git a/contracts/data-storage/andromeda-form/src/interface.rs b/contracts/data-storage/andromeda-form/src/interface.rs new file mode 100644 index 000000000..8eede40a4 --- /dev/null +++ b/contracts/data-storage/andromeda-form/src/interface.rs @@ -0,0 +1,6 @@ +use andromeda_data_storage::form::{ExecuteMsg, InstantiateMsg, QueryMsg}; +use andromeda_std::{ado_base::MigrateMsg, contract_interface, deploy::ADOMetadata}; + +pub const CONTRACT_ID: &str = "form"; + +contract_interface!(FormContract, CONTRACT_ID, "andromeda_form.wasm"); diff --git a/contracts/data-storage/andromeda-form/src/lib.rs b/contracts/data-storage/andromeda-form/src/lib.rs new file mode 100644 index 000000000..16c926105 --- /dev/null +++ b/contracts/data-storage/andromeda-form/src/lib.rs @@ -0,0 +1,14 @@ +pub mod contract; +pub mod execute; +pub mod query; +pub mod state; +#[cfg(test)] +pub mod testing; + +#[cfg(all(not(target_arch = "wasm32"), feature = "testing"))] +pub mod mock; + +#[cfg(not(target_arch = "wasm32"))] +mod interface; +#[cfg(not(target_arch = "wasm32"))] +pub use crate::interface::FormContract; diff --git a/contracts/data-storage/andromeda-form/src/mock.rs b/contracts/data-storage/andromeda-form/src/mock.rs new file mode 100644 index 000000000..e9d978ee9 --- /dev/null +++ b/contracts/data-storage/andromeda-form/src/mock.rs @@ -0,0 +1,210 @@ +#![cfg(all(not(target_arch = "wasm32"), feature = "testing"))] +use crate::contract::{execute, instantiate, query}; +use andromeda_data_storage::form::{ExecuteMsg, InstantiateMsg, QueryMsg}; +use andromeda_data_storage::form::{ + FormConfig, GetAllSubmissionsResponse, GetFormStatusResponse, GetSchemaResponse, + GetSubmissionResponse, +}; +use andromeda_std::amp::AndrAddr; +use andromeda_testing::mock::MockApp; +use andromeda_testing::{ + mock_ado, + mock_contract::{ExecuteResult, MockADO, MockContract}, +}; +use cosmwasm_std::{Addr, Coin, Empty}; +use cw_multi_test::{Contract, ContractWrapper}; + +pub struct MockForm(Addr); +mock_ado!(MockForm, ExecuteMsg, QueryMsg); + +impl MockForm { + pub fn instantiate( + code_id: u64, + sender: Addr, + app: &mut MockApp, + kernel_address: String, + owner: Option, + schema_ado_address: AndrAddr, + authorized_addresses_for_submission: Option>, + form_config: FormConfig, + custom_key_for_notifications: Option, + ) -> MockForm { + let msg = mock_form_instantiate_msg( + kernel_address, + owner, + schema_ado_address, + authorized_addresses_for_submission, + form_config, + custom_key_for_notifications, + ); + let addr = app + .instantiate_contract( + code_id, + sender.clone(), + &msg, + &[], + "Form Contract", + Some(sender.to_string()), + ) + .unwrap(); + MockForm(Addr::unchecked(addr)) + } + + pub fn execute_submit_form( + &self, + app: &mut MockApp, + sender: Addr, + funds: Option, + data: String, + ) -> ExecuteResult { + let msg = ExecuteMsg::SubmitForm { data }; + + // Conditionally build the funds vector + let funds_vec = match funds { + Some(funds) => vec![funds], + None => vec![], + }; + + // Call the method once + app.execute_contract(sender, self.addr().clone(), &msg, &funds_vec) + } + + pub fn execute_delete_submission( + &self, + app: &mut MockApp, + sender: Addr, + funds: Option, + submission_id: u64, + wallet_address: AndrAddr, + ) -> ExecuteResult { + let msg = ExecuteMsg::DeleteSubmission { + submission_id, + wallet_address, + }; + + // Conditionally build the funds vector + let funds_vec = match funds { + Some(funds) => vec![funds], + None => vec![], + }; + + // Call the method once + app.execute_contract(sender, self.addr().clone(), &msg, &funds_vec) + } + + pub fn execute_edit_submission( + &self, + app: &mut MockApp, + sender: Addr, + funds: Option, + submission_id: u64, + wallet_address: AndrAddr, + data: String, + ) -> ExecuteResult { + let msg = ExecuteMsg::EditSubmission { + submission_id, + wallet_address, + data, + }; + + // Conditionally build the funds vector + let funds_vec = match funds { + Some(funds) => vec![funds], + None => vec![], + }; + + // Call the method once + app.execute_contract(sender, self.addr().clone(), &msg, &funds_vec) + } + + pub fn execute_open_form( + &self, + app: &mut MockApp, + sender: Addr, + funds: Option, + ) -> ExecuteResult { + let msg = ExecuteMsg::OpenForm {}; + + // Conditionally build the funds vector + let funds_vec = match funds { + Some(funds) => vec![funds], + None => vec![], + }; + + // Call the method once + app.execute_contract(sender, self.addr().clone(), &msg, &funds_vec) + } + + pub fn execute_close_form( + &self, + app: &mut MockApp, + sender: Addr, + funds: Option, + ) -> ExecuteResult { + let msg = ExecuteMsg::CloseForm {}; + + // Conditionally build the funds vector + let funds_vec = match funds { + Some(funds) => vec![funds], + None => vec![], + }; + + // Call the method once + app.execute_contract(sender, self.addr().clone(), &msg, &funds_vec) + } + + pub fn query_schema(&self, app: &mut MockApp) -> GetSchemaResponse { + let msg = QueryMsg::GetSchema {}; + let res: GetSchemaResponse = self.query(app, msg); + res + } + + pub fn query_form_status(&self, app: &mut MockApp) -> GetFormStatusResponse { + let msg = QueryMsg::GetFormStatus {}; + let res: GetFormStatusResponse = self.query(app, msg); + res + } + + pub fn query_all_submissions(&self, app: &mut MockApp) -> GetAllSubmissionsResponse { + let msg = QueryMsg::GetAllSubmissions {}; + let res: GetAllSubmissionsResponse = self.query(app, msg); + res + } + + pub fn query_submission( + &self, + app: &mut MockApp, + submission_id: u64, + wallet_address: AndrAddr, + ) -> GetSubmissionResponse { + let msg = QueryMsg::GetSubmission { + submission_id, + wallet_address, + }; + let res: GetSubmissionResponse = self.query(app, msg); + res + } +} + +pub fn mock_andromeda_form() -> Box> { + let contract = ContractWrapper::new_with_empty(execute, instantiate, query); + Box::new(contract) +} + +pub fn mock_form_instantiate_msg( + kernel_address: impl Into, + owner: Option, + schema_ado_address: AndrAddr, + authorized_addresses_for_submission: Option>, + form_config: FormConfig, + custom_key_for_notifications: Option, +) -> InstantiateMsg { + InstantiateMsg { + kernel_address: kernel_address.into(), + owner, + schema_ado_address, + authorized_addresses_for_submission, + form_config, + custom_key_for_notifications, + } +} diff --git a/contracts/data-storage/andromeda-form/src/query.rs b/contracts/data-storage/andromeda-form/src/query.rs new file mode 100644 index 000000000..dac0bc5df --- /dev/null +++ b/contracts/data-storage/andromeda-form/src/query.rs @@ -0,0 +1,74 @@ +use andromeda_data_storage::form::{ + GetAllSubmissionsResponse, GetFormStatusResponse, GetSchemaResponse, GetSubmissionIdsResponse, + GetSubmissionResponse, SubmissionInfo, +}; +use andromeda_modules::schema::{GetSchemaResponse as SchemaResponse, QueryMsg as SchemaQueryMsg}; +use andromeda_std::{amp::AndrAddr, common::encode_binary, error::ContractError}; +use cosmwasm_std::{Deps, Env, StdResult, Storage}; +use cosmwasm_std::{QueryRequest, WasmQuery}; + +use crate::execute::validate_form_is_opened; +use crate::state::{submissions, CONFIG, SCHEMA_ADO_ADDRESS}; + +pub fn get_schema(deps: Deps) -> Result { + let schema_ado_address = SCHEMA_ADO_ADDRESS.load(deps.storage)?; + let res: SchemaResponse = deps.querier.query(&QueryRequest::Wasm(WasmQuery::Smart { + contract_addr: schema_ado_address.get_raw_address(&deps)?.into_string(), + msg: encode_binary(&SchemaQueryMsg::GetSchema {})?, + }))?; + let schema = res.schema; + Ok(GetSchemaResponse { schema }) +} + +pub fn get_form_status( + storage: &dyn Storage, + env: Env, +) -> Result { + let config = CONFIG.load(storage)?; + // validate if the Form is opened + let res_validation = validate_form_is_opened(env, config); + if res_validation.is_ok() { + Ok(GetFormStatusResponse::Opened) + } else { + Ok(GetFormStatusResponse::Closed) + } +} + +pub fn get_all_submissions( + storage: &dyn Storage, +) -> Result { + let all_submissions: Vec = submissions() + .idx + .submission_id + .range(storage, None, None, cosmwasm_std::Order::Ascending) + .map(|result| result.map(|(_, submission)| submission)) + .collect::>()?; + Ok(GetAllSubmissionsResponse { all_submissions }) +} + +pub fn get_submission( + deps: Deps, + submission_id: u64, + wallet_address: AndrAddr, +) -> Result { + let wallet_address = wallet_address.get_raw_address(&deps)?; + let submission = + submissions().may_load(deps.storage, &(submission_id, wallet_address.clone()))?; + Ok(GetSubmissionResponse { submission }) +} + +pub fn get_submission_ids( + deps: Deps, + wallet_address: AndrAddr, +) -> Result { + let submissions_map = submissions(); + let submission_ids: Vec = submissions_map + .idx + .wallet_address + .prefix(wallet_address.get_raw_address(&deps)?) + .range(deps.storage, None, None, cosmwasm_std::Order::Ascending) + .map(|result| result.map(|(_, submission)| submission.submission_id)) + .collect::>()?; + + Ok(GetSubmissionIdsResponse { submission_ids }) +} diff --git a/contracts/data-storage/andromeda-form/src/state.rs b/contracts/data-storage/andromeda-form/src/state.rs new file mode 100644 index 000000000..bc661c871 --- /dev/null +++ b/contracts/data-storage/andromeda-form/src/state.rs @@ -0,0 +1,52 @@ +use andromeda_data_storage::form::SubmissionInfo; +use andromeda_std::{amp::AndrAddr, common::MillisecondsExpiration}; +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Addr, Uint64}; +use cw_storage_plus::{Index, IndexList, IndexedMap, Item, MultiIndex}; + +pub const SCHEMA_ADO_ADDRESS: Item = Item::new("schema_ado_address"); +pub const CONFIG: Item = Item::new("config"); +pub const SUBMISSION_ID: Item = Item::new("submission_id"); + +#[cw_serde] +pub struct Config { + pub start_time: Option, + pub end_time: Option, + pub allow_multiple_submissions: bool, + pub allow_edit_submission: bool, +} + +pub struct SubmissionIndexes<'a> { + /// PK: submission_id + wallet_address + /// Secondary key: submission_id + pub submission_id: MultiIndex<'a, u64, SubmissionInfo, (u64, Addr)>, + + /// PK: submission_id + wallet_address + /// Secondary key: wallet_address + pub wallet_address: MultiIndex<'a, Addr, SubmissionInfo, (u64, Addr)>, +} + +impl<'a> IndexList for SubmissionIndexes<'a> { + fn get_indexes( + &'_ self, + ) -> Box> + '_> { + let v: Vec<&dyn Index> = vec![&self.submission_id, &self.wallet_address]; + Box::new(v.into_iter()) + } +} + +pub fn submissions<'a>() -> IndexedMap<'a, &'a (u64, Addr), SubmissionInfo, SubmissionIndexes<'a>> { + let indexes = SubmissionIndexes { + submission_id: MultiIndex::new( + |_pk: &[u8], r| r.submission_id, + "submission", + "submission_id_index", + ), + wallet_address: MultiIndex::new( + |_pk: &[u8], r| r.wallet_address.clone(), + "submission", + "wallet_address_index", + ), + }; + IndexedMap::new("submission", indexes) +} diff --git a/contracts/data-storage/andromeda-form/src/testing/mock.rs b/contracts/data-storage/andromeda-form/src/testing/mock.rs new file mode 100644 index 000000000..b74e4634b --- /dev/null +++ b/contracts/data-storage/andromeda-form/src/testing/mock.rs @@ -0,0 +1,198 @@ +use andromeda_data_storage::form::{ExecuteMsg, InstantiateMsg, QueryMsg}; +use andromeda_data_storage::form::{ + FormConfig, GetAllSubmissionsResponse, GetFormStatusResponse, GetSchemaResponse, + GetSubmissionIdsResponse, GetSubmissionResponse, +}; +use andromeda_std::{ + amp::AndrAddr, error::ContractError, testing::mock_querier::MOCK_KERNEL_CONTRACT, +}; +use cosmwasm_std::{ + from_json, + testing::{mock_env, mock_info, MockApi, MockStorage}, + Deps, DepsMut, MessageInfo, OwnedDeps, Response, Timestamp, +}; + +use crate::contract::{execute, instantiate, query}; +use crate::testing::mock_querier::{mock_dependencies_custom, WasmMockQuerier}; + +pub type MockDeps = OwnedDeps; + +pub fn valid_initialization( + schema_ado_address: AndrAddr, + authorized_addresses_for_submission: Option>, + form_config: FormConfig, + custom_key_for_notifications: Option, + timestamp_nanos: u64, +) -> (MockDeps, MessageInfo, Response) { + let mut deps = mock_dependencies_custom(&[]); + let info = mock_info("creator", &[]); + let msg = InstantiateMsg { + kernel_address: MOCK_KERNEL_CONTRACT.to_string(), + owner: None, + schema_ado_address, + authorized_addresses_for_submission, + form_config, + custom_key_for_notifications, + }; + let mut env = mock_env(); + env.block.time = Timestamp::from_nanos(timestamp_nanos); + let response = instantiate(deps.as_mut(), env, info.clone(), msg).unwrap(); + + (deps, info.clone(), response) +} + +pub fn invalid_initialization( + schema_ado_address: AndrAddr, + authorized_addresses_for_submission: Option>, + form_config: FormConfig, + custom_key_for_notifications: Option, + timestamp_nanos: u64, +) -> (MockDeps, MessageInfo, ContractError) { + let mut deps = mock_dependencies_custom(&[]); + let info = mock_info("creator", &[]); + let msg = InstantiateMsg { + kernel_address: MOCK_KERNEL_CONTRACT.to_string(), + owner: None, + schema_ado_address, + authorized_addresses_for_submission, + form_config, + custom_key_for_notifications, + }; + let mut env = mock_env(); + env.block.time = Timestamp::from_nanos(timestamp_nanos); + let err = instantiate(deps.as_mut(), env, info.clone(), msg).unwrap_err(); + + (deps, info.clone(), err) +} + +pub fn submit_form( + deps: DepsMut<'_>, + sender: &str, + data: String, + timestamp_nanos: u64, +) -> Result { + let msg = ExecuteMsg::SubmitForm { data }; + let info = mock_info(sender, &[]); + let mut env = mock_env(); + env.block.time = Timestamp::from_nanos(timestamp_nanos); + execute(deps, env, info, msg) +} + +pub fn delete_submission( + deps: DepsMut<'_>, + sender: &str, + submission_id: u64, + wallet_address: AndrAddr, +) -> Result { + let msg = ExecuteMsg::DeleteSubmission { + submission_id, + wallet_address, + }; + let info = mock_info(sender, &[]); + let env = mock_env(); + execute(deps, env, info, msg) +} + +pub fn edit_submission( + deps: DepsMut<'_>, + sender: &str, + submission_id: u64, + wallet_address: AndrAddr, + data: String, +) -> Result { + let msg = ExecuteMsg::EditSubmission { + submission_id, + wallet_address, + data, + }; + let info = mock_info(sender, &[]); + let env = mock_env(); + execute(deps, env, info, msg) +} + +pub fn open_form( + deps: DepsMut<'_>, + sender: &str, + timestamp_nanos: u64, +) -> Result { + let msg = ExecuteMsg::OpenForm {}; + let info = mock_info(sender, &[]); + let mut env = mock_env(); + env.block.time = Timestamp::from_nanos(timestamp_nanos); + execute(deps, env, info, msg) +} + +pub fn close_form( + deps: DepsMut<'_>, + sender: &str, + timestamp_nanos: u64, +) -> Result { + let msg = ExecuteMsg::CloseForm {}; + let info = mock_info(sender, &[]); + let mut env = mock_env(); + env.block.time = Timestamp::from_nanos(timestamp_nanos); + execute(deps, env, info, msg) +} + +pub fn query_schema(deps: Deps) -> Result { + let res = query(deps, mock_env(), QueryMsg::GetSchema {}); + match res { + Ok(res) => Ok(from_json(res)?), + Err(err) => Err(err), + } +} + +pub fn query_form_status( + deps: Deps, + timestamp_nanos: u64, +) -> Result { + let mut env = mock_env(); + env.block.time = Timestamp::from_nanos(timestamp_nanos); + let res = query(deps, env, QueryMsg::GetFormStatus {}); + match res { + Ok(res) => Ok(from_json(res)?), + Err(err) => Err(err), + } +} + +pub fn query_all_submissions(deps: Deps) -> Result { + let res = query(deps, mock_env(), QueryMsg::GetAllSubmissions {}); + match res { + Ok(res) => Ok(from_json(res)?), + Err(err) => Err(err), + } +} + +pub fn query_submission( + deps: Deps, + submission_id: u64, + wallet_address: AndrAddr, +) -> Result { + let res = query( + deps, + mock_env(), + QueryMsg::GetSubmission { + submission_id, + wallet_address, + }, + ); + match res { + Ok(res) => Ok(from_json(res)?), + Err(err) => Err(err), + } +} + +pub fn query_submission_ids( + deps: Deps, + wallet_address: AndrAddr, +) -> Result { + let res = query( + deps, + mock_env(), + QueryMsg::GetSubmissionIds { wallet_address }, + ); + match res { + Ok(res) => Ok(from_json(res)?), + Err(err) => Err(err), + } +} diff --git a/contracts/data-storage/andromeda-form/src/testing/mock_querier.rs b/contracts/data-storage/andromeda-form/src/testing/mock_querier.rs new file mode 100644 index 000000000..bb4ca00fc --- /dev/null +++ b/contracts/data-storage/andromeda-form/src/testing/mock_querier.rs @@ -0,0 +1,110 @@ +use andromeda_data_storage::form::GetSchemaResponse; +use andromeda_modules::schema::{QueryMsg as SchemaQueryMsg, ValidateDataResponse}; +use andromeda_std::testing::mock_querier::MockAndromedaQuerier; +use andromeda_std::{ + ado_base::InstantiateMsg, ado_contract::ADOContract, + testing::mock_querier::MOCK_KERNEL_CONTRACT, +}; +use cosmwasm_std::QuerierWrapper; +use cosmwasm_std::{ + from_json, + testing::{mock_env, mock_info, MockApi, MockQuerier, MockStorage, MOCK_CONTRACT_ADDR}, + Coin, OwnedDeps, Querier, QuerierResult, QueryRequest, SystemError, SystemResult, WasmQuery, +}; +use cosmwasm_std::{to_json_binary, Binary, ContractResult}; + +pub const MOCK_SCHEMA_ADO: &str = "schema_ado"; + +/// Alternative to `cosmwasm_std::testing::mock_dependencies` that allows us to respond to custom queries. +/// +/// Automatically assigns a kernel address as MOCK_KERNEL_CONTRACT. +pub fn mock_dependencies_custom( + contract_balance: &[Coin], +) -> OwnedDeps { + let custom_querier: WasmMockQuerier = + WasmMockQuerier::new(MockQuerier::new(&[(MOCK_CONTRACT_ADDR, contract_balance)])); + let storage = MockStorage::default(); + let mut deps = OwnedDeps { + storage, + api: MockApi::default(), + querier: custom_querier, + custom_query_type: std::marker::PhantomData, + }; + ADOContract::default() + .instantiate( + &mut deps.storage, + mock_env(), + &deps.api, + &QuerierWrapper::new(&deps.querier), + mock_info("sender", &[]), + InstantiateMsg { + ado_type: "form".to_string(), + ado_version: "test".to_string(), + kernel_address: MOCK_KERNEL_CONTRACT.to_string(), + owner: None, + }, + ) + .unwrap(); + deps +} +pub struct WasmMockQuerier { + pub base: MockQuerier, +} + +impl Querier for WasmMockQuerier { + fn raw_query(&self, bin_request: &[u8]) -> QuerierResult { + // MockQuerier doesn't support Custom, so we ignore it completely here + let request: QueryRequest = match from_json(bin_request) { + Ok(v) => v, + Err(e) => { + return SystemResult::Err(SystemError::InvalidRequest { + error: format!("Parsing query request: {e}"), + request: bin_request.into(), + }) + } + }; + self.handle_query(&request) + } +} + +impl WasmMockQuerier { + pub fn handle_query(&self, request: &QueryRequest) -> QuerierResult { + match &request { + QueryRequest::Wasm(WasmQuery::Smart { contract_addr, msg }) => { + match contract_addr.as_str() { + MOCK_SCHEMA_ADO => self.handle_schema_smart_query(msg), + _ => MockAndromedaQuerier::default().handle_query(&self.base, request), + } + } + _ => MockAndromedaQuerier::default().handle_query(&self.base, request), + } + } + + fn handle_schema_smart_query(&self, msg: &Binary) -> QuerierResult { + match from_json(msg).unwrap() { + SchemaQueryMsg::GetSchema {} => { + let msg_response = GetSchemaResponse { + schema: "{\"properties\":{\"age\":{\"type\":\"number\"},\"name\":{\"type\":\"string\"}},\"required\":[\"name\",\"age\"],\"type\":\"object\"}".to_string(), + }; + SystemResult::Ok(ContractResult::Ok(to_json_binary(&msg_response).unwrap())) + } + SchemaQueryMsg::ValidateData { data } => match data.as_str().starts_with("valid") { + true => { + let msg_response = ValidateDataResponse::Valid; + SystemResult::Ok(ContractResult::Ok(to_json_binary(&msg_response).unwrap())) + } + false => { + let msg_response = ValidateDataResponse::Invalid { + msg: "Invalid data against schema".to_string(), + }; + SystemResult::Ok(ContractResult::Ok(to_json_binary(&msg_response).unwrap())) + } + }, + _ => panic!("Unsupported Query"), + } + } + + pub fn new(base: MockQuerier) -> Self { + WasmMockQuerier { base } + } +} diff --git a/contracts/data-storage/andromeda-form/src/testing/mod.rs b/contracts/data-storage/andromeda-form/src/testing/mod.rs new file mode 100644 index 000000000..217ceb8c1 --- /dev/null +++ b/contracts/data-storage/andromeda-form/src/testing/mod.rs @@ -0,0 +1,3 @@ +mod mock; +mod mock_querier; +mod tests; diff --git a/contracts/data-storage/andromeda-form/src/testing/tests.rs b/contracts/data-storage/andromeda-form/src/testing/tests.rs new file mode 100644 index 000000000..c760e6267 --- /dev/null +++ b/contracts/data-storage/andromeda-form/src/testing/tests.rs @@ -0,0 +1,670 @@ +use super::mock::{ + close_form, invalid_initialization, open_form, query_all_submissions, query_form_status, + query_schema, query_submission, query_submission_ids, valid_initialization, +}; +use andromeda_data_storage::form::{ + FormConfig, GetFormStatusResponse, GetSchemaResponse, SubmissionInfo, +}; +use andromeda_std::{ + amp::AndrAddr, + common::{expiration::Expiry, Milliseconds}, + error::ContractError, +}; +use cosmwasm_std::{testing::mock_env, Addr, Timestamp}; +use test_case::test_case; + +use crate::{ + state::CONFIG, + testing::mock::{delete_submission, edit_submission, submit_form}, +}; + +pub const MOCK_SCHEMA_ADO: &str = "schema_ado"; + +#[test_case( + FormConfig { + start_time: None, + end_time: None, + allow_multiple_submissions: true, + allow_edit_submission: true, + }, + 10000_u64; + "With none start and none end time" +)] +#[test_case( + FormConfig { + start_time: Some(Expiry::AtTime(Milliseconds::from_nanos(1000002000000_u64))), + end_time: None, + allow_multiple_submissions: true, + allow_edit_submission: true, + }, + 1000000000000_u64; + "With valid start time and none end time" +)] +#[test_case( + FormConfig { + start_time: None, + end_time: Some(Expiry::AtTime(Milliseconds::from_nanos(1000002000000_u64))), + allow_multiple_submissions: true, + allow_edit_submission: true, + }, + 1000000000000_u64; + "With none start time and valid end time" +)] +#[test_case( + FormConfig { + start_time: Some(Expiry::AtTime(Milliseconds::from_nanos(1000002000000_u64))), + end_time: Some(Expiry::AtTime(Milliseconds::from_nanos(2000000000000_u64))), + allow_multiple_submissions: true, + allow_edit_submission: true, + }, + 1000000000000_u64; + "With valid start and end time" +)] +#[test_case( + FormConfig { + start_time: Some(Expiry::FromNow(Milliseconds::from_nanos(1000002000000_u64))), + end_time: None, + allow_multiple_submissions: true, + allow_edit_submission: true, + }, + 1000000000000_u64; + "With valid FromNow start time and none end time" +)] +#[test_case( + FormConfig { + start_time: None, + end_time: Some(Expiry::FromNow(Milliseconds::from_nanos(1000002000000_u64))), + allow_multiple_submissions: true, + allow_edit_submission: true, + }, + 1000000000000_u64; + "With none start time and valid FromNow end time" +)] +#[test_case( + FormConfig { + start_time: Some(Expiry::FromNow(Milliseconds::from_nanos(1000000000000_u64))), + end_time: Some(Expiry::FromNow(Milliseconds::from_nanos(2000000000000_u64))), + allow_multiple_submissions: true, + allow_edit_submission: true, + }, + 1000000000000_u64; + "With valid FromNow start and end time" +)] +fn test_valid_instantiation(form_config: FormConfig, timestamp: u64) { + valid_initialization( + AndrAddr::from_string(MOCK_SCHEMA_ADO), + None, + form_config, + None, + timestamp, + ); +} + +#[test_case( + FormConfig { + start_time: Some(Expiry::AtTime(Milliseconds::from_nanos(900000000000_u64))), + end_time: None, + allow_multiple_submissions: true, + allow_edit_submission: true, + }, + 1000000000000_u64, + ContractError::StartTimeInThePast { current_time: 1000000_u64, current_block: 12345_u64 }; + "With invalid start and none end time" +)] +#[test_case( + FormConfig { + start_time: None, + end_time: Some(Expiry::AtTime(Milliseconds::from_nanos(900000000000_u64))), + allow_multiple_submissions: true, + allow_edit_submission: true, + }, + 1000000000000_u64, + ContractError::CustomError { + msg: format!( + "End time in the past. current_time {:?}, current_block {:?}", + 1000000_u64, 12345_u64 + ), + }; + "With none start and invalid end time" +)] +#[test_case( + FormConfig { + start_time: Some(Expiry::AtTime(Milliseconds::from_nanos(2000000000000_u64))), + end_time: Some(Expiry::AtTime(Milliseconds::from_nanos(1200000000000_u64))), + allow_multiple_submissions: true, + allow_edit_submission: true, + }, + 1000000000000_u64, + ContractError::StartTimeAfterEndTime {}; + "With invalid start and end time_1" +)] +#[test_case( + FormConfig { + start_time: Some(Expiry::FromNow(Milliseconds::from_nanos(2000000000000_u64))), + end_time: Some(Expiry::FromNow(Milliseconds::from_nanos(1200000000000_u64))), + allow_multiple_submissions: true, + allow_edit_submission: true, + }, + 1000000000000_u64, + ContractError::StartTimeAfterEndTime {}; + "With invalid start and end time_2" +)] +fn test_invalid_instantiation( + form_config: FormConfig, + timestamp: u64, + expected_err: ContractError, +) { + let (_, _, err) = invalid_initialization( + AndrAddr::from_string(MOCK_SCHEMA_ADO), + None, + form_config, + None, + timestamp, + ); + assert_eq!(expected_err, err); +} + +#[test_case( + FormConfig { + start_time: Some(Expiry::AtTime(Milliseconds::from_nanos(1000000000000_u64))), + end_time: None, + allow_multiple_submissions: true, + allow_edit_submission: true, + }, + 500000000000_u64, + 2000000000000_u64, + ContractError::CustomError { + msg: format!("Already opened. Opened time {:?}", Milliseconds::from_nanos(1000000000000_u64)), + }; + "Invalid timestamp at execution with saved start time" +)] +#[test_case( + FormConfig { + start_time: Some(Expiry::AtTime(Milliseconds::from_nanos(1000000000000_u64))), + end_time: Some(Expiry::AtTime(Milliseconds::from_nanos(3000000000000_u64))), + allow_multiple_submissions: true, + allow_edit_submission: true, + }, + 500000000000_u64, + 2000000000000_u64, + ContractError::CustomError { + msg: format!("Already opened. Opened time {:?}", Milliseconds::from_nanos(1000000000000_u64)), + }; + "Invalid timestamp at execution with saved start and end time" +)] +fn test_failed_open_form( + form_config: FormConfig, + instantiation_timestamp: u64, + execute_timestamp: u64, + expected_err: ContractError, +) { + let (mut deps, info, _) = valid_initialization( + AndrAddr::from_string(MOCK_SCHEMA_ADO), + None, + form_config, + None, + instantiation_timestamp, + ); + let err = open_form(deps.as_mut(), info.sender.as_ref(), execute_timestamp).unwrap_err(); + assert_eq!(expected_err, err); +} + +#[test_case( + FormConfig { + start_time: Some(Expiry::AtTime(Milliseconds::from_nanos(2000000000000_u64))), + end_time: Some(Expiry::AtTime(Milliseconds::from_nanos(3000000000000_u64))), + allow_multiple_submissions: true, + allow_edit_submission: true, + }, + 500000000000_u64, + 1000000000000_u64; + "Valid timestamp at execution with saved start and end time_1" +)] +#[test_case( + FormConfig { + start_time: Some(Expiry::AtTime(Milliseconds::from_nanos(2000000000000_u64))), + end_time: Some(Expiry::AtTime(Milliseconds::from_nanos(3000000000000_u64))), + allow_multiple_submissions: true, + allow_edit_submission: true, + }, + 500000000000_u64, + 4000000000000_u64; + "Valid timestamp at execution with saved start and end time_2" +)] +#[test_case( + FormConfig { + start_time: None, + end_time: None, + allow_multiple_submissions: true, + allow_edit_submission: true, + }, + 500000000000_u64, + 1000000000000_u64; + "Valid timestamp at execution with none start and end time" +)] +#[test_case( + FormConfig { + start_time: None, + end_time: Some(Expiry::AtTime(Milliseconds::from_nanos(4000000000000_u64))), + allow_multiple_submissions: true, + allow_edit_submission: true, + }, + 500000000000_u64, + 1000000000000_u64; + "Valid timestamp at execution with end time_1" +)] +#[test_case( + FormConfig { + start_time: None, + end_time: Some(Expiry::AtTime(Milliseconds::from_nanos(1000000000000_u64))), + allow_multiple_submissions: true, + allow_edit_submission: true, + }, + 500000000000_u64, + 3000000000000_u64; + "Valid timestamp at execution with end time_2" +)] +fn test_success_open_form( + form_config: FormConfig, + instantiation_timestamp: u64, + execute_timestamp: u64, +) { + let (mut deps, info, _) = valid_initialization( + AndrAddr::from_string(MOCK_SCHEMA_ADO), + None, + form_config.clone(), + None, + instantiation_timestamp, + ); + let res = open_form(deps.as_mut(), info.sender.as_ref(), execute_timestamp); + assert!(res.is_ok()); + + let config = CONFIG.load(&deps.storage).unwrap(); + let start_time = config.start_time; + let expected_saved_start_time = if start_time.is_some() { + Some(Milliseconds::from_nanos(execute_timestamp).plus_milliseconds(Milliseconds(1))) + } else { + None + }; + assert_eq!(expected_saved_start_time, start_time); + + let end_time = config.end_time; + + let saved_start_time = if let Some(start_time) = form_config.start_time { + let mut env = mock_env(); + env.block.time = Timestamp::from_nanos(instantiation_timestamp); + Some(start_time.get_time(&env.block)) + } else { + None + }; + let saved_end_time = if let Some(end_time) = form_config.end_time { + let mut env = mock_env(); + env.block.time = Timestamp::from_nanos(instantiation_timestamp); + Some(end_time.get_time(&env.block)) + } else { + None + }; + let execute_time = Milliseconds::from_nanos(execute_timestamp); + match saved_start_time { + Some(saved_start_time) => match saved_end_time { + Some(saved_end_time) => { + if saved_start_time.gt(&execute_time) { + assert_eq!(end_time, Some(saved_end_time)) + } else if saved_end_time.gt(&execute_time) { + assert_eq!(end_time, None); + } + } + None => { + if saved_start_time.gt(&execute_time) { + assert_eq!(end_time, None); + } + } + }, + None => { + if let Some(saved_end_time) = saved_end_time { + if execute_time.gt(&saved_end_time) { + assert_eq!(end_time, None); + } else { + assert_eq!(end_time, Some(saved_end_time)) + } + } + } + } +} + +#[test_case( + FormConfig { + start_time: Some(Expiry::AtTime(Milliseconds::from_nanos(2000000000000_u64))), + end_time: Some(Expiry::AtTime(Milliseconds::from_nanos(3000000000000_u64))), + allow_multiple_submissions: true, + allow_edit_submission: true, + }, + 500000000000_u64, + 1000000000000_u64, + ContractError::CustomError { + msg: format!("Not opened yet. Will be opened at {:?}", Milliseconds::from_nanos(2000000000000_u64)), + }; + "Invalid timestamp at execution with saved start and end time-1" +)] +#[test_case( + FormConfig { + start_time: Some(Expiry::AtTime(Milliseconds::from_nanos(2000000000000_u64))), + end_time: Some(Expiry::AtTime(Milliseconds::from_nanos(3000000000000_u64))), + allow_multiple_submissions: true, + allow_edit_submission: true, + }, + 500000000000_u64, + 4000000000000_u64, + ContractError::CustomError { + msg: format!("Already closed. Closed at {:?}", Milliseconds::from_nanos(3000000000000_u64)), + }; + "Invalid timestamp at execution with saved start and end time-2" +)] +#[test_case( + FormConfig { + start_time: None, + end_time: Some(Expiry::AtTime(Milliseconds::from_nanos(3000000000000_u64))), + allow_multiple_submissions: true, + allow_edit_submission: true, + }, + 500000000000_u64, + 4000000000000_u64, + ContractError::CustomError { + msg: "Not opened yet".to_string(), + }; + "Invalid timestamp at execution with none start time" +)] +#[test_case( + FormConfig { + start_time: Some(Expiry::AtTime(Milliseconds::from_nanos(2000000000000_u64))), + end_time: None, + allow_multiple_submissions: true, + allow_edit_submission: true, + }, + 500000000000_u64, + 1000000000000_u64, + ContractError::CustomError { + msg: format!("Not opened yet. Will be opened at {:?}", Milliseconds::from_nanos(2000000000000_u64)), + }; + "Invalid timestamp at execution with start time" +)] +#[test_case( + FormConfig { + start_time: None, + end_time: None, + allow_multiple_submissions: true, + allow_edit_submission: true, + }, + 500000000000_u64, + 1000000000000_u64, + ContractError::CustomError { + msg: "Not opened yet".to_string(), + }; + "Invalid timestamp at execution with none start and end time" +)] +fn test_failed_close_form( + form_config: FormConfig, + instantiation_timestamp: u64, + execute_timestamp: u64, + expected_err: ContractError, +) { + let (mut deps, info, _) = valid_initialization( + AndrAddr::from_string(MOCK_SCHEMA_ADO), + None, + form_config, + None, + instantiation_timestamp, + ); + let err = close_form(deps.as_mut(), info.sender.as_ref(), execute_timestamp).unwrap_err(); + assert_eq!(expected_err, err); +} + +#[test] +fn test_submit_form_allowed_multiple_submission() { + let form_config = FormConfig { + start_time: None, + end_time: None, + allow_multiple_submissions: true, + allow_edit_submission: true, + }; + let (mut deps, info, _) = valid_initialization( + AndrAddr::from_string(MOCK_SCHEMA_ADO), + None, + form_config, + None, + 5000000000_u64, + ); + open_form(deps.as_mut(), info.sender.as_ref(), 10000000000_u64).unwrap(); + + let form_status = query_form_status(deps.as_ref(), 20000000000_u64).unwrap(); + assert_eq!(form_status, GetFormStatusResponse::Opened); + + submit_form( + deps.as_mut(), + "user1", + "valid_data1".to_string(), + 20000000000_u64, + ) + .unwrap(); + submit_form( + deps.as_mut(), + "user1", + "valid_data2".to_string(), + 30000000000_u64, + ) + .unwrap(); + submit_form( + deps.as_mut(), + "user2", + "valid_data3".to_string(), + 40000000000_u64, + ) + .unwrap(); + submit_form( + deps.as_mut(), + "user3", + "valid_data4".to_string(), + 50000000000_u64, + ) + .unwrap(); + submit_form( + deps.as_mut(), + "user4", + "valid_data5".to_string(), + 60000000000_u64, + ) + .unwrap(); + submit_form( + deps.as_mut(), + "user4", + "valid_data6".to_string(), + 70000000000_u64, + ) + .unwrap(); + submit_form( + deps.as_mut(), + "user4", + "valid_data7".to_string(), + 80000000000_u64, + ) + .unwrap(); + let err = submit_form( + deps.as_mut(), + "user4", + "invalid_data8".to_string(), + 85000000000_u64, + ) + .unwrap_err(); + assert_eq!( + err, + ContractError::CustomError { + msg: "Invalid data against schema".to_string(), + } + ); + + let all_submissions = query_all_submissions(deps.as_ref()) + .unwrap() + .all_submissions; + assert_eq!(all_submissions.len(), 7_usize); + + let submission_ids = query_submission_ids(deps.as_ref(), AndrAddr::from_string("user1")) + .unwrap() + .submission_ids; + assert_eq!(submission_ids.len(), 2_usize); + + let submission_ids = query_submission_ids(deps.as_ref(), AndrAddr::from_string("user5")) + .unwrap() + .submission_ids; + assert_eq!(submission_ids.len(), 0_usize); + + delete_submission( + deps.as_mut(), + info.sender.as_ref(), + 4, + AndrAddr::from_string("user3"), + ) + .unwrap(); + let all_submissions = query_all_submissions(deps.as_ref()) + .unwrap() + .all_submissions; + assert_eq!(all_submissions.len(), 6_usize); + + close_form(deps.as_mut(), info.sender.as_ref(), 90000000000_u64).unwrap(); + + let form_status = query_form_status(deps.as_ref(), 100000000000_u64).unwrap(); + assert_eq!(form_status, GetFormStatusResponse::Closed); + + let err = submit_form( + deps.as_mut(), + "user5", + "valid_data8".to_string(), + 110000000000_u64, + ) + .unwrap_err(); + + assert_eq!( + err, + ContractError::CustomError { + msg: format!( + "Already closed. Closed at {:?}", + Milliseconds::from_nanos(90000000000_u64).plus_milliseconds(Milliseconds(1)) + ) + } + ); +} + +#[test] +fn test_submit_form_disallowed_multiple_submission_disallowed_edit() { + let form_config = FormConfig { + start_time: None, + end_time: None, + allow_multiple_submissions: false, + allow_edit_submission: false, + }; + let (mut deps, info, _) = valid_initialization( + AndrAddr::from_string(MOCK_SCHEMA_ADO), + None, + form_config, + None, + 5000000000_u64, + ); + open_form(deps.as_mut(), info.sender.as_ref(), 10000000000_u64).unwrap(); + submit_form( + deps.as_mut(), + "user1", + "valid_data1".to_string(), + 20000000000_u64, + ) + .unwrap(); + let res = submit_form( + deps.as_mut(), + "user1", + "valid_data2".to_string(), + 30000000000_u64, + ) + .unwrap_err(); + assert_eq!( + res, + ContractError::CustomError { + msg: "Multiple submissions are not allowed".to_string(), + } + ); + + let res = edit_submission( + deps.as_mut(), + "user1", + 1, + AndrAddr::from_string("user1"), + "valid_data2".to_string(), + ) + .unwrap_err(); + assert_eq!( + res, + ContractError::CustomError { + msg: "Edit submission is not allowed".to_string(), + } + ); +} + +#[test] +fn test_submit_form_disallowed_multiple_submission_allowed_edit() { + let form_config = FormConfig { + start_time: None, + end_time: None, + allow_multiple_submissions: false, + allow_edit_submission: true, + }; + let (mut deps, info, _) = valid_initialization( + AndrAddr::from_string(MOCK_SCHEMA_ADO), + None, + form_config, + None, + 5000000000_u64, + ); + open_form(deps.as_mut(), info.sender.as_ref(), 10000000000_u64).unwrap(); + submit_form( + deps.as_mut(), + "user1", + "valid_data1".to_string(), + 20000000000_u64, + ) + .unwrap(); + + let res = edit_submission( + deps.as_mut(), + "user2", + 1, + AndrAddr::from_string("user1"), + "valid_data2".to_string(), + ) + .unwrap_err(); + assert_eq!(res, ContractError::Unauthorized {}); + + edit_submission( + deps.as_mut(), + "user1", + 1, + AndrAddr::from_string("user1"), + "valid_data2".to_string(), + ) + .unwrap(); + let submission = query_submission(deps.as_ref(), 1, AndrAddr::from_string("user1")) + .unwrap() + .submission; + assert_eq!( + submission, + Some(SubmissionInfo { + submission_id: 1, + wallet_address: Addr::unchecked("user1"), + data: "valid_data2".to_string() + }) + ); + + let schema = query_schema(deps.as_ref()).unwrap(); + assert_eq!( + schema, + GetSchemaResponse { + schema: "{\"properties\":{\"age\":{\"type\":\"number\"},\"name\":{\"type\":\"string\"}},\"required\":[\"name\",\"age\"],\"type\":\"object\"}".to_string(), + } + ); +} diff --git a/contracts/modules/andromeda-schema/.cargo/config b/contracts/modules/andromeda-schema/.cargo/config new file mode 100644 index 000000000..624255c74 --- /dev/null +++ b/contracts/modules/andromeda-schema/.cargo/config @@ -0,0 +1,4 @@ +[alias] +wasm = "build --release --target wasm32-unknown-unknown" +unit-test = "test --lib" +schema = "run --example schema" \ No newline at end of file diff --git a/contracts/modules/andromeda-schema/Cargo.toml b/contracts/modules/andromeda-schema/Cargo.toml new file mode 100644 index 000000000..cee6c91a8 --- /dev/null +++ b/contracts/modules/andromeda-schema/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "andromeda-schema" +version = "0.1.0-beta" +edition = "2021" +rust-version = "1.75.0" + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +# for more explicit tests, cargo test --features=backtraces +backtraces = ["cosmwasm-std/backtraces"] +# use library feature to disable all instantiate/execute/query exports +library = [] +testing = ["cw-multi-test", "andromeda-testing"] + + +[dependencies] +cosmwasm-std = { workspace = true } +cosmwasm-schema = { workspace = true } +cw-storage-plus = { workspace = true } +cw-json = { git = "https://github.com/SlayerAnsh/cw-json.git" } +serde_json = { workspace = true } +test-case = { workspace = true } + +andromeda-std = { workspace = true } +andromeda-modules = { workspace = true } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +cw-orch = { workspace = true } +cw-multi-test = { workspace = true, optional = true } +andromeda-testing = { workspace = true, optional = true } + +[dev-dependencies] +andromeda-app = { workspace = true } diff --git a/contracts/modules/andromeda-schema/examples/schema.rs b/contracts/modules/andromeda-schema/examples/schema.rs new file mode 100644 index 000000000..218073863 --- /dev/null +++ b/contracts/modules/andromeda-schema/examples/schema.rs @@ -0,0 +1,10 @@ +use andromeda_modules::schema::{ExecuteMsg, InstantiateMsg, QueryMsg}; +use cosmwasm_schema::write_api; +fn main() { + write_api! { + instantiate: InstantiateMsg, + query: QueryMsg, + execute: ExecuteMsg, + + } +} diff --git a/contracts/modules/andromeda-schema/src/contract.rs b/contracts/modules/andromeda-schema/src/contract.rs new file mode 100644 index 000000000..634f3fd3b --- /dev/null +++ b/contracts/modules/andromeda-schema/src/contract.rs @@ -0,0 +1,100 @@ +#[cfg(not(feature = "library"))] +use cosmwasm_std::{ + entry_point, Binary, Deps, DepsMut, Env, MessageInfo, Reply, Response, StdError, +}; + +use andromeda_modules::schema::{ExecuteMsg, InstantiateMsg, QueryMsg}; + +use andromeda_std::{ + ado_base::{InstantiateMsg as BaseInstantiateMsg, MigrateMsg}, + ado_contract::ADOContract, + common::{context::ExecuteContext, encode_binary}, + error::ContractError, +}; +use cw_json::JSON; +use serde_json::{from_str, Value}; + +use crate::{ + execute::handle_execute, + query::{get_schema, validate_data}, + state::SCHEMA, +}; + +// version info for migration info +const CONTRACT_NAME: &str = "crates.io:andromeda-schema"; +const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: InstantiateMsg, +) -> Result { + let resp = ADOContract::default().instantiate( + deps.storage, + env, + deps.api, + &deps.querier, + info, + BaseInstantiateMsg { + ado_type: CONTRACT_NAME.to_string(), + ado_version: CONTRACT_VERSION.to_string(), + kernel_address: msg.kernel_address, + owner: msg.owner, + }, + )?; + + let schema_json_string = msg.schema_json_string; + + let schema_json_value: Value = + from_str(schema_json_string.as_str()).map_err(|_| ContractError::CustomError { + msg: "Invalid JSON Schema".to_string(), + })?; + let schema_json = JSON::try_from(schema_json_value.to_string().as_str()).unwrap(); + + SCHEMA.save(deps.storage, &schema_json)?; + + Ok(resp) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + let ctx = ExecuteContext::new(deps, info, env); + match msg { + ExecuteMsg::AMPReceive(pkt) => { + ADOContract::default().execute_amp_receive(ctx, pkt, handle_execute) + } + _ => handle_execute(ctx, msg), + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> Result { + match msg { + QueryMsg::ValidateData { data } => encode_binary(&validate_data(deps.storage, data)?), + QueryMsg::GetSchema {} => encode_binary(&get_schema(deps.storage)?), + _ => ADOContract::default().query(deps, env, msg), + } +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn migrate(deps: DepsMut, _env: Env, _msg: MigrateMsg) -> Result { + ADOContract::default().migrate(deps, CONTRACT_NAME, CONTRACT_VERSION) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn reply(_deps: DepsMut, _env: Env, msg: Reply) -> Result { + if msg.result.is_err() { + return Err(ContractError::Std(StdError::generic_err( + msg.result.unwrap_err(), + ))); + } + + Ok(Response::default()) +} diff --git a/contracts/modules/andromeda-schema/src/execute.rs b/contracts/modules/andromeda-schema/src/execute.rs new file mode 100644 index 000000000..ee1361a2d --- /dev/null +++ b/contracts/modules/andromeda-schema/src/execute.rs @@ -0,0 +1,59 @@ +use andromeda_modules::schema::ExecuteMsg; +use andromeda_std::{ + ado_contract::ADOContract, + common::{actions::call_action, context::ExecuteContext}, + error::ContractError, +}; +use cosmwasm_std::{ensure, Response}; +use cw_json::JSON; +use serde_json::{from_str, Value}; + +use crate::state::SCHEMA; + +pub fn handle_execute(mut ctx: ExecuteContext, msg: ExecuteMsg) -> Result { + let action_response = call_action( + &mut ctx.deps, + &ctx.info, + &ctx.env, + &ctx.amp_ctx, + msg.as_ref(), + )?; + + let res = match msg { + ExecuteMsg::UpdateSchema { + new_schema_json_string, + } => execute_update_schema(ctx, new_schema_json_string), + _ => ADOContract::default().execute(ctx, msg), + }?; + + Ok(res + .add_submessages(action_response.messages) + .add_attributes(action_response.attributes) + .add_events(action_response.events)) +} + +fn execute_update_schema( + ctx: ExecuteContext, + new_schema_json: String, +) -> Result { + let sender: cosmwasm_std::Addr = ctx.info.sender; + + ensure!( + ADOContract::default().is_owner_or_operator(ctx.deps.storage, sender.as_ref())?, + ContractError::Unauthorized {} + ); + + let new_schema_json_value: Value = + from_str(new_schema_json.as_str()).map_err(|_| ContractError::CustomError { + msg: "Invalid JSON Schema".to_string(), + })?; + let new_schema_json = JSON::try_from(new_schema_json_value.to_string().as_str()).unwrap(); + + SCHEMA.save(ctx.deps.storage, &new_schema_json)?; + + let response = Response::new() + .add_attribute("method", "update_schema") + .add_attribute("sender", sender); + + Ok(response) +} diff --git a/contracts/modules/andromeda-schema/src/interface.rs b/contracts/modules/andromeda-schema/src/interface.rs new file mode 100644 index 000000000..e64c35f36 --- /dev/null +++ b/contracts/modules/andromeda-schema/src/interface.rs @@ -0,0 +1,6 @@ +use andromeda_modules::schema::{ExecuteMsg, InstantiateMsg, QueryMsg}; +use andromeda_std::{ado_base::MigrateMsg, contract_interface, deploy::ADOMetadata}; + +pub const CONTRACT_ID: &str = "schema"; + +contract_interface!(SchemaContract, CONTRACT_ID, "andromeda_schema.wasm"); diff --git a/contracts/modules/andromeda-schema/src/lib.rs b/contracts/modules/andromeda-schema/src/lib.rs new file mode 100644 index 000000000..bc1546615 --- /dev/null +++ b/contracts/modules/andromeda-schema/src/lib.rs @@ -0,0 +1,14 @@ +pub mod contract; +pub mod execute; +pub mod query; +pub mod state; +#[cfg(test)] +pub mod testing; + +#[cfg(all(not(target_arch = "wasm32"), feature = "testing"))] +pub mod mock; + +#[cfg(not(target_arch = "wasm32"))] +mod interface; +#[cfg(not(target_arch = "wasm32"))] +pub use crate::interface::SchemaContract; diff --git a/contracts/modules/andromeda-schema/src/mock.rs b/contracts/modules/andromeda-schema/src/mock.rs new file mode 100644 index 000000000..39db6cffa --- /dev/null +++ b/contracts/modules/andromeda-schema/src/mock.rs @@ -0,0 +1,81 @@ +#![cfg(all(not(target_arch = "wasm32"), feature = "testing"))] +use crate::contract::{execute, instantiate, query}; +use andromeda_modules::schema::{ExecuteMsg, InstantiateMsg, QueryMsg, ValidateDataResponse}; +use andromeda_testing::mock::MockApp; +use andromeda_testing::{ + mock_ado, + mock_contract::{ExecuteResult, MockADO, MockContract}, +}; +use cosmwasm_std::{Addr, Coin, Empty}; +use cw_multi_test::{Contract, ContractWrapper}; + +pub struct MockSchema(Addr); +mock_ado!(MockSchema, ExecuteMsg, QueryMsg); + +impl MockSchema { + pub fn instantiate( + code_id: u64, + sender: Addr, + app: &mut MockApp, + kernel_address: String, + owner: Option, + schema_json_string: String, + ) -> MockSchema { + let msg = mock_schema_instantiate_msg(kernel_address, owner, schema_json_string); + let addr = app + .instantiate_contract( + code_id, + sender.clone(), + &msg, + &[], + "Schema Contract", + Some(sender.to_string()), + ) + .unwrap(); + MockSchema(Addr::unchecked(addr)) + } + + pub fn execute_update_schema( + &self, + app: &mut MockApp, + sender: Addr, + funds: Option, + new_schema_json_string: String, + ) -> ExecuteResult { + let msg = ExecuteMsg::UpdateSchema { + new_schema_json_string, + }; + + // Conditionally build the funds vector + let funds_vec = match funds { + Some(funds) => vec![funds], + None => vec![], + }; + + // Call the method once + app.execute_contract(sender, self.addr().clone(), &msg, &funds_vec) + } + + pub fn query_validate_data(&self, app: &mut MockApp, data: String) -> ValidateDataResponse { + let msg = QueryMsg::ValidateData { data }; + let res: ValidateDataResponse = self.query(app, msg); + res + } +} + +pub fn mock_andromeda_schema() -> Box> { + let contract = ContractWrapper::new_with_empty(execute, instantiate, query); + Box::new(contract) +} + +pub fn mock_schema_instantiate_msg( + kernel_address: impl Into, + owner: Option, + schema_json_string: String, +) -> InstantiateMsg { + InstantiateMsg { + kernel_address: kernel_address.into(), + owner, + schema_json_string, + } +} diff --git a/contracts/modules/andromeda-schema/src/query.rs b/contracts/modules/andromeda-schema/src/query.rs new file mode 100644 index 000000000..aa92e1b5c --- /dev/null +++ b/contracts/modules/andromeda-schema/src/query.rs @@ -0,0 +1,226 @@ +use andromeda_modules::schema::{GetSchemaResponse, ValidateDataResponse}; +use andromeda_std::error::ContractError; +use cosmwasm_std::Storage; +use serde_json::{from_str, json, Value}; + +use crate::state::SCHEMA; + +pub fn get_schema(storage: &dyn Storage) -> Result { + let schema = SCHEMA.load(storage)?.to_string(); + Ok(GetSchemaResponse { schema }) +} + +pub fn validate_data( + storage: &dyn Storage, + data: String, +) -> Result { + // Load the schema from storage + let schema: Value = json!(SCHEMA.load(storage)?); + let data_instance: Value = from_str(&data).map_err(|e| ContractError::CustomError { + msg: format!("Invalid data JSON: {}", e), + })?; + + // Perform basic validation for types: string, array, and object + if basic_type_matches(&schema, &data_instance) { + Ok(ValidateDataResponse::Valid) + } else { + Ok(ValidateDataResponse::Invalid { + msg: "Data structure does not match the basic schema types.".to_string(), + }) + } +} + +fn basic_type_matches(schema: &Value, data: &Value) -> bool { + match schema.get("type") { + Some(Value::String(schema_type)) => match schema_type.as_str() { + "string" => data.is_string(), + "number" => data.is_number(), + "boolean" => data.is_boolean(), + "array" => { + if let Some(items_schema) = schema.get("items") { + if let Value::Array(data_array) = data { + data_array + .iter() + .all(|item| basic_type_matches(items_schema, item)) + } else { + false + } + } else { + false + } + } + "object" => { + if let Some(Value::Object(schema_props)) = schema.get("properties") { + if let Value::Object(data_obj) = data { + // Check for required properties + let required_fields = schema.get("required").and_then(|r| r.as_array()); + if let Some(required_fields) = required_fields { + if !required_fields.iter().all(|field| { + field.as_str().map_or(false, |f| data_obj.contains_key(f)) + }) { + return false; + } + } + // Check each property + schema_props.iter().all(|(key, prop_schema)| { + if let Some(data_value) = data_obj.get(key) { + basic_type_matches(prop_schema, data_value) + } else { + true // Property not present, acceptable if not required + } + }) + } else { + false + } + } else { + false + } + } + _ => false, // Unsupported type + }, + _ => false, // Type not specified in schema + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_basic_type_matches_string() { + let schema = json!({ "type": "string" }); + let data = json!("Hello World"); + assert!(basic_type_matches(&schema, &data)); + + let data = json!(123); + assert!(!basic_type_matches(&schema, &data)); + } + + #[test] + fn test_basic_type_matches_number() { + let schema = json!({ "type": "number" }); + let data = json!(42); + assert!(basic_type_matches(&schema, &data)); + + let data = json!("42"); + assert!(!basic_type_matches(&schema, &data)); + } + + #[test] + fn test_basic_type_matches_boolean() { + let schema = json!({ "type": "boolean" }); + let data = json!(true); + assert!(basic_type_matches(&schema, &data)); + + let data = json!("true"); + assert!(!basic_type_matches(&schema, &data)); + } + + #[test] + fn test_basic_type_matches_array_of_strings() { + let schema = json!({ + "type": "array", + "items": { "type": "string" } + }); + let data = json!(["apple", "banana", "cherry"]); + assert!(basic_type_matches(&schema, &data)); + + let data = json!(["apple", 123, "cherry"]); + assert!(!basic_type_matches(&schema, &data)); + } + + #[test] + fn test_basic_type_matches_array_of_objects() { + let schema = json!({ + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { "type": "number" }, + "name": { "type": "string" } + }, + "required": ["id", "name"] + } + }); + let data = json!([ + { "id": 1, "name": "Alice" }, + { "id": 2, "name": "Bob" } + ]); + assert!(basic_type_matches(&schema, &data)); + + let data = json!([ + { "id": "one", "name": "Alice" }, + { "id": 2, "name": "Bob" } + ]); + assert!(!basic_type_matches(&schema, &data)); + } + + #[test] + fn test_basic_type_matches_object() { + let schema = json!({ + "type": "object", + "properties": { + "title": { "type": "string" }, + "count": { "type": "number" } + }, + "required": ["title", "count"] + }); + let data = json!({ "title": "Introduction", "count": 10 }); + assert!(basic_type_matches(&schema, &data)); + + let data = json!({ "title": "Introduction" }); + assert!(!basic_type_matches(&schema, &data)); + + let data = json!({ "title": "Introduction", "count": "ten" }); + assert!(!basic_type_matches(&schema, &data)); + } + + #[test] + fn test_basic_type_matches_nested_object() { + let schema = json!({ + "type": "object", + "properties": { + "user": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "age": { "type": "number" } + }, + "required": ["name", "age"] + }, + "roles": { + "type": "array", + "items": { "type": "string" } + } + }, + "required": ["user", "roles"] + }); + let data = json!({ + "user": { "name": "Charlie", "age": 25 }, + "roles": ["admin", "editor"] + }); + assert!(basic_type_matches(&schema, &data)); + + // Missing required property "age" + let data = json!({ + "user": { "name": "Charlie" }, + "roles": ["admin", "editor"] + }); + assert!(!basic_type_matches(&schema, &data)); + + // Incorrect type for "age" + let data = json!({ + "user": { "name": "Charlie", "age": "twenty-five" }, + "roles": ["admin", "editor"] + }); + assert!(!basic_type_matches(&schema, &data)); + + // Incorrect type in "roles" array + let data = json!({ + "user": { "name": "Charlie", "age": 25 }, + "roles": ["admin", 123] + }); + assert!(!basic_type_matches(&schema, &data)); + } +} diff --git a/contracts/modules/andromeda-schema/src/state.rs b/contracts/modules/andromeda-schema/src/state.rs new file mode 100644 index 000000000..93a48ec34 --- /dev/null +++ b/contracts/modules/andromeda-schema/src/state.rs @@ -0,0 +1,4 @@ +use cw_json::JSON; +use cw_storage_plus::Item; + +pub const SCHEMA: Item = Item::new("schema"); diff --git a/contracts/modules/andromeda-schema/src/testing/mock.rs b/contracts/modules/andromeda-schema/src/testing/mock.rs new file mode 100644 index 000000000..0d6bfaf6a --- /dev/null +++ b/contracts/modules/andromeda-schema/src/testing/mock.rs @@ -0,0 +1,49 @@ +use andromeda_modules::schema::{ + GetSchemaResponse, InstantiateMsg, QueryMsg, ValidateDataResponse, +}; +use andromeda_std::{ + error::ContractError, + testing::mock_querier::{mock_dependencies_custom, WasmMockQuerier, MOCK_KERNEL_CONTRACT}, +}; +use cosmwasm_std::{ + from_json, + testing::{mock_env, mock_info, MockApi, MockStorage}, + Deps, MessageInfo, OwnedDeps, +}; + +use crate::contract::{instantiate, query}; + +pub type MockDeps = OwnedDeps; + +pub fn proper_initialization(schema_json_string: String) -> (MockDeps, MessageInfo) { + let mut deps = mock_dependencies_custom(&[]); + let info = mock_info("creator", &[]); + let msg = InstantiateMsg { + kernel_address: MOCK_KERNEL_CONTRACT.to_string(), + owner: None, + schema_json_string, + }; + let env = mock_env(); + let res = instantiate(deps.as_mut(), env, info.clone(), msg).unwrap(); + assert_eq!(0, res.messages.len()); + (deps, info) +} + +pub fn query_validate_data( + deps: Deps, + data: String, +) -> Result { + let res = query(deps, mock_env(), QueryMsg::ValidateData { data }); + match res { + Ok(res) => Ok(from_json(res).unwrap()), + Err(err) => Err(err), + } +} + +pub fn query_schema(deps: Deps) -> Result { + let res = query(deps, mock_env(), QueryMsg::GetSchema {}); + match res { + Ok(res) => Ok(from_json(res).unwrap()), + Err(err) => Err(err), + } +} diff --git a/contracts/modules/andromeda-schema/src/testing/mod.rs b/contracts/modules/andromeda-schema/src/testing/mod.rs new file mode 100644 index 000000000..3bfda2893 --- /dev/null +++ b/contracts/modules/andromeda-schema/src/testing/mod.rs @@ -0,0 +1,2 @@ +mod mock; +mod tests; diff --git a/contracts/modules/andromeda-schema/src/testing/tests.rs b/contracts/modules/andromeda-schema/src/testing/tests.rs new file mode 100644 index 000000000..356c0856b --- /dev/null +++ b/contracts/modules/andromeda-schema/src/testing/tests.rs @@ -0,0 +1,220 @@ +use super::mock::{proper_initialization, query_schema, query_validate_data}; +use andromeda_modules::schema::ValidateDataResponse; +use test_case::test_case; + +// JSON schema definitions for each type and nested structures +pub const SCHEMA_STRING_TYPE: &str = r#" +{ + "type": "string" +}"#; + +pub const SCHEMA_NUMBER_TYPE: &str = r#" +{ + "type": "number" +}"#; + +pub const SCHEMA_BOOLEAN_TYPE: &str = r#" +{ + "type": "boolean" +}"#; + +pub const SCHEMA_ARRAY_OF_STRINGS: &str = r#" +{ + "type": "array", + "items": { "type": "string" } +}"#; + +pub const SCHEMA_ARRAY_OF_OBJECTS: &str = r#" +{ + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { "type": "number" }, + "name": { "type": "string" } + }, + "required": ["id", "name"] + } +}"#; + +pub const SCHEMA_SIMPLE_OBJECT: &str = r#" +{ + "type": "object", + "properties": { + "title": { "type": "string" }, + "count": { "type": "number" } + }, + "required": ["title", "count"] +}"#; + +pub const SCHEMA_NESTED_OBJECT: &str = r#" +{ + "type": "object", + "properties": { + "user": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "age": { "type": "number" } + }, + "required": ["name", "age"] + }, + "roles": { + "type": "array", + "items": { "type": "string" } + } + }, + "required": ["user", "roles"] +}"#; + +#[test_case( + SCHEMA_STRING_TYPE, + r#""Hello World""#, + ValidateDataResponse::Valid ; + "valid string data" +)] +#[test_case( + SCHEMA_STRING_TYPE, + r#"123"#, + ValidateDataResponse::Invalid { msg: "Data structure does not match the basic schema types.".to_string() } ; + "invalid non-string for string schema" +)] +#[test_case( + SCHEMA_NUMBER_TYPE, + r#"42"#, + ValidateDataResponse::Valid ; + "valid number data" +)] +#[test_case( + SCHEMA_NUMBER_TYPE, + r#""forty-two""#, + ValidateDataResponse::Invalid { msg: "Data structure does not match the basic schema types.".to_string() } ; + "invalid non-number for number schema" +)] +#[test_case( + SCHEMA_BOOLEAN_TYPE, + r#"true"#, + ValidateDataResponse::Valid ; + "valid boolean data" +)] +#[test_case( + SCHEMA_BOOLEAN_TYPE, + r#""true""#, + ValidateDataResponse::Invalid { msg: "Data structure does not match the basic schema types.".to_string() } ; + "invalid non-boolean for boolean schema" +)] +#[test_case( + SCHEMA_ARRAY_OF_STRINGS, + r#"["apple", "banana", "cherry"]"#, + ValidateDataResponse::Valid ; + "valid array of strings" +)] +#[test_case( + SCHEMA_ARRAY_OF_STRINGS, + r#"[1, 2, 3]"#, + ValidateDataResponse::Invalid { msg: "Data structure does not match the basic schema types.".to_string() } ; + "invalid array of non-strings for string array schema" +)] +#[test_case( + SCHEMA_ARRAY_OF_OBJECTS, + r#"[{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]"#, + ValidateDataResponse::Valid ; + "valid array of objects with required fields" +)] +#[test_case( + SCHEMA_ARRAY_OF_OBJECTS, + r#"[{"id": "one", "name": "Alice"}, {"id": 2, "name": "Bob"}]"#, + ValidateDataResponse::Invalid { msg: "Data structure does not match the basic schema types.".to_string() } ; + "invalid array of objects with wrong type for id field" +)] +#[test_case( + SCHEMA_SIMPLE_OBJECT, + r#"{"title": "Introduction", "count": 10}"#, + ValidateDataResponse::Valid ; + "valid simple object with required fields" +)] +#[test_case( + SCHEMA_SIMPLE_OBJECT, + r#"{"title": "Introduction"}"#, + ValidateDataResponse::Invalid { msg: "Data structure does not match the basic schema types.".to_string() } ; + "missing required field in simple object" +)] +#[test_case( + SCHEMA_SIMPLE_OBJECT, + r#"{"title": "Introduction", "count": "not_a_number"}"#, + ValidateDataResponse::Invalid { msg: "Data structure does not match the basic schema types.".to_string() } ; + "invalid type for count in simple object" +)] +#[test_case( + SCHEMA_NESTED_OBJECT, + r#"{ + "user": { + "name": "Charlie", + "age": 25 + }, + "roles": ["admin", "user"] + }"#, + ValidateDataResponse::Valid ; + "valid nested object with required properties" +)] +#[test_case( + SCHEMA_NESTED_OBJECT, + r#"{ + "user": { + "name": "Charlie" + }, + "roles": ["admin", "user"] + }"#, + ValidateDataResponse::Invalid { msg: "Data structure does not match the basic schema types.".to_string() } ; + "missing required field in nested object" +)] +#[test_case( + SCHEMA_NESTED_OBJECT, + r#"{ + "user": { + "name": "Charlie", + "age": "twenty-five" + }, + "roles": ["admin", "user"] + }"#, + ValidateDataResponse::Invalid { msg: "Data structure does not match the basic schema types.".to_string() } ; + "invalid type for age in nested object" +)] +#[test_case( + SCHEMA_NESTED_OBJECT, + r#"{ + "user": { + "name": "Charlie", + "age": 25 + }, + "roles": ["admin", 123] + }"#, + ValidateDataResponse::Invalid { msg: "Data structure does not match the basic schema types.".to_string() } ; + "invalid type in roles array within nested object" +)] +fn test_basic_type_matches_cases(schema: &str, data: &str, expected_res: ValidateDataResponse) { + let (deps, _) = proper_initialization(schema.to_string()); + let query_res = query_validate_data(deps.as_ref(), data.to_string()).unwrap(); + assert_eq!(query_res, expected_res); +} + +pub const SCHEMA_INITIAL: &str = r#" +{ + "type": "object", + "properties": { + "name": { "type": "string" }, + "age": { "type": "number" } + }, + "required": ["name", "age"] +}"#; + +#[test] +fn test_query_schema() { + let (deps, _) = proper_initialization(SCHEMA_INITIAL.to_string()); + let query_res = query_schema(deps.as_ref()).unwrap(); + let schema = query_res.schema; + assert_eq!( + schema, + "{\"properties\":{\"age\":{\"type\":\"number\"},\"name\":{\"type\":\"string\"}},\"required\":[\"name\",\"age\"],\"type\":\"object\"}".to_string() + ); +} diff --git a/packages/andromeda-data-storage/src/form.rs b/packages/andromeda-data-storage/src/form.rs new file mode 100644 index 000000000..b86c3519c --- /dev/null +++ b/packages/andromeda-data-storage/src/form.rs @@ -0,0 +1,92 @@ +use andromeda_std::{amp::AndrAddr, common::expiration::Expiry}; +use andromeda_std::{andr_exec, andr_instantiate, andr_query}; +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::Addr; + +#[andr_instantiate] +#[cw_serde] +pub struct InstantiateMsg { + pub schema_ado_address: AndrAddr, // Address of the schema ADO + pub authorized_addresses_for_submission: Option>, + pub form_config: FormConfig, + pub custom_key_for_notifications: Option, +} + +#[cw_serde] +pub struct FormConfig { + pub start_time: Option, // Optional start time for form + pub end_time: Option, // Optional end time for form + pub allow_multiple_submissions: bool, // Whether multiple submissions are allowed + pub allow_edit_submission: bool, // Whether users can edit their submission +} + +#[andr_exec] +#[cw_serde] +pub enum ExecuteMsg { + SubmitForm { + data: String, + }, + DeleteSubmission { + submission_id: u64, + wallet_address: AndrAddr, + }, + EditSubmission { + submission_id: u64, + wallet_address: AndrAddr, + data: String, + }, + OpenForm {}, + CloseForm {}, +} + +#[andr_query] +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + #[returns(GetSchemaResponse)] + GetSchema {}, + #[returns(GetAllSubmissionsResponse)] + GetAllSubmissions {}, + #[returns(GetSubmissionResponse)] + GetSubmission { + submission_id: u64, + wallet_address: AndrAddr, + }, + #[returns(GetSubmissionIdsResponse)] + GetSubmissionIds { wallet_address: AndrAddr }, + #[returns(GetFormStatusResponse)] + GetFormStatus {}, +} + +#[cw_serde] +pub struct GetSchemaResponse { + pub schema: String, +} + +#[cw_serde] +pub struct GetAllSubmissionsResponse { + pub all_submissions: Vec, +} + +#[cw_serde] +pub struct GetSubmissionResponse { + pub submission: Option, +} + +#[cw_serde] +pub struct GetSubmissionIdsResponse { + pub submission_ids: Vec, +} + +#[cw_serde] +pub struct SubmissionInfo { + pub submission_id: u64, + pub wallet_address: Addr, + pub data: String, +} + +#[cw_serde] +pub enum GetFormStatusResponse { + Opened, + Closed, +} diff --git a/packages/andromeda-data-storage/src/lib.rs b/packages/andromeda-data-storage/src/lib.rs index 35859a631..6571b6fe8 100644 --- a/packages/andromeda-data-storage/src/lib.rs +++ b/packages/andromeda-data-storage/src/lib.rs @@ -1,3 +1,4 @@ pub mod boolean; +pub mod form; pub mod primitive; pub mod string_storage; diff --git a/packages/andromeda-modules/src/lib.rs b/packages/andromeda-modules/src/lib.rs index 600952077..877440ce5 100644 --- a/packages/andromeda-modules/src/lib.rs +++ b/packages/andromeda-modules/src/lib.rs @@ -1,2 +1,3 @@ pub mod address_list; pub mod rates; +pub mod schema; diff --git a/packages/andromeda-modules/src/schema.rs b/packages/andromeda-modules/src/schema.rs new file mode 100644 index 000000000..8636a66ee --- /dev/null +++ b/packages/andromeda-modules/src/schema.rs @@ -0,0 +1,35 @@ +use andromeda_std::{andr_exec, andr_instantiate, andr_query}; +use cosmwasm_schema::{cw_serde, QueryResponses}; + +#[andr_instantiate] +#[cw_serde] +pub struct InstantiateMsg { + pub schema_json_string: String, +} + +#[andr_exec] +#[cw_serde] +pub enum ExecuteMsg { + UpdateSchema { new_schema_json_string: String }, +} + +#[andr_query] +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + #[returns(ValidateDataResponse)] + ValidateData { data: String }, + #[returns(GetSchemaResponse)] + GetSchema {}, +} + +#[cw_serde] +pub enum ValidateDataResponse { + Valid, + Invalid { msg: String }, +} + +#[cw_serde] +pub struct GetSchemaResponse { + pub schema: String, +}