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
61 changes: 53 additions & 8 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
# ============================================================
# Build output
# ============================================================
target/
*.wasm
wasm32-unknown-unknown/
**/wasm32-unknown-unknown/

# Environment files
# ============================================================
# Environment / secrets
# ============================================================
.env
.env.local
.env.*.local

# OS / Editor files
# ============================================================
# OS / editor artefacts
# ============================================================
.DS_Store
Thumbs.db
desktop.ini
Expand All @@ -21,7 +27,9 @@ desktop.ini
*.sublime-project
*.sublime-workspace

# Test snapshots and generated artifacts
# ============================================================
# Test snapshots and generated test artefacts
# ============================================================
**/__snapshots__/
*.snap
*.snap.orig
Expand All @@ -30,35 +38,44 @@ desktop.ini
tests/snapshots/
tests/fixtures/

# ============================================================
# Stellar CLI
# ============================================================
.stellar/
identity/
.stellar-cli/

# ============================================================
# Logs
# ============================================================
*.log
*.log.*
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# ============================================================
# Coverage reports
# ============================================================
lcov.info
tarpaulin-report.html
coverage/
.nyc_output/
profdata/

# ============================================================
# Temporary files
# ============================================================
*.tmp
*.bak
*.orig
*.rej
*~
.*.swp
.*.swo

# Node / JS tooling
# ============================================================
# Node / JS tooling (not used in this project, kept for safety)
# ============================================================
node_modules/
dist/
.pnp
Expand All @@ -69,21 +86,49 @@ dist/
.parcel-cache/
.cache/

