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:
davidherranandClaude Opus 4.8 committed 2026-06-09 17:45:02 -05:00
1 parent a15a65966f
commit 6663dbdc0f
5 files changed
+325 -149

No files matched your search

+76 -30
View File
@@ -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<StakeId, Stake>,
staker_stakes: Mapping<AccountId, Vec<StakeId>>,
staker_stakes: Mapping<Address, Vec<StakeId>>,
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<T> = core::result::Result<T, Error>;
/// Convierte un valor `U256` del entorno pallet-revive a `Balance` (u128)
/// de forma saturante. ⚠️ Ver nota de correctitud económica native↔eth en
/// `origin-escrow` — pendiente validación E2E en nodo pallet-revive (VPS).
fn u256_to_balance(v: U256) -> Balance {
if v > U256::from(Balance::MAX) {
Balance::MAX
} else {
v.as_u128()
}
}
impl 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<Stake> { self.stakes.get(id) }
#[ink(message)]
pub fn stakes_of(&self, staker: AccountId) -> Vec<StakeId> {
pub fn stakes_of(&self, staker: Address) -> Vec<StakeId> {
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<StakeId> {
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));
}
}
}