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