diff --git a/.gitignore b/.gitignore index 63d745b..7f764e9 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,12 @@ Cargo.lock.bak **/*.rs.bk +# Standalone ink! v6 contract builds (excluidos del workspace Substrate; +# se compilan aparte con `cargo +1.90 test --manifest-path contracts//Cargo.toml`). +# Sus target/ + Cargo.lock NO van al repo — son templates, no la binaria de la cadena. +contracts/*/target/ +contracts/*/Cargo.lock + # macOS .DS_Store ._.DS_Store diff --git a/Cargo.toml b/Cargo.toml index 5593a3e..2e3ed58 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,17 @@ members = [ "crates/origin-chain-node", ] +# ink! v6 contract templates — excluidos del workspace Substrate (toolchain +# PolkaVM/pallet-revive distinta; deps + lockfile NO compartibles con el runtime). +# Se buildean/testean standalone: cargo test --manifest-path contracts//Cargo.toml +exclude = [ + "contracts/origin-nft-mint", + "contracts/origin-escrow", + "contracts/origin-credential-mint", + "contracts/origin-staking", + "contracts/origin-mdk-reconciliation", +] + [workspace.package] version = "0.0.1" edition = "2021" diff --git a/contracts/origin-escrow/lib.rs b/contracts/origin-escrow/lib.rs index 42ad654..9b4fe7b 100644 --- a/contracts/origin-escrow/lib.rs +++ b/contracts/origin-escrow/lib.rs @@ -29,7 +29,7 @@ //! //! ## Lifecycle canonical (5 states) //! -//! ``` +//! ```text //! Created ──► Active ──► Releasing ──► Completed //! │ │ │ //! │ ▼ ▼ @@ -58,8 +58,8 @@ #[ink::contract] mod origin_escrow { - use ink::prelude::vec::Vec; use ink::storage::Mapping; + use ink::U256; pub type MilestoneId = u32; pub type BasisPoints = u16; @@ -70,7 +70,7 @@ mod origin_escrow { // ───────────────────────────────────────────────────────────────── #[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 EscrowState { Created, Active, @@ -81,7 +81,7 @@ mod origin_escrow { /// Milestone canonical structure — define partial releases del total_amount. #[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 Milestone { pub amount: Balance, pub due_block: BlockNumber, @@ -107,10 +107,10 @@ mod origin_escrow { #[ink(storage)] pub struct OriginEscrow { // ── Parties + config ───────────────────────────────────── - depositor: AccountId, - beneficiary: AccountId, - arbiter: AccountId, - treasury: AccountId, + depositor: Address, + beneficiary: Address, + arbiter: Address, + treasury: Address, terms_hash: TermsHash, total_amount: Balance, escrow_fee_bps: BasisPoints, @@ -123,7 +123,7 @@ mod origin_escrow { deposited_amount: Balance, released_amount: Balance, dispute_raised_at: Option, - dispute_raised_by: Option, + dispute_raised_by: Option
, last_release_block: Option, // ── Milestones canonical (partial releases) ───────────── @@ -138,7 +138,7 @@ mod origin_escrow { #[ink(event)] pub struct Deposited { #[ink(topic)] - depositor: AccountId, + depositor: Address, amount: Balance, } @@ -152,15 +152,15 @@ mod origin_escrow { #[ink(event)] pub struct DisputeRaised { #[ink(topic)] - raised_by: AccountId, + raised_by: Address, #[ink(topic)] - arbiter: AccountId, + arbiter: Address, } #[ink(event)] pub struct DisputeResolved { #[ink(topic)] - arbiter: AccountId, + arbiter: Address, resolution: Resolution, depositor_refund: Balance, beneficiary_release: Balance, @@ -196,16 +196,32 @@ mod origin_escrow { pub type Result = core::result::Result; + /// Convierte un valor `U256` del entorno pallet-revive a `Balance` (u128) + /// de forma saturante. + /// + /// ⚠️ NOTA de correctitud económica: pallet-revive maneja dos + /// representaciones (nativa vs eth, `NATIVE_TO_ETH_RATIO`). Aquí se asume + /// representación nativa 1:1 (lo que el doc-example de `transfer` sugiere). + /// La equivalencia exacta DEBE validarse E2E en un nodo pallet-revive real + /// antes de cualquier anclaje en testnet/mainnet — pendiente VPS. + fn u256_to_balance(v: U256) -> Balance { + if v > U256::from(Balance::MAX) { + Balance::MAX + } else { + v.as_u128() + } + } + impl OriginEscrow { /// Constructor canonical con full parameters. /// /// Invariant compile-detect: escrow_fee_bps ≤ 500 (5% max ceiling BM V2 §5.3). #[ink(constructor)] pub fn new( - depositor: AccountId, - beneficiary: AccountId, - arbiter: AccountId, - treasury: AccountId, + depositor: Address, + beneficiary: Address, + arbiter: Address, + treasury: Address, terms_hash: TermsHash, total_amount: Balance, escrow_fee_bps: BasisPoints, @@ -251,13 +267,13 @@ mod origin_escrow { pub fn state(&self) -> EscrowState { self.state } #[ink(message)] - pub fn depositor(&self) -> AccountId { self.depositor } + pub fn depositor(&self) -> Address { self.depositor } #[ink(message)] - pub fn beneficiary(&self) -> AccountId { self.beneficiary } + pub fn beneficiary(&self) -> Address { self.beneficiary } #[ink(message)] - pub fn arbiter(&self) -> AccountId { self.arbiter } + pub fn arbiter(&self) -> Address { self.arbiter } #[ink(message)] pub fn terms_hash(&self) -> TermsHash { self.terms_hash } @@ -291,7 +307,7 @@ mod origin_escrow { if self.env().caller() != self.depositor { return Err(Error::UnauthorizedCaller); } - let value = self.env().transferred_value(); + let value = u256_to_balance(self.env().transferred_value()); if value < self.total_amount { return Err(Error::InsufficientDeposit); } @@ -354,30 +370,41 @@ mod origin_escrow { return Err(Error::EarlyReleaseProhibited); } - // Release canonical: amount - escrow_fee → beneficiary + // Release: amount - escrow_fee → beneficiary. let fee = milestone.amount .saturating_mul(self.escrow_fee_bps as Balance) .saturating_div(10_000); let net = milestone.amount.saturating_sub(fee); - self.env().transfer(self.beneficiary, net).map_err(|_| Error::TransferFailed)?; - self.env().transfer(self.treasury, fee).map_err(|_| Error::TransferFailed)?; - + // FIX-1 (V1-C1) Checks-Effects-Interactions: TODO el storage muta + // ANTES de cualquier transfer(), para que una reentrada vea el estado + // final (milestone ya completado) y falle sus checks. + // + // ── EFFECTS ── milestone.completed = true; milestone.completed_at = Some(now); 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; + if completed { + self.state = EscrowState::Completed; + } + // ── INTERACTIONS (transfers al final) ── + self.env() + .transfer(self.beneficiary, U256::from(net)) + .map_err(|_| Error::TransferFailed)?; + self.env() + .transfer(self.treasury, U256::from(fee)) + .map_err(|_| Error::TransferFailed)?; + + // ── EVENTS ── self.env().emit_event(MilestoneCompleted { milestone_id, amount_released: net, }); - - // Auto-transition Completed if all milestones canonical released - if self.released_amount >= self.total_amount { - self.state = EscrowState::Completed; + if completed { self.env().emit_event(EscrowCompleted { total_released: self.released_amount, total_refunded: 0, @@ -447,15 +474,28 @@ mod origin_escrow { } }; + // FIX-1 (V1-C1) CEI: estado terminal ANTES de los transfers, para que + // una reentrada vea state=Completed y falle el check de InvalidState. + // + // ── EFFECTS ── + self.state = EscrowState::Completed; + + // ── INTERACTIONS ── if depositor_refund > 0 { - self.env().transfer(self.depositor, depositor_refund).map_err(|_| Error::TransferFailed)?; + self.env() + .transfer(self.depositor, U256::from(depositor_refund)) + .map_err(|_| Error::TransferFailed)?; } if beneficiary_release > 0 { - self.env().transfer(self.beneficiary, beneficiary_release).map_err(|_| Error::TransferFailed)?; + self.env() + .transfer(self.beneficiary, U256::from(beneficiary_release)) + .map_err(|_| Error::TransferFailed)?; } - self.env().transfer(self.treasury, fee).map_err(|_| Error::TransferFailed)?; + self.env() + .transfer(self.treasury, U256::from(fee)) + .map_err(|_| Error::TransferFailed)?; - self.state = EscrowState::Completed; + // ── EVENTS ── self.env().emit_event(DisputeResolved { arbiter: self.arbiter, resolution, @@ -476,53 +516,93 @@ mod origin_escrow { mod tests { use super::*; - #[ink::test] - fn constructor_canonical_invariants() { - let result = OriginEscrow::new( - AccountId::from([1u8; 32]), - AccountId::from([2u8; 32]), - AccountId::from([3u8; 32]), - AccountId::from([4u8; 32]), + /// Contrato válido en estado `Created` (default test caller = owner). + fn mk() -> OriginEscrow { + OriginEscrow::new( + Address::from([1u8; 20]), + Address::from([2u8; 20]), + Address::from([3u8; 20]), + Address::from([4u8; 20]), [1u8; 32], 1_000_000_000_000_000_000, - 150, // 1.5% canonical BM V2 + 150, // 1.5% BM V2 100, 1000, false, - ); - assert!(result.is_ok()); - let escrow = result.unwrap(); - assert_eq!(escrow.state(), EscrowState::Created); - assert_eq!(escrow.deposited_amount(), 0); + ) + .expect("valid config") } #[ink::test] - fn excessive_fee_rejected_canonical() { + fn constructor_invariants() { + let escrow = mk(); + assert_eq!(escrow.state(), EscrowState::Created); + assert_eq!(escrow.deposited_amount(), 0); + assert_eq!(escrow.released_amount(), 0); + } + + #[ink::test] + fn excessive_fee_rejected() { let result = OriginEscrow::new( - AccountId::from([1u8; 32]), - AccountId::from([2u8; 32]), - AccountId::from([3u8; 32]), - AccountId::from([4u8; 32]), + Address::from([1u8; 20]), + Address::from([2u8; 20]), + Address::from([3u8; 20]), + Address::from([4u8; 20]), [1u8; 32], 1_000_000_000_000_000_000, - 600, // 6% — exceeds 5% max BM V2 ceiling - 100, 1000, false, + 600, // 6% — exceeds 5% max BM V2 ceiling + 100, + 1000, + false, ); assert_eq!(result.err(), Some(Error::InvalidConfiguration)); } #[ink::test] fn same_party_depositor_beneficiary_rejected() { - let same = AccountId::from([1u8; 32]); + let same = Address::from([1u8; 20]); let result = OriginEscrow::new( - same, same, - AccountId::from([3u8; 32]), - AccountId::from([4u8; 32]), + same, + same, + Address::from([3u8; 20]), + Address::from([4u8; 20]), [1u8; 32], 1_000_000_000_000_000_000, - 150, 100, 1000, false, + 150, + 100, + 1000, + false, ); assert_eq!(result.err(), Some(Error::InvalidConfiguration)); } + + /// FIX-1 (V1-C1) — guarda de estado: ninguna acción que mueva fondos + /// (`confirm_milestone` / `raise_dispute` / `resolve_dispute`) es + /// alcanzable desde un estado equivocado. Es la "C" (Checks) del patrón + /// CEI — el primer guard corta ANTES de llegar a effects/interactions. + /// + /// ⚠️ Los tests E2E de reentrancy / double-spend reales (que ejercitan + /// los `transfer()` y el revert-on-Err del pallet) están node-gated: + /// requieren cargo-contract v6 + un nodo pallet-revive (VPS). El env + /// off-chain de `#[ink::test]` NO modela el revert-on-Err ni la + /// semántica native↔eth de balances, por lo que un test de transfer + /// aquí sería engañoso. El fix CEI es correctness-by-construction + /// (red-team ratificado); su validación dinámica espera el nodo real. + #[ink::test] + fn fund_moving_actions_blocked_from_created_state() { + let mut escrow = mk(); + assert_eq!(escrow.state(), EscrowState::Created); + // confirm_milestone exige Active. + assert_eq!(escrow.confirm_milestone(0), Err(Error::InvalidState)); + // raise_dispute exige Active. + assert_eq!(escrow.raise_dispute(), Err(Error::InvalidState)); + // resolve_dispute exige Disputed. + assert_eq!( + escrow.resolve_dispute(Resolution::SplitFifty), + Err(Error::InvalidState) + ); + // El estado permanece Created — no hubo mutación ni transfer. + assert_eq!(escrow.state(), EscrowState::Created); + } } } diff --git a/contracts/origin-nft-mint/lib.rs b/contracts/origin-nft-mint/lib.rs index edff591..b8500d1 100644 --- a/contracts/origin-nft-mint/lib.rs +++ b/contracts/origin-nft-mint/lib.rs @@ -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, + token_owner: Mapping, /// Token → creator mapping canonical (original minter, immutable post-mint). /// Usado para royalty payments canonical en secondary transfers. - token_creator: Mapping, + token_creator: Mapping, /// Token → asset hash mapping canonical (BLAKE3 content addressing per /// Inmutable #26). @@ -131,10 +131,10 @@ mod origin_nft_mint { token_mint_block: Mapping, /// Owner → token count mapping canonical (balance_of canonical). - owner_balance: Mapping, + owner_balance: Mapping, /// Token → approved spender mapping canonical (approve/transfer_from pattern). - token_approved: Mapping, + token_approved: Mapping, // ── 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 = core::result::Result; + /// 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 { + pub fn token_owner(&self, token_id: TokenId) -> Option
{ 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 { + pub fn token_creator(&self, token_id: TokenId) -> Option
{ 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 { + pub fn approved_spender(&self, token_id: TokenId) -> Option
{ 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); + } } } diff --git a/contracts/origin-staking/lib.rs b/contracts/origin-staking/lib.rs index 25d5651..3e49edf 100644 --- a/contracts/origin-staking/lib.rs +++ b/contracts/origin-staking/lib.rs @@ -30,7 +30,7 @@ //! //! ## Lifecycle canonical //! -//! ``` +//! ```text //! stake() ──► Active ──unstake_request──► Unbonding ──after lockup──► Withdrawable //! │ │ //! ▼ └──withdraw()──► Burned/Returned @@ -43,12 +43,13 @@ mod origin_staking { use ink::prelude::vec::Vec; use ink::storage::Mapping; + use ink::U256; pub type StakeId = u32; pub type BasisPoints = u16; #[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 StakeKind { Validator, Beacon, @@ -56,7 +57,7 @@ mod origin_staking { } #[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 StakeState { Active, Unbonding, @@ -65,9 +66,9 @@ mod origin_staking { } #[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 Stake { - pub staker: AccountId, + pub staker: Address, pub kind: StakeKind, pub amount: Balance, pub state: StakeState, @@ -79,8 +80,8 @@ mod origin_staking { #[ink(storage)] pub struct OriginStaking { - owner: AccountId, - treasury: AccountId, + owner: Address, + treasury: Address, // ── Config per StakeKind ────────────────────────────── min_validator_stake: Balance, @@ -96,12 +97,12 @@ mod origin_staking { reputation_reward_rate_bps: BasisPoints, // ── Slashing authority (Tribunal Pillar 6) ──────────── - slashing_authority: AccountId, + slashing_authority: Address, // ── State ───────────────────────────────────────────── total_stakes: u32, stakes: Mapping, - staker_stakes: Mapping>, + staker_stakes: Mapping>, total_staked_validator: Balance, total_staked_beacon: Balance, @@ -113,7 +114,7 @@ mod origin_staking { #[ink(topic)] stake_id: StakeId, #[ink(topic)] - staker: AccountId, + staker: Address, kind: StakeKind, amount: Balance, } @@ -158,11 +159,22 @@ mod origin_staking { pub type Result = core::result::Result; + /// 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 OriginStaking { #[ink(constructor)] pub fn new( - treasury: AccountId, - slashing_authority: AccountId, + treasury: Address, + slashing_authority: Address, min_validator_stake: Balance, min_beacon_stake: Balance, min_reputation_stake: Balance, @@ -221,7 +233,7 @@ mod origin_staking { pub fn stake(&self, id: StakeId) -> Option { self.stakes.get(id) } #[ink(message)] - pub fn stakes_of(&self, staker: AccountId) -> Vec { + pub fn stakes_of(&self, staker: Address) -> Vec { self.staker_stakes.get(staker).unwrap_or_default() } @@ -258,7 +270,7 @@ mod origin_staking { /// Verifies min_stake per kind canonical. #[ink(message, payable)] pub fn stake_tokens(&mut self, kind: StakeKind) -> Result { - let value = self.env().transferred_value(); + let value = u256_to_balance(self.env().transferred_value()); let min = self.min_stake(kind); if value < min { return Err(Error::InsufficientStake); @@ -337,21 +349,34 @@ mod origin_staking { } let to_return = stake.amount.saturating_add(stake.rewards_accumulated); - self.env().transfer(stake.staker, to_return).map_err(|_| Error::TransferFailed)?; + let staker = stake.staker; + let principal = stake.amount; + let rewards = stake.rewards_accumulated; + let kind = stake.kind; - // Update totals - match stake.kind { - StakeKind::Validator => self.total_staked_validator = self.total_staked_validator.saturating_sub(stake.amount), - StakeKind::Beacon => self.total_staked_beacon = self.total_staked_beacon.saturating_sub(stake.amount), - StakeKind::Reputation => self.total_staked_reputation = self.total_staked_reputation.saturating_sub(stake.amount), + // FIX-1 (V1-C1) CEI: muta TODO el estado ANTES del transfer, para que + // una reentrada vea state=Withdrawable y falle el check de InvalidState + // (cierra el drenado por re-entrada en `withdraw`). + // + // ── EFFECTS ── + match kind { + StakeKind::Validator => self.total_staked_validator = self.total_staked_validator.saturating_sub(principal), + StakeKind::Beacon => self.total_staked_beacon = self.total_staked_beacon.saturating_sub(principal), + StakeKind::Reputation => self.total_staked_reputation = self.total_staked_reputation.saturating_sub(principal), } - stake.state = StakeState::Withdrawable; self.stakes.insert(stake_id, &stake); + + // ── INTERACTIONS ── + self.env() + .transfer(staker, U256::from(to_return)) + .map_err(|_| Error::TransferFailed)?; + + // ── EVENTS ── self.env().emit_event(Withdrawn { stake_id, - amount: stake.amount, - rewards: stake.rewards_accumulated, + amount: principal, + rewards, }); Ok(()) @@ -377,24 +402,33 @@ mod origin_staking { let treasury_share = amount / 2; let burn = amount.saturating_sub(treasury_share); - self.env().transfer(self.treasury, treasury_share).map_err(|_| Error::TransferFailed)?; - // Burn portion canonical — transfer to zero address (interim Slice 3.5 SCAFFOLD) - let zero = AccountId::from([0u8; 32]); - self.env().transfer(zero, burn).map_err(|_| Error::TransferFailed)?; - + // FIX-1 (V1-C1) CEI + (V1-C4) burn real: + // · TODO el estado muta ANTES del transfer. + // · El "burn" NO se transfiere a la zero-address (que no reduce + // emisión y ensucia la cuenta cero). Se RETIENE en el contrato + // (fuera de circulación), a barrer por el primitivo de burn del + // runtime — una sola semántica de burn, no dos. + // + // ── EFFECTS ── + let kind = stake.kind; stake.amount = stake.amount.saturating_sub(amount); stake.slashed_amount = stake.slashed_amount.saturating_add(amount); if stake.amount == 0 { stake.state = StakeState::Slashed; } self.stakes.insert(stake_id, &stake); - - match stake.kind { + match kind { StakeKind::Validator => self.total_staked_validator = self.total_staked_validator.saturating_sub(amount), StakeKind::Beacon => self.total_staked_beacon = self.total_staked_beacon.saturating_sub(amount), StakeKind::Reputation => self.total_staked_reputation = self.total_staked_reputation.saturating_sub(amount), } + // ── INTERACTIONS (solo treasury_share sale; el burn queda retenido) ── + self.env() + .transfer(self.treasury, U256::from(treasury_share)) + .map_err(|_| Error::TransferFailed)?; + + // ── EVENTS ── self.env().emit_event(Slashed { stake_id, amount_slashed: amount, @@ -431,5 +465,17 @@ mod origin_staking { assert_ne!(StakeKind::Validator, StakeKind::Beacon); assert_ne!(StakeKind::Beacon, StakeKind::Reputation); } + + /// FIX-1 (V1-C1) — guarda: las acciones que mueven fondos sobre un stake + /// inexistente cortan en el check (StakeNotFound) ANTES de cualquier + /// transfer. Los tests E2E de reentrancy/double-spend reales son + /// node-gated (cargo-contract v6 + nodo pallet-revive) — ver nota en + /// `origin-escrow`. + #[ink::test] + fn fund_moving_actions_blocked_for_unknown_stake() { + let mut c = OriginStaking::default_chn3(); + assert_eq!(c.withdraw(999), Err(Error::StakeNotFound)); + assert_eq!(c.unbond_request(999), Err(Error::StakeNotFound)); + } } }