Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4,004 changes: 3,428 additions & 576 deletions Cargo.lock

Large diffs are not rendered by default.

7 changes: 4 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
[workspace]
members = [
"contracts/*",
"examples/*",
"scripts/*"
]
resolver = "2"

[profile.release]
opt-level = 3 # Use slightly better optimizations.
Expand All @@ -13,7 +16,6 @@ cosmwasm-schema = "2.2.2"
cosmwasm-std = { version = "2.2.2", features = ["stargate", "cosmwasm_2_1"] }
cw2 = "2.0.0"
cw-storage-plus = "2.0.0"
cw-utils = "2.0.0"
hex = "0.4"
sha2 = { version = "0.10.8", features = ["oid"]}
thiserror = "1"
Expand All @@ -24,9 +26,8 @@ schemars = "0.8.10"
ripemd = "0.1.3"
bech32 = "0.9.1"
base64 = "0.21.4"
phf = { version = "0.11.2", features = ["macros"] }
rsa = { version = "0.9.2" }
getrandom = { version = "0.2.10", features = ["custom"] }
p256 = {version = "0.13.2", features = ["ecdsa-core", "arithmetic", "serde"]}
cosmos-sdk-proto = {package = "xion-cosmos-sdk-proto", version = "0.26.1", default-features = false, features = ["std", "cosmwasm", "xion", "serde"]}
url = "2.5.2"
url = "2.5.2"
25 changes: 25 additions & 0 deletions contracts/opacity_verifier/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
[package]
name = "opacity_verifier"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
crate-type = ["cdylib", "rlib"]

[features]
# enable feature if you want to disable entry points
library = []

[dependencies]
hex = { workspace = true }
cosmwasm-std = { workspace = true }
cosmwasm-schema = { workspace = true }
cw-storage-plus = { workspace = true }
thiserror = { workspace = true }
cw2 = { workspace = true }
getrandom = { workspace = true }
tiny-keccak = { workspace = true }

[dev-dependencies]
cw-orch = "0.28.0"
10 changes: 10 additions & 0 deletions contracts/opacity_verifier/examples/schema.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
use cosmwasm_schema::write_api;
use opacity_verifier::msg::*;

fn main() {
write_api! {
instantiate: InstantiateMsg,
query: QueryMsg,
execute: ExecuteMsg,
};
}
125 changes: 125 additions & 0 deletions contracts/opacity_verifier/src/contract.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
use cosmwasm_std::{to_json_binary, Binary, Deps, DepsMut, Empty, Env, Event, MessageInfo, Response, StdResult};
use crate::error::{ContractError, ContractResult};
use crate::msg::{QueryMsg, ExecuteMsg, InstantiateMsg};
use crate::{query, CONTRACT_NAME, CONTRACT_VERSION};
use crate::state::{ADMIN, VERIFICATION_KEY_ALLOW_LIST};

fn normalize_addr_hex(s: &str) -> Result<String, crate::error::ContractError> {
let s = s.trim_start_matches("0x").to_ascii_lowercase();
if s.len() != 40 {
return Err(crate::error::ContractError::Std(cosmwasm_std::StdError::generic_err(
"address hex must be 40 chars",
)));
}
let _ = hex::decode(&s)?; // ensure valid hex
Ok(s)
}

#[cfg_attr(not(feature = "library"), cosmwasm_std::entry_point)]
pub fn instantiate(
deps: DepsMut,
_env: Env,
_info: MessageInfo,
msg: InstantiateMsg,
) -> ContractResult<Response> {
cw2::set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?;
let admin = deps.api.addr_validate(&msg.admin)?;
ADMIN.save(deps.storage, &admin)?;
for key in msg.allow_list {
let k = normalize_addr_hex(&key)?;
VERIFICATION_KEY_ALLOW_LIST.save(deps.storage, k, &Empty{})?;
}
Ok(Response::new().add_event(Event::new("create_opacity_verifier").add_attributes( vec![
("admin", admin.to_string()),
])))
}


#[cfg_attr(not(feature = "library"), cosmwasm_std::entry_point)]
pub fn execute(
deps: DepsMut,
_env: Env,
info: MessageInfo,
msg: ExecuteMsg,
) -> ContractResult<Response> {
let admin = ADMIN.load(deps.storage)?;
if info.sender != admin {
return Err(ContractError::Unauthorized {});
}
match msg {
ExecuteMsg::UpdateAdmin { admin } => {
let new_admin = deps.api.addr_validate(&admin)?;
ADMIN.save(deps.storage, &new_admin)?;
}
ExecuteMsg::UpdateAllowList { keys } => {
VERIFICATION_KEY_ALLOW_LIST.clear(deps.storage);
for key in keys {
let k = normalize_addr_hex(&key)?;
VERIFICATION_KEY_ALLOW_LIST.save(deps.storage, k, &Empty{})?;
}
}
}
Ok(Response::default())
}

