Skip to content
Merged
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
12 changes: 7 additions & 5 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ This repository contains the core Soroban smart contracts that power StellarSett

- **Invoice Escrow**: Secure escrow creation, funding, and settlement
- **Invoice Tokens**: SEP-41 compliant tokenization of invoices
- **Payment Distribution**: Automated payment distribution to investors
- **Payment Distribution**: Lifecycle-driven payout fan-out for seller, investor, and platform fee
- **Emergency Pause**: Admin-controlled stop switches for escrow and token operations

## 🏗️ Architecture
```
Expand Down Expand Up @@ -55,7 +56,7 @@ soroban contract build

Repeatable deployment scripts live in [`scripts/`](scripts/). See the [**How to run scripts**](#-how-to-run-scripts) section below for full instructions.

The scripts deploy and initialise all three contracts in the correct order using environment variables. No manual copy-paste of WASM paths or contract IDs required.
The scripts deploy and initialise all three contracts, then wire `invoice-escrow` to `payment-distributor` so settlement and refund payouts run through the distributor flow by default.

## 📚 Contract Documentation

Expand Down Expand Up @@ -120,7 +121,8 @@ The script:
2. Validates all required variables and WASM paths
3. Deploys **invoice-token**, **invoice-escrow**, and **payment-distributor** in order
4. Calls `initialize` on each contract with the configured arguments
5. Prints a summary of deployed contract IDs
5. Calls `invoice-escrow.set_payment_distributor(...)` to enable distributor-based payouts
6. Prints a summary of deployed contract IDs

### 3b. PowerShell (Windows)

Expand All @@ -144,7 +146,7 @@ INVOICE_TOKEN_CONTRACT_ID=C...
PAYMENT_DISTRIBUTOR_CONTRACT_ID=C...
```

The deploy step is skipped for any contract whose ID is pre-filled; `initialize` is still called.
The deploy step is skipped for any contract whose ID is pre-filled; `initialize` is still called, and the escrow-to-distributor wiring step still runs.

