Skip to content

fix(harness): seed the Solana outpost roster via opp_bootstrap (SOL-376/WNS-139) - #73

Merged
valthon merged 3 commits into
masterfrom
fix/SOL-376-381-opp-bootstrap-auth
Aug 25, 2026
Merged

fix(harness): seed the Solana outpost roster via opp_bootstrap (SOL-376/WNS-139)#73
valthon merged 3 commits into
masterfrom
fix/SOL-376-381-opp-bootstrap-auth

Conversation

@valthon

@valthon valthon commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Caution

Merge gate — this PR MUST NOT merge before wire-solana #408

This PR adds an unconditional hard dependency on the opp_bootstrap instruction. That
instruction exists only on wire-solana fix/SOL-376-381-opp-bootstrap-auth; it is absent from
origin/next
, which is wire-solana's manifest revision (default.xml:58).

Merged in the wrong order, every local wire-cluster-tool create and every flow test fails at
the new step. The harness now also fails legibly in that case (see "IDL feature check" below)
rather than with a bare TypeError, but the ordering constraint is real regardless.

Land wire-solana #408 first.

Seed the Solana outpost roster via opp_bootstrap (SOL-376 / WNS-139)

wire-solana#408 deletes the outpost's pre-roster consensus window (the WNS-139 finding: the first
arbitrary signer could finalize epoch 1 and install the root operator registry). The roster is now
installed once, by the GlobalConfig admin, through a new opp_bootstrap instruction — and
epoch_in refuses to finalize until that has run. Without the seed the SOL outpost returns
RegistryNotInitialized, the epoch stalls, and every OPP flow dies in cluster bootstrap.

This PR wires that call into the harness bootstrap.

What gets seeded

The roster IS the group — exactly the depot's epoch-1 batch-operator group, not the full
provisioned topology. Two properties are load-bearing:

  • Size drives consensus. The outpost's count-based threshold derives from the seeded group's
    size, so the seed never EXCEEDS the depot's delivering set — seeding more signers than actually
    deliver would stall epoch 1. A capped subset is safe in the other direction: it only lowers the
    threshold (see "Encoding limit" below).
  • The seed is minimal and transient. The program only needs one signable group; the depot's
    first BatchOperatorGroups attestation replaces the roster wholesale under consensus.

Where it sits

SolanaOutpostSteps.planOppBootstrap is a new input-less Step in the EpochBootstrap phase,
sequenced AFTER the depot's schbatchgps materializes the epoch-1 group and BEFORE the first
envelope is delivered. Local outposts only — external outposts are seeded by their own operators,
out of band.

The ordering is load-bearing in both directions: before schbatchgps the window is empty, and the
read is only valid pre-advance. It holds because sysio.epoch::advance skips the window pop on the
genesis 0→1 advance (had_expiring_group = current_epoch_index > 0), so
batch_op_groups[current_batch_op_group] is still the group schbatchgps placed for epoch 1.

Encoding limit — the constraint is GROUP size

Anchor's instruction coder borsh-encodes into a fixed 1000-byte buffer. Because the roster is
the group, both the roster vector (48 B/entry) and the group-member vector (32 B/entry) scale
together, so the ceiling is on group size, not on topology size:

floor((1000 − 20 fixed) / (48 + 32)) = 12 group members

opp_bootstrap now asserts that limit with named constants and an
oppBootstrapEncodedBytes(...) helper rather than scattered magic numbers. Previously an
oversized group produced an opaque RangeError: encoding overruns Buffer — reachable via
explicit flags (--operators-per-epoch 13), since the program's own MAX_GROUP_MEMBERS is 16.
Derived topologies cap group size at 7, so defaults were never affected.

The harness caps the seed below both ceilings. The 1000-byte encode buffer is not the tightest
constraint: a legacy transaction packet is 1232 bytes and this instruction ships ~270 bytes of
envelope, which binds the group at roughly 11 — under the buffer's 12.
resolveOppBootstrapSeed therefore trims the depot's epoch-1 group to
SolanaOutpostSteps.MaxOppBootstrapOperators (5) before building the seed. The trim is logged
through the per-file logger rather than applied silently, and it happens BEFORE operator
resolution, so an account past the cap that is unprovisioned or SOL-keyless cannot fail a seed it
is not part of.

