Files
arkhe-origin-chain/contracts/origin-escrow/lib.rs
T
davidherran 9f7897c47c feat(contracts): v0.0.43 — Slice 3.3 origin-escrow template SCAFFOLD (multi-party + dispute) 🎉
Ship Slice 3.3 v0.0.43 — second foundational template del Origin Contract Atelier.
Multi-party escrow + time-locked milestones + dispute-enabled via Pillar 6 Tribunal.

Cambios:
- contracts/origin-escrow/Cargo.toml: ink! 6.0.0-beta.1 + scale codec aligned
- contracts/origin-escrow/lib.rs: full ink! v6 contract source ~500 LOC:
  * 5-state machine canonical (Created → Active → Disputed → Releasing → Completed)
  * Storage canonical: parties (depositor + beneficiary + arbiter + treasury) +
    config (terms_hash + total + fee + windows) + state tracking + milestones mapping
  * 5 events canonical (Deposited, MilestoneCompleted, DisputeRaised,
    DisputeResolved, EscrowCompleted)
  * 12 error variants canonical (InvalidState, UnauthorizedCaller, etc.)
  * Constructor canonical con invariants compile-detect (fee ≤ 5% BM V2 §5.3,
    depositor != beneficiary, terms_hash != zero)
  * 9 read methods canonical (state, parties, amounts, milestones)
  * 5 write methods canonical (deposit, add_milestone, confirm_milestone,
    raise_dispute, resolve_dispute)
  * 4 Resolution variants Tribunal: ReleaseToBeneficiary, RefundToDepositor,
    SplitFifty, Custom (with depositor_bps + beneficiary_bps == 10_000 invariant)
  * 3 unit tests canonical
- contracts/origin-escrow/README.md: use cases + state machine diagram +
  resolution patterns + cross-pillar integration

Inmutables compliance:
- #16 HTS anti-clasista: escrow_fee_bps cap 5% BM V2 §5.3
- #26 Content-Addressed: terms_hash + description_hash BLAKE3 inmutable
- #37 Free perpetua: 9 read methods sin payment
- #41 AGI Cross-Species: AGI puede ser depositor/beneficiary/arbiter

Cross-pillar wire canonical:
- Pillar 6 Tribunal (Mathi): arbiter = Tribunal contract address
- Pillar 2 OMC Marketplace (Marihan): escrow integrado purchases
- Pillar 1 Hosting (Larihana): refundable hosting fees future
- Pillar 4 Origin Chain (Charon): deploy vía pallet-revive Index 11

Use cases canonical:
- Freelance contracts (client + worker + Tribunal)
- OMC marketplace purchases (buyer + seller + Tribunal)
- Institutional partnerships (university + researcher + Tribunal)
- Refundable event tickets (organizer + holder + Tribunal)

Próximo Ship Slice 3.4 v0.0.44: origin-credential-mint contract template
(Ed25519 + BLAKE3 attestation, Sussi Ethos schema integration).

⚓ Path A Slice 3.3 SCAFFOLD shipped. Templates library canonical 2/5 complete.

🤖 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 09:04:15 -05:00

529 lines
21 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)
//!
//! ```
//! 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::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<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: 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<BlockNumber>,
dispute_raised_by: Option<AccountId>,
last_release_block: Option<BlockNumber>,
// ── Milestones canonical (partial releases) ─────────────
milestone_count: u32,
milestones: Mapping<MilestoneId, Milestone>,
}
// ─────────────────────────────────────────────────────────────────
// 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<T> = core::result::Result<T, Error>;
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<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,
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<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 = 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);
}
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));
}
}
}