## 🧪 Testing
```bash
Expand Down Expand Up @@ -197,4 +199,4 @@ MIT License - see [LICENSE](LICENSE) file for details

---

Built with ❤️ on Stellar
Built with ❤️ on Stellar
3 changes: 2 additions & 1 deletion contracts/invoice-escrow/src/errors.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
//! Error types for the invoice escrow contract.

use soroban_sdk::contracterror;

/// Errors that can occur during contract execution.
Expand Down Expand Up @@ -35,6 +34,8 @@ pub enum Error {
Overflow = 13,
/// Escrow has been cancelled by the seller.
EscrowCancelled = 14,
/// Contract is paused and the requested operation is temporarily disabled.
Paused = 15,
/// Payer is not the authorized debtor for this invoice.
InvalidPayer = 15,
/// Due date is invalid (e.g., in the past or zero).
Expand Down
23 changes: 23 additions & 0 deletions contracts/invoice-escrow/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,26 @@ pub fn platform_fee_updated(env: &Env, old_fee_bps: u32, new_fee_bps: u32) {
(old_fee_bps, new_fee_bps),
);
}

/// Publish payment distributor update event with previous and new distributor addresses.
pub fn payment_distributor_updated(
env: &Env,
had_previous_distributor: bool,
new_distributor: &Address,
) {
env.events().publish(
(
Symbol::new(env, "distributor_updated"),
new_distributor.clone(),
),
had_previous_distributor,
);
}

/// Publish pause state updates.
pub fn paused_updated(env: &Env, old_paused: bool, new_paused: bool) {
env.events().publish(
(Symbol::new(env, "paused_updated"),),
(old_paused, new_paused),
);
}
199 changes: 154 additions & 45 deletions contracts/invoice-escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,26 @@ mod types;

use soroban_sdk::{contract, contractimpl, token, Address, Env, IntoVal, Symbol};

// EscrowStatus is re-exported publicly; Config and EscrowData are crate-private.
pub use types::EscrowStatus;
use types::{Config, EscrowData};

use errors::Error;
use types::{Config, EscrowData};

const MAX_BPS: u32 = 10_000;
const DISTRIBUTE_PAYMENT_FN: &str = "distribute_payment";
const DISTRIBUTE_REFUND_FN: &str = "distribute_refund";

#[contract]
pub struct InvoiceEscrow;

fn ensure_not_paused(config: &Config) -> Result<(), Error> {
if config.paused {
return Err(Error::Paused);
}
Ok(())
}

#[contractimpl]
impl InvoiceEscrow {
/// Initialize the contract with admin and platform fee (basis points, e.g. 300 = 3%).
Expand All @@ -35,6 +45,8 @@ impl InvoiceEscrow {
let config = Config {
admin: admin.clone(),
fee_bps: platform_fee_bps,
payment_distributor: None,
paused: false,
};
storage::set_config(&env, &config);
Ok(())
Expand Down Expand Up @@ -107,6 +119,8 @@ impl InvoiceEscrow {
/// Emits `escrow_cancelled` with `(invoice_id, seller)`.
pub fn cancel_escrow(env: Env, invoice_id: Symbol, seller: Address) -> Result<(), Error> {
seller.require_auth();
let config = storage::get_config(&env).ok_or(Error::NotInit)?;
ensure_not_paused(&config)?;
let mut data =
storage::get_escrow(&env, invoice_id.clone()).ok_or(Error::EscrowNotFound)?;
if data.seller != seller {
Expand All @@ -130,9 +144,13 @@ impl InvoiceEscrow {
amount: i128,
) -> Result<(), Error> {
buyer.require_auth();
// Fail fast: validate amount before hitting storage.
if amount <= 0 {
return Err(Error::InvalidAmount);
}
let config = storage::get_config(&env).ok_or(Error::NotInit)?;
ensure_not_paused(&config)?;

let mut data =
storage::get_escrow(&env, invoice_id.clone()).ok_or(Error::EscrowNotFound)?;
if data.status == EscrowStatus::Cancelled {
Expand Down Expand Up @@ -210,6 +228,7 @@ impl InvoiceEscrow {
return Err(Error::InvalidAmount);
}
let config = storage::get_config(&env).ok_or(Error::NotInit)?;
ensure_not_paused(&config)?;
let mut data =
storage::get_escrow(&env, invoice_id.clone()).ok_or(Error::EscrowNotFound)?;

Expand Down Expand Up @@ -243,54 +262,90 @@ impl InvoiceEscrow {
let token = token::Client::new(&env, &data.token);
let contract = env.current_contract_address();

// 1. Transfer payer funds into escrow
// 1. Pull payer's funds into escrow
token.transfer(&payer, &contract, &amount);

// 2. Distribute platform fee to admin
token.transfer(&contract, &config.admin, &platform_fee);

// 3. Distribute investor_amount pro-rata to all funders
// MVP: For single funder, distribute full amount. For multiple, pro-rata via invoice tokens.
if let Some(funder) = &data.funder {
if data.funded_amt > 0 && investor_amount > 0 {
// Pro-rata: each funder gets (their_amount / total_funded) * investor_amount
let funder_amt = storage::get_funder_amount(&env, invoice_id.clone(), funder);
let pro_rata_share = investor_amount
.checked_mul(funder_amt)
.ok_or(Error::Overflow)?
.checked_div(data.funded_amt)
.ok_or(Error::Overflow)?;

if pro_rata_share > 0 {
token.transfer(&contract, funder, &pro_rata_share);
}
}
}

// 4. Release corresponding funding from initial buy-in back to the seller
token.transfer(&contract, &data.seller, &amount);

data.paid_amt = data.paid_amt.checked_add(amount).ok_or(Error::Overflow)?;

// Settlement occurs when paid_amt reaches face_value
if data.paid_amt == data.face_value {
data.status = EscrowStatus::Settled;
// Unlock invoice token transfers only when the invoice is completely settled
}

storage::set_escrow(&env, invoice_id.clone(), &data);

// Extract funder address before branching so it is available in both paths.
let funder_opt = data.funder.clone();

if let Some(distributor) = config.payment_distributor.as_ref() {
// Forward the full payment amount to the distributor contract.
// Fix: was `amount + amount` (double-counting); correct is investor_amount + platform_fee == amount.
let total_to_distributor = investor_amount
.checked_add(platform_fee)
.ok_or(Error::Overflow)?;
token.transfer(&contract, distributor, &total_to_distributor);
env.invoke_contract::<()>(
distributor,
&Symbol::new(&env, DISTRIBUTE_PAYMENT_FN),
soroban_sdk::vec![
&env,
contract.to_val(),
invoice_id.clone().into_val(&env),
soroban_sdk::vec![
&env,
data.token.clone(),
data.seller.clone(),
funder_opt.clone().into_val(&env),
config.admin.clone()
]
.into_val(&env),
soroban_sdk::vec![&env, data.paid_amt, amount, investor_amount, platform_fee]
.into_val(&env),
(data.status as u32).into_val(&env)
],
);
} else {
// 2. Platform fee to admin
token.transfer(&contract, &config.admin, &platform_fee);

// 3. Pro-rata investor distribution
if let Some(funder) = &funder_opt {
if data.funded_amt > 0 && investor_amount > 0 {
let funder_amt =
storage::get_funder_amount(&env, invoice_id.clone(), funder);
let pro_rata_share = investor_amount
.checked_mul(funder_amt)
.ok_or(Error::Overflow)?
.checked_div(data.funded_amt)
.ok_or(Error::Overflow)?;
if pro_rata_share > 0 {
token.transfer(&contract, funder, &pro_rata_share);
}
}
}

// 4. Release the purchase_price collateral back to the seller
token.transfer(&contract, &data.seller, &amount);
}

if data.status == EscrowStatus::Settled {
// Unlock invoice token transfers only when the invoice is completely settled.
env.invoke_contract::<()>(
&data.inv_token,
&Symbol::new(&env, "set_transfer_locked"),
soroban_sdk::vec![&env, contract.to_val(), false.into_val(&env)],
);
}

storage::set_escrow(&env, invoice_id.clone(), &data);
events::payment_settled(&env, invoice_id, amount, platform_fee, investor_amount);
Ok(())
}

/// Refund the investors if the invoice was not paid by due date. Anyone may call.
/// Refunds are distributed pro-rata based on each investor's contribution.
pub fn refund(env: Env, invoice_id: Symbol) -> Result<(), Error> {
let config = storage::get_config(&env).ok_or(Error::NotInit)?;
ensure_not_paused(&config)?;
let mut data =
storage::get_escrow(&env, invoice_id.clone()).ok_or(Error::EscrowNotFound)?;
if data.status != EscrowStatus::Funded {
Expand All @@ -301,7 +356,7 @@ impl InvoiceEscrow {
return Err(Error::RefundNotAllowed);
}

// Refund the remaining collateral (initial purchase_price minus already released partial payments)
// Refund the remaining collateral (purchase_price minus already released partial payments)
let amount_to_refund = data
.purchase_price
.checked_sub(data.paid_amt)
Expand All @@ -310,27 +365,51 @@ impl InvoiceEscrow {
let token = token::Client::new(&env, &data.token);
let contract = env.current_contract_address();

if amount_to_refund > 0 {
// MVP: Distribute pro-rata to all funders
if let Some(funder) = &data.funder {
if data.funded_amt > 0 {
let funder_amt = storage::get_funder_amount(&env, invoice_id.clone(), funder);
let pro_rata_refund = amount_to_refund
.checked_mul(funder_amt)
.ok_or(Error::Overflow)?
.checked_div(data.funded_amt)
.ok_or(Error::Overflow)?;
// Extract funder address before status mutation so it is available in both paths.
let funder_opt = data.funder.clone();

if pro_rata_refund > 0 {
token.transfer(&contract, funder, &pro_rata_refund);
data.status = EscrowStatus::Refunded;
storage::set_escrow(&env, invoice_id.clone(), &data);

if amount_to_refund > 0 {
if let Some(distributor) = config.payment_distributor.as_ref() {
token.transfer(&contract, distributor, &amount_to_refund);
env.invoke_contract::<()>(
distributor,
&Symbol::new(&env, DISTRIBUTE_REFUND_FN),
soroban_sdk::vec![
&env,
contract.to_val(),
invoice_id.clone().into_val(&env),
soroban_sdk::vec![
&env,
data.token.clone(),
funder_opt.clone().into_val(&env)
]
.into_val(&env),
soroban_sdk::vec![&env, amount_to_refund].into_val(&env),
(data.status as u32).into_val(&env)
],
);
} else {
// Pro-rata refund to funders
if let Some(funder) = &funder_opt {
if data.funded_amt > 0 {
let funder_amt =
storage::get_funder_amount(&env, invoice_id.clone(), funder);
let pro_rata_refund = amount_to_refund
.checked_mul(funder_amt)
.ok_or(Error::Overflow)?
.checked_div(data.funded_amt)
.ok_or(Error::Overflow)?;
if pro_rata_refund > 0 {
token.transfer(&contract, funder, &pro_rata_refund);
}
}
}
}
}

data.status = EscrowStatus::Refunded;
storage::set_escrow(&env, invoice_id.clone(), &data);

// Unlock invoice token transfers now that the invoice is refunded
env.invoke_contract::<()>(
&data.inv_token,
Expand All @@ -357,6 +436,30 @@ impl InvoiceEscrow {
Ok(())
}

/// Set the payment distributor used for settlement/refund fan-out. Admin only.
pub fn set_payment_distributor(env: Env, payment_distributor: Address) -> Result<(), Error> {
let mut config = storage::get_config(&env).ok_or(Error::NotInit)?;
let admin = config.admin.clone();
admin.require_auth();
let old_distributor = config.payment_distributor.clone();
config.payment_distributor = Some(payment_distributor.clone());
storage::set_config(&env, &config);
events::payment_distributor_updated(&env, old_distributor.is_some(), &payment_distributor);
Ok(())
}

/// Toggle the emergency pause flag. Admin only.
pub fn set_paused(env: Env, paused: bool) -> Result<(), Error> {
let mut config = storage::get_config(&env).ok_or(Error::NotInit)?;
let admin = config.admin.clone();
admin.require_auth();
let old_paused = config.paused;
config.paused = paused;
storage::set_config(&env, &config);
events::paused_updated(&env, old_paused, paused);
Ok(())
}

/// View: return escrow data for an invoice, or None if not found.
pub fn get_escrow(env: Env, invoice_id: Symbol) -> Result<EscrowData, Error> {
storage::get_escrow(&env, invoice_id).ok_or(Error::EscrowNotFound)
Expand All @@ -372,9 +475,15 @@ impl InvoiceEscrow {
let data = storage::get_escrow(&env, invoice_id).ok_or(Error::EscrowNotFound)?;
Ok(data.status)
}

/// View: return the current pause state.
pub fn paused(env: Env) -> Result<bool, Error> {
let config = storage::get_config(&env).ok_or(Error::NotInit)?;
Ok(config.paused)
}
}

#[cfg(test)]
mod integration_test;
#[cfg(test)]
mod test;
mod test;
Loading
Loading