mirror of
https://github.com/XRPLF/rippled.git
synced 2025-12-06 17:27:55 +00:00
Merge branch 'new_pathfinding' of github.com:jedmccaleb/NewCoin into develop
This commit is contained in:
@@ -50,7 +50,8 @@ Application::Application() :
|
||||
mIOWork(mIOService), mAuxWork(mAuxService), mUNL(mIOService), mNetOps(mIOService, &mLedgerMaster),
|
||||
mTempNodeCache("NodeCache", 16384, 90), mHashedObjectStore(16384, 300), mSLECache("LedgerEntryCache", 4096, 120),
|
||||
mSNTPClient(mAuxService), mJobQueue(mIOService), mFeeTrack(),
|
||||
mRpcDB(NULL), mTxnDB(NULL), mLedgerDB(NULL), mWalletDB(NULL), mHashNodeDB(NULL), mNetNodeDB(NULL),
|
||||
mRpcDB(NULL), mTxnDB(NULL), mLedgerDB(NULL), mWalletDB(NULL),
|
||||
mHashNodeDB(NULL), mNetNodeDB(NULL), mPathFindDB(NULL),
|
||||
mConnectionPool(mIOService), mPeerDoor(NULL), mRPCDoor(NULL), mWSPublicDoor(NULL), mWSPrivateDoor(NULL),
|
||||
mSweepTimer(mAuxService), mShutdown(false)
|
||||
{
|
||||
@@ -58,8 +59,10 @@ Application::Application() :
|
||||
getRand(reinterpret_cast<unsigned char *>(&mNonceST), sizeof(mNonceST));
|
||||
}
|
||||
|
||||
extern const char *RpcDBInit[], *TxnDBInit[], *LedgerDBInit[], *WalletDBInit[], *HashNodeDBInit[], *NetNodeDBInit[];
|
||||
extern int RpcDBCount, TxnDBCount, LedgerDBCount, WalletDBCount, HashNodeDBCount, NetNodeDBCount;
|
||||
extern const char *RpcDBInit[], *TxnDBInit[], *LedgerDBInit[], *WalletDBInit[], *HashNodeDBInit[],
|
||||
*NetNodeDBInit[], *PathFindDBInit[];
|
||||
extern int RpcDBCount, TxnDBCount, LedgerDBCount, WalletDBCount, HashNodeDBCount,
|
||||
NetNodeDBCount, PathFindDBCount;
|
||||
bool Instance::running = true;
|
||||
|
||||
void Application::stop()
|
||||
@@ -135,10 +138,14 @@ void Application::setup()
|
||||
boost::thread t1(boost::bind(&InitDB, &mRpcDB, "rpc.db", RpcDBInit, RpcDBCount));
|
||||
boost::thread t2(boost::bind(&InitDB, &mTxnDB, "transaction.db", TxnDBInit, TxnDBCount));
|
||||
boost::thread t3(boost::bind(&InitDB, &mLedgerDB, "ledger.db", LedgerDBInit, LedgerDBCount));
|
||||
t1.join(); t2.join(); t3.join();
|
||||
|
||||
boost::thread t4(boost::bind(&InitDB, &mWalletDB, "wallet.db", WalletDBInit, WalletDBCount));
|
||||
boost::thread t5(boost::bind(&InitDB, &mHashNodeDB, "hashnode.db", HashNodeDBInit, HashNodeDBCount));
|
||||
boost::thread t6(boost::bind(&InitDB, &mNetNodeDB, "netnode.db", NetNodeDBInit, NetNodeDBCount));
|
||||
t1.join(); t2.join(); t3.join(); t4.join(); t5.join(); t6.join();
|
||||
boost::thread t7(boost::bind(&InitDB, &mPathFindDB, "pathfind.db", PathFindDBInit, PathFindDBCount));
|
||||
t4.join(); t5.join(); t6.join(); t7.join();
|
||||
|
||||
mTxnDB->getDB()->setupCheckpointing(&mJobQueue);
|
||||
mLedgerDB->getDB()->setupCheckpointing(&mJobQueue);
|
||||
mHashNodeDB->getDB()->setupCheckpointing(&mJobQueue);
|
||||
@@ -340,6 +347,7 @@ Application::~Application()
|
||||
delete mWalletDB;
|
||||
delete mHashNodeDB;
|
||||
delete mNetNodeDB;
|
||||
delete mPathFindDB;
|
||||
}
|
||||
|
||||
void Application::startNewLedger()
|
||||
|
||||
@@ -72,7 +72,7 @@ class Application
|
||||
TXQueue mTxnQueue;
|
||||
OrderBookDB mOrderBookDB;
|
||||
|
||||
DatabaseCon *mRpcDB, *mTxnDB, *mLedgerDB, *mWalletDB, *mHashNodeDB, *mNetNodeDB;
|
||||
DatabaseCon *mRpcDB, *mTxnDB, *mLedgerDB, *mWalletDB, *mHashNodeDB, *mNetNodeDB, *mPathFindDB;
|
||||
|
||||
ConnectionPool mConnectionPool;
|
||||
PeerDoor* mPeerDoor;
|
||||
@@ -139,6 +139,7 @@ public:
|
||||
DatabaseCon* getWalletDB() { return mWalletDB; }
|
||||
DatabaseCon* getHashNodeDB() { return mHashNodeDB; }
|
||||
DatabaseCon* getNetNodeDB() { return mNetNodeDB; }
|
||||
DatabaseCon* getPathFindDB() { return mPathFindDB; }
|
||||
|
||||
uint256 getNonce256() { return mNonce256; }
|
||||
std::size_t getNonceST() { return mNonceST; }
|
||||
|
||||
@@ -294,7 +294,38 @@ const char *NetNodeDBInit[] = {
|
||||
);"
|
||||
};
|
||||
|
||||
|
||||
int NetNodeDBCount = NUMBER(NetNodeDBInit);
|
||||
|
||||
const char *PathFindDBInit[] = {
|
||||
"PRAGMA synchronous = OFF; ",
|
||||
|
||||
"DROP TABLE TrustLines; ",
|
||||
|
||||
"CREATE TABLE TrustLines { "
|
||||
"To CHARACTER(40), " // Hex of account trusted
|
||||
"By CHARACTER(40), " // Hex of account trusting
|
||||
"Currency CHARACTER(80), " // Hex currency, hex issuer
|
||||
"Use INTEGER, " // Use count
|
||||
"Seq BIGINT UNSIGNED " // Sequence when use count was updated
|
||||
"}; ",
|
||||
|
||||
"CREATE INDEX TLBy ON TrustLines(By, Currency, Use);",
|
||||
"CREATE INDEX TLTo ON TrustLines(To, Currency, Use);",
|
||||
|
||||
"DROP TABLE Exchanges;",
|
||||
|
||||
"CREATE TABLE Exchanges { "
|
||||
"From CHARACTER(80), "
|
||||
"To CHARACTER(80), "
|
||||
"Currency CHARACTER(80), "
|
||||
"Use INTEGER, "
|
||||
"Seq BIGINT UNSIGNED "
|
||||
"}; ",
|
||||
|
||||
"CREATE INDEX ExBy ON Exchanges(By, Currency, Use);",
|
||||
"CREATE INDEX ExTo ON Exchanges(To, Currency, Use);",
|
||||
};
|
||||
|
||||
int PathFindDBCount = NUMBER(PathFindDBInit);
|
||||
|
||||
// vim:ts=4
|
||||
|
||||
@@ -17,7 +17,6 @@ void OrderBookDB::invalidate()
|
||||
mSeq = 0;
|
||||
}
|
||||
|
||||
// TODO: this would be way faster if we could just look under the order dirs
|
||||
void OrderBookDB::setup(Ledger::ref ledger)
|
||||
{
|
||||
boost::unordered_set<uint256> mSeen;
|
||||
@@ -30,14 +29,13 @@ void OrderBookDB::setup(Ledger::ref ledger)
|
||||
|
||||
LoadEvent::autoptr ev = theApp->getJobQueue().getLoadEventAP(jtOB_SETUP, "OrderBookDB::setup");
|
||||
|
||||
mXRPOrders.clear();
|
||||
mIssuerMap.clear();
|
||||
|
||||
// walk through the entire ledger looking for orderbook entries
|
||||
uint256 currentIndex = ledger->getFirstLedgerIndex();
|
||||
mDestMap.clear();
|
||||
mSourceMap.clear();
|
||||
|
||||
cLog(lsDEBUG) << "OrderBookDB>";
|
||||
|
||||
// walk through the entire ledger looking for orderbook entries
|
||||
uint256 currentIndex = ledger->getFirstLedgerIndex();
|
||||
while (currentIndex.isNonZero())
|
||||
{
|
||||
SLE::pointer entry = ledger->getSLEi(currentIndex);
|
||||
@@ -55,10 +53,8 @@ void OrderBookDB::setup(Ledger::ref ledger)
|
||||
OrderBook::pointer book = boost::make_shared<OrderBook>(boost::cref(index),
|
||||
boost::cref(ci), boost::cref(co), boost::cref(ii), boost::cref(io));
|
||||
|
||||
if (!book->getCurrencyIn()) // XRP
|
||||
mXRPOrders.push_back(book);
|
||||
else
|
||||
mIssuerMap[book->getIssuerIn()].push_back(book);
|
||||
mSourceMap[currencyIssuer_ct(ci, ii)].push_back(book);
|
||||
mDestMap[currencyIssuer_ct(co, io)].push_back(book);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,29 +64,30 @@ void OrderBookDB::setup(Ledger::ref ledger)
|
||||
cLog(lsDEBUG) << "OrderBookDB<";
|
||||
}
|
||||
|
||||
// return list of all orderbooks that want IssuerID
|
||||
std::vector<OrderBook::pointer>& OrderBookDB::getBooks(const uint160& issuerID)
|
||||
// return list of all orderbooks that want this issuerID and currencyID
|
||||
void OrderBookDB::getBooksByTakerPays(const uint160& issuerID, const uint160& currencyID,
|
||||
std::vector<OrderBook::pointer>& bookRet)
|
||||
{
|
||||
boost::recursive_mutex::scoped_lock sl(mLock);
|
||||
boost::unordered_map< uint160, std::vector<OrderBook::pointer> >::iterator it = mIssuerMap.find(issuerID);
|
||||
return (it == mIssuerMap.end())
|
||||
? mEmptyVector
|
||||
: it->second;
|
||||
boost::unordered_map< currencyIssuer_t, std::vector<OrderBook::pointer> >::const_iterator
|
||||
it = mSourceMap.find(currencyIssuer_ct(currencyID, issuerID));
|
||||
if (it != mSourceMap.end())
|
||||
bookRet = it->second;
|
||||
else
|
||||
bookRet.clear();
|
||||
}
|
||||
|
||||
// return list of all orderbooks that want this issuerID and currencyID
|
||||
void OrderBookDB::getBooks(const uint160& issuerID, const uint160& currencyID, std::vector<OrderBook::pointer>& bookRet)
|
||||
// return list of all orderbooks that give this issuerID and currencyID
|
||||
void OrderBookDB::getBooksByTakerGets(const uint160& issuerID, const uint160& currencyID,
|
||||
std::vector<OrderBook::pointer>& bookRet)
|
||||
{
|
||||
boost::recursive_mutex::scoped_lock sl(mLock);
|
||||
boost::unordered_map< uint160, std::vector<OrderBook::pointer> >::iterator it = mIssuerMap.find(issuerID);
|
||||
if (it != mIssuerMap.end())
|
||||
{
|
||||
BOOST_FOREACH(OrderBook::ref book, it->second)
|
||||
{
|
||||
if (book->getCurrencyIn() == currencyID)
|
||||
bookRet.push_back(book);
|
||||
}
|
||||
}
|
||||
boost::unordered_map< currencyIssuer_t, std::vector<OrderBook::pointer> >::const_iterator
|
||||
it = mDestMap.find(currencyIssuer_ct(currencyID, issuerID));
|
||||
if (it != mDestMap.end())
|
||||
bookRet = it->second;
|
||||
else
|
||||
bookRet.clear();
|
||||
}
|
||||
|
||||
BookListeners::pointer OrderBookDB::makeBookListeners(const uint160& currencyPays, const uint160& currencyGets,
|
||||
@@ -133,53 +130,6 @@ BookListeners::pointer OrderBookDB::getBookListeners(const uint160& currencyPays
|
||||
return (*it3).second;
|
||||
}
|
||||
|
||||
/*
|
||||
"CreatedNode" : {
|
||||
"LedgerEntryType" : "Offer",
|
||||
"LedgerIndex" : "F353BF8A7DCE35EA2985596F4C8421E30EF3B9A21618566BFE0ED00B62A8A5AB",
|
||||
"NewFields" : {
|
||||
"Account" : "rB5TihdPbKgMrkFqrqUC3yLdE8hhv4BdeY",
|
||||
"BookDirectory" : "FF26BE244767D0EA9EFD523941439009E4185E4CBB918F714C08E1BC9BF04000",
|
||||
"Sequence" : 112,
|
||||
"TakerGets" : "400000000",
|
||||
"TakerPays" : {
|
||||
"currency" : "BTC",
|
||||
"issuer" : "r3kmLJN5D28dHuH8vZNUZpMC43pEHpaocV",
|
||||
"value" : "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"ModifiedNode" : {
|
||||
"FinalFields" : {
|
||||
"Account" : "rHTxKLzRbniScyQFGMb3NodmxA848W8dKM",
|
||||
"BookDirectory" : "407AF8FFDE71114B1981574FDDA9B0334572D56FC579735B4B0BD7A625405555",
|
||||
"BookNode" : "0000000000000000",
|
||||
"Flags" : 0,
|
||||
"OwnerNode" : "0000000000000000",
|
||||
"Sequence" : 32,
|
||||
"TakerGets" : "149900000000",
|
||||
"TakerPays" : {
|
||||
"currency" : "USD",
|
||||
"issuer" : "r9vbV3EHvXWjSkeQ6CAcYVPGeq7TuiXY2X",
|
||||
"value" : "49.96666666666667"
|
||||
}
|
||||
},
|
||||
"LedgerEntryType" : "Offer",
|
||||
"LedgerIndex" : "C60F8CC514208FA5F7BD03CF1B64B38B7183CD52318FCBBD3726350D4FE693B0",
|
||||
"PreviousFields" : {
|
||||
"TakerGets" : "150000000000",
|
||||
"TakerPays" : {
|
||||
"currency" : "USD",
|
||||
"issuer" : "r9vbV3EHvXWjSkeQ6CAcYVPGeq7TuiXY2X",
|
||||
"value" : "50"
|
||||
}
|
||||
},
|
||||
"PreviousTxnID" : "1A6AAE3F1AC5A8A7554A5ABC395D17FED5BF62CD90181AA8E4315EDFED4EDEB3",
|
||||
"PreviousTxnLgrSeq" : 140734
|
||||
}
|
||||
|
||||
*/
|
||||
// Based on the meta, send the meta to the streams that are listening
|
||||
// We need to determine which streams a given meta effects
|
||||
void OrderBookDB::processTxn(Ledger::ref ledger, const ALTransaction& alTx, Json::Value& jvObj)
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
// But, for now it is probably faster to just generate it each time.
|
||||
//
|
||||
|
||||
typedef std::pair<uint160, uint160> currencyIssuer_t;
|
||||
typedef std::pair<const uint160&, const uint160&> currencyIssuer_ct;
|
||||
|
||||
class BookListeners
|
||||
{
|
||||
boost::unordered_map<uint64, InfoSub::wptr> mListeners;
|
||||
@@ -30,10 +33,8 @@ public:
|
||||
|
||||
class OrderBookDB
|
||||
{
|
||||
std::vector<OrderBook::pointer> mEmptyVector;
|
||||
std::vector<OrderBook::pointer> mXRPOrders;
|
||||
boost::unordered_map<uint160, std::vector<OrderBook::pointer> > mIssuerMap;
|
||||
//std::vector<OrderBook::pointer> mAllOrderBooks;
|
||||
boost::unordered_map< currencyIssuer_t, std::vector<OrderBook::pointer> > mSourceMap; // by ci/ii
|
||||
boost::unordered_map< currencyIssuer_t, std::vector<OrderBook::pointer> > mDestMap; // by co/io
|
||||
|
||||
// issuerPays, issuerGets, currencyPays, currencyGets
|
||||
std::map<uint160, std::map<uint160, std::map<uint160, std::map<uint160, BookListeners::pointer> > > > mListeners;
|
||||
@@ -46,18 +47,11 @@ public:
|
||||
void setup(Ledger::ref ledger);
|
||||
void invalidate();
|
||||
|
||||
// return list of all orderbooks that want XRP
|
||||
std::vector<OrderBook::pointer>& getXRPInBooks(){ return mXRPOrders; }
|
||||
|
||||
// return list of all orderbooks that want IssuerID
|
||||
std::vector<OrderBook::pointer>& getBooks(const uint160& issuerID);
|
||||
|
||||
// return list of all orderbooks that want this issuerID and currencyID
|
||||
void getBooks(const uint160& issuerID, const uint160& currencyID, std::vector<OrderBook::pointer>& bookRet);
|
||||
|
||||
// returns the best rate we can find
|
||||
float getPrice(uint160& currencyPays,uint160& currencyGets);
|
||||
|
||||
void getBooksByTakerPays(const uint160& issuerID, const uint160& currencyID,
|
||||
std::vector<OrderBook::pointer>& bookRet);
|
||||
void getBooksByTakerGets(const uint160& issuerID, const uint160& currencyID,
|
||||
std::vector<OrderBook::pointer>& bookRet);
|
||||
|
||||
BookListeners::pointer getBookListeners(const uint160& currencyPays, const uint160& currencyGets,
|
||||
const uint160& issuerPays, const uint160& issuerGets);
|
||||
@@ -66,7 +60,6 @@ public:
|
||||
|
||||
// see if this txn effects any orderbook
|
||||
void processTxn(Ledger::ref ledger, const ALTransaction& alTx, Json::Value& jvObj);
|
||||
|
||||
};
|
||||
|
||||
#endif
|
||||
|
||||
1
src/cpp/ripple/PathDB.cpp
Normal file
1
src/cpp/ripple/PathDB.cpp
Normal file
@@ -0,0 +1 @@
|
||||
#include "PathDB.h"
|
||||
78
src/cpp/ripple/PathDB.h
Normal file
78
src/cpp/ripple/PathDB.h
Normal file
@@ -0,0 +1,78 @@
|
||||
#ifndef PATHDB__H
|
||||
#define PATHBD__H
|
||||
|
||||
#include "uint256.h"
|
||||
#include "TaggedCache.h"
|
||||
|
||||
typedef std::pair<uint160, uint160> currencyIssuer_t;
|
||||
typedef std::pair<const uint160&, const uint160&> currencyIssuer_ct;
|
||||
|
||||
class PathDBEntry
|
||||
{
|
||||
public:
|
||||
typedef boost::shared_ptr<PathDBEntry> pointer;
|
||||
typedef const pointer& ref;
|
||||
|
||||
const unsigned int sIsExchange = 0x00001;
|
||||
const unsigned int sIsOffer = 0x00002;
|
||||
const unsigned int sIsDirty = 0x10000;
|
||||
|
||||
protected:
|
||||
currencyIssuer_t mIn;
|
||||
currencyIssuer_t mOut;
|
||||
uint32 mLastSeq;
|
||||
int mUseCount;
|
||||
unsigned mFlags;
|
||||
std::size_t mHash;
|
||||
|
||||
public:
|
||||
|
||||
void updateSeq(uint32);
|
||||
|
||||
const uint160& getCurrencyIn() const { return mIn.first; }
|
||||
const uint160& getIssuerIn() const { return mIn.second; }
|
||||
const uint160& getCurrencyOut() const { return mOut.first; }
|
||||
const uint160& getIssuerOut() const { return mOut.second; }
|
||||
|
||||
bool isExchange() const;
|
||||
bool isOffer() const;
|
||||
bool isDirty() const;
|
||||
};
|
||||
|
||||
class PathDB
|
||||
{
|
||||
protected:
|
||||
boost::recursive_mutex mLock;
|
||||
TaggedCache<currencyIssuer_t, PathDBEntry> mFromCache;
|
||||
TaggedCache<currencyIssuer_t, PathDBEntry> mToCache;
|
||||
std::set<PathDBEntry::pointer> mDirtyPaths;
|
||||
|
||||
public:
|
||||
|
||||
PathDB();
|
||||
|
||||
std::vector<PathDBEntry::pointer> getPathsFrom(const uint160& currency, const uint160& issuer,
|
||||
int maxBestPaths = 10, int maxRandPaths = 10);
|
||||
|
||||
std::vector<PathDBEntry::pointer> getPathsTo(const uint160& currency, const uint160& issuer,
|
||||
int maxBestPaths = 10, int maxRandPaths = 10);
|
||||
|
||||
void usedLine(const uint160& currency, const uint160& accountIn, const uint160& accountOut);
|
||||
void usedExchange(const uint160& currencyFrom, const uint160& issuerFrom,
|
||||
const uint160& currencyTo, const uint160& issuerTo);
|
||||
};
|
||||
|
||||
extern std::size_t hash_value(const currencyIssuer_ct& ci)
|
||||
{
|
||||
std::size_t r = hash_value(ci.second);
|
||||
return ci.first.hash_combine(r);
|
||||
}
|
||||
|
||||
static inline std::size_t hash_value(const currencyIssuer_t& ci)
|
||||
{
|
||||
std::size_t r = hash_value(ci.second);
|
||||
return ci.first.hash_combine(r);
|
||||
}
|
||||
|
||||
|
||||
#endif
|
||||
@@ -389,7 +389,9 @@ bool Pathfinder::findPaths(const unsigned int iMaxSteps, const unsigned int iMax
|
||||
// XXX Might restrict the number of times bridging through XRP.
|
||||
|
||||
// Cursor is for XRP, continue with qualifying books: XRP -> non-XRP
|
||||
BOOST_FOREACH(OrderBook::ref book, theApp->getOrderBookDB().getXRPInBooks())
|
||||
std::vector<OrderBook::pointer> xrpBooks;
|
||||
theApp->getOrderBookDB().getBooksByTakerPays(ACCOUNT_XRP, CURRENCY_XRP, xrpBooks);
|
||||
BOOST_FOREACH(OrderBook::ref book, xrpBooks)
|
||||
{
|
||||
// New end is an order book with the currency and issuer.
|
||||
|
||||
@@ -504,11 +506,10 @@ bool Pathfinder::findPaths(const unsigned int iMaxSteps, const unsigned int iMax
|
||||
}
|
||||
}
|
||||
|
||||
// Every book that wants the source currency.
|
||||
std::vector<OrderBook::pointer> books;
|
||||
|
||||
// XXX Need to flip getBooks argument order to be in normal order: currency then issuer.
|
||||
theApp->getOrderBookDB().getBooks(speEnd.mIssuerID, speEnd.mCurrencyID, books);
|
||||
// XXX Flip argument order to norm. (currency, issuer)
|
||||
std::vector<OrderBook::pointer> books;
|
||||
theApp->getOrderBookDB().getBooksByTakerPays(speEnd.mIssuerID, speEnd.mCurrencyID, books);
|
||||
|
||||
BOOST_FOREACH(OrderBook::ref book, books)
|
||||
{
|
||||
@@ -718,7 +719,9 @@ void Pathfinder::addOptions(PathOption::pointer tail)
|
||||
{
|
||||
if (!tail->mCurrencyID)
|
||||
{ // source XRP
|
||||
BOOST_FOREACH(OrderBook::ref book, theApp->getOrderBookDB().getXRPInBooks())
|
||||
std::vector<OrderBook::pointer> xrpBooks;
|
||||
theApp->getOrderBookDB().getBooksByTakerPays(ISSUER_XRP, CURRENCY_XRP, xrpBooks);
|
||||
BOOST_FOREACH(OrderBook::ref book, xrpBooks)
|
||||
{
|
||||
PathOption::pointer pathOption(new PathOption(tail));
|
||||
|
||||
@@ -750,11 +753,10 @@ void Pathfinder::addOptions(PathOption::pointer tail)
|
||||
}
|
||||
}
|
||||
|
||||
// every offer that wants the source currency
|
||||
// Every offer that can take this currency in
|
||||
std::vector<OrderBook::pointer> books;
|
||||
theApp->getOrderBookDB().getBooks(tail->mCurrentAccount, tail->mCurrencyID, books);
|
||||
|
||||
BOOST_FOREACH(OrderBook::ref book,books)
|
||||
theApp->getOrderBookDB().getBooksByTakerPays(tail->mCurrentAccount, tail->mCurrencyID, books);
|
||||
BOOST_FOREACH(OrderBook::ref book, books)
|
||||
{
|
||||
PathOption::pointer pathOption(new PathOption(tail));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user