diff --git a/unittests/kv_api_tests.cpp b/unittests/kv_api_tests.cpp index 01fa7b2986..2f93a4b812 100644 --- a/unittests/kv_api_tests.cpp +++ b/unittests/kv_api_tests.cpp @@ -224,6 +224,32 @@ BOOST_FIXTURE_TEST_CASE(large_value_1024, kv_api_tester) { BOOST_CHECK_NO_THROW(run_action("testmaxval"_n)); } +// Key/value boundary tests on UPDATE (create tests above) + +BOOST_FIXTURE_TEST_CASE(max_key_256_update, kv_api_tester) { + BOOST_CHECK_NO_THROW(run_action("tstmaxkeyupd"_n)); +} + +BOOST_FIXTURE_TEST_CASE(oversize_key_257_update, kv_api_tester) { + BOOST_CHECK_THROW(run_action("tstovrkyupd"_n), kv_key_too_large); +} + +BOOST_FIXTURE_TEST_CASE(max_value_262144_create, kv_api_tester) { + BOOST_CHECK_NO_THROW(run_action("tstmaxvalcr"_n)); +} + +BOOST_FIXTURE_TEST_CASE(oversize_value_262145_create, kv_api_tester) { + BOOST_CHECK_THROW(run_action("tstovrvalcr"_n), kv_value_too_large); +} + +BOOST_FIXTURE_TEST_CASE(max_value_262144_update, kv_api_tester) { + BOOST_CHECK_NO_THROW(run_action("tstmaxvalupd"_n)); +} + +BOOST_FIXTURE_TEST_CASE(oversize_value_262145_update, kv_api_tester) { + BOOST_CHECK_THROW(run_action("tstovrvalupd"_n), kv_value_too_large); +} + // ─── Value operations ────────────────────────────────────────────────────── BOOST_FIXTURE_TEST_CASE(partial_read, kv_api_tester) { @@ -528,7 +554,11 @@ struct kv_billing_tester : validating_tester { } int64_t get_ram_usage() { - return control->get_resource_limits_manager().get_account_ram_usage(test_account); + return get_ram_usage(test_account); + } + + int64_t get_ram_usage(name acct) { + return control->get_resource_limits_manager().get_account_ram_usage(acct); } void ram_store(uint32_t key_id, uint32_t val_size) { @@ -552,9 +582,9 @@ struct kv_billing_tester : validating_tester { BOOST_FIXTURE_TEST_CASE(billing_create_row, kv_billing_tester) { // Creating a row should bill key_size + value_size + KV_OVERHEAD - auto before = get_ram_usage(); + auto before = get_ram_usage(test_account); ram_store(1, 100); // 4-byte key, 100-byte value - auto after = get_ram_usage(); + auto after = get_ram_usage(test_account); int64_t expected = 4 + 100 + KV_OVERHEAD; // key + value + overhead int64_t actual = after - before; @@ -565,9 +595,9 @@ BOOST_FIXTURE_TEST_CASE(billing_create_row, kv_billing_tester) { BOOST_FIXTURE_TEST_CASE(billing_update_grow, kv_billing_tester) { // Growing a value should bill the delta ram_store(2, 50); - auto before = get_ram_usage(); + auto before = get_ram_usage(test_account); ram_update(2, 200); // grow from 50 to 200 - auto after = get_ram_usage(); + auto after = get_ram_usage(test_account); int64_t expected = 200 - 50; // only value delta, key+overhead unchanged int64_t actual = after - before; @@ -578,9 +608,9 @@ BOOST_FIXTURE_TEST_CASE(billing_update_grow, kv_billing_tester) { BOOST_FIXTURE_TEST_CASE(billing_update_shrink, kv_billing_tester) { // Shrinking a value should refund the delta ram_store(3, 200); - auto before = get_ram_usage(); + auto before = get_ram_usage(test_account); ram_update(3, 50); // shrink from 200 to 50 - auto after = get_ram_usage(); + auto after = get_ram_usage(test_account); int64_t expected = 50 - 200; // negative delta = refund int64_t actual = after - before; @@ -591,9 +621,9 @@ BOOST_FIXTURE_TEST_CASE(billing_update_shrink, kv_billing_tester) { BOOST_FIXTURE_TEST_CASE(billing_update_same_size, kv_billing_tester) { // Updating with same size should bill zero ram_store(4, 100); - auto before = get_ram_usage(); + auto before = get_ram_usage(test_account); ram_update(4, 100); // same size - auto after = get_ram_usage(); + auto after = get_ram_usage(test_account); BOOST_TEST_MESSAGE("billing_update_same_size: delta=" << (after - before)); BOOST_REQUIRE_EQUAL(after - before, 0); @@ -602,9 +632,9 @@ BOOST_FIXTURE_TEST_CASE(billing_update_same_size, kv_billing_tester) { BOOST_FIXTURE_TEST_CASE(billing_erase_refund, kv_billing_tester) { // Erasing should refund the full amount billed on create ram_store(5, 100); - auto before = get_ram_usage(); + auto before = get_ram_usage(test_account); ram_erase(5); - auto after = get_ram_usage(); + auto after = get_ram_usage(test_account); int64_t expected = -(4 + 100 + KV_OVERHEAD); // full refund int64_t actual = after - before; @@ -614,10 +644,10 @@ BOOST_FIXTURE_TEST_CASE(billing_erase_refund, kv_billing_tester) { BOOST_FIXTURE_TEST_CASE(billing_create_erase_net_zero, kv_billing_tester) { // Create + erase should result in net zero RAM change - auto before = get_ram_usage(); + auto before = get_ram_usage(test_account); ram_store(6, 500); ram_erase(6); - auto after = get_ram_usage(); + auto after = get_ram_usage(test_account); BOOST_TEST_MESSAGE("billing_create_erase_net_zero: delta=" << (after - before)); BOOST_REQUIRE_EQUAL(after - before, 0); @@ -625,11 +655,11 @@ BOOST_FIXTURE_TEST_CASE(billing_create_erase_net_zero, kv_billing_tester) { BOOST_FIXTURE_TEST_CASE(billing_multiple_rows, kv_billing_tester) { // Multiple rows should each be billed independently - auto before = get_ram_usage(); + auto before = get_ram_usage(test_account); ram_store(10, 100); ram_store(11, 200); ram_store(12, 300); - auto after = get_ram_usage(); + auto after = get_ram_usage(test_account); int64_t expected = 3 * (4 + KV_OVERHEAD) + 100 + 200 + 300; // 3 rows, different values int64_t actual = after - before; @@ -683,4 +713,554 @@ BOOST_FIXTURE_TEST_CASE(billing_idx_update_shrink, kv_billing_tester) { BOOST_REQUIRE_EQUAL(actual, expected); } +// ════════════════════════════════════════════════════════════════════════════ +// Payer change billing tests — verify correct billing when payer changes +// ════════════════════════════════════════════════════════════════════════════ + +static constexpr int64_t KV_IDX_OVERHEAD = config::billable_size_v; + +struct kv_payer_billing_tester : validating_tester { + kv_payer_billing_tester() { + create_accounts({test_account, "alice"_n, "bob"_n}); + produce_block(); + set_code(test_account, test_contracts::test_kv_api_wasm()); + set_abi(test_account, test_contracts::test_kv_api_abi().c_str()); + produce_block(); + } + + int64_t get_ram_usage(name acct) { + return control->get_resource_limits_manager().get_account_ram_usage(acct); + } + + // Push rampyrset with proper sysio.payer authorization for cross-account billing. + // When authorize_payer=true and payer is a third party, the payer's sysio.payer + // permission must be first in the auth list (enforced by authorization_manager). + void ram_pyr_set(uint32_t key_id, uint32_t val_size, name payer, + bool authorize_payer = true) { + vector auths; + if (payer != name{0} && payer != test_account && authorize_payer) { + auths.push_back({payer, config::sysio_payer_name}); + auths.push_back({payer, config::active_name}); + } else { + auths.push_back({test_account, config::active_name}); + } + push_action(test_account, "rampyrset"_n, auths, + mutable_variant_object()("key_id", key_id)("val_size", val_size)("payer", payer)); + produce_block(); + } + + // Self-pay store (payer = receiver) + void ram_store(uint32_t key_id, uint32_t val_size) { + push_action(test_account, "ramstore"_n, test_account, + mutable_variant_object()("key_id", key_id)("val_size", val_size)); + produce_block(); + } + + void ram_erase(uint32_t key_id) { + push_action(test_account, "ramerase"_n, test_account, + mutable_variant_object()("key_id", key_id)); + produce_block(); + } +}; + +// Payer change, same value size — old payer fully refunded, new payer charged +BOOST_FIXTURE_TEST_CASE(payer_change_same_size, kv_payer_billing_tester) { + // Create row with payer=alice + const uint32_t VAL_SIZE = 100; + int64_t expected_billable = 4 + VAL_SIZE + KV_OVERHEAD; + + auto alice_before = get_ram_usage("alice"_n); + ram_pyr_set(1, VAL_SIZE, "alice"_n); + auto alice_after_create = get_ram_usage("alice"_n); + BOOST_REQUIRE_EQUAL(alice_after_create - alice_before, expected_billable); + + // Update same key, change payer to bob, same size + auto bob_before = get_ram_usage("bob"_n); + alice_before = get_ram_usage("alice"_n); + ram_pyr_set(1, VAL_SIZE, "bob"_n); + auto alice_after = get_ram_usage("alice"_n); + auto bob_after = get_ram_usage("bob"_n); + + // alice fully refunded, bob charged same amount + BOOST_REQUIRE_EQUAL(alice_after - alice_before, -expected_billable); + BOOST_REQUIRE_EQUAL(bob_after - bob_before, expected_billable); +} + +// Payer change with value growth — old payer refunded old amount, new payer charged new amount +BOOST_FIXTURE_TEST_CASE(payer_change_with_growth, kv_payer_billing_tester) { + const uint32_t SMALL = 50, LARGE = 200; + int64_t small_billable = 4 + SMALL + KV_OVERHEAD; + int64_t large_billable = 4 + LARGE + KV_OVERHEAD; + + ram_pyr_set(1, SMALL, "alice"_n); + + auto alice_before = get_ram_usage("alice"_n); + auto bob_before = get_ram_usage("bob"_n); + ram_pyr_set(1, LARGE, "bob"_n); + auto alice_after = get_ram_usage("alice"_n); + auto bob_after = get_ram_usage("bob"_n); + + // alice refunded full old amount, bob charged full new (larger) amount + BOOST_REQUIRE_EQUAL(alice_after - alice_before, -small_billable); + BOOST_REQUIRE_EQUAL(bob_after - bob_before, large_billable); +} + +// Payer change with value shrink — old payer refunded old amount, new payer charged smaller amount +BOOST_FIXTURE_TEST_CASE(payer_change_with_shrink, kv_payer_billing_tester) { + const uint32_t LARGE = 200, SMALL = 50; + int64_t large_billable = 4 + LARGE + KV_OVERHEAD; + int64_t small_billable = 4 + SMALL + KV_OVERHEAD; + + ram_pyr_set(1, LARGE, "alice"_n); + + auto alice_before = get_ram_usage("alice"_n); + auto bob_before = get_ram_usage("bob"_n); + ram_pyr_set(1, SMALL, "bob"_n); + auto alice_after = get_ram_usage("alice"_n); + auto bob_after = get_ram_usage("bob"_n); + + // alice refunded full old amount, bob charged full new (smaller) amount + BOOST_REQUIRE_EQUAL(alice_after - alice_before, -large_billable); + BOOST_REQUIRE_EQUAL(bob_after - bob_before, small_billable); +} + +// Payer change back to self (payer=0 means receiver) +BOOST_FIXTURE_TEST_CASE(payer_change_back_to_self, kv_payer_billing_tester) { + const uint32_t VAL_SIZE = 100; + int64_t expected_billable = 4 + VAL_SIZE + KV_OVERHEAD; + + // Create with payer=alice + ram_pyr_set(1, VAL_SIZE, "alice"_n); + + // Change payer back to receiver (payer=0 means receiver=test_account) + // Only test_account needs to sign since alice's delta is negative + auto alice_before = get_ram_usage("alice"_n); + auto contract_before = get_ram_usage(test_account); + ram_pyr_set(1, VAL_SIZE, name{0}); + auto alice_after = get_ram_usage("alice"_n); + auto contract_after = get_ram_usage(test_account); + + BOOST_REQUIRE_EQUAL(alice_after - alice_before, -expected_billable); + BOOST_REQUIRE_EQUAL(contract_after - contract_before, expected_billable); +} + +// Erase refunds the stored payer (not the contract/receiver) +BOOST_FIXTURE_TEST_CASE(erase_refunds_correct_payer, kv_payer_billing_tester) { + const uint32_t VAL_SIZE = 100; + int64_t expected_billable = 4 + VAL_SIZE + KV_OVERHEAD; + + // Create with payer=alice + ram_pyr_set(1, VAL_SIZE, "alice"_n); + + // Erase — alice should get the refund, not the contract + auto alice_before = get_ram_usage("alice"_n); + auto contract_before = get_ram_usage(test_account); + ram_erase(1); + auto alice_after = get_ram_usage("alice"_n); + auto contract_after = get_ram_usage(test_account); + + BOOST_REQUIRE_EQUAL(alice_after - alice_before, -expected_billable); + BOOST_REQUIRE_EQUAL(contract_after - contract_before, 0); +} + +// Payer change without new payer authorization — should fail. +// Alice is not in the auth list at all, so the unprivileged has_authorization +// check (Path 1 in validate_account_ram_deltas) fires first -> +// unauthorized_ram_usage_increase. +BOOST_FIXTURE_TEST_CASE(payer_change_unauth_fails, kv_payer_billing_tester) { + // Create with self-pay + ram_store(1, 100); + + // Try to change payer to alice without alice signing + BOOST_CHECK_THROW( + ram_pyr_set(1, 100, "alice"_n, false), + unauthorized_ram_usage_increase + ); +} + +// Payer signed with active but NOT sysio.payer — should fail. +// Alice is in the auth list (passes Path 1 has_authorization), but without +// the sysio.payer permission role Path 2 rejects -> unsatisfied_authorization. +BOOST_FIXTURE_TEST_CASE(payer_change_active_only_fails, kv_payer_billing_tester) { + ram_store(1, 100); + + // Alice signs with active only — no sysio.payer permission + BOOST_CHECK_THROW( + push_action(test_account, "rampyrset"_n, + vector{{"alice"_n, config::active_name}}, + mutable_variant_object()("key_id", 1)("val_size", 100)("payer", "alice")), + unsatisfied_authorization + ); +} + +// Payer change — old payer not authorized, but succeeds +// because old payer's delta is negative (refund doesn't need auth) +BOOST_FIXTURE_TEST_CASE(payer_change_old_payer_unauth_ok, kv_payer_billing_tester) { + const uint32_t VAL_SIZE = 100; + int64_t billable = 4 + VAL_SIZE + KV_OVERHEAD; + + // Create with payer=alice + ram_pyr_set(1, VAL_SIZE, "alice"_n); + + // Change payer from alice->bob. Only bob signs. + // alice is refunded (negative delta -> no auth needed). + auto alice_before = get_ram_usage("alice"_n); + auto bob_before = get_ram_usage("bob"_n); + ram_pyr_set(1, VAL_SIZE, "bob"_n); + auto alice_after = get_ram_usage("alice"_n); + auto bob_after = get_ram_usage("bob"_n); + + BOOST_REQUIRE_EQUAL(alice_after - alice_before, -billable); + BOOST_REQUIRE_EQUAL(bob_after - bob_before, billable); +} + +// Multiple actions with different payers in one transaction — independent deltas +BOOST_FIXTURE_TEST_CASE(mixed_payer_independent_deltas, kv_payer_billing_tester) { + auto alice_before = get_ram_usage("alice"_n); + auto bob_before = get_ram_usage("bob"_n); + + // Single transaction: action1 bills alice, action2 bills bob + signed_transaction trx; + trx.actions.push_back(get_action(test_account, "rampyrset"_n, + {{"alice"_n, config::sysio_payer_name}, {"alice"_n, config::active_name}}, + mutable_variant_object()("key_id", 1)("val_size", 100)("payer", "alice"))); + trx.actions.push_back(get_action(test_account, "rampyrset"_n, + {{"bob"_n, config::sysio_payer_name}, {"bob"_n, config::active_name}}, + mutable_variant_object()("key_id", 2)("val_size", 200)("payer", "bob"))); + set_transaction_headers(trx); + trx.sign(get_private_key("alice"_n, "active"), control->get_chain_id()); + trx.sign(get_private_key("bob"_n, "active"), control->get_chain_id()); + push_transaction(trx); + produce_block(); + + auto alice_after = get_ram_usage("alice"_n); + auto bob_after = get_ram_usage("bob"_n); + + BOOST_REQUIRE_EQUAL(alice_after - alice_before, 4 + 100 + KV_OVERHEAD); + BOOST_REQUIRE_EQUAL(bob_after - bob_before, 4 + 200 + KV_OVERHEAD); +} + +// Create + erase across actions in one transaction — net zero for payer +BOOST_FIXTURE_TEST_CASE(mixed_payer_create_erase_net_zero, kv_payer_billing_tester) { + auto alice_before = get_ram_usage("alice"_n); + + // Single transaction: action1 creates with payer=alice, action2 erases + signed_transaction trx; + trx.actions.push_back(get_action(test_account, "rampyrset"_n, + {{"alice"_n, config::sysio_payer_name}, {"alice"_n, config::active_name}}, + mutable_variant_object()("key_id", 1)("val_size", 100)("payer", "alice"))); + trx.actions.push_back(get_action(test_account, "ramerase"_n, + {{test_account, config::active_name}}, + mutable_variant_object()("key_id", 1))); + set_transaction_headers(trx); + trx.sign(get_private_key("alice"_n, "active"), control->get_chain_id()); + trx.sign(get_private_key(test_account, "active"), control->get_chain_id()); + push_transaction(trx); + produce_block(); + + auto alice_after = get_ram_usage("alice"_n); + BOOST_REQUIRE_EQUAL(alice_after - alice_before, 0); +} + +// ════════════════════════════════════════════════════════════════════════════ +// Secondary index billing tests +// ════════════════════════════════════════════════════════════════════════════ + +// kv_idx_store bills sec_key_size + pri_key_size + overhead +BOOST_FIXTURE_TEST_CASE(idx_billing_store, kv_billing_tester) { + auto before = get_ram_usage(test_account); + push_action(test_account, "ramidxstore"_n, test_account, + mutable_variant_object()("sec_size", 8)("pri_size", 4)); + produce_block(); + auto after = get_ram_usage(test_account); + + int64_t expected = 8 + 4 + KV_IDX_OVERHEAD; + BOOST_TEST_MESSAGE("idx_billing_store: expected=" << expected << " actual=" << (after - before)); + BOOST_REQUIRE_EQUAL(after - before, expected); +} + +// kv_idx_remove refunds the full billable amount +BOOST_FIXTURE_TEST_CASE(idx_billing_remove, kv_billing_tester) { + push_action(test_account, "ramidxstore"_n, test_account, + mutable_variant_object()("sec_size", 8)("pri_size", 4)); + produce_block(); + + auto before = get_ram_usage(test_account); + push_action(test_account, "ramidxremov"_n, test_account, + mutable_variant_object()("sec_size", 8)("pri_size", 4)); + produce_block(); + auto after = get_ram_usage(test_account); + + int64_t expected = -(8 + 4 + KV_IDX_OVERHEAD); + BOOST_TEST_MESSAGE("idx_billing_remove: expected=" << expected << " actual=" << (after - before)); + BOOST_REQUIRE_EQUAL(after - before, expected); +} + +// kv_idx_update with size change — bills only the sec key delta +BOOST_FIXTURE_TEST_CASE(idx_billing_update_size_change, kv_billing_tester) { + push_action(test_account, "ramidxstore"_n, test_account, + mutable_variant_object()("sec_size", 8)("pri_size", 4)); + produce_block(); + + auto before = get_ram_usage(test_account); + push_action(test_account, "ramidxupdat"_n, test_account, + mutable_variant_object()("old_ss", 8)("new_ss", 20)("pri_size", 4)); + produce_block(); + auto after = get_ram_usage(test_account); + + int64_t expected = 20 - 8; // sec key delta only + BOOST_TEST_MESSAGE("idx_billing_update_grow: expected=" << expected << " actual=" << (after - before)); + BOOST_REQUIRE_EQUAL(after - before, expected); +} + +// kv_idx_update same size — zero delta +BOOST_FIXTURE_TEST_CASE(idx_billing_update_same_size, kv_billing_tester) { + push_action(test_account, "ramidxstore"_n, test_account, + mutable_variant_object()("sec_size", 8)("pri_size", 4)); + produce_block(); + + auto before = get_ram_usage(test_account); + push_action(test_account, "ramidxupdat"_n, test_account, + mutable_variant_object()("old_ss", 8)("new_ss", 8)("pri_size", 4)); + produce_block(); + auto after = get_ram_usage(test_account); + + BOOST_TEST_MESSAGE("idx_billing_update_same: delta=" << (after - before)); + BOOST_REQUIRE_EQUAL(after - before, 0); +} + +// ════════════════════════════════════════════════════════════════════════════ +// Cross-account secondary index billing — payer != receiver +// ════════════════════════════════════════════════════════════════════════════ + +// Secondary index store bills the specified payer, not the contract +BOOST_FIXTURE_TEST_CASE(idx_billing_payer_not_receiver, kv_payer_billing_tester) { + auto alice_before = get_ram_usage("alice"_n); + auto contract_before = get_ram_usage(test_account); + + push_action(test_account, "ramidxstpyr"_n, + {{"alice"_n, config::sysio_payer_name}, {"alice"_n, config::active_name}}, + mutable_variant_object()("payer", "alice")("sec_size", 8)("pri_size", 4)); + produce_block(); + + auto alice_after = get_ram_usage("alice"_n); + auto contract_after = get_ram_usage(test_account); + + int64_t expected = 8 + 4 + KV_IDX_OVERHEAD; + BOOST_REQUIRE_EQUAL(alice_after - alice_before, expected); + BOOST_REQUIRE_EQUAL(contract_after - contract_before, 0); +} + +// Secondary index remove refunds the stored payer, not the contract +BOOST_FIXTURE_TEST_CASE(idx_remove_refunds_payer, kv_payer_billing_tester) { + // Store with alice as payer + push_action(test_account, "ramidxstpyr"_n, + {{"alice"_n, config::sysio_payer_name}, {"alice"_n, config::active_name}}, + mutable_variant_object()("payer", "alice")("sec_size", 8)("pri_size", 4)); + produce_block(); + + // Remove — should refund alice, not the contract + auto alice_before = get_ram_usage("alice"_n); + auto contract_before = get_ram_usage(test_account); + push_action(test_account, "ramidxremov"_n, test_account, + mutable_variant_object()("sec_size", 8)("pri_size", 4)); + produce_block(); + + auto alice_after = get_ram_usage("alice"_n); + auto contract_after = get_ram_usage(test_account); + + int64_t expected = -(8 + 4 + KV_IDX_OVERHEAD); + BOOST_REQUIRE_EQUAL(alice_after - alice_before, expected); + BOOST_REQUIRE_EQUAL(contract_after - contract_before, 0); +} + +// Secondary index update with payer change +BOOST_FIXTURE_TEST_CASE(idx_update_payer_change, kv_payer_billing_tester) { + // Store with alice as payer + push_action(test_account, "ramidxstpyr"_n, + {{"alice"_n, config::sysio_payer_name}, {"alice"_n, config::active_name}}, + mutable_variant_object()("payer", "alice")("sec_size", 8)("pri_size", 4)); + produce_block(); + + int64_t old_billable = 8 + 4 + KV_IDX_OVERHEAD; + int64_t new_billable = 12 + 4 + KV_IDX_OVERHEAD; + + // Update, change payer from alice to bob + auto alice_before = get_ram_usage("alice"_n); + auto bob_before = get_ram_usage("bob"_n); + push_action(test_account, "ramidxuppyr"_n, + {{"bob"_n, config::sysio_payer_name}, {"bob"_n, config::active_name}}, + mutable_variant_object()("payer", "bob")("old_ss", 8)("new_ss", 12)("pri_size", 4)); + produce_block(); + + auto alice_after = get_ram_usage("alice"_n); + auto bob_after = get_ram_usage("bob"_n); + + // alice fully refunded, bob charged new amount + BOOST_REQUIRE_EQUAL(alice_after - alice_before, -old_billable); + BOOST_REQUIRE_EQUAL(bob_after - bob_before, new_billable); +} + +// ════════════════════════════════════════════════════════════════════════════ +// Read-only transaction rejection tests +// ════════════════════════════════════════════════════════════════════════════ + +BOOST_FIXTURE_TEST_CASE(read_only_rejects_kv_erase, kv_api_tester) { + signed_transaction trx; + trx.actions.emplace_back( + vector{}, test_account, "tstrdoerase"_n, bytes{} + ); + t.set_transaction_headers(trx); + BOOST_CHECK_THROW( + t.push_transaction(trx, fc::time_point::maximum(), kv_shared_tester::DEFAULT_BILLED_CPU_TIME_US, false, + transaction_metadata::trx_type::read_only), + table_operation_not_permitted); +} + +BOOST_FIXTURE_TEST_CASE(read_only_rejects_kv_idx_store, kv_api_tester) { + signed_transaction trx; + trx.actions.emplace_back( + vector{}, test_account, "tstrdoidxst"_n, bytes{} + ); + t.set_transaction_headers(trx); + BOOST_CHECK_THROW( + t.push_transaction(trx, fc::time_point::maximum(), kv_shared_tester::DEFAULT_BILLED_CPU_TIME_US, false, + transaction_metadata::trx_type::read_only), + table_operation_not_permitted); +} + +BOOST_FIXTURE_TEST_CASE(read_only_rejects_kv_idx_remove, kv_api_tester) { + signed_transaction trx; + trx.actions.emplace_back( + vector{}, test_account, "tstrdoidxrm"_n, bytes{} + ); + t.set_transaction_headers(trx); + BOOST_CHECK_THROW( + t.push_transaction(trx, fc::time_point::maximum(), kv_shared_tester::DEFAULT_BILLED_CPU_TIME_US, false, + transaction_metadata::trx_type::read_only), + table_operation_not_permitted); +} + +BOOST_FIXTURE_TEST_CASE(read_only_rejects_kv_idx_update, kv_api_tester) { + signed_transaction trx; + trx.actions.emplace_back( + vector{}, test_account, "tstrdoidxup"_n, bytes{} + ); + t.set_transaction_headers(trx); + BOOST_CHECK_THROW( + t.push_transaction(trx, fc::time_point::maximum(), kv_shared_tester::DEFAULT_BILLED_CPU_TIME_US, false, + transaction_metadata::trx_type::read_only), + table_operation_not_permitted); +} + +// ════════════════════════════════════════════════════════════════════════════ +// Notification context billing tests +// ════════════════════════════════════════════════════════════════════════════ + +static const name notify_account = "kvnotify"_n; + +struct kv_notify_billing_tester : validating_tester { + kv_notify_billing_tester() { + create_accounts({test_account, notify_account, "alice"_n}); + produce_block(); + set_code(test_account, test_contracts::test_kv_api_wasm()); + set_abi(test_account, test_contracts::test_kv_api_abi().c_str()); + set_code(notify_account, test_contracts::test_kv_api_wasm()); + set_abi(notify_account, test_contracts::test_kv_api_abi().c_str()); + produce_block(); + } + + int64_t get_ram_usage(name acct) { + return control->get_resource_limits_manager().get_account_ram_usage(acct); + } +}; + +// Self-pay kv_set in notification context — RAM billed to notified receiver +BOOST_FIXTURE_TEST_CASE(notify_self_pay_bills_receiver, kv_notify_billing_tester) { + auto notify_before = get_ram_usage(notify_account); + auto sender_before = get_ram_usage(test_account); + + push_action(test_account, "ramnotify"_n, test_account, + mutable_variant_object()("key_id", 1)("val_size", 100)); + produce_block(); + + auto notify_after = get_ram_usage(notify_account); + auto sender_after = get_ram_usage(test_account); + + // RAM charged to notified receiver (kvnotify), not sender (kvtest) + int64_t expected = 4 + 100 + KV_OVERHEAD; + BOOST_REQUIRE_EQUAL(notify_after - notify_before, expected); + BOOST_REQUIRE_EQUAL(sender_after - sender_before, 0); +} + +// Third-party payer in notification context — always fails. +// Any cross-account RAM increase is blocked in notification context +// regardless of sysio.payer authorization. +BOOST_FIXTURE_TEST_CASE(notify_third_party_payer_fails, kv_notify_billing_tester) { + BOOST_CHECK_THROW( + push_action(test_account, "ramnotiferr"_n, + {{"alice"_n, config::sysio_payer_name}, {"alice"_n, config::active_name}}, + mutable_variant_object()("key_id", 1)("val_size", 100)("payer", "alice")), + unauthorized_ram_usage_increase + ); +} + +// ════════════════════════════════════════════════════════════════════════════ +// Cross-contract secondary index reads +// ════════════════════════════════════════════════════════════════════════════ + +// Contract B reads contract A's secondary index and primary row +BOOST_FIXTURE_TEST_CASE(cross_contract_idx_read, kv_notify_billing_tester) { + // Setup: kvnotify stores a row + secondary index entry + push_action(notify_account, "xcsetup"_n, notify_account, mutable_variant_object()); + produce_block(); + + // kvtest reads kvnotify's secondary index and primary row + BOOST_CHECK_NO_THROW( + push_action(test_account, "xcidxread"_n, test_account, + mutable_variant_object()("other", notify_account)) + ); +} + +// ════════════════════════════════════════════════════════════════════════════ +// Iterator invalidation under mutation +// ════════════════════════════════════════════════════════════════════════════ + +// Primary: update value while iterator points to row +BOOST_FIXTURE_TEST_CASE(iter_mutation_update_value, kv_api_tester) { + BOOST_CHECK_NO_THROW(run_action("tstmutupdval"_n)); +} + +// Primary: erase current row then next() advances to next valid +BOOST_FIXTURE_TEST_CASE(iter_mutation_erase_next, kv_api_tester) { + BOOST_CHECK_NO_THROW(run_action("tsterasenext"_n)); +} + +// Primary: erase all rows during iteration -> end +BOOST_FIXTURE_TEST_CASE(iter_mutation_erase_all, kv_api_tester) { + BOOST_CHECK_NO_THROW(run_action("tsteraseall"_n)); +} + +// Secondary: remove entry under sec iterator -> next advances +BOOST_FIXTURE_TEST_CASE(idx_iter_mutation_erase, kv_api_tester) { + BOOST_CHECK_NO_THROW(run_action("tstidxerase"_n)); +} + +// Secondary: update key under sec iterator -> old position invalid +BOOST_FIXTURE_TEST_CASE(idx_iter_mutation_update, kv_api_tester) { + BOOST_CHECK_NO_THROW(run_action("tstidxmutupd"_n)); +} + +// Secondary: insert during iteration -> new entry visible +BOOST_FIXTURE_TEST_CASE(idx_iter_mutation_insert, kv_api_tester) { + BOOST_CHECK_NO_THROW(run_action("tstidxinsert"_n)); +} + +// Fork/undo coverage note: +// All KV test fixtures use validating_tester, which replays every block on a +// second chain and compares integrity hashes. This implicitly verifies that +// kv_object and kv_index_object chainbase undo sessions work correctly — +// any mismatch would cause test failure. Savanna consensus does not support +// pop_block, so explicit undo testing is handled via the validating replay. + BOOST_AUTO_TEST_SUITE_END() diff --git a/unittests/payer_choice_test.cpp b/unittests/payer_choice_test.cpp index f188685878..78237c5406 100644 --- a/unittests/payer_choice_test.cpp +++ b/unittests/payer_choice_test.cpp @@ -91,7 +91,7 @@ BOOST_AUTO_TEST_SUITE(payer_choice_test) // 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.1000 SYS", 0, 0); + c.add_roa_policy(bob_account, contract_account, "10.0000 SYS", "10.0000 SYS", "1.2000 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/protocol_feature_tests.cpp b/unittests/protocol_feature_tests.cpp index ecb3f19758..000699b848 100644 --- a/unittests/protocol_feature_tests.cpp +++ b/unittests/protocol_feature_tests.cpp @@ -1010,6 +1010,95 @@ BOOST_AUTO_TEST_CASE( ram_restrictions_with_roa_test ) { try { } FC_LOG_AND_RETHROW() } +// ROA limit enforcement on KV storage operations. +// All multi_index operations route through kv_set/kv_idx_store internally, +// so this tests the KV billing path hitting the ROA RAM ceiling. +BOOST_AUTO_TEST_CASE( kv_roa_limit_enforcement ) { try { + tester c( setup_policy::full ); + c.produce_block(); + + const auto& tester1_account = account_name("tester1"); + const auto& alice_account = account_name("alice"); + + c.create_accounts( {tester1_account, alice_account}, false, true, false ); + c.add_roa_policy(c.NODE_DADDY, tester1_account, "1.0000 SYS", "1.0000 SYS", "0.0986 SYS", 0, 0); + // Give alice a small RAM quota — enough for small data but not large + c.add_roa_policy(c.NODE_DADDY, alice_account, "1.0000 SYS", "1.0000 SYS", "0.0100 SYS", 0, 0); + c.produce_block(); + + c.set_code( tester1_account, test_contracts::ram_restrictions_test_wasm() ); + c.set_abi( tester1_account, test_contracts::ram_restrictions_test_abi() ); + c.produce_block(); + + // Small store works (within quota) + auto alice_payer = vector{ + {alice_account, config::sysio_payer_name}, {alice_account, config::active_name}}; + c.push_action( tester1_account, "setdata"_n, alice_payer, mutable_variant_object() + ("len1", 10)("len2", 0)("payer", alice_account) ); + c.produce_block(); + + // Large store exceeds alice's ROA RAM quota (kv_set -> update_db_usage -> ram limit) + BOOST_REQUIRE_EXCEPTION( + c.push_action( tester1_account, "setdata"_n, alice_payer, mutable_variant_object() + ("len1", 100000)("len2", 0)("payer", alice_account) ), + ram_usage_exceeded, + fc_exception_message_contains("has insufficient ram") + ); + +} FC_LOG_AND_RETHROW() } + +// Privileged sysio.* contract bypasses payer authorization. +// validate_account_ram_deltas allows sysio.* privileged contracts to bill +// any account's RAM without sysio.payer permission. +BOOST_AUTO_TEST_CASE( privileged_kv_payer_bypass ) { try { + tester c( setup_policy::full ); + c.produce_block(); + + const auto& priv_account = account_name("sysio.test"); + const auto& alice_account = account_name("alice"); + + c.create_account( priv_account, config::system_account_name, false, true, false, false ); + c.create_accounts( {alice_account}, false, true, false ); + c.add_roa_policy(c.NODE_DADDY, alice_account, "1.0000 SYS", "1.0000 SYS", "1.0000 SYS", 0, 0); + c.produce_block(); + + c.set_code( priv_account, test_contracts::ram_restrictions_test_wasm() ); + c.set_abi( priv_account, test_contracts::ram_restrictions_test_abi() ); + c.set_privileged( priv_account ); + c.produce_block(); + + // Privileged sysio.* contract billing alice WITHOUT sysio.payer auth succeeds + auto alice_ram_before = c.control->get_resource_limits_manager().get_account_ram_usage(alice_account); + c.push_action( priv_account, "setdata"_n, priv_account, mutable_variant_object() + ("len1", 10)("len2", 0)("payer", alice_account) ); + c.produce_block(); + + // alice's RAM increased even though she didn't authorize + auto alice_ram_after = c.control->get_resource_limits_manager().get_account_ram_usage(alice_account); + BOOST_REQUIRE_GT(alice_ram_after, alice_ram_before); + + // Non-sysio privileged contract still requires sysio.payer auth + const auto& priv2_account = account_name("privtest"); + c.create_accounts( {priv2_account}, false, true, false ); + c.add_roa_policy(c.NODE_DADDY, priv2_account, "1.0000 SYS", "1.0000 SYS", "0.0986 SYS", 0, 0); + c.produce_block(); + c.set_code( priv2_account, test_contracts::ram_restrictions_test_wasm() ); + c.set_abi( priv2_account, test_contracts::ram_restrictions_test_abi() ); + c.set_privileged( priv2_account ); + c.produce_block(); + + // Privileged but NOT sysio.* — skips the unprivileged has_authorization check + // (Path 1 in validate_account_ram_deltas) but still fails the sysio.payer + // permission check (Path 2) -> unsatisfied_authorization. + BOOST_REQUIRE_EXCEPTION( + c.push_action( priv2_account, "setdata"_n, priv2_account, mutable_variant_object() + ("len1", 10)("len2", 0)("payer", alice_account) ), + unsatisfied_authorization, + fc_exception_message_contains("Missing") + ); + +} FC_LOG_AND_RETHROW() } + /*** * Test KV RAM billing without ROA (unlimited RAM). * 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 135c605f80..cc6477d9b7 100644 --- a/unittests/test-contracts/test_kv_api/test_kv_api.abi +++ b/unittests/test-contracts/test_kv_api/test_kv_api.abi @@ -41,6 +41,142 @@ } ] }, + { + "name": "ramidxremov", + "base": "", + "fields": [ + { + "name": "sec_size", + "type": "uint32" + }, + { + "name": "pri_size", + "type": "uint32" + } + ] + }, + { + "name": "ramidxstore", + "base": "", + "fields": [ + { + "name": "sec_size", + "type": "uint32" + }, + { + "name": "pri_size", + "type": "uint32" + } + ] + }, + { + "name": "ramidxstpyr", + "base": "", + "fields": [ + { + "name": "payer", + "type": "name" + }, + { + "name": "sec_size", + "type": "uint32" + }, + { + "name": "pri_size", + "type": "uint32" + } + ] + }, + { + "name": "ramidxupdat", + "base": "", + "fields": [ + { + "name": "old_ss", + "type": "uint32" + }, + { + "name": "new_ss", + "type": "uint32" + }, + { + "name": "pri_size", + "type": "uint32" + } + ] + }, + { + "name": "ramidxuppyr", + "base": "", + "fields": [ + { + "name": "payer", + "type": "name" + }, + { + "name": "old_ss", + "type": "uint32" + }, + { + "name": "new_ss", + "type": "uint32" + }, + { + "name": "pri_size", + "type": "uint32" + } + ] + }, + { + "name": "ramnotiferr", + "base": "", + "fields": [ + { + "name": "key_id", + "type": "uint32" + }, + { + "name": "val_size", + "type": "uint32" + }, + { + "name": "payer", + "type": "name" + } + ] + }, + { + "name": "ramnotify", + "base": "", + "fields": [ + { + "name": "key_id", + "type": "uint32" + }, + { + "name": "val_size", + "type": "uint32" + } + ] + }, + { + "name": "rampyrset", + "base": "", + "fields": [ + { + "name": "key_id", + "type": "uint32" + }, + { + "name": "val_size", + "type": "uint32" + }, + { + "name": "payer", + "type": "name" + } + ] + }, { "name": "ramstore", "base": "", @@ -352,11 +488,21 @@ "base": "", "fields": [] }, + { + "name": "tsteraseall", + "base": "", + "fields": [] + }, { "name": "tsterasedinv", "base": "", "fields": [] }, + { + "name": "tsterasenext", + "base": "", + "fields": [] + }, { "name": "tstfindmiss", "base": "", @@ -367,6 +513,21 @@ "base": "", "fields": [] }, + { + "name": "tstidxerase", + "base": "", + "fields": [] + }, + { + "name": "tstidxinsert", + "base": "", + "fields": [] + }, + { + "name": "tstidxmutupd", + "base": "", + "fields": [] + }, { "name": "tstidxpayer", "base": "", @@ -407,21 +568,56 @@ "base": "", "fields": [] }, + { + "name": "tstmaxkeyupd", + "base": "", + "fields": [] + }, + { + "name": "tstmaxvalcr", + "base": "", + "fields": [] + }, + { + "name": "tstmaxvalupd", + "base": "", + "fields": [] + }, { "name": "tstmodify", "base": "", "fields": [] }, + { + "name": "tstmutupdval", + "base": "", + "fields": [] + }, { "name": "tstnotifyram", "base": "", "fields": [] }, + { + "name": "tstovrkyupd", + "base": "", + "fields": [] + }, { "name": "tstovrszkey", "base": "", "fields": [] }, + { + "name": "tstovrvalcr", + "base": "", + "fields": [] + }, + { + "name": "tstovrvalupd", + "base": "", + "fields": [] + }, { "name": "tstpayeroth", "base": "", @@ -452,6 +648,26 @@ "base": "", "fields": [] }, + { + "name": "tstrdoerase", + "base": "", + "fields": [] + }, + { + "name": "tstrdoidxrm", + "base": "", + "fields": [] + }, + { + "name": "tstrdoidxst", + "base": "", + "fields": [] + }, + { + "name": "tstrdoidxup", + "base": "", + "fields": [] + }, { "name": "tstrdonly", "base": "", @@ -556,6 +772,21 @@ "name": "tstxzero", "base": "", "fields": [] + }, + { + "name": "xcidxread", + "base": "", + "fields": [ + { + "name": "other", + "type": "name" + } + ] + }, + { + "name": "xcsetup", + "base": "", + "fields": [] } ], "actions": [ @@ -564,6 +795,46 @@ "type": "ramerase", "ricardian_contract": "" }, + { + "name": "ramidxremov", + "type": "ramidxremov", + "ricardian_contract": "" + }, + { + "name": "ramidxstore", + "type": "ramidxstore", + "ricardian_contract": "" + }, + { + "name": "ramidxstpyr", + "type": "ramidxstpyr", + "ricardian_contract": "" + }, + { + "name": "ramidxupdat", + "type": "ramidxupdat", + "ricardian_contract": "" + }, + { + "name": "ramidxuppyr", + "type": "ramidxuppyr", + "ricardian_contract": "" + }, + { + "name": "ramnotiferr", + "type": "ramnotiferr", + "ricardian_contract": "" + }, + { + "name": "ramnotify", + "type": "ramnotify", + "ricardian_contract": "" + }, + { + "name": "rampyrset", + "type": "rampyrset", + "ricardian_contract": "" + }, { "name": "ramstore", "type": "ramstore", @@ -829,11 +1100,21 @@ "type": "tsterase", "ricardian_contract": "" }, + { + "name": "tsteraseall", + "type": "tsteraseall", + "ricardian_contract": "" + }, { "name": "tsterasedinv", "type": "tsterasedinv", "ricardian_contract": "" }, + { + "name": "tsterasenext", + "type": "tsterasenext", + "ricardian_contract": "" + }, { "name": "tstfindmiss", "type": "tstfindmiss", @@ -844,6 +1125,21 @@ "type": "tstfwditer", "ricardian_contract": "" }, + { + "name": "tstidxerase", + "type": "tstidxerase", + "ricardian_contract": "" + }, + { + "name": "tstidxinsert", + "type": "tstidxinsert", + "ricardian_contract": "" + }, + { + "name": "tstidxmutupd", + "type": "tstidxmutupd", + "ricardian_contract": "" + }, { "name": "tstidxpayer", "type": "tstidxpayer", @@ -879,21 +1175,56 @@ "type": "tstlbound", "ricardian_contract": "" }, + { + "name": "tstmaxkeyupd", + "type": "tstmaxkeyupd", + "ricardian_contract": "" + }, + { + "name": "tstmaxvalcr", + "type": "tstmaxvalcr", + "ricardian_contract": "" + }, + { + "name": "tstmaxvalupd", + "type": "tstmaxvalupd", + "ricardian_contract": "" + }, { "name": "tstmodify", "type": "tstmodify", "ricardian_contract": "" }, + { + "name": "tstmutupdval", + "type": "tstmutupdval", + "ricardian_contract": "" + }, { "name": "tstnotifyram", "type": "tstnotifyram", "ricardian_contract": "" }, + { + "name": "tstovrkyupd", + "type": "tstovrkyupd", + "ricardian_contract": "" + }, { "name": "tstovrszkey", "type": "tstovrszkey", "ricardian_contract": "" }, + { + "name": "tstovrvalcr", + "type": "tstovrvalcr", + "ricardian_contract": "" + }, + { + "name": "tstovrvalupd", + "type": "tstovrvalupd", + "ricardian_contract": "" + }, { "name": "tstpayeroth", "type": "tstpayeroth", @@ -924,6 +1255,26 @@ "type": "tstrbempty", "ricardian_contract": "" }, + { + "name": "tstrdoerase", + "type": "tstrdoerase", + "ricardian_contract": "" + }, + { + "name": "tstrdoidxrm", + "type": "tstrdoidxrm", + "ricardian_contract": "" + }, + { + "name": "tstrdoidxst", + "type": "tstrdoidxst", + "ricardian_contract": "" + }, + { + "name": "tstrdoidxup", + "type": "tstrdoidxup", + "ricardian_contract": "" + }, { "name": "tstrdonly", "type": "tstrdonly", @@ -1028,6 +1379,16 @@ "name": "tstxzero", "type": "tstxzero", "ricardian_contract": "" + }, + { + "name": "xcidxread", + "type": "xcidxread", + "ricardian_contract": "" + }, + { + "name": "xcsetup", + "type": "xcsetup", + "ricardian_contract": "" } ], "tables": [ @@ -1045,7 +1406,14 @@ "index_type": "i64", "key_names": ["scope","primary_key"], "key_types": ["name","uint64"], - "table_id": 62600 + "table_id": 62600, + "secondary_indexes": [ + { + "name": "byval", + "key_type": "float64", + "table_id": 64649 + } + ] }, { "name": "mi_row", @@ -1085,7 +1453,14 @@ "index_type": "i64", "key_names": ["scope","primary_key"], "key_types": ["name","uint64"], - "table_id": 13040 + "table_id": 13040, + "secondary_indexes": [ + { + "name": "byscore", + "key_type": "uint128", + "table_id": 8945 + } + ] }, { "name": "sectbl", @@ -1093,7 +1468,14 @@ "index_type": "i64", "key_names": ["scope","primary_key"], "key_types": ["name","uint64"], - "table_id": 7439 + "table_id": 7439, + "secondary_indexes": [ + { + "name": "byage", + "key_type": "uint64", + "table_id": 48144 + } + ] } ], "ricardian_clauses": [], 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 c0aae615ef..1153863803 100644 --- a/unittests/test-contracts/test_kv_api/test_kv_api.cpp +++ b/unittests/test-contracts/test_kv_api/test_kv_api.cpp @@ -15,6 +15,15 @@ static constexpr uint32_t test_table_id = 42; static constexpr uint32_t test_sec_table_id = 100; +// Table ids for the RAM-billing and read-only / cross-contract / iterator +// mutation actions added in the ram-billing test set. +static constexpr uint32_t ramidx_tid = 300; +static constexpr uint32_t rdo_tid = 301; +static constexpr uint32_t xc_tid = 302; +static constexpr uint32_t idxmut_tid = 303; +static constexpr uint32_t idxmut2_tid = 304; +static constexpr uint32_t idxmut3_tid = 305; + using namespace sysio; // ── Helpers ──────────────────────────────────────────────────────────────────── @@ -958,6 +967,64 @@ class [[sysio::contract("test_kv_api")]] test_kv_api : public contract { check(memcmp(buf, val, 1024) == 0, "maxval: data mismatch"); } + // ─── Boundary tests: key/value size limits on create AND update ────────── + + // Update existing row with max key (256 bytes) — should succeed + [[sysio::action]] + void tstmaxkeyupd() { + char key[256]; + memset(key, 0xB0, 256); + kv_set(test_table_id, 0, key, 256, "v1", 2); // create + kv_set(test_table_id, 0, key, 256, "v2", 2); // update — same key, different value + char buf[4]; uint32_t actual = 0; + check(kv_get(test_table_id, get_self().value, key, 256, buf, 4) == 2, "maxkeyupd: value size"); + check(memcmp(buf, "v2", 2) == 0, "maxkeyupd: value should be updated"); + } + + // Update with oversize key (257 bytes) — should fail + [[sysio::action]] + void tstovrkyupd() { + char key[257]; + memset(key, 0xB1, 257); + kv_set(test_table_id, 0, key, 257, "v", 1); + } + + // Create with max value size (256 KiB = 262144 bytes) + [[sysio::action]] + void tstmaxvalcr() { + char key[] = {(char)0xB2, 0x01}; + std::vector val(262144, 'V'); + kv_set(test_table_id, 0, key, 2, val.data(), 262144); + check(kv_get(test_table_id, get_self().value, key, 2, nullptr, 0) == 262144, "maxvalcr: size"); + } + + // Create with oversize value (262145 bytes) — should fail + [[sysio::action]] + void tstovrvalcr() { + char key[] = {(char)0xB3, 0x01}; + std::vector val(262145, 'X'); + kv_set(test_table_id, 0, key, 2, val.data(), 262145); + } + + // Update to max value size + [[sysio::action]] + void tstmaxvalupd() { + char key[] = {(char)0xB4, 0x01}; + kv_set(test_table_id, 0, key, 2, "small", 5); // create small + std::vector val(262144, 'W'); + kv_set(test_table_id, 0, key, 2, val.data(), 262144); // update to max + check(kv_get(test_table_id, get_self().value, key, 2, nullptr, 0) == 262144, "maxvalupd: size"); + } + + // Update to oversize value — should fail + [[sysio::action]] + void tstovrvalupd() { + char key[] = {(char)0xB5, 0x01}; + kv_set(test_table_id, 0, key, 2, "small", 5); // create small + std::vector val(262145, 'Y'); + kv_set(test_table_id, 0, key, 2, val.data(), 262145); // update to over max + } + // ─── 38. testpartread: kv_get with small buffer returns actual size ─────── [[sysio::action]] void testpartread() { @@ -2221,4 +2288,350 @@ class [[sysio::contract("test_kv_api")]] test_kv_api : public contract { ++it; check(it == idxA.end(), "tstxubound: end after upper_bound advance"); } + + // ════════════════════════════════════════════════════════════════════════════ + // Payer-parameterized RAM billing actions + // ════════════════════════════════════════════════════════════════════════════ + + // Store/update with explicit payer (payer=0 means receiver pays) + [[sysio::action]] + void rampyrset(uint32_t key_id, uint32_t val_size, sysio::name payer) { + char key[4]; + memcpy(key, &key_id, 4); + std::vector val(val_size, 'X'); + kv_set(test_table_id, payer.value, key, 4, val.data(), val_size); + } + + // ════════════════════════════════════════════════════════════════════════════ + // Secondary index RAM billing actions + // ════════════════════════════════════════════════════════════════════════════ + + // Store secondary index with parameterized key sizes + [[sysio::action]] + void ramidxstore(uint32_t sec_size, uint32_t pri_size) { + std::vector sec_key(sec_size, 'S'); + std::vector pri_key(pri_size, 'P'); + kv_idx_store(0, ramidx_tid, + pri_key.data(), pri_size, sec_key.data(), sec_size); + } + + // Remove secondary index + [[sysio::action]] + void ramidxremov(uint32_t sec_size, uint32_t pri_size) { + std::vector sec_key(sec_size, 'S'); + std::vector pri_key(pri_size, 'P'); + kv_idx_remove(ramidx_tid, + pri_key.data(), pri_size, sec_key.data(), sec_size); + } + + // Update secondary index (old sec filled with 'S', new with 'T') + [[sysio::action]] + void ramidxupdat(uint32_t old_ss, uint32_t new_ss, uint32_t pri_size) { + std::vector old_sec(old_ss, 'S'); + std::vector new_sec(new_ss, 'T'); + std::vector pri_key(pri_size, 'P'); + kv_idx_update(0, ramidx_tid, + pri_key.data(), pri_size, + old_sec.data(), old_ss, new_sec.data(), new_ss); + } + + // Secondary index store with explicit payer (for cross-account billing tests) + [[sysio::action]] + void ramidxstpyr(sysio::name payer, uint32_t sec_size, uint32_t pri_size) { + std::vector sec_key(sec_size, 'S'); + std::vector pri_key(pri_size, 'P'); + kv_idx_store(payer.value, ramidx_tid, + pri_key.data(), pri_size, sec_key.data(), sec_size); + } + + // Secondary index update with explicit payer + [[sysio::action]] + void ramidxuppyr(sysio::name payer, uint32_t old_ss, uint32_t new_ss, uint32_t pri_size) { + std::vector old_sec(old_ss, 'S'); + std::vector new_sec(new_ss, 'T'); + std::vector pri_key(pri_size, 'P'); + kv_idx_update(payer.value, ramidx_tid, + pri_key.data(), pri_size, + old_sec.data(), old_ss, new_sec.data(), new_ss); + } + + // ════════════════════════════════════════════════════════════════════════════ + // Read-only rejection actions + // ════════════════════════════════════════════════════════════════════════════ + + [[sysio::action]] + void tstrdoerase() { + char key[] = "rdoerase"; + kv_erase(test_table_id, key, sizeof(key)); + } + + [[sysio::action]] + void tstrdoidxst() { + char sec[] = "sec"; + char pri[] = "pri"; + kv_idx_store(0, rdo_tid, pri, 3, sec, 3); + } + + [[sysio::action]] + void tstrdoidxrm() { + char sec[] = "sec"; + char pri[] = "pri"; + kv_idx_remove(rdo_tid, pri, 3, sec, 3); + } + + [[sysio::action]] + void tstrdoidxup() { + char os[] = "sec"; + char ns[] = "new"; + char pri[] = "pri"; + kv_idx_update(0, rdo_tid, pri, 3, os, 3, ns, 3); + } + + // ════════════════════════════════════════════════════════════════════════════ + // Notification billing actions + // ════════════════════════════════════════════════════════════════════════════ + + // Send notification — receiver's on_notify handler does self-pay kv_set + [[sysio::action]] + void ramnotify(uint32_t key_id, uint32_t val_size) { + require_recipient("kvnotify"_n); + } + + // Notification handler: self-pay kv_set — RAM billed to notified receiver + [[sysio::on_notify("*::ramnotify")]] + void on_ramnotify(uint32_t key_id, uint32_t val_size) { + char key[4]; + memcpy(key, &key_id, 4); + std::vector val(val_size, 'N'); + kv_set(test_table_id, 0, key, 4, val.data(), val_size); + } + + // Send notification — receiver's handler tries third-party payer + [[sysio::action]] + void ramnotiferr(uint32_t key_id, uint32_t val_size, sysio::name payer) { + require_recipient("kvnotify"_n); + } + + // Notification handler: third-party payer in notify context — should fail + [[sysio::on_notify("*::ramnotiferr")]] + void on_ramnotiferr(uint32_t key_id, uint32_t val_size, sysio::name payer) { + char key[4]; + memcpy(key, &key_id, 4); + std::vector val(val_size, 'E'); + kv_set(test_table_id, payer.value, key, 4, val.data(), val_size); + } + + // ════════════════════════════════════════════════════════════════════════════ + // Iterator invalidation under mutation + // ════════════════════════════════════════════════════════════════════════════ + + // Update value of current row while iterator points to it — verify new value visible + [[sysio::action]] + void tstmutupdval() { + char prefix[] = {(char)0xA0}; + char key[] = {(char)0xA0, 0x01}; + kv_set(test_table_id, 0, key, 2, "old", 3); + + uint32_t h = kv_it_create(test_table_id, get_self().value, prefix, 1); + kv_it_lower_bound(h, key, 2); + check(kv_it_status(h) == 0, "mutupdval: should be valid"); + + // Update value while iterator points to row + kv_set(test_table_id, 0, key, 2, "newval", 6); + + // Iterator should still be valid (key unchanged) and see new value + char vbuf[16]; uint32_t vlen = 0; + int32_t st = kv_it_value(h, 0, vbuf, sizeof(vbuf), &vlen); + check(st == 0, "mutupdval: status after update"); + check(vlen == 6, "mutupdval: new value size"); + check(memcmp(vbuf, "newval", 6) == 0, "mutupdval: new value data"); + + kv_it_destroy(h); + } + + // Erase current row then call next() — should advance to next valid row + [[sysio::action]] + void tsterasenext() { + char prefix[] = {(char)0xA1}; + char k1[] = {(char)0xA1, 0x01}; + char k2[] = {(char)0xA1, 0x02}; + char k3[] = {(char)0xA1, 0x03}; + kv_set(test_table_id, 0, k1, 2, "a", 1); + kv_set(test_table_id, 0, k2, 2, "b", 1); + kv_set(test_table_id, 0, k3, 2, "c", 1); + + uint32_t h = kv_it_create(test_table_id, get_self().value, prefix, 1); + kv_it_lower_bound(h, k2, 2); + check(kv_it_status(h) == 0, "erasenext: on k2"); + + // Erase k2 under the iterator + kv_erase(test_table_id, k2, 2); + + // next() should advance to k3 + int32_t st = kv_it_next(h); + check(st == 0, "erasenext: next should find k3"); + + char kbuf[4]; uint32_t klen = 0; + kv_it_key(h, 0, kbuf, sizeof(kbuf), &klen); + check(klen == 2, "erasenext: key size"); + check(kbuf[1] == 0x03, "erasenext: should be at k3"); + + kv_it_destroy(h); + } + + // Erase all rows during iteration — iterator should reach end + [[sysio::action]] + void tsteraseall() { + char prefix[] = {(char)0xA2}; + char k1[] = {(char)0xA2, 0x01}; + char k2[] = {(char)0xA2, 0x02}; + kv_set(test_table_id, 0, k1, 2, "a", 1); + kv_set(test_table_id, 0, k2, 2, "b", 1); + + uint32_t h = kv_it_create(test_table_id, get_self().value, prefix, 1); + kv_it_lower_bound(h, k1, 2); + check(kv_it_status(h) == 0, "eraseall: on k1"); + + // Erase both rows + kv_erase(test_table_id, k1, 2); + kv_erase(test_table_id, k2, 2); + + // next() should reach end (status 1) + int32_t st = kv_it_next(h); + check(st == 1, "eraseall: should be at end"); + + kv_it_destroy(h); + } + + // Remove secondary index entry under sec iterator — verify detection + [[sysio::action]] + void tstidxerase() { + uint64_t self = get_self().value; + constexpr uint32_t table = idxmut_tid; + char sec1[] = "alpha"; + char sec2[] = "bravo"; + char pri1[] = "pk1"; + char pri2[] = "pk2"; + kv_idx_store(0, table, pri1, 3, sec1, 5); + kv_idx_store(0, table, pri2, 3, sec2, 5); + + // Position sec iterator on "alpha" + int32_t h = kv_idx_find_secondary(self, table, sec1, 5); + check(h >= 0, "idxerase: should find alpha"); + + // Remove "alpha" entry + kv_idx_remove(table, pri1, 3, sec1, 5); + + // next() should advance to "bravo" + int32_t st = kv_idx_next(h); + check(st == 0, "idxerase: next should find bravo"); + + char sbuf[16]; uint32_t slen = 0; + kv_idx_key(h, 0, sbuf, sizeof(sbuf), &slen); + check(slen == 5, "idxerase: sec key size"); + check(memcmp(sbuf, "bravo", 5) == 0, "idxerase: should be bravo"); + + kv_idx_destroy(h); + } + + // Update secondary key under sec iterator — iterator re-scans from old position + [[sysio::action]] + void tstidxmutupd() { + uint64_t self = get_self().value; + constexpr uint32_t table = idxmut2_tid; + char sec_old[] = "delta"; + char sec_new[] = "zebra"; + char pri[] = "pk1"; + kv_idx_store(0, table, pri, 3, sec_old, 5); + + // Position sec iterator on "delta" + int32_t h = kv_idx_find_secondary(self, table, sec_old, 5); + check(h >= 0, "idxmutupd: should find delta"); + + // Update "delta" -> "zebra" — removes old entry, inserts new one + kv_idx_update(0, table, pri, 3, sec_old, 5, sec_new, 5); + + // next() re-scans from "delta" position: "delta" gone, lower_bound lands on "zebra" + int32_t st = kv_idx_next(h); + check(st == 0, "idxmutupd: should find zebra"); + + char sbuf[16]; uint32_t slen = 0; + kv_idx_key(h, 0, sbuf, sizeof(sbuf), &slen); + check(slen == 5, "idxmutupd: key size"); + check(memcmp(sbuf, "zebra", 5) == 0, "idxmutupd: should be zebra"); + + kv_idx_destroy(h); + } + + // Insert new secondary entry during iteration — verify visible + [[sysio::action]] + void tstidxinsert() { + uint64_t self = get_self().value; + constexpr uint32_t table = idxmut3_tid; + char sec1[] = "aaa"; + char sec3[] = "ccc"; + char pri1[] = "pk1"; + char pri3[] = "pk3"; + kv_idx_store(0, table, pri1, 3, sec1, 3); + kv_idx_store(0, table, pri3, 3, sec3, 3); + + // Position on "aaa" + int32_t h = kv_idx_find_secondary(self, table, sec1, 3); + check(h >= 0, "idxinsert: should find aaa"); + + // Insert "bbb" between aaa and ccc + char sec2[] = "bbb"; + char pri2[] = "pk2"; + kv_idx_store(0, table, pri2, 3, sec2, 3); + + // next() should see "bbb" + int32_t st = kv_idx_next(h); + check(st == 0, "idxinsert: next should find bbb"); + + char sbuf[8]; uint32_t slen = 0; + kv_idx_key(h, 0, sbuf, sizeof(sbuf), &slen); + check(slen == 3, "idxinsert: key size"); + check(memcmp(sbuf, "bbb", 3) == 0, "idxinsert: should be bbb"); + + kv_idx_destroy(h); + } + + // ════════════════════════════════════════════════════════════════════════════ + // Cross-contract read actions + // ════════════════════════════════════════════════════════════════════════════ + + // Store a primary row + secondary index entry for cross-contract reads + [[sysio::action]] + void xcsetup() { + char key[] = "xcpri"; + char val[] = "xcval"; + kv_set(test_table_id, 0, key, 5, val, 5); + char sec[] = "xcsec"; + char pri[] = "xcpri"; + kv_idx_store(0, xc_tid, pri, 5, sec, 5); + } + + // Read another contract's secondary index + [[sysio::action]] + void xcidxread(sysio::name other) { + // Find secondary index entry on other contract + int32_t h = kv_idx_find_secondary(other.value, xc_tid, "xcsec", 5); + check(h >= 0, "xcidxread: secondary not found"); + + // Read primary key from the iterator + char pk_buf[16]; + uint32_t pk_sz = 0; + int32_t st = kv_idx_primary_key(h, 0, pk_buf, sizeof(pk_buf), &pk_sz); + check(st == 0, "xcidxread: iterator status"); + check(pk_sz == 5, "xcidxread: pri_key size"); + check(memcmp(pk_buf, "xcpri", 5) == 0, "xcidxread: pri_key data"); + + // Use that primary key to read the row from the other contract + char val_buf[16]; + int32_t val_sz = kv_get(test_table_id, other.value, "xcpri", 5, val_buf, sizeof(val_buf)); + check(val_sz == 5, "xcidxread: value size"); + check(memcmp(val_buf, "xcval", 5) == 0, "xcidxread: value data"); + + kv_idx_destroy(h); + } }; 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 6b08769a0b..56deac7965 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