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 ed11caf..5d068de 100644
--- a/contracts/split/src/error.rs
+++ b/contracts/split/src/error.rs
@@ -80,6 +80,14 @@ 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,
/// Issue #519: An invoice status transition is not permitted by the state machine.
InvalidStateTransition = 47,
/// Issue #518: A split ratio is invalid (e.g. >= denominator or sum mismatch).
diff --git a/contracts/split/src/events.rs b/contracts/split/src/events.rs
index 0cec7c0..12c48a7 100644
--- a/contracts/split/src/events.rs
+++ b/contracts/split/src/events.rs
@@ -1545,3 +1545,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 a3e5742..600660d 100644
--- a/contracts/split/src/lib.rs
+++ b/contracts/split/src/lib.rs
@@ -158,6 +158,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())
}
@@ -5069,6 +5078,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,
options.ratios.clone(),
options.ext.ratio_denominator,
);
@@ -5201,6 +5211,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,
options.ratios.clone(),
options.ext.ratio_denominator,
);
@@ -5274,6 +5285,7 @@ impl SplitContract {
release_condition_hash: Option>,
early_bird_window_ledgers: u32,
early_bird_fee_bps: u32,
+ creator_fee_bps: u32,
ratios: Vec,
ratio_denominator: u64,
) -> u64 {
@@ -5327,6 +5339,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(),
@@ -5772,6 +5798,7 @@ impl SplitContract {
early_bird_window_ledgers,
early_bird_fee_bps,
early_bird_fee_credit: 0,
+ creator_fee_bps,
ratio_denominator,
ratios,
};
@@ -5940,6 +5967,10 @@ impl SplitContract {
None, // recipient_max_payouts
false, // recipient_whitelist_enabled
None, // release_condition_hash
+ 0, // early_bird_window_ledgers
+ 0, // early_bird_fee_bps
+ 0, // creator_fee_bps
+ );
0, // early_bird_window_ledgers
0, // early_bird_fee_bps
Vec::new(&env), // ratios
@@ -6051,6 +6082,10 @@ impl SplitContract {
None, // recipient_max_payouts
false, // recipient_whitelist_enabled
None, // release_condition_hash
+ 0, // early_bird_window_ledgers
+ 0, // early_bird_fee_bps
+ 0, // creator_fee_bps
+ );
0, // early_bird_window_ledgers
0, // early_bird_fee_bps
Vec::new(&env), // ratios
@@ -6148,6 +6183,10 @@ impl SplitContract {
None, // recipient_max_payouts
false, // recipient_whitelist_enabled
None, // release_condition_hash
+ 0, // early_bird_window_ledgers
+ 0, // early_bird_fee_bps
+ 0, // creator_fee_bps
+ );
0, // early_bird_window_ledgers
0, // early_bird_fee_bps
Vec::new(&env), // ratios
@@ -10643,6 +10682,10 @@ impl SplitContract {
None, // recipient_max_payouts
false, // recipient_whitelist_enabled
None, // release_condition_hash
+ 0, // early_bird_window_ledgers
+ 0, // early_bird_fee_bps
+ 0, // creator_fee_bps
+ );
0, // early_bird_window_ledgers
0, // early_bird_fee_bps
Vec::new(env), // ratios
@@ -11740,6 +11783,7 @@ impl SplitContract {
None, // release_condition_hash
old_invoice.early_bird_window_ledgers,
old_invoice.early_bird_fee_bps,
+ old_invoice.creator_fee_bps,
old_invoice.ratios.clone(),
old_invoice.ratio_denominator,
);
@@ -12114,6 +12158,10 @@ impl SplitContract {
None, // recipient_max_payouts
false, // recipient_whitelist_enabled
None, // release_condition_hash
+ 0, // early_bird_window_ledgers
+ 0, // early_bird_fee_bps
+ 0, // creator_fee_bps
+ )
0, // early_bird_window_ledgers
0, // early_bird_fee_bps
Vec::new(&env),
@@ -14384,8 +14432,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())
@@ -14458,6 +14504,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));
@@ -14581,23 +14644,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.
@@ -14803,7 +14854,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).
@@ -14900,6 +14952,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
@@ -14912,6 +15067,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,
@@ -15354,6 +15531,7 @@ impl SplitContract {
None, // release_condition_hash
0, // early_bird_window_ledgers
0, // early_bird_fee_bps
+ 0, // creator_fee_bps
template.ratios.clone(),
10_000u64,
);
diff --git a/contracts/split/src/storage_keys.rs b/contracts/split/src/storage_keys.rs
index 7bf3736..5aa9943 100644
--- a/contracts/split/src/storage_keys.rs
+++ b/contracts/split/src/storage_keys.rs
@@ -383,3 +383,34 @@ mod tests {
}
}
}
+
+// ---------------------------------------------------------------------------
+// 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 8ec0e9e..878b9da 100644
--- a/contracts/split/src/test.rs
+++ b/contracts/split/src/test.rs
@@ -130,6 +130,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,
early_bird_fee_credit: 0,
ratio_denominator: 10_000,
}
@@ -203,6 +204,11 @@ fn invoice_options(
release_condition_hash: None,
recipient_whitelist_enabled: false,
escrow_hold_period: None,
+ overfunding_policy: types::OverfundingPolicy::Cap,
+ early_bird_window_ledgers: 0,
+ early_bird_fee_bps: 0,
+ creator_fee_bps: 0,
+ },
overfunding_policy: types::OverfundingPolicy::Cap,
early_bird_window_ledgers: 0,
early_bird_fee_bps: 0,
@@ -7573,3 +7579,460 @@ fn test_310_propose_overwrites_existing() {
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);
+}
diff --git a/contracts/split/src/types.rs b/contracts/split/src/types.rs
index 743b7a4..e73d85c 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,
/// 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,
@@ -867,6 +880,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,
/// Issue #518: denominator for high-precision ratio splits.
pub ratio_denominator: u64,
/// Issue #518: per-recipient split ratios evaluated at release time.
@@ -979,6 +995,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,
ratio_denominator: self.ratio_denominator,
ratios: self.ratios.clone(),
},
@@ -1085,6 +1102,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,
ratio_denominator: ext2.ratio_denominator,
ratios: ext2.ratios,
}
@@ -1172,6 +1190,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);
@@ -1208,6 +1229,9 @@ impl Invoice {
3 => InvoiceStatus::Cancelled,
4 => InvoiceStatus::Expired,
5 => InvoiceStatus::Disputed,
+ 6 => InvoiceStatus::PartiallyReleased,
+ 7 => InvoiceStatus::Finalised,
+ 8 => InvoiceStatus::Deleted,
_ => InvoiceStatus::Pending,
};
@@ -1339,6 +1363,7 @@ impl Invoice {
early_bird_window_ledgers: 0,
early_bird_fee_bps: 0,
early_bird_fee_credit: 0,
+ creator_fee_bps: 0,
ratio_denominator: 10_000,
ratios: Vec::new(env),
}
@@ -1364,6 +1389,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)]