From b96c7436a8df70a3c918bf98d3995f6de70baa22 Mon Sep 17 00:00:00 2001 From: Ken Sedgwick Date: Mon, 5 Aug 2024 19:11:03 -0700 Subject: [PATCH 1/8] factor parse_json into Jsmn::Object static method This makes it easier to generate test cases by using literal JSON. --- Jsmn/Object.cpp | 7 +++++++ Jsmn/Object.hpp | 2 ++ tests/boss/test_forwardfeemonitor.cpp | 13 +++---------- tests/boss/test_jitrebalancer.cpp | 11 ++--------- 4 files changed, 14 insertions(+), 19 deletions(-) diff --git a/Jsmn/Object.cpp b/Jsmn/Object.cpp index 8333f604c..c11dd1cbe 100644 --- a/Jsmn/Object.cpp +++ b/Jsmn/Object.cpp @@ -302,6 +302,13 @@ Detail::Iterator Object::end() const { return pimpl->end(); } +Object Object::parse_json(char const* txt) { + auto is = std::istringstream(std::string(txt)); + auto js = Jsmn::Object(); + is >> js; + return js; +} + /* Implements indented printing. */ namespace { diff --git a/Jsmn/Object.hpp b/Jsmn/Object.hpp index e2bca4b7d..976f15b97 100644 --- a/Jsmn/Object.hpp +++ b/Jsmn/Object.hpp @@ -42,6 +42,8 @@ class Object { Object& operator=(Object const&) =default; Object& operator=(Object&&) =default; + static Object parse_json(char const* txt); + /* Type queries on the object. */ bool is_null() const; bool is_boolean() const; diff --git a/tests/boss/test_forwardfeemonitor.cpp b/tests/boss/test_forwardfeemonitor.cpp index 85a3427cb..094e365ca 100644 --- a/tests/boss/test_forwardfeemonitor.cpp +++ b/tests/boss/test_forwardfeemonitor.cpp @@ -52,13 +52,6 @@ auto const listpeers_result = R"JSON( } )JSON"; -Jsmn::Object parse_json(char const* txt) { - auto is = std::istringstream(std::string(txt)); - auto js = Jsmn::Object(); - is >> js; - return js; -} - } int main() { @@ -103,14 +96,14 @@ int main() { /* Give the peers. */ return bus.raise(Boss::Msg::ListpeersResult{ - Boss::Mod::convert_legacy_listpeers(parse_json(listpeers_result)["peers"]), true + Boss::Mod::convert_legacy_listpeers(Jsmn::Object::parse_json(listpeers_result)["peers"]), true }); }).then([&]() { /* Should ignore non-forward_event. */ forwardfee = nullptr; return bus.raise(Boss::Msg::Notification{ - "not-forward_event", parse_json("{}") + "not-forward_event", Jsmn::Object::parse_json("{}") }); }).then([&]() { return Ev::yield(42); @@ -121,7 +114,7 @@ int main() { forwardfee = nullptr; return bus.raise(Boss::Msg::Notification{ "forward_event", - parse_json(R"JSON( + Jsmn::Object::parse_json(R"JSON( { "forward_event": { "payment_hash": "f5a6a059a25d1e329d9b094aeeec8c2191ca037d3f5b0662e21ae850debe8ea2", diff --git a/tests/boss/test_jitrebalancer.cpp b/tests/boss/test_jitrebalancer.cpp index aeca10691..baaa3c988 100644 --- a/tests/boss/test_jitrebalancer.cpp +++ b/tests/boss/test_jitrebalancer.cpp @@ -169,13 +169,6 @@ class DummyEarningsManager { ) : bus(bus_) { start(); } }; -Jsmn::Object parse_json(char const* txt) { - auto is = std::istringstream(txt); - auto rv = Jsmn::Object(); - is >> rv; - return rv; -} - class DummyRpc { private: S::Bus& bus; @@ -183,7 +176,7 @@ class DummyRpc { Ev::Io respond(void* requester, char const* res) { return bus.raise(Boss::Msg::ResponseRpcCommand{ requester, true, - parse_json(res), + Jsmn::Object::parse_json(res), "" }); } @@ -341,7 +334,7 @@ int main() { assert(deferrer); /* Raise the ListpeersResult. */ - auto res = parse_json(listpeers_result); + auto res = Jsmn::Object::parse_json(listpeers_result); auto peers = res["peers"]; return bus.raise(Boss::Msg::ListpeersResult{ std::move(Boss::Mod::convert_legacy_listpeers(peers)), true From 46f9ddab495337c3c1ef4fc0af2a507916d9c64c Mon Sep 17 00:00:00 2001 From: Ken Sedgwick Date: Mon, 5 Aug 2024 19:51:16 -0700 Subject: [PATCH 2/8] add Jsmn::Object::operator== This makes it easier to write test cases by comparing to expected values --- Jsmn/Object.cpp | 46 ++++++++++++++++++++ Jsmn/Object.hpp | 5 +++ Makefile.am | 1 + tests/jsmn/test_equality.cpp | 81 ++++++++++++++++++++++++++++++++++++ 4 files changed, 133 insertions(+) create mode 100644 tests/jsmn/test_equality.cpp diff --git a/Jsmn/Object.cpp b/Jsmn/Object.cpp index c11dd1cbe..141e42b3e 100644 --- a/Jsmn/Object.cpp +++ b/Jsmn/Object.cpp @@ -309,6 +309,52 @@ Object Object::parse_json(char const* txt) { return js; } +bool Object::operator==(Object const& other) const { + if (this == &other) { + return true; + } + + // Compare types first + if (is_null() != other.is_null() || is_boolean() != other.is_boolean() || + is_string() != other.is_string() || is_object() != other.is_object() || + is_array() != other.is_array() || is_number() != other.is_number()) { + return false; + } + + // Compare values based on type + if (is_null()) { + return true; // Both are null + } else if (is_boolean()) { + return static_cast(*this) == static_cast(other); + } else if (is_string()) { + return static_cast(*this) == static_cast(other); + } else if (is_number()) { + return static_cast(*this) == static_cast(other); + } else if (is_object()) { + if (size() != other.size()) { + return false; + } + for (const auto& key : keys()) { + if (!other.has(key) || (*this)[key] != other[key]) { + return false; + } + } + return true; + } else if (is_array()) { + if (size() != other.size()) { + return false; + } + for (std::size_t i = 0; i < size(); ++i) { + if ((*this)[i] != other[i]) { + return false; + } + } + return true; + } + + return false; // Fallback, should not reach here +} + /* Implements indented printing. */ namespace { diff --git a/Jsmn/Object.hpp b/Jsmn/Object.hpp index 976f15b97..42d9a6b22 100644 --- a/Jsmn/Object.hpp +++ b/Jsmn/Object.hpp @@ -71,6 +71,11 @@ class Object { Object operator[](std::size_t) const; /* Return null if out-of-range. */ /* TODO: Iterator. */ + bool operator==(Object const& other) const; + bool operator!=(Object const& other) const { + return !(*this == other); + } + /* Formal factory. */ friend class ParserExposedBuffer; /* Iterator type. */ diff --git a/Makefile.am b/Makefile.am index c64ed4acb..6688d08c4 100644 --- a/Makefile.am +++ b/Makefile.am @@ -621,6 +621,7 @@ TESTS = \ tests/ev/test_semaphore \ tests/ev/test_throw_in_then \ tests/graph/test_dijkstra \ + tests/jsmn/test_equality \ tests/jsmn/test_iterator \ tests/jsmn/test_parser \ tests/jsmn/test_performance \ diff --git a/tests/jsmn/test_equality.cpp b/tests/jsmn/test_equality.cpp new file mode 100644 index 000000000..02bc4bcd0 --- /dev/null +++ b/tests/jsmn/test_equality.cpp @@ -0,0 +1,81 @@ +#undef NDEBUG +#include "Jsmn/Object.hpp" +#include +#include + +void test_null_equality() { + auto json_null1 = Jsmn::Object::parse_json(R"JSON({ "x": null })JSON"); + auto json_null2 = Jsmn::Object::parse_json(R"JSON({ "x": null })JSON"); + assert(json_null1 == json_null2); + + auto json_non_null = Jsmn::Object::parse_json(R"JSON({ "x": 1 })JSON"); + assert(json_null1 != json_non_null); +} + +void test_boolean_equality() { + auto json_true1 = Jsmn::Object::parse_json(R"JSON({ "x": true })JSON"); + auto json_true2 = Jsmn::Object::parse_json(R"JSON({ "x": true })JSON"); + assert(json_true1 == json_true2); + + auto json_false1 = Jsmn::Object::parse_json(R"JSON({ "x": false })JSON"); + auto json_false2 = Jsmn::Object::parse_json(R"JSON({ "x": false })JSON"); + assert(json_false1 == json_false2); + + assert(json_true1 != json_false1); +} + +void test_string_equality() { + auto json_str1 = Jsmn::Object::parse_json(R"JSON({ "x": "hello" })JSON"); + auto json_str2 = Jsmn::Object::parse_json(R"JSON({ "x": "hello" })JSON"); + assert(json_str1 == json_str2); + + auto json_str3 = Jsmn::Object::parse_json(R"JSON({ "x": "world" })JSON"); + assert(json_str1 != json_str3); +} + +void test_number_equality() { + auto json_num1 = Jsmn::Object::parse_json(R"JSON({ "x": 42 })JSON"); + auto json_num2 = Jsmn::Object::parse_json(R"JSON({ "x": 42 })JSON"); + assert(json_num1 == json_num2); + + auto json_num3 = Jsmn::Object::parse_json(R"JSON({ "x": 3.14 })JSON"); + assert(json_num1 != json_num3); +} + +void test_object_equality() { + auto json_obj1 = Jsmn::Object::parse_json(R"JSON({ "a": 1, "b": 2 })JSON"); + auto json_obj2 = Jsmn::Object::parse_json(R"JSON({ "b": 2, "a": 1 })JSON"); + assert(json_obj1 == json_obj2); + + auto json_obj3 = Jsmn::Object::parse_json(R"JSON({ "a": 1, "b": 3 })JSON"); + assert(json_obj1 != json_obj3); +} + +void test_array_equality() { + auto json_arr1 = Jsmn::Object::parse_json(R"JSON([1, 2, 3])JSON"); + auto json_arr2 = Jsmn::Object::parse_json(R"JSON([1, 2, 3])JSON"); + assert(json_arr1 == json_arr2); + + auto json_arr3 = Jsmn::Object::parse_json(R"JSON([3, 2, 1])JSON"); + assert(json_arr1 != json_arr3); +} + +void test_nested_structure_equality() { + auto json_nested1 = Jsmn::Object::parse_json(R"JSON({ "x": [ { "a": 1 }, { "b": 2 } ] })JSON"); + auto json_nested2 = Jsmn::Object::parse_json(R"JSON({ "x": [ { "a": 1 }, { "b": 2 } ] })JSON"); + assert(json_nested1 == json_nested2); + + auto json_nested3 = Jsmn::Object::parse_json(R"JSON({ "x": [ { "b": 2 }, { "a": 1 } ] })JSON"); + assert(json_nested1 != json_nested3); +} + +int main() { + test_null_equality(); + test_boolean_equality(); + test_string_equality(); + test_number_equality(); + test_object_equality(); + test_array_equality(); + test_nested_structure_equality(); + return 0; +} From 6188ffa30e34cdd9e5702c63caeaa2786f60c510 Mon Sep 17 00:00:00 2001 From: Ken Sedgwick Date: Mon, 5 Aug 2024 19:15:32 -0700 Subject: [PATCH 3/8] add test_earningstracker for legacy tracking --- Makefile.am | 1 + tests/boss/test_earningstracker.cpp | 202 ++++++++++++++++++++++++++++ 2 files changed, 203 insertions(+) create mode 100644 tests/boss/test_earningstracker.cpp diff --git a/Makefile.am b/Makefile.am index 6688d08c4..d62b30a00 100644 --- a/Makefile.am +++ b/Makefile.am @@ -590,6 +590,7 @@ TESTS = \ tests/boss/test_channelcreator_rearrangerbysize \ tests/boss/test_channelcreator_reprioritizer \ tests/boss/test_earningsrebalancer \ + tests/boss/test_earningstracker \ tests/boss/test_feemodderbypricetheory \ tests/boss/test_forwardfeemonitor \ tests/boss/test_getmanifest \ diff --git a/tests/boss/test_earningstracker.cpp b/tests/boss/test_earningstracker.cpp new file mode 100644 index 000000000..929ae4418 --- /dev/null +++ b/tests/boss/test_earningstracker.cpp @@ -0,0 +1,202 @@ +#undef NDEBUG + +#include"Boss/Mod/EarningsTracker.hpp" +#include"Boss/Msg/DbResource.hpp" +#include"Boss/Msg/ForwardFee.hpp" +#include"Boss/Msg/ProvideStatus.hpp" +#include"Boss/Msg/RequestEarningsInfo.hpp" +#include"Boss/Msg/RequestMoveFunds.hpp" +#include"Boss/Msg/ResponseEarningsInfo.hpp" +#include"Boss/Msg/ResponseMoveFunds.hpp" +#include"Boss/Msg/SolicitStatus.hpp" +#include"Ev/start.hpp" +#include"Jsmn/Object.hpp" +#include"Jsmn/Parser.hpp" +#include"Json/Out.hpp" +#include"Ln/NodeId.hpp" +#include"S/Bus.hpp" +#include"Sqlite3.hpp" + +#include +#include +#include + +namespace { +auto const A = Ln::NodeId("020000000000000000000000000000000000000000000000000000000000000001"); +auto const B = Ln::NodeId("020000000000000000000000000000000000000000000000000000000000000002"); +auto const C = Ln::NodeId("020000000000000000000000000000000000000000000000000000000000000003"); +} + +int main() { + auto bus = S::Bus(); + + /* Module under test */ + Boss::Mod::EarningsTracker mut(bus); + + auto db = Sqlite3::Db(":memory:"); + + Boss::Msg::ProvideStatus lastStatus; + Boss::Msg::ResponseEarningsInfo lastEarningsInfo; + + bus.subscribe([&](Boss::Msg::ProvideStatus const& st) { + lastStatus = st; + return Ev::lift(); + }); + + bus.subscribe([&](Boss::Msg::ResponseEarningsInfo const& ei) { + lastEarningsInfo = ei; + return Ev::lift(); + }); + + auto code = Ev::lift().then([&]() { + return bus.raise(Boss::Msg::DbResource{ db }); + }).then([&]() { + return bus.raise( + Boss::Msg::ForwardFee{ + A, // in_id + B, // out_id + Ln::Amount::sat(1), // fee + 1.0 // resolution_time + }); + }).then([&]() { + return bus.raise(Boss::Msg::SolicitStatus{}); + }).then([&]() { + // std::cerr << lastStatus.value.output() << std::endl; + assert(lastStatus.key == "offchain_earnings_tracker"); + assert( + Jsmn::Object::parse_json(lastStatus.value.output().c_str()) == + Jsmn::Object::parse_json(R"JSON( + { + "020000000000000000000000000000000000000000000000000000000000000001": { + "in_earnings": 1000, + "in_expenditures": 0, + "out_earnings": 0, + "out_expenditures": 0 + }, + "020000000000000000000000000000000000000000000000000000000000000002": { + "in_earnings": 0, + "in_expenditures": 0, + "out_earnings": 1000, + "out_expenditures": 0 + }, + "total": { + "in_earnings": 1000, + "in_expenditures": 0, + "out_earnings": 1000, + "out_expenditures": 0 + } + } + )JSON")); + return Ev::lift(); + }).then([&]() { + return bus.raise( + Boss::Msg::ForwardFee{ + A, // in_id + B, // out_id + Ln::Amount::sat(1), // fee + 1.0 // resolution_time + }); + }).then([&]() { + return bus.raise(Boss::Msg::SolicitStatus{}); + }).then([&]() { + // std::cerr << lastStatus.value.output() << std::endl; + assert(lastStatus.key == "offchain_earnings_tracker"); + assert( + Jsmn::Object::parse_json(lastStatus.value.output().c_str()) == + Jsmn::Object::parse_json(R"JSON( + { + "020000000000000000000000000000000000000000000000000000000000000001": { + "in_earnings": 2000, + "in_expenditures": 0, + "out_earnings": 0, + "out_expenditures": 0 + }, + "020000000000000000000000000000000000000000000000000000000000000002": { + "in_earnings": 0, + "in_expenditures": 0, + "out_earnings": 2000, + "out_expenditures": 0 + }, + "total": { + "in_earnings": 2000, + "in_expenditures": 0, + "out_earnings": 2000, + "out_expenditures": 0 + } + } + )JSON")); + return Ev::lift(); + }).then([&]() { + return bus.raise( + Boss::Msg::RequestMoveFunds{ + NULL, // requester (match ResponseMoveFunds) + C, // source + A, // destination + Ln::Amount::sat(1000), // amount + Ln::Amount::sat(3) // fee_budget + }); + }).then([&]() { + return bus.raise( + Boss::Msg::ResponseMoveFunds{ + NULL, // requester (match RequestMoveFunds) + Ln::Amount::sat(1000), // amount_moved + Ln::Amount::sat(2) // fee_spent + }); + }).then([&]() { + return bus.raise(Boss::Msg::SolicitStatus{}); + }).then([&]() { + // std::cerr << lastStatus.value.output() << std::endl; + assert(lastStatus.key == "offchain_earnings_tracker"); + assert( + Jsmn::Object::parse_json(lastStatus.value.output().c_str()) == + Jsmn::Object::parse_json(R"JSON( + { + "020000000000000000000000000000000000000000000000000000000000000001": { + "in_earnings": 2000, + "in_expenditures": 0, + "out_earnings": 0, + "out_expenditures": 2000 + }, + "020000000000000000000000000000000000000000000000000000000000000002": { + "in_earnings": 0, + "in_expenditures": 0, + "out_earnings": 2000, + "out_expenditures": 0 + }, + "020000000000000000000000000000000000000000000000000000000000000003": { + "in_earnings": 0, + "in_expenditures": 2000, + "out_earnings": 0, + "out_expenditures": 0 + }, + "total": { + "in_earnings": 2000, + "in_expenditures": 2000, + "out_earnings": 2000, + "out_expenditures": 2000 + } + } + )JSON")); + return Ev::lift(); + }).then([&]() { + mock_now = 4000.0; + return bus.raise( + Boss::Msg::RequestEarningsInfo{ + NULL, // requester (match ResponseEarningsInfo) + A // node + }); + }).then([&]() { + return Ev::yield(42); + }).then([&]() { + assert(lastEarningsInfo.node == A); + assert(lastEarningsInfo.in_earnings == Ln::Amount::msat(2000)); + assert(lastEarningsInfo.in_expenditures == Ln::Amount::msat(0)); + assert(lastEarningsInfo.out_earnings == Ln::Amount::msat(0)); + assert(lastEarningsInfo.out_expenditures == Ln::Amount::msat(2000)); + return Ev::lift(); + }).then([&]() { + return Ev::lift(0); + }); + + return Ev::start(std::move(code)); +} From 721a4f01f8c5e4ae871734a8b5034e8004e26d15 Mon Sep 17 00:00:00 2001 From: Ken Sedgwick Date: Tue, 6 Aug 2024 12:11:18 -0700 Subject: [PATCH 4/8] add get_now() and mock_get_now() to EarningsTracker and test_earningstracker A time source is needed for upcoming time buckets change. --- Boss/Mod/EarningsTracker.cpp | 7 ++++--- Boss/Mod/EarningsTracker.hpp | 4 +++- tests/boss/test_earningstracker.cpp | 12 +++++++++++- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/Boss/Mod/EarningsTracker.cpp b/Boss/Mod/EarningsTracker.cpp index a141e0094..a3caea4de 100644 --- a/Boss/Mod/EarningsTracker.cpp +++ b/Boss/Mod/EarningsTracker.cpp @@ -19,6 +19,7 @@ namespace Boss { namespace Mod { class EarningsTracker::Impl { private: S::Bus& bus; + std::function get_now; Sqlite3::Db db; /* Information of a pending MoveFunds. */ @@ -287,13 +288,13 @@ class EarningsTracker::Impl { Impl(Impl const&) =delete; explicit - Impl(S::Bus& bus_) : bus(bus_) { start(); } + Impl(S::Bus& bus_, std::function get_now_) : bus(bus_), get_now(std::move(get_now_)) { start(); } }; EarningsTracker::EarningsTracker(EarningsTracker&&) =default; EarningsTracker::~EarningsTracker() =default; -EarningsTracker::EarningsTracker(S::Bus& bus) - : pimpl(Util::make_unique(bus)) { } + EarningsTracker::EarningsTracker(S::Bus& bus, std::function get_now_ ) + : pimpl(Util::make_unique(bus, get_now_)) { } }} diff --git a/Boss/Mod/EarningsTracker.hpp b/Boss/Mod/EarningsTracker.hpp index 6c059329b..d8829687c 100644 --- a/Boss/Mod/EarningsTracker.hpp +++ b/Boss/Mod/EarningsTracker.hpp @@ -1,6 +1,8 @@ #ifndef BOSS_MOD_EARNINGSTRACKER_HPP #define BOSS_MOD_EARNINGSTRACKER_HPP +#include"Ev/now.hpp" +#include #include namespace S { class Bus; } @@ -28,7 +30,7 @@ class EarningsTracker { ~EarningsTracker(); explicit - EarningsTracker(S::Bus& bus); + EarningsTracker(S::Bus& bus, std::function get_now_ = &Ev::now); }; }} diff --git a/tests/boss/test_earningstracker.cpp b/tests/boss/test_earningstracker.cpp index 929ae4418..3ace7d9c8 100644 --- a/tests/boss/test_earningstracker.cpp +++ b/tests/boss/test_earningstracker.cpp @@ -27,11 +27,16 @@ auto const B = Ln::NodeId("02000000000000000000000000000000000000000000000000000 auto const C = Ln::NodeId("020000000000000000000000000000000000000000000000000000000000000003"); } +double mock_now = 0.0; +double mock_get_now() { + return mock_now; +} + int main() { auto bus = S::Bus(); /* Module under test */ - Boss::Mod::EarningsTracker mut(bus); + Boss::Mod::EarningsTracker mut(bus, &mock_get_now); auto db = Sqlite3::Db(":memory:"); @@ -51,6 +56,7 @@ int main() { auto code = Ev::lift().then([&]() { return bus.raise(Boss::Msg::DbResource{ db }); }).then([&]() { + mock_now = 1000.0; return bus.raise( Boss::Msg::ForwardFee{ A, // in_id @@ -59,6 +65,7 @@ int main() { 1.0 // resolution_time }); }).then([&]() { + mock_now = 2000.0; return bus.raise(Boss::Msg::SolicitStatus{}); }).then([&]() { // std::cerr << lastStatus.value.output() << std::endl; @@ -89,6 +96,7 @@ int main() { )JSON")); return Ev::lift(); }).then([&]() { + mock_now = 3000.0; return bus.raise( Boss::Msg::ForwardFee{ A, // in_id @@ -127,6 +135,7 @@ int main() { )JSON")); return Ev::lift(); }).then([&]() { + mock_now = 4000.0; return bus.raise( Boss::Msg::RequestMoveFunds{ NULL, // requester (match ResponseMoveFunds) @@ -136,6 +145,7 @@ int main() { Ln::Amount::sat(3) // fee_budget }); }).then([&]() { + mock_now = 5000.0; return bus.raise( Boss::Msg::ResponseMoveFunds{ NULL, // requester (match RequestMoveFunds) From 528fcd9ab964feb4defd7abddeba9bc55ae5f0d6 Mon Sep 17 00:00:00 2001 From: Ken Sedgwick Date: Tue, 6 Aug 2024 12:43:14 -0700 Subject: [PATCH 5/8] add EarningsTracker::bucket_time quantizer and unit tests --- Boss/Mod/EarningsTracker.cpp | 10 +++++++++- Boss/Mod/EarningsTracker.hpp | 3 +++ tests/boss/test_earningstracker.cpp | 6 ++++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/Boss/Mod/EarningsTracker.cpp b/Boss/Mod/EarningsTracker.cpp index a3caea4de..a1fa99d47 100644 --- a/Boss/Mod/EarningsTracker.cpp +++ b/Boss/Mod/EarningsTracker.cpp @@ -12,6 +12,8 @@ #include"S/Bus.hpp" #include"Sqlite3.hpp" #include"Util/make_unique.hpp" + +#include #include namespace Boss { namespace Mod { @@ -294,7 +296,13 @@ class EarningsTracker::Impl { EarningsTracker::EarningsTracker(EarningsTracker&&) =default; EarningsTracker::~EarningsTracker() =default; - EarningsTracker::EarningsTracker(S::Bus& bus, std::function get_now_ ) +EarningsTracker::EarningsTracker(S::Bus& bus, std::function get_now_ ) : pimpl(Util::make_unique(bus, get_now_)) { } +double EarningsTracker::bucket_time(double input_time) { + constexpr double seconds_per_day = 24 * 60 * 60; + double bucket_start = std::floor(input_time / seconds_per_day) * seconds_per_day; + return bucket_start; +} + }} diff --git a/Boss/Mod/EarningsTracker.hpp b/Boss/Mod/EarningsTracker.hpp index d8829687c..9f47e2ea1 100644 --- a/Boss/Mod/EarningsTracker.hpp +++ b/Boss/Mod/EarningsTracker.hpp @@ -31,6 +31,9 @@ class EarningsTracker { explicit EarningsTracker(S::Bus& bus, std::function get_now_ = &Ev::now); + + // exposed for unit testing, otherwise internal + static double bucket_time(double input_time); }; }} diff --git a/tests/boss/test_earningstracker.cpp b/tests/boss/test_earningstracker.cpp index 3ace7d9c8..fe6cb0270 100644 --- a/tests/boss/test_earningstracker.cpp +++ b/tests/boss/test_earningstracker.cpp @@ -33,6 +33,12 @@ double mock_get_now() { } int main() { + // check the UTC quantitization boundaries + assert(Boss::Mod::EarningsTracker::bucket_time(1722902400 - 1) == 1722816000); + assert(Boss::Mod::EarningsTracker::bucket_time(1722902400) == 1722902400); + assert(Boss::Mod::EarningsTracker::bucket_time(1722902400 + 86400 - 1) == 1722902400); + assert(Boss::Mod::EarningsTracker::bucket_time(1722902400 + 86400) == 1722988800); + auto bus = S::Bus(); /* Module under test */ From 5bc9075481199a5c061d7ac263f9c02c0485f04a Mon Sep 17 00:00:00 2001 From: Ken Sedgwick Date: Tue, 6 Aug 2024 16:19:04 -0700 Subject: [PATCH 6/8] Upgrade EarningsTracker to time bucket schema, use old semantics This commit modifies the schema of EarningsTracker to allow storing and accessing earning and expenditure data in specific time ranges. All existing strategies and reports still use all data from all time so this PR should not change any balancing behavior. After we've run w/ this for a while we'll have time-based data collected and can evaluate how to improve the strategies. --- Boss/Mod/EarningsTracker.cpp | 98 +++++++++++++++++++++++++++++------- 1 file changed, 81 insertions(+), 17 deletions(-) diff --git a/Boss/Mod/EarningsTracker.cpp b/Boss/Mod/EarningsTracker.cpp index a1fa99d47..3f92f384d 100644 --- a/Boss/Mod/EarningsTracker.cpp +++ b/Boss/Mod/EarningsTracker.cpp @@ -65,28 +65,79 @@ class EarningsTracker::Impl { Ev::Io init() { return db.transact().then([](Sqlite3::Tx tx) { + // If we already have a bucket schema we're done + if (have_bucket_table(tx)) { + tx.commit(); + return Ev::lift(); + } + + // Create the bucket table w/ a temp name tx.query_execute(R"QRY( - CREATE TABLE IF NOT EXISTS "EarningsTracker" - ( node TEXT PRIMARY KEY + CREATE TABLE IF NOT EXISTS EarningsTracker_New + ( node TEXT NOT NULL + , time_bucket REAL NOT NULL , in_earnings INTEGER NOT NULL , in_expenditures INTEGER NOT NULL , out_earnings INTEGER NOT NULL , out_expenditures INTEGER NOT NULL + , PRIMARY KEY (node, time_bucket) ); )QRY"); + // If we have a legacy table migrate the data and lose the old table + if (have_legacy_table(tx)) { + tx.query_execute(R"QRY( + INSERT INTO EarningsTracker_New + ( node, time_bucket, in_earnings, in_expenditures + , out_earnings, out_expenditures) + SELECT node, 0, in_earnings, in_expenditures + , out_earnings, out_expenditures FROM EarningsTracker; + DROP TABLE EarningsTracker; + )QRY"); + } + + // Move the new table to the official name and add the indexes + tx.query_execute(R"QRY( + ALTER TABLE EarningsTracker_New RENAME TO EarningsTracker; + CREATE INDEX IF NOT EXISTS + idx_earnings_tracker_node_time ON EarningsTracker (node, time_bucket); + CREATE INDEX IF NOT EXISTS + idx_earnings_tracker_time_node ON EarningsTracker (time_bucket, node); + )QRY"); + tx.commit(); return Ev::lift(); }); } + + static bool have_bucket_table(Sqlite3::Tx& tx) { + auto fetch = tx.query(R"QRY( + SELECT 1 + FROM pragma_table_info('EarningsTracker') + WHERE name='time_bucket'; + )QRY").execute(); + return fetch.begin() != fetch.end(); + } + + static bool have_legacy_table(Sqlite3::Tx& tx) { + // Do we have a legacy table defined? + auto fetch = tx.query(R"QRY( + SELECT 1 + FROM pragma_table_info('EarningsTracker') + WHERE name='node'; + )QRY").execute(); + return fetch.begin() != fetch.end(); + } + /* Ensure the given node has an entry. */ - void ensure(Sqlite3::Tx& tx, Ln::NodeId const& node) { + void ensure(Sqlite3::Tx& tx, Ln::NodeId const& node, double bucket) { tx.query(R"QRY( INSERT OR IGNORE INTO "EarningsTracker" - VALUES(:node, 0, 0, 0, 0); + VALUES(:node, :bucket, 0, 0, 0, 0); )QRY") .bind(":node", std::string(node)) + .bind(":bucket", bucket) .execute() ; } @@ -96,16 +147,19 @@ class EarningsTracker::Impl { ) { return db.transact().then([this, in, out, fee ](Sqlite3::Tx tx) { - ensure(tx, in); - ensure(tx, out); + auto bucket = bucket_time(get_now()); + ensure(tx, in, bucket); + ensure(tx, out, bucket); tx.query(R"QRY( UPDATE "EarningsTracker" SET in_earnings = in_earnings + :fee WHERE node = :node + AND time_bucket = :bucket ; )QRY") .bind(":node", std::string(in)) + .bind(":bucket", bucket) .bind(":fee", fee.to_msat()) .execute() ; @@ -113,9 +167,11 @@ class EarningsTracker::Impl { UPDATE "EarningsTracker" SET out_earnings = out_earnings + :fee WHERE node = :node + AND time_bucket = :bucket ; )QRY") .bind(":node", std::string(out)) + .bind(":bucket", bucket) .bind(":fee", fee.to_msat()) .execute() ; @@ -143,8 +199,9 @@ class EarningsTracker::Impl { return Ev::lift(); auto& pending = it->second; - ensure(tx, pending.source); - ensure(tx, pending.destination); + auto bucket = bucket_time(get_now()); + ensure(tx, pending.source, bucket); + ensure(tx, pending.destination, bucket); /* Source gets in-expenditures since it gets more * incoming capacity (for more earnings for the @@ -153,9 +210,11 @@ class EarningsTracker::Impl { UPDATE "EarningsTracker" SET in_expenditures = in_expenditures + :fee WHERE node = :node + AND time_bucket = :bucket ; )QRY") .bind(":node", std::string(pending.source)) + .bind(":bucket", bucket) .bind(":fee", fee.to_msat()) .execute() ; @@ -166,11 +225,13 @@ class EarningsTracker::Impl { UPDATE "EarningsTracker" SET out_expenditures = out_expenditures + :fee WHERE node = :node + AND time_bucket = :bucket ; )QRY") .bind( ":node" , std::string(pending.destination) ) + .bind(":bucket", bucket) .bind(":fee", fee.to_msat()) .execute() ; @@ -195,11 +256,12 @@ class EarningsTracker::Impl { auto out_expenditures = Ln::Amount::sat(0); auto fetch = tx.query(R"QRY( - SELECT in_earnings, in_expenditures - , out_earnings, out_expenditures + SELECT SUM(in_earnings), + SUM(in_expenditures), + SUM(out_earnings), + SUM(out_expenditures) FROM "EarningsTracker" - WHERE node = :node - ; + WHERE node = :node; )QRY") .bind(":node", std::string(node)) .execute() @@ -231,11 +293,13 @@ class EarningsTracker::Impl { Ev::Io status() { return db.transact().then([this](Sqlite3::Tx tx) { auto fetch = tx.query(R"QRY( - SELECT node - , in_earnings, in_expenditures - , out_earnings, out_expenditures - FROM "EarningsTracker" - ; + SELECT node, + SUM(in_earnings) AS total_in_earnings, + SUM(in_expenditures) AS total_in_expenditures, + SUM(out_earnings) AS total_out_earnings, + SUM(out_expenditures) AS total_out_expenditures + FROM "EarningsTracker" + GROUP BY node; )QRY").execute(); uint64_t total_in_earnings = 0; From 55479bb212b5eafde3714b066e4cf057949c5e0d Mon Sep 17 00:00:00 2001 From: Ken Sedgwick Date: Wed, 7 Aug 2024 14:02:46 -0700 Subject: [PATCH 7/8] add Either::operator<< to support debugging --- Util/Either.hpp | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/Util/Either.hpp b/Util/Either.hpp index 5fc5bdd95..0fbd25fd6 100644 --- a/Util/Either.hpp +++ b/Util/Either.hpp @@ -2,6 +2,7 @@ #define UTIL_EITHER_HPP #include +#include #include namespace Util { @@ -225,6 +226,19 @@ bool operator<=(Util::Either const& a, Util::Either const& b) { return !(b < a); } +template +std::ostream& operator<<(std::ostream& os, const Either& either) { + either.cmatch( + [&](const L& l) { + os << "Left(" << l << ")"; + }, + [&](const R& r) { + os << "Right(" << r << ")"; + } + ); + return os; } +} // namespace Util + #endif /* !defined(UTIL_EITHER_HPP) */ From 13a4e8ff535d3641411e8f2e62783a078b257892 Mon Sep 17 00:00:00 2001 From: Ken Sedgwick Date: Wed, 7 Aug 2024 16:44:14 -0700 Subject: [PATCH 8/8] add clboss-recent-earnings and clboss-earnings-history --- Boss/Mod/EarningsTracker.cpp | 218 +++++++++++++++- Makefile.am | 2 + README.md | 30 +++ .../test_availablerpccommandsannouncer.cpp | 10 + tests/boss/test_earningshistory.cpp | 232 ++++++++++++++++++ tests/boss/test_recentearnings.cpp | 213 ++++++++++++++++ 6 files changed, 704 insertions(+), 1 deletion(-) create mode 100644 tests/boss/test_earningshistory.cpp create mode 100644 tests/boss/test_recentearnings.cpp diff --git a/Boss/Mod/EarningsTracker.cpp b/Boss/Mod/EarningsTracker.cpp index 3f92f384d..a3e329eb9 100644 --- a/Boss/Mod/EarningsTracker.cpp +++ b/Boss/Mod/EarningsTracker.cpp @@ -1,6 +1,11 @@ #include"Boss/Mod/EarningsTracker.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/ManifestCommand.hpp" +#include"Boss/Msg/Manifestation.hpp" #include"Boss/Msg/ProvideStatus.hpp" #include"Boss/Msg/RequestEarningsInfo.hpp" #include"Boss/Msg/RequestMoveFunds.hpp" @@ -15,6 +20,7 @@ #include #include +#include namespace Boss { namespace Mod { @@ -54,7 +60,92 @@ class EarningsTracker::Impl { >([this](Msg::RequestEarningsInfo const& req) { return Boss::concurrent(request_earnings_info(req)); }); - + bus.subscribe([this](Msg::Manifestation const& _) { + return bus.raise(Msg::ManifestCommand{ + "clboss-recent-earnings", "[days]", + "Show offchain_earnings_tracker data for the most recent {days} " + "(default 14 days).", + false + }) + bus.raise(Msg::ManifestCommand{ + "clboss-earnings-history", "[nodeid]", + "Show earnings and expenditure history for {nodeid} " + "(default all nodes)", + false + }); + }); + bus.subscribe([this](Msg::CommandRequest const& req) { + auto id = req.id; + auto paramfail = [this, id]() { + return bus.raise(Msg::CommandFail{ + id, -32602, + "Parameter failure", + Json::Out::empty_object() + }); + }; + if (req.command == "clboss-recent-earnings") { + auto days = double(14.0); + auto days_j = Jsmn::Object(); + auto params = req.params; + if (params.is_object()) { + if (params.size() > 1) + return paramfail(); + if (params.size() == 1) { + if (!params.has("days")) + return paramfail(); + days_j = params["days"]; + } + } else if (params.is_array()) { + if (params.size() > 1) + return paramfail(); + if (params.size() == 1) + days_j = params[0]; + } + if (!days_j.is_null()) { + if (!days_j.is_number()) + return paramfail(); + days = (double) days_j; + } + return db.transact().then([this, id, days](Sqlite3::Tx tx) { + auto report = recent_earnings_report(tx, days); + tx.commit(); + return bus.raise(Msg::CommandResponse{ + id, report + }); + }); + } else if (req.command == "clboss-earnings-history") { + auto nodeid = std::string(""); + auto nodeid_j = Jsmn::Object(); + auto params = req.params; + if (params.is_object()) { + if (params.size() > 1) + return paramfail(); + if (params.size() == 1) { + if (!params.has("nodeid")) + return paramfail(); + nodeid_j = params["nodeid"]; + } + } else if (params.is_array()) { + if (params.size() > 1) + return paramfail(); + if (params.size() == 1) + nodeid_j = params[0]; + } + if (!nodeid_j.is_null()) { + if (!nodeid_j.is_string()) + return paramfail(); + nodeid = (std::string) nodeid_j; + } + return db.transact().then([this, id, nodeid](Sqlite3::Tx tx) { + auto report = earnings_history_report(tx, nodeid); + tx.commit(); + return bus.raise(Msg::CommandResponse{ + id, report + }); + }); + } + return Ev::lift(); + }); bus.subscribe([this](Msg::SolicitStatus const& _) { if (!db) @@ -348,6 +439,131 @@ class EarningsTracker::Impl { }); } + Json::Out recent_earnings_report(Sqlite3::Tx& tx, double days) { + auto cutoff = bucket_time(get_now()) - (days * 24 * 60 * 60); + auto fetch = tx.query(R"QRY( + SELECT node, + SUM(in_earnings) AS total_in_earnings, + SUM(in_expenditures) AS total_in_expenditures, + SUM(out_earnings) AS total_out_earnings, + SUM(out_expenditures) AS total_out_expenditures + FROM "EarningsTracker" + WHERE time_bucket >= :cutoff + GROUP BY node + ORDER BY (total_in_earnings - total_in_expenditures + + total_out_earnings - total_out_expenditures) DESC; + )QRY") + .bind(":cutoff", cutoff) + .execute() + ; + auto out = Json::Out(); + auto top = out.start_object(); + auto recent = top.start_object("recent"); + uint64_t total_in_earnings = 0; + uint64_t total_in_expenditures = 0; + uint64_t total_out_earnings = 0; + uint64_t total_out_expenditures = 0; + for (auto& r : fetch) { + auto in_earnings = r.get(1); + auto in_expenditures = r.get(2); + auto out_earnings = r.get(3); + auto out_expenditures = r.get(4); + auto sub = recent.start_object(r.get(0)); + sub + .field("in_earnings", in_earnings) + .field("in_expenditures", in_expenditures) + .field("out_earnings", out_earnings) + .field("out_expenditures", out_expenditures) + ; + sub.end_object(); + total_in_earnings += in_earnings; + total_in_expenditures += in_expenditures; + total_out_earnings += out_earnings; + total_out_expenditures += out_expenditures; + } + recent.end_object(); + auto total = top.start_object("total"); + total + .field("in_earnings", total_in_earnings) + .field("in_expenditures", total_in_expenditures) + .field("out_earnings", total_out_earnings) + .field("out_expenditures", total_out_expenditures) + ; + total.end_object(); + top.end_object(); + return out; + } + + Json::Out earnings_history_report(Sqlite3::Tx& tx, std::string nodeid) { + std::string sql; + if (nodeid.empty()) { + sql = R"QRY( + SELECT time_bucket, + SUM(in_earnings) AS total_in_earnings, + SUM(in_expenditures) AS total_in_expenditures, + SUM(out_earnings) AS total_out_earnings, + SUM(out_expenditures) AS total_out_expenditures + FROM "EarningsTracker" + GROUP BY time_bucket + ORDER BY time_bucket; + )QRY"; + } else { + sql = R"QRY( + SELECT time_bucket, + in_earnings, + in_expenditures, + out_earnings, + out_expenditures + FROM "EarningsTracker" + WHERE node = :nodeid + ORDER BY time_bucket; + )QRY"; + } + auto query = tx.query(sql.c_str()); + if (!nodeid.empty()) { + query.bind(":nodeid", nodeid); + } + auto fetch = query.execute(); + + auto out = Json::Out(); + auto top = out.start_object(); + auto history = top.start_array("history"); + uint64_t total_in_earnings = 0; + uint64_t total_in_expenditures = 0; + uint64_t total_out_earnings = 0; + uint64_t total_out_expenditures = 0; + for (auto& r : fetch) { + auto in_earnings = r.get(1); + auto in_expenditures = r.get(2); + auto out_earnings = r.get(3); + auto out_expenditures = r.get(4); + auto sub = history.start_object(); + sub + .field("bucket_time", static_cast(r.get(0))) + .field("in_earnings", in_earnings) + .field("in_expenditures", in_expenditures) + .field("out_earnings", out_earnings) + .field("out_expenditures", out_expenditures) + ; + sub.end_object(); + total_in_earnings += in_earnings; + total_in_expenditures += in_expenditures; + total_out_earnings += out_earnings; + total_out_expenditures += out_expenditures; + } + history.end_array(); + auto total = top.start_object("total"); + total + .field("in_earnings", total_in_earnings) + .field("in_expenditures", total_in_expenditures) + .field("out_earnings", total_out_earnings) + .field("out_expenditures", total_out_expenditures) + ; + total.end_object(); + top.end_object(); + return out; + } + public: Impl() =delete; Impl(Impl&&) =delete; diff --git a/Makefile.am b/Makefile.am index d62b30a00..783efc9c1 100644 --- a/Makefile.am +++ b/Makefile.am @@ -599,6 +599,8 @@ TESTS = \ tests/boss/test_needsconnectsolicitor \ tests/boss/test_onchainfeemonitor_samples_init \ tests/boss/test_peerjudge_agetracker \ + tests/boss/test_recentearnings \ + tests/boss/test_earningshistory \ tests/boss/test_peerjudge_algo \ tests/boss/test_peerjudge_datagatherer \ tests/boss/test_peerstatistician \ diff --git a/README.md b/README.md index 1dc1d88f4..ca7704ce7 100644 --- a/README.md +++ b/README.md @@ -356,6 +356,36 @@ Earlier versions do not record, so if you have been using CLBOSS before 0.11D, then historical offchain-to-onchain swaps are not reported. +### `clboss-recent-earnings`, `clboss-earnings-history` + +As of CLBOSS version [TBD], earnings and expenditures are tracked on a daily basis. +The following commands have been added to observe the new data: + +- **`clboss-recent-earnings`**: + - **Purpose**: Returns a data structure equivalent to the + `offchain_earnings_tracker` collection in `clboss-status`, but + only includes recent earnings and expenditures. + - **Arguments**: + - `days` (optional): Specifies the number of days to include in + the report. Defaults to a fortnight (14 days) if not provided. + +- **`clboss-earnings-history`**: + - **Purpose**: Provides a daily breakdown of earnings and expenditures. + - **Arguments**: + - `nodeid` (optional): Limits the history to a particular node if + provided. Without this argument, the values are accumulated + across all peers. + - **Output**: + - The history consists of an array of records showing the earnings + and expenditures for each day. + - The history includes an initial record with a time value of 0, + which contains any legacy earnings and expenditures collected by + CLBOSS before daily tracking was implemented. + +These commands enhance the tracking of financial metrics, allowing for +detailed and recent analysis of earnings and expenditures on a daily +basis. + ### `--clboss-min-onchain=` Pass this option to `lightningd` in order to specify a target diff --git a/tests/boss/test_availablerpccommandsannouncer.cpp b/tests/boss/test_availablerpccommandsannouncer.cpp index eb6157dd6..28c454962 100644 --- a/tests/boss/test_availablerpccommandsannouncer.cpp +++ b/tests/boss/test_availablerpccommandsannouncer.cpp @@ -25,6 +25,12 @@ auto const help = std::string(R"JSON( , { "command": "clboss-unmanage nodeid tags" , "category": "plugin" } + , { "command": "clboss-recent-earnings [days]" + , "category": "plugin" + } + , { "command": "clboss-earnings-history [nodeid]" + , "category": "plugin" + } ] } )JSON"); @@ -73,6 +79,10 @@ int main() { assert(commands["clboss-status"].usage == ""); assert(commands["clboss-ignore-onchain"].usage == "[hours]"); assert(commands["clboss-unmanage"].usage == "nodeid tags"); + assert(commands.count("clboss-recent-earnings") != 0); + assert(commands["clboss-recent-earnings"].usage == "[days]"); + assert(commands.count("clboss-earnings-history") != 0); + assert(commands["clboss-earnings-history"].usage == "[nodeid]"); return Ev::lift(); }); diff --git a/tests/boss/test_earningshistory.cpp b/tests/boss/test_earningshistory.cpp new file mode 100644 index 000000000..6e9ac35b7 --- /dev/null +++ b/tests/boss/test_earningshistory.cpp @@ -0,0 +1,232 @@ +#undef NDEBUG + +#include"Boss/Mod/EarningsTracker.hpp" +#include"Boss/Msg/DbResource.hpp" +#include"Boss/Msg/ForwardFee.hpp" +#include"Boss/Msg/RequestMoveFunds.hpp" +#include"Boss/Msg/ResponseMoveFunds.hpp" +#include"Boss/Msg/CommandResponse.hpp" +#include"Boss/Msg/CommandRequest.hpp" +#include"Ev/start.hpp" +#include"Jsmn/Object.hpp" +#include"Jsmn/Parser.hpp" +#include"Json/Out.hpp" +#include"Ln/NodeId.hpp" +#include"S/Bus.hpp" +#include"Sqlite3.hpp" + +#include +#include +#include + +namespace { +auto const A = Ln::NodeId("020000000000000000000000000000000000000000000000000000000000000001"); +auto const B = Ln::NodeId("020000000000000000000000000000000000000000000000000000000000000002"); +auto const C = Ln::NodeId("020000000000000000000000000000000000000000000000000000000000000003"); +} + +double mock_now = 0.0; +double mock_get_now() { + return mock_now; +} + +Ev::Io raiseForwardFeeLoop(S::Bus& bus, int count) { + // Creates a series of forwards, one per hour + if (count == 0) { + return Ev::lift(); + } + return bus.raise(Boss::Msg::ForwardFee{ + A, // in_id + B, // out_id + Ln::Amount::sat(1), // fee + 1.0 // resolution_time + }) + .then([&, count]() { + mock_now += 60 * 60; + return raiseForwardFeeLoop(bus, count - 1); + }); +} + +Ev::Io raiseMoveFundsLoop(S::Bus& bus, int count) { + if (count == 0) { + return Ev::lift(); + } + return bus.raise( + Boss::Msg::RequestMoveFunds{ + nullptr, // requester (match ResponseMoveFunds) + C, // source + A, // destination + Ln::Amount::sat(1000), // amount + Ln::Amount::sat(3) // fee_budget + }) + .then([&bus]() { + return bus.raise( + Boss::Msg::ResponseMoveFunds{ + nullptr, // requester (see RequestMoveFunds) + Ln::Amount::sat(1000), // amount_moved + Ln::Amount::sat(2) // fee_spent + }); + }) + .then([&bus, count]() { + mock_now += 24 * 60 * 60; // advance one day + return raiseMoveFundsLoop(bus, count - 1); + }); +} + +int main() { + auto bus = S::Bus(); + + /* Module under test */ + Boss::Mod::EarningsTracker mut(bus, &mock_get_now); + + auto db = Sqlite3::Db(":memory:"); + + auto req_id = std::uint64_t(); + auto lastRsp = Boss::Msg::CommandResponse{}; + auto rsp = false; + bus.subscribe([&](Boss::Msg::CommandResponse const& m) { + lastRsp = m; + rsp = true; + return Ev::yield(); + }); + + + auto code = Ev::lift().then([&]() { + return bus.raise(Boss::Msg::DbResource{ db }); + }).then([&]() { + // Insert 2 months of one fee per hour + mock_now = 1722902400; // stroke of midnight + return raiseForwardFeeLoop(bus, 24 * 60); + }).then([&]() { + // Rewind 1 week and add 7 days of one rebalance per day + mock_now -= (7 * 24 * 60 * 60); + return raiseMoveFundsLoop(bus, 7); + }).then([&]() { + // Check history for all peers + ++req_id; + return bus.raise(Boss::Msg::CommandRequest{ + "clboss-earnings-history", + Jsmn::Object(), + Ln::CommandId::left(req_id) + }); + }).then([&]() { + // std::cerr << lastRsp.response.output() << std::endl; + assert(rsp); + assert(lastRsp.id == Ln::CommandId::left(req_id)); + auto result = Jsmn::Object::parse_json(lastRsp.response.output().c_str()); + assert(result["history"].size() == 60); + assert(result["history"][0] == Jsmn::Object::parse_json(R"JSON( + { + "bucket_time": 1722902400, + "in_earnings": 24000, + "in_expenditures": 0, + "out_earnings": 24000, + "out_expenditures": 0 + } + )JSON")); + assert(result["history"][59] == Jsmn::Object::parse_json(R"JSON( + { + "bucket_time": 1728000000, + "in_earnings": 24000, + "in_expenditures": 2000, + "out_earnings": 24000, + "out_expenditures": 2000 + } + )JSON")); + assert(result["total"] == Jsmn::Object::parse_json(R"JSON( + { + "in_earnings": 1440000, + "in_expenditures": 14000, + "out_earnings": 1440000, + "out_expenditures": 14000 + } + )JSON")); + return Ev::lift(); + }).then([&]() { + // Check history for peer A + ++req_id; + return bus.raise(Boss::Msg::CommandRequest{ + "clboss-earnings-history", + Jsmn::Object::parse_json(R"JSON(["020000000000000000000000000000000000000000000000000000000000000001"])JSON"), + Ln::CommandId::left(req_id) + }); + }).then([&]() { + // std::cerr << lastRsp.response.output() << std::endl; + assert(rsp); + assert(lastRsp.id == Ln::CommandId::left(req_id)); + auto result = Jsmn::Object::parse_json(lastRsp.response.output().c_str()); + assert(result["history"].size() == 60); + assert(result["history"][0] == Jsmn::Object::parse_json(R"JSON( + { + "bucket_time": 1722902400, + "in_earnings": 24000, + "in_expenditures": 0, + "out_earnings": 0, + "out_expenditures": 0 + } + )JSON")); + assert(result["history"][59] == Jsmn::Object::parse_json(R"JSON( + { + "bucket_time": 1728000000, + "in_earnings": 24000, + "in_expenditures": 0, + "out_earnings": 0, + "out_expenditures": 2000 + } + )JSON")); + assert(result["total"] == Jsmn::Object::parse_json(R"JSON( + { + "in_earnings": 1440000, + "in_expenditures": 0, + "out_earnings": 0, + "out_expenditures": 14000 + } + )JSON")); + return Ev::lift(); + }).then([&]() { + // Check history for peer C (should only be one week) + ++req_id; + return bus.raise(Boss::Msg::CommandRequest{ + "clboss-earnings-history", + Jsmn::Object::parse_json(R"JSON(["020000000000000000000000000000000000000000000000000000000000000003"])JSON"), + Ln::CommandId::left(req_id) + }); + }).then([&]() { + // std::cerr << lastRsp.response.output() << std::endl; + assert(rsp); + assert(lastRsp.id == Ln::CommandId::left(req_id)); + auto result = Jsmn::Object::parse_json(lastRsp.response.output().c_str()); + assert(result["history"].size() == 7); + assert(result["history"][0] == Jsmn::Object::parse_json(R"JSON( + { + "bucket_time": 1727481600, + "in_earnings": 0, + "in_expenditures": 2000, + "out_earnings": 0, + "out_expenditures": 0 + } + )JSON")); + assert(result["history"][6] == Jsmn::Object::parse_json(R"JSON( + { + "bucket_time": 1728000000, + "in_earnings": 0, + "in_expenditures": 2000, + "out_earnings": 0, + "out_expenditures": 0 + } + )JSON")); + assert(result["total"] == Jsmn::Object::parse_json(R"JSON( + { + "in_earnings": 0, + "in_expenditures": 14000, + "out_earnings": 0, + "out_expenditures": 0 + } + )JSON")); + return Ev::lift(); + }).then([&]() { + return Ev::lift(0); + }); + + return Ev::start(std::move(code)); +} diff --git a/tests/boss/test_recentearnings.cpp b/tests/boss/test_recentearnings.cpp new file mode 100644 index 000000000..3079a9c40 --- /dev/null +++ b/tests/boss/test_recentearnings.cpp @@ -0,0 +1,213 @@ +#undef NDEBUG + +#include"Boss/Mod/EarningsTracker.hpp" +#include"Boss/Msg/DbResource.hpp" +#include"Boss/Msg/ForwardFee.hpp" +#include"Boss/Msg/RequestMoveFunds.hpp" +#include"Boss/Msg/ResponseMoveFunds.hpp" +#include"Boss/Msg/CommandResponse.hpp" +#include"Boss/Msg/CommandRequest.hpp" +#include"Ev/start.hpp" +#include"Jsmn/Object.hpp" +#include"Jsmn/Parser.hpp" +#include"Json/Out.hpp" +#include"Ln/NodeId.hpp" +#include"S/Bus.hpp" +#include"Sqlite3.hpp" + +#include +#include +#include + +namespace { +auto const A = Ln::NodeId("020000000000000000000000000000000000000000000000000000000000000001"); +auto const B = Ln::NodeId("020000000000000000000000000000000000000000000000000000000000000002"); +auto const C = Ln::NodeId("020000000000000000000000000000000000000000000000000000000000000003"); +} + +double mock_now = 0.0; +double mock_get_now() { + return mock_now; +} + +Ev::Io raiseForwardFeeLoop(S::Bus& bus, int count) { + // Creates a series of forwards, one per hour + if (count == 0) { + return Ev::lift(); + } + return bus.raise(Boss::Msg::ForwardFee{ + A, // in_id + B, // out_id + Ln::Amount::sat(1), // fee + 1.0 // resolution_time + }) + .then([&, count]() { + mock_now += 60 * 60; + return raiseForwardFeeLoop(bus, count - 1); + }); +} + +Ev::Io raiseMoveFundsLoop(S::Bus& bus, int count) { + if (count == 0) { + return Ev::lift(); + } + return bus.raise( + Boss::Msg::RequestMoveFunds{ + nullptr, // requester (match ResponseMoveFunds) + C, // source + B, // destination + Ln::Amount::sat(1000), // amount + Ln::Amount::sat(3) // fee_budget + }) + .then([&bus]() { + return bus.raise( + Boss::Msg::ResponseMoveFunds{ + nullptr, // requester (see RequestMoveFunds) + Ln::Amount::sat(1000), // amount_moved + Ln::Amount::sat(2) // fee_spent + }); + }) + .then([&bus, count]() { + mock_now += 24 * 60 * 60; // advance one day + return raiseMoveFundsLoop(bus, count - 1); + }); +} + +int main() { + auto bus = S::Bus(); + + /* Module under test */ + Boss::Mod::EarningsTracker mut(bus, &mock_get_now); + + auto db = Sqlite3::Db(":memory:"); + + auto req_id = std::uint64_t(); + auto lastRsp = Boss::Msg::CommandResponse{}; + auto rsp = false; + bus.subscribe([&](Boss::Msg::CommandResponse const& m) { + lastRsp = m; + rsp = true; + return Ev::yield(); + }); + + + auto code = Ev::lift().then([&]() { + return bus.raise(Boss::Msg::DbResource{ db }); + }).then([&]() { + ++req_id; + return bus.raise(Boss::Msg::CommandRequest{ + "clboss-recent-earnings", + Jsmn::Object(), + Ln::CommandId::left(req_id) + }); + }).then([&]() { + // With no fees should see an empty result collection + auto result = Jsmn::Object::parse_json(lastRsp.response.output().c_str()); + assert(result["recent"] == Jsmn::Object::parse_json(R"JSON( + {} + )JSON")); + return Ev::lift(); + }).then([&]() { + // Insert 2 months of one fee per hour + mock_now = 1722902400; // stroke of midnight + return raiseForwardFeeLoop(bus, 24 * 60); + }).then([&]() { + // Rewind 1 week and add 7 days of one rebalance per day + mock_now -= (7 * 24 * 60 * 60); + return raiseMoveFundsLoop(bus, 7); + }).then([&]() { + // Default report should include 14 days + ++req_id; + return bus.raise(Boss::Msg::CommandRequest{ + "clboss-recent-earnings", + Jsmn::Object(), + Ln::CommandId::left(req_id) + }); + }).then([&]() { + // std::cerr << lastRsp.response.output() << std::endl; + assert(rsp); + assert(lastRsp.id == Ln::CommandId::left(req_id)); + auto result = Jsmn::Object::parse_json(lastRsp.response.output().c_str()); + assert(result["recent"] == Jsmn::Object::parse_json(R"JSON( + { + "020000000000000000000000000000000000000000000000000000000000000001": { + "in_earnings": 336000, + "in_expenditures": 0, + "out_earnings": 0, + "out_expenditures": 0 + }, + "020000000000000000000000000000000000000000000000000000000000000002": { + "in_earnings": 0, + "in_expenditures": 0, + "out_earnings": 336000, + "out_expenditures": 14000 + }, + "020000000000000000000000000000000000000000000000000000000000000003": { + "in_earnings": 0, + "in_expenditures": 14000, + "out_earnings": 0, + "out_expenditures": 0 + } + } + )JSON")); + assert(result["total"] == Jsmn::Object::parse_json(R"JSON( + { + "in_earnings": 336000, + "in_expenditures": 14000, + "out_earnings": 336000, + "out_expenditures": 14000 + } + )JSON")); + return Ev::lift(); + }).then([&]() { + // Check a 30 day report + ++req_id; + return bus.raise(Boss::Msg::CommandRequest{ + "clboss-recent-earnings", + Jsmn::Object::parse_json(R"JSON( [30] )JSON"), + Ln::CommandId::left(req_id) + }); + }).then([&]() { + // std::cerr << lastRsp.response.output() << std::endl; + assert(rsp); + assert(lastRsp.id == Ln::CommandId::left(req_id)); + auto result = Jsmn::Object::parse_json(lastRsp.response.output().c_str()); + // The expenditures stay the same because they are entirely in the last + // week but the earnings increase. + assert(result["recent"] == Jsmn::Object::parse_json(R"JSON( + { + "020000000000000000000000000000000000000000000000000000000000000001": { + "in_earnings": 720000, + "in_expenditures": 0, + "out_earnings": 0, + "out_expenditures": 0 + }, + "020000000000000000000000000000000000000000000000000000000000000002": { + "in_earnings": 0, + "in_expenditures": 0, + "out_earnings": 720000, + "out_expenditures": 14000 + }, + "020000000000000000000000000000000000000000000000000000000000000003": { + "in_earnings": 0, + "in_expenditures": 14000, + "out_earnings": 0, + "out_expenditures": 0 + } + } + )JSON")); + assert(result["total"] == Jsmn::Object::parse_json(R"JSON( + { + "in_earnings": 720000, + "in_expenditures": 14000, + "out_earnings": 720000, + "out_expenditures": 14000 + } + )JSON")); + return Ev::lift(); + }).then([&]() { + return Ev::lift(0); + }); + + return Ev::start(std::move(code)); +}