//! # 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) //! //! ``` //! 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 (auto-release if no dispute canonical post-completion) //! - `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::prelude::vec::Vec; use ink::storage::Mapping; 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))] 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))] 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: AccountId, beneficiary: AccountId, arbiter: AccountId, treasury: AccountId, 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, milestones: Mapping, } // ───────────────────────────────────────────────────────────────── // Events canonical // ───────────────────────────────────────────────────────────────── #[ink(event)] pub struct Deposited { #[ink(topic)] depositor: AccountId, amount: Balance, } #[ink(event)] pub struct MilestoneCompleted { #[ink(topic)] milestone_id: MilestoneId, amount_released: Balance, } #[ink(event)] pub struct DisputeRaised { #[ink(topic)] raised_by: AccountId, #[ink(topic)] arbiter: AccountId, } #[ink(event)] pub struct DisputeResolved { #[ink(topic)] arbiter: AccountId, 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, } pub type Result = core::result::Result; 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: AccountId, beneficiary: AccountId, arbiter: AccountId, treasury: AccountId, 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, milestones: Mapping::default(), }) } // ── Reads (Inmutable #37 free perpetua) ────────────────── #[ink(message)] pub fn state(&self) -> EscrowState { self.state } #[ink(message)] pub fn depositor(&self) -> AccountId { self.depositor } #[ink(message)] pub fn beneficiary(&self) -> AccountId { self.beneficiary } #[ink(message)] pub fn arbiter(&self) -> AccountId { 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 = self.env().transferred_value(); if value < self.total_amount { return Err(Error::InsufficientDeposit); } self.deposited_amount = value; self.state = EscrowState::Active; self.env().emit_event(Deposited { depositor: self.depositor, amount: value, }); 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); } 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); 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 canonical: 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); self.env().transfer(self.beneficiary, net).map_err(|_| Error::TransferFailed)?; self.env().transfer(self.treasury, fee).map_err(|_| Error::TransferFailed)?; 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); self.env().emit_event(MilestoneCompleted { milestone_id, amount_released: net, }); // Auto-transition Completed if all milestones canonical released if self.released_amount >= self.total_amount { self.state = EscrowState::Completed; self.env().emit_event(EscrowCompleted { total_released: self.released_amount, total_refunded: 0, 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)) } }; if depositor_refund > 0 { self.env().transfer(self.depositor, depositor_refund).map_err(|_| Error::TransferFailed)?; } if beneficiary_release > 0 { self.env().transfer(self.beneficiary, beneficiary_release).map_err(|_| Error::TransferFailed)?; } self.env().transfer(self.treasury, fee).map_err(|_| Error::TransferFailed)?; self.state = EscrowState::Completed; 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::*; #[ink::test] fn constructor_canonical_invariants() { let result = OriginEscrow::new( AccountId::from([1u8; 32]), AccountId::from([2u8; 32]), AccountId::from([3u8; 32]), AccountId::from([4u8; 32]), [1u8; 32], 1_000_000_000_000_000_000, 150, // 1.5% canonical BM V2 100, 1000, false, ); assert!(result.is_ok()); let escrow = result.unwrap(); assert_eq!(escrow.state(), EscrowState::Created); assert_eq!(escrow.deposited_amount(), 0); } #[ink::test] fn excessive_fee_rejected_canonical() { let result = OriginEscrow::new( AccountId::from([1u8; 32]), AccountId::from([2u8; 32]), AccountId::from([3u8; 32]), AccountId::from([4u8; 32]), [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 = AccountId::from([1u8; 32]); let result = OriginEscrow::new( same, same, AccountId::from([3u8; 32]), AccountId::from([4u8; 32]), [1u8; 32], 1_000_000_000_000_000_000, 150, 100, 1000, false, ); assert_eq!(result.err(), Some(Error::InvalidConfiguration)); } } }