The cap is safe in the direction that matters. Trimming only ever LOWERS the outpost's seeded
consensus threshold (ceil(n / 2)) — it can never seed more signers than the depot's delivering
set, so epoch 1 stays reachable. Members left out of the seed bounce with OperatorNotActive
until the first envelope's BatchOperatorGroups attestation installs the authoritative roster
under consensus: the self-healing registry-sync lag the heartbeat monitor already classifies as
NOISE.

IDL feature check

The step asserts the loaded IDL actually carries opp_bootstrap, naming the IDL path and the
required wire-solana branch. Against a stale program this now fails with a diagnosis instead of
TypeError: program.methods.oppBootstrap is not a function.

Review fixes folded in

This PR was reviewed and every finding is addressed here. The substantive ones removed
duplication that predates and surrounds the feature:

  • One Anchor Program construction site repo-wide. SolanaOutpostProgramTool.loadProgram
    is now the single factory; SolanaCollateralTool.loadOppOutpostProgram is a ctx-shaped
    adapter over it, and the bootstrapper delegates to it too.
  • One program-id read. The new private resolveProgramId was deleted in favour of the
    existing SolanaOutpostProgramTool.programId / assertProgramId, and bootstrap()'s
    long-standing inline copy was collapsed onto the same helper — 3 copies to 1. This also
    recovers assertProgramId's build-remediation hint, which the hand-rolled assert had dropped.
    programKeypairFile was dropped from the bootstrapper options: nothing set it, and the
    path-based helpers ignore it, so a settable-but-ignored option would be a lie.
  • One epoch-state unwrap. EpochContractSteps gains a ── Reads ── section exporting
    readEpochState / batchOperatorGroups / activeBatchOperatorGroup, consumed here and by
    flow-batch-operator-slashing and flow-batch-operator-termination. That also retires a
    second indexing convention — the slashing flow read a hardcoded batch_op_groups[0] where its
    own JSDoc said "the ACTIVE group" (inert today: that flow pins batchOpGroups = 1).
  • Preconditions hoisted above the filesystem read, per STYLE.md — which also makes them
    reachable from unit tests for the first time.
  • The two-cause assert split, so "group member not provisioned" and "operator has no Solana
    key" no longer share one misleading message.
  • Test fixtures consolidated. Two more surviving operator fixtures folded into the shared
    one; its Ethereum/Solana derivation is now lazy, so callers that need only the harness-side
    identity pay nothing. The fixture imports AnvilMnemonic / DerivationPath /
    Constants.BOOTSTRAP_NODE_OWNER instead of re-spelling them.

Formatting findings are deliberately not here — printWidth and the repo-wide prettier
drift are handled separately so this diff stays semantic.

Verification

Run at the PR head in a worktree under the platform root, with the @wireio sibling links
verified against ../wire-libraries-ts/packages/sdk-core and ../wire-sysio/build/opp/typescript
first — outside that layout pnpm silently resolves published registry versions and every gate goes
green against the wrong dependency graph.

Gate Result
pnpm build (tsc -b) exit 0
pnpm run lint (eslint .) exit 0
jest (8 projects) 226 suites / 1978 tests passing

Every commit was additionally verified on its own, by clean detached checkout, judged by exit
code — so the history is bisect-safe in fact, not just in intent:

Commit Suites Tests
Collapse duplicate Anchor program construction 225 1958
Read the active batch-operator group once 225 1962
Seed the Solana outpost roster via opp_bootstrap 226 1978

The counts grow because tests arrive with the chapter they cover. The new coverage is the hoisted
argument preconditions (reachable for the first time), the loadProgram factory, the three
EpochContractSteps read accessors, the split assert causes, the seed cap, and a new
ClusterBuildDefaultsEpochBootstrap suite pinning the load-bearing step ORDERING
(schbatchgpsopp_bootstrapmsgch::planBootstrap) rather than mere presence.

Full e2e flow matrix was green 14/14 on the pre-review revision of this branch:
run 31865120614.
The review fixes since are refactors and assertions under unit test; a re-run against the final
revision is worth doing once wire-solana #408 lands, which the merge gate requires anyway.

The three commits

