fix(contracts): cierra migración ink! v6 (credential) + FIX-1 V1-H7 (escrow milestone sum)

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>
This commit is contained in:
davidherranandClaude Opus 4.8 committed 2026-06-09 21:51:13 -05:00
1 parent 6663dbdc0f
commit 34a2041f2b
2 files changed
+97 -27

No files matched your search

+52 -25
View File
@@ -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<AccountId, bool>,
authorized_issuers: Mapping<Address, bool>,
total_credentials: u32,
credentials: Mapping<CredentialId, Credential>,
holder_credentials: Mapping<AccountId, Vec<CredentialId>>,
issuer_credentials: Mapping<AccountId, Vec<CredentialId>>,
holder_credentials: Mapping<Address, Vec<CredentialId>>,
issuer_credentials: Mapping<Address, Vec<CredentialId>>,
}
#[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<BlockNumber>,
@@ -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<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: 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<CredentialId> {
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: AccountId) -> Vec<CredentialId> {
pub fn credentials_of_issuer(&self, issuer: Address) -> Vec<CredentialId> {
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]
+45 -2
View File
@@ -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<MilestoneId, Milestone>,
}
@@ -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());
}
}
}