Skip to content

OPP: bound the outbound attestation queue and the operator roster; floor desyndicate - #653

Closed
heifner wants to merge 3 commits into
masterfrom
fix/wns-56-bounded-outbound-attestations
Closed

heifner wants to merge 3 commits into
masterfrom
fix/wns-56-bounded-outbound-attestations

Conversation

@heifner

@heifner heifner commented Sep 28, 2026

Copy link
Copy Markdown
Contributor

Bounds the depot's outbound attestation queue and the operator roster that feeds it, and puts a floor under sysio.liq::desyndicate. These come from the CertiK Sysio Audit 1 preliminary report.

Oversized attestations no longer halt epochs

buildenv runs inline in sysio.epoch::advance. It aborted when a READY attestation was too large for any envelope, so one such row halted epoch advancement chain-wide.

It now drops a row that is larger than its lane can ever ship, prints a DROP attestation diagnostic, and ships the rows behind it. If the drops empty the queue, the epoch's envelope still goes out, because each outpost answers the depot's envelope with its own.

Bounded walk, operator schedule first

The READY scan walked every outpost's rows every epoch, and desyndicate let any holder grow the queue.

A new byqueue index on (status, chain, lane) replaces bystatus. buildenv walks one outpost's queue and stops at the first row that does not fit, so each call reads about one envelope's worth of rows plus any it drops. The unread bytype and byepoch indexes are removed.

The operator schedule (OPERATORS and BATCH_OPERATOR_GROUPS) now packs ahead of other traffic, and each lane ships oldest first. Solana seats the next epoch's batch-operator group only from BATCH_OPERATOR_GROUPS, and epoch_in admits only that group. In strict queue order, a backlog queued before advance could push BATCH_OPERATOR_GROUPS out of the envelope and deadlock the bridge.

The schedule gets a 24 KiB budget, equal to Solana's OPERATORS byte cap. Other rows are bounded by what fits beside a full schedule, 7,656 bytes, so every row the walk keeps eventually ships. Every current non-schedule payload is under about 1 KiB.

Roster: ACTIVE and SLASHED operators only, withheld over budget

The OPERATORS roster listed every sysio.opreg row, and regoperator is permissionless, so anyone could grow it. It now lists ACTIVE and SLASHED operators only.

  • Omitted operators. Ethereum treats an omitted operator as stale by roster generation, and Solana removes it from its registry.
  • Why SLASHED stays. Solana resolves a SLASH's target through its registry, and deferred slashes can still arrive after the roster that would drop the entry.
  • Over budget. A roster that still takes the schedule past the budget is withheld with a diagnostic, like the existing empty-window withhold, and the outposts keep their previous roster.

Desyndicate floor

desyndicate accepted a single subunit, and each call bought a sysio-billed outbound attestation and a relayer-paid payout.

Each shadow token now has a minimum: by default one hundredth of a whole token, and at least one subunit. The contract's own authority can change it with the new setmindesyn action. precision_from_decimals moves from sysio.swap to sysio.opp.common/decimal.hpp, so both contracts share it.

Notes

  • CertiK WNS-56 / WIRE-406, and the desyndicate queue growth, WIRE-415.
  • ABI changes:
    • In msgch, the attestations indexes bystatus, bytype and byepoch become a single byqueue (uint128).
    • liq gains currency_stats.min_desyndicate and setmindesyn.
  • Rebuilt: sysio.msgch and sysio.liq (wasm and abi), and sysio.epoch (wasm only).
  • For the Solana outpost owners: the roster filter changes two outpost behaviours.
    • A TERMINATED or demoted (UNKNOWN) member still in the batch window is now absent from the roster, so Solana seats the rest of the group instead of refusing the window.
      • The refusal was a chain-wide OPP halt: only BATCH_OPERATOR_GROUPS moves Solana's window, and the depot waits on every outpost.
      • The seated subset still needs a strict majority of its own count, but that margin shrinks with each absent member.
    • Operators that are not yet ACTIVE are no longer in Solana's registry, so their bonds lose the deposit priority lane and compete in the low-priority buffer. Listing them would reopen the cheap roster inflation above, so a pre-active bond needs another basis for priority.
  • Follow-ups:
    • SLASHED rows never leave sysio.opreg, and Solana skips any roster over its 128-entry MAX_OPERATORS. At nominal operator counts, about two dozen slashes would freeze Solana's roster. If Solana could settle a SLASH by op_address, the depot could drop settled SLASHED rows.
    • Separately, the depot keeps a slashed operator in its batch window, and Solana refuses a window that names one.
  • Draft WIRE-342: send the OPERATORS roster only when it changes #568 overlap: this PR supersedes its roster filter and single-row guard. Its send-on-change digest and its max_available_* enforcement remain follow-ups.
  • wire-libraries-ts: SysioContractTypes.ts needs regenerating for the liq changes.

