diff --git a/contracts/split/src/confidential_tracking.rs b/contracts/split/src/confidential_tracking.rs new file mode 100644 index 0000000..f6a7e4b --- /dev/null +++ b/contracts/split/src/confidential_tracking.rs @@ -0,0 +1,718 @@ +//! Issue #921: Confidential payment tracking. +//! +//! Records payments against an invoice without ever putting the amount +//! on-chain, while still letting anyone check that each record is well formed +//! and letting the creator prove the invoice total — all without revealing +//! any individual amount. +//! +//! ## Amount masking +//! A payer submits a Pedersen commitment `C = v·G + r·H` over BLS12-381 G1, +//! where `v` is the amount and `r` a random blinding scalar. `G` and `H` are +//! the contract's nothing-up-my-sleeve generators pinned at `initialize` +//! (shared with `reveal_confidential_payment`). Without `r`, `C` reveals +//! nothing about `v`. Events carry only a digest of `C` — never an amount. +//! +//! ## Zero-knowledge verification +//! Each record carries a non-interactive proof of knowledge of the opening +//! `(v, r)` (Okamoto / Schnorr protocol, Fiat–Shamir transformed): +//! +//! * prover picks random `a, b`, sends `T = a·G + b·H` +//! * challenge `e = H(domain ‖ C ‖ T ‖ invoice_id ‖ payer)` mod r +//! * responses `s1 = a + e·v`, `s2 = b + e·r` +//! * verifier checks `s1·G + s2·H == T + e·C` +//! +//! The proof convinces the verifier the payer knows a valid opening while +//! revealing nothing about `v`. Binding the challenge to `invoice_id` and +//! `payer` prevents replaying a proof on another invoice or by another payer. +//! +//! Commitments are additively homomorphic, so the contract keeps a running +//! aggregate `ΣC = (Σv)·G + (Σr)·H`. The creator (who receives every payer's +//! encrypted amount) can prove the invoice total `Σv` by revealing only the +//! aggregate blinding `Σr` — individual amounts stay hidden. +//! +//! ## Encrypted storage +//! The payer also submits the amount encrypted to the creator's key +//! (`encrypted_amount`). The contract treats it as opaque ciphertext, bounds +//! its size and stores its SHA-256 digest so later tampering is detectable. +//! +//! ## Audit trail +//! Every state change appends an entry to a per-invoice hash chain: +//! `entry_hash = sha256(prev_hash ‖ seq ‖ action ‖ actor ‖ subject ‖ timestamp)`. +//! `verify_confidential_audit_chain` recomputes the chain end to end. +//! +//! ## Storage +//! * `(ctrk_rec, invoice_id, payer)` → [`ConfidentialRecord`] (persistent) +//! * `(ctrk_pyr, invoice_id)` → `Vec
` payers with a record (persistent) +//! * `(ctrk_agg, invoice_id)` → `BytesN<96>` aggregate commitment (persistent) +//! * `(ctrk_tot, invoice_id)` → `i128` last verified total (persistent) +//! * `(ctrk_aud, invoice_id, seq)` → [`ConfidentialAuditEntry`] (persistent) +//! * `(ctrk_ahd, invoice_id)` → `(u32, BytesN<32>)` chain length and head hash (persistent) + +use soroban_sdk::crypto::bls12_381::{Fr, G1Affine}; +use soroban_sdk::xdr::ToXdr; +use soroban_sdk::{ + contractimpl, contracttype, symbol_short, Address, Bytes, BytesN, Env, Symbol, Vec, U256, +}; + +use crate::events; +use crate::types::InvoiceStatus; +use crate::{ + load_invoice, pedersen_g_key, pedersen_h_key, SplitContract, SplitContractArgs, + SplitContractClient, +}; + +/// Maximum distinct payers tracked per invoice. +pub const MAX_CONFIDENTIAL_PAYERS: u32 = 100; + +/// Maximum audit entries per invoice (bounds replacement churn). +pub const MAX_CONFIDENTIAL_AUDIT_ENTRIES: u32 = 500; + +/// Maximum ciphertext size for `encrypted_amount`, in bytes. +pub const MAX_ENCRYPTED_AMOUNT_LEN: u32 = 512; + +/// Maximum entries returned by one `get_confidential_audit_trail` call. +pub const MAX_AUDIT_PAGE: u32 = 50; + +/// Fiat–Shamir domain separator for the proof-of-opening challenge. +const POK_DOMAIN: &[u8] = b"StellarSplit-ConfTrack-PoK-v1"; + +/// Non-interactive proof of knowledge of a commitment opening `(v, r)`. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ConfidentialProof { + /// `T = a·G + b·H` for random `a, b`. + pub nonce_commitment: BytesN<96>, + /// `s1 = a + e·v` (big-endian scalar). + pub response_value: BytesN<32>, + /// `s2 = b + e·r` (big-endian scalar). + pub response_blinding: BytesN<32>, +} + +/// A tracked confidential payment. Contains no plaintext amount. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ConfidentialRecord { + pub invoice_id: u64, + pub payer: Address, + /// Pedersen commitment `C = v·G + r·H` (uncompressed G1 point). + pub commitment: BytesN<96>, + pub proof: ConfidentialProof, + /// Amount encrypted to the invoice creator; opaque to the contract. + pub encrypted_amount: Bytes, + /// SHA-256 of `encrypted_amount`, for integrity checks. + pub ciphertext_hash: BytesN<32>, + pub recorded_at: u64, + /// Number of times this payer's record has been written (1 = original). + pub revision: u32, +} + +/// Kind of state change captured by an audit entry. +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum ConfidentialAuditAction { + Recorded = 0, + Replaced = 1, + TotalVerified = 2, +} + +/// One link of the per-invoice audit hash chain. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ConfidentialAuditEntry { + pub seq: u32, + pub action: ConfidentialAuditAction, + pub actor: Address, + /// Digest of the commitment (record actions) or aggregate (verification). + pub subject: BytesN<32>, + pub timestamp: u64, + pub prev_hash: BytesN<32>, + pub entry_hash: BytesN<32>, +} + +/// Amount-free overview of an invoice's confidential payments. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ConfidentialSummary { + pub invoice_id: u64, + pub payment_count: u32, + pub aggregate_commitment: Option>, + /// Total proven by the creator against the current aggregate, if any. + pub verified_total: Option, + pub audit_length: u32, + pub audit_head: BytesN<32>, +} + +fn record_key(invoice_id: u64, payer: &Address) -> (Symbol, u64, Address) { + (symbol_short!("ctrk_rec"), invoice_id, payer.clone()) +} + +fn payers_key(invoice_id: u64) -> (Symbol, u64) { + (symbol_short!("ctrk_pyr"), invoice_id) +} + +fn aggregate_key(invoice_id: u64) -> (Symbol, u64) { + (symbol_short!("ctrk_agg"), invoice_id) +} + +fn verified_total_key(invoice_id: u64) -> (Symbol, u64) { + (symbol_short!("ctrk_tot"), invoice_id) +} + +fn audit_entry_key(invoice_id: u64, seq: u32) -> (Symbol, u64, u32) { + (symbol_short!("ctrk_aud"), invoice_id, seq) +} + +fn audit_head_key(invoice_id: u64) -> (Symbol, u64) { + (symbol_short!("ctrk_ahd"), invoice_id) +} + +fn generators(env: &Env) -> (G1Affine, G1Affine) { + let g: G1Affine = env + .storage() + .instance() + .get(&pedersen_g_key()) + .expect("not initialized"); + let h: G1Affine = env + .storage() + .instance() + .get(&pedersen_h_key()) + .expect("not initialized"); + (g, h) +} + +/// Parse and validate a G1 point supplied by a caller. +fn parse_point(env: &Env, bytes: &BytesN<96>) -> G1Affine { + let point = G1Affine::from_bytes(bytes.clone()); + assert!( + env.crypto().bls12_381().g1_is_in_subgroup(&point), + "invalid commitment point" + ); + point +} + +fn digest_point(env: &Env, point: &BytesN<96>) -> BytesN<32> { + let bytes: Bytes = point.clone().into(); + env.crypto().sha256(&bytes).into() +} + +/// Fiat–Shamir challenge bound to the commitment, nonce, invoice and payer. +fn challenge( + env: &Env, + commitment: &BytesN<96>, + nonce_commitment: &BytesN<96>, + invoice_id: u64, + payer: &Address, +) -> Fr { + let mut transcript = Bytes::from_slice(env, POK_DOMAIN); + transcript.append(&commitment.clone().into()); + transcript.append(&nonce_commitment.clone().into()); + transcript.extend_from_array(&invoice_id.to_be_bytes()); + transcript.append(&payer.clone().to_xdr(env)); + let hash: BytesN<32> = env.crypto().sha256(&transcript).into(); + Fr::from_bytes(hash) +} + +/// Verify a proof of knowledge of the opening of `commitment`: +/// `s1·G + s2·H == T + e·C`. +fn verify_opening_proof( + env: &Env, + commitment: &BytesN<96>, + proof: &ConfidentialProof, + invoice_id: u64, + payer: &Address, +) -> bool { + let (g, h) = generators(env); + let c = parse_point(env, commitment); + let t = parse_point(env, &proof.nonce_commitment); + let e = challenge(env, commitment, &proof.nonce_commitment, invoice_id, payer); + let s1 = Fr::from_bytes(proof.response_value.clone()); + let s2 = Fr::from_bytes(proof.response_blinding.clone()); + + let lhs = (g * s1) + (h * s2); + let rhs = t + (c * e); + lhs.to_bytes() == rhs.to_bytes() +} + +/// `v·G + r·H` for a non-negative amount `v`. +fn commit(env: &Env, value: i128, blinding: &BytesN<32>) -> G1Affine { + assert!(value >= 0, "amount must not be negative"); + let (g, h) = generators(env); + let v = Fr::from_u256(U256::from_u128(env, value as u128)); + let r = Fr::from_bytes(blinding.clone()); + (g * v) + (h * r) +} + +fn load_payers(env: &Env, invoice_id: u64) -> Vec
{ + env.storage() + .persistent() + .get(&payers_key(invoice_id)) + .unwrap_or(Vec::new(env)) +} + +fn load_audit_head(env: &Env, invoice_id: u64) -> (u32, BytesN<32>) { + env.storage() + .persistent() + .get(&audit_head_key(invoice_id)) + .unwrap_or((0u32, BytesN::from_array(env, &[0u8; 32]))) +} + +fn audit_entry_hash( + env: &Env, + prev_hash: &BytesN<32>, + seq: u32, + action: ConfidentialAuditAction, + actor: &Address, + subject: &BytesN<32>, + timestamp: u64, +) -> BytesN<32> { + let mut preimage: Bytes = prev_hash.clone().into(); + preimage.extend_from_array(&seq.to_be_bytes()); + preimage.extend_from_array(&(action as u32).to_be_bytes()); + preimage.append(&actor.clone().to_xdr(env)); + preimage.append(&subject.clone().into()); + preimage.extend_from_array(×tamp.to_be_bytes()); + env.crypto().sha256(&preimage).into() +} + +fn append_audit( + env: &Env, + invoice_id: u64, + action: ConfidentialAuditAction, + actor: &Address, + subject: BytesN<32>, +) { + let (len, prev_hash) = load_audit_head(env, invoice_id); + assert!( + len < MAX_CONFIDENTIAL_AUDIT_ENTRIES, + "confidential audit trail is full" + ); + let timestamp = env.ledger().timestamp(); + let entry_hash = audit_entry_hash(env, &prev_hash, len, action, actor, &subject, timestamp); + let entry = ConfidentialAuditEntry { + seq: len, + action, + actor: actor.clone(), + subject, + timestamp, + prev_hash, + entry_hash: entry_hash.clone(), + }; + env.storage() + .persistent() + .set(&audit_entry_key(invoice_id, len), &entry); + env.storage() + .persistent() + .set(&audit_head_key(invoice_id), &(len + 1, entry_hash)); +} + +#[contractimpl] +impl SplitContract { + /// Issue #921: Record a confidential payment for `invoice_id`. + /// + /// Stores only the commitment, its proof and the encrypted amount — the + /// plaintext amount never touches the chain. A payer that already has a + /// record replaces it (e.g. to correct an amount); the aggregate is + /// updated homomorphically and any previously verified total is cleared. + /// + /// No funds move: this tracks payments settled through other channels + /// (or later via `reveal_confidential_payment`). + /// + /// # Panics + /// * `"invalid commitment point"` — commitment or nonce not a valid G1 point. + /// * `"invalid confidential payment proof"` — proof does not verify. + /// * `"encrypted amount ..."` — empty or oversized ciphertext. + /// * invoice not pending, past deadline, or payer not allowed. + pub fn record_confidential_payment( + env: Env, + payer: Address, + invoice_id: u64, + commitment: BytesN<96>, + proof: ConfidentialProof, + encrypted_amount: Bytes, + ) -> ConfidentialRecord { + crate::require_not_paused(&env); + payer.require_auth(); + + let invoice = load_invoice(&env, invoice_id); + assert!( + invoice.status == InvoiceStatus::Pending, + "invoice is not pending" + ); + assert!( + env.ledger().timestamp() <= invoice.deadline, + "invoice deadline has passed" + ); + if let Some(ref whitelist) = invoice.allowed_payers { + assert!(whitelist.contains(&payer), "payer not allowed"); + } + + assert!(!encrypted_amount.is_empty(), "encrypted amount must not be empty"); + assert!( + encrypted_amount.len() <= MAX_ENCRYPTED_AMOUNT_LEN, + "encrypted amount too large" + ); + assert!( + verify_opening_proof(&env, &commitment, &proof, invoice_id, &payer), + "invalid confidential payment proof" + ); + + let key = record_key(invoice_id, &payer); + let previous: Option = env.storage().persistent().get(&key); + + // Update the homomorphic aggregate: ΣC' = ΣC − C_old + C_new. + let new_point = parse_point(&env, &commitment); + let aggregate: Option> = env.storage().persistent().get(&aggregate_key(invoice_id)); + let mut next_aggregate = match aggregate { + Some(bytes) => G1Affine::from_bytes(bytes) + new_point, + None => new_point, + }; + if let Some(ref prev) = previous { + next_aggregate = next_aggregate + (-G1Affine::from_bytes(prev.commitment.clone())); + } else { + let mut payers = load_payers(&env, invoice_id); + assert!( + payers.len() < MAX_CONFIDENTIAL_PAYERS, + "too many confidential payers" + ); + payers.push_back(payer.clone()); + env.storage().persistent().set(&payers_key(invoice_id), &payers); + } + env.storage() + .persistent() + .set(&aggregate_key(invoice_id), &next_aggregate.to_bytes()); + // The old total no longer matches the aggregate. + env.storage() + .persistent() + .remove(&verified_total_key(invoice_id)); + + let record = ConfidentialRecord { + invoice_id, + payer: payer.clone(), + commitment: commitment.clone(), + proof, + ciphertext_hash: env.crypto().sha256(&encrypted_amount).into(), + encrypted_amount, + recorded_at: env.ledger().timestamp(), + revision: previous.as_ref().map(|p| p.revision + 1).unwrap_or(1), + }; + env.storage().persistent().set(&key, &record); + + let action = if previous.is_some() { + ConfidentialAuditAction::Replaced + } else { + ConfidentialAuditAction::Recorded + }; + let commitment_digest = digest_point(&env, &commitment); + append_audit(&env, invoice_id, action, &payer, commitment_digest.clone()); + events::confidential_payment_recorded( + &env, + invoice_id, + &payer, + &commitment_digest, + record.revision, + ); + record + } + + /// Issue #921: Creator proves the invoice's confidential total. + /// + /// Checks `total·G + aggregate_blinding·H == ΣC`. On success the total is + /// stored and an audit entry appended; individual amounts remain hidden. + /// Returns `false` (and changes nothing) when the opening does not match. + pub fn verify_confidential_total( + env: Env, + caller: Address, + invoice_id: u64, + total: i128, + aggregate_blinding: BytesN<32>, + ) -> bool { + crate::require_not_paused(&env); + caller.require_auth(); + let invoice = load_invoice(&env, invoice_id); + let is_creator = + invoice.creator == caller || invoice.co_creators.iter().any(|c| c == caller); + assert!(is_creator, "NotAuthorized: only creator or co-creator may verify"); + assert!(total > 0, "total must be positive"); + + let aggregate: BytesN<96> = env + .storage() + .persistent() + .get(&aggregate_key(invoice_id)) + .expect("no confidential payments recorded"); + if commit(&env, total, &aggregate_blinding).to_bytes() != aggregate { + return false; + } + + env.storage() + .persistent() + .set(&verified_total_key(invoice_id), &total); + append_audit( + &env, + invoice_id, + ConfidentialAuditAction::TotalVerified, + &caller, + digest_point(&env, &aggregate), + ); + events::confidential_total_verified(&env, invoice_id, &caller, total); + true + } + + /// Issue #921: Re-verify a stored record's proof of knowledge. Confirms + /// the payer knows a valid opening without revealing the amount. + pub fn verify_confidential_record(env: Env, invoice_id: u64, payer: Address) -> bool { + let record: ConfidentialRecord = env + .storage() + .persistent() + .get(&record_key(invoice_id, &payer)) + .expect("no confidential record"); + verify_opening_proof(&env, &record.commitment, &record.proof, invoice_id, &payer) + && env.crypto().sha256(&record.encrypted_amount).to_bytes() == record.ciphertext_hash + } + + /// Issue #921: Check an opening `(value, blinding)` shared off-chain (e.g. + /// with an auditor) against a payer's stored commitment. Read-only: the + /// opening is never written to storage or emitted. + pub fn verify_confidential_opening( + env: Env, + invoice_id: u64, + payer: Address, + value: i128, + blinding: BytesN<32>, + ) -> bool { + let record: ConfidentialRecord = env + .storage() + .persistent() + .get(&record_key(invoice_id, &payer)) + .expect("no confidential record"); + commit(&env, value, &blinding).to_bytes() == record.commitment + } + + /// Issue #921: A payer's confidential record, if any. + pub fn get_confidential_record( + env: Env, + invoice_id: u64, + payer: Address, + ) -> Option { + env.storage() + .persistent() + .get(&record_key(invoice_id, &payer)) + } + + /// Issue #921: Payers with a confidential record on the invoice. + pub fn get_confidential_payers(env: Env, invoice_id: u64) -> Vec
{ + load_payers(&env, invoice_id) + } + + /// Issue #921: Amount-free summary of an invoice's confidential payments. + pub fn get_confidential_summary(env: Env, invoice_id: u64) -> ConfidentialSummary { + let (audit_length, audit_head) = load_audit_head(&env, invoice_id); + ConfidentialSummary { + invoice_id, + payment_count: load_payers(&env, invoice_id).len(), + aggregate_commitment: env.storage().persistent().get(&aggregate_key(invoice_id)), + verified_total: env + .storage() + .persistent() + .get(&verified_total_key(invoice_id)), + audit_length, + audit_head, + } + } + + /// Issue #921: Page of audit entries starting at `start` (max `MAX_AUDIT_PAGE`). + pub fn get_confidential_audit_trail( + env: Env, + invoice_id: u64, + start: u32, + limit: u32, + ) -> Vec { + let (len, _) = load_audit_head(&env, invoice_id); + let end = start.saturating_add(limit.min(MAX_AUDIT_PAGE)).min(len); + let mut entries = Vec::new(&env); + for seq in start..end { + if let Some(entry) = env + .storage() + .persistent() + .get(&audit_entry_key(invoice_id, seq)) + { + entries.push_back(entry); + } + } + entries + } + + /// Issue #921: Recompute the audit hash chain and check it against the + /// stored head. `false` if any entry is missing, altered or out of order. + pub fn verify_confidential_audit_chain(env: Env, invoice_id: u64) -> bool { + let (len, head) = load_audit_head(&env, invoice_id); + let mut prev_hash = BytesN::from_array(&env, &[0u8; 32]); + for seq in 0..len { + let entry: ConfidentialAuditEntry = match env + .storage() + .persistent() + .get(&audit_entry_key(invoice_id, seq)) + { + Some(e) => e, + None => return false, + }; + if entry.seq != seq || entry.prev_hash != prev_hash { + return false; + } + let expected = audit_entry_hash( + &env, + &prev_hash, + seq, + entry.action, + &entry.actor, + &entry.subject, + entry.timestamp, + ); + if expected != entry.entry_hash { + return false; + } + prev_hash = expected; + } + prev_hash == head + } +} + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::testutils::Address as _; + + /// Register the contract and pin the same generators `initialize` derives. + fn setup_generators(env: &Env) -> Address { + let contract_id = env.register(SplitContract, ()); + env.as_contract(&contract_id, || { + let dst = Bytes::from_slice(env, b"StellarSplit-Pedersen-BLS12381G1-v1"); + let bls = env.crypto().bls12_381(); + let g = bls.hash_to_g1(&Bytes::from_slice(env, b"pedersen-generator-G"), &dst); + let h = bls.hash_to_g1(&Bytes::from_slice(env, b"pedersen-generator-H"), &dst); + env.storage().instance().set(&pedersen_g_key(), &g); + env.storage().instance().set(&pedersen_h_key(), &h); + }); + contract_id + } + + fn scalar(env: &Env, v: u128) -> Fr { + Fr::from_u256(U256::from_u128(env, v)) + } + + /// Build `(C, proof)` for amount `v` with blinding `r` and nonces `a, b`. + fn prove( + env: &Env, + invoice_id: u64, + payer: &Address, + v: u128, + r: u128, + a: u128, + b: u128, + ) -> (BytesN<96>, ConfidentialProof) { + let (g, h) = generators(env); + let c = (g.clone() * scalar(env, v)) + (h.clone() * scalar(env, r)); + let t = (g * scalar(env, a)) + (h * scalar(env, b)); + let e = challenge(env, &c.to_bytes(), &t.to_bytes(), invoice_id, payer); + let s1 = scalar(env, a) + e.clone() * scalar(env, v); + let s2 = scalar(env, b) + e * scalar(env, r); + ( + c.to_bytes(), + ConfidentialProof { + nonce_commitment: t.to_bytes(), + response_value: s1.to_bytes(), + response_blinding: s2.to_bytes(), + }, + ) + } + + #[test] + fn test_valid_opening_proof_verifies() { + let env = Env::default(); + let contract_id = setup_generators(&env); + let payer = Address::generate(&env); + env.as_contract(&contract_id, || { + let (c, proof) = prove(&env, 1, &payer, 500, 77, 11, 13); + assert!(verify_opening_proof(&env, &c, &proof, 1, &payer)); + }); + } + + #[test] + fn test_proof_bound_to_invoice_and_payer() { + let env = Env::default(); + let contract_id = setup_generators(&env); + let payer = Address::generate(&env); + let other = Address::generate(&env); + env.as_contract(&contract_id, || { + let (c, proof) = prove(&env, 1, &payer, 500, 77, 11, 13); + assert!(!verify_opening_proof(&env, &c, &proof, 2, &payer)); + assert!(!verify_opening_proof(&env, &c, &proof, 1, &other)); + }); + } + + #[test] + fn test_tampered_response_fails() { + let env = Env::default(); + let contract_id = setup_generators(&env); + let payer = Address::generate(&env); + env.as_contract(&contract_id, || { + let (c, mut proof) = prove(&env, 1, &payer, 500, 77, 11, 13); + proof.response_value = scalar(&env, 12345).to_bytes(); + assert!(!verify_opening_proof(&env, &c, &proof, 1, &payer)); + }); + } + + #[test] + fn test_commitments_aggregate_homomorphically() { + let env = Env::default(); + let contract_id = setup_generators(&env); + env.as_contract(&contract_id, || { + let r1 = scalar(&env, 5).to_bytes(); + let r2 = scalar(&env, 9).to_bytes(); + let sum = commit(&env, 300, &r1) + commit(&env, 200, &r2); + let expected = commit(&env, 500, &scalar(&env, 14).to_bytes()); + assert_eq!(sum.to_bytes(), expected.to_bytes()); + let wrong = commit(&env, 499, &scalar(&env, 14).to_bytes()); + assert_ne!(sum.to_bytes(), wrong.to_bytes()); + }); + } + + #[test] + fn test_audit_chain_append_and_verify() { + let env = Env::default(); + let contract_id = setup_generators(&env); + let client = SplitContractClient::new(&env, &contract_id); + let actor = Address::generate(&env); + env.as_contract(&contract_id, || { + let subject = BytesN::from_array(&env, &[1u8; 32]); + append_audit(&env, 7, ConfidentialAuditAction::Recorded, &actor, subject.clone()); + append_audit(&env, 7, ConfidentialAuditAction::Replaced, &actor, subject); + }); + assert!(client.verify_confidential_audit_chain(&7)); + let trail = client.get_confidential_audit_trail(&7, &0, &10); + assert_eq!(trail.len(), 2); + assert_eq!(trail.get(1).unwrap().prev_hash, trail.get(0).unwrap().entry_hash); + + // Tamper with the first entry: the chain must no longer verify. + env.as_contract(&contract_id, || { + let mut entry: ConfidentialAuditEntry = + env.storage().persistent().get(&audit_entry_key(7, 0)).unwrap(); + entry.timestamp += 1; + env.storage().persistent().set(&audit_entry_key(7, 0), &entry); + }); + assert!(!client.verify_confidential_audit_chain(&7)); + } + + #[test] + fn test_empty_summary() { + let env = Env::default(); + let contract_id = setup_generators(&env); + let client = SplitContractClient::new(&env, &contract_id); + let summary = client.get_confidential_summary(&3); + assert_eq!(summary.payment_count, 0); + assert_eq!(summary.aggregate_commitment, None); + assert_eq!(summary.verified_total, None); + assert_eq!(summary.audit_length, 0); + assert!(client.verify_confidential_audit_chain(&3)); + } +} diff --git a/contracts/split/src/creator_fee_tiers.rs b/contracts/split/src/creator_fee_tiers.rs new file mode 100644 index 0000000..71788ec --- /dev/null +++ b/contracts/split/src/creator_fee_tiers.rs @@ -0,0 +1,438 @@ +//! Issue #919: Volume-based fee tier system for creators. +//! +//! High-volume creators earn a lower fee rate. The admin publishes an ordered +//! tier table; each creator is placed in the highest tier whose `min_volume` +//! their lifetime volume meets. Lifetime volume is the per-creator total that +//! the contract already accumulates on every payment +//! (`creator_stats_volume_key`, issue #299), so no extra bookkeeping happens on +//! the payment hot path. +//! +//! ## Config rules +//! * 1 – `MAX_CREATOR_FEE_TIERS` tiers. +//! * `tier_id` equals the tier's position (0, 1, 2, ...). +//! * The first tier starts at `min_volume == 0`, so every creator has a tier. +//! * `min_volume` is strictly ascending. +//! * `fee_bps` never increases from one tier to the next and is at most +//! `MAX_CREATOR_FEE_BPS`. +//! +//! Each successful update bumps the config `version`, which lets tracked +//! tiers detect that they were computed against an older table. +//! +//! ## Lookup +//! Tier lookup is a binary search over the (sorted) table — O(log n) with a +//! single instance-storage read for the table plus one persistent read for +//! the creator's volume. +//! +//! ## Storage +//! * `cfee_cfg` → [`CreatorFeeTierConfig`] (instance) +//! * `(cfee_trk, creator)` → [`CreatorTierRecord`] (persistent) + +use soroban_sdk::{contractimpl, contracttype, symbol_short, Address, Env, Symbol, Vec}; + +use crate::events; +use crate::types::AdminRole; +use crate::{ + creator_stats_volume_key, require_admin_role, SplitContract, SplitContractArgs, + SplitContractClient, +}; + +/// Maximum number of tiers in the table. +pub const MAX_CREATOR_FEE_TIERS: u32 = 10; + +/// Highest fee rate any tier may charge (10%). +pub const MAX_CREATOR_FEE_BPS: u32 = 1_000; + +const BPS_DENOMINATOR: i128 = 10_000; + +/// One row of the creator fee tier table. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CreatorFeeTier { + /// Position of the tier in the table (0-based). + pub tier_id: u32, + /// Human-readable label, e.g. `bronze`, `silver`, `gold`. + pub name: Symbol, + /// Minimum lifetime creator volume (inclusive) to qualify. + pub min_volume: i128, + /// Fee rate charged to creators in this tier, in basis points. + pub fee_bps: u32, +} + +/// The full tier table plus versioning metadata. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CreatorFeeTierConfig { + pub tiers: Vec, + /// Incremented on every successful update. + pub version: u32, + /// Ledger timestamp of the last update. + pub updated_at: u64, +} + +/// A creator's current tier placement. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CreatorFeeTierStatus { + pub creator: Address, + pub tier_id: u32, + pub tier_name: Symbol, + pub fee_bps: u32, + /// Lifetime volume the placement was computed from. + pub volume: i128, + /// `min_volume` of the next tier up, or `None` at the top tier. + pub next_tier_min_volume: Option, + /// Additional volume needed to reach the next tier (0 at the top tier). + pub volume_to_next_tier: i128, +} + +/// Result of applying a creator's tier rate to an amount. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CreatorFeeQuote { + pub tier_id: u32, + pub fee_bps: u32, + pub gross_amount: i128, + pub fee: i128, + pub net_amount: i128, +} + +/// Last tier recorded for a creator by `sync_creator_fee_tier`. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CreatorTierRecord { + pub tier_id: u32, + pub fee_bps: u32, + /// Config version the tier was computed against. + pub config_version: u32, + /// Ledger timestamp when the creator entered this tier. + pub since: u64, +} + +fn creator_fee_config_key() -> Symbol { + symbol_short!("cfee_cfg") +} + +fn creator_tier_record_key(creator: &Address) -> (Symbol, Address) { + (symbol_short!("cfee_trk"), creator.clone()) +} + +/// Validate a tier table against the config rules in the module docs. +fn validate_tiers(tiers: &Vec) { + assert!(!tiers.is_empty(), "at least one fee tier is required"); + assert!( + tiers.len() <= MAX_CREATOR_FEE_TIERS, + "too many fee tiers" + ); + + let first = tiers.get(0).unwrap(); + assert!(first.min_volume == 0, "first fee tier must start at zero volume"); + + for i in 0..tiers.len() { + let tier = tiers.get(i).unwrap(); + assert!(tier.tier_id == i, "tier_id must match tier position"); + assert!(tier.fee_bps <= MAX_CREATOR_FEE_BPS, "tier fee_bps exceeds maximum"); + if i > 0 { + let prev = tiers.get(i - 1).unwrap(); + assert!( + tier.min_volume > prev.min_volume, + "tier min_volume must be strictly ascending" + ); + assert!( + tier.fee_bps <= prev.fee_bps, + "tier fee_bps must not increase with volume" + ); + } + } +} + +fn load_config(env: &Env) -> Option { + env.storage().instance().get(&creator_fee_config_key()) +} + +fn store_config(env: &Env, tiers: Vec) -> u32 { + let version = load_config(env).map(|c| c.version).unwrap_or(0) + 1; + let tier_count = tiers.len(); + let config = CreatorFeeTierConfig { + tiers, + version, + updated_at: env.ledger().timestamp(), + }; + env.storage().instance().set(&creator_fee_config_key(), &config); + events::creator_fee_tiers_configured(env, version, tier_count); + version +} + +/// Lifetime volume accumulated for `creator` (issue #299 counter). +fn creator_volume(env: &Env, creator: &Address) -> i128 { + env.storage() + .persistent() + .get::<_, u64>(&creator_stats_volume_key(creator)) + .unwrap_or(0) as i128 +} + +/// Index of the highest tier whose `min_volume <= volume`. Binary search over +/// a validated (ascending) table; tier 0 always matches since it starts at 0. +fn tier_index_for_volume(tiers: &Vec, volume: i128) -> u32 { + let mut lo: u32 = 0; + let mut hi: u32 = tiers.len() - 1; + while lo < hi { + let mid = lo + (hi - lo + 1) / 2; + if tiers.get(mid).unwrap().min_volume <= volume { + lo = mid; + } else { + hi = mid - 1; + } + } + lo +} + +/// Compute the creator's tier status. Falls back to a single implicit tier at +/// the platform fee when no table has been configured. +fn resolve_status(env: &Env, creator: &Address) -> CreatorFeeTierStatus { + let volume = creator_volume(env, creator); + match load_config(env) { + Some(config) => { + let idx = tier_index_for_volume(&config.tiers, volume); + let tier = config.tiers.get(idx).unwrap(); + let next_tier_min_volume = config.tiers.get(idx + 1).map(|t| t.min_volume); + let volume_to_next_tier = next_tier_min_volume + .map(|min| (min - volume).max(0)) + .unwrap_or(0); + CreatorFeeTierStatus { + creator: creator.clone(), + tier_id: tier.tier_id, + tier_name: tier.name, + fee_bps: tier.fee_bps, + volume, + next_tier_min_volume, + volume_to_next_tier, + } + } + None => CreatorFeeTierStatus { + creator: creator.clone(), + tier_id: 0, + tier_name: symbol_short!("default"), + fee_bps: SplitContract::get_platform_fee_bps(env.clone()), + volume, + next_tier_min_volume: None, + volume_to_next_tier: 0, + }, + } +} + +/// Apply `fee_bps` to `amount`, rounding the fee down. +fn apply_fee(amount: i128, fee_bps: u32) -> i128 { + amount + .checked_mul(fee_bps as i128) + .expect("fee calculation overflow") + / BPS_DENOMINATOR +} + +#[contractimpl] +impl SplitContract { + /// Issue #919: Replace the creator fee tier table. SuperAdmin only. + /// Returns the new config version. + pub fn set_creator_fee_tiers(env: Env, admin: Address, tiers: Vec) -> u32 { + require_admin_role(&env, &admin, AdminRole::SuperAdmin); + validate_tiers(&tiers); + store_config(&env, tiers) + } + + /// Issue #919: Change the fee rate of a single existing tier, keeping the + /// rest of the table. The updated table is re-validated. SuperAdmin only. + /// Returns the new config version. + pub fn update_creator_fee_tier_bps(env: Env, admin: Address, tier_id: u32, fee_bps: u32) -> u32 { + require_admin_role(&env, &admin, AdminRole::SuperAdmin); + let config = load_config(&env).expect("creator fee tiers not configured"); + let mut tiers = config.tiers; + let mut tier = tiers.get(tier_id).expect("unknown fee tier"); + tier.fee_bps = fee_bps; + tiers.set(tier_id, tier); + validate_tiers(&tiers); + store_config(&env, tiers) + } + + /// Issue #919: Remove the tier table; creators fall back to the platform fee. + /// SuperAdmin only. + pub fn clear_creator_fee_tiers(env: Env, admin: Address) { + require_admin_role(&env, &admin, AdminRole::SuperAdmin); + env.storage().instance().remove(&creator_fee_config_key()); + events::creator_fee_tiers_configured(&env, 0, 0); + } + + /// Issue #919: The current tier table, if configured. + pub fn get_creator_fee_tier_config(env: Env) -> Option { + load_config(&env) + } + + /// Issue #919: The tier a creator currently qualifies for, based on their + /// lifetime volume. + pub fn get_creator_fee_tier(env: Env, creator: Address) -> CreatorFeeTierStatus { + resolve_status(&env, &creator) + } + + /// Issue #919: Fee owed by `creator` on `amount` at their current tier rate. + pub fn calc_creator_fee(env: Env, creator: Address, amount: i128) -> CreatorFeeQuote { + assert!(amount > 0, "amount must be positive"); + let status = resolve_status(&env, &creator); + let fee = apply_fee(amount, status.fee_bps); + CreatorFeeQuote { + tier_id: status.tier_id, + fee_bps: status.fee_bps, + gross_amount: amount, + fee, + net_amount: amount - fee, + } + } + + /// Issue #919: Recompute a creator's tier and record it. Emits a + /// `cfee_chg` event whenever the tier (or its rate) differs from the last + /// recorded one. Permissionless — the result depends only on on-chain state. + pub fn sync_creator_fee_tier(env: Env, creator: Address) -> CreatorTierRecord { + let status = resolve_status(&env, &creator); + let config_version = load_config(&env).map(|c| c.version).unwrap_or(0); + let key = creator_tier_record_key(&creator); + let previous: Option = env.storage().persistent().get(&key); + + let changed = match previous { + Some(ref prev) => prev.tier_id != status.tier_id || prev.fee_bps != status.fee_bps, + None => true, + }; + + let record = CreatorTierRecord { + tier_id: status.tier_id, + fee_bps: status.fee_bps, + config_version, + since: match previous { + Some(ref prev) if !changed => prev.since, + _ => env.ledger().timestamp(), + }, + }; + + if changed { + let old_tier = previous.as_ref().map(|p| p.tier_id); + events::creator_fee_tier_changed( + &env, + &creator, + old_tier, + status.tier_id, + status.fee_bps, + status.volume, + ); + } + if changed || previous.map(|p| p.config_version) != Some(config_version) { + env.storage().persistent().set(&key, &record); + } + record + } + + /// Issue #919: Last tier recorded for a creator by `sync_creator_fee_tier`. + pub fn get_creator_tracked_tier(env: Env, creator: Address) -> Option { + env.storage() + .persistent() + .get(&creator_tier_record_key(&creator)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tier(env: &Env, tier_id: u32, name: &str, min_volume: i128, fee_bps: u32) -> CreatorFeeTier { + CreatorFeeTier { + tier_id, + name: Symbol::new(env, name), + min_volume, + fee_bps, + } + } + + fn sample_tiers(env: &Env) -> Vec { + Vec::from_array( + env, + [ + tier(env, 0, "bronze", 0, 300), + tier(env, 1, "silver", 10_000, 200), + tier(env, 2, "gold", 100_000, 100), + ], + ) + } + + #[test] + fn test_tier_index_for_volume() { + let env = Env::default(); + let tiers = sample_tiers(&env); + assert_eq!(tier_index_for_volume(&tiers, 0), 0); + assert_eq!(tier_index_for_volume(&tiers, 9_999), 0); + assert_eq!(tier_index_for_volume(&tiers, 10_000), 1); + assert_eq!(tier_index_for_volume(&tiers, 99_999), 1); + assert_eq!(tier_index_for_volume(&tiers, 100_000), 2); + assert_eq!(tier_index_for_volume(&tiers, i128::MAX), 2); + } + + #[test] + fn test_tier_index_single_tier() { + let env = Env::default(); + let tiers = Vec::from_array(&env, [tier(&env, 0, "flat", 0, 250)]); + assert_eq!(tier_index_for_volume(&tiers, 0), 0); + assert_eq!(tier_index_for_volume(&tiers, 1_000_000), 0); + } + + #[test] + fn test_validate_accepts_sample_tiers() { + let env = Env::default(); + validate_tiers(&sample_tiers(&env)); + } + + #[test] + #[should_panic(expected = "first fee tier must start at zero volume")] + fn test_validate_rejects_nonzero_first_tier() { + let env = Env::default(); + validate_tiers(&Vec::from_array(&env, [tier(&env, 0, "bronze", 5, 300)])); + } + + #[test] + #[should_panic(expected = "tier min_volume must be strictly ascending")] + fn test_validate_rejects_unsorted_volume() { + let env = Env::default(); + validate_tiers(&Vec::from_array( + &env, + [tier(&env, 0, "bronze", 0, 300), tier(&env, 1, "silver", 0, 200)], + )); + } + + #[test] + #[should_panic(expected = "tier fee_bps must not increase with volume")] + fn test_validate_rejects_increasing_fee() { + let env = Env::default(); + validate_tiers(&Vec::from_array( + &env, + [tier(&env, 0, "bronze", 0, 100), tier(&env, 1, "silver", 10, 200)], + )); + } + + #[test] + #[should_panic(expected = "tier fee_bps exceeds maximum")] + fn test_validate_rejects_fee_above_max() { + let env = Env::default(); + validate_tiers(&Vec::from_array( + &env, + [tier(&env, 0, "bronze", 0, MAX_CREATOR_FEE_BPS + 1)], + )); + } + + #[test] + #[should_panic(expected = "tier_id must match tier position")] + fn test_validate_rejects_bad_tier_id() { + let env = Env::default(); + validate_tiers(&Vec::from_array(&env, [tier(&env, 3, "bronze", 0, 100)])); + } + + #[test] + fn test_apply_fee_rounds_down() { + assert_eq!(apply_fee(10_000, 250), 250); + assert_eq!(apply_fee(999, 100), 9); + assert_eq!(apply_fee(1, 1_000), 0); + assert_eq!(apply_fee(1_000, 0), 0); + } +} diff --git a/contracts/split/src/events.rs b/contracts/split/src/events.rs index 8a8f631..a382c0d 100644 --- a/contracts/split/src/events.rs +++ b/contracts/split/src/events.rs @@ -1918,6 +1918,147 @@ pub fn deadline_extended(env: &Env, invoice_id: u64, old_deadline: u64, new_dead // Unit tests for the per-invoice event sequence counter (issue #708) // --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// Issue #918: Invoice prioritization +// --------------------------------------------------------------------------- + +/// Issue #918: Emitted when an invoice's priority is set or updated. +/// Topics: (split, pri_set, invoice_id) +/// Data: (set_by, old_score, new_score, tier, event_seq) +pub fn invoice_priority_set( + env: &Env, + invoice_id: u64, + set_by: &Address, + old_score: Option, + new_score: u32, + tier: u32, +) { + let event_seq = next_seq(env, invoice_id); + env.events().publish( + (symbol_short!("split"), symbol_short!("pri_set"), invoice_id), + (set_by.clone(), old_score, new_score, tier, event_seq), + ); +} + +/// Issue #918: Emitted when an invoice's priority is cleared. +/// Topics: (split, pri_clr, invoice_id) +/// Data: (cleared_by, event_seq) +pub fn invoice_priority_cleared(env: &Env, invoice_id: u64, cleared_by: &Address) { + let event_seq = next_seq(env, invoice_id); + env.events().publish( + (symbol_short!("split"), symbol_short!("pri_clr"), invoice_id), + (cleared_by.clone(), event_seq), + ); +} + +// --------------------------------------------------------------------------- +// Issue #919: Creator fee tier system +// --------------------------------------------------------------------------- + +/// Issue #919: Emitted when the creator fee tier table is replaced, updated or +/// cleared (`version == 0`, `tier_count == 0` on clear). +/// Topics: (split, cfee_cfg) +/// Data: (version, tier_count) +pub fn creator_fee_tiers_configured(env: &Env, version: u32, tier_count: u32) { + env.events().publish( + (symbol_short!("split"), symbol_short!("cfee_cfg")), + (version, tier_count), + ); +} + +/// Issue #919: Emitted when a creator's tracked fee tier changes. +/// Topics: (split, cfee_chg, creator) +/// Data: (old_tier_id, new_tier_id, fee_bps, volume) +pub fn creator_fee_tier_changed( + env: &Env, + creator: &Address, + old_tier_id: Option, + new_tier_id: u32, + fee_bps: u32, + volume: i128, +) { + env.events().publish( + (symbol_short!("split"), symbol_short!("cfee_chg"), creator.clone()), + (old_tier_id, new_tier_id, fee_bps, volume), + ); +} + +// --------------------------------------------------------------------------- +// Issue #920: Invoice metadata storage +// --------------------------------------------------------------------------- + +/// Issue #920: Emitted when a metadata entry is created or overwritten. +/// Topics: (split, meta_set, invoice_id) +/// Data: (key, version, updated_by, event_seq) +pub fn invoice_metadata_set( + env: &Env, + invoice_id: u64, + key: &String, + version: u32, + updated_by: &Address, +) { + let event_seq = next_seq(env, invoice_id); + env.events().publish( + (symbol_short!("split"), symbol_short!("meta_set"), invoice_id), + (key.clone(), version, updated_by.clone(), event_seq), + ); +} + +/// Issue #920: Emitted when a metadata entry is removed. +/// Topics: (split, meta_del, invoice_id) +/// Data: (key, removed_by, event_seq) +pub fn invoice_metadata_removed(env: &Env, invoice_id: u64, key: &String, removed_by: &Address) { + let event_seq = next_seq(env, invoice_id); + env.events().publish( + (symbol_short!("split"), symbol_short!("meta_del"), invoice_id), + (key.clone(), removed_by.clone(), event_seq), + ); +} + +/// Issue #920: Emitted when an invoice's metadata is permanently locked. +/// Topics: (split, meta_lck, invoice_id) +/// Data: (locked_by, event_seq) +pub fn invoice_metadata_locked(env: &Env, invoice_id: u64, locked_by: &Address) { + let event_seq = next_seq(env, invoice_id); + env.events().publish( + (symbol_short!("split"), symbol_short!("meta_lck"), invoice_id), + (locked_by.clone(), event_seq), + ); +} + +// --------------------------------------------------------------------------- +// Issue #921: Confidential payment tracking +// --------------------------------------------------------------------------- + +/// Issue #921: Emitted when a confidential payment is recorded or replaced. +/// Carries only a digest of the commitment — never an amount. +/// Topics: (split, ctrk_rec, invoice_id) +/// Data: (payer, commitment_digest, revision, event_seq) +pub fn confidential_payment_recorded( + env: &Env, + invoice_id: u64, + payer: &Address, + commitment_digest: &BytesN<32>, + revision: u32, +) { + let event_seq = next_seq(env, invoice_id); + env.events().publish( + (symbol_short!("split"), symbol_short!("ctrk_rec"), invoice_id), + (payer.clone(), commitment_digest.clone(), revision, event_seq), + ); +} + +/// Issue #921: Emitted when the creator proves the confidential invoice total. +/// Topics: (split, ctrk_ver, invoice_id) +/// Data: (verified_by, total, event_seq) +pub fn confidential_total_verified(env: &Env, invoice_id: u64, verified_by: &Address, total: i128) { + let event_seq = next_seq(env, invoice_id); + env.events().publish( + (symbol_short!("split"), symbol_short!("ctrk_ver"), invoice_id), + (verified_by.clone(), total, event_seq), + ); +} + #[cfg(test)] mod tests { use super::*; diff --git a/contracts/split/src/invoice_metadata.rs b/contracts/split/src/invoice_metadata.rs new file mode 100644 index 0000000..a4786fe --- /dev/null +++ b/contracts/split/src/invoice_metadata.rs @@ -0,0 +1,372 @@ +//! Issue #920: Per-invoice key-value metadata store. +//! +//! Creators attach small pieces of custom data to an invoice (order numbers, +//! PO references, external ids, content hashes, ...) without changing the +//! invoice struct. Complements the single off-chain `metadata_hash` +//! (issue #329) with structured, individually addressable entries. +//! +//! ## Validation +//! * Keys: 1 – `MAX_METADATA_KEY_LEN` bytes of `[a-z0-9_.-]`. +//! * Values: 1 – `MAX_METADATA_VALUE_LEN` bytes (opaque). +//! * At most `MAX_METADATA_ENTRIES` keys per invoice. +//! * Only the creator or a co-creator may write; cancelled or deleted +//! invoices are read-only. +//! * Once locked via `lock_invoice_metadata`, the store is permanently +//! read-only (useful once metadata is referenced by an audit or receipt). +//! +//! ## Storage +//! Each entry lives under its own key so single-key reads and writes touch +//! one ledger entry regardless of how many keys an invoice has: +//! * `(imeta_ent, invoice_id, key)` → [`MetadataEntry`] (persistent) +//! * `(imeta_idx, invoice_id)` → `Vec` key index, insertion order (persistent) +//! * `(imeta_lck, invoice_id)` → `bool` lock flag (persistent) + +use soroban_sdk::{contractimpl, contracttype, symbol_short, Address, Bytes, Env, Map, String, Symbol, Vec}; + +use crate::events; +use crate::types::{Invoice, InvoiceStatus}; +use crate::{load_invoice, SplitContract, SplitContractArgs, SplitContractClient}; + +/// Maximum number of metadata keys per invoice. +pub const MAX_METADATA_ENTRIES: u32 = 20; + +/// Maximum key length in bytes. +pub const MAX_METADATA_KEY_LEN: u32 = 32; + +/// Maximum value length in bytes. +pub const MAX_METADATA_VALUE_LEN: u32 = 256; + +/// Maximum number of entries written by one `set_invoice_metadata_batch` call. +pub const MAX_METADATA_BATCH: u32 = 10; + +/// A single metadata entry. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MetadataEntry { + pub key: String, + pub value: Bytes, + /// Starts at 1 and increments on every overwrite of this key. + pub version: u32, + pub updated_by: Address, + pub updated_at: u64, +} + +fn metadata_entry_key(invoice_id: u64, key: &String) -> (Symbol, u64, String) { + (symbol_short!("imeta_ent"), invoice_id, key.clone()) +} + +fn metadata_index_key(invoice_id: u64) -> (Symbol, u64) { + (symbol_short!("imeta_idx"), invoice_id) +} + +fn metadata_lock_key(invoice_id: u64) -> (Symbol, u64) { + (symbol_short!("imeta_lck"), invoice_id) +} + +/// `true` for bytes allowed in a metadata key. +fn is_valid_key_byte(b: u8) -> bool { + b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'.' || b == b'-' +} + +fn validate_key(key: &String) { + let len = key.len(); + assert!(len > 0, "metadata key must not be empty"); + assert!(len <= MAX_METADATA_KEY_LEN, "metadata key too long"); + let mut buf = [0u8; MAX_METADATA_KEY_LEN as usize]; + let slice = &mut buf[..len as usize]; + key.copy_into_slice(slice); + assert!( + slice.iter().all(|b| is_valid_key_byte(*b)), + "metadata key contains invalid characters" + ); +} + +fn validate_value(value: &Bytes) { + assert!(!value.is_empty(), "metadata value must not be empty"); + assert!(value.len() <= MAX_METADATA_VALUE_LEN, "metadata value too long"); +} + +fn load_index(env: &Env, invoice_id: u64) -> Vec { + env.storage() + .persistent() + .get(&metadata_index_key(invoice_id)) + .unwrap_or(Vec::new(env)) +} + +fn save_index(env: &Env, invoice_id: u64, index: &Vec) { + if index.is_empty() { + env.storage().persistent().remove(&metadata_index_key(invoice_id)); + } else { + env.storage() + .persistent() + .set(&metadata_index_key(invoice_id), index); + } +} + +fn is_locked(env: &Env, invoice_id: u64) -> bool { + env.storage() + .persistent() + .get(&metadata_lock_key(invoice_id)) + .unwrap_or(false) +} + +/// Authenticate `caller` and check they may modify `invoice_id`'s metadata. +fn require_metadata_writer(env: &Env, caller: &Address, invoice_id: u64) -> Invoice { + crate::require_not_paused(env); + caller.require_auth(); + let invoice = load_invoice(env, invoice_id); + let is_creator = + invoice.creator == *caller || invoice.co_creators.iter().any(|c| c == *caller); + assert!(is_creator, "NotAuthorized: only creator or co-creator may edit metadata"); + assert!( + invoice.status != InvoiceStatus::Cancelled && invoice.status != InvoiceStatus::Deleted, + "metadata is read-only for cancelled or deleted invoices" + ); + assert!(!is_locked(env, invoice_id), "invoice metadata is locked"); + invoice +} + +/// Validate and write one entry, updating `index` in memory. Returns the +/// stored entry. The caller persists `index`. +fn write_entry( + env: &Env, + caller: &Address, + invoice_id: u64, + index: &mut Vec, + key: String, + value: Bytes, +) -> MetadataEntry { + validate_key(&key); + validate_value(&value); + + let storage_key = metadata_entry_key(invoice_id, &key); + let previous: Option = env.storage().persistent().get(&storage_key); + let version = match previous { + Some(ref prev) => prev.version + 1, + None => { + assert!(index.len() < MAX_METADATA_ENTRIES, "too many metadata entries"); + index.push_back(key.clone()); + 1 + } + }; + + let entry = MetadataEntry { + key: key.clone(), + value, + version, + updated_by: caller.clone(), + updated_at: env.ledger().timestamp(), + }; + env.storage().persistent().set(&storage_key, &entry); + events::invoice_metadata_set(env, invoice_id, &key, version, caller); + entry +} + +#[contractimpl] +impl SplitContract { + /// Issue #920: Create or overwrite one metadata entry on an invoice. + /// + /// # Panics + /// * `"NotAuthorized: ..."` — caller is not the creator or a co-creator. + /// * `"metadata key ..."` / `"metadata value ..."` — validation failure. + /// * `"too many metadata entries"` — adding a new key past the limit. + /// * `"invoice metadata is locked"`. + pub fn set_invoice_metadata( + env: Env, + caller: Address, + invoice_id: u64, + key: String, + value: Bytes, + ) -> MetadataEntry { + require_metadata_writer(&env, &caller, invoice_id); + let mut index = load_index(&env, invoice_id); + let entry = write_entry(&env, &caller, invoice_id, &mut index, key, value); + save_index(&env, invoice_id, &index); + entry + } + + /// Issue #920: Write several entries in one call (all-or-nothing, since a + /// failed validation aborts the transaction). Returns the number written. + pub fn set_invoice_metadata_batch( + env: Env, + caller: Address, + invoice_id: u64, + entries: Map, + ) -> u32 { + assert!(!entries.is_empty(), "no metadata entries supplied"); + assert!( + entries.len() <= MAX_METADATA_BATCH, + "too many metadata entries in batch" + ); + require_metadata_writer(&env, &caller, invoice_id); + let mut index = load_index(&env, invoice_id); + for (key, value) in entries.iter() { + write_entry(&env, &caller, invoice_id, &mut index, key, value); + } + save_index(&env, invoice_id, &index); + entries.len() + } + + /// Issue #920: Delete one entry. Returns `false` when the key was absent. + pub fn remove_invoice_metadata(env: Env, caller: Address, invoice_id: u64, key: String) -> bool { + require_metadata_writer(&env, &caller, invoice_id); + let storage_key = metadata_entry_key(invoice_id, &key); + if !env.storage().persistent().has(&storage_key) { + return false; + } + env.storage().persistent().remove(&storage_key); + + let mut index = load_index(&env, invoice_id); + if let Some(pos) = index.first_index_of(&key) { + index.remove(pos); + } + save_index(&env, invoice_id, &index); + events::invoice_metadata_removed(&env, invoice_id, &key, &caller); + true + } + + /// Issue #920: Delete every entry on an invoice. Returns the number removed. + pub fn clear_invoice_metadata(env: Env, caller: Address, invoice_id: u64) -> u32 { + require_metadata_writer(&env, &caller, invoice_id); + let index = load_index(&env, invoice_id); + for key in index.iter() { + env.storage() + .persistent() + .remove(&metadata_entry_key(invoice_id, &key)); + events::invoice_metadata_removed(&env, invoice_id, &key, &caller); + } + save_index(&env, invoice_id, &Vec::new(&env)); + index.len() + } + + /// Issue #920: Permanently make an invoice's metadata read-only. + pub fn lock_invoice_metadata(env: Env, caller: Address, invoice_id: u64) { + require_metadata_writer(&env, &caller, invoice_id); + env.storage() + .persistent() + .set(&metadata_lock_key(invoice_id), &true); + events::invoice_metadata_locked(&env, invoice_id, &caller); + } + + /// Issue #920: Whether an invoice's metadata has been locked. + pub fn is_invoice_metadata_locked(env: Env, invoice_id: u64) -> bool { + is_locked(&env, invoice_id) + } + + /// Issue #920: Value stored under `key`, if any. Single ledger read. + pub fn get_invoice_metadata(env: Env, invoice_id: u64, key: String) -> Option { + env.storage() + .persistent() + .get::<_, MetadataEntry>(&metadata_entry_key(invoice_id, &key)) + .map(|e| e.value) + } + + /// Issue #920: Full entry (value plus version / author / timestamp) for `key`. + pub fn get_invoice_metadata_entry(env: Env, invoice_id: u64, key: String) -> Option { + env.storage() + .persistent() + .get(&metadata_entry_key(invoice_id, &key)) + } + + /// Issue #920: Whether `key` is set on the invoice. + pub fn has_invoice_metadata(env: Env, invoice_id: u64, key: String) -> bool { + env.storage() + .persistent() + .has(&metadata_entry_key(invoice_id, &key)) + } + + /// Issue #920: All keys set on the invoice, in insertion order. + pub fn get_invoice_metadata_keys(env: Env, invoice_id: u64) -> Vec { + load_index(&env, invoice_id) + } + + /// Issue #920: Every key/value pair on the invoice. + pub fn get_all_invoice_metadata(env: Env, invoice_id: u64) -> Map { + let mut all = Map::new(&env); + for key in load_index(&env, invoice_id).iter() { + if let Some(entry) = env + .storage() + .persistent() + .get::<_, MetadataEntry>(&metadata_entry_key(invoice_id, &key)) + { + all.set(key, entry.value); + } + } + all + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_valid_keys_accepted() { + let env = Env::default(); + validate_key(&String::from_str(&env, "order_id")); + validate_key(&String::from_str(&env, "po-2026.10")); + validate_key(&String::from_str(&env, "a")); + validate_key(&String::from_str(&env, "abcdefghijklmnopqrstuvwxyz012345")); + } + + #[test] + #[should_panic(expected = "metadata key must not be empty")] + fn test_empty_key_rejected() { + let env = Env::default(); + validate_key(&String::from_str(&env, "")); + } + + #[test] + #[should_panic(expected = "metadata key too long")] + fn test_long_key_rejected() { + let env = Env::default(); + validate_key(&String::from_str(&env, "abcdefghijklmnopqrstuvwxyz0123456")); + } + + #[test] + #[should_panic(expected = "metadata key contains invalid characters")] + fn test_uppercase_key_rejected() { + let env = Env::default(); + validate_key(&String::from_str(&env, "OrderId")); + } + + #[test] + #[should_panic(expected = "metadata key contains invalid characters")] + fn test_space_in_key_rejected() { + let env = Env::default(); + validate_key(&String::from_str(&env, "order id")); + } + + #[test] + #[should_panic(expected = "metadata value must not be empty")] + fn test_empty_value_rejected() { + let env = Env::default(); + validate_value(&Bytes::new(&env)); + } + + #[test] + #[should_panic(expected = "metadata value too long")] + fn test_long_value_rejected() { + let env = Env::default(); + validate_value(&Bytes::from_array(&env, &[7u8; (MAX_METADATA_VALUE_LEN + 1) as usize])); + } + + #[test] + fn test_max_value_accepted() { + let env = Env::default(); + validate_value(&Bytes::from_array(&env, &[7u8; MAX_METADATA_VALUE_LEN as usize])); + } + + #[test] + fn test_reads_on_unknown_invoice_are_empty() { + let env = Env::default(); + let contract_id = env.register(SplitContract, ()); + let client = SplitContractClient::new(&env, &contract_id); + let key = String::from_str(&env, "order_id"); + assert_eq!(client.get_invoice_metadata(&42, &key), None); + assert!(!client.has_invoice_metadata(&42, &key)); + assert!(client.get_invoice_metadata_keys(&42).is_empty()); + assert!(client.get_all_invoice_metadata(&42).is_empty()); + assert!(!client.is_invoice_metadata_locked(&42)); + } +} diff --git a/contracts/split/src/lib.rs b/contracts/split/src/lib.rs index ab78604..109d76a 100644 --- a/contracts/split/src/lib.rs +++ b/contracts/split/src/lib.rs @@ -73,6 +73,11 @@ mod storage_keys; mod migrations; +mod priority; +mod creator_fee_tiers; +mod invoice_metadata; +mod confidential_tracking; + use error::ContractError; use validation::assert_valid_bps; use calc::{calc_platform_fee, funding_bps}; diff --git a/contracts/split/src/priority.rs b/contracts/split/src/priority.rs new file mode 100644 index 0000000..e2395c8 --- /dev/null +++ b/contracts/split/src/priority.rs @@ -0,0 +1,456 @@ +//! Issue #918: Invoice prioritization. +//! +//! Lets creators (and admins) attach a priority score to a pending invoice so +//! off-chain keepers and on-chain processing can order work and allocate +//! resources by importance. +//! +//! ## Tier system +//! Every score in `0..=MAX_PRIORITY_SCORE` maps to exactly one tier: +//! +//! | Tier | Score range | Weight | +//! |------------|-------------|--------| +//! | `Low` | 0 – 249 | 1 | +//! | `Normal` | 250 – 499 | 2 | +//! | `High` | 500 – 749 | 4 | +//! | `Critical` | 750 – 1000 | 8 | +//! +//! The weight is the relative share of processing resources an invoice in +//! that tier should receive (e.g. keeper batch slots). +//! +//! ## Ordering +//! Invoices are processed by descending score; equal scores fall back to the +//! lower invoice id first (FIFO, since ids are allocated monotonically). +//! +//! ## Authorization +//! * The creator or a co-creator may set any score below the `Critical` tier. +//! * Admins (`Operator` or `SuperAdmin`) may set any score, including `Critical`, +//! on any invoice. +//! +//! ## Storage +//! * `(ipri_rec, invoice_id)` → [`InvoicePriority`] (persistent) +//! * `(ipri_q, tier)` → `Vec<(u64, u32)>` per-tier queue of `(invoice_id, score)`, +//! kept sorted in processing order (persistent) + +use soroban_sdk::{contractimpl, contracttype, symbol_short, Address, Env, Map, Symbol, Vec}; + +use crate::events; +use crate::types::{AdminRole, InvoiceStatus}; +use crate::{admins_key, load_invoice, SplitContract, SplitContractArgs, SplitContractClient}; + +/// Highest accepted priority score (inclusive). +pub const MAX_PRIORITY_SCORE: u32 = 1_000; + +/// Maximum number of invoice ids accepted by `sort_invoices_by_priority`. +pub const MAX_PRIORITY_SORT_BATCH: u32 = 50; + +/// Maximum number of invoices tracked in a single tier queue. +pub const MAX_PRIORITY_QUEUE_LEN: u32 = 200; + +/// Lowest score that falls in the `Normal` tier. +const NORMAL_TIER_MIN_SCORE: u32 = 250; +/// Lowest score that falls in the `High` tier. +const HIGH_TIER_MIN_SCORE: u32 = 500; +/// Lowest score that falls in the `Critical` tier. +const CRITICAL_TIER_MIN_SCORE: u32 = 750; + +/// Priority tier derived from an invoice's score. +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum PriorityTier { + Low = 0, + Normal = 1, + High = 2, + Critical = 3, +} + +impl PriorityTier { + /// Map a (validated) score onto its tier. + pub fn from_score(score: u32) -> Self { + if score >= CRITICAL_TIER_MIN_SCORE { + PriorityTier::Critical + } else if score >= HIGH_TIER_MIN_SCORE { + PriorityTier::High + } else if score >= NORMAL_TIER_MIN_SCORE { + PriorityTier::Normal + } else { + PriorityTier::Low + } + } + + /// Relative share of processing resources for invoices in this tier. + pub fn weight(self) -> u32 { + match self { + PriorityTier::Low => 1, + PriorityTier::Normal => 2, + PriorityTier::High => 4, + PriorityTier::Critical => 8, + } + } + + /// All tiers from most to least urgent. + fn descending() -> [PriorityTier; 4] { + [ + PriorityTier::Critical, + PriorityTier::High, + PriorityTier::Normal, + PriorityTier::Low, + ] + } +} + +/// Priority record stored per invoice. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InvoicePriority { + pub invoice_id: u64, + /// Score in `0..=MAX_PRIORITY_SCORE`; higher is processed first. + pub score: u32, + /// Tier derived from `score` at the time it was set. + pub tier: PriorityTier, + /// Address that last set the priority (creator, co-creator or admin). + pub set_by: Address, + /// Ledger timestamp of the last update. + pub updated_at: u64, +} + +fn priority_record_key(invoice_id: u64) -> (Symbol, u64) { + (symbol_short!("ipri_rec"), invoice_id) +} + +fn priority_queue_key(tier: PriorityTier) -> (Symbol, u32) { + (symbol_short!("ipri_q"), tier as u32) +} + +/// `true` when `(a_score, a_id)` must be processed before `(b_score, b_id)`. +fn ranks_before(a_score: u32, a_id: u64, b_score: u32, b_id: u64) -> bool { + a_score > b_score || (a_score == b_score && a_id < b_id) +} + +/// Index at which `(invoice_id, score)` must be inserted to keep `entries` +/// in processing order. Binary search — O(log n) comparisons. +fn insertion_index(entries: &Vec<(u64, u32)>, invoice_id: u64, score: u32) -> u32 { + let mut lo: u32 = 0; + let mut hi: u32 = entries.len(); + while lo < hi { + let mid = lo + (hi - lo) / 2; + let (mid_id, mid_score) = entries.get(mid).unwrap(); + if ranks_before(mid_score, mid_id, score, invoice_id) { + lo = mid + 1; + } else { + hi = mid; + } + } + lo +} + +fn load_queue(env: &Env, tier: PriorityTier) -> Vec<(u64, u32)> { + env.storage() + .persistent() + .get(&priority_queue_key(tier)) + .unwrap_or(Vec::new(env)) +} + +fn save_queue(env: &Env, tier: PriorityTier, queue: &Vec<(u64, u32)>) { + if queue.is_empty() { + env.storage().persistent().remove(&priority_queue_key(tier)); + } else { + env.storage().persistent().set(&priority_queue_key(tier), queue); + } +} + +fn remove_from_queue(env: &Env, tier: PriorityTier, invoice_id: u64) { + let mut queue = load_queue(env, tier); + if let Some(pos) = queue.iter().position(|(id, _)| id == invoice_id) { + queue.remove(pos as u32); + save_queue(env, tier, &queue); + } +} + +fn insert_into_queue(env: &Env, tier: PriorityTier, invoice_id: u64, score: u32) { + let mut queue = load_queue(env, tier); + assert!( + queue.len() < MAX_PRIORITY_QUEUE_LEN, + "priority queue is full for this tier" + ); + let idx = insertion_index(&queue, invoice_id, score); + queue.insert(idx, (invoice_id, score)); + save_queue(env, tier, &queue); +} + +/// `true` when `caller` holds `Operator` or `SuperAdmin`. Read-only: does not +/// call `require_auth` (the caller has already been authenticated). +fn is_priority_admin(env: &Env, caller: &Address) -> bool { + env.storage() + .instance() + .get::<_, Map>(&admins_key()) + .and_then(|admins| admins.get(caller.clone())) + .is_some() +} + +/// Validate the score and the caller's right to apply it to `invoice_id`. +fn validate_priority_update(env: &Env, caller: &Address, invoice_id: u64, score: u32) { + assert!(score <= MAX_PRIORITY_SCORE, "priority score out of range"); + + let invoice = load_invoice(env, invoice_id); + assert!( + invoice.status == InvoiceStatus::Pending + || invoice.status == InvoiceStatus::PartiallyReleased, + "invoice is not open for prioritization" + ); + + let is_creator = + invoice.creator == *caller || invoice.co_creators.iter().any(|c| c == *caller); + let is_admin = is_priority_admin(env, caller); + assert!(is_creator || is_admin, "NotAuthorized: cannot prioritize this invoice"); + + if PriorityTier::from_score(score) == PriorityTier::Critical { + assert!(is_admin, "only admins may assign Critical priority"); + } +} + +#[contractimpl] +impl SplitContract { + /// Issue #918: Set (or update) the priority score of an open invoice. + /// + /// Moves the invoice between tier queues when its tier changes and keeps + /// each queue sorted in processing order. + /// + /// # Panics + /// * `"priority score out of range"` — `score > MAX_PRIORITY_SCORE`. + /// * `"invoice is not open for prioritization"` — invoice is finalised, + /// refunded, cancelled, etc. + /// * `"NotAuthorized: ..."` — caller is neither creator/co-creator nor admin. + /// * `"only admins may assign Critical priority"`. + /// * `"priority queue is full for this tier"`. + pub fn set_invoice_priority( + env: Env, + caller: Address, + invoice_id: u64, + score: u32, + ) -> InvoicePriority { + crate::require_not_paused(&env); + caller.require_auth(); + validate_priority_update(&env, &caller, invoice_id, score); + + let tier = PriorityTier::from_score(score); + let previous: Option = env + .storage() + .persistent() + .get(&priority_record_key(invoice_id)); + if let Some(ref prev) = previous { + remove_from_queue(&env, prev.tier, invoice_id); + } + insert_into_queue(&env, tier, invoice_id, score); + + let record = InvoicePriority { + invoice_id, + score, + tier, + set_by: caller.clone(), + updated_at: env.ledger().timestamp(), + }; + env.storage() + .persistent() + .set(&priority_record_key(invoice_id), &record); + + let old_score = previous.map(|p| p.score); + events::invoice_priority_set(&env, invoice_id, &caller, old_score, score, tier as u32); + record + } + + /// Issue #918: Remove an invoice's priority and drop it from its tier queue. + /// + /// Callable by the creator, a co-creator or an admin. Clearing a + /// `Critical` priority requires an admin. + pub fn clear_invoice_priority(env: Env, caller: Address, invoice_id: u64) { + crate::require_not_paused(&env); + caller.require_auth(); + let record: InvoicePriority = env + .storage() + .persistent() + .get(&priority_record_key(invoice_id)) + .expect("invoice has no priority"); + + let is_admin = is_priority_admin(&env, &caller); + if !is_admin { + let invoice = load_invoice(&env, invoice_id); + let is_creator = + invoice.creator == caller || invoice.co_creators.iter().any(|c| c == caller); + assert!(is_creator, "NotAuthorized: cannot prioritize this invoice"); + assert!( + record.tier != PriorityTier::Critical, + "only admins may assign Critical priority" + ); + } + + remove_from_queue(&env, record.tier, invoice_id); + env.storage() + .persistent() + .remove(&priority_record_key(invoice_id)); + events::invoice_priority_cleared(&env, invoice_id, &caller); + } + + /// Issue #918: Priority record for an invoice, if one has been set. + pub fn get_invoice_priority(env: Env, invoice_id: u64) -> Option { + env.storage() + .persistent() + .get(&priority_record_key(invoice_id)) + } + + /// Issue #918: Tier a given score maps to (pure lookup). + pub fn get_priority_tier_for_score(_env: Env, score: u32) -> PriorityTier { + assert!(score <= MAX_PRIORITY_SCORE, "priority score out of range"); + PriorityTier::from_score(score) + } + + /// Issue #918: Resource-allocation weight of a tier. + pub fn get_priority_tier_weight(_env: Env, tier: PriorityTier) -> u32 { + tier.weight() + } + + /// Issue #918: Invoice ids queued in `tier`, in processing order. + pub fn get_priority_queue(env: Env, tier: PriorityTier) -> Vec { + let mut ids = Vec::new(&env); + for (id, _) in load_queue(&env, tier).iter() { + ids.push_back(id); + } + ids + } + + /// Issue #918: The invoice that should be processed next — the head of the + /// most urgent non-empty tier queue. `None` when nothing is queued. + pub fn get_next_priority_invoice(env: Env) -> Option { + for tier in PriorityTier::descending() { + if let Some((id, _)) = load_queue(&env, tier).first() { + return Some(id); + } + } + None + } + + /// Issue #918: Sort `invoice_ids` into processing order (descending score, + /// then ascending id). Invoices without a priority are treated as score 0. + /// + /// Bounded by `MAX_PRIORITY_SORT_BATCH`; uses binary insertion so the + /// comparison count is O(n log n). + pub fn sort_invoices_by_priority(env: Env, invoice_ids: Vec) -> Vec { + assert!( + invoice_ids.len() <= MAX_PRIORITY_SORT_BATCH, + "too many invoices to sort" + ); + let mut ranked: Vec<(u64, u32)> = Vec::new(&env); + for id in invoice_ids.iter() { + let score = env + .storage() + .persistent() + .get::<_, InvoicePriority>(&priority_record_key(id)) + .map(|p| p.score) + .unwrap_or(0); + let idx = insertion_index(&ranked, id, score); + ranked.insert(idx, (id, score)); + } + let mut sorted = Vec::new(&env); + for (id, _) in ranked.iter() { + sorted.push_back(id); + } + sorted + } + + /// Issue #918: Drop queue entries for invoices that are no longer open + /// (released, refunded, cancelled, ...). Permissionless housekeeping; + /// returns the number of entries removed. + pub fn prune_priority_queue(env: Env, tier: PriorityTier) -> u32 { + let queue = load_queue(&env, tier); + let mut kept: Vec<(u64, u32)> = Vec::new(&env); + let mut removed: u32 = 0; + for (id, score) in queue.iter() { + let key = crate::invoice_key(id); + let exists = + env.storage().persistent().has(&key) || env.storage().instance().has(&key); + let open = exists + && { + let status = load_invoice(&env, id).status; + status == InvoiceStatus::Pending || status == InvoiceStatus::PartiallyReleased + }; + if open { + kept.push_back((id, score)); + } else { + env.storage().persistent().remove(&priority_record_key(id)); + removed += 1; + } + } + if removed > 0 { + save_queue(&env, tier, &kept); + } + removed + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_tier_from_score_boundaries() { + assert_eq!(PriorityTier::from_score(0), PriorityTier::Low); + assert_eq!(PriorityTier::from_score(249), PriorityTier::Low); + assert_eq!(PriorityTier::from_score(250), PriorityTier::Normal); + assert_eq!(PriorityTier::from_score(499), PriorityTier::Normal); + assert_eq!(PriorityTier::from_score(500), PriorityTier::High); + assert_eq!(PriorityTier::from_score(749), PriorityTier::High); + assert_eq!(PriorityTier::from_score(750), PriorityTier::Critical); + assert_eq!(PriorityTier::from_score(MAX_PRIORITY_SCORE), PriorityTier::Critical); + } + + #[test] + fn test_tier_weights_increase_with_urgency() { + let tiers = PriorityTier::descending(); + for i in 1..tiers.len() { + assert!(tiers[i - 1].weight() > tiers[i].weight()); + } + } + + #[test] + fn test_ranks_before_orders_by_score_then_id() { + assert!(ranks_before(10, 5, 9, 1)); + assert!(ranks_before(10, 1, 10, 2)); + assert!(!ranks_before(10, 2, 10, 1)); + assert!(!ranks_before(9, 1, 10, 5)); + } + + #[test] + fn test_insertion_index_keeps_processing_order() { + let env = Env::default(); + let mut queue: Vec<(u64, u32)> = Vec::new(&env); + for (id, score) in [(1u64, 100u32), (2, 300), (3, 300), (4, 50), (5, 900)] { + let idx = insertion_index(&queue, id, score); + queue.insert(idx, (id, score)); + } + let mut order = Vec::new(&env); + for (id, _) in queue.iter() { + order.push_back(id); + } + let expected = Vec::from_array(&env, [5u64, 2, 3, 1, 4]); + assert_eq!(order, expected); + } + + #[test] + fn test_sort_invoices_without_priority_preserves_id_order() { + let env = Env::default(); + let contract_id = env.register(SplitContract, ()); + let client = SplitContractClient::new(&env, &contract_id); + let ids = Vec::from_array(&env, [7u64, 3, 9]); + let sorted = client.sort_invoices_by_priority(&ids); + assert_eq!(sorted, Vec::from_array(&env, [3u64, 7, 9])); + } + + #[test] + #[should_panic(expected = "priority score out of range")] + fn test_tier_for_score_rejects_out_of_range() { + let env = Env::default(); + let contract_id = env.register(SplitContract, ()); + let client = SplitContractClient::new(&env, &contract_id); + client.get_priority_tier_for_score(&(MAX_PRIORITY_SCORE + 1)); + } +}