sysio.msig: chunked storage for large proposals + read-only getproposal#292
Merged
Conversation
Wire's per-row KV value limit is 256 KiB and a direct setcode trx can carry ~512 KiB, so post-#291 sysio.msig setcode was capped well below what a non-msig setcode could deploy. This restores parity by chunking the serialized inner trx across rows of a new `propchunks` table when it exceeds an internal threshold (200 KiB), reassembling on `exec`, and exposing a read-only `getproposal` action that returns the assembled struct via /v1/chain/send_read_only_transaction. Storage layout - `proposal` row gains three appended `binary_extension` fields: `chunk_count`, `total_size`, `trx_hash`. Small proposals keep the legacy shape (full blob in `packed_transaction`, chunk_count = 0) so external tooling that reads the row directly via get_table_rows still works for the small case. - New `propchunks` `kv::scoped_table` keyed by (proposal_name, chunk_index) holds the bytes when a proposal is chunked; the parent row's `packed_transaction` is empty in that case. propose / approve / unapprove / cancel / exec - `propose` hashes the inner trx once via `sysio::sha256(trx_pos, size)` and stores it in `trx_hash`. Inner trxs at or below `proposal_chunk_size` go into `packed_transaction` as before; larger ones are split into ceil(size / proposal_chunk_size) `propchunks` rows. - Helpers `is_chunked`, `assemble_packed_trx`, `read_trx_header`, and `erase_proposal_chunks` keep the chunk-aware logic isolated. The header read pulls from chunk 0 alone for chunked proposals so approve doesn't pay reassembly cost just to inspect expiration / delay_sec. - `approve --proposal-hash` keeps the legacy `assert_sha256` path for inline proposals and uses the precomputed `trx_hash` for chunked proposals, since chunked rows have no inline blob to re-hash. - `exec` reassembles, deserializes, dispatches each action via `act.send()` exactly as before. Each individual dispatched action is still bounded by `max_inline_action_size`, so no chain-config change is required. - `cancel` and `exec` both call `erase_proposal_chunks` so chunk rows never outlive their parent proposal — no orphaned RAM. getproposal (read-only) - New `[[sysio::action("getproposal"), sysio::read_only]] proposal get_proposal(proposer, proposal_name)` returns the assembled proposal struct with `packed_transaction` always populated. CDT's codegen auto-emits `set_action_return_value` for non-void actions, and the chain skips `max_action_return_value_size` in read-only context, so the full ~512 KiB blob comes back via the trace's `action_traces[0].return_value`. clio multisig review now uses getproposal - Switched away from /v1/chain/get_table_rows on the `proposal` row. `multisig review` builds a one-action getproposal trx, posts it to /v1/chain/send_read_only_transaction, and pulls `processed.action_traces[0].return_value_data` (ABI-decoded by chain_plugin via the new action_results entry CDT generates from the return type). Storage-layout-agnostic — if sysio.msig refactors its tables again, clio keeps working as long as the getproposal signature is preserved. - This is the design principle for new clio commands going forward: read-only actions are the stable contract surface; get_table_rows reaches into table internals and is brittle (the same brittleness that bit us in #291 with ABI field-name matching). clio multisig propose_trx accepts structured action.data - Mirrors the try/catch fallback that `clio push transaction` already has at ~line 3145: try the literal `as<transaction>()` cast first, fall back to `abi_serializer::from_variant(trx_var, trx, abi_serializer_resolver, ...)` on the catch. Strict superset of the old behavior — old hex-data callers keep working unchanged, new callers can pass the natural JSON shape with structured `action.data` objects and clio will recursively encode via each contract's ABI. - Generic clio improvement that benefits anyone using propose_trx, not just the new msig integration test. Tests - contracts/tests/sysio.msig_tests.cpp adds three cases: - `big_transaction_chunked`: builds a >200 KiB inner trx (two setcode actions of sysio.system.wasm), proposes, exercises the chunked-path `--proposal-hash` check (correct hash succeeds, wrong hash rejected against the stored trx_hash), approves, and execs end-to-end. Default chain config — no setparams bumps. - `getproposal_read_only_returns_assembled`: pushes a read-only trx invoking getproposal, ABI-decodes the return value, asserts the reassembled `packed_transaction` byte-equals the original, and that `chunk_count > 0` and `total_size` matches. - `cancel_chunked_proposal_cleans_chunks`: proposes, cancels, re-proposes under the same name, approves, execs — would fail with "key already exists" on the second propose if cancel did not erase chunk rows. - New tests/multisig_review_test.py Python integration test launches a producer + non-producer API node cluster (with `--read-only-threads 1` on the API node), deploys the new sysio.msig wasm, submits a chunked propose via clio, runs `clio multisig review` against the API node so it actually goes through the read-only-trx getproposal path, and validates the JSON output: chunk_count > 0, total_size > 200 KiB, trx_hash populated, both setcode actions present, packed_transaction non-empty (proving the review fell through getproposal rather than echoing the empty inline row). Also runs `--show-approvals` which exercises the approvals2 / approvals / invals lookup paths. - All 117 contracts_unit_test cases, 14 read_only_trx_tests, and the full plugin_test suite pass.
…ling, extra chunked tests Addresses a pre-PR review pass over fd91d9b. No behavior changes in the happy path; perf, error UX, and test coverage improvements. Contract: assemble_packed_trx consumes proposal&& to avoid inline copy - `assemble_packed_trx` now takes `multisig::proposal&&` and, for the inline path, returns `std::move(prop.packed_transaction)` so the (~up to 200 KiB) vector is moved out instead of copied. NRVO doesn't apply to returning a const-lvalue member, so the previous `const proposal&` version paid a full heap copy on every inline approve/unapprove/exec auth recheck. - New small helper `read_proposal_chunks(const proposal&, ...)` holds the chunk-reading logic. Shared by `assemble_packed_trx`'s chunked branch and directly by `get_proposal`, which preserves `prop` for return — avoiding the moved-from-then-write awkwardness of routing `get_proposal` through the consume-prop helper. - `erase_proposal_chunks` now takes `(uint32_t chunk_count, name proposal_name, name self, name proposer)` instead of a `proposal&`. Lets `exec` and `cancel` call it AFTER the proposal has been moved into `assemble_packed_trx`. Both callers snapshot `chunk_count` (and `exec` snapshots `earliest_exec_time`) into locals before the std::move. - `get_proposal` rewritten to call `read_proposal_chunks` directly on a preserved `prop` local, then return `prop` via NRVO. Zero redundant copies for both inline and chunked paths. Contract: defense-in-depth static_assert on proposal_chunk_size - `read_trx_header`'s chunk-0 fast path assumes chunk 0 holds at least a full serialized transaction_header (max ~21 bytes). Added a `static_assert(proposal_chunk_size >= 128)` next to the constant so anyone reducing it has to confront the invariant at compile time before silently breaking the fast path. Contract: minor cleanups - Dropped the redundant `(char*)` casts on `memcpy(..., pkd_trans.data(), ...)` and the chunked equivalent — `std::vector<char>::data()` already returns `char*` for non-const vectors. - Replaced the ternary `(size - off < proposal_chunk_size) ? (size - off) : proposal_chunk_size` with `std::min(size - off, proposal_chunk_size)`. - Moved the `get_proposal` action declaration and `getproposal_action` wrapper from between `propchunks` and `old_approval_key` down to after `invalidations`, so all tables are grouped above the single read-only action at the bottom. Doesn't change the ABI (no action/table set changes) — confirmed by a clean `git status` on sysio.msig.abi after rebuild. clio: tighter exception handling in multisig review - Replaced `catch(...) { std::cerr << "Proposal not found"; }` around the send_read_only_transaction call with `catch(const fc::exception& e)`. The old catch-all swallowed network errors, parse failures, and server errors as "Proposal not found"; the new version only friendly-prints the literal contract assertion "proposal not found" and rethrows everything else so the real failure reaches clio's main handler. - Improved the missing_chain_api_plugin_exception message to explicitly mention `--read-only-threads N` and the producer-node restriction, so operators hitting this for the first time don't have to grep nodeop's source. - Wrapped the JSON envelope walk (result1 -> processed -> action_traces -> first_trace -> return_value_data) in a try/catch that catches the `fc::exception` base (covers both bad_cast_exception for wrong types and key_not_found_exception for missing fields). On failure the error message includes both the fc exception detail AND the raw response JSON so the user can diagnose what the node actually returned. The `proposal_object` is carried out of the try via a `const fc::variant_object*` pointer so the ~150 lines of downstream code don't need to move into the try block and no ~200 KiB copy of the packed_transaction hex is made. Tests: three additional chunked-path tests - `getproposal_read_only_inline` — sibling of the existing chunked test that exercises the inline branch of get_proposal (proposal_object returned via read_only trx for a small reqauth-based proposal). Uses BOOST_REQUIRE_EQUAL_COLLECTIONS for the byte-level packed_transaction comparison so failures report the first mismatching index instead of a bare assertion-failed message. - `unapprove_chunked_past_threshold` — exercises the chunked-trx-recheck branch in unapprove (the `assemble_packed_trx(std::move(prop), ...)` call at sysio.msig.cpp). Propose chunked -> approve(alice) -> approve (bob, sets earliest_exec_time inner optional) -> unapprove(bob, triggers the chunked reassembly and clears earliest_exec_time) -> exec (must reject because bob's approval was removed) -> re-approve (bob) + exec (must succeed, proves the unapprove path didn't corrupt anything). - `cancel_chunked_by_non_proposer_past_expiration` — exercises the chunk-0 read_trx_header fast path from `cancel`'s non-proposer branch. Computes a near-future expiration relative to `control->head().block_time()`, proposes chunked, tries non-proposer cancel before expiration (must fail), advances 20 blocks past expiration, cancels as non-proposer (must succeed AND clean up chunks), then re-proposes under the same name (would fail with "key already exists" if any chunk rows were orphaned). - The existing `getproposal_read_only_returns_assembled` test's byte comparison also switched to BOOST_REQUIRE_EQUAL_COLLECTIONS for the same diagnostic benefit. - `build_chunking_trx` takes an optional `expiration_iso` parameter (default: the existing far-future value) so the cancel-expiration test can inject a near-future value without disturbing the other chunked tests. Pre-commit sweep: contracts_unit_test 120/120, plugin_test clean, unit_test read_only_trx_tests 14/14, multisig_review_test.py end-to-end integration test passes.
…d-storage Sole conflict was the compiled contracts/sysio.msig/sysio.msig.wasm. Resolved by rebuilding sysio.msig from the merged source (the chunked-storage feature plus master) with the current CDT (wire-cdt master, post-#51 contract-side librt); sysio.msig.abi regenerated to match. The rebuilt contract has no env compiler-builtin imports, and reference data is unaffected (sysio.msig is not deployed in the snapshot / deep-mind / consensus scenarios).
huangminghuang
approved these changes
May 18, 2026
heifner
added a commit
that referenced
this pull request
May 20, 2026
…locks-at-slot-entry Brings origin/master up to 3806434, picking up #292 (msig chunked storage), #318 (host builtin intrinsic removal), and #346 (locks_out_branch_of genesis-core and last_final boundary cases). Conflict in unittests/fork_db_tests.cpp resolved by keeping our h14a/h14b extensions to locks_out_branch_of_test alongside master's h_root genesis case. block_handle.hpp deduplicated -- both sides had appended extends() in different positions; kept the one with the tightened comment.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Wire's per-row KV value limit is 256 KiB but a direct
setcodetransaction can carry roughly 524287 bytes (max_transaction_net_usage), so post-#291 sysio.msig setcode regressed to a smaller ceiling than non-msig setcode. This PR restores parity by chunking the serialized inner trx across rows of a newpropchunkstable, reassembling it onexec, and exposing a read-onlygetproposalaction so external tooling can fetch the assembled proposal regardless of how the bytes are physically laid out on disk.No chain-config changes are required: each individual dispatched action is still bounded by
max_inline_action_size, andproposestill has to fit inside one outer transaction. This PR purely lifts the storage ceiling so msig setcode matches direct setcode at current chain limits.Depends on:
[[sysio::action, sysio::read_only]]works on non-trivial bodies (false-positive validator fix)Storage layout
The
proposalrow gains three appendedbinary_extensionfields. Old shape stays bit-for-bit identical at the head, so external tooling that reads the row directly viaget_table_rowskeeps working for small proposals.proposal_chunk_size(200 KiB): legacy single-row layout,packed_transactionpopulated,chunk_count = 0. Same wire format as today plus three trailing extension fields. msig.app / cleos / bloks.io render unchanged.packed_transactionis empty, bytes split across Npropchunksrows keyed by(proposal_name, chunk_index). Tooling has to callgetproposalto retrieve the assembled blob.approve --proposal-hashkeeps working for both shapes: the inline path usesassert_sha256againstpacked_transaction(the historical semantic), and the chunked path checks against the precomputedtrx_hashstored at propose time, so chunks don't have to be reassembled on every approve.On-chain changes
proposestoressysio::sha256(trx_pos, size)intrx_hashand either keeps the bytes inline (≤ 200 KiB) or splits across Npropchunksrows.execreassembles viaassemble_packed_trxonce into WASM linear memory, deserializes, dispatches each action viaact.send()exactly as before. After successful exec, erases the chunk rows.approve/unapproveread the trx header from chunk 0 alone (not the full blob) so the common case stays cheap. The full reassembly only fires once per proposal — when it transitions to "fully approved" andearliest_exec_timeis being set.cancelerases all chunk rows after erasing the parent proposal. Together with exec's cleanup this guarantees no orphaned RAM.get_proposalaction:[[sysio::action(\"getproposal\"), sysio::read_only]] proposal get_proposal(name proposer, name proposal_name);. Returns the assembled struct withpacked_transactionpopulated. CDT auto-generatesset_action_return_valuefor non-void actions, and the chain skips themax_action_return_value_sizecap in read-only context, so the full ~512 KiB blob comes back via the trace'saction_traces[0].return_value.clio changes ⭐ (please pick up for clio docs)
Two clio updates land in this PR — both worth documenting and both generic enough to benefit any future contract / use case, not just msig.
1.
multisig reviewnow usesgetproposalvia send_read_only_transactionPreviously
clio multisig reviewreached into theproposaltable directly via/v1/chain/get_table_rowsand decoded thepacked_transactionfield locally. After this PR it builds a one-action transaction callingsysio.msig::getproposal(proposer, proposal_name), posts it to/v1/chain/send_read_only_transaction, and pulls the assembledproposalstruct fromprocessed.action_traces[0].return_value_data(ABI-decoded by chain_plugin via the newaction_resultsentry CDT generates).Why it matters for docs:
multi_indextokv::scoped_tableand now to chunked storage —multisig reviewkeeps working as long as thegetproposalsignature is preserved.get_table_rowsclients have to relearn the schema every time.primary_key/account/proposal_nameliterally; that's exactly the brittleness that bit us in Migrate production contracts from multi_index/singleton to kv::table #291 when the underlying ABI shape changed. The new path is shape-stable.--read-only-threads Nset (withN >= 1); otherwisemultisig reviewfails withunsupported_feature: read-only transactions execution not enabled on API node. The producer node rejects this flag, so the standard topology is producer + non-producer API node, with review traffic routed at the API node.--show-approvalsis unchanged: it still usesget_table_rowsforapprovals2/approvals/invalsbecause those tables don't have any storage indirection.2.
multisig propose_trxaccepts structuredaction.dataPreviously
multisig propose_trxonly accepted a transaction JSON whoseaction.datafields were already hex-encoded (because the propose action's ABI typestrxastransaction, and the chaintransactionABI typesaction.dataasbytes). Passing a structured object — the natural shape, the same oneclio push transactionalready accepts — producedBad Cast (7) Invalid cast from type 'object_type' to string.This PR adds the same try/catch fallback that
push transactionalready had atmain.cpp:3145:Why it matters for docs:
clio convert pack_action_datadance. Users who today pre-encode each inner action's data viaclio convert pack_action_data sysio setcode '{...}'and splice the resulting hex into the trx JSON can drop that step entirely.{ \"actions\": [{ \"account\": \"sysio\", \"name\": \"setcode\", \"authorization\": [{\"actor\": \"alice\", \"permission\": \"active\"}], \"data\": { \"account\": \"alice\", \"vmtype\": 0, \"vmversion\": 0, \"code\": \"<hex>\" } }] }multisig propose_trxwith any contract, not just msig setcode.Tests
contracts/tests/sysio.msig_tests.cpp
Three new cases under
sysio_msig_tests, all running against default chain config (nosetparamsbumps):big_transaction_chunked— builds a >200 KiB inner trx (twosetcodeactions ofsysio.system.wasmto alice and bob), proposes, exercises the chunked-path--proposal-hashcheck (correct hash succeeds, wrong hash rejected against the storedtrx_hash), approves with both signers, execs end-to-end. Verifies the trace contains the dispatchedsetcodeactions in order.getproposal_read_only_returns_assembled— pushes a read-only trx invokinggetproposal, ABI-decodes the action_trace return value via the cached msig abi_serializer, asserts that the reassembledpacked_transactionbyte-equals the originalfc::raw::pack(trx), and thatchunk_count > 0andtotal_sizematches.cancel_chunked_proposal_cleans_chunks— proposes a chunked proposal, cancels (as proposer, no expiration wait), re-proposes under the same name, approves and execs. Would fail with "key already exists" on the second propose if cancel didn't erase the chunk rows.tests/multisig_review_test.py
New Python integration test (
add_np_test multisig_review_test) that exercises the full clio path end-to-end:--read-only-threads 1on the API node.clio multisig propose_trxwith structured (non-hex) action data — exercises the new clio fallback.clio multisig reviewagainst the API node (so it actually usessend_read_only_transaction→getproposal).chunk_count > 0,total_size > 200 KiB,trx_hashpopulated, twosetcodeactions present, andpacked_transactionnon-empty in the review output (which proves clio is going through the read-only path rather than echoing the empty inline row).multisig review --show-approvals, verifies both signers show as unapproved and the approvals/invals fetch path is intact.clio review output shape (non-breaking addition)
clio multisig reviewnow exposes three additional top-level fields in its JSON output, copied through from theproposalstruct thatsysio.msig::getproposalreturns:trx_hash— the precomputedsha256(packed_transaction)stored by the contract at propose time. Most useful: users who pass--proposal-hashtoclio multisig approvecan now read this directly from review output instead of computingsha256(packed_transaction)themselves.chunk_count—0for inline proposals,> 0for chunked. Tells the user at a glance whether this proposal is stored inline or split across thepropchunkstable.total_size— size in bytes of the assembled serialized inner transaction.Purely additive — no fields removed, no types changed, no renames. Scrapers using lenient JSON parsers (jq, Python's
json.load) keep working. Only strict-schema parsers that reject unknown keys are affected, and those would already need to adapt to the fork's additions to the proposal struct (likeearliest_exec_timebefore this PR).Review follow-up commit
A review pass after the initial commit produced a follow-up (
sysio.msig review fixes) addressing:assemble_packed_trxnow consumesproposal&&— moves the inline blob out viastd::move(prop.packed_transaction)instead of copying. NRVO doesn't apply to returning a const-lvalue member, so the previous shape paid a ~200 KiB heap copy on every inline approve/unapprove/exec auth recheck. New small helperread_proposal_chunksholds the chunked-reading logic and is called directly byget_proposal(which preservespropfor return, avoiding moved-from-then-write subtleties).erase_proposal_chunkstakes primitives —(uint32_t chunk_count, name proposal_name, name self, name proposer)instead of aconst proposal&, soexecandcancelcan call it after the proposal has been moved intoassemble_packed_trx.static_assert(proposal_chunk_size >= 128)— defense against anyone reducing the constant below the size of a serializedtransaction_header, which would silently breakread_trx_header's chunk-0 fast path.catch(...) { "Proposal not found"; }withcatch(const fc::exception&)that only friendly-prints the literal contract assertion and rethrows everything else. Network errors, server errors, parse failures, and unrelated contract assertions now reach clio's main handler with their real messages. The missing-RPC error message now mentions--read-only-threads Nand the producer-node restriction so operators don't have to grep nodeop's source.fc::exception(covers bothbad_cast_exceptionandkey_not_found_exception) and prints the raw response JSON on failure so users can diagnose what the node actually returned.proposal_objectis carried out of the try via aconst fc::variant_object*pointer so the downstream code stays outside the try block and no ~200 KiB copy of the packed_transaction hex is made.getproposal_read_only_inline(the inline branch ofget_proposal),unapprove_chunked_past_threshold(the chunked-trx-recheck path inunapprove),cancel_chunked_by_non_proposer_past_expiration(the chunk-0read_trx_headerfast path fromcancel's non-proposer branch, computed against a near-future expiration and advanced past it withproduce_blocks(20)).(char*)casts onmemcpy(...), replaced a ternary chunk-length calc withstd::min, moved theget_proposalaction declaration andgetproposal_actionwrapper from betweenpropchunksandold_approval_keydown to afterinvalidationsso all tables group above the single read-only action at the bottom.sysio.msig.abiis unchanged by the reordering.Pre-commit sweep:
contracts_unit_test120/120,plugin_testclean,unit_test --run_test=read_only_trx_tests14/14,multisig_review_test.pyend-to-end integration test passes.