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));
+ }
+}