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
294 changes: 112 additions & 182 deletions Cargo.lock

Large diffs are not rendered by default.

21 changes: 21 additions & 0 deletions contracts/invoice-escrow/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,27 @@ pub fn early_settlement_applied(
);
}

/// Investment made event (#518): emitted when an investor locks funds into
/// an invoice's escrow via `invest`. `investment_topped_up` below covers a
/// separate, legacy top-up path; this is the primary event for the
/// multi-investor `FundingInvoice` flow.
pub fn investment_made(
env: &Env,
investor: &Address,
invoice_id: BytesN<32>,
amount: i128,
new_total_position: i128,
) {
env.events().publish(
(
Symbol::new(env, "investment_made"),
investor.clone(),
invoice_id.clone(),
),
(amount, new_total_position),
);
}

/// Investment topped up event.
pub fn investment_topped_up(
env: &Env,
Expand Down
27 changes: 27 additions & 0 deletions contracts/invoice-escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1751,6 +1751,7 @@ impl InvoiceEscrow {
min_investment,
per_investor_cap,
token,
None,
)
}

Expand All @@ -1767,6 +1768,7 @@ impl InvoiceEscrow {
min_investment: i128,
per_investor_cap: Option<i128>,
token: Address,
fraction_token: Option<Address>,
) -> Result<(), Error> {
seller.require_auth();
Self::create_invoice_core(
Expand All @@ -1780,6 +1782,7 @@ impl InvoiceEscrow {
min_investment,
per_investor_cap,
token,
fraction_token,
)
}

Expand All @@ -1795,6 +1798,7 @@ impl InvoiceEscrow {
min_investment: i128,
per_investor_cap: Option<i128>,
token: Address,
fraction_token: Option<Address>,
) -> Result<(), Error> {
if funding_target <= 0 {
return Err(Error::InvalidAmount);
Expand Down Expand Up @@ -1827,6 +1831,7 @@ impl InvoiceEscrow {
min_funding_threshold,
max_funding_threshold,
threshold_reached: false,
fraction_token,
};
storage::set_invoice(env, invoice_id.clone(), &invoice);
events::funding_thresholds_set(
Expand Down Expand Up @@ -2190,6 +2195,7 @@ impl InvoiceEscrow {
// be paid out of other investors' principal or issuer collateral.
storage::set_investor_position(&env, &invoice_id, &investor, new_pos);
investor_cap::sync_holder(&env, &invoice_id, &investor);
let fraction_token = invoice.fraction_token.clone();
invoice.total_raised = new_total;
storage::set_invoice(&env, invoice_id.clone(), &invoice);
if returned > 0 {
Expand All @@ -2202,6 +2208,27 @@ impl InvoiceEscrow {
returned,
);
}

// Mint fraction tokens to the investor to represent their
// ownership share (#517), same pattern as the legacy
// create_escrow_with_tokens path's inv_token mint below. Scaled
// to `accepted`, not the originally requested `amount`: any
// `returned` remainder above the funding cap was refunded to
// the investor and must not also get a fraction-token claim.
if let Some(fraction_token) = fraction_token {
env.invoke_contract::<()>(
&fraction_token,
&Symbol::new(&env, "mint"),
soroban_sdk::vec![
&env,
investor.to_val(),
accepted.into_val(&env),
env.current_contract_address().to_val()
],
);
}

events::investment_made(&env, &investor, invoice_id, accepted, new_pos);
return Ok(());
}

Expand Down
128 changes: 127 additions & 1 deletion contracts/invoice-escrow/src/test.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
#![allow(deprecated, unused_variables, dead_code, unused_mut, clippy::all)]

use super::*;
use invoice_token::{InvoiceToken, InvoiceTokenClient};
use soroban_sdk::testutils::{Address as _, Events, Ledger};
use soroban_sdk::token::Client as TokenClient;
use soroban_sdk::token::StellarAssetClient as AssetClient;
use soroban_sdk::{
contract, contractimpl, Address, BytesN, Env, IntoVal, Symbol, TryFromVal, TryIntoVal, Val, Vec,
contract, contractimpl, Address, BytesN, Env, IntoVal, String as SorobanString, Symbol,
TryFromVal, TryIntoVal, Val, Vec,
};

/// Helper function to create a test commitment hash (SHA-256 format)
Expand Down Expand Up @@ -11029,3 +11031,127 @@ fn issue455_fill_to_target_under_cap_pressure() {
EscrowStatus::Funded
);
}

/// `invest` on the multi-investor `FundingInvoice` flow (created via
/// `create_invoice`, distinct from the legacy `register_invoice`/
/// `EscrowStatus` path) silently emitted no event at all — every other
/// state-changing call in this contract emits one (#518's acceptance
/// criteria: "Events emitted correctly for all state transitions"). Covers
/// the fix: `investment_made` is published with the investor, invoice id,
/// amount, and the investor's new total position.
#[test]
fn test_invest_on_funding_invoice_emits_investment_made_event() {
let env = Env::default();
env.mock_all_auths();
let contract_id = env.register_contract(None, InvoiceEscrow);
let client = InvoiceEscrowClient::new(&env, &contract_id);

let admin = Address::generate(&env);
let seller = Address::generate(&env);
let investor = Address::generate(&env);
let invoice_id = BytesN::from_array(&env, &[200u8; 32]);

let token_admin = Address::generate(&env);
let token_id = env.register_stellar_asset_contract_v2(token_admin.clone());
let token = TokenClient::new(&env, &token_id.address());
let token_asset = AssetClient::new(&env, &token_id.address());
token_asset.mint(&investor, &100_000i128);

client.initialize(&admin, &300);
client.create_invoice_legacy(
&invoice_id,
&seller,
&100_000i128,
&1_000u32,
&1_000i128,
&None,
&token_id.address(),
&None,
);

client.invest(&invoice_id, &investor, &40_000i128);

assert_eq!(token.balance(&investor), 60_000i128);

let events = env.events().all();
let event = events
.events()
.iter()
.rev()
.find(|e| {
let (_, topics, _) = parse_event(&env, e);
topics
.get(0)
.map(|t| {
Symbol::try_from_val(&env, &t).unwrap() == Symbol::new(&env, "investment_made")
})
.unwrap_or(false)
})
.expect("expected investment_made event");

let (_contract_addr, topics, data) = parse_event(&env, event);
assert_eq!(
topics,
(
Symbol::new(&env, "investment_made"),
investor.clone(),
invoice_id.clone(),
)
.into_val(&env)
);
let event_data: (i128, i128) = data.try_into_val(&env).unwrap();
assert_eq!(event_data.0, 40_000i128); // amount invested
assert_eq!(event_data.1, 40_000i128); // new total position
}

/// `invest` on a `FundingInvoice` created with a `fraction_token` set
/// mints the investor that many fraction tokens on the configured
/// invoice-token contract (#517), so their ownership share has an
/// on-chain representation, not just an internal storage entry.
#[test]
fn test_invest_mints_fraction_tokens_when_fraction_token_configured() {
let env = Env::default();
env.mock_all_auths();
let escrow_id = env.register_contract(None, InvoiceEscrow);
let escrow = InvoiceEscrowClient::new(&env, &escrow_id);

let admin = Address::generate(&env);
let seller = Address::generate(&env);
let investor = Address::generate(&env);
let invoice_id = BytesN::from_array(&env, &[201u8; 32]);

let payment_token_admin = Address::generate(&env);
let payment_token_id = env.register_stellar_asset_contract_v2(payment_token_admin.clone());
let payment_token_asset = AssetClient::new(&env, &payment_token_id.address());
payment_token_asset.mint(&investor, &100_000i128);

let fraction_token_id = env.register_contract(None, InvoiceToken);
let fraction_token = InvoiceTokenClient::new(&env, &fraction_token_id);
fraction_token.initialize(
&admin,
&SorobanString::from_str(&env, "Invoice Fraction"),
&SorobanString::from_str(&env, "IFRAC"),
&7u32,
&Symbol::new(&env, "inv201"),
&escrow_id, // the escrow contract itself is the authorized minter
);

escrow.initialize(&admin, &300);
escrow.create_invoice_legacy(
&invoice_id,
&seller,
&100_000i128,
&1_000u32,
&1_000i128,
&None,
&payment_token_id.address(),
&Some(fraction_token_id.clone()),
);

escrow.invest(&invoice_id, &investor, &40_000i128);

assert_eq!(fraction_token.balance(&investor), 40_000i128);

escrow.invest(&invoice_id, &investor, &10_000i128);
assert_eq!(fraction_token.balance(&investor), 50_000i128);
}
4 changes: 4 additions & 0 deletions contracts/invoice-escrow/src/test_buy_limit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ fn setup<'a>() -> Market<'a> {
per_investor_cap: None,
status: InvoiceStatus::Funded,
token: Address::generate(&env),
min_funding_threshold: 1_000,
max_funding_threshold: 1_000,
threshold_reached: true,
fraction_token: None,
};
storage::set_invoice(&env, invoice_id.clone(), &invoice);
});
Expand Down
6 changes: 6 additions & 0 deletions contracts/invoice-escrow/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,12 @@ pub struct FundingInvoice {
/// Whether `ThresholdReached` has already been emitted for this invoice.
/// Guarantees the event fires exactly once per invoice.
pub threshold_reached: bool,
/// Optional fraction-token contract address (#517): when set, `invest`
/// mints the investor a matching amount of fraction tokens on this
/// contract so their ownership share has an on-chain representation.
/// `None` preserves the pre-#517 behaviour (payment-only, no fraction
/// tokens) for invoices that don't need one.
pub fraction_token: Option<soroban_sdk::Address>,
}

/// Funding progress view returned by `get_funding_status`.
Expand Down
5 changes: 3 additions & 2 deletions contracts/invoice-escrow/tests/institutional_controls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -483,8 +483,9 @@ fn collateral_breach_blocks_creation_until_a_real_topup_restores_ratio() {
Err(Ok(Error::CollateralTopUpRequired))
);
assert_eq!(
f.client
.try_create_invoice_legacy(&next, &f.issuer, &1000, &1000, &0, &None, &f.token),
f.client.try_create_invoice_legacy(
&next, &f.issuer, &1000, &1000, &0, &None, &f.token, &None,
),
Err(Ok(Error::CollateralTopUpRequired))
);
f.client.top_up_collateral(&f.id, &50);
Expand Down
12 changes: 10 additions & 2 deletions contracts/invoice-escrow/tests/thresholds.rs
Original file line number Diff line number Diff line change
Expand Up @@ -285,8 +285,16 @@ fn test_legacy_create_invoice_defaults_min_and_max_to_target() {
let investor = Address::generate(&env);
mint(&env, &f.token, &investor, 500_000);

f.client
.create_invoice_legacy(&id, &f.seller, &80_000i128, &1000u32, &0i128, &None, &f.token);
f.client.create_invoice_legacy(
&id,
&f.seller,
&80_000i128,
&1000u32,
&0i128,
&None,
&f.token,
&None,
);

let status = f.client.get_funding_status(&id);
assert_eq!(status.min_threshold, 80_000);
Expand Down
Loading