diff --git a/docs/kv-intrinsics-reference.md b/docs/kv-intrinsics-reference.md index c603c6131..9e53bf02a 100644 --- a/docs/kv-intrinsics-reference.md +++ b/docs/kv-intrinsics-reference.md @@ -402,7 +402,7 @@ void kv_idx_store(uint64_t payer, uint64_t table, uint32_t index_id, | `payer` | `uint64_t` | Account to bill for RAM (0 = receiver) | | `table` | `uint64_t` | Logical table name (e.g., `"accounts"_n.value`) | | `index_id` | `uint32_t` | Index identifier (0-255, corresponding to the Nth `indexed_by`) | -| `pri_key` | `const void*` | Primary key bytes (`[scope:8B][pk:8B]` = 16 bytes in CDT) | +| `pri_key` | `const void*` | Primary key bytes (`[pk:8B]` = 8 bytes in CDT) | | `pri_key_size` | `uint32_t` | Primary key size (max 256) | | `sec_key` | `const void*` | Secondary key bytes (big-endian encoded) | | `sec_key_size` | `uint32_t` | Secondary key size (max 256) | @@ -621,8 +621,8 @@ int32_t kv_idx_primary_key(uint32_t handle, uint32_t offset, **Returns:** `int32_t` -- Status code (0=OK). **Behavior:** -- In the CDT multi\_index implementation, the stored primary key is `[scope:8B][pk:8B]` = 16 bytes -- The caller extracts the 8-byte primary key from offset 8 +- In the CDT multi\_index implementation, the stored primary key is `[pk:8B]` = 8 bytes +- The caller reads the 8-byte primary key directly from offset 0 --- diff --git a/docs/kv-storage-guide.md b/docs/kv-storage-guide.md index cbf23f2e6..669ec7d18 100644 --- a/docs/kv-storage-guide.md +++ b/docs/kv-storage-guide.md @@ -53,7 +53,8 @@ Properties: - Fixed 24 bytes - Integer fast-path: 8-byte big-endian keys compared via single `bswap64` - SHiP compatible: reversible to legacy `contract_row` format (table, scope, primary\_key) -- Secondary index keys include scope: `[scope:8B][pk:8B]` = 16 bytes +- Secondary index keys prepend scope to the secondary value: `[scope:8B BE][secondary_value]` +- Primary keys stored in secondary index entries are `[pk:8B]` = 8 bytes (scope is in the secondary key) ### Format=0 (indexed\_table, raw\_table) diff --git a/libraries/sysiolib/contracts/sysio/kv_multi_index.hpp b/libraries/sysiolib/contracts/sysio/kv_multi_index.hpp index 34dbbe428..cfe377f78 100644 --- a/libraries/sysiolib/contracts/sysio/kv_multi_index.hpp +++ b/libraries/sysiolib/contracts/sysio/kv_multi_index.hpp @@ -236,17 +236,15 @@ class kv_multi_index { return p; } - // 16-byte scoped primary key for secondary index: [scope:8B][pk:8B] - // Scope must be included so that rows with the same primary key in - // different scopes produce distinct secondary-index entries. + // 8-byte primary key for secondary index: [pk:8B] + // Scope is encoded in the sec_key prefix instead (see store_all). struct pk_bytes_buf { - char data[16]; + char data[8]; }; pk_bytes_buf pk_to_bytes(uint64_t pk) const { pk_bytes_buf b; - _kv_multi_index_detail::encode_be64(b.data, _scope); - _kv_multi_index_detail::encode_be64(b.data + 8, pk); + _kv_multi_index_detail::encode_be64(b.data, pk); return b; } @@ -296,16 +294,63 @@ class kv_multi_index { return raw; } + // Encode [scope:8B BE][secondary_value] using the correct sort-preserving + // encoder for each type. The chain's kv_idx_* intrinsics have no scope + // parameter; encoding scope into sec_key provides equivalent isolation to + // the legacy db_idx*_find_secondary(code, scope, ...) API. + // + // Generic version: delegates to _kv_multi_index_detail::encode_secondary() + // which has specializations for uint64_t, uint128_t, double, and long double. + template + std::vector encode_scoped_secondary(const SecVal& val) const { + auto raw = _kv_multi_index_detail::encode_secondary(val); + std::vector buf(8 + raw.size()); + _kv_multi_index_detail::encode_be64(buf.data(), _scope); + memcpy(buf.data() + 8, raw.data(), raw.size()); + return buf; + } + + // Single-allocation fast path for uint64_t. + std::vector encode_scoped_secondary(const uint64_t& val) const { + std::vector buf(16); + _kv_multi_index_detail::encode_be64(buf.data(), _scope); + _kv_multi_index_detail::encode_be64(buf.data() + 8, val); + return buf; + } + + // Single-allocation fast path for uint128_t. + std::vector encode_scoped_secondary(const uint128_t& val) const { + std::vector buf(24); + _kv_multi_index_detail::encode_be64(buf.data(), _scope); + _kv_multi_index_detail::encode_be64(buf.data() + 8, static_cast(val >> 64)); + _kv_multi_index_detail::encode_be64(buf.data() + 16, static_cast(val)); + return buf; + } + + // Single-allocation fast path for double (sort-preserving transform + scope). + std::vector encode_scoped_secondary(const double& val) const { + uint64_t bits; + memcpy(&bits, &val, 8); + if (bits & (uint64_t(1) << 63)) + bits = ~bits; + else + bits ^= (uint64_t(1) << 63); + std::vector buf(16); + _kv_multi_index_detail::encode_be64(buf.data(), _scope); + _kv_multi_index_detail::encode_be64(buf.data() + 8, bits); + return buf; + } + // --- Secondary index helpers --- template struct secondary_ops { static void store_all(uint64_t payer, const kv_multi_index& idx, const T& obj) { using extractor_t = typename Index::secondary_extractor_type; extractor_t ext; - auto sec_key = _kv_multi_index_detail::encode_secondary(ext(obj)); + auto sec_key = idx.encode_scoped_secondary(ext(obj)); auto pri_key = idx.pk_to_bytes(obj.primary_key()); ::kv_idx_store(payer, static_cast(TableName), N, - pri_key.data, 16, + pri_key.data, 8, sec_key.data(), sec_key.size()); if constexpr (sizeof...(Rest) > 0) { secondary_ops::store_all(payer, idx, obj); @@ -315,10 +360,10 @@ class kv_multi_index { static void remove_all(const kv_multi_index& idx, const T& obj) { using extractor_t = typename Index::secondary_extractor_type; extractor_t ext; - auto sec_key = _kv_multi_index_detail::encode_secondary(ext(obj)); + auto sec_key = idx.encode_scoped_secondary(ext(obj)); auto pri_key = idx.pk_to_bytes(obj.primary_key()); ::kv_idx_remove(static_cast(TableName), N, - pri_key.data, 16, + pri_key.data, 8, sec_key.data(), sec_key.size()); if constexpr (sizeof...(Rest) > 0) { secondary_ops::remove_all(idx, obj); @@ -328,12 +373,12 @@ class kv_multi_index { static void update_all(uint64_t payer, const kv_multi_index& idx, const T& old_obj, const T& new_obj) { using extractor_t = typename Index::secondary_extractor_type; extractor_t ext; - auto old_sec = _kv_multi_index_detail::encode_secondary(ext(old_obj)); - auto new_sec = _kv_multi_index_detail::encode_secondary(ext(new_obj)); + auto old_sec = idx.encode_scoped_secondary(ext(old_obj)); + auto new_sec = idx.encode_scoped_secondary(ext(new_obj)); auto pri_key = idx.pk_to_bytes(old_obj.primary_key()); if (old_sec != new_sec) { ::kv_idx_update(payer, static_cast(TableName), N, - pri_key.data, 16, + pri_key.data, 8, old_sec.data(), old_sec.size(), new_sec.data(), new_sec.size()); } @@ -776,18 +821,22 @@ class kv_multi_index { using index_type = typename std::tuple_element>::type; using secondary_extractor_type = typename index_type::secondary_extractor_type; using secondary_key_type = std::decay_t; + // Secondary key encoding uses sizeof(secondary_key_type) for stack buffer sizing. + // Trivially copyable types guarantee sizeof == packed size (no varint prefixes). + static_assert(std::is_trivially_copyable::value, + "secondary key type must be trivially copyable for fixed-size encoding"); const kv_multi_index* _mi; secondary_index_view(const kv_multi_index& mi) : _mi(&mi) {} - // Helper: read scoped primary key from secondary iterator handle. - // The stored pri_key is [scope:8B][pk:8B] = 16 bytes; we extract the pk. + // Helper: read primary key from secondary iterator handle. + // The stored pri_key is [pk:8B]. static bool read_primary_key(uint32_t handle, uint64_t& pk) { - char pri_buf[16]; + char pri_buf[8]; uint32_t actual = 0; - int32_t status = ::kv_idx_primary_key(handle, 0, pri_buf, 16, &actual); - if (status != 0 || actual != 16) return false; - pk = _kv_multi_index_detail::decode_be64(pri_buf + 8); + int32_t status = ::kv_idx_primary_key(handle, 0, pri_buf, 8, &actual); + if (status != 0 || actual != 8) return false; + pk = _kv_multi_index_detail::decode_be64(pri_buf); return true; } @@ -807,7 +856,7 @@ class kv_multi_index { const_iterator& operator++() { check(_has_obj, "cannot increment end iterator"); if (!_has_obj || _handle < 0) return *this; - if (::kv_idx_next(_handle) == 0) { load_current(); } + if (::kv_idx_next(_handle) == 0 && check_scope()) { load_current(); } else { _has_obj = false; } return *this; } @@ -817,19 +866,22 @@ class kv_multi_index { const_iterator& operator--() { if (_handle < 0) { - // End sentinel: create real iterator at last entry. - // Use lower_bound with a maximal key (all 0xFF) to position - // past all entries, then prev to land on the last one. - char max_sec[kv::kv_key_max_bytes]; - memset(max_sec, 0xFF, sizeof(max_sec)); + // End sentinel: create real iterator at last entry in THIS scope. + // Build a maximal sec_key for this scope: [scope:8B][0xFF...] + // so lower_bound positions past this scope's entries, then prev + // lands on the last entry in the scope. + constexpr size_t sec_val_size = sizeof(secondary_key_type); + char max_sec[8 + sec_val_size]; + _kv_multi_index_detail::encode_be64(max_sec, _mi->_scope); + memset(max_sec + 8, 0xFF, sec_val_size); _handle = ::kv_idx_lower_bound( _mi->_code.value, static_cast(TableName), index_number, max_sec, sizeof(max_sec)); if (_handle < 0) { _has_obj = false; } - else if (::kv_idx_prev(_handle) == 0) { _has_obj = true; load_current(); } + else if (::kv_idx_prev(_handle) == 0 && check_scope()) { _has_obj = true; load_current(); } else { _has_obj = false; } } else { - if (::kv_idx_prev(_handle) == 0) { _has_obj = true; load_current(); } + if (::kv_idx_prev(_handle) == 0 && check_scope()) { _has_obj = true; load_current(); } else { check(false, "cannot decrement iterator at beginning of index"); } } return *this; @@ -870,7 +922,7 @@ class kv_multi_index { if (o._handle >= 0 && _mi && _has_obj) { using extractor_t = typename std::tuple_element>::type; extractor_t ext; - auto sec_bytes = _kv_multi_index_detail::encode_secondary(ext(_obj)); + auto sec_bytes = _mi->encode_scoped_secondary(ext(_obj)); _handle = ::kv_idx_find_secondary( _mi->_code.value, static_cast(TableName), index_number, sec_bytes.data(), sec_bytes.size()); @@ -912,6 +964,20 @@ class kv_multi_index { if (ptr) { _obj = *ptr; _has_obj = true; } else { _has_obj = false; } } + + // Verify the current sec_key still starts with [scope:8B]. + // After kv_idx_next/prev, the iterator may have crossed into + // another scope — treat that as end-of-range. + bool check_scope() const { + if (_handle < 0 || !_mi) return false; + char scope_buf[8]; + uint32_t actual = 0; + int32_t status = ::kv_idx_key(_handle, 0, scope_buf, 8, &actual); + if (status != 0 || actual < 8) return false; + char expected[8]; + _kv_multi_index_detail::encode_be64(expected, _mi->_scope); + return memcmp(scope_buf, expected, 8) == 0; + } }; const_iterator end() const { return const_iterator::make_end(_mi); } @@ -925,16 +991,22 @@ class kv_multi_index { const_reverse_iterator crend() const { return rend(); } const_iterator begin() const { - // Lower bound with empty key = first entry + // Lower bound with scope prefix = first entry in this scope + char scope_prefix[8]; + _kv_multi_index_detail::encode_be64(scope_prefix, _mi->_scope); int32_t handle = ::kv_idx_lower_bound( - _mi->_code.value, static_cast(TableName), index_number, nullptr, 0); + _mi->_code.value, static_cast(TableName), index_number, + scope_prefix, 8); if (handle < 0) return end(); - return const_iterator(_mi, handle, true); + // Verify we landed in the right scope (may be past it if scope is empty) + const_iterator it(_mi, handle, true); + if (it._has_obj && !it.check_scope()) return end(); + return it; } template const_iterator find(const SecKey& sec_key) const { - auto sec_bytes = _kv_multi_index_detail::encode_secondary(secondary_key_type(sec_key)); + auto sec_bytes = _mi->encode_scoped_secondary(secondary_key_type(sec_key)); int32_t handle = ::kv_idx_find_secondary( _mi->_code.value, static_cast(TableName), index_number, sec_bytes.data(), sec_bytes.size()); @@ -944,12 +1016,14 @@ class kv_multi_index { template const_iterator lower_bound(const SecKey& sec_key) const { - auto sec_bytes = _kv_multi_index_detail::encode_secondary(secondary_key_type(sec_key)); + auto sec_bytes = _mi->encode_scoped_secondary(secondary_key_type(sec_key)); int32_t handle = ::kv_idx_lower_bound( _mi->_code.value, static_cast(TableName), index_number, sec_bytes.data(), sec_bytes.size()); if (handle < 0) return end(); - return const_iterator(_mi, handle, true); + const_iterator it(_mi, handle, true); + if (it._has_obj && !it.check_scope()) return end(); + return it; } template diff --git a/libraries/sysiolib/core/sysio/datastream.hpp b/libraries/sysiolib/core/sysio/datastream.hpp index cd86d7ff6..63b64b23b 100644 --- a/libraries/sysiolib/core/sysio/datastream.hpp +++ b/libraries/sysiolib/core/sysio/datastream.hpp @@ -197,7 +197,7 @@ class datastream { * * @param init_size - The initial size */ - datastream( size_t init_size = 0):_size(init_size){} + constexpr datastream( size_t init_size = 0):_size(init_size){} /** * Increment the size by s. This behaves the same as write( const char* ,size_t s ). @@ -205,7 +205,7 @@ class datastream { * @param s - The amount of size to increase * @return true */ - inline bool skip( size_t s ) { _size += s; return true; } + constexpr bool skip( size_t s ) { _size += s; return true; } /** * Increment the size by s. This behaves the same as skip( size_t s ) @@ -213,7 +213,7 @@ class datastream { * @param s - The amount of size to increase * @return true */ - inline bool write( const char* ,size_t s ) { _size += s; return true; } + constexpr bool write( const char* ,size_t s ) { _size += s; return true; } /** * Increment the size by s. This behaves the same as skip( size_t s ) @@ -221,7 +221,7 @@ class datastream { * @param s - The amount of size to increase * @return true */ - inline bool write( char ) { _size++; return true; } + constexpr bool write( char ) { _size++; return true; } /** * Increment the size by s. This behaves the same as skip( size_t s ) @@ -229,7 +229,7 @@ class datastream { * @param s - The amount of size to increase * @return true */ - inline bool write( const void* ,size_t s ) { _size += s; return true; } + constexpr bool write( const void* ,size_t s ) { _size += s; return true; } /** * Increment the size by one @@ -258,7 +258,7 @@ class datastream { * * @return size_t - The size */ - inline size_t tellp()const { return _size; } + constexpr size_t tellp()const { return _size; } /** * Always returns 0 @@ -997,8 +997,14 @@ DataStream& operator>>( DataStream& ds, T& v ) { * @return datastream& - Reference to the datastream */ template()>* = nullptr> -datastream& operator<<( datastream& ds, const T& v ) { - ds.write( (const char*)&v, sizeof(T) ); +constexpr datastream& operator<<( datastream& ds, const T& v ) { + // datastream is a size-counting stream that ignores the pointer; + // use skip() to avoid the reinterpret_cast which is illegal in constexpr. + if constexpr (std::is_same_v) { + ds.skip( sizeof(T) ); + } else { + ds.write( (const char*)&v, sizeof(T) ); + } return ds; } @@ -1072,7 +1078,7 @@ T unpack( const std::vector& bytes ) { * @return size_t - Size of the packed data */ template -size_t pack_size( const T& value ) { +constexpr size_t pack_size( const T& value ) { datastream ps; ps << value; return ps.tellp(); diff --git a/tests/integration/contracts.hpp.in b/tests/integration/contracts.hpp.in index b5081b6fc..3567cc6de 100644 --- a/tests/integration/contracts.hpp.in +++ b/tests/integration/contracts.hpp.in @@ -40,6 +40,9 @@ namespace sysio::testing { static std::vector test_multi_index_wasm() { return read_wasm("${CMAKE_BINARY_DIR}/../unit/test_contracts/test_multi_index.wasm"); } static std::vector test_multi_index_abi() { return read_abi("${CMAKE_BINARY_DIR}/../unit/test_contracts/test_multi_index.abi"); } + static std::vector mi_scope_tests_wasm() { return read_wasm("${CMAKE_BINARY_DIR}/../unit/test_contracts/mi_scope_tests.wasm"); } + static std::vector mi_scope_tests_abi() { return read_abi("${CMAKE_BINARY_DIR}/../unit/test_contracts/mi_scope_tests.abi"); } + static std::vector kv_table_tests_wasm() { return read_wasm("${CMAKE_BINARY_DIR}/../unit/test_contracts/kv_table_tests.wasm"); } static std::vector kv_table_tests_abi() { return read_abi("${CMAKE_BINARY_DIR}/../unit/test_contracts/kv_table_tests.abi"); } diff --git a/tests/integration/multi_index_tests.cpp b/tests/integration/multi_index_tests.cpp index 5c7543bb2..d76dd16f1 100644 --- a/tests/integration/multi_index_tests.cpp +++ b/tests/integration/multi_index_tests.cpp @@ -87,4 +87,22 @@ BOOST_FIXTURE_TEST_CASE(main_multi_index_tests, TESTER) { try { BOOST_REQUIRE_EQUAL( validate(), true ); } FC_LOG_AND_RETHROW() } +// Cross-scope secondary index isolation tests (separate contract to stay under net limit) +BOOST_FIXTURE_TEST_CASE(cross_scope_secondary_index_tests, TESTER) { try { + produce_blocks(1); + create_account( "scopetest"_n ); + produce_blocks(1); + set_code( "scopetest"_n, contracts::mi_scope_tests_wasm() ); + set_abi( "scopetest"_n, contracts::mi_scope_tests_abi().data() ); + produce_blocks(1); + + push_action( "scopetest"_n, "xscope"_n, "scopetest"_n, {} ); // iteration isolated per scope + push_action( "scopetest"_n, "xscopefind"_n, "scopetest"_n, {} ); // find() respects scope + push_action( "scopetest"_n, "xscopeerase"_n, "scopetest"_n, {} ); // erase in A doesn't affect B + push_action( "scopetest"_n, "xscopeub"_n, "scopetest"_n, {} ); // upper_bound stops at scope + push_action( "scopetest"_n, "xscoperev"_n, "scopetest"_n, {} ); // reverse iteration within scope + + BOOST_REQUIRE_EQUAL( validate(), true ); +} FC_LOG_AND_RETHROW() } + BOOST_AUTO_TEST_SUITE_END() \ No newline at end of file diff --git a/tests/unit/test_contracts/CMakeLists.txt b/tests/unit/test_contracts/CMakeLists.txt index 759d740ff..1a731ed5c 100644 --- a/tests/unit/test_contracts/CMakeLists.txt +++ b/tests/unit/test_contracts/CMakeLists.txt @@ -13,6 +13,7 @@ add_contract(get_code_hash_tests get_code_hash_write get_code_hash_write.cpp) add_contract(get_code_hash_tests get_code_hash_read get_code_hash_read.cpp) add_contract(name_pk_tests name_pk_tests name_pk_tests.cpp) add_contract(test_multi_index test_multi_index multi_index_tests.cpp) +add_contract(mi_scope_tests mi_scope_tests multi_index_scope_tests.cpp) add_contract(kv_table_tests kv_table_tests kv_table_tests.cpp) add_contract(kv_raw_table_tests kv_raw_table_tests kv_raw_table_tests.cpp) add_contract(kv_indexed_table_tests kv_indexed_table_tests kv_indexed_table_tests.cpp) diff --git a/tests/unit/test_contracts/multi_index_scope_tests.cpp b/tests/unit/test_contracts/multi_index_scope_tests.cpp new file mode 100644 index 000000000..ce009a18a --- /dev/null +++ b/tests/unit/test_contracts/multi_index_scope_tests.cpp @@ -0,0 +1,129 @@ +#include +#include + +// Separate contract for cross-scope secondary index isolation tests. +// Split from multi_index_tests.cpp to stay under max_transaction_net_usage. + +namespace _scope_tests { + +struct [[sysio::table("scopetbl")]] row { + uint64_t id; + uint64_t sec; + + auto primary_key() const { return id; } + uint64_t get_secondary() const { return sec; } + + SYSLIB_SERIALIZE(row, (id)(sec)) +}; + +} // namespace _scope_tests + +class [[sysio::contract]] mi_scope_tests : public sysio::contract { +public: + using sysio::contract::contract; + + using scope_table = sysio::multi_index<"scopetbl"_n, _scope_tests::row, + sysio::indexed_by<"bysec"_n, sysio::const_mem_fun<_scope_tests::row, uint64_t, + &_scope_tests::row::get_secondary>> + >; + + // Same PKs and overlapping secondary keys in two scopes — iteration isolated + [[sysio::action]] + void xscope() { + sysio::name payer = get_self(); + scope_table ta(get_self(), "scope.a"_n.value); + scope_table tb(get_self(), "scope.b"_n.value); + + ta.emplace(payer, [](auto& r) { r.id = 1; r.sec = 100; }); + ta.emplace(payer, [](auto& r) { r.id = 2; r.sec = 200; }); + tb.emplace(payer, [](auto& r) { r.id = 1; r.sec = 300; }); + tb.emplace(payer, [](auto& r) { r.id = 2; r.sec = 400; }); + + auto idxA = ta.get_index<"bysec"_n>(); + auto it = idxA.begin(); + sysio::check(it != idxA.end() && it->sec == 100, "xscope: A[0]=100"); + ++it; + sysio::check(it != idxA.end() && it->sec == 200, "xscope: A[1]=200"); + ++it; + sysio::check(it == idxA.end(), "xscope: A end after 2"); + + auto idxB = tb.get_index<"bysec"_n>(); + auto itb = idxB.begin(); + sysio::check(itb != idxB.end() && itb->sec == 300, "xscope: B[0]=300"); + ++itb; + sysio::check(itb != idxB.end() && itb->sec == 400, "xscope: B[1]=400"); + ++itb; + sysio::check(itb == idxB.end(), "xscope: B end after 2"); + } + + // find() in scope A must not see scope B's entries + [[sysio::action]] + void xscopefind() { + sysio::name payer = get_self(); + scope_table ta(get_self(), "xfind.a"_n.value); + scope_table tb(get_self(), "xfind.b"_n.value); + + ta.emplace(payer, [](auto& r) { r.id = 1; r.sec = 50; }); + tb.emplace(payer, [](auto& r) { r.id = 1; r.sec = 99; }); + + auto idxA = ta.get_index<"bysec"_n>(); + sysio::check(idxA.find(99) == idxA.end(), "xscopefind: 99 not in A"); + sysio::check(idxA.find(50) != idxA.end(), "xscopefind: 50 in A"); + } + + // erase from scope A must not affect scope B + [[sysio::action]] + void xscopeerase() { + sysio::name payer = get_self(); + scope_table ta(get_self(), "xerase.a"_n.value); + scope_table tb(get_self(), "xerase.b"_n.value); + + ta.emplace(payer, [](auto& r) { r.id = 1; r.sec = 77; }); + tb.emplace(payer, [](auto& r) { r.id = 1; r.sec = 77; }); + + auto idxA = ta.get_index<"bysec"_n>(); + idxA.erase(idxA.find(77)); + sysio::check(idxA.begin() == idxA.end(), "xscopeerase: A empty"); + + auto idxB = tb.get_index<"bysec"_n>(); + sysio::check(idxB.find(77) != idxB.end(), "xscopeerase: B still has 77"); + } + + // upper_bound in scope A stops at scope boundary + [[sysio::action]] + void xscopeub() { + sysio::name payer = get_self(); + scope_table ta(get_self(), "xub.a"_n.value); + scope_table tb(get_self(), "xub.b"_n.value); + + ta.emplace(payer, [](auto& r) { r.id = 1; r.sec = 10; }); + ta.emplace(payer, [](auto& r) { r.id = 2; r.sec = 20; }); + tb.emplace(payer, [](auto& r) { r.id = 1; r.sec = 15; }); + + auto idxA = ta.get_index<"bysec"_n>(); + auto it = idxA.upper_bound(10); + sysio::check(it != idxA.end() && it->sec == 20, "xscopeub: ub(10)=20"); + ++it; + sysio::check(it == idxA.end(), "xscopeub: end after ub"); + } + + // reverse iteration stays within scope + [[sysio::action]] + void xscoperev() { + sysio::name payer = get_self(); + scope_table ta(get_self(), "xrev.a"_n.value); + scope_table tb(get_self(), "xrev.b"_n.value); + + ta.emplace(payer, [](auto& r) { r.id = 1; r.sec = 100; }); + ta.emplace(payer, [](auto& r) { r.id = 2; r.sec = 200; }); + tb.emplace(payer, [](auto& r) { r.id = 1; r.sec = 300; }); + + auto idxA = ta.get_index<"bysec"_n>(); + auto rit = idxA.rbegin(); + sysio::check(rit != idxA.rend() && rit->sec == 200, "xscoperev: rbegin=200"); + ++rit; + sysio::check(rit != idxA.rend() && rit->sec == 100, "xscoperev: next=100"); + ++rit; + sysio::check(rit == idxA.rend(), "xscoperev: rend after 2"); + } +};