# ============================================================
# Rust / Cargo artefacts not caught by target/
# ============================================================
**/*.rs.bk
Cargo.lock.bak
**/*.profraw
**/*.profdata

# Fuzz corpus (generated during fuzzing runs)
# ============================================================
# Fuzz corpus (generated during fuzzing runs — seed files are
# committed via explicit !fuzz/corpus/<target>/seed_* rules)
# ============================================================
fuzz/corpus/*
!fuzz/corpus/.gitkeep
!fuzz/corpus/fuzz_sequence/seed_zeros
!fuzz/corpus/fuzz_sequence/seed_empty
!fuzz/corpus/fuzz_sequence/seed_mixed
!fuzz/corpus/fuzz_sequence/seed_ones
!fuzz/corpus/fuzz_create_invoice/seed_zeros
!fuzz/corpus/fuzz_create_invoice/seed_empty
!fuzz/corpus/fuzz_create_invoice/seed_mixed
!fuzz/corpus/fuzz_create_invoice/seed_ones
!fuzz/corpus/fuzz_pay/seed_zeros
!fuzz/corpus/fuzz_pay/seed_empty
!fuzz/corpus/fuzz_pay/seed_mixed
!fuzz/corpus/fuzz_pay/seed_ones
!fuzz/corpus/fuzz_refund/seed_zeros
!fuzz/corpus/fuzz_refund/seed_empty
!fuzz/corpus/fuzz_refund/seed_mixed
!fuzz/corpus/fuzz_refund/seed_ones
!fuzz/corpus/fuzz_release/seed_zeros
!fuzz/corpus/fuzz_release/seed_empty
!fuzz/corpus/fuzz_release/seed_mixed
!fuzz/corpus/fuzz_release/seed_ones

# Cargo audit / deny / outdated cache
# ============================================================
# Cargo audit / deny cache
# ============================================================
.cargo-ok
cargo-audit.toml

# ============================================================
# Python / misc tooling
# ============================================================
__pycache__/
*.pyc
.pytest_cache/
47 changes: 44 additions & 3 deletions contracts/factory/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,13 @@ const MAX_DEPLOYMENTS_PER_CREATOR: u32 = 10_000;

/// Emitted when a new split contract is deployed by the factory.
///
/// Topics: (factory, deployed, creator)
/// Data: (contract_address, salt)
/// Topics: `(factory, deployed, creator)`
/// Data: `contract_address`
///
/// # Parameters
/// * `env` — The current Soroban execution environment.
/// * `creator` — The address that triggered the deployment.
/// * `contract_address` — The address of the newly deployed contract.
pub fn contract_deployed(env: &Env, creator: &Address, contract_address: &Address) {
env.events().publish(
(
Expand All @@ -58,7 +63,17 @@ pub struct SplitFactory;
#[contractimpl]
impl SplitFactory {
/// Initialise the factory by recording its admin.
/// Can only be called once.
///
/// Must be called exactly once after the contract is deployed. Subsequent
/// calls panic with `"already initialized"`.
///
/// # Parameters
/// * `env` — The current Soroban execution environment.
/// * `admin` — The address that will become the factory administrator.
///
/// # Panics
/// Panics with `"already initialized"` if the factory has already been
/// initialised.
pub fn initialize(env: Env, admin: Address) {
assert!(
!env.storage().instance().has(&factory_admin_key()),
Expand Down Expand Up @@ -150,6 +165,18 @@ impl SplitFactory {
}

/// Return all deployed contract addresses for a given creator.
///
/// Looks up the persistent list of contracts that `creator` has deployed
/// through this factory. Returns an empty `Vec` when the creator has not
/// yet deployed any contracts.
///
/// # Parameters
/// * `env` — The current Soroban execution environment.
/// * `creator` — The creator address to query.
///
/// # Returns
/// A `Vec<Address>` of contract addresses in deployment order. Empty if
/// no contracts have been deployed for this creator.
pub fn get_deployments(env: Env, creator: Address) -> Vec<Address> {
env.storage()
.persistent()
Expand All @@ -158,6 +185,20 @@ impl SplitFactory {
}

/// Check whether a specific (creator, salt) pair has already been used.
///
/// Because the deployed address is deterministic per `(creator, salt)`,
/// the factory rejects duplicate salts for the same creator. Use this
/// view function before calling [`Self::deploy_invoice_contract`] to
/// pre-check whether a salt is available.
///
/// # Parameters
/// * `env` — The current Soroban execution environment.
/// * `creator` — The creator address to check.
/// * `salt` — The 32-byte salt to check.
///
/// # Returns
/// `true` if the `(creator, salt)` pair was already used in a previous
/// deployment; `false` otherwise.
pub fn is_salt_used(env: Env, creator: Address, salt: BytesN<32>) -> bool {
env.storage().persistent().has(&salt_key(&creator, &salt))
}
Expand Down
33 changes: 32 additions & 1 deletion contracts/invoice-escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ use errors::Error;
use soroban_sdk::{
contract, contractimpl, symbol_short, token, Address, BytesN, Env, Symbol, Vec,
};
use types::{BlacklistEntry, EscrowInvoice, EscrowStatus};
use types::{BlacklistEntry, EscrowInvoice, EscrowReleased, EscrowStatus};

// ---------------------------------------------------------------------------
// Storage key helpers
Expand Down Expand Up @@ -476,6 +476,17 @@ impl InvoiceEscrowContract {
let token_client = token::Client::new(&env, &invoice.token);
token_client.transfer(&env.current_contract_address(), &creator, &total);
emit_released(&env, invoice_id, total);

// Emit structured EscrowReleased event for off-chain indexers.
env.events().publish(
(symbol_short!("escrow"), symbol_short!("released")),
EscrowReleased {
invoice_id,
recipient: creator,
amount: total,
},
);

Ok(())
}

Expand Down Expand Up @@ -774,6 +785,26 @@ impl InvoiceEscrowContract {
get_invoice(&env, invoice_id)
}

/// Return the current locked balance held in escrow for a given invoice.
///
/// This is a read-only view function — it performs no state mutations.
/// Returns `0` when no escrow entry exists for `invoice_id` (i.e. the
/// invoice has never been created or has already been released/refunded).
///
/// # Arguments
/// * `invoice_id` — The ID of the invoice to query.
///
/// # Returns
/// The `funded_amount` currently locked in this escrow invoice, or `0`
/// if the invoice does not exist.
pub fn get_escrow_balance(env: Env, invoice_id: u64) -> i128 {
env.storage()
.persistent()
.get::<_, EscrowInvoice>(&invoice_key(invoice_id))
.map(|inv| inv.funded_amount)
.unwrap_or(0)
}

/// Return the amount a specific payer has deposited toward an invoice.
pub fn get_deposit(env: Env, invoice_id: u64, payer: Address) -> i128 {
env.storage()
Expand Down
103 changes: 103 additions & 0 deletions contracts/invoice-escrow/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -716,3 +716,106 @@ fn test_cannot_finalise_twice() {
let result = client.try_finalise_blacklist(&admin, &payer, &true);
assert_eq!(result, Err(Ok(Error::AlreadyFinalised)));
}

// ---------------------------------------------------------------------------
// #735: get_escrow_balance
// ---------------------------------------------------------------------------

#[test]
fn test_get_escrow_balance_existing_invoice_returns_funded_amount() {
let (env, contract_id) = setup();
let client = InvoiceEscrowContractClient::new(&env, &contract_id);
let admin = Address::generate(&env);
let token_admin = Address::generate(&env);
let payer = Address::generate(&env);

client.initialize(&admin);

let token = create_token(&env, &token_admin);
let total: i128 = 1_000;
let deposit_amount: i128 = 400;
let deadline: u64 = env.ledger().timestamp() + 10_000;

// Mint enough tokens to the payer.
mint(&env, &token, &token_admin, &payer, total);

let invoice_id = client.create_invoice(
&Address::generate(&env),
&token,
&total,
&deadline,
);

// Before any deposit, balance should equal 0.
assert_eq!(client.get_escrow_balance(&invoice_id), 0);

// Deposit a partial amount.
client.deposit(&payer, &invoice_id, &deposit_amount);

// Balance must reflect the deposited amount.
assert_eq!(client.get_escrow_balance(&invoice_id), deposit_amount);
}

#[test]
fn test_get_escrow_balance_unknown_invoice_returns_zero() {
let (env, contract_id) = setup();
let client = InvoiceEscrowContractClient::new(&env, &contract_id);
let admin = Address::generate(&env);

client.initialize(&admin);

// Invoice ID 9999 was never created — must return 0, not panic.
assert_eq!(client.get_escrow_balance(&9999), 0);
}

// ---------------------------------------------------------------------------
// #736: EscrowReleased event
// ---------------------------------------------------------------------------

#[test]
fn test_release_emits_escrow_released_event() {
let (env, contract_id) = setup();
let client = InvoiceEscrowContractClient::new(&env, &contract_id);
let admin = Address::generate(&env);
let token_admin = Address::generate(&env);
let creator = Address::generate(&env);
let payer = Address::generate(&env);

client.initialize(&admin);

let token = create_token(&env, &token_admin);
let total: i128 = 500;
let deadline: u64 = env.ledger().timestamp() + 10_000;

mint(&env, &token, &token_admin, &payer, total);

let invoice_id = client.create_invoice(&creator, &token, &total, &deadline);

// Deposit the full amount so the invoice becomes fully funded.
client.deposit(&payer, &invoice_id, &total);

// Verify EscrowReleased event was emitted.
let all_events = env.events().all();
let mut found = false;
for event in all_events.iter() {
// Topics for the structured release event are (escrow, released).
let topics = event.1;
if topics.len() >= 2 {
if let Ok(t0) = <soroban_sdk::Symbol as soroban_sdk::TryFromVal<Env, soroban_sdk::Val>>::try_from_val(
&env,
&topics.get_unchecked(0),
) {
if let Ok(t1) = <soroban_sdk::Symbol as soroban_sdk::TryFromVal<Env, soroban_sdk::Val>>::try_from_val(
&env,
&topics.get_unchecked(1),
) {
if t0 == symbol_short!("escrow") && t1 == symbol_short!("released") {
found = true;
break;
}
}
}
}
}
assert!(found, "EscrowReleased event was not emitted during release");
}
20 changes: 20 additions & 0 deletions contracts/invoice-escrow/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,26 @@ pub struct AdminTransferCancelledEvent {
pub ledger: u32,
}

// ──────────────────────────────────────────────────────────────────────
// Escrow release event
// ──────────────────────────────────────────────────────────────────────

/// Emitted after funds are successfully transferred to a recipient during
/// a release call.
///
/// Event topics: `(escrow, released)`
/// Event data: `EscrowReleased { invoice_id, recipient, amount }`
#[contracttype]
#[derive(Clone, Debug)]
pub struct EscrowReleased {
/// The ID of the invoice that was released.
pub invoice_id: u64,
/// The address that received the released funds.
pub recipient: Address,
/// The amount of tokens transferred to the recipient.
pub amount: i128,
}

// ──────────────────────────────────────────────────────────────────────
// Payer Blacklist types
// ──────────────────────────────────────────────────────────────────────
Expand Down
Loading