Repository: stellar/stellar-core
Pull request #4704: Remove redundant loads from checkValid path
Author: login: SirTyson
Created: 2025-04-25T21:12:10Z | Merged: 2025-04-30T17:52:21Z

# Description

This change refactor transaction and operation `checkValid` to reduce the number of redundant loads. Initially, TransactionFrame cached source accounts, making loads cheap. This resulted in calling load 3-4 times during check valid. However, that cache has been removed, resulting in reading from disk 3-4 times per call to checkValid.

# Checklist
- [x] Reviewed the [contributing](https://github.com/stellar/stellar-core/blob/master/CONTRIBUTING.md#submitting-changes) document
- [x] Rebased on top of master (no merge commits)
- [x] Ran `clang-format` v8.0.0 (via `make format` or the Visual Studio extension)
- [x] Compiles
- [x] Ran all tests
- [ ] If change impacts performance, include supporting evidence per the [performance document](https://github.com/stellar/stellar-core/blob/master/performance-eval/performance-eval.md)



Diff:
diff --git a/src/transactions/FeeBumpTransactionFrame.cpp b/src/transactions/FeeBumpTransactionFrame.cpp
index 13133648fa..d3514abee5 100644
--- a/src/transactions/FeeBumpTransactionFrame.cpp
+++ b/src/transactions/FeeBumpTransactionFrame.cpp
@@ -206,7 +206,7 @@ FeeBumpTransactionFrame::checkSorobanResourceAndSetError(
                                                      txResult);
 }
 
-bool
+std::optional<LedgerEntryWrapper>
 FeeBumpTransactionFrame::commonValidPreSeqNum(
     LedgerSnapshot const& ls, MutableTransactionResultBase& txResult) const
 {
@@ -218,14 +218,14 @@ FeeBumpTransactionFrame::commonValidPreSeqNum(
                                 ProtocolVersion::V_13))
     {
         txResult.setResultCode(txNOT_SUPPORTED);
-        return false;
+        return std::nullopt;
     }
     auto inclusionFee = getInclusionFee();
     auto minInclusionFee = getMinInclusionFee(*this, header.current());
     if (inclusionFee < minInclusionFee)
     {
         txResult.setResultCode(txINSUFFICIENT_FEE);
-        return false;
+        return std::nullopt;
     }
     // While in theory it should be possible to bump a Soroban
     // transaction with negative inclusion fee (this is unavoidable
@@ -235,7 +235,7 @@ FeeBumpTransactionFrame::commonValidPreSeqNum(
     if (mInnerTx->getInclusionFee() < 0)
     {
         txResult.setResultCode(txFEE_BUMP_INNER_FAILED);
-        return false;
+        return std::nullopt;
     }
     auto const& lh = header.current();
     // Make sure that fee bump is actually happening, i.e. that the
@@ -254,16 +254,17 @@ FeeBumpTransactionFrame::commonValidPreSeqNum(
             txResult.getResult().feeCharged = INT64_MAX;
         }
         txResult.setResultCode(txINSUFFICIENT_FEE);
-        return false;
+        return std::nullopt;
     }
 
-    if (!ls.getAccount(getFeeSourceID()))
+    auto feeSource = ls.getAccount(getFeeSourceID());
+    if (!feeSource)
     {
         txResult.setResultCode(txNO_ACCOUNT);
-        return false;
+        return std::nullopt;
     }
 
-    return true;
+    return feeSource;
 }
 
 FeeBumpTransactionFrame::ValidationType
