Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions contracts/refund/ERRORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ This document lists the numeric error codes defined in the refund contract, thei
| 29 | `CircuitBreakerTripped` | The refund circuit breaker is currently open because the configured threshold was exceeded. |
| 30 | `InvalidFeeConfig` | The refund fee configuration is malformed or inconsistent. |
| 31 | `InsufficientTreasuryFees` | There are not enough accumulated treasury fees to satisfy the requested withdrawal. |
| 32 | `AutoApproveThresholdExceedsCeiling` | The merchant attempted to set an auto-approval threshold above the platform ceiling. |

## Extension errors (`ExtError`)

Expand Down
214 changes: 119 additions & 95 deletions contracts/refund/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ pub enum DataKey {
// Payment refund caps
PaymentRefundCap(u64),
PaymentRefundUsage(u64),
AutoApproveBelowCeiling,
// Issue #370: Customer-tier-based refund caps
CustomerTier(Address),
CustomerTierPolicy(Address, u32),
Expand Down Expand Up @@ -258,6 +259,7 @@ pub enum CoreError {
CircuitBreakerTripped = 29,
InvalidFeeConfig = 30,
InsufficientTreasuryFees = 31,
AutoApproveThresholdExceedsCeiling = 32,
}

#[contracterror]
Expand Down Expand Up @@ -1383,6 +1385,7 @@ impl RefundContract {
Self::set_inherit_from_parent_inner(&env, &admin, false);
Self::set_requires_admin_approval_inner(&env, &admin, true);
Self::set_auto_approve_below_inner(&env, &admin, 0);
Self::set_auto_approve_below_ceiling_inner(&env, 0);
env.storage()
.instance()
.set(&DataKey::AppealWindowSeconds, &604800u64);
Expand Down Expand Up @@ -4547,6 +4550,19 @@ impl RefundContract {
env.storage().instance().set(&composite_key, &value);
}

fn get_auto_approve_below_ceiling_inner(env: &Env) -> i128 {
env.storage()
.instance()
.get(&DataKey::AutoApproveBelowCeiling)
.unwrap_or(0)
}

fn set_auto_approve_below_ceiling_inner(env: &Env, value: i128) {
env.storage()
.instance()
.set(&DataKey::AutoApproveBelowCeiling, &value);
}

fn get_inherit_from_parent_inner(env: &Env, merchant: &Address) -> bool {
let key = Symbol::new(env, "inherit_from_parent");
let composite_key: (Symbol, Address) = (key, merchant.clone());
Expand Down Expand Up @@ -4602,9 +4618,44 @@ impl RefundContract {
/// # Arguments
/// * `merchant` - The merchant address to configure (must authenticate).
/// * `value` - The threshold amount below which refunds are auto-approved.
pub fn set_auto_approve_below(env: Env, merchant: Address, value: i128) {
pub fn set_auto_approve_below(env: Env, merchant: Address, value: i128) -> Result<(), Error> {
merchant.require_auth();
let ceiling = Self::get_auto_approve_below_ceiling_inner(&env);
if value > ceiling {
return Err(Error::Core(CoreError::AutoApproveThresholdExceedsCeiling));
}
Self::set_auto_approve_below_inner(&env, &merchant, value);
Ok(())
}

/// Get the platform-wide ceiling for merchant auto-approval thresholds.
///
/// Refund thresholds above this value are rejected by `set_auto_approve_below()`.
pub fn get_auto_approve_below_ceiling(env: Env) -> i128 {
Self::get_auto_approve_below_ceiling_inner(&env)
}

/// Set the platform-wide ceiling for merchant auto-approval thresholds.
///
/// # Arguments
/// * `admin` - The contract admin configuring the ceiling.
/// * `value` - The maximum auto-approval threshold any merchant may set.
pub fn set_auto_approve_below_ceiling(
env: Env,
admin: Address,
value: i128,
) -> Result<(), Error> {
admin.require_auth();
let stored_admin: Address = env
.storage()
.instance()
.get(&DataKey::Admin)
.ok_or(Error::Core(CoreError::Unauthorized))?;
if admin != stored_admin {
return Err(Error::Core(CoreError::Unauthorized));
}
Self::set_auto_approve_below_ceiling_inner(&env, value);
Ok(())
}

/// Check whether a merchant inherits its refund policy from its parent merchant.
Expand Down Expand Up @@ -4661,6 +4712,49 @@ impl RefundContract {
Ok(())
}

/// Set whether a merchant inherits its refund policy from its parent merchant.
///
/// # Arguments
/// * `merchant` - The merchant address to configure (must authenticate).
/// * `inherit` - `true` to enable inheritance, `false` to disable it.
pub fn set_inherit_from_parent(env: Env, merchant: Address, inherit: bool) {
merchant.require_auth();
Self::set_inherit_from_parent_inner(&env, &merchant, inherit);
}

/// Deactivate a merchant's refund policy so it is no longer enforced.
///
/// # Arguments
/// * `merchant` - The merchant whose policy should be deactivated (must authenticate).
///
/// # Errors
/// Returns `PolicyNotFound` if no policy exists for the merchant.
/// Returns `PolicyInactive` if the policy is already inactive.
pub fn deactivate_refund_policy(env: Env, merchant: Address) -> Result<(), Error> {
// Require merchant authentication
merchant.require_auth();

let mut policy: RefundPolicy = env
.storage()
.instance()
.get(&DataKey::RefundPolicy(merchant.clone()))
.ok_or(Error::Core(CoreError::PolicyNotFound))?;

if !policy.active {
return Err(Error::Core(CoreError::PolicyInactive));
}

policy.active = false;
env.storage()
.instance()
.set(&DataKey::RefundPolicy(merchant.clone()), &policy);

// Emit RefundPolicyDeactivated event
(RefundPolicyDeactivated { merchant }).publish(&env);

Ok(())
}

/// Override a refund decision as an admin and create an immutable audit log entry.
///
/// Records the override with a SHA-256 transaction hash for integrity verification.
Expand Down Expand Up @@ -5447,7 +5541,12 @@ impl RefundContract {
};
let requires_approval =
Self::get_requires_admin_approval_inner(&env, &effective_merchant);
let auto_below = Self::get_auto_approve_below_inner(&env, &effective_merchant);
let auto_below = {
let merchant_threshold =
Self::get_auto_approve_below_inner(&env, &effective_merchant);
let platform_ceiling = Self::get_auto_approve_below_ceiling_inner(&env);
core::cmp::min(merchant_threshold, platform_ceiling)
};
if !requires_approval && amount <= auto_below {
RefundStatus::Approved
} else {
Expand Down Expand Up @@ -6593,8 +6692,23 @@ impl RefundContract {

// Helper functions for fraud detection
fn get_customer_payment_count(env: &Env, address: &Address) -> u64 {
// Without a payment contract configured, we have no payment data
0
let payment_contract: Address = match env
.storage()
.instance()
.get(&DataKey::PaymentContractAddress)
{
Some(addr) => addr,
None => return 0,
};

let func = Symbol::new(env, "get_payment_count_by_customer");
let args = (address.clone(),).into_val(env);

match env.try_invoke_contract::<u64, soroban_sdk::InvokeError>(&payment_contract, &func, args)
{
Ok(Ok(count)) => count,
_ => 0,
}
}

fn get_customer_refund_count(env: &Env, address: &Address) -> u64 {
Expand Down Expand Up @@ -8573,94 +8687,4 @@ impl RefundContract {
/// Enable or disable strict tier policy enforcement for a merchant.
///
/// When strict mode is enabled, customers without an assigned tier are
/// denied refunds instead of falling back to default behavior.
///
/// # Arguments
/// * `merchant` - The merchant to configure (must authenticate).
/// * `strict` - `true` to enable strict mode, `false` to disable it.
pub fn set_strict_tier_policy(
env: Env,
merchant: Address,
strict: bool,
) -> Result<(), Error> {
merchant.require_auth();
env.storage()
.instance()
.set(&DataKey::StrictTierPolicy(merchant), &strict);
Ok(())
}

/// Check whether strict tier policy enforcement is enabled for a merchant.
///
/// # Arguments
/// * `merchant` - The merchant to query.
///
/// # Returns
/// `true` if strict mode is enabled, `false` otherwise (the default).
pub fn get_strict_tier_policy(env: Env, merchant: Address) -> bool {
env.storage()
.instance()
.get(&DataKey::StrictTierPolicy(merchant))
.unwrap_or(false)
}
}

mod test;
mod test_policy;
mod test_process;
mod test_rate_limit;

#[cfg(test)]
mod test_payment_refund_cap;

#[cfg(test)]
mod test_circuit_breaker;

// #[cfg(test)]
// mod test_versioning;

#[cfg(test)]
mod test_batch;

#[cfg(test)]
mod test_cross_contract;

#[cfg(test)]
mod test_arbitration_fees;

#[cfg(test)]
mod test_arbitration_stake;

#[cfg(test)]
mod test_arbitrator_reputation;

#[cfg(test)]
mod test_auto_refund;

#[cfg(test)]
mod test_inheritance;

mod test_customer_history;
#[cfg(test)]
mod test_notification_hooks;

#[cfg(test)]
mod test_arbitration_timeout;

#[cfg(test)]
mod test_merchant_eligibility;

#[cfg(test)]
mod test_customer_tier_policy;

#[cfg(test)]
mod test_voucher_expiry;

#[cfg(test)]
mod schema_version_test;

#[cfg(test)]
mod test_merchant_override_and_error_codes;

#[cfg(test)]
mod test_admin_rotation;
/// denied refunds inst
3 changes: 3 additions & 0 deletions contracts/refund/src/test_inheritance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ fn test_single_level_inheritance() {
client.initialize(&admin);

env.mock_all_auths();
client.set_auto_approve_below_ceiling(&admin, &1000i128);

// Parent sets their own policy
let tiers = Vec::from_array(
Expand Down Expand Up @@ -128,6 +129,7 @@ fn test_child_override_priority() {
);
client.set_refund_policy(&child_merchant, &child_tiers);
client.set_requires_admin_approval(&child_merchant, &false);
client.set_auto_approve_below_ceiling(&admin, &1000i128);
client.set_auto_approve_below(&child_merchant, &100i128);

// Child policy should be returned, not parent's
Expand Down Expand Up @@ -485,6 +487,7 @@ fn test_parent_updates_existing_policy() {
client.initialize(&admin);

env.mock_all_auths();
client.set_auto_approve_below_ceiling(&admin, &1000i128);

// Child sets policy first (no parent yet)
let tiers = Vec::from_array(
Expand Down
Loading
Loading