feat(opp-solana): drive resumable inbound dispatch; delete the depot-side terminal budget - #552
feat(opp-solana): drive resumable inbound dispatch; delete the depot-side terminal budget#552valthon wants to merge 5 commits into
Conversation
…epot Solana dispatch is resumable as of wire-solana@feat/resumable-opp-dispatch: the outpost settles [cursor, cursor + dispatch_limit) per terminal call and the relay loops until the cursor drains. An envelope needing more effect accounts than one Solana transaction can carry is therefore settled across several calls -- it no longer has to be refused when it is built. That removes the reason the depot ever modelled Solana's transaction capacity. Deleted: - `estimate_svm_dynamic_accounts` -- a per-attestation-type account estimator living in a WIRE consensus contract. - The `svm_terminal_budget_fits` gate in `buildenv`'s packing loop, which truncated an outbound envelope to fit a Solana packet. - The `queueout` admission check. This was the sharper half: it did not cap packing, it REFUSED TO QUEUE any Solana-bound attestation the estimator did not recognise, making the SEC-94 manifest an allow-list gating what could ever be sent to Solana. Adding an attestation type meant editing a depot contract's estimator or the depot rejected it outright. - `solana_terminal_budget.hpp`, the plugin's `sec94_terminal_budget_fixture_matches_contract_estimator`, and `tests/fixtures/sec-94-solana-terminal-budget.json`. Two contract tests asserted the deleted behaviour and are deleted rather than re-baselined -- their premise is a budget that no longer exists: `buildenv_svm_packs_until_terminal_budget_then_leaves_remainder` (expected buildenv to emit 2 of 3 SWAP_REMITs and leave a remainder; it now emits all 3, which is the point) and `queueout_svm_rejects_uncovered_attestation_type`. `UNCOVERED_TEST_ATTESTATION_TYPE` and `emitted_attestation_count` were left without callers and went with them. The packing loop keeps its MAX_ENVELOPE_BYTES bound and gains a comment stating why there is deliberately no destination-chain bound, so the next reader does not reintroduce one. Verified: contracts_unit_test --sys-vm "*** No errors detected"; the plugin suite likewise. Built against the platform's OWN CDT (bazel build //:cdt, cdt-cpp 1.0.0) -- the wire-e2e-env image ships cdt-cpp 4.2.0, and building contracts against that emits no dispatch code, so every contract fails to link on an undefined `apply`. NOTE: this is safe only once the relay loops terminal calls until the cursor drains. That plugin change is not in this commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXyuL6uTe59k69ecxfmh2E Change-Id: I4bc74566278b79067e4f18a4865abbe9dc0c9511
The Solana program's `epoch_in` gained a sixth argument when dispatch became resumable (wire-solana@feat/resumable-opp-dispatch). The relay still sent five, so every delivery would fail to encode against the new instruction -- this is a required correction, not an enhancement. The terminal call passes `DISPATCH_ALL_ATTESTATIONS`. The program clamps the window to the envelope's attestation count, so this settles the whole envelope in one call: byte-for-byte the pre-resumable behaviour. That is correct *because* the terminal call still carries the manifest for the WHOLE envelope -- the relay-side invariant is that `dispatch_limit` must cover exactly the attestations whose effect accounts the transaction carries, and today it carries all of them. That constraint is written next to the constant, because it stops holding the moment the manifest is split into per-attestation batches: a handler whose account is absent log-and-skips (a SUCCESSFUL no-op) and the cursor advances past it regardless, so a call claiming more attestations than its accounts cover silently drops those effects -- a remit never paid, a slash never seized, with no retry path once the epoch completes. The stub IDL fixture declared only the five old args. Nothing caught it: the one test asserting `epoch_in`'s argument list was the SEC-94 budget case removed in c0233d2. Fixture updated and `opp_outpost_epoch_in_has_chunked_args` extended to pin all six -- args encode positionally, so their order must match the program's #[instruction(...)]. Does NOT yet loop terminal calls until the cursor drains; an envelope needing more effect accounts than one transaction can carry is still under-served. That is the follow-up, and it is what makes c0233d2 (deleting the depot's terminal-account budget) safe. Verified: plugin suite "*** No errors detected"; full build green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXyuL6uTe59k69ecxfmh2E Change-Id: I7347ef4a889dbb4601ad175cee2d4a4d4a11bd04
…ains Completes resumable dispatch on the relay side. A terminal call now carries the account union for exactly the attestations it claims, and the relay keeps submitting until the on-chain cursor reaches the envelope's attestation count. The relay is the only party that can size a batch: it supplies the effect accounts and therefore knows the transaction's real byte cost. That is why the budget constant lives here now rather than in the depot -- a WIRE consensus contract had no business modelling Solana's packet limit (c0233d2 deleted the depot's copy; this is what makes that safe). Manifest building is now per-attestation. `extract_inbound_effects` is the single authoritative walk of the envelope, keyed by each attestation's FLAT position in dispatch order -- the same sequence the program's cursor counts. The five whole-envelope extractors became deduplicating VIEWS over it, so the per-attestation-type dispatch exists in exactly one place and their existing tests keep passing unchanged. Index alignment is the load-bearing invariant, so the index advances for EVERY attestation, including ones needing no accounts and ones that fail to decode. Skipping a position would make later batches claim attestations whose accounts are absent -- and a handler with a missing account log-and-skips (a SUCCESSFUL no-op) while the cursor advances past it, silently dropping a remit or a slash with no retry path. Verified by negative control: advancing the index only on effect-producing attestations makes `extract_effects_indices_track_dispatch_order` fail with [1 != 2] on an envelope whose middle attestation is a SLASH. The loop has three exits, not one: * cursor drained -- the envelope is fully settled; * `consensus_reached == false` -- this operator delivered and nothing dispatched, the normal outcome for every operator except the one whose delivery tips the threshold, and there is nothing further it can do; * cursor failed to advance -- another operator is draining the envelope; stop rather than spin. Plus MAX_TERMINAL_ROUNDS as a backstop. The cursor is read from EpochDeliveries after every call and is authoritative -- the relay never assumes its batch landed whole. Packing always takes at least one attestation, so a single effect needing more accounts than the budget makes progress instead of wedging the epoch. Verified: plugin suite "*** No errors detected"; full build green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXyuL6uTe59k69ecxfmh2E Change-Id: I7cb484de566818fa545c13abc8cde0176599269e
…ntics
The on-chain handlers now ABORT on an effect account they need and did not
receive (wire-solana 51d2fd9e) instead of skipping the effect. The relay's
manifest builder still bailed out early when the Reserve record could not be
read, returning a partial account set -- which would have turned a transient
RPC failure into a failed terminal call with no explanation.
It now passes everything still derivable without that record: the reserve
vault PDA and the constant token program derive from the (token, reserve)
codes alone, and the recipient comes off the attestation. Only the
recipient/creator ATA genuinely needs the custody mint.
So the outcomes are now both correct and loud:
* Reserve PDA simply uninitialized -> the handler has nothing to settle and
skips on CHAIN state, which no caller controls;
* transient read failure against a real SPL reserve -> the call aborts and
is re-driven, rather than silently paying nobody.
Also corrects two comments that still asserted the pre-abort contract (that
under-supplying accounts silently drops effects). Both now state that the
program enforces the invariant, because a stale comment claiming a dead
security property invites exactly the omission it used to describe.
Verified: plugin suite "*** No errors detected"; full build green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXyuL6uTe59k69ecxfmh2E
Change-Id: I79cda0f983a4c9879d94653aa87567a6d4dd27a3
…t limit `dispatch_limit` added a 6th u32 to every `epoch_in` call, including data chunks, without lowering SOLANA_MAX_CHUNK_BYTES. A full 672-byte chunk then serialised to 1234 bytes against Solana's 1232-byte raw packet limit and threw in `solana_types.cpp::serialize` — host-side, before any RPC. `run_outbound` logged it at warn and retried the identical build every 15s, so the SOL outpost never processed the epoch and the depot froze waiting on its consensus. Paired with the same change to MAX_CHUNK_BYTES in wire-solana: `epoch_in` requires every non-final chunk to be exactly that size, so the two constants must move together. Overhead is 562 bytes, so the ceiling is 670; 668 restores the 2-byte margin that existed before `dispatch_limit`. Adds `epoch_in_full_data_chunk_fits_packet_limit`, which serialises a full data chunk and asserts it fits. This replaces the SEC-94 fixture parity test and covers changes to the instruction's ARGUMENT TUPLE, not just its account list — the old fixture's contract was account-list-only, so it would not have caught this. Verified to fail at 672 and pass at 668. Drops the fixture-driven measurement helpers left dead by the SEC-94 fixture's removal, and corrects the chunk-count arithmetic for the new size. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Change-Id: I3d1523d3fcabd2ab7463c6f0c0a0580fd74193a4
heifner
left a comment
There was a problem hiding this comment.
Review of the resumable-dispatch change: 10 findings, ranked by severity. Nine are anchored inline on the diff; one sits in a file this PR does not touch and is included here.
Retry idempotence / fee burn — plugins/batch_operator_plugin/src/outpost_opp_job.cpp:120 (file not in this diff)
Nothing records that chunks were already staged, and _last_outbound_epoch only advances on a clean return (outpost_opp_job.cpp:120-134). Every throw path in the new, much longer delivery (deadline, program abort, decode failure, stale cursor read) leaves it unset, so the cron re-enters deliver_outbound_envelope every 15 s, which unconditionally re-sends all data chunks from offset 0 (outpost_solana_client.cpp:908-923) before reaching the terminal loop. For a wedged epoch that is an unbounded fee burn and RPC load — 4 tx/tick for the dev-026 envelope, 99 tx/tick at the 64 KiB cap — with no backoff, and the re-upload itself eats into the same 15 s budget, making the deadline-throw wedge self-reinforcing. Suggest probing the chunk_buffer PDA (or a local memo) before re-uploading.
| /// exactly this size. 668 keeps a full data chunk under Solana's 1 232 B packet | ||
| /// limit (562 B tx overhead, `dispatch_limit` included). Any change to the | ||
| /// `epoch_in` account list or argument tuple moves that overhead; | ||
| /// `epoch_in_full_data_chunk_fits_packet_limit` is what catches it. |
There was a problem hiding this comment.
Lockstep break: the program on the manifest-pinned wire-solana next has none of this contract.
Checked wire-solana next (local == origin/next, and wire-platform-manifest/default.xml pins next):
programs/liqsol-core/src/states/opp_states.rs:54—MAX_CHUNK_BYTES = 672, andepoch_in.rs:122-134requires non-final chunks to be exactly 672 → every envelope > 668 bytes from this relay is rejected withChunkSizeMismatch(6027); the epoch never assembles and SOL OPP stalls in both directions. Envelopes ≤ 668 bytes are single-chunk and still pass, so this breaks intermittently under load.epoch_in.rs:17/lib.rs:1031-1047— nodispatch_limitarg. anchor 0.31.1 usesBorshDeserialize::deserialize(trailing bytes tolerated), so on a single-chunk terminal call the 4 extra bytes are silently dropped and dispatch runs unbounded — no error, no signal, while the relay believes it throttled.opp_states.rs:704-710—EpochDeliverieshas nodispatched_count; no resumability exists.errors/opp_errors.rstops out at 6063 — noInvalidDispatchLimit/EffectAccountMissing; missing effect accounts still log-and-skip (inbound.rs:78-90,:710-717,:803-825), so on the pinned program a missing account is a silently lost remit with the epoch then closed — the exact failure this PR exists to close.
No commit on any wire-solana ref implements the new contract (git log --all -S"dispatch_limit" is empty). This PR must land/deploy in lock-step with the companion program change, and the two new error codes should be appended at 6064/6065 (the numeric table in opp-outpost-technical-spec.md:72-105 and scripts/opp/patch-idl-errors.js both key on stable numbering).
| // `dispatch_limit` -- the relay is the only party that can size the batch, | ||
| // because it supplies the accounts and therefore knows the transaction's | ||
| // real byte cost. | ||
| uint32_t settled = 0; |
There was a problem hiding this comment.
settled is never seeded from the on-chain cursor — any re-drive or late delivery against a partially-drained cursor wedges the epoch permanently.
read_epoch_dispatch_progress is first called only at line 963, after the first terminal epoch_in has been submitted. So the first call of every invocation packs accounts for [0, batch_end) and claims dispatch_limit = batch_end, while the program settles [dispatched_count, dispatched_count + dispatch_limit).
Concrete wedge: an epoch needs 2 terminal rounds; round 1 settles [0,5), then the 15 s delivery deadline trips at line 932. outpost_opp_job.cpp:120-144 catches, leaves _last_outbound_epoch unset, and retries next tick — which re-enters with settled = 0, packs accounts for [0,5), and sends dispatch_limit = 5; the program dispatches [5,10) from its cursor and aborts with EffectAccountMissing per this PR's own contract. execute_tx_and_confirm throws on a failed tx (libfc solana_client.cpp:1737-1741), so the self-heal read at 963 is never reached, and every retry repeats identically: attestations 5-9 (remits/slashes) never dispatch. Same wedge via a late operator delivering after another operator partially drained (its first call sees cursor K>0), MAX_TERMINAL_ROUNDS exhaustion + the retry_pending redelivery, or a nodeop restart. Note the single-round case is benign ([K,total) ⊂ accounts for [0,total)), so this bites exactly the multi-round case the feature exists for.
Fix: seed settled = read_epoch_dispatch_progress(epoch_index).dispatched_count before the loop, and skip the loop when settled >= total_attestations (still sending one terminal call to record delivery if needed).
| ++batch_end; | ||
| } | ||
| const uint32_t dispatch_limit = batch_end - settled; | ||
| FC_ASSERT(dispatch_limit > 0, |
There was a problem hiding this comment.
Zero-attestation path: on envelope decode failure this asserts before any terminal call — the delivery is never recorded. Regression vs. the base.
count_inbound_attestations returns 0 on ParseFromArray failure (lines 411-416); the packing while then never runs, dispatch_limit = 0, and this FC_ASSERT throws. All data chunks were already staged (line 917), but no terminal epoch_in is ever sent → EpochDeliveries never records this operator → consensus can never tip → the depot row stays PENDING_DELIVERY and the job re-uploads the whole envelope every 15 s forever. The merge base (2b74bed5a0) sent the terminal finalize unconditionally with zero extras and let effects log-and-skip on-chain.
The PR contradicts itself on this path: the extract_inbound_effects wlog (lines 429-435) still says "submitting epoch_in with no extras", and the new test's comment (test_outpost_solana_client_plugin.cpp:880-882) states "the relay submits a terminal call claiming nothing" — behavior the loop no longer performs (the test only asserts on the extractors, so nothing catches it).
A genuinely zero-attestation envelope is not producible by the depot (sysio.msgch.cpp check(included_count > 0, ...)), so decode failure is the live trigger — guard total_attestations == 0 and still send the delivery-recording terminal call.
| to_string(), epoch_index, round, settled, batch_end, total_attestations, | ||
| batch_extra_count, last_sig); | ||
|
|
||
| const auto progress = read_epoch_dispatch_progress(epoch_index); |
There was a problem hiding this comment.
Commitment mismatch: the write confirms at processed, this read defaults to confirmed, no re-poll — a stale cursor read produces false breaks.
execute_tx_and_confirm returns once the tx is processed (libfc solana_client.hpp:45, solana_client.cpp:1707-1718); get_account_info defaults to confirmed (solana_client.hpp:586) and read_epoch_dispatch_progress calls it immediately with no commitment argument and no retry (line 687). When the confirmed bank lags the processed slot, the read returns the pre-tx EpochDeliveries — or nullopt on the epoch's first delivery, i.e. zero-valued progress — so the loop breaks at 969 ("consensus not yet reached" when this very tx reached it) or 979 ("another operator is draining" when this relay advanced the cursor). Result: only the first batch dispatches per invocation and the remainder waits for a re-drive, which then hits the settled = 0 wedge.
read_inbound_envelope already pins its read commitment deliberately (lines 1006-1010); this read should pin processed (or poll until the read catches up to the write) to match.
| // because it supplies the accounts and therefore knows the transaction's | ||
| // real byte cost. | ||
| uint32_t settled = 0; | ||
| for (uint32_t round = 0; round < MAX_TERMINAL_ROUNDS; ++round) { |
There was a problem hiding this comment.
Loop budgets don't fit the feature: the 15 s delivery deadline was sized for one terminal call, and MAX_TERMINAL_ROUNDS = 64 can be exceeded by a legal envelope — with exhaustion returning silently as success.
batch_operator_plugin.cpp:38-39gives the entire delivery (N confirmed chunk txs + 2get_account_infoper unique reserve + M rounds of confirmed tx + read) 15 s — equal to the cron period. Any envelope needing ≥ 3 rounds makes the deadline throw at line 932 the normal exit path, feeding the unseeded-cursor wedge. The deadline was not raised in this PR.- With the depot's 16-account SVM cap deleted,
MAX_ENVELOPE_BYTES = 65'536permits ~500 small SPL swap-remits across distinct reserves (~5 attestations per 16-account round → 100+ rounds). After 64 rounds the loop falls through with no log and no error, returnslast_sigas success, and strands the cursor mid-envelope with nothing scheduled to resume it (_last_outbound_epochadvances, so this operator never re-drives).
At minimum: log/throw on round exhaustion, and size MAX_TERMINAL_ROUNDS + the delivery deadline against the depot-side worst case now that the depot no longer bounds it.
| for (const auto& meta : per_attestation[batch_end]) { | ||
| outpost_solana_client_detail::record_terminal_account(candidate, meta.key, meta.is_writable); | ||
| } | ||
| if (candidate.size() > MAX_TERMINAL_DYNAMIC_ACCOUNTS && batch_end > settled) break; |
There was a problem hiding this comment.
The always-take-one rule's justification is wrong, and the 16-account budget lost its measurement test.
The comment above ("its surplus accounts log-and-skip on-chain") is false twice: (a) an oversized single attestation's accounts are all included — nothing is surplus/omitted; the real failure is transaction::serialize's 1232-byte packet assert (libfc solana_types.cpp:309-313), which throws locally on every attempt — the deterministic packer rebuilds the identical oversized tx each 15 s retry, i.e. exactly the "wedging the epoch" the comment claims is avoided; (b) per this PR's own contract, a genuinely missing account now aborts the terminal call, it does not log-and-skip.
Compounding: this PR deletes the SEC-94 fixture test that measured a terminal epoch_in with 16 dynamic accounts against the packet limit, so MAX_TERMINAL_DYNAMIC_ACCOUNTS = 16 (line 48) is now an unmeasured hand estimate — the budget counts only the extras, never the ~13 IDL-declared accounts or the compute-budget pre-ix. The new epoch_in_full_data_chunk_fits_packet_limit test covers only the data-chunk shape; please add the terminal-shape equivalent (static accounts + 16 extras ≤ 1232), which is the same failure class that test was added to prevent.
| std::move(terminal_accounts)); | ||
| ilog("outpost_solana_client[{}]: epoch_in terminal finalize sent epoch={} chunk={}/{} bytes=0 extras={} sig={}", | ||
| to_string(), epoch_index, total_chunks, total_chunks, terminal_extra_count, last_sig); | ||
| // Terminal calls, looped until the on-chain cursor drains. Each call |
There was a problem hiding this comment.
Design conflicts with the standing outpost-remit-is-immediate rule — the rule/spec must change in the same coordinated landing.
wire-platform-manifest/.claude/rules/outpost-remit-is-immediate.md: "every remit-class attestation in that envelope MUST trigger its on-chain transfer inline, in the same tx that processes the envelope. There is no concept of 'pending remit,' 'queued release,' 'delayed payout,' or any kind of waiting state on the outpost... the dispatch loop IS the release."
The resumable cursor is precisely a queued release: after the consensus-tipping tx, undispatched remits sit in a pending state until later terminal calls that may never come (see the unseeded-cursor and round-exhaustion findings on this file). If the cursor design is the intended direction — and the depot-side rationale in buildenv reads that way — the rule doc and wire-solana's normative opp-outpost-technical-spec.md (lines 150, 838-842, 882, and 915, which still codifies log-and-skip, 672-byte chunks, and the 5-arg epoch_in) need updating in the same landing, or every diagnosis playbook built on them will misread production.
| "queueout: caller not authorized to queue outbound attestations"); | ||
|
|
||
| { | ||
| sysio::chains::chains_t chains_tbl(CHAINS_ACCOUNT); |
There was a problem hiding this comment.
queueout lost its only chain_code registration check — an authorized caller's typo now creates a permanent, unreachable READY row.
All 8 production call sites derive their code from sysio.chains (epoch:686,729; opreg:634,673,745; uwrit:349,561; reserv:153), so this is not an attack vector — but it is an ops footgun: a governance msig holding one of the authorized authorities pushing queueout directly with an unregistered code used to abort with "chain_code is not registered"; now it succeeds silently. epoch::advance only fans buildenv to registered active outposts, the PROCESSED-drain (lines 1853-1860) skips mismatched chain codes, and no prune action exists — the row is permanent sysio-pool RAM, re-walked by the Phase-1 bystatus scan on every buildenv for every outpost, every epoch.
Related cleanup if the removal stands: sysio.reserv.cpp:93-95 ("sysio.msgch::queueout fails loudly for direct callers"), sysio.msgch_tests.cpp:164-170, and sysio.reserv_tests.cpp:411-414 all still describe the deleted guard — a future reader will conclude msgch is the backstop and relax the reserv-side guards. Also buildenv's chains fetch (new lines 1648-1651) now asserts with the bare default "key not found" — worth passing the message overload.
| /// would claim attestations whose accounts are absent -- and a handler with a | ||
| /// missing account log-and-skips (a SUCCESSFUL no-op) while the cursor | ||
| /// advances past it, silently dropping a remit or a slash with no retry path. | ||
| BOOST_AUTO_TEST_CASE(extract_effects_indices_track_dispatch_order) try { |
There was a problem hiding this comment.
The resumable-dispatch state machine itself ships untested, and the five whole-envelope extractors are now dead public surface.
The new tests cover extract_inbound_effects / count_inbound_attestations only. Nothing drives the packing loop, dispatch_limit sizing, cursor resume, the consensus_reached/stall breaks, or the 16-account budget — the two confirmed bugs in that loop (the zero-attestation FC_ASSERT and the unseeded cursor) both live in the untested region and both contradict comments in the same files, which is why they survived.
Separately: extract_inbound_recipient_pubkeys, extract_inbound_swap_remit_spl_targets, extract_inbound_swap_revert_spl_targets, extract_inbound_swap_remit_reserve_seeds, and extract_inbound_reserve_create_cancelled_seeds now have zero production callers (deliver_outbound_envelope uses extract_inbound_effects directly) but remain exported from the public header purely for tests — and the comments at outpost_solana_client.cpp:525/539 mislabel the two SPL-target views as "deduplicating", which they are not. Either test the loop through a seam and delete/relocate the views, or the dead surface plus wrong docs will drift.
Important
Must merge together with https://github.com/Wire-Network/wire-solana/pull/419.
SOLANA_MAX_CHUNK_BYTEShere andMAX_CHUNK_BYTESthere are one coupled protocolconstant:
epoch_inrequires every non-final chunk to be exactly that size, somerging either side alone breaks multi-chunk envelopes with
ChunkSizeMismatch.The relay also cannot send
dispatch_limituntil the program accepts it.Relay side of resumable OPP dispatch. Pairs with Wire-Network/wire-solana#419 —
they must land together.
What changes
buildenv's packing gateand
queueout's admission check were capping what could ever be sent to Solana, basedon a depot-side estimate of the outpost's account budget. Chunking plus a per-call
dispatch limit replaces it, so the depot no longer needs to model the outpost's
transaction shape.
epoch_incarriesdispatch_limit, and the terminal call loops until the on-chaindispatched_countcursor drains. Each round greedily packs attestations while theiraccount union still fits, always taking at least one so an oversized effect makes
progress rather than wedging the epoch. Three exits: cursor drained, consensus not yet
reached, or the cursor failed to advance (another operator is draining it).
extract_inbound_effects()is now the authoritative indexed walk of the envelope;the five per-effect extractors became dedup views over it, so per-attestation account
manifests stay aligned with attestation indices.
Packet budget
SOLANA_MAX_CHUNK_BYTESdrops 672 -> 668.dispatch_limitadds 4 bytes to everyepoch_incall including data chunks, where it is ignored — that pushed a full chunk from1230 to 1234 bytes against Solana's 1232-byte limit, so
solana_types.cpp::serializethrew host-side before any RPC. Coupled by protocol to
MAX_CHUNK_BYTESin wire-solana:non-final chunks must be exactly this size so offsets line up at
chunk_index * MAX_CHUNK_BYTES. Overhead is 562 bytes, ceiling 670; 668 restores the2-byte margin that existed before
dispatch_limit.Adds
epoch_in_full_data_chunk_fits_packet_limit, which serializes a full data chunk andasserts it fits. This replaces the SEC-94 fixture parity test and covers changes to the
instruction's argument tuple, not just its account list — the old fixture's contract
was account-list-only, so it would not have caught this. Verified to fail at 672 and pass
at 668.
Verification
test_outpost_solana_client_plugin— No errors detected(built against wire-solana
fc061a46+ wire-sysio00e456ee; every other repo'srevision identical to the prior run, so the result is attributable to this change)