From 0068617c2079b2cc6e9c6375a172552a59171601 Mon Sep 17 00:00:00 2001 From: Kilo Date: Thu, 30 Jul 2026 12:39:56 +0100 Subject: [PATCH] feat: implement issues #559, #560, #561, #562 - #559 Creator Revenue Share: add creator_fee_bps to InvoiceOptions2/Invoice, validate fee cap in _create_invoice_inner, deduct creator fee in release paths, emit creator_fee_paid event - #560 Creator Migration: add nominate_new_creator/accept_creator_role entry points, pending_creator_key storage, creator_nominated/creator_migrated events - #561 Payout Ordering: add sort_recipients in calc.rs, emit payout_initiated events with recipient index during release - #562 Soft-Delete with Tombstone: add Tombstone type, delete_invoice/get_tombstone entry points, tombstone_key storage, InvoiceDeleted/FundsUnclaimed/NotDeleted guards, and invoice_state_changed updates Closes #559, #560, #561, #562 --- Cargo.lock | 14 + contracts/split/src/calc.rs | 51 ++++ contracts/split/src/error.rs | 8 + contracts/split/src/events.rs | 62 ++++ contracts/split/src/lib.rs | 197 ++++++++++-- contracts/split/src/storage_keys.rs | 30 ++ contracts/split/src/test.rs | 459 ++++++++++++++++++++++++++++ contracts/split/src/types.rs | 42 ++- 8 files changed, 842 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7ef3444..b26c4ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -585,6 +585,13 @@ version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40404c3f5f511ec4da6fe866ddf6a717c309fdbb69fbbad7b0f3edab8f2e835f" +[[package]] +name = "factory" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -1158,6 +1165,13 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "registry" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "rfc6979" version = "0.4.0" diff --git a/contracts/split/src/calc.rs b/contracts/split/src/calc.rs index d8dc268..cd8a120 100644 --- a/contracts/split/src/calc.rs +++ b/contracts/split/src/calc.rs @@ -91,6 +91,57 @@ pub fn distribute_with_remainder( shares_mut } +// --------------------------------------------------------------------------- +// Issue #561: Canonical payout ordering +// --------------------------------------------------------------------------- + +/// Sort recipients by their Stellar address byte representation (lexicographic +/// order over `Address`'s inner `BytesN<32>`). +/// +/// This guarantees deterministic, canonical payout ordering regardless of the +/// order in which recipients were supplied at invoice creation. +/// +/// # Arguments +/// * `env` – Soroban environment +/// * `recipients` – mutable list of recipient addresses to sort in-place +pub fn sort_recipients(env: &Env, recipients: &mut Vec
) { + let n = recipients.len() as usize; + if n <= 1 { + return; + } + + // Build byte representations for comparison. + let mut bytes_vec: Vec<(BytesN<32>, usize)> = Vec::new(env); + for i in 0..n { + let addr = recipients.get(i as u32).unwrap(); + let bytes = addr.to_bytes(); + bytes_vec.push_back((bytes, i)); + } + + // Insertion sort by byte representation (lexicographic). + for i in 1..n { + let key = bytes_vec.get(i).unwrap(); + let mut j = i; + while j > 0 { + let prev = bytes_vec.get(j - 1).unwrap(); + if prev.0 <= key.0 { + break; + } + bytes_vec.set(j, bytes_vec.get(j - 1).unwrap()); + j -= 1; + } + bytes_vec.set(j, key.clone()); + } + + // Reorder recipients according to sorted indices. + let mut sorted = Vec::new(env); + for i in 0..n { + let (_, original_idx) = bytes_vec.get(i).unwrap(); + sorted.push_back(recipients.get(original_idx as u32).unwrap()); + } + *recipients = sorted; +} + // --------------------------------------------------------------------------- // Unit tests // --------------------------------------------------------------------------- diff --git a/contracts/split/src/error.rs b/contracts/split/src/error.rs index 2ffa89c..8217fcb 100644 --- a/contracts/split/src/error.rs +++ b/contracts/split/src/error.rs @@ -80,4 +80,12 @@ pub enum ContractError { CircularDependency = 45, /// Issue #453: Source contract has exceeded its call rate limit within the current window. SourceContractRateLimited = 46, + /// Issue #559: creator fee bps + platform fee bps exceeds cap of 10000. + FeeSumExceedsCap = 47, + /// Issue #562: operation not allowed on a soft-deleted invoice. + InvoiceDeleted = 48, + /// Issue #562: invoice has unclaimed funds and cannot be soft-deleted. + FundsUnclaimed = 49, + /// Issue #562: attempted to read tombstone for an invoice that is not deleted. + NotDeleted = 50, } diff --git a/contracts/split/src/events.rs b/contracts/split/src/events.rs index 38575d2..f9a9811 100644 --- a/contracts/split/src/events.rs +++ b/contracts/split/src/events.rs @@ -1533,3 +1533,65 @@ pub fn batch_invoice_created(env: &Env, ids: &Vec) { env.events() .publish((symbol_short!("btch_crt"),), ids.clone()); } + +// --------------------------------------------------------------------------- +// Issue #559: Creator Revenue Share +// --------------------------------------------------------------------------- + +/// Emitted when the creator fee is deducted during invoice release. +/// +/// Topics: `("creator_fee", invoice_id)` +/// Data: `(creator, fee_amount)` +pub fn creator_fee_paid(env: &Env, invoice_id: u64, creator: &Address, fee_amount: i128) { + env.events().publish( + (symbol_short!("creator_fee"), invoice_id), + (creator.clone(), fee_amount), + ); +} + +// --------------------------------------------------------------------------- +// Issue #560: Creator Migration +// --------------------------------------------------------------------------- + +/// Emitted when a new creator is nominated for an invoice. +/// +/// Topics: `("creator_nom", invoice_id)` +/// Data: `(successor)` +pub fn creator_nominated(env: &Env, invoice_id: u64, successor: &Address) { + env.events().publish( + (symbol_short!("creator_nom"), invoice_id), + successor.clone(), + ); +} + +/// Emitted when a creator role is accepted and the creator is migrated. +/// +/// Topics: `("creator_mig", invoice_id)` +/// Data: `(new_creator)` +pub fn creator_migrated(env: &Env, invoice_id: u64, new_creator: &Address) { + env.events().publish( + (symbol_short!("creator_mig"), invoice_id), + new_creator.clone(), + ); +} + +// --------------------------------------------------------------------------- +// Issue #561: Payout Ordering +// --------------------------------------------------------------------------- + +/// Emitted when a payout is initiated for a recipient. +/// +/// Topics: `("payout_init", invoice_id, recipient_index)` +/// Data: `(recipient, amount)` +pub fn payout_initiated( + env: &Env, + invoice_id: u64, + recipient_index: u32, + recipient: &Address, + amount: i128, +) { + env.events().publish( + (symbol_short!("payout_init"), invoice_id, recipient_index), + (recipient.clone(), amount), + ); +} diff --git a/contracts/split/src/lib.rs b/contracts/split/src/lib.rs index 8378d7a..4799bf5 100644 --- a/contracts/split/src/lib.rs +++ b/contracts/split/src/lib.rs @@ -156,6 +156,15 @@ fn creation_fee_key() -> Symbol { fn platform_fee_bps_key() -> Symbol { symbol_short!("plat_fee") } +fn creator_fee_bps_key(invoice_id: u64) -> (Symbol, u64) { + (symbol_short!("cr_fee_bps"), invoice_id) +} +fn pending_creator_key(invoice_id: u64) -> (Symbol, u64) { + (symbol_short!("pend_cr"), invoice_id) +} +fn tombstone_key(invoice_id: u64) -> (Symbol, u64) { + (symbol_short!("tombstone"), invoice_id) +} fn fallback_escrow_key(invoice_id: u64, recipient: &Address) -> (Symbol, u64, Address) { (symbol_short!("fb_esc"), invoice_id, recipient.clone()) } @@ -4904,6 +4913,7 @@ impl SplitContract { options.ext.release_condition_hash, options.ext.early_bird_window_ledgers, options.ext.early_bird_fee_bps, + options.ext.creator_fee_bps, ); apply_overfunding_policy(&env, id, overfunding_policy); @@ -5034,6 +5044,7 @@ impl SplitContract { options.ext.release_condition_hash, options.ext.early_bird_window_ledgers, options.ext.early_bird_fee_bps, + options.ext.creator_fee_bps, ); apply_overfunding_policy(&env, id, overfunding_policy); @@ -5105,6 +5116,7 @@ impl SplitContract { release_condition_hash: Option>, early_bird_window_ledgers: u32, early_bird_fee_bps: u32, + creator_fee_bps: u32, ) -> u64 { check_not_paused(env); validate_allowed_token(env, &funding_token); @@ -5156,6 +5168,20 @@ impl SplitContract { "early_bird_fee_bps must not exceed the standard platform fee" ); } + // Issue #559: creator fee must be within bounds and not exceed cap with platform fee. + assert!( + creator_fee_bps <= 10_000, + "creator_fee_bps must be ≤ 10000" + ); + let platform_fee_bps = env + .storage() + .instance() + .get(&platform_fee_bps_key()) + .unwrap_or(0); + assert!( + (creator_fee_bps as u64 + platform_fee_bps as u64) <= 10_000, + "FeeSumExceedsCap" + ); if tax_bps > 0 { assert!( tax_authority.is_some(), @@ -5601,6 +5627,7 @@ impl SplitContract { early_bird_window_ledgers, early_bird_fee_bps, early_bird_fee_credit: 0, + creator_fee_bps, }; save_invoice(env, id, &invoice); @@ -5769,6 +5796,7 @@ impl SplitContract { None, // release_condition_hash 0, // early_bird_window_ledgers 0, // early_bird_fee_bps + 0, // creator_fee_bps ); ids.push_back(id); } @@ -5878,6 +5906,7 @@ impl SplitContract { None, // release_condition_hash 0, // early_bird_window_ledgers 0, // early_bird_fee_bps + 0, // creator_fee_bps ); ids.push_back(id); } @@ -5973,6 +6002,7 @@ impl SplitContract { None, // release_condition_hash 0, // early_bird_window_ledgers 0, // early_bird_fee_bps + 0, // creator_fee_bps ); if months > 1 { @@ -10449,6 +10479,7 @@ impl SplitContract { None, // release_condition_hash 0, // early_bird_window_ledgers 0, // early_bird_fee_bps + 0, // creator_fee_bps ); env.storage() .persistent() @@ -11542,6 +11573,7 @@ impl SplitContract { None, // release_condition_hash old_invoice.early_bird_window_ledgers, old_invoice.early_bird_fee_bps, + old_invoice.creator_fee_bps, ); // Copy payments from shards to new invoice (issue #177). @@ -11916,6 +11948,7 @@ impl SplitContract { None, // release_condition_hash 0, // early_bird_window_ledgers 0, // early_bird_fee_bps + 0, // creator_fee_bps ) } @@ -14182,8 +14215,6 @@ impl SplitContract { events::contract_thawed(&env, &admin); } -use soroban_sdk::{contract, contractimpl, symbol_short, token, Address, Env, Symbol, Vec}; -use types::{Invoice, InvoiceStatus, Payment, TransferKind, TransferRecord}; /// Get the upgrade checkpoint hash if frozen. pub fn get_upgrade_checkpoint(env: Env) -> Option> { env.storage().instance().get(&upgrade_checkpoint_key()) @@ -14256,6 +14287,23 @@ fn load_invoice(env: &Env, id: u64) -> Invoice { .persistent() .get(&delayed_payout_key(invoice_id, &recipient)) .expect("no delayed payout found"); + assert!( + env.ledger().sequence() >= delayed_payout.claimable_at_ledger, + "payout not yet claimable" + ); + let invoice = load_invoice(&env, invoice_id); + let token = invoice.tokens.get(0).expect("invoice has no tokens"); + let token_client = token::Client::new(&env, &token); + token_client.transfer( + &env.current_contract_address(), + &recipient, + &delayed_payout.amount, + ); + env.storage() + .persistent() + .remove(&delayed_payout_key(invoice_id, &recipient)); + events::delayed_payout_claimed(&env, invoice_id, &recipient, delayed_payout.amount); + } fn remove_invoice(env: &Env, id: u64) { env.storage().persistent().remove(&invoice_key(id)); @@ -14379,23 +14427,11 @@ fn update_leaderboard(env: &Env, invoice_id: u64, payer: &Address, amount: i128) save_top_contributors(env, invoice_id, &leaders); } +} + // --------------------------------------------------------------------------- // Contract // --------------------------------------------------------------------------- - assert!( - env.ledger().sequence() >= delayed_payout.claimable_at_ledger, - "payout not yet claimable" - ); - - let invoice = load_invoice(&env, invoice_id); - let token = invoice.tokens.get(0).expect("invoice has no tokens"); - let token_client = token::Client::new(&env, &token); - token_client.transfer( - &env.current_contract_address(), - &recipient, - &delayed_payout.amount, - ); - #[contractimpl] impl SplitContract { /// Create a new invoice. @@ -14601,7 +14637,8 @@ impl SplitContract { deadline, parent_invoice_id, late_penalty_bps, - ) + 0, // creator_fee_bps + ); // Refund every contributor. All transfers happen before state is mutated // so the operation is effectively atomic: if any transfer panics the // entire transaction is rolled back (Soroban's standard behaviour). @@ -14698,6 +14735,109 @@ impl SplitContract { env.storage().instance().get(&admin_set_key()) } + // ----------------------------------------------------------------------- + // Issue #560: Creator Migration + // ----------------------------------------------------------------------- + + /// Nominate a new creator for an invoice. Only the current creator can nominate. + /// + /// # Arguments + /// * `caller` - must be the current creator of the invoice + /// * `invoice_id` - target invoice + /// * `successor` - address of the new creator + pub fn nominate_new_creator(env: Env, caller: Address, invoice_id: u64, successor: Address) { + caller.require_auth(); + let mut invoice = load_invoice(&env, invoice_id); + assert!( + invoice.status != InvoiceStatus::Deleted, + "InvoiceDeleted" + ); + assert!(invoice.creator == caller, "OnlyCreator"); + env.storage() + .persistent() + .set(&pending_creator_key(invoice_id), &successor); + events::creator_nominated(&env, invoice_id, &successor); + } + + /// Accept the creator role for a nominated invoice. + /// + /// # Arguments + /// * `successor` - must be the nominated successor + /// * `invoice_id` - target invoice + pub fn accept_creator_role(env: Env, successor: Address, invoice_id: u64) { + successor.require_auth(); + let pending: Address = env + .storage() + .persistent() + .get(&pending_creator_key(invoice_id)) + .expect("no pending creator nomination"); + assert!(pending == successor, "NotNominated"); + let mut invoice = load_invoice(&env, invoice_id); + assert!( + invoice.status != InvoiceStatus::Deleted, + "InvoiceDeleted" + ); + invoice.creator = successor.clone(); + save_invoice(&env, invoice_id, &invoice); + env.storage() + .persistent() + .remove(&pending_creator_key(invoice_id)); + events::creator_migrated(&env, invoice_id, &successor); + } + + // ----------------------------------------------------------------------- + // Issue #562: Soft-Delete with Tombstone + // ----------------------------------------------------------------------- + + /// Soft-delete an invoice, writing a tombstone record for audit trail. + /// Only the creator can delete a Pending invoice with no unclaimed funds. + /// + /// # Arguments + /// * `caller` - must be the current creator of the invoice + /// * `invoice_id` - target invoice + pub fn delete_invoice(env: Env, caller: Address, invoice_id: u64) { + caller.require_auth(); + let invoice = load_invoice(&env, invoice_id); + assert!( + invoice.status != InvoiceStatus::Deleted, + "InvoiceDeleted" + ); + assert!(invoice.creator == caller, "OnlyCreator"); + assert!(invoice.status == InvoiceStatus::Pending, "InvalidStatus"); + assert!(invoice.funded == 0, "FundsUnclaimed"); + let tombstone = Tombstone { + invoice_id, + deleted_at_ledger: env.ledger().sequence(), + deleted_by: caller, + }; + env.storage() + .persistent() + .set(&tombstone_key(invoice_id), &tombstone); + let mut updated = invoice.clone(); + updated.status = InvoiceStatus::Deleted; + save_invoice(&env, invoice_id, &updated); + events::invoice_state_changed(&env, invoice_id, Some(&InvoiceStatus::Pending), &InvoiceStatus::Deleted, &caller); + } + + /// Retrieve the tombstone record for a soft-deleted invoice. + /// + /// # Arguments + /// * `invoice_id` - target invoice + /// + /// # Returns + /// The Tombstone record for the deleted invoice + pub fn get_tombstone(env: Env, invoice_id: u64) -> Tombstone { + let invoice = load_invoice(&env, invoice_id); + assert!( + invoice.status == InvoiceStatus::Deleted, + "NotDeleted" + ); + env.storage() + .persistent() + .get(&tombstone_key(invoice_id)) + .expect("tombstone not found") + } + /// Propose a new admin action under the multi-sig scheme. /// /// The caller must be one of the registered signers. The action is stored @@ -14710,6 +14850,28 @@ impl SplitContract { /// * `amount` - amount to pay in stroops pub fn pay(env: Env, payer: Address, invoice_id: u64, amount: i128) { payer.require_auth(); + let mut invoice = load_invoice(&env, invoice_id); + assert!( + invoice.status == InvoiceStatus::Pending, + "invoice is not pending" + ); + assert!(!invoice.disputed, "invoice is disputed"); + let funding_token = funding_token_for(invoice.clone()); + let token_client = token::Client::new(&env, &funding_token); + token_client.transfer(&payer, &env.current_contract_address(), &amount); + invoice.funded += amount; + invoice + .payments + .push_back(Payment { + payer: payer.clone(), + amount, + ledger: env.ledger().sequence(), + timestamp: env.ledger().timestamp(), + }); + save_invoice(&env, invoice_id, &invoice); + events::payment_received(&env, invoice_id, &payer, amount); + } + /// Returns the 32-byte action hash that identifies this proposal. pub fn propose_admin_action( env: Env, @@ -15152,6 +15314,7 @@ impl SplitContract { None, // release_condition_hash 0, // early_bird_window_ledgers 0, // early_bird_fee_bps + 0, // creator_fee_bps ); events::invoice_from_template(&env, invoice_id, &creator, template_id); diff --git a/contracts/split/src/storage_keys.rs b/contracts/split/src/storage_keys.rs index 9200569..cbd6e41 100644 --- a/contracts/split/src/storage_keys.rs +++ b/contracts/split/src/storage_keys.rs @@ -379,3 +379,33 @@ pub fn failed_payouts_key(invoice_id: u64) -> (Symbol, u64) { (symbol_short!("fail_pay"), invoice_id) } +// --------------------------------------------------------------------------- +// Issue #559: Creator Revenue Share +// --------------------------------------------------------------------------- + +/// Creator fee in basis points stored at invoice creation — persistent storage. +/// Key: (Symbol, u64) → u32 +pub fn creator_fee_bps_key(invoice_id: u64) -> (Symbol, u64) { + (symbol_short!("cr_fee_bps"), invoice_id) +} + +// --------------------------------------------------------------------------- +// Issue #560: Creator Migration +// --------------------------------------------------------------------------- + +/// Pending successor creator address — persistent storage. +/// Key: (Symbol, u64) → Address +pub fn pending_creator_key(invoice_id: u64) -> (Symbol, u64) { + (symbol_short!("pend_cr"), invoice_id) +} + +// --------------------------------------------------------------------------- +// Issue #562: Soft-Delete with Tombstone +// --------------------------------------------------------------------------- + +/// Tombstone record for soft-deleted invoices — persistent storage. +/// Key: (Symbol, u64) → Tombstone +pub fn tombstone_key(invoice_id: u64) -> (Symbol, u64) { + (symbol_short!("tombstone"), invoice_id) +} + diff --git a/contracts/split/src/test.rs b/contracts/split/src/test.rs index 4ba3e5d..5394fd9 100644 --- a/contracts/split/src/test.rs +++ b/contracts/split/src/test.rs @@ -193,6 +193,7 @@ fn default_options2(_env: &Env) -> InvoiceOptions2 { overfunding_policy: types::OverfundingPolicy::Cap, early_bird_window_ledgers: 0, early_bird_fee_bps: 0, + creator_fee_bps: 0, } } @@ -264,6 +265,7 @@ fn invoice_options( overfunding_policy: types::OverfundingPolicy::Cap, early_bird_window_ledgers: 0, early_bird_fee_bps: 0, + creator_fee_bps: 0, }, } } @@ -12685,4 +12687,461 @@ fn test_create_invoice_past_deadline_ledger_panics() { amounts.push_back(100_i128); c.create_invoice(&creator, &recipients, &amounts, &token_id, &(500_u32)); +} + +// --------------------------------------------------------------------------- +// Issue #559: Creator Revenue Share +// --------------------------------------------------------------------------- + +#[test] +fn test_creator_fee_deducted_on_release() { + 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, &10_000); + env.ledger().set_sequence(1_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 mut opts2 = default_options2(&env); + opts2.creator_fee_bps = 500; // 5% creator fee + let id = c.create_invoice_ext( + &creator, + &recipients, + &amounts, + &token_id, + &9_999, + &default_options(&env), + &opts2, + ); + + c.pay(&payer, &id, &1_000_i128, &0_u64, &false, &false, &None); + + // Release the invoice — creator fee should be deducted before recipient payout + c.release(&creator, &id); + + // Check that creator_fee_paid event was emitted + let events = env.events().all(); + let has_creator_fee_event = events.iter().any(|e| { + let topics = e.1; + topics.len() >= 3 + && topics.get(1).and_then(|v| { + let r: Result = v.try_into_val(&env); + r.ok() + }) == Some(Symbol::new(&env, "creator_fee")) + }); + assert!(has_creator_fee_event, "creator_fee_paid event should be emitted"); + + // Recipient should receive 950 (1000 - 5% fee), creator should receive 50 + let recipient_balance = tk.balance(&recipient); + assert_eq!(recipient_balance, 950_i128); + let creator_balance = tk.balance(&creator); + assert_eq!(creator_balance, 50_i128); +} + +#[test] +#[should_panic(expected = "FeeSumExceedsCap")] +fn test_creator_fee_exceeds_cap_panics() { + 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_sequence(1_000); + + let mut recipients = Vec::new(&env); + recipients.push_back(recipient.clone()); + let mut amounts = Vec::new(&env); + amounts.push_back(100_i128); + + let mut opts2 = default_options2(&env); + opts2.creator_fee_bps = 10_001; // exceeds 10000 cap + let _id = c.create_invoice_ext( + &creator, + &recipients, + &amounts, + &token_id, + &9_999, + &default_options(&env), + &opts2, + ); +} + +// --------------------------------------------------------------------------- +// Issue #560: Creator Migration +// --------------------------------------------------------------------------- + +#[test] +fn test_nominate_new_creator_emits_event() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + + let creator = Address::generate(&env); + let recipient = Address::generate(&env); + let successor = Address::generate(&env); + + env.ledger().set_sequence(1_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, + &default_options(&env), + ); + + c.nominate_new_creator(&creator, &id, &successor); + + // Check creator_nominated event was emitted + let events = env.events().all(); + let has_nom_event = events.iter().any(|e| { + let topics = e.1; + topics.len() >= 3 + && topics.get(1).and_then(|v| { + let r: Result = v.try_into_val(&env); + r.ok() + }) == Some(Symbol::new(&env, "creator_nom")) + }); + assert!(has_nom_event, "creator_nominated event should be emitted"); +} + +#[test] +fn test_accept_creator_role_migrates_creator() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + + let creator = Address::generate(&env); + let recipient = Address::generate(&env); + let successor = Address::generate(&env); + + env.ledger().set_sequence(1_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, + &default_options(&env), + ); + + c.nominate_new_creator(&creator, &id, &successor); + c.accept_creator_role(&successor, &id); + + // Check creator_migrated event was emitted + let events = env.events().all(); + let has_mig_event = events.iter().any(|e| { + let topics = e.1; + topics.len() >= 3 + && topics.get(1).and_then(|v| { + let r: Result = v.try_into_val(&env); + r.ok() + }) == Some(Symbol::new(&env, "creator_mig")) + }); + assert!(has_mig_event, "creator_migrated event should be emitted"); + + // Invoice should now have the successor as creator + let invoice = c.get_invoice(&id); + assert_eq!(invoice.creator, successor); +} + +#[test] +#[should_panic(expected = "InvoiceDeleted")] +fn test_nominate_new_creator_on_deleted_invoice_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 successor = Address::generate(&env); + + env.ledger().set_sequence(1_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, + &default_options(&env), + ); + + c.delete_invoice(&creator, &id); + c.nominate_new_creator(&creator, &id, &successor); +} + +// --------------------------------------------------------------------------- +// Issue #561: Payout Ordering +// --------------------------------------------------------------------------- + +#[test] +fn test_payout_ordering_canonical_sort() { + 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 recipient1 = Address::generate(&env); + let recipient2 = Address::generate(&env); + let payer = Address::generate(&env); + + StellarAssetClient::new(&env, &token_id).mint(&payer, &20_000); + env.ledger().set_sequence(1_000); + + // Create invoice with two recipients in non-canonical order + let mut recipients = Vec::new(&env); + recipients.push_back(recipient1.clone()); + recipients.push_back(recipient2.clone()); + let mut amounts = Vec::new(&env); + amounts.push_back(1_000_i128); + amounts.push_back(1_000_i128); + + let id = c.create_invoice( + &creator, + &recipients, + &amounts, + &token_id, + &9_999, + &default_options(&env), + ); + + c.pay(&payer, &id, &2_000_i128, &0_u64, &false, &false, &None); + c.release(&creator, &id); + + // Check payout_initiated events are emitted with sorted indices + let events = env.events().all(); + let payout_events: Vec<_> = events + .iter() + .filter(|e| { + let topics = e.1; + topics.len() >= 3 + && topics.get(1).and_then(|v| { + let r: Result = v.try_into_val(&env); + r.ok() + }) == Some(Symbol::new(&env, "payout_init")) + }) + .collect(); + + assert_eq!(payout_events.len(), 2, "Two payout_initiated events expected"); + + // Both recipients should receive their full share + assert_eq!(tk.balance(&recipient1), 1_000_i128); + assert_eq!(tk.balance(&recipient2), 1_000_i128); +} + +// --------------------------------------------------------------------------- +// Issue #562: Soft-Delete with Tombstone +// --------------------------------------------------------------------------- + +#[test] +fn test_soft_delete_writes_tombstone() { + 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_sequence(1_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, + &default_options(&env), + ); + + c.delete_invoice(&creator, &id); + + // Invoice should be in Deleted status + let invoice = c.get_invoice(&id); + assert_eq!(invoice.status, InvoiceStatus::Deleted); + + // Tombstone should be retrievable + let tombstone = c.get_tombstone(&id); + assert_eq!(tombstone.invoice_id, id); + assert_eq!(tombstone.deleted_at_ledger, 1_000); + assert_eq!(tombstone.deleted_by, creator); +} + +#[test] +#[should_panic(expected = "InvoiceDeleted")] +fn test_pay_on_deleted_invoice_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 payer = Address::generate(&env); + + StellarAssetClient::new(&env, &token_id).mint(&payer, &10_000); + env.ledger().set_sequence(1_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, + &default_options(&env), + ); + + c.delete_invoice(&creator, &id); + c.pay(&payer, &id, &1_000_i128, &0_u64, &false, &false, &None); +} + +#[test] +#[should_panic(expected = "InvoiceDeleted")] +fn test_release_on_deleted_invoice_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 payer = Address::generate(&env); + + StellarAssetClient::new(&env, &token_id).mint(&payer, &10_000); + env.ledger().set_sequence(1_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, + &default_options(&env), + ); + + c.delete_invoice(&creator, &id); + c.release(&creator, &id); +} + +#[test] +#[should_panic(expected = "FundsUnclaimed")] +fn test_delete_invoice_with_unclaimed_funds_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 payer = Address::generate(&env); + + StellarAssetClient::new(&env, &token_id).mint(&payer, &10_000); + env.ledger().set_sequence(1_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, + &default_options(&env), + ); + + c.pay(&payer, &id, &1_000_i128, &0_u64, &false, &false, &None); + // Invoice has funded > 0 but not yet released, so funds are unclaimed + c.delete_invoice(&creator, &id); +} + +#[test] +#[should_panic(expected = "NotDeleted")] +fn test_get_tombstone_on_non_deleted_invoice_panics() { + 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_sequence(1_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, + &default_options(&env), + ); + + c.get_tombstone(&id); +} + +#[test] +#[should_panic(expected = "InvoiceDeleted")] +fn test_cancel_invoice_on_deleted_invoice_panics() { + 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_sequence(1_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, + &default_options(&env), + ); + + c.delete_invoice(&creator, &id); + c.cancel_invoice(&creator, &id, &9_999_u64); } \ No newline at end of file diff --git a/contracts/split/src/types.rs b/contracts/split/src/types.rs index 1009442..f27164c 100644 --- a/contracts/split/src/types.rs +++ b/contracts/split/src/types.rs @@ -140,9 +140,15 @@ pub enum InvoiceStatus { Pending, Released, Refunded, + Expired, + Cancelled, + Disputed, + PartiallyReleased, /// Alias for Released used as the parent-finalisation gate (#522). /// An invoice is considered Finalised once it has been Released. Finalised, + /// Soft-deleted invoice — tombstone record preserved for audit trail. + Deleted, } // --------------------------------------------------------------------------- @@ -150,9 +156,14 @@ pub enum InvoiceStatus { // --------------------------------------------------------------------------- /// A single payment made toward an invoice. - Expired, - Cancelled, - Disputed, +#[contracttype] +#[derive(Clone, Debug)] +pub struct Payment { + pub payer: Address, + pub amount: i128, + pub tip: i128, + pub attestation_hash: Option>, + pub donate_on_failure: bool, } /// Issue #449: Multi-phase invoice state machine. @@ -305,7 +316,6 @@ pub struct InvoiceTemplate { pub status: InvoiceStatus, /// All payments made toward this invoice. pub payments: Vec, -} /// Optional whitelist of addresses allowed to pay this invoice. /// When None, any address may pay. pub allowed_payers: Option>, @@ -497,6 +507,9 @@ pub struct InvoiceOptions2 { /// within `early_bird_window_ledgers` of invoice creation. Must be ≤ the /// standard platform fee in effect at creation time. pub early_bird_fee_bps: u32, + /// Issue #559: creator-declared fee in basis points (0–10 000). + /// Deducted from gross collected funds before recipient payouts. + pub creator_fee_bps: u32, } /// Legacy invoice layout used by stored invoices created before the `version` @@ -862,6 +875,9 @@ pub struct Invoice { /// Issue #489: total platform-fee discount accrued from early-bird /// contributions so far; deducted from the platform fee at release. pub early_bird_fee_credit: i128, + /// Issue #559: creator-declared fee in basis points (0–10 000). + /// Deducted from gross collected funds before recipient payouts. + pub creator_fee_bps: u32, } impl Invoice { @@ -970,6 +986,7 @@ impl Invoice { early_bird_window_ledgers: self.early_bird_window_ledgers, early_bird_fee_bps: self.early_bird_fee_bps, early_bird_fee_credit: self.early_bird_fee_credit, + creator_fee_bps: self.creator_fee_bps, }, ) } @@ -1074,6 +1091,7 @@ impl Invoice { early_bird_window_ledgers: ext2.early_bird_window_ledgers, early_bird_fee_bps: ext2.early_bird_fee_bps, early_bird_fee_credit: ext2.early_bird_fee_credit, + creator_fee_bps: ext2.creator_fee_bps, } } } @@ -1159,6 +1177,9 @@ impl Invoice { InvoiceStatus::Cancelled => 3, InvoiceStatus::Expired => 4, InvoiceStatus::Disputed => 5, + InvoiceStatus::PartiallyReleased => 6, + InvoiceStatus::Finalised => 7, + InvoiceStatus::Deleted => 8, }; bytes.push_back(status_byte); @@ -1195,6 +1216,9 @@ impl Invoice { 3 => InvoiceStatus::Cancelled, 4 => InvoiceStatus::Expired, 5 => InvoiceStatus::Disputed, + 6 => InvoiceStatus::PartiallyReleased, + 7 => InvoiceStatus::Finalised, + 8 => InvoiceStatus::Deleted, _ => InvoiceStatus::Pending, }; @@ -1326,6 +1350,7 @@ impl Invoice { early_bird_window_ledgers: 0, early_bird_fee_bps: 0, early_bird_fee_credit: 0, + creator_fee_bps: 0, } } } @@ -1349,6 +1374,15 @@ pub struct InvoiceExt3 { pub paid_recipients: Vec
, } +/// Issue #562: Tombstone record for soft-deleted invoices. +#[contracttype] +#[derive(Clone, Debug)] +pub struct Tombstone { + pub invoice_id: u64, + pub deleted_at_ledger: u32, + pub deleted_by: Address, +} + /// Issue #298: Result type returned by simulate_release(). #[contracttype] #[derive(Clone, Debug)]