diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml new file mode 100644 index 0000000..6bce07a --- /dev/null +++ b/.github/workflows/smoke-test.yml @@ -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 diff --git a/contracts/invoice-escrow/src/errors.rs b/contracts/invoice-escrow/src/errors.rs index 0d1f263..3f364d8 100644 --- a/contracts/invoice-escrow/src/errors.rs +++ b/contracts/invoice-escrow/src/errors.rs @@ -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, } diff --git a/contracts/invoice-escrow/src/events.rs b/contracts/invoice-escrow/src/events.rs index 53bd575..2b8069c 100644 --- a/contracts/invoice-escrow/src/events.rs +++ b/contracts/invoice-escrow/src/events.rs @@ -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( diff --git a/contracts/invoice-escrow/src/lib.rs b/contracts/invoice-escrow/src/lib.rs index 8269046..3586171 100644 --- a/contracts/invoice-escrow/src/lib.rs +++ b/contracts/invoice-escrow/src/lib.rs @@ -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); } diff --git a/contracts/invoice-escrow/src/test.rs b/contracts/invoice-escrow/src/test.rs index f21424a..3a8d5e3 100644 --- a/contracts/invoice-escrow/src/test.rs +++ b/contracts/invoice-escrow/src/test.rs @@ -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))); +} diff --git a/contracts/invoice-escrow/src/types.rs b/contracts/invoice-escrow/src/types.rs index ba2dc05..5c40c48 100644 --- a/contracts/invoice-escrow/src/types.rs +++ b/contracts/invoice-escrow/src/types.rs @@ -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. diff --git a/contracts/payment-distributor/Cargo.toml b/contracts/payment-distributor/Cargo.toml index c5a7340..b4f157f 100644 --- a/contracts/payment-distributor/Cargo.toml +++ b/contracts/payment-distributor/Cargo.toml @@ -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" } diff --git a/contracts/payment-distributor/src/integration_test.rs b/contracts/payment-distributor/src/integration_test.rs new file mode 100644 index 0000000..eb2e3d6 --- /dev/null +++ b/contracts/payment-distributor/src/integration_test.rs @@ -0,0 +1,249 @@ +//! Cross-contract integration tests for payment-distributor. +//! +//! These tests exercise the full distribution pipeline: +//! invoice-escrow lifecycle → settlement → payment-distributor distribution/claim +//! +//! Closes #36. + +#![allow(deprecated)] + +use super::*; +use invoice_escrow::{InvoiceEscrow, InvoiceEscrowClient}; +use invoice_token::{InvoiceToken, InvoiceTokenClient}; +use soroban_sdk::token::{Client as TokenClient, StellarAssetClient as AssetClient}; +use soroban_sdk::{ + testutils::{Address as _, Ledger as _}, + Address, Env, String as SorobanString, Symbol, +}; + +// --------------------------------------------------------------------------- +// Happy-path integration: create → fund → settle → distribute +// --------------------------------------------------------------------------- + +#[test] +fn test_integration_settle_then_distribute() { + let env = Env::default(); + env.mock_all_auths(); + + // Identities + let admin = Address::generate(&env); + let seller = Address::generate(&env); + let buyer = Address::generate(&env); + let payer = Address::generate(&env); + let distributor_recipient = Address::generate(&env); + + // Register contracts + let escrow_id = env.register(InvoiceEscrow, ()); + let escrow = InvoiceEscrowClient::new(&env, &escrow_id); + + let inv_token_id = env.register(InvoiceToken, ()); + let inv_token = InvoiceTokenClient::new(&env, &inv_token_id); + + let distributor_id = env.register(PaymentDistributor, ()); + let distributor = PaymentDistributorClient::new(&env, &distributor_id); + + // Payment token + let pt_admin = Address::generate(&env); + let pt_id = env.register_stellar_asset_contract_v2(pt_admin.clone()); + let pt_client = TokenClient::new(&env, &pt_id.address()); + let pt_asset = AssetClient::new(&env, &pt_id.address()); + + // Initialize + let invoice_id = Symbol::new(&env, "INV_DIST"); + inv_token.initialize( + &admin, + &SorobanString::from_str(&env, "Invoice Dist"), + &SorobanString::from_str(&env, "INVD"), + &18, + &invoice_id, + &escrow_id, + ); + escrow.initialize(&admin, &0); // 0% fee for simplicity + distributor.initialize(&admin); + + // Fund participants + let amount = 1000i128; + pt_asset.mint(&buyer, &amount); + pt_asset.mint(&payer, &amount); + + // Escrow lifecycle + let due_date = 99_999u64; + escrow.create_escrow(&invoice_id, &seller, &amount, &due_date, &pt_id.address(), &inv_token_id); + escrow.fund_escrow(&invoice_id, &buyer); + + assert_eq!(pt_client.balance(&escrow_id), amount); + + // Payer settles the invoice + escrow.record_payment(&invoice_id, &payer, &amount); + + // After settlement seller received the escrow principal back, buyer received payer's funds + // Seller now wants to distribute their proceeds via payment-distributor + // Seller mints into distributor as an example redistribution + let dist_amount = 500i128; + pt_asset.mint(&distributor_id, &dist_amount); + distributor.distribute(&pt_id.address(), &distributor_recipient, &dist_amount); + + assert_eq!(pt_client.balance(&distributor_recipient), dist_amount); + assert_eq!(pt_client.balance(&distributor_id), 0); +} + +// --------------------------------------------------------------------------- +// Failure: distribution blocked when escrow is not yet settled +// --------------------------------------------------------------------------- + +#[test] +fn test_integration_distribute_while_escrow_funded_not_settled() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let seller = Address::generate(&env); + let buyer = Address::generate(&env); + + let escrow_id = env.register(InvoiceEscrow, ()); + let escrow = InvoiceEscrowClient::new(&env, &escrow_id); + + let inv_token_id = env.register(InvoiceToken, ()); + let inv_token = InvoiceTokenClient::new(&env, &inv_token_id); + + let distributor_id = env.register(PaymentDistributor, ()); + let distributor = PaymentDistributorClient::new(&env, &distributor_id); + + let pt_admin = Address::generate(&env); + let pt_id = env.register_stellar_asset_contract_v2(pt_admin); + let pt_client = TokenClient::new(&env, &pt_id.address()); + let pt_asset = AssetClient::new(&env, &pt_id.address()); + + let invoice_id = Symbol::new(&env, "INV_NSET"); + inv_token.initialize( + &admin, + &SorobanString::from_str(&env, "Invoice Unsettled"), + &SorobanString::from_str(&env, "INVNS"), + &18, + &invoice_id, + &escrow_id, + ); + escrow.initialize(&admin, &0); + distributor.initialize(&admin); + + let amount = 500i128; + pt_asset.mint(&buyer, &amount); + escrow.create_escrow(&invoice_id, &seller, &amount, &99_999u64, &pt_id.address(), &inv_token_id); + escrow.fund_escrow(&invoice_id, &buyer); + + // Escrow is Funded (not Settled). The distributor has no funds yet. + // Attempting to distribute 0 tokens should fail with InvalidAmount. + let recipient = Address::generate(&env); + let res = distributor.try_distribute(&pt_id.address(), &recipient, &0i128); + assert_eq!(res, Err(Ok(Error::InvalidAmount))); + + // Also confirm the escrow is still in Funded state (settlement hasn't happened) + let escrow_data = escrow.get_escrow(&invoice_id); + assert_eq!(escrow_data.status, invoice_escrow::EscrowStatus::Funded); + + let _ = pt_client; +} + +// --------------------------------------------------------------------------- +// Failure: claim fails for unauthorized caller (non-admin distribute) +// --------------------------------------------------------------------------- + +#[test] +fn test_integration_distribute_unauthorized_caller() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + + let distributor_id = env.register(PaymentDistributor, ()); + let distributor = PaymentDistributorClient::new(&env, &distributor_id); + + let pt_admin = Address::generate(&env); + let pt_id = env.register_stellar_asset_contract_v2(pt_admin); + let pt_asset = AssetClient::new(&env, &pt_id.address()); + let pt_client = TokenClient::new(&env, &pt_id.address()); + + distributor.initialize(&admin); + pt_asset.mint(&distributor_id, &1000i128); + + // PaymentDistributor.distribute internally requires admin auth. + // Without admin auth mocked, calling try_distribute returns an auth error. + let env2 = Env::default(); + // No mock_all_auths — unauthorized scenario + let distributor_id2 = env2.register(PaymentDistributor, ()); + let distributor2 = PaymentDistributorClient::new(&env2, &distributor_id2); + let admin2 = Address::generate(&env2); + env2.mock_all_auths(); + distributor2.initialize(&admin2); + let pt_id2 = env2.register_stellar_asset_contract_v2(Address::generate(&env2)); + AssetClient::new(&env2, &pt_id2.address()).mint(&distributor_id2, &1000i128); + let pt_client2 = TokenClient::new(&env2, &pt_id2.address()); + let recipient2 = Address::generate(&env2); + + // With no auth for admin2, distribute panics (auth failure) + let res = distributor2.try_distribute(&pt_client2.address, &recipient2, &100i128); + // Auth failures in soroban tests manifest as errors + let _ = (admin, distributor, pt_client, pt_id); + assert!(res.is_err()); +} + +// --------------------------------------------------------------------------- +// Integration: refund lifecycle does NOT trigger distributor distribution +// --------------------------------------------------------------------------- + +#[test] +fn test_integration_refund_does_not_affect_distributor() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let seller = Address::generate(&env); + let buyer = Address::generate(&env); + + let escrow_id = env.register(InvoiceEscrow, ()); + let escrow = InvoiceEscrowClient::new(&env, &escrow_id); + + let inv_token_id = env.register(InvoiceToken, ()); + let inv_token = InvoiceTokenClient::new(&env, &inv_token_id); + + let distributor_id = env.register(PaymentDistributor, ()); + let distributor = PaymentDistributorClient::new(&env, &distributor_id); + + let pt_admin = Address::generate(&env); + let pt_id = env.register_stellar_asset_contract_v2(pt_admin); + let pt_client = TokenClient::new(&env, &pt_id.address()); + let pt_asset = AssetClient::new(&env, &pt_id.address()); + + let invoice_id = Symbol::new(&env, "INV_REF"); + let due_date = 10_000u64; + inv_token.initialize( + &admin, + &SorobanString::from_str(&env, "Refund Test"), + &SorobanString::from_str(&env, "INVR"), + &18, + &invoice_id, + &escrow_id, + ); + escrow.initialize(&admin, &0); + distributor.initialize(&admin); + + let amount = 800i128; + pt_asset.mint(&buyer, &amount); + + env.ledger().set_timestamp(5_000); + escrow.create_escrow(&invoice_id, &seller, &amount, &due_date, &pt_id.address(), &inv_token_id); + escrow.fund_escrow(&invoice_id, &buyer); + + // Advance past due date and refund + env.ledger().set_timestamp(10_001); + escrow.refund(&invoice_id); + + // Distributor has no funds; distributing 0 fails + let recipient = Address::generate(&env); + let res = distributor.try_distribute(&pt_id.address(), &recipient, &0i128); + assert_eq!(res, Err(Ok(Error::InvalidAmount))); + + // Buyer was refunded + assert_eq!(pt_client.balance(&buyer), amount); + assert_eq!(pt_client.balance(&distributor_id), 0); +} diff --git a/contracts/payment-distributor/src/lib.rs b/contracts/payment-distributor/src/lib.rs index 8ebda9b..32f8361 100644 --- a/contracts/payment-distributor/src/lib.rs +++ b/contracts/payment-distributor/src/lib.rs @@ -56,3 +56,5 @@ impl PaymentDistributor { #[cfg(test)] mod test; +#[cfg(test)] +mod integration_test; diff --git a/contracts/payment-distributor/src/test.rs b/contracts/payment-distributor/src/test.rs index dae733d..dd67475 100644 --- a/contracts/payment-distributor/src/test.rs +++ b/contracts/payment-distributor/src/test.rs @@ -5,6 +5,29 @@ use soroban_sdk::token::Client as TokenClient; use soroban_sdk::token::StellarAssetClient as AssetClient; use soroban_sdk::{testutils::Address as _, Address, Env}; +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn setup(env: &Env) -> (Address, PaymentDistributorClient<'_>, Address, TokenClient<'_>) { + let distributor_id = env.register_contract(None, PaymentDistributor); + let client = PaymentDistributorClient::new(env, &distributor_id); + let admin = Address::generate(env); + client.initialize(&admin); + + let token_admin = Address::generate(env); + let token_id = env.register_stellar_asset_contract_v2(token_admin.clone()); + let token_client = TokenClient::new(env, &token_id.address()); + let token_asset = AssetClient::new(env, &token_id.address()); + token_asset.mint(&distributor_id, &10_000i128); + + (admin, client, distributor_id, token_client) +} + +// --------------------------------------------------------------------------- +// Issue #35: unit tests for payment-distributor distribution logic +// --------------------------------------------------------------------------- + #[test] fn test_initialize_and_distribute() { let env = Env::default(); @@ -14,23 +37,17 @@ fn test_initialize_and_distribute() { let client = PaymentDistributorClient::new(&env, &distributor_id); let admin = Address::generate(&env); - - // Initialize client.initialize(&admin); - // Register token let token_admin = Address::generate(&env); let token_id = env.register_stellar_asset_contract_v2(token_admin.clone()); let token_client = TokenClient::new(&env, &token_id.address()); let token_asset = AssetClient::new(&env, &token_id.address()); - // Mint token to contract let amount = 1000; token_asset.mint(&distributor_id, &amount); let recipient = Address::generate(&env); - - // Distribute let distribute_amt = 400; client.distribute(&token_client.address, &recipient, &distribute_amt); @@ -40,3 +57,123 @@ fn test_initialize_and_distribute() { amount - distribute_amt ); } + +#[test] +fn test_double_initialize_fails() { + let env = Env::default(); + env.mock_all_auths(); + let (admin, client, _, _) = setup(&env); + let res = client.try_initialize(&admin); + assert_eq!(res, Err(Ok(Error::AlreadyInit))); +} + +#[test] +fn test_get_admin_returns_correct_address() { + let env = Env::default(); + env.mock_all_auths(); + let (admin, client, _, _) = setup(&env); + assert_eq!(client.get_admin(), admin); +} + +#[test] +fn test_distribute_zero_amount_fails() { + let env = Env::default(); + env.mock_all_auths(); + let (_admin, client, _, token) = setup(&env); + let recipient = Address::generate(&env); + let res = client.try_distribute(&token.address, &recipient, &0i128); + assert_eq!(res, Err(Ok(Error::InvalidAmount))); +} + +#[test] +fn test_distribute_negative_amount_fails() { + let env = Env::default(); + env.mock_all_auths(); + let (_admin, client, _, token) = setup(&env); + let recipient = Address::generate(&env); + let res = client.try_distribute(&token.address, &recipient, &-100i128); + assert_eq!(res, Err(Ok(Error::InvalidAmount))); +} + +#[test] +fn test_distribute_unauthorized_non_admin_fails() { + let env = Env::default(); + // Do NOT mock_all_auths; the non-admin has no authorization + let distributor_id = env.register_contract(None, PaymentDistributor); + let client = PaymentDistributorClient::new(&env, &distributor_id); + let admin = Address::generate(&env); + // Initialize with mock auth just for the init call + env.mock_all_auths(); + client.initialize(&admin); + + let token_admin = Address::generate(&env); + let token_id = env.register_stellar_asset_contract_v2(token_admin.clone()); + let token_asset = AssetClient::new(&env, &token_id.address()); + token_asset.mint(&distributor_id, &1000i128); + + let token_client = TokenClient::new(&env, &token_id.address()); + let recipient = Address::generate(&env); + + // Reset mock — now no auth is provided + let env2 = Env::default(); + let distributor_id2 = env2.register_contract(None, PaymentDistributor); + let client2 = PaymentDistributorClient::new(&env2, &distributor_id2); + let admin2 = Address::generate(&env2); + env2.mock_all_auths_allowing_non_root_auth(); + client2.initialize(&admin2); + let token_id2 = env2.register_stellar_asset_contract_v2(Address::generate(&env2)); + let token_asset2 = AssetClient::new(&env2, &token_id2.address()); + token_asset2.mint(&distributor_id2, &1000i128); + let token_client2 = TokenClient::new(&env2, &token_id2.address()); + let recipient2 = Address::generate(&env2); + let non_admin = Address::generate(&env2); + + // Calling distribute without admin auth should panic/fail + let res = client2.try_distribute(&token_client2.address, &recipient2, &100i128); + // Without admin auth mocked, this must fail + let _ = (admin2, non_admin, token_client, recipient); + // Just verify the success path works when admin auth IS present + assert!(res.is_err()); +} + +#[test] +fn test_distribute_not_initialized_fails() { + let env = Env::default(); + env.mock_all_auths(); + let distributor_id = env.register_contract(None, PaymentDistributor); + let client = PaymentDistributorClient::new(&env, &distributor_id); + let token_admin = Address::generate(&env); + let token_id = env.register_stellar_asset_contract_v2(token_admin); + let token_client = TokenClient::new(&env, &token_id.address()); + let recipient = Address::generate(&env); + let res = client.try_distribute(&token_client.address, &recipient, &100i128); + assert_eq!(res, Err(Ok(Error::NotInit))); +} + +#[test] +fn test_distribute_full_balance() { + let env = Env::default(); + env.mock_all_auths(); + let (_admin, client, distributor_id, token) = setup(&env); + let recipient = Address::generate(&env); + let full_balance = token.balance(&distributor_id); + client.distribute(&token.address, &recipient, &full_balance); + assert_eq!(token.balance(&recipient), full_balance); + assert_eq!(token.balance(&distributor_id), 0); +} + +#[test] +fn test_distribute_multiple_recipients() { + let env = Env::default(); + env.mock_all_auths(); + let (_admin, client, distributor_id, token) = setup(&env); + + let r1 = Address::generate(&env); + let r2 = Address::generate(&env); + client.distribute(&token.address, &r1, &3000i128); + client.distribute(&token.address, &r2, &2000i128); + + assert_eq!(token.balance(&r1), 3000); + assert_eq!(token.balance(&r2), 2000); + assert_eq!(token.balance(&distributor_id), 5000); +} diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh new file mode 100644 index 0000000..a31cea8 --- /dev/null +++ b/scripts/smoke-test.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash +# smoke-test.sh — StellarSettle testnet smoke test +# +# Deploys (or re-uses) the invoice-escrow, invoice-token, and +# payment-distributor contracts on Stellar testnet and runs one +# minimal happy-path lifecycle: create → fund → settle. +# +# Required environment variables (copy from .env.example and fill in): +# STELLAR_NETWORK testnet | futurenet | mainnet (default: testnet) +# ADMIN_SECRET admin Stellar secret key +# SELLER_SECRET seller Stellar secret key +# BUYER_SECRET buyer Stellar secret key +# PAYER_SECRET payer Stellar secret key (settles the invoice) +# USDC_TOKEN_ADDRESS address of the USDC / payment token contract +# +# Optional — skip deployment and reuse existing contracts: +# ESCROW_CONTRACT_ID +# INV_TOKEN_CONTRACT_ID +# DISTRIBUTOR_CONTRACT_ID +# +# Usage: +# chmod +x scripts/smoke-test.sh +# source .env && ./scripts/smoke-test.sh + +set -euo pipefail + +NETWORK="${STELLAR_NETWORK:-testnet}" +RPC_URL="https://soroban-testnet.stellar.org" +NETWORK_PASSPHRASE="Test SDF Network ; September 2015" + +if [[ "$NETWORK" == "mainnet" ]]; then + RPC_URL="https://mainnet.sorobanrpc.com" + NETWORK_PASSPHRASE="Public Global Stellar Network ; September 2015" +fi + +INVOICE_ID="SMOKE$(date +%s)" + +log() { echo "[smoke] $*"; } +assert_eq() { + local label="$1" expected="$2" actual="$3" + if [[ "$expected" != "$actual" ]]; then + echo "[FAIL] $label: expected=$expected actual=$actual" >&2 + exit 1 + fi + log "[PASS] $label" +} + +# ── 1. Build WASM ────────────────────────────────────────────────────────────── +log "Building WASM targets..." +cargo build --target wasm32-unknown-unknown --release \ + -p invoice-escrow -p invoice-token -p payment-distributor + +ESCROW_WASM="target/wasm32-unknown-unknown/release/invoice_escrow.wasm" +INV_TOKEN_WASM="target/wasm32-unknown-unknown/release/invoice_token.wasm" +DISTRIBUTOR_WASM="target/wasm32-unknown-unknown/release/payment_distributor.wasm" + +# ── 2. Deploy contracts (skip if IDs supplied) ───────────────────────────────── +deploy_contract() { + local wasm="$1" label="$2" + log "Deploying $label..." + stellar contract deploy \ + --wasm "$wasm" \ + --source "$ADMIN_SECRET" \ + --network "$NETWORK" \ + --rpc-url "$RPC_URL" \ + --network-passphrase "$NETWORK_PASSPHRASE" +} + +ESCROW_CONTRACT_ID="${ESCROW_CONTRACT_ID:-$(deploy_contract "$ESCROW_WASM" invoice-escrow)}" +INV_TOKEN_CONTRACT_ID="${INV_TOKEN_CONTRACT_ID:-$(deploy_contract "$INV_TOKEN_WASM" invoice-token)}" +DISTRIBUTOR_CONTRACT_ID="${DISTRIBUTOR_CONTRACT_ID:-$(deploy_contract "$DISTRIBUTOR_WASM" payment-distributor)}" + +log "ESCROW_CONTRACT_ID=$ESCROW_CONTRACT_ID" +log "INV_TOKEN_CONTRACT_ID=$INV_TOKEN_CONTRACT_ID" +log "DISTRIBUTOR_CONTRACT_ID=$DISTRIBUTOR_CONTRACT_ID" + +invoke() { + local contract="$1" fn="$2" source="$3" + shift 3 + stellar contract invoke \ + --id "$contract" \ + --source "$source" \ + --network "$NETWORK" \ + --rpc-url "$RPC_URL" \ + --network-passphrase "$NETWORK_PASSPHRASE" \ + -- "$fn" "$@" +} + +# ── 3. Initialize contracts ──────────────────────────────────────────────────── +ADMIN_PUB=$(stellar keys address "$ADMIN_SECRET" 2>/dev/null || \ + stellar keys show --source "$ADMIN_SECRET" | grep "Public Key" | awk '{print $NF}') +SELLER_PUB=$(stellar keys address "$SELLER_SECRET" 2>/dev/null || true) +BUYER_PUB=$(stellar keys address "$BUYER_SECRET" 2>/dev/null || true) +PAYER_PUB=$(stellar keys address "$PAYER_SECRET" 2>/dev/null || true) +AMOUNT=1000 + +log "Initializing invoice-token..." +invoke "$INV_TOKEN_CONTRACT_ID" initialize "$ADMIN_SECRET" \ + --admin "$ADMIN_PUB" \ + --name "Smoke Invoice" \ + --symbol "SINV" \ + --decimals 18 \ + --invoice_id "$INVOICE_ID" \ + --escrow_contract "$ESCROW_CONTRACT_ID" + +log "Initializing invoice-escrow..." +invoke "$ESCROW_CONTRACT_ID" initialize "$ADMIN_SECRET" \ + --admin "$ADMIN_PUB" \ + --platform_fee_bps 300 + +log "Initializing payment-distributor..." +invoke "$DISTRIBUTOR_CONTRACT_ID" initialize "$ADMIN_SECRET" \ + --admin "$ADMIN_PUB" + +# ── 4. Create escrow ─────────────────────────────────────────────────────────── +DUE_DATE=$(( $(date +%s) + 86400 )) # 24 hours from now +log "Creating escrow invoice_id=$INVOICE_ID amount=$AMOUNT due_date=$DUE_DATE..." +invoke "$ESCROW_CONTRACT_ID" create_escrow "$SELLER_SECRET" \ + --invoice_id "$INVOICE_ID" \ + --seller "$SELLER_PUB" \ + --amount "$AMOUNT" \ + --due_date "$DUE_DATE" \ + --payment_token "$USDC_TOKEN_ADDRESS" \ + --invoice_token "$INV_TOKEN_CONTRACT_ID" + +STATUS=$(invoke "$ESCROW_CONTRACT_ID" get_escrow_status "$ADMIN_SECRET" --invoice_id "$INVOICE_ID") +log "Status after create: $STATUS" +assert_eq "status=Created" "Created" "$STATUS" + +# ── 5. Fund escrow ───────────────────────────────────────────────────────────── +log "Funding escrow (buyer=$BUYER_PUB)..." +invoke "$ESCROW_CONTRACT_ID" fund_escrow "$BUYER_SECRET" \ + --invoice_id "$INVOICE_ID" \ + --buyer "$BUYER_PUB" + +STATUS=$(invoke "$ESCROW_CONTRACT_ID" get_escrow_status "$ADMIN_SECRET" --invoice_id "$INVOICE_ID") +log "Status after fund: $STATUS" +assert_eq "status=Funded" "Funded" "$STATUS" + +# ── 6. Settle (record payment) ───────────────────────────────────────────────── +log "Recording payment (payer=$PAYER_PUB amount=$AMOUNT)..." +invoke "$ESCROW_CONTRACT_ID" record_payment "$PAYER_SECRET" \ + --invoice_id "$INVOICE_ID" \ + --payer "$PAYER_PUB" \ + --amount "$AMOUNT" + +STATUS=$(invoke "$ESCROW_CONTRACT_ID" get_escrow_status "$ADMIN_SECRET" --invoice_id "$INVOICE_ID") +log "Status after settlement: $STATUS" +assert_eq "status=Settled" "Settled" "$STATUS" + +# ── 7. Done ──────────────────────────────────────────────────────────────────── +log "Smoke test PASSED." +log " ESCROW_CONTRACT_ID=$ESCROW_CONTRACT_ID" +log " INV_TOKEN_CONTRACT_ID=$INV_TOKEN_CONTRACT_ID" +log " DISTRIBUTOR_CONTRACT_ID=$DISTRIBUTOR_CONTRACT_ID"