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
32 changes: 32 additions & 0 deletions .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion payment_router/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]
crate-type = ["cdylib", "rlib"]

[features]
testutils = ["soroban-sdk/testutils"]
Expand Down
5 changes: 5 additions & 0 deletions payment_router/fuzz/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
target
corpus
artifacts
coverage
Cargo.lock
27 changes: 27 additions & 0 deletions payment_router/fuzz/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
[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

[patch.crates-io]
ethnum = { path = "../ethnum-patch" }

[profile.release]
debug = 1
88 changes: 88 additions & 0 deletions payment_router/fuzz/fuzz_targets/route_payments.rs
Original file line number Diff line number Diff line change
@@ -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<Payment>` 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<FuzzPayment>,
}

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<Address> = (0..NUM_SENDERS).map(|_| Address::generate(&env)).collect();
for sender in &senders {
sac.mint(sender, &SENDER_STARTING_BALANCE);
}
let recipients: Vec<Address> = (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);
});
Loading
Loading