@@ -273,15 +274,17 @@ FeeBumpTransactionFrame::commonValid(
 {
     ValidationType res = ValidationType::kInvalid;
 
-    if (!commonValidPreSeqNum(ls, txResult))
+    // Get the fee source account during commonValidPreSeqNum to avoid redundant
+    // account loading
+    auto feeSource = commonValidPreSeqNum(ls, txResult);
+    if (!feeSource)
     {
         return res;
     }
 
-    auto const feeSource = ls.getAccount(getFeeSourceID());
     if (!checkSignature(
-            signatureChecker, feeSource,
-            feeSource.current().data.account().thresholds[THRESHOLD_LOW]))
+            signatureChecker, *feeSource,
+            feeSource->current().data.account().thresholds[THRESHOLD_LOW]))
     {
         txResult.setResultCode(txBAD_AUTH);
         return res;
@@ -296,7 +299,7 @@ FeeBumpTransactionFrame::commonValid(
     int64_t feeToPay = applying ? 0 : getFullFee();
     // don't let the account go below the reserve after accounting for
     // liabilities
-    if (getAvailableBalance(header.current(), feeSource.current()) < feeToPay)
+    if (getAvailableBalance(header.current(), feeSource->current()) < feeToPay)
     {
         txResult.setResultCode(txINSUFFICIENT_BALANCE);
         return res;
diff --git a/src/transactions/FeeBumpTransactionFrame.h b/src/transactions/FeeBumpTransactionFrame.h
index 8d005612c5..f6c7bc96c3 100644
--- a/src/transactions/FeeBumpTransactionFrame.h
+++ b/src/transactions/FeeBumpTransactionFrame.h
@@ -31,8 +31,11 @@ class FeeBumpTransactionFrame : public TransactionFrameBase
                         LedgerEntryWrapper const& account,
                         int32_t neededWeight) const;
 
-    bool commonValidPreSeqNum(LedgerSnapshot const& ls,
-                              MutableTransactionResultBase& txResult) const;
+    // If check passes, returns the fee source account. Otherwise returns
+    // nullopt.
+    std::optional<LedgerEntryWrapper>
+    commonValidPreSeqNum(LedgerSnapshot const& ls,
+                         MutableTransactionResultBase& txResult) const;
 
     enum ValidationType
     {
diff --git a/src/transactions/OperationFrame.cpp b/src/transactions/OperationFrame.cpp
index 00d2d46041..1831a0ff0f 100644
--- a/src/transactions/OperationFrame.cpp
+++ b/src/transactions/OperationFrame.cpp
@@ -254,7 +254,14 @@ OperationFrame::checkValid(AppConnector& app,
         {
             // for ledger versions >= 10 we need to load account here, as for
             // previous versions it is done in checkSignature call
-            if (!ls.getAccount(ls.getLedgerHeader(), mParentTx, getSourceID()))
+            // If we get to operation checkvalid, we know the tx source account
+            // has already been checked for existence. If we're not applying,
+            // it's guaranteed that the tx source account exists, since ledger
+            // state hasn't changed, so we can skip this redundant check.
+            // If we're applying, it's possible an earlier op modified the TX
+            // source, so we need to check again.
+            if ((mOperation.sourceAccount || forApply) &&
+                !ls.getAccount(ls.getLedgerHeader(), mParentTx, getSourceID()))
             {
                 res.code(opNO_ACCOUNT);
                 validationResult = false;
diff --git a/src/transactions/TransactionFrame.cpp b/src/transactions/TransactionFrame.cpp
index 53bfb5635d..02a51d1653 100644
--- a/src/transactions/TransactionFrame.cpp
+++ b/src/transactions/TransactionFrame.cpp
@@ -982,7 +982,7 @@ TransactionFrame::isTooEarlyForAccount(LedgerHeaderWrapper const& header,
     return false;
 }
 
-bool
+std::optional<LedgerEntryWrapper>
 TransactionFrame::commonValidPreSeqNum(
     AppConnector& app, std::optional<SorobanNetworkConfig> const& cfg,
     LedgerSnapshot const& ls, bool chargeFee,
@@ -1003,7 +1003,7 @@ TransactionFrame::commonValidPreSeqNum(
          mEnvelope.type() == ENVELOPE_TYPE_TX_V0))
     {
         txResult->setInnermostResultCode(txNOT_SUPPORTED);
-        return false;
+        return std::nullopt;
     }
 
     if (protocolVersionIsBefore(ledgerVersion, ProtocolVersion::V_19) &&
@@ -1011,7 +1011,7 @@ TransactionFrame::commonValidPreSeqNum(
         mEnvelope.v1().tx.cond.type() == PRECOND_V2)
     {
         txResult->setInnermostResultCode(txNOT_SUPPORTED);
-        return false;
+        return std::nullopt;
     }
 
     if (extraSignersExist())
@@ -1022,7 +1022,7 @@ TransactionFrame::commonValidPreSeqNum(
         if (extraSigners.size() == 2 && extraSigners[0] == extraSigners[1])
         {
             txResult->setInnermostResultCode(txMALFORMED);
-            return false;
+            return std::nullopt;
         }
 
         for (auto const& signer : extraSigners)
@@ -1031,7 +1031,7 @@ TransactionFrame::commonValidPreSeqNum(
                 signer.ed25519SignedPayload().payload.empty())
             {
                 txResult->setInnermostResultCode(txMALFORMED);
-                return false;
+                return std::nullopt;
             }
         }
     }
@@ -1039,27 +1039,27 @@ TransactionFrame::commonValidPreSeqNum(
     if (getNumOperations() == 0)
     {
         txResult->setInnermostResultCode(txMISSING_OPERATION);
-        return false;
+        return std::nullopt;
     }
 
     if (!validateSorobanOpsConsistency())
     {
         txResult->setInnermostResultCode(txMALFORMED);
-        return false;
+        return std::nullopt;
     }
     if (isSoroban())
     {
         if (protocolVersionIsBefore(ledgerVersion, SOROBAN_PROTOCOL_VERSION))
         {
             txResult->setInnermostResultCode(txMALFORMED);
-            return false;
+            return std::nullopt;
         }
 
         releaseAssert(cfg);
         if (!checkSorobanResourceAndSetError(app, cfg.value(), ledgerVersion,
                                              txResult))
         {
-            return false;
+            return std::nullopt;
         }
 
         auto const& sorobanData = mEnvelope.v1().tx.ext.sorobanData();
@@ -1074,7 +1074,7 @@ TransactionFrame::commonValidPreSeqNum(
                  makeU64SCVal(getFullFee())});
 
             txResult->setInnermostResultCode(txSOROBAN_INVALID);
-            return false;
+            return std::nullopt;
         }
         releaseAssertOrThrow(sorobanResourceFee);
         if (sorobanResourceFee->refundable_fee >
@@ -1087,7 +1087,7 @@ TransactionFrame::commonValidPreSeqNum(
                  makeU64SCVal(sorobanResourceFee->non_refundable_fee)});
 
             txResult->setInnermostResultCode(txSOROBAN_INVALID);
-            return false;
+            return std::nullopt;
         }
         auto const resourceFees = sorobanResourceFee->refundable_fee +
                                   sorobanResourceFee->non_refundable_fee;
@@ -1101,7 +1101,7 @@ TransactionFrame::commonValidPreSeqNum(
                  makeU64SCVal(resourceFees)});
 
             txResult->setInnermostResultCode(txSOROBAN_INVALID);
-            return false;
+            return std::nullopt;
         }
 
         // check for duplicates
@@ -1129,7 +1129,7 @@ TransactionFrame::commonValidPreSeqNum(
         if (!checkDuplicates(sorobanData.resources.footprint.readOnly) ||
             !checkDuplicates(sorobanData.resources.footprint.readWrite))
         {
-            return false;
+            return std::nullopt;
         }
     }
     else
@@ -1140,7 +1140,7 @@ TransactionFrame::commonValidPreSeqNum(
                 mEnvelope.v1().tx.ext.v() != 0)
             {
                 txResult->setInnermostResultCode(txMALFORMED);
-                return false;
+                return std::nullopt;
             }
         }
     }
@@ -1149,33 +1149,34 @@ TransactionFrame::commonValidPreSeqNum(
     if (isTooEarly(header, lowerBoundCloseTimeOffset))
     {
         txResult->setInnermostResultCode(txTOO_EARLY);
-        return false;
+        return std::nullopt;
     }
     if (isTooLate(header, upperBoundCloseTimeOffset))
     {
         txResult->setInnermostResultCode(txTOO_LATE);
-        return false;
+        return std::nullopt;
     }
 
     if (chargeFee &&
         getInclusionFee() < getMinInclusionFee(*this, header.current()))
     {
         txResult->setInnermostResultCode(txINSUFFICIENT_FEE);
-        return false;
+        return std::nullopt;
     }
     if (!chargeFee && getInclusionFee() < 0)
     {
         txResult->setInnermostResultCode(txINSUFFICIENT_FEE);
-        return false;
+        return std::nullopt;
     }
 
-    if (!ls.getAccount(header, *this))
+    auto sourceAccount = ls.getAccount(header, *this);
+    if (!sourceAccount)
     {
         txResult->setInnermostResultCode(txNO_ACCOUNT);
-        return false;
+        return std::nullopt;
     }
 
-    return true;
+    return sourceAccount;
 }
 
 void
@@ -1317,15 +1318,18 @@ TransactionFrame::commonValid(AppConnector& app,
                 "Applying transaction with non-current closeTime");
         }
 
