- 1. Introduction
- 2. Terminology and Concepts
- 3. Flow
- 4. Converting Paths to Strands (toStrands)
- 5. Path Normalization and Strand Creation (toStrand)
- 6. Iterative Strands Evaluation (strandsFlow)
- 7. Single Strand Evaluation (strandFlow)
- 8. Validation and Error Codes
Flow is rippled's Payment Engine. It is used to evaluate payment paths and execute payments.
Note
The usual terminology is Payment Engine and Flow can be seen as an implementation of the Payment Engine. We choose to use term Flow here because Payment Engine is often colloquially used in wider context to include the wider payment system.
Flow operates on paths - potential routes for value to flow from source to destination. These paths are typically discovered by the pathfinding algorithm, though they can also be explicitly provided by users or generated for offer crossing.
Flow takes a set of paths and converts them into strands - sequences of executable steps that move value from source to destination. Flow ranks these strands by quality, with higher-quality strands prioritized.
During execution, Flow iteratively consumes liquidity from the best available strands, re-ranking them as their quality changes, until the payment amount is satisfied, all liquidity is exhausted, or an error prevents completion.
It supports both exact amount delivery and partial payments, handling all combinations of XRP, MPT and IOU transfers while respecting quality limits and spending constraints.
A strand is the concrete, executable representation of one payment route. While paths describe where value can go (accounts and order books), strands describe how to actually move value through those locations.
Each step is a unit of routing logic. They execute the operations needed to move value along a payment route. Steps transfer IOUs between accounts through trust lines, convert currencies by consuming order book liquidity, or handle XRP/MPT transfers at the payment's source or destination.
- DirectStepI - Transfers tokens between accounts via trust lines
- XRPEndpointStep - Transfers XRP to/from source or destination
- MPTEndpointStep - Transfers MPT to/from source or destination
- BookStep - Converts currencies through order books and AMM pools
Once constructed, strands have no need for path elements anymore and they represent the complete list of steps required for a payment. However, one way to understand steps is as if they were connections between path elements. For example, this is how path elements (circles) will be spanned by strand steps (diamonds):
flowchart LR
alice((Alice))
usdIssuer((USD Issuer))
book((USD/EUR<br/>Order Book))
eurIssuer((EUR Issuer))
bob((Bob))
aliceToIssuer{{DirectStepI}}
bookStep{{BookStepII}}
issuerToBob{{DirectStepI}}
alice --> aliceToIssuer
aliceToIssuer --> usdIssuer
usdIssuer --> bookStep
bookStep --> book
book -.-> eurIssuer
eurIssuer --> issuerToBob
issuerToBob --> bob
Figure: Strand steps can be thought of as a connection between path elements
Path elements (circles): Alice, USD Issuer, USD/EUR Order Book, EUR Issuer, Bob
Steps (diamonds):
- DirectStepI: Alice -> USD Issuer
- BookStepII: USD/EUR conversion through order book - the book delivers directly to EUR Issuer (shown as dotted line, not a separate step)
- DirectStepI: EUR Issuer -> Bob
Flow executes payments by converting paths into executable strands and consuming liquidity from the best-quality strands until the payment is complete. We'll illustrate the algorithm using an example: Alice wants to send up to 300 USD and Bob should receive 250 EUR.
This example uses "quality", which is effectivelly the exchange rate including transfer rates and fees. Note that quality is stored as in/out, so lower values are better, but the codebase inverts its comparison operators to make "higher quality" mean "better deal". See Quality Representation for details on how this works and the potential confusion it introduces. Composite quality is the product of qualities for all steps along the strand.
Setup:
-
Alice has 1000 USD with USD Issuer
-
Bob has trust line to EUR Issuer
-
Available liquidity:
- Path 1: USD->EUR via one order book (102 USD / 100 EUR). Quality is 1.02.
- Path 2: USD->XRP->EUR via two order books (100 USD / 100 drops and 106 drops / 100 EUR). Composite quality is 1.06.
- Path 3: USD->MPT->EUR via two order books (104 USD / 100 MPT and 100 MPT / 100 EUR). Composite quality is 1.04.
-
Path finding returns:
- Path 1:
[USD/EUR order book] - Path 2:
[USD/XRP order book, XRP/EUR order book] - Path 3:
[USD/MPT order book, MPT/EUR order book]
- Path 1:
For this example, we will assume that Flow examines only those Paths, disregarding default path.
Step 1: Path Normalization
Flow normalizes each path by adding the source and destination connections that path finding omitted.
Path finding omits these elements because its job is to discover liquidity routes through the network (which order books and intermediate accounts can convert currencies), not to construct complete executable paths. The source and destination connections are deterministic - they always follow the same pattern based on the payment parameters (source account, destination account, currencies, and issuers). Path finding searches to the effective destination (the issuer for token payments, or the destination account for XRP), not the final recipient. Flow's normalization logic adds the final hop from issuer to recipient when needed.
-
Path 1:
[USD/EUR order book]- Add source: Alice needs to send USD through USD Issuer to reach the order book
- Add destination: EUR from the order book goes through EUR Issuer to reach Bob
- Normalized:
[Alice, USD Issuer, USD/EUR book, EUR Issuer, Bob]
-
Path 2:
[USD/XRP order book, XRP/EUR order book]- Add source: Alice sends USD through USD Issuer
- Add destination: EUR goes through EUR Issuer to Bob
- Normalized:
[Alice, USD Issuer, USD/XRP book, XRP/EUR book, EUR Issuer, Bob]
-
Path 3:
[USD/MPT order book, MPT/EUR order book]- Add source: Alice sends USD through USD Issuer
- Add destination: EUR goes through EUR Issuer to Bob
- Normalized:
[Alice, USD Issuer, USD/MPT book, MPT/EUR book, EUR Issuer, Bob]
Step 2: Path to Strand Conversion
Flow converts each normalized path into a strand by creating steps that connect adjacent elements in the path.
A path is a sequence of locations (accounts and order books), while a strand is a sequence of actions (steps) that move value between those locations. Each step connects two adjacent elements:
-
DirectStep: Connects two accounts via a trust line (e.g., Alice -> USD Issuer)
-
BookStep: Connects through an order book to convert currencies (e.g., USD -> EUR)
-
XRPEndpointStep: Transfers XRP directly to/from the source or destination account)
-
MPTEndpointStep: Transfers MPT directly to/from the source or destination account
-
Normalized Path 1 -> Strand 1:
[DirectStep: Alice->USD Issuer with USD][BookStep: USD/EUR][DirectStep: EUR Issuer->Bob with EUR]
-
Normalized Path 2 -> Strand 2:
[DirectStep: Alice->USD Issuer with USD][BookStep: USD->XRP][BookStep: XRP->EUR][DirectStep: EUR Issuer->Bob with EUR]
-
Normalized Path 3 -> Strand 3:
[DirectStep: Alice->USD Issuer with USD][BookStep: USD->MPT][BookStep: MPT->EUR][DirectStep: EUR Issuer->Bob with EUR]
Step 3: Step Validation
Flow validates each step in every strand using the step's check() method to ensure it is valid. For example, that required accounts exist, no loops are present, and authorization requirements are met. Each step type has different validation requirements. See steps documentation: check implementation for details.
For this example, all steps pass validation. If any step failed validation, the entire strand would be discarded.
Step 4: Iterative Strand Evaluation
Each strand is evaluated using a two-pass method:
Payments specify a desired output amount to deliver to the destination. To determine how much input that requires, the engine must work backward from the destination through each step. But during this backward walk, a step may discover it cannot deliver the full requested amount. The steps already computed (those closer to the destination) assumed the full amount would flow and made state changes accordingly, so those must be discarded. After re-executing the bottleneck step with the reduced amount, a forward walk recalculates the actual outputs for the remaining steps downstream.
The reverse pass works backwards from destination to source, calculating how much input is needed to deliver the desired output. If any step cannot provide the requested output (becoming a "limiting step"), the pass clears the sandbox (discarding all state changes), re-executes that limiting step with clean state, and then continues the reverse pass backwards through the remaining steps (those before the limiting step) using the limiting step's actual (reduced) output.
The forward pass executes if a limiting step was found. It runs forward from the step after the limiting step to the destination, recalculating outputs based on the actual available liquidity from the limiting step. This ensures steps after the limiting step have correct values with limited input.
If the first step, during the reverse pass consumes more than how much the strand is allowed to spend, it is reexecuted in the forward direction since the limit is coming from the input side.
In each iteration, the quality of a strand is computed. The quality is presented from the point of view of user "taking". So, if taker needs to pay 90 EUR to get 100 USD, the quality is 0.9, which is better. If the taker needs to pay 110 EUR to get 100 USD, the quality is 1.1, which is worse.
Iteration 1:
- Rank strands by quality: Strand 1 (1.02) is better than Strand 3 (1.04), which is better than Strand 2 (1.06).
- Select Strand 1 (best quality for the taker) to evaluate.
- The last step will provide full liquidity - Issuer can send all 250 EUR to Bob.
- The BookStep however can only provide 100 EUR for 102 USD. This is a limiting step.
- The first step requires USD Issuer to transfer 102 USD from Alice to BookStep.
- Strand 1 delivers 100 EUR at cost of 102 USD (hit liquidity limit)
- Update: Delivered 100 EUR total, 150 EUR remaining
Iteration 2:
- Remaining strands: Strand 3 (1.04), Strand 2 (1.06)
- Rank strands by quality: Strand 3 (1.04) is better than Strand 2 (1.06).
- Select Strand 3 (best quality) to evaluate.
- The last step will provide full liquidity - Issuer can deliver the requested 150 EUR to Bob.
- The second BookStep, however, can only provide 100 EUR for 100 of its input currency - this becomes the limiting step.
- The first BookStep upstream must supply 104 USD to produce those 100 intermediary units (quality 1.04).
- Strand 3 delivers 100 EUR at a cost of 104 USD (hit liquidity limit).
- Update: Delivered 100 EUR more, 50 EUR remaining.
Iteration 3:
- Rank strands by quality: only Strand 2 remains active.
- Select Strand 2 (only strand) to evaluate.
- The last step (Direct to Bob) can deliver the full 50 EUR to Bob.
- The BookStep (XRP -> EUR) provides 50 EUR out for 53 drops in (quality 1.06). No step is limiting - the strand has sufficient capacity.
- The previous BookStep (USD -> XRP) and Direct step each supply 53 of their respective inputs to feed the path.
- Strand 2 delivers 50 EUR at a cost of 53 USD (quality 1.06).
- Update: Delivered final 50 EUR, payment complete.
All 250 EUR were delivered, for a total cost of 259 USD.
Step 5: Return Results
Flow returns:
- Result code:
tesSUCCESS - Actual amount in: 259 USD (102 + 104 + 53)
- Actual amount out: 250 EUR (100 + 100 + 50)
- Effective quality: 1.036
rippled implements the payment engine using the following structure:
flowchart LR
payment((Entrypoint))
flow[Flow]
toStrands[toStrands]
toStrand[toStrand]
strandsFlow[Iterative Strands Evaluation]
strandFlow[Strand Flow]
finishFlow[Finish Flow]
payment --> flow
flow --> toStrands
toStrands --> toStrand
toStrand --> strandsFlow
strandsFlow --> strandFlow
strandFlow --> finishFlow
Figure: Overview of sequence of steps in the Payment Engine
- Flow (
flow) is an entry point function that starts the payment engine. This function accepts the paths that will eventually become strands and be evaluated. Inrippledthis is implemented asflow()inFlow.cppin the paths module1 and in this document we refer to it asflow. It is called by one of:- Payment transaction
- OfferCreate transaction crossing
- RPC path finding endpoints
- CashCheck transaction
- XChainBridge
- toStrands is a function that converts a set of paths into strands, by calling
toStrandon each one. - toStrand converts a single path to a strand through path normalization, path to strand conversion, and step generation
- Iterative Strands Evaluation is another function called
flow(accepting a vector ofstrandsas parameter) implemented inStrandFlow.h2. In this document we refer to it asstrandsFlow. It orchestrates evaluating each strand and deciding which of them to use. - Strand Flow (another function called
flow, accepting a singlestrandas parameter) is implemented inStrandFlow.hin the paths/detail module3. In this document we refer to it asstrandFlow. It evaluates a strand using the two-pass method. - Finish Flow (
finishFlowfunction) cleans up after the execution is complete.
Step Implementations:
Each step type inherits from the Step interface through a StepImp template base class and implements methods for reverse pass calculation (revImp), forward pass calculation (fwdImp), quality estimation (qualityUpperBound), and validation (check) - see step methods documentation for details. Each step type is implemented as a base class with derived classes for payments and offer crossing. Payment variants enforce trust line limits and the offer owner pays transfer fees. Offer crossing variants waive trust line limits for the destination step and the taker pays transfer fees.
Strand - A sequence of steps that represents an executable payment route. See section 1.1.
Step - An atomic operation that transfers or converts value between two adjacent path elements. See section 1.1.
Path - A sequence of path elements (accounts and order books) discovered by path finding that describes a potential route for value to flow.
Path Element - A building block in a path.
Path Normalization - The process of adding source and destination connections to paths returned by path finding. See section 5.1.
Limiting Step - A step that restricts how much value can flow through a strand due to constraints like insufficient trust line credit, limited order book liquidity, or low account balance.
Default Path - The direct route between source and destination that Flow always attempts (unless disabled by tfNoRippleDirect flag). See path finding: default paths.
Partial Payment - A payment mode where delivering less than the full requested amount is acceptable. Enabled by the tfPartialPayment flag on Payment transactions.
Offer Crossing - The process by which an OfferCreate transaction consumes existing offers from the order book, using the Flow engine to execute the exchange.
Quality represents the exchange rate for a payment step or strand, calculated as the ratio of input amount to output amount. Lower quality values are better, meaning less input is required to produce the same output. For example, a quality of 1.05 means 105 units of input produce 100 units of output. A quality of exactly 1.0 (represented in the codebase as the constant QUALITY_ONE = 1,000,000,000) means parity: one unit of input per unit of output.
Note
Quality Representation in the Codebase: Quality is stored as the ratio of input to output (in/out).4 A quality value of 1.02 means the taker pays 1.02 units per unit received. Lower quality values represent better deals.
This creates a potential source of confusion: colloquially, "higher quality" means a better deal, but a better deal corresponds to a lower quality value. The codebase resolves this by inverting the comparison operators in the Quality class5. Comparing two qualities returns true for "greater than" when the left side has a lower stored value, because a lower ratio represents a better deal. Similarly, the increment operator advances to the next higher quality by decreasing the stored value, and the decrement operator does the opposite.6
Throughout this document, "better quality" and "higher quality" mean a lower in/out ratio (less input per unit of output).
Quality is determined by the following factors:
Order book offer exchange rates: The ratio at which offers in the order book exchange currencies. An offer of takerGets = 105 USD and takerPays = 100 EUR provides quality of ~0.95. Different offers have different exchange rates, and the order book is sorted by quality with the best offers consumed first. CLOB (Central Limit Order Book) offers have fixed quality that doesn't change as liquidity is consumed.
AMM pool exchange rates: Unlike CLOB offers, AMM pools have dynamic quality that degrades as more liquidity is consumed from the pool. For example, consuming 10 tokens might have quality of 1.00, but consuming 100 tokens from the same pool might degrade to quality of 1.05 due to the slippage curve. This dynamic quality requires special handling in the payment engine - see limitOut for how the engine optimizes single-path AMM payments with quality limits. AMM pools also charge trading fees that further affect quality.
Transfer fees: Fees set on an issuer's account (via the TransferRate field) that are charged when value flows through their account as an intermediary in a trust line payment. When an issuer has a 2% transfer fee, moving 100 units of their issued currency through them as an intermediary requires 102 units of input, degrading quality by the fee percentage. Transfer fees do NOT apply to direct issuer<->holder transfers, only to holder<->holder transfers that pass through the issuer. For MPTs, transfer fees are set on the MPT issuance itself. See DirectStepI quality and MPTEndpointStep quality for implementation details.
QualityIn and QualityOut: Trust line settings that adjust the effective value of tokens when rippling through an account. Both are stored as ratios relative to QUALITY_ONE (1,000,000,000); a value of 0 or QUALITY_ONE means face value (no adjustment). QualityIn controls how the destination values incoming tokens: a value below QUALITY_ONE (e.g. 990,000,000, ratio 0.99) means each token is counted at less than face value (here, 99%), so more tokens must be sent to deliver the same effective value downstream. QualityOut controls the cost of sending: a value above QUALITY_ONE (e.g. 1,010,000,000, ratio 1.01) means 1% more tokens must be sent per unit of value. Together, these settings allow intermediary accounts to charge a spread when value ripples through them. QualityIn applies only when the source of the step is issuing, and QualityOut applies only when the source is redeeming. Additionally, QualityIn is clamped to QUALITY_ONE on the last step of a strand, so the final recipient does not benefit from a favorable QualityIn setting. See DirectStepI quality implementation for details.
When the source of a DirectStepI is redeeming, the engine ensures that quality does not improve as value flows through an intermediate account. If the previous step's destination has a QualityIn that exceeds the current step's source QualityOut, the source QualityOut is raised to match.7 To see why this is necessary, consider a USD payment path Alice -> Bob -> Charlie, where Charlie is the issuer. The Bob -> Charlie DirectStep is redeeming. Bob has the following trust line settings:
- Bob's trust line from Alice has QualityIn = 1.05
- Bob's trust line to Charlie has QualityOut = 1.02
The composed rate through Bob is out = in × QualityIn / QualityOut. Without anti-improvement, if Alice sends 100 USD into Bob: out = 100 × 1.05 / 1.02 = 102.94 USD, value increases just by flowing through Bob.
With the anti-improvement check, because the Bob -> Charlie step is redeeming, the engine enforces QualityOut >= previous step's QualityIn. QualityOut is raised to 1.05: out = 100 × 1.05 / 1.05 = 100 USD. Value does not improve as it passes through Bob.
Each step type calculates quality differently:
| Step Type | Payment Quality Factors | Offer Crossing Quality Factors |
|---|---|---|
| XRPEndpointStep | Always QUALITY_ONE (1:1)8 |
Same9 |
| MPTEndpointStep | Transfer rate only (applies only when issuing and previous step redeems)10 | Always QUALITY_ONE: during offer crossing, the previous step is always a BookStep that issues11, so the transfer rate condition is never met |
| DirectStepI | Trust line QualityIn/QualityOut12 + issuer transfer rate (transfer rate applies only when issuing and previous step redeems)13 | Always QUALITY_ONE: trust line quality settings are ignored14 and the previous step is always a BookStep that issues15, so the transfer rate condition is also never met |
| BookStep | Order book exchange rate + input transfer fee (when previous step redeems)16. Output transfer fee does not apply for payments (ownerPaysTransferFee is false)17 |
CLOB and multi-path AMM: no transfer fee adjustment18. Single-path AMM (requires fixAMMv1_1): input transfer fee only (when previous step redeems)19 |
See the steps documentation for detailed quality calculations.
The engine uses quality in two phases. Before executing a strand, it computes an estimated quality upper bound (the product of step quality estimates) to sort strands from best to worst and to filter out strands that fall below the limitQuality threshold. After executing a strand, it computes the actual quality from the execution results (actual input / actual output) and checks it against limitQuality again. The estimate and actual quality can differ because offers may be unfunded, balances may have changed, or rounding may have occurred. See qualityUpperBound for details on strand sorting and filtering.
Every step must know whether its source account is redeeming or issuing relative to its destination.
Debt direction controls which quality adjustments and fees apply to a step. The same step between the same two accounts can produce different exchange rates depending on which direction the debt is moving. Transfer fees, QualityIn/QualityOut, and the anti-improvement constraint each apply only in one debt direction, not the other.
A step also needs to know the previous step's debt direction. Certain fees are charged when the current step is issuing but the previous step was redeeming. Each step therefore propagates its debt direction to the next step via qualityUpperBound and the revImp/fwdImp methods.
See step quality implementation for how each factor is conditionally applied based on debt direction and how debt direction is determined.
The flow function1 is the entry point to the payment execution engine. It accepts the following parameters:
Note
Parameter names and definitions are simplified to provide an overview. They do not map 1:1 to the C++ implementation, but are intended to make the pseudocode in later sections easier to follow.
| Parameter | Description | Required |
|---|---|---|
sb |
PaymentSandbox - Ledger state view for reading balances and trust lines | ✅ |
deliver |
Amount to deliver to destination | ✅ |
src |
Source account | ✅ |
dst |
Destination account | ✅ |
paths |
User-provided paths to evaluate | ✅ (but can be empty) |
defaultPaths |
Whether to add default direct path | ✅ |
partialPayment |
Allow partial delivery | ✅ |
ownerPaysTransferFee |
Whether offer owner pays transfer fees. For regular payments this is false, for offer crossings this is true. | ✅ |
offerCrossing |
Enum: No (if regular payment), Yes (if regular offer crossing), Sell (if Sell offer) | ✅ |
limitQuality |
Worst acceptable quality threshold | ❌ |
sendMax |
Maximum amount sender willing to spend | ❌ |
domainID |
Domain for permissioned DEX | ❌ |
The flow function orchestrates payment execution by converting paths into strands and evaluating them to find the best liquidity. It executes the following operations in order:
- Creates an
AMMContextobject initialized withmultiPath = false - Calls
toStrands()to convert the provided paths (and optionally the default path) into executable strands - Sets
ammContext.multiPath = trueifstrands.size() > 1(more than one strand exists) - Calls
strandsFlow()to iteratively evaluate strands, consuming liquidity from the best-quality strands until the payment is satisfied or all liquidity is exhausted - Calls
finishFlow()to package the results and clean up state - Returns the transaction result code, actual amount in, actual amount out, and any offers to remove
def flow(
sb: PaymentSandbox,
deliver: STAmount,
src: AccountID,
dst: AccountID,
paths: STPathSet,
defaultPaths: bool,
partialPayment: bool,
ownerPaysTransferFee: bool,
offerCrossing: OfferCrossing,
limitQuality: Optional[Quality],
sendMax: Optional[STAmount],
domainID: Optional[DomainID]
) -> TER, Optional[actualIn], Optional[actualOut], offersToRemove:
# Create AMM context to track AMM-specific state throughout payment execution
ammContext = AMMContext(multiPath=false)
# Convert paths into executable strands
result = toStrands(
sb,
src,
dst,
deliver,
limitQuality,
sendMax,
paths,
defaultPaths,
ownerPaysTransferFee,
offerCrossing,
ammContext,
domainID
)
if result.ter != tesSUCCESS:
return result.ter, None, None, []
strands = result.strands
# Update AMM context if multiple strands exist (affects AMM offer sizing strategy)
ammContext.setMultiPath(len(strands) > 1)
# Evaluate strands to consume liquidity
flowResult = strandsFlow(
baseView,
strands,
deliver,
partialPayment,
offerCrossing,
limitQuality,
sendMax,
ammContext
)
# Package results and clean up state
finishFlow(baseView, flowResult)
return flowResult.ter, flowResult.actualIn, flowResult.actualOut, flowResult.offersToRemoveThe AMMContext class tracks AMM-specific state throughout payment execution to manage how AMM synthetic offers are sized and consumed across iterations. Unlike CLOB offers which are consumed and removed, AMM pools generate synthetic offers that can be repeatedly consumed in multiple iterations. The context coordinates this by tracking whether multiple paths exist (affecting offer sizing strategy) and limiting the number of iterations where AMM offers can be consumed (preventing excessive iteration).
A single instance is created at the start of the flow() function and passed to all subsequent functions, allowing BookSteps to query the context and adjust their AMM offer generation strategy accordingly. See BookStep implementation for details on how BookSteps use this context.
Key fields:
| Field | Type | Description |
|---|---|---|
account_ |
AccountID | Transaction sender account |
multiPath_ |
bool | Whether payment has multiple strands (set to true if strands.size() > 1) |
ammUsed_ |
bool | Whether AMM offer was consumed in current iteration |
ammIters_ |
uint16 | Counter of iterations where AMM was consumed (max 30) |
Multi-path detection:
The multiPath_ flag is set based on the number of strands after toStrands() completes:
ammContext.setMultiPath(strands.size() > 1);This flag determines which of the two AMM offer sizing strategies is used. See BookStep: Offer Generation Strategies for details.
AMM offers can be consumed in at most 30 iterations (MaxIterations = 30). The ammIters_ counter increments each time an AMM offer is consumed. Once ammIters_ >= 30, maxItersReached() returns true and no more AMM offers are generated.
When a payment or offer crossing includes a domainID parameter, the Flow engine restricts liquidity consumption to the specified permissioned domain's order book. The domainID parameter is provided when:
- A Payment transaction specifies the
DomainIDfield - An OfferCreate transaction specifies the
DomainIDfield and crosses existing offers
Access Verification: Before the Flow engine executes, domain access is verified during transaction preclaim. For Payment transactions, both the sender (Account) and receiver (Destination) must be "in domain"20 - either the domain owner or hold a valid accepted credential that matches one of the domain's AcceptedCredentials entries. For OfferCreate transactions, the offer creator needs to be in domain.21 If verification fails, the transaction fails with tecNO_PERMISSION before Flow begins execution.
Domain membership is verified using the accountInDomain() function, which:
- Checks if the account is the domain owner (immediate access)
- Otherwise, iterates through the domain's AcceptedCredentials array (max 10 entries) looking for a matching credential where:
- Subject = account
- Issuer and CredentialType match an entry in AcceptedCredentials
- lsfAccepted flag is set
- Credential is not expired (checked against parentCloseTime)
See Domain Access Control for implementation details.
This verification ensures that when Flow executes with domainID, all participants have already been authorized.
Once domain access is verified and Flow begins execution, it has two key implications:
Order Book Isolation: BookSteps are constructed with the domain ID, which affects order book directory lookup. The book directory hash includes the domain ID: hash(BOOK_NAMESPACE, asset_in, asset_out, domainID). This ensures that only offers within the specified domain can be discovered and consumed.
Domain payments and offer crossing cannot consume AMM liquidity. When domainID is specified, BookSteps do not generate AMM offers.
For example:
- Domain Payment or Offer Crossing (domainID set):
- Flow engine creates BookSteps with domainID
- BookSteps look up domain-specific order book directories
- Only domain offers and hybrid offers (in domain book) can be consumed
- AMM is NOT accessible
- Open Payment or Offer Crossing (domainID not set):
- Flow engine creates BookSteps without domainID
- BookSteps look up standard order book directories
- Open offers, hybrid offers (in open book), and AMM can be consumed
See PermissionedDomains documentation for details on domain creation and access control.
toStrands22 converts a collection of paths into executable strands. The inputs are almost the same as in flow.
This is a planning phase of a payment, so toStrands does not care about the delivery amount and only works on ReadView ledger view. It is only concerned with the asset that needs to be delivered, which is what flow passes to it. The amount doesn't matter because toStrands only converts paths to strands and filters out invalid ones - it doesn't calculate capacity. The actual capacity calculation ("how much can this strand deliver?") happens later in strandsFlow and strandFlow.
toStrands creates strand for the default path (unless a flag that prevents it was added) and user-provided paths. If the strand is malformed, which amounts to unexpected behavior, toStrands immediately returns.
If the strand fails for a legitimate reason, for example if it is dry (no liquidity), it is simply skipped and the next path is evaluated.
Otherwise, the strand is added to a collection storing strands.
If there are any valid strands, they are returned. If there are no valid strands, the function returns the reason why the last strand has failed.
Please note that the following diagram simplifies the function. rippled implementation gives special consideration to the default path and then loops over user-provided paths. This does not change functionality from what is described here.
sequenceDiagram
participant Caller as Flow
participant toStrands
participant Strands as strands[]
participant toStrand
Caller->>toStrands: Call
toStrands->>Strands: Initialize empty collection
alt defaultPath is true
toStrands->>Strands: Add default path
end
alt paths are provided
toStrands->>Strands: Add user-provided paths
end
Strands-->>toStrands: Collection with strands
alt strands are empty
toStrands-->Caller: Return temRIPPLE_EMPTY
end
loop For each path in paths
toStrands->>toStrand: Convert path to strand
toStrand-->>toStrands: Return result
alt result.ter == tesSUCCESS
toStrands->>Strands: Add strand
else isMalformedTer(result.ter)
toStrands-->>Caller: Return error immediately
else isAnotherError(result.ter)
toStrands->>toStrands: Track error, continue
end
end
Strands-->>toStrands: Collection with strands
alt Has valid strands
toStrands-->>Caller: Return SUCCESS + strands
else No valid strands
toStrands-->>Caller: Return last error
end
toStrand23 first normalizes the path and then converts a single path into a strand (sequence of steps).
Paths and Path Elements:
A path is a sequence of path elements that describe a route through which value can flow. For complete details on path element structure and types, see Path Elements.
toStrand's inputs are similar to toStrands, except that instead of passing in multiple paths and defaultPath boolean, toStrands provides only one path to toStrand.
| Parameter | Description | Required |
|---|---|---|
view |
Read-only view of the ledger state, provides access to account balances, trust lines, and offers | ✅ |
src |
Source account ID | ✅ |
dst |
Destination account ID | ✅ |
deliver |
The asset that should be delivered to the destination | ✅ |
limitQuality |
The worst acceptable quality from a single strand | ❌ |
sendMaxAsset |
Maximum user is willing to spend - specifies the asset being spent (if different from deliver) | ❌ |
path |
User-provided payment path - sequence of accounts and/or currency/issuer pairs to route through | ✅ (but can be empty) |
ownerPaysTransferFee |
If true, the offer owner pays transfer fees; if false, the taker pays | ✅ |
offerCrossing |
Enum indicating if this is offer crossing (no/yes/sell) | ✅ |
ammContext |
Context for Automated Market Maker (AMM) related operations | ❌ |
domainID |
Domain identifier for the transaction | ❌ |
Before normalization begins, the payment parameters are validated24 to ensure source and destination accounts are valid, assets are consistent, and issuers are properly specified. Then each path element is validated to ensure it is well-formed25. This validation checks that path element field combinations are valid, asset types are used correctly, and MPT-specific constraints are satisfied (MPTs have restrictions on what path elements can follow them since they don't support rippling).
After validation, toStrand normalizes the path26 before converting path elements to strand steps.
The path is composed of path elements, and normalizing a path means that path elements that are not explicitly defined but are necessary are added to the path.
The normalization process constructs a complete path by adding implied elements in the following order:
-
Add source element27:
- Type:
account | issuer | currency(for Issue) oraccount | issuer | MPT(for MPTIssue) - Account:
src - Currency/MPT:
sendMaxif specified, otherwisedeliver - Issuer:
- For Issue:
src - For MPTIssue: actual MPT issuer from MPTID
- For Issue:
- Type:
-
Add SendMax issuer28:
- Type:
account - Account:
sendMax.issuer - Condition:
sendMaxis specified ANDsendMax.issuerdiffers fromsrcAND the user-provided path does not already start withsendMax.issueras its first account element. This prevents duplicate path elements when users explicitly include the sendMax issuer as the first step in their path.
- Type:
-
Add user-provided path elements29 (if any exist)
-
Add delivery currency/MPT conversion30:
- Type:
offer(currency/MPT + issuer) - Currency/MPT:
deliver - Issuer:
deliver.issuer(for Issue) or MPT issuer from MPTID (for MPTIssue) - Condition: The last asset in the normalized path differs from
deliverOR (offerCrossingis true AND the last issuer differs fromdeliver.issuer)
- Type:
-
Add delivery issuer account31:
- Type:
account - Account:
deliver.issuer(extracted from Issue.account for Issue, or from MPTID for MPTIssue) - Condition: The last element in normPath is NOT an account with
deliver.issuerANDdstdiffers fromdeliver.issuer
- Type:
-
Add destination account32:
- Type:
account - Account:
dst - Condition: The last element in normPath is NOT an account with
dst
- Type:
Take for an example a payment in which Alice wants to deliver 100 MPT to Bob and spend USD (IOU) issued by Issuer for it.
The default path (empty before normalization) will be normalized to:
- Type=0x31: Account: Alice, currency: USD, issuer: Alice
- Type=0x01: Account: Issuer
- Type=0x60: Order book: USD/MPT
- Type=0x01: Account: Issuer
- Type=0x01: Account: Bob
Here are some example normalization scenarios:
| Scenario | Path from Path Finding / OfferCreate | Normalized Path Structure |
|---|---|---|
| Issuing IOU | [] |
[Issuer (0x31, currency set to IOU and issuer to self), Alice (0x01)] |
| IOU to IOU | [] |
[Alice (0x31, with currency set to IOU and issuer to self), Issuer (0x01), Bob (0x01)] |
| XRP to Token | [XRP/USD offer] |
[Alice (0x31, with issuer set to 0 and currency to XRP), XRP/USD order book (0x30), USD Issuer (0x01), Bob (0x01)] |
| IOU to XRP | [USD/XRP offer] |
[Alice (0x31), USD Issuer, USD/XRP offer (0x30), XRP (0x01, account 0), Bob (0x01)] |
| IOU to MPT | [USD/MPT offer] |
[Alice (0x31), USD Issuer, USD/MPT offer (0x60), MPT issuer (0x01), Bob (0x01)] |
| Issuing MPT | [] |
[Issuer (0x61), Alice (0x01)] |
The conversion process iterates through adjacent pairs of path elements in the normalized path, generating executable steps33. Path elements do not map one-to-one to steps; a single pair may generate multiple steps when intermediate issuer hops are required.
Core Algorithm:
- Initialize current asset (
curAsset)34:- If sending XRP: Set to XRP
- For IOUs: Set to
Asset(currency, src) - For MPTs: Set to the MPTIssue
- Iterate through path element pairs (current and next)35 and for each adjacent pair of path elements, the algorithm:
- Tracks the current asset being transferred by updating
curAssetbased on the current element's fields - Injects implied intermediate steps when necessary
- Calls
toStep()to generate the primary step that transfers value between the pair - Accumulates all generated steps into the final strand
- Tracks the current asset being transferred by updating
Implied Step Injection:
In certain cases, additional steps must be inserted instead of calling toStep():
- Offer -> Account (XRP output)36: When an offer outputs XRP and the next element is an account: if this is the last pair, insert
XRPEndpointStep(next); otherwise returntemBAD_PATHsince XRP can only appear at strand endpoints - Offer -> Account (IOU output, next is not issuer)37: When an offer outputs an IOU and the next account is not the issuer, insert
DirectStepI(issuer -> next)and skiptoStep()call
The validation context (StrandContext)38 serves two purposes during strand construction:
-
Provides execution parameters: Carries the ledger view, source/destination accounts, quality limits, flags (ownerPaysTransferFee, offerCrossing, etc.), AMM context, and strandDeliver domain needed for step construction and validation
-
Detects invalid loops: Tracks which assets have been used39 to prevent the same account from appearing multiple times in the same asset and role within a single strand
The context is created at the start of path-to-strand conversion and passed to each step's check() method during validation.
Loop Detection: The context tracks assets in two collections to prevent counting the same liquidity twice:
seenDirectAssets: A two-element array of asset sets that tracks assets used in direct transfers and endpoint steps:
seenDirectAssets[0]: Tracks assets where the account acts as the source of the asset within a step. For DirectStepI transferring from Alice to Bob, this records the (currency, Alice) pair since Alice is the source account in that stepseenDirectAssets[1]: Tracks assets where the account acts as the destination of the asset within a step. For the same DirectStepI, this records the (currency, Bob) pair since Bob is the destination account in that step
This two-index separation allows the same asset to appear legitimately in both roles within a single strand. Consider a holder-to-holder MPT transfer where Alice sends MPT to Bob:
Alice (holder) -> Issuer (destination of first step) -> Issuer (source of second step) -> Bob (holder)
The issuer appears twice but in different roles:
- First MPTEndpointStep: Issuer is the destination (Alice redeems MPT to issuer, decreasing OutstandingAmount) - recorded in
seenDirectAssets[1] - Last MPTEndpointStep: Issuer is the source (issuer issues MPT to Bob, increasing OutstandingAmount) - recorded in
seenDirectAssets[0]
This is valid because the issuer acts as an intermediary, receiving tokens in one step and sending them in another. The two indices prevent invalid loops (issuer as source twice, or destination twice) while allowing valid intermediary routing.
seenBookOuts: A single asset set tracking output assets from order book conversions (BookStep). Each BookStep records its output asset to ensure the same order book doesn't appear multiple times in a strand.
When a step's check() method validates, it verifies its asset hasn't been seen before in the appropriate context. If the asset was already present (.insert().second returns false), validation fails with temBAD_PATH_LOOP.
After path normalization and the main conversion loop (which handles implied steps), the toStep function40 is called for each adjacent pair of path elements to create the appropriate step type. This function acts as a factory that examines the characteristics of the two elements in the pair and selects the correct step implementation.
The choice of step type depends on three key factors:
- Position in strand: Whether the strand has no steps yet (
ctx.isFirst, set viastrand.empty()41) or this is the last element pair in the normalized path (ctx.isLast), which affects how XRP is handled - Currency types: Whether the currencies involved are XRP, Token (issued currency), or MPT (multi-purpose token)
- Element types: Whether each element in the pair is an Account or an Order Book
Step selection rules:
When XRP appears at the beginning or end of a path, XRPEndpointStep is created to directly credit or debit the account's XRP balance. In the middle of a path, XRP flows through order books using regular BookStep variants.
Account-to-account pairs (both elements in the pair are accounts) create DirectStepI when curAsset is an IOU. When curAsset is an MPT, it creates MPTEndpointStep. Unlike trust lines, which can appear in the middle of the strand, MPTs do not support rippling through intermediary accounts, so they have to be either at the start or the end of the strand and the BookStep is directly connected to MPTEndpointStep.
When the second element in the pair is an order book, toStep creates BookStep variants based on the input and output currency types. There are multiple BookStep combinations covering all pairings of XRP, IOU, and MPT (except XRP->XRP, which is invalid and returns temBAD_PATH).
Step selection rules are:
- First element in the pair is an account with XRP currency (and this is the first pair in the path) ->
XRPEndpointStep(source)42 - First element in the pair is the XRP account (account ID 0) and this is the last pair in the path ->
XRPEndpointStep(destination)43 - Both elements in the pair are accounts44:
- First element in the pair is an order book, second element is an account -> UNREACHABLE (should be handled by implied step injection logic)47
- Second element in the pair is an order book ->
BookStepvariant48:
Example: IOU to different IOU Payment with MPT bridge
For example, Alice sends USD and the payment is converted through two order books (USD to MPT, then MPT to EUR) before Bob receives EUR. The normalized path would look like:
[0] Type=0x31: Account=Alice, Currency=USD, Issuer=Alice
[1] Type=0x01: Account=Issuer
[2] Type=0x60: Issuer=Issuer, MPT=000005FF73F0 (order book element)
[3] Type=0x30: Currency=EUR, Issuer=Issuer (order book element)
[4] Type=0x01: Account=Issuer
[5] Type=0x01: Account=Bob
toStrand iterates over each cur -> next pair. Initially: curAsset = Issue(USD, Alice).
Pair [0]->[1]: Account(Alice, USD, Alice) -> Account(Issuer)
- Update curAsset: element [0] is Account with Currency = USD ->
curAsset = Alice/USD - Call
toStep([0], [1], Issue(USD, Alice)) - Creates:
DirectStepI(Alice -> Issuer)
Pair [1]->[2]: Account(Issuer) -> Offer(Issuer, MPT)
- Update curAsset: element [1] is Account -> issuer becomes Issuer ->
curAsset = Issuer/USD - Call
toStep([1], [2], Issue(USD, Issuer)) - Creates:
BookStepIM(USD/Issuer -> MPT)
Pair [2]->[3]: Offer(Issuer, MPT) -> Offer(EUR, Issuer)
- Update curAsset: element [2] has MPT ->
curAsset = 000005FF73F0/MPT - Call
toStep([2], [3], MPT(000005FF73F0)) - Creates:
BookStepMI(MPT -> EUR/Issuer)
Pair [3]->[4]: Offer(EUR, Issuer) -> Account(Issuer)
- Update curAsset: element [3] is Offer with Currency(EUR) -> MPT->IOU transition ->
curAsset = Issuer/EUR - Offer->Account case: output issuer (Issuer) equals account (Issuer) -> no implied step needed
- No step created (continue, skip toStep)
Pair [4]->[5]: Account(Issuer) -> Account(Bob)
- Update curAsset: element [4] is Account ->
curAsset = Issuer/EUR(unchanged) - Call
toStep([4], [5], Issue(EUR, Issuer)) - Creates:
DirectStepI(Issuer -> Bob)
Resulting Strand:
- DirectStepI: Alice -> Issuer (USD)
- BookStepIM: USD/Issuer -> MPT
- BookStepMI: MPT -> EUR/Issuer
- DirectStepI: Issuer -> Bob (EUR)
Example: MPT to Different MPT Payment with MPT bridge
For example, Alice wants to send MPT/B to Bob and pay for it using MPT/A. There is an MPT/A / MPT/B offer in the order book. The normalized path looks like:
[0] Type=0x61: Account: Alice, Issuer: Issuer, MPT: MPT/A
[1] Type=0x01: Account: Issuer
[2] Type=0x60: Issuer: Issuer, MPT: MPT/B (order book element)
[3] Type=0x01: Account: Issuer
[4] Type=0x01: Account: Bob
toStrand iterates over each cur -> next pair. Initially: curAsset = MPTIssue(MPT/A).
Pair [0]->[1]: Account(Alice, MPT/A, Issuer) -> Account(Issuer)
- Update curAsset: element [0] is Account with MPT = MPT/A ->
curAsset = MPT/A - Call
toStep([0], [1], MPTIssue(MPT/A)) - Creates:
MPTEndpointStep(Alice -> Issuer)
Pair [1]->[2]: Account(Issuer) -> Offer(Issuer, MPT/B)
- Update curAsset: element [1] is Account ->
curAsset = MPT/A(unchanged) - Call
toStep([1], [2], MPTIssue(MPT/A)) - Creates:
BookStepMM(MPT/A -> MPT/B)
Pair [2]->[3]: Offer(Issuer, MPT/B) -> Account(Issuer)
- Update curAsset: element [2] has MPT = MPT/B ->
curAsset = MPT/B - Offer->Account case: output issuer (Issuer) equals account (Issuer) -> no implied step needed
- No step created (continue, skip toStep)
Pair [3]->[4]: Account(Issuer) -> Account(Bob)
- Update curAsset: element [3] is Account ->
curAsset = MPT/B(unchanged) - Call
toStep([3], [4], MPTIssue(MPT/B)) - Creates:
MPTEndpointStep(Issuer -> Bob)
Resulting Strand:
- MPTEndpointStep: Alice -> Issuer (MPT/A)
- BookStepMM: MPT/A -> MPT/B
- MPTEndpointStep: Issuer -> Bob (MPT/B)
Asset Representation
During path-to-strand conversion, the algorithm tracks the current asset flowing through the path using curAsset, which is a variant that can hold either:
-
Issue: Represents XRP or IOU (issued currency). Has two separate fields:
currency: The IOU currency code (or special XRP currency code)account: The issuer's account ID (for IOUs) or special zero account (for XRP)
These fields are independently mutable - a path element might update just the currency, just the issuer, or both.
-
MPTIssue: Represents MPT (multi-purpose token). Has a single field:
mptID: The MPT identifier with the issuer embedded within it
The issuer is immutable once the MPT is created, so the entire
mptIDmust be replaced when switching between different MPTs.
# For toStrand parameters, see table at the beginning of section 5
def toStrand(...):
...
Validation
Path normalization
...
result: List[Step] = []
# Initialize loop detection tracking (see section 5.2.1)
seenDirectAssets = [set(), set()] # [source assets, destination assets]
seenBookOuts = set()
# Context is recreated each iteration but holds references to shared state:
# - seenDirectAssets, seenBookOuts, ammContext are passed by reference (shared/reused)
# - Other parameters (view, deliver, limitQuality, etc.) passed from toStrand arguments
# - isLast varies per iteration (true for last pair, false otherwise)
# See section 5.2.1 for details on StrandContext
def ctx(isLast=False):
return StrandContext(
view, result, strandSrc, strandDst, deliver, limitQuality,
isLast, ownerPaysTransferFee, offerCrossing, isDefaultPath,
seenDirectAssets, seenBookOuts, ammContext, domainID)
# Pick sendMaxAsset if specified, otherwise deliver
asset = sendMaxAsset if sendMaxAsset else deliver
if asset is MPTIssue:
curAsset = asset # MPT: use as-is
elif isXRP(asset):
curAsset = xrpIssue() # XRP: special XRP issue
else:
curAsset = Issue(asset.currency, src) # IOU: currency with src as issuer
# Iterate over pairs in normPath, cur is current, next is following path element
for i in range(len(normPath) - 1):
cur = normPath[i]
next = normPath[i + 1]
# Implied Path Element
impliedPe = None
# Switch from MPT to Currency if needed
if curAsset is MPTIssue and cur.hasCurrency():
curAsset = Issue()
# Update curAsset account (only for Issue, not MPTIssue)
if curAsset is Issue:
if cur.isAccount():
curAsset.account = cur.getAccountId()
elif cur.hasIssuer():
curAsset.account = cur.getIssuerId()
# Update curAsset currency/MPT
if cur.hasCurrency():
curAsset.currency = cur.getCurrency()
if isXRP(curAsset.currency):
curAsset.account = xrpAccount()
elif cur.hasMPT():
curAsset = MPTIssue(cur.getMPTID())
if cur.isAccount() and next.isAccount():
# NOTE: rippled developers identified this block never executes because
# curAsset.issuer is always set to cur.getAccountID() above, making the
# first condition always false. For MPT, rippling is invalid.
if not isXRP(curAsset) and curAsset.issuer != cur.getAccountID() and curAsset.issuer != next.getAccountID():
result.add(DirectStepI(cur.getAccountId() -> curAsset.issuer))
impliedPe = STPathElement(typeAccount, curAsset.issuer)
cur = impliedPE
elif cur.isAccount() and next.isOffer():
# NOTE: rippled developers identified this block never executes because
# curAsset.issuer is always set to cur.getAccountID() above, making the
# condition always false.
if curAsset.issuer != cur.getAccountId():
result.add(DirectStepI(cur.getAccountId() -> curAsset.issuer))
impliedPe = STPathElement(typeAccount, curAsset.issuer)
cur = impliedPe
elif cur.isOffer() and next.isAccount():
# If offer outputs to account that's not the issuer
if curAsset.issuer != next.getAccountId() and not isXRP(next.getAccountID()):
if isXRP(curAsset):
# Last step: insert XRP endpoint step
result.add(XRPEndpointStep(next.getAccountId()))
else:
# Insert DirectStepI from issuer to destination
result.add(DirectStepI(curAsset.issuer -> next.getAccountId()))
continue # Skip toStep call
if not (cur.isOffer() and next.isAccount()):
s = toStep(ctx(), cur, next, curAsset)
result.add(s)
...
Strand validation
...
return resultdef toStep(
ctx,
e1: STPathElement,
e2: STPathElement,
curAsset: Asset) # Asset can be Issue or MPTIssue
# First element is account with XRP currency and this is first pair
if ctx.isFirst and e1.isAccount() and hasCurrency(e1) and isXRP(e1.getCurrency()):
return make_XRPEndpointStep(e1.getAccountId())
# First element is XRP account (ID 0) and this is last pair
if ctx.isLast and isXRPAccount(e1) and e2.isAccount():
return make_XRPEndpointStep(e2.getAccountId())
# Both elements are accounts
if e1.isAccount() and e2.isAccount():
# Dispatch based on asset type
if curAsset is MPTIssue:
return make_MPTEndpointStep(e1.getAccountId(), e2.getAccountId(), curAsset.mptID)
else: # curAsset is Issue
return make_DirectStepI(e1.getAccountId(), e2.getAccountId(), curAsset.currency)
# First element is offer, second is account (should be unreachable)
if e1.isOffer() and e2.isAccount():
raise UNREACHABLE # Handled by implied step injection
# Second element is an order book - determine output asset
outAsset = e2.getPathAsset() if hasAsset(e2) else curAsset
outIssuer = e2.getIssuerId() if hasIssuer(e2) else curAsset.issuer
# XRP -> XRP is invalid
if isXRP(curAsset) and isXRP(outAsset):
return temBAD_PATH
# Dispatch to appropriate BookStep based on input/output asset types
if isXRP(outAsset):
# Output is XRP
if curAsset is MPTIssue:
return make_BookStepMX(curAsset)
else: # curAsset is Issue
return make_BookStepIX(curAsset)
if isXRP(curAsset):
# Input is XRP
if outAsset is MPT:
return make_BookStepXM(outAsset.mptID)
else: # outAsset is Currency
return make_BookStepXI(outAsset.currency, outIssuer)
# Both are non-XRP assets (IOU or MPT)
if curAsset is MPTIssue:
if outAsset is MPT:
return make_BookStepMM(curAsset, outAsset.mptID)
else: # outAsset is Currency
return make_BookStepMI(curAsset, outAsset.currency, outIssuer)
else: # curAsset is Issue
if outAsset is MPT:
return make_BookStepIM(curAsset, outAsset.mptID)
else: # outAsset is Currency
return make_BookStepII(curAsset, outAsset.currency, outIssuer)After each step is created by its factory function (make_DirectStepI, make_BookStepII, etc.), the step is immediately validated using its check() method before being added to the strand.
sequenceDiagram
participant toStrand
participant Factory as Step Factory
participant Step
participant check as check()
toStrand->>Factory: make_Step(ctx, src, dst, currency)
Factory->Step: Create step object
Factory->>check: step->check(ctx)
alt check returns tesSUCCESS
check-->>Factory: tesSUCCESS
Factory-->>toStrand: {tesSUCCESS, step}
toStrand->>toStrand: Add step to strand
else check fails
check-->>Factory: error code (temBAD_PATH, terNO_LINE, etc.)
Factory-->>toStrand: {error, nullptr}
toStrand->>toStrands: Reject entire path
end
Figure: Step factory and calling check() for validation
If check() fails, the entire path is rejected and toStrand returns an error code to toStrands.
Each step type has specific validation requirements detailed in the Steps documentation.
Iterative Strands Evaluation is a function called
flowinrippled58. Here we call itstrandsFlowto differentiate between the primaryflowfunction andstrandFlowfunction which executes a single strand.
So far, the Flow engine has been constructing a possible set of steps that a payment can take. Iterative Strands Evaluation is interested in executing those strands to see if they have enough liquidity, and which ones produce the best quality.
It executes a payment across multiple strands using iterative liquidity consumption, with all changes tracked in a PaymentSandbox (in-memory ledger view) that can be committed or discarded.
The inputs to this function are:
| Parameter | Description | Required |
|---|---|---|
baseView |
PaymentSandbox with current trust lines and balances - base state before payment execution | ✅ |
strands |
Vector of strands to use for payment - each strand is a sequence of steps | ✅ |
outReq |
Requested output amount to deliver (templated type: XRPAmount or IOUAmount) | ✅ |
partialPayment |
If true, allow delivering less than full amount; if false, must deliver exact amount or fail | ✅ |
offerCrossing |
Enum indicating if this is offer crossing (no/yes/sell) | ✅ |
limitQuality |
Minimum quality threshold - strands below this quality are excluded | ❌ |
sendMaxST |
Maximum amount sender is willing to spend (STAmount format) | ❌ |
ammContext |
Context for tracking AMM (Automated Market Maker) offer iterations | ✅ |
The strands flow function iterates through multiple liquidity passes, consuming liquidity from the available strands in each pass. Each pass selects and executes the best-quality (lowest exchange rate) strand from those remaining.
The iteration continues until the payment is complete, liquidity at appropriate quality has been exhausted or one of iteration limits has been exceeded.
Each iteration performs:
-
Check iteration limit: If the number of iterations has reached the maximum (1000), exit with error (telFAILED_PROCESSING).
-
Sort remaining strands by quality. The remaining strands are those passed to this function and not yet fully consumed or permanently discarded in previous iterations.
-
AMM optimization (when applicable): If only one strand remains with a
limitQualityrequirement, calculate the maximum output amount that maintains acceptable average quality. AMM pools degrade in quality as liquidity is consumed, so this pre-calculation ensures the requested amount won't violate the quality threshold. -
Evaluate each strand in quality order:
- If the strand's quality is below
limitQuality(when defined), permanently remove it from consideration - If the strand is dry (no liquidity available), reject it and continue to the next strand
- Otherwise, execute the strand:
- Consume liquidity from the strand
- Decrement
remainingOutby the delivered amount - Decrement
remainingInby the consumed amount (ifsendMaxis defined) - If the strand still has liquidity available after consumption, add it to the queue for the next iteration
- Add all subsequent unevaluated strands to the queue for the next iteration
- Continue to the next iteration (do not evaluate remaining strands in this iteration)
- If the strand's quality is below
-
Termination check: Exit the outer loop if any of the following conditions are met:
- No strand was successfully used in this iteration (no best strand found)
- The full requested output amount has been delivered (remainingOut <= 0)
- The maximum input amount has been exhausted (remainingIn <= 0, when sendMax is defined)
- The maximum number of offers to consider has been reached (offersConsidered >= 1500)
Throughout the strand evaluation, strandsFlow collects offers that are unusable and need to be removed (unfunded or expired offers). These offers are removed via offerDelete() calls on the PaymentSandbox - a writable ledger view (an in-memory layer that tracks modifications before committing them to the actual ledger). If the payment succeeds, the PaymentSandbox is applied and the offer deletions propagate to the ledger. If the payment fails, the PaymentSandbox is discarded, but strandsFlow returns all collected offersToRemove to its caller, who is responsible for cleaning them up even on failure.
Final Validation:
After the iteration loop completes, strandsFlow calculates the total amounts consumed and delivered by summing all individual iteration results (smallest to largest for precision). It then validates the results (assuming the fixFillOrKill amendment is enabled, which is the current behavior):
- Sanity check: If
actualOut > outReq, this indicates a rounding error and returnstefEXCEPTION - Partial payment validation: If the full
outReqwas not delivered:- For regular payments with
partialPayment=false, returnstecPATH_PARTIAL(payment failed) - For offer crossing with
FillOrKillflag:- Without
tfSell: Must deliver fullTakerPays(outReq), otherwise returnstecPATH_PARTIAL - With
tfSell: Must consume fullTakerGets(remainingIn must be zero), otherwise returnstecPATH_PARTIAL
- Without
- For partial payments, if
actualOutis zero, returnstecPATH_DRY(no liquidity found) - Otherwise, the partial payment succeeds with whatever was delivered
- For regular payments with
If all validations pass, returns success with the actual amounts consumed/delivered and the PaymentSandbox containing all ledger modifications.
Example: Cross currency payment with three strands
Alice wants to send 100 EUR (IOU) to Bob using her USD (IOU). Her SendMax is 1000 USD and she does not set a quality limit.
Liquidity is provided by:
- AMM Pool: 100 USD <-> 100 EUR (0.3% fee)
- CLOB Offer: takerPays=20 USD, takerGets=20 XRP
- CLOB Offer: takerPays=18 XRP, takerGets=20 EUR
- CLOB Offer: takerPays=20 USD, takerGets=20 ICC
- CLOB Offer: takerPays=24 ICC, takerGets=20 EUR
Available strands are:
- Strand 0 (XRP):
- DirectStepI: Alice -> Issuer (USD)
- BookStep: USD/Issuer -> XRP
- BookStep: XRP -> EUR/Issuer
- DirectStepI: Issuer -> Bob (EUR)
- Strand 1 (AMM):
- DirectStepI: Alice -> Issuer (USD)
- BookStep: USD/Issuer -> EUR/Issuer
- DirectStepI: Issuer -> Bob (EUR)
- Strand 2 (ICC):
- DirectStepI: Alice -> Issuer (USD)
- BookStep: USD/Issuer -> ICC/Issuer
- BookStep: ICC/Issuer -> EUR/Issuer
- DirectStepI: Issuer -> Bob (EUR)
Iteration 0
- Remaining out: 100 EUR, remaining in: 1000 USD
- Ranking:
- XRP strand, quality: 0.9 (best)
- AMM strand, quality: ~1.003
- ICC strand, quality: 1.2
- Selected: XRP strand
- Consumed: 18 USD -> 20 EUR
Iteration 1-12
- Remaining out: 80 EUR -> .. -> 74.219 EUR, remaining in: 982 USD -> 975.845 USD
- Ranking:
- AMM strand, quality: ~1.003 .. ~1.175
- ICC strand, quality: 1.2
- Selected: AMM strand
- AMM uses Multi-Path Mode (steps.md section 5.4.3.2) with Fibonacci sequence sizing because multiple strands are available. Offers start tiny (0.025% of pool) and grow exponentially, allowing the AMM to win iterations at competitive quality while quality degrades along the slippage curve.
- AMM iterations:
- Iteration 1: multiplier=1, consumed 0.025 USD -> 0.0249 EUR
- Iteration 2: multiplier=1, consumed 0.025 USD -> 0.0249 EUR
- Iteration 3: multiplier=2, consumed 0.050 USD -> 0.0498 EUR
- Iteration 4: multiplier=3, consumed 0.075 USD -> 0.0748 EUR
- Iteration 5: multiplier=5, consumed 0.126 USD -> 0.125 EUR
- Iteration 6: multiplier=8, consumed 0.202 USD -> 0.199 EUR
- Iteration 7: multiplier=13, consumed 0.330 USD -> 0.324 EUR
- Iteration 8: multiplier=21, consumed 0.537 USD -> 0.523 EUR
- Iteration 9: multiplier=34, consumed 0.881 USD -> 0.847 EUR
- Iteration 10: multiplier=55, consumed 1.458 USD -> 1.371 EUR
- Iteration 11: multiplier=89, consumed 2.448 USD -> 2.218 EUR
- Iteration 12: multiplier=144, consumed 4.216 USD -> 3.588 EUR
Iteration 13
- Remaining: ~70.631 EUR
- Ranking:
- AMM strand, quality: ~1.305
- ICC strand, quality: 1.2
- Selected: ICC strand
- Consumed: 20 USD -> 16.667 EUR
Iteration 14
- Remaining: ~53.964 EUR
- Ranking:
- AMM strand (only strand remaining). During ranking, Multi-Path mode is used for calculating, because at this point it's not clear that there will be only one strand remaining.
- Selected: AMM strand
- AMM switches to Single-Path Mode (steps.md section 5.4.3.3) because only one strand remains. The AMM consumes a large portion of the pool to complete the payment, resulting in significant quality degradation (quality ~3.019 vs ~1.305 in multi-path mode).
- Consumed: ~162.925 USD -> ~53.964 EUR
Payment was completed: Alice delivered 100 EUR to Bob, while paying ~211.296 USD.
Example: Cross currency payment with three strands and quality limit
Now, let us consider the same setup as before but this time, SendMax is 250 USD and the payment has a flag of tfLimitQuality. This means that the effective quality limit per-strand is 2.5.
Up to including iteration 13, the payment will behave exactly the same. However, iteration 14 will be different.
The code will, like in the previous example, first rank the strands using multi-path AMM logic. However, we do have limit quality and AMM optimization will be invoked.
New output limit for this step will be set to ~46.350 EUR. Since partial payments are not provided, that is less than the required 53.964, so the payment would fail.
Please note that the following pseudocode is a simplified version of logic. One major difference is that pseudocode does not illustrate use of ActiveStrands, but it flattens its logic into the primary method.
def strandsFlow(
baseView: PaymentSandbox,
strands: List[Strand],
outReq: XRPAmount | IOUAmount | MPTAmount,
partialPayment: bool,
offerCrossing: OfferCrossing,
limitQuality: Optional<Quality>,
sendMax: Optional<SendMaxST>,
ammContext: AMMContext,
) -> TER, Optional[actualIn], Optional[actualOut], offersToRemove
# Create a writable sandbox layered on top of baseView for tracking changes
# All modifications happen here; can be committed (on success) or discarded (on failure)
sb = PaymentSandbox(baseView)
remainingOut = outReq
remainingIn = sendMax if (bool) sendMax else None
curTry = 0
strandsWaitingToBeConsidered = strands
offersConsidered = 0
offersToRemoveOnFailure: Set = []
savedIns = []
savedOuts = []
# Keep looping until we exhaust all outReq or sendMax (if specified)
while remainingOut > 0 and (not remainingIn or remainingIn > 0):
if ++curTry >= maxTries:
return telFAILED_PROCESSING, offersToRemoveOnFailure
# Better quality strands will be first
strandsToConsider = sort(strandsWaitingToBeConsidered, limitQuality)
strandsWaitingToBeConsidered = []
ammContext.setMultiPath(len(strandsToConsider) > 1)
# AMM optimization: When only one strand with limitQuality exists, calculate the maximum output that maintains acceptable average quality.
# AMM pools degrade in quality as liquidity is consumed, so we pre-calculate how much we can safely request without violating the quality threshold.
limitedRemainingOut = remainingOut
adjustedRemOut = False
if len(strandsToConsider) == 1 and limitQuality:
strand = strandsToConsider.get(0)
limitedRemainingOut = limitOut(sb, strand, remainingOut, limitQuality)
adjustedRemOut = (limitedRemainingOut != remainingOut)
offersToRemove: Set = []
best = None
for strandIndex in range(len(strandsToConsider)):
strand = strandsToConsider.get(strandIndex)
# Clear AMM liquidity flag before evaluating each strand. This flag tracks whether AMM was used in the current strand evaluation
ammContext.clear()
if offerCrossing and limitQuality and qualityUpperBound(strand) < limitQuality:
# If we are crossing offers, and limitQuality is defined, there is no leeway given. Do not consider any strand that has a lower quality
continue
f = strandFlow(strand, remainingIn, limitedRemainingOut)
# f contains:
# - f.in // How much was consumed
# - f.out // How much was delivered
# - f.inactive // There is no more liquidity in this strand
# - f.offersToRemove // Offers that cannot be consumed
# - f.offersUsed
offersToRemove += f.offersToRemove
offersToRemoveOnFailure.append(f.offersToRemove)
offersConsidered += f.offersUsed
if not f.success or f.out == 0:
# Strand is dry or failed
continue
# Calculate strand quality
q = quality(f.out, f.in)
# adjustedRemOut is never true, unless AMM was involved.
if limitQuality and q < limitQuality and (not adjustedRemOut or not withinRelativeDistance(q, limitQuality)):
# Reject the path if quality is below the limit quality. If we did reduce remaining out, we give quality some slack.
continue
best = strand
sb.apply(best)
# AMM Logic: Update AMM context after consuming liquidity from best strand
ammContext.update()
if not f.inactive:
# The strand still has liquidity available, so we add it to strands to still consider in another iteration
strandsWaitingToBeConsidered.add(strand)
strandsWaitingToBeConsidered.add(strandsToConsider[strandIndex + 1:])
break # out of inner loop
if best:
remainingOut -= best.out
savedOuts.add(best.out)
if sendMax:
remainingIn -= best.in
savedIns.add(best.in)
sb.remove(offersToRemove)
# Break out of while (outer) loop if we did not find a good strand or we used too many offers
if not best or offersConsidered >= 1500:
break
# Sum amounts from smallest to largest for better precision (avoids rounding errors)
# savedOuts and savedIns are sorted collections that accumulate amounts from each iteration
actualOut = sum(savedOuts)
actualIn = sum(savedIns)
# There is an amendment guard for fixFillOrKill in this block - but it's supported now and simplifies logic to assume
# it's enabled
if actualOut != outReq:
if actualOut > outReq:
# Sanity check. Could be caused by rounding errors.
return tefEXCEPTION, offersToRemoveOnFailure
if not partialPayment:
if not offerCrossing or offerCrossing != "sell":
# If this is a buy offer crossing
return tecPATH_PARTIAL, actualIn, actualOut, offersToRemoveOnFailure
elif actualOut == 0:
return tecPATH_DRY, offersToRemoveOnFailure
# At this point, actualOut != outReq only if offerCrossing is "sell" or there is no offer crossing
# We keep this logic from `rippled` implementation, although it is a bit of a technical debt to introduction of
# fixFillOrKill
if not partialPayment and offerCrossing == "sell" and remainingIn != 0:
return tecPATH_PARTIAL, actualIn, actualOut, offersToRemoveOnFailure
return actualIn, actualOut, offersToRemoveOnFailure
The StrandFlow::qualityUpperBound function computes the composite quality of a strand. It is used in two places during strandsFlow execution:
- Sorting: At the start of each
strandsFlowiteration (the outer loop that continues until all liquidity is consumed or the payment is satisfied) to rank remaining strands by quality59 - Filtering: In the inner loop that evaluates strands in quality order within each iteration, to reject strands below the
limitQualitythreshold60
Quality Calculation:
A strand's quality upper bound is the composite quality of all steps in the strand, calculated as:
where
Quality Filtering:
During sorting, if limitQuality is provided and a strand's composite quality is worse than limitQuality, the strand is permanently removed from consideration.
Dynamic Quality Changes:
Strands are re-sorted in every iteration because their quality can change after partial consumption:
- Account state transitions: An account can transition from redeeming to issuing as balances change
- AMM liquidity effects: Automated Market Maker quality changes as liquidity is consumed
- Order book updates: When an offer is consumed, the order book's quality changes to reflect the next best offer
There is no guarantee that this quality will actually be achieved. For example, an offer may be unfunded in which case the strand may not achieve this quality.
The limitOut function calculates the maximum output amount for a strand containing AMM liquidity that maintains a specified quality threshold. This is an AMM-specific optimization applied only when:61
- There is a single strand available
- A
limitQualitythreshold is specified - The strand contains at least one AMM step (a
BookStepwith AMM liquidity)
Unlike CLOB offers which have fixed quality, AMM pools exhibit dynamic quality that degrades as more liquidity is consumed. AMMs use a constant product invariant: poolGets * poolPays = (poolGets + in * cfee) * (poolPays - out). Solving for in and substituting into q = out / in gives:
Quality degrades as more output is consumed. Reducing the output increases quality, which is why limitOut can find the exact output where quality equals limitQuality.
The function will loop over steps in strands and get the quality function for each step. Steps like XRPEndpointStep, MPTEndpointStep and DirectStepI will have a constant quality function.62 A BookStep with AMM offer will have a dynamic quality function.63
All quality functions are combined and a composed quality function is created which will not be constant if at least one quality within it is not constant.64
If the composite quality function is constant (all steps have fixed quality), then the strand's quality won't change regardless of the amount consumed. In this case, limitOut returns the original remainingOut unchanged since the optimization doesn't apply. The strand will either meet the quality threshold for the full amount or fail entirely.
When the composite quality function is non-constant (contains at least one AMM step), limitOut uses qualityFunction.outFromAvgQ(limitQuality) to mathematically solve for the maximum output amount where the average quality equals limitQuality.
Quality functions represent average quality as a linear function of output: q(out) = m * out + b. The parameters m and b differ based on the liquidity source:
For AMM steps:
m(slope) =-cfee / amounts.in- captures how quality degrades as more is consumed from the AMM pool, whereamounts.inispoolGetsandcfee = 1 - tfeeis the fee multiplierb(intercept) =amounts.out * cfee / amounts.in- represents the initial quality when no liquidity has been consumed, derived frompoolPays / poolGets, adjusted by the fee multiplier
For CLOB offers:
m=0- no quality degradation (fixed quality)b=1 / quality.rate()- the offer's fixed quality
When multiple steps are combined, their quality functions are composed to create a strand-level quality function.
The outFromAvgQ function inverts this relationship to solve for output: out = (1/quality.rate() - b) / m.
A special case is handled when there is no positive out for the desired limitQuality, when outFromAvgQ returns a null result to indicate this is the case.
Single Strand Evaluation is a function called
flowinrippled65. Here we call itstrandFlowto differentiate between the primaryflowfunction andstrandsFlowfunction which evaluates a vector of strands
Single Strand Evaluation returns how much of the out output parameter a strand can produce without violating maxIn constraint.
The inputs to this function are:
| Parameter | Description | Required |
|---|---|---|
baseView |
PaymentSandbox with current trust lines and balances, in the state that strandsFlow provides after it has potentially already parsed some strands | ✅ |
strand |
Vector of steps that constitute this strand | ✅ |
maxIn |
Maximum input amount that can be consumed | ❌ |
out |
Desired output amount | ✅ |
The function evaluates each of the steps in the strand. Each step can be evaluated in two ways:
- The forward method answers: for given input, how much output can this step produce?
- The reverse method answers: how much input is required for the desired output?
The base Step class defines common fwd and rev interfaces, which delegate to step-specific fwdImp and revImp implementations. Each concrete step class (DirectStepI, BookStep, XRPEndpointStep and MPTEndpointStep) implements fwdImp and revImp with its own liquidity calculation logic. For details on how each step type implements these methods, see the Steps documentation.
Dual Ledger Views:
Both fwd and rev methods accept two separate ledger views:
sb(PaymentSandbox): Tracks balance changes as the strand executes (updated with each step, and reset when a limiting step is found)afView("all funds view", ApplyView): Preserves account balances from the start of the current evaluation phase (reset along withsbwhen an output-limiting step is found, kept intact for maxIn-limiting steps), used to determine if an offer becomes unfunded or is found unfunded based on the original balances before this evaluation phase
The fwd method (calling fwdImp in a derived step) recalculates amounts based on the given input and simulates the transfer by updating the payment sandbox, applying the minimum of the forward calculation and the cached reverse pass values to prevent over-delivery - see fwdImp documentation for details.
Each step type has its own implementation with different liquidity calculation logic:
fwd accepts:
| Parameter | Description | Required |
|---|---|---|
sb |
Mutable PaymentSandbox tracking current balance changes, updated by the function | ✅ |
afView |
Mutable ApplyView with original balances, updated by the function | ✅ |
ofrsToRm |
Mutable ofrsToRemove, which will be updated | ✅ |
in |
The input amount | ✅ |
It returns, in our pseudocode:
| Value | Description |
|---|---|
in |
How much input was actually consumed |
out |
How much output was produced |
The rev method (calling revImp in a derived step) calculates the required input given a desired output amount and simulates the transfer by updating the payment sandbox - see revImp documentation for details. Each step type has its own implementation:
Like fwd, the implementations differ between payments and offer crossing contexts based on transfer fee responsibility and trust line limit handling.
rev accepts:
| Parameter | Description | Required |
|---|---|---|
sb |
Mutable PaymentSandbox tracking current balance changes, updated by the function | ✅ |
afView |
Mutable ApplyView with original balances, updated by the function | ✅ |
ofrsToRm |
Mutable ofrsToRemove, which will be updated | ✅ |
out |
The desired output amount | ✅ |
It returns, in our pseudocode:
| Value | Description |
|---|---|
in |
The input amount needed to produce actual output |
out |
The actual output |
Evaluating steps in isolation is insufficient. strandFlow must determine the effective inputs and outputs for the entire sequence of steps. Each step can compute its capacity, but only when given specific input or output amounts - a step cannot independently determine how much it should process without knowing the requirements of adjacent steps. To solve this, Flow uses a two-pass evaluation strategy.
The reverse pass works backwards from the destination to calculate possible and required inputs and execute the payment.
The forward pass, if reverse pass showed that the maximum input was limited (due to liquidity, quality or SendMax constraints), then calculates the output for the possible inputs that the reverse pass found.
Reverse Pass:
The engine starts at the destination and walks backward through the strand. For each step (from last to first):
- Ask: "Given required output
X, what is your maximum deliverable outputY, and what inputZdoes it require?" - The step responds with its actual output capability
Y(whereY <= X) and required inputZ - Use
Zas the required output for the previous step
This backward traversal identifies limiting steps - steps where either:
- Required input exceeds
maxIn(first step only), or - Actual output
Yis less than required outputX
When a maxIn-limiting step is found (first step only, required input exceeds maxIn):
- Clear the payment sandbox (
sb) to reset any state changes - Re-execute the step in the forward direction with
maxInas input - Verify the re-execution consumes exactly
maxIn
When an output-limiting step is found (actual output less than requested):
- Clear both the payment sandbox (
sb) and all funds view (afView) - Re-execute the step in the reverse direction with the reduced output
- Verify the re-execution produces consistent results
Forward Pass:
Starting from the first step after the earliest limiting step, execute each remaining step forward using the actual output of the previous step as input. This applies the liquidity consumption determined in the reverse pass.
Example:
Consider a payment where Alice wants to deliver 10 EUR to Bob but will pay at most 12 USD (sendMax) in a partial payment. The first strand evaluated must satisfy the full input and output amounts.
The strand contains three steps:
- DirectStepI: Alice delivers USD to issuer
- BookStep: Converts USD to EUR via order book
- DirectStepI: Issuer delivers EUR to Bob
flowchart LR
alice((Alice))
aliceToIssuer{{DirectStepI}}
issuerToOfferToIssuer{{BookStep}}
issuerToBob{{DirectStepI}}
bob((Bob))
alice -- 12 USD --> aliceToIssuer --> issuerToOfferToIssuer --> issuerToBob -- 10 EUR --> bob
We first examine the last step, using rev method and requiring output of 10 EUR. The step returns 10 EUR as input and 10 EUR as output, meaning it can provide full liquidity.
flowchart LR
alice((Alice))
aliceToIssuer{{DirectStepI}}
issuerToOfferToIssuer{{BookStep}}
issuerToBob{{DirectStepI<br>rev: 10 EUR out}}
bob((Bob))
alice --> aliceToIssuer --> issuerToOfferToIssuer -- 10 EUR --> issuerToBob -- 10 EUR --> bob
style issuerToBob stroke:red,stroke-width:3px
Since we know we require 10 EUR for the last step, we call BookStep's rev method with 10 EUR as desired output.
The BookStep can only provide 8 EUR as the output, and it is asking for 12 USD.
flowchart LR
alice((Alice))
aliceToIssuer{{DirectStepI}}
issuerToOfferToIssuer{{BookStep<br>rev: 10 EUR out}}
issuerToBob{{DirectStepI}}
bob((Bob))
alice --> aliceToIssuer -- 12 USD --> issuerToOfferToIssuer -- 8 EUR --> issuerToBob --> bob
style issuerToOfferToIssuer stroke:red,stroke-width:3px
We clear any transactions we had in the PaymentSandbox and reexecute the transaction in the reverse direction. This ensures that the quality or liquidity that our initial request might have affected is reset in the offer book.
flowchart LR
alice((Alice))
aliceToIssuer{{DirectStepI}}
issuerToOfferToIssuer{{BookStep<br>rev: 8 EUR out}}
issuerToBob{{DirectStepI}}
bob((Bob))
alice --> aliceToIssuer -- 12 USD --> issuerToOfferToIssuer -- 8 EUR --> issuerToBob --> bob
style issuerToOfferToIssuer stroke:red,stroke-width:3px
Now we know that the first step will require 12 USD output. We call its rev method with 12 USD desired output. However, this issuer charges a fee, so they require a 14 USD input!
flowchart LR
alice((Alice))
aliceToIssuer{{DirectStepI<br>rev: 12 USD out}}
issuerToOfferToIssuer{{BookStep}}
issuerToBob{{DirectStepI}}
bob((Bob))
alice -- 14 USD --> aliceToIssuer -- 12 USD --> issuerToOfferToIssuer --> issuerToBob --> bob
style aliceToIssuer stroke:red,stroke-width:3px
Alice is only willing to spend up to 12 USD. We reexecute the step in the forward direction, setting the input to 12 USD and clearing the payment sandbox so liquidity in the following steps is reset.
flowchart LR
alice((Alice))
aliceToIssuer{{DirectStepI<br>fwd: 12 USD in}}
issuerToOfferToIssuer{{BookStep}}
issuerToBob{{DirectStepI}}
bob((Bob))
alice -- 12 USD --> aliceToIssuer -- 10.29 USD --> issuerToOfferToIssuer --> issuerToBob --> bob
style aliceToIssuer stroke:blue,stroke-width:3px
With this, the reverse pass is completed. rippled will reexecute the first step in forwards direction in the scenario above as part of the reverse pass. However, we have noted that the limiting step was the first DirectStepI. In the forward pass, we will start from the first step after the limiting step - so we would start at BookStep. If the USD issuer was willing to pass through 12 USD at 1:1 rate, then the limiting step would have been the last one that failed - BookStep, so the forward pass would start at the second DirectStepI.
As the forward pass starts, we go to BookStep. The input is 10.29 USD and we are interested to see the output, which is 6.86 EUR. It is no surprise that the BookStep can provide this liquidity, since in the reverse pass it provided even more.
flowchart LR
alice((Alice))
aliceToIssuer{{DirectStepI}}
issuerToOfferToIssuer{{BookStep<br>fwd: 10.29 USD in}}
issuerToBob{{DirectStepI}}
bob((Bob))
alice --> aliceToIssuer -- 10.29 USD --> issuerToOfferToIssuer -- 6.86 EUR --> issuerToBob --> bob
style issuerToOfferToIssuer stroke:blue,stroke-width:3px
The output of BookStep, 6.86 EUR, will be the input of the second DirectStepI:
flowchart LR
alice((Alice))
aliceToIssuer{{DirectStepI}}
issuerToOfferToIssuer{{BookStep}}
issuerToBob{{DirectStepI<br>fwd: 6.86 EUR in}}
bob((Bob))
alice --> aliceToIssuer --> issuerToOfferToIssuer -- 6.86 EUR --> issuerToBob -- 6.86 EUR --> bob
style issuerToBob stroke:blue,stroke-width:3px
At this point, we can conclude that the strand can deliver 6.86 EUR to Bob in exchange for 12 USD from Alice.
def strandFlow(
baseView: PaymentSandbox,
strand: Strand,
maxIn: Optional[TInAmt],
out: TOutAmt
) -> StrandResult:
if strand.empty():
return {}
offersToRemove: SortedSet = []
# Direct XRP-to-XRP transfers shouldn't use strand evaluation
# (strand.size() == 2 with both XRPEndpointSteps)
if isDirectXrpToXrp(strand):
return {'success': False, 'offersToRemove': offersToRemove}
s = len(strand)
limitingStep = s
sb = baseView
afView = baseView # All funds view (preserves balances from start of current evaluation phase)
limitStepOut = None
stepOut = out;
for i in range(s - 1, -1, -1): # s-1, s-2, ... 0
r = strand[i].rev(sb, afView, offersToRemove, stepOut)
if r.out == 0:
# The step has nothing to output. Path dry in reverse
return {'success': False, 'offersToRemove': offersToRemove}
if i == 0 and maxIn and maxIn < r.in:
# Limiting step for *in* amount. This is the first step and it requires more *in* then *maxIn*
sb = baseView # Reset the sandbox, reverting offer consumption, resetting balances, etc.
limitingStep = i
r = strand[i].fwd(sb, afView, offersToRemove, maxIn)
limitStepOut = r.out
if r.out == 0:
# first step is dry after applying maxIn limit
return {'success': False, 'offersToRemove': offersToRemove}
if r.in != maxIn:
# This is a sanity check that should never fail. It verifies that when the limiting step is re-executed
# in a "cleaner" environment (with the sandbox reset), it can still produce the same result.
return {'success': False, 'offersToRemove': offersToRemove}
elif r.out != stepOut:
# Limiting step for out amount
sb = baseView # Reset the sandbox
afView = baseView # Reset the AF view
limitingStep = i
# Reexecute reverse direction with reduced stepOut
stepOut = r.out
r = strand[i].rev(sb, afView, offersToRemove, stepOut)
if r.out == 0:
# Reducing desired **out** amount can end up with an **in** that is so tiny that it rounds the output to 0.
return {'success': False, 'offersToRemove': offersToRemove}
if r.out != stepOut:
# This is a sanity check that should never fail. It verifies that when the limiting step is re-executed
# in a "cleaner" environment (with the sandbox reset), it can still produce the same result.
return {'success': False, 'offersToRemove': offersToRemove}
# prev node needs to produce what this node wants to consume
stepOut = r.in
stepIn = limitStepOut
for i in range(limitingStep + 1, s):
r = strand[i].fwd(sb, afView, offersToRemove, stepIn)
if r.out == 0:
# A tiny **in** can round **out** to zero.
return {'success': False, 'offersToRemove': offersToRemove}
if r.in != stepIn:
# The limits should already have been found, so executing a strand forward from the limiting step should not find a new limit
return {'success': False, 'offersToRemove': offersToRemove}
stepIn = r.out
# Amount of currency computed coming into the first step
strandIn = strand[0].cachedIn()
# Amount of currency computed coming out of the last step
strandOut = strand[-1].cachedOut()
inactive = any(step->inactive() for step in strand)
return {'success': True, 'in': strandIn, 'out': strandOut, 'offersToRemove': offersToRemove, 'inactive': inactive, 'sb': sb}The Flow engine performs validation during two main phases: path conversion and execution. Various error codes can be returned at different stages.
8.1. Path Conversion Errors66
These errors occur in toStrands when converting the provided paths into strands:
temRIPPLE_EMPTY: No paths provided and direct ripple path not allowedtemBAD_PATH: Malformed path elements in the providedPathsfieldtefEXCEPTION: Internal error during path-to-strand conversion
These errors occur during payment execution from different sources:
8.2.1. StrandFlow Errors67
Errors returned by the main flow execution logic:
telFAILED_PROCESSING: Exceeded maximum iteration limit (1000 tries) while searching for liquidity across strandstefEXCEPTION: Internal rounding error where actual output exceeds requested amounttecPATH_PARTIAL: Payment failed because the payment would require spending more than SendMax allows, or with partial payments allowed, the deliverable amount is less than DeliverMintecPATH_DRY: No liquidity found (zero output) when partial payment is allowed. Also used when RippleCalc converts retry error codes totecPATH_DRYto claim the transaction fee and discourage users from submitting payments with poorly specified paths
8.2.2. Step Validation Errors68
Errors returned by individual step implementations during strand construction or execution:
temBAD_PATH_LOOP: Loop detection in account, book, or XRP endpoint steps fires during strand constructionterNO_ACCOUNT: Required account is missing while building direct or XRP endpoint stepsterNO_LINE: The needed trust line is absent or frozen, including AMM freeze checksterNO_AUTH: The issuer required authorization and the trust line lacks it (in DirectStep)terNO_RIPPLE: No-ripple flag blocks the pathtecNO_ISSUER: An offer book step references an issuer that no longer existstecLOCKED: MPT validation failure (see section 8.3)tecNO_PERMISSION: MPT validation failure (see section 8.3)tecOBJECT_NOT_FOUND: MPT validation failure (see section 8.3)tecINTERNAL:- AMM freeze lookup fails since there is no AMM ledger item
- Exception thrown during flow execution
8.3. MPT-Specific Validations69
When a cross-currency payment path includes MPT assets (with MPTokensV2 amendment enabled), the Flow engine validates MPTs using checkMPTDEXAllowed. See MPT Validation Functions for details on validation logic and error conditions.
Footnotes
-
Iterative Strands Evaluation implementation:
StrandFlow.h↩ -
Strand Flow implementation:
StrandFlow.h↩ -
Quality stored as normalize(input / output) via
getRate:STAmount.cpp,Quality.cpp↩ -
Inverted comparison operators (lower stored value = higher quality):
Quality.h↩ -
Increment decreases stored value (higher quality), decrement increases it (lower quality):
Quality.cpp↩ -
Quality anti-improvement check in
qualitiesSrcRedeems:DirectStep.cpp↩ -
XRPEndpointStep always returns
Quality{STAmount::uRateOne}:XRPEndpointStep.cpp↩ -
qualityUpperBoundis in the base templateXRPEndpointStep<TDerived>with no override in the offer crossing variantXRPEndpointOfferCrossingStep:XRPEndpointStep.cpp↩ -
MPTEndpointStep applies transfer rate in
qualitiesSrcIssuesonly whenredeems(prevStepDebtDirection):MPTEndpointStep.cpp↩ -
MPTEndpointOfferCrossingStep asserts previous step always issues:
MPTEndpointStep.cpp↩ -
DirectIPaymentStep reads QualityIn/QualityOut from trust line fields:
DirectStep.cpp↩ -
DirectStepI applies transfer rate in
qualitiesSrcIssuesonly whenredeems(prevStepDebtDirection):DirectStep.cpp↩ -
DirectIOfferCrossingStep ignores trust line quality fields, always returns
QUALITY_ONE:DirectStep.cpp↩ -
DirectIOfferCrossingStep asserts previous step always issues:
DirectStep.cpp↩ -
BookPaymentStep
adjustQualityWithFeesapplies input transfer fee whenredeems(prevStepDir):BookStep.cpp↩ -
Output transfer fee requires
ownerPaysTransferFee_, which is only true for offer crossing:BookStep.cpp↩ -
BookOfferCrossingStep returns unmodified offer quality for CLOB and multi-path AMM:
BookStep.cpp↩ -
Single-path AMM input transfer fee during offer crossing requires
fixAMMv1_1:BookStep.cpp↩ -
Payment domain access verification:
Payment.cpp:373-382↩ -
OfferCreate domain access verification:
CreateOffer.cpp:215-220↩ -
toStrands implementation:
PaySteps.cpp↩ -
toStrand implementation:
PaySteps.cpp↩ -
Payment parameter validation:
PaySteps.cpp↩ -
Path element validation:
PaySteps.cpp↩ -
Path normalization implementation:
PaySteps.cpp↩ -
https://github.com/gregtatcam/rippled/blob/a72c3438eb0591a76ac829305fcbcd0ed3b8c325/src/xrpld/app/paths/detail/PaySteps.cpp#L274-L286 ↩
-
https://github.com/gregtatcam/rippled/blob/a72c3438eb0591a76ac829305fcbcd0ed3b8c325/src/xrpld/app/paths/detail/PaySteps.cpp#L288-L298 ↩
-
https://github.com/gregtatcam/rippled/blob/a72c3438eb0591a76ac829305fcbcd0ed3b8c325/src/xrpld/app/paths/detail/PaySteps.cpp#L300-L301 ↩
-
https://github.com/gregtatcam/rippled/blob/a72c3438eb0591a76ac829305fcbcd0ed3b8c325/src/xrpld/app/paths/detail/PaySteps.cpp#L303-L316 ↩
-
https://github.com/gregtatcam/rippled/blob/a72c3438eb0591a76ac829305fcbcd0ed3b8c325/src/xrpld/app/paths/detail/PaySteps.cpp#L323-L329 ↩
-
https://github.com/gregtatcam/rippled/blob/a72c3438eb0591a76ac829305fcbcd0ed3b8c325/src/xrpld/app/paths/detail/PaySteps.cpp#L331-L337 ↩
-
Path to strand conversion implementation:
PaySteps.cpp↩ -
curAsset initialization:
PaySteps.cpp↩ -
Path element pair iteration:
PaySteps.cpp↩ -
XRP endpoint injection:
PaySteps.cpp↩ -
Direct step injection after offer:
PaySteps.cpp↩ -
seenDirectAssets and seenBookOuts initialization:
PaySteps.cpp↩ -
toStep implementation:
PaySteps.cpp↩ -
isFirst initialization:
PaySteps.cpp↩ -
First XRP element check:
PaySteps.cpp↩ -
Last XRP element check:
PaySteps.cpp↩ -
Account to account check:
PaySteps.cpp↩ -
DirectStepI creation:
PaySteps.cpp↩ -
MPTEndpointStep creation:
PaySteps.cpp↩ -
Offer to account unreachable:
PaySteps.cpp↩ -
Offer element assertion:
PaySteps.cpp↩ -
BookStepIX creation:
PaySteps.cpp↩ -
BookStepXI creation:
PaySteps.cpp↩ -
BookStepII creation:
PaySteps.cpp↩ -
BookStepMX creation:
PaySteps.cpp↩ -
BookStepXM creation:
PaySteps.cpp↩ -
BookStepMM creation:
PaySteps.cpp↩ -
BookStepMI creation:
PaySteps.cpp↩ -
BookStepIM creation:
PaySteps.cpp↩ -
XRP to XRP error:
PaySteps.cpp↩ -
strandsFlow implementation:
StrandFlow.h↩ -
See
qualityUpperBoundcall inActiveStrands::activateNextin StrandFlow.h ↩ -
See
qualityUpperBoundcall inflowfunction in StrandFlow.h ↩ -
See default
Step::getQualityFuncimplementation which returnsCLOBLikeTagin Steps.h ↩ -
See
BookStep::getQualityFuncimplementation which checksisConst()and returns AMM quality function in BookStep.cpp, andAMMOffer::getQualityFuncin AMMOffer.cpp ↩ -
Quality functions constructed with
CLOBLikeTagare constant (returntrueforisConst()), while those constructed withAMMTagare non-constant. See QualityFunction.h ↩ -
See
flowfunction (single strand evaluation) in StrandFlow.h:86-286 ↩ -
Path conversion errors (toStrands):
PaySteps.cpp↩ -
StrandFlow errors:
StrandFlow.h↩ -
Step validation errors:
DirectStep.cpp,BookStep.cpp,XRPEndpointStep.cpp,MPTEndpointStep.cpp↩ -
MPT validations in Flow steps:
BookStep.cpp,MPTEndpointStep.cpp↩