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
36 changes: 36 additions & 0 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name: Workspace Check

on:
pull_request:
branches: [main]

env:
CARGO_TERM_COLOR: always

jobs:
cargo-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Load version pins
shell: bash
run: |
set -a
source .github/versions.env
set +a
echo "RUST_VERSION=$RUST_VERSION" >> "$GITHUB_ENV"

- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@master
with:
toolchain: ${{ env.RUST_VERSION }}
targets: wasm32-unknown-unknown

- name: Cache Rust dependencies
uses: Swatinem/rust-cache@v2
with:
key: check

- name: Check workspace (all targets)
run: cargo check --workspace --all-targets
6 changes: 6 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,16 @@ docs: expand CONTRIBUTING.md with branch, commit, and PR guidelines
### Before opening a PR

- [ ] Pre-commit hooks pass locally (`pre-commit run --all-files`)
- [ ] Full workspace check passes (`cargo check --workspace --all-targets`)
- [ ] All tests pass (`cargo test`)
- [ ] Clippy is clean (`cargo clippy -- -D warnings`)
- [ ] ABI snapshots regenerated if contract interfaces changed

> **Required CI check**: every PR to `main` must pass the `cargo-check` job in
> `.github/workflows/check.yml`, which runs `cargo check --workspace --all-targets`.
> This job catches declared-but-missing modules and test-only breakage before they
> land on `main`. Branch protection must be configured to require this check.

### PR description template

