diff --git a/Boss/Mod/EarningsTracker.cpp b/Boss/Mod/EarningsTracker.cpp index a141e0094..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" @@ -12,13 +17,17 @@ #include"S/Bus.hpp" #include"Sqlite3.hpp" #include"Util/make_unique.hpp" + +#include #include +#include namespace Boss { namespace Mod { class EarningsTracker::Impl { private: S::Bus& bus; + std::function get_now; Sqlite3::Db db; /* Information of a pending MoveFunds. */ @@ -51,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) @@ -62,28 +156,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() ; } @@ -93,16 +238,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() ; @@ -110,9 +258,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() ; @@ -140,8 +290,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 @@ -150,9 +301,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() ; @@ -163,11 +316,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() ; @@ -192,11 +347,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() @@ -228,11 +384,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; @@ -281,19 +439,150 @@ 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; 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_)) { } + +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 6c059329b..9f47e2ea1 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,10 @@ class EarningsTracker { ~EarningsTracker(); explicit - EarningsTracker(S::Bus& bus); + 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/Jsmn/Object.cpp b/Jsmn/Object.cpp index 8333f604c..141e42b3e 100644 --- a/Jsmn/Object.cpp +++ b/Jsmn/Object.cpp @@ -302,6 +302,59 @@ 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; +} + +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 e2bca4b7d..42d9a6b22 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; @@ -69,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..783efc9c1 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 \ @@ -598,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 \ @@ -621,6 +624,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/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/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) */ 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_earningstracker.cpp b/tests/boss/test_earningstracker.cpp new file mode 100644 index 000000000..fe6cb0270 --- /dev/null +++ b/tests/boss/test_earningstracker.cpp @@ -0,0 +1,218 @@ +#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"); +} + +double mock_now = 0.0; +double mock_get_now() { + return mock_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 */ + Boss::Mod::EarningsTracker mut(bus, &mock_get_now); + + 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([&]() { + mock_now = 1000.0; + return bus.raise( + Boss::Msg::ForwardFee{ + A, // in_id + B, // out_id + Ln::Amount::sat(1), // fee + 1.0 // resolution_time + }); + }).then([&]() { + mock_now = 2000.0; + 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([&]() { + mock_now = 3000.0; + 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([&]() { + mock_now = 4000.0; + 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([&]() { + mock_now = 5000.0; + 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)); +} 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 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)); +} 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; +}