diff --git a/contracts/invoice-escrow/src/errors.rs b/contracts/invoice-escrow/src/errors.rs index 53efb91..d34102f 100644 --- a/contracts/invoice-escrow/src/errors.rs +++ b/contracts/invoice-escrow/src/errors.rs @@ -1,107 +1,309 @@ -//! Error types for the invoice escrow contract. -use soroban_sdk::contracterror; - -/// Errors that can occur during contract execution. -#[contracterror] -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -#[repr(u32)] -pub enum Error { - /// Contract has already been initialized. - AlreadyInit = 1, - /// Contract has not been initialized. - NotInit = 2, - /// Caller is not authorized (e.g. not admin). - Unauthorized = 3, - /// Amount is zero or negative. - InvalidAmount = 4, - /// Platform fee basis points exceed 10000 (100%). - InvalidFeeBps = 5, - /// No escrow exists for the given invoice. - EscrowNotFound = 6, - /// Escrow already exists for this invoice (duplicate create). - EscrowExists = 7, - /// Escrow has already been funded. - EscrowFunded = 8, - /// Escrow has not been funded yet. - EscrowNotFunded = 9, - /// Payment has already been settled or escrow refunded. - AlreadySettled = 10, - /// Refund not allowed (e.g. not past due date or wrong status). - RefundNotAllowed = 11, - /// Token transfer failed (e.g. insufficient balance). - TransferFailed = 12, - /// Arithmetic overflow or invalid operation. - Overflow = 13, - /// Escrow has been cancelled by the seller. - EscrowCancelled = 14, - /// Contract is paused and the requested operation is temporarily disabled. - Paused = 15, - /// Payer is not the authorized debtor for this invoice. - InvalidPayer = 16, - /// Due date is invalid (e.g., in the past or zero). - InvalidDueDate = 17, - /// Asset decimals for payment token and invoice token do not align. - InvalidAssetDecimals = 18, - /// Nonce has already been consumed by a prior signed off-chain approval (replay attempt). - NonceAlreadyUsed = 19, - /// Escrow is not yet in a terminal state (Settled, Refunded, or Cancelled) and cannot be cleaned up. - EscrowNotSettled = 20, - /// Buyer is not whitelisted to fund escrows. - NotWhitelisted = 21, - /// Off-chain signature has expired (timestamp too old). - SignatureExpired = 22, - /// Funding amount does not meet the required milestone threshold. - InvalidMilestoneAmount = 23, - /// Cannot cancel because escrow is not in the correct state. - CancelNotAllowed = 24, - /// Penalty configuration is invalid (e.g. rate exceeds maximum). - InvalidPenaltyConfig = 25, - /// Payment token contract is invalid or does not implement the token interface. - InvalidPaymentToken = 26, - /// Invoice token contract is invalid or does not implement the required interface. - InvalidInvoiceToken = 27, - /// Payment token and invoice token must be different contracts. - IdenticalTokens = 28, - /// Investor has no position for this invoice. - NoPositionFound = 29, - /// Invoice status does not allow this operation. - InvalidInvoiceStatus = 30, - /// Remaining position after withdrawal is below the minimum investment floor. - BelowMinimumInvestment = 31, - /// Funding target has not yet been reached. - FundingTargetNotReached = 32, - /// Deposit amount is zero (dust prevention: use a positive amount). - ZeroAmount = 29, - /// Deposit amount is below the configured minimum investment. - AmountBelowMinimum = 30, - /// Address is the zero address (all-zero 32-byte key). - InvalidAddress = 31, - /// Escrow duration is outside the allowed [MIN, MAX] window. - InvalidDuration = 32, - /// Caller is not a member of the emergency admin multi-sig set. - NotEmergencyAdmin = 33, - /// Caller has already approved this emergency release (duplicate). - AlreadyApproved = 34, - /// Emergency release threshold has not been reached yet. - ThresholdNotMet = 35, - /// Emergency multi-sig config has not been set. - EmergencyNotConfigured = 36, - /// Pagination limit is invalid (zero). - InvalidLimit = 37, - /// Pagination limit exceeds maximum allowed page size. - LimitExceeded = 38, - /// Invoice with the given ID already exists. - InvoiceAlreadyExists = 39, - /// Yield basis points is invalid (must be between 1 and 5000). - InvalidYield = 40, - /// Funding deadline has not passed yet. - FundingDeadlineNotPassed = 41, - /// Invoice status is invalid for the requested operation. - InvalidInvoiceStatus = 42, - /// No position found for the investor on this invoice. - NoPositionFound = 43, - /// Repayment amount is less than the total raised amount. - InsufficientRepayment = 44, - /// New funding deadline must be greater than the current deadline. - DeadlineNotExtended = 45, -} \ No newline at end of file +//! Error types for the invoice escrow contract. +use soroban_sdk::contracterror; + +/// Errors that can occur during contract execution. +#[contracterror] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum Error { + /// Contract has already been initialized. + AlreadyInit = 1, + /// Contract has not been initialized. + NotInit = 2, + /// Caller is not authorized (e.g. not admin). + Unauthorized = 3, + /// Amount is zero or negative. + InvalidAmount = 4, + /// Platform fee basis points exceed 10000 (100%). + InvalidFeeBps = 5, + /// No escrow exists for the given invoice. + EscrowNotFound = 6, + /// Escrow already exists for this invoice (duplicate create). + EscrowExists = 7, + /// Escrow has already been funded. + EscrowFunded = 8, + /// Escrow has not been funded yet. + EscrowNotFunded = 9, + /// Payment has already been settled or escrow refunded. + AlreadySettled = 10, + /// Refund not allowed (e.g. not past due date or wrong status). + RefundNotAllowed = 11, + /// Token transfer failed (e.g. insufficient balance). + TransferFailed = 12, + /// Arithmetic overflow or invalid operation. + Overflow = 13, + /// Escrow has been cancelled by the seller. + EscrowCancelled = 14, + /// Contract is paused and the requested operation is temporarily disabled. + Paused = 15, + /// Payer is not the authorized debtor for this invoice. + InvalidPayer = 16, + /// Due date is invalid (e.g., in the past or zero). + InvalidDueDate = 17, + /// Asset decimals for payment token and invoice token do not align. + InvalidAssetDecimals = 18, + /// Nonce has already been consumed by a prior signed off-chain approval (replay attempt). + NonceAlreadyUsed = 19, + /// Escrow is not yet in a terminal state (Settled, Refunded, or Cancelled) and cannot be cleaned up. + EscrowNotSettled = 20, + /// Buyer is not whitelisted to fund escrows. + NotWhitelisted = 21, + /// Off-chain signature has expired (timestamp too old). + SignatureExpired = 22, + /// Funding amount does not meet the required milestone threshold. + InvalidMilestoneAmount = 23, + /// Cannot cancel because escrow is not in the correct state. + CancelNotAllowed = 24, + /// Penalty configuration is invalid (e.g. rate exceeds maximum). + InvalidPenaltyConfig = 25, + /// Payment token contract is invalid or does not implement the token interface. + InvalidPaymentToken = 26, + /// Invoice token contract is invalid or does not implement the required interface. + InvalidInvoiceToken = 27, + /// Payment token and invoice token must be different contracts. + IdenticalTokens = 28, + /// Investor has no position for this invoice. + NoPositionFound = 29, + /// Invoice status does not allow this operation. + InvalidInvoiceStatus = 30, + /// Remaining position after withdrawal is below the minimum investment floor. + BelowMinimumInvestment = 31, + /// Maximum number of investors reached for this invoice. + MaxInvestorsReached = 32, + /// Funding target has not yet been reached. + FundingTargetNotReached = 33, + /// Deposit amount is zero (dust prevention: use a positive amount). + ZeroAmount = 33, + /// Deposit amount is below the configured minimum investment. + AmountBelowMinimum = 34, + /// Address is the zero address (all-zero 32-byte key). + InvalidAddress = 35, + /// Escrow duration is outside the allowed [MIN, MAX] window. + InvalidDuration = 36, + /// Caller is not a member of the emergency admin multi-sig set. + NotEmergencyAdmin = 37, + /// Caller has already approved this emergency release (duplicate). + AlreadyApproved = 38, + /// Emergency release threshold has not been reached yet. + ThresholdNotMet = 39, + /// Emergency multi-sig config has not been set. + EmergencyNotConfigured = 40, + /// Fee configuration is invalid (e.g. rate exceeds maximum). + FeeTooHigh = 43, + /// Pagination limit is invalid (zero). + InvalidLimit = 41, + /// Pagination limit exceeds maximum allowed page size. + LimitExceeded = 42, +} +//! Error types for the invoice escrow contract. +use soroban_sdk::contracterror; + +/// Errors that can occur during contract execution. +#[contracterror] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum Error { + /// Contract has already been initialized. + AlreadyInit = 1, + /// Contract has not been initialized. + NotInit = 2, + /// Caller is not authorized (e.g. not admin). + Unauthorized = 3, + /// Amount is zero or negative. + InvalidAmount = 4, + /// Platform fee basis points exceed 10000 (100%). + InvalidFeeBps = 5, + /// No escrow exists for the given invoice. + EscrowNotFound = 6, + /// Escrow already exists for this invoice (duplicate create). + EscrowExists = 7, + /// Escrow has already been funded. + EscrowFunded = 8, + /// Escrow has not been funded yet. + EscrowNotFunded = 9, + /// Payment has already been settled or escrow refunded. + AlreadySettled = 10, + /// Refund not allowed (e.g. not past due date or wrong status). + RefundNotAllowed = 11, + /// Token transfer failed (e.g. insufficient balance). + TransferFailed = 12, + /// Arithmetic overflow or invalid operation. + Overflow = 13, + /// Escrow has been cancelled by the seller. + EscrowCancelled = 14, + /// Contract is paused and the requested operation is temporarily disabled. + Paused = 15, + /// Payer is not the authorized debtor for this invoice. + InvalidPayer = 16, + /// Due date is invalid (e.g., in the past or zero). + InvalidDueDate = 17, + /// Asset decimals for payment token and invoice token do not align. + InvalidAssetDecimals = 18, + /// Nonce has already been consumed by a prior signed off-chain approval (replay attempt). + NonceAlreadyUsed = 19, + /// Escrow is not yet in a terminal state (Settled, Refunded, or Cancelled) and cannot be cleaned up. + EscrowNotSettled = 20, + /// Buyer is not whitelisted to fund escrows. + NotWhitelisted = 21, + /// Off-chain signature has expired (timestamp too old). + SignatureExpired = 22, + /// Funding amount does not meet the required milestone threshold. + InvalidMilestoneAmount = 23, + /// Cannot cancel because escrow is not in the correct state. + CancelNotAllowed = 24, + /// Penalty configuration is invalid (e.g. rate exceeds maximum). + InvalidPenaltyConfig = 25, + /// Payment token contract is invalid or does not implement the token interface. + InvalidPaymentToken = 26, + /// Invoice token contract is invalid or does not implement the required interface. + InvalidInvoiceToken = 27, + /// Payment token and invoice token must be different contracts. + IdenticalTokens = 28, + /// Investor has no position for this invoice. + NoPositionFound = 29, + /// Invoice status does not allow this operation. + InvalidInvoiceStatus = 30, + /// Remaining position after withdrawal is below the minimum investment floor. + BelowMinimumInvestment = 31, + /// Funding target has not yet been reached. + FundingTargetNotReached = 32, + /// Deposit amount is zero (dust prevention: use a positive amount). + ZeroAmount = 29, + /// Deposit amount is below the configured minimum investment. + AmountBelowMinimum = 30, + /// Address is the zero address (all-zero 32-byte key). + InvalidAddress = 31, + /// Escrow duration is outside the allowed [MIN, MAX] window. + InvalidDuration = 32, + /// Caller is not a member of the emergency admin multi-sig set. + NotEmergencyAdmin = 33, + /// Caller has already approved this emergency release (duplicate). + AlreadyApproved = 34, + /// Emergency release threshold has not been reached yet. + ThresholdNotMet = 35, + /// Emergency multi-sig config has not been set. + EmergencyNotConfigured = 36, + /// Pagination limit is invalid (zero). + InvalidLimit = 37, + /// Pagination limit exceeds maximum allowed page size. + LimitExceeded = 38, + /// Invoice with the given ID already exists. + InvoiceAlreadyExists = 39, + /// Yield basis points is invalid (must be between 1 and 5000). + InvalidYield = 40, + /// Funding deadline has not passed yet. + FundingDeadlineNotPassed = 41, + /// Invoice status is invalid for the requested operation. + InvalidInvoiceStatus = 42, + /// No position found for the investor on this invoice. + NoPositionFound = 43, + /// Repayment amount is less than the total raised amount. + InsufficientRepayment = 44, +} +//! Error types for the invoice escrow contract. +use soroban_sdk::contracterror; + +/// Errors that can occur during contract execution. +#[contracterror] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum Error { + /// Contract has already been initialized. + AlreadyInit = 1, + /// Contract has not been initialized. + NotInit = 2, + /// Caller is not authorized (e.g. not admin). + Unauthorized = 3, + /// Amount is zero or negative. + InvalidAmount = 4, + /// Platform fee basis points exceed 10000 (100%). + InvalidFeeBps = 5, + /// No escrow exists for the given invoice. + EscrowNotFound = 6, + /// Escrow already exists for this invoice (duplicate create). + EscrowExists = 7, + /// Escrow has already been funded. + EscrowFunded = 8, + /// Escrow has not been funded yet. + EscrowNotFunded = 9, + /// Payment has already been settled or escrow refunded. + AlreadySettled = 10, + /// Refund not allowed (e.g. not past due date or wrong status). + RefundNotAllowed = 11, + /// Token transfer failed (e.g. insufficient balance). + TransferFailed = 12, + /// Arithmetic overflow or invalid operation. + Overflow = 13, + /// Escrow has been cancelled by the seller. + EscrowCancelled = 14, + /// Contract is paused and the requested operation is temporarily disabled. + Paused = 15, + /// Payer is not the authorized debtor for this invoice. + InvalidPayer = 16, + /// Due date is invalid (e.g., in the past or zero). + InvalidDueDate = 17, + /// Asset decimals for payment token and invoice token do not align. + InvalidAssetDecimals = 18, + /// Nonce has already been consumed by a prior signed off-chain approval (replay attempt). + NonceAlreadyUsed = 19, + /// Escrow is not yet in a terminal state (Settled, Refunded, or Cancelled) and cannot be cleaned up. + EscrowNotSettled = 20, + /// Buyer is not whitelisted to fund escrows. + NotWhitelisted = 21, + /// Off-chain signature has expired (timestamp too old). + SignatureExpired = 22, + /// Funding amount does not meet the required milestone threshold. + InvalidMilestoneAmount = 23, + /// Cannot cancel because escrow is not in the correct state. + CancelNotAllowed = 24, + /// Penalty configuration is invalid (e.g. rate exceeds maximum). + InvalidPenaltyConfig = 25, + /// Payment token contract is invalid or does not implement the token interface. + InvalidPaymentToken = 26, + /// Invoice token contract is invalid or does not implement the required interface. + InvalidInvoiceToken = 27, + /// Payment token and invoice token must be different contracts. + IdenticalTokens = 28, + /// Investor has no position for this invoice. + NoPositionFound = 29, + /// Invoice status does not allow this operation. + InvalidInvoiceStatus = 30, + /// Remaining position after withdrawal is below the minimum investment floor. + BelowMinimumInvestment = 31, + /// Funding target has not yet been reached. + FundingTargetNotReached = 32, + /// Deposit amount is zero (dust prevention: use a positive amount). + ZeroAmount = 29, + /// Deposit amount is below the configured minimum investment. + AmountBelowMinimum = 30, + /// Address is the zero address (all-zero 32-byte key). + InvalidAddress = 31, + /// Escrow duration is outside the allowed [MIN, MAX] window. + InvalidDuration = 32, + /// Caller is not a member of the emergency admin multi-sig set. + NotEmergencyAdmin = 33, + /// Caller has already approved this emergency release (duplicate). + AlreadyApproved = 34, + /// Emergency release threshold has not been reached yet. + ThresholdNotMet = 35, + /// Emergency multi-sig config has not been set. + EmergencyNotConfigured = 36, + /// Pagination limit is invalid (zero). + InvalidLimit = 37, + /// Pagination limit exceeds maximum allowed page size. + LimitExceeded = 38, + /// Invoice with the given ID already exists. + InvoiceAlreadyExists = 39, + /// Yield basis points is invalid (must be between 1 and 5000). + InvalidYield = 40, + /// Funding deadline has not passed yet. + FundingDeadlineNotPassed = 41, + /// Invoice status is invalid for the requested operation. + InvalidInvoiceStatus = 42, + /// No position found for the investor on this invoice. + NoPositionFound = 43, + /// Repayment amount is less than the total raised amount. + InsufficientRepayment = 44, + /// New funding deadline must be greater than the current deadline. + DeadlineNotExtended = 45, +} diff --git a/contracts/invoice-escrow/src/events.rs b/contracts/invoice-escrow/src/events.rs index 30408a1..ad5ee46 100644 --- a/contracts/invoice-escrow/src/events.rs +++ b/contracts/invoice-escrow/src/events.rs @@ -1,277 +1,751 @@ -#![allow(deprecated)] -//! Event definitions for state changes (escrow_created, escrow_funded, payment_settled). - -use soroban_sdk::{Address, BytesN, Env, Symbol}; - -use crate::types::EscrowStatus; - -/// Publish a lifecycle transition event carrying the new status and ledger -/// timestamp, in addition to the narrower per-action events below. Lets -/// off-chain indexers reconstruct full escrow lifecycle history/metadata -/// from a single event stream instead of correlating five separate events. -pub fn escrow_status_changed(env: &Env, inv_id: Symbol, status: EscrowStatus, timestamp: u64) { - env.events().publish( - (Symbol::new(env, "escrow_status_changed"),), - (inv_id, status as u32, timestamp), - ); -} - -pub fn escrow_created( - env: &Env, - inv_id: Symbol, - seller: &Address, - debtor: &Address, - face_value: i128, - purchase_price: i128, - due_dt: u64, - token: &Address, - inv_token: &Address, - commitment: &soroban_sdk::BytesN<32>, - funding_milestone: Option, -) { - env.events().publish( - (Symbol::new(env, "escrow_created"),), - ( - inv_id.clone(), - seller, - debtor, - face_value, - purchase_price, - due_dt, - token, - inv_token, - commitment, - funding_milestone, - ), - ); -} - -/// Publish escrow_funded event with partial funding info. -pub fn escrow_funded( - env: &Env, - inv_id: Symbol, - funder: &Address, - amount: i128, - funded_amt: i128, - purchase_price: i128, -) { - env.events().publish( - (Symbol::new(env, "escrow_funded"),), - (inv_id, funder, amount, funded_amt, purchase_price), - ); -} - -/// Publish payment_settled event (amount, platform_fee, investor_amount). -pub fn payment_settled( - env: &Env, - inv_id: Symbol, - amount: i128, - platform_fee: i128, - investor_amount: i128, -) { - env.events().publish( - (Symbol::new(env, "payment_settled"),), - (inv_id, amount, platform_fee, investor_amount), - ); -} - -/// Publish refund event. -pub fn escrow_refunded(env: &Env, inv_id: Symbol, amount: i128) { - env.events() - .publish((Symbol::new(env, "escrow_refunded"),), (inv_id, amount)); -} - -/// Publish escrow_cancelled event (invoice_id, seller). -pub fn escrow_cancelled(env: &Env, inv_id: Symbol, seller: &Address) { - env.events() - .publish((Symbol::new(env, "escrow_cancelled"),), (inv_id, seller)); -} - -/// Publish escrow_funded event for a signed off-chain approval, including the consumed nonce. -pub fn escrow_funded_signed(env: &Env, inv_id: Symbol, buyer: &Address, amount: i128, nonce: u64) { - env.events().publish( - (Symbol::new(env, "escrow_fund_sig"),), - (inv_id, buyer, amount, nonce), - ); -} - -/// Publish escrow_cleaned_up event once a terminal escrow's storage has been reclaimed. -pub fn escrow_cleaned_up(env: &Env, inv_id: Symbol) { - env.events() - .publish((Symbol::new(env, "escrow_cleaned"),), inv_id); -} - -/// Publish platform fee update event with old and new basis points. -pub fn platform_fee_updated(env: &Env, old_fee_bps: u32, new_fee_bps: u32) { - env.events().publish( - (Symbol::new(env, "platform_fee_updated"),), - (old_fee_bps, new_fee_bps), - ); -} - -/// Publish payment distributor update event with previous and new distributor addresses. -pub fn payment_distributor_updated( - env: &Env, - had_previous_distributor: bool, - new_distributor: &Address, -) { - env.events().publish( - ( - Symbol::new(env, "distributor_updated"), - new_distributor.clone(), - ), - had_previous_distributor, - ); -} - -/// Publish paused state updates. -pub fn paused_updated(env: &Env, old_paused: bool, new_paused: bool) { - env.events().publish( - (Symbol::new(env, "paused_updated"),), - (old_paused, new_paused), - ); -} - -/// Emitted when an early-settlement discount hook is applied during `record_payment`. -/// `original_face` is the unmodified face value; `effective_face` is the discounted value -/// that will be used as the settlement target. -pub fn early_settlement_applied( - env: &Env, - inv_id: Symbol, - discount_bps: u32, - original_face: i128, - effective_face: i128, -) { - env.events().publish( - (Symbol::new(env, "early_settlement_applied"),), - (inv_id, discount_bps, original_face, effective_face), - ); -} - -/// Investment topped up event. -pub fn investment_topped_up( - env: &Env, - investor: &Address, - invoice_id: BytesN<32>, - additional_amount: i128, - new_total_position: i128, -) { - env.events().publish( - ( - Symbol::new(env, "investment_topped_up"), - investor.clone(), - invoice_id.clone(), - ), - (additional_amount, new_total_position), - ); -} - -/// Investment partially refunded event. -pub fn investment_partially_refunded( - env: &Env, - investor: &Address, - invoice_id: BytesN<32>, - amount_refunded: i128, - remaining_position: i128, -) { - env.events().publish( - ( - Symbol::new(env, "investment_partially_refunded"), - investor.clone(), - invoice_id.clone(), - ), - (amount_refunded, remaining_position), - ); -} - -/// Position transferred event. -pub fn position_transferred( - env: &Env, - from: &Address, - to: &Address, - invoice_id: BytesN<32>, - position_amount: i128, - price: i128, -) { - env.events().publish( - ( - Symbol::new(env, "position_transferred"), - from.clone(), - to.clone(), - invoice_id.clone(), - ), - (position_amount, price), - ); -} - -/// Publish funding finalised event. -pub fn funding_finalised(env: &Env, invoice_id: BytesN<32>, total_raised: i128, seller: &Address) { - env.events().publish( - (Symbol::new(env, "funding_finalised"), invoice_id.clone()), - (total_raised, seller.clone()), - ); -} - -/// Publish invoice_registered event with all parameters. -pub fn invoice_registered( - env: &Env, - invoice_id: &soroban_sdk::BytesN<32>, - face_value: i128, - funding_target: i128, - yield_bps: u32, - deadline_ledger: u32, -) { - env.events().publish( - (Symbol::new(env, "invoice_registered"),), - ( - invoice_id.clone(), - face_value, - funding_target, - yield_bps, - deadline_ledger, - ), - ); -} - -/// Publish deadline_extended event with old and new ledger deadlines. -pub fn deadline_extended( - env: &Env, - invoice_id: &soroban_sdk::BytesN<32>, - old_deadline_ledger: u32, - new_deadline_ledger: u32, -) { - env.events().publish( - (Symbol::new(env, "deadline_extended"),), - ( - invoice_id.clone(), - old_deadline_ledger, - new_deadline_ledger, - ), - ); -} -/// Publish investment_refunded event. -pub fn investment_refunded( - env: &Env, - investor: &Address, - invoice_id: &soroban_sdk::BytesN<32>, - amount_refunded: i128, -) { - env.events().publish( - (Symbol::new(env, "investment_refunded"),), - (investor.clone(), invoice_id.clone(), amount_refunded), - ); -} - -/// Publish settlement_paid event per investor. -pub fn settlement_paid( - env: &Env, - investor: &Address, - invoice_id: &soroban_sdk::BytesN<32>, - payout_amount: i128, - yield_earned: i128, -) { - env.events().publish( - (Symbol::new(env, "settlement_paid"),), - (investor.clone(), invoice_id.clone(), payout_amount, yield_earned), - ); -} \ No newline at end of file +#![allow(deprecated)] +//! Event definitions for state changes (escrow_created, escrow_funded, payment_settled). + +use soroban_sdk::{Address, BytesN, Env, Symbol}; + +use crate::types::EscrowStatus; + +/// Publish a lifecycle transition event carrying the new status and ledger +/// timestamp, in addition to the narrower per-action events below. Lets +/// off-chain indexers reconstruct full escrow lifecycle history/metadata +/// from a single event stream instead of correlating five separate events. +pub fn escrow_status_changed(env: &Env, inv_id: Symbol, status: EscrowStatus, timestamp: u64) { + env.events().publish( + (Symbol::new(env, "escrow_status_changed"),), + (inv_id, status as u32, timestamp), + ); +} + +pub fn escrow_created( + env: &Env, + inv_id: Symbol, + seller: &Address, + debtor: &Address, + face_value: i128, + purchase_price: i128, + due_dt: u64, + token: &Address, + inv_token: &Address, + commitment: &soroban_sdk::BytesN<32>, + funding_milestone: Option, +) { + env.events().publish( + (Symbol::new(env, "escrow_created"),), + ( + inv_id.clone(), + seller, + debtor, + face_value, + purchase_price, + due_dt, + token, + inv_token, + commitment, + funding_milestone, + ), + ); +} + +/// Publish escrow_funded event with partial funding info. +pub fn escrow_funded( + env: &Env, + inv_id: Symbol, + funder: &Address, + amount: i128, + funded_amt: i128, + purchase_price: i128, +) { + env.events().publish( + (Symbol::new(env, "escrow_funded"),), + (inv_id, funder, amount, funded_amt, purchase_price), + ); +} + +/// Publish payment_settled event (amount, platform_fee, investor_amount). +pub fn payment_settled( + env: &Env, + inv_id: Symbol, + amount: i128, + platform_fee: i128, + investor_amount: i128, +) { + env.events().publish( + (Symbol::new(env, "payment_settled"),), + (inv_id, amount, platform_fee, investor_amount), + ); +} + +/// Publish refund event. +pub fn escrow_refunded(env: &Env, inv_id: Symbol, amount: i128) { + env.events() + .publish((Symbol::new(env, "escrow_refunded"),), (inv_id, amount)); +} + +/// Publish fee_collected event (amount, treasury_address). +pub fn fee_collected(env: &Env, amount: i128, treasury: &Address) { + env.events().publish( + (Symbol::new(env, "fee_collected"),), + (amount, treasury), + ); +} + +/// Publish max_investors_updated event (new_count, admin). +pub fn max_investors_updated(env: &Env, count: u32, admin: &Address) { + env.events().publish( + (Symbol::new(env, "max_investors_updated"),), + (count, admin), + ); +} + +/// Publish escrow_cancelled event (invoice_id, seller). +pub fn escrow_cancelled(env: &Env, inv_id: Symbol, seller: &Address) { + env.events() + .publish((Symbol::new(env, "escrow_cancelled"),), (inv_id, seller)); +} + +/// Publish escrow_funded event for a signed off-chain approval, including the consumed nonce. +pub fn escrow_funded_signed(env: &Env, inv_id: Symbol, buyer: &Address, amount: i128, nonce: u64) { + env.events().publish( + (Symbol::new(env, "escrow_fund_sig"),), + (inv_id, buyer, amount, nonce), + ); +} + +/// Publish escrow_cleaned_up event once a terminal escrow's storage has been reclaimed. +pub fn escrow_cleaned_up(env: &Env, inv_id: Symbol) { + env.events() + .publish((Symbol::new(env, "escrow_cleaned"),), inv_id); +} + +/// Publish platform fee update event with old and new basis points. +pub fn platform_fee_updated(env: &Env, old_fee_bps: u32, new_fee_bps: u32) { + env.events().publish( + (Symbol::new(env, "platform_fee_updated"),), + (old_fee_bps, new_fee_bps), + ); +} + +/// Publish payment distributor update event with previous and new distributor addresses. +pub fn payment_distributor_updated( + env: &Env, + had_previous_distributor: bool, + new_distributor: &Address, +) { + env.events().publish( + ( + Symbol::new(env, "distributor_updated"), + new_distributor.clone(), + ), + had_previous_distributor, + ); +} + +/// Publish pause state updates. +pub fn paused_updated(env: &Env, old_paused: bool, new_paused: bool) { + env.events().publish( + (Symbol::new(env, "paused_updated"),), + (old_paused, new_paused), + ); +} + +/// Investment topped up event. +pub fn investment_topped_up( + env: &Env, + investor: &Address, + invoice_id: BytesN<32>, + additional_amount: i128, + new_total_position: i128, +) { + env.events().publish( + ( + Symbol::new(env, "investment_topped_up"), + investor.clone(), + invoice_id.clone(), + ), + (additional_amount, new_total_position), + ); +} + +/// Investment partially refunded event. +pub fn investment_partially_refunded( + env: &Env, + investor: &Address, + invoice_id: BytesN<32>, + amount_refunded: i128, + remaining_position: i128, +) { + env.events().publish( + ( + Symbol::new(env, "investment_partially_refunded"), + investor.clone(), + invoice_id.clone(), + ), + (amount_refunded, remaining_position), + ); +} + +/// Position transferred event. +pub fn position_transferred( + env: &Env, + from: &Address, + to: &Address, + invoice_id: BytesN<32>, + position_amount: i128, + price: i128, +) { + env.events().publish( + ( + Symbol::new(env, "position_transferred"), + from.clone(), + to.clone(), + invoice_id.clone(), + ), + (position_amount, price), + ); +} + +/// Funding finalised event. +pub fn funding_finalised(env: &Env, invoice_id: BytesN<32>, total_raised: i128, seller: &Address) { + env.events().publish( + (Symbol::new(env, "funding_finalised"), invoice_id.clone()), + (total_raised, seller.clone()), + ); +} +#![allow(deprecated)] +//! Event definitions for state changes (escrow_created, escrow_funded, payment_settled). + +use soroban_sdk::{Address, BytesN, Env, Symbol}; + +use crate::types::EscrowStatus; + +/// Publish a lifecycle transition event carrying the new status and ledger +/// timestamp, in addition to the narrower per-action events below. Lets +/// off-chain indexers reconstruct full escrow lifecycle history/metadata +/// from a single event stream instead of correlating five separate events. +pub fn escrow_status_changed(env: &Env, inv_id: Symbol, status: EscrowStatus, timestamp: u64) { + env.events().publish( + (Symbol::new(env, "escrow_status_changed"),), + (inv_id, status as u32, timestamp), + ); +} + +pub fn escrow_created( + env: &Env, + inv_id: Symbol, + seller: &Address, + debtor: &Address, + face_value: i128, + purchase_price: i128, + due_dt: u64, + token: &Address, + inv_token: &Address, + commitment: &soroban_sdk::BytesN<32>, + funding_milestone: Option, +) { + env.events().publish( + (Symbol::new(env, "escrow_created"),), + ( + inv_id.clone(), + seller, + debtor, + face_value, + purchase_price, + due_dt, + token, + inv_token, + commitment, + funding_milestone, + ), + ); +} + +/// Publish escrow_funded event with partial funding info. +pub fn escrow_funded( + env: &Env, + inv_id: Symbol, + funder: &Address, + amount: i128, + funded_amt: i128, + purchase_price: i128, +) { + env.events().publish( + (Symbol::new(env, "escrow_funded"),), + (inv_id, funder, amount, funded_amt, purchase_price), + ); +} + +/// Publish payment_settled event (amount, platform_fee, investor_amount). +pub fn payment_settled( + env: &Env, + inv_id: Symbol, + amount: i128, + platform_fee: i128, + investor_amount: i128, +) { + env.events().publish( + (Symbol::new(env, "payment_settled"),), + (inv_id, amount, platform_fee, investor_amount), + ); +} + +/// Publish refund event. +pub fn escrow_refunded(env: &Env, inv_id: Symbol, amount: i128) { + env.events() + .publish((Symbol::new(env, "escrow_refunded"),), (inv_id, amount)); +} + +/// Publish escrow_cancelled event (invoice_id, seller). +pub fn escrow_cancelled(env: &Env, inv_id: Symbol, seller: &Address) { + env.events() + .publish((Symbol::new(env, "escrow_cancelled"),), (inv_id, seller)); +} + +/// Publish escrow_funded event for a signed off-chain approval, including the consumed nonce. +pub fn escrow_funded_signed(env: &Env, inv_id: Symbol, buyer: &Address, amount: i128, nonce: u64) { + env.events().publish( + (Symbol::new(env, "escrow_fund_sig"),), + (inv_id, buyer, amount, nonce), + ); +} + +/// Publish escrow_cleaned_up event once a terminal escrow's storage has been reclaimed. +pub fn escrow_cleaned_up(env: &Env, inv_id: Symbol) { + env.events() + .publish((Symbol::new(env, "escrow_cleaned"),), inv_id); +} + +/// Publish platform fee update event with old and new basis points. +pub fn platform_fee_updated(env: &Env, old_fee_bps: u32, new_fee_bps: u32) { + env.events().publish( + (Symbol::new(env, "platform_fee_updated"),), + (old_fee_bps, new_fee_bps), + ); +} + +/// Publish payment distributor update event with previous and new distributor addresses. +pub fn payment_distributor_updated( + env: &Env, + had_previous_distributor: bool, + new_distributor: &Address, +) { + env.events().publish( + ( + Symbol::new(env, "distributor_updated"), + new_distributor.clone(), + ), + had_previous_distributor, + ); +} + +/// Publish paused state updates. +pub fn paused_updated(env: &Env, old_paused: bool, new_paused: bool) { + env.events().publish( + (Symbol::new(env, "paused_updated"),), + (old_paused, new_paused), + ); +} + +/// Emitted when an early-settlement discount hook is applied during `record_payment`. +/// `original_face` is the unmodified face value; `effective_face` is the discounted value +/// that will be used as the settlement target. +pub fn early_settlement_applied( + env: &Env, + inv_id: Symbol, + discount_bps: u32, + original_face: i128, + effective_face: i128, +) { + env.events().publish( + (Symbol::new(env, "early_settlement_applied"),), + (inv_id, discount_bps, original_face, effective_face), + ); +} + +/// Investment topped up event. +pub fn investment_topped_up( + env: &Env, + investor: &Address, + invoice_id: BytesN<32>, + additional_amount: i128, + new_total_position: i128, +) { + env.events().publish( + ( + Symbol::new(env, "investment_topped_up"), + investor.clone(), + invoice_id.clone(), + ), + (additional_amount, new_total_position), + ); +} + +/// Investment partially refunded event. +pub fn investment_partially_refunded( + env: &Env, + investor: &Address, + invoice_id: BytesN<32>, + amount_refunded: i128, + remaining_position: i128, +) { + env.events().publish( + ( + Symbol::new(env, "investment_partially_refunded"), + investor.clone(), + invoice_id.clone(), + ), + (amount_refunded, remaining_position), + ); +} + +/// Position transferred event. +pub fn position_transferred( + env: &Env, + from: &Address, + to: &Address, + invoice_id: BytesN<32>, + position_amount: i128, + price: i128, +) { + env.events().publish( + ( + Symbol::new(env, "position_transferred"), + from.clone(), + to.clone(), + invoice_id.clone(), + ), + (position_amount, price), + ); +} + +/// Publish funding finalised event. +pub fn funding_finalised(env: &Env, invoice_id: BytesN<32>, total_raised: i128, seller: &Address) { + env.events().publish( + (Symbol::new(env, "funding_finalised"), invoice_id.clone()), + (total_raised, seller.clone()), + ); +} + +/// Publish invoice_registered event with all parameters. +pub fn invoice_registered( + env: &Env, + invoice_id: &soroban_sdk::BytesN<32>, + face_value: i128, + funding_target: i128, + yield_bps: u32, + deadline_ledger: u32, +) { + env.events().publish( + (Symbol::new(env, "invoice_registered"),), + ( + invoice_id.clone(), + face_value, + funding_target, + yield_bps, + deadline_ledger, + ), + ); +} + +/// Publish investment_refunded event. +pub fn investment_refunded( + env: &Env, + investor: &Address, + invoice_id: &soroban_sdk::BytesN<32>, + amount_refunded: i128, +) { + env.events().publish( + (Symbol::new(env, "investment_refunded"),), + (investor.clone(), invoice_id.clone(), amount_refunded), + ); +} + +/// Publish settlement_paid event per investor. +pub fn settlement_paid( + env: &Env, + investor: &Address, + invoice_id: &soroban_sdk::BytesN<32>, + payout_amount: i128, + yield_earned: i128, +) { + env.events().publish( + (Symbol::new(env, "settlement_paid"),), + (investor.clone(), invoice_id.clone(), payout_amount, yield_earned), + ); +} +#![allow(deprecated)] +//! Event definitions for state changes (escrow_created, escrow_funded, payment_settled). + +use soroban_sdk::{Address, BytesN, Env, Symbol}; + +use crate::types::EscrowStatus; + +/// Publish a lifecycle transition event carrying the new status and ledger +/// timestamp, in addition to the narrower per-action events below. Lets +/// off-chain indexers reconstruct full escrow lifecycle history/metadata +/// from a single event stream instead of correlating five separate events. +pub fn escrow_status_changed(env: &Env, inv_id: Symbol, status: EscrowStatus, timestamp: u64) { + env.events().publish( + (Symbol::new(env, "escrow_status_changed"),), + (inv_id, status as u32, timestamp), + ); +} + +pub fn escrow_created( + env: &Env, + inv_id: Symbol, + seller: &Address, + debtor: &Address, + face_value: i128, + purchase_price: i128, + due_dt: u64, + token: &Address, + inv_token: &Address, + commitment: &soroban_sdk::BytesN<32>, + funding_milestone: Option, +) { + env.events().publish( + (Symbol::new(env, "escrow_created"),), + ( + inv_id.clone(), + seller, + debtor, + face_value, + purchase_price, + due_dt, + token, + inv_token, + commitment, + funding_milestone, + ), + ); +} + +/// Publish escrow_funded event with partial funding info. +pub fn escrow_funded( + env: &Env, + inv_id: Symbol, + funder: &Address, + amount: i128, + funded_amt: i128, + purchase_price: i128, +) { + env.events().publish( + (Symbol::new(env, "escrow_funded"),), + (inv_id, funder, amount, funded_amt, purchase_price), + ); +} + +/// Publish payment_settled event (amount, platform_fee, investor_amount). +pub fn payment_settled( + env: &Env, + inv_id: Symbol, + amount: i128, + platform_fee: i128, + investor_amount: i128, +) { + env.events().publish( + (Symbol::new(env, "payment_settled"),), + (inv_id, amount, platform_fee, investor_amount), + ); +} + +/// Publish refund event. +pub fn escrow_refunded(env: &Env, inv_id: Symbol, amount: i128) { + env.events() + .publish((Symbol::new(env, "escrow_refunded"),), (inv_id, amount)); +} + +/// Publish escrow_cancelled event (invoice_id, seller). +pub fn escrow_cancelled(env: &Env, inv_id: Symbol, seller: &Address) { + env.events() + .publish((Symbol::new(env, "escrow_cancelled"),), (inv_id, seller)); +} + +/// Publish escrow_funded event for a signed off-chain approval, including the consumed nonce. +pub fn escrow_funded_signed(env: &Env, inv_id: Symbol, buyer: &Address, amount: i128, nonce: u64) { + env.events().publish( + (Symbol::new(env, "escrow_fund_sig"),), + (inv_id, buyer, amount, nonce), + ); +} + +/// Publish escrow_cleaned_up event once a terminal escrow's storage has been reclaimed. +pub fn escrow_cleaned_up(env: &Env, inv_id: Symbol) { + env.events() + .publish((Symbol::new(env, "escrow_cleaned"),), inv_id); +} + +/// Publish platform fee update event with old and new basis points. +pub fn platform_fee_updated(env: &Env, old_fee_bps: u32, new_fee_bps: u32) { + env.events().publish( + (Symbol::new(env, "platform_fee_updated"),), + (old_fee_bps, new_fee_bps), + ); +} + +/// Publish payment distributor update event with previous and new distributor addresses. +pub fn payment_distributor_updated( + env: &Env, + had_previous_distributor: bool, + new_distributor: &Address, +) { + env.events().publish( + ( + Symbol::new(env, "distributor_updated"), + new_distributor.clone(), + ), + had_previous_distributor, + ); +} + +/// Publish paused state updates. +pub fn paused_updated(env: &Env, old_paused: bool, new_paused: bool) { + env.events().publish( + (Symbol::new(env, "paused_updated"),), + (old_paused, new_paused), + ); +} + +/// Emitted when an early-settlement discount hook is applied during `record_payment`. +/// `original_face` is the unmodified face value; `effective_face` is the discounted value +/// that will be used as the settlement target. +pub fn early_settlement_applied( + env: &Env, + inv_id: Symbol, + discount_bps: u32, + original_face: i128, + effective_face: i128, +) { + env.events().publish( + (Symbol::new(env, "early_settlement_applied"),), + (inv_id, discount_bps, original_face, effective_face), + ); +} + +/// Investment topped up event. +pub fn investment_topped_up( + env: &Env, + investor: &Address, + invoice_id: BytesN<32>, + additional_amount: i128, + new_total_position: i128, +) { + env.events().publish( + ( + Symbol::new(env, "investment_topped_up"), + investor.clone(), + invoice_id.clone(), + ), + (additional_amount, new_total_position), + ); +} + +/// Investment partially refunded event. +pub fn investment_partially_refunded( + env: &Env, + investor: &Address, + invoice_id: BytesN<32>, + amount_refunded: i128, + remaining_position: i128, +) { + env.events().publish( + ( + Symbol::new(env, "investment_partially_refunded"), + investor.clone(), + invoice_id.clone(), + ), + (amount_refunded, remaining_position), + ); +} + +/// Position transferred event. +pub fn position_transferred( + env: &Env, + from: &Address, + to: &Address, + invoice_id: BytesN<32>, + position_amount: i128, + price: i128, +) { + env.events().publish( + ( + Symbol::new(env, "position_transferred"), + from.clone(), + to.clone(), + invoice_id.clone(), + ), + (position_amount, price), + ); +} + +/// Publish funding finalised event. +pub fn funding_finalised(env: &Env, invoice_id: BytesN<32>, total_raised: i128, seller: &Address) { + env.events().publish( + (Symbol::new(env, "funding_finalised"), invoice_id.clone()), + (total_raised, seller.clone()), + ); +} + +/// Publish invoice_registered event with all parameters. +pub fn invoice_registered( + env: &Env, + invoice_id: &soroban_sdk::BytesN<32>, + face_value: i128, + funding_target: i128, + yield_bps: u32, + deadline_ledger: u32, +) { + env.events().publish( + (Symbol::new(env, "invoice_registered"),), + ( + invoice_id.clone(), + face_value, + funding_target, + yield_bps, + deadline_ledger, + ), + ); +} + +/// Publish deadline_extended event with old and new ledger deadlines. +pub fn deadline_extended( + env: &Env, + invoice_id: &soroban_sdk::BytesN<32>, + old_deadline_ledger: u32, + new_deadline_ledger: u32, +) { + env.events().publish( + (Symbol::new(env, "deadline_extended"),), + ( + invoice_id.clone(), + old_deadline_ledger, + new_deadline_ledger, + ), + ); +} +/// Publish investment_refunded event. +pub fn investment_refunded( + env: &Env, + investor: &Address, + invoice_id: &soroban_sdk::BytesN<32>, + amount_refunded: i128, +) { + env.events().publish( + (Symbol::new(env, "investment_refunded"),), + (investor.clone(), invoice_id.clone(), amount_refunded), + ); +} + +/// Publish settlement_paid event per investor. +pub fn settlement_paid( + env: &Env, + investor: &Address, + invoice_id: &soroban_sdk::BytesN<32>, + payout_amount: i128, + yield_earned: i128, +) { + env.events().publish( + (Symbol::new(env, "settlement_paid"),), + (investor.clone(), invoice_id.clone(), payout_amount, yield_earned), + ); +} diff --git a/contracts/invoice-escrow/src/lib.rs b/contracts/invoice-escrow/src/lib.rs index c654064..0e604e6 100644 --- a/contracts/invoice-escrow/src/lib.rs +++ b/contracts/invoice-escrow/src/lib.rs @@ -1,1521 +1,4282 @@ -//! Invoice Escrow contract for StellarSettle. -//! -//! Handles escrow creation, funding by investors, payment settlement, -//! and refunds when invoices are not paid by due date. - -#![no_std] -#![allow(clippy::too_many_arguments)] - -mod errors; -mod events; -mod storage; -mod types; - -use soroban_sdk::{contract, contractimpl, token, Address, BytesN, Env, IntoVal, Symbol}; - -use types::MultiSigConfig; - -// EscrowStatus is re-exported publicly; Config, EscrowData, and InvoiceData are crate-private. -pub use types::EscrowStatus; -use types::{Config, EscrowData, FundingInvoice, InvoiceData, InvoiceStatus}; - -use errors::Error; - -/// Reject the zero address (all-zero 32-byte Ed25519 key) which is never a valid participant. -fn ensure_non_zero_address(env: &Env, address: &Address) -> Result<(), Error> { - // Convert address to its string representation and check for the well-known - // zero account (all 32 bytes are 0x00). The StrKey encoding of the zero - // account is the constant below. - let zero_str = soroban_sdk::String::from_str( - env, - "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", - ); - let zero = Address::from_string(&zero_str); - if *address == zero { - return Err(Error::InvalidAddress); - } - Ok(()) -} - -const MAX_BPS: u32 = 10_000; -const DISTRIBUTE_PAYMENT_FN: &str = "distribute_payment"; -const DISTRIBUTE_REFUND_FN: &str = "distribute_refund"; - -/// Minimum escrow duration: 1 hour (3600 seconds). -const MIN_ESCROW_DURATION_SECS: u64 = 3_600; -/// Maximum escrow duration: 365 days (31,536,000 seconds). -const MAX_ESCROW_DURATION_SECS: u64 = 31_536_000; - -#[contract] -pub struct InvoiceEscrow; - -fn ensure_not_paused(config: &Config) -> Result<(), Error> { - if config.paused { - return Err(Error::Paused); - } - Ok(()) -} - -#[contractimpl] -impl InvoiceEscrow { - /// Initialize the contract with admin and platform fee (basis points, e.g. 300 = 3%). - pub fn initialize(env: Env, admin: Address, platform_fee_bps: u32) -> Result<(), Error> { - ensure_non_zero_address(&env, &admin)?; - admin.require_auth(); - if storage::get_config(&env).is_some() { - return Err(Error::AlreadyInit); - } - if platform_fee_bps > MAX_BPS { - return Err(Error::InvalidFeeBps); - } - let config = Config { - admin: admin.clone(), - fee_bps: platform_fee_bps, - payment_distributor: None, - paused: false, - whitelist_enabled: false, - min_investment: 0, - }; - storage::set_config(&env, &config); - Ok(()) - } - - /// Admin-only: set the minimum investment amount for `fund_escrow`. - /// Pass `0` to disable the floor (deposits must still be strictly positive). - pub fn set_min_investment(env: Env, admin: Address, min_investment: i128) -> Result<(), Error> { - admin.require_auth(); - if min_investment < 0 { - return Err(Error::InvalidAmount); - } - let mut config = storage::get_config(&env).ok_or(Error::NotInit)?; - if config.admin != admin { - return Err(Error::Unauthorized); - } - config.min_investment = min_investment; - storage::set_config(&env, &config); - Ok(()) - } - - /// Admin-only: enable/disable buyer whitelist enforcement on `fund_escrow`. - pub fn set_whitelist_enabled(env: Env, admin: Address, enabled: bool) -> Result<(), Error> { - admin.require_auth(); - let mut config = storage::get_config(&env).ok_or(Error::NotInit)?; - if config.admin != admin { - return Err(Error::Unauthorized); - } - config.whitelist_enabled = enabled; - storage::set_config(&env, &config); - Ok(()) - } - - /// Admin-only: add or remove a buyer from the whitelist. - pub fn set_buyer_whitelisted( - env: Env, - admin: Address, - buyer: Address, - allowed: bool, - ) -> Result<(), Error> { - admin.require_auth(); - let config = storage::get_config(&env).ok_or(Error::NotInit)?; - if config.admin != admin { - return Err(Error::Unauthorized); - } - storage::set_whitelisted(&env, &buyer, allowed); - Ok(()) - } - - /// View: is `buyer` whitelisted to fund escrows. - pub fn is_buyer_whitelisted(env: Env, buyer: Address) -> bool { - storage::is_whitelisted(&env, &buyer) - } - - /// Create an escrow for an invoice. Caller (seller) must be authenticated. - /// face_value: what the debtor owes (amount to be paid at settlement) - /// purchase_price: what the investor pays (discount applied here) - /// commitment: immutable on-chain anchor (SHA-256 hash of off-chain invoice data) - pub fn create_escrow( - env: Env, - invoice_id: Symbol, - seller: Address, - debtor: Address, - face_value: i128, - purchase_price: i128, - due_date: u64, - payment_token: Address, - invoice_token: Address, - commitment: soroban_sdk::BytesN<32>, - funding_milestone: Option, - ) -> Result<(), Error> { - seller.require_auth(); - if face_value <= 0 || purchase_price <= 0 { - return Err(Error::InvalidAmount); - } - if due_date == 0 { - return Err(Error::InvalidDueDate); - } - let current_timestamp = env.ledger().timestamp(); - if due_date <= current_timestamp { - return Err(Error::InvalidDueDate); - } - let duration = due_date.saturating_sub(current_timestamp); - if duration < MIN_ESCROW_DURATION_SECS || duration > MAX_ESCROW_DURATION_SECS { - return Err(Error::InvalidDuration); - } - let config = storage::get_config(&env).ok_or(Error::NotInit)?; - ensure_not_paused(&config)?; - if storage::has_escrow(&env, invoice_id.clone()) { - return Err(Error::EscrowExists); - } - // Ensure the payment token and invoice token use the same decimals to avoid - // settlement/rounding mismatches during distribution and fee calculations. - let inv_decimals: Option = env - .try_invoke_contract::( - &invoice_token, - &Symbol::new(&env, "decimals"), - soroban_sdk::vec![&env], - ) - .ok() - .and_then(|r| r.ok()); - let pay_decimals: Option = env - .try_invoke_contract::( - &payment_token, - &Symbol::new(&env, "decimals"), - soroban_sdk::vec![&env], - ) - .ok() - .and_then(|r| r.ok()); - if let (Some(inv_d), Some(pay_d)) = (inv_decimals, pay_decimals) { - if inv_d != pay_d { - return Err(Error::InvalidAssetDecimals); - } - } - let data = EscrowData { - inv_id: invoice_id.clone(), - seller: seller.clone(), - debtor: debtor.clone(), - face_value, - purchase_price, - funded_amt: 0, - funder: None, - funders: soroban_sdk::Vec::new(&env), - due_dt: due_date, - token: payment_token.clone(), - inv_token: invoice_token.clone(), - paid_amt: 0, - status: EscrowStatus::Created, - funding_milestone, - commitment: commitment.clone(), - early_settlement: None, - }; - storage::set_escrow(&env, invoice_id.clone(), &data); - - // Store the invoice_id at the current index for pagination - let current_count = storage::get_escrow_count(&env); - storage::set_escrow_id_by_index(&env, current_count, &invoice_id); - storage::increment_escrow_count(&env); - - events::escrow_created( - &env, - invoice_id.clone(), - &seller, - &debtor, - face_value, - purchase_price, - due_date, - &payment_token, - &invoice_token, - &commitment, - data.funding_milestone, - ); - events::escrow_status_changed(&env, invoice_id, EscrowStatus::Created, current_timestamp); - Ok(()) - } - - /// Cancel an escrow in Created state, refunding any partial funds to the funders. - /// Only the seller may cancel, and only while status is Created. - /// Cancel an unfunded escrow. Only the seller may cancel, and only while status is Created - /// AND no investor has contributed any funds yet. - /// - /// Locked out after partial payment: `fund_escrow` accepts partial contributions and only - /// flips `status` to `Funded` once the escrow is fully subscribed, so an escrow with - /// `funded_amt > 0` can still read as `Created`. Cancelling in that window would strand the - /// investor's already-transferred funds (cancellation has no refund path), so any nonzero - /// `funded_amt` blocks cancellation regardless of status. - /// - /// Emits `escrow_refunded` (if partial funds existed) and `escrow_cancelled`. - pub fn cancel_escrow(env: Env, invoice_id: Symbol, seller: Address) -> Result<(), Error> { - seller.require_auth(); - let config = storage::get_config(&env).ok_or(Error::NotInit)?; - ensure_not_paused(&config)?; - let mut data = - storage::get_escrow(&env, invoice_id.clone()).ok_or(Error::EscrowNotFound)?; - if data.seller != seller { - return Err(Error::Unauthorized); - } - if data.status == EscrowStatus::Cancelled { - return Err(Error::EscrowCancelled); - } - if data.status == EscrowStatus::Funded { - return Err(Error::EscrowFunded); - } - if data.status != EscrowStatus::Created { - return Err(Error::CancelNotAllowed); - } - - if data.funded_amt > 0 { - let amount_to_refund = data.funded_amt; - let token = token::Client::new(&env, &data.token); - let contract = env.current_contract_address(); - let funder_opt = data.funder.clone(); - - if let Some(distributor) = config.payment_distributor.as_ref() { - token.transfer(&contract, distributor, &amount_to_refund); - env.invoke_contract::<()>( - distributor, - &Symbol::new(&env, DISTRIBUTE_REFUND_FN), - soroban_sdk::vec![ - &env, - contract.to_val(), - invoice_id.clone().into_val(&env), - soroban_sdk::vec![ - &env, -
>::into_val( - &data.token, - &env - ), - as IntoVal>::into_val( - &funder_opt, - &env, - ) - ] - .into_val(&env), - soroban_sdk::vec![&env, amount_to_refund].into_val(&env), - (EscrowStatus::Cancelled as u32).into_val(&env) - ], - ); - } else { - if let Some(funder) = &funder_opt { - let funder_amt = storage::get_funder_amount(&env, invoice_id.clone(), funder); - if funder_amt > 0 { - token.transfer(&contract, funder, &funder_amt); - } - } - } - - env.invoke_contract::<()>( - &data.inv_token, - &Symbol::new(&env, "set_transfer_locked"), - soroban_sdk::vec![&env, contract.to_val(), false.into_val(&env)], - ); - events::escrow_refunded(&env, invoice_id.clone(), amount_to_refund); - } - data.status = EscrowStatus::Cancelled; - storage::set_escrow(&env, invoice_id.clone(), &data); - events::escrow_cancelled(&env, invoice_id.clone(), &seller); - events::escrow_status_changed( - &env, - invoice_id, - EscrowStatus::Cancelled, - env.ledger().timestamp(), - ); - Ok(()) - } - - /// Seller-only: attach or update the early-settlement discount hook for a Created or Funded escrow. - /// - /// Rules: - /// - Only callable by the escrow's seller. - /// - `discount_bps` must be in [1, 9999]. A zero discount is meaningless; 10 000 bps - /// (100%) would collapse the effective face value to zero, so it is rejected. - /// - `cutoff_date` must be strictly in the future and must not exceed `due_dt`. - /// - Cannot be set on an escrow that has already reached a terminal state - /// (Settled, Refunded, Cancelled). - /// - Can be called multiple times to update the config (e.g., extend the window - /// or adjust the rate) as long as the escrow is still live. - pub fn set_early_settlement( - env: Env, - invoice_id: Symbol, - seller: Address, - discount_bps: u32, - cutoff_date: u64, - ) -> Result<(), Error> { - seller.require_auth(); - let config = storage::get_config(&env).ok_or(Error::NotInit)?; - ensure_not_paused(&config)?; - - let mut data = - storage::get_escrow(&env, invoice_id.clone()).ok_or(Error::EscrowNotFound)?; - if data.seller != seller { - return Err(Error::Unauthorized); - } - - // Terminal states: hook can no longer be meaningful - match data.status { - EscrowStatus::Settled | EscrowStatus::Refunded | EscrowStatus::Cancelled => { - return Err(Error::InvalidEarlySettlement); - } - _ => {} - } - - // Validate discount_bps: must be [1, 9999] - if discount_bps == 0 || discount_bps >= MAX_BPS { - return Err(Error::InvalidEarlySettlement); - } - - // cutoff_date must be strictly in the future - let now = env.ledger().timestamp(); - if cutoff_date <= now { - return Err(Error::InvalidEarlySettlement); - } - // cutoff_date must not exceed due_dt (no discount window past maturity) - if cutoff_date > data.due_dt { - return Err(Error::InvalidEarlySettlement); - } - - data.early_settlement = Some(EarlySettlementConfig { - discount_bps, - cutoff_date, - }); - storage::set_escrow(&env, invoice_id, &data); - Ok(()) - } - - /// Fund the escrow (investor buys part or all of the invoice at purchase_price). - /// Transfers `amount` from buyer to this contract. Multiple investors can fund until fully subscribed. - pub fn fund_escrow( - env: Env, - invoice_id: Symbol, - buyer: Address, - amount: i128, - ) -> Result<(), Error> { - buyer.require_auth(); - Self::fund_escrow_core(&env, invoice_id, &buyer, amount) - } - - /// Fund the escrow on behalf of `buyer` using a signed off-chain approval that a relayer - /// submits on their behalf. `buyer` authorizes exactly this `(invoice_id, amount, nonce, expiry)` - /// tuple, and `nonce` must be strictly greater than the last nonce consumed by `buyer` so - /// the same signed approval cannot be replayed. - /// - /// Issue #183: Includes an `expiry` timestamp. If the ledger timestamp exceeds `expiry` - /// the signature is rejected, limiting the window for replay attacks. - pub fn fund_escrow_signed( - env: Env, - invoice_id: Symbol, - buyer: Address, - amount: i128, - nonce: u64, - expiry: u64, - ) -> Result<(), Error> { - buyer.require_auth_for_args((invoice_id.clone(), amount, nonce, expiry).into_val(&env)); - - let current_ts = env.ledger().timestamp(); - if current_ts > expiry { - return Err(Error::SignatureExpired); - } - - let last_nonce = storage::get_nonce(&env, &buyer); - if nonce <= last_nonce { - return Err(Error::NonceAlreadyUsed); - } - - Self::fund_escrow_core(&env, invoice_id.clone(), &buyer, amount)?; - - storage::set_nonce(&env, &buyer, nonce); - events::escrow_funded_signed(&env, invoice_id, &buyer, amount, nonce); - Ok(()) - } - - /// Shared funding logic used by both the directly-authorized and signed-approval entry points. - fn fund_escrow_core( - env: &Env, - invoice_id: Symbol, - buyer: &Address, - amount: i128, - ) -> Result<(), Error> { - // Fail fast: validate amount before hitting storage. - if amount == 0 { - return Err(Error::ZeroAmount); - } - if amount < 0 { - return Err(Error::InvalidAmount); - } - let config = storage::get_config(env).ok_or(Error::NotInit)?; - ensure_not_paused(&config)?; - if config.whitelist_enabled && !storage::is_whitelisted(env, buyer) { - return Err(Error::NotWhitelisted); - } - - let mut data = storage::get_escrow(env, invoice_id.clone()).ok_or(Error::EscrowNotFound)?; - if data.status == EscrowStatus::Cancelled { - return Err(Error::EscrowCancelled); - } - if data.status != EscrowStatus::Created { - return Err(Error::EscrowFunded); - } - - // Check that funding doesn't exceed purchase_price - let new_funded = data.funded_amt.checked_add(amount).ok_or(Error::Overflow)?; - if new_funded > data.purchase_price { - return Err(Error::InvalidAmount); - } - - let remaining_to_fund = data - .purchase_price - .checked_sub(data.funded_amt) - .ok_or(Error::Overflow)?; - - // Enforce global minimum investment to prevent dust deposits, except when - // the funder is completing the exact remaining capacity. - if config.min_investment > 0 - && amount != remaining_to_fund - && amount < config.min_investment - { - return Err(Error::AmountBelowMinimum); - } - - // Validate milestone constraints if a milestone is set - if let Some(milestone) = data.funding_milestone { - // Funder is always allowed to just fund exactly the remaining amount to complete the escrow. - // If they are not completing the escrow, the amount must be at least the milestone and a multiple of it. - if amount != remaining_to_fund && (amount < milestone || amount % milestone != 0) { - return Err(Error::InvalidMilestoneAmount); - } - } - - let token = token::Client::new(env, &data.token); - let contract = env.current_contract_address(); - token.transfer(buyer, &contract, &amount); - - // Mint invoice tokens to the buyer to represent their ownership share - env.invoke_contract::<()>( - &data.inv_token, - &Symbol::new(env, "mint"), - soroban_sdk::vec![env, buyer.to_val(), amount.into_val(env), contract.to_val()], - ); - - // Track this funder's contribution - let current_funder_amt = storage::get_funder_amount(env, invoice_id.clone(), buyer); - let new_funder_amt = current_funder_amt - .checked_add(amount) - .ok_or(Error::Overflow)?; - storage::set_funder_amount(env, invoice_id.clone(), buyer, new_funder_amt); - - data.funded_amt = new_funded; - - let mut already_recorded = false; - for funder in data.funders.iter() { - if funder == buyer.clone() { - already_recorded = true; - break; - } - } - if !already_recorded { - data.funders.push_back(buyer.clone()); - } - - // MVP: Store the first funder for direct distribution - if data.funder.is_none() { - data.funder = Some(buyer.clone()); - } - - // If fully funded, transition to Funded status - if data.funded_amt == data.purchase_price { - data.status = EscrowStatus::Funded; - } - - storage::set_escrow(env, invoice_id.clone(), &data); - events::escrow_funded( - env, - invoice_id.clone(), - buyer, - amount, - data.funded_amt, - data.purchase_price, - ); - if data.status == EscrowStatus::Funded { - events::escrow_status_changed( - env, - invoice_id, - EscrowStatus::Funded, - env.ledger().timestamp(), - ); - } - Ok(()) - } - - /// Record payment: distribute to investors and platform fee. Payer must auth. - /// Payer must be the authorized debtor for this invoice. - /// Payment is applied toward face_value; fees are calculated on the payment amount. - /// MVP: Distributes pro-rata to all funders based on their contribution. - pub fn record_payment( - env: Env, - invoice_id: Symbol, - payer: Address, - amount: i128, - ) -> Result<(), Error> { - payer.require_auth(); - if amount <= 0 { - return Err(Error::InvalidAmount); - } - let config = storage::get_config(&env).ok_or(Error::NotInit)?; - ensure_not_paused(&config)?; - let mut data = - storage::get_escrow(&env, invoice_id.clone()).ok_or(Error::EscrowNotFound)?; - - // Enforce payer role: payer must be the authorized debtor - if payer != data.debtor { - return Err(Error::InvalidPayer); - } - - if data.status != EscrowStatus::Funded { - return Err(Error::AlreadySettled); - } - - // Compute effective face value: apply early-settlement discount if the hook - // is configured and the payment arrives strictly before the cutoff date. - let current_ts = env.ledger().timestamp(); - let effective_face_value = - if let Some(ref es) = data.early_settlement { - if current_ts < es.cutoff_date { - let discount = data - .face_value - .checked_mul(i128::from(es.discount_bps)) - .ok_or(Error::Overflow)? - .checked_div(i128::from(MAX_BPS)) - .ok_or(Error::Overflow)?; - let discounted = data - .face_value - .checked_sub(discount) - .ok_or(Error::Overflow)? - .max(1); // floor at 1 stroop - // Emit the hook application event (only on first payment in the window - // to avoid redundant emissions on subsequent partial payments). - if data.paid_amt == 0 { - events::early_settlement_applied( - &env, - invoice_id.clone(), - es.discount_bps, - data.face_value, - discounted, - ); - } - discounted - } else { - data.face_value - } - } else { - data.face_value - }; - - // Remaining balance toward effective face value - let remaining = effective_face_value - .checked_sub(data.paid_amt) - .ok_or(Error::Overflow)?; - if amount > remaining { - return Err(Error::InvalidAmount); - } - - let fee_bps = i128::from(config.fee_bps); - // Fee is calculated on the payment amount (not face_value) - let platform_fee = amount - .checked_mul(fee_bps) - .ok_or(Error::Overflow)? - .checked_div(i128::from(MAX_BPS)) - .ok_or(Error::Overflow)?; - let investor_amount = amount.checked_sub(platform_fee).ok_or(Error::Overflow)?; - - let token = token::Client::new(&env, &data.token); - let contract = env.current_contract_address(); - - // 1. Pull payer's funds into escrow - token.transfer(&payer, &contract, &amount); - - data.paid_amt = data.paid_amt.checked_add(amount).ok_or(Error::Overflow)?; - - // Settlement occurs when paid_amt reaches effective face value - if data.paid_amt == effective_face_value { - data.status = EscrowStatus::Settled; - } - - storage::set_escrow(&env, invoice_id.clone(), &data); - - let funder_addr = data.funder.clone().unwrap_or_else(|| data.seller.clone()); - - if let Some(distributor) = config.payment_distributor.as_ref() { - // The distributor must pay seller_amount (== amount) plus investor_amount + platform_fee - // (== amount), mirroring the direct path below which releases the payer's `amount` to the - // seller in addition to paying the investor/admin out of escrow's held funding. - let total_to_distributor = amount.checked_add(amount).ok_or(Error::Overflow)?; - token.transfer(&contract, distributor, &total_to_distributor); - env.invoke_contract::<()>( - distributor, - &Symbol::new(&env, DISTRIBUTE_PAYMENT_FN), - soroban_sdk::vec![ - &env, - contract.to_val(), - invoice_id.clone().into_val(&env), - soroban_sdk::vec![ - &env, -
>::into_val(&data.token, &env), -
>::into_val(&data.seller, &env), -
>::into_val(&funder_addr, &env), -
>::into_val(&config.admin, &env) - ] - .into_val(&env), - soroban_sdk::vec![ - &env, - data.paid_amt, - amount, - investor_amount, - config.fee_bps as i128, - ] - .into_val(&env), - (data.status as u32).into_val(&env) - ], - ); - } else { - // 2. Platform fee to admin - token.transfer(&contract, &config.admin, &platform_fee); - - // 3. Pro-rata investor distribution - if let Some(funder) = &data.funder { - if data.funded_amt > 0 && investor_amount > 0 { - let funder_amt = storage::get_funder_amount(&env, invoice_id.clone(), funder); - let pro_rata_share = investor_amount - .checked_mul(funder_amt) - .ok_or(Error::Overflow)? - .checked_div(data.funded_amt) - .ok_or(Error::Overflow)?; - if pro_rata_share > 0 { - token.transfer(&contract, funder, &pro_rata_share); - } - } - } - // Seller receives the full payment amount - token.transfer(&contract, &data.seller, &amount); - } - - if data.status == EscrowStatus::Settled { - // Unlock invoice token transfers only when the invoice is completely settled. - env.invoke_contract::<()>( - &data.inv_token, - &Symbol::new(&env, "set_transfer_locked"), - soroban_sdk::vec![&env, contract.to_val(), false.into_val(&env)], - ); - } - - events::payment_settled( - &env, - invoice_id.clone(), - amount, - platform_fee, - investor_amount, - ); - if data.status == EscrowStatus::Settled { - events::escrow_status_changed( - &env, - invoice_id, - EscrowStatus::Settled, - env.ledger().timestamp(), - ); - } - Ok(()) - } - - /// Refund the investors if the invoice was not paid by due date. Anyone may call. - /// Refunds are distributed pro-rata based on each investor's contribution. - pub fn refund_escrow(env: Env, invoice_id: Symbol) -> Result<(), Error> { - let config = storage::get_config(&env).ok_or(Error::NotInit)?; - ensure_not_paused(&config)?; - let mut data = - storage::get_escrow(&env, invoice_id.clone()).ok_or(Error::EscrowNotFound)?; - if data.status != EscrowStatus::Funded { - return Err(Error::RefundNotAllowed); - } - let ledger_ts = env.ledger().timestamp(); - if ledger_ts < data.due_dt { - return Err(Error::RefundNotAllowed); - } - - // Refund the remaining collateral (purchase_price minus already released partial payments) - let amount_to_refund = data - .purchase_price - .checked_sub(data.paid_amt) - .ok_or(Error::Overflow)?; - - let token = token::Client::new(&env, &data.token); - let contract = env.current_contract_address(); - - // Extract funder address before status mutation so it is available in both paths. - let funder_opt = data.funder.clone(); - - data.status = EscrowStatus::Refunded; - storage::set_escrow(&env, invoice_id.clone(), &data); - - if amount_to_refund > 0 { - if let Some(distributor) = config.payment_distributor.as_ref() { - token.transfer(&contract, distributor, &amount_to_refund); - env.invoke_contract::<()>( - distributor, - &Symbol::new(&env, DISTRIBUTE_REFUND_FN), - soroban_sdk::vec![ - &env, - contract.to_val(), - invoice_id.clone().into_val(&env), - soroban_sdk::vec![ - &env, -
>::into_val( - &data.token, - &env - ), - as IntoVal>::into_val( - &funder_opt, - &env, - ) - ] - .into_val(&env), - soroban_sdk::vec![&env, amount_to_refund].into_val(&env), - (data.status as u32).into_val(&env) - ], - ); - } else { - // Pro-rata refund to funders - if let Some(funder) = &funder_opt { - if data.funded_amt > 0 { - let funder_amt = - storage::get_funder_amount(&env, invoice_id.clone(), funder); - let pro_rata_refund = amount_to_refund - .checked_mul(funder_amt) - .ok_or(Error::Overflow)? - .checked_div(data.funded_amt) - .ok_or(Error::Overflow)?; - if pro_rata_refund > 0 { - token.transfer(&contract, funder, &pro_rata_refund); - } - } - } - } - } - - // Unlock invoice token transfers now that the invoice is refunded - env.invoke_contract::<()>( - &data.inv_token, - &Symbol::new(&env, "set_transfer_locked"), - soroban_sdk::vec![&env, contract.to_val(), false.into_val(&env)], - ); - - events::escrow_refunded(&env, invoice_id.clone(), amount_to_refund); - events::escrow_status_changed( - &env, - invoice_id, - EscrowStatus::Refunded, - env.ledger().timestamp(), - ); - Ok(()) - } - - /// Update platform fee (basis points). Admin only. - pub fn update_platform_fee_bps(env: Env, new_fee_bps: u32) -> Result<(), Error> { - let mut config = storage::get_config(&env).ok_or(Error::NotInit)?; - let admin = config.admin.clone(); - admin.require_auth(); - if new_fee_bps > MAX_BPS { - return Err(Error::InvalidFeeBps); - } - let old_fee_bps = config.fee_bps; - config.fee_bps = new_fee_bps; - storage::set_config(&env, &config); - events::platform_fee_updated(&env, old_fee_bps, new_fee_bps); - Ok(()) - } - - /// Set the payment distributor used for settlement/refund fan-out. Admin only. - pub fn set_payment_distributor(env: Env, payment_distributor: Address) -> Result<(), Error> { - let mut config = storage::get_config(&env).ok_or(Error::NotInit)?; - let admin = config.admin.clone(); - admin.require_auth(); - let old_distributor = config.payment_distributor.clone(); - config.payment_distributor = Some(payment_distributor.clone()); - storage::set_config(&env, &config); - events::payment_distributor_updated(&env, old_distributor.is_some(), &payment_distributor); - Ok(()) - } - - /// Toggle the emergency pause flag. Admin only. - pub fn set_paused(env: Env, paused: bool) -> Result<(), Error> { - let mut config = storage::get_config(&env).ok_or(Error::NotInit)?; - let admin = config.admin.clone(); - admin.require_auth(); - let old_paused = config.paused; - config.paused = paused; - storage::set_config(&env, &config); - events::paused_updated(&env, old_paused, paused); - Ok(()) - } - - /// View: return escrow data for an invoice, or Err(Error::EscrowNotFound) if not found. - pub fn get_escrow(env: Env, invoice_id: Symbol) -> Result { - storage::get_escrow(&env, invoice_id).ok_or(Error::EscrowNotFound) - } - - /// View: return current config (admin and fee_bps). - pub fn get_config(env: Env) -> Result { - storage::get_config(&env).ok_or(Error::NotInit) - } - - /// View: return escrow status for an invoice. - pub fn get_escrow_status(env: Env, invoice_id: Symbol) -> Result { - let data = storage::get_escrow(&env, invoice_id).ok_or(Error::EscrowNotFound)?; - Ok(data.status) - } - - /// View: return the current pause state. - pub fn paused(env: Env) -> Result { - let config = storage::get_config(&env).ok_or(Error::NotInit)?; - Ok(config.paused) - } - - /// Admin-only: configure the emergency multi-sig admin set and threshold. - pub fn set_emergency_config( - env: Env, - admin: Address, - config: MultiSigConfig, - ) -> Result<(), Error> { - admin.require_auth(); - let stored_config = storage::get_config(&env).ok_or(Error::NotInit)?; - if stored_config.admin != admin { - return Err(Error::Unauthorized); - } - if config.threshold == 0 || config.threshold > config.admins.len() as u32 { - return Err(Error::InvalidFeeBps); // reuse for invalid threshold - } - storage::set_emergency_config(&env, &config); - Ok(()) - } - - /// Emergency multi-sig release: an admin approves releasing funds for an invoice. - /// When the threshold is reached, funds are paid out to the seller and the escrow - /// is marked as Settled. - pub fn emergency_release(env: Env, caller: Address, invoice_id: Symbol) -> Result { - caller.require_auth(); - let config = storage::get_emergency_config(&env).ok_or(Error::EmergencyNotConfigured)?; - - // Verify caller is an emergency admin - let mut is_admin = false; - for admin in config.admins.iter() { - if admin == caller { - is_admin = true; - break; - } - } - if !is_admin { - return Err(Error::NotEmergencyAdmin); - } - - let mut approvals = storage::get_emergency_approvals(&env, &invoice_id); - - // Check for duplicate approval - for addr in approvals.approvals.iter() { - if addr == caller { - return Err(Error::AlreadyApproved); - } - } - - approvals.approvals.push_back(caller.clone()); - storage::set_emergency_approvals(&env, &invoice_id, &approvals); - - if (approvals.approvals.len() as u32) < config.threshold { - return Ok(false); - } - - // Threshold reached ? execute emergency release - let mut data = - storage::get_escrow(&env, invoice_id.clone()).ok_or(Error::EscrowNotFound)?; - - if data.status == EscrowStatus::Settled - || data.status == EscrowStatus::Refunded - || data.status == EscrowStatus::Cancelled - { - return Err(Error::AlreadySettled); - } - - let token = token::Client::new(&env, &data.token); - let contract = env.current_contract_address(); - let remaining = data - .purchase_price - .checked_sub(data.paid_amt) - .ok_or(Error::Overflow)?; - - // Pay remaining to seller - if remaining > 0 { - token.transfer(&contract, &data.seller, &remaining); - } - - data.status = EscrowStatus::Settled; - storage::set_escrow(&env, invoice_id.clone(), &data); - - events::escrow_status_changed( - &env, - invoice_id.clone(), - EscrowStatus::Settled, - env.ledger().timestamp(), - ); - Ok(true) - } - - /// Reclaim persistent storage for an escrow that has reached a terminal state - /// (Settled, Refunded, or Cancelled). Callable only by the seller or the admin. - /// The escrow and its per-funder contribution record are removed permanently; - /// terminal-state escrows are never mutated again, so this is safe to prune. - pub fn cleanup_escrow(env: Env, invoice_id: Symbol, caller: Address) -> Result<(), Error> { - caller.require_auth(); - let config = storage::get_config(&env).ok_or(Error::NotInit)?; - let data = storage::get_escrow(&env, invoice_id.clone()).ok_or(Error::EscrowNotFound)?; - if caller != data.seller && caller != config.admin { - return Err(Error::Unauthorized); - } - match data.status { - EscrowStatus::Settled | EscrowStatus::Refunded | EscrowStatus::Cancelled => {} - _ => return Err(Error::EscrowNotSettled), - } - storage::remove_escrow_state(&env, invoice_id.clone(), &data.funders); - events::escrow_cleaned_up(&env, invoice_id); - Ok(()) - } - - // ?? Position management: top_up / partial_refund / transfer_position / finalise_funding ?? - - /// Create a funding invoice (BytesN<32> id) for the new position management flows. - /// This is the setup entrypoint for tests and admin tooling for the - /// top_up / partial_refund / transfer_position / finalise_funding lifecycle. - pub fn create_invoice( - env: Env, - invoice_id: BytesN<32>, - seller: Address, - funding_target: i128, - deadline_ledger: u32, - min_investment: i128, - per_investor_cap: Option, - token: Address, - ) -> Result<(), Error> { - seller.require_auth(); - if funding_target <= 0 { - return Err(Error::InvalidAmount); - } - if min_investment < 0 { - return Err(Error::InvalidAmount); - } - if storage::has_invoice(&env, invoice_id.clone()) { - return Err(Error::EscrowExists); - } - let invoice = FundingInvoice { - seller: seller.clone(), - funding_target, - total_raised: 0, - deadline_ledger, - min_investment, - per_investor_cap, - status: InvoiceStatus::Open, - token: token.clone(), - }; - storage::set_invoice(&env, invoice_id, &invoice); - Ok(()) - } - - /// Top up an existing investor position. - /// Validates invoice is Open, caller has non-zero position, and cap not exceeded. - pub fn top_up( - env: Env, - investor: Address, - invoice_id: BytesN<32>, - additional_amount: i128, - ) -> Result<(), Error> { - investor.require_auth(); - if additional_amount <= 0 { - return Err(Error::InvalidAmount); - } - let mut invoice = - storage::get_invoice(&env, invoice_id.clone()).ok_or(Error::EscrowNotFound)?; - if invoice.status != InvoiceStatus::Open { - return Err(Error::InvalidInvoiceStatus); - } - let current = storage::get_investor_position(&env, &invoice_id, &investor); - if current == 0 { - return Err(Error::NoPositionFound); - } - let new_total_position = current - .checked_add(additional_amount) - .ok_or(Error::Overflow)?; - if let Some(cap) = invoice.per_investor_cap { - if new_total_position > cap { - return Err(Error::InvalidAmount); - } - } - let new_total_raised = invoice - .total_raised - .checked_add(additional_amount) - .ok_or(Error::Overflow)?; - if new_total_raised > invoice.funding_target { - return Err(Error::InvalidAmount); - } - // Transfer additional_amount from investor to contract - let token_client = token::Client::new(&env, &invoice.token); - token_client.transfer( - &investor, - &env.current_contract_address(), - &additional_amount, - ); - storage::set_investor_position(&env, &invoice_id, &investor, new_total_position); - invoice.total_raised = new_total_raised; - storage::set_invoice(&env, invoice_id.clone(), &invoice); - events::investment_topped_up( - &env, - &investor, - invoice_id, - additional_amount, - new_total_position, - ); - Ok(()) - } - - // ---------- Invoice Registration, Investment, Refund, Settlement & TTL Refresh ---------- - - /// Register invoice metadata and funding parameters on-chain. Callable only by admin. - pub fn register_invoice( - env: Env, - invoice_id: BytesN<32>, - face_value: i128, - funding_target: i128, - yield_bps: u32, - deadline_ledger: u32, - ) -> Result<(), Error> { - let config = storage::get_config(&env).ok_or(Error::NotInit)?; - config.admin.require_auth(); - - if face_value <= 0 || funding_target <= 0 { - return Err(Error::InvalidAmount); - } - if !(1..=5000).contains(&yield_bps) { - return Err(Error::InvalidYield); - } - if storage::has_invoice_record(&env, &invoice_id) { - return Err(Error::InvoiceAlreadyExists); - } - - let data = InvoiceData { - invoice_id: invoice_id.clone(), - face_value, - funding_target, - yield_bps, - deadline_ledger, - total_raised: 0, - status: EscrowStatus::Created, - investors: soroban_sdk::Vec::new(&env), - }; - - storage::set_invoice_record(&env, &invoice_id, &data); - events::invoice_registered( - &env, - &invoice_id, - face_value, - funding_target, - yield_bps, - deadline_ledger, - ); - Ok(()) - } - - /// Admin-only: extend the funding deadline for an open invoice. - pub fn extend_deadline( - env: Env, - invoice_id: BytesN<32>, - new_deadline_ledger: u32, - ) -> Result<(), Error> { - let config = storage::get_config(&env).ok_or(Error::NotInit)?; - config.admin.require_auth(); - - let mut invoice = - storage::get_invoice(&env, invoice_id.clone()).ok_or(Error::EscrowNotFound)?; - if invoice.status != InvoiceStatus::Open { - return Err(Error::InvalidInvoiceStatus); - } - - let old_deadline_ledger = invoice.deadline_ledger; - if new_deadline_ledger <= old_deadline_ledger { - return Err(Error::DeadlineNotExtended); - } - - invoice.deadline_ledger = new_deadline_ledger; - storage::set_invoice(&env, invoice_id.clone(), &invoice); - events::deadline_extended( - &env, - &invoice_id, - old_deadline_ledger, - new_deadline_ledger, - ); - Ok(()) - } - /// Invest in a registered invoice or funding invoice. - pub fn invest( - env: Env, - invoice_id: BytesN<32>, - investor: Address, - amount: i128, - ) -> Result<(), Error> { - investor.require_auth(); - if amount <= 0 { - return Err(Error::InvalidAmount); - } - - if let Some(mut invoice) = storage::get_invoice(&env, invoice_id.clone()) { - if invoice.status != InvoiceStatus::Open { - return Err(Error::InvalidInvoiceStatus); - } - if amount < invoice.min_investment { - return Err(Error::BelowMinimumInvestment); - } - if let Some(cap) = invoice.per_investor_cap { - if amount > cap { - return Err(Error::InvalidAmount); - } - } - let new_total = invoice - .total_raised - .checked_add(amount) - .ok_or(Error::Overflow)?; - if new_total > invoice.funding_target { - return Err(Error::InvalidAmount); - } - let current = storage::get_investor_position(&env, &invoice_id, &investor); - let new_pos = current.checked_add(amount).ok_or(Error::Overflow)?; - if let Some(cap) = invoice.per_investor_cap { - if new_pos > cap { - return Err(Error::InvalidAmount); - } - } - let token_client = token::Client::new(&env, &invoice.token); - token_client.transfer(&investor, &env.current_contract_address(), &amount); - storage::set_investor_position(&env, &invoice_id, &investor, new_pos); - invoice.total_raised = new_total; - storage::set_invoice(&env, invoice_id, &invoice); - return Ok(()); - } - - let config = storage::get_config(&env).ok_or(Error::NotInit)?; - ensure_not_paused(&config)?; - - let mut record = - storage::get_invoice_record(&env, &invoice_id).ok_or(Error::EscrowNotFound)?; - if record.status != EscrowStatus::Created { - return Err(Error::InvalidInvoiceStatus); - } - let current_ledger = env.ledger().sequence(); - if current_ledger > record.deadline_ledger { - return Err(Error::FundingDeadlineNotPassed); - } - - let new_raised = record - .total_raised - .checked_add(amount) - .ok_or(Error::Overflow)?; - if new_raised > record.funding_target { - return Err(Error::InvalidAmount); - } - - let current_pos = storage::get_investor_position(&env, &invoice_id, &investor); - let new_pos = current_pos.checked_add(amount).ok_or(Error::Overflow)?; - storage::set_investor_position(&env, &invoice_id, &investor, new_pos); - - let mut already_in = false; - for inv in record.investors.iter() { - if inv == investor { - already_in = true; - break; - } - } - if !already_in { - record.investors.push_back(investor.clone()); - } - - record.total_raised = new_raised; - if record.total_raised == record.funding_target { - record.status = EscrowStatus::Funded; - } - - storage::set_invoice_record(&env, &invoice_id, &record); - Ok(()) - } - - /// Partially refund an investor's position before deadline. - pub fn partial_refund( - env: Env, - investor: Address, - invoice_id: BytesN<32>, - amount: i128, - ) -> Result<(), Error> { - investor.require_auth(); - if amount <= 0 { - return Err(Error::InvalidAmount); - } - let mut invoice = - storage::get_invoice(&env, invoice_id.clone()).ok_or(Error::EscrowNotFound)?; - if invoice.status != InvoiceStatus::Open { - return Err(Error::InvalidInvoiceStatus); - } - let current_ledger = env.ledger().sequence(); - if current_ledger >= invoice.deadline_ledger { - return Err(Error::InvalidInvoiceStatus); - } - let current = storage::get_investor_position(&env, &invoice_id, &investor); - if current == 0 { - return Err(Error::NoPositionFound); - } - if amount > current { - return Err(Error::InvalidAmount); - } - let remaining = current.checked_sub(amount).ok_or(Error::Overflow)?; - if remaining != 0 && remaining < invoice.min_investment { - return Err(Error::BelowMinimumInvestment); - } - let new_total_raised = invoice - .total_raised - .checked_sub(amount) - .ok_or(Error::Overflow)?; - // Transfer back to caller - let token_client = token::Client::new(&env, &invoice.token); - token_client.transfer(&env.current_contract_address(), &investor, &amount); - storage::set_investor_position(&env, &invoice_id, &investor, remaining); - invoice.total_raised = new_total_raised; - storage::set_invoice(&env, invoice_id.clone(), &invoice); - events::investment_partially_refunded(&env, &investor, invoice_id, amount, remaining); - Ok(()) - } - - /// Transfer a funded position from seller to buyer for an agreed price. - pub fn transfer_position( - env: Env, - from: Address, - invoice_id: BytesN<32>, - to: Address, - price: i128, - ) -> Result<(), Error> { - from.require_auth(); - if price < 0 { - return Err(Error::InvalidAmount); - } - let invoice = - storage::get_invoice(&env, invoice_id.clone()).ok_or(Error::EscrowNotFound)?; - if invoice.status != InvoiceStatus::Funded { - return Err(Error::InvalidInvoiceStatus); - } - let position = storage::get_investor_position(&env, &invoice_id, &from); - if position == 0 { - return Err(Error::NoPositionFound); - } - to.require_auth(); - if price > 0 { - let token_client = token::Client::new(&env, &invoice.token); - token_client.transfer(&to, &from, &price); - } - let buyer_existing = storage::get_investor_position(&env, &invoice_id, &to); - let new_buyer_pos = buyer_existing - .checked_add(position) - .ok_or(Error::Overflow)?; - if let Some(cap) = invoice.per_investor_cap { - if new_buyer_pos > cap { - return Err(Error::InvalidAmount); - } - } - storage::set_investor_position(&env, &invoice_id, &from, 0); - storage::set_investor_position(&env, &invoice_id, &to, new_buyer_pos); - events::position_transferred(&env, &from, &to, invoice_id, position, price); - Ok(()) - } - - /// Finalise funding: transition Open->Funded when target reached, release proceeds to seller. - pub fn finalise_funding(env: Env, invoice_id: BytesN<32>) -> Result<(), Error> { - if let Some(mut invoice) = storage::get_invoice(&env, invoice_id.clone()) { - let config = storage::get_config(&env).ok_or(Error::NotInit)?; - config.admin.require_auth(); - if invoice.status != InvoiceStatus::Open { - return Err(Error::InvalidInvoiceStatus); - } - if invoice.total_raised < invoice.funding_target { - return Err(Error::FundingTargetNotReached); - } - invoice.status = InvoiceStatus::Funded; - storage::set_invoice(&env, invoice_id.clone(), &invoice); - if invoice.total_raised > 0 { - let token_client = token::Client::new(&env, &invoice.token); - token_client.transfer( - &env.current_contract_address(), - &invoice.seller, - &invoice.total_raised, - ); - } - events::funding_finalised(&env, invoice_id, invoice.total_raised, &invoice.seller); - return Ok(()); - } - - let mut record = - storage::get_invoice_record(&env, &invoice_id).ok_or(Error::EscrowNotFound)?; - if record.status != EscrowStatus::Created && record.status != EscrowStatus::Funded { - return Err(Error::InvalidInvoiceStatus); - } - record.status = EscrowStatus::Funded; - storage::set_invoice_record(&env, &invoice_id, &record); - Ok(()) - } - - /// Refund an investor's committed position if deadline passed without reaching target. - pub fn refund( - env: Env, - invoice_id: BytesN<32>, - investor: Address, - ) -> Result<(), Error> { - investor.require_auth(); - let config = storage::get_config(&env).ok_or(Error::NotInit)?; - ensure_not_paused(&config)?; - - let mut record = - storage::get_invoice_record(&env, &invoice_id).ok_or(Error::EscrowNotFound)?; - - if record.status == EscrowStatus::Funded { - return Err(Error::InvalidInvoiceStatus); - } - - let current_ledger = env.ledger().sequence(); - if current_ledger <= record.deadline_ledger { - return Err(Error::FundingDeadlineNotPassed); - } - - let committed = storage::get_investor_position(&env, &invoice_id, &investor); - if committed <= 0 { - return Err(Error::NoPositionFound); - } - - storage::remove_investor_position(&env, &invoice_id, &investor); - - record.total_raised = record - .total_raised - .checked_sub(committed) - .ok_or(Error::Overflow)?; - - storage::set_invoice_record(&env, &invoice_id, &record); - events::investment_refunded(&env, &investor, &invoice_id, committed); - Ok(()) - } - - /// Settle invoice pro-rata across investors when seller repays. Callable only by admin. - pub fn settle_invoice( - env: Env, - invoice_id: BytesN<32>, - repayment_amount: i128, - ) -> Result<(), Error> { - let config = storage::get_config(&env).ok_or(Error::NotInit)?; - config.admin.require_auth(); - - let mut record = - storage::get_invoice_record(&env, &invoice_id).ok_or(Error::EscrowNotFound)?; - - if record.status != EscrowStatus::Funded { - return Err(Error::InvalidInvoiceStatus); - } - - if repayment_amount < record.total_raised { - return Err(Error::InsufficientRepayment); - } - - let mut total_payouts: i128 = 0; - for investor in record.investors.iter() { - let committed = storage::get_investor_position(&env, &invoice_id, &investor); - if committed > 0 { - let payout = committed - .checked_mul(repayment_amount) - .ok_or(Error::Overflow)? - .checked_div(record.total_raised) - .ok_or(Error::Overflow)?; - let yield_earned = payout.saturating_sub(committed); - total_payouts = total_payouts.checked_add(payout).ok_or(Error::Overflow)?; - events::settlement_paid(&env, &investor, &invoice_id, payout, yield_earned); - } - } - - let _dust = repayment_amount - .checked_sub(total_payouts) - .ok_or(Error::Overflow)?; - - record.status = EscrowStatus::Settled; - storage::set_invoice_record(&env, &invoice_id, &record); - Ok(()) - } - - /// Admin-only: refresh TTL for invoice record and all investor position entries. - pub fn refresh_all_ttls(env: Env, invoice_id: BytesN<32>) -> Result<(), Error> { - let config = storage::get_config(&env).ok_or(Error::NotInit)?; - config.admin.require_auth(); - - let record = - storage::get_invoice_record(&env, &invoice_id).ok_or(Error::EscrowNotFound)?; - for investor in record.investors.iter() { - let _ = storage::get_investor_position(&env, &invoice_id, &investor); - } - Ok(()) - } - - /// View: get funding invoice (BytesN<32>). - pub fn get_invoice(env: Env, invoice_id: BytesN<32>) -> Result { - storage::get_invoice(&env, invoice_id).ok_or(Error::EscrowNotFound) - } - - /// View: return registered invoice data. - pub fn get_invoice_record(env: Env, invoice_id: BytesN<32>) -> Result { - storage::get_invoice_record(&env, &invoice_id).ok_or(Error::EscrowNotFound) - } - - /// View: return investor position amount for an invoice. - pub fn get_investor_position( - env: Env, - invoice_id: BytesN<32>, - investor: Address, - ) -> Result { - Ok(storage::get_investor_position(&env, &invoice_id, &investor)) - } - - /// Paginated query to retrieve multiple escrows by sequential creation order. - pub fn get_escrows(env: Env, start: u32, limit: u32) -> Result, Error> { - const MAX_PAGE_SIZE: u32 = 100; - - if limit == 0 { - return Err(Error::InvalidLimit); - } - if limit > MAX_PAGE_SIZE { - return Err(Error::LimitExceeded); - } - - let total_count = storage::get_escrow_count(&env); - - if start >= total_count { - return Ok(soroban_sdk::Vec::new(&env)); - } - - let end = core::cmp::min(start + limit, total_count); - let mut results = soroban_sdk::Vec::new(&env); - - for index in start..end { - if let Some(invoice_id) = storage::get_escrow_id_by_index(&env, index) { - if let Some(escrow_data) = storage::get_escrow(&env, invoice_id) { - results.push_back(escrow_data); - } - } - } - - Ok(results) - } -} - -#[cfg(test)] -mod integration_test; -#[cfg(test)] -mod test; \ No newline at end of file +//! Invoice Escrow contract for StellarSettle. +//! +//! Handles escrow creation, funding by investors, payment settlement, +//! and refunds when invoices are not paid by due date. + +#![no_std] +#![allow(clippy::too_many_arguments)] + +mod errors; +mod events; +mod storage; +mod types; + +use soroban_sdk::{contract, contractimpl, token, Address, BytesN, Env, IntoVal, Symbol}; + +use types::{EmergencyApprovals, MultiSigConfig}; + +// EscrowStatus is re-exported publicly; Config and EscrowData are crate-private. +pub use types::EscrowStatus; +use types::{Config, EscrowData, FundingInvoice, InvoiceStatus}; + +use errors::Error; + +/// Reject the zero address (all-zero 32-byte Ed25519 key) which is never a valid participant. +fn ensure_non_zero_address(env: &Env, address: &Address) -> Result<(), Error> { + // Convert address to its string representation and check for the well-known + // zero account (all 32 bytes are 0x00). The StrKey encoding of the zero + // account is the constant below. + let zero_str = soroban_sdk::String::from_str( + env, + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + ); + let zero = Address::from_string(&zero_str); + if *address == zero { + return Err(Error::InvalidAddress); + } + Ok(()) +} + +const MAX_BPS: u32 = 10_000; +const DISTRIBUTE_PAYMENT_FN: &str = "distribute_payment"; +const DISTRIBUTE_REFUND_FN: &str = "distribute_refund"; + +/// Minimum escrow duration: 1 hour (3600 seconds). +const MIN_ESCROW_DURATION_SECS: u64 = 3_600; +/// Maximum escrow duration: 365 days (31,536,000 seconds). +const MAX_ESCROW_DURATION_SECS: u64 = 31_536_000; + +#[contract] +pub struct InvoiceEscrow; + +fn ensure_not_paused(config: &Config) -> Result<(), Error> { + if config.paused { + return Err(Error::Paused); + } + Ok(()) +} + +#[contractimpl] +impl InvoiceEscrow { + /// Initialize the contract with admin and platform fee (basis points, e.g. 300 = 3%). + pub fn initialize( + env: Env, + admin: Address, + platform_fee_bps: u32, + settlement_fee_bps: u32, + treasury_address: Address, + ) -> Result<(), Error> { + ensure_non_zero_address(&env, &admin)?; + ensure_non_zero_address(&env, &treasury_address)?; + admin.require_auth(); + if storage::get_config(&env).is_some() { + return Err(Error::AlreadyInit); + } + if platform_fee_bps > MAX_BPS || settlement_fee_bps > 1000 { + return Err(Error::InvalidFeeBps); + } + let config = Config { + admin: admin.clone(), + fee_bps: platform_fee_bps, + payment_distributor: None, + paused: false, + whitelist_enabled: false, + min_investment: 0, + max_investors: 500, + settlement_fee_bps, + treasury_address, + }; + storage::set_config(&env, &config); + Ok(()) + } + + /// Admin-only: set the minimum investment amount for `fund_escrow`. + /// Pass `0` to disable the floor (deposits must still be strictly positive). + pub fn set_min_investment(env: Env, admin: Address, min_investment: i128) -> Result<(), Error> { + admin.require_auth(); + if min_investment < 0 { + return Err(Error::InvalidAmount); + } + let mut config = storage::get_config(&env).ok_or(Error::NotInit)?; + if config.admin != admin { + return Err(Error::Unauthorized); + } + config.min_investment = min_investment; + storage::set_config(&env, &config); + Ok(()) + } + + /// Admin-only: set the settlement fee (basis points, 0-1000). + pub fn set_settlement_fee( + env: Env, + admin: Address, + fee_bps: u32, + treasury_address: Address, + ) -> Result<(), Error> { + admin.require_auth(); + ensure_non_zero_address(&env, &treasury_address)?; + let mut config = storage::get_config(&env).ok_or(Error::NotInit)?; + if config.admin != admin { + return Err(Error::Unauthorized); + } + if fee_bps > 1000 { + return Err(Error::FeeTooHigh); + } + config.settlement_fee_bps = fee_bps; + config.treasury_address = treasury_address; + storage::set_config(&env, &config); + Ok(()) + } + + /// Admin-only: add or remove a buyer from the whitelist. + pub fn set_buyer_whitelisted( + env: Env, + admin: Address, + buyer: Address, + allowed: bool, + ) -> Result<(), Error> { + admin.require_auth(); + let config = storage::get_config(&env).ok_or(Error::NotInit)?; + if config.admin != admin { + return Err(Error::Unauthorized); + } + storage::set_whitelisted(&env, &buyer, allowed); + Ok(()) + } + + /// View: is `buyer` whitelisted to fund escrows. + pub fn is_buyer_whitelisted(env: Env, buyer: Address) -> bool { + storage::is_whitelisted(&env, &buyer) + } + + /// Create an escrow for an invoice. Caller (seller) must be authenticated. + /// face_value: what the debtor owes (amount to be paid at settlement) + /// purchase_price: what the investor pays (discount applied here) + /// commitment: immutable on-chain anchor (SHA-256 hash of off-chain invoice data) + pub fn create_escrow( + env: Env, + invoice_id: Symbol, + seller: Address, + debtor: Address, + face_value: i128, + purchase_price: i128, + due_date: u64, + payment_token: Address, + invoice_token: Address, + commitment: soroban_sdk::BytesN<32>, + funding_milestone: Option, + ) -> Result<(), Error> { + seller.require_auth(); + if face_value <= 0 || purchase_price <= 0 { + return Err(Error::InvalidAmount); + } + if due_date == 0 { + return Err(Error::InvalidDueDate); + } + let current_timestamp = env.ledger().timestamp(); + if due_date <= current_timestamp { + return Err(Error::InvalidDueDate); + } + let duration = due_date.saturating_sub(current_timestamp); + if duration < MIN_ESCROW_DURATION_SECS || duration > MAX_ESCROW_DURATION_SECS { + return Err(Error::InvalidDuration); + } + let config = storage::get_config(&env).ok_or(Error::NotInit)?; + ensure_not_paused(&config)?; + if storage::has_escrow(&env, invoice_id.clone()) { + return Err(Error::EscrowExists); + } + // Ensure the payment token and invoice token use the same decimals to avoid + // settlement/rounding mismatches during distribution and fee calculations. + let inv_decimals: Option = env + .try_invoke_contract::( + &invoice_token, + &Symbol::new(&env, "decimals"), + soroban_sdk::vec![&env], + ) + .ok() + .and_then(|r| r.ok()); + let pay_decimals: Option = env + .try_invoke_contract::( + &payment_token, + &Symbol::new(&env, "decimals"), + soroban_sdk::vec![&env], + ) + .ok() + .and_then(|r| r.ok()); + if let (Some(inv_d), Some(pay_d)) = (inv_decimals, pay_decimals) { + if inv_d != pay_d { + return Err(Error::InvalidAssetDecimals); + } + } + let data = EscrowData { + inv_id: invoice_id.clone(), + seller: seller.clone(), + debtor: debtor.clone(), + face_value, + purchase_price, + funded_amt: 0, + funder: None, + funders: soroban_sdk::Vec::new(&env), + due_dt: due_date, + token: payment_token.clone(), + inv_token: invoice_token.clone(), + paid_amt: 0, + status: EscrowStatus::Created, + funding_milestone, + commitment: commitment.clone(), + }; + storage::set_escrow(&env, invoice_id.clone(), &data); + + // Store the invoice_id at the current index for pagination + let current_count = storage::get_escrow_count(&env); + storage::set_escrow_id_by_index(&env, current_count, &invoice_id); + storage::increment_escrow_count(&env); + + events::escrow_created( + &env, + invoice_id.clone(), + &seller, + &debtor, + face_value, + purchase_price, + due_date, + &payment_token, + &invoice_token, + &commitment, + data.funding_milestone, + ); + events::escrow_status_changed(&env, invoice_id, EscrowStatus::Created, current_timestamp); + Ok(()) + } + + /// Cancel an escrow in Created state, refunding any partial funds to the funders. + /// Only the seller may cancel, and only while status is Created. + /// Cancel an unfunded escrow. Only the seller may cancel, and only while status is Created + /// AND no investor has contributed any funds yet. + /// + /// Locked out after partial payment: `fund_escrow` accepts partial contributions and only + /// flips `status` to `Funded` once the escrow is fully subscribed, so an escrow with + /// `funded_amt > 0` can still read as `Created`. Cancelling in that window would strand the + /// investor's already-transferred funds (cancellation has no refund path), so any nonzero + /// `funded_amt` blocks cancellation regardless of status. + /// + /// Emits `escrow_refunded` (if partial funds existed) and `escrow_cancelled`. + pub fn cancel_escrow(env: Env, invoice_id: Symbol, seller: Address) -> Result<(), Error> { + seller.require_auth(); + let config = storage::get_config(&env).ok_or(Error::NotInit)?; + ensure_not_paused(&config)?; + let mut data = + storage::get_escrow(&env, invoice_id.clone()).ok_or(Error::EscrowNotFound)?; + if data.seller != seller { + return Err(Error::Unauthorized); + } + if data.status == EscrowStatus::Cancelled { + return Err(Error::EscrowCancelled); + } + if data.status == EscrowStatus::Funded { + return Err(Error::EscrowFunded); + } + if data.status != EscrowStatus::Created { + return Err(Error::CancelNotAllowed); + } + + if data.funded_amt > 0 { + let amount_to_refund = data.funded_amt; + let token = token::Client::new(&env, &data.token); + let contract = env.current_contract_address(); + let funder_opt = data.funder.clone(); + + if let Some(distributor) = config.payment_distributor.as_ref() { + token.transfer(&contract, distributor, &amount_to_refund); + env.invoke_contract::<()>( + distributor, + &Symbol::new(&env, DISTRIBUTE_REFUND_FN), + soroban_sdk::vec![ + &env, + contract.to_val(), + invoice_id.clone().into_val(&env), + soroban_sdk::vec![ + &env, +
>::into_val( + &data.token, + &env + ), + as IntoVal>::into_val( + &funder_opt, + &env, + ) + ] + .into_val(&env), + soroban_sdk::vec![&env, amount_to_refund].into_val(&env), + (EscrowStatus::Cancelled as u32).into_val(&env) + ], + ); + } else { + if let Some(funder) = &funder_opt { + let funder_amt = storage::get_funder_amount(&env, invoice_id.clone(), funder); + if funder_amt > 0 { + token.transfer(&contract, funder, &funder_amt); + } + } + } + + env.invoke_contract::<()>( + &data.inv_token, + &Symbol::new(&env, "set_transfer_locked"), + soroban_sdk::vec![&env, contract.to_val(), false.into_val(&env)], + ); + events::escrow_refunded(&env, invoice_id.clone(), amount_to_refund); + } + data.status = EscrowStatus::Cancelled; + storage::set_escrow(&env, invoice_id.clone(), &data); + events::escrow_cancelled(&env, invoice_id.clone(), &seller); + events::escrow_status_changed( + &env, + invoice_id, + EscrowStatus::Cancelled, + env.ledger().timestamp(), + ); + Ok(()) + } + + /// Fund the escrow (investor buys part or all of the invoice at purchase_price). + /// Transfers `amount` from buyer to this contract. Multiple investors can fund until fully subscribed. + pub fn fund_escrow( + env: Env, + invoice_id: Symbol, + buyer: Address, + amount: i128, + ) -> Result<(), Error> { + buyer.require_auth(); + Self::fund_escrow_core(&env, invoice_id, &buyer, amount) + } + + /// Fund the escrow on behalf of `buyer` using a signed off-chain approval that a relayer + /// submits on their behalf. `buyer` authorizes exactly this `(invoice_id, amount, nonce, expiry)` + /// tuple, and `nonce` must be strictly greater than the last nonce consumed by `buyer` so + /// the same signed approval cannot be replayed. + /// + /// Issue #183: Includes an `expiry` timestamp. If the ledger timestamp exceeds `expiry` + /// the signature is rejected, limiting the window for replay attacks. + pub fn fund_escrow_signed( + env: Env, + invoice_id: Symbol, + buyer: Address, + amount: i128, + nonce: u64, + expiry: u64, + ) -> Result<(), Error> { + buyer.require_auth_for_args((invoice_id.clone(), amount, nonce, expiry).into_val(&env)); + + let current_ts = env.ledger().timestamp(); + if current_ts > expiry { + return Err(Error::SignatureExpired); + } + + let last_nonce = storage::get_nonce(&env, &buyer); + if nonce <= last_nonce { + return Err(Error::NonceAlreadyUsed); + } + + Self::fund_escrow_core(&env, invoice_id.clone(), &buyer, amount)?; + + storage::set_nonce(&env, &buyer, nonce); + events::escrow_funded_signed(&env, invoice_id, &buyer, amount, nonce); + Ok(()) + } + + /// Shared funding logic used by both the directly-authorized and signed-approval entry points. + fn fund_escrow_core( + env: &Env, + invoice_id: Symbol, + buyer: &Address, + amount: i128, + ) -> Result<(), Error> { + // Fail fast: validate amount before hitting storage. + if amount == 0 { + return Err(Error::ZeroAmount); + } + if amount < 0 { + return Err(Error::InvalidAmount); + } + let config = storage::get_config(env).ok_or(Error::NotInit)?; + ensure_not_paused(&config)?; + if config.whitelist_enabled && !storage::is_whitelisted(env, buyer) { + return Err(Error::NotWhitelisted); + } + + let mut data = storage::get_escrow(env, invoice_id.clone()).ok_or(Error::EscrowNotFound)?; + if data.status == EscrowStatus::Cancelled { + return Err(Error::EscrowCancelled); + } + if data.status != EscrowStatus::Created { + return Err(Error::EscrowFunded); + } + + // Check that funding doesn't exceed purchase_price + let new_funded = data.funded_amt.checked_add(amount).ok_or(Error::Overflow)?; + if new_funded > data.purchase_price { + return Err(Error::InvalidAmount); + } + + let remaining_to_fund = data + .purchase_price + .checked_sub(data.funded_amt) + .ok_or(Error::Overflow)?; + + // Enforce global minimum investment to prevent dust deposits, except when + // the funder is completing the exact remaining capacity. + if config.min_investment > 0 + && amount != remaining_to_fund + && amount < config.min_investment + { + return Err(Error::AmountBelowMinimum); + } + + // Validate milestone constraints if a milestone is set + if let Some(milestone) = data.funding_milestone { + // Funder is always allowed to just fund exactly the remaining amount to complete the escrow. + // If they are not completing the escrow, the amount must be at least the milestone and a multiple of it. + if amount != remaining_to_fund && (amount < milestone || amount % milestone != 0) { + return Err(Error::InvalidMilestoneAmount); + } + } + + let token = token::Client::new(env, &data.token); + let contract = env.current_contract_address(); + token.transfer(buyer, &contract, &amount); + + // Mint invoice tokens to the buyer to represent their ownership share + env.invoke_contract::<()>( + &data.inv_token, + &Symbol::new(env, "mint"), + soroban_sdk::vec![env, buyer.to_val(), amount.into_val(env), contract.to_val()], + ); + + // Track this funder's contribution + let current_funder_amt = storage::get_funder_amount(env, invoice_id.clone(), buyer); + let new_funder_amt = current_funder_amt + .checked_add(amount) + .ok_or(Error::Overflow)?; + storage::set_funder_amount(env, invoice_id.clone(), buyer, new_funder_amt); + + data.funded_amt = new_funded; + + let mut already_recorded = false; + for funder in data.funders.iter() { + if funder == buyer.clone() { + already_recorded = true; + break; + } + } + if !already_recorded { + data.funders.push_back(buyer.clone()); + } + + // MVP: Store the first funder for direct distribution + if data.funder.is_none() { + data.funder = Some(buyer.clone()); + } + + // If fully funded, transition to Funded status + if data.funded_amt == data.purchase_price { + data.status = EscrowStatus::Funded; + } + + storage::set_escrow(env, invoice_id.clone(), &data); + events::escrow_funded( + env, + invoice_id.clone(), + buyer, + amount, + data.funded_amt, + data.purchase_price, + ); + if data.status == EscrowStatus::Funded { + events::escrow_status_changed( + env, + invoice_id, + EscrowStatus::Funded, + env.ledger().timestamp(), + ); + } + Ok(()) + } + + /// Record payment: distribute to investors and platform fee. Payer must auth. + /// Payer must be the authorized debtor for this invoice. + /// Payment is applied toward face_value; fees are calculated on the payment amount. + /// MVP: Distributes pro-rata to all funders based on their contribution. + pub fn record_payment( + env: Env, + invoice_id: Symbol, + payer: Address, + amount: i128, + ) -> Result<(), Error> { + payer.require_auth(); + if amount <= 0 { + return Err(Error::InvalidAmount); + } + let config = storage::get_config(&env).ok_or(Error::NotInit)?; + ensure_not_paused(&config)?; + let mut data = + storage::get_escrow(&env, invoice_id.clone()).ok_or(Error::EscrowNotFound)?; + + // Enforce payer role: payer must be the authorized debtor + if payer != data.debtor { + return Err(Error::InvalidPayer); + } + + if data.status != EscrowStatus::Funded { + return Err(Error::AlreadySettled); + } + + // Remaining balance toward face_value + let remaining = data + .face_value + .checked_sub(data.paid_amt) + .ok_or(Error::Overflow)?; + if amount > remaining { + return Err(Error::InvalidAmount); + } + + let fee_bps = i128::from(config.fee_bps); + let settlement_fee_bps = i128::from(config.settlement_fee_bps); + + // Fees are calculated on the payment amount + let platform_fee = amount + .checked_mul(fee_bps) + .ok_or(Error::Overflow)? + .checked_div(i128::from(MAX_BPS)) + .ok_or(Error::Overflow)?; + + let settlement_fee = amount + .checked_mul(settlement_fee_bps) + .ok_or(Error::Overflow)? + .checked_div(i128::from(MAX_BPS)) + .ok_or(Error::Overflow)?; + + let total_fee = platform_fee.checked_add(settlement_fee).ok_or(Error::Overflow)?; + let investor_amount = amount.checked_sub(total_fee).ok_or(Error::Overflow)?; + + let token = token::Client::new(&env, &data.token); + let contract = env.current_contract_address(); + + // 1. Pull payer's funds into escrow + token.transfer(&payer, &contract, &amount); + + data.paid_amt = data.paid_amt.checked_add(amount).ok_or(Error::Overflow)?; + + // Settlement occurs when paid_amt reaches face_value + if data.paid_amt == data.face_value { + data.status = EscrowStatus::Settled; + } + + storage::set_escrow(&env, invoice_id.clone(), &data); + + let funder_addr = data.funder.clone().unwrap_or_else(|| data.seller.clone()); + + if let Some(distributor) = config.payment_distributor.as_ref() { + // The distributor must pay seller_amount (== amount) plus investor_amount + platform_fee + // (== amount), mirroring the direct path below which releases the payer's `amount` to the + // seller in addition to paying the investor/admin out of escrow's held funding. + let total_to_distributor = amount.checked_add(amount).ok_or(Error::Overflow)?; + token.transfer(&contract, distributor, &total_to_distributor); + env.invoke_contract::<()>( + distributor, + &Symbol::new(&env, DISTRIBUTE_PAYMENT_FN), + soroban_sdk::vec![ + &env, + contract.to_val(), + invoice_id.clone().into_val(&env), + soroban_sdk::vec![ + &env, +
>::into_val(&data.token, &env), +
>::into_val(&data.seller, &env), +
>::into_val(&funder_addr, &env), +
>::into_val(&config.admin, &env) + ] + .into_val(&env), + soroban_sdk::vec![ + &env, + data.paid_amt, + amount, + investor_amount, + config.fee_bps as i128, + ] + .into_val(&env), + (data.status as u32).into_val(&env) + ], + ); + } else { + // 2. Platform fee to admin + token.transfer(&contract, &config.admin, &platform_fee); + // 2b. Settlement fee to treasury + token.transfer(&contract, &config.treasury_address, &settlement_fee); + + // 3. Pro-rata investor distribution + if let Some(funder) = &data.funder { + if data.funded_amt > 0 && investor_amount > 0 { + let funder_amt = storage::get_funder_amount(&env, invoice_id.clone(), funder); + let pro_rata_share = investor_amount + .checked_mul(funder_amt) + .ok_or(Error::Overflow)? + .checked_div(data.funded_amt) + .ok_or(Error::Overflow)?; + if pro_rata_share > 0 { + token.transfer(&contract, funder, &pro_rata_share); + } + } + } + // Seller receives the full payment amount + token.transfer(&contract, &data.seller, &amount); + } + + if data.status == EscrowStatus::Settled { + // Unlock invoice token transfers only when the invoice is completely settled. + env.invoke_contract::<()>( + &data.inv_token, + &Symbol::new(&env, "set_transfer_locked"), + soroban_sdk::vec![&env, contract.to_val(), false.into_val(&env)], + ); + } + + events::payment_settled( + &env, + invoice_id.clone(), + amount, + platform_fee, + investor_amount, + ); + events::fee_collected(&env, settlement_fee, &config.treasury_address); + if data.status == EscrowStatus::Settled { + events::escrow_status_changed( + &env, + invoice_id, + EscrowStatus::Settled, + env.ledger().timestamp(), + ); + } + Ok(()) + } + + /// Refund the investors if the invoice was not paid by due date. Anyone may call. + /// Refunds are distributed pro-rata based on each investor's contribution. + pub fn refund(env: Env, invoice_id: Symbol) -> Result<(), Error> { + let config = storage::get_config(&env).ok_or(Error::NotInit)?; + ensure_not_paused(&config)?; + let mut data = + storage::get_escrow(&env, invoice_id.clone()).ok_or(Error::EscrowNotFound)?; + if data.status != EscrowStatus::Funded { + return Err(Error::RefundNotAllowed); + } + let ledger_ts = env.ledger().timestamp(); + if ledger_ts < data.due_dt { + return Err(Error::RefundNotAllowed); + } + + // Refund the remaining collateral (purchase_price minus already released partial payments) + let amount_to_refund = data + .purchase_price + .checked_sub(data.paid_amt) + .ok_or(Error::Overflow)?; + + let token = token::Client::new(&env, &data.token); + let contract = env.current_contract_address(); + + // Extract funder address before status mutation so it is available in both paths. + let funder_opt = data.funder.clone(); + + data.status = EscrowStatus::Refunded; + storage::set_escrow(&env, invoice_id.clone(), &data); + + if amount_to_refund > 0 { + if let Some(distributor) = config.payment_distributor.as_ref() { + token.transfer(&contract, distributor, &amount_to_refund); + env.invoke_contract::<()>( + distributor, + &Symbol::new(&env, DISTRIBUTE_REFUND_FN), + soroban_sdk::vec![ + &env, + contract.to_val(), + invoice_id.clone().into_val(&env), + soroban_sdk::vec![ + &env, +
>::into_val( + &data.token, + &env + ), + as IntoVal>::into_val( + &funder_opt, + &env, + ) + ] + .into_val(&env), + soroban_sdk::vec![&env, amount_to_refund].into_val(&env), + (data.status as u32).into_val(&env) + ], + ); + } else { + // Pro-rata refund to funders + if let Some(funder) = &funder_opt { + if data.funded_amt > 0 { + let funder_amt = + storage::get_funder_amount(&env, invoice_id.clone(), funder); + let pro_rata_refund = amount_to_refund + .checked_mul(funder_amt) + .ok_or(Error::Overflow)? + .checked_div(data.funded_amt) + .ok_or(Error::Overflow)?; + if pro_rata_refund > 0 { + token.transfer(&contract, funder, &pro_rata_refund); + } + } + } + } + } + + // Unlock invoice token transfers now that the invoice is refunded + env.invoke_contract::<()>( + &data.inv_token, + &Symbol::new(&env, "set_transfer_locked"), + soroban_sdk::vec![&env, contract.to_val(), false.into_val(&env)], + ); + + events::escrow_refunded(&env, invoice_id.clone(), amount_to_refund); + events::escrow_status_changed( + &env, + invoice_id, + EscrowStatus::Refunded, + env.ledger().timestamp(), + ); + Ok(()) + } + + /// Update platform fee (basis points). Admin only. + pub fn update_platform_fee_bps(env: Env, new_fee_bps: u32) -> Result<(), Error> { + let mut config = storage::get_config(&env).ok_or(Error::NotInit)?; + let admin = config.admin.clone(); + admin.require_auth(); + if new_fee_bps > MAX_BPS { + return Err(Error::InvalidFeeBps); + } + let old_fee_bps = config.fee_bps; + config.fee_bps = new_fee_bps; + storage::set_config(&env, &config); + events::platform_fee_updated(&env, old_fee_bps, new_fee_bps); + Ok(()) + } + + /// Set the payment distributor used for settlement/refund fan-out. Admin only. + pub fn set_payment_distributor(env: Env, payment_distributor: Address) -> Result<(), Error> { + let mut config = storage::get_config(&env).ok_or(Error::NotInit)?; + let admin = config.admin.clone(); + admin.require_auth(); + let old_distributor = config.payment_distributor.clone(); + config.payment_distributor = Some(payment_distributor.clone()); + storage::set_config(&env, &config); + events::payment_distributor_updated(&env, old_distributor.is_some(), &payment_distributor); + Ok(()) + } + + /// Toggle the emergency pause flag. Admin only. + pub fn set_paused(env: Env, paused: bool) -> Result<(), Error> { + let mut config = storage::get_config(&env).ok_or(Error::NotInit)?; + let admin = config.admin.clone(); + admin.require_auth(); + let old_paused = config.paused; + config.paused = paused; + storage::set_config(&env, &config); + events::paused_updated(&env, old_paused, paused); + Ok(()) + } + + /// View: return escrow data for an invoice, or Err(Error::EscrowNotFound) if not found. + pub fn get_escrow(env: Env, invoice_id: Symbol) -> Result { + storage::get_escrow(&env, invoice_id).ok_or(Error::EscrowNotFound) + } + + /// View: return current config (admin and fee_bps). + pub fn get_config(env: Env) -> Result { + storage::get_config(&env).ok_or(Error::NotInit) + } + + /// View: return escrow status for an invoice. + pub fn get_escrow_status(env: Env, invoice_id: Symbol) -> Result { + let data = storage::get_escrow(&env, invoice_id).ok_or(Error::EscrowNotFound)?; + Ok(data.status) + } + + /// View: return the current pause state. + pub fn paused(env: Env) -> Result { + let config = storage::get_config(&env).ok_or(Error::NotInit)?; + Ok(config.paused) + } + + /// Admin-only: configure the emergency multi-sig admin set and threshold. + pub fn set_emergency_config( + env: Env, + admin: Address, + config: MultiSigConfig, + ) -> Result<(), Error> { + admin.require_auth(); + let stored_config = storage::get_config(&env).ok_or(Error::NotInit)?; + if stored_config.admin != admin { + return Err(Error::Unauthorized); + } + if config.threshold == 0 || config.threshold > config.admins.len() as u32 { + return Err(Error::InvalidFeeBps); // reuse for invalid threshold + } + storage::set_emergency_config(&env, &config); + Ok(()) + } + + /// Emergency multi-sig release: an admin approves releasing funds for an invoice. + /// When the threshold is reached, funds are paid out to the seller and the escrow + /// is marked as Settled. + pub fn emergency_release(env: Env, caller: Address, invoice_id: Symbol) -> Result<(), Error> { + caller.require_auth(); + let config = storage::get_emergency_config(&env).ok_or(Error::EmergencyNotConfigured)?; + + // Verify caller is an emergency admin + let mut is_admin = false; + for admin in config.admins.iter() { + if admin == caller { + is_admin = true; + break; + } + } + if !is_admin { + return Err(Error::NotEmergencyAdmin); + } + + let mut approvals = storage::get_emergency_approvals(&env, &invoice_id); + + // Check for duplicate approval + for addr in approvals.approvals.iter() { + if addr == caller { + return Err(Error::AlreadyApproved); + } + } + + approvals.approvals.push_back(caller.clone()); + storage::set_emergency_approvals(&env, &invoice_id, &approvals); + + if (approvals.approvals.len() as u32) < config.threshold { + return Err(Error::ThresholdNotMet); + } + + // Threshold reached — execute emergency release + let mut data = + storage::get_escrow(&env, invoice_id.clone()).ok_or(Error::EscrowNotFound)?; + + if data.status == EscrowStatus::Settled + || data.status == EscrowStatus::Refunded + || data.status == EscrowStatus::Cancelled + { + return Err(Error::AlreadySettled); + } + + let token = token::Client::new(&env, &data.token); + let contract = env.current_contract_address(); + let remaining = data + .purchase_price + .checked_sub(data.paid_amt) + .ok_or(Error::Overflow)?; + + // Pay remaining to seller + if remaining > 0 { + token.transfer(&contract, &data.seller, &remaining); + } + + data.status = EscrowStatus::Settled; + storage::set_escrow(&env, invoice_id.clone(), &data); + + events::escrow_status_changed( + &env, + invoice_id.clone(), + EscrowStatus::Settled, + env.ledger().timestamp(), + ); + Ok(()) + } + + /// Reclaim persistent storage for an escrow that has reached a terminal state + /// (Settled, Refunded, or Cancelled). Callable only by the seller or the admin. + /// The escrow and its per-funder contribution record are removed permanently; + /// terminal-state escrows are never mutated again, so this is safe to prune. + pub fn cleanup_escrow(env: Env, invoice_id: Symbol, caller: Address) -> Result<(), Error> { + caller.require_auth(); + let config = storage::get_config(&env).ok_or(Error::NotInit)?; + let data = storage::get_escrow(&env, invoice_id.clone()).ok_or(Error::EscrowNotFound)?; + if caller != data.seller && caller != config.admin { + return Err(Error::Unauthorized); + } + match data.status { + EscrowStatus::Settled | EscrowStatus::Refunded | EscrowStatus::Cancelled => {} + _ => return Err(Error::EscrowNotSettled), + } + storage::remove_escrow_state(&env, invoice_id.clone(), &data.funders); + events::escrow_cleaned_up(&env, invoice_id); + Ok(()) + } + + // ── Position management: top_up / partial_refund / transfer_position / finalise_funding ── + + /// Admin-only: cancel an open invoice (BytesN<32>), transitioning it to Cancelled. + pub fn cancel_invoice(env: Env, admin: Address, invoice_id: BytesN<32>) -> Result<(), Error> { + admin.require_auth(); + let config = storage::get_config(&env).ok_or(Error::NotInit)?; + if config.admin != admin { + return Err(Error::Unauthorized); + } + let mut invoice = + storage::get_invoice(&env, invoice_id.clone()).ok_or(Error::EscrowNotFound)?; + + if invoice.status == InvoiceStatus::Funded || invoice.status == InvoiceStatus::Settled { + return Err(Error::InvalidInvoiceStatus); + } + if invoice.status == InvoiceStatus::Cancelled { + return Err(Error::InvalidInvoiceStatus); + } + + invoice.status = InvoiceStatus::Cancelled; + storage::set_invoice(&env, invoice_id.clone(), &invoice); + + events::invoice_cancelled(&env, invoice_id, &admin); + + Ok(()) + } + + /// Create a funding invoice (BytesN<32> id) for the new position management flows. + /// This is the setup entrypoint for tests and admin tooling for the + /// top_up / partial_refund / transfer_position / finalise_funding lifecycle. + pub fn create_invoice( + env: Env, + invoice_id: BytesN<32>, + seller: Address, + funding_target: i128, + deadline_ledger: u32, + min_investment: i128, + per_investor_cap: Option, + token: Address, + ) -> Result<(), Error> { + seller.require_auth(); + if funding_target <= 0 { + return Err(Error::InvalidAmount); + } + if min_investment < 0 { + return Err(Error::InvalidAmount); + } + if storage::has_invoice(&env, invoice_id.clone()) { + return Err(Error::EscrowExists); + } + let invoice = FundingInvoice { + seller: seller.clone(), + funding_target, + total_raised: 0, + deadline_ledger, + min_investment, + per_investor_cap, + status: InvoiceStatus::Open, + token: token.clone(), + }; + storage::set_invoice(&env, invoice_id, &invoice); + Ok(()) + } + + /// Top up an existing investor position. + /// Validates invoice is Open, caller has non-zero position, and cap not exceeded. + pub fn top_up( + env: Env, + investor: Address, + invoice_id: BytesN<32>, + additional_amount: i128, + ) -> Result<(), Error> { + investor.require_auth(); + if additional_amount <= 0 { + return Err(Error::InvalidAmount); + } + let mut invoice = + storage::get_invoice(&env, invoice_id.clone()).ok_or(Error::EscrowNotFound)?; + if invoice.status != InvoiceStatus::Open { + return Err(Error::InvalidInvoiceStatus); + } + let current = storage::get_investor_position(&env, invoice_id.clone(), &investor); + if current == 0 { + return Err(Error::NoPositionFound); + } + let new_total_position = current + .checked_add(additional_amount) + .ok_or(Error::Overflow)?; + if let Some(cap) = invoice.per_investor_cap { + if new_total_position > cap { + return Err(Error::InvalidAmount); + } + } + let new_total_raised = invoice + .total_raised + .checked_add(additional_amount) + .ok_or(Error::Overflow)?; + if new_total_raised > invoice.funding_target { + return Err(Error::InvalidAmount); + } + // Transfer additional_amount from investor to contract + let token_client = token::Client::new(&env, &invoice.token); + token_client.transfer( + &investor, + &env.current_contract_address(), + &additional_amount, + ); + storage::set_investor_position(&env, invoice_id.clone(), &investor, new_total_position); + invoice.total_raised = new_total_raised; + storage::set_invoice(&env, invoice_id.clone(), &invoice); + events::investment_topped_up( + &env, + &investor, + invoice_id, + additional_amount, + new_total_position, + ); + Ok(()) + } + + /// Initial investment helper for FundingInvoice flow (used to set up position before top_up). + pub fn invest( + env: Env, + investor: Address, + invoice_id: BytesN<32>, + amount: i128, + ) -> Result<(), Error> { + investor.require_auth(); + if amount <= 0 { + return Err(Error::InvalidAmount); + } + let mut invoice = + storage::get_invoice(&env, invoice_id.clone()).ok_or(Error::EscrowNotFound)?; + if invoice.status != InvoiceStatus::Open { + return Err(Error::InvalidInvoiceStatus); + } + if amount < invoice.min_investment { + return Err(Error::BelowMinimumInvestment); + } + if let Some(cap) = invoice.per_investor_cap { + if amount > cap { + return Err(Error::InvalidAmount); + } + } + let new_total = invoice + .total_raised + .checked_add(amount) + .ok_or(Error::Overflow)?; + if new_total > invoice.funding_target { + return Err(Error::InvalidAmount); + } + let current = storage::get_investor_position(&env, invoice_id.clone(), &investor); + if current == 0 { + let investor_count = storage::get_investor_count(&env, invoice_id.clone()); + let config = storage::get_config(&env).ok_or(Error::NotInit)?; + if investor_count >= config.max_investors { + return Err(Error::MaxInvestorsReached); + } + storage::increment_investor_count(&env, invoice_id.clone()); + } + let new_pos = current.checked_add(amount).ok_or(Error::Overflow)?; + if let Some(cap) = invoice.per_investor_cap { + if new_pos > cap { + return Err(Error::InvalidAmount); + } + } + let token_client = token::Client::new(&env, &invoice.token); + token_client.transfer(&investor, &env.current_contract_address(), &amount); + storage::set_investor_position(&env, invoice_id.clone(), &investor, new_pos); + invoice.total_raised = new_total; + storage::set_invoice(&env, invoice_id, &invoice); + Ok(()) + } + + /// Partially refund an investor's position before deadline. + pub fn partial_refund( + env: Env, + investor: Address, + invoice_id: BytesN<32>, + amount: i128, + ) -> Result<(), Error> { + investor.require_auth(); + if amount <= 0 { + return Err(Error::InvalidAmount); + } + let mut invoice = + storage::get_invoice(&env, invoice_id.clone()).ok_or(Error::EscrowNotFound)?; + if invoice.status != InvoiceStatus::Open { + return Err(Error::InvalidInvoiceStatus); + } + let current_ledger = env.ledger().sequence(); + if current_ledger >= invoice.deadline_ledger { + return Err(Error::InvalidInvoiceStatus); + } + let current = storage::get_investor_position(&env, invoice_id.clone(), &investor); + if current == 0 { + return Err(Error::NoPositionFound); + } + if amount > current { + return Err(Error::InvalidAmount); + } + let remaining = current.checked_sub(amount).ok_or(Error::Overflow)?; + if remaining != 0 && remaining < invoice.min_investment { + return Err(Error::BelowMinimumInvestment); + } + let new_total_raised = invoice + .total_raised + .checked_sub(amount) + .ok_or(Error::Overflow)?; + // Transfer back to caller + let token_client = token::Client::new(&env, &invoice.token); + token_client.transfer(&env.current_contract_address(), &investor, &amount); + storage::set_investor_position(&env, invoice_id.clone(), &investor, remaining); + invoice.total_raised = new_total_raised; + storage::set_invoice(&env, invoice_id.clone(), &invoice); + events::investment_partially_refunded(&env, &investor, invoice_id, amount, remaining); + Ok(()) + } + + /// Transfer a funded position from seller to buyer for an agreed price. + pub fn transfer_position( + env: Env, + from: Address, + invoice_id: BytesN<32>, + to: Address, + price: i128, + ) -> Result<(), Error> { + from.require_auth(); + if price < 0 { + return Err(Error::InvalidAmount); + } + let invoice = + storage::get_invoice(&env, invoice_id.clone()).ok_or(Error::EscrowNotFound)?; + if invoice.status != InvoiceStatus::Funded { + return Err(Error::InvalidInvoiceStatus); + } + let position = storage::get_investor_position(&env, invoice_id.clone(), &from); + if position == 0 { + return Err(Error::NoPositionFound); + } + // Collect price from buyer to seller (token transfer price if >0) + // Require both parties to authenticate + to.require_auth(); + if price > 0 { + let token_client = token::Client::new(&env, &invoice.token); + token_client.transfer(&to, &from, &price); + } else if price < 0 { + return Err(Error::InvalidAmount); + } + let buyer_existing = storage::get_investor_position(&env, invoice_id.clone(), &to); + let new_buyer_pos = buyer_existing + .checked_add(position) + .ok_or(Error::Overflow)?; + if let Some(cap) = invoice.per_investor_cap { + if new_buyer_pos > cap { + return Err(Error::InvalidAmount); + } + } + storage::set_investor_position(&env, invoice_id.clone(), &from, 0); + storage::set_investor_position(&env, invoice_id.clone(), &to, new_buyer_pos); + events::position_transferred(&env, &from, &to, invoice_id, position, price); + Ok(()) + } + + /// Finalise funding: transition Open->Funded when target reached, release proceeds to seller. + pub fn finalise_funding(env: Env, invoice_id: BytesN<32>) -> Result<(), Error> { + let config = storage::get_config(&env).ok_or(Error::NotInit)?; + config.admin.require_auth(); + let mut invoice = + storage::get_invoice(&env, invoice_id.clone()).ok_or(Error::EscrowNotFound)?; + if invoice.status != InvoiceStatus::Open { + return Err(Error::InvalidInvoiceStatus); + } + if invoice.total_raised < invoice.funding_target { + return Err(Error::FundingTargetNotReached); + } + invoice.status = InvoiceStatus::Funded; + storage::set_invoice(&env, invoice_id.clone(), &invoice); + if invoice.total_raised > 0 { + let token_client = token::Client::new(&env, &invoice.token); + token_client.transfer( + &env.current_contract_address(), + &invoice.seller, + &invoice.total_raised, + ); + } + events::funding_finalised(&env, invoice_id, invoice.total_raised, &invoice.seller); + Ok(()) + } + + /// View: get funding invoice (BytesN<32>). + pub fn get_invoice(env: Env, invoice_id: BytesN<32>) -> Result { + storage::get_invoice(&env, invoice_id).ok_or(Error::EscrowNotFound) + } + + /// View: get investor position for a BytesN<32> invoice. + pub fn get_investor_position( + env: Env, + invoice_id: BytesN<32>, + investor: Address, + ) -> Result { + if storage::get_invoice(&env, invoice_id.clone()).is_none() { + return Err(Error::EscrowNotFound); + } + Ok(storage::get_investor_position(&env, invoice_id, &investor)) + } + + /// Paginated query to retrieve multiple escrows by sequential creation order. + /// Returns a Vec of EscrowData for the requested range [start, start+limit). + /// Maximum page size is 100. Returns empty Vec if start >= total_count. + pub fn get_escrows(env: Env, start: u32, limit: u32) -> Result, Error> { + const MAX_PAGE_SIZE: u32 = 100; + + if limit == 0 { + return Err(Error::InvalidLimit); + } + if limit > MAX_PAGE_SIZE { + return Err(Error::LimitExceeded); + } + + let total_count = storage::get_escrow_count(&env); + + if start >= total_count { + return Ok(soroban_sdk::Vec::new(&env)); + } + + let end = core::cmp::min(start + limit, total_count); + let mut results = soroban_sdk::Vec::new(&env); + + for index in start..end { + if let Some(invoice_id) = storage::get_escrow_id_by_index(&env, index) { + if let Some(escrow_data) = storage::get_escrow(&env, invoice_id) { + results.push_back(escrow_data); + } + } + } + + Ok(results) + } + +} + +#[cfg(test)] +mod integration_test; +#[cfg(test)] +mod test; +//! Invoice Escrow contract for StellarSettle. +//! +//! Handles escrow creation, funding by investors, payment settlement, +//! and refunds when invoices are not paid by due date. + +#![no_std] +#![allow(clippy::too_many_arguments)] + +mod errors; +mod events; +mod storage; +mod types; + +use soroban_sdk::{contract, contractimpl, token, Address, BytesN, Env, IntoVal, Symbol}; + +use types::MultiSigConfig; + +// EscrowStatus is re-exported publicly; Config, EscrowData, and InvoiceData are crate-private. +pub use types::EscrowStatus; +use types::{Config, EscrowData, FundingInvoice, InvoiceData, InvoiceStatus}; + +use errors::Error; + +/// Reject the zero address (all-zero 32-byte Ed25519 key) which is never a valid participant. +fn ensure_non_zero_address(env: &Env, address: &Address) -> Result<(), Error> { + // Convert address to its string representation and check for the well-known + // zero account (all 32 bytes are 0x00). The StrKey encoding of the zero + // account is the constant below. + let zero_str = soroban_sdk::String::from_str( + env, + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + ); + let zero = Address::from_string(&zero_str); + if *address == zero { + return Err(Error::InvalidAddress); + } + Ok(()) +} + +const MAX_BPS: u32 = 10_000; +const DISTRIBUTE_PAYMENT_FN: &str = "distribute_payment"; +const DISTRIBUTE_REFUND_FN: &str = "distribute_refund"; + +/// Minimum escrow duration: 1 hour (3600 seconds). +const MIN_ESCROW_DURATION_SECS: u64 = 3_600; +/// Maximum escrow duration: 365 days (31,536,000 seconds). +const MAX_ESCROW_DURATION_SECS: u64 = 31_536_000; + +#[contract] +pub struct InvoiceEscrow; + +fn ensure_not_paused(config: &Config) -> Result<(), Error> { + if config.paused { + return Err(Error::Paused); + } + Ok(()) +} + +#[contractimpl] +impl InvoiceEscrow { + /// Initialize the contract with admin and platform fee (basis points, e.g. 300 = 3%). + pub fn initialize(env: Env, admin: Address, platform_fee_bps: u32) -> Result<(), Error> { + ensure_non_zero_address(&env, &admin)?; + admin.require_auth(); + if storage::get_config(&env).is_some() { + return Err(Error::AlreadyInit); + } + if platform_fee_bps > MAX_BPS { + return Err(Error::InvalidFeeBps); + } + let config = Config { + admin: admin.clone(), + fee_bps: platform_fee_bps, + payment_distributor: None, + paused: false, + whitelist_enabled: false, + min_investment: 0, + }; + storage::set_config(&env, &config); + Ok(()) + } + + /// Admin-only: set the minimum investment amount for `fund_escrow`. + /// Pass `0` to disable the floor (deposits must still be strictly positive). + pub fn set_min_investment(env: Env, admin: Address, min_investment: i128) -> Result<(), Error> { + admin.require_auth(); + if min_investment < 0 { + return Err(Error::InvalidAmount); + } + let mut config = storage::get_config(&env).ok_or(Error::NotInit)?; + if config.admin != admin { + return Err(Error::Unauthorized); + } + config.min_investment = min_investment; + storage::set_config(&env, &config); + Ok(()) + } + + /// Admin-only: enable/disable buyer whitelist enforcement on `fund_escrow`. + pub fn set_whitelist_enabled(env: Env, admin: Address, enabled: bool) -> Result<(), Error> { + admin.require_auth(); + let mut config = storage::get_config(&env).ok_or(Error::NotInit)?; + if config.admin != admin { + return Err(Error::Unauthorized); + } + config.whitelist_enabled = enabled; + storage::set_config(&env, &config); + Ok(()) + } + + /// Admin-only: add or remove a buyer from the whitelist. + pub fn set_buyer_whitelisted( + env: Env, + admin: Address, + buyer: Address, + allowed: bool, + ) -> Result<(), Error> { + admin.require_auth(); + let config = storage::get_config(&env).ok_or(Error::NotInit)?; + if config.admin != admin { + return Err(Error::Unauthorized); + } + storage::set_whitelisted(&env, &buyer, allowed); + Ok(()) + } + + /// View: is `buyer` whitelisted to fund escrows. + pub fn is_buyer_whitelisted(env: Env, buyer: Address) -> bool { + storage::is_whitelisted(&env, &buyer) + } + + /// Create an escrow for an invoice. Caller (seller) must be authenticated. + /// face_value: what the debtor owes (amount to be paid at settlement) + /// purchase_price: what the investor pays (discount applied here) + /// commitment: immutable on-chain anchor (SHA-256 hash of off-chain invoice data) + pub fn create_escrow( + env: Env, + invoice_id: Symbol, + seller: Address, + debtor: Address, + face_value: i128, + purchase_price: i128, + due_date: u64, + payment_token: Address, + invoice_token: Address, + commitment: soroban_sdk::BytesN<32>, + funding_milestone: Option, + ) -> Result<(), Error> { + seller.require_auth(); + if face_value <= 0 || purchase_price <= 0 { + return Err(Error::InvalidAmount); + } + if due_date == 0 { + return Err(Error::InvalidDueDate); + } + let current_timestamp = env.ledger().timestamp(); + if due_date <= current_timestamp { + return Err(Error::InvalidDueDate); + } + let duration = due_date.saturating_sub(current_timestamp); + if duration < MIN_ESCROW_DURATION_SECS || duration > MAX_ESCROW_DURATION_SECS { + return Err(Error::InvalidDuration); + } + let config = storage::get_config(&env).ok_or(Error::NotInit)?; + ensure_not_paused(&config)?; + if storage::has_escrow(&env, invoice_id.clone()) { + return Err(Error::EscrowExists); + } + // Ensure the payment token and invoice token use the same decimals to avoid + // settlement/rounding mismatches during distribution and fee calculations. + let inv_decimals: Option = env + .try_invoke_contract::( + &invoice_token, + &Symbol::new(&env, "decimals"), + soroban_sdk::vec![&env], + ) + .ok() + .and_then(|r| r.ok()); + let pay_decimals: Option = env + .try_invoke_contract::( + &payment_token, + &Symbol::new(&env, "decimals"), + soroban_sdk::vec![&env], + ) + .ok() + .and_then(|r| r.ok()); + if let (Some(inv_d), Some(pay_d)) = (inv_decimals, pay_decimals) { + if inv_d != pay_d { + return Err(Error::InvalidAssetDecimals); + } + } + let data = EscrowData { + inv_id: invoice_id.clone(), + seller: seller.clone(), + debtor: debtor.clone(), + face_value, + purchase_price, + funded_amt: 0, + funder: None, + funders: soroban_sdk::Vec::new(&env), + due_dt: due_date, + token: payment_token.clone(), + inv_token: invoice_token.clone(), + paid_amt: 0, + status: EscrowStatus::Created, + funding_milestone, + commitment: commitment.clone(), + early_settlement: None, + }; + storage::set_escrow(&env, invoice_id.clone(), &data); + + // Store the invoice_id at the current index for pagination + let current_count = storage::get_escrow_count(&env); + storage::set_escrow_id_by_index(&env, current_count, &invoice_id); + storage::increment_escrow_count(&env); + + events::escrow_created( + &env, + invoice_id.clone(), + &seller, + &debtor, + face_value, + purchase_price, + due_date, + &payment_token, + &invoice_token, + &commitment, + data.funding_milestone, + ); + events::escrow_status_changed(&env, invoice_id, EscrowStatus::Created, current_timestamp); + Ok(()) + } + + /// Cancel an escrow in Created state, refunding any partial funds to the funders. + /// Only the seller may cancel, and only while status is Created. + /// Cancel an unfunded escrow. Only the seller may cancel, and only while status is Created + /// AND no investor has contributed any funds yet. + /// + /// Locked out after partial payment: `fund_escrow` accepts partial contributions and only + /// flips `status` to `Funded` once the escrow is fully subscribed, so an escrow with + /// `funded_amt > 0` can still read as `Created`. Cancelling in that window would strand the + /// investor's already-transferred funds (cancellation has no refund path), so any nonzero + /// `funded_amt` blocks cancellation regardless of status. + /// + /// Emits `escrow_refunded` (if partial funds existed) and `escrow_cancelled`. + pub fn cancel_escrow(env: Env, invoice_id: Symbol, seller: Address) -> Result<(), Error> { + seller.require_auth(); + let config = storage::get_config(&env).ok_or(Error::NotInit)?; + ensure_not_paused(&config)?; + let mut data = + storage::get_escrow(&env, invoice_id.clone()).ok_or(Error::EscrowNotFound)?; + if data.seller != seller { + return Err(Error::Unauthorized); + } + if data.status == EscrowStatus::Cancelled { + return Err(Error::EscrowCancelled); + } + if data.status == EscrowStatus::Funded { + return Err(Error::EscrowFunded); + } + if data.status != EscrowStatus::Created { + return Err(Error::CancelNotAllowed); + } + + if data.funded_amt > 0 { + let amount_to_refund = data.funded_amt; + let token = token::Client::new(&env, &data.token); + let contract = env.current_contract_address(); + let funder_opt = data.funder.clone(); + + if let Some(distributor) = config.payment_distributor.as_ref() { + token.transfer(&contract, distributor, &amount_to_refund); + env.invoke_contract::<()>( + distributor, + &Symbol::new(&env, DISTRIBUTE_REFUND_FN), + soroban_sdk::vec![ + &env, + contract.to_val(), + invoice_id.clone().into_val(&env), + soroban_sdk::vec![ + &env, +
>::into_val( + &data.token, + &env + ), + as IntoVal>::into_val( + &funder_opt, + &env, + ) + ] + .into_val(&env), + soroban_sdk::vec![&env, amount_to_refund].into_val(&env), + (EscrowStatus::Cancelled as u32).into_val(&env) + ], + ); + } else { + if let Some(funder) = &funder_opt { + let funder_amt = storage::get_funder_amount(&env, invoice_id.clone(), funder); + if funder_amt > 0 { + token.transfer(&contract, funder, &funder_amt); + } + } + } + + env.invoke_contract::<()>( + &data.inv_token, + &Symbol::new(&env, "set_transfer_locked"), + soroban_sdk::vec![&env, contract.to_val(), false.into_val(&env)], + ); + events::escrow_refunded(&env, invoice_id.clone(), amount_to_refund); + } + data.status = EscrowStatus::Cancelled; + storage::set_escrow(&env, invoice_id.clone(), &data); + events::escrow_cancelled(&env, invoice_id.clone(), &seller); + events::escrow_status_changed( + &env, + invoice_id, + EscrowStatus::Cancelled, + env.ledger().timestamp(), + ); + Ok(()) + } + + /// Seller-only: attach or update the early-settlement discount hook for a Created or Funded escrow. + /// + /// Rules: + /// - Only callable by the escrow's seller. + /// - `discount_bps` must be in [1, 9999]. A zero discount is meaningless; 10 000 bps + /// (100%) would collapse the effective face value to zero, so it is rejected. + /// - `cutoff_date` must be strictly in the future and must not exceed `due_dt`. + /// - Cannot be set on an escrow that has already reached a terminal state + /// (Settled, Refunded, Cancelled). + /// - Can be called multiple times to update the config (e.g., extend the window + /// or adjust the rate) as long as the escrow is still live. + pub fn set_early_settlement( + env: Env, + invoice_id: Symbol, + seller: Address, + discount_bps: u32, + cutoff_date: u64, + ) -> Result<(), Error> { + seller.require_auth(); + let config = storage::get_config(&env).ok_or(Error::NotInit)?; + ensure_not_paused(&config)?; + + let mut data = + storage::get_escrow(&env, invoice_id.clone()).ok_or(Error::EscrowNotFound)?; + if data.seller != seller { + return Err(Error::Unauthorized); + } + + // Terminal states: hook can no longer be meaningful + match data.status { + EscrowStatus::Settled | EscrowStatus::Refunded | EscrowStatus::Cancelled => { + return Err(Error::InvalidEarlySettlement); + } + _ => {} + } + + // Validate discount_bps: must be [1, 9999] + if discount_bps == 0 || discount_bps >= MAX_BPS { + return Err(Error::InvalidEarlySettlement); + } + + // cutoff_date must be strictly in the future + let now = env.ledger().timestamp(); + if cutoff_date <= now { + return Err(Error::InvalidEarlySettlement); + } + // cutoff_date must not exceed due_dt (no discount window past maturity) + if cutoff_date > data.due_dt { + return Err(Error::InvalidEarlySettlement); + } + + data.early_settlement = Some(EarlySettlementConfig { + discount_bps, + cutoff_date, + }); + storage::set_escrow(&env, invoice_id, &data); + Ok(()) + } + + /// Fund the escrow (investor buys part or all of the invoice at purchase_price). + /// Transfers `amount` from buyer to this contract. Multiple investors can fund until fully subscribed. + pub fn fund_escrow( + env: Env, + invoice_id: Symbol, + buyer: Address, + amount: i128, + ) -> Result<(), Error> { + buyer.require_auth(); + Self::fund_escrow_core(&env, invoice_id, &buyer, amount) + } + + /// Fund the escrow on behalf of `buyer` using a signed off-chain approval that a relayer + /// submits on their behalf. `buyer` authorizes exactly this `(invoice_id, amount, nonce, expiry)` + /// tuple, and `nonce` must be strictly greater than the last nonce consumed by `buyer` so + /// the same signed approval cannot be replayed. + /// + /// Issue #183: Includes an `expiry` timestamp. If the ledger timestamp exceeds `expiry` + /// the signature is rejected, limiting the window for replay attacks. + pub fn fund_escrow_signed( + env: Env, + invoice_id: Symbol, + buyer: Address, + amount: i128, + nonce: u64, + expiry: u64, + ) -> Result<(), Error> { + buyer.require_auth_for_args((invoice_id.clone(), amount, nonce, expiry).into_val(&env)); + + let current_ts = env.ledger().timestamp(); + if current_ts > expiry { + return Err(Error::SignatureExpired); + } + + let last_nonce = storage::get_nonce(&env, &buyer); + if nonce <= last_nonce { + return Err(Error::NonceAlreadyUsed); + } + + Self::fund_escrow_core(&env, invoice_id.clone(), &buyer, amount)?; + + storage::set_nonce(&env, &buyer, nonce); + events::escrow_funded_signed(&env, invoice_id, &buyer, amount, nonce); + Ok(()) + } + + /// Shared funding logic used by both the directly-authorized and signed-approval entry points. + fn fund_escrow_core( + env: &Env, + invoice_id: Symbol, + buyer: &Address, + amount: i128, + ) -> Result<(), Error> { + // Fail fast: validate amount before hitting storage. + if amount == 0 { + return Err(Error::ZeroAmount); + } + if amount < 0 { + return Err(Error::InvalidAmount); + } + let config = storage::get_config(env).ok_or(Error::NotInit)?; + ensure_not_paused(&config)?; + if config.whitelist_enabled && !storage::is_whitelisted(env, buyer) { + return Err(Error::NotWhitelisted); + } + + let mut data = storage::get_escrow(env, invoice_id.clone()).ok_or(Error::EscrowNotFound)?; + if data.status == EscrowStatus::Cancelled { + return Err(Error::EscrowCancelled); + } + if data.status != EscrowStatus::Created { + return Err(Error::EscrowFunded); + } + + // Check that funding doesn't exceed purchase_price + let new_funded = data.funded_amt.checked_add(amount).ok_or(Error::Overflow)?; + if new_funded > data.purchase_price { + return Err(Error::InvalidAmount); + } + + let remaining_to_fund = data + .purchase_price + .checked_sub(data.funded_amt) + .ok_or(Error::Overflow)?; + + // Enforce global minimum investment to prevent dust deposits, except when + // the funder is completing the exact remaining capacity. + if config.min_investment > 0 + && amount != remaining_to_fund + && amount < config.min_investment + { + return Err(Error::AmountBelowMinimum); + } + + // Validate milestone constraints if a milestone is set + if let Some(milestone) = data.funding_milestone { + // Funder is always allowed to just fund exactly the remaining amount to complete the escrow. + // If they are not completing the escrow, the amount must be at least the milestone and a multiple of it. + if amount != remaining_to_fund && (amount < milestone || amount % milestone != 0) { + return Err(Error::InvalidMilestoneAmount); + } + } + + let token = token::Client::new(env, &data.token); + let contract = env.current_contract_address(); + token.transfer(buyer, &contract, &amount); + + // Mint invoice tokens to the buyer to represent their ownership share + env.invoke_contract::<()>( + &data.inv_token, + &Symbol::new(env, "mint"), + soroban_sdk::vec![env, buyer.to_val(), amount.into_val(env), contract.to_val()], + ); + + // Track this funder's contribution + let current_funder_amt = storage::get_funder_amount(env, invoice_id.clone(), buyer); + let new_funder_amt = current_funder_amt + .checked_add(amount) + .ok_or(Error::Overflow)?; + storage::set_funder_amount(env, invoice_id.clone(), buyer, new_funder_amt); + + data.funded_amt = new_funded; + + let mut already_recorded = false; + for funder in data.funders.iter() { + if funder == buyer.clone() { + already_recorded = true; + break; + } + } + if !already_recorded { + data.funders.push_back(buyer.clone()); + } + + // MVP: Store the first funder for direct distribution + if data.funder.is_none() { + data.funder = Some(buyer.clone()); + } + + // If fully funded, transition to Funded status + if data.funded_amt == data.purchase_price { + data.status = EscrowStatus::Funded; + } + + storage::set_escrow(env, invoice_id.clone(), &data); + events::escrow_funded( + env, + invoice_id.clone(), + buyer, + amount, + data.funded_amt, + data.purchase_price, + ); + if data.status == EscrowStatus::Funded { + events::escrow_status_changed( + env, + invoice_id, + EscrowStatus::Funded, + env.ledger().timestamp(), + ); + } + Ok(()) + } + + /// Record payment: distribute to investors and platform fee. Payer must auth. + /// Payer must be the authorized debtor for this invoice. + /// Payment is applied toward face_value; fees are calculated on the payment amount. + /// MVP: Distributes pro-rata to all funders based on their contribution. + pub fn record_payment( + env: Env, + invoice_id: Symbol, + payer: Address, + amount: i128, + ) -> Result<(), Error> { + payer.require_auth(); + if amount <= 0 { + return Err(Error::InvalidAmount); + } + let config = storage::get_config(&env).ok_or(Error::NotInit)?; + ensure_not_paused(&config)?; + let mut data = + storage::get_escrow(&env, invoice_id.clone()).ok_or(Error::EscrowNotFound)?; + + // Enforce payer role: payer must be the authorized debtor + if payer != data.debtor { + return Err(Error::InvalidPayer); + } + + if data.status != EscrowStatus::Funded { + return Err(Error::AlreadySettled); + } + + // Compute effective face value: apply early-settlement discount if the hook + // is configured and the payment arrives strictly before the cutoff date. + let current_ts = env.ledger().timestamp(); + let effective_face_value = + if let Some(ref es) = data.early_settlement { + if current_ts < es.cutoff_date { + let discount = data + .face_value + .checked_mul(i128::from(es.discount_bps)) + .ok_or(Error::Overflow)? + .checked_div(i128::from(MAX_BPS)) + .ok_or(Error::Overflow)?; + let discounted = data + .face_value + .checked_sub(discount) + .ok_or(Error::Overflow)? + .max(1); // floor at 1 stroop + // Emit the hook application event (only on first payment in the window + // to avoid redundant emissions on subsequent partial payments). + if data.paid_amt == 0 { + events::early_settlement_applied( + &env, + invoice_id.clone(), + es.discount_bps, + data.face_value, + discounted, + ); + } + discounted + } else { + data.face_value + } + } else { + data.face_value + }; + + // Remaining balance toward effective face value + let remaining = effective_face_value + .checked_sub(data.paid_amt) + .ok_or(Error::Overflow)?; + if amount > remaining { + return Err(Error::InvalidAmount); + } + + let fee_bps = i128::from(config.fee_bps); + // Fee is calculated on the payment amount (not face_value) + let platform_fee = amount + .checked_mul(fee_bps) + .ok_or(Error::Overflow)? + .checked_div(i128::from(MAX_BPS)) + .ok_or(Error::Overflow)?; + let investor_amount = amount.checked_sub(platform_fee).ok_or(Error::Overflow)?; + + let token = token::Client::new(&env, &data.token); + let contract = env.current_contract_address(); + + // 1. Pull payer's funds into escrow + token.transfer(&payer, &contract, &amount); + + data.paid_amt = data.paid_amt.checked_add(amount).ok_or(Error::Overflow)?; + + // Settlement occurs when paid_amt reaches effective face value + if data.paid_amt == effective_face_value { + data.status = EscrowStatus::Settled; + } + + storage::set_escrow(&env, invoice_id.clone(), &data); + + let funder_addr = data.funder.clone().unwrap_or_else(|| data.seller.clone()); + + if let Some(distributor) = config.payment_distributor.as_ref() { + // The distributor must pay seller_amount (== amount) plus investor_amount + platform_fee + // (== amount), mirroring the direct path below which releases the payer's `amount` to the + // seller in addition to paying the investor/admin out of escrow's held funding. + let total_to_distributor = amount.checked_add(amount).ok_or(Error::Overflow)?; + token.transfer(&contract, distributor, &total_to_distributor); + env.invoke_contract::<()>( + distributor, + &Symbol::new(&env, DISTRIBUTE_PAYMENT_FN), + soroban_sdk::vec![ + &env, + contract.to_val(), + invoice_id.clone().into_val(&env), + soroban_sdk::vec![ + &env, +
>::into_val(&data.token, &env), +
>::into_val(&data.seller, &env), +
>::into_val(&funder_addr, &env), +
>::into_val(&config.admin, &env) + ] + .into_val(&env), + soroban_sdk::vec![ + &env, + data.paid_amt, + amount, + investor_amount, + config.fee_bps as i128, + ] + .into_val(&env), + (data.status as u32).into_val(&env) + ], + ); + } else { + // 2. Platform fee to admin + token.transfer(&contract, &config.admin, &platform_fee); + + // 3. Pro-rata investor distribution + if let Some(funder) = &data.funder { + if data.funded_amt > 0 && investor_amount > 0 { + let funder_amt = storage::get_funder_amount(&env, invoice_id.clone(), funder); + let pro_rata_share = investor_amount + .checked_mul(funder_amt) + .ok_or(Error::Overflow)? + .checked_div(data.funded_amt) + .ok_or(Error::Overflow)?; + if pro_rata_share > 0 { + token.transfer(&contract, funder, &pro_rata_share); + } + } + } + // Seller receives the full payment amount + token.transfer(&contract, &data.seller, &amount); + } + + if data.status == EscrowStatus::Settled { + // Unlock invoice token transfers only when the invoice is completely settled. + env.invoke_contract::<()>( + &data.inv_token, + &Symbol::new(&env, "set_transfer_locked"), + soroban_sdk::vec![&env, contract.to_val(), false.into_val(&env)], + ); + } + + events::payment_settled( + &env, + invoice_id.clone(), + amount, + platform_fee, + investor_amount, + ); + if data.status == EscrowStatus::Settled { + events::escrow_status_changed( + &env, + invoice_id, + EscrowStatus::Settled, + env.ledger().timestamp(), + ); + } + Ok(()) + } + + /// Refund the investors if the invoice was not paid by due date. Anyone may call. + /// Refunds are distributed pro-rata based on each investor's contribution. + pub fn refund_escrow(env: Env, invoice_id: Symbol) -> Result<(), Error> { + let config = storage::get_config(&env).ok_or(Error::NotInit)?; + ensure_not_paused(&config)?; + let mut data = + storage::get_escrow(&env, invoice_id.clone()).ok_or(Error::EscrowNotFound)?; + if data.status != EscrowStatus::Funded { + return Err(Error::RefundNotAllowed); + } + let ledger_ts = env.ledger().timestamp(); + if ledger_ts < data.due_dt { + return Err(Error::RefundNotAllowed); + } + + // Refund the remaining collateral (purchase_price minus already released partial payments) + let amount_to_refund = data + .purchase_price + .checked_sub(data.paid_amt) + .ok_or(Error::Overflow)?; + + let token = token::Client::new(&env, &data.token); + let contract = env.current_contract_address(); + + // Extract funder address before status mutation so it is available in both paths. + let funder_opt = data.funder.clone(); + + data.status = EscrowStatus::Refunded; + storage::set_escrow(&env, invoice_id.clone(), &data); + + if amount_to_refund > 0 { + if let Some(distributor) = config.payment_distributor.as_ref() { + token.transfer(&contract, distributor, &amount_to_refund); + env.invoke_contract::<()>( + distributor, + &Symbol::new(&env, DISTRIBUTE_REFUND_FN), + soroban_sdk::vec![ + &env, + contract.to_val(), + invoice_id.clone().into_val(&env), + soroban_sdk::vec![ + &env, +
>::into_val( + &data.token, + &env + ), + as IntoVal>::into_val( + &funder_opt, + &env, + ) + ] + .into_val(&env), + soroban_sdk::vec![&env, amount_to_refund].into_val(&env), + (data.status as u32).into_val(&env) + ], + ); + } else { + // Pro-rata refund to funders + if let Some(funder) = &funder_opt { + if data.funded_amt > 0 { + let funder_amt = + storage::get_funder_amount(&env, invoice_id.clone(), funder); + let pro_rata_refund = amount_to_refund + .checked_mul(funder_amt) + .ok_or(Error::Overflow)? + .checked_div(data.funded_amt) + .ok_or(Error::Overflow)?; + if pro_rata_refund > 0 { + token.transfer(&contract, funder, &pro_rata_refund); + } + } + } + } + } + + // Unlock invoice token transfers now that the invoice is refunded + env.invoke_contract::<()>( + &data.inv_token, + &Symbol::new(&env, "set_transfer_locked"), + soroban_sdk::vec![&env, contract.to_val(), false.into_val(&env)], + ); + + events::escrow_refunded(&env, invoice_id.clone(), amount_to_refund); + events::escrow_status_changed( + &env, + invoice_id, + EscrowStatus::Refunded, + env.ledger().timestamp(), + ); + Ok(()) + } + + /// Update platform fee (basis points). Admin only. + pub fn update_platform_fee_bps(env: Env, new_fee_bps: u32) -> Result<(), Error> { + let mut config = storage::get_config(&env).ok_or(Error::NotInit)?; + let admin = config.admin.clone(); + admin.require_auth(); + if new_fee_bps > MAX_BPS { + return Err(Error::InvalidFeeBps); + } + let old_fee_bps = config.fee_bps; + config.fee_bps = new_fee_bps; + storage::set_config(&env, &config); + events::platform_fee_updated(&env, old_fee_bps, new_fee_bps); + Ok(()) + } + + /// Set the payment distributor used for settlement/refund fan-out. Admin only. + pub fn set_payment_distributor(env: Env, payment_distributor: Address) -> Result<(), Error> { + let mut config = storage::get_config(&env).ok_or(Error::NotInit)?; + let admin = config.admin.clone(); + admin.require_auth(); + let old_distributor = config.payment_distributor.clone(); + config.payment_distributor = Some(payment_distributor.clone()); + storage::set_config(&env, &config); + events::payment_distributor_updated(&env, old_distributor.is_some(), &payment_distributor); + Ok(()) + } + + /// Toggle the emergency pause flag. Admin only. + pub fn set_paused(env: Env, paused: bool) -> Result<(), Error> { + let mut config = storage::get_config(&env).ok_or(Error::NotInit)?; + let admin = config.admin.clone(); + admin.require_auth(); + let old_paused = config.paused; + config.paused = paused; + storage::set_config(&env, &config); + events::paused_updated(&env, old_paused, paused); + Ok(()) + } + + /// View: return escrow data for an invoice, or Err(Error::EscrowNotFound) if not found. + pub fn get_escrow(env: Env, invoice_id: Symbol) -> Result { + storage::get_escrow(&env, invoice_id).ok_or(Error::EscrowNotFound) + } + + /// View: return current config (admin and fee_bps). + pub fn get_config(env: Env) -> Result { + storage::get_config(&env).ok_or(Error::NotInit) + } + + /// View: return escrow status for an invoice. + pub fn get_escrow_status(env: Env, invoice_id: Symbol) -> Result { + let data = storage::get_escrow(&env, invoice_id).ok_or(Error::EscrowNotFound)?; + Ok(data.status) + } + + /// View: return the current pause state. + pub fn paused(env: Env) -> Result { + let config = storage::get_config(&env).ok_or(Error::NotInit)?; + Ok(config.paused) + } + + /// Admin-only: configure the emergency multi-sig admin set and threshold. + pub fn set_emergency_config( + env: Env, + admin: Address, + config: MultiSigConfig, + ) -> Result<(), Error> { + admin.require_auth(); + let stored_config = storage::get_config(&env).ok_or(Error::NotInit)?; + if stored_config.admin != admin { + return Err(Error::Unauthorized); + } + if config.threshold == 0 || config.threshold > config.admins.len() as u32 { + return Err(Error::InvalidFeeBps); // reuse for invalid threshold + } + storage::set_emergency_config(&env, &config); + Ok(()) + } + + /// Emergency multi-sig release: an admin approves releasing funds for an invoice. + /// When the threshold is reached, funds are paid out to the seller and the escrow + /// is marked as Settled. + pub fn emergency_release(env: Env, caller: Address, invoice_id: Symbol) -> Result { + caller.require_auth(); + let config = storage::get_emergency_config(&env).ok_or(Error::EmergencyNotConfigured)?; + + // Verify caller is an emergency admin + let mut is_admin = false; + for admin in config.admins.iter() { + if admin == caller { + is_admin = true; + break; + } + } + if !is_admin { + return Err(Error::NotEmergencyAdmin); + } + + let mut approvals = storage::get_emergency_approvals(&env, &invoice_id); + + // Check for duplicate approval + for addr in approvals.approvals.iter() { + if addr == caller { + return Err(Error::AlreadyApproved); + } + } + + approvals.approvals.push_back(caller.clone()); + storage::set_emergency_approvals(&env, &invoice_id, &approvals); + + if (approvals.approvals.len() as u32) < config.threshold { + return Ok(false); + } + + // Threshold reached — execute emergency release + let mut data = + storage::get_escrow(&env, invoice_id.clone()).ok_or(Error::EscrowNotFound)?; + + if data.status == EscrowStatus::Settled + || data.status == EscrowStatus::Refunded + || data.status == EscrowStatus::Cancelled + { + return Err(Error::AlreadySettled); + } + + let token = token::Client::new(&env, &data.token); + let contract = env.current_contract_address(); + let remaining = data + .purchase_price + .checked_sub(data.paid_amt) + .ok_or(Error::Overflow)?; + + // Pay remaining to seller + if remaining > 0 { + token.transfer(&contract, &data.seller, &remaining); + } + + data.status = EscrowStatus::Settled; + storage::set_escrow(&env, invoice_id.clone(), &data); + + events::escrow_status_changed( + &env, + invoice_id.clone(), + EscrowStatus::Settled, + env.ledger().timestamp(), + ); + Ok(true) + } + + /// Reclaim persistent storage for an escrow that has reached a terminal state + /// (Settled, Refunded, or Cancelled). Callable only by the seller or the admin. + /// The escrow and its per-funder contribution record are removed permanently; + /// terminal-state escrows are never mutated again, so this is safe to prune. + pub fn cleanup_escrow(env: Env, invoice_id: Symbol, caller: Address) -> Result<(), Error> { + caller.require_auth(); + let config = storage::get_config(&env).ok_or(Error::NotInit)?; + let data = storage::get_escrow(&env, invoice_id.clone()).ok_or(Error::EscrowNotFound)?; + if caller != data.seller && caller != config.admin { + return Err(Error::Unauthorized); + } + match data.status { + EscrowStatus::Settled | EscrowStatus::Refunded | EscrowStatus::Cancelled => {} + _ => return Err(Error::EscrowNotSettled), + } + storage::remove_escrow_state(&env, invoice_id.clone(), &data.funders); + events::escrow_cleaned_up(&env, invoice_id); + Ok(()) + } + + // ── Position management: top_up / partial_refund / transfer_position / finalise_funding ── + + /// Create a funding invoice (BytesN<32> id) for the new position management flows. + /// This is the setup entrypoint for tests and admin tooling for the + /// top_up / partial_refund / transfer_position / finalise_funding lifecycle. + pub fn create_invoice( + env: Env, + invoice_id: BytesN<32>, + seller: Address, + funding_target: i128, + deadline_ledger: u32, + min_investment: i128, + per_investor_cap: Option, + token: Address, + ) -> Result<(), Error> { + seller.require_auth(); + if funding_target <= 0 { + return Err(Error::InvalidAmount); + } + if min_investment < 0 { + return Err(Error::InvalidAmount); + } + if storage::has_invoice(&env, invoice_id.clone()) { + return Err(Error::EscrowExists); + } + let invoice = FundingInvoice { + seller: seller.clone(), + funding_target, + total_raised: 0, + deadline_ledger, + min_investment, + per_investor_cap, + status: InvoiceStatus::Open, + token: token.clone(), + }; + storage::set_invoice(&env, invoice_id, &invoice); + Ok(()) + } + + /// Top up an existing investor position. + /// Validates invoice is Open, caller has non-zero position, and cap not exceeded. + pub fn top_up( + env: Env, + investor: Address, + invoice_id: BytesN<32>, + additional_amount: i128, + ) -> Result<(), Error> { + investor.require_auth(); + if additional_amount <= 0 { + return Err(Error::InvalidAmount); + } + let mut invoice = + storage::get_invoice(&env, invoice_id.clone()).ok_or(Error::EscrowNotFound)?; + if invoice.status != InvoiceStatus::Open { + return Err(Error::InvalidInvoiceStatus); + } + let current = storage::get_investor_position(&env, &invoice_id, &investor); + if current == 0 { + return Err(Error::NoPositionFound); + } + let new_total_position = current + .checked_add(additional_amount) + .ok_or(Error::Overflow)?; + if let Some(cap) = invoice.per_investor_cap { + if new_total_position > cap { + return Err(Error::InvalidAmount); + } + } + let new_total_raised = invoice + .total_raised + .checked_add(additional_amount) + .ok_or(Error::Overflow)?; + if new_total_raised > invoice.funding_target { + return Err(Error::InvalidAmount); + } + // Transfer additional_amount from investor to contract + let token_client = token::Client::new(&env, &invoice.token); + token_client.transfer( + &investor, + &env.current_contract_address(), + &additional_amount, + ); + storage::set_investor_position(&env, &invoice_id, &investor, new_total_position); + invoice.total_raised = new_total_raised; + storage::set_invoice(&env, invoice_id.clone(), &invoice); + events::investment_topped_up( + &env, + &investor, + invoice_id, + additional_amount, + new_total_position, + ); + Ok(()) + } + + // ---------- Invoice Registration, Investment, Refund, Settlement & TTL Refresh ---------- + + /// Register invoice metadata and funding parameters on-chain. Callable only by admin. + pub fn register_invoice( + env: Env, + invoice_id: BytesN<32>, + face_value: i128, + funding_target: i128, + yield_bps: u32, + deadline_ledger: u32, + ) -> Result<(), Error> { + let config = storage::get_config(&env).ok_or(Error::NotInit)?; + config.admin.require_auth(); + + if face_value <= 0 || funding_target <= 0 { + return Err(Error::InvalidAmount); + } + if !(1..=5000).contains(&yield_bps) { + return Err(Error::InvalidYield); + } + if storage::has_invoice_record(&env, &invoice_id) { + return Err(Error::InvoiceAlreadyExists); + } + + let data = InvoiceData { + invoice_id: invoice_id.clone(), + face_value, + funding_target, + yield_bps, + deadline_ledger, + total_raised: 0, + status: EscrowStatus::Created, + investors: soroban_sdk::Vec::new(&env), + }; + + storage::set_invoice_record(&env, &invoice_id, &data); + events::invoice_registered( + &env, + &invoice_id, + face_value, + funding_target, + yield_bps, + deadline_ledger, + ); + Ok(()) + } + + /// Invest in a registered invoice or funding invoice. + pub fn invest( + env: Env, + invoice_id: BytesN<32>, + investor: Address, + amount: i128, + ) -> Result<(), Error> { + investor.require_auth(); + if amount <= 0 { + return Err(Error::InvalidAmount); + } + + if let Some(mut invoice) = storage::get_invoice(&env, invoice_id.clone()) { + if invoice.status != InvoiceStatus::Open { + return Err(Error::InvalidInvoiceStatus); + } + if amount < invoice.min_investment { + return Err(Error::BelowMinimumInvestment); + } + if let Some(cap) = invoice.per_investor_cap { + if amount > cap { + return Err(Error::InvalidAmount); + } + } + let new_total = invoice + .total_raised + .checked_add(amount) + .ok_or(Error::Overflow)?; + if new_total > invoice.funding_target { + return Err(Error::InvalidAmount); + } + let current = storage::get_investor_position(&env, &invoice_id, &investor); + let new_pos = current.checked_add(amount).ok_or(Error::Overflow)?; + if let Some(cap) = invoice.per_investor_cap { + if new_pos > cap { + return Err(Error::InvalidAmount); + } + } + let token_client = token::Client::new(&env, &invoice.token); + token_client.transfer(&investor, &env.current_contract_address(), &amount); + storage::set_investor_position(&env, &invoice_id, &investor, new_pos); + invoice.total_raised = new_total; + storage::set_invoice(&env, invoice_id, &invoice); + return Ok(()); + } + + let config = storage::get_config(&env).ok_or(Error::NotInit)?; + ensure_not_paused(&config)?; + + let mut record = + storage::get_invoice_record(&env, &invoice_id).ok_or(Error::EscrowNotFound)?; + if record.status != EscrowStatus::Created { + return Err(Error::InvalidInvoiceStatus); + } + let current_ledger = env.ledger().sequence(); + if current_ledger > record.deadline_ledger { + return Err(Error::FundingDeadlineNotPassed); + } + + let new_raised = record + .total_raised + .checked_add(amount) + .ok_or(Error::Overflow)?; + if new_raised > record.funding_target { + return Err(Error::InvalidAmount); + } + + let current_pos = storage::get_investor_position(&env, &invoice_id, &investor); + let new_pos = current_pos.checked_add(amount).ok_or(Error::Overflow)?; + storage::set_investor_position(&env, &invoice_id, &investor, new_pos); + + let mut already_in = false; + for inv in record.investors.iter() { + if inv == investor { + already_in = true; + break; + } + } + if !already_in { + record.investors.push_back(investor.clone()); + } + + record.total_raised = new_raised; + if record.total_raised == record.funding_target { + record.status = EscrowStatus::Funded; + } + + storage::set_invoice_record(&env, &invoice_id, &record); + Ok(()) + } + + /// Partially refund an investor's position before deadline. + pub fn partial_refund( + env: Env, + investor: Address, + invoice_id: BytesN<32>, + amount: i128, + ) -> Result<(), Error> { + investor.require_auth(); + if amount <= 0 { + return Err(Error::InvalidAmount); + } + let mut invoice = + storage::get_invoice(&env, invoice_id.clone()).ok_or(Error::EscrowNotFound)?; + if invoice.status != InvoiceStatus::Open { + return Err(Error::InvalidInvoiceStatus); + } + let current_ledger = env.ledger().sequence(); + if current_ledger >= invoice.deadline_ledger { + return Err(Error::InvalidInvoiceStatus); + } + let current = storage::get_investor_position(&env, &invoice_id, &investor); + if current == 0 { + return Err(Error::NoPositionFound); + } + if amount > current { + return Err(Error::InvalidAmount); + } + let remaining = current.checked_sub(amount).ok_or(Error::Overflow)?; + if remaining != 0 && remaining < invoice.min_investment { + return Err(Error::BelowMinimumInvestment); + } + let new_total_raised = invoice + .total_raised + .checked_sub(amount) + .ok_or(Error::Overflow)?; + // Transfer back to caller + let token_client = token::Client::new(&env, &invoice.token); + token_client.transfer(&env.current_contract_address(), &investor, &amount); + storage::set_investor_position(&env, &invoice_id, &investor, remaining); + invoice.total_raised = new_total_raised; + storage::set_invoice(&env, invoice_id.clone(), &invoice); + events::investment_partially_refunded(&env, &investor, invoice_id, amount, remaining); + Ok(()) + } + + /// Transfer a funded position from seller to buyer for an agreed price. + pub fn transfer_position( + env: Env, + from: Address, + invoice_id: BytesN<32>, + to: Address, + price: i128, + ) -> Result<(), Error> { + from.require_auth(); + if price < 0 { + return Err(Error::InvalidAmount); + } + let invoice = + storage::get_invoice(&env, invoice_id.clone()).ok_or(Error::EscrowNotFound)?; + if invoice.status != InvoiceStatus::Funded { + return Err(Error::InvalidInvoiceStatus); + } + let position = storage::get_investor_position(&env, &invoice_id, &from); + if position == 0 { + return Err(Error::NoPositionFound); + } + to.require_auth(); + if price > 0 { + let token_client = token::Client::new(&env, &invoice.token); + token_client.transfer(&to, &from, &price); + } + let buyer_existing = storage::get_investor_position(&env, &invoice_id, &to); + let new_buyer_pos = buyer_existing + .checked_add(position) + .ok_or(Error::Overflow)?; + if let Some(cap) = invoice.per_investor_cap { + if new_buyer_pos > cap { + return Err(Error::InvalidAmount); + } + } + storage::set_investor_position(&env, &invoice_id, &from, 0); + storage::set_investor_position(&env, &invoice_id, &to, new_buyer_pos); + events::position_transferred(&env, &from, &to, invoice_id, position, price); + Ok(()) + } + + /// Finalise funding: transition Open->Funded when target reached, release proceeds to seller. + pub fn finalise_funding(env: Env, invoice_id: BytesN<32>) -> Result<(), Error> { + if let Some(mut invoice) = storage::get_invoice(&env, invoice_id.clone()) { + let config = storage::get_config(&env).ok_or(Error::NotInit)?; + config.admin.require_auth(); + if invoice.status != InvoiceStatus::Open { + return Err(Error::InvalidInvoiceStatus); + } + if invoice.total_raised < invoice.funding_target { + return Err(Error::FundingTargetNotReached); + } + invoice.status = InvoiceStatus::Funded; + storage::set_invoice(&env, invoice_id.clone(), &invoice); + if invoice.total_raised > 0 { + let token_client = token::Client::new(&env, &invoice.token); + token_client.transfer( + &env.current_contract_address(), + &invoice.seller, + &invoice.total_raised, + ); + } + events::funding_finalised(&env, invoice_id, invoice.total_raised, &invoice.seller); + return Ok(()); + } + + let mut record = + storage::get_invoice_record(&env, &invoice_id).ok_or(Error::EscrowNotFound)?; + if record.status != EscrowStatus::Created && record.status != EscrowStatus::Funded { + return Err(Error::InvalidInvoiceStatus); + } + record.status = EscrowStatus::Funded; + storage::set_invoice_record(&env, &invoice_id, &record); + Ok(()) + } + + /// Refund an investor's committed position if deadline passed without reaching target. + pub fn refund( + env: Env, + invoice_id: BytesN<32>, + investor: Address, + ) -> Result<(), Error> { + investor.require_auth(); + let config = storage::get_config(&env).ok_or(Error::NotInit)?; + ensure_not_paused(&config)?; + + let mut record = + storage::get_invoice_record(&env, &invoice_id).ok_or(Error::EscrowNotFound)?; + + if record.status == EscrowStatus::Funded { + return Err(Error::InvalidInvoiceStatus); + } + + let current_ledger = env.ledger().sequence(); + if current_ledger <= record.deadline_ledger { + return Err(Error::FundingDeadlineNotPassed); + } + + let committed = storage::get_investor_position(&env, &invoice_id, &investor); + if committed <= 0 { + return Err(Error::NoPositionFound); + } + + storage::remove_investor_position(&env, &invoice_id, &investor); + + record.total_raised = record + .total_raised + .checked_sub(committed) + .ok_or(Error::Overflow)?; + + storage::set_invoice_record(&env, &invoice_id, &record); + events::investment_refunded(&env, &investor, &invoice_id, committed); + Ok(()) + } + + /// Settle invoice pro-rata across investors when seller repays. Callable only by admin. + pub fn settle_invoice( + env: Env, + invoice_id: BytesN<32>, + repayment_amount: i128, + ) -> Result<(), Error> { + let config = storage::get_config(&env).ok_or(Error::NotInit)?; + config.admin.require_auth(); + + let mut record = + storage::get_invoice_record(&env, &invoice_id).ok_or(Error::EscrowNotFound)?; + + if record.status != EscrowStatus::Funded { + return Err(Error::InvalidInvoiceStatus); + } + + if repayment_amount < record.total_raised { + return Err(Error::InsufficientRepayment); + } + + let mut total_payouts: i128 = 0; + for investor in record.investors.iter() { + let committed = storage::get_investor_position(&env, &invoice_id, &investor); + if committed > 0 { + let payout = committed + .checked_mul(repayment_amount) + .ok_or(Error::Overflow)? + .checked_div(record.total_raised) + .ok_or(Error::Overflow)?; + let yield_earned = payout.saturating_sub(committed); + total_payouts = total_payouts.checked_add(payout).ok_or(Error::Overflow)?; + events::settlement_paid(&env, &investor, &invoice_id, payout, yield_earned); + } + } + + let _dust = repayment_amount + .checked_sub(total_payouts) + .ok_or(Error::Overflow)?; + + record.status = EscrowStatus::Settled; + storage::set_invoice_record(&env, &invoice_id, &record); + Ok(()) + } + + /// Admin-only: refresh TTL for invoice record and all investor position entries. + pub fn refresh_all_ttls(env: Env, invoice_id: BytesN<32>) -> Result<(), Error> { + let config = storage::get_config(&env).ok_or(Error::NotInit)?; + config.admin.require_auth(); + + let record = + storage::get_invoice_record(&env, &invoice_id).ok_or(Error::EscrowNotFound)?; + for investor in record.investors.iter() { + let _ = storage::get_investor_position(&env, &invoice_id, &investor); + } + Ok(()) + } + + /// View: get funding invoice (BytesN<32>). + pub fn get_invoice(env: Env, invoice_id: BytesN<32>) -> Result { + storage::get_invoice(&env, invoice_id).ok_or(Error::EscrowNotFound) + } + + /// View: return registered invoice data. + pub fn get_invoice_record(env: Env, invoice_id: BytesN<32>) -> Result { + storage::get_invoice_record(&env, &invoice_id).ok_or(Error::EscrowNotFound) + } + + /// View: return investor position amount for an invoice. + pub fn get_investor_position( + env: Env, + invoice_id: BytesN<32>, + investor: Address, + ) -> Result { + Ok(storage::get_investor_position(&env, &invoice_id, &investor)) + } + + /// Paginated query to retrieve multiple escrows by sequential creation order. + pub fn get_escrows(env: Env, start: u32, limit: u32) -> Result, Error> { + const MAX_PAGE_SIZE: u32 = 100; + + if limit == 0 { + return Err(Error::InvalidLimit); + } + if limit > MAX_PAGE_SIZE { + return Err(Error::LimitExceeded); + } + + let total_count = storage::get_escrow_count(&env); + + if start >= total_count { + return Ok(soroban_sdk::Vec::new(&env)); + } + + let end = core::cmp::min(start + limit, total_count); + let mut results = soroban_sdk::Vec::new(&env); + + for index in start..end { + if let Some(invoice_id) = storage::get_escrow_id_by_index(&env, index) { + if let Some(escrow_data) = storage::get_escrow(&env, invoice_id) { + results.push_back(escrow_data); + } + } + } + + Ok(results) + } +} + +#[cfg(test)] +mod integration_test; +#[cfg(test)] +mod test; +//! Invoice Escrow contract for StellarSettle. +//! +//! Handles escrow creation, funding by investors, payment settlement, +//! and refunds when invoices are not paid by due date. + +#![no_std] +#![allow(clippy::too_many_arguments)] + +mod errors; +mod events; +mod storage; +mod types; + +use soroban_sdk::{contract, contractimpl, token, Address, BytesN, Env, IntoVal, Symbol}; + +use types::MultiSigConfig; + +// EscrowStatus is re-exported publicly; Config, EscrowData, and InvoiceData are crate-private. +pub use types::EscrowStatus; +use types::{Config, EscrowData, FundingInvoice, InvoiceData, InvoiceStatus}; + +use errors::Error; + +/// Reject the zero address (all-zero 32-byte Ed25519 key) which is never a valid participant. +fn ensure_non_zero_address(env: &Env, address: &Address) -> Result<(), Error> { + // Convert address to its string representation and check for the well-known + // zero account (all 32 bytes are 0x00). The StrKey encoding of the zero + // account is the constant below. + let zero_str = soroban_sdk::String::from_str( + env, + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + ); + let zero = Address::from_string(&zero_str); + if *address == zero { + return Err(Error::InvalidAddress); + } + Ok(()) +} + +const MAX_BPS: u32 = 10_000; +const DISTRIBUTE_PAYMENT_FN: &str = "distribute_payment"; +const DISTRIBUTE_REFUND_FN: &str = "distribute_refund"; + +/// Minimum escrow duration: 1 hour (3600 seconds). +const MIN_ESCROW_DURATION_SECS: u64 = 3_600; +/// Maximum escrow duration: 365 days (31,536,000 seconds). +const MAX_ESCROW_DURATION_SECS: u64 = 31_536_000; + +#[contract] +pub struct InvoiceEscrow; + +fn ensure_not_paused(config: &Config) -> Result<(), Error> { + if config.paused { + return Err(Error::Paused); + } + Ok(()) +} + +#[contractimpl] +impl InvoiceEscrow { + /// Initialize the contract with admin and platform fee (basis points, e.g. 300 = 3%). + pub fn initialize(env: Env, admin: Address, platform_fee_bps: u32) -> Result<(), Error> { + ensure_non_zero_address(&env, &admin)?; + admin.require_auth(); + if storage::get_config(&env).is_some() { + return Err(Error::AlreadyInit); + } + if platform_fee_bps > MAX_BPS { + return Err(Error::InvalidFeeBps); + } + let config = Config { + admin: admin.clone(), + fee_bps: platform_fee_bps, + payment_distributor: None, + paused: false, + whitelist_enabled: false, + min_investment: 0, + }; + storage::set_config(&env, &config); + Ok(()) + } + + /// Admin-only: set the minimum investment amount for `fund_escrow`. + /// Pass `0` to disable the floor (deposits must still be strictly positive). + pub fn set_min_investment(env: Env, admin: Address, min_investment: i128) -> Result<(), Error> { + admin.require_auth(); + if min_investment < 0 { + return Err(Error::InvalidAmount); + } + let mut config = storage::get_config(&env).ok_or(Error::NotInit)?; + if config.admin != admin { + return Err(Error::Unauthorized); + } + config.min_investment = min_investment; + storage::set_config(&env, &config); + Ok(()) + } + + /// Admin-only: enable/disable buyer whitelist enforcement on `fund_escrow`. + pub fn set_whitelist_enabled(env: Env, admin: Address, enabled: bool) -> Result<(), Error> { + admin.require_auth(); + let mut config = storage::get_config(&env).ok_or(Error::NotInit)?; + if config.admin != admin { + return Err(Error::Unauthorized); + } + config.whitelist_enabled = enabled; + storage::set_config(&env, &config); + Ok(()) + } + + /// Admin-only: add or remove a buyer from the whitelist. + pub fn set_buyer_whitelisted( + env: Env, + admin: Address, + buyer: Address, + allowed: bool, + ) -> Result<(), Error> { + admin.require_auth(); + let config = storage::get_config(&env).ok_or(Error::NotInit)?; + if config.admin != admin { + return Err(Error::Unauthorized); + } + storage::set_whitelisted(&env, &buyer, allowed); + Ok(()) + } + + /// View: is `buyer` whitelisted to fund escrows. + pub fn is_buyer_whitelisted(env: Env, buyer: Address) -> bool { + storage::is_whitelisted(&env, &buyer) + } + + /// Create an escrow for an invoice. Caller (seller) must be authenticated. + /// face_value: what the debtor owes (amount to be paid at settlement) + /// purchase_price: what the investor pays (discount applied here) + /// commitment: immutable on-chain anchor (SHA-256 hash of off-chain invoice data) + pub fn create_escrow( + env: Env, + invoice_id: Symbol, + seller: Address, + debtor: Address, + face_value: i128, + purchase_price: i128, + due_date: u64, + payment_token: Address, + invoice_token: Address, + commitment: soroban_sdk::BytesN<32>, + funding_milestone: Option, + ) -> Result<(), Error> { + seller.require_auth(); + if face_value <= 0 || purchase_price <= 0 { + return Err(Error::InvalidAmount); + } + if due_date == 0 { + return Err(Error::InvalidDueDate); + } + let current_timestamp = env.ledger().timestamp(); + if due_date <= current_timestamp { + return Err(Error::InvalidDueDate); + } + let duration = due_date.saturating_sub(current_timestamp); + if duration < MIN_ESCROW_DURATION_SECS || duration > MAX_ESCROW_DURATION_SECS { + return Err(Error::InvalidDuration); + } + let config = storage::get_config(&env).ok_or(Error::NotInit)?; + ensure_not_paused(&config)?; + if storage::has_escrow(&env, invoice_id.clone()) { + return Err(Error::EscrowExists); + } + // Ensure the payment token and invoice token use the same decimals to avoid + // settlement/rounding mismatches during distribution and fee calculations. + let inv_decimals: Option = env + .try_invoke_contract::( + &invoice_token, + &Symbol::new(&env, "decimals"), + soroban_sdk::vec![&env], + ) + .ok() + .and_then(|r| r.ok()); + let pay_decimals: Option = env + .try_invoke_contract::( + &payment_token, + &Symbol::new(&env, "decimals"), + soroban_sdk::vec![&env], + ) + .ok() + .and_then(|r| r.ok()); + if let (Some(inv_d), Some(pay_d)) = (inv_decimals, pay_decimals) { + if inv_d != pay_d { + return Err(Error::InvalidAssetDecimals); + } + } + let data = EscrowData { + inv_id: invoice_id.clone(), + seller: seller.clone(), + debtor: debtor.clone(), + face_value, + purchase_price, + funded_amt: 0, + funder: None, + funders: soroban_sdk::Vec::new(&env), + due_dt: due_date, + token: payment_token.clone(), + inv_token: invoice_token.clone(), + paid_amt: 0, + status: EscrowStatus::Created, + funding_milestone, + commitment: commitment.clone(), + early_settlement: None, + }; + storage::set_escrow(&env, invoice_id.clone(), &data); + + // Store the invoice_id at the current index for pagination + let current_count = storage::get_escrow_count(&env); + storage::set_escrow_id_by_index(&env, current_count, &invoice_id); + storage::increment_escrow_count(&env); + + events::escrow_created( + &env, + invoice_id.clone(), + &seller, + &debtor, + face_value, + purchase_price, + due_date, + &payment_token, + &invoice_token, + &commitment, + data.funding_milestone, + ); + events::escrow_status_changed(&env, invoice_id, EscrowStatus::Created, current_timestamp); + Ok(()) + } + + /// Cancel an escrow in Created state, refunding any partial funds to the funders. + /// Only the seller may cancel, and only while status is Created. + /// Cancel an unfunded escrow. Only the seller may cancel, and only while status is Created + /// AND no investor has contributed any funds yet. + /// + /// Locked out after partial payment: `fund_escrow` accepts partial contributions and only + /// flips `status` to `Funded` once the escrow is fully subscribed, so an escrow with + /// `funded_amt > 0` can still read as `Created`. Cancelling in that window would strand the + /// investor's already-transferred funds (cancellation has no refund path), so any nonzero + /// `funded_amt` blocks cancellation regardless of status. + /// + /// Emits `escrow_refunded` (if partial funds existed) and `escrow_cancelled`. + pub fn cancel_escrow(env: Env, invoice_id: Symbol, seller: Address) -> Result<(), Error> { + seller.require_auth(); + let config = storage::get_config(&env).ok_or(Error::NotInit)?; + ensure_not_paused(&config)?; + let mut data = + storage::get_escrow(&env, invoice_id.clone()).ok_or(Error::EscrowNotFound)?; + if data.seller != seller { + return Err(Error::Unauthorized); + } + if data.status == EscrowStatus::Cancelled { + return Err(Error::EscrowCancelled); + } + if data.status == EscrowStatus::Funded { + return Err(Error::EscrowFunded); + } + if data.status != EscrowStatus::Created { + return Err(Error::CancelNotAllowed); + } + + if data.funded_amt > 0 { + let amount_to_refund = data.funded_amt; + let token = token::Client::new(&env, &data.token); + let contract = env.current_contract_address(); + let funder_opt = data.funder.clone(); + + if let Some(distributor) = config.payment_distributor.as_ref() { + token.transfer(&contract, distributor, &amount_to_refund); + env.invoke_contract::<()>( + distributor, + &Symbol::new(&env, DISTRIBUTE_REFUND_FN), + soroban_sdk::vec![ + &env, + contract.to_val(), + invoice_id.clone().into_val(&env), + soroban_sdk::vec![ + &env, +
>::into_val( + &data.token, + &env + ), + as IntoVal>::into_val( + &funder_opt, + &env, + ) + ] + .into_val(&env), + soroban_sdk::vec![&env, amount_to_refund].into_val(&env), + (EscrowStatus::Cancelled as u32).into_val(&env) + ], + ); + } else { + if let Some(funder) = &funder_opt { + let funder_amt = storage::get_funder_amount(&env, invoice_id.clone(), funder); + if funder_amt > 0 { + token.transfer(&contract, funder, &funder_amt); + } + } + } + + env.invoke_contract::<()>( + &data.inv_token, + &Symbol::new(&env, "set_transfer_locked"), + soroban_sdk::vec![&env, contract.to_val(), false.into_val(&env)], + ); + events::escrow_refunded(&env, invoice_id.clone(), amount_to_refund); + } + data.status = EscrowStatus::Cancelled; + storage::set_escrow(&env, invoice_id.clone(), &data); + events::escrow_cancelled(&env, invoice_id.clone(), &seller); + events::escrow_status_changed( + &env, + invoice_id, + EscrowStatus::Cancelled, + env.ledger().timestamp(), + ); + Ok(()) + } + + /// Seller-only: attach or update the early-settlement discount hook for a Created or Funded escrow. + /// + /// Rules: + /// - Only callable by the escrow's seller. + /// - `discount_bps` must be in [1, 9999]. A zero discount is meaningless; 10 000 bps + /// (100%) would collapse the effective face value to zero, so it is rejected. + /// - `cutoff_date` must be strictly in the future and must not exceed `due_dt`. + /// - Cannot be set on an escrow that has already reached a terminal state + /// (Settled, Refunded, Cancelled). + /// - Can be called multiple times to update the config (e.g., extend the window + /// or adjust the rate) as long as the escrow is still live. + pub fn set_early_settlement( + env: Env, + invoice_id: Symbol, + seller: Address, + discount_bps: u32, + cutoff_date: u64, + ) -> Result<(), Error> { + seller.require_auth(); + let config = storage::get_config(&env).ok_or(Error::NotInit)?; + ensure_not_paused(&config)?; + + let mut data = + storage::get_escrow(&env, invoice_id.clone()).ok_or(Error::EscrowNotFound)?; + if data.seller != seller { + return Err(Error::Unauthorized); + } + + // Terminal states: hook can no longer be meaningful + match data.status { + EscrowStatus::Settled | EscrowStatus::Refunded | EscrowStatus::Cancelled => { + return Err(Error::InvalidEarlySettlement); + } + _ => {} + } + + // Validate discount_bps: must be [1, 9999] + if discount_bps == 0 || discount_bps >= MAX_BPS { + return Err(Error::InvalidEarlySettlement); + } + + // cutoff_date must be strictly in the future + let now = env.ledger().timestamp(); + if cutoff_date <= now { + return Err(Error::InvalidEarlySettlement); + } + // cutoff_date must not exceed due_dt (no discount window past maturity) + if cutoff_date > data.due_dt { + return Err(Error::InvalidEarlySettlement); + } + + data.early_settlement = Some(EarlySettlementConfig { + discount_bps, + cutoff_date, + }); + storage::set_escrow(&env, invoice_id, &data); + Ok(()) + } + + /// Fund the escrow (investor buys part or all of the invoice at purchase_price). + /// Transfers `amount` from buyer to this contract. Multiple investors can fund until fully subscribed. + pub fn fund_escrow( + env: Env, + invoice_id: Symbol, + buyer: Address, + amount: i128, + ) -> Result<(), Error> { + buyer.require_auth(); + Self::fund_escrow_core(&env, invoice_id, &buyer, amount) + } + + /// Fund the escrow on behalf of `buyer` using a signed off-chain approval that a relayer + /// submits on their behalf. `buyer` authorizes exactly this `(invoice_id, amount, nonce, expiry)` + /// tuple, and `nonce` must be strictly greater than the last nonce consumed by `buyer` so + /// the same signed approval cannot be replayed. + /// + /// Issue #183: Includes an `expiry` timestamp. If the ledger timestamp exceeds `expiry` + /// the signature is rejected, limiting the window for replay attacks. + pub fn fund_escrow_signed( + env: Env, + invoice_id: Symbol, + buyer: Address, + amount: i128, + nonce: u64, + expiry: u64, + ) -> Result<(), Error> { + buyer.require_auth_for_args((invoice_id.clone(), amount, nonce, expiry).into_val(&env)); + + let current_ts = env.ledger().timestamp(); + if current_ts > expiry { + return Err(Error::SignatureExpired); + } + + let last_nonce = storage::get_nonce(&env, &buyer); + if nonce <= last_nonce { + return Err(Error::NonceAlreadyUsed); + } + + Self::fund_escrow_core(&env, invoice_id.clone(), &buyer, amount)?; + + storage::set_nonce(&env, &buyer, nonce); + events::escrow_funded_signed(&env, invoice_id, &buyer, amount, nonce); + Ok(()) + } + + /// Shared funding logic used by both the directly-authorized and signed-approval entry points. + fn fund_escrow_core( + env: &Env, + invoice_id: Symbol, + buyer: &Address, + amount: i128, + ) -> Result<(), Error> { + // Fail fast: validate amount before hitting storage. + if amount == 0 { + return Err(Error::ZeroAmount); + } + if amount < 0 { + return Err(Error::InvalidAmount); + } + let config = storage::get_config(env).ok_or(Error::NotInit)?; + ensure_not_paused(&config)?; + if config.whitelist_enabled && !storage::is_whitelisted(env, buyer) { + return Err(Error::NotWhitelisted); + } + + let mut data = storage::get_escrow(env, invoice_id.clone()).ok_or(Error::EscrowNotFound)?; + if data.status == EscrowStatus::Cancelled { + return Err(Error::EscrowCancelled); + } + if data.status != EscrowStatus::Created { + return Err(Error::EscrowFunded); + } + + // Check that funding doesn't exceed purchase_price + let new_funded = data.funded_amt.checked_add(amount).ok_or(Error::Overflow)?; + if new_funded > data.purchase_price { + return Err(Error::InvalidAmount); + } + + let remaining_to_fund = data + .purchase_price + .checked_sub(data.funded_amt) + .ok_or(Error::Overflow)?; + + // Enforce global minimum investment to prevent dust deposits, except when + // the funder is completing the exact remaining capacity. + if config.min_investment > 0 + && amount != remaining_to_fund + && amount < config.min_investment + { + return Err(Error::AmountBelowMinimum); + } + + // Validate milestone constraints if a milestone is set + if let Some(milestone) = data.funding_milestone { + // Funder is always allowed to just fund exactly the remaining amount to complete the escrow. + // If they are not completing the escrow, the amount must be at least the milestone and a multiple of it. + if amount != remaining_to_fund && (amount < milestone || amount % milestone != 0) { + return Err(Error::InvalidMilestoneAmount); + } + } + + let token = token::Client::new(env, &data.token); + let contract = env.current_contract_address(); + token.transfer(buyer, &contract, &amount); + + // Mint invoice tokens to the buyer to represent their ownership share + env.invoke_contract::<()>( + &data.inv_token, + &Symbol::new(env, "mint"), + soroban_sdk::vec![env, buyer.to_val(), amount.into_val(env), contract.to_val()], + ); + + // Track this funder's contribution + let current_funder_amt = storage::get_funder_amount(env, invoice_id.clone(), buyer); + let new_funder_amt = current_funder_amt + .checked_add(amount) + .ok_or(Error::Overflow)?; + storage::set_funder_amount(env, invoice_id.clone(), buyer, new_funder_amt); + + data.funded_amt = new_funded; + + let mut already_recorded = false; + for funder in data.funders.iter() { + if funder == buyer.clone() { + already_recorded = true; + break; + } + } + if !already_recorded { + data.funders.push_back(buyer.clone()); + } + + // MVP: Store the first funder for direct distribution + if data.funder.is_none() { + data.funder = Some(buyer.clone()); + } + + // If fully funded, transition to Funded status + if data.funded_amt == data.purchase_price { + data.status = EscrowStatus::Funded; + } + + storage::set_escrow(env, invoice_id.clone(), &data); + events::escrow_funded( + env, + invoice_id.clone(), + buyer, + amount, + data.funded_amt, + data.purchase_price, + ); + if data.status == EscrowStatus::Funded { + events::escrow_status_changed( + env, + invoice_id, + EscrowStatus::Funded, + env.ledger().timestamp(), + ); + } + Ok(()) + } + + /// Record payment: distribute to investors and platform fee. Payer must auth. + /// Payer must be the authorized debtor for this invoice. + /// Payment is applied toward face_value; fees are calculated on the payment amount. + /// MVP: Distributes pro-rata to all funders based on their contribution. + pub fn record_payment( + env: Env, + invoice_id: Symbol, + payer: Address, + amount: i128, + ) -> Result<(), Error> { + payer.require_auth(); + if amount <= 0 { + return Err(Error::InvalidAmount); + } + let config = storage::get_config(&env).ok_or(Error::NotInit)?; + ensure_not_paused(&config)?; + let mut data = + storage::get_escrow(&env, invoice_id.clone()).ok_or(Error::EscrowNotFound)?; + + // Enforce payer role: payer must be the authorized debtor + if payer != data.debtor { + return Err(Error::InvalidPayer); + } + + if data.status != EscrowStatus::Funded { + return Err(Error::AlreadySettled); + } + + // Compute effective face value: apply early-settlement discount if the hook + // is configured and the payment arrives strictly before the cutoff date. + let current_ts = env.ledger().timestamp(); + let effective_face_value = + if let Some(ref es) = data.early_settlement { + if current_ts < es.cutoff_date { + let discount = data + .face_value + .checked_mul(i128::from(es.discount_bps)) + .ok_or(Error::Overflow)? + .checked_div(i128::from(MAX_BPS)) + .ok_or(Error::Overflow)?; + let discounted = data + .face_value + .checked_sub(discount) + .ok_or(Error::Overflow)? + .max(1); // floor at 1 stroop + // Emit the hook application event (only on first payment in the window + // to avoid redundant emissions on subsequent partial payments). + if data.paid_amt == 0 { + events::early_settlement_applied( + &env, + invoice_id.clone(), + es.discount_bps, + data.face_value, + discounted, + ); + } + discounted + } else { + data.face_value + } + } else { + data.face_value + }; + + // Remaining balance toward effective face value + let remaining = effective_face_value + .checked_sub(data.paid_amt) + .ok_or(Error::Overflow)?; + if amount > remaining { + return Err(Error::InvalidAmount); + } + + let fee_bps = i128::from(config.fee_bps); + // Fee is calculated on the payment amount (not face_value) + let platform_fee = amount + .checked_mul(fee_bps) + .ok_or(Error::Overflow)? + .checked_div(i128::from(MAX_BPS)) + .ok_or(Error::Overflow)?; + let investor_amount = amount.checked_sub(platform_fee).ok_or(Error::Overflow)?; + + let token = token::Client::new(&env, &data.token); + let contract = env.current_contract_address(); + + // 1. Pull payer's funds into escrow + token.transfer(&payer, &contract, &amount); + + data.paid_amt = data.paid_amt.checked_add(amount).ok_or(Error::Overflow)?; + + // Settlement occurs when paid_amt reaches effective face value + if data.paid_amt == effective_face_value { + data.status = EscrowStatus::Settled; + } + + storage::set_escrow(&env, invoice_id.clone(), &data); + + let funder_addr = data.funder.clone().unwrap_or_else(|| data.seller.clone()); + + if let Some(distributor) = config.payment_distributor.as_ref() { + // The distributor must pay seller_amount (== amount) plus investor_amount + platform_fee + // (== amount), mirroring the direct path below which releases the payer's `amount` to the + // seller in addition to paying the investor/admin out of escrow's held funding. + let total_to_distributor = amount.checked_add(amount).ok_or(Error::Overflow)?; + token.transfer(&contract, distributor, &total_to_distributor); + env.invoke_contract::<()>( + distributor, + &Symbol::new(&env, DISTRIBUTE_PAYMENT_FN), + soroban_sdk::vec![ + &env, + contract.to_val(), + invoice_id.clone().into_val(&env), + soroban_sdk::vec![ + &env, +
>::into_val(&data.token, &env), +
>::into_val(&data.seller, &env), +
>::into_val(&funder_addr, &env), +
>::into_val(&config.admin, &env) + ] + .into_val(&env), + soroban_sdk::vec![ + &env, + data.paid_amt, + amount, + investor_amount, + config.fee_bps as i128, + ] + .into_val(&env), + (data.status as u32).into_val(&env) + ], + ); + } else { + // 2. Platform fee to admin + token.transfer(&contract, &config.admin, &platform_fee); + + // 3. Pro-rata investor distribution + if let Some(funder) = &data.funder { + if data.funded_amt > 0 && investor_amount > 0 { + let funder_amt = storage::get_funder_amount(&env, invoice_id.clone(), funder); + let pro_rata_share = investor_amount + .checked_mul(funder_amt) + .ok_or(Error::Overflow)? + .checked_div(data.funded_amt) + .ok_or(Error::Overflow)?; + if pro_rata_share > 0 { + token.transfer(&contract, funder, &pro_rata_share); + } + } + } + // Seller receives the full payment amount + token.transfer(&contract, &data.seller, &amount); + } + + if data.status == EscrowStatus::Settled { + // Unlock invoice token transfers only when the invoice is completely settled. + env.invoke_contract::<()>( + &data.inv_token, + &Symbol::new(&env, "set_transfer_locked"), + soroban_sdk::vec![&env, contract.to_val(), false.into_val(&env)], + ); + } + + events::payment_settled( + &env, + invoice_id.clone(), + amount, + platform_fee, + investor_amount, + ); + if data.status == EscrowStatus::Settled { + events::escrow_status_changed( + &env, + invoice_id, + EscrowStatus::Settled, + env.ledger().timestamp(), + ); + } + Ok(()) + } + + /// Refund the investors if the invoice was not paid by due date. Anyone may call. + /// Refunds are distributed pro-rata based on each investor's contribution. + pub fn refund_escrow(env: Env, invoice_id: Symbol) -> Result<(), Error> { + let config = storage::get_config(&env).ok_or(Error::NotInit)?; + ensure_not_paused(&config)?; + let mut data = + storage::get_escrow(&env, invoice_id.clone()).ok_or(Error::EscrowNotFound)?; + if data.status != EscrowStatus::Funded { + return Err(Error::RefundNotAllowed); + } + let ledger_ts = env.ledger().timestamp(); + if ledger_ts < data.due_dt { + return Err(Error::RefundNotAllowed); + } + + // Refund the remaining collateral (purchase_price minus already released partial payments) + let amount_to_refund = data + .purchase_price + .checked_sub(data.paid_amt) + .ok_or(Error::Overflow)?; + + let token = token::Client::new(&env, &data.token); + let contract = env.current_contract_address(); + + // Extract funder address before status mutation so it is available in both paths. + let funder_opt = data.funder.clone(); + + data.status = EscrowStatus::Refunded; + storage::set_escrow(&env, invoice_id.clone(), &data); + + if amount_to_refund > 0 { + if let Some(distributor) = config.payment_distributor.as_ref() { + token.transfer(&contract, distributor, &amount_to_refund); + env.invoke_contract::<()>( + distributor, + &Symbol::new(&env, DISTRIBUTE_REFUND_FN), + soroban_sdk::vec![ + &env, + contract.to_val(), + invoice_id.clone().into_val(&env), + soroban_sdk::vec![ + &env, +
>::into_val( + &data.token, + &env + ), + as IntoVal>::into_val( + &funder_opt, + &env, + ) + ] + .into_val(&env), + soroban_sdk::vec![&env, amount_to_refund].into_val(&env), + (data.status as u32).into_val(&env) + ], + ); + } else { + // Pro-rata refund to funders + if let Some(funder) = &funder_opt { + if data.funded_amt > 0 { + let funder_amt = + storage::get_funder_amount(&env, invoice_id.clone(), funder); + let pro_rata_refund = amount_to_refund + .checked_mul(funder_amt) + .ok_or(Error::Overflow)? + .checked_div(data.funded_amt) + .ok_or(Error::Overflow)?; + if pro_rata_refund > 0 { + token.transfer(&contract, funder, &pro_rata_refund); + } + } + } + } + } + + // Unlock invoice token transfers now that the invoice is refunded + env.invoke_contract::<()>( + &data.inv_token, + &Symbol::new(&env, "set_transfer_locked"), + soroban_sdk::vec![&env, contract.to_val(), false.into_val(&env)], + ); + + events::escrow_refunded(&env, invoice_id.clone(), amount_to_refund); + events::escrow_status_changed( + &env, + invoice_id, + EscrowStatus::Refunded, + env.ledger().timestamp(), + ); + Ok(()) + } + + /// Update platform fee (basis points). Admin only. + pub fn update_platform_fee_bps(env: Env, new_fee_bps: u32) -> Result<(), Error> { + let mut config = storage::get_config(&env).ok_or(Error::NotInit)?; + let admin = config.admin.clone(); + admin.require_auth(); + if new_fee_bps > MAX_BPS { + return Err(Error::InvalidFeeBps); + } + let old_fee_bps = config.fee_bps; + config.fee_bps = new_fee_bps; + storage::set_config(&env, &config); + events::platform_fee_updated(&env, old_fee_bps, new_fee_bps); + Ok(()) + } + + /// Set the payment distributor used for settlement/refund fan-out. Admin only. + pub fn set_payment_distributor(env: Env, payment_distributor: Address) -> Result<(), Error> { + let mut config = storage::get_config(&env).ok_or(Error::NotInit)?; + let admin = config.admin.clone(); + admin.require_auth(); + let old_distributor = config.payment_distributor.clone(); + config.payment_distributor = Some(payment_distributor.clone()); + storage::set_config(&env, &config); + events::payment_distributor_updated(&env, old_distributor.is_some(), &payment_distributor); + Ok(()) + } + + /// Toggle the emergency pause flag. Admin only. + pub fn set_paused(env: Env, paused: bool) -> Result<(), Error> { + let mut config = storage::get_config(&env).ok_or(Error::NotInit)?; + let admin = config.admin.clone(); + admin.require_auth(); + let old_paused = config.paused; + config.paused = paused; + storage::set_config(&env, &config); + events::paused_updated(&env, old_paused, paused); + Ok(()) + } + + /// View: return escrow data for an invoice, or Err(Error::EscrowNotFound) if not found. + pub fn get_escrow(env: Env, invoice_id: Symbol) -> Result { + storage::get_escrow(&env, invoice_id).ok_or(Error::EscrowNotFound) + } + + /// View: return current config (admin and fee_bps). + pub fn get_config(env: Env) -> Result { + storage::get_config(&env).ok_or(Error::NotInit) + } + + /// View: return escrow status for an invoice. + pub fn get_escrow_status(env: Env, invoice_id: Symbol) -> Result { + let data = storage::get_escrow(&env, invoice_id).ok_or(Error::EscrowNotFound)?; + Ok(data.status) + } + + /// View: return the current pause state. + pub fn paused(env: Env) -> Result { + let config = storage::get_config(&env).ok_or(Error::NotInit)?; + Ok(config.paused) + } + + /// Admin-only: configure the emergency multi-sig admin set and threshold. + pub fn set_emergency_config( + env: Env, + admin: Address, + config: MultiSigConfig, + ) -> Result<(), Error> { + admin.require_auth(); + let stored_config = storage::get_config(&env).ok_or(Error::NotInit)?; + if stored_config.admin != admin { + return Err(Error::Unauthorized); + } + if config.threshold == 0 || config.threshold > config.admins.len() as u32 { + return Err(Error::InvalidFeeBps); // reuse for invalid threshold + } + storage::set_emergency_config(&env, &config); + Ok(()) + } + + /// Emergency multi-sig release: an admin approves releasing funds for an invoice. + /// When the threshold is reached, funds are paid out to the seller and the escrow + /// is marked as Settled. + pub fn emergency_release(env: Env, caller: Address, invoice_id: Symbol) -> Result { + caller.require_auth(); + let config = storage::get_emergency_config(&env).ok_or(Error::EmergencyNotConfigured)?; + + // Verify caller is an emergency admin + let mut is_admin = false; + for admin in config.admins.iter() { + if admin == caller { + is_admin = true; + break; + } + } + if !is_admin { + return Err(Error::NotEmergencyAdmin); + } + + let mut approvals = storage::get_emergency_approvals(&env, &invoice_id); + + // Check for duplicate approval + for addr in approvals.approvals.iter() { + if addr == caller { + return Err(Error::AlreadyApproved); + } + } + + approvals.approvals.push_back(caller.clone()); + storage::set_emergency_approvals(&env, &invoice_id, &approvals); + + if (approvals.approvals.len() as u32) < config.threshold { + return Ok(false); + } + + // Threshold reached ? execute emergency release + let mut data = + storage::get_escrow(&env, invoice_id.clone()).ok_or(Error::EscrowNotFound)?; + + if data.status == EscrowStatus::Settled + || data.status == EscrowStatus::Refunded + || data.status == EscrowStatus::Cancelled + { + return Err(Error::AlreadySettled); + } + + let token = token::Client::new(&env, &data.token); + let contract = env.current_contract_address(); + let remaining = data + .purchase_price + .checked_sub(data.paid_amt) + .ok_or(Error::Overflow)?; + + // Pay remaining to seller + if remaining > 0 { + token.transfer(&contract, &data.seller, &remaining); + } + + data.status = EscrowStatus::Settled; + storage::set_escrow(&env, invoice_id.clone(), &data); + + events::escrow_status_changed( + &env, + invoice_id.clone(), + EscrowStatus::Settled, + env.ledger().timestamp(), + ); + Ok(true) + } + + /// Reclaim persistent storage for an escrow that has reached a terminal state + /// (Settled, Refunded, or Cancelled). Callable only by the seller or the admin. + /// The escrow and its per-funder contribution record are removed permanently; + /// terminal-state escrows are never mutated again, so this is safe to prune. + pub fn cleanup_escrow(env: Env, invoice_id: Symbol, caller: Address) -> Result<(), Error> { + caller.require_auth(); + let config = storage::get_config(&env).ok_or(Error::NotInit)?; + let data = storage::get_escrow(&env, invoice_id.clone()).ok_or(Error::EscrowNotFound)?; + if caller != data.seller && caller != config.admin { + return Err(Error::Unauthorized); + } + match data.status { + EscrowStatus::Settled | EscrowStatus::Refunded | EscrowStatus::Cancelled => {} + _ => return Err(Error::EscrowNotSettled), + } + storage::remove_escrow_state(&env, invoice_id.clone(), &data.funders); + events::escrow_cleaned_up(&env, invoice_id); + Ok(()) + } + + // ?? Position management: top_up / partial_refund / transfer_position / finalise_funding ?? + + /// Create a funding invoice (BytesN<32> id) for the new position management flows. + /// This is the setup entrypoint for tests and admin tooling for the + /// top_up / partial_refund / transfer_position / finalise_funding lifecycle. + pub fn create_invoice( + env: Env, + invoice_id: BytesN<32>, + seller: Address, + funding_target: i128, + deadline_ledger: u32, + min_investment: i128, + per_investor_cap: Option, + token: Address, + ) -> Result<(), Error> { + seller.require_auth(); + if funding_target <= 0 { + return Err(Error::InvalidAmount); + } + if min_investment < 0 { + return Err(Error::InvalidAmount); + } + if storage::has_invoice(&env, invoice_id.clone()) { + return Err(Error::EscrowExists); + } + let invoice = FundingInvoice { + seller: seller.clone(), + funding_target, + total_raised: 0, + deadline_ledger, + min_investment, + per_investor_cap, + status: InvoiceStatus::Open, + token: token.clone(), + }; + storage::set_invoice(&env, invoice_id, &invoice); + Ok(()) + } + + /// Top up an existing investor position. + /// Validates invoice is Open, caller has non-zero position, and cap not exceeded. + pub fn top_up( + env: Env, + investor: Address, + invoice_id: BytesN<32>, + additional_amount: i128, + ) -> Result<(), Error> { + investor.require_auth(); + if additional_amount <= 0 { + return Err(Error::InvalidAmount); + } + let mut invoice = + storage::get_invoice(&env, invoice_id.clone()).ok_or(Error::EscrowNotFound)?; + if invoice.status != InvoiceStatus::Open { + return Err(Error::InvalidInvoiceStatus); + } + let current = storage::get_investor_position(&env, &invoice_id, &investor); + if current == 0 { + return Err(Error::NoPositionFound); + } + let new_total_position = current + .checked_add(additional_amount) + .ok_or(Error::Overflow)?; + if let Some(cap) = invoice.per_investor_cap { + if new_total_position > cap { + return Err(Error::InvalidAmount); + } + } + let new_total_raised = invoice + .total_raised + .checked_add(additional_amount) + .ok_or(Error::Overflow)?; + if new_total_raised > invoice.funding_target { + return Err(Error::InvalidAmount); + } + // Transfer additional_amount from investor to contract + let token_client = token::Client::new(&env, &invoice.token); + token_client.transfer( + &investor, + &env.current_contract_address(), + &additional_amount, + ); + storage::set_investor_position(&env, &invoice_id, &investor, new_total_position); + invoice.total_raised = new_total_raised; + storage::set_invoice(&env, invoice_id.clone(), &invoice); + events::investment_topped_up( + &env, + &investor, + invoice_id, + additional_amount, + new_total_position, + ); + Ok(()) + } + + // ---------- Invoice Registration, Investment, Refund, Settlement & TTL Refresh ---------- + + /// Register invoice metadata and funding parameters on-chain. Callable only by admin. + pub fn register_invoice( + env: Env, + invoice_id: BytesN<32>, + face_value: i128, + funding_target: i128, + yield_bps: u32, + deadline_ledger: u32, + ) -> Result<(), Error> { + let config = storage::get_config(&env).ok_or(Error::NotInit)?; + config.admin.require_auth(); + + if face_value <= 0 || funding_target <= 0 { + return Err(Error::InvalidAmount); + } + if !(1..=5000).contains(&yield_bps) { + return Err(Error::InvalidYield); + } + if storage::has_invoice_record(&env, &invoice_id) { + return Err(Error::InvoiceAlreadyExists); + } + + let data = InvoiceData { + invoice_id: invoice_id.clone(), + face_value, + funding_target, + yield_bps, + deadline_ledger, + total_raised: 0, + status: EscrowStatus::Created, + investors: soroban_sdk::Vec::new(&env), + }; + + storage::set_invoice_record(&env, &invoice_id, &data); + events::invoice_registered( + &env, + &invoice_id, + face_value, + funding_target, + yield_bps, + deadline_ledger, + ); + Ok(()) + } + + /// Admin-only: extend the funding deadline for an open invoice. + pub fn extend_deadline( + env: Env, + invoice_id: BytesN<32>, + new_deadline_ledger: u32, + ) -> Result<(), Error> { + let config = storage::get_config(&env).ok_or(Error::NotInit)?; + config.admin.require_auth(); + + let mut invoice = + storage::get_invoice(&env, invoice_id.clone()).ok_or(Error::EscrowNotFound)?; + if invoice.status != InvoiceStatus::Open { + return Err(Error::InvalidInvoiceStatus); + } + + let old_deadline_ledger = invoice.deadline_ledger; + if new_deadline_ledger <= old_deadline_ledger { + return Err(Error::DeadlineNotExtended); + } + + invoice.deadline_ledger = new_deadline_ledger; + storage::set_invoice(&env, invoice_id.clone(), &invoice); + events::deadline_extended( + &env, + &invoice_id, + old_deadline_ledger, + new_deadline_ledger, + ); + Ok(()) + } + /// Invest in a registered invoice or funding invoice. + pub fn invest( + env: Env, + invoice_id: BytesN<32>, + investor: Address, + amount: i128, + ) -> Result<(), Error> { + investor.require_auth(); + if amount <= 0 { + return Err(Error::InvalidAmount); + } + + if let Some(mut invoice) = storage::get_invoice(&env, invoice_id.clone()) { + if invoice.status != InvoiceStatus::Open { + return Err(Error::InvalidInvoiceStatus); + } + if amount < invoice.min_investment { + return Err(Error::BelowMinimumInvestment); + } + if let Some(cap) = invoice.per_investor_cap { + if amount > cap { + return Err(Error::InvalidAmount); + } + } + let new_total = invoice + .total_raised + .checked_add(amount) + .ok_or(Error::Overflow)?; + if new_total > invoice.funding_target { + return Err(Error::InvalidAmount); + } + let current = storage::get_investor_position(&env, &invoice_id, &investor); + let new_pos = current.checked_add(amount).ok_or(Error::Overflow)?; + if let Some(cap) = invoice.per_investor_cap { + if new_pos > cap { + return Err(Error::InvalidAmount); + } + } + let token_client = token::Client::new(&env, &invoice.token); + token_client.transfer(&investor, &env.current_contract_address(), &amount); + storage::set_investor_position(&env, &invoice_id, &investor, new_pos); + invoice.total_raised = new_total; + storage::set_invoice(&env, invoice_id, &invoice); + return Ok(()); + } + + let config = storage::get_config(&env).ok_or(Error::NotInit)?; + ensure_not_paused(&config)?; + + let mut record = + storage::get_invoice_record(&env, &invoice_id).ok_or(Error::EscrowNotFound)?; + if record.status != EscrowStatus::Created { + return Err(Error::InvalidInvoiceStatus); + } + let current_ledger = env.ledger().sequence(); + if current_ledger > record.deadline_ledger { + return Err(Error::FundingDeadlineNotPassed); + } + + let new_raised = record + .total_raised + .checked_add(amount) + .ok_or(Error::Overflow)?; + if new_raised > record.funding_target { + return Err(Error::InvalidAmount); + } + + let current_pos = storage::get_investor_position(&env, &invoice_id, &investor); + let new_pos = current_pos.checked_add(amount).ok_or(Error::Overflow)?; + storage::set_investor_position(&env, &invoice_id, &investor, new_pos); + + let mut already_in = false; + for inv in record.investors.iter() { + if inv == investor { + already_in = true; + break; + } + } + if !already_in { + record.investors.push_back(investor.clone()); + } + + record.total_raised = new_raised; + if record.total_raised == record.funding_target { + record.status = EscrowStatus::Funded; + } + + storage::set_invoice_record(&env, &invoice_id, &record); + Ok(()) + } + + /// Partially refund an investor's position before deadline. + pub fn partial_refund( + env: Env, + investor: Address, + invoice_id: BytesN<32>, + amount: i128, + ) -> Result<(), Error> { + investor.require_auth(); + if amount <= 0 { + return Err(Error::InvalidAmount); + } + let mut invoice = + storage::get_invoice(&env, invoice_id.clone()).ok_or(Error::EscrowNotFound)?; + if invoice.status != InvoiceStatus::Open { + return Err(Error::InvalidInvoiceStatus); + } + let current_ledger = env.ledger().sequence(); + if current_ledger >= invoice.deadline_ledger { + return Err(Error::InvalidInvoiceStatus); + } + let current = storage::get_investor_position(&env, &invoice_id, &investor); + if current == 0 { + return Err(Error::NoPositionFound); + } + if amount > current { + return Err(Error::InvalidAmount); + } + let remaining = current.checked_sub(amount).ok_or(Error::Overflow)?; + if remaining != 0 && remaining < invoice.min_investment { + return Err(Error::BelowMinimumInvestment); + } + let new_total_raised = invoice + .total_raised + .checked_sub(amount) + .ok_or(Error::Overflow)?; + // Transfer back to caller + let token_client = token::Client::new(&env, &invoice.token); + token_client.transfer(&env.current_contract_address(), &investor, &amount); + storage::set_investor_position(&env, &invoice_id, &investor, remaining); + invoice.total_raised = new_total_raised; + storage::set_invoice(&env, invoice_id.clone(), &invoice); + events::investment_partially_refunded(&env, &investor, invoice_id, amount, remaining); + Ok(()) + } + + /// Transfer a funded position from seller to buyer for an agreed price. + pub fn transfer_position( + env: Env, + from: Address, + invoice_id: BytesN<32>, + to: Address, + price: i128, + ) -> Result<(), Error> { + from.require_auth(); + if price < 0 { + return Err(Error::InvalidAmount); + } + let invoice = + storage::get_invoice(&env, invoice_id.clone()).ok_or(Error::EscrowNotFound)?; + if invoice.status != InvoiceStatus::Funded { + return Err(Error::InvalidInvoiceStatus); + } + let position = storage::get_investor_position(&env, &invoice_id, &from); + if position == 0 { + return Err(Error::NoPositionFound); + } + to.require_auth(); + if price > 0 { + let token_client = token::Client::new(&env, &invoice.token); + token_client.transfer(&to, &from, &price); + } + let buyer_existing = storage::get_investor_position(&env, &invoice_id, &to); + let new_buyer_pos = buyer_existing + .checked_add(position) + .ok_or(Error::Overflow)?; + if let Some(cap) = invoice.per_investor_cap { + if new_buyer_pos > cap { + return Err(Error::InvalidAmount); + } + } + storage::set_investor_position(&env, &invoice_id, &from, 0); + storage::set_investor_position(&env, &invoice_id, &to, new_buyer_pos); + events::position_transferred(&env, &from, &to, invoice_id, position, price); + Ok(()) + } + + /// Finalise funding: transition Open->Funded when target reached, release proceeds to seller. + pub fn finalise_funding(env: Env, invoice_id: BytesN<32>) -> Result<(), Error> { + if let Some(mut invoice) = storage::get_invoice(&env, invoice_id.clone()) { + let config = storage::get_config(&env).ok_or(Error::NotInit)?; + config.admin.require_auth(); + if invoice.status != InvoiceStatus::Open { + return Err(Error::InvalidInvoiceStatus); + } + if invoice.total_raised < invoice.funding_target { + return Err(Error::FundingTargetNotReached); + } + invoice.status = InvoiceStatus::Funded; + storage::set_invoice(&env, invoice_id.clone(), &invoice); + if invoice.total_raised > 0 { + let token_client = token::Client::new(&env, &invoice.token); + token_client.transfer( + &env.current_contract_address(), + &invoice.seller, + &invoice.total_raised, + ); + } + events::funding_finalised(&env, invoice_id, invoice.total_raised, &invoice.seller); + return Ok(()); + } + + let mut record = + storage::get_invoice_record(&env, &invoice_id).ok_or(Error::EscrowNotFound)?; + if record.status != EscrowStatus::Created && record.status != EscrowStatus::Funded { + return Err(Error::InvalidInvoiceStatus); + } + record.status = EscrowStatus::Funded; + storage::set_invoice_record(&env, &invoice_id, &record); + Ok(()) + } + + /// Refund an investor's committed position if deadline passed without reaching target. + pub fn refund( + env: Env, + invoice_id: BytesN<32>, + investor: Address, + ) -> Result<(), Error> { + investor.require_auth(); + let config = storage::get_config(&env).ok_or(Error::NotInit)?; + ensure_not_paused(&config)?; + + let mut record = + storage::get_invoice_record(&env, &invoice_id).ok_or(Error::EscrowNotFound)?; + + if record.status == EscrowStatus::Funded { + return Err(Error::InvalidInvoiceStatus); + } + + let current_ledger = env.ledger().sequence(); + if current_ledger <= record.deadline_ledger { + return Err(Error::FundingDeadlineNotPassed); + } + + let committed = storage::get_investor_position(&env, &invoice_id, &investor); + if committed <= 0 { + return Err(Error::NoPositionFound); + } + + storage::remove_investor_position(&env, &invoice_id, &investor); + + record.total_raised = record + .total_raised + .checked_sub(committed) + .ok_or(Error::Overflow)?; + + storage::set_invoice_record(&env, &invoice_id, &record); + events::investment_refunded(&env, &investor, &invoice_id, committed); + Ok(()) + } + + /// Settle invoice pro-rata across investors when seller repays. Callable only by admin. + pub fn settle_invoice( + env: Env, + invoice_id: BytesN<32>, + repayment_amount: i128, + ) -> Result<(), Error> { + let config = storage::get_config(&env).ok_or(Error::NotInit)?; + config.admin.require_auth(); + + let mut record = + storage::get_invoice_record(&env, &invoice_id).ok_or(Error::EscrowNotFound)?; + + if record.status != EscrowStatus::Funded { + return Err(Error::InvalidInvoiceStatus); + } + + if repayment_amount < record.total_raised { + return Err(Error::InsufficientRepayment); + } + + let mut total_payouts: i128 = 0; + for investor in record.investors.iter() { + let committed = storage::get_investor_position(&env, &invoice_id, &investor); + if committed > 0 { + let payout = committed + .checked_mul(repayment_amount) + .ok_or(Error::Overflow)? + .checked_div(record.total_raised) + .ok_or(Error::Overflow)?; + let yield_earned = payout.saturating_sub(committed); + total_payouts = total_payouts.checked_add(payout).ok_or(Error::Overflow)?; + events::settlement_paid(&env, &investor, &invoice_id, payout, yield_earned); + } + } + + let _dust = repayment_amount + .checked_sub(total_payouts) + .ok_or(Error::Overflow)?; + + record.status = EscrowStatus::Settled; + storage::set_invoice_record(&env, &invoice_id, &record); + Ok(()) + } + + /// Admin-only: refresh TTL for invoice record and all investor position entries. + pub fn refresh_all_ttls(env: Env, invoice_id: BytesN<32>) -> Result<(), Error> { + let config = storage::get_config(&env).ok_or(Error::NotInit)?; + config.admin.require_auth(); + + let record = + storage::get_invoice_record(&env, &invoice_id).ok_or(Error::EscrowNotFound)?; + for investor in record.investors.iter() { + let _ = storage::get_investor_position(&env, &invoice_id, &investor); + } + Ok(()) + } + + /// View: get funding invoice (BytesN<32>). + pub fn get_invoice(env: Env, invoice_id: BytesN<32>) -> Result { + storage::get_invoice(&env, invoice_id).ok_or(Error::EscrowNotFound) + } + + /// View: return registered invoice data. + pub fn get_invoice_record(env: Env, invoice_id: BytesN<32>) -> Result { + storage::get_invoice_record(&env, &invoice_id).ok_or(Error::EscrowNotFound) + } + + /// View: return investor position amount for an invoice. + pub fn get_investor_position( + env: Env, + invoice_id: BytesN<32>, + investor: Address, + ) -> Result { + Ok(storage::get_investor_position(&env, &invoice_id, &investor)) + } + + /// Paginated query to retrieve multiple escrows by sequential creation order. + pub fn get_escrows(env: Env, start: u32, limit: u32) -> Result, Error> { + const MAX_PAGE_SIZE: u32 = 100; + + if limit == 0 { + return Err(Error::InvalidLimit); + } + if limit > MAX_PAGE_SIZE { + return Err(Error::LimitExceeded); + } + + let total_count = storage::get_escrow_count(&env); + + if start >= total_count { + return Ok(soroban_sdk::Vec::new(&env)); + } + + let end = core::cmp::min(start + limit, total_count); + let mut results = soroban_sdk::Vec::new(&env); + + for index in start..end { + if let Some(invoice_id) = storage::get_escrow_id_by_index(&env, index) { + if let Some(escrow_data) = storage::get_escrow(&env, invoice_id) { + results.push_back(escrow_data); + } + } + } + + Ok(results) + } +} + +#[cfg(test)] +mod integration_test; +#[cfg(test)] +mod test; diff --git a/contracts/invoice-escrow/src/storage.rs b/contracts/invoice-escrow/src/storage.rs index 43e0c3c..7423748 100644 --- a/contracts/invoice-escrow/src/storage.rs +++ b/contracts/invoice-escrow/src/storage.rs @@ -1,324 +1,908 @@ -//! Storage helpers: instance for config, persistent for escrow data, invoice records, and positions. - -use soroban_sdk::{Address, BytesN, Env, Symbol}; - -use crate::types::{ - Config, EmergencyApprovals, EscrowData, InvoiceData, MultiSigConfig, StorageKey, -}; - -/// Ledgers below which a persistent entry's TTL is extended (~7 days at 5s/ledger). -pub const TTL_THRESHOLD: u32 = 120_960; -/// Minimum TTL extension in ledger units (~60 days at 5s/ledger: 60 * 24 * 3600 / 5 = 1,036,800 ledgers). -pub const MIN_TTL_EXTEND: u32 = 1_036_800; - -/// Extend the TTL of any persistent storage entry to at least 60 days in ledger units. -pub fn bump_persistent(env: &Env, key: &StorageKey) { - env.storage() - .persistent() - .extend_ttl(key, TTL_THRESHOLD, MIN_TTL_EXTEND); -} - - -/// Load contract config from instance storage, bumping instance TTL. -pub fn get_config(env: &Env) -> Option { - env.storage() - .instance() - .extend_ttl(TTL_THRESHOLD, MIN_TTL_EXTEND); - env.storage().instance().get(&StorageKey::Config) -} - -/// Save contract config to instance storage, bumping instance TTL. -pub fn set_config(env: &Env, config: &Config) { - env.storage().instance().set(&StorageKey::Config, config); - env.storage() - .instance() - .extend_ttl(TTL_THRESHOLD, MIN_TTL_EXTEND); -} - -/// Load escrow data for an invoice from persistent storage. -/// Extends the entry's TTL on every access so actively-used escrows never -/// expire mid-lifecycle regardless of how long between state transitions. -pub fn get_escrow(env: &Env, inv_id: Symbol) -> Option { - let key = StorageKey::Escrow(inv_id.clone()); - let data = env.storage().persistent().get(&key); - if data.is_some() { - bump_persistent(env, &key); - } - data -} - -/// Save escrow data for an invoice to persistent storage, extending its TTL. -pub fn set_escrow(env: &Env, inv_id: Symbol, data: &EscrowData) { - let key = StorageKey::Escrow(inv_id.clone()); - env.storage().persistent().set(&key, data); - bump_persistent(env, &key); -} - -/// Check if an escrow exists for the given invoice. -pub fn has_escrow(env: &Env, inv_id: Symbol) -> bool { - let key = StorageKey::Escrow(inv_id); - let exists = env.storage().persistent().has(&key); - if exists { - bump_persistent(env, &key); - } - exists -} - -/// Remove escrow data and all per-funder contribution records for an invoice from -/// persistent storage (storage footprint cleanup). -pub fn remove_escrow_state( - env: &Env, - inv_id: Symbol, - funders: &soroban_sdk::Vec
, -) { - for funder in funders.iter() { - env.storage() - .persistent() - .remove(&StorageKey::FunderAmount(inv_id.clone(), funder)); - } - env.storage() - .persistent() - .remove(&StorageKey::Escrow(inv_id)); -} - -/// Get the highest nonce consumed so far for a buyer's signed off-chain approvals. -pub fn get_nonce(env: &Env, buyer: &Address) -> u64 { - let key = StorageKey::Nonce(buyer.clone()); - let nonce = env.storage().persistent().get(&key).unwrap_or(0); - if env.storage().persistent().has(&key) { - bump_persistent(env, &key); - } - nonce -} - -/// Record the highest nonce consumed for a buyer's signed off-chain approvals. -pub fn set_nonce(env: &Env, buyer: &Address, nonce: u64) { - let key = StorageKey::Nonce(buyer.clone()); - env.storage().persistent().set(&key, &nonce); - bump_persistent(env, &key); -} - -/// Get the amount funded by a specific funder for an invoice. -pub fn get_funder_amount( - env: &Env, - inv_id: Symbol, - funder: &Address, -) -> i128 { - let key = StorageKey::FunderAmount(inv_id, funder.clone()); - let amount = env.storage().persistent().get(&key).unwrap_or(0); - if env.storage().persistent().has(&key) { - bump_persistent(env, &key); - } - amount -} - -/// Set the amount funded by a specific funder for an invoice. -pub fn set_funder_amount( - env: &Env, - inv_id: Symbol, - funder: &Address, - amount: i128, -) { - let key = StorageKey::FunderAmount(inv_id, funder.clone()); - if amount == 0 { - env.storage().persistent().remove(&key); - } else { - env.storage().persistent().set(&key, &amount); - bump_persistent(env, &key); - } -} - -/// Whether `buyer` is whitelisted to fund (buy) escrows. Absent entry = not whitelisted. -pub fn is_whitelisted(env: &Env, buyer: &Address) -> bool { - let key = StorageKey::BuyerWhitelist(buyer.clone()); - let whitelisted = env.storage().persistent().get(&key).unwrap_or(false); - if env.storage().persistent().has(&key) { - bump_persistent(env, &key); - } - whitelisted -} - -/// Set (or clear) a buyer's whitelist status. -pub fn set_whitelisted(env: &Env, buyer: &Address, allowed: bool) { - let key = StorageKey::BuyerWhitelist(buyer.clone()); - if allowed { - env.storage().persistent().set(&key, &true); - bump_persistent(env, &key); - } else { - env.storage().persistent().remove(&key); - } -} - -// ?? Funding invoice (BytesN<32>) storage for position management ??????? - -use soroban_sdk::BytesN; - -use crate::types::FundingInvoice; - -pub fn get_invoice(env: &soroban_sdk::Env, invoice_id: BytesN<32>) -> Option { - env.storage() - .persistent() - .get(&StorageKey::Invoice(invoice_id)) -} - -pub fn set_invoice(env: &soroban_sdk::Env, invoice_id: BytesN<32>, invoice: &FundingInvoice) { - env.storage() - .persistent() - .set(&StorageKey::Invoice(invoice_id), invoice); -} - -pub fn has_invoice(env: &soroban_sdk::Env, invoice_id: BytesN<32>) -> bool { - env.storage() - .persistent() - .has(&StorageKey::Invoice(invoice_id)) -} - -pub fn get_investor_position( - env: &soroban_sdk::Env, - invoice_id: BytesN<32>, - investor: &Address, -) -> i128 { - env.storage() - .persistent() - .get(&StorageKey::InvestorPosition(invoice_id, investor.clone())) - .unwrap_or(0) -} - -pub fn set_investor_position( - env: &soroban_sdk::Env, - invoice_id: BytesN<32>, - investor: &Address, - amount: i128, -) { - let key = StorageKey::InvestorPosition(invoice_id, investor.clone()); - if amount == 0 { - env.storage().persistent().remove(&key); - } else { - env.storage().persistent().set(&key, &amount); - } -/// Load the emergency multi-sig admin configuration. -pub fn get_emergency_config(env: &Env) -> Option { - env.storage() - .instance() - .extend_ttl(TTL_THRESHOLD, MIN_TTL_EXTEND); - env.storage().instance().get(&StorageKey::EmergencyConfig) -} - -/// Save the emergency multi-sig admin configuration. -pub fn set_emergency_config(env: &Env, config: &MultiSigConfig) { - env.storage() - .instance() - .set(&StorageKey::EmergencyConfig, config); - env.storage() - .instance() - .extend_ttl(TTL_THRESHOLD, MIN_TTL_EXTEND); -} - -/// Load the current emergency approvals for a given invoice. -pub fn get_emergency_approvals(env: &Env, inv_id: &Symbol) -> EmergencyApprovals { - let key = StorageKey::EmergencyApprovals(inv_id.clone()); - let approvals = env - .storage() - .persistent() - .get(&key) - .unwrap_or(EmergencyApprovals { - approvals: soroban_sdk::Vec::new(env), - }); - if env.storage().persistent().has(&key) { - bump_persistent(env, &key); - } - approvals -} - -/// Save emergency approvals for a given invoice. -pub fn set_emergency_approvals(env: &Env, inv_id: &Symbol, approvals: &EmergencyApprovals) { - let key = StorageKey::EmergencyApprovals(inv_id.clone()); - env.storage().persistent().set(&key, approvals); - bump_persistent(env, &key); -} - -/// Load invoice record by BytesN<32>. -pub fn get_invoice_record(env: &Env, inv_id: &BytesN<32>) -> Option { - let key = StorageKey::InvoiceRecord(inv_id.clone()); - let data: Option = env.storage().persistent().get(&key); - if data.is_some() { - bump_persistent(env, &key); - } - data -} - -/// Save invoice record by BytesN<32>, bumping its persistent TTL. -pub fn set_invoice_record(env: &Env, inv_id: &BytesN<32>, data: &InvoiceData) { - let key = StorageKey::InvoiceRecord(inv_id.clone()); - env.storage().persistent().set(&key, data); - bump_persistent(env, &key); -} - -/// Check if an invoice record exists for BytesN<32>. -pub fn has_invoice_record(env: &Env, inv_id: &BytesN<32>) -> bool { - let key = StorageKey::InvoiceRecord(inv_id.clone()); - let exists = env.storage().persistent().has(&key); - if exists { - bump_persistent(env, &key); - } - exists -} - -/// Get investor position for (invoice_id, investor). -pub fn get_investor_position(env: &Env, inv_id: &BytesN<32>, investor: &Address) -> i128 { - let key = StorageKey::InvestorPosition(inv_id.clone(), investor.clone()); - let amount: i128 = env.storage().persistent().get(&key).unwrap_or(0); - if env.storage().persistent().has(&key) { - bump_persistent(env, &key); - } - amount -} - -/// Set investor position for (invoice_id, investor). -pub fn set_investor_position(env: &Env, inv_id: &BytesN<32>, investor: &Address, amount: i128) { - let key = StorageKey::InvestorPosition(inv_id.clone(), investor.clone()); - if amount == 0 { - env.storage().persistent().remove(&key); - } else { - env.storage().persistent().set(&key, &amount); - bump_persistent(env, &key); - } -} - -/// Remove investor position for (invoice_id, investor). -pub fn remove_investor_position(env: &Env, inv_id: &BytesN<32>, investor: &Address) { - let key = StorageKey::InvestorPosition(inv_id.clone(), investor.clone()); - env.storage().persistent().remove(&key); -} - -/// Get the total count of escrows created (for pagination). -pub fn get_escrow_count(env: &soroban_sdk::Env) -> u32 { - env.storage() - .instance() - .get(&StorageKey::EscrowCount) - .unwrap_or(0) -} - -/// Increment the escrow count and return the new value. -pub fn increment_escrow_count(env: &soroban_sdk::Env) -> u32 { - let count = get_escrow_count(env); - let new_count = count + 1; - env.storage() - .instance() - .set(&StorageKey::EscrowCount, &new_count); - new_count -} - -/// Get the invoice_id at a specific index. -pub fn get_escrow_id_by_index(env: &soroban_sdk::Env, index: u32) -> Option { - env.storage() - .persistent() - .get(&StorageKey::EscrowIdByIndex(index)) -} - -/// Set the invoice_id at a specific index. -pub fn set_escrow_id_by_index(env: &soroban_sdk::Env, index: u32, invoice_id: &Symbol) { - env.storage() - .persistent() - .set(&StorageKey::EscrowIdByIndex(index), invoice_id); -} \ No newline at end of file +//! Storage helpers: instance for config, persistent for escrow data. + +use soroban_sdk::{Address, Symbol}; + +use crate::types::{Config, EmergencyApprovals, EscrowData, MultiSigConfig, StorageKey}; + +/// Ledgers below which a persistent entry's TTL is extended (~7 days at 5s/ledger). +const TTL_THRESHOLD: u32 = 120_960; +/// Ledgers to extend a persistent entry's TTL to when bumped (~30 days at 5s/ledger). +const TTL_EXTEND_TO: u32 = 518_400; + +/// Extend the TTL of an escrow's persistent storage entry so it survives +/// ledger pruning across the full lifetime of a (potentially long-lived, +/// e.g. multi-month) invoice, not just the archival minimum. +pub fn extend_ttl(env: &soroban_sdk::Env, inv_id: Symbol) { + env.storage().persistent().extend_ttl( + &StorageKey::Escrow(inv_id), + TTL_THRESHOLD, + TTL_EXTEND_TO, + ); +} + +/// Load contract config from instance storage. +pub fn get_config(env: &soroban_sdk::Env) -> Option { + env.storage().instance().get(&StorageKey::Config) +} + +/// Save contract config to instance storage. +pub fn set_config(env: &soroban_sdk::Env, config: &Config) { + env.storage().instance().set(&StorageKey::Config, config); +} + +/// Load escrow data for an invoice from persistent storage. +/// Extends the entry's TTL on every access so actively-used escrows never +/// expire mid-lifecycle regardless of how long between state transitions. +pub fn get_escrow(env: &soroban_sdk::Env, inv_id: Symbol) -> Option { + let data = env + .storage() + .persistent() + .get(&StorageKey::Escrow(inv_id.clone())); + if data.is_some() { + extend_ttl(env, inv_id); + } + data +} + +/// Save escrow data for an invoice to persistent storage, extending its TTL. +pub fn set_escrow(env: &soroban_sdk::Env, inv_id: Symbol, data: &EscrowData) { + env.storage() + .persistent() + .set(&StorageKey::Escrow(inv_id.clone()), data); + extend_ttl(env, inv_id); +} + +/// Check if an escrow exists for the given invoice. +pub fn has_escrow(env: &soroban_sdk::Env, inv_id: Symbol) -> bool { + env.storage().persistent().has(&StorageKey::Escrow(inv_id)) +} + +/// Remove escrow data and all per-funder contribution records for an invoice from +/// persistent storage (storage footprint cleanup). +pub fn remove_escrow_state( + env: &soroban_sdk::Env, + inv_id: Symbol, + funders: &soroban_sdk::Vec
, +) { + for funder in funders.iter() { + env.storage() + .persistent() + .remove(&StorageKey::FunderAmount(inv_id.clone(), funder)); + } + env.storage() + .persistent() + .remove(&StorageKey::Escrow(inv_id)); +} + +/// Get the highest nonce consumed so far for a buyer's signed off-chain approvals. +pub fn get_nonce(env: &soroban_sdk::Env, buyer: &soroban_sdk::Address) -> u64 { + env.storage() + .persistent() + .get(&StorageKey::Nonce(buyer.clone())) + .unwrap_or(0) +} + +/// Record the highest nonce consumed for a buyer's signed off-chain approvals. +pub fn set_nonce(env: &soroban_sdk::Env, buyer: &soroban_sdk::Address, nonce: u64) { + env.storage() + .persistent() + .set(&StorageKey::Nonce(buyer.clone()), &nonce); +} + +/// Get the amount funded by a specific funder for an invoice. +pub fn get_funder_amount( + env: &soroban_sdk::Env, + inv_id: Symbol, + funder: &soroban_sdk::Address, +) -> i128 { + env.storage() + .persistent() + .get(&StorageKey::FunderAmount(inv_id, funder.clone())) + .unwrap_or(0) +} + +/// Set the amount funded by a specific funder for an invoice. +pub fn set_funder_amount( + env: &soroban_sdk::Env, + inv_id: Symbol, + funder: &soroban_sdk::Address, + amount: i128, +) { + if amount == 0 { + env.storage() + .persistent() + .remove(&StorageKey::FunderAmount(inv_id, funder.clone())); + } else { + env.storage() + .persistent() + .set(&StorageKey::FunderAmount(inv_id, funder.clone()), &amount); + } +} + +/// Whether `buyer` is whitelisted to fund (buy) escrows. Absent entry = not whitelisted. +pub fn is_whitelisted(env: &soroban_sdk::Env, buyer: &Address) -> bool { + env.storage() + .persistent() + .get(&StorageKey::BuyerWhitelist(buyer.clone())) + .unwrap_or(false) +} + +/// Set (or clear) a buyer's whitelist status. +pub fn set_whitelisted(env: &soroban_sdk::Env, buyer: &Address, allowed: bool) { + if allowed { + env.storage() + .persistent() + .set(&StorageKey::BuyerWhitelist(buyer.clone()), &true); + } else { + env.storage() + .persistent() + .remove(&StorageKey::BuyerWhitelist(buyer.clone())); + } +} + +// ── Funding invoice (BytesN<32>) storage for position management ─────── + +use soroban_sdk::BytesN; + +use crate::types::FundingInvoice; + +pub fn get_invoice(env: &soroban_sdk::Env, invoice_id: BytesN<32>) -> Option { + env.storage() + .persistent() + .get(&StorageKey::Invoice(invoice_id)) +} + +pub fn set_invoice(env: &soroban_sdk::Env, invoice_id: BytesN<32>, invoice: &FundingInvoice) { + env.storage() + .persistent() + .set(&StorageKey::Invoice(invoice_id), invoice); +} + +pub fn has_invoice(env: &soroban_sdk::Env, invoice_id: BytesN<32>) -> bool { + env.storage() + .persistent() + .has(&StorageKey::Invoice(invoice_id)) +} + +pub fn get_investor_position( + env: &soroban_sdk::Env, + invoice_id: BytesN<32>, + investor: &Address, +) -> i128 { + env.storage() + .persistent() + .get(&StorageKey::InvestorPosition(invoice_id, investor.clone())) + .unwrap_or(0) +} + +pub fn set_investor_position( + env: &soroban_sdk::Env, + invoice_id: BytesN<32>, + investor: &Address, + amount: i128, +) { + let key = StorageKey::InvestorPosition(invoice_id, investor.clone()); + if amount == 0 { + env.storage().persistent().remove(&key); + } else { + env.storage().persistent().set(&key, &amount); + } + } + + /// Load the emergency multi-sig admin configuration. +pub fn get_emergency_config(env: &soroban_sdk::Env) -> Option { + env.storage().instance().get(&StorageKey::EmergencyConfig) +} + +/// Save the emergency multi-sig admin configuration. +pub fn set_emergency_config(env: &soroban_sdk::Env, config: &MultiSigConfig) { + env.storage() + .instance() + .set(&StorageKey::EmergencyConfig, config); +} + +/// Load the current emergency approvals for a given invoice. +pub fn get_emergency_approvals(env: &soroban_sdk::Env, inv_id: &Symbol) -> EmergencyApprovals { + env.storage() + .persistent() + .get(&StorageKey::EmergencyApprovals(inv_id.clone())) + .unwrap_or(EmergencyApprovals { + approvals: soroban_sdk::Vec::new(env), + }) +} + +/// Save emergency approvals for a given invoice. +pub fn set_emergency_approvals(env: &soroban_sdk::Env, inv_id: &Symbol, approvals: &EmergencyApprovals) { + env.storage() + .persistent() + .set(&StorageKey::EmergencyApprovals(inv_id.clone()), approvals); +} + +/// Get the total count of escrows created (for pagination). +pub fn get_escrow_count(env: &soroban_sdk::Env) -> u32 { + env.storage() + .instance() + .get(&StorageKey::EscrowCount) + .unwrap_or(0) +} + +/// Increment the escrow count and return the new value. +pub fn increment_escrow_count(env: &soroban_sdk::Env) -> u32 { + let count = get_escrow_count(env); + let new_count = count + 1; + env.storage() + .instance() + .set(&StorageKey::EscrowCount, &new_count); + new_count +} + +/// Get the invoice_id at a specific index. +pub fn get_escrow_id_by_index(env: &soroban_sdk::Env, index: u32) -> Option { + env.storage() + .persistent() + .get(&StorageKey::EscrowIdByIndex(index)) +} + +/// Get the count of unique investors for a given invoice. +pub fn get_investor_count(env: &soroban_sdk::Env, invoice_id: BytesN<32>) -> u32 { + env.storage() + .persistent() + .get(&StorageKey::InvestorCount(invoice_id)) + .unwrap_or(0) +} + +/// Increment the count of unique investors for a given invoice. +pub fn increment_investor_count(env: &soroban_sdk::Env, invoice_id: BytesN<32>) { + let count = get_investor_count(env, invoice_id.clone()); + env.storage() + .persistent() + .set(&StorageKey::InvestorCount(invoice_id), &(count + 1)); +} +//! Storage helpers: instance for config, persistent for escrow data, invoice records, and positions. + +use soroban_sdk::{Address, BytesN, Env, Symbol}; + +use crate::types::{ + Config, EmergencyApprovals, EscrowData, InvoiceData, MultiSigConfig, StorageKey, +}; + +/// Ledgers below which a persistent entry's TTL is extended (~7 days at 5s/ledger). +pub const TTL_THRESHOLD: u32 = 120_960; +/// Minimum TTL extension in ledger units (~60 days at 5s/ledger: 60 * 24 * 3600 / 5 = 1,036,800 ledgers). +pub const MIN_TTL_EXTEND: u32 = 1_036_800; + +/// Extend the TTL of any persistent storage entry to at least 60 days in ledger units. +pub fn bump_persistent(env: &Env, key: &StorageKey) { + env.storage() + .persistent() + .extend_ttl(key, TTL_THRESHOLD, MIN_TTL_EXTEND); +} + + +/// Load contract config from instance storage, bumping instance TTL. +pub fn get_config(env: &Env) -> Option { + env.storage() + .instance() + .extend_ttl(TTL_THRESHOLD, MIN_TTL_EXTEND); + env.storage().instance().get(&StorageKey::Config) +} + +/// Save contract config to instance storage, bumping instance TTL. +pub fn set_config(env: &Env, config: &Config) { + env.storage().instance().set(&StorageKey::Config, config); + env.storage() + .instance() + .extend_ttl(TTL_THRESHOLD, MIN_TTL_EXTEND); +} + +/// Load escrow data for an invoice from persistent storage. +/// Extends the entry's TTL on every access so actively-used escrows never +/// expire mid-lifecycle regardless of how long between state transitions. +pub fn get_escrow(env: &Env, inv_id: Symbol) -> Option { + let key = StorageKey::Escrow(inv_id.clone()); + let data = env.storage().persistent().get(&key); + if data.is_some() { + bump_persistent(env, &key); + } + data +} + +/// Save escrow data for an invoice to persistent storage, extending its TTL. +pub fn set_escrow(env: &Env, inv_id: Symbol, data: &EscrowData) { + let key = StorageKey::Escrow(inv_id.clone()); + env.storage().persistent().set(&key, data); + bump_persistent(env, &key); +} + +/// Check if an escrow exists for the given invoice. +pub fn has_escrow(env: &Env, inv_id: Symbol) -> bool { + let key = StorageKey::Escrow(inv_id); + let exists = env.storage().persistent().has(&key); + if exists { + bump_persistent(env, &key); + } + exists +} + +/// Remove escrow data and all per-funder contribution records for an invoice from +/// persistent storage (storage footprint cleanup). +pub fn remove_escrow_state( + env: &Env, + inv_id: Symbol, + funders: &soroban_sdk::Vec
, +) { + for funder in funders.iter() { + env.storage() + .persistent() + .remove(&StorageKey::FunderAmount(inv_id.clone(), funder)); + } + env.storage() + .persistent() + .remove(&StorageKey::Escrow(inv_id)); +} + +/// Get the highest nonce consumed so far for a buyer's signed off-chain approvals. +pub fn get_nonce(env: &Env, buyer: &Address) -> u64 { + let key = StorageKey::Nonce(buyer.clone()); + let nonce = env.storage().persistent().get(&key).unwrap_or(0); + if env.storage().persistent().has(&key) { + bump_persistent(env, &key); + } + nonce +} + +/// Record the highest nonce consumed for a buyer's signed off-chain approvals. +pub fn set_nonce(env: &Env, buyer: &Address, nonce: u64) { + let key = StorageKey::Nonce(buyer.clone()); + env.storage().persistent().set(&key, &nonce); + bump_persistent(env, &key); +} + +/// Get the amount funded by a specific funder for an invoice. +pub fn get_funder_amount( + env: &Env, + inv_id: Symbol, + funder: &Address, +) -> i128 { + let key = StorageKey::FunderAmount(inv_id, funder.clone()); + let amount = env.storage().persistent().get(&key).unwrap_or(0); + if env.storage().persistent().has(&key) { + bump_persistent(env, &key); + } + amount +} + +/// Set the amount funded by a specific funder for an invoice. +pub fn set_funder_amount( + env: &Env, + inv_id: Symbol, + funder: &Address, + amount: i128, +) { + let key = StorageKey::FunderAmount(inv_id, funder.clone()); + if amount == 0 { + env.storage().persistent().remove(&key); + } else { + env.storage().persistent().set(&key, &amount); + bump_persistent(env, &key); + } +} + +/// Whether `buyer` is whitelisted to fund (buy) escrows. Absent entry = not whitelisted. +pub fn is_whitelisted(env: &Env, buyer: &Address) -> bool { + let key = StorageKey::BuyerWhitelist(buyer.clone()); + let whitelisted = env.storage().persistent().get(&key).unwrap_or(false); + if env.storage().persistent().has(&key) { + bump_persistent(env, &key); + } + whitelisted +} + +/// Set (or clear) a buyer's whitelist status. +pub fn set_whitelisted(env: &Env, buyer: &Address, allowed: bool) { + let key = StorageKey::BuyerWhitelist(buyer.clone()); + if allowed { + env.storage().persistent().set(&key, &true); + bump_persistent(env, &key); + } else { + env.storage().persistent().remove(&key); + } +} + +// ── Funding invoice (BytesN<32>) storage for position management ─────── + +use soroban_sdk::BytesN; + +use crate::types::FundingInvoice; + +pub fn get_invoice(env: &soroban_sdk::Env, invoice_id: BytesN<32>) -> Option { + env.storage() + .persistent() + .get(&StorageKey::Invoice(invoice_id)) +} + +pub fn set_invoice(env: &soroban_sdk::Env, invoice_id: BytesN<32>, invoice: &FundingInvoice) { + env.storage() + .persistent() + .set(&StorageKey::Invoice(invoice_id), invoice); +} + +pub fn has_invoice(env: &soroban_sdk::Env, invoice_id: BytesN<32>) -> bool { + env.storage() + .persistent() + .has(&StorageKey::Invoice(invoice_id)) +} + +pub fn get_investor_position( + env: &soroban_sdk::Env, + invoice_id: BytesN<32>, + investor: &Address, +) -> i128 { + env.storage() + .persistent() + .get(&StorageKey::InvestorPosition(invoice_id, investor.clone())) + .unwrap_or(0) +} + +pub fn set_investor_position( + env: &soroban_sdk::Env, + invoice_id: BytesN<32>, + investor: &Address, + amount: i128, +) { + let key = StorageKey::InvestorPosition(invoice_id, investor.clone()); + if amount == 0 { + env.storage().persistent().remove(&key); + } else { + env.storage().persistent().set(&key, &amount); + } +/// Load the emergency multi-sig admin configuration. +pub fn get_emergency_config(env: &Env) -> Option { + env.storage() + .instance() + .extend_ttl(TTL_THRESHOLD, MIN_TTL_EXTEND); + env.storage().instance().get(&StorageKey::EmergencyConfig) +} + +/// Save the emergency multi-sig admin configuration. +pub fn set_emergency_config(env: &Env, config: &MultiSigConfig) { + env.storage() + .instance() + .set(&StorageKey::EmergencyConfig, config); + env.storage() + .instance() + .extend_ttl(TTL_THRESHOLD, MIN_TTL_EXTEND); +} + +/// Load the current emergency approvals for a given invoice. +pub fn get_emergency_approvals(env: &Env, inv_id: &Symbol) -> EmergencyApprovals { + let key = StorageKey::EmergencyApprovals(inv_id.clone()); + let approvals = env + .storage() + .persistent() + .get(&key) + .unwrap_or(EmergencyApprovals { + approvals: soroban_sdk::Vec::new(env), + }); + if env.storage().persistent().has(&key) { + bump_persistent(env, &key); + } + approvals +} + +/// Save emergency approvals for a given invoice. +pub fn set_emergency_approvals(env: &Env, inv_id: &Symbol, approvals: &EmergencyApprovals) { + let key = StorageKey::EmergencyApprovals(inv_id.clone()); + env.storage().persistent().set(&key, approvals); + bump_persistent(env, &key); +} + +/// Load invoice record by BytesN<32>. +pub fn get_invoice_record(env: &Env, inv_id: &BytesN<32>) -> Option { + let key = StorageKey::InvoiceRecord(inv_id.clone()); + let data: Option = env.storage().persistent().get(&key); + if data.is_some() { + bump_persistent(env, &key); + } + data +} + +/// Save invoice record by BytesN<32>, bumping its persistent TTL. +pub fn set_invoice_record(env: &Env, inv_id: &BytesN<32>, data: &InvoiceData) { + let key = StorageKey::InvoiceRecord(inv_id.clone()); + env.storage().persistent().set(&key, data); + bump_persistent(env, &key); +} + +/// Check if an invoice record exists for BytesN<32>. +pub fn has_invoice_record(env: &Env, inv_id: &BytesN<32>) -> bool { + let key = StorageKey::InvoiceRecord(inv_id.clone()); + let exists = env.storage().persistent().has(&key); + if exists { + bump_persistent(env, &key); + } + exists +} + +/// Get investor position for (invoice_id, investor). +pub fn get_investor_position(env: &Env, inv_id: &BytesN<32>, investor: &Address) -> i128 { + let key = StorageKey::InvestorPosition(inv_id.clone(), investor.clone()); + let amount: i128 = env.storage().persistent().get(&key).unwrap_or(0); + if env.storage().persistent().has(&key) { + bump_persistent(env, &key); + } + amount +} + +/// Set investor position for (invoice_id, investor). +pub fn set_investor_position(env: &Env, inv_id: &BytesN<32>, investor: &Address, amount: i128) { + let key = StorageKey::InvestorPosition(inv_id.clone(), investor.clone()); + if amount == 0 { + env.storage().persistent().remove(&key); + } else { + env.storage().persistent().set(&key, &amount); + bump_persistent(env, &key); + } +} + +/// Remove investor position for (invoice_id, investor). +pub fn remove_investor_position(env: &Env, inv_id: &BytesN<32>, investor: &Address) { + let key = StorageKey::InvestorPosition(inv_id.clone(), investor.clone()); + env.storage().persistent().remove(&key); +} + +/// Get the total count of escrows created (for pagination). +pub fn get_escrow_count(env: &soroban_sdk::Env) -> u32 { + env.storage() + .instance() + .get(&StorageKey::EscrowCount) + .unwrap_or(0) +} + +/// Increment the escrow count and return the new value. +pub fn increment_escrow_count(env: &soroban_sdk::Env) -> u32 { + let count = get_escrow_count(env); + let new_count = count + 1; + env.storage() + .instance() + .set(&StorageKey::EscrowCount, &new_count); + new_count +} + +/// Get the invoice_id at a specific index. +pub fn get_escrow_id_by_index(env: &soroban_sdk::Env, index: u32) -> Option { + env.storage() + .persistent() + .get(&StorageKey::EscrowIdByIndex(index)) +} + +/// Set the invoice_id at a specific index. +pub fn set_escrow_id_by_index(env: &soroban_sdk::Env, index: u32, invoice_id: &Symbol) { + env.storage() + .persistent() + .set(&StorageKey::EscrowIdByIndex(index), invoice_id); +} +//! Storage helpers: instance for config, persistent for escrow data, invoice records, and positions. + +use soroban_sdk::{Address, BytesN, Env, Symbol}; + +use crate::types::{ + Config, EmergencyApprovals, EscrowData, InvoiceData, MultiSigConfig, StorageKey, +}; + +/// Ledgers below which a persistent entry's TTL is extended (~7 days at 5s/ledger). +pub const TTL_THRESHOLD: u32 = 120_960; +/// Minimum TTL extension in ledger units (~60 days at 5s/ledger: 60 * 24 * 3600 / 5 = 1,036,800 ledgers). +pub const MIN_TTL_EXTEND: u32 = 1_036_800; + +/// Extend the TTL of any persistent storage entry to at least 60 days in ledger units. +pub fn bump_persistent(env: &Env, key: &StorageKey) { + env.storage() + .persistent() + .extend_ttl(key, TTL_THRESHOLD, MIN_TTL_EXTEND); +} + + +/// Load contract config from instance storage, bumping instance TTL. +pub fn get_config(env: &Env) -> Option { + env.storage() + .instance() + .extend_ttl(TTL_THRESHOLD, MIN_TTL_EXTEND); + env.storage().instance().get(&StorageKey::Config) +} + +/// Save contract config to instance storage, bumping instance TTL. +pub fn set_config(env: &Env, config: &Config) { + env.storage().instance().set(&StorageKey::Config, config); + env.storage() + .instance() + .extend_ttl(TTL_THRESHOLD, MIN_TTL_EXTEND); +} + +/// Load escrow data for an invoice from persistent storage. +/// Extends the entry's TTL on every access so actively-used escrows never +/// expire mid-lifecycle regardless of how long between state transitions. +pub fn get_escrow(env: &Env, inv_id: Symbol) -> Option { + let key = StorageKey::Escrow(inv_id.clone()); + let data = env.storage().persistent().get(&key); + if data.is_some() { + bump_persistent(env, &key); + } + data +} + +/// Save escrow data for an invoice to persistent storage, extending its TTL. +pub fn set_escrow(env: &Env, inv_id: Symbol, data: &EscrowData) { + let key = StorageKey::Escrow(inv_id.clone()); + env.storage().persistent().set(&key, data); + bump_persistent(env, &key); +} + +/// Check if an escrow exists for the given invoice. +pub fn has_escrow(env: &Env, inv_id: Symbol) -> bool { + let key = StorageKey::Escrow(inv_id); + let exists = env.storage().persistent().has(&key); + if exists { + bump_persistent(env, &key); + } + exists +} + +/// Remove escrow data and all per-funder contribution records for an invoice from +/// persistent storage (storage footprint cleanup). +pub fn remove_escrow_state( + env: &Env, + inv_id: Symbol, + funders: &soroban_sdk::Vec
, +) { + for funder in funders.iter() { + env.storage() + .persistent() + .remove(&StorageKey::FunderAmount(inv_id.clone(), funder)); + } + env.storage() + .persistent() + .remove(&StorageKey::Escrow(inv_id)); +} + +/// Get the highest nonce consumed so far for a buyer's signed off-chain approvals. +pub fn get_nonce(env: &Env, buyer: &Address) -> u64 { + let key = StorageKey::Nonce(buyer.clone()); + let nonce = env.storage().persistent().get(&key).unwrap_or(0); + if env.storage().persistent().has(&key) { + bump_persistent(env, &key); + } + nonce +} + +/// Record the highest nonce consumed for a buyer's signed off-chain approvals. +pub fn set_nonce(env: &Env, buyer: &Address, nonce: u64) { + let key = StorageKey::Nonce(buyer.clone()); + env.storage().persistent().set(&key, &nonce); + bump_persistent(env, &key); +} + +/// Get the amount funded by a specific funder for an invoice. +pub fn get_funder_amount( + env: &Env, + inv_id: Symbol, + funder: &Address, +) -> i128 { + let key = StorageKey::FunderAmount(inv_id, funder.clone()); + let amount = env.storage().persistent().get(&key).unwrap_or(0); + if env.storage().persistent().has(&key) { + bump_persistent(env, &key); + } + amount +} + +/// Set the amount funded by a specific funder for an invoice. +pub fn set_funder_amount( + env: &Env, + inv_id: Symbol, + funder: &Address, + amount: i128, +) { + let key = StorageKey::FunderAmount(inv_id, funder.clone()); + if amount == 0 { + env.storage().persistent().remove(&key); + } else { + env.storage().persistent().set(&key, &amount); + bump_persistent(env, &key); + } +} + +/// Whether `buyer` is whitelisted to fund (buy) escrows. Absent entry = not whitelisted. +pub fn is_whitelisted(env: &Env, buyer: &Address) -> bool { + let key = StorageKey::BuyerWhitelist(buyer.clone()); + let whitelisted = env.storage().persistent().get(&key).unwrap_or(false); + if env.storage().persistent().has(&key) { + bump_persistent(env, &key); + } + whitelisted +} + +/// Set (or clear) a buyer's whitelist status. +pub fn set_whitelisted(env: &Env, buyer: &Address, allowed: bool) { + let key = StorageKey::BuyerWhitelist(buyer.clone()); + if allowed { + env.storage().persistent().set(&key, &true); + bump_persistent(env, &key); + } else { + env.storage().persistent().remove(&key); + } +} + +// ?? Funding invoice (BytesN<32>) storage for position management ??????? + +use soroban_sdk::BytesN; + +use crate::types::FundingInvoice; + +pub fn get_invoice(env: &soroban_sdk::Env, invoice_id: BytesN<32>) -> Option { + env.storage() + .persistent() + .get(&StorageKey::Invoice(invoice_id)) +} + +pub fn set_invoice(env: &soroban_sdk::Env, invoice_id: BytesN<32>, invoice: &FundingInvoice) { + env.storage() + .persistent() + .set(&StorageKey::Invoice(invoice_id), invoice); +} + +pub fn has_invoice(env: &soroban_sdk::Env, invoice_id: BytesN<32>) -> bool { + env.storage() + .persistent() + .has(&StorageKey::Invoice(invoice_id)) +} + +pub fn get_investor_position( + env: &soroban_sdk::Env, + invoice_id: BytesN<32>, + investor: &Address, +) -> i128 { + env.storage() + .persistent() + .get(&StorageKey::InvestorPosition(invoice_id, investor.clone())) + .unwrap_or(0) +} + +pub fn set_investor_position( + env: &soroban_sdk::Env, + invoice_id: BytesN<32>, + investor: &Address, + amount: i128, +) { + let key = StorageKey::InvestorPosition(invoice_id, investor.clone()); + if amount == 0 { + env.storage().persistent().remove(&key); + } else { + env.storage().persistent().set(&key, &amount); + } +/// Load the emergency multi-sig admin configuration. +pub fn get_emergency_config(env: &Env) -> Option { + env.storage() + .instance() + .extend_ttl(TTL_THRESHOLD, MIN_TTL_EXTEND); + env.storage().instance().get(&StorageKey::EmergencyConfig) +} + +/// Save the emergency multi-sig admin configuration. +pub fn set_emergency_config(env: &Env, config: &MultiSigConfig) { + env.storage() + .instance() + .set(&StorageKey::EmergencyConfig, config); + env.storage() + .instance() + .extend_ttl(TTL_THRESHOLD, MIN_TTL_EXTEND); +} + +/// Load the current emergency approvals for a given invoice. +pub fn get_emergency_approvals(env: &Env, inv_id: &Symbol) -> EmergencyApprovals { + let key = StorageKey::EmergencyApprovals(inv_id.clone()); + let approvals = env + .storage() + .persistent() + .get(&key) + .unwrap_or(EmergencyApprovals { + approvals: soroban_sdk::Vec::new(env), + }); + if env.storage().persistent().has(&key) { + bump_persistent(env, &key); + } + approvals +} + +/// Save emergency approvals for a given invoice. +pub fn set_emergency_approvals(env: &Env, inv_id: &Symbol, approvals: &EmergencyApprovals) { + let key = StorageKey::EmergencyApprovals(inv_id.clone()); + env.storage().persistent().set(&key, approvals); + bump_persistent(env, &key); +} + +/// Load invoice record by BytesN<32>. +pub fn get_invoice_record(env: &Env, inv_id: &BytesN<32>) -> Option { + let key = StorageKey::InvoiceRecord(inv_id.clone()); + let data: Option = env.storage().persistent().get(&key); + if data.is_some() { + bump_persistent(env, &key); + } + data +} + +/// Save invoice record by BytesN<32>, bumping its persistent TTL. +pub fn set_invoice_record(env: &Env, inv_id: &BytesN<32>, data: &InvoiceData) { + let key = StorageKey::InvoiceRecord(inv_id.clone()); + env.storage().persistent().set(&key, data); + bump_persistent(env, &key); +} + +/// Check if an invoice record exists for BytesN<32>. +pub fn has_invoice_record(env: &Env, inv_id: &BytesN<32>) -> bool { + let key = StorageKey::InvoiceRecord(inv_id.clone()); + let exists = env.storage().persistent().has(&key); + if exists { + bump_persistent(env, &key); + } + exists +} + +/// Get investor position for (invoice_id, investor). +pub fn get_investor_position(env: &Env, inv_id: &BytesN<32>, investor: &Address) -> i128 { + let key = StorageKey::InvestorPosition(inv_id.clone(), investor.clone()); + let amount: i128 = env.storage().persistent().get(&key).unwrap_or(0); + if env.storage().persistent().has(&key) { + bump_persistent(env, &key); + } + amount +} + +/// Set investor position for (invoice_id, investor). +pub fn set_investor_position(env: &Env, inv_id: &BytesN<32>, investor: &Address, amount: i128) { + let key = StorageKey::InvestorPosition(inv_id.clone(), investor.clone()); + if amount == 0 { + env.storage().persistent().remove(&key); + } else { + env.storage().persistent().set(&key, &amount); + bump_persistent(env, &key); + } +} + +/// Remove investor position for (invoice_id, investor). +pub fn remove_investor_position(env: &Env, inv_id: &BytesN<32>, investor: &Address) { + let key = StorageKey::InvestorPosition(inv_id.clone(), investor.clone()); + env.storage().persistent().remove(&key); +} + +/// Get the total count of escrows created (for pagination). +pub fn get_escrow_count(env: &soroban_sdk::Env) -> u32 { + env.storage() + .instance() + .get(&StorageKey::EscrowCount) + .unwrap_or(0) +} + +/// Increment the escrow count and return the new value. +pub fn increment_escrow_count(env: &soroban_sdk::Env) -> u32 { + let count = get_escrow_count(env); + let new_count = count + 1; + env.storage() + .instance() + .set(&StorageKey::EscrowCount, &new_count); + new_count +} + +/// Get the invoice_id at a specific index. +pub fn get_escrow_id_by_index(env: &soroban_sdk::Env, index: u32) -> Option { + env.storage() + .persistent() + .get(&StorageKey::EscrowIdByIndex(index)) +} + +/// Set the invoice_id at a specific index. +pub fn set_escrow_id_by_index(env: &soroban_sdk::Env, index: u32, invoice_id: &Symbol) { + env.storage() + .persistent() + .set(&StorageKey::EscrowIdByIndex(index), invoice_id); +} diff --git a/contracts/invoice-escrow/src/types.rs b/contracts/invoice-escrow/src/types.rs index 8cd3362..aa6b330 100644 --- a/contracts/invoice-escrow/src/types.rs +++ b/contracts/invoice-escrow/src/types.rs @@ -1,209 +1,386 @@ -//! Data types for the invoice escrow contract. -//! All names respect Soroban's 10-character limit for contracttype. - -use soroban_sdk::contracttype; - -/// Storage key enum for instance and persistent storage. -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum StorageKey { - /// Instance: global config. - Config, - /// Persistent: escrow data by invoice id. - Escrow(soroban_sdk::Symbol), - /// Persistent: funder amounts by (invoice_id, funder_address). - FunderAmount(soroban_sdk::Symbol, soroban_sdk::Address), - /// Persistent: highest nonce consumed for a signed off-chain approval, by buyer address. - Nonce(soroban_sdk::Address), - /// Persistent: buyer whitelist flag by buyer address. - BuyerWhitelist(soroban_sdk::Address), - /// Persistent: funding invoice by BytesN<32> invoice id (new position management). - Invoice(soroban_sdk::BytesN<32>), - /// Persistent: investor position by (invoice_id BytesN<32>, investor address). - InvestorPosition(soroban_sdk::BytesN<32>, soroban_sdk::Address), - /// Instance: emergency multi-sig admin configuration. - EmergencyConfig, - /// Persistent: approvals collected for a given invoice's emergency release. - EmergencyApprovals(soroban_sdk::Symbol), - /// Instance: total count of escrows created for indexing. - EscrowCount, - /// Persistent: invoice_id indexed by sequential creation order. - EscrowIdByIndex(u32), - /// Persistent: invoice metadata and parameters by BytesN<32>. - InvoiceRecord(soroban_sdk::BytesN<32>), -} - -/// Registered invoice metadata and funding parameters stored in persistent storage. -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct InvoiceData { - /// Invoice identifier (32 bytes). - pub invoice_id: soroban_sdk::BytesN<32>, - /// Face value: total amount owed by debtor. - pub face_value: i128, - /// Funding target: total funding amount to raise. - pub funding_target: i128, - /// Yield rate in basis points (1 to 5000). - pub yield_bps: u32, - /// Funding deadline (ledger sequence). - pub deadline_ledger: u32, - /// Total amount raised so far from investors. - pub total_raised: i128, - /// Current lifecycle status. - pub status: EscrowStatus, - /// List of investor addresses. - pub investors: soroban_sdk::Vec, -} -} - -/// Global contract configuration. -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct Config { - /// Admin address (fee updates, platform recipient). - pub admin: soroban_sdk::Address, - /// Platform fee in basis points (e.g. 300 = 3%). - pub fee_bps: u32, - /// Optional payment distributor contract used for settlement/refund fan-out. - pub payment_distributor: Option, - /// Emergency pause flag for lifecycle-changing operations. - pub paused: bool, - /// When true, `fund_escrow` requires the buyer to be on the whitelist. - /// Defaults to false (opt-in) so existing deployments/tests are unaffected - /// until an admin explicitly enables it. - pub whitelist_enabled: bool, - /// Minimum investment amount (stroops) accepted by `fund_escrow`. - /// `0` disables the floor (only `amount > 0` is required). Completing the - /// remaining capacity below this floor is always allowed. - pub min_investment: i128, -} - -/// Lifecycle status of an escrow. -#[contracttype] -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -#[repr(u32)] -pub enum EscrowStatus { - /// Created, awaiting funding. - Created = 0, - /// Funded by investor. - Funded = 1, - /// Payment recorded and distributed. - Settled = 2, - /// Refunded to investor after due date. - Refunded = 3, - /// Cancelled by seller while in Created state (refunds partial funders if any). - /// Cancelled by seller while still in Created state and never funded - /// (locked out once any investor contribution has been received). - Cancelled = 4, -} - -/// Per-invoice escrow data stored in persistent storage. -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct EscrowData { - /// Invoice identifier (Symbol, ≤10 chars when used as key). - pub inv_id: soroban_sdk::Symbol, - /// Seller (invoice owner). - pub seller: soroban_sdk::Address, - /// Debtor (authorized payer of the invoice). - pub debtor: soroban_sdk::Address, - /// Face value: what the debtor owes (amount to be paid at settlement). - pub face_value: i128, - /// Purchase price: total amount to be funded by all investors (discount applied here). - pub purchase_price: i128, - /// Total amount funded so far by all investors. - pub funded_amt: i128, - /// Primary funder address (MVP: single funder for now). - pub funder: Option, - /// Every funder that has contributed to this escrow so cleanup can prune their - /// contribution records once the escrow reaches a terminal state. - pub funders: soroban_sdk::Vec, - /// Due date (ledger timestamp). - pub due_dt: u64, - /// Payment token contract address. - pub token: soroban_sdk::Address, - /// Invoice token contract address (ownership/claim). - pub inv_token: soroban_sdk::Address, - /// Amount already paid by payer. - pub paid_amt: i128, - /// Current status. - pub status: EscrowStatus, - /// Minimum chunk size required for each partial funding operation (except the final one). - pub funding_milestone: Option, - /// Commitment hash: immutable on-chain anchor for off-chain invoice data (PDF hash, ERP ID, etc.). - /// Set at creation, cannot be modified. SHA-256 hash (32 bytes). - pub commitment: soroban_sdk::BytesN<32>, - /// Optional early-settlement discount hook. If present, payments made before - /// `cutoff_date` receive a reduced effective face value. - pub early_settlement: Option, -} - -/// Status for BytesN<32> funding invoices (position management). -#[contracttype] -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -#[repr(u32)] -pub enum InvoiceStatus { - Open = 0, - Funded = 1, -} - -/// Funding invoice for secondary market position management (BytesN<32> invoices). -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct FundingInvoice { - /// Seller who will receive funds on finalisation. - pub seller: soroban_sdk::Address, - /// Total funding target. - pub funding_target: i128, - /// Total raised so far across all investors. - pub total_raised: i128, - /// Ledger deadline for funding (partial_refund only before this). - pub deadline_ledger: u32, - /// Minimum investment floor for remaining position after partial refund. - pub min_investment: i128, - /// Optional per-investor cap applied on top_up. - pub per_investor_cap: Option, - /// Current status. - pub status: InvoiceStatus, - /// Payment token contract address. - pub token: soroban_sdk::Address, -} -/// Multi-signature configuration for emergency releases. -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct MultiSigConfig { - /// Set of admin addresses authorized to approve emergency releases. - pub admins: soroban_sdk::Vec, - /// Number of approvals required to trigger the emergency release (N-of-M). - pub threshold: u32, -} - -/// Tracks which admins have approved an emergency release for a given invoice. -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct EmergencyApprovals { - /// List of admin addresses that have already approved this release. - pub approvals: soroban_sdk::Vec, -} - -/// Optional early-settlement discount hook configuration. -/// -/// If set on an escrow, any `record_payment` whose ledger timestamp is strictly -/// before `cutoff_date` will have the effective face value reduced by -/// `discount_bps` basis points (e.g. 200 = 2%). The discounted effective face -/// value is floored to `1` so the escrow can always be settled. -/// -/// The discount applies uniformly across all partial payments made before the -/// cutoff; payments made on or after the cutoff are settled at the original -/// `face_value`. -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct EarlySettlementConfig { - /// Discount in basis points applied to `face_value` when payment is early. - /// Must be in [1, 9999] — a 0-bps discount is a no-op; 10000 bps (100%) is - /// rejected to prevent the effective face value from collapsing to 0. - pub discount_bps: u32, - /// Ledger timestamp deadline (exclusive): payment is "early" iff - /// `current_timestamp < cutoff_date`. - pub cutoff_date: u64, -} +//! Data types for the invoice escrow contract. +//! All names respect Soroban's 10-character limit for contracttype. + +use soroban_sdk::contracttype; + +/// Storage key enum for instance and persistent storage. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum StorageKey { + /// Instance: global config. + Config, + /// Persistent: escrow data by invoice id. + Escrow(soroban_sdk::Symbol), + /// Persistent: funder amounts by (invoice_id, funder_address). + FunderAmount(soroban_sdk::Symbol, soroban_sdk::Address), + /// Persistent: highest nonce consumed for a signed off-chain approval, by buyer address. + Nonce(soroban_sdk::Address), + /// Persistent: buyer whitelist flag by buyer address. + BuyerWhitelist(soroban_sdk::Address), + /// Persistent: funding invoice by BytesN<32> invoice id (new position management). + Invoice(soroban_sdk::BytesN<32>), + /// Persistent: investor position by (invoice_id BytesN<32>, investor address). + InvestorPosition(soroban_sdk::BytesN<32>, soroban_sdk::Address), + /// Instance: emergency multi-sig admin configuration. + EmergencyConfig, + /// Persistent: approvals collected for a given invoice's emergency release. + EmergencyApprovals(soroban_sdk::Symbol), + /// Instance: total count of escrows created for indexing. + EscrowCount, + /// Persistent: invoice_id indexed by sequential creation order. + EscrowIdByIndex(u32), + /// Persistent: investor count per invoice. + InvestorCount(soroban_sdk::BytesN<32>), +} + +/// Global contract configuration. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Config { + /// Admin address (fee updates, platform recipient). + pub admin: soroban_sdk::Address, + /// Platform fee in basis points (e.g. 300 = 3%). + pub fee_bps: u32, + /// Optional payment distributor contract used for settlement/refund fan-out. + pub payment_distributor: Option, + /// Emergency pause flag for lifecycle-changing operations. + pub paused: bool, + /// When true, `fund_escrow` requires the buyer to be on the whitelist. + /// Defaults to false (opt-in) so existing deployments/tests are unaffected + /// until an admin explicitly enables it. + pub whitelist_enabled: bool, + /// Minimum investment amount (stroops) accepted by `fund_escrow`. + /// `0` disables the floor (only `amount > 0` is required). Completing the + /// remaining capacity below this floor is always allowed. + pub min_investment: i128, + /// Maximum number of investors allowed per invoice. + pub max_investors: u32, + /// Fee (basis points) deducted from repayment before investor distribution. + pub settlement_fee_bps: u32, + /// Address that receives settlement fees. + pub treasury_address: soroban_sdk::Address, +} + +/// Helper for tracking investor count per invoice. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InvestorCount { + pub count: u32, +} + +/// Lifecycle status of an escrow. +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum EscrowStatus { + /// Created, awaiting funding. + Created = 0, + /// Funded by investor. + Funded = 1, + /// Payment recorded and distributed. + Settled = 2, + /// Refunded to investor after due date. + Refunded = 3, + /// Cancelled by seller while in Created state (refunds partial funders if any). + /// Cancelled by seller while still in Created state and never funded + /// (locked out once any investor contribution has been received). + Cancelled = 4, +} + +/// Per-invoice escrow data stored in persistent storage. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EscrowData { + /// Invoice identifier (Symbol, ≤10 chars when used as key). + pub inv_id: soroban_sdk::Symbol, + /// Seller (invoice owner). + pub seller: soroban_sdk::Address, + /// Debtor (authorized payer of the invoice). + pub debtor: soroban_sdk::Address, + /// Face value: what the debtor owes (amount to be paid at settlement). + pub face_value: i128, + /// Purchase price: total amount to be funded by all investors (discount applied here). + pub purchase_price: i128, + /// Total amount funded so far by all investors. + pub funded_amt: i128, + /// Primary funder address (MVP: single funder for now). + pub funder: Option, + /// Every funder that has contributed to this escrow so cleanup can prune their + /// contribution records once the escrow reaches a terminal state. + pub funders: soroban_sdk::Vec, + /// Due date (ledger timestamp). + pub due_dt: u64, + /// Payment token contract address. + pub token: soroban_sdk::Address, + /// Invoice token contract address (ownership/claim). + pub inv_token: soroban_sdk::Address, + /// Amount already paid by payer. + pub paid_amt: i128, + /// Current status. + pub status: EscrowStatus, + /// Minimum chunk size required for each partial funding operation (except the final one). + pub funding_milestone: Option, + /// Commitment hash: immutable on-chain anchor for off-chain invoice data (PDF hash, ERP ID, etc.). + /// Set at creation, cannot be modified. SHA-256 hash (32 bytes). + pub commitment: soroban_sdk::BytesN<32>, +} + +/// Status for BytesN<32> funding invoices (position management). +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum InvoiceStatus { + Open = 0, + Funded = 1, + Settled = 2, + Cancelled = 3, +} + +/// Funding invoice for secondary market position management (BytesN<32> invoices). +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FundingInvoice { + /// Seller who will receive funds on finalisation. + pub seller: soroban_sdk::Address, + /// Total funding target. + pub funding_target: i128, + /// Total raised so far across all investors. + pub total_raised: i128, + /// Ledger deadline for funding (partial_refund only before this). + pub deadline_ledger: u32, + /// Minimum investment floor for remaining position after partial refund. + pub min_investment: i128, + /// Optional per-investor cap applied on top_up. + pub per_investor_cap: Option, + /// Current status. + pub status: InvoiceStatus, + /// Payment token contract address. + pub token: soroban_sdk::Address, +} + +/// Multi-signature configuration for emergency releases. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MultiSigConfig { + /// Set of admin addresses authorized to approve emergency releases. + pub admins: soroban_sdk::Vec, + /// Number of approvals required to trigger the emergency release (N-of-M). + pub threshold: u32, +} + +/// Tracks which admins have approved an emergency release for a given invoice. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EmergencyApprovals { + /// List of admin addresses that have already approved this release. + pub approvals: soroban_sdk::Vec, +} +//! Data types for the invoice escrow contract. +//! All names respect Soroban's 10-character limit for contracttype. + +use soroban_sdk::contracttype; + +/// Storage key enum for instance and persistent storage. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum StorageKey { + /// Instance: global config. + Config, + /// Persistent: escrow data by invoice id. + Escrow(soroban_sdk::Symbol), + /// Persistent: funder amounts by (invoice_id, funder_address). + FunderAmount(soroban_sdk::Symbol, soroban_sdk::Address), + /// Persistent: highest nonce consumed for a signed off-chain approval, by buyer address. + Nonce(soroban_sdk::Address), + /// Persistent: buyer whitelist flag by buyer address. + BuyerWhitelist(soroban_sdk::Address), + /// Persistent: funding invoice by BytesN<32> invoice id (new position management). + Invoice(soroban_sdk::BytesN<32>), + /// Persistent: investor position by (invoice_id BytesN<32>, investor address). + InvestorPosition(soroban_sdk::BytesN<32>, soroban_sdk::Address), + /// Instance: emergency multi-sig admin configuration. + EmergencyConfig, + /// Persistent: approvals collected for a given invoice's emergency release. + EmergencyApprovals(soroban_sdk::Symbol), + /// Instance: total count of escrows created for indexing. + EscrowCount, + /// Persistent: invoice_id indexed by sequential creation order. + EscrowIdByIndex(u32), + /// Persistent: invoice metadata and parameters by BytesN<32>. + InvoiceRecord(soroban_sdk::BytesN<32>), +} + +/// Registered invoice metadata and funding parameters stored in persistent storage. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InvoiceData { + /// Invoice identifier (32 bytes). + pub invoice_id: soroban_sdk::BytesN<32>, + /// Face value: total amount owed by debtor. + pub face_value: i128, + /// Funding target: total funding amount to raise. + pub funding_target: i128, + /// Yield rate in basis points (1 to 5000). + pub yield_bps: u32, + /// Funding deadline (ledger sequence). + pub deadline_ledger: u32, + /// Total amount raised so far from investors. + pub total_raised: i128, + /// Current lifecycle status. + pub status: EscrowStatus, + /// List of investor addresses. + pub investors: soroban_sdk::Vec, +} +} + +/// Global contract configuration. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Config { + /// Admin address (fee updates, platform recipient). + pub admin: soroban_sdk::Address, + /// Platform fee in basis points (e.g. 300 = 3%). + pub fee_bps: u32, + /// Optional payment distributor contract used for settlement/refund fan-out. + pub payment_distributor: Option, + /// Emergency pause flag for lifecycle-changing operations. + pub paused: bool, + /// When true, `fund_escrow` requires the buyer to be on the whitelist. + /// Defaults to false (opt-in) so existing deployments/tests are unaffected + /// until an admin explicitly enables it. + pub whitelist_enabled: bool, + /// Minimum investment amount (stroops) accepted by `fund_escrow`. + /// `0` disables the floor (only `amount > 0` is required). Completing the + /// remaining capacity below this floor is always allowed. + pub min_investment: i128, +} + +/// Lifecycle status of an escrow. +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum EscrowStatus { + /// Created, awaiting funding. + Created = 0, + /// Funded by investor. + Funded = 1, + /// Payment recorded and distributed. + Settled = 2, + /// Refunded to investor after due date. + Refunded = 3, + /// Cancelled by seller while in Created state (refunds partial funders if any). + /// Cancelled by seller while still in Created state and never funded + /// (locked out once any investor contribution has been received). + Cancelled = 4, +} + +/// Per-invoice escrow data stored in persistent storage. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EscrowData { + /// Invoice identifier (Symbol, ≤10 chars when used as key). + pub inv_id: soroban_sdk::Symbol, + /// Seller (invoice owner). + pub seller: soroban_sdk::Address, + /// Debtor (authorized payer of the invoice). + pub debtor: soroban_sdk::Address, + /// Face value: what the debtor owes (amount to be paid at settlement). + pub face_value: i128, + /// Purchase price: total amount to be funded by all investors (discount applied here). + pub purchase_price: i128, + /// Total amount funded so far by all investors. + pub funded_amt: i128, + /// Primary funder address (MVP: single funder for now). + pub funder: Option, + /// Every funder that has contributed to this escrow so cleanup can prune their + /// contribution records once the escrow reaches a terminal state. + pub funders: soroban_sdk::Vec, + /// Due date (ledger timestamp). + pub due_dt: u64, + /// Payment token contract address. + pub token: soroban_sdk::Address, + /// Invoice token contract address (ownership/claim). + pub inv_token: soroban_sdk::Address, + /// Amount already paid by payer. + pub paid_amt: i128, + /// Current status. + pub status: EscrowStatus, + /// Minimum chunk size required for each partial funding operation (except the final one). + pub funding_milestone: Option, + /// Commitment hash: immutable on-chain anchor for off-chain invoice data (PDF hash, ERP ID, etc.). + /// Set at creation, cannot be modified. SHA-256 hash (32 bytes). + pub commitment: soroban_sdk::BytesN<32>, + /// Optional early-settlement discount hook. If present, payments made before + /// `cutoff_date` receive a reduced effective face value. + pub early_settlement: Option, +} + +/// Status for BytesN<32> funding invoices (position management). +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum InvoiceStatus { + Open = 0, + Funded = 1, +} + +/// Funding invoice for secondary market position management (BytesN<32> invoices). +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FundingInvoice { + /// Seller who will receive funds on finalisation. + pub seller: soroban_sdk::Address, + /// Total funding target. + pub funding_target: i128, + /// Total raised so far across all investors. + pub total_raised: i128, + /// Ledger deadline for funding (partial_refund only before this). + pub deadline_ledger: u32, + /// Minimum investment floor for remaining position after partial refund. + pub min_investment: i128, + /// Optional per-investor cap applied on top_up. + pub per_investor_cap: Option, + /// Current status. + pub status: InvoiceStatus, + /// Payment token contract address. + pub token: soroban_sdk::Address, +} +/// Multi-signature configuration for emergency releases. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MultiSigConfig { + /// Set of admin addresses authorized to approve emergency releases. + pub admins: soroban_sdk::Vec, + /// Number of approvals required to trigger the emergency release (N-of-M). + pub threshold: u32, +} + +/// Tracks which admins have approved an emergency release for a given invoice. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EmergencyApprovals { + /// List of admin addresses that have already approved this release. + pub approvals: soroban_sdk::Vec, +} + +/// Optional early-settlement discount hook configuration. +/// +/// If set on an escrow, any `record_payment` whose ledger timestamp is strictly +/// before `cutoff_date` will have the effective face value reduced by +/// `discount_bps` basis points (e.g. 200 = 2%). The discounted effective face +/// value is floored to `1` so the escrow can always be settled. +/// +/// The discount applies uniformly across all partial payments made before the +/// cutoff; payments made on or after the cutoff are settled at the original +/// `face_value`. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EarlySettlementConfig { + /// Discount in basis points applied to `face_value` when payment is early. + /// Must be in [1, 9999] — a 0-bps discount is a no-op; 10000 bps (100%) is + /// rejected to prevent the effective face value from collapsing to 0. + pub discount_bps: u32, + /// Ledger timestamp deadline (exclusive): payment is "early" iff + /// `current_timestamp < cutoff_date`. + pub cutoff_date: u64, +}