diff --git a/.gitignore b/.gitignore
index f1f90bde4..71c49e6c3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -49,6 +49,7 @@ compile_commands.json
/src/main/StellarCoreVersion.cpp
/src/main/XDRFilesSha256.cpp
/src/rust/soroban/tmp
+/src/rust/.soroban-revs
/src/rust/src/dep-trees/*-actual.txt
/src/testdata/*
diff --git a/Builds/VisualStudio/build_rust.bat b/Builds/VisualStudio/build_rust.bat
index 103bff104..2e0b6c920 100644
--- a/Builds/VisualStudio/build_rust.bat
+++ b/Builds/VisualStudio/build_rust.bat
@@ -40,6 +40,7 @@ set LATEST_P=26
rem ---- Accumulators for final rustc link flags ----
set "EXTERNS="
set "LPATHS="
+set "ALL_REVS="
set "SOURCE_STAMP=.source-rev"
rem ---- Build protocols MIN_P..MAX_P ----
@@ -52,6 +53,7 @@ for /l %%P in (%MIN_P%,1,%MAX_P%) do (
rem -- Resolve current submodule rev --
set "current_rev="
for /f %%R in ('git -C "!proto_dir!" rev-parse HEAD 2^>nul') do set "current_rev=%%R"
+ set "ALL_REVS=!ALL_REVS!p%%P-!current_rev:~0,12!_"
rem -- Compare stamp to decide if cargo needs to run --
set "stamp_ok="
@@ -125,9 +127,25 @@ rem Clear RUSTFLAGS so that metadata from soroban-protocol builds above does
rem not leak into the stellar-core build and cause cargo to invalidate its
rem fingerprints on the next run (where the soroban builds may be skipped).
set "RUSTFLAGS="
+
+rem Write submodule revisions to a file that build.rs watches via
+rem cargo:rerun-if-changed. Only update the file when content actually
+rem changes so that the mtime (which cargo uses for freshness) stays
+rem stable across no-op builds.
+set "REVS_FILE=%project_dir%\src\rust\.soroban-revs"
+set "revs_changed="
+if exist "!REVS_FILE!" (
+ set "saved_revs="
+ set /p saved_revs=<"!REVS_FILE!"
+ if not "!saved_revs!"=="!ALL_REVS!" set "revs_changed=1"
+) else (
+ set "revs_changed=1"
+)
+if defined revs_changed >"!REVS_FILE!" echo(!ALL_REVS!
+
rem Always invoke cargo here: cargo's own incremental-build tracking will
-rem no-op quickly when nothing changed, and the submodule-stamp mechanism
-rem above does not detect changes to local Rust sources (src\rust\src\*.rs).
+rem no-op quickly when nothing changed, and the .soroban-revs file
+rem (tracked via build.rs) forces a rebuild when submodules change.
echo Building stellar-core Rust library...
%set_linker_flags% & cd /d "%project_dir%" & cargo +%version% rustc %release_profile% --package stellar-core --locked %features% --target-dir "%out_dir%\target" -- %EXTERNS% %LPATHS%
diff --git a/Builds/VisualStudio/stellar-core.vcxproj b/Builds/VisualStudio/stellar-core.vcxproj
index 1875ac73b..b17ed7851 100644
--- a/Builds/VisualStudio/stellar-core.vcxproj
+++ b/Builds/VisualStudio/stellar-core.vcxproj
@@ -464,7 +464,6 @@ exit /b 0
-
@@ -943,7 +942,6 @@ exit /b 0
-
diff --git a/Builds/VisualStudio/stellar-core.vcxproj.filters b/Builds/VisualStudio/stellar-core.vcxproj.filters
index 989423e41..640e26a0e 100644
--- a/Builds/VisualStudio/stellar-core.vcxproj.filters
+++ b/Builds/VisualStudio/stellar-core.vcxproj.filters
@@ -1275,9 +1275,6 @@
bucket
-
- bucket
-
overlay\tests
@@ -2391,9 +2388,6 @@
main
-
- bucket
-
overlay
diff --git a/scripts/run_apply_load_matrix.py b/scripts/run_apply_load_matrix.py
new file mode 100644
index 000000000..2f7bf908d
--- /dev/null
+++ b/scripts/run_apply_load_matrix.py
@@ -0,0 +1,440 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import argparse
+import csv
+import hashlib
+import re
+import shutil
+import subprocess
+import sys
+import tempfile
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from pathlib import Path
+
+
+SCRIPT_DIR = Path(__file__).resolve().parent
+DEFAULT_STELLAR_CORE_BIN = SCRIPT_DIR.parent / "src" / "stellar-core"
+DEFAULT_TEMPLATE_CONFIG = SCRIPT_DIR.parent / "docs" / "apply-load-benchmark-sac.cfg"
+DEFAULT_OUTPUT_ROOT = Path.home() / "apply-load"
+APPLY_LOAD_NUM_LEDGERS = 200
+
+FLOAT_RE = r"([-+]?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)"
+RESULT_PATTERNS = {
+ "median_time_ms": re.compile(rf"p50 close time:\s+{FLOAT_RE}\s+ms"),
+ "p95_time_ms": re.compile(rf"p95 close time:\s+{FLOAT_RE}\s+ms"),
+ "p99_time_ms": re.compile(rf"p99 close time:\s+{FLOAT_RE}\s+ms"),
+}
+
+
+@dataclass(frozen=True, slots=True)
+class Scenario:
+ model_tx: str
+ tx_count: int
+ thread_count: int
+ time_writes: bool = True
+ disable_metrics: bool = True
+ sac_batch_size: int = 1
+
+ def __post_init__(self) -> None:
+ if self.sac_batch_size <= 0:
+ raise ValueError("sac_batch_size must be positive")
+
+ if self.model_tx == "sac":
+ if self.sac_batch_size <= 0:
+ raise ValueError(
+ f"Scenario '{self.identifier()}' must define a positive SAC batch size"
+ )
+ elif self.sac_batch_size != 1:
+ raise ValueError(
+ "sac_batch_size can only differ from 1 for model_tx='sac'"
+ )
+
+ def identifier(self) -> str:
+ parts = [self.model_tx, f"TX={self.tx_count}", f"T={self.thread_count}"]
+ if not self.time_writes:
+ parts.append("TW=0")
+ if not self.disable_metrics:
+ parts.append("DM=0")
+ if self.model_tx == "sac" and self.sac_batch_size != 1:
+ parts.append(f"B={self.sac_batch_size}")
+ return ",".join(parts)
+
+ def slug(self) -> str:
+ return re.sub(r"[^a-z0-9]+", "-", self.identifier().lower()).strip("-")
+
+ def summary(self) -> str:
+ return self.identifier()
+
+
+SCENARIOS: tuple[Scenario, ...] = (
+ Scenario(
+ model_tx="sac",
+ tx_count=6400,
+ thread_count=1,
+ ),
+ Scenario(
+ model_tx="sac",
+ tx_count=6400,
+ thread_count=8,
+ ),
+ Scenario(
+ model_tx="custom_token",
+ tx_count=3000,
+ thread_count=1,
+ ),
+ Scenario(
+ model_tx="custom_token",
+ tx_count=3000,
+ thread_count=8,
+ ),
+ Scenario(
+ model_tx="soroswap",
+ tx_count=1600,
+ thread_count=1,
+ ),
+ Scenario(
+ model_tx="soroswap",
+ tx_count=1600,
+ thread_count=8,
+ ),
+)
+
+
+def validate_scenarios(scenarios: tuple[Scenario, ...]) -> None:
+ seen_identifiers: set[str] = set()
+ for scenario in scenarios:
+ identifier = scenario.identifier()
+ if identifier in seen_identifiers:
+ raise ValueError(f"Duplicate scenario identifier: {identifier}")
+ seen_identifiers.add(identifier)
+
+ if scenario.model_tx != "sac":
+ continue
+
+ if scenario.tx_count % scenario.sac_batch_size != 0:
+ raise ValueError(
+ "Invalid SAC scenario "
+ f"{identifier}: TX must be divisible by B"
+ )
+
+ sac_tx_envelopes = scenario.tx_count // scenario.sac_batch_size
+ if sac_tx_envelopes < scenario.thread_count:
+ raise ValueError(
+ "Invalid SAC scenario "
+ f"{identifier}: TX / B must be at least T"
+ )
+
+ if scenario.sac_batch_size > 1 and sac_tx_envelopes % scenario.thread_count != 0:
+ raise ValueError(
+ "Invalid SAC scenario "
+ f"{identifier}: TX / B must be divisible by T when B > 1"
+ )
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(
+ description="Run a fixed matrix of apply-load scenarios and emit a CSV summary.",
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+ )
+ parser.add_argument(
+ "--stellar-core-bin",
+ type=Path,
+ default=DEFAULT_STELLAR_CORE_BIN,
+ help="Path to the stellar-core executable to run.",
+ )
+ parser.add_argument(
+ "--template-config",
+ type=Path,
+ default=DEFAULT_TEMPLATE_CONFIG,
+ help="Path to the benchmark apply-load template config.",
+ )
+ parser.add_argument(
+ "--output-root",
+ type=Path,
+ default=DEFAULT_OUTPUT_ROOT,
+ help="Directory where apply-load// outputs should be written.",
+ )
+ parser.add_argument(
+ "--build-tag",
+ help="Optional build tag to embed in the run identifier. Defaults to a hash of `stellar-core version` output.",
+ )
+ return parser.parse_args()
+
+
+def bool_literal(value: bool) -> str:
+ return "true" if value else "false"
+
+
+def quoted(value: str) -> str:
+ return f'"{value}"'
+
+
+def sanitize_tag(tag: str) -> str:
+ cleaned = re.sub(r"[^a-zA-Z0-9._-]+", "-", tag.strip()).strip("-._")
+ if not cleaned:
+ raise ValueError("Build tag is empty after sanitization")
+ return cleaned.lower()
+
+
+def run_command(command: list[str], *, cwd: Path) -> subprocess.CompletedProcess[str]:
+ return subprocess.run(
+ command,
+ cwd=cwd,
+ text=True,
+ capture_output=True,
+ check=False,
+ )
+
+
+def get_version_string(stellar_core_bin: Path) -> str:
+ result = run_command([str(stellar_core_bin), "version"], cwd=stellar_core_bin.parent)
+ if result.returncode != 0:
+ raise RuntimeError(
+ "Failed to run `stellar-core version`:\n"
+ f"stdout:\n{result.stdout}\n"
+ f"stderr:\n{result.stderr}"
+ )
+ version_parts = []
+ if result.stderr.strip():
+ version_parts.append(result.stderr.strip())
+ if result.stdout.strip():
+ version_parts.append(result.stdout.strip())
+ version_text = "\n".join(version_parts)
+ if not version_text:
+ raise RuntimeError("`stellar-core version` produced empty output")
+ return version_text
+
+
+def derive_build_tag(version_text: str, user_build_tag: str | None) -> str:
+ if user_build_tag:
+ return sanitize_tag(user_build_tag)
+ version_hash = hashlib.sha256(version_text.encode("utf-8")).hexdigest()[:12]
+ return version_hash
+
+
+def create_run_id(build_tag: str) -> str:
+ timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
+ return f"{build_tag}-{timestamp}"
+
+
+def read_template_config(template_config: Path) -> str:
+ try:
+ return template_config.read_text(encoding="utf-8")
+ except FileNotFoundError as exc:
+ raise FileNotFoundError(f"Template config not found: {template_config}") from exc
+
+
+def apply_overrides(template_text: str, overrides: dict[str, str]) -> str:
+ lines = template_text.splitlines()
+ seen_keys: set[str] = set()
+ rendered_lines: list[str] = []
+ key_pattern = re.compile(r"^(\s*)([A-Z0-9_]+)\s*=.*$")
+ section_pattern = re.compile(r"^\s*\[[^\]]+\]\s*$")
+ first_section_index: int | None = None
+
+ for line in lines:
+ if first_section_index is None and section_pattern.match(line):
+ first_section_index = len(rendered_lines)
+
+ match = key_pattern.match(line)
+ if match:
+ indent, key = match.groups()
+ if key in overrides:
+ rendered_lines.append(f"{indent}{key} = {overrides[key]}")
+ seen_keys.add(key)
+ continue
+ rendered_lines.append(line)
+
+ missing_keys = [key for key in overrides if key not in seen_keys]
+ if missing_keys:
+ insertion_lines = ["# Overrides added by run_apply_load_matrix.py"]
+ insertion_lines.extend(f"{key} = {overrides[key]}" for key in missing_keys)
+
+ if first_section_index is None:
+ if rendered_lines and rendered_lines[-1] != "":
+ rendered_lines.append("")
+ rendered_lines.extend(insertion_lines)
+ else:
+ if first_section_index > 0 and rendered_lines[first_section_index - 1] != "":
+ insertion_lines.insert(0, "")
+ first_section_index += 1
+ insertion_lines.append("")
+ rendered_lines[first_section_index:first_section_index] = insertion_lines
+
+ return "\n".join(rendered_lines) + "\n"
+
+
+def build_config_text(template_text: str, scenario: Scenario, log_name: str) -> str:
+ overrides = {
+ "APPLY_LOAD_MODEL_TX": quoted(scenario.model_tx),
+ "APPLY_LOAD_MAX_SOROBAN_TX_COUNT": str(scenario.tx_count),
+ "APPLY_LOAD_LEDGER_MAX_DEPENDENT_TX_CLUSTERS": str(scenario.thread_count),
+ "APPLY_LOAD_TIME_WRITES": bool_literal(scenario.time_writes),
+ "DISABLE_SOROBAN_METRICS_FOR_TESTING": bool_literal(scenario.disable_metrics),
+ "APPLY_LOAD_NUM_LEDGERS": str(APPLY_LOAD_NUM_LEDGERS),
+ "LOG_FILE_PATH": quoted(log_name),
+ }
+ if scenario.model_tx == "sac":
+ overrides["APPLY_LOAD_BATCH_SAC_COUNT"] = str(scenario.sac_batch_size)
+ return apply_overrides(template_text, overrides)
+
+
+def parse_benchmark_results(log_path: Path) -> dict[str, float]:
+ log_text = log_path.read_text(encoding="utf-8")
+ parsed: dict[str, float] = {}
+ for field_name, pattern in RESULT_PATTERNS.items():
+ matches = pattern.findall(log_text)
+ if not matches:
+ raise RuntimeError(
+ f"Could not find `{field_name}` in benchmark log {log_path}"
+ )
+ parsed[field_name] = float(matches[-1])
+ return parsed
+
+
+def write_csv_header(results_csv: Path) -> None:
+ with results_csv.open("w", newline="", encoding="utf-8") as output_file:
+ writer = csv.DictWriter(
+ output_file,
+ fieldnames=["scenario", "median_time_ms", "p95_time_ms", "p99_time_ms"],
+ )
+ writer.writeheader()
+
+
+def append_csv_row(results_csv: Path, row: dict[str, str | float]) -> None:
+ with results_csv.open("a", newline="", encoding="utf-8") as output_file:
+ writer = csv.DictWriter(
+ output_file,
+ fieldnames=["scenario", "median_time_ms", "p95_time_ms", "p99_time_ms"],
+ )
+ writer.writerow(row)
+
+
+def ensure_inputs(stellar_core_bin: Path, template_config: Path) -> tuple[Path, Path]:
+ stellar_core_bin = stellar_core_bin.expanduser().resolve()
+ template_config = template_config.expanduser().resolve()
+
+ if not stellar_core_bin.exists():
+ raise FileNotFoundError(f"stellar-core binary not found: {stellar_core_bin}")
+ if not stellar_core_bin.is_file():
+ raise FileNotFoundError(f"stellar-core path is not a file: {stellar_core_bin}")
+ if not template_config.exists():
+ raise FileNotFoundError(f"Template config not found: {template_config}")
+
+ return stellar_core_bin, template_config
+
+
+def run_scenario(
+ scenario_index: int,
+ scenario: Scenario,
+ *,
+ stellar_core_bin: Path,
+ template_text: str,
+ run_id: str,
+ logs_dir: Path,
+) -> dict[str, float]:
+ log_name = f"{run_id}-{scenario_index:02d}-{scenario.slug()}.log"
+ with tempfile.TemporaryDirectory(prefix=f"apply-load-{scenario.slug()}-") as temp_dir:
+ work_dir = Path(temp_dir)
+ config_text = build_config_text(template_text, scenario, log_name)
+ config_path = work_dir / "apply-load.cfg"
+ config_path.write_text(config_text, encoding="utf-8")
+
+ print(f"Running {scenario.summary()}")
+ result = run_command(
+ [str(stellar_core_bin), "--conf", str(config_path), "apply-load"],
+ cwd=work_dir,
+ )
+
+ scenario_log = work_dir / log_name
+ if scenario_log.exists():
+ shutil.copy2(scenario_log, logs_dir / log_name)
+
+ if result.returncode != 0:
+ raise RuntimeError(
+ f"Scenario '{scenario.identifier()}' failed with exit code {result.returncode}.\n"
+ f"stdout:\n{result.stdout}\n"
+ f"stderr:\n{result.stderr}"
+ )
+
+ if not scenario_log.exists():
+ raise RuntimeError(
+ f"Scenario '{scenario.identifier()}' completed but did not produce log file {log_name}"
+ )
+
+ return parse_benchmark_results(scenario_log)
+
+
+def main() -> int:
+ args = parse_args()
+
+ try:
+ stellar_core_bin, template_config = ensure_inputs(
+ args.stellar_core_bin, args.template_config
+ )
+ scenarios = SCENARIOS
+ validate_scenarios(scenarios)
+ version_text = get_version_string(stellar_core_bin)
+ build_tag = derive_build_tag(version_text, args.build_tag)
+ run_id = create_run_id(build_tag)
+ output_root = args.output_root.expanduser().resolve()
+ run_dir = output_root / run_id
+ logs_dir = run_dir / "logs"
+ results_csv = run_dir / "results.csv"
+ stamp_path = run_dir / "stamp"
+ template_text = read_template_config(template_config)
+ except Exception as exc:
+ print(f"Error: {exc}", file=sys.stderr)
+ return 1
+
+ try:
+ logs_dir.mkdir(parents=True, exist_ok=False)
+ except FileExistsError:
+ print(f"Error: run directory already exists: {run_dir}", file=sys.stderr)
+ return 1
+
+ stamp_path.write_text(version_text + "\n\n" + f"Benchmark ledgers={APPLY_LOAD_NUM_LEDGERS}", encoding="utf-8")
+ write_csv_header(results_csv)
+
+ print(f"Run ID: {run_id}")
+ print(f"Version stamp: {stamp_path}")
+ print(f"Results CSV: {results_csv}")
+
+ try:
+ for scenario_index, scenario in enumerate(scenarios, start=1):
+ metrics = run_scenario(
+ scenario_index,
+ scenario,
+ stellar_core_bin=stellar_core_bin,
+ template_text=template_text,
+ run_id=run_id,
+ logs_dir=logs_dir,
+ )
+ append_csv_row(
+ results_csv,
+ {
+ "scenario": scenario.summary(),
+ "median_time_ms": metrics["median_time_ms"],
+ "p95_time_ms": metrics["p95_time_ms"],
+ "p99_time_ms": metrics["p99_time_ms"],
+ },
+ )
+ print(
+ "Captured "
+ f"median={metrics['median_time_ms']}ms, "
+ f"p95={metrics['p95_time_ms']}ms, "
+ f"p99={metrics['p99_time_ms']}ms"
+ )
+ except Exception as exc:
+ print(f"Error: {exc}", file=sys.stderr)
+ print(f"Partial outputs retained in {run_dir}", file=sys.stderr)
+ return 1
+
+ print(f"Completed {len(scenarios)} scenario(s). Outputs written to {run_dir}")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
\ No newline at end of file
diff --git a/src/main/Config.cpp b/src/main/Config.cpp
index f60ec2688..13abb8a51 100644
--- a/src/main/Config.cpp
+++ b/src/main/Config.cpp
@@ -439,8 +439,13 @@ parseApplyLoadModelTx(ConfigItem const& item)
{
return ApplyLoadModelTx::CUSTOM_TOKEN;
}
+ if (modelTx == "soroswap")
+ {
+ return ApplyLoadModelTx::SOROSWAP;
+ }
throw std::invalid_argument(
- "invalid 'APPLY_LOAD_MODEL_TX', expected one of: sac, custom_token");
+ "invalid 'APPLY_LOAD_MODEL_TX', expected one of: sac, custom_token, "
+ "soroswap");
}
#endif
diff --git a/src/main/Config.h b/src/main/Config.h
index 2deb49b4e..cb217d87c 100644
--- a/src/main/Config.h
+++ b/src/main/Config.h
@@ -81,7 +81,8 @@ enum class ApplyLoadMode
enum class ApplyLoadModelTx
{
SAC,
- CUSTOM_TOKEN
+ CUSTOM_TOKEN,
+ SOROSWAP
};
#endif
diff --git a/src/rust/apply-load-wasm/README.md b/src/rust/apply-load-wasm/README.md
index 09c41478d..5cffc5285 100644
--- a/src/rust/apply-load-wasm/README.md
+++ b/src/rust/apply-load-wasm/README.md
@@ -2,4 +2,5 @@ This directory contains additional Wasm contracts built for apply load benchmark
Contents:
-- `token.wasm` - a token contract generated by OpenZeppelin contract wizard. Source can be found in `scripts/apply_load/token` directory.
\ No newline at end of file
+- `token.wasm` - a token contract generated by OpenZeppelin contract wizard. Source can be found in `scripts/apply_load/token` directory.
+- Soroswap Wasm contracts: `soroswap_factory.wasm`, `soroswap_pool.wasm`, and `soroswap_router.wasm`. These are the official Soroswap Wasm contracts downloaded directly from Mainnet. https://docs.soroswap.finance/smart-contracts/01-protocol-overview/03-technical-reference/deployed-addresses documents the deployed addresses and https://github.com/soroswap/core contains the source code for reference.
\ No newline at end of file
diff --git a/src/rust/apply-load-wasm/soroswap_factory.wasm b/src/rust/apply-load-wasm/soroswap_factory.wasm
new file mode 100644
index 000000000..00eded64d
Binary files /dev/null and b/src/rust/apply-load-wasm/soroswap_factory.wasm differ
diff --git a/src/rust/apply-load-wasm/soroswap_pool.wasm b/src/rust/apply-load-wasm/soroswap_pool.wasm
new file mode 100644
index 000000000..132b4181f
Binary files /dev/null and b/src/rust/apply-load-wasm/soroswap_pool.wasm differ
diff --git a/src/rust/apply-load-wasm/soroswap_router.wasm b/src/rust/apply-load-wasm/soroswap_router.wasm
new file mode 100644
index 000000000..bd621c598
Binary files /dev/null and b/src/rust/apply-load-wasm/soroswap_router.wasm differ
diff --git a/src/rust/build.rs b/src/rust/build.rs
new file mode 100644
index 000000000..764c6e985
--- /dev/null
+++ b/src/rust/build.rs
@@ -0,0 +1,14 @@
+fn main() {
+ // On Windows (build_rust.bat), soroban submodule revisions are written to
+ // .soroban-revs before invoking cargo. Watching this file causes cargo to
+ // mark the crate dirty when a submodule changes, so it re-links against
+ // the updated extern rlibs (which are passed via --extern after "--" and
+ // not tracked by cargo's own dependency graph).
+ //
+ // On Unix (Makefile.am), the rebuild is instead driven by Make
+ // prerequisites and -Cmetadata in RUSTFLAGS, so the file won't exist;
+ // we only register it when it's actually present.
+ if std::path::Path::new(".soroban-revs").exists() {
+ println!("cargo:rerun-if-changed=.soroban-revs");
+ }
+}
diff --git a/src/rust/src/bridge.rs b/src/rust/src/bridge.rs
index 2d2eeb8f2..87666bba7 100644
--- a/src/rust/src/bridge.rs
+++ b/src/rust/src/bridge.rs
@@ -220,6 +220,9 @@ pub(crate) mod rust_bridge {
fn get_write_bytes() -> Result;
fn get_invoke_contract_wasm() -> Result;
fn get_apply_load_token_wasm() -> Result;
+ fn get_apply_load_soroswap_factory_wasm() -> Result;
+ fn get_apply_load_soroswap_pool_wasm() -> Result;
+ fn get_apply_load_soroswap_router_wasm() -> Result;
fn get_hostile_large_val_wasm() -> Result;
diff --git a/src/rust/src/soroban_test_wasm.rs b/src/rust/src/soroban_test_wasm.rs
index 82e581c62..4b76ed2cd 100644
--- a/src/rust/src/soroban_test_wasm.rs
+++ b/src/rust/src/soroban_test_wasm.rs
@@ -119,6 +119,25 @@ pub(crate) fn get_apply_load_token_wasm() -> Result Result>
+{
+ Ok(RustBuf {
+ data: include_bytes!("../apply-load-wasm/soroswap_factory.wasm").to_vec(),
+ })
+}
+
+pub(crate) fn get_apply_load_soroswap_pool_wasm() -> Result> {
+ Ok(RustBuf {
+ data: include_bytes!("../apply-load-wasm/soroswap_pool.wasm").to_vec(),
+ })
+}
+
+pub(crate) fn get_apply_load_soroswap_router_wasm() -> Result> {
+ Ok(RustBuf {
+ data: include_bytes!("../apply-load-wasm/soroswap_router.wasm").to_vec(),
+ })
+}
+
pub(crate) fn get_invoke_contract_wasm() -> Result> {
Ok(RustBuf {
data: soroban_test_wasms::INVOKE_CONTRACT
diff --git a/src/simulation/ApplyLoad.cpp b/src/simulation/ApplyLoad.cpp
index 57ebbad5c..0508f7531 100644
--- a/src/simulation/ApplyLoad.cpp
+++ b/src/simulation/ApplyLoad.cpp
@@ -37,6 +37,26 @@ namespace
{
constexpr double NOISY_BINARY_SEARCH_CONFIDENCE = 0.99;
+LedgerKey
+makeSACBalanceKey(SCAddress const& sacContract, SCVal const& holderAddrVal)
+{
+ LedgerKey key(CONTRACT_DATA);
+ key.contractData().contract = sacContract;
+ key.contractData().key =
+ txtest::makeVecSCVal({makeSymbolSCVal("Balance"), holderAddrVal});
+ key.contractData().durability = ContractDataDurability::PERSISTENT;
+ return key;
+}
+
+LedgerKey
+makeTrustlineKey(PublicKey const& accountID, Asset const& asset)
+{
+ LedgerKey key(TRUSTLINE);
+ key.trustLine().accountID = accountID;
+ key.trustLine().asset = assetToTrustLineAsset(asset);
+ return key;
+}
+
void
logExecutionEnvironmentSnapshot(Config const& cfg)
{
@@ -403,6 +423,8 @@ ApplyLoad::calculateInstructionsPerTx() const
{
case ApplyLoadModelTx::CUSTOM_TOKEN:
return TxGenerator::CUSTOM_TOKEN_TX_INSTRUCTIONS;
+ case ApplyLoadModelTx::SOROSWAP:
+ return TxGenerator::SOROSWAP_SWAP_TX_INSTRUCTIONS;
case ApplyLoadModelTx::SAC:
{
uint32_t batchSize = mApp.getConfig().APPLY_LOAD_BATCH_SAC_COUNT;
@@ -449,6 +471,9 @@ ApplyLoad::calculateBenchmarkModelTxCount() const
case ApplyLoadModelTx::CUSTOM_TOKEN:
// No batching for custom token, one transfer per tx envelope
return config.APPLY_LOAD_MAX_SOROBAN_TX_COUNT;
+ case ApplyLoadModelTx::SOROSWAP:
+ // No batching for Soroswap, one swap per tx envelope
+ return config.APPLY_LOAD_MAX_SOROBAN_TX_COUNT;
}
releaseAssertOrThrow(false);
return 0;
@@ -649,6 +674,12 @@ ApplyLoad::ApplyLoad(Application& app)
mNumAccounts = config.APPLY_LOAD_MAX_SOROBAN_TX_COUNT * 2 +
config.APPLY_LOAD_CLASSIC_TXS_PER_LEDGER;
}
+ else if (mModelTx == ApplyLoadModelTx::SOROSWAP)
+ {
+ // Need 1 unique account per swap + classic accounts + root
+ mNumAccounts = config.APPLY_LOAD_MAX_SOROBAN_TX_COUNT + 1 +
+ config.APPLY_LOAD_CLASSIC_TXS_PER_LEDGER;
+ }
else
{
mNumAccounts =
@@ -729,6 +760,9 @@ ApplyLoad::setup()
case ApplyLoadModelTx::CUSTOM_TOKEN:
setupTokenContract();
break;
+ case ApplyLoadModelTx::SOROSWAP:
+ setupSoroswapContracts();
+ break;
}
break;
}
@@ -750,7 +784,7 @@ ApplyLoad::setup()
break;
}
- // Setup initital bucket list for modes that support it.
+ // Setup initial bucket list for modes that support it.
if (mMode == ApplyLoadMode::LIMIT_BASED ||
mMode == ApplyLoadMode::FIND_LIMITS_FOR_MODEL_TX)
{
@@ -1124,7 +1158,7 @@ ApplyLoad::setupBatchTransferContracts()
{
auto const& lm = mApp.getLedgerManager();
- // First, upload the batch_transfer contract WASM
+ // First, upload the batch_transfer contract Wasm
auto wasm = rust_bridge::get_test_contract_sac_transfer(
mApp.getConfig().LEDGER_PROTOCOL_VERSION);
xdr::opaque_vec<> wasmBytes;
@@ -1873,6 +1907,10 @@ ApplyLoad::benchmarkModelTx()
ApplyLoadModelTx::CUSTOM_TOKEN,
calculateBenchmarkModelTxCount());
break;
+ case ApplyLoadModelTx::SOROSWAP:
+ closeTimeMs = benchmarkModelTxTpsSingleLedger(
+ ApplyLoadModelTx::SOROSWAP, calculateBenchmarkModelTxCount());
+ break;
}
closeTimes.emplace_back(closeTimeMs);
}
@@ -1945,6 +1983,9 @@ ApplyLoad::benchmarkModelTxTpsSingleLedger(ApplyLoadModelTx modelTx,
case ApplyLoadModelTx::CUSTOM_TOKEN:
generateTokenTransfers(txs, txsPerLedger);
break;
+ case ApplyLoadModelTx::SOROSWAP:
+ generateSoroswapSwaps(txs, txsPerLedger);
+ break;
}
releaseAssertOrThrow(
txs.size() ==
@@ -2301,4 +2342,869 @@ ApplyLoad::generateTokenTransfers(std::vector& txs,
}
}
+void
+ApplyLoad::setupSoroswapContracts()
+{
+ auto const& lm = mApp.getLedgerManager();
+ auto const& config = mApp.getConfig();
+ int64_t initialSuccessCount = mTxGenerator.getApplySorobanSuccess().count();
+
+ // Upgrade maxTxSetSize so we can batch up to 10000 classic ops per
+ // ledger during setup.
+ static constexpr uint32_t SETUP_MAX_TX_SET_SIZE = 10000;
+ {
+ auto upgrade = xdr::xvector{};
+ LedgerUpgrade ledgerUpgrade;
+ ledgerUpgrade.type(LEDGER_UPGRADE_MAX_TX_SET_SIZE);
+ ledgerUpgrade.newMaxTxSetSize() = SETUP_MAX_TX_SET_SIZE;
+ auto v = xdr::xdr_to_opaque(ledgerUpgrade);
+ upgrade.push_back(UpgradeType{v.begin(), v.end()});
+ closeLedger({}, upgrade);
+ }
+
+ // Step 1: We create exactly APPLY_LOAD_LEDGER_MAX_DEPENDENT_TX_CLUSTERS (C)
+ // token pairs (one per cluster/bin) so that the tx set builder can assign
+ // each pair's transactions to its own bin, achieving maximum parallelism.
+ // Using C+1 tokens in a chain gives exactly C pairs: (T0,T1), (T1,T2), ...,
+ // (T_{C-1},T_C).
+ uint32_t numPairs = config.APPLY_LOAD_LEDGER_MAX_DEPENDENT_TX_CLUSTERS;
+ uint32_t numTokens = numPairs + 1;
+ mSoroswapState.numTokens = numTokens;
+
+ CLOG_INFO(Perf, "Soroswap setup: {} tokens, {} pairs for {} clusters",
+ numTokens, numPairs, numPairs);
+
+ // Step 2: Create N classic credit assets using root as issuer
+ auto rootAccount = mTxGenerator.findAccount(TxGenerator::ROOT_ACCOUNT_ID,
+ lm.getLastClosedLedgerNum());
+ for (uint32_t i = 0; i < numTokens; ++i)
+ {
+ std::string code = "T" + std::to_string(i);
+ mSoroswapState.assets.push_back(
+ txtest::makeAsset(rootAccount->getSecretKey(), code));
+ }
+
+ // Step 3: Create trustlines for all accounts x all assets.
+ // Batch up to 10000 ChangeTrust txs per ledger close.
+ CLOG_INFO(Perf,
+ "Soroswap setup: creating trustlines for {} accounts x {} "
+ "assets",
+ mNumAccounts, numTokens);
+ for (uint32_t assetIdx = 0; assetIdx < numTokens; ++assetIdx)
+ {
+ std::vector trustlineTxs;
+ for (uint32_t accIdx = 1; accIdx < mNumAccounts; ++accIdx)
+ {
+ auto acc =
+ mTxGenerator.findAccount(accIdx, lm.getLastClosedLedgerNum());
+ acc->loadSequenceNumber();
+ auto op =
+ txtest::changeTrust(mSoroswapState.assets[assetIdx], INT64_MAX);
+ auto tx =
+ mTxGenerator.createTransactionFramePtr(acc, {op}, std::nullopt);
+ trustlineTxs.push_back(
+ std::const_pointer_cast(tx));
+
+ // Close ledger in batches of SETUP_MAX_TX_SET_SIZE
+ if (trustlineTxs.size() >= SETUP_MAX_TX_SET_SIZE)
+ {
+ closeLedger(trustlineTxs);
+ trustlineTxs.clear();
+ }
+ }
+ if (!trustlineTxs.empty())
+ {
+ closeLedger(trustlineTxs);
+ }
+ }
+
+ // Step 4: Fund all accounts with each asset.
+ // Two-phase approach for efficiency:
+ // Phase 1: Root mints to NUM_DISTRIBUTORS "distribution" accounts
+ // (one multi-op tx per asset, closed in a single ledger).
+ // Phase 2: Each distributor pays ~100 target accounts via a multi-op
+ // tx. We batch up to 100 such txs per ledger close, giving
+ // ~10000 ops per ledger.
+ static constexpr uint32_t NUM_DISTRIBUTORS = 100;
+ static constexpr uint32_t OPS_PER_TX = 100;
+ // Total amount each final account should receive.
+ static constexpr int64_t AMOUNT_PER_ACCOUNT = 1'000'000'000;
+
+ CLOG_INFO(Perf, "Soroswap setup: funding accounts ({} distributors)",
+ NUM_DISTRIBUTORS);
+
+ // Accounts [1 .. NUM_DISTRIBUTORS] are distributors.
+ // Accounts [NUM_DISTRIBUTORS+1 .. mNumAccounts-1] are targets.
+ uint32_t numTargets = mNumAccounts - 1 - NUM_DISTRIBUTORS;
+
+ for (uint32_t assetIdx = 0; assetIdx < numTokens; ++assetIdx)
+ {
+ // Phase 1: Root -> distributors (single multi-op tx per asset).
+ {
+ int64_t amountPerDistributor =
+ AMOUNT_PER_ACCOUNT *
+ static_cast((numTargets / NUM_DISTRIBUTORS) + 2);
+ std::vector ops;
+ for (uint32_t d = 1; d <= NUM_DISTRIBUTORS; ++d)
+ {
+ ops.push_back(txtest::payment(
+ mTxGenerator.getAccount(d)->getPublicKey(),
+ mSoroswapState.assets[assetIdx], amountPerDistributor));
+ }
+ rootAccount = mTxGenerator.findAccount(TxGenerator::ROOT_ACCOUNT_ID,
+ lm.getLastClosedLedgerNum());
+ rootAccount->loadSequenceNumber();
+ auto tx = mTxGenerator.createTransactionFramePtr(rootAccount, ops,
+ std::nullopt);
+ closeLedger({std::const_pointer_cast(tx)});
+ }
+
+ // Phase 2: Distributors -> targets.
+ // Each distributor handles a slice of target accounts.
+ // Build one multi-op tx per distributor, batch up to 100 txs per
+ // ledger close (~10000 ops per ledger).
+ uint32_t firstTarget = NUM_DISTRIBUTORS + 1;
+
+ // Group targets by distributor (round-robin assignment).
+ std::vector> distTargets(NUM_DISTRIBUTORS);
+ for (uint32_t targetIdx = firstTarget; targetIdx < mNumAccounts;
+ ++targetIdx)
+ {
+ uint32_t distSlot = (targetIdx - firstTarget) % NUM_DISTRIBUTORS;
+ distTargets[distSlot].push_back(targetIdx);
+ }
+
+ // Build txs: one tx per OPS_PER_TX targets of a distributor.
+ std::vector batchTxs;
+ for (uint32_t d = 0; d < NUM_DISTRIBUTORS; ++d)
+ {
+ uint32_t distAccId = d + 1;
+ auto const& targets = distTargets[d];
+ std::vector ops;
+ for (size_t t = 0; t < targets.size(); ++t)
+ {
+ ops.push_back(txtest::payment(
+ mTxGenerator.getAccount(targets[t])->getPublicKey(),
+ mSoroswapState.assets[assetIdx], AMOUNT_PER_ACCOUNT));
+
+ if (ops.size() >= OPS_PER_TX || t == targets.size() - 1)
+ {
+ auto distAcc = mTxGenerator.findAccount(
+ distAccId, lm.getLastClosedLedgerNum());
+ distAcc->loadSequenceNumber();
+ auto tx = mTxGenerator.createTransactionFramePtr(
+ distAcc, ops, std::nullopt);
+ batchTxs.push_back(
+ std::const_pointer_cast(tx));
+ ops.clear();
+
+ if (batchTxs.size() >= 100)
+ {
+ closeLedger(batchTxs);
+ batchTxs.clear();
+ }
+ }
+ }
+ }
+ if (!batchTxs.empty())
+ {
+ closeLedger(batchTxs);
+ }
+ }
+
+ // Step 5: Create N SAC contracts for each asset.
+ // We use higher resource limits than createSACTransaction's defaults
+ // because credit asset SAC initialization needs more than 1M
+ // instructions.
+ CLOG_INFO(Perf, "Soroswap setup: creating {} SAC contracts", numTokens);
+ mSoroswapState.sacInstances.resize(numTokens);
+ for (uint32_t i = 0; i < numTokens; ++i)
+ {
+ rootAccount = mTxGenerator.findAccount(TxGenerator::ROOT_ACCOUNT_ID,
+ lm.getLastClosedLedgerNum());
+ rootAccount->loadSequenceNumber();
+
+ SorobanResources sacResources;
+ sacResources.instructions = 10'000'000;
+ sacResources.diskReadBytes = 1000;
+ sacResources.writeBytes = 1000;
+
+ auto contractIDPreimage =
+ txtest::makeContractIDPreimage(mSoroswapState.assets[i]);
+
+ auto createTx = txtest::makeSorobanCreateContractTx(
+ mApp, *rootAccount, contractIDPreimage,
+ txtest::makeAssetExecutable(mSoroswapState.assets[i]), sacResources,
+ mTxGenerator.generateFee(std::nullopt, /* opsCnt */ 1));
+ closeLedger({createTx});
+
+ auto instanceKey =
+ createTx->sorobanResources().footprint.readWrite.back();
+ mSoroswapState.sacInstances[i].readOnlyKeys.emplace_back(instanceKey);
+ mSoroswapState.sacInstances[i].contractID =
+ instanceKey.contractData().contract;
+ }
+
+ // Step 6: Upload 3 Soroswap Wasms (factory, pair, router)
+ CLOG_INFO(Perf, "Soroswap setup: uploading Wasms");
+
+ auto factoryWasm = rust_bridge::get_apply_load_soroswap_factory_wasm();
+ xdr::opaque_vec<> factoryWasmBytes;
+ factoryWasmBytes.assign(factoryWasm.data.begin(), factoryWasm.data.end());
+ LedgerKey factoryCodeKey;
+ factoryCodeKey.type(CONTRACT_CODE);
+ factoryCodeKey.contractCode().hash = sha256(factoryWasmBytes);
+ mSoroswapState.factoryCodeKey = factoryCodeKey;
+
+ SorobanResources factoryUploadRes;
+ factoryUploadRes.instructions = 50'000'000;
+ factoryUploadRes.diskReadBytes =
+ static_cast(factoryWasmBytes.size()) + 500;
+ factoryUploadRes.writeBytes =
+ static_cast(factoryWasmBytes.size()) + 500;
+ auto factoryUploadTx = mTxGenerator.createUploadWasmTransaction(
+ lm.getLastClosedLedgerNum() + 1, TxGenerator::ROOT_ACCOUNT_ID,
+ factoryWasmBytes, factoryCodeKey, std::nullopt, factoryUploadRes);
+ closeLedger({factoryUploadTx.second});
+
+ auto pairWasm = rust_bridge::get_apply_load_soroswap_pool_wasm();
+ xdr::opaque_vec<> pairWasmBytes;
+ pairWasmBytes.assign(pairWasm.data.begin(), pairWasm.data.end());
+ LedgerKey pairCodeKey;
+ pairCodeKey.type(CONTRACT_CODE);
+ pairCodeKey.contractCode().hash = sha256(pairWasmBytes);
+ mSoroswapState.pairCodeKey = pairCodeKey;
+
+ SorobanResources pairUploadRes;
+ pairUploadRes.instructions = 50'000'000;
+ pairUploadRes.diskReadBytes =
+ static_cast(pairWasmBytes.size()) + 500;
+ pairUploadRes.writeBytes =
+ static_cast(pairWasmBytes.size()) + 500;
+ auto pairUploadTx = mTxGenerator.createUploadWasmTransaction(
+ lm.getLastClosedLedgerNum() + 1, TxGenerator::ROOT_ACCOUNT_ID,
+ pairWasmBytes, pairCodeKey, std::nullopt, pairUploadRes);
+ closeLedger({pairUploadTx.second});
+
+ auto routerWasm = rust_bridge::get_apply_load_soroswap_router_wasm();
+ xdr::opaque_vec<> routerWasmBytes;
+ routerWasmBytes.assign(routerWasm.data.begin(), routerWasm.data.end());
+ LedgerKey routerCodeKey;
+ routerCodeKey.type(CONTRACT_CODE);
+ routerCodeKey.contractCode().hash = sha256(routerWasmBytes);
+ mSoroswapState.routerCodeKey = routerCodeKey;
+
+ SorobanResources routerUploadRes;
+ routerUploadRes.instructions = 50'000'000;
+ routerUploadRes.diskReadBytes =
+ static_cast(routerWasmBytes.size()) + 500;
+ routerUploadRes.writeBytes =
+ static_cast(routerWasmBytes.size()) + 500;
+ auto routerUploadTx = mTxGenerator.createUploadWasmTransaction(
+ lm.getLastClosedLedgerNum() + 1, TxGenerator::ROOT_ACCOUNT_ID,
+ routerWasmBytes, routerCodeKey, std::nullopt, routerUploadRes);
+ closeLedger({routerUploadTx.second});
+
+ // Step 7: Deploy factory contract and initialize it
+ CLOG_INFO(Perf, "Soroswap setup: deploying factory");
+ {
+ rootAccount = mTxGenerator.findAccount(TxGenerator::ROOT_ACCOUNT_ID,
+ lm.getLastClosedLedgerNum());
+ rootAccount->loadSequenceNumber();
+
+ auto salt = sha256("soroswap factory salt");
+ auto contractIDPreimage =
+ txtest::makeContractIDPreimage(*rootAccount, salt);
+
+ SorobanResources createResources;
+ createResources.instructions = 50'000'000;
+ createResources.diskReadBytes =
+ static_cast(factoryWasmBytes.size()) + 10000;
+ createResources.writeBytes = 50000;
+
+ auto createTx = txtest::makeSorobanCreateContractTx(
+ mApp, *rootAccount, contractIDPreimage,
+ txtest::makeWasmExecutable(factoryCodeKey.contractCode().hash),
+ createResources,
+ mTxGenerator.generateFee(std::nullopt, /* opsCnt */ 1));
+ closeLedger({createTx});
+
+ auto instanceKey =
+ createTx->sorobanResources().footprint.readWrite.back();
+ mSoroswapState.factoryInstanceKey = instanceKey;
+ mSoroswapState.factoryContractID = instanceKey.contractData().contract;
+ }
+
+ // Initialize factory: initialize(setter, pair_wasm_hash)
+ CLOG_INFO(Perf, "Soroswap setup: initializing factory");
+ {
+ rootAccount = mTxGenerator.findAccount(TxGenerator::ROOT_ACCOUNT_ID,
+ lm.getLastClosedLedgerNum());
+ rootAccount->loadSequenceNumber();
+
+ auto setterVal =
+ makeAddressSCVal(makeAccountAddress(rootAccount->getPublicKey()));
+
+ SCVal pairWasmHashVal(SCV_BYTES);
+ pairWasmHashVal.bytes().assign(pairCodeKey.contractCode().hash.begin(),
+ pairCodeKey.contractCode().hash.end());
+
+ Operation op;
+ op.body.type(INVOKE_HOST_FUNCTION);
+ auto& ihf = op.body.invokeHostFunctionOp().hostFunction;
+ ihf.type(HOST_FUNCTION_TYPE_INVOKE_CONTRACT);
+ ihf.invokeContract().contractAddress = mSoroswapState.factoryContractID;
+ ihf.invokeContract().functionName = "initialize";
+ ihf.invokeContract().args = {setterVal, pairWasmHashVal};
+
+ SorobanResources resources;
+ resources.instructions = 50'000'000;
+ resources.diskReadBytes =
+ static_cast(factoryWasmBytes.size()) + 10000;
+ resources.writeBytes = 50000;
+ resources.footprint.readOnly.push_back(factoryCodeKey);
+ resources.footprint.readWrite.push_back(
+ mSoroswapState.factoryInstanceKey);
+
+ // PairWasmHash persistent data key (factory.initialize writes this)
+ {
+ LedgerKey pairWasmHashDataKey(CONTRACT_DATA);
+ pairWasmHashDataKey.contractData().contract =
+ mSoroswapState.factoryContractID;
+ pairWasmHashDataKey.contractData().key =
+ txtest::makeVecSCVal({makeSymbolSCVal("PairWasmHash")});
+ pairWasmHashDataKey.contractData().durability =
+ ContractDataDurability::PERSISTENT;
+ resources.footprint.readWrite.push_back(pairWasmHashDataKey);
+ }
+
+ // Source account for auth
+ SorobanAuthorizedInvocation invocation;
+ invocation.function.type(SOROBAN_AUTHORIZED_FUNCTION_TYPE_CONTRACT_FN);
+ invocation.function.contractFn() = ihf.invokeContract();
+ SorobanCredentials credentials(SOROBAN_CREDENTIALS_SOURCE_ACCOUNT);
+ op.body.invokeHostFunctionOp().auth.emplace_back(credentials,
+ invocation);
+
+ auto resourceFee =
+ txtest::sorobanResourceFee(mApp, resources, 5000, 200);
+ resourceFee += 50'000'000;
+
+ auto tx = txtest::sorobanTransactionFrameFromOps(
+ mApp.getNetworkID(), *rootAccount, {op}, {}, resources,
+ mTxGenerator.generateFee(std::nullopt, 1), resourceFee);
+ closeLedger({tx});
+ }
+
+ // Step 8: Deploy router contract and initialize it
+ CLOG_INFO(Perf, "Soroswap setup: deploying router");
+ {
+ rootAccount = mTxGenerator.findAccount(TxGenerator::ROOT_ACCOUNT_ID,
+ lm.getLastClosedLedgerNum());
+ rootAccount->loadSequenceNumber();
+
+ auto salt = sha256("soroswap router salt");
+ auto contractIDPreimage =
+ txtest::makeContractIDPreimage(*rootAccount, salt);
+
+ SorobanResources createResources;
+ createResources.instructions = 50'000'000;
+ createResources.diskReadBytes =
+ static_cast(routerWasmBytes.size()) + 10000;
+ createResources.writeBytes = 50000;
+
+ auto createTx = txtest::makeSorobanCreateContractTx(
+ mApp, *rootAccount, contractIDPreimage,
+ txtest::makeWasmExecutable(routerCodeKey.contractCode().hash),
+ createResources,
+ mTxGenerator.generateFee(std::nullopt, /* opsCnt */ 1));
+ closeLedger({createTx});
+
+ auto instanceKey =
+ createTx->sorobanResources().footprint.readWrite.back();
+ mSoroswapState.routerInstanceKey = instanceKey;
+ mSoroswapState.routerContractID = instanceKey.contractData().contract;
+ }
+
+ // Initialize router: initialize(factory_address)
+ CLOG_INFO(Perf, "Soroswap setup: initializing router");
+ {
+ rootAccount = mTxGenerator.findAccount(TxGenerator::ROOT_ACCOUNT_ID,
+ lm.getLastClosedLedgerNum());
+ rootAccount->loadSequenceNumber();
+
+ auto factoryVal = makeAddressSCVal(mSoroswapState.factoryContractID);
+
+ Operation op;
+ op.body.type(INVOKE_HOST_FUNCTION);
+ auto& ihf = op.body.invokeHostFunctionOp().hostFunction;
+ ihf.type(HOST_FUNCTION_TYPE_INVOKE_CONTRACT);
+ ihf.invokeContract().contractAddress = mSoroswapState.routerContractID;
+ ihf.invokeContract().functionName = "initialize";
+ ihf.invokeContract().args = {factoryVal};
+
+ SorobanResources resources;
+ resources.instructions = 50'000'000;
+ resources.diskReadBytes =
+ static_cast(routerWasmBytes.size()) + 10000;
+ resources.writeBytes = 50000;
+ resources.footprint.readOnly.push_back(routerCodeKey);
+ resources.footprint.readWrite.push_back(
+ mSoroswapState.routerInstanceKey);
+
+ SorobanAuthorizedInvocation invocation;
+ invocation.function.type(SOROBAN_AUTHORIZED_FUNCTION_TYPE_CONTRACT_FN);
+ invocation.function.contractFn() = ihf.invokeContract();
+ SorobanCredentials credentials(SOROBAN_CREDENTIALS_SOURCE_ACCOUNT);
+ op.body.invokeHostFunctionOp().auth.emplace_back(credentials,
+ invocation);
+
+ auto resourceFee =
+ txtest::sorobanResourceFee(mApp, resources, 5000, 200);
+ resourceFee += 50'000'000;
+
+ auto tx = txtest::sorobanTransactionFrameFromOps(
+ mApp.getNetworkID(), *rootAccount, {op}, {}, resources,
+ mTxGenerator.generateFee(std::nullopt, 1), resourceFee);
+ closeLedger({tx});
+ }
+
+ // Step 9: Create pairs explicitly via factory.create_pair().
+ // We compute each pair's contract address deterministically so we can
+ // build the correct footprint before submission.
+ CLOG_INFO(Perf, "Soroswap setup: creating {} pairs via factory", numPairs);
+ for (uint32_t pairNum = 0; pairNum < numPairs; ++pairNum)
+ {
+ // Chain: pair pairNum uses tokens (pairNum, pairNum+1)
+ uint32_t i = pairNum;
+ uint32_t j = pairNum + 1;
+
+ rootAccount = mTxGenerator.findAccount(TxGenerator::ROOT_ACCOUNT_ID,
+ lm.getLastClosedLedgerNum());
+ rootAccount->loadSequenceNumber();
+
+ // Sort tokens as Soroswap factory does (token_0 < token_1)
+ SCAddress token0 = mSoroswapState.sacInstances[i].contractID;
+ SCAddress token1 = mSoroswapState.sacInstances[j].contractID;
+ if (token1 < token0)
+ std::swap(token0, token1);
+
+ // Compute pair salt: sha256(xdr(ScVal(token0)) ||
+ // xdr(ScVal(token1))). This matches Soroban SDK's
+ // Address::to_xdr() used in factory's pair.rs salt().
+ auto token0Val = makeAddressSCVal(token0);
+ auto token1Val = makeAddressSCVal(token1);
+ auto xdr0 = xdr::xdr_to_opaque(token0Val);
+ auto xdr1 = xdr::xdr_to_opaque(token1Val);
+ std::vector saltInput(xdr0.begin(), xdr0.end());
+ saltInput.insert(saltInput.end(), xdr1.begin(), xdr1.end());
+ uint256 pairSalt =
+ sha256(ByteSlice(saltInput.data(), saltInput.size()));
+
+ // Derive pair contract address deterministically
+ ContractIDPreimage pairPreimage(CONTRACT_ID_PREIMAGE_FROM_ADDRESS);
+ pairPreimage.fromAddress().address = mSoroswapState.factoryContractID;
+ pairPreimage.fromAddress().salt = pairSalt;
+ auto fullPreimage = txtest::makeFullContractIdPreimage(
+ mApp.getNetworkID(), pairPreimage);
+ Hash pairContractHash = xdrSha256(fullPreimage);
+ SCAddress pairAddress = txtest::makeContractAddress(pairContractHash);
+ LedgerKey pairInstanceKey =
+ txtest::makeContractInstanceKey(pairAddress);
+
+ // Store pair info
+ SoroswapPairInfo pairInfo;
+ pairInfo.tokenAIndex = i;
+ pairInfo.tokenBIndex = j;
+ pairInfo.pairContractID = pairAddress;
+ mSoroswapState.pairs.push_back(pairInfo);
+ uint32_t pairIdx =
+ static_cast(mSoroswapState.pairs.size() - 1);
+
+ // Build factory.create_pair(token_a, token_b) invocation
+ auto tokenAVal =
+ makeAddressSCVal(mSoroswapState.sacInstances[i].contractID);
+ auto tokenBVal =
+ makeAddressSCVal(mSoroswapState.sacInstances[j].contractID);
+
+ Operation op;
+ op.body.type(INVOKE_HOST_FUNCTION);
+ auto& ihf = op.body.invokeHostFunctionOp().hostFunction;
+ ihf.type(HOST_FUNCTION_TYPE_INVOKE_CONTRACT);
+ ihf.invokeContract().contractAddress = mSoroswapState.factoryContractID;
+ ihf.invokeContract().functionName = "create_pair";
+ ihf.invokeContract().args = {tokenAVal, tokenBVal};
+
+ SorobanResources resources;
+ resources.instructions = 100'000'000;
+ resources.diskReadBytes = 100'000;
+ resources.writeBytes = 100'000;
+
+ // Read-only: factory code, pair Wasm code,
+ // PairWasmHash (persistent, read during deploy),
+ // SAC token instances (pair.initialize calls
+ // token_0.symbol() and token_1.symbol())
+ resources.footprint.readOnly.push_back(factoryCodeKey);
+ resources.footprint.readOnly.push_back(pairCodeKey);
+ resources.footprint.readOnly.push_back(
+ mSoroswapState.sacInstances[i].readOnlyKeys.at(0));
+ resources.footprint.readOnly.push_back(
+ mSoroswapState.sacInstances[j].readOnlyKeys.at(0));
+ {
+ LedgerKey pairWasmHashKey(CONTRACT_DATA);
+ pairWasmHashKey.contractData().contract =
+ mSoroswapState.factoryContractID;
+ pairWasmHashKey.contractData().key =
+ txtest::makeVecSCVal({makeSymbolSCVal("PairWasmHash")});
+ pairWasmHashKey.contractData().durability =
+ ContractDataDurability::PERSISTENT;
+ resources.footprint.readOnly.push_back(pairWasmHashKey);
+ }
+
+ // Read-write: factory instance (TotalPairs update),
+ // new pair instance (created),
+ // PairAddressesByTokens (created),
+ // PairAddressesNIndexed(n) (created)
+ resources.footprint.readWrite.push_back(
+ mSoroswapState.factoryInstanceKey);
+ resources.footprint.readWrite.push_back(pairInstanceKey);
+ {
+ LedgerKey pairByTokensLK(CONTRACT_DATA);
+ pairByTokensLK.contractData().contract =
+ mSoroswapState.factoryContractID;
+ pairByTokensLK.contractData().key = txtest::makeVecSCVal(
+ {makeSymbolSCVal("PairAddressesByTokens"),
+ txtest::makeVecSCVal({token0Val, token1Val})});
+ pairByTokensLK.contractData().durability =
+ ContractDataDurability::PERSISTENT;
+ resources.footprint.readWrite.push_back(pairByTokensLK);
+ }
+ {
+ LedgerKey nIndexedLK(CONTRACT_DATA);
+ nIndexedLK.contractData().contract =
+ mSoroswapState.factoryContractID;
+ nIndexedLK.contractData().key =
+ txtest::makeVecSCVal({makeSymbolSCVal("PairAddressesNIndexed"),
+ txtest::makeU32(pairIdx)});
+ nIndexedLK.contractData().durability =
+ ContractDataDurability::PERSISTENT;
+ resources.footprint.readWrite.push_back(nIndexedLK);
+ }
+
+ // factory.create_pair doesn't call require_auth
+ auto resourceFee =
+ txtest::sorobanResourceFee(mApp, resources, 20000, 200);
+ resourceFee += 500'000'000;
+
+ auto tx = txtest::sorobanTransactionFrameFromOps(
+ mApp.getNetworkID(), *rootAccount, {op}, {}, resources,
+ mTxGenerator.generateFee(std::nullopt, 1), resourceFee);
+ closeLedger({tx});
+ }
+
+ // Step 10: Add liquidity to all pairs via router.add_liquidity.
+ // Pairs already exist from step 9, so footprint is simpler.
+ CLOG_INFO(Perf, "Soroswap setup: adding liquidity to {} pairs", numPairs);
+ for (size_t pairIdx = 0; pairIdx < mSoroswapState.pairs.size(); ++pairIdx)
+ {
+ auto const& pair = mSoroswapState.pairs[pairIdx];
+ uint32_t ti = pair.tokenAIndex;
+ uint32_t tj = pair.tokenBIndex;
+
+ rootAccount = mTxGenerator.findAccount(TxGenerator::ROOT_ACCOUNT_ID,
+ lm.getLastClosedLedgerNum());
+ rootAccount->loadSequenceNumber();
+
+ auto tokenAVal =
+ makeAddressSCVal(mSoroswapState.sacInstances[ti].contractID);
+ auto tokenBVal =
+ makeAddressSCVal(mSoroswapState.sacInstances[tj].contractID);
+
+ int64_t desiredAmount = 100'000'000;
+ int64_t minAmount = 99'000'000;
+
+ auto toVal =
+ makeAddressSCVal(makeAccountAddress(rootAccount->getPublicKey()));
+
+ SCVal deadlineVal(SCV_U64);
+ deadlineVal.u64() = UINT64_MAX;
+
+ Operation op;
+ op.body.type(INVOKE_HOST_FUNCTION);
+ auto& ihf = op.body.invokeHostFunctionOp().hostFunction;
+ ihf.type(HOST_FUNCTION_TYPE_INVOKE_CONTRACT);
+ ihf.invokeContract().contractAddress = mSoroswapState.routerContractID;
+ ihf.invokeContract().functionName = "add_liquidity";
+ ihf.invokeContract().args = {tokenAVal,
+ tokenBVal,
+ txtest::makeI128(desiredAmount),
+ txtest::makeI128(desiredAmount),
+ txtest::makeI128(minAmount),
+ txtest::makeI128(minAmount),
+ toVal,
+ deadlineVal};
+
+ SorobanResources resources;
+ resources.instructions = 100'000'000;
+ resources.diskReadBytes = 100'000;
+ resources.writeBytes = 100'000;
+
+ // Sort tokens for the factory PairAddressesByTokens lookup key
+ SCAddress sortedToken0 = mSoroswapState.sacInstances[ti].contractID;
+ SCAddress sortedToken1 = mSoroswapState.sacInstances[tj].contractID;
+ if (sortedToken1 < sortedToken0)
+ std::swap(sortedToken0, sortedToken1);
+ auto sortedToken0Val = makeAddressSCVal(sortedToken0);
+ auto sortedToken1Val = makeAddressSCVal(sortedToken1);
+
+ auto pairAddrVal = makeAddressSCVal(pair.pairContractID);
+
+ // Read-only: router code+instance, factory code+instance,
+ // PairAddressesByTokens, token SAC instances, pair code
+ resources.footprint.readOnly.push_back(routerCodeKey);
+ resources.footprint.readOnly.push_back(
+ mSoroswapState.routerInstanceKey);
+ resources.footprint.readOnly.push_back(factoryCodeKey);
+ resources.footprint.readOnly.push_back(
+ mSoroswapState.factoryInstanceKey);
+ {
+ LedgerKey pairByTokensLK(CONTRACT_DATA);
+ pairByTokensLK.contractData().contract =
+ mSoroswapState.factoryContractID;
+ pairByTokensLK.contractData().key = txtest::makeVecSCVal(
+ {makeSymbolSCVal("PairAddressesByTokens"),
+ txtest::makeVecSCVal({sortedToken0Val, sortedToken1Val})});
+ pairByTokensLK.contractData().durability =
+ ContractDataDurability::PERSISTENT;
+ resources.footprint.readOnly.push_back(pairByTokensLK);
+ }
+ resources.footprint.readOnly.push_back(
+ mSoroswapState.sacInstances[ti].readOnlyKeys.at(0));
+ resources.footprint.readOnly.push_back(
+ mSoroswapState.sacInstances[tj].readOnlyKeys.at(0));
+ resources.footprint.readOnly.push_back(pairCodeKey);
+
+ // Read-write: root account, trustlines, token balances,
+ // pair instance, LP token balance
+ LedgerKey rootKey(ACCOUNT);
+ rootKey.account().accountID = rootAccount->getPublicKey();
+ resources.footprint.readWrite.emplace_back(rootKey);
+
+ // Note: root is the asset issuer, so no trustline entries are
+ // needed — issuers have unlimited supply and no trustlines.
+
+ // Token A Balance[pair]
+ resources.footprint.readWrite.emplace_back(makeSACBalanceKey(
+ mSoroswapState.sacInstances[ti].contractID, pairAddrVal));
+ // Token B Balance[pair]
+ resources.footprint.readWrite.emplace_back(makeSACBalanceKey(
+ mSoroswapState.sacInstances[tj].contractID, pairAddrVal));
+ // Pair contract instance (RW - modified during deposit)
+ resources.footprint.readWrite.emplace_back(
+ txtest::makeContractInstanceKey(pair.pairContractID));
+ // Pair LP token Balance[root] (minted during first deposit)
+ resources.footprint.readWrite.emplace_back(
+ makeSACBalanceKey(pair.pairContractID, toVal));
+ // Pair LP token Balance[pair_contract] (MINIMUM_LIQUIDITY minted
+ // to pair itself during first deposit)
+ resources.footprint.readWrite.emplace_back(
+ makeSACBalanceKey(pair.pairContractID, pairAddrVal));
+
+ // Auth: root authorizes add_liquidity which sub-invokes
+ // token_a.transfer and token_b.transfer
+ SorobanAuthorizedInvocation rootInvocation;
+ rootInvocation.function.type(
+ SOROBAN_AUTHORIZED_FUNCTION_TYPE_CONTRACT_FN);
+ rootInvocation.function.contractFn() = ihf.invokeContract();
+
+ // Sub-invocation: token_a.transfer(root, pair, amount)
+ SorobanAuthorizedInvocation transferAInvocation;
+ transferAInvocation.function.type(
+ SOROBAN_AUTHORIZED_FUNCTION_TYPE_CONTRACT_FN);
+ transferAInvocation.function.contractFn().contractAddress =
+ mSoroswapState.sacInstances[ti].contractID;
+ transferAInvocation.function.contractFn().functionName = "transfer";
+ transferAInvocation.function.contractFn().args = {
+ toVal, pairAddrVal, txtest::makeI128(desiredAmount)};
+
+ // Sub-invocation: token_b.transfer(root, pair, amount)
+ SorobanAuthorizedInvocation transferBInvocation;
+ transferBInvocation.function.type(
+ SOROBAN_AUTHORIZED_FUNCTION_TYPE_CONTRACT_FN);
+ transferBInvocation.function.contractFn().contractAddress =
+ mSoroswapState.sacInstances[tj].contractID;
+ transferBInvocation.function.contractFn().functionName = "transfer";
+ transferBInvocation.function.contractFn().args = {
+ toVal, pairAddrVal, txtest::makeI128(desiredAmount)};
+
+ rootInvocation.subInvocations.push_back(transferAInvocation);
+ rootInvocation.subInvocations.push_back(transferBInvocation);
+
+ SorobanCredentials credentials(SOROBAN_CREDENTIALS_SOURCE_ACCOUNT);
+ op.body.invokeHostFunctionOp().auth.emplace_back(credentials,
+ rootInvocation);
+
+ auto resourceFee =
+ txtest::sorobanResourceFee(mApp, resources, 20000, 200);
+ resourceFee += 500'000'000;
+
+ auto tx = txtest::sorobanTransactionFrameFromOps(
+ mApp.getNetworkID(), *rootAccount, {op}, {}, resources,
+ mTxGenerator.generateFee(std::nullopt, 1), resourceFee);
+ closeLedger({tx});
+ }
+
+ // Initialize swap counters for alternating direction
+ mSoroswapSwapCounters.resize(numPairs, 0);
+
+ int64_t totalSetupTxs =
+ mTxGenerator.getApplySorobanSuccess().count() - initialSuccessCount;
+ // N SAC creates + 3 Wasm uploads + factory create + factory init
+ // + router create + router init + numPairs create_pair
+ // + numPairs add_liquidity
+ int64_t expectedSorobanTxs = numTokens + 3 + 2 + 2 + 2 * numPairs;
+ CLOG_INFO(Perf,
+ "Soroswap setup complete: {} soroban txs (expected {}), {} "
+ "failures",
+ totalSetupTxs, expectedSorobanTxs,
+ mTxGenerator.getApplySorobanFailure().count());
+ releaseAssert(mTxGenerator.getApplySorobanFailure().count() == 0);
+}
+
+void
+ApplyLoad::generateSoroswapSwaps(std::vector& txs,
+ uint32_t count)
+{
+ auto& lm = mApp.getLedgerManager();
+ uint32_t numPairs = mSoroswapState.pairs.size();
+ releaseAssert(numPairs > 0);
+
+ for (uint32_t i = 0; i < count; ++i)
+ {
+ // Round-robin across pairs for parallelism
+ uint32_t pairIndex = i % numPairs;
+ auto const& pair = mSoroswapState.pairs[pairIndex];
+
+ // Unique account per tx (skip account 0 = root/issuer)
+ uint32_t accountIdx = i + 1;
+
+ // Alternate swap direction per pair to keep pools balanced
+ bool swapAForB = (mSoroswapSwapCounters[pairIndex] % 2 == 0);
+ mSoroswapSwapCounters[pairIndex]++;
+
+ uint32_t tokenInIdx = swapAForB ? pair.tokenAIndex : pair.tokenBIndex;
+ uint32_t tokenOutIdx = swapAForB ? pair.tokenBIndex : pair.tokenAIndex;
+
+ auto fromAccount =
+ mTxGenerator.findAccount(accountIdx, lm.getLastClosedLedgerNum());
+ fromAccount->loadSequenceNumber();
+
+ auto fromVal =
+ makeAddressSCVal(makeAccountAddress(fromAccount->getPublicKey()));
+
+ // Build path: [token_in, token_out]
+ auto tokenInVal = makeAddressSCVal(
+ mSoroswapState.sacInstances[tokenInIdx].contractID);
+ auto tokenOutVal = makeAddressSCVal(
+ mSoroswapState.sacInstances[tokenOutIdx].contractID);
+
+ SCVal pathVec(SCV_VEC);
+ pathVec.vec().activate();
+ pathVec.vec()->push_back(tokenInVal);
+ pathVec.vec()->push_back(tokenOutVal);
+
+ int64_t swapAmount = 100;
+ SCVal deadlineVal(SCV_U64);
+ deadlineVal.u64() = UINT64_MAX;
+
+ Operation op;
+ op.body.type(INVOKE_HOST_FUNCTION);
+ auto& ihf = op.body.invokeHostFunctionOp().hostFunction;
+ ihf.type(HOST_FUNCTION_TYPE_INVOKE_CONTRACT);
+ ihf.invokeContract().contractAddress = mSoroswapState.routerContractID;
+ ihf.invokeContract().functionName = "swap_exact_tokens_for_tokens";
+ ihf.invokeContract().args = {
+ txtest::makeI128(swapAmount), // amount_in
+ txtest::makeI128(0), // amount_out_min
+ pathVec, // path
+ fromVal, // to
+ deadlineVal // deadline
+ };
+
+ // Footprint
+ SorobanResources resources;
+ resources.instructions = TxGenerator::SOROSWAP_SWAP_TX_INSTRUCTIONS;
+ resources.diskReadBytes = 5000;
+ resources.writeBytes = 5000;
+
+ // Read-only: router instance, token_in SAC instance,
+ // token_out SAC instance, router code, pair code
+ resources.footprint.readOnly.push_back(
+ mSoroswapState.routerInstanceKey);
+ resources.footprint.readOnly.push_back(
+ mSoroswapState.sacInstances[tokenInIdx].readOnlyKeys.at(0));
+ resources.footprint.readOnly.push_back(
+ mSoroswapState.sacInstances[tokenOutIdx].readOnlyKeys.at(0));
+ resources.footprint.readOnly.push_back(mSoroswapState.routerCodeKey);
+ resources.footprint.readOnly.push_back(mSoroswapState.pairCodeKey);
+
+ // Read-write: user trustline(A), user trustline(B),
+ // Balance[pair] for token_in, Balance[pair] for
+ // token_out, pair instance
+ resources.footprint.readWrite.emplace_back(makeTrustlineKey(
+ fromAccount->getPublicKey(), mSoroswapState.assets[tokenInIdx]));
+ resources.footprint.readWrite.emplace_back(makeTrustlineKey(
+ fromAccount->getPublicKey(), mSoroswapState.assets[tokenOutIdx]));
+
+ auto pairAddrVal = makeAddressSCVal(pair.pairContractID);
+ // Balance[pair] for token_in
+ resources.footprint.readWrite.emplace_back(makeSACBalanceKey(
+ mSoroswapState.sacInstances[tokenInIdx].contractID, pairAddrVal));
+ // Balance[pair] for token_out
+ resources.footprint.readWrite.emplace_back(makeSACBalanceKey(
+ mSoroswapState.sacInstances[tokenOutIdx].contractID, pairAddrVal));
+ // Pair contract instance (RW - modified during swap)
+ resources.footprint.readWrite.emplace_back(
+ txtest::makeContractInstanceKey(pair.pairContractID));
+
+ // Auth: source_account authorizes swap_exact_tokens_for_tokens
+ // which sub-invokes token_in.transfer(user, pair, amount)
+ SorobanAuthorizedInvocation rootInvocation;
+ rootInvocation.function.type(
+ SOROBAN_AUTHORIZED_FUNCTION_TYPE_CONTRACT_FN);
+ rootInvocation.function.contractFn() = ihf.invokeContract();
+
+ SorobanAuthorizedInvocation transferInvocation;
+ transferInvocation.function.type(
+ SOROBAN_AUTHORIZED_FUNCTION_TYPE_CONTRACT_FN);
+ transferInvocation.function.contractFn().contractAddress =
+ mSoroswapState.sacInstances[tokenInIdx].contractID;
+ transferInvocation.function.contractFn().functionName = "transfer";
+ transferInvocation.function.contractFn().args = {
+ fromVal, pairAddrVal, txtest::makeI128(swapAmount)};
+ rootInvocation.subInvocations.push_back(transferInvocation);
+
+ SorobanCredentials credentials(SOROBAN_CREDENTIALS_SOURCE_ACCOUNT);
+ op.body.invokeHostFunctionOp().auth.emplace_back(credentials,
+ rootInvocation);
+
+ auto resourceFee =
+ txtest::sorobanResourceFee(mApp, resources, 1000, 200);
+ resourceFee += 5'000'000;
+
+ auto tx = txtest::sorobanTransactionFrameFromOps(
+ mApp.getNetworkID(), *fromAccount, {op}, {}, resources,
+ mTxGenerator.generateFee(std::nullopt, 1), resourceFee);
+ txs.push_back(tx);
+ }
+
+ LedgerSnapshot ls(mApp);
+ auto diag = DiagnosticEventManager::createDisabled();
+ for (auto const& tx : txs)
+ {
+ releaseAssert(tx->checkValid(mApp.getAppConnector(), ls, 0, 0, 0, diag)
+ ->isSuccess());
+ }
+}
+
} // namespace stellar
diff --git a/src/simulation/ApplyLoad.h b/src/simulation/ApplyLoad.h
index 8dc6a1ecd..d16c200f4 100644
--- a/src/simulation/ApplyLoad.h
+++ b/src/simulation/ApplyLoad.h
@@ -54,6 +54,7 @@ class ApplyLoad
void setupXLMContract();
void setupBatchTransferContracts();
void setupTokenContract();
+ void setupSoroswapContracts();
void setupBucketList();
// Runs for `execute() in `ApplyLoadMode::LIMIT_BASED` mode.
@@ -119,6 +120,11 @@ class ApplyLoad
void generateTokenTransfers(std::vector& txs,
uint32_t count);
+ // Generates the given number of Soroswap swap TXs across pairs with no
+ // conflicts.
+ void generateSoroswapSwaps(std::vector& txs,
+ uint32_t count);
+
// Calculate instructions per transaction based on batch size
uint64_t calculateInstructionsPerTx() const;
@@ -182,6 +188,37 @@ class ApplyLoad
// Used to generate custom token transfer transactions
TxGenerator::ContractInstance mTokenInstance;
+ // Soroswap AMM benchmark state
+ struct SoroswapPairInfo
+ {
+ SCAddress pairContractID;
+ uint32_t tokenAIndex;
+ uint32_t tokenBIndex;
+ };
+
+ struct SoroswapState
+ {
+ SCAddress factoryContractID;
+ SCAddress routerContractID;
+
+ std::vector pairs;
+ std::vector sacInstances;
+
+ LedgerKey routerCodeKey;
+ LedgerKey pairCodeKey;
+ LedgerKey factoryCodeKey;
+
+ LedgerKey routerInstanceKey;
+ LedgerKey factoryInstanceKey;
+
+ std::vector assets;
+ uint32_t numTokens = 0;
+ };
+ SoroswapState mSoroswapState;
+
+ // Counter for alternating swap direction per pair
+ std::vector mSoroswapSwapCounters;
+
// Counter for generating unique destination addresses for SAC payments
uint32_t mDestCounter = 0;
};
diff --git a/src/simulation/TxGenerator.h b/src/simulation/TxGenerator.h
index ebb556737..dacd3d674 100644
--- a/src/simulation/TxGenerator.h
+++ b/src/simulation/TxGenerator.h
@@ -102,6 +102,8 @@ class TxGenerator
static constexpr uint64_t BATCH_TRANSFER_TX_INSTRUCTIONS = 500'000;
// Instructions per custom token transfer transaction
static constexpr uint64_t CUSTOM_TOKEN_TX_INSTRUCTIONS = 5'000'000;
+ // Instructions per Soroswap swap transaction
+ static constexpr uint64_t SOROSWAP_SWAP_TX_INSTRUCTIONS = 5'000'000;
static constexpr uint32_t SOROBAN_LOAD_V2_EVENT_SIZE_BYTES = 80;
// Special account ID to represent the root account
diff --git a/src/simulation/test/LoadGeneratorTests.cpp b/src/simulation/test/LoadGeneratorTests.cpp
index 7ade6369f..1d1bc7753 100644
--- a/src/simulation/test/LoadGeneratorTests.cpp
+++ b/src/simulation/test/LoadGeneratorTests.cpp
@@ -1143,6 +1143,38 @@ TEST_CASE("apply load benchmark custom token",
REQUIRE(successCountMetric.count() > 0);
}
+TEST_CASE("apply load benchmark soroswap",
+ "[loadgen][applyload][soroban][acceptance]")
+{
+ auto cfg = getTestConfig();
+ cfg.APPLY_LOAD_MODE = ApplyLoadMode::BENCHMARK_MODEL_TX;
+ cfg.APPLY_LOAD_MODEL_TX = ApplyLoadModelTx::SOROSWAP;
+ cfg.USE_CONFIG_FOR_GENESIS = true;
+ cfg.LEDGER_PROTOCOL_VERSION = Config::CURRENT_LEDGER_PROTOCOL_VERSION;
+ cfg.MANUAL_CLOSE = true;
+ cfg.IGNORE_MESSAGE_LIMITS_FOR_TESTING = true;
+ cfg.GENESIS_TEST_ACCOUNT_COUNT = 10000;
+ cfg.ENABLE_SOROBAN_DIAGNOSTIC_EVENTS = true;
+
+ cfg.APPLY_LOAD_NUM_LEDGERS = 10;
+ cfg.APPLY_LOAD_MAX_SOROBAN_TX_COUNT = 1000;
+ cfg.APPLY_LOAD_LEDGER_MAX_DEPENDENT_TX_CLUSTERS = 4;
+ cfg.APPLY_LOAD_CLASSIC_TXS_PER_LEDGER = 100;
+
+ VirtualClock clock(VirtualClock::REAL_TIME);
+ auto app = createTestApplication(clock, cfg);
+
+ ApplyLoad al(*app);
+
+ al.execute();
+
+ REQUIRE(1.0 - al.successRate() < std::numeric_limits::epsilon());
+
+ auto& successCountMetric =
+ app->getMetrics().NewCounter({"ledger", "apply-soroban", "success"});
+ REQUIRE(successCountMetric.count() > 0);
+}
+
TEST_CASE("noisy binary search", "[applyload]")
{
std::mt19937 rng(12345); // Fixed seed for reproducibility