Rebased onto master (v0.1.18) and the history rewritten so each commit is one severable
reviewing decision. Fixups on this branch's own work no longer appear as commits — they were
folded into the chapter they belong to.

Commit What Review weight
a99d1fc1 Collapse duplicate Anchor program construction Pure refactor of pre-existing duplication — three program-id copies to one. Accept without reading the feature.
817a3939 Read the active batch-operator group once Separate refactor across two flow packages, carrying one intended, inert cursor-index fix (below).
457a921a Seed the Solana outpost roster via opp_bootstrap The actual change — the seed, its 5-operator cap, the IDL feature check, and the fixture consolidation its tests use.

Every commit builds, lints, and tests green standalone (table above).

The one intended semantic delta in the refactor: the slashing flow's
disputeOperatorsOwnGroup read a hardcoded batch_op_groups[0] while its own JSDoc said
"the ACTIVE group", and now reads the cursor-indexed group. That is inert on the current depot
contract — current_batch_op_group is written in exactly two places (schbatchgps and
advance) and both pin it to 0, because the window rotates rather than the cursor — but it is
the form that survives if rotation ever becomes cursor-based.

Known follow-up, deliberately not in this PR

Three const { rows } = await ctx.wire.getEpochState() sites remain outside the new accessors
(ClusterBuildDefaults.ts:1142, ClusterManager.ts:432 and :443). All three read
current_epoch_index rather than the group, all three are pre-existing, and ClusterManager.ts
is a file this PR otherwise never touches. Migrating them is a clean follow-up; pulling an
unrelated file into this diff would make it less reviewable, not more.

Formatting is also deliberately absent: printWidth and the repo-wide prettier drift are
handled in #79, so this diff stays semantic.

@valthon valthon changed the title fix/SOL 376 381 opp bootstrap auth fix(harness): seed the Solana outpost roster via opp_bootstrap (SOL-376/WNS-139) Aug 15, 2026
@valthon
valthon marked this pull request as ready for review August 15, 2026 12:24
@valthon
valthon force-pushed the fix/SOL-376-381-opp-bootstrap-auth branch from 4aeacb4 to ebeb652 Compare August 19, 2026 23:00
@valthon
valthon force-pushed the fix/SOL-376-381-opp-bootstrap-auth branch 2 times, most recently from 3250e9e to 917f42e Compare August 20, 2026 19:28
Base automatically changed from fix/SOL-375-379-380-opp-collateral to master August 20, 2026 20:33
@valthon
valthon force-pushed the fix/SOL-376-381-opp-bootstrap-auth branch from 917f42e to 664ea5b Compare August 20, 2026 20:33
@valthon
valthon force-pushed the fix/SOL-376-381-opp-bootstrap-auth branch from 664ea5b to 25904d1 Compare August 21, 2026 00:17
@valthon
valthon requested a review from jglanz August 21, 2026 05:38
@valthon
valthon force-pushed the fix/SOL-376-381-opp-bootstrap-auth branch from 655a3e2 to 2c37303 Compare August 24, 2026 15:16
@valthon

valthon commented Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

e2e tests clean

valthon and others added 3 commits August 25, 2026 11:15
Three call sites built their own Anchor `Program` and three more read the
outpost program id straight off the keypair file, so a change to either
had to land in every copy. The hand-rolled program-id reads had already
drifted: they dropped the build-remediation hint that `assertProgramId`
attaches to its failure, leaving a bare "not found" where the tool would
have said how to fix it.

`SolanaOutpostProgramTool.loadProgram` is now the single Program factory,
and `programId` / `assertProgramId` the single program-id read.
`SolanaCollateralTool.loadOppOutpostProgram` becomes a `ctx`-shaped
adapter over the factory, and the bootstrapper's long-standing inline
copy collapses onto the same helper -- three copies to one.

`programKeypairFile` leaves `SolanaOutpostBootstrapperOptions`: nothing
set it and the path-based helpers ignore it, so a settable-but-ignored
option was a lie about what the bootstrapper honours. The keypair path is
derived from `solanaPath` at the one site that reports it.