-        if (!commonValidPreSeqNum(
-                app, cfg, ls, chargeFee, lowerBoundCloseTimeOffset,
-                upperBoundCloseTimeOffset, sorobanResourceFee, txResult))
+        // Get the source account during commonValidPreSeqNum to avoid redundant
+        // account loading
+        auto sourceAccount = commonValidPreSeqNum(
+            app, cfg, ls, chargeFee, lowerBoundCloseTimeOffset,
+            upperBoundCloseTimeOffset, sorobanResourceFee, txResult);
+
+        if (!sourceAccount)
         {
             return;
         }
 
         auto header = ls.getLedgerHeader();
-        auto const sourceAccount = ls.getAccount(header, *this);
 
         // in older versions, the account's sequence number is updated when
         // taking fees
@@ -1335,7 +1339,7 @@ TransactionFrame::commonValid(AppConnector& app,
         {
             if (current == 0)
             {
-                current = sourceAccount.current().data.account().seqNum;
+                current = sourceAccount->current().data.account().seqNum;
             }
             if (isBadSeq(header, current))
             {
@@ -1346,15 +1350,15 @@ TransactionFrame::commonValid(AppConnector& app,
 
         res = ValidationType::kInvalidUpdateSeqNum;
 
-        if (isTooEarlyForAccount(header, sourceAccount,
+        if (isTooEarlyForAccount(header, *sourceAccount,
                                  lowerBoundCloseTimeOffset))
         {
             txResult->setInnermostResultCode(txBAD_MIN_SEQ_AGE_OR_GAP);
             return;
         }
 
-        if (!checkSignature(signatureChecker, sourceAccount,
-                            sourceAccount.current()
+        if (!checkSignature(signatureChecker, *sourceAccount,
+                            sourceAccount->current()
                                 .data.account()
                                 .thresholds[THRESHOLD_LOW]))
         {
@@ -1383,7 +1387,7 @@ TransactionFrame::commonValid(AppConnector& app,
         // don't let the account go below the reserve after accounting for
         // liabilities
         if (chargeFee &&
-            getAvailableBalance(header.current(), sourceAccount.current()) <
+            getAvailableBalance(header.current(), sourceAccount->current()) <
                 feeToPay)
         {
             txResult->setInnermostResultCode(txINSUFFICIENT_BALANCE);
diff --git a/src/transactions/TransactionFrame.h b/src/transactions/TransactionFrame.h
index 96811b6312..aaa2c21a5d 100644
--- a/src/transactions/TransactionFrame.h
+++ b/src/transactions/TransactionFrame.h
@@ -94,13 +94,13 @@ class TransactionFrame : public TransactionFrameBase
                               LedgerEntryWrapper const& sourceAccount,
                               uint64_t lowerBoundCloseTimeOffset) const;
 
-    bool commonValidPreSeqNum(AppConnector& app,
-                              std::optional<SorobanNetworkConfig> const& cfg,
-                              LedgerSnapshot const& ls, bool chargeFee,
-                              uint64_t lowerBoundCloseTimeOffset,
-                              uint64_t upperBoundCloseTimeOffset,
-                              std::optional<FeePair> sorobanResourceFee,
-                              MutableTxResultPtr txResult) const;
+    // If check passes, returns the source account. Otherwise returns nullopt.
+    std::optional<LedgerEntryWrapper> commonValidPreSeqNum(
+        AppConnector& app, std::optional<SorobanNetworkConfig> const& cfg,
+        LedgerSnapshot const& ls, bool chargeFee,
+        uint64_t lowerBoundCloseTimeOffset, uint64_t upperBoundCloseTimeOffset,
+        std::optional<FeePair> sorobanResourceFee,
+        MutableTxResultPtr txResult) const;
 
     virtual bool isBadSeq(LedgerHeaderWrapper const& header,
                           int64_t seqNum) const;


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

This pull request refactors the checkValid path to reduce disk loads by removing redundant account fetching. Key changes include:
- Changing the return type of commonValidPreSeqNum from bool to std::optional<LedgerEntryWrapper> to return the source account if valid.
- Updating all corresponding call sites to handle the new optional return type.
- Eliminating duplicate account loads by retrieving the source account once and reusing it.

### Reviewed Changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated no comments.

<details>
<summary>Show a summary per file</summary>

| File                                       | Description                                                      |
| ------------------------------------------ | ---------------------------------------------------------------- |
| src/transactions/TransactionFrame.h        | Updated commonValidPreSeqNum signature and comment                 |
| src/transactions/TransactionFrame.cpp        | Replaced false returns with std::nullopt and refactored source account use  |
| src/transactions/OperationFrame.cpp         | Adjusted account existence check in operation validation           |
| src/transactions/FeeBumpTransactionFrame.h | Updated commonValidPreSeqNum signature                               |
| src/transactions/FeeBumpTransactionFrame.cpp | Refactored to use std::optional for fee source account and updated related checks |
</details>



<details>
<summary>Comments suppressed due to low confidence (2)</summary>

**src/transactions/TransactionFrame.cpp:1172**
* [nitpick] Consider renaming 'sourceAccount' to 'optSourceAccount' or similar to more clearly indicate its optional nature.
```
auto sourceAccount = ls.getAccount(header, *this);
```
**src/transactions/FeeBumpTransactionFrame.cpp:260**
* [nitpick] Consider renaming 'feeSource' to 'optFeeSource' to convey that the variable holds an optional value resulting from the updated account fetch.
```
auto feeSource = ls.getAccount(getFeeSourceID());
```
</details>


state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T21:12:25Z
user: login: bboston7
body: I've noticed the redundant loads in our tracy evaluations, and this seems like a nice way to reduce them.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T23:55:22Z
user: login: marta-lokhova
body: LGTM!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T17:52:02Z

Review comments:
diff_hunk: @@ -31,8 +31,9 @@ class FeeBumpTransactionFrame : public TransactionFrameBase
                         LedgerEntryWrapper const& account,
                         int32_t neededWeight) const;
 
-    bool commonValidPreSeqNum(LedgerSnapshot const& ls,
-                              MutableTransactionResultBase& txResult) const;
+    std::optional<LedgerEntryWrapper>
+    commonValidPreSeqNum(LedgerSnapshot const& ls,
+                         MutableTransactionResultBase& txResult) const;
path: src/transactions/FeeBumpTransactionFrame.h
user: login: bboston7
body: Please document what this returns. Copying the comment from the equivalent function in `TransactionFrame` would be fine.
created_at: 2025-04-29T23:45:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -254,7 +254,14 @@ OperationFrame::checkValid(AppConnector& app,
         {
             // for ledger versions >= 10 we need to load account here, as for
             // previous versions it is done in checkSignature call
-            if (!ls.getAccount(ls.getLedgerHeader(), mParentTx, getSourceID()))
+            // If we get to operation checkvalid, we know the tx source acount
path: src/transactions/OperationFrame.cpp
user: login: bboston7
body: 
```suggestion
            // If we get to operation checkvalid, we know the tx source account
```
created_at: 2025-04-29T23:47:19Z
author_association: CONTRIBUTOR

PR comments:
user: login: marta-lokhova
created_at: 2025-04-25T21:42:10Z
body: definitely not opposed to being a bit more efficient in our validation paths, but won't this problem be solved by in-memory buckets + account cache anyway?
author_association: CONTRIBUTOR
user: login: SirTyson
created_at: 2025-04-25T22:05:15Z
body: > definitely not opposed to being a bit more efficient in our validation paths, but won't this problem be solved by in-memory buckets + account cache anyway?

The caches certainly help since we're not going to disk, but they're still not free. With the in-memory cache, loads still account for about 33% of checkValid time (assuming signature cache hit). We're talking microseconds here, but at high TPS it adds up. Also worth noting captive-core has caching disabled for memory reasons, so I think it's still worth doing regardless. 


author_association: CONTRIBUTOR