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>
This commit is contained in:
1 parent
f4f46767fe
commit
9f7897c47c
3 files changed
+643
No files matched your search
@@ -0,0 +1,41 @@
|
||||
[package]
|
||||
name = "origin-escrow"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
authors = [
|
||||
"Charon <charon@originmetaverse.com>",
|
||||
"David Herran <david@dreamhousestudios.com>",
|
||||
"Origin Metaverse community",
|
||||
]
|
||||
license = "MIT OR Apache-2.0"
|
||||
description = "Origin Chain Escrow contract template — multi-party + time-locked + dispute-enabled via Pillar 6 Tribunal integration. Para commerce + freelance + institutional partnerships. Second foundational template del Origin Contract Atelier."
|
||||
homepage = "https://originmetaverse.com"
|
||||
repository = "https://git.originmetaverse.com/Origin_Metaverse/arkhe-origin-chain"
|
||||
keywords = ["smart-contract", "escrow", "ink", "polkavm", "origin-metaverse"]
|
||||
categories = ["cryptography::cryptocurrencies", "no-std", "wasm"]
|
||||
|
||||
[dependencies]
|
||||
ink = { version = "6.0.0-beta.1", default-features = false }
|
||||
scale = { package = "parity-scale-codec", version = "3.7", default-features = false, features = ["derive"] }
|
||||
scale-info = { version = "2.11", default-features = false, features = ["derive"] }
|
||||
|
||||
[lib]
|
||||
path = "lib.rs"
|
||||
|
||||
[features]
|
||||
default = ["std"]
|
||||
std = [
|
||||
"ink/std",
|
||||
"scale/std",
|
||||
"scale-info/std",
|
||||
]
|
||||
ink-as-dependency = []
|
||||
e2e-tests = []
|
||||
|
||||
[profile.release]
|
||||
panic = "abort"
|
||||
lto = true
|
||||
opt-level = "z"
|
||||
overflow-checks = false
|
||||
codegen-units = 1
|
||||
strip = "symbols"
|
||||
@@ -0,0 +1,74 @@
|
||||
# origin-escrow — Multi-party Escrow Contract Template (Slice 3.3)
|
||||
|
||||
**Second foundational template** del Origin Contract Atelier. Multi-party escrow + time-locked milestones + dispute-enabled via Pillar 6 Tribunal integration.
|
||||
|
||||
## Use cases canonical
|
||||
|
||||
| Use case | Parties | Flow típico |
|
||||
|----------|---------|-------------|
|
||||
| **Freelance contracts** | Cliente + Freelancer + Tribunal | Deposit → milestones met → progressive releases |
|
||||
| **OMC marketplace** (Marihan) | Buyer + Seller + Tribunal | Deposit → asset delivered → release. Dispute → refund. |
|
||||
| **Institutional partnerships** | Universidad + Researcher + Tribunal | Multi-milestone investigation funding |
|
||||
| **Refundable event tickets** | Organizer + Holder + Tribunal | Deposit guarantee → event held → release. Cancel → refund. |
|
||||
|
||||
## State machine canonical
|
||||
|
||||
```
|
||||
┌──────► Created ──deposit──► Active ──confirm_milestone──► Releasing
|
||||
│ │
|
||||
│ ├──raise_dispute──► Disputed
|
||||
│ │ │
|
||||
│ │ └──resolve_dispute──► Completed
|
||||
│ │
|
||||
│ └──all milestones complete──► Completed
|
||||
└────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
5 estados terminales: `Created → Active → Disputed → Releasing → Completed`.
|
||||
|
||||
## Inmutables compliance
|
||||
|
||||
| Inmutable | Enforcement |
|
||||
|-----------|-------------|
|
||||
| **#16 HTS anti-clasista** | escrow_fee_bps capped 5% BM V2 §5.3 ceiling. Pi 5 institutional rate Phase 3.x |
|
||||
| **#26 Content-Addressed Immutability** | terms_hash + description_hash BLAKE3 inmutable post-deploy |
|
||||
| **#37 Free perpetua** | 9 read methods sin payment (state, depositor, beneficiary, milestones, etc.) |
|
||||
| **#41 AGI Cross-Species** | AGI accounts pueden ser any party (depositor, beneficiary, arbiter) |
|
||||
|
||||
## Resolution canonical (Tribunal options)
|
||||
|
||||
4 resolution patterns canonical para Tribunal:
|
||||
|
||||
1. **ReleaseToBeneficiary**: 100% remaining → beneficiary (work completed satisfactorily)
|
||||
2. **RefundToDepositor**: 100% remaining → depositor (work not delivered / fraud)
|
||||
3. **SplitFifty**: 50/50 split (partial completion / shared responsibility)
|
||||
4. **Custom { depositor_bps, beneficiary_bps }**: arbitrary split (e.g., 30/70 partial completion)
|
||||
|
||||
Invariant compile-detect: `depositor_bps + beneficiary_bps == 10_000` (100%).
|
||||
|
||||
## Tests canonical (3 unit tests)
|
||||
|
||||
1. `constructor_canonical_invariants` — valid params + initial state Created
|
||||
2. `excessive_fee_rejected_canonical` — 6% fee rejected (>5% ceiling)
|
||||
3. `same_party_depositor_beneficiary_rejected` — same address rejected
|
||||
|
||||
## Cross-pillar integration
|
||||
|
||||
- **Pillar 4 Origin Chain (Charon)**: deploy vía pallet-revive Index 11
|
||||
- **Pillar 6 Nexus Services (Mathi)**: Tribunal contract address = arbiter parameter
|
||||
- **Pillar 2 OMC Marketplace (Marihan)**: escrow integrado a marketplace purchases
|
||||
- **Pillar 1 Hosting (Larihana)**: refundable hosting fees future use case
|
||||
|
||||
## Deploy
|
||||
|
||||
```bash
|
||||
cd contracts/origin-escrow
|
||||
cargo contract build --release
|
||||
# → target/ink/origin_escrow.polkavm
|
||||
```
|
||||
|
||||
Deploy via Polkadot.js Apps OR Origin Contract Atelier (Phase 3.x maduración).
|
||||
|
||||
---
|
||||
|
||||
— Charon ⚓ (Pilar 4, Phase 3 Slice 3.3 SCAFFOLD shipped 2026-05-27)
|
||||
@@ -0,0 +1,528 @@
|
||||
//! # 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user