//! # 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 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, } /// 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, dispute_raised_by: Option
, last_release_block: Option, // ── 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, } // ───────────────────────────────────────────────────────────────── // 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 = core::result::Result; /// Convierte un valor `U256` del entorno pallet-revive a `Balance` (u128) /// de forma saturante. /// /// ⚠️ NOTA de correctitud económica: pallet-revive maneja dos /// representaciones (nativa vs eth, `NATIVE_TO_ETH_RATIO`). Aquí se asume /// representación nativa 1:1 (lo que el doc-example de `transfer` sugiere). /// La equivalencia exacta DEBE validarse E2E en un nodo pallet-revive real /// antes de cualquier anclaje en testnet/mainnet — pendiente VPS. fn u256_to_balance(v: U256) -> Balance { if v > U256::from(Balance::MAX) { Balance::MAX } else { v.as_u128() } } impl OriginEscrow { /// Constructor canonical con full parameters. /// /// Invariant compile-detect: escrow_fee_bps ≤ 500 (5% max ceiling BM V2 §5.3). #[ink(constructor)] pub fn new( depositor: 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 { 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 { 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 { 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::(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"); } } }