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
24 changes: 22 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ Pulsar is a comprehensive payment-processing smart contract for the Stellar Soro
|---|---|
| Merchant registry | Register, deactivate, and query merchants. |
| Signed payments | Process payments verified by ed25519 merchant signature |
| Refunds | Initiate → Approve/Reject → Execute with 30-day window |
| Refunds | Initiate → Approve/Reject → Execute with 30-day window + 1-hour grace buffer |
| Multi-sig | Require N-of-N signers before executing a payment |
| History queries | Cursor-based pagination with filtering and sorting |
| Global stats | Admin-only aggregate payment and refund statistics |
Expand Down Expand Up @@ -481,7 +481,27 @@ stellar contract invoke --id $CONTRACT_ID --source-account <MERCHANT_KEY> --netw

### Refunds

Refund window: **30 days** from `paid_at`. Partial refunds are allowed; cumulative refunds cannot exceed the original amount.
Refund window: **30 days + 1-hour grace buffer** from `paid_at`. Partial refunds are allowed; cumulative refunds cannot exceed the original amount.

#### Timestamp trust model

Refund eligibility is enforced using `env.ledger().timestamp()`, which returns the `close_time` field of the Stellar ledger header set by the validator quorum.

**Why not ledger sequence numbers?**
Ledger sequence numbers increment by 1 per closed ledger, but the wall-clock duration of each ledger varies (typically 5–7 s, not guaranteed). Converting a 30-day window into a fixed sequence-number delta would require assuming a constant close time; any deviation accumulates drift that could silently shorten or extend the window. Because `paid_at` already stores a Unix timestamp, using timestamps for the deadline check is the only consistent approach.

**Validator-provided timestamps**
The Stellar Consensus Protocol requires each ledger's `close_time` to be strictly greater than the previous ledger's `close_time`, so timestamps are monotonically increasing. They are *not* guaranteed to match wall-clock time exactly — validators may set `close_time` a small number of seconds ahead of or behind real time. In practice the drift is well under a minute.

**Grace buffer**
A 1-hour grace buffer (`REFUND_GRACE_BUFFER = 3600 s`) is added to the deadline:

```
deadline = paid_at + REFUND_WINDOW + REFUND_GRACE_BUFFER
= paid_at + 2_592_000 + 3_600
```

This absorbs minor timestamp drift near the boundary (e.g., a refund submitted seconds before midnight on day 30 that lands in a ledger whose `close_time` is a few seconds past the nominal deadline). The buffer is sized to accommodate expected network timing variance, not to provide a meaningful extension of the 30-day window. Because validator timestamps are monotonically increasing and the buffer is small relative to the window, it does not create a meaningful abuse surface.

#### `initiate_refund`

Expand Down
18 changes: 10 additions & 8 deletions contracts/payment-processing-contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@ mod helper;
mod storage;
mod types;

#[cfg(test)]
mod test;
#[cfg(test)]
mod repro_tests;
#[cfg(test)]
Expand All @@ -22,7 +20,7 @@ use soroban_sdk::{
};

