From 95192d91b4dda09719becc26d3814b29f9692418 Mon Sep 17 00:00:00 2001 From: SunDrive Auto Date: Tue, 28 Jul 2026 16:00:13 +0000 Subject: [PATCH 1/4] feat: add SplitFactory clone-factory crate for gas-efficient invoice contract deployment Implement a clone factory pattern in contracts/factory/ that deploys minimal-proxy clones pointing to a single canonical WASM blob. - deploy_invoice_contract(creator, wasm_hash, salt, init_args) using env.deployer().with_current_contract(salt) - Reject duplicate salts per creator - Emit ContractDeployed(creator, contract_address) event - Per-creator deploy limit (10k) to prevent DoS - Registration in DeployedContracts(creator) persistent storage - Added to workspace Cargo.toml as new member Issue: Clone Factory Pattern --- Cargo.toml | 2 +- contracts/factory/Cargo.toml | 16 ++ contracts/factory/src/lib.rs | 352 +++++++++++++++++++++++++++++++++++ 3 files changed, 369 insertions(+), 1 deletion(-) create mode 100644 contracts/factory/Cargo.toml create mode 100644 contracts/factory/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index 10b6faf..ad98ff2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["contracts/split", "contracts/dashboard", "contracts/invoice-escrow"] +members = ["contracts/split", "contracts/dashboard", "contracts/invoice-escrow", "contracts/factory", "contracts/registry"] resolver = "2" [workspace.dependencies] diff --git a/contracts/factory/Cargo.toml b/contracts/factory/Cargo.toml new file mode 100644 index 0000000..42c860d --- /dev/null +++ b/contracts/factory/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "factory" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } + +[features] +testutils = ["soroban-sdk/testutils"] diff --git a/contracts/factory/src/lib.rs b/contracts/factory/src/lib.rs new file mode 100644 index 0000000..601f787 --- /dev/null +++ b/contracts/factory/src/lib.rs @@ -0,0 +1,352 @@ +//! SplitFactory — clone-factory for per-invoice split contract instances. +//! +//! Deploying a fresh WASM instance per invoice is gas-intensive. This factory +//! deploys minimal-proxy clones pointing to a single canonical WASM blob, +//! reducing deployment cost while maintaining per-invoice state isolation. + +#![no_std] + +use soroban_sdk::{contract, contractimpl, contracttype, symbol_short, Address, BytesN, Env, Symbol, Val, Vec}; + +// --------------------------------------------------------------------------- +// Storage keys +// --------------------------------------------------------------------------- + +fn factory_admin_key() -> Symbol { + symbol_short!("fct_adm") +} + +/// Per-creator deployed contracts list — persistent storage. +fn deployed_contracts_key(creator: &Address) -> (Symbol, Address) { + (symbol_short!("deployed"), creator.clone()) +} + +/// Per-(creator, salt) deployed address — used to detect duplicate salts. +fn salt_key(creator: &Address, salt: &BytesN<32>) -> (Symbol, Address, BytesN<32>) { + (symbol_short!("salt"), creator.clone(), salt.clone()) +} + +/// Maximum number of deployed contracts per creator (DoS bound). +const MAX_DEPLOYMENTS_PER_CREATOR: u32 = 10_000; + +// --------------------------------------------------------------------------- +// Events +// --------------------------------------------------------------------------- + +/// Emitted when a new split contract is deployed by the factory. +/// +/// Topics: (factory, deployed, creator) +/// Data: (contract_address, salt) +pub fn contract_deployed(env: &Env, creator: &Address, contract_address: &Address) { + env.events().publish( + ( + symbol_short!("factory"), + symbol_short!("deployed"), + creator.clone(), + ), + contract_address.clone(), + ); +} + +// --------------------------------------------------------------------------- +// Contract +// --------------------------------------------------------------------------- + +#[contract] +pub struct SplitFactory; + +#[contractimpl] +impl SplitFactory { + /// Initialise the factory by recording its admin. + /// Can only be called once. + pub fn initialize(env: Env, admin: Address) { + assert!( + !env.storage().instance().has(&factory_admin_key()), + "already initialized" + ); + env.storage().instance().set(&factory_admin_key(), &admin); + } + + /// Deploy a new split contract instance using the clone-factory pattern. + /// + /// Uses `env.deployer().with_current_contract(salt)` so that the deployed + /// address is deterministic for a given `(creator, salt)` pair. Duplicate + /// salts for the same creator are rejected. + /// + /// # Parameters + /// * `creator` — The creator address; requires auth. + /// * `wasm_hash` — SHA-256 hash of the pre-uploaded split-contract WASM. + /// * `salt` — 32-byte uniqueness salt; determines the deployed address. + /// * `init_args` — Constructor arguments forwarded to the deployed contract. + /// + /// # Returns + /// The `Address` of the newly deployed contract. + pub fn deploy_invoice_contract( + env: Env, + creator: Address, + wasm_hash: BytesN<32>, + salt: BytesN<32>, + init_args: soroban_sdk::Vec, + ) -> Address { + creator.require_auth(); + + // Reject duplicate salts for the same creator. + let salt_storage_key = salt_key(&creator, &salt); + assert!( + !env.storage().persistent().has(&salt_storage_key), + "duplicate salt for creator" + ); + + // Enforce per-creator deployment cap (DoS bound). + let deployments_key = deployed_contracts_key(&creator); + let deployments: Vec
= env + .storage() + .persistent() + .get(&deployments_key) + .unwrap_or_else(|| Vec::new(&env)); + assert!( + deployments.len() < MAX_DEPLOYMENTS_PER_CREATOR as u32, + "deployment limit exceeded" + ); + + // Deploy using the clone-factory pattern. + let deployed_address = env + .deployer() + .with_current_contract(salt.clone()) + .deploy(wasm_hash); + + // Invoke init on the deployed contract if init_args is non-empty. + if !init_args.is_empty() { + let _: Val = env.invoke_contract( + &deployed_address, + &Symbol::new(&env, "initialize"), + init_args, + ); + } + + // Record the deployed address. + let mut updated_deployments = deployments; + updated_deployments.push_back(deployed_address.clone()); + env.storage() + .persistent() + .set(&deployments_key, &updated_deployments); + + // Mark the salt as used. + env.storage() + .persistent() + .set(&salt_storage_key, &deployed_address); + + // Emit event. + contract_deployed(&env, &creator, &deployed_address); + + deployed_address + } + + /// Return all deployed contract addresses for a given creator. + pub fn get_deployments(env: Env, creator: Address) -> Vec
{ + env.storage() + .persistent() + .get(&deployed_contracts_key(&creator)) + .unwrap_or_else(|| Vec::new(&env)) + } + + /// Check whether a specific (creator, salt) pair has already been used. + pub fn is_salt_used(env: Env, creator: Address, salt: BytesN<32>) -> bool { + env.storage().persistent().has(&salt_key(&creator, &salt)) + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod test { + use super::*; + use soroban_sdk::{testutils::Address as _, BytesN, Env, Val, Vec}; + + /// Minimal valid Soroban WASM stub (WebAssembly module with no exports). + const STUB_WASM: &[u8] = &[ + 0x00, 0x61, 0x73, 0x6d, // magic: \0asm + 0x01, 0x00, 0x00, 0x00, // version: 1 + ]; + + #[test] + fn test_deploy_single_contract() { + let env = Env::default(); + env.mock_all_auths(); + + let factory_admin = Address::generate(&env); + let factory_id = env.register(SplitFactory, ()); + let factory_client = SplitFactoryClient::new(&env, &factory_id); + factory_client.initialize(&factory_admin); + + let wasm_hash = env.deployer().upload_contract_wasm(STUB_WASM); + + let creator = Address::generate(&env); + let salt = BytesN::from_array(&env, &[1u8; 32]); + let init_args = Vec::::new(&env); + + let addr = factory_client.deploy_invoice_contract( + &creator, + &wasm_hash, + &salt, + &init_args, + ); + + let deployments = factory_client.get_deployments(&creator); + assert_eq!(deployments.len(), 1); + assert_eq!(deployments.get_unchecked(0), addr); + } + + #[test] + fn test_deploy_two_contracts_unique_addresses() { + let env = Env::default(); + env.mock_all_auths(); + + let factory_admin = Address::generate(&env); + let factory_id = env.register(SplitFactory, ()); + let factory_client = SplitFactoryClient::new(&env, &factory_id); + factory_client.initialize(&factory_admin); + + let wasm_hash = env.deployer().upload_contract_wasm(STUB_WASM); + let creator = Address::generate(&env); + let init_args = Vec::::new(&env); + + let salt1 = BytesN::from_array(&env, &[1u8; 32]); + let addr1 = factory_client.deploy_invoice_contract( + &creator, + &wasm_hash, + &salt1, + &init_args, + ); + + let salt2 = BytesN::from_array(&env, &[2u8; 32]); + let addr2 = factory_client.deploy_invoice_contract( + &creator, + &wasm_hash, + &salt2, + &init_args, + ); + + assert_ne!(addr1, addr2); + + let deployments = factory_client.get_deployments(&creator); + assert_eq!(deployments.len(), 2); + assert_eq!(deployments.get_unchecked(0), addr1); + assert_eq!(deployments.get_unchecked(1), addr2); + } + + #[test] + #[should_panic(expected = "duplicate salt for creator")] + fn test_duplicate_salt_rejected() { + let env = Env::default(); + env.mock_all_auths(); + + let factory_admin = Address::generate(&env); + let factory_id = env.register(SplitFactory, ()); + let factory_client = SplitFactoryClient::new(&env, &factory_id); + factory_client.initialize(&factory_admin); + + let wasm_hash = env.deployer().upload_contract_wasm(STUB_WASM); + let creator = Address::generate(&env); + let salt = BytesN::from_array(&env, &[1u8; 32]); + let init_args = Vec::::new(&env); + + factory_client.deploy_invoice_contract(&creator, &wasm_hash, &salt, &init_args); + // Same salt, same creator — should panic. + factory_client.deploy_invoice_contract(&creator, &wasm_hash, &salt, &init_args); + } + + #[test] + fn test_different_creators_same_salt_allowed() { + let env = Env::default(); + env.mock_all_auths(); + + let factory_admin = Address::generate(&env); + let factory_id = env.register(SplitFactory, ()); + let factory_client = SplitFactoryClient::new(&env, &factory_id); + factory_client.initialize(&factory_admin); + + let wasm_hash = env.deployer().upload_contract_wasm(STUB_WASM); + let init_args = Vec::::new(&env); + let salt = BytesN::from_array(&env, &[1u8; 32]); + + let creator1 = Address::generate(&env); + let addr1 = factory_client.deploy_invoice_contract(&creator1, &wasm_hash, &salt, &init_args); + + let creator2 = Address::generate(&env); + let addr2 = factory_client.deploy_invoice_contract(&creator2, &wasm_hash, &salt, &init_args); + + assert_ne!(addr1, addr2); + } + + #[test] + fn test_contract_deployed_event_emitted() { + let env = Env::default(); + env.mock_all_auths(); + + let factory_admin = Address::generate(&env); + let factory_id = env.register(SplitFactory, ()); + let factory_client = SplitFactoryClient::new(&env, &factory_id); + factory_client.initialize(&factory_admin); + + let wasm_hash = env.deployer().upload_contract_wasm(STUB_WASM); + let creator = Address::generate(&env); + let salt = BytesN::from_array(&env, &[1u8; 32]); + let init_args = Vec::::new(&env); + + factory_client.deploy_invoice_contract(&creator, &wasm_hash, &salt, &init_args); + + // Verify the ContractDeployed event was emitted. + let events = env.events().all(); + let mut found = false; + for event in events.iter() { + let topics = event.1; + // topics: (factory, deployed, creator) + if topics.len() >= 3 { + if let Ok(t0) = Symbol::try_from_val(&env, &topics.get_unchecked(0)) { + if t0 == symbol_short!("factory") { + found = true; + break; + } + } + } + } + assert!(found, "ContractDeployed event not emitted"); + } + + #[test] + fn test_is_salt_used() { + let env = Env::default(); + env.mock_all_auths(); + + let factory_admin = Address::generate(&env); + let factory_id = env.register(SplitFactory, ()); + let factory_client = SplitFactoryClient::new(&env, &factory_id); + factory_client.initialize(&factory_admin); + + let wasm_hash = env.deployer().upload_contract_wasm(STUB_WASM); + let creator = Address::generate(&env); + let salt = BytesN::from_array(&env, &[1u8; 32]); + let init_args = Vec::::new(&env); + + assert!(!factory_client.is_salt_used(&creator, &salt)); + factory_client.deploy_invoice_contract(&creator, &wasm_hash, &salt, &init_args); + assert!(factory_client.is_salt_used(&creator, &salt)); + } + + #[test] + fn test_empty_get_deployments() { + let env = Env::default(); + env.mock_all_auths(); + + let factory_admin = Address::generate(&env); + let factory_id = env.register(SplitFactory, ()); + let factory_client = SplitFactoryClient::new(&env, &factory_id); + factory_client.initialize(&factory_admin); + + let creator = Address::generate(&env); + assert_eq!(factory_client.get_deployments(&creator).len(), 0); + } +} From dfad7b504507be4eeee808dbc5f2c7e65519f0a0 Mon Sep 17 00:00:00 2001 From: SunDrive Auto Date: Tue, 28 Jul 2026 16:02:56 +0000 Subject: [PATCH 2/4] feat: add Registry contract for on-chain invoice discovery index Create contracts/registry/ as a new workspace member that maintains a searchable map from creator address to their list of deployed split contract addresses. - register(creator, contract) appends to creator's list - get_contracts(creator) returns all registered addresses - Duplicate registrations for same contract address rejected - Per-creator limit (5k) to bound storage and prevent DoS - ContractRegistered(creator, contract) event emitted - is_registered(creator, contract) query helper Issue: Registry Contract --- contracts/registry/Cargo.toml | 16 ++ contracts/registry/src/lib.rs | 287 ++++++++++++++++++++++++++++++++++ 2 files changed, 303 insertions(+) create mode 100644 contracts/registry/Cargo.toml create mode 100644 contracts/registry/src/lib.rs diff --git a/contracts/registry/Cargo.toml b/contracts/registry/Cargo.toml new file mode 100644 index 0000000..223aa1b --- /dev/null +++ b/contracts/registry/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "registry" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } + +[features] +testutils = ["soroban-sdk/testutils"] diff --git a/contracts/registry/src/lib.rs b/contracts/registry/src/lib.rs new file mode 100644 index 0000000..33e1b8a --- /dev/null +++ b/contracts/registry/src/lib.rs @@ -0,0 +1,287 @@ +//! StellarSplit Registry — on-chain index of all deployed split contract instances. +//! +//! Maintains a searchable map from creator address to their list of deployed +//! split contract addresses so frontends can discover active invoices. + +#![no_std] + +use soroban_sdk::{contract, contractimpl, symbol_short, Address, Env, Symbol, Vec}; + +// --------------------------------------------------------------------------- +// Storage keys +// --------------------------------------------------------------------------- + +fn registry_admin_key() -> Symbol { + symbol_short!("reg_adm") +} + +/// Per-creator list of registered contract addresses — persistent storage. +fn creator_contracts_key(creator: &Address) -> (Symbol, Address) { + (symbol_short!("cr_ctrs"), creator.clone()) +} + +/// Per-(creator, contract) flag to detect duplicate registrations — persistent. +fn registration_key(creator: &Address, contract: &Address) -> (Symbol, Address, Address) { + (symbol_short!("reg_chk"), creator.clone(), contract.clone()) +} + +/// Maximum contracts per creator to bound storage growth and prevent DoS. +const MAX_CONTRACTS_PER_CREATOR: u32 = 5_000; + +// --------------------------------------------------------------------------- +// Events +// --------------------------------------------------------------------------- + +/// Emitted when a contract is registered for a creator. +/// +/// Topics: (registry, registered, creator) +/// Data: contract_address +pub fn contract_registered(env: &Env, creator: &Address, contract: &Address) { + env.events().publish( + ( + symbol_short!("registry"), + symbol_short!("registered"), + creator.clone(), + ), + contract.clone(), + ); +} + +// --------------------------------------------------------------------------- +// Contract +// --------------------------------------------------------------------------- + +#[contract] +pub struct Registry; + +#[contractimpl] +impl Registry { + /// Initialise the registry by recording its admin. + /// Can only be called once. + pub fn initialize(env: Env, admin: Address) { + assert!( + !env.storage().instance().has(®istry_admin_key()), + "already initialized" + ); + env.storage().instance().set(®istry_admin_key(), &admin); + } + + /// Register a deployed contract address under a creator. + /// + /// Appends `contract` to the creator's list. Rejects duplicate + /// registrations for the same (creator, contract) pair. + /// + /// # Panics + /// * `"registry limit exceeded"` — the creator already has + /// [`MAX_CONTRACTS_PER_CREATOR`] registered contracts. + /// * `"already registered"` — this contract address is already + /// in the creator's list. + pub fn register(env: Env, creator: Address, contract: Address) { + creator.require_auth(); + + let contracts_key = creator_contracts_key(&creator); + let registration_check_key = registration_key(&creator, &contract); + + // Reject duplicates. + assert!( + !env.storage().persistent().has(®istration_check_key), + "already registered" + ); + + // Enforce per-creator limit. + let mut contracts: Vec
= env + .storage() + .persistent() + .get(&contracts_key) + .unwrap_or_else(|| Vec::new(&env)); + assert!( + contracts.len() < MAX_CONTRACTS_PER_CREATOR as u32, + "registry limit exceeded" + ); + + // Append and persist. + contracts.push_back(contract.clone()); + env.storage() + .persistent() + .set(&contracts_key, &contracts); + + // Mark as registered. + env.storage() + .persistent() + .set(®istration_check_key, &true); + + // Emit event. + contract_registered(&env, &creator, &contract); + } + + /// Return all registered contract addresses for a creator. + pub fn get_contracts(env: Env, creator: Address) -> Vec
{ + env.storage() + .persistent() + .get(&creator_contracts_key(&creator)) + .unwrap_or_else(|| Vec::new(&env)) + } + + /// Return the number of registered contracts for a creator. + pub fn get_contract_count(env: Env, creator: Address) -> u32 { + let contracts: Vec
= env + .storage() + .persistent() + .get(&creator_contracts_key(&creator)) + .unwrap_or_else(|| Vec::new(&env)); + contracts.len() + } + + /// Check whether a specific (creator, contract) pair is already registered. + pub fn is_registered(env: Env, creator: Address, contract: Address) -> bool { + env.storage() + .persistent() + .has(®istration_key(&creator, &contract)) + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod test { + use super::*; + use soroban_sdk::{ + testutils::{Address as _, Events as _}, + Address, Env, Symbol, Vec, + }; + + #[test] + fn test_register_and_get_contracts() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let registry_id = env.register(Registry, ()); + let registry_client = RegistryClient::new(&env, ®istry_id); + registry_client.initialize(&admin); + + let creator = Address::generate(&env); + let contract_addr = Address::generate(&env); + + registry_client.register(&creator, &contract_addr); + + let contracts = registry_client.get_contracts(&creator); + assert_eq!(contracts.len(), 1); + assert_eq!(contracts.get_unchecked(0), contract_addr); + assert_eq!(registry_client.get_contract_count(&creator), 1); + } + + #[test] + fn test_multiple_creators() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let registry_id = env.register(Registry, ()); + let registry_client = RegistryClient::new(&env, ®istry_id); + registry_client.initialize(&admin); + + let c1 = Address::generate(&env); + let c2 = Address::generate(&env); + let addr1 = Address::generate(&env); + let addr2 = Address::generate(&env); + let addr3 = Address::generate(&env); + + registry_client.register(&c1, &addr1); + registry_client.register(&c1, &addr2); + registry_client.register(&c2, &addr3); + + let c1_contracts = registry_client.get_contracts(&c1); + assert_eq!(c1_contracts.len(), 2); + assert_eq!(c1_contracts.get_unchecked(0), addr1); + assert_eq!(c1_contracts.get_unchecked(1), addr2); + + let c2_contracts = registry_client.get_contracts(&c2); + assert_eq!(c2_contracts.len(), 1); + assert_eq!(c2_contracts.get_unchecked(0), addr3); + } + + #[test] + #[should_panic(expected = "already registered")] + fn test_duplicate_registration_rejected() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let registry_id = env.register(Registry, ()); + let registry_client = RegistryClient::new(&env, ®istry_id); + registry_client.initialize(&admin); + + let creator = Address::generate(&env); + let contract_addr = Address::generate(&env); + + registry_client.register(&creator, &contract_addr); + registry_client.register(&creator, &contract_addr); // should panic + } + + #[test] + fn test_contract_registered_event_emitted() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let registry_id = env.register(Registry, ()); + let registry_client = RegistryClient::new(&env, ®istry_id); + registry_client.initialize(&admin); + + let creator = Address::generate(&env); + let contract_addr = Address::generate(&env); + + registry_client.register(&creator, &contract_addr); + + let events = env.events().all(); + let mut found = false; + for event in events.iter() { + let topics = event.1; + if topics.len() >= 3 { + if let Ok(t0) = Symbol::try_from_val(&env, &topics.get_unchecked(0)) { + if t0 == symbol_short!("registry") { + found = true; + break; + } + } + } + } + assert!(found, "ContractRegistered event not emitted"); + } + + #[test] + fn test_empty_get_contracts() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let registry_id = env.register(Registry, ()); + let registry_client = RegistryClient::new(&env, ®istry_id); + registry_client.initialize(&admin); + + let creator = Address::generate(&env); + assert_eq!(registry_client.get_contracts(&creator).len(), 0); + assert_eq!(registry_client.get_contract_count(&creator), 0); + } + + #[test] + fn test_is_registered() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let registry_id = env.register(Registry, ()); + let registry_client = RegistryClient::new(&env, ®istry_id); + registry_client.initialize(&admin); + + let creator = Address::generate(&env); + let contract_addr = Address::generate(&env); + + assert!(!registry_client.is_registered(&creator, &contract_addr)); + registry_client.register(&creator, &contract_addr); + assert!(registry_client.is_registered(&creator, &contract_addr)); + } +} From bdb0f926a9d7941123f852e6111bf313da413ac6 Mon Sep 17 00:00:00 2001 From: SunDrive Auto Date: Tue, 28 Jul 2026 16:03:16 +0000 Subject: [PATCH 3/4] feat: add metadata_hash to InvoiceCore for off-chain metadata references Store a 32-byte hash (IPFS CID / SHA-256) with each invoice so clients can retrieve extended details without inflating on-chain storage. - Add metadata_hash: Option> to InvoiceCore struct - Add field to Invoice struct and invoice assembly/disassembly - Populate metadata_hash in load_invoice from persistent storage - create_invoice already accepts and stores optional 32-byte hash - get_invoice now returns the metadata_hash field via InvoiceCore - update_metadata_hash rejects updates on Finalised/Cancelled invoices - MetadataHashUpdated event already emitted on successful update Issue: Metadata Hash --- contracts/split/src/lib.rs | 30 ++++++++++++++++++++++++++++++ contracts/split/src/types.rs | 7 +++++++ 2 files changed, 37 insertions(+) diff --git a/contracts/split/src/lib.rs b/contracts/split/src/lib.rs index 3a9c2b1..6425f97 100644 --- a/contracts/split/src/lib.rs +++ b/contracts/split/src/lib.rs @@ -882,6 +882,25 @@ fn refunded_key(invoice_id: u64) -> (Symbol, u64) { (symbol_short!("refunded"), invoice_id) } +// --------------------------------------------------------------------------- +// Event sequence number helper (per-invoice, temporary-storage counter) +// --------------------------------------------------------------------------- + +/// Temporary-storage key for per-invoice event sequence counter. +/// Lives in `storage::temporary` so it resets between transactions. +fn event_seq_key(invoice_id: u64) -> (Symbol, u64) { + (symbol_short!("evt_seq"), invoice_id) +} + +/// Fetch and increment the per-invoice event sequence counter. +/// Returns the new (post-increment) sequence number, starting at 1. +pub(crate) fn event_seq(env: &Env, invoice_id: u64) -> u64 { + let key = event_seq_key(invoice_id); + let seq: u64 = env.storage().temporary().get(&key).unwrap_or(0) + 1; + env.storage().temporary().set(&key, &seq); + seq +} + // --------------------------------------------------------------------------- // Reentrancy guard (issue #451-reentrancy) // --------------------------------------------------------------------------- @@ -1599,6 +1618,12 @@ fn load_invoice(env: &Env, id: u64) -> Invoice { invoice.recipients = hot.recipients; } + // Populate metadata_hash from its separate storage key. + invoice.metadata_hash = env + .storage() + .persistent() + .get(&metadata_hash_key(id)); + invoice } @@ -7377,6 +7402,11 @@ impl SplitContract { invoice.creator == creator, "only creator can update metadata hash" ); + // Reject updates on finalised or cancelled invoices. + assert!( + invoice.status != InvoiceStatus::Released && invoice.status != InvoiceStatus::Cancelled, + "cannot update metadata hash on finalised or cancelled invoice" + ); let old_hash: Option> = env .storage() diff --git a/contracts/split/src/types.rs b/contracts/split/src/types.rs index 08e9ae5..18e064b 100644 --- a/contracts/split/src/types.rs +++ b/contracts/split/src/types.rs @@ -498,6 +498,8 @@ pub struct InvoiceCore { pub released_bps: u32, pub clone_depth: u32, pub predecessor_id: Option, + /// Issue #329: optional IPFS CID / SHA-256 hash of off-chain invoice metadata. + pub metadata_hash: Option>, } #[contracttype] @@ -784,6 +786,8 @@ pub struct Invoice { /// Issue #420: creator-configurable overfunding behaviour. pub overfunding_policy: OverfundingPolicy, pub predecessor_id: Option, + /// Issue #329: optional IPFS CID / SHA-256 hash of off-chain invoice metadata. + pub metadata_hash: Option>, /// Issue #485: optional contributor allowlist; when Some only listed addresses may call pay/contribute. pub contributor_allowlist: Option>, /// Issue #489: ledgers after creation during which contributions qualify @@ -825,6 +829,7 @@ impl Invoice { released_bps: self.released_bps, clone_depth: self.clone_depth, predecessor_id: self.predecessor_id, + metadata_hash: self.metadata_hash, }, InvoiceExt { co_signers: self.co_signers, @@ -932,6 +937,7 @@ impl Invoice { released_bps: core.released_bps, clone_depth: core.clone_depth, predecessor_id: core.predecessor_id, + metadata_hash: core.metadata_hash, co_signers: ext.co_signers, required_signatures: ext.required_signatures, signatures: ext.signatures, @@ -1250,6 +1256,7 @@ impl Invoice { // original overfunding semantics exactly. overfunding_policy: OverfundingPolicy::Cap, predecessor_id: None, + metadata_hash: None, contributor_allowlist: None, early_bird_window_ledgers: 0, early_bird_fee_bps: 0, From d9c21a2b4fda976b00128e55b048451a0d966a6c Mon Sep 17 00:00:00 2001 From: SunDrive Auto Date: Tue, 28 Jul 2026 16:03:25 +0000 Subject: [PATCH 4/4] feat: add per-invoice event sequence numbers for deduplication Each emitted event now carries a monotonically increasing event_seq field to allow indexers to detect and discard duplicates. - Add next_seq(env, invoice_id) helper using storage::temporary - Counter resets automatically between transactions - Sequence is scoped per invoice (independent counters) - Event data tuples append event_seq as the last field - Key invoice-scoped events updated: invoice_created, payment_received, payment_committed, milestone_released, surplus_claimed, invoice_released, invoice_refunded, condition_verified, invoice_expired, recipient_whitelisted, recipient_removed_from_whitelist, payer_refunded, recipient_added, split_adjusted, recipients_rebalanced, invoice_archived Issue: Event Sequence Numbers --- contracts/split/src/events.rs | 73 +++++++++++++++++++++++++---------- 1 file changed, 53 insertions(+), 20 deletions(-) diff --git a/contracts/split/src/events.rs b/contracts/split/src/events.rs index f149f58..bf3743a 100644 --- a/contracts/split/src/events.rs +++ b/contracts/split/src/events.rs @@ -1,9 +1,26 @@ use crate::types::{DisputeOutcome, InvoiceStatus, RepScore, TimelockAction}; use soroban_sdk::{contracttype, symbol_short, Address, BytesN, Env, String, Vec}; +// --------------------------------------------------------------------------- +// Event sequence helper (per-invoice, temporary-storage counter) +// --------------------------------------------------------------------------- + +/// Fetch and increment the per-invoice event sequence counter. +/// Lives in `storage::temporary` so it resets between transactions. +fn next_seq(env: &Env, invoice_id: u64) -> u64 { + let key = (symbol_short!("ev_seq"), invoice_id); + let seq: u64 = env.storage().temporary().get(&key).unwrap_or(0) + 1; + env.storage().temporary().set(&key, &seq); + seq +} + +// --------------------------------------------------------------------------- +// Events +// --------------------------------------------------------------------------- + /// Emitted when a new invoice is created. /// Topics: (split, created, invoice_id) -/// Data: (creator, total) +/// Data: (creator, total, event_seq) pub fn invoice_created( env: &Env, invoice_id: u64, @@ -11,72 +28,79 @@ pub fn invoice_created( total: i128, cross_chain_ref: &Option, ) { + let event_seq = next_seq(env, invoice_id); env.events().publish( (symbol_short!("split"), symbol_short!("created"), invoice_id), - (creator.clone(), total, cross_chain_ref.clone()), + (creator.clone(), total, cross_chain_ref.clone(), event_seq), ); } /// Emitted when a payment is received toward an invoice. /// Topics: (split, paid, invoice_id) -/// Data: (payer, amount) +/// Data: (payer, amount, event_seq) pub fn payment_received(env: &Env, invoice_id: u64, payer: &Address, amount: i128) { + let event_seq = next_seq(env, invoice_id); env.events().publish( (symbol_short!("split"), symbol_short!("paid"), invoice_id), - (payer.clone(), amount), + (payer.clone(), amount, event_seq), ); } pub fn payment_committed(env: &Env, invoice_id: u64, payer: &Address, commit_ledger: u32) { + let event_seq = next_seq(env, invoice_id); env.events().publish( (symbol_short!("split"), symbol_short!("pay_cmt"), invoice_id), - (payer.clone(), commit_ledger), + (payer.clone(), commit_ledger, event_seq), ); } pub fn milestone_released(env: &Env, invoice_id: u64, milestone_bps: u32, amount_released: i128) { + let event_seq = next_seq(env, invoice_id); env.events().publish( ( symbol_short!("split"), symbol_short!("mile_rel"), invoice_id, ), - (milestone_bps, amount_released), + (milestone_bps, amount_released, event_seq), ); } pub fn surplus_claimed(env: &Env, invoice_id: u64, payer: &Address, amount: i128) { + let event_seq = next_seq(env, invoice_id); env.events().publish( (symbol_short!("split"), symbol_short!("surplus"), invoice_id), - (payer.clone(), amount), + (payer.clone(), amount, event_seq), ); } /// Emitted when an invoice is fully funded and funds are released. /// Topics: (split, released, invoice_id) -/// Data: recipients +/// Data: (recipients, event_seq) pub fn invoice_released(env: &Env, invoice_id: u64, recipients: &Vec
) { + let event_seq = next_seq(env, invoice_id); env.events().publish( ( symbol_short!("split"), symbol_short!("released"), invoice_id, ), - recipients.clone(), + (recipients.clone(), event_seq), ); } /// Emitted when an invoice is refunded after deadline. /// Topics: (split, refunded, invoice_id) -/// Data: () +/// Data: (event_seq) pub fn invoice_refunded(env: &Env, invoice_id: u64) { + let event_seq = next_seq(env, invoice_id); env.events().publish( ( symbol_short!("split"), symbol_short!("refunded"), invoice_id, ), - (), + (event_seq,), ); } @@ -84,9 +108,10 @@ pub fn invoice_refunded(env: &Env, invoice_id: u64) { /// Topics: (split, cond_ok, invoice_id) /// Data: preimage_hash pub fn condition_verified(env: &Env, invoice_id: u64, preimage_hash: &BytesN<32>) { + let event_seq = next_seq(env, invoice_id); env.events().publish( (symbol_short!("split"), symbol_short!("cond_ok"), invoice_id), - preimage_hash.clone(), + (preimage_hash.clone(), event_seq), ); } @@ -94,9 +119,10 @@ pub fn condition_verified(env: &Env, invoice_id: u64, preimage_hash: &BytesN<32> /// Topics: (split, expired, invoice_id) /// Data: (deadline, funded) pub fn invoice_expired(env: &Env, invoice_id: u64, deadline: u64, funded: i128) { + let event_seq = next_seq(env, invoice_id); env.events().publish( (symbol_short!("split"), symbol_short!("expired"), invoice_id), - (deadline, funded), + (deadline, funded, event_seq), ); } @@ -104,9 +130,10 @@ pub fn invoice_expired(env: &Env, invoice_id: u64, deadline: u64, funded: i128) /// Topics: (split, rcp_wl, invoice_id) /// Data: address pub fn recipient_whitelisted(env: &Env, invoice_id: u64, address: &Address) { + let event_seq = next_seq(env, invoice_id); env.events().publish( (symbol_short!("split"), symbol_short!("rcp_wl"), invoice_id), - address.clone(), + (address.clone(), event_seq), ); } @@ -114,9 +141,10 @@ pub fn recipient_whitelisted(env: &Env, invoice_id: u64, address: &Address) { /// Topics: (split, rcp_rl, invoice_id) /// Data: address pub fn recipient_removed_from_whitelist(env: &Env, invoice_id: u64, address: &Address) { + let event_seq = next_seq(env, invoice_id); env.events().publish( (symbol_short!("split"), symbol_short!("rcp_rl"), invoice_id), - address.clone(), + (address.clone(), event_seq), ); } @@ -138,9 +166,10 @@ pub fn rebate_accrued(env: &Env, creator: &Address, amount: i128, tier_bps: u32) /// Topics: (split, pay_ref, invoice_id) /// Data: (payer, amount) pub fn payer_refunded(env: &Env, invoice_id: u64, payer: &Address, amount: i128) { + let event_seq = next_seq(env, invoice_id); env.events().publish( (symbol_short!("split"), symbol_short!("pay_ref"), invoice_id), - (payer.clone(), amount), + (payer.clone(), amount, event_seq), ); } @@ -148,9 +177,10 @@ pub fn payer_refunded(env: &Env, invoice_id: u64, payer: &Address, amount: i128) /// Topics: (split, add_rec, invoice_id) /// Data: (recipient, amount) pub fn recipient_added(env: &Env, invoice_id: u64, recipient: &Address, amount: i128) { + let event_seq = next_seq(env, invoice_id); env.events().publish( (symbol_short!("split"), symbol_short!("add_rec"), invoice_id), - (recipient.clone(), amount), + (recipient.clone(), amount, event_seq), ); } @@ -158,9 +188,10 @@ pub fn recipient_added(env: &Env, invoice_id: u64, recipient: &Address, amount: /// Topics: (split, adj_spl, invoice_id) /// Data: creator pub fn split_adjusted(env: &Env, invoice_id: u64, creator: &Address) { + let event_seq = next_seq(env, invoice_id); env.events().publish( (symbol_short!("split"), symbol_short!("adj_spl"), invoice_id), - creator.clone(), + (creator.clone(), event_seq), ); } @@ -174,13 +205,14 @@ pub fn recipients_rebalanced( removed_address: &Address, redistributed_amount: i128, ) { + let event_seq = next_seq(env, invoice_id); env.events().publish( ( symbol_short!("split"), symbol_short!("rebalance"), invoice_id, ), - (removed_address.clone(), redistributed_amount), + (removed_address.clone(), redistributed_amount, event_seq), ); } @@ -188,13 +220,14 @@ pub fn recipients_rebalanced( /// Topics: (split, archived, invoice_id) /// Data: () pub fn invoice_archived(env: &Env, invoice_id: u64) { + let event_seq = next_seq(env, invoice_id); env.events().publish( ( symbol_short!("split"), symbol_short!("archived"), invoice_id, ), - (), + (event_seq,), ); }