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>
This commit is contained in:
1 parent
a15a65966f
commit
6663dbdc0f
5 files changed
+325
-149
No files matched your search
@@ -52,8 +52,8 @@
|
||||
|
||||
#[ink::contract]
|
||||
mod origin_nft_mint {
|
||||
use ink::prelude::vec::Vec;
|
||||
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.
|
||||
@@ -76,11 +76,11 @@ mod origin_nft_mint {
|
||||
// ── Identity + ownership ──────────────────────────────────
|
||||
/// Contract owner canonical — Foundation address o creator EOA.
|
||||
/// Authority canonical para set_mint_fee + set_royalty + emergency_pause.
|
||||
owner: AccountId,
|
||||
owner: Address,
|
||||
|
||||
/// Treasury address canonical — recibe 9% mint + burn share canonical
|
||||
/// per BM V2 ratificado 2026-05-18.
|
||||
treasury: AccountId,
|
||||
treasury: Address,
|
||||
|
||||
// ── Configuration parameters (Contract Atelier visual UI) ──
|
||||
/// Mint fee base canonical — modulated por HardwareTier vía OitFeeSchedule
|
||||
@@ -117,11 +117,11 @@ mod origin_nft_mint {
|
||||
total_supply: u32,
|
||||
|
||||
/// Token → owner mapping canonical (PSP34-like standard).
|
||||
token_owner: Mapping<TokenId, AccountId>,
|
||||
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, AccountId>,
|
||||
token_creator: Mapping<TokenId, Address>,
|
||||
|
||||
/// Token → asset hash mapping canonical (BLAKE3 content addressing per
|
||||
/// Inmutable #26).
|
||||
@@ -131,10 +131,10 @@ mod origin_nft_mint {
|
||||
token_mint_block: Mapping<TokenId, BlockNumber>,
|
||||
|
||||
/// Owner → token count mapping canonical (balance_of canonical).
|
||||
owner_balance: Mapping<AccountId, u32>,
|
||||
owner_balance: Mapping<Address, u32>,
|
||||
|
||||
/// Token → approved spender mapping canonical (approve/transfer_from pattern).
|
||||
token_approved: Mapping<TokenId, AccountId>,
|
||||
token_approved: Mapping<TokenId, Address>,
|
||||
|
||||
// ── Emergency controls ─────────────────────────────────────
|
||||
/// Paused state canonical — owner-only emergency_pause + governance unpause.
|
||||
@@ -151,7 +151,7 @@ mod origin_nft_mint {
|
||||
#[ink(topic)]
|
||||
token_id: TokenId,
|
||||
#[ink(topic)]
|
||||
creator: AccountId,
|
||||
creator: Address,
|
||||
asset_hash: AssetHash,
|
||||
mint_fee_paid: Balance,
|
||||
creator_share: Balance,
|
||||
@@ -165,9 +165,9 @@ mod origin_nft_mint {
|
||||
#[ink(topic)]
|
||||
token_id: TokenId,
|
||||
#[ink(topic)]
|
||||
from: AccountId,
|
||||
from: Address,
|
||||
#[ink(topic)]
|
||||
to: AccountId,
|
||||
to: Address,
|
||||
sale_price: Balance,
|
||||
royalty_paid: Balance,
|
||||
}
|
||||
@@ -178,16 +178,16 @@ mod origin_nft_mint {
|
||||
#[ink(topic)]
|
||||
token_id: TokenId,
|
||||
#[ink(topic)]
|
||||
owner: AccountId,
|
||||
owner: Address,
|
||||
#[ink(topic)]
|
||||
spender: AccountId,
|
||||
spender: Address,
|
||||
}
|
||||
|
||||
/// Emitted on canonical emergency_pause toggle.
|
||||
#[ink(event)]
|
||||
pub struct PauseToggled {
|
||||
#[ink(topic)]
|
||||
actor: AccountId,
|
||||
actor: Address,
|
||||
paused: bool,
|
||||
}
|
||||
|
||||
@@ -226,6 +226,17 @@ mod origin_nft_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()
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// Constructor canonical
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
@@ -248,7 +259,7 @@ mod origin_nft_mint {
|
||||
/// MUST == 10_000 (100%) — enforced via constructor assertion.
|
||||
#[ink(constructor)]
|
||||
pub fn new(
|
||||
treasury: AccountId,
|
||||
treasury: Address,
|
||||
mint_fee_base: Balance,
|
||||
creator_share_bps: BasisPoints,
|
||||
treasury_share_bps: BasisPoints,
|
||||
@@ -320,13 +331,13 @@ mod origin_nft_mint {
|
||||
|
||||
/// Owner canonical del contract.
|
||||
#[ink(message)]
|
||||
pub fn owner(&self) -> AccountId {
|
||||
pub fn owner(&self) -> Address {
|
||||
self.owner
|
||||
}
|
||||
|
||||
/// Treasury address canonical.
|
||||
#[ink(message)]
|
||||
pub fn treasury(&self) -> AccountId {
|
||||
pub fn treasury(&self) -> Address {
|
||||
self.treasury
|
||||
}
|
||||
|
||||
@@ -350,13 +361,13 @@ mod origin_nft_mint {
|
||||
|
||||
/// Owner canonical del token específico.
|
||||
#[ink(message)]
|
||||
pub fn token_owner(&self, token_id: TokenId) -> Option<AccountId> {
|
||||
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<AccountId> {
|
||||
pub fn token_creator(&self, token_id: TokenId) -> Option<Address> {
|
||||
self.token_creator.get(token_id)
|
||||
}
|
||||
|
||||
@@ -368,13 +379,13 @@ mod origin_nft_mint {
|
||||
|
||||
/// Balance (token count) canonical del account.
|
||||
#[ink(message)]
|
||||
pub fn balance_of(&self, account: AccountId) -> u32 {
|
||||
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<AccountId> {
|
||||
pub fn approved_spender(&self, token_id: TokenId) -> Option<Address> {
|
||||
self.token_approved.get(token_id)
|
||||
}
|
||||
|
||||
@@ -412,7 +423,7 @@ mod origin_nft_mint {
|
||||
if asset_hash == [0u8; 32] {
|
||||
return Err(Error::InvalidAssetHash);
|
||||
}
|
||||
let payment = self.env().transferred_value();
|
||||
let payment = u256_to_balance(self.env().transferred_value());
|
||||
if payment < self.mint_fee_base {
|
||||
return Err(Error::InsufficientPayment);
|
||||
}
|
||||
@@ -425,30 +436,20 @@ mod origin_nft_mint {
|
||||
.saturating_mul(self.treasury_share_bps as Balance)
|
||||
.saturating_div(10_000);
|
||||
|
||||
// Treasury burn canonical (per BM V2 burn matrix Pillar 3).
|
||||
// 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);
|
||||
|
||||
// Transfer canonical funds: creator + treasury_net + burn (via send to zero).
|
||||
let creator = self.env().caller();
|
||||
self.env()
|
||||
.transfer(creator, creator_share)
|
||||
.map_err(|_| Error::InsufficientPayment)?;
|
||||
self.env()
|
||||
.transfer(self.treasury, treasury_net)
|
||||
.map_err(|_| Error::InsufficientPayment)?;
|
||||
// Burn portion canonical: transfer a zero address (ink! v6 burn pattern).
|
||||
// Real Origin Chain canonical burn vía pallet-balances::burn extrinsic
|
||||
// post Phase 3.x integration. Slice 3.2 SCAFFOLD interim: zero address.
|
||||
let zero = AccountId::from([0u8; 32]);
|
||||
self.env()
|
||||
.transfer(zero, treasury_burn)
|
||||
.map_err(|_| Error::InsufficientPayment)?;
|
||||
|
||||
// Mint canonical state update.
|
||||
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);
|
||||
@@ -458,6 +459,19 @@ mod origin_nft_mint {
|
||||
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,
|
||||
@@ -490,7 +504,7 @@ mod origin_nft_mint {
|
||||
/// 9. Clear approval canonical post-transfer
|
||||
/// 10. Emit Transferred event canonical
|
||||
#[ink(message, payable)]
|
||||
pub fn transfer(&mut self, token_id: TokenId, to: AccountId) -> Result<()> {
|
||||
pub fn transfer(&mut self, token_id: TokenId, to: Address) -> Result<()> {
|
||||
if self.paused {
|
||||
return Err(Error::ContractPaused);
|
||||
}
|
||||
@@ -514,29 +528,22 @@ mod origin_nft_mint {
|
||||
return Err(Error::TokenLocked);
|
||||
}
|
||||
|
||||
// Royalty canonical: pay 5% al original creator (secondary sale).
|
||||
let sale_price = self.env().transferred_value();
|
||||
// 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);
|
||||
if royalty > 0 {
|
||||
let original_creator = self
|
||||
.token_creator
|
||||
.get(token_id)
|
||||
.ok_or(Error::TokenNotFound)?;
|
||||
self.env()
|
||||
.transfer(original_creator, royalty)
|
||||
.map_err(|_| Error::InsufficientPayment)?;
|
||||
}
|
||||
// Remaining canonical sale_price → seller (from).
|
||||
let original_creator = self
|
||||
.token_creator
|
||||
.get(token_id)
|
||||
.ok_or(Error::TokenNotFound)?;
|
||||
let to_seller = sale_price.saturating_sub(royalty);
|
||||
if to_seller > 0 {
|
||||
self.env()
|
||||
.transfer(from, to_seller)
|
||||
.map_err(|_| Error::InsufficientPayment)?;
|
||||
}
|
||||
|
||||
// Ownership canonical update.
|
||||
// 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);
|
||||
@@ -544,6 +551,19 @@ mod origin_nft_mint {
|
||||
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,
|
||||
@@ -558,7 +578,7 @@ mod origin_nft_mint {
|
||||
/// Approve canonical spender para token específico.
|
||||
/// Standard PSP34-like approval pattern.
|
||||
#[ink(message)]
|
||||
pub fn approve(&mut self, token_id: TokenId, spender: AccountId) -> Result<()> {
|
||||
pub fn approve(&mut self, token_id: TokenId, spender: Address) -> Result<()> {
|
||||
if self.paused {
|
||||
return Err(Error::ContractPaused);
|
||||
}
|
||||
@@ -642,7 +662,7 @@ mod origin_nft_mint {
|
||||
#[ink::test]
|
||||
fn invalid_share_split_rejected_canonical() {
|
||||
let result = OriginNftMint::new(
|
||||
AccountId::from([0u8; 32]),
|
||||
Address::from([0u8; 20]),
|
||||
1_000_000_000_000_000_000,
|
||||
9_000, // 90% — invalid sum
|
||||
500, // 5%
|
||||
@@ -659,7 +679,7 @@ mod origin_nft_mint {
|
||||
#[ink::test]
|
||||
fn excessive_royalty_rejected_canonical() {
|
||||
let result = OriginNftMint::new(
|
||||
AccountId::from([0u8; 32]),
|
||||
Address::from([0u8; 20]),
|
||||
1_000_000_000_000_000_000,
|
||||
9_100,
|
||||
900,
|
||||
@@ -671,5 +691,18 @@ mod origin_nft_mint {
|
||||
);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user