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
41 changes: 41 additions & 0 deletions .github/workflows/smoke-test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: Testnet Smoke Test

on:
workflow_dispatch:
inputs:
network:
description: "Stellar network to target"
required: false
default: "testnet"
type: choice
options: [testnet, futurenet]

jobs:
smoke-test:
name: Escrow lifecycle smoke test (${{ inputs.network }})
runs-on: ubuntu-latest
environment: testnet-smoke # store secrets in this GitHub environment

steps:
- uses: actions/checkout@v4

- name: Install Rust + wasm32 target
uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown

- name: Install Stellar CLI
run: |
cargo install --locked stellar-cli --features opt

- name: Run smoke test
env:
STELLAR_NETWORK: ${{ inputs.network }}
ADMIN_SECRET: ${{ secrets.ADMIN_SECRET }}
SELLER_SECRET: ${{ secrets.SELLER_SECRET }}
BUYER_SECRET: ${{ secrets.BUYER_SECRET }}
PAYER_SECRET: ${{ secrets.PAYER_SECRET }}
USDC_TOKEN_ADDRESS: ${{ secrets.USDC_TOKEN_ADDRESS }}
run: |
chmod +x scripts/smoke-test.sh
./scripts/smoke-test.sh
2 changes: 2 additions & 0 deletions contracts/invoice-escrow/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,6 @@ pub enum Error {
TransferFailed = 12,
/// Arithmetic overflow or invalid operation.
Overflow = 13,
/// Escrow has been cancelled by the seller.
EscrowCancelled = 14,
}
8 changes: 8 additions & 0 deletions contracts/invoice-escrow/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ pub fn escrow_refunded(env: &Env, inv_id: Symbol, funder: &Address, amount: i128
);
}

/// Publish escrow_cancelled event (invoice_id, seller).
pub fn escrow_cancelled(env: &Env, inv_id: Symbol, seller: &Address) {
env.events().publish(
(Symbol::new(env, "escrow_cancelled"),),
(inv_id, seller),
);
}

