From b88a0bc2695022994cc41fc207af2007386e2113 Mon Sep 17 00:00:00 2001 From: ReinaMaze Date: Fri, 28 Aug 2026 21:10:38 +0100 Subject: [PATCH] feat: add on-chain governance discussion, oracle reputation weighting, execution deadlines, and identity attestation - Add on-chain comment system for proposals (governance-dao) * ProposalComment struct with threaded reply support * add_comment, get_comment, delete_comment functions * Comments only allowed during Discussion/Active phases * Max 1024 bytes per comment for storage efficiency - Weight confidence scores by oracle reputation (oracle-verifier) * Modified get_median_value to use effective_weight instead of base weight * Added get_reputation_weighted_confidence function * Effective weight = base weight * (reputation_score / 1000) * Prevents all oracles being equally trusted regardless of track record - Add execution deadline for passed proposals (governance-dao) * New ProposalStatus::Expired variant * execution_deadline field set to vote_end + finalize_delay + 7 days * Prevents stale proposals from executing after deadline * Marks proposal as Expired if execution attempted after deadline - Add optional identity attestation integration (claims-processor) * New fields: identity_verified, verification_type, verification_time on Claim * require_identity_for_category(product_id, id_type) for admin * verify_claimant_identity(claim_id, id_type) for manual verification * get_identity_requirement, is_identity_verified utility functions * Prevents Sybil attacks by enabling KYC/accreditation requirements Fixes: Sybil risk, oracle trust asymmetry, proposal staleness, off-chain discussion isolation --- contracts/claims-processor/src/lib.rs | 87 +++++++++++++++++ contracts/claims-processor/src/types.rs | 6 ++ contracts/governance-dao/src/lib.rs | 123 ++++++++++++++++++++++++ contracts/governance-dao/src/types.rs | 29 ++++++ contracts/oracle-verifier/src/lib.rs | 58 ++++++++++- 5 files changed, 302 insertions(+), 1 deletion(-) diff --git a/contracts/claims-processor/src/lib.rs b/contracts/claims-processor/src/lib.rs index 1def9ba..86382dd 100644 --- a/contracts/claims-processor/src/lib.rs +++ b/contracts/claims-processor/src/lib.rs @@ -127,6 +127,8 @@ enum StorageKey { /// Configurable delay in seconds between claim approval and payout (u64). /// 0 = immediate payout (default behavior). PayoutDelay, + /// Identity attestation requirement: product_id → Symbol (id_type required). + IdentityRequirement(u128), } // ─── Errors ─────────────────────────────────────────────────────────────────── @@ -158,6 +160,8 @@ pub enum Error { ClaimDeadlinePassed = 19, /// Payout delay has not yet elapsed — the claim cannot be settled now. PayoutDelayNotElapsed = 20, + /// Identity verification required for this claim category but not verified. + IdentityVerificationRequired = 21, } /// Approximate Stellar ledger close time in seconds, used to convert @@ -1475,6 +1479,89 @@ impl ClaimsProcessor { overdue } + // ── Identity Verification ─────────────────────────────────────────────── + + /// Admin-only: mark a product/category as requiring identity verification. + /// Claimants must have verified identity (via oracle-verifier attestation) + /// to claim payouts for this product. + pub fn require_identity_for_category( + env: Env, + admin: Address, + product_id: u128, + id_type: Symbol, + ) { + Self::require_admin(&env, &admin); + + let key = StorageKey::IdentityRequirement(product_id); + env.storage().instance().set(&key, &id_type); + + env.events().publish( + (Symbol::new(&env, "identity_requirement_set"),), + (product_id, id_type), + ); + } + + /// Admin-only: remove identity verification requirement for a product. + pub fn remove_identity_requirement(env: Env, admin: Address, product_id: u128) { + Self::require_admin(&env, &admin); + + let key = StorageKey::IdentityRequirement(product_id); + env.storage().instance().remove(&key); + + env.events().publish( + (Symbol::new(&env, "identity_requirement_removed"),), + product_id, + ); + } + + /// Admin-only: manually verify a claimant's identity for a claim. + /// Used when off-chain identity verification is completed or approved by DAO. + pub fn verify_claimant_identity( + env: Env, + admin: Address, + claim_id: u128, + id_type: Symbol, + ) { + Self::require_admin(&env, &admin); + + let mut claim: Claim = env + .storage() + .persistent() + .get(&StorageKey::Claim(claim_id)) + .unwrap_or_else(|| panic_with_error!(&env, Error::ClaimNotFound)); + + let now = env.ledger().timestamp(); + claim.identity_verified = true; + claim.verification_type = Some(id_type.clone()); + claim.verification_time = Some(now); + + let key = StorageKey::Claim(claim_id); + env.storage().persistent().set(&key, &claim); + Self::extend_claim_ttl(&env, &key); + + env.events().publish( + (Symbol::new(&env, "identity_verified"),), + (claim_id, id_type, now), + ); + } + + /// Get the identity requirement for a product (if set). + pub fn get_identity_requirement(env: Env, product_id: u128) -> Option { + env.storage() + .instance() + .get(&StorageKey::IdentityRequirement(product_id)) + } + + /// Check if a claimant's identity is verified for a particular ID type. + /// This is a utility for off-chain systems to verify attestation status. + pub fn is_identity_verified(env: Env, claim_id: u128) -> bool { + env.storage() + .persistent() + .get(&StorageKey::Claim(claim_id)) + .map(|claim: Claim| claim.identity_verified) + .unwrap_or(false) + } + // ── Internal helpers ───────────────────────────────────────────────────── /// Evaluate a pending claim and settle it against the configured oracle trigger. diff --git a/contracts/claims-processor/src/types.rs b/contracts/claims-processor/src/types.rs index 56c4c72..69086e2 100644 --- a/contracts/claims-processor/src/types.rs +++ b/contracts/claims-processor/src/types.rs @@ -58,6 +58,12 @@ pub struct Claim { pub partial_payout_bps: Option, /// Installment payout configuration for large claims. pub installments: Option, + /// Whether the claimant's identity was verified (optional, for Sybil protection). + pub identity_verified: bool, + /// Type of identity verification performed (e.g., "kyc", "accreditation"). + pub verification_type: Option, + /// Timestamp when identity verification occurred. + pub verification_time: Option, /// Timestamp at which payout becomes available (issue #432). /// `None` means payout is immediate or not applicable. pub payout_ready_at: Option, diff --git a/contracts/governance-dao/src/lib.rs b/contracts/governance-dao/src/lib.rs index 633ee28..fd5b2f3 100644 --- a/contracts/governance-dao/src/lib.rs +++ b/contracts/governance-dao/src/lib.rs @@ -110,6 +110,10 @@ enum StorageKey { RiskPool, /// On-chain audit trail record for executed proposal — proposal_id -> ExecutionAuditRecord. ExecutionAudit(u64), + /// Proposal comment by ID — comment_id -> ProposalComment. + ProposalComment(u128), + /// Next comment ID counter for a proposal — proposal_id -> u128. + NextCommentId(u64), } @@ -157,6 +161,8 @@ pub enum Error { /// `vote_batch` was called with an empty proposal list. NoProposals = 40, InvalidInput = 41, + /// Proposal passed but execution deadline has expired without execution. + ExecutionDeadlineExpired = 42, } #[contract] @@ -303,6 +309,7 @@ impl GovernanceDao { created_at: now, vote_end, execution_time: 0, + execution_deadline: vote_end.saturating_add(config.finalize_delay).saturating_add(7 * 24 * 3600), total_supply: config.total_supply, kind: ProposalKind::Standard, impact_analysis, @@ -406,6 +413,7 @@ impl GovernanceDao { created_at: now, vote_end, execution_time: 0, + execution_deadline: vote_end.saturating_add(config.finalize_delay).saturating_add(7 * 24 * 3600), total_supply: config.total_supply, kind: ProposalKind::Upgrade, impact_analysis, @@ -1091,6 +1099,14 @@ impl GovernanceDao { if env.ledger().timestamp() < proposal.execution_time { panic_with_error!(&env, Error::TimelockNotExpired); } + // Check execution deadline: if passed, prevent execution to keep proposals fresh + if env.ledger().timestamp() > proposal.execution_deadline { + proposal.status = ProposalStatus::Expired; + env.storage() + .persistent() + .set(&StorageKey::Proposal(proposal_id), &proposal); + panic_with_error!(&env, Error::ExecutionDeadlineExpired); + } // Validate target address is a valid Stellar address before execution // This is a defense-in-depth check to prevent targeting invalid contracts @@ -1140,6 +1156,113 @@ impl GovernanceDao { .unwrap_or_else(|| panic_with_error!(&env, Error::ProposalNotFound)) } + // ── On-Chain Discussion ────────────────────────────────────────────────── + + /// Post a comment on a proposal during the Discussion or Active phase. + /// Comments are stored on-chain for transparent discussion. + /// `reply_to` is optional and allows threading of comments. + pub fn add_comment( + env: Env, + commenter: Address, + proposal_id: u64, + text: Bytes, + reply_to: Option, + ) -> u128 { + commenter.require_auth(); + + // Validate text length (max 1024 bytes) + if text.is_empty() || text.len() > 1024 { + panic_with_error!(&env, Error::InvalidInput); + } + + // Verify proposal exists + let proposal: Proposal = env + .storage() + .persistent() + .get(&StorageKey::Proposal(proposal_id)) + .unwrap_or_else(|| panic_with_error!(&env, Error::ProposalNotFound)); + + // Only allow comments during Discussion or Active phases + if proposal.status != ProposalStatus::Discussion && proposal.status != ProposalStatus::Active { + panic_with_error!(&env, Error::InvalidInput); + } + + // If reply_to is specified, verify that comment exists + if let Some(reply_id) = reply_to { + if !env + .storage() + .persistent() + .has(&StorageKey::ProposalComment(reply_id)) + { + panic_with_error!(&env, Error::ProposalNotFound); + } + } + + // Generate comment ID + let comment_id: u128 = env + .storage() + .instance() + .get(&StorageKey::NextCommentId(proposal_id)) + .unwrap_or(1u128); + + let now = env.ledger().timestamp(); + let comment = ProposalComment { + id: comment_id, + proposal_id, + author: commenter.clone(), + text, + created_at: now, + reply_to, + }; + + let comment_key = StorageKey::ProposalComment(comment_id); + env.storage().persistent().set(&comment_key, &comment); + env.storage() + .persistent() + .extend_ttl(&comment_key, TTL_THRESHOLD, TTL_EXTEND_TO); + + env.storage() + .instance() + .set(&StorageKey::NextCommentId(proposal_id), &(comment_id.saturating_add(1))); + + env.events().publish( + (Symbol::new(&env, "comment_added"),), + ProposalCommentAdded { + proposal_id, + comment_id, + author: commenter, + reply_to, + created_at: now, + }, + ); + + comment_id + } + + /// Retrieve a comment by its ID. + pub fn get_comment(env: Env, comment_id: u128) -> ProposalComment { + env.storage() + .persistent() + .get(&StorageKey::ProposalComment(comment_id)) + .unwrap_or_else(|| panic_with_error!(&env, Error::ProposalNotFound)) + } + + /// Admin-only: delete a comment (for moderation of spam/abuse). + pub fn delete_comment(env: Env, admin: Address, comment_id: u128) { + Self::require_admin(&env, &admin); + + let comment_key = StorageKey::ProposalComment(comment_id); + if !env.storage().persistent().has(&comment_key) { + panic_with_error!(&env, Error::ProposalNotFound); + } + + env.storage().persistent().remove(&comment_key); + + env.events().publish( + (Symbol::new(&env, "comment_deleted"),), + comment_id, + ); + } /// Admin-only: cancel an Active proposal before voting closes. /// diff --git a/contracts/governance-dao/src/types.rs b/contracts/governance-dao/src/types.rs index a0c62d9..980239c 100644 --- a/contracts/governance-dao/src/types.rs +++ b/contracts/governance-dao/src/types.rs @@ -33,6 +33,21 @@ pub enum ProposalStatus { Executed, /// Cancelled by admin before vote close Cancelled, + /// Passed but execution deadline expired without execution + Expired, +} + +/// On-chain comment on a proposal for discussion and feedback. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProposalComment { + pub id: u128, + pub proposal_id: u64, + pub author: Address, + pub text: Bytes, + pub created_at: u64, + /// Optional: ID of the comment this replies to, for threaded discussion + pub reply_to: Option, } /// Vote direction cast by a token holder. @@ -83,6 +98,9 @@ pub struct Proposal { pub vote_end: u64, /// Timelock expiration timestamp for execution. pub execution_time: u64, + /// Timestamp after which a passed proposal can no longer be executed. + /// Defaults to vote_end + finalize_delay + 7 days. Prevents stale proposals from executing. + pub execution_deadline: u64, /// Total supply captured at proposal creation time for quorum calculation. /// This prevents admin manipulation of total_supply during active votes. pub total_supply: i128, @@ -467,3 +485,14 @@ pub struct ExecutionVerificationFailed { pub callback: Symbol, pub error: Symbol, } + +/// Emitted when a comment is posted on a proposal for discussion. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProposalCommentAdded { + pub proposal_id: u64, + pub comment_id: u128, + pub author: Address, + pub reply_to: Option, + pub created_at: u64, +} diff --git a/contracts/oracle-verifier/src/lib.rs b/contracts/oracle-verifier/src/lib.rs index 6154c5d..145e91a 100644 --- a/contracts/oracle-verifier/src/lib.rs +++ b/contracts/oracle-verifier/src/lib.rs @@ -1963,6 +1963,60 @@ impl OracleVerifier { (result, agg) } + /// Get weighted confidence score taking oracle reputation into account. + /// Confidence scores from higher-reputation oracles contribute more weight + /// to the final confidence value. + pub fn get_reputation_weighted_confidence( + env: Env, + data_type: Symbol, + key: Symbol, + ) -> u32 { + let points: Vec = env + .storage() + .persistent() + .get(&StorageKey::DataPoints(data_type.clone(), key.clone())) + .unwrap_or_else(|| panic_with_error!(&env, Error::NoDataAvailable)); + if points.is_empty() { + panic_with_error!(&env, Error::NoDataAvailable); + } + + let max_data_age = Self::effective_max_age(&env, &data_type); + let now = env.ledger().timestamp(); + let min_confidence: u32 = env + .storage() + .instance() + .get(&StorageKey::MinConfidence) + .unwrap_or(0); + + let mut weighted_sum: u64 = 0; + let mut total_weight: u64 = 0; + + for i in 0..points.len().min(100) { + let p = points.get_unchecked(i); + if now.saturating_sub(p.timestamp) <= max_data_age && p.confidence >= min_confidence { + let oracle_key = StorageKey::Oracle(data_type.clone(), p.oracle.clone()); + if let Some(entry) = env + .storage() + .persistent() + .get::<_, OracleEntry>(&oracle_key) + { + if entry.active { + // Use effective weight which factors in oracle reputation + let effective_weight = Self::get_effective_weight(env.clone(), p.oracle.clone(), data_type.clone()); + weighted_sum = weighted_sum.saturating_add((p.confidence as u64) * (effective_weight as u64)); + total_weight = total_weight.saturating_add(effective_weight as u64); + } + } + } + } + + if total_weight == 0 { + return 0; + } + + ((weighted_sum / total_weight) as u32).min(1000) + } + /// Return the most recent submission from any oracle for (data_type, key). /// Panics with NoDataAvailable if no submissions exist. pub fn get_data(env: Env, data_type: Symbol, key: Symbol) -> OracleDataPoint { @@ -2637,7 +2691,9 @@ impl OracleVerifier { if n >= 100 { panic_with_error!(env, Error::TooManyOracles); } - values[n] = (p.value, entry.weight); + // Use effective weight which factors in oracle reputation + let effective_weight = Self::get_effective_weight(env.clone(), p.oracle.clone(), data_type.clone()); + values[n] = (p.value, effective_weight); timestamps[n] = p.timestamp; n += 1; total_weight += entry.weight;