diff --git a/contracts/origin-credential-mint/lib.rs b/contracts/origin-credential-mint/lib.rs index c9e2dcf..ede489d 100644 --- a/contracts/origin-credential-mint/lib.rs +++ b/contracts/origin-credential-mint/lib.rs @@ -54,13 +54,14 @@ mod origin_credential_mint { use ink::prelude::vec::Vec; use ink::storage::Mapping; + use ink::U256; pub type CredentialId = u32; pub type DataHash = [u8; 32]; pub type Ed25519Signature = [u8; 64]; #[derive(Debug, Clone, Copy, PartialEq, Eq, scale::Encode, scale::Decode)] - #[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] + #[cfg_attr(feature = "std", derive(scale_info::TypeInfo, ink::storage::traits::StorageLayout))] pub enum CredentialType { /// Diploma universitario, certificate, transcript canonical. Academic, @@ -75,10 +76,10 @@ mod origin_credential_mint { } #[derive(Debug, Clone, scale::Encode, scale::Decode)] - #[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] + #[cfg_attr(feature = "std", derive(scale_info::TypeInfo, ink::storage::traits::StorageLayout))] pub struct Credential { - pub issuer: AccountId, - pub holder: AccountId, + pub issuer: Address, + pub holder: Address, pub schema_type: CredentialType, pub data_hash: DataHash, pub issuer_signature: Ed25519Signature, @@ -90,18 +91,18 @@ mod origin_credential_mint { #[ink(storage)] pub struct OriginCredentialMint { - owner: AccountId, - treasury: AccountId, + owner: Address, + treasury: Address, issue_fee: Balance, /// Whitelist canonical de issuers autorizados (Foundation manages). /// Sussi Ethos Oracle add/remove canonical via governance Phase 5. - authorized_issuers: Mapping, + authorized_issuers: Mapping, total_credentials: u32, credentials: Mapping, - holder_credentials: Mapping>, - issuer_credentials: Mapping>, + holder_credentials: Mapping>, + issuer_credentials: Mapping>, } #[ink(event)] @@ -109,9 +110,9 @@ mod origin_credential_mint { #[ink(topic)] credential_id: CredentialId, #[ink(topic)] - issuer: AccountId, + issuer: Address, #[ink(topic)] - holder: AccountId, + holder: Address, schema_type: CredentialType, data_hash: DataHash, expires_at: Option, @@ -122,14 +123,14 @@ mod origin_credential_mint { #[ink(topic)] credential_id: CredentialId, #[ink(topic)] - issuer: AccountId, + issuer: Address, revoked_at: BlockNumber, } #[ink(event)] pub struct IssuerAuthorized { #[ink(topic)] - issuer: AccountId, + issuer: Address, authorized: bool, } @@ -150,10 +151,21 @@ mod origin_credential_mint { pub type Result = core::result::Result; + /// Convierte un valor `U256` del entorno pallet-revive a `Balance` (u128) + /// de forma saturante. ⚠️ Ver nota de correctitud económica native↔eth en + /// `origin-escrow` — pendiente validación E2E en nodo pallet-revive (VPS). + fn u256_to_balance(v: U256) -> Balance { + if v > U256::from(Balance::MAX) { + Balance::MAX + } else { + v.as_u128() + } + } + impl OriginCredentialMint { #[ink(constructor)] pub fn new( - treasury: AccountId, + treasury: Address, issue_fee: Balance, ) -> Self { Self { @@ -171,10 +183,10 @@ mod origin_credential_mint { // ── Reads (Inmutable #37 free perpetua) ────────────────── #[ink(message)] - pub fn owner(&self) -> AccountId { self.owner } + pub fn owner(&self) -> Address { self.owner } #[ink(message)] - pub fn treasury(&self) -> AccountId { self.treasury } + pub fn treasury(&self) -> Address { self.treasury } #[ink(message)] pub fn issue_fee(&self) -> Balance { self.issue_fee } @@ -183,7 +195,7 @@ mod origin_credential_mint { pub fn total_credentials(&self) -> u32 { self.total_credentials } #[ink(message)] - pub fn is_issuer_authorized(&self, issuer: AccountId) -> bool { + pub fn is_issuer_authorized(&self, issuer: Address) -> bool { self.authorized_issuers.get(issuer).unwrap_or(false) } @@ -193,12 +205,12 @@ mod origin_credential_mint { } #[ink(message)] - pub fn credentials_of_holder(&self, holder: AccountId) -> Vec { + pub fn credentials_of_holder(&self, holder: Address) -> Vec { self.holder_credentials.get(holder).unwrap_or_default() } #[ink(message)] - pub fn credentials_of_issuer(&self, issuer: AccountId) -> Vec { + pub fn credentials_of_issuer(&self, issuer: Address) -> Vec { self.issuer_credentials.get(issuer).unwrap_or_default() } @@ -223,7 +235,7 @@ mod origin_credential_mint { /// Authorize issuer canonical (owner-only — Foundation manages whitelist). /// Future Phase 5 governance vote requirement canonical. #[ink(message)] - pub fn authorize_issuer(&mut self, issuer: AccountId, authorized: bool) -> Result<()> { + pub fn authorize_issuer(&mut self, issuer: Address, authorized: bool) -> Result<()> { if self.env().caller() != self.owner { return Err(Error::UnauthorizedCaller); } @@ -242,7 +254,7 @@ mod origin_credential_mint { #[ink(message, payable)] pub fn issue( &mut self, - holder: AccountId, + holder: Address, schema_type: CredentialType, data_hash: DataHash, issuer_signature: Ed25519Signature, @@ -252,7 +264,7 @@ mod origin_credential_mint { if !self.is_issuer_authorized(issuer) { return Err(Error::UnauthorizedIssuer); } - if self.env().transferred_value() < self.issue_fee { + if u256_to_balance(self.env().transferred_value()) < self.issue_fee { return Err(Error::InsufficientPayment); } if data_hash == [0u8; 32] { @@ -334,7 +346,7 @@ mod origin_credential_mint { #[ink::test] fn constructor_canonical() { let contract = OriginCredentialMint::new( - AccountId::from([1u8; 32]), + Address::from([1u8; 20]), 1_000_000_000_000_000_000, // 1 OGC issue fee ); assert_eq!(contract.total_credentials(), 0); @@ -344,10 +356,25 @@ mod origin_credential_mint { #[ink::test] fn unauthorized_issuer_rejected() { let contract = OriginCredentialMint::new( - AccountId::from([1u8; 32]), + Address::from([1u8; 20]), 1_000_000_000_000_000_000, ); - assert!(!contract.is_issuer_authorized(AccountId::from([99u8; 32]))); + assert!(!contract.is_issuer_authorized(Address::from([99u8; 20]))); + } + + /// FIX-1 — guarda: issue desde caller no autorizado + verify/revoke sobre + /// credencial inexistente cortan en el check ANTES de cualquier efecto o + /// transfer. (issue() ya es CEI-correcto: muta estado antes del transfer.) + #[ink::test] + fn guards_block_before_state_change() { + let mut c = OriginCredentialMint::new(Address::from([1u8; 20]), 1_000_000_000_000_000_000); + assert_eq!( + c.issue(Address::from([2u8; 20]), CredentialType::Academic, [7u8; 32], [0u8; 64], None), + Err(Error::UnauthorizedIssuer) + ); + assert_eq!(c.verify(999), Err(Error::CredentialNotFound)); + assert_eq!(c.revoke(999), Err(Error::CredentialNotFound)); + assert_eq!(c.total_credentials(), 0); } #[ink::test] diff --git a/contracts/origin-escrow/lib.rs b/contracts/origin-escrow/lib.rs index 9b4fe7b..94faa76 100644 --- a/contracts/origin-escrow/lib.rs +++ b/contracts/origin-escrow/lib.rs @@ -128,6 +128,9 @@ mod origin_escrow { // ── Milestones canonical (partial releases) ───────────── milestone_count: u32, + /// FIX-1 (V1-H7): Σ de los milestone.amount asignados. Invariante: + /// milestone_total ≤ total_amount (enforced en add_milestone). + milestone_total: Balance, milestones: Mapping, } @@ -257,6 +260,7 @@ mod origin_escrow { dispute_raised_by: None, last_release_block: None, milestone_count: 0, + milestone_total: 0, milestones: Mapping::default(), }) } @@ -335,6 +339,12 @@ mod origin_escrow { if self.env().caller() != self.depositor { return Err(Error::UnauthorizedCaller); } + // FIX-1 (V1-H7): Σ milestone.amount no puede exceder total_amount + // (previene sobre-liberación: releasear más de lo depositado). + let new_total = self.milestone_total.saturating_add(amount); + if new_total > self.total_amount { + return Err(Error::InvalidConfiguration); + } let id = self.milestone_count; self.milestones.insert(id, &Milestone { amount, @@ -344,6 +354,7 @@ mod origin_escrow { completed_at: None, }); self.milestone_count = self.milestone_count.saturating_add(1); + self.milestone_total = new_total; Ok(id) } @@ -386,7 +397,16 @@ mod origin_escrow { self.milestones.insert(milestone_id, &milestone); self.released_amount = self.released_amount.saturating_add(milestone.amount); self.last_release_block = Some(now); - let completed = self.released_amount >= self.total_amount; + // FIX-1 (V1-H7): completa cuando TODOS los milestones asignados se + // liberaron (Σ = milestone_total, garantizado ≤ total_amount por + // add_milestone). El residual no-asignado (deposited − milestone_total, + // p.ej. sobre-depósito) se barre al depositor al completar (sweep terminal). + let completed = self.milestone_total > 0 && self.released_amount >= self.milestone_total; + let sweep = if completed { + self.deposited_amount.saturating_sub(self.milestone_total) + } else { + 0 + }; if completed { self.state = EscrowState::Completed; } @@ -398,6 +418,11 @@ mod origin_escrow { self.env() .transfer(self.treasury, U256::from(fee)) .map_err(|_| Error::TransferFailed)?; + if sweep > 0 { + self.env() + .transfer(self.depositor, U256::from(sweep)) + .map_err(|_| Error::TransferFailed)?; + } // ── EVENTS ── self.env().emit_event(MilestoneCompleted { @@ -407,7 +432,7 @@ mod origin_escrow { if completed { self.env().emit_event(EscrowCompleted { total_released: self.released_amount, - total_refunded: 0, + total_refunded: sweep, treasury_fee: fee, }); } @@ -604,5 +629,23 @@ mod origin_escrow { // El estado permanece Created — no hubo mutación ni transfer. assert_eq!(escrow.state(), EscrowState::Created); } + + /// FIX-1 (V1-H7): add_milestone enforce Σ milestone.amount ≤ total_amount + /// → previene sobre-liberación. (El sweep terminal del residual se ejercita + /// E2E con transfers — node-gated, ver nota arriba.) + #[ink::test] + fn milestone_total_cannot_exceed_total_amount() { + let mut e = mk(); // depositor = [1;20], total_amount = 1 OGC (1e18) + ink::env::test::set_caller(Address::from([1u8; 20])); // caller = depositor + // 0.6 OGC: dentro del total → ok + assert!(e.add_milestone(600_000_000_000_000_000, 100, [1u8; 32]).is_ok()); + // +0.6 OGC: Σ = 1.2 OGC > total → rechazado + assert_eq!( + e.add_milestone(600_000_000_000_000_000, 100, [1u8; 32]), + Err(Error::InvalidConfiguration) + ); + // +0.4 OGC: Σ = 1.0 OGC == total → ok (boundary) + assert!(e.add_milestone(400_000_000_000_000_000, 100, [1u8; 32]).is_ok()); + } } }