diff --git a/contracts/docustore/.cargo/config b/contracts/docustore/.cargo/config new file mode 100644 index 00000000..af5698e5 --- /dev/null +++ b/contracts/docustore/.cargo/config @@ -0,0 +1,4 @@ +[alias] +wasm = "build --release --lib --target wasm32-unknown-unknown" +unit-test = "test --lib" +schema = "run --bin schema" diff --git a/contracts/docustore/Cargo.toml b/contracts/docustore/Cargo.toml new file mode 100644 index 00000000..5b6f6a66 --- /dev/null +++ b/contracts/docustore/Cargo.toml @@ -0,0 +1,53 @@ +[package] +name = "docustore" +version = "0.1.0" +authors = ["Adrian Thompson"] +edition = "2021" + +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. + "artifacts/*", +] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +crate-type = ["cdylib", "rlib"] + +[profile.release] +codegen-units = 1 +debug = false +debug-assertions = false +incremental = false +lto = true +opt-level = 3 +overflow-checks = true +panic = 'abort' +rpath = false + +[features] +# for more explicit tests, cargo test --features=backtraces +backtraces = ["cosmwasm-std/backtraces"] +# use library feature to disable all instantiate/execute/query exports +library = [] + +[package.metadata.scripts] +optimize = """docker run --rm -v "$(pwd)":/code \ + --mount type=volume,source="$(basename "$(pwd)")_cache",target=/target \ + --mount type=volume,source=registry_cache,target=/usr/local/cargo/registry \ + cosmwasm/optimizer:0.16.0 +""" + +[dependencies] +cosmwasm-schema = "1.3.1" +cosmwasm-std = "1.3.1" +cosmwasm-storage = "1.3.1" +cw-storage-plus = "1.1.0" +cw2 = "1.1.0" +schemars = "0.8.12" +serde = { version = "1.0.183", default-features = false, features = ["derive"] } +thiserror = "1.0.44" +serde_json = "1.0" + +[dev-dependencies] +cw-multi-test = "0.17.0" diff --git a/contracts/docustore/LICENSE b/contracts/docustore/LICENSE new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/contracts/docustore/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/contracts/docustore/NOTICE b/contracts/docustore/NOTICE new file mode 100644 index 00000000..a9730633 --- /dev/null +++ b/contracts/docustore/NOTICE @@ -0,0 +1,13 @@ +Copyright 2022 arcayne + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/contracts/docustore/README.md b/contracts/docustore/README.md new file mode 100644 index 00000000..ac580985 --- /dev/null +++ b/contracts/docustore/README.md @@ -0,0 +1,163 @@ +# **CW Counter Starter Contract** + +This is a basic CosmWasm smart contract that allows you to set a counter and then either **increment** or **reset** it. You can also query the current counter value. + +--- + +## **Prerequisites** + +Before deploying the contract, ensure you have the following: + +1. **XION Daemon (`xiond`)** + Follow the official guide to install `xiond`: + [Interact with XION Chain: Setup XION Daemon](https://docs.burnt.com/xion/developers/featured-guides/setup-local-environment/interact-with-xion-chain-setup-xion-daemon) + +2. **Docker** + Install and run [Docker](https://www.docker.com/get-started), as it is required to compile the contract. + +--- + +## **Deploy and Interact with the Contract** + +### **Step 1: Clone the Repository** +```sh +git clone https://github.com/burnt-labs/cw-counter +cd cw-counter +``` + +--- + +### **Step 2: Compile and Optimize the Wasm Bytecode** +Run the following command to compile and optimize the contract: + +```sh +docker run --rm -v "$(pwd)":/code \ + --mount type=volume,source="$(basename "$(pwd)")_cache",target=/target \ + --mount type=volume,source=registry_cache,target=/usr/local/cargo/registry \ + cosmwasm/optimizer:0.16.0 +``` + +> **Note:** +> This step uses **CosmWasm's Optimizing Compiler**, which reduces the contract's binary size, making it more efficient for deployment. +> Learn more [here](https://github.com/CosmWasm/optimizer). + +The optimized contract will be stored as: +``` +cw-counter/artifacts/cw_counter.wasm +``` + +--- + +### **Step 3: Upload the Bytecode to the Blockchain** +First, set your wallet address: +```sh +WALLET="your-wallet-address-here" +``` + +Now, upload the contract to the blockchain: +```sh +RES=$(xiond tx wasm store ./artifacts/cw_counter.wasm \ + --chain-id xion-testnet-1 \ + --gas-adjustment 1.3 \ + --gas-prices 0.1uxion \ + --gas auto \ + -y --output json \ + --node https://rpc.xion-testnet-1.burnt.com:443 \ + --from $WALLET) +``` + +After running the command, **extract the transaction hash**: +```sh +echo $RES +``` + +Example output: +```json +{ + "height": "0", + "txhash": "B557242F3BBF2E68D228EBF6A792C3C617C8C8C984440405A578FBBB8A385035", + ... +} +``` + +Copy the transaction hash for the next step. + +--- + +### **Step 4: Retrieve the Code ID** +Set your transaction hash: +```sh +TXHASH="your-txhash-here" +``` + +Query the blockchain to get the **Code ID**: +```sh +CODE_ID=$(xiond query tx $TXHASH \ + --node https://rpc.xion-testnet-1.burnt.com:443 \ + --output json | jq -r '.events[-1].attributes[1].value') +``` + +Now, display the retrieved Code ID: +```sh +echo $CODE_ID +``` + +Example output: +``` +1213 +``` + +--- + +### **Step 5: Instantiate the Contract** +Set the contract's initialization message: +```sh +MSG='{ "count": 1 }' +``` + +Instantiate the contract with the **Code ID** from the previous step: +```sh +xiond tx wasm instantiate $CODE_ID "$MSG" \ + --from $WALLET \ + --label "cw-counter" \ + --gas-prices 0.025uxion \ + --gas auto \ + --gas-adjustment 1.3 \ + -y --no-admin \ + --chain-id xion-testnet-1 \ + --node https://rpc.xion-testnet-1.burnt.com:443 +``` + +Example output: +``` +gas estimate: 217976 +code: 0 +txhash: 09D48FE11BE8D8BD4FCE11D236D80D180E7ED7707186B1659F5BADC4EC116F30 +``` + +Copy the new transaction hash for the next step. + +--- + +### **Step 6: Retrieve the Contract Address** +Set the new transaction hash: +```sh +TXHASH="your-txhash-here" +``` + +Query the blockchain to get the **contract address**: +```sh +CONTRACT=$(xiond query tx $TXHASH \ + --node https://rpc.xion-testnet-1.burnt.com:443 \ + --output json | jq -r '.events[] | select(.type == "instantiate") | .attributes[] | select(.key == "_contract_address") | .value') +``` + +Display the contract address: +```sh +echo $CONTRACT +``` + +Example output: +``` +xion1v6476wrjmw8fhsh20rl4h6jadeh5sdvlhrt8jyk2szrl3pdj4musyxj6gl +``` diff --git a/contracts/docustore/artifacts/checksums.txt b/contracts/docustore/artifacts/checksums.txt new file mode 100644 index 00000000..cf6f9a20 --- /dev/null +++ b/contracts/docustore/artifacts/checksums.txt @@ -0,0 +1 @@ +3510cf6599926a9e56de52660de213b75b7704e7e4015807b2899bea81d77ceb docustore.wasm diff --git a/contracts/docustore/artifacts/docustore.wasm b/contracts/docustore/artifacts/docustore.wasm new file mode 100644 index 00000000..dde6722f Binary files /dev/null and b/contracts/docustore/artifacts/docustore.wasm differ diff --git a/contracts/docustore/src/contract.rs b/contracts/docustore/src/contract.rs new file mode 100644 index 00000000..d384b570 --- /dev/null +++ b/contracts/docustore/src/contract.rs @@ -0,0 +1,619 @@ +use cosmwasm_std::{ + entry_point, to_json_binary, Addr, Binary, Deps, DepsMut, Env, MessageInfo, + Response, StdError, StdResult, Order, +}; +use cw2::set_contract_version; +use cw_storage_plus::Bound; +use serde_json; + +use crate::msg::{ExecuteMsg, InstantiateMsg, QueryMsg, DocumentResponse, CollectionResponse, WriteOperation, WriteType}; +use crate::state::{Document, CollectionPermissions, PermissionLevel, DOCUMENTS, ADMIN, COLLECTION_PERMISSIONS, USER_ROLES}; + +const CONTRACT_NAME: &str = "firebase-storage"; +const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[entry_point] +pub fn instantiate( + deps: DepsMut, + env: Env, + _info: MessageInfo, + msg: InstantiateMsg, +) -> StdResult { + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + + let admin_addr = deps.api.addr_validate(&msg.admin)?; + ADMIN.save(deps.storage, &admin_addr)?; + + Ok(Response::new() + .add_attribute("method", "instantiate") + .add_attribute("admin", admin_addr)) +} + +#[entry_point] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> StdResult { + match msg { + ExecuteMsg::Set { collection, document, data } => { + execute_set(deps, env, info, collection, document, data) + } + ExecuteMsg::Update { collection, document, data } => { + execute_update(deps, env, info, collection, document, data) + } + ExecuteMsg::Delete { collection, document } => { + execute_delete(deps, env, info, collection, document) + } + ExecuteMsg::BatchWrite { operations } => { + execute_batch_write(deps, env, info, operations) + } + ExecuteMsg::SetCollectionPermissions { collection, permissions } => { + execute_set_permissions(deps, env, info, collection, permissions) + } + ExecuteMsg::GrantRole { user, role } => { + execute_grant_role(deps, env, info, user, role) + } + ExecuteMsg::RevokeRole { user, role } => { + execute_revoke_role(deps, env, info, user, role) + } + ExecuteMsg::TransferAdmin { new_admin } => { + execute_transfer_admin(deps, env, info, new_admin) + } + } +} + +fn execute_set( + deps: DepsMut, + env: Env, + info: MessageInfo, + collection: String, + document_id: String, + data: String, +) -> StdResult { + // Check create permission + if !check_permission(deps.as_ref(), &collection, &info.sender, "create")? { + return Err(StdError::generic_err("Insufficient permissions to create documents in this collection")); + } + + // Validate JSON + serde_json::from_str::(&data) + .map_err(|e| StdError::generic_err(e.to_string()))?; + + let doc = Document { + data, + owner: info.sender.clone(), + created_at: env.block.time, + updated_at: env.block.time, + }; + + let key = (collection.clone(), document_id.clone()); + DOCUMENTS.save(deps.storage, key, &doc)?; + + Ok(Response::new() + .add_attribute("action", "set") + .add_attribute("collection", collection) + .add_attribute("document", document_id) + .add_attribute("owner", info.sender)) +} + +fn execute_update( + deps: DepsMut, + env: Env, + info: MessageInfo, + collection: String, + document_id: String, + data: String, +) -> StdResult { + let key = (collection.clone(), document_id.clone()); + + // Load existing document + let mut doc = DOCUMENTS.load(deps.storage, key.clone())?; + + // Check if user owns document OR has update permission for collection + let admin = ADMIN.load(deps.storage)?; + let owns_document = doc.owner == info.sender; + let is_admin = info.sender == admin; + let has_update_permission = check_permission(deps.as_ref(), &collection, &info.sender, "update")?; + + if !owns_document && !is_admin && !has_update_permission { + return Err(StdError::generic_err("Unauthorized: Must own document or have update permission")); + } + + // Merge JSON data + let existing: serde_json::Value = serde_json::from_str(&doc.data) + .map_err(|e| StdError::generic_err(e.to_string()))?; + let new_data: serde_json::Value = serde_json::from_str(&data) + .map_err(|e| StdError::generic_err(e.to_string()))?; + + let merged = merge_json(existing, new_data); + + doc.data = serde_json::to_string(&merged) + .map_err(|e| StdError::generic_err(e.to_string()))?; + doc.updated_at = env.block.time; + + DOCUMENTS.save(deps.storage, key, &doc)?; + + Ok(Response::new() + .add_attribute("action", "update") + .add_attribute("collection", collection) + .add_attribute("document", document_id)) +} + +fn execute_delete( + deps: DepsMut, + _env: Env, + info: MessageInfo, + collection: String, + document_id: String, +) -> StdResult { + let key = (collection.clone(), document_id.clone()); + + // Check if document exists + let doc = DOCUMENTS.load(deps.storage, key.clone())?; + + // Check if user owns document OR has delete permission for collection + let admin = ADMIN.load(deps.storage)?; + let owns_document = doc.owner == info.sender; + let is_admin = info.sender == admin; + let has_delete_permission = check_permission(deps.as_ref(), &collection, &info.sender, "delete")?; + + if !owns_document && !is_admin && !has_delete_permission { + return Err(StdError::generic_err("Unauthorized: Must own document or have delete permission")); + } + + DOCUMENTS.remove(deps.storage, key); + + Ok(Response::new() + .add_attribute("action", "delete") + .add_attribute("collection", collection) + .add_attribute("document", document_id)) +} + +fn execute_batch_write( + mut deps: DepsMut, + env: Env, + info: MessageInfo, + operations: Vec, +) -> StdResult { + for op in operations { + match op.operation { + WriteType::Set { data } => { + execute_set(deps.branch(), env.clone(), info.clone(), op.collection, op.document, data)?; + } + WriteType::Update { data } => { + execute_update(deps.branch(), env.clone(), info.clone(), op.collection, op.document, data)?; + } + WriteType::Delete => { + execute_delete(deps.branch(), env.clone(), info.clone(), op.collection, op.document)?; + } + } + } + + Ok(Response::new().add_attribute("action", "batch_write")) +} + +// Permission management functions +fn execute_set_permissions( + deps: DepsMut, + _env: Env, + info: MessageInfo, + collection: String, + permissions: CollectionPermissions, +) -> StdResult { + // Only admin can set permissions + let admin = ADMIN.load(deps.storage)?; + if info.sender != admin { + return Err(StdError::generic_err("Only admin can set collection permissions")); + } + + COLLECTION_PERMISSIONS.save(deps.storage, collection.clone(), &permissions)?; + + Ok(Response::new() + .add_attribute("action", "set_permissions") + .add_attribute("collection", collection)) +} + +fn execute_grant_role( + deps: DepsMut, + _env: Env, + info: MessageInfo, + user: String, + role: String, +) -> StdResult { + // Only admin can grant roles + let admin = ADMIN.load(deps.storage)?; + if info.sender != admin { + return Err(StdError::generic_err("Only admin can grant roles")); + } + + let user_addr = deps.api.addr_validate(&user)?; + let mut user_roles = USER_ROLES.may_load(deps.storage, user_addr.clone())?.unwrap_or_default(); + + if !user_roles.contains(&role) { + user_roles.push(role.clone()); + USER_ROLES.save(deps.storage, user_addr, &user_roles)?; + } + + Ok(Response::new() + .add_attribute("action", "grant_role") + .add_attribute("user", user) + .add_attribute("role", role)) +} + +fn execute_revoke_role( + deps: DepsMut, + _env: Env, + info: MessageInfo, + user: String, + role: String, +) -> StdResult { + // Only admin can revoke roles + let admin = ADMIN.load(deps.storage)?; + if info.sender != admin { + return Err(StdError::generic_err("Only admin can revoke roles")); + } + + let user_addr = deps.api.addr_validate(&user)?; + let mut user_roles = USER_ROLES.may_load(deps.storage, user_addr.clone())?.unwrap_or_default(); + + user_roles.retain(|r| r != &role); + USER_ROLES.save(deps.storage, user_addr, &user_roles)?; + + Ok(Response::new() + .add_attribute("action", "revoke_role") + .add_attribute("user", user) + .add_attribute("role", role)) +} + +fn execute_transfer_admin( + deps: DepsMut, + _env: Env, + info: MessageInfo, + new_admin: String, +) -> StdResult { + // Only current admin can transfer admin + let admin = ADMIN.load(deps.storage)?; + if info.sender != admin { + return Err(StdError::generic_err("Only admin can transfer admin role")); + } + + let new_admin_addr = deps.api.addr_validate(&new_admin)?; + ADMIN.save(deps.storage, &new_admin_addr)?; + + Ok(Response::new() + .add_attribute("action", "transfer_admin") + .add_attribute("old_admin", admin) + .add_attribute("new_admin", new_admin_addr)) +} + +#[entry_point] +pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::Get { collection, document } => { + query_get(deps, collection, document) + } + QueryMsg::Collection { collection, limit, start_after } => { + query_collection(deps, collection, limit, start_after) + } + QueryMsg::UserDocuments { owner, collection, limit, start_after } => { + query_user_documents(deps, owner, collection, limit, start_after) + } + QueryMsg::GetCollectionPermissions { collection } => { + query_collection_permissions(deps, collection) + } + QueryMsg::GetUserRoles { user } => { + query_user_roles(deps, user) + } + QueryMsg::CheckPermission { collection, user, action } => { + query_check_permission(deps, collection, user, action) + } + } +} + +fn query_get( + deps: Deps, + collection: String, + document_id: String, +) -> StdResult { + let key = (collection, document_id); + let doc = DOCUMENTS.may_load(deps.storage, key)?; + + let response = DocumentResponse { + exists: doc.is_some(), + document: doc, + }; + + to_json_binary(&response) +} + +fn query_collection_permissions( + deps: Deps, + collection: String, +) -> StdResult { + let permissions = COLLECTION_PERMISSIONS.may_load(deps.storage, collection)? + .unwrap_or_default(); + to_json_binary(&permissions) +} + +fn query_user_roles( + deps: Deps, + user: String, +) -> StdResult { + let user_addr = deps.api.addr_validate(&user)?; + let roles = USER_ROLES.may_load(deps.storage, user_addr)? + .unwrap_or_default(); + to_json_binary(&roles) +} + +fn query_check_permission( + deps: Deps, + collection: String, + user: String, + action: String, +) -> StdResult { + let user_addr = deps.api.addr_validate(&user)?; + let has_permission = check_permission(deps, &collection, &user_addr, &action)?; + to_json_binary(&has_permission) +} + +// Permission checking helper function +fn check_permission( + deps: Deps, + collection: &str, + user: &Addr, + action: &str, +) -> StdResult { + // Admin always has permission + let admin = ADMIN.load(deps.storage)?; + if user == &admin { + return Ok(true); + } + + // Get collection permissions (use defaults if not set) + let permissions = COLLECTION_PERMISSIONS.may_load(deps.storage, collection.to_string())? + .unwrap_or_default(); + + let permission_level = match action { + "create" => &permissions.create, + "update" => &permissions.update, + "delete" => &permissions.delete, + "read" => &permissions.read, + _ => return Ok(false), // Unknown action + }; + + match permission_level { + PermissionLevel::Anyone => Ok(true), + PermissionLevel::AdminOnly => Ok(user == &admin), + PermissionLevel::AllowList(allowed_users) => Ok(allowed_users.contains(&user.to_string())), + PermissionLevel::DenyList(denied_users) => Ok(!denied_users.contains(&user.to_string())), + PermissionLevel::RequireRole(required_role) => { + let user_roles = USER_ROLES.may_load(deps.storage, user.clone())? + .unwrap_or_default(); + Ok(user_roles.contains(required_role)) + } + } +} + +fn query_collection( + deps: Deps, + collection: String, + limit: Option, + start_after: Option, +) -> StdResult { + let limit = limit.unwrap_or(30) as usize; + + let start = start_after.as_ref().map(|s| Bound::exclusive((collection.clone(), s.clone()))); + let end = Bound::exclusive((format!("{}~", collection), String::new())); + + let documents: Vec<(String, Document)> = DOCUMENTS + .range(deps.storage, start, Some(end), Order::Ascending) + .take(limit) + .map(|item| { + let ((_, doc_id), doc) = item?; + Ok((doc_id, doc)) + }) + .collect::>>()?; + + let next_start_after = if documents.len() == limit { + documents.last().map(|(id, _)| id.clone()) + } else { + None + }; + + let response = CollectionResponse { + documents, + next_start_after, + }; + + to_json_binary(&response) +} + +fn query_user_documents( + deps: Deps, + owner: String, + collection: Option, + limit: Option, + start_after: Option, +) -> StdResult { + let owner_addr = deps.api.addr_validate(&owner)?; + let limit = limit.unwrap_or(30) as usize; + + let start = if let (Some(coll), Some(s)) = (collection.clone(), start_after.clone()) { + Some(Bound::exclusive((coll, s))) + } else { + None + }; + + let documents: Vec<(String, Document)> = DOCUMENTS + .idx + .owner + .prefix(owner_addr) + .range(deps.storage, start, None, Order::Ascending) + .filter_map(|item| { + let (key, doc) = item.ok()?; + let (coll, doc_id) = key; + + // Filter by collection if specified + if let Some(ref filter_collection) = collection { + if &coll != filter_collection { + return None; + } + } + + Some((doc_id, doc)) + }) + .take(limit) + .collect(); + + let next_start_after = if documents.len() == limit { + documents.last().map(|(id, _)| id.clone()) + } else { + None + }; + + let response = CollectionResponse { + documents, + next_start_after, + }; + + to_json_binary(&response) +} + +// Helper function to merge JSON objects +fn merge_json(mut existing: serde_json::Value, new: serde_json::Value) -> serde_json::Value { + if let (serde_json::Value::Object(ref mut existing_map), serde_json::Value::Object(new_map)) = (&mut existing, &new) { + for (key, value) in new_map { + existing_map.insert(key.clone(), value.clone()); + } + } + existing +} + +// ============================================================================ +// USAGE EXAMPLES +// ============================================================================ + +/* +// PERMISSION SYSTEM USAGE EXAMPLES: + +// 1. Admin sets up permissions for a "premium_content" collection +await contract.execute({ + set_collection_permissions: { + collection: "premium_content", + permissions: { + create: { "require_role": "creator" }, // Only creators can add content + update: "anyone", // Content owners can update (default behavior) + delete: { "allow_list": ["xion1admin...", "xion1moderator..."] }, // Only specific users + read: { "require_role": "premium_subscriber" } // Only premium subscribers can read + } + } +}); + +// 2. Admin grants roles to users +await contract.execute({ + grant_role: { + user: "xion1alice...", + role: "creator" + } +}); + +await contract.execute({ + grant_role: { + user: "xion1bob...", + role: "premium_subscriber" + } +}); + +// 3. Alice (creator) can now create premium content +await contract.execute({ + set: { + collection: "premium_content", + document: "advanced_tutorial", + data: JSON.stringify({ + title: "Advanced Web3 Development", + content: "This is premium content...", + price: 50 + }) + } +}); // Works - Alice has "creator" role + +// 4. Bob (subscriber) can read but not create +const content = await contract.query({ + get: { + collection: "premium_content", + document: "advanced_tutorial" + } +}); // Works - Bob has "premium_subscriber" role + +await contract.execute({ + set: { + collection: "premium_content", + document: "my_content", + data: JSON.stringify({ title: "My Tutorial" }) + } +}); // Fails - Bob doesn't have "creator" role + +// 5. Different permission models for different collections: + +// Public forum - anyone can post +await contract.execute({ + set_collection_permissions: { + collection: "forum_posts", + permissions: { + create: "anyone", + update: "anyone", // Users can edit their own posts + delete: { "require_role": "moderator" }, // Only moderators can delete + read: "anyone" + } + } +}); + +// Admin announcements - admin only +await contract.execute({ + set_collection_permissions: { + collection: "announcements", + permissions: { + create: "admin_only", + update: "admin_only", + delete: "admin_only", + read: "anyone" + } + } +}); + +// Private messages - restricted access +await contract.execute({ + set_collection_permissions: { + collection: "private_messages", + permissions: { + create: "anyone", + update: "anyone", // Users can edit their own messages + delete: "anyone", // Users can delete their own messages + read: { "deny_list": ["xion1banned_user..."] } // Everyone except banned users + } + } +}); + +// 6. Query permission status +const canCreate = await contract.query({ + check_permission: { + collection: "premium_content", + user: "xion1alice...", + action: "create" + } +}); // Returns: true (Alice has creator role) + +const userRoles = await contract.query({ + get_user_roles: { + user: "xion1alice..." + } +}); // Returns: ["creator"] + +const collectionPerms = await contract.query({ + get_collection_permissions: { + collection: "premium_content" + } +}); // Returns the full permission structure +*/ \ No newline at end of file diff --git a/contracts/docustore/src/error.rs b/contracts/docustore/src/error.rs new file mode 100644 index 00000000..4a69d8ff --- /dev/null +++ b/contracts/docustore/src/error.rs @@ -0,0 +1,13 @@ +use cosmwasm_std::StdError; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + + #[error("Unauthorized")] + Unauthorized {}, + // Add any other custom errors you like here. + // Look at https://docs.rs/thiserror/1.0.21/thiserror/ for details. +} diff --git a/contracts/docustore/src/lib.rs b/contracts/docustore/src/lib.rs new file mode 100644 index 00000000..dfedc9dc --- /dev/null +++ b/contracts/docustore/src/lib.rs @@ -0,0 +1,6 @@ +pub mod contract; +mod error; +pub mod msg; +pub mod state; + +pub use crate::error::ContractError; diff --git a/contracts/docustore/src/msg.rs b/contracts/docustore/src/msg.rs new file mode 100644 index 00000000..dad28f5e --- /dev/null +++ b/contracts/docustore/src/msg.rs @@ -0,0 +1,110 @@ +use cosmwasm_std::Addr; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use crate::state::Document; +use crate::state::CollectionPermissions; + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct InstantiateMsg { + pub admin: String, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub enum ExecuteMsg { + // Firebase-style operations + Set { + collection: String, + document: String, + data: String, // JSON string + }, + Update { + collection: String, + document: String, + data: String, // Merge with existing data + }, + Delete { + collection: String, + document: String, + }, + // Batch operations + BatchWrite { + operations: Vec, + }, + // Admin permission management + SetCollectionPermissions { + collection: String, + permissions: CollectionPermissions, + }, + GrantRole { + user: String, + role: String, + }, + RevokeRole { + user: String, + role: String, + }, + TransferAdmin { + new_admin: String, + }, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct WriteOperation { + pub collection: String, + pub document: String, + pub operation: WriteType, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub enum WriteType { + Set { data: String }, + Update { data: String }, + Delete, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub enum QueryMsg { + // Get single document + Get { + collection: String, + document: String, + }, + // List documents in collection + Collection { + collection: String, + limit: Option, + start_after: Option, + }, + // List documents by owner + UserDocuments { + owner: String, + collection: Option, + limit: Option, + start_after: Option, + }, + // Permission queries + GetCollectionPermissions { + collection: String, + }, + GetUserRoles { + user: String, + }, + CheckPermission { + collection: String, + user: String, + action: String, // "create", "update", "delete", "read" + }, +} + +// Response types +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct DocumentResponse { + pub exists: bool, + pub document: Option, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct CollectionResponse { + pub documents: Vec<(String, Document)>, // (doc_id, document) + pub next_start_after: Option, +} \ No newline at end of file diff --git a/contracts/docustore/src/state.rs b/contracts/docustore/src/state.rs new file mode 100644 index 00000000..e5ec92f2 --- /dev/null +++ b/contracts/docustore/src/state.rs @@ -0,0 +1,91 @@ +use cosmwasm_std::{Addr, Timestamp}; +use cw_storage_plus::{Item, Map, MultiIndex, IndexList, IndexedMap, Index}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +// Document structure - simple JSON storage +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct Document { + pub data: String, // JSON string - flexible like Firebase + pub owner: Addr, + pub created_at: Timestamp, + pub updated_at: Timestamp, +} + +// Collection path: /collection/document_id +// Storage key: (collection_name, document_id) +pub type DocumentKey = (String, String); + +// Indexes for efficient queries +pub struct DocumentIndexes<'a> { + pub collection: MultiIndex<'a, String, Document, DocumentKey>, + pub owner: MultiIndex<'a, Addr, Document, DocumentKey>, + pub created_at: MultiIndex<'a, u64, Document, DocumentKey>, +} + +impl<'a> IndexList for DocumentIndexes<'a> { + fn get_indexes(&'_ self) -> Box> + '_> { + let v: Vec<&dyn Index> = vec![&self.collection, &self.owner, &self.created_at]; + Box::new(v.into_iter()) + } +} + +// Main storage: Map<(collection, doc_id), Document> +pub const DOCUMENTS: IndexedMap = IndexedMap::new( + "documents", + DocumentIndexes { + collection: MultiIndex::new( + |_pk: &[u8], d: &Document| d.owner.to_string(), + "documents", + "documents__collection" + ), + owner: MultiIndex::new( + |_pk: &[u8], d: &Document| d.owner.clone(), + "documents", + "documents__owner" + ), + created_at: MultiIndex::new( + |_pk: &[u8], d: &Document| d.created_at.seconds(), + "documents", + "documents__created" + ), + }, +); + +// Contract admin +pub const ADMIN: Item = Item::new("admin"); + +// Permission system +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub struct CollectionPermissions { + pub create: PermissionLevel, + pub update: PermissionLevel, + pub delete: PermissionLevel, + pub read: PermissionLevel, +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)] +pub enum PermissionLevel { + Anyone, // Any user can perform this action + AdminOnly, // Only admin can perform this action + AllowList(Vec), // Only specific users in the list + DenyList(Vec), // Anyone except users in the list + RequireRole(String), // User must have specific role +} + +impl Default for CollectionPermissions { + fn default() -> Self { + Self { + create: PermissionLevel::Anyone, + update: PermissionLevel::Anyone, // Users can update their own docs + delete: PermissionLevel::Anyone, // Users can delete their own docs + read: PermissionLevel::Anyone, + } + } +} + +// Collection-specific permissions: Map +pub const COLLECTION_PERMISSIONS: Map = Map::new("collection_perms"); + +// User roles system +pub const USER_ROLES: Map> = Map::new("user_roles"); diff --git a/contracts/docustore/test1.txt b/contracts/docustore/test1.txt new file mode 100644 index 00000000..dde6722f Binary files /dev/null and b/contracts/docustore/test1.txt differ diff --git a/contracts/docustore/xiond_commands.sh b/contracts/docustore/xiond_commands.sh new file mode 100644 index 00000000..0e61eead --- /dev/null +++ b/contracts/docustore/xiond_commands.sh @@ -0,0 +1,64 @@ +#!/bin/bash +# Xiond contract interaction commands +# Contract address: xion1svpts9q2ml4ahgc4tuu95w8cqzv988s6mf5mupt5kt56gvdnklks9hzar4 +# Node: https://rpc.xion-testnet-2.burnt.com:443 +# Chain ID: xion-testnet-2 +# Broadcast mode: sync + +CONTRACT=xion1svpts9q2ml4ahgc4tuu95w8cqzv988s6mf5mupt5kt56gvdnklks9hzar4 +NODE=https://rpc.xion-testnet-2.burnt.com:443 +CHAIN_ID=xion-testnet-2 +KEY= +USER= + +# Set Document +xiond tx wasm execute $CONTRACT '{"Set":{"collection":"mycol","document":"doc1","data":"{\"foo\":\"bar\"}"}}' \ + --from $KEY --gas auto --gas-adjustment 1.3 --gas-prices 0.025uxion --broadcast-mode sync --chain-id $CHAIN_ID --node $NODE + +# Update Document +xiond tx wasm execute $CONTRACT '{"Update":{"collection":"mycol","document":"doc1","data":"{\"foo\":\"baz\"}"}}' \ + --from $KEY --gas auto --gas-adjustment 1.3 --gas-prices 0.025uxion --broadcast-mode sync --chain-id $CHAIN_ID --node $NODE + +# Delete Document +xiond tx wasm execute $CONTRACT '{"Delete":{"collection":"mycol","document":"doc1"}}' \ + --from $KEY --gas auto --gas-adjustment 1.3 --gas-prices 0.025uxion --broadcast-mode sync --chain-id $CHAIN_ID --node $NODE + +# Set Collection Permissions +xiond tx wasm execute $CONTRACT '{"SetCollectionPermissions":{"collection":"mycol","permissions":{"create":{"Anyone":{}},"update":{"Anyone":{}},"delete":{"AdminOnly":{}},"read":{"Anyone":{}}}}}' \ + --from $KEY --gas auto --gas-adjustment 1.3 --gas-prices 0.025uxion --broadcast-mode sync --chain-id $CHAIN_ID --node $NODE + +# Grant Role +xiond tx wasm execute $CONTRACT '{"GrantRole":{"user":"'$USER'","role":"editor"}}' \ + --from $KEY --gas auto --gas-adjustment 1.3 --gas-prices 0.025uxion --broadcast-mode sync --chain-id $CHAIN_ID --node $NODE + +# Revoke Role +xiond tx wasm execute $CONTRACT '{"RevokeRole":{"user":"'$USER'","role":"editor"}}' \ + --from $KEY --gas auto --gas-adjustment 1.3 --gas-prices 0.025uxion --broadcast-mode sync --chain-id $CHAIN_ID --node $NODE + +# Transfer Admin +xiond tx wasm execute $CONTRACT '{"TransferAdmin":{"new_admin":"'$USER'"}}' \ + --from $KEY --gas auto --gas-adjustment 1.3 --gas-prices 0.025uxion --broadcast-mode sync --chain-id $CHAIN_ID --node $NODE + +# Batch Write +xiond tx wasm execute $CONTRACT '{"BatchWrite":{"operations":[{"collection":"mycol","document":"doc2","operation":{"Set":{"data":"{\"foo\":\"bar2\"}"}}}]}}' \ + --from $KEY --gas auto --gas-adjustment 1.3 --gas-prices 0.025uxion --broadcast-mode sync --chain-id $CHAIN_ID --node $NODE + +# Queries + +# Get Document +xiond query wasm contract-state smart $CONTRACT '{"Get":{"collection":"mycol","document":"doc1"}}' --node $NODE + +# List Collection +xiond query wasm contract-state smart $CONTRACT '{"Collection":{"collection":"mycol","limit":10}}' --node $NODE + +# User Documents +xiond query wasm contract-state smart $CONTRACT '{"UserDocuments":{"owner":"'$USER'","collection":"mycol","limit":10}}' --node $NODE + +# Get Collection Permissions +xiond query wasm contract-state smart $CONTRACT '{"GetCollectionPermissions":{"collection":"mycol"}}' --node $NODE + +# Get User Roles +xiond query wasm contract-state smart $CONTRACT '{"GetUserRoles":{"user":"'$USER'"}}' --node $NODE + +# Check Permission +xiond query wasm contract-state smart $CONTRACT '{"CheckPermission":{"collection":"mycol","user":"'$USER'","action":"create"}}' --node $NODE \ No newline at end of file