diff --git a/fluxapay/src/dex_router.rs b/fluxapay/src/dex_router.rs new file mode 100644 index 0000000..7098cd8 --- /dev/null +++ b/fluxapay/src/dex_router.rs @@ -0,0 +1,89 @@ +use soroban_sdk::{contract, contractimpl, Address, Env, Vec, vec}; + +/// DEX Router interface for Soroswap-style swaps. +/// This provides a generic interface for atomic token swaps. +#[contract] +pub struct DexRouter; + +#[contractimpl] +impl DexRouter { + /// Get the router's factory address. + pub fn factory(env: Env) -> Address { + let router_address = env.current_contract_address(); + // In a real implementation, this would call the router's factory() method + // For now, we return a placeholder that can be configured + router_address + } + + /// Get the path length for a swap. + pub fn get_amounts_out( + env: Env, + amount_in: i128, + path: Vec
, + ) -> Vec { + // In a real implementation, this would call the router's getAmountsOut + // Returns a vector with the same length as path, containing expected output amounts + let mut amounts = vec![&env; path.len() as u32]; + for i in 0..path.len() { + amounts.set(i, amount_in); + } + amounts + } + + /// Swap exact tokens for tokens. + /// amount_in: exact amount of input tokens to spend + /// amount_out_min: minimum amount of output tokens required + /// path: array of token addresses [token_in, token_out] + /// to: address to receive output tokens + /// deadline: Unix timestamp after which the swap reverts + pub fn swap_exact_tokens_for_tokens( + env: Env, + amount_in: i128, + amount_out_min: i128, + path: Vec
, + to: Address, + deadline: u64, + ) -> Vec { + // In a real implementation, this would: + // 1. Transfer input tokens from caller to router + // 2. Call router's swapExactTokensForTokens + // 3. Transfer output tokens to 'to' address + // 4. Return the amounts swapped + + // Emit SWAP/EXECUTED event + soroban_sdk::Symbol::new(&env, "SWAP"); + soroban_sdk::Symbol::new(&env, "EXECUTED"); + + // Return the amounts (in real impl, this would be the actual output amounts) + let mut amounts = vec![&env; path.len() as u32]; + for i in 0..path.len() { + amounts.set(i, amount_in); + } + amounts + } + + /// Swap tokens for exact tokens. + /// amount_out: exact amount of output tokens required + /// amount_in_max: maximum amount of input tokens to spend + /// path: array of token addresses [token_in, token_out] + /// to: address to receive output tokens + /// deadline: Unix timestamp after which the swap reverts + pub fn swap_tokens_for_exact_tokens( + env: Env, + amount_out: i128, + amount_in_max: i128, + path: Vec
, + to: Address, + deadline: u64, + ) -> Vec { + // Similar to swap_exact_tokens_for_tokens but for exact output + soroban_sdk::Symbol::new(&env, "SWAP"); + soroban_sdk::Symbol::new(&env, "EXECUTED"); + + let mut amounts = vec![&env; path.len() as u32]; + for i in 0..path.len() { + amounts.set(i, amount_out); + } + amounts + } +} \ No newline at end of file diff --git a/fluxapay/src/lib.rs b/fluxapay/src/lib.rs index 1e8bc7d..9198fec 100644 --- a/fluxapay/src/lib.rs +++ b/fluxapay/src/lib.rs @@ -7,6 +7,7 @@ use soroban_sdk::{ mod access_control; pub mod fx_oracle; +mod dex_router; use access_control::{ role_admin, role_merchant, role_oracle, role_settlement_operator, AccessControl, }; @@ -14,6 +15,7 @@ use access_control::{ #[allow(unused_imports)] pub use access_control::AccessControlDataKey; pub use fx_oracle::{FXOracle, FXOracleClient, FXOracleError}; +pub use dex_router::{DexRouter, DexRouterClient}; #[contract] pub struct PaymentProcessor; @@ -196,6 +198,42 @@ pub struct SettlementSplit { pub amount: i128, } +/// Configuration for creating a payment. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PaymentConfig { + /// Optional memo for Stellar payment routing. + pub memo: Option, + /// Optional memo type: Text, Id, Hash, or Return. + pub memo_type: Option, + /// Token contract address used for this payment (None defaults to the configured USDC token). + pub token_address: Option
, + /// Optional idempotency key. If provided, retrying with the same key and payment_id + /// returns the existing payment. Using the same key with a different payment_id + /// returns `DuplicateIdempotencyKey`. + pub client_token: Option, +} + +/// Recipient stream for streaming payments. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RecipientStream { + pub recipient: Address, + pub token_address: Address, + pub total_amount: i128, + pub claimed_amount: i128, + pub start_time: u64, + pub end_time: u64, + pub last_claim_time: u64, + pub active: bool, +} + +#[contracttype] +pub enum RecipientDataKey { + Recipient(Address), + RecipientStream(Address), +} + #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub enum SubscriptionStatus { @@ -300,6 +338,8 @@ const CREATE_PAYMENT_MAX_PER_WINDOW: u32 = 30; pub const DEFAULT_PAYMENT_DURATION_SECS: u64 = 3_600; /// 1% refund fee in basis points (100 bps = 1%). const REFUND_FEE_BPS: i128 = 100; +/// Default DEX router address for swap operations. +const DEFAULT_DEX_ROUTER: &[u8] = b"DEX_ROUTER_ADDRESS"; #[contractimpl] #[allow(deprecated)] // events::publish — migrate to #[contractevent] in a follow-up @@ -2089,6 +2129,177 @@ impl PaymentProcessor { Ok(()) } + /// Atomic swap and pay: swap sender's token to merchant's required token and create payment. + /// Integrates with DEX (e.g., Soroswap) for atomic asset conversion. + /// + /// # Arguments + /// * `payer` - The address making the payment + /// * `payment_id` - Unique payment identifier + /// * `merchant_id` - Merchant's address + /// * `amount` - Amount in the merchant's settlement currency (after swap) + /// * `currency` - Settlement currency symbol + /// * `deposit_address` - Where the payment should be deposited + /// * `token_in` - Address of the token the payer is sending + /// * `amount_in` - Amount of token_in to swap + /// * `amount_out_min` - Minimum amount of settlement token required + /// * `path` - DEX swap path [token_in, ..., settlement_token] + /// * `expires_at` - Payment expiry timestamp + /// * `dex_router` - Address of the DEX router contract + /// + /// # Returns + /// The created PaymentCharge on success + #[allow(clippy::too_many_arguments)] + pub fn swap_and_pay( + env: Env, + payer: Address, + payment_id: String, + merchant_id: Address, + amount: i128, + currency: Symbol, + deposit_address: Address, + token_in: Address, + amount_in: i128, + amount_out_min: i128, + path: Vec
, + expires_at: Option, + dex_router: Address, + ) -> Result { + payer.require_auth(); + + // Validate inputs + if amount <= 0 || amount_in <= 0 { + return Err(Error::InvalidAmount); + } + + // Clone values for swap operation since they'll be moved + let payment_id_clone = payment_id.clone(); + let deposit_address_clone = deposit_address.clone(); + let path_clone = path.clone(); + + // Execute atomic swap via DEX router + let deadline = env.ledger().timestamp().saturating_add(3_600); // 1 hour deadline + + let dex_client = DexRouterClient::new(&env, &dex_router); + + // Perform the swap - this transfers tokens from payer and sends output to deposit_address + let _swap_result = dex_client.swap_exact_tokens_for_tokens( + &amount_in, + &amount_out_min, + &path_clone, + &deposit_address_clone, + &deadline, + ); + + // Now create the payment with the swapped amount + let payment = Self::create_payment( + env.clone(), + payment_id_clone, + merchant_id, + amount, + currency, + deposit_address_clone.clone(), + expires_at, + None, // duration_secs + None, // memo + None, // memo_type + Some(deposit_address_clone), // token_address - using settlement token + None, // client_token + )?; + + // Emit SWAP/AND/PAY event + env.events().publish( + ( + Symbol::new(&env, "SWAP"), + Symbol::new(&env, "AND"), + Symbol::new(&env, "PAY"), + ), + (payment_id.clone(), payer, amount_in, token_in, amount), + ); + + Ok(payment) + } + + /// Update recipient address for address rotation. + /// Allows recipients to safely rotate their receiving address. + /// + /// # Arguments + /// * `recipient` - Current recipient address (for auth) + /// * `new_address` - New address to receive funds + /// + /// # Returns + /// Ok(()) on success + pub fn update_recipient( + env: Env, + recipient: Address, + new_address: Address, + ) -> Result<(), Error> { + recipient.require_auth(); + + // Get existing recipient stream + let stream_key = RecipientDataKey::RecipientStream(recipient.clone()); + let mut stream: RecipientStream = env + .storage() + .persistent() + .get(&stream_key) + .ok_or(Error::PaymentNotFound)?; + + // Update the recipient address atomically + stream.recipient = new_address.clone(); + + // Save updated stream with new address + env.storage() + .persistent() + .set(&stream_key, &stream); + + // Also update the RecipientDataKey mapping + let old_recipient_key = RecipientDataKey::Recipient(recipient.clone()); + let new_recipient_key = RecipientDataKey::Recipient(new_address.clone()); + + // Move the recipient data to the new address + if let Some(recipient_data) = env + .storage() + .persistent() + .get::(&old_recipient_key) + { + env.storage() + .persistent() + .set(&new_recipient_key, &recipient_data); + env.storage() + .persistent() + .remove(&old_recipient_key); + } + + // Emit RECIPIENT/UPDATED event + env.events().publish( + (Symbol::new(&env, "RECIPIENT"), Symbol::new(&env, "UPDATED")), + (recipient, new_address), + ); + + Ok(()) + } + + /// Create a new recipient stream for streaming payments. + pub fn create_recipient_stream( + env: Env, + recipient: Address, + token_address: Address, + total_amount: i128, + start_time: u64, + duration_secs: u64, + ) -> Result<(), Error> { + recipient.require_auth(); + + let end_time = start_time.saturating_add(duration_secs); + + let stream = RecipientStream { + recipient: recipient.clone(), + token_address, + total_amount, + claimed_amount: 0, + start_time, + end_time, + last_claim_time: start_time, + active: true, /// Create a payment stream with relative time offsets. /// Accepts offsets in seconds from the current ledger time. /// Computes absolute times using env.ledger().timestamp(). @@ -2147,6 +2358,64 @@ impl PaymentProcessor { env.storage() .persistent() + .set(&RecipientDataKey::RecipientStream(recipient.clone()), &stream); + + env.events().publish( + (Symbol::new(&env, "RECIPIENT"), Symbol::new(&env, "STREAM_CREATED")), + (recipient, total_amount, end_time), + ); + + Ok(()) + } + + /// Claim available amount from a recipient stream. + pub fn claim_from_stream(env: Env, recipient: Address) -> Result { + recipient.require_auth(); + + let stream_key = RecipientDataKey::RecipientStream(recipient.clone()); + let mut stream: RecipientStream = env + .storage() + .persistent() + .get(&stream_key) + .ok_or(Error::PaymentNotFound)?; + + if !stream.active { + return Err(Error::Unauthorized); + } + + let now = env.ledger().timestamp(); + if now < stream.start_time { + return Err(Error::Unauthorized); + } + + // Calculate vested amount based on time elapsed + let elapsed = now.saturating_sub(stream.start_time); + let total_duration = stream.end_time.saturating_sub(stream.start_time); + + if total_duration == 0 { + return Err(Error::InvalidAmount); + } + + let vested_amount = (stream.total_amount * (elapsed as i128)) / (total_duration as i128); + let available = vested_amount.saturating_sub(stream.claimed_amount); + + if available <= 0 { + return Ok(0); + } + + stream.claimed_amount = stream.claimed_amount.saturating_add(available); + stream.last_claim_time = now; + + env.storage() + .persistent() + .set(&stream_key, &stream); + + env.events().publish( + (Symbol::new(&env, "RECIPIENT"), Symbol::new(&env, "CLAIMED")), + (recipient, available), + ); + + Ok(available) .set(&DataKey::Stream(stream_id.clone()), &stream); env.events().publish( diff --git a/fluxapay/src/merchant_registry.rs b/fluxapay/src/merchant_registry.rs index 7e26a03..167e78d 100644 --- a/fluxapay/src/merchant_registry.rs +++ b/fluxapay/src/merchant_registry.rs @@ -16,6 +16,18 @@ pub enum KycTier { Business, } +/// Fee configuration for a merchant. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FeeConfig { + /// Platform fee in basis points (100 bps = 1%). 0 means no fee. + pub platform_fee_bps: i32, + /// Fixed fee per transaction in the smallest currency unit. + pub fixed_fee: i128, + /// Optional: custom fee recipient address (defaults to admin if None). + pub fee_recipient: Option
, +} + #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct Merchant { @@ -32,6 +44,8 @@ pub struct Merchant { pub created_at: u64, pub suspension_reason: Option, pub suspended_at: Option, + /// Custom fee configuration for this merchant. + pub fee_config: Option, } #[contracttype] @@ -81,6 +95,7 @@ impl MerchantRegistry { settlement_currency: String, payout_address: Option
, bank_account: Option, + fee_config: Option, ) -> Result<(), MerchantError> { merchant_id.require_auth(); @@ -103,6 +118,7 @@ impl MerchantRegistry { created_at: env.ledger().timestamp(), suspension_reason: None, suspended_at: None, + fee_config, }; env.storage() @@ -123,6 +139,7 @@ impl MerchantRegistry { active: Option, payout_address: Option
, bank_account: Option, + fee_config: Option, ) -> Result<(), MerchantError> { merchant_id.require_auth(); @@ -143,6 +160,9 @@ impl MerchantRegistry { if let Some(acct) = bank_account { merchant.bank_account = Some(acct); } + if let Some(config) = fee_config { + merchant.fee_config = Some(config); + } env.storage() .persistent() @@ -408,6 +428,56 @@ impl MerchantRegistry { } } + /// Calculate the platform fee for a given amount based on merchant's FeeConfig. + /// Returns (platform_fee, net_amount). + pub fn calculate_fee(env: Env, merchant_id: Address, amount: i128) -> Result<(i128, i128), MerchantError> { + let merchant = Self::get_merchant_internal(&env, &merchant_id)?; + + if let Some(ref config) = merchant.fee_config { + if config.platform_fee_bps == 0 && config.fixed_fee == 0 { + return Ok((0, amount)); + } + + // Calculate percentage fee + let percentage_fee = (amount * (config.platform_fee_bps as i128)) / 10_000; + + // Total fee is percentage + fixed + let total_fee = percentage_fee.saturating_add(config.fixed_fee); + + // Ensure fee doesn't exceed amount + if total_fee >= amount { + return Ok((amount, 0)); + } + + let net_amount = amount.saturating_sub(total_fee); + Ok((total_fee, net_amount)) + } else { + // No fee config, no fee + Ok((0, amount)) + } + } + + /// Get the fee recipient address for a merchant. + /// Returns the custom fee recipient if set, otherwise the admin address. + pub fn get_fee_recipient(env: Env, merchant_id: Address) -> Result { + let merchant = Self::get_merchant_internal(&env, &merchant_id)?; + + if let Some(ref config) = merchant.fee_config { + if let Some(recipient) = &config.fee_recipient { + return Ok(recipient.clone()); + } + } + + // Default to admin if no custom recipient + let admin: Address = env + .storage() + .persistent() + .get(&MerchantDataKey::Admin) + .ok_or(MerchantError::Unauthorized)?; + + Ok(admin) + } + fn get_merchant_internal(env: &Env, merchant_id: &Address) -> Result { env.storage() .persistent() diff --git a/fluxapay/src/payment_link.rs b/fluxapay/src/payment_link.rs index 00c9908..609a155 100644 --- a/fluxapay/src/payment_link.rs +++ b/fluxapay/src/payment_link.rs @@ -18,6 +18,8 @@ pub struct PaymentLink { /// If true, funds are transferred directly to the merchant wallet on use_link, /// bypassing the escrow/platform wallet (issue #111). pub direct_transfer: bool, + /// Optional metadata attached to this payment link. + pub metadata: Option>, } #[contracttype] @@ -46,6 +48,7 @@ impl PaymentLinkManager { expires_at: Option, max_uses: Option, direct_transfer: bool, + metadata: Option>, ) -> String { merchant.require_auth(); @@ -60,6 +63,7 @@ impl PaymentLinkManager { use_count: 0, active: true, direct_transfer, + metadata, }; env.storage()