Moves sysio.swap's 10^decimals helper, unchanged, to
sysio.opp.common/decimal.hpp so sysio.liq can size its desyndicate floor
with the same definition. sysio.swap's wasm and abi are unchanged.
buildenv runs inline in sysio.epoch::advance. A READY attestation too
large for any envelope made it abort, so one oversized row halted epoch
advancement chain-wide, and its READY scan walked every outpost's rows
each epoch with no bound, while sysio.liq::desyndicate lets any holder
add rows.

It now walks one outpost's queue through a (status, chain, lane) index
and stops at the first row that does not fit, so a call reads about one
envelope's worth of rows plus any it drops. The operator schedule
(OPERATORS and BATCH_OPERATOR_GROUPS) packs ahead of other traffic, each
lane oldest first: an outpost seats the next epoch's batch-operator
group only from BATCH_OPERATOR_GROUPS, and in strict queue order a
backlog queued before advance pushed it out of the envelope, so Solana
refused the next epoch's deliverers.

The schedule gets a fixed budget of 24 KiB, Solana's OPERATORS byte
cap. A row larger than its lane can ever ship is dropped with a
diagnostic: for the schedule, whatever an empty envelope cannot hold;
for other traffic, whatever cannot fit beside a full schedule (7656
bytes). Without that bound a kept row could sit at the front of its lane
forever and block every row behind it. An emptied queue still emits the
epoch's envelope, since each outpost answers the depot's envelope with
its own. The attestations table's unread bytype and byepoch indexes go.

advance keeps the schedule within that budget. The OPERATORS roster
listed every sysio.opreg row, and regoperator is permissionless, so
unbonded registrations grew it without bound; it now lists ACTIVE and
SLASHED operators only. Both outposts treat an omitted operator as not
active, and SLASHED stays because Solana resolves a slash's target
through its registry. A roster that would still take the schedule past
the budget is withheld with a diagnostic, the way an empty batch window
already is: only the queueout is skipped, advance continues, and the
outposts keep their previous roster.
desyndicate accepted a single subunit, so each call bought a
sysio-billed outbound attestation and a relayer-paid payout on the
outpost. Each shadow token now carries a minimum, by default one
hundredth of a whole token and at least one subunit, which the
contract's own authority can change with setmindesyn.
@heifner
heifner requested a review from a team September 28, 2026 18:48

@huangminghuang huangminghuang left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fresh-deployment review findings; migration and backward-compatibility concerns intentionally excluded.

if (ship_operators) {
queue_to_active_outposts(opp::types::ATTESTATION_TYPE_OPERATORS, encoded_operators);
}
if (have_next_group) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ship_operators is false when the combined schedule exceeds the budget, but this branch still publishes BATCH_OPERATOR_GROUPS. The new window is therefore resolved against the previous roster. Ethereum stores unresolved names as address(0) seats that still count toward the quorum denominator, while Solana drops them; enough newly active or changed operators can make the next epoch impossible to deliver, and the correcting roster is then trapped inside an envelope no seated group can deliver. Publish the roster and dependent window atomically, or prove the window is resolvable against the last delivered roster.

auto queue = atts.get_index<"byqueue"_n>();
for (auto it = queue.lower_bound(first); it != queue.end() && it->by_queue() <= last;) {
const queue_lane lane = lane_of(it->type);
if (it->data.size() > max_attestation_data_bytes(lane)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

queueout accepts any payload size, but this branch permanently erases non-schedule payloads above 7,656 bytes. createuwreq can reach this with current code: its size-cap rejection copies the oversized inbound SwapRequest.actor into SwapRevert.depositor, queues that refund, and buildenv then drops it, leaving source-outpost escrow without the refund. Ensure a never-throw producer emits a bounded refund or persists a recoverable failure before enqueueing an undeliverable row.

{
const auto [first, last] = queue_span(AttestationStatus::ATTESTATION_STATUS_READY, chain_code);
auto queue = atts.get_index<"byqueue"_n>();
for (auto it = queue.lower_bound(first); it != queue.end() && it->by_queue() <= last;) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This loop has no scan or drop budget: oversized rows are erased without increasing estimated_bytes, so it can traverse an arbitrary run of them inside inline buildenv. If it exhausts transaction CPU, rollback restores every deletion and the next epoch retries the same run forever. Cap drops per invocation or move cleanup to a bounded, paginated action.

@heifner

heifner commented Oct 22, 2026

Copy link
Copy Markdown
Contributor Author

Superseded: the roster bound is #692, the queue bound is #696, and the desyndicate floor is on master as sysio.synd's min_desyndicate.

@heifner heifner closed this Oct 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants