Files
arkhe-origin-chain/contracts/origin-escrow/lib.rs
T
davidherranandClaude Opus 4.8 34a2041f2b fix(contracts): cierra migración ink! v6 (credential) + FIX-1 V1-H7 (escrow milestone sum)
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>
2026-06-09 21:51:13 -05:00

652 lines
26 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 (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());
}
}
}