Skip to Content
shapekit

Indexer Hardening

Draft, pending SDF review

This guide has not yet been reviewed by a stellar-core or platform engineer. The canonical copy will be on developers.stellar.org once the review is done.

Most of a transaction envelope is chosen by whoever submitted the transaction. stellar-core checks some of it, checks some of it only in part, and passes the rest through to the ledger unchanged. An indexer that trusts an unchecked field, decodes without limits, or reads participants from the envelope can store wrong data or stop ingesting on a single transaction.

This page covers, in order: which envelope fields core validates, field by field; depth, length and count caps that never refuse data the network could have produced; why participants come from transaction meta and not from the envelope; how to store every strkey form defined by SEP-23, including muxed and signed-payload addresses; and a checklist for auditing an indexer written in any language.

The field table, the caps table and the checklist are generated from the shapekit registry, where every row cites the stellar-core v30.0.0, stellar-xdr v30.0 or soroban-env-host v30.0.0 source it was read from. The field table is checked against the shapekit corpus manifest, which records the corpus entries that exercise each field. "Protocol" on this page always means the protocol version of the ledger that holds the data.

This page assumes you already read ledgers, for example with the Ingest SDK or the Token Transfer Processor. For how muxed accounts and memos are used by applications, see Pooled accounts: muxed accounts and memos, and for converting between address types in the SDKs, see Convert an address to other types.

Validated and unvalidated fields

The table below gives one of three answers for each field:

  • yes: core rejects the transaction, or fails the operation, when the value is wrong.
  • partial: core checks some properties of the value, or the check happens later inside the Soroban host and only on some paths.
  • no: core passes the value through without looking at its content.

A "yes" does not make a value true. Many checks run when the transaction is applied, and they only fail that transaction or operation. A ledger still carries the failed transaction: LedgerCloseMetaV2 holds the whole transaction set and one TransactionResultMetaV1 per transaction with its result (Stellar-ledger.x, LedgerCloseMetaV2). From protocol 13 core stops checking signatures of a transaction that already failed validation, so a failed transaction can carry signatures that were never verified (TransactionFrame::processSignatures). Treat an envelope value as known good only when the transaction result and the operation result are successes.

Fields core never checks

Mux ids. Core reduces every MuxedAccount to its ed25519 key with toAccountID, which drops the 64-bit id (toAccountID). Outside tests the id is read only to copy it into classic and Soroban events (makeMuxIDSCVal, makeMuxedAccountAddress) and into core's JSON output (XDRCereal.cpp). It is never compared, looked up or stored in a ledger entry, so a mux id is any 64-bit value the submitter chose, with no account behind it. The only protocol rule about muxed accounts is their key type: before protocol 13 core rejects a transaction that contains any muxed account with txNOT_SUPPORTED (MuxChecker, TransactionFrame::commonValidPreSeqNum).

Memos. MEMO_TEXT is limited only by its XDR type, string text<28>, which bounds bytes, not characters (Memo). Core runs no UTF-8 or content check on it. MEMO_ID, MEMO_HASH and MEMO_RETURN are not checked either; a MEMO_RETURN hash need not name any transaction. Core copies the memo of a classic payment to a plain account into the to_muxed_id field of the transfer or mint event, as a string, a u64 or bytes depending on the memo type (makeClassicMemoSCVal, getPossibleMuxedData), so the same unchecked bytes reappear inside event data.

Data entry values. ManageDataOp.dataValue is up to 64 opaque bytes. Core copies it into the data entry as it is (ManageDataOpFrame::doApply).

Fields core checks only in part

Signed payloads. A signed-payload signer key carries 1 to 64 payload bytes: the upper bound comes from XDR and core rejects an empty payload (TransactionFrame::commonValidPreSeqNum, SetOptionsOpFrame::doCheckValid). The payload content is never checked.

Soroban footprints. Core limits the key types, key sizes and entry counts of a footprint and rejects duplicate keys, but the keys need not exist, and the host only enforces that the contract stays inside the footprint (TransactionFrame::checkSorobanResources, invoke_host_function). A footprint is what the submitter allowed a contract to touch, not what it touched.

Soroban authorization entries. Core never inspects auth entries; it serializes them and passes them to the Soroban host (invokeHostFunction). The host converts every entry to host objects at the start of execution, so an entry that cannot be converted fails the operation. It checks signatures, nonces and expiration only for an entry that a require_auth call matches, and nothing checks that every entry was used (require_auth_enforcing, maybe_authorize_invocation). An entry that no call matches can name any address with any signature and still appear in a successful transaction. Source-account credentials carry no signature at all and rest on the transaction signatures (authenticate).

Contract call arguments. Core checks only that the envelope parses within the XDR depth limit and fits the transaction size limit. The host converts arguments when it runs the call and fails the operation on a map whose keys are not strictly sorted (validateXDRForProtocol, MeteredOrdMap::from_map_with_host). A failed invocation can therefore carry unsorted or colliding map keys.

Assets, claimants and names. From protocol 13 core does not check that an asset issuer exists (PathPaymentOpFrameBase::checkIssuer). Claimable balance claimants must be unique within the operation but need not exist (CreateClaimableBalanceOpFrame::doCheckValid). Home domains and data entry names must be printable ASCII, and nothing more (isStringValid).

Ordering rules

