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)); +}