Files
arkhe-origin-chain/contracts/origin-escrow/lib.rs
T
davidherranandClaude Fable 5 0d56e0bcbf feat(ink): escrow auto-release (C.2) + NFT modelo delegación documentado (C.1)
Decisiones C de David (2026-07-04):

ESCROW — Opción 2 (auto-release por tiempo, el tiempo como juez determinista):
El audit encontró [HIGH] que si alguien depositaba pero nunca marcaba un
milestone, los fondos quedaban ATRAPADOS (solo salían por disputa+árbitro), y los
campos auto_release_blocks/last_release_block eran código MUERTO. Fix: nueva
`claim_auto_release` (permisionless) que, tras `auto_release_blocks` sin actividad
ni disputa, libera el remanente al beneficiario (menos fee) y cierra el escrow.
`deposit()` arranca el reloj. CRÍTICO para la economía M2M: una AGI que contrata a
otra no queda en limbo porque alguien olvidó un hito. El depositor disconforme
levanta disputa ANTES del vencimiento. +test E2E (ink v6). Campos ya no muertos.

NFT — Opción 1 (delegación ERC-721 estándar, modularidad ADR-0057): el audit
marcó que un spender aprobado puede mover el token con pago cero. NO es bug — es
el estándar Web3 ("si no confías, no apruebes"). La venta con precio pactado +
split 91/9 vive en un contrato de MARKETPLACE SEPARADO (acoplar rompería los NFTs
al actualizar reglas de venta). Documentado el modelo en `transfer`. Sin cambio de
código. Contratos: escrow 6 tests, nft 4 tests verde.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 21:30:13 -05:00

