fix(harness): seed the Solana outpost roster via opp_bootstrap (SOL-376/WNS-139) - #73
Merged
Merged
Conversation
valthon
marked this pull request as ready for review
August 15, 2026 12:24
valthon
force-pushed
the
fix/SOL-376-381-opp-bootstrap-auth
branch
from
August 19, 2026 23:00
4aeacb4 to
ebeb652
Compare
valthon
force-pushed
the
fix/SOL-376-381-opp-bootstrap-auth
branch
2 times, most recently
from
August 20, 2026 19:28
3250e9e to
917f42e
Compare
valthon
force-pushed
the
fix/SOL-376-381-opp-bootstrap-auth
branch
from
August 20, 2026 20:33
917f42e to
664ea5b
Compare
valthon
force-pushed
the
fix/SOL-376-381-opp-bootstrap-auth
branch
from
August 21, 2026 00:17
664ea5b to
25904d1
Compare
valthon
force-pushed
the
fix/SOL-376-381-opp-bootstrap-auth
branch
from
August 24, 2026 15:16
655a3e2 to
2c37303
Compare
Contributor
Author
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
force-pushed
the
fix/SOL-376-381-opp-bootstrap-auth
branch
from
August 25, 2026 16:17
2c37303 to
457a921
Compare
jglanz
approved these changes
Aug 25, 2026
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.
Caution
Merge gate — this PR MUST NOT merge before wire-solana #408
This PR adds an unconditional hard dependency on the
opp_bootstrapinstruction. Thatinstruction exists only on wire-solana
fix/SOL-376-381-opp-bootstrap-auth; it is absent fromorigin/next, which is wire-solana's manifest revision (default.xml:58).Merged in the wrong order, every local
wire-cluster-tool createand every flow test fails atthe 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
GlobalConfigadmin, through a newopp_bootstrapinstruction — andepoch_inrefuses to finalize until that has run. Without the seed the SOL outpost returnsRegistryNotInitialized, 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, 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).
first
BatchOperatorGroupsattestation replaces the roster wholesale under consensus.Where it sits
SolanaOutpostSteps.planOppBootstrapis a new input-less Step in theEpochBootstrapphase,sequenced AFTER the depot's
schbatchgpsmaterializes the epoch-1 group and BEFORE the firstenvelope 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
schbatchgpsthe window is empty, and theread is only valid pre-advance. It holds because
sysio.epoch::advanceskips the window pop on thegenesis 0→1 advance (
had_expiring_group = current_epoch_index > 0), sobatch_op_groups[current_batch_op_group]is still the groupschbatchgpsplaced 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:
opp_bootstrapnow asserts that limit with named constants and anoppBootstrapEncodedBytes(...)helper rather than scattered magic numbers. Previously anoversized group produced an opaque
RangeError: encoding overruns Buffer— reachable viaexplicit flags (
--operators-per-epoch 13), since the program's ownMAX_GROUP_MEMBERSis 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.
resolveOppBootstrapSeedtherefore trims the depot's epoch-1 group toSolanaOutpostSteps.MaxOppBootstrapOperators(5) before building the seed. The trim is loggedthrough 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 deliveringset, so epoch 1 stays reachable. Members left out of the seed bounce with
OperatorNotActiveuntil the first envelope's
BatchOperatorGroupsattestation installs the authoritative rosterunder 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 therequired 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:
Programconstruction site repo-wide.SolanaOutpostProgramTool.loadProgramis now the single factory;
SolanaCollateralTool.loadOppOutpostProgramis actx-shapedadapter over it, and the bootstrapper delegates to it too.
resolveProgramIdwas deleted in favour of theexisting
SolanaOutpostProgramTool.programId/assertProgramId, andbootstrap()'slong-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.programKeypairFilewas dropped from the bootstrapper options: nothing set it, and thepath-based helpers ignore it, so a settable-but-ignored option would be a lie.
EpochContractStepsgains a── Reads ──section exportingreadEpochState/batchOperatorGroups/activeBatchOperatorGroup, consumed here and byflow-batch-operator-slashingandflow-batch-operator-termination. That also retires asecond indexing convention — the slashing flow read a hardcoded
batch_op_groups[0]where itsown JSDoc said "the ACTIVE group" (inert today: that flow pins
batchOpGroups = 1).reachable from unit tests for the first time.
key" no longer share one misleading message.
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_OWNERinstead of re-spelling them.Formatting findings are deliberately not here —
printWidthand the repo-wide prettierdrift are handled separately so this diff stays semantic.
Verification
Run at the PR head in a worktree under the platform root, with the
@wireiosibling linksverified against
../wire-libraries-ts/packages/sdk-coreand../wire-sysio/build/opp/typescriptfirst — outside that layout pnpm silently resolves published registry versions and every gate goes
green against the wrong dependency graph.
pnpm build(tsc -b)pnpm run lint(eslint .)jest(8 projects)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:
Collapse duplicate Anchor program constructionRead the active batch-operator group onceSeed the Solana outpost roster via opp_bootstrapThe counts grow because tests arrive with the chapter they cover. The new coverage is the hoisted
argument preconditions (reachable for the first time), the
loadProgramfactory, the threeEpochContractStepsread accessors, the split assert causes, the seed cap, and a newClusterBuildDefaultsEpochBootstrapsuite pinning the load-bearing step ORDERING(
schbatchgps→opp_bootstrap→msgch::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 severablereviewing decision. Fixups on this branch's own work no longer appear as commits — they were
folded into the chapter they belong to.
a99d1fc1817a3939457a921aopp_bootstrapEvery commit builds, lints, and tests green standalone (table above).
The one intended semantic delta in the refactor: the slashing flow's
disputeOperatorsOwnGroupread a hardcodedbatch_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_groupis written in exactly two places (schbatchgpsandadvance) and both pin it to 0, because the window rotates rather than the cursor — but it isthe 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:432and:443). All three readcurrent_epoch_indexrather than the group, all three are pre-existing, andClusterManager.tsis 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:
printWidthand the repo-wide prettier drift arehandled in #79, so this diff stays semantic.