Liquidity pool assets order by raw bytes. A pool-share trustline requires assetA < assetB (isPoolShareAssetValid). The comparison is by asset type, then the asset code as raw bytes including zero padding, then the issuer as its raw 32 key bytes, and a pair in the wrong order fails with CHANGE_TRUST_MALFORMED. Strkey text order is different, because base32 puts the digits 2 to 7 before A to Z in ASCII. With the same asset code, an issuer of 32 zero bytes (GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF) comes first by bytes, while an issuer of 32 bytes of 0x3a (GA5DUOR2HI5DUOR2HI5DUOR2HI5DUOR2HI5DUOR2HI5DUOR2HI5DVTUJ) comes first as text. js-stellar-sdk sorted issuers as text until version 17.2.1 and rejected valid pairs (js-stellar-sdk#1766).

ScMap keys order by the Soroban host's comparison, not by XDR bytes. The host requires map keys to be strictly increasing (from_map_with_host). Its comparison orders values of different types by their ScValType discriminant, then compares bytes, strings and symbols bytewise, vectors and maps element by element, and numbers by value (Compare<ScVal>). XDR byte order differs: a length prefix puts symbol "b" before "aa", and two's complement puts -1 after 1 (js-stellar-sdk#1766, RFC 4506). In host order, symbols b, aa, a sort to a, aa, b, i128 keys 1, -1, 0, -200 sort to -200, -1, 0, 1, and bytes keys [10], [9], [1,2], [2] sort to [1,2], [2], [9], [10]. Use this order in any code that builds, re-encodes or checks a map.

Signatures and hints

A signed-payload signature is not bound to a network or a transaction. For a signed-payload signer core verifies an ed25519 signature over the raw payload bytes, with no network ID and no hashing (verifyEd25519SignedPayload). A transaction signature, by contrast, signs a hash of the network ID, the envelope type and the transaction (TransactionFrame::getContentsHash). The same signed-payload signature therefore satisfies the same signer on every network and in every transaction that lists it.

Hints are a filter, not an identity. Core tries a signer only when the 4-byte hint of a signature matches it (SignatureUtils::verify). For a signed-payload signer the expected hint is the last 4 bytes of the ed25519 key XORed with a 4-byte hint of the payload (getSignedPayloadHint). The payload hint is the last 4 payload bytes when the payload has 4 or more bytes; a payload of 1 to 3 bytes is copied into the start of the hint instead (getHint). Two signers can share a hint, so never attribute a signature to a signer by its hint.

Soroban memo and muxed source rule

From protocol 25 core rejects with txSOROBAN_INVALID a transaction whose single INVOKE_HOST_FUNCTION operation comes with a memo, a muxed transaction source or a muxed operation source (TransactionFrame::validateSorobanMemo, TransactionFrame::commonValidPreSeqNum). Before protocol 25 the same check exists only as an admission policy of a node's transaction queue (TransactionQueue::canAdd), which is not a consensus rule, so ledgers before protocol 25 can contain Soroban invocations with a memo or a muxed source. Extend and restore transactions are not covered by the rule.

Field table

Each row lists what core checks, the risk of trusting the value, how to handle it, the source the row was read from, and how many corpus entries exercise the field.

88 fields: 52 validated, 25 partly validated, 11 not validated by stellar-core.

FieldCore validatesWhat core checksRisk if trusted, and handlingSourceCorpus
tx.envelope.type
TransactionEnvelope.type
all protocols
yesBefore protocol 13 any ENVELOPE_TYPE_TX or any muxed account anywhere in the envelope gives txNOT_SUPPORTED; from protocol 13 ENVELOPE_TYPE_TX_V0 gives txNOT_SUPPORTED. fee bump envelopes give txNOT_SUPPORTED before protocol 13. every envelope must also parse with the rust xdr parser of the ledger protocol (depth 500 c++ check before protocol 28, rust depth 1000 from 28) or it is txMALFORMED.Risk: An indexer that only handles v1 envelopes drops or crashes on v0 envelopes in pre-13 history and on fee bumps from 13 on.
Handling: Switch on all three arms; treat v0 as history-only and fee bump as a wrapper whose inner v1 transaction carries the operations.
TransactionFrame::commonValidPreSeqNum
FeeBumpTransactionFrame::commonValidPreSeqNum
validateXDRForProtocol
TransactionEnvelope
8
tx.v0.source_account_ed25519
TransactionV0Envelope.tx.sourceAccountEd25519
protocols 0 to 12
yesCore rebuilds an AccountID from the raw 32 bytes and applies the same existence and signature checks as for v1 sources; the whole v0 arm is rejected from protocol 13.Risk: v0 has no key type discriminant, so code that expects a MuxedAccount here misreads the bytes.
Handling: Wrap the 32 bytes as an ed25519 account id (G strkey); never expect a mux id in v0.
TransactionFrame::getSourceID
TransactionV0
7
tx.signatures
TransactionV1Envelope.signatures
all protocols
yesAt most 20 by xdr. source account signers must reach the low threshold (txBAD_AUTH), each operation source must reach its op threshold (opBAD_AUTH), and from protocol 19 every extra signer must be satisfied. every signature must be used by some signer or the transaction is txBAD_AUTH_EXTRA. from protocol 28 at most 1000 ed25519 verifications per transaction. at apply, from protocol 13, signature checking stops early when the transaction already failed validation, so a failed transaction can carry signatures that were never verified.Risk: Signatures on a failed transaction are not proof of anything; a signature count says nothing about which signer signed.
Handling: Derive signer participation from signer changes in meta, not from the signature list; only treat signatures as verified on successful transactions.
SignatureChecker::checkSignature
SignatureChecker::checkAllSignaturesUsed
TransactionFrame::processSignatures
TransactionFrame::checkAllTransactionSignatures
16
tx.signatures.hint
DecoratedSignature.hint
all protocols
partialThe hint is only a filter: core tries a signer only when the hint equals the last 4 bytes of the ed25519 key (or of the hash-x hash). for a signed-payload signer the expected hint is the last 4 bytes of the key xor the payload hint; getHint copies the last 4 payload bytes, or, for a payload shorter than 4 bytes, the payload into the start of the hint. pre-auth-tx signers use no signature and no hint.Risk: 4 bytes are not unique; two signers can share a hint, so a hint does not identify the signer.
Handling: Never key or attribute signers by hint; if a signer must be attributed, verify the signature against candidate keys yourself.
SignatureUtils::verify
SignatureUtils::getSignedPayloadHint
SignatureUtils::getHint
SignatureUtils::doesHintMatch
6
tx.signatures.signature
DecoratedSignature.signature
all protocols
yesed25519 signers: ed25519 verify over the transaction contents hash, sha256(networkID, envelope type, tx). hash-x signers: sha256(signature) must equal the hash, so the signature field is the preimage (up to 64 bytes). signed-payload signers: ed25519 verify over the raw payload bytes, with no network id and no hashing.Risk: A signed-payload signature is not bound to any network or transaction; hash-x signatures reveal a preimage, not a signature.
Handling: Do not treat a signed-payload signature as transaction-specific or network-specific; store signature bytes as opaque, up to 64 bytes.
SignatureUtils::verifyEd25519SignedPayload
SignatureUtils::verifyHashX
TransactionFrame::getContentsHash
Signature
none
tx.source_account
Transaction.sourceAccount
all protocols
yesCore reduces the MuxedAccount to its ed25519 key with toAccountID; that account must exist (txNO_ACCOUNT) and its signers must reach the low threshold (txBAD_AUTH); it pays the fee and its sequence number is consumed.Risk: The claimed source is real, but it is not the full list of affected accounts.
Handling: Use the base key (G) as the account identity; take the full participant set from meta changes and events.
TransactionFrame::commonValidPreSeqNum
TransactionFrame::commonValid
toAccountID
18
tx.source_account.mux_id
Transaction.sourceAccount(KEY_TYPE_MUXED_ED25519).med25519.id
protocol 13 and later
noNever compared, never looked up, never stored in a ledger entry. toAccountID discards it. outside tests the only reads of med25519().id copy it into classic and soroban events (makeMuxedAccountAddress, makeMuxIDSCVal) and the json dumper. before protocol 13 any muxed account makes the transaction txNOT_SUPPORTED (hasMuxedAccount checks only the key type). from protocol 25 a soroban invoke transaction with a muxed source is txSOROBAN_INVALID.Risk: Any 64-bit value, chosen by the submitter, with no account behind it. trusting it as an account id, or storing a muxed address in a 32-byte column, truncates or fabricates identities (wallet-backend#716).
Handling: Store the base account and the mux id separately, the id as an unsigned 64-bit value; never treat an M address as an account that exists; key balances by the base account.
toAccountID
makeMuxIDSCVal, makeMuxedAccountAddress
detail::MuxChecker
TransactionFrame::validateSorobanMemo
MuxedAccount
19
tx.fee
Transaction.fee
all protocols
yesInclusion fee (fee minus declared soroban resource fee) must be at least baseFee times max(1, op count) (txINSUFFICIENT_FEE), and the source must afford it above reserve (txINSUFFICIENT_BALANCE). the fee actually charged is computed at apply, min(inclusion fee, baseFee times ops) plus the resource fee, then soroban refunds; it is not the envelope value.Risk: The envelope fee is a bid, not what was paid.
Handling: Read feeCharged from the transaction result and fee changes from meta.
TransactionFrame::commonValidPreSeqNum
TransactionFrame::getFee
TransactionFrame::processFeeSeqNum
getMinInclusionFee
none
tx.seq_num
Transaction.seqNum
all protocols
yesMust be account seqNum + 1, or, from protocol 19 with minSeqNum set, account seqNum in [minSeqNum, tx.seqNum); a seqNum equal to the ledger starting sequence (ledgerSeq << 32) or INT64_MAX is rejected (txBAD_SEQ). on apply the account seqNum is set to tx.seqNum.Risk: low; the value is checked. gaps are legal with minSeqNum, so seqNum is not always previous + 1.
Handling: Safe to store; do not assume contiguity per account.
TransactionFrame::isBadSeq
TransactionFrame::processSeqNum
3
tx.cond.type
Transaction.cond.type
protocol 13 and later
yesPRECOND_V2 before protocol 19 gives txNOT_SUPPORTED; PRECOND_TIME and PRECOND_NONE are accepted in all v1 envelopes.Risk: Low.
Handling: Handle all three arms; v0 envelopes carry an optional TimeBounds instead.
TransactionFrame::commonValidPreSeqNum
Preconditions
8
tx.cond.time_bounds
Transaction.cond(PRECOND_TIME).timeBounds | Transaction.cond(PRECOND_V2).v2.timeBounds
all protocols
yesminTime must be <= ledger close time (txTOO_EARLY), maxTime 0 means unbounded, otherwise maxTime must be >= close time (txTOO_LATE). minTime > maxTime is not rejected as malformed, it just can never be valid.Risk: Low.
Handling: Safe to store; treat maxTime 0 as no bound.
TransactionFrame::isTooEarly
TransactionFrame::isTooLate
TransactionFrame::getTimeBounds
2
tx.cond.ledger_bounds
Transaction.cond(PRECOND_V2).v2.ledgerBounds
protocol 19 and later
yesFrom protocol 19: minLedger must be <= ledger seq (txTOO_EARLY); maxLedger 0 means unbounded, otherwise ledger seq must be < maxLedger (txTOO_LATE).Risk: Low.
Handling: Safe to store; maxLedger is exclusive.
TransactionFrame::isTooEarly
TransactionFrame::isTooLate
4
tx.cond.min_seq_num
Transaction.cond(PRECOND_V2).v2.minSeqNum
protocol 19 and later
yesFrom protocol 19, when set, relaxes the sequence check to minSeqNum <= account seqNum < tx.seqNum.Risk: An indexer that assumes seqNum = previous + 1 sees gaps.
Handling: Do not reconstruct account sequence numbers from envelopes; read them from meta.
TransactionFrame::isBadSeq2
tx.cond.min_seq_age
Transaction.cond(PRECOND_V2).v2.minSeqAge
protocol 19 and later
yesFrom protocol 19, close time minus minSeqAge must be >= the source account seqTime (AccountEntryExtensionV3, 0 when absent), else txBAD_MIN_SEQ_AGE_OR_GAP.Risk: Low.
Handling: Safe to store.
TransactionFrame::isTooEarlyForAccount4
tx.cond.min_seq_ledger_gap
Transaction.cond(PRECOND_V2).v2.minSeqLedgerGap
protocol 19 and later
yesFrom protocol 19, ledger seq minus minSeqLedgerGap must be >= the source account seqLedger, else txBAD_MIN_SEQ_AGE_OR_GAP.Risk: Low.
Handling: Safe to store.
TransactionFrame::isTooEarlyForAccount6
tx.cond.extra_signers
Transaction.cond(PRECOND_V2).v2.extraSigners
protocol 19 and later
yesAt most 2 by xdr; two identical entries give txMALFORMED; a signed-payload signer with an empty payload gives txMALFORMED; from protocol 19 each extra signer needs a matching signature (weight 1 each, needed weight = count), else txBAD_AUTH. the signer keys need not belong to any account.Risk: Extra signers are arbitrary keys, not accounts; a P (signed payload) signer carries up to 64 payload bytes that a 32-byte column truncates.
Handling: Store signer keys with their type and full payload; do not treat them as participants unless meta shows them.
TransactionFrame::commonValidPreSeqNum
TransactionFrame::checkExtraSigners
PreconditionsV2
7
signer_key.signed_payload.payload
SignerKey(SIGNER_KEY_TYPE_ED25519_SIGNED_PAYLOAD).ed25519SignedPayload.payload
protocol 19 and later
partialLength 1 to 64: the 64 upper bound is xdr, the empty check is core (txMALFORMED in extraSigners, SET_OPTIONS_BAD_SIGNER in set options). content is never checked. the signature is ed25519 over these raw bytes, not over the transaction, not bound to the network.Risk: Variable length payload: storing it in a fixed 32-byte column truncates it and makes the row undecodable (wallet-backend#716, #729); a valid signature can be replayed on any network.
Handling: Store the full payload with its length; encode as a P strkey (SEP-23) when rendering.
TransactionFrame::commonValidPreSeqNum
SetOptionsOpFrame::doCheckValid
SignatureUtils::verifyEd25519SignedPayload
SignerKey
38
tx.memo.type
Transaction.memo.type
all protocols
partialAny memo type is accepted for classic transactions. from protocol 25 a soroban transaction whose single operation is INVOKE_HOST_FUNCTION must use MEMO_NONE (txSOROBAN_INVALID) as a consensus rule; extend and restore transactions may carry a memo. before protocol 25 the same rule is only a transaction queue admission policy of the receiving node, so ledgers before 25 can contain soroban invocations with a memo or a muxed source.Risk: Low for the type itself; the memo values below are unvalidated.
Handling: Switch on all five arms.
TransactionFrame::validateSorobanMemo
TransactionFrame::commonValidPreSeqNum
TransactionQueue::canAdd
27
tx.memo.text
Transaction.memo(MEMO_TEXT).text
all protocols
noXDR length limit 28 bytes only; no utf-8, character set or content check. searched src/ outside tests for memo handling: the only use is makeClassicMemoSCVal, which copies the bytes into an SCV_STRING in the to_muxed_id field of transfer and mint events.Risk: Arbitrary bytes up to 28: invalid utf-8, nul bytes, control characters. decoding as a utf-8 string can throw, replace bytes (js-stellar-sdk#1823) or break a text column; the same bytes reappear in event data.
Handling: Store as bytes; if a string is needed, validate utf-8 explicitly and keep the raw bytes alongside; never use it as a key or a destination identifier.
Memo
makeClassicMemoSCVal
getPossibleMuxedData
12
tx.memo.id
Transaction.memo(MEMO_ID).id
all protocols
noAny uint64; no check. copied into event to_muxed_id when the destination is a plain account.Risk: submitter-chosen; values above 2^63-1 overflow signed 64-bit columns and above 2^53-1 lose precision as js numbers.
Handling: Store as unsigned 64-bit (or decimal string); never treat as an account reference.
Memo
makeClassicMemoSCVal
7
tx.memo.hash
Transaction.memo(MEMO_HASH).hash
all protocols
no32 opaque bytes by xdr; no check.Risk: Arbitrary bytes; it is not a hash of anything core knows.
Handling: Store as 32 raw bytes or hex.
Memo
makeClassicMemoSCVal
6
tx.memo.return
Transaction.memo(MEMO_RETURN).retHash
all protocols
no32 opaque bytes by xdr; core does not check that it names a real transaction.Risk: A fabricated reference to a transaction that may not exist.
Handling: Store as 32 raw bytes; do not join on it without checking the referenced transaction exists.
Memo
makeClassicMemoSCVal
3
tx.operations
Transaction.operations
all protocols
yesAt most 100 by xdr; empty gives txMISSING_OPERATION; soroban and classic operations cannot be mixed and a soroban transaction has exactly one operation (txMALFORMED); soroban operations before protocol 20 are txMALFORMED.Risk: Low.
Handling: safe; per-operation fields are covered by their own rows.
TransactionFrame::commonValidPreSeqNum
TransactionFrame::validateSorobanOpsConsistency
4
tx.ext
Transaction.ext
protocol 20 and later
yesA soroban transaction must use ext v1 with sorobanData (txMALFORMED otherwise); from protocol 21 a classic transaction with ext v1 is txMALFORMED.Risk: Low.
Handling: Read sorobanData only when ext.v is 1.
TransactionFrame::XDRProvidesValidFee
TransactionFrame::commonValidPreSeqNum
10
tx.soroban.resource_fee
Transaction.ext(1).sorobanData.resourceFee
protocol 20 and later
yesMust be in [0, 2^50] (txMALFORMED); must be <= tx.fee (from protocol 23 only when the fee is charged, so a fee bump inner transaction may exceed its own fee); must be >= the computed non-refundable plus refundable resource fee (txSOROBAN_INVALID).Risk: declared, not charged; the refundable part is returned.
Handling: Use feeCharged and the soroban fee fields in meta for what was paid.
TransactionFrame::XDRProvidesValidFee
TransactionFrame::commonValidPreSeqNum
6
tx.soroban.resources.limits
Transaction.ext(1).sorobanData.resources.{instructions,diskReadBytes,writeBytes}
protocol 20 and later
yesEach must be <= the network per-transaction limit (txSOROBAN_INVALID); the whole transaction size must be <= txMaxSizeBytes. they are upper bounds enforced during execution, not consumption.Risk: Treating declared resources as usage overstates cost.
Handling: Read actual consumption from soroban meta and diagnostic or metrics events.
TransactionFrame::checkSorobanResourcesnone
tx.soroban.footprint
Transaction.ext(1).sorobanData.resources.footprint.{readOnly,readWrite}
protocol 20 and later
partialKey types limited to ACCOUNT, TRUSTLINE (valid non-native asset, account not issuer), CONTRACT_DATA, CONTRACT_CODE; each key xdr size <= maxContractDataKeySizeBytes; entry counts within network limits (from protocol 23 total entries <= txMaxFootprintEntries); no duplicate keys within or across the two sets. keys need not exist, and the host enforces only that accesses stay inside the footprint.Risk: The footprint is what the submitter allowed the contract to touch, not what it touched; keys can name entries that do not exist or were never read or written.
Handling: Derive touched entries and participants from ledger entry changes in meta, never from the footprint.
TransactionFrame::checkSorobanResources
TransactionFrame::commonValidPreSeqNum
invoke_host_function
15
tx.soroban.ext.archived_soroban_entries
Transaction.ext(1).sorobanData.ext(1).resourceExt.archivedSorobanEntries
protocol 23 and later
yesExt v1 before protocol 23 is txSOROBAN_INVALID; indexes must be strictly ascending, < readWrite length, and point at persistent CONTRACT_DATA or CONTRACT_CODE keys.Risk: low; it marks entries the submitter claims are archived, the restore itself shows in meta.
Handling: Use meta restore changes to learn what was restored.
TransactionFrame::checkSorobanResources1
feebump.fee_source
FeeBumpTransaction.feeSource
protocol 13 and later
yesReduced to its base key; must exist (txNO_ACCOUNT), its signers must reach the low threshold over the fee bump contents hash (txBAD_AUTH), must afford the fee (txINSUFFICIENT_BALANCE).Risk: The fee source pays but is not the inner transaction source.
Handling: Record the fee payer separately from the transaction source.
FeeBumpTransactionFrame::commonValidPreSeqNum
FeeBumpTransactionFrame::commonValid
FeeBumpTransactionFrame::getFeeSourceID
6
feebump.fee_source.mux_id
FeeBumpTransaction.feeSource(KEY_TYPE_MUXED_ED25519).med25519.id
protocol 13 and later
noDiscarded by toAccountID in getFeeSourceID; not checked.Risk: submitter-chosen 64-bit value with no account behind it (stellar-etl#477 historical fee_account_muxed rows).
Handling: Store base account and mux id separately.
FeeBumpTransactionFrame::getFeeSourceID
toAccountID
2
feebump.fee
FeeBumpTransaction.fee
protocol 13 and later
yesMust be >= 0 (txMALFORMED); inclusion fee must be >= baseFee times (inner ops + 1); the per-op inclusion rate must be >= the inner rate (txINSUFFICIENT_FEE), and the inner inclusion fee must be >= 0 except, from protocol 23, for soroban inner transactions (txFEE_BUMP_INNER_FAILED).Risk: A bid; the inner transaction fee is not what was paid.
Handling: Use feeCharged from the fee bump result.
FeeBumpTransactionFrame::XDRProvidesValidFee
FeeBumpTransactionFrame::commonValidPreSeqNum
6
feebump.signatures
FeeBumpTransactionEnvelope.signatures
protocol 13 and later
yesChecked against the fee source over sha256(networkID, ENVELOPE_TYPE_TX_FEE_BUMP, feeBump.tx); every signature must be used (txBAD_AUTH_EXTRA).Risk: Outer and inner signature lists sign different hashes.
Handling: Keep outer and inner signatures apart.
FeeBumpTransactionFrame::checkValidImpl
FeeBumpTransactionFrame::getContentsHash
6
feebump.inner_tx
FeeBumpTransaction.innerTx(ENVELOPE_TYPE_TX).v1
protocol 13 and later
yesOnly the ENVELOPE_TYPE_TX arm exists in xdr. core rebuilds a v1 envelope and runs the full transaction validation on it without charging its fee, so inner signatures are checked over the inner contents hash and every inner field row applies.Risk: Using the outer hash for the inner transaction, or the inner fee as paid.
Handling: Index the inner transaction with both hashes (outer full hash and inner hash) and the outer fee source as payer.
FeeBumpTransactionFrame::convertInnerTxToV1
FeeBumpTransactionFrame::checkValidImpl
FeeBumpTransaction
9
op.source_account
Operation.sourceAccount
all protocols
yesoptional; when absent the transaction source is used. reduced to its base key; must exist (opNO_ACCOUNT) and its signers must reach the operation threshold (opBAD_AUTH).Risk: An operation source is a real account, but not every affected account.
Handling: Use the base key; resolve the absent case to the transaction source.
OperationFrame::checkSignature
OperationFrame::checkValid
OperationFrame::getSourceID
7
op.source_account.mux_id
Operation.sourceAccount(KEY_TYPE_MUXED_ED25519).med25519.id
protocol 13 and later
noDiscarded by toAccountID; copied into events only. from protocol 25 a muxed operation source on INVOKE_HOST_FUNCTION is txSOROBAN_INVALID; the soroban host is given only the base AccountID.Risk: Same as the transaction source mux id; a muxed operation source plus its base account can collide as two keys for one account (wallet-backend#753).
Handling: Store base account and mux id separately; de-duplicate participants by base account.
OperationFrame::getSourceID
TransactionFrame::validateSorobanMemo
InvokeHostFunctionApplyHelper::invokeHostFunction
2
op.create_account.destination
CreateAccountOp.destination
all protocols
yesMust differ from the source (CREATE_ACCOUNT_MALFORMED); must not exist at apply (CREATE_ACCOUNT_ALREADY_EXIST).Risk: Low on success.
Handling: Confirm creation from the account entry created in meta.
CreateAccountOpFrame::doCheckValid
CreateAccountOpFrame::doApply
5
op.create_account.starting_balance
CreateAccountOp.startingBalance
all protocols
yesMust be >= 0 from protocol 14, > 0 before (CREATE_ACCOUNT_MALFORMED); reserve and source balance checked at apply.Risk: Low.
Handling: Safe on success; 0 is legal from protocol 14 with sponsorship.
CreateAccountOpFrame::doCheckValid3
op.payment.destination
PaymentOp.destination
all protocols
yesBase key must exist at apply (PAYMENT_NO_DESTINATION through the internal path payment) and hold a trustline for non-native assets; a native payment to self succeeds instantly.Risk: On a failed payment the destination may not exist at all.
Handling: Only treat the destination as a participant when the payment succeeded or meta shows a change.
PaymentOpFrame::doApply
PathPaymentStrictReceiveOpFrame::doApply
42
op.payment.destination.mux_id
PaymentOp.destination(KEY_TYPE_MUXED_ED25519).med25519.id
protocol 13 and later
noReduced away by toAccountID; copied into the transfer event as to_muxed_id (protocol 23 event format).Risk: An arbitrary id that does not correspond to anything on ledger; truncating M addresses to 32 bytes loses it (wallet-backend#716).
Handling: Store base account plus unsigned 64-bit id; read the id from the event when present.
PaymentOpFrame::doApply
getPossibleMuxedData
16
op.payment.asset
PaymentOp.asset
all protocols
partialisAssetValid: alphanum4 code 1 to 4 and alphanum12 code 5 to 12 ascii [a-zA-Z0-9] characters with only trailing zero padding (PAYMENT_MALFORMED). from protocol 13 the issuer is not checked for existence.Risk: The issuer account may not exist; codes compare by raw bytes including padding.
Handling: Key assets by (type, raw code bytes, issuer key bytes), not by display string.
PaymentOpFrame::doCheckValid
isAssetValid
PathPaymentOpFrameBase::checkIssuer
17
op.payment.amount
PaymentOp.amount
all protocols
yesMust be > 0 (PAYMENT_MALFORMED); balance, limits and authorization checked at apply.Risk: low; int64 stroops lose precision as float64 (stellar-etl#454).
Handling: Store as int64 or decimal, never float.
PaymentOpFrame::doCheckValid6
op.path_payment.destination
PathPaymentStrictReceiveOp.destination | PathPaymentStrictSendOp.destination
all protocols
yesBase key must exist at apply (NO_DESTINATION) unless the issuer bypass applies; trustline checked when crediting.Risk: As for payment destination.
Handling: As for payment destination.
PathPaymentStrictReceiveOpFrame::doApply
PathPaymentStrictSendOpFrame::doApply
PathPaymentOpFrameBase::getDestID
31
op.path_payment.destination.mux_id
PathPaymentStrictReceiveOp.destination(KEY_TYPE_MUXED_ED25519).med25519.id | PathPaymentStrictSendOp.destination(KEY_TYPE_MUXED_ED25519).med25519.id
protocol 13 and later
noDiscarded by getDestID (toAccountID); copied into the transfer event.Risk: As for payment mux id.
Handling: As for payment mux id.
PathPaymentOpFrameBase::getDestID
toAccountID
7
op.path_payment.amounts
PathPaymentStrictReceiveOp.{sendMax,destAmount} | PathPaymentStrictSendOp.{sendAmount,destMin}
all protocols
yesAll must be > 0 (MALFORMED); sendMax and destMin are limits, the exchanged amounts come from the result.Risk: sendMax and destMin are bounds, not amounts moved.
Handling: Take actual amounts from the operation result (claim atoms and last amount) or from events.
PathPaymentStrictReceiveOpFrame::doCheckValid
PathPaymentStrictSendOpFrame::doCheckValid
27
op.path_payment.path
PathPaymentStrictReceiveOp.path | PathPaymentStrictSendOp.path
all protocols
partialAt most 5 by xdr; each asset must pass isAssetValid (MALFORMED); send, dest and path assets are all checked with isAssetValid. distinctness and existence of order books are not checked.Risk: The path is the submitter's route request; hops with no liquidity or repeated assets are legal and the trades actually made are only in the result.
Handling: Derive trades from claim atoms in the result and from meta, not from the path.
PathPaymentStrictReceiveOpFrame::doCheckValid
PathPaymentStrictSendOpFrame::doCheckValid
PathPaymentStrictReceiveOp
6
op.manage_offer.offer_id
ManageSellOfferOp.offerID | ManageBuyOfferOp.offerID
all protocols
yesFrom protocol 15 must be >= 0 (MALFORMED); 0 with a delete (amount 0) is MALFORMED from protocol 11; a nonzero id must name an offer owned by the source at apply (NOT_FOUND). 0 means create, and the new id is assigned by core and returned in the result.Risk: offerID 0 is not the offer's id; ids above 2^53-1 lose precision in js; negative ids appear in pre-15 history.
Handling: Take the created or updated offer id from the operation result or meta; store as int64.
ManageOfferOpFrameBase::doCheckValid
ManageOfferOpFrameBase::doApply
32
op.manage_offer.assets_amount_price
ManageSellOfferOp.{selling,buying,amount,price} | ManageBuyOfferOp.{selling,buying,buyAmount,price} | CreatePassiveSellOfferOp.{selling,buying,amount,price}
all protocols
yesBoth assets isAssetValid and different; amount >= 0; price n and d > 0 (MALFORMED). trustlines and authorization at apply (only before protocol 13 also issuer existence).Risk: An offer may be fully or partly crossed immediately, so the envelope amount is not the resting amount.
Handling: Read offer state and trades from meta and the result.
ManageOfferOpFrameBase::doCheckValid
ManageOfferOpFrameBase::checkOfferValid
33
op.set_options.home_domain
SetOptionsOp.homeDomain
all protocols
partialXDR string32 (at most 32 bytes); every byte must be printable ascii 0x20 to 0x7e (SET_OPTIONS_INVALID_HOME_DOMAIN). empty is allowed. not checked to be a domain name.Risk: Any printable ascii, not necessarily a resolvable domain; fetching stellar.toml from it is an outbound request to a submitter-chosen host.
Handling: Store as text; validate as a hostname before any network use.
SetOptionsOpFrame::doCheckValid
isStringValid
isAsciiNonControl
3
op.set_options.inflation_dest
SetOptionsOp.inflationDest
all protocols
yesMust be the source or an existing account at apply (SET_OPTIONS_INVALID_INFLATION).Risk: low; inflation has been disabled since protocol 12 but the field can still be set.
Handling: Safe on success.
SetOptionsOpFrame::doApply2
op.set_options.signer
SetOptionsOp.signer
all protocols
yesKey must not be the source's own key; weight <= 255 from protocol 10; non-ed25519 keys are rejected before protocol 3; a signed-payload key is rejected before protocol 19 and when its payload is empty (SET_OPTIONS_BAD_SIGNER). weight 0 removes the signer. the key need not be an existing account.Risk: Signer keys of four types, including P keys with up to 64 payload bytes; a 32-byte signer column halts ingestion or truncates (wallet-backend#729).
Handling: Store signer type, key and full payload; take the resulting signer set from the account entry in meta.
SetOptionsOpFrame::doCheckValid
Signer
24
op.set_options.thresholds_and_flags
SetOptionsOp.{clearFlags,setFlags,masterWeight,lowThreshold,medThreshold,highThreshold}
all protocols
yesFlags must be within the protocol's account flag mask (SET_OPTIONS_UNKNOWN_FLAG) and set and clear must not overlap (SET_OPTIONS_BAD_FLAGS); weight and thresholds <= 255 (SET_OPTIONS_THRESHOLD_OUT_OF_RANGE); auth flags cannot change on an immutable account at apply.Risk: Low.
Handling: Take resulting flags and thresholds from the account entry in meta.
SetOptionsOpFrame::doCheckValid4
op.change_trust.line
ChangeTrustOp.line
all protocols
yesisAssetValid; native rejected from protocol 10; the source must not be the issuer from protocol 16 (CHANGE_TRUST_MALFORMED). issuer existence is not checked.Risk: The issuer may not exist.
Handling: Key the trustline asset by raw bytes.
ChangeTrustOpFrame::doCheckValid
ChangeTrustOpFrame::doApply
9
op.change_trust.line.liquidity_pool
ChangeTrustOp.line(ASSET_TYPE_POOL_SHARE).liquidityPool.constantProduct
protocol 18 and later
yesFrom protocol 18; assetA and assetB must each pass isAssetValid, assetA < assetB under the xdr-generated operator< (type, then raw code bytes with padding, then issuer key bytes), fee must equal LIQUIDITY_POOL_FEE_V18 = 30, else CHANGE_TRUST_MALFORMED.Risk: An sdk that orders issuers by strkey text builds a pool id core rejects (js-stellar-sdk#1766); the pool id is sha256 of these parameters.
Handling: Order assets by raw xdr bytes, never by display string; take the pool id from meta.
isPoolShareAssetValid(ChangeTrustAsset)
LiquidityPoolConstantProductParameters
16
op.change_trust.limit
ChangeTrustOp.limit
all protocols
yesMust be >= 0 (CHANGE_TRUST_MALFORMED); at apply must be >= current balance plus buying liabilities (CHANGE_TRUST_INVALID_LIMIT); 0 deletes the trustline.Risk: Low.
Handling: Take the resulting limit from the trustline entry in meta.
ChangeTrustOpFrame::doCheckValid
ChangeTrustOpFrame::doApply
8
op.allow_trust
AllowTrustOp.{trustor,asset,authorize}
all protocols
yesAsset not native and its code valid; authorize <= AUTHORIZED_TO_MAINTAIN_LIABILITIES_FLAG and a valid trustline flag for the protocol; trustor must differ from the source from protocol 16 (ALLOW_TRUST_MALFORMED).Risk: Low.
Handling: Take authorization state from the trustline entry in meta.
AllowTrustOpFrame::doCheckValid11
op.manage_data.data_name
ManageDataOp.dataName
protocol 2 and later
partialXDR string64 (at most 64 bytes); at least 1 byte and every byte printable ascii 0x20 to 0x7e (MANAGE_DATA_INVALID_NAME). no other content check.Risk: Printable ascii only, but any printable ascii, including quotes and separators.
Handling: Store as text with proper escaping; key data entries by (account, name bytes).
ManageDataOpFrame::doCheckValid
isStringValid
5
op.manage_data.data_value
ManageDataOp.dataValue
protocol 2 and later
noXDR opaque up to 64 bytes; doCheckValid does not read it and doApply copies it verbatim into the data entry. null deletes the entry (MANAGE_DATA_NAME_NOT_FOUND when absent).Risk: Arbitrary binary; decoding as text or as base64 of text fails or corrupts.
Handling: Store as bytes.
ManageDataOpFrame::doApply
DataValue
6
op.bump_sequence.bump_to
BumpSequenceOp.bumpTo
protocol 10 and later
yesMust be >= 0 (BUMP_SEQUENCE_BAD_SEQ); applied only when greater than the current sequence.Risk: A lower bumpTo is a successful no-op.
Handling: Read the resulting sequence from meta.
BumpSequenceOpFrame::doCheckValid
BumpSequenceOpFrame::doApply
9
op.create_claimable_balance.claimants.destination
CreateClaimableBalanceOp.claimants[].v0.destination
protocol 14 and later
partial1 to 10 claimants (xdr upper bound, core rejects empty); destinations must be unique within the operation (CREATE_CLAIMABLE_BALANCE_MALFORMED). destinations are not checked for existence.Risk: Claimants can be accounts that do not exist and never will; they are not participants of the transaction.
Handling: Record claimants as potential claimers only; a claimant participates when a claim succeeds.
CreateClaimableBalanceOpFrame::doCheckValid
CreateClaimableBalanceOpFrame::doApply
6
op.create_claimable_balance.claimants.predicate
CreateClaimableBalanceOp.claimants[].v0.predicate
protocol 14 and later
yesRecursion depth at most 4 (the claimant predicate is depth 1); AND and OR must have exactly 2 children; NOT must be non-null; absBefore and relBefore must be >= 0 (CREATE_CLAIMABLE_BALANCE_MALFORMED). at apply every relBefore is rewritten to absBefore = close time + relBefore, so the stored entry differs from the envelope.Risk: A recursive structure; the envelope predicate and the ledger entry predicate differ for relative times.
Handling: Bound recursion at depth 4 when walking; read the stored predicate from the claimable balance entry in meta.
validatePredicate
updatePredicatesForApply
ClaimPredicate
3
op.create_claimable_balance.asset_amount
CreateClaimableBalanceOp.{asset,amount}
protocol 14 and later
yesisAssetValid and amount > 0 (MALFORMED); funds, trustline and authorization at apply. the balance id is derived by core and returned in the result.Risk: The balance id is not in the envelope.
Handling: Take the balance id from the result or meta.
CreateClaimableBalanceOpFrame::doCheckValid9
op.claim_claimable_balance.balance_id
ClaimClaimableBalanceOp.balanceID
protocol 14 and later
yesNo static check (doCheckValid returns true); at apply the entry must exist (CLAIM_CLAIMABLE_BALANCE_DOES_NOT_EXIST) and the source must be a claimant whose predicate holds (CANNOT_CLAIM).Risk: On a failed claim the id may name nothing.
Handling: Trust only on success.
ClaimClaimableBalanceOpFrame::doCheckValid
ClaimClaimableBalanceOpFrame::doApply
8
op.clawback.from
ClawbackOp.from
protocol 17 and later
yesCompared as a full MuxedAccount against the source (CLAWBACK_MALFORMED when equal); at apply reduced to its base key, which must hold a clawback-enabled trustline (CLAWBACK_NO_TRUST, CLAWBACK_NOT_CLAWBACK_ENABLED).Risk: Low on success.
Handling: Use the base key.
ClawbackOpFrame::doCheckValid
ClawbackOpFrame::doApply
9
op.clawback.from.mux_id
ClawbackOp.from(KEY_TYPE_MUXED_ED25519).med25519.id
protocol 17 and later
noDiscarded by toAccountID at apply; copied into the clawback event.Risk: Arbitrary 64-bit value.
Handling: Store separately from the base account.
ClawbackOpFrame::doApply2
op.clawback.asset_amount
ClawbackOp.{asset,amount}
protocol 17 and later
yesAmount >= 1; asset not native, valid, and issued by the source (CLAWBACK_MALFORMED); balance checked at apply.Risk: Low.
Handling: Safe on success.
ClawbackOpFrame::doCheckValid7
op.clawback_claimable_balance.balance_id
ClawbackClaimableBalanceOp.balanceID
protocol 17 and later
yesNo static check; at apply the entry must exist (DOES_NOT_EXIST), the source must be the asset issuer (NOT_ISSUER) and clawback must be enabled on the balance.Risk: On failure the id may name nothing.
Handling: Trust only on success.
ClawbackClaimableBalanceOpFrame::doApply7
op.set_trust_line_flags
SetTrustLineFlagsOp.{trustor,asset,clearFlags,setFlags}
protocol 17 and later
yesAsset not native, valid and issued by the source; trustor differs from source; set and clear disjoint; setFlags a valid trustline flag set without the clawback flag; clearFlags within the mask (SET_TRUST_LINE_FLAGS_MALFORMED).Risk: Low.
Handling: Take resulting flags from the trustline entry in meta.
SetTrustLineFlagsOpFrame::doCheckValid15
op.begin_sponsoring.sponsored_id
BeginSponsoringFutureReservesOp.sponsoredID
protocol 14 and later
partialMust differ from the source (BEGIN_SPONSORING_FUTURE_RESERVES_MALFORMED); at apply must not already be sponsored or sponsoring (ALREADY_SPONSORED, RECURSIVE). the account is not loaded, so it need not exist (it may be created later in the same transaction); from protocol 14 a transaction that leaves a sponsorship open fails with txBAD_SPONSORSHIP.Risk: The sponsored id may be an account that does not exist yet.
Handling: Derive sponsorship relations from sponsoring ids on ledger entries in meta.
BeginSponsoringFutureReservesOpFrame::doCheckValid
BeginSponsoringFutureReservesOpFrame::doApply
TransactionFrame::applyOperations
5
op.revoke_sponsorship
RevokeSponsorshipOp.ledgerKey | RevokeSponsorshipOp.signer
protocol 14 and later
partialFor ledger keys only ACCOUNT, TRUSTLINE (valid non-native asset, account not issuer), OFFER (offerID > 0), DATA (name 1 to 64 printable ascii) and CLAIMABLE_BALANCE are allowed (REVOKE_SPONSORSHIP_MALFORMED); existence and sponsorship are checked at apply. the signer arm is not checked statically.Risk: On failure the key may name nothing.
Handling: Read sponsorship changes from meta.
RevokeSponsorshipOpFrame::doCheckValid8
op.account_merge.destination
Operation.body(ACCOUNT_MERGE).destination
all protocols
yesBase key must differ from the source (ACCOUNT_MERGE_MALFORMED) and exist at apply (ACCOUNT_MERGE_NO_ACCOUNT); the mux id is discarded by toAccountID and copied into the transfer event.Risk: The mux id part is unvalidated as for payments.
Handling: Store base account and mux id separately; take the merged amount from the result.
MergeOpFrame::doCheckValid
MergeOpFrame::doApplyFromV16
12
op.liquidity_pool_deposit
LiquidityPoolDepositOp.{liquidityPoolID,maxAmountA,maxAmountB,minPrice,maxPrice}
protocol 18 and later
yesMax amounts > 0, both prices with n and d > 0, minPrice <= maxPrice (LIQUIDITY_POOL_DEPOSIT_MALFORMED); the pool id must have a pool-share trustline of the source at apply (NO_TRUST).Risk: Max amounts are limits, not deposited amounts.
Handling: Take deposited amounts from meta or events.
LiquidityPoolDepositOpFrame::doCheckValid
LiquidityPoolDepositOpFrame::doApply
10
op.liquidity_pool_withdraw
LiquidityPoolWithdrawOp.{liquidityPoolID,amount,minAmountA,minAmountB}
protocol 18 and later
yesAmount > 0, min amounts >= 0 (LIQUIDITY_POOL_WITHDRAW_MALFORMED); pool-share trustline must exist at apply (NO_TRUST).Risk: Min amounts are bounds.
Handling: Take withdrawn amounts from meta or events.
LiquidityPoolWithdrawOpFrame::doCheckValid
LiquidityPoolWithdrawOpFrame::doApply
5
op.invoke_host_function.invoke_contract.contract_address
InvokeHostFunctionOp.hostFunction(HOST_FUNCTION_TYPE_INVOKE_CONTRACT).invokeContract.contractAddress
protocol 20 and later
partialCore validation does not read it (doCheckValidForSoroban checks only wasm size and asset preimages). the host resolves it at execution, where a missing contract or one outside the footprint fails the operation; the transaction is still in the ledger.Risk: On a failed invocation the address can name nothing; even on success the called contract is not the full set of contracts touched.
Handling: Take invoked contracts from events and meta; treat the envelope address as the submitter's target only.
InvokeHostFunctionOpFrame::doCheckValidForSoroban
invoke_host_function
21
op.invoke_host_function.invoke_contract.function_name
InvokeHostFunctionOp.hostFunction(HOST_FUNCTION_TYPE_INVOKE_CONTRACT).invokeContract.functionName
protocol 20 and later
partialXDR limit 32 bytes only in core. the host checks the [a-zA-Z0-9_] symbol character set when converting it at execution.Risk: On a failed transaction the name can contain any bytes up to 32.
Handling: Store as bytes or validate the symbol charset before treating as text.
SCSymbol
SymbolError
21
op.invoke_host_function.invoke_contract.args
InvokeHostFunctionOp.hostFunction(HOST_FUNCTION_TYPE_INVOKE_CONTRACT).invokeContract.args
protocol 20 and later
partialCore checks only that the envelope parses within the xdr depth limit and that the transaction fits txMaxSizeBytes. the host converts args to host values at execution, rejecting maps not strictly sorted by key and invalid symbols; a failed conversion fails the operation, not inclusion.Risk: Nested ScVal trees bounded only by xdr depth and tx size; unsorted or colliding map keys and invalid symbols can appear in failed transactions (js-stellar-sdk#1789).
Handling: Decode with depth and length limits; treat map key order and uniqueness as untrusted; tolerate refusal per value.
validateXDRForProtocol
MeteredOrdMap::from_map_with_host
59
op.invoke_host_function.upload_wasm
InvokeHostFunctionOp.hostFunction(HOST_FUNCTION_TYPE_UPLOAD_CONTRACT_WASM).wasm
protocol 20 and later
partialSize must be <= the network maxContractSizeBytes at validation; whether it is a valid module is decided by the host at execution.Risk: Large opaque blob; may not be valid wasm on failed transactions.
Handling: Store by hash with a size cap; take the uploaded code entry from meta.
InvokeHostFunctionOpFrame::doCheckValidForSoroban1
op.invoke_host_function.create_contract.preimage
InvokeHostFunctionOp.hostFunction(HOST_FUNCTION_TYPE_CREATE_CONTRACT|HOST_FUNCTION_TYPE_CREATE_CONTRACT_V2).{createContract,createContractV2}.contractIDPreimage
protocol 20 and later
partialCore checks only that an asset preimage passes isAssetValid (v1 create only). the host requires the address in an address preimage to authorize the creation, and rejects mismatched preimage and executable pairs, at execution.Risk: The deployer address in the envelope is a claim until the host authorizes it; contract-creation state read from declared auth entries is wrong (wallet-backend#746).
Handling: Take the created contract id and its creator from meta (new contract instance entry) and events.
InvokeHostFunctionOpFrame::doCheckValidForSoroban
Host::create_contract_with_optional_auth
8
op.invoke_host_function.create_contract_v2.constructor_args
InvokeHostFunctionOp.hostFunction(HOST_FUNCTION_TYPE_CREATE_CONTRACT_V2).createContractV2.constructorArgs
protocol 22 and later
partialNot read by core validation; converted and passed to the constructor by the host at execution.Risk: As for invoke args.
Handling: As for invoke args.
InvokeHostFunctionOpFrame::doCheckValidForSoroban
Host::create_contract_with_optional_auth
1
op.invoke_host_function.auth
InvokeHostFunctionOp.auth
protocol 20 and later
partialCore never inspects auth entries; it serializes them and hands them to the host. at the start of execution the host decodes every entry and converts its address, signature ScVal and invocation tree to host objects, so an entry that cannot be converted fails the operation. signatures, nonces and expiration are then checked lazily, only for an entry that a require_auth call matches. there is no check that every entry was used, so a declared entry that is never matched is never authenticated.Risk: Declared auth entries are submitter claims; an unmatched entry can name any address with any signature and still appear in a successful transaction. unbounded trees halted wallet-backend ingestion.
Handling: Never derive participants or authorizations from declared entries; use events and meta; cap recursion when walking.
InvokeHostFunctionApplyHelper::invokeHostFunction
invoke_host_function
AuthorizationManager::new_enforcing
AuthorizationManager::require_auth_enforcing
AccountAuthorizationTracker::maybe_authorize_invocation
22
auth.credentials.source_account
SorobanAuthorizationEntry.credentials(SOROBAN_CREDENTIALS_SOURCE_ACCOUNT)
protocol 20 and later
partialBinds the entry to the operation source account (the base AccountID core passes to the host); no signature, nonce or expiration; authenticate and verify_and_consume_nonce return immediately. authorization rests on the transaction signatures.Risk: No per-entry proof exists.
Handling: Attribute to the operation source only when a matching authorization shows in events or meta.
AccountAuthorizationTracker::from_authorization_entry
AccountAuthorizationTracker::authenticate
Host::source_account_address
4
auth.credentials.address.address
SorobanAddressCredentials.address
protocol 20 and later
partialNot checked by core. converted to a host address object up front; checked only when the entry is matched, then an account address must authenticate with signatures and a contract address through __check_auth; any other address type fails authentication with an internal error.Risk: An unmatched entry can name any address, including muxed, claimable balance or liquidity pool addresses.
Handling: Do not treat the address as a participant unless meta or events confirm it.
AccountAuthorizationTracker::authenticate
AccountAuthorizationTracker::from_authorization_entry
14
auth.credentials.address.nonce
SorobanAddressCredentials.nonce
protocol 20 and later
partialConsumed by the host only when the entry is matched: the nonce must not already exist as a temporary ledger entry for the address (Auth ExistingValue) and must be in the footprint; it is then written. unmatched entries consume nothing.Risk: A nonce in an unmatched entry was never consumed and proves nothing.
Handling: Read consumed nonces from contract data changes in meta.
AccountAuthorizationTracker::verify_and_consume_nonce
Host::consume_nonce
14
auth.credentials.address.signature_expiration_ledger
SorobanAddressCredentials.signatureExpirationLedger
protocol 20 and later
partialChecked by the host only when the entry is matched: ledger seq must be <= it (signature has expired) and it must be <= the maximum live-until ledger (signature expiration is too late).Risk: Unchecked on unmatched entries.
Handling: Do not use it as evidence of a live authorization.
AccountAuthorizationTracker::verify_and_consume_nonce14
auth.credentials.address.signature
SorobanAddressCredentials.signature
protocol 20 and later
partialConverted to a host value up front (so it must be a convertible ScVal). verified only when matched: for an account address it must be a vec of 1 to 20 {public_key, signature} entries in strictly increasing key order, each an ed25519 signature over sha256 of the HashIdPreimage (network id, nonce, expiration, invocation, plus the address for the v2 and delegates credential types), each key a signer of the account, total weight >= the medium threshold; for a contract address the value is passed to the contract's __check_auth.Risk: An arbitrary nested ScVal on unmatched entries; even on matched contract entries its shape is contract-defined.
Handling: Treat as opaque; decode with depth limits; never infer signers from it.
check_account_authentication
check_account_contract_auth
AccountAuthorizationTracker::get_signature_payload
14
auth.credentials.address_with_delegates.delegates
SorobanAddressCredentialsWithDelegates.delegates
protocol 27 and later
partialDecoded by the host up front for every entry: at each level delegate addresses must be strictly increasing with no duplicates, and each nested signature is converted to a host value; signatures are only used when the entry is matched.Risk: A recursive tree of addresses and signatures that core never looks at.
Handling: Cap recursion; never derive participants from delegates.
DelegatedAccountAuthSignerTreeNode::from_xdr
SorobanDelegateSignature
2
auth.root_invocation
SorobanAuthorizationEntry.rootInvocation
protocol 20 and later
partialCore does not look at it. the host converts the whole tree up front (contract addresses, function names as symbols, args to host values), then matches nodes lazily against actual require_auth calls; subtrees that never match are never compared with anything that ran.Risk: recursive, bounded only by xdr depth and tx size; an unmatched subtree can describe calls that never happened.
Handling: Cap depth and node count when walking; take actual calls from events.
AuthorizedInvocation::from_xdr
AuthorizedFunction::from_xdr
SorobanAuthorizedInvocation
18
op.extend_footprint_ttl.extend_to
ExtendFootprintTTLOp.extendTo
protocol 20 and later
yesMust be <= maxEntryTTL - 1; the read-write footprint must be empty and every read-only key must be contract data or code (EXTEND_FOOTPRINT_TTL_MALFORMED).Risk: The requested extension, not the resulting live-until ledger.
Handling: Read new TTLs from meta.
ExtendFootprintTTLOpFrame::doCheckValidForSoroban4
op.restore_footprint
Operation.body(RESTORE_FOOTPRINT) with footprint
protocol 20 and later
yesThe read-only footprint must be empty and every read-write key must be a persistent soroban entry (RESTORE_FOOTPRINT_MALFORMED).Risk: Keys that were not archived are legal and restore nothing.
Handling: Read restored entries from meta.
RestoreFootprintOpFrame::doCheckValidForSoroban5

Corpus entries by field

The shapekit corpus holds ledger data and test vectors for rare shapes, each recorded in the corpus manifest with the fields it exercises. These are the entries for each field.

Each cap below equals the tightest bound the network itself guarantees, so applying it never refuses data that core could have put in a ledger. A value above a cap is malformed or hostile input.

The bounds that matter most:

  • Envelope depth. From protocol 28 core rejects with txMALFORMED any envelope that its Rust XDR parser cannot read at depth 1000. Before protocol 28 it used a depth check of 500 in its C++ XDR library (validateXDRForProtocol).
  • ScVal depth. The Soroban host does not produce values nested deeper than DEFAULT_HOST_DEPTH_LIMIT, 100 (limits.rs). Arguments in an envelope are bounded only by the envelope depth, so a failed transaction can carry deeper ones.
  • Authorization trees. The host converts declared invocation trees without a depth limit (AuthorizedInvocation::from_xdr). Only the envelope depth and size bound them, which allows a single chain of 493 levels and up to 2540 invocation nodes in one transaction (auth.invocation.depth and auth.invocation.nodes below).
  • Unbounded XDR vectors. SCVec, SCMap, SCBytes, SCString, authorization entry lists and sub-invocation lists have no XDR length bound (Stellar-contract.x, Stellar-transaction.x). Their caps below are derived from the transaction size limit.

Decoders differ in what they do by default. rs-stellar-xdr decodes with the Limits the caller passes, and Limits::none() turns off both the depth and the length limit (generated.rs). go-xdr, which go-stellar-sdk uses, defaults to a depth limit of 1500 and caps slice preallocation at 256 elements (decode.go). Whatever the language, pass the caps below explicitly.

Caps marked "network setting" come from CONFIG_SETTING ledger entries that validators can change by vote. The values shown were read from testnet and mainnet on the date listed; read the current values from the network rather than hardcoding them.

When a value is refused

A cap, or a decoder, will sometimes refuse a value that is in a ledger. SDKs add refusals as they fix bugs: from version 17.3.0, js-stellar-sdk's scValToNative() throws a TypeError when two keys of an scvMap convert to the same object property, and its release notes say that humanizeEvents() and contract-client decoding can now throw on data read from the network, because any contract can emit such a map (v17.3.0 release notes, js-stellar-sdk#1789).

An indexer that has no plan for a refusal tends to halt. In wallet-backend, a SetOptions operation that added a signed-payload signer made the database copy fail, the persist rolled back together with the cursor, and ingestion retried the same ledger forever (wallet-backend#729). A muxed Soroban source and its base account produced a duplicate key that stopped ingestion at that ledger after every restart (wallet-backend#753). A muxed contract address failed the whole ledger persist (wallet-backend#792). stellar-etl has an open request for a documented policy for non-conforming events (stellar-etl#467).

Decide the policy before it is needed, and test it:

  • Skip and count a single value that exceeds a cap or that a decoder refuses: store the rest of the transaction, record what was skipped and why, and expose the count as a metric.
  • Quarantine and continue a transaction that cannot be stored: write it, with the error, to a quarantine table, store the rest of the ledger, advance the cursor, and alert. Reprocess quarantined transactions after a fix.
  • Halt only when the ledger itself cannot be read, for example when a LedgerCloseMeta fails to decode within the caps, because skipping it would leave a silent gap.

Caps table

CapApplies toRecommendedDerived fromWhy
xdr.envelope.depthTransactionEnvelope decode (Limits.depth in rs-stellar-xdr, equivalent recursion guard elsewhere).1000 levelsvalidateXDRForProtocol (rustDepthLimit) = 1000
validateXDRForProtocol (xdr::check_xdr_depth before protocol 28) = 500
can_parse_transaction (Limits { depth: depth_limit, len: xdr.data.len() }) = depth_limit
From protocol 28 core rejects with txMALFORMED any envelope that the Rust XDR parser cannot read at depth 1000, so no ledger at protocol 28 or later holds a deeper envelope. Before protocol 28 core applied the xdrpp depth checker at 500, which counts every struct, union, vector and optional as one level. The Rust decoder also counts typedef newtypes, and the measured cost of the deepest recursions is 4 Rust levels per 3 xdrpp levels for nested ScVec and 2 per 2 for auth invocations, which keeps those envelopes inside 1000 as well. Decoders that leave depth unlimited (for example Limits::none(), which the stellar-xdr CLI decode command uses) can overflow the stack on hostile input.
xdr.envelope.len
network setting
TransactionEnvelope decode (Limits.len), and any buffer allocated for one envelope.132096 bytesCONFIG_SETTING_CONTRACT_BANDWIDTH_V0.txMaxSizeBytes = 132096 on testnet, read 2026-11-12 at ledger 5694341
CONFIG_SETTING_CONTRACT_BANDWIDTH_V0.txMaxSizeBytes = 132096 on mainnet, read 2026-11-12 at ledger 65441861
TransactionFrame::checkSorobanResources (getSize() > txMaxSizeBytes) = txMaxSizeBytes
MAX_CLASSIC_TX_SIZE_BYTES = 102400
Soroban envelopes are rejected when xdr_size(envelope) exceeds txMaxSizeBytes, 132096 on both networks. Classic envelopes are held to MAX_CLASSIC_TX_SIZE_BYTES = 100 * 1024 = 102400 by the overlay flood limit; every classic operation also has only fixed-size or XDR-bounded fields. The cap is max(132096, 102400) = 132096. Pass the actual input length as Limits.len when it is smaller, which is what core does in can_parse_transaction.
xdr.meta.depthLedgerCloseMeta decode (Limits.depth), including the transaction set it carries.1012 levelsvalidateXDRForProtocol (rustDepthLimit) = 1000
LedgerCloseMeta::V2 -> GeneralizedTransactionSet -> TransactionPhase::V1 -> ParallelTxsComponent -> DependentTxCluster -> TransactionEnvelope = 12 levels above the envelope, measured: depthprobe: deepest valid envelope (997) decodes alone at 997 and inside LedgerCloseMeta V2 at 1009
DEFAULT_HOST_DEPTH_LIMIT = 100
The deepest part of a ledger is an envelope in its transaction set. Envelopes are bounded at depth 1000 (xdr.envelope.depth) and the parallel Soroban phase of a LedgerCloseMeta V2 adds 12 levels above the envelope (measured), so 1000 + 12 = 1012. Host-produced values in transaction meta are shallower: at most 100 ScVal levels at 5 decode levels per ScMap level, 500 levels plus the meta prefix. Ten recent testnet and mainnet ledgers decoded at depths 41 to 53.
xdr.meta.lenLedgerCloseMeta decode (Limits.len) from a datastore file, RPC getLedgers or a captive-core stream.33554432 bytesDEFAULT_XDR_RW_LIMITS.len = 32 * 1024 * 1024No network setting bounds the size of a whole LedgerCloseMeta. Set Limits.len to the length of the buffer being decoded, as soroban-env-host does in metered_from_xdr, and refuse buffers above 32 * 1024 * 1024 = 33554432 bytes, the host's last-resort XDR length ceiling. For scale, ten mainnet ledgers read on 2026-11-12 (65441894 to 65441903) were 1.49 MB to 2.49 MB.
scval.depthNested ScVal traversal and native conversion (ScVec, ScMap) for values from transaction meta: contract events, contract data and code entries, return values, diagnostic events.100 levelsDEFAULT_HOST_DEPTH_LIMIT = 100
Host::from_host_val and Host::to_host_val (with_limited_depth per nested Val) = one level per ScVal
Every ScVal the host writes is produced by from_host_val, which enters one budget depth level per nested value and fails with ExceededLimit past DEFAULT_HOST_DEPTH_LIMIT = 100. Values the host emitted therefore nest at most 100 deep. Use this cap for recursive walks and native conversion of meta-borne ScVals.
scval.depth.envelopeNested ScVal traversal for values read from a transaction envelope (InvokeContractArgs.args, constructor args, auth invocation args).246 levelsvalidateXDRForProtocol (rustDepthLimit) = 1000
ReadXdr for ScVal, Option<ScVec>, ScVec, VecM (one with_limited_depth each) = 4 levels per ScVec nesting, 5 per ScMap nesting, measured: depthprobe: largest ScVec nesting in an InvokeContract argument that decodes at depth 1000
Envelope arguments are only bounded by the envelope depth check, not by the host. Nesting through ScVec costs 4 decode levels and through ScMap 5, so an InvokeHostFunction argument can nest at most 246 ScVec levels (measured; (1000 - envelope prefix) / 4) or 197 ScMap levels inside a valid envelope. The host refuses to convert anything past 100 levels, so such a transaction fails, but it is still in the ledger with its arguments. Walk envelope arguments with this bound, and treat anything between 101 and 246 levels as a value the contract never saw.
scval.vec.len
network setting
ScVec element count, and any allocation sized by a decoded ScVec length.33024 elementsCONFIG_SETTING_CONTRACT_BANDWIDTH_V0.txMaxSizeBytes = 132096 on mainnet, read 2026-11-12 at ledger 65441861
ScVal::Void encoding = 4 bytes, measured: depthprobe: ScVal::Void.to_xdr()
typedef SCVal SCVec<> = unbounded
SCVec<> has no XDR bound. The largest ScVal container any ledger holds is in a Soroban envelope (contract data entries are capped at 65536 bytes and events plus return value at 16384, both smaller). The smallest element is ScVal::Void at 4 bytes, so a vector inside a 132096-byte envelope has at most 132096 / 4 = 33024 elements. Check the decoded length against this before reserving capacity (the bug fixed in rs-stellar-xdr#581).
scval.map.len
network setting
ScMap entry count, and any allocation sized by a decoded ScMap length.16512 entriesCONFIG_SETTING_CONTRACT_BANDWIDTH_V0.txMaxSizeBytes = 132096 on mainnet, read 2026-11-12 at ledger 65441861
ScMapEntry { key: Void, val: Void } encoding = 8 bytes, measured: depthprobe: ScMapEntry.to_xdr()
typedef SCMapEntry SCMap<> = unbounded
SCMap<> has no XDR bound. The smallest entry is two ScVal::Void values, 8 bytes, so a map inside a 132096-byte envelope has at most 132096 / 8 = 16512 entries.
scval.bytes.len
network setting
ScBytes and ScString length (SCV_BYTES, SCV_STRING), and any buffer or text column sized by one.132096 bytesCONFIG_SETTING_CONTRACT_BANDWIDTH_V0.txMaxSizeBytes = 132096 on mainnet, read 2026-11-12 at ledger 65441861
CONFIG_SETTING_CONTRACT_DATA_ENTRY_SIZE_BYTES = 65536 on mainnet, read 2026-11-12 at ledger 65441861
CONFIG_SETTING_CONTRACT_EVENTS_V0.txMaxContractEventsSizeBytes = 16384 on mainnet, read 2026-11-12 at ledger 65441861
typedef opaque SCBytes<>; typedef string SCString<> = unbounded
SCBytes<> and SCString<> have no XDR bound. A byte string lives in an envelope (at most txMaxSizeBytes = 132096), a contract data entry (at most 65536) or an event or return value (at most 16384 in total), so max(132096, 65536, 16384) = 132096. SCString is not required to be UTF-8; store it as bytes or validate before converting.
scval.symbol.lenScSymbol length (SCV_SYMBOL, function names, event topics).32 bytesSCSYMBOL_LIMIT = 32Typedef string SCSymbol<SCSYMBOL_LIMIT> with SCSYMBOL_LIMIT = 32.
auth.invocation.depthSorobanAuthorizedInvocation tree walk (subInvocations recursion) in declared auth entries.493 levelsvalidateXDRForProtocol (rustDepthLimit) = 1000
ReadXdr for SorobanAuthorizedInvocation and VecM (one with_limited_depth each) = 2 levels per invocation, measured: depthprobe: deepest single-chain invocation tree in an InvokeHostFunction auth entry that decodes at depth 1000
AuthorizedInvocation::from_xdr (recursion without a depth limiter) = no host depth check
with_frame_and_initial_mem_charge (context stack >= DEFAULT_HOST_DEPTH_LIMIT) = 100
The XDR declares subInvocations<> without a bound and the host converts the tree recursively without a depth limiter, so the only bound is the envelope depth check. Each nesting costs 2 decode levels, and a single chain in an auth entry reaches 493 levels before the envelope exceeds depth 1000 (measured). The host's call stack is capped at DEFAULT_HOST_DEPTH_LIMIT = 100 frames, so only the top 100 levels can ever match a real call; deeper nodes are declared but never authorized, and a transaction carrying them can still succeed. This is the unbounded authorization tree that halted wallet-backend ingestion.
auth.invocation.nodes
network setting
Total SorobanAuthorizedInvocation nodes across all auth entries of one transaction.2540 nodesCONFIG_SETTING_CONTRACT_BANDWIDTH_V0.txMaxSizeBytes = 132096 on mainnet, read 2026-11-12 at ledger 65441861
minimal node: ContractFn, ScAddress::Contract, empty symbol, no args, no children = 52 bytes, measured: depthprobe: encoded size difference per added node is 56 bytes with a 1-byte symbol (4 bytes of padded name), so 52 with an empty symbol
typedef SorobanAuthorizationEntry SorobanAuthorizationEntries<>; SorobanAuthorizedInvocation subInvocations<> = unbounded
Neither the entry list nor subInvocations has an XDR bound, so the tree size is bounded only by the envelope size. The smallest node is 4 (function type) + 36 (contract address) + 4 (empty symbol) + 4 (args length) + 4 (subInvocations length) = 52 bytes, so a 132096-byte envelope holds at most floor(132096 / 52) = 2540 nodes. Count nodes during the walk and stop at this cap; do not allocate per declared child before checking it.
events.contract.bytes_per_tx
network setting
Total encoded size of contract events plus the return value of one Soroban transaction.16384 bytesCONFIG_SETTING_CONTRACT_EVENTS_V0.txMaxContractEventsSizeBytes = 16384 on testnet, read 2026-11-12 at ledger 5694341
CONFIG_SETTING_CONTRACT_EVENTS_V0.txMaxContractEventsSizeBytes = 16384 on mainnet, read 2026-11-12 at ledger 65441861
InvokeHostFunctionApplyHelper (mEmitEventByte > txMaxContractEventsSizeBytes) = INVOKE_HOST_FUNCTION_RESOURCE_LIMIT_EXCEEDED
Core sums the encoded size of every contract event and then the return value, and fails the operation with INVOKE_HOST_FUNCTION_RESOURCE_LIMIT_EXCEEDED once the sum passes txMaxContractEventsSizeBytes = 16384. Diagnostic events are not counted.
events.contract.per_tx
network setting
Number of contract events (type CONTRACT or SYSTEM) in one Soroban transaction's meta.682 eventsCONFIG_SETTING_CONTRACT_EVENTS_V0.txMaxContractEventsSizeBytes = 16384 on mainnet, read 2026-11-12 at ledger 65441861
minimal ContractEvent (no contract id, no topics, void data) = 24 bytes, measured: depthprobe: ContractEvent.to_xdr()
The smallest ContractEvent encodes to 24 bytes: ext 4, absent contractID 4, type 4, body arm 4, empty topics 4, void data 4. floor(16384 / 24) = 682. Classic operation events added by CAP-67 are not counted against this setting; bound them through the operation count and offers crossed instead.
events.contract.per_ledger
network setting
Number of contract events across all Soroban transactions of one ledger.1364000 eventsCONFIG_SETTING_CONTRACT_EXECUTION_LANES.ledgerMaxTxCount = 2000 on mainnet, read 2026-11-12 at ledger 65441861
CONFIG_SETTING_CONTRACT_EXECUTION_LANES.ledgerMaxTxCount = 2000 on testnet, read 2026-11-12 at ledger 5694341
ledgerMaxTxCount is the maximum number of Soroban transactions per ledger (2000 on both networks). 2000 transactions * 682 events (events.contract.per_tx) = 1364000.
events.contract.bytes_per_ledger
network setting
Total encoded contract event and return value bytes across one ledger.32768000 bytesCONFIG_SETTING_CONTRACT_EXECUTION_LANES.ledgerMaxTxCount = 2000 on mainnet, read 2026-11-12 at ledger 65441861
CONFIG_SETTING_CONTRACT_EVENTS_V0.txMaxContractEventsSizeBytes = 16384 on mainnet, read 2026-11-12 at ledger 65441861
2000 Soroban transactions * 16384 bytes = 32768000 bytes.
soroban.footprint.entries
network setting
protocol 23 and later
read-only plus read-write footprint keys in SorobanTransactionData of one transaction.400 ledger keysCONFIG_SETTING_CONTRACT_LEDGER_COST_EXT_V0.txMaxFootprintEntries = 400 on testnet, read 2026-11-12 at ledger 5694341
CONFIG_SETTING_CONTRACT_LEDGER_COST_EXT_V0.txMaxFootprintEntries = 400 on mainnet, read 2026-11-12 at ledger 65441861
TransactionFrame::checkSorobanResources (totalReads > txMaxFootprintEntries from protocol 23) = txMaxFootprintEntries
From protocol 23 core rejects a transaction whose total footprint exceeds txMaxFootprintEntries = 400. Before protocol 23 the bound was txMaxDiskReadEntries over the whole footprint; that setting reads 200 today, and core initialised txMaxFootprintEntries from it at the protocol 23 upgrade (initialLedgerCostExtEntry). Past values of txMaxDiskReadEntries were not read for this registry, so for ledgers before protocol 23 re-derive the cap from the setting in force at that ledger.
soroban.footprint.write_entries
network setting
read-write footprint keys of one transaction (bounds ledger entry changes it can write).200 ledger keysCONFIG_SETTING_CONTRACT_LEDGER_COST_V0.txMaxWriteLedgerEntries = 200 on mainnet, read 2026-11-12 at ledger 65441861
TransactionFrame::checkSorobanResources (writeEntries.size() > txMaxWriteLedgerEntries) = txMaxWriteLedgerEntries
Core rejects a Soroban transaction with more read-write footprint keys than txMaxWriteLedgerEntries = 200.
soroban.contract_data.entry_bytes
network setting
Encoded size of one CONTRACT_DATA ledger entry in meta changes.65536 bytesCONFIG_SETTING_CONTRACT_DATA_ENTRY_SIZE_BYTES = 65536 on testnet, read 2026-11-12 at ledger 5694341
CONFIG_SETTING_CONTRACT_DATA_ENTRY_SIZE_BYTES = 65536 on mainnet, read 2026-11-12 at ledger 65441861
validateContractLedgerEntry (maxContractDataEntrySizeBytes < entrySize) = maxContractDataEntrySizeBytes
Core refuses to write a contract data entry larger than contractDataEntrySizeBytes = 65536.
soroban.contract_data.key_bytes
network setting
Encoded size of one CONTRACT_DATA ledger key (footprint keys and meta).250 bytesCONFIG_SETTING_CONTRACT_DATA_KEY_SIZE_BYTES = 250 on mainnet, read 2026-11-12 at ledger 65441861
TransactionFrame::checkSorobanResources (xdr_size(key) > maxContractDataKeySizeBytes) = maxContractDataKeySizeBytes
Core rejects a footprint key larger than contractDataKeySizeBytes = 250.
soroban.contract_code.bytes
network setting
Wasm byte length in upload envelopes and CONTRACT_CODE entries.131072 bytesCONFIG_SETTING_CONTRACT_MAX_SIZE_BYTES = 131072 on testnet, read 2026-11-12 at ledger 5694341
CONFIG_SETTING_CONTRACT_MAX_SIZE_BYTES = 131072 on mainnet, read 2026-11-12 at ledger 65441861
validateContractLedgerEntry (maxContractSizeBytes < entrySize) = maxContractSizeBytes
Core refuses Wasm larger than contractMaxSizeBytes = 131072.
soroban.txs.per_ledger
network setting
Soroban transactions in one ledger.2000 transactionsCONFIG_SETTING_CONTRACT_EXECUTION_LANES.ledgerMaxTxCount = 2000 on testnet, read 2026-11-12 at ledger 5694341
CONFIG_SETTING_CONTRACT_EXECUTION_LANES.ledgerMaxTxCount = 2000 on mainnet, read 2026-11-12 at ledger 65441861
ledgerMaxTxCount is defined in Stellar-contract-config-setting.x as the maximum number of Soroban transactions per ledger.
state_changes.per_tx.queryState change rows materialised per transaction when serving an account-history page (query time only, never at ingestion).2000 rowsmaxAccountStateChangesPerToID = 2000wallet-backend PR #757 bounds BatchGetAccountStateChangesByToIDs at 2000 rows per transaction with a ROW_NUMBER window; its comment calls the value a ceiling chosen from mainnet data. It is not a protocol bound: rows beyond it are dropped from the page, so apply it only to serving and keep every row at ingestion. The 4096 figure that circulates for this PR is the TOID operation-index range used by the operations loader (internal/data/operations.go), not a state-change cap.
claim_predicate.depthClaimPredicate recursion (and, or, not) in CreateClaimableBalance operations and ClaimableBalanceEntry.4 levelsvalidatePredicate (depth > 4 fails; called with depth 1) = 4
ClaimPredicate andPredicates<2>, orPredicates<2>, notPredicate* = 2 children
Core starts validatePredicate at depth 1 and fails CreateClaimableBalance when depth exceeds 4, so a stored predicate has at most 4 levels and, with at most 2 children per node, at most 2^4 - 1 = 15 nodes. A failed CreateClaimableBalance still carries a deeper predicate in its envelope, which is bounded only by xdr.envelope.depth.
claimants.per_balanceClaimants in CreateClaimableBalanceOp and ClaimableBalanceEntry.10 claimantsClaimableBalanceEntry claimants<10> = 10
CreateClaimableBalanceOp claimants<10> = 10
XDR bound.
path.lenIntermediate assets in PathPaymentStrictReceive and PathPaymentStrictSend.5 assetsPathPaymentStrictReceiveOp path<5>, PathPaymentStrictSendOp path<5> = 5XDR bound. The full conversion path is the send asset, up to 5 hops and the destination asset.
offers.crossed.per_op
protocol 11 and later
order-book claim atoms in one ManageOffer or path payment operation result, summed over all hops.1000 offersMAX_OFFERS_TO_CROSS = 1000
maxOffersToCross (one budget for the whole operation, decremented per hop) = 1000
convertWithOffers (maxOffersToCross == INT64_MAX before protocol version 11) = unbounded before protocol 11
From protocol 11 an operation crosses at most MAX_OFFERS_TO_CROSS = 1000 offers across all of its hops. Before protocol 11 there was no limit, so do not apply this cap to older ledgers.
tx.operationsOperations in one transaction.100 operationsMAX_OPS_PER_TX = 100Transaction and TransactionV0 declare operations<MAX_OPS_PER_TX> with MAX_OPS_PER_TX = 100. A Soroban transaction has exactly one operation.
tx.signaturesDecoratedSignature entries in one envelope signature list.20 signaturesTransactionV0Envelope, TransactionV1Envelope, FeeBumpTransactionEnvelope signatures<20> = 20XDR bound per signature list. A fee-bump envelope has its own list and the inner envelope's, so up to 2 * 20 = 40 signatures in total.
tx.extra_signersPreconditionsV2.extraSigners.2 signer keysPreconditionsV2 extraSigners<2> = 2XDR bound (CAP-21).
tx.memo_text.lenMEMO_TEXT bytes.28 bytesMemo text<28> = 28XDR bound. The bound is in bytes, not characters, and the bytes are not required to be UTF-8; store them as bytes or validate before converting to text.
signer.signed_payload.lenPayload of an ED25519_SIGNED_PAYLOAD signer key (strkey P).64 bytesSignerKey ed25519SignedPayload payload<64> = 64XDR bound (CAP-40). A signed-payload signer is therefore up to 32 + 4 + 64 = 100 bytes of key material, which does not fit a 32-byte account column.
account.signersSigners on one AccountEntry and its signerSponsoringIDs.20 signersMAX_SIGNERS = 20AccountEntry signers<MAX_SIGNERS> and AccountEntryExtensionV2 signerSponsoringIDs<MAX_SIGNERS> with MAX_SIGNERS = 20.
account.subentries
protocol 11 and later
numSubEntries of one account (trustlines, offers, data entries, signers).1000 subentriesACCOUNT_SUBENTRY_LIMIT = 1000
numSubEntries > getAccountSubEntryLimit() - mult, from FIRST_PROTOCOL_SUPPORTING_OPERATION_LIMITS = protocol 11
From protocol 11 (FIRST_PROTOCOL_SUPPORTING_OPERATION_LIMITS) core refuses to add subentries that would take an account past ACCOUNT_SUBENTRY_LIMIT = 1000. Older ledgers had no such limit.
ledger.upgradesUpgradeType entries in StellarValue.upgrades.6 upgradesStellarValue upgrades<6>; typedef opaque UpgradeType<128> = 6 entries of at most 128 bytesXDR bound: at most 6 upgrades per ledger, each at most 128 bytes.

Participants from meta, not from the envelope

The envelope names who a transaction meant to involve. Transaction meta records which ledger entries actually changed. A participant set built from the envelope is both incomplete and too large:

  • It includes accounts that were never touched. A failed transaction keeps its envelope. Claimants of a claimable balance need not exist (CreateClaimableBalanceOpFrame::doCheckValid), footprint keys need not exist, and a declared authorization entry that no call matched was never checked (see the field table).
  • It misses accounts that were touched. The Stellar Asset Contract's trust function creates a trustline without emitting an event, and an XLM transfer through it can create an account (CAP-73). Contract calls change balances and data of accounts and contracts that no envelope field names.
  • It names the same account twice. A muxed source and its base G account are one account (toAccountID). Keeping both as participants produced the duplicate key in wallet-backend#753.
  • It treats declared intent as a fact. wallet-backend emitted contract-creation state changes from declared authorization entries whether or not the deployment ran (wallet-backend#746), and later moved Soroban participants to executed meta (commit 9538b3c).

Every change to the ledger is in one of these places (Stellar-ledger.x):

  • feeProcessing in each transaction's result meta: the fee charge, which happens even when the transaction fails.
  • txChangesBefore, the changes of each operation, and txChangesAfter in TransactionMeta.
  • postTxApplyFeeProcessing in TransactionResultMetaV1: Soroban fee refunds from protocol 23.
  • Events: contract events, and from TransactionMetaV4 (protocol 23) the per-operation events and the transaction-level fee events of CAP-67.

Read all of them. In go-stellar-sdk, LedgerTransaction.GetChanges() returns the transaction and operation changes but not the fee changes, which come from GetFeeChanges() and GetPostApplyFeeChanges() (ledger_transaction.go).

To build a transaction's participant set:

  1. Take the owner of every ledger entry that changed in the places above, keyed by base account (G), contract (C), liquidity pool (L) or claimable balance (B).
  2. Add the addresses in event topics and data, reduced to their base account or contract, with mux ids recorded separately.
  3. Keep envelope accounts that do not appear in meta, if you need them, in a separate "declared" set, and never merge the two.

Muxed and signed-payload addresses

Strkey forms

SEP-23 defines ten strkey forms. The first character is set by the top 5 bits of the version byte; the low 3 bits select the algorithm, ed25519 or SHA-256, both 0 today. The binary form is the version byte, the payload, and a 2-byte CRC16 checksum, encoded as base32 without padding.

PrefixSEP-23 typeVersion bytePayloadWhere it appears in ledger dataHow to store it
GSTRKEY_PUBKEY6 << 332-byte ed25519 public keyAccountID, the plain arm of MuxedAccount, ed25519 signer keys, account ScAddress32 key bytes with the key type
MSTRKEY_MUXED12 << 332-byte ed25519 key, then 8-byte id, most significant byte firstMuxedAccount (CAP-27, protocol 13), muxed account ScAddress (CAP-67, protocol 23)base G key, and the id as an unsigned 64-bit value in its own column
SSTRKEY_PRIVKEY18 << 332-byte ed25519 secret seeda secret key, used to signnever store; treat one in ledger-derived data as a bug
TSTRKEY_PRE_AUTH_TX19 << 332-byte transaction hashpre-authorized transaction signer keyssigner column, with the key type
XSTRKEY_HASH_X23 << 332-byte SHA-256 hashhash-x signer keyssigner column, with the key type
PSTRKEY_SIGNED_PAYLOAD15 << 332-byte ed25519 key, 4-byte payload length, 1 to 64 payload bytes, zero padding to a multiple of 4signed-payload signer keys (CAP-40, protocol 19)signer column wide enough for the whole payload
CSTRKEY_CONTRACT2 << 332-byte contract idcontract ScAddress, ContractID32 bytes with the address type
LSTRKEY_LIQUIDITY_POOL11 << 332-byte pool idPoolID, liquidity pool ScAddress (CAP-67)32 bytes with the address type
BSTRKEY_CLAIMABLE_BALANCE1 << 31-byte balance id type (0 for V0), then 32-byte hashClaimableBalanceID, claimable balance ScAddress (CAP-67)33 bytes, type byte included
WSTRKEY_MUXED_CONTRACT22 << 332-byte contract id, then 8-byte id, most significant byte firstmuxed contract ScAddress (CAP-84, protocol 30)contract C id, and the id as an unsigned 64-bit value in its own column

The XDR types behind these forms are MuxedAccount (Stellar-transaction.x), SignerKey (Stellar-types.x), PoolID and ClaimableBalanceID (Stellar-types.x) and SCAddress with its six address types (Stellar-contract.x).

SEP-23 requires decoders to reject a strkey that does not re-encode to the same string, including lengths congruent to 1, 3 or 6 mod 8, nonzero unused bits and padding characters. Some base32 libraries accept those inputs, so check them yourself or round-trip every decoded strkey.

Storing muxed addresses

Store the base account and the mux id separately. Core never validates or stores a mux id (see Fields core never checks), so the account that exists on the ledger is the G key. Key balances, trustlines and participants by the base key, and keep the id in a separate column. The id is an unsigned 64-bit value: the SEP-23 test vectors include id 9223372036854775808, above the largest signed 64-bit integer. Store it as an unsigned 64-bit integer or a decimal string. wallet-backend stores it as a decimal string so that ids above 2^63-1 round-trip (toMuxedIDValue).

Never truncate. An M or W payload is 40 bytes. Fitting it into a 32-byte column either fails the write or silently produces rows that do not decode back (wallet-backend#716). wallet-backend now reduces M and W addresses to their 32-byte base key and rejects any other payload length rather than truncating it (AddressBytea.Value).

Build strkeys from parsed fields, not from XDR bytes. XDR puts the id first: MuxedAccount has { id, ed25519 } and MuxedContract has { id, contractId }. The strkey payload puts the key first and the id last. Copying the XDR bytes into a strkey yields a valid-looking address for the wrong key.

Read the destination id of a transfer from its event. From protocol 23 classic and Stellar Asset Contract transfer and mint events carry the destination's id in a to_muxed_id field of the event data (CAP-67), and from protocol 30 a transfer to a W address carries the contract as the destination and the id in to_muxed_id (CAP-84). For a classic payment to a plain G account the same field holds the transaction memo instead, as a string, a u64 or bytes (makeClassicMemoSCVal), so check its type before treating it as an id.

Storing signed-payload signers

Store the full payload with its length. A signed-payload key is a 32-byte ed25519 key plus 1 to 64 payload bytes, up to 100 bytes of key material, so it does not fit an account column. wallet-backend stores signer keys as the version byte plus the full payload, 33 bytes for G, T and X keys and up to 101 bytes for P keys, and rejects any other key type in a signer column (SignerKeyBytea, wallet-backend#729). Two P signers over the same ed25519 key with different payloads are different signers, and both differ from the plain G signer of that key.

Do not treat a signed-payload signature as transaction-specific. It signs only the payload (see Signatures and hints), so the same signature can authorize transactions on any network.

Take signer sets from meta. A SetOptions signer, an extra signer in preconditions and a revoked signer sponsorship are requests. The account entry in meta shows the resulting signers.

Self-audit checklist

Use this list to review an indexer in any language. The first three items are the rules of indexerlint, the shapekit analyzer for Go indexers built on go-stellar-sdk, with the rule IDs and wording it prints.

  • IDX001 unvalidated-field-trusted: a transaction field that stellar-core does not validate is trusted without a check. Before you store, key, display or convert to text any field listed as not validated in the field table, validate it yourself (length, encoding, range) or confirm it against what the transaction meta and events say actually happened.
  • IDX002 missing-depth-or-size-cap: a recursive or length-prefixed xdr structure is walked or decoded without a depth or size cap. Decode external XDR with an explicit depth limit and a length limit no larger than the input buffer, carry a depth counter through every recursive walk of ScVal, auth invocation and claim predicate trees, and check each decoded length against the cap in the caps table before allocating or iterating.
  • IDX003 participants-from-envelope: participants are derived from the transaction envelope instead of meta changes or events. Build each transaction's participant set from the ledger entry changes (fee changes included) and events in its meta, and treat accounts named only in the envelope as declared intent, not participation.
  • Muxed accounts are stored as a base account plus an id. Every M address is reduced to its 32-byte G key for account identity, and its 64-bit id is kept in a separate unsigned 64-bit column or as a decimal string. No column truncates an M address to 32 bytes. Fields: tx.source_account.mux_id, op.source_account.mux_id, feebump.fee_source.mux_id, op.payment.destination.mux_id, op.path_payment.destination.mux_id, op.clawback.from.mux_id. Sources: SEP-23, wallet-backend#716.
  • Muxed contract (W) addresses are handled. From protocol 30 an ScAddress can be a muxed contract. Its contract id (C) and its 64-bit id are stored separately, and a W address anywhere in events or arguments does not fail the ledger. Sources: CAP-84, wallet-backend#792.
  • Signer keys keep their type and full payload. Signer columns store the key type with the key, and are wide enough for a signed-payload (P) key with a 64-byte payload. Signers are never stored in an account column. Fields: signer_key.signed_payload.payload, op.set_options.signer, tx.cond.extra_signers, op.revoke_sponsorship. Caps: signer.signed_payload.len. Sources: wallet-backend#729.
  • Strkeys and textual ids are decoded strictly. Strkey decoding rejects every form SEP-23 lists as invalid, and a textual mux id or offer id is accepted only in its canonical decimal form. Fields: tx.source_account.mux_id, op.manage_offer.offer_id. Sources: SEP-23, js-stellar-sdk#1785.
  • 64-bit ids stay exact. Mux ids and memo ids are unsigned 64-bit values and can exceed 2^63-1. They are not stored in signed 64-bit columns or converted to floating point numbers, and amounts are never stored as floats. Fields: tx.memo.id, tx.source_account.mux_id, op.manage_offer.offer_id, op.payment.amount.
  • Memo text is stored as bytes. MEMO_TEXT is kept as its raw bytes (at most 28). If a text form is needed, UTF-8 is validated explicitly and the raw bytes are kept alongside it. Fields: tx.memo.text. Caps: tx.memo_text.len. Sources: js-stellar-sdk#1823.
  • Ordering follows the network's rules. Liquidity pool assets are ordered by asset type, raw code bytes and raw issuer key bytes, never by strkey text. Code that sorts or checks ScMap keys uses the Soroban host's ScVal comparison, not string order and not XDR byte order. Fields: op.change_trust.line.liquidity_pool, op.invoke_host_function.invoke_contract.args. Sources: js-stellar-sdk#1766.
  • Failed transactions are not treated as effects. An envelope value is only treated as having taken effect when the transaction result and the operation result are successes. For a failed transaction only the fee changes in meta are recorded as effects. Fields: tx.signatures, op.payment.destination, op.invoke_host_function.invoke_contract.contract_address.
  • Caps are applied when decoding. Every XDR decode of external input sets an explicit depth limit and a length limit no larger than the input buffer, using the caps in the table. A decoder is never run with its limits turned off. Caps: xdr.envelope.depth, xdr.envelope.len, xdr.meta.depth, xdr.meta.len.
  • Serving limits stay at query time. Limits that protect an API, such as rows per transaction on a history page, are applied when serving. Ingestion stores every row. Caps: state_changes.per_tx.query.
  • Caps that come from network settings are re-read. Caps marked as network settings are read from the CONFIG_SETTING ledger entries the network currently uses, not hardcoded, because validators can change them by vote. Caps: xdr.envelope.len, soroban.footprint.entries, soroban.contract_data.entry_bytes, events.contract.bytes_per_tx.
  • There is a written refusal policy. For every place a decoder can throw or a value can exceed a cap, the indexer has a tested, documented choice: skip the value and count it, or halt. Nothing is dropped without being counted, and SDK upgrades are checked for new refusals. Sources: js-stellar-sdk 17.3.0 release notes, stellar-etl#467.
  • One poison transaction cannot stop ingestion. When a single transaction cannot be stored, the indexer records it in a quarantine with the error, stores the rest of the ledger, advances the cursor and alerts, instead of retrying the same ledger forever. Sources: wallet-backend#729, wallet-backend#753.
  • Soroban invocations before protocol 25 can carry a memo or a muxed source. Code that handles INVOKE_HOST_FUNCTION transactions accepts a memo and muxed source accounts in ledgers before protocol 25, where the rule was only a transaction queue policy. Fields: tx.memo.type, tx.source_account.mux_id, op.source_account.mux_id.

This page is generated from docs/guide/indexer-hardening.mdx in the repository.

On this page