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
@@ -3,6 +3,12 @@
|
|||||||
Cargo.lock.bak
|
Cargo.lock.bak
|
||||||
**/*.rs.bk
|
**/*.rs.bk
|
||||||
|
|
||||||
|
# Standalone ink! v6 contract builds (excluidos del workspace Substrate;
|
||||||
|
# se compilan aparte con `cargo +1.90 test --manifest-path contracts/<c>/Cargo.toml`).
|
||||||
|
# Sus target/ + Cargo.lock NO van al repo — son templates, no la binaria de la cadena.
|
||||||
|
contracts/*/target/
|
||||||
|
contracts/*/Cargo.lock
|
||||||
|
|
||||||
# macOS
|
# macOS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
._.DS_Store
|
._.DS_Store
|
||||||
|
|||||||
+11
@@ -13,6 +13,17 @@ members = [
|
|||||||
"crates/origin-chain-node",
|
"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/<c>/Cargo.toml
|
||||||
|
exclude = [
|
||||||
|
"contracts/origin-nft-mint",
|
||||||
|
"contracts/origin-escrow",
|
||||||
|
"contracts/origin-credential-mint",
|
||||||
|
"contracts/origin-staking",
|
||||||
|
"contracts/origin-mdk-reconciliation",
|
||||||
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.0.1"
|
version = "0.0.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
+139
-59
@@ -29,7 +29,7 @@
|
|||||||
//!
|
//!
|
||||||
//! ## Lifecycle canonical (5 states)
|
//! ## Lifecycle canonical (5 states)
|
||||||
//!
|
//!
|
||||||
//! ```
|
//! ```text
|
||||||
//! Created ──► Active ──► Releasing ──► Completed
|
//! Created ──► Active ──► Releasing ──► Completed
|
||||||
//! │ │ │
|
//! │ │ │
|
||||||
//! │ ▼ ▼
|
//! │ ▼ ▼
|
||||||
@@ -58,8 +58,8 @@
|
|||||||
|
|
||||||
#[ink::contract]
|
#[ink::contract]
|
||||||
mod origin_escrow {
|
mod origin_escrow {
|
||||||
use ink::prelude::vec::Vec;
|
|
||||||
use ink::storage::Mapping;
|
use ink::storage::Mapping;
|
||||||
|
use ink::U256;
|
||||||
|
|
||||||
pub type MilestoneId = u32;
|
pub type MilestoneId = u32;
|
||||||
pub type BasisPoints = u16;
|
pub type BasisPoints = u16;
|
||||||
@@ -70,7 +70,7 @@ mod origin_escrow {
|
|||||||
// ─────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, scale::Encode, scale::Decode)]
|
#[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 {
|
pub enum EscrowState {
|
||||||
Created,
|
Created,
|
||||||
Active,
|
Active,
|
||||||
@@ -81,7 +81,7 @@ mod origin_escrow {
|
|||||||
|
|
||||||
/// Milestone canonical structure — define partial releases del total_amount.
|
/// Milestone canonical structure — define partial releases del total_amount.
|
||||||
#[derive(Debug, Clone, scale::Encode, scale::Decode)]
|
#[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 struct Milestone {
|
||||||
pub amount: Balance,
|
pub amount: Balance,
|
||||||
pub due_block: BlockNumber,
|
pub due_block: BlockNumber,
|
||||||
@@ -107,10 +107,10 @@ mod origin_escrow {
|
|||||||
#[ink(storage)]
|
#[ink(storage)]
|
||||||
pub struct OriginEscrow {
|
pub struct OriginEscrow {
|
||||||
// ── Parties + config ─────────────────────────────────────
|
// ── Parties + config ─────────────────────────────────────
|
||||||
depositor: AccountId,
|
depositor: Address,
|
||||||
beneficiary: AccountId,
|
beneficiary: Address,
|
||||||
arbiter: AccountId,
|
arbiter: Address,
|
||||||
treasury: AccountId,
|
treasury: Address,
|
||||||
terms_hash: TermsHash,
|
terms_hash: TermsHash,
|
||||||
total_amount: Balance,
|
total_amount: Balance,
|
||||||
escrow_fee_bps: BasisPoints,
|
escrow_fee_bps: BasisPoints,
|
||||||
@@ -123,7 +123,7 @@ mod origin_escrow {
|
|||||||
deposited_amount: Balance,
|
deposited_amount: Balance,
|
||||||
released_amount: Balance,
|
released_amount: Balance,
|
||||||
dispute_raised_at: Option<BlockNumber>,
|
dispute_raised_at: Option<BlockNumber>,
|
||||||
dispute_raised_by: Option<AccountId>,
|
dispute_raised_by: Option<Address>,
|
||||||
last_release_block: Option<BlockNumber>,
|
last_release_block: Option<BlockNumber>,
|
||||||
|
|
||||||
// ── Milestones canonical (partial releases) ─────────────
|
// ── Milestones canonical (partial releases) ─────────────
|
||||||
@@ -138,7 +138,7 @@ mod origin_escrow {
|
|||||||
#[ink(event)]
|
#[ink(event)]
|
||||||
pub struct Deposited {
|
pub struct Deposited {
|
||||||
#[ink(topic)]
|
#[ink(topic)]
|
||||||
depositor: AccountId,
|
depositor: Address,
|
||||||
amount: Balance,
|
amount: Balance,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,15 +152,15 @@ mod origin_escrow {
|
|||||||
#[ink(event)]
|
#[ink(event)]
|
||||||
pub struct DisputeRaised {
|
pub struct DisputeRaised {
|
||||||
#[ink(topic)]
|
#[ink(topic)]
|
||||||
raised_by: AccountId,
|
raised_by: Address,
|
||||||
#[ink(topic)]
|
#[ink(topic)]
|
||||||
arbiter: AccountId,
|
arbiter: Address,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[ink(event)]
|
#[ink(event)]
|
||||||
pub struct DisputeResolved {
|
pub struct DisputeResolved {
|
||||||
#[ink(topic)]
|
#[ink(topic)]
|
||||||
arbiter: AccountId,
|
arbiter: Address,
|
||||||
resolution: Resolution,
|
resolution: Resolution,
|
||||||
depositor_refund: Balance,
|
depositor_refund: Balance,
|
||||||
beneficiary_release: Balance,
|
beneficiary_release: Balance,
|
||||||
@@ -196,16 +196,32 @@ mod origin_escrow {
|
|||||||
|
|
||||||
pub type Result<T> = core::result::Result<T, Error>;
|
pub type Result<T> = core::result::Result<T, Error>;
|
||||||
|
|
||||||
|
/// 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 {
|
impl OriginEscrow {
|
||||||
/// Constructor canonical con full parameters.
|
/// Constructor canonical con full parameters.
|
||||||
///
|
///
|
||||||
/// Invariant compile-detect: escrow_fee_bps ≤ 500 (5% max ceiling BM V2 §5.3).
|
/// Invariant compile-detect: escrow_fee_bps ≤ 500 (5% max ceiling BM V2 §5.3).
|
||||||
#[ink(constructor)]
|
#[ink(constructor)]
|
||||||
pub fn new(
|
pub fn new(
|
||||||
depositor: AccountId,
|
depositor: Address,
|
||||||
beneficiary: AccountId,
|
beneficiary: Address,
|
||||||
arbiter: AccountId,
|
arbiter: Address,
|
||||||
treasury: AccountId,
|
treasury: Address,
|
||||||
terms_hash: TermsHash,
|
terms_hash: TermsHash,
|
||||||
total_amount: Balance,
|
total_amount: Balance,
|
||||||
escrow_fee_bps: BasisPoints,
|
escrow_fee_bps: BasisPoints,
|
||||||
@@ -251,13 +267,13 @@ mod origin_escrow {
|
|||||||
pub fn state(&self) -> EscrowState { self.state }
|
pub fn state(&self) -> EscrowState { self.state }
|
||||||
|
|
||||||
#[ink(message)]
|
#[ink(message)]
|
||||||
pub fn depositor(&self) -> AccountId { self.depositor }
|
pub fn depositor(&self) -> Address { self.depositor }
|
||||||
|
|
||||||
#[ink(message)]
|
#[ink(message)]
|
||||||
pub fn beneficiary(&self) -> AccountId { self.beneficiary }
|
pub fn beneficiary(&self) -> Address { self.beneficiary }
|
||||||
|
|
||||||
#[ink(message)]
|
#[ink(message)]
|
||||||
pub fn arbiter(&self) -> AccountId { self.arbiter }
|
pub fn arbiter(&self) -> Address { self.arbiter }
|
||||||
|
|
||||||
#[ink(message)]
|
#[ink(message)]
|
||||||
pub fn terms_hash(&self) -> TermsHash { self.terms_hash }
|
pub fn terms_hash(&self) -> TermsHash { self.terms_hash }
|
||||||
@@ -291,7 +307,7 @@ mod origin_escrow {
|
|||||||
if self.env().caller() != self.depositor {
|
if self.env().caller() != self.depositor {
|
||||||
return Err(Error::UnauthorizedCaller);
|
return Err(Error::UnauthorizedCaller);
|
||||||
}
|
}
|
||||||
let value = self.env().transferred_value();
|
let value = u256_to_balance(self.env().transferred_value());
|
||||||
if value < self.total_amount {
|
if value < self.total_amount {
|
||||||
return Err(Error::InsufficientDeposit);
|
return Err(Error::InsufficientDeposit);
|
||||||
}
|
}
|
||||||
@@ -354,30 +370,41 @@ mod origin_escrow {
|
|||||||
return Err(Error::EarlyReleaseProhibited);
|
return Err(Error::EarlyReleaseProhibited);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Release canonical: amount - escrow_fee → beneficiary
|
// Release: amount - escrow_fee → beneficiary.
|
||||||
let fee = milestone.amount
|
let fee = milestone.amount
|
||||||
.saturating_mul(self.escrow_fee_bps as Balance)
|
.saturating_mul(self.escrow_fee_bps as Balance)
|
||||||
.saturating_div(10_000);
|
.saturating_div(10_000);
|
||||||
let net = milestone.amount.saturating_sub(fee);
|
let net = milestone.amount.saturating_sub(fee);
|
||||||
|
|
||||||
self.env().transfer(self.beneficiary, net).map_err(|_| Error::TransferFailed)?;
|
// FIX-1 (V1-C1) Checks-Effects-Interactions: TODO el storage muta
|
||||||
self.env().transfer(self.treasury, fee).map_err(|_| Error::TransferFailed)?;
|
// 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 = true;
|
||||||
milestone.completed_at = Some(now);
|
milestone.completed_at = Some(now);
|
||||||
self.milestones.insert(milestone_id, &milestone);
|
self.milestones.insert(milestone_id, &milestone);
|
||||||
|
|
||||||
self.released_amount = self.released_amount.saturating_add(milestone.amount);
|
self.released_amount = self.released_amount.saturating_add(milestone.amount);
|
||||||
self.last_release_block = Some(now);
|
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 {
|
self.env().emit_event(MilestoneCompleted {
|
||||||
milestone_id,
|
milestone_id,
|
||||||
amount_released: net,
|
amount_released: net,
|
||||||
});
|
});
|
||||||
|
if completed {
|
||||||
// Auto-transition Completed if all milestones canonical released
|
|
||||||
if self.released_amount >= self.total_amount {
|
|
||||||
self.state = EscrowState::Completed;
|
|
||||||
self.env().emit_event(EscrowCompleted {
|
self.env().emit_event(EscrowCompleted {
|
||||||
total_released: self.released_amount,
|
total_released: self.released_amount,
|
||||||
total_refunded: 0,
|
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 {
|
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 {
|
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 {
|
self.env().emit_event(DisputeResolved {
|
||||||
arbiter: self.arbiter,
|
arbiter: self.arbiter,
|
||||||
resolution,
|
resolution,
|
||||||
@@ -476,53 +516,93 @@ mod origin_escrow {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[ink::test]
|
/// Contrato válido en estado `Created` (default test caller = owner).
|
||||||
fn constructor_canonical_invariants() {
|
fn mk() -> OriginEscrow {
|
||||||
let result = OriginEscrow::new(
|
OriginEscrow::new(
|
||||||
AccountId::from([1u8; 32]),
|
Address::from([1u8; 20]),
|
||||||
AccountId::from([2u8; 32]),
|
Address::from([2u8; 20]),
|
||||||
AccountId::from([3u8; 32]),
|
Address::from([3u8; 20]),
|
||||||
AccountId::from([4u8; 32]),
|
Address::from([4u8; 20]),
|
||||||
[1u8; 32],
|
[1u8; 32],
|
||||||
1_000_000_000_000_000_000,
|
1_000_000_000_000_000_000,
|
||||||
150, // 1.5% canonical BM V2
|
150, // 1.5% BM V2
|
||||||
100,
|
100,
|
||||||
1000,
|
1000,
|
||||||
false,
|
false,
|
||||||
);
|
)
|
||||||
assert!(result.is_ok());
|
.expect("valid config")
|
||||||
let escrow = result.unwrap();
|
|
||||||
assert_eq!(escrow.state(), EscrowState::Created);
|
|
||||||
assert_eq!(escrow.deposited_amount(), 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[ink::test]
|
#[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(
|
let result = OriginEscrow::new(
|
||||||
AccountId::from([1u8; 32]),
|
Address::from([1u8; 20]),
|
||||||
AccountId::from([2u8; 32]),
|
Address::from([2u8; 20]),
|
||||||
AccountId::from([3u8; 32]),
|
Address::from([3u8; 20]),
|
||||||
AccountId::from([4u8; 32]),
|
Address::from([4u8; 20]),
|
||||||
[1u8; 32],
|
[1u8; 32],
|
||||||
1_000_000_000_000_000_000,
|
1_000_000_000_000_000_000,
|
||||||
600, // 6% — exceeds 5% max BM V2 ceiling
|
600, // 6% — exceeds 5% max BM V2 ceiling
|
||||||
100, 1000, false,
|
100,
|
||||||
|
1000,
|
||||||
|
false,
|
||||||
);
|
);
|
||||||
assert_eq!(result.err(), Some(Error::InvalidConfiguration));
|
assert_eq!(result.err(), Some(Error::InvalidConfiguration));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[ink::test]
|
#[ink::test]
|
||||||
fn same_party_depositor_beneficiary_rejected() {
|
fn same_party_depositor_beneficiary_rejected() {
|
||||||
let same = AccountId::from([1u8; 32]);
|
let same = Address::from([1u8; 20]);
|
||||||
let result = OriginEscrow::new(
|
let result = OriginEscrow::new(
|
||||||
same, same,
|
same,
|
||||||
AccountId::from([3u8; 32]),
|
same,
|
||||||
AccountId::from([4u8; 32]),
|
Address::from([3u8; 20]),
|
||||||
|
Address::from([4u8; 20]),
|
||||||
[1u8; 32],
|
[1u8; 32],
|
||||||
1_000_000_000_000_000_000,
|
1_000_000_000_000_000_000,
|
||||||
150, 100, 1000, false,
|
150,
|
||||||
|
100,
|
||||||
|
1000,
|
||||||
|
false,
|
||||||
);
|
);
|
||||||
assert_eq!(result.err(), Some(Error::InvalidConfiguration));
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,8 +52,8 @@
|
|||||||
|
|
||||||
#[ink::contract]
|
#[ink::contract]
|
||||||
mod origin_nft_mint {
|
mod origin_nft_mint {
|
||||||
use ink::prelude::vec::Vec;
|
|
||||||
use ink::storage::Mapping;
|
use ink::storage::Mapping;
|
||||||
|
use ink::U256;
|
||||||
|
|
||||||
/// Token identifier canonical — incrementing u32 per mint.
|
/// Token identifier canonical — incrementing u32 per mint.
|
||||||
/// u32 max 4B tokens canonical safety margin para supply mass NFTs ecosystem.
|
/// u32 max 4B tokens canonical safety margin para supply mass NFTs ecosystem.
|
||||||
@@ -76,11 +76,11 @@ mod origin_nft_mint {
|
|||||||
// ── Identity + ownership ──────────────────────────────────
|
// ── Identity + ownership ──────────────────────────────────
|
||||||
/// Contract owner canonical — Foundation address o creator EOA.
|
/// Contract owner canonical — Foundation address o creator EOA.
|
||||||
/// Authority canonical para set_mint_fee + set_royalty + emergency_pause.
|
/// Authority canonical para set_mint_fee + set_royalty + emergency_pause.
|
||||||
owner: AccountId,
|
owner: Address,
|
||||||
|
|
||||||
/// Treasury address canonical — recibe 9% mint + burn share canonical
|
/// Treasury address canonical — recibe 9% mint + burn share canonical
|
||||||
/// per BM V2 ratificado 2026-05-18.
|
/// per BM V2 ratificado 2026-05-18.
|
||||||
treasury: AccountId,
|
treasury: Address,
|
||||||
|
|
||||||
// ── Configuration parameters (Contract Atelier visual UI) ──
|
// ── Configuration parameters (Contract Atelier visual UI) ──
|
||||||
/// Mint fee base canonical — modulated por HardwareTier vía OitFeeSchedule
|
/// Mint fee base canonical — modulated por HardwareTier vía OitFeeSchedule
|
||||||
@@ -117,11 +117,11 @@ mod origin_nft_mint {
|
|||||||
total_supply: u32,
|
total_supply: u32,
|
||||||
|
|
||||||
/// Token → owner mapping canonical (PSP34-like standard).
|
/// 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).
|
/// Token → creator mapping canonical (original minter, immutable post-mint).
|
||||||
/// Usado para royalty payments canonical en secondary transfers.
|
/// 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
|
/// Token → asset hash mapping canonical (BLAKE3 content addressing per
|
||||||
/// Inmutable #26).
|
/// Inmutable #26).
|
||||||
@@ -131,10 +131,10 @@ mod origin_nft_mint {
|
|||||||
token_mint_block: Mapping<TokenId, BlockNumber>,
|
token_mint_block: Mapping<TokenId, BlockNumber>,
|
||||||
|
|
||||||
/// Owner → token count mapping canonical (balance_of canonical).
|
/// 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 spender mapping canonical (approve/transfer_from pattern).
|
||||||
token_approved: Mapping<TokenId, AccountId>,
|
token_approved: Mapping<TokenId, Address>,
|
||||||
|
|
||||||
// ── Emergency controls ─────────────────────────────────────
|
// ── Emergency controls ─────────────────────────────────────
|
||||||
/// Paused state canonical — owner-only emergency_pause + governance unpause.
|
/// Paused state canonical — owner-only emergency_pause + governance unpause.
|
||||||
@@ -151,7 +151,7 @@ mod origin_nft_mint {
|
|||||||
#[ink(topic)]
|
#[ink(topic)]
|
||||||
token_id: TokenId,
|
token_id: TokenId,
|
||||||
#[ink(topic)]
|
#[ink(topic)]
|
||||||
creator: AccountId,
|
creator: Address,
|
||||||
asset_hash: AssetHash,
|
asset_hash: AssetHash,
|
||||||
mint_fee_paid: Balance,
|
mint_fee_paid: Balance,
|
||||||
creator_share: Balance,
|
creator_share: Balance,
|
||||||
@@ -165,9 +165,9 @@ mod origin_nft_mint {
|
|||||||
#[ink(topic)]
|
#[ink(topic)]
|
||||||
token_id: TokenId,
|
token_id: TokenId,
|
||||||
#[ink(topic)]
|
#[ink(topic)]
|
||||||
from: AccountId,
|
from: Address,
|
||||||
#[ink(topic)]
|
#[ink(topic)]
|
||||||
to: AccountId,
|
to: Address,
|
||||||
sale_price: Balance,
|
sale_price: Balance,
|
||||||
royalty_paid: Balance,
|
royalty_paid: Balance,
|
||||||
}
|
}
|
||||||
@@ -178,16 +178,16 @@ mod origin_nft_mint {
|
|||||||
#[ink(topic)]
|
#[ink(topic)]
|
||||||
token_id: TokenId,
|
token_id: TokenId,
|
||||||
#[ink(topic)]
|
#[ink(topic)]
|
||||||
owner: AccountId,
|
owner: Address,
|
||||||
#[ink(topic)]
|
#[ink(topic)]
|
||||||
spender: AccountId,
|
spender: Address,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Emitted on canonical emergency_pause toggle.
|
/// Emitted on canonical emergency_pause toggle.
|
||||||
#[ink(event)]
|
#[ink(event)]
|
||||||
pub struct PauseToggled {
|
pub struct PauseToggled {
|
||||||
#[ink(topic)]
|
#[ink(topic)]
|
||||||
actor: AccountId,
|
actor: Address,
|
||||||
paused: bool,
|
paused: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,6 +226,17 @@ mod origin_nft_mint {
|
|||||||
|
|
||||||
pub type Result<T> = core::result::Result<T, Error>;
|
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
|
// Constructor canonical
|
||||||
// ─────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────
|
||||||
@@ -248,7 +259,7 @@ mod origin_nft_mint {
|
|||||||
/// MUST == 10_000 (100%) — enforced via constructor assertion.
|
/// MUST == 10_000 (100%) — enforced via constructor assertion.
|
||||||
#[ink(constructor)]
|
#[ink(constructor)]
|
||||||
pub fn new(
|
pub fn new(
|
||||||
treasury: AccountId,
|
treasury: Address,
|
||||||
mint_fee_base: Balance,
|
mint_fee_base: Balance,
|
||||||
creator_share_bps: BasisPoints,
|
creator_share_bps: BasisPoints,
|
||||||
treasury_share_bps: BasisPoints,
|
treasury_share_bps: BasisPoints,
|
||||||
@@ -320,13 +331,13 @@ mod origin_nft_mint {
|
|||||||
|
|
||||||
/// Owner canonical del contract.
|
/// Owner canonical del contract.
|
||||||
#[ink(message)]
|
#[ink(message)]
|
||||||
pub fn owner(&self) -> AccountId {
|
pub fn owner(&self) -> Address {
|
||||||
self.owner
|
self.owner
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Treasury address canonical.
|
/// Treasury address canonical.
|
||||||
#[ink(message)]
|
#[ink(message)]
|
||||||
pub fn treasury(&self) -> AccountId {
|
pub fn treasury(&self) -> Address {
|
||||||
self.treasury
|
self.treasury
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,13 +361,13 @@ mod origin_nft_mint {
|
|||||||
|
|
||||||
/// Owner canonical del token específico.
|
/// Owner canonical del token específico.
|
||||||
#[ink(message)]
|
#[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)
|
self.token_owner.get(token_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Original creator canonical del token (immutable post-mint).
|
/// Original creator canonical del token (immutable post-mint).
|
||||||
#[ink(message)]
|
#[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)
|
self.token_creator.get(token_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,13 +379,13 @@ mod origin_nft_mint {
|
|||||||
|
|
||||||
/// Balance (token count) canonical del account.
|
/// Balance (token count) canonical del account.
|
||||||
#[ink(message)]
|
#[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)
|
self.owner_balance.get(account).unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Approved spender canonical del token específico.
|
/// Approved spender canonical del token específico.
|
||||||
#[ink(message)]
|
#[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)
|
self.token_approved.get(token_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,7 +423,7 @@ mod origin_nft_mint {
|
|||||||
if asset_hash == [0u8; 32] {
|
if asset_hash == [0u8; 32] {
|
||||||
return Err(Error::InvalidAssetHash);
|
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 {
|
if payment < self.mint_fee_base {
|
||||||
return Err(Error::InsufficientPayment);
|
return Err(Error::InsufficientPayment);
|
||||||
}
|
}
|
||||||
@@ -425,30 +436,20 @@ mod origin_nft_mint {
|
|||||||
.saturating_mul(self.treasury_share_bps as Balance)
|
.saturating_mul(self.treasury_share_bps as Balance)
|
||||||
.saturating_div(10_000);
|
.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
|
let treasury_burn = treasury_share
|
||||||
.saturating_mul(self.treasury_burn_bps as Balance)
|
.saturating_mul(self.treasury_burn_bps as Balance)
|
||||||
.saturating_div(10_000);
|
.saturating_div(10_000);
|
||||||
let treasury_net = treasury_share.saturating_sub(treasury_burn);
|
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();
|
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;
|
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_owner.insert(token_id, &creator);
|
||||||
self.token_creator.insert(token_id, &creator);
|
self.token_creator.insert(token_id, &creator);
|
||||||
self.token_asset_hash.insert(token_id, &asset_hash);
|
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.owner_balance.insert(creator, &new_balance);
|
||||||
self.total_supply = self.total_supply.saturating_add(1);
|
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 {
|
self.env().emit_event(Minted {
|
||||||
token_id,
|
token_id,
|
||||||
creator,
|
creator,
|
||||||
@@ -490,7 +504,7 @@ mod origin_nft_mint {
|
|||||||
/// 9. Clear approval canonical post-transfer
|
/// 9. Clear approval canonical post-transfer
|
||||||
/// 10. Emit Transferred event canonical
|
/// 10. Emit Transferred event canonical
|
||||||
#[ink(message, payable)]
|
#[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 {
|
if self.paused {
|
||||||
return Err(Error::ContractPaused);
|
return Err(Error::ContractPaused);
|
||||||
}
|
}
|
||||||
@@ -514,29 +528,22 @@ mod origin_nft_mint {
|
|||||||
return Err(Error::TokenLocked);
|
return Err(Error::TokenLocked);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Royalty canonical: pay 5% al original creator (secondary sale).
|
// Royalty: pay 5% al original creator (secondary sale).
|
||||||
let sale_price = self.env().transferred_value();
|
let sale_price = u256_to_balance(self.env().transferred_value());
|
||||||
let royalty = sale_price
|
let royalty = sale_price
|
||||||
.saturating_mul(self.transfer_royalty_bps as Balance)
|
.saturating_mul(self.transfer_royalty_bps as Balance)
|
||||||
.saturating_div(10_000);
|
.saturating_div(10_000);
|
||||||
if royalty > 0 {
|
let original_creator = self
|
||||||
let original_creator = self
|
.token_creator
|
||||||
.token_creator
|
.get(token_id)
|
||||||
.get(token_id)
|
.ok_or(Error::TokenNotFound)?;
|
||||||
.ok_or(Error::TokenNotFound)?;
|
|
||||||
self.env()
|
|
||||||
.transfer(original_creator, royalty)
|
|
||||||
.map_err(|_| Error::InsufficientPayment)?;
|
|
||||||
}
|
|
||||||
// Remaining canonical sale_price → seller (from).
|
|
||||||
let to_seller = sale_price.saturating_sub(royalty);
|
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);
|
self.token_owner.insert(token_id, &to);
|
||||||
let from_balance = self.balance_of(from).saturating_sub(1);
|
let from_balance = self.balance_of(from).saturating_sub(1);
|
||||||
self.owner_balance.insert(from, &from_balance);
|
self.owner_balance.insert(from, &from_balance);
|
||||||
@@ -544,6 +551,19 @@ mod origin_nft_mint {
|
|||||||
self.owner_balance.insert(to, &to_balance);
|
self.owner_balance.insert(to, &to_balance);
|
||||||
self.token_approved.remove(token_id); // clear approval post-transfer
|
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 {
|
self.env().emit_event(Transferred {
|
||||||
token_id,
|
token_id,
|
||||||
from,
|
from,
|
||||||
@@ -558,7 +578,7 @@ mod origin_nft_mint {
|
|||||||
/// Approve canonical spender para token específico.
|
/// Approve canonical spender para token específico.
|
||||||
/// Standard PSP34-like approval pattern.
|
/// Standard PSP34-like approval pattern.
|
||||||
#[ink(message)]
|
#[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 {
|
if self.paused {
|
||||||
return Err(Error::ContractPaused);
|
return Err(Error::ContractPaused);
|
||||||
}
|
}
|
||||||
@@ -642,7 +662,7 @@ mod origin_nft_mint {
|
|||||||
#[ink::test]
|
#[ink::test]
|
||||||
fn invalid_share_split_rejected_canonical() {
|
fn invalid_share_split_rejected_canonical() {
|
||||||
let result = OriginNftMint::new(
|
let result = OriginNftMint::new(
|
||||||
AccountId::from([0u8; 32]),
|
Address::from([0u8; 20]),
|
||||||
1_000_000_000_000_000_000,
|
1_000_000_000_000_000_000,
|
||||||
9_000, // 90% — invalid sum
|
9_000, // 90% — invalid sum
|
||||||
500, // 5%
|
500, // 5%
|
||||||
@@ -659,7 +679,7 @@ mod origin_nft_mint {
|
|||||||
#[ink::test]
|
#[ink::test]
|
||||||
fn excessive_royalty_rejected_canonical() {
|
fn excessive_royalty_rejected_canonical() {
|
||||||
let result = OriginNftMint::new(
|
let result = OriginNftMint::new(
|
||||||
AccountId::from([0u8; 32]),
|
Address::from([0u8; 20]),
|
||||||
1_000_000_000_000_000_000,
|
1_000_000_000_000_000_000,
|
||||||
9_100,
|
9_100,
|
||||||
900,
|
900,
|
||||||
@@ -671,5 +691,18 @@ mod origin_nft_mint {
|
|||||||
);
|
);
|
||||||
assert_eq!(result.err(), Some(Error::InvalidConfiguration));
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
//!
|
//!
|
||||||
//! ## Lifecycle canonical
|
//! ## Lifecycle canonical
|
||||||
//!
|
//!
|
||||||
//! ```
|
//! ```text
|
||||||
//! stake() ──► Active ──unstake_request──► Unbonding ──after lockup──► Withdrawable
|
//! stake() ──► Active ──unstake_request──► Unbonding ──after lockup──► Withdrawable
|
||||||
//! │ │
|
//! │ │
|
||||||
//! ▼ └──withdraw()──► Burned/Returned
|
//! ▼ └──withdraw()──► Burned/Returned
|
||||||
@@ -43,12 +43,13 @@
|
|||||||
mod origin_staking {
|
mod origin_staking {
|
||||||
use ink::prelude::vec::Vec;
|
use ink::prelude::vec::Vec;
|
||||||
use ink::storage::Mapping;
|
use ink::storage::Mapping;
|
||||||
|
use ink::U256;
|
||||||
|
|
||||||
pub type StakeId = u32;
|
pub type StakeId = u32;
|
||||||
pub type BasisPoints = u16;
|
pub type BasisPoints = u16;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, scale::Encode, scale::Decode)]
|
#[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 {
|
pub enum StakeKind {
|
||||||
Validator,
|
Validator,
|
||||||
Beacon,
|
Beacon,
|
||||||
@@ -56,7 +57,7 @@ mod origin_staking {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, scale::Encode, scale::Decode)]
|
#[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 {
|
pub enum StakeState {
|
||||||
Active,
|
Active,
|
||||||
Unbonding,
|
Unbonding,
|
||||||
@@ -65,9 +66,9 @@ mod origin_staking {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, scale::Encode, scale::Decode)]
|
#[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 struct Stake {
|
||||||
pub staker: AccountId,
|
pub staker: Address,
|
||||||
pub kind: StakeKind,
|
pub kind: StakeKind,
|
||||||
pub amount: Balance,
|
pub amount: Balance,
|
||||||
pub state: StakeState,
|
pub state: StakeState,
|
||||||
@@ -79,8 +80,8 @@ mod origin_staking {
|
|||||||
|
|
||||||
#[ink(storage)]
|
#[ink(storage)]
|
||||||
pub struct OriginStaking {
|
pub struct OriginStaking {
|
||||||
owner: AccountId,
|
owner: Address,
|
||||||
treasury: AccountId,
|
treasury: Address,
|
||||||
|
|
||||||
// ── Config per StakeKind ──────────────────────────────
|
// ── Config per StakeKind ──────────────────────────────
|
||||||
min_validator_stake: Balance,
|
min_validator_stake: Balance,
|
||||||
@@ -96,12 +97,12 @@ mod origin_staking {
|
|||||||
reputation_reward_rate_bps: BasisPoints,
|
reputation_reward_rate_bps: BasisPoints,
|
||||||
|
|
||||||
// ── Slashing authority (Tribunal Pillar 6) ────────────
|
// ── Slashing authority (Tribunal Pillar 6) ────────────
|
||||||
slashing_authority: AccountId,
|
slashing_authority: Address,
|
||||||
|
|
||||||
// ── State ─────────────────────────────────────────────
|
// ── State ─────────────────────────────────────────────
|
||||||
total_stakes: u32,
|
total_stakes: u32,
|
||||||
stakes: Mapping<StakeId, Stake>,
|
stakes: Mapping<StakeId, Stake>,
|
||||||
staker_stakes: Mapping<AccountId, Vec<StakeId>>,
|
staker_stakes: Mapping<Address, Vec<StakeId>>,
|
||||||
|
|
||||||
total_staked_validator: Balance,
|
total_staked_validator: Balance,
|
||||||
total_staked_beacon: Balance,
|
total_staked_beacon: Balance,
|
||||||
@@ -113,7 +114,7 @@ mod origin_staking {
|
|||||||
#[ink(topic)]
|
#[ink(topic)]
|
||||||
stake_id: StakeId,
|
stake_id: StakeId,
|
||||||
#[ink(topic)]
|
#[ink(topic)]
|
||||||
staker: AccountId,
|
staker: Address,
|
||||||
kind: StakeKind,
|
kind: StakeKind,
|
||||||
amount: Balance,
|
amount: Balance,
|
||||||
}
|
}
|
||||||
@@ -158,11 +159,22 @@ mod origin_staking {
|
|||||||
|
|
||||||
pub type Result<T> = core::result::Result<T, Error>;
|
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 {
|
impl OriginStaking {
|
||||||
#[ink(constructor)]
|
#[ink(constructor)]
|
||||||
pub fn new(
|
pub fn new(
|
||||||
treasury: AccountId,
|
treasury: Address,
|
||||||
slashing_authority: AccountId,
|
slashing_authority: Address,
|
||||||
min_validator_stake: Balance,
|
min_validator_stake: Balance,
|
||||||
min_beacon_stake: Balance,
|
min_beacon_stake: Balance,
|
||||||
min_reputation_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) }
|
pub fn stake(&self, id: StakeId) -> Option<Stake> { self.stakes.get(id) }
|
||||||
|
|
||||||
#[ink(message)]
|
#[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()
|
self.staker_stakes.get(staker).unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,7 +270,7 @@ mod origin_staking {
|
|||||||
/// Verifies min_stake per kind canonical.
|
/// Verifies min_stake per kind canonical.
|
||||||
#[ink(message, payable)]
|
#[ink(message, payable)]
|
||||||
pub fn stake_tokens(&mut self, kind: StakeKind) -> Result<StakeId> {
|
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);
|
let min = self.min_stake(kind);
|
||||||
if value < min {
|
if value < min {
|
||||||
return Err(Error::InsufficientStake);
|
return Err(Error::InsufficientStake);
|
||||||
@@ -337,21 +349,34 @@ mod origin_staking {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let to_return = stake.amount.saturating_add(stake.rewards_accumulated);
|
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
|
// FIX-1 (V1-C1) CEI: muta TODO el estado ANTES del transfer, para que
|
||||||
match stake.kind {
|
// una reentrada vea state=Withdrawable y falle el check de InvalidState
|
||||||
StakeKind::Validator => self.total_staked_validator = self.total_staked_validator.saturating_sub(stake.amount),
|
// (cierra el drenado por re-entrada en `withdraw`).
|
||||||
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),
|
// ── 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;
|
stake.state = StakeState::Withdrawable;
|
||||||
self.stakes.insert(stake_id, &stake);
|
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 {
|
self.env().emit_event(Withdrawn {
|
||||||
stake_id,
|
stake_id,
|
||||||
amount: stake.amount,
|
amount: principal,
|
||||||
rewards: stake.rewards_accumulated,
|
rewards,
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -377,24 +402,33 @@ mod origin_staking {
|
|||||||
let treasury_share = amount / 2;
|
let treasury_share = amount / 2;
|
||||||
let burn = amount.saturating_sub(treasury_share);
|
let burn = amount.saturating_sub(treasury_share);
|
||||||
|
|
||||||
self.env().transfer(self.treasury, treasury_share).map_err(|_| Error::TransferFailed)?;
|
// FIX-1 (V1-C1) CEI + (V1-C4) burn real:
|
||||||
// Burn portion canonical — transfer to zero address (interim Slice 3.5 SCAFFOLD)
|
// · TODO el estado muta ANTES del transfer.
|
||||||
let zero = AccountId::from([0u8; 32]);
|
// · El "burn" NO se transfiere a la zero-address (que no reduce
|
||||||
self.env().transfer(zero, burn).map_err(|_| Error::TransferFailed)?;
|
// 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.amount = stake.amount.saturating_sub(amount);
|
||||||
stake.slashed_amount = stake.slashed_amount.saturating_add(amount);
|
stake.slashed_amount = stake.slashed_amount.saturating_add(amount);
|
||||||
if stake.amount == 0 {
|
if stake.amount == 0 {
|
||||||
stake.state = StakeState::Slashed;
|
stake.state = StakeState::Slashed;
|
||||||
}
|
}
|
||||||
self.stakes.insert(stake_id, &stake);
|
self.stakes.insert(stake_id, &stake);
|
||||||
|
match kind {
|
||||||
match stake.kind {
|
|
||||||
StakeKind::Validator => self.total_staked_validator = self.total_staked_validator.saturating_sub(amount),
|
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::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),
|
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 {
|
self.env().emit_event(Slashed {
|
||||||
stake_id,
|
stake_id,
|
||||||
amount_slashed: amount,
|
amount_slashed: amount,
|
||||||
@@ -431,5 +465,17 @@ mod origin_staking {
|
|||||||
assert_ne!(StakeKind::Validator, StakeKind::Beacon);
|
assert_ne!(StakeKind::Validator, StakeKind::Beacon);
|
||||||
assert_ne!(StakeKind::Beacon, StakeKind::Reputation);
|
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