/// Publish platform fee update event with old and new basis points.
pub fn platform_fee_updated(env: &Env, old_fee_bps: u32, new_fee_bps: u32) {
env.events().publish(
Expand Down
22 changes: 22 additions & 0 deletions contracts/invoice-escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,33 @@ impl InvoiceEscrow {
Ok(())
}

/// Cancel an unfunded escrow. Only the seller may cancel, and only while status is Created.
///
/// Emits `escrow_cancelled` with `(invoice_id, seller)`.
pub fn cancel_escrow(env: Env, invoice_id: Symbol, seller: Address) -> Result<(), Error> {
seller.require_auth();
let mut data =
storage::get_escrow(&env, invoice_id.clone()).ok_or(Error::EscrowNotFound)?;
if data.seller != seller {
return Err(Error::Unauthorized);
}
if data.status != EscrowStatus::Created {
return Err(Error::EscrowFunded);
}
data.status = EscrowStatus::Cancelled;
storage::set_escrow(&env, invoice_id.clone(), &data);
events::escrow_cancelled(&env, invoice_id, &seller);
Ok(())
}

/// Fund the escrow (investor buys the invoice). Transfers `amount` from buyer to this contract.
pub fn fund_escrow(env: Env, invoice_id: Symbol, buyer: Address) -> Result<(), Error> {
buyer.require_auth();
let mut data =
storage::get_escrow(&env, invoice_id.clone()).ok_or(Error::EscrowNotFound)?;
if data.status == EscrowStatus::Cancelled {
return Err(Error::EscrowCancelled);
}
if data.status != EscrowStatus::Created {
return Err(Error::EscrowFunded);
}
Expand Down
120 changes: 120 additions & 0 deletions contracts/invoice-escrow/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1488,3 +1488,123 @@ fn test_record_payment_removes_initial_fund_even_on_full_payment() {
assert_eq!(payment_token.balance(&seller), 5000);
assert_eq!(payment_token.balance(&buyer), 5000);
}


// ── Issue #41: cancel_escrow ─────────────────────────────────────────────────

fn setup_escrow_created(env: &Env) -> (Address, InvoiceEscrowClient<'_>, Address, Address, Symbol) {
let escrow_id = env.register_contract(None, InvoiceEscrow);
let client = InvoiceEscrowClient::new(env, &escrow_id);
let admin = Address::generate(env);
let inv_token_id = env.register_contract(None, MockInvoiceToken);

let pt_admin = Address::generate(env);
let pt_id = env.register_stellar_asset_contract_v2(pt_admin.clone());
let pt_asset = AssetClient::new(env, &pt_id.address());

client.initialize(&admin, &300);

let seller = Address::generate(env);
let invoice_id = Symbol::new(env, "INV_CANC");

client.create_escrow(
&invoice_id,
&seller,
&1000i128,
&9_999_999u64,
&pt_id.address(),
&inv_token_id,
);

let _ = (pt_asset,);
(escrow_id, client, seller, admin, invoice_id)
}

#[test]
fn test_cancel_escrow_happy_path() {
let env = Env::default();
env.mock_all_auths();
let (_id, client, seller, _admin, invoice_id) = setup_escrow_created(&env);

client.cancel_escrow(&invoice_id, &seller);

assert_eq!(client.get_escrow_status(&invoice_id), EscrowStatus::Cancelled);
}

#[test]
fn test_cancel_escrow_emits_event() {
let env = Env::default();
env.mock_all_auths();
let (_id, client, seller, _admin, invoice_id) = setup_escrow_created(&env);

client.cancel_escrow(&invoice_id, &seller);

let events = env.events().all();
let last = events.last().expect("expected event");
let topic: Symbol = last.1.get(0).unwrap().try_into_val(&env).unwrap();
assert_eq!(topic, Symbol::new(&env, "escrow_cancelled"));
}

#[test]
fn test_cancel_escrow_non_seller_rejected() {
let env = Env::default();
env.mock_all_auths();
let (_id, client, _seller, _admin, invoice_id) = setup_escrow_created(&env);

let impostor = Address::generate(&env);
let res = client.try_cancel_escrow(&invoice_id, &impostor);
assert_eq!(res, Err(Ok(Error::Unauthorized)));
}

#[test]
fn test_cancel_escrow_already_funded_rejected() {
let env = Env::default();
env.mock_all_auths();

let escrow_id = env.register_contract(None, InvoiceEscrow);
let client = InvoiceEscrowClient::new(&env, &escrow_id);
let admin = Address::generate(&env);
let inv_token_id = env.register_contract(None, MockInvoiceToken);

let pt_admin = Address::generate(&env);
let pt_id = env.register_stellar_asset_contract_v2(pt_admin.clone());
let pt_asset = AssetClient::new(&env, &pt_id.address());
let pt_client = TokenClient::new(&env, &pt_id.address());

client.initialize(&admin, &0);

let seller = Address::generate(&env);
let buyer = Address::generate(&env);
let invoice_id = Symbol::new(&env, "INV_CFUND");

pt_asset.mint(&buyer, &1000);

client.create_escrow(
&invoice_id,
&seller,
&1000i128,
&9_999_999u64,
&pt_id.address(),
&inv_token_id,
);
client.fund_escrow(&invoice_id, &buyer);

// Cannot cancel once funded
let res = client.try_cancel_escrow(&invoice_id, &seller);
assert_eq!(res, Err(Ok(Error::EscrowFunded)));

let _ = pt_client;
}

#[test]
fn test_fund_cancelled_escrow_rejected() {
let env = Env::default();
env.mock_all_auths();

let (_id, client, seller, _admin, invoice_id) = setup_escrow_created(&env);
client.cancel_escrow(&invoice_id, &seller);

let buyer = Address::generate(&env);
let res = client.try_fund_escrow(&invoice_id, &buyer);
assert_eq!(res, Err(Ok(Error::EscrowCancelled)));
}
2 changes: 2 additions & 0 deletions contracts/invoice-escrow/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ pub enum EscrowStatus {
Settled = 2,
/// Refunded to investor after due date.
Refunded = 3,
/// Cancelled by seller while still in Created state (never funded).
Cancelled = 4,
}

/// Per-invoice escrow data stored in persistent storage.
Expand Down
5 changes: 5 additions & 0 deletions contracts/payment-distributor/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,8 @@ crate-type = ["cdylib", "rlib"]

[dependencies]
soroban-sdk = { workspace = true }

[dev-dependencies]
soroban-sdk = { workspace = true, features = ["testutils"] }
invoice-escrow = { path = "../invoice-escrow" }
invoice-token = { path = "../invoice-token" }
Loading
Loading