Completa la capa de contratos: los 4 templates buildables migrados a ink! 6.0.0-beta.1 (17 tests verde). El 5º (mdk-reconciliation) es design-only (DESIGN.md, sin código Rust — nada que migrar). origin-credential-mint: migración ink! v6 (Address/U256/StorageLayout). Su issue() ya era CEI-correcto (muta estado antes del transfer) — es el template que el roadmap cita como referencia. 4 tests verde (+ guard test). NOTA: FIX-4 (verify de issuer_signature on-chain) sigue pendiente — necesita el payload canónico de credencial + precompile Ed25519 (Phase 3.x), no es parte de la migración. FIX-1 V1-H7 (escrow): enforce Σ milestone.amount <= total_amount en add_milestone (previene sobre-liberación) + completion vía milestone_total + sweep terminal del residual no-asignado al depositor (deposited − milestone_total). 5 tests verde (+ test del invariante con set_caller). El sweep (transfer) se valida E2E en nodo — node-gated. 17 tests verde totales (escrow 5 + staking 4 + nft 4 + credential 4). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
652 lines
26 KiB
Rust
652 lines
26 KiB
Rust
//! # 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 (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::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,
|
||
}
|
||
|
||
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;
|
||
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<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());
|
||
}
|
||
}
|
||
}
|