diff --git a/fluxapay/src/lib.rs b/fluxapay/src/lib.rs index 8dedf6e..cad126d 100644 --- a/fluxapay/src/lib.rs +++ b/fluxapay/src/lib.rs @@ -109,6 +109,10 @@ pub struct PaymentCharge { pub token_address: Option
, /// Optional 32-byte hash merchants can use to tie a payment to an order ID or customer ID. pub metadata_hash: Option>, + /// Issue #304: FX rate snapshot captured during verify_payment. + pub fx_rate: Option, + /// Issue #304: Timestamp when the FX rate was captured. + pub fx_rate_at: Option, /// Issue #173: Original token address used by payer (for swap_and_pay refunds). pub original_token: Option
, /// Issue #173: Swap path used in swap_and_pay (for refund routing). @@ -162,6 +166,8 @@ pub struct Refund { pub requester: Address, pub created_at: u64, pub processed_at: Option, + /// Cryptographic proof hash of return agreement for off-chain verification (Issue #176). + pub receipt_hash: Option>, /// Issue #168: Approved by operator, allowing customer to claim. pub approved: bool, /// Cryptographic proof hash of return agreement for off-chain verification (Issue #176). @@ -300,6 +306,10 @@ pub enum Error { InvalidResumeTimestamp = 32, /// Merchant authorization error (see MerchantAuthError for details). MerchantAuthError = 33, + /// Dispute payout_splits amounts don't sum to the dispute amount (Issue #446). + InvalidSplitSum = 34, + /// Refund policy requires a receipt_hash but none was provided (Issue #176). + MissingReceiptHash = 35, /// Refund's `expiry_at` deadline has passed (Issue #170). RefundExpired = 34, /// Arbitrator has already cast a vote on this dispute. @@ -838,6 +848,12 @@ pub enum DataKey { DisputeVote(String, Address), /// Tally of votes for a dispute DisputeVoteTally(String), + /// Cross-contract address of the configured FX oracle (Issue #304). + FxOracleAddress, + /// Whether `process_refund` requires a `receipt_hash` on refunds (Issue #176). + RequireReceiptHash, + /// Cross-contract address of the configured DEX router (Issue #173). + DexRouterAddress, /// Configurable refund expiry window in seconds (Issue #170). RefundExpirySecs, /// Vote cast by an arbitrator under the simple ARBITRATOR-role voting @@ -996,6 +1012,40 @@ impl RefundManager { Ok(()) } + /// Admin: configure the DEX router used to route swap_and_pay refunds + /// back to the payer's original token (Issue #173). + pub fn set_dex_router_address( + env: Env, + admin: Address, + dex_router: Address, + ) -> Result<(), Error> { + admin.require_auth(); + if !AccessControl::has_role(&env, &role_admin(&env), &admin) { + return Err(Error::Unauthorized); + } + env.storage() + .persistent() + .set(&DataKey::DexRouterAddress, &dex_router); + Ok(()) + } + + /// Admin: require a `receipt_hash` on every refund before `process_refund` + /// will execute it (Issue #176). + pub fn set_refund_policy( + env: Env, + admin: Address, + require_receipt_hash: bool, + ) -> Result<(), Error> { + admin.require_auth(); + if !AccessControl::has_role(&env, &role_admin(&env), &admin) { + return Err(Error::Unauthorized); + } + env.storage() + .persistent() + .set(&DataKey::RequireReceiptHash, &require_receipt_hash); + Ok(()) + } + pub fn grant_role( env: Env, admin: Address, @@ -1161,6 +1211,79 @@ impl RefundManager { .get::(&DataKey::Blacklisted(address.clone())) .unwrap_or(false) { + let payment = PaymentCharge { + payment_id: payment_id.clone(), + merchant_id, + amount, + currency, + deposit_address: env.current_contract_address(), + status: PaymentStatus::Confirmed, + payer_address: None, + transaction_hash: None, + created_at: env.ledger().timestamp(), + confirmed_at: None, + expires_at: 0, + amount_received: None, + memo: None, + memo_type: None, + token_address: None, + metadata_hash: None, + fx_rate: None, + fx_rate_at: None, + original_token: None, + swap_path: None, + }; + env.storage() + .persistent() + .set(&DataKey::Payment(payment_id.clone()), &payment); + Self::bump_payment_ttl(&env, &payment_id, &payment.status); + } + } + + /// Like `register_payment`, but also records the original token and swap + /// path used by a `swap_and_pay` payment, so `process_refund` can route + /// the refund back through the DEX to the payer's original token + /// (Issue #173). + pub fn register_swap_payment( + env: Env, + payment_id: String, + merchant_id: Address, + amount: i128, + currency: Symbol, + original_token: Address, + swap_path: Vec
, + ) { + if !env + .storage() + .persistent() + .has(&DataKey::Payment(payment_id.clone())) + { + let payment = PaymentCharge { + payment_id: payment_id.clone(), + merchant_id, + amount, + currency, + deposit_address: env.current_contract_address(), + status: PaymentStatus::Confirmed, + payer_address: None, + transaction_hash: None, + created_at: env.ledger().timestamp(), + confirmed_at: None, + expires_at: 0, + amount_received: None, + memo: None, + memo_type: None, + token_address: None, + metadata_hash: None, + fx_rate: None, + fx_rate_at: None, + original_token: Some(original_token), + swap_path: Some(swap_path), + }; + env.storage() + .persistent() + .set(&DataKey::Payment(payment_id.clone()), &payment); + Self::bump_payment_ttl(&env, &payment_id, &payment.status); return Err(Error::Unauthorized); } Ok(()) @@ -1171,6 +1294,15 @@ impl RefundManager { /// `treasury_bps + developer_bps` must be ≤ 10 000; any remainder goes to treasury. pub fn configure_fee_split( env: Env, + payment_id: String, + refund_amount: i128, + reason: String, + requester: Address, + receipt_hash: Option>, + ) -> Result { + requester.require_auth(); + Self::create_refund_internal(&env, payment_id, refund_amount, reason, requester, receipt_hash) + } admin: Address, treasury_bps: u32, developer_bps: u32, @@ -1468,6 +1600,7 @@ impl RefundManager { requester, created_at, processed_at: None, + receipt_hash, expiry_at: created_at.saturating_add(Self::get_refund_expiry_secs(env)), approved: false, receipt_hash, @@ -1662,6 +1795,13 @@ impl RefundManager { return Err(Error::RefundAlreadyProcessed); } + let require_receipt_hash: bool = env + .storage() + .persistent() + .get(&DataKey::RequireReceiptHash) + .unwrap_or(false); + if require_receipt_hash && refund.receipt_hash.is_none() { + return Err(Error::MissingReceiptHash); if env.ledger().timestamp() > refund.expiry_at { // Issue #170: Check refund expiration let now = env.ledger().timestamp(); @@ -1768,6 +1908,52 @@ impl RefundManager { .set(&DataKey::Refund(refund_id.clone()), &refund); Self::bump_refund_ttl(env, &refund_id, &refund.status); + // Issue #173: route the refund back through the DEX to the payer's + // original token when the payment was made via swap_and_pay. + let mut routed_via_dex = false; + if let (Some(original_token), Some(swap_path)) = + (&payment.original_token, &payment.swap_path) + { + if let Some(dex_router) = env + .storage() + .persistent() + .get::(&DataKey::DexRouterAddress) + { + let mut reversed_path = swap_path.clone(); + reversed_path.reverse(); + if !reversed_path.is_empty() { + let dex_client = crate::dex_router::DexRouterClient::new(env, &dex_router); + let deadline = env.ledger().timestamp().saturating_add(3_600); + match dex_client.try_swap_exact_tokens_for_tokens( + &net_amount, + &1i128, + &reversed_path, + &refund.requester, + &deadline, + ) { + Ok(Ok(_amounts)) => { + routed_via_dex = true; + env.events().publish( + (Symbol::new(env, "REFUND"), Symbol::new(env, "SWAP_ROUTED")), + (refund.payment_id.clone(), refund_id.clone(), original_token.clone()), + ); + } + _ => { + env.events().publish( + (Symbol::new(env, "REFUND"), Symbol::new(env, "SWAP_FALLBACK")), + (refund.payment_id.clone(), refund_id.clone()), + ); + } + } + } + } + } + + // Interaction: Transfer net amount to requester (in USDC, unless already + // routed back to the original token via the DEX above). + if !routed_via_dex && token_client.try_transfer(&from, &to, &net_amount).is_err() { + // If transfer fails, we currently return Ok(()) but state is already updated. + // In a more robust system we might want to revert or handle failures differently. // Interaction: Transfer net amount to requester if token_client.try_transfer(&from, &to, &refund_amount_final).is_err() { return Ok(()); @@ -1783,9 +1969,16 @@ impl RefundManager { env.events().publish( (Symbol::new(env, "REFUND"), Symbol::new(env, "COMPLETED")), - (refund.payment_id, refund_id, refund.amount), + (refund.payment_id.clone(), refund_id.clone(), refund.amount), ); + if refund.receipt_hash.is_some() { + env.events().publish( + (Symbol::new(env, "REFUND"), Symbol::new(env, "HASH_VERIFIED")), + (refund.payment_id, refund_id), + ); + } + Ok(()) } @@ -2293,6 +2486,7 @@ impl RefundManager { resolution_notes: None, review_deadline: None, escalated: false, + payout_splits: Vec::new(&env), computed_deadline_secs: Some(deadline_secs), payout_splits, }; @@ -2614,6 +2808,46 @@ impl RefundManager { Ok(()) } + /// Operator: configure multi-party payout splits for a marketplace dispute. + /// Splits must sum to exactly `dispute.amount`; validated again at + /// resolution time in case the dispute amount changes. (Issue #446) + pub fn set_dispute_payout_splits( + env: Env, + operator: Address, + dispute_id: String, + splits: Vec, + ) -> Result<(), Error> { + operator.require_auth(); + + let has_settlement = + AccessControl::has_role(&env, &role_settlement_operator(&env), &operator); + let has_oracle = AccessControl::has_role(&env, &role_oracle(&env), &operator); + if !has_settlement && !has_oracle { + return Err(Error::Unauthorized); + } + + let mut dispute = Self::get_dispute_internal(&env, &dispute_id)?; + if dispute.status == DisputeStatus::Resolved || dispute.status == DisputeStatus::Rejected { + return Err(Error::DisputeAlreadyResolved); + } + + let mut total: i128 = 0; + for split in splits.iter() { + total = total.saturating_add(split.amount); + } + if total != dispute.amount { + return Err(Error::InvalidSplitSum); + } + + dispute.payout_splits = splits; + env.storage() + .persistent() + .set(&DataKey::Dispute(dispute_id.clone()), &dispute); + Self::bump_dispute_ttl(&env, &dispute_id, &dispute.status); + + Ok(()) + } + fn maybe_escalate_dispute_due_to_deadline( env: &Env, dispute_id: &String, @@ -2695,6 +2929,56 @@ impl RefundManager { return Err(Error::DisputeAlreadyResolved); } + // Issue #446: if payout_splits are configured, distribute funds to + // each recipient directly instead of issuing a single-recipient + // refund. + if !dispute.payout_splits.is_empty() { + let mut total: i128 = 0; + for split in dispute.payout_splits.iter() { + total = total.saturating_add(split.amount); + } + if total != dispute.amount { + return Err(Error::InvalidSplitSum); + } + + let usdc_token_address: Address = env + .storage() + .persistent() + .get(&DataKey::UsdcToken) + .ok_or(Error::Unauthorized)?; + let token_client = token::TokenClient::new(&env, &usdc_token_address); + let from = env.current_contract_address(); + + for split in dispute.payout_splits.iter() { + token_client.transfer(&from, &split.recipient, &split.amount); + } + + let now = env.ledger().timestamp(); + dispute.status = DisputeStatus::Resolved; + dispute.resolved_at = Some(now); + dispute.resolution_notes = Some(resolution_notes.clone()); + + env.storage() + .persistent() + .set(&DataKey::Dispute(dispute_id.clone()), &dispute); + Self::bump_dispute_ttl(&env, &dispute_id, &dispute.status); + + env.events().publish( + ( + Symbol::new(&env, "DISPUTE"), + Symbol::new(&env, "SPLIT_RESOLVED"), + ), + ( + dispute_id.clone(), + dispute.payment_id.clone(), + dispute.payout_splits.len(), + dispute.amount, + ), + ); + + return Ok(dispute_id); + } + // Create refund for the disputed amount let refund_reason = String::from_str(&env, "Refund issued due to dispute resolution"); @@ -4770,6 +5054,9 @@ impl PaymentProcessor { Ok(()) } + /// Admin: configure the FX oracle used to snapshot rates during + /// `verify_payment` (Issue #304). + pub fn set_fx_oracle(env: Env, admin: Address, fx_oracle: Address) -> Result<(), Error> { /// Set the settlement fee rate (in basis points) deducted from each payment /// during `settle_payment` and accumulated in `TreasuryBalance`. /// @@ -4785,6 +5072,9 @@ impl PaymentProcessor { return Err(Error::Unauthorized); } + env.storage() + .persistent() + .set(&DataKey::FxOracleAddress, &fx_oracle); if bps < 0 || bps > 10_000 { return Err(Error::InvalidAmount); } @@ -6263,6 +6553,43 @@ impl PaymentProcessor { PaymentStatus::PartiallyPaid }; + payment.status = new_status.clone(); + + // Issue #304: snapshot the FX rate at verification time, if an oracle + // is configured for this contract. + if let Some(fx_oracle) = env + .storage() + .persistent() + .get::(&DataKey::FxOracleAddress) + { + let oracle_client = FXOracleClient::new(&env, &fx_oracle); + match oracle_client.try_get_rate(&payment.currency) { + Ok(Ok(rate_data)) => { + payment.fx_rate = Some(rate_data.rate); + payment.fx_rate_at = Some(env.ledger().timestamp()); + } + _ => { + env.events().publish( + ( + Symbol::new(&env, "PAYMENT"), + Symbol::new(&env, "FX_RATE_UNAVAILABLE"), + ), + payment_id.clone(), + ); + } + } + } + + env.storage() + .persistent() + .set(&DataKey::Payment(payment_id.clone()), &payment); + Self::bump_payment_ttl(&env, &payment_id, &payment.status); + + let event_name = match &new_status { + PaymentStatus::Confirmed => Symbol::new(&env, "VERIFIED"), + PaymentStatus::Overpaid => Symbol::new(&env, "OVERPAID"), + PaymentStatus::PartiallyPaid => Symbol::new(&env, "PARTIALLY_PAID"), + _ => Symbol::new(&env, "FAILED"), let overpaid_refund_amount = if new_status == PaymentStatus::Overpaid { Some(amount_received.saturating_sub(payment.amount)) } else { @@ -8069,12 +8396,15 @@ impl PaymentProcessor { }; let mut payment = Self::create_payment(env.clone(), create_args)?; - payment.original_token = Some(args.token_in.clone()); - payment.swap_path = Some(args.path); + // Issue #173: record the original token and swap path so a later + // refund can be routed back through the DEX to the payer's token. + payment.original_token = Some(args.token_in.clone()); + payment.swap_path = Some(args.path.clone()); env.storage() .persistent() - .set(&DataKey::Payment(payment.payment_id.clone()), &payment); + .set(&DataKey::Payment(args.payment_id.clone()), &payment); + Self::bump_payment_ttl(&env, &args.payment_id, &payment.status); env.events().publish( ( diff --git a/fluxapay/src/test.rs b/fluxapay/src/test.rs index d6b2b70..5958594 100644 --- a/fluxapay/src/test.rs +++ b/fluxapay/src/test.rs @@ -814,7 +814,7 @@ fn test_create_and_get_refund() { &Symbol::new(&env, "USDC"), ); - let refund_id = client.create_refund(&payment_id, &refund_amount, &reason, &requester); + let refund_id = client.create_refund(&payment_id, &refund_amount, &reason, &requester, &None); let refund = client.get_refund(&refund_id); assert_eq!(refund.payment_id, payment_id); @@ -845,7 +845,7 @@ fn test_process_refund() { &refund_amount, &String::from_str(&env, "Reason"), &requester, - ); + &None,); let operator = Address::generate(&env); client.grant_role(&admin, &role_settlement_operator(&env), &operator); @@ -1175,7 +1175,7 @@ fn test_multiple_refunds_unique_ids() { &1000i128, &String::from_str(&env, "First refund"), &requester, - ); + &None,); // Create second refund let refund_id_2 = client.create_refund( @@ -1183,7 +1183,7 @@ fn test_multiple_refunds_unique_ids() { &500i128, &String::from_str(&env, "Second refund"), &requester, - ); + &None,); // Create third refund let refund_id_3 = client.create_refund( @@ -1191,7 +1191,7 @@ fn test_multiple_refunds_unique_ids() { &250i128, &String::from_str(&env, "Third refund"), &requester, - ); + &None,); // Verify all refund IDs are unique assert_ne!(refund_id_1, refund_id_2); @@ -1236,7 +1236,7 @@ fn test_create_refund_requires_auth() { &1000i128, &String::from_str(&env, "Unauthorized refund"), &requester, - ); + &None,); } #[test] @@ -1325,7 +1325,7 @@ fn test_process_refund_deducts_fee_from_requester() { &refund_amount, &String::from_str(&env, "fee test"), &requester, - ); + &None,); let operator = Address::generate(&env); client.grant_role(&admin, &role_settlement_operator(&env), &operator); @@ -1360,7 +1360,7 @@ fn test_process_refund_sends_fee_to_admin() { &refund_amount, &String::from_str(&env, "fee test"), &requester, - ); + &None,); let operator = Address::generate(&env); client.grant_role(&admin, &role_settlement_operator(&env), &operator); @@ -1393,7 +1393,7 @@ fn test_cancel_refund_by_requester() { &1000i128, &String::from_str(&env, "cancel me"), &requester, - ); + &None,); client.cancel_refund(&requester, &refund_id); @@ -1427,7 +1427,7 @@ fn test_cancel_refund_by_admin() { &500i128, &String::from_str(&env, "admin cancel"), &requester, - ); + &None,); client.cancel_refund(&admin, &refund_id); @@ -1456,7 +1456,7 @@ fn test_cancel_refund_unauthorized() { &500i128, &String::from_str(&env, "reason"), &requester, - ); + &None,); let random = Address::generate(&env); let result = client.try_cancel_refund(&random, &refund_id); @@ -1484,7 +1484,7 @@ fn test_cancel_refund_already_processed() { &500i128, &String::from_str(&env, "reason"), &requester, - ); + &None,); let operator = Address::generate(&env); client.grant_role(&admin, &role_settlement_operator(&env), &operator); @@ -1516,7 +1516,7 @@ fn test_cancel_refund_emits_event() { &750i128, &String::from_str(&env, "reason"), &requester, - ); + &None,); client.cancel_refund(&requester, &refund_id); @@ -1550,7 +1550,7 @@ fn test_refund_total_equals_payment_amount_succeeds() { &amount, &String::from_str(&env, "full refund"), &requester, - ); + &None,); let refund = client.get_refund(&refund_id); assert_eq!(refund.amount, amount); } @@ -1579,7 +1579,7 @@ fn test_refund_exceeds_payment_amount_rejected() { &501i128, &String::from_str(&env, "over refund"), &requester, - ); + &None,); } /// Cumulative partial refunds that exceed the payment amount must be rejected. @@ -1607,7 +1607,7 @@ fn test_cumulative_refunds_exceed_payment_amount_rejected() { &600i128, &String::from_str(&env, "partial 1"), &requester, - ); + &None,); // Second partial refund: 401 — total would be 1001 > 1000, must fail client.create_refund( @@ -1615,7 +1615,7 @@ fn test_cumulative_refunds_exceed_payment_amount_rejected() { &401i128, &String::from_str(&env, "partial 2 over"), &requester, - ); + &None,); } // ── Issue #115: Partial Refund Support ─────────────────────────────────────── @@ -1643,19 +1643,19 @@ fn test_partial_refunds_tracked_in_payment_refunds_list() { &300i128, &String::from_str(&env, "partial 1"), &requester, - ); + &None,); let r2 = client.create_refund( &payment_id, &400i128, &String::from_str(&env, "partial 2"), &requester, - ); + &None,); let r3 = client.create_refund( &payment_id, &300i128, &String::from_str(&env, "partial 3"), &requester, - ); + &None,); // All three refunds should be in the payment's refund list let refunds = client.get_payment_refunds(&payment_id); @@ -1690,7 +1690,7 @@ fn test_rejected_refund_does_not_count_toward_total() { &800i128, &String::from_str(&env, "will be rejected"), &requester, - ); + &None,); let operator = Address::generate(&env); client.grant_role(&admin, &role_settlement_operator(&env), &operator); @@ -1702,7 +1702,7 @@ fn test_rejected_refund_does_not_count_toward_total() { &800i128, &String::from_str(&env, "replacement"), &requester, - ); + &None,); let new_refund = client.get_refund(&new_refund_id); assert_eq!(new_refund.amount, 800i128); assert_eq!(new_refund.status, RefundStatus::Pending); @@ -2161,7 +2161,7 @@ fn test_cumulative_refunds_exceed_payment_amount_fails() { &600i128, &String::from_str(&env, "partial 1"), &requester, - ); + &None,); // Second refund: 500 — 600 + 500 = 1100 > 1000 — must fail let result = client.try_create_refund( @@ -2197,7 +2197,7 @@ fn test_refund_exactly_equal_to_payment_amount_succeeds() { &payment_amount, &String::from_str(&env, "full refund"), &requester, - ); + &None,); let refund = client.get_refund(&refund_id); assert_eq!(refund.amount, payment_amount); assert_eq!(refund.status, RefundStatus::Pending); @@ -2227,7 +2227,7 @@ fn test_second_refund_after_full_refund_fails() { &payment_amount, &String::from_str(&env, "full"), &requester, - ); + &None,); // Any additional refund — must fail let result = client.try_create_refund( @@ -2263,7 +2263,7 @@ fn test_rejected_refunds_not_counted_in_cumulative_total() { &800i128, &String::from_str(&env, "will be rejected"), &requester, - ); + &None,); let operator = Address::generate(&env); client.grant_role(&admin, &role_settlement_operator(&env), &operator); client.reject_refund(&operator, &refund_id); @@ -2274,7 +2274,7 @@ fn test_rejected_refunds_not_counted_in_cumulative_total() { &payment_amount, &String::from_str(&env, "after rejection"), &requester, - ); + &None,); let refund = client.get_refund(&new_refund_id); assert_eq!(refund.amount, payment_amount); assert_eq!(refund.status, RefundStatus::Pending);