Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions contracts/sysio.synd/include/sysio.synd/sysio.synd.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
27 changes: 18 additions & 9 deletions contracts/sysio.synd/src/sysio.synd.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -1578,20 +1576,31 @@ 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<int64_t>(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<int64_t>(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<char> encoded;
auto out = zpp::bits::out{encoded, zpp::bits::no_size{}};
(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();
}

Expand Down
15 changes: 15 additions & 0 deletions contracts/sysio.synd/sysio.synd.abi
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,16 @@
}
]
},
{
"name": "emitdesynd",
"base": "",
"fields": [
{
"name": "request_id",
"type": "uint64"
}
]
},
{
"name": "envelope_key",
"base": "",
Expand Down Expand Up @@ -1265,6 +1275,11 @@
"type": "dropenv",
"ricardian_contract": ""
},
{
"name": "emitdesynd",
"type": "emitdesynd",
"ricardian_contract": ""
},
{
"name": "finishreturn",
"type": "finishreturn",
Expand Down
Binary file modified contracts/sysio.synd/sysio.synd.wasm
Binary file not shown.
1 change: 1 addition & 0 deletions contracts/test_contracts/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 4 additions & 0 deletions contracts/test_contracts/reenter_desyndicate/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -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()
Original file line number Diff line number Diff line change
@@ -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": []
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
#include <sysio/action.hpp>
#include <sysio/asset.hpp>
#include <sysio/contract.hpp>
#include <sysio/singleton.hpp>

#include <string>
#include <tuple>

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<armed_table, armed_request>;

/// 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();
}
};
Binary file not shown.
8 changes: 8 additions & 0 deletions contracts/tests/contracts.hpp.in
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,14 @@ struct contracts {
static std::vector<uint8_t> reject_all_wasm() { return read_wasm("${CMAKE_SOURCE_DIR}/contracts/test_contracts/reject_all.wasm"); }
static std::vector<uint8_t> reenter_deposit_wasm() { return read_wasm("${CMAKE_SOURCE_DIR}/contracts/test_contracts/reenter_deposit/reenter_deposit.wasm"); }
static std::vector<char> 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<uint8_t> 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<char> reenter_desyndicate_abi() {
return read_abi("${CMAKE_SOURCE_DIR}/contracts/test_contracts/reenter_desyndicate/reenter_desyndicate.abi");
}
static std::vector<uint8_t> block_transfer_wasm() { return read_wasm("${CMAKE_SOURCE_DIR}/contracts/test_contracts/block_transfer/block_transfer.wasm"); }
static std::vector<char> block_transfer_abi() { return read_abi("${CMAKE_SOURCE_DIR}/contracts/test_contracts/block_transfer/block_transfer.abi"); }
static std::vector<uint8_t> epoch_stub_wasm() { return read_wasm("${CMAKE_SOURCE_DIR}/contracts/test_contracts/epoch_stub/epoch_stub.wasm"); }
Expand Down
139 changes: 139 additions & 0 deletions contracts/tests/sysio.synd_tests.cpp
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#include <boost/test/unit_test.hpp>
#include <sysio/testing/tester.hpp>
#include <sysio/chain/abi_serializer.hpp>
#include <sysio/chain/global_property_object.hpp>
#include <sysio/chain/kv_table_objects.hpp>
#include <sysio/opp/opp.hpp>
#include <sysio/opp/attestations/attestations.pb.h>
Expand Down Expand Up @@ -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<uint64_t>::max()) {
return push("alice"_n, holder_abi_ser, "alice"_n, "arm"_n,
mvo()("quantity", asset(static_cast<int64_t>(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<std::pair<uint64_t, name>> 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<name> 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<uint64_t>::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
Expand Down
Loading