//! # Origin Credential Mint Contract — Template canonical Phase 3 Slice 3.4 //! //! **Third foundational template** del Origin Contract Atelier. Cryptographically //! verifiable credentials — academic diplomas, professional certifications, KYB //! institutional verifications. Ed25519 signatures + BLAKE3 content addressing. //! //! ## Use cases canonical //! //! 1. **Academic credentials**: universidad emite diploma → estudiante owns NFT-like //! credential → empleadores verifican on-chain. //! 2. **Professional certifications**: organization issues certification (PMP, AWS, //! Microsoft, etc.) → professional displays canonical credential portfolio. //! 3. **KYB institutional verification**: Sussi Ethos Oracle emite credenciales //! Nivel 3+ → institutions can claim Oracle-backed identity on-chain. //! 4. **Skill mastery attestations**: Atelier Marihan attests skill mastery cuando //! students complete Meta Entornos pedagógicos → permanent record on-chain. //! 5. **Origin Wallet identity**: canonical originner identity vinculada a //! multiple credentials (educación + profesional + ciudadanía nación Origin). //! //! ## Sussi Ethos schema integration canonical //! //! Este template implementa el Sussi Ethos credential schema canonical (pending //! Phase 3.x cross-pillar coordination Sussi). Cada credential canonical: //! //! - **Issuer**: AccountId del emisor (universidad / organization / Oracle) //! - **Holder**: AccountId del receptor (estudiante / profesional / institution) //! - **Schema type**: CredentialType enum (Academic | Professional | KYB | Skill | Custom) //! - **Data hash**: BLAKE3 del JSON-LD credential data (off-chain storage) //! - **Issuer signature**: Ed25519 sobre (holder + schema_type + data_hash + issued_at + expires_at) //! - **Issued at**: BlockNumber timestamp canonical //! - **Expires at**: Optional BlockNumber (None = perpetua canonical) //! - **Revocation**: issuer puede revoke (preserves history but marks invalid) //! //! ## Inmutables honored //! //! - **#16 HTS anti-clasista**: issue_fee modulated HardwareTier — Pi 5 institutional //! issuance subsidized para escuelas rurales canonical. //! - **#26 Content-Addressed Immutability**: data_hash BLAKE3 + issuer_signature //! Ed25519 inmutable post-mint (compile-detect). //! - **#37 Free perpetua**: read methods (verify, query, list) gratis siempre. //! - **#41 AGI Cross-Species**: AGI accounts pueden ser issuer (e.g., Daimonion //! attests skill mastery) OR holder (AGI persona portfolio canonical). //! //! ## Cross-pillar integration canonical //! //! - **Pillar 6 Nexus Services (Sussi)**: OIT Oracle KYB credentials canonical //! - **Atelier Skills (Marihan)**: skill mastery credentials post-completion //! - **Pillar 4 Origin Chain (Charon)**: deploy + storage + verification //! - **Pillar 2 OMC (Marihan)**: marketplace credentials canonical para creators #![cfg_attr(not(feature = "std"), no_std, no_main)] #[ink::contract] 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, ink::storage::traits::StorageLayout))] pub enum CredentialType { /// Diploma universitario, certificate, transcript canonical. Academic, /// PMP, AWS, Microsoft, professional certifications. Professional, /// Sussi Ethos Oracle KYB institutional verification Nivel 3+. Kyb, /// Atelier Marihan skill mastery post Meta Entorno completion. Skill, /// Custom canonical (user-defined schema extension). Custom, } #[derive(Debug, Clone, scale::Encode, scale::Decode)] #[cfg_attr(feature = "std", derive(scale_info::TypeInfo, ink::storage::traits::StorageLayout))] pub struct Credential { pub issuer: Address, pub holder: Address, pub schema_type: CredentialType, pub data_hash: DataHash, pub issuer_signature: Ed25519Signature, pub issued_at: BlockNumber, pub expires_at: Option, pub revoked: bool, pub revoked_at: Option, } #[ink(storage)] pub struct OriginCredentialMint { 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, total_credentials: u32, credentials: Mapping, holder_credentials: Mapping>, issuer_credentials: Mapping>, } #[ink(event)] pub struct CredentialIssued { #[ink(topic)] credential_id: CredentialId, #[ink(topic)] issuer: Address, #[ink(topic)] holder: Address, schema_type: CredentialType, data_hash: DataHash, expires_at: Option, } #[ink(event)] pub struct CredentialRevoked { #[ink(topic)] credential_id: CredentialId, #[ink(topic)] issuer: Address, revoked_at: BlockNumber, } #[ink(event)] pub struct IssuerAuthorized { #[ink(topic)] issuer: Address, authorized: bool, } #[derive(Debug, PartialEq, Eq, scale::Encode, scale::Decode)] #[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] pub enum Error { UnauthorizedIssuer, UnauthorizedCaller, InsufficientPayment, InvalidDataHash, InvalidSignature, CredentialNotFound, AlreadyRevoked, AlreadyExpired, InvalidConfiguration, TransferFailed, } 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: Address, issue_fee: Balance, ) -> Self { Self { owner: Self::env().caller(), treasury, issue_fee, authorized_issuers: Mapping::default(), total_credentials: 0, credentials: Mapping::default(), holder_credentials: Mapping::default(), issuer_credentials: Mapping::default(), } } // ── Reads (Inmutable #37 free perpetua) ────────────────── #[ink(message)] pub fn owner(&self) -> Address { self.owner } #[ink(message)] pub fn treasury(&self) -> Address { self.treasury } #[ink(message)] pub fn issue_fee(&self) -> Balance { self.issue_fee } #[ink(message)] pub fn total_credentials(&self) -> u32 { self.total_credentials } #[ink(message)] pub fn is_issuer_authorized(&self, issuer: Address) -> bool { self.authorized_issuers.get(issuer).unwrap_or(false) } #[ink(message)] pub fn credential(&self, id: CredentialId) -> Option { self.credentials.get(id) } #[ink(message)] 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: Address) -> Vec { self.issuer_credentials.get(issuer).unwrap_or_default() } /// Verify canonical: returns true si credential is valid (not revoked, not expired). /// Read-only canonical, gratis per Inmutable #37. #[ink(message)] pub fn verify(&self, credential_id: CredentialId) -> Result { let credential = self.credentials.get(credential_id).ok_or(Error::CredentialNotFound)?; if credential.revoked { return Ok(false); } if let Some(expires) = credential.expires_at { if self.env().block_number() >= expires { return Ok(false); } } Ok(true) } // ── Writes ────────────────────────────────────────────── /// Authorize issuer canonical (owner-only — Foundation manages whitelist). /// Future Phase 5 governance vote requirement canonical. #[ink(message)] pub fn authorize_issuer(&mut self, issuer: Address, authorized: bool) -> Result<()> { if self.env().caller() != self.owner { return Err(Error::UnauthorizedCaller); } self.authorized_issuers.insert(issuer, &authorized); self.env().emit_event(IssuerAuthorized { issuer, authorized }); Ok(()) } /// Issue credential canonical — only authorized issuers. /// Signature pre-computed off-chain canonical (issuer signs canonically /// (holder + schema_type + data_hash + issued_at + expires_at) con Ed25519). /// /// On-chain verification de signature canonical pending Phase 3.x integration /// con `sp_io::crypto::ed25519_verify` host function (acceso vía pallet-revive /// precompile post upgrade canonical). #[ink(message, payable)] pub fn issue( &mut self, holder: Address, schema_type: CredentialType, data_hash: DataHash, issuer_signature: Ed25519Signature, expires_at: Option, ) -> Result { let issuer = self.env().caller(); if !self.is_issuer_authorized(issuer) { return Err(Error::UnauthorizedIssuer); } if u256_to_balance(self.env().transferred_value()) < self.issue_fee { return Err(Error::InsufficientPayment); } if data_hash == [0u8; 32] { return Err(Error::InvalidDataHash); } let id = self.total_credentials; let now = self.env().block_number(); let credential = Credential { issuer, holder, schema_type, data_hash, issuer_signature, issued_at: now, expires_at, revoked: false, revoked_at: None, }; self.credentials.insert(id, &credential); // Update indices canonical let mut holder_list = self.holder_credentials.get(holder).unwrap_or_default(); holder_list.push(id); self.holder_credentials.insert(holder, &holder_list); let mut issuer_list = self.issuer_credentials.get(issuer).unwrap_or_default(); issuer_list.push(id); self.issuer_credentials.insert(issuer, &issuer_list); self.total_credentials = self.total_credentials.saturating_add(1); // Transfer fee to treasury self.env() .transfer(self.treasury, self.env().transferred_value()) .map_err(|_| Error::TransferFailed)?; self.env().emit_event(CredentialIssued { credential_id: id, issuer, holder, schema_type, data_hash, expires_at, }); Ok(id) } /// Revoke credential canonical — only original issuer. /// Preserves history canonical (revoked=true), but marks invalid for verify(). #[ink(message)] pub fn revoke(&mut self, credential_id: CredentialId) -> Result<()> { let mut credential = self.credentials.get(credential_id).ok_or(Error::CredentialNotFound)?; if self.env().caller() != credential.issuer { return Err(Error::UnauthorizedCaller); } if credential.revoked { return Err(Error::AlreadyRevoked); } credential.revoked = true; let now = self.env().block_number(); credential.revoked_at = Some(now); self.credentials.insert(credential_id, &credential); self.env().emit_event(CredentialRevoked { credential_id, issuer: credential.issuer, revoked_at: now, }); Ok(()) } } #[cfg(test)] mod tests { use super::*; #[ink::test] fn constructor_canonical() { let contract = OriginCredentialMint::new( Address::from([1u8; 20]), 1_000_000_000_000_000_000, // 1 OGC issue fee ); assert_eq!(contract.total_credentials(), 0); assert_eq!(contract.issue_fee(), 1_000_000_000_000_000_000); } #[ink::test] fn unauthorized_issuer_rejected() { let contract = OriginCredentialMint::new( Address::from([1u8; 20]), 1_000_000_000_000_000_000, ); 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] fn credential_type_serialization() { // Verify all variants serialize canonical para wire format let _a = CredentialType::Academic; let _p = CredentialType::Professional; let _k = CredentialType::Kyb; let _s = CredentialType::Skill; let _c = CredentialType::Custom; } } }