#include"Boss/Mod/PeerStatistician.hpp" #include"Boss/Msg/CommandFail.hpp" #include"Boss/Msg/CommandRequest.hpp" #include"Boss/Msg/CommandResponse.hpp" #include"Boss/Msg/DbResource.hpp" #include"Boss/Msg/ForwardFee.hpp" #include"Boss/Msg/InternetOnline.hpp" #include"Boss/Msg/ListpeersAnalyzedResult.hpp" #include"Boss/Msg/ManifestCommand.hpp" #include"Boss/Msg/Manifestation.hpp" #include"Boss/Msg/RequestPeerStatistics.hpp" #include"Boss/Msg/ResponsePeerStatistics.hpp" #include"Boss/Msg/SendpayResult.hpp" #include"Boss/Msg/TimerRandomDaily.hpp" #include"Boss/concurrent.hpp" #include"Boss/log.hpp" #include"Ev/Io.hpp" #include"Ev/now.hpp" #include"Ln/NodeId.hpp" #include"S/Bus.hpp" #include"Sha256/Hash.hpp" #include"Sqlite3.hpp" #include"Util/make_unique.hpp" #include /* An important consideration in designing metrics for whether a peer is bad or not, is to ensure that a clever hacker cannot costlessly game those metrics to hurt their competitors, or artificially improve their own. This eliminates one particular class of data: * Payment forwards that failed. This is because this metric is costlessly gameable. If I use, for example, the metric "successful forwards divided by total forwards", then the "total forwards" is the sum of "failed forwards" (which is trivially gameable!) plus "successful forwards". A savvy hacker can then destroy this metric of a competitor by picking random hashes, then sending payments to the competitor, which the competitor cannot claim. This would then be a "failed forward" from my point-of-view, since I cannot read the onion-wrapped error message of the peer. Thus, we can only use: * Data from our own actions, e.g. payments from us, such as from the `SendpayResultMonitor`. Since this is our own action, others cannot game this data. * ***Unless*** they can influence our actions! For example, if we are a custodial service (which is evil, but CLBOSS needs to support custodial services until their operators see the light and stop being evil), then our victims can fight back by creating artificial invoices that have routehints pointing to our peers that they want to attack, but referring to nonexistent channels to nonexistent nodes. The victim then asks us to withdraw funds using this artificial invoice. The peer would have to fail those invoices, and that would artificially reduce reasonable metrics of that peer. * So we support a `clboss-externpay` command, which you have to issue *before* performing a `pay` from an invoice provided by one of your victims. This tells CLBOSS that a `pay` command with specific hash (which you can extract by `decodepay`) was not performed by your own volition (i.e. third-party gameable, i.e. "external") and should be ignored by the peer statistician. * Fees earned from successful forwards. * Number of successful forwards is gameable, by paying tiny inconsequential amounts. * Actual substantial fees on successful forwards are effectively bribes by the "attacker" for us to maintain channels to our peers. Since our entire point for existence is to earn money by maintaining channels with our peers, such subsidy would be something we want to keep on coming in, at least until the attacker runs out of subsidy. i.e. it is "free money" for us, so sure, we will play along and keep on getting free money. Another wrinkle here is that we count a payment we initiated as a "success" if it reached the destination at all. Even if the payment failed at the destination, this module still counts that as a success. After all, this module is responsible for evaluating peers for how good they are at getting payments sent to our destinations, and any problems at the destination are definitely not the fault of the peer. For example, consider an MPP payment. Some number of payments reached the destination via a particularly "good" peer, but because some of our *other* peers are unreliable, the entire payment fails because the sub-payments sent via the other unreliable peers failed. We should still count the peer that managed to get their sub-payment to the destination as "good". This also allows us to work with active probing modules without requiring specific support for those. Active probing simply means generating routes to random nodes on the network, getting a random hash, and trying to reach the node via the route using the random hash. If the trial payment reaches the destination, that means the route to it is fairly reliable, and also that direct peer on that route is probably fairly reliable. */ namespace { /* Entries older than this number of seconds are deleted. */ auto const max_entry_age = double(3 * 30 * 24 * 60 * 60); } namespace Boss { namespace Mod { class PeerStatistician::Impl { private: S::Bus& bus; Sqlite3::Db db; bool online; void start() { online = false; bus.subscribe([this](Msg::InternetOnline const& m) { online = m.online; return Ev::lift(); }); bus.subscribe([this](Msg::DbResource const& m) { db = m.db; return init_db() + clean_entries(); }); bus.subscribe([this](Msg::TimerRandomDaily const& m) { if (!db) return Ev::lift(); return clean_entries(); }); bus.subscribe([this](Msg::SendpayResult const& r) { if (!db) return Ev::lift(); return add_sendpayresult(r); }); bus.subscribe([this](Msg::ListpeersAnalyzedResult const& r) { if (!db) return Ev::lift(); /* At initial startup, we have not connected to any * peers yet, and if we are at "initial", then that * means that it was *us* that was offline, so we * should not blame (lower the score of) the peers. */ if (r.initial) return Ev::lift(); /* Similarly, if *we* are offline, do not demerit * our peers! */ if (!online) return Ev::lift(); return db.transact().then([this, r](Sqlite3::Tx tx) { /* We also gather all channeled peers to a * new set. */ auto all_channeled = std::set(); for (auto const& n : r.connected_channeled) { add_connection(tx, n, true); all_channeled.insert(n); } for (auto const& n : r.disconnected_channeled ) { add_connection(tx, n, false); all_channeled.insert(n); } /* Now destroy data for peers without * channels. */ destroy_unchanneled(tx, all_channeled); tx.commit(); return Ev::lift(); }); }); bus.subscribe([this](Msg::ForwardFee const& m) { if (!db) return Ev::lift(); return add_forwardfee(m); }); bus.subscribe([this](Msg::RequestPeerStatistics const& r) { return Boss::concurrent( wait_db_available() + get_stats(r) ); }); bus.subscribe([this](Msg::Manifestation const& _) { return bus.raise(Msg::ManifestCommand{ "clboss-externpay", "payment_hash", "Inform CLBOSS that a future `pay` command " "with the given `payment_hash` is controlled " "by a third party that might have incentive " "to damage the metrics of our peers. " "Relevant only to custodial services.", false }); }); bus.subscribe([this](Msg::CommandRequest const& c) { if (c.command != "clboss-externpay") return Ev::lift(); auto hash_j = Jsmn::Object(); if (c.params.is_array() && c.params.size() == 1) hash_j = c.params[0]; else if ( c.params.is_object() && c.params.has("payment_hash") ) hash_j = c.params["payment_hash"]; if ( !hash_j.is_string() || !Sha256::Hash::valid_string(std::string(hash_j)) ) return bus.raise(Msg::CommandFail{ c.id, -32602, "Need valid `payment_hash` parameter", Json::Out::empty_object() }); auto hash = Sha256::Hash(std::string(hash_j)); auto act = wait_db_available() + externpay(hash) + bus.raise(Msg::CommandResponse{ c.id, Json::Out::empty_object() }) ; return Boss::concurrent(act); }); } Ev::Io init_db() { return db.transact().then([](Sqlite3::Tx tx) { tx.query_execute(R"QRY( -- Table of when we first started recording -- information about a peer. -- When measuring a particular time period, -- if the period starts before the recorded -- information here, we adjust the start time -- to the creation time of the peer. CREATE TABLE IF NOT EXISTS "PeerStatistician_peers" ( id TEXT PRIMARY KEY -- time this entry was created. , creation REAL NOT NULL ); CREATE TABLE IF NOT EXISTS "PeerStatistician_sendpayresults" -- peer ( id TEXT NOT NULL REFERENCES "PeerStatistician_peers"(id) ON DELETE CASCADE ON UPDATE RESTRICT -- time this entry was created. -- We only maintain entries for 3 months -- to bound our data usage. , creation REAL NOT NULL -- Amount of wallclock time that our -- funds were locked in the sendpay or -- sendonion. , lockrealtime REAL NOT NULL -- Whether this attempt reached the -- destination or not. , destination_reached INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS "PeerStatistician_sendpayresults_timeidx" ON "PeerStatistician_sendpayresults" (creation); CREATE INDEX IF NOT EXISTS "PeerStatistician_sendpayresults_ididx" ON "PeerStatistician_sendpayresults" (id); CREATE TABLE IF NOT EXISTS "PeerStatistician_connection" ( id TEXT NOT NULL REFERENCES "PeerStatistician_peers"(id) ON DELETE CASCADE ON UPDATE RESTRICT , creation REAL NOT NULL -- Whether it was connected at the time. , connected INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS "PeerStatistician_connection_timeidx" ON "PeerStatistician_connection" (creation); CREATE INDEX IF NOT EXISTS "PeerStatistician_connection_ididx" ON "PeerStatistician_connection" (id); CREATE TABLE IF NOT EXISTS "PeerStatistician_forwardfees" -- incoming peer. ( in_id TEXT NOT NULL REFERENCES "PeerStatistician_peers"(id) ON DELETE CASCADE ON UPDATE RESTRICT -- outgoing peer. , out_id TEXT NOT NULL REFERENCES "PeerStatistician_peers"(id) ON DELETE CASCADE ON UPDATE RESTRICT -- time this *successful* forwarding -- event was recorded. , creation REAL NOT NULL -- millisatoshis actually earned. , fee INTEGER NOT NULL -- Time it took for this forwarding event -- to be finished. , resolution_time REAL NOT NULL ); CREATE INDEX IF NOT EXISTS "PeerStatistician_forwardfees_timeidx" ON "PeerStatistician_forwardfees" (creation); CREATE INDEX IF NOT EXISTS "PeerStatistician_forwardfees_inididx" ON "PeerStatistician_forwardfees" (in_id); CREATE INDEX IF NOT EXISTS "PeerStatistician_forwardfees_outididx" ON "PeerStatistician_forwardfees" (out_id); CREATE TABLE IF NOT EXISTS "PeerStatistician_externpays" ( hash TEXT PRIMARY KEY , creation REAL NOT NULL ); CREATE INDEX IF NOT EXISTS "PeerStatistician_externpays_timeidx" ON "PeerStatistician_externpays" (creation); )QRY"); /* Here are the things we want to derive from the above data: FROM "PeerStatistician_sendpaytries" lockrealtime = SUM(lockrealtime) tries = COUNT(*) reaches = COUNT(*) WHERE destination_reached = 1 FROM "PeerStatistician_connection" conns = COUNT(*) WHERE connected = 1 connchecks = COUNT(*) FROM "PeerStatistician_forwardfees" fees = SUM(fee) -- Then here are metrics `realtime_per_attempt` lockrealtime / tries - average time that a payment through the node takes. Bad if this is high. `success_per_attempt` reaches / tries - reliability of payments through the node, i.e. rate that we can expect the payment to actually reach the destination. Bad if this is low. `success_per_second` reaches / TIME - how often we found this node useful in reaching the destination. Bad if this is low. `connected_rate` conns / connchecks - how often we found the peer to be connected with us. Bad if this is even much less than 100%. `fee_msat_per_second` fees / TIME - earning rate from the peer. Good if this is high. */ tx.commit(); return Ev::lift(); }); } /* Cleans up old entries. */ Ev::Io clean_entries() { return db.transact().then([](Sqlite3::Tx tx) { tx.query(R"QRY( DELETE FROM "PeerStatistician_sendpayresults" WHERE creation + :max_age < :now ; )QRY") .bind(":max_age", max_entry_age) .bind(":now", Ev::now()) .execute(); tx.query(R"QRY( DELETE FROM "PeerStatistician_connection" WHERE creation + :max_age < :now ; )QRY") .bind(":max_age", max_entry_age) .bind(":now", Ev::now()) .execute(); tx.query(R"QRY( DELETE FROM "PeerStatistician_forwardfees" WHERE creation + :max_age < :now ; )QRY") .bind(":max_age", max_entry_age) .bind(":now", Ev::now()) .execute(); tx.query(R"QRY( DELETE FROM "PeerStatistician_externpays" WHERE creation + :max_age < :now ; )QRY") .bind(":max_age", max_entry_age) .bind(":now", Ev::now()) .execute(); tx.commit(); return Ev::lift(); }); } /* Creates an entry in PeerStatistician_peers. */ void make_entry(Sqlite3::Tx& tx, Ln::NodeId const& id) { tx.query(R"QRY( INSERT OR IGNORE INTO "PeerStatistician_peers" VALUES( :id , :now ); )QRY") .bind(":id", std::string(id)) .bind(":now", Ev::now()) .execute(); } Ev::Io add_sendpayresult(Msg::SendpayResult const& r) { auto id = r.first_hop; auto creation = Ev::now(); auto lockrealtime = r.completion_time - r.creation_time; auto payment_hash = r.payment_hash; auto destination_reached = r.reached_destination; return db.transact().then([ this , id , creation , lockrealtime , payment_hash , destination_reached ](Sqlite3::Tx tx) { auto check = tx.query(R"QRY( SELECT 1 FROM "PeerStatistician_externpays" WHERE hash = :hash ; )QRY") .bind(":hash", std::string(payment_hash)) .execute() ; auto in_externpays = false; for (auto& r : check) { (void) r; in_externpays = true; } if (in_externpays) return Boss::log( bus, Debug , "PeerStatistician: Ignore " "result of external " "payment hash %s." , std::string(payment_hash) .c_str() ); make_entry(tx, id); tx.query(R"QRY( INSERT INTO "PeerStatistician_sendpayresults" VALUES( :id , :creation , :lockrealtime , :destination_reached ); )QRY") .bind(":id", std::string(id)) .bind(":creation", creation) .bind(":lockrealtime", lockrealtime) .bind( ":destination_reached" , destination_reached ? 1 : 0 ) .execute(); tx.commit(); return Ev::lift(); }); } void add_connection( Sqlite3::Tx& tx , Ln::NodeId const& id , bool connected ) { make_entry(tx, id); tx.query(R"QRY( INSERT INTO "PeerStatistician_connection" VALUES( :id , :creation , :connected ); )QRY") .bind(":id", std::string(id)) .bind(":creation", Ev::now()) .bind(":connected", connected) .execute(); } void destroy_unchanneled( Sqlite3::Tx& tx , std::set const& channeled ) { auto to_del = std::vector(); /* Gather all nodes in our db. */ auto fetch = tx.query(R"QRY( SELECT id FROM "PeerStatistician_peers"; )QRY").execute(); for (auto& r : fetch) { auto peer = Ln::NodeId(r.get(0)); auto it = channeled.find(peer); if (it == channeled.end()) to_del.push_back(peer); } /* And delete. */ for (auto const& p : to_del) { /* This also deletes entries from the other * tables by foreign key constraint. */ tx.query(R"QRY( DELETE FROM "PeerStatistician_peers" WHERE id = :id ; )QRY") .bind(":id", std::string(p)) .execute(); } } Ev::Io add_forwardfee(Msg::ForwardFee const& m) { return db.transact().then([this, m](Sqlite3::Tx tx) { make_entry(tx, m.in_id); make_entry(tx, m.out_id); tx.query(R"QRY( INSERT INTO "PeerStatistician_forwardfees" VALUES( :in_id , :out_id , :creation , :fee , :resolution_time ); )QRY") .bind(":in_id", std::string(m.in_id)) .bind(":out_id", std::string(m.out_id)) .bind(":creation", Ev::now()) .bind(":fee", m.fee.to_msat()) .bind(":resolution_time", m.resolution_time) .execute(); tx.commit(); return Ev::lift(); }); } Ev::Io wait_db_available() { return Ev::lift().then([this]() { if (db) return Ev::lift(); return Ev::yield() + wait_db_available(); }); } Ev::Io get_stats(Msg::RequestPeerStatistics const& r) { return db.transact().then([this, r](Sqlite3::Tx tx) { auto data = std::map< Ln::NodeId , Msg::PeerStatistics >(); auto start_time = r.start_time; auto end_time = r.end_time; auto sendpays = tx.query(R"QRY( SELECT id, lockrealtime, destination_reached FROM "PeerStatistician_sendpayresults" WHERE :start_time <= creation AND creation <= :end_time ; )QRY") .bind(":start_time", start_time) .bind(":end_time", end_time) .execute() ; for (auto& r : sendpays) { auto node = Ln::NodeId(r.get(0)); auto& entry = get_entry( tx, data, node , start_time, end_time ); entry.lockrealtime += r.get(1); ++entry.attempts; if (0 != r.get(2)) ++entry.successes; } auto connects = tx.query(R"QRY( SELECT id, connected FROM "PeerStatistician_connection" WHERE :start_time <= creation AND creation <= :end_time ; )QRY") .bind(":start_time", start_time) .bind(":end_time", end_time) .execute() ; for (auto& r : connects) { auto node = Ln::NodeId(r.get(0)); auto& entry = get_entry( tx, data, node , start_time, end_time ); ++entry.connect_checks; if (0 != r.get(1)) ++entry.connects; } auto fees = tx.query(R"QRY( SELECT in_id, out_id, fee FROM "PeerStatistician_forwardfees" WHERE :start_time <= creation AND creation <= :end_time ; )QRY") .bind(":start_time", start_time) .bind(":end_time", end_time) .execute() ; for (auto& r : fees) { auto in = Ln::NodeId(r.get(0)); auto& in_e = get_entry( tx, data, in , start_time, end_time ); in_e.in_fee += Ln::Amount::msat( r.get(2) ); auto out = Ln::NodeId(r.get(1)); auto& out_e = get_entry( tx, data, out , start_time, end_time ); out_e.out_fee += Ln::Amount::msat( r.get(2) ); } tx.commit(); return bus.raise(Msg::ResponsePeerStatistics{ r.requester, std::move(data) }); }); } /* Used by get_stats above, to ensure that the given node * has an entry in the given peer statistics data. * If not yet, we initialize it and the appropriate time * as well. */ Msg::PeerStatistics& get_entry( Sqlite3::Tx& tx , std::map& data , Ln::NodeId const& node , double start_time , double end_time ) { auto it = data.find(node); if (it != data.end()) return it->second; auto& entry = data[node]; auto fetch = tx.query(R"QRY( SELECT creation FROM "PeerStatistician_peers" WHERE id = :id ; )QRY") .bind(":id", std::string(node)) .execute() ; auto node_start = double(); for (auto& r : fetch) node_start = r.get(0); if (start_time < node_start) start_time = node_start; entry.start_time = start_time; entry.end_time = end_time; entry.age = Ev::now() - node_start; /* Initialize the statistics. */ entry.lockrealtime = 0; entry.attempts = 0; entry.successes = 0; entry.connects = 0; entry.connect_checks = 0; entry.in_fee = Ln::Amount::sat(0); entry.out_fee = Ln::Amount::sat(0); return entry; } /* Called by `clboss-externpay` command. */ Ev::Io externpay(Sha256::Hash const& hash) { return db.transact().then([hash](Sqlite3::Tx tx) { tx.query(R"QRY( INSERT OR REPLACE INTO "PeerStatistician_externpays" VALUES( :hash , :creation ); )QRY") .bind(":hash", std::string(hash)) .bind(":creation", Ev::now()) .execute() ; tx.commit(); return Ev::lift(); }); } public: Impl() =delete; explicit Impl(S::Bus& bus_) : bus(bus_) { start(); } }; PeerStatistician::PeerStatistician(PeerStatistician&&) =default; PeerStatistician::~PeerStatistician() =default; PeerStatistician::PeerStatistician(S::Bus& bus) : pimpl(Util::make_unique(bus)) { } }}