diff --git a/include/xrpl/basics/Number.h b/include/xrpl/basics/Number.h index b2ccbe0bf4..add64ac321 100644 --- a/include/xrpl/basics/Number.h +++ b/include/xrpl/basics/Number.h @@ -418,6 +418,12 @@ public: operator=(NumberRoundModeGuard const&) = delete; }; +class NumberOverflow : public std::overflow_error +{ +public: + using overflow_error::overflow_error; +}; + } // namespace ripple #endif // XRPL_BASICS_NUMBER_H_INCLUDED diff --git a/include/xrpl/protocol/Asset.h b/include/xrpl/protocol/Asset.h index 6765f7cf7d..1e547d80e0 100644 --- a/include/xrpl/protocol/Asset.h +++ b/include/xrpl/protocol/Asset.h @@ -87,7 +87,14 @@ public: bool integral() const { - return !holds() || get().native(); + return std::visit( + [](TIss const& issue) { + if constexpr (std::is_same_v) + return issue.native(); + if constexpr (std::is_same_v) + return true; + }, + issue_); } friend constexpr bool diff --git a/include/xrpl/protocol/STNumber.h b/include/xrpl/protocol/STNumber.h index 2ec3d66fd1..de6da30945 100644 --- a/include/xrpl/protocol/STNumber.h +++ b/include/xrpl/protocol/STNumber.h @@ -24,6 +24,10 @@ class STNumber : public STBase, public CountedObject { private: Number value_; + // isInteger_ is not serialized or transmitted in any way. It is used only + // for internal validation of integer types. It is a one-way switch. Once + // it's on, it stays on. + bool isInteger_ = false; public: using value_type = Number; @@ -51,6 +55,35 @@ public: return *this; } + // Tell the STNumber whether the value it is holding represents an integer, + // and must fit within the allowable range. + void + usesAsset(Asset const& a); + // The asset isn't stored, only whether it's an integral type. Get that flag + // back out. + bool + isIntegral() const; + // Returns whether the value fits within Number::maxIntValue. Transactors + // should check this whenever interacting with an STNumber. + bool + safeNumber() const; + /// Combines usesAsset(a) and safeNumber() + static std::int64_t + safeNumberLimit(); + bool + safeNumber(Asset const& a); + // Returns whether the value fits within Number::maxMantissa. Transactors + // may check this, too, but are not required to. It will be checked when + // serializing, and will throw if false, thus preventing the value from + // being silently truncated. + bool + validNumber() const; + /// Combines usesAsset(a) and validAsset() + bool + validNumber(Asset const& a); + static std::int64_t + validNumberLimit(); + bool isEquivalent(STBase const& t) const override; bool diff --git a/include/xrpl/protocol/STObject.h b/include/xrpl/protocol/STObject.h index 0e985e9712..4de6673ff6 100644 --- a/include/xrpl/protocol/STObject.h +++ b/include/xrpl/protocol/STObject.h @@ -487,6 +487,10 @@ public: T const* operator->() const; + /// Access the underlying STObject without necessarily dereferencing it + T* + stValue() const; + protected: STObject* st_; SOEStyle style_; @@ -726,7 +730,15 @@ template T const* STObject::Proxy::operator->() const { - return this->find(); + return stValue(); +} + +/// Access the underlying STObject without necessarily dereferencing it +template +T* +STObject::Proxy::stValue() const +{ + return dynamic_cast(st_->getPField(*f_)); } template diff --git a/src/libxrpl/protocol/STLedgerEntry.cpp b/src/libxrpl/protocol/STLedgerEntry.cpp index 5665bc1508..eff0f894c1 100644 --- a/src/libxrpl/protocol/STLedgerEntry.cpp +++ b/src/libxrpl/protocol/STLedgerEntry.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -67,6 +68,32 @@ STLedgerEntry::setSLEType() type_ = format->getType(); applyTemplate(format->getSOTemplate()); // May throw + + // Per object type overrides + // Currently only covers STNumber fields to link them to appropriate assets + switch (type_) + { + case ltVAULT: { + auto const asset = at(sfAsset); + for (auto const& field : + {~sfAssetsAvailable, + ~sfAssetsTotal, + ~sfAssetsMaximum, + ~sfLossUnrealized}) + { + if (auto proxy = at(field)) + if (auto stNumber = proxy.stValue()) + stNumber->usesAsset(asset); + } + } + /* + // TODO: If possible, set up the loan-related STNumber fields, too. + // May not be possible because we don't have a view available. + + case ltLOAN_BROKER: + case ltLOAN: + */ + } } std::string diff --git a/src/libxrpl/protocol/STNumber.cpp b/src/libxrpl/protocol/STNumber.cpp index c353f6b795..696134b811 100644 --- a/src/libxrpl/protocol/STNumber.cpp +++ b/src/libxrpl/protocol/STNumber.cpp @@ -50,6 +50,8 @@ STNumber::add(Serializer& s) const XRPL_ASSERT( getFName().fieldType == getSType(), "ripple::STNumber::add : field type match"); + if (!validNumber()) + throw NumberOverflow(to_string(value_)); s.add64(value_.mantissa()); s.add32(value_.exponent()); } @@ -66,6 +68,87 @@ STNumber::setValue(Number const& v) value_ = v; } +// Tell the STNumber whether the value it is holding represents an integer, and +// must fit within the allowable range. +void +STNumber::usesAsset(Asset const& a) +{ + XRPL_ASSERT_PARTS( + !isInteger_ || a.integral(), + "ripple::STNumber::value", + "asset check only gets stricter"); + // isInteger_ is a one-way switch. Once it's on, it stays on. + if (isInteger_) + return; + isInteger_ = a.integral(); +} + +bool +STNumber::isIntegral() const +{ + return isInteger_; +} + +// Returns whether the value fits within Number::maxIntValue. Transactors +// should check this whenever interacting with an STNumber. +bool +STNumber::safeNumber() const +{ + if (!isInteger_) + return true; + + static Number const max = safeNumberLimit(); + static Number const maxNeg = -max; + // Avoid making a copy + if (value_ < 0) + return value_ >= maxNeg; + return value_ <= max; +} + +bool +STNumber::safeNumber(Asset const& a) +{ + usesAsset(a); + return safeNumber(); +} + +std::int64_t +STNumber::safeNumberLimit() +{ + return Number::maxIntValue; +} + +// Returns whether the value fits within Number::maxMantissa. Transactors +// may check this, too, but are not required to. It will be checked when +// serializing, and will throw if false, thus preventing the value from +// being silently truncated. +bool +STNumber::validNumber() const +{ + if (!isInteger_) + return true; + + static Number const max = validNumberLimit(); + static Number const maxNeg = -max; + // Avoid making a copy + if (value_ < 0) + return value_ >= maxNeg; + return value_ <= max; +} + +bool +STNumber::validNumber(Asset const& a) +{ + usesAsset(a); + return validNumber(); +} + +std::int64_t +STNumber::validNumberLimit() +{ + return Number::maxMantissa; +} + STBase* STNumber::copy(std::size_t n, void* buf) const { diff --git a/src/test/app/Vault_test.cpp b/src/test/app/Vault_test.cpp index 37ceffe5b2..9a8722a115 100644 --- a/src/test/app/Vault_test.cpp +++ b/src/test/app/Vault_test.cpp @@ -3684,32 +3684,7 @@ class Vault_test : public beast::unit_test::suite }); testCase(18, [&, this](Env& env, Data d) { - testcase("MPT scale deposit overflow"); - // The computed number of shares can not be represented as an MPT - // without truncation - - { - auto tx = d.vault.deposit( - {.depositor = d.depositor, - .id = d.keylet.key, - .amount = d.asset(5)}); - env(tx, ter{tecPRECISION_LOSS}); - env.close(); - } - }); - - testCase(13, [&, this](Env& env, Data d) { - testcase("MPT scale deposit overflow on first deposit"); - auto tx = d.vault.deposit( - {.depositor = d.depositor, - .id = d.keylet.key, - .amount = d.asset(10)}); - env(tx, ter{tecPRECISION_LOSS}); - env.close(); - }); - - testCase(13, [&, this](Env& env, Data d) { - testcase("MPT scale deposit overflow on second deposit"); + testcase("Scale deposit overflow on second deposit"); { auto tx = d.vault.deposit( @@ -3730,8 +3705,8 @@ class Vault_test : public beast::unit_test::suite } }); - testCase(13, [&, this](Env& env, Data d) { - testcase("No MPT scale deposit overflow on total shares"); + testCase(18, [&, this](Env& env, Data d) { + testcase("Scale deposit overflow on total shares"); { auto tx = d.vault.deposit( @@ -3747,7 +3722,7 @@ class Vault_test : public beast::unit_test::suite {.depositor = d.depositor, .id = d.keylet.key, .amount = d.asset(5)}); - env(tx); + env(tx, ter{tecPATH_DRY}); env.close(); } }); @@ -4031,28 +4006,6 @@ class Vault_test : public beast::unit_test::suite testCase(18, [&, this](Env& env, Data d) { testcase("Scale withdraw overflow"); - { - auto tx = d.vault.deposit( - {.depositor = d.depositor, - .id = d.keylet.key, - .amount = d.asset(5)}); - env(tx, ter{tecPRECISION_LOSS}); - env.close(); - } - - { - auto tx = d.vault.withdraw( - {.depositor = d.depositor, - .id = d.keylet.key, - .amount = STAmount(d.asset, Number(10, 0))}); - env(tx, ter{tecPRECISION_LOSS}); - env.close(); - } - }); - - testCase(13, [&, this](Env& env, Data d) { - testcase("MPT scale withdraw overflow"); - { auto tx = d.vault.deposit( {.depositor = d.depositor, @@ -4271,29 +4224,6 @@ class Vault_test : public beast::unit_test::suite testCase(18, [&, this](Env& env, Data d) { testcase("Scale clawback overflow"); - { - auto tx = d.vault.deposit( - {.depositor = d.depositor, - .id = d.keylet.key, - .amount = d.asset(5)}); - env(tx, ter(tecPRECISION_LOSS)); - env.close(); - } - - { - auto tx = d.vault.clawback( - {.issuer = d.issuer, - .id = d.keylet.key, - .holder = d.depositor, - .amount = STAmount(d.asset, Number(10, 0))}); - env(tx, ter{tecPRECISION_LOSS}); - env.close(); - } - }); - - testCase(13, [&, this](Env& env, Data d) { - testcase("MPT Scale clawback overflow"); - { auto tx = d.vault.deposit( {.depositor = d.depositor, diff --git a/src/xrpld/app/tx/detail/InvariantCheck.cpp b/src/xrpld/app/tx/detail/InvariantCheck.cpp index 10bed85143..3f6b2deb00 100644 --- a/src/xrpld/app/tx/detail/InvariantCheck.cpp +++ b/src/xrpld/app/tx/detail/InvariantCheck.cpp @@ -2164,6 +2164,28 @@ ValidAMM::finalize( //------------------------------------------------------------------------------ +ValidVault::NumberInfo +ValidVault::NumberInfo::make( + SLE const& from, + SF_NUMBER const& field, + Asset const& asset) +{ + bool valid = true; + + // Poke around in the internals of STObject to get the STNumber object + if (auto const stNumber = + dynamic_cast(from.peekAtPField(field))) + valid = stNumber->isIntegral() == asset.integral() && + stNumber->validNumber(); + + return {.n = from.at(field), .valid = valid}; +} + +ValidVault::NumberInfo::operator Number const&() const +{ + return n; +} + ValidVault::Vault ValidVault::Vault::make(SLE const& from) { @@ -2176,10 +2198,11 @@ ValidVault::Vault::make(SLE const& from) self.asset = from.at(sfAsset); self.pseudoId = from.getAccountID(sfAccount); self.shareMPTID = from.getFieldH192(sfShareMPTID); - self.assetsTotal = from.at(sfAssetsTotal); - self.assetsAvailable = from.at(sfAssetsAvailable); - self.assetsMaximum = from.at(sfAssetsMaximum); - self.lossUnrealized = from.at(sfLossUnrealized); + self.assetsTotal = NumberInfo::make(from, sfAssetsTotal, self.asset); + self.assetsAvailable = + NumberInfo::make(from, sfAssetsAvailable, self.asset); + self.assetsMaximum = NumberInfo::make(from, sfAssetsMaximum, self.asset); + self.lossUnrealized = NumberInfo::make(from, sfLossUnrealized, self.asset); return self; } @@ -2413,10 +2436,8 @@ ValidVault::finalize( beforeVault_.empty() || beforeVault_[0].key == afterVault.key, "ripple::ValidVault::finalize : single vault operation"); - if (!afterVault.assetsTotal.representable() || - !afterVault.assetsAvailable.representable() || - !afterVault.assetsMaximum.representable() || - !afterVault.lossUnrealized.representable()) + if (!afterVault.assetsTotal.valid || !afterVault.assetsAvailable.valid || + !afterVault.assetsMaximum.valid || !afterVault.lossUnrealized.valid) { JLOG(j.fatal()) << "Invariant failed: vault overflowed maximum current " "representable integer value"; @@ -2500,7 +2521,7 @@ ValidVault::finalize( result = false; } - if (afterVault.assetsAvailable > afterVault.assetsTotal) + if (afterVault.assetsAvailable.n > afterVault.assetsTotal) { JLOG(j.fatal()) << "Invariant failed: assets available must " "not be greater than assets outstanding"; @@ -2541,7 +2562,7 @@ ValidVault::finalize( } if (!beforeVault_.empty() && - afterVault.lossUnrealized != beforeVault_[0].lossUnrealized) + afterVault.lossUnrealized.n != beforeVault_[0].lossUnrealized) { JLOG(j.fatal()) << // "Invariant failed: vault transaction must not change loss " @@ -2711,7 +2732,7 @@ ValidVault::finalize( result = false; } - if (beforeVault.assetsTotal != afterVault.assetsTotal) + if (beforeVault.assetsTotal.n != afterVault.assetsTotal) { JLOG(j.fatal()) << // "Invariant failed: set must not change assets " @@ -2720,7 +2741,7 @@ ValidVault::finalize( } if (afterVault.assetsMaximum > zero && - afterVault.assetsTotal > afterVault.assetsMaximum) + afterVault.assetsTotal.n > afterVault.assetsMaximum) { JLOG(j.fatal()) << // "Invariant failed: set assets outstanding must not " @@ -2728,7 +2749,7 @@ ValidVault::finalize( result = false; } - if (beforeVault.assetsAvailable != afterVault.assetsAvailable) + if (beforeVault.assetsAvailable.n != afterVault.assetsAvailable) { JLOG(j.fatal()) << // "Invariant failed: set must not change assets " @@ -2816,7 +2837,7 @@ ValidVault::finalize( } if (afterVault.assetsMaximum > zero && - afterVault.assetsTotal > afterVault.assetsMaximum) + afterVault.assetsTotal.n > afterVault.assetsMaximum) { JLOG(j.fatal()) << // "Invariant failed: deposit assets outstanding must not " diff --git a/src/xrpld/app/tx/detail/InvariantCheck.h b/src/xrpld/app/tx/detail/InvariantCheck.h index f9b37f03ed..24b9e6c56b 100644 --- a/src/xrpld/app/tx/detail/InvariantCheck.h +++ b/src/xrpld/app/tx/detail/InvariantCheck.h @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -738,16 +739,38 @@ class ValidVault { Number static constexpr zero{}; + struct Vault; + + struct NumberInfo final + { + Number n; + bool valid; + + // Make this Number wrapper as transparent as possible, except when + // checking validity. However, rather than fleshing out all the + // comparison operators, etc, a few places will still need to specify + // "n". + operator Number const&() const; + + private: + friend class ValidVault::Vault; + + NumberInfo static make( + SLE const& from, + SF_NUMBER const& field, + Asset const& asset); + }; + struct Vault final { uint256 key = beast::zero; Asset asset = {}; AccountID pseudoId = {}; uint192 shareMPTID = beast::zero; - Number assetsTotal = 0; - Number assetsAvailable = 0; - Number assetsMaximum = 0; - Number lossUnrealized = 0; + NumberInfo assetsTotal{0, true}; + NumberInfo assetsAvailable{0, true}; + NumberInfo assetsMaximum{0, true}; + NumberInfo lossUnrealized{0, true}; Vault static make(SLE const&); }; diff --git a/src/xrpld/app/tx/detail/VaultClawback.cpp b/src/xrpld/app/tx/detail/VaultClawback.cpp index f8879bcfa2..7c56ca1d60 100644 --- a/src/xrpld/app/tx/detail/VaultClawback.cpp +++ b/src/xrpld/app/tx/detail/VaultClawback.cpp @@ -71,13 +71,9 @@ VaultClawback::preclaim(PreclaimContext const& ctx) } Asset const vaultAsset = vault->at(sfAsset); - if (auto const amount = ctx.tx[~sfAmount]) - { - if (vaultAsset != amount->asset()) - return tecWRONG_ASSET; - else if (!amount->validNumber()) - return tecPRECISION_LOSS; - } + if (auto const amount = ctx.tx[~sfAmount]; + amount && vaultAsset != amount->asset()) + return tecWRONG_ASSET; if (vaultAsset.native()) { diff --git a/src/xrpld/app/tx/detail/VaultCreate.cpp b/src/xrpld/app/tx/detail/VaultCreate.cpp index 400c36f1b0..4fa66af724 100644 --- a/src/xrpld/app/tx/detail/VaultCreate.cpp +++ b/src/xrpld/app/tx/detail/VaultCreate.cpp @@ -193,7 +193,28 @@ VaultCreate::doApply() vault->at(sfLossUnrealized) = Number(0); // Leave default values for AssetTotal and AssetAvailable, both zero. if (auto value = tx[~sfAssetsMaximum]) - vault->at(sfAssetsMaximum) = *value; + { + auto assetsMaximumProxy = vault->at(~sfAssetsMaximum); + assetsMaximumProxy = *value; + if (auto const stNumber = assetsMaximumProxy.stValue(); + stNumber && !stNumber->validNumber(asset)) + { + JLOG(j_.warn()) << "VaultCreate: Invalid assets maximum value for " + "integral asset type: " + << *value << " > " << STNumber::validNumberLimit(); + return tecPRECISION_LOSS; + } + } + // TODO: Should integral types automatically set a limit to the + // Number::validNumberLimit() value? Or safeNumberLimit()? + /* + else if (asset.integral()) + { + auto assetsMaximumProxy = vault->at(~sfAssetsMaximum); + assetsMaximumProxy = STNumber::validNumberLimit(); + assetsMaximumProxy.stValue()->usesAsset(asset); + } + */ vault->at(sfShareMPTID) = mptIssuanceID; if (auto value = tx[~sfData]) vault->at(sfData) = *value; @@ -204,13 +225,6 @@ VaultCreate::doApply() vault->at(sfWithdrawalPolicy) = vaultStrategyFirstComeFirstServe; if (scale) vault->at(sfScale) = scale; - if (asset.integral()) - { - // Only the Maximum can be a non-zero value, so only it needs to be - // checked. - if (!vault->at(sfAssetsMaximum).value().valid(Number::compatible)) - return tecLIMIT_EXCEEDED; - } view().insert(vault); // Explicitly create MPToken for the vault owner diff --git a/src/xrpld/app/tx/detail/VaultDeposit.cpp b/src/xrpld/app/tx/detail/VaultDeposit.cpp index b39da91aed..46c42d4efe 100644 --- a/src/xrpld/app/tx/detail/VaultDeposit.cpp +++ b/src/xrpld/app/tx/detail/VaultDeposit.cpp @@ -42,9 +42,6 @@ VaultDeposit::preclaim(PreclaimContext const& ctx) if (assets.asset() != vaultAsset) return tecWRONG_ASSET; - if (!assets.validNumber()) - return tecPRECISION_LOSS; - if (vaultAsset.native()) ; // No special checks for XRP else if (vaultAsset.holds()) @@ -230,14 +227,14 @@ VaultDeposit::doApply() return tecINTERNAL; // LCOV_EXCL_LINE sharesCreated = *maybeShares; } - if (sharesCreated == beast::zero || !sharesCreated.validNumber()) + if (sharesCreated == beast::zero) return tecPRECISION_LOSS; auto const maybeAssets = sharesToAssetsDeposit(vault, sleIssuance, sharesCreated); if (!maybeAssets) return tecINTERNAL; // LCOV_EXCL_LINE - else if (*maybeAssets > amount || !maybeAssets->validNumber()) + else if (*maybeAssets > amount) { // LCOV_EXCL_START JLOG(j_.error()) << "VaultDeposit: would take more than offered."; @@ -263,13 +260,43 @@ VaultDeposit::doApply() sharesCreated.asset() != assetsDeposited.asset(), "ripple::VaultDeposit::doApply : assets are not shares"); - vault->at(sfAssetsTotal) += assetsDeposited; - vault->at(sfAssetsAvailable) += assetsDeposited; + auto assetsTotalProxy = vault->at(sfAssetsTotal); + auto assetsAvailableProxy = vault->at(sfAssetsAvailable); + + assetsTotalProxy += assetsDeposited; + assetsAvailableProxy += assetsDeposited; view().update(vault); + auto const asset = *vault->at(sfAsset); + if (auto stNumber = assetsTotalProxy.stValue(); + stNumber && !stNumber->safeNumber(asset)) + { + JLOG(j_.warn()) << "VaultDeposit: Invalid assets total value for " + "integral asset type: " + << *assetsTotalProxy << " > " + << STNumber::safeNumberLimit(); + return tecPRECISION_LOSS; + } + if (auto stNumber = assetsAvailableProxy.stValue(); + stNumber && !stNumber->safeNumber(asset)) + { + // LCOV_EXCL_START + // This should be impossible to reach because total should never be less + // than available, so if total is ok, available should be ok. + UNREACHABLE( + "ripple::VaultDeposit::doApply() : AssetsAvailable exceeds " + "AssetsTotal"); + JLOG(j_.warn()) << "VaultDeposit: Invalid assets available value for " + "integral asset type: " + << *assetsAvailableProxy << " > " + << STNumber::safeNumberLimit(); + return tecPRECISION_LOSS; + // LCOV_EXCL_STOP + } + // A deposit must not push the vault over its limit. auto const maximum = *vault->at(sfAssetsMaximum); - if (maximum != 0 && *vault->at(sfAssetsTotal) > maximum) + if (maximum != 0 && *assetsTotalProxy > maximum) return tecLIMIT_EXCEEDED; // Transfer assets from depositor to vault. diff --git a/src/xrpld/app/tx/detail/VaultSet.cpp b/src/xrpld/app/tx/detail/VaultSet.cpp index 75f1689d7f..d96e464c17 100644 --- a/src/xrpld/app/tx/detail/VaultSet.cpp +++ b/src/xrpld/app/tx/detail/VaultSet.cpp @@ -143,11 +143,18 @@ VaultSet::doApply() if (tx[sfAssetsMaximum] != 0 && tx[sfAssetsMaximum] < *vault->at(sfAssetsTotal)) return tecLIMIT_EXCEEDED; - vault->at(sfAssetsMaximum) = tx[sfAssetsMaximum]; - if (vault->at(sfAsset).value().integral()) + auto assetsMaximumProxy = vault->at(~sfAssetsMaximum); + assetsMaximumProxy = tx[sfAssetsMaximum]; + if (auto const stNumber = assetsMaximumProxy.stValue(); + stNumber && !stNumber->validNumber(vault->at(sfAsset))) { - if (!vault->at(sfAssetsMaximum).value().valid(Number::compatible)) - return tecLIMIT_EXCEEDED; + // LCOV_EXCL_START + // This should be impossible, because invalid values would have been + // stopped by `VaultCreate`. + UNREACHABLE( + "ripple::VaultSet::doApply : invalid assets maximum value"); + return tecLIMIT_EXCEEDED; + // LCOV_EXCL_STOP } } diff --git a/src/xrpld/app/tx/detail/VaultWithdraw.cpp b/src/xrpld/app/tx/detail/VaultWithdraw.cpp index ebdcda6c4b..8cd3f5cd97 100644 --- a/src/xrpld/app/tx/detail/VaultWithdraw.cpp +++ b/src/xrpld/app/tx/detail/VaultWithdraw.cpp @@ -174,8 +174,6 @@ VaultWithdraw::doApply() if (!maybeAssets) return tecINTERNAL; // LCOV_EXCL_LINE assetsWithdrawn = *maybeAssets; - if (!assetsWithdrawn.validNumber()) - return tecPRECISION_LOSS; } else if (amount.asset() == share) { @@ -186,8 +184,6 @@ VaultWithdraw::doApply() if (!maybeAssets) return tecINTERNAL; // LCOV_EXCL_LINE assetsWithdrawn = *maybeAssets; - if (!assetsWithdrawn.validNumber()) - return tecPRECISION_LOSS; } else return tefINTERNAL; // LCOV_EXCL_LINE