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>
709 lines
31 KiB
Rust
709 lines
31 KiB
Rust
//! # 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);
|
||
}
|
||
}
|
||
}
|