diff --git a/contracts/sysio.msgch/include/sysio.msgch/sysio.msgch.hpp b/contracts/sysio.msgch/include/sysio.msgch/sysio.msgch.hpp index e11d93ed00..e2e13b4bf5 100644 --- a/contracts/sysio.msgch/include/sysio.msgch/sysio.msgch.hpp +++ b/contracts/sysio.msgch/include/sysio.msgch/sysio.msgch.hpp @@ -166,6 +166,8 @@ namespace sysio { /// destination's `sysio.chains` `{kind, external_chain_id}`) so the /// receiving outpost can reject an envelope a misconfigured relay /// delivered to the wrong same-kind chain. + /// When the envelope carries DESYNDICATE_LIQ attestations, sends one inline + /// `sysio.synd::retsent` with the envelope's epoch and their request ids. [[sysio::action]] void buildenv(uint64_t chain_code); diff --git a/contracts/sysio.msgch/src/sysio.msgch.cpp b/contracts/sysio.msgch/src/sysio.msgch.cpp index b70b4d250e..5a08492e37 100644 --- a/contracts/sysio.msgch/src/sysio.msgch.cpp +++ b/contracts/sysio.msgch/src/sysio.msgch.cpp @@ -55,6 +55,9 @@ constexpr auto action_onsynd = "onsynd"_n; constexpr auto action_onyield = "onyield"_n; /// Sent once after the envelope's last attestation, only when one of them reached `sysio.synd`. constexpr auto action_closeenv = "closeenv"_n; +/// Sent once per outbound envelope that carried a DESYNDICATE_LIQ, with the envelope's epoch and the +/// request ids it carried. +constexpr auto action_retsent = "retsent"_n; } // namespace synd // System-owned rows bill to the sysio RAM pool, not this contract account (privileged-contract @@ -469,6 +472,17 @@ bool dispatch_syndicate_liq(name self, const std::vector& data, uint64_t c return true; } +/// The request id of the DESYNDICATE_LIQ attestation `data`, or `std::nullopt` when the bytes do not decode +/// or carry no id (0, which the outpost reads as "no id"). `sysio.synd` queues every DESYNDICATE_LIQ with a +/// nonzero id. Anything else is skipped rather than aborting, because `buildenv` runs inside +/// `sysio.epoch::advance`. +std::optional desyndication_request_id(const std::vector& data) { + opp::attestations::DesyndicateLIQ desyndication; + auto in = zpp::bits::in{std::span{data.data(), data.size()}, zpp::bits::no_size{}}; + if (in(desyndication) != zpp::bits::errc{} || desyndication.request_id == 0) return std::nullopt; + return desyndication.request_id; +} + /// LIQ_YIELD: the outpost claimed yield for its syndicated pool since its last report. After the /// same checks as a syndication it goes to `sysio.synd::onyield` with the envelope's identity, /// which holds the amount as a number until the envelope is underwritten; nothing is minted on this @@ -1707,9 +1721,15 @@ void msgch::buildenv(uint64_t chain_code) { // The shipped rows' bytes are in `packed` now: erase them, and count the general-lane ones out of the // outpost's queue. Rows the walk stopped before, or the trim loop popped, stay READY for the next // epoch's `buildenv` call. - uint32_t general_shipped = 0; + uint32_t general_shipped = 0; + std::vector shipped_returns; for (size_t i = 0; i < included_ids.size(); ++i) { if (lane_of(entries[i].type) == queue_lane::general) ++general_shipped; + if (entries[i].type == AttestationType::ATTESTATION_TYPE_DESYNDICATE_LIQ) { + if (const auto request_id = desyndication_request_id(entries[i].data)) { + shipped_returns.push_back(*request_id); + } + } atts.erase(id_key{included_ids[i]}); } if (general_shipped > 0) { @@ -1719,6 +1739,12 @@ void msgch::buildenv(uint64_t chain_code) { queue.queued -= std::min(queue.queued, general_shipped); queues.set(ram_payer, outbound_queue_key{chain_code}, queue); } + // sysio.synd records this envelope's epoch on each shipped desyndication's `returns` row. A row can wait + // in the queue past several envelopes, so only this build knows which envelope carried it. + if (!shipped_returns.empty()) { + action(permission_level{get_self(), "active"_n}, synd::account, synd::action_retsent, + std::make_tuple(sysio::slug_name{chain_code}, epoch, shipped_returns)).send(); + } // This envelope's canonical epoch digest: keccak256 over the packed bytes, which equal the // digest preimage because `envelope_hash` is empty on the wire. This is the value the receiving diff --git a/contracts/sysio.msgch/sysio.msgch.wasm b/contracts/sysio.msgch/sysio.msgch.wasm index 676c0fb7ad..fbf2d55f79 100755 Binary files a/contracts/sysio.msgch/sysio.msgch.wasm and b/contracts/sysio.msgch/sysio.msgch.wasm differ diff --git a/contracts/sysio.synd/include/sysio.synd/sysio.synd.hpp b/contracts/sysio.synd/include/sysio.synd/sysio.synd.hpp index 1e7ad2476b..77fce33c44 100644 --- a/contracts/sysio.synd/include/sysio.synd/sysio.synd.hpp +++ b/contracts/sysio.synd/include/sysio.synd/sysio.synd.hpp @@ -69,6 +69,11 @@ * flat part covers the external-chain gas the batch operators pay for the round trip. The rest is burned with * `sysio.liq::burn`, queued as DESYNDICATE_LIQ to the token's outpost with the next request id, and * retained in `returns` until governance establishes its external outcome. The bucket drops by the whole quantity. + * `sysio.msgch::buildenv` reports through `retsent` which outbound envelope carried each DESYNDICATE_LIQ, + * and the row records that envelope's depot epoch in `sent_epoch`. It stays 0 while the attestation waits + * in the outbound queue, which can hold it past several envelopes. The outpost has consumed the row's envelope + * once `sysio.msgch`'s `outpcons` epoch for the outpost reaches the row's `sent_epoch`: an outpost emits its + * envelope for an epoch only after accepting the depot's. * The attestation carries `total_syndicated`, the depot's outstanding shadow of the symbol * after the burn (`liq::outstanding_of`), for the outpost's custody check. * @@ -105,12 +110,12 @@ * Launch. `importsynd` replays pre-launch positions inside the epoch-0 bootstrap window, minting * straight to a linked account or into `parked`; `importdone` closes the import for good. * - * Never-throw: `onsynd`, `onyield` and `closeenv` run inside `sysio.msgch`'s consensus transaction. - * They `check` nothing on their input; a message that fails a rule is dropped with a `DROP` - * diagnostic on the console and consumes no sequence. The inline `sysio.liq::mint` of a held - * syndication is sent only after every condition that `mint` checks has been verified here. Receipt - * actions accept only self-inline calls; the queue step `closeenv` runs keeps the same discipline - * for every action it sends. + * Never-throw: `onsynd`, `onyield` and `closeenv` run inside `sysio.msgch`'s consensus transaction, and + * `retsent` inside `sysio.epoch::advance`. They `check` nothing on their input; a message that fails a + * rule is dropped with a `DROP` diagnostic on the console and consumes no sequence. The inline + * `sysio.liq::mint` of a held syndication is sent only after every condition that `mint` checks has been + * verified here. Receipt actions accept only self-inline calls; the queue step `closeenv` runs keeps the + * same discipline for every action it sends. * * Privileged (roa::setsyscode): every row bills the `sysio` RAM pool -- there is no signer to bill on * the envelope path -- and inline actions carry this contract's own authority without a @@ -449,6 +454,15 @@ namespace sysio { [[sysio::action, sysio::read_only]] desynd_quote quotedesynd(slug_name chain_code, slug_name token_code, uint64_t quantity); + /// The outbound envelope of outpost `chain_code` built for depot epoch `epoch_index` carried the + /// DESYNDICATE_LIQ attestations of `request_ids`. Each listed `returns` row of that outpost records + /// `epoch_index` as its `sent_epoch`, once: a row already stamped keeps its epoch. An id with no row (governance has + /// resolved it) or whose row belongs to another outpost is dropped with a diagnostic. Sent by + /// `sysio.msgch::buildenv` inside `sysio.epoch::advance`, only when the envelope carried a + /// DESYNDICATE_LIQ; never throws. Auth=sysio.msgch. + [[sysio::action]] void retsent(sysio::slug_name chain_code, uint32_t epoch_index, + std::vector request_ids); + // ----------------------------------------------------------------------- // Launch ingestion (privileged caller, epoch-0 bootstrap window) // ----------------------------------------------------------------------- @@ -658,7 +672,11 @@ namespace sysio { opp::types::ChainKind chain_kind; ///< destination key family std::vector pubkey; ///< destination captured when queued uint64_t amount = 0; ///< exact net burned amount; the fee is not refundable here - SYSLIB_SERIALIZE(return_row, (request_id)(holder)(chain_code)(token_code)(chain_kind)(pubkey)(amount)) + /// Depot epoch of the outbound envelope that carried the DESYNDICATE_LIQ, from `retsent`; 0 until + /// that envelope is built. No envelope is built for epoch 0. + uint32_t sent_epoch = 0; + SYSLIB_SERIALIZE(return_row, + (request_id)(holder)(chain_code)(token_code)(chain_kind)(pubkey)(amount)(sent_epoch)) }; /// Outstanding returns only; completed/refunded rows are erased atomically. using returns_t = kv::table<"returns"_n, return_key, return_row>; diff --git a/contracts/sysio.synd/src/sysio.synd.cpp b/contracts/sysio.synd/src/sysio.synd.cpp index 111824e9f8..60aea87ca4 100644 --- a/contracts/sysio.synd/src/sysio.synd.cpp +++ b/contracts/sysio.synd/src/sysio.synd.cpp @@ -128,10 +128,11 @@ constexpr std::string_view desynd_net_message = "desyndication net of the fee constexpr std::string_view desynd_fee_flat_message = "flat desyndication fee must fit an asset and be below the minimum desyndication"; -/// Diagnostic path names of the intake actions. -constexpr std::string_view onsynd_path = "onsynd"; -constexpr std::string_view onyield_path = "onyield"; -constexpr std::string_view closeenv_path = "closeenv"; +/// Diagnostic path names of the intake actions and of `retsent`. +constexpr std::string_view onsynd_path = "onsynd"; +constexpr std::string_view onyield_path = "onyield"; +constexpr std::string_view closeenv_path = "closeenv"; +constexpr std::string_view retsent_path = "retsent"; /// Drop reasons, one per rule an intake message can fail. constexpr std::string_view pubkey_misfit_reason = "pubkey does not fit the chain family"; @@ -147,6 +148,11 @@ constexpr std::string_view envelope_closed_reason = "envelope already closed or constexpr std::string_view replay_reason = "replayed sequence"; constexpr std::string_view digest_reason = "envelope digest does not match; left OPEN"; +/// Why `retsent` left a request id's row as it was. +constexpr std::string_view return_resolved_reason = "no outstanding return for the request id"; +constexpr std::string_view return_outpost_reason = "the return belongs to another outpost"; +constexpr std::string_view return_stamped_reason = "the return already records the envelope that carried it"; + /// Why one step of the queue left an envelope or an item where it was. constexpr std::string_view no_bond_reason = "sysio.bond is not deployed; the envelope stays WAITING"; constexpr std::string_view bond_token_reason = "sysio.bond cannot bond the token; the envelope stays WAITING"; @@ -1589,6 +1595,29 @@ void synd::desyndicate(name holder, asset quantity, uint64_t max_fee) { .send(); } +void synd::retsent(sysio::slug_name chain_code, uint32_t epoch_index, std::vector request_ids) { + require_auth(MSGCH_ACCOUNT); + returns_t pending(get_self()); + for (const uint64_t request_id : request_ids) { + const return_key key{request_id}; + auto row = pending.try_get(key); + if (!row.has_value()) { + drop(retsent_path, return_resolved_reason); + continue; + } + if (row->chain_code != chain_code) { + drop(retsent_path, return_outpost_reason); + continue; + } + if (row->sent_epoch != 0) { + drop(retsent_path, return_stamped_reason); + continue; + } + row->sent_epoch = epoch_index; + pending.set(ram_payer, key, *row); + } +} + // --------------------------------------------------------------------------- // Launch ingestion // --------------------------------------------------------------------------- diff --git a/contracts/sysio.synd/sysio.synd.abi b/contracts/sysio.synd/sysio.synd.abi index 347ec4af6c..f0125400a1 100644 --- a/contracts/sysio.synd/sysio.synd.abi +++ b/contracts/sysio.synd/sysio.synd.abi @@ -906,6 +906,24 @@ } ] }, + { + "name": "retsent", + "base": "", + "fields": [ + { + "name": "chain_code", + "type": "slug_name" + }, + { + "name": "epoch_index", + "type": "uint32" + }, + { + "name": "request_ids", + "type": "uint64[]" + } + ] + }, { "name": "return_key", "base": "", @@ -947,6 +965,10 @@ { "name": "amount", "type": "uint64" + }, + { + "name": "sent_epoch", + "type": "uint32" } ] }, @@ -1313,6 +1335,11 @@ "type": "refundreturn", "ricardian_contract": "" }, + { + "name": "retsent", + "type": "retsent", + "ricardian_contract": "" + }, { "name": "setconfig", "type": "setconfig", diff --git a/contracts/sysio.synd/sysio.synd.wasm b/contracts/sysio.synd/sysio.synd.wasm index be0e13e721..197ec1c61c 100755 Binary files a/contracts/sysio.synd/sysio.synd.wasm and b/contracts/sysio.synd/sysio.synd.wasm differ diff --git a/contracts/tests/sysio.synd_tests.cpp b/contracts/tests/sysio.synd_tests.cpp index 8078a5645c..3e8ce2c715 100644 --- a/contracts/tests/sysio.synd_tests.cpp +++ b/contracts/tests/sysio.synd_tests.cpp @@ -431,6 +431,17 @@ class sysio_synd_tester : public tester { return push(SYND_ACCOUNT, synd_abi_ser, signer == name{} ? holder : signer, "desyndicate"_n, mvo() ("holder", holder)("quantity", asset(static_cast(amount), sym))("max_fee", max_fee)); } + /// The `sysio.msgch::buildenv` notice that `chain_code`'s envelope of `epoch` carried `request_ids`. + action_result retsent(std::string_view chain_code, uint32_t epoch, const std::vector& request_ids, + name signer = MSGCH_ACCOUNT) { + return push(SYND_ACCOUNT, synd_abi_ser, signer, "retsent"_n, mvo() + ("chain_code", codename_mvo(chain_code))("epoch_index", epoch)("request_ids", request_ids)); + } + /// Build `chain_code`'s outbound envelope at the depot's current epoch, as `sysio.epoch::advance` does. + action_result buildenv(std::string_view chain_code) { + return push(MSGCH_ACCOUNT, msgch_abi_ser, EPOCH_ACCOUNT, "buildenv"_n, + mvo()("chain_code", slug_value(chain_code))); + } /// Query the fee path and retain its return trace, including refusal diagnostics. action_result quotedesynd(std::string_view chain, std::string_view token, uint64_t quantity) { return push(SYND_ACCOUNT, synd_abi_ser, SYND_ACCOUNT, "quotedesynd"_n, mvo() @@ -819,6 +830,12 @@ class sysio_synd_tester : public tester { fc::variant return_row(uint64_t request_id) { return decode(synd_abi_ser, "return_row", get_row_by_id(SYND_ACCOUNT, SYND_ACCOUNT, "returns"_n, request_id)); } + /// The `sent_epoch` of `request_id`'s `returns` row: the epoch of the envelope that carried it, 0 until then. + uint32_t sent_epoch(uint64_t request_id) { + const auto row = return_row(request_id); + BOOST_REQUIRE(!row.is_null()); + return row["sent_epoch"].as(); + } fc::variant pool_row(std::string_view token_code) { return decode(synd_abi_ser, "pool_row", get_row_by_id(SYND_ACCOUNT, SYND_ACCOUNT, "yieldpool"_n, slug_value(token_code))); @@ -1597,6 +1614,7 @@ BOOST_FIXTURE_TEST_CASE(outstanding_returns_record_the_original_obligation, sysi BOOST_REQUIRE_EQUAL(amount, row["amount"].as_uint64()); BOOST_REQUIRE_EQUAL("alice", row["holder"].as_string()); BOOST_REQUIRE_EQUAL(32u, row["pubkey"].as>().size()); + BOOST_REQUIRE_EQUAL(0u, row["sent_epoch"].as()); // no envelope built yet } }; check_log(1, LIQSOL, 10 * UNIT); @@ -1652,6 +1670,183 @@ BOOST_FIXTURE_TEST_CASE(return_refund_failure_preserves_obligation, sysio_synd_t BOOST_REQUIRE_EQUAL(0, liq_balance("alice"_n)); } FC_LOG_AND_RETHROW() +/// The request ids of the `retsent` notices the last pushed transaction ran, in order. +std::vector> retsent_notices(sysio_synd_tester& t) { + std::vector> notices; + if (!t.last_trace) return notices; + for (const auto& at : t.last_trace->action_traces) { + if (at.receiver != sysio_synd_tester::SYND_ACCOUNT || at.act.account != sysio_synd_tester::SYND_ACCOUNT || + at.act.name != "retsent"_n) + continue; + const auto data = t.synd_abi_ser.binary_to_variant("retsent", at.act.data, + sysio_synd_tester::fixture_abi_yield_function()); + notices.push_back(data["request_ids"].as>()); + } + return notices; +} + +/// A `returns` row records the epoch of the outbound envelope that carried its DESYNDICATE_LIQ: 0 while the +/// attestation is queued, then the epoch `buildenv` built the envelope at. Desyndications that ride one +/// envelope share its epoch; a later envelope's epoch leaves earlier rows as they were. +BOOST_FIXTURE_TEST_CASE(a_return_records_the_epoch_of_the_envelope_that_carried_it, sysio_synd_tester) try { + BOOST_REQUIRE_EQUAL(success(), liq_mint("alice"_n, LIQSOL, 100 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), link_svm("alice"_n, ed_signer())); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {})); + BOOST_REQUIRE_EQUAL(success(), set_epoch(3)); + BOOST_REQUIRE_EQUAL(success(), desyndicate("alice"_n, 10 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), desyndicate("alice"_n, 7 * UNIT)); + BOOST_REQUIRE_EQUAL(0u, sent_epoch(1)); + BOOST_REQUIRE_EQUAL(0u, sent_epoch(2)); + + // ETH's envelope ships ETH's own row and none of SOLANA's: no notice, nothing stamped. + BOOST_REQUIRE_EQUAL(success(), push(MSGCH_ACCOUNT, msgch_abi_ser, MSGCH_ACCOUNT, "queueout"_n, mvo() + ("chain_code", slug_value(ETH)) + ("attest_type", static_cast(sysio::opp::types::ATTESTATION_TYPE_OPERATOR_ACTION)) + ("data", std::vector{'\x01'}))); + BOOST_REQUIRE(!attestation_row(3).is_null()); + BOOST_REQUIRE_EQUAL(success(), set_epoch(4)); + BOOST_REQUIRE_EQUAL(success(), buildenv(ETH)); + BOOST_REQUIRE(attestation_row(3).is_null()); + BOOST_REQUIRE(retsent_notices(*this).empty()); + BOOST_REQUIRE_EQUAL(0u, sent_epoch(1)); + + // `advance` builds SOLANA's envelope at the epoch it just started: one notice for both rows. + BOOST_REQUIRE_EQUAL(success(), buildenv(SOLANA)); + BOOST_REQUIRE(attestation_row(1).is_null()); + BOOST_REQUIRE(attestation_row(2).is_null()); + BOOST_REQUIRE((retsent_notices(*this) == std::vector>{{1, 2}})); + BOOST_REQUIRE_EQUAL(4u, sent_epoch(1)); + BOOST_REQUIRE_EQUAL(4u, sent_epoch(2)); + + // A desyndication in epoch 4 rides the envelope built at epoch 5. + BOOST_REQUIRE_EQUAL(success(), desyndicate("alice"_n, 5 * UNIT)); + BOOST_REQUIRE_EQUAL(0u, sent_epoch(3)); + BOOST_REQUIRE_EQUAL(success(), set_epoch(5)); + BOOST_REQUIRE_EQUAL(success(), buildenv(SOLANA)); + BOOST_REQUIRE((retsent_notices(*this) == std::vector>{{3}})); + BOOST_REQUIRE_EQUAL(4u, sent_epoch(1)); + BOOST_REQUIRE_EQUAL(4u, sent_epoch(2)); + BOOST_REQUIRE_EQUAL(5u, sent_epoch(3)); +} FC_LOG_AND_RETHROW() + +/// Desyndications all queued in epoch 1 spill across three envelopes, and each row records the epoch of the +/// envelope it actually shipped in. Two desyndications queued back to back land in different envelopes when +/// the first fills one. The rows between them carry DESYNDICATE_LIQ bytes with no request id: `buildenv` +/// skips them, and an envelope that carries only such rows sends no notice. +BOOST_FIXTURE_TEST_CASE(returns_spilled_across_envelopes_record_the_envelope_each_shipped_in, + sysio_synd_tester) try { + constexpr uint32_t MAX_ENVELOPE_ATTESTATIONS = 64; // mirrors sysio.msgch.hpp + constexpr uint32_t QUEUEOUTS_PER_BLOCK = 20; + BOOST_REQUIRE_EQUAL(success(), liq_mint("alice"_n, LIQSOL, 100 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {})); + BOOST_REQUIRE_EQUAL(success(), link_svm("alice"_n, ed_signer())); + BOOST_REQUIRE_EQUAL(success(), set_epoch(1)); + + // `next_attestation` is the id the next queued attestation takes. + uint64_t next_attestation = 1; + // Queue `count` rows with no request id. + const auto queue_filler = [&](uint32_t count) { + for (uint32_t i = 0; i < count; ++i) { + BOOST_REQUIRE_EQUAL(success(), push(MSGCH_ACCOUNT, msgch_abi_ser, MSGCH_ACCOUNT, "queueout"_n, mvo() + ("chain_code", slug_value(SOLANA)) + ("attest_type", static_cast(sysio::opp::types::ATTESTATION_TYPE_DESYNDICATE_LIQ)) + ("data", std::vector{static_cast(i >> 8), static_cast(i)}))); + ++next_attestation; + if ((i + 1) % QUEUEOUTS_PER_BLOCK == 0) produce_block(); + } + produce_block(); + }; + const auto queue_desyndication = [&](uint64_t amount) { + BOOST_REQUIRE_EQUAL(success(), desyndicate("alice"_n, amount)); + BOOST_REQUIRE_EQUAL("ATTESTATION_TYPE_DESYNDICATE_LIQ", + attestation_row(next_attestation)["type"].as_string()); + ++next_attestation; + }; + + // Attestations 1-64 are filler; requests 1 and 2 take 65 and 66; filler takes 67-127; requests 3 and 4 + // take 128 and 129. + queue_filler(MAX_ENVELOPE_ATTESTATIONS); + queue_desyndication(10 * UNIT); + queue_desyndication(5 * UNIT); + queue_filler(MAX_ENVELOPE_ATTESTATIONS - 3); + queue_desyndication(4 * UNIT); + queue_desyndication(3 * UNIT); + BOOST_REQUIRE_EQUAL(2 * MAX_ENVELOPE_ATTESTATIONS + 2, next_attestation); + + // The epoch-2 envelope carries only filler. + BOOST_REQUIRE_EQUAL(success(), set_epoch(2)); + BOOST_REQUIRE_EQUAL(success(), buildenv(SOLANA)); + BOOST_REQUIRE(!executed(SYND_ACCOUNT, "retsent"_n)); + for (uint64_t id = 1; id <= 4; ++id) BOOST_REQUIRE_EQUAL(0u, sent_epoch(id)); + + // The epoch-3 envelope carries requests 1, 2 and 3 and fills before request 4. + BOOST_REQUIRE_EQUAL(success(), set_epoch(3)); + BOOST_REQUIRE_EQUAL(success(), buildenv(SOLANA)); + BOOST_REQUIRE((retsent_notices(*this) == std::vector>{{1, 2, 3}})); + BOOST_REQUIRE_EQUAL(3u, sent_epoch(1)); + BOOST_REQUIRE_EQUAL(3u, sent_epoch(2)); + BOOST_REQUIRE_EQUAL(3u, sent_epoch(3)); + BOOST_REQUIRE_EQUAL(0u, sent_epoch(4)); + BOOST_REQUIRE(!attestation_row(2 * MAX_ENVELOPE_ATTESTATIONS + 1).is_null()); + + BOOST_REQUIRE_EQUAL(success(), set_epoch(4)); + BOOST_REQUIRE_EQUAL(success(), buildenv(SOLANA)); + BOOST_REQUIRE((retsent_notices(*this) == std::vector>{{4}})); + BOOST_REQUIRE(attestation_row(2 * MAX_ENVELOPE_ATTESTATIONS + 1).is_null()); + BOOST_REQUIRE_EQUAL(3u, sent_epoch(3)); + BOOST_REQUIRE_EQUAL(4u, sent_epoch(4)); +} FC_LOG_AND_RETHROW() + +/// A return governance resolves before its envelope is built leaves the build whole: the notice for its +/// request id finds no row and is dropped with a diagnostic. +BOOST_FIXTURE_TEST_CASE(a_return_resolved_before_its_envelope_is_built_leaves_the_build_whole, + sysio_synd_tester) try { + BOOST_REQUIRE_EQUAL(success(), liq_mint("alice"_n, LIQSOL, 100 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), link_svm("alice"_n, ed_signer())); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {})); + BOOST_REQUIRE_EQUAL(success(), desyndicate("alice"_n, 10 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), desyndicate("alice"_n, 5 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), + push(SYND_ACCOUNT, synd_abi_ser, SYSIO_ACCOUNT, "finishreturn"_n, mvo()("request_id", 1))); + + BOOST_REQUIRE_EQUAL(success(), set_epoch(1)); + BOOST_REQUIRE_EQUAL(success(), buildenv(SOLANA)); + BOOST_REQUIRE((retsent_notices(*this) == std::vector>{{1, 2}})); + BOOST_REQUIRE(console_has("sysio.synd::retsent: DROP -- no outstanding return for the request id")); + BOOST_REQUIRE(return_row(1).is_null()); + BOOST_REQUIRE_EQUAL(1u, sent_epoch(2)); +} FC_LOG_AND_RETHROW() + +/// `retsent` is sysio.msgch's alone. It stamps a row once, only for the outpost whose envelope carried +/// it, and drops every id it cannot stamp without aborting. +BOOST_FIXTURE_TEST_CASE(retsent_stamps_each_return_once_for_its_own_outpost, sysio_synd_tester) try { + BOOST_REQUIRE_EQUAL(success(), liq_mint("alice"_n, LIQSOL, 100 * UNIT)); + BOOST_REQUIRE_EQUAL(success(), link_svm("alice"_n, ed_signer())); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {})); + BOOST_REQUIRE_EQUAL(success(), desyndicate("alice"_n, 10 * UNIT)); + + BOOST_REQUIRE(mentions(retsent(SOLANA, 7, {1}, "alice"_n), "missing authority of sysio.msgch")); + BOOST_REQUIRE(mentions(retsent(SOLANA, 7, {1}, SYND_ACCOUNT), "missing authority of sysio.msgch")); + BOOST_REQUIRE_EQUAL(0u, sent_epoch(1)); + + BOOST_REQUIRE_EQUAL(success(), retsent(SOLANA, 7, {})); + BOOST_REQUIRE_EQUAL(0u, sent_epoch(1)); + + BOOST_REQUIRE_EQUAL(success(), retsent(ETH, 7, {1})); + BOOST_REQUIRE(console_has("sysio.synd::retsent: DROP -- the return belongs to another outpost")); + BOOST_REQUIRE_EQUAL(0u, sent_epoch(1)); + + BOOST_REQUIRE_EQUAL(success(), retsent(SOLANA, 7, {99, 1})); + BOOST_REQUIRE(console_has("sysio.synd::retsent: DROP -- no outstanding return for the request id")); + BOOST_REQUIRE_EQUAL(7u, sent_epoch(1)); + BOOST_REQUIRE(return_row(99).is_null()); + + BOOST_REQUIRE_EQUAL(success(), retsent(SOLANA, 9, {1})); + BOOST_REQUIRE( + console_has("sysio.synd::retsent: DROP -- the return already records the envelope that carried it")); + BOOST_REQUIRE_EQUAL(7u, sent_epoch(1)); +} FC_LOG_AND_RETHROW() + /// The DESYNDICATE_LIQ attestation `id` queued, decoded. sysio::opp::attestations::DesyndicateLIQ queued_desyndication(sysio_synd_tester& t, uint64_t id) { const auto att = t.attestation_row(id);