755 lines
32 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! # Origin Escrow Contract — Template canonical Phase 3 Slice 3.3
//!
//! **Second foundational template** del Origin Contract Atelier. Multi-party
//! escrow + time-locked + dispute-enabled. Para commerce + freelance + institutional
//! partnerships donde necesitamos trust-minimized fund custody con resolution path.
//!
//! ## Use cases canonical
//!
//! 1. **Freelance contracts**: cliente deposita fondos → trabajo completado →
//! fondos released a worker. Si disputa, Tribunal Pillar 6 decide.
//! 2. **Marketplace OMC purchases**: comprador deposita → vendedor delivers asset
//! → fondos released. Refund posible si delivery falla con evidence.
//! 3. **Institutional partnerships**: universidad deposita fondos investigación →
//! milestones met → progressive releases. Tribunal arbitra disputas.
//! 4. **Event tickets refundable**: organizer deposita garantía → evento ocurre →
//! garantía released. Si event cancelado, refunds automáticos a holders.
//!
//! ## Inmutables honored
//!
//! - **#16 HTS anti-clasista**: escrow fee 1.5% canonical baseline + 5% max
//! ceiling (BM V2 Pillar 6 §5.3 ratified). Pi 5 institutional escrows pueden
//! subsidized rate via HardwareTier modulation Phase 3.x.
//! - **#26 Content-Addressed Immutability**: terms_hash BLAKE3 del contract terms
//! inmutable post-deploy. Cualquier party puede verificar terms originales.
//! - **#37 Free perpetua**: reads (query_state, list_milestones, view_terms)
//! gratis sin payment.
//! - **#41 AGI Cross-Species**: AGI accounts pueden ser party canonical (depositor,
//! beneficiary, arbiter). Compile-detect via AccountId32 standard.
//!
//! ## Lifecycle canonical (5 states)
//!
//! ```text
//! Created ──► Active ──► Releasing ──► Completed
//! │ │ │
//! │ ▼ ▼
//! └────► Disputed ──► Tribunal ──► Resolved (Released | Refunded | Split)
//! ```
//!
//! - **Created**: contract deployed canonical, NO deposit yet
//! - **Active**: depositor funded canonical, awaiting milestones/conditions
//! - **Disputed**: party raised dispute canonical, frozen pending Tribunal
//! - **Releasing**: condition met canonical, automatic release initiated
//! - **Completed**: funds disbursed canonical, contract terminal state
//!
//! ## Policy-first parameters (Contract Atelier UI exposure)
//!
//! - `depositor`: AccountId del payer canonical
//! - `beneficiary`: AccountId del payee canonical
//! - `arbiter`: AccountId del Tribunal canonical (Pillar 6 Tribunal contract)
//! - `total_amount`: Balance escrowed canonical
//! - `milestones`: Vec<Milestone> canonical (amount + due_block + description_hash)
//! - `escrow_fee_bps`: u16 (default 150 = 1.5%, BM V2 baseline)
//! - `dispute_window_blocks`: u32 (window canonical para raise dispute post-release)
//! - `auto_release_blocks`: u32 (VIVO desde 2026-07-04, David Opción C.2): el
//! tiempo como juez determinista — `claim_auto_release` (permisionless) libera el
//! remanente al beneficiario si transcurre esta ventana sin actividad ni disputa.
//! Cierra el fund-lock del caso sin-milestones para la economía M2M. `0` = off.
//! - `early_release_allowed`: bool (depositor pueda release early canonical)
#![cfg_attr(not(feature = "std"), no_std, no_main)]
#[ink::contract]
mod origin_escrow {
use ink::storage::Mapping;
use ink::U256;
pub type MilestoneId = u32;
pub type BasisPoints = u16;
pub type TermsHash = [u8; 32];
// ─────────────────────────────────────────────────────────────────
// State machine canonical
// ─────────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy, PartialEq, Eq, scale::Encode, scale::Decode)]
#[cfg_attr(feature = "std", derive(scale_info::TypeInfo, ink::storage::traits::StorageLayout))]
pub enum EscrowState {
Created,
Active,
Disputed,
Releasing,
Completed,
}
/// Milestone canonical structure — define partial releases del total_amount.
#[derive(Debug, Clone, scale::Encode, scale::Decode)]
#[cfg_attr(feature = "std", derive(scale_info::TypeInfo, ink::storage::traits::StorageLayout))]
pub struct Milestone {
pub amount: Balance,
pub due_block: BlockNumber,
pub description_hash: TermsHash,
pub completed: bool,
pub completed_at: Option<BlockNumber>,
}
/// Resolution canonical del Tribunal post-dispute.
#[derive(Debug, Clone, Copy, PartialEq, Eq, scale::Encode, scale::Decode)]
#[cfg_attr(feature = "std", derive(scale_info::TypeInfo))]
pub enum Resolution {
ReleaseToBeneficiary,
RefundToDepositor,
SplitFifty,
Custom { depositor_bps: BasisPoints, beneficiary_bps: BasisPoints },
}
// ─────────────────────────────────────────────────────────────────
// Storage canonical
// ─────────────────────────────────────────────────────────────────
#[ink(storage)]
pub struct OriginEscrow {
// ── Parties + config ─────────────────────────────────────
depositor: Address,
beneficiary: Address,
arbiter: Address,
treasury: Address,
terms_hash: TermsHash,
total_amount: Balance,
escrow_fee_bps: BasisPoints,
dispute_window_blocks: BlockNumber,
auto_release_blocks: BlockNumber,
early_release_allowed: bool,
// ── State ───────────────────────────────────────────────
state: EscrowState,
deposited_amount: Balance,
released_amount: Balance,
dispute_raised_at: Option<BlockNumber>,
dispute_raised_by: Option<Address>,
last_release_block: Option<BlockNumber>,
// ── Milestones canonical (partial releases) ─────────────
milestone_count: u32,
/// FIX-1 (V1-H7): Σ de los milestone.amount asignados. Invariante:
/// milestone_total ≤ total_amount (enforced en add_milestone).
milestone_total: Balance,
milestones: Mapping<MilestoneId, Milestone>,
}
// ─────────────────────────────────────────────────────────────────
// Events canonical
// ─────────────────────────────────────────────────────────────────
#[ink(event)]
pub struct Deposited {
#[ink(topic)]
depositor: Address,
amount: Balance,
}
#[ink(event)]
pub struct MilestoneCompleted {
#[ink(topic)]
milestone_id: MilestoneId,
amount_released: Balance,
}
#[ink(event)]
pub struct DisputeRaised {
#[ink(topic)]
raised_by: Address,
#[ink(topic)]
arbiter: Address,
}
#[ink(event)]
pub struct DisputeResolved {
#[ink(topic)]
arbiter: Address,
resolution: Resolution,
depositor_refund: Balance,
beneficiary_release: Balance,
}
#[ink(event)]
pub struct EscrowCompleted {
total_released: Balance,
total_refunded: Balance,
treasury_fee: Balance,
}
// ─────────────────────────────────────────────────────────────────
// Errors canonical
// ─────────────────────────────────────────────────────────────────
#[derive(Debug, PartialEq, Eq, scale::Encode, scale::Decode)]
#[cfg_attr(feature = "std", derive(scale_info::TypeInfo))]
pub enum Error {
InvalidState,
UnauthorizedCaller,
InsufficientDeposit,
MilestoneNotFound,
MilestoneAlreadyCompleted,
MilestoneNotDueYet,
DisputeAlreadyRaised,
DisputeWindowClosed,
InvalidResolution,
InvalidConfiguration,
TransferFailed,
EarlyReleaseProhibited,
/// Audit 2026-07-04 (C, David Opción 2): el auto-release está deshabilitado
/// para este escrow (`auto_release_blocks == 0`).
AutoReleaseDisabled,
/// Audit 2026-07-04: aún no transcurrió `auto_release_blocks` desde la
/// última actividad (depósito o release) — el tiempo-juez no ha vencido.
AutoReleaseNotReady,
}
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 {
/// 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: Address,
beneficiary: Address,
arbiter: Address,
treasury: Address,
terms_hash: TermsHash,
total_amount: Balance,
escrow_fee_bps: BasisPoints,
dispute_window_blocks: BlockNumber,
auto_release_blocks: BlockNumber,
early_release_allowed: bool,
) -> core::result::Result<Self, Error> {
if escrow_fee_bps > 500 {
return Err(Error::InvalidConfiguration); // Max 5% canonical ceiling
}
if terms_hash == [0u8; 32] {
return Err(Error::InvalidConfiguration);
}
if depositor == beneficiary {
return Err(Error::InvalidConfiguration);
}
Ok(Self {
depositor,
beneficiary,
arbiter,
treasury,
terms_hash,
total_amount,
escrow_fee_bps,
dispute_window_blocks,
auto_release_blocks,
early_release_allowed,
state: EscrowState::Created,
deposited_amount: 0,
released_amount: 0,
dispute_raised_at: None,
dispute_raised_by: None,
last_release_block: None,
milestone_count: 0,
milestone_total: 0,
milestones: Mapping::default(),
})
}
// ── Reads (Inmutable #37 free perpetua) ──────────────────
#[ink(message)]
pub fn state(&self) -> EscrowState { self.state }
#[ink(message)]
pub fn depositor(&self) -> Address { self.depositor }
#[ink(message)]
pub fn beneficiary(&self) -> Address { self.beneficiary }
#[ink(message)]
pub fn arbiter(&self) -> Address { self.arbiter }
#[ink(message)]
pub fn terms_hash(&self) -> TermsHash { self.terms_hash }
#[ink(message)]
pub fn total_amount(&self) -> Balance { self.total_amount }
#[ink(message)]
pub fn deposited_amount(&self) -> Balance { self.deposited_amount }
#[ink(message)]
pub fn released_amount(&self) -> Balance { self.released_amount }
#[ink(message)]
pub fn milestone(&self, id: MilestoneId) -> Option<Milestone> {
self.milestones.get(id)
}
#[ink(message)]
pub fn milestone_count(&self) -> u32 { self.milestone_count }
// ── Writes (state transitions) ──────────────────────────
/// Deposit canonical — depositor funds the escrow.
/// Transitions Created → Active canonical.
#[ink(message, payable)]
pub fn deposit(&mut self) -> Result<()> {
if self.state != EscrowState::Created {
return Err(Error::InvalidState);
}
if self.env().caller() != self.depositor {
return Err(Error::UnauthorizedCaller);
}
let value = u256_to_balance(self.env().transferred_value());
if value < self.total_amount {
return Err(Error::InsufficientDeposit);
}
self.deposited_amount = value;
self.state = EscrowState::Active;
// Audit 2026-07-04 (C, David Opción 2): arranca el reloj del auto-
// release. `last_release_block` es el reloj de "última actividad"
// (depósito o release de milestone); si no hay actividad ni disputa
// por `auto_release_blocks`, cualquiera puede gatillar `claim_auto_release`.
// Cierra el fund-lock del caso sin-milestones para la economía M2M.
self.last_release_block = Some(self.env().block_number());
self.env().emit_event(Deposited {
depositor: self.depositor,
amount: value,
});
Ok(())
}
/// Audit 2026-07-04 (C, David Opción 2): AUTO-RELEASE por tiempo — el
/// tiempo es el JUEZ determinista. **Permisionless**: cualquiera lo gatilla
/// cuando el escrow lleva `Active` (sin disputa) y transcurrió
/// `auto_release_blocks` desde la última actividad (depósito o release).
/// Libera el REMANENTE (deposited − released) al beneficiario (menos el fee
/// de tesorería) y cierra el escrow. Cierra el fund-lock del caso "se
/// depositó pero nadie marcó un milestone" — CRÍTICO para la economía M2M:
/// una AGI que contrata a otra no puede quedar en limbo infinito porque
/// alguien olvidó marcar un hito. El depositor que NO esté conforme debe
/// levantar disputa ANTES del vencimiento (la disputa mueve el estado a
/// Disputed y congela este path).
#[ink(message)]
pub fn claim_auto_release(&mut self) -> Result<()> {
// Solo Active — una disputa habría movido el estado a Disputed.
if self.state != EscrowState::Active {
return Err(Error::InvalidState);
}
// auto_release_blocks == 0 ⇒ feature deshabilitado para este escrow.
if self.auto_release_blocks == 0 {
return Err(Error::AutoReleaseDisabled);
}
let reference = self.last_release_block.unwrap_or(0);
let now = self.env().block_number();
if now < reference.saturating_add(self.auto_release_blocks) {
return Err(Error::AutoReleaseNotReady);
}
let remaining = self.deposited_amount.saturating_sub(self.released_amount);
let fee = remaining
.saturating_mul(self.escrow_fee_bps as Balance)
.saturating_div(10_000);
let net = remaining.saturating_sub(fee);
// FIX-1 CEI: TODO el estado muta ANTES de los transfers.
self.released_amount = self.deposited_amount;
self.state = EscrowState::Completed;
self.last_release_block = Some(now);
// ── INTERACTIONS ──
if net > 0 {
self.env()
.transfer(self.beneficiary, U256::from(net))
.map_err(|_| Error::TransferFailed)?;
}
if fee > 0 {
self.env()
.transfer(self.treasury, U256::from(fee))
.map_err(|_| Error::TransferFailed)?;
}
// ── EVENTS ──
self.env().emit_event(EscrowCompleted {
total_released: self.released_amount,
total_refunded: 0,
treasury_fee: fee,
});
Ok(())
}
/// Add milestone canonical (depositor-only, pre-active state).
/// Sum of milestone amounts MUST eventually equal total_amount.
#[ink(message)]
pub fn add_milestone(
&mut self,
amount: Balance,
due_block: BlockNumber,
description_hash: TermsHash,
) -> Result<MilestoneId> {
if self.state != EscrowState::Created && self.state != EscrowState::Active {
return Err(Error::InvalidState);
}
if self.env().caller() != self.depositor {
return Err(Error::UnauthorizedCaller);
}
// FIX-1 (V1-H7): Σ milestone.amount no puede exceder total_amount
// (previene sobre-liberación: releasear más de lo depositado).
let new_total = self.milestone_total.saturating_add(amount);
if new_total > self.total_amount {
return Err(Error::InvalidConfiguration);
}
let id = self.milestone_count;
self.milestones.insert(id, &Milestone {
amount,
due_block,
description_hash,
completed: false,
completed_at: None,
});
self.milestone_count = self.milestone_count.saturating_add(1);
self.milestone_total = new_total;
Ok(id)
}
/// Confirm milestone completion canonical — releases milestone.amount al
/// beneficiary minus escrow fee canonical.
/// Callable canonical por depositor OR arbiter (depositor for satisfaction,
/// arbiter for compelled release post-dispute).
#[ink(message)]
pub fn confirm_milestone(&mut self, milestone_id: MilestoneId) -> Result<()> {
if self.state != EscrowState::Active {
return Err(Error::InvalidState);
}
let caller = self.env().caller();
if caller != self.depositor && caller != self.arbiter {
return Err(Error::UnauthorizedCaller);
}
let mut milestone = self.milestones.get(milestone_id).ok_or(Error::MilestoneNotFound)?;
if milestone.completed {
return Err(Error::MilestoneAlreadyCompleted);
}
// Early release check canonical
let now = self.env().block_number();
if !self.early_release_allowed && now < milestone.due_block {
return Err(Error::EarlyReleaseProhibited);
}
// 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);
// 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);
// FIX-1 (V1-H7): completa cuando TODOS los milestones asignados se
// liberaron (Σ = milestone_total, garantizado ≤ total_amount por
// add_milestone). El residual no-asignado (deposited − milestone_total,
// p.ej. sobre-depósito) se barre al depositor al completar (sweep terminal).
let completed = self.milestone_total > 0 && self.released_amount >= self.milestone_total;
let sweep = if completed {
self.deposited_amount.saturating_sub(self.milestone_total)
} else {
0
};
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)?;
if sweep > 0 {
self.env()
.transfer(self.depositor, U256::from(sweep))
.map_err(|_| Error::TransferFailed)?;
}
// ── EVENTS ──
self.env().emit_event(MilestoneCompleted {
milestone_id,
amount_released: net,
});
if completed {
self.env().emit_event(EscrowCompleted {
total_released: self.released_amount,
total_refunded: sweep,
treasury_fee: fee,
});
}
Ok(())
}
/// Raise dispute canonical — freezes escrow pending Tribunal resolution.
/// Callable canonical por depositor OR beneficiary (any party).
#[ink(message)]
pub fn raise_dispute(&mut self) -> Result<()> {
if self.state != EscrowState::Active {
return Err(Error::InvalidState);
}
let caller = self.env().caller();
if caller != self.depositor && caller != self.beneficiary {
return Err(Error::UnauthorizedCaller);
}
if self.dispute_raised_at.is_some() {
return Err(Error::DisputeAlreadyRaised);
}
self.state = EscrowState::Disputed;
self.dispute_raised_at = Some(self.env().block_number());
self.dispute_raised_by = Some(caller);
self.env().emit_event(DisputeRaised {
raised_by: caller,
arbiter: self.arbiter,
});
Ok(())
}
/// Resolve dispute canonical — arbiter (Tribunal Pillar 6) decides.
/// Transitions Disputed → Completed canonical.
#[ink(message)]
pub fn resolve_dispute(&mut self, resolution: Resolution) -> Result<()> {
if self.state != EscrowState::Disputed {
return Err(Error::InvalidState);
}
if self.env().caller() != self.arbiter {
return Err(Error::UnauthorizedCaller);
}
let remaining = self.deposited_amount.saturating_sub(self.released_amount);
let fee = remaining
.saturating_mul(self.escrow_fee_bps as Balance)
.saturating_div(10_000);
let net_remaining = remaining.saturating_sub(fee);
let (depositor_refund, beneficiary_release) = match resolution {
Resolution::ReleaseToBeneficiary => (0, net_remaining),
Resolution::RefundToDepositor => (net_remaining, 0),
Resolution::SplitFifty => {
let half = net_remaining / 2;
(half, net_remaining.saturating_sub(half))
}
Resolution::Custom { depositor_bps, beneficiary_bps } => {
if depositor_bps.saturating_add(beneficiary_bps) != 10_000 {
return Err(Error::InvalidResolution);
}
let dep = net_remaining
.saturating_mul(depositor_bps as Balance)
.saturating_div(10_000);
(dep, net_remaining.saturating_sub(dep))
}
};
// 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, U256::from(depositor_refund))
.map_err(|_| Error::TransferFailed)?;
}
if beneficiary_release > 0 {
self.env()
.transfer(self.beneficiary, U256::from(beneficiary_release))
.map_err(|_| Error::TransferFailed)?;
}
self.env()
.transfer(self.treasury, U256::from(fee))
.map_err(|_| Error::TransferFailed)?;
// ── EVENTS ──
self.env().emit_event(DisputeResolved {
arbiter: self.arbiter,
resolution,
depositor_refund,
beneficiary_release,
});
self.env().emit_event(EscrowCompleted {
total_released: self.released_amount.saturating_add(beneficiary_release),
total_refunded: depositor_refund,
treasury_fee: fee,
});
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
/// 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% BM V2
100,
1000,
false,
)
.expect("valid config")
}
#[ink::test]
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(
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,
);
assert_eq!(result.err(), Some(Error::InvalidConfiguration));
}
#[ink::test]
fn same_party_depositor_beneficiary_rejected() {
let same = Address::from([1u8; 20]);
let result = OriginEscrow::new(
same,
same,
Address::from([3u8; 20]),
Address::from([4u8; 20]),
[1u8; 32],
1_000_000_000_000_000_000,
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);
}
/// FIX-1 (V1-H7): add_milestone enforce Σ milestone.amount ≤ total_amount
/// → previene sobre-liberación. (El sweep terminal del residual se ejercita
/// E2E con transfers — node-gated, ver nota arriba.)
#[ink::test]
fn milestone_total_cannot_exceed_total_amount() {
let mut e = mk(); // depositor = [1;20], total_amount = 1 OGC (1e18)
ink::env::test::set_caller(Address::from([1u8; 20])); // caller = depositor
// 0.6 OGC: dentro del total → ok
assert!(e.add_milestone(600_000_000_000_000_000, 100, [1u8; 32]).is_ok());
// +0.6 OGC: Σ = 1.2 OGC > total → rechazado
assert_eq!(
e.add_milestone(600_000_000_000_000_000, 100, [1u8; 32]),
Err(Error::InvalidConfiguration)
);
// +0.4 OGC: Σ = 1.0 OGC == total → ok (boundary)
assert!(e.add_milestone(400_000_000_000_000_000, 100, [1u8; 32]).is_ok());
}
/// Audit 2026-07-04 (C, David Opción 2): AUTO-RELEASE por tiempo cierra el
/// fund-lock del caso sin-milestones (economía M2M). Deposita, avanza el
/// tiempo más allá de `auto_release_blocks` sin disputa, y el remanente
/// se libera al beneficiario. Antes del vencimiento → AutoReleaseNotReady.
#[ink::test]
fn auto_release_libera_al_beneficiario_por_tiempo() {
let total = 1_000_000_000_000_000_000u128; // 1 OGC (= total_amount de mk)
let depositor = Address::from([1u8; 20]);
let mut e = mk(); // auto_release_blocks = 1000, fee 1.5%
let contract = ink::env::test::callee();
ink::env::test::set_contract_balance(depositor, U256::from(total * 3));
ink::env::test::set_contract_balance(contract, U256::from(total * 3));
// Depósito (depositor) → Active, arranca el reloj en el bloque actual (0).
ink::env::test::set_caller(depositor);
ink::env::test::set_value_transferred(U256::from(total));
assert!(e.deposit().is_ok());
assert_eq!(e.state(), EscrowState::Active);
// Sin milestones ni disputa. Antes del vencimiento → not ready.
assert_eq!(e.claim_auto_release(), Err(Error::AutoReleaseNotReady));
// El tiempo (juez determinista) vence: avanza > auto_release_blocks.
ink::env::test::set_block_number::<ink::env::DefaultEnvironment>(1001);
assert!(e.claim_auto_release().is_ok(), "auto-release tras el vencimiento");
assert_eq!(e.state(), EscrowState::Completed);
assert_eq!(e.released_amount(), total, "remanente liberado");
}
}
}