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