From 73b87fad0296e754bf6c523dda861da11540d811 Mon Sep 17 00:00:00 2001 From: Muhammadcodes112 Date: Tue, 25 Aug 2026 02:41:52 -0700 Subject: [PATCH 1/4] Add DB-backed CORS origin validation with Redis caching (#501) --- .../migration.sql | 11 +++++++ stellar-payment-platform/prisma/schema.prisma | 11 +++++++ stellar-payment-platform/server.js | 13 +++++++- stellar-payment-platform/src/originCache.js | 30 +++++++++++++++++++ 4 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 stellar-payment-platform/prisma/migrations/20260825000000_add_approved_origins/migration.sql create mode 100644 stellar-payment-platform/src/originCache.js diff --git a/stellar-payment-platform/prisma/migrations/20260825000000_add_approved_origins/migration.sql b/stellar-payment-platform/prisma/migrations/20260825000000_add_approved_origins/migration.sql new file mode 100644 index 00000000..af60efbb --- /dev/null +++ b/stellar-payment-platform/prisma/migrations/20260825000000_add_approved_origins/migration.sql @@ -0,0 +1,11 @@ +-- CreateTable: approved_origins for dynamic CORS origin validation +CREATE TABLE "approved_origins" ( + "id" TEXT NOT NULL, + "origin" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "approved_origins_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex: unique constraint on origin +CREATE UNIQUE INDEX "approved_origins_origin_key" ON "approved_origins"("origin"); diff --git a/stellar-payment-platform/prisma/schema.prisma b/stellar-payment-platform/prisma/schema.prisma index 6ccfe7d4..739ad5a3 100644 --- a/stellar-payment-platform/prisma/schema.prisma +++ b/stellar-payment-platform/prisma/schema.prisma @@ -54,6 +54,17 @@ model Webhook { @@map("webhooks") } +// Approved merchant frontend origins allowed to call the API. CORS +// validation (see server.js) checks incoming Origin headers against this +// table, with results cached in Redis so most requests avoid a DB round trip. +model ApprovedOrigin { + id String @id @default(uuid()) + origin String @unique + createdAt DateTime @default(now()) @map("created_at") + + @@map("approved_origins") +} + // PaymentIntent model for storing requested payment registrations. These are // created via the bulk registration endpoint and persisted atomically using // `prisma.$transaction`. diff --git a/stellar-payment-platform/server.js b/stellar-payment-platform/server.js index a3a6eec9..e5788b50 100644 --- a/stellar-payment-platform/server.js +++ b/stellar-payment-platform/server.js @@ -61,6 +61,7 @@ const { USER_DATABASE, shouldFallbackToLocalRegistry, } = require('./src/utils'); +const { getCachedApprovedOrigins } = require('./src/originCache'); dotenv.config(); @@ -105,10 +106,20 @@ const allowedOrigins = [ ].filter(Boolean); const corsOptions = { - origin: (origin, callback) => { + origin: async (origin, callback) => { if (!origin || allowedOrigins.includes(origin)) { return callback(null, true); } + try { + const approvedOrigins = await getCachedApprovedOrigins(redisClient, () => + prisma.approvedOrigin.findMany({ select: { origin: true } }).then((rows) => rows.map((row) => row.origin)) + ); + if (approvedOrigins && approvedOrigins.includes(origin)) { + return callback(null, true); + } + } catch (err) { + logger.error(err, 'Failed to validate CORS origin against approved origins'); + } return callback(new Error('Not allowed by CORS')); }, methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], diff --git a/stellar-payment-platform/src/originCache.js b/stellar-payment-platform/src/originCache.js new file mode 100644 index 00000000..321fe0c8 --- /dev/null +++ b/stellar-payment-platform/src/originCache.js @@ -0,0 +1,30 @@ +const { logger } = require('./logger'); + +const APPROVED_ORIGINS_CACHE_TTL = 300; +const APPROVED_ORIGINS_CACHE_KEY = 'cors:approved-origins'; + +async function getCachedApprovedOrigins(redisClient, fetchFn) { + if (redisClient && redisClient.isReady) { + try { + const cached = await redisClient.get(APPROVED_ORIGINS_CACHE_KEY); + if (cached) return JSON.parse(cached); + } catch (err) { + logger.error('Error reading approved origins cache from Redis:', err); + } + } + + const result = await fetchFn(); + + if (result !== null && redisClient && redisClient.isReady) { + redisClient.setEx(APPROVED_ORIGINS_CACHE_KEY, APPROVED_ORIGINS_CACHE_TTL, JSON.stringify(result)) + .catch((err) => logger.error('Error saving approved origins cache to Redis:', err)); + } + + return result; +} + +module.exports = { + APPROVED_ORIGINS_CACHE_TTL, + APPROVED_ORIGINS_CACHE_KEY, + getCachedApprovedOrigins, +}; From 42d99dcb0a97b6e3c30eb3aa4215654b8aee4bf0 Mon Sep 17 00:00:00 2001 From: Muhammadcodes112 Date: Tue, 25 Aug 2026 03:12:06 -0700 Subject: [PATCH 2/4] Add cargo-fuzz target for route_payments (#527) --- .github/workflows/fuzz.yml | 32 +++++++ payment_router/Cargo.toml | 2 +- payment_router/fuzz/.gitignore | 5 ++ payment_router/fuzz/Cargo.toml | 24 +++++ .../fuzz/fuzz_targets/route_payments.rs | 88 +++++++++++++++++++ 5 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/fuzz.yml create mode 100644 payment_router/fuzz/.gitignore create mode 100644 payment_router/fuzz/Cargo.toml create mode 100644 payment_router/fuzz/fuzz_targets/route_payments.rs diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 00000000..5f3846ec --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -0,0 +1,32 @@ +name: Fuzz route_payments + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + fuzz: + name: cargo-fuzz route_payments + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./payment_router + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@nightly + + - name: Cache cargo registry and build artifacts + uses: Swatinem/rust-cache@v2 + with: + workspaces: ./payment_router/fuzz + + - name: Install cargo-fuzz + run: cargo install cargo-fuzz --force + + - name: Run route_payments fuzz target + run: cargo fuzz run route_payments -- -max_total_time=120 diff --git a/payment_router/Cargo.toml b/payment_router/Cargo.toml index d84eadfd..c212719a 100644 --- a/payment_router/Cargo.toml +++ b/payment_router/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [lib] -crate-type = ["cdylib"] +crate-type = ["cdylib", "rlib"] [features] testutils = ["soroban-sdk/testutils"] diff --git a/payment_router/fuzz/.gitignore b/payment_router/fuzz/.gitignore new file mode 100644 index 00000000..5c404b95 --- /dev/null +++ b/payment_router/fuzz/.gitignore @@ -0,0 +1,5 @@ +target +corpus +artifacts +coverage +Cargo.lock diff --git a/payment_router/fuzz/Cargo.toml b/payment_router/fuzz/Cargo.toml new file mode 100644 index 00000000..47681747 --- /dev/null +++ b/payment_router/fuzz/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "payment_router-fuzz" +version = "0.0.0" +publish = false +edition = "2021" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" +arbitrary = { version = "=1.3.2", features = ["derive"] } +soroban-sdk = { version = "20.0.0", features = ["testutils"] } +payment_router = { path = "..", features = ["testutils"] } + +[[bin]] +name = "route_payments" +path = "fuzz_targets/route_payments.rs" +test = false +doc = false +bench = false + +[profile.release] +debug = 1 diff --git a/payment_router/fuzz/fuzz_targets/route_payments.rs b/payment_router/fuzz/fuzz_targets/route_payments.rs new file mode 100644 index 00000000..7795898b --- /dev/null +++ b/payment_router/fuzz/fuzz_targets/route_payments.rs @@ -0,0 +1,88 @@ +#![no_main] + +//! Fuzz target for `PaymentRouter::route_payments` (issue #527). +//! +//! Feeds randomly generated, malformed, and massive `Payment` arrays to the +//! batch routing entry point. The contract is expected to fail gracefully +//! (return an `Err`) on invalid input rather than panic or trap — libFuzzer +//! treats any panic as a crash, so a clean `Result` either way is a pass. + +use arbitrary::Arbitrary; +use libfuzzer_sys::fuzz_target; +use payment_router::{Payment, PaymentRouter, PaymentRouterClient}; +use soroban_sdk::{testutils::Address as _, vec, Address, Env}; + +/// Cap the batch size so a single fuzz iteration stays fast; the underlying +/// `Vec` machinery is already exercised at whatever size libFuzzer +/// generates up to this bound. +const MAX_PAYMENTS: usize = 32; +const NUM_SENDERS: usize = 4; +const NUM_RECIPIENTS: usize = 4; +const SENDER_STARTING_BALANCE: i128 = i128::MAX / 4; + +// Fixed, sane admin configuration. The fuzz target is only concerned with +// malicious/malformed `Payment` arrays, not admin misconfiguration, so fee +// settings mirror the values used by the existing unit tests. +const FEE_BPS: i128 = 100; +const FEE_CAP: i128 = 1_000_000; +// Mirrors the contract's private `PaymentRouter::MAX_AMOUNT` constant, which +// isn't reachable from outside the crate. +const MAX_AMOUNT: i128 = 1_000_000_000_000_000; + +#[derive(Debug, Arbitrary)] +struct FuzzPayment { + amount: i128, + sender_idx: u8, + recipient_idx: u8, +} + +#[derive(Debug, Arbitrary)] +struct FuzzInput { + payments: Vec, +} + +fuzz_target!(|input: FuzzInput| { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + + let contract_id = env.register_contract(None, PaymentRouter); + let client = PaymentRouterClient::new(&env, &contract_id); + + if client + .try_initialize(&admin, &treasury, &FEE_BPS, &FEE_CAP, &MAX_AMOUNT) + .is_err() + { + return; + } + + let token_admin = Address::generate(&env); + let token_address = env.register_stellar_asset_contract(token_admin); + let sac = soroban_sdk::token::StellarAssetClient::new(&env, &token_address); + + let senders: Vec
= (0..NUM_SENDERS).map(|_| Address::generate(&env)).collect(); + for sender in &senders { + sac.mint(sender, &SENDER_STARTING_BALANCE); + } + let recipients: Vec
= (0..NUM_RECIPIENTS) + .map(|_| Address::generate(&env)) + .collect(); + + let mut payments = vec![&env]; + for fuzz_payment in input.payments.iter().take(MAX_PAYMENTS) { + let sender = &senders[fuzz_payment.sender_idx as usize % senders.len()]; + let recipient = &recipients[fuzz_payment.recipient_idx as usize % recipients.len()]; + payments.push_back(Payment { + sender: sender.clone(), + recipient: recipient.clone(), + token_address: token_address.clone(), + amount: fuzz_payment.amount, + }); + } + + // Only the absence of a panic/trap matters here — any `Err` is a graceful + // rejection, which is the behavior this fuzz target verifies. + let _ = client.try_route_payments(&payments); +}); From 6db56b8fb91929424607eed0cebb94ee358997e1 Mon Sep 17 00:00:00 2001 From: Muhammadcodes112 Date: Tue, 25 Aug 2026 03:12:16 -0700 Subject: [PATCH 3/4] Add Rustdoc for all public items in payment_router (#529) --- payment_router/src/lib.rs | 328 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 328 insertions(+) diff --git a/payment_router/src/lib.rs b/payment_router/src/lib.rs index a0d6bf13..b1e9b2f3 100644 --- a/payment_router/src/lib.rs +++ b/payment_router/src/lib.rs @@ -82,35 +82,62 @@ fn unpack_spending(packed: &BytesN<24>) -> (u64, i128) { // it directly continue to compile. All runtime code now uses the packed // BytesN<24> representation stored under DataKey::UserSpending. +/// A user's rolling 24-hour spending record. +/// +/// Retained purely so existing test snapshots that reference this type by +/// name keep compiling. Live contract state is stored as a packed +/// `BytesN<24>` (see `pack_spending` / `unpack_spending`); this struct is not +/// read from or written to storage at runtime. #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct UserSpending { + /// Unix timestamp (seconds) at which the 24-hour window last reset. pub last_reset_time: u64, + /// Total amount routed by the user since `last_reset_time`. pub accumulated_amount: i128, } +/// A single transfer instruction for use with [`PaymentRouter::route_payments`]. #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct Payment { + /// Address the funds are debited from. Must authorize the call. pub sender: Address, + /// Address the funds (minus the platform fee) are credited to. pub recipient: Address, + /// Contract ID of the token (or Stellar Asset Contract) being transferred. pub token_address: Address, + /// Amount to route, denominated in the token's smallest unit. Must be + /// positive and within the contract's configured min/max bounds. pub amount: i128, } +/// Storage keys for all contract instance and persistent data. #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub enum DataKey { + /// The current admin address. Admin, + /// Address that receives collected platform fees. PlatformTreasury, + /// Platform fee rate, in basis points (1/100th of a percent). FeeBps, + /// Upper bound on the fee taken from a single payment. FeeCap, + /// Minimum amount accepted by `route_payment` / `route_payments`, if set. MinLimit, + /// Whether routing is currently paused. Paused, + /// Maximum amount accepted by a single payment. MaxAmount, + /// Cumulative lifetime amount routed by a given sender. UserVolume(Address), + /// Packed 24-hour spending window for a given sender. UserSpending(Address), + /// Whether a given recipient address is blacklisted. Blacklist(Address), + /// Internal refund balance for a (user, token) pair, credited when a + /// direct transfer to the recipient fails. RefundBalance(Address, Address), } @@ -130,7 +157,9 @@ pub enum Error { AlreadyInitialized = 4, /// An admin-configured value (treasury, fee, admin) was read before `initialize`. NotInitialized = 5, + /// The contract is currently paused; routing calls are rejected until unpaused. Paused = 6, + /// A fee configuration value (basis points or cap) is out of the allowed range. InvalidFeeRate = 7, /// Sender and recipient addresses are the same (self-routing not allowed). InvalidRecipient = 8, @@ -140,6 +169,9 @@ pub enum Error { NoRefundAvailable = 10, } +/// Soroban contract that routes token payments between addresses while +/// collecting a configurable platform fee, enforcing per-user daily spending +/// limits, and supporting an admin-managed blacklist and pause switch. #[contract] pub struct PaymentRouter; @@ -374,6 +406,22 @@ impl PaymentRouter { /// One-time setup: records the admin and the initial fee configuration /// in instance storage. Must be called before `route_payment`. + /// + /// # Parameters + /// - `admin`: Address granted admin rights over the contract; must + /// authorize this call. + /// - `platform_treasury`: Address that receives collected platform fees. + /// - `fee_bps`: Platform fee rate, in basis points. + /// - `fee_cap`: Maximum fee (in the token's smallest unit) taken from a + /// single payment. + /// - `max_amount`: Maximum amount accepted by a single payment. + /// + /// # Returns + /// `Ok(())` on success, or `Err(Error::AlreadyInitialized)` if the + /// contract already has an admin set. + /// + /// # Panics + /// Panics if `admin` does not authorize the call. pub fn initialize( env: Env, admin: Address, @@ -406,6 +454,16 @@ impl PaymentRouter { } /// Updates the treasury address that receives the platform fee. Admin-only. + /// + /// # Parameters + /// - `new_treasury`: Address to receive platform fees going forward. + /// + /// # Returns + /// `Ok(())` on success, or `Err(Error::NotInitialized)` if the contract + /// has no admin set yet. + /// + /// # Panics + /// Panics if the current admin does not authorize the call. pub fn set_platform_treasury(env: Env, new_treasury: Address) -> Result<(), Error> { let admin = Self::require_admin(&env)?; admin.require_auth(); @@ -421,6 +479,17 @@ impl PaymentRouter { } /// Updates the fee basis points and fee cap. Admin-only. + /// + /// # Parameters + /// - `fee_bps`: New platform fee rate, in basis points. + /// - `fee_cap`: New maximum fee taken from a single payment. + /// + /// # Returns + /// `Ok(())` on success, or `Err(Error::NotInitialized)` if the contract + /// has no admin set yet. + /// + /// # Panics + /// Panics if the current admin does not authorize the call. pub fn set_fee_config_legacy(env: Env, fee_bps: i128, fee_cap: i128) -> Result<(), Error> { let admin = Self::require_admin(&env)?; admin.require_auth(); @@ -435,11 +504,31 @@ impl PaymentRouter { } /// Alias for `set_fee_config_legacy`. Admin-only. + /// + /// # Parameters + /// - `fee_bps`: New platform fee rate, in basis points. + /// - `fee_cap`: New maximum fee taken from a single payment. + /// + /// # Returns + /// See `set_fee_config_legacy`. + /// + /// # Panics + /// Panics if the current admin does not authorize the call. pub fn set_fee_config(env: Env, fee_bps: i128, fee_cap: i128) -> Result<(), Error> { Self::set_fee_config_legacy(env, fee_bps, fee_cap) } /// Updates the fee basis points. Admin-only. + /// + /// # Parameters + /// - `new_fee_bps`: New platform fee rate, in basis points. + /// + /// # Returns + /// `Ok(())` on success, or `Err(Error::NotInitialized)` if the contract + /// has no admin set yet. + /// + /// # Panics + /// Panics if the current admin does not authorize the call. pub fn set_fee_bps(env: Env, new_fee_bps: i128) -> Result<(), Error> { let admin = Self::require_admin(&env)?; admin.require_auth(); @@ -453,6 +542,17 @@ impl PaymentRouter { } /// Sets the minimum allowed routing amount. Admin-only. + /// + /// # Parameters + /// - `min_limit`: Smallest `amount` that `route_payment` / + /// `route_payments` will accept going forward. + /// + /// # Returns + /// `Ok(())` on success, or `Err(Error::NotInitialized)` if the contract + /// has no admin set yet. + /// + /// # Panics + /// Panics if the current admin does not authorize the call. pub fn set_min_limit(env: Env, min_limit: i128) -> Result<(), Error> { let admin = Self::require_admin(&env)?; admin.require_auth(); @@ -466,11 +566,29 @@ impl PaymentRouter { } /// Returns the current protocol fee percentage in basis points. + /// + /// # Returns + /// The configured `fee_bps`, or `0` if the contract has not been + /// initialized. + /// + /// # Panics + /// Does not panic. pub fn get_fee(env: Env) -> i128 { env.storage().instance().get(&DataKey::FeeBps).unwrap_or(0) } /// Pauses or unpauses the payment router. Admin-only. + /// + /// # Parameters + /// - `paused`: `true` to reject `route_payment` / `route_payments` + /// calls, `false` to allow them again. + /// + /// # Returns + /// `Ok(())` on success, or `Err(Error::NotInitialized)` if the contract + /// has no admin set yet. + /// + /// # Panics + /// Panics if the current admin does not authorize the call. pub fn set_pause(env: Env, paused: bool) -> Result<(), Error> { let admin = Self::require_admin(&env)?; admin.require_auth(); @@ -487,11 +605,26 @@ impl PaymentRouter { } /// Alias for `set_pause`. Admin-only. + /// + /// # Parameters + /// - `paused`: `true` to reject routing calls, `false` to allow them. + /// + /// # Returns + /// See `set_pause`. + /// + /// # Panics + /// Panics if the current admin does not authorize the call. pub fn set_paused(env: Env, paused: bool) -> Result<(), Error> { Self::set_pause(env, paused) } /// Returns whether the contract is currently paused. + /// + /// # Returns + /// `true` if paused, `false` if unpaused or not yet initialized. + /// + /// # Panics + /// Does not panic. pub fn is_paused(env: Env) -> bool { env.storage() .instance() @@ -500,6 +633,16 @@ impl PaymentRouter { } /// Returns the cumulative amount a given sender has routed through the contract. + /// + /// # Parameters + /// - `user`: Sender address to look up. + /// + /// # Returns + /// The lifetime routed volume for `user`, or `0` if they have never + /// routed a payment. + /// + /// # Panics + /// Does not panic. pub fn get_user_volume(env: Env, user: Address) -> i128 { env.storage() .persistent() @@ -508,6 +651,17 @@ impl PaymentRouter { } /// Adds an address to the blacklist. Admin-only. + /// + /// # Parameters + /// - `address`: Address to blacklist; subsequent payments to it as a + /// recipient will be rejected. + /// + /// # Returns + /// `Ok(())` on success, or `Err(Error::NotInitialized)` if the contract + /// has no admin set yet. + /// + /// # Panics + /// Panics if the current admin does not authorize the call. pub fn blacklist_address(env: Env, address: Address) -> Result<(), Error> { let admin = Self::require_admin(&env)?; admin.require_auth(); @@ -525,6 +679,16 @@ impl PaymentRouter { } /// Removes an address from the blacklist. Admin-only. + /// + /// # Parameters + /// - `address`: Address to remove from the blacklist. + /// + /// # Returns + /// `Ok(())` on success, or `Err(Error::NotInitialized)` if the contract + /// has no admin set yet. + /// + /// # Panics + /// Panics if the current admin does not authorize the call. pub fn unblacklist_address(env: Env, address: Address) -> Result<(), Error> { let admin = Self::require_admin(&env)?; admin.require_auth(); @@ -537,6 +701,15 @@ impl PaymentRouter { } /// Returns whether an address is blacklisted. + /// + /// # Parameters + /// - `address`: Address to check. + /// + /// # Returns + /// `true` if `address` is blacklisted, `false` otherwise. + /// + /// # Panics + /// Does not panic. pub fn is_blacklisted(env: Env, address: Address) -> bool { env.storage() .persistent() @@ -546,6 +719,16 @@ impl PaymentRouter { /// Returns the effective fee_bps for a sender after applying any /// volume-based tiered discount. + /// + /// # Parameters + /// - `sender`: Address whose discounted fee rate to compute. + /// + /// # Returns + /// The configured `fee_bps`, halved if `sender`'s lifetime volume + /// exceeds the tiered-discount threshold, or `0` if not initialized. + /// + /// # Panics + /// Does not panic. pub fn get_effective_fee_bps(env: Env, sender: Address) -> i128 { let fee_bps: i128 = env.storage().instance().get(&DataKey::FeeBps).unwrap_or(0); let user_volume = Self::get_user_volume(env.clone(), sender); @@ -557,6 +740,15 @@ impl PaymentRouter { } /// Set a new admin. Gated by the current admin if one exists. + /// + /// # Parameters + /// - `new_admin`: Address to install as the new admin. + /// + /// # Returns + /// Always `Ok(())`. + /// + /// # Panics + /// Panics if an admin is already set and it does not authorize the call. pub fn set_admin(env: Env, new_admin: Address) -> Result<(), Error> { if let Some(admin) = env .storage() @@ -574,6 +766,16 @@ impl PaymentRouter { } /// Transfers admin rights to a new address. Requires the current admin's authorization. + /// + /// # Parameters + /// - `new_admin`: Address to become the new admin. + /// + /// # Returns + /// `Ok(())` on success, or `Err(Error::NotInitialized)` if the contract + /// has no admin set yet. + /// + /// # Panics + /// Panics if the current admin does not authorize the call. pub fn transfer_admin(env: Env, new_admin: Address) -> Result<(), Error> { let current_admin = Self::require_admin(&env)?; current_admin.require_auth(); @@ -586,6 +788,18 @@ impl PaymentRouter { } /// Recovers tokens accidentally sent directly to the contract address. Admin-only. + /// + /// # Parameters + /// - `token`: Contract ID of the token to recover. + /// - `amount`: Amount to transfer from the contract's balance to the admin. + /// + /// # Returns + /// `Ok(())` on success, or `Err(Error::NotInitialized)` if the contract + /// has no admin set yet. + /// + /// # Panics + /// Panics if the current admin does not authorize the call, or if the + /// token transfer fails (e.g. the contract's balance is below `amount`). pub fn recover_tokens(env: Env, token: Address, amount: i128) -> Result<(), Error> { let admin = Self::require_admin(&env)?; admin.require_auth(); @@ -598,11 +812,41 @@ impl PaymentRouter { } /// Records a token as supported (no-op; routing accepts any token contract ID). + /// + /// # Parameters + /// - `_token`: Ignored; present for API compatibility. + /// + /// # Returns + /// Always `Ok(())`. + /// + /// # Panics + /// Does not panic. pub fn add_supported_token(_env: Env, _token: Address) -> Result<(), Error> { Ok(()) } /// Routes a payment from a sender to a recipient, deducting a platform fee. + /// + /// # Parameters + /// - `sender`: Address the funds are debited from; must authorize the call. + /// - `recipient`: Address to receive the funds (minus the platform fee). + /// - `token_address`: Contract ID of the token being transferred. + /// - `amount`: Amount to route, in the token's smallest unit. Must be + /// positive and within the configured min/max and daily-limit bounds. + /// + /// # Returns + /// `Ok(())` on success. Returns `Err(Error::Paused)` if routing is + /// paused, `Err(Error::NotInitialized)` if the contract has no admin + /// set, `Err(Error::InvalidRecipient)` if `sender == recipient`, + /// `Err(Error::Blacklisted)` if `recipient` is blacklisted, + /// `Err(Error::LimitExceeded)` if `amount` is outside the configured + /// bounds or exceeds the sender's remaining daily limit, or + /// `Err(Error::InsufficientBalance)` if `sender`'s token balance is + /// below `amount`. + /// + /// # Panics + /// Panics if `sender` does not authorize the call, or if the underlying + /// token transfer to `platform_treasury` fails. pub fn route_payment( env: Env, sender: Address, @@ -630,6 +874,20 @@ impl PaymentRouter { /// Routes multiple payments in a single transaction. If any payment fails, /// the entire batch is reverted atomically. + /// + /// # Parameters + /// - `payments`: Batch of transfer instructions to apply in order. See + /// [`Payment`] for per-item constraints. + /// + /// # Returns + /// `Ok(())` if every payment in the batch succeeds. Returns the first + /// error encountered (see `route_payment` for the possible `Err` + /// variants and their causes) if any payment fails; the Soroban host + /// reverts all storage and balance changes from the batch in that case. + /// + /// # Panics + /// Panics if any payment's `sender` does not authorize the call, or if + /// a token transfer to `platform_treasury` fails. pub fn route_payments(env: Env, payments: Vec) -> Result<(), Error> { if Self::is_paused(env.clone()) { return Err(Error::Paused); @@ -654,11 +912,39 @@ impl PaymentRouter { } /// Returns the available internal refund balance for a user and token. + /// + /// # Parameters + /// - `user`: Address whose refund balance to look up. + /// - `token`: Contract ID of the token. + /// + /// # Returns + /// The refundable balance for `(user, token)`, or `0` if none is held. + /// + /// # Panics + /// Does not panic. pub fn get_refund_balance(env: Env, user: Address, token: Address) -> i128 { Self::get_refund_balance_internal(&env, &user, &token) } /// Withdraws a specific amount from the user's internal refund balance. + /// + /// A refund balance accrues when a `route_payment` / `route_payments` + /// transfer to the recipient fails (e.g. missing trustline) and the + /// funds are held by the contract on the sender's behalf instead. + /// + /// # Parameters + /// - `user`: Address withdrawing funds; must authorize the call. + /// - `token`: Contract ID of the token to withdraw. + /// - `amount`: Amount to withdraw. Must be positive and not exceed the + /// current refund balance. + /// + /// # Returns + /// `Ok(())` on success, or `Err(Error::NoRefundAvailable)` if `amount` + /// is zero, negative, or greater than the available balance. + /// + /// # Panics + /// Panics if `user` does not authorize the call, or if the underlying + /// token transfer fails. pub fn withdraw_refund( env: Env, user: Address, @@ -703,6 +989,18 @@ impl PaymentRouter { } /// Claims and withdraws the entire available refund balance for a user and token. + /// + /// # Parameters + /// - `user`: Address withdrawing funds; must authorize the call. + /// - `token`: Contract ID of the token to withdraw. + /// + /// # Returns + /// `Ok(amount)` with the amount withdrawn, or + /// `Err(Error::NoRefundAvailable)` if the refund balance is zero. + /// + /// # Panics + /// Panics if `user` does not authorize the call, or if the underlying + /// token transfer fails. pub fn claim_all_refunds(env: Env, user: Address, token: Address) -> Result { user.require_auth(); @@ -716,6 +1014,18 @@ impl PaymentRouter { } /// Admin-only emergency withdrawal of tokens held by this contract. + /// + /// # Parameters + /// - `token`: Contract ID of the token to withdraw. + /// - `amount`: Amount to transfer from the contract's balance to the admin. + /// + /// # Returns + /// `Ok(())` on success, or `Err(Error::NotInitialized)` if the contract + /// has no admin set yet. + /// + /// # Panics + /// Panics if the current admin does not authorize the call, or if the + /// token transfer fails (e.g. the contract's balance is below `amount`). pub fn emergency_withdraw(env: Env, token: Address, amount: i128) -> Result<(), Error> { let admin = Self::require_admin(&env)?; admin.require_auth(); @@ -728,6 +1038,18 @@ impl PaymentRouter { } /// Replaces this contract's WASM with a previously uploaded version. Admin-only. + /// + /// # Parameters + /// - `new_wasm_hash`: Hash of a WASM blob previously uploaded to the + /// network, to install as this contract's new executable. + /// + /// # Returns + /// `Ok(())` on success, or `Err(Error::NotInitialized)` if the contract + /// has no admin set yet. + /// + /// # Panics + /// Panics if the current admin does not authorize the call, or if + /// `new_wasm_hash` does not reference a previously uploaded WASM blob. pub fn upgrade(env: Env, new_wasm_hash: BytesN<32>) -> Result<(), Error> { let admin = Self::require_admin(&env)?; admin.require_auth(); @@ -737,6 +1059,12 @@ impl PaymentRouter { } /// Returns the contract version. + /// + /// # Returns + /// The contract's version number, currently `1`. + /// + /// # Panics + /// Does not panic. pub fn version(_env: Env) -> u32 { Self::VERSION } From a37b21412d22841dd1c77b7e860a23da8f8d2490 Mon Sep 17 00:00:00 2001 From: Muhammadcodes112 Date: Thu, 3 Sep 2026 11:12:17 -0700 Subject: [PATCH 4/4] Fix cargo-fuzz build: apply the ethnum patch to the fuzz crate too payment_router/fuzz is its own manifest root (cargo-fuzz convention), so the [patch.crates-io] override in the parent payment_router/Cargo.toml that swaps in the locally-vendored, transmute-bug-fixed ethnum doesn't carry over. Without it, the fuzz build pulls plain ethnum 1.5.0 from crates.io and fails to compile under the current rustc (E0512: cannot transmute between types of different sizes). Mirroring the same patch in fuzz/Cargo.toml fixes the cargo-fuzz route_payments CI check. --- payment_router/fuzz/Cargo.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/payment_router/fuzz/Cargo.toml b/payment_router/fuzz/Cargo.toml index 47681747..2865e2d1 100644 --- a/payment_router/fuzz/Cargo.toml +++ b/payment_router/fuzz/Cargo.toml @@ -20,5 +20,8 @@ test = false doc = false bench = false +[patch.crates-io] +ethnum = { path = "../ethnum-patch" } + [profile.release] debug = 1