From 813b3befc32ca40a8bd317c5a10bb028adf8399a Mon Sep 17 00:00:00 2001 From: Alheriii Date: Mon, 24 Aug 2026 16:03:45 +0100 Subject: [PATCH] feat(payment-router): partial settlement with pull-based claiming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All-or-nothing escrow release is a poor fit for continuous inference usage. A buyer will not lock a month of spend up front and a provider will not serve a month before being paid, so the standoff blocks adoption. Settlement is now incremental: the metered portion is released as it accrues, the remainder stays escrowed, and the payee pulls what has been released. Release does not push. release_partial only moves funds from escrowed to claimable; the payee calls claim to take them. That keeps the transfer fee on the party receiving the value, and means a payee whose destination is broken cannot make settlement fail for anyone else. claim stays available after the escrow is closed, so a refund of the remainder never strands funds the payee had already earned. Disputes freeze only the disputed portion. dispute takes its amount from the unreleased remainder and cannot reach `released` at all: funds released earlier were settled against usage already metered, and a later disagreement about subsequent usage is not grounds to claw them back. Concretely — with 3,000 released and 2,000 disputed out of 10,000, the payee can still claim the 3,000 while arbitration runs, and the remaining 5,000 keeps settling normally. Resolution in the payer's favour returns the frozen amount to the releasable remainder rather than refunding it, so one resolved disagreement does not force the escrow to be torn down and reopened. Closing is refused while a dispute is open, since it would refund the frozen amount out from under the arbiter. Partial releases are capped at 32 per escrow. Each release is a ledger write and a slot in permanently stored state, so an unbounded count is unbounded state growth. A payee wanting finer granularity opens a second escrow. Arbitrated resolution does not consume a slot — the cap bounds how often the payer voluntarily settles, and an arbitrated outcome is not that. The invariant, enforced on every path and asserted directly: releases can never sum past the deposit, and can never draw on a frozen portion. The invariant test settles repeatedly with interleaved claims, keeps pushing after the deposit is exhausted, and checks released <= total, claimed <= released and releasable >= 0 at every step, finishing on the contract's own balance to confirm it never pays out more than it was given. Streaming is deliberately not implemented. The issue raises it as a "consider", and the property the notes actually argue for — pull-based claiming, fees on the receiver, no contract-initiated push — is what this delivers. Continuous vesting can be layered on the same claimable/claimed split later without changing the settlement model. Authorization follows the pattern from #74: the payer's signature is bound to the escrow id and the amount with require_auth_for_args, so a release the payer agreed to cannot be enlarged in transit, and the same for disputes. claim is the payee's alone; resolve_dispute is the arbiter's alone. Both are tested without mock_all_auths, which would approve an impostor's signature as readily as the real one. Also replaces the Symbol admin placeholder with a real Address, adds typed errors, TTL extension on every persistent write, and #[contractevent] types for the six observable state changes. Tests: 17. fmt, clippy -D warnings, cargo test --workspace and the wasm32v1-none release build all pass. --- payment-router/src/lib.rs | 451 +++++++++++++++++++++++++++++++++++-- payment-router/src/test.rs | 451 +++++++++++++++++++++++++++++++++++++ 2 files changed, 887 insertions(+), 15 deletions(-) create mode 100644 payment-router/src/test.rs diff --git a/payment-router/src/lib.rs b/payment-router/src/lib.rs index 3568337..7d9630a 100644 --- a/payment-router/src/lib.rs +++ b/payment-router/src/lib.rs @@ -1,30 +1,451 @@ #![no_std] -use soroban_sdk::{contract, contractimpl, symbol_short, Env, Symbol}; +//! Escrow and settlement. +//! +//! All-or-nothing release is a poor fit for continuous inference usage: a buyer +//! will not lock a month of spend up front, and a provider will not serve a +//! month before being paid. This contract settles incrementally instead — the +//! metered portion is released as it accrues, the rest stays escrowed, and the +//! payee pulls what has been released on their own schedule. -/// Escrow and dispute-aware payouts. +use soroban_sdk::{ + contract, contracterror, contractevent, contractimpl, contracttype, panic_with_error, token, + Address, Env, IntoVal, +}; + +/// Persistent entries are bumped to ~30 days, renewed once inside ~15 days. +const PERSISTENT_TTL: u32 = 518_400; +const PERSISTENT_THRESHOLD: u32 = 259_200; + +/// Ceiling on partial releases per escrow. +/// +/// Each release is a ledger write and a slot in a bounded, permanently stored +/// counter, so an unbounded count is unbounded state growth funded by whoever +/// opened the escrow. Thirty-two is a month of daily settlement with room to +/// spare; a payee wanting finer granularity opens a second escrow rather than +/// making one escrow grow without limit. +const MAX_RELEASES: u32 = 32; + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum Error { + AlreadyInitialized = 1, + NotInitialized = 2, + EscrowNotFound = 3, + EscrowClosed = 4, + InvalidAmount = 5, + /// The release would exceed what is escrowed and not already released or + /// frozen. This is the invariant that keeps payouts inside the deposit. + InsufficientEscrow = 6, + ReleaseCapReached = 7, + NothingToClaim = 8, + NoDisputeOpen = 9, + DisputeAlreadyOpen = 10, + NotTheArbiter = 11, + SamePayerAndPayee = 12, +} + +#[contracttype] +#[derive(Clone)] +pub enum DataKey { + Arbiter, + Escrow(u64), + Counter, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Escrow { + pub id: u64, + pub payer: Address, + pub payee: Address, + pub token: Address, + /// What the payer deposited. Never changes. + pub total: i128, + /// Cumulative amount moved from escrowed to claimable. Monotonic. + pub released: i128, + /// Cumulative amount the payee has actually pulled. Monotonic, `<= released`. + pub claimed: i128, + /// Frozen pending dispute resolution. Taken from the unreleased remainder + /// only, so it can never claw back a valid earlier release. + pub disputed: i128, + pub release_count: u32, + pub closed: bool, +} + +impl Escrow { + /// What a further release may draw on: the deposit, less what has already + /// been released, less anything frozen. + fn releasable(&self) -> i128 { + self.total - self.released - self.disputed + } + + /// Released but not yet pulled. + fn claimable(&self) -> i128 { + self.released - self.claimed + } +} + +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EscrowOpened { + #[topic] + pub id: u64, + #[topic] + pub payer: Address, + pub payee: Address, + pub total: i128, +} + +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PartialReleased { + #[topic] + pub id: u64, + pub amount: i128, + pub released_total: i128, + pub release_count: u32, +} + +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Claimed { + #[topic] + pub id: u64, + #[topic] + pub payee: Address, + pub amount: i128, +} + +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Disputed { + #[topic] + pub id: u64, + pub amount: i128, +} + +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DisputeResolved { + #[topic] + pub id: u64, + pub amount: i128, + pub to_payee: bool, +} + +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EscrowClosed { + #[topic] + pub id: u64, + pub refunded: i128, +} + +/// Escrow, dispute windows, payout release. #[contract] pub struct PaymentRouter; #[contractimpl] impl PaymentRouter { - /// One-time initialization (scaffold — replace with auth in production). - pub fn initialize(env: Env, admin: Symbol) { - if env.storage().instance().has(&symbol_short!("admin")) { - panic!("already initialized"); + /// One-time initialization. The arbiter is the only party that can resolve + /// a dispute, and signs for its own installation. + pub fn initialize(env: Env, arbiter: Address) { + if env.storage().instance().has(&DataKey::Arbiter) { + panic_with_error!(&env, Error::AlreadyInitialized); } - env.storage() - .instance() - .set(&symbol_short!("admin"), &admin); + arbiter.require_auth(); + env.storage().instance().set(&DataKey::Arbiter, &arbiter); + Self::bump_instance(&env); } - /// Protocol ping — extend with domain logic. - pub fn ping(env: Env, marker: Symbol) -> Symbol { - let _ = env; - marker + /// Deposit `total` into escrow for `payee`. + pub fn open_escrow( + env: Env, + payer: Address, + payee: Address, + token_address: Address, + total: i128, + ) -> u64 { + if total <= 0 { + panic_with_error!(&env, Error::InvalidAmount); + } + if payer == payee { + panic_with_error!(&env, Error::SamePayerAndPayee); + } + payer.require_auth_for_args((payee.clone(), token_address.clone(), total).into_val(&env)); + + let contract = env.current_contract_address(); + token::Client::new(&env, &token_address).transfer(&payer, &contract, &total); + + let id = Self::next_id(&env); + let escrow = Escrow { + id, + payer: payer.clone(), + payee: payee.clone(), + token: token_address, + total, + released: 0, + claimed: 0, + disputed: 0, + release_count: 0, + closed: false, + }; + Self::save(&env, &escrow); + Self::bump_instance(&env); + + EscrowOpened { + id, + payer, + payee, + total, + } + .publish(&env); + + id + } + + /// Settle the metered portion, leaving the remainder escrowed. + /// + /// Authorized by the payer against this exact escrow and amount: the payer + /// is the party confirming that this much usage was actually metered, and + /// binding the signature to the amount stops an intermediate from + /// enlarging a release the payer agreed to. + /// + /// Nothing is pushed here. The release only makes funds claimable; the + /// payee pulls them separately, which keeps the transfer fee on the party + /// receiving the value and means a payee whose destination is broken + /// cannot make settlement fail for everyone else. + pub fn release_partial(env: Env, id: u64, amount: i128) { + let mut escrow = Self::load(&env, id); + Self::assert_open(&env, &escrow); + + if amount <= 0 { + panic_with_error!(&env, Error::InvalidAmount); + } + escrow + .payer + .require_auth_for_args((id, amount).into_val(&env)); + + if escrow.release_count >= MAX_RELEASES { + panic_with_error!(&env, Error::ReleaseCapReached); + } + // The invariant, enforced on every path: releases can never sum past + // the deposit, and can never dip into a frozen portion. + if amount > escrow.releasable() { + panic_with_error!(&env, Error::InsufficientEscrow); + } + + escrow.released += amount; + escrow.release_count += 1; + Self::save(&env, &escrow); + + PartialReleased { + id, + amount, + released_total: escrow.released, + release_count: escrow.release_count, + } + .publish(&env); + } + + /// Pull everything released and not yet taken. + /// + /// Callable after the escrow is closed, so a payee is never stranded by a + /// refund of the remainder. + pub fn claim(env: Env, id: u64) -> i128 { + let mut escrow = Self::load(&env, id); + escrow.payee.require_auth(); + + let amount = escrow.claimable(); + if amount <= 0 { + panic_with_error!(&env, Error::NothingToClaim); + } + + escrow.claimed += amount; + Self::save(&env, &escrow); + + token::Client::new(&env, &escrow.token).transfer( + &env.current_contract_address(), + &escrow.payee, + &amount, + ); + + Claimed { + id, + payee: escrow.payee, + amount, + } + .publish(&env); + + amount + } + + /// Freeze part of the unreleased remainder pending arbitration. + /// + /// Deliberately cannot touch `released`. Funds released before the dispute + /// were validly settled against usage that was already metered, and a + /// later disagreement about subsequent usage is not a reason to claw them + /// back — that is the difference between disputing a portion and freezing + /// the whole relationship. + pub fn dispute(env: Env, id: u64, amount: i128) { + let mut escrow = Self::load(&env, id); + Self::assert_open(&env, &escrow); + + if escrow.disputed > 0 { + panic_with_error!(&env, Error::DisputeAlreadyOpen); + } + if amount <= 0 { + panic_with_error!(&env, Error::InvalidAmount); + } + escrow + .payer + .require_auth_for_args((id, amount).into_val(&env)); + + if amount > escrow.releasable() { + panic_with_error!(&env, Error::InsufficientEscrow); + } + + escrow.disputed = amount; + Self::save(&env, &escrow); + + Disputed { id, amount }.publish(&env); + } + + /// Resolve an open dispute. Arbiter only. + /// + /// In the payee's favour the frozen amount becomes claimable; in the + /// payer's favour it returns to the releasable remainder rather than being + /// refunded outright, so a resolved dispute leaves the escrow usable + /// instead of forcing it to be torn down and reopened. + /// + /// Resolution does not consume a partial-release slot: the cap exists to + /// bound how often the payer can voluntarily settle, and an arbitrated + /// outcome is not that. + pub fn resolve_dispute(env: Env, id: u64, to_payee: bool) { + Self::arbiter(&env).require_auth(); + + let mut escrow = Self::load(&env, id); + if escrow.disputed <= 0 { + panic_with_error!(&env, Error::NoDisputeOpen); + } + + let amount = escrow.disputed; + escrow.disputed = 0; + if to_payee { + escrow.released += amount; + } + Self::save(&env, &escrow); + + DisputeResolved { + id, + amount, + to_payee, + } + .publish(&env); + } + + /// Close the escrow and refund whatever is still unreleased to the payer. + /// + /// Refuses while a dispute is open — closing then would refund the frozen + /// amount out from under the arbitration. Anything already released stays + /// claimable by the payee afterwards. + pub fn close_escrow(env: Env, id: u64) { + let mut escrow = Self::load(&env, id); + Self::assert_open(&env, &escrow); + + if escrow.disputed > 0 { + panic_with_error!(&env, Error::DisputeAlreadyOpen); + } + escrow.payer.require_auth_for_args((id,).into_val(&env)); + + let refund = escrow.releasable(); + escrow.closed = true; + Self::save(&env, &escrow); + + if refund > 0 { + token::Client::new(&env, &escrow.token).transfer( + &env.current_contract_address(), + &escrow.payer, + &refund, + ); + } + + EscrowClosed { + id, + refunded: refund, + } + .publish(&env); + } + + // ----------------------------------------------------------------------- + // Reads + // ----------------------------------------------------------------------- + + pub fn get_escrow(env: Env, id: u64) -> Escrow { + Self::load(&env, id) + } + + /// Released but not yet pulled by the payee. + pub fn claimable(env: Env, id: u64) -> i128 { + Self::load(&env, id).claimable() + } + + /// What a further release may still draw on. + pub fn releasable(env: Env, id: u64) -> i128 { + Self::load(&env, id).releasable() + } + + pub fn max_releases(_env: Env) -> u32 { + MAX_RELEASES } - /// Contract ABI / deployment marker for integrators. pub fn version(_env: Env) -> u32 { - 1 + 2 + } + + // ----------------------------------------------------------------------- + + fn arbiter(env: &Env) -> Address { + match env.storage().instance().get(&DataKey::Arbiter) { + Some(a) => a, + None => panic_with_error!(env, Error::NotInitialized), + } + } + + fn load(env: &Env, id: u64) -> Escrow { + match env.storage().persistent().get(&DataKey::Escrow(id)) { + Some(e) => e, + None => panic_with_error!(env, Error::EscrowNotFound), + } + } + + fn save(env: &Env, escrow: &Escrow) { + let key = DataKey::Escrow(escrow.id); + env.storage().persistent().set(&key, escrow); + env.storage() + .persistent() + .extend_ttl(&key, PERSISTENT_THRESHOLD, PERSISTENT_TTL); + } + + fn assert_open(env: &Env, escrow: &Escrow) { + if escrow.closed { + panic_with_error!(env, Error::EscrowClosed); + } + } + + fn next_id(env: &Env) -> u64 { + let next: u64 = env.storage().instance().get(&DataKey::Counter).unwrap_or(0) + 1; + env.storage().instance().set(&DataKey::Counter, &next); + next + } + + fn bump_instance(env: &Env) { + env.storage() + .instance() + .extend_ttl(PERSISTENT_THRESHOLD, PERSISTENT_TTL); } } + +#[cfg(test)] +mod test; diff --git a/payment-router/src/test.rs b/payment-router/src/test.rs new file mode 100644 index 0000000..a66d3c1 --- /dev/null +++ b/payment-router/src/test.rs @@ -0,0 +1,451 @@ +#![cfg(test)] + +use super::*; +use soroban_sdk::{ + testutils::{Address as _, MockAuth, MockAuthInvoke}, + token::{Client as TokenClient, StellarAssetClient}, + Env, +}; + +struct Fixture { + env: Env, + router: Address, + token: Address, + payer: Address, + payee: Address, + arbiter: Address, +} + +fn setup(funding: i128) -> Fixture { + let env = Env::default(); + env.mock_all_auths(); + + let arbiter = Address::generate(&env); + let router = env.register(PaymentRouter, ()); + PaymentRouterClient::new(&env, &router).initialize(&arbiter); + + let issuer = Address::generate(&env); + let token = env.register_stellar_asset_contract_v2(issuer).address(); + + let payer = Address::generate(&env); + let payee = Address::generate(&env); + StellarAssetClient::new(&env, &token).mint(&payer, &funding); + + Fixture { + env, + router, + token, + payer, + payee, + arbiter, + } +} + +impl Fixture { + fn client(&self) -> PaymentRouterClient<'_> { + PaymentRouterClient::new(&self.env, &self.router) + } + + fn token(&self) -> TokenClient<'_> { + TokenClient::new(&self.env, &self.token) + } + + fn open(&self, total: i128) -> u64 { + self.client() + .open_escrow(&self.payer, &self.payee, &self.token, &total) + } +} + +/// Errors are raised with `panic_with_error!`, so the generated client +/// surfaces them as host errors carrying the contract error code. +fn raised(e: Error) -> soroban_sdk::Error { + soroban_sdk::Error::from_contract_error(e as u32) +} + +// --------------------------------------------------------------------------- +// Partial release against metered usage +// --------------------------------------------------------------------------- + +#[test] +fn test_opening_an_escrow_moves_the_deposit_into_the_contract() { + let f = setup(10_000); + let id = f.open(10_000); + + assert_eq!(f.token().balance(&f.payer), 0); + assert_eq!(f.token().balance(&f.router), 10_000); + assert_eq!(f.client().releasable(&id), 10_000); + assert_eq!(f.client().claimable(&id), 0); +} + +#[test] +fn test_a_partial_release_settles_the_metered_portion_and_leaves_the_rest() { + let f = setup(10_000); + let id = f.open(10_000); + + f.client().release_partial(&id, &2_500); + + let escrow = f.client().get_escrow(&id); + assert_eq!(escrow.released, 2_500); + assert_eq!(escrow.release_count, 1); + assert_eq!(f.client().claimable(&id), 2_500); + assert_eq!(f.client().releasable(&id), 7_500); + + // Nothing was pushed: the release only makes funds claimable, so the + // contract still holds the whole deposit until the payee pulls. + assert_eq!(f.token().balance(&f.payee), 0); + assert_eq!(f.token().balance(&f.router), 10_000); +} + +#[test] +fn test_the_payee_pulls_what_has_been_released() { + let f = setup(10_000); + let id = f.open(10_000); + + f.client().release_partial(&id, &2_500); + f.client().release_partial(&id, &1_500); + + let pulled = f.client().claim(&id); + + assert_eq!(pulled, 4_000); + assert_eq!(f.token().balance(&f.payee), 4_000); + assert_eq!(f.token().balance(&f.router), 6_000); + assert_eq!(f.client().claimable(&id), 0); + + // A second pull with nothing newly released takes nothing. + assert_eq!( + f.client().try_claim(&id), + Err(Ok(raised(Error::NothingToClaim))) + ); +} + +#[test] +fn test_a_release_larger_than_the_remaining_escrow_is_rejected() { + let f = setup(10_000); + let id = f.open(10_000); + + f.client().release_partial(&id, &9_000); + assert_eq!( + f.client().try_release_partial(&id, &1_001), + Err(Ok(raised(Error::InsufficientEscrow))) + ); + + // Exactly the remainder is fine. + f.client().release_partial(&id, &1_000); + assert_eq!(f.client().releasable(&id), 0); +} + +#[test] +fn test_zero_and_negative_releases_are_rejected() { + let f = setup(10_000); + let id = f.open(10_000); + + assert_eq!( + f.client().try_release_partial(&id, &0), + Err(Ok(raised(Error::InvalidAmount))) + ); + assert_eq!( + f.client().try_release_partial(&id, &-100), + Err(Ok(raised(Error::InvalidAmount))) + ); +} + +// --------------------------------------------------------------------------- +// Invariants +// --------------------------------------------------------------------------- + +#[test] +fn test_releases_never_sum_past_the_escrowed_total() { + let f = setup(10_000); + let id = f.open(10_000); + + // Settle repeatedly, interleaving claims, and keep pushing past the point + // where the deposit is exhausted. The escrow must stay inside its own + // deposit at every step, and the contract must never pay out more than it + // was given. + let mut expected_released = 0i128; + for i in 1..=MAX_RELEASES { + let amount = 400i128; + let result = f.client().try_release_partial(&id, &amount); + + if expected_released + amount <= 10_000 { + assert!(result.is_ok(), "release {i} should have succeeded"); + expected_released += amount; + } else { + assert_eq!(result, Err(Ok(raised(Error::InsufficientEscrow)))); + } + + let escrow = f.client().get_escrow(&id); + assert_eq!(escrow.released, expected_released); + assert!(escrow.released <= escrow.total); + assert!(escrow.claimed <= escrow.released); + assert!(escrow.releasable() >= 0); + + if i % 3 == 0 && escrow.claimable() > 0 { + f.client().claim(&id); + } + } + + let escrow = f.client().get_escrow(&id); + assert!(escrow.released <= 10_000); + if escrow.claimable() > 0 { + f.client().claim(&id); + } + assert_eq!(f.token().balance(&f.payee), escrow.released); + assert_eq!( + f.token().balance(&f.router), + 10_000 - escrow.released, + "the contract never pays out more than it holds" + ); +} + +#[test] +fn test_the_partial_release_count_is_capped() { + let f = setup(1_000_000); + let id = f.open(1_000_000); + + for _ in 0..MAX_RELEASES { + f.client().release_partial(&id, &1); + } + + let escrow = f.client().get_escrow(&id); + assert_eq!(escrow.release_count, MAX_RELEASES); + // Plenty still escrowed — the cap, not the balance, is what stops this. + assert!(escrow.releasable() > 0); + assert_eq!( + f.client().try_release_partial(&id, &1), + Err(Ok(raised(Error::ReleaseCapReached))) + ); + assert_eq!(f.client().max_releases(), MAX_RELEASES); +} + +// --------------------------------------------------------------------------- +// Disputes freeze only the disputed portion +// --------------------------------------------------------------------------- + +#[test] +fn test_a_dispute_does_not_touch_funds_already_released() { + let f = setup(10_000); + let id = f.open(10_000); + + f.client().release_partial(&id, &3_000); + f.client().dispute(&id, &2_000); + + let escrow = f.client().get_escrow(&id); + assert_eq!(escrow.disputed, 2_000); + // The earlier release was settled against usage already metered; a later + // disagreement is not a reason to claw it back. + assert_eq!(escrow.released, 3_000); + assert_eq!(f.client().claimable(&id), 3_000); + + // And the payee can still take it while the dispute is open. + assert_eq!(f.client().claim(&id), 3_000); + assert_eq!(f.token().balance(&f.payee), 3_000); +} + +#[test] +fn test_a_dispute_freezes_only_its_own_amount_leaving_the_rest_settleable() { + let f = setup(10_000); + let id = f.open(10_000); + + f.client().dispute(&id, &4_000); + + // 10_000 deposited, 4_000 frozen — the other 6_000 keeps settling normally. + assert_eq!(f.client().releasable(&id), 6_000); + f.client().release_partial(&id, &6_000); + assert_eq!(f.client().claimable(&id), 6_000); + + // The frozen portion is not available to release around. + assert_eq!( + f.client().try_release_partial(&id, &1), + Err(Ok(raised(Error::InsufficientEscrow))) + ); +} + +#[test] +fn test_resolving_for_the_payee_makes_the_frozen_amount_claimable() { + let f = setup(10_000); + let id = f.open(10_000); + + f.client().dispute(&id, &4_000); + f.client().resolve_dispute(&id, &true); + + let escrow = f.client().get_escrow(&id); + assert_eq!(escrow.disputed, 0); + assert_eq!(escrow.released, 4_000); + // Arbitration does not consume a voluntary-settlement slot. + assert_eq!(escrow.release_count, 0); + assert_eq!(f.client().claim(&id), 4_000); +} + +#[test] +fn test_resolving_for_the_payer_returns_the_amount_to_the_escrow() { + let f = setup(10_000); + let id = f.open(10_000); + + f.client().dispute(&id, &4_000); + f.client().resolve_dispute(&id, &false); + + let escrow = f.client().get_escrow(&id); + assert_eq!(escrow.disputed, 0); + assert_eq!(escrow.released, 0); + // Usable again rather than torn down: the relationship survives one + // resolved disagreement. + assert_eq!(f.client().releasable(&id), 10_000); +} + +#[test] +fn test_only_the_arbiter_resolves_and_only_an_open_dispute() { + let f = setup(10_000); + let id = f.open(10_000); + + assert_eq!( + f.client().try_resolve_dispute(&id, &true), + Err(Ok(raised(Error::NoDisputeOpen))) + ); + + f.client().dispute(&id, &1_000); + assert_eq!( + f.client().try_dispute(&id, &1_000), + Err(Ok(raised(Error::DisputeAlreadyOpen))) + ); + + // Auth is checked without mock_all_auths, since that would approve the + // impostor's signature as readily as the arbiter's. + let impostor = Address::generate(&f.env); + f.env.mock_auths(&[MockAuth { + address: &impostor, + invoke: &MockAuthInvoke { + contract: &f.router, + fn_name: "resolve_dispute", + args: (id, true).into_val(&f.env), + sub_invokes: &[], + }, + }]); + assert!(f.client().try_resolve_dispute(&id, &true).is_err()); + assert_eq!(f.client().get_escrow(&id).disputed, 1_000); + + f.env.mock_auths(&[MockAuth { + address: &f.arbiter, + invoke: &MockAuthInvoke { + contract: &f.router, + fn_name: "resolve_dispute", + args: (id, true).into_val(&f.env), + sub_invokes: &[], + }, + }]); + f.client().resolve_dispute(&id, &true); + assert_eq!(f.client().get_escrow(&id).disputed, 0); +} + +// --------------------------------------------------------------------------- +// Closing +// --------------------------------------------------------------------------- + +#[test] +fn test_closing_refunds_the_remainder_and_leaves_released_funds_claimable() { + let f = setup(10_000); + let id = f.open(10_000); + + f.client().release_partial(&id, &2_500); + f.client().close_escrow(&id); + + // Unreleased remainder goes back to the payer... + assert_eq!(f.token().balance(&f.payer), 7_500); + // ...and the payee is not stranded by the close. + assert_eq!(f.client().claim(&id), 2_500); + assert_eq!(f.token().balance(&f.payee), 2_500); + assert_eq!(f.token().balance(&f.router), 0); + + assert_eq!( + f.client().try_release_partial(&id, &1), + Err(Ok(raised(Error::EscrowClosed))) + ); +} + +#[test] +fn test_closing_is_refused_while_a_dispute_is_open() { + let f = setup(10_000); + let id = f.open(10_000); + + f.client().dispute(&id, &4_000); + // Closing here would refund the frozen amount out from under arbitration. + assert_eq!( + f.client().try_close_escrow(&id), + Err(Ok(raised(Error::DisputeAlreadyOpen))) + ); +} + +#[test] +fn test_an_escrow_needs_two_distinct_parties_and_a_positive_amount() { + let f = setup(10_000); + + assert_eq!( + f.client() + .try_open_escrow(&f.payer, &f.payer, &f.token, &1_000), + Err(Ok(raised(Error::SamePayerAndPayee))) + ); + assert_eq!( + f.client().try_open_escrow(&f.payer, &f.payee, &f.token, &0), + Err(Ok(raised(Error::InvalidAmount))) + ); +} + +// --------------------------------------------------------------------------- +// Authorization +// --------------------------------------------------------------------------- + +#[test] +fn test_the_payer_signature_is_bound_to_the_release_amount() { + let f = setup(10_000); + let id = f.open(10_000); + + // Signed to settle 1_000. + f.env.mock_auths(&[MockAuth { + address: &f.payer, + invoke: &MockAuthInvoke { + contract: &f.router, + fn_name: "release_partial", + args: (id, 1_000i128).into_val(&f.env), + sub_invokes: &[], + }, + }]); + + // Submitted to settle 9_000. Binding the signature to the amount is what + // stops a release the payer agreed to from being enlarged in transit. + assert!(f.client().try_release_partial(&id, &9_000).is_err()); + assert_eq!(f.client().get_escrow(&id).released, 0); + + f.env.mock_auths(&[MockAuth { + address: &f.payer, + invoke: &MockAuthInvoke { + contract: &f.router, + fn_name: "release_partial", + args: (id, 1_000i128).into_val(&f.env), + sub_invokes: &[], + }, + }]); + f.client().release_partial(&id, &1_000); + assert_eq!(f.client().get_escrow(&id).released, 1_000); +} + +#[test] +fn test_only_the_payee_can_pull() { + let f = setup(10_000); + let id = f.open(10_000); + f.client().release_partial(&id, &1_000); + + let stranger = Address::generate(&f.env); + f.env.mock_auths(&[MockAuth { + address: &stranger, + invoke: &MockAuthInvoke { + contract: &f.router, + fn_name: "claim", + args: (id,).into_val(&f.env), + sub_invokes: &[], + }, + }]); + + assert!(f.client().try_claim(&id).is_err()); + assert_eq!(f.token().balance(&stranger), 0); + assert_eq!(f.client().claimable(&id), 1_000); +}