diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 00000000..a69df4ba --- /dev/null +++ b/.github/workflows/check.yml @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 31d7f880..3420a5aa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 ``` diff --git a/contracts/invoice/src/entrypoints/admin.rs b/contracts/invoice/src/entrypoints/admin.rs index 4e773c15..20fb4137 100644 --- a/contracts/invoice/src/entrypoints/admin.rs +++ b/contracts/invoice/src/entrypoints/admin.rs @@ -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`. diff --git a/contracts/invoice/src/entrypoints/lifecycle.rs b/contracts/invoice/src/entrypoints/lifecycle.rs index ba6dedf0..bc9d3163 100644 --- a/contracts/invoice/src/entrypoints/lifecycle.rs +++ b/contracts/invoice/src/entrypoints/lifecycle.rs @@ -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, }; @@ -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, @@ -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 { + 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 = 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 diff --git a/contracts/invoice/src/invoice.rs b/contracts/invoice/src/invoice.rs index 61c9f5bc..5f5b8ed2 100644 --- a/contracts/invoice/src/invoice.rs +++ b/contracts/invoice/src/invoice.rs @@ -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; @@ -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` 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 @@ -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), } diff --git a/contracts/invoice/src/lib.rs b/contracts/invoice/src/lib.rs index 464385c7..abe35b1d 100644 --- a/contracts/invoice/src/lib.rs +++ b/contracts/invoice/src/lib.rs @@ -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, diff --git a/contracts/invoice/tests/storage_key_uniqueness_test.rs b/contracts/invoice/tests/storage_key_uniqueness_test.rs index 9df88579..dc2ab829 100644 --- a/contracts/invoice/tests/storage_key_uniqueness_test.rs +++ b/contracts/invoice/tests/storage_key_uniqueness_test.rs @@ -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)), + ), ]); } @@ -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)), + ), ]); } @@ -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)), ]); } diff --git a/crates/invoice-errors/src/lib.rs b/crates/invoice-errors/src/lib.rs index da17e24e..d9d14514 100644 --- a/crates/invoice-errors/src/lib.rs +++ b/crates/invoice-errors/src/lib.rs @@ -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, } } diff --git a/crates/protocol-errors/src/lib.rs b/crates/protocol-errors/src/lib.rs index 1e1229f7..89af98c4 100644 --- a/crates/protocol-errors/src/lib.rs +++ b/crates/protocol-errors/src/lib.rs @@ -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.", } }