diff --git a/README.md b/README.md index 736b9a82..1346794d 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ Athena Consulting has been awarded a grant by [Atom Accelerator DAO](https://www - [TimeLock Contract](https://github.com/athena-consulting/cosmwasm-by-example/tree/main/timelock) - [Crowdfunding Contract](https://github.com/athena-consulting/cosmwasm-by-example/tree/main/crowdfunding) - [Token Vault](https://github.com/athena-consulting/cosmwasm-by-example/tree/main/token-vault) +- [Hash Notary](https://github.com/athena-consulting/cosmwasm-by-example/tree/main/hash-notary) ### :three: Complex Applications - [Constant Product AMM](https://github.com/athena-consulting/cosmwasm-by-example/tree/main/constant-product-amm) diff --git a/hash-notary/Cargo.toml b/hash-notary/Cargo.toml new file mode 100644 index 00000000..a7bf5788 --- /dev/null +++ b/hash-notary/Cargo.toml @@ -0,0 +1,48 @@ +[package] +name = "hash-notary" +version = "0.1.0" +authors = ["nunezve"] +edition = "2021" + +exclude = [ + "contract.wasm", + "hash.txt", +] + +[lib] +crate-type = ["cdylib", "rlib"] + +[profile.release] +opt-level = 3 +debug = false +rpath = false +lto = true +debug-assertions = false +codegen-units = 1 +panic = 'abort' +incremental = false +overflow-checks = true + +[features] +backtraces = ["cosmwasm-std/backtraces"] +library = [] + +[package.metadata.scripts] +optimize = """docker run --rm -v "$(pwd)":/code \ + --mount type=volume,source="$(basename "$(pwd)")_cache",target=/code/target \ + --mount type=volume,source=registry_cache,target=/usr/local/cargo/registry \ + cosmwasm/rust-optimizer:0.12.10 +""" + +[dependencies] +cosmwasm-schema = "1.1.3" +cosmwasm-std = "1.1.3" +cosmwasm-storage = "1.1.3" +cw-storage-plus = "1.0.1" +cw2 = "1.0.1" +schemars = "0.8.10" +serde = { version = "1.0.145", default-features = false, features = ["derive"] } +thiserror = "1.0.31" + +[dev-dependencies] +cw-multi-test = "0.16.2" diff --git a/hash-notary/README.md b/hash-notary/README.md new file mode 100644 index 00000000..567afa95 --- /dev/null +++ b/hash-notary/README.md @@ -0,0 +1,92 @@ +# Hash Notary + +Hash Notary is a simple CosmWasm contract for timestamping document or artifact hashes without storing private file contents on chain. + +Users register a SHA-256 digest with a short label and optional memo. The contract stores the normalized digest, the sender, the block timestamp, and an active flag. The original proof remains queryable even if the owner later revokes it. + +## Messages + +### Instantiate + +```json +{} +``` + +### Register + +```json +{ + "register": { + "digest": "b94d27b9934d3e08a52e52d7da7dabfade035a3e5094b9f9f1795f7c4f3819d0", + "label": "service-agreement.pdf", + "memo": "Signed PDF hash" + } +} +``` + +### Update Memo + +```json +{ + "update_memo": { + "id": 1, + "memo": "Updated internal reference" + } +} +``` + +### Revoke + +```json +{ + "revoke": { + "id": 1 + } +} +``` + +## Queries + +### Get Record + +```json +{ + "get_record": { + "id": 1 + } +} +``` + +### Get Record By Digest + +```json +{ + "get_record_by_digest": { + "digest": "0xb94d27b9934d3e08a52e52d7da7dabfade035a3e5094b9f9f1795f7c4f3819d0" + } +} +``` + +### List Records + +```json +{ + "list_records": { + "start_after": 10, + "limit": 20 + } +} +``` + +## Validation + +- Digests must be SHA-256 hex strings, with or without a `0x` prefix. +- Labels must be non-empty and at most 80 characters. +- Memos are optional and at most 240 characters. +- Only the record owner can update a memo or revoke the record. + +## Tests + +```bash +cargo test --manifest-path hash-notary/Cargo.toml +``` diff --git a/hash-notary/src/bin/schema.rs b/hash-notary/src/bin/schema.rs new file mode 100644 index 00000000..281092c2 --- /dev/null +++ b/hash-notary/src/bin/schema.rs @@ -0,0 +1,17 @@ +use std::env::current_dir; +use std::fs::create_dir_all; + +use cosmwasm_schema::{export_schema, remove_schemas, schema_for}; + +use hash_notary::msg::{ExecuteMsg, InstantiateMsg, QueryMsg}; + +fn main() { + let mut out_dir = current_dir().unwrap(); + out_dir.push("schema"); + create_dir_all(&out_dir).unwrap(); + remove_schemas(&out_dir).unwrap(); + + export_schema(&schema_for!(InstantiateMsg), &out_dir); + export_schema(&schema_for!(ExecuteMsg), &out_dir); + export_schema(&schema_for!(QueryMsg), &out_dir); +} diff --git a/hash-notary/src/contract.rs b/hash-notary/src/contract.rs new file mode 100644 index 00000000..a1ada1af --- /dev/null +++ b/hash-notary/src/contract.rs @@ -0,0 +1,461 @@ +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{ + to_json_binary, Binary, Deps, DepsMut, Env, MessageInfo, Order, Response, StdResult, +}; +use cw2::set_contract_version; +use cw_storage_plus::Bound; + +use crate::error::ContractError; +use crate::msg::{ + ExecuteMsg, InstantiateMsg, ListRecordsResponse, QueryMsg, RecordResponse, StateResponse, +}; +use crate::state::{Config, Record, CONFIG, DIGEST_INDEX, RECORDS, RECORD_SEQ}; + +const CONTRACT_NAME: &str = "crates.io:hash-notary"; +const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); +const DIGEST_HEX_LEN: usize = 64; +const LABEL_MAX_LEN: usize = 80; +const MEMO_MAX_LEN: usize = 240; +const DEFAULT_LIMIT: u32 = 20; +const MAX_LIMIT: u32 = 50; + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + deps: DepsMut, + _env: Env, + info: MessageInfo, + _msg: InstantiateMsg, +) -> Result { + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + CONFIG.save( + deps.storage, + &Config { + owner: info.sender.clone(), + total_records: 0, + }, + )?; + RECORD_SEQ.save(deps.storage, &0)?; + + Ok(Response::new() + .add_attribute("method", "instantiate") + .add_attribute("owner", info.sender)) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + match msg { + ExecuteMsg::Register { + digest, + label, + memo, + } => execute_register(deps, env, info, digest, label, memo), + ExecuteMsg::UpdateMemo { id, memo } => execute_update_memo(deps, info, id, memo), + ExecuteMsg::Revoke { id } => execute_revoke(deps, info, id), + } +} + +pub fn execute_register( + deps: DepsMut, + env: Env, + info: MessageInfo, + digest: String, + label: String, + memo: Option, +) -> Result { + let digest = normalize_digest(&digest)?; + if DIGEST_INDEX.may_load(deps.storage, &digest)?.is_some() { + return Err(ContractError::DuplicateDigest {}); + } + + let label = normalize_label(&label)?; + let memo = normalize_memo(memo)?; + let id = RECORD_SEQ.update(deps.storage, |id| -> StdResult<_> { Ok(id + 1) })?; + + let record = Record { + id, + digest: digest.clone(), + owner: info.sender.clone(), + created_at: env.block.time.seconds(), + label, + memo, + active: true, + }; + + RECORDS.save(deps.storage, id, &record)?; + DIGEST_INDEX.save(deps.storage, &digest, &id)?; + CONFIG.update(deps.storage, |mut config| -> StdResult<_> { + config.total_records += 1; + Ok(config) + })?; + + Ok(Response::new() + .add_attribute("action", "register") + .add_attribute("id", id.to_string()) + .add_attribute("digest", digest) + .add_attribute("owner", info.sender)) +} + +pub fn execute_update_memo( + deps: DepsMut, + info: MessageInfo, + id: u64, + memo: Option, +) -> Result { + let memo = normalize_memo(memo)?; + RECORDS.update(deps.storage, id, |record| -> Result<_, ContractError> { + let mut record = record.ok_or(ContractError::RecordNotFound {})?; + if record.owner != info.sender { + return Err(ContractError::Unauthorized {}); + } + record.memo = memo; + Ok(record) + })?; + + Ok(Response::new() + .add_attribute("action", "update_memo") + .add_attribute("id", id.to_string())) +} + +pub fn execute_revoke( + deps: DepsMut, + info: MessageInfo, + id: u64, +) -> Result { + RECORDS.update(deps.storage, id, |record| -> Result<_, ContractError> { + let mut record = record.ok_or(ContractError::RecordNotFound {})?; + if record.owner != info.sender { + return Err(ContractError::Unauthorized {}); + } + record.active = false; + Ok(record) + })?; + + Ok(Response::new() + .add_attribute("action", "revoke") + .add_attribute("id", id.to_string())) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { + match msg { + QueryMsg::GetState {} => to_json_binary(&query_state(deps)?), + QueryMsg::GetRecord { id } => to_json_binary(&query_record(deps, id)?), + QueryMsg::GetRecordByDigest { digest } => { + to_json_binary(&query_record_by_digest(deps, digest)?) + } + QueryMsg::ListRecords { start_after, limit } => { + to_json_binary(&query_records(deps, start_after, limit)?) + } + } +} + +pub fn query_state(deps: Deps) -> StdResult { + let config = CONFIG.load(deps.storage)?; + Ok(StateResponse { + owner: config.owner, + total_records: config.total_records, + }) +} + +pub fn query_record(deps: Deps, id: u64) -> StdResult { + let record = RECORDS.load(deps.storage, id)?; + Ok(record.into()) +} + +pub fn query_record_by_digest(deps: Deps, digest: String) -> StdResult { + let digest = normalize_digest(&digest) + .map_err(|err| cosmwasm_std::StdError::generic_err(err.to_string()))?; + let id = DIGEST_INDEX.load(deps.storage, &digest)?; + query_record(deps, id) +} + +pub fn query_records( + deps: Deps, + start_after: Option, + limit: Option, +) -> StdResult { + let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT) as usize; + let start = start_after.map(Bound::exclusive); + let records = RECORDS + .range(deps.storage, start, None, Order::Ascending) + .take(limit) + .map(|item| item.map(|(_, record)| record.into())) + .collect::>>()?; + + Ok(ListRecordsResponse { records }) +} + +fn normalize_digest(digest: &str) -> Result { + let trimmed = digest.trim(); + let without_prefix = trimmed + .strip_prefix("0x") + .or_else(|| trimmed.strip_prefix("0X")) + .unwrap_or(trimmed); + let normalized = without_prefix.to_ascii_lowercase(); + + if normalized.len() != DIGEST_HEX_LEN + || !normalized.chars().all(|char| char.is_ascii_hexdigit()) + { + return Err(ContractError::InvalidDigest {}); + } + + Ok(normalized) +} + +fn normalize_label(label: &str) -> Result { + let label = label.trim(); + if label.is_empty() || label.len() > LABEL_MAX_LEN { + return Err(ContractError::InvalidLabel {}); + } + Ok(label.to_string()) +} + +fn normalize_memo(memo: Option) -> Result, ContractError> { + match memo { + None => Ok(None), + Some(memo) => { + let memo = memo.trim(); + if memo.is_empty() { + return Ok(None); + } + if memo.len() > MEMO_MAX_LEN { + return Err(ContractError::InvalidMemo {}); + } + Ok(Some(memo.to_string())) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use cosmwasm_std::testing::{mock_dependencies, mock_env, mock_info}; + use cosmwasm_std::{from_json, Attribute}; + + const DIGEST_A: &str = "b94d27b9934d3e08a52e52d7da7dabfade035a3e5094b9f9f1795f7c4f3819d0"; + const DIGEST_B: &str = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"; + + fn init() -> cosmwasm_std::OwnedDeps< + cosmwasm_std::MemoryStorage, + cosmwasm_std::testing::MockApi, + cosmwasm_std::testing::MockQuerier, + > { + let mut deps = mock_dependencies(); + let info = mock_info("creator", &[]); + instantiate(deps.as_mut(), mock_env(), info, InstantiateMsg {}).unwrap(); + deps + } + + #[test] + fn registers_and_queries_record() { + let mut deps = init(); + let info = mock_info("alice", &[]); + let msg = ExecuteMsg::Register { + digest: format!("0x{}", DIGEST_A.to_uppercase()), + label: " service-agreement.pdf ".to_string(), + memo: Some(" Signed PDF hash ".to_string()), + }; + + let res = execute(deps.as_mut(), mock_env(), info, msg).unwrap(); + assert_eq!( + res.attributes, + vec![ + Attribute::new("action", "register"), + Attribute::new("id", "1"), + Attribute::new("digest", DIGEST_A), + Attribute::new("owner", "alice"), + ] + ); + + let record: RecordResponse = from_json( + &query( + deps.as_ref(), + mock_env(), + QueryMsg::GetRecordByDigest { + digest: DIGEST_A.to_string(), + }, + ) + .unwrap(), + ) + .unwrap(); + + assert_eq!(record.id, 1); + assert_eq!(record.digest, DIGEST_A); + assert_eq!(record.owner, "alice"); + assert_eq!(record.label, "service-agreement.pdf"); + assert_eq!(record.memo, Some("Signed PDF hash".to_string())); + assert!(record.active); + } + + #[test] + fn rejects_duplicate_digest() { + let mut deps = init(); + let msg = ExecuteMsg::Register { + digest: DIGEST_A.to_string(), + label: "first".to_string(), + memo: None, + }; + execute(deps.as_mut(), mock_env(), mock_info("alice", &[]), msg).unwrap(); + + let duplicate = ExecuteMsg::Register { + digest: DIGEST_A.to_uppercase(), + label: "duplicate".to_string(), + memo: None, + }; + let err = execute(deps.as_mut(), mock_env(), mock_info("bob", &[]), duplicate).unwrap_err(); + + match err { + ContractError::DuplicateDigest {} => {} + _ => panic!("expected duplicate digest error"), + } + } + + #[test] + fn only_owner_updates_and_revokes() { + let mut deps = init(); + let msg = ExecuteMsg::Register { + digest: DIGEST_A.to_string(), + label: "artifact".to_string(), + memo: None, + }; + execute(deps.as_mut(), mock_env(), mock_info("alice", &[]), msg).unwrap(); + + let err = execute( + deps.as_mut(), + mock_env(), + mock_info("bob", &[]), + ExecuteMsg::UpdateMemo { + id: 1, + memo: Some("not yours".to_string()), + }, + ) + .unwrap_err(); + match err { + ContractError::Unauthorized {} => {} + _ => panic!("expected unauthorized error"), + } + + execute( + deps.as_mut(), + mock_env(), + mock_info("alice", &[]), + ExecuteMsg::UpdateMemo { + id: 1, + memo: Some("new note".to_string()), + }, + ) + .unwrap(); + execute( + deps.as_mut(), + mock_env(), + mock_info("alice", &[]), + ExecuteMsg::Revoke { id: 1 }, + ) + .unwrap(); + + let record: RecordResponse = + from_json(&query(deps.as_ref(), mock_env(), QueryMsg::GetRecord { id: 1 }).unwrap()) + .unwrap(); + assert_eq!(record.memo, Some("new note".to_string())); + assert!(!record.active); + } + + #[test] + fn validates_input() { + let mut deps = init(); + let invalid_digest = ExecuteMsg::Register { + digest: "abc".to_string(), + label: "artifact".to_string(), + memo: None, + }; + let err = execute( + deps.as_mut(), + mock_env(), + mock_info("alice", &[]), + invalid_digest, + ) + .unwrap_err(); + match err { + ContractError::InvalidDigest {} => {} + _ => panic!("expected invalid digest error"), + } + + let empty_label = ExecuteMsg::Register { + digest: DIGEST_A.to_string(), + label: " ".to_string(), + memo: None, + }; + let err = execute( + deps.as_mut(), + mock_env(), + mock_info("alice", &[]), + empty_label, + ) + .unwrap_err(); + match err { + ContractError::InvalidLabel {} => {} + _ => panic!("expected invalid label error"), + } + + let long_memo = ExecuteMsg::Register { + digest: DIGEST_A.to_string(), + label: "artifact".to_string(), + memo: Some("x".repeat(MEMO_MAX_LEN + 1)), + }; + let err = execute( + deps.as_mut(), + mock_env(), + mock_info("alice", &[]), + long_memo, + ) + .unwrap_err(); + match err { + ContractError::InvalidMemo {} => {} + _ => panic!("expected invalid memo error"), + } + } + + #[test] + fn lists_records_with_pagination() { + let mut deps = init(); + for (digest, label) in [(DIGEST_A, "first"), (DIGEST_B, "second")] { + execute( + deps.as_mut(), + mock_env(), + mock_info("alice", &[]), + ExecuteMsg::Register { + digest: digest.to_string(), + label: label.to_string(), + memo: None, + }, + ) + .unwrap(); + } + + let list: ListRecordsResponse = from_json( + &query( + deps.as_ref(), + mock_env(), + QueryMsg::ListRecords { + start_after: Some(1), + limit: Some(10), + }, + ) + .unwrap(), + ) + .unwrap(); + + assert_eq!(list.records.len(), 1); + assert_eq!(list.records[0].id, 2); + + let state: StateResponse = + from_json(&query(deps.as_ref(), mock_env(), QueryMsg::GetState {}).unwrap()).unwrap(); + assert_eq!(state.total_records, 2); + } +} diff --git a/hash-notary/src/error.rs b/hash-notary/src/error.rs new file mode 100644 index 00000000..2ec7ce26 --- /dev/null +++ b/hash-notary/src/error.rs @@ -0,0 +1,26 @@ +use cosmwasm_std::StdError; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + + #[error("Unauthorized")] + Unauthorized {}, + + #[error("Record not found")] + RecordNotFound {}, + + #[error("Digest already registered")] + DuplicateDigest {}, + + #[error("Digest must be a SHA-256 hex string")] + InvalidDigest {}, + + #[error("Label must be non-empty and at most 80 characters")] + InvalidLabel {}, + + #[error("Memo must be at most 240 characters")] + InvalidMemo {}, +} diff --git a/hash-notary/src/lib.rs b/hash-notary/src/lib.rs new file mode 100644 index 00000000..dfedc9dc --- /dev/null +++ b/hash-notary/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/hash-notary/src/msg.rs b/hash-notary/src/msg.rs new file mode 100644 index 00000000..a795e27a --- /dev/null +++ b/hash-notary/src/msg.rs @@ -0,0 +1,59 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::Addr; + +#[cw_serde] +pub struct InstantiateMsg {} + +#[cw_serde] +pub enum ExecuteMsg { + Register { + digest: String, + label: String, + memo: Option, + }, + UpdateMemo { + id: u64, + memo: Option, + }, + Revoke { + id: u64, + }, +} + +#[cw_serde] +#[derive(QueryResponses)] +pub enum QueryMsg { + #[returns(StateResponse)] + GetState {}, + #[returns(RecordResponse)] + GetRecord { id: u64 }, + #[returns(RecordResponse)] + GetRecordByDigest { digest: String }, + #[returns(ListRecordsResponse)] + ListRecords { + start_after: Option, + limit: Option, + }, +} + +#[cw_serde] +pub struct StateResponse { + pub owner: Addr, + pub total_records: u64, +} + +#[cw_serde] +pub struct RecordResponse { + pub id: u64, + pub digest: String, + pub owner: Addr, + pub created_at: u64, + pub label: String, + pub memo: Option, + pub active: bool, +} + +#[cw_serde] +pub struct ListRecordsResponse { + pub records: Vec, +} diff --git a/hash-notary/src/state.rs b/hash-notary/src/state.rs new file mode 100644 index 00000000..f17ec751 --- /dev/null +++ b/hash-notary/src/state.rs @@ -0,0 +1,41 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::Addr; +use cw_storage_plus::{Item, Map}; + +use crate::msg::RecordResponse; + +#[cw_serde] +pub struct Config { + pub owner: Addr, + pub total_records: u64, +} + +#[cw_serde] +pub struct Record { + pub id: u64, + pub digest: String, + pub owner: Addr, + pub created_at: u64, + pub label: String, + pub memo: Option, + pub active: bool, +} + +impl From for RecordResponse { + fn from(record: Record) -> Self { + RecordResponse { + id: record.id, + digest: record.digest, + owner: record.owner, + created_at: record.created_at, + label: record.label, + memo: record.memo, + active: record.active, + } + } +} + +pub const CONFIG: Item = Item::new("config"); +pub const RECORD_SEQ: Item = Item::new("record_seq"); +pub const RECORDS: Map = Map::new("records"); +pub const DIGEST_INDEX: Map<&str, u64> = Map::new("digest_index");