diff --git a/contracts/sysio.synd/include/sysio.synd/sysio.synd.hpp b/contracts/sysio.synd/include/sysio.synd/sysio.synd.hpp index 77fce33c44..f53f52439a 100644 --- a/contracts/sysio.synd/include/sysio.synd/sysio.synd.hpp +++ b/contracts/sysio.synd/include/sysio.synd/sysio.synd.hpp @@ -442,6 +442,10 @@ namespace sysio { /// `max_fee` caps the fee in shadow base units before any bucket tick or movement. [[sysio::action]] void desyndicate(name holder, asset quantity, uint64_t max_fee); + /// Self-inline continuation after the recorded return's transfer and burn. Reads live outstanding + /// shadow and queues its attestation without an intervening callback. Direct calls are refused. + [[sysio::action]] void emitdesynd(uint64_t request_id); + /// Current desyndication fee and amount released, both in shadow base units. struct desynd_quote { uint64_t fee; ///< Flat plus floored basis-point charge. diff --git a/contracts/sysio.synd/src/sysio.synd.cpp b/contracts/sysio.synd/src/sysio.synd.cpp index 60aea87ca4..7fa92134be 100644 --- a/contracts/sysio.synd/src/sysio.synd.cpp +++ b/contracts/sysio.synd/src/sysio.synd.cpp @@ -69,6 +69,8 @@ constexpr name creditowed_action = "creditowed"_n; constexpr name transfer_action = "transfer"_n; /// `sysio.msgch`'s outbound queue. constexpr name queueout_action = "queueout"_n; +/// Final desyndication emission, after the holder's transfer notifications and the burn. +constexpr name emitdesynd_action = "emitdesynd"_n; /// `sysio.liq::mintyield`, which a released yield item sends. constexpr name mintyield_action = "mintyield"_n; /// `sysio.bond::hold`, which a challenge sends, and `sysio.bond::claim`, which pulls what a ruling awards @@ -1548,10 +1550,6 @@ void synd::desyndicate(name holder, asset quantity, uint64_t max_fee) { // quantity or more leaves nothing to release and is refused before the subtraction. const uint64_t fee = quote.fee; const uint64_t amount = quote.net; - // The depot's outstanding shadow once the burn below has run, read from `st` before it: the burn is - // inline, so nothing has moved supply yet. The net was part of the holder's balance, so it is within - // the supply and the difference cannot underflow. - const uint64_t total_syndicated = liq::outstanding_of(LIQ_ACCOUNT, st) - amount; // The fee pot's position settles before the transfer that backs the fee arrives; no distribution runs // between this action and its inline transfer, so both settle at one index. The quantity moves to this @@ -1578,12 +1576,23 @@ void synd::desyndicate(name holder, asset quantity, uint64_t max_fee) { .amount = amount, }); + // send() queues children: the transfer may re-enter desyndicate before either burn has run. + // Read outstanding only in the final child, after this request's transfer and burn finish. + action(active_of(get_self()), get_self(), emitdesynd_action, std::make_tuple(request_id)).send(); +} + +void synd::emitdesynd(uint64_t request_id) { + require_receipt_origin(get_self()); + const auto row = returns_t(get_self()).get(return_key{request_id}, outstanding_return_not_found_msg); + const auto st = liq::find_stat_by_token(LIQ_ACCOUNT, row.token_code); + check(st.has_value(), no_shadow_reason.data()); + opp::attestations::DesyndicateLIQ msg; - msg.chain_code = st.chain_code.value; - msg.user = opp::types::ChainAddress{*kind, *pubkey}; - msg.amount = opp::types::TokenAmount{st.token_code.value, static_cast(amount)}; + msg.chain_code = row.chain_code.value; + msg.user = opp::types::ChainAddress{row.chain_kind, row.pubkey}; + msg.amount = opp::types::TokenAmount{row.token_code.value, static_cast(row.amount)}; msg.request_id = request_id; - msg.total_syndicated = total_syndicated; + msg.total_syndicated = liq::outstanding_of(LIQ_ACCOUNT, *st); // `no_size{}`: raw protobuf bytes, the form the outpost decodes the attestation `data` field as // (the same encoding sysio.opreg's emitters use). std::vector encoded; @@ -1591,7 +1600,7 @@ void synd::desyndicate(name holder, asset quantity, uint64_t max_fee) { (void)out(msg); action(active_of(get_self()), MSGCH_ACCOUNT, queueout_action, - std::make_tuple(st.chain_code.value, AttestationType::ATTESTATION_TYPE_DESYNDICATE_LIQ, encoded)) + std::make_tuple(row.chain_code.value, AttestationType::ATTESTATION_TYPE_DESYNDICATE_LIQ, encoded)) .send(); } diff --git a/contracts/sysio.synd/sysio.synd.abi b/contracts/sysio.synd/sysio.synd.abi index f0125400a1..0fa2a7d8a3 100644 --- a/contracts/sysio.synd/sysio.synd.abi +++ b/contracts/sysio.synd/sysio.synd.abi @@ -193,6 +193,16 @@ } ] }, + { + "name": "emitdesynd", + "base": "", + "fields": [ + { + "name": "request_id", + "type": "uint64" + } + ] + }, { "name": "envelope_key", "base": "", @@ -1265,6 +1275,11 @@ "type": "dropenv", "ricardian_contract": "" }, + { + "name": "emitdesynd", + "type": "emitdesynd", + "ricardian_contract": "" + }, { "name": "finishreturn", "type": "finishreturn", diff --git a/contracts/sysio.synd/sysio.synd.wasm b/contracts/sysio.synd/sysio.synd.wasm index 197ec1c61c..1edbbdf970 100755 Binary files a/contracts/sysio.synd/sysio.synd.wasm and b/contracts/sysio.synd/sysio.synd.wasm differ diff --git a/contracts/test_contracts/CMakeLists.txt b/contracts/test_contracts/CMakeLists.txt index f264d3e9db..f91afcb81e 100644 --- a/contracts/test_contracts/CMakeLists.txt +++ b/contracts/test_contracts/CMakeLists.txt @@ -1,6 +1,7 @@ add_subdirectory(blockinfo_tester) add_subdirectory(sendinline) add_subdirectory(reenter_deposit) +add_subdirectory(reenter_desyndicate) add_subdirectory(block_transfer) add_subdirectory(epoch_stub) add_subdirectory(badtoken) diff --git a/contracts/test_contracts/reenter_desyndicate/CMakeLists.txt b/contracts/test_contracts/reenter_desyndicate/CMakeLists.txt new file mode 100644 index 0000000000..cac733ae9d --- /dev/null +++ b/contracts/test_contracts/reenter_desyndicate/CMakeLists.txt @@ -0,0 +1,4 @@ +# Like reenter_deposit, the regression consumes committed artifacts under the source tree. +if(BUILD_SYSTEM_CONTRACTS) + add_contract(reenter_desyndicate reenter_desyndicate reenter_desyndicate.cpp) +endif() diff --git a/contracts/test_contracts/reenter_desyndicate/reenter_desyndicate.abi b/contracts/test_contracts/reenter_desyndicate/reenter_desyndicate.abi new file mode 100644 index 0000000000..7c03cd8b2c --- /dev/null +++ b/contracts/test_contracts/reenter_desyndicate/reenter_desyndicate.abi @@ -0,0 +1,55 @@ +{ + "____comment": "This file was generated with sysio-abigen. DO NOT EDIT ", + "version": "sysio::abi/1.2", + "types": [], + "structs": [ + { + "name": "arm", + "base": "", + "fields": [ + { + "name": "quantity", + "type": "asset" + }, + { + "name": "max_fee", + "type": "uint64" + } + ] + }, + { + "name": "armed_request", + "base": "", + "fields": [ + { + "name": "quantity", + "type": "asset" + }, + { + "name": "max_fee", + "type": "uint64" + } + ] + } + ], + "actions": [ + { + "name": "arm", + "type": "arm", + "ricardian_contract": "" + } + ], + "tables": [ + { + "name": "armed", + "type": "armed_request", + "index_type": "i64", + "key_names": ["scope","primary_key"], + "key_types": ["name","uint64"], + "table_id": 49186 + } + ], + "ricardian_clauses": [], + "variants": [], + "action_results": [] +} \ No newline at end of file diff --git a/contracts/test_contracts/reenter_desyndicate/reenter_desyndicate.cpp b/contracts/test_contracts/reenter_desyndicate/reenter_desyndicate.cpp new file mode 100644 index 0000000000..3f2ed6a0a9 --- /dev/null +++ b/contracts/test_contracts/reenter_desyndicate/reenter_desyndicate.cpp @@ -0,0 +1,46 @@ +#include +#include +#include +#include + +#include +#include + +namespace { +/// The real custody transfer whose holder notification exercises WIRE-441. +constexpr sysio::name synd_account{"sysio.synd"}; +constexpr sysio::name desyndicate_action{"desyndicate"}; +constexpr sysio::name active_permission{"active"}; +constexpr sysio::name armed_table{"armed"}; +} // namespace + +/// Re-enter desyndication once from the holder's outgoing LIQ transfer notification. +class [[sysio::contract]] reenter_desyndicate : public sysio::contract { +public: + using contract::contract; + + /// Amount and fee cap of the one nested request; removing the row prevents further recursion. + struct [[sysio::table("armed")]] armed_request { + sysio::asset quantity; + uint64_t max_fee; + }; + using armed_t = sysio::singleton; + + /// Arm a single callback before starting the outer desyndication directly on sysio.synd. + [[sysio::action]] void arm(sysio::asset quantity, uint64_t max_fee) { + require_auth(get_self()); + armed_t(get_self(), get_self().value).set(armed_request{quantity, max_fee}, get_self()); + } + + /// A genuine outgoing custody transfer triggers the self-authorized nested request exactly once. + [[sysio::on_notify("sysio.liq::transfer")]] + void on_transfer(sysio::name from, sysio::name to, sysio::asset quantity, std::string memo) { + if (from != get_self() || to != synd_account) return; + armed_t armed(get_self(), get_self().value); + if (!armed.exists()) return; + const auto request = armed.get(); + armed.remove(); + sysio::action(sysio::permission_level{get_self(), active_permission}, synd_account, desyndicate_action, + std::make_tuple(get_self(), request.quantity, request.max_fee)).send(); + } +}; diff --git a/contracts/test_contracts/reenter_desyndicate/reenter_desyndicate.wasm b/contracts/test_contracts/reenter_desyndicate/reenter_desyndicate.wasm new file mode 100644 index 0000000000..0e86304ba7 Binary files /dev/null and b/contracts/test_contracts/reenter_desyndicate/reenter_desyndicate.wasm differ diff --git a/contracts/tests/contracts.hpp.in b/contracts/tests/contracts.hpp.in index be96caed8e..6e60c20ae6 100644 --- a/contracts/tests/contracts.hpp.in +++ b/contracts/tests/contracts.hpp.in @@ -57,6 +57,14 @@ struct contracts { static std::vector reject_all_wasm() { return read_wasm("${CMAKE_SOURCE_DIR}/contracts/test_contracts/reject_all.wasm"); } static std::vector reenter_deposit_wasm() { return read_wasm("${CMAKE_SOURCE_DIR}/contracts/test_contracts/reenter_deposit/reenter_deposit.wasm"); } static std::vector reenter_deposit_abi() { return read_abi("${CMAKE_SOURCE_DIR}/contracts/test_contracts/reenter_deposit/reenter_deposit.abi"); } + /// One-shot holder callback for the desyndication custody-total regression. + static std::vector reenter_desyndicate_wasm() { + return read_wasm("${CMAKE_SOURCE_DIR}/contracts/test_contracts/reenter_desyndicate/reenter_desyndicate.wasm"); + } + /// ABI paired with the committed reentrant holder WASM. + static std::vector reenter_desyndicate_abi() { + return read_abi("${CMAKE_SOURCE_DIR}/contracts/test_contracts/reenter_desyndicate/reenter_desyndicate.abi"); + } static std::vector block_transfer_wasm() { return read_wasm("${CMAKE_SOURCE_DIR}/contracts/test_contracts/block_transfer/block_transfer.wasm"); } static std::vector block_transfer_abi() { return read_abi("${CMAKE_SOURCE_DIR}/contracts/test_contracts/block_transfer/block_transfer.abi"); } static std::vector epoch_stub_wasm() { return read_wasm("${CMAKE_SOURCE_DIR}/contracts/test_contracts/epoch_stub/epoch_stub.wasm"); } diff --git a/contracts/tests/sysio.synd_tests.cpp b/contracts/tests/sysio.synd_tests.cpp index 53de03e1fd..3e8aad5e0f 100644 --- a/contracts/tests/sysio.synd_tests.cpp +++ b/contracts/tests/sysio.synd_tests.cpp @@ -1,6 +1,7 @@ #include #include #include +#include #include #include #include @@ -1857,6 +1858,144 @@ sysio::opp::attestations::DesyndicateLIQ queued_desyndication(sysio_synd_tester& return msg; } +/// An ordinary, unprivileged holder contract may desyndicate once from its own LIQ notification. +struct reentrant_synd_tester : sysio_synd_tester { + abi_serializer holder_abi_ser; + + /// Give alice only her own code authority; sysio.synd is called directly at transaction depth zero. + reentrant_synd_tester() { + constexpr auto holder = "alice"_n; + deploy(holder, contracts::util::reenter_desyndicate_wasm(), contracts::util::reenter_desyndicate_abi(), + holder_abi_ser, false); + authority auth(get_public_key(holder, "active")); + auth.accounts.push_back(permission_level_weight{{holder, config::sysio_code_name}, 1}); + set_authority(holder, config::active_name, auth, config::owner_name); + produce_blocks(); + BOOST_REQUIRE_EQUAL(config::default_max_inline_action_depth, + control->get_global_properties().configuration.max_inline_action_depth); + } + + /// Arm exactly one nested request without adding a wrapper action around the outer request. + action_result arm(uint64_t quantity, uint64_t max_fee = std::numeric_limits::max()) { + return push("alice"_n, holder_abi_ser, "alice"_n, "arm"_n, + mvo()("quantity", asset(static_cast(quantity), LIQSOL_SYM))("max_fee", max_fee)); + } +}; + +/// Each FIFO message snapshots live supply after its own burn, including parked yield and retained fees. +BOOST_AUTO_TEST_CASE(desyndicate_reentry_emits_nested_then_outer_live_outstanding) try { + for (const uint32_t fee_bps : {0u, 250u}) { + reentrant_synd_tester t; + constexpr uint64_t initial = 100 * t.UNIT; + constexpr uint64_t parked = 7 * t.UNIT; + constexpr uint64_t outer = 40 * t.UNIT; + constexpr uint64_t nested = 20 * t.UNIT; + const uint64_t outer_fee = outer * fee_bps / BPS_DENOMINATOR; + const uint64_t nested_fee = nested * fee_bps / BPS_DENOMINATOR; + const uint64_t outer_net = outer - outer_fee; + const uint64_t nested_net = nested - nested_fee; + BOOST_REQUIRE_EQUAL(t.success(), t.liq_mint("alice"_n, t.LIQSOL, initial)); + BOOST_REQUIRE_EQUAL(t.success(), t.liq_mintyield(t.SOLANA, t.LIQSOL, parked)); + BOOST_REQUIRE_EQUAL(t.success(), t.link_svm("alice"_n, t.ed_signer())); + BOOST_REQUIRE_EQUAL(t.success(), t.setconfig(t.SOLANA, t.LIQSOL, + {.desynd_fee_bps = fee_bps, .desynd_burst = initial})); + BOOST_REQUIRE_EQUAL(t.success(), t.arm(nested, nested_fee)); + BOOST_REQUIRE_EQUAL(t.success(), t.desyndicate("alice"_n, outer, t.LIQSOL_SYM, name{}, outer_fee)); + + const auto inner_msg = queued_desyndication(t, 1); + const auto outer_msg = queued_desyndication(t, 2); + BOOST_REQUIRE_EQUAL(2u, inner_msg.request_id()); + BOOST_REQUIRE_EQUAL(1u, outer_msg.request_id()); + BOOST_REQUIRE_EQUAL(nested_net, inner_msg.amount().amount()); + BOOST_REQUIRE_EQUAL(outer_net, outer_msg.amount().amount()); + BOOST_REQUIRE_EQUAL(initial + parked - nested_net, inner_msg.total_syndicated()); + BOOST_REQUIRE_EQUAL(initial + parked - nested_net - outer_net, outer_msg.total_syndicated()); + BOOST_REQUIRE_EQUAL(initial - nested_net - outer_net, t.supply()); + BOOST_REQUIRE_EQUAL(parked, t.pending()); + BOOST_REQUIRE_EQUAL(initial - outer - nested, t.liq_balance("alice"_n)); + BOOST_REQUIRE_EQUAL(outer_fee + nested_fee, t.feepot_balance(t.LIQSOL)); + BOOST_REQUIRE_EQUAL(outer_fee + nested_fee, t.liq_balance(t.SYND_ACCOUNT)); + BOOST_REQUIRE_EQUAL(initial - outer - nested, t.desynd_bucket_row(t.SOLANA, t.LIQSOL)["level"].as_uint64()); + BOOST_REQUIRE_EQUAL(outer_net, t.return_row(1)["amount"].as_uint64()); + BOOST_REQUIRE_EQUAL(nested_net, t.return_row(2)["amount"].as_uint64()); + BOOST_REQUIRE_EQUAL("alice", t.return_row(1)["holder"].as_string()); + BOOST_REQUIRE_EQUAL("alice", t.return_row(2)["holder"].as_string()); + BOOST_REQUIRE_EQUAL(3u, t.counters_row()["next_request_id"].as_uint64()); + BOOST_REQUIRE(t.attestation_row(3).is_null()); + BOOST_REQUIRE_EQUAL(inner_msg.user().SerializeAsString(), outer_msg.user().SerializeAsString()); + + // Receipt global sequences reflect execution order; action-trace slots reflect scheduling order. + std::vector> ordered; + for (const auto& trace : t.last_trace->action_traces) { + if (trace.receiver == trace.act.account && + (trace.act.name == "burn"_n || trace.act.name == "emitdesynd"_n || trace.act.name == "queueout"_n)) { + BOOST_REQUIRE(trace.receipt.has_value()); + ordered.emplace_back(trace.receipt->global_sequence, trace.act.name); + } + } + std::sort(ordered.begin(), ordered.end()); + const std::vector expected{"burn"_n, "emitdesynd"_n, "queueout"_n, + "burn"_n, "emitdesynd"_n, "queueout"_n}; + BOOST_REQUIRE_EQUAL(ordered.size(), expected.size()); + for (size_t i = 0; i < expected.size(); ++i) BOOST_REQUIRE_EQUAL(expected[i], ordered[i].second); + + // The arm was consumed: a later ordinary request adds exactly one attestation. + BOOST_REQUIRE_EQUAL(t.success(), t.desyndicate("alice"_n, t.UNIT)); + BOOST_REQUIRE_EQUAL(3u, queued_desyndication(t, 3).request_id()); + BOOST_REQUIRE(t.attestation_row(4).is_null()); + } +} FC_LOG_AND_RETHROW() + +/// A nested refusal rolls back the outer transfer, fee, bucket debit, return and request allocation. +BOOST_FIXTURE_TEST_CASE(desyndicate_reentry_refusal_rolls_back_every_request, reentrant_synd_tester) try { + constexpr uint64_t initial = 100 * UNIT; + constexpr uint64_t outer = 40 * UNIT; + constexpr uint64_t nested = 20 * UNIT; + BOOST_REQUIRE_EQUAL(success(), liq_mint("alice"_n, LIQSOL, initial)); + BOOST_REQUIRE_EQUAL(success(), link_svm("alice"_n, ed_signer())); + BOOST_REQUIRE_EQUAL(success(), setconfig(SOLANA, LIQSOL, {.desynd_fee_bps = 250, .desynd_burst = outer})); + BOOST_REQUIRE_EQUAL(success(), arm(nested)); + const auto before = desynd_state(); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("desyndication exceeds the current budget"), desyndicate("alice"_n, outer)); + BOOST_REQUIRE(before == desynd_state()); + BOOST_REQUIRE(return_row(1).is_null()); + BOOST_REQUIRE(attestation_row(1).is_null()); +} FC_LOG_AND_RETHROW() + +/// Neither a holder signature nor a direct system-contract signature can forge or replay an emission. +BOOST_FIXTURE_TEST_CASE(desyndicate_emission_requires_self_inline_origin, 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, UNIT)); + const auto before = desynd_state(); + for (const uint64_t request_id : {uint64_t{0}, uint64_t{1}, std::numeric_limits::max()}) { + BOOST_REQUIRE(mentions(push(SYND_ACCOUNT, synd_abi_ser, "alice"_n, "emitdesynd"_n, + mvo()("request_id", request_id)), "missing authority of sysio.synd")); + BOOST_REQUIRE_EQUAL(wasm_assert_msg("syndication receipts require a self inline action"), + push(SYND_ACCOUNT, synd_abi_ser, SYND_ACCOUNT, "emitdesynd"_n, + mvo()("request_id", request_id))); + BOOST_REQUIRE(before == desynd_state()); + } + BOOST_REQUIRE(attestation_row(2).is_null()); +} FC_LOG_AND_RETHROW() + +/// The rebuilt continuation retains the dispatcher's rejection of bytes after its request ID. +BOOST_FIXTURE_TEST_CASE(desyndicate_emission_rejects_trailing_action_bytes, sysio_synd_tester) try { + constexpr auto emitdesynd_action = "emitdesynd"_n; + constexpr std::string_view trailing_data_message = "action data has bytes left after the arguments"; + const auto before = desynd_state(); + action act; + act.account = SYND_ACCOUNT; + act.name = emitdesynd_action; + act.data = synd_abi_ser.variant_to_binary(synd_abi_ser.get_action_type(emitdesynd_action), + mvo()("request_id", uint64_t{0}), fixture_abi_yield_function()); + act.data.push_back(0); + BOOST_REQUIRE_EQUAL(wasm_assert_msg(trailing_data_message.data()), + base_tester::push_contract_paid_action(std::move(act), SYND_ACCOUNT.to_uint64_t())); + BOOST_REQUIRE(before == desynd_state()); +} FC_LOG_AND_RETHROW() + // Review Focus 6: a DESYNDICATE_LIQ carries the depot's outstanding shadow after its own burn -- the // supply plus the yield parked in liqpending, net of the burned amount -- and `desyndlog` records the // same total. The fee stays outstanding: it is transferred into the fee pot, not burned. The carried