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);