From 315381e5f93e69a6eadbaa9a86eea0c2a4cfd1a3 Mon Sep 17 00:00:00 2001 From: m1s0g1 Date: Wed, 29 Apr 2026 00:33:23 +0000 Subject: [PATCH 1/4] chore: adjusting the setup --- docs/storage.md | 4 +++- package-lock.json | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/storage.md b/docs/storage.md index 2715c68b..3a7259a5 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -198,4 +198,6 @@ When migrating `CarrierWhitelist`, `UserRole`, `Role`, and `ActiveShipmentCount` 1. TTL extension is called for these keys when a shipment is created or updated for that company/carrier, otherwise they may expire during a long-inactive period. 2. Existing data in instance storage will not be automatically migrated — a one-time migration function or re-registration of all roles will be needed on upgrade. -3. All 271 existing tests must be re-run after any migration to confirm no regression. \ No newline at end of file +3. All 271 existing tests must be re-run after any migration to confirm no regression. + +// Starting the changes \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 9eb958a9..c71befb2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "navin-contracts--fork", + "name": "navin-contracts", "lockfileVersion": 3, "requires": true, "packages": {} From a1e10d820bac0d1f0e81162a833e6d94df661aac Mon Sep 17 00:00:00 2001 From: m1s0g1 Date: Sat, 30 May 2026 10:31:33 +0100 Subject: [PATCH 2/4] feat: add shipment validation and token support --- contracts/shipment/src/error_map.rs | 36 ++++ contracts/shipment/src/errors.rs | 12 ++ contracts/shipment/src/events.rs | 29 ++- contracts/shipment/src/lib.rs | 227 ++++++++++++++-------- contracts/shipment/src/storage.rs | 25 +++ contracts/shipment/src/types.rs | 5 + contracts/shipment/src/validation.rs | 279 ++++++++++++++++++++++++++- 7 files changed, 527 insertions(+), 86 deletions(-) diff --git a/contracts/shipment/src/error_map.rs b/contracts/shipment/src/error_map.rs index 6efed72d..a3a97bac 100644 --- a/contracts/shipment/src/error_map.rs +++ b/contracts/shipment/src/error_map.rs @@ -380,6 +380,42 @@ pub fn error_info(error: NavinError) -> ContractErrorInfo { NoRetry, "Proposal salt was already used in a prior proposal; replay attack prevented.", ), + NavinError::InvalidShipmentParticipants => ( + 57, + InvalidInput, + NoRetry, + "Shipment sender, receiver, and carrier must be three distinct addresses.", + ), + NavinError::InvalidShipmentDeadline => ( + 58, + InvalidInput, + NoRetry, + "Shipment deadline must be strictly in the future.", + ), + NavinError::InvalidPaymentMilestones => ( + 59, + InvalidInput, + NoRetry, + "Payment milestone structure is invalid; each percentage must be 1-100.", + ), + NavinError::DuplicatePaymentMilestone => ( + 60, + InvalidInput, + NoRetry, + "Payment milestone checkpoint names must be unique.", + ), + NavinError::InvalidTokenAddress => ( + 61, + InvalidInput, + NoRetry, + "Shipment token address is invalid for this shipment.", + ), + NavinError::InvalidPaymentMilestoneName => ( + 62, + InvalidInput, + NoRetry, + "Payment milestone checkpoint name has an invalid format.", + ), }; ContractErrorInfo { diff --git a/contracts/shipment/src/errors.rs b/contracts/shipment/src/errors.rs index ca9119ea..251802a5 100644 --- a/contracts/shipment/src/errors.rs +++ b/contracts/shipment/src/errors.rs @@ -127,4 +127,16 @@ pub enum NavinError { CircularDependency = 55, /// Proposal salt was already used in a prior proposal; replay attack prevented. ProposalSaltReused = 56, + /// Shipment sender, receiver, and carrier addresses must be distinct. + InvalidShipmentParticipants = 57, + /// Shipment deadline must be strictly in the future. + InvalidShipmentDeadline = 58, + /// Payment milestone list is malformed or contains invalid percentages. + InvalidPaymentMilestones = 59, + /// Payment milestone checkpoint names must be unique. + DuplicatePaymentMilestone = 60, + /// Shipment token address is invalid. + InvalidTokenAddress = 61, + /// Payment milestone checkpoint name has an invalid format. + InvalidPaymentMilestoneName = 62, } diff --git a/contracts/shipment/src/events.rs b/contracts/shipment/src/events.rs index 0f16c7f7..45c8acf1 100644 --- a/contracts/shipment/src/events.rs +++ b/contracts/shipment/src/events.rs @@ -105,6 +105,7 @@ pub fn emit_shipment_created( shipment_id: u64, sender: &Address, receiver: &Address, + token_address: &Address, data_hash: &BytesN<32>, ) { let event_counter = next_event_counter(env, shipment_id); @@ -121,6 +122,7 @@ pub fn emit_shipment_created( shipment_id, sender.clone(), receiver.clone(), + token_address.clone(), data_hash.clone(), EVENT_SCHEMA_VERSION, event_counter, @@ -285,7 +287,13 @@ pub fn emit_milestone_recorded( /// // events::emit_escrow_deposited(&env, 1, &company_addr, 1000); /// ``` #[allow(dead_code)] -pub fn emit_escrow_deposited(env: &Env, shipment_id: u64, from: &Address, amount: i128) { +pub fn emit_escrow_deposited( + env: &Env, + shipment_id: u64, + from: &Address, + token_address: &Address, + amount: i128, +) { let event_counter = next_event_counter(env, shipment_id); let idempotency_key = generate_idempotency_key( env, @@ -299,6 +307,7 @@ pub fn emit_escrow_deposited(env: &Env, shipment_id: u64, from: &Address, amount ( shipment_id, from.clone(), + token_address.clone(), amount, EVENT_SCHEMA_VERSION, event_counter, @@ -336,7 +345,13 @@ pub fn emit_escrow_deposited(env: &Env, shipment_id: u64, from: &Address, amount /// ```rust /// // events::emit_escrow_released(&env, 1, &carrier_addr, 1000); /// ``` -pub fn emit_escrow_released(env: &Env, shipment_id: u64, to: &Address, amount: i128) { +pub fn emit_escrow_released( + env: &Env, + shipment_id: u64, + to: &Address, + token_address: &Address, + amount: i128, +) { let event_counter = next_event_counter(env, shipment_id); let idempotency_key = generate_idempotency_key( env, @@ -350,6 +365,7 @@ pub fn emit_escrow_released(env: &Env, shipment_id: u64, to: &Address, amount: i ( shipment_id, to.clone(), + token_address.clone(), amount, EVENT_SCHEMA_VERSION, event_counter, @@ -387,7 +403,13 @@ pub fn emit_escrow_released(env: &Env, shipment_id: u64, to: &Address, amount: i /// ```rust /// // events::emit_escrow_refunded(&env, 1, &company_addr, 1000); /// ``` -pub fn emit_escrow_refunded(env: &Env, shipment_id: u64, to: &Address, amount: i128) { +pub fn emit_escrow_refunded( + env: &Env, + shipment_id: u64, + to: &Address, + token_address: &Address, + amount: i128, +) { let event_counter = next_event_counter(env, shipment_id); let idempotency_key = generate_idempotency_key( env, @@ -401,6 +423,7 @@ pub fn emit_escrow_refunded(env: &Env, shipment_id: u64, to: &Address, amount: i ( shipment_id, to.clone(), + token_address.clone(), amount, EVENT_SCHEMA_VERSION, event_counter, diff --git a/contracts/shipment/src/lib.rs b/contracts/shipment/src/lib.rs index d4866225..80212641 100644 --- a/contracts/shipment/src/lib.rs +++ b/contracts/shipment/src/lib.rs @@ -1,4 +1,7 @@ #![no_std] +#![allow(clippy::too_many_arguments)] + +extern crate alloc; use soroban_sdk::{ contract, contractimpl, symbol_short, xdr::ToXdr, Address, BytesN, Env, IntoVal, Map, Symbol, @@ -132,26 +135,6 @@ fn extend_shipment_ttl_cached(env: &Env, shipment_id: u64, threshold: u32, exten storage::extend_shipment_ttl(env, shipment_id, threshold, extension); } -fn validate_milestones(env: &Env, milestones: &Vec<(Symbol, u32)>) -> Result<(), NavinError> { - if milestones.is_empty() { - return Ok(()); - } - - // Validate all milestone symbols for bounded usage - validation::validate_milestone_symbols(env, milestones)?; - - let mut total_percentage = 0; - for milestone in milestones.iter() { - total_percentage += milestone.1; - } - - if total_percentage != 100 { - return Err(NavinError::MilestoneSumInvalid); - } - - Ok(()) -} - fn persist_shipment(env: &Env, shipment: &Shipment) -> Result<(), NavinError> { validation::validate_shipment_invariants(shipment)?; storage::set_shipment(env, shipment); @@ -399,8 +382,7 @@ fn internal_release_escrow( }; if actual_release > 0 { - // Get token contract address - let token_contract = storage::get_token_contract(env).ok_or(NavinError::NotInitialized)?; + let token_contract = shipment.token_address.clone(); let contract_address = env.current_contract_address(); // Calculate penalty if delivery is late @@ -454,7 +436,13 @@ fn internal_release_escrow( persist_shipment(env, shipment)?; storage::set_escrow(env, shipment.id, shipment.escrow_amount); - events::emit_escrow_released(env, shipment.id, &shipment.carrier, carrier_amount); + events::emit_escrow_released( + env, + shipment.id, + &shipment.carrier, + &token_contract, + carrier_amount, + ); // Emit penalty event only for full releases (not for milestone releases) // Note: on-time delivery event is emitted by confirm_delivery, not here @@ -646,6 +634,7 @@ fn require_carrier_for_shipment( #[contract] pub struct NavinShipment; +#[allow(clippy::too_many_arguments)] #[contractimpl] impl NavinShipment { /// Set metadata key-value pair for a shipment. Only Company (sender) or Admin can set. @@ -1916,13 +1905,49 @@ impl NavinShipment { payment_milestones: Vec<(Symbol, u32)>, deadline: u64, depends_on: Option>, + ) -> Result { + require_initialized(&env)?; + let token_address = storage::get_token_contract(&env).ok_or(NavinError::NotInitialized)?; + Self::create_shipment_with_token( + env, + sender, + receiver, + carrier, + data_hash, + payment_milestones, + deadline, + token_address, + depends_on, + ) + } + + /// Create a shipment with an explicit token contract for escrow. + #[allow(clippy::too_many_arguments)] + pub fn create_shipment_with_token( + env: Env, + sender: Address, + receiver: Address, + carrier: Address, + data_hash: BytesN<32>, + payment_milestones: Vec<(Symbol, u32)>, + deadline: u64, + token_address: Address, + depends_on: Option>, ) -> Result { require_initialized(&env)?; require_not_paused(&env)?; sender.require_auth(); require_role(&env, &sender, Role::Company)?; - validate_milestones(&env, &payment_milestones)?; - validate_hash(&data_hash)?; + let input = ShipmentInput { + receiver: receiver.clone(), + carrier: carrier.clone(), + token_address: token_address.clone(), + data_hash: data_hash.clone(), + payment_milestones: payment_milestones.clone(), + deadline, + depends_on: depends_on.clone(), + }; + validation::validate_shipment_input(&env, &sender, &input)?; // Idempotency: reject duplicate (sender, data_hash) within the window. let mut payload = soroban_sdk::Bytes::new(&env); @@ -1931,9 +1956,6 @@ impl NavinShipment { check_idempotency(&env, payload)?; let now = env.ledger().timestamp(); - if deadline <= now { - return Err(NavinError::InvalidTimestamp); - } // Check company active shipment limit let current_active = storage::get_active_shipment_count(&env, &sender); @@ -1956,6 +1978,7 @@ impl NavinShipment { sender: sender.clone(), receiver: receiver.clone(), carrier, + token_address: token_address.clone(), data_hash: data_hash.clone(), status: ShipmentStatus::Created, created_at: now, @@ -1973,6 +1996,7 @@ impl NavinShipment { }; persist_shipment(&env, &shipment)?; + storage::set_shipment_token(&env, shipment_id, &token_address); if let Some(dependencies) = depends_on { storage::set_dependencies(&env, shipment_id, &dependencies); for dep_id in dependencies.iter() { @@ -1984,7 +2008,14 @@ impl NavinShipment { storage::increment_active_shipment_count(&env, &sender); extend_shipment_ttl(&env, shipment_id); - events::emit_shipment_created(&env, shipment_id, &sender, &receiver, &data_hash); + events::emit_shipment_created( + &env, + shipment_id, + &sender, + &receiver, + &token_address, + &data_hash, + ); events::emit_notification( &env, &receiver, @@ -2082,15 +2113,7 @@ impl NavinShipment { } for shipment_input in shipments.iter() { - if shipment_input.receiver == shipment_input.carrier { - return Err(NavinError::InvalidShipmentInput); - } - validate_milestones(&env, &shipment_input.payment_milestones)?; - validate_hash(&shipment_input.data_hash)?; - - if shipment_input.deadline <= now { - return Err(NavinError::InvalidTimestamp); - } + validation::validate_shipment_input(&env, &sender, &shipment_input)?; let shipment_id = storage::get_shipment_counter(&env) .checked_add(1) @@ -2103,6 +2126,7 @@ impl NavinShipment { sender: sender.clone(), receiver: shipment_input.receiver.clone(), carrier: shipment_input.carrier.clone(), + token_address: shipment_input.token_address.clone(), data_hash: shipment_input.data_hash.clone(), status: ShipmentStatus::Created, created_at: now, @@ -2120,6 +2144,7 @@ impl NavinShipment { }; persist_shipment(&env, &shipment)?; + storage::set_shipment_token(&env, shipment_id, &shipment_input.token_address); storage::set_shipment_counter(&env, shipment_id); storage::increment_status_count(&env, &ShipmentStatus::Created); storage::increment_active_shipment_count(&env, &sender); @@ -2136,6 +2161,7 @@ impl NavinShipment { shipment_id, &sender, &shipment_input.receiver, + &shipment_input.token_address, &shipment_input.data_hash, ); events::emit_notification( @@ -2180,6 +2206,14 @@ impl NavinShipment { storage::get_shipment(&env, shipment_id).ok_or(NavinError::ShipmentNotFound) } + /// Return the immutable token contract address assigned to a shipment. + pub fn get_shipment_token(env: Env, shipment_id: u64) -> Result { + require_initialized(&env)?; + let shipment = + storage::get_shipment(&env, shipment_id).ok_or(NavinError::ShipmentNotFound)?; + Ok(storage::get_shipment_token(&env, shipment_id).unwrap_or(shipment.token_address)) + } + /// Retrieve the immutable creator identity for a shipment. /// /// Set a deadline for a shipment. Only the Company (sender) or admin can set the deadline. @@ -2446,9 +2480,7 @@ impl NavinShipment { return Err(NavinError::EscrowLocked); } - // Get token contract address - let token_contract = - storage::get_token_contract(&env).ok_or(NavinError::NotInitialized)?; + let token_contract = shipment.token_address.clone(); // Validate that the token uses 7 decimal places (Stellar standard). // This prevents silent amount mismatches for non-standard tokens. @@ -2507,7 +2539,13 @@ impl NavinShipment { storage::add_total_escrow_volume(&env, amount)?; extend_shipment_ttl(&env, shipment_id); - events::emit_escrow_deposited(&env, shipment_id, &from, net_amount); + events::emit_escrow_deposited( + &env, + shipment_id, + &from, + &token_contract, + net_amount, + ); } Err(e) => { fail_settlement(&env, settlement_id, shipment_id, e as u32)?; @@ -3936,7 +3974,13 @@ impl NavinShipment { if escrow_amount > 0 { storage::remove_escrow_balance(&env, shipment_id); - events::emit_escrow_released(&env, shipment_id, &shipment.sender, escrow_amount); + events::emit_escrow_released( + &env, + shipment_id, + &shipment.sender, + &shipment.token_address, + escrow_amount, + ); } finalize_if_settled(&env, &mut shipment); persist_shipment(&env, &shipment)?; @@ -4020,8 +4064,7 @@ impl NavinShipment { // Deterministic escrow refund: always refund to company if escrow is held. if escrow_amount > 0 { - let token_contract = - storage::get_token_contract(&env).ok_or(NavinError::NotInitialized)?; + let token_contract = shipment.token_address.clone(); let contract_address = env.current_contract_address(); invoke_token_transfer( &env, @@ -4032,7 +4075,13 @@ impl NavinShipment { )?; shipment.escrow_amount = 0; - events::emit_escrow_refunded(&env, shipment_id, &shipment.sender, escrow_amount); + events::emit_escrow_refunded( + &env, + shipment_id, + &shipment.sender, + &token_contract, + escrow_amount, + ); } shipment.status = ShipmentStatus::Cancelled; @@ -4319,9 +4368,7 @@ impl NavinShipment { return Err(NavinError::InsufficientFunds); } - // Get token contract address - let token_contract = - storage::get_token_contract(&env).ok_or(NavinError::NotInitialized)?; + let token_contract = shipment.token_address.clone(); let contract_address = env.current_contract_address(); @@ -4373,7 +4420,13 @@ impl NavinShipment { extend_shipment_ttl(&env, shipment_id); extend_shipment_ttl(&env, shipment_id); - events::emit_escrow_refunded(&env, shipment_id, &shipment.sender, escrow_amount); + events::emit_escrow_refunded( + &env, + shipment_id, + &shipment.sender, + &token_contract, + escrow_amount, + ); Ok(()) }) @@ -4534,7 +4587,7 @@ impl NavinShipment { let sender_refund = checked_mul_div_i128(escrow_balance, refund_percentage as i128, 100)?; let carrier_compensation = escrow_balance - sender_refund; - let token_contract = storage::get_token_contract(&env).ok_or(NavinError::NotInitialized)?; + let token_contract = shipment.token_address.clone(); let contract_address = env.current_contract_address(); invoke_token_transfer( @@ -4774,6 +4827,7 @@ impl NavinShipment { if escrow_amount == 0 { return Err(NavinError::InsufficientFunds); } + let token_contract = shipment.token_address.clone(); shipment.escrow_amount = 0; shipment.updated_at = env.ledger().timestamp(); @@ -4801,10 +4855,22 @@ impl NavinShipment { match resolution { DisputeResolution::ReleaseToCarrier => { - events::emit_escrow_released(&env, shipment_id, &recipient, escrow_amount); + events::emit_escrow_released( + &env, + shipment_id, + &recipient, + &token_contract, + escrow_amount, + ); } DisputeResolution::RefundToCompany => { - events::emit_escrow_refunded(&env, shipment_id, &recipient, escrow_amount); + events::emit_escrow_refunded( + &env, + shipment_id, + &recipient, + &token_contract, + escrow_amount, + ); // Reputation: carrier lost this dispute events::emit_carrier_dispute_loss(&env, &shipment.carrier, shipment_id); } @@ -5422,18 +5488,15 @@ impl NavinShipment { let escrow_amount = shipment.escrow_amount; if escrow_amount > 0 { - // Get token contract address - if let Some(token_contract) = storage::get_token_contract(&env) { - // Transfer tokens from this contract to carrier - let contract_address = env.current_contract_address(); - invoke_token_transfer( - &env, - &token_contract, - &contract_address, - &shipment.carrier, - escrow_amount, - )?; - } + let token_contract = shipment.token_address.clone(); + let contract_address = env.current_contract_address(); + invoke_token_transfer( + &env, + &token_contract, + &contract_address, + &shipment.carrier, + escrow_amount, + )?; shipment.escrow_amount = 0; shipment.updated_at = env.ledger().timestamp(); @@ -5444,6 +5507,7 @@ impl NavinShipment { &env, shipment_id, &shipment.carrier, + &token_contract, escrow_amount, ); } @@ -5454,18 +5518,15 @@ impl NavinShipment { let escrow_amount = shipment.escrow_amount; if escrow_amount > 0 { - // Get token contract address - if let Some(token_contract) = storage::get_token_contract(&env) { - // Transfer tokens from this contract to company - let contract_address = env.current_contract_address(); - invoke_token_transfer( - &env, - &token_contract, - &contract_address, - &shipment.sender, - escrow_amount, - )?; - } + let token_contract = shipment.token_address.clone(); + let contract_address = env.current_contract_address(); + invoke_token_transfer( + &env, + &token_contract, + &contract_address, + &shipment.sender, + escrow_amount, + )?; shipment.escrow_amount = 0; shipment.updated_at = env.ledger().timestamp(); @@ -5476,6 +5537,7 @@ impl NavinShipment { &env, shipment_id, &shipment.sender, + &token_contract, escrow_amount, ); } @@ -5685,8 +5747,7 @@ impl NavinShipment { if escrow_amount > 0 { storage::remove_escrow_balance(&env, shipment_id); - let token_contract = - storage::get_token_contract(&env).ok_or(NavinError::NotInitialized)?; + let token_contract = shipment.token_address.clone(); let contract_address = env.current_contract_address(); invoke_token_transfer( &env, @@ -5695,7 +5756,13 @@ impl NavinShipment { &shipment.sender, escrow_amount, )?; - events::emit_escrow_refunded(&env, shipment_id, &shipment.sender, escrow_amount); + events::emit_escrow_refunded( + &env, + shipment_id, + &shipment.sender, + &token_contract, + escrow_amount, + ); } extend_shipment_ttl(&env, shipment_id); diff --git a/contracts/shipment/src/storage.rs b/contracts/shipment/src/storage.rs index 8c535e62..88c31175 100644 --- a/contracts/shipment/src/storage.rs +++ b/contracts/shipment/src/storage.rs @@ -612,6 +612,24 @@ pub fn set_escrow(env: &Env, shipment_id: u64, amount: i128) { .set(&DataKey::Escrow(shipment_id), &amount); } +/// Get the immutable token contract address for a shipment. +/// +/// This mirrors the documented storage key pattern +/// `(Symbol("shipment_token"), shipment_id) -> Address` using the typed +/// `DataKey::ShipmentToken(shipment_id)` key. +pub fn get_shipment_token(env: &Env, shipment_id: u64) -> Option
{ + env.storage() + .persistent() + .get(&DataKey::ShipmentToken(shipment_id)) +} + +/// Store the immutable token contract address for a shipment. +pub fn set_shipment_token(env: &Env, shipment_id: u64, token_address: &Address) { + env.storage() + .persistent() + .set(&DataKey::ShipmentToken(shipment_id), token_address); +} + /// Get the latest escrow freeze reason code for a shipment. /// /// # Arguments @@ -808,6 +826,13 @@ pub fn extend_shipment_ttl(env: &Env, shipment_id: u64, threshold: u32, extend_t .extend_ttl(&escrow_key, threshold, extend_to); } + let token_key = DataKey::ShipmentToken(shipment_id); + if env.storage().persistent().has(&token_key) { + env.storage() + .persistent() + .extend_ttl(&token_key, threshold, extend_to); + } + let hash_key = DataKey::ConfirmationHash(shipment_id); if env.storage().persistent().has(&hash_key) { env.storage() diff --git a/contracts/shipment/src/types.rs b/contracts/shipment/src/types.rs index 6395b377..6c66a99c 100644 --- a/contracts/shipment/src/types.rs +++ b/contracts/shipment/src/types.rs @@ -44,6 +44,8 @@ pub enum DataKey { RoleSuspended(Address, Role), /// Escrow balance for a shipment. Escrow(u64), + /// Token contract address for a specific shipment — (shipment_id) -> Address. + ShipmentToken(u64), /// Legacy single-role storage key for compatibility. Role(Address), /// Hash of proof-of-delivery data for a shipment. @@ -326,6 +328,8 @@ pub struct Shipment { pub receiver: Address, /// Carrier responsible for transport. pub carrier: Address, + /// Token contract used for this shipment's escrow operations. + pub token_address: Address, /// Current status in the shipment lifecycle. pub status: ShipmentStatus, /// SHA-256 hash of the off-chain shipment data. @@ -539,6 +543,7 @@ pub enum GeofenceEvent { pub struct ShipmentInput { pub receiver: Address, pub carrier: Address, + pub token_address: Address, pub data_hash: BytesN<32>, pub payment_milestones: Vec<(Symbol, u32)>, pub deadline: u64, diff --git a/contracts/shipment/src/validation.rs b/contracts/shipment/src/validation.rs index b67ba343..3ad2bc2e 100644 --- a/contracts/shipment/src/validation.rs +++ b/contracts/shipment/src/validation.rs @@ -1,7 +1,9 @@ +use alloc::string::ToString; + use crate::errors::NavinError; use crate::storage; -use crate::types::{Shipment, ShipmentStatus}; -use soroban_sdk::{xdr::ToXdr, BytesN, Env, Symbol}; +use crate::types::{Shipment, ShipmentInput, ShipmentStatus}; +use soroban_sdk::{xdr::ToXdr, Address, BytesN, Env, Symbol}; /// Maximum reasonable escrow amount (1 quadrillion stroops ≈ 1 billion XLM). const MAX_AMOUNT: i128 = 1_000_000_000_000_000; @@ -110,7 +112,7 @@ pub fn validate_milestone_symbols( let other = &milestones.get_unchecked(j).0; let other_xdr = other.to_xdr(env); if current_xdr == other_xdr { - return Err(NavinError::InvalidShipmentInput); + return Err(NavinError::DuplicatePaymentMilestone); } } } @@ -118,6 +120,159 @@ pub fn validate_milestone_symbols( Ok(()) } +/// Validate the addresses involved in shipment creation. +/// +/// The sender, receiver, and carrier must be three distinct addresses so the +/// shipment does not collapse multiple parties into the same role. +pub fn validate_shipment_participants( + sender: &Address, + receiver: &Address, + carrier: &Address, +) -> Result<(), NavinError> { + if sender == receiver || sender == carrier || receiver == carrier { + return Err(NavinError::InvalidShipmentParticipants); + } + + Ok(()) +} + +/// Validate the symbol format used for checkpoint names. +/// +/// Symbols are already bounded by the Stellar symbol type, but this helper +/// adds an explicit format gate so checkpoint names remain readable and +/// machine-friendly: only ASCII letters, digits, and underscores are allowed. +pub fn validate_checkpoint_name_format(symbol: &Symbol) -> Result<(), NavinError> { + let checkpoint_name = symbol.to_string(); + if checkpoint_name + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '_') + { + Ok(()) + } else { + Err(NavinError::InvalidPaymentMilestoneName) + } +} + +/// Validate a shipment's payment milestone structure. +/// +/// This enforces the creation-time contract for milestone payments: +/// - every checkpoint name must pass symbol and format validation, +/// - checkpoint names must be unique, +/// - each percentage must be between 1 and 100, +/// - the percentages must sum to exactly 100 when milestones are present. +pub fn validate_payment_milestones( + env: &Env, + milestones: &soroban_sdk::Vec<(Symbol, u32)>, +) -> Result<(), NavinError> { + if milestones.is_empty() { + return Ok(()); + } + + validate_milestone_symbols(env, milestones)?; + + for milestone in milestones.iter() { + validate_symbol(env, &milestone.0)?; + validate_checkpoint_name_format(&milestone.0)?; + + if milestone.1 == 0 || milestone.1 > 100 { + return Err(NavinError::InvalidPaymentMilestones); + } + } + + let mut total_percentage = 0u32; + for milestone in milestones.iter() { + total_percentage = total_percentage + .checked_add(milestone.1) + .ok_or(NavinError::InvalidPaymentMilestones)?; + } + + if total_percentage != 100 { + return Err(NavinError::MilestoneSumInvalid); + } + + for i in 0..milestones.len() { + let current = &milestones.get_unchecked(i).0; + for j in (i + 1)..milestones.len() { + let other = &milestones.get_unchecked(j).0; + if current == other { + return Err(NavinError::DuplicatePaymentMilestone); + } + } + } + + Ok(()) +} + +/// Validate the deadline supplied at shipment creation time. +/// +/// Deadlines must be strictly in the future so newly-created shipments cannot +/// start in an already-expired state. +pub fn validate_shipment_deadline(env: &Env, deadline: u64) -> Result<(), NavinError> { + if deadline <= env.ledger().timestamp() { + return Err(NavinError::InvalidShipmentDeadline); + } + + Ok(()) +} + +/// Validate the shared inputs for shipment creation. +pub fn validate_shipment_creation_inputs( + env: &Env, + sender: &Address, + receiver: &Address, + carrier: &Address, + payment_milestones: &soroban_sdk::Vec<(Symbol, u32)>, + deadline: u64, +) -> Result<(), NavinError> { + validate_shipment_participants(sender, receiver, carrier)?; + validate_payment_milestones(env, payment_milestones)?; + validate_shipment_deadline(env, deadline)?; + Ok(()) +} + +/// Validate a shipment token address before it is stored on a shipment. +/// +/// Soroban addresses are typed and cannot be an empty string, so this guard +/// rejects the only sentinel value this contract can confuse for "unset": the +/// shipment contract's own address. Token contracts must be external contracts. +pub fn validate_token_address(env: &Env, token_address: &Address) -> Result<(), NavinError> { + if *token_address == env.current_contract_address() { + return Err(NavinError::InvalidTokenAddress); + } + Ok(()) +} + +/// Validate the full input payload used to create a shipment. +/// +/// Rules: +/// - `sender`, `receiver`, and `carrier` must be three distinct addresses. +/// - `data_hash` must be non-zero. +/// - `deadline` must be strictly greater than the current ledger timestamp. +/// - `token_address` must pass token-address validation. +/// - `payment_milestones` may be empty; when present each percentage must be +/// between 1 and 100 inclusive, all checkpoint names must be valid Symbols, +/// names must be unique, and percentages must sum exactly to 100. +/// +/// The function returns domain errors only and does not panic. +pub fn validate_shipment_input( + env: &Env, + sender: &Address, + input: &ShipmentInput, +) -> Result<(), NavinError> { + if *sender == input.receiver || *sender == input.carrier || input.receiver == input.carrier { + return Err(NavinError::InvalidShipmentParticipants); + } + + validate_hash(&input.data_hash)?; + validate_token_address(env, &input.token_address)?; + + if input.deadline <= env.ledger().timestamp() { + return Err(NavinError::InvalidShipmentDeadline); + } + + validate_payment_milestones(env, &input.payment_milestones) +} + /// Validate metadata key-value pair symbols for bounded usage. /// /// This validator ensures that both metadata keys and values conform to @@ -785,6 +940,7 @@ mod symbol_validation_tests { extern crate std; use super::*; + use soroban_sdk::testutils::Address as _; use soroban_sdk::{Env, Symbol, Vec}; // Boundary tests for symbol length @@ -989,6 +1145,123 @@ mod symbol_validation_tests { "Duplicate milestone should return InvalidShipmentInput" ); } + + #[test] + fn test_validate_shipment_input_accepts_valid_payload() { + let env = Env::default(); + let contract_id = env.register(crate::NavinShipment, ()); + let sender = soroban_sdk::Address::generate(&env); + let receiver = soroban_sdk::Address::generate(&env); + let carrier = soroban_sdk::Address::generate(&env); + let token_address = soroban_sdk::Address::generate(&env); + let deadline = env.ledger().timestamp() + 3_600; + + let mut payment_milestones = Vec::new(&env); + payment_milestones.push_back((Symbol::new(&env, "warehouse"), 50)); + payment_milestones.push_back((Symbol::new(&env, "delivery"), 50)); + + let input = ShipmentInput { + receiver, + carrier, + token_address, + data_hash: BytesN::from_array(&env, &[1u8; 32]), + payment_milestones, + deadline, + depends_on: None, + }; + + let result = env.as_contract(&contract_id, || { + validate_shipment_input(&env, &sender, &input) + }); + + assert_eq!(result, Ok(())); + } + + #[test] + fn test_validate_shipment_input_rejects_duplicate_participants() { + let env = Env::default(); + let contract_id = env.register(crate::NavinShipment, ()); + let sender = soroban_sdk::Address::generate(&env); + let carrier = soroban_sdk::Address::generate(&env); + let token_address = soroban_sdk::Address::generate(&env); + let deadline = env.ledger().timestamp() + 3_600; + + let input = ShipmentInput { + receiver: sender.clone(), + carrier, + token_address, + data_hash: BytesN::from_array(&env, &[2u8; 32]), + payment_milestones: Vec::new(&env), + deadline, + depends_on: None, + }; + + let result = env.as_contract(&contract_id, || { + validate_shipment_input(&env, &sender, &input) + }); + + assert_eq!(result, Err(NavinError::InvalidShipmentParticipants)); + } + + #[test] + fn test_validate_shipment_input_rejects_past_deadline() { + let env = Env::default(); + let contract_id = env.register(crate::NavinShipment, ()); + let sender = soroban_sdk::Address::generate(&env); + let receiver = soroban_sdk::Address::generate(&env); + let carrier = soroban_sdk::Address::generate(&env); + let token_address = soroban_sdk::Address::generate(&env); + let deadline = env.ledger().timestamp(); + + let input = ShipmentInput { + receiver, + carrier, + token_address, + data_hash: BytesN::from_array(&env, &[3u8; 32]), + payment_milestones: Vec::new(&env), + deadline, + depends_on: None, + }; + + let result = env.as_contract(&contract_id, || { + validate_shipment_input(&env, &sender, &input) + }); + + assert_eq!(result, Err(NavinError::InvalidShipmentDeadline)); + } + + #[test] + fn test_validate_payment_milestones_rejects_non_100_sum() { + let env = Env::default(); + let mut milestones = Vec::new(&env); + milestones.push_back((Symbol::new(&env, "warehouse"), 30)); + milestones.push_back((Symbol::new(&env, "delivery"), 60)); + + assert_eq!( + validate_payment_milestones(&env, &milestones), + Err(NavinError::MilestoneSumInvalid) + ); + } + + #[test] + fn test_validate_payment_milestones_accepts_exact_100_sum() { + let env = Env::default(); + let mut milestones = Vec::new(&env); + milestones.push_back((Symbol::new(&env, "warehouse"), 40)); + milestones.push_back((Symbol::new(&env, "delivery"), 60)); + + assert_eq!(validate_payment_milestones(&env, &milestones), Ok(())); + } + + #[test] + fn test_validate_token_address_rejects_contract_address() { + let env = Env::default(); + let contract_id = env.register(crate::NavinShipment, ()); + + let result = env.as_contract(&contract_id, || validate_token_address(&env, &contract_id)); + + assert_eq!(result, Err(NavinError::InvalidTokenAddress)); + } } /// Maximum number of dependencies allowed per shipment. From 49ba3d6af91fd6f096ba742059839fd2a2dc8f51 Mon Sep 17 00:00:00 2001 From: m1s0g1 Date: Sat, 30 May 2026 10:32:18 +0100 Subject: [PATCH 3/4] add test files for shipment and token support --- contracts/shipment/src/test_batch_queries.rs | 2 +- contracts/shipment/src/test_finalization.rs | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/contracts/shipment/src/test_batch_queries.rs b/contracts/shipment/src/test_batch_queries.rs index 58d3acdd..615047fc 100644 --- a/contracts/shipment/src/test_batch_queries.rs +++ b/contracts/shipment/src/test_batch_queries.rs @@ -191,7 +191,7 @@ fn test_batch_and_individual_reads_agree() { /// `get_shipment_count` must equal the number of shipments actually created, /// checked after each individual insert. #[test] -fn test_shipment_count_increments_after_each_create() { +fn test_shipment_count_increments_after_each_create_regression() { let (env, client, admin, token_contract) = setup_shipment_env(); let company = Address::generate(&env); diff --git a/contracts/shipment/src/test_finalization.rs b/contracts/shipment/src/test_finalization.rs index e831e93b..fce42299 100644 --- a/contracts/shipment/src/test_finalization.rs +++ b/contracts/shipment/src/test_finalization.rs @@ -614,6 +614,9 @@ fn test_archival_tests_cover_both_archived_and_active_states() { assert!(batch.get(0).unwrap().is_some()); assert!(batch.get(1).unwrap().is_some()); } + +#[test] +fn test_recovery_clear_finalization_unsets_finalized_flag() { let (env, client, admin, _) = setup_recovery_env(); let carrier = Address::generate(&env); client.add_carrier(&admin, &carrier); From 0143a8e053857ae1a883f5304f6418cb3932d191 Mon Sep 17 00:00:00 2001 From: m1s0g1 Date: Sat, 30 May 2026 10:43:33 +0100 Subject: [PATCH 4/4] test: fix shipment validation fixtures --- contracts/shipment/src/validation.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/contracts/shipment/src/validation.rs b/contracts/shipment/src/validation.rs index 3ad2bc2e..5358842c 100644 --- a/contracts/shipment/src/validation.rs +++ b/contracts/shipment/src/validation.rs @@ -668,6 +668,7 @@ mod tests { sender: soroban_sdk::Address::generate(&env), receiver: soroban_sdk::Address::generate(&env), carrier: soroban_sdk::Address::generate(&env), + token_address: soroban_sdk::Address::generate(&env), status: ShipmentStatus::InTransit, data_hash: BytesN::from_array(&env, &[1_u8; 32]), created_at: 100, @@ -695,6 +696,7 @@ mod tests { sender: soroban_sdk::Address::generate(&env), receiver: soroban_sdk::Address::generate(&env), carrier: soroban_sdk::Address::generate(&env), + token_address: soroban_sdk::Address::generate(&env), status: ShipmentStatus::InTransit, data_hash: BytesN::from_array(&env, &[2_u8; 32]), created_at: 100,