diff --git a/contracts/sysio.roa/sysio.roa.wasm b/contracts/sysio.roa/sysio.roa.wasm index f675dc137f..fea66dd37c 100755 Binary files a/contracts/sysio.roa/sysio.roa.wasm and b/contracts/sysio.roa/sysio.roa.wasm differ diff --git a/contracts/sysio.system/sysio.system.wasm b/contracts/sysio.system/sysio.system.wasm index d421f50984..0b734c1512 100755 Binary files a/contracts/sysio.system/sysio.system.wasm and b/contracts/sysio.system/sysio.system.wasm differ diff --git a/docs/kv-ram-billing.md b/docs/kv-ram-billing.md index 2dbfb359a1..34ac58ae64 100644 --- a/docs/kv-ram-billing.md +++ b/docs/kv-ram-billing.md @@ -28,19 +28,22 @@ shared memory). Key data sizes are billed separately at billing time. | Object | Fixed Fields | Indices | Overhead | Billable | |--------|-------------|---------|----------|----------| | `kv_object` | 8 id + 8 code + 8 payer + 8 key (offset_ptr) + 8 value (offset_ptr) + 1 key_format + 7 padding = 48 | 2 | 64 | **112** + key_size + value_size | -| `kv_index_object` | 8 id + 8 code + 8 payer + 8 table + 8 sec_key (offset_ptr) + 8 pri_key (offset_ptr) + 1 index_id + 7 padding = 56 | 3 | 96 | **152** + sec_key_size + pri_key_size | +| `kv_index_object` | 8 id + 8 code + 8 payer + 8 table + 8 sec_key (offset_ptr) + 8 pri_key (offset_ptr) + 1 index_id + 7 padding = 56 | 2 | 64 | **120** + sec_key_size + pri_key_size | ## KV Key Formats KV supports two key formats with different RAM trade-offs: -- **Format 1 (standard):** `kv_multi_index` uses 24-byte keys: `[table:8B][scope:8B][pk:8B]`. - Embeds legacy table/scope routing into the key. Compatible with SHiP table - translation and existing tooling. +- **Format 1 (standard):** `kv_multi_index` uses 24-byte primary keys: + `[table:8B][scope:8B][pk:8B]`. Secondary index entries store + `pri_key = [pk:8B]` and `sec_key = [scope:8B][secondary_value]` so that + secondary iteration is naturally scoped without chain-side scope parameters. -- **Format 0 (raw):** `kv::raw_table` uses contract-defined key structs, - BE-encoded for correct sort order. Key size is controlled by the contract. - A uint64 primary key is just 8 bytes. This is the most RAM-efficient option. +- **Format 0 (raw):** `kv::raw_table` / `kv::indexed_table` use contract-defined + key structs, BE-encoded for correct sort order. Key size is controlled by the + contract. A uint64 primary key is just 8 bytes. This is the most RAM-efficient + option. Secondary indices use `TableName` for table-level isolation; contracts + use key discriminators for further partitioning. ## Per-Row Comparisons (16-byte value, no secondary index) @@ -67,49 +70,65 @@ KV supports two key formats with different RAM trade-offs: | Approach | Primary | Secondary | Total | |----------|---------|-----------|-------| | Legacy | 124 + 108 tid | 128 | **360** | -| kv_multi_index (fmt=1) | 152 | 152+8+24=184 | **336** (-7%) | -| kv::raw_table (fmt=0, 8B key) | 136 | 152+8+8=168 | **304** (-16%) | +| kv_multi_index (fmt=1) | 152 | 120+16+8=144 | **296** (-18%) | +| kv::raw_table (fmt=0, 8B key) | 136 | 120+8+8=136 | **272** (-24%) | ### Many rows per table | Approach | Primary | Secondary | Total | |----------|---------|-----------|-------| | Legacy | 124 | 128 | **252** | -| kv_multi_index (fmt=1) | 152 | 184 | **336** (+33%) | -| kv::raw_table (fmt=0, 8B key) | 136 | 168 | **304** (+21%) | +| kv_multi_index (fmt=1) | 152 | 144 | **296** (+17%) | +| kv::raw_table (fmt=0, 8B key) | 136 | 136 | **272** (+8%) | -Note: with raw_table, the `pri_key` stored in `kv_index_object` is also compact -(8 bytes) vs 24 bytes for standard format. This compounds the savings per -secondary index row. +Note: `kv_index_object` has 2 chainbase indices (by_id, by_code_table_idx_seckey). +For format 1, `sec_key = [scope:8B][secondary_value]` and `pri_key = [pk:8B]`. +For format 0, both `sec_key` and `pri_key` are contract-defined compact keys. -## EOS Mainnet Estimate (excluding xsat, ~103M primary rows) +## EOS Mainnet Estimate -Estimated using an EOS v8 snapshot with xsat Bitcoin UTXO data excluded -(xsat alone has 345M index256 rows that dominate the dataset). +Estimated using EOS v8 snapshot (`snapshot-2026-03-10-19-eos-v8-0487758850.bin`). +Secondary index counts: 13.3M idx64, 3.9M idx128, 355.6M idx256 (345M from xsat), +47K idx_double, 5K idx_long_double. + +### Excluding xsat (~103M primary rows, ~28M secondary entries) | Approach | Total | vs Legacy | |----------|-------|-----------| | Legacy | 14.4 GB | baseline | -| kv_multi_index (fmt=1) | 16.0 GB | **+11%** | -| kv::raw_table (fmt=0, ~10B avg key) | 14.7 GB | **+2%** | +| kv_multi_index (fmt=1) | 15.1 GB | **+5%** | +| kv::raw_table (fmt=0, ~10B avg key) | 13.8 GB | **-4%** | + +### Including xsat (~262M primary rows, ~373M secondary entries) + +Secondary index storage alone (primary rows unchanged by this PR): + +| Approach | Secondary Only | vs Legacy | +|----------|---------------|-----------| +| Legacy | 56.3 GB | baseline | +| kv_multi_index (fmt=1) | 62.3 GB | **+11%** | +| kv::raw_table (fmt=0) | 59.3 GB | **+5%** | -The +11% for `kv_multi_index` comes from 24-byte standard keys replacing -legacy's 8-byte fixed `uint64_t primary_key`. With `kv::raw_table`, compact -keys eliminate this gap. +The `kv_multi_index` premium comes from 24-byte standard keys replacing +legacy's 8-byte fixed `uint64_t primary_key` and scope-prefixed secondary keys. +With `kv::raw_table`, compact keys reduce this gap. Dropping the unused +`by_code_table_idx_prikey` index saves 32 bytes per secondary entry (11.9 GB +across all 373M entries including xsat). ## Summary | Scenario | Legacy | kv_multi_index | kv::raw_table | |----------|--------|----------------|---------------| | Token balance (1 scope/row, no idx) | 232 | 152 (-34%) | 144 (-38%) | -| Token balance + 1 idx (1 scope/row) | 360 | 336 (-7%) | 304 (-16%) | +| Token balance + 1 idx (1 scope/row) | 360 | 296 (-18%) | 272 (-24%) | | Dense table (no idx) | 124 | 152 (+23%) | 136 (+10%) | -| Dense table + 1 idx | 252 | 336 (+33%) | 304 (+21%) | -| EOS mainnet (no xsat) | 14.4 GB | 16.0 GB (+11%) | 14.7 GB (+2%) | - -**Bottom line:** `kv::raw_table` with compact keys achieves near-parity with -legacy (+2% on EOS-scale data) while providing variable-length key flexibility. -`kv_multi_index` pays a ~11-33% premium for the convenience of embedding -table/scope routing in 24-byte standard keys. Contracts without secondary -indices and with many scopes (like sysio.token) save 34-38% regardless of -format, thanks to `table_id_object` elimination. +| Dense table + 1 idx | 252 | 296 (+17%) | 272 (+8%) | + +**Bottom line:** Dropping the unused `by_code_table_idx_prikey` chainbase index +reduced `kv_index_object` overhead from 152 to 120 bytes per secondary entry, +significantly improving secondary index RAM costs. `kv::raw_table` with compact +keys now saves 8-24% vs legacy for tables with secondary indices. +`kv_multi_index` saves 18% for scoped patterns and adds only 17% for dense +tables — a major improvement from the previous 33% premium. Contracts without +secondary indices and with many scopes (like sysio.token) save 34-38% +regardless of format, thanks to `table_id_object` elimination. diff --git a/libraries/chain/include/sysio/chain/kv_table_objects.hpp b/libraries/chain/include/sysio/chain/kv_table_objects.hpp index a80341fdc9..0ac92e0714 100644 --- a/libraries/chain/include/sysio/chain/kv_table_objects.hpp +++ b/libraries/chain/include/sysio/chain/kv_table_objects.hpp @@ -136,7 +136,6 @@ namespace sysio { namespace chain { }; struct by_code_table_idx_seckey; - struct by_code_table_idx_prikey; using kv_index_index = chainbase::shared_multi_index_container< kv_index_object, @@ -154,16 +153,6 @@ namespace sysio { namespace chain { >, composite_key_compare, std::less, std::less, kv_key_less, kv_key_less> - >, - ordered_unique, - composite_key, - member, - member, - kv_pri_key_extractor - >, - composite_key_compare, std::less, std::less, - kv_key_less> > > >; @@ -180,7 +169,7 @@ namespace config { template<> struct billable_size { - static const uint64_t overhead = overhead_per_row_per_index_ram_bytes * 3; ///< 3 indices: by_id, by_code_table_idx_seckey, by_code_table_idx_prikey + static const uint64_t overhead = overhead_per_row_per_index_ram_bytes * 2; ///< 2 indices: by_id, by_code_table_idx_seckey // Fixed fields: 8 id + 8 code + 8 payer + 8 table + 8 sec_key (offset_ptr) // + 8 pri_key (offset_ptr) + 1 index_id + 7 padding = 56 // sec_key.size() and pri_key.size() are added separately at billing time. diff --git a/libraries/chain/include/sysio/chain/types.hpp b/libraries/chain/include/sysio/chain/types.hpp index eae3f3a4c4..58cb54bb32 100644 --- a/libraries/chain/include/sysio/chain/types.hpp +++ b/libraries/chain/include/sysio/chain/types.hpp @@ -96,9 +96,11 @@ namespace sysio::chain { } /// Standard KV key sizes (bytes) - inline constexpr size_t kv_key_size = 24; ///< [table:8B][scope:8B][pk:8B] - inline constexpr size_t kv_prefix_size = 16; ///< [table:8B][scope:8B] - inline constexpr size_t kv_table_prefix_size = 8; ///< [table:8B] + inline constexpr size_t kv_key_size = 24; ///< [table:8B][scope:8B][pk:8B] + inline constexpr size_t kv_prefix_size = 16; ///< [table:8B][scope:8B] + inline constexpr size_t kv_table_prefix_size = 8; ///< [table:8B] + inline constexpr size_t kv_scope_prefix_size = kv_table_prefix_size; ///< [scope:8B] prefix in sec_key + inline constexpr size_t kv_pri_key_size = sizeof(uint64_t); ///< [pk:8B] stored in kv_index_object /// Build a 24-byte KV key: [table:8B BE][scope:8B BE][pk:8B BE] struct kv_key_t { diff --git a/plugins/chain_plugin/src/chain_plugin.cpp b/plugins/chain_plugin/src/chain_plugin.cpp index e7826c396c..3c8a8a66b2 100644 --- a/plugins/chain_plugin/src/chain_plugin.cpp +++ b/plugins/chain_plugin/src/chain_plugin.cpp @@ -1801,15 +1801,31 @@ read_only::get_table_rows_by_seckey( const read_only::get_table_rows_params& p, // Encode bounds auto lb_bytes = encode_sec_bound(p.key_type, p.lower_bound, p.encode_type); auto ub_bytes = encode_sec_bound(p.key_type, p.upper_bound, p.encode_type); - auto lb_sv = std::string_view(lb_bytes.data(), lb_bytes.size()); - auto ub_sv = std::string_view(ub_bytes.data(), ub_bytes.size()); bool has_lower = !lb_bytes.empty(); bool has_upper = !ub_bytes.empty(); - // CDT's kv_multi_index encodes secondary pri_key as [scope:8B BE][pk:8B BE] - char scope_be[chain::kv_table_prefix_size]; + // CDT's kv_multi_index encodes sec_key as [scope:8B BE][secondary_value] + // and pri_key as [pk:8B BE]. Prepend scope to search bounds so that + // B-tree iteration is naturally scoped. + char scope_be[chain::kv_scope_prefix_size]; chain::kv_encode_be64(scope_be, scope); + auto prepend_scope = [&](const vector& bound) -> vector { + vector scoped(chain::kv_scope_prefix_size + bound.size()); + memcpy(scoped.data(), scope_be, chain::kv_scope_prefix_size); + if (!bound.empty()) + memcpy(scoped.data() + chain::kv_scope_prefix_size, bound.data(), bound.size()); + return scoped; + }; + + // If lower bound specified, scope-prefix it; otherwise use bare scope prefix + // to position at the first entry in this scope. + auto scoped_lb = has_lower ? prepend_scope(lb_bytes) + : vector(scope_be, scope_be + chain::kv_scope_prefix_size); + auto scoped_ub = has_upper ? prepend_scope(ub_bytes) : vector(); + auto lb_sv = std::string_view(scoped_lb.data(), scoped_lb.size()); + auto ub_sv = has_upper ? std::string_view(scoped_ub.data(), scoped_ub.size()) : std::string_view(); + const auto& sec_idx = d.get_index(); uint32_t limit = p.limit; @@ -1832,31 +1848,38 @@ read_only::get_table_rows_by_seckey( const read_only::get_table_rows_params& p, }; auto collect_next = [&](const chain::kv_index_object& obj) { + // Validate sec_key has scope prefix and matches our scope + if (obj.sec_key.size() < chain::kv_scope_prefix_size || + memcmp(obj.sec_key.data(), scope_be, chain::kv_scope_prefix_size) != 0) + return; hp.more = true; - hp.next_key = decode_sec_key(p.key_type, obj.sec_key.data(), obj.sec_key.size(), p.encode_type); + // sec_key is [scope:8B][value] — skip scope prefix for the API response + const char* sec_data = obj.sec_key.data() + chain::kv_scope_prefix_size; + size_t sec_size = obj.sec_key.size() - chain::kv_scope_prefix_size; + hp.next_key = decode_sec_key(p.key_type, sec_data, sec_size, p.encode_type); }; bool reverse = p.reverse && *p.reverse; if (!reverse) { - // Forward iteration - auto itr = has_lower - ? sec_idx.lower_bound(boost::make_tuple(p.code, p.table, index_id, lb_sv)) - : sec_idx.lower_bound(boost::make_tuple(p.code, p.table, index_id)); + // Forward iteration — lb_sv already includes [scope:8B] prefix + auto itr = sec_idx.lower_bound(boost::make_tuple(p.code, p.table, index_id, lb_sv)); uint32_t count = 0; for (; itr != sec_idx.end(); ++itr) { if (itr->code != p.code || itr->table != p.table || itr->index_id != index_id) break; - // Upper bound check on secondary key + // Scope boundary: sec_key is [scope:8B][value]; entries are sorted, + // so once scope prefix differs we're past this scope. + if (itr->sec_key.size() < chain::kv_scope_prefix_size || + memcmp(itr->sec_key.data(), scope_be, chain::kv_scope_prefix_size) != 0) + break; + // Upper bound check on scoped secondary key if (has_upper && itr->sec_key_view() > ub_sv) break; - // Scope filter: first 8 bytes of pri_key must match scope - if (itr->pri_key.size() < chain::kv_prefix_size) continue; - if (memcmp(itr->pri_key.data(), scope_be, chain::kv_table_prefix_size) != 0) - continue; if (count >= limit) { collect_next(*itr); break; } - // Extract primary key (bytes 8-15 of pri_key) - uint64_t pk = chain::kv_decode_be64(itr->pri_key.data() + chain::kv_table_prefix_size); + // pri_key is [pk:8B] + if (itr->pri_key.size() < chain::kv_pri_key_size) continue; + uint64_t pk = chain::kv_decode_be64(itr->pri_key.data()); auto [data, payer] = fetch_value(pk); if (!data.empty()) { hp.rows.emplace_back(std::move(data), payer); @@ -1870,25 +1893,35 @@ read_only::get_table_rows_by_seckey( const read_only::get_table_rows_params& p, } } } else { - // Reverse iteration - auto end_itr = has_upper - ? sec_idx.upper_bound(boost::make_tuple(p.code, p.table, index_id, ub_sv)) - : sec_idx.upper_bound(boost::make_tuple(p.code, p.table, index_id)); - auto begin_itr = has_lower - ? sec_idx.lower_bound(boost::make_tuple(p.code, p.table, index_id, lb_sv)) - : sec_idx.lower_bound(boost::make_tuple(p.code, p.table, index_id)); + // Reverse iteration — bounds already include [scope:8B] prefix. + // For no-upper-bound, position past the last entry in this scope + // by using scope+1 as the exclusive upper bound prefix. + auto end_itr = [&]() { + if (has_upper) + return sec_idx.upper_bound(boost::make_tuple(p.code, p.table, index_id, ub_sv)); + if (scope < std::numeric_limits::max()) { + char scope_next_be[chain::kv_scope_prefix_size]; + chain::kv_encode_be64(scope_next_be, scope + 1); + return sec_idx.lower_bound(boost::make_tuple(p.code, p.table, index_id, std::string_view(scope_next_be, chain::kv_scope_prefix_size))); + } + return sec_idx.upper_bound(boost::make_tuple(p.code, p.table, index_id)); + }(); + auto begin_itr = sec_idx.lower_bound(boost::make_tuple(p.code, p.table, index_id, lb_sv)); auto ritr = boost::make_reverse_iterator(end_itr); auto rend = boost::make_reverse_iterator(begin_itr); uint32_t count = 0; for (; ritr != rend; ++ritr) { if (ritr->code != p.code || ritr->table != p.table || ritr->index_id != index_id) break; - // Scope filter - if (ritr->pri_key.size() < chain::kv_prefix_size) continue; - if (memcmp(ritr->pri_key.data(), scope_be, chain::kv_table_prefix_size) != 0) - continue; + // Scope boundary: sec_key is [scope:8B][value]; entries are sorted, + // so once scope prefix differs we're past this scope. + if (ritr->sec_key.size() < chain::kv_scope_prefix_size || + memcmp(ritr->sec_key.data(), scope_be, chain::kv_scope_prefix_size) != 0) + break; if (count >= limit) { collect_next(*ritr); break; } - uint64_t pk = chain::kv_decode_be64(ritr->pri_key.data() + chain::kv_table_prefix_size); + // pri_key is [pk:8B] + if (ritr->pri_key.size() < chain::kv_pri_key_size) continue; + uint64_t pk = chain::kv_decode_be64(ritr->pri_key.data()); auto [data, payer] = fetch_value(pk); if (!data.empty()) { hp.rows.emplace_back(std::move(data), payer); diff --git a/tests/sysio_util_snapshot_info_test.py b/tests/sysio_util_snapshot_info_test.py index dad36fda98..d2e4a1fdbd 100755 --- a/tests/sysio_util_snapshot_info_test.py +++ b/tests/sysio_util_snapshot_info_test.py @@ -16,7 +16,7 @@ "result": { "version": 1, "chain_id": "144035215e20fd016e2b4b065349c959a1070fcbb0dc3f4784f3130685e774fc", - "head_block_id": "0000001db8ed54f83aa97ed6112a0f39568705ead8d2c50ffe4c4c28e193aab3", + "head_block_id": "0000001d6e07189b77f366197e2fe3b797323bc51967d3eff34379c8ea24cd2b", "head_block_num": 29, "head_block_time": "2025-01-01T00:00:14.000" } diff --git a/unittests/kv_api_tests.cpp b/unittests/kv_api_tests.cpp index 0ce8c1ac16..3eee42e95c 100644 --- a/unittests/kv_api_tests.cpp +++ b/unittests/kv_api_tests.cpp @@ -14,8 +14,11 @@ using namespace sysio::testing; static const name test_account = "kvtest"_n; -struct kv_api_tester : validating_tester { - kv_api_tester() { +// Shared blockchain — initialized once, reused by all kv_api test cases. +// Each WASM action is self-contained (creates its own keys, runs its own checks), +// so accumulating chain state across tests is safe. +struct kv_shared_tester : validating_tester { + kv_shared_tester() { create_accounts({test_account}); produce_block(); set_code(test_account, test_contracts::test_kv_api_wasm()); @@ -38,6 +41,23 @@ struct kv_api_tester : validating_tester { } }; +// Per-test fixture: thin wrapper around the shared tester. +// Eliminates redundant blockchain boots (+ OC compilation under ASAN). +struct kv_api_tester { + kv_shared_tester& t; + kv_api_tester() : t(shared_instance()) {} + void run_action(name n) { t.run_action(n); } + static kv_shared_tester& shared_instance() { + static kv_shared_tester inst; + return inst; + } +}; + +// Fresh per-test fixture for cross-scope tests whose actions use overlapping +// primary keys that would collide with prior shared-tester state. +struct kv_api_fresh_tester : kv_shared_tester { +}; + BOOST_AUTO_TEST_SUITE(kv_api_tests) // ─── Primary KV operations ───────────────────────────────────────────────── @@ -323,7 +343,7 @@ BOOST_FIXTURE_TEST_CASE(sec_erase_via_iterator, kv_api_tester) { BOOST_CHECK_NO_THROW(run_action("tstsecerase"_n)); } -BOOST_FIXTURE_TEST_CASE(sec_iterate_order, kv_api_tester) { +BOOST_FIXTURE_TEST_CASE(sec_iterate_order, kv_api_fresh_tester) { BOOST_CHECK_NO_THROW(run_action("tstseciter"_n)); } @@ -340,11 +360,11 @@ BOOST_FIXTURE_TEST_CASE(primary_rbegin_empty, kv_api_tester) { BOOST_CHECK_NO_THROW(run_action("tstrbempty"_n)); } -BOOST_FIXTURE_TEST_CASE(sec_rbegin_rend, kv_api_tester) { +BOOST_FIXTURE_TEST_CASE(sec_rbegin_rend, kv_api_fresh_tester) { BOOST_CHECK_NO_THROW(run_action("tstsecrbegin"_n)); } -BOOST_FIXTURE_TEST_CASE(sec_erase_returns_next, kv_api_tester) { +BOOST_FIXTURE_TEST_CASE(sec_erase_returns_next, kv_api_fresh_tester) { BOOST_CHECK_NO_THROW(run_action("tstsecersnxt"_n)); } @@ -374,7 +394,7 @@ BOOST_FIXTURE_TEST_CASE(kvtable_iterate, kv_api_tester) { BOOST_CHECK_NO_THROW(run_action("tstkvtiter"_n)); } -BOOST_FIXTURE_TEST_CASE(kvtable_begin_all_scopes, kv_api_tester) { +BOOST_FIXTURE_TEST_CASE(kvtable_begin_all_scopes, kv_api_fresh_tester) { BOOST_CHECK_NO_THROW(run_action("tstkvtscope"_n)); } @@ -386,8 +406,8 @@ BOOST_FIXTURE_TEST_CASE(payer_self_allowed, kv_api_tester) { BOOST_FIXTURE_TEST_CASE(payer_other_requires_auth, kv_api_tester) { // Billing another account without their authorization fails at the // transaction level (unauthorized_ram_usage_increase), not at the KV level. - create_accounts({"alice"_n}); - produce_block(); + t.create_accounts({"alice"_n}); + t.produce_block(); BOOST_CHECK_THROW(run_action("tstpayeroth"_n), sysio::chain::unauthorized_ram_usage_increase); } @@ -424,10 +444,10 @@ BOOST_FIXTURE_TEST_CASE(read_only_trx_rejects_write, kv_api_tester) { vector{{test_account, config::active_name}}, test_account, "tstrdonly"_n, bytes{} ); - set_transaction_headers(trx); - trx.sign(get_private_key(test_account, "active"), control->get_chain_id()); + t.set_transaction_headers(trx); + trx.sign(t.get_private_key(test_account, "active"), t.control->get_chain_id()); BOOST_CHECK_THROW( - push_transaction(trx, fc::time_point::maximum(), DEFAULT_BILLED_CPU_TIME_US, false, + t.push_transaction(trx, fc::time_point::maximum(), kv_shared_tester::DEFAULT_BILLED_CPU_TIME_US, false, transaction_metadata::trx_type::read_only), fc::exception); } @@ -446,11 +466,11 @@ BOOST_FIXTURE_TEST_CASE(notify_context_ram_billing, kv_api_tester) { // When a contract receives a notification (receiver != act.account), // kv_set writes to the receiver's namespace and bills receiver's RAM name notify_acct = "kvnotify"_n; - create_accounts({notify_acct}); - produce_block(); - set_code(notify_acct, test_contracts::test_kv_api_wasm()); - set_abi(notify_acct, test_contracts::test_kv_api_abi().c_str()); - produce_block(); + t.create_accounts({notify_acct}); + t.produce_block(); + t.set_code(notify_acct, test_contracts::test_kv_api_wasm()); + t.set_abi(notify_acct, test_contracts::test_kv_api_abi().c_str()); + t.produce_block(); // Push tstsendnotif on test_account — it calls require_recipient(kvnotify) // kvnotify receives the notification and executes tstnotifyram handler (via apply) @@ -459,14 +479,14 @@ BOOST_FIXTURE_TEST_CASE(notify_context_ram_billing, kv_api_tester) { // any kv_set in kvnotify's on_notify will write to kvnotify's KV space. // For this test, we just verify the notification doesn't crash. BOOST_CHECK_NO_THROW(run_action("tstsendnotif"_n)); - produce_block(); + t.produce_block(); // Verify kvnotify did NOT write to test_account's KV space // (notification receiver writes to its own namespace) } // secondary iterator clone with duplicate keys -BOOST_FIXTURE_TEST_CASE(sec_iterator_clone_duplicate_keys, kv_api_tester) { +BOOST_FIXTURE_TEST_CASE(sec_iterator_clone_duplicate_keys, kv_api_fresh_tester) { BOOST_CHECK_NO_THROW(run_action("tstsecclone"_n)); } @@ -475,6 +495,48 @@ BOOST_FIXTURE_TEST_CASE(sec_rbegin_uint128_keys, kv_api_tester) { BOOST_CHECK_NO_THROW(run_action("tstsecrbig"_n)); } +// ════════════════════════════════════════════════════════════════════════════ +// Cross-scope secondary index isolation tests +// Verify that kv_multi_index secondary indices are properly scoped. +// These use kv_api_fresh_tester because they exercise multi_index with +// overlapping primary keys across scopes. +// ════════════════════════════════════════════════════════════════════════════ + +BOOST_FIXTURE_TEST_CASE(sec_cross_scope_isolation, kv_api_fresh_tester) { + // Two scopes with same PKs — iteration in each scope must be isolated + BOOST_CHECK_NO_THROW(run_action("tstxscope"_n)); +} + +BOOST_FIXTURE_TEST_CASE(sec_cross_scope_find, kv_api_fresh_tester) { + // find(sec_val) in scope A must not return scope B's entry + BOOST_CHECK_NO_THROW(run_action("tstxfind"_n)); +} + +BOOST_FIXTURE_TEST_CASE(sec_cross_scope_erase, kv_api_fresh_tester) { + // Erase from scope A must not affect scope B + BOOST_CHECK_NO_THROW(run_action("tstxerase"_n)); +} + +BOOST_FIXTURE_TEST_CASE(sec_cross_scope_double, kv_api_fresh_tester) { + // double secondary: sort-preserving transform + scope isolation + BOOST_CHECK_NO_THROW(run_action("tstxdbl"_n)); +} + +BOOST_FIXTURE_TEST_CASE(sec_cross_scope_zero, kv_api_fresh_tester) { + // scope=0 is the minimum scope prefix — verify isolation + BOOST_CHECK_NO_THROW(run_action("tstxzero"_n)); +} + +BOOST_FIXTURE_TEST_CASE(sec_cross_scope_reverse, kv_api_fresh_tester) { + // Reverse iteration (--end()) must not leak across scopes + BOOST_CHECK_NO_THROW(run_action("tstxrev"_n)); +} + +BOOST_FIXTURE_TEST_CASE(sec_cross_scope_upper_bound, kv_api_fresh_tester) { + // upper_bound in scope A stops at scope boundary + BOOST_CHECK_NO_THROW(run_action("tstxubound"_n)); +} + // ════════════════════════════════════════════════════════════════════════════ // RAM billing tests — verify correct billing on create, update, erase // ════════════════════════════════════════════════════════════════════════════ diff --git a/unittests/payer_choice_test.cpp b/unittests/payer_choice_test.cpp index 0096a7eddd..f188685878 100644 --- a/unittests/payer_choice_test.cpp +++ b/unittests/payer_choice_test.cpp @@ -88,9 +88,10 @@ BOOST_AUTO_TEST_SUITE(payer_choice_test) c.create_accounts({contract_account, alice_account, bob_account}, false, true, false, true); c.produce_block(); - // ROA: give kvtest enough for contract deployment, alice enough for data + // ROA: give kvtest enough RAM for contract deployment (WASM × 10 multiplier), + // alice enough for data storage. c.register_node_owner(bob_account, 2); - c.add_roa_policy(bob_account, contract_account, "10.0000 SYS", "10.0000 SYS", "1.0000 SYS", 0, 0); + c.add_roa_policy(bob_account, contract_account, "10.0000 SYS", "10.0000 SYS", "1.1000 SYS", 0, 0); c.add_roa_policy(c.NODE_DADDY, alice_account, "100.0000 SYS", "100.0000 SYS", "100.0000 SYS", 0, 0); c.produce_block(); diff --git a/unittests/snapshots/blocks.index b/unittests/snapshots/blocks.index index 66bf9c1fab..d64d61b475 100644 Binary files a/unittests/snapshots/blocks.index and b/unittests/snapshots/blocks.index differ diff --git a/unittests/snapshots/blocks.log b/unittests/snapshots/blocks.log index d6220273f7..adde7a3d2e 100644 Binary files a/unittests/snapshots/blocks.log and b/unittests/snapshots/blocks.log differ diff --git a/unittests/snapshots/snap_v1.bin.gz b/unittests/snapshots/snap_v1.bin.gz index 149245cb91..1f3100911a 100644 Binary files a/unittests/snapshots/snap_v1.bin.gz and b/unittests/snapshots/snap_v1.bin.gz differ diff --git a/unittests/snapshots/snap_v1.bin.json.gz b/unittests/snapshots/snap_v1.bin.json.gz index 1da5939c97..8e6989e45c 100644 Binary files a/unittests/snapshots/snap_v1.bin.json.gz and b/unittests/snapshots/snap_v1.bin.json.gz differ diff --git a/unittests/snapshots/snap_v1.json.gz b/unittests/snapshots/snap_v1.json.gz index 8eb370fcea..ebbea6c8fb 100644 Binary files a/unittests/snapshots/snap_v1.json.gz and b/unittests/snapshots/snap_v1.json.gz differ diff --git a/unittests/test-contracts/get_table_test/get_table_test.wasm b/unittests/test-contracts/get_table_test/get_table_test.wasm index 472e738c64..8f74f79954 100755 Binary files a/unittests/test-contracts/get_table_test/get_table_test.wasm and b/unittests/test-contracts/get_table_test/get_table_test.wasm differ diff --git a/unittests/test-contracts/no_auth_table/no_auth_table.wasm b/unittests/test-contracts/no_auth_table/no_auth_table.wasm index 1579d09c94..f2abbc2b4e 100755 Binary files a/unittests/test-contracts/no_auth_table/no_auth_table.wasm and b/unittests/test-contracts/no_auth_table/no_auth_table.wasm differ diff --git a/unittests/test-contracts/savanna/ibc/ibc.wasm b/unittests/test-contracts/savanna/ibc/ibc.wasm index 7a04db6c25..ef9d3bc8c6 100755 Binary files a/unittests/test-contracts/savanna/ibc/ibc.wasm and b/unittests/test-contracts/savanna/ibc/ibc.wasm differ diff --git a/unittests/test-contracts/snapshot_test/snapshot_test.wasm b/unittests/test-contracts/snapshot_test/snapshot_test.wasm index f165c58bf6..6f624e4aab 100755 Binary files a/unittests/test-contracts/snapshot_test/snapshot_test.wasm and b/unittests/test-contracts/snapshot_test/snapshot_test.wasm differ diff --git a/unittests/test-contracts/test_api/test_api.wasm b/unittests/test-contracts/test_api/test_api.wasm index ed04136dae..a705ccb192 100755 Binary files a/unittests/test-contracts/test_api/test_api.wasm and b/unittests/test-contracts/test_api/test_api.wasm differ diff --git a/unittests/test-contracts/test_kv_api/test_kv_api.abi b/unittests/test-contracts/test_kv_api/test_kv_api.abi index 6cce7c6c45..8d90a22bed 100644 --- a/unittests/test-contracts/test_kv_api/test_kv_api.abi +++ b/unittests/test-contracts/test_kv_api/test_kv_api.abi @@ -3,6 +3,20 @@ "version": "sysio::abi/1.2", "types": [], "structs": [ + { + "name": "dbl_row", + "base": "", + "fields": [ + { + "name": "pk", + "type": "uint64" + }, + { + "name": "val", + "type": "float64" + } + ] + }, { "name": "kvt_row", "base": "", @@ -556,6 +570,41 @@ "name": "tstwriteperm", "base": "", "fields": [] + }, + { + "name": "tstxdbl", + "base": "", + "fields": [] + }, + { + "name": "tstxerase", + "base": "", + "fields": [] + }, + { + "name": "tstxfind", + "base": "", + "fields": [] + }, + { + "name": "tstxrev", + "base": "", + "fields": [] + }, + { + "name": "tstxscope", + "base": "", + "fields": [] + }, + { + "name": "tstxubound", + "base": "", + "fields": [] + }, + { + "name": "tstxzero", + "base": "", + "fields": [] } ], "actions": [ @@ -1028,9 +1077,51 @@ "name": "tstwriteperm", "type": "tstwriteperm", "ricardian_contract": "" + }, + { + "name": "tstxdbl", + "type": "tstxdbl", + "ricardian_contract": "" + }, + { + "name": "tstxerase", + "type": "tstxerase", + "ricardian_contract": "" + }, + { + "name": "tstxfind", + "type": "tstxfind", + "ricardian_contract": "" + }, + { + "name": "tstxrev", + "type": "tstxrev", + "ricardian_contract": "" + }, + { + "name": "tstxscope", + "type": "tstxscope", + "ricardian_contract": "" + }, + { + "name": "tstxubound", + "type": "tstxubound", + "ricardian_contract": "" + }, + { + "name": "tstxzero", + "type": "tstxzero", + "ricardian_contract": "" } ], "tables": [ + { + "name": "dbltbl", + "type": "dbl_row", + "index_type": "i64", + "key_names": ["table_name","scope","primary_key"], + "key_types": ["name","name","uint64"] + }, { "name": "kvttable", "type": "kvt_row", diff --git a/unittests/test-contracts/test_kv_api/test_kv_api.cpp b/unittests/test-contracts/test_kv_api/test_kv_api.cpp index 72e1bf50c0..de2199cd07 100644 --- a/unittests/test-contracts/test_kv_api/test_kv_api.cpp +++ b/unittests/test-contracts/test_kv_api/test_kv_api.cpp @@ -2238,4 +2238,199 @@ class [[sysio::contract("test_kv_api")]] test_kv_api : public contract { kv_idx_store(payer.value, "idxpayer"_n.value, 0, pk, sizeof(pk), sec_key, sizeof(sec_key)); } + + // ═══════════════════════════════════════════════════════════════════════════ + // Cross-scope secondary index isolation tests + // Verify that secondary index iteration in one scope does not leak entries + // from another scope using the same table and overlapping primary keys. + // ═══════════════════════════════════════════════════════════════════════════ + + // ─── tstxscope: cross-scope isolation — iterate one scope, verify no leak ── + [[sysio::action]] + void tstxscope() { + sec_table ta(get_self(), "xscope.a"_n.value); + sec_table tb(get_self(), "xscope.b"_n.value); + + // Same PKs, different ages in each scope + ta.emplace(get_self(), [](sec_row& r) { r.pk = 1; r.age = 100; }); + ta.emplace(get_self(), [](sec_row& r) { r.pk = 2; r.age = 200; }); + + tb.emplace(get_self(), [](sec_row& r) { r.pk = 1; r.age = 300; }); + tb.emplace(get_self(), [](sec_row& r) { r.pk = 2; r.age = 400; }); + + // Iterate scope A by secondary — should see exactly 100, 200 + auto idxA = ta.get_index<"byage"_n>(); + auto it = idxA.begin(); + check(it != idxA.end(), "tstxscope: A begin valid"); + check(it->age == 100, "tstxscope: A first age 100"); + ++it; + check(it->age == 200, "tstxscope: A second age 200"); + ++it; + check(it == idxA.end(), "tstxscope: A end after 2"); + + // Iterate scope B — should see exactly 300, 400 + auto idxB = tb.get_index<"byage"_n>(); + auto itb = idxB.begin(); + check(itb != idxB.end(), "tstxscope: B begin valid"); + check(itb->age == 300, "tstxscope: B first age 300"); + ++itb; + check(itb->age == 400, "tstxscope: B second age 400"); + ++itb; + check(itb == idxB.end(), "tstxscope: B end after 2"); + } + + // ─── tstxfind: cross-scope find — find in A must not return B's entry ────── + [[sysio::action]] + void tstxfind() { + sec_table ta(get_self(), "xfind.a"_n.value); + sec_table tb(get_self(), "xfind.b"_n.value); + + ta.emplace(get_self(), [](sec_row& r) { r.pk = 1; r.age = 50; }); + tb.emplace(get_self(), [](sec_row& r) { r.pk = 1; r.age = 99; }); + + auto idxA = ta.get_index<"byage"_n>(); + // find(99) in scope A should miss (only scope B has age=99) + auto it = idxA.find(99); + check(it == idxA.end(), "tstxfind: find(99) in scope A should be end"); + + // find(50) in scope A should hit + auto it2 = idxA.find(50); + check(it2 != idxA.end(), "tstxfind: find(50) in scope A should exist"); + check(it2->pk == 1, "tstxfind: found pk should be 1"); + } + + // ─── tstxerase: erase from scope A must not affect scope B ───────────────── + [[sysio::action]] + void tstxerase() { + sec_table ta(get_self(), "xerase.a"_n.value); + sec_table tb(get_self(), "xerase.b"_n.value); + + ta.emplace(get_self(), [](sec_row& r) { r.pk = 1; r.age = 77; }); + tb.emplace(get_self(), [](sec_row& r) { r.pk = 1; r.age = 77; }); + + // Erase from scope A + auto idxA = ta.get_index<"byage"_n>(); + auto it = idxA.find(77); + check(it != idxA.end(), "tstxerase: A find(77) should exist"); + idxA.erase(it); + + // Scope A should be empty + check(idxA.begin() == idxA.end(), "tstxerase: A should be empty"); + + // Scope B should still have the entry + auto idxB = tb.get_index<"byage"_n>(); + auto itb = idxB.find(77); + check(itb != idxB.end(), "tstxerase: B find(77) should survive"); + check(itb->pk == 1, "tstxerase: B pk should be 1"); + } + + // ─── tstxdbl: cross-scope isolation with double secondary ────────────────── + struct [[sysio::table]] dbl_row { + uint64_t pk; + double val; + uint64_t primary_key() const { return pk; } + double by_val() const { return val; } + SYSLIB_SERIALIZE(dbl_row, (pk)(val)) + }; + using dbl_table = sysio::multi_index<"dbltbl"_n, dbl_row, + sysio::indexed_by<"byval"_n, sysio::const_mem_fun> + >; + + [[sysio::action]] + void tstxdbl() { + dbl_table ta(get_self(), "xdbl.a"_n.value); + dbl_table tb(get_self(), "xdbl.b"_n.value); + + ta.emplace(get_self(), [](dbl_row& r) { r.pk = 1; r.val = 1.5; }); + ta.emplace(get_self(), [](dbl_row& r) { r.pk = 2; r.val = 3.14; }); + tb.emplace(get_self(), [](dbl_row& r) { r.pk = 1; r.val = 2.71; }); + tb.emplace(get_self(), [](dbl_row& r) { r.pk = 2; r.val = -0.5; }); + + // Iterate scope A by secondary — should see 1.5, 3.14 (sorted) + auto idxA = ta.get_index<"byval"_n>(); + auto it = idxA.begin(); + check(it != idxA.end(), "tstxdbl: A begin valid"); + check(it->val == 1.5, "tstxdbl: A first 1.5"); + ++it; + check(it->val == 3.14, "tstxdbl: A second 3.14"); + ++it; + check(it == idxA.end(), "tstxdbl: A end after 2"); + + // Iterate scope B — should see -0.5, 2.71 (sorted) + auto idxB = tb.get_index<"byval"_n>(); + auto itb = idxB.begin(); + check(itb != idxB.end(), "tstxdbl: B begin valid"); + check(itb->val == -0.5, "tstxdbl: B first -0.5"); + ++itb; + check(itb->val == 2.71, "tstxdbl: B second 2.71"); + ++itb; + check(itb == idxB.end(), "tstxdbl: B end after 2"); + + // Reverse iterate scope A — should see 3.14, 1.5 + auto rit = idxA.rbegin(); + check(rit != idxA.rend(), "tstxdbl: A rbegin valid"); + check(rit->val == 3.14, "tstxdbl: A rbegin 3.14"); + ++rit; + check(rit->val == 1.5, "tstxdbl: A rnext 1.5"); + ++rit; + check(rit == idxA.rend(), "tstxdbl: A rend after 2"); + } + + // ─── tstxzero: cross-scope isolation with scope=0 (minimum prefix) ──────── + [[sysio::action]] + void tstxzero() { + // scope 0 encodes as [0x00 x 8] prefix — lexicographic minimum + sec_table t0(get_self(), 0); + sec_table t1(get_self(), 1); + + t0.emplace(get_self(), [](sec_row& r) { r.pk = 1; r.age = 10; }); + t1.emplace(get_self(), [](sec_row& r) { r.pk = 1; r.age = 20; }); + + auto idx0 = t0.get_index<"byage"_n>(); + auto it = idx0.begin(); + check(it != idx0.end(), "tstxzero: scope 0 begin valid"); + check(it->age == 10, "tstxzero: scope 0 age 10"); + ++it; + check(it == idx0.end(), "tstxzero: scope 0 only 1 entry"); + } + + // ─── tstxrev: reverse iteration must not leak across scopes ─────────────── + [[sysio::action]] + void tstxrev() { + sec_table ta(get_self(), "xrev.a"_n.value); + sec_table tb(get_self(), "xrev.b"_n.value); + + ta.emplace(get_self(), [](sec_row& r) { r.pk = 1; r.age = 10; }); + ta.emplace(get_self(), [](sec_row& r) { r.pk = 2; r.age = 20; }); + tb.emplace(get_self(), [](sec_row& r) { r.pk = 1; r.age = 30; }); + + // Reverse iterate scope A — should see 20, 10 only + auto idxA = ta.get_index<"byage"_n>(); + auto rit = idxA.rbegin(); + check(rit != idxA.rend(), "tstxrev: A rbegin valid"); + check(rit->age == 20, "tstxrev: A rbegin age 20"); + ++rit; + check(rit->age == 10, "tstxrev: A rnext age 10"); + ++rit; + check(rit == idxA.rend(), "tstxrev: A rend after 2"); + } + + // ─── tstxubound: upper_bound in scope A stops at scope boundary ──────────── + [[sysio::action]] + void tstxubound() { + sec_table ta(get_self(), "xubound.a"_n.value); + sec_table tb(get_self(), "xubound.b"_n.value); + + ta.emplace(get_self(), [](sec_row& r) { r.pk = 1; r.age = 10; }); + ta.emplace(get_self(), [](sec_row& r) { r.pk = 2; r.age = 20; }); + tb.emplace(get_self(), [](sec_row& r) { r.pk = 1; r.age = 15; }); + + auto idxA = ta.get_index<"byage"_n>(); + // upper_bound(10) in scope A should land on age=20, not on B's age=15 + auto it = idxA.upper_bound(10); + check(it != idxA.end(), "tstxubound: upper_bound(10) should exist"); + check(it->age == 20, "tstxubound: should be age 20, not 15 from scope B"); + ++it; + check(it == idxA.end(), "tstxubound: end after upper_bound advance"); + } }; diff --git a/unittests/test-contracts/test_kv_api/test_kv_api.wasm b/unittests/test-contracts/test_kv_api/test_kv_api.wasm index 693836ce1f..2bc0b20583 100755 Binary files a/unittests/test-contracts/test_kv_api/test_kv_api.wasm and b/unittests/test-contracts/test_kv_api/test_kv_api.wasm differ