The PDA-initialization path deliberately keeps its own existence check.
A missing IDL is not fatal there -- a program that was never built has
nothing to initialize against -- while every other caller wants the
throwing contract of `loadProgram`. That asymmetry is now stated in a
comment instead of being implied by a duplicated provider construction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four sites unwrapped `sysio.epoch::epochstate` by hand to reach the
batch-operator groups, each re-deriving the same `rows[0].value` shape.
Worse, they disagreed about which group is current: the slashing flow
indexed `batch_op_groups[0]` while its own JSDoc said "the ACTIVE group",
so the tree carried two indexing conventions for one concept.

`EpochContractSteps` gains a `-- Reads --` section exporting
`readEpochState`, `batchOperatorGroups` and `activeBatchOperatorGroup`.
The slashing and termination flows consume those instead of re-reading
the singleton, which also retires the now-unused termination constant.

Reading the cursor-indexed group is a deliberate semantic change, and it
is inert today: `current_batch_op_group` is written in exactly two places
(`schbatchgps` and `advance`) and both pin it to 0, because the schedule
window rotates rather than the cursor. It is the form that survives if
rotation ever becomes cursor-based, and it leaves one convention in the
tree instead of two.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
wire-solana#408 deletes the outpost's pre-roster consensus window, which
let the first arbitrary signer finalize epoch 1 and install the root
operator registry (WNS-139). The roster is now installed once, by the
GlobalConfig admin, through a new `opp_bootstrap` instruction, and
`epoch_in` refuses to finalize until it has run. The depot only relays
envelopes and never seeded the outpost, so without this the SOL outpost
returns `RegistryNotInitialized`, the epoch stalls, and every OPP flow
dies in cluster bootstrap.

`Steps.solanaOutpost.planOppBootstrap` is a new input-less Step in the
EpochBootstrap phase, sequenced AFTER `schbatchgps` materializes the
epoch-1 batch-operator group and BEFORE the first envelope is delivered.
Local outposts only -- external outposts are seeded by their own
operators, out of band. Reading the group before the 0->1 advance is
correct: `advance` skips the window pop on the genesis advance
(`had_expiring_group = current_epoch_index > 0`), so the slot still holds
the group `schbatchgps` placed for epoch 1.

The roster IS the group, and the seed is capped. The instruction carries
both vectors in ONE transaction, so every member costs a 48-byte roster
entry AND a 32-byte pubkey: Anchor's fixed 1000-byte encode buffer admits
12, and the 1232-byte transaction packet -- the tighter limit -- roughly
11. `resolveOppBootstrapSeed` therefore trims the depot's group to
`MaxOppBootstrapOperators` (5), logging the trim rather than applying it
silently, and trims BEFORE resolving operators so an account past the cap
that is unprovisioned or SOL-keyless cannot fail a seed it is not part
of. Trimming only ever LOWERS the outpost's seeded consensus threshold
(`ceil(n / 2)`) and can never seed more signers than the depot's
delivering set, so epoch 1 stays reachable. Members left out bounce with
`OperatorNotActive` until the first `BatchOperatorGroups` attestation
installs the authoritative roster under consensus -- the self-healing
registry-sync lag the heartbeat monitor already classifies as NOISE.

The step also asserts the loaded IDL actually carries `opp_bootstrap`,
naming the IDL path and the required wire-solana branch, so a stale
program fails with a diagnosis instead of a bare
`program.methods.oppBootstrap is not a function`.

Test-side, operator fixtures duplicated across four suites collapse into
the shared `operatorAccountFixture`, whose Ethereum/Solana derivation is
lazy so callers needing only the harness-side identity pay nothing for
it. A new `ClusterBuildDefaultsEpochBootstrap` suite pins the
load-bearing step ORDERING (`schbatchgps` -> `opp_bootstrap` ->
`msgch::planBootstrap`) rather than mere presence.

Note for landing: this adds an unconditional dependency on
`opp_bootstrap`, which exists only on wire-solana's
fix/SOL-376-381-opp-bootstrap-auth branch and NOT on its manifest
revision `next`. wire-solana#408 must merge first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@valthon
valthon force-pushed the fix/SOL-376-381-opp-bootstrap-auth branch from 2c37303 to 457a921 Compare August 25, 2026 16:17
@valthon
valthon merged commit c23b1cf into master Aug 25, 2026
2 checks passed
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