From 607e91fb3865b409490d888a412bc668ebd8bdf5 Mon Sep 17 00:00:00 2001 From: marta-lokhova Date: Wed, 22 Apr 2026 16:03:53 -0700 Subject: [PATCH 1/3] Overlay-only mode: run most of checkValid, simulate account loads --- src/herder/TransactionQueue.cpp | 8 +++++++- src/herder/TxSetUtils.cpp | 4 ++++ src/ledger/ImmutableLedgerView.h | 7 +++++++ src/simulation/test/LoadGeneratorTests.cpp | 13 ++++++++++++- src/transactions/TransactionFrame.cpp | 13 +++++-------- 5 files changed, 35 insertions(+), 10 deletions(-) diff --git a/src/herder/TransactionQueue.cpp b/src/herder/TransactionQueue.cpp index 193c7b2708..57af03cea4 100644 --- a/src/herder/TransactionQueue.cpp +++ b/src/herder/TransactionQueue.cpp @@ -440,6 +440,12 @@ TransactionQueue::canAdd( } CheckValidLedgerViewWrapper ledgerView(mApp); +#ifdef BUILD_TESTS + // Overlay-only mode freezes on-disk seqnums at genesis but LoadGenerator + // keeps advancing local ones, so checkValid must skip the seqnum equality + // check or every tx after the first fails. + ledgerView.mSkipSeqNumCheck = mApp.getRunInOverlayOnlyMode(); +#endif // Subtle: transactions are rejected based on the source account limit // prior to this point. This is safe because we can't evict transactions // from the same source account, so a newer transaction won't replace an @@ -496,7 +502,7 @@ TransactionQueue::canAdd( // Loadgen transactions are given unlimited funds, and therefore do no need // to be checked for fees #ifdef BUILD_TESTS - if (!isLoadgenTx && !mApp.getRunInOverlayOnlyMode()) + if (!isLoadgenTx) #endif { auto const feeSource = ledgerView.getAccount(tx->getFeeSourceID()); diff --git a/src/herder/TxSetUtils.cpp b/src/herder/TxSetUtils.cpp index bf6aeeec8e..2a98d38b32 100644 --- a/src/herder/TxSetUtils.cpp +++ b/src/herder/TxSetUtils.cpp @@ -172,6 +172,10 @@ TxSetUtils::getInvalidTxListWithErrors( ZoneScoped; releaseAssert(threadIsMain()); CheckValidLedgerViewWrapper ledgerView(app); +#ifdef BUILD_TESTS + // See TransactionQueue::canAdd for the overlay-only-mode rationale. + ledgerView.mSkipSeqNumCheck = app.getRunInOverlayOnlyMode(); +#endif // Validate minSeqLedgerGap and LedgerBounds against the next ledgerSeq, // which is what will be used at apply time. std::optional validationLedgerSeq; diff --git a/src/ledger/ImmutableLedgerView.h b/src/ledger/ImmutableLedgerView.h index ef7a91353f..98ccf4dfe1 100644 --- a/src/ledger/ImmutableLedgerView.h +++ b/src/ledger/ImmutableLedgerView.h @@ -231,6 +231,13 @@ class CheckValidLedgerViewWrapper : public NonMovableOrCopyable CheckValidLedgerViewWrapper(AbstractLedgerTxn& ltx); CheckValidLedgerViewWrapper(Application& app); explicit CheckValidLedgerViewWrapper(ImmutableLedgerView const& ledgerView); +#ifdef BUILD_TESTS + // Set by overlay-only mode call sites so commonValid skips the seqnum + // equality check: on-disk seqnums are frozen at genesis while + // LoadGenerator keeps advancing its local counters, so every tx after the + // first would otherwise fail isBadSeq. + bool mSkipSeqNumCheck{false}; +#endif LedgerHeaderWrapper getLedgerHeader() const; LedgerEntryWrapper getAccount(AccountID const& account) const; LedgerEntryWrapper diff --git a/src/simulation/test/LoadGeneratorTests.cpp b/src/simulation/test/LoadGeneratorTests.cpp index 9978ba1c68..fbbec28cb0 100644 --- a/src/simulation/test/LoadGeneratorTests.cpp +++ b/src/simulation/test/LoadGeneratorTests.cpp @@ -53,7 +53,10 @@ TEST_CASE("loadgen in overlay-only mode", "[loadgen]") uint32_t nAccounts = 1000; uint32_t nTxs = 100; - // Upgrade the network config. + // Upgrade the network config. Lift both ledger- and tx-level limits so + // SOROBAN_INVOKE_APPLY_LOAD's oversized invoke txs pass validation in + // overlay-only mode (where checkValid now runs end-to-end, including + // checkSorobanResources). upgradeSorobanNetworkConfig( [&](SorobanNetworkConfig& cfg) { auto mx = std::numeric_limits::max(); @@ -64,6 +67,14 @@ TEST_CASE("loadgen in overlay-only mode", "[loadgen]") cfg.mLedgerMaxDiskReadBytes = mx; cfg.mLedgerMaxWriteLedgerEntries = mx; cfg.mLedgerMaxWriteBytes = mx; + cfg.mTxMaxInstructions = mx; + cfg.mTxMaxDiskReadEntries = mx; + cfg.mTxMaxDiskReadBytes = mx; + cfg.mTxMaxWriteLedgerEntries = mx; + cfg.mTxMaxWriteBytes = mx; + cfg.mTxMaxFootprintEntries = mx; + cfg.mTxMaxSizeBytes = mx; + cfg.mTxMaxContractEventsSizeBytes = mx; }, simulation); diff --git a/src/transactions/TransactionFrame.cpp b/src/transactions/TransactionFrame.cpp index ed06a6850b..b26477a481 100644 --- a/src/transactions/TransactionFrame.cpp +++ b/src/transactions/TransactionFrame.cpp @@ -1723,7 +1723,11 @@ TransactionFrame::commonValid( { current = sourceAccount->current().data.account().seqNum; } - if (isBadSeq(header, current)) + bool forceCheck = true; +#ifdef BUILD_TESTS + forceCheck = !ledgerView.mSkipSeqNumCheck; +#endif + if (forceCheck && isBadSeq(header, current)) { txResult.setInnermostError(txBAD_SEQ); return; @@ -1976,13 +1980,6 @@ TransactionFrame::checkValidImpl( DiagnosticEventManager& diagnosticEvents, bool isOverlayValidation, std::optional validationLedgerSeq) const { -#ifdef BUILD_TESTS - if (app.getRunInOverlayOnlyMode()) - { - return MutableTransactionResult::createSuccess(*this, 0); - } -#endif - // Subtle: this check has to happen in `checkValid` and not // `checkValidWithOptionallyChargedFee` in order to not validate the // envelope XDR twice for the fee bump transactions (they use From b9ea7b02fba793bab83989a7059b7a2f92d06a53 Mon Sep 17 00:00:00 2001 From: marta-lokhova Date: Thu, 23 Apr 2026 10:32:04 -0700 Subject: [PATCH 2/3] Add new loadgen modes for overlay-only tx profiles --- src/main/CommandHandler.cpp | 20 +- src/simulation/ApplyLoad.cpp | 131 +------------ src/simulation/ApplyLoad.h | 29 +-- src/simulation/LoadGenerator.cpp | 204 +++++++++++++++++-- src/simulation/LoadGenerator.h | 44 ++++- src/simulation/TxGenerator.cpp | 217 +++++++++++++++++++++ src/simulation/TxGenerator.h | 56 ++++++ src/simulation/test/LoadGeneratorTests.cpp | 104 ++++++++++ 8 files changed, 634 insertions(+), 171 deletions(-) diff --git a/src/main/CommandHandler.cpp b/src/main/CommandHandler.cpp index 9fce0fa428..94c649d704 100644 --- a/src/main/CommandHandler.cpp +++ b/src/main/CommandHandler.cpp @@ -1446,13 +1446,31 @@ CommandHandler::generateLoad(std::string const& params, std::string& retStr) } } - if (cfg.mode == LoadGenMode::PAY_PREGENERATED) + if (cfg.mode == LoadGenMode::PAY_PREGENERATED || cfg.modeMixesPregen()) { // Always use the configuration file path cfg.preloadedTransactionsFile = mApp.getConfig().LOADGEN_PREGENERATED_TRANSACTIONS_FILE; } + if (cfg.modeMixesPregen()) + { + auto& mix = cfg.getMutMixPregenSorobanConfig(); + mix.classicTxRate = + parseOptionalParamOrDefault(map, "classictxrate", 0); + mix.sorobanTxRate = + parseOptionalParamOrDefault(map, "sorobantxrate", 0); + if (mix.classicTxRate == 0 && mix.sorobanTxRate == 0) + { + retStr = "At least one of classictxrate / sorobantxrate must " + "be non-zero"; + return; + } + // cfg.txRate is used for progress / step scheduling logs; set the + // combined rate so users see sensible throughput output. + cfg.txRate = mix.classicTxRate + mix.sorobanTxRate; + } + if (cfg.maxGeneratedFeeRate) { auto baseFee = mApp.getLedgerManager().getLastTxFee(); diff --git a/src/simulation/ApplyLoad.cpp b/src/simulation/ApplyLoad.cpp index 3c1d5ce5f4..f881ef2d40 100644 --- a/src/simulation/ApplyLoad.cpp +++ b/src/simulation/ApplyLoad.cpp @@ -38,26 +38,6 @@ 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) { @@ -2819,7 +2799,7 @@ ApplyLoad::setupSoroswapContracts() txtest::makeContractInstanceKey(pairAddress); // Store pair info - SoroswapPairInfo pairInfo; + TxGenerator::SoroswapPairInfo pairInfo; pairInfo.tokenAIndex = i; pairInfo.tokenBIndex = j; pairInfo.pairContractID = pairAddress; @@ -3089,7 +3069,6 @@ ApplyLoad::generateSoroswapSwaps(std::vector& txs, { // 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; @@ -3098,110 +3077,10 @@ ApplyLoad::generateSoroswapSwaps(std::vector& txs, 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); + auto tx = mTxGenerator.invokeSoroswapSwap( + lm.getLastClosedLedgerNum() + 1, accountIdx, mSoroswapState, + pairIndex, swapAForB, std::nullopt); + txs.push_back(tx.second); } CheckValidLedgerViewWrapper ls(mApp); diff --git a/src/simulation/ApplyLoad.h b/src/simulation/ApplyLoad.h index d16c200f44..9dcc3788ca 100644 --- a/src/simulation/ApplyLoad.h +++ b/src/simulation/ApplyLoad.h @@ -188,33 +188,8 @@ 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; + // Soroswap AMM benchmark state (type defined in TxGenerator.h) + TxGenerator::SoroswapState mSoroswapState; // Counter for alternating swap direction per pair std::vector mSoroswapSwapCounters; diff --git a/src/simulation/LoadGenerator.cpp b/src/simulation/LoadGenerator.cpp index 769da1ead7..6f78d1ba99 100644 --- a/src/simulation/LoadGenerator.cpp +++ b/src/simulation/LoadGenerator.cpp @@ -158,6 +158,18 @@ LoadGenerator::getMode(std::string const& mode) { return LoadGenMode::SOROBAN_INVOKE_APPLY_LOAD; } + else if (mode == "mixed_pregen_sac_payment") + { + return LoadGenMode::MIXED_PREGEN_SAC_PAYMENT; + } + else if (mode == "mixed_pregen_oz_token_transfer") + { + return LoadGenMode::MIXED_PREGEN_OZ_TOKEN_TRANSFER; + } + else if (mode == "mixed_pregen_soroswap_swap") + { + return LoadGenMode::MIXED_PREGEN_SOROSWAP_SWAP; + } else { throw std::runtime_error( @@ -290,12 +302,14 @@ LoadGenerator::start(GeneratedLoadConfig& cfg) } mTransactionsAppliedAtTheStart = getTxCount(mApp, cfg.isSoroban()); - if (cfg.mode == LoadGenMode::SOROBAN_INVOKE_APPLY_LOAD && + if ((cfg.mode == LoadGenMode::SOROBAN_INVOKE_APPLY_LOAD || + cfg.modeMixesPregen()) && !mApp.getRunInOverlayOnlyMode()) { reset(); throw std::runtime_error( - "Can only run SOROBAN_INVOKE_APPLY_LOAD in overlay only mode"); + "Can only run SOROBAN_INVOKE_APPLY_LOAD / MIXED_PREGEN_* modes in " + "overlay only mode"); } if (cfg.txRate == 0) { @@ -418,7 +432,7 @@ LoadGenerator::start(GeneratedLoadConfig& cfg) mPreLoadgenApplySorobanFailure = mTxGenerator.getApplySorobanFailure().count(); - if (cfg.mode == LoadGenMode::PAY_PREGENERATED) + if (cfg.mode == LoadGenMode::PAY_PREGENERATED || cfg.modeMixesPregen()) { if (!mPreloadedTransactionsFile) { @@ -513,17 +527,17 @@ LoadGenerator::scheduleLoadGeneration(GeneratedLoadConfig cfg) } } - if (cfg.mode == LoadGenMode::PAY_PREGENERATED) + if (cfg.mode == LoadGenMode::PAY_PREGENERATED || cfg.modeMixesPregen()) { if (mApp.getConfig().GENESIS_TEST_ACCOUNT_COUNT == 0) { - errorMsg = "PAY_PREGENERATED mode requires non-zero " - "GENESIS_TEST_ACCOUNT_COUNT"; + errorMsg = "PAY_PREGENERATED / MIXED_PREGEN_* modes require " + "non-zero GENESIS_TEST_ACCOUNT_COUNT"; } else if (cfg.preloadedTransactionsFile.empty()) { - errorMsg = - "PAY_PREGENERATED mode requires preloadedTransactionsFile"; + errorMsg = "PAY_PREGENERATED / MIXED_PREGEN_* modes require " + "preloadedTransactionsFile"; } } @@ -606,6 +620,15 @@ GeneratedLoadConfig::getStatus() const case LoadGenMode::SOROBAN_INVOKE_APPLY_LOAD: modeStr = "SOROBAN_INVOKE_APPLY_LOAD"; break; + case LoadGenMode::MIXED_PREGEN_SAC_PAYMENT: + modeStr = "mixed_pregen_sac_payment"; + break; + case LoadGenMode::MIXED_PREGEN_OZ_TOKEN_TRANSFER: + modeStr = "mixed_pregen_oz_token_transfer"; + break; + case LoadGenMode::MIXED_PREGEN_SOROSWAP_SWAP: + modeStr = "mixed_pregen_soroswap_swap"; + break; } ret["mode"] = modeStr; @@ -672,7 +695,23 @@ LoadGenerator::generateLoad(GeneratedLoadConfig cfg) return; } - auto txPerStep = getTxPerStep(cfg.txRate, cfg.spikeInterval, cfg.spikeSize); + // For MIXED_PREGEN_* modes, classic and soroban streams have independent + // TPS. Compute per-step budgets up front; the dispatch emits both streams + // within the same step. + int64_t mixedClassicBudget = 0; + int64_t mixedSorobanBudget = 0; + if (cfg.modeMixesPregen()) + { + auto const& mix = cfg.getMixPregenSorobanConfig(); + mixedClassicBudget = + getTxPerStep(mix.classicTxRate, cfg.spikeInterval, cfg.spikeSize); + mixedSorobanBudget = + getTxPerStep(mix.sorobanTxRate, cfg.spikeInterval, cfg.spikeSize); + } + auto txPerStep = + cfg.modeMixesPregen() + ? (mixedClassicBudget + mixedSorobanBudget) + : getTxPerStep(cfg.txRate, cfg.spikeInterval, cfg.spikeSize); auto submitScope = mStepTimer.TimeScope(); uint64_t now = mApp.timeNow(); @@ -688,7 +727,7 @@ LoadGenerator::generateLoad(GeneratedLoadConfig cfg) for (int64_t i = 0; i < txPerStep; ++i) { if (mAccountsAvailable.empty() && - cfg.mode != LoadGenMode::PAY_PREGENERATED) + cfg.mode != LoadGenMode::PAY_PREGENERATED && !cfg.modeMixesPregen()) { CLOG_WARNING(LoadGen, "Load generation failed: no more accounts available"); @@ -698,7 +737,7 @@ LoadGenerator::generateLoad(GeneratedLoadConfig cfg) } uint64_t sourceAccountId = 0; - if (cfg.mode != LoadGenMode::PAY_PREGENERATED) + if (cfg.mode != LoadGenMode::PAY_PREGENERATED && !cfg.modeMixesPregen()) { sourceAccountId = getNextAvailableAccount(ledgerNum); } @@ -798,6 +837,29 @@ LoadGenerator::generateLoad(GeneratedLoadConfig cfg) dataEntrySize, cfg.maxGeneratedFeeRate); }; break; + case LoadGenMode::MIXED_PREGEN_SAC_PAYMENT: + case LoadGenMode::MIXED_PREGEN_OZ_TOKEN_TRANSFER: + case LoadGenMode::MIXED_PREGEN_SOROSWAP_SWAP: + generateTx = [&]() { + // Soroban stream drained first each step, then classic. + if (mixedSorobanBudget > 0) + { + if (mAccountsAvailable.empty()) + { + throw std::runtime_error( + "Load generation failed: no more accounts " + "available for soroban stream"); + } + --mixedSorobanBudget; + uint64_t srcId = getNextAvailableAccount(ledgerNum); + return createSyntheticSorobanTransaction(ledgerNum, srcId, + cfg); + } + releaseAssert(mixedClassicBudget > 0); + --mixedClassicBudget; + return readTransactionFromFile(cfg); + }; + break; } try @@ -871,14 +933,14 @@ LoadGenerator::submitTx(GeneratedLoadConfig const& cfg, return false; } - // No re-submission in PAY_PREGENERATED mode. + // No re-submission in PAY_PREGENERATED / MIXED_PREGEN_* modes. // Each transaction is for a unique source account, so we // should not see BAD_SEQ error codes unless core is actually dropping // txs due to overload (in which case we should just fail loadgen, // instead of re-submitting) if (++numTries >= TX_SUBMIT_MAX_TRIES || status != TransactionQueue::AddResultCode::ADD_STATUS_ERROR || - cfg.mode == LoadGenMode::PAY_PREGENERATED) + cfg.mode == LoadGenMode::PAY_PREGENERATED || cfg.modeMixesPregen()) { mFailed = true; return false; @@ -1031,6 +1093,72 @@ LoadGenerator::createMixedClassicSorobanTransaction( } } +std::pair +LoadGenerator::createSyntheticSorobanTransaction(uint32_t ledgerNum, + uint64_t sourceAccountId, + GeneratedLoadConfig const& cfg) +{ + switch (cfg.mode) + { + case LoadGenMode::MIXED_PREGEN_SAC_PAYMENT: + { + if (!mSyntheticSACInstance) + { + mSyntheticSACInstance = + makeSyntheticContractInstance("loadgen_sac"); + } + // Each tx targets a fresh synthetic destination to avoid RW conflicts. + SCAddress dest(SC_ADDRESS_TYPE_CONTRACT); + dest.contractId() = sha256(fmt::format( + "loadgen_sac_dest_{}_{}", ledgerNum, mSyntheticSACDestCounter++)); + return mTxGenerator.invokeSACPayment( + ledgerNum, sourceAccountId, dest, *mSyntheticSACInstance, + /* amount */ 100, cfg.maxGeneratedFeeRate); + } + case LoadGenMode::MIXED_PREGEN_OZ_TOKEN_TRANSFER: + { + if (!mSyntheticTokenInstance) + { + mSyntheticTokenInstance = + makeSyntheticContractInstance("loadgen_oz_token"); + } + // Pick a destination account ID distinct from the source. + uint64_t toId = + rand_uniform(0, cfg.nAccounts - 1) + cfg.offset; + if (toId == sourceAccountId) + { + toId = cfg.offset + + ((sourceAccountId - cfg.offset + 1) % cfg.nAccounts); + } + return mTxGenerator.invokeTokenTransfer( + ledgerNum, sourceAccountId, toId, *mSyntheticTokenInstance, + /* amount */ 100, cfg.maxGeneratedFeeRate); + } + case LoadGenMode::MIXED_PREGEN_SOROSWAP_SWAP: + { + if (!mSyntheticSoroswapState) + { + // Small fixed pool: 4 tokens, 2 pairs — enough to exercise + // round-robin + direction alternation. + mSyntheticSoroswapState = + makeSyntheticSoroswapState(/* numTokens */ 4, + /* numPairs */ 2); + } + auto const& state = *mSyntheticSoroswapState; + uint32_t pairIndex = mSyntheticSoroswapSwapCounter % state.pairs.size(); + bool swapAForB = + (mSyntheticSoroswapSwapCounter / state.pairs.size()) % 2 == 0; + ++mSyntheticSoroswapSwapCounter; + return mTxGenerator.invokeSoroswapSwap(ledgerNum, sourceAccountId, + state, pairIndex, swapAForB, + cfg.maxGeneratedFeeRate); + } + default: + throw std::runtime_error( + "createSyntheticSorobanTransaction: unexpected mode"); + } +} + std::pair LoadGenerator::createUploadWasmTransaction(GeneratedLoadConfig const& cfg, uint32_t ledgerNum, @@ -1408,6 +1536,22 @@ LoadGenerator::execute(TransactionFrameBasePtr txf, LoadGenMode mode, case LoadGenMode::SOROBAN_INVOKE_APPLY_LOAD: txm.mSorobanInvokeTxs.Mark(); break; + case LoadGenMode::MIXED_PREGEN_SAC_PAYMENT: + case LoadGenMode::MIXED_PREGEN_OZ_TOKEN_TRANSFER: + case LoadGenMode::MIXED_PREGEN_SOROSWAP_SWAP: + // Both streams flow through here; classify by whether the tx is + // soroban. + if (txf->isSoroban()) + { + txm.mSorobanInvokeTxs.Mark(); + } + else + { + txm.mNativePayment.Mark(txf->getNumOperations()); + txm.mNativePaymentBytes.Mark( + xdr::xdr_argpack_size(*txf->toStellarMessage())); + } + break; } txm.mTxnAttempted.Mark(); @@ -1415,8 +1559,14 @@ LoadGenerator::execute(TransactionFrameBasePtr txf, LoadGenMode mode, auto msg = txf->toStellarMessage(); txm.mTxnBytes.Mark(xdr::xdr_argpack_size(*msg)); - // Skip certain checks for pregenerated transactions - bool isPregeneratedTx = (mode == LoadGenMode::PAY_PREGENERATED); + // Skip certain checks for pregenerated transactions (MIXED_PREGEN_* modes + // also inject pregen txs, so flag them the same way — the flag is harmless + // for the soroban half under overlay-only). + bool isPregeneratedTx = + (mode == LoadGenMode::PAY_PREGENERATED) || + (mode == LoadGenMode::MIXED_PREGEN_SAC_PAYMENT) || + (mode == LoadGenMode::MIXED_PREGEN_OZ_TOKEN_TRANSFER) || + (mode == LoadGenMode::MIXED_PREGEN_SOROSWAP_SWAP); auto addResult = mApp.getHerder().recvTransaction( txf, true, /*force=*/false, /*isLoadgenTx=*/isPregeneratedTx); if (addResult.code != TransactionQueue::AddResultCode::ADD_STATUS_PENDING) @@ -1696,6 +1846,20 @@ GeneratedLoadConfig::getMixClassicSorobanConfig() const return mixClassicSorobanConfig; } +GeneratedLoadConfig::MixPregenSorobanConfig& +GeneratedLoadConfig::getMutMixPregenSorobanConfig() +{ + releaseAssert(modeMixesPregen()); + return mixPregenSorobanConfig; +} + +GeneratedLoadConfig::MixPregenSorobanConfig const& +GeneratedLoadConfig::getMixPregenSorobanConfig() const +{ + releaseAssert(modeMixesPregen()); + return mixPregenSorobanConfig; +} + uint32_t GeneratedLoadConfig::getMinSorobanPercentSuccess() const { @@ -1741,7 +1905,7 @@ GeneratedLoadConfig::isLoad() const mode == LoadGenMode::SOROBAN_CREATE_UPGRADE || mode == LoadGenMode::MIXED_CLASSIC_SOROBAN || mode == LoadGenMode::PAY_PREGENERATED || - mode == LoadGenMode::SOROBAN_INVOKE_APPLY_LOAD; + mode == LoadGenMode::SOROBAN_INVOKE_APPLY_LOAD || modeMixesPregen(); } bool @@ -1752,6 +1916,14 @@ GeneratedLoadConfig::modeInvokes() const mode == LoadGenMode::SOROBAN_INVOKE_APPLY_LOAD; } +bool +GeneratedLoadConfig::modeMixesPregen() const +{ + return mode == LoadGenMode::MIXED_PREGEN_SAC_PAYMENT || + mode == LoadGenMode::MIXED_PREGEN_OZ_TOKEN_TRANSFER || + mode == LoadGenMode::MIXED_PREGEN_SOROSWAP_SWAP; +} + bool GeneratedLoadConfig::modeSetsUpInvoke() const { diff --git a/src/simulation/LoadGenerator.h b/src/simulation/LoadGenerator.h index ff159abedb..51b81644a0 100644 --- a/src/simulation/LoadGenerator.h +++ b/src/simulation/LoadGenerator.h @@ -46,7 +46,14 @@ enum class LoadGenMode // Submit pre-generated payment transactions from an XDR file PAY_PREGENERATED, // Submit the same type of invoke transaction as ApplyLoad - SOROBAN_INVOKE_APPLY_LOAD + SOROBAN_INVOKE_APPLY_LOAD, + // Overlay-only modes: pre-generated classic payments + a soroban + // transaction type of choice, each with its own TPS. No on-ledger setup + // is required; soroban contract keys are synthesized in memory. Apply + // is skipped so nothing is actually executed. + MIXED_PREGEN_SAC_PAYMENT, + MIXED_PREGEN_OZ_TOKEN_TRANSFER, + MIXED_PREGEN_SOROSWAP_SWAP }; struct GeneratedLoadConfig @@ -71,6 +78,16 @@ struct GeneratedLoadConfig double sorobanInvokeWeight = 0; }; + // Config settings for the MIXED_PREGEN_* overlay-only modes. + // Each stream has its own independent TPS. Setting `classicTxRate` to 0 + // yields a pure-soroban run; `sorobanTxRate` to 0 is equivalent to + // PAY_PREGENERATED. `nTxs` is the combined stop target. + struct MixPregenSorobanConfig + { + uint32_t classicTxRate = 0; + uint32_t sorobanTxRate = 0; + }; + void copySorobanNetworkConfigToUpgradeConfig( SorobanNetworkConfig const& baseConfig, SorobanNetworkConfig const& updatedConfig); @@ -95,6 +112,8 @@ struct GeneratedLoadConfig SorobanUpgradeConfig const& getSorobanUpgradeConfig() const; MixClassicSorobanConfig& getMutMixClassicSorobanConfig(); MixClassicSorobanConfig const& getMixClassicSorobanConfig() const; + MixPregenSorobanConfig& getMutMixPregenSorobanConfig(); + MixPregenSorobanConfig const& getMixPregenSorobanConfig() const; uint32_t getMinSorobanPercentSuccess() const; void setMinSorobanPercentSuccess(uint32_t minPercentSuccess); @@ -105,6 +124,10 @@ struct GeneratedLoadConfig // True iff mode generates SOROBAN_INVOKE load bool modeInvokes() const; + // True iff mode emits a classic pregen stream in parallel with a soroban + // stream (MIXED_PREGEN_* modes). + bool modeMixesPregen() const; + // True iff mode generates SOROBAN_INVOKE_SETUP load bool modeSetsUpInvoke() const; @@ -141,6 +164,7 @@ struct GeneratedLoadConfig SorobanConfig sorobanConfig; SorobanUpgradeConfig sorobanUpgradeConfig; MixClassicSorobanConfig mixClassicSorobanConfig; + MixPregenSorobanConfig mixPregenSorobanConfig; // Minimum percentage of successful soroban transactions for run to be // considered successful. @@ -284,6 +308,16 @@ class LoadGenerator // unique instance UnorderedMap mContractInstances; + // Synthetic (off-ledger) contract state for the MIXED_PREGEN_* overlay-only + // modes. Lazily populated on first use of each mode; never touches the DB. + std::optional mSyntheticSACInstance; + std::optional mSyntheticTokenInstance; + std::optional mSyntheticSoroswapState; + // Counter for generating unique SAC payment destination addresses. + uint32_t mSyntheticSACDestCounter{0}; + // Round-robin counter for Soroswap pair selection + direction. + uint32_t mSyntheticSoroswapSwapCounter{0}; + TxGenerator::TestAccountPtr mRoot; medida::Meter& mLoadgenComplete; @@ -317,6 +351,14 @@ class LoadGenerator std::optional classicByteCount, GeneratedLoadConfig const& cfg); + // Build a synthetic-state soroban transaction for the requested mode + // (one of MIXED_PREGEN_SAC_PAYMENT / OZ_TOKEN_TRANSFER / SOROSWAP_SWAP). + // Lazily initializes the mSynthetic* state on first call. + std::pair + createSyntheticSorobanTransaction(uint32_t ledgerNum, + uint64_t sourceAccountId, + GeneratedLoadConfig const& cfg); + std::pair createUploadWasmTransaction(GeneratedLoadConfig const& cfg, uint32_t ledgerNum, uint64_t sourceAccountId); diff --git a/src/simulation/TxGenerator.cpp b/src/simulation/TxGenerator.cpp index 53cd1776cd..5f2dba47fe 100644 --- a/src/simulation/TxGenerator.cpp +++ b/src/simulation/TxGenerator.cpp @@ -4,8 +4,10 @@ #include "simulation/ApplyLoad.h" #include "simulation/LoadGenerator.h" #include "transactions/TransactionBridge.h" +#include "transactions/TransactionUtils.h" #include "transactions/test/SorobanTxTestUtils.h" #include "util/MetricsRegistry.h" +#include "util/types.h" #include #include @@ -884,6 +886,221 @@ TxGenerator::invokeTokenTransfer(uint32_t ledgerNum, uint64_t fromAccountId, return std::make_pair(fromAccount, tx); } +TxGenerator::ContractInstance +makeSyntheticContractInstance(std::string const& salt) +{ + TxGenerator::ContractInstance instance; + + SCAddress contractID(SC_ADDRESS_TYPE_CONTRACT); + contractID.contractId() = sha256("contract_" + salt); + instance.contractID = contractID; + + LedgerKey codeKey(CONTRACT_CODE); + codeKey.contractCode().hash = sha256("code_" + salt); + instance.readOnlyKeys.emplace_back(codeKey); + + instance.readOnlyKeys.emplace_back( + txtest::makeContractInstanceKey(contractID)); + + return instance; +} + +TxGenerator::SoroswapState +makeSyntheticSoroswapState(uint32_t numTokens, uint32_t numPairs) +{ + releaseAssert(numTokens >= 2); + releaseAssert(numPairs >= 1); + + TxGenerator::SoroswapState state; + state.numTokens = numTokens; + + // Synthetic issuer PublicKey for all fake assets. + PublicKey issuer(PUBLIC_KEY_TYPE_ED25519); + issuer.ed25519() = sha256("soroswap_issuer"); + + // Build numTokens synthetic assets + SAC instances. + state.assets.reserve(numTokens); + state.sacInstances.reserve(numTokens); + for (uint32_t i = 0; i < numTokens; ++i) + { + Asset asset(ASSET_TYPE_CREDIT_ALPHANUM4); + auto code = fmt::format("TK{:02}", i); + std::memset(asset.alphaNum4().assetCode.data(), 0, + asset.alphaNum4().assetCode.size()); + std::memcpy(asset.alphaNum4().assetCode.data(), code.data(), + std::min(code.size(), asset.alphaNum4().assetCode.size())); + asset.alphaNum4().issuer = issuer; + state.assets.push_back(asset); + + state.sacInstances.push_back( + makeSyntheticContractInstance("sac_" + std::to_string(i))); + } + + // Factory + router: synthetic code + instance keys. + state.factoryCodeKey.type(CONTRACT_CODE); + state.factoryCodeKey.contractCode().hash = sha256("soroswap_factory_code"); + + state.pairCodeKey.type(CONTRACT_CODE); + state.pairCodeKey.contractCode().hash = sha256("soroswap_pair_code"); + + state.routerCodeKey.type(CONTRACT_CODE); + state.routerCodeKey.contractCode().hash = sha256("soroswap_router_code"); + + SCAddress factoryAddr(SC_ADDRESS_TYPE_CONTRACT); + factoryAddr.contractId() = sha256("soroswap_factory"); + state.factoryContractID = factoryAddr; + state.factoryInstanceKey = txtest::makeContractInstanceKey(factoryAddr); + + SCAddress routerAddr(SC_ADDRESS_TYPE_CONTRACT); + routerAddr.contractId() = sha256("soroswap_router"); + state.routerContractID = routerAddr; + state.routerInstanceKey = txtest::makeContractInstanceKey(routerAddr); + + // Pairs: consecutive token indices modulo numTokens. + state.pairs.reserve(numPairs); + for (uint32_t p = 0; p < numPairs; ++p) + { + TxGenerator::SoroswapPairInfo pairInfo; + pairInfo.tokenAIndex = p % numTokens; + pairInfo.tokenBIndex = (p + 1) % numTokens; + SCAddress pairAddr(SC_ADDRESS_TYPE_CONTRACT); + pairAddr.contractId() = sha256("soroswap_pair_" + std::to_string(p)); + pairInfo.pairContractID = pairAddr; + state.pairs.push_back(pairInfo); + } + + return state; +} + +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; +} + +std::pair +TxGenerator::invokeSoroswapSwap(uint32_t ledgerNum, uint64_t fromAccountId, + SoroswapState const& state, size_t pairIndex, + bool swapAForB, + std::optional maxGeneratedFeeRate) +{ + releaseAssert(pairIndex < state.pairs.size()); + auto const& pair = state.pairs[pairIndex]; + + uint32_t tokenInIdx = swapAForB ? pair.tokenAIndex : pair.tokenBIndex; + uint32_t tokenOutIdx = swapAForB ? pair.tokenBIndex : pair.tokenAIndex; + + auto fromAccount = findAccount(fromAccountId, ledgerNum); + fromAccount->loadSequenceNumber(); + + auto fromVal = + makeAddressSCVal(makeAccountAddress(fromAccount->getPublicKey())); + + // Build path: [token_in, token_out] + auto tokenInVal = + makeAddressSCVal(state.sacInstances[tokenInIdx].contractID); + auto tokenOutVal = + makeAddressSCVal(state.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 = state.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 = 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(state.routerInstanceKey); + resources.footprint.readOnly.push_back( + state.sacInstances[tokenInIdx].readOnlyKeys.at(0)); + resources.footprint.readOnly.push_back( + state.sacInstances[tokenOutIdx].readOnlyKeys.at(0)); + resources.footprint.readOnly.push_back(state.routerCodeKey); + resources.footprint.readOnly.push_back(state.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(), state.assets[tokenInIdx])); + resources.footprint.readWrite.emplace_back(makeTrustlineKey( + fromAccount->getPublicKey(), state.assets[tokenOutIdx])); + + auto pairAddrVal = makeAddressSCVal(pair.pairContractID); + resources.footprint.readWrite.emplace_back(makeSACBalanceKey( + state.sacInstances[tokenInIdx].contractID, pairAddrVal)); + resources.footprint.readWrite.emplace_back(makeSACBalanceKey( + state.sacInstances[tokenOutIdx].contractID, pairAddrVal)); + 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 = + state.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, + generateFee(maxGeneratedFeeRate, /* opsCnt */ 1), resourceFee); + return std::make_pair(fromAccount, tx); +} + std::map const& TxGenerator::getAccounts() { diff --git a/src/simulation/TxGenerator.h b/src/simulation/TxGenerator.h index dacd3d6743..76c0c8a88b 100644 --- a/src/simulation/TxGenerator.h +++ b/src/simulation/TxGenerator.h @@ -18,6 +18,13 @@ namespace stellar uint64_t footprintSize(Application& app, xdr::xvector const& keys); +// Build a CONTRACT_DATA ledger key for a SAC "Balance" entry. +LedgerKey makeSACBalanceKey(SCAddress const& sacContract, + SCVal const& holderAddrVal); + +// Build a TRUSTLINE ledger key for the given account + asset. +LedgerKey makeTrustlineKey(PublicKey const& accountID, Asset const& asset); + // Config settings for SOROBAN_CREATE_UPGRADE struct SorobanUpgradeConfig { @@ -117,6 +124,33 @@ class TxGenerator uint32_t contractEntriesSize = 0; }; + // 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; + }; + using TestAccountPtr = std::shared_ptr; TxGenerator(Application& app, uint32_t prePopulatedArchivedEntries = 0); @@ -201,6 +235,15 @@ class TxGenerator ContractInstance const& batchTransferInstance, ContractInstance const& sacInstance, std::vector const& destinations); + + // Build a Soroswap router swap_exact_tokens_for_tokens transaction for + // the given pair and direction. `state` supplies the router/pair/SAC keys + // and assets; `swapAForB` picks the direction. + std::pair + invokeSoroswapSwap(uint32_t ledgerNum, uint64_t fromAccountId, + SoroswapState const& state, size_t pairIndex, + bool swapAForB, + std::optional maxGeneratedFeeRate); std::pair invokeSorobanCreateUpgradeTransaction( uint32_t ledgerNum, uint64_t accountId, SCBytes const& upgradeBytes, @@ -261,4 +304,17 @@ class TxGenerator uint32_t mNextKeyToRestore{}; }; +// Build a fully-synthetic ContractInstance (contractID + CONTRACT_CODE + +// CONTRACT_DATA instance keys) derived from `salt`. The contract does not +// exist on-ledger; intended for overlay-only load-gen modes where apply is +// simulated and footprint entries are never dereferenced. +TxGenerator::ContractInstance +makeSyntheticContractInstance(std::string const& salt); + +// Build a fully-synthetic SoroswapState with `numTokens` SAC instances and +// `numPairs` pairs (all pairs use consecutive token indices modulo numTokens). +// Same overlay-only caveat as makeSyntheticContractInstance. +TxGenerator::SoroswapState makeSyntheticSoroswapState(uint32_t numTokens, + uint32_t numPairs); + } diff --git a/src/simulation/test/LoadGeneratorTests.cpp b/src/simulation/test/LoadGeneratorTests.cpp index fbbec28cb0..0e1084b37b 100644 --- a/src/simulation/test/LoadGeneratorTests.cpp +++ b/src/simulation/test/LoadGeneratorTests.cpp @@ -108,6 +108,110 @@ TEST_CASE("loadgen in overlay-only mode", "[loadgen]") 500 * simulation->getExpectedLedgerCloseTime(), false); } +TEST_CASE("mixed pregen and synthetic soroban in overlay-only mode", + "[loadgen]") +{ + // Pregen source accounts live in [nAccounts, 2*nAccounts); soroban source + // accounts live in [0, nAccounts). GENESIS_TEST_ACCOUNT_COUNT must cover + // both disjoint ranges. + uint32_t const nAccounts = 200; + uint32_t const genesisAccountCount = nAccounts * 2; + uint32_t const nTxs = 60; + + Hash networkID = sha256(getTestConfig().NETWORK_PASSPHRASE); + Simulation::pointer simulation = + Topologies::pair(Simulation::OVER_LOOPBACK, networkID, [&](int i) { + auto cfg = getTestConfig(i); + cfg.ARTIFICIALLY_ACCELERATE_TIME_FOR_TESTING = true; + cfg.ARTIFICIALLY_GENERATE_LOAD_FOR_TESTING = true; + cfg.TESTING_UPGRADE_LEDGER_PROTOCOL_VERSION = + Config::CURRENT_LEDGER_PROTOCOL_VERSION; + cfg.GENESIS_TEST_ACCOUNT_COUNT = genesisAccountCount; + return cfg; + }); + + simulation->startAllNodes(); + simulation->crankUntil( + [&]() { return simulation->haveAllExternalized(3, 1); }, + 10 * simulation->getExpectedLedgerCloseTime(), false); + + auto nodes = simulation->getNodes(); + auto& app = *nodes[0]; + + // Max out Soroban network limits so synthetic footprints pass checkValid. + upgradeSorobanNetworkConfig( + [&](SorobanNetworkConfig& cfg) { + auto mx = std::numeric_limits::max(); + cfg.mLedgerMaxTxCount = mx; + cfg.mLedgerMaxInstructions = mx; + cfg.mLedgerMaxTransactionsSizeBytes = mx; + cfg.mLedgerMaxDiskReadEntries = mx; + cfg.mLedgerMaxDiskReadBytes = mx; + cfg.mLedgerMaxWriteLedgerEntries = mx; + cfg.mLedgerMaxWriteBytes = mx; + cfg.mTxMaxInstructions = mx; + cfg.mTxMaxDiskReadEntries = mx; + cfg.mTxMaxDiskReadBytes = mx; + cfg.mTxMaxWriteLedgerEntries = mx; + cfg.mTxMaxWriteBytes = mx; + cfg.mTxMaxFootprintEntries = mx; + cfg.mTxMaxSizeBytes = mx; + cfg.mTxMaxContractEventsSizeBytes = mx; + }, + simulation); + + // Generate a pregen payments file targeting the upper half of the account + // range, so the classic stream never collides with the soroban stream. + std::string fileName = + app.getConfig().LOADGEN_PREGENERATED_TRANSACTIONS_FILE; + auto cleanup = gsl::finally([&]() { std::remove(fileName.c_str()); }); + generateTransactions(app, fileName, nTxs, nAccounts, + /* offset */ nAccounts); + + for (auto& node : nodes) + { + node->setRunInOverlayOnlyMode(true); + } + + auto prev = app.getMetrics() + .NewMeter({"loadgen", "run", "complete"}, "run") + .count(); + + auto runMixed = [&](LoadGenMode mode) { + GeneratedLoadConfig cfg = + GeneratedLoadConfig::txLoad(mode, nAccounts, nTxs, + /* txRate */ 1, /* offset */ nAccounts); + cfg.preloadedTransactionsFile = + app.getConfig().LOADGEN_PREGENERATED_TRANSACTIONS_FILE; + auto& mix = cfg.getMutMixPregenSorobanConfig(); + mix.classicTxRate = 20; + mix.sorobanTxRate = 5; + cfg.txRate = mix.classicTxRate + mix.sorobanTxRate; + app.getLoadGenerator().generateLoad(cfg); + }; + + SECTION("sac payment") + { + runMixed(LoadGenMode::MIXED_PREGEN_SAC_PAYMENT); + } + SECTION("oz token transfer") + { + runMixed(LoadGenMode::MIXED_PREGEN_OZ_TOKEN_TRANSFER); + } + SECTION("soroswap swap") + { + runMixed(LoadGenMode::MIXED_PREGEN_SOROSWAP_SWAP); + } + + simulation->crankUntil( + [&]() { + return app.getMetrics() + .NewMeter({"loadgen", "run", "complete"}, "run") + .count() == prev + 1; + }, + 500 * simulation->getExpectedLedgerCloseTime(), false); +} + TEST_CASE("generate load in protocol 1", "[loadgen]") { Hash networkID = sha256(getTestConfig().NETWORK_PASSPHRASE); From d50e9c963b50d662f5120dbabeb1e97bf38049d5 Mon Sep 17 00:00:00 2001 From: marta-lokhova Date: Thu, 23 Apr 2026 16:04:23 -0700 Subject: [PATCH 3/3] Adapt completion condition to new modes and relax unique account checks in overlay-only mode --- src/herder/HerderImpl.cpp | 6 +- src/herder/TxSetFrame.cpp | 9 +- src/ledger/LedgerManagerImpl.cpp | 8 ++ src/simulation/LoadGenerator.cpp | 101 +++++++++++++++------ src/simulation/LoadGenerator.h | 16 +++- src/simulation/test/LoadGeneratorTests.cpp | 18 ++-- src/transactions/TransactionFrame.cpp | 6 +- 7 files changed, 119 insertions(+), 45 deletions(-) diff --git a/src/herder/HerderImpl.cpp b/src/herder/HerderImpl.cpp index 99afcd398b..98d29f62eb 100644 --- a/src/herder/HerderImpl.cpp +++ b/src/herder/HerderImpl.cpp @@ -641,7 +641,11 @@ HerderImpl::recvTransaction(TransactionFrameBasePtr tx, bool submittedFromSelf, bool hasClassic = mTransactionQueue.sourceAccountPending(tx->getSourceID()) && tx->isSoroban(); - if (hasSoroban || hasClassic) + bool enforceSourceAccountLimit = true; +#ifdef BUILD_TESTS + enforceSourceAccountLimit = !mApp.getRunInOverlayOnlyMode(); +#endif + if (enforceSourceAccountLimit && (hasSoroban || hasClassic)) { CLOG_DEBUG(Herder, "recv transaction {} for {} rejected due to 1 tx per source " diff --git a/src/herder/TxSetFrame.cpp b/src/herder/TxSetFrame.cpp index 0eb6c4e3c6..b4a9d83348 100644 --- a/src/herder/TxSetFrame.cpp +++ b/src/herder/TxSetFrame.cpp @@ -2144,7 +2144,14 @@ ApplicableTxSetFrame::checkValidInternalWithResult( releaseAssert(mPhases.size() == 1); } - if (needGeneralizedTxSet) + bool enforceUniqueSourceAccount = needGeneralizedTxSet; +#ifdef BUILD_TESTS + if (app.getRunInOverlayOnlyMode()) + { + enforceUniqueSourceAccount = false; + } +#endif + if (enforceUniqueSourceAccount) { // Ensure the tx set does not contain multiple txs per source // account diff --git a/src/ledger/LedgerManagerImpl.cpp b/src/ledger/LedgerManagerImpl.cpp index 04233d2777..a16f6761c1 100644 --- a/src/ledger/LedgerManagerImpl.cpp +++ b/src/ledger/LedgerManagerImpl.cpp @@ -1620,6 +1620,9 @@ LedgerManagerImpl::applyLedger(LedgerCloseData const& ledgerData, { auto numTxs = txSet->sizeTxTotal(); auto numOps = txSet->sizeOpTotalForLogging(); + auto numClassicTxs = applicableTxSet->sizeTx(TxSetPhase::CLASSIC); + auto numSorobanTxs = applicableTxSet->sizeTx(TxSetPhase::SOROBAN); + auto ledgerSeq = header.current().ledgerSeq; // Force-deactivate header in overlay only mode; in normal mode, this is // done by `processFeesSeqNums` @@ -1631,6 +1634,11 @@ LedgerManagerImpl::applyLedger(LedgerCloseData const& ledgerData, mApplyState.getMetrics().mOperationCount.Update( static_cast(numOps)); TracyPlot("ledger.operation.count", static_cast(numOps)); + + CLOG_INFO(Ledger, + "Overlay-only mode: skipping apply for ledger {} " + "({} classic, {} soroban, {} total txs)", + ledgerSeq, numClassicTxs, numSorobanTxs, numTxs); } else #endif diff --git a/src/simulation/LoadGenerator.cpp b/src/simulation/LoadGenerator.cpp index 6f78d1ba99..476fa9c5ac 100644 --- a/src/simulation/LoadGenerator.cpp +++ b/src/simulation/LoadGenerator.cpp @@ -98,6 +98,14 @@ getTxCount(Application& app, bool isSoroban) } } +static bool +isMixedPregenMode(LoadGenMode mode) +{ + return mode == LoadGenMode::MIXED_PREGEN_SAC_PAYMENT || + mode == LoadGenMode::MIXED_PREGEN_OZ_TOKEN_TRANSFER || + mode == LoadGenMode::MIXED_PREGEN_SOROSWAP_SWAP; +} + LoadGenerator::LoadGenerator(Application& app) : mTxGenerator(app) , mApp(app) @@ -191,6 +199,13 @@ LoadGenerator::chooseByteCount(Config const& cfg) const int64_t LoadGenerator::getTxPerStep(uint32_t txRate, std::chrono::seconds spikeInterval, uint32_t spikeSize) +{ + return getTxPerStep(txRate, spikeInterval, spikeSize, mTotalSubmitted); +} + +int64_t +LoadGenerator::getTxPerStep(uint32_t txRate, std::chrono::seconds spikeInterval, + uint32_t spikeSize, uint64_t submittedSoFar) { if (!mStartTime) { @@ -213,12 +228,12 @@ LoadGenerator::getTxPerStep(uint32_t txRate, std::chrono::seconds spikeInterval, spikeSize; } - if (txs <= mTotalSubmitted) + if (txs <= static_cast(submittedSoFar)) { return 0; } - return txs - mTotalSubmitted; + return txs - static_cast(submittedSoFar); } void @@ -258,13 +273,21 @@ LoadGenerator::reset() mLoadTimer.reset(); mStartTime.reset(); mTotalSubmitted = 0; + mClassicSubmitted = 0; + mSorobanSubmitted = 0; + mClassicAppliedAtStart = 0; + mSorobanAppliedAtStart = 0; + mSyntheticSACInstance.reset(); + mSyntheticTokenInstance.reset(); + mSyntheticSoroswapState.reset(); + mSyntheticSACDestCounter = 0; + mSyntheticSoroswapSwapCounter = 0; mWaitTillCompleteForLedgers = 0; mFailed = false; mStarted = false; mPreLoadgenApplySorobanSuccess = 0; mPreLoadgenApplySorobanFailure = 0; - mTransactionsAppliedAtTheStart = 0; } // Reset Soroban persistent state @@ -301,7 +324,8 @@ LoadGenerator::start(GeneratedLoadConfig& cfg) return; } - mTransactionsAppliedAtTheStart = getTxCount(mApp, cfg.isSoroban()); + mClassicAppliedAtStart = getTxCount(mApp, /* isSoroban */ false); + mSorobanAppliedAtStart = getTxCount(mApp, /* isSoroban */ true); if ((cfg.mode == LoadGenMode::SOROBAN_INVOKE_APPLY_LOAD || cfg.modeMixesPregen()) && !mApp.getRunInOverlayOnlyMode()) @@ -696,17 +720,17 @@ LoadGenerator::generateLoad(GeneratedLoadConfig cfg) } // For MIXED_PREGEN_* modes, classic and soroban streams have independent - // TPS. Compute per-step budgets up front; the dispatch emits both streams - // within the same step. + // TPS. Compute per-step budgets from per-stream submitted counters so one + // stream's output doesn't starve the other (mTotalSubmitted is shared). int64_t mixedClassicBudget = 0; int64_t mixedSorobanBudget = 0; if (cfg.modeMixesPregen()) { auto const& mix = cfg.getMixPregenSorobanConfig(); - mixedClassicBudget = - getTxPerStep(mix.classicTxRate, cfg.spikeInterval, cfg.spikeSize); - mixedSorobanBudget = - getTxPerStep(mix.sorobanTxRate, cfg.spikeInterval, cfg.spikeSize); + mixedClassicBudget = getTxPerStep(mix.classicTxRate, cfg.spikeInterval, + cfg.spikeSize, mClassicSubmitted); + mixedSorobanBudget = getTxPerStep(mix.sorobanTxRate, cfg.spikeInterval, + cfg.spikeSize, mSorobanSubmitted); } auto txPerStep = cfg.modeMixesPregen() @@ -746,6 +770,10 @@ LoadGenerator::generateLoad(GeneratedLoadConfig cfg) TransactionFrameBaseConstPtr>()> generateTx; + // Set by the MIXED_PREGEN_* lambda so the outer loop can bump the + // correct per-stream submitted counter after a successful submit. + bool isMixedSorobanTx = false; + switch (cfg.mode) { case LoadGenMode::PAY: @@ -851,12 +879,14 @@ LoadGenerator::generateLoad(GeneratedLoadConfig cfg) "available for soroban stream"); } --mixedSorobanBudget; + isMixedSorobanTx = true; uint64_t srcId = getNextAvailableAccount(ledgerNum); return createSyntheticSorobanTransaction(ledgerNum, srcId, cfg); } releaseAssert(mixedClassicBudget > 0); --mixedClassicBudget; + isMixedSorobanTx = false; return readTransactionFromFile(cfg); }; break; @@ -867,6 +897,18 @@ LoadGenerator::generateLoad(GeneratedLoadConfig cfg) if (submitTx(cfg, generateTx)) { --cfg.nTxs; + // Mixed modes decide per-tx via the lambda; other modes are + // classified by the mode as a whole. + bool isSorobanTx = + cfg.modeMixesPregen() ? isMixedSorobanTx : cfg.isSoroban(); + if (isSorobanTx) + { + ++mSorobanSubmitted; + } + else + { + ++mClassicSubmitted; + } } } catch (std::runtime_error const& e) @@ -1335,14 +1377,20 @@ LoadGenerator::waitTillComplete(GeneratedLoadConfig cfg) bool sorobanIsDone = false; if (mApp.getRunInOverlayOnlyMode()) { - auto count = getTxCount(mApp, cfg.isSoroban()); - CLOG_INFO(LoadGen, "Transaction count: {}", count); - CLOG_INFO(LoadGen, "Transactions applied at the start: {}", - mTransactionsAppliedAtTheStart); - CLOG_INFO(LoadGen, "Transactions applied: {}", mTotalSubmitted); - classicIsDone = - (count - mTransactionsAppliedAtTheStart) == mTotalSubmitted; - sorobanIsDone = classicIsDone; + // Per-stream accounting works for every mode: single-stream modes + // leave the unused counter at 0, so its "applied == submitted" check + // holds trivially. + auto classicApplied = + getTxCount(mApp, /* isSoroban */ false) - mClassicAppliedAtStart; + auto sorobanApplied = + getTxCount(mApp, /* isSoroban */ true) - mSorobanAppliedAtStart; + CLOG_INFO(LoadGen, + "Classic applied {} (submitted {}), " + "soroban applied {} (submitted {})", + classicApplied, mClassicSubmitted, sorobanApplied, + mSorobanSubmitted); + classicIsDone = classicApplied == mClassicSubmitted; + sorobanIsDone = sorobanApplied == mSorobanSubmitted; } else { @@ -1559,14 +1607,11 @@ LoadGenerator::execute(TransactionFrameBasePtr txf, LoadGenMode mode, auto msg = txf->toStellarMessage(); txm.mTxnBytes.Mark(xdr::xdr_argpack_size(*msg)); - // Skip certain checks for pregenerated transactions (MIXED_PREGEN_* modes - // also inject pregen txs, so flag them the same way — the flag is harmless - // for the soroban half under overlay-only). - bool isPregeneratedTx = - (mode == LoadGenMode::PAY_PREGENERATED) || - (mode == LoadGenMode::MIXED_PREGEN_SAC_PAYMENT) || - (mode == LoadGenMode::MIXED_PREGEN_OZ_TOKEN_TRANSFER) || - (mode == LoadGenMode::MIXED_PREGEN_SOROSWAP_SWAP); + // Skip certain checks for pregenerated classic transactions. The synthetic + // Soroban half of MIXED_PREGEN_* is generated locally, but must still go + // through overlay validation so network resource limits are exercised. + bool isPregeneratedTx = (mode == LoadGenMode::PAY_PREGENERATED) || + (isMixedPregenMode(mode) && !txf->isSoroban()); auto addResult = mApp.getHerder().recvTransaction( txf, true, /*force=*/false, /*isLoadgenTx=*/isPregeneratedTx); if (addResult.code != TransactionQueue::AddResultCode::ADD_STATUS_PENDING) @@ -1919,9 +1964,7 @@ GeneratedLoadConfig::modeInvokes() const bool GeneratedLoadConfig::modeMixesPregen() const { - return mode == LoadGenMode::MIXED_PREGEN_SAC_PAYMENT || - mode == LoadGenMode::MIXED_PREGEN_OZ_TOKEN_TRANSFER || - mode == LoadGenMode::MIXED_PREGEN_SOROSWAP_SWAP; + return isMixedPregenMode(mode); } bool diff --git a/src/simulation/LoadGenerator.h b/src/simulation/LoadGenerator.h index 51b81644a0..8801274efb 100644 --- a/src/simulation/LoadGenerator.h +++ b/src/simulation/LoadGenerator.h @@ -274,7 +274,6 @@ class LoadGenerator // Set when load generation actually begins std::unique_ptr mStartTime; - uint32_t mTransactionsAppliedAtTheStart = 0; // Track account IDs that are currently being referenced by the transaction // queue (to avoid source account collisions during tx submission) std::unordered_set mAccountsInUse; @@ -317,6 +316,16 @@ class LoadGenerator uint32_t mSyntheticSACDestCounter{0}; // Round-robin counter for Soroswap pair selection + direction. uint32_t mSyntheticSoroswapSwapCounter{0}; + // Per-stream submission counters, tracked for every mode. For single- + // stream modes one of them stays at 0, which makes the waitTillComplete + // check trivially hold for that stream. For MIXED_PREGEN_* both are used. + uint64_t mClassicSubmitted{0}; + uint64_t mSorobanSubmitted{0}; + // Per-stream applied-at-start snapshots captured in start(), so + // waitTillComplete compares per-stream applied deltas against per-stream + // submitted counters. + uint32_t mClassicAppliedAtStart{0}; + uint32_t mSorobanAppliedAtStart{0}; TxGenerator::TestAccountPtr mRoot; @@ -340,6 +349,11 @@ class LoadGenerator void resetSorobanState(); int64_t getTxPerStep(uint32_t txRate, std::chrono::seconds spikeInterval, uint32_t spikeSize); + // Variant that paces against a caller-provided submitted counter, used for + // MIXED_PREGEN_* where classic and soroban streams have independent rates + // and must not share mTotalSubmitted. + int64_t getTxPerStep(uint32_t txRate, std::chrono::seconds spikeInterval, + uint32_t spikeSize, uint64_t submittedSoFar); // Schedule a callback to generateLoad() STEP_MSECS milliseconds from now. void scheduleLoadGeneration(GeneratedLoadConfig cfg); diff --git a/src/simulation/test/LoadGeneratorTests.cpp b/src/simulation/test/LoadGeneratorTests.cpp index 0e1084b37b..dc1fee9760 100644 --- a/src/simulation/test/LoadGeneratorTests.cpp +++ b/src/simulation/test/LoadGeneratorTests.cpp @@ -111,11 +111,8 @@ TEST_CASE("loadgen in overlay-only mode", "[loadgen]") TEST_CASE("mixed pregen and synthetic soroban in overlay-only mode", "[loadgen]") { - // Pregen source accounts live in [nAccounts, 2*nAccounts); soroban source - // accounts live in [0, nAccounts). GENESIS_TEST_ACCOUNT_COUNT must cover - // both disjoint ranges. uint32_t const nAccounts = 200; - uint32_t const genesisAccountCount = nAccounts * 2; + uint32_t const genesisAccountCount = nAccounts; uint32_t const nTxs = 60; Hash networkID = sha256(getTestConfig().NETWORK_PASSPHRASE); @@ -160,13 +157,14 @@ TEST_CASE("mixed pregen and synthetic soroban in overlay-only mode", }, simulation); - // Generate a pregen payments file targeting the upper half of the account - // range, so the classic stream never collides with the soroban stream. + // Both classic pregen and synthetic soroban streams draw from the same + // account pool; cross-queue source-account conflicts are allowed in + // overlay-only mode (HerderImpl bypasses the one-tx-per-source-per-ledger + // check), so no disjointness is required. std::string fileName = app.getConfig().LOADGEN_PREGENERATED_TRANSACTIONS_FILE; auto cleanup = gsl::finally([&]() { std::remove(fileName.c_str()); }); - generateTransactions(app, fileName, nTxs, nAccounts, - /* offset */ nAccounts); + generateTransactions(app, fileName, nTxs, nAccounts, /* offset */ 0); for (auto& node : nodes) { @@ -180,7 +178,7 @@ TEST_CASE("mixed pregen and synthetic soroban in overlay-only mode", auto runMixed = [&](LoadGenMode mode) { GeneratedLoadConfig cfg = GeneratedLoadConfig::txLoad(mode, nAccounts, nTxs, - /* txRate */ 1, /* offset */ nAccounts); + /* txRate */ 1, /* offset */ 0); cfg.preloadedTransactionsFile = app.getConfig().LOADGEN_PREGENERATED_TRANSACTIONS_FILE; auto& mix = cfg.getMutMixPregenSorobanConfig(); @@ -209,7 +207,7 @@ TEST_CASE("mixed pregen and synthetic soroban in overlay-only mode", .NewMeter({"loadgen", "run", "complete"}, "run") .count() == prev + 1; }, - 500 * simulation->getExpectedLedgerCloseTime(), false); + 100 * simulation->getExpectedLedgerCloseTime(), false); } TEST_CASE("generate load in protocol 1", "[loadgen]") diff --git a/src/transactions/TransactionFrame.cpp b/src/transactions/TransactionFrame.cpp index b26477a481..a374c62c86 100644 --- a/src/transactions/TransactionFrame.cpp +++ b/src/transactions/TransactionFrame.cpp @@ -1723,11 +1723,11 @@ TransactionFrame::commonValid( { current = sourceAccount->current().data.account().seqNum; } - bool forceCheck = true; + bool skipCheck = false; #ifdef BUILD_TESTS - forceCheck = !ledgerView.mSkipSeqNumCheck; + skipCheck = ledgerView.mSkipSeqNumCheck; #endif - if (forceCheck && isBadSeq(header, current)) + if (!skipCheck && isBadSeq(header, current)) { txResult.setInnermostError(txBAD_SEQ); return;