From 88e2de0f9670cd293aefc85683e577e77f4f6264 Mon Sep 17 00:00:00 2001 From: Divine Osehotue Omoiyobe Date: Mon, 1 Jun 2026 16:16:09 +0000 Subject: [PATCH] examples/multisig: add Multi-Signature Wallet example; update seeds error handling --- backend/src/db/seeds.rs | 33 +++++--- backend/src/services/cache.rs | 82 +++++++++++++++++++ backend/src/services/transformers.rs | 62 +++++++++++++++ examples/multisig/Cargo.toml | 13 +++ examples/multisig/src/lib.rs | 115 +++++++++++++++++++++++++++ examples/multisig/src/test.rs | 77 ++++++++++++++++++ 6 files changed, 372 insertions(+), 10 deletions(-) create mode 100644 backend/src/services/cache.rs create mode 100644 backend/src/services/transformers.rs create mode 100644 examples/multisig/Cargo.toml create mode 100644 examples/multisig/src/lib.rs create mode 100644 examples/multisig/src/test.rs diff --git a/backend/src/db/seeds.rs b/backend/src/db/seeds.rs index a10cd7a..9603763 100644 --- a/backend/src/db/seeds.rs +++ b/backend/src/db/seeds.rs @@ -42,6 +42,9 @@ pub enum SeedError { /// Human-readable reason. reason: String, }, + /// Multiple seed errors collected while running multiple steps. + #[error("Multiple seed errors: {0:?}")] + Multiple(Vec), } // --------------------------------------------------------------------------- @@ -210,21 +213,31 @@ pub async fn seed_feature_flags(pool: &PgPool) -> Result { /// Returns the first [`SeedError`] encountered, if any. pub async fn run_all(pool: &PgPool) -> Result<(), SeedError> { info!("Starting database seed"); + // Run each step and collect errors so one failing step doesn't abort the rest. + let mut errors: Vec = Vec::new(); - seed_users(pool).await.map_err(|e| SeedError::StepFailed { - step: "seed_users".to_string(), - reason: e.to_string(), - })?; + if let Err(e) = seed_users(pool).await { + errors.push(SeedError::StepFailed { + step: "seed_users".to_string(), + reason: e.to_string(), + }); + } - seed_feature_flags(pool) - .await - .map_err(|e| SeedError::StepFailed { + if let Err(e) = seed_feature_flags(pool).await { + errors.push(SeedError::StepFailed { step: "seed_feature_flags".to_string(), reason: e.to_string(), - })?; + }); + } - info!("Database seed complete"); - Ok(()) + if errors.is_empty() { + info!("Database seed complete"); + Ok(()) + } else if errors.len() == 1 { + Err(errors.remove(0)) + } else { + Err(SeedError::Multiple(errors)) + } } // --------------------------------------------------------------------------- diff --git a/backend/src/services/cache.rs b/backend/src/services/cache.rs new file mode 100644 index 0000000..123cb17 --- /dev/null +++ b/backend/src/services/cache.rs @@ -0,0 +1,82 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::RwLock; + +#[derive(Clone)] +struct CacheEntry { + value: String, + expires_at: Option, +} + +#[derive(Clone, Default)] +pub struct MultiLevelCache { + // Simple in-memory local layer for fast reads. This is a mock; a real + // implementation would add Redis or another remote layer. + local: Arc>>, +} + +impl MultiLevelCache { + pub fn new() -> Self { + Self { + local: Arc::new(RwLock::new(HashMap::new())), + } + } + + pub async fn get(&self, key: &str) -> Option { + let now = Instant::now(); + // Check local cache + { + let read = self.local.read().await; + if let Some(entry) = read.get(key) { + if let Some(exp) = entry.expires_at { + if now >= exp { + return None; + } + } + return Some(entry.value.clone()); + } + } + + // Remote layer would be queried here in a production implementation. + // For this mock we return None on miss. + None + } + + pub async fn set(&self, key: String, value: String, ttl: Option) { + let expires_at = ttl.map(|t| Instant::now() + t); + let entry = CacheEntry { value, expires_at }; + let mut write = self.local.write().await; + write.insert(key, entry); + } + + pub async fn invalidate(&self, key: &str) { + let mut write = self.local.write().await; + write.remove(key); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + #[tokio::test] + async fn basic_set_get() { + let cache = MultiLevelCache::new(); + cache.set("k".to_string(), "v".to_string(), None).await; + let v = cache.get("k").await; + assert_eq!(v.as_deref(), Some("v")); + } + + #[tokio::test] + async fn ttl_expires() { + let cache = MultiLevelCache::new(); + cache + .set("k2".to_string(), "v2".to_string(), Some(Duration::from_millis(1))) + .await; + tokio::time::sleep(Duration::from_millis(5)).await; + let v = cache.get("k2").await; + assert!(v.is_none()); + } +} diff --git a/backend/src/services/transformers.rs b/backend/src/services/transformers.rs new file mode 100644 index 0000000..b7c3b22 --- /dev/null +++ b/backend/src/services/transformers.rs @@ -0,0 +1,62 @@ +use serde_json::{Map, Value}; + +pub struct DataTransformer; + +impl DataTransformer { + pub fn new() -> Self { + Self {} + } + + /// Apply a simple normalization pipeline to a JSON value. + /// - If the value is an object, convert all top-level keys to lowercase. + /// - Optionally extract a nested field by dot-path using `extract_field`. + pub fn transform(&self, mut input: Value) -> Value { + if let Value::Object(map) = &mut input { + let mut normalized = Map::new(); + for (k, v) in map.drain() { + normalized.insert(k.to_lowercase(), v); + } + return Value::Object(normalized); + } + input + } + + /// Extract a nested field from `value` using a dot-separated path like "a.b.c". + pub fn extract_field<'a>(value: &'a Value, path: &str) -> Option<&'a Value> { + let mut current = value; + for part in path.split('.') { + match current { + Value::Object(map) => { + current = map.get(part)?; + } + _ => return None, + } + } + Some(current) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_keys() { + let t = DataTransformer::new(); + let input: Value = serde_json::json!({ "Name": "Alice", "AGE": 30 }); + let out = t.transform(input); + if let Value::Object(map) = out { + assert!(map.contains_key("name")); + assert!(map.contains_key("age")); + } else { + panic!("expected object") + } + } + + #[test] + fn extract_dot_path() { + let v: Value = serde_json::json!({ "a": { "b": { "c": 1 } } }); + let got = DataTransformer::extract_field(&v, "a.b.c").unwrap(); + assert_eq!(got, &Value::Number(1.into())); + } +} diff --git a/examples/multisig/Cargo.toml b/examples/multisig/Cargo.toml new file mode 100644 index 0000000..c6a05b2 --- /dev/null +++ b/examples/multisig/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "crucible-example-multisig" +version.workspace = true +edition.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +crucible = { path = "../../contracts/crucible" } diff --git a/examples/multisig/src/lib.rs b/examples/multisig/src/lib.rs new file mode 100644 index 0000000..144d55f --- /dev/null +++ b/examples/multisig/src/lib.rs @@ -0,0 +1,115 @@ +#![no_std] +#![allow(deprecated)] +use soroban_sdk::{contract, contractimpl, contracttype, symbol_short, Address, Bytes, Env, Vec}; + +#[contracttype] +#[derive(Clone)] +pub struct Proposal { + pub proposer: Address, + pub tx: Bytes, + pub approvals: Vec
, + pub executed: bool, +} + +#[contracttype] +#[derive(Clone)] +pub enum DataKey { + Owners, + Threshold, + NextId, + Proposal(u64), +} + +#[contract] +#[derive(Default)] +pub struct MultiSig; + +#[contractimpl] +impl MultiSig { + pub fn initialize(env: Env, owners: Vec
, threshold: u32) { + if env.storage().instance().has(&DataKey::Owners) { + panic!("already initialized"); + } + if owners.is_empty() || threshold == 0 || (threshold as usize) > owners.len() { + panic!("invalid owners/threshold"); + } + env.storage().instance().set(&DataKey::Owners, &owners); + env.storage().instance().set(&DataKey::Threshold, &threshold); + env.storage().instance().set(&DataKey::NextId, &1u64); + env.events().publish((symbol_short!("initialized"),), ()); + } + + pub fn propose(env: Env, proposer: Address, tx: Bytes) -> u64 { + // proposer must be an owner + let owners: Vec
= env.storage().instance().get(&DataKey::Owners).unwrap(); + if !owners.iter().any(|a| a == &proposer) { + panic!("only owner can propose"); + } + proposer.require_auth(); + + let mut id: u64 = 1; + if env.storage().instance().has(&DataKey::NextId) { + id = env.storage().instance().get(&DataKey::NextId).unwrap(); + } + + let proposal = Proposal { + proposer: proposer.clone(), + tx: tx.clone(), + approvals: Vec::new(&env), + executed: false, + }; + + env.storage().instance().set(&DataKey::Proposal(id), &proposal); + env.storage().instance().set(&DataKey::NextId, &(id + 1)); + env.events().publish((symbol_short!("proposed"),), id); + id + } + + pub fn approve(env: Env, approver: Address, id: u64) { + let owners: Vec
= env.storage().instance().get(&DataKey::Owners).unwrap(); + if !owners.iter().any(|a| a == &approver) { + panic!("only owner can approve"); + } + approver.require_auth(); + + let mut p: Proposal = env.storage().instance().get(&DataKey::Proposal(id)).unwrap(); + if p.executed { + panic!("proposal already executed"); + } + // check for duplicate approval + if p.approvals.iter().any(|a| a == &approver) { + return; // idempotent + } + p.approvals.push_back(approver.clone()); + env.storage().instance().set(&DataKey::Proposal(id), &p); + env.events().publish((symbol_short!("approved"),), (id, approver)); + } + + pub fn execute(env: Env, executor: Address, id: u64) { + let owners: Vec
= env.storage().instance().get(&DataKey::Owners).unwrap(); + if !owners.iter().any(|a| a == &executor) { + panic!("only owner can execute"); + } + executor.require_auth(); + + let threshold: u32 = env.storage().instance().get(&DataKey::Threshold).unwrap(); + let mut p: Proposal = env.storage().instance().get(&DataKey::Proposal(id)).unwrap(); + if p.executed { + panic!("proposal already executed"); + } + let approvals = p.approvals.len(); + if (approvals as u32) < threshold { + panic!("not enough approvals"); + } + p.executed = true; + env.storage().instance().set(&DataKey::Proposal(id), &p); + env.events().publish((symbol_short!("executed"),), id); + } + + pub fn get_proposal(env: Env, id: u64) -> Proposal { + env.storage().instance().get(&DataKey::Proposal(id)).unwrap() + } +} + +#[cfg(test)] +mod test; diff --git a/examples/multisig/src/test.rs b/examples/multisig/src/test.rs new file mode 100644 index 0000000..388b122 --- /dev/null +++ b/examples/multisig/src/test.rs @@ -0,0 +1,77 @@ +#![cfg(test)] +extern crate std; + +use crucible::prelude::*; +use crucible::{assert_emitted, assert_reverts}; +use soroban_sdk::symbol_short; + +use crate::{MultiSig, MultiSigClient}; + +struct Ctx { + pub env: MockEnv, + pub id: Address, + pub a: AccountHandle, + pub b: AccountHandle, + pub c: AccountHandle, +} + +impl Ctx { + fn setup() -> Self { + let env = MockEnv::builder() + .with_contract::() + .with_account("a", Stroops::xlm(100)) + .with_account("b", Stroops::xlm(100)) + .with_account("c", Stroops::xlm(100)) + .build(); + + let id = env.contract_id::(); + let a = env.account("a"); + let b = env.account("b"); + let c = env.account("c"); + + Ctx { env, id, a, b, c } + } + + fn client(&self) -> MultiSigClient<'_> { + MultiSigClient::new(self.env.inner(), &self.id) + } +} + +#[test] +fn test_basic_flow() { + let ctx = Ctx::setup(); + ctx.env.mock_all_auths(); + + // initialize with owners a,b,c and threshold 2 + let owners = vec![ctx.a.address(), ctx.b.address(), ctx.c.address()]; + ctx.client().initialize(&owners, &2u32); + assert_emitted!(ctx.env, ctx.id, (symbol_short!("initialized"),), ()); + + // proposer a creates a proposal + ctx.env.mock_all_auths(); + let tx = soroban_sdk::Bytes::from_array(&ctx.env, &[1, 2, 3]); + let id = ctx.client().propose(&ctx.a.address(), &tx); + assert_emitted!(ctx.env, ctx.id, (symbol_short!("proposed"),), id); + + // approve by b + ctx.env.mock_all_auths(); + ctx.client().approve(&ctx.b.address(), &id); + assert_emitted!(ctx.env, ctx.id, (symbol_short!("approved"),), (id, ctx.b.address())); + + // execute should fail because threshold=2 and only 1 approval + ctx.env.mock_all_auths(); + assert_reverts!(ctx.client().execute(&ctx.a.address(), &id), "not enough approvals"); + + // approve by c + ctx.env.mock_all_auths(); + ctx.client().approve(&ctx.c.address(), &id); + + // now execute should succeed + ctx.env.mock_all_auths(); + ctx.client().execute(&ctx.a.address(), &id); + assert_emitted!(ctx.env, ctx.id, (symbol_short!("executed"),), id); + + // double execute should revert + ctx.env.mock_all_auths(); + assert_reverts!(ctx.client().execute(&ctx.a.address(), &id), "proposal already executed"); +}