```
Expand Down
22 changes: 22 additions & 0 deletions contracts/invoice/src/entrypoints/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,28 @@ impl InvoiceContract {
.unwrap_or(0u64)
}

// --- #702: configurable refund window ---

/// Set the refund window: how many seconds after `paid_at` a payer may
/// call `request_refund`. Pass `0` to remove the limit (current behaviour:
/// refunds accepted at any time after payment, giving merchants indefinite
/// exposure).
pub fn set_refund_window(env: Env, admin: Address, seconds: u64) -> Result<(), InvoiceError> {
require_admin(&env, &admin)?;
env.storage()
.instance()
.set(&DataKey::RefundWindow, &seconds);
Ok(())
}

/// Return the current refund window in seconds (0 means no limit).
pub fn get_refund_window(env: Env) -> u64 {
env.storage()
.instance()
.get(&DataKey::RefundWindow)
.unwrap_or(0u64)
}

// --- #15: two-step admin transfer ---

/// Initiate admin transfer. Current admin nominates `new_admin`.
Expand Down
80 changes: 80 additions & 0 deletions contracts/invoice/src/entrypoints/lifecycle.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use crate::events::{self, InvoiceAmountUpdatedEvent};
use crate::invoice::{MaybeU64, RefundStatus};
use crate::refund::{
calculate_net_refund, refund_recipient, transfer_net_refund, verify_payment_state,
};
Expand Down Expand Up @@ -368,10 +369,33 @@ impl InvoiceContract {
return Err(InvoiceError::Unauthorized);
}

// #702: reject refund requests outside the configurable window.
// A window of 0 means no limit (preserving the current behaviour).
let refund_window: u64 = env
.storage()
.instance()
.get(&DataKey::RefundWindow)
.unwrap_or(0u64);
if refund_window > 0 {
if let Some(paid_at) = invoice.paid_at {
// paid_at + refund_window may overflow; use saturating_add so a
// very large window never wraps to a timestamp in the past.
let deadline = paid_at.saturating_add(refund_window);
if env.ledger().timestamp() >= deadline {
return Err(InvoiceError::RefundWindowClosed);
}
}
}

let now = env.ledger().timestamp();
invoice.status = InvoiceStatus::RefundRequested;
env.storage()
.persistent()
.set(&DataKey::Invoice(id), &invoice);
// Store the request timestamp for get_refund_status (#700).
env.storage()
.persistent()
.set(&DataKey::RefundRequestedAt(id), &now);
append_history(
&env,
id,
Expand Down Expand Up @@ -530,6 +554,62 @@ impl InvoiceContract {
.ok_or(InvoiceError::NotFound)
}

/// Return the consolidated refund state and fee breakdown for `id` in a
/// single call (#700).
///
/// Combines the invoice status, the ledger timestamp at which
/// `request_refund` was called (if any), and the fee breakdown stored by
/// `process_refund` (if any), so clients do not need to combine
/// `get_invoice` and `get_refund_breakdown` separately.
///
/// Read-only; no auth required. Works regardless of whether the contract
/// is paused (quoting refund progress is a read and must remain available).
///
/// - `requested_at` is `MaybeU64::None` when no refund has been requested.
/// - Fee fields default to `0` when no breakdown has been stored yet
/// (e.g. the invoice is still in `Paid` status or was approved via
/// `approve_refund` rather than `process_refund`).
///
/// Errors: [`InvoiceError::NotFound`] if no invoice exists for `id`.
pub fn get_refund_status(env: Env, id: u64) -> Result<RefundStatus, InvoiceError> {
let invoice: Invoice = env
.storage()
.persistent()
.get(&DataKey::Invoice(id))
.ok_or(InvoiceError::NotFound)?;

let requested_at: MaybeU64 = env
.storage()
.persistent()
.get(&DataKey::RefundRequestedAt(id))
.map(MaybeU64::Some)
.unwrap_or(MaybeU64::None);

let breakdown: Option<NetRefund> = env
.storage()
.persistent()
.get(&DataKey::RefundBreakdown(id));

let (gross_amount, processing_fee, network_fee, net_amount) =
breakdown.map_or((0, 0, 0, 0), |b| {
(
b.gross_amount,
b.processing_fee,
b.network_fee,
b.net_amount,
)
});

Ok(RefundStatus {
status: invoice.status,
requested_at,
gross_amount,
processing_fee,
network_fee,
net_amount,
})
}

/// Reject a refund request. Admin-only. Transitions RefundRequested → Paid.
/// #70: the payment state is verified for the same reason as on
/// `approve_refund` — a refund round-trip must not be able to land on an
Expand Down
56 changes: 56 additions & 0 deletions contracts/invoice/src/invoice.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ pub use invoice_errors::InvoiceError;
/// USDC on Stellar uses 7 decimal places: 1 USDC = 10_000_000 stroops.
pub const USDC_FACTOR: i128 = 10_000_000;

/// Maximum allowed late fee expressed in basis points.
///
/// `gross_usdc` may exceed `amount_usdc` by at most this many bps of
/// `amount_usdc`. 1 000 bps = 10 %. This is the single source of truth for
/// the late-fee cap; `validation.rs` imports and uses this constant rather
/// than redeclaring it.
pub const MAX_LATE_FEE_BPS: u32 = 1_000;

/// Maximum number of elements accepted by any batch entrypoint (batch_create_invoice,
/// batch_expire) per call, to bound per-invocation storage writes and gas.
pub const MAX_BATCH_SIZE: u32 = 50;
Expand Down Expand Up @@ -104,6 +112,47 @@ pub struct StatusTransition {
pub timestamp: u64,
}

/// A nullable `u64` wrapper compatible with `#[contracttype]`.
///
/// Used to represent an optional timestamp (e.g. `requested_at` before a
/// refund has been requested). `Option<u64>` is not supported by the
/// Soroban contract-type macro in the same way, so this enum mirrors the
/// `MaybeAddress`/`MaybeBytes` pattern already established in this file.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum MaybeU64 {
None,
Some(u64),
}

/// Consolidated refund state for an invoice, returned by `get_refund_status`.
///
/// Combines the invoice's refund-related status fields with the fee breakdown
/// stored under `DataKey::RefundBreakdown` (if any), so a client does not
/// need to call both `get_invoice` and `get_refund_breakdown` to render a
/// refund progress view.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RefundStatus {
/// Current lifecycle status of the invoice.
pub status: InvoiceStatus,
/// Ledger timestamp at which `request_refund` was called, if applicable.
/// `MaybeU64::None` when no refund has been requested.
pub requested_at: MaybeU64,
/// Gross amount the refund was (or would be) computed from.
/// `0` when no refund breakdown has been recorded yet.
pub gross_amount: i128,
/// Payment-gateway processing fee deducted from the gross amount.
/// `0` when no refund breakdown has been recorded yet.
pub processing_fee: i128,
/// Flat network fee deducted from the gross amount.
/// `0` when no refund breakdown has been recorded yet.
pub network_fee: i128,
/// Net amount the payer received (or will receive).
/// `0` when no refund breakdown has been recorded yet.
pub net_amount: i128,
}

