Completa la capa de contratos: los 4 templates buildables migrados a ink! 6.0.0-beta.1 (17 tests verde). El 5º (mdk-reconciliation) es design-only (DESIGN.md, sin código Rust — nada que migrar). origin-credential-mint: migración ink! v6 (Address/U256/StorageLayout). Su issue() ya era CEI-correcto (muta estado antes del transfer) — es el template que el roadmap cita como referencia. 4 tests verde (+ guard test). NOTA: FIX-4 (verify de issuer_signature on-chain) sigue pendiente — necesita el payload canónico de credencial + precompile Ed25519 (Phase 3.x), no es parte de la migración. FIX-1 V1-H7 (escrow): enforce Σ milestone.amount <= total_amount en add_milestone (previene sobre-liberación) + completion vía milestone_total + sweep terminal del residual no-asignado al depositor (deposited − milestone_total). 5 tests verde (+ test del invariante con set_caller). El sweep (transfer) se valida E2E en nodo — node-gated. 17 tests verde totales (escrow 5 + staking 4 + nft 4 + credential 4). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
391 lines
15 KiB
Rust
391 lines
15 KiB
Rust
//! # 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<BlockNumber>,
|
|
pub revoked: bool,
|
|
pub revoked_at: Option<BlockNumber>,
|
|
}
|
|
|
|
#[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<Address, bool>,
|
|
|
|
total_credentials: u32,
|
|
credentials: Mapping<CredentialId, Credential>,
|
|
holder_credentials: Mapping<Address, Vec<CredentialId>>,
|
|
issuer_credentials: Mapping<Address, Vec<CredentialId>>,
|
|
}
|
|
|
|
#[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<BlockNumber>,
|
|
}
|
|
|
|
#[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<T> = core::result::Result<T, Error>;
|
|
|
|
/// 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<Credential> {
|
|
self.credentials.get(id)
|
|
}
|
|
|
|
#[ink(message)]
|
|
pub fn credentials_of_holder(&self, holder: Address) -> Vec<CredentialId> {
|
|
self.holder_credentials.get(holder).unwrap_or_default()
|
|
}
|
|
|
|
#[ink(message)]
|
|
pub fn credentials_of_issuer(&self, issuer: Address) -> Vec<CredentialId> {
|
|
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<bool> {
|
|
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<BlockNumber>,
|
|
) -> Result<CredentialId> {
|
|
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;
|
|
}
|
|
}
|
|
}
|