rippled
Loading...
Searching...
No Matches
PaySteps.cpp
1#include <xrpld/app/paths/detail/Steps.h>
2
3#include <xrpl/basics/contract.h>
4#include <xrpl/json/json_writer.h>
5#include <xrpl/ledger/ReadView.h>
6#include <xrpl/protocol/IOUAmount.h>
7#include <xrpl/protocol/XRPAmount.h>
8
9#include <algorithm>
10
11namespace xrpl {
12
13// Check equal with tolerance
14bool
15checkNear(IOUAmount const& expected, IOUAmount const& actual)
16{
17 double const ratTol = 0.001;
18 if (abs(expected.exponent() - actual.exponent()) > 1)
19 return false;
20
21 if (actual.exponent() < -20)
22 return true;
23
24 auto const a = (expected.exponent() < actual.exponent()) ? expected.mantissa() / 10 : expected.mantissa();
25 auto const b = (actual.exponent() < expected.exponent()) ? actual.mantissa() / 10 : actual.mantissa();
26 if (a == b)
27 return true;
28
29 double const diff = std::abs(a - b);
30 auto const r = diff / std::max(std::abs(a), std::abs(b));
31 return r <= ratTol;
32};
33
34bool
35checkNear(XRPAmount const& expected, XRPAmount const& actual)
36{
37 return expected == actual;
38};
39
40static bool
42{
44 return false;
45 return isXRP(pe.getAccountID());
46};
47
49toStep(StrandContext const& ctx, STPathElement const* e1, STPathElement const* e2, Issue const& curIssue)
50{
51 auto& j = ctx.j;
52
53 if (ctx.isFirst && e1->isAccount() && (e1->getNodeType() & STPathElement::typeCurrency) && isXRP(e1->getCurrency()))
54 {
55 return make_XRPEndpointStep(ctx, e1->getAccountID());
56 }
57
58 if (ctx.isLast && isXRPAccount(*e1) && e2->isAccount())
59 return make_XRPEndpointStep(ctx, e2->getAccountID());
60
61 if (e1->isAccount() && e2->isAccount())
62 {
63 return make_DirectStepI(ctx, e1->getAccountID(), e2->getAccountID(), curIssue.currency);
64 }
65
66 if (e1->isOffer() && e2->isAccount())
67 {
68 // LCOV_EXCL_START
69 // should already be taken care of
70 JLOG(j.error()) << "Found offer/account payment step. Aborting payment strand.";
71 UNREACHABLE("xrpl::toStep : offer/account payment payment strand");
73 // LCOV_EXCL_STOP
74 }
75
76 XRPL_ASSERT(
78 "xrpl::toStep : currency or issuer");
79 auto const outCurrency = e2->getNodeType() & STPathElement::typeCurrency ? e2->getCurrency() : curIssue.currency;
80 auto const outIssuer = e2->getNodeType() & STPathElement::typeIssuer ? e2->getIssuerID() : curIssue.account;
81
82 if (isXRP(curIssue.currency) && isXRP(outCurrency))
83 {
84 JLOG(j.info()) << "Found xrp/xrp offer payment step";
86 }
87
88 XRPL_ASSERT(e2->isOffer(), "xrpl::toStep : is offer");
89
90 if (isXRP(outCurrency))
91 return make_BookStepIX(ctx, curIssue);
92
93 if (isXRP(curIssue.currency))
94 return make_BookStepXI(ctx, {outCurrency, outIssuer});
95
96 return make_BookStepII(ctx, curIssue, {outCurrency, outIssuer});
97}
98
101 ReadView const& view,
102 AccountID const& src,
103 AccountID const& dst,
104 Issue const& deliver,
105 std::optional<Quality> const& limitQuality,
106 std::optional<Issue> const& sendMaxIssue,
107 STPath const& path,
108 bool ownerPaysTransferFee,
109 OfferCrossing offerCrossing,
110 AMMContext& ammContext,
111 std::optional<uint256> const& domainID,
113{
114 if (isXRP(src) || isXRP(dst) || !isConsistent(deliver) || (sendMaxIssue && !isConsistent(*sendMaxIssue)))
115 return {temBAD_PATH, Strand{}};
116
117 if ((sendMaxIssue && sendMaxIssue->account == noAccount()) || (src == noAccount()) || (dst == noAccount()) ||
118 (deliver.account == noAccount()))
119 return {temBAD_PATH, Strand{}};
120
121 for (auto const& pe : path)
122 {
123 auto const t = pe.getNodeType();
124
125 if ((t & ~STPathElement::typeAll) || !t)
126 return {temBAD_PATH, Strand{}};
127
128 bool const hasAccount = t & STPathElement::typeAccount;
129 bool const hasIssuer = t & STPathElement::typeIssuer;
130 bool const hasCurrency = t & STPathElement::typeCurrency;
131
132 if (hasAccount && (hasIssuer || hasCurrency))
133 return {temBAD_PATH, Strand{}};
134
135 if (hasIssuer && isXRP(pe.getIssuerID()))
136 return {temBAD_PATH, Strand{}};
137
138 if (hasAccount && isXRP(pe.getAccountID()))
139 return {temBAD_PATH, Strand{}};
140
141 if (hasCurrency && hasIssuer && isXRP(pe.getCurrency()) != isXRP(pe.getIssuerID()))
142 return {temBAD_PATH, Strand{}};
143
144 if (hasIssuer && (pe.getIssuerID() == noAccount()))
145 return {temBAD_PATH, Strand{}};
146
147 if (hasAccount && (pe.getAccountID() == noAccount()))
148 return {temBAD_PATH, Strand{}};
149 }
150
151 Issue curIssue = [&] {
152 auto const& currency = sendMaxIssue ? sendMaxIssue->currency : deliver.currency;
153 if (isXRP(currency))
154 return xrpIssue();
155 return Issue{currency, src};
156 }();
157
158 auto hasCurrency = [](STPathElement const pe) { return pe.getNodeType() & STPathElement::typeCurrency; };
159
161 // reserve enough for the path, the implied source, destination,
162 // sendmax and deliver.
163 normPath.reserve(4 + path.size());
164 {
165 normPath.emplace_back(STPathElement::typeAll, src, curIssue.currency, curIssue.account);
166
167 if (sendMaxIssue && sendMaxIssue->account != src &&
168 (path.empty() || !path[0].isAccount() || path[0].getAccountID() != sendMaxIssue->account))
169 {
170 normPath.emplace_back(sendMaxIssue->account, std::nullopt, std::nullopt);
171 }
172
173 for (auto const& i : path)
174 normPath.push_back(i);
175
176 {
177 // Note that for offer crossing (only) we do use an offer book
178 // even if all that is changing is the Issue.account.
179 STPathElement const& lastCurrency = *std::find_if(normPath.rbegin(), normPath.rend(), hasCurrency);
180 if ((lastCurrency.getCurrency() != deliver.currency) ||
181 (offerCrossing && lastCurrency.getIssuerID() != deliver.account))
182 {
183 normPath.emplace_back(std::nullopt, deliver.currency, deliver.account);
184 }
185 }
186
187 if (!((normPath.back().isAccount() && normPath.back().getAccountID() == deliver.account) ||
188 (dst == deliver.account)))
189 {
190 normPath.emplace_back(deliver.account, std::nullopt, std::nullopt);
191 }
192
193 if (!normPath.back().isAccount() || normPath.back().getAccountID() != dst)
194 {
195 normPath.emplace_back(dst, std::nullopt, std::nullopt);
196 }
197 }
198
199 if (normPath.size() < 2)
200 return {temBAD_PATH, Strand{}};
201
202 auto const strandSrc = normPath.front().getAccountID();
203 auto const strandDst = normPath.back().getAccountID();
204 bool const isDefaultPath = path.empty();
205
206 Strand result;
207 result.reserve(2 * normPath.size());
208
209 /* A strand may not include the same account node more than once
210 in the same currency. In a direct step, an account will show up
211 at most twice: once as a src and once as a dst (hence the two element
212 array). The strandSrc and strandDst will only show up once each.
213 */
215 // A strand may not include the same offer book more than once
216 boost::container::flat_set<Issue> seenBookOuts;
217 seenDirectIssues[0].reserve(normPath.size());
218 seenDirectIssues[1].reserve(normPath.size());
219 seenBookOuts.reserve(normPath.size());
220 auto ctx = [&](bool isLast = false) {
221 return StrandContext{
222 view,
223 result,
224 strandSrc,
225 strandDst,
226 deliver,
227 limitQuality,
228 isLast,
229 ownerPaysTransferFee,
230 offerCrossing,
232 seenDirectIssues,
233 seenBookOuts,
234 ammContext,
235 domainID,
236 j};
237 };
238
239 for (std::size_t i = 0; i < normPath.size() - 1; ++i)
240 {
241 /* Iterate through the path elements considering them in pairs.
242 The first element of the pair is `cur` and the second element is
243 `next`. When an offer is one of the pairs, the step created will be
244 for `next`. This means when `cur` is an offer and `next` is an
245 account then no step is created, as a step has already been created
246 for that offer.
247 */
249 auto cur = &normPath[i];
250 auto const next = &normPath[i + 1];
251
252 if (cur->isAccount())
253 curIssue.account = cur->getAccountID();
254 else if (cur->hasIssuer())
255 curIssue.account = cur->getIssuerID();
256
257 if (cur->hasCurrency())
258 {
259 curIssue.currency = cur->getCurrency();
260 if (isXRP(curIssue.currency))
261 curIssue.account = xrpAccount();
262 }
263
264 if (cur->isAccount() && next->isAccount())
265 {
266 if (!isXRP(curIssue.currency) && curIssue.account != cur->getAccountID() &&
267 curIssue.account != next->getAccountID())
268 {
269 JLOG(j.trace()) << "Inserting implied account";
270 auto msr = make_DirectStepI(ctx(), cur->getAccountID(), curIssue.account, curIssue.currency);
271 if (msr.first != tesSUCCESS)
272 return {msr.first, Strand{}};
273 result.push_back(std::move(msr.second));
275 cur = &*impliedPE;
276 }
277 }
278 else if (cur->isAccount() && next->isOffer())
279 {
280 if (curIssue.account != cur->getAccountID())
281 {
282 JLOG(j.trace()) << "Inserting implied account before offer";
283 auto msr = make_DirectStepI(ctx(), cur->getAccountID(), curIssue.account, curIssue.currency);
284 if (msr.first != tesSUCCESS)
285 return {msr.first, Strand{}};
286 result.push_back(std::move(msr.second));
288 cur = &*impliedPE;
289 }
290 }
291 else if (cur->isOffer() && next->isAccount())
292 {
293 if (curIssue.account != next->getAccountID() && !isXRP(next->getAccountID()))
294 {
295 if (isXRP(curIssue))
296 {
297 if (i != normPath.size() - 2)
298 return {temBAD_PATH, Strand{}};
299 else
300 {
301 // Last step. insert xrp endpoint step
302 auto msr = make_XRPEndpointStep(ctx(), next->getAccountID());
303 if (msr.first != tesSUCCESS)
304 return {msr.first, Strand{}};
305 result.push_back(std::move(msr.second));
306 }
307 }
308 else
309 {
310 JLOG(j.trace()) << "Inserting implied account after offer";
311 auto msr = make_DirectStepI(ctx(), curIssue.account, next->getAccountID(), curIssue.currency);
312 if (msr.first != tesSUCCESS)
313 return {msr.first, Strand{}};
314 result.push_back(std::move(msr.second));
315 }
316 }
317 continue;
318 }
319
320 if (!next->isOffer() && next->hasCurrency() && next->getCurrency() != curIssue.currency)
321 {
322 // Should never happen
323 // LCOV_EXCL_START
324 UNREACHABLE("xrpl::toStrand : offer currency mismatch");
325 return {temBAD_PATH, Strand{}};
326 // LCOV_EXCL_STOP
327 }
328
329 auto s = toStep(ctx(/*isLast*/ i == normPath.size() - 2), cur, next, curIssue);
330 if (s.first == tesSUCCESS)
331 result.emplace_back(std::move(s.second));
332 else
333 {
334 JLOG(j.debug()) << "toStep failed: " << s.first;
335 return {s.first, Strand{}};
336 }
337 }
338
339 auto checkStrand = [&]() -> bool {
340 auto stepAccts = [](Step const& s) -> std::pair<AccountID, AccountID> {
341 if (auto r = s.directStepAccts())
342 return *r;
343 if (auto const r = s.bookStepBook())
344 return std::make_pair(r->in.account, r->out.account);
345 Throw<FlowException>(tefEXCEPTION, "Step should be either a direct or book step");
347 };
348
349 auto curAcc = src;
350 auto curIss = [&] {
351 auto& currency = sendMaxIssue ? sendMaxIssue->currency : deliver.currency;
352 if (isXRP(currency))
353 return xrpIssue();
354 return Issue{currency, src};
355 }();
356
357 for (auto const& s : result)
358 {
359 auto const accts = stepAccts(*s);
360 if (accts.first != curAcc)
361 return false;
362
363 if (auto const b = s->bookStepBook())
364 {
365 if (curIss != b->in)
366 return false;
367 curIss = b->out;
368 }
369 else
370 {
371 curIss.account = accts.second;
372 }
373
374 curAcc = accts.second;
375 }
376 if (curAcc != dst)
377 return false;
378 if (curIss.currency != deliver.currency)
379 return false;
380 if (curIss.account != deliver.account && curIss.account != dst)
381 return false;
382 return true;
383 };
384
385 if (!checkStrand())
386 {
387 // LCOV_EXCL_START
388 JLOG(j.warn()) << "Flow check strand failed";
389 UNREACHABLE("xrpl::toStrand : invalid strand");
390 return {temBAD_PATH, Strand{}};
391 // LCOV_EXCL_STOP
392 }
393
394 return {tesSUCCESS, std::move(result)};
395}
396
399 ReadView const& view,
400 AccountID const& src,
401 AccountID const& dst,
402 Issue const& deliver,
403 std::optional<Quality> const& limitQuality,
404 std::optional<Issue> const& sendMax,
405 STPathSet const& paths,
406 bool addDefaultPath,
407 bool ownerPaysTransferFee,
408 OfferCrossing offerCrossing,
409 AMMContext& ammContext,
410 std::optional<uint256> const& domainID,
412{
413 std::vector<Strand> result;
414 result.reserve(1 + paths.size());
415 // Insert the strand into result if it is not already part of the vector
416 auto insert = [&](Strand s) {
417 bool const hasStrand = std::find(result.begin(), result.end(), s) != result.end();
418
419 if (!hasStrand)
420 result.emplace_back(std::move(s));
421 };
422
423 if (addDefaultPath)
424 {
425 auto sp = toStrand(
426 view,
427 src,
428 dst,
429 deliver,
430 limitQuality,
431 sendMax,
432 STPath(),
433 ownerPaysTransferFee,
434 offerCrossing,
435 ammContext,
436 domainID,
437 j);
438 auto const ter = sp.first;
439 auto& strand = sp.second;
440
441 if (ter != tesSUCCESS)
442 {
443 JLOG(j.trace()) << "failed to add default path";
444 if (isTemMalformed(ter) || paths.empty())
445 {
446 return {ter, std::vector<Strand>{}};
447 }
448 }
449 else if (strand.empty())
450 {
451 JLOG(j.trace()) << "toStrand failed";
452 Throw<FlowException>(tefEXCEPTION, "toStrand returned tes & empty strand");
453 }
454 else
455 {
456 insert(std::move(strand));
457 }
458 }
459 else if (paths.empty())
460 {
461 JLOG(j.debug()) << "Flow: Invalid transaction: No paths and direct "
462 "ripple not allowed.";
464 }
465
466 TER lastFailTer = tesSUCCESS;
467 for (auto const& p : paths)
468 {
469 auto sp = toStrand(
470 view,
471 src,
472 dst,
473 deliver,
474 limitQuality,
475 sendMax,
476 p,
477 ownerPaysTransferFee,
478 offerCrossing,
479 ammContext,
480 domainID,
481 j);
482 auto ter = sp.first;
483 auto& strand = sp.second;
484
485 if (ter != tesSUCCESS)
486 {
487 lastFailTer = ter;
488 JLOG(j.trace()) << "failed to add path: ter: " << ter << "path: " << p.getJson(JsonOptions::none);
489 if (isTemMalformed(ter))
490 return {ter, std::vector<Strand>{}};
491 }
492 else if (strand.empty())
493 {
494 JLOG(j.trace()) << "toStrand failed";
495 Throw<FlowException>(tefEXCEPTION, "toStrand returned tes & empty strand");
496 }
497 else
498 {
499 insert(std::move(strand));
500 }
501 }
502
503 if (result.empty())
504 return {lastFailTer, std::move(result)};
505
506 return {tesSUCCESS, std::move(result)};
507}
508
510 ReadView const& view_,
511 std::vector<std::unique_ptr<Step>> const& strand_,
512 // A strand may not include an inner node that
513 // replicates the source or destination.
514 AccountID const& strandSrc_,
515 AccountID const& strandDst_,
516 Issue const& strandDeliver_,
517 std::optional<Quality> const& limitQuality_,
518 bool isLast_,
519 bool ownerPaysTransferFee_,
520 OfferCrossing offerCrossing_,
521 bool isDefaultPath_,
522 std::array<boost::container::flat_set<Issue>, 2>& seenDirectIssues_,
523 boost::container::flat_set<Issue>& seenBookOuts_,
524 AMMContext& ammContext_,
525 std::optional<uint256> const& domainID_,
527 : view(view_)
528 , strandSrc(strandSrc_)
529 , strandDst(strandDst_)
530 , strandDeliver(strandDeliver_)
531 , limitQuality(limitQuality_)
532 , isFirst(strand_.empty())
533 , isLast(isLast_)
534 , ownerPaysTransferFee(ownerPaysTransferFee_)
535 , offerCrossing(offerCrossing_)
536 , isDefaultPath(isDefaultPath_)
537 , strandSize(strand_.size())
538 , prevStep(!strand_.empty() ? strand_.back().get() : nullptr)
539 , seenDirectIssues(seenDirectIssues_)
540 , seenBookOuts(seenBookOuts_)
541 , ammContext(ammContext_)
542 , domainID(domainID_)
543 , j(j_)
544{
545}
546
547template <class InAmt, class OutAmt>
548bool
549isDirectXrpToXrp(Strand const& strand)
550{
551 return false;
552}
553
554template <>
555bool
556isDirectXrpToXrp<XRPAmount, XRPAmount>(Strand const& strand)
557{
558 return (strand.size() == 2);
559}
560
561template bool
562isDirectXrpToXrp<XRPAmount, IOUAmount>(Strand const& strand);
563template bool
564isDirectXrpToXrp<IOUAmount, XRPAmount>(Strand const& strand);
565template bool
566isDirectXrpToXrp<IOUAmount, IOUAmount>(Strand const& strand);
567
568} // namespace xrpl
T back(T... args)
T begin(T... args)
A generic endpoint for log messages.
Definition Journal.h:41
Stream debug() const
Definition Journal.h:301
Stream trace() const
Severity stream access functions.
Definition Journal.h:295
Stream warn() const
Definition Journal.h:313
Maintains AMM info per overall payment engine execution and individual iteration.
Definition AMMContext.h:17
Floating point representation of amounts with high dynamic range.
Definition IOUAmount.h:26
mantissa_type mantissa() const noexcept
Definition IOUAmount.h:162
exponent_type exponent() const noexcept
Definition IOUAmount.h:156
A currency issued by an account.
Definition Issue.h:14
Currency currency
Definition Issue.h:16
AccountID account
Definition Issue.h:17
A view into a ledger.
Definition ReadView.h:32
auto getNodeType() const
Definition STPathSet.h:283
AccountID const & getAccountID() const
Definition STPathSet.h:321
bool isOffer() const
Definition STPathSet.h:289
Currency const & getCurrency() const
Definition STPathSet.h:327
AccountID const & getIssuerID() const
Definition STPathSet.h:333
bool isAccount() const
Definition STPathSet.h:295
std::vector< STPath >::size_type size() const
Definition STPathSet.h:462
bool empty() const
Definition STPathSet.h:468
std::vector< STPathElement >::size_type size() const
Definition STPathSet.h:358
bool empty() const
Definition STPathSet.h:364
A step in a payment path.
Definition Steps.h:67
T emplace_back(T... args)
T emplace(T... args)
T empty(T... args)
T end(T... args)
T find_if(T... args)
T front(T... args)
T is_same_v
T make_pair(T... args)
T max(T... args)
Use hash_* containers for keys that do not need a cryptographically secure hashing algorithm.
Definition algorithm.h:6
bool isConsistent(Book const &book)
Definition Book.cpp:10
Issue const & xrpIssue()
Returns an asset specifier that represents XRP.
Definition Issue.h:98
bool isDirectXrpToXrp(Strand const &strand)
Definition PaySteps.cpp:549
bool isXRP(AccountID const &c)
Definition AccountID.h:71
std::pair< TER, std::vector< Strand > > toStrands(ReadView const &view, AccountID const &src, AccountID const &dst, Issue const &deliver, std::optional< Quality > const &limitQuality, std::optional< Issue > const &sendMax, STPathSet const &paths, bool addDefaultPath, bool ownerPaysTransferFee, OfferCrossing offerCrossing, AMMContext &ammContext, std::optional< uint256 > const &domainID, beast::Journal j)
Create a Strand for each specified path (including the default path, if indicated)
Definition PaySteps.cpp:398
T get(Section const &section, std::string const &name, T const &defaultValue=T{})
Retrieve a key/value pair from a section.
bool isDirectXrpToXrp< XRPAmount, XRPAmount >(Strand const &strand)
Definition PaySteps.cpp:556
std::pair< TER, std::unique_ptr< Step > > make_XRPEndpointStep(StrandContext const &ctx, AccountID const &acc)
std::pair< TER, std::unique_ptr< Step > > make_BookStepXI(StrandContext const &ctx, Issue const &out)
@ tefEXCEPTION
Definition TER.h:153
template bool isDirectXrpToXrp< IOUAmount, IOUAmount >(Strand const &strand)
Currency const & xrpCurrency()
XRP currency.
Definition UintTypes.cpp:96
static bool isDefaultPath(STPath const &path)
static std::pair< TER, std::unique_ptr< Step > > toStep(StrandContext const &ctx, STPathElement const *e1, STPathElement const *e2, Issue const &curIssue)
Definition PaySteps.cpp:49
std::pair< TER, std::unique_ptr< Step > > make_DirectStepI(StrandContext const &ctx, AccountID const &src, AccountID const &dst, Currency const &c)
std::pair< TER, std::unique_ptr< Step > > make_BookStepIX(StrandContext const &ctx, Issue const &in)
bool checkNear(IOUAmount const &expected, IOUAmount const &actual)
Definition PaySteps.cpp:15
constexpr Number abs(Number x) noexcept
Definition Number.h:707
AccountID const & noAccount()
A placeholder for empty accounts.
@ temBAD_PATH
Definition TER.h:77
@ temRIPPLE_EMPTY
Definition TER.h:94
template bool isDirectXrpToXrp< XRPAmount, IOUAmount >(Strand const &strand)
std::pair< TER, std::unique_ptr< Step > > make_BookStepII(StrandContext const &ctx, Issue const &in, Issue const &out)
AccountID const & xrpAccount()
Compute AccountID from public key.
static bool isXRPAccount(STPathElement const &pe)
Definition PaySteps.cpp:41
OfferCrossing
Definition Steps.h:26
template bool isDirectXrpToXrp< IOUAmount, XRPAmount >(Strand const &strand)
bool isTemMalformed(TER x) noexcept
Definition TER.h:632
@ tesSUCCESS
Definition TER.h:226
std::pair< TER, Strand > toStrand(ReadView const &view, AccountID const &src, AccountID const &dst, Issue const &deliver, std::optional< Quality > const &limitQuality, std::optional< Issue > const &sendMaxIssue, STPath const &path, bool ownerPaysTransferFee, OfferCrossing offerCrossing, AMMContext &ammContext, std::optional< uint256 > const &domainID, beast::Journal j)
Create a Strand for the specified path.
Definition PaySteps.cpp:100
T push_back(T... args)
T rbegin(T... args)
T rend(T... args)
T reserve(T... args)
T size(T... args)
Context needed to build Strand Steps and for error checking.
Definition Steps.h:509
beast::Journal const j
Definition Steps.h:537
StrandContext(ReadView const &view_, std::vector< std::unique_ptr< Step > > const &strand_, AccountID const &strandSrc_, AccountID const &strandDst_, Issue const &strandDeliver_, std::optional< Quality > const &limitQuality_, bool isLast_, bool ownerPaysTransferFee_, OfferCrossing offerCrossing_, bool isDefaultPath_, std::array< boost::container::flat_set< Issue >, 2 > &seenDirectIssues_, boost::container::flat_set< Issue > &seenBookOuts_, AMMContext &ammContext_, std::optional< uint256 > const &domainID, beast::Journal j_)
StrandContext constructor.
Definition PaySteps.cpp:509
bool const isFirst
true if Step is first in Strand
Definition Steps.h:515
bool const isLast
true if Step is last in Strand
Definition Steps.h:516