use error::PaymentError;
use storage::REFUND_WINDOW;
use storage::{REFUND_GRACE_BUFFER, REFUND_WINDOW};
use types::{
DataKey, GlobalStats, Merchant, MerchantCategory, MultisigPayment, PaymentFilter, PaymentOrder,
PaymentPage, PaymentRecord, PaymentStatus, RefundRecord, RefundStatus, SortField, SortOrder,
Expand Down Expand Up @@ -697,7 +695,13 @@ impl PaymentContract {
return Err(PaymentError::Unauthorized);
}
let now = env.ledger().timestamp();
if now > record.paid_at + REFUND_WINDOW {
// Deadline = paid_at + 30-day window + 1-hour grace buffer.
// The grace buffer absorbs minor ledger timestamp drift near the boundary
// so that legitimate refunds submitted just before the deadline are not
// rejected due to a few seconds of validator clock variance.
// See storage::REFUND_WINDOW and storage::REFUND_GRACE_BUFFER for the
// full trust-model rationale.
if now > record.paid_at + REFUND_WINDOW + REFUND_GRACE_BUFFER {
return Err(PaymentError::RefundWindowExpired);
}
let new_total = record.refunded_amount + record.pending_refund_amount + amount;
Expand Down Expand Up @@ -830,10 +834,8 @@ impl PaymentContract {
storage::save_payment(&env, &record);
storage::decrement_order_refund_count(&env, &refund.order_id);

env.events().publish(
(String::from_str(&env, "refund_rejected"),),
refund_id,
);
env.events()
.publish((String::from_str(&env, "refund_rejected"),), refund_id);
Ok(())
}

Expand Down
46 changes: 45 additions & 1 deletion contracts/payment-processing-contract/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -342,8 +342,52 @@ pub fn set_whitelisted(env: &Env, address: &Address, approved: bool) {

/// Default cleanup period: 90 days in seconds
pub const DEFAULT_CLEANUP_PERIOD: u64 = 7_776_000;
/// Refund window: 30 days in seconds

/// Refund eligibility window: 30 days in seconds.
///
/// # Timestamp trust model
///
/// The refund deadline is computed as `paid_at + REFUND_WINDOW + REFUND_GRACE_BUFFER`,
/// where `paid_at` and the current time (`now`) are both sourced from
/// `env.ledger().timestamp()`.
///
/// **Why timestamps, not ledger sequence numbers?**
/// Stellar ledger sequence numbers increment by 1 per closed ledger, but the
/// wall-clock duration of each ledger varies (typically 5–7 s, but not
/// guaranteed). Converting a 30-day window into a fixed sequence-number delta
/// would require assuming a constant close time; any deviation accumulates
/// drift that could silently shorten or extend the window. Because `paid_at`
/// already stores a Unix timestamp, using timestamps for the deadline check is
/// the only consistent approach.
///
/// **Validator-provided timestamps and their bounds**
/// `env.ledger().timestamp()` returns the `close_time` field of the ledger
/// header, which is set by the validator quorum. The Stellar Consensus Protocol
/// requires that each ledger's `close_time` is strictly greater than the
/// previous ledger's `close_time`, so timestamps are monotonically increasing.
/// However, they are *not* guaranteed to match wall-clock time exactly:
/// validators may set `close_time` up to a small number of seconds ahead of or
/// behind real time. In practice the drift is well under a minute, but callers
/// should not rely on sub-minute precision.
///
/// **Abuse resistance**
/// Because timestamps are monotonically increasing and the grace buffer is
/// only 1 hour (small relative to the 30-day window), a validator cannot
/// meaningfully extend refund eligibility by manipulating `close_time` without
/// violating consensus rules. The grace buffer is sized to absorb legitimate
/// network timing variance, not to provide a meaningful extension of the window.
pub const REFUND_WINDOW: u64 = 2_592_000;

/// Grace buffer added to the refund deadline: 1 hour in seconds.
///
/// A refund is accepted when `now <= paid_at + REFUND_WINDOW + REFUND_GRACE_BUFFER`.
///
/// The 1-hour buffer absorbs minor timestamp drift near the deadline boundary
/// (e.g., a refund submitted seconds before midnight on day 30 that lands in a
/// ledger whose `close_time` is a few seconds past the nominal deadline). It
/// does not meaningfully extend the 30-day window from a user perspective.
pub const REFUND_GRACE_BUFFER: u64 = 3_600;

/// Default multisig expiry: 24 hours in seconds
pub const DEFAULT_MULTISIG_EXPIRY: u64 = 86_400;
/// Maximum number of signers for a multisig payment
Expand Down
120 changes: 120 additions & 0 deletions contracts/payment-processing-contract/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,126 @@ fn test_refund_window_expired_fails() {
);
}

// ── Refund window edge-case tests ─────────────────────────────────────────────

/// Refund well within the 30-day window (day 15) — must succeed.
#[test]
fn test_refund_well_within_window_succeeds() {
let (env, client) = setup();
let (_admin, _merchant, payer, _token) = setup_paid_order(&env, &client);

// Day 15: 15 * 86_400 = 1_296_000
env.ledger().with_mut(|l| l.timestamp = 1_296_000);

client.initiate_refund(
&payer,
&bytes(&env, "REFUND_MID"),
&bytes(&env, "ORDER_001"),
&500,
&str(&env, "Mid-window refund"),
);
let status = client.get_refund_status(&bytes(&env, "REFUND_MID"));
assert_eq!(status, RefundStatus::Pending);
}

/// Refund exactly at the nominal 30-day deadline (paid_at + REFUND_WINDOW) — must succeed.
#[test]
fn test_refund_exactly_at_nominal_deadline_succeeds() {
let (env, client) = setup();
let (_admin, _merchant, payer, _token) = setup_paid_order(&env, &client);

// paid_at = 0 (default ledger timestamp in tests), deadline = 2_592_000
env.ledger().with_mut(|l| l.timestamp = 2_592_000);

client.initiate_refund(
&payer,
&bytes(&env, "REFUND_EXACT"),
&bytes(&env, "ORDER_001"),
&500,
&str(&env, "Exactly at deadline"),
);
let status = client.get_refund_status(&bytes(&env, "REFUND_EXACT"));
assert_eq!(status, RefundStatus::Pending);
}

/// Refund 1 second past the nominal deadline but within the grace buffer — must succeed.
#[test]
fn test_refund_within_grace_buffer_succeeds() {
let (env, client) = setup();
let (_admin, _merchant, payer, _token) = setup_paid_order(&env, &client);

// 1 second past nominal deadline, still inside the 1-hour grace buffer
env.ledger().with_mut(|l| l.timestamp = 2_592_001);

client.initiate_refund(
&payer,
&bytes(&env, "REFUND_GRACE"),
&bytes(&env, "ORDER_001"),
&500,
&str(&env, "Inside grace buffer"),
);
let status = client.get_refund_status(&bytes(&env, "REFUND_GRACE"));
assert_eq!(status, RefundStatus::Pending);
}

/// Refund exactly at the end of the grace buffer (paid_at + REFUND_WINDOW + REFUND_GRACE_BUFFER) — must succeed.
#[test]
fn test_refund_at_grace_buffer_boundary_succeeds() {
let (env, client) = setup();
let (_admin, _merchant, payer, _token) = setup_paid_order(&env, &client);

// Exactly at the last valid second: 2_592_000 + 3_600 = 2_595_600
env.ledger().with_mut(|l| l.timestamp = 2_595_600);

client.initiate_refund(
&payer,
&bytes(&env, "REFUND_BOUNDARY"),
&bytes(&env, "ORDER_001"),
&500,
&str(&env, "At grace boundary"),
);
let status = client.get_refund_status(&bytes(&env, "REFUND_BOUNDARY"));
assert_eq!(status, RefundStatus::Pending);
}

/// Refund 1 second past the grace buffer — must be rejected.
#[test]
fn test_refund_one_second_past_grace_buffer_fails() {
let (env, client) = setup();
let (_admin, _merchant, payer, _token) = setup_paid_order(&env, &client);

// 1 second past the grace buffer: 2_592_000 + 3_600 + 1 = 2_595_601
env.ledger().with_mut(|l| l.timestamp = 2_595_601);

let result = client.try_initiate_refund(
&payer,
&bytes(&env, "REFUND_LATE"),
&bytes(&env, "ORDER_001"),
&500,
&str(&env, "Just past grace"),
);
assert_eq!(result, Err(Ok(PaymentError::RefundWindowExpired)));
}

/// Refund long after the window (day 60) — must be rejected.
#[test]
fn test_refund_long_after_window_fails() {
let (env, client) = setup();
let (_admin, _merchant, payer, _token) = setup_paid_order(&env, &client);

// Day 60: 60 * 86_400 = 5_184_000
env.ledger().with_mut(|l| l.timestamp = 5_184_000);

let result = client.try_initiate_refund(
&payer,
&bytes(&env, "REFUND_OLD"),
&bytes(&env, "ORDER_001"),
&500,
&str(&env, "Way too late"),
);
assert_eq!(result, Err(Ok(PaymentError::RefundWindowExpired)));
}

#[test]
fn test_reject_refund() {
let (env, client) = setup();
Expand Down