diff --git a/unittests/action_ordering_tests.cpp b/unittests/action_ordering_tests.cpp new file mode 100644 index 0000000000..04869dd00d --- /dev/null +++ b/unittests/action_ordering_tests.cpp @@ -0,0 +1,404 @@ +/** + * @file action_ordering_tests.cpp + * + * @brief Regression tests pinning down EOSIO-style action_trace ordering. + * + * What the chain guarantees (see libraries/chain/apply_context.cpp::exec): + * + * Within a single apply_context, actions run in four fixed phases: + * + * phase 1: the receiver's own action body ("self" apply) + * phase 2: every require_recipient() in FIFO (notification phase) + * phase 3: every send_context_free_inline() FIFO (CFA inline phase) + * phase 4: every send_inline() in FIFO (regular inline phase) + * + * +--------+ +---------------+ +-------------+ +-----------------+ + * | self | -> | notifications | -> | CFA inlines | -> | regular inlines | + * | | | FIFO | | FIFO | | FIFO | + * +--------+ +---------------+ +-------------+ +-----------------+ + * phase 1 phase 2 phase 3 phase 4 + * + * Two distinct counters describe a trace: + * + * action_ordinal - SCHEDULE order. Assigned the moment a host call + * (require_recipient / send_*_inline) enqueues a new + * action. action_traces is indexed by action_ordinal, + * so action_traces[0..N] is in SCHEDULE order, NEVER + * execution order. + * + * receipt.global_sequence - EXECUTION order. Assigned only when the + * action's receipt is built, which happens + * AFTER that action has actually run. + * + * Whenever a contract body intermixes require_recipient() and send_inline() + * within a single action, these two counters diverge. A consumer that + * flattens action_traces and assumes the vector order is execution order + * will misread every such trace. This was the class of confusion that + * the vaults.sx 2021 incident (~$13M loss) exploited - the attacker relied + * on observers mis-attributing the order of effects produced by a single + * transaction. + * + * The mitigation is mechanical, and these tests pin it down: + * + * - Contract authors: do not assume your notification handler sees the + * post-inline state. Inlines you queue from your action body run AFTER + * every notification you emit, including notifications you emit AFTER + * the send_inline call (phase ordering, not call ordering). + * + * - History / indexer authors: never iterate action_traces in index order + * when you mean execution order. Sort by receipt.global_sequence, or + * walk the (creator_action_ordinal, action_ordinal) tree explicitly. + * + * Six focused cases are covered here. A separate, monolithic test in + * api_tests.cpp (action_ordinal_test) covers the same rules across a more + * elaborate fixture; the cases below complement that with clean, + * documented invariants and the CFA-inline coverage that test lacks. + * + * Each case is instantiated for every tester in `validating_testers` so the + * trace shape produced by block production is also exercised through the + * replay path of a validating node. + * + * See unittests/test-contracts/action_order_test/action_order_test.hpp for + * an "all cases at a glance" trace-tree summary in the EOSIO PR #6897 + * nested-arrow style (one diagram per case, showing both creation-tree + * shape and execution-order bracket labels in a single view). + */ +#include + +#include + +#include +#include + +#include + +using namespace sysio; +using namespace sysio::chain; +using namespace sysio::testing; +using mvo = fc::mutable_variant_object; + +BOOST_AUTO_TEST_SUITE(action_ordering_tests) + +namespace { + +/** + * @brief Common fixture: four accounts wired up for ordering tests. + * + * source - originates the action; has the contract. + * obs - notification recipient; has the contract so its on_notify + * handler (used by Case 2) can dispatch. + * inlt - inline recipient; has the contract so nested-case inlines + * can run real contract logic (notifyme) on it. + * cfat - CFA-inline recipient; intentionally has NO contract. The + * CFA noop trace is still recorded; no wasm runs. + * + * Parameterised on the tester type so each test case can be instantiated + * via BOOST_AUTO_TEST_CASE_TEMPLATE against validating_testers, picking up + * replay-validation coverage as a side effect. + */ +template +struct ordering_fixture { + TesterT chain; + account_name source = "alice"_n; + account_name obs = "bob"_n; + account_name inlt = "carol"_n; + account_name cfat = "dave"_n; + + ordering_fixture() { + chain.create_accounts({source, obs, inlt, cfat}); + chain.produce_block(); + chain.set_code(source, test_contracts::action_order_test_wasm()); + chain.set_abi(source, test_contracts::action_order_test_abi()); + chain.set_code(obs, test_contracts::action_order_test_wasm()); + chain.set_abi(obs, test_contracts::action_order_test_abi()); + chain.set_code(inlt, test_contracts::action_order_test_wasm()); + chain.set_abi(inlt, test_contracts::action_order_test_abi()); + chain.produce_block(); + } +}; + +/** + * @brief Tuple-of-interest extracted from each action_trace for clean asserts. + * + * The five fields here are the ones a history consumer typically wants. + * Bundling them this way makes the BOOST_CHECK lines below read like the + * doc-comment tables in action_order_test.hpp. + */ +struct trace_row { + uint32_t ordinal; // schedule order + uint64_t gseq; // execution order (from receipt) + account_name receiver; + account_name act_account; + action_name act_name; +}; + +std::vector tabulate(const transaction_trace_ptr& trace) { + std::vector rows; + rows.reserve(trace->action_traces.size()); + for (const auto& at : trace->action_traces) { + BOOST_REQUIRE(at.receipt.has_value()); + rows.push_back({ + static_cast(at.action_ordinal), + at.receipt->global_sequence, + at.receiver, + at.act.account, + at.act.name + }); + } + return rows; +} + +} // namespace + +// --------------------------------------------------------------------------- +// Case 1: a single self-contained action produces a single action_trace. +// Baseline - if this fails, everything else below is suspect. +// --------------------------------------------------------------------------- +BOOST_AUTO_TEST_CASE_TEMPLATE(simple_self_only, T, validating_testers) try { + ordering_fixture f; + auto trace = f.chain.push_action(f.source, "noop"_n, f.source, mvo()); + BOOST_REQUIRE_EQUAL(trace->action_traces.size(), 1u); + + const auto& at = trace->action_traces[0]; + BOOST_CHECK_EQUAL(static_cast(at.action_ordinal), 1); + BOOST_CHECK_EQUAL(at.receiver, f.source); + BOOST_CHECK_EQUAL(at.act.account, f.source); + BOOST_CHECK_EQUAL(at.act.name, "noop"_n); + BOOST_REQUIRE(at.receipt.has_value()); +} FC_LOG_AND_RETHROW() + +// --------------------------------------------------------------------------- +// Case 2: notification-fires-inline. +// +// Source's body does require_recipient(obs). When obs runs its on_notify +// handler, that handler calls send_inline(noop) targeting itself. The +// resulting inline lands in the SHARED _inline_actions queue belonging to +// source's apply_context, NOT in some inner queue local to the handler. +// It therefore runs after the notification phase, with ordinal == 3. +// +// Trace shape: +// [0] ordinal=1 gseq=g0 self apply on source (notiffire) +// [1] ordinal=2 gseq=g0+1 notification on obs (notiffire) +// [2] ordinal=3 gseq=g0+2 inline noop on obs (handler-scheduled inline) +// +// schedule order == execution order here, but the LESSON is that scheduling +// from inside a handler does NOT give you priority over sibling work. +// --------------------------------------------------------------------------- +BOOST_AUTO_TEST_CASE_TEMPLATE(notification_handler_inline_queues_after_siblings, + T, validating_testers) try { + ordering_fixture f; + auto trace = f.chain.push_action(f.source, "notiffire"_n, f.source, + mvo()("obs", f.obs.to_string())); + auto rows = tabulate(trace); + BOOST_REQUIRE_EQUAL(rows.size(), 3u); + + BOOST_CHECK_EQUAL(rows[0].ordinal, 1u); + BOOST_CHECK_EQUAL(rows[0].receiver, f.source); + BOOST_CHECK_EQUAL(rows[0].act_name, "notiffire"_n); + + BOOST_CHECK_EQUAL(rows[1].ordinal, 2u); + BOOST_CHECK_EQUAL(rows[1].receiver, f.obs); + BOOST_CHECK_EQUAL(rows[1].act_account, f.source); + BOOST_CHECK_EQUAL(rows[1].act_name, "notiffire"_n); + BOOST_CHECK_EQUAL(rows[1].gseq, rows[0].gseq + 1); + + BOOST_CHECK_EQUAL(rows[2].ordinal, 3u); + BOOST_CHECK_EQUAL(rows[2].receiver, f.obs); + BOOST_CHECK_EQUAL(rows[2].act_account, f.obs); + BOOST_CHECK_EQUAL(rows[2].act_name, "noop"_n); + BOOST_CHECK_EQUAL(rows[2].gseq, rows[0].gseq + 2); +} FC_LOG_AND_RETHROW() + +// --------------------------------------------------------------------------- +// Case 3 (HEADLINE): schedule order diverges from execution order. +// +// Source's body schedules an inline FIRST (ordinal 2), then a notification +// (ordinal 3). The chain runs the notification before the inline. Therefore +// action_traces[1] (the inline, vector position 2) has a HIGHER +// global_sequence than action_traces[2] (the notification, vector position 3). +// +// Concretely: +// +// action_traces vector receipt.global_sequence +// -------------------- ----------------------- +// [0] self ord=1 g0 (1st to run) +// [1] inline ord=2 g0 + 2 (3rd to run) +// [2] notify ord=3 g0 + 1 (2nd to run) +// +// rows[1].gseq > rows[2].gseq - vector order and execution order swap. +// Anyone iterating action_traces in vector order and assuming "earlier index +// means ran earlier" reads the wrong story here. This is the trap class that +// the vaults.sx 2021 attack relied on. +// --------------------------------------------------------------------------- +BOOST_AUTO_TEST_CASE_TEMPLATE(inline_before_notification_diverges, + T, validating_testers) try { + ordering_fixture f; + auto trace = f.chain.push_action(f.source, "inlnotify"_n, f.source, + mvo()("obs", f.obs.to_string()) + ("inlt", f.inlt.to_string())); + auto rows = tabulate(trace); + BOOST_REQUIRE_EQUAL(rows.size(), 3u); + + // [0] self + BOOST_CHECK_EQUAL(rows[0].ordinal, 1u); + BOOST_CHECK_EQUAL(rows[0].receiver, f.source); + BOOST_CHECK_EQUAL(rows[0].act_name, "inlnotify"_n); + + // [1] inline - scheduled first (ordinal 2), but executed LAST (gseq +2) + BOOST_CHECK_EQUAL(rows[1].ordinal, 2u); + BOOST_CHECK_EQUAL(rows[1].receiver, f.inlt); + BOOST_CHECK_EQUAL(rows[1].act_account, f.inlt); + BOOST_CHECK_EQUAL(rows[1].act_name, "noop"_n); + BOOST_CHECK_EQUAL(rows[1].gseq, rows[0].gseq + 2); + + // [2] notification - scheduled second (ordinal 3), but executed first (gseq +1) + BOOST_CHECK_EQUAL(rows[2].ordinal, 3u); + BOOST_CHECK_EQUAL(rows[2].receiver, f.obs); + BOOST_CHECK_EQUAL(rows[2].act_account, f.source); + BOOST_CHECK_EQUAL(rows[2].act_name, "inlnotify"_n); + BOOST_CHECK_EQUAL(rows[2].gseq, rows[0].gseq + 1); + + // The divergence assertion in one line: the inline's global_sequence is + // GREATER than the notification's, even though the inline appears EARLIER + // in the action_traces vector. + BOOST_CHECK_GT(rows[1].gseq, rows[2].gseq); +} FC_LOG_AND_RETHROW() + +// --------------------------------------------------------------------------- +// Case 4 (sanity contrast): same actions, opposite scheduling. +// Notification scheduled first => ordinal order and gseq order agree. +// Pairs with Case 3 so the divergence stands out by comparison. +// --------------------------------------------------------------------------- +BOOST_AUTO_TEST_CASE_TEMPLATE(notification_before_inline_agrees, + T, validating_testers) try { + ordering_fixture f; + auto trace = f.chain.push_action(f.source, "notifyinl"_n, f.source, + mvo()("obs", f.obs.to_string()) + ("inlt", f.inlt.to_string())); + auto rows = tabulate(trace); + BOOST_REQUIRE_EQUAL(rows.size(), 3u); + + BOOST_CHECK_EQUAL(rows[0].ordinal, 1u); + BOOST_CHECK_EQUAL(rows[0].receiver, f.source); + + BOOST_CHECK_EQUAL(rows[1].ordinal, 2u); + BOOST_CHECK_EQUAL(rows[1].receiver, f.obs); + BOOST_CHECK_EQUAL(rows[1].act_name, "notifyinl"_n); + BOOST_CHECK_EQUAL(rows[1].gseq, rows[0].gseq + 1); + + BOOST_CHECK_EQUAL(rows[2].ordinal, 3u); + BOOST_CHECK_EQUAL(rows[2].receiver, f.inlt); + BOOST_CHECK_EQUAL(rows[2].act_name, "noop"_n); + BOOST_CHECK_EQUAL(rows[2].gseq, rows[0].gseq + 2); +} FC_LOG_AND_RETHROW() + +// --------------------------------------------------------------------------- +// Case 5 (nested): notification + inline, where the inline body itself +// triggers a notification at its OWN apply_context depth. +// +// The inner notification belongs to the inline's apply_context, not the +// outer one - so it appears in the trace as a child of the inline, not a +// sibling of the outer notification. +// +// Trace shape: +// [0] ord=1 gseq=g0 source self (nestedcall) +// [1] ord=2 gseq=g0+1 outer notification on obs (nestedcall) +// [2] ord=3 gseq=g0+2 inline notifyme on inlt +// [3] ord=4 gseq=g0+3 inner notification on obs (notifyme, from inlt) +// --------------------------------------------------------------------------- +BOOST_AUTO_TEST_CASE_TEMPLATE(nested_inline_fires_inner_notification, + T, validating_testers) try { + ordering_fixture f; + auto trace = f.chain.push_action(f.source, "nestedcall"_n, f.source, + mvo()("obs", f.obs.to_string()) + ("inlt", f.inlt.to_string())); + auto rows = tabulate(trace); + BOOST_REQUIRE_EQUAL(rows.size(), 4u); + + // [0] outer self + BOOST_CHECK_EQUAL(rows[0].ordinal, 1u); + BOOST_CHECK_EQUAL(rows[0].receiver, f.source); + BOOST_CHECK_EQUAL(rows[0].act_name, "nestedcall"_n); + + // [1] outer notification: source -> obs, action = nestedcall + BOOST_CHECK_EQUAL(rows[1].ordinal, 2u); + BOOST_CHECK_EQUAL(rows[1].receiver, f.obs); + BOOST_CHECK_EQUAL(rows[1].act_account, f.source); + BOOST_CHECK_EQUAL(rows[1].act_name, "nestedcall"_n); + BOOST_CHECK_EQUAL(rows[1].gseq, rows[0].gseq + 1); + + // [2] inline self: notifyme runs on inlt with code=inlt + BOOST_CHECK_EQUAL(rows[2].ordinal, 3u); + BOOST_CHECK_EQUAL(rows[2].receiver, f.inlt); + BOOST_CHECK_EQUAL(rows[2].act_account, f.inlt); + BOOST_CHECK_EQUAL(rows[2].act_name, "notifyme"_n); + BOOST_CHECK_EQUAL(rows[2].gseq, rows[0].gseq + 2); + + // [3] inner notification: inlt -> obs, action = notifyme + BOOST_CHECK_EQUAL(rows[3].ordinal, 4u); + BOOST_CHECK_EQUAL(rows[3].receiver, f.obs); + BOOST_CHECK_EQUAL(rows[3].act_account, f.inlt); + BOOST_CHECK_EQUAL(rows[3].act_name, "notifyme"_n); + BOOST_CHECK_EQUAL(rows[3].gseq, rows[0].gseq + 3); +} FC_LOG_AND_RETHROW() + +// --------------------------------------------------------------------------- +// Case 6 (CFA): CFA inlines run BEFORE regular inlines, regardless of the +// order in which they were scheduled. +// +// Body schedules: regular_inline -> CFA_inline -> require_recipient. +// Chain runs phases in fixed order: +// self -> notifications -> CFA inlines -> regular inlines. +// +// All three non-self traces have global_sequence values that DISAGREE with +// their action_ordinal. This is the strongest divergence in the suite. +// +// Trace shape: +// [0] ord=1 gseq=g0 source self (mixedord) +// [1] ord=2 gseq=g0+3 regular noop on inlt (LAST executed) +// [2] ord=3 gseq=g0+2 CFA noop on cfat +// [3] ord=4 gseq=g0+1 notification on obs (FIRST executed after self) +// --------------------------------------------------------------------------- +BOOST_AUTO_TEST_CASE_TEMPLATE(cfa_runs_before_regular_inline, + T, validating_testers) try { + ordering_fixture f; + auto trace = f.chain.push_action(f.source, "mixedord"_n, f.source, + mvo()("obs", f.obs.to_string()) + ("inlt", f.inlt.to_string()) + ("cfat", f.cfat.to_string())); + auto rows = tabulate(trace); + BOOST_REQUIRE_EQUAL(rows.size(), 4u); + + // [0] self + BOOST_CHECK_EQUAL(rows[0].ordinal, 1u); + BOOST_CHECK_EQUAL(rows[0].receiver, f.source); + BOOST_CHECK_EQUAL(rows[0].act_name, "mixedord"_n); + + // [1] regular inline (scheduled first, executed last) + BOOST_CHECK_EQUAL(rows[1].ordinal, 2u); + BOOST_CHECK_EQUAL(rows[1].receiver, f.inlt); + BOOST_CHECK_EQUAL(rows[1].act_account, f.inlt); + BOOST_CHECK_EQUAL(rows[1].act_name, "noop"_n); + BOOST_CHECK_EQUAL(rows[1].gseq, rows[0].gseq + 3); + + // [2] CFA inline + BOOST_CHECK_EQUAL(rows[2].ordinal, 3u); + BOOST_CHECK_EQUAL(rows[2].receiver, f.cfat); + BOOST_CHECK_EQUAL(rows[2].act_account, f.cfat); + BOOST_CHECK_EQUAL(rows[2].act_name, "noop"_n); + BOOST_CHECK_EQUAL(rows[2].gseq, rows[0].gseq + 2); + + // [3] notification (scheduled last, executed first after self) + BOOST_CHECK_EQUAL(rows[3].ordinal, 4u); + BOOST_CHECK_EQUAL(rows[3].receiver, f.obs); + BOOST_CHECK_EQUAL(rows[3].act_account, f.source); + BOOST_CHECK_EQUAL(rows[3].act_name, "mixedord"_n); + BOOST_CHECK_EQUAL(rows[3].gseq, rows[0].gseq + 1); + + // Phase-order invariants as plain inequalities. + // (notification phase) < (CFA phase) < (regular inline phase). + BOOST_CHECK_LT(rows[3].gseq, rows[2].gseq); + BOOST_CHECK_LT(rows[2].gseq, rows[1].gseq); +} FC_LOG_AND_RETHROW() + +BOOST_AUTO_TEST_SUITE_END() diff --git a/unittests/test-contracts/CMakeLists.txt b/unittests/test-contracts/CMakeLists.txt index 8c9c529294..fa8a15d601 100644 --- a/unittests/test-contracts/CMakeLists.txt +++ b/unittests/test-contracts/CMakeLists.txt @@ -37,6 +37,7 @@ add_subdirectory( settlewns ) add_subdirectory( snapshot_test ) add_subdirectory( test_api ) add_subdirectory( test_ram_limit ) +add_subdirectory( action_order_test ) add_subdirectory( action_results ) add_subdirectory( wasm_config_bios ) add_subdirectory( params_test ) diff --git a/unittests/test-contracts/action_order_test/CMakeLists.txt b/unittests/test-contracts/action_order_test/CMakeLists.txt new file mode 100644 index 0000000000..6878bf586a --- /dev/null +++ b/unittests/test-contracts/action_order_test/CMakeLists.txt @@ -0,0 +1,6 @@ +if( BUILD_TEST_CONTRACTS ) + add_contract( action_order_test action_order_test action_order_test.cpp ) +else() + configure_file( ${CMAKE_CURRENT_SOURCE_DIR}/action_order_test.wasm ${CMAKE_CURRENT_BINARY_DIR}/action_order_test.wasm COPYONLY ) + configure_file( ${CMAKE_CURRENT_SOURCE_DIR}/action_order_test.abi ${CMAKE_CURRENT_BINARY_DIR}/action_order_test.abi COPYONLY ) +endif() diff --git a/unittests/test-contracts/action_order_test/action_order_test.abi b/unittests/test-contracts/action_order_test/action_order_test.abi new file mode 100644 index 0000000000..17eb953491 --- /dev/null +++ b/unittests/test-contracts/action_order_test/action_order_test.abi @@ -0,0 +1,133 @@ +{ + "____comment": "This file was generated with sysio-abigen. DO NOT EDIT ", + "version": "sysio::abi/1.2", + "types": [], + "structs": [ + { + "name": "inlnotify", + "base": "", + "fields": [ + { + "name": "obs", + "type": "name" + }, + { + "name": "inlt", + "type": "name" + } + ] + }, + { + "name": "mixedord", + "base": "", + "fields": [ + { + "name": "obs", + "type": "name" + }, + { + "name": "inlt", + "type": "name" + }, + { + "name": "cfat", + "type": "name" + } + ] + }, + { + "name": "nestedcall", + "base": "", + "fields": [ + { + "name": "obs", + "type": "name" + }, + { + "name": "inlt", + "type": "name" + } + ] + }, + { + "name": "noop", + "base": "", + "fields": [] + }, + { + "name": "notiffire", + "base": "", + "fields": [ + { + "name": "obs", + "type": "name" + } + ] + }, + { + "name": "notifyinl", + "base": "", + "fields": [ + { + "name": "obs", + "type": "name" + }, + { + "name": "inlt", + "type": "name" + } + ] + }, + { + "name": "notifyme", + "base": "", + "fields": [ + { + "name": "obs", + "type": "name" + } + ] + } + ], + "actions": [ + { + "name": "inlnotify", + "type": "inlnotify", + "ricardian_contract": "" + }, + { + "name": "mixedord", + "type": "mixedord", + "ricardian_contract": "" + }, + { + "name": "nestedcall", + "type": "nestedcall", + "ricardian_contract": "" + }, + { + "name": "noop", + "type": "noop", + "ricardian_contract": "" + }, + { + "name": "notiffire", + "type": "notiffire", + "ricardian_contract": "" + }, + { + "name": "notifyinl", + "type": "notifyinl", + "ricardian_contract": "" + }, + { + "name": "notifyme", + "type": "notifyme", + "ricardian_contract": "" + } + ], + "tables": [], + "ricardian_clauses": [], + "variants": [], + "action_results": [] +} \ No newline at end of file diff --git a/unittests/test-contracts/action_order_test/action_order_test.cpp b/unittests/test-contracts/action_order_test/action_order_test.cpp new file mode 100644 index 0000000000..cabebdd191 --- /dev/null +++ b/unittests/test-contracts/action_order_test/action_order_test.cpp @@ -0,0 +1,93 @@ +#include "action_order_test.hpp" + +using namespace sysio; + +namespace { + /** + * @brief Build an inline action_wrapper instance with no authorizations. + * + * Inline actions normally carry the sender's permission_level. For these + * ordering tests we only care about the trace shape, not auth checks, so + * we pass an empty permission vector - the inline-action code path + * accepts empty authorization (check_authorization has nothing to verify) + * and the resulting trace is identical in shape. + */ + template + Wrapper make_unauthed_wrapper(name code) { + return Wrapper(code, std::vector{}); + } +} // namespace + +void action_order_test::noop() { + // Intentionally empty. The trace's action_receipt is still produced. +} + +void action_order_test::notifyme(name obs) { + require_recipient(obs); +} + +void action_order_test::notiffire(name obs) { + // Single host call: enqueue obs as a notification recipient. The + // matching on_notify handler below queues a follow-on inline. + require_recipient(obs); +} + +void action_order_test::on_notiffire([[maybe_unused]] name obs) { + // We are running inside the notification phase of the SHARED + // apply_context belonging to the originating notiffire action. + // send_inline(noop) lands in that shared _inline_actions queue and + // will execute only AFTER every sibling notification finishes. The + // inline targets get_self() (the notified receiver), so this trace + // shows the inline executing on the notification recipient, not the + // originator - matching the "B observes A and then acts" pattern. + auto act = make_unauthed_wrapper(get_self()); + act.send(); +} + +void action_order_test::inlnotify(name obs, name inlt) { + // HEADLINE: schedule the inline FIRST, then the notification. + // Inline is queued in _inline_actions (regular inline phase, phase 4). + // Notification is queued in _notified (notification phase, phase 2). + // Result: notification runs first at execution time, but the action_trace + // for the inline appears EARLIER in the action_traces vector because it + // was scheduled (assigned its ordinal) first. + auto inline_act = make_unauthed_wrapper(inlt); + inline_act.send(); + require_recipient(obs); +} + +void action_order_test::notifyinl(name obs, name inlt) { + // Natural ordering: notification scheduled first, inline second. + // Here ordinal order and global_sequence order agree, providing the + // contrast case for the inlnotify divergence test. + require_recipient(obs); + auto inline_act = make_unauthed_wrapper(inlt); + inline_act.send(); +} + +void action_order_test::nestedcall(name obs, name inlt) { + // Outer scheduling: notification, then inline. + // The inline itself does require_recipient(obs) inside notifyme; that + // notification belongs to the INLINE'S apply_context, not this one, + // so it shows up as an additional trace inside the inline's subtree. + require_recipient(obs); + auto inline_act = make_unauthed_wrapper(inlt); + inline_act.send(obs); +} + +void action_order_test::mixedord(name obs, name inlt, name cfat) { + // Three host calls in this scheduling order: + // 1. regular inline noop on inlt + // 2. CFA inline noop on cfat + // 3. require_recipient(obs) + // + // Despite that, execution runs phases in fixed order: + // self -> notifications -> CFA inlines -> regular inlines. + // So the obs notification (last scheduled) runs FIRST after self, + // the CFA noop runs second, the regular noop runs LAST. + auto regular_inline = make_unauthed_wrapper(inlt); + regular_inline.send(); + auto cfa_inline = make_unauthed_wrapper(cfat); + cfa_inline.send_context_free(); + require_recipient(obs); +} diff --git a/unittests/test-contracts/action_order_test/action_order_test.hpp b/unittests/test-contracts/action_order_test/action_order_test.hpp new file mode 100644 index 0000000000..d8f466704e --- /dev/null +++ b/unittests/test-contracts/action_order_test/action_order_test.hpp @@ -0,0 +1,330 @@ +#pragma once + +#include + +/** + * @brief Test contract pinning down EOSIO-style action_trace ordering. + * + * Background: each call to apply_context::exec() runs actions in this order: + * + * 1. The receiver's own action body (the "self" apply). + * 2. Every action queued by require_recipient(), in FIFO order + * (the "notification phase"). + * 3. Every action queued by send_context_free_inline(), in FIFO order + * (the "CFA inline phase"). + * 4. Every action queued by send_inline(), in FIFO order + * (the "regular inline phase"). + * + * As a diagram: + * + * +--------+ +---------------+ +-------------+ +-----------------+ + * | self | -> | notifications | -> | CFA inlines | -> | regular inlines | + * | | | FIFO | | FIFO | | FIFO | + * +--------+ +---------------+ +-------------+ +-----------------+ + * phase 1 phase 2 phase 3 phase 4 + * + * Crucially, action_ordinal is the SCHEDULE order (the order in which + * require_recipient / send_*_inline host calls were issued), while + * global_sequence is the EXECUTION order (assigned only when an action's + * receipt is built, after that action has actually run). + * + * These two orderings DIVERGE whenever a contract intermixes notifications + * and inlines within a single action body. action_traces is indexed by + * action_ordinal, so iterating the action_traces vector in index order + * yields the SCHEDULE order, NOT the execution order. + * + * Consumers (history APIs, indexers, monitoring) that flatten action_traces + * and assume the vector is execution-ordered will misread the trace whenever + * a contract uses this mixed pattern. The vaults.sx incident (2021, ~$13M + * loss) exploited exactly this confusion in a contract whose author had + * assumed their notification handler would observe the post-inline state. + * + * ---------------------------------------------------------------------------- + * All six cases at a glance, in the EOSIO PR #6897 trace-tree notation: + * + * (act.account::act.name -> receiver) [N] + * || + * ||===> (child_act::child_name -> child_receiver) [M] + * + * Bracket numbers are EXECUTION position (matches receipt.global_sequence). + * Tree SHAPE follows creator_action_ordinal: each node lists its children + * in the order their parent scheduled them. action_ordinal corresponds to + * a depth-first walk of this tree. + * + * Whenever the top-to-bottom bracket sequence is NOT monotonic, schedule + * order and execution order disagree - the brackets visualize the divergence + * directly. + * + * + * Case 1 - bare self apply (noop): + * + * (alice::noop -> alice) [1] + * + * + * Case 2 - notification handler queues an inline (notiffire): + * alice body : require_recipient(bob); + * bob handler: send_inline(noop on bob); + * + * (alice::notiffire -> alice) [1] + * || + * ||===> (alice::notiffire -> bob) [2] + * || + * ||===> (bob::noop -> bob) [3] + * + * + * Case 3 - schedule-vs-execution divergence (inlnotify) - HEADLINE: + * alice body : send_inline(noop on carol); // scheduled 1st + * require_recipient(bob); // scheduled 2nd + * + * (alice::inlnotify -> alice) [1] + * || + * ||===> (carol::noop -> carol) [3] <-- scheduled 1st, runs 3rd + * || + * ||===> (alice::inlnotify -> bob) [2] <-- scheduled 2nd, runs 2nd + * + * + * Case 4 - natural ordering, contrast with Case 3 (notifyinl): + * alice body : require_recipient(bob); // scheduled 1st + * send_inline(noop on carol); // scheduled 2nd + * + * (alice::notifyinl -> alice) [1] + * || + * ||===> (alice::notifyinl -> bob) [2] + * || + * ||===> (carol::noop -> carol) [3] + * + * + * Case 5 - nested apply_context: inline body fires its own notification + * (nestedcall): + * alice body : require_recipient(bob); + * send_inline(notifyme(bob) on carol); + * carol body : require_recipient(bob); // INSIDE inline's apply_context + * + * (alice::nestedcall -> alice) [1] + * || + * ||===> (alice::nestedcall -> bob) [2] + * || + * ||===> (carol::notifyme -> carol) [3] + * || + * ||===> (carol::notifyme -> bob) [4] + * + * + * Case 6 - CFA inline + regular inline + notification (mixedord) - + * TRIPLE divergence: + * alice body : send_inline(noop on carol); // scheduled 1st + * send_context_free_inline(noop on dave); // scheduled 2nd + * require_recipient(bob); // scheduled 3rd + * + * (alice::mixedord -> alice) [1] + * || + * ||===> (carol::noop -> carol) [4] <-- scheduled 1st, runs LAST + * || + * ||===> (dave::noop -> dave) [3] <-- scheduled 2nd, runs 3rd + * || + * ||===> (alice::mixedord -> bob) [2] <-- scheduled 3rd, runs 2nd + * + * ---------------------------------------------------------------------------- + * + * The 6 actions defined below drive unittests/action_ordering_tests.cpp + * through these permutations so the rule above remains a regression-tested + * invariant of the chain. + */ +class [[sysio::contract]] action_order_test : public sysio::contract { +public: + using sysio::contract::contract; + + /** + * @brief Silent stub. Used as the target of inline / CFA inline calls. + * + * Deliberately does nothing so the resulting action_trace records only + * the apply itself (no further notifications or inlines), keeping + * fixtures small and the global_sequence math easy to follow. + */ + [[sysio::action]] void noop(); + + /** + * @brief Issues a single require_recipient(obs). Used standalone. + * + * Trace shape: 2 traces total. action_traces[0] is the self apply on + * the source; action_traces[1] is the notification with receiver=obs + * and act.account=source. action_ordinal and global_sequence agree. + */ + [[sysio::action]] void notifyme(sysio::name obs); + + /** + * @brief Drives the "notification handler queues an inline" pattern. + * + * Body: require_recipient(obs). + * + * The companion on_notify("*::notiffire") handler runs when obs is + * notified, and inside that handler it calls send_inline(noop) targeting + * get_self() - i.e. the notification recipient (obs), NOT the source. + * The host enqueues that inline in the SAME apply_context's + * _inline_actions queue as the original action's body, so the inline + * runs AFTER all sibling notifications - it does not jump the line just + * because it was scheduled from inside a handler. + * + * Trace shape: 3 traces - source self, source's notification to obs, + * inline noop on obs. global_sequence == action_ordinal for this case. + */ + [[sysio::action]] void notiffire(sysio::name obs); + + /** + * @brief THE HEADLINE TEST: schedule order diverges from execution order. + * + * Body, in order: + * 1. send_inline(noop on inlt) // scheduled FIRST (ordinal +1) + * 2. require_recipient(obs) // scheduled SECOND (ordinal +2) + * + * Despite that scheduling, the chain executes the notification BEFORE + * the inline. The action_traces vector therefore looks like: + * + * index 0 : self apply on source ordinal=1 gseq=g0 + * index 1 : noop inline (target inlt) ordinal=2 gseq=g0+2 + * index 2 : notification on obs ordinal=3 gseq=g0+1 + * + * source body + * | + * v + * +---------------------+ phase 1 + * | self (ord 1) | gseq=g0 + * +---------------------+ + * | + * v + * +---------------------+ phase 2 (notifications) + * | obs notify (ord 3) | gseq=g0+1 <-- 2nd to run, ordinal 3 + * +---------------------+ + * | + * v + * +---------------------+ phase 4 (regular inlines) + * | noop inline (ord 2) | gseq=g0+2 <-- 3rd to run, ordinal 2 + * +---------------------+ + * + * action_traces VECTOR order (by ordinal): [ self, inline, notify ] + * action_traces EXEC order (by gseq): [ self, notify, inline ] + * + * A consumer iterating action_traces[0..] in vector order sees + * "self -> inline -> notification", but the chain actually ran + * "self -> notification -> inline". + * + * History APIs MUST sort by receipt.global_sequence (or otherwise reorder + * relative to action_ordinal) before exposing execution-ordered output. + * Failing to do so reproduces the trap that lets a malicious actor + * confuse downstream tooling about the true sequence of effects. + */ + [[sysio::action]] void inlnotify(sysio::name obs, sysio::name inlt); + + /** + * @brief Sanity check: scheduling notification first AGREES with execution. + * + * Body, in order: + * 1. require_recipient(obs) // scheduled FIRST (ordinal +1) + * 2. send_inline(noop on inlt) // scheduled SECOND (ordinal +2) + * + * Here the notification runs first by virtue of being scheduled first, + * AND because notifications phase runs before inlines phase, so the + * effect is unambiguous. Trace shape: ordinal==global_sequence ordering + * agrees throughout. Pairs with inlnotify so the test makes the contrast + * obvious. + */ + [[sysio::action]] void notifyinl(sysio::name obs, sysio::name inlt); + + /** + * @brief Nested: an inline action that itself notifies a third party. + * + * Body, in order: + * 1. require_recipient(obs) // ordinal +1 + * 2. send_inline(notifyme(obs) on inlt) // ordinal +2 + * + * When the inline runs, it creates a NEW apply_context with its own + * _notified / _inline_actions queues. The inline's own + * require_recipient(obs) is local to that nested context and produces + * its own notification action_trace. + * + * Trace shape: + * index 0: source self ord=1 gseq=g0 + * index 1: source's notification to obs ord=2 gseq=g0+1 + * index 2: inline notifyme on inlt ord=3 gseq=g0+2 + * index 3: inline's own notification to obs ord=4 gseq=g0+3 + * + * outer apply_context (source's nestedcall) + * | + * +-- self (ord 1) gseq=g0 + * +-- notify obs (ord 2) gseq=g0+1 + * +-- inline notifyme (ord 3) --+ + * v + * inner apply_context (inlt's notifyme) + * | + * +-- self (ord 3) gseq=g0+2 + * +-- notify obs (ord 4) gseq=g0+3 + * + * Here schedule order matches execution order because the source body + * scheduled its notification BEFORE its inline. The point of this case + * is to verify that nested apply_contexts behave independently: an inner + * notification does NOT leak into the outer queue. + */ + [[sysio::action]] void nestedcall(sysio::name obs, sysio::name inlt); + + /** + * @brief CFA-inline runs BEFORE regular inline, regardless of schedule. + * + * Body, in order: + * 1. send_inline(noop on inlt) // ordinal +1 + * 2. send_context_free_inline(noop on cfat) // ordinal +2 (CFA) + * 3. require_recipient(obs) // ordinal +3 + * + * Execution order, per apply_context::exec(): + * self -> notifications -> CFA inlines -> regular inlines. + * + * So the chain runs: source self, obs notification, CFA noop on cfat, + * regular noop on inlt. Trace shape: + * + * index 0: source self ord=1 gseq=g0 + * index 1: regular noop on inlt ord=2 gseq=g0+3 (LAST executed) + * index 2: CFA noop on cfat ord=3 gseq=g0+2 + * index 3: notification on obs ord=4 gseq=g0+1 (FIRST after self) + * + * source body (mixedord) + * | + * v + * +-----------------------+ phase 1 + * | self (ord 1) | gseq=g0 + * +-----------------------+ + * | + * v + * +-----------------------+ phase 2 (notifications) + * | obs notify (ord 4) | gseq=g0+1 <-- scheduled LAST, ran 2nd + * +-----------------------+ + * | + * v + * +-----------------------+ phase 3 (CFA inlines) + * | cfa noop (ord 3) | gseq=g0+2 + * +-----------------------+ + * | + * v + * +-----------------------+ phase 4 (regular inlines) + * | inline noop (ord 2) | gseq=g0+3 <-- scheduled FIRST, ran LAST + * +-----------------------+ + * + * action_traces VECTOR order: [ self, inline, cfa, notify ] + * action_traces EXEC order: [ self, notify, cfa, inline ] (tail reversed) + * + * This is a TRIPLE divergence: every non-self trace's gseq disagrees + * with its ordinal. It also encodes the (often forgotten) invariant + * that CFA inlines run BEFORE regular inlines, never alongside them. + */ + [[sysio::action]] void mixedord(sysio::name obs, sysio::name inlt, sysio::name cfat); + + /** + * @brief Notification handler for notiffire. + * + * When the source action notiffire calls require_recipient(obs), + * the chain dispatches THIS handler on obs. From here, send_inline(noop) + * is queued back onto the SHARED apply_context's _inline_actions vector, + * which is processed only after every sibling notification has run. + */ + [[sysio::on_notify("*::notiffire")]] void on_notiffire(sysio::name obs); + + using noop_action = sysio::action_wrapper<"noop"_n, &action_order_test::noop>; + using notifyme_action = sysio::action_wrapper<"notifyme"_n, &action_order_test::notifyme>; +}; diff --git a/unittests/test-contracts/action_order_test/action_order_test.wasm b/unittests/test-contracts/action_order_test/action_order_test.wasm new file mode 100755 index 0000000000..6c6cf96751 Binary files /dev/null and b/unittests/test-contracts/action_order_test/action_order_test.wasm differ diff --git a/unittests/test_contracts.hpp.in b/unittests/test_contracts.hpp.in index c26b5a6abf..5e843d7e53 100644 --- a/unittests/test_contracts.hpp.in +++ b/unittests/test_contracts.hpp.in @@ -42,6 +42,7 @@ namespace sysio { MAKE_READ_WASM_ABI(snapshot_test, snapshot_test, unittests/test-contracts) MAKE_READ_WASM_ABI(test_api, test_api, unittests/test-contracts) MAKE_READ_WASM_ABI(test_ram_limit, test_ram_limit, unittests/test-contracts) + MAKE_READ_WASM_ABI(action_order_test, action_order_test, unittests/test-contracts) MAKE_READ_WASM_ABI(action_results, action_results, unittests/test-contracts) MAKE_READ_WASM_ABI(wasm_config_bios, wasm_config_bios, unittests/test-contracts) MAKE_READ_WASM_ABI(params_test, params_test, unittests/test-contracts)