Skip to content

Commit 7b737dc

Browse files
authored
Merge branch 'main' into feat
2 parents b7d177d + a819a8b commit 7b737dc

6 files changed

Lines changed: 591 additions & 40 deletions

File tree

contracts/invoice/src/errors.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,5 @@ pub enum InvoiceError {
2626
UntrustedSigner = 21,
2727
AlreadyAttested = 22,
2828
VerificationRequired = 23,
29+
CrossContractCallFailed = 24,
2930
}

contracts/invoice/src/lib.rs

Lines changed: 45 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -558,7 +558,6 @@ impl InvoiceContract {
558558
.instance()
559559
.set(&DataKey::Counter, &next_counter);
560560

561-
let now = env.ledger().timestamp();
562561
let mut hash_input = Bytes::new(&env);
563562
let issuer_xdr = issuer.clone().to_xdr(&env);
564563
let buyer_xdr = buyer.clone().to_xdr(&env);
@@ -1062,6 +1061,8 @@ impl InvoiceContract {
10621061
/// * `InvoiceError::NotFound` if the invoice cannot be found, or if the invoice has no
10631062
/// recorded funding pool or funding timestamp.
10641063
/// * `InvoiceError::InvalidStatusTransition` if invoice status is not `Funded`, `Active`, or `Confirmed`.
1064+
/// * `InvoiceError::CrossContractCallFailed` if escrow or pool repayment accounting
1065+
/// returns `false`.
10651066
///
10661067
/// # Returns
10671068
/// * `bool` - `true` when repayment is completed.
@@ -1131,20 +1132,26 @@ impl InvoiceContract {
11311132
let mut escrow_args = Vec::new(&env);
11321133
escrow_args.push_back(invoice_id.clone().into_val(&env));
11331134
escrow_args.push_back(face_value.into_val(&env));
1134-
let _: bool =
1135+
let escrow_released: bool =
11351136
env.invoke_contract(&escrow, &Symbol::new(&env, "release_to_pool"), escrow_args);
1137+
if !escrow_released {
1138+
panic_with_error!(&env, InvoiceError::CrossContractCallFailed);
1139+
}
11361140

11371141
// Step 3: notify pool to update its internal accounting
11381142
let mut args = Vec::new(&env);
11391143
args.push_back(invoice_id.clone().into_val(&env));
11401144
args.push_back(face_value.into_val(&env));
11411145
args.push_back(refund_to_buyer.into_val(&env));
11421146
args.push_back(buyer.into_val(&env));
1143-
let _: bool = env.invoke_contract(
1147+
let repayment_recorded: bool = env.invoke_contract(
11441148
&pool,
11451149
&Symbol::new(&env, "receive_repayment_with_refund"),
11461150
args,
11471151
);
1152+
if !repayment_recorded {
1153+
panic_with_error!(&env, InvoiceError::CrossContractCallFailed);
1154+
}
11481155

11491156
let mut updated = invoice;
11501157
updated.status = InvoiceStatus::Repaid;
@@ -1173,6 +1180,8 @@ impl InvoiceContract {
11731180
///
11741181
/// * `InvoiceError::NotFound` if the invoice, pool, or funding timestamp does not exist.
11751182
/// * `InvoiceError::InvalidStatusTransition` if the invoice is not in the `Confirmed` status or if `now >= due_date`.
1183+
/// * `InvoiceError::CrossContractCallFailed` if escrow or pool repayment accounting
1184+
/// returns `false`.
11761185
///
11771186
/// # Returns
11781187
///
@@ -1242,20 +1251,26 @@ impl InvoiceContract {
12421251
let mut escrow_args = Vec::new(&env);
12431252
escrow_args.push_back(invoice_id.clone().into_val(&env));
12441253
escrow_args.push_back(face_value.into_val(&env));
1245-
let _: bool =
1254+
let escrow_released: bool =
12461255
env.invoke_contract(&escrow, &Symbol::new(&env, "release_to_pool"), escrow_args);
1256+
if !escrow_released {
1257+
panic_with_error!(&env, InvoiceError::CrossContractCallFailed);
1258+
}
12471259

12481260
// Step 3: notify pool to update its internal accounting
12491261
let mut args = Vec::new(&env);
12501262
args.push_back(invoice_id.clone().into_val(&env));
12511263
args.push_back(face_value.into_val(&env));
12521264
args.push_back(refund_to_buyer.into_val(&env));
12531265
args.push_back(buyer.into_val(&env));
1254-
let _: bool = env.invoke_contract(
1266+
let repayment_recorded: bool = env.invoke_contract(
12551267
&pool,
12561268
&Symbol::new(&env, "receive_repayment_with_refund"),
12571269
args,
12581270
);
1271+
if !repayment_recorded {
1272+
panic_with_error!(&env, InvoiceError::CrossContractCallFailed);
1273+
}
12591274

12601275
let mut updated = invoice;
12611276
updated.status = InvoiceStatus::Repaid;
@@ -1293,6 +1308,8 @@ impl InvoiceContract {
12931308
/// * `InvoiceError::InvalidStatusTransition` if invoice is not `Funded`, `Active`, or `Confirmed`.
12941309
/// * `InvoiceError::DueDateNotPassed` if `now < due_date` — the due date
12951310
/// has not yet been reached.
1311+
/// * `InvoiceError::CrossContractCallFailed` if the pool returns `false` from
1312+
/// `handle_default`.
12961313
///
12971314
/// # Returns
12981315
/// * `bool` - `true` when default processing succeeds.
@@ -1307,9 +1324,10 @@ impl InvoiceContract {
13071324
/// and has no awareness of, the escrow contract's own
13081325
/// `DEFAULT_MIN_LOCK_SECONDS` grace period (60s from the escrow lock
13091326
/// timestamp, roughly `funded_at`). If `due_date` is reached less than
1310-
/// that window after the invoice was funded, this call sets the invoice's
1311-
/// local status to `Defaulted` and then transitively invokes
1312-
/// `escrow.handle_default()` (via `pool.handle_default`), which panics
1327+
/// that window after the invoice was funded, the downstream default call
1328+
/// may panic from the escrow constraint. The whole transaction reverts, so
1329+
/// the invoice remains unchanged. The call transitively invokes
1330+
/// `escrow.handle_default()` (via `pool.handle_default`), which may panic
13131331
/// with `EscrowError::NotAuthorized`. The whole transaction reverts, so
13141332
/// there is no persistent state inconsistency, but the caller sees a
13151333
/// revert originating from a constraint this contract does not itself
@@ -1336,19 +1354,31 @@ impl InvoiceContract {
13361354
}
13371355

13381356
let prev_status = invoice.status;
1339-
invoice.status = InvoiceStatus::Defaulted;
1340-
Self::save_invoice(&env, inv_key, &invoice);
1341-
Self::extend_instance_ttl(&env);
1342-
1343-
move_status_index(&env, &invoice_id, prev_status, InvoiceStatus::Defaulted);
13441357

13451358
let pool: Address = invoice
13461359
.funding_pool
1360+
.clone()
13471361
.unwrap_or_else(|| panic_with_error!(&env, InvoiceError::NotFound));
13481362
let mut args = Vec::new(&env);
13491363
args.push_back(invoice_id.clone().into_val(&env));
1350-
let _: bool = env.invoke_contract(&pool, &Symbol::new(&env, "handle_default"), args);
1351-
events::invoice_defaulted(&env, &invoice_id);
1364+
let default_handled: bool =
1365+
env.invoke_contract(&pool, &Symbol::new(&env, "handle_default"), args);
1366+
if !default_handled {
1367+
panic_with_error!(&env, InvoiceError::CrossContractCallFailed);
1368+
}
1369+
1370+
let current: Invoice = env
1371+
.storage()
1372+
.persistent()
1373+
.get(&inv_key)
1374+
.unwrap_or_else(|| panic_with_error!(&env, InvoiceError::NotFound));
1375+
if current.status == prev_status {
1376+
invoice.status = InvoiceStatus::Defaulted;
1377+
Self::save_invoice(&env, inv_key, &invoice);
1378+
Self::extend_instance_ttl(&env);
1379+
move_status_index(&env, &invoice_id, prev_status, InvoiceStatus::Defaulted);
1380+
events::invoice_defaulted(&env, &invoice_id);
1381+
}
13521382
true
13531383
}
13541384

contracts/invoice/src/test.rs

Lines changed: 180 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ use soroban_sdk::{
1010
Address, BytesN, Env, IntoVal, String, Symbol, TryFromVal,
1111
};
1212

13-
use crate::{InvoiceContract, InvoiceContractClient, InvoiceStatus, TTL_EXTEND_TO, TTL_THRESHOLD};
13+
use crate::{
14+
InvoiceContract, InvoiceContractClient, InvoiceStatus, MAX_FACE_VALUE, TTL_EXTEND_TO,
15+
TTL_THRESHOLD,
16+
};
1417

1518
// Default invoice parameters used across tests.
1619
// These computed constants eliminate magic numbers in test assertions
@@ -92,8 +95,20 @@ pub struct MockPool;
9295

9396
#[contractimpl]
9497
impl MockPool {
95-
pub fn handle_default(_env: Env, _invoice_id: BytesN<32>) -> bool {
96-
true
98+
pub fn set_return_values(env: Env, handle_default: bool, repayment: bool) {
99+
env.storage()
100+
.instance()
101+
.set(&Symbol::new(&env, "handle_default"), &handle_default);
102+
env.storage()
103+
.instance()
104+
.set(&Symbol::new(&env, "repayment"), &repayment);
105+
}
106+
107+
pub fn handle_default(env: Env, _invoice_id: BytesN<32>) -> bool {
108+
env.storage()
109+
.instance()
110+
.get(&Symbol::new(&env, "handle_default"))
111+
.unwrap_or(true)
97112
}
98113

99114
pub fn receive_repayment(_env: Env, _invoice_id: BytesN<32>, _amount: u128) -> bool {
@@ -114,7 +129,10 @@ impl MockPool {
114129
) -> bool {
115130
let key = Symbol::new(&env, "last_refund");
116131
env.storage().instance().set(&key, &refund);
117-
true
132+
env.storage()
133+
.instance()
134+
.get(&Symbol::new(&env, "repayment"))
135+
.unwrap_or(true)
118136
}
119137

120138
pub fn get_last_refund(env: Env) -> u128 {
@@ -701,6 +719,26 @@ fn test_create_fails_zero_face_value() {
701719
client.create(&issuer, &buyer, &0, &due_date, &usdc);
702720
}
703721

722+
#[test]
723+
fn test_create_succeeds_at_max_face_value() {
724+
let (env, client, issuer, buyer, _, usdc) = setup();
725+
let due_date = env.ledger().timestamp() + DEFAULT_DUE_OFFSET;
726+
727+
let invoice_id = client.create(&issuer, &buyer, &MAX_FACE_VALUE, &due_date, &usdc);
728+
729+
assert_eq!(client.get(&invoice_id).face_value, MAX_FACE_VALUE);
730+
}
731+
732+
#[test]
733+
#[should_panic(expected = "Error(Contract, #16)")]
734+
fn test_create_fails_above_max_face_value() {
735+
let (env, client, issuer, buyer, _, usdc) = setup();
736+
let due_date = env.ledger().timestamp() + DEFAULT_DUE_OFFSET;
737+
let above_max = MAX_FACE_VALUE.checked_add(1).unwrap();
738+
739+
client.create(&issuer, &buyer, &above_max, &due_date, &usdc);
740+
}
741+
704742
#[test]
705743
#[should_panic(expected = "Error(Contract, #7)")]
706744
fn test_create_fails_past_due_date() {
@@ -2232,6 +2270,12 @@ impl MockEscrow {
22322270
.set(&Symbol::new(&env, "asset"), &asset);
22332271
}
22342272

2273+
pub fn set_release_result(env: Env, result: bool) {
2274+
env.storage()
2275+
.instance()
2276+
.set(&Symbol::new(&env, "release_result"), &result);
2277+
}
2278+
22352279
/// Minimal stub: transfers `amount` from escrow to pool.
22362280
/// No pool auth required — mirrors the real escrow's updated behavior
22372281
/// where the invoice contract (not the pool) is the caller.
@@ -2251,7 +2295,10 @@ impl MockEscrow {
22512295
let token_client = token::Client::new(&env, &asset);
22522296
token_client.transfer(&escrow_addr, &pool, &(amount as i128));
22532297

2254-
true
2298+
env.storage()
2299+
.instance()
2300+
.get(&Symbol::new(&env, "release_result"))
2301+
.unwrap_or(true)
22552302
}
22562303
}
22572304

@@ -2264,6 +2311,33 @@ fn mock_escrow_for_pool(env: &Env, pool_id: &Address, asset: &Address) -> Addres
22642311
escrow_id
22652312
}
22662313

2314+
fn setup_confirmed_invoice() -> (
2315+
Env,
2316+
InvoiceContractClient<'static>,
2317+
Address,
2318+
BytesN<32>,
2319+
Address,
2320+
Address,
2321+
) {
2322+
let (env, client, issuer, buyer, _, usdc) = setup();
2323+
let due_date = env.ledger().timestamp() + DEFAULT_DUE_OFFSET;
2324+
let invoice_id = client.create(&issuer, &buyer, &DEFAULT_FACE_VALUE, &due_date, &usdc);
2325+
attest(&env, &client, &invoice_id);
2326+
client.list_for_financing(&invoice_id, &DEFAULT_DISCOUNT_BPS);
2327+
2328+
let pool = mock_pool_with_asset(&env, &usdc);
2329+
client.set_pool_contract(&pool);
2330+
let escrow = mock_escrow_for_pool(&env, &pool, &usdc);
2331+
client.set_escrow_contract(&escrow);
2332+
client.mark_funded(&invoice_id, &pool, &usdc, &DEFAULT_FUNDED_AMOUNT);
2333+
client.mark_shipped(&invoice_id);
2334+
client.confirm_delivery(&invoice_id, &issuer);
2335+
client.confirm_delivery(&invoice_id, &buyer);
2336+
assert_eq!(client.get(&invoice_id).status, InvoiceStatus::Confirmed);
2337+
2338+
(env, client, buyer, invoice_id, pool, escrow)
2339+
}
2340+
22672341
// ============== SUPPORTED ASSET TESTS ==============
22682342

22692343
#[test]
@@ -2288,6 +2362,107 @@ fn test_add_supported_asset() {
22882362

22892363
// ============================== REPAY TESTS ==============================
22902364

2365+
#[test]
2366+
fn test_repay_rejects_false_escrow_result() {
2367+
let (env, client, buyer, invoice_id, _pool, escrow) = setup_confirmed_invoice();
2368+
MockEscrowClient::new(&env, &escrow).set_release_result(&false);
2369+
mint_tokens(
2370+
&env,
2371+
&client.get_funding_asset(&invoice_id),
2372+
&buyer,
2373+
DEFAULT_FACE_VALUE as i128,
2374+
);
2375+
2376+
let result = env.try_invoke_contract::<bool, soroban_sdk::Error>(
2377+
&client.address,
2378+
&Symbol::new(&env, "repay"),
2379+
(invoice_id.clone(),).into_val(&env),
2380+
);
2381+
2382+
assert!(result.is_err());
2383+
assert_eq!(client.get(&invoice_id).status, InvoiceStatus::Confirmed);
2384+
}
2385+
2386+
#[test]
2387+
fn test_repay_rejects_false_pool_result() {
2388+
let (env, client, buyer, invoice_id, pool, _escrow) = setup_confirmed_invoice();
2389+
MockPoolClient::new(&env, &pool).set_return_values(&true, &false);
2390+
mint_tokens(
2391+
&env,
2392+
&client.get_funding_asset(&invoice_id),
2393+
&buyer,
2394+
DEFAULT_FACE_VALUE as i128,
2395+
);
2396+
2397+
let result = env.try_invoke_contract::<bool, soroban_sdk::Error>(
2398+
&client.address,
2399+
&Symbol::new(&env, "repay"),
2400+
(invoice_id.clone(),).into_val(&env),
2401+
);
2402+
2403+
assert!(result.is_err());
2404+
assert_eq!(client.get(&invoice_id).status, InvoiceStatus::Confirmed);
2405+
}
2406+
2407+
#[test]
2408+
fn test_repay_early_rejects_false_escrow_result() {
2409+
let (env, client, buyer, invoice_id, _pool, escrow) = setup_confirmed_invoice();
2410+
MockEscrowClient::new(&env, &escrow).set_release_result(&false);
2411+
mint_tokens(
2412+
&env,
2413+
&client.get_funding_asset(&invoice_id),
2414+
&buyer,
2415+
DEFAULT_FACE_VALUE as i128,
2416+
);
2417+
2418+
let result = env.try_invoke_contract::<bool, soroban_sdk::Error>(
2419+
&client.address,
2420+
&Symbol::new(&env, "repay_early"),
2421+
(invoice_id.clone(),).into_val(&env),
2422+
);
2423+
2424+
assert!(result.is_err());
2425+
assert_eq!(client.get(&invoice_id).status, InvoiceStatus::Confirmed);
2426+
}
2427+
2428+
#[test]
2429+
fn test_repay_early_rejects_false_pool_result() {
2430+
let (env, client, buyer, invoice_id, pool, _escrow) = setup_confirmed_invoice();
2431+
MockPoolClient::new(&env, &pool).set_return_values(&true, &false);
2432+
mint_tokens(
2433+
&env,
2434+
&client.get_funding_asset(&invoice_id),
2435+
&buyer,
2436+
DEFAULT_FACE_VALUE as i128,
2437+
);
2438+
2439+
let result = env.try_invoke_contract::<bool, soroban_sdk::Error>(
2440+
&client.address,
2441+
&Symbol::new(&env, "repay_early"),
2442+
(invoice_id.clone(),).into_val(&env),
2443+
);
2444+
2445+
assert!(result.is_err());
2446+
assert_eq!(client.get(&invoice_id).status, InvoiceStatus::Confirmed);
2447+
}
2448+
2449+
#[test]
2450+
fn test_trigger_default_rejects_false_pool_result() {
2451+
let (env, client, _buyer, invoice_id, pool, _escrow) = setup_confirmed_invoice();
2452+
MockPoolClient::new(&env, &pool).set_return_values(&false, &true);
2453+
let due_date = client.get(&invoice_id).due_date;
2454+
env.ledger().set_timestamp(due_date);
2455+
2456+
let result = env.try_invoke_contract::<bool, soroban_sdk::Error>(
2457+
&client.address,
2458+
&Symbol::new(&env, "trigger_default"),
2459+
(invoice_id.clone(),).into_val(&env),
2460+
);
2461+
2462+
assert!(result.is_err());
2463+
assert_eq!(client.get(&invoice_id).status, InvoiceStatus::Confirmed);
2464+
}
2465+
22912466
#[test]
22922467
fn test_repay_from_confirmed() {
22932468
let (env, client, issuer, buyer, _, usdc) = setup();

0 commit comments

Comments
 (0)