#[cfg_attr(not(feature = "library"), cosmwasm_std::entry_point)]
pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult<Binary> {
match msg {
QueryMsg::Verify { signature, message } => to_json_binary(
&query::verify_query(deps.storage, deps.api, signature, message)?),
QueryMsg::VerificationKeys {} => to_json_binary(&query::verification_keys(deps.storage)?),
QueryMsg::Admin {} => to_json_binary(&query::admin(deps.storage)?),
}
}

#[cfg(test)]
mod tests {
use super::*;
use cosmwasm_std::{Addr};
use cw_orch::interface;
use cw_orch::prelude::*;

#[interface(InstantiateMsg, ExecuteMsg, QueryMsg, Empty)]
pub struct OpacityVerifier;

impl <Chain> Uploadable for OpacityVerifier<Chain> {
fn wrapper() -> Box<dyn MockContract<Empty>> {
Box::new(
ContractWrapper::new_with_empty(
execute,
instantiate,
query,
)
)
}
}

#[test]
fn test_verify_proof() {
// response from https://verifier.opacity.network/api/public-keys
let allowlist_keys_raw = ["322df8c3146a9891c8d63deec562db5f325f7a28","3ac1e280b6b5d8e15cf428229eccb20d9b824a53","5a29af4859ebc29ac0819c178bd293ba7f7bdfcf","9b776cbbd434d7d8f32b8cb369c37442760457b5","90cbfa246fb5bd65192aeaaa41483e311a13f109","ae16d88cd1f4ba016da8909ebc7c9c4a4fb112b8","8a4ca92581fb9b569ef8152c70a031569ee971b5","bdd5b7410abf138da1008906191188f4b5543be7","5d92cf96045bb80d869ee7bfa5d894be4782cfab","7775b5ffbcd55e7fce07672895145c5961ff828f","cf203ffb676fad5c8924ceebe91ebe3e617f01af"];
let allowlist_keys: Vec<String> = allowlist_keys_raw.iter().map(|x| x.to_string()).collect();

let sender = Addr::unchecked("sender");
// Create a new mock chain (backed by cw-multi-test)
let chain = Mock::new(&sender);

let opacity_verifier: OpacityVerifier<Mock> = OpacityVerifier::new("opacity_verifier", chain);
opacity_verifier.upload().unwrap();

let verifier_init_msg = InstantiateMsg {
// Use a valid bech32 address for admin; this matches the mock sender prefix
admin: "cosmwasm1pgm8hyk0pvphmlvfjc8wsvk4daluz5tgrw6pu5mfpemk74uxnx9qlm3aqg".to_string(),
allow_list: allowlist_keys,
};

opacity_verifier.instantiate(&verifier_init_msg, None, &[]).unwrap();

let verifier_query_msg = QueryMsg::Verify {
signature: "0x67054ee2d920f5fe11e9c34dd20257b4bb7e9549a85aef98be9f98c564838ded3d7a84864342eac1d1991abb4becb82a4cf8476d010dfc05ce973566d1fbffe91c".to_string(),
message: r#"{"body":"{\"login\":\"mvid\",\"id\":74642,\"node_id\":\"MDQ6VXNlcjc0NjQy\",\"avatar_url\":\"https://avatars.githubusercontent.com/u/74642?v=4\",\"gravatar_id\":\"\",\"url\":\"https://api.github.com/users/mvid\",\"html_url\":\"https://github.com/mvid\",\"followers_url\":\"https://api.github.com/users/mvid/followers\",\"following_url\":\"https://api.github.com/users/mvid/following{/other_user}\",\"gists_url\":\"https://api.github.com/users/mvid/gists{/gist_id}\",\"starred_url\":\"https://api.github.com/users/mvid/starred{/owner}{/repo}\",\"subscriptions_url\":\"https://api.github.com/users/mvid/subscriptions\",\"organizations_url\":\"https://api.github.com/users/mvid/orgs\",\"repos_url\":\"https://api.github.com/users/mvid/repos\",\"events_url\":\"https://api.github.com/users/mvid/events{/privacy}\",\"received_events_url\":\"https://api.github.com/users/mvid/received_events\",\"type\":\"User\",\"user_view_type\":\"private\",\"site_admin\":false,\"name\":\"Mantas Vidutis\",\"company\":\"Turbines Consulting, LLC\",\"blog\":\"turbines.io\",\"location\":\"San Francisco, CA\",\"email\":\"mantas.a.vidutis@gmail.com\",\"hireable\":true,\"bio\":\"Software Consultant\",\"twitter_username\":null,\"notification_email\":\"mantas.a.vidutis@gmail.com\",\"public_repos\":41,\"public_gists\":4,\"followers\":44,\"following\":60,\"created_at\":\"2009-04-17T02:12:05Z\",\"updated_at\":\"2025-06-21T08:03:51Z\",\"private_gists\":24,\"total_private_repos\":6,\"owned_private_repos\":6,\"disk_usage\":38482,\"collaborators\":1,\"two_factor_authentication\":true,\"plan\":{\"name\":\"pro\",\"space\":976562499,\"collaborators\":0,\"private_repos\":9999}}","cookies":{},"headers":{"access-control-allow-origin":"*","access-control-expose-headers":"ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset","cache-control":"private, max-age=60, s-maxage=60","content-length":"1497","content-security-policy":"default-src 'none'","content-type":"application/json; charset=utf-8","date":"Fri, 27 Jun 2025 17:32:15 GMT","etag":"\"a9d561910da5ada4f578f0e92f6af450dd3df9a449030bd90abbc7e6da9bc7df\"","last-modified":"Sat, 21 Jun 2025 08:03:51 GMT","referrer-policy":"origin-when-cross-origin, strict-origin-when-cross-origin","server":"github.com","strict-transport-security":"max-age=31536000; includeSubdomains; preload","vary":"Accept, Authorization, Cookie, X-GitHub-OTP,Accept-Encoding, Accept, X-Requested-With","x-accepted-oauth-scopes":"","x-content-type-options":"nosniff","x-frame-options":"deny","x-github-api-version-selected":"2022-11-28","x-github-media-type":"github.v3; format=json","x-github-request-id":"793D:54EE8:1DC7644:1E7E84C:685ED59F","x-oauth-client-id":"Ov23liqmohfBdEpL34Ii","x-oauth-scopes":"read:user, user:email","x-ratelimit-limit":"5000","x-ratelimit-remaining":"4999","x-ratelimit-reset":"1751049135","x-ratelimit-resource":"core","x-ratelimit-used":"1","x-xss-protection":"0"},"status":200,"url":"api.github.com/user","url_params":{}}"#.to_string(),
};
let verification_response: bool = opacity_verifier.query(&verifier_query_msg).unwrap();
assert!(verification_response)
}
}
28 changes: 28 additions & 0 deletions contracts/opacity_verifier/src/error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
#[derive(Debug, thiserror::Error)]
pub enum ContractError {
#[error(transparent)]
Std(#[from] cosmwasm_std::StdError),

#[error(transparent)]
HexError(#[from] hex::FromHexError),

#[error(transparent)]
UTF8Error(#[from] std::str::Utf8Error),

#[error(transparent)]
RecoverPubkeyError(#[from] cosmwasm_std::RecoverPubkeyError),

// #[error(transparent)]
// AlloySignatureError(#[from] alloy_primitives::SignatureError),

#[error("only the admin can call this method")]
Unauthorized,

#[error("short signature")]
ShortSignature,

#[error("recovery id can only be one of 0, 1, 27, 28")]
InvalidRecoveryId,
}

pub type ContractResult<T> = Result<T, ContractError>;
56 changes: 56 additions & 0 deletions contracts/opacity_verifier/src/eth_crypto.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
//! An Ethereum signature has a total length of 65 parts, consisting of three
//! parts:
//! - r: 32 bytes
//! - s: 32 bytes
//! - v: 1 byte
//!
//! r and s together are known as the recoverable signature. v is known as the
//! recovery id, which can take the value of one of 0, 1, 27, and 28.
//!
//! In order to verify a signature, we attempt to recover the signer's pubkey.
//! If the recovered key matches the signer's address, we consider the signature
//! valid.
//!
//! The address is the last 20 bytes of the hash keccak256(pubkey_bytes).
//!
//! Before a message is signed, it is prefixed with the bytes: b"\x19Ethereum Signed Message:\n".
//!
//! Adapted from
//! - sig verification:
//! https://github.com/gakonst/ethers-rs/blob/master/ethers-core/src/types/signature.rs
//! - hash:
//! https://github.com/gakonst/ethers-rs/blob/master/ethers-core/src/utils/hash.rs

use tiny_keccak::{Hasher, Keccak};

use crate::error::{ContractError, ContractResult};

pub fn hash_message(msg: &[u8]) -> [u8; 32] {
const PREFIX: &str = "\x19Ethereum Signed Message:\n";

let mut bytes = vec![];
bytes.extend_from_slice(PREFIX.as_bytes());
bytes.extend_from_slice(msg.len().to_string().as_bytes());
bytes.extend_from_slice(msg);

keccak256(&bytes)
}

pub fn keccak256(bytes: &[u8]) -> [u8; 32] {
let mut output = [0u8; 32];

let mut hasher = Keccak::v256();
hasher.update(bytes);
hasher.finalize(&mut output);

output
}

pub fn normalize_recovery_id(id: u8) -> ContractResult<u8> {
match id {
0 | 1 => Ok(id),
27 => Ok(0),
28 => Ok(1),
_ => Err(ContractError::InvalidRecoveryId),
}
}
20 changes: 20 additions & 0 deletions contracts/opacity_verifier/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
pub mod contract;
pub mod msg;
mod query;
mod state;
mod error;
mod eth_crypto;

pub const CONTRACT_NAME: &str = "opacity_verifier";
pub const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION");

// the random function must be disabled in cosmwasm
use core::num::NonZeroU32;
use getrandom::Error;

pub fn always_fail(_buf: &mut [u8]) -> Result<(), Error> {
let code = NonZeroU32::new(Error::CUSTOM_START).unwrap();
Err(Error::from(code))
}
use getrandom::register_custom_getrandom;
register_custom_getrandom!(always_fail);
30 changes: 30 additions & 0 deletions contracts/opacity_verifier/src/msg.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
use cosmwasm_std::Addr;
use cosmwasm_schema::{cw_serde, QueryResponses};

#[cw_serde]
pub struct InstantiateMsg {
pub admin: String,
pub allow_list: Vec<String>,
}

#[cw_serde]
pub enum ExecuteMsg {
UpdateAdmin { admin: String },
UpdateAllowList { keys: Vec<String> },
}

#[cw_serde]
#[derive(QueryResponses)]
pub enum QueryMsg {
#[returns(bool)]
Verify {
signature: String,
message: String,
},

#[returns(Vec<String>)]
VerificationKeys {},

#[returns(Addr)]
Admin {}
}
44 changes: 44 additions & 0 deletions contracts/opacity_verifier/src/query.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
use cosmwasm_std::{Addr, Api, Order, StdError, StdResult, Storage};
use crate::error::{ContractError, ContractResult};
use crate::state::{ADMIN, VERIFICATION_KEY_ALLOW_LIST};

pub fn verify(api: &dyn Api, store: &dyn Storage, signature: String, message: String) -> ContractResult<bool> {
// 1. Get the signature and message from the response
let signature_hex = signature.trim_start_matches("0x");
let sig_bytes = hex::decode(signature_hex)?;
if sig_bytes.len() < 65 {
return Err(ContractError::ShortSignature);
}

// 2. Recover the public key
let msg_hash_bytes = crate::eth_crypto::hash_message(message.as_bytes());
let recoverable_sig = &sig_bytes[..64];
let recovery_id = crate::eth_crypto::normalize_recovery_id(sig_bytes[64])?;

let pk_bytes = api.secp256k1_recover_pubkey(&msg_hash_bytes, recoverable_sig, recovery_id)?;
let hash = crate::eth_crypto::keccak256(&pk_bytes[1..]);
let recovered_addr = &hash[12..];
let recovered_address_lower = hex::encode(recovered_addr);

// 3. Fetch and check against allowlist
let key_found = VERIFICATION_KEY_ALLOW_LIST.has(store, recovered_address_lower);
Ok(key_found)
}

pub fn verify_query(store: &dyn Storage, api: &dyn Api, signature: String, message: String) -> StdResult<bool> {
match verify(api, store, signature, message) {
Ok(b) => Ok(b),
Err(error) => Err(StdError::generic_err(error.to_string())),
}
}

pub fn verification_keys(store: &dyn Storage) -> StdResult<Vec<String>> {
Ok(VERIFICATION_KEY_ALLOW_LIST
.keys(store, None, None, Order::Ascending)
.map(|k| k.unwrap())
.collect())
}

pub fn admin(store: &dyn Storage) -> StdResult<Addr> {
ADMIN.load(store)
}
6 changes: 6 additions & 0 deletions contracts/opacity_verifier/src/state.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
use cosmwasm_std::{Addr, Empty};
use cw_storage_plus::{Item, Map};

pub const VERIFICATION_KEY_ALLOW_LIST: Map<String, Empty> = Map::new("verification_key_allow_list");

pub const ADMIN: Item<Addr> = Item::new("admin");
2 changes: 1 addition & 1 deletion contracts/treasury/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,4 @@ serde = { workspace = true }
serde_json = { workspace = true }
schemars = { workspace = true }
cosmos-sdk-proto = { workspace = true }
url = { workspace = true }
url = { workspace = true }
Loading
Loading