Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 105 additions & 49 deletions contracts/escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ pub use crate::events::{
};
pub use crate::types::{
ContractConfig, ContractStats, DataKey, DisputeData, DisputeStatus, EscrowData, EscrowState,
FeeConfig, PublicContractConfig, ResolutionType, EscrowInput,
FeeConfig, PublicContractConfig, ResolutionType, EscrowInput, Payee,
};

/// Maximum escrow fee in basis points (300 = 3%).
Expand Down Expand Up @@ -221,27 +221,6 @@ fn write_treasury(env: &Env, treasury: &Address) {
}


#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct EscrowData {
pub payees: Vec<Payee>,
pub buyer: Option<Address>,
pub resolver: Address,
pub token: Address,
pub amount: i128,
pub fee_bps: u32,
pub resolver_fee_bps: u32,
pub shipping_window: u64,
pub funded_at: u64,
pub dispute_deadline: u64,
pub shipped_at: u64,
pub delivered_at: Option<u64>,
pub tracking_id: Option<String>,
pub state: EscrowState,
env.storage()
.instance()
.set(&DataKey::FeeConfig, fee_config);
}

fn validate_escrow_fee_bps(fee_bps: u32) -> Result<(), ContractError> {
if fee_bps > MAX_ESCROW_FEE_BPS {
Expand Down Expand Up @@ -365,8 +344,18 @@ fn get_ttl_extension(env: &Env) -> u32 {
fn save_escrow(env: &Env, id: u64, escrow: &EscrowData) {
let key = DataKey::Escrow(id);
let ext = get_ttl_extension(env);
let previous: Option<EscrowData> = env.storage().persistent().get(&key);
let state_changed = previous
.as_ref()
.map(|existing| existing.state != escrow.state)
.unwrap_or(true);

env.storage().persistent().set(&key, escrow);
env.storage().persistent().extend_ttl(&key, ext / 2, ext);

if state_changed {
append_state_history(env, id, &escrow.state);
}
}

fn load_escrow(env: &Env, id: u64) -> Result<EscrowData, ContractError> {
Expand All @@ -381,6 +370,35 @@ fn load_escrow(env: &Env, id: u64) -> Result<EscrowData, ContractError> {
Ok(escrow)
}

fn append_state_history(env: &Env, id: u64, state: &EscrowState) {
let key = DataKey::EscrowStateHistory(id);
let ext = get_ttl_extension(env);
let mut history: Vec<(EscrowState, u64)> = env
.storage()
.persistent()
.get(&key)
.unwrap_or_else(|| Vec::new(env));

history.push_back((state.clone(), env.ledger().timestamp()));
env.storage().persistent().set(&key, &history);
env.storage().persistent().extend_ttl(&key, ext / 2, ext);
}

fn load_state_history(env: &Env, id: u64) -> Vec<(EscrowState, u64)> {
let key = DataKey::EscrowStateHistory(id);
let ext = get_ttl_extension(env);
let history = env
.storage()
.persistent()
.get(&key)
.unwrap_or_else(|| Vec::new(env));

if !history.is_empty() {
env.storage().persistent().extend_ttl(&key, ext / 2, ext);
}
history
}

fn save_dispute(env: &Env, id: u64, dispute: &DisputeData) {
let key = DataKey::Dispute(id);
let ext = get_ttl_extension(env);
Expand Down Expand Up @@ -509,15 +527,18 @@ fn increment_counter(env: &Env, key: &DataKey) -> Result<(), ContractError> {

fn create_escrow_internal(
env: &Env,
seller: Address,
payees: Vec<Payee>,
buyer: Option<Address>,
resolver: Address,
token: Address,
amount: i128,
fee_bps: u32,
resolver_fee_bps: u32,
shipping_window: u64,
notes: Option<String>,
) -> Result<u64, ContractError> {
let first_payee = payees.get(0).unwrap();
let seller = first_payee.address.clone();
seller.require_auth();

ensure_not_paused(env)?;
Expand Down Expand Up @@ -571,20 +592,21 @@ fn create_escrow_internal(
env.storage().instance().extend_ttl(ext / 2, ext);

let escrow = EscrowData {
payees,
seller,
buyer,
resolver,
token,
amount,
fee_bps,
resolver_fee_bps,
shipping_window,
funded_at: 0,
dispute_deadline: 0,
state: EscrowState::Pending,
shipped_at: 0,
delivered_at: None,
tracking_id: None,

};

save_escrow(env, escrow_id, &escrow);
Expand All @@ -603,6 +625,7 @@ fn create_escrow_internal(
escrow.token.clone(),
escrow.amount,
escrow.fee_bps,
escrow.resolver_fee_bps,
escrow.shipping_window,
);
Ok(escrow_id)
Expand Down Expand Up @@ -833,7 +856,7 @@ impl Escrow {
return Err(ContractError::InvalidAddress);
}
let first_payee = payees.get(0).unwrap();
first_payee.address.require_auth();
// SECURITY: Auth is handled in create_escrow_internal

ensure_not_paused(&env)?;

Expand Down Expand Up @@ -868,27 +891,9 @@ impl Escrow {
// Token allowlist check
is_token_allowed(&env, &token)?;

let escrow_id: u64 = env
.storage()
.instance()
.get(&DataKey::EscrowCounter)
.expect("counter initialized");
let next_id = escrow_id
.checked_add(1)
.ok_or(ContractError::ArithmeticError)?;
env.storage()
.instance()
.set(&DataKey::EscrowCounter, &next_id);

// Extend instance storage TTL on every counter access so the counter key
// cannot expire between a read and the subsequent write.
let ext = get_ttl_extension(&env);
env.storage().instance().extend_ttl(ext / 2, ext);

let escrow = EscrowData {
create_escrow_internal(
&env,
seller,
payees,
buyer,
resolver,
token,
Expand All @@ -902,23 +907,56 @@ impl Escrow {

pub fn create_escrow_with_notes(
env: Env,
seller: Address,
payees: Vec<Payee>,
buyer: Option<Address>,
resolver: Address,
token: Address,
amount: i128,
fee_bps: u32,
resolver_fee_bps: u32,
shipping_window: u64,
notes: Option<String>,
) -> Result<u64, ContractError> {
if payees.is_empty() {
return Err(ContractError::InvalidAddress);
}
// SECURITY: Auth is handled in create_escrow_internal
ensure_not_paused(&env)?;

if amount <= 0 || amount < MIN_ESCROW_AMOUNT {
return Err(ContractError::InvalidAmount);
}
if amount > MAX_ESCROW_AMOUNT {
return Err(ContractError::AmountExceedsMaximum);
}

validate_escrow_fee_bps(fee_bps)?;
validate_resolver_fee_bps(resolver_fee_bps)?;
validate_payees(&env, &payees)?;

for i in 0..payees.len() {
let payee = payees.get(i).unwrap();
if resolver == payee.address {
return Err(ContractError::ConflictingRoles);
}
if let Some(ref b) = buyer {
if b == &payee.address {
return Err(ContractError::ConflictingRoles);
}
}
}

is_token_allowed(&env, &token)?;

create_escrow_internal(
&env,
seller,
payees,
buyer,
resolver,
token,
amount,
fee_bps,
resolver_fee_bps,
shipping_window,
notes,
)
Expand Down Expand Up @@ -1209,7 +1247,7 @@ impl Escrow {
.clone()
.unwrap_or(String::from_str(&env, ""));
save_escrow(&env, escrow_id, &escrow);
emit_escrow_shipped(&env, escrow_id, first_payee.address.clone(), tracking);
emit_escrow_shipped(&env, escrow_id, escrow.seller.clone(), tracking);
Ok(())
}

Expand Down Expand Up @@ -1299,7 +1337,7 @@ impl Escrow {
emit_escrow_completed(
&env,
escrow_id,
first_payee.address.clone(),
escrow.seller.clone(),
escrow.amount,
escrow.fee_bps,
);
Expand Down Expand Up @@ -1617,6 +1655,7 @@ impl Escrow {
recipient,
escrow.amount,
0, // arbitration fee already deducted
0, // resolver_fee
);
Ok(())
}
Expand Down Expand Up @@ -1891,13 +1930,17 @@ impl Escrow {
let primary_amount = amounts.get(0).ok_or(ContractError::InvalidAmount)?;
let primary_token = tokens.get(0).ok_or(ContractError::InvalidAmount)?;

let mut primary_payees: Vec<Payee> = Vec::new(&env);
primary_payees.push_back(Payee { address: seller.clone(), bps: 10_000 });
let escrow = EscrowData {
payees: primary_payees,
seller: seller.clone(),
buyer: buyer.clone(),
resolver: resolver.clone(),
token: primary_token,
amount: primary_amount,
fee_bps,
resolver_fee_bps: 0,
shipping_window,
funded_at: 0,
dispute_deadline: 0,
Expand All @@ -1923,6 +1966,14 @@ impl Escrow {
load_escrow(&env, escrow_id)
}

pub fn get_state_history(
env: Env,
escrow_id: u64,
) -> Result<Vec<(EscrowState, u64)>, ContractError> {
load_escrow(&env, escrow_id)?;
Ok(load_state_history(&env, escrow_id))
}

/// Retrieves the dispute data for a specific escrow, if any.
pub fn get_dispute(env: Env, escrow_id: u64) -> Option<DisputeData> {
load_dispute(&env, escrow_id).ok()
Expand Down Expand Up @@ -2159,14 +2210,17 @@ impl Escrow {

let mut escrow_ids = Vec::new(&env);
for input in escrows.into_iter() {
let mut payees: Vec<Payee> = Vec::new(&env);
payees.push_back(Payee { address: seller.clone(), bps: 10_000 });
let id = create_escrow_internal(
&env,
seller.clone(),
payees,
input.buyer,
input.resolver,
input.token,
input.amount,
input.fee_bps,
0_u32,
input.shipping_window,
input.notes,
)?;
Expand Down Expand Up @@ -2211,6 +2265,7 @@ mod test_auto_release;
mod test_cancel_restrictions;
mod test_concurrent_vendor_escrows;
mod test_contract_config;
mod test_create_escrow_boundary;
mod test_delivery;
mod test_dispute;
mod test_dispute_flow;
Expand All @@ -2234,6 +2289,7 @@ mod test_resolution;
mod test_resolver_rotation;
mod test_mutual_cancel;
mod test_set_fee_boundary;
mod test_state_history;
mod test_string_length;
mod test_ttl;
mod test_unauthorized;
Expand Down
Loading