diff --git a/contracts/split/src/error.rs b/contracts/split/src/error.rs index c9315b0..d1037cf 100644 --- a/contracts/split/src/error.rs +++ b/contracts/split/src/error.rs @@ -27,4 +27,7 @@ pub enum ContractError { FundsLockedUntil = 20, /// Issue #330: Recipient has already been paid on this invoice. RecipientAlreadyPaid = 19, + /// Oracle-priced invoice: the configured price oracle is unreachable, trapped, or + /// returned a non-positive rate at payment time. + OracleUnavailable = 21, } diff --git a/contracts/split/src/events.rs b/contracts/split/src/events.rs index 8e190a6..4848882 100644 --- a/contracts/split/src/events.rs +++ b/contracts/split/src/events.rs @@ -705,6 +705,27 @@ pub fn fee_paid(env: &Env, invoice_id: u64, amount: i128, treasury: &Address) { ); } +/// Emitted when an oracle-priced invoice fetches a fresh rate at payment time. +/// `rate` is the raw price returned by the oracle (USD cents per 1 whole token, +/// scaled by `ORACLE_RATE_SCALE`); `computed_amount` is the resulting required +/// token total derived from the invoice's fixed USD-cents target. +/// +/// Topics: (split, orc_pf, invoice_id) +/// Data: (rate, computed_amount, ledger) +pub fn oracle_price_fetched(env: &Env, invoice_id: u64, rate: i128, computed_amount: i128) { + env.events().publish( + (symbol_short!("split"), symbol_short!("orc_pf"), invoice_id), + (rate, computed_amount, env.ledger().sequence()), + ); +} + +/// Emitted when a single graduated tranche is released via `release_tranche()`. +/// Topics: (split, tr_rel, invoice_id) +/// Data: (tranche_index, amount, ledger) +pub fn tranche_released(env: &Env, invoice_id: u64, tranche_index: u32, amount: i128) { + env.events().publish( + (symbol_short!("split"), symbol_short!("tr_rel"), invoice_id), + (tranche_index, amount, env.ledger().sequence()), /// Issue #349: Emitted when an address's reputation score is updated. /// Topics: (split, rep_upd, address) /// Data: score diff --git a/contracts/split/src/lib.rs b/contracts/split/src/lib.rs index e2dac96..92e4d2f 100644 --- a/contracts/split/src/lib.rs +++ b/contracts/split/src/lib.rs @@ -21,6 +21,11 @@ const STROOPS_PER_10K_INSTRUCTIONS: u64 = 1; /// Issue #296: Maximum entries in the per-creator fee waiver list. const MAX_FEE_WAIVER_ENTRIES: usize = 100; +/// Fixed-point scale for oracle-priced invoices: the oracle's `price()` return +/// value is USD cents per 1 whole token, scaled by this factor (e.g. 1 XLM at +/// $0.12 = 12 cents is reported as `12 * ORACLE_RATE_SCALE` = `12_000_000`). +const ORACLE_RATE_SCALE: i128 = 1_000_000; + mod error; mod events; mod types; @@ -159,6 +164,13 @@ fn milestone_flags_key(id: u64) -> (Symbol, u64) { (symbol_short!("ms_flgs"), id) } +/// Cliff + vesting schedule: bitmask (u32) of tranche indices already released +/// via `release_tranche()` — bit N set means `tranches[N]` has been paid out. +/// Supports up to 32 tranches per invoice (matches `paid_flags_key` convention). +fn released_tranche_idx_key(id: u64) -> (Symbol, u64) { + (symbol_short!("tr_rel_ix"), id) +} + /// Issue #334: compact status byte (u8) — persistent storage. fn compact_status_key(id: u64) -> (Symbol, u64) { (symbol_short!("cpt_sts"), id) @@ -730,6 +742,12 @@ fn archive_invoice_storage(env: &Env, id: u64, core: &InvoiceCore) { .get(&invoice_ext2_key(id)) .or_else(|| env.storage().instance().get(&invoice_ext2_key(id))) .unwrap_or_else(|| InvoiceExt2 { + notification_contract: None, overflow_behavior: OverflowBehavior::Reject, + cross_chain_ref: None, require_kyc: false, arbiter: None, disputed: false, + admin_frozen: false, auction_on_expiry: false, auction_end: 0, bids: Vec::new(env), + min_payment: 0, min_funding_amount: 0, priorities: Vec::new(env), + target_usd_cents: None, refunded_addresses: Vec::new(env), + oracle: None, oracle_asset_pair: None, notification_contract: None, overflow_behavior: OverflowBehavior::Reject, cross_chain_ref: None, @@ -940,6 +958,8 @@ fn load_invoice(env: &Env, id: u64) -> Invoice { priorities: Vec::new(env), target_usd_cents: None, refunded_addresses: Vec::new(env), + oracle: None, + oracle_asset_pair: None, min_payer_rep: None, }); @@ -2056,6 +2076,8 @@ impl SplitContract { priorities: Vec::new(&env), target_usd_cents: None, refunded_addresses: Vec::new(&env), + oracle: None, + oracle_asset_pair: None, min_payer_rep: None, }) }); @@ -2518,6 +2540,8 @@ impl SplitContract { None, // release_delay_ledgers (use create_invoice_ext for this) None, // metadata_hash None, // target_usd_cents + options.oracle, + options.oracle_asset_pair, ) } @@ -2572,6 +2596,8 @@ impl SplitContract { release_delay_ledgers: Option, metadata_hash: Option>, target_usd_cents: Option, + oracle: Option
, + oracle_asset_pair: Option<(Symbol, Symbol)>, ) -> u64 { assert!( recipients.len() == amounts.len(), @@ -2602,6 +2628,16 @@ impl SplitContract { "priorities length must match recipients" ); } + if oracle.is_some() { + assert!( + oracle_asset_pair.is_some(), + "oracle_asset_pair required when oracle is set" + ); + assert!( + price_oracle.is_none(), + "cannot set both price_oracle and oracle" + ); + } for amt in amounts.iter() { assert!(amt > 0, "amounts must be positive"); @@ -2939,6 +2975,8 @@ impl SplitContract { admin_frozen: false, min_funding_amount: 0, target_usd_cents, + oracle, + oracle_asset_pair, min_payer_rep, }; @@ -3078,6 +3116,13 @@ impl SplitContract { None, None, Vec::new(&env), // priorities + false, // require_kyc + None, // scheduled_release_at + None, // release_delay_ledgers + None, // metadata_hash + None, // target_usd_cents + None, // oracle + None, // oracle_asset_pair false, // require_kyc None, // scheduled_release_at None, // release_delay_ledgers @@ -3158,6 +3203,29 @@ impl SplitContract { false, // smart_route None, // notification_contract OverflowBehavior::Reject, + false, // convert_to_stream + Vec::new(&env), // accepted_tokens + None, // forward_to + None, // forward_invoice_id + None, // creator_cosigner + 0_i128, // velocity_limit + 0_u64, // velocity_window + Vec::new(&env), // split_rules + Vec::new(&env), // auto_resolve_rules + None, // cross_chain_ref + None, // allowed_payers + None, // payment_cooldown_secs + None, // max_payments_per_window + None, // payment_window_secs + None, // refund_grace_secs + Vec::new(&env), // priorities + false, // require_kyc + None, // scheduled_release_at + None, // release_delay_ledgers + None, // metadata_hash + None, // target_usd_cents + None, // oracle + None, // oracle_asset_pair false, // convert_to_stream Vec::new(&env), // accepted_tokens None, // forward_to @@ -3255,6 +3323,13 @@ impl SplitContract { None, None, Vec::new(&env), // priorities + false, // require_kyc + None, // scheduled_release_at + None, // release_delay_ledgers + None, // metadata_hash + None, // target_usd_cents + None, // oracle + None, // oracle_asset_pair false, // require_kyc None, // scheduled_release_at None, // release_delay_ledgers @@ -3436,6 +3511,8 @@ impl SplitContract { priorities: source.priorities.clone(), target_usd_cents: source.target_usd_cents, refunded_addresses: Vec::new(&env), + oracle: source.oracle.clone(), + oracle_asset_pair: source.oracle_asset_pair.clone(), min_payer_rep: source.min_payer_rep, }; @@ -3778,6 +3855,33 @@ impl SplitContract { env.invoke_contract(oracle, &Symbol::new(env, "get_price"), Vec::new(env)); let base_total: i128 = invoice.base_amounts.iter().sum(); base_total * oracle_price / 1_000_000 + } else if let Some(ref oracle) = invoice.oracle { + // Oracle-priced invoice: the funding target is determined at payment + // time, not fixed at creation. `amounts` holds the fixed USD-cents + // target (e.g. "$100 worth of XLM"); the required token total is + // recomputed from the oracle's live exchange rate on every payment, + // so it tracks the market instead of being locked in at creation. + let pair = invoice + .oracle_asset_pair + .clone() + .expect("oracle_asset_pair required when oracle is set"); + let args: Vec = (pair,).into_val(env); + let price_result = env.try_invoke_contract::( + oracle, + &Symbol::new(env, "price"), + args, + ); + // Any failure to reach the oracle (trap, missing contract, error + // result) or a non-positive rate (stale/uninitialized feed) is + // treated as unavailable rather than surfacing a cryptic host error. + let rate: i128 = match price_result { + Ok(Ok(r)) if r > 0 => r, + _ => panic!("OracleUnavailable"), + }; + let usd_cents_target: i128 = invoice.amounts.iter().sum(); + let computed_amount = usd_cents_target * ORACLE_RATE_SCALE / rate; + events::oracle_price_fetched(env, invoice_id, rate, computed_amount); + computed_amount } else { invoice.amounts.iter().sum() }; @@ -5419,6 +5523,83 @@ impl SplitContract { save_invoice(env, invoice_id, invoice); } + /// Release a single tranche of a graduated schedule by index. + /// + /// Unlike `release()` (which distributes every tranche unlocked so far in one + /// call), this lets any caller trigger exactly one tranche once its + /// `release_time` has passed. Requires the invoice to be fully funded and the + /// tranche not to have been released already. Pays each recipient their + /// pro-rata share of that tranche's basis points. + pub fn release_tranche(env: Env, invoice_id: u64, tranche_index: u32) { + require_fn_not_paused(&env, &symbol_short!("release")); + let mut invoice = load_invoice(&env, invoice_id); + let actor = env.current_contract_address(); + + assert!(!invoice.frozen, "invoice is frozen"); + assert!(!invoice.admin_frozen, "invoice frozen by admin"); + assert!(invoice.status == InvoiceStatus::Pending, "invoice is not pending"); + + let total: i128 = invoice.amounts.iter().sum(); + assert!(invoice.funded >= total, "invoice not fully funded"); + + assert!(tranche_index < invoice.tranches.len(), "tranche index out of range"); + assert!(tranche_index < 32, "tranche index exceeds bitmask capacity"); + let tranche = invoice.tranches.get(tranche_index).unwrap(); + + let now = env.ledger().timestamp(); + assert!(now >= tranche.timestamp, "tranche not yet releasable"); + + let mut released_idx: u32 = env + .storage() + .persistent() + .get(&released_tranche_idx_key(invoice_id)) + .unwrap_or(0u32); + let bit = 1u32 << tranche_index; + assert!(released_idx & bit == 0, "tranche already released"); + + let token_client = token::Client::new(&env, &invoice.tokens.get(0).expect("no token")); + let funded = invoice.funded; + let n = invoice.recipients.len(); + let mut total_paid: i128 = 0; + for i in 0..n { + let recipient = invoice.recipients.get(i).unwrap(); + let amount = invoice.amounts.get(i).unwrap(); + let payout = ((amount as u128) + .saturating_mul(tranche.basis_points as u128) + .saturating_mul(funded as u128) + / (10_000u128 * total as u128)) as i128; + if payout > 0 { + token_client.transfer(&env.current_contract_address(), &recipient, &payout); + total_paid += payout; + } + } + + released_idx |= bit; + env.storage().persistent().set(&released_tranche_idx_key(invoice_id), &released_idx); + + invoice.released_bps += tranche.basis_points; + + if invoice.released_bps >= 10_000 { + invoice.status = InvoiceStatus::Released; + invoice.completion_time = Some(now); + if invoice.insurance_fund > 0 { + token_client.transfer(&env.current_contract_address(), &invoice.creator, &invoice.insurance_fund); + invoice.insurance_fund = 0; + } + append_audit_entry(&env, invoice_id, symbol_short!("release"), &actor); + events::invoice_released(&env, invoice_id, &invoice.recipients); + events::invoice_state_changed(&env, invoice_id, Some(&InvoiceStatus::Pending), &InvoiceStatus::Released, &actor); + notify_invoice(&env, invoice_id, symbol_short!("release"), &invoice.notification_contract); + maybe_record_released(&env, &invoice.creator, total_paid); + update_creator_stats_on_release(&env, &invoice.creator, total_paid); + } + + save_invoice(&env, invoice_id, &invoice); + + append_audit_entry(&env, invoice_id, symbol_short!("tr_rel"), &actor); + events::tranche_released(&env, invoice_id, tranche_index, total_paid); + } + // ----------------------------------------------------------------------- // Stage release (#86) // ----------------------------------------------------------------------- @@ -6255,6 +6436,13 @@ impl SplitContract { None, None, Vec::new(env), // priorities + false, // require_kyc + None, // scheduled_release_at + None, // release_delay_ledgers + None, // metadata_hash + None, // target_usd_cents + None, // oracle + None, // oracle_asset_pair false, // require_kyc None, // scheduled_release_at None, // release_delay_ledgers @@ -6999,6 +7187,8 @@ impl SplitContract { None, // release_delay_ledgers None, // metadata_hash None, // target_usd_cents + old_invoice.oracle.clone(), + old_invoice.oracle_asset_pair.clone(), ); // Copy payments from shards to new invoice (issue #177). @@ -7277,6 +7467,13 @@ impl SplitContract { None, None, Vec::new(&env), // priorities + false, // require_kyc + None, // scheduled_release_at + None, // release_delay_ledgers + None, // metadata_hash + None, // target_usd_cents + None, // oracle + None, // oracle_asset_pair false, // require_kyc None, // scheduled_release_at None, // release_delay_ledgers @@ -7831,6 +8028,10 @@ impl SplitContract { arbiter: None, disputed: false, admin_frozen: false, + auction_on_expiry: false, auction_end: 0, bids: Vec::new(&env), + min_payment: 0, min_funding_amount: 0, priorities: Vec::new(&env), + target_usd_cents: None, refunded_addresses: Vec::new(&env), + oracle: None, oracle_asset_pair: None, auction_on_expiry: false, auction_end: 0, bids: Vec::new(&env), @@ -7937,6 +8138,10 @@ impl SplitContract { arbiter: None, disputed: false, admin_frozen: false, + auction_on_expiry: false, auction_end: 0, bids: Vec::new(&env), + min_payment: 0, min_funding_amount: 0, priorities: Vec::new(&env), + target_usd_cents: None, refunded_addresses: Vec::new(&env), + oracle: None, oracle_asset_pair: None, auction_on_expiry: false, auction_end: 0, bids: Vec::new(&env), diff --git a/contracts/split/src/test.rs b/contracts/split/src/test.rs index fd2736b..7d033e7 100644 --- a/contracts/split/src/test.rs +++ b/contracts/split/src/test.rs @@ -78,6 +78,8 @@ fn default_options(env: &Env) -> InvoiceOptions { priorities: Vec::new(env), require_kyc: false, scheduled_release_at: None, + oracle: None, + oracle_asset_pair: None, min_payer_rep: None, } } @@ -128,6 +130,8 @@ fn invoice_options( priorities: Vec::new(env), require_kyc: false, scheduled_release_at: None, + oracle: None, + oracle_asset_pair: None, min_payer_rep: None, } } @@ -1603,6 +1607,187 @@ fn test_release_before_any_tranche_unlocked_panics() { c.release(&id); } +// --------------------------------------------------------------------------- +// release_tranche — cliff + per-index graduated release +// --------------------------------------------------------------------------- + +#[test] +fn test_release_tranche_full_vesting_schedule() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + let tk = token_client(&env, &token_id); + + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let recipient = Address::generate(&env); + + StellarAssetClient::new(&env, &token_id).mint(&payer, &1_000); + env.ledger().set_timestamp(1_000); + + // Cliff at t=2_000 (30%), then t=3_000 (30%), then t=4_000 (40%). + let mut tranches = Vec::new(&env); + tranches.push_back(types::Tranche { timestamp: 2_000, basis_points: 3_000 }); + tranches.push_back(types::Tranche { timestamp: 3_000, basis_points: 3_000 }); + tranches.push_back(types::Tranche { timestamp: 4_000, basis_points: 4_000 }); + + let mut recipients = Vec::new(&env); + recipients.push_back(recipient.clone()); + let mut amounts = Vec::new(&env); + amounts.push_back(1_000_i128); + + let id = c.create_invoice( + &creator, + &recipients, + &amounts, + &token_id, + &9_999_u64, + &InvoiceOptions { + tranches: tranches.clone(), + ..default_options(&env) + }, + ); + + c.pay(&payer, &id, &1_000_i128, &0_u64, &false, &false); + + // Before the cliff, nothing has been released. + assert_eq!(c.get_invoice(&id).released_bps, 0); + assert_eq!(tk.balance(&recipient), 0); + + // First tranche unlocks. + env.ledger().set_timestamp(2_000); + c.release_tranche(&id, &0_u32); + assert_eq!(tk.balance(&recipient), 300); + assert_eq!(c.get_invoice(&id).released_bps, 3_000); + assert_eq!(c.get_invoice(&id).status, InvoiceStatus::Pending); + + // Second tranche unlocks. + env.ledger().set_timestamp(3_000); + c.release_tranche(&id, &1_u32); + assert_eq!(tk.balance(&recipient), 600); + assert_eq!(c.get_invoice(&id).released_bps, 6_000); + assert_eq!(c.get_invoice(&id).status, InvoiceStatus::Pending); + + // Final tranche unlocks — invoice becomes fully Released. + env.ledger().set_timestamp(4_000); + c.release_tranche(&id, &2_u32); + assert_eq!(tk.balance(&recipient), 1_000); + assert_eq!(c.get_invoice(&id).released_bps, 10_000); + assert_eq!(c.get_invoice(&id).status, InvoiceStatus::Released); +} + +#[test] +#[should_panic(expected = "tranche not yet releasable")] +fn test_release_tranche_before_time_panics() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let recipient = Address::generate(&env); + + StellarAssetClient::new(&env, &token_id).mint(&payer, &500); + env.ledger().set_timestamp(1_000); + + let mut tranches = Vec::new(&env); + tranches.push_back(types::Tranche { timestamp: 5_000, basis_points: 10_000 }); + + let mut recipients = Vec::new(&env); + recipients.push_back(recipient.clone()); + let mut amounts = Vec::new(&env); + amounts.push_back(500_i128); + + let id = c.create_invoice( + &creator, + &recipients, + &amounts, + &token_id, + &9_999_u64, + &InvoiceOptions { + tranches: tranches.clone(), + ..default_options(&env) + }, + ); + + c.pay(&payer, &id, &500_i128, &0_u64, &false, &false); + + // t=2_000 < tranche timestamp 5_000 — should panic. + env.ledger().set_timestamp(2_000); + c.release_tranche(&id, &0_u32); +} + +#[test] +#[should_panic(expected = "tranche already released")] +fn test_release_tranche_double_release_panics() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let recipient = Address::generate(&env); + + StellarAssetClient::new(&env, &token_id).mint(&payer, &1_000); + env.ledger().set_timestamp(1_000); + + let mut tranches = Vec::new(&env); + tranches.push_back(types::Tranche { timestamp: 1_500, basis_points: 5_000 }); + tranches.push_back(types::Tranche { timestamp: 2_500, basis_points: 5_000 }); + + let mut recipients = Vec::new(&env); + recipients.push_back(recipient.clone()); + let mut amounts = Vec::new(&env); + amounts.push_back(1_000_i128); + + let id = c.create_invoice( + &creator, + &recipients, + &amounts, + &token_id, + &9_999_u64, + &InvoiceOptions { + tranches: tranches.clone(), + ..default_options(&env) + }, + ); + + c.pay(&payer, &id, &1_000_i128, &0_u64, &false, &false); + + env.ledger().set_timestamp(1_600); + c.release_tranche(&id, &0_u32); + // Same index again — should panic even though it's unlocked. + c.release_tranche(&id, &0_u32); +} + +#[test] +#[should_panic(expected = "tranches must sum to 10000 basis points")] +fn test_create_invoice_tranches_bps_not_10000_panics() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + + let creator = Address::generate(&env); + let recipient = Address::generate(&env); + + let mut tranches = Vec::new(&env); + tranches.push_back(types::Tranche { timestamp: 1_000, basis_points: 4_000 }); + tranches.push_back(types::Tranche { timestamp: 2_000, basis_points: 4_000 }); + + let mut recipients = Vec::new(&env); + recipients.push_back(recipient.clone()); + let mut amounts = Vec::new(&env); + amounts.push_back(1_000_i128); + + c.create_invoice( + &creator, + &recipients, + &amounts, + &token_id, + &9_999_u64, + &InvoiceOptions { + tranches: tranches.clone(), + ..default_options(&env) + }, + ); +} + // --------------------------------------------------------------------------- // Issue #24 — on-chain reputation counter // --------------------------------------------------------------------------- @@ -3580,6 +3765,238 @@ fn test_create_invoice_stores_price_oracle_and_base_amounts() { assert_eq!(invoice.amounts.get(0).unwrap(), 500); } +// --------------------------------------------------------------------------- +// Oracle-priced invoices — funding target computed at payment time. +// +// A "$100 worth of XLM" invoice: `amounts` holds the fixed USD-cents target +// (10_000 = $100.00) and the oracle's `price(asset_pair)` call returns USD +// cents per 1 whole token scaled by ORACLE_RATE_SCALE (1_000_000), e.g. +// 1 XLM at $0.10 is rate = 10 * 1_000_000 = 10_000_000. The required token +// total is `usd_cents_target * ORACLE_RATE_SCALE / rate`. +// --------------------------------------------------------------------------- + +/// Configurable mock oracle: `price()` returns whatever rate was last set via +/// `set_rate`, defaulting to 0 (used for the "oracle returns zero" scenario). +#[contract] +struct MockConfigurableOracle; + +#[contractimpl] +impl MockConfigurableOracle { + pub fn set_rate(env: Env, rate: i128) { + env.storage().instance().set(&symbol_short!("rate"), &rate); + } + + pub fn price(env: Env, _asset_pair: (Symbol, Symbol)) -> i128 { + env.storage() + .instance() + .get(&symbol_short!("rate")) + .unwrap_or(0i128) + } +} + +/// Oracle mock that always traps — simulates a stale/unreachable price feed. +#[contract] +struct MockTrapOracle; + +#[contractimpl] +impl MockTrapOracle { + pub fn price(_env: Env, _asset_pair: (Symbol, Symbol)) -> i128 { + panic!("oracle feed stale"); + } +} + +fn xlm_usd_pair() -> (Symbol, Symbol) { + (symbol_short!("XLM"), symbol_short!("USD")) +} + +#[test] +fn test_oracle_create_invoice_stores_oracle_address() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + + let creator = Address::generate(&env); + let recipient = Address::generate(&env); + + env.ledger().set_timestamp(1_000); + + let oracle_id = env.register(MockConfigurableOracle, ()); + + let mut opts = default_options(&env); + opts.oracle = Some(oracle_id.clone()); + opts.oracle_asset_pair = Some(xlm_usd_pair()); + + let mut recipients = Vec::new(&env); + recipients.push_back(recipient.clone()); + let mut amounts = Vec::new(&env); + amounts.push_back(10_000_i128); // $100.00 target, in USD cents + + let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999, &opts); + + let ext2 = c.get_invoice_ext2(&id); + assert_eq!(ext2.oracle, Some(oracle_id)); + assert_eq!(ext2.oracle_asset_pair, Some(xlm_usd_pair())); +} + +#[test] +fn test_oracle_create_invoice_requires_asset_pair() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + + let creator = Address::generate(&env); + let recipient = Address::generate(&env); + + env.ledger().set_timestamp(1_000); + + let oracle_id = env.register(MockConfigurableOracle, ()); + + let mut opts = default_options(&env); + opts.oracle = Some(oracle_id); + // oracle_asset_pair intentionally left None. + + let mut recipients = Vec::new(&env); + recipients.push_back(recipient.clone()); + let mut amounts = Vec::new(&env); + amounts.push_back(10_000_i128); + + let result = c.try_create_invoice(&creator, &recipients, &amounts, &token_id, &9_999, &opts); + assert!(result.is_err()); +} + +#[test] +fn test_oracle_price_changes_between_payments() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let recipient = Address::generate(&env); + + env.ledger().set_timestamp(1_000); + StellarAssetClient::new(&env, &token_id).mint(&payer, &2_000); + + let oracle_id = env.register(MockConfigurableOracle, ()); + let oracle_client = MockConfigurableOracleClient::new(&env, &oracle_id); + oracle_client.set_rate(&10_000_000_i128); // 1 XLM = $0.10 + + let mut opts = default_options(&env); + opts.oracle = Some(oracle_id); + opts.oracle_asset_pair = Some(xlm_usd_pair()); + + let mut recipients = Vec::new(&env); + recipients.push_back(recipient.clone()); + let mut amounts = Vec::new(&env); + amounts.push_back(10_000_i128); // $100.00 target + + let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999, &opts); + + // At $0.10/XLM, $100 requires 1000 XLM. Pay 400 of it. + c.pay(&payer, &id, &400_i128, &0_u64, &false, &false); + let invoice = c.get_invoice(&id); + assert_eq!(invoice.funded, 400); + assert_eq!(invoice.status, InvoiceStatus::Pending); + + // Price rises to $0.20/XLM -> only 500 XLM needed in total; remaining = 100. + oracle_client.set_rate(&20_000_000_i128); + c.pay(&payer, &id, &100_i128, &1_u64, &false, &false); + let invoice = c.get_invoice(&id); + assert_eq!(invoice.funded, 500); + assert_eq!(invoice.status, InvoiceStatus::Released); +} + +#[test] +fn test_oracle_emits_price_fetched_event() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let recipient = Address::generate(&env); + + env.ledger().set_timestamp(1_000); + StellarAssetClient::new(&env, &token_id).mint(&payer, &1_000); + + let oracle_id = env.register(MockConfigurableOracle, ()); + MockConfigurableOracleClient::new(&env, &oracle_id).set_rate(&10_000_000_i128); + + let mut opts = default_options(&env); + opts.oracle = Some(oracle_id); + opts.oracle_asset_pair = Some(xlm_usd_pair()); + + let mut recipients = Vec::new(&env); + recipients.push_back(recipient.clone()); + let mut amounts = Vec::new(&env); + amounts.push_back(10_000_i128); // $100.00 target -> 1000 XLM at $0.10 + + let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999, &opts); + c.pay(&payer, &id, &1_000_i128, &0_u64, &false, &false); + + let found = env + .events() + .all() + .iter() + .any(|(_c, topics, _d)| topic1_is(&env, &topics, "orc_pf")); + assert!(found, "expected OraclePriceFetched event to be published"); +} + +#[test] +#[should_panic(expected = "OracleUnavailable")] +fn test_oracle_unavailable_panics() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let recipient = Address::generate(&env); + + env.ledger().set_timestamp(1_000); + StellarAssetClient::new(&env, &token_id).mint(&payer, &1_000); + + let oracle_id = env.register(MockTrapOracle, ()); + + let mut opts = default_options(&env); + opts.oracle = Some(oracle_id); + opts.oracle_asset_pair = Some(xlm_usd_pair()); + + let mut recipients = Vec::new(&env); + recipients.push_back(recipient.clone()); + let mut amounts = Vec::new(&env); + amounts.push_back(10_000_i128); + + let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999, &opts); + + c.pay(&payer, &id, &100_i128, &0_u64, &false, &false); +} + +#[test] +#[should_panic(expected = "OracleUnavailable")] +fn test_oracle_zero_rate_panics() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let recipient = Address::generate(&env); + + env.ledger().set_timestamp(1_000); + StellarAssetClient::new(&env, &token_id).mint(&payer, &1_000); + + // MockConfigurableOracle defaults to a rate of 0 until set_rate is called. + let oracle_id = env.register(MockConfigurableOracle, ()); + + let mut opts = default_options(&env); + opts.oracle = Some(oracle_id); + opts.oracle_asset_pair = Some(xlm_usd_pair()); + + let mut recipients = Vec::new(&env); + recipients.push_back(recipient.clone()); + let mut amounts = Vec::new(&env); + amounts.push_back(10_000_i128); + + let id = c.create_invoice(&creator, &recipients, &amounts, &token_id, &9_999, &opts); + + c.pay(&payer, &id, &100_i128, &0_u64, &false, &false); +} + // --------------------------------------------------------------------------- // Analytics counters (issue #28) // --------------------------------------------------------------------------- diff --git a/contracts/split/src/types.rs b/contracts/split/src/types.rs index a291d24..c18a473 100644 --- a/contracts/split/src/types.rs +++ b/contracts/split/src/types.rs @@ -271,6 +271,14 @@ pub struct InvoiceOptions { pub scheduled_release_at: Option, /// KYC verification requirement. pub require_kyc: bool, + /// Oracle contract used for oracle-priced invoices: the funding target is + /// computed at payment time from a live exchange rate instead of being + /// fixed at creation. When set, `oracle_asset_pair` must also be set and + /// `amounts` is interpreted as the USD-cents funding target. + pub oracle: Option
, + /// (base, quote) asset symbols passed to the oracle's `price(asset_pair)` + /// call, e.g. (XLM, USD). + pub oracle_asset_pair: Option<(Symbol, Symbol)>, /// Minimum required payer reputation score to pay this invoice (issue #349). pub min_payer_rep: Option, } @@ -419,6 +427,10 @@ pub struct InvoiceExt2 { pub target_usd_cents: Option, /// Issue #308: addresses that have already claimed a per-payer refund on this invoice. pub refunded_addresses: Vec
, + /// Oracle-priced invoices: oracle contract queried at payment time. + pub oracle: Option
, + /// Oracle-priced invoices: (base, quote) asset pair passed to the oracle. + pub oracle_asset_pair: Option<(Symbol, Symbol)>, /// Issue #349: minimum required payer reputation score. pub min_payer_rep: Option, } @@ -535,6 +547,10 @@ pub struct Invoice { pub target_usd_cents: Option, /// Issue #308: addresses that have already claimed a per-payer refund on this invoice. pub refunded_addresses: Vec
, + /// Oracle-priced invoices: oracle contract queried at payment time. + pub oracle: Option
, + /// Oracle-priced invoices: (base, quote) asset pair passed to the oracle. + pub oracle_asset_pair: Option<(Symbol, Symbol)>, /// Issue #349: minimum required payer reputation score. pub min_payer_rep: Option, } @@ -624,6 +640,8 @@ impl Invoice { priorities: self.priorities, target_usd_cents: self.target_usd_cents, refunded_addresses: self.refunded_addresses, + oracle: self.oracle, + oracle_asset_pair: self.oracle_asset_pair, min_payer_rep: self.min_payer_rep, }, ) @@ -708,6 +726,8 @@ impl Invoice { priorities: ext2.priorities, target_usd_cents: ext2.target_usd_cents, refunded_addresses: ext2.refunded_addresses, + oracle: ext2.oracle, + oracle_asset_pair: ext2.oracle_asset_pair, min_payer_rep: ext2.min_payer_rep, } } @@ -928,6 +948,8 @@ impl Invoice { priorities: Vec::new(env), target_usd_cents: None, refunded_addresses: Vec::new(env), + oracle: None, + oracle_asset_pair: None, min_payer_rep: None, } }