/// Storage keys for invoice contract state.
///
/// Used as keys for Soroban instance and persistent storage lookups. Variants
Expand Down Expand Up @@ -137,4 +186,11 @@ pub enum DataKey {
/// Fee breakdown of a processed refund: gross amount, processing fee,
/// network fee and the net amount transferred to the payer (#71).
RefundBreakdown(u64),
/// Admin-configured window (seconds) after payment within which a payer
/// may call request_refund. Zero means no limit (current behaviour).
RefundWindow,
/// Ledger timestamp at which request_refund was called for this invoice.
/// Stored so get_refund_status can surface it without re-reading the
/// full invoice history.
RefundRequestedAt(u64),
}
2 changes: 1 addition & 1 deletion contracts/invoice/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ pub use events::{
pub use invoice::StatusTransition;
pub use invoice::{
BatchInvoiceParams, DataKey, Invoice, InvoiceError, InvoiceStatus, MaybeAddress, MaybeBytes,
MAX_BATCH_EXPIRE, MAX_BATCH_SIZE,
MaybeU64, RefundStatus, MAX_BATCH_EXPIRE, MAX_BATCH_SIZE, MAX_LATE_FEE_BPS,
};
pub use refund::{
calculate_net_refund, refund_recipient, transfer_net_refund, verify_payment_state, NetRefund,
Expand Down
14 changes: 14 additions & 0 deletions contracts/invoice/tests/storage_key_uniqueness_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,15 @@ fn every_variant_serializes_uniquely() {
),
("RefundBreakdown(1)", xdr(&env, DataKey::RefundBreakdown(1))),
("RefundBreakdown(2)", xdr(&env, DataKey::RefundBreakdown(2))),
("RefundWindow", xdr(&env, DataKey::RefundWindow)),
(
"RefundRequestedAt(1)",
xdr(&env, DataKey::RefundRequestedAt(1)),
),
(
"RefundRequestedAt(2)",
xdr(&env, DataKey::RefundRequestedAt(2)),
),
]);
}

Expand All @@ -107,6 +116,10 @@ fn same_id_keyed_collections_stay_separate() {
("Invoice(7)", xdr(&env, DataKey::Invoice(7))),
("InvoiceHistory(7)", xdr(&env, DataKey::InvoiceHistory(7))),
("RefundBreakdown(7)", xdr(&env, DataKey::RefundBreakdown(7))),
(
"RefundRequestedAt(7)",
xdr(&env, DataKey::RefundRequestedAt(7)),
),
]);
}

Expand All @@ -123,5 +136,6 @@ fn unit_variants_are_distinct() {
("GraceWindow", xdr(&env, DataKey::GraceWindow)),
("PendingIndex", xdr(&env, DataKey::PendingIndex)),
("CreationCooldown", xdr(&env, DataKey::CreationCooldown)),
("RefundWindow", xdr(&env, DataKey::RefundWindow)),
]);
}
4 changes: 4 additions & 0 deletions crates/invoice-errors/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,5 +52,9 @@ declare_contract_error! {
// Appended for #71: refund fee arithmetic (`gross_amount * fee_bps`)
// overflowed, so the net payout cannot be computed.
ArithmeticOverflow = 28,
// Appended for #702: request_refund was called after the configurable
// refund window (paid_at + refund_window_secs) has elapsed. Zero means
// no limit, preserving the current behaviour.
RefundWindowClosed = 29,
}
}
1 change: 1 addition & 0 deletions crates/protocol-errors/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ fn invoice_description(e: InvoiceError) -> &'static str {
RefundTokenNotSet => "The invoice has no token to refund through.",
RefundFeeTooHigh => "The refund fee is too high.",
ArithmeticOverflow => "The calculation overflowed.",
RefundWindowClosed => "The refund window for this invoice has closed.",
}
}

Expand Down