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); +}