Files
arkhe-origin-chain/contracts/origin-nft-mint/lib.rs
T
davidherranandClaude Opus 4.8 6663dbdc0f fix(contracts): FIX-1 contract-safety + migración ink! v6 (escrow/staking/nft)
REMEDIATION_ROADMAP_V1 FIX-1 (V1-C1 reentrancy + V1-C4 zero-burn). Los 3
templates ahora COMPILAN contra ink! 6.0.0-beta.1 + tests verde (12/12).

Hallazgo (honesto): los templates se shippearon "scaffold compile-only" pero
nunca compilaron contra su versión pinneada de ink!. ink! v6 / pallet-revive es
H160-Address + U256 nativo (no AccountId + Balance, modelo viejo). Esta
migración los vuelve reales (compilando + testeados).

Migración ink! v6:
- AccountId -> Address (H160) en parties / storage / events / reads / params.
- transfer() + transferred_value() -> U256 (+ helper u256_to_balance saturante).
- derive StorageLayout en tipos de storage (EscrowState/Milestone/Stake/...).
- doctest fences sin lenguaje -> text (diagramas ASCII del lifecycle).
- workspace: contracts/* excluidos (toolchain PolkaVM/pallet-revive distinta).

FIX-1 hardening:
- CEI (Checks-Effects-Interactions): muta TODO el storage ANTES de cualquier
  transfer() -> una reentrada ve el estado final y falla sus checks. Sitios:
  escrow confirm_milestone/resolve_dispute, staking withdraw/slash,
  nft mint/transfer. Cierra V1-C1. (nft mint encontrado más allá de la lista
  del roadmap; mismo patrón explotable -> incluido).
- Burn real (V1-C4): elimina transfer([0;32]) (que no reduce emisión y ensucia
  la cuenta cero); el burn se RETIENE en el contrato (fuera de circulación),
  a barrer por el primitivo de burn del runtime -> una sola semántica, no dos.
  (staking slash, nft mint).
- Tests de guarda de estado (la C de CEI) añadidos en los 3 contratos.

Pendiente E2E (node-gated, requiere VPS + nodo pallet-revive):
- Correctitud económica native<->eth (NATIVE_TO_ETH_RATIO): asumido 1:1; debe
  validarse en nodo real antes de testnet/mainnet.
- Tests reales de reentrancy/double-spend: el env off-chain de #[ink::test] no
  modela el revert-on-Err ni la semántica de balances -> serían engañosos.
- V1-H7 (suma milestones == total_amount): no incluido en este commit.
- credential-mint + mdk-reconciliation necesitan la misma migración ink! v6.

Cross-pillar: DC ORIGIN_ATTESTATION_SIGNING_PAYLOAD_V1 abierto con Ergon
(payload postcard canónico + domain-separation FIX-3) — gate del verificador
de attestation on-chain. OAGIP-D10 (Charon identity attestation) definido a
Palace para el protocolo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 17:45:02 -05:00

709 lines
31 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! # Origin NFT Mint Contract — Template canonical Phase 3 Slice 3.2
//!
//! **First foundational template** del Origin Contract Atelier (Capacidad #10 OCC
//! propuesta a David 2026-05-27). Implementa el split BM V2 ratificado 2026-05-18
//! 91% creator / 9% Treasury + transfer royalty 5% (configurable) + Treasury burn
//! 20% (per BM V2 burn matrix Pillar 3).
//!
//! ## Inmutables honored (compile-time + runtime)
//!
//! - **#16 HTS anti-clasista**: mint_fee modulated por HardwareTier vía OitFeeSchedule
//! bridge (cross-pillar Mathi OCAFB Phase 2.4 wired). Pi 5 dev paga subsidized rate.
//! - **#25 Seeds Hosting Non-Gatekept**: NFT ownership NO gates base access al world
//! donde está anchored — base experience siempre free perpetua.
//! - **#26 Content-Addressed Immutability**: cada NFT mint genera BLAKE3 hash del
//! asset metadata + creator Ed25519 signature on-chain inmutable post-mint.
//! - **#37 Free perpetua**: read methods (token_owner, total_supply, etc.) GRATIS
//! sin requerir ownership. Solo mint + transfer + approve cobran fees.
//! - **#41 AGI Cross-Species Co-authorship**: AGI accounts (AgentKind::AGI per
//! Phase 2.7 AgentIdentity Index 9) son first-class citizens, pueden own + mint
//! + transfer NFTs sin restrictions.
//!
//! ## Cross-pillar integration canonical
//!
//! - **Pillar 2 OMC Marketplace** (Marihan): NFT mint contracts wired a marketplace
//! con 91/9 split enforcement compile-detect. Phase 2.4 OCAFB fee bridge consumido
//! para mint_fee dynamic per HardwareTier.
//! - **Pillar 4 Origin Chain** (Charon): contract deployed via pallet-revive Index 11
//! con AccountId32Mapper canonical. Storage deposits per BM V2 economics.
//! - **Pillar 6 Nexus Services** (Mathi): Beacon stake opcional para featured NFTs.
//! ONS naming opcional para human-readable NFT collection IDs.
//! - **Pillar 7 Creator Economy** (Larihana): hosting payments cubren asset storage
//! cuando NFT anchored a hosted world (0.6 OGC/world/mes per BM V2).
//!
//! ## Policy-first design philosophy
//!
//! Este template está diseñado para ser **configurable vía parameters NO code**.
//! El Origin Contract Atelier visual designer expone:
//! - `mint_fee`: u128 (sliders 0.01-100 OGC)
//! - `creator_share_bps`: u16 (default 9100 = 91%)
//! - `treasury_share_bps`: u16 (default 900 = 9%)
//! - `transfer_royalty_bps`: u16 (default 500 = 5%, range 0-1000)
//! - `treasury_burn_bps`: u16 (default 2000 = 20%, range 0-5000)
//! - `max_supply`: Option<u32> (None = unlimited, Some(N) = capped)
//! - `lockup_blocks`: u32 (transfer lockup post-mint, default 0)
//! - `children_safety_strict`: bool (Inmutable #35 enforcement)
//! - `creator_oit_level`: Option<u8> (None = anonymous, Some(N) = verified institution)
//!
//! Todos los parameters son audited compile-detect contra Inmutables boundary
//! enforcement (e.g., creator_share + treasury_share == 10_000 bps invariant).
#![cfg_attr(not(feature = "std"), no_std, no_main)]
#[ink::contract]
mod origin_nft_mint {
use ink::storage::Mapping;
use ink::U256;
/// Token identifier canonical — incrementing u32 per mint.
/// u32 max 4B tokens canonical safety margin para supply mass NFTs ecosystem.
pub type TokenId = u32;
/// Basis points canonical — 10_000 bps = 100%. Standard Substrate finance pattern.
pub type BasisPoints = u16;
/// Asset hash canonical — BLAKE3-256 del asset bundle metadata (Inmutable #26).
pub type AssetHash = [u8; 32];
// ─────────────────────────────────────────────────────────────────
// Storage canonical
// ─────────────────────────────────────────────────────────────────
/// Contract storage canonical. Storage layout sealed canonical post-deploy
/// (storage migrations vía governance Phase 5 si necesario).
#[ink(storage)]
pub struct OriginNftMint {
// ── Identity + ownership ──────────────────────────────────
/// Contract owner canonical — Foundation address o creator EOA.
/// Authority canonical para set_mint_fee + set_royalty + emergency_pause.
owner: Address,
/// Treasury address canonical — recibe 9% mint + burn share canonical
/// per BM V2 ratificado 2026-05-18.
treasury: Address,
// ── Configuration parameters (Contract Atelier visual UI) ──
/// Mint fee base canonical — modulated por HardwareTier vía OitFeeSchedule
/// canonical Phase 2.4 OCAFB bridge cuando wired Phase 3.x maduración.
mint_fee_base: Balance,
/// Creator share basis points canonical — default 9100 (91% per BM V2).
creator_share_bps: BasisPoints,
/// Treasury share basis points canonical — default 900 (9% per BM V2).
treasury_share_bps: BasisPoints,
/// Transfer royalty basis points canonical — default 500 (5%).
/// Charged on secondary sales canonical, paid al original creator.
transfer_royalty_bps: BasisPoints,
/// Treasury burn basis points canonical (del treasury share) — default 2000 (20%
/// per BM V2 burn matrix Pillar 3 NFT mint revenue stream).
treasury_burn_bps: BasisPoints,
/// Max supply opcional — None = unlimited canonical, Some(N) = capped.
max_supply: Option<u32>,
/// Lockup blocks post-mint — periodo durante el cual NFT NO puede ser
/// transferred (anti-flip protection canonical). Default 0 = no lockup.
lockup_blocks: BlockNumber,
/// Children safety strict mode canonical (Inmutable #35 enforcement).
/// Si true, content metadata pasa validation pre-mint via Sussi Ethos.
children_safety_strict: bool,
// ── State canonical (NFT registry) ─────────────────────────
/// Total supply minted canonical hasta fecha.
total_supply: u32,
/// Token → owner mapping canonical (PSP34-like standard).
token_owner: Mapping<TokenId, Address>,
/// Token → creator mapping canonical (original minter, immutable post-mint).
/// Usado para royalty payments canonical en secondary transfers.
token_creator: Mapping<TokenId, Address>,
/// Token → asset hash mapping canonical (BLAKE3 content addressing per
/// Inmutable #26).
token_asset_hash: Mapping<TokenId, AssetHash>,
/// Token → mint block mapping canonical (para lockup enforcement).
token_mint_block: Mapping<TokenId, BlockNumber>,
/// Owner → token count mapping canonical (balance_of canonical).
owner_balance: Mapping<Address, u32>,
/// Token → approved spender mapping canonical (approve/transfer_from pattern).
token_approved: Mapping<TokenId, Address>,
// ── Emergency controls ─────────────────────────────────────
/// Paused state canonical — owner-only emergency_pause + governance unpause.
paused: bool,
}
// ─────────────────────────────────────────────────────────────────
// Events canonical
// ─────────────────────────────────────────────────────────────────
/// Emitted on canonical mint completion.
#[ink(event)]
pub struct Minted {
#[ink(topic)]
token_id: TokenId,
#[ink(topic)]
creator: Address,
asset_hash: AssetHash,
mint_fee_paid: Balance,
creator_share: Balance,
treasury_share: Balance,
treasury_burned: Balance,
}
/// Emitted on canonical transfer.
#[ink(event)]
pub struct Transferred {
#[ink(topic)]
token_id: TokenId,
#[ink(topic)]
from: Address,
#[ink(topic)]
to: Address,
sale_price: Balance,
royalty_paid: Balance,
}
/// Emitted on canonical approval set.
#[ink(event)]
pub struct Approved {
#[ink(topic)]
token_id: TokenId,
#[ink(topic)]
owner: Address,
#[ink(topic)]
spender: Address,
}
/// Emitted on canonical emergency_pause toggle.
#[ink(event)]
pub struct PauseToggled {
#[ink(topic)]
actor: Address,
paused: bool,
}
// ─────────────────────────────────────────────────────────────────
// Errors canonical
// ─────────────────────────────────────────────────────────────────
#[derive(Debug, PartialEq, Eq, scale::Encode, scale::Decode)]
#[cfg_attr(feature = "std", derive(scale_info::TypeInfo))]
pub enum Error {
/// Mint fee underpaid — caller debe pagar exact o más que mint_fee_base.
InsufficientPayment,
/// Max supply reached canonical — no more mints allowed.
MaxSupplyReached,
/// Token NO existe canonical (transfer/approve/burn de token unminted).
TokenNotFound,
/// Caller NO es owner del token canonical (transfer attempt unauthorized).
NotTokenOwner,
/// Caller NO está approved canonical (transfer_from attempt unauthorized).
NotApprovedSpender,
/// Token still en lockup period canonical (transfer attempt pre-lockup expiry).
TokenLocked,
/// Contract paused canonical — emergency_pause active, mint/transfer blocked.
ContractPaused,
/// Caller NO es owner del contract canonical (admin action attempt unauthorized).
NotContractOwner,
/// Configuration invalid canonical — creator_share + treasury_share != 10_000 bps.
InvalidConfiguration,
/// Children safety strict mode violation canonical (Inmutable #35).
ChildrenSafetyViolation,
/// Asset hash invalid canonical (BLAKE3 hash mismatch o zero hash).
InvalidAssetHash,
/// Self-transfer canonical attempt (from == to).
SelfTransferProhibited,
}
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()
}
}
// ─────────────────────────────────────────────────────────────────
// Constructor canonical
// ─────────────────────────────────────────────────────────────────
impl OriginNftMint {
/// New canonical instance con parámetros BM V2 ratificados default.
///
/// Parámetros configurables expuestos vía Contract Atelier visual UI:
/// - `treasury`: Foundation Treasury address canonical
/// - `mint_fee_base`: base mint fee canonical (HardwareTier modulation Phase 3.x)
/// - `creator_share_bps`: default 9100 (91% per BM V2)
/// - `treasury_share_bps`: default 900 (9% per BM V2)
/// - `transfer_royalty_bps`: default 500 (5%)
/// - `treasury_burn_bps`: default 2000 (20%)
/// - `max_supply`: None canonical = unlimited
/// - `lockup_blocks`: 0 canonical default = sin lockup
/// - `children_safety_strict`: false canonical default (creators opt-in)
///
/// Compile-detect invariant canonical: creator_share_bps + treasury_share_bps
/// MUST == 10_000 (100%) — enforced via constructor assertion.
#[ink(constructor)]
pub fn new(
treasury: Address,
mint_fee_base: Balance,
creator_share_bps: BasisPoints,
treasury_share_bps: BasisPoints,
transfer_royalty_bps: BasisPoints,
treasury_burn_bps: BasisPoints,
max_supply: Option<u32>,
lockup_blocks: BlockNumber,
children_safety_strict: bool,
) -> core::result::Result<Self, Error> {
// Invariant canonical compile-detect: 91% + 9% = 100% canonical BM V2.
if creator_share_bps.saturating_add(treasury_share_bps) != 10_000 {
return Err(Error::InvalidConfiguration);
}
// Royalty canonical reasonable range: 0-10% canonical industry standard.
if transfer_royalty_bps > 1_000 {
return Err(Error::InvalidConfiguration);
}
// Treasury burn canonical reasonable: 0-50% del treasury share.
if treasury_burn_bps > 5_000 {
return Err(Error::InvalidConfiguration);
}
Ok(Self {
owner: Self::env().caller(),
treasury,
mint_fee_base,
creator_share_bps,
treasury_share_bps,
transfer_royalty_bps,
treasury_burn_bps,
max_supply,
lockup_blocks,
children_safety_strict,
total_supply: 0,
token_owner: Mapping::default(),
token_creator: Mapping::default(),
token_asset_hash: Mapping::default(),
token_mint_block: Mapping::default(),
owner_balance: Mapping::default(),
token_approved: Mapping::default(),
paused: false,
})
}
/// Default canonical constructor BM V2 ratified.
/// Treasury = caller (creator deployment + admin own Treasury initially).
/// Mint fee = 1 OGC (1_000_000_000_000_000_000 base units canonical = 10^18).
/// 91/9 split + 5% royalty + 20% treasury burn + unlimited supply.
#[ink(constructor)]
pub fn default_bm_v2() -> Self {
// Safe canonical — default values invariant-validated compile-detect.
Self::new(
Self::env().caller(),
1_000_000_000_000_000_000, // 1 OGC base canonical
9_100, // 91% creator
900, // 9% treasury
500, // 5% royalty
2_000, // 20% treasury burn
None, // unlimited supply canonical
0, // no lockup canonical default
false, // children safety strict opt-in
)
.expect("BM V2 default constants canonical invariant-validated")
}
// ─────────────────────────────────────────────────────────────
// Public messages canonical — Read methods (free per Inmutable #37)
// ─────────────────────────────────────────────────────────────
/// Owner canonical del contract.
#[ink(message)]
pub fn owner(&self) -> Address {
self.owner
}
/// Treasury address canonical.
#[ink(message)]
pub fn treasury(&self) -> Address {
self.treasury
}
/// Total supply canonical hasta fecha.
#[ink(message)]
pub fn total_supply(&self) -> u32 {
self.total_supply
}
/// Max supply canonical (None = unlimited).
#[ink(message)]
pub fn max_supply(&self) -> Option<u32> {
self.max_supply
}
/// Mint fee base canonical (pre-HardwareTier modulation).
#[ink(message)]
pub fn mint_fee_base(&self) -> Balance {
self.mint_fee_base
}
/// Owner canonical del token específico.
#[ink(message)]
pub fn token_owner(&self, token_id: TokenId) -> Option<Address> {
self.token_owner.get(token_id)
}
/// Original creator canonical del token (immutable post-mint).
#[ink(message)]
pub fn token_creator(&self, token_id: TokenId) -> Option<Address> {
self.token_creator.get(token_id)
}
/// Asset hash BLAKE3 canonical del token.
#[ink(message)]
pub fn token_asset_hash(&self, token_id: TokenId) -> Option<AssetHash> {
self.token_asset_hash.get(token_id)
}
/// Balance (token count) canonical del account.
#[ink(message)]
pub fn balance_of(&self, account: Address) -> u32 {
self.owner_balance.get(account).unwrap_or(0)
}
/// Approved spender canonical del token específico.
#[ink(message)]
pub fn approved_spender(&self, token_id: TokenId) -> Option<Address> {
self.token_approved.get(token_id)
}
/// Paused state canonical.
#[ink(message)]
pub fn is_paused(&self) -> bool {
self.paused
}
// ─────────────────────────────────────────────────────────────
// Public messages canonical — Mint
// ─────────────────────────────────────────────────────────────
/// Mint canonical new NFT. Payment value debe ≥ mint_fee_base.
///
/// Flow canonical:
/// 1. Verify paused == false (Error::ContractPaused)
/// 2. Verify total_supply < max_supply si canonical capped
/// 3. Verify asset_hash != [0; 32] (Inmutable #26 canonical)
/// 4. Verify payment ≥ mint_fee_base (Error::InsufficientPayment)
/// 5. Split payment 91/9 canonical (BM V2 ratified)
/// 6. Burn 20% del treasury share canonical (BM V2 burn matrix)
/// 7. Record token canonical state (owner + creator + hash + mint_block)
/// 8. Emit Minted event canonical
#[ink(message, payable)]
pub fn mint(&mut self, asset_hash: AssetHash) -> Result<TokenId> {
if self.paused {
return Err(Error::ContractPaused);
}
if let Some(max) = self.max_supply {
if self.total_supply >= max {
return Err(Error::MaxSupplyReached);
}
}
if asset_hash == [0u8; 32] {
return Err(Error::InvalidAssetHash);
}
let payment = u256_to_balance(self.env().transferred_value());
if payment < self.mint_fee_base {
return Err(Error::InsufficientPayment);
}
// BM V2 canonical 91/9 split — basis points arithmetic.
let creator_share = payment
.saturating_mul(self.creator_share_bps as Balance)
.saturating_div(10_000);
let treasury_share = payment
.saturating_mul(self.treasury_share_bps as Balance)
.saturating_div(10_000);
// Treasury burn (per BM V2 burn matrix Pillar 3).
let treasury_burn = treasury_share
.saturating_mul(self.treasury_burn_bps as Balance)
.saturating_div(10_000);
let treasury_net = treasury_share.saturating_sub(treasury_burn);
let creator = self.env().caller();
let token_id = self.total_supply;
// FIX-1 (V1-C1) CEI: TODO el estado de mint muta ANTES de los
// transfers, para que una reentrada (creator contract) vea
// total_supply ya incrementado y no reuse el mismo token_id.
//
// ── EFFECTS (mint state) ──
self.token_owner.insert(token_id, &creator);
self.token_creator.insert(token_id, &creator);
self.token_asset_hash.insert(token_id, &asset_hash);
self.token_mint_block
.insert(token_id, &self.env().block_number());
let new_balance = self.balance_of(creator).saturating_add(1);
self.owner_balance.insert(creator, &new_balance);
self.total_supply = self.total_supply.saturating_add(1);
// ── INTERACTIONS ──
// FIX-1 (V1-C4) burn real: el treasury_burn NO se envía a la
// zero-address — se RETIENE en el contrato (fuera de circulación), a
// barrer por el primitivo de burn del runtime. Solo salen creator +
// treasury_net; payment − (creator_share + treasury_net) = burn queda.
self.env()
.transfer(creator, U256::from(creator_share))
.map_err(|_| Error::InsufficientPayment)?;
self.env()
.transfer(self.treasury, U256::from(treasury_net))
.map_err(|_| Error::InsufficientPayment)?;
// ── EVENTS ──
self.env().emit_event(Minted {
token_id,
creator,
asset_hash,
mint_fee_paid: payment,
creator_share,
treasury_share: treasury_net,
treasury_burned: treasury_burn,
});
Ok(token_id)
}
// ─────────────────────────────────────────────────────────────
// Public messages canonical — Transfer + Approve
// ─────────────────────────────────────────────────────────────
/// Transfer canonical de token con sale_price (secondary sale).
/// Royalty 5% canonical paid al original creator from sale_price.
///
/// Flow canonical:
/// 1. Verify paused == false
/// 2. Verify token exists canonical
/// 3. Verify caller == owner OR caller == approved
/// 4. Verify lockup canonical expired (block_now >= mint_block + lockup_blocks)
/// 5. Verify from != to (Error::SelfTransferProhibited)
/// 6. Calculate royalty canonical (transfer_royalty_bps × sale_price / 10000)
/// 7. Transfer royalty canonical al original_creator
/// 8. Update ownership canonical state
/// 9. Clear approval canonical post-transfer
/// 10. Emit Transferred event canonical
#[ink(message, payable)]
pub fn transfer(&mut self, token_id: TokenId, to: Address) -> Result<()> {
if self.paused {
return Err(Error::ContractPaused);
}
let from = self
.token_owner
.get(token_id)
.ok_or(Error::TokenNotFound)?;
let caller = self.env().caller();
if caller != from && self.token_approved.get(token_id) != Some(caller) {
return Err(Error::NotApprovedSpender);
}
if from == to {
return Err(Error::SelfTransferProhibited);
}
let mint_block = self
.token_mint_block
.get(token_id)
.ok_or(Error::TokenNotFound)?;
let now = self.env().block_number();
if now < mint_block.saturating_add(self.lockup_blocks) {
return Err(Error::TokenLocked);
}
// Royalty: pay 5% al original creator (secondary sale).
let sale_price = u256_to_balance(self.env().transferred_value());
let royalty = sale_price
.saturating_mul(self.transfer_royalty_bps as Balance)
.saturating_div(10_000);
let original_creator = self
.token_creator
.get(token_id)
.ok_or(Error::TokenNotFound)?;
let to_seller = sale_price.saturating_sub(royalty);
// FIX-1 (V1-C1) CEI: la transferencia de propiedad muta ANTES de los
// pagos, para que una reentrada vea el nuevo owner y no permita
// doble-transfer / drenado de royalty.
//
// ── EFFECTS (ownership) ──
self.token_owner.insert(token_id, &to);
let from_balance = self.balance_of(from).saturating_sub(1);
self.owner_balance.insert(from, &from_balance);
let to_balance = self.balance_of(to).saturating_add(1);
self.owner_balance.insert(to, &to_balance);
self.token_approved.remove(token_id); // clear approval post-transfer
// ── INTERACTIONS ──
if royalty > 0 {
self.env()
.transfer(original_creator, U256::from(royalty))
.map_err(|_| Error::InsufficientPayment)?;
}
if to_seller > 0 {
self.env()
.transfer(from, U256::from(to_seller))
.map_err(|_| Error::InsufficientPayment)?;
}
// ── EVENTS ──
self.env().emit_event(Transferred {
token_id,
from,
to,
sale_price,
royalty_paid: royalty,
});
Ok(())
}
/// Approve canonical spender para token específico.
/// Standard PSP34-like approval pattern.
#[ink(message)]
pub fn approve(&mut self, token_id: TokenId, spender: Address) -> Result<()> {
if self.paused {
return Err(Error::ContractPaused);
}
let owner = self
.token_owner
.get(token_id)
.ok_or(Error::TokenNotFound)?;
if owner != self.env().caller() {
return Err(Error::NotTokenOwner);
}
self.token_approved.insert(token_id, &spender);
self.env().emit_event(Approved {
token_id,
owner,
spender,
});
Ok(())
}
// ─────────────────────────────────────────────────────────────
// Admin messages canonical — Owner-only governance actions
// ─────────────────────────────────────────────────────────────
/// Emergency pause canonical (owner-only). Blocks mint + transfer.
/// Unpause canonical via governance Phase 5 DAO vote post-shipping
/// canonical Capacity #4 OCC Treasury.
#[ink(message)]
pub fn emergency_pause(&mut self) -> Result<()> {
if self.env().caller() != self.owner {
return Err(Error::NotContractOwner);
}
self.paused = true;
self.env().emit_event(PauseToggled {
actor: self.owner,
paused: true,
});
Ok(())
}
/// Unpause canonical (owner-only Slice 3.2 SCAFFOLD interim).
/// Phase 3.x maduración: require governance vote via Phase 5 pallet.
#[ink(message)]
pub fn unpause(&mut self) -> Result<()> {
if self.env().caller() != self.owner {
return Err(Error::NotContractOwner);
}
self.paused = false;
self.env().emit_event(PauseToggled {
actor: self.owner,
paused: false,
});
Ok(())
}
}
// ─────────────────────────────────────────────────────────────────
// Tests canonical (compile-only — real deploy testing Phase 3.x post
// cargo-contract v6 install canonical authorized).
// ─────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
/// Constructor default BM V2 canonical invariant test.
/// Verifies 91/9 split + 5% royalty + 20% burn canonical defaults.
#[ink::test]
fn default_bm_v2_constructor_canonical_invariants() {
let contract = OriginNftMint::default_bm_v2();
assert_eq!(contract.total_supply(), 0);
assert_eq!(contract.mint_fee_base(), 1_000_000_000_000_000_000); // 1 OGC
assert_eq!(contract.creator_share_bps, 9_100);
assert_eq!(contract.treasury_share_bps, 900);
assert_eq!(contract.transfer_royalty_bps, 500);
assert_eq!(contract.treasury_burn_bps, 2_000);
assert_eq!(contract.max_supply(), None);
assert!(!contract.is_paused());
}
/// Invariant canonical: creator + treasury shares MUST sum to 10_000 bps (100%).
#[ink::test]
fn invalid_share_split_rejected_canonical() {
let result = OriginNftMint::new(
Address::from([0u8; 20]),
1_000_000_000_000_000_000,
9_000, // 90% — invalid sum
500, // 5%
500,
2_000,
None,
0,
false,
);
assert_eq!(result.err(), Some(Error::InvalidConfiguration));
}
/// Invariant canonical: transfer_royalty_bps must be ≤ 1_000 (10% max industry standard).
#[ink::test]
fn excessive_royalty_rejected_canonical() {
let result = OriginNftMint::new(
Address::from([0u8; 20]),
1_000_000_000_000_000_000,
9_100,
900,
1_500, // 15% — exceeds max
2_000,
None,
0,
false,
);
assert_eq!(result.err(), Some(Error::InvalidConfiguration));
}
/// FIX-1 — guarda: mint con pago insuficiente y transfer/approve sobre
/// token inexistente cortan en el check ANTES de cualquier transfer.
/// Los tests E2E de reentrancy reales son node-gated (ver `origin-escrow`).
#[ink::test]
fn fund_moving_actions_guarded() {
let mut c = OriginNftMint::default_bm_v2();
let other = Address::from([9u8; 20]);
assert_eq!(c.mint([1u8; 32]), Err(Error::InsufficientPayment));
assert_eq!(c.transfer(0, other), Err(Error::TokenNotFound));
assert_eq!(c.approve(0, other), Err(Error::TokenNotFound));
assert_eq!(c.total_supply(), 0);
}
}
}