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
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user